Primitives, surfaces and tokens. The spec is DESIGN.md.
Semantic tokens — OKLCH color space, light & dark mode
Surfaces
background
card
light-muted
muted
heavy-muted
Brand
primary
primary-light
secondary
secondary-light
accent
Status
success
success-light
warning
warning-light
queued
queued-light
destructive
destructive-light
Two voices — InterVariable for UI, Freight Big Pro for brand & editorial. IBM Plex Mono for coordinates.
Voice 1 · UI · Inter
The operator's console.
Dense UI — tables, forms, buttons, labels. ~95% of the platform and all landing supporting copy.
The quick brown fox jumps over the lazy dog. 1,284 executions completed in 3m 42s average.
Voice 2 · Brand & Editorial · Freight Big Pro
Build agents that run the web.
Landing hero & section titles. In-product hero moments, empty states, onboarding, quotes. One expressive voice across both surfaces.
“A polished power tool that doesn't intimidate.”
Coordinate · IBM Plex Mono
Not a voice — a coordinate system. Identifiers, versions, model slugs, metadata k:v pairs, cron expressions, URLs & paths, raw enum values, and tabular numerics. Where precision beats emotion. Use <Mono> from @workspace/ui/components/mono outside of DataTable (which has meta.mono).
Mono
Not mono
Type roles: pick the role, not a size
Editorial tier — Freight Big Pro (use sparingly)
In-context example — empty state
Executions
Nothing to see here — yet.
Kick off your first workflow run and this page will fill up with the interesting stuff.
Serif headline + Inter supporting copy + mono overline. The three voices playing together.
Button face is lifted from flow, shadow at flow position — sinks on hover, presses on active
Variants × Sizes
| variant \\ size | sm | default | lg |
|---|---|---|---|
| default | |||
| destructive | |||
| outline | |||
| ghost | |||
| link |
With Icons
Icon Only
States
Common Patterns
Dialog Footer — confirm / cancel
Destructive Confirmation
Toolbar — mixed actions
Tab Toggle — ButtonGroup variant switching
Page Header — primary + secondary actions
Split Button — primary action + dropdown
Icons are a supporting voice — they reinforce an affordance or classify a type. Four working sizes, six semantic colors, and a strict anti-pattern list. See DESIGN.md §Iconographyfor the canon.
Time components never wear leading icons — the mono typography carries the signal. Cross-link: §Time & Duration.
1 · Size scale
Four working sizes carry 99% of the system. size-3 is only for tiny glyphs inside chips, pills and the drawer Next pill. size-6 is reserved for rare page-header chrome.
size-312pxsize-3.514pxsize-416pxsize-520pxsize-624pxsize-832px2 · In buttons
Button hands every nested icon size-3.5 and gap-1.5. Never pass size-* or mr-* class overrides.
3 · In tabs
TabsTrigger and line tabs (NavTabsTrigger, PageTabs) use size-3.5. One tab size everywhere.
4 · In menus
DropdownMenuItem and CommandItem both hand icons size-4 and gap-2. Delete rows use text-destructive; everything else inherits text-muted-foreground from the primitive.
5 · In entity and stat cards
Entity full tile (size-10 rounded-lg) holds size-5; compact (size-7 rounded-md) holds size-3.5. A stacked drawer header uses the same tile as the full card. Stat card header icon is size-3.5, right-aligned in the muted label row.
6 · Time components never wear icons
Mono typography + tabular-nums + the two-line tooltip carry the signal. The column header / row label / prefix prop name the field.
Bare time component, or prefix prop for the label. Mono font does the work.
Clock/Calendar/Timer prefixing a time component. The column header already says 'Last run'.
7 · Color canon
Icons inherit currentColor. Use semantic tokens. Never text-green-600, text-red-500, and friends.
text-successtext-destructivetext-warningtext-primarytext-admintext-muted-foreground8 · Empty state hero
EmptyMedia variant="icon" renders a size-10 muted tile around a size-8 icon. Do not pass a size class on the icon itself.
9 · Inputs with a leading icon
No built-in SearchInput primitive yet — use an absolutesize-3.5 Search icon with muted foreground and pad the input to match.
10 · Anti-patterns
Four classes of drift the canon forbids. See §Iconography for the full list.
Status badge tint IS the signal — one glyph or none.
Status-tinted badge AND a same-meaning status glyph next to it.
Let the Button primitive supply size-3.5 and gap-1.5.
Overriding size-4 fights the canon. The Button no longer uses size-3 by default — trust it.
Semantic tokens: text-success, text-destructive, text-warning, text-primary, text-admin.
Raw palette colors. No text-green-*, no text-red-*, no hex literals.
One size per visual group — size-3.5 here.
Mixed size-3 and size-4 in the same row. Pick one.
11 · Admin affordance
The element IS the signal — amber button / amber text instead of an extra badge. AdminBadge scales its Shield glyph (xs: size-2.5, sm: size-3, md: size-3.5).
12 · Loaders
Loader2 animate-spin in a status position inherits the surrounding size canon. Keep it the same size as the icon it replaces.
Grouped actions with shared offset shadow
Horizontal — outline
Horizontal — alignment
Vertical
With Text & Separator
Variant groups
Tinted backgrounds with colored text — quiet labels, not loud fills
Variants
Run status · StatusBadge with the run-status map
Tint and label carry the state. The only glyph is the live dot on states where work is happening now.
The affordance IS the signal — admin elements paint themselves amber. Badge stays as the page-header chip and the fallback for elements that already carry color. See DESIGN.md → Admin Indicators.
Token
--adminSolid amber. Use on raised admin buttons; rarely as a surface.
bg-admin/10Tinted background — strip, hover/active states, ghost surface.
border-admin/30Border tint for the strip and outlined admin elements.
Element coloring (primary pattern)
Buttons
Rail rows
Properties & links
Form-control primitives (admin prop)
<Input admin /><SelectTrigger admin />Playwright traces for every session.
<FeatureToggle admin /> (threads to <Switch admin />)Controls inside <AdminOnly> accept an admin prop instead of hand-rolling amber classes. The palette, focus ring, placeholder tone, and switch track all flip together. Size and disabled behavior are unchanged.
AdminBadge — fallback & page-header
size="xs" — fallback onlysize="sm" — raresize="md" — page headerThree contexts
Property
Element coloring is the primary signal — render the value in text-admin.
Control
The button paints itself amber. No badge needed.
Page header
One md badge marks the whole page identity.
Admin mode toggle (live demo)
AdminPageGate (toggle-off interstitial)
You turned Admin mode off — internal tools are hidden so it's safe to demo. Re-enable to view this page.
Anti-patterns
Don't bolt a badge onto every admin element
The element's own admin coloring is the signal. Use variant="admin", not <AdminBadge /> stapled next to a default-styled control.
Don't use --admin for warnings
Status colors stay status colors. For "might be dangerous," reach for --warning or --destructive. Admin is a category, not a status.
Don't gate access with color or the badge
Both are signals for admins, not gates. Server-side isAdminUser() route guards still enforce real access.
Don't show admin elements to non-admins
Hide the entire element via <AdminOnly>. Disabled admin features leak product surface.
Line tabs navigate a page from the canvas header. A SegmentedControl switches sub-views. Framed chrome tabs only nest inside modals and panels.
Page navigation · line tabs in the canvas header
PageTabs (Tabs appearance="line") · Logins
Profiles on the canvas.
NavTabs + SegmentedControl · PageDetailHeader
Health · table view
Sub-views · SegmentedControl
SegmentedControl size="default"
SegmentedControl size="sm"
Nested tabs · framed Tabs
The default chrome look: active tab with stroked inverted corners, content panel with a full border and one shared drop-shadow. Use it inside a modal or a panel. Suppress the shadow with framedShadow={false} when the strip and panel cannot share a DOM subtree.
Tabs framed (inside a panel on bg-light-muted)
Border traces the active tab through the inverted corners and wraps the content card. One shared drop-shadow.
Flat nested tabs · framed={false}
For tabs inside an existing card or section, where a bordered tongue card would double up. The strip gains a bg-muted plate; the consumer shapes the panel.
With icons
Executions content
Text only
Overview content
With badges
All logs
With disabled tab
Active content
A form is sections of fields; a field is one label and one control. FormField encodes the anatomy and the aria wiring. Type roles replace ad-hoc sizes and weights. See DESIGN.md §Forms and §Type roles.
Field states
We POST each event here.
Admin only.
Placeholders
FormSections · FormSection · FormField
Label, optional marker and info tip share one row. The description sits between label and control, so the error stays next to the value. Try a concurrency above 50.
Drawers, the graph inspector and large dialogs share one panel anatomy. Drawers stack numbered sections. Status uses one badge with a map per domain. Pick the surface with DESIGN.md §Surfaces.
The rules · first match wins
Is the user confirming something destructive, irreversible or costly?
Is the system showing a value the user can see only once?
Is the user changing exactly one value already on screen?
Is the user in a workspace, configuring the object they selected?
Is the user creating, viewing or editing a configuration entity?
Is it a one-screen task that is not the page's main job?
Nothing above matched.
The seven surfaces
| Surface | Blocks | URL | Commits |
|---|---|---|---|
| Inline edit | No | No | Enter, blur or change |
| Popover | No | No | Change or Apply |
| Alert dialog | Yes | No | One verb |
| Dialog | Yes | No | One verb |
| Drawer | Yes | ?new= ?id= §ion= &edit=1 | One save bar, drafts kept |
| Docked panel | No | Selection | Draft, Publish commits |
| Route | — | Yes | Per section |
One blocking layer
Only an alert dialog may open over a Dialog or drawer, to confirm a destructive action inside it. Creating a dependency slides in within the drawer section, or swaps a Dialog's body. Menus and popovers are not layers.
Entity assignments
| Entity | View | Create | Edit | Destroy | Today |
|---|---|---|---|---|---|
| Workflow | Route | Route /create | Inline name, route tabs | Alert dialog | OK |
| Workflow node | Docked panel | Graph menu | Docked panel | Alert dialog | Migrates |
| Execution | Route | Run: Dialog lg | — | Alert dialog | OK |
| Schedule | Drawer | Drawer | Drawer | Alert dialog | Migrates |
| Batch | Route | Route wizard | — | Alert dialog | OK |
| Profile, profile group | Drawer | Drawer | Drawer | Alert dialog | OK |
| Secret | Profile section | Slides in a section | Slides in a section | Alert dialog | OK |
| Secret template | Drawer | Drawer | Drawer | Alert dialog | OK |
| Environment | Drawer | Drawer | Drawer | Alert dialog | OK |
| Integration | Drawer | Drawer | Drawer | Alert dialog | Migrates |
| API key | List | Drawer | — | Alert dialog | Migrates |
| Org member | List | Drawer | Inline role | Alert dialog | Migrates |
| Tag | Popover | Inline | Inline | Alert dialog | Migrates |
| ClassPass venue | Route | Drawer | Route tabs | Alert dialog | Migrates |
Full table with the extra actions (run, retry, show once) in DESIGN.md §Surfaces → Entity assignments.
Docked panel · graph inspector
PanelHeader with back, type overline, docs link and an inline editable name. Hover the name to edit; Enter commits, Escape reverts.
Drawer · numbered sections, edit mode
One list, one section open at a time. Header Edit turns every section into its form; one save bar. Create uses the same sections, with a Next pill on the divider below the open section.
Drawer · view
Same header grammar as the inspector, inside a right sheet.
Steps · routes only
For route wizards such as Batch upload. Completed steps are clickable; upcoming ones are not. Never inside a drawer.
DetailGrid · hint, info, description
Hint: a few words on the value, or why a control is disabled. Info: what the field means, behind the ?. Description: the rare line a user must read. A boolean shows its state dot before the key. A control replaces the value through trailing; units sit beside it.
StatusBadge · status map
One map per domain. Only live states carry a glyph. Unknown values render neutral, humanised.
Empty · size sm
Concept icons
Import from @workspace/ui/lib/icon-concepts. Entity glyphs (for example profile) live in the platform entity registry.
The complete R5 drawer for one entity, built like the Logins page in the product. Create, view and edit share four numbered sections. Edit in the header edits them all behind one save bar. The address bar shows the URL each mode writes.
Modes and URLs
| Mode | URL | Try it |
|---|---|---|
| Create | ?new=profile§ion=… | Create a login profile. Numbered sections. A Next pill on the divider below the open section opens the next one. Open any section directly. No Review step. |
| Validate | — | Clear the name and open another section: Details turns red. Create opens the first section with a problem. |
| View | ?id=…§ion=… | Click a row. Every section reads as a summary. One section is open at a time. |
| Edit | …&edit=1 | Edit in the header turns every section into its form. One save bar at the bottom. |
| Instant section | — | Secrets act at once, in view and edit. New secret slides in over the list and slides back when done. |
| Create-only field | — | Sticky IP is set at create. Afterwards its section shows it read-only with the reason. |
| Destroy | — | Delete lives in the header menu, hidden while editing. It confirms with an alert dialog over the drawer. |
Same sections, every mode
| # | Section | Saves |
|---|---|---|
| 1 | Details / Overview | Draft |
| 2 | Secrets | Instant |
| 3 | Email inbox | Draft |
| 4 | Sticky IP | Create-only; read-only after |
The rules applied to real entities. Each frame is a working slice of the platform built only from the primitives above. Sheets, dialogs and alert dialogs open over the whole page, as they do in the product.
Select a node; the panel follows. Rename it in the header and watch the canvas. The folder button swaps the panel body to files. New environment opens the environment drawer in create mode and selects the result.
How to build a Dialog once the rules chose one (R1 confirms, R2 show once, R6 tasks). One shell, four sizes set by content: sm 0–1 input, md 2–8 fields, lg 9–16 fields in 2–4 sections or tabs, full files, diff and debugger only. See DESIGN.md §Modals.
Alert dialog · R1
sm · 448Destructive, irreversible or costly. Title asks the question, body states the blast radius, the action names the verb. Cancel keeps the thing.
Task · R6
md · 672A one-screen task that is not the page's job. 2–8 fields, one section, no scroll. FormField runs the body; Cancel + verb in the footer.
Tabbed task · R6
lg · 896An R6 task with 2–4 sections: header, persistent identity strip, chrome tabs, body, footer. Tasks only. Entities open in their drawer (R5), never in a tabbed dialog.
List-pick
md · 672Search + scroll + multi-select inside a DialogBody. The toolbar pins to the top, the list scrolls, the count summary lives in the footer.
Loading + error
md · 672Same shell, two states. Loading uses skeleton inside the body. Blocking errors are an in-body banner — never a toast.
Right-action + split footer
lg · 896The Run workflow shape: Pre-fill Select sits in the header right-action slot; Schedule and Advanced cluster left in the footer; Cancel + Run cluster right.
Size by content
Anything else (sm:max-w-[600px], sm:max-w-5xl, custom max-h-[Nvh]) is a smell.
Compact metric tiles — label + icon, value, helper. Used on /overview and /executions and reserved for future summary strips (billing, usage, scheduled). One row, 3 or 4 tiles, top of page.
Anatomy
Strip — 4 tiles · /overview
Strip — 3 tiles · /executions
Loading — full strip skeleton
Future use — billing strip (same shell, different content)
Same shell, swap the helper. Numeric metric → number value + breakdown helper. Set of related items → no value, badge children. Trend tile → same shell, badge as the helper (see kitchen-sink dashboard below).
How a domain entity (Profile, Integration, Notification, API Key, Schedule, …) renders anywhere in the platform. The same identity — icon, name, type, meta — at three densities: full on index pages, compact in side panels and drawers, and chip as an inline reference with a popover. Detail lives in a vertical stack of named body sections — and only those sections — so two cards for different entity types still read as members of the same family.
Anatomy — full variant
EntityCardType) — small uppercased mono label sitting above the title. The icon already telegraphs the type via shape; the overline is the belt-and-braces text label.text-base font-semibold, truncating. The dominant element of the header now that identity info is split across overline + title only.space-y-4) of named section primitives. This card uses PropertyList + FilterRow.EntityCardTimestamps with showCreated renders Created always and Edited when changed (60s threshold). Empty footers don't render at all.Variants — full / compact / chip (hover the chip)
Full
Compact
Chip + popover
Full
Compact
Chip + popover
Body grammar & footer — six body sections plus the audit footer
Every full or popover body is a stack of named sections. Picking from this set is mandatory; ad-hoc divs in an entity body are out. Two densities of PropertyList for key/value, four cluster sections for everything else.
PropertyList density="grid"
PropertyList density="inline"
PillRow
DividedList
DividedList (editable, with actionSlot)
FilterRow (with overflow)
EmptyRow
EntityCardFooter (default)
Edited >60s after create — meta already shows Created.
EntityCardFooter (showCreated)
Custom-meta entity (e.g. Schedule) — footer carries Created too.
Composed example — three sections in one body
The same shell with a stacked body. Sections sit at space-y-4; headers and content within a section sit at space-y-2.
Chip references — hover or click to expand
Chips are entity references inside other content (table cells, log lines, toasts, breadcrumbs). Identity only — icon, type, name. The popover shows the entity's compact card plus an allow-listed subset of body sections, and a footer link that opens the entity in a new tab.
Every mention of a user or an org uses one chip. A user is a circle and an org is a rounded square, so the shape says which one it is. The colour comes from a hash of the email for a user (the ID when there is no email) and of the ID for an org, so the same person or org has the same colour on every page. A profile picture replaces the initials. API keys, services and unnamed people use a neutral tile. The lead cell of a row uses the same avatar with the name and one muted line.
Triggered by Glenn Sim in Mosh two minutes ago.
| Member | Workspace | Invited by |
|---|---|---|
Glenn Sim glenn@getmosh.com | Mosh | Oriane Duval |
Oriane Duval oriane@getmosh.com | Dartford and Gravesham NHS Trust | priya@trifetch.com |
priya@trifetch.com No name set | Trifetch | Tom Okafor |
Tom Okafor tom@asteroid.ai | Mosh Test | Glenn Sim |
One layout: the shell (apps/platform/src/components/shell). The rail sits on the bg-sidebar frame. An inset rounded canvas holds the docked Astro thread and the page. Every page but Home and Astro's page opens with the canvas header: a context row (crumbs + actions) and a nav row (line tabs + controls). Routes render inside it through Page, PageTabs and PageDetailHeader. The rail names the page, so the title slot is usually empty.
Anatomy · AppShell with the thread docked
The canvas page shapes
A · Plain
Connections, Environments, API keys
One header row. The rail names the page, so the row holds the Astro opener and the primary action at its end. The page body is cards on the canvas. Page drops its title when the header names the route.
B · Tabbed
Logins, Insights, Admin → Models
PageTabs → the header row. PageTabsList portals line tabs into the header's tab slot. Tab-scoped actions sit at the row's end. Content stays on the canvas. No chrome strip, no inverted corners.
C · Home
/ (ShellHome)
No canvas header. Home is its own header: an editorial greeting, then Recent workflows, Work from your tools, Learn. Astro's page shares its frame (SHELL_PAGE_FRAME).
D · Detail
Organisation, Batch, workflow pages
PageDetailHeader → both rows. Crumbs start at the list you came from; badges and actions end the context row. NavTabs for the entity's sections, a SegmentedControl for the active tab's sub-views. Workflow pages build the same two rows from the URL (see Anatomy).
Title rule
| Page has… | Title slot | Subtitle slot | Examples |
|---|---|---|---|
| PageTabs | Drop. The rail names the page. Tabs sit in the header row. | Drop. UseInfoCard inside tab content if needed. | Logins, Insights, Admin → Models |
| Editorial greeting | Drop. No canvas header. The greeting is the title. | Drop. Supporting prose carries it. | Home, Astro's page |
| Start composition | Drop. Split editorial prompt replaces it. | Drop. Primary composer and rail copy carry the context. | Create / get started |
| PageDetailHeader | Last crumb · the resource name | Drop. Badges sit after the crumb. | Workflow, Execution, Batch, Organisation |
| Plain route, no tabs | Automatic. Page hides it when the header names the route. | Optional. Treat as a smell. | Connections, Environments, API keys |
Tabs: pick the API by where the tabs sit
PageTabs · NavTabs
Page navigation
Line tabs in the canvas header's nav row. PageTabs (Tabs appearance="line") switches in-page content. NavTabs is stateless: each trigger renders a router Link.
SegmentedControl
Sub-views
Which view of the active tab. Sits at the right end of the nav row, via DetailHeaderTabs views.
Tabs (chrome, default)
Nested tabs
Framed tongue-card tabs. Only inside a modal or a panel, never as a page's own navigation. Never inside an entity drawer.
The Start page shape: a canvas-first decision surface for creating a workflow, onboarding a user, or choosing a first template. One editorial composition may span the primary composer and the supporting template rail, but everything below returns to compact Inter UI.
Canonical ingredients: composer first, primary-tinted interactive controls, neutral/card surfaces, compact learning cards, and a collapsible supporting rail. The rail dimensions are composition constraints, not global spacing tokens.
Describe a repeatable workflow, then let Astro help turn it into a reliable browser or computer-use agent.
Find every Bay Area dental practice accepting new patients, check appointment availability, and summarize the best options.
or start blank
Learn Asteroid
Get the most out of Asteroid
Pro tip
Start broad
Describe the job in plain English first. Astro can help split it into faster scripted steps.
Say what is missing and what to do. Page scale on an empty page or tab;size="sm" inside tables, cards and panels. Filtered-to-nothing offers to clear filters.
Loading chrome that mirrors the final layout. Bars use bg-muted and a 1.5s opacity pulse. Never a single tall blob. Never primary-tinted placeholders. See DESIGN.md §Skeletons.
Primitive. bg-muted, 1.5s opacity pulse
Option card. Same shell as OptionCard
Data table. Card, muted header, compact rows, pagination footer
Collection page. Action row, launcher doors, table
A realistic dashboard combining all components
Monitor and manage your workflow runs
Monday morning · 14 Apr 2026
A quiet weekend — everything held.
Eight workflows ran through2,840 executionswhile you were away. Three flagged themselves for review, one finished early.
Total Runs
Success Rate
Avg Duration
Active workflows
| Execution | Workflow | Status | Duration | Started | Actions |
|---|---|---|---|---|---|
| exec_a1b2c3 | Insurance Scraper | Completed | |||
| exec_d4e5f6 | Form Filler v3 | Running | |||
| exec_g7h8i9 | Data Extractor | Failed | |||
| exec_j0k1l2 | QA Automation | Completed |
Filter · Failed · last 24h
status=failed 24h| Execution | Workflow | Status | Duration | Started | Actions |
|---|---|---|---|---|---|
Nothing failed yesterday. Every one of the 412 runs in the last 24 hours completed clean. You should take the rest of the afternoon off. | |||||
A realistic execution progress feed with collapsible turns, actions, user messages, and chat input
The AI builder assistant conversation with messages, function calls, reasoning blocks, and rich input
Single rule, applied everywhere a moment-in-time is shown. Always relative on screen, single-letter compact, monospace, tabular-nums. Hover for the absolute moment in the user's timezone plus UTC.
See DESIGN.md §Time & Durationfor the convention table and rationale.
Relative time · past
Relative time · future
Absolute time
Tooltip contract — local-with-offset + UTC, mirrored on every time component:
Duration
Prefix prop · one phrase, one font
Pass a `prefix` to render a label inline with the time, in the same mono coordinate font. The whole phrase reads as one unit and the screen-reader label includes the prefix automatically. Prefer this over writing the prefix as plain text next to the component.
Do · Don't
Do
Don't
Single-line labels that can grow arbitrarily long — workflow names, profile names, integration names, file names, schedule labels — get the same treatment everywhere: cap the visible width, ellipsize, and mount a tooltip with the full label only when the visible text is actually clipped. The conditional tooltip is the whole point — a tooltip on an already-visible label is noise.
Use <TruncatedText> from @workspace/ui/components/truncated-text for the standard case. Drop down to useIsTruncated from @workspace/ui/hooks/use-is-truncated only when you need to control the tooltip's open state externally (e.g. suppress it while a sibling popover is open). See DESIGN.md §Tables → Truncated text for the full rules.
Standalone · single width cap
Tooltip mounts only on the third row — the first two fit inside their cap. Hover any row to confirm; tooltips on already-visible labels are noise.
Inside a TableLink with trailing affordance
Pair with min-w-0 flex-1 on the text and shrink-0 on the trailing icon so the affordance always renders, even when the name is clipped.
Inside a table cell — the canonical use
The third row's tooltip carries the full label without forcing the column wider. Resize the page narrower — the second row eventually starts truncating too, and its tooltip mounts the moment the measurement crosses over.