ade
(alias: console)v1.9.33Web console for iii — bundles the React UI and proxies the engine WebSocket on a single port.
- macOS: arm64
- Linux: arm64 · armv7 · x64
- Windows: arm64 · x64
exact versions are immutable; binary and bundle artifacts are digest-pinned.
skill doc
iii Schematic — design system
This document is a self-contained, portable spec for the iii Schematic UI:
an engineering-document web UI built from layered surfaces and alpha-gray
fills — warm cream paper in light, neutral grays from black in dark — with
Geist for UI text, Geist Mono for technical data, one 6px corner radius
everywhere, and a single rationed accent. The system draws no lines: the
only sanctioned strokes are the focus ring and the subtle edge frame
around the floating workspace panels. Everything needed to reproduce the
system in another project lives inside this file — there are no links to
repository sources.
The YAML frontmatter above is the machine-readable token spec. The sections below translate it into implementation-ready CSS and React. They assume:
- Tailwind CSS v4 (uses the
@themeand@utilitydirectives). - React + TypeScript.
- A
cnhelper built onclsx+tailwind-merge. - Optional:
class-variance-authority(cva) and@radix-ui/react-slotfor theButtoncomponent below.
To adopt the system: copy §0 (Setup) into a new project, then bring over the canonical components in §10 as-is.
0. Setup
Fonts
Two families, loaded via @fontsource at the top of the global stylesheet:
Geist (the UI sans — navigation, buttons, body copy, headings) and
Geist Mono (the technical voice — trace names, IDs, timestamps, metrics,
code). Chivo Mono stays in the mono fallback stack for legacy glyph parity.
Weights 400/500/600 cover the entire scale.
@import "@fontsource/geist/400.css";
@import "@fontsource/geist/500.css";
@import "@fontsource/geist/600.css";
@import "@fontsource/geist-mono/400.css";
@import "@fontsource/geist-mono/500.css";
@import "@fontsource/geist-mono/600.css";
@import "@fontsource/chivo-mono/400.css";
@import "@fontsource/chivo-mono/500.css";
@import "@fontsource/chivo-mono/600.css";HTML shell
Light is the canonical theme. Dark is opt-in via a data-theme="dark"
attribute on (you can also wire prefers-color-scheme to set it on
load — see §3).
<html lang="en" class="antialiased">
<body class="bg-bg text-ink font-sans">
<div id="root"></div>
</body>
</html>Theme stylesheet
The full design-token stylesheet — drop this in as your global CSS entrypoint:
@import "tailwindcss";
@theme {
--font-sans:
"Geist", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--font-mono:
"Geist Mono", "Chivo Mono", ui-monospace, SFMono-Regular, Menlo, Monaco,
Consolas, "Liberation Mono", "Courier New", monospace;
/* ── Surface ramp ─────────────────────────────────────────────────────
Layered surfaces are the ONLY structural tool — the system draws no
lines: bg (canvas) → sidebar → panel → panel-raised, then the
component fills surface → surface-hover → surface-selected →
surface-active. The base layers are solid; the component fills are
alpha grays so one step reads identically over any base layer.
`paper-2` is the legacy alias for panel-raised. */
--color-bg: #f2f0ed;
--color-sidebar: #edeae5;
--color-panel: lab(98.26% 0 0);
--color-panel-raised: #f7f5f2;
--color-paper-2: #ebe8e3;
--color-surface: rgba(20, 16, 8, 0.055);
--color-card-highlight: #dbdbdb63;
--color-surface-hover: rgba(20, 16, 8, 0.085);
--color-surface-selected: rgba(20, 16, 8, 0.12);
--color-surface-active: rgba(20, 16, 8, 0.12);
/* ── Ink ramp ──────────────────────────────────────────────────────── */
--color-ink: #0a0a0a;
--color-ink-faint: #6b6865;
--color-ink-ghost: #a3a09c;
--color-ink-disabled: #b8b4ae;
/* ── Border ramp ──────────────────────────────────────────────────────
The system draws no lines: rule/rule-2/rule-strong resolve to
transparent in both themes (legacy `border-rule*` utilities become
inert 1px transparent borders, so layout never shifts). Hierarchy is
carried by the surface fills above. The single exception is
rule-focus — the focus indicator on inputs and controls, which must
stay visible for accessibility. */
--color-rule: transparent;
--color-rule-2: transparent;
--color-rule-strong: transparent;
--color-rule-focus: rgba(184, 66, 15, 0.6);
/* The one structural stroke the system keeps: a VERY subtle edge on the
main workspace panels (the tab columns), so the floating panels
read against the canvas. Never used inside a panel. */
--color-edge: rgba(20, 16, 8, 0.08);
/* accent (single hero — burnt orange on cream, blue on dark) */
--color-accent: #b8420f;
--color-accent-fg: #f2f0ed;
--color-accent-hover: #a53a0c;
--color-accent-muted: rgba(184, 66, 15, 0.1);
--color-accent-border: rgba(184, 66, 15, 0.35);
/* status (each with a muted fill for tinted backgrounds) */
--color-alert: #ff0026;
--color-alert-muted: rgba(255, 0, 38, 0.08);
--color-warn: #a87a00;
--color-warn-muted: rgba(168, 122, 0, 0.12);
--color-ok: #356f3d;
--color-ok-muted: rgba(53, 111, 61, 0.12);
/* glyph tones: the tint of one 16px identity glyph beside a label; each
holds 3:1 against the sidebar in its theme. Never a fill, a border,
a selection state, or text. */
--color-glyph-blue: #2563eb;
--color-glyph-purple: #7c3aed;
--color-glyph-teal: #0d9488;
--color-glyph-green: #15803d;
--color-glyph-amber: #b7791f;
--color-glyph-rose: #e11d48;
/* ── Radii ────────────────────────────────────────────────────────────
One radius everywhere: every step of the Tailwind scale resolves to
6px, so badges, buttons, cards, panels, and modals share the same
corner. Only `none` (main columns' outer frame edge cases) and `full`
(dots, round action buttons) differ. */
--radius-none: 0px;
--radius-xs: 6px;
--radius-sm: 6px;
--radius-md: 6px;
--radius-lg: 6px;
--radius-xl: 6px;
--radius-full: 9999px;
/* ── Elevation (the only two sanctioned shadows) ────────────────────── */
--shadow-raised:
0 1px 0 rgba(255, 255, 255, 0.025) inset, 0 8px 24px rgba(0, 0, 0, 0.18);
--shadow-floating:
0 1px 0 rgba(255, 255, 255, 0.03) inset, 0 12px 32px rgba(0, 0, 0, 0.28);
/* ── Motion ─────────────────────────────────────────────────────────── */
--motion-duration-instant: 0ms;
--motion-duration-fast: 120ms;
--motion-duration-control: 160ms;
--motion-duration-panel: 220ms;
--motion-ease-standard: cubic-bezier(0.2, 0, 0, 1);
--motion-ease-enter: cubic-bezier(0.16, 1, 0.3, 1);
--motion-ease-exit: cubic-bezier(0.4, 0, 1, 1);
--ease-glide: var(--motion-ease-enter);
/* spacing scale (carried from the YAML) */
--spacing-gutter: 24px;
--spacing-section-x: 36px;
--spacing-section-y: 80px;
--spacing-sheet-max: 1200px;
--spacing-content-max: 1216px;
}
[data-theme="dark"] {
/* Neutral grays derived from black — no blue cast in the base ramp. The
component fills (surface*) are white-alpha so a step reads identically
over any base layer, including selection. Borders are gone:
rule/rule-2/rule-strong
resolve to transparent, and hierarchy is carried entirely by fills. */
--color-bg: #0a0a0a;
--color-sidebar: #0e0e0e;
--color-panel: #111111;
--color-panel-raised: #171717;
--color-paper-2: #171717;
--color-surface: rgba(255, 255, 255, 0.055);
--color-card-highlight: #0d0d0e63;
--color-surface-hover: rgba(255, 255, 255, 0.085);
--color-surface-selected: rgba(255, 255, 255, 0.12);
--color-surface-active: rgba(255, 255, 255, 0.12);
--color-ink: #ededed;
--color-ink-faint: #a6a6a6;
--color-ink-ghost: #6f6f6f;
--color-ink-disabled: #4d4d4d;
--color-rule: transparent;
--color-rule-2: transparent;
--color-rule-strong: transparent;
--color-rule-focus: rgba(40, 168, 247, 0.7);
--color-edge: rgba(255, 255, 255, 0.07);
--color-accent: #28a8f7;
--color-accent-fg: #070909;
--color-accent-hover: #46b6fa;
--color-accent-muted: rgba(40, 168, 247, 0.12);
--color-accent-border: rgba(40, 168, 247, 0.35);
--color-alert: #f05d68;
--color-alert-muted: rgba(240, 93, 104, 0.12);
--color-warn: #f5a524;
--color-warn-muted: rgba(245, 165, 36, 0.12);
--color-ok: #36c98f;
--color-ok-muted: rgba(54, 201, 143, 0.12);
--color-glyph-blue: #3b82f6;
--color-glyph-purple: #8b5cf6;
--color-glyph-teal: #14b8a6;
--color-glyph-green: #22c55e;
--color-glyph-amber: #eab308;
--color-glyph-rose: #f43f5e;
}
@layer base {
html,
body,
#root {
height: 100%;
}
html,
body {
scrollbar-gutter: stable;
}
html {
overflow-y: scroll;
}
body {
background-color: var(--color-bg);
color: var(--color-ink);
/* explicitly disable decorative ligatures — the schematic feel relies on
monospace columns, not typographic flourishes */
font-feature-settings: "liga" 0, "clig" 0, "calt" 0, "dlig" 0;
}
::selection {
background-color: var(--color-accent);
color: var(--color-accent-fg);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* rule is transparent (no-lines system) — the thumb needs its own
visible alpha gray. */
::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-ink) 22%, transparent);
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklab, var(--color-ink) 38%, transparent);
}
}
@keyframes pulse-dot {
0% {
box-shadow: 0 0 0 0 var(--color-accent);
}
100% {
box-shadow: 0 0 0 8px transparent;
}
}
@utility pulse-dot {
animation: pulse-dot 1.6s ease-out infinite;
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@utility blink {
animation: blink 1s steps(1) infinite;
}
@keyframes wiggle {
0%, 90%, 100% { transform: rotate(0deg); }
93% { transform: rotate(-3deg); }
96% { transform: rotate(3deg); }
}
@utility wiggle {
animation: wiggle 3s ease-in-out infinite;
}
/* transient shadow stack — used only for the "deal" stack animation on
language cards (default elevation goes through shadow-raised /
shadow-floating from the theme) */
@utility deal-shadow {
box-shadow: -2px 0 0 var(--color-rule),
-16px 4px 36px -10px rgba(0, 0, 0, 0.22);
}
/* Function/tool-call card chrome — an accent-tinted raised surface (a soft
gradient wash over panel-raised, no border) so calls read as special
without any outline. Derived from the accent token, so it tints blue in
dark mode and burnt orange in light mode. */
@utility fcall-chrome {
background:
linear-gradient(
180deg,
color-mix(in oklab, var(--color-accent) 7%, transparent),
color-mix(in oklab, var(--color-accent) 3%, transparent)
),
var(--color-panel-raised);
}cn helper
Every component below uses this helper. Install clsx and tailwind-merge,
then expose it from lib/utils.ts:
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}1. Philosophy — "iii Schematic"
The app should feel like an engineering document, not a SaaS dashboard — technical, precise, dense without feeling cramped, structured without looking boxed.
- The page is built from layered surfaces: hierarchy comes from a
one-step background difference, full stop. The system draws no lines
— no outlines on controls, no dividers between rows or regions. Exactly
three strokes are sanctioned: the focus indicator (
rule-focus), the very subtleedgeframe around floating workspace panels, and an optional neutraledgeon a selected row/card when fill alone is too subtle. - Dark mode is a first-class layered system — neutral grays derived
from black (
#0a0a0a → #171717, no blue cast) with white-alpha component fills, not an inverted paper ramp. - One corner radius: 6px everywhere (every Tailwind radius step resolves to it); the shapes stay disciplined, not soft or consumer-playful. Natural sentence/title case throughout.
- Color is rationed: the palette is essentially ink-on-surface, broken by a single accent (burnt orange on cream, electric blue on dark) reserved for primary actions, form focus, live/running state, and semantic data. Selection itself remains neutral in both themes.
- The personality is technical but unintimidating — the same energy as a well-kept lab notebook or a hand-drawn architecture diagram. It must feel built by engineers, for engineers, and for the agents working alongside them.
- Density is deliberate: spec sheets, code, traces, and console panels coexist on the same surface without a hierarchy contest. Trace visualizations stay the most colorful area of the UI.
Voice
- Author human-facing UI copy in natural sentence/title case, including headlines, buttons, tabs, menus, fields, and navigation. Never force case with CSS text transforms.
- Headlines treat sentence fragments as visual blocks (e.g. "Any task. One experience.").
- Numbers and metadata always use tabular monospace, never proportional figures.
- The wordmark is pronounced "three eye" — every "i" stays lowercase.
If you removed all the type, the page should still read as a structured document. Surfaces establish hierarchy; color marks state. Lines never appear.
2. Typography — two families (Geist + Geist Mono)
Geist (--font-sans) carries all human-facing UI chrome: navigation,
conversation titles, buttons, inputs, tabs, menus, chat content, empty states,
headings, and labels. Geist Mono (--font-mono) is reserved for
machine-readable values: trace names, worker names, function names, IDs,
timestamps, metrics, span labels, filter expressions, and code-like values.
Rule: if a human wrote it, it's sans; if the machine produced it (or a machine will parse it), it's mono. Don't add a third family — variety comes from weight, scale, case, and letter-spacing, not more fonts. Do not apply mono to an entire panel or function-trigger card; technical values inside those surfaces opt into mono individually.
Decorative ligatures are explicitly disabled on mono surfaces
(liga 0, clig 0, calt 0, dlig 0) to preserve the schematic feel.
Size scale
| Use | Token | Size / weight / extras |
|---|---|---|
| Hero headline | display-hero |
72px / 600 / tracking-[-0.02em] |
| Footer CTA | display-foot |
48px / 600 / tracking-[-0.03em] |
| Section headline | headline-section |
28px / 500 / tracking-[-0.01em] |
| Card headline | headline-card |
20px / 500 / tracking-[-0.02em] |
| Cell title | title-cell |
16px / 600 / tracking-[-0.01em] |
| Body | body-md |
14px / 400 / line-height 1.7 |
| Compact body | body-sm |
13px / 400 / line-height 1.7 |
| Code block | code-md |
13px / 400 / line-height 1.65 |
| Compact code / terminal | code-sm |
12.5px / 400 / line-height 1.55 |
| Label (caps, large) | label-caps-lg |
12px / 500 / UPPER, tracking-[0.18em] |
| Label (caps, medium) | label-caps-md |
12px / 500 / UPPER, tracking-[0.14em] |
| Label (caps, small) | label-caps-sm |
11px / 500 / UPPER, tracking-[0.06em] |
| Diagram micro | micro |
9px / 400 / tracking-[0.04em] |
Number & label rendering
- Any numeric or timestamp cell uses
tabular-numsso columns align. See the duration column inTraceand the version row inWorkerCard(§10). - Human-facing labels keep their authored casing. Do not use CSS
uppercaseorlowercasetransforms on tabs, buttons, menus, fields, or navigation. Preserve acronyms and machine identifiers exactly as provided.
3. Color tokens
All tokens live in the @theme block in §0. Use the Tailwind utility
(bg-bg, text-ink-faint, text-accent, bg-surface, …) — never the
raw CSS variable.
Surface ramp (the depth axis)
The base layers (bg → panel-raised) are solid neutral tones; the
component fills (surface*) are alpha grays, so one step reads
identically over any base layer. surface-selected is a stronger neutral
alpha fill in both themes; it never inherits the orange/blue accent.
| Token | Use |
|---|---|
bg |
Application canvas — the deepest layer |
sidebar |
Left navigation/sidebar (one step off the canvas) |
panel |
Main chat and traces columns |
panel-raised |
Composer, trace details, popovers, active tool cards |
paper-2 |
Legacy alias for panel-raised (kept for existing code) |
surface |
Inputs, controls, pills, chips, secondary cards |
card-highlight |
Borderless neutral inset for related content inside a card |
surface-hover |
Hover state on rows, items, and ghost controls |
surface-selected |
Selected conversation, trace row, list item, card, tab, or chip (neutral wash) |
surface-active |
Strong active/pressed state |
card-highlight is structural emphasis inside an existing card. Use
CardHighlight, uiClasses.cardHighlight, or bg-card-highlight; never add
a border or shadow. Do not use it for hover, selection, focus, status, or as a
standalone card surface.
Collapsible cards
Use the public CollapsibleCard, CollapsibleCardTrigger, and
CollapsibleCardContent composition whenever a card has a concise summary and
optional details. The primitive owns keyboard and ARIA behavior and animates
between intrinsic content heights with the panel motion tokens. Content stays
mounted while collapsed so tabs, forms, and worker-local state survive.
Keep padding and borders inside the content child; padding on the animated
content wrapper prevents a complete collapse. Never place a nested button or
link inside CollapsibleCardTrigger. The global duration tokens make the
transition immediate when prefers-reduced-motion is enabled.
Ink (4-step contrast)
| Token | Use |
|---|---|
ink |
Primary type, wordmark, primary buttons |
ink-faint |
Body in muted contexts, captions, inactive nav |
ink-ghost |
Line numbers, placeholders, timestamps |
ink-disabled |
Disabled labels (paired with reduced opacity) |
Rules (there are none)
rule, rule-2, and rule-strong resolve to transparent in both
themes. They exist only so legacy border-rule* / divide-rule*
utilities stay inert (1px transparent — layout never shifts) instead of
breaking. Never design with them.
| Token | Value | Meaning |
|---|---|---|
rule |
transparent | Legacy default border — draws nothing |
rule-2 |
transparent | Legacy subtle divider — draws nothing |
rule-strong |
transparent | Legacy emphasis border — draws nothing |
rule-focus |
accent ~60–70% alpha | The interactive stroke: the focus indicator on inputs and controls |
edge |
ink ~7–8% alpha | The structural stroke: workspace frames and optional neutral selected edges/rails |
Accent (single hero — burnt orange on cream, electric blue on dark)
accent, accent-fg, accent-hover, accent-muted (10–12%-alpha fill),
accent-border (35%-alpha, legacy). Reserved for focused form controls
(rule-focus), live/running state, primary technical actions, semantic chart
data, and the $ prompt. Never use accent for selection labels, rails,
borders, tab underlines, or selected card fills.
Status
alert, warn, ok — each with a -muted 8–12%-alpha fill for tinted
backgrounds. Status is expressed through text color + a small icon/dot on
a muted tinted fill, never a full-saturation background and never a
stripe or outline.
| Token | Use |
|---|---|
accent |
live / running / focused / primary action |
ok |
success, completed calls, diff additions |
alert |
error states (traces, status panels) |
warn |
warning states, pending approval |
workdir |
the session-scope (working directory) mark in the chat timeline |
workdir (Tailwind sky-500, the same value in both themes) has exactly one
job: the folder glyph in the chat activity trail, beside ƒ (accent), the
trigger bolt (warn) and the registration tower (ok). Its -muted fill
tints the glyph tile in the expanded row. Never a border, a body text color,
or a selection state.
Glyph tones
glyph-blue, glyph-purple, glyph-teal, glyph-green, glyph-amber,
glyph-rose — the identity tints an operator picks for an agent profile or
a sub-agent, and the only place a strong hue sits beside ordinary ink. Each
one tints exactly one 16 px Lucide glyph at the head of a row (the
conversation tree is the canonical use, through data-color on the tree
recipe's icon slot) and is deliberately vivid: a saturated 600-step on cream,
the 500-step on near-black, every value holding at least 3:1 against
sidebar. neutral means no tone — the glyph keeps ink-ghost. A tone is
never a fill, a border, a text color, or a selection or status state; status
still arrives through accent/ok/warn/alert on a dot or a mark.
Dark theme
Override the same tokens inside a [data-theme="dark"] block (see §0). Dark
is a neutral gray ramp derived from black (#0a0a0a → #0e0e0e → #111111 → #171717 — no blue cast), white-alpha component fills
(rgba(255,255,255,0.055 → 0.12)), neutral light ink (#ededed → #a6a6a6 → #6f6f6f), and the accent swapped to electric blue (#28a8f7). Selection
remains a white-alpha neutral wash; blue is reserved for meaningful state,
never the neutral gray ramp.
To follow the OS, set the attribute on load:
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches
document.documentElement.dataset.theme = isDark ? 'dark' : 'light'4. The "surfaces, not borders" rule (key composition pattern)
A one-step surface difference defines a region. Strokes are limited to focus, the workspace
edgeframe, and a neutral selected edge when a wash alone is not sufficiently clear.
Structure comes from the layered surface ramp (§3): a new region means a new
background step, never an outline and never a divider. Controls are
alpha-gray fills; rows separate by their hover/selected fills; regions
separate by base-layer steps; overlays separate by panel-raised +
shadow-floating.
Three strokes are allowed, each with one job:
- Focus — a focused field swaps its (transparent) border to
rule-focusand gains a soft 3px accent ring; keyboard focus on buttons uses the samering-rule-focus. - Panel edges — the main workspace panels (each
workspace-tab column) float on the canvas as
rounded-sm border border-edge bg-panelwith 6px gutters; theedgestroke is a VERY subtle ink-alpha frame that keeps a panel readable against the canvas. - Selection edge — dense lists/cards may pair
surface-selectedwith anedgeborder or 2px inset rail. The edge stays neutral and the label staysink; never substituteaccent.
Nothing else in the chrome may draw a line. (Data visualizations are
exempt: charts may draw connector and grid lines with explicit alpha-ink
fills, e.g. bg-ink/15 elbows and bg-ink/8 time-grid guides in the
trace timeline.)
Pattern
<div className="rounded-md bg-surface overflow-hidden">
<div className="bg-panel-raised px-3.5 py-2.5 font-sans text-[12px] font-semibold text-ink-faint">
Title
</div>
<div className="p-5">{/* body */}</div>
</div>The header strip is one surface step off the body — that difference IS the separator. No divider, no outer outline.
Selection and severity
- Selected row, card, conversation, or trace:
bg-surface-selectedwithtext-ink; optionally add a subtleedgeborder/inset rail for dense navigation. Never recolor the label or rail with accent. - Active segment / tab / filter:
bg-surface-selectedwithtext-ink. - Row severity: the status's
-mutedtinted fill (e.g.bg-alert-muted) plus the status text color and dot — no stripe. - Legacy
divide-y divide-rule-2/border-b border-rule-2classes are inert (transparent) — don't add new ones.
Shadows
Four sanctioned elevation shadows live in the theme: shadow-raised (raised
cards — trace detail), shadow-floating (popovers, dialogs, dropdowns,
tooltips), shadow-lift (a crisp-edged instrument surface — the chat
composer) and shadow-keycap (a key cap: the lift turned upside down, so the
key reads as set into the surface; the shared Kbd renders it). Each is a
complete, theme-aware value used alone. .deal-shadow remains a transient animation cue on
the language-card stack. No heavy glows — the only glow is the live
pulse-dot.
Motion vocabulary
Motion communicates continuity; it never decorates a settled screen or delays input. Use the shared tokens instead of component-local milliseconds:
| Token | Duration | Use |
|---|---|---|
motion-duration-instant |
0ms | Streaming/high-frequency updates and direct manipulation |
motion-duration-fast |
120ms | Tooltip/menu opacity and lightweight feedback |
motion-duration-control |
160ms | Hover, selected, pressed, chevrons, segmented controls |
motion-duration-panel |
220ms | Dialogs, sheets and panel/overlay entry/exit |
Use motion-ease-standard for state changes, motion-ease-enter for mounting,
and motion-ease-exit for dismissal. Width/position updates driven by token
streaming, logs, traces, terminal output, drag, resize, or pointer movement are
instant: repeated updates must not queue animations. Under
prefers-reduced-motion, all three non-zero duration tokens resolve to zero and
non-essential keyframes run once without delay.
When in doubt: step the background one level. Never reach for a border.
5. Surfaces & elevation
The surface ramp is the primary tonal tool. Think of it as a depth axis: every step up reads as "closer" without a single stroke.
bg application canvas
└─ sidebar left navigation
└─ panel main chat / traces columns
└─ panel-raised composer, trace detail, popovers, tool cards
└─ surface inputs, controls, pills, secondary cards
└─ surface-hover hover
└─ surface-selected selected row / trace / conversation
└─ surface-active pressed / segmented-control selectionInteractive containers walk the state sub-ramp (surface → surface-hover → surface-selected → surface-active). Dense selected rows/cards
may also reveal the neutral edge; focus remains the only chromatic stroke.
Where elevation actually shows up
- Tonal layering — the ramp above; a one-step difference is the only separator between regions. The alpha-gray component fills compose over any base layer.
- Sanctioned shadows —
shadow-raisedon raised in-flow cards (composer, expanded trace detail) andshadow-floatingon overlays (dropdowns, popovers, dialogs, tooltips, hover cards). Both carry a 1px white inset highlight so raised surfaces catch light in dark mode. - Accent-tinted chrome — the
.fcall-chromeutility (§0): a soft accent gradient wash overpanel-raised(no border), reserved for function/tool-call cards so agent actions read as special. - Pulse + glow — the live state uses
.pulse-dot(1.6s expandingbox-shadowring) on a 6px accent dot. This is the only "glow" the system allows.
Dark mode keeps the same surface-driven hierarchy — neutral grays stepping up from black. Visible edges remain limited to workspace frames and the optional neutral selected-state edge (§4).
6. Radii & shape
One radius: 6px, globally. Every step of the Tailwind scale
(rounded-xs through rounded-xl) resolves to 6px, so badges, chips,
buttons, inputs, sidebar rows, cards, tool/function-call cards, popovers,
dropdowns, dialogs, the composer, and the floating workspace panels all
share the same corner. There is no per-component scale to choose from —
write whichever step reads naturally (rounded-sm is the conventional
spelling) and it renders 6px.
| Token | Value | Used for |
|---|---|---|
rounded-none |
0px | Full-bleed edge cases only |
rounded-xs … -xl |
6px | Everything — one corner everywhere |
rounded-full |
9999px | Status dots, pills, round action buttons |
Don't invent in-between values, and don't reach for rounded-full on
anything that isn't genuinely circular.
Stroke weight
- UI chrome draws no strokes, with two exceptions: the focus indicator — a
1px
rule-focusborder (inputs) or a 2pxring-rule-focus(buttons) — and the 1pxedgeframe around the floating workspace panels (§4). - SVG data diagrams use 1–1.25px strokes; thicker strokes (1.25px
accent) are reserved for emphasized worker connections. Charts draw their connectors/grids with explicit alpha-ink fills (bg-ink/15,bg-ink/8). - The wordmark is six rectangles (three "i"s, each a stem + a tittle), all sharing the same square unit. The mark is the design system in miniature: identical units, no curves, deliberate negative space.
7. Spacing & layout
Console workspace
The console shell is a set of floating panels over the canvas: the
header holds server-persisted closable tabs (stored in the console
configuration entry under workspace.tabs — model in
lib/workspace-tabs.ts), and each tab shows one or two screens (any page
or the chat view) as equal columns; an unattached column renders an
attach affordance instead of a page. Each column renders as rounded-sm border border-edge bg-panel with 6px
gutters between panels and against the viewport, so the canvas shows
through as the seam. That gutter + edge frame is the entire column
chrome; panels draw no other lines.
Workspace tabs behave like this everywhere:
- Selection is per browser tab. The server pointer
(
workspace.activeTabId+activatedAt/activatedBy) is where a new window lands; a live browser keeps its own choice and follows the server only for a newer function activation (console::workspace::open), never for another browser's click. - Closing the active tab lands on its right-hand neighbour, else the
left one; the last tab never closes. A tab or pane whose page reported
unsaved work (
PageRenderProps.setDirty) asks first through the sharedConfirmDialog; the native prompt remains only for a real reload or browser-tab close. - Overflow scrolls the strip. Fades mark the hidden side, the active tab
is kept in view, and an "All workspaces" menu lists every tab with its
digit. The
+and the menu stay visible outside the scrolled region. - Keyboard: every console key is a modifier chord on the free tier,
Ctrl on a Mac and Alt elsewhere (
⌃below; bare keys kept firing under people's hands from any surface whose focus target was not a field):⌃1–⌃9select by position,⌃⇧←/⌃⇧→step,⌃Tcreates,⌃Wcloses,⌃[/⌃]split left / right (⌃{/⌃}, the same keys with Shift, move the keyboard between panes),⌃G then C/W/Tgo to chat, workers or traces (a sequence binding is chords separated by a space in the registry; the prefix arms a 1.5 s pending state and never acts alone, so a new place costs one more letter rather than one more reserved key). Key caps print uppercase. Inside the strip, arrow keys, Home and End move between tabs and Delete closes the focused one. Nothing fires while the caret is in a field (useKeybindings). There is no key to memorise first: every action and every open workspace is a row in⌘K, each showing its key, and hovering a control spells the same key (hoverTitle), so the keys are learned in passing. The full list is a⌘Krow, not a key of its own. Opening a page from⌘K, a chord orpanels.openlands focus inside it (lib/pane-focus.ts). - Page commands: a page contributes rows to
⌘Kand keys to its own pane throughPageRenderProps.commands(render time, pane-scoped keys) orhost.commands(setup time, rows only). They live inlib/page-commands.tsand die with the worker, so a worker that is not attached has no rows and no keys. The dispatcher runs the console's keys first, then the focused pane's; a page can never shadow a console chord (shortcutClaimReason), and no page may bind a key that types a character: a bare letter, digit or punctuation key, with or without Shift, is refused at registration (isBareKey) because it fired under people's hands from any surface whose focus target is not a field and collided with what the page's own widgets do with the same letter. A page key is a modifier chord or a named key (Escape,End); everything else is a⌘Krow without a key. Chat is the first consumer:Endlatest,Escapestop; the composer, transcript walking, approve or deny, expand, copy, model and new chat are rows. - Palette sources, prefixes, recents: a worker registers a live source
(
host.palette.registerSource,lib/palette/providers.ts): rows computed per query, asked debounced with an abort signal, shown under the source's own group.>and/narrow the palette to commands,#to files,@to chats (parseQuery); an empty query opens on the ten most recent choices (lib/palette/recents.ts); several words match in any order.host.palette.open({ query })lets a row hand over to a mode, the way the shell's "Open file…" lands on#. - Annotations:
AnnotationLayer(components/ui/Annotations.tsx, helpers inlib/annotations.ts) puts numbered accent pins on a picture; the selected pin's note is written in a callout beside it; positions are fractions of the picture; pins are buttons (Delete, arrows). A page freezes its frame while annotating and ships the set throughhost.chat.compose, the composer bus that also takes files (lib/composer-insert.ts), as a stack of attachments: the whole view with the pins painted on plus one crop per pin, with the numbered notes as the text. The browser page is the first consumer; its Annotate mode also resolves the element under each pin into the note's label. - Shared primitives carry the keyboard: an interactive
TableRowjoins the tab order and answers Enter, Space and the arrows; aListwalks its items with the arrows. A page built from them is keyboard-reachable without its own handlers. - Phones switch workspaces from the bottom sheet; each workspace remembers which panel it was showing, per browser tab.
Sheet pages
The page is a vertical sheet, max 1200px, sitting on the canvas surface. There is no hero card — the entire site reads as one continuous spec sheet.
- Sheet:
min(1200px, 100%)centered (seeSheetin §10). The sheet itself is unbordered; the surface step against the canvas defines it. - Sticky nav:
py-4.5vertical padding, collapses topy-2.5on scroll. No border — it reads as chrome by sitting on its own surface. - Section padding:
px-9on desktop (36px),px-4.5on tablet (18px),px-3.5on small mobile (14px). Vertical:py-20topy-24(80–96px) between major sections. - Hero grid: Two equal columns (
1fr 1fr) with a 64px gap on desktop; collapses to a single column under 880px. - Feature grids: Always 3-up on desktop (
grid-cols-3), each cell arounded-md bg-surfacecard separated by gridgap— never by drawn rules. - Card padding: 12px compact, 20–24px default. In-flow cards carry no
shadow unless raised (
shadow-raised); overlays useshadow-floating. - Density rhythm: 4/8 micro-scale for inline gaps (icon-to-text, dot-to-label), 12/14/16 for component padding, 24/28 for card padding, 36 for section gutters, 64–96 for section breathing room.
- Scroll anchors: Anchored sections reserve a 90px scroll-margin to clear the sticky nav.
- Responsiveness uses container queries, not viewport breakpoints. Pages
set
@containerand grids use@3xl:/@4xl:to split. This keeps panels responsive when embedded in different layouts.
8. Schematic motifs (utilities)
Reach for these to keep the engineering-document feel consistent:
Prompt(§10) — orange$(or>) used on terminal/install rows and as a page eyebrow above titles.Caret(§10) — blinking 6×13 ink caret with the.blinkutility.StatusDot(§10) — 6px circle,accentfill, optional.pulse-dotfor "live" emphasis..deal-shadow(§0) — the one allowed transient shadow on stacked cards..wiggle(§0) — 1s wiggle every 3s on the wiggle CTA, to draw the eye in long flows.- Lowercase copy is the default; UPPERCASE only via the
label-caps-*styles.
9. Status & semantics
StatusPanel (§10) is the canonical status display — a rounded row on a
muted tinted fill, monospaced, icon + headline + detail. No stripe, no
outline in any variant; the tint + icon + headline color carry the
severity.
| Variant | Icon + headline | Body fill |
|---|---|---|
v-info |
ink |
surface |
v-success |
ok |
ok-muted |
v-warn |
warn |
warn-muted |
v-alert |
alert |
alert-muted |
In tables and traces: the same recipe — the status's -muted tinted
background plus its text color and dot. Never a stripe, ring, or solid
status background. See Trace (§10).
Dot semantics: green (ok) means completed; accent is reserved for
live/running activity; warn for pending; alert for failed. Selection uses
the neutral surface recipe, never a status dot or chromatic state. For inline
"live" emphasis, pair a StatusDot with the
pulse-dot utility — the only sanctioned glow.
10. Canonical components
Each component below is the reference implementation for that role. Copy
them verbatim into a new project — they all depend only on cn (§0), React,
and (for Button) cva and @radix-ui/react-slot.
Prompt
The orange $ (or >) eyebrow used on terminal/install rows and above page
titles. Renders the prompt symbol in accent, with optional inline content
in ink.
import * as React from 'react'
import { cn } from '@/lib/utils'
interface PromptProps {
symbol?: string
className?: string
children?: React.ReactNode
}
export function Prompt({ symbol = '$', className, children }: PromptProps) {
return (
<span className={cn('font-mono text-accent', className)}>
{symbol}
{children !== undefined ? (
<span className="text-ink ml-2">{children}</span>
) : null}
</span>
)
}Caret
A blinking 6×13 ink caret. Uses the .blink utility from §0. Drop next to a
command to read as a typed query mid-stroke.
import { cn } from '@/lib/utils'
interface CaretProps {
className?: string
}
export function Caret({ className }: CaretProps) {
return (
<span
aria-hidden
className={cn(
'blink inline-block w-[6px] h-[13px] bg-ink align-middle',
className,
)}
/>
)
}StatusDot
6px circle. Optional .pulse-dot glow for "live" emphasis. Tone semantics
per §9: ok = completed, accent = live/running, warn = pending,
alert = failed. Selection is not a status tone.
import * as React from 'react'
import { cn } from '@/lib/utils'
type DotTone = 'accent' | 'alert' | 'warn' | 'ink' | 'ok'
const dotTone: Record<DotTone, string> = {
accent: 'bg-accent',
alert: 'bg-alert',
warn: 'bg-warn',
ink: 'bg-ink',
ok: 'bg-ok',
}
interface StatusDotProps extends React.HTMLAttributes<HTMLSpanElement> {
tone?: DotTone
pulse?: boolean
}
export function StatusDot({
tone = 'accent',
pulse,
className,
...props
}: StatusDotProps) {
return (
<span
aria-hidden
className={cn(
'inline-block size-1.5 rounded-full shrink-0',
dotTone[tone],
pulse && 'pulse-dot',
className,
)}
{...props}
/>
)
}Button
Variants: primary (ink fill, 6px radius — reads as a light chip in dark
mode), ghost (borderless, faint text → surface-hover fill on hover),
pill (compact borderless surface chip), icon (30×30 borderless,
surface-hover on hover), terminal (full-width borderless surface row
with $ prompt + command + optional copy), wiggle (primary + corner badge
slot + .wiggle). Sizes: sm md lg icon. Focus is a 2px
rule-focus ring — the only stroke a button ever shows. Depends on
class-variance-authority and @radix-ui/react-slot.
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'iii-ui-motion-control inline-flex items-center justify-center gap-x-2 whitespace-nowrap rounded-sm font-sans transition-[background-color,color,border-color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rule-focus disabled:pointer-events-none disabled:opacity-40 select-none [&>svg]:size-4 [&>svg]:shrink-0',
{
variants: {
variant: {
primary:
'bg-ink text-bg border border-transparent hover:bg-ink/90 rounded-md',
ghost:
'bg-transparent text-ink-faint border border-transparent hover:bg-surface-hover hover:text-ink',
pill:
'bg-surface text-ink border border-transparent hover:bg-surface-hover',
icon:
'bg-transparent text-ink-faint border border-transparent hover:bg-surface-hover hover:text-ink',
terminal:
'bg-surface text-ink border border-transparent justify-start hover:bg-surface-hover',
wiggle:
'wiggle bg-ink text-bg border border-transparent hover:bg-ink/90 rounded-md relative',
},
size: {
sm: 'h-8 px-3 text-[13px]',
md: 'h-9 px-5 text-[13px]',
lg: 'h-11 px-5 text-[14px]',
icon: 'size-[30px] p-0',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild, children, ...props }, ref) => {
const Comp: React.ElementType = asChild ? Slot : 'button'
return (
<Comp
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
>
{children}
</Comp>
)
},
)
Button.displayName = 'Button'
export { buttonVariants }StatusPanel
A row component for system messages: 18px icon slot, 13px Semi-Bold headline, 12px ink-faint detail. Variants are a muted tinted fill — no stripe, no outline (§9).
import * as React from 'react'
import { cn } from '@/lib/utils'
export type StatusVariant = 'info' | 'success' | 'warn' | 'alert'
const variantTone: Record<
StatusVariant,
{ fill: string; icon: string; headline: string }
> = {
info: {
fill: 'bg-surface',
icon: 'text-ink',
headline: 'text-ink',
},
success: {
fill: 'bg-ok-muted',
icon: 'text-ok',
headline: 'text-ok',
},
warn: {
fill: 'bg-warn-muted',
icon: 'text-warn',
headline: 'text-warn',
},
alert: {
fill: 'bg-alert-muted',
icon: 'text-alert',
headline: 'text-alert',
},
}
interface StatusPanelProps {
variant?: StatusVariant
icon?: React.ReactNode
headline: React.ReactNode
detail?: React.ReactNode
className?: string
}
export function StatusPanel({
variant = 'info',
icon,
headline,
detail,
className,
}: StatusPanelProps) {
const tone = variantTone[variant]
return (
<div
className={cn(
'flex items-start gap-x-3 rounded-md px-3.5 py-3',
tone.fill,
className,
)}
>
{icon ? (
<span
aria-hidden
className={cn('size-[18px] shrink-0', tone.icon)}
>
{icon}
</span>
) : null}
<div className="min-w-0 flex flex-col gap-y-0.5">
<div
className={cn(
'font-sans text-[13px] font-semibold',
tone.headline,
)}
>
{headline}
</div>
{detail ? (
<div className="font-sans text-[12px] text-ink-faint">
{detail}
</div>
) : null}
</div>
</div>
)
}Chat system notices (SystemNotice)
Every role: 'system' transcript entry that is not a compaction marker or a
trigger fire renders through components/chat/SystemNotice.tsx, in one of
three presentations chosen by kind:
- One-line notice (
kind: 'notice'or unset): theStatusPanelrecipe — aw-fitrow on the tone's muted fill (surface/warn-muted/alert-muted), a 16 px Lucide glyph (Info/TriangleAlert/CircleAlert), a sans headline and anink-faintdetail. The console authors these asheadline — detail; the row splits at the dash and raises only a plain leading word (/compact,max_turns, paths stay as authored). - Turn failure (
kind: 'turn-failure'): aCardwhose header leads with WHO has to act — aChip(warning"Needs your attention", neutral "Can happen · retry",danger"iii error") next to a category title — then the harness summary, one plain sentence of ownership, a "What you can do" list onCardHighlight, and a collapsed "Technical details" footer with a copy affordance. Classification lives inlib/turn-failure.ts; transient failures tintwarn, everything someone must fix tintsalert. - Working directory (
kind: 'working-dir'): the activity-row grammar shared with function calls and trigger fires — status icon, kind trail (folder inworkdir), one truncating line, hover disclosure — expanding to the before/after paths onCardHighlight.
No presentation draws a stripe, an outline, or uses CSS case transforms.
CodeBlock
A bg well — the canvas tone punching through the surface it sits on, no
border — monospace at 12.5px / line-height 1.55. Light syntax tinting:
comments italic-ghost, strings in accent, keywords bold-ink, numbers in
alert. The accent orange is reserved for string literals and the active
call return, not arbitrary keywords.
import * as React from 'react'
import { cn } from '@/lib/utils'
interface CodeBlockProps extends React.HTMLAttributes<HTMLPreElement> {
children: React.ReactNode
}
export function CodeBlock({ className, children, ...props }: CodeBlockProps) {
return (
<pre
className={cn(
'rounded-sm bg-bg overflow-x-auto px-5 py-4 font-mono text-[12.5px] leading-[1.55] text-ink',
className,
)}
{...props}
>
<code>{children}</code>
</pre>
)
}Terminal & TerminalRow
Header strip in panel-raised with a label-caps title; surface body with
an orange $ prompt, ink command, and an animated 6×13 ink caret. The
head/body surface step is the separator — no divider.
import * as React from 'react'
import { Prompt } from './Prompt'
import { Caret } from './Caret'
import { cn } from '@/lib/utils'
interface TerminalProps {
title?: React.ReactNode
children: React.ReactNode
className?: string
}
export function Terminal({ title, children, className }: TerminalProps) {
return (
<div className={cn('rounded-md bg-surface overflow-hidden', className)}>
{title ? (
<div className="bg-panel-raised px-3.5 py-2 font-sans text-[11px] font-medium text-ink-faint">
{title}
</div>
) : null}
<div className="p-4 font-mono text-[13px] text-ink">{children}</div>
</div>
)
}
interface TerminalRowProps {
command: React.ReactNode
showCaret?: boolean
className?: string
}
export function TerminalRow({
command,
showCaret,
className,
}: TerminalRowProps) {
return (
<div className={cn('flex items-center gap-x-2', className)}>
<Prompt symbol="$" />
<span className="text-ink">{command}</span>
{showCaret ? <Caret /> : null}
</div>
)
}Trace
Header strip + a list of trace rows with a StatusDot, op label, duration,
and a label-caps status. A waterfall of horizontal bars follows: surface
track, ink fill, alert fill for error spans. Rows separate by spacing —
no dividers.
TraceStatusis the consumer's own domain type. The shape assumed here is{ id, op, durationMs, status, startMs?, spanMs? }. Replace with whatever your project uses.
import * as React from 'react'
import { StatusDot } from './StatusDot'
import { cn } from '@/lib/utils'
export type TraceStatus = 'ok' | 'warn' | 'err'
interface TraceRow {
id: string
op: string
durationMs: number
status: TraceStatus
startMs?: number
spanMs?: number
}
interface TraceProps {
title: React.ReactNode
rows: TraceRow[]
totalMs?: number
className?: string
}
const statusTone: Record<
TraceStatus,
{ dot: 'ok' | 'warn' | 'alert'; label: string; bar: string }
> = {
ok: { dot: 'ok', label: 'text-ok', bar: 'bg-ink' },
warn: { dot: 'warn', label: 'text-warn', bar: 'bg-warn' },
err: { dot: 'alert', label: 'text-alert', bar: 'bg-alert' },
}
export function Trace({ title, rows, totalMs, className }: TraceProps) {
const span =
totalMs ??
Math.max(
...rows.map((r) => (r.startMs ?? 0) + (r.spanMs ?? r.durationMs)),
)
return (
<div className={cn('rounded-md bg-surface overflow-hidden', className)}>
<div className="bg-panel-raised px-3.5 py-2 font-sans text-[11px] font-medium text-ink-faint">
{title}
</div>
<ul>
{rows.map((row) => {
const tone = statusTone[row.status]
const start = ((row.startMs ?? 0) / span) * 100
const width = ((row.spanMs ?? row.durationMs) / span) * 100
return (
<li
key={row.id}
className="grid grid-cols-[auto_1fr_auto_auto] items-center gap-x-3 px-3.5 py-2 font-mono text-[12px]"
>
<StatusDot tone={tone.dot} />
<span className="truncate text-ink">{row.op}</span>
<span className="text-ink-faint tabular-nums">
{row.durationMs}ms
</span>
<span
className={cn(
'text-[11px] font-medium',
tone.label,
)}
>
{row.status}
</span>
<div className="col-span-4 mt-1 h-1 bg-surface relative">
<div
className={cn('absolute top-0 h-full', tone.bar)}
style={{ left: `${start}%`, width: `${width}%` }}
/>
</div>
</li>
)
})}
</ul>
</div>
)
}Cell
The universal container for short prose blocks (features, hellos,
pull-quotes, empty states): a borderless surface card with a 6px radius
and 20px padding, optional 16px Semi-Bold ink title, 13px ink-faint body
capped at ~34ch.
import * as React from 'react'
import { cn } from '@/lib/utils'
interface CellProps {
title?: React.ReactNode
children: React.ReactNode
className?: string
}
export function Cell({ title, children, className }: CellProps) {
return (
<div className={cn('rounded-md bg-surface p-5', className)}>
{title ? (
<div className="mb-3 font-sans text-[16px] font-semibold tracking-[-0.01em] text-ink">
{title}
</div>
) : null}
<div className="max-w-[34ch] font-sans text-[13px] leading-[1.7] text-ink-faint">
{children}
</div>
</div>
)
}WorkerCard
400px-wide ticker card with a name + version row, description, a
panel-tinted command block, and a footer with a kind tag and check icon.
Focused state switches the body fill from surface to surface-selected
(a neutral wash) and may add the neutral edge when fill alone is too subtle.
It never changes the title to accent.
import * as React from 'react'
import { cn } from '@/lib/utils'
interface WorkerCardProps {
name: string
version: string
description: React.ReactNode
command: React.ReactNode
kind: string
focused?: boolean
className?: string
}
export function WorkerCard({
name,
version,
description,
command,
kind,
focused,
className,
}: WorkerCardProps) {
return (
<article
className={cn(
'iii-ui-motion-control w-[400px] rounded-md overflow-hidden',
focused ? 'bg-surface-selected' : 'bg-surface',
className,
)}
>
<header className="flex items-center justify-between px-4 py-3">
<div className="font-mono text-[16px] font-semibold text-ink">
{name}
</div>
<div className="font-mono text-[11px] text-ink-ghost tabular-nums">
v{version}
</div>
</header>
<div className="px-4 py-3 font-sans text-[13px] leading-[1.7] text-ink-faint">
{description}
</div>
<div className="bg-panel font-mono text-[12.5px] text-ink px-4 py-2">
{command}
</div>
<footer className="flex items-center justify-between px-4 py-2">
<span className="font-sans text-[11px] font-medium text-ink-faint">
{kind}
</span>
<span aria-hidden className="text-accent">
✓
</span>
</footer>
</article>
)
}HelloCard
Code card with a head row (icon + meta + step number) and a code body. In
flow mode, two language cards stack with a 120px peek and use the
.deal-shadow utility (the one allowed transient shadow) when sliding on
top of one another.
import * as React from 'react'
import { cn } from '@/lib/utils'
interface HelloCardItem {
id: string
language: string
step: number
body: React.ReactNode
}
interface HelloCardProps {
items: HelloCardItem[]
flow?: boolean
className?: string
}
export function HelloCard({ items, flow, className }: HelloCardProps) {
return (
<div className={cn('relative', className)}>
{items.map((item, idx) => {
const isUnder = flow && idx > 0
return (
<div
key={item.id}
className={cn(
'rounded-md bg-panel-raised overflow-hidden',
isUnder && 'absolute inset-x-0 -z-10 deal-shadow',
)}
style={isUnder ? { top: `${idx * 120}px` } : undefined}
>
<header className="flex items-center justify-between bg-panel px-4 py-2 font-sans text-[11px] text-ink-faint">
<span>{item.language}</span>
<span className="text-ink-ghost tabular-nums">
step {item.step}
</span>
</header>
<pre className="bg-bg px-5 py-4 font-mono text-[12.5px] leading-[1.55] text-ink overflow-x-auto">
<code>{item.body}</code>
</pre>
</div>
)
})}
</div>
)
}EmailRow
The standard input recipe: a rounded surface fill whose only stroke is
the rule-focus underline while focused. The submit arrow lives inside the
row, right-aligned. Helper text below is label-caps in ink-ghost.
import * as React from 'react'
import { cn } from '@/lib/utils'
interface EmailRowProps
extends Omit<React.FormHTMLAttributes<HTMLFormElement>, 'children'> {
helper?: React.ReactNode
inputProps?: React.InputHTMLAttributes<HTMLInputElement>
}
export function EmailRow({
helper,
inputProps,
className,
...formProps
}: EmailRowProps) {
return (
<form className={cn('flex flex-col gap-y-2', className)} {...formProps}>
<div className="iii-ui-motion-control flex items-center gap-x-2 rounded-sm bg-surface px-3 border-b border-transparent focus-within:border-rule-focus">
<input
type="email"
{...inputProps}
className={cn(
'flex-1 bg-transparent py-2 font-sans text-[13px] text-ink outline-none placeholder:text-ink-ghost',
inputProps?.className,
)}
/>
<button
type="submit"
aria-label="submit"
className="iii-ui-motion-control text-ink hover:text-accent"
>
→
</button>
</div>
{helper ? (
<div className="font-sans text-[11px] text-ink-ghost">
{helper}
</div>
) : null}
</form>
)
}SearchField
Large 24px display-style text with a blinking 2px accent caret. Reads as a
typed query mid-stroke.
import * as React from 'react'
import { cn } from '@/lib/utils'
interface SearchFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
showCaret?: boolean
}
export function SearchField({
showCaret = true,
className,
...props
}: SearchFieldProps) {
return (
<label
className={cn(
'flex items-center gap-x-2 font-sans text-[24px] text-ink',
className,
)}
>
<input
type="search"
{...props}
className="flex-1 bg-transparent outline-none placeholder:text-ink-ghost"
/>
{showCaret ? (
<span
aria-hidden
className="blink inline-block w-[2px] h-[24px] bg-accent align-middle"
/>
) : null}
</label>
)
}ImageViewer
The only full-screen image surface. Opens from an ImageThumbnailButton
(zoom cursor, "View bg-black/85 overlay in both
themes; the caption and the controls float as panel-raised cards with
shadow-floating. Wheel and pinch zoom about the pointer, drag pans a
zoomed image inside the stage, double-click toggles fit and actual size;
+/- step a zoom ladder, 0 fits, 1 is actual size, arrows pan, Escape
closes and focus returns to the opener. Zoom steps are instant; fit/actual
toggles move on motion-duration-control. Loading, decode failure, a
missing source and an oversized data URL each render in the stage without
freezing the page. Captions are attachment names or relative paths, never
host paths.
Shared recipes and primitives
@iii-dev/console-ui exposes uiClasses for stable CSS recipes and React
components for behavior. Recipes cover list/list item, card, panel, chip,
responsive table anatomy, line tabs, the 16 px icon baseline, segmented
control, field anatomy and motion. State rides on attributes such as
data-selected="true"; workers do not copy utility lists or invent accent
selection variants.
Use List/ListItem, Card, Panel, Chip, IconButton, and
SegmentedControl for their supported shapes. Selector is the searchable
single-choice contract: groups, disabled options, caller-owned async search,
loading/empty/error/validation states, free-form creation when declared, and
combobox/listbox keyboard semantics. Select remains the finite,
non-searchable choice control. Shared portalled components carry the injected
worker's data-iii-ui scope automatically.
Navigation tree rows
uiClasses.tree / treeItem / treeItemIcon / treeItemLabel /
treeItemCaret / treeItemTrailing / treeItemMeta / treeItemAction are
the compact sidebar hierarchy — the conversation tree (chats and their
sub-agents), a worker's folders or scopes. The comfortable ListItem stays
the list/detail row; the tree is denser and reads as one column of marks:
- Row: 28 px tall, 13 px / 500 sans,
ink-faintat rest andinkon hover or selection, 6 px radius,surface-hover/surface-selectedwashes that span the full row width at every depth. Selection is neutral (data-selected="true"oraria-current), never accent. - Rhythm: the row's own inset is 10 px, the glyph-to-label gap 10 px, the
trailing edge 12 px, and each nesting level adds 14 px of padding — the
row sets
--iii-ui-tree-depthand the recipe derives the indent. The page supplies the gutter around the tree (the console sidebar uses 8 px, so every glyph sits on one 18 px column with the heading above it). - Glyph: one 16 px Lucide mark in
treeItemIcon,ink-ghostby default and tinted throughdata-color="blue|purple|teal|green|amber|rose"with the glyph tones (§3). Every row carries a mark so labels align; a plain chat shows a quiet message glyph. - Caret: the disclosure caret sits right after the label, not at the row
edge —
treeItemCaretwitharia-expanded; aChevronRightturns 90° when open instead of swapping icons. Rows without children render no caret. - Trailing:
treeItemTrailingcollects a status dot,treeItemMeta(11 px tabularink-ghosttimestamps) andtreeItemAction— the X (LucideX, never a trash can) that removes the row, revealed on hover or focus-within, always visible where hover does not exist.data-tone="alert"on the action warms it toalerton hover. - Narrow panes:
data-narrowon the tree lifts rows to 44 px, grows the caret and action to 40 px, and keeps the action visible; coarse pointers get a 48 px hit area on both controls without changing the layout. - Motion: the row wash, caret turn and action reveal use
--motion-duration-control; reduced motion makes them immediate.
Simple tables
Use the shared TableViewport → TableFrame → Table composition for
ordinary tabular content in pages, injected renderers, and chat Markdown.
Compose it with TableHeader, TableBody, TableFooter, TableRow,
TableHead, TableCell, and TableCaption; do not copy local table utility
lists.
The visual is documentary rather than spreadsheet-like: natural-case sans
headers, comfortable row padding, and horizontal dividers only. The table
does not create an outer card, border, shadow, zebra striping, or vertical
rules. Use density="compact" inside chat and the comfortable default on
pages. Put mono only on technical cell values such as field names, paths,
types, IDs, code, and exact wire values. Explanatory copy stays sans. The
viewport owns horizontal overflow; add a domain-specific min-width to the
table when columns would otherwise become unreadable in a narrow split pane.
Only TableRow interactive receives hover treatment, and selected uses the
neutral selection ramp.
Tooltip owns timing, collision handling and portal behavior. The app provides
its provider; compose Tooltip/TooltipTrigger asChild/TooltipContent, or
use IconButton so an icon-only action always has both an accessible name and
the shared tooltip.
Tabs and SegmentedControl (ModeToggle locally)
Use TabsList variant="line"/TabsTrigger or SegmentedControl variant="tabs" for peer content views. The shared line treatment has a
bottom rule, neutral active underline, sans labels at weight 600 in natural
case, and a semantic Lucide icon at 16 px by default. Pass an explicit icon
only when the semantic default is wrong; icon={false} is a documented
space-constrained exception. Do not add private boxed-tab CSS.
Use SegmentedControl variant="radio" for persisted mutually exclusive
choices. Only this variant uses the compact surface track (6px radius, 2px
internal padding) with a neutral surface-selected active segment.
<SegmentedControl
value={view}
onChange={setView}
options={[
{ value: 'list', label: 'List' },
{ value: 'graph', label: 'Graph' },
]}
aria-label="View"
/>NumericBadge
16px tall, 4px horizontal padding, accent fill, 6px radius. Sits on
the top-right corner of a CTA to indicate count or unread.
import * as React from 'react'
import { cn } from '@/lib/utils'
interface NumericBadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
count: number | string
}
export function NumericBadge({
count,
className,
...props
}: NumericBadgeProps) {
return (
<span
className={cn(
'inline-flex h-4 items-center justify-center rounded-xs bg-accent px-1 font-mono text-[11px] font-medium tabular-nums text-accent-fg',
className,
)}
{...props}
>
{count}
</span>
)
}Sheet & PageHeader
Sheet is the standard page-level wrapper — min(1200px, 100%), centered,
defined by its surface against the canvas (no outer rule). PageHeader
carries an eyebrow (rendered through
Prompt), a title (display-hero or headline-section), an optional
description in ink-faint, and an actions slot on the right.
import * as React from 'react'
import { Prompt } from '@/components/terminal/Prompt'
import { cn } from '@/lib/utils'
interface SheetProps {
children: React.ReactNode
className?: string
}
export function Sheet({ children, className }: SheetProps) {
return (
<div
className={cn(
'mx-auto w-full max-w-[1200px] min-h-screen bg-bg',
className,
)}
>
{children}
</div>
)
}
interface PageHeaderProps {
eyebrow?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
actions?: React.ReactNode
className?: string
}
export function PageHeader({
eyebrow,
title,
description,
actions,
className,
}: PageHeaderProps) {
return (
<div
className={cn(
'flex items-end justify-between flex-wrap gap-6 px-9 py-12',
className,
)}
>
<div className="min-w-0">
{eyebrow ? (
<div className="mb-3 font-sans text-[11px] font-medium text-ink-faint">
<Prompt symbol="$">{eyebrow}</Prompt>
</div>
) : null}
<h1 className="font-sans text-[28px] font-medium tracking-[-0.01em] text-ink">
{title}
</h1>
{description ? (
<p className="mt-3 max-w-[60ch] font-sans text-[14px] leading-[1.7] text-ink-faint">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex items-center gap-x-3">{actions}</div>
) : null}
</div>
)
}11. Do / don't
Do
- Author human-facing UI in natural sentence/title case and sans. Preserve machine identifiers and acronyms exactly; never rely on CSS case transforms.
- Lean on layered surfaces to define structure. Limit strokes to focus, workspace framing, and an optional neutral selected edge.
- Ration the accent (burnt orange on cream, electric blue on dark) to focused forms, live/running state, primary technical actions, and semantic data. The rest is ink, faint, and ghost.
- Mark selection with the neutral
surface-selectedfill +ink. In a dense list/card, a subtleedgeborder or inset rail may reinforce it. - Use the one 6px radius everywhere (§6) — every Tailwind radius step resolves to it, so there is no per-component choice to make.
- Keep the
iiiwordmark in lowercase, with all six rectangles inink(never colored). - Use Geist for UI text and Geist Mono for technical data (trace names, IDs, timestamps, metrics, code). Two families, never a third.
- Use Lucide icons (
lucide-react) for every UI icon — checkmarks, carets, funnels, status glyphs. One icon family keeps stroke weight and optical size consistent across the console. Application icons use the shared 16 px baseline; do not author application icons below 16 px. - Use the Tailwind utility (
bg-surface,text-ink-faint,text-accent) — never the raw CSS variable. - Use container queries (
@container,@3xl:,@4xl:) for panel-level responsiveness. Pages set@containerand grids split with@3xl:. - Use
tabular-numson every number, timestamp, and KPI. - Use the shared motion duration/easing tokens; keep streaming, log, trace,
terminal, drag, and resize updates instant; honor
prefers-reduced-motion.
Don't
- Don't draw divider soup or accent selection strokes. Selection edges, when
needed, use only the neutral
edge; charts may draw data lines with explicit alpha-ink fills (bg-ink/15,bg-ink/8). - Don't deviate from the single 6px radius — no in-between values, and
rounded-fullonly on genuinely circular elements. - Don't add drop shadows outside the theme tokens (
shadow-raisedin-flow,shadow-floatingfor overlays,shadow-liftfor instrument surfaces,shadow-keycapfor key caps) and the transient.deal-shadow. No heavy glows —pulse-dotis the only glow. - Don't use the accent for body text, large fills, or decorative blocks. It loses meaning the moment it stops being rare.
- Don't mix proportional and tabular figures. Numbers are always tabular monospace.
- Don't use gradients decoratively. The two sanctioned exceptions are
the
.fcall-chromeaccent wash on function-call cards and the.thinking-shimmerstreaming-text mask. - Don't use CSS
uppercaseorlowercaseto rewrite authored interface copy. - Don't stack two headings of the same scale. Headlines are always paired with an ink-faint continuation, never another headline.
- Don't set technical data (IDs, metrics, span labels) in the sans face, and don't use mono for an entire panel or its human-facing controls.
- Don't hand-write inline
elements or text-glyph icons (✓,×, arrows) in components — use the matching Lucide icon instead. The wordmark and data visualizations (trace bars, diagrams) are the only sanctioned hand-drawn SVG. - Don't paint full-color status backgrounds — use text color plus a
small icon, dot, or left stripe on the status's
-mutedtint. - Don't introduce viewport breakpoints for panel layouts — use container queries.
12. App-level patterns
These patterns are not part of the canonical primitive set in §10 but are app-level compositions that the iii worker registry relies on. They are documented here so that any other surface adopting the iii Schematic can pick them up consistently.
Theme toggle
Light is canonical. To let users switch themes (without losing the
schematic feel), expose a two-segment ModeToggle in the sticky nav strip
labelled light / dark. Selection persists to localStorage under the
key iii-theme and is applied by setting data-theme="dark" (or
"light") on . To avoid a flash of the wrong theme, run a tiny
inline script in before paint:
<script>
try {
var t = localStorage.getItem('iii-theme');
document.documentElement.dataset.theme =
t === 'dark' || t === 'light' ? t : 'light';
} catch (_) {
document.documentElement.dataset.theme = 'light';
}
</script>The toggle component itself is the standard ModeToggle from §10, with
light and dark as its two options. It is the only decoration in the
nav strip's right slot, so the structure stays a clean [wordmark] — [toggle] row. There is no auto OS-follow: the user picks once and that
choice sticks.
Hybrid registry layout
For listing pages that have both a small set of "highlighted" items and a
much longer browseable index (the worker registry being the canonical
example), use a hybrid layout inside a single Sheet:
- Featured row — a 3-up grid of
WorkerCard(the spec component from §10), shown only when no search filter is active. The grid uses container queries (grid-cols-1 @3xl:grid-cols-2 @5xl:grid-cols-3) so it stays responsive within theSheet. Eyebrow above the grid is alabel-caps-lgheading with section spacing — no separator rule. - Search + index — the spec's
SearchField(24px display caret) immediately above arounded-md bg-surfacecontainer; rows separate by hover/selected surfaces (no per-row dividers). Each row is a compact row-shaped variant ofWorkerCard(no command block; just name + version + description + meta). Hover usesbg-surface-hover; the focused/selected row uses the samebg-surface-selectedtint as the focusedWorkerCardin §10. - Search behaviour — when the search field has a non-empty query, the featured row collapses entirely; only the filtered index is shown so that "featured" content never competes with results.
- Empty state — the
Cellfrom §10, with atitlethat names the missing thing and a one-line ink-faint body. Never a centered illustration; never an oversize fill.
This pattern keeps the same surface tone for both the showcase and the
index — the Sheet is unbroken from top to bottom — and uses the existing
primitives without inventing a new "hero card" shape.
API reference panel
For documenting a worker's registered functions and triggers (the "api reference" tab on the worker detail page), use a stacked, expanded-by-default "datasheet" composition rather than a sidebar+console pattern. The whole surface should read top-to-bottom like a printed engineering spec, with no accordions or modals at the row level.
The composition has three nesting levels and uses one tonal step per level:
section eyebrow (label-caps) <- bg page
card bg-panel-raised head + surface body <- per function/trigger
pane bg-paper-2 head + bg body <- per schema (request/response)
tree flat type-table <- per field- Sections — two
Sections (functions, triggers), each with alabel-caps-lgeyebrow on the left and a small ink-ghost count on the right. Same chrome already used by other tabs on the worker page; do not wrap the section bodies in an additional border. - Card — a
rounded-md bg-surfacearticleper function/trigger. Thebg-panel-raisedhead strip carries the name (title-cell) on the left, anymetadata.tagsrendered as borderlessbg-surfacelabel-caps-sm pills next to it, and the kind label (FUNCTION/TRIGGER) in label-caps on the right. Function and trigger cards share the exact same head; the only difference is what the name represents. Triggers do not carry a separate "type" chip — a trigger card is the definition of a trigger type, so the trigger's name (e.g.http-endpoint,cron,queue) is the type. Each card carries a stableid(fn-/trigger-) plusscroll-mt-20so that anchor navigation lands cleanly below the sticky site header. - Pane — the two schemas inside a card sit in a
@2xl:grid-cols-2grid separated by gridgap. Each pane is arounded-smblock with abg-paper-2head strip carrying a label-caps-sm eyebrow (request/responsefor functions,invocation/returnfor triggers). Thebg-paper-2step makes the pane read as one tonal level "down" from the card'sbg-panel-raisedhead. - Tree — fields render as a flat type-table inside the pane body, one
row per field. Each row is
name type required-marker enum constraintson one line, with the description on a second line intext-ink-faint text-[12px]. Nested objects, arrays-of-objects, and union variants indent under the parent by indentation alone (no guide line). Nesting deeper than three levels collapses behind a nativerow labelled "… expand N nested" so the surface stays client-JS-free.
Rendering rule. Schemas in this surface render as type tables — never
as raw JSON dumps. If a schema construct can't be expressed in the table
(e.g. JSON Schema $ref or vendor extensions), surface it as an inline
label-caps chip rather than dropping in a block.
Accent rationing (§3). The required-field marker (*) is the only
accent inside a schema tree. There is no accent on the card head — every
trigger row stays ink-on-paper, just like a function row.
Empty states. Use Cell for "no functions registered" / "no triggers
registered" within a present section, and a StatusPanel variant="info"
when the worker registers neither — same convention as the rest of the
worker page.
Sidebar summary. Pair the panel with an api card in the worker
page's right-hand aside (placed between details and author) listing
every function and trigger the worker exposes. The card uses the same
chrome as the other sidebar cards (rounded-md bg-surface block +
bg-panel-raised head + label-caps-lg title) and contains two
sub-sections — functions and
triggers — each headed by a label-caps-sm eyebrow with a tabular
count on the right. Each row is a Next.js Link whose href always
includes ?tab=api#, where the anchor is the same id stamped on
the corresponding card. This makes the same link work from any tab:
clicking from readme switches to api and scrolls to the target,
clicking from api just scrolls. Always show the card when the
worker has any API surface — it doubles as a table of contents on the
api tab and as a discovery hint on the other tabs. Names render in their
original casing (e.g. transcodeVideo); interface casing never alters
machine identifiers.
Sticky aside. The sidebar aside is sticky inside the page's main
scroll container at @4xl and above:
@4xl:sticky @4xl:top-4 @4xl:self-start @4xl:max-h-[calc(100dvh-2rem)] @4xl:overflow-y-auto.
self-start is required so the grid cell sizes to its content (sticky
needs the element to be smaller than its containing block). The
max-h + internal overflow-y-auto is defensive — for very long
sidebars (e.g. a worker with 50 functions) the api card scrolls
internally instead of overflowing the viewport. At narrower widths the
aside stacks below the main column and is not sticky.
Selection echo. When a sidebar link is clicked, the corresponding
function/trigger card lights up with the canonical neutral
bg-surface-selected wash (the same recipe used by WorkerCard focused
state in §10 and the versions Row's aria-current style); the clicked
sidebar link stays text-ink and may use a neutral edge. This gives the
user a clear "you are here" signal that survives subsequent scrolling
without spending the orange/blue accent.
Why CSS :target is not enough. Next.js Link performs same-page
hash navigation through history.pushState, which does not fire
hashchange and which most browsers do not treat as a :target
re-evaluation trigger. As a result, plain
target:bg-surface-selected only highlights on full page
load and back/forward — not on a soft sidebar click. The api panel
must drive the highlight from JS instead.
Shared hash hub. Both the sidebar links and the cards subscribe to
a single client-side hash hub
(use-active-hash.ts) — a module-level
Set plus one global pair of hashchange + popstate
listeners that broadcast the latest window.location.hash to every
mounted subscriber. The hub also exports setActiveHash(hash) so click
handlers can dispatch optimistically the moment the user clicks, before
the URL has changed. There is exactly one source of truth for "what is
selected", and it is shared across the sidebar (which lives in the
aside) and the card grid (which lives in the main content column),
without any context provider crossing that distance.
Wiring:
- Cards wrap their
in a tiny client component (hash-card.tsx) that callsuseActiveHash()and conditionally addsbg-surface-selectedwhen the hash matches itsid. The card body (header, description, schema panes) is still a server-rendered React tree passed in aschildren, so the only JS that runs in the browser is the wrapper. - Sidebar links (
summary-list.tsx) calluseActiveHash()for their visual state andsetActiveHash(target)fromonClick, which broadcasts to the card subscribers immediately so the tint and the link light up in the same frame as the click — even though Next.js will not have firedhashchangeyet.
This is also why the sidebar uses a single SummaryList client
component for both functions and triggers sub-sections rather
than one client component per section: with the hub providing a single
source of truth, splitting state would only cause stale highlights when
the user crossed sub-sections.
Accent rationing. Per §3, selection costs no accent moment: sidebar and target card use the neutral selection recipe. The required-marker accent inside the schema tree remains meaningful because it lives at a different scale (a single character per row).