# URSS Implementation Plan

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

**Goal:** Replace bespoke casting/location flows with a unified RefSelector pattern that handles characters, locations, wardrobe, hair/makeup, and props through one configurable component.

**Architecture:** Generic RefSelector with Type Descriptors (Approach A). One `ref_selector.py` library handles all generation strategies. Server manages `GridSession` state per-session. One `RefSelector` UI component adapts layout per descriptor. Current endpoints remain as backward-compatible aliases.

**Tech Stack:** Python 3 (google-genai SDK), vanilla JS (no framework), JSON state files, Gemini Flash 3.1 (exploration) + NBP (beauty pass/turnaround).

**Design doc:** `docs/plans/2026-03-02-unified-ref-selector-design.md`
**Consultation:** `consultations/urss_architecture/SYNTHESIS.md`

---

## Phase 1: Foundation — Type Descriptors + Generation Library

### Task 1: Create Type Descriptors Config

**Files:**
- Create: `starsend/config/ref_descriptors.json`

**Step 1: Create the config file**

```json
{
  "character": {
    "generation_strategy": "composite_grid",
    "grid_format": "2x3",
    "aspect_ratio": "2:3",
    "model_role": "exploration",
    "temperature": 0.65,
    "prompt_template": "casting_director",
    "diegetic_frame": "A casting director's audition photo array, neutral 18% gray seamless backdrop, flat even studio lighting",
    "ref_handling": {
      "strategy": "vision_extraction",
      "inline_ref": false
    },
    "beauty_pass": true,
    "beauty_pass_temp": 0.2,
    "beauty_pass_model_role": "production"
  },
  "location": {
    "generation_strategy": "parallel_singles",
    "candidates_per_batch": 4,
    "aspect_ratio": "16:9",
    "model_role": "exploration",
    "temperature": 0.7,
    "prompt_template": "location_scout",
    "diegetic_frame": "A cinematic location scout's wide-angle photograph",
    "ref_handling": {
      "strategy": "direct_pass",
      "inline_ref": true
    },
    "stagger_delay_ms": 500
  },
  "wardrobe": {
    "generation_strategy": "composite_grid",
    "grid_format": "2x3",
    "aspect_ratio": "2:3",
    "model_role": "exploration",
    "temperature": 0.45,
    "prompt_template": "costume_designer",
    "diegetic_frame": "A costume designer's flat-lay technical photograph",
    "ref_handling": {
      "strategy": "hybrid",
      "inline_ref": "hero_image",
      "text_modifier": "vision_extraction"
    }
  },
  "hair_makeup": {
    "generation_strategy": "composite_grid",
    "grid_format": "2x2",
    "aspect_ratio": "1:1",
    "model_role": "exploration",
    "temperature": 0.35,
    "prompt_template": "makeup_continuity",
    "diegetic_frame": "A makeup artist's continuity polaroid contact sheet, harsh flash photography, extreme close-up macro shot of the face",
    "ref_handling": {
      "strategy": "hybrid",
      "inline_ref": "hero_image",
      "text_modifier": "vision_extraction"
    }
  },
  "props": {
    "generation_strategy": "composite_grid",
    "grid_format": "3x3",
    "aspect_ratio": "1:1",
    "model_role": "exploration",
    "temperature": 0.6,
    "prompt_template": "prop_master",
    "diegetic_frame": "A prop master's archival inventory grid, shot top-down on a cutting mat",
    "ref_handling": {
      "strategy": "direct_pass",
      "inline_ref": true
    }
  }
}
```

**Step 2: Verify file is valid JSON**

Run: `python3 -c "import json; json.load(open('config/ref_descriptors.json'))"`
Expected: No output (success)

**Step 3: Commit**

```bash
git add config/ref_descriptors.json
git commit -m "feat(urss): add type descriptor configs for 5 asset types"
```

---

### Task 2: Create ref_selector.py — Descriptor Loader + Grid Splitter

**Files:**
- Create: `starsend/lib/ref_selector.py`
- Reference: `starsend/tools/prep_character_angles.py:446-493` (existing `_split_grid_into_panels`)
- Reference: `starsend/tools/prep_character_angles.py:348-443` (existing `_detect_content_bounds`)

This task creates the library skeleton with descriptor loading and grid splitting (no API calls yet).

**Step 1: Create lib/ref_selector.py with descriptor loader and grid splitter**

```python
"""
ref_selector.py — Unified Reference Selection System (URSS) library.

Provides generation functions for all asset types via type descriptors.
Strategies: composite_grid (single image → split), parallel_singles (N calls).
"""

import json
import logging
import os
import time
import uuid
from pathlib import Path
from typing import Optional

from lib.constants import STARSEND_ROOT
from lib.model_profiles import get_model, get_cost

logger = logging.getLogger("starsend.ref_selector")

CONFIG_PATH = STARSEND_ROOT / "config" / "ref_descriptors.json"
OUTPUT_DIR = STARSEND_ROOT / "output" / "refs"

_descriptors_cache = None


def load_descriptor(asset_type: str) -> dict:
    """Load type descriptor for an asset type."""
    global _descriptors_cache
    if _descriptors_cache is None:
        _descriptors_cache = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
    desc = _descriptors_cache.get(asset_type)
    if desc is None:
        raise ValueError(f"Unknown asset type: {asset_type}. Valid: {list(_descriptors_cache.keys())}")
    return desc


def parse_grid_format(grid_format: str) -> tuple:
    """Parse '2x3' → (rows=2, cols=3)."""
    parts = grid_format.lower().split("x")
    return int(parts[0]), int(parts[1])


def candidate_count(descriptor: dict) -> int:
    """How many candidates does one generation produce?"""
    strategy = descriptor["generation_strategy"]
    if strategy == "composite_grid":
        rows, cols = parse_grid_format(descriptor["grid_format"])
        return rows * cols
    elif strategy == "parallel_singles":
        return descriptor["candidates_per_batch"]
    raise ValueError(f"Unknown strategy: {strategy}")


def split_composite_grid(grid_path: Path, grid_format: str, output_dir: Path, prefix: str) -> list:
    """Split a composite grid image into individual panels.

    Reuses the smart label/border detection from prep_character_angles.py.
    Returns list of Path objects for each panel.
    """
    from PIL import Image

    if not grid_path.exists():
        return []

    img = Image.open(grid_path)
    rows, cols = parse_grid_format(grid_format)

    # Detect content bounds (skip any title bands / borders NBP adds)
    top, bottom, left, right = _detect_content_bounds(img)
    content = img.crop((left, top, right, bottom))
    cw, ch = content.size

    col_edges = [round(cw * i / cols) for i in range(cols + 1)]
    row_edges = [round(ch * i / rows) for i in range(rows + 1)]

    output_dir.mkdir(parents=True, exist_ok=True)
    panels = []
    for row in range(rows):
        for col in range(cols):
            panel_num = row * cols + col + 1
            box = (col_edges[col], row_edges[row], col_edges[col + 1], row_edges[row + 1])
            panel = content.crop(box)
            panel_path = output_dir / f"{prefix}_candidate_{panel_num:02d}.png"
            panel.save(panel_path)
            panels.append(panel_path)

    logger.info("Split %s grid into %d panels at %s", grid_format, len(panels), output_dir)
    return panels


def _detect_content_bounds(img):
    """Detect actual content area, skipping title bands and borders.

    Ported from prep_character_angles.py — scans for rows/cols that are
    mostly uniform (label/border) vs varied (content).
    """
    import numpy as np

    arr = np.array(img.convert("RGB"))
    h, w, _ = arr.shape

    def row_variance(y):
        return float(np.std(arr[y, :, :]))

    def col_variance(x):
        return float(np.std(arr[:, x, :]))

    # Scan from top for content start (variance spike)
    threshold = 15.0
    top = 0
    for y in range(min(h // 4, 200)):
        if row_variance(y) > threshold:
            top = max(0, y - 2)
            break

    # Scan from bottom
    bottom = h
    for y in range(h - 1, max(h * 3 // 4, h - 200), -1):
        if row_variance(y) > threshold:
            bottom = min(h, y + 3)
            break

    # Scan from left
    left = 0
    for x in range(min(w // 4, 200)):
        if col_variance(x) > threshold:
            left = max(0, x - 2)
            break

    # Scan from right
    right = w
    for x in range(w - 1, max(w * 3 // 4, w - 200), -1):
        if col_variance(x) > threshold:
            right = min(w, x + 3)
            break

    return top, bottom, left, right
```

