# Design Lock Writeback — Implementation Plan

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

**Goal:** When a character design is visually approved (hero locked, screen test phase locked), sync the bible text descriptions to match the approved image via AI-proposed + human-confirmed writeback.

**Architecture:** Hybrid Approach C — Flash 3.1 vision extracts structured descriptions from approved images, user reviews/edits in a modal, then commits to bible via existing PATCH endpoint. Sync state tracked in casting_state.json (base) and screen_test_state (phases). Bible stays pure text.

**Tech Stack:** Python (review_server.py backend), Gemini Flash 3.1 (vision extraction), vanilla JS (modal UI in screen_test.js + casting.js)

**Consultation:** `starsend/consultations/design_lock_writeback/SYNTHESIS.md`

---

### Task 1: Add `bible_synced` field to PhaseState model

**Files:**
- Modify: `starsend/lib/screen_test.py:49-75`

**Step 1: Add field to PhaseState**

Add `bible_synced` boolean field to PhaseState class:

```python
bible_synced: bool = Field(
    default=False,
    description="Whether bible text has been synced to match the locked image",
)
```

This goes after the `generation_history` field (line 75). Existing state files will auto-default to `False` via Pydantic.

**Step 2: Verify backward compatibility**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 -c "from lib.screen_test import load_screen_test_state; from pathlib import Path; s = load_screen_test_state(Path('../projects/tartarus')); print('OK:', len(s.characters), 'characters')"`

Expected: loads without error (Pydantic defaults handle missing field).

---

### Task 2: Add `bible_synced` to casting_state.json handling

**Files:**
- Modify: `starsend/editors/review_server.py` — `_api_casting_select_hero()` and `_api_casting_approve_ref()`

**Step 1: Set `bible_synced: false` when hero is selected**

In the `_api_casting_select_hero()` method, after setting `hero_path` and `status`, add:

```python
char_state["bible_synced"] = False
```

**Step 2: Set `bible_synced: false` when turnaround is approved**

In `_api_casting_approve_ref()`, after approval, add:

```python
char_state["bible_synced"] = False
```

This ensures any new visual approval resets the sync flag.

---

### Task 3: Create vision extraction helper

**Files:**
- Create: `starsend/lib/visual_sync.py`

**Step 1: Write the extraction function**

```python
"""
visual_sync.py — Vision-based bible text extraction from approved images.

Uses Gemini Flash 3.1 to analyze character images and propose structured
bible field updates. Human confirmation required before write.
"""
import json
import base64
import os
from pathlib import Path
from typing import Optional

import google.generativeai as genai


# Use Flash for extraction — human gate catches errors, Pro unnecessary
_MODEL = "gemini-2.5-flash-preview-05-20"

_SYSTEM_PROMPT_BASE = """You are a Script Supervisor ensuring continuity for a TV production.

Input: A character description (current bible text) and an approved production image.
Task: Update the specified fields to accurately describe what is visible in the image.

Rules:
1. Be descriptive and concrete — materials, colors, specific items.
2. If the text says 'blue jacket' but the image shows a red jacket, change to 'red jacket'.
3. If a detail in the text is not visible in the image but not contradicted by it (e.g., hidden by camera angle), PRESERVE it.
4. Use dry production language (e.g., 'worn leather vest' not 'cool looking vest').
5. Retain existing terminology where possible.
6. Output valid JSON only — no markdown fencing, no explanation."""

_PROMPT_HERO = """Analyze this character's HERO IMAGE (the actor's face and body).

Current bible text:
visual_description: {visual_description}
wardrobe_description: {wardrobe_description}

Output a JSON object with these fields updated to match the image:
{{
  "visual_description": "...",
  "wardrobe_description": "..."
}}"""

_PROMPT_PHASE = """Analyze this WARDROBE PHASE image (costume fitting — focus on clothing, hair, accessories).

Current bible text:
wardrobe_description: {wardrobe_description}
hair_makeup: {hair_makeup}
distinguishing_marks: {distinguishing_marks}

Output a JSON object with these fields updated to match the image:
{{
  "wardrobe_description": "...",
  "hair_makeup": "...",
  "distinguishing_marks": "..."
}}

IMPORTANT: Do NOT describe the character's face or body build — only clothing, hair styling, makeup, and visible marks/accessories."""


def _load_image_part(image_path: str) -> dict:
    """Load an image file as a Gemini API Part."""
    abs_path = Path(image_path)
    if not abs_path.exists():
        raise FileNotFoundError(f"Image not found: {abs_path}")
    data = abs_path.read_bytes()
    ext = abs_path.suffix.lower()
    mime = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png", "webp": "image/webp"}.get(ext.lstrip("."), "image/png")
    return {"inline_data": {"mime_type": mime, "data": base64.b64encode(data).decode()}}


