<!-- source: openspec/current/ui.md -->

---
title: UI
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: Implementation package for the design system — tokens, primitives, blocks, and animations as React and Tailwind components.
---
# UI — capability spec

> Implementation package for the studio design system. Exposes tokens, primitives, blocks, and animations as React + Tailwind components consumed by both `apps/web` and `apps/studio`. This file is the **implementation-reference layer** — visual intent lives in [`design-system.md`](design-system.md), token values in [`design-tokens.md`](design-tokens.md), motion in [`design-motion.md`](design-motion.md), and component anatomy in [`design-components.md`](design-components.md).
>
> Authority: rule R10 from [`_methodology/design-system-convention.md`](../_methodology/design-system-convention.md). When this file and the design-* siblings disagree, the sibling specs win.

---

## Where ui lives

- Source: `packages/ui/`
- Package name: `@rapoport-studio/ui`
- Version: `0.0.0`, `private: true`
- Entry surface: subpath `./*` exports — components, styles, utilities accessible at `@rapoport-studio/ui/*`
- Build: none. Consumers compile `.ts`/`.tsx` directly via Next.js / vitest / app build.
- Scripts: `lint` (eslint src), `typecheck` (tsc --noEmit), `test` (vitest run).
- Test runtime: vitest with `environment: "node"`.

---

## Architecture

```
packages/ui/
├── src/
│   ├── components/        Primitives (button, input, card) and blocks (header, hero)
│   ├── styles/
│   │   └── globals.css    Tailwind base + design tokens (CSS custom properties)
│   ├── lib/
│   │   └── utils.ts       cn() helper (clsx + tailwind-merge)
│   └── index.ts           Barrel re-exports
└── __tests__/             Component shape + render tests
```

The package's role is to be the **single source of truth for the implementation of the visual layer**: every color, spacing value, font, animation curve, and component style lives here. Apps consume; they never re-declare.

Three layers, top-down:

1. **Tokens** — CSS custom properties in `src/styles/globals.css`. Token *contract* lives in [`design-tokens.md`](design-tokens.md); this file maps to file paths.
2. **Primitives** — atomic components built directly on tokens. Buttons, inputs, badges, links.
3. **Blocks** — composed structures used by app pages. Cards, project rows, entity chips, form layouts.

---

## Public surface

Exported via per-component subpath exports declared in `package.json` (`./components/*`, `./hooks/*`, `./lib/*`, `./globals.css`). There is **no** root barrel `src/index.ts` — consumers reach individual components by their subpath (`@rapoport-studio/ui/components/button`, etc.).

| Category | Examples |
|---|---|
| Form primitives | `Button`, `Input`, `Textarea`, `Select`, `Form`, `Label`, `Avatar`, `Badge`, `Separator`, `Skeleton` |
| Layout primitives | `Container`, `Stack`, `Grid`, `Spacer`, `ScrollArea` |
| Feedback | `Skeleton`, `Sonner` (toaster) |
| Marketing — philosophy spine (applied 2026-05-24, RAP-1211) | `ManifestoBlock`, `MirrorReferenceBlock` (inline + card variants), `SpecCorpusDiagram` |
| Overlays | `Dialog`, `AlertDialog`, `Sheet`, `DropdownMenu`, `Tooltip`, `Collapsible`, `Popover`; URL-state overlay system (`@rapoport-studio/ui/overlay`): `OverlayHost`, `defineDialog`, `useDialog`, `announce`, `LiveRegions`, `SonnerWrapper` |
| Tabs | `Tabs` (variants: `default`, `line`, `strip`) |
| Cards | `Card` (variants: `default`, `tactical`) |
| Tactical | `TacticalFrame` (composite four-bracket frame) |
| Studio | `AppShell`, `ModeRail`, `KpiTile`, `DataTable`, `SmartPill`, `Sidebar` |
| Status | `StatusPill` (six visual roles), `ToolStatusPill`, `tool-status-labels` |
| Workspace chrome | `MetaBar`, `MonoLabel`, `DividerTechnical`, `TechPill`, `SectionHead` |
| Code surfaces | `DiffView`, `InlineShiki` |
| Forge primitives | `PhasePipeline`, `LiveLogStream`, `FindingRow` |
| Stub | `_slot.tsx` (placeholder, not consumed) |
| Styles | `globals.css` (imported once at app root) |
| Utils | `cn()` from `lib/utils.ts`; `useIsMobile()` from `hooks/use-mobile.ts` |
| Hooks | `useFormDraft()` from `hooks/use-form-draft.ts` (form draft persistence — see § useFormDraft appendix) |

Component anatomy and variants live in [`design-components.md`](design-components.md). Token values live in [`design-tokens.md`](design-tokens.md).

---

## Implementation references

For every component listed in [`design-components.md`](design-components.md), the file path and key entry point. Use this table to navigate from a spec row to the code that implements it.

