Files
sst/docs/superpowers/plans/2026-10-01-app-5-main-window.md
T

60 KiB
Raw Blame History

App fase 5 — Hoofdvenster Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Het hoofdvenster van de app: vast deck met levende VU-meter, teller, LED-balk en REC/STOP; zijbalk met navigatie en CONTROLES; doorzoekbare transcriptiegeschiedenis met kopieerknop en zichtbare woordenboek-correcties; en het woordenboek met toevoegen, bewerken, verwijderen, zoeken, waarschuwingen en ongedaan maken.

Architecture: Testbare logica (HistoryViewModel, DictionaryViewModel, CorrectionMarkup, StopwatchFormat) staat in SstData; de views in Sources/Sst zijn dun en gebruiken uitsluitend tokens en componenten uit SstUI. Een debug-snapshotmodus (Sst --snapshot) rendert het venster met voorbeeldgegevens in beide afwerkingen, zodat de weergaven te beoordelen zijn zonder rechten of model.

Tech Stack: Swift 6.4 (language mode 5), SwiftUI, Swift Testing.

Spec: docs/superpowers/specs/2026-10-01-macos-app-design.md (secties "Hoofdvenster", "Woordenboek — het ontwerp in detail", "Geschiedenis", "Toegankelijkheid")

Voorwaarde: fase 1 t/m 4 zijn afgerond (de app bouwt en draait met een schil als MainWindow).

Global Constraints

  • Indeling: zijbalk Layout.sidebarWidth met navigatie (Transcripties, Woordenboek) en CONTROLES (Microfoon, Accessibility, Input Monitoring, Model); rechts het deck (altijd zichtbaar) en daaronder de gekozen weergave.
  • Deck: VU-meter 0 VU = −20 dBFS; teller toont opnametijd (in rust de duur van de laatste opname); LED-balk op hetzelfde signaal; ververst 60 Hz alleen tijdens het opnemen; REC blijft ingedrukt zolang er wordt opgenomen; REC uitgeschakeld met reden zolang rechten of model ontbreken; STOP beëindigt elke lopende opname (ook één die met de sneltoets begon).
  • Geschiedenis: nieuwste eerst, per dag gegroepeerd (Nederlandse datums), rij = tijd + tekst + kopieerknop; correcties met amber markering en samenvatting "N correcties · gehoord → geschreven"; geselecteerde rij toont de oorspronkelijke tekst; zoeken (⌘F) hoofdletter- en diakriet-ongevoelig; rechtsklik: kopieer, kopieer origineel, verwijder; ⌫ verwijdert de selectie.
  • Woordenboek: kolommen GEHOORD → SCHRIJF; term toont "—" links; + voegt toe in een editor met live waarschuwingen; dubbelklik bewerkt; ⌫ verwijdert; ⌘Z of "Herstel" maakt ongedaan; zoeken; "Open bestand"; schakelaars Voorrang en Correctie; lampje per rij: rood = fout, amber = waarschuwing, gedoofd = geen of alleen een notitie.
  • Alleen tokens in views (token-scan over Sources/Sst); UI-teksten in Strings.swift.
  • Elke commit eindigt met de trailer Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> (tweede -m).

Bestandsstructuur

Sources/SstData/ViewSupport.swift      (nieuw)   StopwatchFormat, TextSegment, CorrectionMarkup, HistoryViewModel, DictionaryViewModel
Sources/Sst/AppModel.swift             (wijzig via script)  lijst-viewmodellen, preview-modus, STOP-regel
Sources/Sst/Strings.swift              (wijzig via script)  teksten voor lijsten en woordenboek
Sources/Sst/SstApp.swift               (wijzig via script)  @main verhuist naar main.swift
Sources/Sst/main.swift                 (nieuw)   ingang; debug-snapshot
Sources/Sst/Snapshot.swift             (nieuw, alleen DEBUG)
Sources/Sst/ListSupport.swift          (nieuw)   SnapshotMode, ScrollList, SearchField
Sources/Sst/DeckView.swift, Sidebar.swift, MainWindow.swift (vervang), HistoryView.swift, DictionaryView.swift (nieuw)
Tests/SstDataTests/ViewSupportTests.swift (nieuw)

Task 1: Logica achter de weergaven in SstData

Files:

  • Create: Sources/SstData/ViewSupport.swift
  • Test: Tests/SstDataTests/ViewSupportTests.swift

Interfaces:

  • Consumes: fase 2 (HistoryStore, HistorySearch, HistoryGrouping, DictionaryStore, DictionaryFile, EntryWarnings, WordKnowledge), fase 1 (AppliedCorrection).

  • Produces:

    • StopwatchFormat.string(_ seconds: Double) -> String ("mm:ss.t", minuten lopen rond bij 100)
    • TextSegment(text:correction:), CorrectionMarkup.segments(finalText:corrections:) -> [TextSegment] (UTF-16-offsets; ongeldige of overlappende correcties worden overgeslagen)
    • @MainActor HistoryViewModel(store:calendar:) met query, selection, days, totalCount, selectedEntry, refresh(), delete(id:)
    • @MainActor DictionaryViewModel(store:knowledge:) met rows (Row: id, entry, warnings, worst), visibleRows, query, canUndo, errorMessage, reload(), warnings(for:editing:), add(_:) -> UUID?, update(id:to:), remove(id:), undo()
  • Step 1: Schrijf de falende tests

Tests/SstDataTests/ViewSupportTests.swift:

import Foundation
import Testing
import SstCore
@testable import SstData

struct StopwatchFormatTests {
    @Test func formatsMinutesSecondsAndTenths() {
        #expect(StopwatchFormat.string(0) == "00:00.0")
        #expect(StopwatchFormat.string(7.46) == "00:07.4")
        #expect(StopwatchFormat.string(65.2) == "01:05.2")
        #expect(StopwatchFormat.string(-3) == "00:00.0")
        #expect(StopwatchFormat.string(6039.9) == "00:39.9")   // minutes wrap at 100
    }
}

struct CorrectionMarkupTests {
    func correction(_ start: Int, _ length: Int, heard: String = "x", wrote: String = "y") -> AppliedCorrection {
        AppliedCorrection(heard: heard, wrote: wrote, rule: heard, start: start, length: length)
    }

    @Test func noCorrectionsGivesOnePlainSegment() {
        #expect(CorrectionMarkup.segments(finalText: "hallo wereld", corrections: []) == [TextSegment(text: "hallo wereld")])
    }

    @Test func emptyTextGivesNoSegments() {
        #expect(CorrectionMarkup.segments(finalText: "", corrections: [correction(0, 1)]).isEmpty)
    }

