feat: add iOS support and harden receipt scanning
This commit is contained in:
96
app/ios/ScanReceipts/Design/ZenithButtonStyle.swift
Normal file
96
app/ios/ScanReceipts/Design/ZenithButtonStyle.swift
Normal file
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Buttons per `DESIGN (1).md` → Components → Buttons: "Solid #000000
|
||||
/// background with #FFFFFF text for primary actions. 1px solid #000000
|
||||
/// border with no fill for secondary. All buttons are rectangular [0px
|
||||
/// radius] with no padding-inline under 24px." And → Elevation: "On hover,
|
||||
/// elements do not lift; they shift color... a button fill turns from Black
|
||||
/// to Slate Blue-Grey" — the pressed state here plays the role "hover" plays
|
||||
/// on the web.
|
||||
/// Every Zenith button style dims to this opacity when `.disabled(true)` —
|
||||
/// centralised here so a disabled `.zenithPrimary` etc. dims automatically,
|
||||
/// the way `.borderedProminent`/`.bordered` do out of the box. Without this,
|
||||
/// each call site would need its own manual `.opacity(isEnabled ? 1 : 0.4)`,
|
||||
/// which is easy to forget (an earlier pass of this app's screens did, in
|
||||
/// fact, forget it in a couple of places before this was added centrally).
|
||||
private let zenithDisabledOpacity: Double = 0.4
|
||||
|
||||
struct ZenithPrimaryButtonStyle: ButtonStyle {
|
||||
var isDestructive = false
|
||||
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.zenithBodyMd)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.padding(.horizontal, ZenithSpacing.md)
|
||||
.background(fill(pressed: configuration.isPressed))
|
||||
// Sharp corners are the point of this system — a plain
|
||||
// Rectangle fill, never `.cornerRadius`.
|
||||
.opacity(isEnabled ? 1 : zenithDisabledOpacity)
|
||||
}
|
||||
|
||||
private func fill(pressed: Bool) -> Color {
|
||||
if isDestructive { return pressed ? Color.zenithError.opacity(0.8) : .zenithError }
|
||||
return pressed ? .zenithSlate : .zenithBlack
|
||||
}
|
||||
}
|
||||
|
||||
struct ZenithSecondaryButtonStyle: ButtonStyle {
|
||||
var isDestructive = false
|
||||
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.zenithBodyMd)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(isDestructive ? Color.zenithError : Color.zenithBlack)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.padding(.horizontal, ZenithSpacing.md)
|
||||
.background(Color.zenithSurface)
|
||||
.overlay(
|
||||
// Border THICKENS on press instead of the fill lifting —
|
||||
// same "no lift, shift instead" rule as the primary style.
|
||||
Rectangle()
|
||||
.strokeBorder(
|
||||
isDestructive ? Color.zenithError : Color.zenithBlack,
|
||||
lineWidth: configuration.isPressed ? 2 : 1
|
||||
)
|
||||
)
|
||||
.opacity(isEnabled ? 1 : zenithDisabledOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
/// A plain-text button with no fill/border at all — for tertiary actions
|
||||
/// ("Noch kein Konto? Registrieren", "Abbrechen") that shouldn't compete
|
||||
/// visually with a screen's primary/secondary buttons.
|
||||
struct ZenithPlainButtonStyle: ButtonStyle {
|
||||
var color: Color = .zenithMuted
|
||||
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.font(.zenithBodySm)
|
||||
.foregroundStyle(configuration.isPressed ? color.opacity(0.6) : color)
|
||||
.opacity(isEnabled ? 1 : zenithDisabledOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
extension ButtonStyle where Self == ZenithPrimaryButtonStyle {
|
||||
static var zenithPrimary: ZenithPrimaryButtonStyle { ZenithPrimaryButtonStyle() }
|
||||
static var zenithPrimaryDestructive: ZenithPrimaryButtonStyle { ZenithPrimaryButtonStyle(isDestructive: true) }
|
||||
}
|
||||
|
||||
extension ButtonStyle where Self == ZenithSecondaryButtonStyle {
|
||||
static var zenithSecondary: ZenithSecondaryButtonStyle { ZenithSecondaryButtonStyle() }
|
||||
static var zenithSecondaryDestructive: ZenithSecondaryButtonStyle { ZenithSecondaryButtonStyle(isDestructive: true) }
|
||||
}
|
||||
|
||||
extension ButtonStyle where Self == ZenithPlainButtonStyle {
|
||||
static var zenithPlain: ZenithPlainButtonStyle { ZenithPlainButtonStyle() }
|
||||
}
|
||||
35
app/ios/ScanReceipts/Design/ZenithCard.swift
Normal file
35
app/ios/ScanReceipts/Design/ZenithCard.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Cards per `DESIGN (1).md` → Components → Cards: "White backgrounds with
|
||||
/// 1px borders in #E2E8F0. No shadows. Content within cards should follow
|
||||
/// the 24px internal padding rule." And → Shapes: 0px radius everywhere.
|
||||
struct ZenithCardModifier: ViewModifier {
|
||||
var padding: CGFloat = ZenithSpacing.cardPadding
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.padding(padding)
|
||||
.background(Color.zenithSurface)
|
||||
.overlay(Rectangle().strokeBorder(Color.zenithBorder, lineWidth: 1))
|
||||
// Deliberately no `.cornerRadius` and no `.shadow` — both are
|
||||
// explicitly rejected by the design system ("rejects traditional
|
||||
// shadows and depth", "0px radius" everywhere).
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func zenithCard(padding: CGFloat = ZenithSpacing.cardPadding) -> some View {
|
||||
modifier(ZenithCardModifier(padding: padding))
|
||||
}
|
||||
|
||||
/// Strips the default iOS `List`/`Form` chrome (rounded row groups,
|
||||
/// system grey background, inset grouping) toward the flat,
|
||||
/// bordered-card look the rest of this design system uses. Apply to a
|
||||
/// `List`/`Form`; still combine with `.listRowBackground(Color.zenithSurface)`
|
||||
/// and `.listRowSeparatorTint(Color.zenithBorder)` on individual rows if
|
||||
/// you want row separators to match instead of disappearing.
|
||||
func zenithListBackground() -> some View {
|
||||
scrollContentBackground(.hidden)
|
||||
.background(Color.zenithBg)
|
||||
}
|
||||
}
|
||||
81
app/ios/ScanReceipts/Design/ZenithColors.swift
Normal file
81
app/ios/ScanReceipts/Design/ZenithColors.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The web app's "Zenith Silver" palette (see `tailwind.config.ts`'s `zenith`
|
||||
/// color group and `DESIGN (1).md` in the main repo root), ported 1:1 as hex
|
||||
/// values — this is a single fixed light palette, not a light/dark pair: the
|
||||
/// web app itself has no dark mode, and "Architectural Minimalist" is
|
||||
/// deliberately monochromatic, so inventing a dark variant here would not be
|
||||
/// matching the web app's design, it would be a different one. If dark mode
|
||||
/// is wanted later, that's a deliberate follow-up design decision, not a gap
|
||||
/// in this port.
|
||||
extension Color {
|
||||
init(zenithHex hex: UInt32) {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((hex >> 16) & 0xFF) / 255,
|
||||
green: Double((hex >> 8) & 0xFF) / 255,
|
||||
blue: Double(hex & 0xFF) / 255,
|
||||
opacity: 1
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Base surfaces
|
||||
|
||||
/// Page background — "Zenith Silver" itself.
|
||||
static let zenithBg = Color(zenithHex: 0xF6F9FF)
|
||||
/// Card/container fill — Level 1 in the system's elevation model.
|
||||
static let zenithSurface = Color(zenithHex: 0xFFFFFF)
|
||||
static let zenithDim = Color(zenithHex: 0xD4DBE2)
|
||||
static let zenithLow = Color(zenithHex: 0xEEF4FC)
|
||||
static let zenithContainer = Color(zenithHex: 0xE8EEF6)
|
||||
static let zenithHigh = Color(zenithHex: 0xE3E9F1)
|
||||
static let zenithHighest = Color(zenithHex: 0xDDE3EB)
|
||||
|
||||
// MARK: - Structure
|
||||
|
||||
/// The one border color used everywhere at 1px (2px on an active/focused
|
||||
/// control — see `ZenithTextField`).
|
||||
static let zenithBorder = Color(zenithHex: 0xE2E8F0)
|
||||
static let zenithBorderDark = Color(zenithHex: 0xC4C7C9)
|
||||
|
||||
// MARK: - Type
|
||||
|
||||
static let zenithText = Color(zenithHex: 0x161C22)
|
||||
static let zenithMuted = Color(zenithHex: 0x444749)
|
||||
static let zenithSubtle = Color(zenithHex: 0x747779)
|
||||
|
||||
// MARK: - Ink & accent
|
||||
|
||||
/// Pure black — primary buttons, headline type, structural strokes.
|
||||
static let zenithBlack = Color(zenithHex: 0x000000)
|
||||
/// Slate blue-grey — secondary actions, metadata, the color a primary
|
||||
/// button's fill shifts to on a pressed/hover state instead of "lifting".
|
||||
static let zenithSlate = Color(zenithHex: 0x475569)
|
||||
static let zenithAccent = Color(zenithHex: 0x1E293B)
|
||||
static let zenithError = Color(zenithHex: 0xBA1A1A)
|
||||
|
||||
// MARK: - Status tiers
|
||||
//
|
||||
// Ported directly from `src/components/dashboard/StatusBadge.tsx`
|
||||
// (`getStatusTierMeta`) — these are standard Tailwind emerald/amber/blue
|
||||
// shades, NOT part of the Zenith Silver monochrome palette itself. The
|
||||
// web app deliberately allows exactly these three status colors as the
|
||||
// one place the otherwise-monochrome system admits color, so this app
|
||||
// should use them only for the same purpose (receipt status), not as a
|
||||
// general accent.
|
||||
|
||||
static let zenithScannedBg = Color(zenithHex: 0xECFDF5)
|
||||
static let zenithScannedText = Color(zenithHex: 0x065F46)
|
||||
static let zenithScannedBorder = Color(zenithHex: 0xA7F3D0)
|
||||
static let zenithScannedDot = Color(zenithHex: 0x10B981)
|
||||
|
||||
static let zenithPendingBg = Color(zenithHex: 0xFFFBEB)
|
||||
static let zenithPendingText = Color(zenithHex: 0x92400E)
|
||||
static let zenithPendingBorder = Color(zenithHex: 0xFDE68A)
|
||||
static let zenithPendingDot = Color(zenithHex: 0xF59E0B)
|
||||
|
||||
static let zenithConfirmedBg = Color(zenithHex: 0xEFF6FF)
|
||||
static let zenithConfirmedText = Color(zenithHex: 0x1D4ED8)
|
||||
static let zenithConfirmedBorder = Color(zenithHex: 0xBFDBFE)
|
||||
static let zenithConfirmedDot = Color(zenithHex: 0x2563EB)
|
||||
}
|
||||
13
app/ios/ScanReceipts/Design/ZenithDivider.swift
Normal file
13
app/ios/ScanReceipts/Design/ZenithDivider.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A 1px rule in the system's one border color — `DESIGN (1).md` →
|
||||
/// Components → Lists: "Separated by 1px horizontal dividers in #E2E8F0."
|
||||
/// Prefer this over the bare `Divider()`, whose color/weight follow the
|
||||
/// system theme rather than this app's fixed palette.
|
||||
struct ZenithDivider: View {
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(Color.zenithBorder)
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
24
app/ios/ScanReceipts/Design/ZenithSpacing.swift
Normal file
24
app/ios/ScanReceipts/Design/ZenithSpacing.swift
Normal file
@@ -0,0 +1,24 @@
|
||||
import CoreGraphics
|
||||
|
||||
/// The web app's 4px-base spacing scale (`DESIGN (1).md` → Layout & Spacing:
|
||||
/// `spacing.unit: 4px`, `stack-sm/md/lg/xl`, `margin-mobile`). Use these
|
||||
/// instead of ad-hoc padding numbers so spacing stays consistent with the
|
||||
/// web app's rhythm.
|
||||
enum ZenithSpacing {
|
||||
/// The 4px base unit itself — for the rare one-off that doesn't fit a
|
||||
/// named step below.
|
||||
static let unit: CGFloat = 4
|
||||
|
||||
static let xs: CGFloat = 8 // stack-sm
|
||||
static let sm: CGFloat = 16 // margin-mobile
|
||||
static let md: CGFloat = 24 // stack-md / card internal padding
|
||||
static let lg: CGFloat = 48 // stack-lg
|
||||
static let xl: CGFloat = 80 // stack-xl
|
||||
|
||||
/// Card internal padding — `DESIGN (1).md` → Components → Cards:
|
||||
/// "Content within cards should follow the 24px internal padding rule."
|
||||
static let cardPadding: CGFloat = md
|
||||
/// Screen-edge margin on a phone-width layout (mobile margin, not the
|
||||
/// 64px desktop margin, which doesn't apply on iPhone).
|
||||
static let screenMargin: CGFloat = sm
|
||||
}
|
||||
125
app/ios/ScanReceipts/Design/ZenithStatusBadge.swift
Normal file
125
app/ios/ScanReceipts/Design/ZenithStatusBadge.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Ported from `src/components/dashboard/StatusBadge.tsx` — the web
|
||||
/// dashboard's "3-tier status badges" (see `PROJECT.md`'s feature #12).
|
||||
/// Keep this resolver logic in sync with `resolveReceiptStatusTier` /
|
||||
/// `receiptNeedsAttention` (`src/lib/ai/recalculate.ts`) on the web side.
|
||||
enum ReceiptStatusTier {
|
||||
case scanned
|
||||
case pendingReview
|
||||
case confirmed
|
||||
|
||||
/// Mirrors `resolveReceiptStatusTier`: a manual confirmation always wins,
|
||||
/// then math/confidence problems route to "needs review", and everything
|
||||
/// else that at least parsed successfully is "scanned".
|
||||
static func resolve(_ receipt: Receipt) -> ReceiptStatusTier {
|
||||
let validation = receipt.validation
|
||||
if validation.userConfirmed == true { return .confirmed }
|
||||
if validation.needsUserReview || !validation.isMathValid { return .pendingReview }
|
||||
if receipt.status == .needsReview { return .pendingReview }
|
||||
return .scanned
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .scanned: return "Erfasst"
|
||||
case .pendingReview: return "Prüfung erforderlich"
|
||||
case .confirmed: return "Bestätigt"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .scanned: return "checkmark.circle.fill"
|
||||
case .pendingReview: return "exclamationmark.triangle.fill"
|
||||
case .confirmed: return "person.fill.checkmark"
|
||||
}
|
||||
}
|
||||
|
||||
var background: Color {
|
||||
switch self {
|
||||
case .scanned: return .zenithScannedBg
|
||||
case .pendingReview: return .zenithPendingBg
|
||||
case .confirmed: return .zenithConfirmedBg
|
||||
}
|
||||
}
|
||||
|
||||
var foreground: Color {
|
||||
switch self {
|
||||
case .scanned: return .zenithScannedText
|
||||
case .pendingReview: return .zenithPendingText
|
||||
case .confirmed: return .zenithConfirmedText
|
||||
}
|
||||
}
|
||||
|
||||
var border: Color {
|
||||
switch self {
|
||||
case .scanned: return .zenithScannedBorder
|
||||
case .pendingReview: return .zenithPendingBorder
|
||||
case .confirmed: return .zenithConfirmedBorder
|
||||
}
|
||||
}
|
||||
|
||||
var dot: Color {
|
||||
switch self {
|
||||
case .scanned: return .zenithScannedDot
|
||||
case .pendingReview: return .zenithPendingDot
|
||||
case .confirmed: return .zenithConfirmedDot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The badge itself — small rectangular (0-radius), bordered, dot + icon +
|
||||
/// label, exactly the shape `StatusBadge.tsx` renders on the web.
|
||||
struct ZenithStatusBadge: View {
|
||||
let tier: ReceiptStatusTier
|
||||
var showIcon = true
|
||||
var showDot = true
|
||||
|
||||
init(_ receipt: Receipt, showIcon: Bool = true, showDot: Bool = true) {
|
||||
self.tier = .resolve(receipt)
|
||||
self.showIcon = showIcon
|
||||
self.showDot = showDot
|
||||
}
|
||||
|
||||
init(tier: ReceiptStatusTier, showIcon: Bool = true, showDot: Bool = true) {
|
||||
self.tier = tier
|
||||
self.showIcon = showIcon
|
||||
self.showDot = showDot
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
if showDot {
|
||||
Circle().fill(tier.dot).frame(width: 6, height: 6)
|
||||
}
|
||||
if showIcon {
|
||||
Image(systemName: tier.systemImage)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
}
|
||||
Text(tier.label)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.font(.zenithLabelSm)
|
||||
.foregroundStyle(tier.foreground)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(tier.background)
|
||||
.overlay(Rectangle().strokeBorder(tier.border, lineWidth: 1))
|
||||
}
|
||||
}
|
||||
|
||||
/// A generic (non-status) rectangular chip — `DESIGN (1).md` → Components →
|
||||
/// Chips/Tags: "`label-md` typography, small rectangular boxes with #F5F7F9
|
||||
/// fills and no borders." Use for categories, payment methods, etc.
|
||||
struct ZenithChip: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.zenithLabelMdStyle(color: .zenithMuted)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.zenithLow)
|
||||
}
|
||||
}
|
||||
79
app/ios/ScanReceipts/Design/ZenithTextField.swift
Normal file
79
app/ios/ScanReceipts/Design/ZenithTextField.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Input fields per `DESIGN (1).md` → Components → Input Fields: "1px solid
|
||||
/// #E2E8F0 bottom-border only... Use `label-caps` for field labels placed
|
||||
/// strictly above the input." Border thickens to 2px while focused — the
|
||||
/// same "shift, don't lift" interaction rule as `ZenithButtonStyle`.
|
||||
///
|
||||
/// Use this instead of a bare `TextField` + `.textFieldStyle(.roundedBorder)`
|
||||
/// everywhere in the app; `.roundedBorder` fights this design system
|
||||
/// directly (it's rounded and iOS-chrome-grey, the opposite of "sharp,
|
||||
/// monochrome, architectural").
|
||||
struct ZenithTextField: View {
|
||||
let label: String
|
||||
@Binding var text: String
|
||||
var placeholder: String = ""
|
||||
var isSecure = false
|
||||
var keyboardType: UIKeyboardType = .default
|
||||
var textContentType: UITextContentType?
|
||||
var autocapitalization: TextInputAutocapitalization = .sentences
|
||||
var autocorrectionDisabled = false
|
||||
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: ZenithSpacing.unit) {
|
||||
Text(label).zenithLabelCapsStyle()
|
||||
|
||||
Group {
|
||||
if isSecure {
|
||||
SecureField(placeholder, text: $text)
|
||||
} else {
|
||||
TextField(placeholder, text: $text)
|
||||
}
|
||||
}
|
||||
.font(.zenithBodyMd)
|
||||
.foregroundStyle(.zenithText)
|
||||
.keyboardType(keyboardType)
|
||||
.textContentType(textContentType)
|
||||
.textInputAutocapitalization(autocapitalization)
|
||||
.autocorrectionDisabled(autocorrectionDisabled)
|
||||
.focused($isFocused)
|
||||
.padding(.vertical, ZenithSpacing.unit * 2)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
.fill(isFocused ? Color.zenithBlack : Color.zenithBorder)
|
||||
.frame(height: isFocused ? 2 : 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A numeric variant bound to an optional `Double`, for amount fields — free
|
||||
/// text with comma-or-dot decimal entry (matches how receipts are actually
|
||||
/// typed in German usage) rather than a strict `TextField(value:format:)`
|
||||
/// that rejects a comma outright.
|
||||
struct ZenithAmountField: View {
|
||||
let label: String
|
||||
@Binding var value: Double?
|
||||
var placeholder: String = "0,00"
|
||||
|
||||
private var textProxy: Binding<String> {
|
||||
Binding(
|
||||
get: { value.map { String(format: "%.2f", $0) } ?? "" },
|
||||
set: { newValue in
|
||||
let normalized = newValue.replacingOccurrences(of: ",", with: ".")
|
||||
value = Double(normalized)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZenithTextField(
|
||||
label: label,
|
||||
text: textProxy,
|
||||
placeholder: placeholder,
|
||||
keyboardType: .decimalPad
|
||||
)
|
||||
}
|
||||
}
|
||||
93
app/ios/ScanReceipts/Design/ZenithTypography.swift
Normal file
93
app/ios/ScanReceipts/Design/ZenithTypography.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The web app's three-font type system (see `DESIGN (1).md` → Typography,
|
||||
/// and `tailwind.config.ts`'s `fontFamily`), ported with the SAME font
|
||||
/// files: `Resources/Fonts/*.ttf` are the real Hanken Grotesk / Inter /
|
||||
/// JetBrains Mono variable fonts (downloaded from the canonical
|
||||
/// google/fonts repository — the same open-source files the web app loads
|
||||
/// from Google Fonts' CDN), registered via `UIAppFonts` in `project.yml`.
|
||||
///
|
||||
/// PostScript names below were read directly out of each font file's `name`
|
||||
/// table (not guessed) — see the font files themselves if these ever need
|
||||
/// re-verifying after a font update.
|
||||
///
|
||||
/// - Hanken Grotesk → headlines, set tight (negative tracking) at large
|
||||
/// sizes for the "locked, architectural" feel the design system asks for.
|
||||
/// - Inter → body copy.
|
||||
/// - JetBrains Mono → labels, captions, technical/numeric data. `labelCaps`
|
||||
/// is additionally uppercased with wide tracking — apply
|
||||
/// `.zenithLabelCapsStyle()`, not just the bare font, or the case/tracking
|
||||
/// won't happen (SwiftUI doesn't derive that from the font itself).
|
||||
enum ZenithFont {
|
||||
static let display = "HankenGrotesk-Regular"
|
||||
static let body = "Inter-Regular"
|
||||
static let mono = "JetBrainsMono-Regular"
|
||||
}
|
||||
|
||||
extension Font {
|
||||
/// Rarely needed on iPhone (72pt) — kept for completeness / a future
|
||||
/// iPad or marketing surface.
|
||||
static var zenithDisplayLg: Font { .custom(ZenithFont.display, size: 72).weight(.bold) }
|
||||
|
||||
/// Screen/section titles. Sized at the web system's "headline-lg-mobile"
|
||||
/// (32/40) rather than the 48pt desktop size, since this app is
|
||||
/// iPhone-only (see project.yml's TARGETED_DEVICE_FAMILY).
|
||||
static var zenithHeadlineLg: Font { .custom(ZenithFont.display, size: 32).weight(.semibold) }
|
||||
|
||||
/// Card/sub-section titles.
|
||||
static var zenithHeadlineMd: Font { .custom(ZenithFont.display, size: 24).weight(.medium) }
|
||||
|
||||
static var zenithBodyLg: Font { .custom(ZenithFont.body, size: 18) }
|
||||
/// Default body/UI text size.
|
||||
static var zenithBodyMd: Font { .custom(ZenithFont.body, size: 16) }
|
||||
static var zenithBodySm: Font { .custom(ZenithFont.body, size: 14) }
|
||||
|
||||
/// Field labels, eyebrows, section headers — use with
|
||||
/// `.zenithLabelCapsStyle()` for the uppercase + tracking, not bare.
|
||||
static var zenithLabelCaps: Font { .custom(ZenithFont.mono, size: 12).weight(.medium) }
|
||||
/// Chips, technical/tabular data (amounts, dates, IDs).
|
||||
static var zenithLabelMd: Font { .custom(ZenithFont.mono, size: 14) }
|
||||
static var zenithLabelSm: Font { .custom(ZenithFont.mono, size: 12) }
|
||||
}
|
||||
|
||||
/// Complete text styles (font + tracking + default color) for each type
|
||||
/// role. Tracking values are the design system's em values
|
||||
/// (`DESIGN (1).md` → Typography) converted to points AT EACH ROLE'S OWN
|
||||
/// size — `SwiftUI.tracking(_:)` takes absolute points, not em, so these
|
||||
/// can't be a single generic modifier parameterized only by a ratio.
|
||||
/// Prefer these over composing `.font(.zenith...)` by hand.
|
||||
extension View {
|
||||
/// Screen/section titles (32pt, -0.02em ⇒ -0.64pt tracking).
|
||||
func zenithHeadlineLgStyle(color: Color = .zenithText) -> some View {
|
||||
font(.zenithHeadlineLg).tracking(-0.64).foregroundStyle(color)
|
||||
}
|
||||
|
||||
/// Card/sub-section titles (24pt, -0.01em ⇒ -0.24pt tracking).
|
||||
func zenithHeadlineMdStyle(color: Color = .zenithText) -> some View {
|
||||
font(.zenithHeadlineMd).tracking(-0.24).foregroundStyle(color)
|
||||
}
|
||||
|
||||
/// Default body text (16pt, 0 tracking).
|
||||
func zenithBodyStyle(color: Color = .zenithText) -> some View {
|
||||
font(.zenithBodyMd).foregroundStyle(color)
|
||||
}
|
||||
|
||||
func zenithBodySmStyle(color: Color = .zenithMuted) -> some View {
|
||||
font(.zenithBodySm).foregroundStyle(color)
|
||||
}
|
||||
|
||||
/// `label-caps` role: JetBrains Mono, uppercase, wide tracking
|
||||
/// (12pt, 0.1em ⇒ 1.2pt). This is the style used above every
|
||||
/// `ZenithTextField` and for chip/badge/eyebrow text.
|
||||
func zenithLabelCapsStyle(color: Color = .zenithSubtle) -> some View {
|
||||
font(.zenithLabelCaps)
|
||||
.textCase(.uppercase)
|
||||
.tracking(1.2)
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
|
||||
/// Technical/tabular data — amounts, dates, IDs (14pt, 0 tracking).
|
||||
func zenithLabelMdStyle(color: Color = .zenithText) -> some View {
|
||||
font(.zenithLabelMd).foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user