| Component | Path | Notes |
|---|---|---|
| `TacticalFrame` | `packages/ui/src/components/tactical-frame.tsx` | Composite four-bracket frame; consumed by `apps/web/src/components/{home/hero, network/person-card, work/work-card}.tsx` |
| `Chip` | `packages/ui/src/components/chip.tsx` | Unified chip primitive (RAP-223). Seven kinds (`status` / `tool-status` / `smart` / `tech` / `skill` / `persona` / `mono-label`). Wrappers below delegate here for visual DNA. |
| `StatusPill` | `packages/ui/src/components/status-pill.tsx` | Thin facade → `<Chip kind="status">`. Six visual roles via CVA |
| `MonoLabel` | `packages/ui/src/components/mono-label.tsx` | Thin facade → `<Chip kind="mono-label">`. Uppercase mono eyebrow |
| `MetaBar` | `packages/ui/src/components/meta-bar.tsx` | Inline meta-data bar |
| `DividerTechnical` | `packages/ui/src/components/divider-technical.tsx` | Horizontal/vertical rule with tactical ticks |
| `TechPill` | `packages/ui/src/components/tech-pill.tsx` | Thin facade → `<Chip kind="tech">`. Inline code/tech tag |
| `KpiTile` | `packages/ui/src/components/kpi-tile.tsx` | Dashboard tile |
| `DataTable<T>` | `packages/ui/src/components/data-table.tsx` | Generic table |
| `SmartPill` | `packages/ui/src/components/smart-pill.tsx` | Thin facade → `<Chip kind="smart">`. Composer quick-action pill |
| `ModeRail` | `packages/ui/src/components/mode-rail.tsx` | 56px mode-switcher rail (`ModeRailItem` colocated) |
| `AppShell` | `packages/ui/src/components/app-shell.tsx` | Slot-based layout (`AppShellRail`, `AppShellMain` colocated) |
| `Sidebar` | `packages/ui/src/components/sidebar.tsx` | Generic vertical-rail / panel primitive (`SidebarProvider`, `SidebarInset` colocated) |
| `SectionHead` | `packages/ui/src/components/section-head.tsx` | Three-row eyebrow + heading + lede primitive |
| `EntityCard` | `packages/ui/src/components/entity-card.tsx` *(deferred — RAP-92)* | Four variants `inline` / `row` / `card` / `detail` |
| `SkillChip` | *deferred — RAP-94* | Lives in `packages/ui` once shipped |
| `PersonaBadge` | `packages/ui/src/components/persona-badge.tsx` | Thin facade → `<Chip kind="persona">`. Bracketed-letter glyph |
| `ToolStatusPill` | `packages/ui/src/components/tool-status-pill.tsx` | Thin facade → `<Chip kind="tool-status">`. Per-tool execution-status pill |
| `tool-status-labels` | `packages/ui/src/components/tool-status-labels.ts` | Status taxonomy constants |
| `FindingRow` | `packages/ui/src/components/finding-row.tsx` | Severity-coloured row |
| `PhasePipeline` | `packages/ui/src/components/phase-pipeline.tsx` | Vertical accordion (4 phases) |
| `LiveLogStream` | `packages/ui/src/components/live-log-stream.tsx` | SSE-driven monospace stream |
| `DiffView` | `packages/ui/src/components/diff-view.tsx` | Workspace-themed diff viewer |
| `InlineShiki` | `packages/ui/src/syntax/inline-shiki.tsx` | Client-only Shiki primitive |
| `Card` | `packages/ui/src/components/card.tsx` | `default` + `tactical` variants |
| `Tabs` | `packages/ui/src/components/tabs.tsx` | `default` + `line` + `strip` variants |
| `Button` | `packages/ui/src/components/button.tsx` | Primary / Secondary / Accent / Ghost / Mono |
| `Input` | `packages/ui/src/components/input.tsx` | Sans 14px |
| `Textarea` | `packages/ui/src/components/textarea.tsx` | Serif body |
| `Container` | `packages/ui/src/components/container.tsx` | Token-driven page-width wrapper (`reading` / `wide` / `full`) |
| `Stack` | `packages/ui/src/components/stack.tsx` | Flex-flow primitive with `--space-*` gap scale |
| `Grid` | `packages/ui/src/components/grid.tsx` | CSS-grid wrapper, mobile collapses to single column |
| `Spacer` | `packages/ui/src/components/spacer.tsx` | Flex-grow filler or fixed-token gap (decorative) |
| `arkanoid-icon` *(legacy)* | `packages/legacy-brand/src/components/arkanoid-icon.tsx` | Personal-brand legacy — relocated in RAP-210 |
| `period-list-item` *(legacy)* | `packages/legacy-brand/src/components/period-list-item.tsx` | Personal-brand legacy — relocated in RAP-210 |
| `timeline-strip` *(legacy)* | `packages/legacy-brand/src/components/timeline-strip.tsx` | Personal-brand legacy — relocated in RAP-210 |
| `useFormDraft` | `packages/ui/src/hooks/use-form-draft.ts` | Hook for `localStorage`-backed form draft persistence; see § useFormDraft appendix |
| `form-draft-storage` | `packages/ui/src/lib/form-draft-storage.ts` | Pure `loadFormDraft` / `saveFormDraft` / `clearFormDraft` helpers; framework-free |

### Token implementation

| Concern | Path |
|---|---|
| Token declarations | `packages/ui/src/styles/globals.css § :root, .dark` |
| Tailwind utility mapping | `packages/ui/src/styles/globals.css § @theme inline` |
| Variant-portable colour tokens | `packages/ui/src/styles/globals.css § Surface depth tokens` |

### Workspace chrome

Consumer sites for the Workspace dark theme:

| File | Purpose |
|---|---|
| `apps/studio/src/components/studio-sidebar.tsx` | Wires `<Sidebar>` into the workspace shell |
| `apps/studio/src/components/component-design-brief.md` | Operational deviations log |
| `preview/tactical_kit_sampler.html` | Visual canon for tactical primitives |

### Reference files

| File | Purpose |
|---|---|
| `colors_and_type.css` | All design tokens + `@font-face` declarations |
| `ui_kits/_base.css` | Shared CSS: brackets, pills, buttons, inputs, cards |
| `assets/wordmark.svg` | RAPOPORT STUDIO wordmark |
| `assets/corner-brackets.svg` | Tactical frame motif, standalone |
| `assets/blueprint-line.svg` | 2px blueprint rule |
| `fonts/*.woff2` | Inter, Lora, JetBrains Mono (variable) |

---

## Promotion criteria

Per R10, a component is promoted into `packages/ui` when **all four** of the following hold:

1. **≥2 consumers** — at least two app surfaces (or two regions within one app) actively render it. A single consumer keeps the component co-located with that consumer.
2. **No surface-specific logic** — the component does not branch on `apps/studio` vs `apps/web` internally. If it does, the surface-specific behaviour belongs in a wrapper at the consumer site.
3. **Tokens via CSS vars** — every colour, spacing, typography, and motion value comes from `var(--…)` referenced through `@theme inline` or directly. No raw hex, no inline px (except the documented exceptions in [`design-tokens.md`](design-tokens.md)).
4. **A11y contract stable** — the component's keyboard, screen-reader, focus-ring, and touch-target story is written down (in `design-components.md`'s row) and tested (render test in `__tests__/`).

These are criteria, not taste. A component that passes all four migrates with a follow-up change; a component that fails any one stays at the consumer.

### Migration history

When a component moves from a consumer into `packages/ui`, the change folder name follows the pattern `promote-<componentname>` (e.g., `promote-sectionhead`). The applied-delta paragraph in [`design-components.md`](design-components.md) records the date and source change.

---

## Conventions

### Token-driven styling

All visual values come from CSS custom properties. Components reference tokens, not raw hex codes. Adding a new colour: add a token in `globals.css`, reference it via `var(--token-name)` or via Tailwind's `@theme inline` mapping. Never inline a colour in a component file.

### shadcn pattern, customized

Primitives follow the shadcn architecture: components copied into `src/components/`, owned by this package, free to modify. The style is `"new-york"`, base colour `neutral`, RSC-aware, icons via `lucide-react`. Aliases under `@rapoport-studio/ui/*`.

### `cn()` for class composition

Every component that accepts `className` uses the `cn()` helper (`src/lib/utils.ts`):

```ts
import { cn } from "@rapoport-studio/ui/lib/utils";

<Button className={cn("default-styles", className)} />
```

This composes default classes with caller overrides via `tailwind-merge`, deduplicating conflicting Tailwind utilities.

### Server-component-first

Primitives are server components by default. Components that need browser interactivity (state, refs, event handlers) are explicitly marked `"use client"` at the top.

### Reduced-motion respect

Every keyframe animation has a `@media (prefers-reduced-motion: reduce)` block. Test by setting the dev-tools `prefers-reduced-motion: reduce` toggle. See [`design-motion.md § Reduced motion`](design-motion.md).

### Accessibility as default, not afterthought

Primitives use Radix UI for keyboard navigation, focus management, and ARIA attributes. Custom components include `aria-*` props where Radix doesn't cover them.

### File naming

`kebab-case.tsx` for component files, matching the export name in `kebab-case` (e.g., `dropdown-menu.tsx` exports `DropdownMenu`). One component per file. Sub-components (like `DropdownMenu.Item`) live in the same file as the parent.

### Barrel exports per directory

`src/components/index.ts`, `src/lib/index.ts`, etc. Each directory has a barrel that re-exports its public surface.

---

## Consumers

Both `apps/web` and `apps/studio` import from `@rapoport-studio/ui`.

### Setup in apps

Each app must:

1. Import `@rapoport-studio/ui/styles/globals.css` once in the root layout.
2. Configure Tailwind to scan this package's `src/**/*.{ts,tsx}` for class names, alongside the app's own files.
3. Add `@rapoport-studio/ui` as a workspace dependency.

