DESIGN.md@apps/desktop · git:20260927.16e72d2 · 2026-09-27 · sha256 85563b7984c444b8

DESIGN.md@apps/desktop git:20260927.16e72d2A

Immutable. This exact content never changes and is served at /api/v1/blob/85563b7984c444b8.

# Harness Kit v2 — Design Spec (canonical)

**Status:** Signed off 2026-07-03. Direction **A "Instrument"** selected.
**This file is the source of truth for all v2 UI work.** Agents build against this, never invent. When this conflicts with older styling on `main`, this wins. Token values here are expressed against the **existing CSS variable names** in `packages/design-tokens/src/tokens.ts` so rollout is a values-swap + `pnpm generate:tokens` — do not rename variables.

Product framing (why the UI looks the way it does): Harness Kit v2 is **the control plane for your AI coding harnesses** — a dense professional instrument that is scanned and operated, not read top-to-bottom. It is dogfooded daily and must also win a stranger's first five minutes. It is NOT a marketing site; marketing scale lives only on the website.

---

## 0. Direction A "Instrument" — the one-line brief

Warm graphite base + a single azure accent. Linear/Warp lineage but **warm-tinted, not cool slate** — the warm neutral is the whole differentiator; drifting to blue-grey slate makes it generic. Data is mono, dense, and calm. Azure appears only on the active nav rail, primary actions, links, focus, and accented numerals. Everything else is quiet.

---

## 1. Anti-slop rules (hard rejects in review)

Reject any UI that contains:
- Cool blue-grey **slate** neutrals (this direction is warm graphite — `#131215`, not `#0f172a`). Purple/indigo/violet accents. Purple→blue gradients.
- Glassmorphism, gradient hero text, glow-on-everything, floating 3D blobs.
- **Emoji as icons.** Icons are Lucide only, `viewBox="0 0 24 24"`, stroke 1.7, `w:16` default (20 for headers). One set, consistent stroke.
- Visible 1px neutral borders as the default separator. **Borderless is a core principle** — separate with background elevation steps + spacing + soft shadow. `--border-*` alphas are deliberately near-invisible; use them only as hairline reinforcement, never as the primary divider.
- Marketing aesthetics inside the app: oversized type, 48px section gaps, centered hero layouts.
- Four-stat-cards-in-a-row as decoration. Show data the user acts on, or nothing.
- **Inline hex or spacing literals in page code.** Every themed property comes from a token var through a `packages/ui` component. (Lint-enforced in `apps/desktop/src/pages`.)
- Exclamation marks in app copy. Vague errors. System-jargon labels.

---

## 2. Color tokens (map onto existing var names)

Status colors are **shared across themes** and are NOT the accent. Only azure is the accent.

### Dark (default)
```
--bg-base:        #131215   /* warm graphite — NOT slate */
--bg-surface:     #1B191E   /* panels, cards, table body */
--bg-elevated:    #242128   /* header rows, chips, hover targets */
--bg-sidebar:     rgba(27,25,30,0.85)
--fg-base:        #EDEAE6   /* warm off-white */
--fg-muted:       #A29C95
--fg-subtle:      #6C665F
--fg-placeholder: #514C46
--border-base:    rgba(255,255,255,0.05)
--border-strong:  rgba(255,255,255,0.09)
--border-subtle:  rgba(255,255,255,0.03)
--accent:         #2E9BE6   /* azure — active nav, primary btn, links, focus */
--accent-light:   rgba(46,155,230,0.14)   /* accent tint bg (active nav, hover rows) */
--accent-text:    #6BC0F5   /* azure text on dark, AA on --bg-base */
--accent-glow:    rgba(46,155,230,0.35)   /* focus ring / soft accent shadow */
--hover-bg:       rgba(255,255,255,0.04)
--active-bg:      rgba(255,255,255,0.07)
```

### Light
```
--bg-base:        #F4F2EF   /* warm paper-neutral — NOT pure #fff, NOT cool grey */
--bg-surface:     #FFFFFF
--bg-elevated:    #ECEAE5
--bg-sidebar:     rgba(244,242,239,0.85)
--fg-base:        #1A1714
--fg-muted:       #57514A
--fg-subtle:      #8B8479
--fg-placeholder: #A8A199
--border-base:    rgba(20,15,10,0.06)
--border-strong:  rgba(20,15,10,0.10)
--border-subtle:  rgba(20,15,10,0.03)
--accent:         #2E9BE6   /* icons / large accents / fills */
--accent-light:   rgba(46,155,230,0.10)
--accent-text:    #1668A6   /* azure TEXT on light must use this (AA on white); #2E9BE6 fails small-text contrast */
--accent-glow:    rgba(46,155,230,0.25)
--hover-bg:       rgba(20,15,10,0.04)
--active-bg:      rgba(20,15,10,0.07)
```

