diff --git a/docs/superpowers/plans/2026-10-01-app-0-overview.md b/docs/superpowers/plans/2026-10-01-app-0-overview.md new file mode 100644 index 0000000..7b7650c --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-0-overview.md @@ -0,0 +1,38 @@ +# Sst als volwaardige Mac-app — overzicht van de uitvoeringsplannen + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` · **Tokens:** `docs/design/tokens.md` (bron: `Design/tokens.json`) + +De spec is opgesplitst in vijf plannen. Elk levert op zichzelf werkende, geteste software op en bouwt voort op de vorige. Voer ze **in deze volgorde** uit, op één branch (`feat/macos-app`), bij voorkeur met `superpowers:subagent-driven-development`. + +| # | Plan | Levert | Tests daarna (per testtarget) | +|---|---|---|---| +| 1 | `2026-10-01-app-1-engine-hooks.md` | vier additieve raakpunten in de engine: woordenlijst voor de transcriber, niveau-uitvoer, correctie + resultaat-hook, instelbare sneltoets | SstCoreTests 41 · SstWhisperTests 4 | +| 2 | `2026-10-01-app-2-data.md` | `SstData`: woordenboekbestand, correctieronde, waarschuwingen, voorrangslijst, geschiedenis met zoeken | + SstDataTests 54 | +| 3 | `2026-10-01-app-3-ui-kit.md` | tokens als Swift (gegenereerd), componenten, galerij | + SstUITests 15 | +| 4 | `2026-10-01-app-4-shell.md` | echte Mac-app: Dock, menu's, Instellingen, menubalkitem, icoon, ondertekende bundel | SstCoreTests 48 · SstDataTests 56 · SstWhisperTests 11 | +| 5 | `2026-10-01-app-5-main-window.md` | hoofdvenster: deck, zijbalk, geschiedenis, woordenboek | SstDataTests 73 (totaal 147 tests) | + +## Hoe het plan is gecontroleerd + +De code in de plannen is niet geraden: elk plan is samengesteld uit bestanden die in een wegwerp-kopie van de repo zijn gecompileerd en getest (alle fasen samen: 147 tests groen, app bouwt, snapshots van het venster bekeken in beide afwerkingen, bundel gebouwd en ondertekend). Bewerkingen aan bestaande bestanden zijn scripts met asserts die bewezen dezelfde eindtoestand opleveren. Onderweg zijn zo ook fouten in de eerste versie van de plannen gevonden en hersteld (ontbrekende `import`, een te strenge "gewoon woord"-regel, een ontbrekend omgevingsobject, een niet-bestaand-map-bij-voorbeeldgegevens). + +## Gedeelde namen (de interface tussen de plannen) + +| Waar | Naam | +|---|---| +| `SstCore` | `Transcriber.transcribe(_:vocabulary:)`, `AppliedCorrection`, `CorrectionResult`, `TextCorrecting`, `DictationResult`, `DictationController(corrector:vocabularyProvider:)`, `onResult`, `HotkeyBinding`, `AudioRecorder.onLevel`, `AudioLevel`, `GatedInserter`, `SwappableTranscriber`, `LevelSource` | +| `SstData` | `DictionaryFile`, `DictionaryEntry`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistoryStore`, `HistorySearch`, `DictionaryStore`, `DictionaryCorrector`, `AppSettings`, `HistoryViewModel`, `DictionaryViewModel`, `CorrectionMarkup`, `StopwatchFormat` | +| `SstWhisper` | `WhisperKitTranscriber(model:storage:)`, `ModelStorage`, `ModelCatalog`, `ModelOption` | +| `SstUI` | `Palette`, `Space`, `Radius`, `Border`, `Size`, `Layout`, `Opacity`, `Typography`, `Shadows`, `Motion`, `MeterTokens`, `Segment`; `PhysicalSurface`, `PanelBackground`, `WellBackground`, `DisplayBackground`, `PaperBackground`, `SeamDivider`, `SilkLabel`, `BrushedBackground`, `KeyButtonStyle`, `LampView`, `RockerToggleStyle`, `VUMeterView`, `SegmentCounterView`, `LEDBarView` | +| `Sst` | `AppModel`, `Strings`, `AppIdentity`, `PermissionMonitor`, scenes en views | + +## Uitvoeringsadvies + +- Taken met volledige code in het plan (fase 1–3, grote delen van 4 en 5) zijn transcriptie plus testen: een goedkoop model volstaat voor de implementer, een standaardmodel voor de reviewer. +- Visuele taken (fase 3 taak 6, fase 4 taak 7–8, fase 5 taak 3–6) hebben de gebruiker nodig voor het oordeel; de handmatige acceptatietaken zijn daarom bewust niet voor subagents. +- Fase 1 taak 5 stap 6 en fase 3 taak 6 vragen om een microfoon, Fn-toets of het scherm van de gebruiker. +- Na fase 4 moet het oude ad-hoc ondertekende `Sst` eenmalig zijn rechten opnieuw krijgen (`tccutil reset …`, door de gebruiker uit te voeren; staat in het plan). + +## Bewust buiten scope (zie spec) + +Overlay-pill, AI-cleanup, streaming, meerdere woordenboeken, import/export van de geschiedenis, notarisatie, DMG, updates, automatisch starten bij inloggen, toggle-modus, definitieve naam en merk. diff --git a/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md b/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md index 9716a51..484720b 100644 --- a/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md +++ b/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md @@ -62,6 +62,7 @@ Tests/SstWhisperTests/PromptTokensTests.swift (nieuw) `Tests/SstCoreTests/TranscriberVocabularyTests.swift`: ```swift +import Foundation import Testing @testable import SstCore diff --git a/docs/superpowers/plans/2026-10-01-app-4-shell.md b/docs/superpowers/plans/2026-10-01-app-4-shell.md new file mode 100644 index 0000000..48fd6dc --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-4-shell.md @@ -0,0 +1,1874 @@ +# App fase 4 — App-shell 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:** Van menubalk-hulpprogramma naar een echte Mac-app: Dock-icoon, app-menu, hoofdvenster (voorlopig een schil), Instellingen (⌘,) met sneltoets, model en privacy, een secundair menubalkitem, en een dubbelklikbaar `Sst.app` dat ondertekend is met jouw certificaat en in Programma's kan staan. De dicteer-keten gebruikt nu ook het woordenboek en bewaart de geschiedenis. + +**Architecture:** `AppModel` bezit alle services (instellingen, rechten, modelbeheer, woordenboek, geschiedenis, controller, sneltoets) en is de enige brug naar SwiftUI. Testbare lijmcode (`GatedInserter`, `SwappableTranscriber`, `LevelSource`, `AppSettings`, `ModelStorage`) staat in `SstCore`, `SstData` en `SstWhisper`. De views gebruiken alleen tokens; de token-scan uit fase 3 bewaakt dat ook voor `Sources/Sst`. + +**Tech Stack:** Swift 6.4 (language mode 5), SwiftUI (`Window`, `Settings`, `MenuBarExtra`, `Commands`), AppKit, Combine, WhisperKit, `codesign`/`iconutil`. + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "App-shell", "Instellingen", "Rechten en eerste start") + +**Voorwaarde:** fase 1, 2 en 3 zijn afgerond (`DictationController` met corrector/`onResult`, `HotkeyBinding`, `SstData`, `SstUI` met tokens en componenten). + +## Global Constraints + +- Echte app: Dock-icoon, geen `LSUIElement`; bundle-id blijft `local.sst.app`; niet gesandboxed. +- Sluit je het hoofdvenster, dan draait de app door (Dock-icoon en menubalkitem blijven; dicteren werkt). ⌘Q sluit af. Klik op het Dock-icoon opent het venster weer. +- Menubalkitem is secundair: statusregel, sneltoets, "Open Sst", "Instellingen…", "Stop Sst". Geen geschiedenis, geen meter. +- Sneltoetsen alleen: Fn (standaard), Rechter Option, Rechter Command, Rechter Control, Linker Control; alleen vasthouden. Vastleggen door erop te drukken. +- Modellen (exacte WhisperKit-varianten, groottes gemeten bij Hugging Face): `large-v3-v20240930_turbo` (standaard, 1638 MB), `large-v3-v20240930_turbo_632MB` (646 MB), `small` (486 MB). Wisselen laadt het nieuwe model en zet dicteren uit tot het klaar is; mislukt het, dan blijft het vorige actief. +- Tekst wordt niet geplakt wanneer Sst zelf voorop staat of de opname via het venster/⌥⌘R is gestart; hij gaat dan alleen naar de geschiedenis. +- Rechten worden opnieuw gelezen zodra de app actief wordt en elke 2 s zolang een weergave met de lampjes zichtbaar is. +- Ondertekening met de eerste "Apple Development"-identiteit (of `SST_SIGN_IDENTITY`); geen identiteit in de repo; zonder identiteit ad-hoc met een duidelijke waarschuwing. +- UI-teksten Nederlands, allemaal in `Strings.swift`; alleen tokens in views. +- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). + +## Bestandsstructuur + +``` +Package.swift (wijzig) Sst-afhankelijkheden, SstIcon +Sources/SstCore/AppGlue.swift (nieuw) GatedInserter, SwappableTranscriber, LevelSource +Sources/SstData/AppSettings.swift (nieuw) +Sources/SstWhisper/ModelStorage.swift (nieuw) ModelOption, ModelCatalog, ModelStorage +Sources/SstWhisper/WhisperKitTranscriber.swift (vervang) gebruikt ModelStorage +Sources/SstUI/Controls.swift (aanvullen) RockerToggleStyle +Sources/Sst/AppIdentity.swift, Strings.swift, PermissionMonitor.swift, AppModel.swift (nieuw) +Sources/Sst/SstApp.swift, MainWindow.swift, AppCommands.swift, MenuBarContent.swift, SettingsView.swift (nieuw) +Sources/Sst/AppModel.swift (oud), MenuContent.swift (verwijderd) +Sources/SstIcon/IconMaker.swift (nieuw) rendert het icoon uit de tokens +scripts/make-icon.sh, scripts/make-app.sh (nieuw/vervang) +Design/AppIcon.icns (gegenereerd, gecommit) +Tests/SstCoreTests/AppGlueTests.swift, Tests/SstDataTests/AppSettingsTests.swift, +Tests/SstWhisperTests/ModelStorageTests.swift (nieuw) +``` + +--- + +### Task 1: Lijmcode in `SstCore` + +**Files:** +- Create: `Sources/SstCore/AppGlue.swift` +- Test: `Tests/SstCoreTests/AppGlueTests.swift` + +**Interfaces:** +- Produces: + - `GatedInserter(inner: TextInserting, shouldInsert: @escaping () -> Bool)` — vraagt `shouldInsert()` op het moment van invoegen + - `SwappableTranscriber(_ initial: Transcriber? = nil)` met `swap(to:)`, `hasTranscriber`, beide `transcribe`-varianten; werpt `SwapError.noTranscriber` zonder model + - `LevelSource` met `dbfs`, `update(_:)`, `reset()` (thread-safe; start op `AudioLevel.silenceFloorDbfs`) + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstCoreTests/AppGlueTests.swift`: + +```swift +import Foundation +import Testing +@testable import SstCore + +struct GatedInserterTests { + @Test func insertsWhenAllowed() async throws { + let inner = FakeInserter() + try await GatedInserter(inner: inner, shouldInsert: { true }).insert("hallo") + #expect(inner.inserted == ["hallo"]) + } + + @Test func staysSilentWhenNotAllowed() async throws { + let inner = FakeInserter() + try await GatedInserter(inner: inner, shouldInsert: { false }).insert("hallo") + #expect(inner.inserted.isEmpty) + } + + @Test func askedAtInsertionTimeNotAtCreation() async throws { + let inner = FakeInserter() + var allowed = false + let gated = GatedInserter(inner: inner, shouldInsert: { allowed }) + allowed = true + try await gated.insert("nu wel") + #expect(inner.inserted == ["nu wel"]) + } +} + +struct SwappableTranscriberTests { + @Test func throwsWithoutATranscriber() async { + let swappable = SwappableTranscriber() + #expect(!swappable.hasTranscriber) + await #expect(throws: SwappableTranscriber.SwapError.self) { + _ = try await swappable.transcribe([0]) + } + } + + @Test func delegatesIncludingTheVocabulary() async throws { + let inner = FakeVocabularyTranscriber() + inner.result = "klaar" + let swappable = SwappableTranscriber(inner) + #expect(try await swappable.transcribe([0], vocabulary: ["Anthropic"]) == "klaar") + #expect(inner.vocabularies == [["Anthropic"]]) + #expect(try await swappable.transcribe([0]) == "klaar") + #expect(inner.vocabularies == [["Anthropic"], []]) + } + + @Test func swapReplacesTheTranscriber() async throws { + let first = FakeVocabularyTranscriber(), second = FakeVocabularyTranscriber() + first.result = "een"; second.result = "twee" + let swappable = SwappableTranscriber(first) + #expect(try await swappable.transcribe([0]) == "een") + swappable.swap(to: second) + #expect(try await swappable.transcribe([0]) == "twee") + swappable.swap(to: nil) + #expect(!swappable.hasTranscriber) + } +} + +struct LevelSourceTests { + @Test func startsSilentAndKeepsTheLatestValue() { + let source = LevelSource() + #expect(source.dbfs == AudioLevel.silenceFloorDbfs) + source.update(-23) + source.update(-18) + #expect(source.dbfs == -18) + source.reset() + #expect(source.dbfs == AudioLevel.silenceFloorDbfs) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "GatedInserterTests|SwappableTranscriberTests|LevelSourceTests"` +Expected: compilatiefouten (`cannot find 'GatedInserter' in scope`). + +- [ ] **Step 3: Implementatie** + +`Sources/SstCore/AppGlue.swift`: + +```swift +import Foundation + +/// Inserts text only while `shouldInsert()` says so (not when Sst itself is in front, nor for window recordings). +public struct GatedInserter: TextInserting { + private let inner: TextInserting + private let shouldInsert: () -> Bool + + public init(inner: TextInserting, shouldInsert: @escaping () -> Bool) { + self.inner = inner + self.shouldInsert = shouldInsert + } + + public func insert(_ text: String) async throws { + guard shouldInsert() else { return } + try await inner.insert(text) + } +} + +/// Lets the app replace the speech model (for example after a model switch) without rebuilding the controller. +public final class SwappableTranscriber: Transcriber, @unchecked Sendable { + public enum SwapError: Error, LocalizedError { + case noTranscriber + + public var errorDescription: String? { "Spraakmodel is nog niet geladen." } + } + + private let lock = NSLock() + private var current: Transcriber? + + public init(_ initial: Transcriber? = nil) { + current = initial + } + + public func swap(to transcriber: Transcriber?) { + lock.withLock { current = transcriber } + } + + public var hasTranscriber: Bool { + lock.withLock { current != nil } + } + + public func transcribe(_ samples: [Float]) async throws -> String { + try await transcribe(samples, vocabulary: []) + } + + public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + guard let transcriber = lock.withLock({ current }) else { throw SwapError.noTranscriber } + return try await transcriber.transcribe(samples, vocabulary: vocabulary) + } +} + +/// The latest input level, written from the audio thread and read by the meter once per frame. +public final class LevelSource: @unchecked Sendable { + private let lock = NSLock() + private var value = AudioLevel.silenceFloorDbfs + + public init() {} + + public var dbfs: Float { lock.withLock { value } } + + public func update(_ dbfs: Float) { + lock.withLock { value = dbfs } + } + + public func reset() { + update(AudioLevel.silenceFloorDbfs) + } +} +``` + +- [ ] **Step 4: Draai de tests** + +Run: `swift test --filter "GatedInserterTests|SwappableTranscriberTests|LevelSourceTests"` +Expected: 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstCore Tests/SstCoreTests +git commit -m "feat: add GatedInserter, SwappableTranscriber and LevelSource" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: `AppSettings` + +**Files:** +- Create: `Sources/SstData/AppSettings.swift` +- Test: `Tests/SstDataTests/AppSettingsTests.swift` + +**Interfaces:** +- Produces: `final class AppSettings: ObservableObject` met `init(defaults: UserDefaults = .standard)`, `@Published` `hotkeyID` ("fn"), `modelVariant` ("large-v3-v20240930_turbo"), `keepHistory` (true), `usePriming` (true), `useCorrection` (true); elke wijziging wordt direct in `UserDefaults` bewaard. `AppSettings.defaultModelVariant` is bewust gelijk aan `ModelCatalog.defaultVariant` (SstData kent SstWhisper niet). + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/AppSettingsTests.swift`: + +```swift +import Foundation +import Testing +@testable import SstData + +struct AppSettingsTests { + func freshDefaults() -> UserDefaults { + let name = "sst-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test func defaultsMatchTheSpec() { + let settings = AppSettings(defaults: freshDefaults()) + #expect(settings.hotkeyID == "fn") + #expect(settings.modelVariant == "large-v3-v20240930_turbo") + #expect(settings.keepHistory) + #expect(settings.usePriming) + #expect(settings.useCorrection) + } + + @Test func changesArePersisted() { + let defaults = freshDefaults() + let settings = AppSettings(defaults: defaults) + settings.hotkeyID = "rightOption" + settings.modelVariant = "small" + settings.keepHistory = false + settings.usePriming = false + settings.useCorrection = false + + let reloaded = AppSettings(defaults: defaults) + #expect(reloaded.hotkeyID == "rightOption") + #expect(reloaded.modelVariant == "small") + #expect(!reloaded.keepHistory) + #expect(!reloaded.usePriming) + #expect(!reloaded.useCorrection) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter AppSettingsTests` +Expected: compilatiefout `cannot find 'AppSettings' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/AppSettings.swift`: + +```swift +import Combine +import Foundation + +/// User preferences, persisted in UserDefaults as soon as a value changes. +public final class AppSettings: ObservableObject { + public static let defaultModelVariant = "large-v3-v20240930_turbo" + + enum Key { + static let hotkey = "hotkeyID" + static let model = "modelVariant" + static let keepHistory = "keepHistory" + static let usePriming = "usePriming" + static let useCorrection = "useCorrection" + } + + private let defaults: UserDefaults + + /// `HotkeyBinding.id` of the key that has to be held. + @Published public var hotkeyID: String { + didSet { defaults.set(hotkeyID, forKey: Key.hotkey) } + } + @Published public var modelVariant: String { + didSet { defaults.set(modelVariant, forKey: Key.model) } + } + @Published public var keepHistory: Bool { + didSet { defaults.set(keepHistory, forKey: Key.keepHistory) } + } + /// Give the speech engine the dictionary words as context. + @Published public var usePriming: Bool { + didSet { defaults.set(usePriming, forKey: Key.usePriming) } + } + /// Apply the dictionary corrections after transcription. + @Published public var useCorrection: Bool { + didSet { defaults.set(useCorrection, forKey: Key.useCorrection) } + } + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + hotkeyID = defaults.string(forKey: Key.hotkey) ?? "fn" + modelVariant = defaults.string(forKey: Key.model) ?? Self.defaultModelVariant + keepHistory = defaults.object(forKey: Key.keepHistory) as? Bool ?? true + usePriming = defaults.object(forKey: Key.usePriming) as? Bool ?? true + useCorrection = defaults.object(forKey: Key.useCorrection) as? Bool ?? true + } +} +``` + +- [ ] **Step 4: Draai de tests** + +Run: `swift test --filter AppSettingsTests` +Expected: 2 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstData Tests/SstDataTests +git commit -m "feat: add AppSettings" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: `ModelStorage`, `ModelCatalog` en de transcriber + +**Files:** +- Create: `Sources/SstWhisper/ModelStorage.swift` +- Replace: `Sources/SstWhisper/WhisperKitTranscriber.swift` +- Test: `Tests/SstWhisperTests/ModelStorageTests.swift` + +**Interfaces:** +- Consumes: bestaande `WhisperKitTranscriber` (fase 1: `transcribe(_:vocabulary:)`, `promptTokens`). +- Produces: + - `ModelOption` (`id`, `title`, `detail`, `approxSizeMB`), `ModelCatalog.all`, `ModelCatalog.defaultVariant` + - `ModelStorage(base:)` met `base`, `folder(for:) -> URL?`, `isInstalled(_:)`, `delete(_:) throws`, `defaultBase` (`~/Library/Application Support/Sst/models`); varianten worden exact gematcht (`openai_whisper-`) + - `WhisperKitTranscriber(model:storage:)` met `variant`, `load()`, `onProgress` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstWhisperTests/ModelStorageTests.swift`: + +```swift +import Foundation +import Testing +@testable import SstWhisper + +struct ModelStorageTests { + func makeStorage() -> ModelStorage { + ModelStorage(base: FileManager.default.temporaryDirectory.appending(path: "sst-models-\(UUID().uuidString)")) + } + + /// Creates `//.mlmodelc/coremldata.bin` for the given models. + func install(_ storage: ModelStorage, folder: String, models: [String] = ModelStorage.requiredModels) throws { + for model in models { + let dir = storage.repoDirectory.appending(path: "\(folder)/\(model).mlmodelc", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data([1]).write(to: dir.appending(path: "coremldata.bin")) + } + } + + @Test func emptyStorageHasNothingInstalled() { + let storage = makeStorage() + #expect(storage.folder(for: "small") == nil) + #expect(!storage.isInstalled("small")) + } + + @Test func completeInstallIsFound() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo") + #expect(storage.isInstalled("large-v3-v20240930_turbo")) + #expect(storage.folder(for: "large-v3-v20240930_turbo")?.lastPathComponent == "openai_whisper-large-v3-v20240930_turbo") + } + + @Test func incompleteInstallDoesNotCount() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-small", models: ["MelSpectrogram", "AudioEncoder"]) + #expect(!storage.isInstalled("small")) + } + + @Test func variantsAreMatchedExactly() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo_632MB") + #expect(!storage.isInstalled("large-v3-v20240930_turbo")) + #expect(storage.isInstalled("large-v3-v20240930_turbo_632MB")) + } + + @Test func deleteRemovesOnlyThatVariant() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-small") + try install(storage, folder: "openai_whisper-base") + try storage.delete("small") + #expect(!storage.isInstalled("small")) + #expect(storage.isInstalled("base")) + } + + @Test func deletingSomethingNotInstalledIsHarmless() throws { + try makeStorage().delete("small") + } + + @Test func catalogStartsWithTheDefaultAndHasUniqueIds() { + #expect(ModelCatalog.all.first?.id == ModelCatalog.defaultVariant) + #expect(Set(ModelCatalog.all.map(\.id)).count == ModelCatalog.all.count) + #expect(ModelCatalog.all.allSatisfy { $0.approxSizeMB > 0 }) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter ModelStorageTests` +Expected: compilatiefout `cannot find 'ModelStorage' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstWhisper/ModelStorage.swift`: + +```swift +import Foundation + +/// A speech model the user can pick in Settings. +public struct ModelOption: Identifiable, Equatable, Sendable { + public let id: String // WhisperKit variant name + public let title: String + public let detail: String + public let approxSizeMB: Int + + public init(id: String, title: String, detail: String, approxSizeMB: Int) { + self.id = id + self.title = title + self.detail = detail + self.approxSizeMB = approxSizeMB + } +} + +public enum ModelCatalog { + public static let defaultVariant = "large-v3-v20240930_turbo" + + /// Sizes measured from the Hugging Face repository `argmaxinc/whisperkit-coreml`. + public static let all: [ModelOption] = [ + ModelOption(id: defaultVariant, title: "Whisper Large v3 Turbo", detail: "Aanbevolen · nauwkeurigst", approxSizeMB: 1638), + ModelOption(id: "large-v3-v20240930_turbo_632MB", title: "Whisper Large v3 Turbo (compact)", detail: "Kleiner, iets minder nauwkeurig", approxSizeMB: 646), + ModelOption(id: "small", title: "Whisper Small", detail: "Snel en licht, minder nauwkeurig", approxSizeMB: 486), + ] +} + +/// Where models live on disk: `/models/argmaxinc/whisperkit-coreml/openai_whisper-`. +public struct ModelStorage: Sendable { + public static let repo = "argmaxinc/whisperkit-coreml" + public static let requiredModels = ["MelSpectrogram", "AudioEncoder", "TextDecoder"] + + public let base: URL + + public init(base: URL = ModelStorage.defaultBase) { + self.base = base + } + + /// Application Support, so models are neither in ~/Documents (privacy prompt, iCloud) nor re-fetched. + public static var defaultBase: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appending(path: "Sst/models", directoryHint: .isDirectory) + } + + var repoDirectory: URL { + base.appending(path: "models/\(Self.repo)", directoryHint: .isDirectory) + } + + private func folders(for variant: String) -> [URL] { + let names = (try? FileManager.default.contentsOfDirectory(atPath: repoDirectory.path)) ?? [] + return names + .filter { $0 == variant || $0 == "openai_whisper-\(variant)" } + .map { repoDirectory.appending(path: $0, directoryHint: .isDirectory) } + } + + /// The folder of a completely downloaded variant, or nil. + public func folder(for variant: String) -> URL? { + folders(for: variant).first { folder in + Self.requiredModels.allSatisfy { + FileManager.default.fileExists(atPath: folder.appending(path: "\($0).mlmodelc/coremldata.bin").path) + } + } + } + + public func isInstalled(_ variant: String) -> Bool { + folder(for: variant) != nil + } + + public func delete(_ variant: String) throws { + for folder in folders(for: variant) { try FileManager.default.removeItem(at: folder) } + } +} +``` + +Vervang `Sources/SstWhisper/WhisperKitTranscriber.swift` volledig door: + +```swift +import Foundation +import SstCore +import WhisperKit + +public final class WhisperKitTranscriber: Transcriber { + public enum TranscriberError: Error, LocalizedError { + case notLoaded + + public var errorDescription: String? { "Spraakmodel is nog niet geladen." } + } + + private let modelName: String + private let storage: ModelStorage + private var pipeline: WhisperKit? + + /// Download progress (0...1) during the one-time model download. May be called from any thread. + public var onProgress: ((Double) -> Void)? + + public init(model: String = ModelCatalog.defaultVariant, storage: ModelStorage = ModelStorage()) { + self.modelName = model + self.storage = storage + } + + public var variant: String { modelName } + + private func loadPipeline(from folder: URL) async throws -> WhisperKit { + try await WhisperKit(WhisperKitConfig( + downloadBase: storage.base, + modelFolder: folder.path, + tokenizerFolder: storage.base, + prewarm: true, + load: true, + download: false + )) + } + + /// Loads the model into memory. Downloads it (≈1.5 GB) only if it is not stored locally yet; + /// once downloaded, launching needs no network. + public func load() async throws { + guard pipeline == nil else { return } + try FileManager.default.createDirectory(at: storage.base, withIntermediateDirectories: true) + + if let folder = storage.folder(for: modelName), + let loaded = try? await loadPipeline(from: folder) { + pipeline = loaded + return + } + let folder = try await WhisperKit.download( + variant: modelName, + downloadBase: storage.base, + from: ModelStorage.repo + ) { [onProgress] progress in + onProgress?(progress.fractionCompleted) + } + pipeline = try await loadPipeline(from: folder) + } + + public func transcribe(_ samples: [Float]) async throws -> String { + try await transcribe(samples, vocabulary: []) + } + + /// Terms are added one by one while the encoded prompt stays within `maxTokens`. + /// Returns nil when nothing fits or the list is empty. + static func promptTokens( + vocabulary: [String], + maxTokens: Int = 64, + specialTokenBegin: Int, + encode: (String) -> [Int] + ) -> [Int]? { + var kept: [String] = [] + var tokens: [Int] = [] + for term in vocabulary { + let candidate = kept + [term] + let encoded = encode(" " + candidate.joined(separator: ", ")).filter { $0 < specialTokenBegin } + if encoded.count > maxTokens { break } + kept = candidate + tokens = encoded + } + return tokens.isEmpty ? nil : tokens + } + + public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + guard let pipeline else { throw TranscriberError.notLoaded } + // language nil + detectLanguage: Whisper picks NL or EN per utterance. + var options = DecodingOptions( + task: .transcribe, + language: nil, + detectLanguage: true, + skipSpecialTokens: true, + withoutTimestamps: true + ) + if !vocabulary.isEmpty, let tokenizer = pipeline.tokenizer { + options.promptTokens = Self.promptTokens( + vocabulary: vocabulary, + specialTokenBegin: tokenizer.specialTokens.specialTokenBegin, + encode: { tokenizer.encode(text: $0) } + ) + } + let results = try await pipeline.transcribe(audioArray: samples, decodeOptions: options) + return results + .map(\.text) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} +``` + +- [ ] **Step 4: Draai alle tests** + +Run: `swift test` +Expected: alle tests PASS (7 nieuwe `ModelStorageTests`; `PromptTokensTests` uit fase 1 blijft groen). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstWhisper Tests/SstWhisperTests +git commit -m "feat: add ModelStorage and ModelCatalog; transcriber uses ModelStorage" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: `RockerToggleStyle` in `SstUI` + +**Files:** +- Modify: `Sources/SstUI/Controls.swift` (onderaan aanvullen) + +**Interfaces:** +- Consumes: `WellBackground`, `PhysicalSurface`, tokens (`Size.switchWidth/Height`, `Shadows.keyRaised`, `Motion.durationFast`). +- Produces: `RockerToggleStyle()` (`ToggleStyle`): label links, tuimelschakelaar rechts; toegankelijkheidswaarde "aan"/"uit". + +- [ ] **Step 1: Implementatie** + +Voeg onderaan `Sources/SstUI/Controls.swift` toe (een lege regel ertussen): + +```swift +/// A two-position rocker switch: a key cap sliding in a recessed track. +public struct RockerToggleStyle: ToggleStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + HStack(spacing: Space.s4) { + configuration.label + Button { + configuration.isOn.toggle() + } label: { + RockerTrack(isOn: configuration.isOn) + } + .buttonStyle(.plain) + .accessibilityValue(configuration.isOn ? "aan" : "uit") + } + } + + private struct RockerTrack: View { + let isOn: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack(alignment: isOn ? .trailing : .leading) { + WellBackground() + PhysicalSurface( + fill: AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom)), + cornerRadius: Radius.sm, shadows: Shadows.keyRaised) + .frame(width: Size.switchWidth / 2, height: Size.switchHeight - Space.s2) + .padding(Space.s1) + } + .frame(width: Size.switchWidth, height: Size.switchHeight) + .animation(reduceMotion ? nil : .easeOut(duration: Motion.durationFast), value: isOn) + } + } +} +``` + +- [ ] **Step 2: Bouwen en scannen** + +Run: `swift build && swift test --filter SstUITests` +Expected: build zonder fouten of waarschuwingen; alle SstUI-tests PASS (geen losse waarden). + +- [ ] **Step 3: Commit** + +```bash +git add Sources/SstUI +git commit -m "feat: add rocker toggle style" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: App-kern — identiteit, teksten, rechten en `AppModel` + +**Files:** +- Create: `Sources/Sst/AppIdentity.swift`, `Sources/Sst/Strings.swift`, `Sources/Sst/PermissionMonitor.swift` +- Replace: `Sources/Sst/AppModel.swift` +- Modify: `Package.swift` + +**Interfaces:** +- Consumes: Task 1–3, fase 1 (`DictationController` met `corrector`, `vocabularyProvider`, `onResult`; `HotkeyMonitor.binding`; `AudioRecorder.onLevel`), fase 2 (`DictionaryStore`, `DictionaryCorrector`, `HistoryStore`, `VocabularyBuilder`, `SystemWordKnowledge`). +- Produces (gebruikt door de views en door fase 5): + - `AppIdentity.displayName`, `.brandCaption`; `AppPaths.support/dictionary/history` + - `Strings` (alle UI-teksten) + - `PermissionMonitor` met `microphone/accessibility/inputMonitoring`, `allGranted`, `isGranted(_:)`, `refresh()`, `requestMissing()`, `openSettings(for:)`, `startPolling()/stopPolling()`; `PermissionMonitor.Kind` + - `AppModel` (`@MainActor ObservableObject`): `settings`, `permissions`, `levelSource`, `history`, `dictionary`, `storage`, `state`, `modelStatus` (`.loading/.downloading/.ready/.failed`), `activeVariant`, `installedVariants`, `recordingStartedAt`, `lastDuration`, `lastError`, `section` (`.transcripts/.dictionary`), `searchFocusTick`, `confirmingClearHistory`, `isRecording`, `modelReady`, `hotkeyBinding`, `recordingBlocker`, `statusLine`, `menuIconName`, `title(for:)`, `retryHotkey()`, `toggleWindowRecording()`, `startWindowRecording()`, `stopWindowRecording()`, `refreshInstalled()`, `activateModel(_:)`, `deleteModel(_:)`, `show(_:)`, `focusSearch()`, `clearHistory()` + +- [ ] **Step 1: Package.swift** + +Vervang in `Package.swift` de regel + +```swift + .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]), +``` + +door: + +```swift + .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper", "SstData", "SstUI"]), + .executableTarget(name: "SstIcon", dependencies: ["SstUI"]), +``` + +- [ ] **Step 2: Oude app-bestanden opruimen** + +Run: `git rm Sources/Sst/MenuContent.swift` +(`Sources/Sst/AppModel.swift` en `SstApp.swift` worden in deze en de volgende taak vervangen.) + +- [ ] **Step 3: Bestanden schrijven** + +`Sources/Sst/AppIdentity.swift`: + +```swift +import Foundation + +/// The one place that knows the app's name, so renaming it later is a one-file change. +enum AppIdentity { + static let displayName = "Sst" + static let supportFolderName = "Sst" + static let brandCaption = "SST · FIELD RECORDER" +} + +enum AppPaths { + static var support: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appending(path: AppIdentity.supportFolderName, directoryHint: .isDirectory) + } + static var dictionary: URL { support.appending(path: "dictionary.txt") } + static var history: URL { support.appending(path: "history.json") } +} +``` + +`Sources/Sst/Strings.swift`: + +```swift +import Foundation + +/// All user-facing text, in one place (Dutch), so translating later is a single-file job. +enum Strings { + // sections + static let transcripts = "Transcripties" + static let dictionary = "Woordenboek" + static let controls = "Controles" + + // deck + static let record = "Rec" + static let stop = "Stop" + static let time = "Tijd" + static let level = "Niveau" + + // menus + static let recordingMenu = "Opname" + static let startRecording = "Start opname" + static let stopRecording = "Stop opname" + static let search = "Zoek" + static let clearHistory = "Wis geschiedenis…" + static let openApp = "Open Sst" + static let settings = "Instellingen…" + static let quit = "Stop Sst" + + // status + static let statusIdle = "Klaar" + static let statusListening = "Luistert…" + static let statusTranscribing = "Transcriberen…" + static let statusInserting = "Invoegen…" + static func modelLoading(_ title: String) -> String { "Model laden: \(title)…" } + static func modelDownloading(_ percent: Int) -> String { "Model downloaden… \(percent)%" } + static let modelFailed = "Model laden mislukt" + static func holdToDictate(_ keyName: String) -> String { "Houd \(keyName) ingedrukt om te dicteren" } + + // permissions + static let microphone = "Microfoon" + static let accessibility = "Accessibility" + static let inputMonitoring = "Input Monitoring" + static let modelLamp = "Model" + static let microphoneMissing = "Microfoon-toegang ontbreekt" + static let openSystemSettings = "Open Instellingen" + + // settings + static let tabHotkey = "Sneltoets" + static let tabModel = "Model" + static let tabPrivacy = "Privacy" + static let captureHotkey = "Leg nieuwe toets vast" + static let capturing = "Druk op de toets die je wilt gebruiken…" + static let hotkeyRejected = "Die toets kan niet. Kies Fn, Rechter Option, Rechter Command of Control." + static let globeHint = "Zet bij Fn in Systeeminstellingen → Toetsenbord ‘Druk op 🌐 voor’ op ‘Doe niets’." + static let useModel = "Gebruik" + static let download = "Download" + static let remove = "Verwijder" + static let showInFinder = "Toon in Finder" + static let keepHistory = "Transcripties bewaren" + static let clearHistoryConfirm = "Alle transcripties wissen?" + static let clearHistoryDetail = "Dit kan niet ongedaan worden gemaakt. Audio wordt nooit bewaard." + static let cancel = "Annuleer" + static let erase = "Wis" +} +``` + +`Sources/Sst/PermissionMonitor.swift`: + +```swift +import AppKit +import ApplicationServices +import AVFoundation +import Combine + +/// Reads the three macOS permissions and keeps them current: on every app activation and while polling is on. +@MainActor +final class PermissionMonitor: ObservableObject { + enum Kind: CaseIterable, Identifiable { + case microphone, accessibility, inputMonitoring + + var id: Self { self } + + var title: String { + switch self { + case .microphone: Strings.microphone + case .accessibility: Strings.accessibility + case .inputMonitoring: Strings.inputMonitoring + } + } + + fileprivate var pane: String { + switch self { + case .microphone: "Privacy_Microphone" + case .accessibility: "Privacy_Accessibility" + case .inputMonitoring: "Privacy_ListenEvent" + } + } + } + + @Published private(set) var microphone = false + @Published private(set) var accessibility = false + @Published private(set) var inputMonitoring = false + + private var timer: Timer? + private var activation: AnyCancellable? + + init() { + refresh() + activation = NotificationCenter.default + .publisher(for: NSApplication.didBecomeActiveNotification) + .sink { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } } + } + + var allGranted: Bool { microphone && accessibility && inputMonitoring } + + func isGranted(_ kind: Kind) -> Bool { + switch kind { + case .microphone: microphone + case .accessibility: accessibility + case .inputMonitoring: inputMonitoring + } + } + + func refresh() { + microphone = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + accessibility = AXIsProcessTrusted() + inputMonitoring = CGPreflightListenEventAccess() + } + + /// Asks macOS for every permission that is still missing (each shows its own system prompt). + func requestMissing() async { + if !microphone { _ = await AVCaptureDevice.requestAccess(for: .audio) } + if !inputMonitoring { _ = CGRequestListenEventAccess() } + if !accessibility { _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) } + refresh() + } + + func openSettings(for kind: Kind) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(kind.pane)") { + NSWorkspace.shared.open(url) + } + } + + /// Polls while a view that shows the lamps is visible, so granting a permission shows up without a click. + func startPolling(every interval: TimeInterval = 2) { + guard timer == nil else { return } + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } + } + } + + func stopPolling() { + timer?.invalidate() + timer = nil + } +} +``` + +`Sources/Sst/AppModel.swift` (vervang volledig): + +```swift +import AppKit +import AVFoundation +import Combine +import SstCore +import SstData +import SstWhisper +import SwiftUI + +/// Decides at insertion time whether dictated text may be pasted. +/// Not when Sst itself is in front (it would land in Sst's own search field) and not for recordings started in the window. +final class InsertionPolicy { + var windowSession = false + + func allowed() -> Bool { + !windowSession && NSWorkspace.shared.frontmostApplication?.bundleIdentifier != Bundle.main.bundleIdentifier + } +} + +@MainActor +final class AppModel: ObservableObject { + enum Section: String, CaseIterable, Identifiable { + case transcripts, dictionary + var id: String { rawValue } + } + + enum ModelStatus: Equatable { + case loading(String) + case downloading(String, Double) + case ready + case failed(String) + } + + // Services + let settings: AppSettings + let permissions = PermissionMonitor() + let levelSource = LevelSource() + let history: HistoryStore + let dictionary: DictionaryStore + let storage = ModelStorage() + + // Observable state + @Published private(set) var state: DictationState = .idle + @Published private(set) var modelStatus: ModelStatus = .loading(ModelCatalog.defaultVariant) + @Published private(set) var activeVariant: String? + @Published private(set) var installedVariants: Set = [] + @Published private(set) var recordingStartedAt: Date? + @Published private(set) var lastDuration: TimeInterval? + /// Bumped whenever the history or dictionary changes, so views that cache derived data can refresh. + @Published private(set) var historyRevision = 0 + @Published private(set) var dictionaryRevision = 0 + @Published private(set) var searchFocusTick = 0 + @Published var lastError: String? + @Published var section: Section = .transcripts + @Published var confirmingClearHistory = false + + private let transcriber = SwappableTranscriber() + private let corrector: DictionaryCorrector + private let hotkey = HotkeyMonitor() + private let policy: InsertionPolicy + private let controller: DictationController + private var modelTask: Task? + private var loadingVariant: String? + private var cancellables = Set() + + 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) + let levelSource = self.levelSource + let recorder = AudioRecorder() + recorder.onLevel = { levelSource.update($0) } + + self.settings = settings + self.policy = policy + self.corrector = corrector + self.dictionary = dictionary + self.history = history + self.controller = DictationController( + recorder: recorder, + transcriber: transcriber, + cleaner: PassthroughCleaner(), + inserter: GatedInserter( + inner: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()), + shouldInsert: { policy.allowed() }), + corrector: corrector, + vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] }) + + wire() + Task { await start() } + } + + // MARK: derived + + var isRecording: Bool { state == .recording } + var hotkeyBinding: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn } + var modelReady: Bool { modelStatus == .ready } + + /// Why recording is not possible right now, or nil when it is. + var recordingBlocker: String? { + if !permissions.allGranted { return Strings.openSystemSettings } + switch modelStatus { + case .ready: return nil + case .loading(let variant): return Strings.modelLoading(title(for: variant)) + case .downloading(_, let fraction): return Strings.modelDownloading(Int(fraction * 100)) + case .failed: return Strings.modelFailed + } + } + + var statusLine: String { + if let blocker = recordingBlocker, state == .idle { return blocker } + switch state { + case .idle: return Strings.holdToDictate(hotkeyBinding.displayName) + case .recording: return Strings.statusListening + case .transcribing: return Strings.statusTranscribing + case .inserting: return Strings.statusInserting + } + } + + var menuIconName: String { + switch state { + case .idle: + if lastError != nil { return "exclamationmark.triangle" } + return modelReady ? "mic" : "mic.slash" + case .recording: return "mic.fill" + case .transcribing, .inserting: return "waveform" + } + } + + func title(for variant: String) -> String { + ModelCatalog.all.first { $0.id == variant }?.title ?? variant + } + + // MARK: lifecycle + + private func wire() { + controller.onStateChange = { [weak self] state in + guard let self else { return } + self.state = state + switch state { + case .recording: + self.lastError = nil + self.recordingStartedAt = Date() + self.levelSource.reset() + case .idle: + if let started = self.recordingStartedAt { self.lastDuration = Date().timeIntervalSince(started) } + self.recordingStartedAt = nil + self.levelSource.reset() + default: break + } + } + controller.onError = { [weak self] in self?.lastError = $0 } + controller.onResult = { [weak self] result in + guard let self, self.settings.keepHistory else { return } + self.history.add(HistoryEntry(result: result)) + } + hotkey.onEvent = { [weak self] event in self?.handleHotkey(event) } + hotkey.binding = hotkeyBinding + + settings.$hotkeyID + .sink { [weak self] _ in + guard let self else { return } + self.hotkey.binding = self.hotkeyBinding + self.objectWillChange.send() + } + .store(in: &cancellables) + settings.$useCorrection + .sink { [corrector] in corrector.setEnabled($0) } + .store(in: &cancellables) + permissions.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + + dictionary.onChange = { [weak self] in + guard let self else { return } + self.corrector.update(entries: self.dictionary.entries) + self.dictionaryRevision += 1 + } + history.onChange = { [weak self] in self?.historyRevision += 1 } + NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in + MainActor.assumeIsolated { history.flush() } + } + } + + private func start() async { + do { try dictionary.load() } catch { lastError = error.localizedDescription } + dictionary.startWatching() + if case .recoveredCorrupt = history.load() { + lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet." + } + refreshInstalled() + await permissions.requestMissing() + retryHotkey() + activateModel(settings.modelVariant) + } + + func retryHotkey() { + permissions.refresh() + do { + try hotkey.start() + } catch { + lastError = error.localizedDescription + } + } + + // MARK: recording + + private func handleHotkey(_ event: HotkeyEvent) { + guard modelReady else { return } + if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { + lastError = Strings.microphoneMissing + return + } + Task { await controller.handle(event) } + } + + /// Start/stop from the window or ⌥⌘R. The result goes to the history only; nothing is pasted. + func toggleWindowRecording() { + if isRecording { + stopWindowRecording() + } else { + startWindowRecording() + } + } + + func startWindowRecording() { + guard recordingBlocker == nil, state == .idle else { return } + policy.windowSession = true + Task { await controller.handle(.pressed) } + } + + func stopWindowRecording() { + guard policy.windowSession else { return } + Task { + await controller.handle(.released) + policy.windowSession = false + } + } + + // MARK: model + + func refreshInstalled() { + installedVariants = Set(ModelCatalog.all.map(\.id).filter { storage.isInstalled($0) }) + } + + /// Downloads (if needed) and loads `variant`. Dictation is off meanwhile; if it fails the previous model stays. + func activateModel(_ variant: String) { + modelTask?.cancel() + let previous = activeVariant + loadingVariant = variant + modelStatus = .loading(variant) + modelTask = Task { [weak self] in + guard let self else { return } + let candidate = WhisperKitTranscriber(model: variant, storage: storage) + candidate.onProgress = { [weak self] fraction in + Task { @MainActor in + guard let self, self.loadingVariant == variant else { return } + self.modelStatus = fraction < 1 ? .downloading(variant, fraction) : .loading(variant) + } + } + do { + try await candidate.load() + guard !Task.isCancelled else { return } + transcriber.swap(to: candidate) + activeVariant = variant + settings.modelVariant = variant + modelStatus = .ready + } catch { + guard !Task.isCancelled else { return } + lastError = error.localizedDescription + modelStatus = previous == nil ? .failed(error.localizedDescription) : .ready + } + if loadingVariant == variant { loadingVariant = nil } + refreshInstalled() + } + } + + func deleteModel(_ variant: String) { + guard variant != activeVariant else { return } + do { + try storage.delete(variant) + } catch { + lastError = error.localizedDescription + } + refreshInstalled() + } + + // MARK: navigation and history + + func show(_ section: Section) { + self.section = section + NSApp.activate(ignoringOtherApps: true) + } + + func focusSearch() { + searchFocusTick += 1 + } + + func clearHistory() { + history.removeAll() + history.flush() + } +} +``` + +- [ ] **Step 4: Bouwen** + +De app bouwt pas na Task 6 (de scenes ontbreken nog); controleer hier alleen de bibliotheken: + +Run: `swift build --target SstCore --target SstData --target SstWhisper --target SstUI` +Expected: build zonder fouten. + +- [ ] **Step 5: Commit (samen met Task 6)** + +Er wordt pas gecommit na Task 6, want de app-target bouwt pas dan. + +--- + +### Task 6: Scenes, menu's, instellingen en menubalkitem + +**Files:** +- Create: `Sources/Sst/SstApp.swift`, `Sources/Sst/MainWindow.swift`, `Sources/Sst/AppCommands.swift`, `Sources/Sst/MenuBarContent.swift`, `Sources/Sst/SettingsView.swift` +- Test: de token-scan (`Tests/SstUITests/TokenIntegrityTests.swift`) bewaakt ook `Sources/Sst` + +**Interfaces:** +- Consumes: Task 5 (`AppModel`, `Strings`), `SstUI`. +- Produces: scenes `Window("Sst", id: "main")`, `Settings`, `MenuBarExtra`; menu "Opname" (⌥⌘R start/stop, ⌘1 Transcripties, ⌘2 Woordenboek, ⌘F zoek) en "Wis geschiedenis…" in Bewerken; `SettingsView` met drie toetsen-tabs; `MainWindow` is voorlopig een schil (fase 5 vervangt hem). + +- [ ] **Step 1: Bestanden schrijven** + +`Sources/Sst/SstApp.swift`: + +```swift +import SwiftUI +import SstUI + +@main +struct SstApp: App { + @StateObject private var model = AppModel() + + var body: some Scene { + Window(AppIdentity.displayName, id: "main") { + MainWindow() + .environmentObject(model) + .environmentObject(model.settings) + .frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight) + } + .defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) + .commands { AppCommands(model: model) } + + Settings { + SettingsView() + .environmentObject(model) + .environmentObject(model.settings) + } + + MenuBarExtra { + MenuBarContent() + .environmentObject(model) + } label: { + Image(systemName: model.menuIconName) + } + } +} +``` + +`Sources/Sst/MainWindow.swift`: + +```swift +import SwiftUI +import SstUI + +/// Placeholder shell; phase 5 replaces the content with the deck, history and dictionary. +struct MainWindow: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + ZStack { + BrushedBackground().ignoresSafeArea() + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(AppIdentity.brandCaption) + Text(model.statusLine) + .textStyle(Typography.reading) + .foregroundStyle(Palette.inkPrimary) + .padding(Layout.panelPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(PanelBackground()) + } + .padding(Layout.panelPadding) + } + } +} +``` + +`Sources/Sst/AppCommands.swift`: + +```swift +import SwiftUI + +struct AppCommands: Commands { + @ObservedObject var model: AppModel + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandGroup(after: .pasteboard) { + Divider() + Button(Strings.clearHistory) { + openWindow(id: "main") + model.confirmingClearHistory = true + } + } + CommandMenu(Strings.recordingMenu) { + Button(model.isRecording ? Strings.stopRecording : Strings.startRecording) { + model.toggleWindowRecording() + } + .keyboardShortcut("r", modifiers: [.command, .option]) + Divider() + Button(Strings.transcripts) { show(.transcripts) } + .keyboardShortcut("1", modifiers: .command) + Button(Strings.dictionary) { show(.dictionary) } + .keyboardShortcut("2", modifiers: .command) + Button(Strings.search) { + show(.transcripts) + model.focusSearch() + } + .keyboardShortcut("f", modifiers: .command) + } + } + + private func show(_ section: AppModel.Section) { + openWindow(id: "main") + model.show(section) + } +} +``` + +`Sources/Sst/MenuBarContent.swift`: + +```swift +import SwiftUI + +/// The secondary interface: status and shortcut while working in another app. +struct MenuBarContent: View { + @EnvironmentObject private var model: AppModel + @Environment(\.openWindow) private var openWindow + + var body: some View { + Text(model.statusLine) + if let error = model.lastError { + Text(error) + } + Divider() + Button(Strings.openApp) { + openWindow(id: "main") + model.show(model.section) + } + SettingsLink { Text(Strings.settings) } + Divider() + Button(Strings.quit) { NSApplication.shared.terminate(nil) } + .keyboardShortcut("q") + } +} +``` + +`Sources/Sst/SettingsView.swift`: + +```swift +import AppKit +import SstCore +import SstData +import SstUI +import SstWhisper +import SwiftUI + +struct SettingsView: View { + private enum Tab: CaseIterable { + case hotkey, model, privacy + + var title: String { + switch self { + case .hotkey: Strings.tabHotkey + case .model: Strings.tabModel + case .privacy: Strings.tabPrivacy + } + } + } + + @State private var tab: Tab = .hotkey + + var body: some View { + ZStack { + BrushedBackground() + VStack(alignment: .leading, spacing: Layout.panelGap) { + HStack(spacing: Space.s3) { + ForEach(Tab.allCases, id: \.self) { candidate in + Button(candidate.title) { tab = candidate } + .buttonStyle(KeyButtonStyle(latched: tab == candidate)) + } + } + Group { + switch tab { + case .hotkey: HotkeySettingsView() + case .model: ModelSettingsView() + case .privacy: PrivacySettingsView() + } + } + .padding(Layout.panelPadding) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(PanelBackground()) + } + .padding(Layout.panelPadding) + } + .frame(width: Layout.settingsWidth) + .fixedSize(horizontal: false, vertical: true) + } +} + +// MARK: hotkey + +extension HotkeyBinding { + /// The matching flag in an AppKit event, used while capturing a new key. + fileprivate var eventFlag: NSEvent.ModifierFlags { + switch id { + case "fn": .function + case "rightOption": .option + case "rightCommand": .command + default: .control + } + } +} + +@MainActor +private final class HotkeyCapture: ObservableObject { + @Published var isCapturing = false + @Published var message: String? + private var monitor: Any? + + func start(onCapture: @escaping (HotkeyBinding) -> Void) { + stop() + isCapturing = true + message = Strings.capturing + monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged, .keyDown]) { [weak self] event in + guard let self else { return event } + if event.type == .keyDown { + if event.keyCode == 53 { self.stop() } // Escape cancels + return nil + } + guard let binding = HotkeyBinding.binding(keyCode: Int(event.keyCode)) else { + self.message = Strings.hotkeyRejected + return nil + } + if event.modifierFlags.contains(binding.eventFlag) { + self.stop() + onCapture(binding) + } + return nil + } + } + + func stop() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + isCapturing = false + message = nil + } +} + +private struct HotkeySettingsView: View { + @EnvironmentObject private var settings: AppSettings + @StateObject private var capture = HotkeyCapture() + + private var current: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn } + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.tabHotkey, style: Typography.heading) + HStack(spacing: Layout.panelGap) { + Text(current.displayName) + .textStyle(Typography.heading) + .foregroundStyle(Palette.inkPrimary) + .padding(.horizontal, Space.s4) + .frame(minHeight: Size.controlHeight) + .background(WellBackground()) + Button(Strings.captureHotkey) { + capture.start { settings.hotkeyID = $0.id } + } + .buttonStyle(KeyButtonStyle(latched: capture.isCapturing)) + } + if let message = capture.message { + Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + } + SeamDivider() + Text(HotkeyBinding.allowed.map(\.displayName).joined(separator: " · ")) + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + if current == .fn { + Text(Strings.globeHint).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + } + } + .onDisappear { capture.stop() } + } +} + +// MARK: model + +private struct ModelSettingsView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.tabModel, style: Typography.heading) + ForEach(ModelCatalog.all) { option in + row(option) + SeamDivider() + } + HStack { + Text(model.storage.base.path) + .textStyle(Typography.code) + .foregroundStyle(Palette.inkSecondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button(Strings.showInFinder) { + NSWorkspace.shared.activateFileViewerSelecting([model.storage.base]) + } + .buttonStyle(KeyButtonStyle()) + } + } + .onAppear { model.refreshInstalled() } + } + + private func row(_ option: ModelOption) -> some View { + let isActive = model.activeVariant == option.id + let installed = model.installedVariants.contains(option.id) + let progress: Double? = { + if case .downloading(option.id, let fraction) = model.modelStatus { return fraction } + return nil + }() + return HStack(alignment: .center, spacing: Layout.panelGap) { + LampView(isOn: isActive, tint: .amber) + VStack(alignment: .leading, spacing: Space.s1) { + Text(option.title).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) + Text("\(option.detail) · \(option.approxSizeMB) MB") + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + if let progress { + LEDBarView(fraction: progress) + } else if case .loading(option.id) = model.modelStatus { + SilkLabel(Strings.modelLoading(option.title), style: Typography.labelSmall) + } + } + Spacer() + if installed && !isActive { + Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) + Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle()) + } else if !installed { + Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) + } + } + } +} + +// MARK: privacy + +private struct PrivacySettingsView: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settings: AppSettings + @State private var confirming = false + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.tabPrivacy, style: Typography.heading) + Toggle(isOn: $settings.keepHistory) { + Text(Strings.keepHistory).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) + } + .toggleStyle(RockerToggleStyle()) + SeamDivider() + Button(Strings.clearHistory) { confirming = true } + .buttonStyle(KeyButtonStyle()) + } + .confirmationDialog(Strings.clearHistoryConfirm, isPresented: $confirming) { + Button(Strings.erase, role: .destructive) { model.clearHistory() } + Button(Strings.cancel, role: .cancel) {} + } message: { + Text(Strings.clearHistoryDetail) + } + } +} +``` + +- [ ] **Step 2: Bouwen en alle tests** + +Run: `swift build --product Sst && swift test` +Expected: build zonder fouten of waarschuwingen; alle tests PASS, inclusief `viewsContainNoLooseDesignValues` over `Sources/Sst`. + +- [ ] **Step 3: Commit (Task 5 + 6 samen)** + +```bash +git add Package.swift Sources/Sst +git commit -m "feat: turn Sst into a regular Mac app with settings, menus and menu bar item" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 7: Icoon, bundel en ondertekening + +**Files:** +- Create: `Sources/SstIcon/IconMaker.swift`, `scripts/make-icon.sh`, `Design/AppIcon.icns` (gegenereerd) +- Replace: `scripts/make-app.sh` + +**Interfaces:** +- Produces: `build/Sst.app` — een gewone Dock-app (geen `LSUIElement`), met icoon, versie 0.2.0, categorie productiviteit, Nederlandse microfoon-uitleg, ondertekend met je Apple Development-identiteit; `scripts/make-app.sh --install` kopieert naar `/Applications`. + +- [ ] **Step 1: Icoon-generator** + +`Sources/SstIcon/IconMaker.swift`: + +```swift +import AppKit +import SstUI +import SwiftUI + +/// The app icon: a brushed-aluminium plate with a VU meter and the red record lamp, drawn with the design tokens. +/// Canvas is 256 pt; the macOS icon grid puts the plate at 206 pt with a 46 pt corner radius. +struct IconView: View { + static let canvas: CGFloat = 256 + private let plate: CGFloat = 206 + private let corner: CGFloat = 46 + + var body: some View { + ZStack { + BrushedBackground() + .frame(width: plate, height: plate) + .clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: corner, style: .continuous) + .strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline)) + .shadow(color: .black.opacity(Opacity.mark), radius: Space.s3, y: Space.s2) + + VStack(spacing: Space.s5) { + VUMeterView(levelDbfs: -26, isActive: true, brand: "", initialFraction: 0.62) + .frame(width: 150) + HStack(spacing: Space.s4) { + LampView(isOn: true, large: true).scaleEffect(1.5) + SilkLabel("REC", style: Typography.heading) + } + } + .offset(y: Space.s2) + } + .frame(width: Self.canvas, height: Self.canvas) + .environment(\.colorScheme, .light) + } +} + +@main +@MainActor +struct IconMaker { + static func main() { + guard CommandLine.arguments.count > 1 else { + print("usage: SstIcon ") + exit(2) + } + let folder = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + // (file name, pixel size) as iconutil expects them + let outputs: [(String, CGFloat)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), + ] + for (name, pixels) in outputs { + let renderer = ImageRenderer(content: IconView()) + renderer.scale = pixels / IconView.canvas + guard let tiff = renderer.nsImage?.tiffRepresentation, + let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) + else { + print("kon \(name) niet renderen") + exit(1) + } + try? png.write(to: folder.appending(path: "\(name).png")) + } + print("iconset geschreven naar \(folder.path)") + } +} +``` + +`scripts/make-icon.sh`: + +```bash +#!/usr/bin/env bash +# Renders the app icon from the design tokens and stores it as Design/AppIcon.icns (committed). +set -euo pipefail +cd "$(dirname "$0")/.." + +swift build --product SstIcon +rm -rf build/AppIcon.iconset +.build/debug/SstIcon build/AppIcon.iconset +iconutil -c icns build/AppIcon.iconset -o Design/AppIcon.icns +echo "Icoon geschreven: Design/AppIcon.icns" +``` + +- [ ] **Step 2: Icoon genereren en bekijken** + +Run: `chmod +x scripts/make-icon.sh && ./scripts/make-icon.sh && open build/AppIcon.iconset/icon_512x512@2x.png` +Expected: `Icoon geschreven: Design/AppIcon.icns`. Het icoon toont een afgeronde aluminium plaat met een VU-meter (cream wijzerplaat, rode band, wijzer rond −5 VU) en een rood lampje met "REC". + +- [ ] **Step 3: Bundelscript** + +Vervang `scripts/make-app.sh` volledig door: + +```bash +#!/usr/bin/env bash +# Builds build/Sst.app (a normal Dock app, no LSUIElement). Pass --install to copy it to /Applications. +set -euo pipefail +cd "$(dirname "$0")/.." + +APP="build/Sst.app" +IDENTIFIER="local.sst.app" +VERSION="0.2.0" + +swift build -c release --product Sst +BIN=".build/release/Sst" + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/Sst" +# SwiftPM resource bundles (if any dependency ships them) live next to the binary. +for b in .build/release/*.bundle; do + [ -e "$b" ] && cp -R "$b" "$APP/Contents/Resources/" +done +if [ -f Design/AppIcon.icns ]; then + cp Design/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" +else + echo "Let op: Design/AppIcon.icns ontbreekt; run scripts/make-icon.sh." +fi + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleIdentifier$IDENTIFIER + CFBundleNameSst + CFBundleDisplayNameSst + CFBundleExecutableSst + CFBundlePackageTypeAPPL + CFBundleShortVersionString$VERSION + CFBundleVersion1 + CFBundleIconFileAppIcon + LSMinimumSystemVersion14.0 + LSApplicationCategoryTypepublic.app-category.productivity + NSHighResolutionCapable + NSMicrophoneUsageDescription + Sst neemt je stem op zolang je de sneltoets ingedrukt houdt, om die lokaal naar tekst om te zetten. + + +PLIST + +# A stable signing identity keeps the macOS permissions (Microphone, Accessibility, Input Monitoring) across rebuilds. +IDENTITY="${SST_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | sed -n 's/.*"\(Apple Development: [^"]*\)".*/\1/p' | head -1)}" +if [ -n "$IDENTITY" ]; then + codesign --force --sign "$IDENTITY" --identifier "$IDENTIFIER" "$APP" + echo "Ondertekend met: $IDENTITY" +else + codesign --force --sign - --identifier "$IDENTIFIER" "$APP" + echo "WAARSCHUWING: geen Apple Development-certificaat gevonden; ad-hoc ondertekend (rechten gaan na elke herbouw verloren)." +fi +codesign --verify --strict "$APP" +echo "Gebouwd: $APP" +echo "Eenmalig na het wisselen van ondertekening: tccutil reset Accessibility $IDENTIFIER; tccutil reset ListenEvent $IDENTIFIER; tccutil reset Microphone $IDENTIFIER (en geef de rechten opnieuw)." + +if [ "${1:-}" = "--install" ]; then + rm -rf /Applications/Sst.app + cp -R "$APP" /Applications/Sst.app + echo "Geïnstalleerd: /Applications/Sst.app" +fi +``` + +- [ ] **Step 4: Bundel bouwen en controleren** + +Run: +```bash +chmod +x scripts/make-app.sh && ./scripts/make-app.sh +plutil -lint build/Sst.app/Contents/Info.plist +/usr/libexec/PlistBuddy -c "Print :LSUIElement" build/Sst.app/Contents/Info.plist +codesign -dv build/Sst.app 2>&1 | grep -E "Identifier|TeamIdentifier" +ls build/Sst.app/Contents/Resources +``` +Expected: `Ondertekend met: Apple Development: …`; `plutil … OK`; PlistBuddy meldt `:LSUIElement … Does Not Exist`; `Identifier=local.sst.app` en een `TeamIdentifier`; `AppIcon.icns` staat in Resources. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstIcon scripts Design/AppIcon.icns +git commit -m "feat: app icon generator, signed app bundle script with install option" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 8: Handmatige acceptatie met de gebruiker + +Dit vraagt rechten, het toetsenbord en het scherm van de gebruiker; subagents voeren dit niet uit. + +- [ ] **Step 1: Voorbereiden** + +1. Sluit de oude `Sst` (de menubalk-app) als die nog draait: `pkill -f "Sst.app/Contents/MacOS/Sst"`. +2. De ondertekening is veranderd van ad-hoc naar je certificaat, dus eenmalig: `tccutil reset Accessibility local.sst.app; tccutil reset ListenEvent local.sst.app; tccutil reset Microphone local.sst.app` (door de gebruiker zelf uit te voeren). +3. `open build/Sst.app` en sta Microfoon, Accessibility en Input Monitoring toe (bij Input Monitoring zo nodig de app herstarten). + +- [ ] **Step 2: Controleer samen** + +1. **Dock en menu:** Sst heeft een Dock-icoon met het nieuwe icoon; het menu "Sst" toont Instellingen… (⌘,), Verberg en Stop Sst; er is een menu "Opname". +2. **Venster:** het hoofdvenster opent, is vrij te vergroten en niet kleiner te maken dan 800×560; de statusregel zegt "Houd Fn ingedrukt om te dicteren" zodra het model geladen is (of "Geef eerst de rechten onder Controles" zolang rechten ontbreken). +3. **Doorlopen na sluiten:** sluit het venster (⌘W). Dock-icoon en menubalkitem blijven; dicteer in TextEdit met Fn: de tekst verschijnt. Klik op het Dock-icoon: het venster komt terug. +4. **⌘Q** sluit Sst af (Dock-icoon weg, menubalkitem weg). +5. **Instellingen (⌘,):** drie toetsen Sneltoets / Model / Privacy. Sneltoets: kies "Leg nieuwe toets vast" en druk op Rechter Option; dicteer in TextEdit met Rechter Option (Fn doet dan niets). Zet terug op Fn. Druk bij het vastleggen op Shift of A: de melding legt uit dat dat niet kan. +6. **Model:** drie modellen; het standaardmodel heeft een brandend lampje. (Optioneel: download het compacte model, kijk naar de voortgangsbalk, schakel erheen en terug.) +7. **Voorop-regel:** zet Sst voorop (klik in het venster) en dicteer met Fn: er wordt niets geplakt. +8. **Menubalkitem:** toont de statusregel; "Open Sst" opent het venster; "Instellingen…" opent Instellingen; "Stop Sst" sluit af. +9. **Rechten:** trek Microfoon in via Systeeminstellingen; binnen enkele seconden of bij terugkeren naar Sst meldt de statusregel de ontbrekende rechten; geef ze terug. +10. **Installeren:** `./scripts/make-app.sh --install`, start `/Applications/Sst.app` vanuit Launchpad of Finder: werkt, en de rechten blijven na een herbouw behouden (herhaal `make-app.sh` en start opnieuw: geen nieuwe vragen). + +Noteer afwijkingen; kleine visuele bijstellingen horen bij deze taak. + +- [ ] **Step 3: Alle tests en afronden** + +Run: `swift test` +Expected: alle tests PASS. + +--- + +## Self-review + +- **Spec-dekking:** Dock-app zonder `LSUIElement`, vrij schaalbaar venster met minimumgrootte, Instellingen (⌘,) met sneltoets/model/privacy, secundair menubalkitem, menu's (Opname, Wis geschiedenis), doorlopen na sluiten, bundel met icoon, stabiele ondertekening en `--install`, rechten opnieuw lezen bij activeren en polling, voorop-regel en window-opname alleen opslaan, woordenboek en geschiedenis aan de keten gekoppeld (`corrector`, `vocabularyProvider`, `onResult`). Hoofdvenster-inhoud (deck, geschiedenis, woordenboek, zijbalk, CONTROLES) komt in fase 5; hier is `MainWindow` een schil. +- **Placeholders:** geen; alle code is overgenomen uit een gecompileerde en geteste versie (130 tests groen in de toestand na deze fase). +- **Typeconsistentie:** `AppModel.Section`, `ModelStatus`, `PermissionMonitor.Kind`, `Strings.*` en de tokenformaten komen overeen met fase 3 en worden in fase 5 hergebruikt. +- **Afwijkingen van de spec (bewust):** de modelvarianten en groottes zijn nu bij de bron geverifieerd; `AppSettings.defaultModelVariant` dupliceert `ModelCatalog.defaultVariant` omdat `SstData` niet van `SstWhisper` afhangt. +- **Risico's:** het exacte gedrag van `Window`-heropenen via het Dock en `SettingsLink` is alleen handmatig te controleren (Task 8 stap 3 en 8); valt het Dock-heropenen tegen, voeg dan een `NSApplicationDelegateAdaptor` toe met `applicationShouldHandleReopen` en meld dit als afwijking. diff --git a/docs/superpowers/plans/2026-10-01-app-5-main-window.md b/docs/superpowers/plans/2026-10-01-app-5-main-window.md new file mode 100644 index 0000000..6e52271 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-5-main-window.md @@ -0,0 +1,1443 @@ +# 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). diff --git a/docs/superpowers/specs/2026-10-01-macos-app-design.md b/docs/superpowers/specs/2026-10-01-macos-app-design.md index 6f468c9..fcf351c 100644 --- a/docs/superpowers/specs/2026-10-01-macos-app-design.md +++ b/docs/superpowers/specs/2026-10-01-macos-app-design.md @@ -130,7 +130,7 @@ Tabel op papier-oppervlak met kolommen GEHOORD → SCHRIJF. Een regel zonder pij Venster `layout.settings.width` breed, drie tabbladen als zeefdruk-toetsen. - **Sneltoets:** de gekozen toets wordt vastgelegd door erop te drukken (de app is dan actief en luistert met een lokale event-monitor). Alleen de toegestane modifiers uit beslissing 8; andere toetsen worden afgewezen met uitleg. Bij Fn staat een hint over de Globe-instelling van macOS. -- **Model:** vaste lijst van varianten (op dit moment: `large-v3-v20240930_turbo` standaard, de 632 MB-variant en `small`; exacte namen worden bij de bouw tegen WhisperKit gecontroleerd). Per model: status (aanwezig, gekozen, niet gedownload), download- of verwijderknop en voortgang als LED-balk. Wisselen laadt het nieuwe model en zet dicteren uit tot het klaar is; mislukt het laden, dan blijft het vorige model actief met een duidelijke melding. Toont de opslaglocatie met "Toon in Finder". +- **Model:** vaste lijst van drie varianten, namen en groottes gecontroleerd bij Hugging Face (`argmaxinc/whisperkit-coreml`): `large-v3-v20240930_turbo` (standaard, 1638 MB), `large-v3-v20240930_turbo_632MB` (646 MB) en `small` (486 MB). Per model: status (aanwezig, gekozen, niet gedownload), download- of verwijderknop en voortgang als LED-balk. Wisselen laadt het nieuwe model en zet dicteren uit tot het klaar is; mislukt het laden, dan blijft het vorige model actief met een duidelijke melding. Toont de opslaglocatie met "Toon in Finder". - **Privacy:** schakelaar "Transcripties bewaren" en knop "Wis geschiedenis…" (met bevestiging). ## Woordenboek — het ontwerp in detail