diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 02bde7a9..1815a756 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -36,7 +36,11 @@ "./api-error-panel.js": "./src/api-error-panel/ApiErrorPanel.tsx", "./api-error-panel.css": "./src/api-error-panel/api-error-panel.css", "./warn-banner.js": "./src/warn-banner/WarnBanner.tsx", - "./warn-banner.css": "./src/warn-banner/WarnBanner.css" + "./warn-banner.css": "./src/warn-banner/WarnBanner.css", + "./badge.js": "./src/badge/Badge.tsx", + "./badge.css": "./src/badge/Badge.css", + "./dropdown.js": "./src/dropdown/Dropdown.tsx", + "./dropdown.css": "./src/dropdown/Dropdown.css" }, "files": [ "src/" diff --git a/frontend/packages/shared/src/badge/Badge.css b/frontend/packages/shared/src/badge/Badge.css new file mode 100644 index 00000000..1b9c81f6 --- /dev/null +++ b/frontend/packages/shared/src/badge/Badge.css @@ -0,0 +1,58 @@ +/* — pill-shaped status/control chip. Same touch-target floor as + the rest of the shared control set (2.75em ≈ 44px, WCAG 2.5.5) when + interactive; a plain display badge (no `onClick`) stays compact since + it's not a tap target at all. Colours are the shared base16-derived + vars (../theme.css) — `--bg-elev` is the same slot dropdowns/popovers + use, so an open Badge and the Dropdown it triggers read as one surface. */ +.ui-badge { + display: inline-flex; + align-items: center; + gap: 0.35em; + padding: 0.15em 0.6em; + border-radius: 1em; + font: inherit; + font-size: 0.85em; + line-height: 1.4; + background: var(--purple-dim); + color: var(--fg); + border: none; + white-space: nowrap; +} +.ui-badge-interactive { + cursor: pointer; + min-height: 2.75em; + padding-inline: 0.8em; +} +.ui-badge-interactive:hover { + background: var(--border); +} +.ui-badge-interactive:disabled { + opacity: 0.6; + cursor: default; +} +.ui-badge-interactive[aria-expanded='true'] { + background: var(--bg-elev); + outline: 1px solid var(--purple); +} +.ui-badge-label { + color: var(--muted); +} +.ui-badge-value { + color: var(--fg); +} +.ui-badge-caret { + font-size: 0.75em; + color: var(--muted); +} +.ui-badge-positive .ui-badge-value { + color: var(--green); +} +.ui-badge-warning .ui-badge-value { + color: var(--amber); +} +.ui-badge-negative .ui-badge-value { + color: var(--red); +} +.ui-badge-accent .ui-badge-value { + color: var(--purple); +} diff --git a/frontend/packages/shared/src/badge/Badge.tsx b/frontend/packages/shared/src/badge/Badge.tsx new file mode 100644 index 00000000..f2f4cae7 --- /dev/null +++ b/frontend/packages/shared/src/badge/Badge.tsx @@ -0,0 +1,95 @@ +// — a labelled status pill, the "next to the thing it affects" +// building block for the per-agent terminal redesign. +// Design-guide anti-example this exists to fix: the agent page's model/ +// effort *pickers* used to live in a `⋯` overflow menu while the current +// model/effort only showed as a separate, non-interactive chip — control +// disconnected from display. `Badge` is that chip PLUS, optionally, the +// control: pass `onClick` and it renders as a real ` + ); + } + return ( + + {content} + + ); +} diff --git a/frontend/packages/shared/src/dropdown/Dropdown.css b/frontend/packages/shared/src/dropdown/Dropdown.css new file mode 100644 index 00000000..50dfd860 --- /dev/null +++ b/frontend/packages/shared/src/dropdown/Dropdown.css @@ -0,0 +1,48 @@ +/* — anchored option list. `position: absolute` against the + caller's `position: relative` wrapper (see Dropdown.tsx's file-top + comment) so it hangs directly under the badge that opened it. + `--bg-elev` is the same elevated-surface slot the badge uses when + open (../badge/Badge.css), so the pair reads as one continuous panel. */ +.ui-dropdown { + position: absolute; + top: calc(100% + 0.25em); + left: 0; + z-index: 20; + display: flex; + flex-direction: column; + min-width: 12em; + padding: 0.25em; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 0.5em; + box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35); +} +.ui-dropdown-item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.1em; + min-height: 2.75em; + padding: 0.4em 0.7em; + border: none; + border-radius: 0.35em; + background: transparent; + color: var(--fg); + font: inherit; + text-align: left; + cursor: pointer; +} +.ui-dropdown-item:hover { + background: var(--border); +} +.ui-dropdown-item-active { + background: var(--purple-dim); +} +.ui-dropdown-item-active .ui-dropdown-item-label::before { + content: '✓ '; + color: var(--purple); +} +.ui-dropdown-item-desc { + font-size: 0.8em; + color: var(--muted); +} diff --git a/frontend/packages/shared/src/dropdown/Dropdown.tsx b/frontend/packages/shared/src/dropdown/Dropdown.tsx new file mode 100644 index 00000000..ab9c86b7 --- /dev/null +++ b/frontend/packages/shared/src/dropdown/Dropdown.tsx @@ -0,0 +1,79 @@ +// — a small anchored option list, meant to sit directly under +// the `Badge` that opened it (../badge/Badge.tsx) so the control is +// visually attached to the thing it affects, instead of living three +// clicks away in a catch-all menu (the junk-drawer pattern +// `docs/web-ui/design-guide.md` calls out by name against the old +// per-agent terminal's model picker). Not a native `` — a +// `showModal()` dialog's backdrop would visually detach it from its +// anchor badge, and a non-modal `show()` gets no click-outside-to-close +// for free, so this hand-rolls the same "outside click or Escape +// closes" contract `Dialog` gets from the platform. +// +// Positioning is the caller's job: render `` as a child of a +// `position: relative` wrapper (the badge + dropdown pair) and it anchors +// to that box's bottom-left via CSS. No portal — a badge in a normal +// document-flow header never needs one, and skipping it keeps focus +// management simple (no re-parenting to `` to reason about). +import { useEffect, useRef } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import './Dropdown.css'; + +export interface DropdownOption { + value: string; + label: ComponentChildren; + /** Optional dim secondary text, e.g. "sonnet (balanced)". */ + description?: ComponentChildren; +} + +export interface DropdownProps { + open: boolean; + options: DropdownOption[]; + activeValue?: string; + onSelect: (value: string) => void; + onClose: () => void; + /** `aria-label` for the option list (e.g. "select model"). */ + label: string; +} + +export function Dropdown({ open, options, activeValue, onSelect, onClose, label }: DropdownProps) { + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function handlePointerDown(e: PointerEvent) { + if (ref.current && e.target instanceof Node && !ref.current.contains(e.target)) onClose(); + } + function handleKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + // `pointerdown` (not `click`) so a drag-to-select that ends outside + // still closes; capture phase so this sees the event before a + // stopPropagation() elsewhere in the tree could swallow it. + document.addEventListener('pointerdown', handlePointerDown, true); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('pointerdown', handlePointerDown, true); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open, onClose]); + + if (!open) return null; + + return ( + + ); +} diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.css b/frontend/packages/swarm-ui/src/pages/ComponentsPage.css index 49d1e44c..eb030491 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.css +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.css @@ -42,3 +42,10 @@ font-size: 0.8em; color: var(--muted); } +/* Anchor for a Badge + the Dropdown it opens (@hive/shared) — Dropdown + positions `absolute` against this box, see Dropdown.css's file-top + comment. */ +.components-badge-anchor { + position: relative; + display: inline-block; +} diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index f719f361..f2c1c0b5 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -15,6 +15,8 @@ import { Table, type TableColumn } from '../ui/table/Table.js'; import { TextField } from '../ui/text-field/TextField.js'; import { SelectField, type SelectOption } from '../ui/select-field/SelectField.js'; import { Button, type ButtonVariant } from '../ui/button/Button.js'; +import { Badge, type BadgeTone } from '@hive/shared/badge.js'; +import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js'; import './ComponentsPage.css'; function Section({ title, children }: { title: string; children: ComponentChildren }) { @@ -59,6 +61,13 @@ const SELECT_OPTIONS: SelectOption[] = [ ]; const BUTTON_VARIANTS: ButtonVariant[] = ['primary', 'default']; +const BADGE_TONES: BadgeTone[] = ['neutral', 'positive', 'warning', 'negative', 'accent']; + +const MODEL_OPTIONS: DropdownOption[] = [ + { value: 'haiku', label: 'haiku', description: 'fast' }, + { value: 'sonnet', label: 'sonnet', description: 'balanced' }, + { value: 'opus', label: 'opus', description: 'powerful' }, +]; // Controlled samples need their own state to actually type/select into — // module-level consts can't do that, hence these two small wrappers @@ -86,6 +95,51 @@ function RefreshIntervalPickerSample() { return ; } +// The badge-triggers-a-dropdown-right-underneath-itself pattern this is +// meant to land — the model/effort picker shape from the per-agent +// terminal, demoed here with sample data. The wrapper +// needs `position: relative` (`.components-badge-anchor`) so `Dropdown` +// anchors under this badge specifically, not the page. +function BadgePickerSample() { + const [value, setValue] = useState('sonnet'); + const [open, setOpen] = useState(false); + return ( +
+ setOpen((o) => !o)} + expanded={open} + /> + { + setValue(v); + setOpen(false); + }} + onClose={() => setOpen(false)} + /> +
+ ); +} + +// The other interactive shape: a badge that's itself the control (no +// dropdown) — e.g. the agent page's pause/resume action folded into the +// status row instead of living in the `⋯` overflow menu. +function BadgeToggleSample() { + const [paused, setPaused] = useState(false); + return ( + setPaused((p) => !p)} + /> + ); +} + export function ComponentsPage() { return ( @@ -176,6 +230,25 @@ export function ComponentsPage() { + +
+
+ {BADGE_TONES.map((tone) => ( + + + + ))} + + + + + + + + + +
+
); }