<!-- STATUS: COMPLETE -->

# Phase 20: Polish — Deferrals, Accessibility, and Noir Design

## Goal

Close all open deferred items, fix accessibility gaps, and apply Film Noir decorative polish from `noir_theming_notes.md`. This is the final phase before v0.1.0 release.

## Dependencies

- Phase 19 (tests for phases 13-18) — complete, 1037 tests passing

## Open Deferrals Addressed

All 9 items from `deferred.md`:

1. Campaign overwrite confirmation in TUI → Task 5
2. Themed language in TUI prompts → Task 1
3. Mission count discrepancy / unlock notifications → Task 9
4. Modal keyboard navigation (arrow keys) → Task 2
5. Keyboard shortcut to re-focus command input → Task 2
6. Duplicate thematic mission titles → Task 9
7. Spurious campaign directories → Task 6
8. Command reference header styling → Task 7
9. Re-open closed case functionality → Task 8

## Research Notes

### Textual Focus Management

- `widget.focus()` explicitly moves focus to a widget
- Key bindings with `priority=True` fire regardless of current focus
- `Binding("slash", "focus_input")` can be used with `action_focus_input` to re-focus command input
- `/` is idiomatic (vim-style search) and won't conflict with command input since input captures when focused

### Textual Modal Button Navigation

- Textual's `Button` widget supports Tab focus out of the box
- Arrow key navigation between sibling buttons requires either:
  - Using `on_key` to manually move focus between children
  - Or using `FocusGroup` container (not in Textual — custom needed)
- Simplest approach: handle `left`/`right` arrow keys in `ConfirmModal.on_key` to cycle focus between buttons

### Color Extraction Strategy

Current hardcoded colors in Python (from code exploration):
- `#c9a227` (gold accent) — in content.py, header.py, command_ref.py
- `#5a574d` (dim text) — in content.py, command_ref.py
- `#8a8678` (muted text) — in content.py
- `#e8e4d9` (light text) — in content.py
- `#8b3a3a` (error red) — in content.py
- `#3d3d45` (divider) — in content.py

These match CSS variables in noir.tcss but are duplicated as string literals. Extract to `ThemeConfig.colors` dict.

### Noir Decorative Elements (from noir_theming_notes.md)

Applicable to MVP polish:
- **Dividers:** `━━━✦━━━` (section separators)
- **Bullets:** `•` or `◦` (list items in content)
- **Status icons:** `○` (available), `●` (completed), `🔒` (locked)
- **Progress fill:** `█▓▒░` (category progress bars in status view)
- **Section headers:** Bold with decorative underline
- NOT implementing: ASCII art splash, gradients, frame effects (v1.1+)

### Spurious Campaign Directories

`get_campaign_dir()` in config.py creates `~/.unfamiliar/campaigns/` base dir on every call (line 269: `campaigns_base.mkdir(parents=True, exist_ok=True)`). The function itself doesn't create campaign subdirs — those are created by `orchestrator.py:88`. The investigation needs to trace when `create_campaign()` runs unexpectedly. Most likely cause: user running `unfamiliar investigate` from different CWDs, or a path resolution issue where the same repo gets multiple hashes.

---

## Tasks

### Task 1: Themed language in TUI prompts

Replace remaining hardcoded canonical command names and UI strings with theme aliases/vocabulary.

**Files:** `tui/app.py`, `tui/widgets/content.py`, `tui/screens/confirm.py`

**Action:**
- `app.py` line 574: `"Staying on the case."` → use theme vocabulary
- `app.py` line 580: `confirm_label="Quit"` → use `theme_config.alias("quit")` capitalized
- `content.py` show_help keyboard shortcuts section: replace hardcoded descriptions with theme-aware text
- `confirm.py` line 50: `Button("Cancel", ...)` → accept `cancel_label` parameter, default to themed term
- Check all remaining string literals in app.py for canonical names that should be themed

**Verify:** Run `grep -n "next\|complete\|status\|briefing\|mission\|unlock\|quit\|help" tui/app.py` — no unthemed canonical names in user-facing strings.

