// — a single header icon-button + popover holding the // client-local overrides a page has (theme, reduced motion). Originated // in swarm-ui, duplicated to the agent terminal page (mara: "agent // terminal page should get the settings panel from swarm ui as well"), // then unified here per her review on that PR: "i think the component // should be shared. motion setting is missing." — one component, both // pages import it, motion included unconditionally rather than // agent-only. // // Trigger is `Badge` (`../badge/Badge.js`) with `../icons.js`'s // `GearIcon` — an inline SVG, not a text/emoji glyph (see that file's // comment for why a glyph is the wrong tool for a trigger's own icon; // this file went through one regression on exactly that, caught by // mara: "settings icon looks weird since component extract"). // `variant="quiet"` drops `Badge`'s default filled-pill look, which is // right for a status/picker chip but wrong for this icon-only header // button (mara again: "link and settings button should not have the // badge bg") — see `Badge.tsx`'s `BadgeVariant` comment. // // Storage keys are the caller's responsibility (`themeKey`/`motionKey` // props), not derived here — a page mounts `useApplyThemeOverride`/ // `useApplyMotionOverride` (`./theme-apply.js`/`./motion-apply.js`) // once, high in its tree, with the SAME key strings passed to this // component, so the two stay in sync without this component owning any // page-specific naming decision. import { useEffect, useRef, useState } from 'preact/hooks'; import { Badge } from '../badge/Badge.js'; import { GearIcon } from '../icons.js'; import { useThemeOverride, type ThemeOverride } from './theme-apply.js'; import { useMotionOverride, type MotionOverride } from './motion-apply.js'; import './SettingsMenu.css'; const THEME_OPTIONS: ThemeOverride[] = ['system', 'light', 'dark']; const MOTION_OPTIONS: MotionOverride[] = ['system', 'allow', 'reduce']; export interface SettingsMenuProps { themeKey: string; motionKey: string; } export function SettingsMenu({ themeKey, motionKey }: SettingsMenuProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const [theme, setTheme] = useThemeOverride(themeKey); const [motion, setMotion] = useMotionOverride(motionKey); // Close on an outside click or Escape — only listens while open. useEffect(() => { if (!open) return; function onPointerDown(e: PointerEvent) { if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false); } function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false); } document.addEventListener('pointerdown', onPointerDown, true); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('pointerdown', onPointerDown, true); document.removeEventListener('keydown', onKeyDown); }; }, [open]); return (
} title="settings" variant="quiet" onClick={() => setOpen((v) => !v)} expanded={open} /> {open ? ( ) : null}
); }