v1.0.0 — initiale Extraktion aus memoro-native

ManaCore + ManaTokens als Swift-Package für alle nativen
mana-e.V.-Apps. Phase α aus mana/docs/MANA_SWIFT.md durch.

ManaCore:
- AuthClient gegen mana-auth (Login, Refresh, Status-Maschine)
- AuthenticatedTransport (URLSession + 401-Retry)
- ManaAppConfig-Protocol für App-injizierbare Konfig
- KeychainStore mit optionaler Shared-Access-Group
- JWT-Parser für lokale Expiry-Prüfung
- AuthError, CoreLog (interne OSLog-Logger)

ManaTokens:
- 12 Vereins-Tokens als dynamic Light/Dark Colors
- 5 Brand-Literale (mana-yellow, spectrum-orange, ...)
- Spacing, Radius, Typography aus mana/docs/THEMING.md

Tests: 12 Unit-Tests grün via swift test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-12 19:13:31 +02:00
commit df6f67ee45
23 changed files with 1151 additions and 0 deletions

View file

@ -0,0 +1,61 @@
import Foundation
/// Authentifizierter HTTP-Transport. Setzt automatisch den `Authorization: Bearer`-
/// Header, refreshed bei `401` einmal proaktiv und wiederholt den Request.
///
/// Eine Instanz pro App-Backend (z.B. memoro-api, cards-api). Beim Init
/// die Basis-URL des jeweiligen App-Servers übergeben, nicht die mana-auth-URL.
public actor AuthenticatedTransport {
private let baseURL: URL
private let session: URLSession
private let auth: AuthClient
public init(baseURL: URL, auth: AuthClient, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
self.auth = auth
}
/// Führt einen authentifizierten Request aus. Bei `401` wird der
/// Access-Token einmal refreshed und der Call wiederholt.
public func request(
path: String,
method: String = "GET",
body: Data? = nil,
contentType: String = "application/json"
) async throws -> (Data, HTTPURLResponse) {
let token = try await auth.freshAccessToken()
let response = try await send(path: path, method: method, body: body, contentType: contentType, token: token)
if response.1.statusCode == 401 {
let refreshed = try await auth.refreshAccessToken()
return try await send(path: path, method: method, body: body, contentType: contentType, token: refreshed)
}
return response
}
private func send(
path: String,
method: String,
body: Data?,
contentType: String,
token: String
) async throws -> (Data, HTTPURLResponse) {
var request = URLRequest(url: baseURL.appending(path: path))
request.httpMethod = method
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
if let body {
request.httpBody = body
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AuthError.networkFailure("Keine HTTP-Antwort")
}
return (data, http)
} catch let error as URLError {
throw AuthError.networkFailure(error.localizedDescription)
}
}
}

View file

