.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 those UIs share so they read as one coherent product, not a different app per workflow.
The reference implementation is the Smithers product UI. The rules below are extracted from it. When you build or touch a workflow UI, adopt these tokens and principles verbatim rather than inventing a one-off palette. The fastest way to make every workflow UI feel native is to start from the same tokens and the same handful of decisions about shape, depth, motion, and focus.
Most reference UIs in this repo (
.smithers/ui/vcs.tsx, grill-me.tsx, ultragrill.tsx) ship a single inline <style> string. That is fine. The point is not where the CSS lives; it is that the values come from the shared token set below instead of ad-hoc hex codes.Components first
The fastest way to comply with everything in this guide is to not hand-write the CSS at all:smithers-orchestrator/ui ships shared components (Button, Card, Badge, StatusPill, Tabs, Dialog, Tooltip, Select, Input, Alert, Table, Progress, Skeleton, Spinner, EmptyState, and more) whose styling is generated from the token set below, with the exact house recipes (tinted primary button, brand focus halo, 22px status pills, 8px card radius). They are correct in light and dark by construction, and Radix supplies the accessibility behavior. See the shared component library for usage and the UI Component Library reference for the full catalog.
Everything below remains the contract those components implement, and what you follow whenever you write 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, a tint, or a shadow alpha inline; reach for a token (or derive one withcolor-mix). This is what makes light and dark “just work” and what keeps two different workflow UIs from drifting apart.
The Gateway’s HTML shell already injects this exact token block (the canonical
workflowUiThemeCss from @smithers-orchestrator/ui-styleguide) into every /workflows/<key> page, and stamps data-theme on <html> from a ?theme=dark|light query param before first paint. So a workflow UI that just reads the tokens is themed automatically, including for hosts that embed the page in an iframe and force a theme via the query param. Declaring the block yourself (as below, or via WorkflowUiStyles from gateway-ui) is still correct and keeps the UI portable outside the shell; duplicate declarations are harmless.:root block at the top of your <style> (it carries the canonical light values, the dark overrides for both the explicit toggle and the OS preference, and the typographic base):
The OS-follow selector above is written as one rule for brevity. In the product UI the OS branch is split into its own
@media (prefers-color-scheme: dark) block so it can never override an explicit data-theme="light". If you support an in-app toggle, copy that split; if your UI is dark-only or OS-only, keep just the branch you need.The principles
Color is semantic, tints are derived
There are exactly three accent roles. Do not introduce a fourth 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.
color-mix against a token rather than picking a new hex. This keeps the tint correct in both themes automatically:
--inverse-bg / --inverse-text), not the brand color. Brand is for attention and progress; inverse is for the one button you want pressed. Secondary actions are --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. Always render with
-webkit-font-smoothing: antialiasedandfont-synthesis: none. - Weight does the work. Labels and titles live at 600 to 800, not 400. Body copy is the default weight; muted metadata is
--text-faint. - Headings sit tight:
letter-spacing: -0.01em(down to-0.02emfor large display text). Size headings inrem(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: uppercasewith positive tracking (letter-spacing: 0.03emto0.06em) at 11 to 12px and weight 700+. This is how you signal “label” without making text large. - Monospace is for identity, not chrome. Run ids, SHAs, and code use
ui-monospace, monospace. Do not 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.
Depth is glass and layered shadow, never a hard box
Elevated surfaces (cards, floating chrome, toasts, modals) are translucent glass, not flat fills:- 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.
- Tint shadows through
--shadow-rgb. Writergb(var(--shadow-rgb) / 0.1), neverrgba(0,0,0,0.1). In dark mode--shadow-rgbbecomes pure black so shadows stay believable.
--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, so give focus back explicitly and make it feel intentional.-
Containers (a card holding an input) lift on
:focus-withinwith a brand-tinted glow plus a deepened shadow, not a hard border: -
Discrete controls (buttons, links, pills) get a crisp
:focus-visiblering:outline: 2px solid var(--brand); outline-offset: 2px;. Keyboard users must always see where they are.
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 can reach 260ms. Anything slower feels broken.
- Easing:
ease-outfor entrances,cubic-bezier(0.2, 0.8, 0.2, 1)for a confident morph. Always setbothas the fill mode so the start and end states stick. - Entrances are subtle: opacity from 0 with an 8px
translateY, or a directionaltranslateXof 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. Do not cross-fade two elements; morph the one. - Spinners and pulses are tokenized too: a brand-bordered ring for in-progress, a
--branddot with a0 0 0 4px color-mix(...)halo for “live”. - Reduced motion is centralized.
WorkflowUiStyles,SmithersUiStyles, andstandaloneThemeCss()already ship the same document-wide safety net, so component CSS must not add its own media-query guard. Imperative canvas widgets useprefersReducedMotion()andobserveReducedMotion()fromsmithers-orchestrator/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-strongwith 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 consistently across every UI so a user learns the language once:- In progress / running →
--brand(spinner ring, pulsing dot, brand text). - Finished / healthy →
--success. - Failed / blocked →
--danger.
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
bothfill; 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).