60 KiB
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.sidebarWidthmet 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 inStrings.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:)metquery,selection,days,totalCount,selectedEntry,refresh(),delete(id:)@MainActor DictionaryViewModel(store:knowledge:)metrows(Row:id,entry,warnings,worst),visibleRows,query,canUndo,errorMessage,reload(),warnings(for:editing:),add(_:) -> UUID?,update(id:to:),remove(id:),undo()
-
Step 1: Schrijf de falende tests
Tests/SstDataTests/ViewSupportTests.swift:
import Foundation
import Testing
import SstCore
@testable import SstData
struct StopwatchFormatTests {
@Test func formatsMinutesSecondsAndTenths() {
#expect(StopwatchFormat.string(0) == "00:00.0")
#expect(StopwatchFormat.string(7.46) == "00:07.4")
#expect(StopwatchFormat.string(65.2) == "01:05.2")
#expect(StopwatchFormat.string(-3) == "00:00.0")
#expect(StopwatchFormat.string(6039.9) == "00:39.9") // minutes wrap at 100
}
}
struct CorrectionMarkupTests {
func correction(_ start: Int, _ length: Int, heard: String = "x", wrote: String = "y") -> AppliedCorrection {
AppliedCorrection(heard: heard, wrote: wrote, rule: heard, start: start, length: length)
}
@Test func noCorrectionsGivesOnePlainSegment() {
#expect(CorrectionMarkup.segments(finalText: "hallo wereld", corrections: []) == [TextSegment(text: "hallo wereld")])
}
@Test func emptyTextGivesNoSegments() {
#expect(CorrectionMarkup.segments(finalText: "", corrections: [correction(0, 1)]).isEmpty)
}
@Test func splitsAroundACorrectionInTheMiddle() {
let c = correction(11, 11, heard: "cloud code", wrote: "Claude Code")
let segments = CorrectionMarkup.segments(finalText: "ik gebruik Claude Code vandaag", corrections: [c])
#expect(segments == [
TextSegment(text: "ik gebruik "),
TextSegment(text: "Claude Code", correction: c),
TextSegment(text: " vandaag"),
])
}
@Test func handlesCorrectionsAtTheStartAndEnd() {
let first = correction(0, 3), last = correction(8, 3)
let segments = CorrectionMarkup.segments(finalText: "abc def ghi", corrections: [last, first])
#expect(segments.map(\.text) == ["abc", " def ", "ghi"])
#expect(segments[0].correction == first && segments[2].correction == last && segments[1].correction == nil)
}
@Test func skipsOutOfRangeAndOverlappingCorrections() {
let ok = correction(0, 3), overlapping = correction(2, 3), outside = correction(50, 4), empty = correction(4, 0)
let segments = CorrectionMarkup.segments(finalText: "abc def", corrections: [ok, overlapping, outside, empty])
#expect(segments.map(\.text) == ["abc", " def"])
}
@Test func offsetsAreUTF16SoEmojiDoNotShiftThem() {
// "😀 " is 3 UTF-16 units; the corrected word starts at 3.
let c = correction(3, 6, heard: "wereld", wrote: "wereld")
let segments = CorrectionMarkup.segments(finalText: "😀 wereld", corrections: [c])
#expect(segments.map(\.text) == ["😀 ", "wereld"])
}
}
@MainActor
struct HistoryViewModelTests {
func store(_ texts: [String]) -> HistoryStore {
let store = HistoryStore(fileURL: FileManager.default.temporaryDirectory.appending(path: "sst-vm-\(UUID().uuidString)/h.json"))
for (index, text) in texts.enumerated() {
store.add(HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + Double(index)), duration: 1, rawText: text, finalText: text))
}
return store
}
@Test func listsAllEntriesNewestFirstAndFiltersByQuery() {
let model = HistoryViewModel(store: store(["eerste", "tweede zin", "derde"]))
#expect(model.totalCount == 3)
#expect(model.days.flatMap(\.entries).map(\.finalText) == ["derde", "tweede zin", "eerste"])
model.query = "tweede"
#expect(model.days.flatMap(\.entries).map(\.finalText) == ["tweede zin"])
#expect(model.totalCount == 3)
}
@Test func selectionIsClearedWhenItDisappearsFromTheFilter() {
let history = store(["alpha", "beta"])
let model = HistoryViewModel(store: history)
model.selection = history.entries.first { $0.finalText == "alpha" }?.id
#expect(model.selectedEntry?.finalText == "alpha")
model.query = "beta"
#expect(model.selection == nil)
}
@Test func deleteRemovesFromStoreAndList() {
let history = store(["a", "b"])
let model = HistoryViewModel(store: history)
model.delete(id: history.entries[0].id)
#expect(history.entries.count == 1)
#expect(model.totalCount == 1)
}
@Test func refreshPicksUpNewEntries() {
let history = store(["a"])
let model = HistoryViewModel(store: history)
history.add(HistoryEntry(date: .now, duration: 1, rawText: "nieuw", finalText: "nieuw"))
model.refresh()
#expect(model.totalCount == 2)
}
}
@MainActor
struct DictionaryViewModelTests {
func makeModel(_ text: String = "") throws -> (DictionaryViewModel, DictionaryStore) {
let url = FileManager.default.temporaryDirectory.appending(path: "sst-dict-\(UUID().uuidString)/dictionary.txt")
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try text.write(to: url, atomically: true, encoding: .utf8)
let store = DictionaryStore(fileURL: url)
try store.load()
return (DictionaryViewModel(store: store, knowledge: FakeKnowledge()), store)
}
@Test func rowsCarryWarnings() throws {
let (model, _) = try makeModel("Supabase\ncloud => Claude\n")
#expect(model.rows.count == 2)
#expect(model.rows[0].warnings.isEmpty)
#expect(model.rows[1].worst == .warning)
}
@Test func duplicateIsFlaggedOnTheLaterEntryOnly() throws {
let (model, _) = try makeModel("cloud code => Claude Code\nCloud-Code => Other\n")
#expect(model.rows[0].worst != .error)
#expect(model.rows[1].worst == .error)
}
@Test func addUpdateAndRemoveGoThroughTheStore() throws {
let (model, store) = try makeModel()
let id = model.add(.term("Vercel"))!
#expect(store.entries == [term("Vercel")])
model.update(id: id, to: .correction(heard: "versel", wrote: "Vercel"))
#expect(store.entries == [pair("versel", "Vercel")])
model.remove(id: id)
#expect(store.entries.isEmpty)
#expect(model.rows.isEmpty)
}
@Test func undoRestoresTheLastChangeStepByStep() throws {
let (model, store) = try makeModel("Supabase\n")
#expect(!model.canUndo)
let id = model.add(.term("Vercel"))!
model.remove(id: id)
#expect(store.entries == [term("Supabase")])
model.undo()
#expect(store.entries == [term("Supabase"), term("Vercel")])
model.undo()
#expect(store.entries == [term("Supabase")])
#expect(!model.canUndo)
model.undo() // nothing left: harmless
#expect(store.entries == [term("Supabase")])
}
@Test func searchMatchesBothSidesIgnoringCaseAndDiacritics() throws {
let (model, _) = try makeModel("cloud code => Claude Code\nCafé\nVercel\n")
model.query = "CLAUDE"
#expect(model.visibleRows.count == 1)
model.query = "cafe"
#expect(model.visibleRows.count == 1)
model.query = ""
#expect(model.visibleRows.count == 3)
}
@Test func editorWarningsIgnoreTheEntryBeingEdited() throws {
let (model, store) = try makeModel("cloud code => Claude Code\n")
let id = store.entries[0].id
let editingItself = model.warnings(for: .correction(heard: "cloud code", wrote: "Claude Code"), editing: id)
#expect(!editingItself.contains { $0.level == .error })
let asNew = model.warnings(for: .correction(heard: "cloud code", wrote: "Other"), editing: nil)
#expect(asNew.contains { $0.level == .error })
}
}
- Step 2: Draai de tests, moeten falen
Run: swift test --filter "StopwatchFormatTests|CorrectionMarkupTests|HistoryViewModelTests|DictionaryViewModelTests"
Expected: compilatiefouten (cannot find 'StopwatchFormat' in scope).
- Step 3: Implementatie
Sources/SstData/ViewSupport.swift:
import Combine
import Foundation
import SstCore
/// "mm:ss.t" for the counter.
public enum StopwatchFormat {
public static func string(_ seconds: Double) -> String {
let tenths = Int(max(0, seconds) * 10)
return String(format: "%02d:%02d.%d", (tenths / 600) % 100, (tenths / 10) % 60, tenths % 10)
}
}
public struct TextSegment: Equatable, Sendable {
public let text: String
/// Set when this piece was written by the dictionary correction.
public let correction: AppliedCorrection?
public init(text: String, correction: AppliedCorrection? = nil) {
self.text = text
self.correction = correction
}
}
public enum CorrectionMarkup {
/// Splits `finalText` into plain and corrected pieces. Offsets are UTF-16; out-of-range or overlapping ones are skipped.
public static func segments(finalText: String, corrections: [AppliedCorrection]) -> [TextSegment] {
let ns = finalText as NSString
guard ns.length > 0 else { return [] }
var result: [TextSegment] = []
var cursor = 0
for correction in corrections.sorted(by: { $0.start < $1.start }) {
let end = correction.start + correction.length
guard correction.length > 0, correction.start >= cursor, end <= ns.length else { continue }
if correction.start > cursor {
result.append(TextSegment(text: ns.substring(with: NSRange(location: cursor, length: correction.start - cursor))))
}
result.append(TextSegment(text: ns.substring(with: NSRange(location: correction.start, length: correction.length)), correction: correction))
cursor = end
}
if cursor < ns.length {
result.append(TextSegment(text: ns.substring(from: cursor)))
}
return result
}
}
/// The searchable, day-grouped list behind the transcripts view.
@MainActor
public final class HistoryViewModel: ObservableObject {
private let store: HistoryStore
private let calendar: Calendar
@Published public var query = "" {
didSet { refresh() }
}
@Published public var selection: UUID?
@Published public private(set) var days: [HistoryDay] = []
@Published public private(set) var totalCount = 0
public init(store: HistoryStore, calendar: Calendar = .current) {
self.store = store
self.calendar = calendar
refresh()
}
public func refresh() {
let visible = HistorySearch.filter(store.entries, query: query)
days = HistoryGrouping.byDay(visible, calendar: calendar)
totalCount = store.entries.count
if let selection, !visible.contains(where: { $0.id == selection }) { self.selection = nil }
}
public var selectedEntry: HistoryEntry? {
store.entries.first { $0.id == selection }
}
public func delete(id: UUID) {
store.remove(id: id)
refresh()
}
}
/// Dictionary rows with warnings, search, editing and undo.
@MainActor
public final class DictionaryViewModel: ObservableObject {
public struct Row: Identifiable, Equatable {
public let id: UUID
public let entry: DictionaryEntry
public let warnings: [EntryWarning]
public var worst: EntryWarning.Level? { warnings.map(\.level).max() }
}
private let store: DictionaryStore
private let knowledge: WordKnowledge
private var undoStack: [DictionaryFile] = []
@Published public var query = "" {
didSet { applyFilter() }
}
@Published public private(set) var rows: [Row] = []
@Published public private(set) var visibleRows: [Row] = []
@Published public private(set) var canUndo = false
@Published public var errorMessage: String?
public init(store: DictionaryStore, knowledge: WordKnowledge) {
self.store = store
self.knowledge = knowledge
reload()
}
/// Rebuilds the rows from the store (call after the store changed).
public func reload() {
let entries = store.entries
rows = entries.enumerated().map { index, entry in
// Only earlier entries count as "already there", so a duplicate is flagged on the later one.
Row(id: entry.id, entry: entry,
warnings: EntryWarnings.check(entry.kind, among: Array(entries[..<index]), knowledge: knowledge))
}
applyFilter()
}
private func applyFilter() {
let words = HistorySearch.fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
guard !words.isEmpty else {
visibleRows = rows
return
}
visibleRows = rows.filter { row in
let haystack = HistorySearch.fold(row.entry.source + " " + row.entry.wrote)
return words.allSatisfy { haystack.contains($0) }
}
}
/// Live feedback for the editor; `id` is the entry being edited (nil for a new one).
public func warnings(for kind: DictionaryEntry.Kind, editing id: UUID?) -> [EntryWarning] {
EntryWarnings.check(kind, among: store.entries.filter { $0.id != id }, knowledge: knowledge)
}
@discardableResult
public func add(_ kind: DictionaryEntry.Kind) -> UUID? {
var created: UUID?
mutate { created = $0.append(kind) }
return created
}
public func update(id: UUID, to kind: DictionaryEntry.Kind) {
mutate { $0.update(id: id, kind: kind) }
}
public func remove(id: UUID) {
mutate { $0.remove(id: id) }
}
public func undo() {
guard let snapshot = undoStack.popLast() else { return }
canUndo = !undoStack.isEmpty
do {
try store.apply { $0 = snapshot }
} catch {
errorMessage = error.localizedDescription
}
reload()
}
private func mutate(_ change: (inout DictionaryFile) -> Void) {
let snapshot = store.file
do {
try store.apply(change)
undoStack.append(snapshot)
canUndo = true
} catch {
errorMessage = error.localizedDescription
}
reload()
}
}
- Step 4: Draai de tests
Run: swift test --filter SstDataTests
Expected: alle SstData-tests PASS (17 nieuwe).
- Step 5: Commit
git add Sources/SstData Tests/SstDataTests
git commit -m "feat: add view-model logic for history and dictionary, correction markup, stopwatch format" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 2: Fundament — modelkoppeling, teksten, lijsthulpen en snapshotmodus
Files:
- Modify (via script):
Sources/Sst/AppModel.swift,Sources/Sst/Strings.swift,Sources/Sst/SstApp.swift - Create:
Sources/Sst/ListSupport.swift,Sources/Sst/main.swift,Sources/Sst/Snapshot.swift
Interfaces:
-
Consumes: fase 4 (
AppModel,Strings,SstApp,MainWindow-schil). -
Produces:
AppModel.historyList: HistoryViewModel,AppModel.dictionaryList: DictionaryViewModel(verversen zelf bij wijzigingen van de stores),AppModel.openDictionaryFile(),AppModel(preview: true)Stringsvoor lijsten en woordenboek,Strings.open,Strings.permissionsMissingSnapshotMode.isActive,ScrollList { },SearchField(text:focused:)Sst --snapshot <prefix>(alleen DEBUG): schrijft<prefix>-transcripts.pngen<prefix>-dictionary.png(Aluminium links, Graphite rechts)
-
Step 1: Bewerkingsscript uitvoeren
Maak tijdelijk apply_plan5_edits.py in de repo-root:
#!/usr/bin/env python3
"""One-off for phase 5: extends AppModel, Strings and SstApp from their phase-4 state. Run from the repo root; every replacement is asserted."""
from pathlib import Path
def edit(path, replacements):
p = Path(path)
s = p.read_text()
for old, new in replacements:
assert old in s, f"{path}: pattern not found:\n{old}"
s = s.replace(old, new, 1)
p.write_text(s)
# --- AppModel -----------------------------------------------------------------------------------------------
edit("Sources/Sst/AppModel.swift", [
# the two list view models
(" let storage = ModelStorage()\n",
" let storage = ModelStorage()\n let historyList: HistoryViewModel\n let dictionaryList: DictionaryViewModel\n"),
# preview mode: `init()` becomes a convenience init
(''' init() {
let settings = AppSettings()
let policy = InsertionPolicy()
let knowledge = SystemWordKnowledge()
let corrector = DictionaryCorrector(knowledge: knowledge)
let dictionary = DictionaryStore(fileURL: AppPaths.dictionary)
let history = HistoryStore(fileURL: AppPaths.history)
''', ''' convenience init() {
self.init(preview: false)
}
/// `preview: true` is for snapshots: sample data in a temporary folder, no permission prompts, no model, no hotkey.
init(preview: Bool) {
let settings = preview ? AppSettings(defaults: UserDefaults(suiteName: "sst-preview")!) : AppSettings()
let policy = InsertionPolicy()
let knowledge = SystemWordKnowledge()
let corrector = DictionaryCorrector(knowledge: knowledge)
let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\\(UUID().uuidString)", directoryHint: .isDirectory)
let dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary)
let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : AppPaths.history)
'''),
(''' self.dictionary = dictionary
self.history = history
self.controller''', ''' self.dictionary = dictionary
self.history = history
self.historyList = HistoryViewModel(store: history)
self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge)
self.controller'''),
(''' wire()
Task { await start() }
}''', ''' wire()
if preview {
loadPreviewData()
} else {
Task { await start() }
}
}'''),
# the views refresh when the stores change
(''' self.corrector.update(entries: self.dictionary.entries)
self.dictionaryRevision += 1''', ''' self.corrector.update(entries: self.dictionary.entries)
self.dictionaryList.reload()
self.dictionaryRevision += 1'''),
(" history.onChange = { [weak self] in self?.historyRevision += 1 }",
" history.onChange = { [weak self] in\n self?.historyRevision += 1\n self?.historyList.refresh()\n }"),
# STOP also ends a recording that the hotkey started
(''' func stopWindowRecording() {
guard policy.windowSession else { return }''', ''' func stopWindowRecording() {
guard isRecording else { return }'''),
("if !permissions.allGranted { return Strings.openSystemSettings }",
"if !permissions.allGranted { return Strings.permissionsMissing }"),
])
model = Path("Sources/Sst/AppModel.swift")
text = model.read_text().rstrip("\n")
assert text.endswith("}")
model.write_text(text[:-1] + '''
func openDictionaryFile() {
NSWorkspace.shared.open(dictionary.fileURL)
}
// MARK: preview
private func loadPreviewData() {
try? dictionary.load()
try? dictionary.apply { file in
file.append(.term("Anthropic"))
file.append(.term("Supabase"))
file.append(.correction(heard: "cloud code", wrote: "Claude Code"))
file.append(.correction(heard: "cloud", wrote: "Claude"))
}
let now = Date()
history.add(HistoryEntry(date: now.addingTimeInterval(-86_400 * 2), duration: 5.1,
rawText: "een oudere notitie over de planning", finalText: "Een oudere notitie over de planning."))
history.add(HistoryEntry(date: now.addingTimeInterval(-3_600), duration: 7.2,
rawText: "ik wil de meeting verplaatsen naar tomorrow morning",
finalText: "Ik wil de meeting verplaatsen naar tomorrow morning."))
history.add(HistoryEntry(
date: now.addingTimeInterval(-300), duration: 4.4,
rawText: "ik gebruik cloud code met supabase", finalText: "Ik gebruik Claude Code met Supabase.",
corrections: [
AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 11, length: 11),
AppliedCorrection(heard: "supabase", wrote: "Supabase", rule: "Supabase", start: 27, length: 8),
]))
activeVariant = ModelCatalog.defaultVariant
modelStatus = .ready
lastDuration = 4.4
historyList.selection = history.entries.first?.id
}
}
''')
# --- Strings ------------------------------------------------------------------------------------------------
edit("Sources/Sst/Strings.swift", [
(' static let openSystemSettings = "Open Instellingen"\n',
' static let openSystemSettings = "Open Instellingen"\n static let open = "Open"\n static let permissionsMissing = "Geef eerst de rechten onder Controles"\n'),
(" // deck\n", ''' // lists
static let copy = "Kopieer"
static let copyOriginal = "Kopieer origineel"
static let delete = "Verwijder"
static let original = "Origineel"
static let noTranscripts = "Nog geen transcripties. Houd de sneltoets ingedrukt en praat."
static let noResults = "Geen resultaten"
static let historyOff = "De geschiedenis staat uit (Instellingen → Privacy)."
static func corrections(_ count: Int) -> String { count == 1 ? "1 correctie" : "\\(count) correcties" }
static func entries(_ count: Int) -> String { count == 1 ? "1 invoer" : "\\(count) invoeren" }
// dictionary
static let heard = "Gehoord"
static let wrote = "Schrijf"
static let term = "Term"
static let correction = "Correctie"
static let add = "+"
static let openFile = "Open bestand"
static let priming = "Voorrang"
static let correcting = "Correctie"
static let undo = "Herstel"
static let save = "Bewaar"
static let editEntry = "Invoer"
static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +."
// deck
'''),
])
# --- SstApp: the entry point moves to main.swift (needed for the debug snapshot mode) -------------------------
edit("Sources/Sst/SstApp.swift", [("@main\nstruct SstApp: App {", "struct SstApp: App {")])
print("phase 5 edits applied")
Run: python3 apply_plan5_edits.py && rm apply_plan5_edits.py
Expected: phase 5 edits applied; het script is weg.
- Step 2: Nieuwe bestanden
Sources/Sst/ListSupport.swift:
import SstUI
import SwiftUI
/// Debug snapshots (`Sst --snapshot`) are drawn by `ImageRenderer`, which cannot draw scroll views or text fields.
/// While rendering one, the views below fall back to plain stacks and text.
@MainActor
enum SnapshotMode {
static var isActive = false
}
/// A scrolling, lazily built list (a plain stack while a snapshot is rendered).
struct ScrollList<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
if SnapshotMode.isActive {
VStack(alignment: .leading, spacing: 0) { content }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { content }
}
}
}
}
/// The recessed "ZOEK" field.
struct SearchField: View {
@Binding var text: String
var focused: FocusState<Bool>.Binding
var body: some View {
HStack(spacing: Space.s4) {
SilkLabel(Strings.search)
Group {
if SnapshotMode.isActive {
Text(text).frame(maxWidth: .infinity, alignment: .leading)
} else {
TextField("", text: $text)
.textFieldStyle(.plain)
.focused(focused)
}
}
.textStyle(Typography.body)
.foregroundStyle(Palette.inkPrimary)
.padding(Space.s3)
.background(WellBackground())
}
}
}
Sources/Sst/main.swift:
import AppKit
#if DEBUG
// `Sst --snapshot <prefix>` renders the main window with sample data to <prefix>-transcripts.png and
// <prefix>-dictionary.png (Aluminium left, Graphite right) and exits. No prompts, no model, no hotkey.
if let flag = CommandLine.arguments.firstIndex(of: "--snapshot"), CommandLine.arguments.count > flag + 1 {
MainActor.assumeIsolated { Snapshot.run(prefix: CommandLine.arguments[flag + 1]) }
exit(0)
}
#endif
SstApp.main()
Sources/Sst/Snapshot.swift:
#if DEBUG
import AppKit
import SstUI
import SwiftUI
@MainActor
enum Snapshot {
static func run(prefix: String) {
_ = NSApplication.shared
SnapshotMode.isActive = true
let model = AppModel(preview: true)
for section in AppModel.Section.allCases {
model.section = section
let view = HStack(spacing: 0) {
MainWindow().environment(\.colorScheme, .light)
MainWindow().environment(\.colorScheme, .dark)
}
.environmentObject(model)
.environmentObject(model.settings)
.frame(width: Layout.windowDefaultWidth * 2, height: Layout.windowDefaultHeight)
let renderer = ImageRenderer(content: view)
renderer.scale = 1
if let tiff = renderer.nsImage?.tiffRepresentation,
let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) {
try? png.write(to: URL(fileURLWithPath: "\(prefix)-\(section.rawValue).png"))
}
}
}
}
#endif
- Step 3: Bouwen, testen en de snapshot-infrastructuur controleren
Run: swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && ls /tmp/sst-main-*.png
Expected: build zonder fouten of waarschuwingen; alle tests PASS (incl. token-scan); twee PNG's. Ze tonen nog de schil van het venster; de echte weergaven volgen in Task 3 t/m 5.
- Step 4: Commit
git add Sources/Sst
git commit -m "feat: wire list view models into AppModel, add strings, list helpers and debug snapshot mode" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 3: Deck, zijbalk met CONTROLES en het venster
Files:
- Create:
Sources/Sst/DeckView.swift,Sources/Sst/Sidebar.swift,Sources/Sst/HistoryView.swift(tijdelijk),Sources/Sst/DictionaryView.swift(tijdelijk) - Replace:
Sources/Sst/MainWindow.swift
Interfaces:
-
Consumes: Task 2;
SstUI(VUMeterView,SegmentCounterView,LEDBarView,KeyButtonStyle,LampView, panelen);LevelSource,StopwatchFormat. -
Produces:
DeckView,Sidebar,ControlsBlock,MainWindow(zijbalk + deck + gekozen weergave, met het wis-geschiedenis-dialoog), en tijdelijke legeHistoryView(list:)enDictionaryView(list:)die Task 4 en 5 vervangen. -
Step 1: Bestanden schrijven
Sources/Sst/DeckView.swift:
import SstData
import SstUI
import SwiftUI
/// The transport deck: VU meter, counter, LED bar and the REC/STOP keys. Always visible at the top.
struct DeckView: View {
@EnvironmentObject private var model: AppModel
var body: some View {
HStack(alignment: .top, spacing: Layout.panelGap) {
instruments
Spacer(minLength: 0)
controls
}
.padding(Layout.panelPadding)
.background(PanelBackground())
}
private var instruments: some View {
TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !model.isRecording)) { context in
let dbfs = Double(model.levelSource.dbfs)
HStack(alignment: .top, spacing: Layout.panelGap) {
VUMeterView(levelDbfs: dbfs, isActive: model.isRecording, brand: AppIdentity.brandCaption)
.frame(width: MeterTokens.vuWidth)
VStack(alignment: .leading, spacing: Layout.panelGap) {
SilkLabel(Strings.time, style: Typography.labelSmall)
SegmentCounterView(StopwatchFormat.string(elapsed(at: context.date)))
SilkLabel(Strings.level, style: Typography.labelSmall)
LEDBarView(fraction: model.isRecording ? VUScale.fraction(forDbfs: dbfs) : 0)
}
}
}
}
private func elapsed(at date: Date) -> TimeInterval {
if let started = model.recordingStartedAt { return date.timeIntervalSince(started) }
return model.lastDuration ?? 0
}
private var controls: some View {
VStack(alignment: .trailing, spacing: Layout.panelGap) {
HStack(spacing: Layout.panelGap) {
Button { model.toggleWindowRecording() } label: {
HStack(spacing: Space.s3) {
LampView(isOn: model.isRecording)
Text(Strings.record)
}
}
.buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording))
.disabled(!model.isRecording && model.recordingBlocker != nil)
.help(model.recordingBlocker ?? "")
Button(Strings.stop) { model.stopWindowRecording() }
.buttonStyle(KeyButtonStyle())
.disabled(!model.isRecording)
}
if let error = model.lastError {
HStack(spacing: Space.s3) {
LampView(isOn: true, tint: .amber)
Text(error)
.textStyle(Typography.meta)
.foregroundStyle(Palette.inkSecondary)
.multilineTextAlignment(.trailing)
}
} else {
Text(model.statusLine)
.textStyle(Typography.meta)
.foregroundStyle(Palette.inkSecondary)
.multilineTextAlignment(.trailing)
}
}
.frame(maxWidth: Layout.sidebarWidth, alignment: .trailing)
}
}
Sources/Sst/Sidebar.swift:
import SstUI
import SwiftUI
struct Sidebar: View {
@EnvironmentObject private var model: AppModel
var body: some View {
VStack(alignment: .leading, spacing: Layout.panelGap) {
SilkLabel(AppIdentity.brandCaption)
ForEach(AppModel.Section.allCases) { section in
Button {
model.section = section
} label: {
Text(title(for: section)).frame(maxWidth: .infinity)
}
.buttonStyle(KeyButtonStyle(latched: model.section == section))
}
Spacer()
ControlsBlock()
}
.padding(Layout.panelPadding)
.frame(width: Layout.sidebarWidth)
}
private func title(for section: AppModel.Section) -> String {
switch section {
case .transcripts: Strings.transcripts
case .dictionary: Strings.dictionary
}
}
}
/// Four lamps: the three macOS permissions and the speech model.
struct ControlsBlock: View {
@EnvironmentObject private var model: AppModel
var body: some View {
VStack(alignment: .leading, spacing: Space.s3) {
SilkLabel(Strings.controls)
SeamDivider()
ForEach(PermissionMonitor.Kind.allCases) { kind in
let granted = model.permissions.isGranted(kind)
HStack(spacing: Space.s3) {
LampView(isOn: granted, tint: .amber)
Text(kind.title).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
Spacer()
if !granted {
Button(Strings.open) { model.permissions.openSettings(for: kind) }
.buttonStyle(KeyButtonStyle())
.help(Strings.openSystemSettings)
}
}
}
HStack(spacing: Space.s3) {
LampView(isOn: model.modelReady, tint: .amber)
Text(Strings.modelLamp).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
Spacer()
}
}
.onAppear { model.permissions.startPolling() }
.onDisappear { model.permissions.stopPolling() }
}
}
Sources/Sst/MainWindow.swift (vervang volledig):
import SstUI
import SwiftUI
struct MainWindow: View {
@EnvironmentObject private var model: AppModel
var body: some View {
ZStack {
BrushedBackground().ignoresSafeArea()
HStack(spacing: 0) {
Sidebar()
VStack(spacing: Layout.panelGap) {
DeckView()
switch model.section {
case .transcripts: HistoryView(list: model.historyList)
case .dictionary: DictionaryView(list: model.dictionaryList)
}
}
.padding(Layout.panelPadding)
}
}
.confirmationDialog(Strings.clearHistoryConfirm, isPresented: $model.confirmingClearHistory) {
Button(Strings.erase, role: .destructive) { model.clearHistory() }
Button(Strings.cancel, role: .cancel) {}
} message: {
Text(Strings.clearHistoryDetail)
}
}
}
Tijdelijk Sources/Sst/HistoryView.swift:
import SstData
import SstUI
import SwiftUI
// Temporary: replaced in Task 4.
struct HistoryView: View {
@ObservedObject var list: HistoryViewModel
var body: some View { PaperBackground() }
}
Tijdelijk Sources/Sst/DictionaryView.swift:
import SstData
import SstUI
import SwiftUI
// Temporary: replaced in Task 5.
struct DictionaryView: View {
@ObservedObject var list: DictionaryViewModel
var body: some View { PaperBackground() }
}
- Step 2: Bouwen, testen en bekijken
Run: swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-transcripts.png
Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding (Aluminium links, Graphite rechts) toont links de zijbalk met twee navigatietoetsen (de gekozen is ingedrukt) en onderaan CONTROLES met vier lampjes en korte "Open"-toetsen (geen afgebroken tekst); rechts het deck met VU-meter, teller, LED-balk, REC (uitgeschakeld) en STOP, met de melding "Geef eerst de rechten onder Controles"; daaronder een leeg papieren vlak.
- Step 3: Commit
git add Sources/Sst
git commit -m "feat: add deck, sidebar with controls and the main window layout" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 4: Transcriptiegeschiedenis
Files:
- Replace:
Sources/Sst/HistoryView.swift
Interfaces:
-
Consumes: Task 1 (
HistoryViewModel,CorrectionMarkup), Task 2 (ScrollList,SearchField,Strings). -
Produces:
HistoryView(list:)— zoekveld (focus viaAppModel.searchFocusTick), lijst per dag, rij met kopieerknop, correctiemarkering en -samenvatting, contextmenu, ⌫-verwijderen, lege staten. -
Step 1: Implementatie
Sources/Sst/HistoryView.swift (vervang volledig):
import AppKit
import SstCore
import SstData
import SstUI
import SwiftUI
private let dutch = Locale(identifier: "nl_NL")
struct HistoryView: View {
@EnvironmentObject private var model: AppModel
@ObservedObject var list: HistoryViewModel
@FocusState private var searchFocused: Bool
var body: some View {
VStack(spacing: Layout.panelGap) {
SearchField(text: $list.query, focused: $searchFocused)
ZStack {
PaperBackground()
content
}
}
.onChange(of: model.searchFocusTick) { _, _ in searchFocused = true }
}
@ViewBuilder
private var content: some View {
if list.days.isEmpty {
Text(emptyMessage)
.textStyle(Typography.body)
.foregroundStyle(Palette.inkSecondary)
.padding(Layout.panelPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
ScrollList {
ForEach(list.days) { day in
Section {
ForEach(day.entries) { entry in
HistoryRow(entry: entry, isSelected: list.selection == entry.id, list: list)
}
} header: {
SilkLabel(day.day.formatted(Date.FormatStyle(date: .complete, time: .omitted).locale(dutch)))
.padding(.horizontal, Space.s4)
.padding(.vertical, Space.s3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Palette.surfacePaper)
}
}
}
.focusable()
.onDeleteCommand {
if let id = list.selection { list.delete(id: id) }
}
}
}
private var emptyMessage: String {
if list.totalCount > 0 { return Strings.noResults }
return model.settings.keepHistory ? Strings.noTranscripts : Strings.historyOff
}
}
private struct HistoryRow: View {
let entry: HistoryEntry
let isSelected: Bool
@ObservedObject var list: HistoryViewModel
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: Space.s4) {
Text(entry.date.formatted(Date.FormatStyle().hour().minute().second().locale(dutch)))
.textStyle(Typography.counterSmall)
.foregroundStyle(Palette.inkSecondary)
VStack(alignment: .leading, spacing: Space.s2) {
Text(markedText)
.textStyle(Typography.reading)
.foregroundStyle(Palette.inkPrimary)
.textSelection(.enabled)
if !entry.corrections.isEmpty {
SilkLabel(correctionSummary, style: Typography.labelSmall)
}
if isSelected && entry.rawText != entry.finalText {
Text("\(Strings.original): \(entry.rawText)")
.textStyle(Typography.meta)
.foregroundStyle(Palette.inkSecondary)
}
}
Spacer(minLength: Space.s4)
Button(Strings.copy) { copy(entry.finalText) }
.buttonStyle(KeyButtonStyle())
}
.padding(.horizontal, Space.s4)
.padding(.vertical, Space.s3)
.frame(minHeight: Size.rowHeight)
.background(isSelected ? Palette.surfaceSelection : Color.clear)
.contentShape(Rectangle())
.onTapGesture { list.selection = entry.id }
.contextMenu {
Button(Strings.copy) { copy(entry.finalText) }
Button(Strings.copyOriginal) { copy(entry.rawText) }
Divider()
Button(Strings.delete, role: .destructive) { list.delete(id: entry.id) }
}
}
/// The final text with every dictionary replacement highlighted.
private var markedText: AttributedString {
var result = AttributedString()
for segment in CorrectionMarkup.segments(finalText: entry.finalText, corrections: entry.corrections) {
var piece = AttributedString(segment.text)
if segment.correction != nil {
piece.backgroundColor = Palette.levelAmber.opacity(Opacity.mark)
}
result += piece
}
return result
}
private var correctionSummary: String {
let changes = entry.corrections.map { "\($0.heard) → \($0.wrote)" }.joined(separator: ", ")
return "\(Strings.corrections(entry.corrections.count)) · \(changes)"
}
private func copy(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
}
- Step 2: Bouwen, testen en bekijken
Run: swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-transcripts.png
Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding toont drie voorbeeldtranscripties onder twee dagkoppen in het Nederlands ("DONDERDAG 1 OKTOBER 2026"); de nieuwste (geselecteerde) rij heeft "Claude Code" en "Supabase" met amber markering, de regel "2 CORRECTIES · CLOUD CODE → CLAUDE CODE, SUPABASE → SUPABASE" en de oorspronkelijke tekst; elke rij heeft een KOPIEER-toets; tijden staan in mono.
- Step 3: Commit
git add Sources/Sst
git commit -m "feat: add transcript history view with corrections, search and copy" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 5: Woordenboek met editor
Files:
- Replace:
Sources/Sst/DictionaryView.swift
Interfaces:
-
Consumes: Task 1 (
DictionaryViewModel), Task 2 (ScrollList,SearchField,Strings,AppModel.openDictionaryFile()), fase 4 (RockerToggleStyle,AppSettings). -
Produces:
DictionaryView(list:)met zoekveld,+, "Open bestand", schakelaars Voorrang/Correctie, tabel, voettekst met telling en "Herstel", en de editor (sheet) met live waarschuwingen. -
Step 1: Implementatie
Sources/Sst/DictionaryView.swift (vervang volledig):
import SstData
import SstUI
import SwiftUI
struct DictionaryView: View {
enum EditorTarget: Identifiable, Equatable {
case new
case existing(UUID)
var id: String {
switch self {
case .new: "new"
case .existing(let id): id.uuidString
}
}
}
@EnvironmentObject private var model: AppModel
@EnvironmentObject private var settings: AppSettings
@ObservedObject var list: DictionaryViewModel
@State private var selection: UUID?
@State private var editing: EditorTarget?
@FocusState private var searchFocused: Bool
var body: some View {
VStack(spacing: Layout.panelGap) {
header
ZStack {
PaperBackground()
table
}
footer
}
.sheet(item: $editing) { target in
EntryEditor(list: list, target: target) { editing = nil }
}
}
private var header: some View {
VStack(spacing: Layout.panelGap) {
HStack(spacing: Space.s4) {
SearchField(text: $list.query, focused: $searchFocused)
Button(Strings.add) { editing = .new }.buttonStyle(KeyButtonStyle())
Button(Strings.openFile) { model.openDictionaryFile() }.buttonStyle(KeyButtonStyle())
}
HStack(spacing: Layout.panelGap) {
Toggle(isOn: $settings.usePriming) {
SilkLabel(Strings.priming)
}
.toggleStyle(RockerToggleStyle())
Toggle(isOn: $settings.useCorrection) {
SilkLabel(Strings.correcting)
}
.toggleStyle(RockerToggleStyle())
Spacer()
}
}
}
private var table: some View {
VStack(spacing: 0) {
HStack(spacing: Space.s4) {
Color.clear.frame(width: Size.iconMedium, height: Size.iconMedium)
SilkLabel(Strings.heard).frame(maxWidth: .infinity, alignment: .leading)
SilkLabel(Strings.wrote).frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, Space.s4)
.padding(.vertical, Space.s3)
if list.visibleRows.isEmpty {
Text(list.rows.isEmpty ? Strings.emptyDictionary : Strings.noResults)
.textStyle(Typography.body)
.foregroundStyle(Palette.inkSecondary)
.padding(Layout.panelPadding)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
} else {
ScrollList {
ForEach(list.visibleRows) { row in
DictionaryRow(row: row, isSelected: selection == row.id)
.onTapGesture(count: 2) { editing = .existing(row.id) }
.onTapGesture { selection = row.id }
}
}
}
}
.focusable()
.onDeleteCommand {
if let id = selection { list.remove(id: id) }
}
.onKeyPress(KeyEquivalent("z"), phases: .down) { press in
guard press.modifiers == .command, list.canUndo else { return .ignored }
list.undo()
return .handled
}
}
private var footer: some View {
HStack(spacing: Layout.panelGap) {
SilkLabel(Strings.entries(list.rows.count), style: Typography.labelSmall)
if let message = list.errorMessage {
Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
}
Spacer()
Button(Strings.undo) { list.undo() }
.buttonStyle(KeyButtonStyle())
.disabled(!list.canUndo)
}
}
}
private struct DictionaryRow: View {
let row: DictionaryViewModel.Row
let isSelected: Bool
var body: some View {
HStack(spacing: Space.s4) {
lamp
.frame(width: Size.iconMedium, height: Size.iconMedium)
if let heard = heard {
Text(heard).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text("—").textStyle(Typography.code).foregroundStyle(Palette.inkSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
Text(row.entry.wrote).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, Space.s4)
.padding(.vertical, Space.s3)
.frame(minHeight: Size.rowHeight)
.background(isSelected ? Palette.surfaceSelection : Color.clear)
.contentShape(Rectangle())
.help(row.warnings.map(\.message).joined(separator: "\n"))
}
private var heard: String? {
if case .correction(let heard, _) = row.entry.kind { return heard }
return nil
}
@ViewBuilder
private var lamp: some View {
switch row.worst {
case .error: LampView(isOn: true)
case .warning: LampView(isOn: true, tint: .amber)
default: LampView(isOn: false, tint: .amber)
}
}
}
/// Add or edit one entry, with live warnings.
private struct EntryEditor: View {
@ObservedObject var list: DictionaryViewModel
let target: DictionaryView.EditorTarget
let onClose: () -> Void
@State private var isCorrection = false
@State private var heard = ""
@State private var wrote = ""
private var editingID: UUID? {
if case .existing(let id) = target { return id }
return nil
}
private var kind: DictionaryEntry.Kind {
let write = wrote.trimmingCharacters(in: .whitespaces)
return isCorrection ? .correction(heard: heard.trimmingCharacters(in: .whitespaces), wrote: write) : .term(write)
}
private var warnings: [EntryWarning] {
let emptySource = isCorrection ? heard.trimmingCharacters(in: .whitespaces).isEmpty : wrote.trimmingCharacters(in: .whitespaces).isEmpty
guard !emptySource, !wrote.trimmingCharacters(in: .whitespaces).isEmpty else { return [] }
return list.warnings(for: kind, editing: editingID)
}
private var canSave: Bool {
let hasText = !wrote.trimmingCharacters(in: .whitespaces).isEmpty && (!isCorrection || !heard.trimmingCharacters(in: .whitespaces).isEmpty)
return hasText && !warnings.contains { $0.level == .error }
}
var body: some View {
ZStack {
BrushedBackground().ignoresSafeArea()
VStack(alignment: .leading, spacing: Layout.panelGap) {
HStack(spacing: Space.s3) {
Button(Strings.term) { isCorrection = false }.buttonStyle(KeyButtonStyle(latched: !isCorrection))
Button(Strings.correction) { isCorrection = true }.buttonStyle(KeyButtonStyle(latched: isCorrection))
}
VStack(alignment: .leading, spacing: Layout.panelGap) {
if isCorrection { field(Strings.heard, text: $heard) }
field(Strings.wrote, text: $wrote)
ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in
HStack(alignment: .firstTextBaseline, spacing: Space.s3) {
LampView(isOn: warning.level != .note, tint: warning.level == .error ? .record : .amber)
Text(warning.message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary)
}
}
}
.padding(Layout.panelPadding)
.frame(maxWidth: .infinity, alignment: .leading)
.background(PanelBackground())
HStack(spacing: Space.s3) {
Spacer()
Button(Strings.cancel, action: onClose).buttonStyle(KeyButtonStyle())
Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave)
}
}
.padding(Layout.panelPadding)
}
.frame(width: Layout.settingsWidth)
.onAppear(perform: load)
}
private func field(_ title: String, text: Binding<String>) -> some View {
HStack(spacing: Space.s4) {
SilkLabel(title).frame(width: Size.keyRecWidth, alignment: .leading)
TextField("", text: text)
.textFieldStyle(.plain)
.textStyle(Typography.code)
.foregroundStyle(Palette.inkPrimary)
.padding(Space.s3)
.background(WellBackground())
}
}
private func load() {
guard let id = editingID, let row = list.rows.first(where: { $0.id == id }) else { return }
switch row.entry.kind {
case .term(let term): (isCorrection, heard, wrote) = (false, "", term)
case .correction(let h, let w): (isCorrection, heard, wrote) = (true, h, w)
}
}
private func save() {
if let id = editingID {
list.update(id: id, to: kind)
} else {
list.add(kind)
}
onClose()
}
}
- Step 2: Bouwen, testen en bekijken
Run: swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-dictionary.png
Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding toont het woordenboek met vier voorbeeldinvoeren: twee termen ("—" links), "cloud code → Claude Code" en "cloud → Claude"; alleen de laatste heeft een brandend amber lampje (gewoon woord). Zoekveld, + en OPEN BESTAND staan boven; twee tuimelschakelaars (VOORRANG, CORRECTIE); onderaan "4 INVOEREN" en een HERSTEL-toets (uitgeschakeld).
- Step 3: Commit
git add Sources/Sst
git commit -m "feat: add dictionary view with editor, warnings and undo" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Task 6: Handmatige acceptatie met de gebruiker
Dit vraagt rechten, het toetsenbord en het scherm van de gebruiker; subagents voeren dit niet uit.
- Step 1: Voorbereiden
Run: ./scripts/make-app.sh && open build/Sst.app (rechten en model zijn al in orde uit fase 4). Alle vier CONTROLES-lampjes branden binnen enkele seconden.
- Step 2: Deck en opnemen
- 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.
- Het resultaat staat in Transcripties; er is niets geplakt (opname vanuit het venster).
- Houd Fn ingedrukt in TextEdit en spreek: de tekst wordt geplakt én staat in de lijst. Het deck beweegt ook dan.
- Start een opname met de sneltoets en klik STOP in het venster: de opname eindigt en wordt verwerkt.
- ⌥⌘R start en stopt een opname.
- Step 3: Geschiedenis
- Zoek met ⌘F (veld krijgt focus); zoek "cafe" vindt "Café"; meerdere woorden in willekeurige volgorde.
- KOPIEER zet de tekst op het klembord; rechtsklik: kopieer origineel, verwijder; selecteer een rij en druk ⌫.
- 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
- Voeg met
+de correctiecloud code => Claude Codetoe: de editor toont een gele melding (alle delen zijn gewone woorden); bewaar. - 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.
- Voeg
cloud => Claudetoe: waarschuwing (gewoon woord), lampje brandt amber. Verwijder met ⌫ en herstel met ⌘Z. - Voeg de term
Supabasetoe en zeg het woord: de schrijfwijze is goed. - Zet VOORRANG uit, dan CORRECTIE uit: elk mechanisme heeft zichtbaar effect (zonder CORRECTIE blijft "cloud code" staan).
- 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. - Meet de snelheid: zeg 8 seconden spraak met en zonder VOORRANG; noteer beide tijden (doel: onder 1,5 s).
- Zeg niets (hou de sneltoets 2 s ingedrukt) met een gevuld woordenboek: er wordt geen tekst ingevoegd.
- Step 5: Toegankelijkheid en afronden
- Alle bediening is met het toetsenbord bereikbaar; de focusring is zichtbaar.
- VoiceOver leest "Niveau" en "Tijd" voor; de lampjes zijn verborgen voor VoiceOver.
- Zet "Verminder beweging" aan: geen overschrijding van de wijzer.
- Draai
swift test: alle tests PASS. - 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 doorAppModel(Task 2) en de views (Task 3–5) met dezelfde namen gebruikt;Strings-sleutels in de views bestaan na het script in Task 2;AppSettingswordt aan het hoofdvenster meegegeven inSstApp(fase 4). - Risico's:
ImageRenderertekent geen scrollende lijsten of tekstvelden;SnapshotModevervangt 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).