swarm-ui: derive theme override from colors.css instead of duplicating hex values

mara's review: the theme override read as a hand-copied duplicate of
colors.css's hex values instead of deriving from it. Restructured
colors.css to declare each palette's 16 hex values exactly once
(--mocha-baseNN, --latte-baseNN) and have every activation block
(the default, the prefers-color-scheme media query, and two new
:root[data-theme='light'|'dark'] blocks) just re-point the active
--baseNN slot at one of those two raw palettes via var() - never a
second copy of a hex value.

theme-apply.ts simplifies to match: it now only toggles a data-theme
attribute on <html>, same shape motion-apply.ts already had. No
palette values live in JS at all anymore.

Re-verified the override still genuinely outranks the media query
with the new mechanism: same seed-localStorage-while-forcing-the-
opposite-OS-preference test as before, both directions still render
the stored override correctly.
This commit is contained in:
iris 2026-08-18 23:53:42 +02:00 committed by mara
commit afe627e0a9
3 changed files with 200 additions and 163 deletions

View file

@ -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):

View file

@ -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 `<html>`; 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);
}

View file

@ -1,22 +1,13 @@
// Applies the stored theme override (see `settings-storage.ts`) by
// setting the 16 base16 custom properties inline on `<html>` 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 `<html>`, 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<ThemeOverride>(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<string, string> = {
'--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<string, string> = {
'--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 `<Shell>`, 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]);
}