diff --git a/docs/superpowers/specs/2026-10-01-macos-app-design.md b/docs/superpowers/specs/2026-10-01-macos-app-design.md new file mode 100644 index 0000000..7919a75 --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-macos-app-design.md @@ -0,0 +1,259 @@ +# Sst als volwaardige macOS-app — ontwerp + +Datum: 2026-10-01 · Status: concept ter review · Bouwt voort op `2026-10-01-push-to-talk-dictation-design.md` (de engine) en de goedgekeurde tokens in `docs/design/tokens.md` (bron: `Design/tokens.json`, v0.1.0-draft) + +## Doel + +Van menubalk-hulpprogramma naar een echte Mac-app: Dock-icoon, app-menu, een vrij schaalbaar hoofdvenster, een instellingenvenster (⌘,) en een dubbelklikbaar `Sst.app` dat in Programma's kan staan. Het menubalkitem blijft als secundaire status- en sneltoetsweergave. De interface volgt het goedgekeurde ontwerpsysteem: een jaren-80 veldrecorder, met uitsluitend waarden uit de tokens. + +## Wat de gebruiker krijgt + +- **Hoofdvenster:** doorzoekbare transcriptiegeschiedenis met een kopieerknop per rij; een live VU-meter, teller en LED-balk tijdens het opnemen; start- en stopknop; het woordenboek. +- **Instellingen (⌘,):** sneltoets en model. +- **Woordenboek:** woorden en correctieparen, bewerkbaar in de app én als gewoon tekstbestand. Werkt op twee manieren: voorrang bij het model (bias) en een gegarandeerde correctieronde achteraf. +- **Geschiedenis toont correcties:** wanneer het woordenboek iets wijzigt, zie je wat er stond en wat het werd. + +## Buiten scope + +- Overlay-pill tijdens het dicteren, AI-cleanup (taalmodel), streaming-transcriptie. +- Meerdere woordenboeken of profielen, per-app-instellingen, import of export van de geschiedenis, synchronisatie. +- Notarisatie, DMG, automatische updates, automatisch starten bij inloggen. +- Toggle-modus (eenmaal drukken start, nogmaals stopt). Alleen vasthouden. +- Definitieve naam en merk. "Sst" is de werktitel; de naam staat op één plek (`AppIdentity`) zodat hernoemen goedkoop blijft. +- Een app-icoon in volledige merkkwaliteit; wel een eerste icoon in het goedgekeurde ontwerp. + +## Beslissingen en aannames + +1. **De engine blijft in gedrag identiek.** Er komen vier kleine, additieve raakpunten bij (zie "Engine-raakpunten"). Bestaande tests blijven ongewijzigd groen. +2. **"Een kopie van elk document"** is een **kopieerknop per transcriptie** (kopieert de uiteindelijke tekst). +3. **UI-taal is Nederlands**, in één bestand met teksten (`Strings`), zodat vertalen later kan. Systeemmenu's (Hide, Quit, enz.) volgen de taal van macOS. +4. **Geschiedenis staat standaard aan** en bewaart alleen tekst, nooit audio. Een schakelaar in Instellingen zet bewaren uit en een knop wist alles. +5. **Sluit je het hoofdvenster, dan draait de app door** (Dock-icoon en menubalkitem blijven). Dicteren in andere apps blijft werken. ⌘Q sluit de app echt af. Klikken op het Dock-icoon opent het venster weer. +6. **Is Sst zelf de voorgrond-app** (of start je de opname met de knop in het venster), dan wordt de tekst **niet geplakt** maar alleen in de geschiedenis bewaard. Anders zou het dictaat in het zoekveld van Sst terechtkomen. +7. **Ondertekening:** met je Apple Development-certificaat, zodat macOS je rechten (Microfoon, Accessibility, Input Monitoring) na een herbouw niet vergeet. Het script zoekt zelf de eerste "Apple Development"-identiteit en valt anders terug op ad-hoc, met een duidelijke waarschuwing. Geen identiteit staat in de repo. +8. **Sneltoetsen:** alleen modifier-toetsen, vasthouden. Keuze: Fn (standaard), Rechter Option, Rechter Command, Rechter Control, Linker Control. +9. **Afwerking** (Aluminium of Graphite) volgt het uiterlijk van macOS. + +## Architectuur + +Eén Swift Package, vier lagen, steeds in één richting afhankelijk: + +``` +Sst (app) → SstUI → (tokens, componenten) + │ + ├────────→ SstData → (woordenboek, correcties, geschiedenis; alleen Foundation) + └────────→ SstCore + SstWhisper (de engine; dunne uitbreidingen) +``` + +| Module | Verantwoordelijkheid | Testbaar zonder UI | +|---|---|---| +| `SstCore` / `SstWhisper` | Engine (bestaat). Vier additieve raakpunten. | ja | +| `SstData` | `DictionaryFile`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistoryStore`, `HistorySearch` | ja | +| `SstUI` | Gegenereerde `DesignTokens`, componenten (toets, lampje, VU-meter, segmentteller, LED-balk, paneel, verzonken vlak, zeefdruk-label) | gedeeltelijk | +| `Sst` | Scenes, menu's, `AppModel`, vensters, bundelscript | handmatig | + +### Engine-raakpunten (allemaal additief, met standaardwaarden) + +1. **Voorrang voor het model.** `Transcriber` krijgt `transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String`. De oude methode blijft bestaan als extensie met een lege lijst. `WhisperKitTranscriber` zet de woorden om in `DecodingOptions.promptTokens`: tekst `" " + woorden.joined(", ")`, via `tokenizer.encode(text:)`, gefilterd op tokens onder `specialTokens.specialTokenBegin` (zo doet de WhisperKit-CLI het ook). +2. **Niveau-uitvoer.** `AudioRecorder` krijgt `onLevel: ((Float) -> Void)?`: RMS in dBFS per buffer van 1024 frames (~21 ms bij 48 kHz). De bestaande buffer van 4096 blijft ongewijzigd voor de opname zelf; de niveau-tap rekent op dezelfde audio. +3. **Resultaat-hook en correctie.** `DictationController` krijgt een optionele `corrector: TextCorrecting?` en `onResult: ((DictationResult) -> Void)?`. `DictationResult` bevat `rawText`, `finalText`, `corrections`, `duration`, `date`. Zonder corrector en hook gedraagt de controller zich exact als nu. +4. **Instelbare toets.** `HotkeyMonitor` krijgt een `HotkeyBinding` (keycode + vlagmasker) via een nieuwe `ModifierKeyTracker`. `FnKeyTracker` blijft bestaan en wordt de Fn-variant. Het keycode-filter (nu 63) wordt de keycode van de gekozen binding. + +## App-shell + +### Scenes + +``` +Window("Sst", id: "main") — één hoofdvenster, geen documentvensters +Settings — ⌘, +MenuBarExtra — secundair +.commands { … } — app-menu en menu's +``` + +Zonder `LSUIElement`, `NSPrincipalClass` standaard. `applicationShouldHandleReopen` opent het hoofdvenster als alle vensters dicht zijn. Vensterafmetingen komen uit `layout.window.*`: minimaal 800×560, standaard 1080×700; macOS onthoudt de laatste grootte. + +### Menu's + +| Menu | Inhoud | +|---|---| +| **Sst** | Over Sst, Instellingen… (⌘,), Verberg, Verberg anderen, Toon alles, Stop Sst (⌘Q) | +| **Archief** | Sluit venster (⌘W) | +| **Bewerken** | Standaard (Kopieer, Selecteer alles, Zoek…); Verwijder transcriptie (⌘⌫); Wis geschiedenis… | +| **Opname** | Start of stop opname (⌥⌘R); Transcripties (⌘1); Woordenboek (⌘2); Zoek (⌘F) | +| **Venster** / **Help** | Standaard | + +### Menubalkitem (secundair) + +Statusicoon met SF Symbols (`mic`, `mic.fill`, `waveform`, `exclamationmark.triangle`), geen aangepast beeld. Menu: één statusregel, de gekozen sneltoets ("Houd RECHTER OPTION ingedrukt"), "Open Sst", "Instellingen…", "Stop Sst". Geen geschiedenis, geen meter. + +### Bundel en installatie + +`scripts/make-app.sh` bouwt `build/Sst.app` met: `CFBundleIdentifier` (huidige `local.sst.app` blijft, zodat bestaande rechten blijven werken), versie, `LSApplicationCategoryType` productivity, `CFBundleIconFile`, `NSMicrophoneUsageDescription` in het Nederlands, **zonder `LSUIElement`**. Het script ondertekent met de gevonden identiteit. `--install` kopieert naar `/Applications` (alleen door de gebruiker uit te voeren). Het icoon komt uit een script dat een SwiftUI-weergave met de tokens naar een `.icns` rendert (`Design/make_icon`). + +### Rechten en eerste start + +Een blok "CONTROLES" onderaan de zijbalk toont vier lampjes: Microfoon, Accessibility, Input Monitoring, Model. Elk heeft een knop naar de juiste Systeeminstellingen. Rechten worden **opnieuw gelezen zodra de app actief wordt** en elke 2 s zolang het blok zichtbaar is, zodat de weergave niet meer verouderd raakt. Tot alle vier branden staat de REC-toets uit met een duidelijke reden. + +## Hoofdvenster + +Indeling (afmetingen en kleuren uitsluitend uit tokens): + +``` +┌─ zijbalk 232 ─┬─ deck (vast, bovenaan) ───────────────────────┐ +│ TRANSCRIPTIES │ [VU-meter] [00:12.4] [LED-balk] ● REC STOP │ +│ WOORDENBOEK ├────────────────────────────────────────────────┤ +│ │ zoekveld │ +│ CONTROLES │ lijst (papier) of woordenboek │ +└───────────────┴────────────────────────────────────────────────┘ +``` + +### Deck + +- **VU-meter:** 0 VU = −20 dBFS. `vu = rmsDbfs + 20`, schaal −20…+3. De wijzer volgt `onLevel` met de veer uit `motion.needle.*` (respons 0,30 s, demping 0,80; met "Verminder beweging" demping 1,0). Piek-LED 1000 ms. Getekend met `Canvas`, ververst met 60 Hz via `TimelineView` **alleen wanneer het venster zichtbaar is én er wordt opgenomen**; daarna valt de wijzer terug en de timer stopt (geen CPU in rust). +- **Teller:** segmentcijfers (`segment.*`) op `surface.display`, ghost-segmenten voor gedoofde delen. Toont de opnametijd; in rust de duur van de laatste opname. +- **LED-balk:** 12 segmenten (8 groen, 3 amber, 1 rood) op hetzelfde signaal. +- **REC / STOP:** REC start, STOP beëindigt; de REC-toets blijft ingedrukt zolang er wordt opgenomen, met brandend lampje. Een sessie vanuit het venster slaat alleen op (zie beslissing 6). +- **Fout:** een amber lampje met een korte regel; wordt gewist bij de volgende opname. + +### Transcripties + +- Gesorteerd nieuw → oud, gegroepeerd per dag (zeefdruk-kop). Rij: tijd (`type.counterSmall`), tekst (`type.reading`), kopieerknop, minimale hoogte `size.row.height`. +- **Zoeken** (⌘F): live, niet hoofdlettergevoelig en diakriet-ongevoelig, op zowel de uiteindelijke als de oorspronkelijke tekst; woorden hoeven niet aaneen te staan (alle zoekwoorden moeten voorkomen). +- **Correcties zichtbaar:** vervangen stukken krijgen de amber markering (`opacity.mark`); onder de rij staat "2 CORRECTIES · cloud code → Claude Code". Geselecteerd toont de rij de oorspronkelijke tekst naast de uiteindelijke. +- Kopieerknop kopieert de uiteindelijke tekst; rechtsklik: kopieer origineel, verwijder. Lege staat: korte zeefdruk-tekst, geen illustratie. + +### Woordenboek + +Tabel op papier-oppervlak met kolommen GEHOORD → SCHRIJF. Een regel zonder pijl is een term (alleen SCHRIJF). Toevoegen met `+`, bewerken met dubbelklik, verwijderen met ⌫, ongedaan maken met ⌘Z, zoeken met het zoekveld. Een knop "Open bestand" toont het bestand in de standaardeditor. Twee kleine schakelaars: "Voorrang" en "Correctie" (beide standaard aan), zodat je kunt zien wat elk mechanisme doet. Een amber lampje op de rij toont een waarschuwing (zie hieronder). + +## Instellingen + +Venster `layout.settings.width` breed, drie tabbladen als zeefdruk-toetsen. + +- **Sneltoets:** de gekozen toets wordt vastgelegd door erop te drukken (de app is dan actief en luistert met een lokale event-monitor). Alleen de toegestane modifiers uit beslissing 8; andere toetsen worden afgewezen met uitleg. Bij Fn staat een hint over de Globe-instelling van macOS. +- **Model:** vaste lijst van varianten (op dit moment: `large-v3-v20240930_turbo` standaard, de 632 MB-variant en `small`; exacte namen worden bij de bouw tegen WhisperKit gecontroleerd). Per model: status (aanwezig, gekozen, niet gedownload), download- of verwijderknop en voortgang als LED-balk. Wisselen laadt het nieuwe model en zet dicteren uit tot het klaar is; mislukt het laden, dan blijft het vorige model actief met een duidelijke melding. Toont de opslaglocatie met "Toon in Finder". +- **Privacy:** schakelaar "Transcripties bewaren" en knop "Wis geschiedenis…" (met bevestiging). + +## Woordenboek — het ontwerp in detail + +### Bestand + +`~/Library/Application Support/Sst/dictionary.txt`, UTF-8, één invoer per regel: + +``` +# Opmerkingen beginnen met # +Anthropic +Vercel +Supabase +cloud code => Claude Code +``` + +- Een regel zonder `=>` is een **term**. Een regel met `=>` is een **correctiepaar** (links gehoord, rechts schrijven). Spaties rond `=>` en aan de uiteinden worden genegeerd. +- Opmerkingen, lege regels en de volgorde blijven bij opslaan **behouden**: de app leest naar een lijst van regels (invoer, opmerking, leeg) en schrijft die letterlijk terug. Een onleesbare regel blijft staan en krijgt een waarschuwing, ze wordt nooit stilletjes weggegooid. +- De app schrijft atomisch en let op wijzigingen door een externe editor (bestandsmonitor, 200 ms debounce) en herlaadt dan. Verdwijnt de invoer die je aan het bewerken was, dan sluit de editor met een melding. + +### Mechanisme 1 — voorrang bij het model + +`VocabularyBuilder` maakt een korte lijst: eerst de termen (bestandsvolgorde), dan de unieke rechterkanten van de correctieparen. **Maximaal 24 woorden en 64 tokens**; past er meer niet, dan vallen de laatste woorden af. Doorgegeven als `promptTokens` (zie raakpunt 1). + +Tegen verzinnen op stilte: + +- Is het RMS van de opname lager dan **0,015** (het stiltefilter van 0,005 blijft erboven), dan wordt **geen prompt** meegegeven. +- Bestaat het resultaat uitsluitend uit de prompt zelf (genormaliseerd) bij zo'n zachte opname, dan wordt het verworpen. +- WhisperKit zet de snelle prefill-cache uit zodra een prompt is gezet; de snelheid wordt bij de bouw gemeten met en zonder prompt en in het plan vastgelegd. Doel: onder 1,5 s voor 8 s spraak. + +### Mechanisme 2 — correctieronde (het gegarandeerde pad) + +`CorrectionEngine` loopt na de transcriptie over de tekst: + +- **Hele woorden**, niet hoofdlettergevoelig, **één doorgang** van links naar rechts. De langste overeenkomst wint (bij gelijke lengte de eerste in het bestand). Vervangen tekst wordt niet opnieuw gescand, dus A→B en B→C vormen nooit een ketting. +- **Aaneengeplakte en gekoppelde vormen:** de bronzin wordt gesplitst op spaties en koppeltekens; de delen worden verbonden met `[spatie, koppelteken]*`. "cloud code" herkent dus "cloud code", "cloudcode", "Cloud-Code" en "CLOUD CODE". Een bron van één deel (geen splitsing) herkent alleen zichzelf, hoofdletter-onafhankelijk. +- **Woordgrenzen** zijn Unicode-bewust: de overeenkomst mag niet direct aan een letter of cijfer grenzen. Daarom raakt "cloud code" nooit "Cloudflare", "cloud codex" of het losse woord "cloud". +- **Termen** (invoer zonder pijl) krijgen een normaliserende regel: dezelfde letters, maar met de gewenste schrijfwijze (hoofdletters en spatiëring). "claude code", "claudecode" en "Claude-Code" worden "Claude Code". **Uitzondering:** is de term in kleine letters een gewoon woord (bijvoorbeeld "Reef" of "Slack"), dan krijgt hij alleen voorrang bij het model en géén automatische hoofdletterregel; de rij laat dat zien. +- De vervanging is letterlijk (geen sjablonen); `$` en `\` blijven staan. +- De engine geeft per vervanging terug: gehoord (zoals het in de tekst stond), geschreven, de regel, en de plaats in de uiteindelijke tekst. + +### Waarschuwingen bij invoer die veel voorkomt + +`EntryWarnings` bepaalt bij toevoegen en bewerken, met een injecteerbare `WordKnowledge` (standaard: `NSSpellChecker` voor nl en en, plus een meegeleverde lijst van de ~300 meest voorkomende functiewoorden in beide talen): + +| Niveau | Wanneer | +|---|---| +| **Waarschuwing** | De bron is één woord dat in nl of en bestaat ("cloud" → wordt overal vervangen) | +| **Waarschuwing** | De bron is korter dan 3 letters | +| **Waarschuwing** | Een deel van een meerdelige bron is een veelvoorkomend functiewoord ("in de") | +| **Waarschuwing** | De aaneengeplakte vorm is zelf een bestaand woord ("some thing" → "something") | +| **Let op** | Alle delen zijn gewone woorden: de correctie werkt alleen op de hele combinatie (jouw voorbeeld "cloud code") | +| **Fout** | Dubbele bron (de eerste telt) of een lege kant | + +Een waarschuwing blokkeert het opslaan niet, maar toont een amber lampje op de rij en de reden in de editor. + +### Zichtbaar in de geschiedenis + +Elke opname bewaart `rawText`, `finalText` en `corrections`. De rij toont de markeringen en de samenvatting. Is er geen correctie gedaan, dan staat er niets extra. Zo zie je direct of het woordenboek iets doet. + +## Geschiedenis + +`HistoryStore` bewaart `~/Library/Application Support/Sst/history.json`: + +```json +{ "version": 1, "entries": [ + { "id": "UUID", "date": "ISO-8601", "duration": 8.4, + "rawText": "…", "finalText": "…", + "corrections": [ { "heard": "cloud code", "wrote": "Claude Code", "rule": "cloud code", "start": 12, "length": 11 } ] } ] } +``` + +Atomisch schrijven, met korte debounce; geheugen-eerst. Meer dan 10 000 regels is geen doel van deze versie. Onleesbaar bestand: hernoemen naar `history.corrupt-.json` en vers beginnen, met een melding. Staat bewaren uit, dan wordt niets geschreven en toont het hoofdvenster een regel dat de geschiedenis uit staat. + +## Ontwerpsysteem in code + +`Design/build_tokens.py` krijgt een Swift-uitvoer (`--swift`) die `Sources/SstUI/DesignTokens.swift` genereert uit `Design/tokens.json`: + +- Kleuren als dynamische `NSColor`/`Color` met aluminium- en graphite-waarden, schaduwen als lagenlijsten, afmetingen, typografie en beweging als constanten. +- Componenten gebruiken **uitsluitend** deze constanten. Een test zoekt in `Sources/SstUI` en `Sources/Sst` naar losse hex-kleuren en vaste puntafmetingen buiten de gegenereerde file en faalt als ze er zijn. +- Een test controleert dat het gegenereerde bestand overeenkomt met een verse generatie (geen drift) en dat de contrastcontrole slaagt. +- Componenten: `KeyButtonStyle` (rust, ingedrukt, REC), `LampView`, `PanelBackground`, `WellBackground`, `SeamDivider`, `SilkLabel`, `VUMeterView`, `SegmentCounterView`, `LEDBarView`, `PaperList`. De geborstelde textuur wordt één keer gegenereerd en gecachet. + +## Toegankelijkheid + +- Alle bediening bereikbaar met het toetsenbord; zichtbare focusring (`border.focus`, `line.focus`). +- VoiceOver: toetsen en lampjes hebben labels ("Opname starten", "Microfoon: toegestaan"); de VU-meter meldt zijn waarde ("niveau min 12 VU") met 2 Hz, niet met 60 Hz. +- "Verminder beweging" wordt gerespecteerd (zie `motion.reducedMotion`). +- Alle tekstparen halen ≥ 4,5:1 (gecontroleerd door het tokenscript). + +## Foutafhandeling + +| Situatie | Gedrag | +|---|---| +| Model niet gedownload / laden mislukt | Controle-lampje uit, REC uitgeschakeld, melding met knop "Opnieuw proberen"; vorige model blijft bij wisselen actief | +| Geen netwerk bij eerste download | Duidelijke melding; geen herhaalde verzoeken op de achtergrond | +| Rechten ontbreken | Lampje en knop in CONTROLES; hotkey-opnames geven een korte fout | +| Plakken mislukt | Tekst blijft op het klembord en staat wel in de geschiedenis | +| Woordenboekbestand ontbreekt | Wordt aangemaakt met een korte kop; leeg is geldig | +| Woordenboekregel onleesbaar | Regel blijft behouden, krijgt een waarschuwing | +| Geschiedenisbestand beschadigd | Hernoemen, vers beginnen, melding | + +## Testen + +- **Unit (zonder UI):** `DictionaryFile` (ronde parse → schrijf met opmerkingen en volgorde), `CorrectionEngine` (spatie/koppelteken/aaneengeplakt, "Cloudflare" en "cloud codex" blijven onaangeroerd, langste eerst, geen ketens, hoofdletters, Unicode, `$`/`\` letterlijk), `EntryWarnings` met een valse `WordKnowledge`, `VocabularyBuilder` (limieten, volgorde), stilte-bias-regels, `HistoryStore` (lezen, schrijven, beschadigd), `HistorySearch` (diakrieten, meerdere woorden), VU-afbeelding (dBFS → VU → hoek), `ModifierKeyTracker` met meerdere bindingen, en de engine-raakpunten met nep-onderdelen. Het bestaande 13-testen-pakket blijft groen. +- **Tokens:** parity- en contrasttest (zie "Ontwerpsysteem in code"). +- **Handmatig** (met de gebruiker, zoals bij het skeleton): Dock-icoon en app-menu; venster vrij schalen; ⌘, ; sluiten en weer openen via het Dock; dicteren in TextEdit, browser en terwijl Sst achter staat; dicteren terwijl Sst voorop staat (alleen opslaan); woordenboek: term, correctiepaar, "cloud-code"-variaties, "Cloudflare" blijft; waarschuwing bij "cloud"; bestand in een editor wijzigen en zien herladen; model wisselen; rechten intrekken en terugzien; VoiceOver kort; "Verminder beweging". + +## Bouwvolgorde + +Elke fase levert op zichzelf testbare software en krijgt een eigen plan: + +1. **Engine-raakpunten** (SstCore/SstWhisper) met tests. +2. **SstData:** woordenboek, correcties, waarschuwingen, voorrang, geschiedenis, zoeken, met tests. +3. **SstUI:** tokens genereren in Swift, componenten, parity- en contrasttest, een klein ontwikkelvenster om componenten te bekijken. +4. **App-shell:** scenes, menu's, menubalkitem, Instellingen (sneltoets en model), bundelscript, ondertekening, icoon. +5. **Hoofdvenster:** deck met meter, geschiedenis, woordenboekweergave, bedrading met `AppModel`; handmatige rooktest. + +## Open punten (graag bevestigen) + +1. De vier **engine-raakpunten**, in het bijzonder dat `Transcriber` een woordenlijst accepteert. +2. **"Kopie van elk document"** als kopieerknop per transcriptie. +3. **Nederlandse UI-teksten** met macOS-systeemmenu's in de taal van het systeem. +4. De vaste lijst **toegestane sneltoetsen** en alleen vasthouden. +5. Dat Sst **niet plakt** wanneer Sst zelf voorop staat of de opname via het venster start. +6. De keuze om **geschiedenis standaard aan** te zetten.