Files
onyx/Onyx/GalleryView.swift
ScarriffleandClaude Opus 5 edb327cabe 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>
2026-09-05 21:05:51 +02:00

569 lines
22 KiB
Swift
Raw Permalink 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.
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()
}
}
}