**Done when:** All user-visible text in the TUI uses theme aliases and vocabulary. No hardcoded canonical command names in strings the user reads.

---

### Task 2: Keyboard accessibility improvements

Add re-focus shortcut, modal arrow key navigation, focus indicators, and updated help text.

**Files:** `tui/app.py`, `tui/screens/confirm.py`, `tui/widgets/content.py`, `tui/themes/film_noir/noir.tcss`

**Action:**
- `app.py`: Add `Binding("slash", "focus_input", "Focus input", show=False)` to BINDINGS
- `app.py`: Add `action_focus_input()` method that calls `self.query_one(CommandInput).focus()`
- `confirm.py`: In `on_key`, handle `left` and `right` arrow keys to cycle focus between Cancel and Confirm buttons
- `noir.tcss`: Add `:focus` pseudo-class styling for `Button`, `CommandInput` (border-accent or underline)
- `content.py` show_help: Add `/` shortcut to KEYBOARD SHORTCUTS section ("Re-focus command input")
- `content.py` show_help: Add `Tab` to shortcuts ("Move focus between elements")
- `content.py` show_help: Add `←/→` to shortcuts ("Navigate modal buttons")

**Verify:** Launch TUI → press Escape (input blurs) → press `/` (input re-focuses). Open modal → use arrow keys to move between Cancel and Confirm.

**Done when:** All TUI functionality is keyboard-accessible. Focus indicators are visible. Help screen documents all shortcuts.

---

### Task 3: Silent failure handling

Replace silent early returns on `self.db is None` with user-visible error messages.

**Files:** `tui/app.py`

**Action:**
- Identify all `if self.db is None: return` patterns (9 occurrences at lines 200, 245, 301, 364, 372, 380, 440, 486, 516)
- Extract a helper method: `_require_db() -> bool` that checks `self.db is not None and self.campaign is not None`, shows error if not, returns the check result
- Replace all guard clauses with `if not self._require_db(): return`
- Error message: "Database not available. Please restart the application."

**Verify:** All 9 guard sites use the helper. No silent returns on None checks.

**Done when:** Every command handler shows a user-visible error if the database is unavailable instead of silently doing nothing.

---

### Task 4: Extract hardcoded colors to ThemeConfig

Move hex color literals from Python code into a `colors` dict on ThemeConfig so themes can override them.

**Files:** `tui/themes/__init__.py`, `tui/themes/film_noir/theme.py`, `tui/widgets/content.py`, `tui/widgets/header.py`, `tui/widgets/command_ref.py`

**Action:**
- Add `colors: dict[str, str]` field to `ThemeConfig` dataclass (frozen, default_factory)
- Define color keys: `accent`, `accent_dim`, `text`, `text_muted`, `text_dim`, `error`, `divider`, `success`, `info`
- Populate Film Noir colors in `film_noir/theme.py`: `accent=#c9a227`, `text=#e8e4d9`, `text_muted=#8a8678`, `text_dim=#5a574d`, `error=#8b3a3a`, `divider=#3d3d45`, etc.
- Replace all hardcoded hex strings in content.py, header.py, command_ref.py with `self.theme.colors["accent"]` etc.
- Add `color(key: str) -> str` helper method to ThemeConfig (similar to `vocab()` and `alias()`) with fallback

**Verify:** `grep -rn '#[0-9a-f]\{6\}' tui/widgets/ tui/app.py` returns zero matches (no hardcoded hex in Python).

**Done when:** All color references in Python code go through ThemeConfig.colors. Film Noir colors match current values exactly (no visual change).

---

### Task 5: Campaign overwrite confirmation

Prompt the user before silently deleting an ACTIVE campaign that has progress.

**Files:** `campaign/orchestrator.py`, `cli.py`

