diff --git a/docs/web-ui/design-guide.md b/docs/web-ui/design-guide.md index a3f3f3aa..dc24facf 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`/`Badge`/`Table`/`TextField`/`SelectField`/`Button` are the +`Panel`/`StatusChip`/`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/package.json b/frontend/packages/shared/package.json index 1815a756..02bde7a9 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -36,11 +36,7 @@ "./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", - "./badge.js": "./src/badge/Badge.tsx", - "./badge.css": "./src/badge/Badge.css", - "./dropdown.js": "./src/dropdown/Dropdown.tsx", - "./dropdown.css": "./src/dropdown/Dropdown.css" + "./warn-banner.css": "./src/warn-banner/WarnBanner.css" }, "files": [ "src/" diff --git a/frontend/packages/shared/src/badge/Badge.css b/frontend/packages/shared/src/badge/Badge.css deleted file mode 100644 index 1b9c81f6..00000000 --- a/frontend/packages/shared/src/badge/Badge.css +++ /dev/null @@ -1,58 +0,0 @@ -/* — 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 deleted file mode 100644 index 67163d4c..00000000 --- a/frontend/packages/shared/src/badge/Badge.tsx +++ /dev/null @@ -1,97 +0,0 @@ -// — 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/colors.css b/frontend/packages/shared/src/colors.css index 4dbcb5ec..bf78ee55 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 - `Badge`/chip fill-text (green/amber/red/yellow on + `StatusChip` 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/shared/src/dropdown/Dropdown.css b/frontend/packages/shared/src/dropdown/Dropdown.css deleted file mode 100644 index 50dfd860..00000000 --- a/frontend/packages/shared/src/dropdown/Dropdown.css +++ /dev/null @@ -1,48 +0,0 @@ -/* — 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 deleted file mode 100644 index ab9c86b7..00000000 --- a/frontend/packages/shared/src/dropdown/Dropdown.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// — 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/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index 78f20bf8..bfcb22b7 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -21,7 +21,6 @@ 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'; @@ -30,6 +29,7 @@ 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.css b/frontend/packages/swarm-ui/src/pages/ComponentsPage.css index eb030491..49d1e44c 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.css +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.css @@ -42,10 +42,3 @@ 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 a3a6d78d..f719f361 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -10,12 +10,11 @@ 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'; 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 }) { @@ -36,6 +35,8 @@ function Sample({ label, children }: { label: string; children: ComponentChildre ); } +const CHIP_TONES: ChipTone[] = ['neutral', 'positive', 'warning', 'negative']; + interface Row { name: string; detail: string; @@ -44,7 +45,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[] = [ @@ -58,13 +59,6 @@ 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 @@ -92,51 +86,6 @@ 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 ( @@ -167,6 +116,16 @@ export function ComponentsPage() { +
+
+ {CHIP_TONES.map((tone) => ( + + + + ))} +
+
+
r.name} /> @@ -217,25 +176,6 @@ export function ComponentsPage() { - -
-
- {BADGE_TONES.map((tone) => ( - - - - ))} - - - - - - - - - -
-
); } diff --git a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx index 118937d2..369b1df4 100644 --- a/frontend/packages/swarm-ui/src/pages/HivesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/HivesPage.tsx @@ -15,7 +15,6 @@ 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 { @@ -23,6 +22,7 @@ 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 new file mode 100644 index 00000000..c6908552 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.css @@ -0,0 +1,21 @@ +.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 new file mode 100644 index 00000000..0db5ef24 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/status-chip/StatusChip.tsx @@ -0,0 +1,25 @@ +// — 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}; +}