Files
sst/docs/superpowers/plans/2026-10-01-app-3-ui-kit.md
T
2026-10-01 14:32:04 +02:00

1559 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <noreply@anthropic.com>` (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/<this file> → 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<String> = ["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 <noreply@anthropic.com>"
```
---
### 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<SegmentID>`, `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..<Int(seconds / dt) { peak = max(peak, spring.step(target: target, dt: dt)) }
return (spring.position, peak)
}
@Test func reachesTheTargetWithinTheResponseTime() {
let result = simulate(NeedleSpring(), seconds: Motion.needleResponse)
#expect(abs(result.final - 1) < 0.02)
}
@Test func overshootsSlightlyWithTheTokenDamping() {
let result = simulate(NeedleSpring(), seconds: 1)
#expect(result.peak > 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<SegmentID>] = [
"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<SegmentID> {
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 <noreply@anthropic.com>"
```
---
### 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..<side).map { _ in Double.random(in: 0...1, using: &rng) }
var pixels = [UInt8](repeating: 0, count: side * side * 4)
for y in 0..<side {
let row = rows[y]
for x in 0..<side {
let value = row * 0.9 + Double.random(in: 0...1, using: &rng) * 0.1
let alpha = min(1, abs(value - 0.5) * 2 * grain * gain)
let shade = value > 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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..<MeterTokens.ledSegments, id: \.self) { index in
RoundedRectangle(cornerRadius: Radius.sharp, style: .circular)
.fill(color(for: index, lit: index < lit))
.frame(width: MeterTokens.ledSegmentWidth, height: MeterTokens.ledSegmentHeight)
}
}
.accessibilityHidden(true)
}
private func color(for index: Int, lit: Bool) -> 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 <noreply@anthropic.com>"
```
---
### 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 <pad.png>` (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 <noreply@anthropic.com>"
```
---
## 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.