Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
638e1ac2c6 | ||
|
|
62cc0620c8 |
13 changed files with 377 additions and 70 deletions
|
|
@ -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
|
a page reaching for a component that's already there has ready-made
|
||||||
blocks to build with, instead of the next contributor duplicating
|
blocks to build with, instead of the next contributor duplicating
|
||||||
ad-hoc styling that someone then has to hunt down and consolidate.
|
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
|
primitives that exist; `/components` always has the current, complete
|
||||||
list — this doc won't try to keep a duplicate inventory in sync.
|
list — this doc won't try to keep a duplicate inventory in sync.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,11 @@
|
||||||
"./api-error-panel.js": "./src/api-error-panel/ApiErrorPanel.tsx",
|
"./api-error-panel.js": "./src/api-error-panel/ApiErrorPanel.tsx",
|
||||||
"./api-error-panel.css": "./src/api-error-panel/api-error-panel.css",
|
"./api-error-panel.css": "./src/api-error-panel/api-error-panel.css",
|
||||||
"./warn-banner.js": "./src/warn-banner/WarnBanner.tsx",
|
"./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": [
|
"files": [
|
||||||
"src/"
|
"src/"
|
||||||
|
|
|
||||||
58
frontend/packages/shared/src/badge/Badge.css
Normal file
58
frontend/packages/shared/src/badge/Badge.css
Normal 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);
|
||||||
|
}
|
||||||
97
frontend/packages/shared/src/badge/Badge.tsx
Normal file
97
frontend/packages/shared/src/badge/Badge.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// <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.
|
||||||
|
//
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
(same hue/saturation, lower HSL lightness), not a literal upstream
|
(same hue/saturation, lower HSL lightness), not a literal upstream
|
||||||
port like base00-07 above. A review round computed real WCAG
|
port like base00-07 above. A review round computed real WCAG
|
||||||
contrast ratios and found the literal Latte accents fail badly as
|
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
|
`--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
|
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
|
consume this same file (2.3:1-4.8:1). Root cause: Latte's own
|
||||||
|
|
|
||||||
48
frontend/packages/shared/src/dropdown/Dropdown.css
Normal file
48
frontend/packages/shared/src/dropdown/Dropdown.css
Normal 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);
|
||||||
|
}
|
||||||
79
frontend/packages/shared/src/dropdown/Dropdown.tsx
Normal file
79
frontend/packages/shared/src/dropdown/Dropdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
||||||
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.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 { Button } from '../ui/button/Button.js';
|
||||||
import { Dialog } from '../ui/dialog/Dialog.js';
|
import { Dialog } from '../ui/dialog/Dialog.js';
|
||||||
import { Panel } from '../ui/panel/Panel.js';
|
import { Panel } from '../ui/panel/Panel.js';
|
||||||
|
|
@ -29,7 +30,6 @@ import {
|
||||||
useRefreshInterval,
|
useRefreshInterval,
|
||||||
type RefreshIntervalMs,
|
type RefreshIntervalMs,
|
||||||
} from '../ui/refresh-interval/RefreshInterval.js';
|
} from '../ui/refresh-interval/RefreshInterval.js';
|
||||||
import { StatusChip } from '../ui/status-chip/StatusChip.js';
|
|
||||||
import { Table, type TableColumn } from '../ui/table/Table.js';
|
import { Table, type TableColumn } from '../ui/table/Table.js';
|
||||||
import { CreateAgentForm } from './CreateAgentForm.js';
|
import { CreateAgentForm } from './CreateAgentForm.js';
|
||||||
|
|
||||||
|
|
@ -50,9 +50,9 @@ const COLUMNS: TableColumn<AgentRow>[] = [
|
||||||
header: 'config PR',
|
header: 'config PR',
|
||||||
render: (a) =>
|
render: (a) =>
|
||||||
a.configPr ? (
|
a.configPr ? (
|
||||||
<StatusChip
|
<Badge
|
||||||
tone="warning"
|
tone="warning"
|
||||||
label={
|
value={
|
||||||
a.configPr.html_url ? (
|
a.configPr.html_url ? (
|
||||||
<a href={a.configPr.html_url} target="_blank" rel="noreferrer">
|
<a href={a.configPr.html_url} target="_blank" rel="noreferrer">
|
||||||
#{a.configPr.pr_number}
|
#{a.configPr.pr_number}
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,10 @@
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: var(--muted);
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,12 @@ import type { ComponentChildren } from 'preact';
|
||||||
import { Panel } from '../ui/panel/Panel.js';
|
import { Panel } from '../ui/panel/Panel.js';
|
||||||
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
|
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
|
||||||
import { RefreshIntervalPicker, type RefreshIntervalMs } from '../ui/refresh-interval/RefreshInterval.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 { Table, type TableColumn } from '../ui/table/Table.js';
|
||||||
import { TextField } from '../ui/text-field/TextField.js';
|
import { TextField } from '../ui/text-field/TextField.js';
|
||||||
import { SelectField, type SelectOption } from '../ui/select-field/SelectField.js';
|
import { SelectField, type SelectOption } from '../ui/select-field/SelectField.js';
|
||||||
import { Button, type ButtonVariant } from '../ui/button/Button.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';
|
import './ComponentsPage.css';
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: ComponentChildren }) {
|
function Section({ title, children }: { title: string; children: ComponentChildren }) {
|
||||||
|
|
@ -35,8 +36,6 @@ function Sample({ label, children }: { label: string; children: ComponentChildre
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHIP_TONES: ChipTone[] = ['neutral', 'positive', 'warning', 'negative'];
|
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
name: string;
|
name: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
|
|
@ -45,7 +44,7 @@ interface Row {
|
||||||
const TABLE_COLUMNS: TableColumn<Row>[] = [
|
const TABLE_COLUMNS: TableColumn<Row>[] = [
|
||||||
{ key: 'name', header: 'name', render: (r) => r.name },
|
{ key: 'name', header: 'name', render: (r) => r.name },
|
||||||
{ key: 'detail', header: 'detail', render: (r) => r.detail },
|
{ key: 'detail', header: 'detail', render: (r) => r.detail },
|
||||||
{ key: 'status', header: 'status', render: () => <StatusChip tone="positive" label="ok" /> },
|
{ key: 'status', header: 'status', render: () => <Badge tone="positive" value="ok" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const TABLE_ROWS: Row[] = [
|
const TABLE_ROWS: Row[] = [
|
||||||
|
|
@ -59,6 +58,13 @@ const SELECT_OPTIONS: SelectOption[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const BUTTON_VARIANTS: ButtonVariant[] = ['primary', 'default'];
|
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 —
|
// Controlled samples need their own state to actually type/select into —
|
||||||
// module-level consts can't do that, hence these two small wrappers
|
// module-level consts can't do that, hence these two small wrappers
|
||||||
|
|
@ -86,6 +92,51 @@ function RefreshIntervalPickerSample() {
|
||||||
return <RefreshIntervalPicker id="sample-refresh-interval" value={value} onChange={setValue} />;
|
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() {
|
export function ComponentsPage() {
|
||||||
return (
|
return (
|
||||||
<Panel title="components" icon="🧰">
|
<Panel title="components" icon="🧰">
|
||||||
|
|
@ -116,16 +167,6 @@ export function ComponentsPage() {
|
||||||
</Sample>
|
</Sample>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="StatusChip">
|
|
||||||
<div class="components-chip-row">
|
|
||||||
{CHIP_TONES.map((tone) => (
|
|
||||||
<Sample key={tone} label={tone}>
|
|
||||||
<StatusChip tone={tone} label={tone} />
|
|
||||||
</Sample>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Table">
|
<Section title="Table">
|
||||||
<Sample label="populated">
|
<Sample label="populated">
|
||||||
<Table columns={TABLE_COLUMNS} rows={TABLE_ROWS} rowKey={(r) => r.name} />
|
<Table columns={TABLE_COLUMNS} rows={TABLE_ROWS} rowKey={(r) => r.name} />
|
||||||
|
|
@ -176,6 +217,25 @@ export function ComponentsPage() {
|
||||||
</Sample>
|
</Sample>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</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>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
|
||||||
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.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 { Panel } from '../ui/panel/Panel.js';
|
||||||
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
|
import { RelativeTime } from '../ui/relative-time/RelativeTime.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,7 +23,6 @@ import {
|
||||||
useRefreshInterval,
|
useRefreshInterval,
|
||||||
type RefreshIntervalMs,
|
type RefreshIntervalMs,
|
||||||
} from '../ui/refresh-interval/RefreshInterval.js';
|
} 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 { Table, type TableColumn } from '../ui/table/Table.js';
|
||||||
|
|
||||||
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
|
type Freshness = 'fresh' | 'stale' | 'never_reported' | 'unknown';
|
||||||
|
|
@ -41,7 +41,7 @@ interface HiveStatus {
|
||||||
// One tone + label per freshness value. `unknown` (reporting but absent
|
// One tone + label per freshness value. `unknown` (reporting but absent
|
||||||
// from the roster) reads `negative` — it's the one case this endpoint
|
// from the roster) reads `negative` — it's the one case this endpoint
|
||||||
// cannot vouch for at all, per `status.rs`'s doc comment.
|
// cannot vouch for at all, per `status.rs`'s doc comment.
|
||||||
const FRESHNESS: Record<Freshness, { tone: ChipTone; label: string }> = {
|
const FRESHNESS: Record<Freshness, { tone: BadgeTone; label: string }> = {
|
||||||
fresh: { tone: 'positive', label: 'fresh' },
|
fresh: { tone: 'positive', label: 'fresh' },
|
||||||
stale: { tone: 'warning', label: 'stale' },
|
stale: { tone: 'warning', label: 'stale' },
|
||||||
never_reported: { tone: 'neutral', label: 'never reported' },
|
never_reported: { tone: 'neutral', label: 'never reported' },
|
||||||
|
|
@ -68,9 +68,9 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
|
||||||
render: (h) => {
|
render: (h) => {
|
||||||
const { tone, label } = FRESHNESS[h.freshness];
|
const { tone, label } = FRESHNESS[h.freshness];
|
||||||
return (
|
return (
|
||||||
<StatusChip
|
<Badge
|
||||||
tone={tone}
|
tone={tone}
|
||||||
label={
|
value={
|
||||||
h.last_seen_unix !== null ? (
|
h.last_seen_unix !== null ? (
|
||||||
<>
|
<>
|
||||||
{label} (<RelativeTime epochMs={h.last_seen_unix * 1000} />)
|
{label} (<RelativeTime epochMs={h.last_seen_unix * 1000} />)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
// <StatusChip> — 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 `<RelativeTime>` 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 <span class={'ui-chip ui-chip-' + tone}>{label}</span>;
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue