# Screen Test Tab Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add a "Screen Test" tab to the Pre-Production Console where the director reviews characters in their full wardrobe phases, with lock/hold/reject/re-roll and optional director's notes.

**Architecture:** New tab in Recoil's prepro-console (port 8420), calling Starsend API endpoints (port 8430) for generation and state management. Follows the exact patterns established by the existing Casting tab — IIFE module, `TabRegistry.register()`, `STARSEND_API` fetch calls. State persisted as JSON per project. Image generation via NBP with hero references.

**Tech Stack:** Python (Pydantic, google-genai), JavaScript (vanilla, IIFE module pattern), HTML/CSS (dark theme with CSS variables)

---

## Task 1: Screen Test State Module

**Files:**
- Create: `starsend/lib/screen_test.py`
- Test: `starsend/tests/lib/test_screen_test.py`

**Step 1: Write the failing test for state models**

```python
# tests/lib/test_screen_test.py
"""Tests for Screen Test state management."""
import json
import pytest
from pathlib import Path

from lib.screen_test import (
    PhaseVerdict,
    GenerationRecord,
    PhaseState,
    CharacterScreenTest,
    ScreenTestState,
    load_screen_test_state,
    save_screen_test_state,
    record_generation,
    apply_verdict,
)


class TestPhaseState:
    def test_default_status_is_empty(self):
        ps = PhaseState(phase_id="jinx_salvager")
        assert ps.status == "empty"
        assert ps.locked_image is None
        assert ps.held_images == []
        assert ps.generation_history == []
        assert ps.director_note is None

    def test_generation_record(self):
        rec = GenerationRecord(
            image="output/refs/characters/jinx/screen_test/phase_1_v1.png",
            prompt="test prompt",
            note=None,
            verdict="rejected",
        )
        assert rec.verdict == "rejected"
        assert rec.timestamp is not None


class TestCharacterScreenTest:
    def test_default_no_anchor(self):
        cst = CharacterScreenTest()
        assert cst.anchor_phase is None
        assert cst.phases == {}


class TestScreenTestState:
    def test_empty_state(self):
        state = ScreenTestState()
        assert state.characters == {}


class TestLoadSave:
    def test_save_and_load_roundtrip(self, tmp_path):
        state_dir = tmp_path / "state" / "starsend"
        state_dir.mkdir(parents=True)

        state = ScreenTestState(characters={
            "JINX": CharacterScreenTest(
                anchor_phase="jinx_salvager",
                phases={
                    "jinx_salvager": PhaseState(
                        phase_id="jinx_salvager",
                        status="locked",
                        locked_image="output/refs/characters/jinx/screen_test/phase_1_locked.png",
                    )
                },
            )
        })

        save_screen_test_state(tmp_path, state)
        loaded = load_screen_test_state(tmp_path)
        assert loaded.characters["JINX"].anchor_phase == "jinx_salvager"
        assert loaded.characters["JINX"].phases["jinx_salvager"].status == "locked"

    def test_load_missing_returns_empty(self, tmp_path):
        state = load_screen_test_state(tmp_path)
        assert state.characters == {}


class TestRecordGeneration:
    def test_record_appends_to_history(self):
        ps = PhaseState(phase_id="jinx_salvager")
        record_generation(ps, "path/img.png", "prompt text", "some note")
        assert len(ps.generation_history) == 1
        assert ps.generation_history[0].image == "path/img.png"
        assert ps.generation_history[0].note == "some note"
        assert ps.status == "generated"


class TestApplyVerdict:
    def test_lock_sets_locked_image(self):
        ps = PhaseState(phase_id="test", status="generated")
        ps.generation_history.append(
            GenerationRecord(image="img.png", prompt="p", verdict="pending")
        )
        apply_verdict(ps, "lock")
        assert ps.status == "locked"
        assert ps.locked_image == "img.png"
        assert ps.generation_history[-1].verdict == "locked"

    def test_hold_adds_to_held(self):
        ps = PhaseState(phase_id="test", status="generated")
        ps.generation_history.append(
            GenerationRecord(image="img.png", prompt="p", verdict="pending")
        )
        apply_verdict(ps, "hold")
        assert ps.status == "held"
        assert "img.png" in ps.held_images

    def test_reject_marks_rejected(self):
        ps = PhaseState(phase_id="test", status="generated")
        ps.generation_history.append(
            GenerationRecord(image="img.png", prompt="p", verdict="pending")
        )
        apply_verdict(ps, "reject")
        assert ps.status == "rejected"
        assert ps.generation_history[-1].verdict == "rejected"
```

**Step 2: Run test to verify it fails**

Run: `cd ~/Dropbox/CLAUDE_PROJECTS/starsend && python -m pytest tests/lib/test_screen_test.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'lib.screen_test'`

**Step 3: Write the implementation**