    @Test func splitsAroundACorrectionInTheMiddle() {
        let c = correction(11, 11, heard: "cloud code", wrote: "Claude Code")
        let segments = CorrectionMarkup.segments(finalText: "ik gebruik Claude Code vandaag", corrections: [c])
        #expect(segments == [
            TextSegment(text: "ik gebruik "),
            TextSegment(text: "Claude Code", correction: c),
            TextSegment(text: " vandaag"),
        ])
    }

    @Test func handlesCorrectionsAtTheStartAndEnd() {
        let first = correction(0, 3), last = correction(8, 3)
        let segments = CorrectionMarkup.segments(finalText: "abc def ghi", corrections: [last, first])
        #expect(segments.map(\.text) == ["abc", " def ", "ghi"])
        #expect(segments[0].correction == first && segments[2].correction == last && segments[1].correction == nil)
    }

    @Test func skipsOutOfRangeAndOverlappingCorrections() {
        let ok = correction(0, 3), overlapping = correction(2, 3), outside = correction(50, 4), empty = correction(4, 0)
        let segments = CorrectionMarkup.segments(finalText: "abc def", corrections: [ok, overlapping, outside, empty])
        #expect(segments.map(\.text) == ["abc", " def"])
    }

    @Test func offsetsAreUTF16SoEmojiDoNotShiftThem() {
        // "😀 " is 3 UTF-16 units; the corrected word starts at 3.
        let c = correction(3, 6, heard: "wereld", wrote: "wereld")
        let segments = CorrectionMarkup.segments(finalText: "😀 wereld", corrections: [c])
        #expect(segments.map(\.text) == ["😀 ", "wereld"])
    }
}

@MainActor
struct HistoryViewModelTests {
    func store(_ texts: [String]) -> HistoryStore {
        let store = HistoryStore(fileURL: FileManager.default.temporaryDirectory.appending(path: "sst-vm-\(UUID().uuidString)/h.json"))
        for (index, text) in texts.enumerated() {
            store.add(HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + Double(index)), duration: 1, rawText: text, finalText: text))
        }
        return store
    }

    @Test func listsAllEntriesNewestFirstAndFiltersByQuery() {
        let model = HistoryViewModel(store: store(["eerste", "tweede zin", "derde"]))
        #expect(model.totalCount == 3)
        #expect(model.days.flatMap(\.entries).map(\.finalText) == ["derde", "tweede zin", "eerste"])
        model.query = "tweede"
        #expect(model.days.flatMap(\.entries).map(\.finalText) == ["tweede zin"])
        #expect(model.totalCount == 3)
    }

    @Test func selectionIsClearedWhenItDisappearsFromTheFilter() {
        let history = store(["alpha", "beta"])
        let model = HistoryViewModel(store: history)
        model.selection = history.entries.first { $0.finalText == "alpha" }?.id
        #expect(model.selectedEntry?.finalText == "alpha")
        model.query = "beta"
        #expect(model.selection == nil)
    }

    @Test func deleteRemovesFromStoreAndList() {
        let history = store(["a", "b"])
        let model = HistoryViewModel(store: history)
        model.delete(id: history.entries[0].id)
        #expect(history.entries.count == 1)
        #expect(model.totalCount == 1)
    }

    @Test func refreshPicksUpNewEntries() {
        let history = store(["a"])
        let model = HistoryViewModel(store: history)
        history.add(HistoryEntry(date: .now, duration: 1, rawText: "nieuw", finalText: "nieuw"))
        model.refresh()
        #expect(model.totalCount == 2)
    }
}

@MainActor
struct DictionaryViewModelTests {
    func makeModel(_ text: String = "") throws -> (DictionaryViewModel, DictionaryStore) {
        let url = FileManager.default.temporaryDirectory.appending(path: "sst-dict-\(UUID().uuidString)/dictionary.txt")
        try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
        try text.write(to: url, atomically: true, encoding: .utf8)
        let store = DictionaryStore(fileURL: url)
        try store.load()
        return (DictionaryViewModel(store: store, knowledge: FakeKnowledge()), store)
    }

    @Test func rowsCarryWarnings() throws {
        let (model, _) = try makeModel("Supabase\ncloud => Claude\n")
        #expect(model.rows.count == 2)
        #expect(model.rows[0].warnings.isEmpty)
        #expect(model.rows[1].worst == .warning)
    }

    @Test func duplicateIsFlaggedOnTheLaterEntryOnly() throws {
        let (model, _) = try makeModel("cloud code => Claude Code\nCloud-Code => Other\n")
        #expect(model.rows[0].worst != .error)
        #expect(model.rows[1].worst == .error)
    }

    @Test func addUpdateAndRemoveGoThroughTheStore() throws {
        let (model, store) = try makeModel()
        let id = model.add(.term("Vercel"))!
        #expect(store.entries == [term("Vercel")])
        model.update(id: id, to: .correction(heard: "versel", wrote: "Vercel"))
        #expect(store.entries == [pair("versel", "Vercel")])
        model.remove(id: id)
        #expect(store.entries.isEmpty)
        #expect(model.rows.isEmpty)
    }

    @Test func undoRestoresTheLastChangeStepByStep() throws {
        let (model, store) = try makeModel("Supabase\n")
        #expect(!model.canUndo)
        let id = model.add(.term("Vercel"))!
        model.remove(id: id)
        #expect(store.entries == [term("Supabase")])
        model.undo()
        #expect(store.entries == [term("Supabase"), term("Vercel")])
        model.undo()
        #expect(store.entries == [term("Supabase")])
        #expect(!model.canUndo)
        model.undo()   // nothing left: harmless
        #expect(store.entries == [term("Supabase")])
    }

    @Test func searchMatchesBothSidesIgnoringCaseAndDiacritics() throws {
        let (model, _) = try makeModel("cloud code => Claude Code\nCafé\nVercel\n")
        model.query = "CLAUDE"
        #expect(model.visibleRows.count == 1)
        model.query = "cafe"
        #expect(model.visibleRows.count == 1)
        model.query = ""
        #expect(model.visibleRows.count == 3)
    }

    @Test func editorWarningsIgnoreTheEntryBeingEdited() throws {
        let (model, store) = try makeModel("cloud code => Claude Code\n")
        let id = store.entries[0].id
        let editingItself = model.warnings(for: .correction(heard: "cloud code", wrote: "Claude Code"), editing: id)
        #expect(!editingItself.contains { $0.level == .error })
        let asNew = model.warnings(for: .correction(heard: "cloud code", wrote: "Other"), editing: nil)
        #expect(asNew.contains { $0.level == .error })
    }
}
  • Step 2: Draai de tests, moeten falen

Run: swift test --filter "StopwatchFormatTests|CorrectionMarkupTests|HistoryViewModelTests|DictionaryViewModelTests" Expected: compilatiefouten (cannot find 'StopwatchFormat' in scope).

  • Step 3: Implementatie

Sources/SstData/ViewSupport.swift:

import Combine
import Foundation
import SstCore

/// "mm:ss.t" for the counter.
public enum StopwatchFormat {
    public static func string(_ seconds: Double) -> String {
        let tenths = Int(max(0, seconds) * 10)
        return String(format: "%02d:%02d.%d", (tenths / 600) % 100, (tenths / 10) % 60, tenths % 10)
    }
}

public struct TextSegment: Equatable, Sendable {
    public let text: String
    /// Set when this piece was written by the dictionary correction.
    public let correction: AppliedCorrection?

    public init(text: String, correction: AppliedCorrection? = nil) {
        self.text = text
        self.correction = correction
    }
}

