feat(v3): host theme read surface — window.feedBack.theme + always-present --fbv-* tokens (#646)

First slice of the host theme contract (#644): give plugins a host-owned way to
read the active theme + its device affordances, so a feature renders correctly
under any theme instead of binding to whichever one the dev saw.

theme-core.js previously only APPLIED themes and emitted --fbv-* vars only while
a theme was equipped (nothing to read in the default state), with no read API.
Now, all additive + feature-detected:

- Always-present default `fb` palette as `--fbv-*` on :root (the un-themed look
  is unchanged — fb-* utilities still use their compiled defaults; this only
  hands plugins a stable host token to read + derive surfaces from). Adds two
  keystone ROLES the palette lacked: `on-accent` (legible fg on the accent fill)
  and `focus-ring`.
- window.feedBack.theme.get() -> {id, isThemed, tokens}; .capabilities() ->
  {glow, gradients, motion} (the device-affordance signal; recolor-only themes
  report defaults, a theme may opt out via `capabilities` in its payload, motion
  is reduced-motion-gated); .prefersReducedMotion().
- Normalized `theme:changed` event from the single apply() chokepoint.

The apply side stays on window.v3Theme; the read surface is attached defensively
so it survives the feedBack bus being (re)built by capabilities.js regardless of
load order. Verified via a headless render (apply/unequip intact, defaults
present + restored, capability opt-out honored, event payload correct) +
tests/js/v3_theme_read_api.test.js. See docs/host-theme-contract.md.


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou 2026-07-02 06:57:58 -05:00 committed by GitHub
parent 7ff9261000
commit d20b33348b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 170 additions and 2 deletions

View File

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Tuner: finished the "remove unused settings" cleanup and fixed the sidebar panel position.** The Floating Button and Tuning Visibility settings sections were removed, but their config was still live: `disabledTunings` still filtered the tuner menu (with no UI left to re-enable a hidden tuning — a one-way trap) and `showFloatingButton` still gated the floating launcher. Both are now fully retired — the enforcement paths in `plugins/tuner/screen.js`/`utils/ui.js` and the persistence in `plugins/tuner/routes.py` are gone (and `routes.py` strips the retired keys on write, so stale values are purged). The tuner panel opened from the v3 sidebar Plugins rail popover now anchors beside it via the host's stable plugin-control slot API (falling back to the popover id), is **clamped to the viewport** so it can't open off the right/bottom edge on narrow/short windows, and re-anchors on window resize. `plugins/tuner` → 1.3.3.
### Added
- **Host theme read surface — `window.feedBack.theme` + always-present `--fbv-*` tokens — so a plugin feature can render correctly under any theme instead of binding to whichever one the developer happened to see.** The cosmetics applier (`static/v3/theme-core.js`) previously only *applied* themes and emitted `--fbv-*` vars **only while a theme was equipped** (nothing for a plugin to read in the default, un-themed state) with no read/capability API — so plugins reinvented their own theming and a new visual *device* (a glow, a gradient) silently bound to one look. It now: (1) emits the default `fb` palette as **always-present `--fbv-*` on `:root`** (additive — the un-themed look is unchanged; the `fb-*` utilities still use their compiled defaults; this only hands plugins a stable host token to read + derive surfaces from), plus two keystone ROLES the palette lacked — **`on-accent`** (a foreground legible *on* the accent fill — the missing piece behind white-on-accent contrast bugs) and **`focus-ring`**; (2) adds **`window.feedBack.theme`** — `get()``{id, isThemed, tokens}`, **`capabilities()`** → `{glow, gradients, motion}` (the device-affordance signal a feature reads to choose a glow vs. a solid device; recolor-only themes report defaults, a theme may opt out via a `capabilities` block in its payload, and `motion` is additionally reduced-motion-gated), and **`prefersReducedMotion()`** (one central matchMedia wrapper); and (3) emits a normalized **`theme:changed`** event (`{id, isThemed, tokens, capabilities}`) from the single `apply()` chokepoint. All additive + feature-detected (the apply side stays on `window.v3Theme`; the read surface is attached defensively so it survives the `feedBack` bus being (re)built by `capabilities.js` regardless of load order). First slice of the host theme contract (got-feedback/feedBack#644) — the framework fix so a plugin UI feature can't accidentally carve itself into a single theme; see `docs/host-theme-contract.md`. Verified by a headless render (apply/unequip intact, defaults present + restored, capability opt-out honored, event payload correct). Tests: `tests/js/v3_theme_read_api.test.js`.
- **3D Keys Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the keys side of the visual-parity epic).** The gradient sky behind the highway gains the guitar's **background ambience styles** — drifting **Particles**, pitch-class-colored pulsing **Stage lights**, wireframe **Geometric** shapes, or Off — driven by the shared audio-analyser bridge (stems-first on sloppaks, one-shot `#audio` fallback; bass/mid/treble bands, 5 ms cache) with an **Ambience intensity** slider and an **Audio-reactive** toggle. And the score talks back: a **score-FX overlay** canvas draws rising **“+1” pops** off each scored key, an **expanding ring every 10-combo tier**, **milestone bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks (wrong notes and swept misses both count). All settings-gated and on by default (`keys3d_bg_*`), pooled, cleared when idle, torn down with the scene. Butterchurn/image/video remain out of scope. The `#audio` analyser tap is also **shared across visualizers** now (`window.__feedBackAudioTap` — whoever taps first publishes, everyone else adopts, `highway_3d` included), so switching between or splitting the guitar/drum/keys highways can't strand a permanently non-reactive backdrop, and the tap is never created before the page has user activation (a suspended AudioContext would silence live playback). Tests: bg-style id validation + FX defaults (30 total).
- **3D Keys Highway: the anti-plastic pass — lacquered note gems, glossy piano-black keys, a studio environment, scene themes and a gradient sky (parity slice 3).** The note gems move to `MeshPhysicalMaterial` with a full **clearcoat** (roughness 0.32, clearcoat 1.0/0.18, envMapIntensity 0.9): a sharp lacquer highlight over the colored body instead of the old dead matte surface — glass, not plastic. What sells it is **image-based lighting**: the same procedural PMREM "studio" environment as the drum highway (dark room + cool overhead / warm+cool side light strips, no addon dependency) feeds `scene.environment`, so the **black keys finally read as glossy piano black** (roughness 0.55 → 0.22, envMapIntensity 1.3) with visible strip reflections, whites keep an ivory sheen (0.42/0.55), and the highway floor gets a stage sheen (roughness 0.9 → 0.55, metalness 0.15). The flat background becomes a **vertical gradient** (lighter above the horizon → theme color → darker toward the keyboard), and the guitar highway's **11 scene themes** arrive (same names/values — your look carries across instruments; `default` preserves the original keys palette; pitch-class note/key colors are never themed — themes own the scene, Synthesia colors own the notes). Plus **Cinematic lighting** (ambient 0.55/key 1.3, on by default) and a **Glow strength** slider multiplying the note glow, key approach-glow and the sustain consume-flash (0.5 = stock). All live-applying from the Graphics settings (`keys3d_bg_theme` + `keys3d_bg_*`); the PMREM target and gradient texture are disposed with the scene. Tests: theme-table id parity + default-look preservation + fallbacks (28 total).
- **3D Drum Highway: audio-reactive background ambience + score effects (parity slice 4 — completes the drum side of the visual-parity epic).** The empty fog band behind the kit gains the guitar highway's **background ambience styles**: drifting **Particles**, palette-colored pulsing **Stage lights**, and slowly-tumbling wireframe **Geometric** shapes (plus Off) — driven by the same audio-analyser bridge the guitar uses (prefers the stems plugin's per-song analyser on sloppaks, falls back to a one-shot `#audio` tap; bass/mid/treble bands with a 5 ms cache), with an **Ambience intensity** slider and an **Audio-reactive** toggle (off = the styles animate on time only; a permanently-tapped `#audio` in a mixed split degrades the same way). The guitar's butterchurn/image/video styles are deliberately out of scope (vendored megabytes / upload plumbing; the style enum is extensible). And your combo finally talks back: a **score-FX overlay** (2D canvas over the WebGL scene, guitar `drawScoreFx` adapted to this plugin's internal scoring) draws rising **“+1” pops** off each scored lane, an **expanding ring pulse every 10-combo tier**, **milestone particle bursts** at 25/50/100 streak, and a brief **red flicker** when a 3+ streak breaks. Everything is settings-gated (Background ambience dropdown + intensity + reactive, Score effects toggle — all on by default, live-applying, `drum_h3d_bg_*` keys), pooled (zero per-frame allocation), and torn down with the scene across kit changes. Tests: bg-style id validation + FX defaults (15 total).