```python
# lib/screen_test.py
"""Screen Test state management.

Manages per-character, per-wardrobe-phase approval state for the
Screen Test tab in the Pre-Production Console.

State file: projects/{project}/state/starsend/screen_test_state.json
"""
from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from pydantic import BaseModel, Field


class GenerationRecord(BaseModel):
    """One generation attempt for a wardrobe phase."""
    image: str
    prompt: str
    note: Optional[str] = None
    timestamp: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
    verdict: str = "pending"  # pending | locked | held | rejected


class PhaseState(BaseModel):
    """State for a single wardrobe phase of a character."""
    phase_id: str
    status: str = "empty"  # empty | generating | generated | held | locked | rejected
    locked_image: Optional[str] = None
    director_note: Optional[str] = None
    enriched_prompt: Optional[str] = None
    held_images: list[str] = Field(default_factory=list)
    generation_history: list[GenerationRecord] = Field(default_factory=list)


class CharacterScreenTest(BaseModel):
    """Screen test state for one character across all phases."""
    anchor_phase: Optional[str] = None
    phases: dict[str, PhaseState] = Field(default_factory=dict)


class ScreenTestState(BaseModel):
    """Top-level screen test state for a project."""
    characters: dict[str, CharacterScreenTest] = Field(default_factory=dict)


def _state_path(project_dir: Path) -> Path:
    return project_dir / "state" / "starsend" / "screen_test_state.json"


def load_screen_test_state(project_dir: Path) -> ScreenTestState:
    """Load screen test state from disk. Returns empty state if file missing."""
    path = _state_path(project_dir)
    if path.is_file():
        data = json.loads(path.read_text(encoding="utf-8"))
        return ScreenTestState.model_validate(data)
    return ScreenTestState()


def save_screen_test_state(project_dir: Path, state: ScreenTestState) -> None:
    """Save screen test state to disk."""
    path = _state_path(project_dir)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(state.model_dump(), indent=2),
        encoding="utf-8",
    )


def record_generation(phase: PhaseState, image_path: str, prompt: str, note: Optional[str] = None) -> None:
    """Record a generation attempt and update phase status."""
    phase.generation_history.append(
        GenerationRecord(image=image_path, prompt=prompt, note=note)
    )
    phase.status = "generated"


def apply_verdict(phase: PhaseState, action: str) -> None:
    """Apply lock/hold/reject to the most recent generation."""
    if not phase.generation_history:
        return
    latest = phase.generation_history[-1]

    if action == "lock":
        phase.status = "locked"
        phase.locked_image = latest.image
        latest.verdict = "locked"
    elif action == "hold":
        phase.status = "held"
        if latest.image not in phase.held_images:
            phase.held_images.append(latest.image)
        latest.verdict = "held"
    elif action == "reject":
        phase.status = "rejected"
        latest.verdict = "rejected"
```

**Step 4: Run test to verify it passes**

Run: `cd ~/Dropbox/CLAUDE_PROJECTS/starsend && python -m pytest tests/lib/test_screen_test.py -v`
Expected: All 9 tests PASS

**Step 5: Commit**

```bash
cd ~/Dropbox/CLAUDE_PROJECTS/starsend
git add lib/screen_test.py tests/lib/test_screen_test.py
git commit -m "feat: add Screen Test state models and persistence"
```

---

## Task 2: Phase Image Generation

**Files:**
- Create: `starsend/tools/screen_test_gen.py`
- Test: `starsend/tests/tools/test_screen_test_gen.py`

**Context:** Uses the same NBP generation pattern as `tools/prep_character_angles.py`. Hero + three-quarter images are passed as reference parts. Director's note enrichment uses Flash for cheap text translation.

**Step 1: Write the failing test**

```python
# tests/tools/test_screen_test_gen.py
"""Tests for Screen Test image generation (API calls mocked)."""
import pytest
from pathlib import Path
from unittest.mock import patch, MagicMock

from tools.screen_test_gen import (
    build_phase_prompt,
    enrich_director_note,
    generate_phase_image,
)


class TestBuildPhasePrompt:
    def test_basic_prompt(self):
        phase = {
            "phase_id": "jinx_salvager",
            "wardrobe_description": "cargo pants, canvas jacket",
            "hair_makeup": "grime-streaked face",
            "distinguishing_marks": "debt counter on wrist",
        }
        char = {
            "visual_description": "Late 20s, lean and wiry",
            "display_name": "Jinx",
        }
        prompt = build_phase_prompt(char, phase)
        assert "cargo pants" in prompt
        assert "grime-streaked" in prompt
        assert "debt counter" in prompt
        assert "Late 20s" in prompt

    def test_with_director_note(self):
        phase = {
            "phase_id": "test",
            "wardrobe_description": "basic outfit",
            "hair_makeup": "clean",
            "distinguishing_marks": "",
        }
        char = {"visual_description": "test char", "display_name": "Test"}
        prompt = build_phase_prompt(char, phase, enriched_note="darker, grittier")
        assert "darker, grittier" in prompt


class TestEnrichDirectorNote:
    @patch("tools.screen_test_gen._get_text_client")
    def test_enrichment(self, mock_client):
        mock_response = MagicMock()
        mock_response.text = "Enhanced wardrobe: battle-scarred cargo pants with darker tones"
        mock_client.return_value.models.generate_content.return_value = mock_response

        result = enrich_director_note(
            base_description="cargo pants, canvas jacket",
            director_note="more battle-worn, darker",
        )
        assert "battle-scarred" in result or "darker" in result
        mock_client.return_value.models.generate_content.assert_called_once()


class TestGeneratePhaseImage:
    @patch("tools.screen_test_gen._get_image_client")
    def test_generates_image(self, mock_client, tmp_path):
        # Mock the API response with fake image bytes
        mock_part = MagicMock()
        mock_part.inline_data = MagicMock()
        mock_part.inline_data.data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
        mock_candidate = MagicMock()
        mock_candidate.content.parts = [mock_part]
        mock_response = MagicMock()
        mock_response.candidates = [mock_candidate]
        mock_client.return_value.models.generate_content.return_value = mock_response

        hero_path = tmp_path / "hero.png"
        hero_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 50)
        output_path = tmp_path / "output.png"

        result = generate_phase_image(
            hero_path=hero_path,
            three_quarter_path=None,
            prompt="test prompt",
            output_path=output_path,
        )
        assert result is True
        assert output_path.exists()
```

**Step 2: Run test to verify it fails**

Run: `cd ~/Dropbox/CLAUDE_PROJECTS/starsend && python -m pytest tests/tools/test_screen_test_gen.py -v`
Expected: FAIL with `ModuleNotFoundError`

**Step 3: Write the implementation**

