<!-- source: openspec/current/spec-preview.md -->

---
title: Spec preview
status: stable
owner: pavel
audience: [pavel, ai, engineer]
tldr: In-canvas projection of an in-flight openspec change. Snapshots `canvas_files` and renders affected `openspec/current/*.md` capabilities alongside drafted excerpts when `?view=preview`.
---

# Spec preview — capability spec

> **Owns.** The mechanism behind the `Preview applied change` button in the canvas change panel: snapshot of the drafted triplet → file-level scope detection → in-canvas render of the affected capability bodies alongside draft references. Visual surface is intentionally minimal; final polish lives in a future `redesign-spec-preview-mirror` change (D-SP-5).

> **Does not own.** Static spec exports (`forge render` ships those, separately), audience-aware narrative (deferred — D-SP-9), section-level diffs (deferred — D-SP-5), persistent preview state (D-SP-3 / D-SP-7).

## Why

Before this capability shipped, the canvas change panel had a single commit-style action — `Promote to /openspec/changes` — and no way to inspect *what the spec would look like* once that change was applied. The gap forced the author to either promote-and-revert (destructive) or imagine the final state from raw draft text.

Spec preview fills that gap with a soft, reversible alternative. The author hits `Preview applied change`, the URL flips to `?view=preview`, and the mirror panel renders a snapshot of the current openspec for every capability the draft proposal declares it touches — alongside the matching draft excerpts. Toggling back to EDIT drops the snapshot; no state is persisted.

## Surface

### Trigger

`Preview applied change` SmartPill in `apps/studio/src/app/(auth-walled)/(canvas)/spec/[id]/artifact-panel.tsx`, rendered above the existing `Promote to /openspec/changes` pill. Microcopy: *Snapshot draft → preview applied*. Both pills carry `tone="accent"` and full-width — soft (preview) above hard (promote).

- **Enabled** when at least one of `proposal.md` / `design.md` / `tasks.md` carries content (`canPreview = hasAnyDraft`).
- **Disabled** with a Radix tooltip *Draft a proposal first* when the triplet is empty.
- **Promoted canvases keep the button enabled** — the projection surfaces a dedicated `promoted` warning instead.

On click: `window.history.replaceState` with `?view=preview` (shallow History-API write, no RSC round-trip — matches the precedent set by `add-canvas-preview-mode` / RAP-1006). All other search params are preserved.

### URL state

`?view=preview` is the source of truth — single linkable-ui hard rule #9. Reload-safe, deep-link-safe. Absence of the param == edit mode. The EDIT|PREVIEW header-strip toggle shipped by `add-canvas-preview-mode` reads the same param.

## Data flow

```
[author in EDIT]
  ↓ drafts via Muse → canvas_files (path ∈ {proposal.md, design.md, tasks.md})

[click `Preview applied change`]
  ↓ enterPreview() → handleChangeView('preview') → ?view=preview

[CanvasMirrorPanel useEffect — `open` flips true]
  ↓ loadSpecPreviewAction(canvasId)

[server action, server-only]
  ↓ getServerSupabaseClient() → loadSpecPreview(canvasId, supabase)

[loadSpecPreview]
  – reads canvas_sessions.mode + canvas_files (3 paths)
  – passes drafts + mode to buildSpecPreviewProjection (pure)

[buildSpecPreviewProjection — pure]
  – parseProposalHeader(proposal) → { changeTitle, affectedCapabilitySpecs[] }
  – for each affectedCapabilityPath:
      findCapabilityInBundle → entry or warning(`capability-not-found`)
      buildExcerpt(entry.body) → currentExcerpt
      findDraftReferences(drafts, path) → draft refs (one per artefact, first match, ±1 line)
  – warning detection: no-draft / partial-draft / affects-undeclared / capability-not-found / promoted

[<SpecPreviewContent projection={…}>]
  – header: changeTitle ?? "PREVIEW · untitled draft" + "N affected"
  – warning banners (data-slot="warning-banner", data-kind=<kind>)
  – per-capability <details> with current excerpt + draft references
  – terracotta accent stripe (var(--color-accent)) on each affected capability

[toggle EDIT]
  ↓ shallow replaceState — ?view removed; snapshot dropped from RSC cache (D-SP-7)
```

## Data shapes