def propose_visual_sync(
    image_path: str,
    current_text: dict,
    sync_type: str = "phase",  # "hero" or "phase"
) -> dict:
    """
    Analyze an approved image and propose bible text updates.

    Args:
        image_path: Absolute path to the approved image.
        current_text: Dict of current bible field values.
        sync_type: "hero" for base traits, "phase" for wardrobe only.

    Returns:
        Dict with proposed field values (same keys as input).
    """
    genai.configure(api_key=os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY"))
    model = genai.GenerativeModel(_MODEL, system_instruction=_SYSTEM_PROMPT_BASE)

    if sync_type == "hero":
        prompt = _PROMPT_HERO.format(
            visual_description=current_text.get("visual_description", ""),
            wardrobe_description=current_text.get("wardrobe_description", ""),
        )
    else:
        prompt = _PROMPT_PHASE.format(
            wardrobe_description=current_text.get("wardrobe_description", ""),
            hair_makeup=current_text.get("hair_makeup", ""),
            distinguishing_marks=current_text.get("distinguishing_marks", ""),
        )

    image_part = _load_image_part(image_path)
    response = model.generate_content([image_part, prompt])
    text = response.text.strip()

    # Strip markdown fencing if present
    if text.startswith("```"):
        text = text.split("\n", 1)[1] if "\n" in text else text[3:]
        if text.endswith("```"):
            text = text[:-3].strip()
        if text.startswith("json"):
            text = text[4:].strip()

    return json.loads(text)
```

**Step 2: Verify import**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 -c "from lib.visual_sync import propose_visual_sync; print('Import OK')"`

Expected: `Import OK`

---

### Task 4: Add propose-visual-sync API endpoint

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

**Step 1: Add POST route handler in `do_POST()`**

After the screen test endpoints (~line 744), add:

```python
if path.startswith(f"/api/project/{name}/bible/propose-visual-sync"):
    self._api_propose_visual_sync(name, body)
    return
```

**Step 2: Implement the handler method**

```python
def _api_propose_visual_sync(self, project_name, body):
    """POST /api/project/{name}/bible/propose-visual-sync

    Body: { char_id, phase_id (optional), image_path, current_text, sync_type }
    Returns: { proposed_changes: {...}, sync_type }
    """
    from lib.visual_sync import propose_visual_sync

    char_id = body.get("char_id")
    image_rel = body.get("image_path", "")
    current_text = body.get("current_text", {})
    sync_type = body.get("sync_type", "phase")

    if not char_id or not image_rel:
        self._json_response({"error": "char_id and image_path required"}, status=400)
        return

    # Resolve image path (same as static serving)
    if image_rel.startswith("output/"):
        image_abs = str(_STARSEND_OUTPUT / image_rel.replace("output/", "", 1))
        if not Path(image_abs).exists():
            image_abs = str(OUTPUT_DIR / image_rel.replace("output/", "", 1))
    else:
        image_abs = image_rel

    if not Path(image_abs).exists():
        self._json_response({"error": f"Image not found: {image_rel}"}, status=404)
        return

    try:
        proposed = propose_visual_sync(image_abs, current_text, sync_type)
        self._json_response({"proposed_changes": proposed, "sync_type": sync_type})
    except Exception as e:
        self._json_response({"error": str(e)}, status=500)
```

**Step 3: Verify endpoint exists**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 -c "import ast; tree = ast.parse(open('editors/review_server.py').read()); print('Parse OK')"`

Expected: `Parse OK`

---

### Task 5: Add "Sync Bible" button + modal to screen test UI

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

**Step 1: Add sync button to locked phase actions**

In `renderPhaseActions()`, within the `status === 'locked'` block (line 324-333), add a "SYNC BIBLE" button after the anchor button:

```javascript
<button class="btn" style="font-size:9px;padding:2px 8px;border-color:var(--accent-cyan);color:var(--accent-cyan)" onclick="ScreenTestTab.syncBible('${pid}')">SYNC BIBLE</button>
```

**Step 2: Add the sync modal HTML renderer**

Add function `renderSyncModal(phase, proposed, syncType)` that returns modal HTML:
- Left side: the locked image
- Right side: editable textareas for each proposed field
- "Save to Bible" button commits changes

**Step 3: Add `syncBible()` method**

Add to the public API object:

```javascript
async syncBible(phaseId) {
    const phase = phaseData[phaseId];
    if (!phase || !phase.locked_image) return;

    // Get current bible text for this character + phase
    const bibleRes = await fetch(`${STARSEND_API}/api/bible`);
    const bible = await bibleRes.json();
    const charBible = (bible.characters || {})[activeChar] || {};
    const phaseBible = (charBible.phases || []).find(p => p.phase_id === phaseId) || {};

    const currentText = {
        wardrobe_description: phaseBible.wardrobe_description || '',
        hair_makeup: phaseBible.hair_makeup || '',
        distinguishing_marks: phaseBible.distinguishing_marks || '',
    };

    // Call vision extraction
    const syncRes = await postScreenTest(
        `/api/project/${AppState.project}/bible/propose-visual-sync`,
        {
            char_id: activeChar,
            phase_id: phaseId,
            image_path: phase.locked_image,
            current_text: currentText,
            sync_type: 'phase',
        }
    );

    if (syncRes.error) {
        alert('Sync failed: ' + syncRes.error);
        return;
    }

    // Show modal with proposed changes
    showSyncModal(phase, syncRes.proposed_changes, currentText, phaseId);
}
```

**Step 4: Add modal display and commit functions**

```javascript
function showSyncModal(phase, proposed, current, phaseId) {
    // Create overlay
    const overlay = document.createElement('div');
    overlay.id = 'sync-modal-overlay';
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:1000;display:flex;align-items:center;justify-content:center';

    const fields = Object.keys(proposed);
    const fieldInputs = fields.map(f => `
        <div style="margin-bottom:12px">
            <label style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);text-transform:uppercase">${f.replace(/_/g, ' ')}</label>
            <div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);margin:4px 0">Current: ${escHtml(current[f] || '(empty)')}</div>
            <textarea id="sync-field-${f}" style="width:100%;min-height:60px;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-dim);border-radius:var(--radius-sm);padding:8px;font-family:var(--font-mono);font-size:11px;resize:vertical">${escHtml(proposed[f] || '')}</textarea>
        </div>
    `).join('');

    overlay.innerHTML = `
        <div style="background:var(--bg-primary);border:1px solid var(--border-dim);border-radius:var(--radius-md);max-width:900px;width:90%;max-height:90vh;overflow:auto;display:flex">
            <div style="flex:0 0 300px;padding:16px;border-right:1px solid var(--border-dim)">
                <div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:8px">APPROVED IMAGE</div>
                <img src="${STARSEND_API}/${escAttr(phase.locked_image)}" style="width:100%;border-radius:var(--radius-sm)">
            </div>
            <div style="flex:1;padding:16px">
                <div style="font-family:var(--font-mono);font-size:12px;color:var(--text-primary);margin-bottom:16px;font-weight:700">SYNC VISUALS TO BIBLE</div>
                <div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);margin-bottom:16px">AI-proposed descriptions from the approved image. Edit as needed, then save.</div>
                ${fieldInputs}
                <div style="display:flex;gap:8px;margin-top:16px">
                    <button class="btn btn-success" onclick="ScreenTestTab.commitSync('${escAttr(phaseId)}', ${JSON.stringify(fields)})">SAVE TO BIBLE</button>
                    <button class="btn" onclick="document.getElementById('sync-modal-overlay').remove()">CANCEL</button>
                </div>
            </div>
        </div>
    `;

    document.body.appendChild(overlay);
}
```

**Step 5: Add commit function**

```javascript
async commitSync(phaseId, fields) {
    const updates = {};
    for (const f of fields) {
        const el = document.getElementById(`sync-field-${f}`);
        if (el) updates[f] = el.value;
    }

    // Write to bible via existing PATCH endpoint
    const bibleRes = await fetch(`${STARSEND_API}/api/bible`);
    const bible = await bibleRes.json();
    const charBible = (bible.characters || {})[activeChar] || {};
    const phases = charBible.phases || [];
    const phaseIdx = phases.findIndex(p => p.phase_id === phaseId);

    if (phaseIdx >= 0) {
        for (const [k, v] of Object.entries(updates)) {
            phases[phaseIdx][k] = v;
        }
    }

    // PATCH bible
    await fetch(`${STARSEND_API}/api/bible/character/${encodeURIComponent(activeChar)}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phases }),
    });

    // Mark phase as bible-synced
    await postScreenTest(
        `/api/project/${AppState.project}/screen-test/${encodeURIComponent(activeChar)}/${encodeURIComponent(phaseId)}/bible-synced`,
        {}
    );

    // Close modal and refresh
    document.getElementById('sync-modal-overlay')?.remove();
    await loadPhaseData(activeChar);
    render();
}
```

---

### Task 6: Add bible-synced endpoint to mark sync complete

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

**Step 1: Add POST route**

In `do_POST()`, after the verdict endpoint:

```python
if re.match(rf"/api/project/{re.escape(name)}/screen-test/[^/]+/[^/]+/bible-synced", path):
    parts = path.split("/")
    char_id = parts[5]
    phase_id = parts[6]
    self._api_screen_test_bible_synced(project_name=name, char_id=char_id, phase_id=phase_id)
    return
```

**Step 2: Implement handler**

```python
def _api_screen_test_bible_synced(self, project_name, char_id, phase_id):
    """Mark a screen test phase as bible-synced."""
    from lib.screen_test import load_screen_test_state, save_screen_test_state
    project_dir = Path(PROJECTS_DIR) / project_name
    state = load_screen_test_state(project_dir)
    char_st = state.characters.get(char_id)
    if not char_st:
        self._json_response({"error": f"Character {char_id} not found"}, status=404)
        return
    phase_st = char_st.phases.get(phase_id)
    if not phase_st:
        self._json_response({"error": f"Phase {phase_id} not found"}, status=404)
        return
    phase_st.bible_synced = True
    save_screen_test_state(project_dir, state)
    self._json_response({"ok": True, "bible_synced": True})
```

---

### Task 7: Add "Sync Needed" indicator to phase cards

**Files:**
- Modify: `recoil/editors/modules/screen_test.js`
- Modify: `starsend/editors/review_server.py` (screen test GET response)

**Step 1: Include bible_synced in GET response**

In `_api_screen_test_get()`, when building the phase response dict, add:

```python
"bible_synced": phase_st.bible_synced if hasattr(phase_st, 'bible_synced') else False,
```

**Step 2: Show indicator in UI**

In `renderPhaseCard()`, when status is 'locked', add a "SYNC NEEDED" warning pill if `bible_synced` is false:

```javascript
const syncWarning = (status === 'locked' && !phase.bible_synced)
    ? '<span class="pill" style="border-color:#f04040;color:#f04040;font-size:8px;animation:screentest-pulse 2s ease-in-out infinite">SYNC NEEDED</span>'
    : (status === 'locked' && phase.bible_synced)
    ? '<span class="pill" style="border-color:var(--accent-green);color:var(--accent-green);font-size:8px">SYNCED</span>'
    : '';
```

---

### Task 8: Add hero sync to casting tab

**Files:**
- Modify: `recoil/editors/modules/casting.js`

**Step 1: Add "SYNC BIBLE" button to hero-selected view**

In the casting grid view where the hero is displayed, add a sync button when `status === 'hero_selected'` or later:

```javascript
<button class="btn" style="font-size:9px;padding:2px 8px;border-color:var(--accent-cyan);color:var(--accent-cyan)" onclick="CastingTab.syncHeroBible()">SYNC BIBLE</button>
```

**Step 2: Implement hero sync flow**

Similar to screen test sync but targets `visual_description` + first phase `wardrobe_description`:

```javascript
async syncHeroBible() {
    const charState = castingState[activeCharacter];
    if (!charState?.hero_path) return;

    const bibleRes = await fetch(`${STARSEND_API}/api/bible`);
    const bible = await bibleRes.json();
    const charBible = (bible.characters || {})[activeCharacter] || {};
    const firstPhase = (charBible.phases || [])[0] || {};

    const currentText = {
        visual_description: charBible.visual_description || '',
        wardrobe_description: firstPhase.wardrobe_description || '',
    };

    const res = await fetch(`${STARSEND_API}/api/project/${AppState.project}/bible/propose-visual-sync`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            char_id: activeCharacter,
            image_path: charState.hero_path,
            current_text: currentText,
            sync_type: 'hero',
        }),
    });
    const data = await res.json();

    if (data.error) {
        alert('Sync failed: ' + data.error);
        return;
    }

    // Show modal (reuse pattern from screen test)
    showHeroSyncModal(charState, data.proposed_changes, currentText);
}
```

**Step 3: Implement modal + commit (similar to screen test)**

Write to bible via PATCH, set `bible_synced: true` in casting_state.

---

### Task 9: Verify end-to-end flow

**Step 1: Start review server**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 editors/review_server.py --project tartarus`

**Step 2: Verify endpoint responds**

Run: `curl -s -X POST http://localhost:8430/api/project/tartarus/bible/propose-visual-sync -H 'Content-Type: application/json' -d '{"char_id":"TORCH","image_path":"output/refs/characters/torch/concept_panels/torch_concept_08.png","current_text":{"wardrobe_description":"salvage hook on belt, heavy canvas jacket"},"sync_type":"phase"}' | python3 -m json.tool`

Expected: JSON response with `proposed_changes` dict.

**Step 3: Verify UI loads**

Open `http://localhost:8420` → Screen Test tab → select a character → verify "SYNC BIBLE" button appears on locked phases.