**Action:**
- In `create_campaign()` (orchestrator.py): Before deleting existing campaign, check if it's ACTIVE and has completed missions
- Add parameter `force: bool = False` to `create_campaign()` — if False and active campaign exists with progress, raise a new `CampaignExistsError` (add to campaign/types.py)
- In `cli.py` `investigate` command: Catch `CampaignExistsError`, use `typer.confirm()` to ask user, retry with `force=True` if confirmed
- INITIALIZING campaigns can still be silently overwritten (no change)

**Verify:** Run `unfamiliar investigate /path/to/repo` on a repo with an active campaign that has completed missions → prompts for confirmation. INITIALIZING campaigns are still silently overwritten.

**Done when:** Active campaigns with progress require explicit user confirmation before being overwritten.

---

### Task 6: Investigate and fix spurious campaign directories

Trace the root cause of unintended campaign directory creation.

**Files:** `config.py`, `campaign/orchestrator.py`, `cli.py` (potentially)

**Action:**
- Add DEBUG logging to `get_campaign_dir()` showing the resolved repo_path and resulting directory
- Add DEBUG logging to `create_campaign()` at directory creation point
- Investigate: Does `get_campaign_dir()` creating the base `campaigns/` dir cause discovery to list empty entries?
- Check: Does `cli.py` resolve `repo_path` correctly? Does it ever default to CWD when no argument is given?
- Check: Does `discover_campaigns()` pick up directories without databases as valid campaigns?
- Fix the root cause:
  - If it's path resolution: add `.resolve()` canonicalization and validation
  - If it's empty directory creation: defer `mkdir` to the point where DB is actually created
  - If it's discovery listing empty dirs: filter to only dirs with valid campaign.db

**Verify:** After fix, `ls ~/.unfamiliar/campaigns/` only shows directories the user explicitly investigated.

**Done when:** Root cause identified and fixed. No spurious campaign directories created.

---

### Task 7: Command reference header and decorative styling

Make the "Commands" header visually distinct and add Noir decorative elements.

**Files:** `tui/widgets/command_ref.py`, `tui/themes/film_noir/noir.tcss`, `tui/themes/__init__.py`, `tui/themes/film_noir/theme.py`

**Action:**
- Add `decorations: dict[str, str]` field to ThemeConfig with keys: `divider`, `divider_heavy`, `bullet`, `section_separator`
- Film Noir values: `divider="━━━✦━━━"`, `divider_heavy="━" * 20`, `bullet="•"`, `section_separator="─" * 20`
- `command_ref.py`: Replace plain "Commands" text with decorative header using theme divider
- `noir.tcss`: Style `.command-title` with larger text effect (padding, bold, border-bottom accent)
- Add a decorative divider between title and command list

**Verify:** Visual inspection — "Commands" header is clearly larger/differentiated from command entries below it.

**Done when:** Command reference panel has a visually distinct header with Noir-themed decorative elements.

---

### Task 8: Re-open closed case command

Add a `reopen` canonical command to change a CLOSED campaign back to ACTIVE.

**Files:** `tui/commands.py`, `tui/app.py`, `campaign/missions.py` (or new function), `tui/themes/film_noir/theme.py`

**Action:**
- Add `reopen_campaign(db: CampaignDB, campaign_id: int) -> None` function to `campaign/missions.py` (or a new `campaign/lifecycle.py` if cleaner)
  - Validates campaign exists and is CLOSED
  - Raises `MissionStatusError` if not CLOSED (or a more specific error)
  - Updates status to ACTIVE
- Add `reopen` to CommandDef list in `commands.py` with Film Noir alias "reopen the case"
- Add `reopen` alias to Film Noir theme: `"reopen": "cold case"`
- Add `_handle_reopen` in `app.py`:
  - Call `reopen_campaign()`
  - Update campaign status in memory
  - Show confirmation message with themed vocabulary
  - Refresh header data

**Verify:** Close a campaign → run `reopen` (or themed alias) → campaign is ACTIVE again with all progress intact.

**Done when:** Users can reopen a closed campaign. Command appears in help and command reference.

---

### Task 9: Mission unlock investigation and title deduplication

Debug the mission count discrepancy and add post-generation title dedup.

