Theming & color modes
One JSON block drives the whole look — shadcn-named variables, dark mode built in.
The theme block
A pagelet's look is driven by a small JSON theme block compiled to CSS variables that mirrorshadcn's names — so a brand's design system drops in without reinvention. Minimal is fine: set just primary and everything else inherits sensible defaults.
<script type="application/json" data-pagelet="theme">
{
"colors": {
"primary": "#4f46e5",
"background": "#ffffff",
"foreground": "#0a0a0a",
"muted": "#f1f5f9",
"border": "#e5e7eb"
},
"dark": { "background": "#0a0a0a", "foreground": "#fafafa" },
"radius": "0.5rem",
"fontFamily": "Inter, system-ui, sans-serif"
}
</script>| Key | Compiles to | Notes |
|---|---|---|
colors.* | :root { --<name>: … } | shadcn names: background, foreground, card, primary, secondary, muted, accent, border, ring (+ their *-foreground pairs) |
dark.* | [data-theme="dark"] { … } | Per-variable dark-mode overrides — only list what changes |
radius | --radius | Any CSS length |
fontFamily | body font | typography.fontFamily also accepted |
Accepted aliases, so existing brand JSON maps on directly:
| Alias | Resolves to |
|---|---|
text | foreground |
text-muted | muted-foreground |
surface | card |
typography.fontFamily | fontFamily |
The color-mode model
- Initial mode follows the OS (
prefers-color-scheme).<html data-theme="dark">forces dark. - The toolbar toggle cycles auto → light → dark and persists when
localStorageis available. - The resolved mode sets
color-schemeto match (form controls, scrollbars follow). - Every change fires a
pagelet:themechangeevent ondocument— listen for it if your own JS paints anything mode-dependent (e.g. a canvas).
Custom CSS must be mode-aware
The toggle flips <html data-theme> at any time. If your page ships a<style> block with hardcoded light-mode colors, the content stays light while the runtime chrome goes dark — or text ends up dark-on-dark. Both modes must be readable:
- Prefer the theme block for anything the runtime styles (prose, code, tables, toolbar).
- Scope custom CSS by mode. Dark overrides go behind
[data-theme="dark"]. - Never hardcode a light-only palette. A deliberately branded surface (a navy hero) must pin its own text colors so it reads in both modes.
- Don't repurpose the runtime's token names. The runtime owns
--background,--foreground,--card,--primary,--secondary,--muted,--accent,--border,--ringand their*-foregroundpairs on:root— and it loads after your CSS, so it wins ties. Consume the runtime tokens or namespace your own (--brand-ink).
:root { --paper: #ffffff; --ink: #101828; }
[data-theme="dark"] { --paper: #12141c; --ink: #e6e8f0; }
.panel { background: var(--paper); color: var(--ink); }/* light-only palette — breaks the toolbar's dark mode */
.panel { background: #ffffff; color: #101828; }
/* repurposing a runtime token — the runtime loads after your
CSS and owns --muted (a SURFACE color, not muted text) */
:root { --muted: #525252; }Test both modes before publishing: click the toolbar theme toggle once and scan for vanished text or inverted contrast. This is the single most common quality failure.
Deriving a theme from a brand
- If you can read the user's codebase, lift their existing CSS variables / Tailwind tokens / brand colors and map them onto the names above — don't invent a palette.
- Otherwise ask for one or two colors (
primary, maybeaccent) and let the defaults fill in. - On the hosted service, the MCP
extract_brandtool scrapes a public brand URL and returns a ready-made theme (palette + radius) — feature-detect viaservice.brand_extract. - Keep contrast accessible; one accent used sparingly beats a rainbow.
The same theme block works on any template and any bring-your-own-HTML pagelet. A future hosted-theme reference (data-ref="…") will let you pull a saved brand theme; it's feature-detected and not available yet.