Skip to main content
Custom Workflow UIs covers the plumbing: how a .smithers/ui/<workflow>.tsx bundle boots, talks to the Gateway, streams events, and stays stale-data-free. This guide covers the look: the visual language that makes every workflow UI read as one product. The reference implementation is the Smithers product UI; the rules below are extracted from it. Adopt these tokens and principles verbatim, not a one-off palette, so a workflow UI feels native and shares the same shape, depth, motion, and focus decisions.
Most reference UIs in this repo (.smithers/ui/vcs.tsx, grill-me.tsx, ultragrill.tsx) ship a single inline <style> string; that’s fine as long as its values come from the shared token set below, not ad-hoc hex codes.

Components first

The fastest way to comply with this guide: don’t hand-write the CSS. smthrs/ui ships shared components (Button, Card, Badge, StatusPill, Tabs, Dialog, Tooltip, Select, Input, Alert, Table, Progress, Skeleton, Spinner, EmptyState, and more) styled from the tokens below, with house recipes baked in (tinted primary button, brand focus halo, 22px status pills, 8px card radius) and correct in light and dark by construction; Radix supplies accessibility. See the shared component library for usage and the UI Component Library reference for the full catalog. Everything below is the contract those components implement, and what you follow when writing custom CSS next to them.

Start from the token set

Every UI declares the same custom properties and reads them everywhere. Never hardcode a color, tint, or shadow alpha inline: reach for a token, or derive one with color-mix. This keeps light and dark correct and workflow UIs from drifting apart.
The Gateway HTML shell injects the canonical workflowUiThemeCss from @smthrs/ui-styleguide. It stamps data-theme from ?theme=dark|light and data-palette from ?palette=<key> before first paint. A UI that reads the tokens is themed automatically. Declaring the block through WorkflowUiStyles remains useful when the UI must run outside the Gateway.

Choose a palette

Color mode and color palette are separate axes. data-theme="light|dark" continues to override prefers-color-scheme. data-palette selects a registry palette without changing the color mode. An absent or invalid palette uses night-owl.
The registry in @smthrs/ui-styleguide provides these choices: The Pierre diff adapter and xterm terminal adapter observe both axes. Their syntax and ANSI colors come from the selected registry record. Use the optional adapter palette prop only when one surface must differ from the document. Do not copy token values into a workflow stylesheet. That pins the UI to one palette and bypasses future registry updates. Gateway pages already include the registry. Standalone pages render the canonical CSS through WorkflowUiStyles:

The principles

Color is semantic, tints are derived

Exactly five accent roles exist. Do not introduce another hue per workflow.
  • --brand (violet): focus, in-progress, the “this is the live thing” accent.
  • --success (green): completed, healthy, approved.
  • --danger (red): failed, destructive, blocked.
  • --warning (yellow): waiting, needs attention, or degraded.
  • --info (blue): neutral highlights and informational states.
For a status background, soft border, or hover wash, derive the tint with color-mix against a token rather than picking a new hex. This keeps it correct in both themes automatically:
Primary actions (“Run”, “Approve”, “Submit”) use --brand-soft with --brand text and a --brand-border. Secondary actions use --surface with a --border and a --hover background on hover.

Typography: Inter, set tight, weighted heavy

  • Font: Inter, falling back to the system UI stack, with -webkit-font-smoothing: antialiased and font-synthesis: none.
  • Weight does the work: labels and titles sit at 600 to 800, not 400; body copy is default weight; muted metadata is --text-faint.
  • Headings sit tight: letter-spacing: -0.01em (down to -0.02em for large display text), sized in rem (roughly 1.05rem to 1.3rem); never go bigger than you need.
  • Eyebrows and tiny labels go up, not down: section labels and status chips use text-transform: uppercase with positive tracking (letter-spacing: 0.03em to 0.06em) at 11 to 12px, weight 700+, signaling “label” without making text large.
  • Monospace is for identity, not chrome: run ids, SHAs, and code use ui-monospace, monospace; never set whole panels in monospace.

One radius scale

Pick from a small, consistent ladder; mixing arbitrary radii is the fastest way to look unfinished.
  • 8px is the workhorse: buttons, inputs, panels, banners, the auth chip.
  • 6 to 7px for small inline things: chips, glyph tiles, inline code.
  • 14 to 20px for primary cards and the composer surface (bigger surface, softer corner).
  • 50% / 999px for avatars, dots, and pills.
Chat-style bubbles use an asymmetric radius: large everywhere except one tight 6px corner on the speaker’s side, reading as a tail.

Depth is glass and layered shadow, never a hard box

