Design: Operator Board
Context
SPEC-0013 realizes ADR-0016 — the "Operator"
(brass & bakelite) design language selected from the Claude Design hero-journey package — as
switchboard's five-view human surface, replacing the minimal dashboard/agent/vended screens of
SPEC-0012. SPEC-0012 remains the baseline for everything beneath the pixels:
OIDC sessions, CSRF, security headers, embedded assets via embed.FS, SSE plumbing over
http.Flusher, error-handling posture, and WCAG 2.1 AA. This spec is the what the operator sees
layer: the Board's live incoming-lines feed, the durable-queue Todos table and detail drawer, the
Endpoints vend/revoke lifecycle, and — as their capabilities land — Personas and Friends.
The authoritative interaction reference is the design package's full build-out
(Switchboard.dc.html, direction 1a); its component inventory and token values are distilled in the
docs site's Design section. Data semantics come from the governing specs: todo lifecycle from
SPEC-0003, vending from SPEC-0007, personas
from SPEC-0009, friending from SPEC-0010, and agent
connectivity from SPEC-0014.
Goals / Non-Goals
Goals
- One coherent operator surface where every queue-lifecycle transition is visible live (SSE) and every human action (claim/complete/fail/extend/release, vend/revoke, approve/decline) is one or two interactions away.
- Encode the trust model and the durable-queue semantics in the visual vocabulary (trust badges, lease countdowns, dedup badges, lifecycle timeline) so the UI teaches the system's guarantees.
- Ship the design language as owned, documented tokens shared with the docs site.
- Stay within ADR-0001's operational envelope: server-rendered, HTMX fragment swaps, no bundler, all assets embedded, strict same-origin CSP.
Non-Goals
- A SPA or client-side state store — the server renders truth; SSE nudges the DOM.
- Mock-parity animation fidelity (the design canvas simulates traffic; production animates only real transitions).
- Multi-operator/admin views, theming beyond the two ADR-0016 themes, or mobile-first layouts (the rail collapses, but the board is a desk tool).
- Re-specifying queue/vend/persona/friend semantics — those live in their own specs.
Decisions
Server-rendered views + HTMX fragments + a thin vanilla-JS layer
Choice: Each view is a full server-rendered template; live regions are HTMX sse-swap targets
fed by the /events stream; the drawer and modals are HTMX-fetched fragments; a small vanilla JS
file (embedded, CSP-clean) handles purely presentational timers (lease countdown, retry countdown,
toast auto-dismiss) driven by data-* attributes the server stamps.
Rationale: Countdown ticking and focus trapping are the only behaviors HTMX cannot express;
everything stateful stays server-side per ADR-0001. The JS layer never mutates domain state — it
animates toward server-stamped deadlines and re-syncs on every SSE swap.
Alternatives considered:
- Alpine.js: another vendored dependency and an expression DSL in markup for two timers — rejected.
- Pure HTMX polling for countdowns: 1-second polls per open drawer are needless load — rejected.
One authenticated UI SSE stream with typed, human-scoped events
Choice: A single GET /events stream per session carries named SSE events
(event_received, todo_created, todo_claimed, todo_completed, todo_failed,
todo_resurfaced, endpoint_seen, counts) whose payloads are pre-rendered HTML fragments plus
out-of-band swaps for count pills and toasts (hx-swap-oob).
Rationale: Pre-rendered fragments keep all templating server-side (no client templates), let one
event update multiple regions (row + pill + toast) atomically, and reuse the existing lossy Hub
fan-out pattern. Named events let each view subscribe only to what it shows.
Alternatives considered:
- JSON events + client rendering: duplicates templates client-side — rejected.
- Per-view streams: N streams per tab multiplies connections for no isolation win — rejected.
The drawer and modals are overlays over live views, not routes
Choice: The todo drawer (GET /todos/{id}) and the vend/persona/friend modals render as overlay
fragments swapped into a dedicated overlay slot in the layout; the underlying view keeps its SSE
subscriptions. Overlay open/close is also reachable by full-page fallback (the fragment endpoint
renders a standalone page when not requested via HTMX) so deep links and no-JS degrade sanely.
Rationale: Matches the designs (drawer slides over the queue; toasts continue), preserves
progressive enhancement, and keeps URLs meaningful.
Counts, filters, and search are server-computed
Choice: Filter pills, todo counts, and search execute as normal GETs with query params
(?filter=failed&q=stripe) returning the table fragment; SSE counts events keep pill numbers live
between interactions.
Rationale: The store already answers these queries with indexed SQL; client-side filtering would
require shipping the dataset.
Design tokens split from components; fonts vendored
Choice: static/tokens.css = custom properties only (palette, type scale, radii, shadows,
motion, both themes); static/switchboard.css = .sb-* component classes consuming tokens;
static/fonts/ = woff2 subsets of Zilla Slab (500–700), IBM Plex Sans (400–600), IBM Plex Mono
(400–600) with OFL texts, font-display: swap.
Rationale: Per ADR-0016 — the docs site imports tokens without dragging app component styles;
the --pico-* block is deleted with Pico.
Capability gating for Personas and Friends
Choice: The rail renders Personas/Friends entries only when their capabilities are enabled (feature detection at the server: personas store + well-known routes registered; friending store registered). Direct GETs to gated views return 404 while disabled. Rationale: The board ships before SPEC-0009/0010 land; hidden-not-broken is the only state that respects both the designs and the build order.
Architecture
Live-update flow: store mutation → pg_notify / hub publish → hub renders the fragment set for each
subscribed human → named SSE event → HTMX swaps row, pills (oob), and toast (oob). Reloads always
re-render from PostgreSQL, so a dropped frame costs freshness, never correctness.
Risks / Trade-offs
- Owned CSS a11y burden (no framework baseline) → the component layer ships with an axe-based template test pass and contrast checks for both themes in CI.
- SSE fan-out cost as views multiply → one stream per session, named events, fragments rendered once per event per human (not per region); slow consumers are dropped per the lossy-hub baseline.
- CSRF over HTMX — every state-changing swap must carry the token → a single
hx-headersdeclaration on<body>injects it uniformly; a template test asserts its presence. - Client countdown drift vs server truth → countdowns are cosmetic; server stamps absolute deadlines and every SSE update re-syncs; actions validate server-side regardless of what the timer showed.
- Capability gating drift (Personas/Friends arriving later) → gating is feature-detected from registered stores/routes, not config flags that can lie.
Migration Plan
- Land tokens + components + fonts (
tokens.cssrewrite,switchboard.css,static/fonts/) and restyle the existing four screens minimally — no route changes yet. - Ship the layout shell (top bar, rail, overlay slot, toast region) + the UI SSE stream.
- Ship Board, then Todos + drawer (both depend only on existing store capabilities).
- Ship Endpoints view + vend modal, folding SPEC-0012's dashboard/agent/vended screens into it;
retire those templates and their routes (
/agents,/agents/{id}→ redirects to/endpoints). - Personas and Friends views land inside their capability epics, gated until then.
- Update SPEC-0012 cross-references (screen-set + styling requirements marked superseded by this spec) in the same change that retires the old screens.
Open Questions
- Whether the operator "Claim" action claims as a distinguished operator identity (
@you · ops) or requires selecting one of the human's agents — the designs show the former; the store API accepts an arbitrary claimant label either way. - Whether
endpoint_seenevents are worth per-request hub traffic or should be coalesced to a 30-second tick.