**Step 2: Verify import works**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 -c "from lib.ref_selector import load_descriptor, parse_grid_format, candidate_count; d = load_descriptor('character'); print(f'{d[\"grid_format\"]} → {parse_grid_format(d[\"grid_format\"])} → {candidate_count(d)} candidates')"`
Expected: `2x3 → (2, 3) → 6 candidates`

**Step 3: Commit**

```bash
git add lib/ref_selector.py
git commit -m "feat(urss): ref_selector library skeleton with descriptor loader + grid splitter"
```

---

### Task 3: Vision Extraction Function

**Files:**
- Modify: `starsend/lib/ref_selector.py` (add `extract_mood_text()`)
- Reference: `starsend/lib/visual_sync.py` (existing Gemini Vision pattern)

**Step 1: Add vision extraction to ref_selector.py**

Add this function after the existing code:

```python
# ── Vision Extraction ──

_VISION_EXTRACTION_PROMPT = """You are an expert cinematographer and production designer. Analyze this image and generate a highly detailed, comma-separated prompt for an image generation model.

FOCUS STRICTLY ON:
1. Lighting setup, direction, and color grading (e.g., moody neon, harsh overhead sunlight, cool cinematic shadows).
2. Environmental context, background elements, and atmospheric effects (e.g., fog, film grain).
3. Wardrobe style, textures, and fit.
4. Camera angle, lens focal length, and framing.
5. Overall emotional tone, energy, and posture/body language.

CRITICAL DIRECTIVE: DO NOT describe the subject's age, race, gender, facial features, hair color, or specific identity. Replace all references to the person with 'A subject'. Do not use any proper nouns."""


def extract_mood_text(image_path: str) -> str:
    """Extract mood/style text from an image, stripping identity.

    Uses Gemini Flash (text model) with vision to analyze the image
    and produce a text-only mood description safe for casting grids.
    """
    from google import genai
    from google.genai import types

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    image_bytes = Path(image_path).read_bytes()
    suffix = Path(image_path).suffix.lower()
    mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(
        suffix.lstrip("."), "image/png"
    )

    client = genai.Client(api_key=api_key)
    response = client.models.generate_content(
        model=get_model("flash", "text"),
        contents=[
            types.Part.from_bytes(data=image_bytes, mime_type=mime),
            types.Part(text=_VISION_EXTRACTION_PROMPT),
        ],
    )

    mood_text = response.text.strip() if response and response.text else ""
    logger.info("Vision extraction (%d chars): %s...", len(mood_text), mood_text[:100])
    return mood_text
```

**Step 2: Verify it imports cleanly (no API call)**

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

**Step 3: Commit**

```bash
git add lib/ref_selector.py
git commit -m "feat(urss): vision extraction function for mood text (identity-stripped)"
```

---

### Task 4: Candidate Generation — composite_grid Strategy

**Files:**
- Modify: `starsend/lib/ref_selector.py` (add `generate_candidates()`)
- Reference: `starsend/tools/prep_character_angles.py:233-345` (existing concept grid)
- Reference: `starsend/tools/prep_character_angles.py:249-299` (prompt construction)

**Step 1: Add prompt templates and generate_candidates()**

Add to `ref_selector.py`:

```python
# ── Prompt Templates ──

# Maps template names to prompt builders.
# Each builder receives (description, diegetic_frame, mood_text, user_override, **kwargs)
# and returns a complete generation prompt string.

def _build_casting_prompt(description, diegetic_frame, mood_text, user_override,
                          grid_format="2x3", gender=None, **kwargs):
    """Character casting grid prompt (ADR-C01–C04)."""
    rows, cols = parse_grid_format(grid_format)
    count = rows * cols

    # Gender anchor
    if gender and gender.lower() == "female":
        physicality = f"Female. {description}"
    elif gender and gender.lower() == "male":
        physicality = f"Male. {description}"
    else:
        physicality = description

    # Synthetic detection for frame switching
    synthetic_kw = {"android", "cyborg", "robot", "mechanical", "synthetic", "cybernetic",
                    "combat chassis", "alloy", "servos", "hydraulic", "mech"}
    is_synthetic = any(kw in description.lower() for kw in synthetic_kw)

    if is_synthetic:
        texture = ("Stan Winston Studio style practical effects, brushed polycarbonate, "
                   "realistic weathering, mechanical micro-details. NO CGI.")
    else:
        texture = ("Unretouched photorealism. Visible skin pores, peach fuzz, "
                   "micro-imperfections, natural subsurface scattering, matte skin. "
                   "DO NOT AIRBRUSH.")

    mood_section = f"\nMOOD REFERENCE (style only, NOT identity): {mood_text}\n" if mood_text else ""

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single image containing a {rows}x{cols} grid of photos.\n"
        f"DIEGETIC FRAMING: {diegetic_frame}\n"
        f"{count} DIFFERENT interpretations of the same role.\n\n"
        f"PHYSICALITY: {physicality}\n"
        f"{mood_section}\n"
        f"GRID STRUCTURE: {cols} columns by {rows} rows. Strictly isolated panels, no overlapping.\n"
        f"Each panel features a DIFFERENT interpretation (vary facial structure, styling, attitude; "
        f"keep age range identical). Frame as 3/4 medium-full shot.\n\n"
        f"PHOTOGRAPHIC ANCHORS:\n"
        f"- Medium: 35mm film still, shot on Arri Alexa 65, 85mm f/2.8 lens.\n"
        f"- Lighting: Soft, directional studio lighting. 18% neutral gray seamless backdrop.\n"
        f"- Texture (CRITICAL): {texture}\n"
        f"- Negative: DO NOT RENDER. NO ILLUSTRATION. NO 3D MODELS. NO CONCEPT ART."
    )

    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"

    return prompt


def _build_location_prompt(description, diegetic_frame, mood_text, user_override, **kwargs):
    """Location scout prompt for parallel singles."""
    atmosphere = kwargs.get("atmosphere", "")
    lighting = kwargs.get("lighting", "")
    palette = kwargs.get("palette", [])

    parts = [
        f"Cinematic environment concept art.",
        f"Location: {description}.",
        diegetic_frame,
    ]
    if mood_text:
        parts.append(f"Mood reference: {mood_text}.")
    if atmosphere:
        parts.append(f"Atmosphere: {atmosphere}.")
    if lighting:
        parts.append(f"Lighting: {lighting}.")
    if palette:
        parts.append(f"Color palette: {', '.join(palette[:5])}.")
    parts.append("No people, no characters, no figures. Environment only. Photorealistic.")

    prompt = " ".join(parts)
    if user_override:
        prompt += f" [USER OVERRIDE: {user_override}]"
    return prompt