public enum CorrectionMarkup {
    /// Splits `finalText` into plain and corrected pieces. Offsets are UTF-16; out-of-range or overlapping ones are skipped.
    public static func segments(finalText: String, corrections: [AppliedCorrection]) -> [TextSegment] {
        let ns = finalText as NSString
        guard ns.length > 0 else { return [] }
        var result: [TextSegment] = []
        var cursor = 0
        for correction in corrections.sorted(by: { $0.start < $1.start }) {
            let end = correction.start + correction.length
            guard correction.length > 0, correction.start >= cursor, end <= ns.length else { continue }
            if correction.start > cursor {
                result.append(TextSegment(text: ns.substring(with: NSRange(location: cursor, length: correction.start - cursor))))
            }
            result.append(TextSegment(text: ns.substring(with: NSRange(location: correction.start, length: correction.length)), correction: correction))
            cursor = end
        }
        if cursor < ns.length {
            result.append(TextSegment(text: ns.substring(from: cursor)))
        }
        return result
    }
}

/// The searchable, day-grouped list behind the transcripts view.
@MainActor
public final class HistoryViewModel: ObservableObject {
    private let store: HistoryStore
    private let calendar: Calendar

    @Published public var query = "" {
        didSet { refresh() }
    }
    @Published public var selection: UUID?
    @Published public private(set) var days: [HistoryDay] = []
    @Published public private(set) var totalCount = 0

    public init(store: HistoryStore, calendar: Calendar = .current) {
        self.store = store
        self.calendar = calendar
        refresh()
    }

    public func refresh() {
        let visible = HistorySearch.filter(store.entries, query: query)
        days = HistoryGrouping.byDay(visible, calendar: calendar)
        totalCount = store.entries.count
        if let selection, !visible.contains(where: { $0.id == selection }) { self.selection = nil }
    }

    public var selectedEntry: HistoryEntry? {
        store.entries.first { $0.id == selection }
    }

    public func delete(id: UUID) {
        store.remove(id: id)
        refresh()
    }
}

/// Dictionary rows with warnings, search, editing and undo.
@MainActor
public final class DictionaryViewModel: ObservableObject {
    public struct Row: Identifiable, Equatable {
        public let id: UUID
        public let entry: DictionaryEntry
        public let warnings: [EntryWarning]

        public var worst: EntryWarning.Level? { warnings.map(\.level).max() }
    }

    private let store: DictionaryStore
    private let knowledge: WordKnowledge
    private var undoStack: [DictionaryFile] = []

    @Published public var query = "" {
        didSet { applyFilter() }
    }
    @Published public private(set) var rows: [Row] = []
    @Published public private(set) var visibleRows: [Row] = []
    @Published public private(set) var canUndo = false
    @Published public var errorMessage: String?

    public init(store: DictionaryStore, knowledge: WordKnowledge) {
        self.store = store
        self.knowledge = knowledge
        reload()
    }

    /// Rebuilds the rows from the store (call after the store changed).
    public func reload() {
        let entries = store.entries
        rows = entries.enumerated().map { index, entry in
            // Only earlier entries count as "already there", so a duplicate is flagged on the later one.
            Row(id: entry.id, entry: entry,
                warnings: EntryWarnings.check(entry.kind, among: Array(entries[..<index]), knowledge: knowledge))
        }
        applyFilter()
    }

    private func applyFilter() {
        let words = HistorySearch.fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
        guard !words.isEmpty else {
            visibleRows = rows
            return
        }
        visibleRows = rows.filter { row in
            let haystack = HistorySearch.fold(row.entry.source + " " + row.entry.wrote)
            return words.allSatisfy { haystack.contains($0) }
        }
    }

    /// Live feedback for the editor; `id` is the entry being edited (nil for a new one).
    public func warnings(for kind: DictionaryEntry.Kind, editing id: UUID?) -> [EntryWarning] {
        EntryWarnings.check(kind, among: store.entries.filter { $0.id != id }, knowledge: knowledge)
    }

    @discardableResult
    public func add(_ kind: DictionaryEntry.Kind) -> UUID? {
        var created: UUID?
        mutate { created = $0.append(kind) }
        return created
    }

    public func update(id: UUID, to kind: DictionaryEntry.Kind) {
        mutate { $0.update(id: id, kind: kind) }
    }

    public func remove(id: UUID) {
        mutate { $0.remove(id: id) }
    }

    public func undo() {
        guard let snapshot = undoStack.popLast() else { return }
        canUndo = !undoStack.isEmpty
        do {
            try store.apply { $0 = snapshot }
        } catch {
            errorMessage = error.localizedDescription
        }
        reload()
    }

    private func mutate(_ change: (inout DictionaryFile) -> Void) {
        let snapshot = store.file
        do {
            try store.apply(change)
            undoStack.append(snapshot)
            canUndo = true
        } catch {
            errorMessage = error.localizedDescription
        }
        reload()
    }
}
  • Step 4: Draai de tests

Run: swift test --filter SstDataTests Expected: alle SstData-tests PASS (17 nieuwe).

  • Step 5: Commit
git add Sources/SstData Tests/SstDataTests
git commit -m "feat: add view-model logic for history and dictionary, correction markup, stopwatch format" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 2: Fundament — modelkoppeling, teksten, lijsthulpen en snapshotmodus

Files:

  • Modify (via script): Sources/Sst/AppModel.swift, Sources/Sst/Strings.swift, Sources/Sst/SstApp.swift
  • Create: Sources/Sst/ListSupport.swift, Sources/Sst/main.swift, Sources/Sst/Snapshot.swift

Interfaces:

  • Consumes: fase 4 (AppModel, Strings, SstApp, MainWindow-schil).

  • Produces:

    • AppModel.historyList: HistoryViewModel, AppModel.dictionaryList: DictionaryViewModel (verversen zelf bij wijzigingen van de stores), AppModel.openDictionaryFile(), AppModel(preview: true)
    • Strings voor lijsten en woordenboek, Strings.open, Strings.permissionsMissing
    • SnapshotMode.isActive, ScrollList { }, SearchField(text:focused:)
    • Sst --snapshot <prefix> (alleen DEBUG): schrijft <prefix>-transcripts.png en <prefix>-dictionary.png (Aluminium links, Graphite rechts)
  • Step 1: Bewerkingsscript uitvoeren

Maak tijdelijk apply_plan5_edits.py in de repo-root:

#!/usr/bin/env python3
"""One-off for phase 5: extends AppModel, Strings and SstApp from their phase-4 state. Run from the repo root; every replacement is asserted."""
from pathlib import Path


def edit(path, replacements):
    p = Path(path)
    s = p.read_text()
    for old, new in replacements:
        assert old in s, f"{path}: pattern not found:\n{old}"
        s = s.replace(old, new, 1)
    p.write_text(s)


