## Web design system

The visual language for agent-tempo — a calm, document-leaning operations tool where one human (the maestro) conducts ensembles of long-running coding agents (players). This spec is generated from the live prototype: every component below is rendered with the same CSS the dashboard uses, so what you see is the truth. The TUI has its own system; the two share the metronome motif and the terracotta accent, but otherwise speak different visual languages by design.

### Principles

how we make decisions

#### Document, not dashboard

Avoid HUD-y data slop. Prefer reading-grade type, generous whitespace, and quiet panels over neon stat tiles and gradient cards.

#### Warm, not corporate

Cream-on-charcoal with terracotta. The serif italic is the brand — use it for moments, not chrome.

#### Operator-grade density

Power users will run this all day. Density is a first-class token, not an afterthought — every spacing rule scales from one variable.

#### Status earns color

Five semantic colors total. Anything that isn't ok / warn / err / info / accent is neutral. Resist decorative color.

## Brand & voice

The metronome is the brand. A pendulum that swings on the actual tempo of the ensemble — not a logo, an instrument. The wordmark is mono with a terracotta hyphen. Italic serif is reserved for the maestro (the human operator) and one-word headline accents.

### Mark + wordmark

primary lockup · Metronome + agent-tempo
claude-tempo
Mark: stroked-triangle metronome with a terracotta pendulum. The pendulum is animated when the ensemble is playing (`animation-duration: 60/bpm s`) and frozen when paused — the only continuous animation in the chrome. Wordmark: JetBrains Mono 600, lowercase, with a terracotta hyphen (`-`) as the only color accent. Use the full lockup for primary brand moments; mark-only for app chrome (favicon, sidebar at sub-tablet widths).

### Maestro mark

the human operator's avatar
M — that's you, the operator.
What it represents: the human conductor — the person on the other side of the screen, distinct from the `conductor` player (a software agent). It appears in the composer header, on outbound chat messages, and as the sidebar identity badge. Glyph: bare italic `M` in Fraunces (with Instrument Serif as fallback) at the weight/letter-spacing of a hand-set initial. Bone color (`#F5EBDD`) on a faintly tinted square, so it reads as quill on parchment rather than a UI badge.

### Voice samples

copywriting
✓ Yes

- "Conductor is processing"
- "3 active · 1 detached · ready to dispatch"
- "@my-band · 23 msgs/min"
- "critic's adapter detached ~2 min ago"
- "PR #287 is up — needs a review when you have a moment"

✗ No

- "🚀 Let's get your AI agents working!"
- "Your AI workforce is ready"
- "Powerful. Intuitive. Orchestration."
- "Crushing it: 12 tasks completed"
- "Player_4f3a (Researcher Bot) is now active"

The voice is a senior engineer pairing with you. Player names (`conductor`, `lead`, `tuner`, `critic`) read like teammates, not bots. Ensembles are @-mentioned (`@my-band`). Status sentences are observational, not breathless — the dashboard reports, it doesn't sell.

## Color tokens

All color is token-driven. Tokens are CSS custom properties on `:root` with a `[data-theme="light"]` override. Tailwind reads them via `theme.extend.colors` — never hardcode hex in components.

### Surfaces & ink

neutrals

### Accent

terracotta · the only brand color
Use accent sparingly — primary CTA, brand mark, active nav, maestro's own chat bubbles, the metronome tick. Never as a section background or decorative gradient. `--accent-soft` is the only acceptable accent fill.

### Status

semantic · 4 only

### shadcn token mapping

tailwind.config.ts → :root

    /* globals.css */
    @layer base {
      :root {
        /* shadcn tokens — point to ours */
        --background: var(--bg);
        --foreground: var(--text);
        --card: var(--bg-1);
        --card-foreground: var(--text);
        --popover: var(--bg-2);
        --popover-foreground: var(--text);
        --primary: var(--accent);
        --primary-foreground: var(--bg);
        --secondary: var(--bg-2);
        --secondary-foreground: var(--text);
        --muted: var(--bg-2);
        --muted-foreground: var(--dim);
        --accent: var(--accent-soft);
        --accent-foreground: var(--accent-ink);
        --destructive: var(--err);
        --destructive-foreground: #fff;
        --border: var(--rule);
        --input: var(--rule);
        --ring: var(--accent);
        --radius: 8px;
      }
    }