def _build_costume_prompt(description, diegetic_frame, mood_text, user_override,
                          grid_format="2x3", **kwargs):
    """Wardrobe/costume casting grid prompt."""
    rows, cols = parse_grid_format(grid_format)
    count = rows * cols

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single image containing a {rows}x{cols} grid.\n"
        f"DIEGETIC FRAMING: {diegetic_frame}\n"
        f"{count} DIFFERENT wardrobe variations for the same character.\n\n"
        f"WARDROBE BRIEF: {description}\n"
    )
    if mood_text:
        prompt += f"STYLE REFERENCE: {mood_text}\n"
    prompt += (
        f"\nGRID STRUCTURE: {cols} columns by {rows} rows. Strictly isolated panels.\n"
        f"Each panel shows a different interpretation of the wardrobe brief.\n"
        f"Full body shot. Clean white/gray backdrop."
    )
    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"
    return prompt


def _build_makeup_prompt(description, diegetic_frame, mood_text, user_override,
                         grid_format="2x2", **kwargs):
    """Hair/makeup continuity grid prompt."""
    rows, cols = parse_grid_format(grid_format)
    count = rows * cols

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single image containing a {rows}x{cols} grid.\n"
        f"DIEGETIC FRAMING: {diegetic_frame}\n"
        f"{count} DIFFERENT hair and makeup variations.\n\n"
        f"LOOK BRIEF: {description}\n"
    )
    if mood_text:
        prompt += f"STYLE REFERENCE: {mood_text}\n"
    prompt += (
        f"\nGRID STRUCTURE: {cols} columns by {rows} rows. Strictly isolated panels.\n"
        f"Extreme close-up face shots. Harsh flash. Maximum detail."
    )
    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"
    return prompt


def _build_prop_prompt(description, diegetic_frame, mood_text, user_override,
                       grid_format="3x3", **kwargs):
    """Prop master inventory grid prompt."""
    rows, cols = parse_grid_format(grid_format)
    count = rows * cols

    prompt = (
        f"CRITICAL DIRECTIVE: Generate a single image containing a {rows}x{cols} grid.\n"
        f"DIEGETIC FRAMING: {diegetic_frame}\n"
        f"{count} DIFFERENT prop design variations.\n\n"
        f"PROP DESCRIPTION: {description}\n"
    )
    if mood_text:
        prompt += f"STYLE REFERENCE: {mood_text}\n"
    prompt += (
        f"\nGRID STRUCTURE: {cols} columns by {rows} rows. Strictly isolated panels.\n"
        f"Top-down on cutting mat. Clean isolation. No hands."
    )
    if user_override:
        prompt += f"\n\n[USER OVERRIDE: {user_override}]"
    return prompt


_PROMPT_BUILDERS = {
    "casting_director": _build_casting_prompt,
    "location_scout": _build_location_prompt,
    "costume_designer": _build_costume_prompt,
    "makeup_continuity": _build_makeup_prompt,
    "prop_master": _build_prop_prompt,
}


# ── Generation ──

def generate_candidates(
    descriptor: dict,
    description: str,
    output_dir: Path,
    prefix: str,
    mood_text: str = "",
    user_override: str = "",
    anchor_image_path: str = None,
    **prompt_kwargs,
) -> dict:
    """Generate candidates using the strategy defined in the descriptor.

    Returns: {
        "grid_path": str or None (composite_grid only),
        "panels": [str, ...],
        "cost": float,
        "model": str,
    }
    """
    strategy = descriptor["generation_strategy"]
    if strategy == "composite_grid":
        return _generate_composite_grid(descriptor, description, output_dir, prefix,
                                        mood_text, user_override, anchor_image_path, **prompt_kwargs)
    elif strategy == "parallel_singles":
        return _generate_parallel_singles(descriptor, description, output_dir, prefix,
                                          mood_text, user_override, anchor_image_path, **prompt_kwargs)
    else:
        raise ValueError(f"Unknown generation strategy: {strategy}")


def _generate_composite_grid(descriptor, description, output_dir, prefix,
                             mood_text, user_override, anchor_image_path, **prompt_kwargs):
    """Generate a composite grid image and split into panels."""
    from google import genai
    from google.genai import types

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    model_id = get_model(descriptor["model_role"], "image")
    cost = get_cost(model_id)

    # Build prompt
    builder = _PROMPT_BUILDERS[descriptor["prompt_template"]]
    prompt = builder(
        description=description,
        diegetic_frame=descriptor["diegetic_frame"],
        mood_text=mood_text,
        user_override=user_override,
        grid_format=descriptor["grid_format"],
        **prompt_kwargs,
    )

    # Build content parts
    ref_handling = descriptor.get("ref_handling", {})
    parts = []

    # Hybrid strategy: include hero image as inline ref for identity lock
    if ref_handling.get("strategy") == "hybrid" and ref_handling.get("inline_ref") == "hero_image":
        if anchor_image_path and Path(anchor_image_path).exists():
            hero_bytes = Path(anchor_image_path).read_bytes()
            suffix = Path(anchor_image_path).suffix.lower().lstrip(".")
            mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(suffix, "image/png")
            parts.append(types.Part.from_bytes(data=hero_bytes, mime_type=mime))
            parts.append(types.Part(text="[IDENTITY REFERENCE — maintain this person's face and body]"))

    # Direct pass: include anchor as inline ref
    if ref_handling.get("strategy") == "direct_pass" and ref_handling.get("inline_ref") is True:
        if anchor_image_path and Path(anchor_image_path).exists():
            ref_bytes = Path(anchor_image_path).read_bytes()
            suffix = Path(anchor_image_path).suffix.lower().lstrip(".")
            mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(suffix, "image/png")
            parts.append(types.Part.from_bytes(data=ref_bytes, mime_type=mime))
            parts.append(types.Part(text="[STYLE REFERENCE]"))

    parts.append(types.Part(text=prompt))

    # API call
    client = genai.Client(api_key=api_key)
    config = types.GenerateContentConfig(
        temperature=descriptor["temperature"],
        response_modalities=["IMAGE", "TEXT"],
        image_config=types.ImageConfig(
            aspect_ratio=descriptor["aspect_ratio"],
        ),
    )

    try:
        response = client.models.generate_content(model=model_id, contents=parts, config=config)
    except Exception as e:
        logger.error("Composite grid generation failed: %s", e)
        return {"grid_path": None, "panels": [], "cost": cost, "model": model_id}

    # Save grid image
    output_dir.mkdir(parents=True, exist_ok=True)
    grid_path = output_dir / f"{prefix}_grid.png"
    if response and response.candidates:
        for cand in response.candidates:
            if cand.content and cand.content.parts:
                for part in cand.content.parts:
                    if hasattr(part, "inline_data") and part.inline_data:
                        grid_path.write_bytes(part.inline_data.data)
                        break

    # Split into panels
    panels_dir = output_dir / "candidates"
    panels = split_composite_grid(grid_path, descriptor["grid_format"], panels_dir, prefix)

    return {
        "grid_path": str(grid_path) if grid_path.exists() else None,
        "panels": [str(p) for p in panels],
        "cost": cost,
        "model": model_id,
    }


