Design system

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.

--brand

The ink. Every emphatic surface.

--brand-soft

The far stop of an outward gradient.

--brand-top

The top stop of a button's gradient face.

--accent-brand

A second name for --brand, read by the landing's gradients.

--accent-brand-light

The lighter half of those gradients.

--accent-brand-glow

Brand at 10% — glows and halos.

--accent-brand-subtle

Brand 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.

--background

The page.

--foreground

Body ink.

--card

A raised surface.

--card-foreground

Ink on a card.

--popover

An overlay surface.

--popover-foreground

Ink on an overlay.

--primary

The emphatic action.

--primary-foreground

Ink on it.

--secondary

The quiet action.

--secondary-foreground

Ink on it.

--muted

A wash behind something inactive.

--muted-foreground

Secondary ink.

--accent

Hover ground.

--accent-foreground

Ink on hover.

--border

A drawn edge.

--input

A field's edge.

--ring

The focus ring.

--signal

Alias of --brand, kept for `hover:text-signal`.

--signal-foreground

Ink 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.

--success

Working, healthy, connected.

--success-foreground

Ink on a filled success surface.

--success-tint

The wash a success pill sits on.

--warning

Waiting on something or someone.

--warning-foreground

Ink on a filled warning surface.

--warning-tint

The wash a warning pill sits on.

--destructive

Broken, failed, denied.

--destructive-tint

The wash a destructive pill sits on.

Charts

The one place hue is decorative. Five stops, in oklch, reused in order.

--chart-1

First series.

--chart-2

Second series.

--chart-3

Third series.

--chart-4

Fourth series.

--chart-5

Fifth series.

Neutrals

Warm greys that end on the ink. Reached by name only when the semantic set has nothing that fits.

--neutral-0

White.

--neutral-50

The lightest wash.

--neutral-100

A hover ground.

--neutral-200

A sidebar edge.

--neutral-250

The border grey.

--neutral-300

A touchable edge.

--neutral-400

Disabled ink.

--neutral-500

Secondary ink.

--neutral-600

Strong secondary ink.

--neutral-700

Near-ink.

--neutral-800

The ink.

--neutral-900

Darker than the ink.

--neutral-950

The 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.

--rail

The dotted page rails and section rules.

--pattern-fg

The 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-app

The gutter behind the content card.

--canvas.site-app

A well: a code block, an idle row, a demo frame.

--surface.site-app

The card.

--inset.site-app

A hover ground inside a card.

--tint.site-app

The wash behind inputs and idle rows.

--code-surface.site-app

The ink at 5%, for code and URL blocks.

--code-ink.site-app

Ink 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-app

What is being read.

--ink-2.site-app

What is beside it.

--ink-3.site-app

Index 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-app

A rule between rows.

--line.site-app

A card's edge.

--line-strong.site-app

A 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.

Ag
--font-sans

Running UI. Geist.

Ag
--font-display

Headlines, labels, buttons. Inter.

Ag
--font-heading

Alias of --font-display.

Ag
--font-mono

Code, terminal output, tabular readouts. Geist Mono.

The scale

marketing 16px · product 13px

Two 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.

The gateway keeps the audit trailHeadline as h1
The gateway keeps the audit trailHeadline as h2
The gateway keeps the audit trailPanel title
The gateway keeps the audit trailBody, marketing
The gateway keeps the audit trailBody, product
The gateway keeps the audit trailStatus pill, counts

.font-label and .headline

@spinrun/ui/styles/utilities.css

Two 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.

Approvals · 2 waiting.font-label — display, 13px, 600, -0.01em
Connect once, govern everywhere.headline — display, -0.025em, 1.15, text-wrap balance
spinrun_run_tool · 1,204 rows · 6.6sfont-mono, tabular

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-4xl

Every rung is a multiple of --radius, so changing the base moves the whole ladder and nothing has to be re-picked.

sm× 0.7
md× 0.85
lg--radius
xl× 1.3
2xl× 1.7
3xl× 2.1
4xl× 2.5

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.

--radius

The base. 0.75rem.

--radius-chip.site-app

A chip.

--radius-control.site-app

A control in a dense row.

--radius-card.site-app

A card.

--radius-overlay.site-app

A panel or overlay.

--radius-window.site-app

A 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-card

One hero card on a marketing page.

--shadow-raised

A hair of lift on a small control.

--shadow-hairline.site-app

A ring and nothing else.

--shadow-chip.site-app

A chip's edge.

--shadow-tile.site-app

A tile in a grid.

--shadow-float.site-app

Something floating over the page.

--shadow-well.site-app

Inset — a field, a well.

--shadow-panel.site-app

A 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-app

The default curve.

not defined here
--ease-link.site-app

Width, and anything that reflows.