# --- AppModel -----------------------------------------------------------------------------------------------
edit("Sources/Sst/AppModel.swift", [
    # the two list view models
    ("    let storage = ModelStorage()\n",
     "    let storage = ModelStorage()\n    let historyList: HistoryViewModel\n    let dictionaryList: DictionaryViewModel\n"),
    # preview mode: `init()` becomes a convenience init
    ('''    init() {
        let settings = AppSettings()
        let policy = InsertionPolicy()
        let knowledge = SystemWordKnowledge()
        let corrector = DictionaryCorrector(knowledge: knowledge)
        let dictionary = DictionaryStore(fileURL: AppPaths.dictionary)
        let history = HistoryStore(fileURL: AppPaths.history)
''', '''    convenience init() {
        self.init(preview: false)
    }

    /// `preview: true` is for snapshots: sample data in a temporary folder, no permission prompts, no model, no hotkey.
    init(preview: Bool) {
        let settings = preview ? AppSettings(defaults: UserDefaults(suiteName: "sst-preview")!) : AppSettings()
        let policy = InsertionPolicy()
        let knowledge = SystemWordKnowledge()
        let corrector = DictionaryCorrector(knowledge: knowledge)
        let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\\(UUID().uuidString)", directoryHint: .isDirectory)
        let dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary)
        let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : AppPaths.history)
'''),
    ('''        self.dictionary = dictionary
        self.history = history
        self.controller''', '''        self.dictionary = dictionary
        self.history = history
        self.historyList = HistoryViewModel(store: history)
        self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge)
        self.controller'''),
    ('''        wire()
        Task { await start() }
    }''', '''        wire()
        if preview {
            loadPreviewData()
        } else {
            Task { await start() }
        }
    }'''),
    # the views refresh when the stores change
    ('''            self.corrector.update(entries: self.dictionary.entries)
            self.dictionaryRevision += 1''', '''            self.corrector.update(entries: self.dictionary.entries)
            self.dictionaryList.reload()
            self.dictionaryRevision += 1'''),
    ("        history.onChange = { [weak self] in self?.historyRevision += 1 }",
     "        history.onChange = { [weak self] in\n            self?.historyRevision += 1\n            self?.historyList.refresh()\n        }"),
    # STOP also ends a recording that the hotkey started
    ('''    func stopWindowRecording() {
        guard policy.windowSession else { return }''', '''    func stopWindowRecording() {
        guard isRecording else { return }'''),
    ("if !permissions.allGranted { return Strings.openSystemSettings }",
     "if !permissions.allGranted { return Strings.permissionsMissing }"),
])
model = Path("Sources/Sst/AppModel.swift")
text = model.read_text().rstrip("\n")
assert text.endswith("}")
model.write_text(text[:-1] + '''
    func openDictionaryFile() {
        NSWorkspace.shared.open(dictionary.fileURL)
    }

    // MARK: preview

    private func loadPreviewData() {
        try? dictionary.load()
        try? dictionary.apply { file in
            file.append(.term("Anthropic"))
            file.append(.term("Supabase"))
            file.append(.correction(heard: "cloud code", wrote: "Claude Code"))
            file.append(.correction(heard: "cloud", wrote: "Claude"))
        }
        let now = Date()
        history.add(HistoryEntry(date: now.addingTimeInterval(-86_400 * 2), duration: 5.1,
                                 rawText: "een oudere notitie over de planning", finalText: "Een oudere notitie over de planning."))
        history.add(HistoryEntry(date: now.addingTimeInterval(-3_600), duration: 7.2,
                                 rawText: "ik wil de meeting verplaatsen naar tomorrow morning",
                                 finalText: "Ik wil de meeting verplaatsen naar tomorrow morning."))
        history.add(HistoryEntry(
            date: now.addingTimeInterval(-300), duration: 4.4,
            rawText: "ik gebruik cloud code met supabase", finalText: "Ik gebruik Claude Code met Supabase.",
            corrections: [
                AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 11, length: 11),
                AppliedCorrection(heard: "supabase", wrote: "Supabase", rule: "Supabase", start: 27, length: 8),
            ]))
        activeVariant = ModelCatalog.defaultVariant
        modelStatus = .ready
        lastDuration = 4.4
        historyList.selection = history.entries.first?.id
    }
}
''')

# --- Strings ------------------------------------------------------------------------------------------------
edit("Sources/Sst/Strings.swift", [
    ('    static let openSystemSettings = "Open Instellingen"\n',
     '    static let openSystemSettings = "Open Instellingen"\n    static let open = "Open"\n    static let permissionsMissing = "Geef eerst de rechten onder Controles"\n'),
    ("    // deck\n", '''    // lists
    static let copy = "Kopieer"
    static let copyOriginal = "Kopieer origineel"
    static let delete = "Verwijder"
    static let original = "Origineel"
    static let noTranscripts = "Nog geen transcripties. Houd de sneltoets ingedrukt en praat."
    static let noResults = "Geen resultaten"
    static let historyOff = "De geschiedenis staat uit (Instellingen → Privacy)."
    static func corrections(_ count: Int) -> String { count == 1 ? "1 correctie" : "\\(count) correcties" }
    static func entries(_ count: Int) -> String { count == 1 ? "1 invoer" : "\\(count) invoeren" }

    // dictionary
    static let heard = "Gehoord"
    static let wrote = "Schrijf"
    static let term = "Term"
    static let correction = "Correctie"
    static let add = "+"
    static let openFile = "Open bestand"
    static let priming = "Voorrang"
    static let correcting = "Correctie"
    static let undo = "Herstel"
    static let save = "Bewaar"
    static let editEntry = "Invoer"
    static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +."

    // deck
'''),
])

# --- SstApp: the entry point moves to main.swift (needed for the debug snapshot mode) -------------------------
edit("Sources/Sst/SstApp.swift", [("@main\nstruct SstApp: App {", "struct SstApp: App {")])
print("phase 5 edits applied")

Run: python3 apply_plan5_edits.py && rm apply_plan5_edits.py Expected: phase 5 edits applied; het script is weg.

  • Step 2: Nieuwe bestanden

Sources/Sst/ListSupport.swift:

import SstUI
import SwiftUI

/// Debug snapshots (`Sst --snapshot`) are drawn by `ImageRenderer`, which cannot draw scroll views or text fields.
/// While rendering one, the views below fall back to plain stacks and text.
@MainActor
enum SnapshotMode {
    static var isActive = false
}

/// A scrolling, lazily built list (a plain stack while a snapshot is rendered).
struct ScrollList<Content: View>: View {
    @ViewBuilder var content: Content

    var body: some View {
        if SnapshotMode.isActive {
            VStack(alignment: .leading, spacing: 0) { content }
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        } else {
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { content }
            }
        }
    }
}

/// The recessed "ZOEK" field.
struct SearchField: View {
    @Binding var text: String
    var focused: FocusState<Bool>.Binding

    var body: some View {
        HStack(spacing: Space.s4) {
            SilkLabel(Strings.search)
            Group {
                if SnapshotMode.isActive {
                    Text(text).frame(maxWidth: .infinity, alignment: .leading)
                } else {
                    TextField("", text: $text)
                        .textFieldStyle(.plain)
                        .focused(focused)
                }
            }
            .textStyle(Typography.body)
            .foregroundStyle(Palette.inkPrimary)
            .padding(Space.s3)
            .background(WellBackground())
        }
    }
}

Sources/Sst/main.swift:

import AppKit

#if DEBUG
// `Sst --snapshot <prefix>` renders the main window with sample data to <prefix>-transcripts.png and
// <prefix>-dictionary.png (Aluminium left, Graphite right) and exits. No prompts, no model, no hotkey.
if let flag = CommandLine.arguments.firstIndex(of: "--snapshot"), CommandLine.arguments.count > flag + 1 {
    MainActor.assumeIsolated { Snapshot.run(prefix: CommandLine.arguments[flag + 1]) }
    exit(0)
}
#endif

