diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 990bd90b..1306888f 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -221,7 +221,8 @@ commit, whether the run was a try-out or a requested review, how long ago it started, how long it took, how many classifications ran, and the outcome as a badge. Select a card to open it: it shows the same result table as the [try-out](#review-try-out), the overall verdict, the reason when the outcome -has one, and a link to the posted review when there is one. +has one, and a link to the posted review when there is one. While the first +answer loads, the section shows three grey placeholder cards. An outcome can be more than approve or request changes: @@ -589,7 +590,8 @@ config PR). Each card shows the repo and PR, or for a deploy's build the agent and `internal/meta`, the first seven characters of the commit, how the build ended (**built**, **failed** or **error**), when it started and how long it took. Select a card to read it: for a failed build, what `nix` printed; for a built one, the system -closure; for an error, why no build came back. +closure; for an error, why no build came back. While the first answer loads, the +card shows three grey placeholder cards. The **Details** link on a `swarm-builder` commit status opens the jobs tab as `/jobs?run=`, with the builds card open and that build's card in view. Links made before the diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css index d8b07af4..40060f64 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css @@ -8,6 +8,5 @@ margin-top: 1em; } .claude-login-ok { - margin-top: 1em; - color: var(--green); + margin-block: 1em; } diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx index 135bad83..3ce0167a 100644 --- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx +++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx @@ -11,6 +11,8 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Button } from "../../ui/button/Button.js"; import { Dialog } from "../../ui/dialog/Dialog.js"; +import { ExternalLink } from "../../ui/external-link/ExternalLink.js"; +import { Hint } from "../../ui/hint/Hint.js"; import { Panel } from "../../ui/panel/Panel.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { @@ -98,9 +100,7 @@ export function ClaudeLoginDialog({ <>

Open{" "} - - the claude login page - + the claude login page , sign in with this agent's account, and paste the code it shows.

@@ -122,10 +122,10 @@ export function ClaudeLoginDialog({ )} {step.step === "stored" && ( - + )} {step.step === "failed" && (
forge
) : null} @@ -134,13 +135,9 @@ function OverviewRows({ tone="warning" value={ row.config_pr.html_url ? ( - + #{row.config_pr.pr_number} - + ) : ( `#${row.config_pr.pr_number}` ) @@ -211,7 +208,7 @@ function GrantRows({ agent }: { agent: string }) {
permissions
{g.grant === null ? ( - — + — ) : ( <> {g.readFailure} @@ -257,13 +254,13 @@ function LinkAccountAction({ actions: AgentActions; }) { return ( -
+ actions.openLink(row, kind)} /> -
+ ); } diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.css b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.css index 90c18e91..3b7490b8 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.css +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.css @@ -17,8 +17,7 @@ .ui-agent-inference-source { margin-left: -0.25em; } -.ui-agent-inference-source, -.ui-agent-inference-muted { +.ui-agent-inference-source { color: var(--muted); } .ui-agent-inference-form { @@ -28,12 +27,3 @@ gap: 0.75em; margin-top: 1em; } -.ui-agent-inference-problem { - margin: 0; - color: var(--red); -} -.ui-agent-inference-actions { - display: flex; - align-items: center; - gap: 0.75em; -} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx index e1ff907c..063b2603 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx @@ -15,7 +15,9 @@ import { useEffect, 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 { ActionRow } from "../../ui/action-row/ActionRow.js"; import { Dialog } from "../../ui/dialog/Dialog.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"; @@ -171,7 +173,7 @@ export function InferenceFields({ pattern="[0-9]*" onInput={(maxSubagents) => onChange({ ...draft, maxSubagents })} /> - {problem ?

{problem}

: null} + {problem ? {problem} : null} ); } @@ -215,7 +217,7 @@ export function AgentInference({ agent }: { agent: string }) { /> ) : null; if (view === null) { - return errorPanel ?? …; + return errorPanel ?? …; } const { configured, effective } = view; @@ -368,7 +370,7 @@ function InferenceForm({ {problem ? ( ) : null} -
+ -
+ ); diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css index 01ae1d16..5e6ed150 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css @@ -38,9 +38,3 @@ border: 1px solid var(--red); border-radius: 4px; } -.ui-agent-instructions-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.75em; -} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx index 4ca6cd80..e8577d14 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx @@ -16,6 +16,7 @@ import { useEffect, useRef, 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 { ActionRow } from "../../ui/action-row/ActionRow.js"; import { Button } from "../../ui/button/Button.js"; import { Dialog } from "../../ui/dialog/Dialog.js"; import { Panel } from "../../ui/panel/Panel.js"; @@ -235,9 +236,9 @@ function InstructionsForm({ context="loading AGENTS.md failed" problem={load.problem} /> -
+ -
+ ) : null} {load.status === "ready" ? ( @@ -263,18 +264,18 @@ function InstructionsForm({
                   {theirs.content === "" ? "(empty)" : theirs.content}
                 
-
+ -
+ ) : null} {problem ? ( ) : null} -
+ -
+ ) : null} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css index bbebf0fd..6297ef5f 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.css @@ -23,10 +23,4 @@ } .ui-agent-limits-hint { margin: 0 0 0.5em; - color: var(--muted); -} -.ui-agent-limits-actions { - display: flex; - align-items: center; - gap: 0.75em; } diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx index 7353208a..917501be 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentLimits.tsx @@ -16,8 +16,10 @@ import { useEffect, 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 { ActionRow } from "../../ui/action-row/ActionRow.js"; import { Button } from "../../ui/button/Button.js"; import { Dialog } from "../../ui/dialog/Dialog.js"; +import { Hint } from "../../ui/hint/Hint.js"; import { Panel } from "../../ui/panel/Panel.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { BadgeSkeleton } from "../../ui/skeleton/BadgeSkeleton.js"; @@ -71,9 +73,9 @@ export function LimitsFields({ placeholder="e.g. 400%" onInput={onCpu} /> -

+ A percentage of one core: 100% is one core, 400% is four. -

+ -

+ A size such as 512M or 8G, a percentage of the machine's memory such as 50%, or infinity. -

+ ); } @@ -239,7 +241,7 @@ function LimitsForm({ {problem ? ( ) : null} -
+ @@ -249,7 +251,7 @@ function LimitsForm({ > clear the swarm's caps -
+ ); diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx index 913b6c4d..571571eb 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx @@ -22,6 +22,7 @@ import { Badge } from "@hive/shared/badge.js"; import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js"; import { Button } from "../../ui/button/Button.js"; import { BadgeSkeleton } from "../../ui/skeleton/BadgeSkeleton.js"; +import { Hint } from "../../ui/hint/Hint.js"; import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js"; import { requestPermissions, @@ -225,9 +226,9 @@ export function AgentPermissions({ /> ) : ( - + {catalogueError ? "could not load the permissions" : "loading…"} - + )} {saveError ? ( {" "} - {meaning} + {meaning} ))} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx index 35906aa6..9a14767e 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx @@ -24,6 +24,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 { ActionRow } from "../../ui/action-row/ActionRow.js"; import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js"; import { Button } from "../../ui/button/Button.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; @@ -232,7 +233,7 @@ export function AddForm({ {problem ? ( ) : null} -
+ -
+ ); diff --git a/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.css b/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.css index 11184c64..5d4d8761 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.css +++ b/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.css @@ -12,7 +12,6 @@ gap: 0.5em; flex-wrap: wrap; } -.ui-linked-accounts-host, -.ui-linked-accounts-muted { +.ui-linked-accounts-host { color: var(--muted); } diff --git a/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx b/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx index 6d627fee..eadd22ca 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx @@ -15,6 +15,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge } from "@hive/shared/badge.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; +import { Hint } from "../../ui/hint/Hint.js"; import { accountKindLabel } from "./account-kind.js"; import { deleteUrl, listUrl } from "./account-api.js"; import "./LinkedAccounts.css"; @@ -145,13 +146,13 @@ export function LinkedAccounts({ ) : null; if (accounts === null) { - return errorPanel ?? …; + return errorPanel ?? …; } if (accounts.length === 0) { return ( <> {errorPanel} - none linked + none linked ); } diff --git a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx index 31bba464..b64ed3a6 100644 --- a/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AlertsPage.tsx @@ -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 = { @@ -90,10 +92,10 @@ function AlertsBody() { return ( <> -
- + + Grafana's alert list - + {showAll || hidden > 0 ? (
+ {error ? ( +
+
+ {HINT_TONES.map((tone) => ( + + A line of supporting text. + + ))} +
+
+ +
+ + + + + + + + + + + + + Nothing here yet. + + + example.com + +
+ +
+ + {}} + title="build 1" + badge={} + meta="2 minutes ago" + > + body + + {}} + title="build 2" + badge={} + meta="an hour ago" + > + The body appears while the card is open. + + + +
+
diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css index 41f104d1..98e60cb5 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css @@ -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; diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index 7a30d287..6e400bed 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -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 && ( -
+

Queued as job node #{result.nodeId} —{" "} watch it in jobs.

-
+ )} {!done && step === "identity" && (
@@ -409,7 +411,9 @@ export function CreateAgentForm({ onInput={setAgentsMd} /> {refusal !== null && ( -

{refusal}

+ + {refusal} + )}
)} @@ -438,14 +442,12 @@ export function CreateAgentForm({ )} - + )} {!done && step === "resources" && ( @@ -531,9 +533,9 @@ export function CreateAgentForm({ back {problem !== null && (step !== "identity" || name !== "") && ( - + {problem} - + )} {step === "summary" ? ( {result.status === "done" && ( - + )} {result.status === "error" && ( {result.status === "done" && ( - + )} {result.status === "error" && ( gh and git push to github.com then authenticate with it. Use a dedicated bot account and a minimally scoped token, created at{" "} - + github.com/settings/tokens - + ; GitHub notifications also need the notifications scope.

{result.status === "done" && ( - + )} {result.status === "error" && ( {result.status === "done" && ( - + )} {result.status === "error" && ( clear - + {targets.length} selected - + {agents !== null && shown.length === 0 ? ( -

+ {filter === "running" && all.length > 0 ? "no agent is running right now. Show all to pick a stopped one." : "no agents."} -

+ ) : (
    {shown.map((agent) => { diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css index bb8f7616..50bfd3df 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css @@ -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; -} diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx index e944fac4..7ee6b5c8 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx @@ -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 ( - -
    - - {open ? ( -
    - {body ? ( - <> -

    {body.label}

    - {body.preformatted ? ( -
    {body.text}
    - ) : ( -

    {body.text}

    - )} - - ) : ( -

    - nothing more was recorded for this build. -

    - )} -
    - ) : null} -
    -
    + + {runTitle(run)} + {run.head_sha.slice(0, 7)} + + } + badge={ + + } + meta={ + <> + · took{" "} + {durationLabel(run.started_unix, run.finished_unix)} + + } + > + {body ? ( + <> + {body.label} + {body.preformatted ? ( +
    {body.text}
    + ) : ( +

    {body.text}

    + )} + + ) : ( + nothing more was recorded for this build. + )} +
    ); } @@ -133,14 +133,14 @@ function BuildsBody() { return ( <> -
    + -
    +

    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 ? ( -

    + the build in this link is no longer listed: the builder keeps only its newest builds. -

    + ) : null} {runs !== null && runs.length === 0 && !error ? ( -

    No builds yet.

    + No builds yet. ) : null}
    + {runs === null && !error ? ( + <> + + + + + ) : null} {(runs ?? []).map((run) => ( - + {query.trim() === "" ? `${messages.length} messages` : `${shown.length} of ${messages.length} messages`} - +
    @@ -159,11 +160,11 @@ export function MessagesPage() { )}
    -

    + Shows what the agents sent each other. A message an agent drained with an in-turn recv is missing. The history is at most 100 recent messages per agent. -

    + ); diff --git a/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx b/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx index 8bb2dd11..8be87289 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx @@ -1,6 +1,7 @@ // — 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[] = [ ]; export function Deliveries({ rows }: { rows: Delivery[] }) { - if (rows.length === 0) return

    no deliveries yet

    ; + if (rows.length === 0) return no deliveries yet; const placed = rows.map((d, place) => ({ ...d, place })); return ( <> diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx index 50ebdc7e..5807565b 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx @@ -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 (
    - add + + add + {palette.map((p) => (
    +
    {shown ? (

    diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx index 46b18448..cb993318 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx @@ -2,9 +2,11 @@ // it: move it under another node, change when it runs among its siblings, // remove it. Every change goes out as an `Edit` of the flow; the editor shows // the reason when one is refused. New nodes come from the canvas palette. +import { ActionRow } from "../../ui/action-row/ActionRow.js"; import { Button } from "../../ui/button/Button.js"; import { Card } from "../../ui/card/Card.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; +import { Hint } from "../../ui/hint/Hint.js"; import { SelectField } from "../../ui/select-field/SelectField.js"; import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; import { TextField } from "../../ui/text-field/TextField.js"; @@ -204,10 +206,10 @@ export function NodeInspector({ ) : null} {order.count > 1 ? ( -

    - + + runs {ordinal(order.index + 1)} of {order.count} below its parent - + -
    + ) : null} -
    + {node.kind !== "source" ? ( ) : null} -
    + {node.kind !== "source" && parent !== undefined ? ( -
    + draftFrom()}> create flow -
    + {error ? ( ) : null} @@ -149,11 +150,11 @@ export function NotificationRouter() { disabled={pending} onChange={(checked) => setBuiltin(flow.id, checked)} /> -
    + -
    + @@ -168,7 +169,7 @@ export function NotificationRouter() { {flow.enabled ? "on" : "off"} -
    + -
    + diff --git a/frontend/packages/swarm-ui/src/pages/reports/ReportsPage.css b/frontend/packages/swarm-ui/src/pages/reports/ReportsPage.css index be8da904..94970a4a 100644 --- a/frontend/packages/swarm-ui/src/pages/reports/ReportsPage.css +++ b/frontend/packages/swarm-ui/src/pages/reports/ReportsPage.css @@ -4,13 +4,3 @@ flex-direction: column; gap: 1em; } -/* The bar at the top of the alerts card: the Grafana link, the show-more - button and the refresh picker, to the right. */ -.ui-alerts-bar { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: flex-end; - gap: 0.5em 0.75em; - margin-block-end: 0.5em; -} diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.css b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.css index 75e49738..ace1fa1e 100644 --- a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.css +++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.css @@ -1,47 +1,7 @@ -/* The run list: one card per run, the head row a button that opens it. */ +/* The run list: one card per run (`ui/expandable-card`). */ .ui-runs-list { display: flex; flex-direction: column; gap: 0.75em; margin-top: 1em; } -.ui-runs-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-runs-head:focus-visible { - outline: 2px solid var(--purple); - outline-offset: 2px; -} -.ui-runs-title { - display: inline-flex; - align-items: baseline; - gap: 0.5em; -} -.ui-runs-meta { - color: var(--muted); -} -.ui-runs-head .ui-runs-meta { - flex-basis: 100%; -} -.ui-runs-body { - display: flex; - flex-direction: column; - gap: 0.6em; - margin-top: 0.5em; -} -.ui-runs-body p { - margin: 0; -} -.ui-runs-empty { - color: var(--muted); -} diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx index 898c3df3..55d602bf 100644 --- a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx @@ -7,7 +7,13 @@ import { useState } from "preact/hooks"; 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 { ExternalLink } from "../../ui/external-link/ExternalLink.js"; +import { + ExpandableCard, + ExpandableCardSkeleton, +} from "../../ui/expandable-card/ExpandableCard.js"; +import { Hint } from "../../ui/hint/Hint.js"; import { Panel } from "../../ui/panel/Panel.js"; import { RefreshIntervalPicker, @@ -38,55 +44,50 @@ function RunCard({ open: boolean; onToggle: () => void; }) { - const bodyId = `review-run-${run.id}`; return ( - - - {open ? ( -
    - {run.detail ?

    {run.detail}

    : null} - {run.rows && run.rows.length > 0 ? ( - - ) : null} - {run.review_url ? ( - - ) : null} - {run.requester ? ( -

    Requested by {run.requester}.

    - ) : null} -
    + + } + > + {run.detail ?

    {run.detail}

    : null} + {run.rows && run.rows.length > 0 ? ( + ) : null} -
    + {run.review_url ? ( + + ) : null} + {run.requester ? Requested by {run.requester}. : null} + ); } @@ -136,9 +137,16 @@ export function ReviewRuns() { ) : null} {runs !== null && runs.length === 0 && !error ? ( -

    No runs yet.

    + No runs yet. ) : null}
    + {runs === null && !error ? ( + <> + + + + + ) : null} {(runs ?? []).map((run) => ( ) : null} -
    +
    - {problem !== null ? ( -

    {problem}

    - ) : null} + + {problem !== null ? {problem} : null} ) : null} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.css b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.css index 5419d9b4..33c11710 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.css +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.css @@ -48,12 +48,6 @@ .ui-tryout-classification-row > button { flex: none; } -.ui-tryout-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.75em; -} .ui-tryout-hint { margin: 0; color: var(--muted); diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx index ec5425f6..7aa82709 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx @@ -25,9 +25,11 @@ import { useEffect, useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { ActionRow } from "../../ui/action-row/ActionRow.js"; import { Button } from "../../ui/button/Button.js"; import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; import { Disclosure } from "../../ui/disclosure/Disclosure.js"; +import { ExternalLink } from "../../ui/external-link/ExternalLink.js"; import { Panel } from "../../ui/panel/Panel.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { ClassificationEditor } from "./ClassificationEditor.js"; @@ -396,7 +398,7 @@ export function ReviewTryOut() { } /> ))} -
    + -
    + {capProblem !== null ? (

    {capProblem} @@ -440,9 +442,9 @@ export function ReviewTryOut() {

    Pushed {opened.branch} to {opened.repo}, changing{" "} {opened.path} with you as the author.{" "} - + Open the PR on the forge - + .

    ) : null} diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css index 1c0aef8b..33924f12 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css @@ -1,7 +1,4 @@ /* A finished run's table, the overall verdict under it and the head commit. */ -.ui-tryout-row-error { - color: var(--red); -} .ui-tryout-overall, .ui-tryout-sha { margin: 0.75em 0 0; diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx index 519d4549..ebe6bb0f 100644 --- a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx +++ b/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx @@ -4,6 +4,7 @@ // (`docs/swarm/review-classifier.md`); no token // counts, cache counts or cost, here or anywhere in the UI. import { Badge } from "@hive/shared/badge.js"; +import { Hint } from "../../ui/hint/Hint.js"; import { Table, type TableColumn } from "../../ui/table/Table.js"; import type { TryOutResult as Result, TryOutRow } from "./TryOutTypes.js"; import "./TryOutResult.css"; @@ -42,7 +43,13 @@ const COLUMNS: TableColumn[] = [ cellClass: "ui-table-prose", // A row that could not run says why where the reason would be. render: (r) => - r.error ? {r.error} : r.reason, + r.error ? ( + + {r.error} + + ) : ( + r.reason + ), }, ]; diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css index 9d819fdb..62ab4d96 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.css @@ -29,8 +29,5 @@ overflow-wrap: anywhere; } .ui-schedule-card-actions { - display: flex; - flex-wrap: wrap; gap: 0.5em; - justify-content: flex-end; } diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx index 8c604282..31f3c22f 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleCard.tsx @@ -3,6 +3,7 @@ // fires next. Edit, pause/resume and remove sit at the foot; edit swaps the // card for `ScheduleForm` (the page owns that swap). import { Badge, type BadgeTone } from "@hive/shared/badge.js"; +import { ActionRow } from "../../ui/action-row/ActionRow.js"; import { Button } from "../../ui/button/Button.js"; import { Card } from "../../ui/card/Card.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; @@ -72,7 +73,7 @@ export function ScheduleCard({ )}
    -
    + @@ -82,7 +83,7 @@ export function ScheduleCard({ -
    + ); } diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.css b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.css index 849559dc..1cc630de 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.css +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.css @@ -30,17 +30,11 @@ flex: 1 1 9em; } .ui-schedule-form-hint { - margin: 0; font-size: 0.85em; - color: var(--muted); } .ui-schedule-form-warn { - margin: 0; font-size: 0.85em; - color: var(--yellow); } .ui-schedule-form-actions { - display: flex; gap: 0.5em; - justify-content: flex-end; } diff --git a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx index f5d4123c..15f1ff12 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/ScheduleForm.tsx @@ -11,8 +11,10 @@ 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 { ActionRow } from "../../ui/action-row/ActionRow.js"; import { Button } from "../../ui/button/Button.js"; import { Card } from "../../ui/card/Card.js"; +import { Hint } from "../../ui/hint/Hint.js"; import { MultiselectFilterOptions, type MultiselectFilterState, @@ -190,11 +192,11 @@ export function ScheduleForm({
    agents {agentOptions.length === 0 ? ( - + {agentsError ? `could not load the agent list: ${agentsError}` : "no agents to pick from"} - + ) : (
    {fires.length > 0 ? ( -

    + {repeat ? "next fires: " : "fires: "} {fires.map(fmtLocal).join(" · ")} -

    + ) : null} {startInPast && (original === undefined || startChanged) ? ( -

    + That start is in the past: saving fires the latest missed time once, right away, in every agent chosen. -

    + ) : null} {cadenceChanged ? ( -

    + Changing the start or the repeat moves when this schedule fires. Editing only the name, message or agents does not. -

    + ) : null} {error ? ( ) : null} -
    + -
    + ); diff --git a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx index aecaf8c6..86cf3c3d 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.tsx @@ -15,7 +15,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Button } from "../../ui/button/Button.js"; import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; import { FilterableView } from "../../ui/filterable-view/FilterableView.js"; -import "./SchedulesPage.css"; +import { Toolbar } from "../../ui/toolbar/Toolbar.js"; import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js"; import { RefreshIntervalPicker, @@ -152,7 +152,7 @@ function SchedulesBody() { return ( <> -
    +
    + {error ? ( ) : null} diff --git a/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx index 722f8476..08c01481 100644 --- a/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx @@ -24,6 +24,7 @@ import { import { Button } from "../../ui/button/Button.js"; import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js"; import { Dialog } from "../../ui/dialog/Dialog.js"; +import { Hint } from "../../ui/hint/Hint.js"; import "../../agent/detail/AgentLimits.css"; import "./SwarmPage.css"; @@ -61,13 +62,13 @@ function DomainSection({ return (

    {heading}

    -

    {help}

    + {help} {errorPanel} {entries === null ? ( errorPanel === null ? ( - + loading the {kind === "deny" ? "denied" : "allowed"} domains… - + ) : null ) : ( <> diff --git a/frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx b/frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx index 2d6bc8b5..bda0b2bd 100644 --- a/frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/swarm/HivesCard.tsx @@ -16,6 +16,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 { ExternalLink } from "../../ui/external-link/ExternalLink.js"; import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { @@ -25,6 +26,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 { domainCell } from "../hive-domain.js"; type Freshness = "fresh" | "stale" | "never_reported" | "unknown"; @@ -65,13 +67,7 @@ const COLUMNS: TableColumn[] = [ render: (h) => { const { text, href } = domainCell(h); if (!text) return "—"; - return href ? ( - - {text} - - ) : ( - text - ); + return href ? {text} : text; }, sortBy: (h) => domainCell(h).text, filterValue: (h) => domainCell(h).text, @@ -129,14 +125,14 @@ function HiveRoster() { return ( <> -
    + -
    + {error ? ( — the row of buttons at the end of a form, a dialog or a +// section: aligned on the centre line, wrapping when it is narrow, with one +// gap. `end` pushes the buttons to the right edge. +import type { ComponentChildren } from "preact"; +import "./ActionRow.css"; + +export function ActionRow({ + align = "start", + class: extraClass, + children, +}: { + align?: "start" | "end"; + class?: string; + children: ComponentChildren; +}) { + const classes = [ + "ui-action-row", + align === "end" && "ui-action-row-end", + extraClass, + ] + .filter(Boolean) + .join(" "); + return
    {children}
    ; +} diff --git a/frontend/packages/swarm-ui/src/ui/button/Button.css b/frontend/packages/swarm-ui/src/ui/button/Button.css index 1c5e42a6..6558607f 100644 --- a/frontend/packages/swarm-ui/src/ui/button/Button.css +++ b/frontend/packages/swarm-ui/src/ui/button/Button.css @@ -20,6 +20,15 @@ color: var(--fg); border: 1px solid var(--border); } +/* `plain`: text only, no box, for a control that reads as a link in a row of + text (it keeps the touch-target height). */ +.ui-button-plain { + background: none; + color: inherit; + border: 0; + padding: 0; + text-align: left; +} .ui-button:disabled { opacity: 0.6; cursor: default; diff --git a/frontend/packages/swarm-ui/src/ui/button/Button.tsx b/frontend/packages/swarm-ui/src/ui/button/Button.tsx index cdf77e4b..7311907f 100644 --- a/frontend/packages/swarm-ui/src/ui/button/Button.tsx +++ b/frontend/packages/swarm-ui/src/ui/button/Button.tsx @@ -7,7 +7,7 @@ import type { ComponentChildren } from "preact"; import "./Button.css"; -export type ButtonVariant = "primary" | "default"; +export type ButtonVariant = "primary" | "default" | "plain"; export function Button({ variant = "default", diff --git a/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.css b/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.css new file mode 100644 index 00000000..2c0d4ad7 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.css @@ -0,0 +1,8 @@ +.ui-empty-state { + margin: 0; + color: var(--muted); +} +.ui-empty-state-centered { + padding: 1.25em 0.75em; + text-align: center; +} diff --git a/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.tsx b/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.tsx new file mode 100644 index 00000000..e178b895 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.tsx @@ -0,0 +1,19 @@ +// — what a list or a section says when it holds nothing: "No runs +// yet.", "none linked". Muted, with room around it. `Table`'s `emptyMessage` +// and `FilterableView` use the same look through this component. +import type { ComponentChildren } from "preact"; +import "./EmptyState.css"; + +export function EmptyState({ + centered = false, + children, +}: { + centered?: boolean; + children: ComponentChildren; +}) { + return ( +

    + {children} +

    + ); +} diff --git a/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.css b/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.css new file mode 100644 index 00000000..8208f2a8 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.css @@ -0,0 +1,43 @@ +/* The head is the control: it reads as a row of text, not a boxed button. */ +.ui-expandable-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-expandable-head:focus-visible { + outline: 2px solid var(--purple); + outline-offset: 2px; +} +/* The placeholder head is not a control. */ +.ui-expandable-head-static { + cursor: default; +} +.ui-expandable-title { + display: inline-flex; + align-items: baseline; + gap: 0.5em; +} +.ui-expandable-meta { + margin: 0; + color: var(--muted); +} +.ui-expandable-meta-row { + flex-basis: 100%; +} +.ui-expandable-body { + display: flex; + flex-direction: column; + gap: 0.5em; + margin-top: 0.6em; +} +.ui-expandable-body p { + margin: 0; +} diff --git a/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.tsx b/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.tsx new file mode 100644 index 00000000..9f5c5482 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.tsx @@ -0,0 +1,112 @@ +// — a card in a list whose head is a button that opens its +// body in place: a build, a classifier run. The head holds a title, a badge +// and a line of meta (when it started, how long it took); the body is shown +// only while the card is open. It is controlled (`open`, `onToggle`), so a +// page can keep its open cards across a refresh and open one for the user. +// +// Not `ui/disclosure`: that is a `
    ` for one section of a page, and a +// list of these needs the page to own which are open. The head is a real +// ` + {open ? ( +
    + {children} +
    + ) : null} + + + ); +} + +export function ExpandableCardSkeleton({ + metaOnOwnRow = false, +}: { + metaOnOwnRow?: boolean; +}) { + return ( + +
    + } + badge={} + meta={} + metaOnOwnRow={metaOnOwnRow} + /> +
    +
    + ); +} diff --git a/frontend/packages/swarm-ui/src/ui/external-link/ExternalLink.tsx b/frontend/packages/swarm-ui/src/ui/external-link/ExternalLink.tsx new file mode 100644 index 00000000..988205e6 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/external-link/ExternalLink.tsx @@ -0,0 +1,28 @@ +// — a link that opens another site in a new tab. It always +// sets `rel="noopener noreferrer"`, so the opened page cannot reach this one +// and is not told where the visitor came from. +import type { ComponentChildren } from "preact"; + +export function ExternalLink({ + href, + title, + class: extraClass, + children, +}: { + href: string; + title?: string; + class?: string; + children: ComponentChildren; +}) { + return ( + + {children} + + ); +} diff --git a/frontend/packages/swarm-ui/src/ui/hint/Hint.css b/frontend/packages/swarm-ui/src/ui/hint/Hint.css new file mode 100644 index 00000000..19da0b86 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/hint/Hint.css @@ -0,0 +1,17 @@ +/* : small supporting text. A `p` loses its default margin, so the page + that places it owns the spacing around it. */ +.ui-hint { + margin: 0; +} +.ui-hint-muted { + color: var(--muted); +} +.ui-hint-warn { + color: var(--yellow); +} +.ui-hint-error { + color: var(--red); +} +.ui-hint-ok { + color: var(--green); +} diff --git a/frontend/packages/swarm-ui/src/ui/hint/Hint.tsx b/frontend/packages/swarm-ui/src/ui/hint/Hint.tsx new file mode 100644 index 00000000..48025cd3 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/hint/Hint.tsx @@ -0,0 +1,39 @@ +// — a line of small supporting text: helper text under a field, a +// count, a status line, an inline problem or a success note. One component +// for what each form and card used to restyle under its own class name, so +// "muted", "warning", "error" and "ok" mean the same colour everywhere. +// +// `error` and `warn` are read out when they appear (`role="alert"` for an +// error); the others are plain text unless the caller passes a `role`, such as +// `status` for a line that changes after a save. `class` is for a page's +// layout around the hint (a margin, a width), not for its colour. +import type { ComponentChildren } from "preact"; +import "./Hint.css"; + +export type HintTone = "muted" | "warn" | "error" | "ok"; + +export function Hint({ + tone = "muted", + as: Tag = "p", + role, + class: extraClass, + children, +}: { + tone?: HintTone; + as?: "p" | "span" | "div"; + role?: "status" | "alert"; + class?: string; + children: ComponentChildren; +}) { + const classes = ["ui-hint", `ui-hint-${tone}`, extraClass] + .filter(Boolean) + .join(" "); + return ( + + {children} + + ); +} diff --git a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.css b/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.css similarity index 52% rename from frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.css rename to frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.css index bf3bfebb..71218ac8 100644 --- a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.css +++ b/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.css @@ -1,6 +1,4 @@ -/* The bar at the top of the schedules card: the add button, then the refresh - picker, to the right. */ -.ui-schedules-bar { +.ui-toolbar { display: flex; flex-wrap: wrap; align-items: center; @@ -8,3 +6,6 @@ gap: 0.5em 0.75em; margin-block-end: 0.5em; } +.ui-toolbar-start { + justify-content: flex-start; +} diff --git a/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.tsx b/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.tsx new file mode 100644 index 00000000..4685b5d5 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.tsx @@ -0,0 +1,24 @@ +// — the bar above a list or a card: filters, a count, the refresh +// picker. Items sit on one line and wrap when narrow, aligned to the end by +// default (where the refresh picker sits on every page that has one). +import type { ComponentChildren } from "preact"; +import "./Toolbar.css"; + +export function Toolbar({ + justify = "end", + class: extraClass, + children, +}: { + justify?: "start" | "end"; + class?: string; + children: ComponentChildren; +}) { + const classes = [ + "ui-toolbar", + justify === "start" && "ui-toolbar-start", + extraClass, + ] + .filter(Boolean) + .join(" "); + return
    {children}
    ; +}