The animations

@spinrun/ui/styles/utilities.css

The 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-marketing

The centred measure.

not defined here
--header-h.site-marketing

Height the floating nav pill occupies.

not defined here
--sticky-top.site-marketing

Where sticky children start under the header.

not defined here
--app-chrome.site-app

What 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/button

connect 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.

default
secondary
outline
ghost
destructive
connect
link
disabled

Button — sizes

size · icon sizes

Four 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.

xs
sm
default
lg
icon-xs
icon-sm
icon
icon-lg

Badge

@spinrun/ui/components/badge

A label on a thing. Not a status — a status is a StatusPill, because a status has a tone and a badge has a variant.

defaultsecondaryoutlineghostdestructivelink

Kbd

@spinrun/ui/components/kbd

solid 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.

KEscsolid

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/select

Require approval

Destructive tools pause for a person.

InputGroup

@spinrun/ui/components/input-group

One 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.

⌘K
.spinrun.ai
Step budget

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/panel

A 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.

One panel

Standing on its own, on the tint. This is the shape almost every dashboard section is.

Apps

A cell in a board.

Brain

A cell in a board.

Runs

A cell in a board.

Card

@spinrun/ui/components/card

The 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.

Google Drive
Connected 3 days ago
14 tools, read-only.

Tabs

@spinrun/ui/components/tabs

TabsList variant: default is a filled track, line is a bare row for a page-level switch that must not read as a control.

Fourteen tools, three of them destructive.

Table

@spinrun/ui/components/table

Numbers are tabular and right-aligned; the header is the only row that is not the same weight as the body.

AgentStatusRunsCredits
Weekly digestActive1284.10
Lead researcherPending120.90
Invoice sweepDraft00.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.

The risk class at which a tool stops and waits for a person.

{ "limit": 25, "order": "desc" }

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.

Read
Write
Destructive
  • sheets_get_values_1
  • sheets_get_values_2
  • sheets_get_values_3
  • sheets_get_values_4
  • sheets_get_values_5
  • sheets_get_values_6
  • sheets_get_values_7
  • sheets_get_values_8
  • sheets_get_values_9
  • sheets_get_values_10
  • sheets_get_values_11
  • sheets_get_values_12

Sidebar

@spinrun/ui/components/sidebar

The 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.

Workspace
  • 3
  • 8

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-pill

neutral and muted sit on --canvas, which is defined inside .site-app. On a marketing surface use the three coloured tones or wrap the region.

Active
success

working, healthy, connected

Pending
warning

waiting on someone

Failed
destructive

broken

Archived
neutral

a state, not coloured

Draft
muted

the absence of a state

WorkingIndicator

@spinrun/ui/components/working-indicator

The 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.

Working0.0s
Reading 42 documents0.0s

LogoLoader

@spinrun/ui/components/logo-loader

A stroke tracing the mark's own route, clipped to the mark. Three sizes; LogoLoadingScreen is the same loader centred with a label.

sm
md
lg

StepTimeline

@spinrun/ui/components/step-timeline

One 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.

Searched tools for “last week’s revenue”
Read 1,204 rows from the warehouse
Posted the digest
Drafted an email to 240 contacts
Waiting on an approval to send

Skeleton

@spinrun/ui/components/skeleton

Shape 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-state

A 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/avatar

Initials, not a coloured hash. AvatarGroup overlaps and AvatarGroupCount closes the row; AvatarBadge carries presence.

CD
ABCDEF
+4

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/dialog

A 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/sheet

A 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-menu

Actions 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/popover

A 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/command

The palette. Inline here so both halves are visible at once; in the product it is a CommandDialog on ⌘K.

Toaster

@spinrun/ui/components/sonner

Mounted 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-logo

Two 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-line

Absolutely 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.css

The 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.

Ana
Mihai

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-header

Title 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-frame

max-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.

the page’s content, at the real padding

PlatformShell

@/components/platform/shell

Not 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.

The content card

Plain --background, with the panel edge, floating on the tint. That half-step behind it is the whole reason it reads as a card.

Section · Eyebrow · Headline · Highlight

@/components/marketing/section

The 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=plain
tone=muted
tone=brand

Rules

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, no text-gray-500, no bg-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-2 or --radius-card outside .site-app — including inside an overlay, which Radix portals to the body.
  • DoUse Panel rather than Card on a product surface: --shadow-panel is the app’s edge.
  • DoUse Kbd, StatusPill and BIRD_LOGO_PATHS instead 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.
Back to the productHome jumps back to the top
Before anything loads

We would like to switch on analytics, so we can see which pages are worth keeping. Nothing has loaded yet and nothing will until you choose. The cookies that keep you signed in and remember your language are not part of this.

Read the Cookie Policy