@ -0,0 +1,172 @@
import Foundation
import Observation
/// Auth-Client gegen mana-auth. Beobachtbarer Status, Login-Flow,
/// proaktiver Token-Refresh.
///
/// Eine Instanz pro App, beim App-Start initialisiert. `bootstrap()`
/// füllt den Status aus dem Keychain (offline möglich).
@MainActor
@Observable
public final class AuthClient {
public enum Status: Equatable, Sendable {
case unknown
case signedOut
case signingIn
case signedIn(email: String)
case error(String)
}
public private(set) var status: Status = .unknown
private let config: ManaAppConfig
private let keychain: KeychainStore
private let session: URLSession
public init(config: ManaAppConfig, session: URLSession = .shared) {
self.config = config
keychain = KeychainStore(service: config.keychainService, accessGroup: config.keychainAccessGroup)
self.session = session
}
public var currentEmail: String? {
if case let .signedIn(email) = status { return email }
return nil
}
public func bootstrap() {
let email = keychain.getString(for: .email)
let hasToken = keychain.getString(for: .accessToken) != nil
if let email, hasToken {
status = .signedIn(email: email)
} else {
status = .signedOut
}
}
public func signIn(email: String, password: String) async {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !password.isEmpty else {
status = .error("Email und Passwort sind erforderlich")
return
}
status = .signingIn
do {
let url = config.authBaseURL.appending(path: "/api/v1/auth/login")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(LoginRequest(email: trimmed, password: password))
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
status = .error("Ungültige Server-Antwort")
return
}
guard http.statusCode == 200 else {
let message = (try? JSONDecoder().decode(ServerError.self, from: data))?.message
if http.statusCode == 401 {
status = .error("Email oder Passwort falsch")
} else {
status =
.error("Login fehlgeschlagen (HTTP \(http.statusCode))" + (message.map { "\($0)" } ?? ""))
}
return
}
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
try keychain.setString(token.accessToken, for: .accessToken)
try keychain.setString(token.refreshToken, for: .refreshToken)
try keychain.setString(trimmed, for: .email)
status = .signedIn(email: trimmed)
CoreLog.auth.info("Sign-in successful")
} catch let error as URLError {
status = .error("Netzwerk: \(error.localizedDescription)")
CoreLog.auth.error("Sign-in network error: \(error.localizedDescription, privacy: .public)")
} catch {
status = .error(String(describing: error))
CoreLog.auth.error("Sign-in error: \(String(describing: error), privacy: .public)")
}
}
public func signOut() async {
if let token = keychain.getString(for: .accessToken) {
let url = config.authBaseURL.appending(path: "/api/v1/auth/logout")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
_ = try? await session.data(for: request)
}
keychain.wipe()
status = .signedOut
CoreLog.auth.info("Signed out")
}
public func currentAccessToken() throws -> String {
guard let token = keychain.getString(for: .accessToken) else {
throw AuthError.notSignedIn
}
return token
}
/// Liefert einen Access-Token, der nicht innerhalb der nächsten
/// `refreshLeeway` Sekunden abläuft. Refreshed proaktiv, wenn nötig.
public func freshAccessToken(refreshLeeway: TimeInterval = 300) async throws -> String {
let token = try currentAccessToken()
if let expiry = JWT.expiry(of: token), expiry.timeIntervalSinceNow > refreshLeeway {
return token
}
CoreLog.auth.notice("Token expiring within \(Int(refreshLeeway), privacy: .public)s — refreshing proactively")
return try await refreshAccessToken()
}
public func refreshAccessToken() async throws -> String {
guard let refresh = keychain.getString(for: .refreshToken) else {
throw AuthError.notSignedIn
}
let url = config.authBaseURL.appending(path: "/api/v1/auth/refresh")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(RefreshRequest(refreshToken: refresh))
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AuthError.networkFailure("Keine HTTP-Antwort")
}
guard http.statusCode == 200 else {
keychain.wipe()
status = .signedOut
throw AuthError.serverError(
status: http.statusCode,
message: (try? JSONDecoder().decode(ServerError.self, from: data))?.message
)
}
let token = try JSONDecoder().decode(TokenResponse.self, from: data)
try keychain.setString(token.accessToken, for: .accessToken)
if !token.refreshToken.isEmpty {
try keychain.setString(token.refreshToken, for: .refreshToken)
}
return token.accessToken
}
}
private struct LoginRequest: Encodable {
let email: String
let password: String
}
private struct RefreshRequest: Encodable {
let refreshToken: String
}
private struct TokenResponse: Decodable {
let accessToken: String
let refreshToken: String
}
private struct ServerError: Decodable {
let message: String?
}

View file

@ -0,0 +1,31 @@
import Foundation
/// Fehler-Typen des Auth- und Transport-Layers.
public enum AuthError: Error, LocalizedError, Sendable {
case notSignedIn
case invalidCredentials
case networkFailure(String)
case serverError(status: Int, message: String?)
case decoding(String)
case keychain(OSStatus)
case encoding
public var errorDescription: String? {
switch self {
case .notSignedIn:
"Nicht angemeldet"
case .invalidCredentials:
"Ungültige Anmeldedaten"
case let .networkFailure(message):
"Netzwerkfehler: \(message)"
case let .serverError(status, message):
"Server-Fehler (\(status))" + (message.map { ": \($0)" } ?? "")
case let .decoding(detail):
"Antwort konnte nicht gelesen werden: \(detail)"
case let .keychain(status):
"Keychain-Fehler (OSStatus \(status))"
case .encoding:
"Datenkodierung fehlgeschlagen"
}
}
}

