Add Badge + Dropdown Preact components, demo on swarm-ui /components

New shared Preact primitives for the per-agent terminal redesign:

- Badge (@hive/shared/badge.js): a labelled status pill. Plain <span>
  when static (e.g. "alive"), a real <button> with a disclosure caret
  when given onClick (e.g. opens a Dropdown, or toggles itself in
  place). Same component either way so a status row reads as one
  consistent set of badges regardless of which are interactive.
- Dropdown (@hive/shared/dropdown.js): a small option list anchored
  directly under whatever opened it (no portal, no native <dialog> —
  see the file comment for why). Closes on outside click or Escape.

This is the fix for the design guide's own named anti-example: the
agent page's model/effort pickers live in the overflow menu while the
current model/effort only show as a disconnected chip. Badge+Dropdown
composed together is that control moved inline, next to what it shows.

Demoed on swarm-ui's /components page: all Badge tones, a label-prefixed
badge, an interactive badge that opens a Dropdown (model-picker shape),
and an interactive badge that toggles itself (pause/resume shape).

Both packages build + tsc --noEmit clean.
This commit is contained in:
iris 2026-08-28 01:39:32 +02:00 committed by mara
commit 62cc0620c8
7 changed files with 365 additions and 1 deletions

View file

@ -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/"

View file

@ -0,0 +1,58 @@
/* <Badge> 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);
}

View file

@ -0,0 +1,95 @@
// <Badge> — 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 `<button>` (so it can
// open a `Dropdown` right underneath itself, or toggle in place — e.g.
// pause/resume), pass nothing and it stays a plain status `<span>` (e.g.
// "alive"). Same component either way so a page reads as one consistent
// row of badges regardless of which ones happen to be interactive.
//
// Distinct from swarm-ui's own `StatusChip` (`swarm-ui/src/ui/status-chip/`):
// that one is presentational-only and swarm-ui-local. This one lives here
// in `shared` because both swarm-ui *and* the per-agent page need the
// interactive shape, and `shared` is the one package both already depend
// on (see `docs/web-ui/design-guide.md`'s component-first + junk-drawer
// rules — this is the same visual language, applied where it's actually
// consumed).
import type { ComponentChildren } from 'preact';
import './Badge.css';
export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accent';
export interface BadgeProps {
/** Dim prefix text, e.g. "model". Omit for a single-value badge like "alive". */
label?: ComponentChildren;
/** The value itself, e.g. "sonnet". Required — a badge always shows something. */
value: ComponentChildren;
tone?: BadgeTone;
/** Decorative glyph before the label; `aria-hidden`, the text is still the real label. */
icon?: ComponentChildren;
/**
* Present renders a `<button>` (real control, min touch target) and
* shows a disclosure caret. Absent renders a plain `<span>` (status
* display only, e.g. "alive"/"idle 6m").
*/
onClick?: (e: MouseEvent) => void;
/** Only meaningful with `onClick` — drives the caret direction + `aria-expanded`. */
expanded?: boolean;
disabled?: boolean;
class?: string;
title?: string;
}
export function Badge({
label,
value,
tone = 'neutral',
icon,
onClick,
expanded,
disabled,
class: extraClass,
title,
}: BadgeProps) {
const classes = ['ui-badge', `ui-badge-${tone}`, onClick && 'ui-badge-interactive', extraClass]
.filter(Boolean)
.join(' ');
const content = (
<>
{icon ? (
<span class="ui-badge-icon" aria-hidden="true">
{icon}
</span>
) : null}
{label ? <span class="ui-badge-label">{label}</span> : null}
<span class="ui-badge-value">{value}</span>
{onClick ? (
<span class="ui-badge-caret" aria-hidden="true">
{expanded ? '▴' : '▾'}
</span>
) : null}
</>
);
if (onClick) {
return (
<button
type="button"
class={classes}
onClick={onClick}
disabled={disabled}
aria-expanded={expanded}
title={title}
>
{content}
</button>
);
}
return (
<span class={classes} title={title}>
{content}
</span>
);
}

View file

@ -0,0 +1,48 @@
/* <Dropdown> 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);
}

View file

@ -0,0 +1,79 @@
// <Dropdown> — 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 `<dialog>` — 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 `<Dropdown>` 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 `<body>` 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<HTMLDivElement>(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 (
<div class="ui-dropdown" role="menu" aria-label={label} ref={ref}>
{options.map((opt) => (
<button
type="button"
key={opt.value}
role="menuitemradio"
aria-checked={opt.value === activeValue}
class={'ui-dropdown-item' + (opt.value === activeValue ? ' ui-dropdown-item-active' : '')}
onClick={() => onSelect(opt.value)}
>
<span class="ui-dropdown-item-label">{opt.label}</span>
{opt.description ? <span class="ui-dropdown-item-desc">{opt.description}</span> : null}
</button>
))}
</div>
);
}

View file

@ -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;
}

View file

@ -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 <RefreshIntervalPicker id="sample-refresh-interval" value={value} onChange={setValue} />;
}
// 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 (
<div class="components-badge-anchor">
<Badge
label="model"
value={value}
onClick={() => setOpen((o) => !o)}
expanded={open}
/>
<Dropdown
open={open}
options={MODEL_OPTIONS}
activeValue={value}
label="select model"
onSelect={(v) => {
setValue(v);
setOpen(false);
}}
onClose={() => setOpen(false)}
/>
</div>
);
}
// 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 (
<Badge
value={paused ? '▶ resume' : '⏸ pause'}
tone={paused ? 'warning' : 'neutral'}
onClick={() => setPaused((p) => !p)}
/>
);
}
export function ComponentsPage() {
return (
<Panel title="components" icon="🧰">
@ -176,6 +230,25 @@ export function ComponentsPage() {
</Sample>
</div>
</Section>
<Section title="Badge (@hive/shared — used by swarm-ui and the per-agent page)">
<div class="components-chip-row">
{BADGE_TONES.map((tone) => (
<Sample key={tone} label={`display, ${tone}`}>
<Badge value={tone} tone={tone} />
</Sample>
))}
<Sample label="with a label prefix">
<Badge label="ctx" value="534k" />
</Sample>
<Sample label="interactive, opens a Dropdown (model/effort picker shape)">
<BadgePickerSample />
</Sample>
<Sample label="interactive, toggles itself (pause/resume shape)">
<BadgeToggleSample />
</Sample>
</div>
</Section>
</Panel>
);
}