```python
# tools/screen_test_gen.py
"""Screen Test image generation.

Generates character images for each wardrobe phase using NBP (Gemini 3 Pro Image)
with the cast hero as reference. Supports director's note enrichment via Flash.
"""
from __future__ import annotations

import logging
from pathlib import Path
from typing import Optional

from google.genai import types as genai_types

logger = logging.getLogger(__name__)


def _get_image_client():
    """Get the raw genai client for image generation."""
    from lib.api_client import get_client
    client = get_client("gemini-3-pro-image-preview")
    return client._get_client()


def _get_text_client():
    """Get the raw genai client for text enrichment (Flash)."""
    from google import genai
    return genai.Client()


def build_phase_prompt(
    char: dict,
    phase: dict,
    enriched_note: Optional[str] = None,
) -> str:
    """Build the generation prompt for a wardrobe phase image.

    Args:
        char: Character dict from global bible (visual_description, display_name).
        phase: Phase dict from global bible (wardrobe_description, hair_makeup, distinguishing_marks).
        enriched_note: Optional enriched director's note to incorporate.
    """
    parts = [
        "Generate a single 9:16 portrait photograph of this character.",
        "",
        f"CHARACTER: {char.get('display_name', 'Unknown')}",
        f"PHYSICALITY: {char.get('visual_description', '')}",
        "",
        "WARDROBE & LOOK:",
        f"  Wardrobe: {phase.get('wardrobe_description', '')}",
        f"  Hair & Makeup: {phase.get('hair_makeup', '')}",
        f"  Distinguishing Marks: {phase.get('distinguishing_marks', '')}",
    ]

    if enriched_note:
        parts.extend(["", f"DIRECTOR'S NOTE: {enriched_note}"])

    parts.extend([
        "",
        "PHOTOGRAPHIC ANCHORS:",
        "- Medium: 35mm motion picture film still, shot on Arri Alexa 65, 85mm f/2.8 lens.",
        "- Lighting: Soft, directional studio lighting. 18% neutral gray seamless backdrop.",
        "- Frame as medium shot (waist up), facing camera in relaxed pose.",
        "- Must be PHOTOGRAPHIC. No illustration, no 3D render, no concept art.",
        "",
        "CRITICAL: The character must EXACTLY match the reference image(s) provided.",
        "Same face, same build, same skin tone. Only the wardrobe/hair/makeup changes.",
    ])

    return "\n".join(parts)


def enrich_director_note(
    base_description: str,
    director_note: str,
) -> str:
    """Translate a director's natural-language note into prompt modifications.

    Uses Flash for speed and cost ($0.01). Takes the base phase description
    and the director's note, returns an enriched description optimized for
    image generation.
    """
    client = _get_text_client()

    prompt = f"""You are a costume designer translating a film director's notes into visual descriptions for AI image generation.

BASE WARDROBE DESCRIPTION: {base_description}

DIRECTOR'S NOTE: "{director_note}"

Rewrite the wardrobe description incorporating the director's notes. Be specific and visual.
Keep it under 3 sentences. Output ONLY the revised description, nothing else."""

    response = client.models.generate_content(
        model="gemini-2.5-flash-preview-05-20",
        contents=prompt,
    )
    return response.text.strip()


def generate_phase_image(
    hero_path: Path,
    three_quarter_path: Optional[Path],
    prompt: str,
    output_path: Path,
    anchor_path: Optional[Path] = None,
) -> bool:
    """Generate a single wardrobe phase image via NBP.

    Args:
        hero_path: Path to the cast hero image (primary reference).
        three_quarter_path: Optional path to three-quarter angle reference.
        prompt: The full generation prompt (from build_phase_prompt).
        output_path: Where to save the generated image.
        anchor_path: Optional locked phase image to use as style anchor.

    Returns:
        True if generation succeeded, False otherwise.
    """
    client = _get_image_client()

    # Build content parts: reference images first, then prompt
    contents = []

    hero_bytes = hero_path.read_bytes()
    contents.append(genai_types.Part.from_bytes(data=hero_bytes, mime_type="image/png"))

    if three_quarter_path and three_quarter_path.exists():
        tq_bytes = three_quarter_path.read_bytes()
        contents.append(genai_types.Part.from_bytes(data=tq_bytes, mime_type="image/png"))

    if anchor_path and anchor_path.exists():
        anchor_bytes = anchor_path.read_bytes()
        contents.append(genai_types.Part.from_bytes(data=anchor_bytes, mime_type="image/png"))

    contents.append(prompt)

    config = genai_types.GenerateContentConfig(
        temperature=0.6,
        response_modalities=["IMAGE", "TEXT"],
        image_config=genai_types.ImageConfig(
            aspect_ratio="9:16",
        ),
    )

    try:
        response = client.models.generate_content(
            model="gemini-3-pro-image-preview",
            contents=contents,
            config=config,
        )

        if response and response.candidates:
            for candidate in response.candidates:
                if candidate.content and candidate.content.parts:
                    for part in candidate.content.parts:
                        if hasattr(part, "inline_data") and part.inline_data:
                            output_path.parent.mkdir(parents=True, exist_ok=True)
                            output_path.write_bytes(part.inline_data.data)
                            logger.info("Generated screen test image: %s", output_path)
                            return True

        logger.warning("No image in response for %s", output_path)
        return False

    except Exception as e:
        logger.error("Generation failed: %s", e)
        return False
```

**Step 4: Run test to verify it passes**

Run: `cd ~/Dropbox/CLAUDE_PROJECTS/starsend && python -m pytest tests/tools/test_screen_test_gen.py -v`
Expected: All 4 tests PASS

**Step 5: Commit**

```bash
cd ~/Dropbox/CLAUDE_PROJECTS/starsend
git add tools/screen_test_gen.py tests/tools/test_screen_test_gen.py
git commit -m "feat: add Screen Test image generation with director's note enrichment"
```

---

## Task 3: API Endpoints

**Files:**
- Modify: `starsend/editors/review_server.py`

**Context:** Follow the exact pattern of `_api_casting_*` methods. The server class has a `_route_request()` method that dispatches URL paths to handler methods. Casting endpoints are prefixed with `/api/project/{name}/casting/`. Screen test endpoints use `/api/project/{name}/screen-test/`.

**Step 1: Add the screen-test URL routing**

In `review_server.py`, find the `_route_request()` method. Add routing for screen-test paths alongside the existing casting routes. Look for the pattern:

```python
if parts_after_project[0] == "casting":
    # ... existing casting routes
```

Add after it:

```python
elif parts_after_project[0] == "screen-test":
    return self._route_screen_test(method, project_name, project_dir, parts_after_project[1:], body)
```