def _generate_parallel_singles(descriptor, description, output_dir, prefix,
                               mood_text, user_override, anchor_image_path, **prompt_kwargs):
    """Generate N individual images with staggered dispatch."""
    from google import genai
    from google.genai import types

    api_key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
    if not api_key:
        raise RuntimeError("GEMINI_API_KEY not set")

    model_id = get_model(descriptor["model_role"], "image")
    per_cost = get_cost(model_id)
    count = descriptor["candidates_per_batch"]
    stagger = descriptor.get("stagger_delay_ms", 500) / 1000.0

    builder = _PROMPT_BUILDERS[descriptor["prompt_template"]]
    prompt = builder(
        description=description,
        diegetic_frame=descriptor["diegetic_frame"],
        mood_text=mood_text,
        user_override=user_override,
        **prompt_kwargs,
    )

    # Build content parts
    ref_handling = descriptor.get("ref_handling", {})
    base_parts = []
    if ref_handling.get("inline_ref") is True and anchor_image_path and Path(anchor_image_path).exists():
        ref_bytes = Path(anchor_image_path).read_bytes()
        suffix = Path(anchor_image_path).suffix.lower().lstrip(".")
        mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", "webp": "image/webp"}.get(suffix, "image/png")
        base_parts.append(types.Part.from_bytes(data=ref_bytes, mime_type=mime))
        base_parts.append(types.Part(text="[SCENE ENVIRONMENT REFERENCE]"))

    base_parts.append(types.Part(text=prompt))

    client = genai.Client(api_key=api_key)
    config = types.GenerateContentConfig(
        temperature=descriptor["temperature"],
        response_modalities=["IMAGE", "TEXT"],
        image_config=types.ImageConfig(
            aspect_ratio=descriptor["aspect_ratio"],
        ),
    )

    output_dir.mkdir(parents=True, exist_ok=True)
    panels = []
    total_cost = 0.0

    for i in range(count):
        if i > 0:
            time.sleep(stagger)  # Staggered dispatch for Flash QPS

        try:
            response = client.models.generate_content(model=model_id, contents=base_parts, config=config)
        except Exception as e:
            logger.error("Parallel single %d/%d failed: %s", i + 1, count, e)
            continue

        panel_path = output_dir / "candidates" / f"{prefix}_candidate_{i + 1:02d}.png"
        panel_path.parent.mkdir(parents=True, exist_ok=True)

        if response and response.candidates:
            for cand in response.candidates:
                if cand.content and cand.content.parts:
                    for part in cand.content.parts:
                        if hasattr(part, "inline_data") and part.inline_data:
                            panel_path.write_bytes(part.inline_data.data)
                            panels.append(str(panel_path))
                            total_cost += per_cost
                            break

        logger.info("Generated candidate %d/%d: %s", i + 1, count, panel_path.name)

    return {
        "grid_path": None,
        "panels": panels,
        "cost": total_cost,
        "model": model_id,
    }
```

**Step 2: Verify all imports and prompt builders work**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 -c "from lib.ref_selector import generate_candidates, _PROMPT_BUILDERS; print('Builders:', list(_PROMPT_BUILDERS.keys()))"`
Expected: `Builders: ['casting_director', 'location_scout', 'costume_designer', 'makeup_continuity', 'prop_master']`

**Step 3: Commit**

```bash
git add lib/ref_selector.py
git commit -m "feat(urss): candidate generation — composite_grid + parallel_singles strategies"
```

---

## Phase 2: Server — GridSession Endpoints

### Task 5: GridSession State Model + CRUD Helpers

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

Add GridSession helpers near the existing `_load_casting_state` / `_save_casting_state` (around line 300).

**Step 1: Add GridSession state helpers**

After the existing `_save_casting_state` function, add:

```python
def _create_grid_session(self, project_dir, asset_type, parent_context,
                         anchor_path=None, anchor_source=None, mood_text=None):
    """Create a new GridSession and persist it."""
    import uuid as _uuid
    from lib.ref_selector import load_descriptor, candidate_count

    descriptor = load_descriptor(asset_type)
    session_id = str(_uuid.uuid4())[:8]
    count = candidate_count(descriptor)

    session = {
        "session_id": session_id,
        "asset_type": asset_type,
        "parent_context": parent_context,
        "descriptor": descriptor,
        "anchor": {
            "path": anchor_path,
            "source": anchor_source,
            "mood_text": mood_text or "",
        },
        "candidates": [
            {"slot": i, "path": None, "state": "empty", "re_roll_generation": 0}
            for i in range(count)
        ],
        "re_roll_count": 0,
        "user_overrides": [],
        "collapsed_override": "",
        "hero_locked": False,
        "hero_path": None,
        "beauty_pass_path": None,
        "status": "created",
        "cost": 0.0,
    }

    # Persist
    state = self._load_casting_state(project_dir)
    if "grid_sessions" not in state:
        state["grid_sessions"] = {}
    state["grid_sessions"][session_id] = session
    self._save_casting_state(project_dir, state)

    return session


def _get_grid_session(self, project_dir, session_id):
    """Load a GridSession by ID."""
    state = self._load_casting_state(project_dir)
    return state.get("grid_sessions", {}).get(session_id)


def _update_grid_session(self, project_dir, session_id, updates):
    """Update fields on a GridSession and persist."""
    state = self._load_casting_state(project_dir)
    session = state.get("grid_sessions", {}).get(session_id)
    if not session:
        return None
    session.update(updates)
    self._save_casting_state(project_dir, state)
    return session
```

**Step 2: Verify server still starts**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 -c "import editors.review_server; print('OK')"`

**Step 3: Commit**

```bash
git add editors/review_server.py
git commit -m "feat(urss): GridSession state model with CRUD helpers"
```

---

### Task 6: GridSession API Endpoints

**Files:**
- Modify: `starsend/editors/review_server.py` (add 6 new endpoints + routing)

**Step 1: Add endpoint handlers**

Add after the existing casting endpoint handlers (after `_api_casting_bible_synced`):

```python
# ── URSS GridSession Endpoints ──

def _api_grid_session_create(self, project_name, project_dir, body):
    """POST /api/project/{name}/casting/grid-session

    Create a new grid session. Body:
    {
        asset_type: "character"|"location"|"wardrobe"|"hair_makeup"|"props",
        parent_context: { character_id, phase_id, location_id },
        anchor_image_path: optional (intake/existing hero),
    }
    """
    from lib.ref_selector import load_descriptor, extract_mood_text

    asset_type = body.get("asset_type")
    if not asset_type:
        self._json_response({"error": "asset_type required"}, 400)
        return

    parent_context = body.get("parent_context", {})
    anchor_path = body.get("anchor_image_path")
    anchor_source = None
    mood_text = None

    # If anchor image provided, run vision extraction based on ref_handling strategy
    if anchor_path:
        anchor_source = "provided"
        descriptor = load_descriptor(asset_type)
        ref_strategy = descriptor.get("ref_handling", {}).get("strategy", "")

        if ref_strategy in ("vision_extraction", "hybrid"):
            # Resolve path
            abs_path = anchor_path
            if anchor_path.startswith("output/"):
                abs_path = str(_STARSEND_OUTPUT / anchor_path.replace("output/", "", 1))
                if not Path(abs_path).exists():
                    abs_path = str(OUTPUT_DIR / anchor_path.replace("output/", "", 1))

            if Path(abs_path).exists():
                try:
                    mood_text = extract_mood_text(abs_path)
                except Exception as e:
                    logger.warning("Vision extraction failed, continuing without: %s", e)
            else:
                logger.warning("Anchor image not found: %s", anchor_path)

    session = self._create_grid_session(
        project_dir, asset_type, parent_context,
        anchor_path=anchor_path, anchor_source=anchor_source, mood_text=mood_text,
    )

    self._json_response({"session": session})