shadcn components like `<Button>`, `<Input>`, `<Dialog>` read these standard names. By aliasing them to our brand tokens, every shadcn primitive becomes Maestro-themed for free.

## Type system

Three families. Instrument Sans for everything you read or click. Instrument Serif (upright) is the display face for headings, page titles, ensemble names, and the dialog title — anything that wants to read as composed rather than chrome. JetBrains Mono for IDs, timestamps, code, the wordmark, and numerics that need to align.

### Family pairing

3 families · 7 roles
Display @my-band is listening Instrument Serif · 44/48 · italic accent
Page title @my-band Instrument Serif · 34/34 · upright
Section Active players Instrument Serif · 22 · upright
Body critic's adapter detached ~2 min ago — reconnect budget still has 13m. I'll hold reviews until it's back. Instrument Sans · 13.5/21
UI / label Recruit player Instrument Sans · 13/20 · 500
Kicker roster · 6 players JetBrains Mono · 11 · uppercase · 0.12em
Mono player_4f3a · 14:32:08 · @my-band JetBrains Mono · 12 · IDs & numerics
Why two display sizes? Page titles read as the name of the thing in front of you (an ensemble, a host); section titles name a region within. Both are serif so the page reads as a document, not a console. Tailwind: `font-display`, `font-sans`, `font-mono` point at the three CSS variables.

### Italic discipline

italic ≠ display
✓ Italic for

- The maestro mark (Fraunces italic `M`)
- One `<em>` accent inside a display heading (e.g. listening)
- Routed messages in the chat log (visually demoted, "overhearing" voice)
- Empty-state hero text

✗ Never italic for

- Body copy (illegible at length)
- Buttons or labels
- Tables or data
- The page title or section title (those are upright serif)
- Player or ensemble names (those are mono or upright serif)

The serif is doing two jobs: upright serif = "this is a name worth reading" (ensemble, page title, dialog header). Italic serif = "this is a moment" (a verb, an accent, the operator). Don't conflate them.

## Density scale

Density is a single integer token (4–9) on `html[data-density="..."]`. It drives all spacing and type sizes in lockstep. Components don't set padding directly — they consume `--density-*` vars.

### The scale

6 = default
Token consumers: `--density-pad`, `--density-pad-y`, `--density-gap`, `--density-fs`, `--density-fs-sm`, `--density-line`. Set them once at level; every panel, row, button, and chat bubble updates automatically.

## Layout & grid

Three layered grids. The app shell is permanent. Page content is flexible. The workspace inside an ensemble is its own 2-column rhythm.

### App shell

244px · 1fr
Two-column grid: `244px 1fr`. Sidebar is full-height, scrollable independently. Sidebar sections: brand + metronome at top, ensembles list, navigation (hosts, types, schedules, settings), and a maestro identity row at the bottom. At ≤900px the sidebar collapses to 64px icon-only; at ≤520px it becomes a top app bar + bottom tab bar (mobile).

### Ensemble workspace

1fr · 340px
The workspace is a single 2-column grid: `grid-template-columns: 1fr 340px`. Left: conductor chat (scrollable log + composer pinned to bottom), with the roster either stacked beneath or popped into the side column via the "Details" toggle. Right (340px): stacked panels (Roster, Tempo, Events) at natural height; the column scrolls if total height exceeds viewport. At ≤1200px tightens to 300px; at ≤900px collapses to single-column with the side panel becoming a pop-out drawer; at ≤520px the side panel is a bottom sheet.

### Spacing scale

all from density tokens

Token | Maps to | Used for
--- | --- | ---
--density-pad | 14px @ d6 | Panel body padding · main horizontal rhythm
--density-pad-y | 10px @ d6 | Vertical padding for rows, headers, buttons
--density-gap | 14px @ d6 | Gap between siblings (panel grid, rows)
--density-pad × 1.6 | 22px @ d6 | Page-level padding (header, workspace)
--density-pad-y × 0.5 | 5px @ d6 | KV row padding · chat-log gap
--density-fs | 13px @ d6 | Body font-size · table cell text
--density-fs-sm | 11.5px @ d6 | Event row · table headers · captions
--density-line | 1.5 @ d6 | Body line-height