**Files:** `campaign/missions.py`, `llm/missions/validation.py`

**Action:**
- Add detailed DEBUG logging to `_unlock_eligible_dependents()`:
  - Log each dependent found and its prerequisite check result
  - Log when a dependent is skipped (already AVAILABLE or COMPLETED)
  - Log total: "X dependents found, Y unlocked"
- In `complete_mission()`: Log unlocked_ids count and mission IDs before returning
- In `validation.py`: Add `_deduplicate_titles()` step to `validate_category_missions()`:
  - If two missions have the same `title` (thematic), append a disambiguation suffix (e.g., "The Testing Grounds (II)")
  - Log a warning when dedup triggers
- Ensure the toast notification in app.py fires correctly when `result.unlocked_ids` is non-empty (trace the flow)

**Verify:** Complete a mission with dependents → DEBUG logs show prerequisite check for each dependent. Generate missions with forced duplicate titles → titles are deduplicated.

**Done when:** Unlock cascade has full DEBUG logging for diagnosis. Duplicate thematic titles are automatically disambiguated.

---

### Task 10: Noir decorative polish in content views

Apply Film Noir visual accents to content area views using themed decorative elements and status icons.

**Files:** `tui/widgets/content.py`, `tui/themes/film_noir/theme.py`, `tui/themes/__init__.py`

**Action:**
- Add `icons: dict[str, str]` to ThemeConfig with keys: `available`, `completed`, `locked`, `in_progress`, `warning`, `error`
- Film Noir values: `available="○"`, `completed="●"`, `locked="🔒"`, `in_progress="◈"`, `warning="⚡"`, `error="✗"`
- `content.py` show_missions: Prefix each mission with status icon from theme
- `content.py` show_status: Add decorative dividers between sections using theme decorations
- `content.py` show_status: Add simple text-based progress indicators per category (e.g., `████░░░░ 50%`)
- `content.py` show_completion: Add decorative divider before/after completion narrative
- `content.py` show_help: Add decorative dividers between sections
- `content.py` show_briefing: Add section divider between briefing sections
- Use `theme.decorations["bullet"]` for list items instead of plain `-`

**Verify:** Visual inspection — mission lists show status icons, status view has decorative dividers and progress bars, help and briefing have section separators.

**Done when:** Content views have Film Noir atmospheric polish with themed icons, dividers, and progress indicators. All decorative elements come from ThemeConfig (not hardcoded).

---

### Task 11: Tests for new Phase 20 functions

Write unit tests for all new public functions and ThemeConfig additions introduced in Tasks 1–10.

**Files:** `tests/test_theme_config.py` (additions), `tests/test_campaign_missions.py` (additions), `tests/test_campaign_orchestrator.py` (additions), `tests/test_llm_validation.py` (additions or new)

**Action:**
- **`reopen_campaign()`** (~4 tests):
  - CLOSED → ACTIVE (happy path, verify status update)
  - Already ACTIVE → raises MissionStatusError
  - INITIALIZING → raises MissionStatusError
  - Nonexistent campaign_id → raises MissionStatusError
- **`CampaignExistsError` + overwrite guard** (~3 tests):
  - ACTIVE campaign with progress → raises CampaignExistsError with correct counts
  - INITIALIZING campaign → silently overwritten (no error)
  - `force=True` → overwrites without error
- **`ThemeConfig.color()`** (~3 tests):
  - Known key returns correct hex value
  - Unknown key returns key itself (fallback)
  - Empty colors dict → all lookups return key
- **`ThemeConfig.decoration()`** (~3 tests):
  - Known key returns decorative string
  - Unknown key returns "---" fallback
  - All Film Noir decoration keys present
- **`ThemeConfig.icon()`** (~3 tests):
  - Known key returns icon character
  - Unknown key returns empty string
  - All Film Noir icon keys present
- **Title deduplication** (~3 tests):
  - No duplicates → titles unchanged
  - Two missions with same title → second gets "(II)" suffix
  - Three duplicates → "(II)" and "(III)" suffixes