View File

@ -23,7 +23,24 @@
const OPACITY = { 95: '0.95', 90: '0.9', 80: '0.8', 70: '0.7', 60: '0.6',
50: '0.5', 40: '0.4', 30: '0.3', 20: '0.2', 10: '0.1' };
// Default fb palette (mirrors tailwind.config.js `fb`). Emitted as
// always-present `--fbv-*` on :root so `var(--fbv-accent)` resolves even
// un-themed — the un-themed look is unchanged (the fb-* utilities still use
// their compiled defaults; this only hands plugins a stable host token to
// read + derive from). Adds two keystone ROLES the palette lacked:
// `on-accent` (a foreground legible ON the accent fill — the missing piece
// behind white-on-accent contrast bugs) and `focus-ring`.
const DEFAULTS = {
bg: '#0f172a', sidebar: '#111827', card: '#1e293b', cardMuted: '#0b1220',
primary: '#0ea5e9', primaryHi: '#38bdf8', accent: '#ef4444',
text: '#f8fafc', textDim: '#94a3b8', border: '#334155',
good: '#22c55e', mid: '#eab308', low: '#ef4444', gold: '#e8c040',
'on-accent': '#f8fafc', 'focus-ring': '#38bdf8',
};
let _frameStyle = ''; // equipped avatar-frame CSS fragment ('' = none)
let _themeId = null; // equipped theme id (null = default look)
let _themeCaps = null; // equipped theme's declared device capabilities, or null
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
@ -73,12 +90,14 @@
return 'html[data-fb-theme] {\n' + vars + '}\n' + rules;
}
function apply(payload) {
function apply(payload, meta) {
const colors = payload && payload.colors;
let styleEl = document.getElementById(STYLE_ID);
if (!colors) {
if (styleEl) styleEl.remove();
document.documentElement.removeAttribute('data-fb-theme');
_themeId = null; _themeCaps = null;
_emitThemeChanged();
return;
}
if (!styleEl) {
@ -88,6 +107,13 @@
}
styleEl.textContent = cssFor(colors);
document.documentElement.setAttribute('data-fb-theme', '1');
// Track identity + declared device capabilities for the read API. A
// theme MAY carry `capabilities` (e.g. {glow:false}) in its payload;
// recolor-only themes carry none (→ default affordances reported).
_themeId = (meta && meta.id) || (payload && payload.id) || 'custom';
_themeCaps = (payload && payload.capabilities)
|| (meta && meta.capabilities) || null;
_emitThemeChanged();
}
function setFrame(payload) {
@ -106,7 +132,7 @@
function applyCosmetics(cosmetics) {
cosmetics = cosmetics || {};
apply((cosmetics.theme || {}).payload || null);
apply((cosmetics.theme || {}).payload || null, cosmetics.theme || null);
setFrame((cosmetics.avatar_frame || {}).payload || null);
}
@ -123,6 +149,83 @@
} catch (e) { /* offline — keep current look */ }
}
// ── Host theme READ surface (window.feedBack.theme) ──────────────────────
// The apply side stays on window.v3Theme; this is the read/capability side
// plugins consume so a feature can render correctly under any theme instead
// of binding to the one the dev happened to see. See docs/host-theme-contract.md.
// Always-present `--fbv-*` defaults on :root (see DEFAULTS). Additive: the
// un-themed look is unchanged; this only makes var(--fbv-*) resolve so a
// plugin can derive surfaces from host tokens whether or not a theme is on.
function _injectDefaults() {
if (document.getElementById('fb-theme-defaults')) return;
let vars = '';
for (const key in DEFAULTS) {
const rgb = hexToRgb(DEFAULTS[key]);
if (rgb) vars += ' --fbv-' + key + ': ' + rgb + ';\n';
}
const el = document.createElement('style');
el.id = 'fb-theme-defaults';
el.textContent = ':root {\n' + vars + '}\n';
(document.head || document.documentElement).appendChild(el);
}
function prefersReducedMotion() {
try {
return !!(window.matchMedia
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches);
} catch (e) { return false; }
}
// Live snapshot of the resolved role tokens ("r g b" triplets; use as
// rgb(var(--fbv-<key>)) in CSS). Reflects the themed values when a theme is
// equipped, the :root defaults otherwise.
function _readTokens() {
const t = {};
try {
const cs = getComputedStyle(document.documentElement);
for (const key in DEFAULTS) {
const v = cs.getPropertyValue('--fbv-' + key).trim();
if (v) t[key] = v;
}
} catch (e) { /* no computed style available yet */ }
return t;
}
// Whether the ACTIVE theme permits decorative DEVICES — the signal a feature
// reads to pick a device (glow vs. solid) rather than hardcoding one. Today's
// recolor-only themes declare nothing → default affordances; a theme may opt
// out via `capabilities` in its payload (e.g. a clean theme = {glow:false}).
// `motion` is additionally gated by the OS reduced-motion preference.
function capabilities() {
const c = _themeCaps || {};
const allowMotion = c.motion !== undefined ? !!c.motion : true;
return {
glow: c.glow !== undefined ? !!c.glow : true,
gradients: c.gradients !== undefined ? !!c.gradients : true,
motion: allowMotion && !prefersReducedMotion(),
};
}
function get() {
return {
id: _themeId,
isThemed: document.documentElement.hasAttribute('data-fb-theme'),
tokens: _readTokens(),
};
}
function _emitThemeChanged() {
if (window.feedBack && typeof window.feedBack.emit === 'function') {
window.feedBack.emit('theme:changed', {
id: _themeId,
isThemed: document.documentElement.hasAttribute('data-fb-theme'),
tokens: _readTokens(),
capabilities: capabilities(),
});
}
}
window.v3Theme = {
apply, // preview/apply a theme payload directly (null = default)
applyCosmetics, // apply a {theme, avatar_frame} equipped map
@ -131,6 +234,13 @@
refresh, // re-read equipped cosmetics from /api/profile
};
// Host-owned theme READ surface. Attached defensively so it survives the
// feedBack bus being (re)built by capabilities.js regardless of load order
// (the bus constructor copies pre-existing keys onto itself).
window.feedBack = window.feedBack || {};
window.feedBack.theme = { get, capabilities, prefersReducedMotion };
_injectDefaults();
refresh();
// Re-apply when an equip/unequip happens anywhere (shop screen, capability
// command from a plugin).