**Step 2: Add the screen-test router method**

Add this method to the server class:

```python
def _route_screen_test(self, method, project_name, project_dir, parts, body):
    """Route screen-test sub-paths."""
    if not parts:
        return 400, {"error": "Missing character ID"}

    char_id = parts[0].upper()

    if len(parts) == 1:
        if method == "GET":
            return self._api_screen_test_get(project_name, project_dir, char_id)
        elif method == "POST":
            return self._api_screen_test_generate(project_name, project_dir, char_id, body)

    if len(parts) == 2:
        if parts[1] == "set-anchor" and method == "POST":
            return self._api_screen_test_set_anchor(project_name, project_dir, char_id, body)

    if len(parts) == 3:
        phase_id = parts[1]
        action = parts[2]
        if action == "reroll" and method == "POST":
            return self._api_screen_test_reroll(project_name, project_dir, char_id, phase_id, body)
        elif action == "verdict" and method == "POST":
            return self._api_screen_test_verdict(project_name, project_dir, char_id, phase_id, body)

    return 404, {"error": "Unknown screen-test endpoint"}
```

**Step 3: Implement GET endpoint (phase grid state)**

```python
def _api_screen_test_get(self, project_name, project_dir, char_id):
    """GET /api/project/{name}/screen-test/{character}
    Returns phase grid state + bible data for a character.
    """
    from lib.screen_test import load_screen_test_state

    # Load global bible for phase definitions
    bible_path = project_dir / "state" / "starsend" / "global_bible.json"
    if not bible_path.is_file():
        return 404, {"error": "Global bible not found. Run extraction pipeline first."}

    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(char_id)
    if not char_data:
        return 404, {"error": f"Character {char_id} not found in bible"}

    # Load screen test state
    state = load_screen_test_state(project_dir)
    char_state = state.characters.get(char_id)

    # Load casting state for hero/three-quarter paths
    cs_path = self._casting_state_path(project_dir)
    casting = json.loads(cs_path.read_text(encoding="utf-8")) if cs_path.is_file() else {}
    cast_char = casting.get("characters", {}).get(char_id, {})
    hero_path = cast_char.get("hero_path")
    cast_status = cast_char.get("status", "not_cast")

    phases = []
    for phase in char_data.get("phases", []):
        phase_id = phase["phase_id"]
        phase_state = char_state.phases.get(phase_id) if char_state else None

        phases.append({
            "phase_id": phase_id,
            "start_ep": phase.get("start_ep"),
            "end_ep": phase.get("end_ep"),
            "wardrobe_description": phase.get("wardrobe_description", ""),
            "hair_makeup": phase.get("hair_makeup", ""),
            "distinguishing_marks": phase.get("distinguishing_marks", ""),
            "status": phase_state.status if phase_state else "empty",
            "locked_image": phase_state.locked_image if phase_state else None,
            "held_images": phase_state.held_images if phase_state else [],
            "director_note": phase_state.director_note if phase_state else None,
            "history_count": len(phase_state.generation_history) if phase_state else 0,
        })

    return 200, {
        "character": char_id,
        "display_name": char_data.get("display_name", char_id),
        "visual_description": char_data.get("visual_description", ""),
        "hero_path": hero_path,
        "cast_status": cast_status,
        "anchor_phase": char_state.anchor_phase if char_state else None,
        "phases": phases,
    }
```

**Step 4: Implement POST generate endpoint**

```python
def _api_screen_test_generate(self, project_name, project_dir, char_id, body):
    """POST /api/project/{name}/screen-test/{character}
    Generate images for all empty phases. Runs in background thread.
    """
    import threading
    from lib.screen_test import (
        load_screen_test_state, save_screen_test_state,
        CharacterScreenTest, PhaseState, record_generation,
    )
    from tools.screen_test_gen import build_phase_prompt, generate_phase_image

    bible_path = project_dir / "state" / "starsend" / "global_bible.json"
    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(char_id)
    if not char_data:
        return 404, {"error": f"Character {char_id} not found"}

    cs_path = self._casting_state_path(project_dir)
    casting = json.loads(cs_path.read_text(encoding="utf-8")) if cs_path.is_file() else {}
    cast_char = casting.get("characters", {}).get(char_id, {})
    hero_path = cast_char.get("hero_path")
    if not hero_path:
        return 400, {"error": f"No hero image for {char_id}. Complete casting first."}

    # Resolve hero and three-quarter paths
    hero_full = self._resolve_ref_path(hero_path)
    tq_key = f"{char_id.lower()}_three_quarter"
    turnaround = cast_char.get("turnaround", {})
    tq_path = turnaround.get("three_quarter", {}).get("path")
    tq_full = self._resolve_ref_path(tq_path) if tq_path else None

    state = load_screen_test_state(project_dir)
    if char_id not in state.characters:
        state.characters[char_id] = CharacterScreenTest()
    char_state = state.characters[char_id]

    # Find anchor image if set
    anchor_path = None
    if char_state.anchor_phase and char_state.anchor_phase in char_state.phases:
        anchor_img = char_state.phases[char_state.anchor_phase].locked_image
        if anchor_img:
            anchor_path = self._resolve_ref_path(anchor_img)

    # Mark all empty phases as generating
    phases_to_gen = []
    for phase in char_data.get("phases", []):
        pid = phase["phase_id"]
        if pid not in char_state.phases:
            char_state.phases[pid] = PhaseState(phase_id=pid)
        if char_state.phases[pid].status in ("empty", "rejected"):
            char_state.phases[pid].status = "generating"
            phases_to_gen.append(phase)

    save_screen_test_state(project_dir, state)

    def _generate():
        for phase in phases_to_gen:
            pid = phase["phase_id"]
            output_dir = Path(f"output/refs/characters/{char_id.lower()}/screen_test")
            ver = len(char_state.phases[pid].generation_history) + 1
            output_file = output_dir / f"{pid}_v{ver}.png"

            prompt = build_phase_prompt(char_data, phase)
            ok = generate_phase_image(
                hero_path=hero_full,
                three_quarter_path=tq_full,
                prompt=prompt,
                output_path=output_file,
                anchor_path=anchor_path,
            )

            # Reload state (another thread might have modified)
            st = load_screen_test_state(project_dir)
            cs = st.characters.get(char_id, CharacterScreenTest())
            if pid not in cs.phases:
                cs.phases[pid] = PhaseState(phase_id=pid)

            if ok:
                record_generation(cs.phases[pid], str(output_file), prompt)
            else:
                cs.phases[pid].status = "empty"

            st.characters[char_id] = cs
            save_screen_test_state(project_dir, st)

    threading.Thread(target=_generate, daemon=True).start()

    return 200, {
        "status": "generating",
        "character": char_id,
        "phases_queued": len(phases_to_gen),
    }
```