- **Unlock cascade logging** (~1 test):
  - Verify complete_mission with dependents returns correct unlocked_ids (existing test may cover — check first, add only if gap exists)

**Test rules:** Per my-style/testing.md — pure unit tests, zero IO, all fields asserted, exact counts.

**Verify:** `pytest tests/test_theme_config.py tests/test_campaign_missions.py tests/test_campaign_orchestrator.py -v` — all new tests pass. Full suite shows no regressions.

**Done when:** ~20 new tests covering all Phase 20 interface contracts. Full suite green.

---

## Interface Contracts

### `reopen_campaign(db, campaign_id)` — Task 8

```python
def reopen_campaign(db: CampaignDB, campaign_id: int) -> None:
    """Reopen a closed campaign, restoring it to ACTIVE status.

    Purpose: Allow users to resume investigation of a previously closed campaign.

    Invariants:
        - Only CLOSED campaigns can be reopened
        - All mission progress is preserved (no data loss)
        - Campaign status transitions: CLOSED → ACTIVE

    Args:
        db: Open campaign database.
        campaign_id: ID of the campaign to reopen.

    Raises:
        MissionStatusError: If campaign not found or not in CLOSED status.

    Edge cases:
        - Campaign already ACTIVE → raises MissionStatusError("already active")
        - Campaign INITIALIZING → raises MissionStatusError("not yet closed")
        - Nonexistent campaign_id → raises MissionStatusError("not found")
    """
```

### `CampaignExistsError` — Task 5

```python
class CampaignExistsError(Exception):
    """Raised when creating a campaign for a repo that already has an active one.

    Purpose: Signal to CLI layer that user confirmation is needed.

    Attributes:
        campaign_dir: Path to existing campaign directory.
        completed_count: Number of completed missions in existing campaign.
        total_count: Total missions in existing campaign.
    """
```

### `ThemeConfig.colors` — Task 4

```python
@dataclass(frozen=True)
class ThemeConfig:
    # ... existing fields ...
    colors: dict[str, str]  # hex color strings keyed by semantic name

    def color(self, key: str) -> str:
        """Get a color by semantic key, with fallback to default.

        Purpose: Provide theme-overridable colors for Python-side Rich styling.

        Valid keys: accent, accent_dim, text, text_muted, text_dim,
                    error, divider, success, info
        Fallback: Returns key itself if not found (allows raw hex as fallback).

        Edge cases:
            - Unknown key → returns the key string unchanged
            - Empty colors dict → all lookups return the key
        """
```

### `ThemeConfig.decorations` — Task 7

```python
@dataclass(frozen=True)
class ThemeConfig:
    # ... existing fields ...
    decorations: dict[str, str]  # decorative Unicode elements

    def decoration(self, key: str) -> str:
        """Get a decorative element by key.

        Valid keys: divider, divider_heavy, bullet, section_separator
        Fallback: Returns simple ASCII equivalent if key not found.

        Edge cases:
            - Unknown key → returns "---" (safe fallback)
        """
```

### `ThemeConfig.icons` — Task 10

```python
@dataclass(frozen=True)
class ThemeConfig:
    # ... existing fields ...
    icons: dict[str, str]  # status indicator characters

    def icon(self, key: str) -> str:
        """Get a status icon by key.

        Valid keys: available, completed, locked, in_progress, warning, error
        Fallback: Returns empty string if key not found.

        Edge cases:
            - Unknown key → returns ""
            - Terminal without Unicode → degrades to question mark (not handled here;
              terminal compatibility is the user's responsibility)
        """
```

### `_require_db()` helper — Task 3

```python
def _require_db(self) -> bool:
    """Check that database and campaign are available, show error if not.

    Purpose: Replace 9 scattered None-check guard clauses with a single helper.

    Returns:
        True if self.db and self.campaign are both available.
        False if either is None (also shows error in ContentArea).

    Invariants:
        - Never raises an exception
        - Always shows user-visible error when returning False
    """
```
