# BUILD_SPEC — Console v2 Polish

**Generated:** 2026-05-12 (from Opus R1 consult on console-v2-polish)
**Input:** `consultations/recoil/console-v2-polish-2026-05-12/opus_round_1.md`
**Detail level:** max
**Phases:** 5 (A, B, C, D, E)
**Predecessor commits:** `52044ab1` (video autoplay + synthesized toggle landed; the Lineage tab keep/kill question resolved as KEEP — DAG canvas stays for complex multi-branch cases)

---

## Dependency Graph

```
Phase A (Breadcrumb with take):       depends_on none
Phase B (Microdrama scene collapse):  depends_on none
Phase C (Drop lineage minimap):       depends_on none
Phase D (Tabs → ChromeTop):           depends_on A (needs breadcrumb-with-take)
Phase E (Lineage take-scoping + Strip): depends_on C (needs clean canvas), pairs Q3+Q4 from consult
```

Phases A, B, C are independent and parallelizable. Phase D requires A. Phase E requires C.

---

## CODEBASE STATE AT SPEC TIME (read before each phase)

**Already shipped earlier 2026-05-12 (do NOT re-implement):**
- `VideoPlayer.tsx` — autoplay + muted + loop on video takes; keyboard shortcuts (space, ←/→, f, k) work anywhere without click; `hasFocus` gate removed
- `Tweaks.showSynthesized` field added to `manual.ts` (default `false`)
- TweaksPanel `Show inferred hierarchy` toggle in Overlays section
- App root has `data-show-synthesized={"0"|"1"}` attribute sourced from `ws.tweaks.showSynthesized`
- CSS rule in `styles/shell.css`: `#app[data-show-synthesized="0"] .hn-synth-suffix { display: none; }`

**Locked decisions for this build:**
- Keep the Lineage tab (DAG canvas via `LineageExplorer`). LineageStrip is a NEW component for the inspector pane; the DAG tab is unchanged.
- The "legacy lineage-inspector" JT remembers is actually the workspace sidecar's collapsible LINEAGE section (3 metadata rows). LineageStrip mimics that aesthetic — vertical filmstrip, not a graph.
- Microdrama scene collapse: hide the synthetic scene level only when project_type is `microdrama` AND there's exactly one synthesized scene under the episode. Real (non-synthesized) scenes are always shown.
- Take suffix in breadcrumb: use the short form `T002` (extracted from take_id), not the full `EP001_SH30_T002`.