View file

@ -0,0 +1,25 @@
import Foundation
/// JWT-Parsing-Helfer. **Verifiziert keine Signatur** der Server bleibt
/// die Wahrheitsinstanz. Hier nur lokale Expiry-Prüfung für proaktiven
/// Refresh.
public enum JWT {
/// Liest `exp`-Claim aus einem JWT, falls vorhanden.
public static func expiry(of token: String) -> Date? {
let parts = token.split(separator: ".")
guard parts.count == 3 else { return nil }
guard let payloadData = base64URLDecode(String(parts[1])) else { return nil }
guard let dict = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any] else { return nil }
guard let exp = dict["exp"] as? TimeInterval else { return nil }
return Date(timeIntervalSince1970: exp)
}
private static func base64URLDecode(_ value: String) -> Data? {
var base64 = value
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let paddingNeeded = (4 - base64.count % 4) % 4
base64.append(String(repeating: "=", count: paddingNeeded))
return Data(base64Encoded: base64)
}
}

View file

@ -0,0 +1,84 @@
import Foundation
import Security
/// Generische, in der App injizierbare Keychain-Wrapper. Eine Instanz
/// pro `ManaAppConfig`.
public final class KeychainStore: Sendable {
/// Bekannte Keys für mana-auth-Tokens.
public enum Key: String, Sendable {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case email
}
private let service: String
private let accessGroup: String?
public init(service: String, accessGroup: String? = nil) {
self.service = service
self.accessGroup = accessGroup
}
public func setString(_ value: String, for key: Key) throws {
guard let data = value.data(using: .utf8) else {
throw AuthError.encoding
}
var query = baseQuery(for: key)
let attributes: [CFString: Any] = [
kSecValueData: data,
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock,
]
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
switch updateStatus {
case errSecSuccess:
return
case errSecItemNotFound:
for (attrKey, attrValue) in attributes {
query[attrKey] = attrValue
}
let addStatus = SecItemAdd(query as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw AuthError.keychain(addStatus)
}
default:
throw AuthError.keychain(updateStatus)
}
}
public func getString(for key: Key) -> String? {
var query = baseQuery(for: key)
query[kSecReturnData] = true
query[kSecMatchLimit] = kSecMatchLimitOne
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess, let data = result as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
public func remove(for key: Key) {
let query = baseQuery(for: key)
SecItemDelete(query as CFDictionary)
}
public func wipe() {
remove(for: .accessToken)
remove(for: .refreshToken)
remove(for: .email)
}
private func baseQuery(for key: Key) -> [CFString: Any] {
var query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key.rawValue,
]
if let accessGroup {
query[kSecAttrAccessGroup] = accessGroup
}
return query
}
}

View file

