# 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 ` (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`: ```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`: ```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[.. [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** ```bash 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 " ``` --- ### 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 ` (alleen DEBUG): schrijft `-transcripts.png` en `-dictionary.png` (Aluminium links, Graphite rechts) - [ ] **Step 1: Bewerkingsscript uitvoeren** Maak tijdelijk `apply_plan5_edits.py` in de repo-root: ```python #!/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`: ```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: 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.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`: ```swift import AppKit #if DEBUG // `Sst --snapshot ` renders the main window with sample data to -transcripts.png and // -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`: ```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** ```bash 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 " ``` --- ### 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`: ```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`: ```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): ```swift 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`: ```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`: ```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** ```bash 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 " ``` --- ### 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): ```swift 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** ```bash 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 " ``` --- ### 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): ```swift 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) -> 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** ```bash git add Sources/Sst git commit -m "feat: add dictionary view with editor, warnings and undo" -m "Co-Authored-By: Claude Sonnet 5.5 " ``` --- ### 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).