# BUILD_SPEC — Console v2 Polish (Phases C, D, E)

**Generated:** 2026-05-13 (CDE-only continuation; A+B shipped at f14f3c41)
**Input:** `recoil/console-v2/BUILD_SPEC_POLISH.md` (phases C/D/E extracted)
**Detail level:** max
**Phases:** 3 (C, D, E)
**Predecessor commits:** `f14f3c41` (Phase B — Microdrama Scene Collapse)

---

## Dependency Graph

```
Phase C (Drop lineage minimap):           depends_on none
Phase D (Tabs → ChromeTop):               depends_on none (Phase A already shipped at f14f3c41)
Phase E (Lineage take-scoping + Strip):   depends_on C (needs clean canvas)
```

Phases C and D are independent and can run concurrently.
Phase E requires C.

Execution order: C → D (concurrently) → E (after C)

---

## CODEBASE STATE AT SPEC TIME

**Already shipped (do NOT re-implement):**
- Phase A: `use_breadcrumb.ts` — take suffix appended (`T002` format); `App.tsx` passes `selectedTakeId` to `useBreadcrumb`. Commit: precedes f14f3c41.
- Phase B: `HierarchyNavigator.tsx` — microdrama scene collapse (single synthesized scene → beats render at depth 2). `App.tsx` passes `showSynthesized` prop. Commit: `f14f3c41`.
- `VideoPlayer.tsx` — autoplay + muted + loop; keyboard shortcuts wired
- `Tweaks.showSynthesized` field in `manual.ts` (default `false`)
- TweaksPanel `Show inferred hierarchy` toggle
- `data-show-synthesized` attribute on `#app`; CSS rule hiding `.hn-synth-suffix`

**Test infrastructure:**
- Frontend tests: `recoil/console-v2/packages/desktop/tests/` (flat, no `__tests__/`)
- Run frontend tests: `cd recoil/console-v2 && pnpm --filter desktop test -- <file>`
- TypeScript: `pnpm --filter desktop typecheck`
- Phase E backend: `PYTHONPATH=/Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS pytest recoil/api/tests/test_lineage_*.py -v`

**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/LinageStage.tsx` (if exists — check)
- `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 C: Drop Lineage Minimap

depends_on: none

### Recommendation

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

### Rationale

The minimap's `.mm-viewport` span renders a semi-transparent accent-colored rectangle. On a small or sparse lineage, this rectangle fills most of the minimap and looks like a disconnected glowing cursor. The minimap doesn't support drag-to-navigate (deferred in comments). Most take-scoped lineages (post-Phase E) are 3–7 nodes that fit in the viewport — a minimap for non-scrolling content is clutter.

### Implementation

**File: `stage/lineage/LineageExplorer.tsx`** — delete the `LineageMinimap` import and the `<LineageMinimap ... />` JSX. Delete the `vpRect` state and the viewport tracking effect.

Specifically:
1. Find and remove the import line: `import { LineageMinimap } from "./LineageMinimap";` (or similar path)
2. Find and remove the `<LineageMinimap ... />` JSX block in the return
3. Find and remove `const [vpRect, setVpRect] = useState(...)` state
4. Find and remove the `useEffect` that tracks viewport rect for the minimap

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

**File: `stage/lineage/lineage.css`** — delete the minimap CSS rules. Remove rules for: `.lineage-minimap`, `.mm-label`, `.mm-track`, `.mm-dot`, `.mm-dot.focal`, `.mm-dot.failed`, `.mm-viewport`.

### Validation

```bash
cd recoil/console-v2 && pnpm --filter desktop typecheck
pnpm --filter desktop test
```

No new test file needed — the minimap had no dedicated test. TypeScript clean = pass.

### Risks

None. The minimap is passive (no state, no side effects outside these three files).

---

## Phase D: Tabs → ChromeTop

depends_on: none (Phase A breadcrumb-with-take already shipped)

### Recommendation

Merge the stage tabs into ChromeTop, eliminating the duplicate `stage-header` bar. Layout after merge: **breadcrumb (left) | tabs (center) | action buttons (right)** — one 32px bar instead of two stacked bars. Reclaims 32px of vertical real estate.

### Rationale

Today two identical-height bars stack:
1. **ChromeTop** (`shell.css`): breadcrumb + empty `.actions` div
2. **stage-header** (`shell.css`): breadcrumb *again* + tabs + action buttons

The breadcrumb in `stage-header`'s `.stage-title` renders the same `focusBreadcrumb` prop as ChromeTop. Pure redundancy. Collapse into one bar.

### Implementation

Read the actual current file contents before editing — line numbers in the original spec may have shifted due to Phase A/B edits.

**File: `shell/ChromeTop.tsx`** — expand Props interface to accept tabs, activeTab, onTabChange, and toolbarRight. Render `.chrome-tabs` between `.crumbs` and `.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`** — remove the entire `<div className="stage-header">...</div>` block. The `TEMPLATES` array and `toolbarRight` logic move to `App.tsx`. `ArtifactStage` becomes body-only. Read the current file first to find the exact stage-header block boundaries.

