diff --git a/Design/AppIcon.icns b/Design/AppIcon.icns new file mode 100644 index 0000000..5ec0b5a Binary files /dev/null and b/Design/AppIcon.icns differ diff --git a/Design/build_tokens.py b/Design/build_tokens.py new file mode 100644 index 0000000..c81edd0 --- /dev/null +++ b/Design/build_tokens.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Reads Design/tokens.json and writes docs/design/tokens.md and docs/design/tokens-preview.html. +Also checks WCAG contrast for every text pairing; exits 1 if a text pair is below 4.5:1.""" +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +tokens = json.loads((ROOT / "Design" / "tokens.json").read_text()) +HEX = re.compile(r"^#[0-9A-Fa-f]{6}$") + + +def lum(hexcolor): + h = hexcolor.lstrip("#") + out = [] + for i in (0, 2, 4): + c = int(h[i:i + 2], 16) / 255 + out.append(c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4) + return 0.2126 * out[0] + 0.7152 * out[1] + 0.0722 * out[2] + + +def contrast(a, b): + la, lb = sorted((lum(a), lum(b)), reverse=True) + return (la + 0.05) / (lb + 0.05) + + +# --- validation ------------------------------------------------------------- +errors = [] +for name, t in tokens["color"].items(): + for theme in ("light", "dark"): + if not HEX.match(t.get(theme, "")): + errors.append(f"color.{name}.{theme} is not a #RRGGBB value") +if errors: + print("\n".join(errors)) + sys.exit(1) + +# --- contrast --------------------------------------------------------------- +TEXT_PAIRS = [ + ("ink.primary", "surface.chassis"), ("ink.primary", "surface.panel"), ("ink.primary", "surface.paper"), + ("ink.primary", "surface.well"), ("ink.primary", "surface.selection"), ("ink.onMeter", "surface.meterFace"), + ("ink.primary", "surface.keyHigh"), ("ink.primary", "surface.keyLow"), + ("ink.secondary", "surface.chassis"), ("ink.secondary", "surface.panel"), ("ink.secondary", "surface.paper"), + ("ink.tertiary", "surface.chassis"), ("ink.tertiary", "surface.panel"), ("ink.tertiary", "surface.paper"), + ("ink.tertiary", "surface.metalHigh"), ("ink.tertiary", "surface.metalLow"), + ("ink.inverse", "accent.record"), + ("level.amber", "surface.display"), +] +rows, failed = [], False +for fg, bg in TEXT_PAIRS: + for theme in ("light", "dark"): + r = contrast(tokens["color"][fg][theme], tokens["color"][bg][theme]) + ok = r >= 4.5 + failed |= not ok + rows.append((fg, bg, theme, r, ok)) + +print(f"{'text':16} {'on':20} {'finish':6} ratio") +for fg, bg, theme, r, ok in rows: + print(f"{fg:16} {bg:20} {theme:6} {r:5.2f} {'ok' if ok else 'FAIL'}") + +# --- swift ------------------------------------------------------------------ +def camel(name): + parts = name.replace("-", ".").split(".") + return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:]) + + +def num(v): + return repr(float(v)) if isinstance(v, float) else str(v) + + +def doc(use): + return " /// " + use.replace("\n", " ") + + +def swift_source(t): + o = [] + add = o.append + add("// GENERATED by Design/build_tokens.py from Design/tokens.json. Do not edit by hand.") + add("import SwiftUI\n") + + add("public enum Palette {") + for n, c in t["color"].items(): + add(doc(c["use"])) + add(f" public static let {camel(n)} = Color.dynamic(light: 0x{c['light'][1:].upper()}, dark: 0x{c['dark'][1:].upper()})") + add("}\n") + + add("public enum Opacity {") + for n, v in t["opacity"].items(): + add(doc(v["use"])) + add(f" public static let {camel(n)}: Double = {num(v['value'])}") + add("}\n") + + add("public enum FontFamily: String {") + for n, f in t["font"].items(): + add(f" case {n.replace('family.', '')} = \"{f['value']}\"") + add("}\n") + + add("public enum Typography {") + for n, s in t["type"].items(): + add(doc(s["use"])) + add(f" public static let {camel(n)} = TextStyle(family: .{s['family']}, size: {num(s['size'])}, weight: {s['weight']}, " + f"tracking: {num(s['tracking'])}, line: {num(s['line'])}, caps: {'true' if s['caps'] else 'false'}, " + f"tabular: {'true' if s.get('tabular') else 'false'})") + add("}\n") + + add("public enum Segment {") + for n, v in t["segment"].items(): + add(doc(v["use"])) + add(f" public static let {camel(n)}: CGFloat = {num(v['value'])}") + add("}\n") + + add("public enum Space {") + for n, v in t["space"].items(): + add(f" public static let s{n}: CGFloat = {v}") + add("}\n") + + for enum, group in (("Layout", "layout"), ("Size", "size"), ("Radius", "radius"), ("Border", "border")): + add(f"public enum {enum} {{") + for n, v in t[group].items(): + add(doc(v["use"])) + add(f" public static let {camel(n)}: CGFloat = {num(v['value'])}") + add("}\n") + + add("public enum Shadows {") + for n, s in t["shadow"].items(): + add(doc(s["use"])) + add(f" public static let {camel(n)}: [ShadowLayer] = [") + for l in s["layers"]: + add(f" ShadowLayer(inset: {'true' if l['inset'] else 'false'}, x: {l['x']}, y: {l['y']}, blur: {l['blur']}, spread: {l['spread']}, " + f"hex: 0x{l['color'][1:].upper()}, alphaLight: {num(l['alpha']['light'])}, alphaDark: {num(l['alpha']['dark'])}),") + add(" ]") + if "translateY" in s: + add(f" public static let {camel(n)}Offset: CGFloat = {s['translateY']}") + add("}\n") + + add("public enum Texture {") + for n, v in t["texture"].items(): + add(f" public static let {camel(n)}GrainLight: Double = {num(v['grain']['light'])}") + add(f" public static let {camel(n)}GrainDark: Double = {num(v['grain']['dark'])}") + add("}\n") + + add("public enum MeterTokens {") + for n, v in t["meter"].items(): + val = v["value"] + if n == "vu.ticks": + add(doc(v["use"])) + add(f" public static let vuTicks: [Int] = [{', '.join(str(int(x)) for x in val.split())}]") + elif n == "led.segmentSize": + w, h = val.split("x") + add(doc(v["use"])) + add(f" public static let ledSegmentWidth: CGFloat = {w}") + add(f" public static let ledSegmentHeight: CGFloat = {h}") + elif n == "peak.holdMs": + add(doc(v["use"])) + add(f" public static let peakHoldSeconds: Double = {num(val / 1000)}") + elif n == "led.segments": + add(doc(v["use"])) + add(f" public static let ledSegments: Int = {val}") + else: + add(doc(v["use"])) + add(f" public static let {camel(n)}: Double = {num(val)}") + add("}\n") + + add("public enum Motion {") + for n, m in t["motion"].items(): + if n == "reducedMotion": + continue + add(doc(m["use"])) + if m.get("unit") == "ms": + add(f" public static let {camel(n)}: Double = {num(m['value'] / 1000)}") + elif m.get("unit") in ("s", "ratio", "Hz"): + add(f" public static let {camel(n)}: Double = {num(m['value'])}") + elif isinstance(m["value"], str) and m["value"].startswith("cubic-bezier"): + pts = [x.strip() for x in m["value"][m["value"].index("(") + 1:-1].split(",")] + add(f" public static let {camel(n)}: (Double, Double, Double, Double) = ({', '.join(pts)})") + add("}") + return "\n".join(o) + "\n" + + +swift_text = swift_source(tokens) +swift_dir = ROOT / "Sources" / "SstUI" +swift_path = swift_dir / "DesignTokens.swift" + +if "--check" in sys.argv: + in_sync = swift_path.exists() and swift_path.read_text() == swift_text + print("DesignTokens.swift is " + ("in sync with tokens.json" if in_sync else "OUT OF DATE: run python3 Design/build_tokens.py")) + sys.exit(0 if (in_sync and not failed) else 1) + +if swift_dir.exists(): + swift_path.write_text(swift_text) + print("wrote Sources/SstUI/DesignTokens.swift") + +# --- markdown --------------------------------------------------------------- +md = [] +add = md.append +m = tokens["meta"] +add(f"# {m['name']} — v{m['version']}\n") +add(f"**Status:** {m['status']}\n") +add(f"{m['concept']}\n") +add("Bron: `Design/tokens.json`. Dit bestand en `tokens-preview.html` worden gegenereerd met `python3 Design/build_tokens.py`; pas nooit alleen deze bestanden aan.\n") + + +def table(headers, body): + add("| " + " | ".join(headers) + " |") + add("|" + "|".join("---" for _ in headers) + "|") + for r in body: + add("| " + " | ".join(str(c) for c in r) + " |") + add("") + + +add("## Kleur\n") +add("Verwijs ernaar als `color.`. Kolommen: ALUMINIUM (licht) en GRAPHITE (donker).\n") +table(["Token", "Aluminium", "Graphite", "Gebruik"], + [(f"`color.{n}`", f"`{t['light']}`", f"`{t['dark']}`", t["use"]) for n, t in tokens["color"].items()]) +add("**Regel:** `accent.record` is het enige accent en wordt nooit voor lopende tekst gebruikt. Amber en groen zijn alleen voor niveau-indicatoren en markeringen.\n") +add("### Contrast (berekend)\n") +table(["Tekst", "Op", "Afwerking", "Verhouding", ""], + [(f"`{a}`", f"`{b}`", th, f"{r:.2f}:1", "ok" if ok else "**te laag**") for a, b, th, r, ok in rows]) + +add("## Opaciteit\n") +table(["Token", "Waarde", "Gebruik"], [(f"`opacity.{n}`", t["value"], t["use"]) for n, t in tokens["opacity"].items()]) + +add("## Lettertypen\n") +table(["Token", "Familie", "Terugval", "Gebruik"], + [(f"`font.{n}`", t["value"], t["fallback"], t["use"]) for n, t in tokens["font"].items()]) +add("## Tekststijlen\n") +table(["Token", "Familie", "Grootte", "Gewicht", "Spatiëring", "Regelhoogte", "Hoofdletters", "Gebruik"], + [(f"`type.{n}`", t["family"], f"{t['size']} pt", t["weight"], f"{t['tracking']} em", f"{t['line']} pt", + "ja" if t["caps"] else "nee", t["use"]) for n, t in tokens["type"].items()]) +add("## Segmentcijfers (getekend, geen lettertype)\n") +table(["Token", "Waarde", "Gebruik"], [(f"`segment.{n}`", t["value"], t["use"]) for n, t in tokens["segment"].items()]) + +add("## Ruimte\n") +table(["Token", "Waarde"], [(f"`space.{n}`", f"{v} pt") for n, v in tokens["space"].items()]) +add("## Indeling\n") +table(["Token", "Waarde", "Gebruik"], [(f"`layout.{n}`", t["value"], t["use"]) for n, t in tokens["layout"].items()]) +add("## Afmetingen\n") +table(["Token", "Waarde", "Gebruik"], [(f"`size.{n}`", t["value"], t["use"]) for n, t in tokens["size"].items()]) +add("## Hoekradius\n") +table(["Token", "Waarde", "Gebruik"], [(f"`radius.{n}`", t["value"], t["use"]) for n, t in tokens["radius"].items()]) +add("## Randen\n") +table(["Token", "Waarde", "Gebruik"], [(f"`border.{n}`", f"{t['value']} pt", t["use"]) for n, t in tokens["border"].items()]) + +add("## Schaduw en diepte\n") +for n, t in tokens["shadow"].items(): + add(f"### `shadow.{n}`\n\n{t['use']}" + (f" Dop verschuift {t['translateY']} pt omlaag." if "translateY" in t else "") + "\n") + table(["Laag", "Inset", "x", "y", "blur", "spread", "kleur", "alpha aluminium", "alpha graphite"], + [(i + 1, "ja" if l["inset"] else "nee", l["x"], l["y"], l["blur"], l["spread"], f"`{l['color']}`", + l["alpha"]["light"], l["alpha"]["dark"]) for i, l in enumerate(t["layers"])]) +add("## Textuur\n") +for n, t in tokens["texture"].items(): + add(f"**`texture.{n}`** — {t['use']}. Richting {t['direction']}; korrel {t['grain']['light']} (aluminium) / {t['grain']['dark']} (graphite); {t['gradient']}.\n") +add("## Meters\n") +table(["Token", "Waarde", "Gebruik"], [(f"`meter.{n}`", t["value"], t["use"]) for n, t in tokens["meter"].items()]) +add("## Beweging\n") +table(["Token", "Waarde", "Eenheid", "Gebruik"], + [(f"`motion.{n}`", t["value"], t.get("unit", ""), t["use"]) for n, t in tokens["motion"].items()]) + +add("## Wat bewust ontbreekt\n") +add("Geen neon, vaporwave, synthwave, paars-roze verlopen, gloeiende tekst, chromen letters of rasterhorizonten. Lampjes mogen een kleine gloed hebben (`shadow.lamp.on`); tekst nooit.\n") + +out_dir = ROOT / "docs" / "design" +out_dir.mkdir(parents=True, exist_ok=True) +(out_dir / "tokens.md").write_text("\n".join(md)) + +# --- preview ---------------------------------------------------------------- +template = (ROOT / "Design" / "preview.template.html").read_text() +blob = json.dumps(tokens, ensure_ascii=False).replace(" + + + + +Sst Ontwerp-tokens + + + + + +
+ +
+
+
Ontwerpsysteem · Sst
+

Ontwerp-tokens

+

+
+
+
Ter goedkeuring
+
+
Bron · Design/tokens.json
+
+
+ +
+
01Instrumenten in gebruikmeter.* · motion.needle.* · segment.*
+
+
Afwerking · Aluminiumlicht
+
Afwerking · Graphitedonker
+
+
+ +
+
02Kleurcolor.*
+
+
+ +
+
03Diepte en materiaalshadow.* · border.* · texture.*
+
+
+
+
+
+ +
+
04Typografiefont.* · type.*
+
+
+ +
+
05Ruimte, hoeken, randenspace.* · radius.* · border.*
+
+
Ruimte (pt)
+
+
Hoekradius
+
+
+ +
+
06Bewegingmotion.*
+
+
+
Toets en lampje
+
+

Toets: 50 ms in, 90 ms uit, zonder veer. Lampje: 30 ms aan, 200 ms uit — dooft als een gloeidraad.

+
+
+
Wijzer-ballistiek
+
+

Veer: 300 ms tot 99 %, demping 0,80 → ongeveer 1,5 % overschot. De meters bij 01 gebruiken dezelfde waarden.

+
+
+
+ +
+
07Wat het niet is
+
    +
  • Geen neon, vaporwave, synthwave, paars-roze verlopen, gloeiende tekst, chromen letters of rasterhorizonten.
  • +
  • Eén accent: het rode opnamelampje. Amber en groen alleen als niveau-indicator of markering, nooit als versiering.
  • +
  • Lampjes mogen een kleine gloed hebben. Tekst nooit.
  • +
  • Geen eenmalige waarden in componenten: elke afmeting, kleur en tijd komt uit deze tokens.
  • +
+
+
+ + + + diff --git a/Design/tokens.json b/Design/tokens.json new file mode 100644 index 0000000..a8f350f --- /dev/null +++ b/Design/tokens.json @@ -0,0 +1,217 @@ +{ + "meta": { + "name": "Sst Design Tokens", + "version": "0.1.0-draft", + "status": "Ter goedkeuring — nog niet gebruikt door de app", + "concept": "Draagbare veldrecorder / cassettedeck uit de jaren 80 (Sony TC-D5, Marantz PMD, Nakamichi, Braun). Twee afwerkingen: ALUMINIUM (licht) en GRAPHITE (donker). Eén accent: het rode opnamelampje. Amber en groen alleen voor niveau-indicatoren." + }, + + "color": { + "surface.chassis": { "light": "#CBC8C0", "dark": "#1C1B19", "use": "Venster-achtergrond; het 'huis' van het apparaat" }, + "surface.metalHigh": { "light": "#E3E1DB", "dark": "#3B3A36", "use": "Geborsteld metaal, bovenkant van de verloop" }, + "surface.metalLow": { "light": "#BDBAB2", "dark": "#262522", "use": "Geborsteld metaal, onderkant van de verloop" }, + "surface.panel": { "light": "#E2DFD5", "dark": "#2B2A27", "use": "Frontplaat van mat plastic; zijbalk en bedieningspanelen" }, + "surface.well": { "light": "#B4B1A8", "dark": "#131211", "use": "Verzonken vlak: invoervelden, display-vensters" }, + "surface.paper": { "light": "#EEE9D9", "dark": "#232220", "use": "Cassette-label crème: lijsten met transcripties en woordenboek" }, + "surface.keyHigh": { "light": "#E6E3DA", "dark": "#44433F", "use": "Toetsdop, bovenkant" }, + "surface.keyLow": { "light": "#CBC8BF", "dark": "#33322F", "use": "Toetsdop, onderkant" }, + "surface.keyPressed": { "light": "#BEBBB2", "dark": "#2A2927", "use": "Ingedrukte toets" }, + "surface.meterFace": { "light": "#EFE6C8", "dark": "#D8CEA6", "use": "Wijzerplaat van de VU-meter (blijft crème in beide afwerkingen)" }, + "surface.selection": { "light": "#D4CDB2", "dark": "#3A3828", "use": "Geselecteerde rij in een lijst" }, + "surface.display": { "light": "#1B1A18", "dark": "#0E0D0C", "use": "Displayglas van de teller; donker in beide afwerkingen" }, + "display.segmentUnlit": { "light": "#3A3320", "dark": "#2A2415", "use": "Gedoofd segment op het displayglas" }, + + "ink.primary": { "light": "#1E1D1B", "dark": "#ECE8DC", "use": "Hoofdtekst en wijzer" }, + "ink.secondary": { "light": "#403D38", "dark": "#C4BFB2", "use": "Secundaire tekst" }, + "ink.tertiary": { "light": "#4D4A44", "dark": "#ABA79B", "use": "Zeefdruk-labels en hulptekst" }, + "ink.disabled": { "light": "#8F8B82", "dark": "#6B685F", "use": "Uitgeschakeld; nooit voor essentiële tekst" }, + "ink.inverse": { "light": "#F4EFE3", "dark": "#F4EFE3", "use": "Tekst op rode of donkere vulling; crème in beide afwerkingen" }, + "ink.onMeter": { "light": "#1E1D1B", "dark": "#1E1D1B", "use": "Schaal, cijfers en wijzer op de wijzerplaat; vast donker in beide afwerkingen" }, + + "accent.record": { "light": "#C8281E", "dark": "#CC2A20", "use": "ENIGE accent: opnamelampje, REC-toets, piek-LED. Nooit voor lopende tekst" }, + "accent.recordUnlit": { "light": "#7A2E28", "dark": "#4A1F1B", "use": "Lampje uit" }, + + "level.green": { "light": "#5E9E48", "dark": "#6DB556", "use": "Niveau-segment, brandend" }, + "level.amber": { "light": "#D9A023", "dark": "#E5B032", "use": "Niveau-segment, waarschuwing, gecorrigeerd woord" }, + "level.greenUnlit": { "light": "#A8B79C", "dark": "#26331F", "use": "Groen segment, uit" }, + "level.amberUnlit": { "light": "#CDBA8A", "dark": "#3D3418", "use": "Amber segment, uit" }, + + "line.seamDark": { "light": "#8C8981", "dark": "#0A0A09", "use": "Naad tussen panelen, donkere helft" }, + "line.seamLight": { "light": "#F3F1EB", "dark": "#45433F", "use": "Naad tussen panelen, lichte helft" }, + "line.hairline": { "light": "#A9A69D", "dark": "#3A3834", "use": "Fijne scheidingslijn, zeefdruk-kader" }, + "line.focus": { "light": "#1E1D1B", "dark": "#ECE8DC", "use": "Toetsenbord-focus" } + }, + + "opacity": { + "mark": { "value": 0.30, "use": "Amber markering achter een gecorrigeerd woord" }, + "disabled": { "value": 0.45, "use": "Uitgeschakelde bediening" }, + "unlit": { "value": 1.00, "use": "Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie" }, + "pressedRecord": { "value": 0.85, "use": "Ingedrukte REC-toets: het rood dooft licht" }, + "specularOn": { "value": 0.60, "use": "Lichtvlekje op een brandend lampje" }, + "specularOff": { "value": 0.30, "use": "Lichtvlekje op een gedoofd lampje" }, + "caption": { "value": 0.75, "use": "Kleine opschriften op de wijzerplaat" } + }, + + "font": { + "family.chrome": { "value": "Helvetica Neue", "fallback": "Helvetica, Arial", "use": "Zeefdruk, toetsen, koppen: neutrale grotesk" }, + "family.text": { "value": "SF Pro Text", "fallback": "system-ui", "use": "Leesbare tekst: transcripties, woordenboek" }, + "family.mono": { "value": "SF Mono", "fallback": "Menlo, monospace", "use": "Teller, tijden, woordenboekbestand" } + }, + + "type": { + "label": { "family": "chrome", "size": 10, "weight": 500, "tracking": 0.09, "line": 12, "caps": true, "use": "Zeefdruk-label" }, + "labelSmall": { "family": "chrome", "size": 9, "weight": 500, "tracking": 0.10, "line": 11, "caps": true, "use": "Schaalmarkeringen en eenheden" }, + "control": { "family": "chrome", "size": 11, "weight": 500, "tracking": 0.06, "line": 14, "caps": true, "use": "Opschrift op toetsen" }, + "heading": { "family": "chrome", "size": 12, "weight": 500, "tracking": 0.12, "line": 16, "caps": true, "use": "Paneelkop" }, + "body": { "family": "text", "size": 13, "weight": 400, "tracking": 0, "line": 18, "caps": false, "use": "Standaardtekst" }, + "reading": { "family": "text", "size": 15, "weight": 400, "tracking": 0, "line": 22, "caps": false, "use": "Transcript" }, + "meta": { "family": "text", "size": 11, "weight": 400, "tracking": 0, "line": 14, "caps": false, "use": "Bijschriften" }, + "counter": { "family": "mono", "size": 22, "weight": 500, "tracking": 0, "line": 26, "caps": false, "tabular": true, "use": "Teller" }, + "counterSmall": { "family": "mono", "size": 12, "weight": 500, "tracking": 0, "line": 16, "caps": false, "tabular": true, "use": "Tijdstip in een rij" }, + "code": { "family": "mono", "size": 12, "weight": 400, "tracking": 0, "line": 18, "caps": false, "use": "Woordenboekregels" } + }, + + "segment": { + "digitHeight": { "value": 36, "use": "Hoogte van een getekend segmentcijfer" }, + "digitWidth": { "value": 20, "use": "Breedte van een segmentcijfer" }, + "thickness": { "value": 4, "use": "Dikte van één segment" }, + "gap": { "value": 1, "use": "Ruimte tussen segmenten" }, + "slant": { "value": 0, "use": "Schuinte in graden; 0 = rechtop" }, + "advance": { "value": 26, "use": "Afstand van cijfer tot cijfer" }, + "colonWidth": { "value": 10, "use": "Breedte van een dubbele punt of punt" } + }, + + "space": { + "0": 0, "1": 2, "2": 4, "3": 8, "4": 12, "5": 16, "6": 24, "7": 32, "8": 48 + }, + + "layout": { + "window.minWidth": { "value": 800, "use": "Minimale vensterbreedte" }, + "window.minHeight": { "value": 560, "use": "Minimale vensterhoogte" }, + "window.defaultWidth": { "value": 1080, "use": "Standaard vensterbreedte" }, + "window.defaultHeight":{ "value": 700, "use": "Standaard vensterhoogte" }, + "sidebar.width": { "value": 232, "use": "Breedte zijbalk" }, + "settings.width": { "value": 560, "use": "Breedte instellingenvenster" }, + "panel.padding": { "value": 16, "use": "Binnenmarge van een paneel (= space.5)" }, + "panel.gap": { "value": 12, "use": "Afstand tussen panelen (= space.4)" } + }, + + "size": { + "control.height": { "value": 28, "use": "Standaardhoogte van toets of veld" }, + "key.minWidth": { "value": 56, "use": "Minimale breedte van een toets" }, + "key.recWidth": { "value": 64, "use": "REC-toets, breedte" }, + "key.recHeight": { "value": 44, "use": "REC-toets, hoogte" }, + "lamp.diameter": { "value": 10, "use": "Standaard lampje" }, + "lamp.large": { "value": 14, "use": "Opnamelampje" }, + "switch.width": { "value": 40, "use": "Tuimelschakelaar, breedte" }, + "switch.height": { "value": 22, "use": "Tuimelschakelaar, hoogte" }, + "row.height": { "value": 40, "use": "Rij in transcriptielijst" }, + "icon.small": { "value": 16, "use": "Klein pictogram" }, + "icon.medium": { "value": 20, "use": "Normaal pictogram" }, + "hit.min": { "value": 24, "use": "Minimaal klikdoel" } + }, + + "radius": { + "none": { "value": 0, "use": "Panelen en velden: scherpe randen" }, + "sharp": { "value": 1, "use": "Toetsranden: bijna scherp" }, + "sm": { "value": 2, "use": "Toetsen, velden" }, + "md": { "value": 4, "use": "Grote panelen" }, + "bezel": { "value": 6, "use": "Rand van de VU-meter" }, + "lamp": { "value": 999, "use": "Lampjes: rond" } + }, + + "border": { + "hairline": { "value": 1, "use": "Fijne lijn" }, + "seam": { "value": 2, "use": "Naad: 1 donkere lijn boven, 1 lichte lijn eronder, leest als gegraveerde groef" }, + "bezel": { "value": 3, "use": "Rand om de VU-meter" }, + "focus": { "value": 2, "use": "Focusring, 2 pt offset" } + }, + + "shadow": { + "key.raised": { + "use": "Toets in rust: lichtvang boven, donkere onderrand, valschaduw", + "layers": [ + { "inset": true, "x": 0, "y": 1, "blur": 0, "spread": 0, "color": "#FFFFFF", "alpha": { "light": 0.65, "dark": 0.10 } }, + { "inset": true, "x": 0, "y": -1, "blur": 0, "spread": 0, "color": "#000000", "alpha": { "light": 0.25, "dark": 0.55 } }, + { "inset": false, "x": 0, "y": 2, "blur": 2, "spread": 0, "color": "#000000", "alpha": { "light": 0.35, "dark": 0.60 } } + ] + }, + "key.pressed": { + "use": "Ingedrukte toets: dop 1 pt omlaag, geen valschaduw", + "layers": [ + { "inset": true, "x": 0, "y": 2, "blur": 4, "spread": 0, "color": "#000000", "alpha": { "light": 0.50, "dark": 0.70 } }, + { "inset": true, "x": 0, "y": 0, "blur": 0, "spread": 1, "color": "#000000", "alpha": { "light": 0.35, "dark": 0.60 } } + ], + "translateY": 1 + }, + "well": { + "use": "Verzonken vlak met lichtvang op de onderlip", + "layers": [ + { "inset": true, "x": 0, "y": 1, "blur": 3, "spread": 0, "color": "#000000", "alpha": { "light": 0.40, "dark": 0.70 } }, + { "inset": false, "x": 0, "y": 1, "blur": 0, "spread": 0, "color": "#FFFFFF", "alpha": { "light": 0.55, "dark": 0.07 } } + ] + }, + "panel": { + "use": "Frontplaat boven het chassis", + "layers": [ + { "inset": true, "x": 0, "y": 1, "blur": 0, "spread": 0, "color": "#FFFFFF", "alpha": { "light": 0.60, "dark": 0.08 } }, + { "inset": false, "x": 0, "y": 3, "blur": 8, "spread": 0, "color": "#000000", "alpha": { "light": 0.22, "dark": 0.55 } } + ] + }, + "lamp.on": { + "use": "Brandend lampje: kleine gloed, alleen voor lampjes en nooit voor tekst", + "layers": [ + { "inset": false, "x": 0, "y": 0, "blur": 6, "spread": 1, "color": "#CC2A20", "alpha": { "light": 0.45, "dark": 0.55 } }, + { "inset": true, "x": 0, "y": -1, "blur": 1, "spread": 0, "color": "#000000", "alpha": { "light": 0.35, "dark": 0.45 } } + ] + } + }, + + "texture": { + "brushed": { + "use": "Geborsteld aluminium voor chassis en zijbalk", + "direction": "horizontaal", + "grain": { "light": 0.035, "dark": 0.05 }, + "gradient": "surface.metalHigh naar surface.metalLow, van boven naar beneden" + } + }, + + "meter": { + "vu.referenceDbfs": { "value": -20, "use": "0 VU = -20 dBFS (RMS)" }, + "vu.min": { "value": -20, "use": "Onderkant van de schaal in VU" }, + "vu.max": { "value": 3, "use": "Bovenkant van de schaal in VU; vanaf 0 VU rood" }, + "vu.arcDegrees": { "value": 90, "use": "Hoek tussen minimale en maximale stand" }, + "vu.width": { "value": 260, "use": "Breedte van de meter" }, + "vu.height": { "value": 150, "use": "Hoogte van de meter" }, + "vu.needleWidth": { "value": 1.5, "use": "Dikte van de wijzer" }, + "vu.ticks": { "value": "-20 -10 -7 -5 -3 0 +3", "use": "Genummerde schaalmarkeringen" }, + "vu.pivotOffset": { "value": 22, "use": "Draaipunt van de wijzer, onder de onderrand van de wijzerplaat" }, + "vu.scaleRadius": { "value": 142, "use": "Straal van de schaalboog vanaf het draaipunt" }, + "vu.needleLength": { "value": 146, "use": "Lengte van de wijzer" }, + "vu.tickMajor": { "value": 11, "use": "Lengte van een genummerde streep" }, + "vu.tickMinor": { "value": 6, "use": "Lengte van een gewone streep" }, + "vu.labelInset": { "value": 24, "use": "Afstand van de schaalboog tot de cijfers" }, + "vu.redBandWidth": { "value": 5, "use": "Dikte van de rode band vanaf 0 VU" }, + "led.segments": { "value": 12, "use": "Aantal segmenten: 8 groen, 3 amber, 1 rood" }, + "led.segmentSize": { "value": "6x14", "use": "Segment in pt (breedte x hoogte)" }, + "led.gap": { "value": 2, "use": "Ruimte tussen segmenten" }, + "peak.holdMs": { "value": 1000, "use": "Piek-LED blijft zo lang branden" } + }, + + "motion": { + "duration.instant": { "value": 0, "unit": "ms", "use": "Geen overgang" }, + "duration.fast": { "value": 60, "unit": "ms", "use": "Toets in/uit" }, + "duration.base": { "value": 120, "unit": "ms", "use": "Standaardovergang" }, + "duration.slow": { "value": 220, "unit": "ms", "use": "Paneel of venster" }, + "easing.mechanical":{ "value": "cubic-bezier(0.2, 0, 0, 1)", "use": "Toetsen: direct, zonder veer" }, + "easing.standard": { "value": "cubic-bezier(0.4, 0, 0.2, 1)", "use": "Overige overgangen" }, + "key.press": { "value": 50, "unit": "ms", "use": "Toets indrukken" }, + "key.release": { "value": 90, "unit": "ms", "use": "Toets loslaten" }, + "lamp.on": { "value": 30, "unit": "ms", "use": "Lampje aan" }, + "lamp.off": { "value": 200, "unit": "ms", "use": "Lampje uit: dooft langzaam, als een gloeidraad" }, + "needle.response": { "value": 0.30, "unit": "s", "use": "VU-ballistiek: 300 ms om de stand te bereiken" }, + "needle.damping": { "value": 0.80, "unit": "ratio", "use": "Veerdemping; geeft ongeveer 1,5 % overschot" }, + "needle.updateHz": { "value": 60, "unit": "Hz", "use": "Verversing van de wijzer" }, + "reducedMotion": { "value": "Overgangen 0 ms; wijzer blijft bewegen maar met demping 1.0 (geen overschot)", "use": "Systeeminstelling 'Verminder beweging'" } + } +} diff --git a/Package.swift b/Package.swift index 127d1c3..7104fd6 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,8 @@ let package = Package( ], targets: [ .target(name: "SstCore"), + .target(name: "SstUI"), + .target(name: "SstData", dependencies: ["SstCore"]), .target( name: "SstWhisper", dependencies: [ @@ -16,9 +18,14 @@ let package = Package( .product(name: "WhisperKit", package: "WhisperKit"), ] ), - .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]), + .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper", "SstData", "SstUI"]), + .executableTarget(name: "SstIcon", dependencies: ["SstUI"]), .executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]), + .executableTarget(name: "SstGallery", dependencies: ["SstUI"]), .testTarget(name: "SstCoreTests", dependencies: ["SstCore"]), + .testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]), + .testTarget(name: "SstWhisperTests", dependencies: ["SstWhisper"]), + .testTarget(name: "SstUITests", dependencies: ["SstUI"]), ], swiftLanguageModes: [.v5] ) diff --git a/Sources/Sst/AppCommands.swift b/Sources/Sst/AppCommands.swift new file mode 100644 index 0000000..dc801e0 --- /dev/null +++ b/Sources/Sst/AppCommands.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct AppCommands: Commands { + @ObservedObject var model: AppModel + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandGroup(after: .pasteboard) { + Divider() + Button(Strings.clearHistory) { + openWindow(id: "main") + model.confirmingClearHistory = true + } + } + CommandMenu(Strings.recordingMenu) { + Button(model.isRecording ? Strings.stopRecording : Strings.startRecording) { + model.toggleWindowRecording() + } + .keyboardShortcut("r", modifiers: [.command, .option]) + Divider() + Button(Strings.transcripts) { show(.transcripts) } + .keyboardShortcut("1", modifiers: .command) + Button(Strings.dictionary) { show(.dictionary) } + .keyboardShortcut("2", modifiers: .command) + Button(Strings.note) { show(.note) } + .keyboardShortcut("3", modifiers: .command) + Button(Strings.search) { + show(model.section) + model.focusSearch() + } + .keyboardShortcut("f", modifiers: .command) + } + } + + private func show(_ section: AppModel.Section) { + openWindow(id: "main") + model.show(section) + } +} diff --git a/Sources/Sst/AppIdentity.swift b/Sources/Sst/AppIdentity.swift new file mode 100644 index 0000000..e3ecb82 --- /dev/null +++ b/Sources/Sst/AppIdentity.swift @@ -0,0 +1,18 @@ +import Foundation + +/// The one place that knows the app's name, so renaming it later is a one-file change. +enum AppIdentity { + static let displayName = "Sst" + static let supportFolderName = "Sst" + static let brandCaption = "SST · FIELD RECORDER" +} + +enum AppPaths { + static var support: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appending(path: AppIdentity.supportFolderName, directoryHint: .isDirectory) + } + static var dictionary: URL { support.appending(path: "dictionary.txt") } + static var history: URL { support.appending(path: "history.json") } + static var note: URL { support.appending(path: "note.txt") } +} diff --git a/Sources/Sst/AppModel.swift b/Sources/Sst/AppModel.swift index 64a56b7..ddd406e 100644 --- a/Sources/Sst/AppModel.swift +++ b/Sources/Sst/AppModel.swift @@ -1,57 +1,153 @@ +import AppKit import AVFoundation -import ApplicationServices -import SwiftUI +import Combine import SstCore +import SstData import SstWhisper +import SwiftUI + +/// Decides at insertion time where dictated text goes. +/// Sst in front: into the Notitie editor if (and only if) it has the cursor, otherwise nowhere (it would land in Sst's own +/// search field). Another app in front: pasted there, except for recordings started in the window. +final class InsertionPolicy { + var windowSession = false + + func route() -> InsertionRoute { + MainActor.assumeIsolated { + let sstInFront = NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Bundle.main.bundleIdentifier + if sstInFront { return NoteEditorRegistry.shared.active != nil ? .editor : .none } + return windowSession ? .none : .system + } + } +} @MainActor final class AppModel: ObservableObject { - @Published var state: DictationState = .idle - @Published var modelStatus = "Model laden…" - @Published var modelReady = false + enum Section: String, CaseIterable, Identifiable { + case transcripts, dictionary, note + var id: String { rawValue } + } + + enum ModelStatus: Equatable { + case loading(String) + case downloading(String, Double) + case ready + case failed(String) + } + + // Services + let settings: AppSettings + let permissions = PermissionMonitor() + let levelSource = LevelSource() + let history: HistoryStore + let dictionary: DictionaryStore + let storage = ModelStorage() + let note: NoteStore + let historyList: HistoryViewModel + let dictionaryList: DictionaryViewModel + + // Observable state + @Published private(set) var state: DictationState = .idle + @Published private(set) var modelStatus: ModelStatus = .loading(ModelCatalog.defaultVariant) + @Published private(set) var activeVariant: String? + @Published private(set) var installedVariants: Set = [] + @Published private(set) var recordingStartedAt: Date? + @Published private(set) var lastDuration: TimeInterval? + @Published private(set) var loadingVariant: String? + /// Bumped whenever the history or dictionary changes, so views that cache derived data can refresh. + @Published private(set) var historyRevision = 0 + @Published private(set) var dictionaryRevision = 0 + @Published private(set) var searchFocusTick = 0 @Published var lastError: String? - @Published var micGranted = false - @Published var accessibilityGranted = false - @Published var inputMonitoringGranted = false + @Published var section: Section = .transcripts + @Published var confirmingClearHistory = false - private let transcriber = WhisperKitTranscriber() + private let transcriber = SwappableTranscriber() + private let corrector: DictionaryCorrector private let hotkey = HotkeyMonitor() + private let policy: InsertionPolicy private let controller: DictationController + private var modelTask: Task? + private var cancellables = Set() - init() { - controller = DictationController( - recorder: AudioRecorder(), + 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) + let note = NoteStore(fileURL: preview ? folder.appending(path: "note.txt") : AppPaths.note) + let levelSource = self.levelSource + let recorder = AudioRecorder() + recorder.onLevel = { levelSource.update($0) } + + self.settings = settings + self.policy = policy + self.corrector = corrector + self.dictionary = dictionary + self.history = history + self.note = note + self.historyList = HistoryViewModel(store: history) + self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge) + self.controller = DictationController( + recorder: recorder, transcriber: transcriber, cleaner: PassthroughCleaner(), - inserter: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()) - ) - controller.onStateChange = { [weak self] in - self?.state = $0 - if $0 == .recording { self?.lastError = nil } + inserter: RoutingInserter( + system: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()), + editor: EditorInserter(), + route: { policy.route() }), + corrector: corrector, + vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] }) + + wire() + if preview { + loadPreviewData() + } else { + Task { await start() } } - controller.onError = { [weak self] in self?.lastError = $0 } - hotkey.onEvent = { [weak self] event in - guard let self, self.modelReady else { return } - if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { - self.lastError = "Microfoon-toegang ontbreekt" - return - } - Task { await self.controller.handle(event) } + } + + // MARK: derived + + var isRecording: Bool { state == .recording } + var hotkeyBinding: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn } + var modelReady: Bool { modelStatus == .ready } + + /// Why recording is not possible right now, or nil when it is. + var recordingBlocker: String? { + if !permissions.allGranted { return Strings.permissionsMissing } + switch modelStatus { + case .ready: return nil + case .loading(let variant): return Strings.modelLoading(title(for: variant)) + case .downloading(_, let fraction): return Strings.modelDownloading(Int(fraction * 100)) + case .failed: return Strings.modelFailed } - Task { await start() } + } + + /// Like `recordingBlocker`, plus: a window recording is only kept when the history is on. + var windowRecordingBlocker: String? { + recordingBlocker ?? (settings.keepHistory ? nil : Strings.historyNeededForWindow) } var statusLine: String { - guard modelReady else { return modelStatus } + if let blocker = recordingBlocker, state == .idle { return blocker } switch state { - case .idle: return "Klaar — houd Fn ingedrukt om te dicteren" - case .recording: return "Luistert…" - case .transcribing: return "Transcriberen…" - case .inserting: return "Invoegen…" + case .idle: return Strings.holdToDictate(hotkeyBinding.displayName) + case .recording: return Strings.statusListening + case .transcribing: return Strings.statusTranscribing + case .inserting: return Strings.statusInserting } } - var iconName: String { + var menuIconName: String { switch state { case .idle: if lastError != nil { return "exclamationmark.triangle" } @@ -61,43 +157,250 @@ final class AppModel: ObservableObject { } } - func refreshPermissions() { - micGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - accessibilityGranted = AXIsProcessTrusted() - inputMonitoringGranted = CGPreflightListenEventAccess() + func title(for variant: String) -> String { + ModelCatalog.all.first { $0.id == variant }?.title ?? variant } - func retryHotkey() { - refreshPermissions() - do { - try hotkey.start() - lastError = nil - } catch { - lastError = error.localizedDescription + // MARK: lifecycle + + private func wire() { + controller.onStateChange = { [weak self] state in + guard let self else { return } + self.state = state + if state != .recording, let started = self.recordingStartedAt { + // The counter shows the recording length, not the time spent transcribing and pasting. + self.lastDuration = Date().timeIntervalSince(started) + self.recordingStartedAt = nil + } + switch state { + case .recording: + self.lastError = nil + self.recordingStartedAt = Date() + self.levelSource.reset() + case .idle: + // Also covers a window recording that was ended with the hotkey; the paste decision is already made. + self.policy.windowSession = false + self.levelSource.reset() + default: break + } + } + controller.onError = { [weak self] in self?.lastError = $0 } + controller.onResult = { [weak self] result in + guard let self, self.settings.keepHistory else { return } + self.history.add(HistoryEntry(result: result)) + } + hotkey.onEvent = { [weak self] event in self?.handleHotkey(event) } + hotkey.binding = hotkeyBinding + + settings.$hotkeyID + .sink { [weak self] id in + guard let self else { return } + self.hotkey.binding = HotkeyBinding.binding(id: id) ?? .fn + self.objectWillChange.send() + } + .store(in: &cancellables) + permissions.$inputMonitoring + .dropFirst() + .removeDuplicates() + .filter { $0 } + .sink { [weak self] _ in self?.retryHotkey() } + .store(in: &cancellables) + settings.$useCorrection + .sink { [corrector] in corrector.setEnabled($0) } + .store(in: &cancellables) + permissions.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + settings.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + + dictionary.onChange = { [weak self] in + guard let self else { return } + self.corrector.update(entries: self.dictionary.entries) + self.dictionaryList.reload() + self.dictionaryRevision += 1 + } + dictionary.onExternalChange = { [weak self] in self?.dictionaryList.externalChangeDetected() } + history.onChange = { [weak self] in + self?.historyRevision += 1 + self?.historyList.refresh() + } + // Backstop for editors that write in place, which the folder watcher can miss. + NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.dictionary.reloadIfChanged() } + } + NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history, note] _ in + MainActor.assumeIsolated { + history.flush() + note.flush() + } } } private func start() async { - _ = await AVCaptureDevice.requestAccess(for: .audio) - if !CGPreflightListenEventAccess() { _ = CGRequestListenEventAccess() } - if !AXIsProcessTrusted() { - _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) + do { try dictionary.load() } catch { lastError = error.localizedDescription } + dictionary.startWatching() + note.load() + switch history.load() { + case .recoveredCorrupt: + lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet." + case .unreadable: + lastError = Strings.historyUnreadable + case .empty, .loaded: + break } + refreshInstalled() + if !ModelCatalog.all.contains(where: { $0.id == settings.modelVariant }) { + settings.modelVariant = ModelCatalog.defaultVariant + } + activateModel(settings.modelVariant) // the download must not wait for the microphone prompt + await permissions.requestMissing() retryHotkey() - transcriber.onProgress = { [weak self] fraction in - Task { @MainActor in - self?.modelStatus = fraction < 1 - ? "Model downloaden… \(Int(fraction * 100))%" - : "Model laden…" - } - } + } + + func retryHotkey() { + permissions.refresh() do { - try await transcriber.load() - modelReady = true - modelStatus = "Klaar" + try hotkey.start() + if lastError == HotkeyMonitor.HotkeyError.tapUnavailable.errorDescription { lastError = nil } } catch { - modelStatus = "Model laden mislukt" lastError = error.localizedDescription } } + + // MARK: recording + + private func handleHotkey(_ event: HotkeyEvent) { + guard event == .released || modelReady else { return } + if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { + lastError = Strings.microphoneMissing + return + } + Task { await controller.handle(event) } + } + + /// Start/stop from the window or ⌥⌘R. The result goes to the history only; nothing is pasted. + func toggleWindowRecording() { + if isRecording { + stopWindowRecording() + } else { + startWindowRecording() + } + } + + func startWindowRecording() { + guard windowRecordingBlocker == nil, state == .idle else { return } + policy.windowSession = true + Task { + await controller.handle(.pressed) + if state != .recording { policy.windowSession = false } + } + } + + func stopWindowRecording() { + guard isRecording else { return } + Task { + await controller.handle(.released) + policy.windowSession = false + } + } + + // MARK: model + + func refreshInstalled() { + installedVariants = Set(ModelCatalog.all.map(\.id).filter { storage.isInstalled($0) }) + } + + /// Downloads (if needed) and loads `variant`. Dictation is off meanwhile; if it fails the previous model stays. + func activateModel(_ variant: String) { + modelTask?.cancel() + let previous = activeVariant + loadingVariant = variant + modelStatus = .loading(variant) + modelTask = Task { [weak self] in + guard let self else { return } + let candidate = WhisperKitTranscriber(model: variant, storage: storage) + candidate.onProgress = { [weak self] fraction in + Task { @MainActor in + guard let self, self.loadingVariant == variant else { return } + self.modelStatus = fraction < 1 ? .downloading(variant, fraction) : .loading(variant) + } + } + do { + try await candidate.load() + guard !Task.isCancelled else { return } + transcriber.swap(to: candidate) + activeVariant = variant + settings.modelVariant = variant + modelStatus = .ready + } catch { + guard !Task.isCancelled else { return } + lastError = error.localizedDescription + modelStatus = previous == nil ? .failed(error.localizedDescription) : .ready + } + if loadingVariant == variant { loadingVariant = nil } + refreshInstalled() + } + } + + func deleteModel(_ variant: String) { + guard variant != activeVariant, variant != loadingVariant else { return } + do { + try storage.delete(variant) + } catch { + lastError = error.localizedDescription + } + refreshInstalled() + } + + // MARK: navigation and history + + func show(_ section: Section) { + self.section = section + NSApp.activate(ignoringOtherApps: true) + } + + func focusSearch() { + searchFocusTick += 1 + } + + func clearHistory() { + history.removeAll() + history.flush() + } + + func openDictionaryFile() { + NSWorkspace.shared.open(dictionary.fileURL) + } + + // MARK: preview + + private func loadPreviewData() { + note.text = "Notities voor vrijdag: Supabase-migratie plannen en Claude Code uitproberen." + 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 + } } diff --git a/Sources/Sst/DeckView.swift b/Sources/Sst/DeckView.swift new file mode 100644 index 0000000..9983a05 --- /dev/null +++ b/Sources/Sst/DeckView.swift @@ -0,0 +1,74 @@ +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.windowRecordingBlocker != nil) + .help(model.windowRecordingBlocker ?? "") + + 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) + } +} diff --git a/Sources/Sst/DictionaryView.swift b/Sources/Sst/DictionaryView.swift new file mode 100644 index 0000000..5b95b7d --- /dev/null +++ b/Sources/Sst/DictionaryView.swift @@ -0,0 +1,263 @@ +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 + @FocusState private var listFocused: 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 + listFocused = true + } + } + } + } + } + .focusable() + .focused($listFocused) + .focusEffectDisabled() + .onDeleteCommand { + if let id = selection { + list.remove(id: id) + selection = nil + } + } + .onChange(of: model.searchFocusTick) { _, _ in searchFocused = true } + .onChange(of: list.rows) { _, rows in + if let s = selection, !rows.contains(where: { $0.id == s }) { selection = nil } + } + .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()) + .keyboardShortcut(.cancelAction) + Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave) + .keyboardShortcut(.defaultAction) + } + } + .padding(Layout.panelPadding) + } + .frame(width: Layout.settingsWidth) + .onAppear(perform: load) + } + + private func field(_ title: String, text: Binding) -> some View { + HStack(spacing: Space.s4) { + SilkLabel(title).frame(width: Size.keyRecWidth, alignment: .leading) + TextField("", text: text) + .textFieldStyle(.plain) + .textStyle(Typography.code) + .foregroundStyle(Palette.inkPrimary) + .padding(Space.s3) + .background(WellBackground()) + } + } + + private func load() { + guard let id = editingID, let row = list.rows.first(where: { $0.id == id }) else { return } + switch row.entry.kind { + case .term(let term): (isCorrection, heard, wrote) = (false, "", term) + case .correction(let h, let w): (isCorrection, heard, wrote) = (true, h, w) + } + } + + private func save() { + if let id = editingID { + guard list.rows.contains(where: { $0.id == id }) else { + list.errorMessage = Strings.entryGone + onClose() + return + } + list.update(id: id, to: kind) + } else { + list.add(kind) + } + onClose() + } +} diff --git a/Sources/Sst/HistoryView.swift b/Sources/Sst/HistoryView.swift new file mode 100644 index 0000000..452cef3 --- /dev/null +++ b/Sources/Sst/HistoryView.swift @@ -0,0 +1,130 @@ +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 + @FocusState private var listFocused: 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() + .focused($listFocused) + .focusEffectDisabled() + .onChange(of: list.selection) { _, new in if new != nil { listFocused = true } } + .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) + } +} diff --git a/Sources/Sst/ListSupport.swift b/Sources/Sst/ListSupport.swift new file mode 100644 index 0000000..36dd7c3 --- /dev/null +++ b/Sources/Sst/ListSupport.swift @@ -0,0 +1,50 @@ +import SstUI +import SwiftUI + +/// Debug snapshots (`Sst --snapshot`) are drawn by `ImageRenderer`, which cannot draw scroll views or text fields. +/// While rendering one, the views below fall back to plain stacks and text. +@MainActor +enum SnapshotMode { + static var isActive = false +} + +/// A scrolling, lazily built list (a plain stack while a snapshot is rendered). +struct ScrollList: View { + @ViewBuilder var content: Content + + var body: some View { + if SnapshotMode.isActive { + VStack(alignment: .leading, spacing: 0) { content } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { content } + } + } + } +} + +/// The recessed "ZOEK" field. +struct SearchField: View { + @Binding var text: String + var focused: FocusState.Binding + + var body: some View { + HStack(spacing: Space.s4) { + SilkLabel(Strings.search) + Group { + if SnapshotMode.isActive { + Text(text).frame(maxWidth: .infinity, alignment: .leading) + } else { + TextField("", text: $text) + .textFieldStyle(.plain) + .focused(focused) + } + } + .textStyle(Typography.body) + .foregroundStyle(Palette.inkPrimary) + .padding(Space.s3) + .background(WellBackground()) + } + } +} diff --git a/Sources/Sst/MainWindow.swift b/Sources/Sst/MainWindow.swift new file mode 100644 index 0000000..1b35312 --- /dev/null +++ b/Sources/Sst/MainWindow.swift @@ -0,0 +1,30 @@ +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) + case .note: NoteView(note: model.note) + } + } + .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) + } + } +} diff --git a/Sources/Sst/MenuBarContent.swift b/Sources/Sst/MenuBarContent.swift new file mode 100644 index 0000000..3132fff --- /dev/null +++ b/Sources/Sst/MenuBarContent.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// The secondary interface: status and shortcut while working in another app. +struct MenuBarContent: View { + @EnvironmentObject private var model: AppModel + @Environment(\.openWindow) private var openWindow + + var body: some View { + Text(model.statusLine) + if let error = model.lastError { + Text(error) + } + Divider() + Button(Strings.openApp) { + openWindow(id: "main") + model.show(model.section) + } + SettingsLink { Text(Strings.settings) } + Divider() + Button(Strings.quit) { NSApplication.shared.terminate(nil) } + .keyboardShortcut("q") + } +} diff --git a/Sources/Sst/MenuContent.swift b/Sources/Sst/MenuContent.swift deleted file mode 100644 index 5d9b15f..0000000 --- a/Sources/Sst/MenuContent.swift +++ /dev/null @@ -1,34 +0,0 @@ -import AppKit -import SwiftUI - -struct MenuContent: View { - @ObservedObject var model: AppModel - - var body: some View { - Text(model.statusLine) - if let error = model.lastError { - Text("Fout: \(error)") - } - Divider() - permissionRow("Microfoon", granted: model.micGranted, pane: "Privacy_Microphone") - permissionRow("Accessibility", granted: model.accessibilityGranted, pane: "Privacy_Accessibility") - permissionRow("Input Monitoring", granted: model.inputMonitoringGranted, pane: "Privacy_ListenEvent") - Button("Rechten opnieuw controleren") { model.retryHotkey() } - Divider() - Button("Stop") { NSApplication.shared.terminate(nil) } - .keyboardShortcut("q") - } - - @ViewBuilder - private func permissionRow(_ title: String, granted: Bool, pane: String) -> some View { - if granted { - Text("✓ \(title)") - } else { - Button("⚠︎ \(title) — open Instellingen") { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") { - NSWorkspace.shared.open(url) - } - } - } - } -} diff --git a/Sources/Sst/NoteEditor.swift b/Sources/Sst/NoteEditor.swift new file mode 100644 index 0000000..3cbae10 --- /dev/null +++ b/Sources/Sst/NoteEditor.swift @@ -0,0 +1,130 @@ +import AppKit +import SstCore +import SstData +import SstUI +import SwiftUI + +/// Text view of the Notitie editor; registers itself while it has the cursor. +final class NoteTextView: NSTextView { + override func becomeFirstResponder() -> Bool { + let accepted = super.becomeFirstResponder() + if accepted { NoteEditorRegistry.shared.focused = self } + return accepted + } + + override func resignFirstResponder() -> Bool { + let accepted = super.resignFirstResponder() + if accepted, NoteEditorRegistry.shared.focused === self { NoteEditorRegistry.shared.focused = nil } + return accepted + } + + /// Types dictated text at the cursor (replacing a selection) through the normal edit path, so undo works. + func insertDictation(_ text: String) { + let range = selectedRange() + let ns = string as NSString + var before: Character? + if range.location > 0, range.location <= ns.length { + before = ns.substring(with: ns.rangeOfComposedCharacterSequence(at: range.location - 1)).first + } + let prepared = DictationSpacing.prepared(text, characterBefore: before) + if shouldChangeText(in: range, replacementString: prepared) { + insertText(prepared, replacementRange: range) + } + scrollRangeToVisible(selectedRange()) + } +} + +@MainActor +final class NoteEditorRegistry { + static let shared = NoteEditorRegistry() + weak var focused: NoteTextView? + + /// The editor, but only while it really has the cursor in the key window. + var active: NoteTextView? { + guard let view = focused, let window = view.window, window.isKeyWindow, window.firstResponder === view else { return nil } + return view + } +} + +/// Sends dictated text into the Notitie editor. +struct EditorInserter: TextInserting { + func insert(_ text: String) async throws { + await MainActor.run { NoteEditorRegistry.shared.active?.insertDictation(text) } + } +} + +struct NoteEditor: NSViewRepresentable { + @Binding var text: String + var isEditable: Bool + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSScrollView { + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.borderType = .noBorder + + let view = NoteTextView(frame: .zero) + view.minSize = .zero + view.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + view.isVerticallyResizable = true + view.isHorizontallyResizable = false + view.autoresizingMask = [.width] + view.textContainer?.widthTracksTextView = true + view.textContainerInset = NSSize(width: Space.s5, height: Space.s5) + view.isRichText = false + view.allowsUndo = true + view.drawsBackground = false + view.isAutomaticQuoteSubstitutionEnabled = false + view.isAutomaticDashSubstitutionEnabled = false + view.delegate = context.coordinator + view.string = text + scroll.documentView = view + context.coordinator.view = view + apply(to: view) + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + context.coordinator.parent = self + guard let view = context.coordinator.view else { return } + if view.string != text, !view.hasMarkedText() { + let selection = view.selectedRange() + view.string = text + let length = (text as NSString).length + view.setSelectedRange(NSRange(location: min(selection.location, length), length: 0)) + } + view.isEditable = isEditable + apply(to: view) + } + + /// Font and colours come from the design tokens; colours follow the current appearance. + private func apply(to view: NoteTextView) { + let style = Typography.reading + let font = NSFont.systemFont(ofSize: style.size, weight: .regular) + view.font = font + view.effectiveAppearance.performAsCurrentDrawingAppearance { + let ink = NSColor(Palette.inkPrimary) + view.textColor = ink + view.insertionPointColor = ink + } + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = max(0, style.line - style.size * 1.2) + view.defaultParagraphStyle = paragraph + view.typingAttributes = [.font: font, .foregroundColor: view.textColor ?? .labelColor, .paragraphStyle: paragraph] + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: NoteEditor + weak var view: NoteTextView? + + init(_ parent: NoteEditor) { self.parent = parent } + + func textDidChange(_ notification: Notification) { + guard let view else { return } + parent.text = view.string + } + } +} diff --git a/Sources/Sst/NoteView.swift b/Sources/Sst/NoteView.swift new file mode 100644 index 0000000..82da6d1 --- /dev/null +++ b/Sources/Sst/NoteView.swift @@ -0,0 +1,59 @@ +import AppKit +import SstData +import SstUI +import SwiftUI + +struct NoteView: View { + @EnvironmentObject private var model: AppModel + @ObservedObject var note: NoteStore + @State private var confirmingClear = false + + var body: some View { + VStack(spacing: Layout.panelGap) { + header + ZStack { + PaperBackground() + if SnapshotMode.isActive { + // ImageRenderer cannot draw an NSView; the snapshot shows the text instead. + Text(note.text) + .textStyle(Typography.reading) + .foregroundStyle(Palette.inkPrimary) + .padding(Space.s5) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + NoteEditor(text: $note.text, isEditable: !note.isReadOnly) + } + } + footer + } + .confirmationDialog(Strings.clearNoteConfirm, isPresented: $confirmingClear) { + Button(Strings.clear, role: .destructive) { note.text = "" } + } + } + + private var header: some View { + HStack(spacing: Space.s4) { + SilkLabel(Strings.note) + Spacer() + Text(Strings.characters(note.text.count)) + .textStyle(Typography.counterSmall) + .foregroundStyle(Palette.inkSecondary) + Button(Strings.copy) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(note.text, forType: .string) + } + .buttonStyle(KeyButtonStyle()) + .disabled(note.text.isEmpty) + Button(Strings.clear) { confirmingClear = true } + .buttonStyle(KeyButtonStyle()) + .disabled(note.text.isEmpty) + } + } + + private var footer: some View { + Text(note.isReadOnly ? Strings.noteUnreadable : Strings.noteHint(model.hotkeyBinding.displayName)) + .textStyle(Typography.label) + .foregroundStyle(Palette.inkTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Sources/Sst/PermissionMonitor.swift b/Sources/Sst/PermissionMonitor.swift new file mode 100644 index 0000000..d44f327 --- /dev/null +++ b/Sources/Sst/PermissionMonitor.swift @@ -0,0 +1,87 @@ +import AppKit +import ApplicationServices +import AVFoundation +import Combine + +/// Reads the three macOS permissions and keeps them current: on every app activation and while polling is on. +@MainActor +final class PermissionMonitor: ObservableObject { + enum Kind: CaseIterable, Identifiable { + case microphone, accessibility, inputMonitoring + + var id: Self { self } + + var title: String { + switch self { + case .microphone: Strings.microphone + case .accessibility: Strings.accessibility + case .inputMonitoring: Strings.inputMonitoring + } + } + + fileprivate var pane: String { + switch self { + case .microphone: "Privacy_Microphone" + case .accessibility: "Privacy_Accessibility" + case .inputMonitoring: "Privacy_ListenEvent" + } + } + } + + @Published private(set) var microphone = false + @Published private(set) var accessibility = false + @Published private(set) var inputMonitoring = false + + private var timer: Timer? + private var activation: AnyCancellable? + + init() { + refresh() + activation = NotificationCenter.default + .publisher(for: NSApplication.didBecomeActiveNotification) + .sink { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } } + } + + var allGranted: Bool { microphone && accessibility && inputMonitoring } + + func isGranted(_ kind: Kind) -> Bool { + switch kind { + case .microphone: microphone + case .accessibility: accessibility + case .inputMonitoring: inputMonitoring + } + } + + func refresh() { + microphone = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + accessibility = AXIsProcessTrusted() + inputMonitoring = CGPreflightListenEventAccess() + } + + /// Asks macOS for every permission that is still missing (each shows its own system prompt). + func requestMissing() async { + if !microphone { _ = await AVCaptureDevice.requestAccess(for: .audio) } + if !inputMonitoring { _ = CGRequestListenEventAccess() } + if !accessibility { _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) } + refresh() + } + + func openSettings(for kind: Kind) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(kind.pane)") { + NSWorkspace.shared.open(url) + } + } + + /// Polls while a view that shows the lamps is visible, so granting a permission shows up without a click. + func startPolling(every interval: TimeInterval = 2) { + guard timer == nil else { return } + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } + } + } + + func stopPolling() { + timer?.invalidate() + timer = nil + } +} diff --git a/Sources/Sst/SettingsView.swift b/Sources/Sst/SettingsView.swift new file mode 100644 index 0000000..deb4c1e --- /dev/null +++ b/Sources/Sst/SettingsView.swift @@ -0,0 +1,224 @@ +import AppKit +import SstCore +import SstData +import SstUI +import SstWhisper +import SwiftUI + +struct SettingsView: View { + private enum Tab: CaseIterable { + case hotkey, model, privacy + + var title: String { + switch self { + case .hotkey: Strings.tabHotkey + case .model: Strings.tabModel + case .privacy: Strings.tabPrivacy + } + } + } + + @State private var tab: Tab = .hotkey + + var body: some View { + ZStack { + BrushedBackground() + VStack(alignment: .leading, spacing: Layout.panelGap) { + HStack(spacing: Space.s3) { + ForEach(Tab.allCases, id: \.self) { candidate in + Button(candidate.title) { tab = candidate } + .buttonStyle(KeyButtonStyle(latched: tab == candidate)) + } + } + Group { + switch tab { + case .hotkey: HotkeySettingsView() + case .model: ModelSettingsView() + case .privacy: PrivacySettingsView() + } + } + .padding(Layout.panelPadding) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(PanelBackground()) + } + .padding(Layout.panelPadding) + } + .frame(width: Layout.settingsWidth) + .fixedSize(horizontal: false, vertical: true) + } +} + +// MARK: hotkey + +extension HotkeyBinding { + /// The matching flag in an AppKit event, used while capturing a new key. + fileprivate var eventFlag: NSEvent.ModifierFlags { + switch id { + case "fn": .function + case "rightOption": .option + case "rightCommand": .command + default: .control + } + } +} + +@MainActor +private final class HotkeyCapture: ObservableObject { + @Published var isCapturing = false + @Published var message: String? + private var monitor: Any? + + func start(onCapture: @escaping (HotkeyBinding) -> Void) { + stop() + isCapturing = true + message = Strings.capturing + monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged, .keyDown]) { [weak self] event in + guard let self else { return event } + if event.type == .keyDown { + if event.keyCode == 53 { self.stop() } // Escape cancels + return nil + } + guard let binding = HotkeyBinding.binding(keyCode: Int(event.keyCode)) else { + self.message = Strings.hotkeyRejected + return nil + } + if event.modifierFlags.contains(binding.eventFlag) { + self.stop() + onCapture(binding) + } + return nil + } + } + + func stop() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + isCapturing = false + message = nil + } +} + +private struct HotkeySettingsView: View { + @EnvironmentObject private var settings: AppSettings + @StateObject private var capture = HotkeyCapture() + + private var current: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn } + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.tabHotkey, style: Typography.heading) + HStack(spacing: Layout.panelGap) { + Text(current.displayName) + .textStyle(Typography.heading) + .foregroundStyle(Palette.inkPrimary) + .padding(.horizontal, Space.s4) + .frame(minHeight: Size.controlHeight) + .background(WellBackground()) + Button(Strings.captureHotkey) { + capture.start { settings.hotkeyID = $0.id } + } + .buttonStyle(KeyButtonStyle(latched: capture.isCapturing)) + } + if let message = capture.message { + Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + } + SeamDivider() + Text(HotkeyBinding.allowed.map(\.displayName).joined(separator: " · ")) + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + if current == .fn { + Text(Strings.globeHint).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + } + } + .onDisappear { capture.stop() } + } +} + +// MARK: model + +private struct ModelSettingsView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.tabModel, style: Typography.heading) + ForEach(ModelCatalog.all) { option in + row(option) + SeamDivider() + } + HStack { + Text(model.storage.base.path) + .textStyle(Typography.code) + .foregroundStyle(Palette.inkSecondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button(Strings.showInFinder) { + NSWorkspace.shared.activateFileViewerSelecting([model.storage.base]) + } + .buttonStyle(KeyButtonStyle()) + } + } + .onAppear { model.refreshInstalled() } + } + + private func row(_ option: ModelOption) -> some View { + let isActive = model.activeVariant == option.id + let installed = model.installedVariants.contains(option.id) + let progress: Double? = { + if case .downloading(option.id, let fraction) = model.modelStatus { return fraction } + return nil + }() + return HStack(alignment: .center, spacing: Layout.panelGap) { + LampView(isOn: isActive, tint: .amber) + VStack(alignment: .leading, spacing: Space.s1) { + Text(option.title).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) + Text("\(option.detail) · \(option.approxSizeMB) MB") + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + if let progress { + LEDBarView(fraction: progress) + } else if case .loading(option.id) = model.modelStatus { + SilkLabel(Strings.modelLoading(option.title), style: Typography.labelSmall) + } + } + Spacer() + if installed && !isActive { + Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) + .disabled(model.loadingVariant != nil) + Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle()) + .disabled(model.loadingVariant != nil) + } else if !installed { + Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) + .disabled(model.loadingVariant != nil) + } + } + } +} + +// MARK: privacy + +private struct PrivacySettingsView: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settings: AppSettings + @State private var confirming = false + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.tabPrivacy, style: Typography.heading) + Toggle(isOn: $settings.keepHistory) { + Text(Strings.keepHistory).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) + } + .toggleStyle(RockerToggleStyle()) + SeamDivider() + Button(Strings.clearHistory) { confirming = true } + .buttonStyle(KeyButtonStyle()) + } + .confirmationDialog(Strings.clearHistoryConfirm, isPresented: $confirming) { + Button(Strings.erase, role: .destructive) { model.clearHistory() } + Button(Strings.cancel, role: .cancel) {} + } message: { + Text(Strings.clearHistoryDetail) + } + } +} diff --git a/Sources/Sst/Sidebar.swift b/Sources/Sst/Sidebar.swift new file mode 100644 index 0000000..14f2ab3 --- /dev/null +++ b/Sources/Sst/Sidebar.swift @@ -0,0 +1,64 @@ +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 + case .note: Strings.note + } + } +} + +/// 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() } + } +} diff --git a/Sources/Sst/Snapshot.swift b/Sources/Sst/Snapshot.swift new file mode 100644 index 0000000..220bbce --- /dev/null +++ b/Sources/Sst/Snapshot.swift @@ -0,0 +1,31 @@ +#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 diff --git a/Sources/Sst/SstApp.swift b/Sources/Sst/SstApp.swift index e4d86be..eeedd03 100644 --- a/Sources/Sst/SstApp.swift +++ b/Sources/Sst/SstApp.swift @@ -1,14 +1,30 @@ import SwiftUI +import SstUI -@main struct SstApp: App { @StateObject private var model = AppModel() var body: some Scene { + Window(AppIdentity.displayName, id: "main") { + MainWindow() + .environmentObject(model) + .environmentObject(model.settings) + .frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight) + } + .defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) + .commands { AppCommands(model: model) } + + Settings { + SettingsView() + .environmentObject(model) + .environmentObject(model.settings) + } + MenuBarExtra { - MenuContent(model: model) + MenuBarContent() + .environmentObject(model) } label: { - Image(systemName: model.iconName) + Image(systemName: model.menuIconName) } } } diff --git a/Sources/Sst/Strings.swift b/Sources/Sst/Strings.swift new file mode 100644 index 0000000..8751a09 --- /dev/null +++ b/Sources/Sst/Strings.swift @@ -0,0 +1,99 @@ +import Foundation + +/// All user-facing text, in one place (Dutch), so translating later is a single-file job. +enum Strings { + // sections + static let transcripts = "Transcripties" + static let dictionary = "Woordenboek" + static let controls = "Controles" + + // 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 entryGone = "Deze invoer bestaat niet meer; je wijziging is niet bewaard." + static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +." + + // note + static let note = "Notitie" + static let clear = "Wis" + static func characters(_ count: Int) -> String { count == 1 ? "1 teken" : "\(count) tekens" } + static func noteHint(_ keyName: String) -> String { "Zet de cursor in het vak en houd \(keyName) ingedrukt om hier te dicteren." } + static let clearNoteConfirm = "Notitie wissen?" + static let noteUnreadable = "De notitie kon niet worden gelezen; wijzigingen worden niet bewaard." + + // deck + static let record = "Rec" + static let stop = "Stop" + static let time = "Tijd" + static let level = "Niveau" + + // menus + static let recordingMenu = "Opname" + static let startRecording = "Start opname" + static let stopRecording = "Stop opname" + static let search = "Zoek" + static let clearHistory = "Wis geschiedenis…" + static let openApp = "Open Sst" + static let settings = "Instellingen…" + static let quit = "Stop Sst" + + // status + static let statusIdle = "Klaar" + static let statusListening = "Luistert…" + static let statusTranscribing = "Transcriberen…" + static let statusInserting = "Invoegen…" + static func modelLoading(_ title: String) -> String { "Model laden: \(title)…" } + static func modelDownloading(_ percent: Int) -> String { "Model downloaden… \(percent)%" } + static let modelFailed = "Model laden mislukt" + static func holdToDictate(_ keyName: String) -> String { "Houd \(keyName) ingedrukt om te dicteren" } + + // permissions + static let microphone = "Microfoon" + static let accessibility = "Accessibility" + static let inputMonitoring = "Input Monitoring" + static let modelLamp = "Model" + static let microphoneMissing = "Microfoon-toegang ontbreekt" + static let openSystemSettings = "Open Instellingen" + static let open = "Open" + static let historyNeededForWindow = "Zet ‘Transcripties bewaren’ aan om vanuit het venster op te nemen." + static let historyUnreadable = "Het geschiedenisbestand kon niet worden gelezen; nieuwe transcripties worden niet bewaard." + static let permissionsMissing = "Geef eerst de rechten onder Controles" + + // settings + static let tabHotkey = "Sneltoets" + static let tabModel = "Model" + static let tabPrivacy = "Privacy" + static let captureHotkey = "Leg nieuwe toets vast" + static let capturing = "Druk op de toets die je wilt gebruiken…" + static let hotkeyRejected = "Die toets kan niet. Kies Fn, Rechter Option, Rechter Command of Control." + static let globeHint = "Zet bij Fn in Systeeminstellingen → Toetsenbord ‘Druk op 🌐 voor’ op ‘Doe niets’." + static let useModel = "Gebruik" + static let download = "Download" + static let remove = "Verwijder" + static let showInFinder = "Toon in Finder" + static let keepHistory = "Transcripties bewaren" + static let clearHistoryConfirm = "Alle transcripties wissen?" + static let clearHistoryDetail = "Dit kan niet ongedaan worden gemaakt. Audio wordt nooit bewaard." + static let cancel = "Annuleer" + static let erase = "Wis" +} diff --git a/Sources/Sst/main.swift b/Sources/Sst/main.swift new file mode 100644 index 0000000..c234e37 --- /dev/null +++ b/Sources/Sst/main.swift @@ -0,0 +1,12 @@ +import AppKit + +#if DEBUG +// `Sst --snapshot ` renders the main window with sample data to -transcripts.png and +// -dictionary.png (Aluminium left, Graphite right) and exits. No prompts, no model, no hotkey. +if let flag = CommandLine.arguments.firstIndex(of: "--snapshot"), CommandLine.arguments.count > flag + 1 { + MainActor.assumeIsolated { Snapshot.run(prefix: CommandLine.arguments[flag + 1]) } + exit(0) +} +#endif + +SstApp.main() diff --git a/Sources/SstCore/AppGlue.swift b/Sources/SstCore/AppGlue.swift new file mode 100644 index 0000000..218afdd --- /dev/null +++ b/Sources/SstCore/AppGlue.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Where one dictation's text goes. +public enum InsertionRoute: Equatable, Sendable { + /// Pasted into whichever app is in front. + case system + /// Typed into the Notitie editor inside Sst. + case editor + /// Dropped (Sst is in front but nothing there takes text, or the recording started in the window). + case none +} + +/// Sends each dictation to the system paster or the in-app editor, asked at insertion time rather than at creation. +public struct RoutingInserter: TextInserting { + private let system: TextInserting + private let editor: TextInserting + private let route: () -> InsertionRoute + + public init(system: TextInserting, editor: TextInserting, route: @escaping () -> InsertionRoute) { + self.system = system + self.editor = editor + self.route = route + } + + public func insert(_ text: String) async throws { + switch route() { + case .system: try await system.insert(text) + case .editor: try await editor.insert(text) + case .none: break + } + } +} + +/// Lets the app replace the speech model (for example after a model switch) without rebuilding the controller. +public final class SwappableTranscriber: Transcriber, @unchecked Sendable { + public enum SwapError: Error, LocalizedError { + case noTranscriber + + public var errorDescription: String? { "Spraakmodel is nog niet geladen." } + } + + private let lock = NSLock() + private var current: Transcriber? + + public init(_ initial: Transcriber? = nil) { + current = initial + } + + public func swap(to transcriber: Transcriber?) { + lock.withLock { current = transcriber } + } + + public var hasTranscriber: Bool { + lock.withLock { current != nil } + } + + public func transcribe(_ samples: [Float]) async throws -> String { + try await transcribe(samples, vocabulary: []) + } + + public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + guard let transcriber = lock.withLock({ current }) else { throw SwapError.noTranscriber } + return try await transcriber.transcribe(samples, vocabulary: vocabulary) + } +} + +/// The latest input level, written from the audio thread and read by the meter once per frame. +public final class LevelSource: @unchecked Sendable { + private let lock = NSLock() + private var value = AudioLevel.silenceFloorDbfs + + public init() {} + + public var dbfs: Float { lock.withLock { value } } + + public func update(_ dbfs: Float) { + lock.withLock { value = dbfs } + } + + public func reset() { + update(AudioLevel.silenceFloorDbfs) + } +} diff --git a/Sources/SstCore/AudioLevel.swift b/Sources/SstCore/AudioLevel.swift new file mode 100644 index 0000000..804e1f0 --- /dev/null +++ b/Sources/SstCore/AudioLevel.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum AudioLevel { + /// Reported for empty or silent buffers (log of zero would be -infinity). + public static let silenceFloorDbfs: Float = -120 + + public static func rmsDbfs(_ samples: [Float]) -> Float { + samples.withUnsafeBufferPointer { rmsDbfs($0) } + } + + public static func rmsDbfs(_ samples: UnsafeBufferPointer) -> Float { + guard !samples.isEmpty else { return silenceFloorDbfs } + var sum: Float = 0 + for sample in samples { sum += sample * sample } + let rms = (sum / Float(samples.count)).squareRoot() + guard rms > 0 else { return silenceFloorDbfs } + return max(silenceFloorDbfs, 20 * log10(rms)) + } + + /// VU reading for a dBFS level, where `referenceDbfs` is 0 VU. + public static func vu(fromDbfs dbfs: Float, referenceDbfs: Float = -20) -> Float { + dbfs - referenceDbfs + } +} diff --git a/Sources/SstCore/AudioRecorder.swift b/Sources/SstCore/AudioRecorder.swift index b537465..1e31c4a 100644 --- a/Sources/SstCore/AudioRecorder.swift +++ b/Sources/SstCore/AudioRecorder.swift @@ -12,6 +12,10 @@ public final class AudioRecorder: AudioRecording { private let engine = AVAudioEngine() private let lock = NSLock() private var buffer: [Float] = [] + + /// RMS level (dBFS) of every delivered input buffer. Called on the audio thread; set before `start()`. + public var onLevel: ((Float) -> Void)? + private let targetFormat = AVAudioFormat( commonFormat: .pcmFormatFloat32, sampleRate: 16_000, @@ -32,8 +36,11 @@ public final class AudioRecorder: AudioRecording { let ratio = targetFormat.sampleRate / inputFormat.sampleRate input.removeTap(onBus: 0) - input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] pcm, _ in + input.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] pcm, _ in guard let self else { return } + if let onLevel = self.onLevel, let channel = pcm.floatChannelData?[0] { + onLevel(AudioLevel.rmsDbfs(UnsafeBufferPointer(start: channel, count: Int(pcm.frameLength)))) + } let capacity = AVAudioFrameCount(Double(pcm.frameLength) * ratio) + 1 guard let out = AVAudioPCMBuffer(pcmFormat: self.targetFormat, frameCapacity: capacity) else { return } diff --git a/Sources/SstCore/DictationController.swift b/Sources/SstCore/DictationController.swift index 5db278c..3c6c2f6 100644 --- a/Sources/SstCore/DictationController.swift +++ b/Sources/SstCore/DictationController.swift @@ -7,6 +7,8 @@ public final class DictationController { } public var onStateChange: ((DictationState) -> Void)? public var onError: ((String) -> Void)? + /// Called with every finished transcription, just before it is inserted. + public var onResult: ((DictationResult) -> Void)? private let recorder: AudioRecording private let transcriber: Transcriber @@ -14,16 +16,25 @@ public final class DictationController { private let inserter: TextInserting private let minimumSamples: Int private let silenceThreshold: Float + private let corrector: TextCorrecting? + private let vocabularyProvider: () -> [String] + private let biasMinimumRMS: Float /// - Parameter minimumSamples: 16 kHz samples; default is 0.3 s. /// - Parameter silenceThreshold: RMS below which a recording counts as silence and is skipped. + /// - Parameter corrector: optional dictionary correction applied to the transcript. + /// - Parameter vocabularyProvider: words to bias the transcriber towards. + /// - Parameter biasMinimumRMS: below this RMS no vocabulary is passed (guards against invented text on quiet audio). public init( recorder: AudioRecording, transcriber: Transcriber, cleaner: Cleaner, inserter: TextInserting, minimumSamples: Int = 4_800, - silenceThreshold: Float = 0.005 + silenceThreshold: Float = 0.005, + corrector: TextCorrecting? = nil, + vocabularyProvider: @escaping () -> [String] = { [] }, + biasMinimumRMS: Float = 0.015 ) { self.recorder = recorder self.transcriber = transcriber @@ -31,6 +42,9 @@ public final class DictationController { self.inserter = inserter self.minimumSamples = minimumSamples self.silenceThreshold = silenceThreshold + self.corrector = corrector + self.vocabularyProvider = vocabularyProvider + self.biasMinimumRMS = biasMinimumRMS } public func handle(_ event: HotkeyEvent) async { @@ -49,24 +63,35 @@ public final class DictationController { return } let energy = samples.reduce(0) { $0 + $1 * $1 } - guard (energy / Float(samples.count)).squareRoot() >= silenceThreshold else { + let rms = (energy / Float(samples.count)).squareRoot() + guard rms >= silenceThreshold else { state = .idle return } - await process(samples) + await process(samples, rms: rms) default: break } } - private func process(_ samples: [Float]) async { + private func process(_ samples: [Float], rms: Float) async { defer { state = .idle } do { state = .transcribing - let raw = try await transcriber.transcribe(samples) + let vocabulary = rms >= biasMinimumRMS ? vocabularyProvider() : [] + let raw = try await transcriber.transcribe(samples, vocabulary: vocabulary) let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } - let cleaned = try await cleaner.clean(text, context: CleaningContext()) + guard !text.isEmpty, !PromptEcho.isEcho(text, vocabulary: vocabulary) else { return } + let corrected = corrector?.correct(text) ?? CorrectionResult(text: text, corrections: []) + let cleaned = try await cleaner.clean(corrected.text, context: CleaningContext()) + onResult?(DictationResult( + date: Date(), + duration: Double(samples.count) / 16_000, + rawText: text, + finalText: cleaned, + // Offsets only stay valid if the cleaner left the corrected text alone. + corrections: cleaned == corrected.text ? corrected.corrections : [] + )) state = .inserting try await inserter.insert(cleaned) } catch { diff --git a/Sources/SstCore/HotkeyBinding.swift b/Sources/SstCore/HotkeyBinding.swift new file mode 100644 index 0000000..140b710 --- /dev/null +++ b/Sources/SstCore/HotkeyBinding.swift @@ -0,0 +1,27 @@ +import CoreGraphics + +/// A modifier key that can be held to dictate. Left/right keys share a flag, so the key code decides. +public struct HotkeyBinding: Hashable, Sendable, Identifiable { + public let id: String + public let displayName: String + public let keyCode: Int + public let flagRawValue: UInt64 + + public var flag: CGEventFlags { CGEventFlags(rawValue: flagRawValue) } + + public static let fn = HotkeyBinding( + id: "fn", displayName: "Fn", keyCode: 63, flagRawValue: CGEventFlags.maskSecondaryFn.rawValue) + public static let rightOption = HotkeyBinding( + id: "rightOption", displayName: "Rechter Option", keyCode: 61, flagRawValue: CGEventFlags.maskAlternate.rawValue) + public static let rightCommand = HotkeyBinding( + id: "rightCommand", displayName: "Rechter Command", keyCode: 54, flagRawValue: CGEventFlags.maskCommand.rawValue) + public static let rightControl = HotkeyBinding( + id: "rightControl", displayName: "Rechter Control", keyCode: 62, flagRawValue: CGEventFlags.maskControl.rawValue) + public static let leftControl = HotkeyBinding( + id: "leftControl", displayName: "Linker Control", keyCode: 59, flagRawValue: CGEventFlags.maskControl.rawValue) + + public static let allowed: [HotkeyBinding] = [.fn, .rightOption, .rightCommand, .rightControl, .leftControl] + + public static func binding(id: String) -> HotkeyBinding? { allowed.first { $0.id == id } } + public static func binding(keyCode: Int) -> HotkeyBinding? { allowed.first { $0.keyCode == keyCode } } +} diff --git a/Sources/SstCore/HotkeyMonitor.swift b/Sources/SstCore/HotkeyMonitor.swift index 07d4f22..44b1b6f 100644 --- a/Sources/SstCore/HotkeyMonitor.swift +++ b/Sources/SstCore/HotkeyMonitor.swift @@ -6,13 +6,18 @@ public final class HotkeyMonitor { case tapUnavailable public var errorDescription: String? { - "Kan de Fn-toets niet volgen: geef Input Monitoring-rechten in Systeeminstellingen en probeer opnieuw (soms is een herstart van de app nodig)." + "Kan de sneltoets niet volgen: geef Input Monitoring-rechten in Systeeminstellingen en probeer opnieuw (soms is een herstart van de app nodig)." } } public var onEvent: ((HotkeyEvent) -> Void)? - private var tracker = FnKeyTracker() + /// The key that has to be held. Changing it resets the press tracking. + public var binding: HotkeyBinding = .fn { + didSet { tracker = ModifierKeyTracker() } + } + + private var tracker = ModifierKeyTracker() private var tap: CFMachPort? private var source: CFRunLoopSource? @@ -56,10 +61,10 @@ public final class HotkeyMonitor { case .tapDisabledByTimeout, .tapDisabledByUserInput: if let tap { CGEvent.tapEnable(tap: tap, enable: true) } case .flagsChanged: - // .maskSecondaryFn is also set while arrows/F-keys are held; only react to the Fn key itself. - guard event.getIntegerValueField(.keyboardEventKeycode) == 63 else { return } - let fnDown = event.flags.contains(.maskSecondaryFn) - if let hotkeyEvent = tracker.process(fnDown: fnDown) { + // Flags are shared between left and right keys and with arrows/F-keys: only react to the bound key itself. + guard event.getIntegerValueField(.keyboardEventKeycode) == Int64(binding.keyCode) else { return } + let isDown = event.flags.contains(binding.flag) + if let hotkeyEvent = tracker.process(isDown: isDown) { onEvent?(hotkeyEvent) } default: diff --git a/Sources/SstCore/Models.swift b/Sources/SstCore/Models.swift index 36444a2..9cdac52 100644 --- a/Sources/SstCore/Models.swift +++ b/Sources/SstCore/Models.swift @@ -24,6 +24,63 @@ public protocol AudioRecording { public protocol Transcriber { func transcribe(_ samples: [Float]) async throws -> String + /// Biases decoding towards `vocabulary`. Engines without bias support ignore the list. + func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String +} + +public extension Transcriber { + func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + try await transcribe(samples) + } +} + +/// One replacement made by the dictionary correction pass. +/// `start`/`length` are UTF-16 offsets into the corrected text. +public struct AppliedCorrection: Equatable, Codable, Sendable { + public var heard: String + public var wrote: String + public var rule: String + public var start: Int + public var length: Int + + public init(heard: String, wrote: String, rule: String, start: Int, length: Int) { + self.heard = heard + self.wrote = wrote + self.rule = rule + self.start = start + self.length = length + } +} + +public struct CorrectionResult: Equatable, Sendable { + public var text: String + public var corrections: [AppliedCorrection] + + public init(text: String, corrections: [AppliedCorrection]) { + self.text = text + self.corrections = corrections + } +} + +public protocol TextCorrecting { + func correct(_ text: String) -> CorrectionResult +} + +/// A finished dictation, emitted just before the text is inserted. +public struct DictationResult: Equatable, Sendable { + public var date: Date + public var duration: TimeInterval + public var rawText: String + public var finalText: String + public var corrections: [AppliedCorrection] + + public init(date: Date, duration: TimeInterval, rawText: String, finalText: String, corrections: [AppliedCorrection]) { + self.date = date + self.duration = duration + self.rawText = rawText + self.finalText = finalText + self.corrections = corrections + } } public protocol Cleaner { diff --git a/Sources/SstCore/ModifierKeyTracker.swift b/Sources/SstCore/ModifierKeyTracker.swift new file mode 100644 index 0000000..e83880c --- /dev/null +++ b/Sources/SstCore/ModifierKeyTracker.swift @@ -0,0 +1,12 @@ +public struct ModifierKeyTracker { + private var isDown = false + + public init() {} + + /// Feed the current state of the bound modifier flag; returns an event only on a transition. + public mutating func process(isDown: Bool) -> HotkeyEvent? { + guard isDown != self.isDown else { return nil } + self.isDown = isDown + return isDown ? .pressed : .released + } +} diff --git a/Sources/SstCore/PromptEcho.swift b/Sources/SstCore/PromptEcho.swift new file mode 100644 index 0000000..8c11184 --- /dev/null +++ b/Sources/SstCore/PromptEcho.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Whisper sometimes answers a vocabulary prompt with the prompt itself when there is little speech. +public enum PromptEcho { + static func normalize(_ text: String) -> String { + String(text.lowercased().map { $0.isLetter || $0.isNumber ? $0 : " " }) + .split(separator: " ") + .joined(separator: " ") + } + + /// True when `text` is nothing but the first k ≥ 2 terms of the prompt. + /// A single term on its own is valid speech ("Anthropic"). + public static func isEcho(_ text: String, vocabulary: [String]) -> Bool { + guard vocabulary.count >= 2 else { return false } + let normalized = normalize(text) + guard !normalized.isEmpty else { return false } + return (2...vocabulary.count).contains { k in + normalize(vocabulary.prefix(k).joined(separator: " ")) == normalized + } + } +} diff --git a/Sources/SstData/AppSettings.swift b/Sources/SstData/AppSettings.swift new file mode 100644 index 0000000..9747790 --- /dev/null +++ b/Sources/SstData/AppSettings.swift @@ -0,0 +1,45 @@ +import Combine +import Foundation + +/// User preferences, persisted in UserDefaults as soon as a value changes. +public final class AppSettings: ObservableObject { + public static let defaultModelVariant = "large-v3-v20240930_turbo" + + enum Key { + static let hotkey = "hotkeyID" + static let model = "modelVariant" + static let keepHistory = "keepHistory" + static let usePriming = "usePriming" + static let useCorrection = "useCorrection" + } + + private let defaults: UserDefaults + + /// `HotkeyBinding.id` of the key that has to be held. + @Published public var hotkeyID: String { + didSet { defaults.set(hotkeyID, forKey: Key.hotkey) } + } + @Published public var modelVariant: String { + didSet { defaults.set(modelVariant, forKey: Key.model) } + } + @Published public var keepHistory: Bool { + didSet { defaults.set(keepHistory, forKey: Key.keepHistory) } + } + /// Give the speech engine the dictionary words as context. + @Published public var usePriming: Bool { + didSet { defaults.set(usePriming, forKey: Key.usePriming) } + } + /// Apply the dictionary corrections after transcription. + @Published public var useCorrection: Bool { + didSet { defaults.set(useCorrection, forKey: Key.useCorrection) } + } + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + hotkeyID = defaults.string(forKey: Key.hotkey) ?? "fn" + modelVariant = defaults.string(forKey: Key.model) ?? Self.defaultModelVariant + keepHistory = defaults.object(forKey: Key.keepHistory) as? Bool ?? true + usePriming = defaults.object(forKey: Key.usePriming) as? Bool ?? true + useCorrection = defaults.object(forKey: Key.useCorrection) as? Bool ?? true + } +} diff --git a/Sources/SstData/CorrectionEngine.swift b/Sources/SstData/CorrectionEngine.swift new file mode 100644 index 0000000..9e11266 --- /dev/null +++ b/Sources/SstData/CorrectionEngine.swift @@ -0,0 +1,79 @@ +import Foundation +import SstCore + +/// Whole-word, case-insensitive, single-pass replacement. Longest match wins, replaced text is never rescanned. +public struct CorrectionEngine: TextCorrecting { + private struct Rule { + let source: String + let wrote: String + } + + private let rules: [Rule] + private let regex: NSRegularExpression? + + public init(entries: [DictionaryEntry], knowledge: WordKnowledge) { + var seen = Set() + var built: [(rule: Rule, order: Int)] = [] + for (order, entry) in entries.enumerated() { + let source = entry.source.trimmingCharacters(in: .whitespacesAndNewlines) + let key = EntryKey.key(source) + guard !key.isEmpty, !seen.contains(key) else { continue } + // A single-word term that is also a real word only biases the model; capitalising it everywhere would corrupt normal text. + if case .term(let term) = entry.kind, EntryKey.parts(term).count == 1, knowledge.isWord(term) { continue } + seen.insert(key) + built.append((Rule(source: source, wrote: entry.wrote), order)) + } + let ordered = built.sorted { a, b in + let la = a.rule.source.filter(\.isLetter).count + let lb = b.rule.source.filter(\.isLetter).count + return la != lb ? la > lb : a.order < b.order + }.map(\.rule) + rules = ordered + if ordered.isEmpty { + regex = nil + } else { + let alternatives = ordered.map { "(\(Self.pattern($0.source)))" }.joined(separator: "|") + regex = try? NSRegularExpression( + pattern: "(? String { + EntryKey.parts(source) + .map { NSRegularExpression.escapedPattern(for: $0) } + .joined(separator: "[\\s\\-‐‑–]*") + } + + public func correct(_ text: String) -> CorrectionResult { + guard let regex, !text.isEmpty else { return CorrectionResult(text: text, corrections: []) } + let ns = text as NSString + var output = "" + var outputLength = 0 + var last = 0 + var applied: [AppliedCorrection] = [] + + for match in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { + let gap = ns.substring(with: NSRange(location: last, length: match.range.location - last)) + output += gap + outputLength += gap.utf16.count + + let heard = ns.substring(with: match.range) + let group = (1.. Bool { lhs.kind == rhs.kind } + + /// The text the correction pass listens for (a term listens for itself). + public var source: String { + switch kind { + case .term(let term): term + case .correction(let heard, _): heard + } + } + + /// The text that ends up in the transcript. + public var wrote: String { + switch kind { + case .term(let term): term + case .correction(_, let wrote): wrote + } + } +} + +public enum DictionaryLine: Equatable, Sendable { + case entry(DictionaryEntry) + case comment(String) + case blank + case invalid(String) +} + +/// The dictionary as a list of lines, so comments, blank lines and order survive an edit. +public struct DictionaryFile: Equatable, Sendable { + public var lines: [DictionaryLine] + + public init(lines: [DictionaryLine] = []) { + self.lines = lines + } + + public init(parsing text: String) { + var raw = text.replacingOccurrences(of: "\r\n", with: "\n") + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + if raw.last == "" { raw.removeLast() } + lines = raw.map(Self.parseLine) + } + + static func parseLine(_ line: String) -> DictionaryLine { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .blank } + if trimmed.hasPrefix("#") { return .comment(line) } + if let arrow = trimmed.range(of: "=>") { + let heard = trimmed[.. DictionaryFile { + var available = old.entries + var result = self + result.lines = lines.map { line in + guard case .entry(let entry) = line else { return line } + if let index = available.firstIndex(where: { $0.kind == entry.kind }) { + let reused = available.remove(at: index) + return .entry(DictionaryEntry(id: reused.id, kind: entry.kind)) + } + return line + } + return result + } + + public func serialized() -> String { + let out = lines.map { line -> String in + switch line { + case .entry(let entry): + switch entry.kind { + case .term(let term): return term + case .correction(let heard, let wrote): return "\(heard) => \(wrote)" + } + case .comment(let text), .invalid(let text): return text + case .blank: return "" + } + } + return out.isEmpty ? "" : out.joined(separator: "\n") + "\n" + } + + public var entries: [DictionaryEntry] { + lines.compactMap { line in + if case .entry(let entry) = line { return entry } + return nil + } + } + + @discardableResult + public mutating func append(_ kind: DictionaryEntry.Kind) -> UUID { + let entry = DictionaryEntry(kind: kind) + lines.append(.entry(entry)) + return entry.id + } + + public mutating func update(id: UUID, kind: DictionaryEntry.Kind) { + guard let index = lines.firstIndex(where: { if case .entry(let e) = $0 { return e.id == id } else { return false } }) else { return } + lines[index] = .entry(DictionaryEntry(id: id, kind: kind)) + } + + public mutating func remove(id: UUID) { + lines.removeAll { if case .entry(let e) = $0 { return e.id == id } else { return false } } + } +} diff --git a/Sources/SstData/DictionaryStore.swift b/Sources/SstData/DictionaryStore.swift new file mode 100644 index 0000000..c846ed1 --- /dev/null +++ b/Sources/SstData/DictionaryStore.swift @@ -0,0 +1,119 @@ +import Foundation +import SstCore + +/// Owns `dictionary.txt`: reads, writes atomically, and notices edits made in another editor. +@MainActor +public final class DictionaryStore { + public static let header = """ + # Sst woordenboek + # Eén invoer per regel. Een los woord is een term (voorrang bij het model). + # 'gehoord => schrijf' is een correctie, bijvoorbeeld: cloud code => Claude Code + + """ + + public let fileURL: URL + public private(set) var file = DictionaryFile() + public var onChange: (() -> Void)? + /// Fired (after `onChange`) when the file was changed by something other than this store. + public var onExternalChange: (() -> Void)? + + private var lastText = "" + private var watcher: DispatchSourceFileSystemObject? + private var debounce: Task? + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + public var entries: [DictionaryEntry] { file.entries } + + /// Reads the file; creates it with a short header when it does not exist yet. + public func load() throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: fileURL.path) { + try Self.header.write(to: fileURL, atomically: true, encoding: .utf8) + } + let text = try String(contentsOf: fileURL, encoding: .utf8) + lastText = text + file = DictionaryFile(parsing: text) + onChange?() + } + + /// Applies an edit and writes the file straight away. + public func apply(_ mutate: (inout DictionaryFile) -> Void) throws { + var updated = file + mutate(&updated) + let text = updated.serialized() + try text.write(to: fileURL, atomically: true, encoding: .utf8) + file = updated + lastText = text + onChange?() + } + + /// Re-reads the file when its text differs from what we last wrote or read. + public func reloadIfChanged() { + guard let text = try? String(contentsOf: fileURL, encoding: .utf8), text != lastText else { return } + lastText = text + file = DictionaryFile(parsing: text).preservingIDs(from: file) + onChange?() + onExternalChange?() + } + + /// Watches the folder (editors replace files instead of editing them in place). + public func startWatching() { + guard watcher == nil else { return } + let descriptor = open(fileURL.deletingLastPathComponent().path, O_EVTONLY) + guard descriptor >= 0 else { return } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, eventMask: .write, queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { self?.scheduleReload() } + } + source.setCancelHandler { close(descriptor) } + source.resume() + watcher = source + } + + public func stopWatching() { + watcher?.cancel() + watcher = nil + debounce?.cancel() + } + + private func scheduleReload() { + debounce?.cancel() + debounce = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + self?.reloadIfChanged() + } + } +} + +/// Bridges the dictionary to the controller: thread-safe, can be switched off, rebuilt when the dictionary changes. +public final class DictionaryCorrector: TextCorrecting, @unchecked Sendable { + private let lock = NSLock() + private let knowledge: WordKnowledge + private var engine: CorrectionEngine + private var enabled = true + + public init(knowledge: WordKnowledge) { + self.knowledge = knowledge + self.engine = CorrectionEngine(entries: [], knowledge: knowledge) + } + + public func update(entries: [DictionaryEntry]) { + let rebuilt = CorrectionEngine(entries: entries, knowledge: knowledge) + lock.withLock { engine = rebuilt } + } + + public func setEnabled(_ value: Bool) { + lock.withLock { enabled = value } + } + + public func correct(_ text: String) -> CorrectionResult { + let (current, isOn) = lock.withLock { (engine, enabled) } + return isOn ? current.correct(text) : CorrectionResult(text: text, corrections: []) + } +} diff --git a/Sources/SstData/EntryWarnings.swift b/Sources/SstData/EntryWarnings.swift new file mode 100644 index 0000000..b8b559f --- /dev/null +++ b/Sources/SstData/EntryWarnings.swift @@ -0,0 +1,67 @@ +import Foundation + +public struct EntryWarning: Equatable, Sendable { + public enum Level: Int, Comparable, Sendable { + case note = 0, warning, error + public static func < (a: Level, b: Level) -> Bool { a.rawValue < b.rawValue } + } + + public let level: Level + public let message: String + + public init(level: Level, message: String) { + self.level = level + self.message = message + } +} + +/// Flags dictionary entries that are likely to corrupt normal text. +public enum EntryWarnings { + /// - Parameter others: all entries except the one being checked. + public static func check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning] { + let source: String + let isCorrection: Bool + switch kind { + case .term(let term): (source, isCorrection) = (term, false) + case .correction(let heard, _): (source, isCorrection) = (heard, true) + } + + let parts = EntryKey.parts(source) + guard !parts.isEmpty else { return [EntryWarning(level: .error, message: "Leeg: vul een woord in.")] } + + var result: [EntryWarning] = [] + if others.contains(where: { EntryKey.key($0.source) == EntryKey.key(source) }) { + result.append(EntryWarning(level: .error, message: "Dubbele invoer: deze bron staat er al, de eerste telt.")) + } + + if !isCorrection { + if parts.count == 1, knowledge.isWord(source) { + result.append(EntryWarning(level: .note, message: "Gewoon woord: krijgt alleen voorrang bij het model, geen automatische hoofdletterregel.")) + } + return result.sorted { $0.level > $1.level } + } + + if parts.count == 1, knowledge.isWord(parts[0]) { + result.append(EntryWarning(level: .warning, message: "'\(parts[0])' is een gewoon woord en wordt overal in je tekst vervangen.")) + } + if source.filter(\.isLetter).count < 3 { + result.append(EntryWarning(level: .warning, message: "Erg kort (minder dan 3 letters): veel kans op ongewenste vervangingen.")) + } + if parts.count > 1 { + var flagged = false + if let common = parts.first(where: { knowledge.isFunctionWord($0) }) { + result.append(EntryWarning(level: .warning, message: "Bevat het veelvoorkomende woord '\(common)'; dit kan gewone zinnen raken.")) + flagged = true + } + let glued = parts.joined() + if knowledge.isWord(glued) { + result.append(EntryWarning(level: .warning, message: "Aaneengeschreven ('\(glued)') is een bestaand woord en wordt ook vervangen.")) + flagged = true + } + if !flagged, parts.allSatisfy({ knowledge.isWord($0) }) { + result.append(EntryWarning(level: .note, message: "Alle delen zijn gewone woorden: alleen de hele combinatie wordt vervangen.")) + } + } + return result.sorted { $0.level > $1.level } + } +} diff --git a/Sources/SstData/History.swift b/Sources/SstData/History.swift new file mode 100644 index 0000000..eb888e5 --- /dev/null +++ b/Sources/SstData/History.swift @@ -0,0 +1,179 @@ +import Foundation +import SstCore + +public struct HistoryEntry: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var date: Date + public var duration: TimeInterval + public var rawText: String + public var finalText: String + public var corrections: [AppliedCorrection] + + public init( + id: UUID = UUID(), date: Date, duration: TimeInterval, + rawText: String, finalText: String, corrections: [AppliedCorrection] = [] + ) { + self.id = id + self.date = date + self.duration = duration + self.rawText = rawText + self.finalText = finalText + self.corrections = corrections + } + + public init(result: DictationResult) { + self.init( + date: result.date, duration: result.duration, rawText: result.rawText, + finalText: result.finalText, corrections: result.corrections) + } +} + +/// Newest first, stored as one JSON file; saves are debounced. +@MainActor +public final class HistoryStore { + public enum LoadOutcome: Equatable { + case empty + case loaded + case recoveredCorrupt(backup: URL) + /// The file exists but cannot be read; it is left alone and nothing is written over it. + case unreadable(String) + } + + private struct Document: Codable { + var version: Int + var entries: [HistoryEntry] + } + + public private(set) var entries: [HistoryEntry] = [] + public private(set) var lastError: String? + public var onChange: (() -> Void)? + + private let fileURL: URL + private var readOnly = false + private var saveTask: Task? + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + @discardableResult + public func load() -> LoadOutcome { + let data: Data + do { + data = try Data(contentsOf: fileURL) + } catch { + if !FileManager.default.fileExists(atPath: fileURL.path) { + entries = [] + onChange?() + return .empty + } + readOnly = true + lastError = error.localizedDescription + return .unreadable(error.localizedDescription) + } + do { + entries = try Self.makeDecoder().decode(Document.self, from: data).entries + onChange?() + return .loaded + } catch { + let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") + let backup = fileURL.deletingLastPathComponent().appending(path: "history.corrupt-\(stamp).json") + try? FileManager.default.moveItem(at: fileURL, to: backup) + entries = [] + onChange?() + return .recoveredCorrupt(backup: backup) + } + } + + public func add(_ entry: HistoryEntry) { + entries.insert(entry, at: 0) + changed() + } + + public func remove(id: UUID) { + entries.removeAll { $0.id == id } + changed() + } + + public func removeAll() { + entries = [] + changed() + } + + /// Writes immediately (also call this when the app quits). + public func flush() { + saveTask?.cancel() + saveTask = nil + guard !readOnly else { return } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try Self.makeEncoder().encode(Document(version: 1, entries: entries)) + try data.write(to: fileURL, options: .atomic) + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func changed() { + onChange?() + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + self?.flush() + } + } +} + +public enum HistorySearch { + static func fold(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + } + + /// Every query word must occur in the final or the original text (case- and diacritic-insensitive). + public static func filter(_ entries: [HistoryEntry], query: String) -> [HistoryEntry] { + let words = fold(query).split(whereSeparator: \.isWhitespace).map(String.init) + guard !words.isEmpty else { return entries } + return entries.filter { entry in + let haystack = fold(entry.finalText + " " + entry.rawText) + return words.allSatisfy { haystack.contains($0) } + } + } +} + +public struct HistoryDay: Identifiable, Equatable, Sendable { + public let day: Date + public let entries: [HistoryEntry] + public var id: Date { day } +} + +public enum HistoryGrouping { + /// Groups newest-first entries by calendar day, keeping their order. + public static func byDay(_ entries: [HistoryEntry], calendar: Calendar = .current) -> [HistoryDay] { + var days: [HistoryDay] = [] + for entry in entries { + let start = calendar.startOfDay(for: entry.date) + if let last = days.last, last.day == start { + days[days.count - 1] = HistoryDay(day: start, entries: last.entries + [entry]) + } else { + days.append(HistoryDay(day: start, entries: [entry])) + } + } + return days + } +} diff --git a/Sources/SstData/NoteStore.swift b/Sources/SstData/NoteStore.swift new file mode 100644 index 0000000..86f22b5 --- /dev/null +++ b/Sources/SstData/NoteStore.swift @@ -0,0 +1,68 @@ +import Foundation + +/// The text of the Notitie editor, kept in one plain file. Saves are debounced and atomic. +@MainActor +public final class NoteStore: ObservableObject { + @Published public var text: String = "" { + didSet { + guard text != oldValue, !loading else { return } + changed() + } + } + public private(set) var lastError: String? + /// True when the file exists but could not be read: saving would overwrite the user's text, so it is switched off. + public private(set) var isReadOnly = false + + private let fileURL: URL + private var saveTask: Task? + private var loading = false + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + public func load() { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + loading = true + defer { loading = false } + do { + text = try String(contentsOf: fileURL, encoding: .utf8) + } catch { + isReadOnly = true + lastError = error.localizedDescription + } + } + + /// Writes immediately (also call this when the app quits). + public func flush() { + saveTask?.cancel() + saveTask = nil + guard !isReadOnly else { return } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try text.write(to: fileURL, atomically: true, encoding: .utf8) + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func changed() { + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + self?.flush() + } + } +} + +/// Puts a space between dictations: the text before the cursor decides whether one is needed. +public enum DictationSpacing { + public static func prepared(_ text: String, characterBefore: Character?) -> String { + guard let before = characterBefore, !before.isWhitespace, let first = text.first else { return text } + if first.isWhitespace || ".,;:!?)]}\u{201D}\u{2019}".contains(first) { return text } + return " " + text + } +} diff --git a/Sources/SstData/ViewSupport.swift b/Sources/SstData/ViewSupport.swift new file mode 100644 index 0000000..f7cfbd5 --- /dev/null +++ b/Sources/SstData/ViewSupport.swift @@ -0,0 +1,192 @@ +import Combine +import Foundation +import SstCore + +/// "mm:ss.t" for the counter. +public enum StopwatchFormat { + public static func string(_ seconds: Double) -> String { + let tenths = Int(max(0, seconds) * 10) + return String(format: "%02d:%02d.%d", (tenths / 600) % 100, (tenths / 10) % 60, tenths % 10) + } +} + +public struct TextSegment: Equatable, Sendable { + public let text: String + /// Set when this piece was written by the dictionary correction. + public let correction: AppliedCorrection? + + public init(text: String, correction: AppliedCorrection? = nil) { + self.text = text + self.correction = correction + } +} + +public enum CorrectionMarkup { + /// Splits `finalText` into plain and corrected pieces. Offsets are UTF-16; out-of-range or overlapping ones are skipped. + public static func segments(finalText: String, corrections: [AppliedCorrection]) -> [TextSegment] { + let ns = finalText as NSString + guard ns.length > 0 else { return [] } + var result: [TextSegment] = [] + var cursor = 0 + for correction in corrections.sorted(by: { $0.start < $1.start }) { + let end = correction.start + correction.length + guard correction.length > 0, correction.start >= cursor, end <= ns.length else { continue } + if correction.start > cursor { + result.append(TextSegment(text: ns.substring(with: NSRange(location: cursor, length: correction.start - cursor)))) + } + result.append(TextSegment(text: ns.substring(with: NSRange(location: correction.start, length: correction.length)), correction: correction)) + cursor = end + } + if cursor < ns.length { + result.append(TextSegment(text: ns.substring(from: cursor))) + } + return result + } +} + +/// The searchable, day-grouped list behind the transcripts view. +@MainActor +public final class HistoryViewModel: ObservableObject { + private let store: HistoryStore + private let calendar: Calendar + + @Published public var query = "" { + didSet { refresh() } + } + @Published public var selection: UUID? + @Published public private(set) var days: [HistoryDay] = [] + @Published public private(set) var totalCount = 0 + + public init(store: HistoryStore, calendar: Calendar = .current) { + self.store = store + self.calendar = calendar + refresh() + } + + public func refresh() { + let visible = HistorySearch.filter(store.entries, query: query) + days = HistoryGrouping.byDay(visible, calendar: calendar) + totalCount = store.entries.count + if let selection, !visible.contains(where: { $0.id == selection }) { self.selection = nil } + } + + public var selectedEntry: HistoryEntry? { + store.entries.first { $0.id == selection } + } + + public func delete(id: UUID) { + store.remove(id: id) + refresh() + } +} + +/// Dictionary rows with warnings, search, editing and undo. +@MainActor +public final class DictionaryViewModel: ObservableObject { + public struct Row: Identifiable, Equatable { + public let id: UUID + public let entry: DictionaryEntry + public let warnings: [EntryWarning] + + public var worst: EntryWarning.Level? { warnings.map(\.level).max() } + } + + private let store: DictionaryStore + private let knowledge: WordKnowledge + private var undoStack: [DictionaryFile] = [] + + @Published public var query = "" { + didSet { applyFilter() } + } + @Published public private(set) var rows: [Row] = [] + @Published public private(set) var visibleRows: [Row] = [] + @Published public private(set) var canUndo = false + @Published public var errorMessage: String? + + public init(store: DictionaryStore, knowledge: WordKnowledge) { + self.store = store + self.knowledge = knowledge + reload() + } + + /// Rebuilds the rows from the store (call after the store changed). + public func reload() { + let entries = store.entries + rows = entries.enumerated().map { index, entry in + // Only earlier entries count as "already there", so a duplicate is flagged on the later one. + Row(id: entry.id, entry: entry, + warnings: EntryWarnings.check(entry.kind, among: Array(entries[.. [EntryWarning] { + let entries = store.entries + let others: [DictionaryEntry] + if let id, let index = entries.firstIndex(where: { $0.id == id }) { + others = Array(entries[.. 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) { + guard store.entries.contains(where: { $0.id == id }) else { return } + mutate { $0.remove(id: id) } + } + + public func undo() { + guard let snapshot = undoStack.last else { return } + do { + try store.apply { $0 = snapshot } + undoStack.removeLast() + canUndo = !undoStack.isEmpty + } catch { + errorMessage = error.localizedDescription + } + reload() + } + + /// The file was edited elsewhere: older snapshots no longer describe it, so undo is dropped. + public func externalChangeDetected() { + undoStack.removeAll() + canUndo = false + } + + 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() + } +} diff --git a/Sources/SstData/VocabularyBuilder.swift b/Sources/SstData/VocabularyBuilder.swift new file mode 100644 index 0000000..43a2dc0 --- /dev/null +++ b/Sources/SstData/VocabularyBuilder.swift @@ -0,0 +1,19 @@ +import Foundation + +/// The short word list handed to the speech engine as context. +public enum VocabularyBuilder { + /// Terms first (file order), then the unique right-hand sides of correction pairs; at most `maxTerms` words. + public static func build(from entries: [DictionaryEntry], maxTerms: Int = 24) -> [String] { + var out: [String] = [] + var seen = Set() + func add(_ word: String) { + let key = word.lowercased() + guard !word.isEmpty, !seen.contains(key) else { return } + seen.insert(key) + out.append(word) + } + for entry in entries { if case .term(let term) = entry.kind { add(term) } } + for entry in entries { if case .correction(_, let wrote) = entry.kind { add(wrote) } } + return Array(out.prefix(maxTerms)) + } +} diff --git a/Sources/SstData/WordKnowledge.swift b/Sources/SstData/WordKnowledge.swift new file mode 100644 index 0000000..b2e0e62 --- /dev/null +++ b/Sources/SstData/WordKnowledge.swift @@ -0,0 +1,72 @@ +import AppKit +import Foundation +import NaturalLanguage + +public protocol WordKnowledge { + /// A common word in Dutch or English (case-insensitive). Rare words, names and product names are not. + func isWord(_ word: String) -> Bool + /// A very frequent function word ("in", "de", "the", "of"). + func isFunctionWord(_ word: String) -> Bool +} + +/// Splitting and normalising the left-hand side of an entry, shared by the engine and the warnings. +enum EntryKey { + static func parts(_ source: String) -> [String] { + source.split(whereSeparator: { $0.isWhitespace || "-‐‑–".contains($0) }).map(String.init) + } + + /// Spacing, hyphens and case do not matter: "Cloud-Code", "cloud code" and "cloudcode" are the same source. + static func key(_ source: String) -> String { + parts(source).joined().lowercased() + } +} + +/// The most frequent Dutch and English function words. +enum CommonWords { + static let english: Set = [ + "a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at", "be", "because", "been", "but", + "by", "can", "could", "did", "do", "does", "for", "from", "had", "has", "have", "he", "her", "here", "him", + "his", "how", "i", "if", "in", "into", "is", "it", "its", "just", "me", "more", "most", "my", "no", "not", + "now", "of", "on", "one", "only", "or", "other", "our", "out", "over", "she", "so", "some", "such", "than", + "that", "the", "their", "them", "then", "there", "these", "they", "this", "those", "to", "up", "us", "very", + "was", "we", "were", "what", "when", "which", "who", "will", "with", "would", "you", "your", + ] + static let dutch: Set = [ + "aan", "al", "als", "ben", "bij", "dan", "dat", "de", "den", "der", "deze", "die", "dit", "doen", "door", + "dus", "een", "en", "er", "ga", "had", "heb", "heeft", "hem", "het", "hier", "hij", "hoe", "ik", "in", "is", + "ja", "je", "jij", "kan", "kon", "maar", "me", "mij", "mijn", "moet", "na", "naar", "niet", "nog", "nu", + "of", "om", "ons", "onze", "ook", "op", "over", "te", "ten", "ter", "toch", "toen", "tot", "u", "uit", "van", + "veel", "voor", "was", "wat", "we", "wel", "werd", "wij", "wil", "worden", "wordt", "zal", "ze", "zei", + "zich", "zij", "zijn", "zo", "zou", + ] + static let all: Set = english.union(dutch) +} + +/// English: the on-device word list of `NaturalLanguage` (common words only, so "cloud" and "reef" are in, "anthropic" and "vercel" are not). +/// Dutch has no such list, so the spell checker decides. Function words come from the built-in list. +public final class SystemWordKnowledge: WordKnowledge, @unchecked Sendable { + private let englishWords = NLEmbedding.wordEmbedding(for: .english) + + public init() {} + + public func isWord(_ word: String) -> Bool { + let lowered = word.lowercased() + guard !lowered.isEmpty else { return false } + if let englishWords { + if englishWords.contains(lowered) { return true } + } else if spellChecks(lowered, language: "en") { + return true + } + return spellChecks(lowered, language: "nl") + } + + public func isFunctionWord(_ word: String) -> Bool { + CommonWords.all.contains(word.lowercased()) + } + + private func spellChecks(_ word: String, language: String) -> Bool { + NSSpellChecker.shared.checkSpelling( + of: word, startingAt: 0, language: language, wrap: false, + inSpellDocumentWithTag: 0, wordCount: nil).location == NSNotFound + } +} diff --git a/Sources/SstGallery/GalleryApp.swift b/Sources/SstGallery/GalleryApp.swift new file mode 100644 index 0000000..cd9f0da --- /dev/null +++ b/Sources/SstGallery/GalleryApp.swift @@ -0,0 +1,138 @@ +import SwiftUI +import SstUI + +@main +struct GalleryApp: App { + init() { + // `SstGallery --snapshot out.png` renders both finishes to a PNG and exits (for checking without a screen grab). + let args = CommandLine.arguments + guard let flag = args.firstIndex(of: "--snapshot"), args.count > flag + 1 else { return } + let path = args[flag + 1] + Task { @MainActor in + let view = HStack(spacing: 0) { + Finish(scheme: .light, title: "Aluminium", frozen: true) + Finish(scheme: .dark, title: "Graphite", frozen: true) + } + .frame(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + if let tiff = renderer.nsImage?.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: path)) + } + exit(0) + } + } + + var body: some Scene { + WindowGroup("Sst componenten") { + HStack(spacing: 0) { + Finish(scheme: .light, title: "Aluminium") + Finish(scheme: .dark, title: "Graphite") + } + .frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight) + } + .defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) + } +} + +/// Fake speech level so the meter, LEDs and counter can be judged in motion. +private func simulatedDbfs(at t: Double) -> Double { + if t.truncatingRemainder(dividingBy: 9) > 6.2 { return -90 } + let syllable = floor(t * 4.2) + let r = abs(sin(syllable * 12.9898) * 43758.5453).truncatingRemainder(dividingBy: 1) + return r > 0.12 ? -38 + r * 22 : -90 +} + +struct Finish: View { + let scheme: ColorScheme + let title: String + var frozen = false + @State private var recording = true + @State private var started = Date() + @State private var query = "" + + var body: some View { + Group { + if frozen { + content(elapsed: 12.3, dbfs: -26) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in + let elapsed = timeline.date.timeIntervalSince(started) + let dbfs = recording ? simulatedDbfs(at: elapsed) : -120 + content(elapsed: recording ? elapsed : 0, dbfs: dbfs) + } + } + } + .environment(\.colorScheme, scheme) + } + + private func content(elapsed: Double, dbfs: Double) -> some View { + ZStack { + BrushedBackground() + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel("Afwerking · \(title)") + VStack(alignment: .leading, spacing: Layout.panelGap) { + HStack(alignment: .top, spacing: Layout.panelGap) { + VUMeterView(levelDbfs: dbfs, isActive: recording, initialFraction: frozen ? VUScale.fraction(forDbfs: dbfs) : 0) + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel("Tijd", style: Typography.labelSmall) + SegmentCounterView(Self.clock(elapsed)) + SilkLabel("Niveau", style: Typography.labelSmall) + LEDBarView(fraction: VUScale.fraction(forDbfs: dbfs)) + } + } + SeamDivider() + HStack(spacing: Layout.panelGap) { + Button { recording.toggle(); started = Date() } label: { + HStack { LampView(isOn: recording); Text("Rec") } + } + .buttonStyle(KeyButtonStyle(kind: .record, latched: recording)) + Button("Stop") { recording = false }.buttonStyle(KeyButtonStyle()) + Button("Kopieer") {}.buttonStyle(KeyButtonStyle()) + Button("Uit") {}.buttonStyle(KeyButtonStyle()).disabled(true) + Spacer() + LampView(isOn: recording, large: true) + LampView(isOn: true, tint: .amber) + } + } + .padding(Layout.panelPadding) + .background(PanelBackground()) + + VStack(alignment: .leading, spacing: 0) { + row("14:02:11", "Ik wil de meeting verplaatsen naar tomorrow morning.", selected: false) + row("14:03:40", "Hij stuurt het door naar Claude Code vandaag.", selected: true) + } + .background(PaperBackground()) + + HStack { + SilkLabel("Zoek") + Group { + if frozen { + Text("claude").textStyle(Typography.body).frame(maxWidth: .infinity, alignment: .leading) + } else { + TextField("", text: $query).textFieldStyle(.plain).textStyle(Typography.body) + } + } + .padding(Space.s3).background(WellBackground()) + } + } + .padding(Layout.panelPadding) + } + } + + private func row(_ time: String, _ text: String, selected: Bool) -> some View { + HStack(alignment: .firstTextBaseline, spacing: Space.s4) { + Text(time).textStyle(Typography.counterSmall).foregroundStyle(Palette.inkSecondary) + Text(text).textStyle(Typography.reading).foregroundStyle(Palette.inkPrimary) + Spacer() + } + .padding(.horizontal, Space.s4).padding(.vertical, Space.s3) + .frame(minHeight: Size.rowHeight) + .background(selected ? Palette.surfaceSelection : Color.clear) + } + + private static func clock(_ seconds: Double) -> String { + let total = Int(seconds * 10) + return String(format: "%02d:%02d.%d", (total / 600) % 100, (total / 10) % 60, total % 10) + } +} diff --git a/Sources/SstIcon/IconMaker.swift b/Sources/SstIcon/IconMaker.swift new file mode 100644 index 0000000..1ed1852 --- /dev/null +++ b/Sources/SstIcon/IconMaker.swift @@ -0,0 +1,67 @@ +import AppKit +import SstUI +import SwiftUI + +/// The app icon: a brushed-aluminium plate with a VU meter and the red record lamp, drawn with the design tokens. +/// Canvas is 256 pt; the macOS icon grid puts the plate at 206 pt with a 46 pt corner radius. +struct IconView: View { + static let canvas: CGFloat = 256 + private let plate: CGFloat = 206 + private let corner: CGFloat = 46 + + var body: some View { + ZStack { + BrushedBackground() + .frame(width: plate, height: plate) + .clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: corner, style: .continuous) + .strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline)) + .shadow(color: .black.opacity(Opacity.mark), radius: Space.s3, y: Space.s2) + + VStack(spacing: Space.s5) { + VUMeterView(levelDbfs: -26, isActive: true, brand: "", initialFraction: 0.62) + .frame(width: 150) + HStack(spacing: Space.s4) { + LampView(isOn: true, large: true).scaleEffect(1.5) + SilkLabel("REC", style: Typography.heading) + } + } + .offset(y: Space.s2) + } + .frame(width: Self.canvas, height: Self.canvas) + .environment(\.colorScheme, .light) + } +} + +@main +@MainActor +struct IconMaker { + static func main() { + guard CommandLine.arguments.count > 1 else { + print("usage: SstIcon ") + exit(2) + } + let folder = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + // (file name, pixel size) as iconutil expects them + let outputs: [(String, CGFloat)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), + ] + for (name, pixels) in outputs { + let renderer = ImageRenderer(content: IconView()) + renderer.scale = pixels / IconView.canvas + guard let tiff = renderer.nsImage?.tiffRepresentation, + let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) + else { + print("kon \(name) niet renderen") + exit(1) + } + try? png.write(to: folder.appending(path: "\(name).png")) + } + print("iconset geschreven naar \(folder.path)") + } +} diff --git a/Sources/SstProbe/RecordProbe.swift b/Sources/SstProbe/RecordProbe.swift index e8e3c3f..0566a31 100644 --- a/Sources/SstProbe/RecordProbe.swift +++ b/Sources/SstProbe/RecordProbe.swift @@ -2,12 +2,13 @@ import AVFoundation import SstCore /// Records `seconds` of microphone audio; returns nil if access is denied or recording fails. -func recordSamples(seconds: Double) async -> [Float]? { +func recordSamples(seconds: Double, onLevel: ((Float) -> Void)? = nil) async -> [Float]? { guard await AVCaptureDevice.requestAccess(for: .audio) else { print("Microfoon-toegang geweigerd (Systeeminstellingen → Privacy → Microfoon).") return nil } let recorder = AudioRecorder() + recorder.onLevel = onLevel do { try recorder.start() } catch { print("start mislukt:", error.localizedDescription) return nil @@ -18,7 +19,19 @@ func recordSamples(seconds: Double) async -> [Float]? { } func runRecordProbe() async { - guard let samples = await recordSamples(seconds: 3) else { return } + final class Levels: @unchecked Sendable { + let lock = NSLock() + var values: [Float] = [] + func add(_ v: Float) { lock.withLock { values.append(v) } } + } + let levels = Levels() + guard let samples = await recordSamples(seconds: 3, onLevel: { levels.add($0) }) else { return } let peak = samples.map { abs($0) }.max() ?? 0 print("samples: \(samples.count) (verwacht ≈ 48000), piek: \(peak)") + let values = levels.lock.withLock { levels.values } + if let lo = values.min(), let hi = values.max() { + print(String(format: "niveau-meldingen: %d (≈ %.0f per s), bereik %.1f … %.1f dBFS", values.count, Double(values.count) / 3, lo, hi)) + } else { + print("geen niveau-meldingen ontvangen") + } } diff --git a/Sources/SstProbe/TranscribeProbe.swift b/Sources/SstProbe/TranscribeProbe.swift index f678751..914862c 100644 --- a/Sources/SstProbe/TranscribeProbe.swift +++ b/Sources/SstProbe/TranscribeProbe.swift @@ -25,8 +25,11 @@ func runTranscribeProbe() async { } print("RMS per seconde:", perSecond.joined(separator: " ")) do { + let vocabulary = (ProcessInfo.processInfo.environment["SST_VOCAB"] ?? "") + .split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + if !vocabulary.isEmpty { print("voorrang: \(vocabulary.joined(separator: ", "))") } let started = Date.now - let text = try await transcriber.transcribe(samples) + let text = try await transcriber.transcribe(samples, vocabulary: vocabulary) let elapsed = Date.now.timeIntervalSince(started) print(String(format: "(%.2f s) ", elapsed) + text) } catch { diff --git a/Sources/SstUI/Controls.swift b/Sources/SstUI/Controls.swift new file mode 100644 index 0000000..622914d --- /dev/null +++ b/Sources/SstUI/Controls.swift @@ -0,0 +1,143 @@ +import SwiftUI + +/// A physical key. `latched` keeps it looking pressed (REC while recording). +public struct KeyButtonStyle: ButtonStyle { + public enum Kind { case standard, record } + + private let kind: Kind + private let latched: Bool + + public init(kind: Kind = .standard, latched: Bool = false) { + self.kind = kind + self.latched = latched + } + + public func makeBody(configuration: Configuration) -> some View { + KeyBody(configuration: configuration, kind: kind, latched: latched) + } + + private struct KeyBody: View { + let configuration: Configuration + let kind: Kind + let latched: Bool + @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var pressed: Bool { configuration.isPressed || latched } + + private var face: AnyShapeStyle { + switch (kind, pressed) { + case (.standard, false): + AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom)) + case (.standard, true): + AnyShapeStyle(Palette.surfaceKeyPressed) + case (.record, false): + AnyShapeStyle(Palette.accentRecord) + case (.record, true): + AnyShapeStyle(Palette.accentRecord.opacity(Opacity.pressedRecord)) + } + } + + var body: some View { + configuration.label + .textStyle(Typography.control) + .foregroundStyle(kind == .record ? Palette.inkInverse : Palette.inkPrimary) + .padding(.horizontal, Space.s4) + .frame( + minWidth: kind == .record ? Size.keyRecWidth : Size.keyMinWidth, + minHeight: kind == .record ? Size.keyRecHeight : Size.controlHeight) + .background(PhysicalSurface( + fill: face, cornerRadius: Radius.sm, + shadows: pressed ? Shadows.keyPressed : Shadows.keyRaised)) + .offset(y: pressed ? Shadows.keyPressedOffset : 0) + .opacity(isEnabled ? 1 : Opacity.disabled) + .animation( + reduceMotion ? nil : .timingCurve( + Motion.easingMechanical.0, Motion.easingMechanical.1, Motion.easingMechanical.2, Motion.easingMechanical.3, + duration: pressed ? Motion.keyPress : Motion.keyRelease), + value: pressed) + } + } +} + +/// A round indicator lamp. The record lamp has a small glow; text never does. +public struct LampView: View { + public enum Tint { case record, amber } + + private let isOn: Bool + private let tint: Tint + private let large: Bool + @Environment(\.colorScheme) private var scheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + public init(isOn: Bool, tint: Tint = .record, large: Bool = false) { + self.isOn = isOn + self.tint = tint + self.large = large + } + + private var color: Color { + switch (tint, isOn) { + case (.record, true): Palette.accentRecord + case (.record, false): Palette.accentRecordUnlit + case (.amber, true): Palette.levelAmber + case (.amber, false): Palette.levelAmberUnlit + } + } + + public var body: some View { + let diameter = large ? Size.lampLarge : Size.lampDiameter + let glow = Shadows.lampOn[0] + Circle() + .fill(color) + .overlay( + Circle().fill(RadialGradient( + colors: [Color.white.opacity(isOn ? Opacity.specularOn : Opacity.specularOff), .clear], + center: UnitPoint(x: 0.35, y: 0.3), startRadius: 0, endRadius: diameter * 0.45))) + .overlay(Circle().strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline)) + .frame(width: diameter, height: diameter) + .shadow( + color: isOn && tint == .record ? glow.color(scheme) : .clear, + radius: glow.blur / 2) + .animation( + reduceMotion ? nil : .easeOut(duration: isOn ? Motion.lampOn : Motion.lampOff), + value: isOn) + .accessibilityHidden(true) + } +} + +/// A two-position rocker switch: a key cap sliding in a recessed track. +public struct RockerToggleStyle: ToggleStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + HStack(spacing: Space.s4) { + configuration.label + Button { + configuration.isOn.toggle() + } label: { + RockerTrack(isOn: configuration.isOn) + } + .buttonStyle(.plain) + .accessibilityValue(configuration.isOn ? "aan" : "uit") + } + } + + private struct RockerTrack: View { + let isOn: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack(alignment: isOn ? .trailing : .leading) { + WellBackground() + PhysicalSurface( + fill: AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom)), + cornerRadius: Radius.sm, shadows: Shadows.keyRaised) + .frame(width: Size.switchWidth / 2, height: Size.switchHeight - Space.s2) + .padding(Space.s1) + } + .frame(width: Size.switchWidth, height: Size.switchHeight) + .animation(reduceMotion ? nil : .easeOut(duration: Motion.durationFast), value: isOn) + } + } +} diff --git a/Sources/SstUI/CounterAndLEDs.swift b/Sources/SstUI/CounterAndLEDs.swift new file mode 100644 index 0000000..926fa5b --- /dev/null +++ b/Sources/SstUI/CounterAndLEDs.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// Seven-segment counter on dark glass. Digits, ":" and "." are drawn; unlit segments stay faintly visible. +public struct SegmentCounterView: View { + private let text: String + + public init(_ text: String) { + self.text = text + } + + private var contentWidth: CGFloat { + text.reduce(0) { total, character in + total + (character.isNumber ? Segment.advance : Segment.colonWidth) + } + } + + public var body: some View { + Canvas { context, _ in + var x: CGFloat = 0 + for character in text { + if character.isNumber { + var origin = context + origin.translateBy(x: x, y: 0) + let lit = SegmentGlyph.lit(for: character) + for id in SegmentID.allCases { + let polygon = SegmentGlyph.polygon(id) + var path = Path() + path.addLines(polygon) + path.closeSubpath() + origin.fill(path, with: .color(lit.contains(id) ? Palette.levelAmber : Palette.displaySegmentUnlit)) + } + x += Segment.advance + } else { + let dot = Segment.thickness + let inset = (Segment.colonWidth - dot) / 2 + let ys: [CGFloat] = character == ":" + ? [Segment.digitHeight * 0.28, Segment.digitHeight * 0.64] + : [Segment.digitHeight - dot] + for y in ys { + context.fill(Path(CGRect(x: x + inset, y: y, width: dot, height: dot)), with: .color(Palette.levelAmber)) + } + x += Segment.colonWidth + } + } + } + .frame(width: contentWidth, height: Segment.digitHeight) + .padding(.horizontal, Space.s4) + .padding(.vertical, Space.s3) + .background(DisplayBackground()) + .accessibilityElement() + .accessibilityLabel("Tijd") + .accessibilityValue(text) + } +} + +/// Twelve-segment level bar: green, amber, then one red segment. +public struct LEDBarView: View { + private let fraction: Double + + public init(fraction: Double) { + self.fraction = fraction + } + + public var body: some View { + let lit = LEDBar.litCount(forFraction: fraction) + HStack(spacing: MeterTokens.ledGap) { + ForEach(0.. Color { + switch LEDBar.tone(forSegment: index) { + case .green: lit ? Palette.levelGreen : Palette.levelGreenUnlit + case .amber: lit ? Palette.levelAmber : Palette.levelAmberUnlit + case .red: lit ? Palette.accentRecord : Palette.accentRecordUnlit + } + } +} diff --git a/Sources/SstUI/DesignTokens.swift b/Sources/SstUI/DesignTokens.swift new file mode 100644 index 0000000..e8a4e1d --- /dev/null +++ b/Sources/SstUI/DesignTokens.swift @@ -0,0 +1,316 @@ +// GENERATED by Design/build_tokens.py from Design/tokens.json. Do not edit by hand. +import SwiftUI + +public enum Palette { + /// Venster-achtergrond; het 'huis' van het apparaat + public static let surfaceChassis = Color.dynamic(light: 0xCBC8C0, dark: 0x1C1B19) + /// Geborsteld metaal, bovenkant van de verloop + public static let surfaceMetalHigh = Color.dynamic(light: 0xE3E1DB, dark: 0x3B3A36) + /// Geborsteld metaal, onderkant van de verloop + public static let surfaceMetalLow = Color.dynamic(light: 0xBDBAB2, dark: 0x262522) + /// Frontplaat van mat plastic; zijbalk en bedieningspanelen + public static let surfacePanel = Color.dynamic(light: 0xE2DFD5, dark: 0x2B2A27) + /// Verzonken vlak: invoervelden, display-vensters + public static let surfaceWell = Color.dynamic(light: 0xB4B1A8, dark: 0x131211) + /// Cassette-label crème: lijsten met transcripties en woordenboek + public static let surfacePaper = Color.dynamic(light: 0xEEE9D9, dark: 0x232220) + /// Toetsdop, bovenkant + public static let surfaceKeyHigh = Color.dynamic(light: 0xE6E3DA, dark: 0x44433F) + /// Toetsdop, onderkant + public static let surfaceKeyLow = Color.dynamic(light: 0xCBC8BF, dark: 0x33322F) + /// Ingedrukte toets + public static let surfaceKeyPressed = Color.dynamic(light: 0xBEBBB2, dark: 0x2A2927) + /// Wijzerplaat van de VU-meter (blijft crème in beide afwerkingen) + public static let surfaceMeterFace = Color.dynamic(light: 0xEFE6C8, dark: 0xD8CEA6) + /// Geselecteerde rij in een lijst + public static let surfaceSelection = Color.dynamic(light: 0xD4CDB2, dark: 0x3A3828) + /// Displayglas van de teller; donker in beide afwerkingen + public static let surfaceDisplay = Color.dynamic(light: 0x1B1A18, dark: 0x0E0D0C) + /// Gedoofd segment op het displayglas + public static let displaySegmentUnlit = Color.dynamic(light: 0x3A3320, dark: 0x2A2415) + /// Hoofdtekst en wijzer + public static let inkPrimary = Color.dynamic(light: 0x1E1D1B, dark: 0xECE8DC) + /// Secundaire tekst + public static let inkSecondary = Color.dynamic(light: 0x403D38, dark: 0xC4BFB2) + /// Zeefdruk-labels en hulptekst + public static let inkTertiary = Color.dynamic(light: 0x4D4A44, dark: 0xABA79B) + /// Uitgeschakeld; nooit voor essentiële tekst + public static let inkDisabled = Color.dynamic(light: 0x8F8B82, dark: 0x6B685F) + /// Tekst op rode of donkere vulling; crème in beide afwerkingen + public static let inkInverse = Color.dynamic(light: 0xF4EFE3, dark: 0xF4EFE3) + /// Schaal, cijfers en wijzer op de wijzerplaat; vast donker in beide afwerkingen + public static let inkOnMeter = Color.dynamic(light: 0x1E1D1B, dark: 0x1E1D1B) + /// ENIGE accent: opnamelampje, REC-toets, piek-LED. Nooit voor lopende tekst + public static let accentRecord = Color.dynamic(light: 0xC8281E, dark: 0xCC2A20) + /// Lampje uit + public static let accentRecordUnlit = Color.dynamic(light: 0x7A2E28, dark: 0x4A1F1B) + /// Niveau-segment, brandend + public static let levelGreen = Color.dynamic(light: 0x5E9E48, dark: 0x6DB556) + /// Niveau-segment, waarschuwing, gecorrigeerd woord + public static let levelAmber = Color.dynamic(light: 0xD9A023, dark: 0xE5B032) + /// Groen segment, uit + public static let levelGreenUnlit = Color.dynamic(light: 0xA8B79C, dark: 0x26331F) + /// Amber segment, uit + public static let levelAmberUnlit = Color.dynamic(light: 0xCDBA8A, dark: 0x3D3418) + /// Naad tussen panelen, donkere helft + public static let lineSeamDark = Color.dynamic(light: 0x8C8981, dark: 0x0A0A09) + /// Naad tussen panelen, lichte helft + public static let lineSeamLight = Color.dynamic(light: 0xF3F1EB, dark: 0x45433F) + /// Fijne scheidingslijn, zeefdruk-kader + public static let lineHairline = Color.dynamic(light: 0xA9A69D, dark: 0x3A3834) + /// Toetsenbord-focus + public static let lineFocus = Color.dynamic(light: 0x1E1D1B, dark: 0xECE8DC) +} + +public enum Opacity { + /// Amber markering achter een gecorrigeerd woord + public static let mark: Double = 0.3 + /// Uitgeschakelde bediening + public static let disabled: Double = 0.45 + /// Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie + public static let unlit: Double = 1.0 + /// Ingedrukte REC-toets: het rood dooft licht + public static let pressedRecord: Double = 0.85 + /// Lichtvlekje op een brandend lampje + public static let specularOn: Double = 0.6 + /// Lichtvlekje op een gedoofd lampje + public static let specularOff: Double = 0.3 + /// Kleine opschriften op de wijzerplaat + public static let caption: Double = 0.75 +} + +public enum FontFamily: String { + case chrome = "Helvetica Neue" + case text = "SF Pro Text" + case mono = "SF Mono" +} + +public enum Typography { + /// Zeefdruk-label + public static let label = TextStyle(family: .chrome, size: 10, weight: 500, tracking: 0.09, line: 12, caps: true, tabular: false) + /// Schaalmarkeringen en eenheden + public static let labelSmall = TextStyle(family: .chrome, size: 9, weight: 500, tracking: 0.1, line: 11, caps: true, tabular: false) + /// Opschrift op toetsen + public static let control = TextStyle(family: .chrome, size: 11, weight: 500, tracking: 0.06, line: 14, caps: true, tabular: false) + /// Paneelkop + public static let heading = TextStyle(family: .chrome, size: 12, weight: 500, tracking: 0.12, line: 16, caps: true, tabular: false) + /// Standaardtekst + public static let body = TextStyle(family: .text, size: 13, weight: 400, tracking: 0, line: 18, caps: false, tabular: false) + /// Transcript + public static let reading = TextStyle(family: .text, size: 15, weight: 400, tracking: 0, line: 22, caps: false, tabular: false) + /// Bijschriften + public static let meta = TextStyle(family: .text, size: 11, weight: 400, tracking: 0, line: 14, caps: false, tabular: false) + /// Teller + public static let counter = TextStyle(family: .mono, size: 22, weight: 500, tracking: 0, line: 26, caps: false, tabular: true) + /// Tijdstip in een rij + public static let counterSmall = TextStyle(family: .mono, size: 12, weight: 500, tracking: 0, line: 16, caps: false, tabular: true) + /// Woordenboekregels + public static let code = TextStyle(family: .mono, size: 12, weight: 400, tracking: 0, line: 18, caps: false, tabular: false) +} + +public enum Segment { + /// Hoogte van een getekend segmentcijfer + public static let digitHeight: CGFloat = 36 + /// Breedte van een segmentcijfer + public static let digitWidth: CGFloat = 20 + /// Dikte van één segment + public static let thickness: CGFloat = 4 + /// Ruimte tussen segmenten + public static let gap: CGFloat = 1 + /// Schuinte in graden; 0 = rechtop + public static let slant: CGFloat = 0 + /// Afstand van cijfer tot cijfer + public static let advance: CGFloat = 26 + /// Breedte van een dubbele punt of punt + public static let colonWidth: CGFloat = 10 +} + +public enum Space { + public static let s0: CGFloat = 0 + public static let s1: CGFloat = 2 + public static let s2: CGFloat = 4 + public static let s3: CGFloat = 8 + public static let s4: CGFloat = 12 + public static let s5: CGFloat = 16 + public static let s6: CGFloat = 24 + public static let s7: CGFloat = 32 + public static let s8: CGFloat = 48 +} + +public enum Layout { + /// Minimale vensterbreedte + public static let windowMinWidth: CGFloat = 800 + /// Minimale vensterhoogte + public static let windowMinHeight: CGFloat = 560 + /// Standaard vensterbreedte + public static let windowDefaultWidth: CGFloat = 1080 + /// Standaard vensterhoogte + public static let windowDefaultHeight: CGFloat = 700 + /// Breedte zijbalk + public static let sidebarWidth: CGFloat = 232 + /// Breedte instellingenvenster + public static let settingsWidth: CGFloat = 560 + /// Binnenmarge van een paneel (= space.5) + public static let panelPadding: CGFloat = 16 + /// Afstand tussen panelen (= space.4) + public static let panelGap: CGFloat = 12 +} + +public enum Size { + /// Standaardhoogte van toets of veld + public static let controlHeight: CGFloat = 28 + /// Minimale breedte van een toets + public static let keyMinWidth: CGFloat = 56 + /// REC-toets, breedte + public static let keyRecWidth: CGFloat = 64 + /// REC-toets, hoogte + public static let keyRecHeight: CGFloat = 44 + /// Standaard lampje + public static let lampDiameter: CGFloat = 10 + /// Opnamelampje + public static let lampLarge: CGFloat = 14 + /// Tuimelschakelaar, breedte + public static let switchWidth: CGFloat = 40 + /// Tuimelschakelaar, hoogte + public static let switchHeight: CGFloat = 22 + /// Rij in transcriptielijst + public static let rowHeight: CGFloat = 40 + /// Klein pictogram + public static let iconSmall: CGFloat = 16 + /// Normaal pictogram + public static let iconMedium: CGFloat = 20 + /// Minimaal klikdoel + public static let hitMin: CGFloat = 24 +} + +public enum Radius { + /// Panelen en velden: scherpe randen + public static let none: CGFloat = 0 + /// Toetsranden: bijna scherp + public static let sharp: CGFloat = 1 + /// Toetsen, velden + public static let sm: CGFloat = 2 + /// Grote panelen + public static let md: CGFloat = 4 + /// Rand van de VU-meter + public static let bezel: CGFloat = 6 + /// Lampjes: rond + public static let lamp: CGFloat = 999 +} + +public enum Border { + /// Fijne lijn + public static let hairline: CGFloat = 1 + /// Naad: 1 donkere lijn boven, 1 lichte lijn eronder, leest als gegraveerde groef + public static let seam: CGFloat = 2 + /// Rand om de VU-meter + public static let bezel: CGFloat = 3 + /// Focusring, 2 pt offset + public static let focus: CGFloat = 2 +} + +public enum Shadows { + /// Toets in rust: lichtvang boven, donkere onderrand, valschaduw + public static let keyRaised: [ShadowLayer] = [ + ShadowLayer(inset: true, x: 0, y: 1, blur: 0, spread: 0, hex: 0xFFFFFF, alphaLight: 0.65, alphaDark: 0.1), + ShadowLayer(inset: true, x: 0, y: -1, blur: 0, spread: 0, hex: 0x000000, alphaLight: 0.25, alphaDark: 0.55), + ShadowLayer(inset: false, x: 0, y: 2, blur: 2, spread: 0, hex: 0x000000, alphaLight: 0.35, alphaDark: 0.6), + ] + /// Ingedrukte toets: dop 1 pt omlaag, geen valschaduw + public static let keyPressed: [ShadowLayer] = [ + ShadowLayer(inset: true, x: 0, y: 2, blur: 4, spread: 0, hex: 0x000000, alphaLight: 0.5, alphaDark: 0.7), + ShadowLayer(inset: true, x: 0, y: 0, blur: 0, spread: 1, hex: 0x000000, alphaLight: 0.35, alphaDark: 0.6), + ] + public static let keyPressedOffset: CGFloat = 1 + /// Verzonken vlak met lichtvang op de onderlip + public static let well: [ShadowLayer] = [ + ShadowLayer(inset: true, x: 0, y: 1, blur: 3, spread: 0, hex: 0x000000, alphaLight: 0.4, alphaDark: 0.7), + ShadowLayer(inset: false, x: 0, y: 1, blur: 0, spread: 0, hex: 0xFFFFFF, alphaLight: 0.55, alphaDark: 0.07), + ] + /// Frontplaat boven het chassis + public static let panel: [ShadowLayer] = [ + ShadowLayer(inset: true, x: 0, y: 1, blur: 0, spread: 0, hex: 0xFFFFFF, alphaLight: 0.6, alphaDark: 0.08), + ShadowLayer(inset: false, x: 0, y: 3, blur: 8, spread: 0, hex: 0x000000, alphaLight: 0.22, alphaDark: 0.55), + ] + /// Brandend lampje: kleine gloed, alleen voor lampjes en nooit voor tekst + public static let lampOn: [ShadowLayer] = [ + ShadowLayer(inset: false, x: 0, y: 0, blur: 6, spread: 1, hex: 0xCC2A20, alphaLight: 0.45, alphaDark: 0.55), + ShadowLayer(inset: true, x: 0, y: -1, blur: 1, spread: 0, hex: 0x000000, alphaLight: 0.35, alphaDark: 0.45), + ] +} + +public enum Texture { + public static let brushedGrainLight: Double = 0.035 + public static let brushedGrainDark: Double = 0.05 +} + +public enum MeterTokens { + /// 0 VU = -20 dBFS (RMS) + public static let vuReferenceDbfs: Double = -20 + /// Onderkant van de schaal in VU + public static let vuMin: Double = -20 + /// Bovenkant van de schaal in VU; vanaf 0 VU rood + public static let vuMax: Double = 3 + /// Hoek tussen minimale en maximale stand + public static let vuArcDegrees: Double = 90 + /// Breedte van de meter + public static let vuWidth: Double = 260 + /// Hoogte van de meter + public static let vuHeight: Double = 150 + /// Dikte van de wijzer + public static let vuNeedleWidth: Double = 1.5 + /// Genummerde schaalmarkeringen + public static let vuTicks: [Int] = [-20, -10, -7, -5, -3, 0, 3] + /// Draaipunt van de wijzer, onder de onderrand van de wijzerplaat + public static let vuPivotOffset: Double = 22 + /// Straal van de schaalboog vanaf het draaipunt + public static let vuScaleRadius: Double = 142 + /// Lengte van de wijzer + public static let vuNeedleLength: Double = 146 + /// Lengte van een genummerde streep + public static let vuTickMajor: Double = 11 + /// Lengte van een gewone streep + public static let vuTickMinor: Double = 6 + /// Afstand van de schaalboog tot de cijfers + public static let vuLabelInset: Double = 24 + /// Dikte van de rode band vanaf 0 VU + public static let vuRedBandWidth: Double = 5 + /// Aantal segmenten: 8 groen, 3 amber, 1 rood + public static let ledSegments: Int = 12 + /// Segment in pt (breedte x hoogte) + public static let ledSegmentWidth: CGFloat = 6 + public static let ledSegmentHeight: CGFloat = 14 + /// Ruimte tussen segmenten + public static let ledGap: Double = 2 + /// Piek-LED blijft zo lang branden + public static let peakHoldSeconds: Double = 1.0 +} + +public enum Motion { + /// Geen overgang + public static let durationInstant: Double = 0.0 + /// Toets in/uit + public static let durationFast: Double = 0.06 + /// Standaardovergang + public static let durationBase: Double = 0.12 + /// Paneel of venster + public static let durationSlow: Double = 0.22 + /// Toetsen: direct, zonder veer + public static let easingMechanical: (Double, Double, Double, Double) = (0.2, 0, 0, 1) + /// Overige overgangen + public static let easingStandard: (Double, Double, Double, Double) = (0.4, 0, 0.2, 1) + /// Toets indrukken + public static let keyPress: Double = 0.05 + /// Toets loslaten + public static let keyRelease: Double = 0.09 + /// Lampje aan + public static let lampOn: Double = 0.03 + /// Lampje uit: dooft langzaam, als een gloeidraad + public static let lampOff: Double = 0.2 + /// VU-ballistiek: 300 ms om de stand te bereiken + public static let needleResponse: Double = 0.3 + /// Veerdemping; geeft ongeveer 1,5 % overschot + public static let needleDamping: Double = 0.8 + /// Verversing van de wijzer + public static let needleUpdateHz: Double = 60 +} diff --git a/Sources/SstUI/DynamicColor.swift b/Sources/SstUI/DynamicColor.swift new file mode 100644 index 0000000..c31a376 --- /dev/null +++ b/Sources/SstUI/DynamicColor.swift @@ -0,0 +1,26 @@ +import AppKit +import SwiftUI + +extension NSColor { + convenience init(hex: UInt32) { + self.init( + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1) + } +} + +extension Color { + /// A colour that switches between the Aluminium (light) and Graphite (dark) value with the appearance. + static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? NSColor(hex: dark) : NSColor(hex: light) + }) + } + + /// The same colour in both finishes (shadow bases). + static func fixed(hex: UInt32) -> Color { + Color(nsColor: NSColor(hex: hex)) + } +} diff --git a/Sources/SstUI/Instruments.swift b/Sources/SstUI/Instruments.swift new file mode 100644 index 0000000..c77fee9 --- /dev/null +++ b/Sources/SstUI/Instruments.swift @@ -0,0 +1,118 @@ +import CoreGraphics +import Foundation + +/// dBFS → VU → needle position and angle, all from the meter tokens. +public enum VUScale { + public static func vu(fromDbfs dbfs: Double) -> Double { + dbfs - MeterTokens.vuReferenceDbfs + } + + /// 0 = bottom of the scale, 1 = top. The needle may swing slightly past the end (up to 1.03). + public static func fraction(forVU vu: Double) -> Double { + min(1.03, max(0, (vu - MeterTokens.vuMin) / (MeterTokens.vuMax - MeterTokens.vuMin))) + } + + public static func fraction(forDbfs dbfs: Double) -> Double { + fraction(forVU: vu(fromDbfs: dbfs)) + } + + /// Radians from vertical; negative is left of vertical. + public static func angle(forFraction fraction: Double) -> Double { + (-MeterTokens.vuArcDegrees / 2 + MeterTokens.vuArcDegrees * fraction) * .pi / 180 + } + + /// Level as VoiceOver should say it: rounded to steps of 3 VU, clamped to the scale, "stil" for non-finite input. + public static func spokenLevel(fromDbfs dbfs: Double) -> String { + guard dbfs.isFinite else { return "stil" } + let stepped = (vu(fromDbfs: dbfs) / 3).rounded() * 3 + let clamped = min(MeterTokens.vuMax, max(MeterTokens.vuMin, stepped)) + return "\(Int(clamped)) VU" + } +} + +/// Second-order needle: reaches ~99 % in `response` seconds, slight overshoot for damping < 1. +public struct NeedleSpring: Equatable { + public private(set) var position: Double + public private(set) var velocity: Double = 0 + private let omega: Double + private let zeta: Double + + public init(response: Double = Motion.needleResponse, damping: Double = Motion.needleDamping, position: Double = 0) { + self.zeta = damping + self.omega = 4.6 / (damping * response) + self.position = position + } + + /// Advances the simulation by `dt` seconds in small fixed sub-steps (stable for any frame time). + @discardableResult + public mutating func step(target: Double, dt: Double) -> Double { + var remaining = max(0, dt) + let h = 1.0 / 240.0 + while remaining > 1e-9 { + let s = min(h, remaining) + let acceleration = omega * omega * (target - position) - 2 * zeta * omega * velocity + velocity += acceleration * s + position += velocity * s + remaining -= s + } + return position + } +} + +public enum SegmentID: CaseIterable, Hashable { + case a, b, c, d, e, f, g +} + +/// Which segments of a seven-segment digit are lit, and where each segment sits (from the segment tokens). +public enum SegmentGlyph { + static let map: [Character: Set] = [ + "0": [.a, .b, .c, .d, .e, .f], "1": [.b, .c], "2": [.a, .b, .d, .e, .g], "3": [.a, .b, .c, .d, .g], + "4": [.b, .c, .f, .g], "5": [.a, .c, .d, .f, .g], "6": [.a, .c, .d, .e, .f, .g], "7": [.a, .b, .c], + "8": Set(SegmentID.allCases), "9": [.a, .b, .c, .d, .f, .g], + ] + + public static func lit(for character: Character) -> Set { + map[character] ?? [] + } + + /// Hexagonal bar for one segment, inside a `Segment.digitWidth` × `Segment.digitHeight` box. + public static func polygon(_ id: SegmentID) -> [CGPoint] { + let width = Segment.digitWidth, height = Segment.digitHeight + let h = Segment.thickness / 2, gap = Segment.gap + let left = h, right = width - h, mid = height / 2 + + func horizontal(_ x0: CGFloat, _ x1: CGFloat, _ cy: CGFloat) -> [CGPoint] { + [CGPoint(x: x0, y: cy), CGPoint(x: x0 + h, y: cy - h), CGPoint(x: x1 - h, y: cy - h), + CGPoint(x: x1, y: cy), CGPoint(x: x1 - h, y: cy + h), CGPoint(x: x0 + h, y: cy + h)] + } + func vertical(_ cx: CGFloat, _ y0: CGFloat, _ y1: CGFloat) -> [CGPoint] { + [CGPoint(x: cx, y: y0), CGPoint(x: cx + h, y: y0 + h), CGPoint(x: cx + h, y: y1 - h), + CGPoint(x: cx, y: y1), CGPoint(x: cx - h, y: y1 - h), CGPoint(x: cx - h, y: y0 + h)] + } + let x0 = left + h + gap, x1 = right - h - gap + switch id { + case .a: return horizontal(x0, x1, h) + case .g: return horizontal(x0, x1, mid) + case .d: return horizontal(x0, x1, height - h) + case .f: return vertical(left, h + h + gap, mid - h - gap) + case .b: return vertical(right, h + h + gap, mid - h - gap) + case .e: return vertical(left, mid + h + gap, height - h - h - gap) + case .c: return vertical(right, mid + h + gap, height - h - h - gap) + } + } +} + +public enum LEDBar { + public enum Tone { case green, amber, red } + + /// The last segment is red, the three before it amber, the rest green. + public static func tone(forSegment index: Int, segments: Int = MeterTokens.ledSegments) -> Tone { + if index >= segments - 1 { return .red } + if index >= segments - 4 { return .amber } + return .green + } + + public static func litCount(forFraction fraction: Double, segments: Int = MeterTokens.ledSegments) -> Int { + Int((min(1, max(0, fraction)) * Double(segments)).rounded()) + } +} diff --git a/Sources/SstUI/Surfaces.swift b/Sources/SstUI/Surfaces.swift new file mode 100644 index 0000000..3b03615 --- /dev/null +++ b/Sources/SstUI/Surfaces.swift @@ -0,0 +1,175 @@ +import AppKit +import SwiftUI + +/// A shape with a fill, layered drop shadows, layered inset shadows and a hairline edge: a physical part. +public struct PhysicalSurface: View { + private let fill: AnyShapeStyle + private let cornerRadius: CGFloat + private let shadows: [ShadowLayer] + private let edge: Color? + @Environment(\.colorScheme) private var scheme + + public init(fill: AnyShapeStyle, cornerRadius: CGFloat, shadows: [ShadowLayer], edge: Color? = Palette.lineSeamDark) { + self.fill = fill + self.cornerRadius = cornerRadius + self.shadows = shadows + self.edge = edge + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .circular) + ZStack { + shadows.filter { !$0.inset }.reduce(AnyView(shape.fill(fill))) { view, layer in + AnyView(view.shadow(color: layer.color(scheme), radius: layer.blur / 2, x: layer.x, y: layer.y)) + } + ForEach(Array(shadows.filter(\.inset).enumerated()), id: \.offset) { _, layer in + InsetShadow(layer: layer, cornerRadius: cornerRadius) + } + if let edge { + shape.strokeBorder(edge, lineWidth: Border.hairline) + } + } + } +} + +/// A shadow cast inwards from the edge of a rounded rectangle. +struct InsetShadow: View { + let layer: ShadowLayer + let cornerRadius: CGFloat + @Environment(\.colorScheme) private var scheme + + var body: some View { + Canvas { context, size in + let rect = CGRect(origin: .zero, size: size) + context.clip(to: Path(roundedRect: rect, cornerRadius: cornerRadius)) + context.drawLayer { inner in + inner.addFilter(.blur(radius: layer.blur / 2)) + let hole = rect.insetBy(dx: layer.spread, dy: layer.spread).offsetBy(dx: layer.x, dy: layer.y) + var frame = Path(rect.insetBy(dx: -size.width, dy: -size.height)) + frame.addPath(Path(roundedRect: hole, cornerRadius: max(0, cornerRadius - layer.spread))) + inner.fill(frame, with: .color(layer.color(scheme)), style: FillStyle(eoFill: true)) + } + } + .allowsHitTesting(false) + } +} + +/// Faceplate of matt plastic, sitting above the chassis. +public struct PanelBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePanel), cornerRadius: Radius.none, shadows: Shadows.panel) + } +} + +/// A recessed area (input fields, display windows). +public struct WellBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceWell), cornerRadius: Radius.none, shadows: Shadows.well) + } +} + +/// Dark display glass for the counter. +public struct DisplayBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceDisplay), cornerRadius: Radius.none, shadows: Shadows.well) + } +} + +/// Cassette-label cream, for lists of transcripts and dictionary entries. +public struct PaperBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePaper), cornerRadius: Radius.none, shadows: Shadows.well) + } +} + +/// An engraved groove: a dark line with a light line below it. +public struct SeamDivider: View { + public init() {} + public var body: some View { + VStack(spacing: 0) { + Rectangle().fill(Palette.lineSeamDark).frame(height: Border.hairline) + Rectangle().fill(Palette.lineSeamLight).frame(height: Border.hairline) + } + .accessibilityHidden(true) + } +} + +/// Small capitals silkscreen label. +public struct SilkLabel: View { + private let text: String + private let style: TextStyle + + public init(_ text: String, style: TextStyle = Typography.label) { + self.text = text + self.style = style + } + + public var body: some View { + Text(text).textStyle(style).foregroundStyle(Palette.inkTertiary) + } +} + +/// Brushed aluminium: a vertical gradient with a horizontal grain. +public struct BrushedBackground: View { + @Environment(\.colorScheme) private var scheme + public init() {} + + public var body: some View { + ZStack { + LinearGradient(colors: [Palette.surfaceMetalHigh, Palette.surfaceMetalLow], startPoint: .top, endPoint: .bottom) + Image(nsImage: scheme == .dark ? BrushedTexture.dark : BrushedTexture.light) + .resizable(resizingMode: .tile) + } + } +} + +enum BrushedTexture { + private static let side = 256 + private static let gain = 2.2 // makes the token grain value visible as streak opacity + + static let light = make(grain: Texture.brushedGrainLight) + static let dark = make(grain: Texture.brushedGrainDark) + + private struct SplitMix64: RandomNumberGenerator { + var state: UInt64 + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + } + + /// A seeded grain: every row has its own brightness and pixels vary only slightly, which gives fine horizontal streaks. + private static func make(grain: Double) -> NSImage { + var rng = SplitMix64(state: 7) + let rows = (0.. 0.5 ? 1.0 : 0.0 + let index = (y * side + x) * 4 + let premultiplied = UInt8(shade * alpha * 255) + pixels[index] = premultiplied + pixels[index + 1] = premultiplied + pixels[index + 2] = premultiplied + pixels[index + 3] = UInt8(alpha * 255) + } + } + let provider = CGDataProvider(data: Data(pixels) as CFData)! + let image = CGImage( + width: side, height: side, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: side * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)! + return NSImage(cgImage: image, size: NSSize(width: side, height: side)) + } +} diff --git a/Sources/SstUI/TextStyle.swift b/Sources/SstUI/TextStyle.swift new file mode 100644 index 0000000..8896cab --- /dev/null +++ b/Sources/SstUI/TextStyle.swift @@ -0,0 +1,78 @@ +import SwiftUI + +public struct TextStyle: Equatable { + public let family: FontFamily + public let size: CGFloat + public let weight: Int + /// In em. + public let tracking: CGFloat + public let line: CGFloat + public let caps: Bool + public let tabular: Bool + + public init(family: FontFamily, size: CGFloat, weight: Int, tracking: CGFloat, line: CGFloat, caps: Bool, tabular: Bool) { + self.family = family + self.size = size + self.weight = weight + self.tracking = tracking + self.line = line + self.caps = caps + self.tabular = tabular + } + + var fontWeight: Font.Weight { + switch weight { + case ..<450: .regular + case 450..<550: .medium + case 550..<650: .semibold + default: .bold + } + } + + public var font: Font { + let base: Font + switch family { + case .mono: base = .system(size: size, weight: fontWeight, design: .monospaced) + case .text: base = .system(size: size, weight: fontWeight) + case .chrome: base = .custom(family.rawValue, size: size).weight(fontWeight) + } + return tabular ? base.monospacedDigit() : base + } +} + +public extension View { + /// Font, tracking, line spacing and capitalisation from a token text style. + func textStyle(_ style: TextStyle) -> some View { + self.font(style.font) + .tracking(style.tracking * style.size) + .lineSpacing(max(0, style.line - style.size * 1.2)) + .textCase(style.caps ? .uppercase : nil) + } +} + +/// One layer of a layered shadow token. Inset layers are drawn inside the shape. +public struct ShadowLayer: Equatable { + public let inset: Bool + public let x: CGFloat + public let y: CGFloat + public let blur: CGFloat + public let spread: CGFloat + public let hex: UInt32 + public let alphaLight: Double + public let alphaDark: Double + + public init(inset: Bool, x: CGFloat, y: CGFloat, blur: CGFloat, spread: CGFloat, hex: UInt32, alphaLight: Double, alphaDark: Double) { + self.inset = inset + self.x = x + self.y = y + self.blur = blur + self.spread = spread + self.hex = hex + self.alphaLight = alphaLight + self.alphaDark = alphaDark + } + + public func color(_ scheme: ColorScheme) -> Color { + Color.fixed(hex: hex).opacity(scheme == .dark ? alphaDark : alphaLight) + } +} diff --git a/Sources/SstUI/VUMeterView.swift b/Sources/SstUI/VUMeterView.swift new file mode 100644 index 0000000..2ae62cf --- /dev/null +++ b/Sources/SstUI/VUMeterView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +/// Analogue VU meter with a physical needle. Pass the RMS level in dBFS; 0 VU = `MeterTokens.vuReferenceDbfs`. +/// The needle only animates while `isActive` (and briefly after, to settle), so an idle meter costs nothing. +public struct VUMeterView: View { + private let levelDbfs: Double + private let isActive: Bool + private let brand: String + + @State private var needle: NeedleState + @State private var awake = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private final class NeedleState { + var spring: NeedleSpring + var dampingUsed = Motion.needleDamping + var last: TimeInterval? + var peakUntil: TimeInterval = 0 + + init(position: Double) { + spring = NeedleSpring(position: position) + } + } + + /// - Parameter initialFraction: where the needle starts (0 = bottom of the scale); useful for previews and snapshots. + public init(levelDbfs: Double, isActive: Bool, brand: String = "SST · FIELD RECORDER", initialFraction: Double = 0) { + self.levelDbfs = levelDbfs + self.isActive = isActive + self.brand = brand + _needle = State(initialValue: NeedleState(position: initialFraction)) + } + + public var body: some View { + TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !(isActive || awake))) { timeline in + Canvas { context, size in + draw(&context, size: size, now: timeline.date.timeIntervalSinceReferenceDate) + } + } + .aspectRatio(MeterTokens.vuWidth / MeterTokens.vuHeight, contentMode: .fit) + .background(Palette.surfaceMeterFace) + .overlay( + RoundedRectangle(cornerRadius: Radius.bezel, style: .circular) + .strokeBorder(Palette.lineSeamDark, lineWidth: Border.bezel)) + .clipShape(RoundedRectangle(cornerRadius: Radius.bezel, style: .circular)) + .onChange(of: isActive) { _, active in + if active { + awake = true + } else { + Task { + try? await Task.sleep(for: .seconds(1.5)) + awake = false + } + } + } + .accessibilityElement() + .accessibilityLabel("Niveau") + .accessibilityValue(spokenValue) + } + + /// Rounded to steps of 3 VU so VoiceOver is not flooded with changes. + private var spokenValue: String { + guard isActive else { return "stil" } + return VUScale.spokenLevel(fromDbfs: levelDbfs) + } + + // MARK: drawing (design coordinates: MeterTokens.vuWidth × vuHeight) + + private func draw(_ context: inout GraphicsContext, size: CGSize, now: TimeInterval) { + let width = MeterTokens.vuWidth, height = MeterTokens.vuHeight + let scale = min(size.width / width, size.height / height) + context.translateBy(x: (size.width - width * scale) / 2, y: (size.height - height * scale) / 2) + context.scaleBy(x: scale, y: scale) + context.clip(to: Path(CGRect(x: 0, y: 0, width: width, height: height))) + + // physics + let damping = reduceMotion ? 1.0 : Motion.needleDamping + if needle.dampingUsed != damping { + needle.spring = NeedleSpring(damping: damping, position: needle.spring.position) + needle.dampingUsed = damping + } + let dt = needle.last.map { min(0.05, now - $0) } ?? 0 + needle.last = now + let target = isActive ? VUScale.fraction(forDbfs: levelDbfs) : 0 + let position = needle.spring.step(target: target, dt: dt) + let vuNow = MeterTokens.vuMin + (MeterTokens.vuMax - MeterTokens.vuMin) * position + if vuNow > 0 && isActive { needle.peakUntil = now + MeterTokens.peakHoldSeconds } + + let pivot = CGPoint(x: width / 2, y: height + MeterTokens.vuPivotOffset) + func point(_ radius: CGFloat, _ fraction: Double) -> CGPoint { + let angle = VUScale.angle(forFraction: fraction) + return CGPoint(x: pivot.x + radius * CGFloat(sin(angle)), y: pivot.y - radius * CGFloat(cos(angle))) + } + func arc(_ radius: CGFloat, from: Double, to: Double) -> Path { + var path = Path() + let steps = 48 + for step in 0...steps { + let p = point(radius, from + (to - from) * Double(step) / Double(steps)) + step == 0 ? path.move(to: p) : path.addLine(to: p) + } + return path + } + + let radius = MeterTokens.vuScaleRadius + let redFrom = VUScale.fraction(forVU: 0) + context.stroke(arc(radius, from: 0, to: redFrom), with: .color(Palette.inkOnMeter), lineWidth: Border.hairline) + context.stroke(arc(radius - Border.hairline, from: redFrom, to: 1), with: .color(Palette.accentRecord), lineWidth: MeterTokens.vuRedBandWidth) + + for vu in Int(MeterTokens.vuMin)...Int(MeterTokens.vuMax) { + let major = MeterTokens.vuTicks.contains(vu) + let fraction = VUScale.fraction(forVU: Double(vu)) + let color = vu > 0 ? Palette.accentRecord : Palette.inkOnMeter + var tick = Path() + tick.move(to: point(radius, fraction)) + tick.addLine(to: point(radius - (major ? MeterTokens.vuTickMajor : MeterTokens.vuTickMinor), fraction)) + context.stroke(tick, with: .color(color), lineWidth: Border.hairline) + if major { + let label = Text(vu > 0 ? "+\(vu)" : "\(vu)").font(Typography.labelSmall.font).foregroundStyle(color) + context.draw(label, at: point(radius - MeterTokens.vuLabelInset, fraction), anchor: .center) + } + } + + context.draw( + Text("VU").font(Typography.heading.font).tracking(Typography.heading.tracking * Typography.heading.size).foregroundStyle(Palette.inkOnMeter), + at: CGPoint(x: width / 2, y: height - Space.s6), anchor: .center) + context.draw( + Text(brand).font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter.opacity(Opacity.caption)), + at: CGPoint(x: Space.s4, y: height - Space.s3), anchor: .leading) + + // peak lamp + let lampCenter = CGPoint(x: width - Space.s6, y: Space.s6) + let lampRadius = Size.lampDiameter / 2 + let peakLit = now < needle.peakUntil + context.fill(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)), + with: .color(peakLit ? Palette.accentRecord : Palette.accentRecordUnlit)) + context.stroke(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)), + with: .color(Palette.inkOnMeter), lineWidth: Border.hairline) + context.draw( + Text("PEAK").font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter), + at: CGPoint(x: lampCenter.x, y: lampCenter.y + lampRadius + Space.s3), anchor: .center) + + // needle + var needlePath = Path() + needlePath.move(to: pivot) + needlePath.addLine(to: point(MeterTokens.vuNeedleLength, max(-0.02, min(1.05, position)))) + context.stroke(needlePath, with: .color(Palette.inkOnMeter), lineWidth: MeterTokens.vuNeedleWidth) + } +} diff --git a/Sources/SstWhisper/ModelStorage.swift b/Sources/SstWhisper/ModelStorage.swift new file mode 100644 index 0000000..a352f84 --- /dev/null +++ b/Sources/SstWhisper/ModelStorage.swift @@ -0,0 +1,73 @@ +import Foundation + +/// A speech model the user can pick in Settings. +public struct ModelOption: Identifiable, Equatable, Sendable { + public let id: String // WhisperKit variant name + public let title: String + public let detail: String + public let approxSizeMB: Int + + public init(id: String, title: String, detail: String, approxSizeMB: Int) { + self.id = id + self.title = title + self.detail = detail + self.approxSizeMB = approxSizeMB + } +} + +public enum ModelCatalog { + public static let defaultVariant = "large-v3-v20240930_turbo" + + /// Sizes measured from the Hugging Face repository `argmaxinc/whisperkit-coreml`. + public static let all: [ModelOption] = [ + ModelOption(id: defaultVariant, title: "Whisper Large v3 Turbo", detail: "Aanbevolen · nauwkeurigst", approxSizeMB: 1638), + ModelOption(id: "large-v3-v20240930_turbo_632MB", title: "Whisper Large v3 Turbo (compact)", detail: "Kleiner, iets minder nauwkeurig", approxSizeMB: 646), + ModelOption(id: "small", title: "Whisper Small", detail: "Snel en licht, minder nauwkeurig", approxSizeMB: 486), + ] +} + +/// Where models live on disk: `/models/argmaxinc/whisperkit-coreml/openai_whisper-`. +public struct ModelStorage: Sendable { + public static let repo = "argmaxinc/whisperkit-coreml" + public static let requiredModels = ["MelSpectrogram", "AudioEncoder", "TextDecoder"] + + public let base: URL + + public init(base: URL = ModelStorage.defaultBase) { + self.base = base + } + + /// Application Support, so models are neither in ~/Documents (privacy prompt, iCloud) nor re-fetched. + public static var defaultBase: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appending(path: "Sst/models", directoryHint: .isDirectory) + } + + var repoDirectory: URL { + base.appending(path: "models/\(Self.repo)", directoryHint: .isDirectory) + } + + private func folders(for variant: String) -> [URL] { + let names = (try? FileManager.default.contentsOfDirectory(atPath: repoDirectory.path)) ?? [] + return names + .filter { $0 == variant || $0 == "openai_whisper-\(variant)" } + .map { repoDirectory.appending(path: $0, directoryHint: .isDirectory) } + } + + /// The folder of a completely downloaded variant, or nil. + public func folder(for variant: String) -> URL? { + folders(for: variant).first { folder in + Self.requiredModels.allSatisfy { + FileManager.default.fileExists(atPath: folder.appending(path: "\($0).mlmodelc/coremldata.bin").path) + } + } + } + + public func isInstalled(_ variant: String) -> Bool { + folder(for: variant) != nil + } + + public func delete(_ variant: String) throws { + for folder in folders(for: variant) { try FileManager.default.removeItem(at: folder) } + } +} diff --git a/Sources/SstWhisper/WhisperKitTranscriber.swift b/Sources/SstWhisper/WhisperKitTranscriber.swift index 6bf4916..e0e81bf 100644 --- a/Sources/SstWhisper/WhisperKitTranscriber.swift +++ b/Sources/SstWhisper/WhisperKitTranscriber.swift @@ -10,44 +10,24 @@ public final class WhisperKitTranscriber: Transcriber { } private let modelName: String + private let storage: ModelStorage private var pipeline: WhisperKit? /// Download progress (0...1) during the one-time model download. May be called from any thread. public var onProgress: ((Double) -> Void)? - public init(model: String = "large-v3-v20240930_turbo") { + public init(model: String = ModelCatalog.defaultVariant, storage: ModelStorage = ModelStorage()) { self.modelName = model + self.storage = storage } - /// Hugging Face hub root; WhisperKit stores models in `/models//`. - /// Kept in Application Support so it is neither in ~/Documents (TCC prompt, iCloud) nor re-fetched. - private static var storageBase: URL { - FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appending(path: "Sst/models", directoryHint: .isDirectory) - } + public var variant: String { modelName } - private static let repo = "argmaxinc/whisperkit-coreml" - - /// A previously downloaded variant folder, if it contains the compiled Core ML models. - private func localModelFolder(base: URL) -> URL? { - let repoDir = base.appending(path: "models/\(Self.repo)", directoryHint: .isDirectory) - let folders = (try? FileManager.default.contentsOfDirectory(atPath: repoDir.path)) ?? [] - return folders - .filter { $0.hasSuffix(modelName) } - .map { repoDir.appending(path: $0, directoryHint: .isDirectory) } - .first { folder in - ["MelSpectrogram", "AudioEncoder", "TextDecoder"].allSatisfy { - FileManager.default.fileExists( - atPath: folder.appending(path: "\($0).mlmodelc/coremldata.bin").path) - } - } - } - - private func loadPipeline(from folder: URL, base: URL) async throws -> WhisperKit { + private func loadPipeline(from folder: URL) async throws -> WhisperKit { try await WhisperKit(WhisperKitConfig( - downloadBase: base, + downloadBase: storage.base, modelFolder: folder.path, - tokenizerFolder: base, + tokenizerFolder: storage.base, prewarm: true, load: true, download: false @@ -58,34 +38,64 @@ public final class WhisperKitTranscriber: Transcriber { /// once downloaded, launching needs no network. public func load() async throws { guard pipeline == nil else { return } - let base = Self.storageBase - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: storage.base, withIntermediateDirectories: true) - if let folder = localModelFolder(base: base), - let loaded = try? await loadPipeline(from: folder, base: base) { + if let folder = storage.folder(for: modelName), + let loaded = try? await loadPipeline(from: folder) { pipeline = loaded return } let folder = try await WhisperKit.download( variant: modelName, - downloadBase: base, - from: Self.repo + downloadBase: storage.base, + from: ModelStorage.repo ) { [onProgress] progress in onProgress?(progress.fractionCompleted) } - pipeline = try await loadPipeline(from: folder, base: base) + pipeline = try await loadPipeline(from: folder) } public func transcribe(_ samples: [Float]) async throws -> String { + try await transcribe(samples, vocabulary: []) + } + + /// Terms are added one by one while the encoded prompt stays within `maxTokens`. + /// Returns nil when nothing fits or the list is empty. + static func promptTokens( + vocabulary: [String], + maxTokens: Int = 64, + specialTokenBegin: Int, + encode: (String) -> [Int] + ) -> [Int]? { + var kept: [String] = [] + var tokens: [Int] = [] + for term in vocabulary { + let candidate = kept + [term] + let encoded = encode(" " + candidate.joined(separator: ", ")).filter { $0 < specialTokenBegin } + if encoded.count > maxTokens { break } + kept = candidate + tokens = encoded + } + return tokens.isEmpty ? nil : tokens + } + + public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { guard let pipeline else { throw TranscriberError.notLoaded } // language nil + detectLanguage: Whisper picks NL or EN per utterance. - let options = DecodingOptions( + var options = DecodingOptions( task: .transcribe, language: nil, detectLanguage: true, skipSpecialTokens: true, withoutTimestamps: true ) + if !vocabulary.isEmpty, let tokenizer = pipeline.tokenizer { + options.promptTokens = Self.promptTokens( + vocabulary: vocabulary, + specialTokenBegin: tokenizer.specialTokens.specialTokenBegin, + encode: { tokenizer.encode(text: $0) } + ) + } let results = try await pipeline.transcribe(audioArray: samples, decodeOptions: options) return results .map(\.text) diff --git a/Tests/SstCoreTests/AppGlueTests.swift b/Tests/SstCoreTests/AppGlueTests.swift new file mode 100644 index 0000000..202a0c5 --- /dev/null +++ b/Tests/SstCoreTests/AppGlueTests.swift @@ -0,0 +1,87 @@ +import Foundation +import Testing +@testable import SstCore + +struct RoutingInserterTests { + @Test func sendsToTheSystemRoute() async throws { + let system = FakeInserter(), editor = FakeInserter() + try await RoutingInserter(system: system, editor: editor, route: { .system }).insert("hallo") + #expect(system.inserted == ["hallo"]) + #expect(editor.inserted.isEmpty) + } + + @Test func sendsToTheEditorRoute() async throws { + let system = FakeInserter(), editor = FakeInserter() + try await RoutingInserter(system: system, editor: editor, route: { .editor }).insert("hallo") + #expect(editor.inserted == ["hallo"]) + #expect(system.inserted.isEmpty) + } + + @Test func dropsTheTextOnNone() async throws { + let system = FakeInserter(), editor = FakeInserter() + try await RoutingInserter(system: system, editor: editor, route: { .none }).insert("hallo") + #expect(system.inserted.isEmpty) + #expect(editor.inserted.isEmpty) + } + + @Test func askedAtInsertionTimeNotAtCreation() async throws { + let system = FakeInserter(), editor = FakeInserter() + var current = InsertionRoute.none + let router = RoutingInserter(system: system, editor: editor, route: { current }) + current = .editor + try await router.insert("nu wel") + #expect(editor.inserted == ["nu wel"]) + } + + @Test func propagatesInserterErrors() async { + struct Boom: Error {} + let system = FakeInserter() + system.error = Boom() + await #expect(throws: Boom.self) { + try await RoutingInserter(system: system, editor: FakeInserter(), route: { .system }).insert("x") + } + } +} + +struct SwappableTranscriberTests { + @Test func throwsWithoutATranscriber() async { + let swappable = SwappableTranscriber() + #expect(!swappable.hasTranscriber) + await #expect(throws: SwappableTranscriber.SwapError.self) { + _ = try await swappable.transcribe([0]) + } + } + + @Test func delegatesIncludingTheVocabulary() async throws { + let inner = FakeVocabularyTranscriber() + inner.result = "klaar" + let swappable = SwappableTranscriber(inner) + #expect(try await swappable.transcribe([0], vocabulary: ["Anthropic"]) == "klaar") + #expect(inner.vocabularies == [["Anthropic"]]) + #expect(try await swappable.transcribe([0]) == "klaar") + #expect(inner.vocabularies == [["Anthropic"], []]) + } + + @Test func swapReplacesTheTranscriber() async throws { + let first = FakeVocabularyTranscriber(), second = FakeVocabularyTranscriber() + first.result = "een"; second.result = "twee" + let swappable = SwappableTranscriber(first) + #expect(try await swappable.transcribe([0]) == "een") + swappable.swap(to: second) + #expect(try await swappable.transcribe([0]) == "twee") + swappable.swap(to: nil) + #expect(!swappable.hasTranscriber) + } +} + +struct LevelSourceTests { + @Test func startsSilentAndKeepsTheLatestValue() { + let source = LevelSource() + #expect(source.dbfs == AudioLevel.silenceFloorDbfs) + source.update(-23) + source.update(-18) + #expect(source.dbfs == -18) + source.reset() + #expect(source.dbfs == AudioLevel.silenceFloorDbfs) + } +} diff --git a/Tests/SstCoreTests/AudioLevelTests.swift b/Tests/SstCoreTests/AudioLevelTests.swift new file mode 100644 index 0000000..6561bf4 --- /dev/null +++ b/Tests/SstCoreTests/AudioLevelTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import SstCore + +struct AudioLevelTests { + @Test func constantTenthIsMinusTwentyDbfs() { + let level = AudioLevel.rmsDbfs([Float](repeating: 0.1, count: 1_000)) + #expect(abs(level - (-20)) < 0.01) + } + + @Test func fullScaleIsZeroDbfs() { + let level = AudioLevel.rmsDbfs([Float](repeating: 1.0, count: 100)) + #expect(abs(level) < 0.01) + } + + @Test func negativeSamplesCountLikePositiveOnes() { + let level = AudioLevel.rmsDbfs([Float](repeating: -0.1, count: 1_000)) + #expect(abs(level - (-20)) < 0.01) + } + + @Test func emptyAndSilentBuffersHitTheFloor() { + #expect(AudioLevel.rmsDbfs([]) == AudioLevel.silenceFloorDbfs) + #expect(AudioLevel.rmsDbfs([Float](repeating: 0, count: 64)) == AudioLevel.silenceFloorDbfs) + } + + @Test func vuIsRelativeToTheReference() { + #expect(AudioLevel.vu(fromDbfs: -20) == 0) + #expect(AudioLevel.vu(fromDbfs: -40) == -20) + #expect(AudioLevel.vu(fromDbfs: -17) == 3) + #expect(AudioLevel.vu(fromDbfs: -30, referenceDbfs: -18) == -12) + } +} diff --git a/Tests/SstCoreTests/DictationControllerHooksTests.swift b/Tests/SstCoreTests/DictationControllerHooksTests.swift new file mode 100644 index 0000000..fda62e6 --- /dev/null +++ b/Tests/SstCoreTests/DictationControllerHooksTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing +@testable import SstCore + +@MainActor +struct DictationControllerHooksTests { + let recorder = FakeRecorder() + let transcriber = FakeVocabularyTranscriber() + let inserter = FakeInserter() + static let loud = [Float](repeating: 0.1, count: 16_000) + static let quiet = [Float](repeating: 0.01, count: 16_000) + + func makeController( + cleaner: Cleaner = FakeCleaner(), + corrector: TextCorrecting? = nil, + vocabulary: [String] = [] + ) -> DictationController { + DictationController( + recorder: recorder, + transcriber: transcriber, + cleaner: cleaner, + inserter: inserter, + corrector: corrector, + vocabularyProvider: { vocabulary } + ) + } + + func dictate(_ controller: DictationController) async { + await controller.handle(.pressed) + await controller.handle(.released) + } + + @Test func loudRecordingPassesVocabularyToTranscriber() async { + recorder.samples = Self.loud + await dictate(makeController(vocabulary: ["Anthropic", "Vercel"])) + #expect(transcriber.vocabularies == [["Anthropic", "Vercel"]]) + } + + @Test func quietRecordingSkipsVocabularyButStillTranscribes() async { + recorder.samples = Self.quiet // RMS 0.01: above silence (0.005), below bias minimum (0.015) + await dictate(makeController(vocabulary: ["Anthropic", "Vercel"])) + #expect(transcriber.vocabularies == [[]]) + #expect(inserter.inserted == ["[hallo wereld]"]) + } + + @Test func promptEchoIsDiscarded() async { + recorder.samples = Self.loud + transcriber.result = "Anthropic, Vercel." + let controller = makeController(vocabulary: ["Anthropic", "Vercel", "Supabase"]) + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + + await dictate(controller) + + #expect(inserter.inserted.isEmpty) + #expect(results.isEmpty) + #expect(controller.state == .idle) + } + + @Test func singleVocabularyTermIsKept() async { + recorder.samples = Self.loud + transcriber.result = "Anthropic" + await dictate(makeController(vocabulary: ["Anthropic", "Vercel", "Supabase"])) + #expect(inserter.inserted == ["[Anthropic]"]) + } + + @Test func correctorRunsBeforeCleanerAndCorrectionsSurviveAPassthroughCleaner() async { + recorder.samples = Self.loud + transcriber.result = "hallo" + let controller = makeController(cleaner: PassthroughCleaner(), corrector: UppercaseCorrector()) + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + + await dictate(controller) + + #expect(inserter.inserted == ["HALLO"]) + #expect(results.count == 1) + #expect(results[0].rawText == "hallo") + #expect(results[0].finalText == "HALLO") + #expect(results[0].corrections.count == 1) + } + + @Test func correctionsAreDroppedWhenTheCleanerChangesTheText() async { + recorder.samples = Self.loud + transcriber.result = "hallo" + let controller = makeController(cleaner: FakeCleaner(), corrector: UppercaseCorrector()) + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + + await dictate(controller) + + #expect(results[0].finalText == "[HALLO]") + #expect(results[0].corrections.isEmpty) + } + + @Test func resultIsReportedEvenWhenInsertionFails() async { + recorder.samples = Self.loud + inserter.error = TestError() + let controller = makeController() + var results: [DictationResult] = [] + var errors: [String] = [] + controller.onResult = { results.append($0) } + controller.onError = { errors.append($0) } + + await dictate(controller) + + #expect(results.count == 1) + #expect(errors.count == 1) + } + + @Test func resultCarriesDurationAndDate() async { + recorder.samples = Self.loud // 16 000 samples = 1 s + let controller = makeController() + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + let before = Date() + + await dictate(controller) + + #expect(results[0].duration == 1.0) + #expect(results[0].date >= before) + #expect(results[0].corrections.isEmpty) + } +} diff --git a/Tests/SstCoreTests/Fakes.swift b/Tests/SstCoreTests/Fakes.swift index f8b2cf7..c6afd8a 100644 --- a/Tests/SstCoreTests/Fakes.swift +++ b/Tests/SstCoreTests/Fakes.swift @@ -41,3 +41,25 @@ final class FakeInserter: TextInserting { inserted.append(text) } } + +final class FakeVocabularyTranscriber: Transcriber { + var result = "hallo wereld" + private(set) var vocabularies: [[String]] = [] + + func transcribe(_ samples: [Float]) async throws -> String { result } + + func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + vocabularies.append(vocabulary) + return result + } +} + +struct UppercaseCorrector: TextCorrecting { + func correct(_ text: String) -> CorrectionResult { + let upper = text.uppercased() + return CorrectionResult( + text: upper, + corrections: [AppliedCorrection(heard: text, wrote: upper, rule: "upper", start: 0, length: upper.utf16.count)] + ) + } +} diff --git a/Tests/SstCoreTests/HotkeyBindingTests.swift b/Tests/SstCoreTests/HotkeyBindingTests.swift new file mode 100644 index 0000000..0e09999 --- /dev/null +++ b/Tests/SstCoreTests/HotkeyBindingTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import SstCore + +struct HotkeyBindingTests { + @Test func allowedBindingsMatchTheSpec() { + #expect(HotkeyBinding.allowed.map(\.id) == ["fn", "rightOption", "rightCommand", "rightControl", "leftControl"]) + #expect(HotkeyBinding.allowed.map(\.keyCode) == [63, 61, 54, 62, 59]) + } + + @Test func idsAndKeyCodesAreUnique() { + #expect(Set(HotkeyBinding.allowed.map(\.id)).count == HotkeyBinding.allowed.count) + #expect(Set(HotkeyBinding.allowed.map(\.keyCode)).count == HotkeyBinding.allowed.count) + } + + @Test func lookupByKeyCodeAndId() { + #expect(HotkeyBinding.binding(keyCode: 61) == .rightOption) + #expect(HotkeyBinding.binding(id: "leftControl") == .leftControl) + } + + @Test func unsupportedKeysAreRejected() { + #expect(HotkeyBinding.binding(keyCode: 56) == nil) // left shift + #expect(HotkeyBinding.binding(keyCode: 57) == nil) // caps lock + #expect(HotkeyBinding.binding(id: "nope") == nil) + } + + @Test func everyBindingHasAFlagAndName() { + for binding in HotkeyBinding.allowed { + #expect(binding.flagRawValue != 0) + #expect(!binding.displayName.isEmpty) + } + } +} diff --git a/Tests/SstCoreTests/ModifierKeyTrackerTests.swift b/Tests/SstCoreTests/ModifierKeyTrackerTests.swift new file mode 100644 index 0000000..377b04a --- /dev/null +++ b/Tests/SstCoreTests/ModifierKeyTrackerTests.swift @@ -0,0 +1,18 @@ +import Testing +@testable import SstCore + +struct ModifierKeyTrackerTests { + @Test func emitsPressedThenReleasedOnlyOnTransitions() { + var tracker = ModifierKeyTracker() + #expect(tracker.process(isDown: false) == nil) + #expect(tracker.process(isDown: true) == .pressed) + #expect(tracker.process(isDown: true) == nil) + #expect(tracker.process(isDown: false) == .released) + #expect(tracker.process(isDown: false) == nil) + } + + @Test func freshTrackerStartsReleased() { + var tracker = ModifierKeyTracker() + #expect(tracker.process(isDown: true) == .pressed) + } +} diff --git a/Tests/SstCoreTests/PromptEchoTests.swift b/Tests/SstCoreTests/PromptEchoTests.swift new file mode 100644 index 0000000..84ff80c --- /dev/null +++ b/Tests/SstCoreTests/PromptEchoTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import SstCore + +struct PromptEchoTests { + let vocabulary = ["Anthropic", "Vercel", "Supabase"] + + @Test func firstTwoTermsAreAnEcho() { + #expect(PromptEcho.isEcho("Anthropic, Vercel.", vocabulary: vocabulary)) + } + + @Test func wholeListIsAnEcho() { + #expect(PromptEcho.isEcho("anthropic vercel supabase", vocabulary: vocabulary)) + } + + @Test func singleTermIsRealSpeech() { + #expect(!PromptEcho.isEcho("Anthropic", vocabulary: vocabulary)) + } + + @Test func termsOutOfOrderAreNotAnEcho() { + #expect(!PromptEcho.isEcho("Vercel, Anthropic", vocabulary: vocabulary)) + } + + @Test func sentenceContainingTermsIsNotAnEcho() { + #expect(!PromptEcho.isEcho("Ik gebruik Anthropic en Vercel", vocabulary: vocabulary)) + } + + @Test func tooShortVocabularyNeverEchoes() { + #expect(!PromptEcho.isEcho("Anthropic", vocabulary: ["Anthropic"])) + #expect(!PromptEcho.isEcho("", vocabulary: vocabulary)) + } +} diff --git a/Tests/SstCoreTests/TranscriberVocabularyTests.swift b/Tests/SstCoreTests/TranscriberVocabularyTests.swift new file mode 100644 index 0000000..e066b40 --- /dev/null +++ b/Tests/SstCoreTests/TranscriberVocabularyTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import SstCore + +private struct PlainTranscriber: Transcriber { + func transcribe(_ samples: [Float]) async throws -> String { "plain \(samples.count)" } +} + +struct TranscriberVocabularyTests { + @Test func vocabularyVariantFallsBackToPlainTranscribe() async throws { + let result = try await PlainTranscriber().transcribe([0, 0, 0], vocabulary: ["Anthropic"]) + #expect(result == "plain 3") + } + + @Test func appliedCorrectionRoundTripsThroughJSON() throws { + let original = AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 12, length: 11) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(AppliedCorrection.self, from: data) + #expect(decoded == original) + } +} diff --git a/Tests/SstDataTests/AppSettingsTests.swift b/Tests/SstDataTests/AppSettingsTests.swift new file mode 100644 index 0000000..e384f41 --- /dev/null +++ b/Tests/SstDataTests/AppSettingsTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing +@testable import SstData + +struct AppSettingsTests { + func freshDefaults() -> UserDefaults { + let name = "sst-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test func defaultsMatchTheSpec() { + let settings = AppSettings(defaults: freshDefaults()) + #expect(settings.hotkeyID == "fn") + #expect(settings.modelVariant == "large-v3-v20240930_turbo") + #expect(settings.keepHistory) + #expect(settings.usePriming) + #expect(settings.useCorrection) + } + + @Test func changesArePersisted() { + let defaults = freshDefaults() + let settings = AppSettings(defaults: defaults) + settings.hotkeyID = "rightOption" + settings.modelVariant = "small" + settings.keepHistory = false + settings.usePriming = false + settings.useCorrection = false + + let reloaded = AppSettings(defaults: defaults) + #expect(reloaded.hotkeyID == "rightOption") + #expect(reloaded.modelVariant == "small") + #expect(!reloaded.keepHistory) + #expect(!reloaded.usePriming) + #expect(!reloaded.useCorrection) + } +} diff --git a/Tests/SstDataTests/CorrectionEngineTests.swift b/Tests/SstDataTests/CorrectionEngineTests.swift new file mode 100644 index 0000000..a940f0b --- /dev/null +++ b/Tests/SstDataTests/CorrectionEngineTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing +import SstCore +@testable import SstData + +struct CorrectionEngineTests { + let knowledge = FakeKnowledge() + + func engine(_ entries: [DictionaryEntry]) -> CorrectionEngine { + CorrectionEngine(entries: entries, knowledge: knowledge) + } + + @Test func replacesAPairAndReportsIt() { + let result = engine([pair("cloud code", "Claude Code")]).correct("ik gebruik cloud code vandaag") + #expect(result.text == "ik gebruik Claude Code vandaag") + #expect(result.corrections.count == 1) + let c = result.corrections[0] + #expect(c.heard == "cloud code" && c.wrote == "Claude Code" && c.rule == "cloud code") + #expect((result.text as NSString).substring(with: NSRange(location: c.start, length: c.length)) == "Claude Code") + } + + @Test func findsGluedHyphenatedAndSpacedVariants() { + let e = engine([pair("cloud code", "Claude Code")]) + for variant in ["CloudCode", "Cloud-Code", "CLOUD CODE", "cloud\u{2011}code", "cloud code"] { + #expect(e.correct(variant).text == "Claude Code", "variant \(variant)") + } + } + + @Test func neverTouchesRealWordsThatMerelyStartTheSame() { + let e = engine([pair("cloud code", "Claude Code")]) + #expect(e.correct("Cloudflare is handig").text == "Cloudflare is handig") + #expect(e.correct("cloud codex").text == "cloud codex") + #expect(e.correct("de cloud en de code").text == "de cloud en de code") + #expect(e.correct("the cloud").corrections.isEmpty) + } + + @Test func respectsPunctuationBoundaries() { + let e = engine([pair("cloud code", "Claude Code")]) + #expect(e.correct("(cloud code)").text == "(Claude Code)") + #expect(e.correct("cloud code.").text == "Claude Code.") + #expect(e.correct("Cloud Code, Cloud Code").text == "Claude Code, Claude Code") + } + + @Test func longestMatchWins() { + let e = engine([pair("cloud code", "Claude Code"), pair("cloud code review", "Claude Code Review")]) + #expect(e.correct("cloud code review nu").text == "Claude Code Review nu") + #expect(e.correct("cloud-code-review").text == "Claude Code Review") + } + + @Test func replacedTextIsNeverRescanned() { + let e = engine([pair("a", "b"), pair("b", "c")]) + #expect(e.correct("a b").text == "b c") + } + + @Test func termsFixCasingAndSpacing() { + let e = engine([term("Anthropic"), term("Claude Code")]) + #expect(e.correct("anthropic ANTHROPIC").text == "Anthropic Anthropic") + #expect(e.correct("claudecode en claude-code").text == "Claude Code en Claude Code") + } + + @Test func termAlreadyRightIsNotReportedAsACorrection() { + let result = engine([term("Anthropic")]).correct("Anthropic bouwt Claude") + #expect(result.text == "Anthropic bouwt Claude") + #expect(result.corrections.isEmpty) + } + + @Test func singleWordTermThatIsARealWordGetsNoCasingRule() { + let e = engine([term("Reef"), term("Slack")]) + #expect(e.correct("het reef en slack").text == "het reef en slack") + } + + @Test func firstEntryWinsOnDuplicateSources() { + let e = engine([pair("cloud code", "Claude Code"), pair("Cloud-Code", "Other")]) + #expect(e.correct("cloudcode").text == "Claude Code") + } + + @Test func replacementIsLiteral() { + let e = engine([pair("price", "US$1 \\0 $1")]) + #expect(e.correct("the price").text == "the US$1 \\0 $1") + } + + @Test func handlesDiacritics() { + let e = engine([term("Café")]) + #expect(e.correct("een CAFÉ om de hoek").text == "een Café om de hoek") + } + + @Test func offsetsAreRelativeToTheCorrectedText() { + let result = engine([pair("a", "xxx"), pair("b", "y")]).correct("a b a") + #expect(result.text == "xxx y xxx") + #expect(result.corrections.map(\.start) == [0, 4, 6]) + #expect(result.corrections.map(\.length) == [3, 1, 3]) + } + + @Test func emptyDictionaryAndEmptyTextAreNoOps() { + #expect(engine([]).correct("hallo").text == "hallo") + #expect(engine([pair("a", "b")]).correct("").text == "") + } +} diff --git a/Tests/SstDataTests/DictionaryFileTests.swift b/Tests/SstDataTests/DictionaryFileTests.swift new file mode 100644 index 0000000..e666d14 --- /dev/null +++ b/Tests/SstDataTests/DictionaryFileTests.swift @@ -0,0 +1,71 @@ +import Testing +@testable import SstData + +struct DictionaryFileTests { + let sample = "# Opmerking\nAnthropic\n\ncloud code => Claude Code\n" + + @Test func parsesTermsPairsCommentsAndBlanks() { + let file = DictionaryFile(parsing: sample) + #expect(file.lines.count == 4) + #expect(file.entries == [term("Anthropic"), pair("cloud code", "Claude Code")]) + #expect(file.lines[0] == .comment("# Opmerking")) + #expect(file.lines[2] == .blank) + } + + @Test func roundTripKeepsCommentsBlankLinesAndOrder() { + #expect(DictionaryFile(parsing: sample).serialized() == sample) + } + + @Test func normalisesSpacingAroundTheArrow() { + #expect(DictionaryFile(parsing: "a=>b\n").serialized() == "a => b\n") + #expect(DictionaryFile(parsing: " cloud code => Claude Code \n").entries == [pair("cloud code", "Claude Code")]) + } + + @Test func unreadableLinesAreKeptAsInvalid() { + let file = DictionaryFile(parsing: "oops =>\n=> nothing\nok\n") + #expect(file.lines[0] == .invalid("oops =>")) + #expect(file.lines[1] == .invalid("=> nothing")) + #expect(file.serialized() == "oops =>\n=> nothing\nok\n") + } + + @Test func handlesWindowsLineEndingsAndMissingFinalNewline() { + let file = DictionaryFile(parsing: "a\r\nb => c") + #expect(file.entries == [term("a"), pair("b", "c")]) + #expect(file.serialized() == "a\nb => c\n") + } + + @Test func emptyTextIsAValidEmptyFile() { + #expect(DictionaryFile(parsing: "").entries.isEmpty) + #expect(DictionaryFile(parsing: "").serialized() == "") + } + + @Test func appendUpdateAndRemove() { + var file = DictionaryFile(parsing: "# c\n") + let id = file.append(.term("Vercel")) + #expect(file.entries == [term("Vercel")]) + file.update(id: id, kind: .correction(heard: "versel", wrote: "Vercel")) + #expect(file.entries == [pair("versel", "Vercel")]) + file.remove(id: id) + #expect(file.entries.isEmpty) + #expect(file.serialized() == "# c\n") + } + + @Test func preservingIDsKeepsIdsOfUnchangedLines() { + let old = DictionaryFile(parsing: "a\nb => c\nd\n") + let new = DictionaryFile(parsing: "z\na\nb => c\nd2\n").preservingIDs(from: old) + let oldIDs = old.entries.map(\.id), newIDs = new.entries.map(\.id) + #expect(newIDs[1] == oldIDs[0]) + #expect(newIDs[2] == oldIDs[1]) + #expect(!oldIDs.contains(newIDs[0])) + #expect(!oldIDs.contains(newIDs[3])) + #expect(new.serialized() == "z\na\nb => c\nd2\n") + } + + @Test func preservingIDsMapsDuplicatesOneToOneInOrder() { + let old = DictionaryFile(parsing: "a\na\n") + let new = DictionaryFile(parsing: "a\na\na\n").preservingIDs(from: old) + let oldIDs = old.entries.map(\.id), newIDs = new.entries.map(\.id) + #expect(Array(newIDs[0...1]) == oldIDs) + #expect(!oldIDs.contains(newIDs[2])) + } +} diff --git a/Tests/SstDataTests/DictionaryStoreTests.swift b/Tests/SstDataTests/DictionaryStoreTests.swift new file mode 100644 index 0000000..7f84126 --- /dev/null +++ b/Tests/SstDataTests/DictionaryStoreTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +import SstCore +@testable import SstData + +@MainActor +struct DictionaryStoreTests { + func tempFile() -> URL { + FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/dictionary.txt") + } + + @Test func loadCreatesAFileWithAHeaderWhenMissing() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(store.entries.isEmpty) + #expect(try String(contentsOf: url, encoding: .utf8).hasPrefix("# Sst woordenboek")) + } + + @Test func applyWritesAtomicallyAndKeepsComments() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + try store.apply { $0.append(.correction(heard: "cloud code", wrote: "Claude Code")) } + let text = try String(contentsOf: url, encoding: .utf8) + #expect(text.hasPrefix("# Sst woordenboek")) + #expect(text.hasSuffix("cloud code => Claude Code\n")) + #expect(store.entries == [pair("cloud code", "Claude Code")]) + } + + @Test func externalEditIsPickedUp() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + var changes = 0 + store.onChange = { changes += 1 } + try "Vercel\n".write(to: url, atomically: true, encoding: .utf8) + store.reloadIfChanged() + #expect(store.entries == [term("Vercel")]) + #expect(changes == 1) + store.reloadIfChanged() // unchanged text: no second notification + #expect(changes == 1) + } + + @Test func externalChangeFiresBothCallbacksAndKeepsIDs() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + try store.apply { $0.append(.term("Vercel")) } + let id = store.entries[0].id + var changes = 0, external = 0 + store.onChange = { changes += 1 } + store.onExternalChange = { external += 1 } + try "Nieuw\nVercel\n".write(to: url, atomically: true, encoding: .utf8) + store.reloadIfChanged() + #expect(changes == 1 && external == 1) + #expect(store.entries[1].id == id) + try store.apply { $0.append(.term("Eigen")) } + #expect(changes == 2 && external == 1) + } + + @Test func ourOwnWriteDoesNotTriggerAReload() throws { + let store = DictionaryStore(fileURL: tempFile()) + try store.load() + try store.apply { $0.append(.term("Supabase")) } + var changes = 0 + store.onChange = { changes += 1 } + store.reloadIfChanged() + #expect(changes == 0) + } + + @Test func watcherNoticesAnEditorSavingTheFile() async throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + var changed = false + store.onChange = { changed = true } + store.startWatching() + try "Anthropic\n".write(to: url, atomically: true, encoding: .utf8) + for _ in 0..<40 where !changed { try await Task.sleep(for: .milliseconds(100)) } + store.stopWatching() + #expect(changed) + #expect(store.entries == [term("Anthropic")]) + } +} + +struct DictionaryCorrectorTests { + @Test func correctsWhenEnabledAndPassesThroughWhenDisabled() { + let corrector = DictionaryCorrector(knowledge: FakeKnowledge()) + corrector.update(entries: [pair("cloud code", "Claude Code")]) + #expect(corrector.correct("cloud code").text == "Claude Code") + corrector.setEnabled(false) + #expect(corrector.correct("cloud code").text == "cloud code") + corrector.setEnabled(true) + corrector.update(entries: []) + #expect(corrector.correct("cloud code").text == "cloud code") + } +} diff --git a/Tests/SstDataTests/EntryWarningsTests.swift b/Tests/SstDataTests/EntryWarningsTests.swift new file mode 100644 index 0000000..d37338a --- /dev/null +++ b/Tests/SstDataTests/EntryWarningsTests.swift @@ -0,0 +1,60 @@ +import Testing +@testable import SstData + +struct EntryWarningsTests { + let knowledge = FakeKnowledge() + + func check(_ kind: DictionaryEntry.Kind, others: [DictionaryEntry] = []) -> [EntryWarning] { + EntryWarnings.check(kind, among: others, knowledge: knowledge) + } + + @Test func singleCommonWordIsAWarning() { + let w = check(.correction(heard: "cloud", wrote: "Claude")) + #expect(w.contains { $0.level == .warning && $0.message.contains("cloud") }) + } + + @Test func veryShortSourceIsAWarning() { + #expect(check(.correction(heard: "ab", wrote: "AB")).contains { $0.level == .warning }) + } + + @Test func functionWordInsideAPhraseIsAWarning() { + #expect(check(.correction(heard: "in de", wrote: "indeed")).contains { $0.level == .warning }) + } + + @Test func gluedFormThatIsARealWordIsAWarning() { + let w = check(.correction(heard: "some thing", wrote: "Something")) + #expect(w.contains { $0.level == .warning && $0.message.contains("something") }) + } + + @Test func phraseOfCommonWordsOnlyGetsANote() { + let w = check(.correction(heard: "cloud code", wrote: "Claude Code")) + #expect(w.count == 1) + #expect(w[0].level == .note) + } + + @Test func distinctiveEntriesGetNoWarnings() { + #expect(check(.correction(heard: "versel", wrote: "Vercel")).isEmpty) + #expect(check(.term("Supabase")).isEmpty) + } + + @Test func duplicateSourceIsAnErrorEvenWithDifferentSpelling() { + let others = [pair("cloud code", "Claude Code")] + let w = check(.correction(heard: "Cloud-Code", wrote: "X"), others: others) + #expect(w.first?.level == .error) + } + + @Test func termThatIsARealWordGetsANoteAboutBiasOnly() { + let w = check(.term("Reef")) + #expect(w.count == 1) + #expect(w[0].level == .note) + } + + @Test func emptySourceIsAnError() { + #expect(check(.term(" ")).first?.level == .error) + } + + @Test func warningsAreSortedMostSevereFirst() { + let w = check(.correction(heard: "cloud", wrote: "Claude"), others: [pair("cloud", "x")]) + #expect(w.map(\.level) == w.map(\.level).sorted(by: >)) + } +} diff --git a/Tests/SstDataTests/Fakes.swift b/Tests/SstDataTests/Fakes.swift new file mode 100644 index 0000000..b93dedd --- /dev/null +++ b/Tests/SstDataTests/Fakes.swift @@ -0,0 +1,13 @@ +import Foundation +@testable import SstData + +/// Deterministic stand-in for the macOS spell checker. +struct FakeKnowledge: WordKnowledge { + var words: Set = ["cloud", "code", "some", "thing", "something", "reef", "slack", "in", "de", "the", "of"] + var functionWords: Set = ["in", "de", "the", "of"] + func isWord(_ word: String) -> Bool { words.contains(word.lowercased()) } + func isFunctionWord(_ word: String) -> Bool { functionWords.contains(word.lowercased()) } +} + +func term(_ text: String) -> DictionaryEntry { DictionaryEntry(kind: .term(text)) } +func pair(_ heard: String, _ wrote: String) -> DictionaryEntry { DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)) } diff --git a/Tests/SstDataTests/HistoryTests.swift b/Tests/SstDataTests/HistoryTests.swift new file mode 100644 index 0000000..2571c8c --- /dev/null +++ b/Tests/SstDataTests/HistoryTests.swift @@ -0,0 +1,148 @@ +import Foundation +import Testing +import SstCore +@testable import SstData + +@MainActor +struct HistoryStoreTests { + func tempFile() -> URL { + FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/history.json") + } + + func entry(_ text: String, at seconds: TimeInterval) -> HistoryEntry { + HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + seconds), duration: 2, rawText: text, finalText: text) + } + + @Test func newestEntryComesFirstAndSurvivesARoundTrip() { + let url = tempFile() + let store = HistoryStore(fileURL: url) + store.add(entry("eerste", at: 0)) + store.add(HistoryEntry( + date: Date(timeIntervalSince1970: 1_700_000_100), duration: 3, rawText: "cloud code", finalText: "Claude Code", + corrections: [AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 0, length: 11)])) + store.flush() + + let reloaded = HistoryStore(fileURL: url) + #expect(reloaded.load() == .loaded) + #expect(reloaded.entries.map(\.finalText) == ["Claude Code", "eerste"]) + #expect(reloaded.entries == store.entries) + } + + @Test func missingFileIsEmpty() { + let store = HistoryStore(fileURL: tempFile()) + #expect(store.load() == .empty) + #expect(store.entries.isEmpty) + } + + @Test func removeAndRemoveAll() { + let store = HistoryStore(fileURL: tempFile()) + let a = entry("a", at: 0), b = entry("b", at: 1) + store.add(a); store.add(b) + store.remove(id: a.id) + #expect(store.entries.map(\.finalText) == ["b"]) + store.removeAll() + #expect(store.entries.isEmpty) + } + + @Test func corruptFileIsMovedAsideAndStoreStartsFresh() throws { + let url = tempFile() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "dit is geen json".write(to: url, atomically: true, encoding: .utf8) + let store = HistoryStore(fileURL: url) + guard case .recoveredCorrupt(let backup) = store.load() else { + Issue.record("expected recoveredCorrupt"); return + } + #expect(FileManager.default.fileExists(atPath: backup.path)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(store.entries.isEmpty) + } + + @Test func loadNotifiesOnChange() { + let url = tempFile() + let writer = HistoryStore(fileURL: url) + writer.add(entry("a", at: 0)); writer.add(entry("b", at: 1)) + writer.flush() + + let store = HistoryStore(fileURL: url) + var calls = 0 + store.onChange = { calls += 1 } + #expect(store.load() == .loaded) + #expect(calls == 1) + #expect(store.entries.count == 2) + } + + @Test func unreadableFileIsReportedAndNeverOverwritten() throws { + let url = tempFile() + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) // history.json is a directory + let store = HistoryStore(fileURL: url) + guard case .unreadable = store.load() else { + Issue.record("expected unreadable"); return + } + store.add(entry("a", at: 0)) + store.flush() + var isDirectory: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory)) + #expect(isDirectory.boolValue) + #expect(store.lastError != nil) + } + + @Test func onChangeFiresForEdits() { + let store = HistoryStore(fileURL: tempFile()) + var calls = 0 + store.onChange = { calls += 1 } + store.add(entry("a", at: 0)) + store.removeAll() + #expect(calls == 2) + } + + @Test func entryFromDictationResultKeepsEverything() { + let result = DictationResult( + date: Date(timeIntervalSince1970: 1_700_000_000), duration: 4.5, rawText: "raw", finalText: "final", + corrections: [AppliedCorrection(heard: "a", wrote: "b", rule: "a", start: 0, length: 1)]) + let e = HistoryEntry(result: result) + #expect(e.rawText == "raw" && e.finalText == "final" && e.duration == 4.5 && e.corrections.count == 1) + } +} + +struct HistorySearchTests { + let entries = [ + HistoryEntry(date: .now, duration: 1, rawText: "ik ga naar het café", finalText: "Ik ga naar het Café"), + HistoryEntry(date: .now, duration: 1, rawText: "cloud code is handig", finalText: "Claude Code is handig"), + ] + + @Test func emptyQueryReturnsEverything() { + #expect(HistorySearch.filter(entries, query: " ").count == 2) + } + + @Test func ignoresCaseAndDiacritics() { + #expect(HistorySearch.filter(entries, query: "CAFE").count == 1) + #expect(HistorySearch.filter(entries, query: "café").count == 1) + } + + @Test func allWordsMustMatchInAnyOrder() { + #expect(HistorySearch.filter(entries, query: "handig claude").count == 1) + #expect(HistorySearch.filter(entries, query: "handig café").isEmpty) + } + + @Test func searchesTheOriginalTextToo() { + #expect(HistorySearch.filter(entries, query: "cloud").count == 1) + } +} + +struct HistoryGroupingTests { + @Test func groupsByDayKeepingOrder() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + func at(_ day: Int, _ hour: Int) -> Date { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))! + } + let entries = [at(2, 15), at(2, 9), at(1, 20)].map { + HistoryEntry(date: $0, duration: 1, rawText: "x", finalText: "x") + } + let days = HistoryGrouping.byDay(entries, calendar: calendar) + #expect(days.count == 2) + #expect(days[0].entries.count == 2) + #expect(days[1].entries.count == 1) + #expect(days[0].day > days[1].day) + } +} diff --git a/Tests/SstDataTests/NoteTests.swift b/Tests/SstDataTests/NoteTests.swift new file mode 100644 index 0000000..187824b --- /dev/null +++ b/Tests/SstDataTests/NoteTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import SstData + +@MainActor +struct NoteStoreTests { + private func temp() -> URL { + FileManager.default.temporaryDirectory.appending(path: "note-\(UUID().uuidString)/note.txt") + } + + @Test func missingFileLoadsEmpty() { + let store = NoteStore(fileURL: temp()) + store.load() + #expect(store.text == "") + #expect(!store.isReadOnly) + } + + @Test func flushWritesAndAnotherStoreReadsIt() { + let url = temp() + let store = NoteStore(fileURL: url) + store.text = "eerste regel\nté" + store.flush() + let other = NoteStore(fileURL: url) + other.load() + #expect(other.text == "eerste regel\nté") + } + + @Test func editsAreSavedAfterTheDebounce() async throws { + let url = temp() + let store = NoteStore(fileURL: url) + store.text = "bewaard" + #expect(!FileManager.default.fileExists(atPath: url.path)) + try await Task.sleep(for: .milliseconds(900)) + #expect(try String(contentsOf: url, encoding: .utf8) == "bewaard") + } + + @Test func loadingDoesNotTriggerASave() async throws { + let url = temp() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "oud".write(to: url, atomically: true, encoding: .utf8) + let store = NoteStore(fileURL: url) + store.load() + #expect(store.text == "oud") + try FileManager.default.removeItem(at: url) + try await Task.sleep(for: .milliseconds(900)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + } + + @Test func unreadableFileIsNeverOverwritten() throws { + let url = temp() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data([0xFF, 0xFE, 0xFD]).write(to: url) + let store = NoteStore(fileURL: url) + store.load() + #expect(store.isReadOnly) + store.text = "nieuw" + store.flush() + #expect(try Data(contentsOf: url) == Data([0xFF, 0xFE, 0xFD])) + } +} + +struct DictationSpacingTests { + @Test func noSpaceAtTheStartOrAfterWhitespace() { + #expect(DictationSpacing.prepared("hallo", characterBefore: nil) == "hallo") + #expect(DictationSpacing.prepared("hallo", characterBefore: " ") == "hallo") + #expect(DictationSpacing.prepared("hallo", characterBefore: "\n") == "hallo") + } + + @Test func spaceAfterAWordOrSentenceEnd() { + #expect(DictationSpacing.prepared("hallo", characterBefore: "k") == " hallo") + #expect(DictationSpacing.prepared("Nog een.", characterBefore: ".") == " Nog een.") + } + + @Test func noSpaceBeforeClosingPunctuationOrLeadingWhitespace() { + #expect(DictationSpacing.prepared(", dus", characterBefore: "k") == ", dus") + #expect(DictationSpacing.prepared(" dus", characterBefore: "k") == " dus") + } + + @Test func emptyTextStaysEmpty() { + #expect(DictationSpacing.prepared("", characterBefore: "k") == "") + } +} diff --git a/Tests/SstDataTests/SystemWordKnowledgeTests.swift b/Tests/SstDataTests/SystemWordKnowledgeTests.swift new file mode 100644 index 0000000..4a42015 --- /dev/null +++ b/Tests/SstDataTests/SystemWordKnowledgeTests.swift @@ -0,0 +1,24 @@ +import Testing +@testable import SstData + +struct SystemWordKnowledgeTests { + let knowledge = SystemWordKnowledge() + + @Test func recognisesEnglishAndDutchWords() { + #expect(knowledge.isWord("cloud")) + #expect(knowledge.isWord("Cloud")) + #expect(knowledge.isWord("vandaag")) + } + + @Test func doesNotTreatProductNamesAsWords() { + #expect(!knowledge.isWord("vercel")) + #expect(!knowledge.isWord("supabase")) + #expect(!knowledge.isWord("anthropic")) + } + + @Test func functionWordsComeFromTheBuiltInList() { + #expect(knowledge.isFunctionWord("de")) + #expect(knowledge.isFunctionWord("The")) + #expect(!knowledge.isFunctionWord("vercel")) + } +} diff --git a/Tests/SstDataTests/ViewSupportTests.swift b/Tests/SstDataTests/ViewSupportTests.swift new file mode 100644 index 0000000..47f8e3a --- /dev/null +++ b/Tests/SstDataTests/ViewSupportTests.swift @@ -0,0 +1,229 @@ +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 daysAreFilledAfterTheStoreLoads() { + let url = FileManager.default.temporaryDirectory.appending(path: "sst-vm-\(UUID().uuidString)/h.json") + let writer = HistoryStore(fileURL: url) + writer.add(HistoryEntry(date: .now, duration: 1, rawText: "x", finalText: "x")) + writer.flush() + let store = HistoryStore(fileURL: url) + let model = HistoryViewModel(store: store) + store.onChange = { model.refresh() } + #expect(model.days.isEmpty) + store.load() + #expect(!model.days.isEmpty) + #expect(model.totalCount == 1) + } + + @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 externalChangeClearsUndo() throws { + let (model, store) = try makeModel("Supabase\n") + model.add(.term("Vercel")) + #expect(model.canUndo) + model.externalChangeDetected() + #expect(!model.canUndo) + model.undo() + #expect(store.entries == [term("Supabase"), term("Vercel")]) + } + + @Test func failedUndoKeepsTheSnapshot() throws { + let (model, store) = try makeModel("Supabase\n") + model.add(.term("Vercel")) + let folder = store.fileURL.deletingLastPathComponent() + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: folder.path) + model.undo() + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: folder.path) + #expect(model.errorMessage != nil) + #expect(model.canUndo) + model.errorMessage = nil + model.undo() + #expect(store.entries == [term("Supabase")]) + #expect(!model.canUndo) + } + + @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 }) + } + + @Test func removingAnUnknownIdChangesNothing() throws { + let (model, store) = try makeModel("Supabase\n") + let before = store.entries + model.remove(id: UUID()) + #expect(store.entries == before) + #expect(model.canUndo == false) + } + + @Test func editingTheEarlierOfTwoDuplicatesShowsNoDuplicateError() throws { + let (model, store) = try makeModel("cloud code => A\nCloud-Code => B\n") + let first = store.entries[0] + let second = store.entries[1] + #expect(!model.warnings(for: first.kind, editing: first.id).contains { $0.level == .error }) + #expect(model.warnings(for: second.kind, editing: second.id).contains { $0.level == .error }) + #expect(model.warnings(for: first.kind, editing: nil).contains { $0.level == .error }) + } +} diff --git a/Tests/SstDataTests/VocabularyBuilderTests.swift b/Tests/SstDataTests/VocabularyBuilderTests.swift new file mode 100644 index 0000000..f9cd1ca --- /dev/null +++ b/Tests/SstDataTests/VocabularyBuilderTests.swift @@ -0,0 +1,19 @@ +import Testing +@testable import SstData + +struct VocabularyBuilderTests { + @Test func termsComeFirstThenCorrectionTargetsWithoutDuplicates() { + let entries = [pair("cloud code", "Claude Code"), term("Anthropic"), term("anthropic"), pair("versel", "Vercel"), term("Supabase")] + #expect(VocabularyBuilder.build(from: entries) == ["Anthropic", "Supabase", "Claude Code", "Vercel"]) + } + + @Test func listIsCapped() { + let entries = (1...40).map { term("Woord\($0)") } + #expect(VocabularyBuilder.build(from: entries).count == 24) + #expect(VocabularyBuilder.build(from: entries, maxTerms: 5).count == 5) + } + + @Test func emptyDictionaryGivesNoVocabulary() { + #expect(VocabularyBuilder.build(from: []).isEmpty) + } +} diff --git a/Tests/SstUITests/InstrumentsTests.swift b/Tests/SstUITests/InstrumentsTests.swift new file mode 100644 index 0000000..00984c0 --- /dev/null +++ b/Tests/SstUITests/InstrumentsTests.swift @@ -0,0 +1,131 @@ +import CoreGraphics +import Testing +@testable import SstUI + +struct VUScaleTests { + @Test func zeroVUIsMinusTwentyDbfs() { + #expect(VUScale.vu(fromDbfs: -20) == 0) + #expect(VUScale.vu(fromDbfs: -40) == -20) + } + + @Test func fractionSpansTheScale() { + #expect(VUScale.fraction(forVU: -20) == 0) + #expect(abs(VUScale.fraction(forVU: 3) - 1) < 1e-9) + #expect(abs(VUScale.fraction(forVU: 0) - 20.0 / 23.0) < 1e-9) + } + + @Test func fractionIsClampedAtBothEnds() { + #expect(VUScale.fraction(forVU: -60) == 0) + #expect(VUScale.fraction(forVU: 30) == 1.03) + #expect(VUScale.fraction(forDbfs: -120) == 0) + } + + @Test func angleIsSymmetricAroundVertical() { + #expect(abs(VUScale.angle(forFraction: 0) + VUScale.angle(forFraction: 1)) < 1e-9) + #expect(abs(VUScale.angle(forFraction: 0.5)) < 1e-9) + #expect(abs(VUScale.angle(forFraction: 1) - 45 * .pi / 180) < 1e-9) + } +} + +struct NeedleSpringTests { + func simulate(_ spring: NeedleSpring, seconds: Double, target: Double = 1) -> (final: Double, peak: Double) { + var spring = spring + var peak = 0.0 + let dt = 1.0 / 240 + for _ in 0.. 1.003) + #expect(result.peak < 1.03) + #expect(abs(result.final - 1) < 0.001) + } + + @Test func criticalDampingNeverOvershoots() { + let result = simulate(NeedleSpring(damping: 1), seconds: 1) + #expect(result.peak <= 1.000001) + } + + @Test func largeFrameStepsStayStable() { + var spring = NeedleSpring() + for _ in 0..<40 { spring.step(target: 1, dt: 0.05) } + #expect(abs(spring.position - 1) < 0.001) + } + + @Test func restsAtTheStartWithoutATarget() { + var spring = NeedleSpring() + for _ in 0..<60 { spring.step(target: 0, dt: 1.0 / 60) } + #expect(spring.position == 0) + } +} + +struct SegmentGlyphTests { + @Test func everyDigitHasASegmentPattern() { + for digit in "0123456789" { #expect(!SegmentGlyph.lit(for: digit).isEmpty) } + #expect(SegmentGlyph.lit(for: "8").count == 7) + #expect(SegmentGlyph.lit(for: "1") == [.b, .c]) + #expect(SegmentGlyph.lit(for: ":").isEmpty) + } + + @Test func polygonsStayInsideTheDigitBoxAndDiffer() { + var seen: [[CGPoint]] = [] + for id in SegmentID.allCases { + let polygon = SegmentGlyph.polygon(id) + #expect(polygon.count == 6) + for point in polygon { + #expect(point.x >= 0 && point.x <= Segment.digitWidth) + #expect(point.y >= 0 && point.y <= Segment.digitHeight) + } + #expect(!seen.contains(polygon)) + seen.append(polygon) + } + #expect(seen.count == 7) + } +} + +struct LEDBarTests { + @Test func tonesFollowTheEightThreeOneSplit() { + let tones = (0..<12).map { LEDBar.tone(forSegment: $0) } + #expect(tones.prefix(8).allSatisfy { $0 == .green }) + #expect(tones[8...10].allSatisfy { $0 == .amber }) + #expect(tones[11] == .red) + } + + @Test func litCountScalesAndClamps() { + #expect(LEDBar.litCount(forFraction: 0) == 0) + #expect(LEDBar.litCount(forFraction: 0.5) == 6) + #expect(LEDBar.litCount(forFraction: 1) == 12) + #expect(LEDBar.litCount(forFraction: 1.03) == 12) + #expect(LEDBar.litCount(forFraction: -1) == 0) + } +} + +struct SpokenLevelTests { + @Test func nonFiniteInputReturnsStil() { + #expect(VUScale.spokenLevel(fromDbfs: -.infinity) == "stil") + #expect(VUScale.spokenLevel(fromDbfs: .nan) == "stil") + } + + @Test func referenceLevel() { + #expect(VUScale.spokenLevel(fromDbfs: -20) == "0 VU") + } + + @Test func negativeLevel() { + #expect(VUScale.spokenLevel(fromDbfs: -30) == "-9 VU") + } + + @Test func clampedToMinimum() { + #expect(VUScale.spokenLevel(fromDbfs: -120) == "-20 VU") + } + + @Test func clampedToMaximum() { + #expect(VUScale.spokenLevel(fromDbfs: 10) == "3 VU") + } +} diff --git a/Tests/SstUITests/TokenIntegrityTests.swift b/Tests/SstUITests/TokenIntegrityTests.swift new file mode 100644 index 0000000..4e4f769 --- /dev/null +++ b/Tests/SstUITests/TokenIntegrityTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +/// Root of the package: Tests/SstUITests/ → three levels up. +private let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + +struct TokenIntegrityTests { + @Test func generatedSwiftMatchesTokensJSONAndContrastHolds() throws { + #expect( + FileManager.default.fileExists(atPath: root.appending(path: "Sources/SstUI/DesignTokens.swift").path), + "Sources/SstUI/DesignTokens.swift ontbreekt: run `python3 Design/build_tokens.py`.") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["python3", "Design/build_tokens.py", "--check"] + process.currentDirectoryURL = root + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + try process.run() + process.waitUntilExit() + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + #expect(process.terminationStatus == 0, "Run `python3 Design/build_tokens.py`.\n\(output.suffix(600))") + } + + /// Views take every colour, size and time from the tokens. A line may opt out with `// token-exempt: reason`. + @Test func viewsContainNoLooseDesignValues() throws { + let forbidden: [(name: String, pattern: String)] = [ + ("hex colour", #"0x[0-9A-Fa-f]{6}\b"#), + ("component colour", #"(NS)?Color\((red|white|hue):"#), + ("literal font size", #"\.system\(size:\s*\d"#), + ("literal corner radius", #"cornerRadius:\s*\d|\.cornerRadius\(\s*\d"#), + ("literal padding", #"\.padding\(\s*(\.\w+\s*,\s*)?\d"#), + ("literal frame size", #"(width|height|minWidth|minHeight|maxWidth|maxHeight|idealWidth|idealHeight):\s*[1-9]"#), + ("literal line width", #"lineWidth:\s*\d"#), + ("literal opacity", #"\.opacity\(\s*[0-9]"#), + ] + let skipped: Set = ["DesignTokens.swift", "DynamicColor.swift"] + var directories = [root.appending(path: "Sources/SstUI")] + let app = root.appending(path: "Sources/Sst") + if FileManager.default.fileExists(atPath: app.path) { directories.append(app) } + + var problems: [String] = [] + for directory in directories { + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "swift" && !skipped.contains($0.lastPathComponent) } + for file in files { + let lines = try String(contentsOf: file, encoding: .utf8).components(separatedBy: "\n") + for (index, line) in lines.enumerated() where !line.contains("token-exempt") { + for rule in forbidden where line.range(of: rule.pattern, options: .regularExpression) != nil { + problems.append("\(file.lastPathComponent):\(index + 1) \(rule.name): \(line.trimmingCharacters(in: .whitespaces))") + } + } + } + } + #expect(problems.isEmpty, "Loose design values:\n\(problems.joined(separator: "\n"))") + } +} diff --git a/Tests/SstWhisperTests/ModelStorageTests.swift b/Tests/SstWhisperTests/ModelStorageTests.swift new file mode 100644 index 0000000..40465ab --- /dev/null +++ b/Tests/SstWhisperTests/ModelStorageTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing +@testable import SstWhisper + +struct ModelStorageTests { + func makeStorage() -> ModelStorage { + ModelStorage(base: FileManager.default.temporaryDirectory.appending(path: "sst-models-\(UUID().uuidString)")) + } + + /// Creates `//.mlmodelc/coremldata.bin` for the given models. + func install(_ storage: ModelStorage, folder: String, models: [String] = ModelStorage.requiredModels) throws { + for model in models { + let dir = storage.repoDirectory.appending(path: "\(folder)/\(model).mlmodelc", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data([1]).write(to: dir.appending(path: "coremldata.bin")) + } + } + + @Test func emptyStorageHasNothingInstalled() { + let storage = makeStorage() + #expect(storage.folder(for: "small") == nil) + #expect(!storage.isInstalled("small")) + } + + @Test func completeInstallIsFound() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo") + #expect(storage.isInstalled("large-v3-v20240930_turbo")) + #expect(storage.folder(for: "large-v3-v20240930_turbo")?.lastPathComponent == "openai_whisper-large-v3-v20240930_turbo") + } + + @Test func incompleteInstallDoesNotCount() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-small", models: ["MelSpectrogram", "AudioEncoder"]) + #expect(!storage.isInstalled("small")) + } + + @Test func variantsAreMatchedExactly() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo_632MB") + #expect(!storage.isInstalled("large-v3-v20240930_turbo")) + #expect(storage.isInstalled("large-v3-v20240930_turbo_632MB")) + } + + @Test func deleteRemovesOnlyThatVariant() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-small") + try install(storage, folder: "openai_whisper-base") + try storage.delete("small") + #expect(!storage.isInstalled("small")) + #expect(storage.isInstalled("base")) + } + + @Test func deletingSomethingNotInstalledIsHarmless() throws { + try makeStorage().delete("small") + } + + @Test func catalogStartsWithTheDefaultAndHasUniqueIds() { + #expect(ModelCatalog.all.first?.id == ModelCatalog.defaultVariant) + #expect(Set(ModelCatalog.all.map(\.id)).count == ModelCatalog.all.count) + #expect(ModelCatalog.all.allSatisfy { $0.approxSizeMB > 0 }) + } +} diff --git a/Tests/SstWhisperTests/PromptTokensTests.swift b/Tests/SstWhisperTests/PromptTokensTests.swift new file mode 100644 index 0000000..1ac2169 --- /dev/null +++ b/Tests/SstWhisperTests/PromptTokensTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import SstWhisper + +struct PromptTokensTests { + /// Fake tokenizer: one token (id 7) per word, plus one special token (id 2000) that must be filtered out. + let encode: (String) -> [Int] = { text in + text.split(whereSeparator: { !$0.isLetter }).map { _ in 7 } + [2000] + } + + @Test func allTermsFitAndSpecialTokensAreFiltered() { + let tokens = WhisperKitTranscriber.promptTokens( + vocabulary: ["Anthropic", "Vercel", "Supabase"], maxTokens: 64, specialTokenBegin: 1000, encode: encode) + #expect(tokens == [7, 7, 7]) + } + + @Test func termsThatDoNotFitAreDroppedFromTheEnd() { + let vocab = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] + let tokens = WhisperKitTranscriber.promptTokens( + vocabulary: vocab, maxTokens: 4, specialTokenBegin: 1000, encode: encode) + #expect(tokens?.count == 4) + } + + @Test func emptyVocabularyGivesNoPrompt() { + #expect(WhisperKitTranscriber.promptTokens( + vocabulary: [], maxTokens: 64, specialTokenBegin: 1000, encode: encode) == nil) + } + + @Test func firstTermAloneTooLongGivesNoPrompt() { + #expect(WhisperKitTranscriber.promptTokens( + vocabulary: ["one two three"], maxTokens: 2, specialTokenBegin: 1000, encode: encode) == nil) + } +} diff --git a/docs/design/tokens-preview.html b/docs/design/tokens-preview.html new file mode 100644 index 0000000..c9f9b10 --- /dev/null +++ b/docs/design/tokens-preview.html @@ -0,0 +1,380 @@ + + + + + +Sst Ontwerp-tokens + + + + + +
+ +
+
+
Ontwerpsysteem · Sst
+

Ontwerp-tokens

+

+
+
+
Ter goedkeuring
+
+
Bron · Design/tokens.json
+
+
+ +
+
01Instrumenten in gebruikmeter.* · motion.needle.* · segment.*
+
+
Afwerking · Aluminiumlicht
+
Afwerking · Graphitedonker
+
+
+ +
+
02Kleurcolor.*
+
+
+ +
+
03Diepte en materiaalshadow.* · border.* · texture.*
+
+
+
+
+
+ +
+
04Typografiefont.* · type.*
+
+
+ +
+
05Ruimte, hoeken, randenspace.* · radius.* · border.*
+
+
Ruimte (pt)
+
+
Hoekradius
+
+
+ +
+
06Bewegingmotion.*
+
+
+
Toets en lampje
+
+

Toets: 50 ms in, 90 ms uit, zonder veer. Lampje: 30 ms aan, 200 ms uit — dooft als een gloeidraad.

+
+
+
Wijzer-ballistiek
+
+

Veer: 300 ms tot 99 %, demping 0,80 → ongeveer 1,5 % overschot. De meters bij 01 gebruiken dezelfde waarden.

+
+
+
+ +
+
07Wat het niet is
+
    +
  • Geen neon, vaporwave, synthwave, paars-roze verlopen, gloeiende tekst, chromen letters of rasterhorizonten.
  • +
  • Eén accent: het rode opnamelampje. Amber en groen alleen als niveau-indicator of markering, nooit als versiering.
  • +
  • Lampjes mogen een kleine gloed hebben. Tekst nooit.
  • +
  • Geen eenmalige waarden in componenten: elke afmeting, kleur en tijd komt uit deze tokens.
  • +
+
+
+ + + + diff --git a/docs/design/tokens.md b/docs/design/tokens.md new file mode 100644 index 0000000..6f0937c --- /dev/null +++ b/docs/design/tokens.md @@ -0,0 +1,296 @@ +# Sst Design Tokens — v0.1.0-draft + +**Status:** Ter goedkeuring — nog niet gebruikt door de app + +Draagbare veldrecorder / cassettedeck uit de jaren 80 (Sony TC-D5, Marantz PMD, Nakamichi, Braun). Twee afwerkingen: ALUMINIUM (licht) en GRAPHITE (donker). Eén accent: het rode opnamelampje. Amber en groen alleen voor niveau-indicatoren. + +Bron: `Design/tokens.json`. Dit bestand en `tokens-preview.html` worden gegenereerd met `python3 Design/build_tokens.py`; pas nooit alleen deze bestanden aan. + +## Kleur + +Verwijs ernaar als `color.`. Kolommen: ALUMINIUM (licht) en GRAPHITE (donker). + +| Token | Aluminium | Graphite | Gebruik | +|---|---|---|---| +| `color.surface.chassis` | `#CBC8C0` | `#1C1B19` | Venster-achtergrond; het 'huis' van het apparaat | +| `color.surface.metalHigh` | `#E3E1DB` | `#3B3A36` | Geborsteld metaal, bovenkant van de verloop | +| `color.surface.metalLow` | `#BDBAB2` | `#262522` | Geborsteld metaal, onderkant van de verloop | +| `color.surface.panel` | `#E2DFD5` | `#2B2A27` | Frontplaat van mat plastic; zijbalk en bedieningspanelen | +| `color.surface.well` | `#B4B1A8` | `#131211` | Verzonken vlak: invoervelden, display-vensters | +| `color.surface.paper` | `#EEE9D9` | `#232220` | Cassette-label crème: lijsten met transcripties en woordenboek | +| `color.surface.keyHigh` | `#E6E3DA` | `#44433F` | Toetsdop, bovenkant | +| `color.surface.keyLow` | `#CBC8BF` | `#33322F` | Toetsdop, onderkant | +| `color.surface.keyPressed` | `#BEBBB2` | `#2A2927` | Ingedrukte toets | +| `color.surface.meterFace` | `#EFE6C8` | `#D8CEA6` | Wijzerplaat van de VU-meter (blijft crème in beide afwerkingen) | +| `color.surface.selection` | `#D4CDB2` | `#3A3828` | Geselecteerde rij in een lijst | +| `color.surface.display` | `#1B1A18` | `#0E0D0C` | Displayglas van de teller; donker in beide afwerkingen | +| `color.display.segmentUnlit` | `#3A3320` | `#2A2415` | Gedoofd segment op het displayglas | +| `color.ink.primary` | `#1E1D1B` | `#ECE8DC` | Hoofdtekst en wijzer | +| `color.ink.secondary` | `#403D38` | `#C4BFB2` | Secundaire tekst | +| `color.ink.tertiary` | `#4D4A44` | `#ABA79B` | Zeefdruk-labels en hulptekst | +| `color.ink.disabled` | `#8F8B82` | `#6B685F` | Uitgeschakeld; nooit voor essentiële tekst | +| `color.ink.inverse` | `#F4EFE3` | `#F4EFE3` | Tekst op rode of donkere vulling; crème in beide afwerkingen | +| `color.ink.onMeter` | `#1E1D1B` | `#1E1D1B` | Schaal, cijfers en wijzer op de wijzerplaat; vast donker in beide afwerkingen | +| `color.accent.record` | `#C8281E` | `#CC2A20` | ENIGE accent: opnamelampje, REC-toets, piek-LED. Nooit voor lopende tekst | +| `color.accent.recordUnlit` | `#7A2E28` | `#4A1F1B` | Lampje uit | +| `color.level.green` | `#5E9E48` | `#6DB556` | Niveau-segment, brandend | +| `color.level.amber` | `#D9A023` | `#E5B032` | Niveau-segment, waarschuwing, gecorrigeerd woord | +| `color.level.greenUnlit` | `#A8B79C` | `#26331F` | Groen segment, uit | +| `color.level.amberUnlit` | `#CDBA8A` | `#3D3418` | Amber segment, uit | +| `color.line.seamDark` | `#8C8981` | `#0A0A09` | Naad tussen panelen, donkere helft | +| `color.line.seamLight` | `#F3F1EB` | `#45433F` | Naad tussen panelen, lichte helft | +| `color.line.hairline` | `#A9A69D` | `#3A3834` | Fijne scheidingslijn, zeefdruk-kader | +| `color.line.focus` | `#1E1D1B` | `#ECE8DC` | Toetsenbord-focus | + +**Regel:** `accent.record` is het enige accent en wordt nooit voor lopende tekst gebruikt. Amber en groen zijn alleen voor niveau-indicatoren en markeringen. + +### Contrast (berekend) + +| Tekst | Op | Afwerking | Verhouding | | +|---|---|---|---|---| +| `ink.primary` | `surface.chassis` | light | 10.08:1 | ok | +| `ink.primary` | `surface.chassis` | dark | 14.05:1 | ok | +| `ink.primary` | `surface.panel` | light | 12.63:1 | ok | +| `ink.primary` | `surface.panel` | dark | 11.72:1 | ok | +| `ink.primary` | `surface.paper` | light | 13.87:1 | ok | +| `ink.primary` | `surface.paper` | dark | 12.98:1 | ok | +| `ink.primary` | `surface.well` | light | 7.86:1 | ok | +| `ink.primary` | `surface.well` | dark | 15.27:1 | ok | +| `ink.primary` | `surface.selection` | light | 10.57:1 | ok | +| `ink.primary` | `surface.selection` | dark | 9.65:1 | ok | +| `ink.onMeter` | `surface.meterFace` | light | 13.49:1 | ok | +| `ink.onMeter` | `surface.meterFace` | dark | 10.67:1 | ok | +| `ink.primary` | `surface.keyHigh` | light | 13.13:1 | ok | +| `ink.primary` | `surface.keyHigh` | dark | 8.08:1 | ok | +| `ink.primary` | `surface.keyLow` | light | 10.07:1 | ok | +| `ink.primary` | `surface.keyLow` | dark | 10.47:1 | ok | +| `ink.secondary` | `surface.chassis` | light | 6.47:1 | ok | +| `ink.secondary` | `surface.chassis` | dark | 9.38:1 | ok | +| `ink.secondary` | `surface.panel` | light | 8.11:1 | ok | +| `ink.secondary` | `surface.panel` | dark | 7.82:1 | ok | +| `ink.secondary` | `surface.paper` | light | 8.90:1 | ok | +| `ink.secondary` | `surface.paper` | dark | 8.66:1 | ok | +| `ink.tertiary` | `surface.chassis` | light | 5.28:1 | ok | +| `ink.tertiary` | `surface.chassis` | dark | 7.16:1 | ok | +| `ink.tertiary` | `surface.panel` | light | 6.62:1 | ok | +| `ink.tertiary` | `surface.panel` | dark | 5.97:1 | ok | +| `ink.tertiary` | `surface.paper` | light | 7.27:1 | ok | +| `ink.tertiary` | `surface.paper` | dark | 6.61:1 | ok | +| `ink.tertiary` | `surface.metalHigh` | light | 6.75:1 | ok | +| `ink.tertiary` | `surface.metalHigh` | dark | 4.73:1 | ok | +| `ink.tertiary` | `surface.metalLow` | light | 4.55:1 | ok | +| `ink.tertiary` | `surface.metalLow` | dark | 6.37:1 | ok | +| `ink.inverse` | `accent.record` | light | 4.84:1 | ok | +| `ink.inverse` | `accent.record` | dark | 4.67:1 | ok | +| `level.amber` | `surface.display` | light | 7.46:1 | ok | +| `level.amber` | `surface.display` | dark | 9.79:1 | ok | + +## Opaciteit + +| Token | Waarde | Gebruik | +|---|---|---| +| `opacity.mark` | 0.3 | Amber markering achter een gecorrigeerd woord | +| `opacity.disabled` | 0.45 | Uitgeschakelde bediening | +| `opacity.unlit` | 1.0 | Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie | +| `opacity.pressedRecord` | 0.85 | Ingedrukte REC-toets: het rood dooft licht | +| `opacity.specularOn` | 0.6 | Lichtvlekje op een brandend lampje | +| `opacity.specularOff` | 0.3 | Lichtvlekje op een gedoofd lampje | +| `opacity.caption` | 0.75 | Kleine opschriften op de wijzerplaat | + +## Lettertypen + +| Token | Familie | Terugval | Gebruik | +|---|---|---|---| +| `font.family.chrome` | Helvetica Neue | Helvetica, Arial | Zeefdruk, toetsen, koppen: neutrale grotesk | +| `font.family.text` | SF Pro Text | system-ui | Leesbare tekst: transcripties, woordenboek | +| `font.family.mono` | SF Mono | Menlo, monospace | Teller, tijden, woordenboekbestand | + +## Tekststijlen + +| Token | Familie | Grootte | Gewicht | Spatiëring | Regelhoogte | Hoofdletters | Gebruik | +|---|---|---|---|---|---|---|---| +| `type.label` | chrome | 10 pt | 500 | 0.09 em | 12 pt | ja | Zeefdruk-label | +| `type.labelSmall` | chrome | 9 pt | 500 | 0.1 em | 11 pt | ja | Schaalmarkeringen en eenheden | +| `type.control` | chrome | 11 pt | 500 | 0.06 em | 14 pt | ja | Opschrift op toetsen | +| `type.heading` | chrome | 12 pt | 500 | 0.12 em | 16 pt | ja | Paneelkop | +| `type.body` | text | 13 pt | 400 | 0 em | 18 pt | nee | Standaardtekst | +| `type.reading` | text | 15 pt | 400 | 0 em | 22 pt | nee | Transcript | +| `type.meta` | text | 11 pt | 400 | 0 em | 14 pt | nee | Bijschriften | +| `type.counter` | mono | 22 pt | 500 | 0 em | 26 pt | nee | Teller | +| `type.counterSmall` | mono | 12 pt | 500 | 0 em | 16 pt | nee | Tijdstip in een rij | +| `type.code` | mono | 12 pt | 400 | 0 em | 18 pt | nee | Woordenboekregels | + +## Segmentcijfers (getekend, geen lettertype) + +| Token | Waarde | Gebruik | +|---|---|---| +| `segment.digitHeight` | 36 | Hoogte van een getekend segmentcijfer | +| `segment.digitWidth` | 20 | Breedte van een segmentcijfer | +| `segment.thickness` | 4 | Dikte van één segment | +| `segment.gap` | 1 | Ruimte tussen segmenten | +| `segment.slant` | 0 | Schuinte in graden; 0 = rechtop | +| `segment.advance` | 26 | Afstand van cijfer tot cijfer | +| `segment.colonWidth` | 10 | Breedte van een dubbele punt of punt | + +## Ruimte + +| Token | Waarde | +|---|---| +| `space.0` | 0 pt | +| `space.1` | 2 pt | +| `space.2` | 4 pt | +| `space.3` | 8 pt | +| `space.4` | 12 pt | +| `space.5` | 16 pt | +| `space.6` | 24 pt | +| `space.7` | 32 pt | +| `space.8` | 48 pt | + +## Indeling + +| Token | Waarde | Gebruik | +|---|---|---| +| `layout.window.minWidth` | 800 | Minimale vensterbreedte | +| `layout.window.minHeight` | 560 | Minimale vensterhoogte | +| `layout.window.defaultWidth` | 1080 | Standaard vensterbreedte | +| `layout.window.defaultHeight` | 700 | Standaard vensterhoogte | +| `layout.sidebar.width` | 232 | Breedte zijbalk | +| `layout.settings.width` | 560 | Breedte instellingenvenster | +| `layout.panel.padding` | 16 | Binnenmarge van een paneel (= space.5) | +| `layout.panel.gap` | 12 | Afstand tussen panelen (= space.4) | + +## Afmetingen + +| Token | Waarde | Gebruik | +|---|---|---| +| `size.control.height` | 28 | Standaardhoogte van toets of veld | +| `size.key.minWidth` | 56 | Minimale breedte van een toets | +| `size.key.recWidth` | 64 | REC-toets, breedte | +| `size.key.recHeight` | 44 | REC-toets, hoogte | +| `size.lamp.diameter` | 10 | Standaard lampje | +| `size.lamp.large` | 14 | Opnamelampje | +| `size.switch.width` | 40 | Tuimelschakelaar, breedte | +| `size.switch.height` | 22 | Tuimelschakelaar, hoogte | +| `size.row.height` | 40 | Rij in transcriptielijst | +| `size.icon.small` | 16 | Klein pictogram | +| `size.icon.medium` | 20 | Normaal pictogram | +| `size.hit.min` | 24 | Minimaal klikdoel | + +## Hoekradius + +| Token | Waarde | Gebruik | +|---|---|---| +| `radius.none` | 0 | Panelen en velden: scherpe randen | +| `radius.sharp` | 1 | Toetsranden: bijna scherp | +| `radius.sm` | 2 | Toetsen, velden | +| `radius.md` | 4 | Grote panelen | +| `radius.bezel` | 6 | Rand van de VU-meter | +| `radius.lamp` | 999 | Lampjes: rond | + +## Randen + +| Token | Waarde | Gebruik | +|---|---|---| +| `border.hairline` | 1 pt | Fijne lijn | +| `border.seam` | 2 pt | Naad: 1 donkere lijn boven, 1 lichte lijn eronder, leest als gegraveerde groef | +| `border.bezel` | 3 pt | Rand om de VU-meter | +| `border.focus` | 2 pt | Focusring, 2 pt offset | + +## Schaduw en diepte + +### `shadow.key.raised` + +Toets in rust: lichtvang boven, donkere onderrand, valschaduw + +| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite | +|---|---|---|---|---|---|---|---|---| +| 1 | ja | 0 | 1 | 0 | 0 | `#FFFFFF` | 0.65 | 0.1 | +| 2 | ja | 0 | -1 | 0 | 0 | `#000000` | 0.25 | 0.55 | +| 3 | nee | 0 | 2 | 2 | 0 | `#000000` | 0.35 | 0.6 | + +### `shadow.key.pressed` + +Ingedrukte toets: dop 1 pt omlaag, geen valschaduw Dop verschuift 1 pt omlaag. + +| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite | +|---|---|---|---|---|---|---|---|---| +| 1 | ja | 0 | 2 | 4 | 0 | `#000000` | 0.5 | 0.7 | +| 2 | ja | 0 | 0 | 0 | 1 | `#000000` | 0.35 | 0.6 | + +### `shadow.well` + +Verzonken vlak met lichtvang op de onderlip + +| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite | +|---|---|---|---|---|---|---|---|---| +| 1 | ja | 0 | 1 | 3 | 0 | `#000000` | 0.4 | 0.7 | +| 2 | nee | 0 | 1 | 0 | 0 | `#FFFFFF` | 0.55 | 0.07 | + +### `shadow.panel` + +Frontplaat boven het chassis + +| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite | +|---|---|---|---|---|---|---|---|---| +| 1 | ja | 0 | 1 | 0 | 0 | `#FFFFFF` | 0.6 | 0.08 | +| 2 | nee | 0 | 3 | 8 | 0 | `#000000` | 0.22 | 0.55 | + +### `shadow.lamp.on` + +Brandend lampje: kleine gloed, alleen voor lampjes en nooit voor tekst + +| Laag | Inset | x | y | blur | spread | kleur | alpha aluminium | alpha graphite | +|---|---|---|---|---|---|---|---|---| +| 1 | nee | 0 | 0 | 6 | 1 | `#CC2A20` | 0.45 | 0.55 | +| 2 | ja | 0 | -1 | 1 | 0 | `#000000` | 0.35 | 0.45 | + +## Textuur + +**`texture.brushed`** — Geborsteld aluminium voor chassis en zijbalk. Richting horizontaal; korrel 0.035 (aluminium) / 0.05 (graphite); surface.metalHigh naar surface.metalLow, van boven naar beneden. + +## Meters + +| Token | Waarde | Gebruik | +|---|---|---| +| `meter.vu.referenceDbfs` | -20 | 0 VU = -20 dBFS (RMS) | +| `meter.vu.min` | -20 | Onderkant van de schaal in VU | +| `meter.vu.max` | 3 | Bovenkant van de schaal in VU; vanaf 0 VU rood | +| `meter.vu.arcDegrees` | 90 | Hoek tussen minimale en maximale stand | +| `meter.vu.width` | 260 | Breedte van de meter | +| `meter.vu.height` | 150 | Hoogte van de meter | +| `meter.vu.needleWidth` | 1.5 | Dikte van de wijzer | +| `meter.vu.ticks` | -20 -10 -7 -5 -3 0 +3 | Genummerde schaalmarkeringen | +| `meter.vu.pivotOffset` | 22 | Draaipunt van de wijzer, onder de onderrand van de wijzerplaat | +| `meter.vu.scaleRadius` | 142 | Straal van de schaalboog vanaf het draaipunt | +| `meter.vu.needleLength` | 146 | Lengte van de wijzer | +| `meter.vu.tickMajor` | 11 | Lengte van een genummerde streep | +| `meter.vu.tickMinor` | 6 | Lengte van een gewone streep | +| `meter.vu.labelInset` | 24 | Afstand van de schaalboog tot de cijfers | +| `meter.vu.redBandWidth` | 5 | Dikte van de rode band vanaf 0 VU | +| `meter.led.segments` | 12 | Aantal segmenten: 8 groen, 3 amber, 1 rood | +| `meter.led.segmentSize` | 6x14 | Segment in pt (breedte x hoogte) | +| `meter.led.gap` | 2 | Ruimte tussen segmenten | +| `meter.peak.holdMs` | 1000 | Piek-LED blijft zo lang branden | + +## Beweging + +| Token | Waarde | Eenheid | Gebruik | +|---|---|---|---| +| `motion.duration.instant` | 0 | ms | Geen overgang | +| `motion.duration.fast` | 60 | ms | Toets in/uit | +| `motion.duration.base` | 120 | ms | Standaardovergang | +| `motion.duration.slow` | 220 | ms | Paneel of venster | +| `motion.easing.mechanical` | cubic-bezier(0.2, 0, 0, 1) | | Toetsen: direct, zonder veer | +| `motion.easing.standard` | cubic-bezier(0.4, 0, 0.2, 1) | | Overige overgangen | +| `motion.key.press` | 50 | ms | Toets indrukken | +| `motion.key.release` | 90 | ms | Toets loslaten | +| `motion.lamp.on` | 30 | ms | Lampje aan | +| `motion.lamp.off` | 200 | ms | Lampje uit: dooft langzaam, als een gloeidraad | +| `motion.needle.response` | 0.3 | s | VU-ballistiek: 300 ms om de stand te bereiken | +| `motion.needle.damping` | 0.8 | ratio | Veerdemping; geeft ongeveer 1,5 % overschot | +| `motion.needle.updateHz` | 60 | Hz | Verversing van de wijzer | +| `motion.reducedMotion` | Overgangen 0 ms; wijzer blijft bewegen maar met demping 1.0 (geen overschot) | | Systeeminstelling 'Verminder beweging' | + +## Wat bewust ontbreekt + +Geen neon, vaporwave, synthwave, paars-roze verlopen, gloeiende tekst, chromen letters of rasterhorizonten. Lampjes mogen een kleine gloed hebben (`shadow.lamp.on`); tekst nooit. diff --git a/docs/superpowers/plans/2026-10-01-app-0-overview.md b/docs/superpowers/plans/2026-10-01-app-0-overview.md new file mode 100644 index 0000000..7b7650c --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-0-overview.md @@ -0,0 +1,38 @@ +# Sst als volwaardige Mac-app — overzicht van de uitvoeringsplannen + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` · **Tokens:** `docs/design/tokens.md` (bron: `Design/tokens.json`) + +De spec is opgesplitst in vijf plannen. Elk levert op zichzelf werkende, geteste software op en bouwt voort op de vorige. Voer ze **in deze volgorde** uit, op één branch (`feat/macos-app`), bij voorkeur met `superpowers:subagent-driven-development`. + +| # | Plan | Levert | Tests daarna (per testtarget) | +|---|---|---|---| +| 1 | `2026-10-01-app-1-engine-hooks.md` | vier additieve raakpunten in de engine: woordenlijst voor de transcriber, niveau-uitvoer, correctie + resultaat-hook, instelbare sneltoets | SstCoreTests 41 · SstWhisperTests 4 | +| 2 | `2026-10-01-app-2-data.md` | `SstData`: woordenboekbestand, correctieronde, waarschuwingen, voorrangslijst, geschiedenis met zoeken | + SstDataTests 54 | +| 3 | `2026-10-01-app-3-ui-kit.md` | tokens als Swift (gegenereerd), componenten, galerij | + SstUITests 15 | +| 4 | `2026-10-01-app-4-shell.md` | echte Mac-app: Dock, menu's, Instellingen, menubalkitem, icoon, ondertekende bundel | SstCoreTests 48 · SstDataTests 56 · SstWhisperTests 11 | +| 5 | `2026-10-01-app-5-main-window.md` | hoofdvenster: deck, zijbalk, geschiedenis, woordenboek | SstDataTests 73 (totaal 147 tests) | + +## Hoe het plan is gecontroleerd + +De code in de plannen is niet geraden: elk plan is samengesteld uit bestanden die in een wegwerp-kopie van de repo zijn gecompileerd en getest (alle fasen samen: 147 tests groen, app bouwt, snapshots van het venster bekeken in beide afwerkingen, bundel gebouwd en ondertekend). Bewerkingen aan bestaande bestanden zijn scripts met asserts die bewezen dezelfde eindtoestand opleveren. Onderweg zijn zo ook fouten in de eerste versie van de plannen gevonden en hersteld (ontbrekende `import`, een te strenge "gewoon woord"-regel, een ontbrekend omgevingsobject, een niet-bestaand-map-bij-voorbeeldgegevens). + +## Gedeelde namen (de interface tussen de plannen) + +| Waar | Naam | +|---|---| +| `SstCore` | `Transcriber.transcribe(_:vocabulary:)`, `AppliedCorrection`, `CorrectionResult`, `TextCorrecting`, `DictationResult`, `DictationController(corrector:vocabularyProvider:)`, `onResult`, `HotkeyBinding`, `AudioRecorder.onLevel`, `AudioLevel`, `GatedInserter`, `SwappableTranscriber`, `LevelSource` | +| `SstData` | `DictionaryFile`, `DictionaryEntry`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistoryStore`, `HistorySearch`, `DictionaryStore`, `DictionaryCorrector`, `AppSettings`, `HistoryViewModel`, `DictionaryViewModel`, `CorrectionMarkup`, `StopwatchFormat` | +| `SstWhisper` | `WhisperKitTranscriber(model:storage:)`, `ModelStorage`, `ModelCatalog`, `ModelOption` | +| `SstUI` | `Palette`, `Space`, `Radius`, `Border`, `Size`, `Layout`, `Opacity`, `Typography`, `Shadows`, `Motion`, `MeterTokens`, `Segment`; `PhysicalSurface`, `PanelBackground`, `WellBackground`, `DisplayBackground`, `PaperBackground`, `SeamDivider`, `SilkLabel`, `BrushedBackground`, `KeyButtonStyle`, `LampView`, `RockerToggleStyle`, `VUMeterView`, `SegmentCounterView`, `LEDBarView` | +| `Sst` | `AppModel`, `Strings`, `AppIdentity`, `PermissionMonitor`, scenes en views | + +## Uitvoeringsadvies + +- Taken met volledige code in het plan (fase 1–3, grote delen van 4 en 5) zijn transcriptie plus testen: een goedkoop model volstaat voor de implementer, een standaardmodel voor de reviewer. +- Visuele taken (fase 3 taak 6, fase 4 taak 7–8, fase 5 taak 3–6) hebben de gebruiker nodig voor het oordeel; de handmatige acceptatietaken zijn daarom bewust niet voor subagents. +- Fase 1 taak 5 stap 6 en fase 3 taak 6 vragen om een microfoon, Fn-toets of het scherm van de gebruiker. +- Na fase 4 moet het oude ad-hoc ondertekende `Sst` eenmalig zijn rechten opnieuw krijgen (`tccutil reset …`, door de gebruiker uit te voeren; staat in het plan). + +## Bewust buiten scope (zie spec) + +Overlay-pill, AI-cleanup, streaming, meerdere woordenboeken, import/export van de geschiedenis, notarisatie, DMG, updates, automatisch starten bij inloggen, toggle-modus, definitieve naam en merk. diff --git a/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md b/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md new file mode 100644 index 0000000..484720b --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-1-engine-hooks.md @@ -0,0 +1,1102 @@ +# App fase 1 — Engine-raakpunten 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:** De engine krijgt vier additieve uitbreidingen (woordenlijst voor de transcriber, niveau-uitvoer, resultaat-hook met correctie, instelbare sneltoets) zonder dat bestaand gedrag verandert. + +**Architecture:** Alle wijzigingen zijn additief met standaardwaarden die het huidige gedrag behouden. Nieuwe pure logica (`PromptEcho`, `AudioLevel`, `HotkeyBinding`, `ModifierKeyTracker`, `promptTokens`) staat los van hardware en wordt met unit-tests gedekt; de hardware-afhankelijke stukken (`AudioRecorder`, `HotkeyMonitor`, WhisperKit) zijn dun en worden handmatig gecontroleerd. + +**Tech Stack:** Swift 6.4 (language mode 5), Swift Testing, AVFoundation, CoreGraphics, WhisperKit 0.18. + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "Engine-raakpunten", "Woordenboek — mechanisme 1") + +## Global Constraints + +- Bestaande tests (13 stuks) blijven ongewijzigd groen; bestaande testbestanden worden alleen *aangevuld* (`Fakes.swift`) of krijgen er een nieuw bestand naast. +- Nieuwe parameters hebben standaardwaarden die het huidige gedrag behouden. +- Vocabulaire: maximaal 64 tokens in de prompt; prompt alleen bij opnames met RMS ≥ 0,015; stiltefilter blijft 0,005. +- Prompt-echo: resultaat dat uitsluitend uit de eerste *k* ≥ 2 termen van de prompt bestaat wordt verworpen; één term alleen niet. +- Niveau: `onLevel` levert RMS in dBFS; 0 VU = −20 dBFS. +- Toegestane sneltoetsen (exact): Fn (keycode 63), Rechter Option (61), Rechter Command (54), Rechter Control (62), Linker Control (59). +- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). + +## Bestandsstructuur + +``` +Sources/SstCore/Models.swift (wijzig) Transcriber + AppliedCorrection, CorrectionResult, TextCorrecting, DictationResult +Sources/SstCore/PromptEcho.swift (nieuw) +Sources/SstCore/DictationController.swift(vervang) corrector, onResult, vocabularyProvider +Sources/SstCore/HotkeyBinding.swift (nieuw) +Sources/SstCore/ModifierKeyTracker.swift (nieuw) +Sources/SstCore/HotkeyMonitor.swift (vervang) binding +Sources/SstCore/AudioLevel.swift (nieuw) +Sources/SstCore/AudioRecorder.swift (wijzig) onLevel +Sources/SstWhisper/WhisperKitTranscriber.swift (wijzig) vocabulary → promptTokens +Sources/SstProbe/RecordProbe.swift, TranscribeProbe.swift (wijzig) handmatige controle +Package.swift (wijzig) SstWhisperTests +Tests/SstCoreTests/Fakes.swift (aanvullen) +Tests/SstCoreTests/TranscriberVocabularyTests.swift, PromptEchoTests.swift, + DictationControllerHooksTests.swift, HotkeyBindingTests.swift, + ModifierKeyTrackerTests.swift, AudioLevelTests.swift (nieuw) +Tests/SstWhisperTests/PromptTokensTests.swift (nieuw) +``` + +--- + +### Task 1: Gedeelde typen en woordenlijst op `Transcriber` + +**Files:** +- Modify: `Sources/SstCore/Models.swift` +- Test: `Tests/SstCoreTests/TranscriberVocabularyTests.swift` + +**Interfaces:** +- Produces (gebruikt door alle latere fasen): + - `Transcriber.transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String` (protocol-eis, met standaardimplementatie die `transcribe(_:)` aanroept) + - `struct AppliedCorrection: Equatable, Codable, Sendable { heard, wrote, rule: String; start, length: Int }` met publieke init `init(heard:wrote:rule:start:length:)` + - `struct CorrectionResult: Equatable, Sendable { text: String; corrections: [AppliedCorrection] }` met publieke init `init(text:corrections:)` + - `protocol TextCorrecting { func correct(_ text: String) -> CorrectionResult }` + - `struct DictationResult: Equatable, Sendable { date: Date; duration: TimeInterval; rawText: String; finalText: String; corrections: [AppliedCorrection] }` met publieke init in die volgorde + +- [ ] **Step 1: Schrijf de falende test** + +`Tests/SstCoreTests/TranscriberVocabularyTests.swift`: + +```swift +import Foundation +import Testing +@testable import SstCore + +private struct PlainTranscriber: Transcriber { + func transcribe(_ samples: [Float]) async throws -> String { "plain \(samples.count)" } +} + +struct TranscriberVocabularyTests { + @Test func vocabularyVariantFallsBackToPlainTranscribe() async throws { + let result = try await PlainTranscriber().transcribe([0, 0, 0], vocabulary: ["Anthropic"]) + #expect(result == "plain 3") + } + + @Test func appliedCorrectionRoundTripsThroughJSON() throws { + let original = AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 12, length: 11) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(AppliedCorrection.self, from: data) + #expect(decoded == original) + } +} +``` + +- [ ] **Step 2: Draai de test, moet falen** + +Run: `swift test --filter TranscriberVocabularyTests` +Expected: compilatiefout `extra argument 'vocabulary' in call` / `cannot find 'AppliedCorrection' in scope`. + +- [ ] **Step 3: Implementatie** + +In `Sources/SstCore/Models.swift` vervang het blok + +```swift +public protocol Transcriber { + func transcribe(_ samples: [Float]) async throws -> String +} +``` + +door: + +```swift +public protocol Transcriber { + func transcribe(_ samples: [Float]) async throws -> String + /// Biases decoding towards `vocabulary`. Engines without bias support ignore the list. + func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String +} + +public extension Transcriber { + func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + try await transcribe(samples) + } +} + +/// One replacement made by the dictionary correction pass. +/// `start`/`length` are UTF-16 offsets into the corrected text. +public struct AppliedCorrection: Equatable, Codable, Sendable { + public var heard: String + public var wrote: String + public var rule: String + public var start: Int + public var length: Int + + public init(heard: String, wrote: String, rule: String, start: Int, length: Int) { + self.heard = heard + self.wrote = wrote + self.rule = rule + self.start = start + self.length = length + } +} + +public struct CorrectionResult: Equatable, Sendable { + public var text: String + public var corrections: [AppliedCorrection] + + public init(text: String, corrections: [AppliedCorrection]) { + self.text = text + self.corrections = corrections + } +} + +public protocol TextCorrecting { + func correct(_ text: String) -> CorrectionResult +} + +/// A finished dictation, emitted just before the text is inserted. +public struct DictationResult: Equatable, Sendable { + public var date: Date + public var duration: TimeInterval + public var rawText: String + public var finalText: String + public var corrections: [AppliedCorrection] + + public init(date: Date, duration: TimeInterval, rawText: String, finalText: String, corrections: [AppliedCorrection]) { + self.date = date + self.duration = duration + self.rawText = rawText + self.finalText = finalText + self.corrections = corrections + } +} +``` + +- [ ] **Step 4: Draai alle tests** + +Run: `swift test` +Expected: alle tests PASS (13 bestaande + 2 nieuwe). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstCore/Models.swift Tests/SstCoreTests/TranscriberVocabularyTests.swift +git commit -m "feat: add vocabulary-aware Transcriber and shared correction/result types" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: `DictationController` — voorrang, echo-filter, correctie en resultaat-hook + +**Files:** +- Create: `Sources/SstCore/PromptEcho.swift` +- Replace: `Sources/SstCore/DictationController.swift` +- Modify: `Tests/SstCoreTests/Fakes.swift` (aanvullen onderaan) +- Test: `Tests/SstCoreTests/PromptEchoTests.swift`, `Tests/SstCoreTests/DictationControllerHooksTests.swift` + +**Interfaces:** +- Consumes: Task 1 (`Transcriber.transcribe(_:vocabulary:)`, `TextCorrecting`, `CorrectionResult`, `DictationResult`). +- Produces: + - `PromptEcho.isEcho(_ text: String, vocabulary: [String]) -> Bool` + - `DictationController.init(recorder:transcriber:cleaner:inserter:minimumSamples:silenceThreshold:corrector:vocabularyProvider:biasMinimumRMS:)` — de nieuwe parameters zijn `corrector: TextCorrecting? = nil`, `vocabularyProvider: @escaping () -> [String] = { [] }`, `biasMinimumRMS: Float = 0.015` + - `DictationController.onResult: ((DictationResult) -> Void)?` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstCoreTests/PromptEchoTests.swift`: + +```swift +import Testing +@testable import SstCore + +struct PromptEchoTests { + let vocabulary = ["Anthropic", "Vercel", "Supabase"] + + @Test func firstTwoTermsAreAnEcho() { + #expect(PromptEcho.isEcho("Anthropic, Vercel.", vocabulary: vocabulary)) + } + + @Test func wholeListIsAnEcho() { + #expect(PromptEcho.isEcho("anthropic vercel supabase", vocabulary: vocabulary)) + } + + @Test func singleTermIsRealSpeech() { + #expect(!PromptEcho.isEcho("Anthropic", vocabulary: vocabulary)) + } + + @Test func termsOutOfOrderAreNotAnEcho() { + #expect(!PromptEcho.isEcho("Vercel, Anthropic", vocabulary: vocabulary)) + } + + @Test func sentenceContainingTermsIsNotAnEcho() { + #expect(!PromptEcho.isEcho("Ik gebruik Anthropic en Vercel", vocabulary: vocabulary)) + } + + @Test func tooShortVocabularyNeverEchoes() { + #expect(!PromptEcho.isEcho("Anthropic", vocabulary: ["Anthropic"])) + #expect(!PromptEcho.isEcho("", vocabulary: vocabulary)) + } +} +``` + +Voeg onderaan `Tests/SstCoreTests/Fakes.swift` toe: + +```swift + +final class FakeVocabularyTranscriber: Transcriber { + var result = "hallo wereld" + private(set) var vocabularies: [[String]] = [] + + func transcribe(_ samples: [Float]) async throws -> String { result } + + func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + vocabularies.append(vocabulary) + return result + } +} + +struct UppercaseCorrector: TextCorrecting { + func correct(_ text: String) -> CorrectionResult { + let upper = text.uppercased() + return CorrectionResult( + text: upper, + corrections: [AppliedCorrection(heard: text, wrote: upper, rule: "upper", start: 0, length: upper.utf16.count)] + ) + } +} +``` + +`Tests/SstCoreTests/DictationControllerHooksTests.swift`: + +```swift +import Foundation +import Testing +@testable import SstCore + +@MainActor +struct DictationControllerHooksTests { + let recorder = FakeRecorder() + let transcriber = FakeVocabularyTranscriber() + let inserter = FakeInserter() + static let loud = [Float](repeating: 0.1, count: 16_000) + static let quiet = [Float](repeating: 0.01, count: 16_000) + + func makeController( + cleaner: Cleaner = FakeCleaner(), + corrector: TextCorrecting? = nil, + vocabulary: [String] = [] + ) -> DictationController { + DictationController( + recorder: recorder, + transcriber: transcriber, + cleaner: cleaner, + inserter: inserter, + corrector: corrector, + vocabularyProvider: { vocabulary } + ) + } + + func dictate(_ controller: DictationController) async { + await controller.handle(.pressed) + await controller.handle(.released) + } + + @Test func loudRecordingPassesVocabularyToTranscriber() async { + recorder.samples = Self.loud + await dictate(makeController(vocabulary: ["Anthropic", "Vercel"])) + #expect(transcriber.vocabularies == [["Anthropic", "Vercel"]]) + } + + @Test func quietRecordingSkipsVocabularyButStillTranscribes() async { + recorder.samples = Self.quiet // RMS 0.01: above silence (0.005), below bias minimum (0.015) + await dictate(makeController(vocabulary: ["Anthropic", "Vercel"])) + #expect(transcriber.vocabularies == [[]]) + #expect(inserter.inserted == ["[hallo wereld]"]) + } + + @Test func promptEchoIsDiscarded() async { + recorder.samples = Self.loud + transcriber.result = "Anthropic, Vercel." + let controller = makeController(vocabulary: ["Anthropic", "Vercel", "Supabase"]) + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + + await dictate(controller) + + #expect(inserter.inserted.isEmpty) + #expect(results.isEmpty) + #expect(controller.state == .idle) + } + + @Test func singleVocabularyTermIsKept() async { + recorder.samples = Self.loud + transcriber.result = "Anthropic" + await dictate(makeController(vocabulary: ["Anthropic", "Vercel", "Supabase"])) + #expect(inserter.inserted == ["[Anthropic]"]) + } + + @Test func correctorRunsBeforeCleanerAndCorrectionsSurviveAPassthroughCleaner() async { + recorder.samples = Self.loud + transcriber.result = "hallo" + let controller = makeController(cleaner: PassthroughCleaner(), corrector: UppercaseCorrector()) + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + + await dictate(controller) + + #expect(inserter.inserted == ["HALLO"]) + #expect(results.count == 1) + #expect(results[0].rawText == "hallo") + #expect(results[0].finalText == "HALLO") + #expect(results[0].corrections.count == 1) + } + + @Test func correctionsAreDroppedWhenTheCleanerChangesTheText() async { + recorder.samples = Self.loud + transcriber.result = "hallo" + let controller = makeController(cleaner: FakeCleaner(), corrector: UppercaseCorrector()) + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + + await dictate(controller) + + #expect(results[0].finalText == "[HALLO]") + #expect(results[0].corrections.isEmpty) + } + + @Test func resultIsReportedEvenWhenInsertionFails() async { + recorder.samples = Self.loud + inserter.error = TestError() + let controller = makeController() + var results: [DictationResult] = [] + var errors: [String] = [] + controller.onResult = { results.append($0) } + controller.onError = { errors.append($0) } + + await dictate(controller) + + #expect(results.count == 1) + #expect(errors.count == 1) + } + + @Test func resultCarriesDurationAndDate() async { + recorder.samples = Self.loud // 16 000 samples = 1 s + let controller = makeController() + var results: [DictationResult] = [] + controller.onResult = { results.append($0) } + let before = Date() + + await dictate(controller) + + #expect(results[0].duration == 1.0) + #expect(results[0].date >= before) + #expect(results[0].corrections.isEmpty) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "PromptEchoTests|DictationControllerHooksTests"` +Expected: compilatiefouten (`cannot find 'PromptEcho'`, `extra argument 'corrector'`). + +- [ ] **Step 3: Implementatie** + +`Sources/SstCore/PromptEcho.swift`: + +```swift +import Foundation + +/// Whisper sometimes answers a vocabulary prompt with the prompt itself when there is little speech. +public enum PromptEcho { + static func normalize(_ text: String) -> String { + String(text.lowercased().map { $0.isLetter || $0.isNumber ? $0 : " " }) + .split(separator: " ") + .joined(separator: " ") + } + + /// True when `text` is nothing but the first k ≥ 2 terms of the prompt. + /// A single term on its own is valid speech ("Anthropic"). + public static func isEcho(_ text: String, vocabulary: [String]) -> Bool { + guard vocabulary.count >= 2 else { return false } + let normalized = normalize(text) + guard !normalized.isEmpty else { return false } + return (2...vocabulary.count).contains { k in + normalize(vocabulary.prefix(k).joined(separator: " ")) == normalized + } + } +} +``` + +Vervang `Sources/SstCore/DictationController.swift` volledig door: + +```swift +import Foundation + +@MainActor +public final class DictationController { + public private(set) var state: DictationState = .idle { + didSet { onStateChange?(state) } + } + public var onStateChange: ((DictationState) -> Void)? + public var onError: ((String) -> Void)? + /// Called with every finished transcription, just before it is inserted. + public var onResult: ((DictationResult) -> Void)? + + private let recorder: AudioRecording + private let transcriber: Transcriber + private let cleaner: Cleaner + private let inserter: TextInserting + private let minimumSamples: Int + private let silenceThreshold: Float + private let corrector: TextCorrecting? + private let vocabularyProvider: () -> [String] + private let biasMinimumRMS: Float + + /// - Parameter minimumSamples: 16 kHz samples; default is 0.3 s. + /// - Parameter silenceThreshold: RMS below which a recording counts as silence and is skipped. + /// - Parameter corrector: optional dictionary correction applied to the transcript. + /// - Parameter vocabularyProvider: words to bias the transcriber towards. + /// - Parameter biasMinimumRMS: below this RMS no vocabulary is passed (guards against invented text on quiet audio). + public init( + recorder: AudioRecording, + transcriber: Transcriber, + cleaner: Cleaner, + inserter: TextInserting, + minimumSamples: Int = 4_800, + silenceThreshold: Float = 0.005, + corrector: TextCorrecting? = nil, + vocabularyProvider: @escaping () -> [String] = { [] }, + biasMinimumRMS: Float = 0.015 + ) { + self.recorder = recorder + self.transcriber = transcriber + self.cleaner = cleaner + self.inserter = inserter + self.minimumSamples = minimumSamples + self.silenceThreshold = silenceThreshold + self.corrector = corrector + self.vocabularyProvider = vocabularyProvider + self.biasMinimumRMS = biasMinimumRMS + } + + public func handle(_ event: HotkeyEvent) async { + switch (event, state) { + case (.pressed, .idle): + do { + try recorder.start() + state = .recording + } catch { + onError?(error.localizedDescription) + } + case (.released, .recording): + let samples = recorder.stop() + guard samples.count >= minimumSamples else { + state = .idle + return + } + let energy = samples.reduce(0) { $0 + $1 * $1 } + let rms = (energy / Float(samples.count)).squareRoot() + guard rms >= silenceThreshold else { + state = .idle + return + } + await process(samples, rms: rms) + default: + break + } + } + + private func process(_ samples: [Float], rms: Float) async { + defer { state = .idle } + do { + state = .transcribing + let vocabulary = rms >= biasMinimumRMS ? vocabularyProvider() : [] + let raw = try await transcriber.transcribe(samples, vocabulary: vocabulary) + let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !PromptEcho.isEcho(text, vocabulary: vocabulary) else { return } + let corrected = corrector?.correct(text) ?? CorrectionResult(text: text, corrections: []) + let cleaned = try await cleaner.clean(corrected.text, context: CleaningContext()) + onResult?(DictationResult( + date: Date(), + duration: Double(samples.count) / 16_000, + rawText: text, + finalText: cleaned, + // Offsets only stay valid if the cleaner left the corrected text alone. + corrections: cleaned == corrected.text ? corrected.corrections : [] + )) + state = .inserting + try await inserter.insert(cleaned) + } catch { + onError?(error.localizedDescription) + } + } +} +``` + +- [ ] **Step 4: Draai alle tests** + +Run: `swift test` +Expected: alle tests PASS (de 13 + 2 + 6 + 8 nieuwe = 29). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstCore Tests/SstCoreTests +git commit -m "feat: controller supports vocabulary bias, prompt-echo guard, corrector and result hook" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: Instelbare sneltoets — `HotkeyBinding`, `ModifierKeyTracker`, `HotkeyMonitor` + +**Files:** +- Create: `Sources/SstCore/HotkeyBinding.swift`, `Sources/SstCore/ModifierKeyTracker.swift` +- Replace: `Sources/SstCore/HotkeyMonitor.swift` +- Test: `Tests/SstCoreTests/HotkeyBindingTests.swift`, `Tests/SstCoreTests/ModifierKeyTrackerTests.swift` + +**Interfaces:** +- Consumes: `HotkeyEvent` uit Models.swift. +- Produces: + - `struct HotkeyBinding: Hashable, Sendable, Identifiable` met `id: String`, `displayName: String`, `keyCode: Int`, `flagRawValue: UInt64`, `flag: CGEventFlags`; statics `.fn`, `.rightOption`, `.rightCommand`, `.rightControl`, `.leftControl`, `allowed: [HotkeyBinding]`, `binding(id:)`, `binding(keyCode:)` + - `struct ModifierKeyTracker { init(); mutating func process(isDown: Bool) -> HotkeyEvent? }` + - `HotkeyMonitor.binding: HotkeyBinding` (standaard `.fn`; wisselen herstelt de tracker) + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstCoreTests/HotkeyBindingTests.swift`: + +```swift +import Testing +@testable import SstCore + +struct HotkeyBindingTests { + @Test func allowedBindingsMatchTheSpec() { + #expect(HotkeyBinding.allowed.map(\.id) == ["fn", "rightOption", "rightCommand", "rightControl", "leftControl"]) + #expect(HotkeyBinding.allowed.map(\.keyCode) == [63, 61, 54, 62, 59]) + } + + @Test func idsAndKeyCodesAreUnique() { + #expect(Set(HotkeyBinding.allowed.map(\.id)).count == HotkeyBinding.allowed.count) + #expect(Set(HotkeyBinding.allowed.map(\.keyCode)).count == HotkeyBinding.allowed.count) + } + + @Test func lookupByKeyCodeAndId() { + #expect(HotkeyBinding.binding(keyCode: 61) == .rightOption) + #expect(HotkeyBinding.binding(id: "leftControl") == .leftControl) + } + + @Test func unsupportedKeysAreRejected() { + #expect(HotkeyBinding.binding(keyCode: 56) == nil) // left shift + #expect(HotkeyBinding.binding(keyCode: 57) == nil) // caps lock + #expect(HotkeyBinding.binding(id: "nope") == nil) + } + + @Test func everyBindingHasAFlagAndName() { + for binding in HotkeyBinding.allowed { + #expect(binding.flagRawValue != 0) + #expect(!binding.displayName.isEmpty) + } + } +} +``` + +`Tests/SstCoreTests/ModifierKeyTrackerTests.swift`: + +```swift +import Testing +@testable import SstCore + +struct ModifierKeyTrackerTests { + @Test func emitsPressedThenReleasedOnlyOnTransitions() { + var tracker = ModifierKeyTracker() + #expect(tracker.process(isDown: false) == nil) + #expect(tracker.process(isDown: true) == .pressed) + #expect(tracker.process(isDown: true) == nil) + #expect(tracker.process(isDown: false) == .released) + #expect(tracker.process(isDown: false) == nil) + } + + @Test func freshTrackerStartsReleased() { + var tracker = ModifierKeyTracker() + #expect(tracker.process(isDown: true) == .pressed) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "HotkeyBindingTests|ModifierKeyTrackerTests"` +Expected: compilatiefout `cannot find 'HotkeyBinding' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstCore/ModifierKeyTracker.swift`: + +```swift +public struct ModifierKeyTracker { + private var isDown = false + + public init() {} + + /// Feed the current state of the bound modifier flag; returns an event only on a transition. + public mutating func process(isDown: Bool) -> HotkeyEvent? { + guard isDown != self.isDown else { return nil } + self.isDown = isDown + return isDown ? .pressed : .released + } +} +``` + +`Sources/SstCore/HotkeyBinding.swift`: + +```swift +import CoreGraphics + +/// A modifier key that can be held to dictate. Left/right keys share a flag, so the key code decides. +public struct HotkeyBinding: Hashable, Sendable, Identifiable { + public let id: String + public let displayName: String + public let keyCode: Int + public let flagRawValue: UInt64 + + public var flag: CGEventFlags { CGEventFlags(rawValue: flagRawValue) } + + public static let fn = HotkeyBinding( + id: "fn", displayName: "Fn", keyCode: 63, flagRawValue: CGEventFlags.maskSecondaryFn.rawValue) + public static let rightOption = HotkeyBinding( + id: "rightOption", displayName: "Rechter Option", keyCode: 61, flagRawValue: CGEventFlags.maskAlternate.rawValue) + public static let rightCommand = HotkeyBinding( + id: "rightCommand", displayName: "Rechter Command", keyCode: 54, flagRawValue: CGEventFlags.maskCommand.rawValue) + public static let rightControl = HotkeyBinding( + id: "rightControl", displayName: "Rechter Control", keyCode: 62, flagRawValue: CGEventFlags.maskControl.rawValue) + public static let leftControl = HotkeyBinding( + id: "leftControl", displayName: "Linker Control", keyCode: 59, flagRawValue: CGEventFlags.maskControl.rawValue) + + public static let allowed: [HotkeyBinding] = [.fn, .rightOption, .rightCommand, .rightControl, .leftControl] + + public static func binding(id: String) -> HotkeyBinding? { allowed.first { $0.id == id } } + public static func binding(keyCode: Int) -> HotkeyBinding? { allowed.first { $0.keyCode == keyCode } } +} +``` + +Vervang `Sources/SstCore/HotkeyMonitor.swift` volledig door: + +```swift +import ApplicationServices +import CoreGraphics + +public final class HotkeyMonitor { + public enum HotkeyError: Error, LocalizedError { + case tapUnavailable + + public var errorDescription: String? { + "Kan de sneltoets niet volgen: geef Input Monitoring-rechten in Systeeminstellingen en probeer opnieuw (soms is een herstart van de app nodig)." + } + } + + public var onEvent: ((HotkeyEvent) -> Void)? + + /// The key that has to be held. Changing it resets the press tracking. + public var binding: HotkeyBinding = .fn { + didSet { tracker = ModifierKeyTracker() } + } + + private var tracker = ModifierKeyTracker() + private var tap: CFMachPort? + private var source: CFRunLoopSource? + + public init() {} + + public func start() throws { + guard tap == nil else { return } + let mask = CGEventMask(1 << CGEventType.flagsChanged.rawValue) + let callback: CGEventTapCallBack = { _, type, event, refcon in + if let refcon { + let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + monitor.handle(type: type, event: event) + } + return Unmanaged.passUnretained(event) + } + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: mask, + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) else { throw HotkeyError.tapUnavailable } + + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + CGEvent.tapEnable(tap: tap, enable: true) + self.tap = tap + self.source = source + } + + public func stop() { + if let source { CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) } + if let tap { CGEvent.tapEnable(tap: tap, enable: false) } + tap = nil + source = nil + } + + private func handle(type: CGEventType, event: CGEvent) { + switch type { + case .tapDisabledByTimeout, .tapDisabledByUserInput: + if let tap { CGEvent.tapEnable(tap: tap, enable: true) } + case .flagsChanged: + // Flags are shared between left and right keys and with arrows/F-keys: only react to the bound key itself. + guard event.getIntegerValueField(.keyboardEventKeycode) == Int64(binding.keyCode) else { return } + let isDown = event.flags.contains(binding.flag) + if let hotkeyEvent = tracker.process(isDown: isDown) { + onEvent?(hotkeyEvent) + } + default: + break + } + } +} +``` + +> `FnKeyTracker` en zijn test blijven ongewijzigd bestaan; `HotkeyMonitor` gebruikt hem niet meer. + +- [ ] **Step 4: Draai alle tests en bouw** + +Run: `swift test && swift build` +Expected: alle tests PASS; build slaagt (de app en `SstProbe` gebruiken `HotkeyMonitor()` zonder argumenten en blijven dus compileren). + +- [ ] **Step 5: Handmatige controle (met de gebruiker, na Task 5)** + +Wordt gebundeld in Task 5, stap 6. + +- [ ] **Step 6: Commit** + +```bash +git add Sources/SstCore Tests/SstCoreTests +git commit -m "feat: configurable hotkey binding and ModifierKeyTracker" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: Niveau-uitvoer — `AudioLevel` en `AudioRecorder.onLevel` + +**Files:** +- Create: `Sources/SstCore/AudioLevel.swift` +- Modify: `Sources/SstCore/AudioRecorder.swift` +- Modify: `Sources/SstProbe/RecordProbe.swift` +- Test: `Tests/SstCoreTests/AudioLevelTests.swift` + +**Interfaces:** +- Produces: + - `AudioLevel.rmsDbfs(_ samples: [Float]) -> Float` en `rmsDbfs(_ samples: UnsafeBufferPointer) -> Float` (−120 voor stilte of leeg) + - `AudioLevel.vu(fromDbfs:referenceDbfs:) -> Float` (standaard referentie −20) + - `AudioRecorder.onLevel: ((Float) -> Void)?` — RMS in dBFS per aangeleverde buffer, aangeroepen op de audio-thread; vóór `start()` instellen. + +- [ ] **Step 1: Schrijf de falende test** + +`Tests/SstCoreTests/AudioLevelTests.swift`: + +```swift +import Testing +@testable import SstCore + +struct AudioLevelTests { + @Test func constantTenthIsMinusTwentyDbfs() { + let level = AudioLevel.rmsDbfs([Float](repeating: 0.1, count: 1_000)) + #expect(abs(level - (-20)) < 0.01) + } + + @Test func fullScaleIsZeroDbfs() { + let level = AudioLevel.rmsDbfs([Float](repeating: 1.0, count: 100)) + #expect(abs(level) < 0.01) + } + + @Test func negativeSamplesCountLikePositiveOnes() { + let level = AudioLevel.rmsDbfs([Float](repeating: -0.1, count: 1_000)) + #expect(abs(level - (-20)) < 0.01) + } + + @Test func emptyAndSilentBuffersHitTheFloor() { + #expect(AudioLevel.rmsDbfs([]) == AudioLevel.silenceFloorDbfs) + #expect(AudioLevel.rmsDbfs([Float](repeating: 0, count: 64)) == AudioLevel.silenceFloorDbfs) + } + + @Test func vuIsRelativeToTheReference() { + #expect(AudioLevel.vu(fromDbfs: -20) == 0) + #expect(AudioLevel.vu(fromDbfs: -40) == -20) + #expect(AudioLevel.vu(fromDbfs: -17) == 3) + #expect(AudioLevel.vu(fromDbfs: -30, referenceDbfs: -18) == -12) + } +} +``` + +- [ ] **Step 2: Draai de test, moet falen** + +Run: `swift test --filter AudioLevelTests` +Expected: compilatiefout `cannot find 'AudioLevel' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstCore/AudioLevel.swift`: + +```swift +import Foundation + +public enum AudioLevel { + /// Reported for empty or silent buffers (log of zero would be -infinity). + public static let silenceFloorDbfs: Float = -120 + + public static func rmsDbfs(_ samples: [Float]) -> Float { + samples.withUnsafeBufferPointer { rmsDbfs($0) } + } + + public static func rmsDbfs(_ samples: UnsafeBufferPointer) -> Float { + guard !samples.isEmpty else { return silenceFloorDbfs } + var sum: Float = 0 + for sample in samples { sum += sample * sample } + let rms = (sum / Float(samples.count)).squareRoot() + guard rms > 0 else { return silenceFloorDbfs } + return max(silenceFloorDbfs, 20 * log10(rms)) + } + + /// VU reading for a dBFS level, where `referenceDbfs` is 0 VU. + public static func vu(fromDbfs dbfs: Float, referenceDbfs: Float = -20) -> Float { + dbfs - referenceDbfs + } +} +``` + +In `Sources/SstCore/AudioRecorder.swift`: + +1. Voeg onder `private var buffer: [Float] = []` toe: + +```swift + + /// RMS level (dBFS) of every delivered input buffer. Called on the audio thread; set before `start()`. + public var onLevel: ((Float) -> Void)? +``` + +2. Vervang in de tap-closure de regel `guard let self else { return }` door: + +```swift + guard let self else { return } + if let onLevel = self.onLevel, let channel = pcm.floatChannelData?[0] { + onLevel(AudioLevel.rmsDbfs(UnsafeBufferPointer(start: channel, count: Int(pcm.frameLength)))) + } +``` + +3. Vervang `bufferSize: 4096` door `bufferSize: 1024` (macOS mag een grotere buffer leveren; de meter werkt met elke grootte). + +`Sources/SstProbe/RecordProbe.swift` — vervang volledig door: + +```swift +import AVFoundation +import SstCore + +/// Records `seconds` of microphone audio; returns nil if access is denied or recording fails. +func recordSamples(seconds: Double, onLevel: ((Float) -> Void)? = nil) async -> [Float]? { + guard await AVCaptureDevice.requestAccess(for: .audio) else { + print("Microfoon-toegang geweigerd (Systeeminstellingen → Privacy → Microfoon).") + return nil + } + let recorder = AudioRecorder() + recorder.onLevel = onLevel + do { try recorder.start() } catch { + print("start mislukt:", error.localizedDescription) + return nil + } + print("Neem \(Int(seconds)) s op — praat nu") + try? await Task.sleep(for: .seconds(seconds)) + return recorder.stop() +} + +func runRecordProbe() async { + final class Levels: @unchecked Sendable { + let lock = NSLock() + var values: [Float] = [] + func add(_ v: Float) { lock.withLock { values.append(v) } } + } + let levels = Levels() + guard let samples = await recordSamples(seconds: 3, onLevel: { levels.add($0) }) else { return } + let peak = samples.map { abs($0) }.max() ?? 0 + print("samples: \(samples.count) (verwacht ≈ 48000), piek: \(peak)") + let values = levels.lock.withLock { levels.values } + if let lo = values.min(), let hi = values.max() { + print(String(format: "niveau-meldingen: %d (≈ %.0f per s), bereik %.1f … %.1f dBFS", values.count, Double(values.count) / 3, lo, hi)) + } else { + print("geen niveau-meldingen ontvangen") + } +} +``` + +- [ ] **Step 4: Draai alle tests en bouw** + +Run: `swift test && swift build` +Expected: alle tests PASS; build slaagt. + +- [ ] **Step 5: Handmatige controle (met de gebruiker, in Task 5, stap 6)** + +Hier alleen: de code compileert. + +- [ ] **Step 6: Commit** + +```bash +git add Sources Tests +git commit -m "feat: AudioLevel helper and AudioRecorder.onLevel" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: WhisperKit-voorrang — woordenlijst als `promptTokens` + +**Files:** +- Modify: `Package.swift` (testtarget `SstWhisperTests`) +- Modify: `Sources/SstWhisper/WhisperKitTranscriber.swift` +- Modify: `Sources/SstProbe/TranscribeProbe.swift` +- Test: `Tests/SstWhisperTests/PromptTokensTests.swift` + +**Interfaces:** +- Consumes: Task 1 (`Transcriber.transcribe(_:vocabulary:)`). +- Produces: `WhisperKitTranscriber.transcribe(_:vocabulary:)`; intern `static func promptTokens(vocabulary:maxTokens:specialTokenBegin:encode:) -> [Int]?`. + +- [ ] **Step 1: Testtarget toevoegen** + +In `Package.swift` voeg onder de bestaande `.testTarget(name: "SstCoreTests", …)` toe: + +```swift + .testTarget(name: "SstWhisperTests", dependencies: ["SstWhisper"]), +``` + +- [ ] **Step 2: Schrijf de falende test** + +`Tests/SstWhisperTests/PromptTokensTests.swift`: + +```swift +import Testing +@testable import SstWhisper + +struct PromptTokensTests { + /// Fake tokenizer: one token (id 7) per word, plus one special token (id 2000) that must be filtered out. + let encode: (String) -> [Int] = { text in + text.split(whereSeparator: { !$0.isLetter }).map { _ in 7 } + [2000] + } + + @Test func allTermsFitAndSpecialTokensAreFiltered() { + let tokens = WhisperKitTranscriber.promptTokens( + vocabulary: ["Anthropic", "Vercel", "Supabase"], maxTokens: 64, specialTokenBegin: 1000, encode: encode) + #expect(tokens == [7, 7, 7]) + } + + @Test func termsThatDoNotFitAreDroppedFromTheEnd() { + let vocab = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"] + let tokens = WhisperKitTranscriber.promptTokens( + vocabulary: vocab, maxTokens: 4, specialTokenBegin: 1000, encode: encode) + #expect(tokens?.count == 4) + } + + @Test func emptyVocabularyGivesNoPrompt() { + #expect(WhisperKitTranscriber.promptTokens( + vocabulary: [], maxTokens: 64, specialTokenBegin: 1000, encode: encode) == nil) + } + + @Test func firstTermAloneTooLongGivesNoPrompt() { + #expect(WhisperKitTranscriber.promptTokens( + vocabulary: ["one two three"], maxTokens: 2, specialTokenBegin: 1000, encode: encode) == nil) + } +} +``` + +- [ ] **Step 3: Draai de test, moet falen** + +Run: `swift test --filter PromptTokensTests` +Expected: compilatiefout `type 'WhisperKitTranscriber' has no member 'promptTokens'`. + +- [ ] **Step 4: Implementatie** + +In `Sources/SstWhisper/WhisperKitTranscriber.swift` vervang de hele methode `public func transcribe(_ samples: [Float]) async throws -> String { … }` (onderaan het bestand) door: + +```swift + public func transcribe(_ samples: [Float]) async throws -> String { + try await transcribe(samples, vocabulary: []) + } + + /// Terms are added one by one while the encoded prompt stays within `maxTokens`. + /// Returns nil when nothing fits or the list is empty. + static func promptTokens( + vocabulary: [String], + maxTokens: Int = 64, + specialTokenBegin: Int, + encode: (String) -> [Int] + ) -> [Int]? { + var kept: [String] = [] + var tokens: [Int] = [] + for term in vocabulary { + let candidate = kept + [term] + let encoded = encode(" " + candidate.joined(separator: ", ")).filter { $0 < specialTokenBegin } + if encoded.count > maxTokens { break } + kept = candidate + tokens = encoded + } + return tokens.isEmpty ? nil : tokens + } + + public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + guard let pipeline else { throw TranscriberError.notLoaded } + // language nil + detectLanguage: Whisper picks NL or EN per utterance. + var options = DecodingOptions( + task: .transcribe, + language: nil, + detectLanguage: true, + skipSpecialTokens: true, + withoutTimestamps: true + ) + if !vocabulary.isEmpty, let tokenizer = pipeline.tokenizer { + options.promptTokens = Self.promptTokens( + vocabulary: vocabulary, + specialTokenBegin: tokenizer.specialTokens.specialTokenBegin, + encode: { tokenizer.encode(text: $0) } + ) + } + let results = try await pipeline.transcribe(audioArray: samples, decodeOptions: options) + return results + .map(\.text) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +``` + +> Wijkt de WhisperKit-API licht af (bijvoorbeeld de plaats van `specialTokens`), pas dan alleen dit bestand aan en behoud de signatuur van `promptTokens` zodat de test geldig blijft. + +`Sources/SstProbe/TranscribeProbe.swift` — vervang regel `let text = try await transcriber.transcribe(samples)` door: + +```swift + let vocabulary = (ProcessInfo.processInfo.environment["SST_VOCAB"] ?? "") + .split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + if !vocabulary.isEmpty { print("voorrang: \(vocabulary.joined(separator: ", "))") } + let text = try await transcriber.transcribe(samples, vocabulary: vocabulary) +``` + +- [ ] **Step 5: Draai alle tests en bouw** + +Run: `swift test && swift build` +Expected: alle tests PASS (incl. 4 nieuwe in `SstWhisperTests`); build slaagt. + +- [ ] **Step 6: Handmatige controle samen met de gebruiker (hardware)** + +Doe in deze volgorde en noteer de uitkomsten in het commit-bericht van stap 7: + +1. `swift run SstProbe record` → er verschijnt een regel `niveau-meldingen: N (≈ X per s), bereik … dBFS`. Verwacht: meer dan 5 meldingen per seconde en een bereik dat bij praten duidelijk hoger ligt dan bij stilte. Noteer X (macOS levert soms ~10 per s in plaats van ~47). +2. `swift run SstProbe transcribe` — zeg een zin, noteer de tijd **zonder** voorrang. +3. `SST_VOCAB="Anthropic,Vercel,Supabase,Claude Code" swift run SstProbe transcribe` — zeg dezelfde zin met een of twee van die woorden erin ("Ik werk met Supabase en Claude Code"); noteer de tijd en of de spelling beter is. Verwacht: tijd onder 1,5 s voor 8 s spraak. Is het trager, noteer hoeveel; dat is geen blokkade, wel informatie voor de spec. +4. Zeg niets (8 s stilte) met `SST_VOCAB` gezet: het resultaat mag geen woorden uit de lijst bevatten. +5. Hotkey-controle: `swift run SstProbe hotkey` toont nog steeds `pressed`/`released` voor Fn. + +- [ ] **Step 7: Commit** + +```bash +git add Package.swift Sources Tests +git commit -m "feat: WhisperKit transcriber biases decoding with vocabulary prompt tokens" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +## Self-review + +- **Spec-dekking:** raakpunt 1 (woordenlijst → `promptTokens`, Task 1+5), 2 (niveau, Task 4), 3 (corrector + `onResult`, Task 1+2), 4 (instelbare toets, Task 3). Voorrang-regels: RMS-grens 0,015 (Task 2 test `quietRecordingSkipsVocabularyButStillTranscribes`), echo-filter (Task 2), 64 tokens (Task 5). Niet in deze fase, wel in fase 2: het opbouwen van de woordenlijst (`VocabularyBuilder`) en de correctie zelf. +- **Placeholders:** geen; de waarschuwing in Task 5 stap 4 is een gerichte terugval, geen open eind. +- **Typeconsistentie:** `AppliedCorrection(heard:wrote:rule:start:length:)`, `CorrectionResult(text:corrections:)` en `DictationResult(date:duration:rawText:finalText:corrections:)` worden in Task 2 precies zo gebruikt als in Task 1 gedefinieerd. `HotkeyMonitor.binding` gebruikt `HotkeyBinding.keyCode: Int` en `flag`. +- **Risico's:** de bufferlevering van `installTap` (Task 4, stap 6.1) en de snelheid met prompt (stap 6.3) zijn de enige onbekenden; beide worden gemeten. diff --git a/docs/superpowers/plans/2026-10-01-app-2-data.md b/docs/superpowers/plans/2026-10-01-app-2-data.md new file mode 100644 index 0000000..6db5f90 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-2-data.md @@ -0,0 +1,1413 @@ +# App fase 2 — SstData (woordenboek, correcties, geschiedenis) 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:** De gegevenslaag van de app: het woordenboek (bestand, correctieronde, waarschuwingen, voorrangslijst) en de transcriptiegeschiedenis (opslag, zoeken, groeperen), allemaal los van de interface en volledig getest. + +**Architecture:** Eén nieuw target `SstData` dat alleen van `SstCore` afhangt (voor `AppliedCorrection`, `CorrectionResult`, `TextCorrecting`, `DictationResult`). Pure logica (`DictionaryFile`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistorySearch`, `HistoryGrouping`) is hardware- en UI-vrij; bestandstoegang zit in `DictionaryStore` en `HistoryStore`. Woordkennis zit achter het protocol `WordKnowledge` zodat tests deterministisch zijn. + +**Tech Stack:** Swift 6.4 (language mode 5), Swift Testing, Foundation, NaturalLanguage, AppKit (spellingcontrole). + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "Woordenboek — het ontwerp in detail", "Geschiedenis") + +**Voorwaarde:** fase 1 (`2026-10-01-app-1-engine-hooks.md`) is afgerond: `AppliedCorrection`, `CorrectionResult`, `TextCorrecting` en `DictationResult` bestaan in `SstCore`. + +## Global Constraints + +- Dictionary-bestand: UTF-8, één invoer per regel; `#` = opmerking; `gehoord => schrijf` = correctiepaar; een regel zonder `=>` is een term. Opmerkingen, lege regels en volgorde blijven bij opslaan behouden; onleesbare regels blijven staan (`invalid`). +- Correctie: hele woorden, hoofdletter-onafhankelijk, één doorgang van links naar rechts, langste overeenkomst eerst (gelijke lengte: eerste in het bestand), vervangen tekst wordt niet opnieuw gescand, vervanging is letterlijk. Delen van een bron worden verbonden door nul of meer spaties/koppeltekens (`[\s\-‐‑–]*`). Woordgrenzen: niet direct naast een letter of cijfer (`\p{L}`, `\p{N}`). +- Een term van één woord dat een gewoon woord is (spellingcontrole of Engelse woordenlijst) krijgt géén automatische hoofdletterregel (wel voorrang bij het model). +- Voorrangslijst: eerst termen (bestandsvolgorde), dan unieke rechterkanten van paren; maximaal 24 woorden. +- Geschiedenis: JSON `{ "version": 1, "entries": [...] }`, ISO-8601 datums, nieuwste eerst, atomisch schrijven met 300 ms debounce; beschadigd bestand wordt hernoemd naar `history.corrupt-.json`. +- Zoeken: hoofdletter- en diakriet-ongevoelig; alle zoekwoorden moeten voorkomen in de uiteindelijke of oorspronkelijke tekst. +- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). + +## Bestandsstructuur + +``` +Package.swift (wijzig) target SstData + SstDataTests +Sources/SstData/WordKnowledge.swift (nieuw) WordKnowledge, EntryKey, CommonWords, SystemWordKnowledge +Sources/SstData/DictionaryFile.swift (nieuw) DictionaryEntry, DictionaryLine, DictionaryFile +Sources/SstData/CorrectionEngine.swift (nieuw) +Sources/SstData/EntryWarnings.swift (nieuw) +Sources/SstData/VocabularyBuilder.swift (nieuw) +Sources/SstData/History.swift (nieuw) HistoryEntry, HistoryStore, HistorySearch, HistoryGrouping +Sources/SstData/DictionaryStore.swift (nieuw) DictionaryStore, DictionaryCorrector +Tests/SstDataTests/Fakes.swift, DictionaryFileTests.swift, SystemWordKnowledgeTests.swift, + CorrectionEngineTests.swift, EntryWarningsTests.swift, VocabularyBuilderTests.swift, + HistoryTests.swift, DictionaryStoreTests.swift +``` + +--- + +### Task 1: Target, woordkennis en `DictionaryFile` + +**Files:** +- Modify: `Package.swift` +- Create: `Sources/SstData/WordKnowledge.swift`, `Sources/SstData/DictionaryFile.swift` +- Test: `Tests/SstDataTests/Fakes.swift`, `Tests/SstDataTests/DictionaryFileTests.swift`, `Tests/SstDataTests/SystemWordKnowledgeTests.swift` + +**Interfaces:** +- Produces (gebruikt door Task 2–5 en de latere fasen): + - `protocol WordKnowledge { func isWord(_:) -> Bool; func isFunctionWord(_:) -> Bool }` + - `final class SystemWordKnowledge: WordKnowledge` (`init()`) + - `enum EntryKey { static func parts(_:) -> [String]; static func key(_:) -> String }` (intern) + - `struct DictionaryEntry: Identifiable, Equatable` met `id`, `kind: Kind` (`.term(String)` | `.correction(heard:wrote:)`), `source`, `wrote` + - `enum DictionaryLine { entry, comment, blank, invalid }` + - `struct DictionaryFile` met `init(parsing:)`, `serialized()`, `entries`, `append(_:) -> UUID`, `update(id:kind:)`, `remove(id:)` + +- [ ] **Step 1: Targets toevoegen** + +In `Package.swift`, voeg bij `targets` toe (naast de bestaande regels): + +```swift + .target(name: "SstData", dependencies: ["SstCore"]), +``` + +en bij de testtargets: + +```swift + .testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]), +``` + +- [ ] **Step 2: Schrijf de falende tests** + +`Tests/SstDataTests/Fakes.swift`: + +```swift +import Foundation +@testable import SstData + +/// Deterministic stand-in for the macOS spell checker. +struct FakeKnowledge: WordKnowledge { + var words: Set = ["cloud", "code", "some", "thing", "something", "reef", "slack", "in", "de", "the", "of"] + var functionWords: Set = ["in", "de", "the", "of"] + func isWord(_ word: String) -> Bool { words.contains(word.lowercased()) } + func isFunctionWord(_ word: String) -> Bool { functionWords.contains(word.lowercased()) } +} + +func term(_ text: String) -> DictionaryEntry { DictionaryEntry(kind: .term(text)) } +func pair(_ heard: String, _ wrote: String) -> DictionaryEntry { DictionaryEntry(kind: .correction(heard: heard, wrote: wrote)) } +``` + +`Tests/SstDataTests/DictionaryFileTests.swift`: + +```swift +import Testing +@testable import SstData + +struct DictionaryFileTests { + let sample = "# Opmerking\nAnthropic\n\ncloud code => Claude Code\n" + + @Test func parsesTermsPairsCommentsAndBlanks() { + let file = DictionaryFile(parsing: sample) + #expect(file.lines.count == 4) + #expect(file.entries == [term("Anthropic"), pair("cloud code", "Claude Code")]) + #expect(file.lines[0] == .comment("# Opmerking")) + #expect(file.lines[2] == .blank) + } + + @Test func roundTripKeepsCommentsBlankLinesAndOrder() { + #expect(DictionaryFile(parsing: sample).serialized() == sample) + } + + @Test func normalisesSpacingAroundTheArrow() { + #expect(DictionaryFile(parsing: "a=>b\n").serialized() == "a => b\n") + #expect(DictionaryFile(parsing: " cloud code => Claude Code \n").entries == [pair("cloud code", "Claude Code")]) + } + + @Test func unreadableLinesAreKeptAsInvalid() { + let file = DictionaryFile(parsing: "oops =>\n=> nothing\nok\n") + #expect(file.lines[0] == .invalid("oops =>")) + #expect(file.lines[1] == .invalid("=> nothing")) + #expect(file.serialized() == "oops =>\n=> nothing\nok\n") + } + + @Test func handlesWindowsLineEndingsAndMissingFinalNewline() { + let file = DictionaryFile(parsing: "a\r\nb => c") + #expect(file.entries == [term("a"), pair("b", "c")]) + #expect(file.serialized() == "a\nb => c\n") + } + + @Test func emptyTextIsAValidEmptyFile() { + #expect(DictionaryFile(parsing: "").entries.isEmpty) + #expect(DictionaryFile(parsing: "").serialized() == "") + } + + @Test func appendUpdateAndRemove() { + var file = DictionaryFile(parsing: "# c\n") + let id = file.append(.term("Vercel")) + #expect(file.entries == [term("Vercel")]) + file.update(id: id, kind: .correction(heard: "versel", wrote: "Vercel")) + #expect(file.entries == [pair("versel", "Vercel")]) + file.remove(id: id) + #expect(file.entries.isEmpty) + #expect(file.serialized() == "# c\n") + } +} +``` + +`Tests/SstDataTests/SystemWordKnowledgeTests.swift`: + +```swift +import Testing +@testable import SstData + +struct SystemWordKnowledgeTests { + let knowledge = SystemWordKnowledge() + + @Test func recognisesEnglishAndDutchWords() { + #expect(knowledge.isWord("cloud")) + #expect(knowledge.isWord("Cloud")) + #expect(knowledge.isWord("vandaag")) + } + + @Test func doesNotTreatProductNamesAsWords() { + #expect(!knowledge.isWord("vercel")) + #expect(!knowledge.isWord("supabase")) + #expect(!knowledge.isWord("anthropic")) + } + + @Test func functionWordsComeFromTheBuiltInList() { + #expect(knowledge.isFunctionWord("de")) + #expect(knowledge.isFunctionWord("The")) + #expect(!knowledge.isFunctionWord("vercel")) + } +} +``` + +- [ ] **Step 3: Draai de tests, moeten falen** + +Run: `swift test --filter SstDataTests` +Expected: compilatiefouten (`cannot find 'DictionaryFile'`, `cannot find type 'WordKnowledge'`). + +- [ ] **Step 4: Implementatie** + +`Sources/SstData/WordKnowledge.swift`: + +```swift +import AppKit +import Foundation +import NaturalLanguage + +public protocol WordKnowledge { + /// A common word in Dutch or English (case-insensitive). Rare words, names and product names are not. + func isWord(_ word: String) -> Bool + /// A very frequent function word ("in", "de", "the", "of"). + func isFunctionWord(_ word: String) -> Bool +} + +/// Splitting and normalising the left-hand side of an entry, shared by the engine and the warnings. +enum EntryKey { + static func parts(_ source: String) -> [String] { + source.split(whereSeparator: { $0.isWhitespace || "-‐‑–".contains($0) }).map(String.init) + } + + /// Spacing, hyphens and case do not matter: "Cloud-Code", "cloud code" and "cloudcode" are the same source. + static func key(_ source: String) -> String { + parts(source).joined().lowercased() + } +} + +/// The most frequent Dutch and English function words. +enum CommonWords { + static let english: Set = [ + "a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at", "be", "because", "been", "but", + "by", "can", "could", "did", "do", "does", "for", "from", "had", "has", "have", "he", "her", "here", "him", + "his", "how", "i", "if", "in", "into", "is", "it", "its", "just", "me", "more", "most", "my", "no", "not", + "now", "of", "on", "one", "only", "or", "other", "our", "out", "over", "she", "so", "some", "such", "than", + "that", "the", "their", "them", "then", "there", "these", "they", "this", "those", "to", "up", "us", "very", + "was", "we", "were", "what", "when", "which", "who", "will", "with", "would", "you", "your", + ] + static let dutch: Set = [ + "aan", "al", "als", "ben", "bij", "dan", "dat", "de", "den", "der", "deze", "die", "dit", "doen", "door", + "dus", "een", "en", "er", "ga", "had", "heb", "heeft", "hem", "het", "hier", "hij", "hoe", "ik", "in", "is", + "ja", "je", "jij", "kan", "kon", "maar", "me", "mij", "mijn", "moet", "na", "naar", "niet", "nog", "nu", + "of", "om", "ons", "onze", "ook", "op", "over", "te", "ten", "ter", "toch", "toen", "tot", "u", "uit", "van", + "veel", "voor", "was", "wat", "we", "wel", "werd", "wij", "wil", "worden", "wordt", "zal", "ze", "zei", + "zich", "zij", "zijn", "zo", "zou", + ] + static let all: Set = english.union(dutch) +} + +/// English: the on-device word list of `NaturalLanguage` (common words only, so "cloud" and "reef" are in, "anthropic" and "vercel" are not). +/// Dutch has no such list, so the spell checker decides. Function words come from the built-in list. +public final class SystemWordKnowledge: WordKnowledge, @unchecked Sendable { + private let englishWords = NLEmbedding.wordEmbedding(for: .english) + + public init() {} + + public func isWord(_ word: String) -> Bool { + let lowered = word.lowercased() + guard !lowered.isEmpty else { return false } + if let englishWords { + if englishWords.contains(lowered) { return true } + } else if spellChecks(lowered, language: "en") { + return true + } + return spellChecks(lowered, language: "nl") + } + + public func isFunctionWord(_ word: String) -> Bool { + CommonWords.all.contains(word.lowercased()) + } + + private func spellChecks(_ word: String, language: String) -> Bool { + NSSpellChecker.shared.checkSpelling( + of: word, startingAt: 0, language: language, wrap: false, + inSpellDocumentWithTag: 0, wordCount: nil).location == NSNotFound + } +} +``` + +`Sources/SstData/DictionaryFile.swift`: + +```swift +import Foundation + +public struct DictionaryEntry: Identifiable, Equatable, Sendable { + public enum Kind: Equatable, Sendable { + case term(String) + case correction(heard: String, wrote: String) + } + + public let id: UUID + public var kind: Kind + + public init(id: UUID = UUID(), kind: Kind) { + self.id = id + self.kind = kind + } + + /// Equality ignores `id`: two entries with the same content are the same entry. + public static func == (lhs: DictionaryEntry, rhs: DictionaryEntry) -> Bool { lhs.kind == rhs.kind } + + /// The text the correction pass listens for (a term listens for itself). + public var source: String { + switch kind { + case .term(let term): term + case .correction(let heard, _): heard + } + } + + /// The text that ends up in the transcript. + public var wrote: String { + switch kind { + case .term(let term): term + case .correction(_, let wrote): wrote + } + } +} + +public enum DictionaryLine: Equatable, Sendable { + case entry(DictionaryEntry) + case comment(String) + case blank + case invalid(String) +} + +/// The dictionary as a list of lines, so comments, blank lines and order survive an edit. +public struct DictionaryFile: Equatable, Sendable { + public var lines: [DictionaryLine] + + public init(lines: [DictionaryLine] = []) { + self.lines = lines + } + + public init(parsing text: String) { + var raw = text.replacingOccurrences(of: "\r\n", with: "\n") + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + if raw.last == "" { raw.removeLast() } + lines = raw.map(Self.parseLine) + } + + static func parseLine(_ line: String) -> DictionaryLine { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return .blank } + if trimmed.hasPrefix("#") { return .comment(line) } + if let arrow = trimmed.range(of: "=>") { + let heard = trimmed[.. String { + let out = lines.map { line -> String in + switch line { + case .entry(let entry): + switch entry.kind { + case .term(let term): return term + case .correction(let heard, let wrote): return "\(heard) => \(wrote)" + } + case .comment(let text), .invalid(let text): return text + case .blank: return "" + } + } + return out.isEmpty ? "" : out.joined(separator: "\n") + "\n" + } + + public var entries: [DictionaryEntry] { + lines.compactMap { line in + if case .entry(let entry) = line { return entry } + return nil + } + } + + @discardableResult + public mutating func append(_ kind: DictionaryEntry.Kind) -> UUID { + let entry = DictionaryEntry(kind: kind) + lines.append(.entry(entry)) + return entry.id + } + + public mutating func update(id: UUID, kind: DictionaryEntry.Kind) { + guard let index = lines.firstIndex(where: { if case .entry(let e) = $0 { return e.id == id } else { return false } }) else { return } + lines[index] = .entry(DictionaryEntry(id: id, kind: kind)) + } + + public mutating func remove(id: UUID) { + lines.removeAll { if case .entry(let e) = $0 { return e.id == id } else { return false } } + } +} +``` + +- [ ] **Step 5: Draai de tests** + +Run: `swift test --filter SstDataTests` +Expected: alle tests PASS (7 `DictionaryFile`, 3 `SystemWordKnowledge`). + +> De Engelse woordenlijst komt uit `NLEmbedding.wordEmbedding(for: .english)`. Is die op de machine niet beschikbaar, dan valt `isWord` terug op de Engelse spellingcontrole; de tests verwachten dan dat "anthropic" óók een woord is en kunnen falen. Meld dat als BLOCKED met de uitvoer van `swift test`, wijzig de test niet stilletjes. + +- [ ] **Step 6: Commit** + +```bash +git add Package.swift Sources/SstData Tests/SstDataTests +git commit -m "feat: add SstData with DictionaryFile and word knowledge" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: `CorrectionEngine` + +**Files:** +- Create: `Sources/SstData/CorrectionEngine.swift` +- Test: `Tests/SstDataTests/CorrectionEngineTests.swift` + +**Interfaces:** +- Consumes: Task 1 (`DictionaryEntry`, `WordKnowledge`, `EntryKey`) en `SstCore.TextCorrecting`/`CorrectionResult`/`AppliedCorrection`. +- Produces: `struct CorrectionEngine: TextCorrecting` met `init(entries: [DictionaryEntry], knowledge: WordKnowledge)` en `correct(_:) -> CorrectionResult`. + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/CorrectionEngineTests.swift`: + +```swift +import Foundation +import Testing +import SstCore +@testable import SstData + +struct CorrectionEngineTests { + let knowledge = FakeKnowledge() + + func engine(_ entries: [DictionaryEntry]) -> CorrectionEngine { + CorrectionEngine(entries: entries, knowledge: knowledge) + } + + @Test func replacesAPairAndReportsIt() { + let result = engine([pair("cloud code", "Claude Code")]).correct("ik gebruik cloud code vandaag") + #expect(result.text == "ik gebruik Claude Code vandaag") + #expect(result.corrections.count == 1) + let c = result.corrections[0] + #expect(c.heard == "cloud code" && c.wrote == "Claude Code" && c.rule == "cloud code") + #expect((result.text as NSString).substring(with: NSRange(location: c.start, length: c.length)) == "Claude Code") + } + + @Test func findsGluedHyphenatedAndSpacedVariants() { + let e = engine([pair("cloud code", "Claude Code")]) + for variant in ["CloudCode", "Cloud-Code", "CLOUD CODE", "cloud\u{2011}code", "cloud code"] { + #expect(e.correct(variant).text == "Claude Code", "variant \(variant)") + } + } + + @Test func neverTouchesRealWordsThatMerelyStartTheSame() { + let e = engine([pair("cloud code", "Claude Code")]) + #expect(e.correct("Cloudflare is handig").text == "Cloudflare is handig") + #expect(e.correct("cloud codex").text == "cloud codex") + #expect(e.correct("de cloud en de code").text == "de cloud en de code") + #expect(e.correct("the cloud").corrections.isEmpty) + } + + @Test func respectsPunctuationBoundaries() { + let e = engine([pair("cloud code", "Claude Code")]) + #expect(e.correct("(cloud code)").text == "(Claude Code)") + #expect(e.correct("cloud code.").text == "Claude Code.") + #expect(e.correct("Cloud Code, Cloud Code").text == "Claude Code, Claude Code") + } + + @Test func longestMatchWins() { + let e = engine([pair("cloud code", "Claude Code"), pair("cloud code review", "Claude Code Review")]) + #expect(e.correct("cloud code review nu").text == "Claude Code Review nu") + #expect(e.correct("cloud-code-review").text == "Claude Code Review") + } + + @Test func replacedTextIsNeverRescanned() { + let e = engine([pair("a", "b"), pair("b", "c")]) + #expect(e.correct("a b").text == "b c") + } + + @Test func termsFixCasingAndSpacing() { + let e = engine([term("Anthropic"), term("Claude Code")]) + #expect(e.correct("anthropic ANTHROPIC").text == "Anthropic Anthropic") + #expect(e.correct("claudecode en claude-code").text == "Claude Code en Claude Code") + } + + @Test func termAlreadyRightIsNotReportedAsACorrection() { + let result = engine([term("Anthropic")]).correct("Anthropic bouwt Claude") + #expect(result.text == "Anthropic bouwt Claude") + #expect(result.corrections.isEmpty) + } + + @Test func singleWordTermThatIsARealWordGetsNoCasingRule() { + let e = engine([term("Reef"), term("Slack")]) + #expect(e.correct("het reef en slack").text == "het reef en slack") + } + + @Test func firstEntryWinsOnDuplicateSources() { + let e = engine([pair("cloud code", "Claude Code"), pair("Cloud-Code", "Other")]) + #expect(e.correct("cloudcode").text == "Claude Code") + } + + @Test func replacementIsLiteral() { + let e = engine([pair("price", "US$1 \\0 $1")]) + #expect(e.correct("the price").text == "the US$1 \\0 $1") + } + + @Test func handlesDiacritics() { + let e = engine([term("Café")]) + #expect(e.correct("een CAFÉ om de hoek").text == "een Café om de hoek") + } + + @Test func offsetsAreRelativeToTheCorrectedText() { + let result = engine([pair("a", "xxx"), pair("b", "y")]).correct("a b a") + #expect(result.text == "xxx y xxx") + #expect(result.corrections.map(\.start) == [0, 4, 6]) + #expect(result.corrections.map(\.length) == [3, 1, 3]) + } + + @Test func emptyDictionaryAndEmptyTextAreNoOps() { + #expect(engine([]).correct("hallo").text == "hallo") + #expect(engine([pair("a", "b")]).correct("").text == "") + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter CorrectionEngineTests` +Expected: compilatiefout `cannot find 'CorrectionEngine' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/CorrectionEngine.swift`: + +```swift +import Foundation +import SstCore + +/// Whole-word, case-insensitive, single-pass replacement. Longest match wins, replaced text is never rescanned. +public struct CorrectionEngine: TextCorrecting { + private struct Rule { + let source: String + let wrote: String + } + + private let rules: [Rule] + private let regex: NSRegularExpression? + + public init(entries: [DictionaryEntry], knowledge: WordKnowledge) { + var seen = Set() + var built: [(rule: Rule, order: Int)] = [] + for (order, entry) in entries.enumerated() { + let source = entry.source.trimmingCharacters(in: .whitespacesAndNewlines) + let key = EntryKey.key(source) + guard !key.isEmpty, !seen.contains(key) else { continue } + // A single-word term that is also a real word only biases the model; capitalising it everywhere would corrupt normal text. + if case .term(let term) = entry.kind, EntryKey.parts(term).count == 1, knowledge.isWord(term) { continue } + seen.insert(key) + built.append((Rule(source: source, wrote: entry.wrote), order)) + } + let ordered = built.sorted { a, b in + let la = a.rule.source.filter(\.isLetter).count + let lb = b.rule.source.filter(\.isLetter).count + return la != lb ? la > lb : a.order < b.order + }.map(\.rule) + rules = ordered + if ordered.isEmpty { + regex = nil + } else { + let alternatives = ordered.map { "(\(Self.pattern($0.source)))" }.joined(separator: "|") + regex = try? NSRegularExpression( + pattern: "(? String { + EntryKey.parts(source) + .map { NSRegularExpression.escapedPattern(for: $0) } + .joined(separator: "[\\s\\-‐‑–]*") + } + + public func correct(_ text: String) -> CorrectionResult { + guard let regex, !text.isEmpty else { return CorrectionResult(text: text, corrections: []) } + let ns = text as NSString + var output = "" + var outputLength = 0 + var last = 0 + var applied: [AppliedCorrection] = [] + + for match in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) { + let gap = ns.substring(with: NSRange(location: last, length: match.range.location - last)) + output += gap + outputLength += gap.utf16.count + + let heard = ns.substring(with: match.range) + let group = (1.." +``` + +--- + +### Task 3: `EntryWarnings` en `VocabularyBuilder` + +**Files:** +- Create: `Sources/SstData/EntryWarnings.swift`, `Sources/SstData/VocabularyBuilder.swift` +- Test: `Tests/SstDataTests/EntryWarningsTests.swift`, `Tests/SstDataTests/VocabularyBuilderTests.swift` + +**Interfaces:** +- Consumes: Task 1. +- Produces: + - `struct EntryWarning { level: Level (.note < .warning < .error); message: String }` + - `EntryWarnings.check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning]` (meest ernstig eerst; `others` = alle andere invoeren) + - `VocabularyBuilder.build(from: [DictionaryEntry], maxTerms: Int = 24) -> [String]` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/EntryWarningsTests.swift`: + +```swift +import Testing +@testable import SstData + +struct EntryWarningsTests { + let knowledge = FakeKnowledge() + + func check(_ kind: DictionaryEntry.Kind, others: [DictionaryEntry] = []) -> [EntryWarning] { + EntryWarnings.check(kind, among: others, knowledge: knowledge) + } + + @Test func singleCommonWordIsAWarning() { + let w = check(.correction(heard: "cloud", wrote: "Claude")) + #expect(w.contains { $0.level == .warning && $0.message.contains("cloud") }) + } + + @Test func veryShortSourceIsAWarning() { + #expect(check(.correction(heard: "ab", wrote: "AB")).contains { $0.level == .warning }) + } + + @Test func functionWordInsideAPhraseIsAWarning() { + #expect(check(.correction(heard: "in de", wrote: "indeed")).contains { $0.level == .warning }) + } + + @Test func gluedFormThatIsARealWordIsAWarning() { + let w = check(.correction(heard: "some thing", wrote: "Something")) + #expect(w.contains { $0.level == .warning && $0.message.contains("something") }) + } + + @Test func phraseOfCommonWordsOnlyGetsANote() { + let w = check(.correction(heard: "cloud code", wrote: "Claude Code")) + #expect(w.count == 1) + #expect(w[0].level == .note) + } + + @Test func distinctiveEntriesGetNoWarnings() { + #expect(check(.correction(heard: "versel", wrote: "Vercel")).isEmpty) + #expect(check(.term("Supabase")).isEmpty) + } + + @Test func duplicateSourceIsAnErrorEvenWithDifferentSpelling() { + let others = [pair("cloud code", "Claude Code")] + let w = check(.correction(heard: "Cloud-Code", wrote: "X"), others: others) + #expect(w.first?.level == .error) + } + + @Test func termThatIsARealWordGetsANoteAboutBiasOnly() { + let w = check(.term("Reef")) + #expect(w.count == 1) + #expect(w[0].level == .note) + } + + @Test func emptySourceIsAnError() { + #expect(check(.term(" ")).first?.level == .error) + } + + @Test func warningsAreSortedMostSevereFirst() { + let w = check(.correction(heard: "cloud", wrote: "Claude"), others: [pair("cloud", "x")]) + #expect(w.map(\.level) == w.map(\.level).sorted(by: >)) + } +} +``` + +`Tests/SstDataTests/VocabularyBuilderTests.swift`: + +```swift +import Testing +@testable import SstData + +struct VocabularyBuilderTests { + @Test func termsComeFirstThenCorrectionTargetsWithoutDuplicates() { + let entries = [pair("cloud code", "Claude Code"), term("Anthropic"), term("anthropic"), pair("versel", "Vercel"), term("Supabase")] + #expect(VocabularyBuilder.build(from: entries) == ["Anthropic", "Supabase", "Claude Code", "Vercel"]) + } + + @Test func listIsCapped() { + let entries = (1...40).map { term("Woord\($0)") } + #expect(VocabularyBuilder.build(from: entries).count == 24) + #expect(VocabularyBuilder.build(from: entries, maxTerms: 5).count == 5) + } + + @Test func emptyDictionaryGivesNoVocabulary() { + #expect(VocabularyBuilder.build(from: []).isEmpty) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "EntryWarningsTests|VocabularyBuilderTests"` +Expected: compilatiefouten (`cannot find 'EntryWarnings'`, `cannot find 'VocabularyBuilder'`). + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/EntryWarnings.swift`: + +```swift +import Foundation + +public struct EntryWarning: Equatable, Sendable { + public enum Level: Int, Comparable, Sendable { + case note = 0, warning, error + public static func < (a: Level, b: Level) -> Bool { a.rawValue < b.rawValue } + } + + public let level: Level + public let message: String + + public init(level: Level, message: String) { + self.level = level + self.message = message + } +} + +/// Flags dictionary entries that are likely to corrupt normal text. +public enum EntryWarnings { + /// - Parameter others: all entries except the one being checked. + public static func check(_ kind: DictionaryEntry.Kind, among others: [DictionaryEntry], knowledge: WordKnowledge) -> [EntryWarning] { + let source: String + let isCorrection: Bool + switch kind { + case .term(let term): (source, isCorrection) = (term, false) + case .correction(let heard, _): (source, isCorrection) = (heard, true) + } + + let parts = EntryKey.parts(source) + guard !parts.isEmpty else { return [EntryWarning(level: .error, message: "Leeg: vul een woord in.")] } + + var result: [EntryWarning] = [] + if others.contains(where: { EntryKey.key($0.source) == EntryKey.key(source) }) { + result.append(EntryWarning(level: .error, message: "Dubbele invoer: deze bron staat er al, de eerste telt.")) + } + + if !isCorrection { + if parts.count == 1, knowledge.isWord(source) { + result.append(EntryWarning(level: .note, message: "Gewoon woord: krijgt alleen voorrang bij het model, geen automatische hoofdletterregel.")) + } + return result.sorted { $0.level > $1.level } + } + + if parts.count == 1, knowledge.isWord(parts[0]) { + result.append(EntryWarning(level: .warning, message: "'\(parts[0])' is een gewoon woord en wordt overal in je tekst vervangen.")) + } + if source.filter(\.isLetter).count < 3 { + result.append(EntryWarning(level: .warning, message: "Erg kort (minder dan 3 letters): veel kans op ongewenste vervangingen.")) + } + if parts.count > 1 { + var flagged = false + if let common = parts.first(where: { knowledge.isFunctionWord($0) }) { + result.append(EntryWarning(level: .warning, message: "Bevat het veelvoorkomende woord '\(common)'; dit kan gewone zinnen raken.")) + flagged = true + } + let glued = parts.joined() + if knowledge.isWord(glued) { + result.append(EntryWarning(level: .warning, message: "Aaneengeschreven ('\(glued)') is een bestaand woord en wordt ook vervangen.")) + flagged = true + } + if !flagged, parts.allSatisfy({ knowledge.isWord($0) }) { + result.append(EntryWarning(level: .note, message: "Alle delen zijn gewone woorden: alleen de hele combinatie wordt vervangen.")) + } + } + return result.sorted { $0.level > $1.level } + } +} +``` + +`Sources/SstData/VocabularyBuilder.swift`: + +```swift +import Foundation + +/// The short word list handed to the speech engine as context. +public enum VocabularyBuilder { + /// Terms first (file order), then the unique right-hand sides of correction pairs; at most `maxTerms` words. + public static func build(from entries: [DictionaryEntry], maxTerms: Int = 24) -> [String] { + var out: [String] = [] + var seen = Set() + func add(_ word: String) { + let key = word.lowercased() + guard !word.isEmpty, !seen.contains(key) else { return } + seen.insert(key) + out.append(word) + } + for entry in entries { if case .term(let term) = entry.kind { add(term) } } + for entry in entries { if case .correction(_, let wrote) = entry.kind { add(wrote) } } + return Array(out.prefix(maxTerms)) + } +} +``` + +- [ ] **Step 4: Draai de tests** + +Run: `swift test --filter "EntryWarningsTests|VocabularyBuilderTests"` +Expected: 10 + 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstData Tests/SstDataTests +git commit -m "feat: add EntryWarnings and VocabularyBuilder" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: Geschiedenis — opslag, zoeken, groeperen + +**Files:** +- Create: `Sources/SstData/History.swift` +- Test: `Tests/SstDataTests/HistoryTests.swift` + +**Interfaces:** +- Consumes: `SstCore.DictationResult`, `SstCore.AppliedCorrection`. +- Produces: + - `struct HistoryEntry: Codable, Equatable, Identifiable` (`id`, `date`, `duration`, `rawText`, `finalText`, `corrections`; `init(result:)`) + - `@MainActor final class HistoryStore` met `init(fileURL:)`, `entries` (nieuwste eerst), `load() -> LoadOutcome` (`.empty | .loaded | .recoveredCorrupt(backup:)`), `add(_:)`, `remove(id:)`, `removeAll()`, `flush()`, `onChange`, `lastError` + - `HistorySearch.filter(_:query:) -> [HistoryEntry]` + - `HistoryGrouping.byDay(_:calendar:) -> [HistoryDay]` (`HistoryDay`: `day: Date`, `entries`) + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/HistoryTests.swift`: + +```swift +import Foundation +import Testing +import SstCore +@testable import SstData + +@MainActor +struct HistoryStoreTests { + func tempFile() -> URL { + FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/history.json") + } + + func entry(_ text: String, at seconds: TimeInterval) -> HistoryEntry { + HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + seconds), duration: 2, rawText: text, finalText: text) + } + + @Test func newestEntryComesFirstAndSurvivesARoundTrip() { + let url = tempFile() + let store = HistoryStore(fileURL: url) + store.add(entry("eerste", at: 0)) + store.add(HistoryEntry( + date: Date(timeIntervalSince1970: 1_700_000_100), duration: 3, rawText: "cloud code", finalText: "Claude Code", + corrections: [AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 0, length: 11)])) + store.flush() + + let reloaded = HistoryStore(fileURL: url) + #expect(reloaded.load() == .loaded) + #expect(reloaded.entries.map(\.finalText) == ["Claude Code", "eerste"]) + #expect(reloaded.entries == store.entries) + } + + @Test func missingFileIsEmpty() { + let store = HistoryStore(fileURL: tempFile()) + #expect(store.load() == .empty) + #expect(store.entries.isEmpty) + } + + @Test func removeAndRemoveAll() { + let store = HistoryStore(fileURL: tempFile()) + let a = entry("a", at: 0), b = entry("b", at: 1) + store.add(a); store.add(b) + store.remove(id: a.id) + #expect(store.entries.map(\.finalText) == ["b"]) + store.removeAll() + #expect(store.entries.isEmpty) + } + + @Test func corruptFileIsMovedAsideAndStoreStartsFresh() throws { + let url = tempFile() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "dit is geen json".write(to: url, atomically: true, encoding: .utf8) + let store = HistoryStore(fileURL: url) + guard case .recoveredCorrupt(let backup) = store.load() else { + Issue.record("expected recoveredCorrupt"); return + } + #expect(FileManager.default.fileExists(atPath: backup.path)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + #expect(store.entries.isEmpty) + } + + @Test func onChangeFiresForEdits() { + let store = HistoryStore(fileURL: tempFile()) + var calls = 0 + store.onChange = { calls += 1 } + store.add(entry("a", at: 0)) + store.removeAll() + #expect(calls == 2) + } + + @Test func entryFromDictationResultKeepsEverything() { + let result = DictationResult( + date: Date(timeIntervalSince1970: 1_700_000_000), duration: 4.5, rawText: "raw", finalText: "final", + corrections: [AppliedCorrection(heard: "a", wrote: "b", rule: "a", start: 0, length: 1)]) + let e = HistoryEntry(result: result) + #expect(e.rawText == "raw" && e.finalText == "final" && e.duration == 4.5 && e.corrections.count == 1) + } +} + +struct HistorySearchTests { + let entries = [ + HistoryEntry(date: .now, duration: 1, rawText: "ik ga naar het café", finalText: "Ik ga naar het Café"), + HistoryEntry(date: .now, duration: 1, rawText: "cloud code is handig", finalText: "Claude Code is handig"), + ] + + @Test func emptyQueryReturnsEverything() { + #expect(HistorySearch.filter(entries, query: " ").count == 2) + } + + @Test func ignoresCaseAndDiacritics() { + #expect(HistorySearch.filter(entries, query: "CAFE").count == 1) + #expect(HistorySearch.filter(entries, query: "café").count == 1) + } + + @Test func allWordsMustMatchInAnyOrder() { + #expect(HistorySearch.filter(entries, query: "handig claude").count == 1) + #expect(HistorySearch.filter(entries, query: "handig café").isEmpty) + } + + @Test func searchesTheOriginalTextToo() { + #expect(HistorySearch.filter(entries, query: "cloud").count == 1) + } +} + +struct HistoryGroupingTests { + @Test func groupsByDayKeepingOrder() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC")! + func at(_ day: Int, _ hour: Int) -> Date { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))! + } + let entries = [at(2, 15), at(2, 9), at(1, 20)].map { + HistoryEntry(date: $0, duration: 1, rawText: "x", finalText: "x") + } + let days = HistoryGrouping.byDay(entries, calendar: calendar) + #expect(days.count == 2) + #expect(days[0].entries.count == 2) + #expect(days[1].entries.count == 1) + #expect(days[0].day > days[1].day) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests"` +Expected: compilatiefout `cannot find 'HistoryStore' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/History.swift`: + +```swift +import Foundation +import SstCore + +public struct HistoryEntry: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var date: Date + public var duration: TimeInterval + public var rawText: String + public var finalText: String + public var corrections: [AppliedCorrection] + + public init( + id: UUID = UUID(), date: Date, duration: TimeInterval, + rawText: String, finalText: String, corrections: [AppliedCorrection] = [] + ) { + self.id = id + self.date = date + self.duration = duration + self.rawText = rawText + self.finalText = finalText + self.corrections = corrections + } + + public init(result: DictationResult) { + self.init( + date: result.date, duration: result.duration, rawText: result.rawText, + finalText: result.finalText, corrections: result.corrections) + } +} + +/// Newest first, stored as one JSON file; saves are debounced. +@MainActor +public final class HistoryStore { + public enum LoadOutcome: Equatable { + case empty + case loaded + case recoveredCorrupt(backup: URL) + } + + private struct Document: Codable { + var version: Int + var entries: [HistoryEntry] + } + + public private(set) var entries: [HistoryEntry] = [] + public private(set) var lastError: String? + public var onChange: (() -> Void)? + + private let fileURL: URL + private var saveTask: Task? + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + private static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + private static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } + + @discardableResult + public func load() -> LoadOutcome { + guard let data = try? Data(contentsOf: fileURL) else { + entries = [] + return .empty + } + do { + entries = try Self.makeDecoder().decode(Document.self, from: data).entries + return .loaded + } catch { + let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: ":", with: "-") + let backup = fileURL.deletingLastPathComponent().appending(path: "history.corrupt-\(stamp).json") + try? FileManager.default.moveItem(at: fileURL, to: backup) + entries = [] + return .recoveredCorrupt(backup: backup) + } + } + + public func add(_ entry: HistoryEntry) { + entries.insert(entry, at: 0) + changed() + } + + public func remove(id: UUID) { + entries.removeAll { $0.id == id } + changed() + } + + public func removeAll() { + entries = [] + changed() + } + + /// Writes immediately (also call this when the app quits). + public func flush() { + saveTask?.cancel() + saveTask = nil + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try Self.makeEncoder().encode(Document(version: 1, entries: entries)) + try data.write(to: fileURL, options: .atomic) + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func changed() { + onChange?() + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + self?.flush() + } + } +} + +public enum HistorySearch { + static func fold(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + } + + /// Every query word must occur in the final or the original text (case- and diacritic-insensitive). + public static func filter(_ entries: [HistoryEntry], query: String) -> [HistoryEntry] { + let words = fold(query).split(whereSeparator: \.isWhitespace).map(String.init) + guard !words.isEmpty else { return entries } + return entries.filter { entry in + let haystack = fold(entry.finalText + " " + entry.rawText) + return words.allSatisfy { haystack.contains($0) } + } + } +} + +public struct HistoryDay: Identifiable, Equatable, Sendable { + public let day: Date + public let entries: [HistoryEntry] + public var id: Date { day } +} + +public enum HistoryGrouping { + /// Groups newest-first entries by calendar day, keeping their order. + public static func byDay(_ entries: [HistoryEntry], calendar: Calendar = .current) -> [HistoryDay] { + var days: [HistoryDay] = [] + for entry in entries { + let start = calendar.startOfDay(for: entry.date) + if let last = days.last, last.day == start { + days[days.count - 1] = HistoryDay(day: start, entries: last.entries + [entry]) + } else { + days.append(HistoryDay(day: start, entries: [entry])) + } + } + return days + } +} +``` + +- [ ] **Step 4: Draai de tests** + +Run: `swift test --filter "HistoryStoreTests|HistorySearchTests|HistoryGroupingTests"` +Expected: 6 + 4 + 1 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstData Tests/SstDataTests +git commit -m "feat: add history store, search and day grouping" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: `DictionaryStore` en `DictionaryCorrector` + +**Files:** +- Create: `Sources/SstData/DictionaryStore.swift` +- Test: `Tests/SstDataTests/DictionaryStoreTests.swift` + +**Interfaces:** +- Consumes: Task 1 (`DictionaryFile`), Task 2 (`CorrectionEngine`). +- Produces: + - `@MainActor final class DictionaryStore` met `init(fileURL:)`, `fileURL`, `file`, `entries`, `load() throws`, `apply(_ mutate: (inout DictionaryFile) -> Void) throws`, `reloadIfChanged()`, `startWatching()`, `stopWatching()`, `onChange`, `static header` + - `final class DictionaryCorrector: TextCorrecting` met `init(knowledge:)`, `update(entries:)`, `setEnabled(_:)` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/DictionaryStoreTests.swift`: + +```swift +import Foundation +import Testing +import SstCore +@testable import SstData + +@MainActor +struct DictionaryStoreTests { + func tempFile() -> URL { + FileManager.default.temporaryDirectory.appending(path: "sst-tests-\(UUID().uuidString)/dictionary.txt") + } + + @Test func loadCreatesAFileWithAHeaderWhenMissing() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(store.entries.isEmpty) + #expect(try String(contentsOf: url, encoding: .utf8).hasPrefix("# Sst woordenboek")) + } + + @Test func applyWritesAtomicallyAndKeepsComments() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + try store.apply { $0.append(.correction(heard: "cloud code", wrote: "Claude Code")) } + let text = try String(contentsOf: url, encoding: .utf8) + #expect(text.hasPrefix("# Sst woordenboek")) + #expect(text.hasSuffix("cloud code => Claude Code\n")) + #expect(store.entries == [pair("cloud code", "Claude Code")]) + } + + @Test func externalEditIsPickedUp() throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + var changes = 0 + store.onChange = { changes += 1 } + try "Vercel\n".write(to: url, atomically: true, encoding: .utf8) + store.reloadIfChanged() + #expect(store.entries == [term("Vercel")]) + #expect(changes == 1) + store.reloadIfChanged() // unchanged text: no second notification + #expect(changes == 1) + } + + @Test func ourOwnWriteDoesNotTriggerAReload() throws { + let store = DictionaryStore(fileURL: tempFile()) + try store.load() + try store.apply { $0.append(.term("Supabase")) } + var changes = 0 + store.onChange = { changes += 1 } + store.reloadIfChanged() + #expect(changes == 0) + } + + @Test func watcherNoticesAnEditorSavingTheFile() async throws { + let url = tempFile() + let store = DictionaryStore(fileURL: url) + try store.load() + var changed = false + store.onChange = { changed = true } + store.startWatching() + try "Anthropic\n".write(to: url, atomically: true, encoding: .utf8) + for _ in 0..<40 where !changed { try await Task.sleep(for: .milliseconds(100)) } + store.stopWatching() + #expect(changed) + #expect(store.entries == [term("Anthropic")]) + } +} + +struct DictionaryCorrectorTests { + @Test func correctsWhenEnabledAndPassesThroughWhenDisabled() { + let corrector = DictionaryCorrector(knowledge: FakeKnowledge()) + corrector.update(entries: [pair("cloud code", "Claude Code")]) + #expect(corrector.correct("cloud code").text == "Claude Code") + corrector.setEnabled(false) + #expect(corrector.correct("cloud code").text == "cloud code") + corrector.setEnabled(true) + corrector.update(entries: []) + #expect(corrector.correct("cloud code").text == "cloud code") + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "DictionaryStoreTests|DictionaryCorrectorTests"` +Expected: compilatiefout `cannot find 'DictionaryStore' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/DictionaryStore.swift`: + +```swift +import Foundation +import SstCore + +/// Owns `dictionary.txt`: reads, writes atomically, and notices edits made in another editor. +@MainActor +public final class DictionaryStore { + public static let header = """ + # Sst woordenboek + # Eén invoer per regel. Een los woord is een term (voorrang bij het model). + # 'gehoord => schrijf' is een correctie, bijvoorbeeld: cloud code => Claude Code + + """ + + public let fileURL: URL + public private(set) var file = DictionaryFile() + public var onChange: (() -> Void)? + + private var lastText = "" + private var watcher: DispatchSourceFileSystemObject? + private var debounce: Task? + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + public var entries: [DictionaryEntry] { file.entries } + + /// Reads the file; creates it with a short header when it does not exist yet. + public func load() throws { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: fileURL.path) { + try Self.header.write(to: fileURL, atomically: true, encoding: .utf8) + } + let text = try String(contentsOf: fileURL, encoding: .utf8) + lastText = text + file = DictionaryFile(parsing: text) + onChange?() + } + + /// Applies an edit and writes the file straight away. + public func apply(_ mutate: (inout DictionaryFile) -> Void) throws { + var updated = file + mutate(&updated) + let text = updated.serialized() + try text.write(to: fileURL, atomically: true, encoding: .utf8) + file = updated + lastText = text + onChange?() + } + + /// Re-reads the file when its text differs from what we last wrote or read. + public func reloadIfChanged() { + guard let text = try? String(contentsOf: fileURL, encoding: .utf8), text != lastText else { return } + lastText = text + file = DictionaryFile(parsing: text) + onChange?() + } + + /// Watches the folder (editors replace files instead of editing them in place). + public func startWatching() { + guard watcher == nil else { return } + let descriptor = open(fileURL.deletingLastPathComponent().path, O_EVTONLY) + guard descriptor >= 0 else { return } + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, eventMask: .write, queue: .main) + source.setEventHandler { [weak self] in + MainActor.assumeIsolated { self?.scheduleReload() } + } + source.setCancelHandler { close(descriptor) } + source.resume() + watcher = source + } + + public func stopWatching() { + watcher?.cancel() + watcher = nil + debounce?.cancel() + } + + private func scheduleReload() { + debounce?.cancel() + debounce = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + guard !Task.isCancelled else { return } + self?.reloadIfChanged() + } + } +} + +/// Bridges the dictionary to the controller: thread-safe, can be switched off, rebuilt when the dictionary changes. +public final class DictionaryCorrector: TextCorrecting, @unchecked Sendable { + private let lock = NSLock() + private let knowledge: WordKnowledge + private var engine: CorrectionEngine + private var enabled = true + + public init(knowledge: WordKnowledge) { + self.knowledge = knowledge + self.engine = CorrectionEngine(entries: [], knowledge: knowledge) + } + + public func update(entries: [DictionaryEntry]) { + let rebuilt = CorrectionEngine(entries: entries, knowledge: knowledge) + lock.withLock { engine = rebuilt } + } + + public func setEnabled(_ value: Bool) { + lock.withLock { enabled = value } + } + + public func correct(_ text: String) -> CorrectionResult { + let (current, isOn) = lock.withLock { (engine, enabled) } + return isOn ? current.correct(text) : CorrectionResult(text: text, corrections: []) + } +} +``` + +- [ ] **Step 4: Draai alle tests** + +Run: `swift test` +Expected: alle tests PASS (engine-tests uit fase 1 + 54 in `SstDataTests`). De test `watcherNoticesAnEditorSavingTheFile` wacht maximaal 4 s; faalt hij sporadisch, draai hem apart opnieuw voordat je de implementatie wijzigt. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstData Tests/SstDataTests +git commit -m "feat: add DictionaryStore with file watching and DictionaryCorrector" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +## Self-review + +- **Spec-dekking:** bestandsformaat en behoud van opmerkingen/volgorde/ongeldige regels (Task 1), alle correctieregels uit de spec — hele woorden, glue/koppelteken, langste eerst, geen ketens, termen die gewone woorden zijn, letterlijke vervanging, Cloudflare/cloud codex (Task 2), waarschuwingstabel (Task 3), voorrangslijst met limiet 24 (Task 3), geschiedenis met zoeken, groeperen, beschadigd bestand (Task 4), bestandsmonitor en uit/aan-schakelaar (Task 5). Niet in deze fase: de plaats van de bestanden in Application Support, de koppeling aan `DictationController` en de UI (fase 4 en 5). +- **Placeholders:** geen; alle code is uit een werkende, geteste versie overgenomen. +- **Typeconsistentie:** `DictionaryEntry.Kind` (`.term`, `.correction(heard:wrote:)`), `CorrectionEngine(entries:knowledge:)`, `EntryWarnings.check(_:among:knowledge:)`, `HistoryEntry(result:)` en `DictionaryStore.apply` komen overeen met wat Task 2–5 en de latere fasen gebruiken. +- **Afwijking van de spec (bewust):** "gewoon woord" gebruikt de Engelse woordenlijst van `NaturalLanguage` (alleen gewone woorden) plus de Nederlandse spellingcontrole, niet de Engelse spellingcontrole: die rekent "anthropic" tot de woorden en zou jouw eigen voorbeeldterm onterecht uitsluiten van de hoofdletterregel. diff --git a/docs/superpowers/plans/2026-10-01-app-3-ui-kit.md b/docs/superpowers/plans/2026-10-01-app-3-ui-kit.md new file mode 100644 index 0000000..76736a7 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-3-ui-kit.md @@ -0,0 +1,1558 @@ +# App fase 3 — SstUI (ontwerpsysteem in code en componenten) 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:** De goedgekeurde ontwerp-tokens als Swift-code, plus de herbruikbare componenten van de veldrecorder-stijl (oppervlakken, toetsen, lampjes, VU-meter, segmentteller, LED-balk) en een galerij om ze in beide afwerkingen te bekijken. + +**Architecture:** `Design/tokens.json` blijft de enige bron. `Design/build_tokens.py` genereert daaruit nu ook `Sources/SstUI/DesignTokens.swift` (en `--check` bewaakt dat ze gelijk blijven). Componenten gebruiken uitsluitend die constanten; een test scant de broncode op losse kleuren en maten. Instrument-logica (schaal, wijzerveer, segmenten) is puur en getest; tekenwerk zit in `Canvas`-views. Het target `SstUI` hangt van niets anders in het pakket af. + +**Tech Stack:** Swift 6.4 (language mode 5), SwiftUI, AppKit (`NSColor`, `NSImage`), Swift Testing, Python 3 (generator). + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (sectie "Ontwerpsysteem in code") en `docs/design/tokens.md` + +## Global Constraints + +- Geen losse waarden in componenten: kleuren, maten, hoeken, randen, tijden komen uit `Palette`, `Space`, `Radius`, `Border`, `Size`, `Layout`, `Opacity`, `Typography`, `Shadows`, `Motion`, `MeterTokens`, `Segment`, `Texture`. Een regel mag afwijken met `// token-exempt: reden`. +- Twee afwerkingen via dynamische kleuren: Aluminium (licht) en Graphite (donker), gevolgd uit het uiterlijk van macOS. +- Eén accent: `Palette.accentRecord`; amber en groen alleen voor niveau-indicatoren en markeringen; gloed alleen op lampjes, nooit op tekst. +- Wijzer: 300 ms respons, demping 0,80 (1,0 bij "Verminder beweging"); verversing 60 Hz en alleen terwijl de meter actief is (plus 1,5 s uitzweven). +- 0 VU = −20 dBFS; schaal −20…+3 VU; vanaf 0 VU rood. +- Toegankelijkheid: tekstparen ≥ 4,5:1 (bewaakt door het tokenscript); focus-ring `Border.focus` + `Palette.lineFocus`; meter en teller hebben een toegankelijkheidslabel. +- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). + +## Bestandsstructuur + +``` +Design/tokens.json (wijzig) extra meetwaarden en opaciteiten +Design/preview.template.html (wijzig) leest die waarden uit de tokens +Design/build_tokens.py (wijzig) Swift-uitvoer en --check +Package.swift (wijzig) SstUI, SstUITests, SstGallery +Sources/SstUI/DesignTokens.swift (gegenereerd) +Sources/SstUI/DynamicColor.swift (nieuw) Color.dynamic / Color.fixed +Sources/SstUI/TextStyle.swift (nieuw) TextStyle, ShadowLayer, .textStyle(_:) +Sources/SstUI/Instruments.swift (nieuw) VUScale, NeedleSpring, SegmentGlyph, LEDBar +Sources/SstUI/Surfaces.swift (nieuw) PhysicalSurface, Panel/Well/Display/Paper, SeamDivider, SilkLabel, BrushedBackground +Sources/SstUI/Controls.swift (nieuw) KeyButtonStyle, LampView +Sources/SstUI/VUMeterView.swift (nieuw) +Sources/SstUI/CounterAndLEDs.swift (nieuw) SegmentCounterView, LEDBarView +Sources/SstGallery/GalleryApp.swift (nieuw) galerij + --snapshot +Tests/SstUITests/TokenIntegrityTests.swift, InstrumentsTests.swift +docs/design/tokens.md, tokens-preview.html (opnieuw gegenereerd) +``` + +--- + +### Task 1: Tokens uitbreiden en als Swift genereren + +**Files:** +- Modify: `Package.swift`, `Design/tokens.json`, `Design/preview.template.html`, `Design/build_tokens.py` +- Create: `Sources/SstUI/DynamicColor.swift`, `Sources/SstUI/TextStyle.swift`, `Sources/SstUI/DesignTokens.swift` (gegenereerd) +- Test: `Tests/SstUITests/TokenIntegrityTests.swift` + +**Interfaces:** +- Produces (gebruikt door alle latere taken en fasen): de enums `Palette`, `Opacity`, `FontFamily`, `Typography` (`TextStyle`), `Segment`, `Space`, `Layout`, `Size`, `Radius`, `Border`, `Shadows` (`[ShadowLayer]`; `Shadows.keyPressedOffset`), `Texture`, `MeterTokens`, `Motion`; `Color.dynamic(light:dark:)`, `Color.fixed(hex:)` (intern); `View.textStyle(_:)`; `ShadowLayer.color(_:)`. + +- [ ] **Step 1: Targets toevoegen** + +In `Package.swift`, voeg bij `targets` toe: + +```swift + .target(name: "SstUI"), +``` + +en bij de testtargets: + +```swift + .testTarget(name: "SstUITests", dependencies: ["SstUI"]), +``` + +Maak een tijdelijke `Sources/SstUI/Placeholder.swift` met de regel `import Foundation` (een target heeft minstens één bronbestand nodig; je verwijdert hem in stap 6). + +- [ ] **Step 2: Schrijf de falende tests** + +`Tests/SstUITests/TokenIntegrityTests.swift`: + +```swift +import Foundation +import Testing + +/// Root of the package: Tests/SstUITests/ → three levels up. +private let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + +struct TokenIntegrityTests { + @Test func generatedSwiftMatchesTokensJSONAndContrastHolds() throws { + #expect( + FileManager.default.fileExists(atPath: root.appending(path: "Sources/SstUI/DesignTokens.swift").path), + "Sources/SstUI/DesignTokens.swift ontbreekt: run `python3 Design/build_tokens.py`.") + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["python3", "Design/build_tokens.py", "--check"] + process.currentDirectoryURL = root + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + try process.run() + process.waitUntilExit() + let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + #expect(process.terminationStatus == 0, "Run `python3 Design/build_tokens.py`.\n\(output.suffix(600))") + } + + /// Views take every colour, size and time from the tokens. A line may opt out with `// token-exempt: reason`. + @Test func viewsContainNoLooseDesignValues() throws { + let forbidden: [(name: String, pattern: String)] = [ + ("hex colour", #"0x[0-9A-Fa-f]{6}\b"#), + ("component colour", #"(NS)?Color\((red|white|hue):"#), + ("literal font size", #"\.system\(size:\s*\d"#), + ("literal corner radius", #"cornerRadius:\s*\d|\.cornerRadius\(\s*\d"#), + ("literal padding", #"\.padding\(\s*(\.\w+\s*,\s*)?\d"#), + ("literal frame size", #"(width|height|minWidth|minHeight|maxWidth|maxHeight|idealWidth|idealHeight):\s*[1-9]"#), + ("literal line width", #"lineWidth:\s*\d"#), + ("literal opacity", #"\.opacity\(\s*[0-9]"#), + ] + let skipped: Set = ["DesignTokens.swift", "DynamicColor.swift"] + var directories = [root.appending(path: "Sources/SstUI")] + let app = root.appending(path: "Sources/Sst") + if FileManager.default.fileExists(atPath: app.path) { directories.append(app) } + + var problems: [String] = [] + for directory in directories { + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { $0.pathExtension == "swift" && !skipped.contains($0.lastPathComponent) } + for file in files { + let lines = try String(contentsOf: file, encoding: .utf8).components(separatedBy: "\n") + for (index, line) in lines.enumerated() where !line.contains("token-exempt") { + for rule in forbidden where line.range(of: rule.pattern, options: .regularExpression) != nil { + problems.append("\(file.lastPathComponent):\(index + 1) \(rule.name): \(line.trimmingCharacters(in: .whitespaces))") + } + } + } + } + #expect(problems.isEmpty, "Loose design values:\n\(problems.joined(separator: "\n"))") + } +} +``` + +- [ ] **Step 3: Draai de tests, de eerste moet falen** + +Run: `swift test --filter TokenIntegrityTests` +Expected: `generatedSwiftMatchesTokensJSONAndContrastHolds` faalt met "Sources/SstUI/DesignTokens.swift ontbreekt"; `viewsContainNoLooseDesignValues` slaagt (er is nog geen code). + +- [ ] **Step 4: Tokens, preview en generator aanpassen** + +Maak tijdelijk `Design/apply_design_edits.py`: + +```python +#!/usr/bin/env python3 +"""One-off: extend Design/ for the Swift UI kit. Run once from the repo root; every replacement is asserted.""" +import json +import pathlib + +root = pathlib.Path(".") + + +def edit(path, replacements): + p = root / 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) + + +# 1. tokens.json: geometry for the meter and counter, extra opacities +edit("Design/tokens.json", [ + (''' "vu.ticks": { "value": "-20 -10 -7 -5 -3 0 +3", "use": "Genummerde schaalmarkeringen" },''', + ''' "vu.ticks": { "value": "-20 -10 -7 -5 -3 0 +3", "use": "Genummerde schaalmarkeringen" }, + "vu.pivotOffset": { "value": 22, "use": "Draaipunt van de wijzer, onder de onderrand van de wijzerplaat" }, + "vu.scaleRadius": { "value": 142, "use": "Straal van de schaalboog vanaf het draaipunt" }, + "vu.needleLength": { "value": 146, "use": "Lengte van de wijzer" }, + "vu.tickMajor": { "value": 11, "use": "Lengte van een genummerde streep" }, + "vu.tickMinor": { "value": 6, "use": "Lengte van een gewone streep" }, + "vu.labelInset": { "value": 24, "use": "Afstand van de schaalboog tot de cijfers" }, + "vu.redBandWidth": { "value": 5, "use": "Dikte van de rode band vanaf 0 VU" },'''), + (''' "slant": { "value": 0, "use": "Schuinte in graden; 0 = rechtop" }''', + ''' "slant": { "value": 0, "use": "Schuinte in graden; 0 = rechtop" }, + "advance": { "value": 26, "use": "Afstand van cijfer tot cijfer" }, + "colonWidth": { "value": 10, "use": "Breedte van een dubbele punt of punt" }'''), + (''' "unlit": { "value": 1.00, "use": "Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie" }''', + ''' "unlit": { "value": 1.00, "use": "Gedoofde lampjes gebruiken eigen 'unlit'-kleuren, geen transparantie" }, + "pressedRecord": { "value": 0.85, "use": "Ingedrukte REC-toets: het rood dooft licht" }, + "specularOn": { "value": 0.60, "use": "Lichtvlekje op een brandend lampje" }, + "specularOff": { "value": 0.30, "use": "Lichtvlekje op een gedoofd lampje" }, + "caption": { "value": 0.75, "use": "Kleine opschriften op de wijzerplaat" }'''), +]) +json.loads((root / "Design/tokens.json").read_text()) # still valid JSON + +# 2. preview: read the meter and counter geometry from the tokens instead of fixed numbers +edit("Design/preview.template.html", [ + (" const R = 142;", " const R = VU['vu.scaleRadius'].value;"), + ("const pivot = { x: W / 2, y: H + 22 };", "const pivot = { x: W / 2, y: H + VU['vu.pivotOffset'].value };"), + ("y2=\"${pivot.y - 146}\"", "y2=\"${pivot.y - VU['vu.needleLength'].value}\""), + ("const [nx, ny] = pt(146, ang_);", "const [nx, ny] = pt(VU['vu.needleLength'].value, ang_);"), + ("pt(R - 24, a)", "pt(R - VU['vu.labelInset'].value, a)"), + ("pt(R - (major ? 11 : 6), a)", "pt(R - (major ? VU['vu.tickMajor'].value : VU['vu.tickMinor'].value), a)"), + ("stroke-width=\"5\" fill=\"none\"/>`;\n for (let v", "stroke-width=\"${VU['vu.redBandWidth'].value}\" fill=\"none\"/>`;\n for (let v"), + ("const adv = DW + 6;", "const adv = SEG.advance.value;"), + ("x += 10; }", "x += SEG.colonWidth.value; }"), +]) + +# 3. generator: Swift output and --check +marker = "# --- markdown ---------------------------------------------------------------" +swift_block = pathlib.Path("Design/swift_block.txt").read_text() +edit("Design/build_tokens.py", [(marker, swift_block + "\n" + marker)]) +print("Design/ updated") +``` + +en `Design/swift_block.txt` (de Swift-generator, wordt in `build_tokens.py` ingevoegd): + +```python +# --- swift ------------------------------------------------------------------ +def camel(name): + parts = name.replace("-", ".").split(".") + return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:]) + + +def num(v): + return repr(float(v)) if isinstance(v, float) else str(v) + + +def doc(use): + return " /// " + use.replace("\n", " ") + + +def swift_source(t): + o = [] + add = o.append + add("// GENERATED by Design/build_tokens.py from Design/tokens.json. Do not edit by hand.") + add("import SwiftUI\n") + + add("public enum Palette {") + for n, c in t["color"].items(): + add(doc(c["use"])) + add(f" public static let {camel(n)} = Color.dynamic(light: 0x{c['light'][1:].upper()}, dark: 0x{c['dark'][1:].upper()})") + add("}\n") + + add("public enum Opacity {") + for n, v in t["opacity"].items(): + add(doc(v["use"])) + add(f" public static let {camel(n)}: Double = {num(v['value'])}") + add("}\n") + + add("public enum FontFamily: String {") + for n, f in t["font"].items(): + add(f" case {n.replace('family.', '')} = \"{f['value']}\"") + add("}\n") + + add("public enum Typography {") + for n, s in t["type"].items(): + add(doc(s["use"])) + add(f" public static let {camel(n)} = TextStyle(family: .{s['family']}, size: {num(s['size'])}, weight: {s['weight']}, " + f"tracking: {num(s['tracking'])}, line: {num(s['line'])}, caps: {'true' if s['caps'] else 'false'}, " + f"tabular: {'true' if s.get('tabular') else 'false'})") + add("}\n") + + add("public enum Segment {") + for n, v in t["segment"].items(): + add(doc(v["use"])) + add(f" public static let {camel(n)}: CGFloat = {num(v['value'])}") + add("}\n") + + add("public enum Space {") + for n, v in t["space"].items(): + add(f" public static let s{n}: CGFloat = {v}") + add("}\n") + + for enum, group in (("Layout", "layout"), ("Size", "size"), ("Radius", "radius"), ("Border", "border")): + add(f"public enum {enum} {{") + for n, v in t[group].items(): + add(doc(v["use"])) + add(f" public static let {camel(n)}: CGFloat = {num(v['value'])}") + add("}\n") + + add("public enum Shadows {") + for n, s in t["shadow"].items(): + add(doc(s["use"])) + add(f" public static let {camel(n)}: [ShadowLayer] = [") + for l in s["layers"]: + add(f" ShadowLayer(inset: {'true' if l['inset'] else 'false'}, x: {l['x']}, y: {l['y']}, blur: {l['blur']}, spread: {l['spread']}, " + f"hex: 0x{l['color'][1:].upper()}, alphaLight: {num(l['alpha']['light'])}, alphaDark: {num(l['alpha']['dark'])}),") + add(" ]") + if "translateY" in s: + add(f" public static let {camel(n)}Offset: CGFloat = {s['translateY']}") + add("}\n") + + add("public enum Texture {") + for n, v in t["texture"].items(): + add(f" public static let {camel(n)}GrainLight: Double = {num(v['grain']['light'])}") + add(f" public static let {camel(n)}GrainDark: Double = {num(v['grain']['dark'])}") + add("}\n") + + add("public enum MeterTokens {") + for n, v in t["meter"].items(): + val = v["value"] + if n == "vu.ticks": + add(doc(v["use"])) + add(f" public static let vuTicks: [Int] = [{', '.join(str(int(x)) for x in val.split())}]") + elif n == "led.segmentSize": + w, h = val.split("x") + add(doc(v["use"])) + add(f" public static let ledSegmentWidth: CGFloat = {w}") + add(f" public static let ledSegmentHeight: CGFloat = {h}") + elif n == "peak.holdMs": + add(doc(v["use"])) + add(f" public static let peakHoldSeconds: Double = {num(val / 1000)}") + elif n == "led.segments": + add(doc(v["use"])) + add(f" public static let ledSegments: Int = {val}") + else: + add(doc(v["use"])) + add(f" public static let {camel(n)}: Double = {num(val)}") + add("}\n") + + add("public enum Motion {") + for n, m in t["motion"].items(): + if n == "reducedMotion": + continue + add(doc(m["use"])) + if m.get("unit") == "ms": + add(f" public static let {camel(n)}: Double = {num(m['value'] / 1000)}") + elif m.get("unit") in ("s", "ratio", "Hz"): + add(f" public static let {camel(n)}: Double = {num(m['value'])}") + elif isinstance(m["value"], str) and m["value"].startswith("cubic-bezier"): + pts = [x.strip() for x in m["value"][m["value"].index("(") + 1:-1].split(",")] + add(f" public static let {camel(n)}: (Double, Double, Double, Double) = ({', '.join(pts)})") + add("}") + return "\n".join(o) + "\n" + + +swift_text = swift_source(tokens) +swift_dir = ROOT / "Sources" / "SstUI" +swift_path = swift_dir / "DesignTokens.swift" + +if "--check" in sys.argv: + in_sync = swift_path.exists() and swift_path.read_text() == swift_text + print("DesignTokens.swift is " + ("in sync with tokens.json" if in_sync else "OUT OF DATE: run python3 Design/build_tokens.py")) + sys.exit(0 if (in_sync and not failed) else 1) + +if swift_dir.exists(): + swift_path.write_text(swift_text) + print("wrote Sources/SstUI/DesignTokens.swift") +``` + +Run vanuit de repo-root: `python3 Design/apply_design_edits.py && rm Design/apply_design_edits.py Design/swift_block.txt` +Expected: `Design/ updated`; beide tijdelijke bestanden zijn weg. + +Run: `python3 Design/build_tokens.py` +Expected: tabel met contrastwaarden zonder `FAIL`, daarna `wrote Sources/SstUI/DesignTokens.swift` en `wrote docs/design/tokens.md and docs/design/tokens-preview.html`. + +- [ ] **Step 5: Hulpbestanden schrijven** + +`Sources/SstUI/DynamicColor.swift`: + +```swift +import AppKit +import SwiftUI + +extension NSColor { + convenience init(hex: UInt32) { + self.init( + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1) + } +} + +extension Color { + /// A colour that switches between the Aluminium (light) and Graphite (dark) value with the appearance. + static func dynamic(light: UInt32, dark: UInt32) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? NSColor(hex: dark) : NSColor(hex: light) + }) + } + + /// The same colour in both finishes (shadow bases). + static func fixed(hex: UInt32) -> Color { + Color(nsColor: NSColor(hex: hex)) + } +} +``` + +`Sources/SstUI/TextStyle.swift`: + +```swift +import SwiftUI + +public struct TextStyle: Equatable { + public let family: FontFamily + public let size: CGFloat + public let weight: Int + /// In em. + public let tracking: CGFloat + public let line: CGFloat + public let caps: Bool + public let tabular: Bool + + public init(family: FontFamily, size: CGFloat, weight: Int, tracking: CGFloat, line: CGFloat, caps: Bool, tabular: Bool) { + self.family = family + self.size = size + self.weight = weight + self.tracking = tracking + self.line = line + self.caps = caps + self.tabular = tabular + } + + var fontWeight: Font.Weight { + switch weight { + case ..<450: .regular + case 450..<550: .medium + case 550..<650: .semibold + default: .bold + } + } + + public var font: Font { + let base: Font + switch family { + case .mono: base = .system(size: size, weight: fontWeight, design: .monospaced) + case .text: base = .system(size: size, weight: fontWeight) + case .chrome: base = .custom(family.rawValue, size: size).weight(fontWeight) + } + return tabular ? base.monospacedDigit() : base + } +} + +public extension View { + /// Font, tracking, line spacing and capitalisation from a token text style. + func textStyle(_ style: TextStyle) -> some View { + self.font(style.font) + .tracking(style.tracking * style.size) + .lineSpacing(max(0, style.line - style.size * 1.2)) + .textCase(style.caps ? .uppercase : nil) + } +} + +/// One layer of a layered shadow token. Inset layers are drawn inside the shape. +public struct ShadowLayer: Equatable { + public let inset: Bool + public let x: CGFloat + public let y: CGFloat + public let blur: CGFloat + public let spread: CGFloat + public let hex: UInt32 + public let alphaLight: Double + public let alphaDark: Double + + public init(inset: Bool, x: CGFloat, y: CGFloat, blur: CGFloat, spread: CGFloat, hex: UInt32, alphaLight: Double, alphaDark: Double) { + self.inset = inset + self.x = x + self.y = y + self.blur = blur + self.spread = spread + self.hex = hex + self.alphaLight = alphaLight + self.alphaDark = alphaDark + } + + public func color(_ scheme: ColorScheme) -> Color { + Color.fixed(hex: hex).opacity(scheme == .dark ? alphaDark : alphaLight) + } +} +``` + +- [ ] **Step 6: Placeholder weg, bouwen en testen** + +Run: `rm Sources/SstUI/Placeholder.swift && swift build && swift test --filter TokenIntegrityTests` +Expected: build zonder fouten of waarschuwingen; beide tests PASS. + +- [ ] **Step 7: Preview nog steeds in orde (handmatig, kort)** + +Run: `open docs/design/tokens-preview.html` +Expected: de meter, teller en LED-balk bewegen als eerder; geen lege vlakken. (De meetwaarden komen nu uit de tokens.) + +- [ ] **Step 8: Commit** + +```bash +git add Package.swift Design Sources/SstUI Tests/SstUITests docs/design +git commit -m "feat: generate Swift design tokens from tokens.json and add SstUI base" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: Instrument-logica — `VUScale`, `NeedleSpring`, `SegmentGlyph`, `LEDBar` + +**Files:** +- Create: `Sources/SstUI/Instruments.swift` +- Test: `Tests/SstUITests/InstrumentsTests.swift` + +**Interfaces:** +- Consumes: `MeterTokens`, `Motion`, `Segment` uit Task 1. +- Produces: + - `VUScale.vu(fromDbfs:)`, `fraction(forVU:)`, `fraction(forDbfs:)`, `angle(forFraction:)` (radialen vanaf verticaal) + - `NeedleSpring(response:damping:position:)` met `step(target:dt:) -> Double` en `position` + - `SegmentID` (a…g), `SegmentGlyph.lit(for:) -> Set`, `SegmentGlyph.polygon(_:) -> [CGPoint]` + - `LEDBar.tone(forSegment:segments:)` (`.green/.amber/.red`), `LEDBar.litCount(forFraction:segments:)` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstUITests/InstrumentsTests.swift`: + +```swift +import CoreGraphics +import Testing +@testable import SstUI + +struct VUScaleTests { + @Test func zeroVUIsMinusTwentyDbfs() { + #expect(VUScale.vu(fromDbfs: -20) == 0) + #expect(VUScale.vu(fromDbfs: -40) == -20) + } + + @Test func fractionSpansTheScale() { + #expect(VUScale.fraction(forVU: -20) == 0) + #expect(abs(VUScale.fraction(forVU: 3) - 1) < 1e-9) + #expect(abs(VUScale.fraction(forVU: 0) - 20.0 / 23.0) < 1e-9) + } + + @Test func fractionIsClampedAtBothEnds() { + #expect(VUScale.fraction(forVU: -60) == 0) + #expect(VUScale.fraction(forVU: 30) == 1.03) + #expect(VUScale.fraction(forDbfs: -120) == 0) + } + + @Test func angleIsSymmetricAroundVertical() { + #expect(abs(VUScale.angle(forFraction: 0) + VUScale.angle(forFraction: 1)) < 1e-9) + #expect(abs(VUScale.angle(forFraction: 0.5)) < 1e-9) + #expect(abs(VUScale.angle(forFraction: 1) - 45 * .pi / 180) < 1e-9) + } +} + +struct NeedleSpringTests { + func simulate(_ spring: NeedleSpring, seconds: Double, target: Double = 1) -> (final: Double, peak: Double) { + var spring = spring + var peak = 0.0 + let dt = 1.0 / 240 + for _ in 0.. 1.003) + #expect(result.peak < 1.03) + #expect(abs(result.final - 1) < 0.001) + } + + @Test func criticalDampingNeverOvershoots() { + let result = simulate(NeedleSpring(damping: 1), seconds: 1) + #expect(result.peak <= 1.000001) + } + + @Test func largeFrameStepsStayStable() { + var spring = NeedleSpring() + for _ in 0..<40 { spring.step(target: 1, dt: 0.05) } + #expect(abs(spring.position - 1) < 0.001) + } + + @Test func restsAtTheStartWithoutATarget() { + var spring = NeedleSpring() + for _ in 0..<60 { spring.step(target: 0, dt: 1.0 / 60) } + #expect(spring.position == 0) + } +} + +struct SegmentGlyphTests { + @Test func everyDigitHasASegmentPattern() { + for digit in "0123456789" { #expect(!SegmentGlyph.lit(for: digit).isEmpty) } + #expect(SegmentGlyph.lit(for: "8").count == 7) + #expect(SegmentGlyph.lit(for: "1") == [.b, .c]) + #expect(SegmentGlyph.lit(for: ":").isEmpty) + } + + @Test func polygonsStayInsideTheDigitBoxAndDiffer() { + var seen: [[CGPoint]] = [] + for id in SegmentID.allCases { + let polygon = SegmentGlyph.polygon(id) + #expect(polygon.count == 6) + for point in polygon { + #expect(point.x >= 0 && point.x <= Segment.digitWidth) + #expect(point.y >= 0 && point.y <= Segment.digitHeight) + } + #expect(!seen.contains(polygon)) + seen.append(polygon) + } + #expect(seen.count == 7) + } +} + +struct LEDBarTests { + @Test func tonesFollowTheEightThreeOneSplit() { + let tones = (0..<12).map { LEDBar.tone(forSegment: $0) } + #expect(tones.prefix(8).allSatisfy { $0 == .green }) + #expect(tones[8...10].allSatisfy { $0 == .amber }) + #expect(tones[11] == .red) + } + + @Test func litCountScalesAndClamps() { + #expect(LEDBar.litCount(forFraction: 0) == 0) + #expect(LEDBar.litCount(forFraction: 0.5) == 6) + #expect(LEDBar.litCount(forFraction: 1) == 12) + #expect(LEDBar.litCount(forFraction: 1.03) == 12) + #expect(LEDBar.litCount(forFraction: -1) == 0) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "VUScaleTests|NeedleSpringTests|SegmentGlyphTests|LEDBarTests"` +Expected: compilatiefouten (`cannot find 'VUScale' in scope`). + +- [ ] **Step 3: Implementatie** + +`Sources/SstUI/Instruments.swift`: + +```swift +import CoreGraphics +import Foundation + +/// dBFS → VU → needle position and angle, all from the meter tokens. +public enum VUScale { + public static func vu(fromDbfs dbfs: Double) -> Double { + dbfs - MeterTokens.vuReferenceDbfs + } + + /// 0 = bottom of the scale, 1 = top. The needle may swing slightly past the end (up to 1.03). + public static func fraction(forVU vu: Double) -> Double { + min(1.03, max(0, (vu - MeterTokens.vuMin) / (MeterTokens.vuMax - MeterTokens.vuMin))) + } + + public static func fraction(forDbfs dbfs: Double) -> Double { + fraction(forVU: vu(fromDbfs: dbfs)) + } + + /// Radians from vertical; negative is left of vertical. + public static func angle(forFraction fraction: Double) -> Double { + (-MeterTokens.vuArcDegrees / 2 + MeterTokens.vuArcDegrees * fraction) * .pi / 180 + } +} + +/// Second-order needle: reaches ~99 % in `response` seconds, slight overshoot for damping < 1. +public struct NeedleSpring: Equatable { + public private(set) var position: Double + public private(set) var velocity: Double = 0 + private let omega: Double + private let zeta: Double + + public init(response: Double = Motion.needleResponse, damping: Double = Motion.needleDamping, position: Double = 0) { + self.zeta = damping + self.omega = 4.6 / (damping * response) + self.position = position + } + + /// Advances the simulation by `dt` seconds in small fixed sub-steps (stable for any frame time). + @discardableResult + public mutating func step(target: Double, dt: Double) -> Double { + var remaining = max(0, dt) + let h = 1.0 / 240.0 + while remaining > 1e-9 { + let s = min(h, remaining) + let acceleration = omega * omega * (target - position) - 2 * zeta * omega * velocity + velocity += acceleration * s + position += velocity * s + remaining -= s + } + return position + } +} + +public enum SegmentID: CaseIterable, Hashable { + case a, b, c, d, e, f, g +} + +/// Which segments of a seven-segment digit are lit, and where each segment sits (from the segment tokens). +public enum SegmentGlyph { + static let map: [Character: Set] = [ + "0": [.a, .b, .c, .d, .e, .f], "1": [.b, .c], "2": [.a, .b, .d, .e, .g], "3": [.a, .b, .c, .d, .g], + "4": [.b, .c, .f, .g], "5": [.a, .c, .d, .f, .g], "6": [.a, .c, .d, .e, .f, .g], "7": [.a, .b, .c], + "8": Set(SegmentID.allCases), "9": [.a, .b, .c, .d, .f, .g], + ] + + public static func lit(for character: Character) -> Set { + map[character] ?? [] + } + + /// Hexagonal bar for one segment, inside a `Segment.digitWidth` × `Segment.digitHeight` box. + public static func polygon(_ id: SegmentID) -> [CGPoint] { + let width = Segment.digitWidth, height = Segment.digitHeight + let h = Segment.thickness / 2, gap = Segment.gap + let left = h, right = width - h, mid = height / 2 + + func horizontal(_ x0: CGFloat, _ x1: CGFloat, _ cy: CGFloat) -> [CGPoint] { + [CGPoint(x: x0, y: cy), CGPoint(x: x0 + h, y: cy - h), CGPoint(x: x1 - h, y: cy - h), + CGPoint(x: x1, y: cy), CGPoint(x: x1 - h, y: cy + h), CGPoint(x: x0 + h, y: cy + h)] + } + func vertical(_ cx: CGFloat, _ y0: CGFloat, _ y1: CGFloat) -> [CGPoint] { + [CGPoint(x: cx, y: y0), CGPoint(x: cx + h, y: y0 + h), CGPoint(x: cx + h, y: y1 - h), + CGPoint(x: cx, y: y1), CGPoint(x: cx - h, y: y1 - h), CGPoint(x: cx - h, y: y0 + h)] + } + let x0 = left + h + gap, x1 = right - h - gap + switch id { + case .a: return horizontal(x0, x1, h) + case .g: return horizontal(x0, x1, mid) + case .d: return horizontal(x0, x1, height - h) + case .f: return vertical(left, h + h + gap, mid - h - gap) + case .b: return vertical(right, h + h + gap, mid - h - gap) + case .e: return vertical(left, mid + h + gap, height - h - h - gap) + case .c: return vertical(right, mid + h + gap, height - h - h - gap) + } + } +} + +public enum LEDBar { + public enum Tone { case green, amber, red } + + /// The last segment is red, the three before it amber, the rest green. + public static func tone(forSegment index: Int, segments: Int = MeterTokens.ledSegments) -> Tone { + if index >= segments - 1 { return .red } + if index >= segments - 4 { return .amber } + return .green + } + + public static func litCount(forFraction fraction: Double, segments: Int = MeterTokens.ledSegments) -> Int { + Int((min(1, max(0, fraction)) * Double(segments)).rounded()) + } +} +``` + +- [ ] **Step 4: Draai alle tests** + +Run: `swift test --filter SstUITests` +Expected: 13 instrumenttests + 2 integriteitstests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstUI Tests/SstUITests +git commit -m "feat: add instrument logic (VU scale, needle spring, segment glyphs, LED bar)" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: Oppervlakken en materiaal + +**Files:** +- Create: `Sources/SstUI/Surfaces.swift` + +**Interfaces:** +- Consumes: `Palette`, `Shadows`, `Radius`, `Border`, `Texture`, `Typography` (Task 1); `ShadowLayer` (Task 1). +- Produces (SwiftUI-views, alle `public`): `PhysicalSurface(fill:cornerRadius:shadows:edge:)`, `PanelBackground()`, `WellBackground()`, `DisplayBackground()`, `PaperBackground()`, `SeamDivider()`, `SilkLabel(_:style:)`, `BrushedBackground()`. + +Tekenwerk wordt visueel getoetst in Task 6; hier moet het compileren en door de token-scan komen. + +- [ ] **Step 1: Implementatie** + +`Sources/SstUI/Surfaces.swift`: + +```swift +import AppKit +import SwiftUI + +/// A shape with a fill, layered drop shadows, layered inset shadows and a hairline edge: a physical part. +public struct PhysicalSurface: View { + private let fill: AnyShapeStyle + private let cornerRadius: CGFloat + private let shadows: [ShadowLayer] + private let edge: Color? + @Environment(\.colorScheme) private var scheme + + public init(fill: AnyShapeStyle, cornerRadius: CGFloat, shadows: [ShadowLayer], edge: Color? = Palette.lineSeamDark) { + self.fill = fill + self.cornerRadius = cornerRadius + self.shadows = shadows + self.edge = edge + } + + public var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .circular) + ZStack { + shadows.filter { !$0.inset }.reduce(AnyView(shape.fill(fill))) { view, layer in + AnyView(view.shadow(color: layer.color(scheme), radius: layer.blur / 2, x: layer.x, y: layer.y)) + } + ForEach(Array(shadows.filter(\.inset).enumerated()), id: \.offset) { _, layer in + InsetShadow(layer: layer, cornerRadius: cornerRadius) + } + if let edge { + shape.strokeBorder(edge, lineWidth: Border.hairline) + } + } + } +} + +/// A shadow cast inwards from the edge of a rounded rectangle. +struct InsetShadow: View { + let layer: ShadowLayer + let cornerRadius: CGFloat + @Environment(\.colorScheme) private var scheme + + var body: some View { + Canvas { context, size in + let rect = CGRect(origin: .zero, size: size) + context.clip(to: Path(roundedRect: rect, cornerRadius: cornerRadius)) + context.drawLayer { inner in + inner.addFilter(.blur(radius: layer.blur / 2)) + let hole = rect.insetBy(dx: layer.spread, dy: layer.spread).offsetBy(dx: layer.x, dy: layer.y) + var frame = Path(rect.insetBy(dx: -size.width, dy: -size.height)) + frame.addPath(Path(roundedRect: hole, cornerRadius: max(0, cornerRadius - layer.spread))) + inner.fill(frame, with: .color(layer.color(scheme)), style: FillStyle(eoFill: true)) + } + } + .allowsHitTesting(false) + } +} + +/// Faceplate of matt plastic, sitting above the chassis. +public struct PanelBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePanel), cornerRadius: Radius.none, shadows: Shadows.panel) + } +} + +/// A recessed area (input fields, display windows). +public struct WellBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceWell), cornerRadius: Radius.none, shadows: Shadows.well) + } +} + +/// Dark display glass for the counter. +public struct DisplayBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfaceDisplay), cornerRadius: Radius.none, shadows: Shadows.well) + } +} + +/// Cassette-label cream, for lists of transcripts and dictionary entries. +public struct PaperBackground: View { + public init() {} + public var body: some View { + PhysicalSurface(fill: AnyShapeStyle(Palette.surfacePaper), cornerRadius: Radius.none, shadows: Shadows.well) + } +} + +/// An engraved groove: a dark line with a light line below it. +public struct SeamDivider: View { + public init() {} + public var body: some View { + VStack(spacing: 0) { + Rectangle().fill(Palette.lineSeamDark).frame(height: Border.hairline) + Rectangle().fill(Palette.lineSeamLight).frame(height: Border.hairline) + } + .accessibilityHidden(true) + } +} + +/// Small capitals silkscreen label. +public struct SilkLabel: View { + private let text: String + private let style: TextStyle + + public init(_ text: String, style: TextStyle = Typography.label) { + self.text = text + self.style = style + } + + public var body: some View { + Text(text).textStyle(style).foregroundStyle(Palette.inkTertiary) + } +} + +/// Brushed aluminium: a vertical gradient with a horizontal grain. +public struct BrushedBackground: View { + @Environment(\.colorScheme) private var scheme + public init() {} + + public var body: some View { + ZStack { + LinearGradient(colors: [Palette.surfaceMetalHigh, Palette.surfaceMetalLow], startPoint: .top, endPoint: .bottom) + Image(nsImage: scheme == .dark ? BrushedTexture.dark : BrushedTexture.light) + .resizable(resizingMode: .tile) + } + } +} + +enum BrushedTexture { + private static let side = 256 + private static let gain = 2.2 // makes the token grain value visible as streak opacity + + static let light = make(grain: Texture.brushedGrainLight) + static let dark = make(grain: Texture.brushedGrainDark) + + private struct SplitMix64: RandomNumberGenerator { + var state: UInt64 + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + } + + /// A seeded grain: every row has its own brightness and pixels vary only slightly, which gives fine horizontal streaks. + private static func make(grain: Double) -> NSImage { + var rng = SplitMix64(state: 7) + let rows = (0.. 0.5 ? 1.0 : 0.0 + let index = (y * side + x) * 4 + let premultiplied = UInt8(shade * alpha * 255) + pixels[index] = premultiplied + pixels[index + 1] = premultiplied + pixels[index + 2] = premultiplied + pixels[index + 3] = UInt8(alpha * 255) + } + } + let provider = CGDataProvider(data: Data(pixels) as CFData)! + let image = CGImage( + width: side, height: side, bitsPerComponent: 8, bitsPerPixel: 32, bytesPerRow: side * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent)! + return NSImage(cgImage: image, size: NSSize(width: side, height: side)) + } +} +``` + +- [ ] **Step 2: Bouwen en scannen** + +Run: `swift build && swift test --filter TokenIntegrityTests` +Expected: build zonder fouten of waarschuwingen; beide tests PASS (geen losse waarden). + +- [ ] **Step 3: Commit** + +```bash +git add Sources/SstUI +git commit -m "feat: add physical surfaces, brushed metal and silkscreen label" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: Toets en lampje + +**Files:** +- Create: `Sources/SstUI/Controls.swift` + +**Interfaces:** +- Consumes: `PhysicalSurface` (Task 3), tokens (Task 1). +- Produces: `KeyButtonStyle(kind: .standard | .record, latched: Bool)` (`ButtonStyle`) en `LampView(isOn:tint: .record | .amber, large:)`. + +- [ ] **Step 1: Implementatie** + +`Sources/SstUI/Controls.swift`: + +```swift +import SwiftUI + +/// A physical key. `latched` keeps it looking pressed (REC while recording). +public struct KeyButtonStyle: ButtonStyle { + public enum Kind { case standard, record } + + private let kind: Kind + private let latched: Bool + + public init(kind: Kind = .standard, latched: Bool = false) { + self.kind = kind + self.latched = latched + } + + public func makeBody(configuration: Configuration) -> some View { + KeyBody(configuration: configuration, kind: kind, latched: latched) + } + + private struct KeyBody: View { + let configuration: Configuration + let kind: Kind + let latched: Bool + @Environment(\.isEnabled) private var isEnabled + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var pressed: Bool { configuration.isPressed || latched } + + private var face: AnyShapeStyle { + switch (kind, pressed) { + case (.standard, false): + AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom)) + case (.standard, true): + AnyShapeStyle(Palette.surfaceKeyPressed) + case (.record, false): + AnyShapeStyle(Palette.accentRecord) + case (.record, true): + AnyShapeStyle(Palette.accentRecord.opacity(Opacity.pressedRecord)) + } + } + + var body: some View { + configuration.label + .textStyle(Typography.control) + .foregroundStyle(kind == .record ? Palette.inkInverse : Palette.inkPrimary) + .padding(.horizontal, Space.s4) + .frame( + minWidth: kind == .record ? Size.keyRecWidth : Size.keyMinWidth, + minHeight: kind == .record ? Size.keyRecHeight : Size.controlHeight) + .background(PhysicalSurface( + fill: face, cornerRadius: Radius.sm, + shadows: pressed ? Shadows.keyPressed : Shadows.keyRaised)) + .offset(y: pressed ? Shadows.keyPressedOffset : 0) + .opacity(isEnabled ? 1 : Opacity.disabled) + .animation( + reduceMotion ? nil : .timingCurve( + Motion.easingMechanical.0, Motion.easingMechanical.1, Motion.easingMechanical.2, Motion.easingMechanical.3, + duration: pressed ? Motion.keyPress : Motion.keyRelease), + value: pressed) + } + } +} + +/// A round indicator lamp. The record lamp has a small glow; text never does. +public struct LampView: View { + public enum Tint { case record, amber } + + private let isOn: Bool + private let tint: Tint + private let large: Bool + @Environment(\.colorScheme) private var scheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + public init(isOn: Bool, tint: Tint = .record, large: Bool = false) { + self.isOn = isOn + self.tint = tint + self.large = large + } + + private var color: Color { + switch (tint, isOn) { + case (.record, true): Palette.accentRecord + case (.record, false): Palette.accentRecordUnlit + case (.amber, true): Palette.levelAmber + case (.amber, false): Palette.levelAmberUnlit + } + } + + public var body: some View { + let diameter = large ? Size.lampLarge : Size.lampDiameter + let glow = Shadows.lampOn[0] + Circle() + .fill(color) + .overlay( + Circle().fill(RadialGradient( + colors: [Color.white.opacity(isOn ? Opacity.specularOn : Opacity.specularOff), .clear], + center: UnitPoint(x: 0.35, y: 0.3), startRadius: 0, endRadius: diameter * 0.45))) + .overlay(Circle().strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline)) + .frame(width: diameter, height: diameter) + .shadow( + color: isOn && tint == .record ? glow.color(scheme) : .clear, + radius: glow.blur / 2) + .animation( + reduceMotion ? nil : .easeOut(duration: isOn ? Motion.lampOn : Motion.lampOff), + value: isOn) + .accessibilityHidden(true) + } +} +``` + +- [ ] **Step 2: Bouwen en scannen** + +Run: `swift build && swift test --filter SstUITests` +Expected: build zonder fouten of waarschuwingen; alle SstUI-tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add Sources/SstUI +git commit -m "feat: add physical key button style and lamp" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: VU-meter, segmentteller en LED-balk + +**Files:** +- Create: `Sources/SstUI/VUMeterView.swift`, `Sources/SstUI/CounterAndLEDs.swift` + +**Interfaces:** +- Consumes: `VUScale`, `NeedleSpring`, `SegmentGlyph`, `LEDBar` (Task 2); `DisplayBackground` (Task 3); tokens. +- Produces: + - `VUMeterView(levelDbfs: Double, isActive: Bool, brand: String = "SST · FIELD RECORDER", initialFraction: Double = 0)` — alleen animatie terwijl `isActive` (en 1,5 s erna) + - `SegmentCounterView(_ text: String)` — cijfers, `:` en `.` + - `LEDBarView(fraction: Double)` + +- [ ] **Step 1: Implementatie** + +`Sources/SstUI/VUMeterView.swift`: + +```swift +import SwiftUI + +/// Analogue VU meter with a physical needle. Pass the RMS level in dBFS; 0 VU = `MeterTokens.vuReferenceDbfs`. +/// The needle only animates while `isActive` (and briefly after, to settle), so an idle meter costs nothing. +public struct VUMeterView: View { + private let levelDbfs: Double + private let isActive: Bool + private let brand: String + + @State private var needle: NeedleState + @State private var awake = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private final class NeedleState { + var spring: NeedleSpring + var dampingUsed = Motion.needleDamping + var last: TimeInterval? + var peakUntil: TimeInterval = 0 + + init(position: Double) { + spring = NeedleSpring(position: position) + } + } + + /// - Parameter initialFraction: where the needle starts (0 = bottom of the scale); useful for previews and snapshots. + public init(levelDbfs: Double, isActive: Bool, brand: String = "SST · FIELD RECORDER", initialFraction: Double = 0) { + self.levelDbfs = levelDbfs + self.isActive = isActive + self.brand = brand + _needle = State(initialValue: NeedleState(position: initialFraction)) + } + + public var body: some View { + TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !(isActive || awake))) { timeline in + Canvas { context, size in + draw(&context, size: size, now: timeline.date.timeIntervalSinceReferenceDate) + } + } + .aspectRatio(MeterTokens.vuWidth / MeterTokens.vuHeight, contentMode: .fit) + .background(Palette.surfaceMeterFace) + .overlay( + RoundedRectangle(cornerRadius: Radius.bezel, style: .circular) + .strokeBorder(Palette.lineSeamDark, lineWidth: Border.bezel)) + .clipShape(RoundedRectangle(cornerRadius: Radius.bezel, style: .circular)) + .onChange(of: isActive) { _, active in + if active { + awake = true + } else { + Task { + try? await Task.sleep(for: .seconds(1.5)) + awake = false + } + } + } + .accessibilityElement() + .accessibilityLabel("Niveau") + .accessibilityValue(spokenValue) + } + + /// Rounded to steps of 3 VU so VoiceOver is not flooded with changes. + private var spokenValue: String { + guard isActive else { return "stil" } + let vu = (VUScale.vu(fromDbfs: levelDbfs) / 3).rounded() * 3 + return "\(Int(vu)) VU" + } + + // MARK: drawing (design coordinates: MeterTokens.vuWidth × vuHeight) + + private func draw(_ context: inout GraphicsContext, size: CGSize, now: TimeInterval) { + let width = MeterTokens.vuWidth, height = MeterTokens.vuHeight + let scale = min(size.width / width, size.height / height) + context.translateBy(x: (size.width - width * scale) / 2, y: (size.height - height * scale) / 2) + context.scaleBy(x: scale, y: scale) + context.clip(to: Path(CGRect(x: 0, y: 0, width: width, height: height))) + + // physics + let damping = reduceMotion ? 1.0 : Motion.needleDamping + if needle.dampingUsed != damping { + needle.spring = NeedleSpring(damping: damping, position: needle.spring.position) + needle.dampingUsed = damping + } + let dt = needle.last.map { min(0.05, now - $0) } ?? 0 + needle.last = now + let target = isActive ? VUScale.fraction(forDbfs: levelDbfs) : 0 + let position = needle.spring.step(target: target, dt: dt) + let vuNow = MeterTokens.vuMin + (MeterTokens.vuMax - MeterTokens.vuMin) * position + if vuNow > 0 && isActive { needle.peakUntil = now + MeterTokens.peakHoldSeconds } + + let pivot = CGPoint(x: width / 2, y: height + MeterTokens.vuPivotOffset) + func point(_ radius: CGFloat, _ fraction: Double) -> CGPoint { + let angle = VUScale.angle(forFraction: fraction) + return CGPoint(x: pivot.x + radius * CGFloat(sin(angle)), y: pivot.y - radius * CGFloat(cos(angle))) + } + func arc(_ radius: CGFloat, from: Double, to: Double) -> Path { + var path = Path() + let steps = 48 + for step in 0...steps { + let p = point(radius, from + (to - from) * Double(step) / Double(steps)) + step == 0 ? path.move(to: p) : path.addLine(to: p) + } + return path + } + + let radius = MeterTokens.vuScaleRadius + let redFrom = VUScale.fraction(forVU: 0) + context.stroke(arc(radius, from: 0, to: redFrom), with: .color(Palette.inkOnMeter), lineWidth: Border.hairline) + context.stroke(arc(radius - Border.hairline, from: redFrom, to: 1), with: .color(Palette.accentRecord), lineWidth: MeterTokens.vuRedBandWidth) + + for vu in Int(MeterTokens.vuMin)...Int(MeterTokens.vuMax) { + let major = MeterTokens.vuTicks.contains(vu) + let fraction = VUScale.fraction(forVU: Double(vu)) + let color = vu > 0 ? Palette.accentRecord : Palette.inkOnMeter + var tick = Path() + tick.move(to: point(radius, fraction)) + tick.addLine(to: point(radius - (major ? MeterTokens.vuTickMajor : MeterTokens.vuTickMinor), fraction)) + context.stroke(tick, with: .color(color), lineWidth: Border.hairline) + if major { + let label = Text(vu > 0 ? "+\(vu)" : "\(vu)").font(Typography.labelSmall.font).foregroundStyle(color) + context.draw(label, at: point(radius - MeterTokens.vuLabelInset, fraction), anchor: .center) + } + } + + context.draw( + Text("VU").font(Typography.heading.font).tracking(Typography.heading.tracking * Typography.heading.size).foregroundStyle(Palette.inkOnMeter), + at: CGPoint(x: width / 2, y: height - Space.s6), anchor: .center) + context.draw( + Text(brand).font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter.opacity(Opacity.caption)), + at: CGPoint(x: Space.s4, y: height - Space.s3), anchor: .leading) + + // peak lamp + let lampCenter = CGPoint(x: width - Space.s6, y: Space.s6) + let lampRadius = Size.lampDiameter / 2 + let peakLit = now < needle.peakUntil + context.fill(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)), + with: .color(peakLit ? Palette.accentRecord : Palette.accentRecordUnlit)) + context.stroke(Path(ellipseIn: CGRect(x: lampCenter.x - lampRadius, y: lampCenter.y - lampRadius, width: lampRadius * 2, height: lampRadius * 2)), + with: .color(Palette.inkOnMeter), lineWidth: Border.hairline) + context.draw( + Text("PEAK").font(Typography.labelSmall.font).foregroundStyle(Palette.inkOnMeter), + at: CGPoint(x: lampCenter.x, y: lampCenter.y + lampRadius + Space.s3), anchor: .center) + + // needle + var needlePath = Path() + needlePath.move(to: pivot) + needlePath.addLine(to: point(MeterTokens.vuNeedleLength, max(-0.02, min(1.05, position)))) + context.stroke(needlePath, with: .color(Palette.inkOnMeter), lineWidth: MeterTokens.vuNeedleWidth) + } +} +``` + +`Sources/SstUI/CounterAndLEDs.swift`: + +```swift +import SwiftUI + +/// Seven-segment counter on dark glass. Digits, ":" and "." are drawn; unlit segments stay faintly visible. +public struct SegmentCounterView: View { + private let text: String + + public init(_ text: String) { + self.text = text + } + + private var contentWidth: CGFloat { + text.reduce(0) { total, character in + total + (character.isNumber ? Segment.advance : Segment.colonWidth) + } + } + + public var body: some View { + Canvas { context, _ in + var x: CGFloat = 0 + for character in text { + if character.isNumber { + var origin = context + origin.translateBy(x: x, y: 0) + let lit = SegmentGlyph.lit(for: character) + for id in SegmentID.allCases { + let polygon = SegmentGlyph.polygon(id) + var path = Path() + path.addLines(polygon) + path.closeSubpath() + origin.fill(path, with: .color(lit.contains(id) ? Palette.levelAmber : Palette.displaySegmentUnlit)) + } + x += Segment.advance + } else { + let dot = Segment.thickness + let inset = (Segment.colonWidth - dot) / 2 + let ys: [CGFloat] = character == ":" + ? [Segment.digitHeight * 0.28, Segment.digitHeight * 0.64] + : [Segment.digitHeight - dot] + for y in ys { + context.fill(Path(CGRect(x: x + inset, y: y, width: dot, height: dot)), with: .color(Palette.levelAmber)) + } + x += Segment.colonWidth + } + } + } + .frame(width: contentWidth, height: Segment.digitHeight) + .padding(.horizontal, Space.s4) + .padding(.vertical, Space.s3) + .background(DisplayBackground()) + .accessibilityElement() + .accessibilityLabel("Tijd") + .accessibilityValue(text) + } +} + +/// Twelve-segment level bar: green, amber, then one red segment. +public struct LEDBarView: View { + private let fraction: Double + + public init(fraction: Double) { + self.fraction = fraction + } + + public var body: some View { + let lit = LEDBar.litCount(forFraction: fraction) + HStack(spacing: MeterTokens.ledGap) { + ForEach(0.. Color { + switch LEDBar.tone(forSegment: index) { + case .green: lit ? Palette.levelGreen : Palette.levelGreenUnlit + case .amber: lit ? Palette.levelAmber : Palette.levelAmberUnlit + case .red: lit ? Palette.accentRecord : Palette.accentRecordUnlit + } + } +} +``` + +- [ ] **Step 2: Bouwen en scannen** + +Run: `swift build && swift test --filter SstUITests` +Expected: build zonder fouten of waarschuwingen; alle SstUI-tests PASS. + +- [ ] **Step 3: Commit** + +```bash +git add Sources/SstUI +git commit -m "feat: add VU meter, segment counter and LED bar views" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 6: Galerij en visuele controle + +**Files:** +- Modify: `Package.swift` +- Create: `Sources/SstGallery/GalleryApp.swift` + +**Interfaces:** +- Consumes: alle componenten uit Task 2–5. +- Produces: executable `SstGallery` (venster met beide afwerkingen, bewegende meter) en `SstGallery --snapshot ` (rendert de afwerkingen naar een PNG en stopt). + +- [ ] **Step 1: Target toevoegen** + +In `Package.swift`, voeg bij `targets` toe: + +```swift + .executableTarget(name: "SstGallery", dependencies: ["SstUI"]), +``` + +- [ ] **Step 2: Implementatie** + +`Sources/SstGallery/GalleryApp.swift`: + +```swift +import SwiftUI +import SstUI + +@main +struct GalleryApp: App { + init() { + // `SstGallery --snapshot out.png` renders both finishes to a PNG and exits (for checking without a screen grab). + let args = CommandLine.arguments + guard let flag = args.firstIndex(of: "--snapshot"), args.count > flag + 1 else { return } + let path = args[flag + 1] + Task { @MainActor in + let view = HStack(spacing: 0) { + Finish(scheme: .light, title: "Aluminium", frozen: true) + Finish(scheme: .dark, title: "Graphite", frozen: true) + } + .frame(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + if let tiff = renderer.nsImage?.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: path)) + } + exit(0) + } + } + + var body: some Scene { + WindowGroup("Sst componenten") { + HStack(spacing: 0) { + Finish(scheme: .light, title: "Aluminium") + Finish(scheme: .dark, title: "Graphite") + } + .frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight) + } + .defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) + } +} + +/// Fake speech level so the meter, LEDs and counter can be judged in motion. +private func simulatedDbfs(at t: Double) -> Double { + if t.truncatingRemainder(dividingBy: 9) > 6.2 { return -90 } + let syllable = floor(t * 4.2) + let r = abs(sin(syllable * 12.9898) * 43758.5453).truncatingRemainder(dividingBy: 1) + return r > 0.12 ? -38 + r * 22 : -90 +} + +struct Finish: View { + let scheme: ColorScheme + let title: String + var frozen = false + @State private var recording = true + @State private var started = Date() + @State private var query = "" + + var body: some View { + Group { + if frozen { + content(elapsed: 12.3, dbfs: -26) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in + let elapsed = timeline.date.timeIntervalSince(started) + let dbfs = recording ? simulatedDbfs(at: elapsed) : -120 + content(elapsed: recording ? elapsed : 0, dbfs: dbfs) + } + } + } + .environment(\.colorScheme, scheme) + } + + private func content(elapsed: Double, dbfs: Double) -> some View { + ZStack { + BrushedBackground() + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel("Afwerking · \(title)") + VStack(alignment: .leading, spacing: Layout.panelGap) { + HStack(alignment: .top, spacing: Layout.panelGap) { + VUMeterView(levelDbfs: dbfs, isActive: recording, initialFraction: frozen ? VUScale.fraction(forDbfs: dbfs) : 0) + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel("Tijd", style: Typography.labelSmall) + SegmentCounterView(Self.clock(elapsed)) + SilkLabel("Niveau", style: Typography.labelSmall) + LEDBarView(fraction: VUScale.fraction(forDbfs: dbfs)) + } + } + SeamDivider() + HStack(spacing: Layout.panelGap) { + Button { recording.toggle(); started = Date() } label: { + HStack { LampView(isOn: recording); Text("Rec") } + } + .buttonStyle(KeyButtonStyle(kind: .record, latched: recording)) + Button("Stop") { recording = false }.buttonStyle(KeyButtonStyle()) + Button("Kopieer") {}.buttonStyle(KeyButtonStyle()) + Button("Uit") {}.buttonStyle(KeyButtonStyle()).disabled(true) + Spacer() + LampView(isOn: recording, large: true) + LampView(isOn: true, tint: .amber) + } + } + .padding(Layout.panelPadding) + .background(PanelBackground()) + + VStack(alignment: .leading, spacing: 0) { + row("14:02:11", "Ik wil de meeting verplaatsen naar tomorrow morning.", selected: false) + row("14:03:40", "Hij stuurt het door naar Claude Code vandaag.", selected: true) + } + .background(PaperBackground()) + + HStack { + SilkLabel("Zoek") + Group { + if frozen { + Text("claude").textStyle(Typography.body).frame(maxWidth: .infinity, alignment: .leading) + } else { + TextField("", text: $query).textFieldStyle(.plain).textStyle(Typography.body) + } + } + .padding(Space.s3).background(WellBackground()) + } + } + .padding(Layout.panelPadding) + } + } + + private func row(_ time: String, _ text: String, selected: Bool) -> some View { + HStack(alignment: .firstTextBaseline, spacing: Space.s4) { + Text(time).textStyle(Typography.counterSmall).foregroundStyle(Palette.inkSecondary) + Text(text).textStyle(Typography.reading).foregroundStyle(Palette.inkPrimary) + Spacer() + } + .padding(.horizontal, Space.s4).padding(.vertical, Space.s3) + .frame(minHeight: Size.rowHeight) + .background(selected ? Palette.surfaceSelection : Color.clear) + } + + private static func clock(_ seconds: Double) -> String { + let total = Int(seconds * 10) + return String(format: "%02d:%02d.%d", (total / 600) % 100, (total / 10) % 60, total % 10) + } +} +``` + +- [ ] **Step 3: Bouwen en een momentopname maken** + +Run: `swift build && .build/debug/SstGallery --snapshot /tmp/sst-gallery.png && open /tmp/sst-gallery.png` +Expected: een PNG met links Aluminium en rechts Graphite. Controleer visueel: geborsteld metaal is fijn en subtiel (geen grove scanlijnen); de toetsen lijken verhoogd, de REC-toets is rood met een lampje; de meter heeft een wijzer tussen −7 en −5 VU en een rode band vanaf 0; de teller toont amber segmenten met gedoofde segmenten erachter; het papier heeft een krijtstreep-achtige gedempte selectie; beide afwerkingen zijn goed leesbaar. + +- [ ] **Step 4: Levende controle samen met de gebruiker** + +Run: `swift run SstGallery` +Laat de gebruiker beoordelen: +1. De wijzer reageert met een korte, rustige overschrijding (niet stuiterend) en valt terug naar links bij stilte. +2. De piek-LED op de meter gaat aan boven 0 VU en blijft ongeveer 1 s branden. +3. REC/STOP: de REC-toets blijft ingedrukt zolang er wordt "opgenomen"; de lamp gaat snel aan en dooft traag. +4. Klik op een toets: die zakt 1 pt en wordt donkerder. +5. Zet in Systeeminstellingen → Toegankelijkheid → Beeldscherm "Verminder beweging" aan en herstart de galerij: geen overschrijding meer van de wijzer. +Noteer eventuele opmerkingen; kleine visuele bijstellingen horen bij deze taak, nieuwe tokens eerst voorleggen. + +- [ ] **Step 5: Alle tests en commit** + +Run: `swift test` +Expected: alle tests PASS. + +```bash +git add Package.swift Sources/SstGallery +git commit -m "feat: add component gallery with snapshot mode" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +## Self-review + +- **Spec-dekking:** tokens → Swift met parity- en contrasttest (Task 1), geen losse waarden (scan, Task 1), dynamische Aluminium/Graphite-kleuren, componentenlijst uit de spec (`KeyButtonStyle`, `LampView`, `PanelBackground`, `WellBackground`, `SeamDivider`, `SilkLabel`, `VUMeterView`, `SegmentCounterView`, `LEDBarView`, `PaperList` als `PaperBackground` + rijen in fase 5), geborstelde textuur gecachet (`BrushedTexture.light/dark` zijn statics), meter 60 Hz alleen actief, VoiceOver-labels, "Verminder beweging". `PaperList` zelf (de lijstweergave) komt in fase 5; hier alleen de achtergrond. +- **Placeholders:** geen; alle code is overgenomen uit een gecompileerde en geteste versie. +- **Typeconsistentie:** `Shadows.keyRaised/keyPressed/well/panel/lampOn`, `Shadows.keyPressedOffset`, `Motion.easingMechanical` (tuple), `MeterTokens.vu*`, `Opacity.pressedRecord/specularOn/specularOff/caption` zijn de namen die de generator uit `tokens.json` maakt en die de componenten gebruiken. +- **Risico's:** `NSSpellChecker`/AppKit-afhankelijkheid zit niet in deze fase. Tekenwerk is alleen visueel te beoordelen (Task 6 stap 3–4). `ImageRenderer` toont geen native tekstvelden; de galerij vervangt het zoekveld daarom in snapshotmodus. diff --git a/docs/superpowers/plans/2026-10-01-app-4-shell.md b/docs/superpowers/plans/2026-10-01-app-4-shell.md new file mode 100644 index 0000000..7aa308d --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-4-shell.md @@ -0,0 +1,1883 @@ +# App fase 4 — App-shell Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Van menubalk-hulpprogramma naar een echte Mac-app: Dock-icoon, app-menu, hoofdvenster (voorlopig een schil), Instellingen (⌘,) met sneltoets, model en privacy, een secundair menubalkitem, en een dubbelklikbaar `Sst.app` dat ondertekend is met jouw certificaat en in Programma's kan staan. De dicteer-keten gebruikt nu ook het woordenboek en bewaart de geschiedenis. + +**Architecture:** `AppModel` bezit alle services (instellingen, rechten, modelbeheer, woordenboek, geschiedenis, controller, sneltoets) en is de enige brug naar SwiftUI. Testbare lijmcode (`GatedInserter`, `SwappableTranscriber`, `LevelSource`, `AppSettings`, `ModelStorage`) staat in `SstCore`, `SstData` en `SstWhisper`. De views gebruiken alleen tokens; de token-scan uit fase 3 bewaakt dat ook voor `Sources/Sst`. + +**Tech Stack:** Swift 6.4 (language mode 5), SwiftUI (`Window`, `Settings`, `MenuBarExtra`, `Commands`), AppKit, Combine, WhisperKit, `codesign`/`iconutil`. + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "App-shell", "Instellingen", "Rechten en eerste start") + +**Voorwaarde:** fase 1, 2 en 3 zijn afgerond (`DictationController` met corrector/`onResult`, `HotkeyBinding`, `SstData`, `SstUI` met tokens en componenten). + +## Global Constraints + +- Echte app: Dock-icoon, geen `LSUIElement`; bundle-id blijft `local.sst.app`; niet gesandboxed. +- Sluit je het hoofdvenster, dan draait de app door (Dock-icoon en menubalkitem blijven; dicteren werkt). ⌘Q sluit af. Klik op het Dock-icoon opent het venster weer. +- Menubalkitem is secundair: statusregel, sneltoets, "Open Sst", "Instellingen…", "Stop Sst". Geen geschiedenis, geen meter. +- Sneltoetsen alleen: Fn (standaard), Rechter Option, Rechter Command, Rechter Control, Linker Control; alleen vasthouden. Vastleggen door erop te drukken. +- Modellen (exacte WhisperKit-varianten, groottes gemeten bij Hugging Face): `large-v3-v20240930_turbo` (standaard, 1638 MB), `large-v3-v20240930_turbo_632MB` (646 MB), `small` (486 MB). Wisselen laadt het nieuwe model en zet dicteren uit tot het klaar is; mislukt het, dan blijft het vorige actief. +- Tekst wordt niet geplakt wanneer Sst zelf voorop staat of de opname via het venster/⌥⌘R is gestart; hij gaat dan alleen naar de geschiedenis. +- Rechten worden opnieuw gelezen zodra de app actief wordt en elke 2 s zolang een weergave met de lampjes zichtbaar is. +- Ondertekening met de eerste "Apple Development"-identiteit (of `SST_SIGN_IDENTITY`); geen identiteit in de repo; zonder identiteit ad-hoc met een duidelijke waarschuwing. +- UI-teksten Nederlands, allemaal in `Strings.swift`; alleen tokens in views. +- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). + +## Bestandsstructuur + +``` +Package.swift (wijzig) Sst-afhankelijkheden, SstIcon +Sources/SstCore/AppGlue.swift (nieuw) GatedInserter, SwappableTranscriber, LevelSource +Sources/SstData/AppSettings.swift (nieuw) +Sources/SstWhisper/ModelStorage.swift (nieuw) ModelOption, ModelCatalog, ModelStorage +Sources/SstWhisper/WhisperKitTranscriber.swift (vervang) gebruikt ModelStorage +Sources/SstUI/Controls.swift (aanvullen) RockerToggleStyle +Sources/Sst/AppIdentity.swift, Strings.swift, PermissionMonitor.swift, AppModel.swift (nieuw) +Sources/Sst/SstApp.swift, MainWindow.swift, AppCommands.swift, MenuBarContent.swift, SettingsView.swift (nieuw) +Sources/Sst/AppModel.swift (oud), MenuContent.swift (verwijderd) +Sources/SstIcon/IconMaker.swift (nieuw) rendert het icoon uit de tokens +scripts/make-icon.sh, scripts/make-app.sh (nieuw/vervang) +Design/AppIcon.icns (gegenereerd, gecommit) +Tests/SstCoreTests/AppGlueTests.swift, Tests/SstDataTests/AppSettingsTests.swift, +Tests/SstWhisperTests/ModelStorageTests.swift (nieuw) +``` + +--- + +### Task 1: Lijmcode in `SstCore` + +**Files:** +- Create: `Sources/SstCore/AppGlue.swift` +- Test: `Tests/SstCoreTests/AppGlueTests.swift` + +**Interfaces:** +- Produces: + - `GatedInserter(inner: TextInserting, shouldInsert: @escaping () -> Bool)` — vraagt `shouldInsert()` op het moment van invoegen + - `SwappableTranscriber(_ initial: Transcriber? = nil)` met `swap(to:)`, `hasTranscriber`, beide `transcribe`-varianten; werpt `SwapError.noTranscriber` zonder model + - `LevelSource` met `dbfs`, `update(_:)`, `reset()` (thread-safe; start op `AudioLevel.silenceFloorDbfs`) + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstCoreTests/AppGlueTests.swift`: + +```swift +import Foundation +import Testing +@testable import SstCore + +struct GatedInserterTests { + @Test func insertsWhenAllowed() async throws { + let inner = FakeInserter() + try await GatedInserter(inner: inner, shouldInsert: { true }).insert("hallo") + #expect(inner.inserted == ["hallo"]) + } + + @Test func staysSilentWhenNotAllowed() async throws { + let inner = FakeInserter() + try await GatedInserter(inner: inner, shouldInsert: { false }).insert("hallo") + #expect(inner.inserted.isEmpty) + } + + @Test func askedAtInsertionTimeNotAtCreation() async throws { + let inner = FakeInserter() + var allowed = false + let gated = GatedInserter(inner: inner, shouldInsert: { allowed }) + allowed = true + try await gated.insert("nu wel") + #expect(inner.inserted == ["nu wel"]) + } +} + +struct SwappableTranscriberTests { + @Test func throwsWithoutATranscriber() async { + let swappable = SwappableTranscriber() + #expect(!swappable.hasTranscriber) + await #expect(throws: SwappableTranscriber.SwapError.self) { + _ = try await swappable.transcribe([0]) + } + } + + @Test func delegatesIncludingTheVocabulary() async throws { + let inner = FakeVocabularyTranscriber() + inner.result = "klaar" + let swappable = SwappableTranscriber(inner) + #expect(try await swappable.transcribe([0], vocabulary: ["Anthropic"]) == "klaar") + #expect(inner.vocabularies == [["Anthropic"]]) + #expect(try await swappable.transcribe([0]) == "klaar") + #expect(inner.vocabularies == [["Anthropic"], []]) + } + + @Test func swapReplacesTheTranscriber() async throws { + let first = FakeVocabularyTranscriber(), second = FakeVocabularyTranscriber() + first.result = "een"; second.result = "twee" + let swappable = SwappableTranscriber(first) + #expect(try await swappable.transcribe([0]) == "een") + swappable.swap(to: second) + #expect(try await swappable.transcribe([0]) == "twee") + swappable.swap(to: nil) + #expect(!swappable.hasTranscriber) + } +} + +struct LevelSourceTests { + @Test func startsSilentAndKeepsTheLatestValue() { + let source = LevelSource() + #expect(source.dbfs == AudioLevel.silenceFloorDbfs) + source.update(-23) + source.update(-18) + #expect(source.dbfs == -18) + source.reset() + #expect(source.dbfs == AudioLevel.silenceFloorDbfs) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "GatedInserterTests|SwappableTranscriberTests|LevelSourceTests"` +Expected: compilatiefouten (`cannot find 'GatedInserter' in scope`). + +- [ ] **Step 3: Implementatie** + +`Sources/SstCore/AppGlue.swift`: + +```swift +import Foundation + +/// Inserts text only while `shouldInsert()` says so (not when Sst itself is in front, nor for window recordings). +public struct GatedInserter: TextInserting { + private let inner: TextInserting + private let shouldInsert: () -> Bool + + public init(inner: TextInserting, shouldInsert: @escaping () -> Bool) { + self.inner = inner + self.shouldInsert = shouldInsert + } + + public func insert(_ text: String) async throws { + guard shouldInsert() else { return } + try await inner.insert(text) + } +} + +/// Lets the app replace the speech model (for example after a model switch) without rebuilding the controller. +public final class SwappableTranscriber: Transcriber, @unchecked Sendable { + public enum SwapError: Error, LocalizedError { + case noTranscriber + + public var errorDescription: String? { "Spraakmodel is nog niet geladen." } + } + + private let lock = NSLock() + private var current: Transcriber? + + public init(_ initial: Transcriber? = nil) { + current = initial + } + + public func swap(to transcriber: Transcriber?) { + lock.withLock { current = transcriber } + } + + public var hasTranscriber: Bool { + lock.withLock { current != nil } + } + + public func transcribe(_ samples: [Float]) async throws -> String { + try await transcribe(samples, vocabulary: []) + } + + public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + guard let transcriber = lock.withLock({ current }) else { throw SwapError.noTranscriber } + return try await transcriber.transcribe(samples, vocabulary: vocabulary) + } +} + +/// The latest input level, written from the audio thread and read by the meter once per frame. +public final class LevelSource: @unchecked Sendable { + private let lock = NSLock() + private var value = AudioLevel.silenceFloorDbfs + + public init() {} + + public var dbfs: Float { lock.withLock { value } } + + public func update(_ dbfs: Float) { + lock.withLock { value = dbfs } + } + + public func reset() { + update(AudioLevel.silenceFloorDbfs) + } +} +``` + +- [ ] **Step 4: Draai de tests** + +Run: `swift test --filter "GatedInserterTests|SwappableTranscriberTests|LevelSourceTests"` +Expected: 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstCore Tests/SstCoreTests +git commit -m "feat: add GatedInserter, SwappableTranscriber and LevelSource" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: `AppSettings` + +**Files:** +- Create: `Sources/SstData/AppSettings.swift` +- Test: `Tests/SstDataTests/AppSettingsTests.swift` + +**Interfaces:** +- Produces: `final class AppSettings: ObservableObject` met `init(defaults: UserDefaults = .standard)`, `@Published` `hotkeyID` ("fn"), `modelVariant` ("large-v3-v20240930_turbo"), `keepHistory` (true), `usePriming` (true), `useCorrection` (true); elke wijziging wordt direct in `UserDefaults` bewaard. `AppSettings.defaultModelVariant` is bewust gelijk aan `ModelCatalog.defaultVariant` (SstData kent SstWhisper niet). + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/AppSettingsTests.swift`: + +```swift +import Foundation +import Testing +@testable import SstData + +struct AppSettingsTests { + func freshDefaults() -> UserDefaults { + let name = "sst-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test func defaultsMatchTheSpec() { + let settings = AppSettings(defaults: freshDefaults()) + #expect(settings.hotkeyID == "fn") + #expect(settings.modelVariant == "large-v3-v20240930_turbo") + #expect(settings.keepHistory) + #expect(settings.usePriming) + #expect(settings.useCorrection) + } + + @Test func changesArePersisted() { + let defaults = freshDefaults() + let settings = AppSettings(defaults: defaults) + settings.hotkeyID = "rightOption" + settings.modelVariant = "small" + settings.keepHistory = false + settings.usePriming = false + settings.useCorrection = false + + let reloaded = AppSettings(defaults: defaults) + #expect(reloaded.hotkeyID == "rightOption") + #expect(reloaded.modelVariant == "small") + #expect(!reloaded.keepHistory) + #expect(!reloaded.usePriming) + #expect(!reloaded.useCorrection) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter AppSettingsTests` +Expected: compilatiefout `cannot find 'AppSettings' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/AppSettings.swift`: + +```swift +import Combine +import Foundation + +/// User preferences, persisted in UserDefaults as soon as a value changes. +public final class AppSettings: ObservableObject { + public static let defaultModelVariant = "large-v3-v20240930_turbo" + + enum Key { + static let hotkey = "hotkeyID" + static let model = "modelVariant" + static let keepHistory = "keepHistory" + static let usePriming = "usePriming" + static let useCorrection = "useCorrection" + } + + private let defaults: UserDefaults + + /// `HotkeyBinding.id` of the key that has to be held. + @Published public var hotkeyID: String { + didSet { defaults.set(hotkeyID, forKey: Key.hotkey) } + } + @Published public var modelVariant: String { + didSet { defaults.set(modelVariant, forKey: Key.model) } + } + @Published public var keepHistory: Bool { + didSet { defaults.set(keepHistory, forKey: Key.keepHistory) } + } + /// Give the speech engine the dictionary words as context. + @Published public var usePriming: Bool { + didSet { defaults.set(usePriming, forKey: Key.usePriming) } + } + /// Apply the dictionary corrections after transcription. + @Published public var useCorrection: Bool { + didSet { defaults.set(useCorrection, forKey: Key.useCorrection) } + } + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + hotkeyID = defaults.string(forKey: Key.hotkey) ?? "fn" + modelVariant = defaults.string(forKey: Key.model) ?? Self.defaultModelVariant + keepHistory = defaults.object(forKey: Key.keepHistory) as? Bool ?? true + usePriming = defaults.object(forKey: Key.usePriming) as? Bool ?? true + useCorrection = defaults.object(forKey: Key.useCorrection) as? Bool ?? true + } +} +``` + +- [ ] **Step 4: Draai de tests** + +Run: `swift test --filter AppSettingsTests` +Expected: 2 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstData Tests/SstDataTests +git commit -m "feat: add AppSettings" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: `ModelStorage`, `ModelCatalog` en de transcriber + +**Files:** +- Create: `Sources/SstWhisper/ModelStorage.swift` +- Replace: `Sources/SstWhisper/WhisperKitTranscriber.swift` +- Test: `Tests/SstWhisperTests/ModelStorageTests.swift` + +**Interfaces:** +- Consumes: bestaande `WhisperKitTranscriber` (fase 1: `transcribe(_:vocabulary:)`, `promptTokens`). +- Produces: + - `ModelOption` (`id`, `title`, `detail`, `approxSizeMB`), `ModelCatalog.all`, `ModelCatalog.defaultVariant` + - `ModelStorage(base:)` met `base`, `folder(for:) -> URL?`, `isInstalled(_:)`, `delete(_:) throws`, `defaultBase` (`~/Library/Application Support/Sst/models`); varianten worden exact gematcht (`openai_whisper-`) + - `WhisperKitTranscriber(model:storage:)` met `variant`, `load()`, `onProgress` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstWhisperTests/ModelStorageTests.swift`: + +```swift +import Foundation +import Testing +@testable import SstWhisper + +struct ModelStorageTests { + func makeStorage() -> ModelStorage { + ModelStorage(base: FileManager.default.temporaryDirectory.appending(path: "sst-models-\(UUID().uuidString)")) + } + + /// Creates `//.mlmodelc/coremldata.bin` for the given models. + func install(_ storage: ModelStorage, folder: String, models: [String] = ModelStorage.requiredModels) throws { + for model in models { + let dir = storage.repoDirectory.appending(path: "\(folder)/\(model).mlmodelc", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data([1]).write(to: dir.appending(path: "coremldata.bin")) + } + } + + @Test func emptyStorageHasNothingInstalled() { + let storage = makeStorage() + #expect(storage.folder(for: "small") == nil) + #expect(!storage.isInstalled("small")) + } + + @Test func completeInstallIsFound() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo") + #expect(storage.isInstalled("large-v3-v20240930_turbo")) + #expect(storage.folder(for: "large-v3-v20240930_turbo")?.lastPathComponent == "openai_whisper-large-v3-v20240930_turbo") + } + + @Test func incompleteInstallDoesNotCount() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-small", models: ["MelSpectrogram", "AudioEncoder"]) + #expect(!storage.isInstalled("small")) + } + + @Test func variantsAreMatchedExactly() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-large-v3-v20240930_turbo_632MB") + #expect(!storage.isInstalled("large-v3-v20240930_turbo")) + #expect(storage.isInstalled("large-v3-v20240930_turbo_632MB")) + } + + @Test func deleteRemovesOnlyThatVariant() throws { + let storage = makeStorage() + try install(storage, folder: "openai_whisper-small") + try install(storage, folder: "openai_whisper-base") + try storage.delete("small") + #expect(!storage.isInstalled("small")) + #expect(storage.isInstalled("base")) + } + + @Test func deletingSomethingNotInstalledIsHarmless() throws { + try makeStorage().delete("small") + } + + @Test func catalogStartsWithTheDefaultAndHasUniqueIds() { + #expect(ModelCatalog.all.first?.id == ModelCatalog.defaultVariant) + #expect(Set(ModelCatalog.all.map(\.id)).count == ModelCatalog.all.count) + #expect(ModelCatalog.all.allSatisfy { $0.approxSizeMB > 0 }) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter ModelStorageTests` +Expected: compilatiefout `cannot find 'ModelStorage' in scope`. + +- [ ] **Step 3: Implementatie** + +`Sources/SstWhisper/ModelStorage.swift`: + +```swift +import Foundation + +/// A speech model the user can pick in Settings. +public struct ModelOption: Identifiable, Equatable, Sendable { + public let id: String // WhisperKit variant name + public let title: String + public let detail: String + public let approxSizeMB: Int + + public init(id: String, title: String, detail: String, approxSizeMB: Int) { + self.id = id + self.title = title + self.detail = detail + self.approxSizeMB = approxSizeMB + } +} + +public enum ModelCatalog { + public static let defaultVariant = "large-v3-v20240930_turbo" + + /// Sizes measured from the Hugging Face repository `argmaxinc/whisperkit-coreml`. + public static let all: [ModelOption] = [ + ModelOption(id: defaultVariant, title: "Whisper Large v3 Turbo", detail: "Aanbevolen · nauwkeurigst", approxSizeMB: 1638), + ModelOption(id: "large-v3-v20240930_turbo_632MB", title: "Whisper Large v3 Turbo (compact)", detail: "Kleiner, iets minder nauwkeurig", approxSizeMB: 646), + ModelOption(id: "small", title: "Whisper Small", detail: "Snel en licht, minder nauwkeurig", approxSizeMB: 486), + ] +} + +/// Where models live on disk: `/models/argmaxinc/whisperkit-coreml/openai_whisper-`. +public struct ModelStorage: Sendable { + public static let repo = "argmaxinc/whisperkit-coreml" + public static let requiredModels = ["MelSpectrogram", "AudioEncoder", "TextDecoder"] + + public let base: URL + + public init(base: URL = ModelStorage.defaultBase) { + self.base = base + } + + /// Application Support, so models are neither in ~/Documents (privacy prompt, iCloud) nor re-fetched. + public static var defaultBase: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appending(path: "Sst/models", directoryHint: .isDirectory) + } + + var repoDirectory: URL { + base.appending(path: "models/\(Self.repo)", directoryHint: .isDirectory) + } + + private func folders(for variant: String) -> [URL] { + let names = (try? FileManager.default.contentsOfDirectory(atPath: repoDirectory.path)) ?? [] + return names + .filter { $0 == variant || $0 == "openai_whisper-\(variant)" } + .map { repoDirectory.appending(path: $0, directoryHint: .isDirectory) } + } + + /// The folder of a completely downloaded variant, or nil. + public func folder(for variant: String) -> URL? { + folders(for: variant).first { folder in + Self.requiredModels.allSatisfy { + FileManager.default.fileExists(atPath: folder.appending(path: "\($0).mlmodelc/coremldata.bin").path) + } + } + } + + public func isInstalled(_ variant: String) -> Bool { + folder(for: variant) != nil + } + + public func delete(_ variant: String) throws { + for folder in folders(for: variant) { try FileManager.default.removeItem(at: folder) } + } +} +``` + +Vervang `Sources/SstWhisper/WhisperKitTranscriber.swift` volledig door: + +```swift +import Foundation +import SstCore +import WhisperKit + +public final class WhisperKitTranscriber: Transcriber { + public enum TranscriberError: Error, LocalizedError { + case notLoaded + + public var errorDescription: String? { "Spraakmodel is nog niet geladen." } + } + + private let modelName: String + private let storage: ModelStorage + private var pipeline: WhisperKit? + + /// Download progress (0...1) during the one-time model download. May be called from any thread. + public var onProgress: ((Double) -> Void)? + + public init(model: String = ModelCatalog.defaultVariant, storage: ModelStorage = ModelStorage()) { + self.modelName = model + self.storage = storage + } + + public var variant: String { modelName } + + private func loadPipeline(from folder: URL) async throws -> WhisperKit { + try await WhisperKit(WhisperKitConfig( + downloadBase: storage.base, + modelFolder: folder.path, + tokenizerFolder: storage.base, + prewarm: true, + load: true, + download: false + )) + } + + /// Loads the model into memory. Downloads it (≈1.5 GB) only if it is not stored locally yet; + /// once downloaded, launching needs no network. + public func load() async throws { + guard pipeline == nil else { return } + try FileManager.default.createDirectory(at: storage.base, withIntermediateDirectories: true) + + if let folder = storage.folder(for: modelName), + let loaded = try? await loadPipeline(from: folder) { + pipeline = loaded + return + } + let folder = try await WhisperKit.download( + variant: modelName, + downloadBase: storage.base, + from: ModelStorage.repo + ) { [onProgress] progress in + onProgress?(progress.fractionCompleted) + } + pipeline = try await loadPipeline(from: folder) + } + + public func transcribe(_ samples: [Float]) async throws -> String { + try await transcribe(samples, vocabulary: []) + } + + /// Terms are added one by one while the encoded prompt stays within `maxTokens`. + /// Returns nil when nothing fits or the list is empty. + static func promptTokens( + vocabulary: [String], + maxTokens: Int = 64, + specialTokenBegin: Int, + encode: (String) -> [Int] + ) -> [Int]? { + var kept: [String] = [] + var tokens: [Int] = [] + for term in vocabulary { + let candidate = kept + [term] + let encoded = encode(" " + candidate.joined(separator: ", ")).filter { $0 < specialTokenBegin } + if encoded.count > maxTokens { break } + kept = candidate + tokens = encoded + } + return tokens.isEmpty ? nil : tokens + } + + public func transcribe(_ samples: [Float], vocabulary: [String]) async throws -> String { + guard let pipeline else { throw TranscriberError.notLoaded } + // language nil + detectLanguage: Whisper picks NL or EN per utterance. + var options = DecodingOptions( + task: .transcribe, + language: nil, + detectLanguage: true, + skipSpecialTokens: true, + withoutTimestamps: true + ) + if !vocabulary.isEmpty, let tokenizer = pipeline.tokenizer { + options.promptTokens = Self.promptTokens( + vocabulary: vocabulary, + specialTokenBegin: tokenizer.specialTokens.specialTokenBegin, + encode: { tokenizer.encode(text: $0) } + ) + } + let results = try await pipeline.transcribe(audioArray: samples, decodeOptions: options) + return results + .map(\.text) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} +``` + +- [ ] **Step 4: Draai alle tests** + +Run: `swift test` +Expected: alle tests PASS (7 nieuwe `ModelStorageTests`; `PromptTokensTests` uit fase 1 blijft groen). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstWhisper Tests/SstWhisperTests +git commit -m "feat: add ModelStorage and ModelCatalog; transcriber uses ModelStorage" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: `RockerToggleStyle` in `SstUI` + +**Files:** +- Modify: `Sources/SstUI/Controls.swift` (onderaan aanvullen) + +**Interfaces:** +- Consumes: `WellBackground`, `PhysicalSurface`, tokens (`Size.switchWidth/Height`, `Shadows.keyRaised`, `Motion.durationFast`). +- Produces: `RockerToggleStyle()` (`ToggleStyle`): label links, tuimelschakelaar rechts; toegankelijkheidswaarde "aan"/"uit". + +- [ ] **Step 1: Implementatie** + +Voeg onderaan `Sources/SstUI/Controls.swift` toe (een lege regel ertussen): + +```swift +/// A two-position rocker switch: a key cap sliding in a recessed track. +public struct RockerToggleStyle: ToggleStyle { + public init() {} + + public func makeBody(configuration: Configuration) -> some View { + HStack(spacing: Space.s4) { + configuration.label + Button { + configuration.isOn.toggle() + } label: { + RockerTrack(isOn: configuration.isOn) + } + .buttonStyle(.plain) + .accessibilityValue(configuration.isOn ? "aan" : "uit") + } + } + + private struct RockerTrack: View { + let isOn: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + ZStack(alignment: isOn ? .trailing : .leading) { + WellBackground() + PhysicalSurface( + fill: AnyShapeStyle(LinearGradient(colors: [Palette.surfaceKeyHigh, Palette.surfaceKeyLow], startPoint: .top, endPoint: .bottom)), + cornerRadius: Radius.sm, shadows: Shadows.keyRaised) + .frame(width: Size.switchWidth / 2, height: Size.switchHeight - Space.s2) + .padding(Space.s1) + } + .frame(width: Size.switchWidth, height: Size.switchHeight) + .animation(reduceMotion ? nil : .easeOut(duration: Motion.durationFast), value: isOn) + } + } +} +``` + +- [ ] **Step 2: Bouwen en scannen** + +Run: `swift build && swift test --filter SstUITests` +Expected: build zonder fouten of waarschuwingen; alle SstUI-tests PASS (geen losse waarden). + +- [ ] **Step 3: Commit** + +```bash +git add Sources/SstUI +git commit -m "feat: add rocker toggle style" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: App-kern — identiteit, teksten, rechten en `AppModel` + +**Files:** +- Create: `Sources/Sst/AppIdentity.swift`, `Sources/Sst/Strings.swift`, `Sources/Sst/PermissionMonitor.swift` +- Replace: `Sources/Sst/AppModel.swift` +- Modify: `Package.swift` + +**Interfaces:** +- Consumes: Task 1–3, fase 1 (`DictationController` met `corrector`, `vocabularyProvider`, `onResult`; `HotkeyMonitor.binding`; `AudioRecorder.onLevel`), fase 2 (`DictionaryStore`, `DictionaryCorrector`, `HistoryStore`, `VocabularyBuilder`, `SystemWordKnowledge`). +- Produces (gebruikt door de views en door fase 5): + - `AppIdentity.displayName`, `.brandCaption`; `AppPaths.support/dictionary/history` + - `Strings` (alle UI-teksten) + - `PermissionMonitor` met `microphone/accessibility/inputMonitoring`, `allGranted`, `isGranted(_:)`, `refresh()`, `requestMissing()`, `openSettings(for:)`, `startPolling()/stopPolling()`; `PermissionMonitor.Kind` + - `AppModel` (`@MainActor ObservableObject`): `settings`, `permissions`, `levelSource`, `history`, `dictionary`, `storage`, `state`, `modelStatus` (`.loading/.downloading/.ready/.failed`), `activeVariant`, `installedVariants`, `recordingStartedAt`, `lastDuration`, `lastError`, `section` (`.transcripts/.dictionary`), `searchFocusTick`, `confirmingClearHistory`, `isRecording`, `modelReady`, `hotkeyBinding`, `recordingBlocker`, `statusLine`, `menuIconName`, `title(for:)`, `retryHotkey()`, `toggleWindowRecording()`, `startWindowRecording()`, `stopWindowRecording()`, `refreshInstalled()`, `activateModel(_:)`, `deleteModel(_:)`, `show(_:)`, `focusSearch()`, `clearHistory()` + +- [ ] **Step 1: Package.swift** + +Vervang in `Package.swift` de regel + +```swift + .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]), +``` + +door: + +```swift + .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper", "SstData", "SstUI"]), +``` + +(Het `SstIcon`-target volgt in Task 7, samen met zijn bronbestanden; een target zonder bronnen laat SwiftPM elke build weigeren.) + +- [ ] **Step 2: Oude app-bestanden opruimen** + +Run: `git rm Sources/Sst/MenuContent.swift` +(`Sources/Sst/AppModel.swift` en `SstApp.swift` worden in deze en de volgende taak vervangen.) + +- [ ] **Step 3: Bestanden schrijven** + +`Sources/Sst/AppIdentity.swift`: + +```swift +import Foundation + +/// The one place that knows the app's name, so renaming it later is a one-file change. +enum AppIdentity { + static let displayName = "Sst" + static let supportFolderName = "Sst" + static let brandCaption = "SST · FIELD RECORDER" +} + +enum AppPaths { + static var support: URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appending(path: AppIdentity.supportFolderName, directoryHint: .isDirectory) + } + static var dictionary: URL { support.appending(path: "dictionary.txt") } + static var history: URL { support.appending(path: "history.json") } +} +``` + +`Sources/Sst/Strings.swift`: + +```swift +import Foundation + +/// All user-facing text, in one place (Dutch), so translating later is a single-file job. +enum Strings { + // sections + static let transcripts = "Transcripties" + static let dictionary = "Woordenboek" + static let controls = "Controles" + + // deck + static let record = "Rec" + static let stop = "Stop" + static let time = "Tijd" + static let level = "Niveau" + + // menus + static let recordingMenu = "Opname" + static let startRecording = "Start opname" + static let stopRecording = "Stop opname" + static let search = "Zoek" + static let clearHistory = "Wis geschiedenis…" + static let openApp = "Open Sst" + static let settings = "Instellingen…" + static let quit = "Stop Sst" + + // status + static let statusIdle = "Klaar" + static let statusListening = "Luistert…" + static let statusTranscribing = "Transcriberen…" + static let statusInserting = "Invoegen…" + static func modelLoading(_ title: String) -> String { "Model laden: \(title)…" } + static func modelDownloading(_ percent: Int) -> String { "Model downloaden… \(percent)%" } + static let modelFailed = "Model laden mislukt" + static func holdToDictate(_ keyName: String) -> String { "Houd \(keyName) ingedrukt om te dicteren" } + + // permissions + static let microphone = "Microfoon" + static let accessibility = "Accessibility" + static let inputMonitoring = "Input Monitoring" + static let modelLamp = "Model" + static let microphoneMissing = "Microfoon-toegang ontbreekt" + static let openSystemSettings = "Open Instellingen" + + // settings + static let tabHotkey = "Sneltoets" + static let tabModel = "Model" + static let tabPrivacy = "Privacy" + static let captureHotkey = "Leg nieuwe toets vast" + static let capturing = "Druk op de toets die je wilt gebruiken…" + static let hotkeyRejected = "Die toets kan niet. Kies Fn, Rechter Option, Rechter Command of Control." + static let globeHint = "Zet bij Fn in Systeeminstellingen → Toetsenbord ‘Druk op 🌐 voor’ op ‘Doe niets’." + static let useModel = "Gebruik" + static let download = "Download" + static let remove = "Verwijder" + static let showInFinder = "Toon in Finder" + static let keepHistory = "Transcripties bewaren" + static let clearHistoryConfirm = "Alle transcripties wissen?" + static let clearHistoryDetail = "Dit kan niet ongedaan worden gemaakt. Audio wordt nooit bewaard." + static let cancel = "Annuleer" + static let erase = "Wis" +} +``` + +`Sources/Sst/PermissionMonitor.swift`: + +```swift +import AppKit +import ApplicationServices +import AVFoundation +import Combine + +/// Reads the three macOS permissions and keeps them current: on every app activation and while polling is on. +@MainActor +final class PermissionMonitor: ObservableObject { + enum Kind: CaseIterable, Identifiable { + case microphone, accessibility, inputMonitoring + + var id: Self { self } + + var title: String { + switch self { + case .microphone: Strings.microphone + case .accessibility: Strings.accessibility + case .inputMonitoring: Strings.inputMonitoring + } + } + + fileprivate var pane: String { + switch self { + case .microphone: "Privacy_Microphone" + case .accessibility: "Privacy_Accessibility" + case .inputMonitoring: "Privacy_ListenEvent" + } + } + } + + @Published private(set) var microphone = false + @Published private(set) var accessibility = false + @Published private(set) var inputMonitoring = false + + private var timer: Timer? + private var activation: AnyCancellable? + + init() { + refresh() + activation = NotificationCenter.default + .publisher(for: NSApplication.didBecomeActiveNotification) + .sink { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } } + } + + var allGranted: Bool { microphone && accessibility && inputMonitoring } + + func isGranted(_ kind: Kind) -> Bool { + switch kind { + case .microphone: microphone + case .accessibility: accessibility + case .inputMonitoring: inputMonitoring + } + } + + func refresh() { + microphone = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + accessibility = AXIsProcessTrusted() + inputMonitoring = CGPreflightListenEventAccess() + } + + /// Asks macOS for every permission that is still missing (each shows its own system prompt). + func requestMissing() async { + if !microphone { _ = await AVCaptureDevice.requestAccess(for: .audio) } + if !inputMonitoring { _ = CGRequestListenEventAccess() } + if !accessibility { _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) } + refresh() + } + + func openSettings(for kind: Kind) { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(kind.pane)") { + NSWorkspace.shared.open(url) + } + } + + /// Polls while a view that shows the lamps is visible, so granting a permission shows up without a click. + func startPolling(every interval: TimeInterval = 2) { + guard timer == nil else { return } + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.refresh() } + } + } + + func stopPolling() { + timer?.invalidate() + timer = nil + } +} +``` + +`Sources/Sst/AppModel.swift` (vervang volledig): + +```swift +import AppKit +import AVFoundation +import Combine +import SstCore +import SstData +import SstWhisper +import SwiftUI + +/// Decides at insertion time whether dictated text may be pasted. +/// Not when Sst itself is in front (it would land in Sst's own search field) and not for recordings started in the window. +final class InsertionPolicy { + var windowSession = false + + func allowed() -> Bool { + !windowSession && NSWorkspace.shared.frontmostApplication?.bundleIdentifier != Bundle.main.bundleIdentifier + } +} + +@MainActor +final class AppModel: ObservableObject { + enum Section: String, CaseIterable, Identifiable { + case transcripts, dictionary + var id: String { rawValue } + } + + enum ModelStatus: Equatable { + case loading(String) + case downloading(String, Double) + case ready + case failed(String) + } + + // Services + let settings: AppSettings + let permissions = PermissionMonitor() + let levelSource = LevelSource() + let history: HistoryStore + let dictionary: DictionaryStore + let storage = ModelStorage() + + // Observable state + @Published private(set) var state: DictationState = .idle + @Published private(set) var modelStatus: ModelStatus = .loading(ModelCatalog.defaultVariant) + @Published private(set) var activeVariant: String? + @Published private(set) var installedVariants: Set = [] + @Published private(set) var recordingStartedAt: Date? + @Published private(set) var lastDuration: TimeInterval? + /// Bumped whenever the history or dictionary changes, so views that cache derived data can refresh. + @Published private(set) var historyRevision = 0 + @Published private(set) var dictionaryRevision = 0 + @Published private(set) var searchFocusTick = 0 + @Published var lastError: String? + @Published var section: Section = .transcripts + @Published var confirmingClearHistory = false + + private let transcriber = SwappableTranscriber() + private let corrector: DictionaryCorrector + private let hotkey = HotkeyMonitor() + private let policy: InsertionPolicy + private let controller: DictationController + private var modelTask: Task? + private var loadingVariant: String? + private var cancellables = Set() + + init() { + let settings = AppSettings() + let policy = InsertionPolicy() + let knowledge = SystemWordKnowledge() + let corrector = DictionaryCorrector(knowledge: knowledge) + let dictionary = DictionaryStore(fileURL: AppPaths.dictionary) + let history = HistoryStore(fileURL: AppPaths.history) + let levelSource = self.levelSource + let recorder = AudioRecorder() + recorder.onLevel = { levelSource.update($0) } + + self.settings = settings + self.policy = policy + self.corrector = corrector + self.dictionary = dictionary + self.history = history + self.controller = DictationController( + recorder: recorder, + transcriber: transcriber, + cleaner: PassthroughCleaner(), + inserter: GatedInserter( + inner: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()), + shouldInsert: { policy.allowed() }), + corrector: corrector, + vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] }) + + wire() + Task { await start() } + } + + // MARK: derived + + var isRecording: Bool { state == .recording } + var hotkeyBinding: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn } + var modelReady: Bool { modelStatus == .ready } + + /// Why recording is not possible right now, or nil when it is. + var recordingBlocker: String? { + if !permissions.allGranted { return Strings.openSystemSettings } + switch modelStatus { + case .ready: return nil + case .loading(let variant): return Strings.modelLoading(title(for: variant)) + case .downloading(_, let fraction): return Strings.modelDownloading(Int(fraction * 100)) + case .failed: return Strings.modelFailed + } + } + + var statusLine: String { + if let blocker = recordingBlocker, state == .idle { return blocker } + switch state { + case .idle: return Strings.holdToDictate(hotkeyBinding.displayName) + case .recording: return Strings.statusListening + case .transcribing: return Strings.statusTranscribing + case .inserting: return Strings.statusInserting + } + } + + var menuIconName: String { + switch state { + case .idle: + if lastError != nil { return "exclamationmark.triangle" } + return modelReady ? "mic" : "mic.slash" + case .recording: return "mic.fill" + case .transcribing, .inserting: return "waveform" + } + } + + func title(for variant: String) -> String { + ModelCatalog.all.first { $0.id == variant }?.title ?? variant + } + + // MARK: lifecycle + + private func wire() { + controller.onStateChange = { [weak self] state in + guard let self else { return } + self.state = state + switch state { + case .recording: + self.lastError = nil + self.recordingStartedAt = Date() + self.levelSource.reset() + case .idle: + if let started = self.recordingStartedAt { self.lastDuration = Date().timeIntervalSince(started) } + self.recordingStartedAt = nil + self.levelSource.reset() + default: break + } + } + controller.onError = { [weak self] in self?.lastError = $0 } + controller.onResult = { [weak self] result in + guard let self, self.settings.keepHistory else { return } + self.history.add(HistoryEntry(result: result)) + } + hotkey.onEvent = { [weak self] event in self?.handleHotkey(event) } + hotkey.binding = hotkeyBinding + + settings.$hotkeyID + .sink { [weak self] _ in + guard let self else { return } + self.hotkey.binding = self.hotkeyBinding + self.objectWillChange.send() + } + .store(in: &cancellables) + settings.$useCorrection + .sink { [corrector] in corrector.setEnabled($0) } + .store(in: &cancellables) + permissions.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) + + dictionary.onChange = { [weak self] in + guard let self else { return } + self.corrector.update(entries: self.dictionary.entries) + self.dictionaryRevision += 1 + } + history.onChange = { [weak self] in self?.historyRevision += 1 } + NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in + MainActor.assumeIsolated { history.flush() } + } + } + + private func start() async { + do { try dictionary.load() } catch { lastError = error.localizedDescription } + dictionary.startWatching() + if case .recoveredCorrupt = history.load() { + lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet." + } + refreshInstalled() + await permissions.requestMissing() + retryHotkey() + activateModel(settings.modelVariant) + } + + func retryHotkey() { + permissions.refresh() + do { + try hotkey.start() + } catch { + lastError = error.localizedDescription + } + } + + // MARK: recording + + private func handleHotkey(_ event: HotkeyEvent) { + guard modelReady else { return } + if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { + lastError = Strings.microphoneMissing + return + } + Task { await controller.handle(event) } + } + + /// Start/stop from the window or ⌥⌘R. The result goes to the history only; nothing is pasted. + func toggleWindowRecording() { + if isRecording { + stopWindowRecording() + } else { + startWindowRecording() + } + } + + func startWindowRecording() { + guard recordingBlocker == nil, state == .idle else { return } + policy.windowSession = true + Task { await controller.handle(.pressed) } + } + + func stopWindowRecording() { + guard policy.windowSession else { return } + Task { + await controller.handle(.released) + policy.windowSession = false + } + } + + // MARK: model + + func refreshInstalled() { + installedVariants = Set(ModelCatalog.all.map(\.id).filter { storage.isInstalled($0) }) + } + + /// Downloads (if needed) and loads `variant`. Dictation is off meanwhile; if it fails the previous model stays. + func activateModel(_ variant: String) { + modelTask?.cancel() + let previous = activeVariant + loadingVariant = variant + modelStatus = .loading(variant) + modelTask = Task { [weak self] in + guard let self else { return } + let candidate = WhisperKitTranscriber(model: variant, storage: storage) + candidate.onProgress = { [weak self] fraction in + Task { @MainActor in + guard let self, self.loadingVariant == variant else { return } + self.modelStatus = fraction < 1 ? .downloading(variant, fraction) : .loading(variant) + } + } + do { + try await candidate.load() + guard !Task.isCancelled else { return } + transcriber.swap(to: candidate) + activeVariant = variant + settings.modelVariant = variant + modelStatus = .ready + } catch { + guard !Task.isCancelled else { return } + lastError = error.localizedDescription + modelStatus = previous == nil ? .failed(error.localizedDescription) : .ready + } + if loadingVariant == variant { loadingVariant = nil } + refreshInstalled() + } + } + + func deleteModel(_ variant: String) { + guard variant != activeVariant else { return } + do { + try storage.delete(variant) + } catch { + lastError = error.localizedDescription + } + refreshInstalled() + } + + // MARK: navigation and history + + func show(_ section: Section) { + self.section = section + NSApp.activate(ignoringOtherApps: true) + } + + func focusSearch() { + searchFocusTick += 1 + } + + func clearHistory() { + history.removeAll() + history.flush() + } +} +``` + +- [ ] **Step 4: Bouwen** + +De app bouwt pas na Task 6 (de scenes ontbreken nog); controleer hier alleen de bibliotheken: + +Run: `swift build --target SstCore --target SstData --target SstWhisper --target SstUI` +Expected: build zonder fouten. + +- [ ] **Step 5: Commit (samen met Task 6)** + +Er wordt pas gecommit na Task 6, want de app-target bouwt pas dan. + +--- + +### Task 6: Scenes, menu's, instellingen en menubalkitem + +**Files:** +- Create: `Sources/Sst/SstApp.swift`, `Sources/Sst/MainWindow.swift`, `Sources/Sst/AppCommands.swift`, `Sources/Sst/MenuBarContent.swift`, `Sources/Sst/SettingsView.swift` +- Test: de token-scan (`Tests/SstUITests/TokenIntegrityTests.swift`) bewaakt ook `Sources/Sst` + +**Interfaces:** +- Consumes: Task 5 (`AppModel`, `Strings`), `SstUI`. +- Produces: scenes `Window("Sst", id: "main")`, `Settings`, `MenuBarExtra`; menu "Opname" (⌥⌘R start/stop, ⌘1 Transcripties, ⌘2 Woordenboek, ⌘F zoek) en "Wis geschiedenis…" in Bewerken; `SettingsView` met drie toetsen-tabs; `MainWindow` is voorlopig een schil (fase 5 vervangt hem). + +- [ ] **Step 1: Bestanden schrijven** + +`Sources/Sst/SstApp.swift`: + +```swift +import SwiftUI +import SstUI + +@main +struct SstApp: App { + @StateObject private var model = AppModel() + + var body: some Scene { + Window(AppIdentity.displayName, id: "main") { + MainWindow() + .environmentObject(model) + .environmentObject(model.settings) + .frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight) + } + .defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) + .commands { AppCommands(model: model) } + + Settings { + SettingsView() + .environmentObject(model) + .environmentObject(model.settings) + } + + MenuBarExtra { + MenuBarContent() + .environmentObject(model) + } label: { + Image(systemName: model.menuIconName) + } + } +} +``` + +`Sources/Sst/MainWindow.swift`: + +```swift +import SwiftUI +import SstUI + +/// Placeholder shell; phase 5 replaces the content with the deck, history and dictionary. +struct MainWindow: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + ZStack { + BrushedBackground().ignoresSafeArea() + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(AppIdentity.brandCaption) + Text(model.statusLine) + .textStyle(Typography.reading) + .foregroundStyle(Palette.inkPrimary) + .padding(Layout.panelPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(PanelBackground()) + } + .padding(Layout.panelPadding) + } + } +} +``` + +`Sources/Sst/AppCommands.swift`: + +```swift +import SwiftUI + +struct AppCommands: Commands { + @ObservedObject var model: AppModel + @Environment(\.openWindow) private var openWindow + + var body: some Commands { + CommandGroup(after: .pasteboard) { + Divider() + Button(Strings.clearHistory) { + openWindow(id: "main") + model.confirmingClearHistory = true + } + } + CommandMenu(Strings.recordingMenu) { + Button(model.isRecording ? Strings.stopRecording : Strings.startRecording) { + model.toggleWindowRecording() + } + .keyboardShortcut("r", modifiers: [.command, .option]) + Divider() + Button(Strings.transcripts) { show(.transcripts) } + .keyboardShortcut("1", modifiers: .command) + Button(Strings.dictionary) { show(.dictionary) } + .keyboardShortcut("2", modifiers: .command) + Button(Strings.search) { + show(.transcripts) + model.focusSearch() + } + .keyboardShortcut("f", modifiers: .command) + } + } + + private func show(_ section: AppModel.Section) { + openWindow(id: "main") + model.show(section) + } +} +``` + +`Sources/Sst/MenuBarContent.swift`: + +```swift +import SwiftUI + +/// The secondary interface: status and shortcut while working in another app. +struct MenuBarContent: View { + @EnvironmentObject private var model: AppModel + @Environment(\.openWindow) private var openWindow + + var body: some View { + Text(model.statusLine) + if let error = model.lastError { + Text(error) + } + Divider() + Button(Strings.openApp) { + openWindow(id: "main") + model.show(model.section) + } + SettingsLink { Text(Strings.settings) } + Divider() + Button(Strings.quit) { NSApplication.shared.terminate(nil) } + .keyboardShortcut("q") + } +} +``` + +`Sources/Sst/SettingsView.swift`: + +```swift +import AppKit +import SstCore +import SstData +import SstUI +import SstWhisper +import SwiftUI + +struct SettingsView: View { + private enum Tab: CaseIterable { + case hotkey, model, privacy + + var title: String { + switch self { + case .hotkey: Strings.tabHotkey + case .model: Strings.tabModel + case .privacy: Strings.tabPrivacy + } + } + } + + @State private var tab: Tab = .hotkey + + var body: some View { + ZStack { + BrushedBackground() + VStack(alignment: .leading, spacing: Layout.panelGap) { + HStack(spacing: Space.s3) { + ForEach(Tab.allCases, id: \.self) { candidate in + Button(candidate.title) { tab = candidate } + .buttonStyle(KeyButtonStyle(latched: tab == candidate)) + } + } + Group { + switch tab { + case .hotkey: HotkeySettingsView() + case .model: ModelSettingsView() + case .privacy: PrivacySettingsView() + } + } + .padding(Layout.panelPadding) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(PanelBackground()) + } + .padding(Layout.panelPadding) + } + .frame(width: Layout.settingsWidth) + .fixedSize(horizontal: false, vertical: true) + } +} + +// MARK: hotkey + +extension HotkeyBinding { + /// The matching flag in an AppKit event, used while capturing a new key. + fileprivate var eventFlag: NSEvent.ModifierFlags { + switch id { + case "fn": .function + case "rightOption": .option + case "rightCommand": .command + default: .control + } + } +} + +@MainActor +private final class HotkeyCapture: ObservableObject { + @Published var isCapturing = false + @Published var message: String? + private var monitor: Any? + + func start(onCapture: @escaping (HotkeyBinding) -> Void) { + stop() + isCapturing = true + message = Strings.capturing + monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged, .keyDown]) { [weak self] event in + guard let self else { return event } + if event.type == .keyDown { + if event.keyCode == 53 { self.stop() } // Escape cancels + return nil + } + guard let binding = HotkeyBinding.binding(keyCode: Int(event.keyCode)) else { + self.message = Strings.hotkeyRejected + return nil + } + if event.modifierFlags.contains(binding.eventFlag) { + self.stop() + onCapture(binding) + } + return nil + } + } + + func stop() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + isCapturing = false + message = nil + } +} + +private struct HotkeySettingsView: View { + @EnvironmentObject private var settings: AppSettings + @StateObject private var capture = HotkeyCapture() + + private var current: HotkeyBinding { HotkeyBinding.binding(id: settings.hotkeyID) ?? .fn } + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.tabHotkey, style: Typography.heading) + HStack(spacing: Layout.panelGap) { + Text(current.displayName) + .textStyle(Typography.heading) + .foregroundStyle(Palette.inkPrimary) + .padding(.horizontal, Space.s4) + .frame(minHeight: Size.controlHeight) + .background(WellBackground()) + Button(Strings.captureHotkey) { + capture.start { settings.hotkeyID = $0.id } + } + .buttonStyle(KeyButtonStyle(latched: capture.isCapturing)) + } + if let message = capture.message { + Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + } + SeamDivider() + Text(HotkeyBinding.allowed.map(\.displayName).joined(separator: " · ")) + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + if current == .fn { + Text(Strings.globeHint).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + } + } + .onDisappear { capture.stop() } + } +} + +// MARK: model + +private struct ModelSettingsView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.tabModel, style: Typography.heading) + ForEach(ModelCatalog.all) { option in + row(option) + SeamDivider() + } + HStack { + Text(model.storage.base.path) + .textStyle(Typography.code) + .foregroundStyle(Palette.inkSecondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Button(Strings.showInFinder) { + NSWorkspace.shared.activateFileViewerSelecting([model.storage.base]) + } + .buttonStyle(KeyButtonStyle()) + } + } + .onAppear { model.refreshInstalled() } + } + + private func row(_ option: ModelOption) -> some View { + let isActive = model.activeVariant == option.id + let installed = model.installedVariants.contains(option.id) + let progress: Double? = { + if case .downloading(option.id, let fraction) = model.modelStatus { return fraction } + return nil + }() + return HStack(alignment: .center, spacing: Layout.panelGap) { + LampView(isOn: isActive, tint: .amber) + VStack(alignment: .leading, spacing: Space.s1) { + Text(option.title).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) + Text("\(option.detail) · \(option.approxSizeMB) MB") + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + if let progress { + LEDBarView(fraction: progress) + } else if case .loading(option.id) = model.modelStatus { + SilkLabel(Strings.modelLoading(option.title), style: Typography.labelSmall) + } + } + Spacer() + if installed && !isActive { + Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) + Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle()) + } else if !installed { + Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) + } + } + } +} + +// MARK: privacy + +private struct PrivacySettingsView: View { + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settings: AppSettings + @State private var confirming = false + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.tabPrivacy, style: Typography.heading) + Toggle(isOn: $settings.keepHistory) { + Text(Strings.keepHistory).textStyle(Typography.body).foregroundStyle(Palette.inkPrimary) + } + .toggleStyle(RockerToggleStyle()) + SeamDivider() + Button(Strings.clearHistory) { confirming = true } + .buttonStyle(KeyButtonStyle()) + } + .confirmationDialog(Strings.clearHistoryConfirm, isPresented: $confirming) { + Button(Strings.erase, role: .destructive) { model.clearHistory() } + Button(Strings.cancel, role: .cancel) {} + } message: { + Text(Strings.clearHistoryDetail) + } + } +} +``` + +- [ ] **Step 2: Bouwen en alle tests** + +Run: `swift build --product Sst && swift test` +Expected: build zonder fouten of waarschuwingen; alle tests PASS, inclusief `viewsContainNoLooseDesignValues` over `Sources/Sst`. + +- [ ] **Step 3: Commit (Task 5 + 6 samen)** + +```bash +git add Package.swift Sources/Sst +git commit -m "feat: turn Sst into a regular Mac app with settings, menus and menu bar item" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 7: Icoon, bundel en ondertekening + +**Files:** +- Create: `Sources/SstIcon/IconMaker.swift`, `scripts/make-icon.sh`, `Design/AppIcon.icns` (gegenereerd) +- Replace: `scripts/make-app.sh` + +**Interfaces:** +- Produces: `build/Sst.app` — een gewone Dock-app (geen `LSUIElement`), met icoon, versie 0.2.0, categorie productiviteit, Nederlandse microfoon-uitleg, ondertekend met je Apple Development-identiteit; `scripts/make-app.sh --install` kopieert naar `/Applications`. + +- [ ] **Step 0: Target toevoegen** + +In `Package.swift`, voeg onder de `Sst`-regel toe: + +```swift + .executableTarget(name: "SstIcon", dependencies: ["SstUI"]), +``` + +- [ ] **Step 1: Icoon-generator** + +`Sources/SstIcon/IconMaker.swift`: + +```swift +import AppKit +import SstUI +import SwiftUI + +/// The app icon: a brushed-aluminium plate with a VU meter and the red record lamp, drawn with the design tokens. +/// Canvas is 256 pt; the macOS icon grid puts the plate at 206 pt with a 46 pt corner radius. +struct IconView: View { + static let canvas: CGFloat = 256 + private let plate: CGFloat = 206 + private let corner: CGFloat = 46 + + var body: some View { + ZStack { + BrushedBackground() + .frame(width: plate, height: plate) + .clipShape(RoundedRectangle(cornerRadius: corner, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: corner, style: .continuous) + .strokeBorder(Palette.lineSeamDark, lineWidth: Border.hairline)) + .shadow(color: .black.opacity(Opacity.mark), radius: Space.s3, y: Space.s2) + + VStack(spacing: Space.s5) { + VUMeterView(levelDbfs: -26, isActive: true, brand: "", initialFraction: 0.62) + .frame(width: 150) + HStack(spacing: Space.s4) { + LampView(isOn: true, large: true).scaleEffect(1.5) + SilkLabel("REC", style: Typography.heading) + } + } + .offset(y: Space.s2) + } + .frame(width: Self.canvas, height: Self.canvas) + .environment(\.colorScheme, .light) + } +} + +@main +@MainActor +struct IconMaker { + static func main() { + guard CommandLine.arguments.count > 1 else { + print("usage: SstIcon ") + exit(2) + } + let folder = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + + // (file name, pixel size) as iconutil expects them + let outputs: [(String, CGFloat)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), ("icon_256x256", 256), ("icon_256x256@2x", 512), + ("icon_512x512", 512), ("icon_512x512@2x", 1024), + ] + for (name, pixels) in outputs { + let renderer = ImageRenderer(content: IconView()) + renderer.scale = pixels / IconView.canvas + guard let tiff = renderer.nsImage?.tiffRepresentation, + let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) + else { + print("kon \(name) niet renderen") + exit(1) + } + try? png.write(to: folder.appending(path: "\(name).png")) + } + print("iconset geschreven naar \(folder.path)") + } +} +``` + +`scripts/make-icon.sh`: + +```bash +#!/usr/bin/env bash +# Renders the app icon from the design tokens and stores it as Design/AppIcon.icns (committed). +set -euo pipefail +cd "$(dirname "$0")/.." + +swift build --product SstIcon +rm -rf build/AppIcon.iconset +.build/debug/SstIcon build/AppIcon.iconset +iconutil -c icns build/AppIcon.iconset -o Design/AppIcon.icns +echo "Icoon geschreven: Design/AppIcon.icns" +``` + +- [ ] **Step 2: Icoon genereren en bekijken** + +Run: `chmod +x scripts/make-icon.sh && ./scripts/make-icon.sh && open build/AppIcon.iconset/icon_512x512@2x.png` +Expected: `Icoon geschreven: Design/AppIcon.icns`. Het icoon toont een afgeronde aluminium plaat met een VU-meter (cream wijzerplaat, rode band, wijzer rond −5 VU) en een rood lampje met "REC". + +- [ ] **Step 3: Bundelscript** + +Vervang `scripts/make-app.sh` volledig door: + +```bash +#!/usr/bin/env bash +# Builds build/Sst.app (a normal Dock app, no LSUIElement). Pass --install to copy it to /Applications. +set -euo pipefail +cd "$(dirname "$0")/.." + +APP="build/Sst.app" +IDENTIFIER="local.sst.app" +VERSION="0.2.0" + +swift build -c release --product Sst +BIN=".build/release/Sst" + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/Sst" +# SwiftPM resource bundles (if any dependency ships them) live next to the binary. +for b in .build/release/*.bundle; do + [ -e "$b" ] && cp -R "$b" "$APP/Contents/Resources/" +done +if [ -f Design/AppIcon.icns ]; then + cp Design/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" +else + echo "Let op: Design/AppIcon.icns ontbreekt; run scripts/make-icon.sh." +fi + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleIdentifier$IDENTIFIER + CFBundleNameSst + CFBundleDisplayNameSst + CFBundleExecutableSst + CFBundlePackageTypeAPPL + CFBundleShortVersionString$VERSION + CFBundleVersion1 + CFBundleIconFileAppIcon + LSMinimumSystemVersion14.0 + LSApplicationCategoryTypepublic.app-category.productivity + NSHighResolutionCapable + NSMicrophoneUsageDescription + Sst neemt je stem op zolang je de sneltoets ingedrukt houdt, om die lokaal naar tekst om te zetten. + + +PLIST + +# A stable signing identity keeps the macOS permissions (Microphone, Accessibility, Input Monitoring) across rebuilds. +IDENTITY="${SST_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | sed -n 's/.*"\(Apple Development: [^"]*\)".*/\1/p' | head -1)}" +if [ -n "$IDENTITY" ]; then + codesign --force --sign "$IDENTITY" --identifier "$IDENTIFIER" "$APP" + echo "Ondertekend met: $IDENTITY" +else + codesign --force --sign - --identifier "$IDENTIFIER" "$APP" + echo "WAARSCHUWING: geen Apple Development-certificaat gevonden; ad-hoc ondertekend (rechten gaan na elke herbouw verloren)." +fi +codesign --verify --strict "$APP" +echo "Gebouwd: $APP" +echo "Eenmalig na het wisselen van ondertekening: tccutil reset Accessibility $IDENTIFIER; tccutil reset ListenEvent $IDENTIFIER; tccutil reset Microphone $IDENTIFIER (en geef de rechten opnieuw)." + +if [ "${1:-}" = "--install" ]; then + rm -rf /Applications/Sst.app + cp -R "$APP" /Applications/Sst.app + echo "Geïnstalleerd: /Applications/Sst.app" +fi +``` + +- [ ] **Step 4: Bundel bouwen en controleren** + +Run: +```bash +chmod +x scripts/make-app.sh && ./scripts/make-app.sh +plutil -lint build/Sst.app/Contents/Info.plist +/usr/libexec/PlistBuddy -c "Print :LSUIElement" build/Sst.app/Contents/Info.plist +codesign -dv build/Sst.app 2>&1 | grep -E "Identifier|TeamIdentifier" +ls build/Sst.app/Contents/Resources +``` +Expected: `Ondertekend met: Apple Development: …`; `plutil … OK`; PlistBuddy meldt `:LSUIElement … Does Not Exist`; `Identifier=local.sst.app` en een `TeamIdentifier`; `AppIcon.icns` staat in Resources. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstIcon scripts Design/AppIcon.icns +git commit -m "feat: app icon generator, signed app bundle script with install option" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 8: Handmatige acceptatie met de gebruiker + +Dit vraagt rechten, het toetsenbord en het scherm van de gebruiker; subagents voeren dit niet uit. + +- [ ] **Step 1: Voorbereiden** + +1. Sluit de oude `Sst` (de menubalk-app) als die nog draait: `pkill -f "Sst.app/Contents/MacOS/Sst"`. +2. De ondertekening is veranderd van ad-hoc naar je certificaat, dus eenmalig: `tccutil reset Accessibility local.sst.app; tccutil reset ListenEvent local.sst.app; tccutil reset Microphone local.sst.app` (door de gebruiker zelf uit te voeren). +3. `open build/Sst.app` en sta Microfoon, Accessibility en Input Monitoring toe (bij Input Monitoring zo nodig de app herstarten). + +- [ ] **Step 2: Controleer samen** + +1. **Dock en menu:** Sst heeft een Dock-icoon met het nieuwe icoon; het menu "Sst" toont Instellingen… (⌘,), Verberg en Stop Sst; er is een menu "Opname". +2. **Venster:** het hoofdvenster opent, is vrij te vergroten en niet kleiner te maken dan 800×560; de statusregel zegt "Houd Fn ingedrukt om te dicteren" zodra het model geladen is (of "Geef eerst de rechten onder Controles" zolang rechten ontbreken). +3. **Doorlopen na sluiten:** sluit het venster (⌘W). Dock-icoon en menubalkitem blijven; dicteer in TextEdit met Fn: de tekst verschijnt. Klik op het Dock-icoon: het venster komt terug. +4. **⌘Q** sluit Sst af (Dock-icoon weg, menubalkitem weg). +5. **Instellingen (⌘,):** drie toetsen Sneltoets / Model / Privacy. Sneltoets: kies "Leg nieuwe toets vast" en druk op Rechter Option; dicteer in TextEdit met Rechter Option (Fn doet dan niets). Zet terug op Fn. Druk bij het vastleggen op Shift of A: de melding legt uit dat dat niet kan. +6. **Model:** drie modellen; het standaardmodel heeft een brandend lampje. (Optioneel: download het compacte model, kijk naar de voortgangsbalk, schakel erheen en terug.) +7. **Voorop-regel:** zet Sst voorop (klik in het venster) en dicteer met Fn: er wordt niets geplakt. +8. **Menubalkitem:** toont de statusregel; "Open Sst" opent het venster; "Instellingen…" opent Instellingen; "Stop Sst" sluit af. +9. **Rechten:** trek Microfoon in via Systeeminstellingen; binnen enkele seconden of bij terugkeren naar Sst meldt de statusregel de ontbrekende rechten; geef ze terug. +10. **Installeren:** `./scripts/make-app.sh --install`, start `/Applications/Sst.app` vanuit Launchpad of Finder: werkt, en de rechten blijven na een herbouw behouden (herhaal `make-app.sh` en start opnieuw: geen nieuwe vragen). + +Noteer afwijkingen; kleine visuele bijstellingen horen bij deze taak. + +- [ ] **Step 3: Alle tests en afronden** + +Run: `swift test` +Expected: alle tests PASS. + +--- + +## Self-review + +- **Spec-dekking:** Dock-app zonder `LSUIElement`, vrij schaalbaar venster met minimumgrootte, Instellingen (⌘,) met sneltoets/model/privacy, secundair menubalkitem, menu's (Opname, Wis geschiedenis), doorlopen na sluiten, bundel met icoon, stabiele ondertekening en `--install`, rechten opnieuw lezen bij activeren en polling, voorop-regel en window-opname alleen opslaan, woordenboek en geschiedenis aan de keten gekoppeld (`corrector`, `vocabularyProvider`, `onResult`). Hoofdvenster-inhoud (deck, geschiedenis, woordenboek, zijbalk, CONTROLES) komt in fase 5; hier is `MainWindow` een schil. +- **Placeholders:** geen; alle code is overgenomen uit een gecompileerde en geteste versie (130 tests groen in de toestand na deze fase). +- **Typeconsistentie:** `AppModel.Section`, `ModelStatus`, `PermissionMonitor.Kind`, `Strings.*` en de tokenformaten komen overeen met fase 3 en worden in fase 5 hergebruikt. +- **Afwijkingen van de spec (bewust):** de modelvarianten en groottes zijn nu bij de bron geverifieerd; `AppSettings.defaultModelVariant` dupliceert `ModelCatalog.defaultVariant` omdat `SstData` niet van `SstWhisper` afhangt. +- **Risico's:** het exacte gedrag van `Window`-heropenen via het Dock en `SettingsLink` is alleen handmatig te controleren (Task 8 stap 3 en 8); valt het Dock-heropenen tegen, voeg dan een `NSApplicationDelegateAdaptor` toe met `applicationShouldHandleReopen` en meld dit als afwijking. diff --git a/docs/superpowers/plans/2026-10-01-app-5-main-window.md b/docs/superpowers/plans/2026-10-01-app-5-main-window.md new file mode 100644 index 0000000..6e52271 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-app-5-main-window.md @@ -0,0 +1,1443 @@ +# App fase 5 — Hoofdvenster Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Het hoofdvenster van de app: vast deck met levende VU-meter, teller, LED-balk en REC/STOP; zijbalk met navigatie en CONTROLES; doorzoekbare transcriptiegeschiedenis met kopieerknop en zichtbare woordenboek-correcties; en het woordenboek met toevoegen, bewerken, verwijderen, zoeken, waarschuwingen en ongedaan maken. + +**Architecture:** Testbare logica (`HistoryViewModel`, `DictionaryViewModel`, `CorrectionMarkup`, `StopwatchFormat`) staat in `SstData`; de views in `Sources/Sst` zijn dun en gebruiken uitsluitend tokens en componenten uit `SstUI`. Een debug-snapshotmodus (`Sst --snapshot`) rendert het venster met voorbeeldgegevens in beide afwerkingen, zodat de weergaven te beoordelen zijn zonder rechten of model. + +**Tech Stack:** Swift 6.4 (language mode 5), SwiftUI, Swift Testing. + +**Spec:** `docs/superpowers/specs/2026-10-01-macos-app-design.md` (secties "Hoofdvenster", "Woordenboek — het ontwerp in detail", "Geschiedenis", "Toegankelijkheid") + +**Voorwaarde:** fase 1 t/m 4 zijn afgerond (de app bouwt en draait met een schil als `MainWindow`). + +## Global Constraints + +- Indeling: zijbalk `Layout.sidebarWidth` met navigatie (Transcripties, Woordenboek) en CONTROLES (Microfoon, Accessibility, Input Monitoring, Model); rechts het deck (altijd zichtbaar) en daaronder de gekozen weergave. +- Deck: VU-meter 0 VU = −20 dBFS; teller toont opnametijd (in rust de duur van de laatste opname); LED-balk op hetzelfde signaal; ververst 60 Hz **alleen tijdens het opnemen**; REC blijft ingedrukt zolang er wordt opgenomen; REC uitgeschakeld met reden zolang rechten of model ontbreken; STOP beëindigt elke lopende opname (ook één die met de sneltoets begon). +- Geschiedenis: nieuwste eerst, per dag gegroepeerd (Nederlandse datums), rij = tijd + tekst + kopieerknop; correcties met amber markering en samenvatting "N correcties · gehoord → geschreven"; geselecteerde rij toont de oorspronkelijke tekst; zoeken (⌘F) hoofdletter- en diakriet-ongevoelig; rechtsklik: kopieer, kopieer origineel, verwijder; ⌫ verwijdert de selectie. +- Woordenboek: kolommen GEHOORD → SCHRIJF; term toont "—" links; `+` voegt toe in een editor met live waarschuwingen; dubbelklik bewerkt; ⌫ verwijdert; ⌘Z of "Herstel" maakt ongedaan; zoeken; "Open bestand"; schakelaars Voorrang en Correctie; lampje per rij: rood = fout, amber = waarschuwing, gedoofd = geen of alleen een notitie. +- Alleen tokens in views (token-scan over `Sources/Sst`); UI-teksten in `Strings.swift`. +- Elke commit eindigt met de trailer `Co-Authored-By: Claude Sonnet 5.5 ` (tweede `-m`). + +## Bestandsstructuur + +``` +Sources/SstData/ViewSupport.swift (nieuw) StopwatchFormat, TextSegment, CorrectionMarkup, HistoryViewModel, DictionaryViewModel +Sources/Sst/AppModel.swift (wijzig via script) lijst-viewmodellen, preview-modus, STOP-regel +Sources/Sst/Strings.swift (wijzig via script) teksten voor lijsten en woordenboek +Sources/Sst/SstApp.swift (wijzig via script) @main verhuist naar main.swift +Sources/Sst/main.swift (nieuw) ingang; debug-snapshot +Sources/Sst/Snapshot.swift (nieuw, alleen DEBUG) +Sources/Sst/ListSupport.swift (nieuw) SnapshotMode, ScrollList, SearchField +Sources/Sst/DeckView.swift, Sidebar.swift, MainWindow.swift (vervang), HistoryView.swift, DictionaryView.swift (nieuw) +Tests/SstDataTests/ViewSupportTests.swift (nieuw) +``` + +--- + +### Task 1: Logica achter de weergaven in `SstData` + +**Files:** +- Create: `Sources/SstData/ViewSupport.swift` +- Test: `Tests/SstDataTests/ViewSupportTests.swift` + +**Interfaces:** +- Consumes: fase 2 (`HistoryStore`, `HistorySearch`, `HistoryGrouping`, `DictionaryStore`, `DictionaryFile`, `EntryWarnings`, `WordKnowledge`), fase 1 (`AppliedCorrection`). +- Produces: + - `StopwatchFormat.string(_ seconds: Double) -> String` ("mm:ss.t", minuten lopen rond bij 100) + - `TextSegment(text:correction:)`, `CorrectionMarkup.segments(finalText:corrections:) -> [TextSegment]` (UTF-16-offsets; ongeldige of overlappende correcties worden overgeslagen) + - `@MainActor HistoryViewModel(store:calendar:)` met `query`, `selection`, `days`, `totalCount`, `selectedEntry`, `refresh()`, `delete(id:)` + - `@MainActor DictionaryViewModel(store:knowledge:)` met `rows` (`Row`: `id`, `entry`, `warnings`, `worst`), `visibleRows`, `query`, `canUndo`, `errorMessage`, `reload()`, `warnings(for:editing:)`, `add(_:) -> UUID?`, `update(id:to:)`, `remove(id:)`, `undo()` + +- [ ] **Step 1: Schrijf de falende tests** + +`Tests/SstDataTests/ViewSupportTests.swift`: + +```swift +import Foundation +import Testing +import SstCore +@testable import SstData + +struct StopwatchFormatTests { + @Test func formatsMinutesSecondsAndTenths() { + #expect(StopwatchFormat.string(0) == "00:00.0") + #expect(StopwatchFormat.string(7.46) == "00:07.4") + #expect(StopwatchFormat.string(65.2) == "01:05.2") + #expect(StopwatchFormat.string(-3) == "00:00.0") + #expect(StopwatchFormat.string(6039.9) == "00:39.9") // minutes wrap at 100 + } +} + +struct CorrectionMarkupTests { + func correction(_ start: Int, _ length: Int, heard: String = "x", wrote: String = "y") -> AppliedCorrection { + AppliedCorrection(heard: heard, wrote: wrote, rule: heard, start: start, length: length) + } + + @Test func noCorrectionsGivesOnePlainSegment() { + #expect(CorrectionMarkup.segments(finalText: "hallo wereld", corrections: []) == [TextSegment(text: "hallo wereld")]) + } + + @Test func emptyTextGivesNoSegments() { + #expect(CorrectionMarkup.segments(finalText: "", corrections: [correction(0, 1)]).isEmpty) + } + + @Test func splitsAroundACorrectionInTheMiddle() { + let c = correction(11, 11, heard: "cloud code", wrote: "Claude Code") + let segments = CorrectionMarkup.segments(finalText: "ik gebruik Claude Code vandaag", corrections: [c]) + #expect(segments == [ + TextSegment(text: "ik gebruik "), + TextSegment(text: "Claude Code", correction: c), + TextSegment(text: " vandaag"), + ]) + } + + @Test func handlesCorrectionsAtTheStartAndEnd() { + let first = correction(0, 3), last = correction(8, 3) + let segments = CorrectionMarkup.segments(finalText: "abc def ghi", corrections: [last, first]) + #expect(segments.map(\.text) == ["abc", " def ", "ghi"]) + #expect(segments[0].correction == first && segments[2].correction == last && segments[1].correction == nil) + } + + @Test func skipsOutOfRangeAndOverlappingCorrections() { + let ok = correction(0, 3), overlapping = correction(2, 3), outside = correction(50, 4), empty = correction(4, 0) + let segments = CorrectionMarkup.segments(finalText: "abc def", corrections: [ok, overlapping, outside, empty]) + #expect(segments.map(\.text) == ["abc", " def"]) + } + + @Test func offsetsAreUTF16SoEmojiDoNotShiftThem() { + // "😀 " is 3 UTF-16 units; the corrected word starts at 3. + let c = correction(3, 6, heard: "wereld", wrote: "wereld") + let segments = CorrectionMarkup.segments(finalText: "😀 wereld", corrections: [c]) + #expect(segments.map(\.text) == ["😀 ", "wereld"]) + } +} + +@MainActor +struct HistoryViewModelTests { + func store(_ texts: [String]) -> HistoryStore { + let store = HistoryStore(fileURL: FileManager.default.temporaryDirectory.appending(path: "sst-vm-\(UUID().uuidString)/h.json")) + for (index, text) in texts.enumerated() { + store.add(HistoryEntry(date: Date(timeIntervalSince1970: 1_700_000_000 + Double(index)), duration: 1, rawText: text, finalText: text)) + } + return store + } + + @Test func listsAllEntriesNewestFirstAndFiltersByQuery() { + let model = HistoryViewModel(store: store(["eerste", "tweede zin", "derde"])) + #expect(model.totalCount == 3) + #expect(model.days.flatMap(\.entries).map(\.finalText) == ["derde", "tweede zin", "eerste"]) + model.query = "tweede" + #expect(model.days.flatMap(\.entries).map(\.finalText) == ["tweede zin"]) + #expect(model.totalCount == 3) + } + + @Test func selectionIsClearedWhenItDisappearsFromTheFilter() { + let history = store(["alpha", "beta"]) + let model = HistoryViewModel(store: history) + model.selection = history.entries.first { $0.finalText == "alpha" }?.id + #expect(model.selectedEntry?.finalText == "alpha") + model.query = "beta" + #expect(model.selection == nil) + } + + @Test func deleteRemovesFromStoreAndList() { + let history = store(["a", "b"]) + let model = HistoryViewModel(store: history) + model.delete(id: history.entries[0].id) + #expect(history.entries.count == 1) + #expect(model.totalCount == 1) + } + + @Test func refreshPicksUpNewEntries() { + let history = store(["a"]) + let model = HistoryViewModel(store: history) + history.add(HistoryEntry(date: .now, duration: 1, rawText: "nieuw", finalText: "nieuw")) + model.refresh() + #expect(model.totalCount == 2) + } +} + +@MainActor +struct DictionaryViewModelTests { + func makeModel(_ text: String = "") throws -> (DictionaryViewModel, DictionaryStore) { + let url = FileManager.default.temporaryDirectory.appending(path: "sst-dict-\(UUID().uuidString)/dictionary.txt") + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try text.write(to: url, atomically: true, encoding: .utf8) + let store = DictionaryStore(fileURL: url) + try store.load() + return (DictionaryViewModel(store: store, knowledge: FakeKnowledge()), store) + } + + @Test func rowsCarryWarnings() throws { + let (model, _) = try makeModel("Supabase\ncloud => Claude\n") + #expect(model.rows.count == 2) + #expect(model.rows[0].warnings.isEmpty) + #expect(model.rows[1].worst == .warning) + } + + @Test func duplicateIsFlaggedOnTheLaterEntryOnly() throws { + let (model, _) = try makeModel("cloud code => Claude Code\nCloud-Code => Other\n") + #expect(model.rows[0].worst != .error) + #expect(model.rows[1].worst == .error) + } + + @Test func addUpdateAndRemoveGoThroughTheStore() throws { + let (model, store) = try makeModel() + let id = model.add(.term("Vercel"))! + #expect(store.entries == [term("Vercel")]) + model.update(id: id, to: .correction(heard: "versel", wrote: "Vercel")) + #expect(store.entries == [pair("versel", "Vercel")]) + model.remove(id: id) + #expect(store.entries.isEmpty) + #expect(model.rows.isEmpty) + } + + @Test func undoRestoresTheLastChangeStepByStep() throws { + let (model, store) = try makeModel("Supabase\n") + #expect(!model.canUndo) + let id = model.add(.term("Vercel"))! + model.remove(id: id) + #expect(store.entries == [term("Supabase")]) + model.undo() + #expect(store.entries == [term("Supabase"), term("Vercel")]) + model.undo() + #expect(store.entries == [term("Supabase")]) + #expect(!model.canUndo) + model.undo() // nothing left: harmless + #expect(store.entries == [term("Supabase")]) + } + + @Test func searchMatchesBothSidesIgnoringCaseAndDiacritics() throws { + let (model, _) = try makeModel("cloud code => Claude Code\nCafé\nVercel\n") + model.query = "CLAUDE" + #expect(model.visibleRows.count == 1) + model.query = "cafe" + #expect(model.visibleRows.count == 1) + model.query = "" + #expect(model.visibleRows.count == 3) + } + + @Test func editorWarningsIgnoreTheEntryBeingEdited() throws { + let (model, store) = try makeModel("cloud code => Claude Code\n") + let id = store.entries[0].id + let editingItself = model.warnings(for: .correction(heard: "cloud code", wrote: "Claude Code"), editing: id) + #expect(!editingItself.contains { $0.level == .error }) + let asNew = model.warnings(for: .correction(heard: "cloud code", wrote: "Other"), editing: nil) + #expect(asNew.contains { $0.level == .error }) + } +} +``` + +- [ ] **Step 2: Draai de tests, moeten falen** + +Run: `swift test --filter "StopwatchFormatTests|CorrectionMarkupTests|HistoryViewModelTests|DictionaryViewModelTests"` +Expected: compilatiefouten (`cannot find 'StopwatchFormat' in scope`). + +- [ ] **Step 3: Implementatie** + +`Sources/SstData/ViewSupport.swift`: + +```swift +import Combine +import Foundation +import SstCore + +/// "mm:ss.t" for the counter. +public enum StopwatchFormat { + public static func string(_ seconds: Double) -> String { + let tenths = Int(max(0, seconds) * 10) + return String(format: "%02d:%02d.%d", (tenths / 600) % 100, (tenths / 10) % 60, tenths % 10) + } +} + +public struct TextSegment: Equatable, Sendable { + public let text: String + /// Set when this piece was written by the dictionary correction. + public let correction: AppliedCorrection? + + public init(text: String, correction: AppliedCorrection? = nil) { + self.text = text + self.correction = correction + } +} + +public enum CorrectionMarkup { + /// Splits `finalText` into plain and corrected pieces. Offsets are UTF-16; out-of-range or overlapping ones are skipped. + public static func segments(finalText: String, corrections: [AppliedCorrection]) -> [TextSegment] { + let ns = finalText as NSString + guard ns.length > 0 else { return [] } + var result: [TextSegment] = [] + var cursor = 0 + for correction in corrections.sorted(by: { $0.start < $1.start }) { + let end = correction.start + correction.length + guard correction.length > 0, correction.start >= cursor, end <= ns.length else { continue } + if correction.start > cursor { + result.append(TextSegment(text: ns.substring(with: NSRange(location: cursor, length: correction.start - cursor)))) + } + result.append(TextSegment(text: ns.substring(with: NSRange(location: correction.start, length: correction.length)), correction: correction)) + cursor = end + } + if cursor < ns.length { + result.append(TextSegment(text: ns.substring(from: cursor))) + } + return result + } +} + +/// The searchable, day-grouped list behind the transcripts view. +@MainActor +public final class HistoryViewModel: ObservableObject { + private let store: HistoryStore + private let calendar: Calendar + + @Published public var query = "" { + didSet { refresh() } + } + @Published public var selection: UUID? + @Published public private(set) var days: [HistoryDay] = [] + @Published public private(set) var totalCount = 0 + + public init(store: HistoryStore, calendar: Calendar = .current) { + self.store = store + self.calendar = calendar + refresh() + } + + public func refresh() { + let visible = HistorySearch.filter(store.entries, query: query) + days = HistoryGrouping.byDay(visible, calendar: calendar) + totalCount = store.entries.count + if let selection, !visible.contains(where: { $0.id == selection }) { self.selection = nil } + } + + public var selectedEntry: HistoryEntry? { + store.entries.first { $0.id == selection } + } + + public func delete(id: UUID) { + store.remove(id: id) + refresh() + } +} + +/// Dictionary rows with warnings, search, editing and undo. +@MainActor +public final class DictionaryViewModel: ObservableObject { + public struct Row: Identifiable, Equatable { + public let id: UUID + public let entry: DictionaryEntry + public let warnings: [EntryWarning] + + public var worst: EntryWarning.Level? { warnings.map(\.level).max() } + } + + private let store: DictionaryStore + private let knowledge: WordKnowledge + private var undoStack: [DictionaryFile] = [] + + @Published public var query = "" { + didSet { applyFilter() } + } + @Published public private(set) var rows: [Row] = [] + @Published public private(set) var visibleRows: [Row] = [] + @Published public private(set) var canUndo = false + @Published public var errorMessage: String? + + public init(store: DictionaryStore, knowledge: WordKnowledge) { + self.store = store + self.knowledge = knowledge + reload() + } + + /// Rebuilds the rows from the store (call after the store changed). + public func reload() { + let entries = store.entries + rows = entries.enumerated().map { index, entry in + // Only earlier entries count as "already there", so a duplicate is flagged on the later one. + Row(id: entry.id, entry: entry, + warnings: EntryWarnings.check(entry.kind, among: Array(entries[.. [EntryWarning] { + EntryWarnings.check(kind, among: store.entries.filter { $0.id != id }, knowledge: knowledge) + } + + @discardableResult + public func add(_ kind: DictionaryEntry.Kind) -> UUID? { + var created: UUID? + mutate { created = $0.append(kind) } + return created + } + + public func update(id: UUID, to kind: DictionaryEntry.Kind) { + mutate { $0.update(id: id, kind: kind) } + } + + public func remove(id: UUID) { + mutate { $0.remove(id: id) } + } + + public func undo() { + guard let snapshot = undoStack.popLast() else { return } + canUndo = !undoStack.isEmpty + do { + try store.apply { $0 = snapshot } + } catch { + errorMessage = error.localizedDescription + } + reload() + } + + private func mutate(_ change: (inout DictionaryFile) -> Void) { + let snapshot = store.file + do { + try store.apply(change) + undoStack.append(snapshot) + canUndo = true + } catch { + errorMessage = error.localizedDescription + } + reload() + } +} +``` + +- [ ] **Step 4: Draai de tests** + +Run: `swift test --filter SstDataTests` +Expected: alle SstData-tests PASS (17 nieuwe). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/SstData Tests/SstDataTests +git commit -m "feat: add view-model logic for history and dictionary, correction markup, stopwatch format" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 2: Fundament — modelkoppeling, teksten, lijsthulpen en snapshotmodus + +**Files:** +- Modify (via script): `Sources/Sst/AppModel.swift`, `Sources/Sst/Strings.swift`, `Sources/Sst/SstApp.swift` +- Create: `Sources/Sst/ListSupport.swift`, `Sources/Sst/main.swift`, `Sources/Sst/Snapshot.swift` + +**Interfaces:** +- Consumes: fase 4 (`AppModel`, `Strings`, `SstApp`, `MainWindow`-schil). +- Produces: + - `AppModel.historyList: HistoryViewModel`, `AppModel.dictionaryList: DictionaryViewModel` (verversen zelf bij wijzigingen van de stores), `AppModel.openDictionaryFile()`, `AppModel(preview: true)` + - `Strings` voor lijsten en woordenboek, `Strings.open`, `Strings.permissionsMissing` + - `SnapshotMode.isActive`, `ScrollList { }`, `SearchField(text:focused:)` + - `Sst --snapshot ` (alleen DEBUG): schrijft `-transcripts.png` en `-dictionary.png` (Aluminium links, Graphite rechts) + +- [ ] **Step 1: Bewerkingsscript uitvoeren** + +Maak tijdelijk `apply_plan5_edits.py` in de repo-root: + +```python +#!/usr/bin/env python3 +"""One-off for phase 5: extends AppModel, Strings and SstApp from their phase-4 state. Run from the repo root; every replacement is asserted.""" +from pathlib import Path + + +def edit(path, replacements): + p = Path(path) + s = p.read_text() + for old, new in replacements: + assert old in s, f"{path}: pattern not found:\n{old}" + s = s.replace(old, new, 1) + p.write_text(s) + + +# --- AppModel ----------------------------------------------------------------------------------------------- +edit("Sources/Sst/AppModel.swift", [ + # the two list view models + (" let storage = ModelStorage()\n", + " let storage = ModelStorage()\n let historyList: HistoryViewModel\n let dictionaryList: DictionaryViewModel\n"), + # preview mode: `init()` becomes a convenience init + (''' init() { + let settings = AppSettings() + let policy = InsertionPolicy() + let knowledge = SystemWordKnowledge() + let corrector = DictionaryCorrector(knowledge: knowledge) + let dictionary = DictionaryStore(fileURL: AppPaths.dictionary) + let history = HistoryStore(fileURL: AppPaths.history) +''', ''' convenience init() { + self.init(preview: false) + } + + /// `preview: true` is for snapshots: sample data in a temporary folder, no permission prompts, no model, no hotkey. + init(preview: Bool) { + let settings = preview ? AppSettings(defaults: UserDefaults(suiteName: "sst-preview")!) : AppSettings() + let policy = InsertionPolicy() + let knowledge = SystemWordKnowledge() + let corrector = DictionaryCorrector(knowledge: knowledge) + let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\\(UUID().uuidString)", directoryHint: .isDirectory) + let dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary) + let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : AppPaths.history) +'''), + (''' self.dictionary = dictionary + self.history = history + self.controller''', ''' self.dictionary = dictionary + self.history = history + self.historyList = HistoryViewModel(store: history) + self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge) + self.controller'''), + (''' wire() + Task { await start() } + }''', ''' wire() + if preview { + loadPreviewData() + } else { + Task { await start() } + } + }'''), + # the views refresh when the stores change + (''' self.corrector.update(entries: self.dictionary.entries) + self.dictionaryRevision += 1''', ''' self.corrector.update(entries: self.dictionary.entries) + self.dictionaryList.reload() + self.dictionaryRevision += 1'''), + (" history.onChange = { [weak self] in self?.historyRevision += 1 }", + " history.onChange = { [weak self] in\n self?.historyRevision += 1\n self?.historyList.refresh()\n }"), + # STOP also ends a recording that the hotkey started + (''' func stopWindowRecording() { + guard policy.windowSession else { return }''', ''' func stopWindowRecording() { + guard isRecording else { return }'''), + ("if !permissions.allGranted { return Strings.openSystemSettings }", + "if !permissions.allGranted { return Strings.permissionsMissing }"), +]) +model = Path("Sources/Sst/AppModel.swift") +text = model.read_text().rstrip("\n") +assert text.endswith("}") +model.write_text(text[:-1] + ''' + func openDictionaryFile() { + NSWorkspace.shared.open(dictionary.fileURL) + } + + // MARK: preview + + private func loadPreviewData() { + try? dictionary.load() + try? dictionary.apply { file in + file.append(.term("Anthropic")) + file.append(.term("Supabase")) + file.append(.correction(heard: "cloud code", wrote: "Claude Code")) + file.append(.correction(heard: "cloud", wrote: "Claude")) + } + let now = Date() + history.add(HistoryEntry(date: now.addingTimeInterval(-86_400 * 2), duration: 5.1, + rawText: "een oudere notitie over de planning", finalText: "Een oudere notitie over de planning.")) + history.add(HistoryEntry(date: now.addingTimeInterval(-3_600), duration: 7.2, + rawText: "ik wil de meeting verplaatsen naar tomorrow morning", + finalText: "Ik wil de meeting verplaatsen naar tomorrow morning.")) + history.add(HistoryEntry( + date: now.addingTimeInterval(-300), duration: 4.4, + rawText: "ik gebruik cloud code met supabase", finalText: "Ik gebruik Claude Code met Supabase.", + corrections: [ + AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 11, length: 11), + AppliedCorrection(heard: "supabase", wrote: "Supabase", rule: "Supabase", start: 27, length: 8), + ])) + activeVariant = ModelCatalog.defaultVariant + modelStatus = .ready + lastDuration = 4.4 + historyList.selection = history.entries.first?.id + } +} +''') + +# --- Strings ------------------------------------------------------------------------------------------------ +edit("Sources/Sst/Strings.swift", [ + (' static let openSystemSettings = "Open Instellingen"\n', + ' static let openSystemSettings = "Open Instellingen"\n static let open = "Open"\n static let permissionsMissing = "Geef eerst de rechten onder Controles"\n'), + (" // deck\n", ''' // lists + static let copy = "Kopieer" + static let copyOriginal = "Kopieer origineel" + static let delete = "Verwijder" + static let original = "Origineel" + static let noTranscripts = "Nog geen transcripties. Houd de sneltoets ingedrukt en praat." + static let noResults = "Geen resultaten" + static let historyOff = "De geschiedenis staat uit (Instellingen → Privacy)." + static func corrections(_ count: Int) -> String { count == 1 ? "1 correctie" : "\\(count) correcties" } + static func entries(_ count: Int) -> String { count == 1 ? "1 invoer" : "\\(count) invoeren" } + + // dictionary + static let heard = "Gehoord" + static let wrote = "Schrijf" + static let term = "Term" + static let correction = "Correctie" + static let add = "+" + static let openFile = "Open bestand" + static let priming = "Voorrang" + static let correcting = "Correctie" + static let undo = "Herstel" + static let save = "Bewaar" + static let editEntry = "Invoer" + static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +." + + // deck +'''), +]) + +# --- SstApp: the entry point moves to main.swift (needed for the debug snapshot mode) ------------------------- +edit("Sources/Sst/SstApp.swift", [("@main\nstruct SstApp: App {", "struct SstApp: App {")]) +print("phase 5 edits applied") +``` + +Run: `python3 apply_plan5_edits.py && rm apply_plan5_edits.py` +Expected: `phase 5 edits applied`; het script is weg. + +- [ ] **Step 2: Nieuwe bestanden** + +`Sources/Sst/ListSupport.swift`: + +```swift +import SstUI +import SwiftUI + +/// Debug snapshots (`Sst --snapshot`) are drawn by `ImageRenderer`, which cannot draw scroll views or text fields. +/// While rendering one, the views below fall back to plain stacks and text. +@MainActor +enum SnapshotMode { + static var isActive = false +} + +/// A scrolling, lazily built list (a plain stack while a snapshot is rendered). +struct ScrollList: View { + @ViewBuilder var content: Content + + var body: some View { + if SnapshotMode.isActive { + VStack(alignment: .leading, spacing: 0) { content } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { content } + } + } + } +} + +/// The recessed "ZOEK" field. +struct SearchField: View { + @Binding var text: String + var focused: FocusState.Binding + + var body: some View { + HStack(spacing: Space.s4) { + SilkLabel(Strings.search) + Group { + if SnapshotMode.isActive { + Text(text).frame(maxWidth: .infinity, alignment: .leading) + } else { + TextField("", text: $text) + .textFieldStyle(.plain) + .focused(focused) + } + } + .textStyle(Typography.body) + .foregroundStyle(Palette.inkPrimary) + .padding(Space.s3) + .background(WellBackground()) + } + } +} +``` + +`Sources/Sst/main.swift`: + +```swift +import AppKit + +#if DEBUG +// `Sst --snapshot ` renders the main window with sample data to -transcripts.png and +// -dictionary.png (Aluminium left, Graphite right) and exits. No prompts, no model, no hotkey. +if let flag = CommandLine.arguments.firstIndex(of: "--snapshot"), CommandLine.arguments.count > flag + 1 { + MainActor.assumeIsolated { Snapshot.run(prefix: CommandLine.arguments[flag + 1]) } + exit(0) +} +#endif + +SstApp.main() +``` + +`Sources/Sst/Snapshot.swift`: + +```swift +#if DEBUG +import AppKit +import SstUI +import SwiftUI + +@MainActor +enum Snapshot { + static func run(prefix: String) { + _ = NSApplication.shared + SnapshotMode.isActive = true + let model = AppModel(preview: true) + for section in AppModel.Section.allCases { + model.section = section + let view = HStack(spacing: 0) { + MainWindow().environment(\.colorScheme, .light) + MainWindow().environment(\.colorScheme, .dark) + } + .environmentObject(model) + .environmentObject(model.settings) + .frame(width: Layout.windowDefaultWidth * 2, height: Layout.windowDefaultHeight) + + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + if let tiff = renderer.nsImage?.tiffRepresentation, + let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: "\(prefix)-\(section.rawValue).png")) + } + } + } +} +#endif +``` + +- [ ] **Step 3: Bouwen, testen en de snapshot-infrastructuur controleren** + +Run: `swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && ls /tmp/sst-main-*.png` +Expected: build zonder fouten of waarschuwingen; alle tests PASS (incl. token-scan); twee PNG's. Ze tonen nog de schil van het venster; de echte weergaven volgen in Task 3 t/m 5. + +- [ ] **Step 4: Commit** + +```bash +git add Sources/Sst +git commit -m "feat: wire list view models into AppModel, add strings, list helpers and debug snapshot mode" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 3: Deck, zijbalk met CONTROLES en het venster + +**Files:** +- Create: `Sources/Sst/DeckView.swift`, `Sources/Sst/Sidebar.swift`, `Sources/Sst/HistoryView.swift` (tijdelijk), `Sources/Sst/DictionaryView.swift` (tijdelijk) +- Replace: `Sources/Sst/MainWindow.swift` + +**Interfaces:** +- Consumes: Task 2; `SstUI` (`VUMeterView`, `SegmentCounterView`, `LEDBarView`, `KeyButtonStyle`, `LampView`, panelen); `LevelSource`, `StopwatchFormat`. +- Produces: `DeckView`, `Sidebar`, `ControlsBlock`, `MainWindow` (zijbalk + deck + gekozen weergave, met het wis-geschiedenis-dialoog), en tijdelijke lege `HistoryView(list:)` en `DictionaryView(list:)` die Task 4 en 5 vervangen. + +- [ ] **Step 1: Bestanden schrijven** + +`Sources/Sst/DeckView.swift`: + +```swift +import SstData +import SstUI +import SwiftUI + +/// The transport deck: VU meter, counter, LED bar and the REC/STOP keys. Always visible at the top. +struct DeckView: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + HStack(alignment: .top, spacing: Layout.panelGap) { + instruments + Spacer(minLength: 0) + controls + } + .padding(Layout.panelPadding) + .background(PanelBackground()) + } + + private var instruments: some View { + TimelineView(.animation(minimumInterval: 1 / Motion.needleUpdateHz, paused: !model.isRecording)) { context in + let dbfs = Double(model.levelSource.dbfs) + HStack(alignment: .top, spacing: Layout.panelGap) { + VUMeterView(levelDbfs: dbfs, isActive: model.isRecording, brand: AppIdentity.brandCaption) + .frame(width: MeterTokens.vuWidth) + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(Strings.time, style: Typography.labelSmall) + SegmentCounterView(StopwatchFormat.string(elapsed(at: context.date))) + SilkLabel(Strings.level, style: Typography.labelSmall) + LEDBarView(fraction: model.isRecording ? VUScale.fraction(forDbfs: dbfs) : 0) + } + } + } + } + + private func elapsed(at date: Date) -> TimeInterval { + if let started = model.recordingStartedAt { return date.timeIntervalSince(started) } + return model.lastDuration ?? 0 + } + + private var controls: some View { + VStack(alignment: .trailing, spacing: Layout.panelGap) { + HStack(spacing: Layout.panelGap) { + Button { model.toggleWindowRecording() } label: { + HStack(spacing: Space.s3) { + LampView(isOn: model.isRecording) + Text(Strings.record) + } + } + .buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording)) + .disabled(!model.isRecording && model.recordingBlocker != nil) + .help(model.recordingBlocker ?? "") + + Button(Strings.stop) { model.stopWindowRecording() } + .buttonStyle(KeyButtonStyle()) + .disabled(!model.isRecording) + } + if let error = model.lastError { + HStack(spacing: Space.s3) { + LampView(isOn: true, tint: .amber) + Text(error) + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + .multilineTextAlignment(.trailing) + } + } else { + Text(model.statusLine) + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + .multilineTextAlignment(.trailing) + } + } + .frame(maxWidth: Layout.sidebarWidth, alignment: .trailing) + } +} +``` + +`Sources/Sst/Sidebar.swift`: + +```swift +import SstUI +import SwiftUI + +struct Sidebar: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel(AppIdentity.brandCaption) + ForEach(AppModel.Section.allCases) { section in + Button { + model.section = section + } label: { + Text(title(for: section)).frame(maxWidth: .infinity) + } + .buttonStyle(KeyButtonStyle(latched: model.section == section)) + } + Spacer() + ControlsBlock() + } + .padding(Layout.panelPadding) + .frame(width: Layout.sidebarWidth) + } + + private func title(for section: AppModel.Section) -> String { + switch section { + case .transcripts: Strings.transcripts + case .dictionary: Strings.dictionary + } + } +} + +/// Four lamps: the three macOS permissions and the speech model. +struct ControlsBlock: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + VStack(alignment: .leading, spacing: Space.s3) { + SilkLabel(Strings.controls) + SeamDivider() + ForEach(PermissionMonitor.Kind.allCases) { kind in + let granted = model.permissions.isGranted(kind) + HStack(spacing: Space.s3) { + LampView(isOn: granted, tint: .amber) + Text(kind.title).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + Spacer() + if !granted { + Button(Strings.open) { model.permissions.openSettings(for: kind) } + .buttonStyle(KeyButtonStyle()) + .help(Strings.openSystemSettings) + } + } + } + HStack(spacing: Space.s3) { + LampView(isOn: model.modelReady, tint: .amber) + Text(Strings.modelLamp).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + Spacer() + } + } + .onAppear { model.permissions.startPolling() } + .onDisappear { model.permissions.stopPolling() } + } +} +``` + +`Sources/Sst/MainWindow.swift` (vervang volledig): + +```swift +import SstUI +import SwiftUI + +struct MainWindow: View { + @EnvironmentObject private var model: AppModel + + var body: some View { + ZStack { + BrushedBackground().ignoresSafeArea() + HStack(spacing: 0) { + Sidebar() + VStack(spacing: Layout.panelGap) { + DeckView() + switch model.section { + case .transcripts: HistoryView(list: model.historyList) + case .dictionary: DictionaryView(list: model.dictionaryList) + } + } + .padding(Layout.panelPadding) + } + } + .confirmationDialog(Strings.clearHistoryConfirm, isPresented: $model.confirmingClearHistory) { + Button(Strings.erase, role: .destructive) { model.clearHistory() } + Button(Strings.cancel, role: .cancel) {} + } message: { + Text(Strings.clearHistoryDetail) + } + } +} +``` + +Tijdelijk `Sources/Sst/HistoryView.swift`: + +```swift +import SstData +import SstUI +import SwiftUI + +// Temporary: replaced in Task 4. +struct HistoryView: View { + @ObservedObject var list: HistoryViewModel + var body: some View { PaperBackground() } +} +``` + +Tijdelijk `Sources/Sst/DictionaryView.swift`: + +```swift +import SstData +import SstUI +import SwiftUI + +// Temporary: replaced in Task 5. +struct DictionaryView: View { + @ObservedObject var list: DictionaryViewModel + var body: some View { PaperBackground() } +} +``` + +- [ ] **Step 2: Bouwen, testen en bekijken** + +Run: `swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-transcripts.png` +Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding (Aluminium links, Graphite rechts) toont links de zijbalk met twee navigatietoetsen (de gekozen is ingedrukt) en onderaan CONTROLES met vier lampjes en korte "Open"-toetsen (geen afgebroken tekst); rechts het deck met VU-meter, teller, LED-balk, REC (uitgeschakeld) en STOP, met de melding "Geef eerst de rechten onder Controles"; daaronder een leeg papieren vlak. + +- [ ] **Step 3: Commit** + +```bash +git add Sources/Sst +git commit -m "feat: add deck, sidebar with controls and the main window layout" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 4: Transcriptiegeschiedenis + +**Files:** +- Replace: `Sources/Sst/HistoryView.swift` + +**Interfaces:** +- Consumes: Task 1 (`HistoryViewModel`, `CorrectionMarkup`), Task 2 (`ScrollList`, `SearchField`, `Strings`). +- Produces: `HistoryView(list:)` — zoekveld (focus via `AppModel.searchFocusTick`), lijst per dag, rij met kopieerknop, correctiemarkering en -samenvatting, contextmenu, ⌫-verwijderen, lege staten. + +- [ ] **Step 1: Implementatie** + +`Sources/Sst/HistoryView.swift` (vervang volledig): + +```swift +import AppKit +import SstCore +import SstData +import SstUI +import SwiftUI + +private let dutch = Locale(identifier: "nl_NL") + +struct HistoryView: View { + @EnvironmentObject private var model: AppModel + @ObservedObject var list: HistoryViewModel + @FocusState private var searchFocused: Bool + + var body: some View { + VStack(spacing: Layout.panelGap) { + SearchField(text: $list.query, focused: $searchFocused) + ZStack { + PaperBackground() + content + } + } + .onChange(of: model.searchFocusTick) { _, _ in searchFocused = true } + } + + @ViewBuilder + private var content: some View { + if list.days.isEmpty { + Text(emptyMessage) + .textStyle(Typography.body) + .foregroundStyle(Palette.inkSecondary) + .padding(Layout.panelPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + ScrollList { + ForEach(list.days) { day in + Section { + ForEach(day.entries) { entry in + HistoryRow(entry: entry, isSelected: list.selection == entry.id, list: list) + } + } header: { + SilkLabel(day.day.formatted(Date.FormatStyle(date: .complete, time: .omitted).locale(dutch))) + .padding(.horizontal, Space.s4) + .padding(.vertical, Space.s3) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surfacePaper) + } + } + } + .focusable() + .onDeleteCommand { + if let id = list.selection { list.delete(id: id) } + } + } + } + + private var emptyMessage: String { + if list.totalCount > 0 { return Strings.noResults } + return model.settings.keepHistory ? Strings.noTranscripts : Strings.historyOff + } +} + +private struct HistoryRow: View { + let entry: HistoryEntry + let isSelected: Bool + @ObservedObject var list: HistoryViewModel + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: Space.s4) { + Text(entry.date.formatted(Date.FormatStyle().hour().minute().second().locale(dutch))) + .textStyle(Typography.counterSmall) + .foregroundStyle(Palette.inkSecondary) + VStack(alignment: .leading, spacing: Space.s2) { + Text(markedText) + .textStyle(Typography.reading) + .foregroundStyle(Palette.inkPrimary) + .textSelection(.enabled) + if !entry.corrections.isEmpty { + SilkLabel(correctionSummary, style: Typography.labelSmall) + } + if isSelected && entry.rawText != entry.finalText { + Text("\(Strings.original): \(entry.rawText)") + .textStyle(Typography.meta) + .foregroundStyle(Palette.inkSecondary) + } + } + Spacer(minLength: Space.s4) + Button(Strings.copy) { copy(entry.finalText) } + .buttonStyle(KeyButtonStyle()) + } + .padding(.horizontal, Space.s4) + .padding(.vertical, Space.s3) + .frame(minHeight: Size.rowHeight) + .background(isSelected ? Palette.surfaceSelection : Color.clear) + .contentShape(Rectangle()) + .onTapGesture { list.selection = entry.id } + .contextMenu { + Button(Strings.copy) { copy(entry.finalText) } + Button(Strings.copyOriginal) { copy(entry.rawText) } + Divider() + Button(Strings.delete, role: .destructive) { list.delete(id: entry.id) } + } + } + + /// The final text with every dictionary replacement highlighted. + private var markedText: AttributedString { + var result = AttributedString() + for segment in CorrectionMarkup.segments(finalText: entry.finalText, corrections: entry.corrections) { + var piece = AttributedString(segment.text) + if segment.correction != nil { + piece.backgroundColor = Palette.levelAmber.opacity(Opacity.mark) + } + result += piece + } + return result + } + + private var correctionSummary: String { + let changes = entry.corrections.map { "\($0.heard) → \($0.wrote)" }.joined(separator: ", ") + return "\(Strings.corrections(entry.corrections.count)) · \(changes)" + } + + private func copy(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} +``` + +- [ ] **Step 2: Bouwen, testen en bekijken** + +Run: `swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-transcripts.png` +Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding toont drie voorbeeldtranscripties onder twee dagkoppen in het Nederlands ("DONDERDAG 1 OKTOBER 2026"); de nieuwste (geselecteerde) rij heeft "Claude Code" en "Supabase" met amber markering, de regel "2 CORRECTIES · CLOUD CODE → CLAUDE CODE, SUPABASE → SUPABASE" en de oorspronkelijke tekst; elke rij heeft een KOPIEER-toets; tijden staan in mono. + +- [ ] **Step 3: Commit** + +```bash +git add Sources/Sst +git commit -m "feat: add transcript history view with corrections, search and copy" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 5: Woordenboek met editor + +**Files:** +- Replace: `Sources/Sst/DictionaryView.swift` + +**Interfaces:** +- Consumes: Task 1 (`DictionaryViewModel`), Task 2 (`ScrollList`, `SearchField`, `Strings`, `AppModel.openDictionaryFile()`), fase 4 (`RockerToggleStyle`, `AppSettings`). +- Produces: `DictionaryView(list:)` met zoekveld, `+`, "Open bestand", schakelaars Voorrang/Correctie, tabel, voettekst met telling en "Herstel", en de editor (sheet) met live waarschuwingen. + +- [ ] **Step 1: Implementatie** + +`Sources/Sst/DictionaryView.swift` (vervang volledig): + +```swift +import SstData +import SstUI +import SwiftUI + +struct DictionaryView: View { + enum EditorTarget: Identifiable, Equatable { + case new + case existing(UUID) + + var id: String { + switch self { + case .new: "new" + case .existing(let id): id.uuidString + } + } + } + + @EnvironmentObject private var model: AppModel + @EnvironmentObject private var settings: AppSettings + @ObservedObject var list: DictionaryViewModel + @State private var selection: UUID? + @State private var editing: EditorTarget? + @FocusState private var searchFocused: Bool + + var body: some View { + VStack(spacing: Layout.panelGap) { + header + ZStack { + PaperBackground() + table + } + footer + } + .sheet(item: $editing) { target in + EntryEditor(list: list, target: target) { editing = nil } + } + } + + private var header: some View { + VStack(spacing: Layout.panelGap) { + HStack(spacing: Space.s4) { + SearchField(text: $list.query, focused: $searchFocused) + Button(Strings.add) { editing = .new }.buttonStyle(KeyButtonStyle()) + Button(Strings.openFile) { model.openDictionaryFile() }.buttonStyle(KeyButtonStyle()) + } + HStack(spacing: Layout.panelGap) { + Toggle(isOn: $settings.usePriming) { + SilkLabel(Strings.priming) + } + .toggleStyle(RockerToggleStyle()) + Toggle(isOn: $settings.useCorrection) { + SilkLabel(Strings.correcting) + } + .toggleStyle(RockerToggleStyle()) + Spacer() + } + } + } + + private var table: some View { + VStack(spacing: 0) { + HStack(spacing: Space.s4) { + Color.clear.frame(width: Size.iconMedium, height: Size.iconMedium) + SilkLabel(Strings.heard).frame(maxWidth: .infinity, alignment: .leading) + SilkLabel(Strings.wrote).frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, Space.s4) + .padding(.vertical, Space.s3) + if list.visibleRows.isEmpty { + Text(list.rows.isEmpty ? Strings.emptyDictionary : Strings.noResults) + .textStyle(Typography.body) + .foregroundStyle(Palette.inkSecondary) + .padding(Layout.panelPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + ScrollList { + ForEach(list.visibleRows) { row in + DictionaryRow(row: row, isSelected: selection == row.id) + .onTapGesture(count: 2) { editing = .existing(row.id) } + .onTapGesture { selection = row.id } + } + } + } + } + .focusable() + .onDeleteCommand { + if let id = selection { list.remove(id: id) } + } + .onKeyPress(KeyEquivalent("z"), phases: .down) { press in + guard press.modifiers == .command, list.canUndo else { return .ignored } + list.undo() + return .handled + } + } + + private var footer: some View { + HStack(spacing: Layout.panelGap) { + SilkLabel(Strings.entries(list.rows.count), style: Typography.labelSmall) + if let message = list.errorMessage { + Text(message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + } + Spacer() + Button(Strings.undo) { list.undo() } + .buttonStyle(KeyButtonStyle()) + .disabled(!list.canUndo) + } + } +} + +private struct DictionaryRow: View { + let row: DictionaryViewModel.Row + let isSelected: Bool + + var body: some View { + HStack(spacing: Space.s4) { + lamp + .frame(width: Size.iconMedium, height: Size.iconMedium) + if let heard = heard { + Text(heard).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text("—").textStyle(Typography.code).foregroundStyle(Palette.inkSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + Text(row.entry.wrote).textStyle(Typography.code).foregroundStyle(Palette.inkPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, Space.s4) + .padding(.vertical, Space.s3) + .frame(minHeight: Size.rowHeight) + .background(isSelected ? Palette.surfaceSelection : Color.clear) + .contentShape(Rectangle()) + .help(row.warnings.map(\.message).joined(separator: "\n")) + } + + private var heard: String? { + if case .correction(let heard, _) = row.entry.kind { return heard } + return nil + } + + @ViewBuilder + private var lamp: some View { + switch row.worst { + case .error: LampView(isOn: true) + case .warning: LampView(isOn: true, tint: .amber) + default: LampView(isOn: false, tint: .amber) + } + } +} + +/// Add or edit one entry, with live warnings. +private struct EntryEditor: View { + @ObservedObject var list: DictionaryViewModel + let target: DictionaryView.EditorTarget + let onClose: () -> Void + + @State private var isCorrection = false + @State private var heard = "" + @State private var wrote = "" + + private var editingID: UUID? { + if case .existing(let id) = target { return id } + return nil + } + + private var kind: DictionaryEntry.Kind { + let write = wrote.trimmingCharacters(in: .whitespaces) + return isCorrection ? .correction(heard: heard.trimmingCharacters(in: .whitespaces), wrote: write) : .term(write) + } + + private var warnings: [EntryWarning] { + let emptySource = isCorrection ? heard.trimmingCharacters(in: .whitespaces).isEmpty : wrote.trimmingCharacters(in: .whitespaces).isEmpty + guard !emptySource, !wrote.trimmingCharacters(in: .whitespaces).isEmpty else { return [] } + return list.warnings(for: kind, editing: editingID) + } + + private var canSave: Bool { + let hasText = !wrote.trimmingCharacters(in: .whitespaces).isEmpty && (!isCorrection || !heard.trimmingCharacters(in: .whitespaces).isEmpty) + return hasText && !warnings.contains { $0.level == .error } + } + + var body: some View { + ZStack { + BrushedBackground().ignoresSafeArea() + VStack(alignment: .leading, spacing: Layout.panelGap) { + HStack(spacing: Space.s3) { + Button(Strings.term) { isCorrection = false }.buttonStyle(KeyButtonStyle(latched: !isCorrection)) + Button(Strings.correction) { isCorrection = true }.buttonStyle(KeyButtonStyle(latched: isCorrection)) + } + VStack(alignment: .leading, spacing: Layout.panelGap) { + if isCorrection { field(Strings.heard, text: $heard) } + field(Strings.wrote, text: $wrote) + ForEach(Array(warnings.enumerated()), id: \.offset) { _, warning in + HStack(alignment: .firstTextBaseline, spacing: Space.s3) { + LampView(isOn: warning.level != .note, tint: warning.level == .error ? .record : .amber) + Text(warning.message).textStyle(Typography.meta).foregroundStyle(Palette.inkSecondary) + } + } + } + .padding(Layout.panelPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(PanelBackground()) + HStack(spacing: Space.s3) { + Spacer() + Button(Strings.cancel, action: onClose).buttonStyle(KeyButtonStyle()) + Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave) + } + } + .padding(Layout.panelPadding) + } + .frame(width: Layout.settingsWidth) + .onAppear(perform: load) + } + + private func field(_ title: String, text: Binding) -> some View { + HStack(spacing: Space.s4) { + SilkLabel(title).frame(width: Size.keyRecWidth, alignment: .leading) + TextField("", text: text) + .textFieldStyle(.plain) + .textStyle(Typography.code) + .foregroundStyle(Palette.inkPrimary) + .padding(Space.s3) + .background(WellBackground()) + } + } + + private func load() { + guard let id = editingID, let row = list.rows.first(where: { $0.id == id }) else { return } + switch row.entry.kind { + case .term(let term): (isCorrection, heard, wrote) = (false, "", term) + case .correction(let h, let w): (isCorrection, heard, wrote) = (true, h, w) + } + } + + private func save() { + if let id = editingID { + list.update(id: id, to: kind) + } else { + list.add(kind) + } + onClose() + } +} +``` + +- [ ] **Step 2: Bouwen, testen en bekijken** + +Run: `swift build --product Sst && swift test && .build/debug/Sst --snapshot /tmp/sst-main && open /tmp/sst-main-dictionary.png` +Expected: build zonder fouten of waarschuwingen; alle tests PASS. De afbeelding toont het woordenboek met vier voorbeeldinvoeren: twee termen ("—" links), "cloud code → Claude Code" en "cloud → Claude"; alleen de laatste heeft een brandend amber lampje (gewoon woord). Zoekveld, `+` en OPEN BESTAND staan boven; twee tuimelschakelaars (VOORRANG, CORRECTIE); onderaan "4 INVOEREN" en een HERSTEL-toets (uitgeschakeld). + +- [ ] **Step 3: Commit** + +```bash +git add Sources/Sst +git commit -m "feat: add dictionary view with editor, warnings and undo" -m "Co-Authored-By: Claude Sonnet 5.5 " +``` + +--- + +### Task 6: Handmatige acceptatie met de gebruiker + +Dit vraagt rechten, het toetsenbord en het scherm van de gebruiker; subagents voeren dit niet uit. + +- [ ] **Step 1: Voorbereiden** + +Run: `./scripts/make-app.sh && open build/Sst.app` (rechten en model zijn al in orde uit fase 4). Alle vier CONTROLES-lampjes branden binnen enkele seconden. + +- [ ] **Step 2: Deck en opnemen** + +1. Klik REC: de toets blijft ingedrukt, het lampje brandt, de teller loopt, de wijzer volgt je stem en zakt terug bij stilte; de piek-LED gaat aan boven 0 VU. Klik STOP. +2. Het resultaat staat in Transcripties; er is **niets geplakt** (opname vanuit het venster). +3. Houd Fn ingedrukt in TextEdit en spreek: de tekst wordt geplakt én staat in de lijst. Het deck beweegt ook dan. +4. Start een opname met de sneltoets en klik STOP in het venster: de opname eindigt en wordt verwerkt. +5. ⌥⌘R start en stopt een opname. + +- [ ] **Step 3: Geschiedenis** + +1. Zoek met ⌘F (veld krijgt focus); zoek "cafe" vindt "Café"; meerdere woorden in willekeurige volgorde. +2. KOPIEER zet de tekst op het klembord; rechtsklik: kopieer origineel, verwijder; selecteer een rij en druk ⌫. +3. Zet in Instellingen → Privacy "Transcripties bewaren" uit, dicteer: er komt geen nieuwe rij; de lege staat verwijst naar Instellingen. Zet terug aan. "Wis geschiedenis…" vraagt bevestiging en wist alles. + +- [ ] **Step 4: Woordenboek** + +1. Voeg met `+` de correctie `cloud code => Claude Code` toe: de editor toont een gele melding (alle delen zijn gewone woorden); bewaar. +2. Zeg "ik gebruik cloud code": de tekst bevat "Claude Code"; de rij in Transcripties toont de markering en de samenvatting. Zeg ook "CloudCode" en "cloud-code": beide worden "Claude Code". Zeg "Cloudflare" en "the cloud": blijven ongewijzigd. +3. Voeg `cloud => Claude` toe: waarschuwing (gewoon woord), lampje brandt amber. Verwijder met ⌫ en herstel met ⌘Z. +4. Voeg de term `Supabase` toe en zeg het woord: de schrijfwijze is goed. +5. Zet VOORRANG uit, dan CORRECTIE uit: elk mechanisme heeft zichtbaar effect (zonder CORRECTIE blijft "cloud code" staan). +6. Open het bestand met OPEN BESTAND, voeg in de editor een regel toe en bewaar: de lijst in Sst werkt binnen een seconde bij. Wijzig een regel in Sst: het bestand verandert, met behoud van `#`-regels. +7. Meet de snelheid: zeg 8 seconden spraak met en zonder VOORRANG; noteer beide tijden (doel: onder 1,5 s). +8. Zeg niets (hou de sneltoets 2 s ingedrukt) met een gevuld woordenboek: er wordt geen tekst ingevoegd. + +- [ ] **Step 5: Toegankelijkheid en afronden** + +1. Alle bediening is met het toetsenbord bereikbaar; de focusring is zichtbaar. +2. VoiceOver leest "Niveau" en "Tijd" voor; de lampjes zijn verborgen voor VoiceOver. +3. Zet "Verminder beweging" aan: geen overschrijding van de wijzer. +4. Draai `swift test`: alle tests PASS. +5. Noteer afwijkingen; kleine visuele bijstellingen horen bij deze taak. + +--- + +## Self-review + +- **Spec-dekking:** deck met meter/teller/LED/REC/STOP en uitschakelreden, zijbalk met CONTROLES, geschiedenis (dagen, markering, samenvatting, origineel bij selectie, zoeken, kopieerknop, contextmenu, ⌫, lege staten, geschiedenis-uit-melding), woordenboek (tabel, `+`, editor met live waarschuwingen, dubbelklik, ⌫, ⌘Z/Herstel, zoeken, bestand openen, twee schakelaars, lampje per rij), tekst-niet-plakken-voorop en alleen-opslaan (in fase 4), handmatige lijst uit de spec. Niet in scope: overlay, cleanup. +- **Placeholders:** geen; de twee tijdelijke views in Task 3 zijn volledig en worden in Task 4 en 5 vervangen. +- **Typeconsistentie:** `HistoryViewModel`/`DictionaryViewModel` (Task 1) worden door `AppModel` (Task 2) en de views (Task 3–5) met dezelfde namen gebruikt; `Strings`-sleutels in de views bestaan na het script in Task 2; `AppSettings` wordt aan het hoofdvenster meegegeven in `SstApp` (fase 4). +- **Risico's:** `ImageRenderer` tekent geen scrollende lijsten of tekstvelden; `SnapshotMode` vervangt ze alleen tijdens snapshots. Gedrag van ⌘Z en ⌫ op een focusbare lijst en de sheet-editor is alleen handmatig te controleren (Task 6 stap 4). diff --git a/docs/superpowers/specs/2026-10-01-macos-app-design.md b/docs/superpowers/specs/2026-10-01-macos-app-design.md new file mode 100644 index 0000000..fcf351c --- /dev/null +++ b/docs/superpowers/specs/2026-10-01-macos-app-design.md @@ -0,0 +1,258 @@ +# 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 → SstCore (woordenboek, correcties, geschiedenis) + └────────→ SstWhisper → SstCore (de engine; dunne uitbreidingen) +``` + +| Module | Verantwoordelijkheid | Testbaar zonder UI | +|---|---|---| +| `SstCore` / `SstWhisper` | Engine (bestaat). Vier additieve raakpunten. | ja | +| `SstData` | `DictionaryFile`, `CorrectionEngine`, `EntryWarnings`, `VocabularyBuilder`, `HistoryStore`, `HistorySearch`, `AppSettings`. Deelt de typen `AppliedCorrection` en `TextCorrecting` met `SstCore`. | 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 drie varianten, namen en groottes gecontroleerd bij Hugging Face (`argmaxinc/whisperkit-coreml`): `large-v3-v20240930_turbo` (standaard, 1638 MB), `large-v3-v20240930_turbo_632MB` (646 MB) en `small` (486 MB). Per model: status (aanwezig, gekozen, niet gedownload), download- of verwijderknop en voortgang als LED-balk. Wisselen laadt het nieuwe model en zet dicteren uit tot het klaar is; mislukt het laden, dan blijft het vorige model actief met een duidelijke melding. Toont de opslaglocatie met "Toon in Finder". +- **Privacy:** schakelaar "Transcripties bewaren" en knop "Wis geschiedenis…" (met bevestiging). + +## Woordenboek — het ontwerp in detail + +### 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 (genormaliseerd: kleine letters, zonder leestekens) uitsluitend uit de eerste *k* termen van de prompt met *k* ≥ 2, dan wordt het verworpen: dat is een echo van de prompt, geen spraak. Eén term alleen ("Anthropic") is wel geldige spraak. +- 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: de Engelse woordenlijst van `NaturalLanguage` (alleen gewone woorden; "anthropic" en "vercel" staan er niet in, "cloud" en "reef" wel), de Nederlandse spellingcontrole van macOS, en een meegeleverde lijst van de meest voorkomende functiewoorden in beide talen (circa 170): + +| 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. diff --git a/scripts/make-app.sh b/scripts/make-app.sh index 55b449a..fb54ee3 100755 --- a/scripts/make-app.sh +++ b/scripts/make-app.sh @@ -1,10 +1,14 @@ #!/usr/bin/env bash +# Builds build/Sst.app (a normal Dock app, no LSUIElement). Pass --install to copy it to /Applications. set -euo pipefail cd "$(dirname "$0")/.." +APP="build/Sst.app" +IDENTIFIER="local.sst.app" +VERSION="0.2.0" + swift build -c release --product Sst BIN=".build/release/Sst" -APP="build/Sst.app" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" @@ -13,26 +17,49 @@ cp "$BIN" "$APP/Contents/MacOS/Sst" for b in .build/release/*.bundle; do [ -e "$b" ] && cp -R "$b" "$APP/Contents/Resources/" done +if [ -f Design/AppIcon.icns ]; then + cp Design/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" +else + echo "Let op: Design/AppIcon.icns ontbreekt; run scripts/make-icon.sh." +fi -cat > "$APP/Contents/Info.plist" <<'PLIST' +cat > "$APP/Contents/Info.plist" < - CFBundleIdentifierlocal.sst.app + CFBundleIdentifier$IDENTIFIER CFBundleNameSst + CFBundleDisplayNameSst CFBundleExecutableSst CFBundlePackageTypeAPPL - CFBundleShortVersionString0.1.0 + CFBundleShortVersionString$VERSION CFBundleVersion1 + CFBundleIconFileAppIcon LSMinimumSystemVersion14.0 - LSUIElement + LSApplicationCategoryTypepublic.app-category.productivity + NSHighResolutionCapable NSMicrophoneUsageDescription - Sst neemt je stem op zolang je Fn ingedrukt houdt, om die lokaal naar tekst om te zetten. + Sst neemt je stem op zolang je de sneltoets ingedrukt houdt, om die lokaal naar tekst om te zetten. PLIST -codesign --force --sign - --identifier local.sst.app "$APP" +# A stable signing identity keeps the macOS permissions (Microphone, Accessibility, Input Monitoring) across rebuilds. +IDENTITY="${SST_SIGN_IDENTITY:-$(security find-identity -v -p codesigning | sed -n 's/.*"\(Apple Development: [^"]*\)".*/\1/p' | head -1)}" +if [ -n "$IDENTITY" ]; then + codesign --force --sign "$IDENTITY" --identifier "$IDENTIFIER" "$APP" + echo "Ondertekend met: $IDENTITY" +else + codesign --force --sign - --identifier "$IDENTIFIER" "$APP" + echo "WAARSCHUWING: geen Apple Development-certificaat gevonden; ad-hoc ondertekend (rechten gaan na elke herbouw verloren)." +fi +codesign --verify --strict "$APP" echo "Gebouwd: $APP" -echo "Na een herbouw: tccutil reset Accessibility local.sst.app; tccutil reset ListenEvent local.sst.app; tccutil reset Microphone local.sst.app (en geef de rechten opnieuw)." +echo "Eenmalig na het wisselen van ondertekening: tccutil reset Accessibility $IDENTIFIER; tccutil reset ListenEvent $IDENTIFIER; tccutil reset Microphone $IDENTIFIER (en geef de rechten opnieuw)." + +if [ "${1:-}" = "--install" ]; then + rm -rf /Applications/Sst.app + cp -R "$APP" /Applications/Sst.app + echo "Geïnstalleerd: /Applications/Sst.app" +fi diff --git a/scripts/make-icon.sh b/scripts/make-icon.sh new file mode 100755 index 0000000..8faf4f1 --- /dev/null +++ b/scripts/make-icon.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Renders the app icon from the design tokens and stores it as Design/AppIcon.icns (committed). +set -euo pipefail +cd "$(dirname "$0")/.." + +swift build --product SstIcon +rm -rf build/AppIcon.iconset +.build/debug/SstIcon build/AppIcon.iconset +iconutil -c icns build/AppIcon.iconset -o Design/AppIcon.icns +echo "Icoon geschreven: Design/AppIcon.icns"