@ -0,0 +1,42 @@
import Foundation
/// App-spezifische Konfiguration für ManaCore. Wird von der konsumierenden
/// App beim Erzeugen eines `AuthClient` injiziert.
///
/// ManaCore hardcoded nichts App-Spezifisches. Bundle-ID, Auth-Server-URL
/// und Keychain-Adressierung kommen ausschließlich hierüber.
public protocol ManaAppConfig: Sendable {
/// Basis-URL des mana-auth-Servers, z.B. `https://auth.mana.how`.
var authBaseURL: URL { get }
/// Keychain-Service-Identifier, üblich `ev.mana.<app>`. Trennt
/// Token-Einträge verschiedener Apps voneinander, falls keine
/// shared Access-Group benutzt wird.
var keychainService: String { get }
/// Optional: Shared-Keychain-Access-Group für Cross-App-SSO.
/// `nil` bedeutet: nur App-eigener Keychain-Zugriff.
///
/// Wenn gesetzt, müssen alle teilnehmenden Apps unter derselben
/// Apple-Developer-Team-ID provisioniert sein und das Entitlement
/// `keychain-access-groups` mit demselben Wert tragen.
var keychainAccessGroup: String? { get }
}
/// Standard-Implementierung von ``ManaAppConfig``. Apps können diese
/// nutzen oder ein eigenes Type adoptieren.
public struct DefaultManaAppConfig: ManaAppConfig {
public let authBaseURL: URL
public let keychainService: String
public let keychainAccessGroup: String?
public init(
authBaseURL: URL,
keychainService: String,
keychainAccessGroup: String? = nil
) {
self.authBaseURL = authBaseURL
self.keychainService = keychainService
self.keychainAccessGroup = keychainAccessGroup
}
}

View file

@ -0,0 +1,9 @@
import Foundation
import OSLog
/// Interne OSLog-Logger von ManaCore. Apps haben ihre eigenen Logger
/// unter eigenem Subsystem diese hier sind für die Bibliothek selbst.
enum CoreLog {
static let auth = Logger(subsystem: "ev.mana.core", category: "auth")
static let transport = Logger(subsystem: "ev.mana.core", category: "transport")
}

View file

@ -0,0 +1,24 @@
import SwiftUI
/// Brand-Literal-Schicht aus `mana/docs/THEMING.md`. Theme-unabhängig:
/// diese Werte bleiben gleich in Light, Dark, allen Variants.
///
/// Nur für Stellen verwenden, wo bewusst Brand-Identität gemeint ist
/// (Logo-Komponenten, App-spezifische Sondertöne). Allgemeine UI bleibt
/// auf ``ManaColor``.
public enum ManaBrand {
/// `--brand-mana-yellow` Vereins-Gelb.
public static let manaYellow = Color.manaHex(0xFFB700)
/// `--brand-mana-spectrum-orange` Vereins-Spektrum-Orange.
public static let manaSpectrumOrange = Color.manaHex(0xFF6600)
/// `--brand-spiral-indigo` Spiral-Indigo.
public static let spiralIndigo = Color.manaHex(0x6366F1)
/// `--brand-period-pink` Period-Pink.
public static let periodPink = Color.manaHex(0xF902AC)
/// `--brand-zitare-paper` Zitare-Papier-Ton.
public static let zitarePaper = Color.manaHex(0xF7F3E9)
}

View file