def _api_grid_session_get(self, project_name, project_dir, session_id):
    """GET /api/project/{name}/casting/grid-session/{id}"""
    session = self._get_grid_session(project_dir, session_id)
    if not session:
        self._json_response({"error": f"Session {session_id} not found"}, 404)
        return
    self._json_response({"session": session})


def _api_grid_session_action(self, project_name, project_dir, session_id, body):
    """POST /api/project/{name}/casting/grid-session/{id}/action

    Body: { slot: int, action: "reject"|"keep"|"lock" }
    """
    session = self._get_grid_session(project_dir, session_id)
    if not session:
        self._json_response({"error": f"Session {session_id} not found"}, 404)
        return

    slot = body.get("slot")
    action = body.get("action")
    if slot is None or action not in ("reject", "keep", "lock"):
        self._json_response({"error": "slot (int) and action (reject|keep|lock) required"}, 400)
        return

    candidates = session.get("candidates", [])
    if slot < 0 or slot >= len(candidates):
        self._json_response({"error": f"Invalid slot {slot}"}, 400)
        return

    if action == "lock":
        # Lock: this candidate becomes the hero
        candidates[slot]["state"] = "locked"
        session["hero_locked"] = True
        session["hero_path"] = candidates[slot]["path"]
        session["status"] = "hero_locked"
    else:
        candidates[slot]["state"] = action  # "reject" or "keep"

    self._update_grid_session(project_dir, session_id, {
        "candidates": candidates,
        "hero_locked": session.get("hero_locked", False),
        "hero_path": session.get("hero_path"),
        "status": session.get("status", "active"),
    })

    self._json_response({"session": self._get_grid_session(project_dir, session_id)})


def _api_grid_session_reroll(self, project_name, project_dir, session_id, body):
    """POST /api/project/{name}/casting/grid-session/{id}/reroll

    Body: { override_text: optional string }
    Regenerates rejected/empty slots. Kept slots persist.
    """
    session = self._get_grid_session(project_dir, session_id)
    if not session:
        self._json_response({"error": f"Session {session_id} not found"}, 404)
        return

    override_text = body.get("override_text", "").strip()

    # Update override history (collapse, don't stack)
    overrides = session.get("user_overrides", [])
    if override_text:
        overrides.append(override_text)
    collapsed = ", ".join(overrides) if overrides else ""

    # Mark session as generating
    self._update_grid_session(project_dir, session_id, {
        "status": "generating",
        "user_overrides": overrides,
        "collapsed_override": collapsed,
    })

    self._json_response({"status": "generating", "session_id": session_id})

    # Run generation in background thread
    import threading
    def _run():
        try:
            from lib.ref_selector import generate_candidates, load_descriptor

            descriptor = session["descriptor"]
            parent = session.get("parent_context", {})

            # Determine description from bible
            description = self._get_description_for_session(project_dir, session)

            # Determine which slots need regeneration
            candidates = session.get("candidates", [])
            slots_to_fill = [c["slot"] for c in candidates if c["state"] in ("empty", "rejected")]

            if not slots_to_fill:
                self._update_grid_session(project_dir, session_id, {"status": "active"})
                return

            # Build output directory
            char_id = parent.get("character_id", "").lower() or parent.get("location_id", "").lower() or "unknown"
            asset_type = session["asset_type"]
            out_dir = OUTPUT_DIR / "refs" / _asset_type_dir(asset_type) / char_id

            result = generate_candidates(
                descriptor=descriptor,
                description=description,
                output_dir=out_dir,
                prefix=f"{char_id}_{asset_type}_r{session['re_roll_count'] + 1}",
                mood_text=session.get("anchor", {}).get("mood_text", ""),
                user_override=collapsed,
                anchor_image_path=session.get("anchor", {}).get("path"),
                gender=self._get_gender_for_session(project_dir, session),
            )

            # Map new panels to the empty/rejected slots
            new_panels = result.get("panels", [])
            gen_num = session["re_roll_count"] + 1
            for i, slot_idx in enumerate(slots_to_fill):
                if i < len(new_panels):
                    rel_path = _to_relative_output_path(new_panels[i])
                    candidates[slot_idx] = {
                        "slot": slot_idx,
                        "path": rel_path,
                        "state": "new",
                        "re_roll_generation": gen_num,
                    }

            self._update_grid_session(project_dir, session_id, {
                "candidates": candidates,
                "re_roll_count": gen_num,
                "cost": session.get("cost", 0) + result.get("cost", 0),
                "status": "active",
            })

        except Exception as e:
            logger.error("Grid session reroll failed: %s", e)
            self._update_grid_session(project_dir, session_id, {"status": "error"})

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


def _api_grid_session_lock_hero(self, project_name, project_dir, session_id, body):
    """POST /api/project/{name}/casting/grid-session/{id}/lock-hero

    Body: { slot: int }
    Locks the candidate at the given slot as the hero.
    For characters, triggers beauty pass in background.
    Also updates casting_state.characters for backward compat.
    """
    session = self._get_grid_session(project_dir, session_id)
    if not session:
        self._json_response({"error": f"Session {session_id} not found"}, 404)
        return

    slot = body.get("slot")
    candidates = session.get("candidates", [])
    if slot is None or slot < 0 or slot >= len(candidates):
        self._json_response({"error": "Valid slot required"}, 400)
        return

    hero_path = candidates[slot]["path"]
    if not hero_path:
        self._json_response({"error": "Candidate has no image"}, 400)
        return

    # Lock
    candidates[slot]["state"] = "locked"
    self._update_grid_session(project_dir, session_id, {
        "candidates": candidates,
        "hero_locked": True,
        "hero_path": hero_path,
        "status": "hero_locked",
    })

    # Update backward-compat casting_state
    parent = session.get("parent_context", {})
    char_id = parent.get("character_id", "").upper()
    if char_id and session["asset_type"] == "character":
        state = self._load_casting_state(project_dir)
        if "characters" not in state:
            state["characters"] = {}
        if char_id not in state["characters"]:
            state["characters"][char_id] = {}
        state["characters"][char_id]["hero_path"] = hero_path
        state["characters"][char_id]["status"] = "hero_selected"
        state["characters"][char_id]["hero_source"] = "grid_session"
        state["characters"][char_id]["bible_synced"] = False
        state["characters"][char_id]["grid_session_id"] = session_id
        self._save_casting_state(project_dir, state)

    self._json_response({
        "session": self._get_grid_session(project_dir, session_id),
        "hero_path": hero_path,
    })


# ── Helper functions for GridSession ──

def _get_description_for_session(self, project_dir, session):
    """Get the text description for a grid session from the bible."""
    parent = session.get("parent_context", {})
    asset_type = session["asset_type"]

    bible = {}
    if BIBLE_PATH.exists():
        bible = json.loads(BIBLE_PATH.read_text(encoding="utf-8"))

    if asset_type == "character":
        char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
        return char.get("visual_description", "")
    elif asset_type == "location":
        loc = bible.get("locations", {}).get(parent.get("location_id", ""), {})
        return loc.get("description", "")
    elif asset_type == "wardrobe":
        char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
        phases = char.get("phases", [])
        phase_id = parent.get("phase_id")
        for p in phases:
            if p.get("phase_id") == phase_id:
                return p.get("wardrobe_description", "")
        return phases[0].get("wardrobe_description", "") if phases else ""
    elif asset_type == "hair_makeup":
        char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
        phases = char.get("phases", [])
        phase_id = parent.get("phase_id")
        for p in phases:
            if p.get("phase_id") == phase_id:
                return p.get("hair_makeup", "")
        return phases[0].get("hair_makeup", "") if phases else ""
    elif asset_type == "props":
        return parent.get("prop_description", "")

    return ""


