# Page

> Build and publish a self-contained HTML page to Superset, then answer the comments readers pin to it. Use when the user asks to make or publish a page, turn a report, dashboard, chart, doc, or analysis into a shareable link, update or re-version a page already published, or work through comments left on one, including "make me a page for this", "publish this as a page", "share it as a link", "add a version", "address the comments on that page".

- Skill: `superset-sh/page-2` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add superset-sh/page-2`
- Raw SKILL.md: https://api.skillmd.com/api/skills/superset-sh/page-2/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: superset-sh (https://skillmd.com/u/superset-sh)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/superset-sh/page-2

---


# Superset Pages

A page is an **`.html` document** published to a URL people in the org can
open. Publish a single file and it must be self-contained; publish a directory
and its `index.html` is the document, with the images, stylesheets, and media
it references by relative path published alongside it. Every publish mints a version, so a page has history. Readers can
pin a comment to any element on it, and those comments come back to an agent to
fix. That is what makes a page a working surface rather than an export.

Pages are served from their own origin under a strict content policy. Most of
the work in this skill is respecting that policy; a page that ignores it looks
fine locally and breaks silently once published.

## When a page is the right surface

Publish a page when the work has a **reader** and wants a **link**: a report
someone will skim, a dashboard for a standup, a comparison table, a diagram, a
walkthrough of what you changed.

Other skills produce exactly that and stop at the terminal. A standup digest, a
summary of a parallel run across several workspaces, a feature scorecard, the
screenshots from a browser or desktop verification: each has a reader who is not
in the session, and each is better as a link than as scrollback. Recurring ones
gain the most, since republishing versions one page rather than littering the
org with a new one every day. That only holds when the workspace and the path
both stay the same, which is the identity of a page: a job that runs somewhere
new each time needs `--page <id>` instead.

Don't publish when the artifact belongs in the repo (source, docs, config: put
those in files and commit them), or when it genuinely needs a server, a
database, or a login. A page has none of those.

If you're unsure, ask. Publishing is cheap and reversible, but a page the user
didn't want is noise in their org's list.

## The content policy, which is what actually bites

Every page gets its own origin, `https://<pageId>.frame.supersetusercontent.com`, and
is framed with `sandbox="allow-scripts allow-same-origin allow-forms
allow-popups"`. So the page is a real origin of its own, and a locked-down
one. The policy is `default-src 'none'` with a short allowlist, and it is
enforced identically in the desktop pane and the web viewer:

- **No network from script.** `fetch`, `XHR`, `EventSource` and WebSockets are
  all blocked, and so is `fetch("data:...")`: a page cannot read its own
  inlined data URIs back out. Write pages that need no network at all: bake
  the data into the document as a literal, or decode base64 in JavaScript
  (`atob`, then `Uint8Array.from`).
- **No compiling code at runtime.** `script-src` carries no `'unsafe-eval'`,
  so `eval()` and `new Function()` both raise an `EvalError`. This rules out
  inlining any library that builds functions at runtime, which includes
  several chart and templating libraries and a number of date and expression
  helpers. Check for it before you reach for a dependency: the page renders
  nothing and gives no visible reason why.
- **No scripts or stylesheets from a remote host.** `<script
  src="https://…">` and `<link rel="stylesheet" href="https://…">` are
  blocked, Google Fonts `<link>` tags included. A directory publish's own
  files load fine (relative `src`/`href`), and a remote font *file* is
  allowed, so an inline `@font-face { src: url(https://…) }` works.
- **Images, video and audio may be remote** (`https:`, `data:` or `blob:`),
  but prefer `data:` URIs for anything the page cannot do without: a reader
  with the network off sees nothing, and a remote image makes every reader's
  browser call that host directly, which hands a third party the IP address
  of everyone who opens the page.
- **Storage works** and is scoped to the page: `localStorage`,
  `sessionStorage`, `indexedDB` and cookies persist across reloads and across
  versions of the same page. Use it for a chosen tab or filter, never for
  anything the page cannot rebuild from its own content.
- **No parent access.** The viewer is a different origin, so
  `window.parent.document` and `window.top.location` throw. Superset injects
  one script into the page for comment anchoring; nothing else listens to
  `postMessage`, so don't build a handshake on it.
- **No form submission.** `form-action 'none'`: a `<form>` may exist for its
  controls, but submitting it goes nowhere. Handle inputs in script.

Scripts and popups *do* work. Inline JS runs normally, so charts, filters,
sorting, tabs, and interactive controls are all fine, as long as everything
they need is already in the file.

## The other hard limits

1. **`.html` only.** Any other extension is rejected at the CLI.
2. **One file, or one directory.** `superset pages publish ./report/`
   publishes a directory: `index.html` is the page, and every other file
   ships at its relative path, so `<video src="demo.mp4">`,
   `<link href="site.css">` and `<script src="app.js">` all work. Asset
   paths may not start with `versions/`, `files/`, `_superset/` or `~`, or
   be named `thumbnail.jpg`. Assets go up to 1 GiB each; on republish,
   unchanged assets are not re-uploaded. Prefer H.264 MP4 or WebM for
   video: iPhone `.mov` recordings may not play in every browser. Remote
   CDN links and external stylesheets are still blocked; for a single-file
   page, inline all CSS and JS and embed images as `data:` URIs.
