// — 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 type { ComponentChildren } from "preact"; 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; /** * Consumer-owned extra row(s), rendered inside the popover after the * built-in theme/motion rows. Not a named boolean prop per setting — * a `showExpandDetails?: boolean` attempt drew mara's review: "you * cannot just add it like this - if we have more and more options * there in different places we will keep accumulating cruft in the * shared component." A caller owns its row(s) entirely (state, * storage key, markup); this component never learns they exist. Use * `settings-menu-row` (this file's CSS) on each row's outer element * to match spacing/typography — see `Root.tsx` for a worked example. */ children?: ComponentChildren; } export function SettingsMenu({ themeKey, motionKey, children, }: 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}
); }