### Status (shared, both themes)
```
--success:  #3FB07A   --success-light: rgba(63,176,122,0.14)
--warning:  #E0A33D   --warning-light: rgba(224,163,61,0.16)
--danger:   #F2555A   --danger-light:  rgba(242,85,90,0.14)
```
Drift counts use `--warning`; conflicts/denials use `--danger`; in-sync uses `--success`. Status chips = tinted bg + colored text + a 6px dot. **Never solid-fill pills.**

### Shadows (warm-tinted in light)
```
Dark:  --shadow-sm: 0 1px 2px rgba(0,0,0,.4)
       --shadow-md: 0 6px 20px -6px rgba(0,0,0,.55)
       --shadow-lg: 0 16px 40px -12px rgba(0,0,0,.6)
Light: --shadow-sm: 0 1px 2px rgba(20,15,10,.08)
       --shadow-md: 0 8px 24px -8px rgba(20,15,10,.14)
       --shadow-lg: 0 18px 48px -16px rgba(20,15,10,.18)
```

---

## 3. Typography

- **UI face:** system stack `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif`. No webfonts in the app.
- **Data/mono face:** `ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace`. Use for: file paths, YAML, versions, counts in tables, diffs, MCP commands, harness IDs. `font-variant-numeric: tabular-nums` anywhere digits align in columns.
- A distinctive display face is allowed on the **website only**, never in the app.

Scale (px / weight):
| Role | Size | Weight | Notes |
|---|---|---|---|
| Page title | 19 / 650 | | `letter-spacing:-.01em` |
| Section headline | 13 / 600 | | |
| Body / table cell | 12.5–13 / 450 | | line-height 1.5 |
| Caption | 11 / 450 | `--fg-muted` | |
| Micro-label (uppercase) | 10.5 / 650 | `--fg-subtle`, `letter-spacing:.07em`, `text-transform:uppercase` | column headers, group labels |
| Big metric | 23 / 660 | tabular-nums, `-.02em` | summary strip, onboarding stats |
| Mono data | 10.5–12 | | versions/paths/values |

Headings get `text-wrap: balance`. Running prose (rare in-app) stays ≤ ~65ch.

---

## 4. Layout, spacing, motion, interaction

- **Density:** compact. Table/list row height **46px** (this direction is data-dense but not cramped — do not go below 40). 8px spacing grid: 4 / 8 / 12 / 16 / 24. Main content padding 24–28px.
- **Sidebar:** 216px (240 acceptable), collapsible, `--bg-sidebar` with `inset -1px 0 0 --border-base` (no hard border).
- **Elevation model:** `--bg-base` (app) → `--bg-surface` (panels/cards) → `--bg-elevated` (header rows, chips, hover). Depth comes from these + `--shadow-*`, not outlines.
- **Radius:** 8px controls/cards, 7px chips, 6px small icon tiles, 11–12px large panels.
- **Motion:** 150–250ms ease-out, `transform`/`opacity` only. No spring/bounce. Respect `prefers-reduced-motion` (kill transitions/animations).
- **Interaction:** `cursor:pointer` on everything clickable. Hover = background tint (`--hover-bg` or `--accent-light` on rows), **never a scale transform that shifts layout**. Visible focus ring: `2px solid --accent` (or `--accent-glow` box-shadow), offset 2px. Cmd+K command palette with a **central command registry** (adding a page auto-registers its command — no hardcoded palette list).
- **Empty states teach:** every empty surface states what it's for + why it's empty + one action button. Never a blank pane.
- **Copy voice:** plain, specific, count-forward ("5 harnesses, 5 drifted", "Drift 3"). Buttons say what happens ("Recompile all" → toast "Recompiled 4 configs"). Errors: what broke + how to fix. No exclamation marks.
- **Labs:** entries carrying a `labs` key in `apps/desktop/src/nav.ts` (Comparator today) are hidden from the sidebar, the ⌘-number shortcuts and the command palette until enabled in Settings › Labs. The route itself stays mounted, so a direct navigation still renders it (ux-consolidation spec AC-8, design D4).

