---
phase: 22-bionic-reading-mode
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/CompanionCore/DisplayMode.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/BionicRenderer.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/SubtitlePanel.swift
  - plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
  - plugins/claude-tts-companion/Tests/CompanionCoreTests/BionicRendererTests.swift
autonomous: true
requirements:
  - BION-02
  - BION-03
  - BION-04

must_haves:
  truths:
    - "Bionic renderer splits words into bold prefix (40% chars) + regular suffix"
    - "DisplayMode enum has .karaoke, .bionic, .plain cases"
    - "POST /settings/subtitle accepts displayMode field and persists it"
    - "SubtitlePanel renders bionic text when displayMode is .bionic"
    - "Setting displayMode to .bionic automatically sets karaokeEnabled to false and vice versa"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/DisplayMode.swift"
      provides: "DisplayMode enum with karaoke, bionic, plain cases"
      contains: "enum DisplayMode"
    - path: "plugins/claude-tts-companion/Sources/CompanionCore/BionicRenderer.swift"
      provides: "Pure function rendering words with bold prefix"
      exports: ["BionicRenderer"]
    - path: "plugins/claude-tts-companion/Tests/CompanionCoreTests/BionicRendererTests.swift"
      provides: "Unit tests for bionic word splitting"
      contains: "BionicRendererTests"
  key_links:
    - from: "SubtitlePanel.swift"
      to: "BionicRenderer.swift"
      via: "calls BionicRenderer.render() when displayMode == .bionic"
      pattern: "BionicRenderer\\.render"
    - from: "HTTPControlServer.swift"
      to: "SettingsStore.swift"
      via: "displayMode field in SubtitleSettingsUpdate"
      pattern: "displayMode"
    - from: "SettingsStore.swift"
      to: "DisplayMode.swift"
      via: "SubtitleSettings.displayMode property"
      pattern: "displayMode.*DisplayMode"
---

<objective>
Add bionic reading mode to the subtitle system: DisplayMode enum, BionicRenderer, settings persistence, HTTP API endpoint, and SubtitlePanel integration.

Purpose: Users can toggle bold-prefix reading mode via HTTP API for easier subtitle scanning.
Output: Working bionic rendering in SubtitlePanel, toggleable via POST /settings/subtitle with displayMode field.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md

@plugins/claude-tts-companion/Sources/CompanionCore/SubtitlePanel.swift
@plugins/claude-tts-companion/Sources/CompanionCore/SubtitleStyle.swift
@plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
@plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift

<interfaces>
<!-- Key types and contracts the executor needs. -->

From SettingsStore.swift:

```swift
public struct SubtitleSettings: Codable, Sendable {
    var fontSize: String
    var position: String
    var opacity: Double
    var karaokeEnabled: Bool
    var screen: String
}

public final class SettingsStore: @unchecked Sendable {
    func getSettings() -> AppSettings
    func updateSubtitle(_ update: (inout SubtitleSettings) -> Void)
}
```

From HTTPControlServer.swift:

```swift
public struct SubtitleSettingsUpdate: Codable, Sendable {
    var fontSize: String?
    var position: String?
    var opacity: Double?
    var karaokeEnabled: Bool?
    var screen: String?
}
```

From SubtitlePanel.swift:

```swift
@MainActor
public final class SubtitlePanel: NSPanel {
    func show(text: String)
    func highlightWord(at index: Int, in words: [String], isPageTransition: Bool = false)
    func showPages(_ pages: [SubtitlePage], wordTimings: [TimeInterval])
    func showUtterance(_ text: String, wordTimings: [TimeInterval])
    private var settingsStore: SettingsStore?
    var currentFontSizeName: String
}
```

From SubtitleStyle.swift:

```swift
@MainActor
public enum SubtitleStyle {
    static func dynamicCurrentWordFont(_ sizeName: String) -> NSFont
    static func dynamicRegularFont(_ sizeName: String) -> NSFont
    static func font(size: CGFloat, weight: NSFont.Weight) -> NSFont
}
```

