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.
87 lines
3.1 KiB
TypeScript
87 lines
3.1 KiB
TypeScript
// <SettingsMenu> — 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<HTMLDivElement>(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 (
|
|
<div class="settings-menu" ref={rootRef}>
|
|
<button
|
|
type="button"
|
|
class="settings-menu-button"
|
|
aria-haspopup="true"
|
|
aria-expanded={open}
|
|
aria-label="settings"
|
|
onClick={() => setOpen((v) => !v)}
|
|
>
|
|
⚙
|
|
</button>
|
|
{open ? (
|
|
<div class="settings-menu-popover" role="menu">
|
|
<label class="settings-menu-row">
|
|
<span>theme</span>
|
|
<select
|
|
value={theme}
|
|
onChange={(e) => setTheme((e.target as HTMLSelectElement).value as ThemeOverride)}
|
|
>
|
|
{THEME_OPTIONS.map((o) => (
|
|
<option key={o} value={o}>
|
|
{o}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label class="settings-menu-row">
|
|
<span>motion</span>
|
|
<select
|
|
value={motion}
|
|
onChange={(e) => setMotion((e.target as HTMLSelectElement).value as MotionOverride)}
|
|
>
|
|
{MOTION_OPTIONS.map((o) => (
|
|
<option key={o} value={o}>
|
|
{o}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|