SstApp.main()

Sources/Sst/Snapshot.swift:

#if DEBUG
import AppKit
import SstUI
import SwiftUI

@MainActor
enum Snapshot {
    static func run(prefix: String) {
        _ = NSApplication.shared
        SnapshotMode.isActive = true
        let model = AppModel(preview: true)
        for section in AppModel.Section.allCases {
            model.section = section
            let view = HStack(spacing: 0) {
                MainWindow().environment(\.colorScheme, .light)
                MainWindow().environment(\.colorScheme, .dark)
            }
            .environmentObject(model)
            .environmentObject(model.settings)
            .frame(width: Layout.windowDefaultWidth * 2, height: Layout.windowDefaultHeight)

            let renderer = ImageRenderer(content: view)
            renderer.scale = 1
            if let tiff = renderer.nsImage?.tiffRepresentation,
               let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) {
                try? png.write(to: URL(fileURLWithPath: "\(prefix)-\(section.rawValue).png"))
            }
        }
    }
}
#endif
  • Step 3: Bouwen, testen en de snapshot-infrastructuur controleren

Run: swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && ls /tmp/sst-main-*.png Expected: build zonder fouten of waarschuwingen; alle tests PASS (incl. token-scan); twee PNG's. Ze tonen nog de schil van het venster; de echte weergaven volgen in Task 3 t/m 5.

  • Step 4: Commit
git add Sources/Sst
git commit -m "feat: wire list view models into AppModel, add strings, list helpers and debug snapshot mode" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 3: Deck, zijbalk met CONTROLES en het venster

Files:

  • Create: Sources/Sst/DeckView.swift, Sources/Sst/Sidebar.swift, Sources/Sst/HistoryView.swift (tijdelijk), Sources/Sst/DictionaryView.swift (tijdelijk)
  • Replace: Sources/Sst/MainWindow.swift

Interfaces:

  • Consumes: Task 2; SstUI (VUMeterView, SegmentCounterView, LEDBarView, KeyButtonStyle, LampView, panelen); LevelSource, StopwatchFormat.

  • Produces: DeckView, Sidebar, ControlsBlock, MainWindow (zijbalk + deck + gekozen weergave, met het wis-geschiedenis-dialoog), en tijdelijke lege HistoryView(list:) en DictionaryView(list:) die Task 4 en 5 vervangen.

  • Step 1: Bestanden schrijven

Sources/Sst/DeckView.swift:

import SstData
import SstUI
import SwiftUI

/// The transport deck: VU meter, counter, LED bar and the REC/STOP keys. Always visible at the top.
struct DeckView: View {
    @EnvironmentObject private var model: AppModel

    var body: some View {
        HStack(alignment: .top, spacing: Layout.panelGap) {
            instruments
            Spacer(minLength: 0)
            controls
        }
        .padding(Layout.panelPadding)
        .background(PanelBackground())
    }

    private var instruments: some View {
        TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !model.isRecording)) { context in
            let dbfs = Double(model.levelSource.dbfs)
            HStack(alignment: .top, spacing: Layout.panelGap) {
                VUMeterView(levelDbfs: dbfs, isActive: model.isRecording, brand: AppIdentity.brandCaption)
                    .frame(width: MeterTokens.vuWidth)
                VStack(alignment: .leading, spacing: Layout.panelGap) {
                    SilkLabel(Strings.time, style: Typography.labelSmall)
                    SegmentCounterView(StopwatchFormat.string(elapsed(at: context.date)))
                    SilkLabel(Strings.level, style: Typography.labelSmall)
                    LEDBarView(fraction: model.isRecording ? VUScale.fraction(forDbfs: dbfs) : 0)
                }
            }
        }
    }

    private func elapsed(at date: Date) -> TimeInterval {
        if let started = model.recordingStartedAt { return date.timeIntervalSince(started) }
        return model.lastDuration ?? 0
    }

    private var controls: some View {
        VStack(alignment: .trailing, spacing: Layout.panelGap) {
            HStack(spacing: Layout.panelGap) {
                Button { model.toggleWindowRecording() } label: {
                    HStack(spacing: Space.s3) {
                        LampView(isOn: model.isRecording)
                        Text(Strings.record)
                    }
                }
                .buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording))
                .disabled(!model.isRecording && model.recordingBlocker != nil)
                .help(model.recordingBlocker ?? "")

                Button(Strings.stop) { model.stopWindowRecording() }
                    .buttonStyle(KeyButtonStyle())
                    .disabled(!model.isRecording)
            }
            if let error = model.lastError {
                HStack(spacing: Space.s3) {
                    LampView(isOn: true, tint: .amber)
                    Text(error)
                        .textStyle(Typography.meta)
                        .foregroundStyle(Palette.inkSecondary)
                        .multilineTextAlignment(.trailing)
                }
            } else {
                Text(model.statusLine)
                    .textStyle(Typography.meta)
                    .foregroundStyle(Palette.inkSecondary)
                    .multilineTextAlignment(.trailing)
            }
        }
        .frame(maxWidth: Layout.sidebarWidth, alignment: .trailing)
    }
}

Sources/Sst/Sidebar.swift:

import SstUI
import SwiftUI

struct Sidebar: View {
    @EnvironmentObject private var model: AppModel

    var body: some View {
        VStack(alignment: .leading, spacing: Layout.panelGap) {
            SilkLabel(AppIdentity.brandCaption)
            ForEach(AppModel.Section.allCases) { section in
                Button {
                    model.section = section
                } label: {
                    Text(title(for: section)).frame(maxWidth: .infinity)
                }
                .buttonStyle(KeyButtonStyle(latched: model.section == section))
            }
            Spacer()
            ControlsBlock()
        }
        .padding(Layout.panelPadding)
        .frame(width: Layout.sidebarWidth)
    }

    private func title(for section: AppModel.Section) -> String {
        switch section {
        case .transcripts: Strings.transcripts
        case .dictionary: Strings.dictionary
        }
    }
}

/// Four lamps: the three macOS permissions and the speech model.
struct ControlsBlock: View {
    @EnvironmentObject private var model: AppModel

    var body: some View {
        VStack(alignment: .leading, spacing: Space.s3) {
            SilkLabel(Strings.controls)
            SeamDivider()
            ForEach(PermissionMonitor.Kind.allCases) { kind in
                let granted = model.permissions.isGranted(kind)
                HStack(spacing: Space.s3) {
                    LampView(isOn: granted, tint: .amber)
                    Text(kind.title).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
                    Spacer()
                    if !granted {
                        Button(Strings.open) { model.permissions.openSettings(for: kind) }
                            .buttonStyle(KeyButtonStyle())
                            .help(Strings.openSystemSettings)
                    }
                }
            }
            HStack(spacing: Space.s3) {
                LampView(isOn: model.modelReady, tint: .amber)
                Text(Strings.modelLamp).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
                Spacer()
            }
        }
        .onAppear { model.permissions.startPolling() }
        .onDisappear { model.permissions.stopPolling() }
    }
}

Sources/Sst/MainWindow.swift (vervang volledig):