**Step 5: Implement POST reroll endpoint**

```python
def _api_screen_test_reroll(self, project_name, project_dir, char_id, phase_id, body):
    """POST /api/project/{name}/screen-test/{character}/{phase}/reroll
    Re-roll one phase. Accepts optional director_note and deep (bool).
    """
    import threading
    from lib.screen_test import (
        load_screen_test_state, save_screen_test_state,
        CharacterScreenTest, PhaseState, record_generation,
    )
    from tools.screen_test_gen import (
        build_phase_prompt, enrich_director_note, generate_phase_image,
    )

    director_note = body.get("director_note")
    deep = body.get("deep", False)
    count = 4 if deep else 1

    bible_path = project_dir / "state" / "starsend" / "global_bible.json"
    bible = json.loads(bible_path.read_text(encoding="utf-8"))
    char_data = bible.get("characters", {}).get(char_id)
    if not char_data:
        return 404, {"error": f"Character {char_id} not found"}

    phase_data = None
    for p in char_data.get("phases", []):
        if p["phase_id"] == phase_id:
            phase_data = p
            break
    if not phase_data:
        return 404, {"error": f"Phase {phase_id} not found"}

    cs_path = self._casting_state_path(project_dir)
    casting = json.loads(cs_path.read_text(encoding="utf-8")) if cs_path.is_file() else {}
    cast_char = casting.get("characters", {}).get(char_id, {})
    hero_path = cast_char.get("hero_path")
    if not hero_path:
        return 400, {"error": "No hero image. Complete casting first."}

    hero_full = self._resolve_ref_path(hero_path)
    tq_path = cast_char.get("turnaround", {}).get("three_quarter", {}).get("path")
    tq_full = self._resolve_ref_path(tq_path) if tq_path else None

    state = load_screen_test_state(project_dir)
    if char_id not in state.characters:
        state.characters[char_id] = CharacterScreenTest()
    char_state = state.characters[char_id]
    if phase_id not in char_state.phases:
        char_state.phases[phase_id] = PhaseState(phase_id=phase_id)

    phase_state = char_state.phases[phase_id]
    phase_state.status = "generating"
    if director_note:
        phase_state.director_note = director_note
    save_screen_test_state(project_dir, state)

    anchor_path = None
    if char_state.anchor_phase and char_state.anchor_phase in char_state.phases:
        anchor_img = char_state.phases[char_state.anchor_phase].locked_image
        if anchor_img:
            anchor_path = self._resolve_ref_path(anchor_img)

    def _reroll():
        enriched = None
        if director_note:
            base_desc = phase_data.get("wardrobe_description", "")
            enriched = enrich_director_note(base_desc, director_note)

        for i in range(count):
            st = load_screen_test_state(project_dir)
            cs = st.characters.get(char_id, CharacterScreenTest())
            ps = cs.phases.get(phase_id, PhaseState(phase_id=phase_id))

            ver = len(ps.generation_history) + 1
            output_dir = Path(f"output/refs/characters/{char_id.lower()}/screen_test")
            output_file = output_dir / f"{phase_id}_v{ver}.png"

            prompt = build_phase_prompt(char_data, phase_data, enriched_note=enriched)
            ok = generate_phase_image(
                hero_path=hero_full,
                three_quarter_path=tq_full,
                prompt=prompt,
                output_path=output_file,
                anchor_path=anchor_path,
            )

            if ok:
                record_generation(ps, str(output_file), prompt, director_note)
                if enriched:
                    ps.enriched_prompt = enriched
            else:
                if i == count - 1:
                    ps.status = "rejected"

            cs.phases[phase_id] = ps
            st.characters[char_id] = cs
            save_screen_test_state(project_dir, st)

    threading.Thread(target=_reroll, daemon=True).start()

    return 200, {
        "status": "generating",
        "character": char_id,
        "phase": phase_id,
        "count": count,
        "has_note": director_note is not None,
    }
```

**Step 6: Implement POST verdict endpoint**

```python
def _api_screen_test_verdict(self, project_name, project_dir, char_id, phase_id, body):
    """POST /api/project/{name}/screen-test/{character}/{phase}/verdict
    Apply lock/hold/reject to a phase.
    """
    from lib.screen_test import load_screen_test_state, save_screen_test_state, apply_verdict

    action = body.get("action")
    if action not in ("lock", "hold", "reject"):
        return 400, {"error": "action must be lock, hold, or reject"}

    state = load_screen_test_state(project_dir)
    char_state = state.characters.get(char_id)
    if not char_state:
        return 404, {"error": f"No screen test state for {char_id}"}

    phase_state = char_state.phases.get(phase_id)
    if not phase_state:
        return 404, {"error": f"No state for phase {phase_id}"}

    apply_verdict(phase_state, action)
    save_screen_test_state(project_dir, state)

    return 200, {
        "status": "saved",
        "character": char_id,
        "phase": phase_id,
        "action": action,
        "phase_status": phase_state.status,
    }
```

**Step 7: Implement POST set-anchor endpoint**