def _get_gender_for_session(self, project_dir, session):
    """Get gender from bible for character sessions."""
    parent = session.get("parent_context", {})
    if session["asset_type"] not in ("character", "wardrobe", "hair_makeup"):
        return None
    bible = {}
    if BIBLE_PATH.exists():
        bible = json.loads(BIBLE_PATH.read_text(encoding="utf-8"))
    char = bible.get("characters", {}).get(parent.get("character_id", ""), {})
    return char.get("gender")


def _asset_type_dir(asset_type):
    """Map asset type to output subdirectory."""
    return {
        "character": "characters",
        "location": "locations",
        "wardrobe": "characters",
        "hair_makeup": "characters",
        "props": "props",
    }.get(asset_type, asset_type)


def _to_relative_output_path(abs_path):
    """Convert absolute path to output/-relative path for state storage."""
    abs_p = str(abs_path)
    starsend_out = str(_STARSEND_OUTPUT)
    if abs_p.startswith(starsend_out):
        return "output/" + abs_p[len(starsend_out):].lstrip("/")
    return abs_p
```

**Step 2: Add routing for the new endpoints**

In the `do_POST` method, add routing for the new grid-session endpoints. Find the casting endpoint routing block and add:

```python
# POST /api/project/{name}/casting/grid-session
if sub == "grid-session":
    self._api_grid_session_create(project_name, project_dir, body)
    return

# POST /api/project/{name}/casting/grid-session/{id}/action
# POST /api/project/{name}/casting/grid-session/{id}/reroll
# POST /api/project/{name}/casting/grid-session/{id}/lock-hero
if sub.startswith("grid-session/"):
    parts = sub.split("/")
    session_id = parts[1] if len(parts) > 1 else None
    action = parts[2] if len(parts) > 2 else None

    if not session_id:
        self._json_response({"error": "Session ID required"}, 400)
        return

    if action == "action":
        self._api_grid_session_action(project_name, project_dir, session_id, body)
    elif action == "reroll":
        self._api_grid_session_reroll(project_name, project_dir, session_id, body)
    elif action == "lock-hero":
        self._api_grid_session_lock_hero(project_name, project_dir, session_id, body)
    else:
        self._json_response({"error": f"Unknown action: {action}"}, 404)
    return
```

In `do_GET`, add:

```python
# GET /api/project/{name}/casting/grid-session/{id}
if sub.startswith("grid-session/"):
    session_id = sub.split("/")[1] if "/" in sub else None
    if session_id:
        self._api_grid_session_get(project_name, project_dir, session_id)
        return
```

**Step 3: Verify server starts without errors**

Run: `cd /Users/joeturnerlin/Dropbox/CLAUDE_PROJECTS/starsend && python3 -c "import editors.review_server; print('OK')"`

**Step 4: Commit**

```bash
git add editors/review_server.py
git commit -m "feat(urss): GridSession API endpoints — create, poll, action, reroll, lock-hero"
```

---

## Phase 3: UI — RefSelector Component

### Task 7: RefSelector Renderer

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

Replace `renderCastingGrid()` with a `RefSelector` renderer that adapts to the descriptor. Keep the old function as a fallback for sessions that don't exist yet.

**Step 1: Add RefSelector rendering function**

After the existing `renderCastingGrid()` function (line 232), add the new RefSelector renderer. The function renders:
- Anchor image in center (composited by UI, never generated)
- Candidate panels around anchor
- Per-candidate action buttons (reject/keep/lock)
- Re-roll bar with optional text input
- Override history

```javascript
// ── RefSelector Component ──

function renderRefSelector(session) {
    if (!session) return renderCastingGrid();  // Fallback to legacy

    const descriptor = session.descriptor || {};
    const anchor = session.anchor || {};
    const candidates = session.candidates || [];
    const gridFormat = descriptor.grid_format || "2x3";
    const [rows, cols] = gridFormat.split('x').map(Number);
    const totalSlots = rows * cols;
    const isGenerating = session.status === 'generating';

    // Determine grid CSS based on format
    const gridCols = descriptor.generation_strategy === 'parallel_singles'
        ? 2  // 2x2 layout for 4 singles
        : cols + 1;  // +1 for anchor column

    const anchorHtml = anchor.path
        ? `<img src="${STARSEND_API}/${escAttr(anchor.path)}" style="width:100%;border-radius:var(--radius-sm);border:2px solid var(--accent-cyan)" alt="Anchor">`
        : `<div style="width:100%;aspect-ratio:${descriptor.aspect_ratio?.replace(':', '/') || '2/3'};background:var(--bg-tertiary);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-family:var(--font-mono);font-size:10px">NO ANCHOR</div>`;

    const candidateCards = candidates.map((c, i) => {
        const stateClass = c.state === 'kept' ? 'ref-kept' : c.state === 'rejected' ? 'ref-rejected' : c.state === 'locked' ? 'ref-locked' : '';
        const hasImage = c.path != null;
        return `
            <div class="ref-candidate ${stateClass}" data-slot="${i}">
                ${hasImage
                    ? `<img src="${STARSEND_API}/${escAttr(c.path)}" alt="Candidate ${i + 1}" style="width:100%;border-radius:var(--radius-sm)">`
                    : `<div style="width:100%;aspect-ratio:1;background:var(--bg-tertiary);border-radius:var(--radius-sm);display:flex;align-items:center;justify-content:center;color:var(--text-dim);font-size:10px">${isGenerating ? '<span class="loading-spinner"></span>' : 'EMPTY'}</div>`
                }
                ${hasImage && !session.hero_locked ? `
                    <div class="ref-actions" style="display:flex;gap:4px;margin-top:4px;justify-content:center">
                        <button class="btn" style="font-size:8px;padding:1px 6px;${c.state === 'rejected' ? 'opacity:0.3' : ''}"
                                onclick="CastingTab.gridAction('${session.session_id}', ${i}, 'reject')" title="Reject">&#10007;</button>
                        <button class="btn" style="font-size:8px;padding:1px 6px;border-color:var(--accent-amber);color:var(--accent-amber);${c.state === 'kept' ? 'background:var(--accent-amber);color:var(--bg-primary)' : ''}"
                                onclick="CastingTab.gridAction('${session.session_id}', ${i}, 'keep')" title="Keep">&#9733;</button>
                        <button class="btn" style="font-size:8px;padding:1px 6px;border-color:var(--accent-green);color:var(--accent-green)"
                                onclick="CastingTab.gridAction('${session.session_id}', ${i}, 'lock')" title="Lock as Hero">&#10003;</button>
                    </div>
                ` : ''}
            </div>
        `;
    }).join('');

    const overrideHistory = session.collapsed_override
        ? `<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);margin-top:8px;padding:4px 8px;background:var(--bg-secondary);border-radius:var(--radius-sm)">Overrides: ${escHtml(session.collapsed_override)}</div>`
        : '';

    const costDisplay = session.cost > 0 ? ` ($${session.cost.toFixed(3)})` : '';

    return `
        <div class="section-title">${escHtml(session.asset_type.toUpperCase().replace('_', '/'))} &mdash; ${escHtml((session.parent_context?.character_id || session.parent_context?.location_id || '').toUpperCase())}</div>

        ${session.hero_locked ? `
            <div style="display:flex;gap:8px;margin-bottom:16px;align-items:center">
                <span class="pill" style="border-color:var(--accent-green);color:var(--accent-green)">HERO LOCKED</span>
                ${session.asset_type === 'character' ? `
                    <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>
                ` : ''}
            </div>
        ` : ''}

        <div style="display:grid;grid-template-columns:1fr ${('1fr '.repeat(cols)).trim()};gap:8px;margin-bottom:16px">
            <div style="display:flex;flex-direction:column;align-items:center">
                <div style="font-family:var(--font-mono);font-size:9px;color:var(--accent-cyan);margin-bottom:4px;text-transform:uppercase">ANCHOR</div>
                ${anchorHtml}
            </div>
            ${candidateCards}
        </div>

        ${!session.hero_locked ? `
            <div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
                <input type="text" id="reroll-override" placeholder="Optional: adjust direction..."
                       style="flex:1;background:var(--bg-secondary);color:var(--text-primary);border:1px solid var(--border-dim);border-radius:var(--radius-sm);padding:6px 10px;font-family:var(--font-mono);font-size:11px">
                <button class="btn btn-primary" style="font-size:10px" ${isGenerating ? 'disabled' : ''}
                        onclick="CastingTab.rerollSession('${session.session_id}')">
                    ${isGenerating ? '<span class="loading-spinner"></span> GENERATING...' : `RE-ROLL (${session.re_roll_count})${costDisplay}`}
                </button>
            </div>
            ${overrideHistory}
        ` : ''}
    `;
}
```

**Step 2: Add JS action handlers to window.CastingTab**

Add to the `window.CastingTab` object (around line 371):

```javascript
async gridAction(sessionId, slot, action) {
    const res = await postCasting(`grid-session/${sessionId}/action`, { slot, action });
    if (res.error) {
        showToast(res.error, true);
        return;
    }
    // Update local state
    const sessions = castingState?.grid_sessions || {};
    if (res.session) sessions[sessionId] = res.session;
    render();
},