---

## 5. Information architecture

Sidebar, top→bottom, declared once in `apps/desktop/src/nav.ts` — the sidebar, the ⌘-number shortcuts, the command palette and the default-section setting all derive from that one module (ux-consolidation spec AC-2, AC-4; design D2):
```
[logo] Harness Kit
  Machine        (home; ⌘1)   cross-surface grid · Drift vs harness.yaml view
  Profile        (⌘2)         harness.yaml · Compile to project
  Claude Code    (⌘3)         Instructions · MCP servers · Plugins · Hooks · Permissions · Usage
  Marketplace    (⌘4)
  Comparator     (Labs only — hidden until enabled, see §4)
  ─────
  Settings       (pinned bottom; ⌘,)
```
No "Configure" group (retired — spec AC-3, design D5). No Fleet. No Drift or Observatory as top-level items — Drift is a view of Machine, not a destination of its own (the command palette keeps an "Open Drift vs harness.yaml" command), and Observatory's pages render as "Usage" inside Claude Code (spec AC-9; see §6). No Services/Board/Roadmap/Chat/Memory/Terminals. Nothing else.

`/fleet` and `/drift` (and the other retired routes: `/agents`, `/harness/settings`, `/security/*`) redirect to their consolidated destination and preserve any query parameter the destination reads (spec AC-6). `/drift?harness=<id>` and `/machine?view=drift&harness=<id>` open Machine's Drift view pre-filtered (spec AC-7, AC-18); the legacy `/machine?drift=1` still selects the Drift view.

Two groups grow further capability in later phases without changing this shape: Machine gains a team-baseline column in P3 (spec AC-26), and Profile gains "Share" in P3 (spec AC-25). Neither is built yet; the Machine contract in §6 and the Profile note there describe what ships today, including the legend and the Gaps/Differs filters from P2a and the Drift view from P2b.

Active nav item: `--accent-light` background + a 2.5px inset azure rail on the left (`--accent`), `font-weight:550`. Inactive: `--fg-muted`, hover → `--bg-elevated` + `--fg-base`.

---

## 6. Screen contracts

Reference implementation of the visual language: the signed-off mock (Fleet — now shipped as Machine, see D1 in [ux-consolidation/design.md](../../specs/ux-consolidation/design.md) — + Onboarding, Direction A). Match its structure and token usage.

