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
package, not swarm-ui itself, but the pattern it sets applies here
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.
- **Avoid junk drawers.** A control belongs next to the thing it
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() {
return (
<Panel title="404">
<Panel title="404" icon="🧭">
<p>no route here.</p>
</Panel>
);

View file

@ -88,7 +88,7 @@ function RefreshIntervalPickerSample() {
export function ComponentsPage() {
return (
<Panel title="components">
<Panel title="components" icon="🧰">
<p class="components-intro">
Every primitive in <code>src/ui/</code>, shown in each mode it supports. Sample data
only nothing here calls the API.
@ -101,6 +101,11 @@ export function ComponentsPage() {
<Sample label="without title">
<Panel>panel body content, no title</Panel>
</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)">
<Panel
title="example title"

View file

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

View file

@ -127,7 +127,7 @@ export function CreateAgentPage() {
return (
<div class="create-agent-layout">
<div class="create-agent-form-col">
<Panel title="create agent">
<Panel title="create agent" icon="🤖">
<p class="create-agent-intro">
Create a new agent's swarm-level identity. This only queues the job — check{' '}
<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
wired up server-side yet see this file's top comment)
mara's explicit call on this copy: read as finished, not as a
running commentary on partial implementation. */}
<Panel title="what this creates">
<p class="create-agent-info-glyph" aria-hidden="true">
🪪
</p>
running commentary on partial implementation. The 🪪 glyph that
used to sit here as body copy now lives on `Panel`'s own `icon`
prop instead it's what prompted that prop to exist at all
(see Panel.tsx's doc comment) */}
<Panel title="what this creates" icon="🪪">
<p>
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

View file

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

View file

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

View file

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

View file

@ -11,22 +11,44 @@
// dedicated row stealing vertical space from the panel's actual
// content either). `HivesPage`'s refresh-interval picker is the
// 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 './Panel.css';
export function Panel({
title,
icon,
actions,
children,
}: {
title?: string;
icon?: string;
actions?: ComponentChildren;
children: ComponentChildren;
}) {
return (
<section class="ui-panel">
{title || actions ? (
{title || icon || actions ? (
<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}
{actions ? <div class="ui-panel-actions">{actions}</div> : null}
</div>