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
sidebar · 244px claude-tempo ENSEMBLES @my-band @backend-team @release-crew NAVIGATE Hosts Player types Schedules @my-band 6 players · 4 active · 23 msgs/min main · workspace fills 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
@my-band Building v0.27 · 6 players · 23 msgs/min · ▶ active Details CONDUCTOR · CHAT composer · @ players · / commands ROSTER · 6 conductor · processing composer · awaiting EVENTS · TEMPO
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
TokenMaps toUsed for
--density-pad14px @ d6Panel body padding · main horizontal rhythm
--density-pad-y10px @ d6Vertical padding for rows, headers, buttons
--density-gap14px @ d6Gap between siblings (panel grid, rows)
--density-pad × 1.622px @ d6Page-level padding (header, workspace)
--density-pad-y × 0.55px @ d6KV row padding · chat-log gap
--density-fs13px @ d6Body font-size · table cell text
--density-fs-sm11.5px @ d6Event row · table headers · captions
--density-line1.5 @ d6Body 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
TokenValueUsed for
duration-fast120ms · ease-outHover, focus, button press
duration-base220ms · ease-outModal in, sheet slide, tab change
tempo-pulseon-beat · scale 1 → 1.06The 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

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
TokenMeaningUsed for
--okHealthy, active, succeeded▶ playing chip · tempo heartbeat ✓ · stepper checkmark
--warnPaused, stale, attention⏸ paused chip · slow-host indicator · "1 player idle"
--errFailed, missed, destructive● error chip · disband button · failed heartbeat
--infoNeutral notice, route hintconductor route arrow · system info banners
--accentBrand, primary action, mentionprimary 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
LayerChoiceNotes
FrameworkReact 18 · Vite or NextSPA-style; SSR optional, not required
StylingTailwind 3 + CSS varsTokens live as CSS vars; tailwind.config maps to them
Componentsshadcn/uiButton, Dialog, Sheet, Textarea, Badge, Input, Select, Tabs, Tooltip
Iconslucide-react1.5px stroke · 16px default · accent only when state-bearing
StateZustand or React QueryServer state via Query; UI state local or Zustand
Routingreact-router or Next app routerRoutes mirror sidebar: /ensembles/:id, /hosts, /settings
RealtimeSSE or WebSocketTempo + 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