### Machine (home) — this screen IS the product pitch
1. **Page head:** title "Machine" + one-line subtitle ("Every AI-harness resource on this machine, across all supported surfaces.") + primary action "Refresh" (right; reads "Scanning…" while a scan is in flight).
2. **Project directory:** an optional monospace text input + "Browse…" (native picker) + "Clear" (shown once a directory is set). The scan is machine-only by default; picking a directory adds project-scope stores to it. A `--warning`-toned notice ("Project directory could not be scanned — showing machine-only results") appears if the chosen directory degrades.
3. **Summary strip:** a single elevated bar: Resources (n) · Gaps (n) · Differs (n) · Drift vs harness.yaml (n) · Surfaces detected (n/total). Big-metric type, tabular-nums, uppercase micro-labels. Gaps and Differs count *rows* with at least one such chip (the unit the filter reveals; pairwise diffs would overcount), `--warning` when above zero. A non-zero Gaps or Differs cell is a toggle button (`aria-pressed`, active = `--accent-light` with an inset azure bottom rule) that filters the grid through `?filter=gaps|differs`, keeping every other query param. The active cell stays a pressed button even when its count reaches zero, so an empty filtered grid always has a visible cause. While filtered, a status line under the strip reads "Showing N of M resources with a closable gap" (or "…whose content differs") with a ghost "Show all". A drawer whose row is filtered out closes. The filter belongs to the grid: while the Drift view shows, no filter cell reads as pressed and there is no status line, and choosing Gaps or Differs returns to the grid with that filter in one URL update. The Drift cell is always a toggle button that selects the Drift view (pressed while it shows; pressing it again returns to the grid). Its value is the count of drift items the Drift list shows (unacknowledged; under `harness=<id>`, that harness only) from the last scan Drift itself ran since Machine opened, `--warning` above zero, and "—" before any such scan: never "…", which would claim a scan is running, and Machine never scans for drift to fill it (see item 6). Under `harness=` the cell's accessible name says whose count it is ("Drift vs harness.yaml 3 for Cursor"). **View tabs:** under the strip sits a two-option segmented control, Resources | Drift vs harness.yaml. The options switch the content below, so it is a WAI-ARIA tab list labelled "Machine view": each option is a `tab` with `aria-selected` and `aria-controls` naming the one `tabpanel` that holds the view, which is labelled by the selected tab. Only the selected tab is in the Tab order; Left/Right (wrapping) and Home/End move to a tab and select it. Selected = `--accent-light` with an inset azure bottom rule, like a pressed strip cell. It selects `?view=grid|drift` (grid is the default and drops the param), keeping every other query param; choosing the tab already selected adds no history entry. It shows even when the machine scan failed, since Drift does not need the inventory, and keeps one place in the tree whether or not the strip has rendered, so focus on it survives the first scan landing.
4. **Grid:** rows = resources grouped by kind (uppercase group header), columns = surfaces grouped by product family (grouped header row); an undetected surface's column is dimmed and labelled "not installed". Each cell is a mono chip whose variant comes from `pages/machine/cell-state.ts` (spec AC-13, AC-14): `user` / `project` = present at that scope (tinted); `+ copy` = a closable gap, a dashed ghost chip; `differs` = present, but its effective digest departs from the row's baseline (the most frequent digest, ties to the earliest surface in registry order), `--warning` tint; `—` = no such concept on this surface; a dimmed `·` = has the concept but keeps no store here (not a gap); `?` = unknown (surface installed, store may be hidden); blank = absent and nothing can be copied there. Tooltips list every entry's digest, file and scope. Surface headers carry a skipped-entries count badge and, where readable, a marketplace-count badge. A persistent legend under the grid names every chip and both header badges, and lists only states the grid uses. Drift has no chip: it is per harness.yaml target, not per grid cell, and lives in the Drift view. Row click opens the drawer; the selected row is `--accent-light`, so the neutral chips stay legible on it.
5. **Row drawer:** belongs to the grid view; switching to Drift closes it. A right-side panel, `--hk-drawer-width` (380px) wide. While it is open the page reserves that width on its right (`.hk-page[data-drawer-open]`), so no grid column sits under it; a narrow window scrolls the grid instead (AC-19). "Where it lives" (per-surface entries: scope, file path, short digest) and, when the row has cross-surface differences, "Differences" (each delta's path, kind, and left→right values). Below that, the copy action as **From → To** (AC-15): a source select over the surfaces that hold the resource, defaulting to the one holding the baseline, and a target select listing closable gaps first, then surfaces that hold a different version (which replace content and need the overwrite acknowledgement), defaulting to the first gap. Then the resolved CLI command, a capability-loss or overwrite checkbox when the plan requires one, a secret-value notice when applicable, and Apply / Copy CLI command / Copy prompt. A successful Apply toasts "Copied <name> to <surface>" (AC-16) and rescans; the drawer stays open on the selected row, refreshed from the new scan, and Apply stays off until that scan rebuilds the plan, so one click writes once.
6. **Drift view** (`?view=drift`, spec AC-18, design D6): replaces the grid, its legend, the filter status line and the skipped diagnostics (inside the tab panel) with the existing Drift page rendered embedded (no second `<h1>`, no nested page container; see "Drift" below). Reached from the strip's Drift cell, the view toggle, the palette's "Open Drift vs harness.yaml", the redirected `/drift` route and the legacy `?drift=1`; `harness=<id>` filters it (spec AC-6, AC-7). The app's scroll container keeps its offset across routes and views, so whenever the view becomes Drift (on arrival or on a switch) or `harness=` changes while it shows, the page scrolls the tabs and the start of Drift to the top of the scroll area, but only when that part is not already on screen: a user already looking at it is not moved. On a cold load the scroll waits for the first machine scan, since the strip it brings lands above the view; a later Refresh does not scroll. Drift is mounted only in this view, and that is behavioural: it scans project scopes and asks Tauri for directory access when it mounts, and the grid, machine-only by default, must never trigger a permission request the user did not ask for. There is no accordion.
7. **Skipped diagnostics** (grid view only): a collapsible disclosure listing, per surface, each skipped file and the reason it was skipped.

### Drift (a view of Machine) — actionable, never a dead-end
Items grouped by project → harness. Each item shows: classification badge (repairable-inside-markers / user-edited-outside / missing / orphaned), an inline **mono diff viewer** (syntax-highlighted, red/green gutters using `--danger`/`--success` tints), and a per-item **Fix** button that previews the exact plan before applying. Header offers **Fix all** (dry-run preview first). User-content drift offers only Acknowledge / Review — never silent overwrite. Every row has an action.

### Profile and Claude Code (editors)
The generic "Configure" group is retired — no group by that name returns (spec AC-3, design D5). Editors now live under two sidebar groups: **Claude Code** (Instructions, MCP servers, Plugins, Hooks, Permissions, Usage — each edits that one surface's own files) and **Profile** (harness.yaml). Today Profile's `/harness/file` is a single editor whose empty state offers three entry paths — "Generate from Claude Code setup" (scans existing config), "Start from a profile", and "Create from scratch" (the only one that opens a blank template) — each of which drops into the same Monaco editor, where "Save harness.yaml" then appears. Its planned end state (P3, spec AC-22–AC-25, design D11) is a Monaco YAML editor (left/main) + a structured side panel (plugins, MCP servers, instruction slots, permissions) that round-trips to the YAML, with the empty state replaced by the full **import flow** ("Scan this machine"). That full flow is not built yet, but "Generate from Claude Code setup" is already a real scan-based precursor to it today — narrower in scope (one surface, not Machine's planned multi-surface scan), not absent.

### Onboarding (first run) — the highest-craft screen
Full-bleed within the window (no app sidebar yet — it's a wizard). Sequence:
1. Scan progress ("Machine scan complete · 1.8s", azure pulse eyebrow).
2. **The sprawl reveal:** honest headline ("You run 5 harnesses. *They don't agree.*", the disagreement clause in azure), lede explaining "we read the config you already have — no authoring required", then a 4-up **stat row** (harnesses found / config files / overlapping instruction sets [warning] / direct conflicts [danger]).
3. **Convergence map:** source harness chips (each with file counts, drift counts in warning) → converging lines → a single `harness.yaml` card (mono preview: plugins/mcp-servers/skills counts in azure). Schematic, not heavy.
4. **Conflicts list:** concrete, specific rows in warning tint ("'Run tests before commit' appears in 3 tools with 2 wordings"; "MCP server `github` points at two commands"; "Copilot allows `rm -rf` where Claude Code denies it").
5. **CTA:** "Preview harness.yaml" (primary) + "Explore read-only" (ghost) + note "Nothing is written until you confirm." Declining leaves a fully usable read-only app. Every step carries a "Skip setup" ghost action top-right; a failed scan renders its error with "Retry scan" instead of an empty frame (ux-consolidation AC-32, AC-33).

### Comparator / Marketplace / Settings
Re-skin existing data behavior onto `packages/ui` + these tokens. No new features, no external-service affordances. Permissions lives under Claude Code; Secrets and Activity live under Settings (spec AC-10, AC-11). Observatory's pages render as "Usage" inside Claude Code, not as a standalone sidebar item (spec AC-9; see §5).

---

## 7. Component library (`packages/ui`) — minimum inventory

Build these token-driven, borderless-by-default (surface via `--bg-elevated`/`--bg-surface` + `--shadow-sm`, no neutral outline). Components live only in `packages/ui/src/components/`; the desktop-local `apps/desktop/src/components/ui/` set was removed (ux-consolidation AC-41) and must not return.

- `Button` (primary=azure fill+white / ghost=elevated / danger), `Sidebar`+`NavItem`, `SummaryStrip`, `Matrix`/`Table`, `StatusChip` (success/warning/danger/subtle), `DiffViewer` (mono), `Modal` (one implementation, kills the 3 legacy modal styles), `Toast`, `EmptyState`, `Stat`, `Card`, form controls (input/select/toggle), `CommandPalette`.

Every v2 page uses only these + token vars for themed properties. Theme toggle re-themes **every** page — no page may hardcode its own palette (the old Board mistake is banned).

---

## 8. Verification for any UI change

1. Build the screen, then Playwright-screenshot **dark + light** at 1440 / 1024 / 768.
2. Check against §1 (anti-slop) and §2–§7 before requesting review.
3. Final validation for desktop = `pnpm install:desktop`, launch the real `.app` (not a browser), confirm not-blank and theme toggle works. (macOS 26 release builds blank — use the debug binary + dev server; see repo memory.)
4. Contrast: azure text on light MUST use `--accent-text` (#1668A6). Verify 4.5:1 for any small text.