@ -0,0 +1,84 @@
import SwiftUI
/// Die 12 Vereins-Tokens als SwiftUI-`Color`. Wechselt automatisch
/// zwischen Light- und Dark-Variante via System-Appearance.
///
/// Quelle: `mana/docs/THEMING.md` eingefrorenes Vokabular,
/// keine 13. Farbe ohne Vereins-Beschluss.
///
/// `primary` und `primaryForeground` zeigen aktuell die `mana`-Variant
/// (Spektrum-Orange, `25 100% 50%`). Weitere Theme-Variants kommen
/// in einer späteren Version (`mana/docs/MANA_SWIFT.md` Phase ε).
public enum ManaColor {
/// Token 1 `--color-background`. Page-Hintergrund.
public static let background = Color.manaToken(
light: (0, 0, 100),
dark: (222, 47, 11)
)
/// Token 2 `--color-foreground`. Standard-Text.
public static let foreground = Color.manaToken(
light: (222, 47, 11),
dark: (210, 40, 98)
)
/// Token 3 `--color-surface`. Card, Panel, Modal, Popover.
public static let surface = Color.manaToken(
light: (0, 0, 99),
dark: (217, 33, 17)
)
/// Token 4 `--color-surface-hover`. Hover-State auf Surface.
public static let surfaceHover = Color.manaToken(
light: (220, 14, 96),
dark: (215, 28, 22)
)
/// Token 5 `--color-muted`. Disabled-Felder, Skeleton.
public static let muted = Color.manaToken(
light: (220, 14, 94),
dark: (217, 33, 23)
)
/// Token 6 `--color-muted-foreground`. Sekundär-Text, Placeholder.
public static let mutedForeground = Color.manaToken(
light: (220, 9, 46),
dark: (215, 20, 65)
)
/// Token 7 `--color-border`. Rahmen, Trennlinien.
public static let border = Color.manaToken(
light: (220, 13, 91),
dark: (217, 33, 25)
)
/// Token 8 `--color-primary`. App-Akzent. Aktuell mana-Variant.
public static let primary = Color.manaToken(
light: (25, 100, 50),
dark: (25, 100, 50)
)
/// Token 9 `--color-primary-foreground`. Text auf Primary-Flächen.
public static let primaryForeground = Color.manaToken(
light: (0, 0, 100),
dark: (0, 0, 100)
)
/// Token 10 `--color-error`. Fehler, Lösch-Aktion.
public static let error = Color.manaToken(
light: (0, 84, 60),
dark: (0, 63, 55)
)
/// Token 11 `--color-success`. Erfolg, Bestätigung.
public static let success = Color.manaToken(
light: (142, 71, 45),
dark: (142, 71, 45)
)
/// Token 12 `--color-warning`. Warnung, Aufmerksamkeit.
public static let warning = Color.manaToken(
light: (38, 92, 50),
dark: (48, 96, 53)
)
}

View file

