diff --git a/docs/web-ui/css-vars.md b/docs/web-ui/css-vars.md index a7d42f22..36d54669 100644 --- a/docs/web-ui/css-vars.md +++ b/docs/web-ui/css-vars.md @@ -17,18 +17,23 @@ reference the **semantic** names and must **not** redeclare them or reach for the raw `--baseNN` slots directly. (`base.css` holds only the shared `body` typography — it references the palette but no longer defines it.) -`colors.css` ships two rows of the same 16 slots: an unconditional -Catppuccin Mocha (dark) default, and a Catppuccin-Latte-derived (light) -row gated behind `@media (prefers-color-scheme: light)` — both use the -identical slot mapping, so which row is active never changes what a slot -means. The light row's `base00`-`base07` (surfaces/foreground) are stock -Latte hexes; `base08`-`base0F` (the eight chromatic slots) are darkened -from stock Latte to actually clear WCAG AA against how this app uses -them (real contrast failures found in review — see the comment above -those declarations in `colors.css` for the numbers and why). This is the -OS/browser-level default only; it's what a client with no more specific -theme decision gets (see "Theme swapping" below for how a stylix -deployment or a future per-user override supersede it). +`colors.css` declares each palette's 16 hex values exactly once — +`--mocha-baseNN` (dark, Catppuccin Mocha) and `--latte-baseNN` (light, +Catppuccin-Latte-derived; `base08`-`base0F` are darkened from stock +Latte to actually clear WCAG AA against how this app uses them — real +contrast failures found in review, see the comment above those +declarations in `colors.css` for the numbers and why). Everything else +in the file just points the active `--baseNN` slot at one of those two +raw palettes via `var()`, never a second copy of a hex value: an +unconditional default (Mocha), a row gated behind `@media +(prefers-color-scheme: light)` (Latte, the OS/browser-level default), +and two `:root[data-theme='light'|'dark']` blocks a per-user override +sets (swarm-ui's `SettingsMenu`) — an attribute selector always +outranks the plain `:root` inside the media query, so the override wins +regardless of the actual OS preference. Absent an override, the media +query decides; that's what a client with no more specific theme +decision gets (see "Theme swapping" below for how a stylix deployment +supersedes all of this at once). Both are deliberately standalone, not `@import`ed into the page bundles: each package re-exports them (`src/{colors,theme}.css` → @@ -127,12 +132,13 @@ to `theme.css` (the "Derives from" column above) — a generator never needs to know our var names, and `theme.css` + the page bundles stay untouched. -**Two rows, one contract.** `colors.css` ships both a dark default +**Two palettes, one contract.** `colors.css` ships both a dark default (Catppuccin Mocha, unconditional) and a light default (Catppuccin Latte, -behind `@media (prefers-color-scheme: light)`) — a theme generator that -overrides the file wholesale (e.g. the stylix path, which writes a single -unconditional `:root` block with no media query) supersedes both rows at -once, same as today. +behind `@media (prefers-color-scheme: light)`), plus a per-user override +path (`:root[data-theme]`) — a theme generator that overrides the file +wholesale (e.g. the stylix path, which writes a single unconditional +`:root` block with no media query, no `--mocha-*`/`--latte-*` vars, no +`[data-theme]` blocks) supersedes all of it at once, same as today. `colors.css` base16 slot defaults (Catppuccin Mocha, dark default): diff --git a/frontend/packages/shared/src/colors.css b/frontend/packages/shared/src/colors.css index 25181d13..bf78ee55 100644 --- a/frontend/packages/shared/src/colors.css +++ b/frontend/packages/shared/src/colors.css @@ -7,93 +7,174 @@ with nothing else to regenerate. Kept as its own standalone stylesheet (not @import-ed into theme.css) - so a swap replaces just this ~16-line file — `theme.css` and the page - bundles stay untouched. Every page links this BEFORE `theme.css`. The - hex defaults are Catppuccin Mocha. Standard base16 slot meanings are - in the trailing comments. See docs/web-ui/css-vars.md. */ + so a swap replaces just this file — `theme.css` and the page bundles + stay untouched. Every page links this BEFORE `theme.css`. See + docs/web-ui/css-vars.md. + + Two raw palettes, one declaration each — `--mocha-baseNN` (dark, + Catppuccin Mocha) and `--latte-baseNN` (light, Catppuccin-Latte- + derived; base08-0F are darkened from stock Latte, see the comment + above those declarations for why). The four blocks below this one + never restate a hex value — each just points the active `--baseNN` + slot at one of these two raw palettes via `var()`, so "which theme is + active" and "what a theme's colours are" can't drift apart the way a + hand-duplicated copy could. */ :root { - --base00: #1e1e2e; /* default bg */ - --base01: #181825; /* lighter bg (elevated surfaces) */ - --base02: #313244; /* selection / surface — borders */ - --base03: #45475a; /* comments / dim surface */ - --base04: #585b70; /* dark foreground */ - --base05: #cdd6f4; /* default foreground */ - --base06: #f5e0dc; /* light foreground */ - --base07: #b4befe; /* lightest */ - --base08: #f38ba8; /* red */ - --base09: #fab387; /* orange / peach */ - --base0A: #f9e2af; /* yellow */ - --base0B: #a6e3a1; /* green */ - --base0C: #89dceb; /* cyan (our sky — note: Catppuccin's base0C is teal) */ - --base0D: #89b4fa; /* blue */ - --base0E: #cba6f7; /* magenta / mauve */ - --base0F: #f5c2e7; /* extra accent — pink */ + --mocha-base00: #1e1e2e; /* default bg */ + --mocha-base01: #181825; /* lighter bg (elevated surfaces) */ + --mocha-base02: #313244; /* selection / surface — borders */ + --mocha-base03: #45475a; /* comments / dim surface */ + --mocha-base04: #585b70; /* dark foreground */ + --mocha-base05: #cdd6f4; /* default foreground */ + --mocha-base06: #f5e0dc; /* light foreground */ + --mocha-base07: #b4befe; /* lightest */ + --mocha-base08: #f38ba8; /* red */ + --mocha-base09: #fab387; /* orange / peach */ + --mocha-base0A: #f9e2af; /* yellow */ + --mocha-base0B: #a6e3a1; /* green */ + --mocha-base0C: #89dceb; /* cyan (our sky — note: Catppuccin's base0C is teal) */ + --mocha-base0D: #89b4fa; /* blue */ + --mocha-base0E: #cba6f7; /* magenta / mauve */ + --mocha-base0F: #f5c2e7; /* extra accent — pink */ + + --latte-base00: #eff1f5; /* default bg */ + --latte-base01: #e6e9ef; /* lighter bg (elevated surfaces) */ + --latte-base02: #ccd0da; /* selection / surface — borders */ + --latte-base03: #bcc0cc; /* comments / dim surface */ + --latte-base04: #acb0be; /* dark foreground */ + --latte-base05: #4c4f69; /* default foreground */ + --latte-base06: #dc8a78; /* light foreground */ + --latte-base07: #7287fd; /* lightest */ + /* base08-0F: darkened from stock Catppuccin Latte's own accent hexes + (same hue/saturation, lower HSL lightness), not a literal upstream + port like base00-07 above. A review round computed real WCAG + contrast ratios and found the literal Latte accents fail badly as + `StatusChip` fill-text (green/amber/red/yellow on + `--purple-dim`/base03: 1.4:1-3:1, need 4.5:1) and even as plain + text on `--bg`/base00 in the agent/dashboard packages that also + consume this same file (2.3:1-4.8:1). Root cause: Latte's own + accent colors are calibrated against Latte's near-white + `base`/`crust`, not against a mid-gray `surface1` — the Mocha + palette doesn't have this problem because Mocha's pastel accents + are already *light*, so they contrast fine against a *dark* + surface1; Latte's saturated-but-mid-brightness accents don't have + the equivalent headroom against Latte's own (much lighter) + surface1. Each value below was picked by holding the stock Latte + hue+saturation fixed and binary-searching HSL lightness down to + the point real contrast against base03 clears 4.5:1 with a small + margin (also verified against base00: all land above 7:1 there) — + same hue identity, same base16 role, just dark/saturated enough to + actually be legible as filled-chip or plain text either way. + Yellow and peach read closer to olive/brown than a bright + yellow/orange once darkened this far — an inherent property of + darkening a warm hue in sRGB (Bezold-Brücke shift), not a mapping + mistake; boosting saturation further didn't rescue it (verified). */ + --latte-base08: #9c0b2a; /* red */ + --latte-base09: #883201; /* orange / peach */ + --latte-base0A: #6d450e; /* yellow */ + --latte-base0B: #235818; /* green */ + --latte-base0C: #025374; /* cyan (our sky — note: Catppuccin's base0C is teal) */ + --latte-base0D: #0843b8; /* blue */ + --latte-base0E: #6311ce; /* magenta / mauve */ + --latte-base0F: #8f166e; /* extra accent — pink */ + + /* Default activation: Mocha, unless the media query below or a + per-user override (further below) says otherwise. */ + --base00: var(--mocha-base00); + --base01: var(--mocha-base01); + --base02: var(--mocha-base02); + --base03: var(--mocha-base03); + --base04: var(--mocha-base04); + --base05: var(--mocha-base05); + --base06: var(--mocha-base06); + --base07: var(--mocha-base07); + --base08: var(--mocha-base08); + --base09: var(--mocha-base09); + --base0A: var(--mocha-base0A); + --base0B: var(--mocha-base0B); + --base0C: var(--mocha-base0C); + --base0D: var(--mocha-base0D); + --base0E: var(--mocha-base0E); + --base0F: var(--mocha-base0F); } -/* Light-mode default. base00-07 (surfaces/foreground) are stock - Catppuccin Latte — no contrast issue there, they're the ends of the - bg/fg scale. base08-0F (the eight chromatic "tone" slots: red through - pink) are deliberately NOT stock Latte hexes — see the comment right - above those declarations below for why; don't reuse stock Catppuccin - Latte's own accent hexes there if this ever gets regenerated. - - This is a *default*, not a user preference store: it only applies - when nothing more specific has already decided the palette. A - stylix-generated colors.css (nix/host-modules/hive-c0re/theme.nix) - replaces this entire file wholesale — a fixed, unconditional `:root` - block with no media query in it at all — so a stylix-themed - deployment is naturally unaffected by, and never fights, this block. - Some people need light for low-vision/contrast reasons, others need - dark for photosensitivity, so respect the OS/browser signal by - default the same way `prefers-reduced-motion` already is elsewhere — - rather than always forcing the bundled dark palette on a client with - no stylix session of its own (e.g. a phone browser hitting swarm-ui - directly). A future per-user override takes precedence over this - media query by simply setting the vars later in the cascade (e.g. - via an inline `style` on `:root`, which always outranks a - stylesheet rule). */ +/* OS/browser-level light-mode default. This is a *default*, not a user + preference store: it only applies when nothing more specific has + already decided the palette. A stylix-generated colors.css + (nix/host-modules/hive-c0re/theme.nix) replaces this entire file + wholesale — a fixed, unconditional `:root` block with no media query + in it at all — so a stylix-themed deployment is naturally unaffected + by, and never fights, this block. Some people need light for + low-vision/contrast reasons, others need dark for photosensitivity, + so respect the OS/browser signal by default the same way + `prefers-reduced-motion` already is elsewhere — rather than always + forcing the bundled dark palette on a client with no stylix session + of its own (e.g. a phone browser hitting swarm-ui directly). */ @media (prefers-color-scheme: light) { :root { - --base00: #eff1f5; /* default bg */ - --base01: #e6e9ef; /* lighter bg (elevated surfaces) */ - --base02: #ccd0da; /* selection / surface — borders */ - --base03: #bcc0cc; /* comments / dim surface */ - --base04: #acb0be; /* dark foreground */ - --base05: #4c4f69; /* default foreground */ - --base06: #dc8a78; /* light foreground */ - --base07: #7287fd; /* lightest */ - /* base08-0F: darkened from stock Catppuccin Latte's own accent - hexes (same hue/saturation, lower HSL lightness), not a literal - upstream port like base00-07 above. argus's review on this PR - computed real WCAG contrast ratios and found the literal Latte - accents fail badly as `StatusChip` fill-text (green/amber/red/ - yellow on `--purple-dim`/base03: 1.4:1-3:1, need 4.5:1) and even - as plain text on `--bg`/base00 in the agent/dashboard packages - that also consume this same file (2.3:1-4.8:1). Root cause: - Latte's own accent colors are calibrated against Latte's - near-white `base`/`crust`, not against a mid-gray `surface1` — - the Mocha row doesn't have this problem because Mocha's pastel - accents are already *light*, so they contrast fine against a - *dark* surface1; Latte's saturated-but-mid-brightness accents - don't have the equivalent headroom against Latte's own - (much lighter) surface1. Each value below was picked by holding - the stock Latte hue+saturation fixed and binary-searching HSL - lightness down to the point real contrast against base03 clears - 4.5:1 with a small margin (also verified against base00: all - land above 7:1 there) — same hue identity, same base16 role, - just dark/saturated enough to actually be legible as filled-chip - or plain text either way. Yellow and peach read closer to - olive/brown than a bright yellow/orange once darkened this far — - an inherent property of darkening a warm hue in sRGB (Bezold- - Brücke shift), not a mapping mistake; boosting saturation - further didn't rescue it (verified). */ - --base08: #9c0b2a; /* red */ - --base09: #883201; /* orange / peach */ - --base0A: #6d450e; /* yellow */ - --base0B: #235818; /* green */ - --base0C: #025374; /* cyan (our sky — note: Catppuccin's base0C is teal) */ - --base0D: #0843b8; /* blue */ - --base0E: #6311ce; /* magenta / mauve */ - --base0F: #8f166e; /* extra accent — pink */ + --base00: var(--latte-base00); + --base01: var(--latte-base01); + --base02: var(--latte-base02); + --base03: var(--latte-base03); + --base04: var(--latte-base04); + --base05: var(--latte-base05); + --base06: var(--latte-base06); + --base07: var(--latte-base07); + --base08: var(--latte-base08); + --base09: var(--latte-base09); + --base0A: var(--latte-base0A); + --base0B: var(--latte-base0B); + --base0C: var(--latte-base0C); + --base0D: var(--latte-base0D); + --base0E: var(--latte-base0E); + --base0F: var(--latte-base0F); } } + +/* Per-user override (swarm-ui's `SettingsMenu` / `lib/theme-apply.ts` + sets `data-theme` on ``; unset/`"system"` removes the + attribute and lets the media query above decide, same as before this + existed). `:root[data-theme='light']` has higher specificity than + the plain `:root` inside the media query above (an attribute + selector always outranks none), so this wins regardless of the + actual OS preference or source order — no `!important`, no inline + style, and critically no second copy of either palette's hex values: + both blocks below just re-point at the same `--mocha-baseNN` / + `--latte-baseNN` custom properties declared once at the top of this + file. */ +:root[data-theme='light'] { + --base00: var(--latte-base00); + --base01: var(--latte-base01); + --base02: var(--latte-base02); + --base03: var(--latte-base03); + --base04: var(--latte-base04); + --base05: var(--latte-base05); + --base06: var(--latte-base06); + --base07: var(--latte-base07); + --base08: var(--latte-base08); + --base09: var(--latte-base09); + --base0A: var(--latte-base0A); + --base0B: var(--latte-base0B); + --base0C: var(--latte-base0C); + --base0D: var(--latte-base0D); + --base0E: var(--latte-base0E); + --base0F: var(--latte-base0F); +} +:root[data-theme='dark'] { + --base00: var(--mocha-base00); + --base01: var(--mocha-base01); + --base02: var(--mocha-base02); + --base03: var(--mocha-base03); + --base04: var(--mocha-base04); + --base05: var(--mocha-base05); + --base06: var(--mocha-base06); + --base07: var(--mocha-base07); + --base08: var(--mocha-base08); + --base09: var(--mocha-base09); + --base0A: var(--mocha-base0A); + --base0B: var(--mocha-base0B); + --base0C: var(--mocha-base0C); + --base0D: var(--mocha-base0D); + --base0E: var(--mocha-base0E); + --base0F: var(--mocha-base0F); +} diff --git a/frontend/packages/swarm-ui/src/lib/theme-apply.ts b/frontend/packages/swarm-ui/src/lib/theme-apply.ts index c5acfd98..a18608e8 100644 --- a/frontend/packages/swarm-ui/src/lib/theme-apply.ts +++ b/frontend/packages/swarm-ui/src/lib/theme-apply.ts @@ -1,22 +1,13 @@ // Applies the stored theme override (see `settings-storage.ts`) by -// setting the 16 base16 custom properties inline on `` when the -// override isn't "system", and clearing them (letting the -// `prefers-color-scheme` media query in `@hive/shared/colors.css` -// resume control) when it is. An inline style on an element always -// outranks a stylesheet rule regardless of media query or specificity — -// colors.css's own comment on its light-mode block already names this -// as the intended mechanism for a future per-user override, so this -// isn't a new architectural decision, just the promised implementation. -// -// The two palettes below are a deliberate, commented duplication of -// colors.css's two `:root` blocks, not a second source of truth for -// them — colors.css stays authoritative, this only exists because -// *outranking* a stylesheet rule needs the values inline, and CSS has -// no "read the media-query block's own values" primitive to borrow -// from instead. Both palettes are tiny (16 hex values each) and change -// rarely, so hand-keeping them in sync is a reasonable cost next to the -// alternative (restructuring colors.css's cascade to avoid the -// duplication) for a "keep it small" first cut. +// setting `data-theme="light"|"dark"` on ``, cleared when the +// override is "system" (letting the `prefers-color-scheme` media +// query in `@hive/shared/colors.css` resume control). No palette +// values live here — `colors.css`'s own `:root[data-theme='light']` / +// `:root[data-theme='dark']` blocks are what actually apply the +// colours, by re-pointing at the same `--mocha-baseNN` / +// `--latte-baseNN` custom properties its media-query block already +// uses. This file only ever toggles the attribute; the values come +// from colors.css, not a duplicate copy here. import { useEffect } from 'preact/hooks'; import { useLocalSetting } from './settings-storage.js'; @@ -28,46 +19,6 @@ export function useThemeOverride() { return useLocalSetting(THEME_OVERRIDE_KEY, 'system'); } -// Keep in sync with `frontend/packages/shared/src/colors.css`'s two -// `:root` blocks by hand — see the module comment above for why this -// duplication exists instead of a shared source. -const DARK: Record = { - '--base00': '#1e1e2e', - '--base01': '#181825', - '--base02': '#313244', - '--base03': '#45475a', - '--base04': '#585b70', - '--base05': '#cdd6f4', - '--base06': '#f5e0dc', - '--base07': '#b4befe', - '--base08': '#f38ba8', - '--base09': '#fab387', - '--base0A': '#f9e2af', - '--base0B': '#a6e3a1', - '--base0C': '#89dceb', - '--base0D': '#89b4fa', - '--base0E': '#cba6f7', - '--base0F': '#f5c2e7', -}; -const LIGHT: Record = { - '--base00': '#eff1f5', - '--base01': '#e6e9ef', - '--base02': '#ccd0da', - '--base03': '#bcc0cc', - '--base04': '#acb0be', - '--base05': '#4c4f69', - '--base06': '#dc8a78', - '--base07': '#7287fd', - '--base08': '#9c0b2a', - '--base09': '#883201', - '--base0A': '#6d450e', - '--base0B': '#235818', - '--base0C': '#025374', - '--base0D': '#0843b8', - '--base0E': '#6311ce', - '--base0F': '#8f166e', -}; - // Mounted once, high in the tree (`Shell`) — every route renders // through one ``, so one mount point applies the override // regardless of which page is showing, and `useLocalSetting`'s @@ -78,11 +29,10 @@ export function useApplyThemeOverride(): void { const [override] = useThemeOverride(); useEffect(() => { const root = document.documentElement; - const palette = override === 'light' ? LIGHT : override === 'dark' ? DARK : null; - if (!palette) { - for (const slot of Object.keys(DARK)) root.style.removeProperty(slot); - return; + if (override === 'system') { + delete root.dataset.theme; + } else { + root.dataset.theme = override; } - for (const [slot, hex] of Object.entries(palette)) root.style.setProperty(slot, hex); }, [override]); }