Source of truth: [`apps/studio/src/app/(auth-walled)/(canvas)/spec/[id]/spec-preview-types.ts`](/apps/studio/src/app/(auth-walled)/(canvas)/spec/[id]/spec-preview-types.ts).

- `SpecPreviewProjection` — top-level shape passed to the mirror component.
- `AffectedCapability` — one per declared `Affects capability spec` path that resolves in the bundle.
- `DraftReference` — `{ artefact, excerpt ≤500 chars, lineHint? 1-indexed }`.
- `PreviewWarning` — discriminated union of the five fallback kinds (see § Fallbacks).
- `OpenspecBundleEntry` — `{ path, headerFields, body }` shape of the build-time `openspec/current` snapshot.

## Affected scope detection

Parsing rule on `proposal.md` (`parseProposalHeader` in `loaders/spec-preview.ts`):

1. Skip leading blank lines.
2. Capture `# H1` as `changeTitle` (null if absent).
3. Skip blank lines, then collect the leading blockquote (consecutive `> ` lines).
4. Inside that blockquote, find the line matching `**Affects capability spec(\s*\(at archive\))?:**` (case-insensitive).
5. Extract inline-code-literals (backticked) from that line → candidate paths.
6. Strip optional `openspec/current/` prefix from each.
7. Result: `affectedCapabilitySpecs: string[]` (may be empty).

Empty result → `affects-undeclared` warning. Per-path bundle lookup → `capability-not-found` warning when a declared path doesn't exist on disk.

Section-level scope detection (e.g. *which paragraph of `mirror.md` is impacted*) is out of scope (D-SP-6); file-level is the contract.

## Fallbacks

Five `PreviewWarning` kinds, all rendered as banners by `<SpecPreviewContent>`:

| Kind | Trigger | Banner copy |
|---|---|---|
| `no-draft` | All three artefacts null | *Nothing to preview yet — start drafting in the chat.* |
| `partial-draft` | At least one but not all artefacts present; `missing: DraftArtefact[]` lists which | *Draft incomplete — preview reflects what's drafted so far ({missing} pending).* |
| `affects-undeclared` | `parseProposalHeader` returned `affectedCapabilitySpecs: []` | *Affected scope not declared — add `Affects capability spec` to your proposal header to see impact.* |
| `capability-not-found` | A declared path is absent from the bundle; one warning per missing path | *Referenced capability `{path}` not found in current openspec.* |
| `promoted` | `canvas_sessions.mode === 'legacy_promoted'`; short-circuits before any other detection | *Already promoted — see live change at `/openspec/changes/<slug>`.* |

## Known limitations

- **Bundle staleness.** The `openspec-current.json` consumed at runtime is built at deploy time (postinstall + `pnpm build` chain — `apps/studio/scripts/build-openspec-bundle.ts`). When `openspec/current/` changes on `main` without redeploy of `apps/studio`, preview reflects the deployed snapshot, not live `main`. Acceptable trade-off for D-SP-10 (build-bundle over live FS — Cloudflare Workers `fs` reach for monorepo files is brittle).
- **No auto-refresh on Muse emit.** Muse runs in both EDIT and PREVIEW with the same canvas context (chat session is NOT cloned across views). If the author is in preview and asks Muse to amend `design.md`, Muse emits a new `canvas_files` row, but the projection does NOT auto-update — the author has to toggle EDIT→PREVIEW (or reload the URL) to recompute. Documented behaviour, not a bug; tracked as Q-SP-1 follow-up territory.
- **First-match-per-artefact draft references.** `findDraftReferences` returns one reference per artefact (the first occurrence of the literal path). Multiple matches per artefact would aid future click-through (Q-SP-1).

## Relation to `forge render`

`forge render` is the **static-export** path — same conceptual transform (current + drafted state → rendered output), but it runs offline in the CLI and writes files. Spec preview is the **in-canvas live** path of the same transform: snapshot is in-memory per RSC request, never written. The two mechanisms coexist; this capability does not duplicate forge.

## Related capabilities