@ -0,0 +1,78 @@
import SwiftUI
#if canImport(UIKit)
import UIKit
typealias PlatformColor = UIColor
#elseif canImport(AppKit)
import AppKit
typealias PlatformColor = NSColor
#endif
extension PlatformColor {
/// Konstruiert eine RGB-Farbe aus CSS-HSL-Werten.
/// - Parameters:
/// - hue: 0360 (Grad)
/// - saturation: 0100 (Prozent)
/// - lightness: 0100 (Prozent)
static func fromHSL(_ hue: Double, _ saturation: Double, _ lightness: Double) -> PlatformColor {
let h = hue / 360
let s = saturation / 100
let l = lightness / 100
if s == 0 {
return PlatformColor(red: l, green: l, blue: l, alpha: 1)
}
let q = l < 0.5 ? l * (1 + s) : l + s - l * s
let p = 2 * l - q
let r = hueToRGB(p, q, h + 1.0 / 3.0)
let g = hueToRGB(p, q, h)
let b = hueToRGB(p, q, h - 1.0 / 3.0)
return PlatformColor(red: r, green: g, blue: b, alpha: 1)
}
private static func hueToRGB(_ p: Double, _ q: Double, _ rawT: Double) -> Double {
var t = rawT
if t < 0 { t += 1 }
if t > 1 { t -= 1 }
if t < 1.0 / 6.0 { return p + (q - p) * 6 * t }
if t < 1.0 / 2.0 { return q }
if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6 }
return p
}
}
extension Color {
/// Erzeugt eine SwiftUI-Color, die abhängig vom System-Appearance
/// zwischen Light- und Dark-Variante umschaltet. Beide werden als
/// CSS-HSL-Tripel übergeben (siehe `mana/docs/THEMING.md`).
static func manaToken(
light: (Double, Double, Double),
dark: (Double, Double, Double)
) -> Color {
let lightColor = PlatformColor.fromHSL(light.0, light.1, light.2)
let darkColor = PlatformColor.fromHSL(dark.0, dark.1, dark.2)
#if canImport(UIKit)
return Color(uiColor: UIColor { trait in
trait.userInterfaceStyle == .dark ? darkColor : lightColor
})
#elseif canImport(AppKit)
return Color(nsColor: NSColor(name: nil) { appearance in
let isDark = appearance.bestMatch(from: [.darkAqua, .vibrantDark]) != nil
return isDark ? darkColor : lightColor
})
#else
return Color(red: 0, green: 0, blue: 0)
#endif
}
/// Konstruktor aus einem Hex-RGB-Literal (`0xFFB700`).
static func manaHex(_ rgb: UInt32) -> Color {
let r = Double((rgb >> 16) & 0xFF) / 255.0
let g = Double((rgb >> 8) & 0xFF) / 255.0
let b = Double(rgb & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
}

View file

@ -0,0 +1,42 @@
import CoreGraphics
/// Border-Radius-Werte für die drei Vereins-Modi `sharp`, `soft`, `pill`
/// aus `mana/docs/THEMING.md`. Apps wählen einen Default und lassen
/// User optional umstellen.
public enum Radius {
/// 0pt sharp-Modus
public static let sharp: CGFloat = 0
/// 2pt kantig-aber-nicht-spitz
public static let xs: CGFloat = 2
/// 4pt
public static let sm: CGFloat = 4
/// 8pt Standard für Buttons, Cards (soft-Modus)
public static let md: CGFloat = 8
/// 12pt
public static let lg: CGFloat = 12
/// 16pt
public static let xl: CGFloat = 16
/// 9999pt voll abgerundet (pill-Modus)
public static let pill: CGFloat = 9999
}
/// Multiplikator für die `radius`-Achse aus `mana/docs/THEMING.md`.
public enum RadiusMode: Sendable {
case sharp
case soft
case pill
public var scale: CGFloat {
switch self {
case .sharp: 0
case .soft: 1
case .pill: 2
}
}
}

View file

@ -0,0 +1,48 @@
import CoreGraphics
/// Spacing-Skala für Padding, Margins, Stack-Spacing.
///
/// Tailwind-kompatible Werte. `mana/docs/THEMING.md` definiert die
/// Achse `density` (compact/normal/comfortable) diese skaliert hier
/// nicht, sondern wird in der App über einen Multiplikator angewandt
/// (siehe `densityScale`).
public enum Spacing {
/// 4pt sehr eng
public static let xxs: CGFloat = 4
/// 8pt eng
public static let xs: CGFloat = 8
/// 12pt
public static let sm: CGFloat = 12
/// 16pt Standard-Abstand zwischen Elementen
public static let md: CGFloat = 16
/// 24pt
public static let lg: CGFloat = 24
/// 32pt Sektion-Abstand
public static let xl: CGFloat = 32
/// 48pt Major-Sektion
public static let xxl: CGFloat = 48
/// 64pt
public static let xxxl: CGFloat = 64
}
/// Multiplikator für die `density`-Achse aus `mana/docs/THEMING.md`.
public enum Density: Sendable {
case compact
case normal
case comfortable
public var scale: CGFloat {
switch self {
case .compact: 0.85
case .normal: 1.0
case .comfortable: 1.15
}
}
}

View file

@ -0,0 +1,36 @@
import SwiftUI
/// Typographie-Skala. Nutzt SwiftUI-Standard-`Font`-Styles, die
/// automatisch Dynamic Type respektieren (Accessibility).
///
/// Apps mit speziellen Display-Anforderungen können die Werte
/// überschreiben der Default folgt Apple's Human Interface Guidelines
/// für gemischte Lese-/Aktions-Apps.
public enum ManaTypography {
/// Großüberschrift Screen-Titel.
public static let largeTitle: Font = .largeTitle
/// Sektion-Titel.
public static let title: Font = .title
/// Sub-Sektion.
public static let title2: Font = .title2
/// Card-Titel.
public static let title3: Font = .title3
/// Standard-Überschrift im Body.
public static let headline: Font = .headline
/// Body-Text.
public static let body: Font = .body
/// Body, hervorgehoben.
public static let bodyEmphasized: Font = .body.weight(.semibold)
/// Sekundär-Text, Caption.
public static let caption: Font = .caption
/// Kleinste Schrift Meta, Footnote.
public static let footnote: Font = .footnote
}