From a08aacfdf6416097fe91b7453c344e8f142179b2 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 18 Aug 2026 23:43:30 +0200 Subject: [PATCH] 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(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 (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. --- .../packages/swarm-ui/src/lib/motion-apply.ts | 41 +++++++++ .../swarm-ui/src/lib/settings-storage.ts | 75 ++++++++++++++++ .../packages/swarm-ui/src/lib/theme-apply.ts | 88 +++++++++++++++++++ .../packages/swarm-ui/src/shell/LinksMenu.css | 5 +- .../swarm-ui/src/shell/SettingsMenu.css | 61 +++++++++++++ .../swarm-ui/src/shell/SettingsMenu.tsx | 87 ++++++++++++++++++ .../packages/swarm-ui/src/shell/Shell.css | 9 ++ .../packages/swarm-ui/src/shell/Shell.tsx | 21 ++++- 8 files changed, 384 insertions(+), 3 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/lib/motion-apply.ts create mode 100644 frontend/packages/swarm-ui/src/lib/settings-storage.ts create mode 100644 frontend/packages/swarm-ui/src/lib/theme-apply.ts create mode 100644 frontend/packages/swarm-ui/src/shell/SettingsMenu.css create mode 100644 frontend/packages/swarm-ui/src/shell/SettingsMenu.tsx diff --git a/frontend/packages/swarm-ui/src/lib/motion-apply.ts b/frontend/packages/swarm-ui/src/lib/motion-apply.ts new file mode 100644 index 00000000..42c19f7e --- /dev/null +++ b/frontend/packages/swarm-ui/src/lib/motion-apply.ts @@ -0,0 +1,41 @@ +// Applies the stored reduced-motion override (see `settings-storage.ts`) +// as a `data-motion="reduce"|"allow"` attribute on ``, 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(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]); +} diff --git a/frontend/packages/swarm-ui/src/lib/settings-storage.ts b/frontend/packages/swarm-ui/src/lib/settings-storage.ts new file mode 100644 index 00000000..e5b6beba --- /dev/null +++ b/frontend/packages/swarm-ui/src/lib/settings-storage.ts @@ -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 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(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(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(key: string, fallback: T): [T, (value: T) => void] { + const [value, setValue] = useState(() => readLocalSetting(key, fallback)); + + useEffect(() => subscribe(key, () => setValue(readLocalSetting(key, fallback))), [key]); + + return [value, (next: T) => writeLocalSetting(key, next)]; +} diff --git a/frontend/packages/swarm-ui/src/lib/theme-apply.ts b/frontend/packages/swarm-ui/src/lib/theme-apply.ts new file mode 100644 index 00000000..c5acfd98 --- /dev/null +++ b/frontend/packages/swarm-ui/src/lib/theme-apply.ts @@ -0,0 +1,88 @@ +// 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. +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(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 +// 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]); +} diff --git a/frontend/packages/swarm-ui/src/shell/LinksMenu.css b/frontend/packages/swarm-ui/src/shell/LinksMenu.css index ddb2fd89..268d6096 100644 --- a/frontend/packages/swarm-ui/src/shell/LinksMenu.css +++ b/frontend/packages/swarm-ui/src/shell/LinksMenu.css @@ -4,10 +4,11 @@ as the shared `ui/` kit (2.75em ≈ 44px, WCAG 2.5.5) even though it isn't built from that kit — a bespoke icon trigger, not a form control, but the floor applies regardless of which component drew - it. */ + it. No `margin-left: auto` here — `Shell.tsx`'s `.shell-header-actions` + wrapper owns pushing the whole action group to the right edge now + that `SettingsMenu` sits alongside this one. */ .links-menu { position: relative; - margin-left: auto; } .links-menu-button { display: flex; diff --git a/frontend/packages/swarm-ui/src/shell/SettingsMenu.css b/frontend/packages/swarm-ui/src/shell/SettingsMenu.css new file mode 100644 index 00000000..06c711df --- /dev/null +++ b/frontend/packages/swarm-ui/src/shell/SettingsMenu.css @@ -0,0 +1,61 @@ +/* — same header icon-button + popover chrome as + `LinksMenu` (quiet until interacted with, 2.75em touch-target floor + on the trigger). Kept as its own stylesheet rather than sharing + `LinksMenu.css` classes — two small, independently-evolving popovers + that happen to look alike today, not one component with two skins. */ +.settings-menu { + position: relative; +} +.settings-menu-button { + display: flex; + align-items: center; + justify-content: center; + width: 2.75em; + height: 2.75em; + padding: 0; + border: 1px solid transparent; + border-radius: 0.4em; + background: none; + color: var(--fg); + font-size: 1em; + line-height: 1; + cursor: pointer; +} +.settings-menu-button:hover, +.settings-menu-button[aria-expanded='true'] { + border-color: var(--border); + background: var(--bg-elev); +} +.settings-menu-popover { + position: absolute; + top: calc(100% + 0.4em); + right: 0; + z-index: 10; + display: flex; + flex-direction: column; + gap: 0.3em; + min-width: 12em; + padding: 0.5em; + border: 1px solid var(--border); + border-radius: 0.5em; + background: var(--bg-elev); + box-shadow: 0 0.25em 0.75em rgba(0, 0, 0, 0.3); +} +.settings-menu-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75em; + min-height: 2.75em; + padding: 0 0.3em; + color: var(--fg); +} +.settings-menu-row select { + min-height: 2.75em; + padding: 0 0.4em; + border: 1px solid var(--border); + border-radius: 0.35em; + background: var(--bg); + color: var(--fg); + font: inherit; +} diff --git a/frontend/packages/swarm-ui/src/shell/SettingsMenu.tsx b/frontend/packages/swarm-ui/src/shell/SettingsMenu.tsx new file mode 100644 index 00000000..1e0fb16b --- /dev/null +++ b/frontend/packages/swarm-ui/src/shell/SettingsMenu.tsx @@ -0,0 +1,87 @@ +// — a single header icon-button + popover holding the +// client-local overrides swarm-ui currently has (theme, reduced +// motion). Mirrors `LinksMenu`'s shape (icon button, popover, close on +// outside-click/Escape) — same "quiet chrome, not another nav item" +// affordance, not a coincidence: the operator, when asked how big this +// settings surface should be, said "small thing somewhere" since there +// are only two overrides right now — a full `/settings` route + nav +// entry would be over-building for two selects. Grows into a real page +// only if the setting count outgrows a popover; nothing here assumes +// it can't. +import { useEffect, useRef, useState } from 'preact/hooks'; +import { useThemeOverride, type ThemeOverride } from '../lib/theme-apply.js'; +import { useMotionOverride, type MotionOverride } from '../lib/motion-apply.js'; +import './SettingsMenu.css'; + +const THEME_OPTIONS: ThemeOverride[] = ['system', 'light', 'dark']; +const MOTION_OPTIONS: MotionOverride[] = ['system', 'allow', 'reduce']; + +export function SettingsMenu() { + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const [theme, setTheme] = useThemeOverride(); + const [motion, setMotion] = useMotionOverride(); + + // Close on an outside click or Escape — only listens while open, same + // pattern (and same rationale) as `LinksMenu`. + useEffect(() => { + if (!open) return; + function onPointerDown(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + document.addEventListener('pointerdown', onPointerDown); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + return ( +
+ + {open ? ( + + ) : null} +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.css b/frontend/packages/swarm-ui/src/shell/Shell.css index b3bfffc4..615e40f7 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.css +++ b/frontend/packages/swarm-ui/src/shell/Shell.css @@ -28,6 +28,15 @@ flex-wrap: wrap; gap: 1em; } +/* Holds `SettingsMenu` + `LinksMenu` — one `margin-left: auto` on the + wrapper, not one on each child (see Shell.tsx's comment for why two + adjacent auto margins don't sit flush together). */ +.shell-header-actions { + display: flex; + align-items: center; + gap: 0.25em; + margin-left: auto; +} /* Touch target and active-state underline are deliberately on two different elements. An earlier version put both `min-height: 2.75em` and `border-bottom` on the same box: centering the text within a diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 70538e0b..cdd5d22d 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -16,6 +16,9 @@ import { useEffect, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import { Link, useRoute } from 'wouter-preact'; import { LinksMenu } from './LinksMenu.js'; +import { SettingsMenu } from './SettingsMenu.js'; +import { useApplyThemeOverride } from '../lib/theme-apply.js'; +import { useApplyMotionOverride } from '../lib/motion-apply.js'; import './Shell.css'; const NAV_ITEMS: { href: string; label: string }[] = [ @@ -47,6 +50,15 @@ function NavLink({ href, label }: { href: string; label: string }) { export function Shell({ children }: { children: ComponentChildren }) { const [swarmName, setSwarmName] = useState(null); + // Applied once here, not inside `SettingsMenu` — every route mounts + // through this one ``, so the override takes effect regardless + // of which page is showing or whether the menu's ever been opened, + // and `useLocalSetting`'s same-tab subscription (settings-storage.ts) + // means `SettingsMenu` changing the stored value re-runs these + // effects without either component needing a reference to the other. + useApplyThemeOverride(); + useApplyMotionOverride(); + // Fetched once here, not per-page: every route mounts inside one // ``, and the swarm's name doesn't change within a page // visit. A fetch failure is silently ignored — `swarmName` just stays @@ -78,7 +90,14 @@ export function Shell({ children }: { children: ComponentChildren }) { ))} - + {/* Single `margin-left: auto` on the wrapper, not on each menu + individually — two adjacent flex items both set to + `margin-left: auto` split the available space between them + instead of sitting flush together at the right edge. */} +
+ + +
{children}