Badge's default look is a filled pill, right for status/picker chips but wrong for an icon-only header button like the settings gear or agent links trigger, which should read as chrome. Add variant='quiet' (same naming/shape as Button's ButtonVariant) that drops the idle background, keeping the existing hover/expanded feedback. Apply it to the two icon-only Badge triggers: shared SettingsMenu (used by both swarm-ui and the agent page) and the agent page's own MetaNav links trigger. Add a components-page showcase sample so the variant has a visible regression check going forward.
105 lines
4.1 KiB
TypeScript
105 lines
4.1 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 { 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<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>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|