3. **16 MB maximum for the HTML document itself**, and base64 `data:` URIs
   count toward it at ~1.37× their
   raw size. A few small SVGs or PNGs are fine; a photo gallery is not.
4. **Full-bleed frame.** There is no chrome around the document: what you
   write is the whole surface, edge to edge. The injected theme paints the
   background (see below), so inherit it or set your own, never leaving it to
   the browser default.

Check before publishing: no `<script src>` or `<link rel="stylesheet">` pointing
at a remote host, no `fetch` of any kind including of a `data:` URI, no `eval`
or `new Function` anywhere in the file or in anything you inlined, page fits in
16 MB, opens correctly from `file://` with the network disabled. Remote images
are the one permitted exception: they go blank offline, which is the price of
not inlining them.

## Structure and theme

Every page is served with a stylesheet of ours inlined at the top of `<head>`.
You never write it. The origin injects it into the document on the way out, so
it reaches pages published before it existed too. It gives bare HTML a readable
default: type scale, links, lists, tables, code blocks, `box-sizing`,
responsive images. **Don't inline a CSS reset, a normalize, or a webfont.**
Write semantic HTML and most pages need no `<style>` block at all.

What it deliberately does *not* set, because it reaches pages written before it
existed and those pages never agreed to it: padding on `body`, a width cap on
your text, or a height on your `<iframe>`s. The frame stays full-bleed and the
measure is yours to choose (see `--sp-measure` below).

Start from this skeleton:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Q3 pipeline</title>
  </head>
  <body>
    <main>
      <h1>Q3 pipeline</h1>
      <p>Where every open deal stands going into Q4.</p>
      <section>…</section>
    </main>
  </body>
