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

1444 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# App fase 5 — Hoofdvenster Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Het hoofdvenster van de app: vast deck met levende VU-meter, teller, LED-balk en REC/STOP; zijbalk met navigatie en CONTROLES; doorzoekbare transcriptiegeschiedenis met kopieerknop en zichtbare woordenboek-correcties; en het woordenboek met toevoegen, bewerken, verwijderen, zoeken, waarschuwingen en ongedaan maken.
**Architecture:** Testbare logica (`HistoryViewModel`, `DictionaryViewModel`, `CorrectionMarkup`, `StopwatchFormat`) staat in `SstData`; de views in `Sources/Sst` zijn dun en gebruiken uitsluitend tokens en componenten uit `SstUI`. Een debug-snapshotmodus (`Sst --snapshot`) rendert het venster met voorbeeldgegevens in beide afwerkingen, zodat de weergaven te beoordelen zijn zonder rechten of model.
**Tech Stack:** Swift 6.4 (language mode 5), SwiftUI, Swift Testing.
**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "Hoofdvenster", "Woordenboek — het ontwerp in detail", "Geschiedenis", "Toegankelijkheid")
**Voorwaarde:** fase 1 t/m 4 zijn afgerond (de app bouwt en draait met een schil als `MainWindow`).
## Global Constraints
- Indeling: zijbalk `Layout.sidebarWidth` met navigatie (Transcripties, Woordenboek) en CONTROLES (Microfoon, Accessibility, Input Monitoring, Model); rechts het deck (altijd zichtbaar) en daaronder de gekozen weergave.
- Deck: VU-meter 0 VU = −20 dBFS; teller toont opnametijd (in rust de duur van de laatste opname); LED-balk op hetzelfde signaal; ververst 60 Hz **alleen tijdens het opnemen**; REC blijft ingedrukt zolang er wordt opgenomen; REC uitgeschakeld met reden zolang rechten of model ontbreken; STOP beëindigt elke lopende opname (ook één die met de sneltoets begon).
- Geschiedenis: nieuwste eerst, per dag gegroepeerd (Nederlandse datums), rij = tijd + tekst + kopieerknop; correcties met amber markering en samenvatting "N correcties · gehoord → geschreven"; geselecteerde rij toont de oorspronkelijke tekst; zoeken (⌘F) hoofdletter- en diakriet-ongevoelig; rechtsklik: kopieer, kopieer origineel, verwijder; ⌫ verwijdert de selectie.
- Woordenboek: kolommen GEHOORD → SCHRIJF; term toont "—" links; `+` voegt toe in een editor met live waarschuwingen; dubbelklik bewerkt; ⌫ verwijdert; ⌘Z of "Herstel" maakt ongedaan; zoeken; "Open bestand"; schakelaars Voorrang en Correctie; lampje per rij: rood = fout, amber = waarschuwing, gedoofd = geen of alleen een notitie.
- Alleen tokens in views (token-scan over `Sources/Sst`); UI-teksten in `Strings.swift`.
- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>` (tweede `-m`).
## Bestandsstructuur
```
Sources/SstData/ViewSupport.swift (nieuw) StopwatchFormat, TextSegment, CorrectionMarkup, HistoryViewModel, DictionaryViewModel
Sources/Sst/AppModel.swift (wijzig via script) lijst-viewmodellen, preview-modus, STOP-regel
Sources/Sst/Strings.swift (wijzig via script) teksten voor lijsten en woordenboek
Sources/Sst/SstApp.swift (wijzig via script) @main verhuist naar main.swift
Sources/Sst/main.swift (nieuw) ingang; debug-snapshot
Sources/Sst/Snapshot.swift (nieuw, alleen DEBUG)
Sources/Sst/ListSupport.swift (nieuw) SnapshotMode, ScrollList, SearchField
Sources/Sst/DeckView.swift, Sidebar.swift, MainWindow.swift (vervang), HistoryView.swift, DictionaryView.swift (nieuw)
Tests/SstDataTests/ViewSupportTests.swift (nieuw)
```
---
### Task 1: Logica achter de weergaven in `SstData`
**Files:**
- Create: `Sources/SstData/ViewSupport.swift`
- Test: `Tests/SstDataTests/ViewSupportTests.swift`
**Interfaces:**
- Consumes: fase 2 (`HistoryStore`, `HistorySearch`, `HistoryGrouping`, `DictionaryStore`, `DictionaryFile`, `EntryWarnings`, `WordKnowledge`), fase 1 (`AppliedCorrection`).
- Produces:
- `StopwatchFormat.string(_ seconds: Double) -> String` ("mm:ss.t", minuten lopen rond bij 100)
- `TextSegment(text:correction:)`, `CorrectionMarkup.segments(finalText:corrections:) -> [TextSegment]` (UTF-16-offsets; ongeldige of overlappende correcties worden overgeslagen)
- `@MainActor HistoryViewModel(store:calendar:)` met `query`, `selection`, `days`, `totalCount`, `selectedEntry`, `refresh()`, `delete(id:)`
- `@MainActor DictionaryViewModel(store:knowledge:)` met `rows` (`Row`: `id`, `entry`, `warnings`, `worst`), `visibleRows`, `query`, `canUndo`, `errorMessage`, `reload()`, `warnings(for:editing:)`, `add(_:) -> UUID?`, `update(id:to:)`, `remove(id:)`, `undo()`
- [ ] **Step 1: Schrijf de falende tests**
`Tests/SstDataTests/ViewSupportTests.swift`:
```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[..<index]), knowledge: knowledge))
}
applyFilter()
}
private func applyFilter() {
let words = HistorySearch.fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
guard !words.isEmpty else {
visibleRows = rows
return
}
visibleRows = rows.filter { row in
let haystack = HistorySearch.fold(row.entry.source + " " + row.entry.wrote)
return words.allSatisfy { haystack.contains($0) }
}
}
/// Live feedback for the editor; `id` is the entry being edited (nil for a new one).
public func warnings(for kind: DictionaryEntry.Kind, editing id: UUID?) -> [EntryWarning] {
EntryWarnings.check(kind, among: store.entries.filter { $0.id != id }, knowledge: knowledge)
}
@discardableResult
public func add(_ kind: DictionaryEntry.Kind) -> UUID? {
var created: UUID?
mutate { created = $0.append(kind) }
return created
}
public func update(id: UUID, to kind: DictionaryEntry.Kind) {
mutate { $0.update(id: id, kind: kind) }
}
public func remove(id: UUID) {
mutate { $0.remove(id: id) }
}
public func undo() {
guard let snapshot = undoStack.popLast() else { return }
canUndo = !undoStack.isEmpty
do {
try store.apply { $0 = snapshot }
} catch {
errorMessage = error.localizedDescription
}
reload()
}
private func mutate(_ change: (inout DictionaryFile) -> Void) {
let snapshot = store.file
do {
try store.apply(change)
undoStack.append(snapshot)
canUndo = true
} catch {
errorMessage = error.localizedDescription
}
reload()
}
}
```
- [ ] **Step 4: Draai de tests**
Run: `swift test --filter SstDataTests`
Expected: alle SstData-tests PASS (17 nieuwe).
- [ ] **Step 5: Commit**
```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 <noreply@anthropic.com>"
```
---
### Task 2: Fundament — modelkoppeling, teksten, lijsthulpen en snapshotmodus
**Files:**
- Modify (via script): `Sources/Sst/AppModel.swift`, `Sources/Sst/Strings.swift`, `Sources/Sst/SstApp.swift`
- Create: `Sources/Sst/ListSupport.swift`, `Sources/Sst/main.swift`, `Sources/Sst/Snapshot.swift`
**Interfaces:**
- Consumes: fase 4 (`AppModel`, `Strings`, `SstApp`, `MainWindow`-schil).
- Produces:
- `AppModel.historyList: HistoryViewModel`, `AppModel.dictionaryList: DictionaryViewModel` (verversen zelf bij wijzigingen van de stores), `AppModel.openDictionaryFile()`, `AppModel(preview: true)`
- `Strings` voor lijsten en woordenboek, `Strings.open`, `Strings.permissionsMissing`
- `SnapshotMode.isActive`, `ScrollList { }`, `SearchField(text:focused:)`
- `Sst --snapshot <prefix>` (alleen DEBUG): schrijft `<prefix>-transcripts.png` en `<prefix>-dictionary.png` (Aluminium links, Graphite rechts)
- [ ] **Step 1: Bewerkingsscript uitvoeren**
Maak tijdelijk `apply_plan5_edits.py` in de repo-root:
```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<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
if SnapshotMode.isActive {
VStack(alignment: .leading, spacing: 0) { content }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { content }
}
}
}
}
/// The recessed "ZOEK" field.
struct SearchField: View {
@Binding var text: String
var focused: FocusState<Bool>.Binding
var body: some View {
HStack(spacing: Space.s4) {
SilkLabel(Strings.search)
Group {
if SnapshotMode.isActive {
Text(text).frame(maxWidth: .infinity, alignment: .leading)
} else {
TextField("", text: $text)
.textFieldStyle(.plain)
.focused(focused)
}
}
.textStyle(Typography.body)
.foregroundStyle(Palette.inkPrimary)
.padding(Space.s3)
.background(WellBackground())
}
}
}
```
`Sources/Sst/main.swift`:
```swift
import AppKit
#if DEBUG
// `Sst --snapshot <prefix>` renders the main window with sample data to <prefix>-transcripts.png and
// <prefix>-dictionary.png (Aluminium left, Graphite right) and exits. No prompts, no model, no hotkey.
if let flag = CommandLine.arguments.firstIndex(of: "--snapshot"), CommandLine.arguments.count > flag + 1 {
MainActor.assumeIsolated { Snapshot.run(prefix: CommandLine.arguments[flag + 1]) }
exit(0)
}
#endif
SstApp.main()
```
`Sources/Sst/Snapshot.swift`:
```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 <noreply@anthropic.com>"
```
---
### Task 3: Deck, zijbalk met CONTROLES en het venster
**Files:**
- Create: `Sources/Sst/DeckView.swift`, `Sources/Sst/Sidebar.swift`, `Sources/Sst/HistoryView.swift` (tijdelijk), `Sources/Sst/DictionaryView.swift` (tijdelijk)
- Replace: `Sources/Sst/MainWindow.swift`
**Interfaces:**
- Consumes: Task 2; `SstUI` (`VUMeterView`, `SegmentCounterView`, `LEDBarView`, `KeyButtonStyle`, `LampView`, panelen); `LevelSource`, `StopwatchFormat`.
- Produces: `DeckView`, `Sidebar`, `ControlsBlock`, `MainWindow` (zijbalk + deck + gekozen weergave, met het wis-geschiedenis-dialoog), en tijdelijke lege `HistoryView(list:)` en `DictionaryView(list:)` die Task 4 en 5 vervangen.
- [ ] **Step 1: Bestanden schrijven**
`Sources/Sst/DeckView.swift`:
```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 <noreply@anthropic.com>"
```
---
### Task 4: Transcriptiegeschiedenis
**Files:**
- Replace: `Sources/Sst/HistoryView.swift`
**Interfaces:**
- Consumes: Task 1 (`HistoryViewModel`, `CorrectionMarkup`), Task 2 (`ScrollList`, `SearchField`, `Strings`).
- Produces: `HistoryView(list:)` — zoekveld (focus via `AppModel.searchFocusTick`), lijst per dag, rij met kopieerknop, correctiemarkering en -samenvatting, contextmenu, ⌫-verwijderen, lege staten.
- [ ] **Step 1: Implementatie**
`Sources/Sst/HistoryView.swift` (vervang volledig):
```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 <noreply@anthropic.com>"
```
---
### Task 5: Woordenboek met editor
**Files:**
- Replace: `Sources/Sst/DictionaryView.swift`
**Interfaces:**
- Consumes: Task 1 (`DictionaryViewModel`), Task 2 (`ScrollList`, `SearchField`, `Strings`, `AppModel.openDictionaryFile()`), fase 4 (`RockerToggleStyle`, `AppSettings`).
- Produces: `DictionaryView(list:)` met zoekveld, `+`, "Open bestand", schakelaars Voorrang/Correctie, tabel, voettekst met telling en "Herstel", en de editor (sheet) met live waarschuwingen.
- [ ] **Step 1: Implementatie**
`Sources/Sst/DictionaryView.swift` (vervang volledig):
```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<String>) -> some View {
HStack(spacing: Space.s4) {
SilkLabel(title).frame(width: Size.keyRecWidth, alignment: .leading)
TextField("", text: text)
.textFieldStyle(.plain)
.textStyle(Typography.code)
.foregroundStyle(Palette.inkPrimary)
.padding(Space.s3)
.background(WellBackground())
}
}
private func load() {
guard let id = editingID, let row = list.rows.first(where: { $0.id == id }) else { return }
switch row.entry.kind {
case .term(let term): (isCorrection, heard, wrote) = (false, "", term)
case .correction(let h, let w): (isCorrection, heard, wrote) = (true, h, w)
}
}
private func save() {
if let id = editingID {
list.update(id: id, to: kind)
} else {
list.add(kind)
}
onClose()
}
}
```
- [ ] **Step 2: Bouwen, testen en bekijken**
Run: `swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-dictionary.png`
Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding toont het woordenboek met vier voorbeeldinvoeren: twee termen ("—" links), "cloud code → Claude Code" en "cloud → Claude"; alleen de laatste heeft een brandend amber lampje (gewoon woord). Zoekveld, `+` en OPEN BESTAND staan boven; twee tuimelschakelaars (VOORRANG, CORRECTIE); onderaan "4 INVOEREN" en een HERSTEL-toets (uitgeschakeld).
- [ ] **Step 3: Commit**
```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 <noreply@anthropic.com>"
```
---
### Task 6: Handmatige acceptatie met de gebruiker
Dit vraagt rechten, het toetsenbord en het scherm van de gebruiker; subagents voeren dit niet uit.
- [ ] **Step 1: Voorbereiden**
Run: `./scripts/make-app.sh && open build/Sst.app` (rechten en model zijn al in orde uit fase 4). Alle vier CONTROLES-lampjes branden binnen enkele seconden.
- [ ] **Step 2: Deck en opnemen**
1. Klik REC: de toets blijft ingedrukt, het lampje brandt, de teller loopt, de wijzer volgt je stem en zakt terug bij stilte; de piek-LED gaat aan boven 0 VU. Klik STOP.
2. Het resultaat staat in Transcripties; er is **niets geplakt** (opname vanuit het venster).
3. Houd Fn ingedrukt in TextEdit en spreek: de tekst wordt geplakt én staat in de lijst. Het deck beweegt ook dan.
4. Start een opname met de sneltoets en klik STOP in het venster: de opname eindigt en wordt verwerkt.
5. ⌥⌘R start en stopt een opname.
- [ ] **Step 3: Geschiedenis**
1. Zoek met ⌘F (veld krijgt focus); zoek "cafe" vindt "Café"; meerdere woorden in willekeurige volgorde.
2. KOPIEER zet de tekst op het klembord; rechtsklik: kopieer origineel, verwijder; selecteer een rij en druk ⌫.
3. Zet in Instellingen → Privacy "Transcripties bewaren" uit, dicteer: er komt geen nieuwe rij; de lege staat verwijst naar Instellingen. Zet terug aan. "Wis geschiedenis…" vraagt bevestiging en wist alles.
- [ ] **Step 4: Woordenboek**
1. Voeg met `+` de correctie `cloud code => Claude Code` toe: de editor toont een gele melding (alle delen zijn gewone woorden); bewaar.
2. Zeg "ik gebruik cloud code": de tekst bevat "Claude Code"; de rij in Transcripties toont de markering en de samenvatting. Zeg ook "CloudCode" en "cloud-code": beide worden "Claude Code". Zeg "Cloudflare" en "the cloud": blijven ongewijzigd.
3. Voeg `cloud => Claude` toe: waarschuwing (gewoon woord), lampje brandt amber. Verwijder met ⌫ en herstel met ⌘Z.
4. Voeg de term `Supabase` toe en zeg het woord: de schrijfwijze is goed.
5. Zet VOORRANG uit, dan CORRECTIE uit: elk mechanisme heeft zichtbaar effect (zonder CORRECTIE blijft "cloud code" staan).
6. Open het bestand met OPEN BESTAND, voeg in de editor een regel toe en bewaar: de lijst in Sst werkt binnen een seconde bij. Wijzig een regel in Sst: het bestand verandert, met behoud van `#`-regels.
7. Meet de snelheid: zeg 8 seconden spraak met en zonder VOORRANG; noteer beide tijden (doel: onder 1,5 s).
8. Zeg niets (hou de sneltoets 2 s ingedrukt) met een gevuld woordenboek: er wordt geen tekst ingevoegd.
- [ ] **Step 5: Toegankelijkheid en afronden**
1. Alle bediening is met het toetsenbord bereikbaar; de focusring is zichtbaar.
2. VoiceOver leest "Niveau" en "Tijd" voor; de lampjes zijn verborgen voor VoiceOver.
3. Zet "Verminder beweging" aan: geen overschrijding van de wijzer.
4. Draai `swift test`: alle tests PASS.
5. Noteer afwijkingen; kleine visuele bijstellingen horen bij deze taak.
---
## Self-review
- **Spec-dekking:** deck met meter/teller/LED/REC/STOP en uitschakelreden, zijbalk met CONTROLES, geschiedenis (dagen, markering, samenvatting, origineel bij selectie, zoeken, kopieerknop, contextmenu, ⌫, lege staten, geschiedenis-uit-melding), woordenboek (tabel, `+`, editor met live waarschuwingen, dubbelklik, ⌫, ⌘Z/Herstel, zoeken, bestand openen, twee schakelaars, lampje per rij), tekst-niet-plakken-voorop en alleen-opslaan (in fase 4), handmatige lijst uit de spec. Niet in scope: overlay, cleanup.
- **Placeholders:** geen; de twee tijdelijke views in Task 3 zijn volledig en worden in Task 4 en 5 vervangen.
- **Typeconsistentie:** `HistoryViewModel`/`DictionaryViewModel` (Task 1) worden door `AppModel` (Task 2) en de views (Task 3–5) met dezelfde namen gebruikt; `Strings`-sleutels in de views bestaan na het script in Task 2; `AppSettings` wordt aan het hoofdvenster meegegeven in `SstApp` (fase 4).
- **Risico's:** `ImageRenderer` tekent geen scrollende lijsten of tekstvelden; `SnapshotMode` vervangt ze alleen tijdens snapshots. Gedrag van ⌘Z en ⌫ op een focusbare lijst en de sheet-editor is alleen handmatig te controleren (Task 6 stap 4).