Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
134 lines
5 KiB
TypeScript
134 lines
5 KiB
TypeScript
// <SettingsMenu> — 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<HTMLDivElement>(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 (
|
|
<div class="settings-menu-anchor" ref={rootRef}>
|
|
<Badge
|
|
value={<GearIcon />}
|
|
title="settings"
|
|
variant="quiet"
|
|
onClick={() => setOpen((v) => !v)}
|
|
expanded={open}
|
|
/>
|
|
{open ? (
|
|
<div class="settings-menu-popover" role="menu" aria-label="settings">
|
|
<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>
|
|
{children}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|