From 54431323dfee9cbc7d5d9299b1b5b867685227c4 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 14:30:16 +0300 Subject: [PATCH 1/4] feat: generate Swift design-token adapter --- .gitignore | 3 + AGENTS.md | 2 + Apps/iOS/Sources/PutioApp.swift | 13 +- Apps/tvOS/Sources/PutioTVApp.swift | 13 +- Apps/watchOS/Sources/PutioWatchApp.swift | 14 +- CONTRIBUTING.md | 14 +- .../Generated/PutioTheme+Generated.swift | 1144 +++++++++++++++++ .../PutioCoreTests/PutioThemeTests.swift | 22 + README.md | 5 + mise.toml | 6 + package.json | 18 + pnpm-lock.yaml | 248 ++++ scripts/bootstrap.sh | 1 + scripts/generate-design-tokens.test.ts | 29 + scripts/generate-design-tokens.ts | 596 +++++++++ scripts/test.sh | 1 + scripts/verify.sh | 1 + tsconfig.json | 12 + 18 files changed, 2130 insertions(+), 12 deletions(-) create mode 100644 Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift create mode 100644 Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 scripts/generate-design-tokens.test.ts create mode 100644 scripts/generate-design-tokens.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index f1bc95c..089b8dc 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ mise.local.toml .env .env.* !.env.example + +# JavaScript tooling +node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 2a3325c..c47e440 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ - `mise run doctor` - `mise run bootstrap` - `mise run generate` +- `mise run tokens` - `mise run test` - `mise run build` - `mise run verify` @@ -27,6 +28,7 @@ - Run `mise run bootstrap` in a fresh checkout or worktree - Run `mise run verify` before handoff - Change targets and settings in `Project.swift`, never in generated Xcode files +- Change design tokens in `putio-design`, then bump the locked package and regenerate; never edit `PutioTheme+Generated.swift` - Use Swift Package Manager for dependencies - Keep platform-specific UI, lifecycle, focus, playback, and download behavior in the matching app shell - Put only genuinely cross-platform models, session, API, and feature logic in `PutioCore` diff --git a/Apps/iOS/Sources/PutioApp.swift b/Apps/iOS/Sources/PutioApp.swift index 9c72160..a549289 100644 --- a/Apps/iOS/Sources/PutioApp.swift +++ b/Apps/iOS/Sources/PutioApp.swift @@ -19,16 +19,23 @@ struct PutioApp: App { } private struct SignedOutView: View { + @Environment(\.colorScheme) private var colorScheme + let presentation: SignedOutPresentation var body: some View { - VStack(spacing: 12) { + VStack(spacing: PutioTheme.Spacing.space3) { Text(presentation.title) - .font(.largeTitle.bold()) + .font(PutioTheme.Typography.title.font) + .lineSpacing(PutioTheme.Typography.title.lineSpacing) + .foregroundStyle(PutioTheme.Colors.text.resolve(for: colorScheme)) Text(presentation.message) - .foregroundStyle(.secondary) + .font(PutioTheme.Typography.body.font) + .lineSpacing(PutioTheme.Typography.body.lineSpacing) + .foregroundStyle(PutioTheme.Colors.textSecondary.resolve(for: colorScheme)) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(PutioTheme.Colors.appBg.resolve(for: colorScheme)) .preferredColorScheme(.dark) } } diff --git a/Apps/tvOS/Sources/PutioTVApp.swift b/Apps/tvOS/Sources/PutioTVApp.swift index 6e17d62..12e852f 100644 --- a/Apps/tvOS/Sources/PutioTVApp.swift +++ b/Apps/tvOS/Sources/PutioTVApp.swift @@ -19,16 +19,23 @@ struct PutioTVApp: App { } private struct SignedOutView: View { + @Environment(\.colorScheme) private var colorScheme + let presentation: SignedOutPresentation var body: some View { - VStack(spacing: 20) { + VStack(spacing: PutioTheme.TV.Spacing.small) { Text(presentation.title) - .font(.largeTitle.bold()) + .font(PutioTheme.TV.Typography.heading.font) + .lineSpacing(PutioTheme.TV.Typography.heading.lineSpacing) + .foregroundStyle(PutioTheme.TV.Colors.text1) Text(presentation.message) - .foregroundStyle(.secondary) + .font(PutioTheme.TV.Typography.body.font) + .lineSpacing(PutioTheme.TV.Typography.body.lineSpacing) + .foregroundStyle(PutioTheme.TV.Colors.text2) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(PutioTheme.Colors.appBg.resolve(for: colorScheme)) .preferredColorScheme(.dark) } } diff --git a/Apps/watchOS/Sources/PutioWatchApp.swift b/Apps/watchOS/Sources/PutioWatchApp.swift index 47f1c34..5820ddd 100644 --- a/Apps/watchOS/Sources/PutioWatchApp.swift +++ b/Apps/watchOS/Sources/PutioWatchApp.swift @@ -19,16 +19,22 @@ struct PutioWatchApp: App { } private struct SignedOutView: View { + @Environment(\.colorScheme) private var colorScheme + let presentation: SignedOutPresentation var body: some View { - VStack(spacing: 6) { + VStack(spacing: PutioTheme.Spacing.space1) { Text(presentation.title) - .font(.headline) + .font(PutioTheme.Typography.subheading.font) + .foregroundStyle(PutioTheme.Colors.text.resolve(for: colorScheme)) Text(presentation.message) - .font(.footnote) - .foregroundStyle(.secondary) + .font(PutioTheme.Typography.caption.font) + .foregroundStyle(PutioTheme.Colors.textSecondary.resolve(for: colorScheme)) .multilineTextAlignment(.center) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(PutioTheme.Colors.appBg.resolve(for: colorScheme)) + .preferredColorScheme(.dark) } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8ca81c0..ecda5c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,7 @@ mise install mise run bootstrap ``` -Bootstrap validates Xcode, installs the pinned Tuist release through mise, resolves package dependencies, and generates `Putio.xcworkspace`. It requires no Tuist login or private configuration. +Bootstrap validates Xcode, installs the pinned Tuist, Node.js, and pnpm releases through mise, resolves package dependencies, and generates `Putio.xcworkspace`. It requires no Tuist login or private configuration. For a machine-readable environment report: @@ -32,13 +32,23 @@ mise run open Source changes inside existing `buildableFolders` appear without regenerating. Regenerate after manifest or dependency-graph changes. +### Design tokens + +The committed `PutioTheme+Generated.swift` adapter comes from the exact `@putdotio/design` version in `pnpm-lock.yaml`. + +```bash +mise run tokens +``` + +Do not edit the generated Swift or token values directly. Update tokens in `putio-design`, bump the package version here, regenerate, and commit the lockfile and generated output together. The verification lane fails when they drift. + ## Verification ```bash mise run verify ``` -This runs the `PutioCore` and harness tests, then builds the iOS, watchOS, and tvOS schemes against generic simulators. Exercise the affected shell with the headless harness when a change alters runtime behavior. +This installs the locked token tooling, checks generated-output drift, runs the `PutioCore` and harness tests, then builds the iOS, watchOS, and tvOS schemes against generic simulators. Exercise the affected shell with the headless harness when a change alters runtime behavior. Use the headless harness for runtime-sensitive changes: diff --git a/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift b/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift new file mode 100644 index 0000000..366407b --- /dev/null +++ b/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift @@ -0,0 +1,1144 @@ +// Do not edit directly. Generated by scripts/generate-design-tokens.ts. +// Source: @putdotio/design 2.0.1 (449 tokens). + +import SwiftUI + +public struct PutioDynamicColor: Sendable { + public let light: Color + public let dark: Color + + public init(light: Color, dark: Color) { + self.light = light + self.dark = dark + } + + public func resolve(for colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? dark : light + } +} + +public struct PutioFontRole: Sendable { + public let family: String + public let size: CGFloat + public let weight: Font.Weight + public let lineHeight: CGFloat + + public init( + family: String, + size: CGFloat, + weight: Font.Weight, + lineHeight: CGFloat + ) { + self.family = family + self.size = size + self.weight = weight + self.lineHeight = lineHeight + } + + public var font: Font { + .custom(family, size: size).weight(weight) + } + + public var lineSpacing: CGFloat { + max(0, size * (lineHeight - 1)) + } +} + +public struct PutioCubicBezier: Equatable, Sendable { + public let x1: Double + public let y1: Double + public let x2: Double + public let y2: Double + + public init(x1: Double, y1: Double, x2: Double, y2: Double) { + self.x1 = x1 + self.y1 = y1 + self.x2 = x2 + self.y2 = y2 + } + + public func animation(duration: TimeInterval) -> Animation { + .timingCurve(x1, y1, x2, y2, duration: duration) + } +} + +public enum PutioTheme { + public static let sourcePackage = "@putdotio/design" + public static let sourceVersion = "2.0.1" + public static let sourceTokenCount = 449 + + public enum Colors { + public static let accent = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let accentForeground = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let appBg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 0.085, green: 0.085, blue: 0.085, opacity: 1.0) + ) + + public static let background = PutioDynamicColor( + light: Color(.sRGB, red: 0.99, green: 0.99, blue: 0.99, opacity: 1.0), + dark: Color(.sRGB, red: 0.085, green: 0.085, blue: 0.085, opacity: 1.0) + ) + + public static let bg = PutioDynamicColor( + light: Color(.sRGB, red: 0.99, green: 0.99, blue: 0.99, opacity: 1.0), + dark: Color(.sRGB, red: 0.085, green: 0.085, blue: 0.085, opacity: 1.0) + ) + + public static let bgSecondary = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.11, green: 0.11, blue: 0.11, opacity: 1.0) + ) + + public static let border = PutioDynamicColor( + light: Color(.sRGB, red: 0.858, green: 0.858, blue: 0.858, opacity: 1.0), + dark: Color(.sRGB, red: 0.243, green: 0.243, blue: 0.243, opacity: 1.0) + ) + + public static let borderHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.78, green: 0.78, blue: 0.78, opacity: 1.0), + dark: Color(.sRGB, red: 0.312, green: 0.312, blue: 0.312, opacity: 1.0) + ) + + public static let buttonDangerBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.8975, green: 0.2825, blue: 0.303, opacity: 1.0), + dark: Color(.sRGB, red: 0.8975, green: 0.2825, blue: 0.303, opacity: 1.0) + ) + + public static let buttonDangerBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.8402, green: 0.2198, blue: 0.25082, opacity: 1.0), + dark: Color(.sRGB, red: 0.8449, green: 0.2151, blue: 0.24659, opacity: 1.0) + ) + + public static let buttonDangerFg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0) + ) + + public static let buttonDefaultBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let buttonDefaultBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0), + dark: Color(.sRGB, red: 0.158, green: 0.158, blue: 0.158, opacity: 1.0) + ) + + public static let buttonDefaultFg = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let buttonGhostBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0) + ) + + public static let buttonGhostBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0), + dark: Color(.sRGB, red: 0.158, green: 0.158, blue: 0.158, opacity: 1.0) + ) + + public static let buttonGhostFg = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let buttonGhostFgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let buttonInfoBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.561, green: 0.561, blue: 0.561, opacity: 1.0), + dark: Color(.sRGB, red: 0.439, green: 0.439, blue: 0.439, opacity: 1.0) + ) + + public static let buttonInfoBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.523, green: 0.523, blue: 0.523, opacity: 1.0), + dark: Color(.sRGB, red: 0.494, green: 0.494, blue: 0.494, opacity: 1.0) + ) + + public static let buttonInfoFg = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0) + ) + + public static let buttonPrimaryBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.992251, green: 0.808013, blue: 0.269749, opacity: 1.0), + dark: Color(.sRGB, red: 0.992251, green: 0.808013, blue: 0.269749, opacity: 1.0) + ) + + public static let buttonPrimaryBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.9538, green: 0.7669, blue: 0.2062, opacity: 1.0), + dark: Color(.sRGB, red: 0.9538, green: 0.7669, blue: 0.2062, opacity: 1.0) + ) + + public static let buttonPrimaryFg = PutioDynamicColor( + light: Color(.sRGB, red: 0.165, green: 0.117333, blue: 0.035, opacity: 1.0), + dark: Color(.sRGB, red: 0.165, green: 0.117333, blue: 0.035, opacity: 1.0) + ) + + public static let buttonSecondaryBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let buttonSecondaryBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0), + dark: Color(.sRGB, red: 0.158, green: 0.158, blue: 0.158, opacity: 1.0) + ) + + public static let buttonSecondaryFg = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let buttonSuccessBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.18675, green: 0.64325, blue: 0.422608, opacity: 1.0), + dark: Color(.sRGB, red: 0.18675, green: 0.64325, blue: 0.422608, opacity: 1.0) + ) + + public static let buttonSuccessBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.1554, green: 0.5846, blue: 0.377153, opacity: 1.0), + dark: Color(.sRGB, red: 0.112, green: 0.588, blue: 0.357933, opacity: 1.0) + ) + + public static let buttonSuccessFg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0) + ) + + public static let card = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let cardForeground = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let componentBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let componentBgActive = PutioDynamicColor( + light: Color(.sRGB, red: 0.909, green: 0.909, blue: 0.909, opacity: 1.0), + dark: Color(.sRGB, red: 0.179, green: 0.179, blue: 0.179, opacity: 1.0) + ) + + public static let componentBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0), + dark: Color(.sRGB, red: 0.158, green: 0.158, blue: 0.158, opacity: 1.0) + ) + + public static let destructive = PutioDynamicColor( + light: Color(.sRGB, red: 0.8975, green: 0.2825, blue: 0.303, opacity: 1.0), + dark: Color(.sRGB, red: 0.8975, green: 0.2825, blue: 0.303, opacity: 1.0) + ) + + public static let destructiveForeground = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0) + ) + + public static let fieldBg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 0.085, green: 0.085, blue: 0.085, opacity: 1.0) + ) + + public static let fieldBgDisabled = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.11, green: 0.11, blue: 0.11, opacity: 1.0) + ) + + public static let fieldBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.858, green: 0.858, blue: 0.858, opacity: 1.0), + dark: Color(.sRGB, red: 0.243, green: 0.243, blue: 0.243, opacity: 1.0) + ) + + public static let fieldBorderFocus = PutioDynamicColor( + light: Color(.sRGB, red: 0.78, green: 0.78, blue: 0.78, opacity: 1.0), + dark: Color(.sRGB, red: 0.312, green: 0.312, blue: 0.312, opacity: 1.0) + ) + + public static let fieldBorderHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.78, green: 0.78, blue: 0.78, opacity: 1.0), + dark: Color(.sRGB, red: 0.312, green: 0.312, blue: 0.312, opacity: 1.0) + ) + + public static let fieldPlaceholder = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let fieldText = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let fileRowBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0) + ) + + public static let fileRowBgActive = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.11, green: 0.11, blue: 0.11, opacity: 1.0) + ) + + public static let fileRowBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.11, green: 0.11, blue: 0.11, opacity: 1.0) + ) + + public static let fileRowBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.909, green: 0.909, blue: 0.909, opacity: 1.0), + dark: Color(.sRGB, red: 0.179, green: 0.179, blue: 0.179, opacity: 1.0) + ) + + public static let fileRowIcon = PutioDynamicColor( + light: Color(.sRGB, red: 0.992251, green: 0.808013, blue: 0.269749, opacity: 1.0), + dark: Color(.sRGB, red: 0.992251, green: 0.808013, blue: 0.269749, opacity: 1.0) + ) + + public static let foreground = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let foregroundMuted = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let greenBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.984, green: 0.996, blue: 0.988, opacity: 1.0), + dark: Color(.sRGB, red: 0.0522, green: 0.0678, blue: 0.0574, opacity: 1.0) + ) + + public static let greenBgSecondary = PutioDynamicColor( + light: Color(.sRGB, red: 0.9577, green: 0.9823, blue: 0.9659, opacity: 1.0), + dark: Color(.sRGB, red: 0.0711, green: 0.1089, blue: 0.08937, opacity: 1.0) + ) + + public static let greenBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.5552, green: 0.8048, blue: 0.6384, opacity: 1.0), + dark: Color(.sRGB, red: 0.1711, green: 0.4089, blue: 0.274147, opacity: 1.0) + ) + + public static let greenBorderHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.3745, green: 0.7255, blue: 0.5266, opacity: 1.0), + dark: Color(.sRGB, red: 0.1938, green: 0.4862, blue: 0.330253, opacity: 1.0) + ) + + public static let greenComponentBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.8929, green: 0.9671, blue: 0.91145, opacity: 1.0), + dark: Color(.sRGB, red: 0.0845, green: 0.1755, blue: 0.131517, opacity: 1.0) + ) + + public static let greenComponentBgActive = PutioDynamicColor( + light: Color(.sRGB, red: 0.76, green: 0.92, blue: 0.813333, opacity: 1.0), + dark: Color(.sRGB, red: 0.1026, green: 0.2774, blue: 0.187087, opacity: 1.0) + ) + + public static let greenComponentBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.8262, green: 0.9538, blue: 0.86448, opacity: 1.0), + dark: Color(.sRGB, red: 0.0816, green: 0.2384, blue: 0.157387, opacity: 1.0) + ) + + public static let greenLine = PutioDynamicColor( + light: Color(.sRGB, red: 0.6619, green: 0.8781, blue: 0.73757, opacity: 1.0), + dark: Color(.sRGB, red: 0.1392, green: 0.3408, blue: 0.22656, opacity: 1.0) + ) + + public static let greenSolid = PutioDynamicColor( + light: Color(.sRGB, red: 0.18675, green: 0.64325, blue: 0.422608, opacity: 1.0), + dark: Color(.sRGB, red: 0.18675, green: 0.64325, blue: 0.422608, opacity: 1.0) + ) + + public static let greenSolidHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.1554, green: 0.5846, blue: 0.377153, opacity: 1.0), + dark: Color(.sRGB, red: 0.112, green: 0.588, blue: 0.357933, opacity: 1.0) + ) + + public static let greenText = PutioDynamicColor( + light: Color(.sRGB, red: 0.1105, green: 0.2295, blue: 0.162067, opacity: 1.0), + dark: Color(.sRGB, red: 0.6922, green: 0.9478, blue: 0.78592, opacity: 1.0) + ) + + public static let greenTextSecondary = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.5, blue: 0.291667, opacity: 1.0), + dark: Color(.sRGB, red: 0.376, green: 0.824, blue: 0.570133, opacity: 1.0) + ) + + public static let hiContrast = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let htmlBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 1.0) + ) + + public static let input = PutioDynamicColor( + light: Color(.sRGB, red: 0.858, green: 0.858, blue: 0.858, opacity: 1.0), + dark: Color(.sRGB, red: 0.243, green: 0.243, blue: 0.243, opacity: 1.0) + ) + + public static let inputBg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 0.085, green: 0.085, blue: 0.085, opacity: 1.0) + ) + + public static let inputBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.858, green: 0.858, blue: 0.858, opacity: 1.0), + dark: Color(.sRGB, red: 0.243, green: 0.243, blue: 0.243, opacity: 1.0) + ) + + public static let inputBorderFocus = PutioDynamicColor( + light: Color(.sRGB, red: 0.78, green: 0.78, blue: 0.78, opacity: 1.0), + dark: Color(.sRGB, red: 0.312, green: 0.312, blue: 0.312, opacity: 1.0) + ) + + public static let inputBorderHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.78, green: 0.78, blue: 0.78, opacity: 1.0), + dark: Color(.sRGB, red: 0.312, green: 0.312, blue: 0.312, opacity: 1.0) + ) + + public static let inputPlaceholder = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let inputText = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let invertForeground = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 0.085, green: 0.085, blue: 0.085, opacity: 1.0) + ) + + public static let lime3 = PutioDynamicColor( + light: Color(.sRGB, red: 0.932753, green: 0.98152, blue: 0.86448, opacity: 1.0), + dark: Color(.sRGB, red: 0.129, green: 0.174, blue: 0.066, opacity: 1.0) + ) + + public static let line = PutioDynamicColor( + light: Color(.sRGB, red: 0.887, green: 0.887, blue: 0.887, opacity: 1.0), + dark: Color(.sRGB, red: 0.205, green: 0.205, blue: 0.205, opacity: 1.0) + ) + + public static let listItemBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0) + ) + + public static let listItemBgActive = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.11, green: 0.11, blue: 0.11, opacity: 1.0) + ) + + public static let listItemBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.11, green: 0.11, blue: 0.11, opacity: 1.0) + ) + + public static let listItemBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.909, green: 0.909, blue: 0.909, opacity: 1.0), + dark: Color(.sRGB, red: 0.179, green: 0.179, blue: 0.179, opacity: 1.0) + ) + + public static let loContrast = PutioDynamicColor( + light: Color(.sRGB, red: 0.99, green: 0.99, blue: 0.99, opacity: 1.0), + dark: Color(.sRGB, red: 0.085, green: 0.085, blue: 0.085, opacity: 1.0) + ) + + public static let menuBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let menuBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.887, green: 0.887, blue: 0.887, opacity: 1.0), + dark: Color(.sRGB, red: 0.205, green: 0.205, blue: 0.205, opacity: 1.0) + ) + + public static let menuItemBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0), + dark: Color(.sRGB, red: 0.158, green: 0.158, blue: 0.158, opacity: 1.0) + ) + + public static let menuItemDangerBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.922667, blue: 0.92, opacity: 1.0), + dark: Color(.sRGB, red: 0.2325, green: 0.0675, blue: 0.0675, opacity: 1.0) + ) + + public static let menuItemDangerText = PutioDynamicColor( + light: Color(.sRGB, red: 0.8036, green: 0.1764, blue: 0.228667, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 0.555333, blue: 0.54, opacity: 1.0) + ) + + public static let menuItemIcon = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let menuItemShortcut = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let menuItemText = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let menuLabel = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let menuSeparator = PutioDynamicColor( + light: Color(.sRGB, red: 0.887, green: 0.887, blue: 0.887, opacity: 1.0), + dark: Color(.sRGB, red: 0.205, green: 0.205, blue: 0.205, opacity: 1.0) + ) + + public static let muted = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.11, green: 0.11, blue: 0.11, opacity: 1.0) + ) + + public static let mutedForeground = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let navBg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 0.085, green: 0.085, blue: 0.085, opacity: 1.0) + ) + + public static let navItemBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0) + ) + + public static let navItemBgActive = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let navItemBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let notificationBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let notificationBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.887, green: 0.887, blue: 0.887, opacity: 1.0), + dark: Color(.sRGB, red: 0.205, green: 0.205, blue: 0.205, opacity: 1.0) + ) + + public static let notificationDangerBg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.922667, blue: 0.92, opacity: 1.0), + dark: Color(.sRGB, red: 0.2325, green: 0.0675, blue: 0.0675, opacity: 1.0) + ) + + public static let notificationDangerBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.962, green: 0.6488, blue: 0.638, opacity: 1.0), + dark: Color(.sRGB, red: 0.5548, green: 0.2052, blue: 0.2052, opacity: 1.0) + ) + + public static let notificationDangerFg = PutioDynamicColor( + light: Color(.sRGB, red: 0.3888, green: 0.0912, blue: 0.10112, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 0.835, blue: 0.82, opacity: 1.0) + ) + + public static let notificationFg = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let notificationIcon = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let notificationInfoBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let notificationSuccessBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.9577, green: 0.9823, blue: 0.9659, opacity: 1.0), + dark: Color(.sRGB, red: 0.0711, green: 0.1089, blue: 0.08937, opacity: 1.0) + ) + + public static let notificationSuccessBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.5552, green: 0.8048, blue: 0.6384, opacity: 1.0), + dark: Color(.sRGB, red: 0.1711, green: 0.4089, blue: 0.274147, opacity: 1.0) + ) + + public static let notificationSuccessFg = PutioDynamicColor( + light: Color(.sRGB, red: 0.1105, green: 0.2295, blue: 0.162067, opacity: 1.0), + dark: Color(.sRGB, red: 0.6922, green: 0.9478, blue: 0.78592, opacity: 1.0) + ) + + public static let overlayFull = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.439), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.565) + ) + + public static let overlayInline = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.047), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.439) + ) + + public static let paletteBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let paletteBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.887, green: 0.887, blue: 0.887, opacity: 1.0), + dark: Color(.sRGB, red: 0.205, green: 0.205, blue: 0.205, opacity: 1.0) + ) + + public static let paletteGroupLabel = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let paletteItemBgActive = PutioDynamicColor( + light: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0), + dark: Color(.sRGB, red: 0.158, green: 0.158, blue: 0.158, opacity: 1.0) + ) + + public static let paletteItemMeta = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let paletteScrim = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.439), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.565) + ) + + public static let panelBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let panelBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.887, green: 0.887, blue: 0.887, opacity: 1.0), + dark: Color(.sRGB, red: 0.205, green: 0.205, blue: 0.205, opacity: 1.0) + ) + + public static let popover = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let popoverForeground = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let primary = PutioDynamicColor( + light: Color(.sRGB, red: 0.992251, green: 0.808013, blue: 0.269749, opacity: 1.0), + dark: Color(.sRGB, red: 0.992251, green: 0.808013, blue: 0.269749, opacity: 1.0) + ) + + public static let primaryForeground = PutioDynamicColor( + light: Color(.sRGB, red: 0.165, green: 0.117333, blue: 0.035, opacity: 1.0), + dark: Color(.sRGB, red: 0.165, green: 0.117333, blue: 0.035, opacity: 1.0) + ) + + public static let redBg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.98, blue: 0.98, opacity: 1.0), + dark: Color(.sRGB, red: 0.0791, green: 0.063327, blue: 0.0609, opacity: 1.0) + ) + + public static let redBgSecondary = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.96, blue: 0.96, opacity: 1.0), + dark: Color(.sRGB, red: 0.128, green: 0.075733, blue: 0.072, opacity: 1.0) + ) + + public static let redBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.962, green: 0.6488, blue: 0.638, opacity: 1.0), + dark: Color(.sRGB, red: 0.5548, green: 0.2052, blue: 0.2052, opacity: 1.0) + ) + + public static let redBorderHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.9217, green: 0.55108, blue: 0.5383, opacity: 1.0), + dark: Color(.sRGB, red: 0.7105, green: 0.2695, blue: 0.27685, opacity: 1.0) + ) + + public static let redComponentBg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.922667, blue: 0.92, opacity: 1.0), + dark: Color(.sRGB, red: 0.2325, green: 0.0675, blue: 0.0675, opacity: 1.0) + ) + + public static let redComponentBgActive = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.791, blue: 0.78, opacity: 1.0), + dark: Color(.sRGB, red: 0.3888, green: 0.0912, blue: 0.10112, opacity: 1.0) + ) + + public static let redComponentBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.869333, blue: 0.86, opacity: 1.0), + dark: Color(.sRGB, red: 0.323, green: 0.057, blue: 0.0703, opacity: 1.0) + ) + + public static let redLine = PutioDynamicColor( + light: Color(.sRGB, red: 0.9958, green: 0.73778, blue: 0.7242, opacity: 1.0), + dark: Color(.sRGB, red: 0.456, green: 0.144, blue: 0.1492, opacity: 1.0) + ) + + public static let redSolid = PutioDynamicColor( + light: Color(.sRGB, red: 0.8975, green: 0.2825, blue: 0.303, opacity: 1.0), + dark: Color(.sRGB, red: 0.8975, green: 0.2825, blue: 0.303, opacity: 1.0) + ) + + public static let redSolidHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.8402, green: 0.2198, blue: 0.25082, opacity: 1.0), + dark: Color(.sRGB, red: 0.8449, green: 0.2151, blue: 0.24659, opacity: 1.0) + ) + + public static let redText = PutioDynamicColor( + light: Color(.sRGB, red: 0.3888, green: 0.0912, blue: 0.10112, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 0.835, blue: 0.82, opacity: 1.0) + ) + + public static let redTextSecondary = PutioDynamicColor( + light: Color(.sRGB, red: 0.8036, green: 0.1764, blue: 0.228667, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 0.555333, blue: 0.54, opacity: 1.0) + ) + + public static let ring = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.848333, blue: 0.3, opacity: 0.35), + dark: Color(.sRGB, red: 1.0, green: 0.848333, blue: 0.3, opacity: 0.35) + ) + + public static let segmentedControlBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.085, green: 0.085, blue: 0.085, opacity: 1.0) + ) + + public static let segmentedControlBgActive = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let shadow = PutioDynamicColor( + light: Color(.sRGB, red: 0.78, green: 0.78, blue: 0.78, opacity: 1.0), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 1.0) + ) + + public static let shadowColor = PutioDynamicColor( + light: Color(.sRGB, red: 0.78, green: 0.78, blue: 0.78, opacity: 1.0), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 1.0) + ) + + public static let shadowFocusColor = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.848333, blue: 0.3, opacity: 0.35), + dark: Color(.sRGB, red: 1.0, green: 0.848333, blue: 0.3, opacity: 0.35) + ) + + public static let sheetBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.951, green: 0.951, blue: 0.951, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let sheetBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.887, green: 0.887, blue: 0.887, opacity: 1.0), + dark: Color(.sRGB, red: 0.205, green: 0.205, blue: 0.205, opacity: 1.0) + ) + + public static let sheetScrim = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.439), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.565) + ) + + public static let solid = PutioDynamicColor( + light: Color(.sRGB, red: 0.561, green: 0.561, blue: 0.561, opacity: 1.0), + dark: Color(.sRGB, red: 0.439, green: 0.439, blue: 0.439, opacity: 1.0) + ) + + public static let solidForeground = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0) + ) + + public static let solidHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.523, green: 0.523, blue: 0.523, opacity: 1.0), + dark: Color(.sRGB, red: 0.494, green: 0.494, blue: 0.494, opacity: 1.0) + ) + + public static let success = PutioDynamicColor( + light: Color(.sRGB, red: 0.18675, green: 0.64325, blue: 0.422608, opacity: 1.0), + dark: Color(.sRGB, red: 0.18675, green: 0.64325, blue: 0.422608, opacity: 1.0) + ) + + public static let successForeground = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 1.0, blue: 1.0, opacity: 1.0) + ) + + public static let text = PutioDynamicColor( + light: Color(.sRGB, red: 0.09, green: 0.09, blue: 0.09, opacity: 1.0), + dark: Color(.sRGB, red: 0.93, green: 0.93, blue: 0.93, opacity: 1.0) + ) + + public static let textSecondary = PutioDynamicColor( + light: Color(.sRGB, red: 0.435, green: 0.435, blue: 0.435, opacity: 1.0), + dark: Color(.sRGB, red: 0.628, green: 0.628, blue: 0.628, opacity: 1.0) + ) + + public static let transferListItemCompletingBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.932753, green: 0.98152, blue: 0.86448, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let transferListItemDownloadingBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.932753, green: 0.98152, blue: 0.86448, opacity: 1.0), + dark: Color(.sRGB, red: 0.136, green: 0.136, blue: 0.136, opacity: 1.0) + ) + + public static let transferListItemFinishedBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.973, green: 0.973, blue: 0.973, opacity: 1.0), + dark: Color(.sRGB, red: 0.11, green: 0.11, blue: 0.11, opacity: 1.0) + ) + + public static let transferListItemPassiveBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.99, green: 0.99, blue: 0.99, opacity: 1.0), + dark: Color(.sRGB, red: 0.11, green: 0.11, blue: 0.11, opacity: 1.0) + ) + + public static let transparent = PutioDynamicColor( + light: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 0.0) + ) + + public static let yellowBg = PutioDynamicColor( + light: Color(.sRGB, red: 0.996, green: 0.993, blue: 0.984, opacity: 1.0), + dark: Color(.sRGB, red: 0.0, green: 0.0, blue: 0.0, opacity: 1.0) + ) + + public static let yellowBgSecondary = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.990667, blue: 0.92, opacity: 1.0), + dark: Color(.sRGB, red: 0.081, green: 0.0656, blue: 0.039, opacity: 1.0) + ) + + public static let yellowBorder = PutioDynamicColor( + light: Color(.sRGB, red: 0.8911, green: 0.78055, blue: 0.4489, opacity: 1.0), + dark: Color(.sRGB, red: 0.3978, green: 0.3289, blue: 0.1222, opacity: 1.0) + ) + + public static let yellowBorderHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.8152, green: 0.679093, blue: 0.3048, opacity: 1.0), + dark: Color(.sRGB, red: 0.5066, green: 0.4233, blue: 0.1734, opacity: 1.0) + ) + + public static let yellowComponentBg = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.968, blue: 0.76, opacity: 1.0), + dark: Color(.sRGB, red: 0.153, green: 0.1215, blue: 0.027, opacity: 1.0) + ) + + public static let yellowComponentBgActive = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.904, blue: 0.52, opacity: 1.0), + dark: Color(.sRGB, red: 0.28, green: 0.205333, blue: 0.0, opacity: 1.0) + ) + + public static let yellowComponentBgHover = PutioDynamicColor( + light: Color(.sRGB, red: 1.0, green: 0.94, blue: 0.64, opacity: 1.0), + dark: Color(.sRGB, red: 0.22, green: 0.161333, blue: 0.0, opacity: 1.0) + ) + + public static let yellowLine = PutioDynamicColor( + light: Color(.sRGB, red: 0.9649, green: 0.85528, blue: 0.4951, opacity: 1.0), + dark: Color(.sRGB, red: 0.3249, green: 0.25745, blue: 0.0551, opacity: 1.0) + ) + + public static let yellowSolid = PutioDynamicColor( + light: Color(.sRGB, red: 0.992251, green: 0.808013, blue: 0.269749, opacity: 1.0), + dark: Color(.sRGB, red: 0.992251, green: 0.808013, blue: 0.269749, opacity: 1.0) + ) + + public static let yellowSolidHover = PutioDynamicColor( + light: Color(.sRGB, red: 0.9538, green: 0.7669, blue: 0.2062, opacity: 1.0), + dark: Color(.sRGB, red: 0.9538, green: 0.7669, blue: 0.2062, opacity: 1.0) + ) + + public static let yellowText = PutioDynamicColor( + light: Color(.sRGB, red: 0.2772, green: 0.23912, blue: 0.1428, opacity: 1.0), + dark: Color(.sRGB, red: 0.9846, green: 0.926453, blue: 0.7354, opacity: 1.0) + ) + + public static let yellowTextSecondary = PutioDynamicColor( + light: Color(.sRGB, red: 0.54, green: 0.414, blue: 0.0, opacity: 1.0), + dark: Color(.sRGB, red: 1.0, green: 0.82, blue: 0.28, opacity: 1.0) + ) + } + + public enum Typography { + public static let familySans = "GT America" + public static let familyDisplay = "GT America" + public static let familyMono = "Berkeley Mono" + + public static let sizeXs: CGFloat = 12.0 + public static let sizeSm: CGFloat = 14.0 + public static let sizeBase: CGFloat = 16.0 + public static let sizeMd: CGFloat = 18.0 + public static let sizeLg: CGFloat = 24.0 + public static let sizeXl: CGFloat = 32.0 + public static let size2xl: CGFloat = 48.0 + public static let size3xl: CGFloat = 64.0 + public static let sizeDisplay: CGFloat = 96.0 + + public static let caption = PutioFontRole( + family: familySans, + size: sizeSm, + weight: .regular, + lineHeight: 1.45 + ) + public static let body = PutioFontRole( + family: familySans, + size: sizeBase, + weight: .regular, + lineHeight: 1.45 + ) + public static let subheading = PutioFontRole( + family: familySans, + size: sizeMd, + weight: .medium, + lineHeight: 1.25 + ) + public static let heading = PutioFontRole( + family: familySans, + size: sizeLg, + weight: .bold, + lineHeight: 1.1 + ) + public static let title = PutioFontRole( + family: familySans, + size: sizeXl, + weight: .bold, + lineHeight: 1.1 + ) + public static let display = PutioFontRole( + family: familyDisplay, + size: sizeDisplay, + weight: .black, + lineHeight: 1.1 + ) + #if !os(tvOS) + public static let mono = PutioFontRole( + family: familyMono, + size: sizeSm, + weight: .regular, + lineHeight: 1.45 + ) + #endif + } + + public enum Spacing { + public static let space0: CGFloat = 0.0 + public static let space1: CGFloat = 4.0 + public static let space2: CGFloat = 8.0 + public static let space3: CGFloat = 16.0 + public static let space4: CGFloat = 32.0 + public static let space5: CGFloat = 64.0 + public static let space6: CGFloat = 128.0 + public static let space7: CGFloat = 256.0 + public static let space8: CGFloat = 512.0 + } + + public enum Radius { + public static let small: CGFloat = 4.0 + public static let standard: CGFloat = 6.0 + public static let medium: CGFloat = 8.0 + public static let large: CGFloat = 10.0 + public static let pill: CGFloat = 999.0 + } + + public enum Border { + public static let width: CGFloat = 1.0 + } + + public enum Motion { + public static let durationFast: TimeInterval = 0.12 + public static let durationBase: TimeInterval = 0.2 + public static let durationSlow: TimeInterval = 0.32 + public static let easingOut = PutioCubicBezier( + x1: 0.22, + y1: 1.0, + x2: 0.36, + y2: 1.0 + ) + public static let easingInOut = PutioCubicBezier( + x1: 0.65, + y1: 0.0, + x2: 0.35, + y2: 1.0 + ) + } + + #if os(tvOS) + public enum TV { + public enum Colors { + public static let text1 = Color( + .sRGB, + red: 0.93, + green: 0.93, + blue: 0.93, + opacity: 1.0 + ) + public static let text2 = Color( + .sRGB, + red: 0.628, + green: 0.628, + blue: 0.628, + opacity: 1.0 + ) + public static let text3 = Color( + .sRGB, + red: 0.628, + green: 0.628, + blue: 0.628, + opacity: 1.0 + ) + public static let tvChannelArtDevelopmentAccent = Color( + .sRGB, + red: 0.624, + green: 0.835187, + blue: 1.0, + opacity: 1.0 + ) + public static let tvChannelArtDevelopmentBg = Color( + .sRGB, + red: 0.14176, + green: 0.561606, + blue: 0.89824, + opacity: 1.0 + ) + public static let tvChannelArtDevelopmentGrid = Color( + .sRGB, + red: 1.0, + green: 1.0, + blue: 1.0, + opacity: 0.18 + ) + public static let tvChannelArtLabAccent = Color( + .sRGB, + red: 0.222447, + green: 1.0, + blue: 0.078, + opacity: 1.0 + ) + public static let tvChannelArtLabBg = Color( + .sRGB, + red: 0.0392, + green: 0.0588, + blue: 0.0392, + opacity: 1.0 + ) + public static let tvChannelArtLabFg = Color( + .sRGB, + red: 0.909723, + green: 1.0, + blue: 0.894, + opacity: 1.0 + ) + public static let tvChannelArtProductionPosterBg = Color( + .sRGB, + red: 0.2, + green: 0.2, + blue: 0.2, + opacity: 1.0 + ) + public static let tvChannelArtProductionSplashBg = Color( + .sRGB, + red: 0.085, + green: 0.085, + blue: 0.085, + opacity: 1.0 + ) + } + + public enum Typography { + public static let heading = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: 64.0, + weight: .medium, + lineHeight: 1.1 + ) + public static let label = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: 48.0, + weight: .medium, + lineHeight: 1.25 + ) + public static let body = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: 36.0, + weight: .regular, + lineHeight: 1.45 + ) + public static let caption = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: 32.0, + weight: .regular, + lineHeight: 1.45 + ) + public static let small = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: 24.0, + weight: .regular, + lineHeight: 1.45 + ) + } + + public enum Spacing { + public static let xxs: CGFloat = 4.0 + public static let xs: CGFloat = 8.0 + public static let small: CGFloat = 16.0 + public static let medium: CGFloat = 32.0 + public static let large: CGFloat = 64.0 + public static let xl: CGFloat = 128.0 + public static let xxl: CGFloat = 256.0 + } + + public enum Overscan { + public static let horizontal = 0.04 + public static let vertical = 0.02 + } + + public static let radius: CGFloat = 12.0 + + public enum ZIndex { + public static let modal = 100.0 + public static let toast = 200.0 + public static let overlay = 300.0 + } + } + #endif +} diff --git a/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift b/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift new file mode 100644 index 0000000..39f1e98 --- /dev/null +++ b/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift @@ -0,0 +1,22 @@ +import XCTest + +@testable import PutioCore + +final class PutioThemeTests: XCTestCase { + func testGeneratedThemeIdentifiesItsPinnedSource() { + XCTAssertEqual(PutioTheme.sourcePackage, "@putdotio/design") + XCTAssertEqual(PutioTheme.sourceVersion, "2.0.1") + XCTAssertEqual(PutioTheme.sourceTokenCount, 449) + } + + func testGeneratedScalesPreservePublishedValues() { + XCTAssertEqual(PutioTheme.Spacing.space3, 16) + XCTAssertEqual(PutioTheme.Radius.standard, 6) + XCTAssertEqual(PutioTheme.Border.width, 1) + XCTAssertEqual(PutioTheme.Motion.durationBase, 0.2) + XCTAssertEqual( + PutioTheme.Motion.easingOut, + PutioCubicBezier(x1: 0.22, y1: 1, x2: 0.36, y2: 1) + ) + } +} diff --git a/README.md b/README.md index 0c9caa4..4b55f6a 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,17 @@ No Tuist account, application secret, or signing material is required. ```bash mise run generate # regenerate Putio.xcworkspace without opening Xcode +mise run tokens # regenerate the committed Swift design-token adapter mise run open # regenerate and open the workspace mise run test # format-check and test PutioCore mise run build # build all three app shells mise run verify # test PutioCore and build all three app shells ``` +## Design tokens + +`PutioCore` exposes the generated `PutioTheme` API consumed by every app shell. Its source is the exact `@putdotio/design` version in `pnpm-lock.yaml`; `mise run verify` rejects stale generated Swift. Change token values in the design-system repository, bump the package here, then run `mise run tokens`. + ## Development identities - iOS: `io.put.dev.ios` diff --git a/mise.toml b/mise.toml index ce0a185..9a1069f 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,6 @@ [tools] +node = "24.18.0" +pnpm = "11.2.2" tuist = "4.203.4" [tasks.doctor] @@ -13,6 +15,10 @@ run = "./scripts/bootstrap.sh" description = "Generate the Xcode workspace without opening Xcode" run = "./scripts/generate.sh" +[tasks.tokens] +description = "Regenerate the Swift design-token adapter" +run = "pnpm run tokens:generate" + [tasks.test] description = "Format-check and test the shared core" run = "./scripts/test.sh" diff --git a/package.json b/package.json new file mode 100644 index 0000000..4427611 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "putio-apple-tooling", + "private": true, + "type": "module", + "scripts": { + "tokens:generate": "node scripts/generate-design-tokens.ts", + "tokens:check": "node scripts/generate-design-tokens.ts --check", + "test": "node --test scripts/generate-design-tokens.test.ts", + "typecheck": "tsc --noEmit", + "verify": "pnpm typecheck && pnpm test && pnpm tokens:check" + }, + "devDependencies": { + "@putdotio/design": "2.0.1", + "@types/node": "24.13.3", + "typescript": "7.0.2" + }, + "packageManager": "pnpm@11.2.2" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7bcc4ad --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,248 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@putdotio/design': + specifier: 2.0.1 + version: 2.0.1 + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + typescript: + specifier: 7.0.2 + version: 7.0.2 + +packages: + + '@putdotio/design@2.0.1': + resolution: {integrity: sha512-ZXy7opHsluAtDMgFp5SaZWUnsjsX9PNJTivOgCcHpVU487SHoMsDcDe8tonSRQtImVST82NRKaKOdBX8CgxjkQ==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + +snapshots: + + '@putdotio/design@2.0.1': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@7.18.2: {} diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 92662fe..f38f5fa 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -4,5 +4,6 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo_root" +pnpm install --frozen-lockfile ./scripts/generate.sh ./scripts/harness.sh doctor diff --git a/scripts/generate-design-tokens.test.ts b/scripts/generate-design-tokens.test.ts new file mode 100644 index 0000000..bbb1e31 --- /dev/null +++ b/scripts/generate-design-tokens.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseTokens } from "./generate-design-tokens.ts"; + +const token = ( + type: string, + mode: string, + value: string | number, + cssName: string, +) => ({ cssName, type, mode, value, originalValue: value }); + +test("rejects malformed token records at the package boundary", () => { + assert.throws( + () => parseTokens({ "color.brand": token("mystery", "global", "#fff", "--brand") }), + /unsupported type/, + ); +}); + +test("preserves TV mode and viewport basis for platform-scoped generation", () => { + const [entry] = parseTokens({ + "tv.overscan.x": { + ...token("number", "tv", 0.04, "--tv-overscan-x"), + basis: "viewport-width", + }, + }); + assert.equal(entry?.token.mode, "tv"); + assert.equal(entry?.token.basis, "viewport-width"); +}); diff --git a/scripts/generate-design-tokens.ts b/scripts/generate-design-tokens.ts new file mode 100644 index 0000000..914b7e1 --- /dev/null +++ b/scripts/generate-design-tokens.ts @@ -0,0 +1,596 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const tokenTypes = [ + "color", + "cubicBezier", + "dimension", + "duration", + "fontFamily", + "fontWeight", + "number", + "string", +] as const; +const tokenModes = ["light", "dark", "global", "tv"] as const; + +type TokenType = (typeof tokenTypes)[number]; +type TokenMode = (typeof tokenModes)[number]; + +export type DesignToken = { + readonly cssName: string; + readonly type: TokenType; + readonly mode: TokenMode; + readonly value: string | number; + readonly originalValue: string | number; + readonly description?: string; + readonly basis?: "viewport-width" | "viewport-height"; + readonly deprecated?: boolean; +}; + +export type TokenEntry = { + readonly name: string; + readonly token: DesignToken; +}; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const packageRoot = path.join(repositoryRoot, "node_modules", "@putdotio", "design"); +const inputPath = path.join(packageRoot, "dist", "tokens.flat.json"); +const packagePath = path.join(packageRoot, "package.json"); +const outputPath = path.join( + repositoryRoot, + "Packages", + "PutioCore", + "Sources", + "PutioCore", + "Generated", + "PutioTheme+Generated.swift", +); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isStringOrNumber = (value: unknown): value is string | number => + typeof value === "string" || typeof value === "number"; + +export function parseTokens(value: unknown): readonly TokenEntry[] { + if (!isRecord(value)) { + throw new Error("design token artifact must be an object"); + } + + return Object.entries(value) + .map(([name, candidate]): TokenEntry => { + if (!isRecord(candidate)) { + throw new Error(`token ${name} must be an object`); + } + const { cssName, type, mode, originalValue } = candidate; + const tokenValue = candidate.value; + if (typeof cssName !== "string" || !cssName.startsWith("--")) { + throw new Error(`token ${name} has an invalid cssName`); + } + if (typeof type !== "string" || !tokenTypes.includes(type as TokenType)) { + throw new Error(`token ${name} has an unsupported type`); + } + if (typeof mode !== "string" || !tokenModes.includes(mode as TokenMode)) { + throw new Error(`token ${name} has an unsupported mode`); + } + if (!isStringOrNumber(tokenValue) || !isStringOrNumber(originalValue)) { + throw new Error(`token ${name} has an invalid value`); + } + if ( + candidate.basis !== undefined && + candidate.basis !== "viewport-width" && + candidate.basis !== "viewport-height" + ) { + throw new Error(`token ${name} has an unsupported basis`); + } + + return { + name, + token: { + cssName, + type: type as TokenType, + mode: mode as TokenMode, + value: tokenValue, + originalValue, + ...(typeof candidate.description === "string" + ? { description: candidate.description } + : {}), + ...(candidate.basis === "viewport-width" || candidate.basis === "viewport-height" + ? { basis: candidate.basis } + : {}), + ...(typeof candidate.deprecated === "boolean" + ? { deprecated: candidate.deprecated } + : {}), + }, + }; + }) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +const swiftKeywords = new Set([ + "associatedtype", + "class", + "deinit", + "enum", + "extension", + "fileprivate", + "func", + "import", + "init", + "inout", + "internal", + "let", + "open", + "operator", + "private", + "protocol", + "public", + "rethrows", + "static", + "struct", + "subscript", + "typealias", + "var", +]); + +const swiftIdentifier = (cssName: string): string => { + const words = cssName.replace(/^--/, "").split(/[^A-Za-z0-9]+/).filter(Boolean); + assert(words.length > 0, `cannot derive a Swift identifier from ${cssName}`); + const identifier = words + .map((word, index) => + index === 0 + ? word.toLowerCase() + : `${word.slice(0, 1).toUpperCase()}${word.slice(1).toLowerCase()}`, + ) + .join(""); + const safe = /^\d/.test(identifier) ? `token${identifier}` : identifier; + return swiftKeywords.has(safe) ? `${safe}Value` : safe; +}; + +const swiftString = (value: string): string => JSON.stringify(value); +const decimal = (value: number): string => { + const rounded = Math.round(value * 1_000_000) / 1_000_000; + return Number.isInteger(rounded) ? `${rounded}.0` : `${rounded}`; +}; + +const hslPattern = + /^hsla?\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)%\s*,\s*(\d+(?:\.\d+)?)%\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)$/; + +const hueToRgb = (p: number, q: number, rawT: number): number => { + let t = rawT; + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; +}; + +const swiftColorComponents = (value: string): readonly string[] => { + const match = hslPattern.exec(value); + if (!match) throw new Error(`unsupported color value: ${value}`); + const hue = (((Number(match[1]) % 360) + 360) % 360) / 360; + const saturation = Number(match[2]) / 100; + const lightness = Number(match[3]) / 100; + const opacity = match[4] === undefined ? 1 : Number(match[4]); + let red = lightness; + let green = lightness; + let blue = lightness; + if (saturation !== 0) { + const q = + lightness < 0.5 + ? lightness * (1 + saturation) + : lightness + saturation - lightness * saturation; + const p = 2 * lightness - q; + red = hueToRgb(p, q, hue + 1 / 3); + green = hueToRgb(p, q, hue); + blue = hueToRgb(p, q, hue - 1 / 3); + } + return [decimal(red), decimal(green), decimal(blue), decimal(opacity)]; +}; + +const swiftColor = (value: string): string => { + const [red, green, blue, opacity] = swiftColorComponents(value); + return `Color(.sRGB, red: ${red}, green: ${green}, blue: ${blue}, opacity: ${opacity})`; +}; + +const swiftMultilineColor = (value: string): string => { + const [red, green, blue, opacity] = swiftColorComponents(value); + return `Color( + .sRGB, + red: ${red}, + green: ${green}, + blue: ${blue}, + opacity: ${opacity} + )`; +}; + +const dimensionPoints = (value: string | number): number => { + if (typeof value === "number") return value; + if (value === "0") return 0; + const match = /^(-?\d+(?:\.\d+)?)(px|rem)$/.exec(value); + if (!match) throw new Error(`unsupported dimension value: ${value}`); + const amount = Number(match[1]); + return match[2] === "rem" ? amount * 16 : amount; +}; + +const durationSeconds = (value: string | number): number => { + if (typeof value === "number") return value; + const match = /^(\d+(?:\.\d+)?)(ms|s)$/.exec(value); + if (!match) throw new Error(`unsupported duration value: ${value}`); + const amount = Number(match[1]); + return match[2] === "ms" ? amount / 1_000 : amount; +}; + +const cubicBezier = (value: string | number): readonly number[] => { + if (typeof value !== "string") throw new Error(`unsupported easing value: ${value}`); + const match = + /^cubic-bezier\(\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)$/.exec( + value, + ); + if (!match) throw new Error(`unsupported easing value: ${value}`); + return match.slice(1).map(Number); +}; + +const primaryFontFamily = (value: string | number): string => { + if (typeof value !== "string") throw new Error(`unsupported font family: ${value}`); + return value.split(",", 1)[0]?.trim().replace(/^"|"$/g, "") ?? value; +}; + +const fontWeight = (value: string | number): string => { + const numeric = Number(value); + const weights = new Map([ + [400, ".regular"], + [500, ".medium"], + [600, ".semibold"], + [700, ".bold"], + [900, ".black"], + ]); + const result = weights.get(numeric); + if (!result) throw new Error(`unsupported font weight: ${value}`); + return result; +}; + +const requiredToken = ( + entries: readonly TokenEntry[], + name: string, + type?: TokenType, +): DesignToken => { + const result = entries.find((entry) => entry.name === name)?.token; + if (!result) throw new Error(`required token is missing: ${name}`); + if (type && result.type !== type) { + throw new Error(`required token ${name} must have type ${type}`); + } + return result; +}; + +const groupedColors = ( + entries: readonly TokenEntry[], + mode: "product" | "tv", +): readonly { readonly identifier: string; readonly entries: readonly TokenEntry[] }[] => { + const groups = new Map(); + for (const entry of entries) { + if (entry.token.type !== "color") continue; + if ((mode === "tv") !== (entry.token.mode === "tv")) continue; + const group = groups.get(entry.token.cssName) ?? []; + group.push(entry); + groups.set(entry.token.cssName, group); + } + const result = [...groups.entries()] + .map(([cssName, colorEntries]) => ({ + identifier: swiftIdentifier(cssName), + entries: colorEntries.sort((left, right) => left.name.localeCompare(right.name)), + })) + .sort((left, right) => left.identifier.localeCompare(right.identifier)); + const identifiers = result.map((item) => item.identifier); + assert.equal(new Set(identifiers).size, identifiers.length, "color identifiers must be unique"); + return result; +}; + +const renderProductColors = (entries: readonly TokenEntry[]): string => + groupedColors(entries, "product") + .map(({ identifier, entries: variants }) => { + const global = variants.find((entry) => entry.token.mode === "global"); + const light = variants.find((entry) => entry.token.mode === "light") ?? global ?? variants[0]; + const dark = variants.find((entry) => entry.token.mode === "dark") ?? global ?? variants[0]; + assert(light && dark, `color ${identifier} has no usable variants`); + return ` public static let ${identifier} = PutioDynamicColor(\n light: ${swiftColor(String(light.token.value))},\n dark: ${swiftColor(String(dark.token.value))}\n )`; + }) + .join("\n\n"); + +const renderTVColors = (entries: readonly TokenEntry[]): string => + groupedColors(entries, "tv") + .map(({ identifier, entries: variants }) => { + assert.equal(variants.length, 1, `TV color ${identifier} must have one variant`); + return ` public static let ${identifier} = ${swiftMultilineColor(String(variants[0]?.token.value))}`; + }) + .join("\n"); + +const dimension = (entries: readonly TokenEntry[], name: string): string => + decimal(dimensionPoints(requiredToken(entries, name, "dimension").value)); + +const numberValue = (entries: readonly TokenEntry[], name: string): string => { + const value = requiredToken(entries, name, "number").value; + const numeric = Number(value); + if (!Number.isFinite(numeric)) throw new Error(`token ${name} must be numeric`); + return decimal(numeric); +}; + +export function renderSwift(entries: readonly TokenEntry[], packageVersion: string): string { + const familySans = primaryFontFamily(requiredToken(entries, "typography.fontFamily.sans").value); + const familyDisplay = primaryFontFamily( + requiredToken(entries, "typography.fontFamily.display").value, + ); + const familyMono = primaryFontFamily(requiredToken(entries, "typography.fontFamily.mono").value); + const easingOut = cubicBezier(requiredToken(entries, "motion.easing.out").value); + const easingInOut = cubicBezier(requiredToken(entries, "motion.easing.inOut").value); + + return `// Do not edit directly. Generated by scripts/generate-design-tokens.ts. +// Source: @putdotio/design ${packageVersion} (${entries.length} tokens). + +import SwiftUI + +public struct PutioDynamicColor: Sendable { + public let light: Color + public let dark: Color + + public init(light: Color, dark: Color) { + self.light = light + self.dark = dark + } + + public func resolve(for colorScheme: ColorScheme) -> Color { + colorScheme == .dark ? dark : light + } +} + +public struct PutioFontRole: Sendable { + public let family: String + public let size: CGFloat + public let weight: Font.Weight + public let lineHeight: CGFloat + + public init( + family: String, + size: CGFloat, + weight: Font.Weight, + lineHeight: CGFloat + ) { + self.family = family + self.size = size + self.weight = weight + self.lineHeight = lineHeight + } + + public var font: Font { + .custom(family, size: size).weight(weight) + } + + public var lineSpacing: CGFloat { + max(0, size * (lineHeight - 1)) + } +} + +public struct PutioCubicBezier: Equatable, Sendable { + public let x1: Double + public let y1: Double + public let x2: Double + public let y2: Double + + public init(x1: Double, y1: Double, x2: Double, y2: Double) { + self.x1 = x1 + self.y1 = y1 + self.x2 = x2 + self.y2 = y2 + } + + public func animation(duration: TimeInterval) -> Animation { + .timingCurve(x1, y1, x2, y2, duration: duration) + } +} + +public enum PutioTheme { + public static let sourcePackage = "@putdotio/design" + public static let sourceVersion = ${swiftString(packageVersion)} + public static let sourceTokenCount = ${entries.length} + + public enum Colors { +${renderProductColors(entries)} + } + + public enum Typography { + public static let familySans = ${swiftString(familySans)} + public static let familyDisplay = ${swiftString(familyDisplay)} + public static let familyMono = ${swiftString(familyMono)} + + public static let sizeXs: CGFloat = ${dimension(entries, "typography.fontSize.xs")} + public static let sizeSm: CGFloat = ${dimension(entries, "typography.fontSize.sm")} + public static let sizeBase: CGFloat = ${dimension(entries, "typography.fontSize.base")} + public static let sizeMd: CGFloat = ${dimension(entries, "typography.fontSize.md")} + public static let sizeLg: CGFloat = ${dimension(entries, "typography.fontSize.lg")} + public static let sizeXl: CGFloat = ${dimension(entries, "typography.fontSize.xl")} + public static let size2xl: CGFloat = ${dimension(entries, "typography.fontSize.2xl")} + public static let size3xl: CGFloat = ${dimension(entries, "typography.fontSize.3xl")} + public static let sizeDisplay: CGFloat = ${dimension(entries, "typography.fontSize.display")} + + public static let caption = PutioFontRole( + family: familySans, + size: sizeSm, + weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.regular").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + ) + public static let body = PutioFontRole( + family: familySans, + size: sizeBase, + weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.regular").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + ) + public static let subheading = PutioFontRole( + family: familySans, + size: sizeMd, + weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.medium").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.snug")} + ) + public static let heading = PutioFontRole( + family: familySans, + size: sizeLg, + weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.bold").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.tight")} + ) + public static let title = PutioFontRole( + family: familySans, + size: sizeXl, + weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.bold").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.tight")} + ) + public static let display = PutioFontRole( + family: familyDisplay, + size: sizeDisplay, + weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.black").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.tight")} + ) + #if !os(tvOS) + public static let mono = PutioFontRole( + family: familyMono, + size: sizeSm, + weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.regular").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + ) + #endif + } + + public enum Spacing { + public static let space0: CGFloat = ${dimension(entries, "spacing.0")} + public static let space1: CGFloat = ${dimension(entries, "spacing.1")} + public static let space2: CGFloat = ${dimension(entries, "spacing.2")} + public static let space3: CGFloat = ${dimension(entries, "spacing.3")} + public static let space4: CGFloat = ${dimension(entries, "spacing.4")} + public static let space5: CGFloat = ${dimension(entries, "spacing.5")} + public static let space6: CGFloat = ${dimension(entries, "spacing.6")} + public static let space7: CGFloat = ${dimension(entries, "spacing.7")} + public static let space8: CGFloat = ${dimension(entries, "spacing.8")} + } + + public enum Radius { + public static let small: CGFloat = ${dimension(entries, "radius.sm")} + public static let standard: CGFloat = ${dimension(entries, "radius.default")} + public static let medium: CGFloat = ${dimension(entries, "radius.md")} + public static let large: CGFloat = ${dimension(entries, "radius.lg")} + public static let pill: CGFloat = ${dimension(entries, "radius.pill")} + } + + public enum Border { + public static let width: CGFloat = ${dimension(entries, "border.width")} + } + + public enum Motion { + public static let durationFast: TimeInterval = ${decimal(durationSeconds(requiredToken(entries, "motion.duration.fast").value))} + public static let durationBase: TimeInterval = ${decimal(durationSeconds(requiredToken(entries, "motion.duration.base").value))} + public static let durationSlow: TimeInterval = ${decimal(durationSeconds(requiredToken(entries, "motion.duration.slow").value))} + public static let easingOut = PutioCubicBezier( + x1: ${decimal(easingOut[0] ?? 0)}, + y1: ${decimal(easingOut[1] ?? 0)}, + x2: ${decimal(easingOut[2] ?? 0)}, + y2: ${decimal(easingOut[3] ?? 0)} + ) + public static let easingInOut = PutioCubicBezier( + x1: ${decimal(easingInOut[0] ?? 0)}, + y1: ${decimal(easingInOut[1] ?? 0)}, + x2: ${decimal(easingInOut[2] ?? 0)}, + y2: ${decimal(easingInOut[3] ?? 0)} + ) + } + + #if os(tvOS) + public enum TV { + public enum Colors { +${renderTVColors(entries)} + } + + public enum Typography { + public static let heading = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: ${dimension(entries, "tv.text.heading")}, + weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.medium").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.tight")} + ) + public static let label = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: ${dimension(entries, "tv.text.label")}, + weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.medium").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.snug")} + ) + public static let body = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: ${dimension(entries, "tv.text.body")}, + weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.regular").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + ) + public static let caption = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: ${dimension(entries, "tv.text.caption")}, + weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.regular").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + ) + public static let small = PutioFontRole( + family: PutioTheme.Typography.familySans, + size: ${dimension(entries, "tv.text.smol")}, + weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.regular").value)}, + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + ) + } + + public enum Spacing { + public static let xxs: CGFloat = ${dimension(entries, "tv.space.xxs")} + public static let xs: CGFloat = ${dimension(entries, "tv.space.xs")} + public static let small: CGFloat = ${dimension(entries, "tv.space.sm")} + public static let medium: CGFloat = ${dimension(entries, "tv.space.md")} + public static let large: CGFloat = ${dimension(entries, "tv.space.lg")} + public static let xl: CGFloat = ${dimension(entries, "tv.space.xl")} + public static let xxl: CGFloat = ${dimension(entries, "tv.space.xxl")} + } + + public enum Overscan { + public static let horizontal = ${numberValue(entries, "tv.overscan.x")} + public static let vertical = ${numberValue(entries, "tv.overscan.y")} + } + + public static let radius: CGFloat = ${dimension(entries, "tv.radius")} + + public enum ZIndex { + public static let modal = ${numberValue(entries, "tv.z.modal")} + public static let toast = ${numberValue(entries, "tv.z.toast")} + public static let overlay = ${numberValue(entries, "tv.z.overlay")} + } + } + #endif +} +`; +} + +const readJSON = async (filePath: string): Promise => + JSON.parse(await readFile(filePath, "utf8")) as unknown; + +const run = async (): Promise => { + const [rawTokens, packageJSON] = await Promise.all([readJSON(inputPath), readJSON(packagePath)]); + if (!isRecord(packageJSON) || typeof packageJSON.version !== "string") { + throw new Error("@putdotio/design package.json has no version"); + } + const generated = renderSwift(parseTokens(rawTokens), packageJSON.version); + if (process.argv.includes("--check")) { + const current = await readFile(outputPath, "utf8").catch(() => ""); + if (current !== generated) { + throw new Error("generated Swift design tokens are stale; run pnpm tokens:generate"); + } + return; + } + await mkdir(path.dirname(outputPath), { recursive: true }); + await writeFile(outputPath, generated); +}; + +if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) { + await run(); +} diff --git a/scripts/test.sh b/scripts/test.sh index 04e8c4f..8a7f8d0 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -4,6 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo_root" +pnpm run verify swift format lint --strict --recursive Apps Packages Tools Project.swift Tuist.swift Tuist/Package.swift swift test --package-path Packages/PutioCore swift test --package-path Tools/PutioHarness diff --git a/scripts/verify.sh b/scripts/verify.sh index e0a5c09..7216f54 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -4,6 +4,7 @@ set -euo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$repo_root" +pnpm install --frozen-lockfile ./scripts/generate.sh ./scripts/doctor.sh ./scripts/test.sh diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..60c3bd3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "module": "NodeNext", + "noEmit": true, + "strict": true, + "target": "ES2024", + "types": ["node"] + }, + "include": ["scripts/**/*.ts"] +} From 01d2610dc1b6f01a054b813cbd6729571d81112f Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 16:07:38 +0300 Subject: [PATCH 2/4] fix: preserve native theme accessibility --- Apps/iOS/Sources/PutioApp.swift | 2 +- Apps/tvOS/Sources/PutioTVApp.swift | 2 +- Apps/watchOS/Sources/PutioWatchApp.swift | 4 +- .../Generated/PutioTheme+Generated.swift | 43 +++++++++++++------ .../PutioCoreTests/PutioThemeTests.swift | 12 ++++++ scripts/generate-design-tokens.ts | 43 +++++++++++++------ 6 files changed, 75 insertions(+), 31 deletions(-) diff --git a/Apps/iOS/Sources/PutioApp.swift b/Apps/iOS/Sources/PutioApp.swift index a549289..6014822 100644 --- a/Apps/iOS/Sources/PutioApp.swift +++ b/Apps/iOS/Sources/PutioApp.swift @@ -9,6 +9,7 @@ struct PutioApp: App { var body: some Scene { WindowGroup { SignedOutView(presentation: presentation) + .preferredColorScheme(.dark) .onAppear { if SignedOutPresentation.isHarnessExercise(arguments: ProcessInfo.processInfo.arguments) { SignedOutPresentation.signalHarnessExercise() @@ -36,6 +37,5 @@ private struct SignedOutView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(PutioTheme.Colors.appBg.resolve(for: colorScheme)) - .preferredColorScheme(.dark) } } diff --git a/Apps/tvOS/Sources/PutioTVApp.swift b/Apps/tvOS/Sources/PutioTVApp.swift index 12e852f..ac9a2a2 100644 --- a/Apps/tvOS/Sources/PutioTVApp.swift +++ b/Apps/tvOS/Sources/PutioTVApp.swift @@ -9,6 +9,7 @@ struct PutioTVApp: App { var body: some Scene { WindowGroup { SignedOutView(presentation: presentation) + .preferredColorScheme(.dark) .onAppear { if SignedOutPresentation.isHarnessExercise(arguments: ProcessInfo.processInfo.arguments) { SignedOutPresentation.signalHarnessExercise() @@ -36,6 +37,5 @@ private struct SignedOutView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(PutioTheme.Colors.appBg.resolve(for: colorScheme)) - .preferredColorScheme(.dark) } } diff --git a/Apps/watchOS/Sources/PutioWatchApp.swift b/Apps/watchOS/Sources/PutioWatchApp.swift index 5820ddd..a680781 100644 --- a/Apps/watchOS/Sources/PutioWatchApp.swift +++ b/Apps/watchOS/Sources/PutioWatchApp.swift @@ -9,6 +9,7 @@ struct PutioWatchApp: App { var body: some Scene { WindowGroup { SignedOutView(presentation: presentation) + .preferredColorScheme(.dark) .onAppear { if SignedOutPresentation.isHarnessExercise(arguments: ProcessInfo.processInfo.arguments) { SignedOutPresentation.signalHarnessExercise() @@ -27,14 +28,15 @@ private struct SignedOutView: View { VStack(spacing: PutioTheme.Spacing.space1) { Text(presentation.title) .font(PutioTheme.Typography.subheading.font) + .lineSpacing(PutioTheme.Typography.subheading.lineSpacing) .foregroundStyle(PutioTheme.Colors.text.resolve(for: colorScheme)) Text(presentation.message) .font(PutioTheme.Typography.caption.font) + .lineSpacing(PutioTheme.Typography.caption.lineSpacing) .foregroundStyle(PutioTheme.Colors.textSecondary.resolve(for: colorScheme)) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(PutioTheme.Colors.appBg.resolve(for: colorScheme)) - .preferredColorScheme(.dark) } } diff --git a/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift b/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift index 366407b..ccb7cff 100644 --- a/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift +++ b/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift @@ -22,21 +22,24 @@ public struct PutioFontRole: Sendable { public let size: CGFloat public let weight: Font.Weight public let lineHeight: CGFloat + public let textStyle: Font.TextStyle public init( family: String, size: CGFloat, weight: Font.Weight, - lineHeight: CGFloat + lineHeight: CGFloat, + textStyle: Font.TextStyle ) { self.family = family self.size = size self.weight = weight self.lineHeight = lineHeight + self.textStyle = textStyle } public var font: Font { - .custom(family, size: size).weight(weight) + .custom(family, size: size, relativeTo: textStyle).weight(weight) } public var lineSpacing: CGFloat { @@ -918,44 +921,51 @@ public enum PutioTheme { family: familySans, size: sizeSm, weight: .regular, - lineHeight: 1.45 + lineHeight: 1.45, + textStyle: .caption ) public static let body = PutioFontRole( family: familySans, size: sizeBase, weight: .regular, - lineHeight: 1.45 + lineHeight: 1.45, + textStyle: .body ) public static let subheading = PutioFontRole( family: familySans, size: sizeMd, weight: .medium, - lineHeight: 1.25 + lineHeight: 1.25, + textStyle: .subheadline ) public static let heading = PutioFontRole( family: familySans, size: sizeLg, weight: .bold, - lineHeight: 1.1 + lineHeight: 1.1, + textStyle: .headline ) public static let title = PutioFontRole( family: familySans, size: sizeXl, weight: .bold, - lineHeight: 1.1 + lineHeight: 1.1, + textStyle: .title ) public static let display = PutioFontRole( family: familyDisplay, size: sizeDisplay, weight: .black, - lineHeight: 1.1 + lineHeight: 1.1, + textStyle: .largeTitle ) #if !os(tvOS) public static let mono = PutioFontRole( family: familyMono, size: sizeSm, weight: .regular, - lineHeight: 1.45 + lineHeight: 1.45, + textStyle: .caption ) #endif } @@ -1089,31 +1099,36 @@ public enum PutioTheme { family: PutioTheme.Typography.familySans, size: 64.0, weight: .medium, - lineHeight: 1.1 + lineHeight: 1.1, + textStyle: .title ) public static let label = PutioFontRole( family: PutioTheme.Typography.familySans, size: 48.0, weight: .medium, - lineHeight: 1.25 + lineHeight: 1.25, + textStyle: .headline ) public static let body = PutioFontRole( family: PutioTheme.Typography.familySans, size: 36.0, weight: .regular, - lineHeight: 1.45 + lineHeight: 1.45, + textStyle: .body ) public static let caption = PutioFontRole( family: PutioTheme.Typography.familySans, size: 32.0, weight: .regular, - lineHeight: 1.45 + lineHeight: 1.45, + textStyle: .caption ) public static let small = PutioFontRole( family: PutioTheme.Typography.familySans, size: 24.0, weight: .regular, - lineHeight: 1.45 + lineHeight: 1.45, + textStyle: .caption2 ) } diff --git a/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift b/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift index 39f1e98..1bceadc 100644 --- a/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift +++ b/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift @@ -19,4 +19,16 @@ final class PutioThemeTests: XCTestCase { PutioCubicBezier(x1: 0.22, y1: 1, x2: 0.36, y2: 1) ) } + + func testFontRoleLineSpacingClampsNegativeLeading() { + let role = PutioFontRole( + family: "System", + size: 20, + weight: .regular, + lineHeight: 0.8, + textStyle: .body + ) + + XCTAssertEqual(role.lineSpacing, 0) + } } diff --git a/scripts/generate-design-tokens.ts b/scripts/generate-design-tokens.ts index 914b7e1..bd41f04 100644 --- a/scripts/generate-design-tokens.ts +++ b/scripts/generate-design-tokens.ts @@ -352,21 +352,24 @@ public struct PutioFontRole: Sendable { public let size: CGFloat public let weight: Font.Weight public let lineHeight: CGFloat + public let textStyle: Font.TextStyle public init( family: String, size: CGFloat, weight: Font.Weight, - lineHeight: CGFloat + lineHeight: CGFloat, + textStyle: Font.TextStyle ) { self.family = family self.size = size self.weight = weight self.lineHeight = lineHeight + self.textStyle = textStyle } public var font: Font { - .custom(family, size: size).weight(weight) + .custom(family, size: size, relativeTo: textStyle).weight(weight) } public var lineSpacing: CGFloat { @@ -420,44 +423,51 @@ ${renderProductColors(entries)} family: familySans, size: sizeSm, weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.regular").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")}, + textStyle: .caption ) public static let body = PutioFontRole( family: familySans, size: sizeBase, weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.regular").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")}, + textStyle: .body ) public static let subheading = PutioFontRole( family: familySans, size: sizeMd, weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.medium").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.snug")} + lineHeight: ${numberValue(entries, "typography.lineHeight.snug")}, + textStyle: .subheadline ) public static let heading = PutioFontRole( family: familySans, size: sizeLg, weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.bold").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.tight")} + lineHeight: ${numberValue(entries, "typography.lineHeight.tight")}, + textStyle: .headline ) public static let title = PutioFontRole( family: familySans, size: sizeXl, weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.bold").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.tight")} + lineHeight: ${numberValue(entries, "typography.lineHeight.tight")}, + textStyle: .title ) public static let display = PutioFontRole( family: familyDisplay, size: sizeDisplay, weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.black").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.tight")} + lineHeight: ${numberValue(entries, "typography.lineHeight.tight")}, + textStyle: .largeTitle ) #if !os(tvOS) public static let mono = PutioFontRole( family: familyMono, size: sizeSm, weight: ${fontWeight(requiredToken(entries, "typography.fontWeight.regular").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")}, + textStyle: .caption ) #endif } @@ -515,31 +525,36 @@ ${renderTVColors(entries)} family: PutioTheme.Typography.familySans, size: ${dimension(entries, "tv.text.heading")}, weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.medium").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.tight")} + lineHeight: ${numberValue(entries, "typography.lineHeight.tight")}, + textStyle: .title ) public static let label = PutioFontRole( family: PutioTheme.Typography.familySans, size: ${dimension(entries, "tv.text.label")}, weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.medium").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.snug")} + lineHeight: ${numberValue(entries, "typography.lineHeight.snug")}, + textStyle: .headline ) public static let body = PutioFontRole( family: PutioTheme.Typography.familySans, size: ${dimension(entries, "tv.text.body")}, weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.regular").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")}, + textStyle: .body ) public static let caption = PutioFontRole( family: PutioTheme.Typography.familySans, size: ${dimension(entries, "tv.text.caption")}, weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.regular").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")}, + textStyle: .caption ) public static let small = PutioFontRole( family: PutioTheme.Typography.familySans, size: ${dimension(entries, "tv.text.smol")}, weight: ${fontWeight(requiredToken(entries, "tv.fontWeight.regular").value)}, - lineHeight: ${numberValue(entries, "typography.lineHeight.normal")} + lineHeight: ${numberValue(entries, "typography.lineHeight.normal")}, + textStyle: .caption2 ) } From 3aeec045b913481a23edd901141e593961bf1ba8 Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 16:10:50 +0300 Subject: [PATCH 3/4] test: cover dynamic theme color resolution --- .../PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift b/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift index 1bceadc..a739221 100644 --- a/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift +++ b/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift @@ -3,6 +3,13 @@ import XCTest @testable import PutioCore final class PutioThemeTests: XCTestCase { + func testDynamicColorResolvesTheRequestedAppearance() { + let dynamicColor = PutioDynamicColor(light: .white, dark: .black) + + XCTAssertEqual(dynamicColor.resolve(for: .light), dynamicColor.light) + XCTAssertEqual(dynamicColor.resolve(for: .dark), dynamicColor.dark) + } + func testGeneratedThemeIdentifiesItsPinnedSource() { XCTAssertEqual(PutioTheme.sourcePackage, "@putdotio/design") XCTAssertEqual(PutioTheme.sourceVersion, "2.0.1") From 4cd282e59cb01f81a895029fd668da71102fdadb Mon Sep 17 00:00:00 2001 From: Altay Date: Mon, 10 Aug 2026 16:18:19 +0300 Subject: [PATCH 4/4] fix: scale token line spacing with Dynamic Type --- Apps/iOS/Sources/PutioApp.swift | 6 ++--- Apps/tvOS/Sources/PutioTVApp.swift | 6 ++--- Apps/watchOS/Sources/PutioWatchApp.swift | 6 ++--- .../Generated/PutioTheme+Generated.swift | 27 ++++++++++++++++++- .../PutioCoreTests/PutioThemeTests.swift | 2 +- scripts/generate-design-tokens.ts | 27 ++++++++++++++++++- 6 files changed, 59 insertions(+), 15 deletions(-) diff --git a/Apps/iOS/Sources/PutioApp.swift b/Apps/iOS/Sources/PutioApp.swift index 6014822..09d59f0 100644 --- a/Apps/iOS/Sources/PutioApp.swift +++ b/Apps/iOS/Sources/PutioApp.swift @@ -27,12 +27,10 @@ private struct SignedOutView: View { var body: some View { VStack(spacing: PutioTheme.Spacing.space3) { Text(presentation.title) - .font(PutioTheme.Typography.title.font) - .lineSpacing(PutioTheme.Typography.title.lineSpacing) + .putioFont(PutioTheme.Typography.title) .foregroundStyle(PutioTheme.Colors.text.resolve(for: colorScheme)) Text(presentation.message) - .font(PutioTheme.Typography.body.font) - .lineSpacing(PutioTheme.Typography.body.lineSpacing) + .putioFont(PutioTheme.Typography.body) .foregroundStyle(PutioTheme.Colors.textSecondary.resolve(for: colorScheme)) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Apps/tvOS/Sources/PutioTVApp.swift b/Apps/tvOS/Sources/PutioTVApp.swift index ac9a2a2..aaf24ea 100644 --- a/Apps/tvOS/Sources/PutioTVApp.swift +++ b/Apps/tvOS/Sources/PutioTVApp.swift @@ -27,12 +27,10 @@ private struct SignedOutView: View { var body: some View { VStack(spacing: PutioTheme.TV.Spacing.small) { Text(presentation.title) - .font(PutioTheme.TV.Typography.heading.font) - .lineSpacing(PutioTheme.TV.Typography.heading.lineSpacing) + .putioFont(PutioTheme.TV.Typography.heading) .foregroundStyle(PutioTheme.TV.Colors.text1) Text(presentation.message) - .font(PutioTheme.TV.Typography.body.font) - .lineSpacing(PutioTheme.TV.Typography.body.lineSpacing) + .putioFont(PutioTheme.TV.Typography.body) .foregroundStyle(PutioTheme.TV.Colors.text2) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Apps/watchOS/Sources/PutioWatchApp.swift b/Apps/watchOS/Sources/PutioWatchApp.swift index a680781..6d83437 100644 --- a/Apps/watchOS/Sources/PutioWatchApp.swift +++ b/Apps/watchOS/Sources/PutioWatchApp.swift @@ -27,12 +27,10 @@ private struct SignedOutView: View { var body: some View { VStack(spacing: PutioTheme.Spacing.space1) { Text(presentation.title) - .font(PutioTheme.Typography.subheading.font) - .lineSpacing(PutioTheme.Typography.subheading.lineSpacing) + .putioFont(PutioTheme.Typography.subheading) .foregroundStyle(PutioTheme.Colors.text.resolve(for: colorScheme)) Text(presentation.message) - .font(PutioTheme.Typography.caption.font) - .lineSpacing(PutioTheme.Typography.caption.lineSpacing) + .putioFont(PutioTheme.Typography.caption) .foregroundStyle(PutioTheme.Colors.textSecondary.resolve(for: colorScheme)) .multilineTextAlignment(.center) } diff --git a/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift b/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift index ccb7cff..791d6ba 100644 --- a/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift +++ b/Packages/PutioCore/Sources/PutioCore/Generated/PutioTheme+Generated.swift @@ -42,11 +42,36 @@ public struct PutioFontRole: Sendable { .custom(family, size: size, relativeTo: textStyle).weight(weight) } - public var lineSpacing: CGFloat { + var baseLineSpacing: CGFloat { max(0, size * (lineHeight - 1)) } } +private struct PutioFontModifier: ViewModifier { + let role: PutioFontRole + @ScaledMetric private var lineSpacing: CGFloat + + init(role: PutioFontRole) { + self.role = role + _lineSpacing = ScaledMetric( + wrappedValue: role.baseLineSpacing, + relativeTo: role.textStyle + ) + } + + func body(content: Content) -> some View { + content + .font(role.font) + .lineSpacing(lineSpacing) + } +} + +extension View { + public func putioFont(_ role: PutioFontRole) -> some View { + modifier(PutioFontModifier(role: role)) + } +} + public struct PutioCubicBezier: Equatable, Sendable { public let x1: Double public let y1: Double diff --git a/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift b/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift index a739221..2050725 100644 --- a/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift +++ b/Packages/PutioCore/Tests/PutioCoreTests/PutioThemeTests.swift @@ -36,6 +36,6 @@ final class PutioThemeTests: XCTestCase { textStyle: .body ) - XCTAssertEqual(role.lineSpacing, 0) + XCTAssertEqual(role.baseLineSpacing, 0) } } diff --git a/scripts/generate-design-tokens.ts b/scripts/generate-design-tokens.ts index bd41f04..ced6999 100644 --- a/scripts/generate-design-tokens.ts +++ b/scripts/generate-design-tokens.ts @@ -372,11 +372,36 @@ public struct PutioFontRole: Sendable { .custom(family, size: size, relativeTo: textStyle).weight(weight) } - public var lineSpacing: CGFloat { + var baseLineSpacing: CGFloat { max(0, size * (lineHeight - 1)) } } +private struct PutioFontModifier: ViewModifier { + let role: PutioFontRole + @ScaledMetric private var lineSpacing: CGFloat + + init(role: PutioFontRole) { + self.role = role + _lineSpacing = ScaledMetric( + wrappedValue: role.baseLineSpacing, + relativeTo: role.textStyle + ) + } + + func body(content: Content) -> some View { + content + .font(role.font) + .lineSpacing(lineSpacing) + } +} + +extension View { + public func putioFont(_ role: PutioFontRole) -> some View { + modifier(PutioFontModifier(role: role)) + } +} + public struct PutioCubicBezier: Equatable, Sendable { public let x1: Double public let y1: Double