- [`linkable-ui`](./linkable-ui.md) — `?view=preview` is governed by linkable-ui hard rule #9 (URL = state of UI).
- [`studio-workspace`](./studio-workspace/spec.md) — the canvas spec page (`/spec/[id]`) is the host surface; the mirror panel rides on the layout shell shipped by `add-canvas-preview-mode`.
- [`muse`](./muse.md) — Muse emits the `canvas_files` triplet that spec preview snapshots; preview itself does not invoke Muse.

## Architectural decisions

| Tag | Decision |
|---|---|
| D-SP-1 | In-flight source for preview is `canvas_files` of *this* canvas, not the eventual `openspec/changes/<slug>/`. |
| D-SP-2 | Explicit user trigger (`Preview applied change` button); never automatic per turn. |
| D-SP-3 | Snapshot is in-memory, RSC-cache-bound; no new DB tables / persisted state. |
| D-SP-4 | Triplet applied whole — no per-file partial projection. |
| D-SP-5 | Minimal visual surface for MVP. Final polish → `redesign-spec-preview-mirror`. |
| D-SP-6 | Scope detection is file-level from the proposal header. Section-level diff deferred. |
| D-SP-7 | Toggle out of preview discards the snapshot; re-entering recomputes from scratch (no stale RSC cache). |
| D-SP-8 | New capability `spec-preview`; lives in `openspec/current/spec-preview.md` (this file). |
| D-SP-9 | Audience-aware render (engineer / client variants) is out of scope; one render for everyone. |
| D-SP-10 | Current openspec source for the projection is a build-time bundle (`apps/studio/src/generated/openspec-current.json`), not live filesystem read. |

## Provenance

Created 2026-05-20 from change [`add-spec-preview`](../archive/2026-05-20-add-spec-preview/) (RAP-1137). Builds on `add-canvas-preview-mode` (RAP-1006 — view-toggle + empty mirror panel) and `rename-spec-screen` (RAP-1018 — `/canvas/[id]` → `/spec/[id]` route).

## Visual surface (applied 2026-05-20 from change `redesign-spec-preview-mirror`, RAP-1142)

Applied from [`openspec/archive/2026-05-20-redesign-spec-preview-mirror/`](../archive/2026-05-20-redesign-spec-preview-mirror/) (RAP-1142).

### Header

`<SpecPreviewContent>` header reworked: `Eye` icon (`h-3.5 w-3.5`) left of the status chip (`PREVIEW · N AFFECTED`) + `h2` title (`text-base font-medium tracking-tight text-foreground`). Padding widened from `px-4 py-3` to `px-5 py-4`. The outer `h-12` strip belonging to `<CanvasMirrorPanel>` is unchanged. `Eye` icon also added as lead-icon in the `Preview applied change` SmartPill trigger (`h-4 w-4`, left of label text, `aria-hidden="true"`).

### Warning banners

Four semantic tones map onto the five `PreviewWarning` kinds (D-RD-2):

| Kind | Tone | CSS tokens |
|---|---|---|
| `no-draft`, `partial-draft` | Info (neutral) | `--rd-banner-info` / `--rd-banner-info-fg` |
| `affects-undeclared` | Attention (muted amber, D-RD-12) | `--rd-banner-attention` / `--rd-banner-attention-fg` |
| `capability-not-found` | Alert (red family) | `--rd-banner-alert` / `--rd-banner-alert-fg` |
| `promoted` | Success (muted green) | `--rd-banner-success` / `--rd-banner-success-fg` |

Tokens declared as oklch pairs in `apps/studio/src/app/globals.css` (`:root` + dark-mode override). Additive only — no existing tokens renamed or removed (D-RD-1).

Banner icons: `Info` for info-tone, `AlertTriangle` for attention-tone, `XCircle` for alert-tone, `CheckCircle2` for success-tone (all Lucide, `h-4 w-4`, `aria-hidden="true"`). Each banner wrapped in `<div role="alert">`.

### Capability cards

Each affected-capability `<details>` card:

- Shape: `rounded-md border bg-card border-l-2 border-l-[var(--color-accent)] shadow-xs hover:shadow-sm transition-shadow`.
- `--color-accent` (terracotta) is the **exclusive** "affected" marker across the entire surface; it is not used for any other element (D-RD-3).
- `<summary>` row carries `ChevronRight` (`h-3.5 w-3.5 text-muted-foreground`) that rotates 90° on `details[open]` via `group-open:rotate-90 transition-transform duration-[var(--duration-base)]`.
- First capability renders with `open` attribute on mount; subsequent capabilities collapsed (pinned contract from RAP-1137 Phase C, D-RD-10).

