diff --git a/Onyx/ColorSettingsView.swift b/Onyx/ColorSettingsView.swift deleted file mode 100644 index 59e1be2..0000000 --- a/Onyx/ColorSettingsView.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Packages/OnyxKit/Sources/OnyxDesign/NotchMaterial.swift b/Packages/OnyxKit/Sources/OnyxDesign/NotchMaterial.swift new file mode 100644 index 0000000..ca82c29 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxDesign/NotchMaterial.swift @@ -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 } +} diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxComponents.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxComponents.swift new file mode 100644 index 0000000..0934834 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxComponents.swift @@ -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: 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: 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)) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxForm.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxForm.swift new file mode 100644 index 0000000..371ce5d --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxForm.swift @@ -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: 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: 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 +} diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxMotionProfile.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxMotionProfile.swift new file mode 100644 index 0000000..7fe2d57 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxMotionProfile.swift @@ -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 + } + } +} diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift index d8dc620..a35abcb 100644 --- a/Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift @@ -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: 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: 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)) diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift index acb2333..5315b73 100644 --- a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift @@ -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 diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift index 202ded8..2d60881 100644 --- a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift @@ -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 + /// Die Kurven — eingestellt über das Bewegungsprofil in `OnyxTheme`. + /// + /// 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: 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. - /// - /// 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) + /// 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) } } diff --git a/Packages/OnyxKit/Tests/OnyxDesignTests/MotionProfileTests.swift b/Packages/OnyxKit/Tests/OnyxDesignTests/MotionProfileTests.swift new file mode 100644 index 0000000..2f7b189 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxDesignTests/MotionProfileTests.swift @@ -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) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxDesignTests/NotchMaterialTests.swift b/Packages/OnyxKit/Tests/OnyxDesignTests/NotchMaterialTests.swift new file mode 100644 index 0000000..c71f064 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxDesignTests/NotchMaterialTests.swift @@ -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) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxDesignTests/OnyxThemeTests.swift b/Packages/OnyxKit/Tests/OnyxDesignTests/OnyxThemeTests.swift index a2780b3..522c1a9 100644 --- a/Packages/OnyxKit/Tests/OnyxDesignTests/OnyxThemeTests.swift +++ b/Packages/OnyxKit/Tests/OnyxDesignTests/OnyxThemeTests.swift @@ -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) + } +}