**File: `App.tsx`** — compute `toolbarRight` based on `ws.tabs.currentTemplate` and pass tabs + toolbarRight to ChromeTop. Find the existing `TEMPLATES` constant (likely in `ArtifactStage.tsx` — move it here or to a shared module):

```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 — update existing ChromeTop mount:
<ChromeTop
  breadcrumb={breadcrumb.label}
  tabs={TEMPLATES}
  activeTab={ws.tabs.currentTemplate}
  onTabChange={setStageTemplate}
  toolbarRight={toolbarRight}
/>
```

**File: `styles/shell.css`** — add `.chrome-tabs` styles:

```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.

### Validation

```bash
cd recoil/console-v2 && pnpm --filter desktop typecheck
pnpm --filter desktop test
```

Typecheck must be clean. No new test file for this phase — the tab switching is covered by existing integration tests if any; otherwise a basic render smoke test for ChromeTop is acceptable.

### Risks

- The `TEMPLATES` constant moves from `ArtifactStage` scope to `App` scope or a shared module — minor import reshuffling.
- The TakeInspector close button moves to ChromeTop's `.actions` div. Visual weight unchanged; Esc key is the primary close affordance.

---

## Phase E: Lineage Take-Scoping + LineageStrip

depends_on: C (minimap removal cleans the canvas code path)

### Sub-task E.1 — Take-Scoped Lineage (frontend default)

### Recommendation

When no take is selected, automatically scope lineage to the primary take instead of returning beat-level (all-takes) lineage.

### Rationale

The backend already supports take-scoped lineage. The adapter already passes `takeId`. The cache key already incorporates `selectedTakeId`. The gap: when `selectedTakeId` is null, the frontend passes `undefined` and the backend returns beat-rooted lineage (all takes as siblings). Fix by defaulting to the primary take.

### Implementation

**File: `App.tsx`** — read the file first to find the exact line numbers. Add `primaryTakeId` and `lineageTakeId` derivation near the existing lineage fetch effect:

```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;
```

In the lineage fetch `useEffect`, replace `selectedTakeId` references with `lineageTakeId`:
```ts
const tId = lineageTakeId ?? "_primary";
const cacheKey = `${focusedProjectId ?? ""}::${focused}::${tId}`;
// ...
adapter.getLineage(focused, focusedProjectId ?? undefined, lineageTakeId ?? undefined)
```

Also update `stageLineages` memo to use `lineageTakeId` instead of `selectedTakeId`.

**No backend change needed.** `lineage.py` already handles `take_id` correctly.

---

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

### Recommendation

Build `LineageStrip` — a vertical filmstrip component for the inspector split pane. Keep the Lineage tab's DAG canvas (`LineageExplorer`) as-is.

### 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);
  for (const n of nodes) {
    if (!visited.has(n.id)) ordered.push(n);
  }

  if (nodes.length === 0) {
    return (
      <div className="lineage-strip mono">
        <div className="ls-empty">No lineage data for this take.</div>
      </div>
    );
  }

  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>
  );
}
```

**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; }
.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;
}
```

**Integration into `ArtifactStage.tsx`** — read the file first to find the exact inspector split block. Replace the `LineageExplorer` mount in the TakeInspector split with `LineageStrip`:

```tsx
// Replace the combined mount that uses display:none toggling:
// OLD (mount-but-hide for LineageExplorer in inspector context):
{activeLineage && activeBeatId && (
  <LineageExplorer ... visible={template === "Lineage" || template === "TakeInspector"} />
)}

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

**New test file: `recoil/console-v2/packages/desktop/tests/LineageStrip.test.tsx`** — cover: empty lineage, single node, linear chain, branched chain (droppedCount > 0 hint).

### Validation

```bash
cd recoil/console-v2 && pnpm --filter desktop typecheck
pnpm --filter desktop test -- LineageStrip
PYTHONPATH=/Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS pytest recoil/api/tests/test_lineage_*.py -v
```

All must pass.

### Risks

- Strip's edge-walking assumes linear chains. For branching lineages, it only follows one branch — acceptable because Q3's take-scoping makes the selected take's ancestry linear by definition.
- The divider drag logic in `ArtifactStage.tsx` that drives `LineageExplorer` works with `LineageStrip` too — it just resizes the same flex slot.
- `LineageExplorer`'s mount-but-hide trick was for preserving drag/pan state. `LineageStrip` has no such state — mount/unmount is fine.

---

## Cross-Phase Verification

After all 3 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):
1. Lineage tab DAG — no minimap glowing rectangle
2. Top bar — tabs in chrome breadcrumb bar, no separate stage-header row below
3. Inspector lineage — vertical filmstrip (LineageStrip), not DAG
4. Lineage tab reflects selected take (or primary take if none selected)
5. Breadcrumb still shows `tartarus · EP001 · SH30 · T002` (Phase A, must not regress)
6. Microdrama EP beats still collapse under episode (Phase B, must not regress)

## BUILD COMPLETE marker

After all three phases pass and cross-phase verification is clean, write exactly:

```
BUILD COMPLETE
```

as the final line of `build-log-console-v2-polish-cde.md`.
