One ink, three faces, forty parts
Everything below is rendered from @spinrun/ui, the one package the marketing site, both product modes and the docs all draw from. The swatches read the value your browser resolved, so a chip here cannot disagree with the CSS.
docs/design.mdthe written source of truthdocs/architecture.mdthe app's shape and its packages- 91
- tokens, in one palette
- 14
- groups, all machine-readable
- 40
- primitives — 27 shadcn, 13 ours
- 1
- package, three hosts
The one thing to get right: scope
Half the vocabulary — the three grounds, the three inks, the three line weights, the tight radius ladder, the shadow ladder, the easings — is defined inside .site-app, which the product shell puts on the app and a marketing page does not. Reaching for one of those outside the scope does not error. It simply does nothing.
Outside .site-app
bg-canvas rounded-[var(--radius-card)]
shadow-[var(--shadow-tile)]No ground, no radius, no edge. Silently.
Inside .site-app
bg-canvas rounded-[var(--radius-card)]
shadow-[var(--shadow-tile)]A well, rounded at the card rung, with the tile edge.
Palette
One palette, at :root, read by marketing, both product modes, auth and the docs site alike. Colour means state and nothing else; the only place a hue is decorative is a chart series.
Brand
One ink, three stops. Buttons run a vertical gradient from --brand-top down to --brand; chat bubbles and pills run outward from --brand to --brand-soft; flat fills use --brand.
--brandThe ink. Every emphatic surface.
—
--brand-softThe far stop of an outward gradient.
—
--brand-topThe top stop of a button's gradient face.
—
--accent-brandA second name for --brand, read by the landing's gradients.
—
--accent-brand-lightThe lighter half of those gradients.
—
--accent-brand-glowBrand at 10% — glows and halos.
—
--accent-brand-subtleBrand at 5% — the quietest wash.
—
Semantic
The shadcn contract. Every primitive is built on these names, so a surface changes by redefining them rather than by editing components.
--backgroundThe page.
—
--foregroundBody ink.
—
--cardA raised surface.
—
--card-foregroundInk on a card.
—
--popoverAn overlay surface.
—
--popover-foregroundInk on an overlay.
—
--primaryThe emphatic action.
—
--primary-foregroundInk on it.
—
--secondaryThe quiet action.
—
--secondary-foregroundInk on it.
—
--mutedA wash behind something inactive.
—
--muted-foregroundSecondary ink.
—
--accentHover ground.
—
--accent-foregroundInk on hover.
—
--borderA drawn edge.
—
--inputA field's edge.
—
--ringThe focus ring.
—
--signalAlias of --brand, kept for `hover:text-signal`.
—
--signal-foregroundInk on it.
—
Status
Colour means state and nothing else. Each hue has a tint to sit on, which is what stopped every pill in the product inventing its own bg-emerald-100.
--successWorking, healthy, connected.
—
--success-foregroundInk on a filled success surface.
—
--success-tintThe wash a success pill sits on.
—
--warningWaiting on something or someone.
—
--warning-foregroundInk on a filled warning surface.
—
--warning-tintThe wash a warning pill sits on.
—
--destructiveBroken, failed, denied.
—
--destructive-tintThe wash a destructive pill sits on.
—
Charts
The one place hue is decorative. Five stops, in oklch, reused in order.
--chart-1First series.
—
--chart-2Second series.
—
--chart-3Third series.
—
--chart-4Fourth series.
—
--chart-5Fifth series.
—
Neutrals
Warm greys that end on the ink. Reached by name only when the semantic set has nothing that fits.
--neutral-0White.
—
--neutral-50The lightest wash.
—
--neutral-100A hover ground.
—
--neutral-200A sidebar edge.
—
--neutral-250The border grey.
—
--neutral-300A touchable edge.
—
--neutral-400Disabled ink.
—
--neutral-500Secondary ink.
—
--neutral-600Strong secondary ink.
—
--neutral-700Near-ink.
—
--neutral-800The ink.
—
--neutral-900Darker than the ink.
—
--neutral-950The deepest stop.
—
Lines and texture
What replaced alternating dark and light bands: a dotted rail down each edge of the measure, and a diagonal hatch behind grouped content.
--railThe dotted page rails and section rules.
—
--pattern-fgThe hatch's ink. Faint by default; a panel may raise it.
—
The three groups below are the product surface’s own, defined inside .site-app. Their swatches wrap themselves in that class so they paint here; on a page that does not, they paint nothing.
Product surfaces
Three grounds, never white on white: --page is the gutter and the rail, --canvas a well, --surface the card itself. Defined inside .site-app — reaching for them on a marketing page gets nothing, deliberately.
--page.site-appThe gutter behind the content card.
—
--canvas.site-appA well: a code block, an idle row, a demo frame.
—
--surface.site-appThe card.
—
--inset.site-appA hover ground inside a card.
—
--tint.site-appThe wash behind inputs and idle rows.
—
--code-surface.site-appThe ink at 5%, for code and URL blocks.
—
--code-ink.site-appInk inside them.
—
Product inks
Three weights: --ink for what is being read, --ink-2 for what is beside it, --ink-3 for what is only there if you look.
--ink.site-appWhat is being read.
—
--ink-2.site-appWhat is beside it.
—
--ink-3.site-appIndex numbers, counts, units.
—
Product lines
Three weights: --line-soft separates rows inside one card, --line is the card's own edge, --line-strong the edge of something you can touch.
--line-soft.site-appA rule between rows.
—
--line.site-appA card's edge.
—
--line-strong.site-appA focused field, a button.
—
Type
Loaded once in @spinrun/ui/fonts, put on <html> as three CSS variables, and mapped onto Tailwind's font keys in tokens.css. Calling next/font from inside a workspace package is nowhere promised in the Next docs; it works because every host transpiles this package, and next build in all three is what proves it rather than assumption.
Type
Three faces, loaded once in @spinrun/ui/fonts and applied at <html>. Host-only faces (JetBrains Mono in web, Satoshi in agents) are that host's business.
--font-sansRunning UI. Geist.
—
--font-displayHeadlines, labels, buttons. Inter.
—
--font-headingAlias of --font-display.
—
--font-monoCode, terminal output, tabular readouts. Geist Mono.
—
The scale
marketing 16px · product 13pxTwo registers, and the .site-app scope is what separates them. A marketing page is read at arm's length; a run transcript is read at a glance, forty rows at a time.
.font-label and .headline
@spinrun/ui/styles/utilities.cssTwo recipes a token cannot express. .font-label was monospace uppercase under the old language; changing the recipe re-toned about ninety call sites without touching any of them.
Radius
Every rung is computed from one number, so moving --radius moves the whole ladder and nothing has to be re-picked by eye.
The Tailwind ladder
rounded-sm … rounded-4xlEvery rung is a multiple of --radius, so changing the base moves the whole ladder and nothing has to be re-picked.
Radius
One base (--radius) with a seven-rung Tailwind ladder off it, plus four product rungs for the denser register. The product rungs are reached as arbitrary values — rounded-[var(--radius-card)] — because a @theme key may not reference a variable of its own name.
--radiusThe base. 0.75rem.
—
--radius-chip.site-appA chip.
—
--radius-control.site-appA control in a dense row.
—
--radius-card.site-appA card.
—
--radius-overlay.site-appA panel or overlay.
—
--radius-window.site-appA window.
—
Why the scoped rungs are arbitrary values
rounded-[var(--radius-card)]Not a workaround anybody is waiting to fix.
A @theme key may not reference a variable of its own name: --radius-card: var(--radius-card) is a cycle, and Tailwind resolves it to nothing.
Both ways out are worse. Registering the scoped rungs under different names leaves two vocabularies for one ladder, and the CSS stops reading like the ladder it is. Writing literal values into @theme inline inlines them into the emitted utility — that is what inline means — so the .site-app definitions would never be read and scoping them would have no effect at all.
So they are reached the way this codebase already reached --radius-control and --shadow-panel before the package existed. Same reasoning for the shadow ladder and the easings.
Shadows
Never hand-write one. The two at :root are for a marketing page; the six inside .site-app are the app's, and the difference between them is the whole argument — the marketing card is built to be the only card on a wide page.
Shadows
Edges come from shadows, not borders: every rung starts with a 1px ring, so a card in a flex row never steals a pixel of layout. The marketing --shadow-card is five stops deep for one hero card on a wide page; the product ladder is quieter because a column of six of them must not read as clutter.
--shadow-cardOne hero card on a marketing page.
—
--shadow-raisedA hair of lift on a small control.
—
--shadow-hairline.site-appA ring and nothing else.
—
--shadow-chip.site-appA chip's edge.
—
--shadow-tile.site-appA tile in a grid.
—
--shadow-float.site-appSomething floating over the page.
—
--shadow-well.site-appInset — a field, a well.
—
--shadow-panel.site-appA panel standing on the tint.
—
Motion
All of it is plain CSS — the app surfaces link no animation library — and all of it is flattened by the prefers-reduced-motion guard at the foot of tokens.css.
Motion
Two curves. The whole surface moves on the first; anything that changes the shape of the page uses the second.
not defined here--ease-out-strong.site-appThe default curve.
—
not defined here--ease-link.site-appWidth, and anything that reflows.
—
The animations
@spinrun/ui/styles/utilities.cssThe two components in the package that animate in JS — BlobMascot and CursorBadge — call useReducedMotion() themselves, because the CSS guard cannot reach a motion/react transform.
This line is running animate-shimmer-text
.animate-step-ina trace row arriving, staggered by index.animate-pop-ina status icon settling once its step finishes.animate-pixel-onone cell of the working indicator's grid.animate-fade-upa card that interrupts: a failure, a turn that stopped short.animate-fade-ina surface arriving: a chat footer, an auth stage.animate-shimmer-texttext with a highlight travelling through it.stream-tailthe streaming edge, so live text has a moving frontier
Measure
The two scope classes used to swap the entire palette. They no longer do; these four numbers are all that is left of them, and they are genuinely different — a fixed centred column under a floating header, against a full-width shell beside a rail.
Measure
The layout arithmetic each surface needs — the one thing the two scope classes still carry.
not defined here--max-page.site-marketingThe centred measure.
—
not defined here--header-h.site-marketingHeight the floating nav pill occupies.
—
not defined here--sticky-top.site-marketingWhere sticky children start under the header.
—
not defined here--app-chrome.site-appWhat a page subtracts from the viewport to fill the card exactly.
—
Actions
The primary is ink on ink: a vertical gradient from the lighter brand stop down to the brand, inside a foreground-coloured border, so the face reads as lit from above rather than flat. Pressing nudges a button down a pixel — except a menu trigger, whose popover would jump with it.
Button — variants
@spinrun/ui/components/buttonconnect is the one place a hue survives outside status: an OAuth handoff to somebody else's product, where blue is the convention people arrive with.
Button — sizes
size · icon sizesFour text heights and four icon squares. The icon sizes tighten their radius with their box so a 24px square does not read as a circle.
Badge
@spinrun/ui/components/badgeA label on a thing. Not a status — a status is a StatusPill, because a status has a tone and a badge has a variant.
Kbd
@spinrun/ui/components/kbdsolid owns its box, its size and its ink, for a key advertised on a surface. bare carries none of the three, for a key named inside someone else's line — including a dark one, which it must not repaint grey.
Press ⌘J to open the assistant, or / to search.
Forms
Fields get their edge from --input and their focus from a three-pixel ring in --ring. Nothing here carries a placeholder that a label should have carried.
Input · Label · Textarea
@spinrun/ui/components/{input,label,textarea}A slug cannot contain a space.
Select
@spinrun/ui/components/selectRequire approval
Destructive tools pause for a person.
InputGroup
@spinrun/ui/components/input-groupOne field with something attached: an icon, a unit, a button, a key hint. align takes inline-start, inline-end, block-start or block-end — the block pair stacks the addon above or below the control instead of beside it.
Structure
How content is grouped. Panel for a product surface, Card for the shadcn recipe, PanelGrid when a set of cards has to read as one board.
Panel · PanelGrid · PanelCell
@spinrun/ui/components/panelA panel's edge is one shadow — a hairline ring and a whisper of lift — so it carries no border and steals no layout. A grid's rules come from a 1px ring on every cell over a zero gap, so two neighbours never double up and the empty half of the last row below draws nothing at all.
Card
@spinrun/ui/components/cardThe shadcn recipe, on ring-1 ring-foreground/10. Reach for Panel on a product surface; this is here because the primitive exists and because CardAction has no Panel equivalent.
Tabs
@spinrun/ui/components/tabsTabsList variant: default is a filled track, line is a bare row for a page-level switch that must not read as a control.
Table
@spinrun/ui/components/tableNumbers are tabular and right-aligned; the header is the only row that is not the same weight as the body.
| Agent | Status | Runs | Credits |
|---|---|---|---|
| Weekly digest | Active | 128 | 4.10 |
| Lead researcher | Pending | 12 | 0.90 |
| Invoice sweep | Draft | 0 | 0.00 |
Accordion · Collapsible
@spinrun/ui/components/{accordion,collapsible}Accordion when the items are a set and one at a time is the point; Collapsible when one block simply folds.
Separator · ScrollArea
@spinrun/ui/components/{separator,scroll-area}A Separator is a drawn line inside one card. Between sections of a page, use a dotted rule instead — see Lines below.
Sidebar
@spinrun/ui/components/sidebarThe app rail's vocabulary, out of its shell. The whole assembly — provider, rail, inset, collapsed state, mobile sheet — is composed once by PlatformShell in apps/web/src/components/platform/shell.tsx; what is worth seeing here is the row: a button, its active state, and a count.
Status and feedback
Colour means state and nothing else, and there are exactly five states worth distinguishing. A caller maps its own vocabulary — expired, revoked, never run — onto a tone; the tone owns the colour.
StatusPill · StatusDot
@spinrun/ui/components/status-pillneutral and muted sit on --canvas, which is defined inside .site-app. On a marketing surface use the three coloured tones or wrap the region.
successworking, healthy, connected
warningwaiting on someone
destructivebroken
neutrala state, not coloured
mutedthe absence of a state
WorkingIndicator
@spinrun/ui/components/working-indicatorThe gap between pressing Enter and the first token is the whole first impression of the surface, and it used to be blank. A grid of cells lighting in sequence reads as work without pretending to know how much is left; the counter answers the only question a person actually has.
LogoLoader
@spinrun/ui/components/logo-loaderA stroke tracing the mark's own route, clipped to the mark. Three sizes; LogoLoadingScreen is the same loader centred with a label.
StepTimeline
@spinrun/ui/components/step-timelineOne timeline for both harnesses. The summary is what most turns need — a person scanning a long run wants the count and the shape of the work, not seven rows — so the rows are one click away and the icons stack until they overflow into a +N.
Skeleton
@spinrun/ui/components/skeletonShape first. A skeleton that does not match the row it stands in is a second layout the reader has to learn and then unlearn.
EmptyState
@spinrun/ui/components/empty-stateA dashed box, never a bare sentence: the surface has to look like it is waiting rather than broken. The icon is drawn large and faint.
No runs yet
A run appears here the moment this agent is triggered — by a schedule, a message, or you.
Tooltip · HoverCard
@spinrun/ui/components/{tooltip,hover-card}A tooltip names a thing in a handful of words. A hover card is for a preview with structure in it — and neither may hold something a person needs, because a pointer is not a promise.
Avatar
@spinrun/ui/components/avatarInitials, not a coloured hash. AvatarGroup overlaps and AvatarGroupCount closes the row; AvatarBadge carries presence.
Overlays
Five surfaces that open over the page, plus the toaster. Radix portals every one of them to document.body — outside .site-app — so their copy uses root tokens and not the ink set.
Dialog
@spinrun/ui/components/dialogA decision that has to be answered before anything else continues. Header, body, footer — and the footer runs cancel then confirm, so the confirm sits under the thumb.
Sheet
@spinrun/ui/components/sheetA drawer for detail beside the thing it belongs to. side takes top, right, bottom or left; right is the default and the one the product uses.
DropdownMenu
@spinrun/ui/components/dropdown-menuActions on a row. Items, checkbox items, sub-menus and shortcut hints; a destructive item comes last, under a separator, so it is never the one you hit by momentum.
Popover
@spinrun/ui/components/popoverA small surface anchored to what opened it, for a fact or a two-field form. Not for a decision — that is a Dialog.
Command
@spinrun/ui/components/commandThe palette. Inline here so both halves are visible at once; in the product it is a CommandDialog on ⌘K.
Toaster
@spinrun/ui/components/sonnerMounted once by PlatformShell and called as toast(…) from anywhere. theme is stated, not asked — see docs/design.md §12.
Brand, lines and texture
The mark exists once, as data. The rest of this group is what replaced alternating dark and light bands: a dotted rail, a dotted rule, and a diagonal hatch behind grouped content.
BirdLogo
@spinrun/ui/components/bird-logoTwo paths — the loop and the two nodes it joins — exported as BIRD_LOGO_PATHS so a clipPath, a satori card or a blog cover draws the same mark. It used to be pasted into four files, which is how a mark drifts.
DashedLine
@spinrun/ui/components/dashed-lineAbsolutely positioned, so it needs a positioned parent. Both axes, a pitch, and a fade at whichever ends it runs to. A solid 1px border reads as a box; this reads as a drafting guide.
.hatch and .dotted-rule
@spinrun/ui/styles/utilities.cssThe hatch sits behind a panel's content, above its background, and takes its density from --pattern-fg. The dotted rule is the horizontal companion to the page rails.
Grouped content, on texture.
BlobMascot · CursorBadge
@spinrun/ui/components/{blob-mascot,cursor-badge}The only two components in the package that animate in JS, so the only two that call useReducedMotion() — the CSS guard in tokens.css cannot reach a motion/react transform.
Layout
Composed from the outside in: the shell owns the scope and the viewport, PageFrame owns the measure, PageHeader owns the top of a page. On marketing, Section owns a band.
PageHeader
@spinrun/ui/components/page-headerTitle and description left, actions right, both bottom-aligned once the row is wide enough to hold them — and stacked when it is not.
Apps
Connect an app once. Every agent and every client reaches it through the same governed gateway.
PageFrame
@spinrun/ui/components/page-framemax-w-7xl, px-4 sm:px-6 lg:px-8, pt-8 pb-16. A list page wraps itself in this because the Agents-mode shell deliberately does not pad its content — the agent workspace wants every pixel.
PlatformShell
@/components/platform/shellNot in the package: it knows about the app. It owns the .site-app scope, the pinned viewport — h-svh plus overflow-hidden, which is what keeps the rail still while only content moves — and two variants: card for Gateway, where the content floats as a card on the tint, and flush for Agents, where pages wrap themselves.
Section · Eyebrow · Headline · Highlight
@/components/marketing/sectionThe marketing band. divider draws the dotted rule at the seam with the section above, tone drops the ground half a step or paints the ink panel, pattern puts the hatch behind the content. Highlight is deliberately rectangular and deliberately rare — one per page, on the phrase carrying the claim.
tone=plaintone=mutedtone=brandRules
Eight of them. Three are enforced by packages/ui/src/boundaries.test.ts rather than by review — the raw-swatch ratchet catches the first two, and the no-second-copy assertion catches the seventh. The rest are on you.
- Don'tWrite a colour by hand. No
bg-white, notext-gray-500, nobg-emerald-100. The frozen baseline in the package’s boundary test only ever shrinks. - Don'tWrite a
dark:half. The product is light-only; the variant exists so the primitives’ own utilities still compile. - Don'tHand-roll a shadow or a radius. Use the ladder, and reach the scoped rungs as
shadow-[var(--shadow-tile)]. - Don'tReach for
bg-canvas,text-ink-2or--radius-cardoutside.site-app— including inside an overlay, which Radix portals to the body. - DoUse
Panelrather thanCardon a product surface:--shadow-panelis the app’s edge. - DoUse
Kbd,StatusPillandBIRD_LOGO_PATHSinstead of a seventh spelling of each. - DoAdd a component with
npx shadcn@latest add <name> -c packages/ui, from the repo root. Never into an app. - DoCall
useReducedMotion()if you animate in JS. The CSS guard cannot reach you.
Read next
docs/design.md— the written source of truth: every token with its role, the inventory table this page is checked against, and the do-and-don’t list in full.docs/architecture.md— one app and two modes, the package graph, the boundary tests as enforced contracts.packages/ui/src/styles/tokens.css— the definitions, with the reasoning in the comments.