Density is a single integer (4–9) on `html[data-density="..."]`. The seven tokens above scale together; components don't set padding, font-size, or line-height directly — they consume these vars. Set the density once at the root and everything reflows: panels, rows, buttons, chat bubbles, tables.

## Shape, elevation, motion

Restrained. Two radii, two shadows, two motion timings. If you find yourself reaching for a third, simplify.

### Radius

2 values
6px · controls, rows, chips
10px · panels, modals

### Elevation

2 shadows
shadow-1 · panels
shadow-2 · modals, popovers

### Motion

2 timings · 1 easing

Token | Value | Used for
--- | --- | ---
duration-fast | 120ms · ease-out | Hover, focus, button press
duration-base | 220ms · ease-out | Modal in, sheet slide, tab change
tempo-pulse | on-beat · scale 1 → 1.06 | The metronome tick (only)

No bouncy easings. No spring. The metronome pulse is the only animation that loops; everything else is one-shot transitional.

## Button shadcn: Button

Three variants. Primary is terracotta — exactly one per page region. Secondary is the workhorse. Ghost is for low-priority chrome actions.

### Variants

primary · secondary · ghost · destructive
Implementation: shadcn `<Button>` with our `--primary` & `--secondary` tokens. Add a custom `variant="ghost"` mapping to transparent + `--text-2`. Min height 32px @ d6, scales with `--density-pad-y`.

## Phase chips, type badges, mentions shadcn: Badge

Pill-shaped metadata. Three flavors that look similar but mean different things: phase chips for player state (drawn as `.phase-dot` — icon + optional label), type badges for player types (drawn as `.type-badge` — colored by type hue), and generic chips for everything else (filter, tag, cadence label).

### Phase chips

7 phases · TUI parity
●active ●processing ○idle ◐draining ◐detached ◔booting ✕gone
Glyphs are inherited from the TUI verbatim (`● ○ ◐ ◔ ✕`) so a single mental model works across web and terminal. Pulse: `processing` is the only phase that animates — a subtle opacity blink on the icon, so you can tell at a glance which players are doing work right now. Phase data + colors live in `window.PHASES` and the `<PhaseDot>` component reads from it.

### Type badges

colored by type hue
tempo-conductor tempo-composer tempo-soloist tempo-tuner tempo-critic tempo-roadie tempo-improv tempo-liner
Each type maps to a stable hue (see `window.TYPE_HUES`). The badge derives `color`, `border`, and `background` from that one hue at fixed L/C — so adding a new type only requires picking a hue. This is also how `<PlayerAvatar>` tints itself.

### Generic chips

filter · tag · cadence
all active @my-band every 20m cron 09:00
Generic chips are clickable filters (form picker, type selector). The active state uses `--accent-soft` background + accent ink. Don't use these for status — use phase chips. Don't use these for types — use type badges.

## Panel

The fundamental container. A panel has a head (label + meta + actions) and a body. Panels never nest more than one level — if you need to, the inner thing is a card, not a panel.

### Anatomy

head · body · (footer)
Roster 3 players · 1 idle
Panel body content goes here. Spacing is driven by `--density-pad`.
Tailwind: `rounded-[10px] border border-rule bg-bg-1`. Head: `border-b`, `px-[var(--density-pad)] py-[var(--density-pad-y)]`. Headline is uppercase 11px tracked-wide; meta is mono 10.5px dim.

## Composer shadcn: Textarea

The text input where the operator addresses the conductor. Auto-grows to ~6 lines, then scrolls. Slash-commands and @-mentions are the only adornments.

### Default

empty & filled
Tell the conductor what to do…
/ commands · @ mention a player
Built on: shadcn `<Textarea>` wrapped in a styled container. The footer is a peer flex row, not part of the textarea. Send button is primary; `⌘↵` shortcut.