```js
content: [
  "./src/**/*.{ts,tsx}",
  "../../packages/ui/src/**/*.{ts,tsx}",
],
```

Without the second path, Tailwind's JIT compiler won't see classes used inside the package's components, and the styles will silently fail to apply.

---

## Adding shadcn components

The package ships `packages/ui/components.json` (shadcn registry config). New primitives are scaffolded in-place via the CLI:

```sh
cd packages/ui && npx shadcn@latest add <component>
```

Files land in `packages/ui/src/components/<component>.tsx` with imports already wired to `@rapoport-studio/ui/lib/utils` and the rest of the package. Verify the new primitive picks up brand tokens (`--accent` / `--ring` etc.) before merging.

### `components.json` shape

| Field | Value | Why |
|---|---|---|
| `style` | `new-york` | Matches existing primitives — `data-slot` attributes, `shadow-xs`, `radix-ui` flat package |
| `rsc` | `true` | Apps are Next.js App Router |
| `tsx` | `true` | TypeScript-only repo |
| `tailwind.config` | `""` | Tailwind 4 — no JS config; `@theme inline` lives in `globals.css` |
| `tailwind.css` | `src/styles/globals.css` | Single Tailwind entry for the package |
| `tailwind.baseColor` | `neutral` | Brand palette is neutral-based |
| `aliases.components` | `@rapoport-studio/ui/components` | Matches package export `./components/*` |
| `aliases.utils` | `@rapoport-studio/ui/lib/utils` | Matches package export `./lib/*` |
| `aliases.ui` / `aliases.lib` / `aliases.hooks` | `@rapoport-studio/ui/*` | Same package convention |
| `iconLibrary` | `lucide` | Already a runtime dep |

---

## Dependencies

### Runtime

| Package | Version | Used by |
|---|---|---|
| `@radix-ui/react-*` | latest | Primitive components |
| `class-variance-authority` | `^0.7` | Variant prop typing |
| `clsx` | `^2` | Class name composition |
| `tailwind-merge` | `^2` | Tailwind class deduplication |
| `lucide-react` | latest | Icon library |
| `tailwindcss` | `^4` | Styling engine (peer-dep in apps) |
| `shiki` | latest | Syntax highlighting (lazy-loaded chunk) |
| `react-diff-viewer-continued` | latest | DiffView underlying lib |
| `nuqs` | `^2` | URL query-state management for `<OverlayHost>` |
| `@radix-ui/react-direction` | `^1` | `<DirectionProvider>` for RTL support in Sheet |

### Dev

| Package | Version | Used by |
|---|---|---|
| `@types/node` | `^20` | type checking |
| `@types/react` | `^19` | React type definitions |
| `react` | `^19` | peer dep for component types |
| `typescript` | `^5` | `tsc --noEmit` |
| `vitest` | `^4` | `__tests__/*.test.ts` |

### Internal

None. Leaf in the package graph.

---

## Boundaries

Enforced by `eslint.config.mjs`. UI is a leaf — may not import any `@rapoport-studio/*` package.

- UI **MAY NOT** import from `@rapoport-studio/{db,config,i18n,auth,forge,canvas,muse}` or any app
- UI **MAY** import from external packages (`react`, `@radix-ui/*`, `lucide-react`, `tailwind-merge`, `nuqs`, etc.)
- UI **MAY NOT** contain business logic (data fetching, route handlers, auth checks)
- `OverlayHost` accepts a `registry` prop (the module map) — the UI package itself stays generic; each consuming app injects its own registry, keeping overlay logic at the app boundary.

---

## Test coverage

Component-level tests focus on shape and basic render:

- Primitives render without crashing with default props
- Components forward `className` correctly via `cn()`
- ARIA attributes are present on accessible primitives
- Variant props produce expected class composition

No visual regression tests. No tests of brand-specific components (their consumption is uncertain).

---

## Extension

### Adding a new primitive

1. Create `src/components/<kebab-case-name>.tsx`.
2. Use `cn()` for class composition; accept `className` prop.
3. Mark `"use client"` only if interactive.
4. Re-export from the appropriate sub-barrel.
5. Add a render test in `__tests__/`.
6. Document the component in [`design-components.md`](design-components.md) — including `status`, `lives_in`, variant rationale, and a11y notes.

### Adding a new design token

1. Add the CSS custom property to `src/styles/globals.css` under `:root` and `.dark`.
2. Reference via Tailwind's `@theme inline` mapping if it should be reachable as a Tailwind utility.
3. Document in [`design-tokens.md`](design-tokens.md) — including `$type`, semantic role, and mode parity.
4. Avoid magic values — every colour, spacing, radius, typography ramp value should be a token, never an inline literal.

### Adding a new theme (R13)

If a future engagement needs a different brand skin (different palette, different motion language):

1. Add a `[data-theme="<name>"]` selector block in `globals.css` with the alternate token values.
2. Provide a way for the consuming app to set the theme attribute (typically a layout-level `<html data-theme="...">`).
3. No component code changes — they reference tokens by name, not value.

This is the path to brand re-skinning without component duplication. Per R13, themes do not overlap; each owns its tokens.

---

## Code surfaces — DiffView + InlineShiki (applied 2026-05-07/2026-05-08 from change `add-studio-code-display`)

Phase 1 (2026-05-07, RAP-127) shipped `<DiffView>` and the mono-font token. Phase 2 (2026-05-08, RAP-129) layers Shiki syntax highlighting through a lazy-loaded `<InlineShiki>` primitive, wired into `<Markdown>` via `lazy` + `Suspense`. Plain prose markdown renders zero Shiki bytes; the highlighter chunk loads only on first code-containing render.

| Component | Source | Purpose |
|---|---|---|
| `DiffView` | `packages/ui/src/components/diff-view.tsx` | Studio-themed diff viewer wrapping `react-diff-viewer-continued`. Mode-aware via `document.documentElement.classList.contains('dark')` read in `useEffect`; pre-mount fallback renders a `<pre>` so hydration never flashes empty. Token overrides reference only mode-aware vars. Border-radius `0` per studio hard rule. |
| `InlineShiki` | `packages/ui/src/syntax/inline-shiki.tsx` | Client-only Shiki rendering primitive. `MutationObserver` on `<html class>` for live mode-toggle re-renders + Shiki invocation on `code` / `lang` / `isDark` change. Pre-load fallback paints `<pre><code>` with `var(--font-mono)`. Lang allow-list falls unknown languages back to `text` instead of throwing. `dangerouslySetInnerHTML` is local-string-only (markdown source / diff cells), no XSS surface. |

Subpaths:

- `@rapoport-studio/ui/syntax/highlighter` — `getHighlighter(): Promise<Highlighter>` singleton + `resetHighlighterCache()` for tests.
- `@rapoport-studio/ui/syntax/inline-shiki` — `<InlineShiki code lang />` client component.

Theme JSON token mapping lives in [`design-tokens.md § Code surface tokens`](design-tokens.md).

`apps/studio/src/components/markdown.tsx` `components.code` override:

- Inline (no `language-*` className) renders unchanged with the mode-aware `--muted` background + `--color-grid` border + `--font-mono` family.
- Fenced (with `language-*`) routes through `<Suspense fallback={<pre>}><InlineShiki .../></Suspense>`. The `lazy()` boundary keeps Shiki out of the synchronous chat bundle until first code-containing render.

