> ## Documentation Index
> Fetch the complete documentation index at: https://smithers.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Workflow UI Design Principles

> The shared visual language every smithers ui workflow UI should follow, so each .smithers/ui/<workflow>.tsx looks like one product instead of a different app per workflow.

[Custom Workflow UIs](/guides/custom-workflow-ui) 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.

<Note>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.</Note>

## Components first

The fastest way to comply with this guide: don't hand-write the CSS. `smithers-orchestrator/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](/guides/custom-workflow-ui#the-shared-component-library) for usage and the [UI Component Library reference](/reference/ui) 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.

<Note>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 UI that just *reads* the tokens is themed automatically, even inside an iframe host forcing a theme via that param. Declaring the block yourself (as below, or via `WorkflowUiStyles` from `gateway-ui`) is still correct, keeps the UI portable outside the shell, and duplicate declarations are harmless.</Note>

Paste this `:root` block at the top of your `<style>`: it carries the canonical light values, dark overrides for both the explicit toggle and the OS preference, and the typographic base.

```css theme={"theme":{"light":"github-light","dark":"github-dark"}}
:root {
  color-scheme: light;

  /* Surfaces & text */
  --bg: #ffffff;
  --text: #0a0a0a;
  --text-muted: #525252;
  --text-faint: #6f6f6f;
  --text-placeholder: #767676;
  --surface: #ffffff;
  --surface-glass: rgba(255, 255, 255, 0.72);
  --surface-glass-strong: rgba(255, 255, 255, 0.85);

  /* Lines & hovers */
  --border: rgba(10, 10, 10, 0.08);
  --border-strong: rgba(10, 10, 10, 0.14);
  --border-solid: #ededed;
  --hover: #f4f4f4;
  --hover-subtle: rgba(10, 10, 10, 0.03);

  /* Inverse: the high-contrast primary-action surface (black in light, white in dark) */
  --inverse-bg: #0a0a0a;
  --inverse-bg-hover: #262626;
  --inverse-text: #ffffff;

  /* Code */
  --code-bg: #0a0a0a;
  --code-text: #f4f4f5;
  --inline-code-bg: rgba(10, 10, 10, 0.07);

  /* Semantic accents */
  --brand: #6d56d8;
  --success: #0f8f78;
  --danger: #e5484d;

  /* Shadow channels: space-separated so any shadow can pick its own alpha */
  --shadow-rgb: 10 10 10;

  /* Geometry (theme-invariant): spacing scale, type scale, radii, and the one
     control height every button/chip/input/select shares. Reach for these
     instead of bare pixel values so spacing rhythm stays even everywhere. The
     --sp scale paces layout-level spacing; component-internal padding and gap
     stay on a 2px grid (even values only, never 5/7/9px). Weight roles: 650
     is the only emphasis weight, 700 is reserved for KPI numerals. */
  --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
  --sp-5: 20px; --sp-6: 24px; --sp-7: 28px; --sp-8: 32px;
  --fs-1: 11px; --fs-2: 12px; --fs-3: 13px; --fs-4: 15px;
  --fs-5: 17px; --fs-6: 20px; --fs-7: 24px;
  --lh-tight: 1.35; --lh-body: 1.5;
  --r-1: 6px; --r-2: 10px; --r-3: 12px; --r-4: 16px;
  --r-bubble: 18px; --r-full: 999px;
  --ctl-h: 32px; --ctl-h-sm: 26px; --ctl-h-lg: 38px;

  color: var(--text);
  background: var(--bg);
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Force-dark via an in-app toggle that sets data-theme on <html>. */
:root[data-theme="dark"],
/* And follow the OS when the user has not forced a choice. */
:root:not([data-theme="light"]) {
  color-scheme: dark;
  --bg: #0b0b0d;
  --text: #f4f4f5;
  --text-muted: #a1a1aa;
  --text-faint: #b0b0b8;
  --text-placeholder: #a6a6ad;
  --surface: #18181b;
  --surface-glass: rgba(24, 24, 27, 0.72);
  --surface-glass-strong: rgba(24, 24, 27, 0.85);
  --border: rgba(255, 255, 255, 0.1);
  --border-strong: rgba(255, 255, 255, 0.18);
  --border-solid: #2a2a2e;
  --hover: #26262b;
  --hover-subtle: rgba(255, 255, 255, 0.05);
  --inverse-bg: #f4f4f5;
  --inverse-bg-hover: #d4d4d8;
  --inverse-text: #0a0a0a;
  --code-bg: #09090b;
  --code-text: #e4e4e7;
  --inline-code-bg: rgba(255, 255, 255, 0.1);
  --brand: #8b78e6;
  --success: #2ec9a8;
  --danger: #f2555a;
  --shadow-rgb: 0 0 0;
}
```

