From 62cc0620c8f09787e6320fa846f2f9881c33eb3a Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 28 Aug 2026 01:39:32 +0200 Subject: [PATCH 1/2] Add Badge + Dropdown Preact components, demo on swarm-ui /components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New shared Preact primitives for the per-agent terminal redesign: - Badge (@hive/shared/badge.js): a labelled status pill. Plain when static (e.g. "alive"), 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) => ( + + + + ))} + + + + + + + + + +
+
); } From 638e1ac2c6863c49427caf4e8a6cfd491b3d9fef Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 28 Aug 2026 10:46:04 +0200 Subject: [PATCH 2/2] swarm-ui: delete StatusChip, migrate its 3 callers to shared Badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mara: non-interactive Badge and StatusChip render identically (same padding/radius/font, same tone-to-color mapping) and StatusChip's 4-tone/single-label shape is a strict subset of Badge's — no real reason to keep both. Deletes StatusChip.tsx/.css, migrates AgentsPage's config-PR chip, HivesPage's freshness chip, and ComponentsPage's own table-status sample to Badge (tone/value, no onClick). Also drops the now-redundant standalone StatusChip demo section on /components (the Badge section already covers all 4 former chip tones plus accent). Updated the two stale StatusChip references outside swarm-ui too: design-guide.md's component-list example and colors.css's WCAG- contrast-rationale comment. --- docs/web-ui/design-guide.md | 2 +- frontend/packages/shared/src/badge/Badge.tsx | 16 ++++++------ frontend/packages/shared/src/colors.css | 2 +- .../swarm-ui/src/pages/AgentsPage.tsx | 6 ++--- .../swarm-ui/src/pages/ComponentsPage.tsx | 15 +---------- .../packages/swarm-ui/src/pages/HivesPage.tsx | 8 +++--- .../src/ui/status-chip/StatusChip.css | 21 ---------------- .../src/ui/status-chip/StatusChip.tsx | 25 ------------------- 8 files changed, 19 insertions(+), 76 deletions(-) delete mode 100644 frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.css delete mode 100644 frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.tsx diff --git a/docs/web-ui/design-guide.md b/docs/web-ui/design-guide.md index dc24facf..a3f3f3aa 100644 --- a/docs/web-ui/design-guide.md +++ b/docs/web-ui/design-guide.md @@ -147,7 +147,7 @@ that needs it, not after it's been styled inline and left for later — a page reaching for a component that's already there has ready-made blocks to build with, instead of the next contributor duplicating ad-hoc styling that someone then has to hunt down and consolidate. -`Panel`/`StatusChip`/`Table`/`TextField`/`SelectField`/`Button` are the +`Panel`/`Badge`/`Table`/`TextField`/`SelectField`/`Button` are the primitives that exist; `/components` always has the current, complete list — this doc won't try to keep a duplicate inventory in sync. diff --git a/frontend/packages/shared/src/badge/Badge.tsx b/frontend/packages/shared/src/badge/Badge.tsx index f2f4cae7..67163d4c 100644 --- a/frontend/packages/shared/src/badge/Badge.tsx +++ b/frontend/packages/shared/src/badge/Badge.tsx @@ -10,13 +10,15 @@ // "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). +// Formerly two components: swarm-ui had its own presentational-only +// `StatusChip`, a strict subset of this shape (no interactivity, no +// label/value split, no icon). Deleted in favour of this one — a +// non-interactive `Badge` renders identically, and having two components +// for the same visual pill just meant a page had to pick between them +// with no real distinction to justify it. Lives 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). import type { ComponentChildren } from 'preact'; import './Badge.css'; diff --git a/frontend/packages/shared/src/colors.css b/frontend/packages/shared/src/colors.css index bf78ee55..4dbcb5ec 100644 --- a/frontend/packages/shared/src/colors.css +++ b/frontend/packages/shared/src/colors.css @@ -49,7 +49,7 @@ (same hue/saturation, lower HSL lightness), not a literal upstream port like base00-07 above. A review round computed real WCAG contrast ratios and found the literal Latte accents fail badly as - `StatusChip` fill-text (green/amber/red/yellow on + `Badge`/chip fill-text (green/amber/red/yellow on `--purple-dim`/base03: 1.4:1-3:1, need 4.5:1) and even as plain text on `--bg`/base00 in the agent/dashboard packages that also consume this same file (2.3:1-4.8:1). Root cause: Latte's own diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index bfcb22b7..78f20bf8 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -21,6 +21,7 @@ import { useState } from 'preact/hooks'; import { ApiErrorPanel } from '@hive/shared/api-error-panel.js'; import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js'; +import { Badge } from '@hive/shared/badge.js'; import { Button } from '../ui/button/Button.js'; import { Dialog } from '../ui/dialog/Dialog.js'; import { Panel } from '../ui/panel/Panel.js'; @@ -29,7 +30,6 @@ import { useRefreshInterval, type RefreshIntervalMs, } from '../ui/refresh-interval/RefreshInterval.js'; -import { StatusChip } from '../ui/status-chip/StatusChip.js'; import { Table, type TableColumn } from '../ui/table/Table.js'; import { CreateAgentForm } from './CreateAgentForm.js'; @@ -50,9 +50,9 @@ const COLUMNS: TableColumn[] = [ header: 'config PR', render: (a) => a.configPr ? ( - #{a.configPr.pr_number} diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index f2c1c0b5..a3a6d78d 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -10,7 +10,6 @@ import type { ComponentChildren } from 'preact'; import { Panel } from '../ui/panel/Panel.js'; import { RelativeTime } from '../ui/relative-time/RelativeTime.js'; import { RefreshIntervalPicker, type RefreshIntervalMs } from '../ui/refresh-interval/RefreshInterval.js'; -import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js'; 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'; @@ -37,8 +36,6 @@ function Sample({ label, children }: { label: string; children: ComponentChildre ); } -const CHIP_TONES: ChipTone[] = ['neutral', 'positive', 'warning', 'negative']; - interface Row { name: string; detail: string; @@ -47,7 +44,7 @@ interface Row { const TABLE_COLUMNS: TableColumn[] = [ { key: 'name', header: 'name', render: (r) => r.name }, { key: 'detail', header: 'detail', render: (r) => r.detail }, - { key: 'status', header: 'status', render: () => }, + { key: 'status', header: 'status', render: () => }, ]; const TABLE_ROWS: Row[] = [ @@ -170,16 +167,6 @@ export function ComponentsPage() { -
-
- {CHIP_TONES.map((tone) => ( - - - - ))} -
-
-
r.name} /> diff --git a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx index 369b1df4..118937d2 100644 --- a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx @@ -15,6 +15,7 @@ import { useState } from 'preact/hooks'; import { ApiErrorPanel } from '@hive/shared/api-error-panel.js'; import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js'; +import { Badge, type BadgeTone } from '@hive/shared/badge.js'; import { Panel } from '../ui/panel/Panel.js'; import { RelativeTime } from '../ui/relative-time/RelativeTime.js'; import { @@ -22,7 +23,6 @@ import { useRefreshInterval, type RefreshIntervalMs, } from '../ui/refresh-interval/RefreshInterval.js'; -import { StatusChip, type ChipTone } from '../ui/status-chip/StatusChip.js'; import { Table, type TableColumn } from '../ui/table/Table.js'; type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown'; @@ -41,7 +41,7 @@ interface HiveStatus { // One tone + label per freshness value. `unknown` (reporting but absent // from the roster) reads `negative` — it's the one case this endpoint // cannot vouch for at all, per `status.rs`'s doc comment. -const FRESHNESS: Record = { +const FRESHNESS: Record = { fresh: { tone: 'positive', label: 'fresh' }, stale: { tone: 'warning', label: 'stale' }, never_reported: { tone: 'neutral', label: 'never reported' }, @@ -68,9 +68,9 @@ const COLUMNS: TableColumn[] = [ render: (h) => { const { tone, label } = FRESHNESS[h.freshness]; return ( - {label} () diff --git a/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.css b/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.css deleted file mode 100644 index c6908552..00000000 --- a/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.css +++ /dev/null @@ -1,21 +0,0 @@ -.ui-chip { - display: inline-block; - padding: 0.15em 0.6em; - border-radius: 1em; - font-size: 0.85em; - line-height: 1.4; - background: var(--purple-dim); - color: var(--fg); -} -.ui-chip-neutral { - color: var(--muted); -} -.ui-chip-positive { - color: var(--green); -} -.ui-chip-warning { - color: var(--amber); -} -.ui-chip-negative { - color: var(--red); -} diff --git a/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.tsx b/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.tsx deleted file mode 100644 index 0db5ef24..00000000 --- a/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.tsx +++ /dev/null @@ -1,25 +0,0 @@ -// — a small labelled state indicator. Generic over -// `tone` rather than a fixed status vocabulary (`online`/`offline`/…) -// because the first real caller (the hive roster) starts with a -// single static "configured" tone and grows real online/stale/offline -// states once a status rollup lands server-side — same component, -// richer data later, no rebuild. -import type { ComponentChildren } from 'preact'; -import './StatusChip.css'; - -export type ChipTone = 'neutral' | 'positive' | 'warning' | 'negative'; - -// `label` takes renderable children, not just a string — a freshness -// chip embeds a live `` alongside its static text (e.g. -// "fresh (5s ago)" where the age keeps ticking), and a plain string -// couldn't carry that without the chip reaching back into a caller's -// formatting choices. -export function StatusChip({ - tone = 'neutral', - label, -}: { - tone?: ChipTone; - label: ComponentChildren; -}) { - return {label}; -}