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.
75 lines
2.9 KiB
TypeScript
75 lines
2.9 KiB
TypeScript
// 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)];
|
|
}
|