From 2d75e17d1c32e29f3ebbc32400b225feb0bafd48 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 14:32:04 +0200 Subject: [PATCH] docs: add app plan 3 (UI kit) Co-Authored-By: Claude Sonnet 5.5 --- .../plans/2026-10-01-app-3-ui-kit.md | 1558 +++++++++++++++++ 1 file changed, 1558 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-01-app-3-ui-kit.md 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.