async rerollSession(sessionId) {
    const overrideEl = document.getElementById('reroll-override');
    const override_text = overrideEl ? overrideEl.value.trim() : '';
    const res = await postCasting(`grid-session/${sessionId}/reroll`, { override_text });
    if (res.error) {
        showToast(res.error, true);
        return;
    }
    showToast('Generating candidates...');
    // Poll for completion
    pollGridSession(sessionId);
},

async startGridSession(assetType, parentContext, anchorPath) {
    const res = await postCasting('grid-session', {
        asset_type: assetType,
        parent_context: parentContext,
        anchor_image_path: anchorPath || null,
    });
    if (res.error) {
        showToast(res.error, true);
        return;
    }
    if (res.session) {
        if (!castingState.grid_sessions) castingState.grid_sessions = {};
        castingState.grid_sessions[res.session.session_id] = res.session;
        activeGridSession = res.session.session_id;
        render();
    }
},
```

**Step 3: Add polling function and activeGridSession state**

At the top of the IIFE (after `let activeView`), add:

```javascript
let activeGridSession = null;  // Current grid session ID
```

Add outside the CastingTab object:

```javascript
async function pollGridSession(sessionId, interval = 2000, maxAttempts = 60) {
    for (let i = 0; i < maxAttempts; i++) {
        await new Promise(r => setTimeout(r, interval));
        try {
            const res = await fetch(`${STARSEND_API}/api/project/${AppState.project}/casting/grid-session/${sessionId}`);
            const data = await res.json();
            if (data.session) {
                if (!castingState.grid_sessions) castingState.grid_sessions = {};
                castingState.grid_sessions[sessionId] = data.session;
                render();
                if (data.session.status !== 'generating') return;
            }
        } catch (e) { /* continue polling */ }
    }
}
```

**Step 4: Wire renderCastingGrid to use RefSelector when a session exists**

Replace the `renderCastingGrid` call in the main `render()` function to check for an active grid session first:

In the `render()` function, find where `renderCastingGrid()` is called and wrap it:

```javascript
// In the view dispatch (around line 98-105):
case 'grid':
    const gridSession = activeGridSession
        ? (castingState?.grid_sessions || {})[activeGridSession]
        : null;
    content = gridSession ? renderRefSelector(gridSession) : renderCastingGrid();
    break;
```

**Step 5: Commit**

```bash
git add recoil/editors/modules/casting.js
git commit -m "feat(urss): RefSelector UI component with action buttons, reroll bar, polling"
```

---

### Task 8: Asset Type Navigation

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

When a character is selected, show an asset type sub-nav (Casting, Wardrobe, Hair/Makeup, Props) before the grid view.

**Step 1: Add asset sub-nav to grid view**

Add a sub-navigation bar at the top of the grid view that lets users switch between asset types for the selected character. Each button creates or resumes a grid session for that asset type.

```javascript
function renderAssetTypeNav() {
    if (!activeCharacter) return '';
    const types = [
        { id: 'character', label: 'CASTING' },
        { id: 'wardrobe', label: 'WARDROBE' },
        { id: 'hair_makeup', label: 'HAIR/MAKEUP' },
        { id: 'props', label: 'PROPS' },
    ];

    // Find existing sessions for this character
    const sessions = castingState?.grid_sessions || {};
    const charSessions = {};
    for (const [sid, s] of Object.entries(sessions)) {
        if (s.parent_context?.character_id === activeCharacter) {
            charSessions[s.asset_type] = sid;
        }
    }

    return `
        <div style="display:flex;gap:6px;margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid var(--border-dim)">
            ${types.map(t => {
                const hasSession = !!charSessions[t.id];
                const isActive = activeGridSession && sessions[activeGridSession]?.asset_type === t.id;
                return `<button class="btn${isActive ? ' btn-primary' : ''}" style="font-size:9px;padding:3px 10px"
                    onclick="CastingTab.switchAssetType('${t.id}')">${t.label}${hasSession ? ' &#10003;' : ''}</button>`;
            }).join('')}
        </div>
    `;
}
```

Add to `window.CastingTab`:

```javascript
switchAssetType(assetType) {
    // Find existing session for this character + asset type
    const sessions = castingState?.grid_sessions || {};
    for (const [sid, s] of Object.entries(sessions)) {
        if (s.parent_context?.character_id === activeCharacter && s.asset_type === assetType) {
            activeGridSession = sid;
            render();
            return;
        }
    }
    // No session exists — show "start session" prompt
    activeGridSession = null;
    activeAssetType = assetType;
    render();
},
```

Add `let activeAssetType = 'character';` to the state variables at the top.

**Step 2: Wire asset nav into the grid view render**

Update the grid view case in `render()` to prepend the asset nav:

```javascript
case 'grid':
    content = renderAssetTypeNav() + (gridSession ? renderRefSelector(gridSession) : renderStartSession());
    break;