View File

@ -0,0 +1,57 @@
// Guard for the host theme READ surface added to the cosmetics applier
// (static/v3/theme-core.js): always-present `--fbv-*` defaults on :root (so a
// plugin can read host tokens un-themed), the two keystone roles the palette
// lacked (on-accent / focus-ring), and the window.feedBack.theme read API +
// theme:changed event. Source-level guards on the contract surface; runtime
// behaviour (apply/unequip/defaults-restore/capabilities) is verified
// separately by a headless render. First slice of the host theme contract
// (got-feedback/feedBack#644).
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const TC = fs.readFileSync(
path.join(__dirname, '..', '..', 'static', 'v3', 'theme-core.js'), 'utf8');
test('always-present --fbv-* defaults are injected on :root', () => {
assert.match(TC, /const DEFAULTS = \{/);
assert.match(TC, /function _injectDefaults\(\)/);
assert.match(TC, /id = 'fb-theme-defaults'/);
assert.match(TC, /':root \{/);
// injected before the first profile read so var(--fbv-*) resolves immediately
assert.match(TC, /_injectDefaults\(\);\s*\n\s*refresh\(\);/);
});
test('two keystone roles the palette lacked are added as defaults', () => {
assert.match(TC, /'on-accent':/);
assert.match(TC, /'focus-ring':/);
});
test('window.feedBack.theme read API is exposed (get/capabilities/prefersReducedMotion)', () => {
assert.match(TC, /window\.feedBack = window\.feedBack \|\| \{\};/);
assert.match(TC, /window\.feedBack\.theme = \{ get, capabilities, prefersReducedMotion \};/);
assert.match(TC, /function get\(\)/);
assert.match(TC, /function capabilities\(\)/);
assert.match(TC, /function prefersReducedMotion\(\)/);
});
test('capabilities reports device affordances and gates motion on reduced-motion', () => {
assert.match(TC, /glow:/);
assert.match(TC, /gradients:/);
assert.match(TC, /motion: allowMotion && !prefersReducedMotion\(\)/);
});
test('apply() tracks theme id + declared capabilities and emits theme:changed', () => {
assert.match(TC, /function apply\(payload, meta\)/);
assert.match(TC, /_themeId =/);
assert.match(TC, /_themeCaps =/);
assert.match(TC, /_emitThemeChanged\(\)/);
assert.match(TC, /emit\('theme:changed'/);
});
test('the apply side stays on window.v3Theme (read surface is additive)', () => {
assert.match(TC, /window\.v3Theme = \{/);
});