import SstUI
import SwiftUI

struct MainWindow: View {
    @EnvironmentObject private var model: AppModel

    var body: some View {
        ZStack {
            BrushedBackground().ignoresSafeArea()
            HStack(spacing: 0) {
                Sidebar()
                VStack(spacing: Layout.panelGap) {
                    DeckView()
                    switch model.section {
                    case .transcripts: HistoryView(list: model.historyList)
                    case .dictionary: DictionaryView(list: model.dictionaryList)
                    }
                }
                .padding(Layout.panelPadding)
            }
        }
        .confirmationDialog(Strings.clearHistoryConfirm, isPresented: $model.confirmingClearHistory) {
            Button(Strings.erase, role: .destructive) { model.clearHistory() }
            Button(Strings.cancel, role: .cancel) {}
        } message: {
            Text(Strings.clearHistoryDetail)
        }
    }
}

Tijdelijk Sources/Sst/HistoryView.swift:

import SstData
import SstUI
import SwiftUI

// Temporary: replaced in Task 4.
struct HistoryView: View {
    @ObservedObject var list: HistoryViewModel
    var body: some View { PaperBackground() }
}

Tijdelijk Sources/Sst/DictionaryView.swift:

import SstData
import SstUI
import SwiftUI

// Temporary: replaced in Task 5.
struct DictionaryView: View {
    @ObservedObject var list: DictionaryViewModel
    var body: some View { PaperBackground() }
}
  • Step 2: Bouwen, testen en bekijken

Run: swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-transcripts.png Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding (Aluminium links, Graphite rechts) toont links de zijbalk met twee navigatietoetsen (de gekozen is ingedrukt) en onderaan CONTROLES met vier lampjes en korte "Open"-toetsen (geen afgebroken tekst); rechts het deck met VU-meter, teller, LED-balk, REC (uitgeschakeld) en STOP, met de melding "Geef eerst de rechten onder Controles"; daaronder een leeg papieren vlak.

  • Step 3: Commit
git add Sources/Sst
git commit -m "feat: add deck, sidebar with controls and the main window layout" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 4: Transcriptiegeschiedenis

Files:

  • Replace: Sources/Sst/HistoryView.swift

Interfaces:

  • Consumes: Task 1 (HistoryViewModel, CorrectionMarkup), Task 2 (ScrollList, SearchField, Strings).

  • Produces: HistoryView(list:) — zoekveld (focus via AppModel.searchFocusTick), lijst per dag, rij met kopieerknop, correctiemarkering en -samenvatting, contextmenu, ⌫-verwijderen, lege staten.

  • Step 1: Implementatie

Sources/Sst/HistoryView.swift (vervang volledig):

import AppKit
import SstCore
import SstData
import SstUI
import SwiftUI

private let dutch = Locale(identifier: "nl_NL")

struct HistoryView: View {
    @EnvironmentObject private var model: AppModel
    @ObservedObject var list: HistoryViewModel
    @FocusState private var searchFocused: Bool

    var body: some View {
        VStack(spacing: Layout.panelGap) {
            SearchField(text: $list.query, focused: $searchFocused)
            ZStack {
                PaperBackground()
                content
            }
        }
        .onChange(of: model.searchFocusTick) { _, _ in searchFocused = true }
    }

    @ViewBuilder
    private var content: some View {
        if list.days.isEmpty {
            Text(emptyMessage)
                .textStyle(Typography.body)
                .foregroundStyle(Palette.inkSecondary)
                .padding(Layout.panelPadding)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        } else {
            ScrollList {
                ForEach(list.days) { day in
                    Section {
                        ForEach(day.entries) { entry in
                            HistoryRow(entry: entry, isSelected: list.selection == entry.id, list: list)
                        }
                    } header: {
                        SilkLabel(day.day.formatted(Date.FormatStyle(date: .complete, time: .omitted).locale(dutch)))
                            .padding(.horizontal, Space.s4)
                            .padding(.vertical, Space.s3)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .background(Palette.surfacePaper)
                    }
                }
            }
            .focusable()
            .onDeleteCommand {
                if let id = list.selection { list.delete(id: id) }
            }
        }
    }

    private var emptyMessage: String {
        if list.totalCount > 0 { return Strings.noResults }
        return model.settings.keepHistory ? Strings.noTranscripts : Strings.historyOff
    }
}

private struct HistoryRow: View {
    let entry: HistoryEntry
    let isSelected: Bool
    @ObservedObject var list: HistoryViewModel

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: Space.s4) {
            Text(entry.date.formatted(Date.FormatStyle().hour().minute().second().locale(dutch)))
                .textStyle(Typography.counterSmall)
                .foregroundStyle(Palette.inkSecondary)
            VStack(alignment: .leading, spacing: Space.s2) {
                Text(markedText)
                    .textStyle(Typography.reading)
                    .foregroundStyle(Palette.inkPrimary)
                    .textSelection(.enabled)
                if !entry.corrections.isEmpty {
                    SilkLabel(correctionSummary, style: Typography.labelSmall)
                }
                if isSelected && entry.rawText != entry.finalText {
                    Text("\(Strings.original): \(entry.rawText)")
                        .textStyle(Typography.meta)
                        .foregroundStyle(Palette.inkSecondary)
                }
            }
            Spacer(minLength: Space.s4)
            Button(Strings.copy) { copy(entry.finalText) }
                .buttonStyle(KeyButtonStyle())
        }
        .padding(.horizontal, Space.s4)
        .padding(.vertical, Space.s3)
        .frame(minHeight: Size.rowHeight)
        .background(isSelected ? Palette.surfaceSelection : Color.clear)
        .contentShape(Rectangle())
        .onTapGesture { list.selection = entry.id }
        .contextMenu {
            Button(Strings.copy) { copy(entry.finalText) }
            Button(Strings.copyOriginal) { copy(entry.rawText) }
            Divider()
            Button(Strings.delete, role: .destructive) { list.delete(id: entry.id) }
        }
    }

    /// The final text with every dictionary replacement highlighted.
    private var markedText: AttributedString {
        var result = AttributedString()
        for segment in CorrectionMarkup.segments(finalText: entry.finalText, corrections: entry.corrections) {
            var piece = AttributedString(segment.text)
            if segment.correction != nil {
                piece.backgroundColor = Palette.levelAmber.opacity(Opacity.mark)
            }
            result += piece
        }
        return result
    }

    private var correctionSummary: String {
        let changes = entry.corrections.map { "\($0.heard) → \($0.wrote)" }.joined(separator: ", ")
        return "\(Strings.corrections(entry.corrections.count)) · \(changes)"
    }

    private func copy(_ text: String) {
        NSPasteboard.general.clearContents()
        NSPasteboard.general.setString(text, forType: .string)
    }
}
  • Step 2: Bouwen, testen en bekijken

Run: swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-transcripts.png Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding toont drie voorbeeldtranscripties onder twee dagkoppen in het Nederlands ("DONDERDAG 1 OKTOBER 2026"); de nieuwste (geselecteerde) rij heeft "Claude Code" en "Supabase" met amber markering, de regel "2 CORRECTIES · CLOUD CODE → CLAUDE CODE, SUPABASE → SUPABASE" en de oorspronkelijke tekst; elke rij heeft een KOPIEER-toets; tijden staan in mono.

  • Step 3: Commit