### Per-artefact draft-reference iconography

| Artefact | Icon | Rationale |
|---|---|---|
| `proposal` | `FileText` | Spec-document semantic |
| `design` | `Compass` | Navigational/design document; `Layers` reserved as canvas-entity glyph per `studio-workspace/spec.md` |
| `tasks` | `ListChecks` | Task-list semantic |

All decorative (`aria-hidden="true"`), Lucide, `h-3.5 w-3.5 text-muted-foreground`.

### Motion

All animation uses existing tokens — no new motion constants introduced (D-RD-5):

| Event | Tailwind classes | Tokens |
|---|---|---|
| Skeleton mount | `animate-in fade-in` | `--duration-base` |
| Content mount (skeleton → content) | `animate-in fade-in slide-in-from-bottom-1` | `--duration-base` / `--ease-standard` |
| Banner appear | `animate-in fade-in slide-in-from-top-0.5` + staggered `animation-delay` (50ms × index, capped 200ms) | `--duration-base` / `--ease-standard` |
| `<details>` expand/collapse | CSS `interpolate-size: allow-keywords` + `::details-content { transition: ... }` — instant-snap fallback in Safari/FF without support | `--duration-slow` / `--ease-standard` |
| Chevron rotation | `group-open:rotate-90 transition-transform` | `--duration-base` |

All `animate-in` / `transition` classes paired with `motion-reduce:transition-none motion-reduce:animate-none`.

### Mobile (`<md`, 768px)

`<aside data-slot="canvas-mirror">` acquires `max-md:fixed max-md:inset-0 max-md:z-50 max-md:w-full max-md:border-l-0` for a full-screen takeover below the `md` breakpoint. `<SpecPreviewContent>` header becomes `max-md:sticky max-md:top-12` (below the outer `h-12` strip) with `bg-canvas-dark/95 backdrop-blur-sm` for readability over scroll. Entry animation: `max-md:animate-in max-md:slide-in-from-bottom max-md:fade-in` (`--duration-slow`, D-RD-9). Exit via the existing EDIT|PREVIEW header-toggle — no additional close button (D-RD-6, D-RD-13).

### Architectural decisions added (D-RD-*)

| Tag | Decision |
|---|---|
| D-RD-1 | New tokens only for warning-semantic (`--rd-banner-*`); existing tokens unchanged. |
| D-RD-2 | Five warning kinds → four tones: info / attention / alert / success. |
| D-RD-3 | Terracotta `--color-accent` exclusively as the per-capability "affected" stripe; no other use. |
| D-RD-4 | Iconography — Lucide only; no new SVG assets. |
| D-RD-5 | Motion — existing `--duration-*` / `--ease-*` tokens only; no new constants. |
| D-RD-6 | Mobile full-screen takeover is in-component; no new Radix/shadcn primitive (Sheet/Drawer/Dialog). |
| D-RD-7 | `<details>` expand/collapse via CSS `interpolate-size`; graceful instant-snap fallback. |
| D-RD-8 | Scaffold PR — documentation-only, four files. |
| D-RD-9 | Mobile entry-animation — `slide-in-from-bottom` via `tw-animate-css`, unified motion vocabulary. |
| D-RD-10 | First `<details>` open on mount; expand/collapse animation applies only to user-driven toggle. |
| D-RD-11 | `Eye` icon — left (lead) of text in trigger SmartPill. |
| D-RD-12 | `--rd-banner-attention` — studio-muted amber; matches temperature of `--color-accent`. |
| D-RD-13 | `<sm` (<640px) receives no separate layout; `<md` full-screen takeover is sufficient. |
| D-RD-14 | Loader-error state stays `text-muted-foreground`; not promoted to `--rd-banner-alert`. |
| D-RD-15 | `Affected` chip in capability `<summary>` row retained (pinned by `/Affected/i` regex in test). |


## Four-artefact quartet + Build sub-section (applied 2026-05-24 from change complete-idea-to-forge-loop)

**RAP-1205 + RAP-1208 (Phases 3 + 6 of complete-idea-to-forge-loop).** The Change-block surface in the spec page widens from a three-tab triplet to a four-tab quartet and gains a live Build sub-section that reflects Forge run state.