</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: DisplayMode enum + BionicRenderer + SettingsStore + HTTP API</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/DisplayMode.swift
    plugins/claude-tts-companion/Sources/CompanionCore/BionicRenderer.swift
    plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
    plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
    plugins/claude-tts-companion/Tests/CompanionCoreTests/BionicRendererTests.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
    plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
    plugins/claude-tts-companion/Sources/CompanionCore/SubtitleStyle.swift
    plugins/claude-tts-companion/Tests/CompanionCoreTests/SubtitleChunkerTests.swift
  </read_first>
  <behavior>
    - BionicRenderer.boldPrefixLength("hello") == 2 (2/5 = 0.4, ceil gives 2)
    - BionicRenderer.boldPrefixLength("a") == 1 (single char = all bold)
    - BionicRenderer.boldPrefixLength("ab") == 1 (0.4*2=0.8, ceil gives 1)
    - BionicRenderer.boldPrefixLength("testing") == 3 (0.4*7=2.8, ceil gives 3)
    - BionicRenderer.render(words: ["Hello", "world"], fontSizeName: "medium") returns NSAttributedString where "Hel" is bold and "lo" is regular, " " separator, "wor" is bold and "ld" is regular
    - DisplayMode.from(string: "bionic") == .bionic
    - DisplayMode.from(string: "karaoke") == .karaoke
    - DisplayMode.from(string: "plain") == .plain
    - DisplayMode.from(string: "invalid") == .karaoke (default)
  </behavior>
  <action>
    1. Create DisplayMode.swift (per D-01):
       - `public enum DisplayMode: String, Codable, Sendable { case karaoke, bionic, plain }`
       - Add `static func from(string: String) -> DisplayMode` that returns `.karaoke` for unknown values
       - Enum is NOT @MainActor (pure value type, used in SettingsStore which is non-isolated)

    2. Create BionicRenderer.swift:
       - `public enum BionicRenderer` (caseless enum, namespace for static functions)
       - `static func boldPrefixLength(_ word: String) -> Int`: returns `max(1, Int(ceil(Double(word.count) * 0.4)))` for words with count >= 1, returns 0 for empty string
       - `@MainActor static func render(words: [String], fontSizeName: String) -> NSAttributedString`:
         builds NSMutableAttributedString where for each word, the first `boldPrefixLength` characters use `SubtitleStyle.dynamicCurrentWordFont(sizeName)` in white color, and remaining characters use `SubtitleStyle.dynamicRegularFont(sizeName)` in white color. Words separated by spaces. Center-aligned paragraph style with `.byWordWrapping` line break mode (matching SubtitlePanel's existing attributed string pattern).
       - All text in `SubtitleStyle.futureWordColor` (white) -- bionic mode does not use gold/grey coloring

    3. Update SettingsStore.swift:
       - Add `var displayMode: String` to `SubtitleSettings` (String, not enum, for Codable simplicity). Default value: `"karaoke"`
       - Update `SubtitleSettings.default` to include `displayMode: "karaoke"`

    4. Update HTTPControlServer.swift:
       - Add `var displayMode: String?` to `SubtitleSettingsUpdate`
       - In the POST /settings/subtitle handler, add: `if let v = update.displayMode { s.displayMode = v }` (per D-05)
       - Add mutual exclusion logic (per D-03): when displayMode is set to "bionic", also set `s.karaokeEnabled = false`; when set to "karaoke", set `s.karaokeEnabled = true`; when set to "plain", set `s.karaokeEnabled = false`

    5. Create BionicRendererTests.swift:
       - Test boldPrefixLength for words of length 1-10
       - Test empty string returns 0
       - Test render produces correct bold/regular segments (verify by checking attributed string attributes at specific ranges)
       - Use swift-testing framework (@Test, #expect) matching existing test patterns

  </action>
  <verify>
    <automated>cd plugins/claude-tts-companion && swift test --filter BionicRendererTests 2>&1 | tail -20</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "enum DisplayMode" plugins/claude-tts-companion/Sources/CompanionCore/DisplayMode.swift
    - grep -q "enum BionicRenderer" plugins/claude-tts-companion/Sources/CompanionCore/BionicRenderer.swift
    - grep -q "boldPrefixLength" plugins/claude-tts-companion/Sources/CompanionCore/BionicRenderer.swift
    - grep -q "displayMode" plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
    - grep -q "displayMode" plugins/claude-tts-companion/Sources/CompanionCore/HTTPControlServer.swift
    - grep -q "BionicRendererTests" plugins/claude-tts-companion/Tests/CompanionCoreTests/BionicRendererTests.swift
  </acceptance_criteria>
  <done>DisplayMode enum exists with 3 cases. BionicRenderer splits words at 40% boundary. SettingsStore persists displayMode. HTTP API accepts displayMode in subtitle settings. All BionicRenderer tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: SubtitlePanel bionic rendering integration</name>
  <files>
    plugins/claude-tts-companion/Sources/CompanionCore/SubtitlePanel.swift
  </files>
  <read_first>
    plugins/claude-tts-companion/Sources/CompanionCore/SubtitlePanel.swift
    plugins/claude-tts-companion/Sources/CompanionCore/BionicRenderer.swift
    plugins/claude-tts-companion/Sources/CompanionCore/DisplayMode.swift
    plugins/claude-tts-companion/Sources/CompanionCore/SettingsStore.swift
  </read_first>
  <action>
    1. Add a computed property `currentDisplayMode` to SubtitlePanel:
       ```swift
       private var currentDisplayMode: DisplayMode {
           DisplayMode.from(string: settingsStore?.getSettings().subtitle.displayMode ?? "karaoke")
       }
       ```

    2. Update `show(text:)` method (per D-02, BION-03):
       - After setting the font, check `currentDisplayMode`:
       - If `.bionic`: split text into words, call `BionicRenderer.render(words:fontSizeName:)`, set `textField.attributedStringValue` instead of `textField.stringValue`
       - If `.karaoke` or `.plain`: keep existing behavior (plain white text)

    3. Update `highlightWord(at:in:isPageTransition:)` method (per BION-04):
       - At the top, check `currentDisplayMode`:
       - If `.bionic`: call `BionicRenderer.render(words:fontSizeName:)` instead of karaoke coloring, then set textField and return early. This means when bionic mode is active during TTS playback, words show with bold-prefix but no gold highlighting.
       - If `.karaoke`: keep existing gold/grey/white karaoke behavior (unchanged)
       - If `.plain`: render all words in white regular font (no bold prefix, no karaoke colors)

    4. Build verification: `cd plugins/claude-tts-companion && swift build 2>&1 | tail -5`

  </action>
  <verify>
    <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "currentDisplayMode" plugins/claude-tts-companion/Sources/CompanionCore/SubtitlePanel.swift
    - grep -q "BionicRenderer" plugins/claude-tts-companion/Sources/CompanionCore/SubtitlePanel.swift
    - grep -q "\.bionic" plugins/claude-tts-companion/Sources/CompanionCore/SubtitlePanel.swift
  </acceptance_criteria>
  <done>SubtitlePanel reads displayMode from settings. When .bionic, show(text:) renders with bold prefix. When .bionic, highlightWord renders bionic instead of karaoke. swift build succeeds with zero errors.</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift test --filter BionicRendererTests` -- all bionic renderer tests pass
2. `cd plugins/claude-tts-companion && swift build` -- clean build with zero errors
3. `curl -X POST -H 'Content-Type: application/json' -d '{"displayMode":"bionic"}' http://localhost:8780/settings/subtitle` -- returns updated settings with displayMode: bionic
</verification>

<success_criteria>

- DisplayMode enum with .karaoke, .bionic, .plain cases exists and compiles
- BionicRenderer.boldPrefixLength correctly computes 40% ceiling for all word lengths
- SettingsStore persists displayMode to disk
- HTTP API POST /settings/subtitle accepts displayMode parameter
- SubtitlePanel renders bionic text when displayMode is .bionic
- Bionic and karaoke are mutually exclusive (setting bionic disables karaoke, setting karaoke disables bionic)
- swift build and swift test pass
  </success_criteria>

<output>
After completion, create `.planning/phases/22-bionic-reading-mode/22-01-SUMMARY.md`
</output>
