Spaces:
Running
Running
| # Theming (light / dark) | |
| The Inspector ships two themes from one token system. **Dark is the default** | |
| (the original "dark by conviction" surface); **light is an opt-in peer** β a | |
| calm, cool paper, never warm cream. A header toggle flips between them; the | |
| choice is persisted and applied before first paint. | |
| Open this when: adding a color anywhere, adding a token, touching a canvas that | |
| paints color, or debugging a surface that doesn't re-skin on theme flip. | |
| ## The one rule | |
| **Every color is a token. No raw hex/rgba/oklch literals in component CSS, | |
| `<style>` blocks, or canvas draw code.** A surface that hardcodes a color is | |
| dark-only and is a bug. The token's value is defined once per theme; components | |
| are theme-agnostic and re-skin for free. | |
| ## How a flip happens | |
| 1. `index.html` has an inline `<script>` in `<head>` that reads | |
| `localStorage['insp_theme']` (falling back to `prefers-color-scheme`) and sets | |
| `document.documentElement[data-theme]` **before any stylesheet parses** β no | |
| flash-of-dark for light users. | |
| 2. `lib/stores/theme.svelte.ts` (`themeStore`) reads that attribute as its | |
| initial truth, then owns every flip: it updates its `$state`, persists to | |
| `localStorage` (`LS_KEYS.THEME` β the literal `insp_theme` is duplicated in | |
| index.html, keep them in sync), re-sets the `data-theme` attribute, and fires | |
| a `window` `themechange` event (`THEME_CHANGE_EVENT`). | |
| - Runes components read `themeStore.current` / `themeStore.isLight`. | |
| - Legacy (`$:` / auto-subscribe) components read the `theme$` readable store | |
| (`$theme$`) exported from the same module. | |
| 3. `ThemeToggle.svelte` (a sun/moon icon button in the header's `.auth-controls`) | |
| calls `themeStore.toggle()`. | |
| ## The tokens | |
| - `styles/tokens.css` `:root` β the **dark** values, the full set. Every new | |
| semantic token lives here with its shipped dark literal, so the dark theme is | |
| byte-for-byte unchanged by the migration. | |
| - `styles/theme-light.css` `:root[data-theme="light"]` β the **light** values, | |
| overriding every color token. Imported **last** in `main.ts` so it wins the | |
| cascade. Non-color tokens (font, spacing, radii, motion, layout) are | |
| theme-invariant and are not overridden. | |
| - `styles/base.css` `:root` and `styles/highlight-constants.css` also hold a few | |
| token *definitions* (`--tj-*` tajweed underlines, `--anim-highlight-color`, | |
| `--hl-cell-*`); their light values are overridden in `theme-light.css`. | |
| Token families: surface ramp Β· borders Β· text Β· accent (+ tints/border/soft) Β· | |
| lifecycle `--state-*` Β· status `--ok/--warn/--bad/--info/--neutral` Β· action | |
| buttons `--btn-*` Β· CTA `--cta-*` Β· waveform `--wf-*` Β· charts `--chart-*` Β· | |
| analysis `--ts-*` / `--hl-*` / `--tj-*` Β· edit-toolbar `--chrome-*` Β· scrims + | |
| `--shadow-*` Β· brand `--brand-*`. See the comments in `tokens.css` for the map. | |
| ### Light design intent | |
| Cool blue-violet identity preserved (soft grey paper, low chroma, softened | |
| contrast). The accent and every state hue **darken** so they read on a light | |
| surface; white-on-dark inks flip to dark-on-light, and the accent's button-ink | |
| flips to white. Dark conveys depth purely by a lightness ramp and stays flat; | |
| light adds a single soft `--shadow-*` on floating surfaces (no headroom for | |
| lightness depth near white) and a soft `--scrim` veil instead of near-black. | |
| ## Canvas surfaces (the part CSS can't reach) | |
| A `data-theme` flip re-skins CSS, but **not** a 2D-canvas `ctx.fillStyle` β that | |
| color is baked in JS. Bridge: | |
| - `lib/utils/canvas-theme.ts` β `themeColor('--token', fallback)` reads a CSS | |
| custom property's computed value, **cached per theme**, cache auto-cleared on | |
| `themechange`. Draw code calls this instead of hardcoding. | |
| - Canvas-owning components repaint on flip: either add `themeStore.current` to an | |
| existing reactive redraw (TimestampsWaveform), or | |
| `addEventListener(THEME_CHANGE_EVENT, redraw)` in `onMount` (dashboard / | |
| segment waveforms, stats charts β Chart.js rebuilds its config from resolved | |
| vars). Waveform colors live in `--wf-*`, chart colors in `--chart-*`. | |
| ## The accent-derived analysis colors | |
| The Timestamps analysis triad (word / letter / phoneme cells), the karaoke wipe, | |
| the teleprompter, and the footer/filmstrip chrome all derive from the user's | |
| chosen highlight accent (the footer droplet). That derivation is **legibility- | |
| banded**, and the band is theme-dependent: | |
| - `lib/utils/color-derive.ts` β pure OKLCh math (`analogousTriadCfg`, | |
| `legibleAccentCfg`, `deepForCfg`, `inkFor`). Takes explicit band knobs; no | |
| theme awareness of its own. `inkFor` auto-picks black/white ink by WCAG | |
| luminance and needs no theme parameter. | |
| - `lib/utils/highlight-model.ts` β `DEFAULT_HIGHLIGHT_MODEL` (dark, legible-on- | |
| dark band ~L 0.66β0.86, deep-block active fill + white ink) and | |
| `LIGHT_HIGHLIGHT_MODEL` (darker band ~L 0.46β0.70, **soft-tint** active fill + | |
| **auto** ink β calm, no harsh dark slab on paper). `modelForTheme(theme)` | |
| selects. `TimestampsTab` feeds the themed model into `resolveHighlightVars`. | |
| - `lib/recitation-animation/config.ts` (`cssVars(cfg, theme)`) β the teleprompter | |
| line: light gets the darker legible band, a dark base-text color, and drops the | |
| silhouette halo (dark-on-paper needs none). | |
| - `lib/utils/accent-override.ts` (`accentVars(hex, theme)`) β remaps `--accent*` | |
| on the footer/player to the user's pick; light clamps a too-**light** pick | |
| **down** into the darker band (dark clamps a too-dark pick up) and darkens on | |
| hover instead of lightening. | |
| **Muted cells** (silent grapheme, dropped diacritic, dropped implicit madd) fade | |
| via `--ts-mute-opacity` β one token every muted cell reads (`timestamps.css` + | |
| the `TajweedSettingsPanel` legend), so the fade is uniform and tunable in one | |
| place. It is theme-conditional: `0.5` in dark, **`0.4`** in light β the only | |
| non-color token overridden per theme, because a fixed alpha that reads as muted | |
| over the near-black dark cell washes a silent glyph almost invisible over | |
| near-white paper, so light needs a deeper fade to still read as *muted*. | |
| ## Adding color β the checklist | |
| - Need a color in CSS? Use an existing `var(--token)`. No token fits? Add it to | |
| `tokens.css :root` (dark = your literal) **and** `theme-light.css` (light | |
| value), then use it. Never a raw literal. | |
| - Painting on a canvas? Add a `--wf-*` / `--chart-*` token, read it via | |
| `themeColor()`, and make the component repaint on `themechange`. | |
| - After adding tokens, sanity-check parity: every token in `theme-light.css` must | |
| have a dark definition in `tokens.css` / `base.css` / `highlight-constants.css`. | |