```

Add `renderStartSession()`:

```javascript
function renderStartSession() {
    const state = castingState?.characters?.[activeCharacter] || {};
    const anchorPath = state.hero_path || null;

    return `
        <div style="text-align:center;padding:40px 0">
            <div style="color:var(--text-dim);font-family:var(--font-mono);font-size:11px;margin-bottom:16px">
                NO ${(activeAssetType || 'character').toUpperCase().replace('_', '/')} SESSION
            </div>
            <button class="btn btn-primary" onclick="CastingTab.startGridSession(
                '${activeAssetType || 'character'}',
                { character_id: '${escAttr(activeCharacter)}' },
                ${anchorPath ? `'${escAttr(anchorPath)}'` : 'null'}
            )">
                START ${(activeAssetType || 'character').toUpperCase().replace('_', '/')} SESSION
            </button>
        </div>
    `;
}
```

**Step 3: Commit**

```bash
git add recoil/editors/modules/casting.js
git commit -m "feat(urss): asset type sub-navigation for character casting"
```

---

## Phase 4: Integration + Backward Compatibility

### Task 9: Wire Intake → Anchor Flow

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

When a character has an intake image, auto-create a grid session with the intake as anchor. Modify `_api_casting_characters()` to include grid session data in its response.

**Step 1: Modify _api_casting_characters to include grid_sessions**

In `_api_casting_characters()` (around line 2027), add grid sessions to the response:

```python
# After building the response dict, add grid_sessions
state = self._load_casting_state(project_dir)
response_data["casting_state"]["grid_sessions"] = state.get("grid_sessions", {})
```

**Step 2: Commit**

```bash
git add editors/review_server.py
git commit -m "feat(urss): include grid_sessions in casting characters API response"
```

---

### Task 10: Wire Hero Lock → Casting State

**Files:**
- Already handled in Task 6 (`_api_grid_session_lock_hero` updates `casting_state.characters`)

Verify that locking a hero in a grid session correctly updates the backward-compatible casting state so turnaround, expressions, and bible sync all still work.

**Step 1: Manual integration test via the console**

1. Start the review server: `python3 editors/review_server.py --project tartarus`
2. Open `http://127.0.0.1:8430/console` → Casting tab
3. Click a character → Start Character Session
4. Wait for candidates to generate
5. Lock a candidate as hero
6. Verify hero shows in gallery card
7. Verify SYNC BIBLE button appears
8. Verify turnaround can be generated from the locked hero

**Step 2: Commit any fixes discovered**

---

### Task 11: Update Legacy Grid to 2x3

**Files:**
- Modify: `starsend/tools/prep_character_angles.py`

The existing `_generate_concept_grid()` still uses 3x3 at 1:1. Update it to 2x3 at 2:3 to match the URSS design. This is the "current casting grid also uses 2x3" requirement.

**Step 1: Update grid prompt and config in _generate_concept_grid()**

Change line 285-299 and 305-311:

```python
# Change grid structure in prompt (line ~289):
# OLD: "3x3 grid" → NEW: "2x3 grid (2 rows, 3 columns)"
# OLD: "9 DIFFERENT" → NEW: "6 DIFFERENT"
# OLD: "9 different high-budget" → NEW: "6 different high-budget"

# Change API config (line ~305-311):
# OLD: aspect_ratio="1:1" → NEW: aspect_ratio="2:3"
```

**Step 2: Update _split_grid_into_panels() to handle 2x3**

Change line 476-477:

```python
# OLD:
col_edges = [round(cw * i / 3) for i in range(4)]
row_edges = [round(ch * i / 3) for i in range(4)]
# NEW:
col_edges = [round(cw * i / 3) for i in range(4)]  # 3 columns
row_edges = [round(ch * i / 2) for i in range(3)]   # 2 rows
```

And update the loop at line 483-490:

```python
# OLD: range(3) for both → NEW: range(2) for rows, range(3) for cols
for row in range(2):
    for col in range(3):
        panel_num = row * 3 + col + 1
        ...
```

**Step 3: Update docstrings and comments**

Change references from "3x3" to "2x3" and "9" to "6" throughout the function.

**Step 4: Commit**

```bash
git add tools/prep_character_angles.py
git commit -m "feat(urss): update legacy casting grid from 3x3 to 2x3 at 2:3 aspect ratio"
```

---

## Phase 5: Beauty Pass (Gated on Consult)

### Task 12: Run NBP Prompting Consult

**Prerequisite:** This task requires running `/consult` with Gemini about NBP-specific prompting practices.

**Step 1: Run the consult**

```
/consult "NBP beauty pass prompting — skin texture, imperfections, material detail, what prompt language actually moves NBP for production-quality final renders"
```

**Step 2: Save findings to consultation directory**

Output goes to `consultations/nbp_beauty_pass/SYNTHESIS.md`

**Step 3: Apply findings to ref_selector.py beauty pass prompts**

The exact prompt language depends on consultation findings.

---

### Task 13: Implement Beauty Pass

**Files:**
- Modify: `starsend/lib/ref_selector.py` (add `run_beauty_pass()`)
- Modify: `starsend/editors/review_server.py` (trigger from lock-hero for characters)

**Gated on:** Task 12 (consult findings inform the prompt).

**Step 1: Add beauty_pass function to ref_selector.py**

```python
def run_beauty_pass(hero_path: str, descriptor: dict) -> dict:
    """Run NBP beauty pass on a Flash-generated hero.

    Single NBP call at low temperature to upscale & clarify.
    Prompt language from NBP consultation findings.
    """
    # Implementation depends on Task 12 consultation findings
    pass
```

**Step 2: Wire into lock-hero endpoint**

In `_api_grid_session_lock_hero`, after locking the hero, check if `descriptor.beauty_pass` is True and spawn a background thread to run the beauty pass.

**Step 3: Add beauty pass polling endpoint**

```
GET /api/project/{name}/casting/grid-session/{id}/beauty-pass
```

Returns `{ status: "pending"|"complete", beauty_pass_path: "..." }`

**Step 4: Commit**

```bash
git add lib/ref_selector.py editors/review_server.py
git commit -m "feat(urss): NBP beauty pass for character hero upscale"
```

---

## Phase 6: CSS + Polish

### Task 14: RefSelector CSS

**Files:**
- Modify: `recoil/editors/prepro-console.html` (add CSS for ref-candidate states)

**Step 1: Add CSS classes**

```css
.ref-candidate { position: relative; transition: opacity 0.2s; }
.ref-candidate.ref-rejected { opacity: 0.3; }
.ref-candidate.ref-kept { outline: 2px solid var(--accent-amber); border-radius: var(--radius-sm); }
.ref-candidate.ref-locked { outline: 2px solid var(--accent-green); border-radius: var(--radius-sm); }
.ref-candidate .ref-actions { opacity: 0; transition: opacity 0.15s; }
.ref-candidate:hover .ref-actions { opacity: 1; }
```

**Step 2: Commit**

```bash
git add recoil/editors/prepro-console.html
git commit -m "feat(urss): RefSelector CSS — candidate state styling + hover actions"
```

---

## Summary

| Phase | Tasks | What It Delivers |
|-------|-------|-----------------|
| 1. Foundation | 1–4 | Config, library, vision extraction, generation |
| 2. Server | 5–6 | GridSession state + 6 API endpoints |
| 3. UI | 7–8 | RefSelector component + asset type nav |
| 4. Integration | 9–11 | Intake→anchor, hero→casting_state, legacy 2x3 |
| 5. Beauty Pass | 12–13 | NBP consult + beauty pass implementation |
| 6. Polish | 14 | CSS for candidate states |

**Total tasks:** 14
**Hard gate:** Task 12 (NBP consult) before Task 13 (beauty pass implementation)
**Backward compat:** Legacy endpoints remain, casting_state.characters updated by lock-hero