```python
def _api_screen_test_set_anchor(self, project_name, project_dir, char_id, body):
    """POST /api/project/{name}/screen-test/{character}/set-anchor
    Mark a locked phase as the style anchor.
    """
    from lib.screen_test import load_screen_test_state, save_screen_test_state

    anchor_phase = body.get("phase")
    if not anchor_phase:
        return 400, {"error": "phase is required"}

    state = load_screen_test_state(project_dir)
    char_state = state.characters.get(char_id)
    if not char_state:
        return 404, {"error": f"No screen test state for {char_id}"}

    phase = char_state.phases.get(anchor_phase)
    if not phase or phase.status != "locked":
        return 400, {"error": f"Phase {anchor_phase} must be locked to set as anchor"}

    char_state.anchor_phase = anchor_phase
    save_screen_test_state(project_dir, state)

    return 200, {
        "status": "saved",
        "character": char_id,
        "anchor_phase": anchor_phase,
    }
```

**Step 8: Add helper method for resolving ref paths**

The server likely already has path resolution logic. If `_resolve_ref_path` doesn't exist, add:

```python
def _resolve_ref_path(self, rel_path):
    """Resolve a relative ref path to absolute. Checks starsend/output/ first, then project output/."""
    if not rel_path:
        return None
    p = Path(rel_path)
    if p.is_absolute() and p.exists():
        return p
    # Try relative to starsend root
    from lib.constants import STARSEND_ROOT
    candidate = STARSEND_ROOT / rel_path
    if candidate.exists():
        return candidate
    return p  # Return as-is, caller handles missing
```

**Step 9: Test with curl**

```bash
# Start the server
cd ~/Dropbox/CLAUDE_PROJECTS/starsend && python editors/review_server.py --project leviathan &

# Test GET
curl -s http://localhost:8430/api/project/leviathan/screen-test/JINX | python -m json.tool

# Test POST generate (will fail without real API key, but should return 200)
curl -s -X POST http://localhost:8430/api/project/leviathan/screen-test/JINX | python -m json.tool

# Stop server
kill %1
```

**Step 10: Commit**

```bash
cd ~/Dropbox/CLAUDE_PROJECTS/starsend
git add editors/review_server.py
git commit -m "feat: add Screen Test API endpoints (get, generate, reroll, verdict, anchor)"
```

---

## Task 4: Frontend Tab Module

**Files:**
- Create: `recoil/editors/modules/screen_test.js`

**Context:** Follows the casting.js IIFE pattern exactly. Uses `TabRegistry.register()`, calls `STARSEND_API` at port 8430, uses `showToast()` for feedback, `escHtml()` for escaping.

**Step 1: Create the tab module skeleton**