**Tab order (Phase 3):** `proposal.md` → `design.md` → `tasks.md` → `verification.md`. The fourth tab carries the deterministic-acceptance contract Forge runs at the spec-acceptance checkpoint (#2.5) before opening the PR. Architect mode emits to all four via `update_proposal / update_design / update_tasks / update_verification` (the last added in Phase 3). The empty-state banner microcopy bumped from "All three artifacts required" → "All four artifacts required". `convertToProjectAction`'s pre-flight gate returns `{ error: 'missing_verification' }` when three artefacts are `accepted` but `verification.md` is missing or not accepted.

**Build sub-section (Phase 6b):** Below the four-tab content, renders a pill + relative-time + "View run →" link when `ideas.forge_state_url` is non-null. The pill role is resolved via `resolveForgeBuildRole(checkpoint, outcome, lastUpdatedAt)` from `@rapoport-studio/ui/lib/status-resolver` (Phase 6a foundation):

- `forge_last_checkpoint ∈ FORGE_CHECKPOINTS` (in-flight) → `progressing`
- `forge_outcome = completed-with-pr` → `live`
- `forge_outcome ∈ { failed-* }` → `error`
- `forge_outcome ∈ { cancelled, aborted-*, completed-no-pr }` → `embargo`
- `forge_last_checkpoint` non-null + stale > 10 min → `idle`
- both null → null (sub-section hidden)

The change-timeline rail status pill (previously visual-only per D-CCP-6 of `reframe-canvas-change-pipeline`) binds to the same `resolveForgeBuildRole` for ideas with non-null `forge_*` columns; ideas with both null fall through to the legacy `draft` placeholder. Single read path, two surfaces.

**Polling contract (Phase 6b):** `useForgeBuildState` client hook polls `pollBuildStateAction` on a 15s interval with four gating axes (per `complete-idea-to-forge-loop/design.md § 5.3`): `forgeStateUrl !== null`, `forgeOutcome === null`, `document.visibilityState === 'visible'`, `view === 'edit'`.

**`drafted → applied` transition (Phase 6b, D-IFL-15):** When the poll observes `forge_outcome = 'completed-with-pr'` AND the idea's `last_pr_url` is a merged PR, the same PATCH writes `ideas.status = 'applied'`.

Drift coverage: `packages/db/src/forge-build-state.ts` exports `FORGE_CHECKPOINTS` + `FORGE_BUILD_OUTCOMES`; the studio's `forge-resolver-drift.test.ts` asserts every value resolves to a non-null `VisualRole` (cross-package guard since `@rapoport-studio/ui` cannot import `@rapoport-studio/db` per package boundaries).

Source: `openspec/archive/2026-05-24-complete-idea-to-forge-loop/`.


## SpecBlock data sources (applied 2026-05-25 from change `lift-spec-block-and-ingest-linked-repo-openspec`)

**RAP-1223.** `SpecBlock` (`apps/studio/src/components/canvas/spec-block.tsx`) was split into an entry-router + two leaves so non-studio projects render their own corpus instead of the legacy "No spec available for this project" empty state:

| Project slug | Source | Component |
|---|---|---|
| `studio` (`STUDIO_PROJECT_SLUG`) | `OPENSPEC_BUNDLE` from `apps/studio/src/generated/openspec-bundle.ts` (build-time generated from `openspec/current/`) | `StudioBundleSpecBlock` — extraction of the pre-existing rendering path; no behaviour change |
| every other slug | `project_openspec_snapshots` rows for that project, populated by `syncProjectOpenspec(projectId)` (see `current/studio.md § OpenSpec ingest for client projects`) | `ProjectSnapshotSpecBlock` — `useState` / `useEffect` fetch via `fetchProjectSnapshot`, "Sync now" button gated by owner-role, last-synced status + last-error surfaced |
| no `projectSlug` / no `projectId` | n/a | `SpecEmptyState` — "No project bound — link a project to browse the spec." |

Shared layout primitive: `SpecFileBrowser` (`apps/studio/src/components/canvas/spec-file-browser.tsx`) — the two-column `file-list + selected-file MediaBlock` UI consumed by both leaves. Single rendering layer for both data sources.

The `STUDIO_PROJECT_SLUG` constant survives (D-SPEC-INGEST-4 from the source change). Future contributors should not "clean up" the constant — it is the discriminator between the build-time bundle and the runtime snapshot, not dead code.

Source: `openspec/archive/2026-05-25-lift-spec-block-and-ingest-linked-repo-openspec/`.

## Canonical proposal.md header — parser + warning kind (applied 2026-05-26 from change `teach-muse-proposal-header`)

The parser surface documented under `§ Affected scope detection` and `§ Fallbacks` is **extended** by this change. The original 7-step parsing rule and 5-row fallback table remain accurate for the pre-change state; the following deltas apply on top.

### Parser extensions

1. **YAML frontmatter skip (D-MPH-2).** Inserted between the initial blank-line skip and H1 capture: if the file's first non-blank line is exactly `---`, the parser advances past it and past every following line up to and including the next standalone `---`. Positionally constrained — only the leading `---` opens a frontmatter block. Mid-body `---` lines are Markdown thematic breaks (`<hr>`) and remain content. Resolves a silent regression where Storyteller-emitted proposals with YAML frontmatter dropped to `affects-undeclared` because the parser couldn't find the H1.

2. **Singular and plural label (D-MPH-1).** The affects-line regex is `**Affects capability specs?(\s*\(at archive\))?:**` — both `spec` and `specs` parse identically, with or without the `(at archive)` annotation. Existing 5+ archived + 2 active proposals using the plural form now resolve correctly without migration.

3. **`affectsDeclaration` classification (D-MPH-3).** `parseProposalHeader` now returns `{ changeTitle, affectedCapabilitySpecs, affectsDeclaration: 'paths' | 'none-directly' | 'absent' }`. Classification priority `paths > none-directly > absent`, **scope-local to the affects line** — body prose containing the phrase "none directly" or similar does NOT influence the result. An anti-pattern test in `loaders/spec-preview.test.ts` guards against any future regression to a global file scan.

### Fallback table extension

The `§ Fallbacks` table gains one row, and the existing `affects-undeclared` row's trigger description is sharpened:

| Kind | Trigger | Banner copy | Tone |
|---|---|---|---|
| `affects-undeclared` (revised) | `parseProposalHeader` returned `affectsDeclaration: 'absent'` (no affects line at all, OR present but empty / malformed) | *Affected scope not declared — add `Affects capability spec` to your proposal header to see impact.* | attention |
| `affects-declared-as-none` (new) | `parseProposalHeader` returned `affectsDeclaration: 'none-directly'` (affects line contains literal `none directly`, no backticked paths) | *This change deliberately declares zero capability impact — nothing to preview.* | info |

The tone distinction is load-bearing per **D-MPH-4**: same copy at the same tone would erase the only signal that capability impact was actually thought about. "Author forgot" stays alarming (attention); "deliberate zero" gets a calm info banner. A regression test in `spec-preview-content.test.tsx` asserts the two tones never collide.

### `none directly` sentinel + no-backticks rule

For a change that deliberately touches no `openspec/current/*.md` file (process / methodology / copy / packaging), authors write `> **Affects capability spec:** none directly — <one-clause reason>`. The reason after the em-dash is recommended for human readers but not parser-required. The literal `none directly` MUST NOT be wrapped in backticks — backticks make the parser treat it as a path and try to resolve it (`capability-not-found` warning).

Source: `openspec/archive/2026-05-26-teach-muse-proposal-header/` (PR-A #1800 — parser polish, PR-B #1804 — prompts + AGENTS.md). Decisions D-MPH-1..4 ratified.

## Idea + spec title surfaces (applied 2026-05-26 from change `add-title-to-ideas-and-specs`, RAP-1226)

The spec workspace header and the idea header (inside spec view) now read from the new `specs.title` / `ideas.title` columns with `slug` fallback (`title || slug`). UUID-based routing (`/spec/[id]`, idea anchors) is unchanged — title is display-only. Inline rename via the `<InlineEditTitle>` component is owner-gated and does NOT mutate `ideas.slug` or `ideas.github_branch`. Source: [`openspec/archive/2026-05-26-add-title-to-ideas-and-specs/`](../archive/2026-05-26-add-title-to-ideas-and-specs/).
