swarm-ui: client-local settings surface (theme + reduced-motion overrides)

Adds the settings surface + storage plumbing swarm-ui has been missing:
nowhere to put a client-local preference and no shared code for one to
build on. Scoped small per explicit direction ("small thing somewhere",
localStorage, theme and motion in scope for now) rather than a full
/settings route + nav entry for two toggles.

- frontend/packages/swarm-ui/src/lib/settings-storage.ts: generic
  useLocalSetting<T>(key, fallback) hook - read once, write through,
  stay in sync with other same-tab consumers of the same key via a
  small module-level pub/sub (localStorage's own storage event only
  fires cross-tab).
- frontend/packages/swarm-ui/src/lib/theme-apply.ts: tri-state
  system/light/dark override, applied by setting the 16 base16 custom
  properties inline on <html> (an inline style always outranks a
  stylesheet rule, including a media-query-gated one) - colors.css's
  own comment on its light-mode block already named this as the
  intended mechanism for a future override.
- frontend/packages/swarm-ui/src/lib/motion-apply.ts: tri-state
  system/reduce/allow override, applied as a data-motion attribute.
  Currently inert - swarm-ui has zero CSS animations yet - included
  because the marginal cost riding alongside the theme override is
  near zero and it was named in the same scoping answer; the first
  swarm-ui animation's own CSS is what makes this do anything.
- frontend/packages/swarm-ui/src/shell/SettingsMenu.{tsx,css}: a
  header icon-button + popover holding both selects, same shape as
  LinksMenu (manages its own state, not a ui/ primitive, hence no
  ComponentsPage demo - same exception LinksMenu already established).
- Shell.tsx/.css: mounts the two override-application hooks once
  (every route renders through one Shell), and wraps SettingsMenu +
  LinksMenu in a single .shell-header-actions flex wrapper so one
  margin-left: auto pushes both to the right edge together - two
  adjacent auto-margins on separate elements split the space between
  them instead of sitting flush.

Verified the override actually outranks the media query, not just
"looks right": seeded localStorage with each override value while
forcing the opposite OS-level prefers-color-scheme via headless
chromium, both directions render the stored override, not the forced
OS preference. Typecheck and build clean.
This commit is contained in:
iris 2026-08-18 23:43:30 +02:00 committed by mara
commit a08aacfdf6
8 changed files with 384 additions and 3 deletions

View file

@ -0,0 +1,41 @@
// Applies the stored reduced-motion override (see `settings-storage.ts`)
// as a `data-motion="reduce"|"allow"` attribute on `<html>`, cleared
// when the override is "system" (letting `prefers-reduced-motion`
// alone decide, same as today).
//
// Currently inert: swarm-ui has zero CSS animations as of this file's
// writing (the design guide's "playful whimsy" motion — the matrix-rain
// home background — lives in the dashboard package, not swarm-ui). This
// hook exists because the settings surface itself was scoped to cover
// theme *and* motion together (both named in the same go-ahead), and the
// marginal cost of the storage/attribute plumbing is near zero riding
// alongside the theme override's real wiring — but there is genuinely
// nothing in swarm-ui for `data-motion` to gate yet. The first swarm-ui
// animation's own CSS is what makes this do anything, e.g.:
// @media (prefers-reduced-motion: reduce) { ... }
// :root[data-motion='reduce'] { ... same rule ... }
// :root[data-motion='allow'] { /* opt back in despite OS-level reduce */ }
import { useEffect } from 'preact/hooks';
import { useLocalSetting } from './settings-storage.js';
export type MotionOverride = 'system' | 'reduce' | 'allow';
export const MOTION_OVERRIDE_KEY = 'swarm-ui:motion-override';
export function useMotionOverride() {
return useLocalSetting<MotionOverride>(MOTION_OVERRIDE_KEY, 'system');
}
// Mounted once alongside `useApplyThemeOverride` — see that module's
// comment for why `Shell` is the right single mount point.
export function useApplyMotionOverride(): void {
const [override] = useMotionOverride();
useEffect(() => {
const root = document.documentElement;
if (override === 'system') {
delete root.dataset.motion;
} else {
root.dataset.motion = override;
}
}, [override]);
}

View file

@ -0,0 +1,75 @@
// Generic localStorage-backed setting — the shared plumbing the theme
// override and reduced-motion override both need ("read a stored value,
// react to it changing, write a new one") rather than each reinventing
// its own key handling. Not settings-page-specific: any future
// client-local preference reaches for `useLocalSetting` directly.
//
// `localStorage`'s own `storage` event only fires in *other* tabs, never
// the tab that made the write — so two components in the same tab both
// watching the same key (e.g. `SettingsMenu` writing, a top-level effect
// reading) need their own same-tab signal. The tiny module-level pub/sub
// below is that signal; it's deliberately not exported, callers only see
// the hook.
import { useEffect, useState } from 'preact/hooks';
const listeners = new Map<string, Set<() => void>>();
function subscribe(key: string, onChange: () => void): () => void {
let set = listeners.get(key);
if (!set) {
set = new Set();
listeners.set(key, set);
}
set.add(onChange);
return () => {
set!.delete(onChange);
if (set!.size === 0) listeners.delete(key);
};
}
function notify(key: string): void {
listeners.get(key)?.forEach((fn) => fn());
}
// Reads outside a component (e.g. an early top-level check) can call
// this directly; `useLocalSetting` builds on it rather than duplicating
// the try/catch.
export function readLocalSetting<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : (JSON.parse(raw) as T);
} catch {
// Private-browsing storage bans, a corrupted value, quota errors —
// all the same answer: behave as if nothing were stored.
return fallback;
}
}
function writeLocalSetting<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// Best-effort: the setting just won't persist this time, not worth
// surfacing an error for a client-local preference.
}
notify(key);
}
// `[value, setValue]`, same shape as `useState` — deliberately, so a
// caller that later needs to swap a plain `useState` for a persisted
// setting (or vice versa) changes one line, not its whole call site.
//
// `setValue` only writes + notifies; it doesn't also call this
// component's own `setValue` state setter directly. The subscription
// below already fires for a same-instance write (it's in the same
// `listeners` set as every other subscriber), so a self-write reaches
// this component's state through the identical "react to a change"
// path every other subscriber uses — one path, not two that have to
// agree.
export function useLocalSetting<T>(key: string, fallback: T): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => readLocalSetting(key, fallback));
useEffect(() => subscribe(key, () => setValue(readLocalSetting(key, fallback))), [key]);
return [value, (next: T) => writeLocalSetting(key, next)];
}

View file

@ -0,0 +1,88 @@
// 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.
import { useEffect } from 'preact/hooks';
import { useLocalSetting } from './settings-storage.js';
export type ThemeOverride = 'system' | 'light' | 'dark';
export const THEME_OVERRIDE_KEY = 'swarm-ui:theme-override';
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
// same-tab subscription means `SettingsMenu` changing the value
// re-runs this effect without either component needing to know about
// the other directly.
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;
}
for (const [slot, hex] of Object.entries(palette)) root.style.setProperty(slot, hex);
}, [override]);
}