LinksMenu and SettingsMenu triggers mixed a full-colour emoji (link) with a plain text glyph (gear) - different rendering paths mean different, unfixable sizes/styles. Replace both with matching inline SVG icons (feather/lucide gear + link glyphs), same viewBox/stroke/ size, so the two buttons finally share one rendering path. Also: shared base.css never zeroed the default UA body margin, which showed as a bg-coloured strip around the whole viewport edge on any full-bleed header (swarm-uis .shell-header among them). Zeroed it in the shared file so every consumer (dashboard, agent UI, swarm-ui) gets the fix, not just swarm-ui. Screenshot-verified at both desktop and phone widths.
108 lines
4.7 KiB
TypeScript
108 lines
4.7 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)}
|
|
>
|
|
{/* Feather/lucide "settings" glyph, not the bare `⚙` character —
|
|
reported live by mara: icon styles/sizes inconsistent between
|
|
this button and LinksMenu's. `⚙` is a plain text glyph so it does
|
|
scale with `font-size`/`color`, but its exact shape and
|
|
optical weight are down to whatever font the browser
|
|
substitutes for that one codepoint — not guaranteed to match
|
|
an SVG icon at all, which is what LinksMenu's link glyph is
|
|
now built from too. One shared rendering path for both. */}
|
|
<svg
|
|
width="1.3em"
|
|
height="1.3em"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="12" cy="12" r="3" />
|
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
|
</svg>
|
|
</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>
|
|
);
|
|
}
|