1559 lines
64 KiB
Markdown
1559 lines
64 KiB
Markdown
# 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.
|