Eine Designschicht statt hundertfach gesetzter Zahlen

Token für Schrift, Symbolgröße, Abstand, Radius und Bewegung, dazu sieben
Bauteile, die vorher in jeder Kachel neu gebaut wurden — Karte, Abschnittstitel,
Hinweis, Pillenknopf, Regler, Ablegefläche, Haarlinie.

Symbole bekommen eine **eigene** Staffel und nicht die der Schrift: ein
9-Punkt-Symbol neben 9-Punkt-Text wirkt kleiner, nicht gleich groß. Beide in
eine Staffel zu zwingen hieße, an jeder Stelle gegen sie zu arbeiten.

Das Flächenrezept — Tint, Bänderung, Kante — stand zweimal da, einmal für das
Panel und einmal für Fenster. Es ist jetzt `OnyxStone`, generisch über die Form,
damit auch die animierende Notch-Silhouette es benutzen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 21:04:51 +02:00
co-authored by Claude Opus 5
parent 49a3ff0548
commit ecb39fbe5f
11 changed files with 1195 additions and 184 deletions
@@ -0,0 +1,243 @@
import SwiftUI
/// Die Bauteile der **Fenster** — Einstellungen, Onboarding, Popover.
///
/// **Getrennt von den Panel-Bauteilen, und genau das ist der Punkt.** Das Panel
/// ist schwarzer Stein und bleibt es; ein Fenster gehört dem System und folgt
/// dessen Erscheinung. Diese Trennung fehlte bisher: `Onyx.Color.textTertiary`
/// ist Weiß mit 34 % Deckkraft und stand in den Einstellungen — im Hellmodus
/// war das praktisch unsichtbar. Die Bauteile hier benutzen deshalb
/// Systemsemantik (`.secondary`, `.quaternary`, `controlBackgroundColor`), die
/// hell wie dunkel richtig herauskommt. Die Akzentfarbe von Onyx kommt über
/// `.tint` von außen dazu.
// MARK: - Karte
/// Eine Gruppe zusammengehörender Zeilen.
///
/// Zieht die Trennlinien **selbst** zwischen die Zeilen. Von Hand gesetzt
/// fehlen sie irgendwann irgendwo, oder es steht eine zu viel am Ende der
/// Gruppe — das ist keine Frage von Sorgfalt, sondern von Gelegenheit.
public struct OnyxFormCard<Content: View>: View {
private let header: String?
private let headerHint: String?
private let content: Content
/// - Parameters:
/// - header: Die Überschrift der Gruppe. Sie steht **in** der Karte und
/// ohne Trennlinie darunter — sie benennt die Zeilen, sie ist keine
/// davon.
/// - headerHint: Ein Satz, der für die **ganze** Gruppe gilt. Er gehört
/// hierher und nicht an die erste Zeile: dort läse er sich, als
/// erkläre er nur diese eine.
public init(_ header: String? = nil,
hint headerHint: String? = nil,
@ViewBuilder content: () -> Content) {
self.header = header
self.headerHint = headerHint
self.content = content()
}
public var body: some View {
VStack(spacing: 0) {
if header != nil || headerHint != nil {
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
if let header {
Text(header).font(.headline)
}
if let headerHint {
Text(headerHint)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.top, OnyxForm.rowPadding)
.padding(.bottom, Onyx.Space.xxs)
}
Group(subviews: content) { rows in
ForEach(rows.indices, id: \.self) { index in
if index > 0 {
// Eingerückt wie in den Systemeinstellungen: eine Linie
// von Kante zu Kante zerschneidet die Karte, statt ihre
// Zeilen zu trennen.
Divider().padding(.leading, OnyxForm.rowPadding)
}
rows[index]
}
}
}
// Ein Material, keine Farbe.
//
// `controlBackgroundColor` ist deckend und macht aus jeder Karte eine
// Platte — das Fenster darunter kann dann so durchlässig sein, wie es
// will, man sieht nur zwischen den Karten etwas davon. Ein Material
// nimmt den Grund auf und bleibt trotzdem lesbar, weil es ihn
// gleichzeitig verwäscht.
.background(.ultraThinMaterial,
in: .rect(cornerRadius: OnyxForm.cardRadius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: OnyxForm.cardRadius, style: .continuous)
.strokeBorder(.quaternary, lineWidth: 1)
}
}
}
// MARK: - Zeile
/// Eine Einstellung: was sie ist, warum es sie gibt, und ihr Bedienelement.
///
/// Der Hinweis steht **unter** dem Titel, nicht daneben und nicht in einer
/// eigenen Zeile darunter. Daneben drängt er das Bedienelement an den Rand;
/// als eigene Zeile verliert er den Bezug zu dem, was er erklärt.
public struct OnyxFormRow<Trailing: View>: View {
/// Wie ernst die Zeile ist.
///
/// Die Signalfarben stehen fest und bedeuten etwas. Eine Warnung, die
/// aussieht wie eine Einstellung, ist keine.
public enum Tone {
case normal
case warning
case critical
/// `nil` heißt: nimm, was die Umgebung vorgibt — `.tint` beim Symbol,
/// `.primary` beim Titel. Ein fester Ersatzwert stünde beiden im Weg.
var style: AnyShapeStyle? {
switch self {
case .normal: nil
case .warning: AnyShapeStyle(Onyx.Color.warning)
case .critical: AnyShapeStyle(Onyx.Color.critical)
}
}
}
private let title: String
private let hint: String?
private let symbol: String?
private let tone: Tone
private let trailing: Trailing
public init(_ title: String,
hint: String? = nil,
symbol: String? = nil,
tone: Tone = .normal,
@ViewBuilder trailing: () -> Trailing) {
self.title = title
self.hint = hint
self.symbol = symbol
self.tone = tone
self.trailing = trailing()
}
public var body: some View {
HStack(alignment: .center, spacing: OnyxForm.rowPadding) {
if let symbol {
Image(systemName: symbol)
.frame(width: 18)
.foregroundStyle(tone.style ?? AnyShapeStyle(.tint))
}
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
Text(title)
.foregroundStyle(tone.style ?? AnyShapeStyle(.primary))
if let hint {
Text(hint)
.font(.callout)
.foregroundStyle(.secondary)
// Ohne das bricht ein längerer Hinweis nicht um,
// sondern schiebt die ganze Zeile über die Kante.
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
trailing
}
.padding(.horizontal, OnyxForm.rowPadding)
.padding(.vertical, Onyx.Space.row)
}
}
public extension OnyxFormRow where Trailing == EmptyView {
init(_ title: String, hint: String? = nil, symbol: String? = nil, tone: Tone = .normal) {
self.init(title, hint: hint, symbol: symbol, tone: tone) { EmptyView() }
}
}
// MARK: - Seitenleiste
/// Ein Eintrag in der Seitenleiste: farbiges Plättchen, dann der Name.
///
/// Das Plättchen ist keine Verzierung. Eine Seitenleiste aus zwölf Zeilen
/// gleicher Schrift liest man jedes Mal von vorn; mit Farbe und Form findet
/// man den gesuchten Eintrag, ohne ihn zu lesen.
public struct OnyxSidebarLabel: View {
private let title: String
private let symbol: String
private let tint: Color
public init(_ title: String, symbol: String, tint: Color) {
self.title = title
self.symbol = symbol
self.tint = tint
}
public var body: some View {
HStack(spacing: Onyx.Space.md) {
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
.fill(tint)
.frame(width: 20, height: 20)
.overlay {
Image(systemName: symbol)
.font(.system(size: Onyx.Symbol.small, weight: .semibold))
// Weiß, nicht `.primary`: das Plättchen ist in beiden
// Erscheinungen farbig und damit immer dunkel genug.
.foregroundStyle(.white)
}
Text(title)
}
}
}
/// Die Überschrift eines Abschnitts in einem Popover.
///
/// Sie stand zweimal im Baum, zeichengleich — einmal als Struktur in
/// `MetricPopovers`, einmal als Funktion in `NetworkPopover`. Dass beide gleich
/// aussahen, war Zufall und keine Zusage.
///
/// In der Akzentfarbe und nicht gedämpft: ein Popover ist klein, und die
/// Abschnitte darin sind das einzige Gerüst, an dem man sich festhält.
public struct OnyxPopoverTitle: View {
private let text: String
public init(_ text: String) { self.text = text }
public var body: some View {
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(Onyx.Color.accent)
.textCase(.uppercase)
}
}
// MARK: - Maße
/// Die wenigen Maße, die Karte, Zeile und Trennlinie miteinander teilen.
///
/// Nicht in `Onyx.Space`: das ist die Staffel für die dichte Kachelfläche.
/// Ein Fenster hat andere Verhältnisse, und beides in eine Staffel zu zwingen
/// hieße, eines von beidem falsch zu machen.
public enum OnyxForm {
public static let cardRadius: CGFloat = 10
public static let rowPadding: CGFloat = 14
/// Abstand zwischen zwei Karten.
public static let cardSpacing: CGFloat = 18
/// Rand um den Inhalt eines Bereichs.
public static let pagePadding: CGFloat = 20
}