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:
parent
5ca5433e0b
commit
a08aacfdf6
8 changed files with 384 additions and 3 deletions
|
|
@ -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;
|
||||
|
|
|
|||
61
frontend/packages/swarm-ui/src/shell/SettingsMenu.css
Normal file
61
frontend/packages/swarm-ui/src/shell/SettingsMenu.css
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/* <SettingsMenu> — 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;
|
||||
}
|
||||
87
frontend/packages/swarm-ui/src/shell/SettingsMenu.tsx
Normal file
87
frontend/packages/swarm-ui/src/shell/SettingsMenu.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// <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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
|
||||
// Applied once here, not inside `SettingsMenu` — every route mounts
|
||||
// through this one `<Shell>`, 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
|
||||
// `<Shell>`, 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 }) {
|
|||
<NavLink key={item.href} href={item.href} label={item.label} />
|
||||
))}
|
||||
</nav>
|
||||
<LinksMenu />
|
||||
{/* 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. */}
|
||||
<div class="shell-header-actions">
|
||||
<SettingsMenu />
|
||||
<LinksMenu />
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell-body">{children}</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue