Repository source: docs/specs/theme_menu_representative_options_spec_v1.md
Theme-Representative Options in the Theme Menu — Spec
Status: shipped (branch theme-menu-representative-options merged; all
4 progress items below complete, including the Playwright verification
sweep). The mechanism below is described as originally built, against a
single shared public/assets/site.css — the per-theme variable/garnish
blocks this spec adds were later split out into individual
public/assets/theme-<name>.css files (see
docs/runbooks/theme_development_guide.md), so the "Files" section's file
path is no longer current, though the CSS rules themselves are unchanged.
Progress
Context
The header theme control (branch theme-selector-popover) is a capsule with a
swatch dot that cycles themes and a caret that opens a popover menu
(templates/partials/theme_menu.php). Each menu option is a row rendered in
the ambient (currently active) theme: the ambient --ui-font, --ink,
and backgrounds, plus a 1rem swatch dot as the only hint of what the target
theme looks like.
With 9 themes whose identities differ far beyond a dot — Console is glowing green monospace, Chicago is beveled silver on teal, Sticker is black-on-red zine, Vapor is neon on purple — a dot undersells them. The goal is for each option row to look like the theme it selects: its own page background, ink color, and font, so the menu reads as a strip of live previews.
Key insight / mechanism
Every theme is already fully described by CSS custom properties in one
:root[data-theme="<name>"] block in public/assets/site.css (lines ~1–215):
--page-bg, --body-background, --ink, --ink-soft, --line, --panel,
--ui-font, etc.
Custom properties set on an element override inherited ones. So if each
option row receives its theme's variable block, the row and everything inside
it can be styled with plain var(...) references and will automatically render
in that theme's palette and font — no color value is duplicated anywhere.
The rows already carry a stable hook: data-theme-option="<name>"
(server-rendered from ThemeRegistry). The change is purely additive
selectors:
/* before */
:root[data-theme="console"] { ...vars... }
/* after */
:root[data-theme="console"],
.theme-menu__option[data-theme-option="console"] { ...vars... }
One per theme. For light, whose values are the :root defaults (its
:root[data-theme="light"] block only sets color-scheme), extend the base
defaults block instead:
:root,
.theme-menu__option[data-theme-option="light"] { ...defaults... }
Notes on why this is safe:
The scoped structural overrides (
:root[data-theme=X] .card,:root[data-theme=X] button...) all require:root[data-theme]and cannot leak into the popover rows; only the variable blocks are re-scoped.Element-level custom properties beat inherited ones regardless of the ambient theme, so a Console row inside a Vapor page still gets Console vars.
Each row matches exactly one theme block (names are unique per
ThemeRegistry), so no ordering hazards between blocks.color-schemeriding along in the blocks is harmless on a styled button.The swatch dots (
.theme-swatch[data-theme=X]) already use fixed literal colors, not vars, so they are unaffected and stay correct inside the rows.
Row design
Each option row becomes a miniature page strip:
.theme-menu__option {
/* existing: flex row, gap 0.55rem, text-align left, cursor pointer */
background: var(--body-background); /* the theme's real page background,
gradients included */
color: var(--ink);
font-family: var(--ui-font);
font-size: 0.78rem; /* fixed size so Verdana/Georgia/
monospace rows align */
min-height: 2rem; /* mixed font metrics don't jiggle
the column */
border: 1px solid var(--line); /* the theme's own hairline */
}
--body-background(not just--page-bg) is deliberate: it carries the signature gradients — Vapor's purple wash, Console's dark green fade, Chicago's teal. Compressed into a 2rem row they still read.The label (
.theme-menu__label) inherits row color/font — no change needed.The checkmark
::afterinherits the row's ink — visible on every row by construction, since each theme's--inkcontrasts with its own background.Keep the swatch dot. It still carries identity (Forge's ember, Auto's split), gives the rows a consistent left rhythm, and the smoke tests and JS already reference the markup. Markup (
theme_menu.php) is unchanged.
Interaction states
The current states (hover/focus-visible/checked → ambient
--input-bg + --line) stop working once rows own their backgrounds.
Replace with indicators that use the row's own tokens, guaranteeing
contrast on every row:
.theme-menu__option:hover {
border-color: var(--ink-soft);
}
.theme-menu__option:focus-visible {
outline: 2px solid var(--ink);
outline-offset: -2px;
}
.theme-menu__option[aria-checked="true"] {
border-color: var(--ink); /* plus the existing ::after checkmark */
}
(Do not use --active-line/--active-bg here: Chicago's block doesn't
define them, so a Chicago row would inherit the ambient theme's values —
--ink is defined by every block.)
Popover container
Stays in the ambient theme (it is chrome, not a preview): keep the opaque
--page-bg + --panel backing added for Vapor. Two tweaks so the differently
colored rows read as tiles rather than a broken gradient:
bump row gap from
2pxto4px;keep rows squared (no radius) to match the site's aesthetic.
The Auto row
Auto is a mode, not a palette. Recommended: leave the Auto row in ambient styling with its existing split light/dark swatch — its "not one of the themes" look is informative, and it costs nothing.
Alternative (only if the ambient-styled row looks out of place in review):
have JS mirror the resolved system scheme onto the row
(autoOption.dataset.resolved = systemTheme(), updated in the existing
matchMedia listener) and add .theme-menu__option[data-resolved="dark"] /
[data-resolved="light"] to the dark/defaults selector lists. Small, but
touches theme_toggle.js; skip it for v1.
To keep Auto ambient while using the generic row rule above, either exclude it
(.theme-menu__option:not([data-theme-option="auto"]) { ... }) or give the
generic rule ambient-var fallbacks and let the eight re-scoped blocks
override; the :not() exclusion is simpler and self-documenting.
Optional garnish (slice 2, cuttable)
Fonts and palettes come free via the variables, but four themes have identities carried by effects the variables can't express. Four tiny scoped rules complete the previews:
.theme-menu__option[data-theme-option="console"] { text-shadow: 0 0 6px rgba(155, 255, 138, 0.55); }
.theme-menu__option[data-theme-option="vapor"] { text-shadow: 0 0 8px rgba(122, 252, 255, 0.6); }
.theme-menu__option[data-theme-option="chicago"] {
background: #c0c0c0; /* silver chrome, not the teal desktop */
box-shadow: inset -1px -1px 0 #7b7b7b, inset 1px 1px 0 #ffffff;
}
.theme-menu__option[data-theme-option="sticker"] {
border: 2px solid #0a0a0a;
box-shadow: 2px 2px 0 #0a0a0a; /* the sticker offset */
}
Sticker's offset shadow needs ~2px breathing room — covered by the 4px row gap. If any of these fight the checked/hover border indicators in practice, cut the garnish before weakening the indicators: state visibility wins.
Accessibility
Contrast inside each row is guaranteed by construction (each theme's
--inkvs its own background is the contrast the whole site runs on).Checked/hover/focus indicators use the row's own ink — no state relies on a color that might blend with a particular row's background.
Fixed
font-size/min-heightkeeps 9 rows visually scannable despite five different font stacks.No ARIA changes:
role="menuitemradio",aria-checked, focus order, and keyboard behavior are untouched.The menu is a redundant path (the cycle dot still works), so even in forced-colors/high-contrast modes where the previews flatten, nothing is lost functionally.
Non-goals
No markup or PHP changes (
theme_menu.php,ThemeRegistry,TemplateRenderer,layout.phpuntouched).No JS changes in v1 (Auto stays ambient).
No change to the header capsule, cycle button, or swatch dots.
No mini "screenshot" tiles (panel-in-page compositions per row) — the row strip is the 90% version; tiles can be a later iteration if wanted.
The forge
@media (max-width: 520px)gradient suppression is not extended to the row (a 2rem gradient is negligible on OLED).
Files
public/assets/site.css— the only file that changes:Extend 8 selector lists (7 theme blocks + the
:rootdefaults block for light) with.theme-menu__option[data-theme-option="<name>"].Rework
.theme-menu__optionbase + state rules as above.(Slice 2) 4 garnish rules.
tests/LocalAppSmokeTest.php— existing theme-menu assertions still pass unchanged (markup is identical). Optionally assert nothing; CSS-only.
Verification
php tests/run.php(should be untouched-green; delete any stalepublic/index.htmlartifact first).Playwright sweep, ambient theme = each of the 9 in turn:
open the popover; screenshot; every row shows its own colors/font regardless of ambient theme;
computed-style spot checks under a contrasting ambient (e.g. ambient vapor): console row
background-color≈rgb(8, 17, 10)andfont-familycontainsLucida Console; sticker row background#e0493a; light row white;checked row shows checkmark + ink border; hover and focus-visible visible on light, dark, and sticker rows;
select from a preview row → theme applies, persists, popover closes, focus returns (existing behavior, must not regress);
keyboard: arrows/Escape unchanged.
Confirm no bleed: ambient page styling (cards, nav, buttons) unchanged after the selector-list edits — the re-scoped blocks contain only custom properties.
Open questions to resolve before shipping
Swatch dot redundancy — with full-row previews, is the dot per row still wanted, or should rows drop it (label-only previews)? Spec keeps it; cheap to remove later (CSS
display: none, or drop the span from the partial).Auto row treatment — ambient (recommended) vs. resolved-scheme preview (needs the small JS attribute sync).
Chicago row background — teal
--body-background(true to the page) vs. silver chrome garnish (true to the UI). Spec recommends silver via the garnish rule; if garnish is cut, teal is what you get and it's fine.Popover width — mixed fonts may want
min-widthbumped from 11rem to ~12rem so Verdana/monospace labels don't feel cramped. Decide by eye in the Playwright sweep.