</html>
```

Every rule in the theme is wrapped in `:where()`, which carries no
specificity. Any selector you write beats it: `body { background: #0b0b0b }`
in your own `<style>` wins outright, and a class of your own is never touched
by it. So the theme is a floor, not a cage: lean on it for the ordinary parts
and style the parts that make this page itself.

### Light and dark

The theme is light unless you ask for dark, with a class on `<body>` that flips
background, text, borders, code blocks and native controls together:

```html
<body class="dark">
  <!-- or class="light", which is the default -->
</body>
```

It does **not** follow the reader's system setting on its own, and that is
deliberate. A page that set a background but no text colour, or set one on
`<html>` rather than `<body>`, would take the other half of the pair from a
palette that inverted underneath it, and end up dark text on a dark ground.
Opting in keeps that decision with the page that can actually see its own
colours.

So: add `class="dark"` when the page's visuals assume it: a chart with
baked-in colours, a screenshot with a dark background, a diagram with hardcoded
strokes. To follow the reader's system setting, ask for that too:

```html
<body class="auto">
```

`auto` is the right choice for text and tables, where nothing is pinned to one
scheme. Use it whenever the page has no baked-in colours of its own, but reach
for it deliberately, and if you hardcode any colour anywhere on the page, set
its partner as well so the pair can never come from two different themes.

### Tokens

These are Superset's own palette, so a page read next to the app belongs to
it. Build on them rather than hardcoding colours and both themes keep working:

| Token | What it is |
| --- | --- |
| `--sp-bg` | Page background |
| `--sp-surface` | Raised or inset panels |
| `--sp-text` | Body text |
| `--sp-muted` | Secondary text, captions, table headers |
| `--sp-border` | Rules and hairlines |
| `--sp-accent` / `--sp-accent-text` | Links and emphasis, and text on top of the accent |
| `--sp-code-bg` | Code background |
| `--sp-chart-1` … `--sp-chart-5` | Categorical series colours, distinct in both themes |
| `--sp-radius` | Corner radius |
| `--sp-measure` | Reading measure for prose blocks |
| `--sp-font-sans` / `--sp-font-mono` | Font stacks |

The accent is a near-neutral, the way the app's is. It carries emphasis
through weight and underline rather than hue. Reach for `--sp-chart-*` when you
need colours that separate from one another, and don't paint a chart in five
shades of the accent.

Redefine any of them on `:root` to re-skin the whole page in one place:

```css
:root {
  --sp-accent: #b4531f;
  --sp-measure: 62ch;
}
```

Define overrides on `:root`, not on `body`. The theme's own values live on
`:root`, and a value set closer to the content would win in only one of the two
colour schemes.

## Design

The page should look deliberate. Avoid the house style of generic AI output:
purple-to-blue gradients, everything centered, uniform pill-rounded corners on
every element, Inter (or system-sans) for every line, and emoji as section
icons. Those read as "generated" at a glance.

Instead: hold to one palette (the tokens above, or a real one of your own)
set a typographic scale with actual contrast between heading and body, and let
the layout follow the content: a data-dense table wants a wide flush-left page,
a narrative report wants the default measure. Use whitespace for grouping
instead of borders on everything.

Make it responsive with relative units and flex/grid, and give wide content
(tables, code blocks, charts) its own `overflow-x: auto` container so the page
body never scrolls sideways.

If the user's project has a design system, read it first and match it.

## Publish

```bash
superset pages publish report.html \
  --title "Q3 pipeline" \
  --description "Where every open deal stands going into Q4" \
  --label "first draft"

# Or a directory: index.html is the page, everything else rides along
superset pages publish ./report/ --title "Q3 pipeline"
```

`--title` defaults to the filename with dashes and underscores turned into
spaces, so name the file well or pass the flag. `--label` is what shows in
version history; write what changed, not "update".

**Every page belongs to a workspace.** The CLI records the file's path relative
to the workspace root as the page's entry path, and that path is the key:
publish the same path again and it becomes **version 2 of the same page** rather
than a second page.

Write the `.html` **inside the workspace**, not in `/tmp` or an agent
scratchpad. A file outside the workspace has no relative path, so it falls back
to being keyed by filename alone (`/external/report.html`), which means two
unrelated files with the same name will version each other. Keeping it in the
workspace also keeps the source next to the work it describes.

Outside a workspace entirely, with no `$SUPERSET_WORKSPACE_ID` and no
`--workspace`, the publish is refused rather than creating a page nothing can
list. Pass `--page <id>` to add a version to a page you already have.

Keep the source file. It is the only copy you can edit; the published version is
derived from it.

## Update an existing page

Two routes, and the difference matters:

```bash
superset pages publish report.html --label "fixed Q3 totals"   # same path in the same workspace
superset pages publish report.html --page <page-id> --label "…" # anywhere, explicit
```

Use `--page` whenever you're outside the original workspace, the file moved, or
you're not certain the path still matches. A wrong guess doesn't error; it
quietly creates a *new* page, and the reader's link keeps showing the old one.

## Visibility

`org` (the default) or `just_me`, set with `--visibility`. Anything wider is not
settable from the CLI. A new page is readable by the org, because that is what a
page is usually for; pass `--visibility just_me` when the user wants a draft only
they can open.

Visibility belongs to the page, not to the publish. Republishing never changes
it, so a page someone narrowed to `just_me` stays that way through every later
version, and a page created before `org` became the default is still `just_me`
until someone widens it.

## Read a page back

```bash
superset pages list --workspace <id>     # or omit --workspace for the whole org
superset pages get <page-id-or-slug>
superset pages versions <page-id-or-slug>
superset pages pull <page-id-or-slug> --version 2 > v2.html
```

`pull` writes HTML to stdout; use it to recover a source file you no longer
have, or to diff what actually shipped against what you have locally.

## Answer comments

A reader clicks an element on the published page and pins a comment to it. When
they hand the thread to an agent, the prompt that arrives names the page, and
for each thread gives a `thread:` id, an `at:` CSS selector path from `<body>`,
and the element's text at the time of writing.

**That selector points into the published HTML, which is the same document as
your source file.** That document is the `index.html` you published, not any
asset beside it, so the anchor locates the exact element to edit. Quoted text alone doesn't; the same words often
appear more than once.

The loop, in order:

```bash
superset pages comments list --page <page-id-or-slug>
# edit the source file, fixing what each thread asked for
superset pages publish report.html --label "addressed review comments"
superset pages comments reply --thread <thread-id> "Recomputed from the Q3 close; the total is 1.42M now."
superset pages comments resolve --thread <thread-id>
```

Rules that keep this honest:

- **Fix the source, then republish, then reply.** A reply pointing at a version
  that doesn't exist yet wastes the reader's time.
- **Reply before resolving.** Resolving silently closes the thread with no
  record of what changed. Say what you did, then close it.
- **Only answer threads that were handed to you.** Other threads on the page are
  someone else's conversation.
- **Don't resolve what you didn't fix.** If a comment asks for something you
  can't do or disagree with, reply saying so and leave it open for a human.

Reopen with `superset pages comments resolve --thread <id> --reopen`.

## When it fails

| Symptom | Cause |
| --- | --- |
| `Only .html files can be published as a page` | Wrong extension, or you pointed at a directory |
| Publish rejected on size | Over 16 MB; the `data:` URIs are almost always why |
| A new page appeared instead of a version | Published from outside the workspace, or the path changed; use `--page <id>` |
| Reader gets a 404 | Page is `just_me`, either set that way or created before `org` became the default; widen it with `--visibility org` |
| Page is blank once published, fine locally | A script threw, or the page loads a script or stylesheet from a remote host |
| A chart or widget renders nothing and logs no error | The library compiles code with `new Function` or `eval`, which the policy refuses; pick one that does not |
| Fonts missing when published | A Google Fonts `<link>`; inline the `@font-face` instead, or use `--sp-font-sans` |
| Page ignores `class="dark"` | The class belongs on `<body>`, not on `<html>` or a wrapper |
| A theme token has no effect | It was redefined on `body`; move the override to `:root` |
| Images missing when published | `http://` URLs, or the reader is offline; embed as `data:` URIs |