### MediaBlock — display primitive (applied 2026-05-11/2026-05-12 from change `add-mediablock-display-primitive`, RAP-373)

`<MediaBlock>` is the canonical display primitive for all displayable file content in `@rapoport-studio/ui`. Consumers must use `<MediaBlock>` rather than instantiating `<Markdown>`, `<InlineShiki>`, or `<DiffView>` directly.

| Component | Source | Purpose |
|---|---|---|
| `MediaBlock` | `packages/ui/src/components/media-block/media-block.tsx` | Top-level display primitive. Accepts `mimeType`, `body`, `title`, `defaultMode`, `density`. |
| `registry` | `packages/ui/src/components/media-block/registry.ts` | MIME-type renderer map. `registerMediaRenderer(mimeType, renderer)` registers a renderer; `getMediaRenderer(mimeType)` looks one up. Unknown MIME types fall through to Source view. |
| `renderers/markdown.tsx` | `packages/ui/src/components/media-block/renderers/markdown.tsx` | Internal `text/markdown` renderer — the only file permitted to import `@rapoport-studio/ui/components/markdown`. |
| `SourceView` | `packages/ui/src/components/media-block/source-view.tsx` | Universal fallback: raw text in mono font. Always available as the `source` mode. |

Density modes:

- `compact` — in-chat chrome: body only, no header title or footer. Used in canvas conversation turns.
- `full` — in-pane chrome: header with title + copy button, mode-switch rail, footer slot. Used in artifact panel and file preview pane.

Registry pattern: renderers are registered at app startup (or lazily on first use) via `registerMediaRenderer`. The markdown renderer is auto-registered in `packages/ui/src/components/media-block/index.ts`. Apps may extend the registry with additional MIME types.

Follow-up: `<DiffView>` integration as `text/x-diff` renderer is deferred. **Closed 2026-05-26 via RAP-1225** — diff is now a first-class `ViewMode` literal (gated on `baseContent` opt-in) wired through the existing `text/markdown` renderer's optional `Diff?` slot. See § MediaBlock diff mode amendment below.

Subpath: `@rapoport-studio/ui/components/media-block`.

---

## Forge primitives (applied 2026-05-07 from change `add-forge-ui-task-detail`)

Three primitives ship in `@rapoport-studio/ui` to support the Forge UI surface (`forge/entities.md § UI surface`, `studio-workspace/spec.md § Task page`). All three live alongside the existing Studio components and share the same hard-rules: mode-aware tokens only (no `*-on-dark` references), border-radius `0`, mono labels.

| Component | Source | Purpose |
|---|---|---|
| `PhasePipeline` | `packages/ui/src/components/phase-pipeline.tsx` | Vertical accordion, exactly four fixed phases (`plan` / `implement` / `review` / `pr`). Status pill top-right, optional meta line. Auto-expands the first `running` or `awaiting_input` phase on initial render. Radix `Collapsible` underneath; `aria-expanded` on triggers. |
| `LiveLogStream` | `packages/ui/src/components/live-log-stream.tsx` | SSE-driven monospace stream, in-memory buffer cap 500 entries with fade marker on drop, auto-scroll with pause-on-hover, jump-to-bottom button. Color prefix per level (success / info / warning / error). `"use client"`. |
| `FindingRow` | `packages/ui/src/components/finding-row.tsx` | Severity-coloured row primitive — severity dot + agent badge (mono pill) + file:line + body (one line truncated, expandable on click) + primary action + `[⋮]` overflow. CVA variants by severity. |

Severity tokens live in [`design-tokens.md § Severity tokens`](design-tokens.md).

Forge primitives **do not** invent new code, markdown, or diff rendering. Every code surface inside Forge UI goes through `<MediaBlock>` (Phase 5, RAP-383) or the `<DiffView>` primitive.

---

## Workspace sidebar wiring (applied 2026-05-10 from change `add-studio-sidebar`)

The `Sidebar` primitive is now actively wired into the studio shell, not just available. `apps/studio/src/components/studio-sidebar.tsx` consumes the primitive as-is — no changes to `packages/ui/src/components/sidebar.tsx`.

### Tokens (no new declarations)

`--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-accent`, `--sidebar-border`, `--sidebar-ring` were already declared for both modes in `packages/ui/src/styles/globals.css` (Tailwind 4 `@theme inline` mapping in the same file). The studio integration consumes them via Tailwind utilities (`bg-sidebar`, `text-sidebar-foreground`, etc.) and `data-[active=true]:before:bg-sidebar-primary` for the active-state left rule.

### Composition contract (consumer side)

Workspace pattern documented in `studio-workspace/spec.md § Workspace sidebar`:

```
<SidebarProvider defaultOpen={cookieValue}>
  <Sidebar collapsible="icon"> ... </Sidebar>
  <SidebarInset>{children}</SidebarInset>
</SidebarProvider>
```

The provider must wrap a flex row containing both `<Sidebar>` and `<SidebarInset>`. The studio chrome composes them as the only left surface (no sibling rail since `consolidate-studio-sidebar-with-brand`, RAP-197) — the previous `transform-gpu` containing-block workaround is no longer needed.

### Existing rendering invariant

`<SidebarInset>` renders `<main>` (the landmark). Consumer route layouts wrapped by `SidebarInset` must use `<div>` or `<section>`, not nested `<main>`. The workspace `(canvas)` and `(default)` layouts ship as `<div>` for this reason.

### Change source

