* docs: host theme contract proposal (prevent features carving into one theme) Charrette output after a plugin UI feature (note_detect results card) was built against only the default skin and broke on the others — the colours adapted via tokens but the visual *devices* (glow ring, gradient) did not, because themes are design languages, not palettes, and nothing governs whether a theme does glow. Proposes a host theme contract: always-present semantic role tokens (incl. the missing on-accent + focus-ring), intent-named capability recipe slots where "off" is legal (an EMPHASIS recipe + an ACCENT-TEXT recipe), a window.feedBack.theme read/capability API + theme:changed event, a derive-surfaces-from-host reconciliation rule, accessibility baked in, and a skin-matrix verification gate. All additive + feature-detected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * docs(host-theme-contract): make the proposal normative (review fixes) Address the review of #645 (manual + Codex) — the doc was a sound design sketch but not yet the precise *contract* it claims to be: - P1 Token contract pinned: one public namespace `--fb-*` written on :root by a host-owned contract stylesheet (present themed-or-not); `--fbv-*` explicitly demoted to internal Tailwind-override plumbing (plugins must not read it). Added a normative role table + value grammar (colour roles = `r g b` triplets consumed via `rgb(var(--fb-x))`; recipe slots = full CSS device values). The §5 example now uses `--fb-*` throughout (was bare `--accent`/`--emph-*`). - P1 Invisible-text bug removed from the spec: `--fb-acc-text-fill` is the one slot where `none` is ILLEGAL (always a real paint, defaulting to the solid accent); the example feature-detects `background-clip: text` and keeps a solid `color` base, so the accuracy number can never render transparent — honouring the DoD "a device stays legible when its slot resolves to none". - P1 capabilities() booleans removed: they contradicted "never branch on glowy?" and were too lossy for canvas. The JS API is now CSS/DOM-forbidden and exposes RESOLVED token values (`get().tokens`) for canvas/WebGL renderers only. - P1 Physical home decided: a static `theme-contract.css` (outside the prebuilt Tailwind artifact, so no tailwind-fresh CI churn) holds the :root `--fb-*` defaults + the single focus-visible + reduced-motion rules; existing v3.css focus/motion rules are a tracked reconciliation, not day-one magic. - P2 Full on-fill family (`--fb-on-accent/-good/-warn/-bad`) + good/warn/bad ↔ existing good/mid/low mapping; `theme:changed` lifecycle pinned (get() sync + valid pre-apply, event after commit + once on hydration, plugins read on mount); same-document-light-DOM scope + shadow/iframe bridge stated; prefersReducedMotion() named the single JS motion gate. Resolved open questions folded into the body; the two genuine ones (skins-as- host-themes, component-recipe bundles) remain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
16 KiB
Host Theme Contract — design proposal
Status: proposal (charrette output, 2026-06-29) · Owner area: core v3 + plugin UI Trigger: a plugin UI feature accidentally "carved itself into a single theme."
1. Problem
A results-card feature in the note_detect plugin (a glow-ring hero button + a
gradient-filled accuracy number) was built and visually verified against only the
default skin ("neon"). On the other skins it broke: on "esports" — a deliberately
glow-less, near-monochrome design language — the glow ring and the colour gradient
simply vanished. The colours adapted (everything used CSS custom-property tokens),
but the visual devices themselves did not port, because nothing in the system says
"this theme does / doesn't do glow rings."
Root cause (three findings)
- Themes are design languages, not palettes. neon = glow + animation + gradients; esports = no-glow, square, near-monochrome amber; metal = brushed steel + hard bevels + drop-shadows. Tokens made colour portable; they never made a device portable.
- Tokens are named by device, not intent. e.g.
--nd-glow-*holds a glow in neon but a hard drop-shadow in metal — the metal skin is already repurposing a device-named slot to express a different language. The cure is to finish that move: name slots by intent, with "off" (none) a legal value. - No "text-legible-on-accent" role. White-on-accent was hardcoded in several places;
on esports' amber accent that's a contrast failure. And
--nd-accent2was double-booked (gradient-end and S-grade colour), so the hero gradient resolved amber→near-white and washed out.
A process gap compounds it: verification covered one skin, so the regression was invisible until a user switched themes. And this recurs ecosystem-wide — other plugins ship their own independent skin systems too.
2. Current state (two disconnected systems)
| System | What it is | Limits |
|---|---|---|
Host themes (static/v3/theme-core.js, html[data-fb-theme]) |
Cosmetic "shop" themes that recolour fb-* Tailwind tokens (surfaces/text/borders). |
Apply-only & recolour-only. --fbv-* vars exist only while a theme is equipped (nothing to read in the default state). No read API, no capability signal, no normalized theme:changed event. Comment explicitly says it leaves decorative accents (rings/shadows) at defaults → devices are an ownerless gap. |
Plugin skins (e.g. note_detect data-nd-skin) |
Full per-plugin design languages (neon/esports/metal) as CSS-var blocks. | Each plugin reinvents the wheel; disconnected from host themes; a feature can't see both. |
3. Goals / non-goals
- Goal: a feature, authored once, renders correctly in any theme — including ones not yet invented — and degrades intentionally (neon ring → esports border), never accidentally.
- Goal: the host owns a canonical contract so plugins consume instead of reinventing.
- Non-goal: forcing every plugin skin to become a host theme. Skins stay plugin-local but implement the contract.
- Non-goal: backward-compat with pre-v3 hosts. Everything here is additive + feature-detected.
4. The contract — three layers
Layer 1 — Semantic colour roles (always present)
The host writes default --fb-* role tokens on :root unconditionally (not only under
[data-fb-theme]), seeded from the canonical fb palette, so var(--fb-accent, …) always
resolves — themed or not. Roles:
Namespace (normative). The public contract lives under one prefix, --fb-*, written on
:root by a host-owned contract stylesheet (see §6 / §8) so it is present themed or not.
The existing --fbv-* vars stay internal plumbing — theme-core.js uses them only to
recolour the Tailwind .bg-fb-*/.text-fb-*/.border-fb-* utilities under html[data-fb-theme];
they are not part of this contract and plugins must not read them. (Implementation may seed
--fb-* from the same source the --fbv-* overrides use, so an equipped theme moves both.)
Value grammar (normative). Colour roles are a space-separated r g b triplet (matching
today's --fbv-* and the Tailwind utilities), consumed as rgb(var(--fb-accent)) with optional
alpha rgb(var(--fb-accent) / .5). Recipe slots (Layer 2) hold full CSS values for their
device (a box-shadow, a border shorthand, a length, a paint), with none legal except
where noted.
Normative role tokens (all --fb-*, all always present):
| Role | Token | Notes |
|---|---|---|
| surface / card / border | --fb-surface --fb-card --fb-border |
structural |
| text / dim | --fb-text --fb-text-dim |
|
| accent / second hue | --fb-accent --fb-accent-2 |
accent-2 is just a second hue — never an assumed gradient end |
| status | --fb-good --fb-warn --fb-bad |
maps onto today's palette good / mid / low (mid→warn, low→bad) — implementation aliases both |
| on-fill (new) | --fb-on-accent --fb-on-good --fb-on-warn --fb-on-bad |
Rule: every role used as a fill behind text gets a paired --fb-on-* (fixes white-on-amber). Required + contrast-linted (§6). |
| focus (new) | --fb-focus-ring |
focus indicator independent of accent, so focus stays visible when accent ≈ surface |
Layer 2 — Capability recipes (intent-named slots; "off" is legal)
A theme declares its design language by filling intent-named slots (all --fb-*-prefixed,
same namespace as the roles). A feature applies the slot bundle unconditionally; it never
branches on "is this theme glowy?". Atomic slots (renames-by-intent of today's tokens):
--fb-corner-radius, --fb-corner-clip, --fb-panel-shadow, --fb-text-emph-shadow,
--fb-panel-texture, --fb-motion-decorative (reduced-motion-gated). For these, none is legal.
Two composite recipes carry the load:
- EMPHASIS — how this theme makes a primary action special:
--fb-emph-fill / --fb-emph-border / --fb-emph-halo / --fb-emph-on. neon → halo (glow ring); esports → border (solid accent); metal → fill + drop-shadow. Any individual slot may benone— but a theme must emphasise somehow (at least one of fill/border/halo non-none), so a primary action is never visually flat. - ACCENT-TEXT — how this theme fills a big accent number:
--fb-acc-text-fill(decoupled fromaccent-2). neon/metal → a gradient; esports → a solid accent.--fb-acc-text-fillis the one slot wherenoneis illegal — it is always a valid paint (solid colour or gradient), defaulting torgb(var(--fb-accent)). Reason: the number is rendered withbackground-clip: text+ transparent text-fill, so anonepaint would make the digits invisible (transparent fill, nothing to clip) — which would violate the DoD "a device stays legible when its slot resolves tonone". The feature also feature-detectsbackground-clip: textand keeps a solidcolorbase (see §5), so the digits are legible even where clip-text is unsupported.
These generalize the interim per-skin tokens already shipped in
note_detect(--nd-hero-ring-idle/on,--nd-hero-border,--nd-acc-fill).
Layer 3 — JS read API + reconciliation
The JS API is only for renderers that can't use CSS (canvas / WebGL), never for DOM/CSS
consumers — those use the tokens and slots directly (§5). Critically, it exposes resolved
token values, not theme-style booleans: a glow:false flag can't tell a canvas whether to
draw a border, a bevel, a drop-shadow, or flat text, so there is no capabilities() of
booleans. On the existing window.feedBack bus:
feedBack.theme.get()→{ id, isThemed, tokens }wheretokensis the resolved map of every--fb-*role + recipe slot (the computed values, so a canvas reads the actual device, e.g. the gradient stops for--fb-acc-text-fill, not a boolean).feedBack.theme.prefersReducedMotion()→ boolean (host wrapsmatchMediaonce). This is the single approved JS reduced-motion gate going forward — existing directmatchMediacallers (venue-mood-fx.js,pedal-cables.js) migrate to it;--fb-motion-decorativecovers the CSS-authored decorative motion.theme:changedevent →{ id, tokens }.
Lifecycle (normative). get() always returns the current effective theme synchronously
and is valid at any time — before any theme is applied it returns the default/unthemed roles
(which always exist on :root). Theme application is async (it follows a /api/profile refresh);
theme:changed fires only after the DOM vars/classes are committed, and once on initial
hydration so a late-mounting plugin isn't stuck on stale state. Plugin rule: read get()
on mount, then subscribe to theme:changed — never assume an order between your mount and the
first theme apply.
Reconciliation rule (ends the two-disconnected-systems problem): a plugin skin
derives surface/text/border from host tokens (--nd-bg: rgb(var(--fb-card)), etc.) and
owns only its accent + its devices, selecting the device via the recipe. A host theme then
pulls plugin chrome along (one truth for surfaces), while the plugin layers identity on top and
never imposes a device the active theme neutralizes.
Propagation scope (normative). The contract is same-document light-DOM: :root --fb-*
inheritance and the central focus/motion rules (§6) reach any normal plugin screen. A plugin that
renders into a shadow root or iframe is responsible for bridging — copy the resolved
get().tokens into its sub-root and re-subscribe to theme:changed (host :root vars don't
cross those boundaries).
5. Consumption pattern (the rule for feature authors)
A feature may reference a colour role or a recipe slot. It may never write a raw device — no literal glow
box-shadow, no literallinear-gradient, no hex. Devices live in slots; the theme owns the slots.
.hero-cta {
background: var(--fb-emph-fill);
border: var(--fb-emph-border);
box-shadow: var(--fb-emph-halo); /* neon→ring · esports→none · metal→drop-shadow */
color: var(--fb-emph-on); /* never hardcoded #fff again */
border-radius: var(--fb-corner-radius);
}
.accuracy-number {
/* Always-legible solid base; survives no-clip-text support too. */
color: rgb(var(--fb-accent));
}
/* Apply the clipped paint ONLY where supported — and --fb-acc-text-fill is
guaranteed a real paint (never `none`, per Layer 2), so the digits can't go
invisible. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
.accuracy-number {
background: var(--fb-acc-text-fill);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
}
Where the contract physically lives. A host-owned static contract stylesheet (e.g.
static/v3/theme-contract.css, hand-authored, linked from static/v3/index.html) holds the
always-present :root --fb-* defaults plus the two central a11y rules below. It is not a
Tailwind file, so it never touches the prebuilt static/tailwind.min.css artifact the
tailwind-fresh CI check diffs (and it's independent of theme-core.js, which keeps
runtime-injecting only the --fbv-* utility overrides under [data-fb-theme]).
- Reduced motion:
--fb-motion-decorativeis the only place CSS decorative animation is named; one central rule in the contract sheet sets it tononeunder@media (prefers-reduced-motion: reduce), so no theme can forget the gate. (JS-driven motion usesfeedBack.theme.prefersReducedMotion()— §4.3.) - Focus parity: one contract-level
:focus-visible { outline: 2px solid rgb(var(--fb-focus-ring)) }for contract consumers; themes recolour--fb-focus-ringbut may not author their own focus styling. Migration: v3 already ships component-specific focus + reduced-motion rules inv3.css; those are reconciled onto the contract token (not magically replaced) as a tracked cleanup — "one rule" describes the end state, not day one. - On-fill contrast: every
--fb-on-*is required and lintable (contrast(on-X, X) ≥ 4.5:1, 3:1 large) for each fill role (accent / good / warn / bad). Contrast is the theme's job, computed once — not re-judged per feature.
7. Verification gate (prevent recurrence)
- A committed render-matrix tool, driven off the runtime skin list, that renders the key surfaces (hero CTA, accent number, and the canvas share-image card) across every skin × key states (rest / hover / focus / reduced-motion).
- The gate is computed-style invariant assertions (deterministic, CI-safe) — e.g. "emphasis present and text legible in each theme" — not pixel-snapshot diffing (the animated ring + fonts + AA make snapshots flaky); a contact-sheet montage is the human backstop.
- Triggered on the version bump that CSS changes already require; skins enumerated at runtime + a guard test so the matrix can't silently go stale.
Definition-of-done for any theme-touching UI change (the few items that would have caught this):
expressed via tokens not hardcoded values · rendered across all skins · a new visual device
stays legible when its slot resolves to none · reduced-motion + focus parity · on-accent contrast.
8. Back-compat & rollout
All additive: the new always-present --fb-* tokens (in the contract sheet, §6) + a new
feedBack.theme namespace + a new event with no current listeners. Existing plugins (those
reading fb-* Tailwind utility classes, or shipping their own skins) are untouched unless they
opt in. On a host too old to ship the contract sheet, a consumer still degrades cleanly: the
two-arg fallback rgb(var(--fb-accent, 224 128 32)) resolves to the literal, and
window.feedBack?.theme?.get?.() is feature-detected — so older hosts behave exactly as today.
Workstream (sub-tasks):
- Host minimal surface — the contract stylesheet's always-present default
--fb-*tokens +feedBack.theme.{get, prefersReducedMotion}(get().tokens= resolved values; no booleancapabilities()) +theme:changed. (the smallest thing that would have prevented the incident) - note_detect refactor — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add
on-accent+focus-ring, derive surfaces from host tokens. - Verification gate — commit the render-matrix + DoD checklist; add the canvas share-card surface.
- Ecosystem migration guide — document the contract + the consumption rule for community plugin authors.
9. Cross-apply status (already done)
note_detectresults-card hero + accuracy number — fixed via per-skin device tokens (the Layer-2 prototype) and verified across neon/esports/metal.- The canvas share-image card — re-checked across all three skins: theme-robust
(reads per-skin colour tokens via computed style, draws skin-neutral solid devices). Minor
fidelity gap only: it uses flat
--nd-bgand skips metal's brushed-steel texture.
10. Open questions
- Should plugin skins eventually become selectable host themes (one picker), or stay plugin-local forever? (This proposal assumes plugin-local + contract-implementing.)
- Component-recipe bundles (per named component) are the richer end-state; intent-named slots are the right seed. When/whether to graduate.
(Resolved during review and folded into the sections above: the token namespace + value grammar
and normative role table (§4.1); the none-is-illegal carve-out for --fb-acc-text-fill (§4.2);
JS exposes resolved tokens, not booleans (§4.3); theme:changed lifecycle + shadow/iframe
propagation (§4.3); the physical home of the role tokens + central focus/motion rules — a
host-owned contract stylesheet outside Tailwind (§6).)