git add Sources/Sst
git commit -m "feat: add transcript history view with corrections, search and copy" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 5: Woordenboek met editor

Files:

  • Replace: Sources/Sst/DictionaryView.swift

Interfaces:

  • Consumes: Task 1 (DictionaryViewModel), Task 2 (ScrollList, SearchField, Strings, AppModel.openDictionaryFile()), fase 4 (RockerToggleStyle, AppSettings).

  • Produces: DictionaryView(list:) met zoekveld, +, "Open bestand", schakelaars Voorrang/Correctie, tabel, voettekst met telling en "Herstel", en de editor (sheet) met live waarschuwingen.

  • Step 1: Implementatie

Sources/Sst/DictionaryView.swift (vervang volledig):

import SstData
import SstUI
import SwiftUI

struct DictionaryView: View {
    enum EditorTarget: Identifiable, Equatable {
        case new
        case existing(UUID)

        var id: String {
            switch self {
            case .new: "new"
            case .existing(let id): id.uuidString
            }
        }
    }

    @EnvironmentObject private var model: AppModel
    @EnvironmentObject private var settings: AppSettings
    @ObservedObject var list: DictionaryViewModel
    @State private var selection: UUID?
    @State private var editing: EditorTarget?
    @FocusState private var searchFocused: Bool

    var body: some View {
        VStack(spacing: Layout.panelGap) {
            header
            ZStack {
                PaperBackground()
                table
            }
            footer
        }
        .sheet(item: $editing) { target in
            EntryEditor(list: list, target: target) { editing = nil }
        }
    }

    private var header: some View {
        VStack(spacing: Layout.panelGap) {
            HStack(spacing: Space.s4) {
                SearchField(text: $list.query, focused: $searchFocused)
                Button(Strings.add) { editing = .new }.buttonStyle(KeyButtonStyle())
                Button(Strings.openFile) { model.openDictionaryFile() }.buttonStyle(KeyButtonStyle())
            }
            HStack(spacing: Layout.panelGap) {
                Toggle(isOn: $settings.usePriming) {
                    SilkLabel(Strings.priming)
                }
                .toggleStyle(RockerToggleStyle())
                Toggle(isOn: $settings.useCorrection) {
                    SilkLabel(Strings.correcting)
                }
                .toggleStyle(RockerToggleStyle())
                Spacer()
            }
        }
    }

    private var table: some View {
        VStack(spacing: 0) {
            HStack(spacing: Space.s4) {
                Color.clear.frame(width: Size.iconMedium, height: Size.iconMedium)
                SilkLabel(Strings.heard).frame(maxWidth: .infinity, alignment: .leading)
                SilkLabel(Strings.wrote).frame(maxWidth: .infinity, alignment: .leading)
            }
            .padding(.horizontal, Space.s4)
            .padding(.vertical, Space.s3)
            if list.visibleRows.isEmpty {
                Text(list.rows.isEmpty ? Strings.emptyDictionary : Strings.noResults)
                    .textStyle(Typography.body)
                    .foregroundStyle(Palette.inkSecondary)
                    .padding(Layout.panelPadding)
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
            } else {
                ScrollList {
                    ForEach(list.visibleRows) { row in
                        DictionaryRow(row: row, isSelected: selection == row.id)
                            .onTapGesture(count: 2) { editing = .existing(row.id) }
                            .onTapGesture { selection = row.id }
                    }
                }
            }
        }
        .focusable()
        .onDeleteCommand {
            if let id = selection { list.remove(id: id) }
        }
        .onKeyPress(KeyEquivalent("z"), phases: .down) { press in
            guard press.modifiers == .command, list.canUndo else { return .ignored }
            list.undo()
            return .handled
        }
    }

    private var footer: some View {
        HStack(spacing: Layout.panelGap) {
            SilkLabel(Strings.entries(list.rows.count), style: Typography.labelSmall)
            if let message = list.errorMessage {
                Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
            }
            Spacer()
            Button(Strings.undo) { list.undo() }
                .buttonStyle(KeyButtonStyle())
                .disabled(!list.canUndo)
        }
    }
}

private struct DictionaryRow: View {
    let row: DictionaryViewModel.Row
    let isSelected: Bool

    var body: some View {
        HStack(spacing: Space.s4) {
            lamp
                .frame(width: Size.iconMedium, height: Size.iconMedium)
            if let heard = heard {
                Text(heard).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary)
                    .frame(maxWidth: .infinity, alignment: .leading)
            } else {
                Text("—").textStyle(Typography.code).foregroundStyle(Palette.inkSecondary)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
            Text(row.entry.wrote).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
        .padding(.horizontal, Space.s4)
        .padding(.vertical, Space.s3)
        .frame(minHeight: Size.rowHeight)
        .background(isSelected ? Palette.surfaceSelection : Color.clear)
        .contentShape(Rectangle())
        .help(row.warnings.map(\.message).joined(separator: "\n"))
    }

    private var heard: String? {
        if case .correction(let heard, _) = row.entry.kind { return heard }
        return nil
    }

    @ViewBuilder
    private var lamp: some View {
        switch row.worst {
        case .error: LampView(isOn: true)
        case .warning: LampView(isOn: true, tint: .amber)
        default: LampView(isOn: false, tint: .amber)
        }
    }
}

/// Add or edit one entry, with live warnings.
private struct EntryEditor: View {
    @ObservedObject var list: DictionaryViewModel
    let target: DictionaryView.EditorTarget
    let onClose: () -> Void

    @State private var isCorrection = false
    @State private var heard = ""
    @State private var wrote = ""

    private var editingID: UUID? {
        if case .existing(let id) = target { return id }
        return nil
    }

    private var kind: DictionaryEntry.Kind {
        let write = wrote.trimmingCharacters(in: .whitespaces)
        return isCorrection ? .correction(heard: heard.trimmingCharacters(in: .whitespaces), wrote: write) : .term(write)
    }

    private var warnings: [EntryWarning] {
        let emptySource = isCorrection ? heard.trimmingCharacters(in: .whitespaces).isEmpty : wrote.trimmingCharacters(in: .whitespaces).isEmpty
        guard !emptySource, !wrote.trimmingCharacters(in: .whitespaces).isEmpty else { return [] }
        return list.warnings(for: kind, editing: editingID)
    }

    private var canSave: Bool {
        let hasText = !wrote.trimmingCharacters(in: .whitespaces).isEmpty && (!isCorrection || !heard.trimmingCharacters(in: .whitespaces).isEmpty)
        return hasText && !warnings.contains { $0.level == .error }
    }