Full archived design (token map, deviations log, visual regression strategy): `openspec/archive/2026-05-10-add-studio-sidebar/`. Linear: [RAP-194](https://linear.app/rapoport-studio-slr/issue/RAP-194). PR: #482.

---

## Workspace chrome consolidation (applied 2026-05-10 from change `consolidate-studio-sidebar-with-brand`)

Workspace chrome consolidates to a single labeled `<Sidebar>`; the parallel 56-pixel `ModeRail` and the `AppShell` shell from RAP-194 are removed. Workspace identity (a new shared `BrandMark` primitive) lives in `<SidebarHeader>`; account identity (`UserMenu`) lives in `<SidebarFooter>`. Marketing site (`apps/web`) picks up the same `BrandMark` for its nav, with `secondary="Studio"` to keep the public-facing Title-Case casing.

### `StudioLogo` primitive (supersedes `BrandMark`)

`packages/ui/src/components/studio-logo.tsx` — server-safe, five-variant four-axis API (`variant` × `size` × `tone` × `motion`). Supersedes `brand-mark.tsx` (deprecated; see `design-components.md § Component glossary`).

Sidebar header consumer switches variant + motion via `useSidebar().state`: expanded → `variant="wordmark" motion="morph"`, collapsed → `variant="glyph"`. All consumers pass `secondary="Studio"` (Title-Case universal — RAP-197 D-3 superseded by D-20/D-21 from `archive/2026-05-12-evolve-brand-mark-into-studio-logo/`). `aria-label` carries the full brand name in every variant including `mark`-only. No `asChild` polymorphism — consumers wrap `StudioLogo` in their own `<Link>` / `<a>`.

### Removed primitives

- `mode-rail.tsx` (`ModeRail`, `ModeRailItem`)
- `app-shell.tsx` (`AppShell`, `AppShellRail`, `AppShellMain`)
- `studio-rail.tsx` (`StudioRail` in `apps/studio`)

### Removed semantic studio tokens

- `--rail-bg` (was `var(--bg-1)` in dark; surviving consumers migrated to `bg-bg-1/30` directly)
- `--rail-active` (was `var(--primary)` in dark; only consumer was `ModeRailItem`)
- Tailwind 4 `@theme inline` mappings `--color-rail-bg` and `--color-rail-active`

`--accent-on-dark` and `--canvas-dark` are retained — both have unrelated consumers across canvas chrome, modals, KpiTile values, etc.

### Letter-glyph system note

The 56-pixel ModeRail letter-glyph application is gone; letter-glyphs survive as Tier-3 chip identifiers (`PersonaBadge` `[A]`, etc.) and as the `StudioLogo variant="glyph"` tile (formerly `BrandMark variant="compact"`). The `--glyph-size-lg` (12px) slot stays in the system for any future Tier-3 chip primitive that re-uses the larger glyph size.

### Supersession

This change explicitly supersedes deviations **D-1** (keep both ModeRail + StudioSidebar — "two surfaces, two jobs") and **D-6** (omit `<SidebarFooter>`) from `archive/2026-05-10-add-studio-sidebar/`. The earlier rationale stands as historical context; the canonical chrome from this date forward is the single-surface composition.

### Change source

Full archived design: `openspec/archive/2026-05-10-consolidate-studio-sidebar-with-brand/`. Linear: [RAP-197](https://linear.app/rapoport-studio-slr/issue/RAP-197). PR: #488.

---

## Known gaps

- **Token-name reconciliation between `globals.css` and `design-tokens.md`.** Two coexisting schemes (shadcn names + brand-canonical names). See [`design-tokens.md § Reconciled mapping`](design-tokens.md). Treat as a separate code change, tracked in `improve-design-system-token-coherence`.
- ~~**No `components.json`.**~~ Closed by RAP-212 (2026-05-10) — `packages/ui/components.json` ships with new-york style + `@rapoport-studio/ui/*` aliases; `npx shadcn@latest add` works in-place. See § Adding shadcn components.
- **`_slot.tsx` stub file.** Not imported anywhere; candidate for cleanup.
- **Test coverage of brand-specific components.** The legacy components in `packages/legacy-brand` have no dedicated render tests. They are not currently consumed; once they are, the consumer should add coverage.
- ~~**Missing shadcn primitives.**~~ Closed by RAP-205 (`Checkbox`, `Switch`, `Spinner`, `Alert` shipped 2026-05-10; `Popover` was already present in main, audit's `F8` was off-by-one). See `archive/2026-05-10-restore-missing-shadcn-primitives/`.
- ~~**Layout primitives missing.**~~ Closed by RAP-211 (2026-05-10) — `Container`, `Stack`, `Grid`, `Spacer` ship in `packages/ui`. See `design-components.md § Layout primitives`.

---

## Open questions

- ~~**Brand-specific components — keep, reskin, or remove?**~~ **Resolved 2026-05-10 (RAP-210):** moved to `@rapoport-studio/legacy-brand` (option c from `analysis/2026-05-10-component-reuse-audit.md § F5`). The components, the `--accent-*` token declarations, and the `about-period-flash` keyframe now live in `packages/legacy-brand/`; no current-brand consumer renders them today, and any future consumer must import `@rapoport-studio/legacy-brand/styles/legacy-tokens.css` to get the underlying tokens.
- **Storybook or component playground.** No visual surface today for browsing components in isolation. Useful when consumption grows.
- **Dark mode coverage testing.** `globals.css` declares dark-mode tokens but components are not systematically tested in dark mode.
- **Token versioning.** No mechanism today to version-bump tokens. With single-consumer use this isn't needed; if the package ever ships externally, semver discipline becomes load-bearing.
- **Animation library choice.** All animations are CSS keyframes today. If complex sequences land, Framer Motion or similar would be more ergonomic. No decision.

---

## Cross-references

- [`design-system.md`](design-system.md) — visual intent (philosophy, voice, surface mapping).
- [`design-tokens.md`](design-tokens.md) — token registry; W3C DTCG `$type`; OKLCH source-of-truth.
- [`design-motion.md`](design-motion.md) — durations, easing, motion modes, reduced motion.
- [`design-components.md`](design-components.md) — component anatomy, variants, a11y baseline.
- [`_methodology/design-system-convention.md § R10`](../_methodology/design-system-convention.md) — promotion criteria operationalised here.
- `web.md` — primary consumer (marketing site).
- `studio-workspace/README.md` — consumer (workspace UI).
- `platform.md` — overall context.
- `entities/README.md` — canonical entity registry; the EntityCard primitive reads from this catalog.

---

## Convention applied (applied 2026-05-10 from change `define-design-system-spec-convention`)

This file is the implementation-references slice of the new five-file design-system shape. Path:line anchors for every component listed in `design-components.md` (R3). Promotion criteria for `packages/ui` operationalised as four conditions (R10): ≥2 consumers, no surface-specific logic, tokens via CSS vars, a11y contract stable. Build wiring, package boundaries, and dependency surface preserved verbatim from the previous 523-line `ui.md`. Code-surfaces / Forge primitives / Workspace sidebar wiring applied-deltas migrated intact.

Migration source: `archive/2026-05-10-define-design-system-spec-convention/design.md § 10`. Visual intent moved out (now in `design-system.md`); token *values* moved out (now in `design-tokens.md`); component anatomy moved out (now in `design-components.md`).

---

## Overlay system (applied 2026-05-10 from change `add-url-state-overlay-system`)

Expanded public surface: `OverlayHost`, `defineDialog`, `useDialog`, `announce`, `LiveRegions`, `SonnerWrapper` ship from `@rapoport-studio/ui/overlay`. New runtime dependencies: `nuqs` (URL query-state for `<OverlayHost>`) and `@radix-ui/react-direction` (`<DirectionProvider>` for RTL Sheet sides). Boundary clarification: `OverlayHost` accepts `registry` as a prop — consuming apps inject their own module map; the UI package stays generic and brand-agnostic.

Full URL grammar, `DialogModule` contract, component taxonomy, and accessibility baseline: [`linkable-ui.md`](linkable-ui.md). Linear: RAP-306 (spec) + RAP-301/303/304/305 (implementation phases).

---

## useFormDraft — form draft primitive (applied 2026-05-14 from change `add-form-draft-primitive`, RAP-662)

`useFormDraft` is the canonical React hook for `localStorage`-backed form draft persistence in `@rapoport-studio/ui`. It ships alongside the pure storage helpers in `form-draft-storage.ts` and extends the `toast` object with a `draftRestore` method.

### API surface

```ts
const { clear } = useFormDraft({
  storageKey: string,          // D-FD-10: caller owns key composition
  form: UseFormReturn<TValues>,
  transformOnLoad?: (values: Partial<TValues>, ageMs: number) => Partial<TValues>, // D-FD-5
  schemaVersion?: number,      // default: 1 — D-FD-6
});

// On submit success:
clear();
```

**`{ clear }` only** — hook does not expose `savedAt` / `hasDraft` (D-FD-2 / Q-FD-2 resolved: deferred).

### Storage backend (D-FD-3)

`localStorage` only in v1. Pure helpers (framework-free, SSR-safe):

```ts
loadFormDraft<T>(key, opts?) → { values, savedAt, ageMs } | null
saveFormDraft<T>(key, values, opts?)
clearFormDraft(key)
```

Errors silently dropped (D-FD-9). `window === undefined` → all functions no-op without throwing.

### Toast helper (D-FD-2)

```ts
toast.draftRestore({ when, onRestore, onDiscard, labels?, locale? }): string | number
```

Duration: `Infinity`. Locale-aware date formatting: relative for ≤7 days via `Intl.RelativeTimeFormat`, absolute for older via `Intl.DateTimeFormat` (D-FD-7). Consumer override via `labels?` prop.

### i18n bundle (D-FD-4)

`formDraft` namespace in `packages/i18n/messages/<locale>/ui.json`:

```json
{
  "formDraft": {
    "restorePrompt": "Restore draft from {when}?",
    "restoreCta": "Restore",
    "discardCta": "Start over",
    "clearedNotice": "Draft cleared."
  }
}
```

### Boundary

Marketing-site forms (`apps/web`) are the intended consumer. Studio dialogs MUST NOT use `useFormDraft` — Rule 8 of `forms.md` forbids auto-saving partial drafts in the studio context. See `forms.md § Marketing-site form drafts`.

### Subpaths

- `@rapoport-studio/ui/hooks/use-form-draft` — the hook
- `@rapoport-studio/ui/lib/form-draft-storage` — pure storage helpers

### Source

Full design: `openspec/archive/2026-05-14-add-form-draft-primitive/design.md`. Linear: [RAP-662](https://linear.app/rapoport-studio-slr/issue/RAP-662). Phases: RAP-663 (P1) · RAP-664 (P2) · RAP-665 (P3) · RAP-666 (P4) · RAP-667 (P5) · RAP-668 (P6 — this archive).

---

## May 2026 home audit batch (RAP-744) — EntityCard contract amendment

### Card-variant `<h3>` wraps (HMA, archive `2026-05-14-fix-home-mobile-a11y`)

EntityCard card-variant titles wrap via `line-clamp-2` (or `break-words`); never `truncate`. The truncate pattern silently amputates longer-locale titles (RU/RO commonly 30–40% longer than EN) on narrow viewports — a WCAG 1.4.10 Reflow violation. Source: `packages/ui/src/components/entity-card.tsx`.

---

## DocumentShell subpath (applied 2026-05-15 from change `add-document-primitive`)

```ts
import { DocumentShell } from "@rapoport-studio/ui/components/document-shell"
import type { DocumentShellProps } from "@rapoport-studio/ui/components/document-shell"
```

### Subpath registration

Declared explicitly in `packages/ui/package.json` exports map:

```json
"./components/document-shell": "./src/components/document-shell/index.ts"
```

The folder-based export sits alongside other compound primitives (`./components/chat`, `./components/media-block`). Falls outside the catch-all `./components/*` pattern because the primitive lives in a folder, not a flat `.tsx` file.

### Companion: typography plugin

The shell depends on `@tailwindcss/typography` (loaded via Tailwind 4 `@plugin` directive in `@rapoport-studio/ui/globals.css`). The plugin sits in `@ui` dependencies, not `apps/web`, so every workspace consuming `@ui/globals.css` gets `.prose` / `.prose-document` styling without per-app dependency churn.

### Intent + props

See `design-components.md § DocumentShell`. The export is the same primitive — this section documents only the import surface.

### Source

`openspec/archive/2026-05-15-add-document-primitive/`. Linear: [RAP-780](https://linear.app/rapoport-studio-slr/issue/RAP-780).

---

## Lab MDX render pattern (applied 2026-05-15 from change `publish-operating-model-whitepaper`)

The lab detail route at `apps/web/src/app/[locale]/(marketing)/lab/[slug]/page.tsx` renders MDX bodies via `next-mdx-remote/rsc`:

```tsx
import { MDXRemote } from "next-mdx-remote/rsc"
// ...
<MDXRemote source={post.content} />
```

No custom component overrides; defaults flow through the parent's `prose prose-document` class. Per-kind render branches diverge above the MDX call:

- `kind: "whitepaper"` → `<DocumentShell>` wrapper from `@rapoport-studio/ui/components/document-shell`, with a `<WhitepaperFooter>` slot from `@/components/lab/whitepaper-footer`.
- `kind: "article"` → bare `<article>` wrapper with header (read-time + date + title + excerpt) above the prose body.

The schema-driven branch is enforced by zod default — `kind: z.enum(["article", "whitepaper"]).default("article")` in `apps/web/src/content/lab/_schema.ts`. Existing frontmatter without the field parses as `"article"` automatically.

### MDX gotchas

- Frontmatter values MUST be quoted in YAML — gray-matter parses unquoted ISO dates (`2026-05-15`) as JS `Date` objects, which the zod string schema rejects. Quote all string values: `publishedAt: "2026-05-15"`.
- Bare angle-bracket placeholders in prose (`<slug>`, `<capability>`) are parsed by MDX as JSX tags and break the parse. Wrap them in backticks or use a code fence: `` `<slug>` `` not `<slug>`.
- Heading anchor IDs require an inline `<span id="..."></span>` because `{#anchor}` syntax is parsed as a JSX expression. No `rehype-slug` plugin is wired today; full TOC compound API tracked at [RAP-799](https://linear.app/rapoport-studio-slr/issue/RAP-799).

### Source

`openspec/archive/2026-05-15-publish-operating-model-whitepaper/`. Linear: [RAP-809](https://linear.app/rapoport-studio-slr/issue/RAP-809).

## Chat primitive hardening (applied 2026-05-16 from change `harden-chat-primitive`)

Nine feature increments brought the `@rapoport-studio/ui` chat primitive
(`src/components/chat/`) to industry-standard send/feedback behaviour. The
contract additions below are stable surface; consumers (canvas chat today,
future Maestro / client surfaces) integrate against them.

### Message lifecycle status

`ChatMessage` carries an optional `status?: MessageStatus` field —
`"submitted" | "streaming" | "ready" | "error"`, the Vercel `useChat`
vocabulary. Optional for backwards compatibility: `undefined` renders as
`"ready"`. `<Message>` reflects it as a `data-message-status` attribute plus
`aria-busy` for `submitted`/`streaming`.

**Optimistic-render invariant (D-HCP-1).** A consumer integrating with the
primitive MUST append the user's message to its `messages` array
synchronously on submit, with `status: "submitted"` and a stable
client-generated `clientId`. Server confirmation replaces the row by
`clientId`; the textarea clears optimistically (it already did). Canvas
chat's `chatMachine` enforces this via an `OPTIMISTIC_APPEND`-equivalent
entry action on its `sending` state.

**Failed-send recovery (D-HCP-3).** On a delivery failure the failed row
stays in the transcript with `status: "error"`; `<Message onRetry>` renders
an inline "Couldn't send · Retry" affordance (2px left-edge rule + text
label, so the signal is never colour-only). `ChatPrimitive.onRetryMessage`
threads a `(clientId) => void` callback down to it. Retry flips the row back
to `submitted` in place. A deliberate abort, by contrast, removes the row.

### Content-block kinds

`ContentBlock` gained two default-rendered kinds:

- `thinking` — `{ persona? }`. A baseline indicator (`<ThinkingBlock>`):
  three persona-tinted shimmer bars shown for the whole in-flight window
  before the assistant produces text. D-HCP-4.
- `reasoning` — `{ summary, details, durationMs?, persona? }`. Claude
  Extended Thinking output (`<ReasoningBlock>`): a collapsible `<details>`,
  auto-expanded while streaming (`durationMs` undefined) and auto-collapsed
  once complete; a user toggle is sticky (Q-HCP-5). Contract-laying — no
  live producer is wired yet.

Both render through `MessageList`'s default renderer; canvas surfaces
inherit them because `canvasRenderBlock` delegates unknown kinds to
`defaultRender()`.

### Composer + list affordances

- **`ScrollToBottomPill`** — a floating "N new" pill at the bottom of
  `MessageList`, shown when the user has scrolled up (past the existing
  120px stick-to-bottom threshold) and new messages arrive. Clicking it
  smooth-scrolls to the latest message. D-HCP-5.
- **`SuggestionRow` + `ChatPrimitive.suggestions`** — `Suggestion[]`
  (`{ id, label, onClick }`) renders a `SmartPill` chip row above the
  composer. Replaces the ad-hoc single "Suggest a question" pill consumers
  previously wired into the `banner` slot. D-HCP-6.
- **`Composer.attachmentsSlot`** (forwarded as `ChatPrimitive.attachmentsSlot`)
  — an opaque `ReactNode` rendered between the textarea and the Send button.
  Contract-only: the composer owns layout, the consumer owns the affordance.
  No upload pipeline ships with the primitive.
- **`DateSeparator`** — `MessageList` renders a day-boundary divider
  ("Today" / "Yesterday" / weekday / absolute date) above the first message
  of each new local calendar day. Q-HCP-3.

### Accessibility conventions reaffirmed

`aria-live` regions are not used directly anywhere in the chat primitive —
live announcements route through the singleton `announce()` (the
`no-restricted-syntax` ESLint rule enforces this). On-demand counts (the
scroll pill's unread count) fold into the element's `aria-label` instead.

### Source

`openspec/archive/2026-05-16-harden-chat-primitive/`. Visual contract:
that change's `design/chat-states.html` (Round 1). Linear: epic to be filed.

---

## Chat primitive — chevron wiring + suggestion chips (applied 2026-05-21 from change `polish-chat-virtualization`)

The `@rapoport-studio/ui` chat primitive gains two polish affordances.
`MessageMeta.tsx` wires `←`/`→` sibling chevrons to the regenerate-message
action, so alternate generations of an assistant turn are navigable in
place. A `SuggestionChips.tsx` component renders a set of studio-specific
empty-state suggestion chips, gated by canvas mode. Message-list
virtualization behaviour (the 100-message threshold and the parent-owned
focus coordinator that keeps `j`/`k` navigation stable across row
recycling) is specified in `canvas.md § Chat message-list virtualization
contract`.

Source: `openspec/archive/2026-05-21-polish-chat-virtualization/`.

---

## Entity registry restructure + EntityCard is the only canonical entity renderer (applied 2026-05-23 from change `ratify-entities-atlas`)

The studio's `packages/ui/src/entities/registry.ts` is restructured to mirror Atlas D-ATLAS-6 — the 14 Tier-1 entity manifest at `openspec/brands/studio/entities/_index.md`. Three spheres, fourteen first-class entities, six in-context (Tier-2) renderers, plus infra and external classes.

### The 14 Tier-1 entities

| Sphere | Entities |
|---|---|
| Spec | `project`, `idea`, `spec`, `capability`, `change`, `inquiry` |
| Brand | `brand`, `mirror`, `archetype`, `surface` |
| Network | `engagement`, `lead`, `specialist`, `open-call` |

Each carries a brand-tree portrait at `openspec/brands/studio/entities/<id>.md` and renders through `<EntityCard>` in all five density variants (`mention / row / card / hovercard / detail`) per D-ATLAS-1.

### `<EntityCard>` is the only canonical entity renderer (D-ATLAS-7)

Ad-hoc entity tiles (`featured-work-tile.tsx`, project detail headers, pending-invitation rows, etc.) route through `<EntityCard>`'s slot system. New entity renderers must subclass via the variant prop or compose via slots — bespoke tiles are a regression. The `ast-no-local-component` CI guard scans `apps/` + `packages/` for module-level `function StatusPill(` or `const StatusPill =` outside the canonical source (`packages/ui/src/components/status-pill.tsx`) and fails on a shadow.

Domain-specific status renderers with their own taxonomy may live under different names (e.g. `ControlStatusPill`, `ForgeRunStatusPill`, `CoverageStatusPill`); only the `StatusPill` identifier is reserved.

### Canonical resolver and density grammar

- Status resolver: `packages/ui/src/lib/status-resolver.ts` — see `openspec/brands/studio/design-components.md § Status resolver`.
- Density grammar: five variants per `openspec/current/entity-densities.md` — see `openspec/brands/studio/design-components.md § EntityCard density grammar`.

Cross-ref: `openspec/archive/2026-05-23-ratify-entities-atlas/`. Manifest: `openspec/brands/studio/entities/_index.md`. Capabilities: `openspec/current/{entities,entity-densities,status-resolver}.md`.

---

## Marketing primitives — philosophy spine (applied 2026-05-24, RAP-1211)

Three new primitives under `packages/ui/src/marketing/`, exported via both `@rapoport-studio/ui` (barrel) and `@rapoport-studio/ui/marketing/*` (subpath).

| Primitive | Props | Notes |
|---|---|---|
| `<ManifestoBlock />` | `numeral: "I"|"II"|"III"|"IV"`, `title`, `body`, `citeHref?` | `<section aria-labelledby>` with `<h2>`; dropcap first-letter on first paragraph; numeral rendered `aria-hidden`; optional cite link |
| `<MirrorReferenceBlock />` | `variant: "inline"|"card"`, `href?`, `ctaLabel?` | Inline: link + bookmark icon. Card: `<aside>` with description + CTA button |
| `<SpecCorpusDiagram />` | `className?` | Static inline SVG, `role="img"`, `aria-label`; < 5 KB; labels all four corpus directories (`current/`, `changes/`, `archive/`, `_methodology/`) |

Tests: `packages/ui/__tests__/marketing/{manifesto-block,mirror-reference-block,spec-corpus-diagram}.test.tsx`.

---

## Conversion primitives (applied 2026-05-24 from add-per-service-pages-and-conversion)

### `<DecisionRouter />`

Source: `packages/ui/src/marketing/decision-router.tsx`. Subpath: `@rapoport-studio/ui/marketing/decision-router`.

Props:
```ts
interface DecisionRouterPrompt { slug: string; label: string; }
interface DecisionRouterProps {
  headline: string;
  prompts: DecisionRouterPrompt[];
  onSelect?: (slug: string) => void;
  className?: string;
}
```

Client component (`'use client'`). Mobile: `<details>` accordion with `<ChevronDown>`. Desktop: always-visible button group. `role="navigation"`, `aria-label={headline}`. Buttons carry `aria-pressed`. `history.replaceState` sets `#start=<slug>` on selection. Reads `location.hash` on mount to highlight the matching prompt.

### `<FormProgress />`

Source: `packages/ui/src/components/form-progress.tsx`. Export: `@rapoport-studio/ui` barrel.

Props:
```ts
interface FormProgressProps {
  current: number;   // 1-indexed current step
  total: number;
  labels?: string[];
  className?: string;
}
```

`role="progressbar"`, `aria-valuenow={current}`, `aria-valuemin={1}`, `aria-valuemax={total}`. Three-pill layout: completed (filled + accent/20 + Check icon), current (`aria-current="step"`, filled accent), upcoming (outlined). Used by multi-step `<IntakeForm />`.

### `<IntakeForm />` — multi-step mode

`packages/ui/src/components/intake-form/index.tsx` accepts two new props:

- `mode?: 'single' | 'embedded'` — `'single'` (default) enables the three-step wizard; `'embedded'` falls back to the flat single-page render for inline contexts.
- `defaultService?: IntakeServiceSlug` — pre-fills `engagement_interest` and other fields from the service slug.

Step groups: Step 1 (identity fields), Step 2 (engagement interest + project type), Step 3 (brief). `<FormProgress>` renders above the active step. Step state persists in `sessionStorage` under key `intake-form-step`. `form.trigger(stepFields)` validates each step before advancing. The POST payload to `/api/studio/leads` is unchanged.

### `<ServicePageTemplate />`

Source: `apps/web/src/components/shared/service-page-template.tsx`. Web-only (not exported from `@rapoport-studio/ui`).

Props: `serviceSlug`, `title`, `eyebrow`, `blurb`, `deliverables[]`, `indicativeRange`, `ctaLabel`, `ctaHref`, `readTheSpecHref?`, `jsonLd`. Server component. Emits Schema.org `Service` JSON-LD via `<script type="application/ld+json">`. Deliverables rendered in a 3-column grid via `<TacticalFrame>`.

Cross-ref: `openspec/archive/2026-05-24-add-per-service-pages-and-conversion/`. Linear: [RAP-1212](https://linear.app/rapoport-studio-slr/issue/RAP-1212).

---

## MediaBlock ModeSwitch consumers — right-rail artefact chrome (applied 2026-05-26 from change `redesign-spec-right-rail`)

The right rail's artefact chrome on `/spec/[id]` ships a **local, transitional** `<ModeSwitchStrip>` that mirrors `<MediaBlock>`'s ModeSwitch contract visually but does **not** consume the shared primitive — `apps/studio/src/app/(auth-walled)/(spec)/spec/[id]/components/change-rail/mode-switch-strip.tsx` is a ~50-line implementation that takes plain props (`activeTab`, `mode`, `onModeChange`) and manages per-tab mode in a local `Map<tab, mode>`, with the three buttons (`preview` / `source` / `diff`) hardcoded. It sits below `<ArtefactTabs>` and drives the active artefact's view mode for whichever of the four artefacts (`proposal.md`, `design.md`, `tasks.md`, `verification.md`) is selected. Consolidation onto the shared `<MediaBlock.ModeSwitch>` primitive is tracked as a follow-up (requires either wrapping the artefact panel in a `<MediaBlock>` provider, or extracting a context-free button primitive in `packages/ui`).

The third `diff` mode (added to `<MediaBlock>` per RAP-1225 Phase 1 — `ViewMode = "preview" | "source" | "diff"`, `MediaBlockProps.baseContent?`, optional `Diff?` slot on `MediaRenderer`) is wired in the rail's `<ModeSwitchStrip>` and activates conditionally on a non-null `baseContent` per the registry contract. The two original Phase 2-3 consumers — `<ArtifactDiffBlock>` (chat embed) and `<FilePreviewPane>` (spec drawer) — remain on their pre-MediaBlock implementations pending RAP-1225 Phases 2-3; both will migrate onto the same ModeSwitch primitive when those phases land, at which point the canonical consumer list becomes three (right-rail artefact chrome, `<ArtifactDiffBlock>`, `<FilePreviewPane>`).

Source: `openspec/archive/2026-05-26-redesign-spec-right-rail/`.

---

## MediaBlock diff mode + ModeSwitch third button (applied 2026-05-26 from change `bring-chat-artifact-embeds-under-mediablock`, RAP-1225)

Closes the deferred-decision marker on the original 2026-05-11/12 MediaBlock contract (§ MediaBlock — display primitive above) — diff is no longer a follow-up, it ships as a first-class `ViewMode`.

**Type surface (Phase 1, shipped via #1801):**

- `ViewMode = "preview" | "source" | "diff"` — the literal union on `MediaBlockContext` and `<ModeSwitch>` button identifiers.
- `MediaBlockProps.baseContent?: string` — the prior version of `data`. When set, the third mode is activated; absent it, `<MediaBlock>` and `<ModeSwitch>` behave identically to the pre-RAP-1225 two-mode surface (backward-compatible for the six existing consumers).
- `MediaBlockProps.defaultMode?: ViewMode` — initial uncontrolled state. Defaults to `"preview"`. Consumers that want to land users on a specific mode (e.g. chat artifact embeds landing on `"diff"`) pass it explicitly.
- `MediaRenderer.Diff?: React.FC<{ data: string; baseContent: string }>` — optional per-mime-type renderer slot symmetric with the required `Preview`. Per-mime-type opt-in: entries without `Diff` hide the third `<ModeSwitch>` button for that media type even if the consumer passed `baseContent`.

**Body wiring (Phase 2, shipped via #1829):**

- `<Body>` handles `mode === "diff"` via the registry's `Diff` slot, threading `baseContent` from `MediaBlockContext`. Conservative fallthrough to `Preview` when either the renderer lacks a `Diff` slot or the consumer didn't pass `baseContent` — mirrors the `<ModeSwitch>` button-visibility logic.
- `text/markdown` registry entry extended with a `Diff` slot wired to `<DiffView>`. Co-located with the `Preview` slot in `body.tsx` for chunk-splitting safety (same module reads + populates the registry).
- `<ArtifactDiffBlock>` (chat embed) rewritten as a `<MediaBlock>` consumer with `defaultMode="diff"` + `baseContent={oldContent}`. Header chrome preserved; direct `<DiffView>` import dropped.

**ModeSwitch contract (Phase 3, shipped via #1830):**

- `<ModeSwitch>` mode list is computed per-render from `baseContent` presence: `baseContent !== undefined` → `[preview, source, diff]`; otherwise the original `[preview, source]`.
- `<FilePreviewPane>` (spec drawer) migrated off bespoke `showDiff` useState onto unified `<MediaBlock.ModeSwitch>` + `<MediaBlock.Body>`. Body becomes unconditional `<MediaBlock.Body />`.

**Active ModeSwitch consumers (after Phase 3):**

1. Right-rail artefact chrome on `/spec/[id]` (`<ModeSwitchStrip>` from `redesign-spec-right-rail` Wave 2 — see the 2026-05-26 right-rail amendment above; consumes preview/source today, gains diff conditionally when the artefact carries a baseline).
2. `<ArtifactDiffBlock>` (`apps/studio/src/components/canvas/ArtifactDiffBlock.tsx`) — chat artifact embed; lands on `defaultMode="diff"`.
3. `<FilePreviewPane>` (`apps/studio/src/components/canvas/file-preview-pane.tsx`) — spec-drawer file preview pane; defaults to `preview`, third button activates conditionally on `file.baseContent`.

The "future joiners" caveat in the 2026-05-26 right-rail amendment block is now satisfied — `<ArtifactDiffBlock>` and `<FilePreviewPane>` are active consumers, not pending.

Source: `openspec/archive/2026-05-26-bring-chat-artifact-embeds-under-mediablock/`.