## Chat row

The conversation between the maestro (you) and the conductor player. Three voices: the conductor talks back to you in plain rows; your messages are right-aligned cards with a terracotta edge; routed messages between the conductor and other players are visually demoted (smaller avatar, italic body, "overhearing" rail) — present but not loud, because they're not addressed to you.

### Three voices

conductor (in) · maestro (out) · route
𝄞
conductor 14:02
Acknowledged. I'll split this into two parallel tracks. lead takes the state machine, eng takes the hosts tool. tuner will run the conformance suite after each merge.
♪
conductor → lead 14:03
start on attachment-math.ts — unify the lease-extension helper with the CAN-boundary path. Keep it pure; no Temporal imports.
maestro → conductor 14:18
how's critic doing? last I saw it was reviewing PR #284
𝄞
conductor 14:18
critic's adapter detached ~2 min ago — reconnect budget still has 13m. I'll hold reviews until it's back.
Conductor (`.msg`): grid `28px 1fr`, treble-clef avatar in conductor gold, body in plain text up to 72ch wide. Maestro (`.msg.out`): right-aligned card, `--bg-chat-out` background, 3px terracotta right border, 78% max-width, no avatar — the right-edge accent is the identity. Route (`.msg.route`): indented, shrunk avatar (20px), italic body in `--text-2`, and a 2px rail on the left to convey "overhearing." This is critical: in a dashboard with hundreds of inter-agent messages, you must instantly tell which ones are for you.

## Roster row

A single player, condensed. Avatar, name, phase chip, last-event snippet, host. Dense — operator scans this list often.

### Anatomy

avatar · name · phase · last · host
R
researcher player_4f3a
Reviewed 3 RFCs · summary in progress
▶ playing claude-code
W
writer player_2b91
Awaiting researcher's summary
idle staging-2
Click target: the entire row → opens player detail sheet. Hover: bg lifts to `--bg-2`. Avatar is a serif italic letter from the player type name — no images.

## Event row

The audit trail. Mono, dense, no avatars. Time on the left, actor in accent, action in neutral. Filterable but never decorated.

### Stream

ring · max 200 · messages elided
14:32:08 conductor recruited player_4f3a · researcher
14:32:14 player_4f3a phase → playing
14:33:01 @my-band heartbeat ✓ · 3 players · 4 bpm
14:33:42 conductor routed task → player_2b91
What lives here: phase transitions, heartbeats, recruits, ensemble lifecycle, conductor route decisions. What doesn't: message content (it's already in the chat — never duplicate).

## Tempo strip

A horizontal sparkline of message activity over the session window — one bar per minute, recent bars in `--accent`, older bars in neutral. Current bpm sits in the top-right as an overlay. There's no ticking dot, no beat grid in the visible sense — the sparkline is the tempo. It tells you "how loud is this ensemble right now, and is it speeding up or slowing down."

### Default

.tempo-strip · 60 bars · 92 bpm
tempo 92 bpm
Markup: a positioned wrapper with a label overlay (`.tempo-strip-label`) and an SVG that fills 100% width via `preserveAspectRatio="none"` — bars stretch to the viewport. Bar width 4px + 2px gap; every 10th column gets a dashed ruler line so the eye can chunk time. Color rule: last 10 bars (the most recent ~10 minutes of activity) render in `--accent`; older bars render in `--rule-strong` at 0.75 opacity. bpm overlay is computed server-side from the rolling-window message count, not from a fake metronome — it's a real signal. Sits in the page header above the workspace, full-width.

## Key/value table

Player metadata, ensemble settings, host configuration. Two columns: label dim/uppercase, value default. Mono for IDs, sans for everything else.

### Player metadata

5 fields
id
player_4f3a-9c2e
type
researcher
host
claude-code · staging-2
ensemble
@my-band
recruited
2025-04-25 14:32:08

## Modal & sheet shadcn: Dialog · Sheet

Modals interrupt; sheets reveal. Use a modal for confirmations and quick forms (≤ 4 fields). Use a sheet for player detail, longer forms, and anything with its own scroll.