Cards, floating chrome, toasts, and modals are translucent glass:
Two rules make depth read correctly:
  1. Stack two shadows, not one: a 1px near-shadow grounds the edge; a large (16 to 50px) soft far-shadow at low alpha (0.08 to 0.14) lifts it off the page. A single hard shadow looks cheap.
  2. Tint shadows through --shadow-rgb: write rgb(var(--shadow-rgb) / 0.1), never rgba(0,0,0,0.1). In dark mode --shadow-rgb becomes pure black, so shadows stay believable.
Flat inline rows and list items get a --border hairline and a --hover (or --hover-subtle) background change instead of a shadow; reserve elevation for things that actually float.

Focus is a brand halo, not a default outline

Custom controls suppress the UA ring; give focus back explicitly, and make it feel intentional.
  • Containers (a card holding an input) lift on :focus-within with a brand-tinted glow plus a deepened shadow, not a hard border:
  • Discrete controls (buttons, links, pills) get a crisp :focus-visible ring: outline: 2px solid var(--brand); outline-offset: 2px;. Keyboard users must always see where they are.

Pressed means a step darker, never a transform

Every clickable control gives :active feedback by deepening its background with a color-mix tint, not by scaling, translating, or adding shadows. Derive the pressed wash from the same tokens as the idle state so it stays correct in both themes and never invents a new hex:
The shared components ship this recipe already (:active:not(:disabled) on every button variant); hand-rolled controls must match it.

Motion: short, purposeful, and named

Animation is fast and earns its place; nothing bounces for decoration.
  • Durations live in 120 to 260ms: entrances around 120 to 160ms, a deliberate morph up to 260ms. Anything slower feels broken.
  • Easing: ease-out for entrances, cubic-bezier(0.2, 0.8, 0.2, 1) for a confident morph, with fill mode both so start and end states stick.
  • Entrances are subtle: opacity from 0 with an 8px translateY, or a directional translateX of about 18px for forward/back view transitions. Small distances, fast.
  • Use a real FLIP morph for “this became that”: when a composing card turns into a running toast, measure both rects (getBoundingClientRect), drive a CSS-variable transform, and let one keyframe interpolate rather than cross-fading two elements.
  • Spinners and pulses are tokenized too: a brand-bordered ring for in-progress, a --brand dot with a 0 0 0 4px color-mix(...) halo for “live”.
  • Reduced motion is centralized. WorkflowUiStyles, SmithersUiStyles, and standaloneThemeCss() already ship the same document-wide safety net, so component CSS must not add its own media-query guard. Imperative canvas widgets use prefersReducedMotion() and observeReducedMotion() from smthrs/ui.

Layout: a readable measure and calm chrome

  • Constrain content width: primary columns are width: min(100%, 720px); margin: 0 auto;. Wide raw dumps belong in a scroll container, not stretched edge to edge.
  • Float the chrome, glass it, keep it out of the corners that matter: fixed status/auth widgets use --surface-glass-strong with backdrop blur and a soft shadow, pinned with breathing room (about 12 to 14px from the edge).
  • Spacing is generous and even: card padding around 16px, gaps of 8 to 18px, list rows around 10px vertical. Empty states get real space (around 48px of padding) and --text-faint, not a cramped one-liner.

Status surfaces read at a glance

Map run state to the semantic accents the same way in every UI, so users learn the language once:
  • In progress / running--brand (spinner ring, pulsing dot, brand text).
  • Finished / healthy--success.
  • Failed / blocked--danger.
A status chip is an uppercase, tracked, 11px, weight-600 label with a color-mix border and tint in its accent. A run id is a monospace pill on --surface with a --border.

Putting it together

A minimal, on-system .smithers/ui/<workflow>.tsx looks like this (tokens above, then a thin layer of component classes that only reference tokens):

Checklist before you ship a UI

  • Colors, tints, and shadow alphas come from tokens or color-mix, never raw hex inline.
  • Light and dark both look right (the token block handles both; spot-check by toggling data-theme).
  • Type is Inter, antialiased, with heavy weights on labels and tight tracking on headings.
  • Radii come from the 6/10/12/16/pill scale; nothing arbitrary.
  • Elevated surfaces are glass with a two-layer, --shadow-rgb-tinted shadow.
  • Primary action is the inverse surface; brand is reserved for focus and progress.
  • Every interactive control has a visible :focus-visible (or :focus-within) treatment.
  • Motion is 120 to 260ms with both fill; the shared reduced-motion safety net is present.
  • Run state maps to brand/success/danger the same way it does in every other UI.

Reference

  • Guide: Custom Workflow UIs for the Gateway wiring, hooks, boot config, and stale-data-free model.
  • Reference bundles in this repo: .smithers/ui/vcs.tsx, .smithers/ui/grill-me.tsx, .smithers/ui/ultragrill.tsx, .smithers/ui/workflow-skill.tsx.
  • Examples: Workflow UI (React), Workflow UI (Vanilla).