**Test infrastructure:**
- Frontend tests live at `recoil/console-v2/packages/desktop/tests/` (flat, no `__tests__/` subdirs). Existing files include `TakesBrowser.test.tsx`, `TakeInspector.test.tsx`, `HierarchyNavigator.test.tsx`, `LineageExplorer.test.tsx`.
- Run frontend tests: `cd recoil/console-v2 && pnpm --filter desktop test -- <file>`
- TypeScript: `pnpm --filter desktop typecheck`
- No backend tests need to run for this build (no API changes other than Phase E's lineage adapter)
- For Phase E only: `PYTHONPATH=/Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS pytest recoil/api/tests/test_lineage_*.py -v`

**New test files to create in this build:**
- Phase A: `recoil/console-v2/packages/desktop/tests/useBreadcrumb.test.ts` — exercises take-suffix logic (currently no test fixture uses non-null `selectedTakeId` in the breadcrumb context)
- Phase E: `recoil/console-v2/packages/desktop/tests/LineageStrip.test.tsx` — covers empty / single / linear / branched lineage cases

**File locations (verified at spec time):**
- `recoil/console-v2/packages/desktop/src/lib/use_breadcrumb.ts`
- `recoil/console-v2/packages/desktop/src/App.tsx`
- `recoil/console-v2/packages/desktop/src/nav/HierarchyNavigator.tsx`
- `recoil/console-v2/packages/desktop/src/shell/ChromeTop.tsx`
- `recoil/console-v2/packages/desktop/src/stage/ArtifactStage.tsx`
- `recoil/console-v2/packages/desktop/src/stage/lineage/LineageExplorer.tsx`
- `recoil/console-v2/packages/desktop/src/stage/lineage/LineageMinimap.tsx` (DELETE in Phase C)
- `recoil/console-v2/packages/desktop/src/stage/lineage/lineage.css`
- `recoil/console-v2/packages/desktop/src/styles/shell.css`
- `recoil/api/adapters/lineage.py` (Phase E only)

---
## Phase D: Tabs to ChromeTop

depends_on: A (breadcrumb-with-take must land first)


### Recommendation

Merge the tabs into ChromeTop. Eliminate stage-header entirely — the breadcrumb is already duplicated between the two bars, and ChromeTop has a whole empty `.actions` div waiting for content.

### Rationale

Today the UI stacks two identical-height (32px each) bars:
1. **ChromeTop** (`shell.css:245`): breadcrumb + empty `.actions` div = 64px of wasted vertical space
2. **stage-header** (`shell.css:606`): breadcrumb *again* + tabs + action buttons

The breadcrumb in stage-header's `.stage-title` renders the same `focusBreadcrumb` prop that ChromeTop gets (`App.tsx:663` and `App.tsx:716`). This is pure redundancy. With Q2 landing (take in breadcrumb), the TakeInspector's contextual path suffix (`/ EP001_SH02_T003 · inspector`) also becomes redundant — the breadcrumb itself carries the take, and the active tab tells you it's the inspector.

Layout after merge: **breadcrumb (left) | tabs (center) | action buttons (right)** — all in one 32px bar. This reclaims 32px of vertical stage real estate.

### Implementation

**File: `shell/ChromeTop.tsx`** — becomes the host for tabs + actions.

```tsx
interface Props {
  breadcrumb: string;
  tabs: { id: StageTemplate; label: string }[];
  activeTab: StageTemplate;
  onTabChange: (t: StageTemplate) => void;
  toolbarRight: React.ReactNode;
}

export function ChromeTop({ breadcrumb, tabs, activeTab, onTabChange, toolbarRight }: Props) {
  return (
    <div className="chrome-top">
      <div className="crumbs">
        <div className="crumb current">
          <span className="path">{breadcrumb}</span>
        </div>
      </div>
      <div className="chrome-tabs">
        {tabs.map((t) => (
          <button
            key={t.id}
            className={`btn ${activeTab === t.id ? "on" : ""}`}
            onClick={() => onTabChange(t.id)}
          >
            {t.label}
          </button>
        ))}
      </div>
      <div className="actions">
        {toolbarRight}
      </div>
    </div>
  );
}
```

**File: `stage/ArtifactStage.tsx`** — delete the entire `<div className="stage-header">...</div>` block (lines 346–371). The `TEMPLATES` array (line 92–98) and `toolbarRight` logic (lines 200–234, 361–369) move to App.tsx which passes them down to ChromeTop. ArtifactStage becomes body-only.

**File: `App.tsx`** — compute `toolbarRight` based on `ws.tabs.currentTemplate` and pass it + the TEMPLATES array to ChromeTop:

```tsx
const toolbarRight = useMemo(() => {
  if (ws.tabs.currentTemplate === "TakeInspector") {
    return (
      <>
        <span className="mono" style={{ color: "var(--fg-3)", fontSize: "10.5px" }}>
          ← / → step · ↑ / ↓ beat · Z zoom
        </span>
        <button className="btn ti-close" onClick={closeInspector} title="Close (Esc)">
          <span style={{ fontSize: "12px", lineHeight: 1 }}>×</span>
          <span>close</span>
          <span className="kbd">esc</span>
        </button>
      </>
    );
  }
  return (
    <>
      <button className="btn">filter</button>
      <button className="btn">sort: idx</button>
      <button className="btn primary">retry selected <span className="kbd">⌘R</span></button>
    </>
  );
}, [ws.tabs.currentTemplate, closeInspector]);

// In JSX:
<ChromeTop
  breadcrumb={breadcrumb.label}
  tabs={TEMPLATES}
  activeTab={ws.tabs.currentTemplate}
  onTabChange={setStageTemplate}
  toolbarRight={toolbarRight}
/>
```

**File: `styles/shell.css`** — add `.chrome-tabs` between `.crumbs` and `.actions`:

```css
.chrome-top .chrome-tabs {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 0 8px;
  border-left: 1px solid var(--border-2);
  border-right: 1px solid var(--border-2);
}
```

Move `.stage-toolbar .btn` styles into `.chrome-tabs .btn` (same rules). Delete `.stage-header`, `.stage-title`, `.stage-toolbar` CSS blocks (shell.css:606–670).

**File: `styles/shell.css`** — grid row update. Currently `grid-template-rows: 28px auto auto 1fr auto 24px`. The chrome-top remains `auto` (it's now 32px via its own height rule). No grid change needed. But the stage panel no longer has a header, so `.stage-body` needs top padding adjustment — change the existing `padding: 16px` to `padding: 16px` (no change — the stage-body already has its own padding).

### Risks / open questions

- The `TEMPLATES` constant moves from ArtifactStage scope to either App scope or a shared module. Minor import reshuffling.
- The TakeInspector close button and keyboard hints currently live in stage-header's `toolbarRight`. They move to ChromeTop's `actions` div. Visual weight is the same but the close button is now further from the inspector content. Acceptable — Esc key is the primary close affordance anyway.

---

## Phase A: Breadcrumb with Selected Take

depends_on: none


### Recommendation

Append ` · T002` to the breadcrumb when a take is selected. Use the take-index suffix (e.g. `T002`) extracted from the take id. No click handler on the take crumb.

### Rationale

`T002` is the most information-dense format. The beat is already in the breadcrumb (`EP001_SH30`), so repeating it (`EP001_SH30_T002`) wastes chrome pixels. `take 2` is more readable but takes 50% more horizontal space and doesn't match the ids used everywhere else in the UI. The convention in the codebase is `T{idx:03d}` — match it.

When no take is selected (beat focused, no inspector open), the breadcrumb stays at beat level: `tartarus · EP001 · SH30`. This is correct — the take is ephemeral selection state, not navigation state.

Clicking the take crumb would need a destination. The take is already selected and visible. Jump-to-inspector? That's what double-click on a take card does. Copy id? That's Cmd+C. Adding a click handler adds complexity for a feature that already exists elsewhere.

### Implementation

**File: `lib/use_breadcrumb.ts`** — add `selectedTakeId` parameter:

```ts
export function useBreadcrumb(
  focused: string | null,
  path: FocusPath | null,
  selectedTakeId?: string | null,
): BreadcrumbResult {
  return useMemo(() => {
    if (!focused) return { label: "no project selected", parts: [] };
    if (!path) return { label: focused, parts: [focused] };
    const { project, episode, scene, beat } = path;
    const parts: string[] = [project.name];
    if (episode) parts.push(episode.name);
    if (scene) parts.push(scene.name);
    if (beat) parts.push(beat.name);
    else if ((project.id as string) !== focused) parts.push(focused);
    if (selectedTakeId) {
      const suffix = selectedTakeId.includes("_T")
        ? selectedTakeId.slice(selectedTakeId.lastIndexOf("_T") + 1)
        : selectedTakeId;
      parts.push(suffix);
    }
    return { label: parts.join(" · "), parts };
  }, [focused, path, selectedTakeId]);
}
```

**File: `App.tsx:358`** — pass `selectedTakeId`:

```ts
const breadcrumb = useBreadcrumb(focused, focusPath, selectedTakeId);
```

Result: `tartarus · EP001 · SH30 · T002` when take selected, `tartarus · EP001 · SH30` when not.

### Risks / open questions

- The suffix extraction (`lastIndexOf("_T") + 1`) assumes take ids follow the `{beatId}_T{NNN}` convention. This is enforced by `_resolve_take_id` in `lineage.py:64-72` and the take model. Safe assumption.
- Scene names contain "(synthetic scene)" which adds visual noise. But that's already handled by the synthesized-suffix toggle (shipped). No new issue.

---

## Phase E: Lineage Take-Scoping + LineageStrip

depends_on: C (minimap removal cleans the canvas code)

### Sub-task E.1 - Take-Scoped Lineage


### Recommendation

The backend already supports take-scoped lineage. The only change needed is the **default behavior when no take is selected**: resolve to the beat's primary take instead of returning beat-level (all-takes) lineage.

### Rationale

The adapter already passes `takeId` as a query param (`http-adapter/src/adapter.ts:87`). The backend `get_lineage()` in `lineage.py:113-286` already handles `take_id` with `_walk_parent_chain()`. The cache key in `App.tsx:286` already incorporates `selectedTakeId`. The plumbing is done.

The gap: when `selectedTakeId` is null (beat focused, no take selected), the frontend passes `undefined` and the backend returns beat-rooted lineage (all takes as siblings). JT doesn't want this — beat-level lineage is confusing (all takes splayed as siblings from a single prompt node).

Fix: when no take is selected, automatically scope to the primary take. This happens frontend-side by defaulting to the primary take id from the `takes` array.

### Implementation

**File: `App.tsx`** — derive `effectiveTakeId` for lineage fetching (around line 278):

```ts
const primaryTakeId = useMemo(() => {
  const primary = takes.find((t) => t.primary);
  return primary ? (primary.id as string) : takes[0] ? (takes[0].id as string) : null;
}, [takes]);

const lineageTakeId = selectedTakeId ?? primaryTakeId;
```

Then in the lineage fetch effect (line 285-306), replace `selectedTakeId` references with `lineageTakeId`:

```ts
useEffect(() => {
  if (!focused) return;
  if (takes.length === 0) return;
  const tId = lineageTakeId ?? "_primary";
  const cacheKey = `${focusedProjectId ?? ""}::${focused}::${tId}`;
  if (cacheKey in lineages) return;
  let cancelled = false;
  adapter
    .getLineage(focused, focusedProjectId ?? undefined, lineageTakeId ?? undefined)
    .then((lin) => {
      if (cancelled) return;
      setLineages((prev) =>
        cacheKey in prev ? prev : { ...prev, [cacheKey]: lin },
      );
    })
    .catch(() => {
      if (cancelled) return;
      setLineages((prev) =>
        cacheKey in prev ? prev : { ...prev, [cacheKey]: null },
      );
    });
  return () => { cancelled = true; };
}, [focused, focusedProjectId, lineageTakeId, takes.length, lineages]);
```

Also update `stageLineages` memo (line 331-337) to use `lineageTakeId` instead of `selectedTakeId`.

**No backend change needed.** The `lineage.py` adapter already handles `take_id` correctly. When the frontend passes the primary take's id, the backend walks its parent chain and returns a focused graph.

### Risks / open questions

- When `takes` is empty (beat has no takes yet), `lineageTakeId` is null and the backend returns beat-rooted lineage. This is fine — if there are no takes, there's nothing to scope to.
- Cache invalidation: if a new take is generated while viewing, the lineage for the old primary is stale. This is pre-existing — the lineage cache is session-ephemeral and doesn't auto-refresh. Out of scope.

---


### Sub-task E.2 - LineageStrip Component


### Recommendation

Build a new `LineageStrip` component for the inspector split pane. Keep the Lineage tab's DAG canvas (`LineageExplorer`) as-is — it covers the complex multi-branch case.

### Rationale

**There is no legacy lineage inspector component.** I searched the workspace (8450), the legacy production console (8430), and the pre-production console (8420). None have a lineage visualization component. What JT remembers from the workspace is the **sidecar inspector's collapsible "LINEAGE" section** (`workspace/static/workspace.js:1722-1734`), which renders three key-value rows: `derived_from`, `method`, `parent_hash`. It's a simple vertical list of inspector metadata — exactly the aesthetic JT is describing ("like a strip in editorial").

The DAG canvas (`LineageExplorer`) is the right tool for complex lineages with multiple injection points, branching, and 18+ nodes (like the fixture `b5` lineage). But for the typical inspector case — viewing a single take's ancestry chain (prompt → keyframe step → keyframe output → video step → video output) — a DAG canvas with drag-to-pan, SVG bezier curves, and a minimap is heavy. A vertical filmstrip is the right shape.

### Implementation

**New file: `stage/lineage/LineageStrip.tsx`**

```tsx
import type { Lineage, LineageNode as LineageNodeType } from "../../data";
import { resolveMediaUrl } from "../../lib/media";

interface Props {
  lineage: Lineage;
  showScores: boolean;
  costOn: boolean;
}

export function LineageStrip({ lineage, showScores, costOn }: Props) {
  const nodes = lineage.nodes ?? [];
  const edges = lineage.edges ?? [];

  // Build ordered chain: walk edges from nodes with no incoming edge to leaves.
  const incoming = new Set(edges.map((e) => e.to));
  const outMap = new Map<string, string>();
  for (const e of edges) outMap.set(e.from, e.to);

  const roots = nodes.filter((n) => !incoming.has(n.id));
  const ordered: LineageNodeType[] = [];
  const visited = new Set<string>();

  function walk(id: string) {
    if (visited.has(id)) return;
    visited.add(id);
    const node = nodes.find((n) => n.id === id);
    if (node) ordered.push(node);
    const next = outMap.get(id);
    if (next) walk(next);
  }
  for (const r of roots) walk(r.id);
  // Add any unvisited nodes (disconnected) at the end
  for (const n of nodes) {
    if (!visited.has(n.id)) ordered.push(n);
  }

  // Edge case: empty lineage. Render a clean empty state, not a "0 nodes" header.
  if (nodes.length === 0) {
    return (
      <div className="lineage-strip mono">
        <div className="ls-empty">No lineage data for this take.</div>
      </div>
    );
  }

  // Edge case: branched lineage. The walk above produced one chain via `outMap`,
  // which silently overwrites on multi-child nodes. If we dropped any nodes,
  // surface a hint pointing the operator at the full DAG tab.
  const droppedCount = nodes.length - ordered.length;

  return (
    <div className="lineage-strip mono">
      <div className="ls-header">LINEAGE · {ordered.length} nodes</div>
      <div className="ls-list">
        {ordered.map((node, i) => (
          <div key={node.id} className="ls-item">
            {i > 0 && <div className="ls-connector" />}
            <div className={`ls-card ${node.failed ? "failed" : ""}`}>
              {node.mediaKind === "image" || node.mediaKind === "video" ? (
                <div className="ls-thumb">
                  {node.mediaKind === "image" && node.url && (
                    <img src={resolveMediaUrl(node.url)} alt={node.label ?? ""} />
                  )}
                  {node.mediaKind === "video" && node.url && (
                    <video src={resolveMediaUrl(node.url)} muted loop />
                  )}
                </div>
              ) : null}
              <div className="ls-meta">
                <span className="ls-kind">{node.kind}</span>
                <span className="ls-label">{node.label}</span>
                {node.sub && <span className="ls-sub">{node.sub}</span>}
                {costOn && node.cost != null && (
                  <span className="ls-cost">${node.cost.toFixed(3)}</span>
                )}
              </div>
            </div>
          </div>
        ))}
      </div>
      {droppedCount > 0 && (
        <div className="ls-truncated">
          {droppedCount} node{droppedCount === 1 ? "" : "s"} not shown — open the Lineage tab for the full graph.
        </div>
      )}
    </div>
  );
}
```

Also add CSS for the new states. Append to the `.lineage-strip` block in `lineage.css`:

```css
.lineage-strip .ls-empty {
  padding: 24px 16px;
  color: var(--fg-3);
  font-style: italic;
}
.lineage-strip .ls-truncated {
  padding: 6px 12px;
  color: var(--fg-3);
  border-top: 1px solid var(--border-2);
  font-size: 11px;
}
```

**DOM shape:**
```
div.lineage-strip
  div.ls-header "LINEAGE · 5 nodes"
  div.ls-list
    div.ls-item
      div.ls-card
        div.ls-thumb > img (if image/video node)
        div.ls-meta > kind + label + model + cost
    div.ls-item
      div.ls-connector (vertical line)
      div.ls-card ...
    ...
```

**CSS: `stage/lineage/lineage.css`** — append:

```css
.lineage-strip {
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow-y: auto;
  padding: 12px;
  background: var(--bg-0);
}
.ls-header {
  font-size: 9.5px;
  letter-spacing: 0.08em;
  color: var(--fg-3);
  text-transform: uppercase;
  margin-bottom: 8px;
}
.ls-list {
  display: flex;
  flex-direction: column;
  gap: 0;
}
.ls-connector {
  width: 1px;
  height: 12px;
  background: var(--border-3);
  margin: 0 auto;
}
.ls-card {
  border: 1px solid var(--border-2);
  border-radius: var(--r-sm);
  background: var(--bg-1);
  overflow: hidden;
}
.ls-card.failed {
  border-color: var(--fail);
}
.ls-thumb {
  width: 100%;
  max-height: 160px;
  overflow: hidden;
  background: var(--bg-2);
}
.ls-thumb img, .ls-thumb video {
  width: 100%;
  height: auto;
  display: block;
  object-fit: cover;
}
.ls-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 6px 8px;
  font-size: 10.5px;
  align-items: center;
}
.ls-kind {
  font-size: 9px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--fg-3);
  padding: 1px 4px;
  border: 1px solid var(--border-2);
  border-radius: 2px;
}
.ls-label { color: var(--fg-0); }
.ls-sub { color: var(--fg-2); font-size: 10px; }
.ls-cost { color: var(--fg-3); font-size: 10px; }
```

**Integration into ArtifactStage.tsx:** Replace the `LineageExplorer` mount in the TakeInspector split with `LineageStrip`:

```tsx
// In the split-inspector block (lines 390-418), replace:
{activeLineage && activeBeatId && (
  <LineageExplorer ... visible={template === "Lineage" || template === "TakeInspector"} />
)}

// With:
{activeLineage && activeBeatId && template === "TakeInspector" && (
  <LineageStrip lineage={activeLineage} showScores={showScores} costOn={costOn} />
)}
{activeLineage && activeBeatId && template === "Lineage" && (
  <LineageExplorer ... visible={true} />
)}
```

This way `LineageStrip` renders in the inspector split, and `LineageExplorer` renders on the standalone Lineage tab. No more mount-but-hide dance for `LineageExplorer` in the inspector context.

**Lineage tab (DAG canvas): keep it.** The fixture lineage has 18 nodes across 8 columns with branching (keyframe → video → audio → lipsync → composite). The strip handles linear chains; the DAG handles complex cases. The tab survives as an "advanced view."

### Risks / open questions

- The strip's edge-walking assumes linear chains (one outgoing edge per node). For branching lineages (multiple edges from one node), the walk would only follow one branch. This is acceptable — the strip shows the *ancestry of the selected take*, which is by definition a linear chain thanks to Q3's take-scoping.
- The divider drag logic in `ArtifactStage.tsx` (lines 148-190) currently drives the `LineageExplorer` pane. With `LineageStrip`, the same divider logic works — it just resizes a different component in the same flex slot.
- `LineageExplorer`'s mount-but-hide (`display: none`) trick was needed so drag/pan state survived template switches. With the strip, there's no drag/pan state to preserve — mounting/unmounting is fine.

---

## Phase B: Microdrama Scene Collapse

depends_on: none


### Recommendation

Collapse in the frontend `HierarchyNavigator.tsx`'s `EpisodeNode`. When a microdrama episode has exactly one scene with `synthesized === true`, skip the scene row and render beats directly at depth 2.

### Rationale

The collapse should happen at the rendering layer, not the API, for two reasons:
1. The API contract (`list_scenes`, `list_beats`) is consumed by other clients (MCP tools, future mobile). Changing the wire shape creates a special case that every consumer must handle.
2. The frontend already has `project.projectType` available (`generated.ts:106`). The decision is: "if microdrama AND single synthetic scene → flatten." This is a presentation concern.

Only collapse when the scene is synthesized (`synthesized === true`). If a microdrama has a real (non-synthesized) scene — because someone explicitly set `scene_id` in the shot file — keep it visible. The `synthesized` flag is the discriminator, not the count.

For >1 synthetic scenes: this can't happen today. `list_scenes()` in `beats.py:380-411` returns exactly ONE synthetic scene per episode (id: `<episode_id>__synthetic_scene_1`). If the engine later supports multiple scenes, they'd either be real scenes (non-synthesized, show them) or multiple synthetic scenes (show them — multiple synthetics means the model is doing something intentional).

### Implementation

**File: `nav/HierarchyNavigator.tsx` — `EpisodeNode`** (lines 311-412).

The change: after `scenesState` loads as `"ok"`, check if we should collapse. If so, auto-fetch beats for the single synthetic scene and render them at depth 2 (where scene rows normally go).

```tsx
function EpisodeNode({
  projectId,
  episode,
  expanded,
  focused,
  focusedProjectId,
  onSelect,
  onToggleExpand,
  showScores,
  adapter,
  projectType,       // NEW PROP — gates microdrama scene collapse
  showSynthesized,   // NEW PROP — when true, scene row stays visible
}: {
  // ...existing props...
  projectType?: string;
  showSynthesized: boolean;
}) {
  const eid = episode.id as string;
  const isOpen = expanded.has(eid);
  const [scenesState, retry] = useAsyncLoad<Scene[]>(
    isOpen,
    () => adapter.getScenes(projectId, eid),
    [projectId, eid],
  );

  // Microdrama collapse: if exactly 1 synthesized scene, skip it and
  // render beats directly under the episode. Respect the
  // `showSynthesized` tweak — when the user has toggled "Show inferred
  // hierarchy" ON in TweaksPanel, the scene row stays visible so the
  // synthesized status is observable.
  const shouldCollapse = projectType === "microdrama"
    && scenesState.kind === "ok"
    && scenesState.data.length === 1
    && scenesState.data[0]?.synthesized === true
    && !showSynthesized;

  const collapsedSceneId = shouldCollapse
    ? (scenesState.data[0]!.id as string)
    : null;

  const [collapsedBeats, retryBeats] = useAsyncLoad<Beat[]>(
    shouldCollapse && collapsedSceneId !== null,
    () => adapter.getBeats(projectId, eid, collapsedSceneId!),
    [projectId, eid, collapsedSceneId],
  );

  // ...existing rollup logic...

  return (
    <>
      <NavRow ... />
      {isOpen && scenesState.kind === "loading" && <LoadingRow depth={1} />}
      {isOpen && scenesState.kind === "error" && (
        <ErrorRow depth={1} message={...} onRetry={retry} />
      )}
      {/* Collapsed path: render beats at depth 2, skip scene row */}
      {isOpen && shouldCollapse && collapsedBeats.kind === "loading" && (
        <LoadingRow depth={2} />
      )}
      {isOpen && shouldCollapse && collapsedBeats.kind === "error" && (
        <ErrorRow depth={2} message={...} onRetry={retryBeats} />
      )}
      {isOpen && shouldCollapse && collapsedBeats.kind === "ok" &&
        collapsedBeats.data.map((b) => {
          const bs = beatKeeperState(b);
          const beatId = b.id as string;
          return (
            <NavRow
              key={beatId}
              id={`${projectId}::${beatId}`}
              depth={2}
              caret="·"
              status={b.status}
              label={b.name}
              sub={`${b.takes}t`}
              focused={focused === beatId && (focusedProjectId === null || focusedProjectId === projectId)}
              badges={<><ScoreBadge value={b.score} on={showScores} /><KeeperGlyph state={bs} /></>}
              onClick={() => { trackClick(projectId, beatId, "shot"); onSelect(beatId, projectId); }}
            />
          );
        })
      }
      {/* Normal path: render scene nodes */}
      {isOpen && !shouldCollapse && scenesState.kind === "ok" &&
        scenesState.data.map((sc) => (
          <SceneNode key={sc.id as string} ... />
        ))}
    </>
  );
}
```

**File: `nav/HierarchyNavigator.tsx` — `ProjectNode`** — thread `project.projectType` AND `showSynthesized` down to `EpisodeNode`. `showSynthesized` is a new prop on `HierarchyNavigator` itself (passed from `App.tsx` as `ws.tweaks.showSynthesized`):

```tsx
<EpisodeNode
  key={ep.id as string}
  projectId={pid}
  episode={ep}
  // ...existing props...
  projectType={project.projectType}
  showSynthesized={showSynthesized}
/>
```

**File: `nav/HierarchyNavigator.tsx` — top-level `Props`** — add `showSynthesized: boolean`. Thread through `ProjectNode` callsite to `EpisodeNode`.

**File: `App.tsx`** — at the `<HierarchyNavigator>` mount point (currently line ~678), add the prop:

```tsx
<HierarchyNavigator
  // ...existing props...
  showScores={ws.tweaks.showScores}
  showSynthesized={ws.tweaks.showSynthesized}
/>
```

### Risks / open questions

- The `useAsyncLoad` for `collapsedBeats` fires an extra fetch when the episode is first expanded. This is fine — it's the same fetch `SceneNode` would make, just triggered one level up.
- Beat depth is 2 (where scenes normally are) instead of 3 (where beats normally are under scenes). This is intentional — the collapsed layout skips a nesting level. The visual indent matches the hierarchy depth.
- If the `showSynthesized` tweak toggle is ON, should the scene row reappear? Arguably yes — the toggle exists to show synthesized nodes. Add a `showSynthesized` prop check: `shouldCollapse && !showSynthesized`.

---

## Phase C: Drop Lineage Minimap

depends_on: none


### Recommendation

Drop the minimap entirely from `LineageExplorer`. It adds confusion for little navigational value.

### Rationale

The "glowing pointer" is confirmed: `LineageMinimap`'s `.mm-viewport` span (`lineage.css:697-702`) renders a semi-transparent accent-colored rectangle with accent-colored left and right borders. On a small or sparse lineage, this rectangle fills most of the minimap and looks like a disconnected glowing cursor.

With Q4 landing, the inspector-side lineage becomes `LineageStrip` (no canvas, no viewport to minimap). The minimap only applies to the standalone Lineage tab's DAG canvas. But even there:
- The minimap doesn't support drag-to-navigate (per `LineageMinimap.tsx` line 7: "No drag-to-scroll yet (deferred)") — it's passive-only, which limits its utility.
- Most take-scoped lineages (post-Q3) are linear chains with 3-7 nodes — the entire graph fits in the viewport. A minimap for content that doesn't scroll is UI clutter.
- Complex lineages (18+ node fixture case) are the only case where the minimap helps, but that's the power-user scenario where keyboard navigation (arrows + R reset, already wired in `LineageExplorer.tsx:300-339`) is more natural.

Kill it outright. If a future complex lineage case justifies it, re-add it with drag-to-navigate at that point.

### Implementation

**File: `stage/lineage/LineageExplorer.tsx`** — delete the `LineageMinimap` import (line 42) and the `<LineageMinimap ... />` JSX (lines 420-426). Delete the `vpRect` state (line 126) and the viewport tracking effect (lines 163-177).

**File: `stage/lineage/LineageMinimap.tsx`** — delete the file.

**File: `stage/lineage/lineage.css`** — delete the `.lineage-minimap`, `.mm-label`, `.mm-track`, `.mm-dot`, `.mm-dot.focal`, `.mm-dot.failed`, `.mm-viewport` rules (lines 658-702).

### Risks / open questions

- None. The minimap is passive (no state, no side effects) and its removal is fully contained to these three files.

---

---

## Cross-Phase Verification

After all 5 phases land:

```bash
PYTHONPATH=$PWD pytest recoil/api/tests/test_lineage_*.py -v
cd recoil/console-v2 && pnpm --filter desktop typecheck
pnpm --filter desktop test
```

Manual UI checks (vite dev server up):
1. Hierarchy tree - no synthesized labels by default; Tweaks toggle reveals them
2. Tartarus EP001 - beats directly under EP001 (no intermediate scene)
3. Click a take - breadcrumb shows tartarus dot EP001_SH30 dot T002
4. Top bar - tabs in chrome breadcrumb, no separate stage-header
5. Inspector lineage - vertical strip, not DAG
6. Lineage tab - DAG works; no minimap glowing rectangle
7. Lineage tab DAG reflects selected take

## Known Risk

No legacy lineage inspector component exists. LineageStrip is from-scratch in the aesthetic of the workspace sidecar metadata rows.

