swarm-ui: give Panel an optional header icon slot

Adds an `icon` prop to Panel (small emoji glyph left of the title,
aria-hidden, chosen per panel with no default) and wires it into every
current Panel caller: hives (bee), create-agent's form (robot) and info
panel (identity card, moved off the info panel's body copy where it
started as a one-off), jobs (puzzle piece), the components gallery
itself (toolbox), and the 404 page (compass). Adds a components-page
demo section and a whimsy-section pointer in the design guide.

Closes: #3508
This commit is contained in:
iris 2026-08-19 17:17:15 +02:00
commit b25aa157df
9 changed files with 49 additions and 15 deletions

View file

@ -24,7 +24,13 @@ repeated here.
home.js`, is the reference example — currently in the dashboard home.js`, is the reference example — currently in the dashboard
package, not swarm-ui itself, but the pattern it sets applies here package, not swarm-ui itself, but the pattern it sets applies here
too). Whimsy still has to clear the accessibility bar below (motion, too). Whimsy still has to clear the accessibility bar below (motion,
in particular). in particular). swarm-ui's own reference example: `Panel`'s optional
`icon` prop (`src/ui/panel/Panel.tsx`), a small emoji glyph in a
panel's header, chosen per panel with no default — grew out of a
one-off emoji dropped into a single page's copy, which wasn't whimsy
in this sense (a *consistent*, reusable touch) until it became a real
prop every panel can opt into. `aria-hidden`, since it's decorative —
the title text is still the actual label.
- **Efficient navigation** — minimize clicks/hops for a common task. - **Efficient navigation** — minimize clicks/hops for a common task.
- **Avoid junk drawers.** A control belongs next to the thing it - **Avoid junk drawers.** A control belongs next to the thing it
affects, not tucked into a catch-all menu. Concrete anti-example (not affects, not tucked into a catch-all menu. Concrete anti-example (not

View file

@ -10,7 +10,7 @@ import { Panel } from './ui/panel/Panel.js';
function NotFound() { function NotFound() {
return ( return (
<Panel title="404"> <Panel title="404" icon="🧭">
<p>no route here.</p> <p>no route here.</p>
</Panel> </Panel>
); );

View file

@ -88,7 +88,7 @@ function RefreshIntervalPickerSample() {
export function ComponentsPage() { export function ComponentsPage() {
return ( return (
<Panel title="components"> <Panel title="components" icon="🧰">
<p class="components-intro"> <p class="components-intro">
Every primitive in <code>src/ui/</code>, shown in each mode it supports. Sample data Every primitive in <code>src/ui/</code>, shown in each mode it supports. Sample data
only nothing here calls the API. only nothing here calls the API.
@ -101,6 +101,11 @@ export function ComponentsPage() {
<Sample label="without title"> <Sample label="without title">
<Panel>panel body content, no title</Panel> <Panel>panel body content, no title</Panel>
</Sample> </Sample>
<Sample label="with icon (per panel, no default — see the doc comment)">
<Panel title="example title" icon="✨">
panel body content
</Panel>
</Sample>
<Sample label="with actions (e.g. HivesPage's refresh picker)"> <Sample label="with actions (e.g. HivesPage's refresh picker)">
<Panel <Panel
title="example title" title="example title"

View file

@ -36,10 +36,6 @@
flex: 1 1 0; flex: 1 1 0;
min-width: 16em; min-width: 16em;
} }
.create-agent-info-glyph {
margin: 0 0 0.5em;
font-size: 2em;
}
.create-agent-intro { .create-agent-intro {
margin: 0 0 1.5em; margin: 0 0 1.5em;
color: var(--muted); color: var(--muted);

View file

@ -127,7 +127,7 @@ export function CreateAgentPage() {
return ( return (
<div class="create-agent-layout"> <div class="create-agent-layout">
<div class="create-agent-form-col"> <div class="create-agent-form-col">
<Panel title="create agent"> <Panel title="create agent" icon="🤖">
<p class="create-agent-intro"> <p class="create-agent-intro">
Create a new agent's swarm-level identity. This only queues the job — check{' '} Create a new agent's swarm-level identity. This only queues the job — check{' '}
<Link href="/jobs">jobs</Link> to watch it settle. <Link href="/jobs">jobs</Link> to watch it settle.
@ -180,11 +180,11 @@ export function CreateAgentPage() {
literal behaviour (deploying the container onto the hive isn't literal behaviour (deploying the container onto the hive isn't
wired up server-side yet see this file's top comment) wired up server-side yet see this file's top comment)
mara's explicit call on this copy: read as finished, not as a mara's explicit call on this copy: read as finished, not as a
running commentary on partial implementation. */} running commentary on partial implementation. The 🪪 glyph that
<Panel title="what this creates"> used to sit here as body copy now lives on `Panel`'s own `icon`
<p class="create-agent-info-glyph" aria-hidden="true"> prop instead it's what prompted that prop to exist at all
🪪 (see Panel.tsx's doc comment) */}
</p> <Panel title="what this creates" icon="🪪">
<p> <p>
Submitting this queues everything a new agent needs: a swarm-level identity, a config Submitting this queues everything a new agent needs: a swarm-level identity, a config
repo on the forge with the operator added as a collaborator, and a container running repo on the forge with the operator added as a collaborator, and a container running

View file

@ -115,6 +115,7 @@ export function HivesPage() {
return ( return (
<Panel <Panel
title="hives" title="hives"
icon="🐝"
actions={<RefreshIntervalPicker id="hives-refresh" value={intervalMs} onChange={setIntervalMs} />} actions={<RefreshIntervalPicker id="hives-refresh" value={intervalMs} onChange={setIntervalMs} />}
> >
{error ? <ApiErrorPanel context="failed to load the hive roster" problem={error} /> : null} {error ? <ApiErrorPanel context="failed to load the hive roster" problem={error} /> : null}

View file

@ -18,7 +18,7 @@ import './JobsPage.css';
export function JobsPage() { export function JobsPage() {
return ( return (
<Panel title="jobs"> <Panel title="jobs" icon="🧩">
<JobqRollup endpoint="/api/jobq/rollup" /> <JobqRollup endpoint="/api/jobq/rollup" />
<JobqGraph endpoint="/api/jobq/graph" /> <JobqGraph endpoint="/api/jobq/graph" />
</Panel> </Panel>

View file

@ -15,6 +15,10 @@
font-size: 1em; font-size: 1em;
font-weight: 600; font-weight: 600;
} }
.ui-panel-icon {
font-size: 1.2em;
line-height: 1;
}
/* `margin-left: auto` (not `justify-content: space-between` on the /* `margin-left: auto` (not `justify-content: space-between` on the
header) so actions still land at the right edge even on the rare header) so actions still land at the right edge even on the rare
panel that has actions but no title. */ panel that has actions but no title. */

View file

@ -11,22 +11,44 @@
// dedicated row stealing vertical space from the panel's actual // dedicated row stealing vertical space from the panel's actual
// content either). `HivesPage`'s refresh-interval picker is the // content either). `HivesPage`'s refresh-interval picker is the
// motivating caller. // motivating caller.
//
// `icon` is a small header glyph, left of the title — the swarm-ui
// design guide's own whimsy reference (before this, the guide only
// pointed at the dashboard's matrix-rain background). Grew out of
// CreateAgentPage's one-off 🪪 dropped straight into a panel's body
// copy: mara's call on review was that a single ad-hoc emoji isn't
// whimsy in the guide's sense (small, delightful, *consistent*), it
// should be a real theme every panel can opt into the same way. Plain
// `string` (an emoji literal), not an icon-library asset — same
// lightweight-glyph precedent `RefreshIntervalPicker`'s 🕐 set before
// this. Chosen per panel, no default: not every panel needs one, and
// there's no single semantic mapping (e.g. "jobs" pages) worth
// hardcoding. `aria-hidden` — decorative only, the title text still
// carries the actual label, so this never becomes a second source of
// truth an assistive-tech user has to parse.
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import './Panel.css'; import './Panel.css';
export function Panel({ export function Panel({
title, title,
icon,
actions, actions,
children, children,
}: { }: {
title?: string; title?: string;
icon?: string;
actions?: ComponentChildren; actions?: ComponentChildren;
children: ComponentChildren; children: ComponentChildren;
}) { }) {
return ( return (
<section class="ui-panel"> <section class="ui-panel">
{title || actions ? ( {title || icon || actions ? (
<div class="ui-panel-header"> <div class="ui-panel-header">
{icon ? (
<span class="ui-panel-icon" aria-hidden="true">
{icon}
</span>
) : null}
{title ? <h2 class="ui-panel-title">{title}</h2> : null} {title ? <h2 class="ui-panel-title">{title}</h2> : null}
{actions ? <div class="ui-panel-actions">{actions}</div> : null} {actions ? <div class="ui-panel-actions">{actions}</div> : null}
</div> </div>