Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: shared Hint, ActionRow, Toolbar, EmptyState, ExternalLink and ExpandableCard replace per-page copies

Hint, ActionRow, Toolbar, EmptyState and ExternalLink replace the muted
text, button row, refresh bar, empty line and _blank anchor that each page
restyled under its own class. ExpandableCard (with a loading twin) holds the
builds and recent-runs cards, which had identical head and body CSS. The
create form's raw checkbox becomes CheckboxField. The components page shows
each new piece.
This commit is contained in:
iris 2026-11-06 17:54:24 +01:00 • committed by mara
commit 8a2e5203ad
42 changed files with 689 additions and 427 deletions

View file

@ -7,6 +7,7 @@ import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Button } from "../ui/button/Button.js";
import { ExternalLink } from "../ui/external-link/ExternalLink.js";
import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import {
@ -16,6 +17,7 @@ import {
import { fetchJson, usePolled } from "../ui/polled/usePolled.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import { TableSkeleton } from "../ui/table/TableSkeleton.js";
import { Toolbar } from "../ui/toolbar/Toolbar.js";
import { activeSinceMs, alertKey, listedAlerts, type Alert } from "./alerts.js";
const STATE_TONE: Record<string, BadgeTone> = {
@ -90,10 +92,10 @@ function AlertsBody() {
return (
<>
<div class="ui-alerts-bar">
<a href={GRAFANA_ALERTS_URL} target="_blank" rel="noopener noreferrer">
<Toolbar>
<ExternalLink href={GRAFANA_ALERTS_URL}>
Grafana's alert list
</a>
</ExternalLink>
{showAll || hidden > 0 ? (
<Button onClick={() => setShowAll(!showAll)}>
{showAll ? "only firing" : `show ${hidden} more`}
@ -105,7 +107,7 @@ function AlertsBody() {
onChange={setIntervalMs}
refreshing={refreshing}
/>
</div>
</Toolbar>
{error ? (
<ApiErrorPanel
context="failed to load Grafana's alerts"

View file

@ -34,6 +34,15 @@ import {
import { CheckboxField } from "../ui/checkbox-field/CheckboxField.js";
import { Button, type ButtonVariant } from "../ui/button/Button.js";
import { Dialog } from "../ui/dialog/Dialog.js";
import { ActionRow } from "../ui/action-row/ActionRow.js";
import { EmptyState } from "../ui/empty-state/EmptyState.js";
import {
ExpandableCard,
ExpandableCardSkeleton,
} from "../ui/expandable-card/ExpandableCard.js";
import { ExternalLink } from "../ui/external-link/ExternalLink.js";
import { Hint, type HintTone } from "../ui/hint/Hint.js";
import { Toolbar } from "../ui/toolbar/Toolbar.js";
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
import { Popover } from "../ui/popover/Popover.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
@ -41,6 +50,8 @@ import { GearIcon } from "@hive/shared/icons.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import "./ComponentsPage.css";
const HINT_TONES: HintTone[] = ["muted", "warn", "error", "ok"];
function Section({
title,
children,
@ -590,6 +601,62 @@ export function ComponentsPage() {
</div>
</Section>
<Section title="Hint">
<div class="components-chip-row">
{HINT_TONES.map((tone) => (
<Sample key={tone} label={tone}>
<Hint tone={tone}>A line of supporting text.</Hint>
</Sample>
))}
</div>
</Section>
<Section title="ActionRow, Toolbar, EmptyState, ExternalLink">
<Sample label="ActionRow: the buttons that end a form">
<ActionRow>
<Button variant="primary">save</Button>
<Button>cancel</Button>
</ActionRow>
</Sample>
<Sample label="Toolbar: the bar above a list">
<Toolbar>
<Button>refresh</Button>
</Toolbar>
</Sample>
<Sample label="EmptyState">
<EmptyState>Nothing here yet.</EmptyState>
</Sample>
<Sample label="ExternalLink">
<ExternalLink href="https://example.com">example.com</ExternalLink>
</Sample>
</Section>
<Section title="ExpandableCard">
<Sample label="closed, open, and the loading twin">
<ExpandableCard
id="components-expandable-closed"
open={false}
onToggle={() => {}}
title="build 1"
badge={<Badge value="ok" />}
meta="2 minutes ago"
>
body
</ExpandableCard>
<ExpandableCard
id="components-expandable-open"
open
onToggle={() => {}}
title="build 2"
badge={<Badge value="failed" />}
meta="an hour ago"
>
<Hint>The body appears while the card is open.</Hint>
</ExpandableCard>
<ExpandableCardSkeleton />
</Sample>
</Section>
<Section title="Dialog">
<Sample label="modal, caller-owned content">
<DialogSample />

View file

@ -94,7 +94,6 @@
flex: 1 1 auto;
min-width: 0;
margin-left: 0.25em;
color: var(--muted);
font-size: 0.9em;
}
.create-agent-summary {
@ -118,14 +117,6 @@
margin: 0;
overflow-wrap: anywhere;
}
.create-agent-result-ok {
color: var(--green);
}
/* The controller's refusal of the AGENTS.md text, under its box. */
.create-agent-field-error {
margin: 0;
color: var(--red);
}
/* The picked archetype's description, under its select. */
.create-agent-archetype-description {
margin: 0;

View file

@ -45,7 +45,9 @@ import {
InferenceFields,
type InferenceDraft,
} from "../agent/detail/AgentInference.js";
import { CheckboxField } from "../ui/checkbox-field/CheckboxField.js";
import { FormField } from "../ui/form-field/FormField.js";
import { Hint } from "../ui/hint/Hint.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import {
initialSelection,
@ -325,12 +327,12 @@ export function CreateAgentForm({
/>
)}
{done && (
<div class="create-agent-result create-agent-result-ok">
<Hint tone="ok" as="div" class="create-agent-result">
<p>
Queued as job node #{result.nodeId} —{" "}
<Link href="/jobs">watch it in jobs</Link>.
</p>
</div>
</Hint>
)}
{!done && step === "identity" && (
<div class="create-agent-fields">
@ -409,7 +411,9 @@ export function CreateAgentForm({
onInput={setAgentsMd}
/>
{refusal !== null && (
<p class="create-agent-field-error">{refusal}</p>
<Hint tone="error" class="create-agent-field-error">
{refusal}
</Hint>
)}
</div>
)}
@ -438,14 +442,12 @@ export function CreateAgentForm({
)}
</div>
</FormField>
<label>
<input
type="checkbox"
checked={internet}
onChange={() => setInternet((v) => !v)}
/>{" "}
Unrestricted internet
</label>
<CheckboxField
id="create-agent-internet"
label="Unrestricted internet"
checked={internet}
onChange={setInternet}
/>
</div>
)}
{!done && step === "resources" && (
@ -531,9 +533,9 @@ export function CreateAgentForm({
back
</Button>
{problem !== null && (step !== "identity" || name !== "") && (
<span class="create-agent-hint" role="status">
<Hint as="span" role="status" class="create-agent-hint">
{problem}
</span>
</Hint>
)}
{step === "summary" ? (
<Button

View file

@ -32,6 +32,7 @@ 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 { useLocalSetting } from "@hive/shared/settings-storage.js";
import { ExternalLink } from "../ui/external-link/ExternalLink.js";
import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js";
import { CheckboxField } from "../ui/checkbox-field/CheckboxField.js";
import { SelectField } from "../ui/select-field/SelectField.js";
@ -268,9 +269,7 @@ function IssueReportBody() {
ariaSort: ariaSortFor("number"),
render: (r) =>
r.html_url ? (
<a href={r.html_url} target="_blank" rel="noreferrer">
#{r.number}
</a>
<ExternalLink href={r.html_url}>#{r.number}</ExternalLink>
) : (
`#${r.number}`
),

View file

@ -14,8 +14,3 @@
flex-direction: column;
gap: 1em;
}
.ui-jobs-refresh {
display: flex;
justify-content: flex-end;
margin-block-end: 0.5em;
}

View file

@ -22,6 +22,7 @@ import { useState } from "preact/hooks";
import { JobqGraph } from "@hive/shared/jobq-graph.js";
import { JobqRollup } from "@hive/shared/jobq-rollup.js";
import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js";
import { Toolbar } from "../ui/toolbar/Toolbar.js";
import { BuildsCard } from "./builds/BuildsPage.js";
import { ReviewPage } from "./review/ReviewPage.js";
import {
@ -53,13 +54,13 @@ function JobsBody() {
return (
<>
<div class="ui-jobs-refresh">
<Toolbar>
<RefreshIntervalPicker
id="jobs-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</div>
</Toolbar>
<JobqRollup endpoint="/api/jobq/rollup" refreshToken={refreshToken} />
<JobqGraph endpoint="/api/jobq/graph" refreshToken={refreshToken} />
</>

View file

@ -13,6 +13,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextAreaField } from "../ui/text-area-field/TextAreaField.js";
import { Button } from "../ui/button/Button.js";
import { Hint } from "../ui/hint/Hint.js";
import "./LinkMatrixAccountForm.css";
type SubmitState =
@ -115,9 +116,9 @@ export function LinkBackendEnvForm({
</Button>
</form>
{result.status === "done" && (
<p class="link-matrix-account-result-ok">
<Hint tone="ok" class="link-matrix-account-result-ok">
{result.message} <strong>{agent}</strong>
</p>
</Hint>
)}
{result.status === "error" && (
<ApiErrorPanel

View file

@ -14,6 +14,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
import { Button } from "../ui/button/Button.js";
import { Hint } from "../ui/hint/Hint.js";
import {
checkLink,
LABEL_PATTERN,
@ -147,10 +148,10 @@ export function LinkForgeAccountForm({
</Button>
</form>
{result.status === "done" && (
<p class="link-matrix-account-result-ok">
<Hint tone="ok" class="link-matrix-account-result-ok">
linked <strong>{result.label}</strong> ({result.url}) to{" "}
<strong>{agent}</strong>
</p>
</Hint>
)}
{result.status === "error" && (
<ApiErrorPanel

View file

@ -12,6 +12,8 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
import { Button } from "../ui/button/Button.js";
import { ExternalLink } from "../ui/external-link/ExternalLink.js";
import { Hint } from "../ui/hint/Hint.js";
import "./LinkMatrixAccountForm.css";
type SubmitState =
@ -67,13 +69,9 @@ export function LinkGithubAccountForm({
two minutes, and its <code>gh</code> and <code>git push</code> to
github.com then authenticate with it. Use a dedicated bot account and a
minimally scoped token, created at{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink href="https://github.com/settings/tokens">
github.com/settings/tokens
</a>
</ExternalLink>
; GitHub notifications also need the <code>notifications</code> scope.
</p>
<form class="link-matrix-account-form" onSubmit={submit}>
@ -94,9 +92,9 @@ export function LinkGithubAccountForm({
</Button>
</form>
{result.status === "done" && (
<p class="link-matrix-account-result-ok">
<Hint tone="ok" class="link-matrix-account-result-ok">
stored a github token for <strong>{agent}</strong>
</p>
</Hint>
)}
{result.status === "error" && (
<ApiErrorPanel

View file

@ -13,6 +13,5 @@
margin-top: 1em;
}
.link-matrix-account-result-ok {
margin-top: 1em;
color: var(--green);
margin-block: 1em;
}

View file

@ -32,6 +32,7 @@ import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
import { SelectField } from "../ui/select-field/SelectField.js";
import { Button } from "../ui/button/Button.js";
import { Hint } from "../ui/hint/Hint.js";
import "./LinkMatrixAccountForm.css";
type Mode = "token" | "password";
@ -224,7 +225,7 @@ export function LinkMatrixAccountForm({
</Button>
</form>
{result.status === "done" && (
<p class="link-matrix-account-result-ok">
<Hint tone="ok" class="link-matrix-account-result-ok">
linked <strong>{result.account}</strong> to <strong>{agent}</strong>
{result.userId ? (
<>
@ -232,7 +233,7 @@ export function LinkMatrixAccountForm({
— logged in as <strong>{result.userId}</strong>
</>
) : null}
</p>
</Hint>
)}
{result.status === "error" && (
<ApiErrorPanel

View file

@ -54,15 +54,8 @@
}
.ui-broadcast-count {
margin-left: auto;
color: var(--muted);
font-size: 0.9em;
}
.ui-broadcast-empty {
color: var(--muted);
text-align: center;
padding: 1.25em 0.75em;
margin: 0;
}
/* The picker: each agent a labelled checkbox card, icon over name. */
.ui-broadcast-grid {

View file

@ -21,6 +21,8 @@ import {
RefreshIntervalPicker,
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js";
import { EmptyState } from "../../ui/empty-state/EmptyState.js";
import { Hint } from "../../ui/hint/Hint.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import {
@ -170,9 +172,9 @@ export function BroadcastComposer() {
>
clear
</button>
<span class="ui-broadcast-count" role="status">
<Hint as="span" role="status" class="ui-broadcast-count">
{targets.length} selected
</span>
</Hint>
<RefreshIntervalPicker
id="broadcast-refresh"
value={intervalMs}
@ -182,11 +184,11 @@ export function BroadcastComposer() {
</div>
{agents !== null && shown.length === 0 ? (
<p class="ui-broadcast-empty">
<EmptyState centered>
{filter === "running" && all.length > 0
? "no agent is running right now. Show all to pick a stopped one."
: "no agents."}
</p>
</EmptyState>
) : (
<ul class="ui-broadcast-grid" aria-label="agents">
{shown.map((agent) => {

View file

@ -1,42 +1,10 @@
/* The build list: one card per build, the head row a button that opens it. */
/* The build list: one card per build (`ui/expandable-card`). */
.ui-builds-list {
display: flex;
flex-direction: column;
gap: 0.75em;
margin-top: 1em;
}
.ui-builds-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em 0.75em;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.ui-builds-head:focus-visible {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
.ui-builds-title {
display: inline-flex;
align-items: baseline;
gap: 0.5em;
}
.ui-builds-meta {
color: var(--muted);
margin: 0;
}
.ui-builds-body {
display: flex;
flex-direction: column;
gap: 0.4em;
margin-top: 0.6em;
}
.ui-builds-text {
margin: 0;
overflow-wrap: anywhere;
@ -51,11 +19,3 @@
background: color-mix(in srgb, var(--crust) 60%, transparent);
font-size: 0.85em;
}
.ui-builds-empty {
color: var(--muted);
}
.ui-builds-refresh {
display: flex;
justify-content: flex-end;
margin-block-end: 0.5em;
}

View file

@ -9,7 +9,13 @@ import { useEffect, useRef, useState } from "preact/hooks";
import { useSearch } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge } from "@hive/shared/badge.js";
import { Card } from "../../ui/card/Card.js";
import { EmptyState } from "../../ui/empty-state/EmptyState.js";
import {
ExpandableCard,
ExpandableCardSkeleton,
} from "../../ui/expandable-card/ExpandableCard.js";
import { Hint } from "../../ui/hint/Hint.js";
import { Toolbar } from "../../ui/toolbar/Toolbar.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import {
RefreshIntervalPicker,
@ -45,50 +51,44 @@ function BuildCard({
onToggle: () => void;
}) {
const body = bodyText(run);
const bodyId = `${cardId(run.id)}-body`;
return (
<Card class="ui-builds-card">
<div id={cardId(run.id)}>
<button
type="button"
class="ui-builds-head"
aria-expanded={open}
aria-controls={bodyId}
onClick={onToggle}
>
<span class="ui-builds-title">
<strong>{runTitle(run)}</strong>
<code>{run.head_sha.slice(0, 7)}</code>
</span>
<Badge
tone={OUTCOME_TONE[run.outcome]}
value={OUTCOME_LABEL[run.outcome]}
/>
<span class="ui-builds-meta">
<RelativeTime epochMs={run.started_unix * 1000} /> · took{" "}
{durationLabel(run.started_unix, run.finished_unix)}
</span>
</button>
{open ? (
<div class="ui-builds-body" id={bodyId}>
{body ? (
<>
<p class="ui-builds-meta">{body.label}</p>
{body.preformatted ? (
<pre class="ui-builds-tail">{body.text}</pre>
) : (
<p class="ui-builds-text">{body.text}</p>
)}
</>
) : (
<p class="ui-builds-meta">
nothing more was recorded for this build.
</p>
)}
</div>
) : null}
</div>
</Card>
<ExpandableCard
id={cardId(run.id)}
class="ui-builds-card"
open={open}
onToggle={onToggle}
title={
<>
<strong>{runTitle(run)}</strong>
<code>{run.head_sha.slice(0, 7)}</code>
</>
}
badge={
<Badge
tone={OUTCOME_TONE[run.outcome]}
value={OUTCOME_LABEL[run.outcome]}
/>
}
meta={
<>
<RelativeTime epochMs={run.started_unix * 1000} /> · took{" "}
{durationLabel(run.started_unix, run.finished_unix)}
</>
}
>
{body ? (
<>
<Hint>{body.label}</Hint>
{body.preformatted ? (
<pre class="ui-builds-tail">{body.text}</pre>
) : (
<p class="ui-builds-text">{body.text}</p>
)}
</>
) : (
<Hint>nothing more was recorded for this build.</Hint>
)}
</ExpandableCard>
);
}
@ -133,14 +133,14 @@ function BuildsBody() {
return (
<>
<div class="ui-builds-refresh">
<Toolbar>
<RefreshIntervalPicker
id="builds-refresh"
value={intervalMs}
onChange={setIntervalMs}
refreshing={refreshing}
/>
</div>
</Toolbar>
<p>
What the swarm builder built, newest first: agent config PR heads, and
the agents it builds from internal/meta before a deploy. How each ended,
@ -153,15 +153,22 @@ function BuildsBody() {
/>
) : null}
{missing ? (
<p class="ui-builds-meta" role="status">
<Hint role="status">
the build in this link is no longer listed: the builder keeps only its
newest builds.
</p>
</Hint>
) : null}
{runs !== null && runs.length === 0 && !error ? (
<p class="ui-builds-empty">No builds yet.</p>
<EmptyState>No builds yet.</EmptyState>
) : null}
<div class="ui-builds-list">
{runs === null && !error ? (
<>
<ExpandableCardSkeleton />
<ExpandableCardSkeleton />
<ExpandableCardSkeleton />
</>
) : null}
{(runs ?? []).map((run) => (
<BuildCard
key={run.id}

View file

@ -19,7 +19,6 @@
}
.ui-messages-count {
margin-inline-start: auto;
color: var(--muted);
}
.ui-messages-wrap.terminal-wrap {
max-height: 70vh;
@ -30,5 +29,4 @@
}
.ui-messages-note {
margin: 0.75em 0 0;
color: var(--muted);
}

View file

@ -16,6 +16,7 @@ import { Row } from "@hive/shared/term-row.js";
import { BroadcastComposer } from "../broadcast/BroadcastComposer.js";
import { SchedulesCard } from "../schedules/SchedulesPage.js";
import { Disclosure } from "../../ui/disclosure/Disclosure.js";
import { Hint } from "../../ui/hint/Hint.js";
import { Panel } from "../../ui/panel/Panel.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js";
@ -136,11 +137,11 @@ export function MessagesPage() {
placeholder="sender or recipient"
onInput={setQuery}
/>
<span class="ui-messages-count">
<Hint as="span" class="ui-messages-count">
{query.trim() === ""
? `${messages.length} messages`
: `${shown.length} of ${messages.length} messages`}
</span>
</Hint>
</div>
<div class="terminal-wrap ui-messages-wrap">
<div class="live terminal" ref={logRef} onScroll={handleScroll}>
@ -159,11 +160,11 @@ export function MessagesPage() {
)}
</div>
</div>
<p class="ui-messages-note">
<Hint class="ui-messages-note">
Shows what the agents sent each other. A message an agent drained with
an in-turn <code>recv</code> is missing. The history is at most 100
recent messages per agent.
</p>
</Hint>
</Panel>
</div>
);

View file

@ -1,6 +1,7 @@
// <Deliveries> — the notification router's recent deliveries, newest first.
// The router keeps the newest 500 rows; this shows what
// `GET /api/notify/deliveries` returns.
import { EmptyState } from "../../ui/empty-state/EmptyState.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { Table, type TableColumn } from "../../ui/table/Table.js";
import { issueRef, outcomeText, type Delivery } from "./RouterLive.js";
@ -48,7 +49,7 @@ const COLUMNS: TableColumn<Row>[] = [
];
export function Deliveries({ rows }: { rows: Delivery[] }) {
if (rows.length === 0) return <p>no deliveries yet</p>;
if (rows.length === 0) return <EmptyState>no deliveries yet</EmptyState>;
const placed = rows.map((d, place) => ({ ...d, place }));
return (
<>

View file

@ -21,6 +21,7 @@ import {
type Edit,
type Limits,
} from "./FlowDraft.js";
import { Hint } from "../../ui/hint/Hint.js";
import { BoxFace, hitsText } from "./FlowBox.js";
import { layoutFlow, neighbour, nodeAt } from "./FlowLayout.js";
import { describeNode, ordinal, type Flow } from "./FlowTypes.js";
@ -282,7 +283,9 @@ export function FlowCanvas({
return (
<div class="notify-canvas-wrap">
<div class="notify-palette" role="group" aria-label="add a node">
<span class="notify-hint">add</span>
<Hint as="span" class="notify-hint">
add
</Hint>
{palette.map((p) => (
<button
key={p.kind}

Some files were not shown because too many files have changed in this diff Show more