mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 20:31:21 +00:00
* 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>
247 lines
16 KiB
Markdown
247 lines
16 KiB
Markdown
# 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)
|
||
|
||
1. **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.
|
||
2. **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.
|
||
3. **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-accent2` was
|
||
**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 be `none` — 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 from `accent-2`). neon/metal → a gradient; esports → a solid accent.
|
||
**`--fb-acc-text-fill` is the one slot where `none` is illegal** — it is always a valid paint
|
||
(solid colour or gradient), defaulting to `rgb(var(--fb-accent))`. Reason: the number is
|
||
rendered with `background-clip: text` + transparent text-fill, so a `none` paint would make
|
||
the digits **invisible** (transparent fill, nothing to clip) — which would violate the DoD
|
||
"a device stays legible when its slot resolves to `none`". The feature also feature-detects
|
||
`background-clip: text` and keeps a solid `color` base (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 }` where `tokens` is 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 wraps `matchMedia` once). **This is the
|
||
single approved JS reduced-motion gate going forward** — existing direct `matchMedia` callers
|
||
(`venue-mood-fx.js`, `pedal-cables.js`) migrate to it; `--fb-motion-decorative` covers the
|
||
CSS-authored decorative motion.
|
||
- `theme:changed` event → `{ 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 literal `linear-gradient`, no hex.** Devices live
|
||
> in slots; the theme owns the slots.
|
||
|
||
```css
|
||
.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-decorative` is the *only* place CSS decorative animation is
|
||
named; one central rule in the contract sheet sets it to `none` under
|
||
`@media (prefers-reduced-motion: reduce)`, so no theme can forget the gate. (JS-driven motion
|
||
uses `feedBack.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-ring` but may not author their own focus
|
||
styling. *Migration:* v3 already ships component-specific focus + reduced-motion rules in
|
||
`v3.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):**
|
||
1. **Host minimal surface** — the contract stylesheet's always-present default `--fb-*` tokens + `feedBack.theme.{get, prefersReducedMotion}` (`get().tokens` = resolved values; no boolean `capabilities()`) + `theme:changed`. *(the smallest thing that would have prevented the incident)*
|
||
2. **note_detect refactor** — rename device tokens by intent (EMPHASIS + ACCENT-TEXT recipes), add `on-accent` + `focus-ring`, derive surfaces from host tokens.
|
||
3. **Verification gate** — commit the render-matrix + DoD checklist; add the canvas share-card surface.
|
||
4. **Ecosystem migration guide** — document the contract + the consumption rule for community plugin authors.
|
||
|
||
## 9. Cross-apply status (already done)
|
||
|
||
- `note_detect` results-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-bg` and 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).)*
|