Kacheln und Fenster sprechen dieselbe Sprache

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 21:05:51 +02:00
co-authored by Claude Opus 5
parent 9b600de9a9
commit edb327cabe
32 changed files with 3677 additions and 968 deletions
@@ -31,10 +31,12 @@ public struct MixerView: View {
// Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
// nichts einschalten müssen.
SectionTitle("mixer.section.system", compact: compact)
OnyxSectionTitle(String(localized: "mixer.section.system", bundle: .module))
.padding(.top, compact ? 0 : Onyx.Space.xxs)
SystemSection(system: system, compact: compact)
SectionTitle("mixer.section.apps", compact: compact)
OnyxSectionTitle(String(localized: "mixer.section.apps", bundle: .module))
.padding(.top, compact ? 0 : Onyx.Space.xxs)
if mixer.isEnabled {
if mixer.groups.isEmpty {
@@ -42,24 +44,20 @@ public struct MixerView: View {
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
.padding(.vertical, Onyx.Space.md)
} else {
// Eine Karte um die Zeilen: sie fasst zusammen, was
// zusammengehört, und trennt es vom Ausschalter darunter.
VStack(spacing: 0) {
OnyxCard {
VStack(spacing: 0) {
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
id: \.element.id) { index, group in
if index > 0 { Divider().overlay(Onyx.Color.hairline) }
if index > 0 { OnyxHairline() }
ProcessRow(mixer: mixer, group: group, compact: compact)
.padding(.horizontal, 10)
.padding(.vertical, compact ? 6 : 8)
.padding(.horizontal, Onyx.Space.row)
.padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md)
}
}
.background(Onyx.Color.elevated.opacity(0.55),
in: .rect(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
}
}
}
@@ -78,7 +76,7 @@ public struct MixerView: View {
.foregroundStyle(Onyx.Color.textSecondary)
}
.buttonStyle(.plain)
.padding(.top, 2)
.padding(.top, Onyx.Space.xxs)
} else {
OffState(mixer: mixer, compact: compact)
autostartToggle
@@ -128,10 +126,10 @@ private struct OffState: View {
let compact: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: Onyx.Space.row) {
HStack(spacing: Onyx.Space.md) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 15, weight: .light))
.font(.system(size: Onyx.Symbol.large, weight: .light))
.foregroundStyle(Onyx.Color.textTertiary)
Text("mixer.off.explain", bundle: .module)
.font(Onyx.Font.caption)
@@ -146,15 +144,7 @@ private struct OffState: View {
.fixedSize(horizontal: false, vertical: true)
}
Button { mixer.enable() } label: {
Text("mixer.on", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.surface)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Onyx.Color.accent, in: .capsule)
}
.buttonStyle(.plain)
OnyxPillButton(String(localized: "mixer.on", bundle: .module)) { mixer.enable() }
}
}
}
@@ -174,7 +164,7 @@ private struct ProcessRow: View {
// ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
// hält den Zeiger auf das Symbol.
HStack(spacing: 10) {
HStack(spacing: Onyx.Space.row) {
icon
Button {
@@ -182,7 +172,7 @@ private struct ProcessRow: View {
mixer.setMuted(!muted, for: group.bundleID)
} label: {
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
.font(.system(size: 11))
.font(.system(size: Onyx.Symbol.small))
.frame(width: 15, alignment: .leading)
}
.buttonStyle(.plain)
@@ -190,8 +180,8 @@ private struct ProcessRow: View {
: (managed ? Onyx.Color.textSecondary
: Onyx.Color.textTertiary))
VolumeBar(
volume: volume,
OnyxSlider(
value: volume,
maximum: Gain.maximum(overdrive: overdriven),
level: mixer.level(for: group.bundleID),
isActive: managed && !muted,
@@ -238,7 +228,7 @@ private struct ProcessRow: View {
mixer.setOverdrive(!overdriven, for: group.bundleID)
} label: {
Image(systemName: "bolt.fill")
.font(.system(size: 9, weight: .medium))
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
@@ -265,7 +255,7 @@ private struct ProcessRow: View {
mixer.setVolume(1, for: group.bundleID)
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.system(size: 9, weight: .medium))
.font(.system(size: Onyx.Symbol.caption, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
@@ -304,7 +294,7 @@ private struct ProcessRow: View {
.opacity(managed ? 1 : 0.65)
} else {
Image(systemName: "app.dashed")
.font(.system(size: 15))
.font(.system(size: Onyx.Symbol.large))
.foregroundStyle(Onyx.Color.textTertiary)
}
}
@@ -326,95 +316,6 @@ private struct ProcessRow: View {
return "\(group.name) (\(group.processes.count))"
}
}
/// Der Lautstärkeregler.
///
/// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes
/// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt.
/// Und er kann nicht zeigen, was hier den Unterschied macht — den Pegel und
/// die Grenze bei 100 %.
private struct VolumeBar: View {
let volume: Double
/// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive.
let maximum: Double
let level: Double
let isActive: Bool
let onChange: (Double) -> Void
@State private var isDragging = false
private let trackHeight: CGFloat = 6
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let fraction = min(max(volume / maximum, 0), 1)
let knob: CGFloat = isDragging ? 13 : 11
// Der Knopf soll an den Enden nicht überstehen.
let travel = max(width - knob, 1)
let fillWidth = travel * fraction + knob / 2
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.surface)
.frame(height: 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: trackHeight + 4)
.offset(x: knob / 2 + travel / maximum)
}
Capsule()
.fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent)
.frame(width: fillWidth, height: trackHeight)
.opacity(isActive ? 1 : 0.3)
// Der Pegel läuft **im** Regler mit, nicht daneben: eine
// zweite Leiste pro Zeile wäre doppelt so viel Grafik für
// dieselbe Auskunft.
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * meterFraction, height: 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: 16)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isDragging = true
let position = min(max((value.location.x - knob / 2) / travel, 0), 1)
onChange(position * maximum)
}
.onEnded { _ in isDragging = false })
}
.frame(height: 16)
.animation(Onyx.Motion.value, value: isDragging)
}
/// 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.
private var meterFraction: Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}
// MARK: - Widget
public struct AudioMixerWidget: OnyxWidget {
@@ -442,24 +343,6 @@ public struct AudioMixerWidget: OnyxWidget {
// MARK: - Systemzeilen
private struct SectionTitle: View {
let key: LocalizedStringKey
let compact: Bool
init(_ key: LocalizedStringKey, compact: Bool) {
self.key = key
self.compact = compact
}
var body: some View {
Text(key, bundle: .module)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
.textCase(.uppercase)
.padding(.top, compact ? 0 : 2)
}
}
/// Ausgabe, Eingabe und Gerätewahl.
///
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
@@ -471,7 +354,8 @@ private struct SystemSection: View {
let compact: Bool
var body: some View {
VStack(spacing: 0) {
OnyxCard {
VStack(spacing: 0) {
SystemRow(
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: outputName,
@@ -486,7 +370,7 @@ private struct SystemSection: View {
onMute: { system.setOutputMuted(!system.outputMuted) },
onSelect: { system.selectOutput($0) })
Divider().overlay(Onyx.Color.hairline)
OnyxHairline()
SystemRow(
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
@@ -504,12 +388,7 @@ private struct SystemSection: View {
onVolume: { system.setInputVolume($0) },
onMute: { system.setInputMuted(!system.inputMuted) },
onSelect: { system.selectInput($0) })
}
.background(Onyx.Color.elevated.opacity(0.55),
in: .rect(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
}
}
}
@@ -555,14 +434,14 @@ private struct SystemRow: View {
}
}
} label: {
HStack(spacing: 4) {
HStack(spacing: Onyx.Space.xs) {
Text(title)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 7))
.font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
}
@@ -571,18 +450,18 @@ private struct SystemRow: View {
.menuIndicator(.hidden)
.fixedSize()
HStack(spacing: 9) {
HStack(spacing: Onyx.Space.row) {
Button(action: onMute) {
Image(systemName: symbol)
.font(.system(size: 11))
.font(.system(size: Onyx.Symbol.small))
.frame(width: 15, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
if adjustable {
SystemVolumeBar(volume: volume, level: level,
isActive: !muted, onChange: onVolume)
OnyxSlider(value: volume, level: level,
isActive: !muted, onChange: onVolume)
if let metering { metering }
Text("\(Int(volume * 100)) %")
.font(Onyx.Font.metricSmall).monospacedDigit()
@@ -599,7 +478,7 @@ private struct SystemRow: View {
}
}
}
.padding(.horizontal, 10)
.padding(.horizontal, Onyx.Space.row)
.padding(.vertical, compact ? 7 : 9)
}
}
@@ -617,7 +496,7 @@ struct MeteringButton: View {
var body: some View {
Button(action: toggle) {
Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle")
.font(.system(size: 12))
.font(.system(size: Onyx.Symbol.medium))
.frame(width: 14, height: 14)
}
.buttonStyle(.plain)
@@ -625,68 +504,3 @@ struct MeteringButton: View {
.help(Text(isOn ? "mixer.input.meterOff" : "mixer.input.meterOn", bundle: .module))
}
}
/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die
/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her.
private struct SystemVolumeBar: View {
let volume: Double
let level: Double
let isActive: Bool
let onChange: (Double) -> Void
@State private var isDragging = false
var body: some View {
GeometryReader { geometry in
let knob: CGFloat = isDragging ? 13 : 11
let travel = max(geometry.size.width - knob, 1)
let fraction = min(max(volume, 0), 1)
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.surface).frame(height: 6)
let fillWidth = travel * fraction + knob / 2
Capsule()
.fill(Onyx.Color.accent)
.frame(width: fillWidth, height: 6)
.opacity(isActive ? 1 : 0.3)
// Derselbe mitlaufende Pegel wie in den Programmzeilen. Er
// sitzt **im** Regler, weil eine zweite Leiste daneben doppelt
// so viel Grafik für dieselbe Auskunft wäre.
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * meterFraction, height: 6)
.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: 16)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isDragging = true
onChange(min(max((value.location.x - knob / 2) / travel, 0), 1))
}
.onEnded { _ in isDragging = false })
}
.frame(height: 16)
.animation(Onyx.Motion.value, value: isDragging)
}
/// 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.
private var meterFraction: Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}