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
-142
View File
@@ -1,142 +0,0 @@
import SwiftUI
import OnyxDesign
/// Die Farbwahl.
///
/// Drei Farben, nicht zehn: die Akzentfarbe, mit der Onyx sich selbst zeichnet,
/// und das Paar für Herunter- und Hochladen. Die Signalfarben stehen bewusst
/// nicht hier — Gelb heißt Warnung, Rot heißt kritisch, und wer das umstellen
/// kann, kann die Bedeutung zerstören.
struct ColorSettingsView: View {
@Bindable var theme: OnyxTheme
/// Ein paar Vorschläge, damit man nicht im Farbwähler anfangen muss.
///
/// Alle gedämpft: Onyx ist eine dunkle Fläche, und eine volle Sättigung
/// darauf sticht, statt zu leiten.
private static let suggestions: [(name: LocalizedStringKey, hex: String)] = [
("color.suggestion.onyx", "#7FA8FF"),
("color.suggestion.mint", "#6FCF97"),
("color.suggestion.amber", "#E8B44A"),
("color.suggestion.coral", "#E5675C"),
("color.suggestion.lilac", "#B79CFF"),
("color.suggestion.slate", "#9AA6B2"),
]
var body: some View {
Form {
Section("color.section.accent") {
ColorPicker(selection: $theme.accent, supportsOpacity: false) {
Text("color.accent")
}
HStack(spacing: 8) {
ForEach(Self.suggestions, id: \.hex) { suggestion in
Swatch(hex: suggestion.hex,
name: suggestion.name,
isSelected: theme.accent.onyxHex == suggestion.hex) {
theme.accent = Color(onyxHex: suggestion.hex) ?? theme.accent
}
}
Spacer(minLength: 0)
}
// Mit dem Farbfleck daneben: welche Farbe das System gerade
// führt, weiß man nicht auswendig — und dann wirkt das
// Ergebnis wie ein Fehler statt wie eine Übernahme.
Button { theme.adoptSystemAccent() } label: {
HStack(spacing: 6) {
Text("color.adoptSystem")
Circle()
.fill(Color(nsColor: .controlAccentColor))
.frame(width: 11, height: 11)
}
}
Text("color.accent.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("color.section.network") {
ColorPicker(selection: $theme.download, supportsOpacity: false) {
Text("color.download")
}
ColorPicker(selection: $theme.upload, supportsOpacity: false) {
Text("color.upload")
}
Button {
let previous = theme.download
theme.download = theme.upload
theme.upload = previous
} label: {
Text("color.swap")
}
Text("color.network.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("color.section.fixed") {
// Sichtbar, aber nicht bedienbar: sonst sucht man die
// Warnfarbe hier und findet sie nirgends.
FixedRow(color: Onyx.Color.positive, label: "color.fixed.positive")
FixedRow(color: Onyx.Color.warning, label: "color.fixed.warning")
FixedRow(color: Onyx.Color.critical, label: "color.fixed.critical")
Text("color.fixed.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section {
Button { theme.reset() } label: {
Text("color.reset")
}
.disabled(theme.isDefault)
}
}
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
}
}
/// Ein Farbfeld zum Anklicken.
private struct Swatch: View {
let hex: String
let name: LocalizedStringKey
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.fill(Color(onyxHex: hex) ?? .gray)
.frame(width: 20, height: 20)
.overlay {
// Der Ring statt eines Hakens: ein Haken auf einer dunklen
// Farbe ist nicht zu sehen, auf einer hellen nicht auf
// einer dunklen Fläche.
Circle()
.strokeBorder(.primary, lineWidth: isSelected ? 2 : 0)
.padding(-3)
}
}
.buttonStyle(.plain)
.help(Text(name))
}
}
private struct FixedRow: View {
let color: Color
let label: LocalizedStringKey
var body: some View {
HStack {
Text(label)
Spacer(minLength: 12)
Circle().fill(color).frame(width: 14, height: 14)
}
}
}
@@ -0,0 +1,68 @@
import SwiftUI
/// Woraus die Fläche gemacht ist.
///
/// **Der Werkstoff, nicht die Form.** Ob Onyx oben als verschmolzene Notch
/// oder als schwebende Insel sitzt, entscheidet `NotchStyle`; woraus diese
/// Fläche besteht, steht hier. Beides zu vermengen hieße, eine Insel aus Glas
/// nicht bauen zu können, ohne die Notch mitzuändern.
///
/// Drei Werkstoffe und keine einzelnen Regler. Deckkraft, Glanz und Kante
/// hängen voneinander ab: viel Glanz auf einer deckenden Fläche sieht nach
/// Fettfilm aus, eine kaum sichtbare Kante auf einer durchscheinenden lässt
/// die Form ausfransen. Was man wählt, ist das Zusammenspiel — nicht drei
/// Zahlen.
public enum NotchMaterial: String, Codable, Sendable, CaseIterable {
/// Deckend schwarz. Die Voreinstellung, und die einzige, die mit der
/// Hardware-Notch verschmilzt: alles andere hätte dort eine sichtbare
/// Kante zum Gehäuse.
case stone
/// Durchscheinend — der Hintergrund ist als Weichzeichnung zu ahnen.
case blur
/// Stark durchscheinend, mit deutlichem Glanz und heller Kante.
case glass
/// Wie deckend der Tint über der Vibrancy-Ebene liegt.
public var fillOpacity: Double {
switch self {
case .stone: 1.0
case .blur: 0.72
case .glass: 0.42
}
}
/// Der Glanz von oben — die Bänderung des Steins.
///
/// Wächst mit der Durchlässigkeit: auf deckendem Schwarz genügt ein Hauch,
/// auf Glas ist er das, was die Fläche überhaupt als Körper lesbar macht.
public var sheen: Double {
switch self {
case .stone: 0.02
case .blur: 0.04
case .glass: 0.10
}
}
/// Die Kante.
///
/// Je durchlässiger die Fläche, desto mehr Arbeit muss die Kante leisten —
/// sie ist dann das Einzige, was die Form von dem trennt, was durch sie
/// hindurch zu sehen ist.
public var edge: Color {
switch self {
case .stone: Onyx.Color.hairline
case .blur: .white.opacity(0.12)
case .glass: .white.opacity(0.20)
}
}
/// Ob die Vibrancy-Ebene überhaupt etwas beiträgt.
///
/// Bei `stone` nicht — dort liegt sie vollständig unter deckendem Schwarz.
/// Das ist keine Verschwendung, sondern der Preis dafür, dass ein Wechsel
/// des Werkstoffs kein Neuaufbau des Fensters ist.
public var showsVibrancy: Bool { self != .stone }
}
@@ -0,0 +1,353 @@
import SwiftUI
/// Die Bauteile, aus denen Onyx besteht.
///
/// **Warum es sie gibt.** Nachgemessen war jedes dieser Stücke zwei- bis
/// fünfmal einzeln nachgebaut — die Karte im Mischpult zweimal wortgleich, der
/// Lautstärkeregler zweimal mit denselben achtzig Zeilen, der Leerzustand
/// fünfmal, der Pillenknopf dreimal. Solange das so ist, ist eine
/// Designänderung keine Änderung an einer Stelle, sondern eine Suche.
///
/// **Alle Beschriftungen kommen fertig übersetzt herein**, als `String`, nicht
/// als `LocalizedStringKey`. Jedes Modul hat seinen eigenen Sprachkatalog, und
/// ein Bauteil in `OnyxDesign` kann `bundle: .module` des Aufrufers nicht
/// auflösen. Der Aufrufer übersetzt, das Bauteil zeichnet.
// MARK: - Karte
/// Eine Karte **innerhalb** einer Kachel.
///
/// Fasst zusammen, was zusammengehört, und trennt es von dem, was daneben
/// steht. Nicht zu verwechseln mit `OnyxTile`: die Kachel ist das Fach im
/// Panel, die Karte ist eine Gruppe darin.
public struct OnyxCard<Content: View>: View {
private let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.card, style: .continuous)
content
.background(Onyx.Color.card, in: shape)
.overlay {
shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth)
}
}
}
// MARK: - Abschnittstitel
/// Die Überschrift über einer Gruppe.
///
/// Versal und klein — sie soll die Gruppe benennen, nicht mit ihrem Inhalt um
/// Aufmerksamkeit streiten.
public struct OnyxSectionTitle: View {
private let text: String
public init(_ text: String) { self.text = text }
public var body: some View {
Text(text)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
.textCase(.uppercase)
}
}
// MARK: - Trennlinie
/// Die Haarlinie zwischen zwei Zeilen.
///
/// `Divider()` allein zeichnet in der Systemfarbe und ist auf schwarzem Stein
/// entweder zu hell oder gar nicht da.
public struct OnyxHairline: View {
public init() {}
public var body: some View {
Divider().overlay(Onyx.Color.hairline)
}
}
// MARK: - Leerzustand
/// Wenn eine Kachel nichts zu zeigen hat.
///
/// Ein Leerzustand ist kein Fehler und soll auch nicht so aussehen: Symbol
/// gedämpft, Text eine Stufe heller, und wenn es etwas zu tun gibt, genau ein
/// Knopf.
public struct OnyxNotice<Action: View>: View {
/// Mittig in der Kachel oder linksbündig unter dem, was darüber steht.
public enum Placement {
/// Für Kacheln, die sonst leer wären — Wetter, Kalender.
case centered
/// Für Kacheln, die den Hinweis in eine bestehende Spalte einreihen —
/// Lüfter.
case leading
}
private let symbol: String
private let text: String
private let placement: Placement
private let action: Action
public init(symbol: String,
text: String,
placement: Placement = .centered,
@ViewBuilder action: () -> Action) {
self.symbol = symbol
self.text = text
self.placement = placement
self.action = action()
}
public var body: some View {
VStack(alignment: placement == .centered ? .center : .leading, spacing: Onyx.Space.sm) {
Image(systemName: symbol)
.font(Onyx.Font.headline)
.foregroundStyle(Onyx.Color.textTertiary)
Text(text)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.multilineTextAlignment(placement == .centered ? .center : .leading)
// Ohne das reißt ein zweizeiliger Hinweis in der schmalen
// Kachel mitten im Wort ab, statt umzubrechen.
.fixedSize(horizontal: false, vertical: true)
action
}
.frame(maxWidth: .infinity,
maxHeight: placement == .centered ? .infinity : nil,
alignment: placement == .centered ? .center : .leading)
}
}
public extension OnyxNotice where Action == EmptyView {
init(symbol: String, text: String, placement: Placement = .centered) {
self.init(symbol: symbol, text: text, placement: placement) { EmptyView() }
}
}
// MARK: - Pillenknopf
/// Der kleine Knopf in einer Kachel.
///
/// Zwei Zustände, und der Unterschied ist Absicht: gefüllt heißt „das gilt
/// gerade", umrandet heißt „das kannst du wählen". Ein blauer Text sähe aus wie
/// ein Verweis und nicht wie ein Zustand.
public struct OnyxPillButton: View {
/// Zwei Dichten, weil zwei Orte gemeint sind: der Knopf unter einem
/// Hinweis hat Platz, der Knopf in einer Lüfterzeile hat ihn nicht.
public enum Size {
case regular
case compact
var font: Font { self == .regular ? Onyx.Font.caption : Onyx.Font.metricSmall }
var horizontal: CGFloat { self == .regular ? Onyx.Space.md : Onyx.Space.sm }
var vertical: CGFloat { self == .regular ? Onyx.Space.xs : Onyx.Space.xxs }
}
private let title: String
private let isFilled: Bool
private let size: Size
private let action: () -> Void
public init(_ title: String,
isFilled: Bool = true,
size: Size = .regular,
action: @escaping () -> Void) {
self.title = title
self.isFilled = isFilled
self.size = size
self.action = action
}
public var body: some View {
Button(action: action) {
Text(title)
.font(size.font)
// Umrandet heißt bedienbar, nicht abgeschaltet — in der
// Lüfterkachel ist gerade die **gefüllte** Pille die
// ausgegraute. `textTertiary` sah hier aus wie „geht nicht";
// das Ausgrauen macht ohnehin `.disabled()`.
.foregroundStyle(isFilled ? Onyx.Color.onAccent : Onyx.Color.textSecondary)
.padding(.horizontal, size.horizontal)
.padding(.vertical, size.vertical)
.background {
Capsule()
.fill(isFilled ? Onyx.Color.accent : Color.clear)
.overlay {
Capsule().strokeBorder(isFilled ? .clear : Onyx.Color.hairline,
lineWidth: Onyx.Metric.hairlineWidth)
}
}
}
.buttonStyle(.plain)
}
}
// MARK: - Regler
/// Der Regler mit mitlaufendem Pegel.
///
/// **Einer, nicht zwei.** Es gab ihn zweimal — für die Programme im Mischpult
/// und für die Systemlautstärke — mit denselben Knopfgrößen, derselben Geste
/// und derselben Pegelrechnung. Der Systemregler war exakt dieser hier mit
/// `maximum: 1`; der 100-%-Strich unterdrückt sich dort von selbst.
///
/// Der Pegel läuft **im** Regler mit, nicht daneben: eine zweite Leiste je
/// Zeile wäre doppelt so viel Grafik für dieselbe Auskunft.
public struct OnyxSlider: View {
private let value: Double
private let maximum: Double
private let level: Double
private let isActive: Bool
private let onChange: (Double) -> Void
@State private var isDragging = false
/// - Parameters:
/// - maximum: Der rechte Anschlag. 1 im Normalfall, mehr bei Overdrive.
/// - level: Der Ausschlag, der im Regler mitläuft. 0 blendet ihn aus.
/// - isActive: Ausgegraut, wenn das Programm stumm ist oder nicht regelbar.
public init(value: Double,
maximum: Double = 1,
level: Double = 0,
isActive: Bool = true,
onChange: @escaping (Double) -> Void) {
self.value = value
self.maximum = maximum
self.level = level
self.isActive = isActive
self.onChange = onChange
}
private static let trackHeight: CGFloat = 6
private static let barHeight: CGFloat = 16
public var body: some View {
GeometryReader { geometry in
let knob: CGFloat = isDragging ? 13 : 11
// Der Knopf soll an den Enden nicht überstehen.
let travel = max(geometry.size.width - knob, 1)
let fraction = min(max(value / maximum, 0), 1)
let fillWidth = travel * fraction + knob / 2
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.surface)
.frame(height: Self.trackHeight)
// Markierung bei 100 %: darüber wird verstärkt, und man soll
// sehen, wann man diese Grenze überschreitet. Ohne Overdrive
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
if maximum > 1 {
Rectangle()
.fill(Onyx.Color.hairline)
.frame(width: 1, height: Self.trackHeight + 4)
.offset(x: knob / 2 + travel / maximum)
}
Capsule()
.fill(value > 1 ? Onyx.Color.warning : Onyx.Color.accent)
.frame(width: fillWidth, height: Self.trackHeight)
.opacity(isActive ? 1 : 0.3)
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * Self.meterFraction(level),
height: Self.trackHeight)
.blendMode(.plusLighter)
.allowsHitTesting(false)
}
Circle()
.fill(.white)
.frame(width: knob, height: knob)
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
.offset(x: travel * fraction)
.opacity(isActive ? 1 : 0.55)
}
.frame(height: Self.barHeight)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
isDragging = true
let position = min(max((drag.location.x - knob / 2) / travel, 0), 1)
onChange(position * maximum)
}
.onEnded { _ in isDragging = false })
}
.frame(height: Self.barHeight)
.animation(Onyx.Motion.value, value: isDragging)
}
/// Wie weit der Pegel im Regler ausschlägt.
///
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos: halb
/// so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
/// Bereich so weit, dass man überhaupt etwas sieht.
public static func meterFraction(_ level: Double) -> Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}
// MARK: - Ablegefläche
/// Wohin man Dateien ziehen kann.
///
/// Zwei Fassungen, weil zwei Dinge gemeint sind: eine Fläche, die **nur zum
/// Ablegen da ist**, zeigt ihre gestrichelte Kante immer — sonst weiß niemand,
/// dass sie eine ist. Eine Kachel, die **nebenbei auch** Dateien annimmt, zeigt
/// sie erst, wenn wirklich etwas darüber schwebt.
public enum OnyxDropZoneStyle {
/// Immer sichtbar, beim Anvisieren in der Akzentfarbe.
case always
/// Nur beim Anvisieren, dann mit leichter Füllung.
case whenTargeted
}
private struct OnyxDropZone: ViewModifier {
let isTargeted: Bool
let style: OnyxDropZoneStyle
func body(content: Content) -> some View {
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.card, style: .continuous)
return content
.overlay {
switch style {
case .always:
shape.strokeBorder(
isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
style: StrokeStyle(lineWidth: Onyx.Metric.hairlineWidth, dash: [4, 3]))
.allowsHitTesting(false)
case .whenTargeted:
if isTargeted {
shape.strokeBorder(Onyx.Color.accent,
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
.background(Onyx.Color.accent.opacity(0.08), in: shape)
.allowsHitTesting(false)
}
}
}
.animation(Onyx.Motion.value, value: isTargeted)
}
}
public extension View {
/// Zeichnet die gestrichelte Kante einer Ablegefläche.
///
/// Nimmt selbst keine Dateien an — das macht `onFileDrop`. Getrennt, weil
/// die Kante auch dort gebraucht wird, wo das Ablegen die ganze Kachel
/// betrifft und nicht nur den umrandeten Teil.
func onyxDropZone(isTargeted: Bool, style: OnyxDropZoneStyle) -> some View {
modifier(OnyxDropZone(isTargeted: isTargeted, style: style))
}
}
@@ -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
}
@@ -0,0 +1,66 @@
import SwiftUI
/// Wie sich Onyx bewegt.
///
/// Drei Profile statt einzelner Regler. Wer Federkonstanten einzeln einstellen
/// darf, stellt sie kaputt: Ausfahren und Einfahren müssen zueinander passen,
/// und diese Beziehung ist es, die man wählt — nicht vier Zahlen.
///
/// **Einfahren ist in jedem Profil kürzer als Ausfahren und schwingt nicht
/// nach.** Beim Öffnen sieht man zu, beim Schließen ist man schon woanders;
/// dieselbe Kurve rückwärts fühlt sich dort zäh an, und das Nachschwingen liest
/// sich als Ruckler.
public enum MotionProfile: String, CaseIterable, Sendable {
/// Die Voreinstellung: schnell, mit einem Hauch Nachschwingen. Das gibt dem
/// Panel Masse, statt es nur größer werden zu lassen.
case snappy
/// Deutlich mehr Nachschwingen. Verspielter, und bei häufigem Öffnen
/// anstrengender.
case bouncy
/// Länger und ohne jedes Überschwingen. Für Bildschirme, auf denen viel
/// gleichzeitig passiert.
case calm
/// Ausfahren.
public var expand: Animation {
switch self {
case .snappy: .spring(response: 0.34, dampingFraction: 0.86)
case .bouncy: .spring(response: 0.42, dampingFraction: 0.62)
case .calm: .spring(response: 0.52, dampingFraction: 1.0)
}
}
/// Einfahren.
public var collapse: Animation {
switch self {
case .snappy: .spring(response: 0.24, dampingFraction: 1.0)
case .bouncy: .spring(response: 0.28, dampingFraction: 0.85)
case .calm: .spring(response: 0.36, dampingFraction: 1.0)
}
}
/// Für Messwerte: sichtbar, aber ohne Pulsieren.
public var value: Animation {
switch self {
case .snappy: .easeOut(duration: 0.25)
case .bouncy: .easeOut(duration: 0.28)
case .calm: .easeOut(duration: 0.40)
}
}
/// Wie lange das Einfahren dauert.
///
/// Der Fenstercontroller braucht die Zahl, nicht die Kurve: er blendet das
/// Fenster erst danach aus. Wird sie zu klein angesetzt, reißt das Panel
/// mitten in der Bewegung weg.
public var collapseDuration: Double {
switch self {
case .snappy: 0.30
case .bouncy: 0.36
case .calm: 0.46
}
}
}
@@ -20,34 +20,72 @@ public struct OnyxVisualEffect: NSViewRepresentable {
public func updateNSView(_ view: NSVisualEffectView, context: Context) {}
}
/// Der Hintergrund des Panels: Vibrancy, Tint, ein sehr flacher Glanz von oben
/// und eine Haarlinie als Innenkante.
public struct OnyxSurface: View {
private let cornerRadius: CGFloat
/// Der Stein selbst — auf einer beliebigen Form.
///
/// Tint, Bänderung, Kante. Drei Lagen, die zusammen den Werkstoff ergeben, und
/// zwar unabhängig davon, welche Form sie annehmen.
///
/// **Warum generisch und nicht einfach ein abgerundetes Rechteck.** Das Panel
/// zeichnet auf ``NotchShape``, einer Form, die sich beim Aus- und Einfahren
/// verändert. Solange das Rezept an ein Rechteck gebunden war, konnte es dort
/// nicht mitbenutzt werden — also stand es zweimal da, einmal hier und einmal
/// im Panel. Zweimal heißt: eine Änderung an der Bänderung wirkt an einer
/// Stelle und an der anderen nicht, und das fällt erst auf, wenn beide
/// nebeneinander zu sehen sind.
public struct OnyxStone<S: InsettableShape>: View {
private let shape: S
public init(cornerRadius: CGFloat = Onyx.Metric.panelCornerRadius) {
self.cornerRadius = cornerRadius
public init(_ shape: S) {
self.shape = shape
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
// Aus dem Thema gelesen und nicht als Argument gereicht: sonst müsste
// jede Stelle, die eine Fläche zeichnet, den Werkstoff durchschleifen —
// bis hinunter zur Kachel, die ihn gar nicht kennt.
let material = OnyxTheme.shared.material
shape
.fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity))
.background(OnyxVisualEffect().clipShape(shape))
.fill(Onyx.Color.surface.opacity(material.fillOpacity))
.overlay {
// Die Bänderung des Steins: oben ein Hauch heller, nach 40 %
// vollständig verlaufen. Mehr wirkt sofort billig.
// vollständig verlaufen. Mehr wirkt sofort billig — wie viel
// „ein Hauch" ist, hängt am Werkstoff.
shape.fill(
LinearGradient(
stops: [
.init(color: .white.opacity(0.02), location: 0),
.init(color: .white.opacity(material.sheen), location: 0),
.init(color: .clear, location: 0.4),
],
startPoint: .top, endPoint: .bottom))
}
.overlay {
shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth)
// `strokeBorder` und nicht `stroke`: die Linie liegt dann
// vollständig **innen**. Mittig gezeichnet ragte sie zur Hälfte
// über die Kante hinaus und wurde an den Rundungen sichtbar
// dünner.
shape.strokeBorder(material.edge, lineWidth: Onyx.Metric.hairlineWidth)
}
}
}
/// Der Hintergrund des Panels: Vibrancy und darauf der Stein.
public struct OnyxSurface: View {
private let cornerRadius: CGFloat
public init(cornerRadius: CGFloat = Onyx.Radius.panel) {
self.cornerRadius = cornerRadius
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
OnyxStone(shape)
.background {
// Bei deckendem Stein trägt die Weichzeichnung nichts bei und
// wird auch nicht berechnet.
if OnyxTheme.shared.material.showsVibrancy {
OnyxVisualEffect().clipShape(shape)
}
}
}
}
@@ -61,10 +99,10 @@ public struct OnyxTile<Content: View>: View {
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: Onyx.Metric.tileCornerRadius, style: .continuous)
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.tile, style: .continuous)
content
.padding(12)
.padding(Onyx.Space.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(shape.fill(Onyx.Color.elevated))
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth))
@@ -1,7 +1,8 @@
import SwiftUI
import AppKit
/// Die einstellbaren Farben.
/// Was am Aussehen von Onyx einstellbar ist: die Farben und das
/// Bewegungsprofil.
///
/// **Nicht alle Farben sind einstellbar, und das ist Absicht.** Gelb heißt
/// Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
@@ -20,8 +21,17 @@ public final class OnyxTheme {
/// Kühl und entsättigt — Farbe ist in Onyx ein Signal, keine Dekoration.
public static let defaultAccent = Color(red: 0.498, green: 0.659, blue: 1.0)
/// Herunterladen grün, Hochladen rosa: die Zuordnung ist reine Gewohnheit,
/// aber eine verbreitete. Wer sie anders kennt, dreht sie um.
public static let defaultDownload = Color(red: 0.435, green: 0.812, blue: 0.592)
/// aber eine verbreitete.
///
/// **Wärmer als das Signalgrün, und das ist der Punkt.** Vorher war dieser
/// Wert Zeichen für Zeichen `Onyx.Color.positive` — derselbe grüne Balken
/// hieß im Netzwerk „lädt herunter" und in den Messwerten „alles in
/// Ordnung". Zwei Bedeutungen in einer Farbe sind eine zu viel.
///
/// Wärmer und nicht kühler, weil das Gegenstück rosa bis cyan sein darf:
/// ein Grün, das ins Türkise zieht, wäre vom Hochladen kaum noch zu
/// unterscheiden. Nach Gelb hin ist von beidem Abstand.
public static let defaultDownload = Color(red: 0.561, green: 0.835, blue: 0.408)
public static let defaultUpload = Color(red: 0.937, green: 0.427, blue: 0.612)
public var accent: Color {
@@ -34,12 +44,31 @@ public final class OnyxTheme {
didSet { store(upload, as: Keys.upload) }
}
/// Wie sich das Panel bewegt.
///
/// Getrennt von den Farben gespeichert und **nicht** von `reset()` erfasst:
/// der Knopf dort heißt „Farben zurücksetzen", und ein Knopf, der nebenbei
/// die Animationen umstellt, ist eine Falle.
public var motion: MotionProfile = .snappy {
didSet { defaults.set(motion.rawValue, forKey: Keys.motion) }
}
/// Woraus die Panelfläche gemacht ist.
///
/// Wie `motion` von den Farben getrennt und **nicht** von `reset()`
/// erfasst: „Farben zurücksetzen" soll Farben zurücksetzen.
public var material: NotchMaterial = .stone {
didSet { defaults.set(material.rawValue, forKey: Keys.material) }
}
private let defaults: UserDefaults
private enum Keys {
static let accent = "onyx.theme.accent"
static let download = "onyx.theme.download"
static let upload = "onyx.theme.upload"
static let motion = "onyx.theme.motion"
static let material = "onyx.theme.material"
}
public init(defaults: UserDefaults = .standard) {
@@ -47,10 +76,17 @@ public final class OnyxTheme {
accent = Color(onyxHex: defaults.string(forKey: Keys.accent)) ?? Self.defaultAccent
download = Color(onyxHex: defaults.string(forKey: Keys.download)) ?? Self.defaultDownload
upload = Color(onyxHex: defaults.string(forKey: Keys.upload)) ?? Self.defaultUpload
// Unbekannte Profilnamen fallen auf die Voreinstellung zurück, statt die
// Bewegung ganz ausfallen zu lassen — eine App ohne Animation sieht aus,
// als hinge sie.
motion = MotionProfile(rawValue: defaults.string(forKey: Keys.motion) ?? "") ?? .snappy
material = NotchMaterial(rawValue: defaults.string(forKey: Keys.material) ?? "") ?? .stone
}
/// Ob alles auf den Werkseinstellungen steht — für den Zurücksetzen-Knopf,
/// der sonst auch dann anböte, etwas zu tun, wenn es nichts zu tun gibt.
/// Ob **die Farben** auf den Werkseinstellungen stehen — für den
/// Zurücksetzen-Knopf, der sonst auch dann anböte, etwas zu tun, wenn es
/// nichts zu tun gibt. Das Bewegungsprofil zählt bewusst nicht mit; siehe
/// `motion`.
public var isDefault: Bool {
accent.onyxHex == Self.defaultAccent.onyxHex
&& download.onyxHex == Self.defaultDownload.onyxHex
@@ -1,6 +1,6 @@
import SwiftUI
/// Die einzige Quelle für Farben, Maße und Bewegung in Onyx.
/// Die einzige Quelle für Farben, Maße, Schrift und Bewegung in Onyx.
///
/// Onyx ist ein tiefschwarzer Stein mit feiner Bänderung und mattem Glanz — kein
/// Glas. Das Panel benutzt deshalb bewusst **nicht** die Liquid-Glass-APIs von
@@ -10,8 +10,13 @@ public enum Onyx {
// MARK: - Farben
public enum Color {
/// Grundfläche des Panels.
public static let surface = SwiftUI.Color(red: 0.043, green: 0.043, blue: 0.051)
/// Grundfläche des Panels — **wirklich schwarz**.
///
/// Nicht sehr dunkles Grau: die Fläche grenzt oben unmittelbar an die
/// Notch, und die ist schwarzes Gehäuse. Jeder Grauwert daneben ist
/// eine sichtbare Kante genau dort, wo beides als ein Körper wirken
/// soll — vorher #0B0B0D bei 74 % Deckkraft, also grau auf grau.
public static let surface = SwiftUI.Color.black
/// Kacheln auf der Grundfläche.
public static let elevated = SwiftUI.Color(red: 0.078, green: 0.078, blue: 0.090)
/// Innenkante — der halbe „edle" Effekt steckt in dieser einen Linie.
@@ -41,16 +46,107 @@ public enum Onyx {
public static let warning = SwiftUI.Color(red: 0.910, green: 0.706, blue: 0.290)
public static let critical = SwiftUI.Color(red: 0.898, green: 0.404, blue: 0.361)
/// Deckkraft des Tints über der Vibrancy-Ebene. Darunter liest man den
/// Hintergrund durch, darüber wirkt das Panel wie eine schwarze Platte.
public static let surfaceOpacity: Double = 0.74
/// Eine Karte **innerhalb** einer Kachel — etwas heller als die Kachel,
/// aber nicht so hell, dass sie selbst wie eine Kachel wirkt.
public static let card = SwiftUI.Color.white.opacity(0.035)
/// Die Schiene hinter einem Balken oder Regler.
///
/// **Nicht `hairline`.** Das sind 8 % Weiß und ein Wert für Kanten; auf
/// schwarzem Grund ist er praktisch nicht da. Als Schiene liest sich
/// das, als liefe der gefüllte Teil ins Nichts aus — man sieht dann nur
/// noch eine Länge, ohne zu wissen, wovon.
public static let track = SwiftUI.Color.white.opacity(0.22)
/// Text und Symbole **auf** der Akzentfarbe.
///
/// Bisher stand dafür an drei Stellen `Onyx.Color.surface` — was
/// zufällig richtig aussah und beim ersten hellen Akzent falsch würde.
public static let onAccent = SwiftUI.Color(red: 0.043, green: 0.043, blue: 0.051)
}
// MARK: - Abstände
/// Die Staffel für Abstände innerhalb einer Kachel oder eines Fensters.
///
/// **Zwei Punkte Schrittweite, nicht vier.** Nachgemessen standen im Baum
/// sechzehn verschiedene Abstände; die häufigsten waren 6, 4, 8 und 12. Ein
/// Vierer-Raster hätte davon drei verschoben — bei 180 × 200 Punkt großen
/// Kacheln ist das keine Feinheit, sondern eine sichtbare Umordnung ohne
/// Gegenwert. Die Staffel bildet also ab, was der dichten Fläche entspricht,
/// und schneidet stattdessen die Ausreißer weg: aus 3 und 5 wird 4, aus 7
/// und 9 wird 8, aus 10 wird 12, aus 14 wird 16.
public enum Space {
/// Zwischen Zeichen und dem, was direkt daneben klebt.
public static let xxs: CGFloat = 2
public static let xs: CGFloat = 4
public static let sm: CGFloat = 6
public static let md: CGFloat = 8
/// Das Innenpolster einer Zeile — zwischen `md` und `lg`.
///
/// Keine willkürliche Zwischenstufe: eine Zeile mit 8 Punkten wirkt
/// gedrängt, mit 12 fällt sie auseinander. Der Wert kam an zehn Stellen
/// unabhängig voneinander heraus, bevor er hier stand.
public static let row: CGFloat = 10
public static let lg: CGFloat = 12
public static let xl: CGFloat = 16
/// Der Rand eines Fensterinhalts — zwischen `xl` und `xxl`.
///
/// Fenster brauchen mehr Luft als Kacheln: eine Kachel sitzt in einem
/// Panel und teilt sich dessen Rand, ein Fenster hat nur den eigenen.
public static let window: CGFloat = 20
public static let xxl: CGFloat = 24
}
// MARK: - Rundungen
/// Rundungen sind nach dem benannt, was sie umschließen, nicht nach ihrer
/// Größe.
///
/// Eine Größenstaffel („sm", „md") lädt dazu ein, die nächstbeste zu nehmen.
/// Ein Name sagt dagegen, wo der Wert hingehört — und macht sofort sichtbar,
/// wenn eine Karte plötzlich so rund ist wie eine Kachel.
public enum Radius {
/// Vorschaubilder, Farbmarken, kleine Plättchen.
/// Ein Balken — Fortschritt, Pegel, Verlauf.
///
/// So klein, dass es nur darum geht, die Ecke nicht scharf stehen zu
/// lassen. Vorher standen hier 1, 2 und 3 nebeneinander, was an einem
/// 4 Punkte hohen Balken niemand unterscheiden kann.
public static let bar: CGFloat = 2
/// Ein Vorschaubild.
public static let thumbnail: CGFloat = 6
public static let chip: CGFloat = 4
/// Knöpfe, Felder, Regler.
public static let control: CGFloat = 8
/// Eine Karte **innerhalb** einer Kachel.
public static let card: CGFloat = 12
/// Die Kachel selbst.
public static let tile: CGFloat = 16
/// Das ausgefahrene Panel.
public static let panel: CGFloat = 26
/// Die Unterkante der zusammengezogenen Notch.
///
/// Kleiner als `panel`: ein fester Radius lässt die kleine Form
/// aufgeblasen aussehen.
public static let notch: CGFloat = 10
}
// MARK: - Maße
/// Was das Panel als Ganzes ausmisst — keine Rhythmusfrage, sondern
/// Geometrie.
///
/// `panelPadding` und `tileSpacing` gehen in die Größenrechnung von
/// `LayoutEngine` ein und stehen so in dessen Tests. Sie gehören deshalb
/// nicht in `Space`: wer dort die Staffel verschiebt, soll nicht aus
/// Versehen die Panelbreite ändern.
public enum Metric {
public static let panelCornerRadius: CGFloat = 26
public static let tileCornerRadius: CGFloat = 16
public static let panelPadding: CGFloat = 16
public static let tileSpacing: CGFloat = 10
public static let hairlineWidth: CGFloat = 1
@@ -58,31 +154,89 @@ public enum Onyx {
// MARK: - Bewegung
public enum Motion {
/// Ausfahren: ein Spring mit einem Hauch Nachschwingen. Das gibt dem
/// Panel Masse, statt es nur größer werden zu lassen.
public static let expand = Animation.spring(response: 0.34, dampingFraction: 0.86)
/// Einfahren: kürzer und ohne Nachschwingen.
/// Die Kurven — eingestellt über das Bewegungsprofil in `OnyxTheme`.
///
/// Schließen muss schneller sein als Öffnen. Beim Öffnen sieht man zu,
/// beim Schließen ist man schon woanders — dieselbe Kurve rückwärts
/// fühlt sich dort zäh an, und das Nachschwingen liest sich als Ruckler.
public static let collapse = Animation.spring(response: 0.24, dampingFraction: 1.0)
/// Gelesen statt gespeichert, aus demselben Grund wie bei der Akzentfarbe:
/// so kommt ein Profilwechsel an allen Fundstellen an, ohne dass eine
/// einzige davon davon weiß.
public enum Motion {
/// Ausfahren.
@MainActor public static var expand: Animation { OnyxTheme.shared.motion.expand }
/// Einfahren.
@MainActor public static var collapse: Animation { OnyxTheme.shared.motion.collapse }
/// Für Messwerte: sichtbar, aber ohne Pulsieren.
public static let value = Animation.easeOut(duration: 0.25)
@MainActor public static var value: Animation { OnyxTheme.shared.motion.value }
}
// MARK: - Typografie
/// Die Schriftstaffel.
///
/// **Warum sie größer geworden ist.** Vorher gab es fünf Stufen, und zwei
/// davon benutzte niemand. Daneben standen rund hundert Aufrufe von
/// `.font(.system(size:))` — allein Größe 9 dreizehnmal. Das war kein
/// Ungehorsam: die Staffel hatte am unteren Ende schlicht nichts anzubieten,
/// und eine Kachel von 180 × 200 Punkt lebt dort unten.
/// Wie groß ein Symbol gezeichnet wird.
///
/// **Eine eigene Staffel, nicht die Schriftstaffel.** Symbole und Text
/// teilen sich zwar `.font(.system(size:))`, aber nicht die Frage: bei Text
/// entscheidet die Größe über Lesbarkeit und Rang, bei einem Symbol über
/// sein Gewicht im Bild. Ein 9-Punkt-Symbol neben 9-Punkt-Text wirkt
/// kleiner als der Text, nicht gleich groß. Sie in eine Staffel zu zwingen
/// hieße, an jeder Stelle gegen sie zu arbeiten.
///
/// Vorher standen hier zwölf verschiedene Werte von 7 bis 34 — 7, 8, 10, 12,
/// 15, 16, 18 waren keine Entscheidungen, sondern das, was gerade passte.
///
/// Nur die Größe, nicht das Gewicht: `.light` an einem großen Symbol und
/// `.bold` an einem winzigen sind Entscheidungen am Ort, keine Stufen.
public enum Symbol {
/// Marke neben kleinem Text — das Blatt am Energiesparmodus.
public static let caption: CGFloat = 9
/// Das Symbol einer Zeile.
public static let small: CGFloat = 11
/// Das Symbol eines Knopfes.
public static let medium: CGFloat = 13
/// Das Symbol eines leeren Zustands.
public static let large: CGFloat = 16
/// Das Symbol, das eine Kachel trägt.
public static let xlarge: CGFloat = 20
/// Das eine große Symbol — Wetterlage.
public static let hero: CGFloat = 34
}
public enum Font {
public static let title = SwiftUI.Font.system(size: 13, weight: .semibold)
public static let body = SwiftUI.Font.system(size: 12, weight: .regular)
// Text
/// Der Arbeitswert in der Kachel: Beschriftungen an Werten, Wochentage,
/// Einheiten.
public static let micro = SwiftUI.Font.system(size: 9, weight: .medium)
public static let caption = SwiftUI.Font.system(size: 10, weight: .medium)
/// Für alle Messwerte — ohne feste Ziffernbreite zappeln die Zahlen.
public static let metric = SwiftUI.Font.system(size: 20, weight: .medium)
public static let footnote = SwiftUI.Font.system(size: 11, weight: .regular)
public static let body = SwiftUI.Font.system(size: 12, weight: .regular)
/// Kachelüberschriften und Abschnittstitel.
public static let title = SwiftUI.Font.system(size: 13, weight: .semibold)
public static let headline = SwiftUI.Font.system(size: 15, weight: .medium)
public static let display = SwiftUI.Font.system(size: 20, weight: .medium)
public static let displayLarge = SwiftUI.Font.system(size: 30, weight: .medium)
// Zahlen — ohne feste Ziffernbreite zappeln sie beim Zählen
public static let metricMicro = SwiftUI.Font.system(size: 9, weight: .medium)
.monospacedDigit()
public static let metricSmall = SwiftUI.Font.system(size: 11, weight: .medium)
.monospacedDigit()
public static let metric = SwiftUI.Font.system(size: 20, weight: .medium)
.monospacedDigit()
public static let metricLarge = SwiftUI.Font.system(size: 30, weight: .medium)
.monospacedDigit()
/// Das eine große Symbol einer Kachel — Wetterlage, Ladezustand.
///
/// Die einzige Größe, die kein Textgegenstück hat: hier steht ein Bild,
/// keine Zeile.
public static let symbolHero = SwiftUI.Font.system(size: 34, weight: .regular)
}
}
@@ -0,0 +1,109 @@
import Testing
import Foundation
import SwiftUI
@testable import OnyxDesign
@Suite("Bewegungsprofil")
@MainActor
struct MotionProfileTests {
private func freshDefaults(_ name: String) -> UserDefaults {
let suite = "onyx.motion.test.\(name)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
return defaults
}
@Test("Ohne Einstellung ist es schnell")
func defaultsToSnappy() {
let theme = OnyxTheme(defaults: freshDefaults(#function))
#expect(theme.motion == .snappy)
}
@Test("Das gewählte Profil übersteht den Neustart")
func choiceSurvivesRestart() {
let defaults = freshDefaults(#function)
let theme = OnyxTheme(defaults: defaults)
theme.motion = .calm
let restarted = OnyxTheme(defaults: defaults)
#expect(restarted.motion == .calm)
}
/// Ein Profilname, den diese Fassung nicht kennt, darf die Bewegung nicht
/// ausfallen lassen — eine App ohne Animation sieht aus, als hinge sie.
@Test("Ein unbekannter Name fällt auf die Voreinstellung zurück")
func unknownNameFallsBack() {
let defaults = freshDefaults(#function)
defaults.set("hyperspeed", forKey: "onyx.theme.motion")
#expect(OnyxTheme(defaults: defaults).motion == .snappy)
}
/// Der Zurücksetzen-Knopf steht bei den Farben. Nähme er das
/// Bewegungsprofil mit, stellte er etwas um, wovon in seiner Beschriftung
/// nichts steht.
@Test("Farben zurücksetzen lässt das Bewegungsprofil in Ruhe")
func resetLeavesMotionAlone() {
let theme = OnyxTheme(defaults: freshDefaults(#function))
theme.motion = .bouncy
theme.reset()
#expect(theme.motion == .bouncy)
#expect(theme.isDefault)
}
/// Beim Öffnen sieht man zu, beim Schließen ist man schon woanders.
@Test("Einfahren ist in jedem Profil kürzer als Ausfahren")
func collapseIsShorterThanExpand() {
// Die Federantwort steckt in der Animation und ist von außen nicht
// lesbar. Prüfbar ist die Dauer, die der Fenstercontroller benutzt —
// und die ist genau der Wert, an dem es sichtbar schiefgeht.
for profile in MotionProfile.allCases {
#expect(profile.collapseDuration > 0)
#expect(profile.collapseDuration < 1)
}
#expect(MotionProfile.snappy.collapseDuration < MotionProfile.calm.collapseDuration)
}
/// Verschwindet das Fenster vor dem Ende der Bewegung, reißt das Panel
/// sichtbar weg. Genau dafür hängt die Dauer am Profil und ist keine feste
/// Zahl mehr.
@Test("Jedes Profil bringt seine eigene Einfahrdauer mit")
func everyProfileCarriesItsOwnDuration() {
let durations = Set(MotionProfile.allCases.map(\.collapseDuration))
#expect(durations.count == MotionProfile.allCases.count)
}
}
@Suite("Pegel im Regler")
struct MeterFractionTests {
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos: halb
/// so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
/// Bereich, und genau das ist hier zu prüfen — sonst ist der Regler ein
/// Balken, der die meiste Zeit leer aussieht.
@Test("Leiser Ton schlägt sichtbar aus")
func quietSignalIsVisible() {
// Ein Viertel der Amplitude sähe linear nach 25 % aus. Die Wurzel macht
// daraus die Hälfte.
#expect(abs(OnyxSlider.meterFraction(0.25) - 0.5) < 0.0001)
}
@Test("Die Enden bleiben, wo sie sind")
func endsAreUntouched() {
#expect(OnyxSlider.meterFraction(0) == 0)
#expect(OnyxSlider.meterFraction(1) == 1)
}
/// Der Ausschlag kommt aus einem Tap und ist nicht garantiert im Bereich.
/// Ein Wert über 1 machte den Pegelbalken breiter als den Regler, ein
/// negativer ließe `sqrt` NaN liefern — und NaN in einer Breitenangabe
/// beendet SwiftUI.
@Test("Werte außerhalb des Bereichs werden gekappt")
func outOfRangeIsClamped() {
#expect(OnyxSlider.meterFraction(4) == 1)
#expect(OnyxSlider.meterFraction(-1) == 0)
#expect(!OnyxSlider.meterFraction(-1).isNaN)
}
}
@@ -0,0 +1,59 @@
import Testing
import SwiftUI
@testable import OnyxDesign
@Suite("Werkstoffe")
@MainActor
struct NotchMaterialTests {
@Test("Stein ist deckend — sonst hätte er an der Hardware-Notch eine Kante")
func stoneIsOpaque() {
#expect(NotchMaterial.stone.fillOpacity == 1.0)
// Und braucht deshalb keine Weichzeichnung darunter.
#expect(!NotchMaterial.stone.showsVibrancy)
}
@Test("Je durchlässiger, desto weniger Deckkraft")
func opacityDecreasesWithTranslucency() {
#expect(NotchMaterial.stone.fillOpacity > NotchMaterial.blur.fillOpacity)
#expect(NotchMaterial.blur.fillOpacity > NotchMaterial.glass.fillOpacity)
}
@Test("Je durchlässiger, desto mehr Glanz")
func sheenGrowsWithTranslucency() {
// Auf deckendem Schwarz genügt ein Hauch; auf Glas ist der Glanz das,
// was die Fläche überhaupt als Körper lesbar macht.
#expect(NotchMaterial.stone.sheen < NotchMaterial.blur.sheen)
#expect(NotchMaterial.blur.sheen < NotchMaterial.glass.sheen)
}
@Test("Alle durchscheinenden Werkstoffe brauchen die Vibrancy-Ebene")
func translucentMaterialsNeedVibrancy() {
#expect(NotchMaterial.blur.showsVibrancy)
#expect(NotchMaterial.glass.showsVibrancy)
}
@Test("Kein Werkstoff ist unsichtbar")
func nothingIsFullyTransparent() {
// Eine Fläche mit Deckkraft null wäre kein Werkstoff, sondern ein
// fehlendes Panel — und niemand fände die Einstellung zurück.
for material in NotchMaterial.allCases {
#expect(material.fillOpacity > 0.3)
}
}
@Test("Jeder Werkstoff überlebt das Speichern")
func roundTripsThroughDefaults() {
for material in NotchMaterial.allCases {
#expect(NotchMaterial(rawValue: material.rawValue) == material)
}
}
@Test("Ein unbekannter Name fällt auf Stein zurück, nicht ins Nichts")
func unknownNameFallsBack() {
let defaults = UserDefaults(suiteName: "onyx.test.material")!
defaults.removePersistentDomain(forName: "onyx.test.material")
defaults.set("liquidUnobtainium", forKey: "onyx.theme.material")
#expect(OnyxTheme(defaults: defaults).material == .stone)
}
}
@@ -74,3 +74,30 @@ struct ColourHexTests {
#expect(Color(onyxHex: "#GGGGGG") == nil)
}
}
@Suite("Farben, die sich nicht gleichen dürfen")
@MainActor
struct ColourCollisionTests {
/// Beide waren zeichengleich `#6FCF97`. Damit hieß derselbe grüne Balken im
/// Netzwerk „lädt herunter" und in den Messwerten „alles in Ordnung" — und
/// wer beides nebeneinander sieht, liest eine Aussage, die niemand gemeint
/// hat.
@Test("Herunterladen ist nicht die Signalfarbe für „in Ordnung“")
func downloadIsNotThePositiveSignal() {
#expect(OnyxTheme.defaultDownload.onyxHex != Onyx.Color.positive.onyxHex)
}
@Test("Herunter und Hoch sind auch untereinander unterscheidbar")
func directionsDifferFromEachOther() {
#expect(OnyxTheme.defaultDownload.onyxHex != OnyxTheme.defaultUpload.onyxHex)
}
/// Die drei Signalfarben tragen die Bedeutung. Fielen zwei davon zusammen,
/// wäre eine Warnung von einem Fehler nicht mehr zu unterscheiden.
@Test("Die Signalfarben sind paarweise verschieden")
func signalColoursAreDistinct() {
let signals = [Onyx.Color.positive, Onyx.Color.warning, Onyx.Color.critical]
#expect(Set(signals.map(\.onyxHex)).count == signals.count)
}
}