<Note>The OS-follow selector above is written as one rule for brevity; the product UI splits the OS branch into its own `@media (prefers-color-scheme: dark)` block so it can never override an explicit `data-theme="light"`. Copy that split if you support an in-app toggle, or keep just the branch you need for a dark-only or OS-only UI.</Note>

## The principles

### Color is semantic, tints are derived

Exactly three accent roles exist. 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.

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:

```css theme={"theme":{"light":"github-light","dark":"github-dark"}}
.error {
  border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border));
  background: color-mix(in srgb, var(--danger) 10%, var(--surface));
  color: var(--danger);
}
```

Primary actions ("Run", "Approve", "Submit") use the **inverse** surface (`--inverse-bg` / `--inverse-text`), not brand: brand marks *attention and progress*, inverse marks *the one button you want pressed*. 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:

```css theme={"theme":{"light":"github-light","dark":"github-dark"}}
.card {
  background: var(--surface-glass);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
  backdrop-filter: blur(20px) saturate(180%);
  box-shadow:
    0 1px 2px rgb(var(--shadow-rgb) / 0.04),
    0 16px 40px rgb(var(--shadow-rgb) / 0.1);
}
```

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:

  ```css theme={"theme":{"light":"github-light","dark":"github-dark"}}
  .composer:focus-within {
    border-color: color-mix(in srgb, var(--brand) 32%, var(--border));
    box-shadow:
      0 0 0 4px color-mix(in srgb, var(--brand) 12%, transparent),
      0 20px 48px rgb(var(--shadow-rgb) / 0.14);
  }
  ```

* **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:

```css theme={"theme":{"light":"github-light","dark":"github-dark"}}
.button:active:not(:disabled)         { background: color-mix(in srgb, var(--text) 6%, var(--hover)); }
.button.primary:active:not(:disabled) { background: color-mix(in srgb, var(--brand) 22%, var(--surface)); }
.button.danger:active:not(:disabled)  { background: color-mix(in srgb, var(--danger) 16%, var(--surface)); }
```

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
  `smithers-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-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):

```tsx theme={"theme":{"light":"github-light","dark":"github-dark"}}
/** @jsxImportSource react */
import { createGatewayReactRoot, useGatewayRun } from "smithers-orchestrator/gateway-react";

const runId = new URLSearchParams(location.search).get("runId") ?? undefined;

const styles = `
  /* paste the :root token block here */
  body { margin: 0; background: var(--bg); color: var(--text); }
  .shell { width: min(100%, 720px); margin: 0 auto; padding: 36px 24px; }
  .card {
    padding: 16px;
    border: 1px solid var(--border);
    border-radius: var(--r-3);
    background: var(--surface-glass);
    -webkit-backdrop-filter: blur(20px) saturate(180%);
    backdrop-filter: blur(20px) saturate(180%);
    box-shadow: 0 1px 2px rgb(var(--shadow-rgb) / 0.04), 0 16px 40px rgb(var(--shadow-rgb) / 0.1);
  }
  .eyebrow {
    font-size: 11px; font-weight: 650; letter-spacing: 0.05em;
    text-transform: uppercase; color: var(--text-faint);
  }
  .badge {
    font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.03em;
    padding: 2px 8px; border-radius: var(--r-1);
    border: 1px solid color-mix(in srgb, var(--brand) 30%, var(--border)); color: var(--brand);
  }
  .badge.finished { border-color: color-mix(in srgb, var(--success) 30%, var(--border)); color: var(--success); }
  .badge.failed { border-color: color-mix(in srgb, var(--danger) 30%, var(--border)); color: var(--danger); }
  .runid { font-family: ui-monospace, monospace; font-size: 12px; color: var(--text-muted); }
  .btn-primary {
    min-height: 38px; padding: 0 14px; border: 0; border-radius: var(--r-2);
    background: var(--inverse-bg); color: var(--inverse-text); font-weight: 650; cursor: pointer;
  }
  .btn-primary:hover { background: var(--inverse-bg-hover); }
  .btn-primary:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
`;

function App() {
  const run = useGatewayRun(runId);
  return (
    <main className="shell">
      <style>{styles}</style>
      <p className="eyebrow">Workflow</p>
      <div className="card">
        <span className="runid">{runId ?? "no run"}</span>
        <span className={`badge ${run.data?.status ?? ""}`}>{run.data?.status ?? "idle"}</span>
      </div>
    </main>
  );
}

createGatewayReactRoot(<App />);
```

## 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](/guides/custom-workflow-ui) 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)](/examples/workflow-ui-react), [Workflow UI (Vanilla)](/examples/workflow-ui-vanilla).
