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

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