    var body: some View {
        ZStack {
            BrushedBackground().ignoresSafeArea()
            VStack(alignment: .leading, spacing: Layout.panelGap) {
                HStack(spacing: Space.s3) {
                    Button(Strings.term) { isCorrection = false }.buttonStyle(KeyButtonStyle(latched: !isCorrection))
                    Button(Strings.correction) { isCorrection = true }.buttonStyle(KeyButtonStyle(latched: isCorrection))
                }
                VStack(alignment: .leading, spacing: Layout.panelGap) {
                    if isCorrection { field(Strings.heard, text: $heard) }
                    field(Strings.wrote, text: $wrote)
                    ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in
                        HStack(alignment: .firstTextBaseline, spacing: Space.s3) {
                            LampView(isOn: warning.level != .note, tint: warning.level == .error ? .record : .amber)
                            Text(warning.message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
                        }
                    }
                }
                .padding(Layout.panelPadding)
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(PanelBackground())
                HStack(spacing: Space.s3) {
                    Spacer()
                    Button(Strings.cancel, action: onClose).buttonStyle(KeyButtonStyle())
                    Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave)
                }
            }
            .padding(Layout.panelPadding)
        }
        .frame(width: Layout.settingsWidth)
        .onAppear(perform: load)
    }

    private func field(_ title: String, text: Binding<String>) -> some View {
        HStack(spacing: Space.s4) {
            SilkLabel(title).frame(width: Size.keyRecWidth, alignment: .leading)
            TextField("", text: text)
                .textFieldStyle(.plain)
                .textStyle(Typography.code)
                .foregroundStyle(Palette.inkPrimary)
                .padding(Space.s3)
                .background(WellBackground())
        }
    }

    private func load() {
        guard let id = editingID, let row = list.rows.first(where: { $0.id == id }) else { return }
        switch row.entry.kind {
        case .term(let term): (isCorrection, heard, wrote) = (false, "", term)
        case .correction(let h, let w): (isCorrection, heard, wrote) = (true, h, w)
        }
    }

    private func save() {
        if let id = editingID {
            list.update(id: id, to: kind)
        } else {
            list.add(kind)
        }
        onClose()
    }
}
  • Step 2: Bouwen, testen en bekijken

Run: swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-dictionary.png Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding toont het woordenboek met vier voorbeeldinvoeren: twee termen ("—" links), "cloud code → Claude Code" en "cloud → Claude"; alleen de laatste heeft een brandend amber lampje (gewoon woord). Zoekveld, + en OPEN BESTAND staan boven; twee tuimelschakelaars (VOORRANG, CORRECTIE); onderaan "4 INVOEREN" en een HERSTEL-toets (uitgeschakeld).

  • Step 3: Commit
git add Sources/Sst
git commit -m "feat: add dictionary view with editor, warnings and undo" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"

Task 6: Handmatige acceptatie met de gebruiker

Dit vraagt rechten, het toetsenbord en het scherm van de gebruiker; subagents voeren dit niet uit.

  • Step 1: Voorbereiden

Run: ./scripts/make-app.sh && open build/Sst.app (rechten en model zijn al in orde uit fase 4). Alle vier CONTROLES-lampjes branden binnen enkele seconden.

  • Step 2: Deck en opnemen
  1. Klik REC: de toets blijft ingedrukt, het lampje brandt, de teller loopt, de wijzer volgt je stem en zakt terug bij stilte; de piek-LED gaat aan boven 0 VU. Klik STOP.
  2. Het resultaat staat in Transcripties; er is niets geplakt (opname vanuit het venster).
  3. Houd Fn ingedrukt in TextEdit en spreek: de tekst wordt geplakt én staat in de lijst. Het deck beweegt ook dan.
  4. Start een opname met de sneltoets en klik STOP in het venster: de opname eindigt en wordt verwerkt.
  5. ⌥⌘R start en stopt een opname.
  • Step 3: Geschiedenis
  1. Zoek met ⌘F (veld krijgt focus); zoek "cafe" vindt "Café"; meerdere woorden in willekeurige volgorde.
  2. KOPIEER zet de tekst op het klembord; rechtsklik: kopieer origineel, verwijder; selecteer een rij en druk ⌫.
  3. Zet in Instellingen → Privacy "Transcripties bewaren" uit, dicteer: er komt geen nieuwe rij; de lege staat verwijst naar Instellingen. Zet terug aan. "Wis geschiedenis…" vraagt bevestiging en wist alles.
  • Step 4: Woordenboek
  1. Voeg met + de correctie cloud code => Claude Code toe: de editor toont een gele melding (alle delen zijn gewone woorden); bewaar.
  2. Zeg "ik gebruik cloud code": de tekst bevat "Claude Code"; de rij in Transcripties toont de markering en de samenvatting. Zeg ook "CloudCode" en "cloud-code": beide worden "Claude Code". Zeg "Cloudflare" en "the cloud": blijven ongewijzigd.
  3. Voeg cloud => Claude toe: waarschuwing (gewoon woord), lampje brandt amber. Verwijder met ⌫ en herstel met ⌘Z.
  4. Voeg de term Supabase toe en zeg het woord: de schrijfwijze is goed.
  5. Zet VOORRANG uit, dan CORRECTIE uit: elk mechanisme heeft zichtbaar effect (zonder CORRECTIE blijft "cloud code" staan).
  6. Open het bestand met OPEN BESTAND, voeg in de editor een regel toe en bewaar: de lijst in Sst werkt binnen een seconde bij. Wijzig een regel in Sst: het bestand verandert, met behoud van #-regels.
  7. Meet de snelheid: zeg 8 seconden spraak met en zonder VOORRANG; noteer beide tijden (doel: onder 1,5 s).
  8. Zeg niets (hou de sneltoets 2 s ingedrukt) met een gevuld woordenboek: er wordt geen tekst ingevoegd.
  • Step 5: Toegankelijkheid en afronden
  1. Alle bediening is met het toetsenbord bereikbaar; de focusring is zichtbaar.
  2. VoiceOver leest "Niveau" en "Tijd" voor; de lampjes zijn verborgen voor VoiceOver.
  3. Zet "Verminder beweging" aan: geen overschrijding van de wijzer.
  4. Draai swift test: alle tests PASS.
  5. Noteer afwijkingen; kleine visuele bijstellingen horen bij deze taak.

Self-review

  • Spec-dekking: deck met meter/teller/LED/REC/STOP en uitschakelreden, zijbalk met CONTROLES, geschiedenis (dagen, markering, samenvatting, origineel bij selectie, zoeken, kopieerknop, contextmenu, ⌫, lege staten, geschiedenis-uit-melding), woordenboek (tabel, +, editor met live waarschuwingen, dubbelklik, ⌫, ⌘Z/Herstel, zoeken, bestand openen, twee schakelaars, lampje per rij), tekst-niet-plakken-voorop en alleen-opslaan (in fase 4), handmatige lijst uit de spec. Niet in scope: overlay, cleanup.
  • Placeholders: geen; de twee tijdelijke views in Task 3 zijn volledig en worden in Task 4 en 5 vervangen.
  • Typeconsistentie: HistoryViewModel/DictionaryViewModel (Task 1) worden door AppModel (Task 2) en de views (Task 3–5) met dezelfde namen gebruikt; Strings-sleutels in de views bestaan na het script in Task 2; AppSettings wordt aan het hoofdvenster meegegeven in SstApp (fase 4).
  • Risico's: ImageRenderer tekent geen scrollende lijsten of tekstvelden; SnapshotMode vervangt ze alleen tijdens snapshots. Gedrag van ⌘Z en ⌫ op een focusbare lijst en de sheet-editor is alleen handmatig te controleren (Task 6 stap 4).