Kacheln und Fenster sprechen dieselbe Sprache

Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 21:05:51 +02:00
co-authored by Claude Opus 5
parent 9b600de9a9
commit edb327cabe
32 changed files with 3677 additions and 968 deletions
+568
View File
@@ -0,0 +1,568 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
/// Das Schaufenster: jede Kachel, das ganze Panel und alle Token — ohne die
/// Notch anzufahren.
///
/// **Warum ein Fenster und keine `#Preview`.** Der Baum setzt
/// `ENABLE_DEBUG_DYLIB = NO`, weil Xcode den Programmcode sonst in eine eigene
/// dylib auslagert und TCC die Berechtigungsanfragen dem verbleibenden Rumpf
/// zuordnet, der weder Info.plist noch Entitlements trägt. Genau diese
/// Auslagerung brauchen SwiftUIs Vorschauen aber. Die Vorschau ist hier also
/// nicht vergessen worden, sie ist der Preis einer Entscheidung, die vorher
/// fiel. Ein Fenster kostet ihn nicht — und zeigt zudem die echten Modelle
/// statt erfundener.
///
/// **Nicht lokalisiert, und das mit Absicht.** Das Schaufenster ist Werkzeug,
/// keine Programmoberfläche. Seine Beschriftungen im Sprachkatalog hätten
/// dieselbe Sichtbarkeit wie die der Einstellungen und wären doch nie zu
/// übersetzen.
struct GalleryView: View {
let model: AppModel
/// Bleibt stehen, wo man zuletzt war.
///
/// Nicht nur bequem: `UserDefaults` liest Startargumente mit, also führt
/// `open Onyx.app --args -onyx.gallery.section tokens` direkt in den
/// Bereich, an dem man gerade arbeitet. Bauen, starten, hinsehen — ohne
/// Zwischenklick.
@AppStorage("onyx.gallery.section") private var section: GallerySection = .tiles
var body: some View {
VStack(spacing: 0) {
Picker("", selection: $section) {
ForEach(GallerySection.allCases) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, Onyx.Space.window)
.padding(.top, Onyx.Space.row)
.padding(.bottom, Onyx.Space.xl)
Divider()
ScrollView {
switch section {
case .tiles: TileGallery()
case .panel: PanelGallery(model: model)
case .components: ComponentGallery()
case .tokens: TokenGallery()
}
}
.background(Color(nsColor: .windowBackgroundColor))
}
.frame(minWidth: 760, minHeight: 520)
// Im Rumpf gelesen, damit ein Farbwechsel ankommt — wie im Panel.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
}
}
enum GallerySection: String, CaseIterable, Identifiable {
case tiles, panel, components, tokens
var id: Self { self }
var title: String {
switch self {
case .tiles: "Kacheln"
case .panel: "Panel"
case .components: "Bauteile"
case .tokens: "Token"
}
}
}
// MARK: - Kacheln
/// Jede angemeldete Kachel in echter Größe auf echter Paneloberfläche.
///
/// Echte Größe und echte Fläche sind der ganze Zweck: eine Kachel, die man in
/// einem beliebig breiten Fenster beurteilt, sieht dort immer gut aus. 180 × 200
/// auf schwarzem Stein ist der Ort, an dem sie später steht.
private struct TileGallery: View {
private struct Entry: Identifiable {
let id: String
let widget: any OnyxWidget
}
var body: some View {
let entries = WidgetRegistry.shared.all.map { Entry(id: $0.id, widget: $0) }
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
if entries.isEmpty {
Text("Keine Kacheln angemeldet.")
.font(.callout)
.foregroundStyle(.secondary)
} else {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: LayoutEngine.tileSize.width,
maximum: LayoutEngine.tileSize.width),
spacing: Onyx.Metric.tileSpacing)],
alignment: .leading,
spacing: Onyx.Space.xxl
) {
ForEach(entries) { entry in
VStack(spacing: Onyx.Space.sm) {
// Dieselbe Reihenfolge wie in `WidgetGrid`: Kachel,
// dann Rahmen, dann beschneiden. Wer erst
// beschneidet, sieht Überlauf nicht, den das Panel
// später zeigt.
OnyxTile { entry.widget.makeView() }
.frame(width: LayoutEngine.tileSize.width,
height: LayoutEngine.tileSize.height)
.clipped()
Text(entry.id)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Space.window)
}
}
// MARK: - Panel
/// Das ganze Panel, so breit, wie man es einstellt.
///
/// Die Breite ist regelbar, weil die Spaltenzahl an ihr hängt: `LayoutEngine`
/// rechnet aus der verfügbaren Breite, wie viele Kacheln nebeneinander passen,
/// und ab wann eine zweite Reihe entsteht. Genau diese Sprünge will man sehen,
/// ohne dafür einen Bildschirm anzustecken.
private struct PanelGallery: View {
let model: AppModel
@State private var availableWidth: Double = 1680
var body: some View {
let width = CGFloat(availableWidth)
let size = WidgetGrid.panelSize(for: model.layout, availableWidth: width)
VStack(alignment: .leading, spacing: Onyx.Space.xl) {
HStack(spacing: Onyx.Space.lg) {
Text("Verfügbare Breite")
.font(.callout)
Slider(value: $availableWidth, in: 400...3000)
.frame(maxWidth: 320)
Text("\(Int(availableWidth)) pt")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
Text("Panel \(Int(size.width)) × \(Int(size.height)) pt · "
+ "\(LayoutEngine.columns(availableWidth: width)) Spalten · "
+ "\(model.layout.count) Kacheln")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
WidgetGrid(placements: model.layout, availableWidth: width)
.background(OnyxSurface())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Space.window)
}
}
// MARK: - Bauteile
/// Jedes Bauteil in jedem Zustand.
///
/// Das ist der Teil, den es bei den Kacheln noch nicht gibt: deren Modelle
/// führen ihren Zustand als `private(set)`, „leer" oder „Fehler" lässt sich von
/// außen nicht setzen. Bei den Bauteilen ist der Zustand ein Argument — hier
/// kann man ihn also einfach hinschreiben.
private struct ComponentGallery: View {
@State private var volume = 0.62
@State private var overdrive = 1.8
@State private var targeted = true
var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
group("OnyxCard mit OnyxHairline und OnyxSectionTitle") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
OnyxSectionTitle("System")
OnyxCard {
VStack(spacing: 0) {
row("MacBook Pro-Lautsprecher", "41 %")
OnyxHairline()
row("MacBook Pro-Mikrofon", "27 %")
}
}
}
.frame(width: 260)
}
group("OnyxNotice") {
HStack(alignment: .top, spacing: Onyx.Space.lg) {
tile { OnyxNotice(symbol: "wifi.slash", text: "Kein Netz erreichbar.") }
tile {
OnyxNotice(symbol: "hand.raised",
text: "Der Helfer wartet auf deine Freigabe.") {
OnyxPillButton("Einstellungen") {}
}
}
tile {
OnyxNotice(symbol: "fan",
text: "Noch keine Lüfterdaten.",
placement: .leading)
}
tile {
OnyxNotice(symbol: "fan",
text: "Der Helfer ist nicht installiert.",
placement: .leading) {
OnyxPillButton("Installieren") {}
}
}
}
}
group("OnyxPillButton") {
HStack(spacing: Onyx.Space.lg) {
OnyxPillButton("Einschalten") {}
OnyxPillButton("Automatik", isFilled: false) {}
OnyxPillButton("Auto", size: .compact) {}
OnyxPillButton("Auto", isFilled: false, size: .compact) {}
}
}
group("OnyxSlider") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
sliderRow("normal") {
OnyxSlider(value: volume) { volume = $0 }
}
sliderRow("mit Pegel") {
OnyxSlider(value: volume, level: 0.35) { volume = $0 }
}
sliderRow("stumm") {
OnyxSlider(value: volume, isActive: false) { volume = $0 }
}
sliderRow("Overdrive") {
OnyxSlider(value: overdrive, maximum: 4, level: 0.5) { overdrive = $0 }
}
}
.frame(width: 320)
}
group("OnyxDropZone") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Toggle("Zeiger schwebt darüber", isOn: $targeted)
.font(Onyx.Font.body)
.foregroundStyle(Onyx.Color.textSecondary)
HStack(spacing: Onyx.Space.lg) {
tile {
OnyxNotice(symbol: "paperplane", text: "Zum Senden ablegen")
.onyxDropZone(isTargeted: targeted, style: .always)
}
tile {
OnyxNotice(symbol: "tray", text: "Nimmt nebenbei Dateien an")
.onyxDropZone(isTargeted: targeted, style: .whenTargeted)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
.padding(Onyx.Space.window)
}
private func row(_ name: String, _ value: String) -> some View {
HStack {
Text(name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
Spacer(minLength: Onyx.Space.md)
Text(value)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
.padding(.horizontal, Onyx.Space.md)
.padding(.vertical, Onyx.Space.md)
}
/// Bauteile in einer echten Kachel zeigen, nicht frei auf der Fläche: ein
/// Leerzustand, der sich mittig ausrichtet, braucht etwas, worin er das tun
/// kann.
private func tile(@ViewBuilder content: () -> some View) -> some View {
OnyxTile { content() }
.frame(width: LayoutEngine.tileSize.width, height: 130)
}
private func sliderRow(_ name: String,
@ViewBuilder content: () -> some View) -> some View {
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 72, alignment: .leading)
content()
}
}
private func group(_ title: String,
@ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Text(title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
content()
}
}
}
// MARK: - Token
/// Alle Token an einem Ort — auf der Fläche, für die sie gemacht sind.
///
/// Farben auf hellem Fensterhintergrund zu zeigen wäre irreführend: `hairline`
/// ist Weiß mit 8 % Deckkraft und auf Weiß schlicht nicht da.
private struct TokenGallery: View {
@State private var moved = false
var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
colors
spaces
radii
fonts
metrics
motion
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Onyx.Metric.panelPadding)
.background(OnyxSurface())
.padding(Onyx.Space.window)
}
private var colors: some View {
let swatches: [(String, Color)] = [
("surface", Onyx.Color.surface),
("elevated", Onyx.Color.elevated),
("hairline", Onyx.Color.hairline),
("textPrimary", Onyx.Color.textPrimary),
("textSecondary", Onyx.Color.textSecondary),
("textTertiary", Onyx.Color.textTertiary),
("accent", Onyx.Color.accent),
("download", Onyx.Color.download),
("upload", Onyx.Color.upload),
("positive", Onyx.Color.positive),
("warning", Onyx.Color.warning),
("critical", Onyx.Color.critical),
]
return section("Farben") {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120, maximum: 160), spacing: Onyx.Space.row)],
alignment: .leading, spacing: Onyx.Space.row) {
ForEach(swatches, id: \.0) { name, color in
HStack(spacing: Onyx.Space.md) {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.fill(color)
.overlay {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.strokeBorder(Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
.frame(width: 28, height: 28)
Text(name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
private var spaces: some View {
let steps: [(String, CGFloat)] = [
("xxs", Onyx.Space.xxs),
("xs", Onyx.Space.xs),
("sm", Onyx.Space.sm),
("md", Onyx.Space.md),
("lg", Onyx.Space.lg),
("xl", Onyx.Space.xl),
("xxl", Onyx.Space.xxl),
]
return section("Abstände") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(steps, id: \.0) { name, value in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 40, alignment: .leading)
Rectangle()
.fill(Onyx.Color.accent)
.frame(width: value, height: 12)
Text("\(Int(value))")
.font(Onyx.Font.metricMicro)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
private var radii: some View {
let steps: [(String, CGFloat)] = [
("chip", Onyx.Radius.chip),
("control", Onyx.Radius.control),
("card", Onyx.Radius.card),
("tile", Onyx.Radius.tile),
("notch", Onyx.Radius.notch),
("panel", Onyx.Radius.panel),
]
return section("Rundungen") {
HStack(alignment: .top, spacing: Onyx.Space.lg) {
ForEach(steps, id: \.0) { name, value in
VStack(spacing: Onyx.Space.sm) {
RoundedRectangle(cornerRadius: value, style: .continuous)
.fill(Onyx.Color.elevated)
.overlay {
RoundedRectangle(cornerRadius: value, style: .continuous)
.strokeBorder(Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
.frame(width: 64, height: 64)
Text("\(name) \(Int(value))")
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
}
}
}
private var fonts: some View {
let specimens: [(String, Font)] = [
("micro", Onyx.Font.micro),
("caption", Onyx.Font.caption),
("footnote", Onyx.Font.footnote),
("body", Onyx.Font.body),
("title", Onyx.Font.title),
("headline", Onyx.Font.headline),
("display", Onyx.Font.display),
("displayLarge", Onyx.Font.displayLarge),
("metricMicro", Onyx.Font.metricMicro),
("metricSmall", Onyx.Font.metricSmall),
("metric", Onyx.Font.metric),
("metricLarge", Onyx.Font.metricLarge),
("symbolHero", Onyx.Font.symbolHero),
]
return section("Schrift") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(specimens, id: \.0) { name, font in
HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
Text("Onyx 0123456789")
.font(font)
.foregroundStyle(Onyx.Color.textPrimary)
}
}
}
}
}
private var metrics: some View {
let values: [(String, CGFloat)] = [
("panelPadding", Onyx.Metric.panelPadding),
("tileSpacing", Onyx.Metric.tileSpacing),
("hairlineWidth", Onyx.Metric.hairlineWidth),
]
return section("Panelgeometrie") {
VStack(alignment: .leading, spacing: Onyx.Space.md) {
ForEach(values, id: \.0) { name, value in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
Rectangle()
.fill(Onyx.Color.accent)
.frame(width: max(value, 1), height: 12)
Text("\(Int(value))")
.font(Onyx.Font.metricMicro)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
}
}
/// Bewegung lässt sich nicht abbilden, nur vorführen — deshalb ein Knopf
/// und ein echter Profilwechsel statt einer Tabelle mit Federkonstanten.
private var motion: some View {
let curves: [(String, Animation)] = [
("expand", Onyx.Motion.expand),
("collapse", Onyx.Motion.collapse),
("value", Onyx.Motion.value),
]
return section("Bewegung") {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Picker("Profil", selection: Binding(
get: { OnyxTheme.shared.motion },
set: { OnyxTheme.shared.motion = $0 })) {
ForEach(MotionProfile.allCases, id: \.self) { profile in
Text(profile.rawValue).tag(profile)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 280)
ForEach(curves, id: \.0) { name, animation in
HStack(spacing: Onyx.Space.lg) {
Text(name)
.font(Onyx.Font.micro)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 96, alignment: .leading)
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.hairline)
.frame(height: 4)
Circle()
.fill(Onyx.Color.accent)
.frame(width: 14, height: 14)
.offset(x: moved ? 246 : 0)
.animation(animation, value: moved)
}
.frame(width: 260, height: 14)
}
}
Button(moved ? "Zurück" : "Los") { moved.toggle() }
}
}
}
@ViewBuilder
private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
Text(title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
content()
}
}
}