1444 lines
60 KiB
Markdown
1444 lines
60 KiB
Markdown
# 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).
|