### Modal · confirmation

centered · max-w 480

#### Disband @my-band?

3 players will be released. Pending tasks will be cancelled. This cannot be undone.

Pattern: serif italic in the title carries the ensemble or player name being acted on — the only place the italic shows up in modals. Destructive primary uses `--err`.

## Wizard

Multi-step flows: create ensemble, recruit player, add host. Steps listed at the top, current step bold + accented, completed steps muted with a check, future steps dim.

### Stepper

3 steps · step 2 active
✓
Pick a player type
2
Choose host
3
Loadout

## Status colors

Five total. Each has a single canonical use and a single canonical soft-fill. Don't tint backgrounds with status colors — use them for ink, borders, or 1-pixel accents only.

### Status semantics

never decorative

Token | Meaning | Used for
--- | --- | ---
--ok | Healthy, active, succeeded | ▶ playing chip · tempo heartbeat ✓ · stepper checkmark
--warn | Paused, stale, attention | ⏸ paused chip · slow-host indicator · "1 player idle"
--err | Failed, missed, destructive | ● error chip · disband button · failed heartbeat
--info | Neutral notice, route hint | conductor route arrow · system info banners
--accent | Brand, primary action, mention | primary CTA · brand mark · conductor name · @mention

## Scroll & focus

Scrollbars are quiet. Focus rings are unmistakable. The dashboard is a keyboard-first tool — every interactive element must show a clear focus state.

### Focus ring

2px accent · 2px offset
Tailwind: `focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2`. shadcn's `focus-visible:ring` is rebound to our accent via `--ring`.

### Scrollbar

webkit · 8px
Scrollable area · 8px webkit scrollbar · track is bg-1, thumb is rule-strong, on hover bumps to dim. No arrows. No background pattern. Quiet.

## Tech stack

How the design lands in code.

### Stack

React · shadcn/ui · Tailwind

Layer | Choice | Notes
--- | --- | ---
Framework | React 18 · Vite or Next | SPA-style; SSR optional, not required
Styling | Tailwind 3 + CSS vars | Tokens live as CSS vars; tailwind.config maps to them
Components | shadcn/ui | Button, Dialog, Sheet, Textarea, Badge, Input, Select, Tabs, Tooltip
Icons | lucide-react | 1.5px stroke · 16px default · accent only when state-bearing
State | Zustand or React Query | Server state via Query; UI state local or Zustand
Routing | react-router or Next app router | Routes mirror sidebar: /ensembles/:id, /hosts, /settings
Realtime | SSE or WebSocket | Tempo + event log are streamed; chat is request/response

### tailwind.config.ts

font + color extensions

    export default {
      content: ['./src/**/*.{ts,tsx}'],
      theme: {
        extend: {
          colors: {
            bg: 'var(--bg)',
            'bg-1': 'var(--bg-1)',
            'bg-2': 'var(--bg-2)',
            text: 'var(--text)',
            'text-2': 'var(--text-2)',
            dim: 'var(--dim)',
            rule: 'var(--rule)',
            accent: 'var(--accent)',
            ok: 'var(--ok)',
            warn: 'var(--warn)',
            err: 'var(--err)',
            info: 'var(--info)',
          },
          fontFamily: {
            sans: ['Instrument Sans', 'ui-sans-serif', 'system-ui'],
            display: ['Instrument Serif', 'serif'],
            mono: ['JetBrains Mono', 'ui-monospace'],
          },
          borderRadius: { md: '6px', lg: '10px' },
        },
      },
    }

### File layout suggestion

react app

    src/
      styles/
        globals.css        // tokens + density + base
      components/
        ui/                // shadcn primitives (Button, Dialog, …)
        Panel.tsx          // Maestro panel wrapper
        Composer.tsx
        ChatRow.tsx
        RosterRow.tsx
        EventRow.tsx
        TempoStrip.tsx
        Wizard.tsx
      pages/
        Overview.tsx
        Ensemble.tsx       // the workspace
        Host.tsx
        Settings.tsx
      lib/
        bus.ts             // SSE / WS client
        tokens.ts          // density level setter