```javascript
// recoil/editors/modules/screen_test.js
/**
 * screen_test.js — Screen Test Tab for the Pre-Production Console
 *
 * Wardrobe + hair/makeup phase review: generate character in each narrative
 * phase's full look, lock/hold/reject, re-roll with director's notes.
 *
 * Prerequisite: Character must be cast (hero + turnaround) in Casting tab.
 */
(() => {
  let panel = null;
  let phases = [];
  let charData = null;
  let activeCharacter = null;
  let characters = [];   // List of available characters from bible
  let polling = null;

  const STARSEND_API = `http://${window.location.hostname}:8430`;

  // ── Lifecycle ──

  function init() {
    panel = document.getElementById('panel-screen-test');
    AppState.on('projectChanged', () => {
      phases = [];
      charData = null;
      activeCharacter = null;
      characters = [];
      stopPolling();
    });
  }

  async function activate() {
    if (!panel) init();
    if (!panel) return;
    if (!AppState.project) {
      panel.innerHTML = '<div class="empty-state"><div class="empty-state-title">Select a project</div></div>';
      return;
    }
    if (!activeCharacter) {
      await loadCharacterList();
    }
    if (activeCharacter) {
      await loadPhaseData();
    }
    render();
  }

  function deactivate() {
    stopPolling();
  }

  // ── Data Loading ──

  async function loadCharacterList() {
    try {
      const res = await fetch(`${STARSEND_API}/api/project/${AppState.project}/casting/characters`);
      if (!res.ok) return;
      const data = await res.json();
      characters = Object.entries(data.characters || {})
        .filter(([_, c]) => c.status === 'turnaround_complete' || c.status === 'fully_cast')
        .map(([id]) => id);
      if (characters.length > 0 && !activeCharacter) {
        activeCharacter = characters[0];
      }
    } catch (e) {
      characters = [];
    }
  }

  async function loadPhaseData() {
    if (!activeCharacter) return;
    try {
      const res = await fetch(`${STARSEND_API}/api/project/${AppState.project}/screen-test/${activeCharacter}`);
      if (!res.ok) { charData = null; phases = []; return; }
      const data = await res.json();
      charData = data;
      phases = data.phases || [];

      // Start polling if any phase is generating
      if (phases.some(p => p.status === 'generating')) {
        startPolling();
      } else {
        stopPolling();
      }
    } catch (e) {
      charData = null;
      phases = [];
    }
  }

  function startPolling() {
    if (polling) return;
    polling = setInterval(async () => {
      await loadPhaseData();
      render();
      if (!phases.some(p => p.status === 'generating')) {
        stopPolling();
      }
    }, 3000);
  }

  function stopPolling() {
    if (polling) { clearInterval(polling); polling = null; }
  }

  // ── Rendering ──

  function render() {
    if (!panel) return;

    if (characters.length === 0) {
      panel.innerHTML = `
        <div class="empty-state">
          <div class="empty-state-title">No cast characters</div>
          <div class="empty-state-text">Complete casting (hero + turnaround) in the Casting tab first.</div>
        </div>`;
      return;
    }

    const charSelector = `
      <div class="subtab-bar">
        ${characters.map(c => `
          <button class="btn${activeCharacter === c ? ' btn-primary' : ''}"
                  onclick="ScreenTestTab.selectCharacter('${escAttr(c)}')">${escHtml(c)}</button>
        `).join('')}
        <span style="margin-left:auto;font-family:var(--font-mono);font-size:11px;color:var(--text-dim)">
          SCREEN TEST
        </span>
      </div>`;

    if (!charData || phases.length === 0) {
      panel.innerHTML = charSelector + `
        <div class="empty-state">
          <div class="empty-state-title">Loading...</div>
        </div>`;
      return;
    }

    const generateBtn = phases.some(p => p.status === 'empty' || p.status === 'rejected')
      ? `<button class="btn btn-primary" onclick="ScreenTestTab.generateAll()" style="margin:12px 0">
           GENERATE ALL EMPTY PHASES
         </button>`
      : '';

    const grid = `
      <div style="padding:16px">
        <div style="margin-bottom:8px;font-size:13px;color:var(--text-dim)">
          ${escHtml(charData.display_name)} — ${phases.length} wardrobe phases
        </div>
        ${generateBtn}
        <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:16px">
          ${phases.map(renderPhaseCell).join('')}
        </div>
      </div>`;

    panel.innerHTML = charSelector + grid;
  }

  function renderPhaseCell(phase) {
    const statusColors = {
      empty: 'var(--text-dim)', generating: 'var(--accent-cyan)',
      generated: '#f0c040', held: '#f0c040', locked: 'var(--accent-green)', rejected: '#f04040',
    };
    const statusColor = statusColors[phase.status] || 'var(--text-dim)';
    const isAnchor = charData.anchor_phase === phase.phase_id;
    const anchorStar = isAnchor ? ' ★' : '';

    // Image display
    let imageHtml;
    if (phase.status === 'generating') {
      imageHtml = `<div style="width:100%;aspect-ratio:9/16;background:var(--bg-secondary);display:flex;align-items:center;justify-content:center;border-radius:6px">
        <div style="color:var(--accent-cyan);font-size:12px;animation:pulse 1.5s infinite">GENERATING...</div>
      </div>`;
    } else if (phase.locked_image) {
      imageHtml = `<img src="/${escAttr(phase.locked_image)}" style="width:100%;aspect-ratio:9/16;object-fit:cover;border-radius:6px;border:2px solid var(--accent-green)" onerror="this.style.display='none'">`;
    } else if (phase.held_images && phase.held_images.length > 0) {
      imageHtml = `<img src="/${escAttr(phase.held_images[phase.held_images.length - 1])}" style="width:100%;aspect-ratio:9/16;object-fit:cover;border-radius:6px;border:2px solid #f0c040" onerror="this.style.display='none'">`;
    } else if (phase.status === 'generated' && phase.history_count > 0) {
      // Show latest from server (we'd need the path — for now show placeholder)
      imageHtml = `<div style="width:100%;aspect-ratio:9/16;background:var(--bg-secondary);display:flex;align-items:center;justify-content:center;border-radius:6px">
        <span style="color:var(--text-dim);font-size:12px">READY</span>
      </div>`;
    } else {
      imageHtml = `<div style="width:100%;aspect-ratio:9/16;background:var(--bg-secondary);display:flex;align-items:center;justify-content:center;border-radius:6px">
        <span style="color:var(--text-dim);font-size:12px">EMPTY</span>
      </div>`;
    }

    // Phase label
    const epRange = (phase.start_ep && phase.end_ep) ? `EP ${phase.start_ep}-${phase.end_ep}` : '';
    const phaseName = phase.phase_id.replace(/^[a-z]+_/, '').replace(/_/g, ' ');

    // Action buttons
    let actions = '';
    if (phase.status === 'generated' || phase.status === 'held') {
      actions = `
        <div style="display:flex;gap:4px;margin-top:6px;flex-wrap:wrap">
          <button class="btn btn-sm" style="background:var(--accent-green);color:#000"
                  onclick="ScreenTestTab.verdict('${escAttr(phase.phase_id)}','lock')">LOCK</button>
          <button class="btn btn-sm" style="background:#f0c040;color:#000"
                  onclick="ScreenTestTab.verdict('${escAttr(phase.phase_id)}','hold')">HOLD</button>
          <button class="btn btn-sm" style="background:#f04040;color:#fff"
                  onclick="ScreenTestTab.verdict('${escAttr(phase.phase_id)}','reject')">REJECT</button>
          <button class="btn btn-sm"
                  onclick="ScreenTestTab.reroll('${escAttr(phase.phase_id)}',false)">RE-ROLL</button>
          <button class="btn btn-sm"
                  onclick="ScreenTestTab.reroll('${escAttr(phase.phase_id)}',true)">DEEP</button>
        </div>`;
    } else if (phase.status === 'locked') {
      actions = `
        <div style="display:flex;gap:4px;margin-top:6px">
          ${!isAnchor ? `<button class="btn btn-sm" onclick="ScreenTestTab.setAnchor('${escAttr(phase.phase_id)}')">SET ANCHOR</button>` : '<span style="font-size:11px;color:var(--accent-green)">★ ANCHOR</span>'}
        </div>`;
    } else if (phase.status === 'rejected' || phase.status === 'empty') {
      actions = `
        <div style="display:flex;gap:4px;margin-top:6px">
          <button class="btn btn-sm"
                  onclick="ScreenTestTab.reroll('${escAttr(phase.phase_id)}',false)">GENERATE</button>
        </div>`;
    }

    // Director's note indicator
    const noteIndicator = phase.director_note
      ? `<div style="font-size:10px;color:var(--accent-cyan);margin-top:4px;font-style:italic">📝 ${escHtml(phase.director_note.substring(0, 40))}${phase.director_note.length > 40 ? '...' : ''}</div>`
      : '';

    return `
      <div style="background:var(--bg-secondary);border-radius:8px;padding:12px;border:1px solid ${statusColor}22">
        <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:8px">
          <div style="font-size:12px;font-weight:600;color:var(--text-primary);text-transform:capitalize">${escHtml(phaseName)}${anchorStar}</div>
          <div style="font-size:10px;color:var(--text-dim)">${escHtml(epRange)}</div>
        </div>
        <div style="font-size:10px;color:var(--text-dim);margin-bottom:8px;max-height:48px;overflow:hidden">
          ${escHtml(phase.wardrobe_description.substring(0, 120))}${phase.wardrobe_description.length > 120 ? '...' : ''}
        </div>
        ${imageHtml}
        <div style="display:flex;align-items:center;gap:6px;margin-top:8px">
          <span style="font-size:10px;font-weight:600;color:${statusColor};text-transform:uppercase">${escHtml(phase.status)}</span>
          ${phase.history_count > 0 ? `<span style="font-size:10px;color:var(--text-dim)">v${phase.history_count}</span>` : ''}
        </div>
        ${noteIndicator}
        ${actions}
      </div>`;
  }

  // ── Actions ──

  const ScreenTestTab = {
    async selectCharacter(charId) {
      activeCharacter = charId;
      await loadPhaseData();
      render();
    },

    async generateAll() {
      showToast('Generating screen test images...');
      try {
        const res = await fetch(`${STARSEND_API}/api/project/${AppState.project}/screen-test/${activeCharacter}`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
        });
        const data = await res.json();
        if (data.error) { showToast(data.error, true); return; }
        showToast(`Generating ${data.phases_queued} phases...`);
        await loadPhaseData();
        render();
        startPolling();
      } catch (e) {
        showToast('Generation failed: ' + e.message, true);
      }
    },

    async verdict(phaseId, action) {
      try {
        const res = await fetch(
          `${STARSEND_API}/api/project/${AppState.project}/screen-test/${activeCharacter}/${phaseId}/verdict`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ action }),
          }
        );
        const data = await res.json();
        if (data.error) { showToast(data.error, true); return; }
        showToast(`${phaseId}: ${action.toUpperCase()}`);
        await loadPhaseData();
        render();
      } catch (e) {
        showToast('Failed: ' + e.message, true);
      }
    },

    async reroll(phaseId, deep) {
      // Prompt for director's note
      const note = prompt("Director's note (optional — leave blank for variation only):");

      showToast(deep ? 'Deep re-rolling (4 variations)...' : 'Re-rolling...');
      try {
        const body = { deep };
        if (note) body.director_note = note;

        const res = await fetch(
          `${STARSEND_API}/api/project/${AppState.project}/screen-test/${activeCharacter}/${phaseId}/reroll`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(body),
          }
        );
        const data = await res.json();
        if (data.error) { showToast(data.error, true); return; }
        showToast(data.has_note ? 'Re-rolling with note...' : 'Re-rolling...');
        await loadPhaseData();
        render();
        startPolling();
      } catch (e) {
        showToast('Re-roll failed: ' + e.message, true);
      }
    },

    async setAnchor(phaseId) {
      try {
        const res = await fetch(
          `${STARSEND_API}/api/project/${AppState.project}/screen-test/${activeCharacter}/set-anchor`,
          {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ phase: phaseId }),
          }
        );
        const data = await res.json();
        if (data.error) { showToast(data.error, true); return; }
        showToast(`Anchor set: ${phaseId}`);
        await loadPhaseData();
        render();
      } catch (e) {
        showToast('Failed: ' + e.message, true);
      }
    },
  };

  // Expose for onclick handlers
  window.ScreenTestTab = ScreenTestTab;

  TabRegistry.register('screen-test', { init, activate, deactivate });
})();
```

**Step 2: Manual test — verify module loads without errors**

Open the browser console and check for JavaScript errors after adding the script tag (next task).

**Step 3: Commit**

```bash
cd ~/Dropbox/CLAUDE_PROJECTS/recoil
git add editors/modules/screen_test.js
git commit -m "feat: add Screen Test tab module for pre-production console"
```

---

## Task 5: Console Integration + E2E Verification

**Files:**
- Modify: `recoil/editors/prepro-console.html`

**Step 1: Add the tab button to the console bar**

In `recoil/editors/prepro-console.html`, find the tab buttons section (around line 17-21). Add the Screen Test tab button after Breakdown (05):

```html
<button class="tab-btn" data-tab="screen-test"><span class="tab-num">06</span> SCREEN TEST</button>
```

**Step 2: Add the panel div to the workspace**

Find the workspace div (around line 59-65). Add after the breakdown panel:

```html
<div id="panel-screen-test" class="tab-panel"></div>
```

**Step 3: Add the script tag**

Find the script tags at the bottom of the file (where other module scripts are loaded). Add:

```html
<script src="modules/screen_test.js"></script>
```

**Step 4: Verify tab switching works**

The existing tab-switching logic in `prepro-console.html` uses `data-tab` attributes. Check that the click handler in the HTML correctly activates the `screen-test` panel. The existing pattern should handle this automatically since it reads `data-tab` from the clicked button and shows the corresponding `panel-{tab}` div.

**Step 5: E2E test**

1. Start both servers:
   ```bash
   cd ~/Dropbox/CLAUDE_PROJECTS/recoil && python editors/serve.py &
   cd ~/Dropbox/CLAUDE_PROJECTS/starsend && python editors/review_server.py --project leviathan &
   ```

2. Open `http://localhost:8420` in Chrome

3. Select project "leviathan"

4. Click "06 SCREEN TEST" tab

5. Verify:
   - Character selector shows cast characters
   - Phase grid loads with phase names and episode ranges
   - "GENERATE ALL EMPTY PHASES" button appears
   - Clicking generate shows "GENERATING..." state
   - After generation, lock/hold/reject buttons appear

6. Kill servers: `kill %1 %2`

**Step 6: Commit**

```bash
cd ~/Dropbox/CLAUDE_PROJECTS/recoil
git add editors/prepro-console.html
git commit -m "feat: wire Screen Test tab into pre-production console"
```

---

## Summary

| Task | Files | Estimated Lines |
|------|-------|----------------|
| 1. State Models | `starsend/lib/screen_test.py` + test | ~120 + ~100 |
| 2. Image Generation | `starsend/tools/screen_test_gen.py` + test | ~120 + ~80 |
| 3. API Endpoints | `starsend/editors/review_server.py` (modify) | ~250 added |
| 4. Frontend Tab | `recoil/editors/modules/screen_test.js` | ~350 |
| 5. Console Integration | `recoil/editors/prepro-console.html` (modify) | ~5 lines |

Total: ~5 tasks, ~1025 new lines across 4 new files + 2 modified files.
