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>
569 lines
22 KiB
Swift
569 lines
22 KiB
Swift
import SwiftUI
|
||
import OnyxDesign
|
||
import OnyxWidgetKit
|
||
|
||
/// Das Schaufenster: jede Kachel, das ganze Panel und alle Token — ohne die
|
||
/// Notch anzufahren.
|
||
///
|
||
/// **Warum ein Fenster und keine `#Preview`.** Der Baum setzt
|
||
/// `ENABLE_DEBUG_DYLIB = NO`, weil Xcode den Programmcode sonst in eine eigene
|
||
/// dylib auslagert und TCC die Berechtigungsanfragen dem verbleibenden Rumpf
|
||
/// zuordnet, der weder Info.plist noch Entitlements trägt. Genau diese
|
||
/// Auslagerung brauchen SwiftUIs Vorschauen aber. Die Vorschau ist hier also
|
||
/// nicht vergessen worden, sie ist der Preis einer Entscheidung, die vorher
|
||
/// fiel. Ein Fenster kostet ihn nicht — und zeigt zudem die echten Modelle
|
||
/// statt erfundener.
|
||
///
|
||
/// **Nicht lokalisiert, und das mit Absicht.** Das Schaufenster ist Werkzeug,
|
||
/// keine Programmoberfläche. Seine Beschriftungen im Sprachkatalog hätten
|
||
/// dieselbe Sichtbarkeit wie die der Einstellungen und wären doch nie zu
|
||
/// übersetzen.
|
||
struct GalleryView: View {
|
||
|
||
let model: AppModel
|
||
|
||
/// Bleibt stehen, wo man zuletzt war.
|
||
///
|
||
/// Nicht nur bequem: `UserDefaults` liest Startargumente mit, also führt
|
||
/// `open Onyx.app --args -onyx.gallery.section tokens` direkt in den
|
||
/// Bereich, an dem man gerade arbeitet. Bauen, starten, hinsehen — ohne
|
||
/// Zwischenklick.
|
||
@AppStorage("onyx.gallery.section") private var section: GallerySection = .tiles
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
Picker("", selection: $section) {
|
||
ForEach(GallerySection.allCases) { Text($0.title).tag($0) }
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
.padding(.horizontal, Onyx.Space.window)
|
||
.padding(.top, Onyx.Space.row)
|
||
.padding(.bottom, Onyx.Space.xl)
|
||
|
||
Divider()
|
||
|
||
ScrollView {
|
||
switch section {
|
||
case .tiles: TileGallery()
|
||
case .panel: PanelGallery(model: model)
|
||
case .components: ComponentGallery()
|
||
case .tokens: TokenGallery()
|
||
}
|
||
}
|
||
.background(Color(nsColor: .windowBackgroundColor))
|
||
}
|
||
.frame(minWidth: 760, minHeight: 520)
|
||
// Im Rumpf gelesen, damit ein Farbwechsel ankommt — wie im Panel.
|
||
.tint(Onyx.Color.accent)
|
||
.labelStyle(.onyx)
|
||
}
|
||
}
|
||
|
||
enum GallerySection: String, CaseIterable, Identifiable {
|
||
case tiles, panel, components, tokens
|
||
|
||
var id: Self { self }
|
||
|
||
var title: String {
|
||
switch self {
|
||
case .tiles: "Kacheln"
|
||
case .panel: "Panel"
|
||
case .components: "Bauteile"
|
||
case .tokens: "Token"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Kacheln
|
||
|
||
/// Jede angemeldete Kachel in echter Größe auf echter Paneloberfläche.
|
||
///
|
||
/// Echte Größe und echte Fläche sind der ganze Zweck: eine Kachel, die man in
|
||
/// einem beliebig breiten Fenster beurteilt, sieht dort immer gut aus. 180 × 200
|
||
/// auf schwarzem Stein ist der Ort, an dem sie später steht.
|
||
private struct TileGallery: View {
|
||
|
||
private struct Entry: Identifiable {
|
||
let id: String
|
||
let widget: any OnyxWidget
|
||
}
|
||
|
||
var body: some View {
|
||
let entries = WidgetRegistry.shared.all.map { Entry(id: $0.id, widget: $0) }
|
||
|
||
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||
if entries.isEmpty {
|
||
Text("Keine Kacheln angemeldet.")
|
||
.font(.callout)
|
||
.foregroundStyle(.secondary)
|
||
} else {
|
||
LazyVGrid(
|
||
columns: [GridItem(.adaptive(minimum: LayoutEngine.tileSize.width,
|
||
maximum: LayoutEngine.tileSize.width),
|
||
spacing: Onyx.Metric.tileSpacing)],
|
||
alignment: .leading,
|
||
spacing: Onyx.Space.xxl
|
||
) {
|
||
ForEach(entries) { entry in
|
||
VStack(spacing: Onyx.Space.sm) {
|
||
// Dieselbe Reihenfolge wie in `WidgetGrid`: Kachel,
|
||
// dann Rahmen, dann beschneiden. Wer erst
|
||
// beschneidet, sieht Überlauf nicht, den das Panel
|
||
// später zeigt.
|
||
OnyxTile { entry.widget.makeView() }
|
||
.frame(width: LayoutEngine.tileSize.width,
|
||
height: LayoutEngine.tileSize.height)
|
||
.clipped()
|
||
|
||
Text(entry.id)
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(Onyx.Metric.panelPadding)
|
||
.background(OnyxSurface())
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(Onyx.Space.window)
|
||
}
|
||
}
|
||
|
||
// MARK: - Panel
|
||
|
||
/// Das ganze Panel, so breit, wie man es einstellt.
|
||
///
|
||
/// Die Breite ist regelbar, weil die Spaltenzahl an ihr hängt: `LayoutEngine`
|
||
/// rechnet aus der verfügbaren Breite, wie viele Kacheln nebeneinander passen,
|
||
/// und ab wann eine zweite Reihe entsteht. Genau diese Sprünge will man sehen,
|
||
/// ohne dafür einen Bildschirm anzustecken.
|
||
private struct PanelGallery: View {
|
||
|
||
let model: AppModel
|
||
|
||
@State private var availableWidth: Double = 1680
|
||
|
||
var body: some View {
|
||
let width = CGFloat(availableWidth)
|
||
let size = WidgetGrid.panelSize(for: model.layout, availableWidth: width)
|
||
|
||
VStack(alignment: .leading, spacing: Onyx.Space.xl) {
|
||
HStack(spacing: Onyx.Space.lg) {
|
||
Text("Verfügbare Breite")
|
||
.font(.callout)
|
||
Slider(value: $availableWidth, in: 400...3000)
|
||
.frame(maxWidth: 320)
|
||
Text("\(Int(availableWidth)) pt")
|
||
.font(.callout.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
Text("Panel \(Int(size.width)) × \(Int(size.height)) pt · "
|
||
+ "\(LayoutEngine.columns(availableWidth: width)) Spalten · "
|
||
+ "\(model.layout.count) Kacheln")
|
||
.font(.caption.monospacedDigit())
|
||
.foregroundStyle(.secondary)
|
||
|
||
WidgetGrid(placements: model.layout, availableWidth: width)
|
||
.background(OnyxSurface())
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(Onyx.Space.window)
|
||
}
|
||
}
|
||
|
||
// MARK: - Bauteile
|
||
|
||
/// Jedes Bauteil in jedem Zustand.
|
||
///
|
||
/// Das ist der Teil, den es bei den Kacheln noch nicht gibt: deren Modelle
|
||
/// führen ihren Zustand als `private(set)`, „leer" oder „Fehler" lässt sich von
|
||
/// außen nicht setzen. Bei den Bauteilen ist der Zustand ein Argument — hier
|
||
/// kann man ihn also einfach hinschreiben.
|
||
private struct ComponentGallery: View {
|
||
|
||
@State private var volume = 0.62
|
||
@State private var overdrive = 1.8
|
||
@State private var targeted = true
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
|
||
group("OnyxCard mit OnyxHairline und OnyxSectionTitle") {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||
OnyxSectionTitle("System")
|
||
OnyxCard {
|
||
VStack(spacing: 0) {
|
||
row("MacBook Pro-Lautsprecher", "41 %")
|
||
OnyxHairline()
|
||
row("MacBook Pro-Mikrofon", "27 %")
|
||
}
|
||
}
|
||
}
|
||
.frame(width: 260)
|
||
}
|
||
|
||
group("OnyxNotice") {
|
||
HStack(alignment: .top, spacing: Onyx.Space.lg) {
|
||
tile { OnyxNotice(symbol: "wifi.slash", text: "Kein Netz erreichbar.") }
|
||
tile {
|
||
OnyxNotice(symbol: "hand.raised",
|
||
text: "Der Helfer wartet auf deine Freigabe.") {
|
||
OnyxPillButton("Einstellungen") {}
|
||
}
|
||
}
|
||
tile {
|
||
OnyxNotice(symbol: "fan",
|
||
text: "Noch keine Lüfterdaten.",
|
||
placement: .leading)
|
||
}
|
||
tile {
|
||
OnyxNotice(symbol: "fan",
|
||
text: "Der Helfer ist nicht installiert.",
|
||
placement: .leading) {
|
||
OnyxPillButton("Installieren") {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
group("OnyxPillButton") {
|
||
HStack(spacing: Onyx.Space.lg) {
|
||
OnyxPillButton("Einschalten") {}
|
||
OnyxPillButton("Automatik", isFilled: false) {}
|
||
OnyxPillButton("Auto", size: .compact) {}
|
||
OnyxPillButton("Auto", isFilled: false, size: .compact) {}
|
||
}
|
||
}
|
||
|
||
group("OnyxSlider") {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||
sliderRow("normal") {
|
||
OnyxSlider(value: volume) { volume = $0 }
|
||
}
|
||
sliderRow("mit Pegel") {
|
||
OnyxSlider(value: volume, level: 0.35) { volume = $0 }
|
||
}
|
||
sliderRow("stumm") {
|
||
OnyxSlider(value: volume, isActive: false) { volume = $0 }
|
||
}
|
||
sliderRow("Overdrive") {
|
||
OnyxSlider(value: overdrive, maximum: 4, level: 0.5) { overdrive = $0 }
|
||
}
|
||
}
|
||
.frame(width: 320)
|
||
}
|
||
|
||
group("OnyxDropZone") {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||
Toggle("Zeiger schwebt darüber", isOn: $targeted)
|
||
.font(Onyx.Font.body)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
HStack(spacing: Onyx.Space.lg) {
|
||
tile {
|
||
OnyxNotice(symbol: "paperplane", text: "Zum Senden ablegen")
|
||
.onyxDropZone(isTargeted: targeted, style: .always)
|
||
}
|
||
tile {
|
||
OnyxNotice(symbol: "tray", text: "Nimmt nebenbei Dateien an")
|
||
.onyxDropZone(isTargeted: targeted, style: .whenTargeted)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(Onyx.Metric.panelPadding)
|
||
.background(OnyxSurface())
|
||
.padding(Onyx.Space.window)
|
||
}
|
||
|
||
private func row(_ name: String, _ value: String) -> some View {
|
||
HStack {
|
||
Text(name)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
Spacer(minLength: Onyx.Space.md)
|
||
Text(value)
|
||
.font(Onyx.Font.metricSmall)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
}
|
||
.padding(.horizontal, Onyx.Space.md)
|
||
.padding(.vertical, Onyx.Space.md)
|
||
}
|
||
|
||
/// Bauteile in einer echten Kachel zeigen, nicht frei auf der Fläche: ein
|
||
/// Leerzustand, der sich mittig ausrichtet, braucht etwas, worin er das tun
|
||
/// kann.
|
||
private func tile(@ViewBuilder content: () -> some View) -> some View {
|
||
OnyxTile { content() }
|
||
.frame(width: LayoutEngine.tileSize.width, height: 130)
|
||
}
|
||
|
||
private func sliderRow(_ name: String,
|
||
@ViewBuilder content: () -> some View) -> some View {
|
||
HStack(spacing: Onyx.Space.lg) {
|
||
Text(name)
|
||
.font(Onyx.Font.micro)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
.frame(width: 72, alignment: .leading)
|
||
content()
|
||
}
|
||
}
|
||
|
||
private func group(_ title: String,
|
||
@ViewBuilder content: () -> some View) -> some View {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||
Text(title)
|
||
.font(Onyx.Font.title)
|
||
.foregroundStyle(Onyx.Color.textPrimary)
|
||
content()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Token
|
||
|
||
/// Alle Token an einem Ort — auf der Fläche, für die sie gemacht sind.
|
||
///
|
||
/// Farben auf hellem Fensterhintergrund zu zeigen wäre irreführend: `hairline`
|
||
/// ist Weiß mit 8 % Deckkraft und auf Weiß schlicht nicht da.
|
||
private struct TokenGallery: View {
|
||
|
||
@State private var moved = false
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.xxl) {
|
||
colors
|
||
spaces
|
||
radii
|
||
fonts
|
||
metrics
|
||
motion
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(Onyx.Metric.panelPadding)
|
||
.background(OnyxSurface())
|
||
.padding(Onyx.Space.window)
|
||
}
|
||
|
||
private var colors: some View {
|
||
let swatches: [(String, Color)] = [
|
||
("surface", Onyx.Color.surface),
|
||
("elevated", Onyx.Color.elevated),
|
||
("hairline", Onyx.Color.hairline),
|
||
("textPrimary", Onyx.Color.textPrimary),
|
||
("textSecondary", Onyx.Color.textSecondary),
|
||
("textTertiary", Onyx.Color.textTertiary),
|
||
("accent", Onyx.Color.accent),
|
||
("download", Onyx.Color.download),
|
||
("upload", Onyx.Color.upload),
|
||
("positive", Onyx.Color.positive),
|
||
("warning", Onyx.Color.warning),
|
||
("critical", Onyx.Color.critical),
|
||
]
|
||
|
||
return section("Farben") {
|
||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120, maximum: 160), spacing: Onyx.Space.row)],
|
||
alignment: .leading, spacing: Onyx.Space.row) {
|
||
ForEach(swatches, id: \.0) { name, color in
|
||
HStack(spacing: Onyx.Space.md) {
|
||
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
|
||
.fill(color)
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
|
||
.strokeBorder(Onyx.Color.hairline,
|
||
lineWidth: Onyx.Metric.hairlineWidth)
|
||
}
|
||
.frame(width: 28, height: 28)
|
||
Text(name)
|
||
.font(Onyx.Font.caption)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var spaces: some View {
|
||
let steps: [(String, CGFloat)] = [
|
||
("xxs", Onyx.Space.xxs),
|
||
("xs", Onyx.Space.xs),
|
||
("sm", Onyx.Space.sm),
|
||
("md", Onyx.Space.md),
|
||
("lg", Onyx.Space.lg),
|
||
("xl", Onyx.Space.xl),
|
||
("xxl", Onyx.Space.xxl),
|
||
]
|
||
|
||
return section("Abstände") {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||
ForEach(steps, id: \.0) { name, value in
|
||
HStack(spacing: Onyx.Space.lg) {
|
||
Text(name)
|
||
.font(Onyx.Font.micro)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
.frame(width: 40, alignment: .leading)
|
||
Rectangle()
|
||
.fill(Onyx.Color.accent)
|
||
.frame(width: value, height: 12)
|
||
Text("\(Int(value))")
|
||
.font(Onyx.Font.metricMicro)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var radii: some View {
|
||
let steps: [(String, CGFloat)] = [
|
||
("chip", Onyx.Radius.chip),
|
||
("control", Onyx.Radius.control),
|
||
("card", Onyx.Radius.card),
|
||
("tile", Onyx.Radius.tile),
|
||
("notch", Onyx.Radius.notch),
|
||
("panel", Onyx.Radius.panel),
|
||
]
|
||
|
||
return section("Rundungen") {
|
||
HStack(alignment: .top, spacing: Onyx.Space.lg) {
|
||
ForEach(steps, id: \.0) { name, value in
|
||
VStack(spacing: Onyx.Space.sm) {
|
||
RoundedRectangle(cornerRadius: value, style: .continuous)
|
||
.fill(Onyx.Color.elevated)
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: value, style: .continuous)
|
||
.strokeBorder(Onyx.Color.hairline,
|
||
lineWidth: Onyx.Metric.hairlineWidth)
|
||
}
|
||
.frame(width: 64, height: 64)
|
||
Text("\(name) \(Int(value))")
|
||
.font(Onyx.Font.micro)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var fonts: some View {
|
||
let specimens: [(String, Font)] = [
|
||
("micro", Onyx.Font.micro),
|
||
("caption", Onyx.Font.caption),
|
||
("footnote", Onyx.Font.footnote),
|
||
("body", Onyx.Font.body),
|
||
("title", Onyx.Font.title),
|
||
("headline", Onyx.Font.headline),
|
||
("display", Onyx.Font.display),
|
||
("displayLarge", Onyx.Font.displayLarge),
|
||
("metricMicro", Onyx.Font.metricMicro),
|
||
("metricSmall", Onyx.Font.metricSmall),
|
||
("metric", Onyx.Font.metric),
|
||
("metricLarge", Onyx.Font.metricLarge),
|
||
("symbolHero", Onyx.Font.symbolHero),
|
||
]
|
||
|
||
return section("Schrift") {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||
ForEach(specimens, id: \.0) { name, font in
|
||
HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.lg) {
|
||
Text(name)
|
||
.font(Onyx.Font.micro)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
.frame(width: 96, alignment: .leading)
|
||
Text("Onyx 0123456789")
|
||
.font(font)
|
||
.foregroundStyle(Onyx.Color.textPrimary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var metrics: some View {
|
||
let values: [(String, CGFloat)] = [
|
||
("panelPadding", Onyx.Metric.panelPadding),
|
||
("tileSpacing", Onyx.Metric.tileSpacing),
|
||
("hairlineWidth", Onyx.Metric.hairlineWidth),
|
||
]
|
||
|
||
return section("Panelgeometrie") {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.md) {
|
||
ForEach(values, id: \.0) { name, value in
|
||
HStack(spacing: Onyx.Space.lg) {
|
||
Text(name)
|
||
.font(Onyx.Font.micro)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
.frame(width: 96, alignment: .leading)
|
||
Rectangle()
|
||
.fill(Onyx.Color.accent)
|
||
.frame(width: max(value, 1), height: 12)
|
||
Text("\(Int(value))")
|
||
.font(Onyx.Font.metricMicro)
|
||
.foregroundStyle(Onyx.Color.textSecondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Bewegung lässt sich nicht abbilden, nur vorführen — deshalb ein Knopf
|
||
/// und ein echter Profilwechsel statt einer Tabelle mit Federkonstanten.
|
||
private var motion: some View {
|
||
let curves: [(String, Animation)] = [
|
||
("expand", Onyx.Motion.expand),
|
||
("collapse", Onyx.Motion.collapse),
|
||
("value", Onyx.Motion.value),
|
||
]
|
||
|
||
return section("Bewegung") {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||
Picker("Profil", selection: Binding(
|
||
get: { OnyxTheme.shared.motion },
|
||
set: { OnyxTheme.shared.motion = $0 })) {
|
||
ForEach(MotionProfile.allCases, id: \.self) { profile in
|
||
Text(profile.rawValue).tag(profile)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
.frame(width: 280)
|
||
|
||
ForEach(curves, id: \.0) { name, animation in
|
||
HStack(spacing: Onyx.Space.lg) {
|
||
Text(name)
|
||
.font(Onyx.Font.micro)
|
||
.foregroundStyle(Onyx.Color.textTertiary)
|
||
.frame(width: 96, alignment: .leading)
|
||
ZStack(alignment: .leading) {
|
||
Capsule()
|
||
.fill(Onyx.Color.hairline)
|
||
.frame(height: 4)
|
||
Circle()
|
||
.fill(Onyx.Color.accent)
|
||
.frame(width: 14, height: 14)
|
||
.offset(x: moved ? 246 : 0)
|
||
.animation(animation, value: moved)
|
||
}
|
||
.frame(width: 260, height: 14)
|
||
}
|
||
}
|
||
|
||
Button(moved ? "Zurück" : "Los") { moved.toggle() }
|
||
}
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
|
||
VStack(alignment: .leading, spacing: Onyx.Space.lg) {
|
||
Text(title)
|
||
.font(Onyx.Font.title)
|
||
.foregroundStyle(Onyx.Color.textPrimary)
|
||
content()
|
||
}
|
||
}
|
||
}
|