geeknative 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +202 -0
  2. package/bin/geeknative.js +12 -0
  3. package/package.json +31 -4
  4. package/src/cli.js +61 -0
  5. package/src/codegen/app.js +97 -0
  6. package/src/codegen/icons.js +87 -0
  7. package/src/codegen/index.js +44 -0
  8. package/src/codegen/strings.js +308 -0
  9. package/src/codegen/tokens.js +449 -0
  10. package/src/codegen/util.js +122 -0
  11. package/src/commands/check.js +143 -0
  12. package/src/commands/create.js +78 -0
  13. package/src/commands/doctor.js +108 -0
  14. package/src/commands/gen.js +44 -0
  15. package/src/commands/parity.js +134 -0
  16. package/src/commands/run.js +91 -0
  17. package/src/lib/exec.js +77 -0
  18. package/src/lib/fsutil.js +92 -0
  19. package/src/lib/log.js +37 -0
  20. package/src/lib/managed.js +80 -0
  21. package/src/lib/project.js +52 -0
  22. package/src/lib/toolchains.js +126 -0
  23. package/template/.claude/agents/android-engineer.md +15 -0
  24. package/template/.claude/agents/ios-engineer.md +15 -0
  25. package/template/.claude/agents/web-engineer.md +15 -0
  26. package/template/.claude/settings.json +65 -0
  27. package/template/.geeknative/framework.mk +41 -0
  28. package/template/.geeknative/hooks/after-edit.mjs +28 -0
  29. package/template/.geeknative/hooks/parity-guard.mjs +49 -0
  30. package/template/.geeknative/hooks/subagent-context.mjs +24 -0
  31. package/template/.geeknative/playbooks/bug.md +7 -0
  32. package/template/.geeknative/playbooks/design.md +10 -0
  33. package/template/.geeknative/playbooks/engineering.md +9 -0
  34. package/template/.geeknative/playbooks/feature.md +11 -0
  35. package/template/.geeknative/playbooks/platform-specific.md +8 -0
  36. package/template/.geeknative/playbooks/product.md +12 -0
  37. package/template/.geeknative/templates/feature.md +49 -0
  38. package/template/.gemini/settings.json +5 -0
  39. package/template/.github/dependabot.yml +18 -0
  40. package/template/AGENTS.md +97 -0
  41. package/template/CLAUDE.md +9 -0
  42. package/template/DESIGN.md +65 -0
  43. package/template/Makefile +6 -0
  44. package/template/PRODUCT.md +40 -0
  45. package/template/README.md +28 -0
  46. package/template/_gitignore +33 -0
  47. package/template/apps/android/AGENTS.md +66 -0
  48. package/template/apps/android/CLAUDE.md +1 -0
  49. package/template/apps/android/app/build.gradle.kts +68 -0
  50. package/template/apps/android/app/identity.properties +4 -0
  51. package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
  52. package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
  53. package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
  54. package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
  55. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
  56. package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
  57. package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
  58. package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
  59. package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
  60. package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
  61. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
  62. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
  63. package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
  64. package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
  65. package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
  66. package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
  67. package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
  68. package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
  69. package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
  70. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
  71. package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
  72. package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
  73. package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
  74. package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
  75. package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
  76. package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
  77. package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
  78. package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
  79. package/template/apps/android/build.gradle.kts +5 -0
  80. package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
  81. package/template/apps/android/gradle/libs.versions.toml +37 -0
  82. package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  83. package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  84. package/template/apps/android/gradle.properties +7 -0
  85. package/template/apps/android/gradlew +248 -0
  86. package/template/apps/android/gradlew.bat +82 -0
  87. package/template/apps/android/settings.gradle.kts +29 -0
  88. package/template/apps/ios/AGENTS.md +70 -0
  89. package/template/apps/ios/App/AppMain.swift +13 -0
  90. package/template/apps/ios/App/Core/AppContainer.swift +22 -0
  91. package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
  92. package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
  93. package/template/apps/ios/App/Core/RootView.swift +32 -0
  94. package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
  95. package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
  96. package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
  97. package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
  98. package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
  99. package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
  100. package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
  101. package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
  102. package/template/apps/ios/App/Generated/Icons.swift +8 -0
  103. package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
  104. package/template/apps/ios/App/Generated/Strings.swift +40 -0
  105. package/template/apps/ios/App/Generated/Tokens.swift +65 -0
  106. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
  107. package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
  108. package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
  109. package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
  110. package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  111. package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
  112. package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
  113. package/template/apps/ios/CLAUDE.md +1 -0
  114. package/template/apps/ios/Config/App.xcconfig +23 -0
  115. package/template/apps/ios/Config/AppTests.xcconfig +10 -0
  116. package/template/apps/ios/Config/Base.xcconfig +25 -0
  117. package/template/apps/ios/Config/Debug.xcconfig +9 -0
  118. package/template/apps/ios/Config/Identity.xcconfig +5 -0
  119. package/template/apps/ios/Config/Release.xcconfig +6 -0
  120. package/template/apps/web/AGENTS.md +63 -0
  121. package/template/apps/web/CLAUDE.md +1 -0
  122. package/template/apps/web/package.json +38 -0
  123. package/template/apps/web/public/favicon.svg +6 -0
  124. package/template/apps/web/src/core/AppContainer.ts +18 -0
  125. package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
  126. package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
  127. package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
  128. package/template/apps/web/src/features/home/home.test.tsx +22 -0
  129. package/template/apps/web/src/features/settings/Appearance.ts +29 -0
  130. package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
  131. package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
  132. package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
  133. package/template/apps/web/src/features/settings/settings.test.ts +45 -0
  134. package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
  135. package/template/apps/web/src/generated/app.ts +9 -0
  136. package/template/apps/web/src/generated/icons.ts +9 -0
  137. package/template/apps/web/src/generated/strings.ts +88 -0
  138. package/template/apps/web/src/generated/tokens.css +86 -0
  139. package/template/apps/web/src/routeTree.gen.ts +86 -0
  140. package/template/apps/web/src/router.tsx +23 -0
  141. package/template/apps/web/src/routes/__root.tsx +72 -0
  142. package/template/apps/web/src/routes/index.tsx +6 -0
  143. package/template/apps/web/src/routes/settings.tsx +9 -0
  144. package/template/apps/web/src/styles.css +9 -0
  145. package/template/apps/web/tsconfig.json +26 -0
  146. package/template/apps/web/tsr.config.json +3 -0
  147. package/template/apps/web/vite.config.ts +9 -0
  148. package/template/apps/web/vitest.config.ts +9 -0
  149. package/template/contracts/app.json +7 -0
  150. package/template/contracts/icons.json +5 -0
  151. package/template/contracts/strings/en.json +13 -0
  152. package/template/contracts/tokens.json +40 -0
  153. package/template/package.json +17 -0
  154. package/template/spec/decisions.md +10 -0
  155. package/template/spec/features/home.md +39 -0
  156. package/template/spec/features/settings.md +50 -0
  157. package/template/spec/navigation.md +16 -0
  158. package/template/spec/parity.md +29 -0
  159. package/README.md +0 -3
@@ -0,0 +1,449 @@
1
+ // @ts-check
2
+ import path from 'node:path'
3
+ import { readJson } from '../lib/fsutil.js'
4
+ import { CliError } from '../lib/log.js'
5
+ import { assertIdentifier, banner, hex2, kebab, OUT, parseHex, xcodeJson } from './util.js'
6
+
7
+ const SOURCE = 'contracts/tokens.json'
8
+
9
+ /** Colors every app relies on. Projects can add more. */
10
+ const REQUIRED_COLORS = [
11
+ 'background',
12
+ 'onBackground',
13
+ 'surface',
14
+ 'onSurface',
15
+ 'surfaceVariant',
16
+ 'onSurfaceVariant',
17
+ 'primary',
18
+ 'onPrimary',
19
+ 'outline',
20
+ 'error',
21
+ 'onError',
22
+ ]
23
+
24
+ const IOS_TEXT_STYLES = new Set([
25
+ 'largeTitle', 'title', 'title2', 'title3', 'headline', 'subheadline', 'body', 'callout', 'footnote', 'caption', 'caption2',
26
+ ])
27
+
28
+ const M3_TEXT_SLOTS = new Set([
29
+ 'displayLarge', 'displayMedium', 'displaySmall', 'headlineLarge', 'headlineMedium', 'headlineSmall',
30
+ 'titleLarge', 'titleMedium', 'titleSmall', 'bodyLarge', 'bodyMedium', 'bodySmall',
31
+ 'labelLarge', 'labelMedium', 'labelSmall',
32
+ ])
33
+
34
+ /** @type {Record<number, string>} */
35
+ const IOS_WEIGHTS = { 100: 'ultraLight', 200: 'thin', 300: 'light', 400: 'regular', 500: 'medium', 600: 'semibold', 700: 'bold', 800: 'heavy', 900: 'black' }
36
+ /** @type {Record<number, string>} */
37
+ const KOTLIN_WEIGHTS = { 100: 'Thin', 200: 'ExtraLight', 300: 'Light', 400: 'Normal', 500: 'Medium', 600: 'SemiBold', 700: 'Bold', 800: 'ExtraBold', 900: 'Black' }
38
+
39
+ /**
40
+ * Material 3 color roles and the token each one is filled from. Screens and app bars sit on
41
+ * `background` (as on iOS); `surface` is for cards and grouped content. A project token named
42
+ * after a role (e.g. "secondary") replaces the default, except `surface` and `onSurface`.
43
+ */
44
+ const M3_ROLES = {
45
+ primary: 'primary',
46
+ onPrimary: 'onPrimary',
47
+ primaryContainer: 'surfaceVariant',
48
+ onPrimaryContainer: 'onSurface',
49
+ inversePrimary: 'primary',
50
+ secondary: 'primary',
51
+ onSecondary: 'onPrimary',
52
+ secondaryContainer: 'surfaceVariant',
53
+ onSecondaryContainer: 'onSurface',
54
+ tertiary: 'primary',
55
+ onTertiary: 'onPrimary',
56
+ tertiaryContainer: 'surfaceVariant',
57
+ onTertiaryContainer: 'onSurface',
58
+ background: 'background',
59
+ onBackground: 'onBackground',
60
+ surface: 'background',
61
+ onSurface: 'onBackground',
62
+ surfaceVariant: 'surfaceVariant',
63
+ onSurfaceVariant: 'onSurfaceVariant',
64
+ surfaceTint: 'primary',
65
+ inverseSurface: 'onBackground',
66
+ inverseOnSurface: 'background',
67
+ error: 'error',
68
+ onError: 'onError',
69
+ outline: 'outline',
70
+ outlineVariant: 'outline',
71
+ surfaceBright: 'background',
72
+ surfaceDim: 'surfaceVariant',
73
+ surfaceContainerLowest: 'background',
74
+ surfaceContainerLow: 'surface',
75
+ surfaceContainer: 'surface',
76
+ surfaceContainerHigh: 'surface',
77
+ surfaceContainerHighest: 'surface',
78
+ }
79
+
80
+ /** shadcn/ui variable names, mapped onto tokens so its components pick up the design. */
81
+ const SHADCN_ALIASES = {
82
+ foreground: 'on-background',
83
+ card: 'surface',
84
+ 'card-foreground': 'on-surface',
85
+ popover: 'surface',
86
+ 'popover-foreground': 'on-surface',
87
+ 'primary-foreground': 'on-primary',
88
+ secondary: 'surface-variant',
89
+ 'secondary-foreground': 'on-surface',
90
+ muted: 'surface-variant',
91
+ 'muted-foreground': 'on-surface-variant',
92
+ accent: 'surface-variant',
93
+ 'accent-foreground': 'on-surface',
94
+ destructive: 'error',
95
+ 'destructive-foreground': 'on-error',
96
+ border: 'outline',
97
+ input: 'outline',
98
+ ring: 'primary',
99
+ }
100
+
101
+ /**
102
+ * @typedef {[number, number, number, number]} Rgba
103
+ * @typedef {{ light: Rgba, dark: Rgba }} ColorToken
104
+ * @typedef {{ size: number, lineHeight: number, weight: number, ios: string, android: string }} TextToken
105
+ * @typedef {{
106
+ * colors: Record<string, ColorToken>,
107
+ * spacing: Record<string, number>,
108
+ * radius: Record<string, number>,
109
+ * typography: Record<string, TextToken>,
110
+ * }} Tokens
111
+ * @typedef {{ path: string, content: string | Buffer }} Output
112
+ */
113
+
114
+ /** @param {string} root @returns {Tokens} */
115
+ export function readTokens(root) {
116
+ const raw = readJson(path.join(root, 'contracts', 'tokens.json'), SOURCE)
117
+
118
+ /** @type {Record<string, ColorToken>} */
119
+ const colors = {}
120
+ for (const [name, value] of Object.entries(raw.color ?? {})) {
121
+ assertIdentifier(name, `${SOURCE} color`)
122
+ if (!value || typeof value !== 'object') {
123
+ throw new CliError(`${SOURCE} color.${name} must be an object like { "light": "#FFFFFF", "dark": "#000000" }.`)
124
+ }
125
+ const light = parseHex(value.light, `${SOURCE} color.${name}.light`)
126
+ const dark = value.dark === undefined ? light : parseHex(value.dark, `${SOURCE} color.${name}.dark`)
127
+ colors[name] = { light, dark }
128
+ }
129
+ const missing = REQUIRED_COLORS.filter((name) => !colors[name])
130
+ if (missing.length) throw new CliError(`${SOURCE} is missing required colors: ${missing.join(', ')}.`)
131
+
132
+ const spacing = readNumbers(raw.spacing, 'spacing')
133
+ const radius = readNumbers(raw.radius, 'radius')
134
+
135
+ /** @type {Record<string, TextToken>} */
136
+ const typography = {}
137
+ for (const [name, value] of Object.entries(raw.typography ?? {})) {
138
+ assertIdentifier(name, `${SOURCE} typography`)
139
+ const where = `${SOURCE} typography.${name}`
140
+ const { size, lineHeight, weight, ios, android } = value ?? {}
141
+ if (!(size > 0) || !(lineHeight > 0)) throw new CliError(`${where} needs positive "size" and "lineHeight" numbers.`)
142
+ if (!IOS_WEIGHTS[weight]) throw new CliError(`${where} "weight" must be 100, 200, ... or 900.`)
143
+ if (!IOS_TEXT_STYLES.has(ios)) {
144
+ throw new CliError(`${where} "ios" must be a Dynamic Type style: ${[...IOS_TEXT_STYLES].join(', ')}.`)
145
+ }
146
+ if (!M3_TEXT_SLOTS.has(android)) {
147
+ throw new CliError(`${where} "android" must be a Material 3 type style: ${[...M3_TEXT_SLOTS].join(', ')}.`)
148
+ }
149
+ typography[name] = { size, lineHeight, weight, ios, android }
150
+ }
151
+ for (const name of ['body']) {
152
+ if (!typography[name]) throw new CliError(`${SOURCE} typography needs a "${name}" style.`)
153
+ }
154
+
155
+ const family = raw.font?.family ?? 'system'
156
+ if (family !== 'system') {
157
+ throw new CliError(
158
+ `${SOURCE} font.family "${family}" isn't supported yet: codegen only handles "system" fonts. Keep "system" for now.`,
159
+ )
160
+ }
161
+
162
+ return { colors, spacing, radius, typography }
163
+ }
164
+
165
+ /** @param {unknown} group @param {string} label */
166
+ function readNumbers(group, label) {
167
+ /** @type {Record<string, number>} */
168
+ const values = {}
169
+ for (const [name, value] of Object.entries(group ?? {})) {
170
+ assertIdentifier(name, `${SOURCE} ${label}`)
171
+ if (typeof value !== 'number' || value < 0) throw new CliError(`${SOURCE} ${label}.${name} must be a number of 0 or more.`)
172
+ values[name] = value
173
+ }
174
+ return values
175
+ }
176
+
177
+ /** @param {Tokens} tokens @returns {Output[]} */
178
+ export function tokenOutputs(tokens) {
179
+ return [
180
+ { path: `${OUT.iosGenerated}/Tokens.swift`, content: swift(tokens) },
181
+ ...accentColorSet(tokens),
182
+ { path: `${OUT.androidKotlin}/Tokens.kt`, content: kotlin(tokens) },
183
+ { path: `${OUT.androidRes}/values/generated_colors.xml`, content: androidColorResources(tokens, 'light') },
184
+ { path: `${OUT.androidRes}/values-night/generated_colors.xml`, content: androidColorResources(tokens, 'dark') },
185
+ { path: `${OUT.webGenerated}/tokens.css`, content: css(tokens) },
186
+ ]
187
+ }
188
+
189
+ // ---------- iOS ----------
190
+
191
+ /** @param {Rgba} c */
192
+ const swiftHex = ([r, g, b, a]) => `0x${hex2(r)}${hex2(g)}${hex2(b)}${hex2(a)}`
193
+
194
+ /** @param {Tokens} tokens */
195
+ function swift(tokens) {
196
+ const colors = Object.entries(tokens.colors)
197
+ .map(([name, c]) => ` static let ${name} = Color(light: ${swiftHex(c.light)}, dark: ${swiftHex(c.dark)})`)
198
+ .join('\n')
199
+ const numbers = (/** @type {Record<string, number>} */ group) =>
200
+ Object.entries(group).map(([name, value]) => ` static let ${name}: CGFloat = ${value}`).join('\n')
201
+ const type = Object.entries(tokens.typography)
202
+ .map(([name, t]) => ` static let ${name}: Font = .${t.ios}.weight(.${IOS_WEIGHTS[t.weight]})`)
203
+ .join('\n')
204
+
205
+ return `// ${banner(SOURCE)}
206
+ import SwiftUI
207
+ import UIKit
208
+
209
+ /// Colors. Each one adapts to light and dark mode on its own.
210
+ nonisolated enum AppColors {
211
+ ${colors}
212
+ }
213
+
214
+ /// Spacing, in points.
215
+ nonisolated enum AppSpacing {
216
+ ${numbers(tokens.spacing)}
217
+ }
218
+
219
+ /// Corner radii, in points.
220
+ nonisolated enum AppRadius {
221
+ ${numbers(tokens.radius)}
222
+ }
223
+
224
+ /// Text styles. Each maps to a Dynamic Type style, so it follows the user's text size setting.
225
+ nonisolated enum AppTypography {
226
+ ${type}
227
+ }
228
+
229
+ private extension Color {
230
+ nonisolated init(light: UInt32, dark: UInt32) {
231
+ self.init(uiColor: UIColor { traits in
232
+ UIColor(rgba: traits.userInterfaceStyle == .dark ? dark : light)
233
+ })
234
+ }
235
+ }
236
+
237
+ private extension UIColor {
238
+ nonisolated convenience init(rgba: UInt32) {
239
+ self.init(
240
+ red: CGFloat((rgba >> 24) & 0xFF) / 255,
241
+ green: CGFloat((rgba >> 16) & 0xFF) / 255,
242
+ blue: CGFloat((rgba >> 8) & 0xFF) / 255,
243
+ alpha: CGFloat(rgba & 0xFF) / 255
244
+ )
245
+ }
246
+ }
247
+ `
248
+ }
249
+
250
+ /** The global accent color, so system controls use the brand's primary color. @param {Tokens} tokens */
251
+ function accentColorSet(tokens) {
252
+ const dir = `${OUT.iosGenerated}/Colors.xcassets`
253
+ const component = (/** @type {Rgba} */ [r, g, b, a]) => ({
254
+ color: {
255
+ 'color-space': 'srgb',
256
+ components: { alpha: (a / 255).toFixed(3), blue: `0x${hex2(b)}`, green: `0x${hex2(g)}`, red: `0x${hex2(r)}` },
257
+ },
258
+ idiom: 'universal',
259
+ })
260
+ const { light, dark } = tokens.colors.primary
261
+ const info = { author: 'xcode', version: 1 }
262
+ return [
263
+ { path: `${dir}/Contents.json`, content: `${xcodeJson({ info })}\n` },
264
+ {
265
+ path: `${dir}/AccentColor.colorset/Contents.json`,
266
+ content: `${xcodeJson({
267
+ colors: [component(light), { appearances: [{ appearance: 'luminosity', value: 'dark' }], ...component(dark) }],
268
+ info,
269
+ })}\n`,
270
+ },
271
+ ]
272
+ }
273
+
274
+ // ---------- Android ----------
275
+
276
+ /** @param {Rgba} c */
277
+ const argb = ([r, g, b, a]) => `${hex2(a)}${hex2(r)}${hex2(g)}${hex2(b)}`
278
+
279
+ /** @param {Tokens} tokens */
280
+ function kotlin(tokens) {
281
+ const names = Object.keys(tokens.colors)
282
+ const fields = names.map((name) => ` val ${name}: Color,`).join('\n')
283
+ const values = (/** @type {'light' | 'dark'} */ mode) =>
284
+ names.map((name) => ` ${name} = Color(0x${argb(tokens.colors[name][mode])}),`).join('\n')
285
+ const roles = Object.entries(M3_ROLES)
286
+ .map(([role, fallback]) => {
287
+ const own = tokens.colors[role] && role !== 'surface' && role !== 'onSurface'
288
+ return ` ${role} = ${own ? role : fallback},`
289
+ })
290
+ .join('\n')
291
+ const numbers = (/** @type {Record<string, number>} */ group) =>
292
+ Object.entries(group).map(([name, value]) => ` val ${name} = ${value}.dp`).join('\n')
293
+ const styles = Object.entries(tokens.typography)
294
+ .map(
295
+ ([name, t]) =>
296
+ ` val ${name} = TextStyle(fontWeight = FontWeight.${KOTLIN_WEIGHTS[t.weight]}, fontSize = ${t.size}.sp, lineHeight = ${t.lineHeight}.sp)`,
297
+ )
298
+ .join('\n')
299
+ const slots = Object.entries(tokens.typography)
300
+ .map(([name, t]) => ` ${t.android} = AppTypography.${name},`)
301
+ .join('\n')
302
+ const shape = (/** @type {string} */ key) => (tokens.radius[key] !== undefined ? `RoundedCornerShape(AppRadius.${key})` : null)
303
+ const shapes = [
304
+ ['extraSmall', shape('xs') ?? shape('sm')],
305
+ ['small', shape('sm')],
306
+ ['medium', shape('md')],
307
+ ['large', shape('lg')],
308
+ ['extraLarge', shape('xl') ?? shape('lg')],
309
+ ]
310
+ .filter(([, value]) => value)
311
+ .map(([slot, value]) => ` ${slot} = ${value},`)
312
+ .join('\n')
313
+
314
+ return `// ${banner(SOURCE)}
315
+ package app.generated
316
+
317
+ import androidx.compose.foundation.shape.RoundedCornerShape
318
+ import androidx.compose.material3.ColorScheme
319
+ import androidx.compose.material3.Shapes
320
+ import androidx.compose.material3.Typography
321
+ import androidx.compose.material3.darkColorScheme
322
+ import androidx.compose.material3.lightColorScheme
323
+ import androidx.compose.runtime.Immutable
324
+ import androidx.compose.ui.graphics.Color
325
+ import androidx.compose.ui.text.TextStyle
326
+ import androidx.compose.ui.text.font.FontWeight
327
+ import androidx.compose.ui.unit.dp
328
+ import androidx.compose.ui.unit.sp
329
+
330
+ /** Colors for one appearance (light or dark). In composables, read the current set with \`AppTheme.colors\`. */
331
+ @Immutable
332
+ data class AppColorTokens(
333
+ ${fields}
334
+ )
335
+
336
+ val LightAppColors = AppColorTokens(
337
+ ${values('light')}
338
+ )
339
+
340
+ val DarkAppColors = AppColorTokens(
341
+ ${values('dark')}
342
+ )
343
+
344
+ /** Maps the tokens onto Material 3 color roles, so stock components match the design. */
345
+ fun AppColorTokens.toColorScheme(dark: Boolean): ColorScheme =
346
+ (if (dark) darkColorScheme() else lightColorScheme()).copy(
347
+ ${roles}
348
+ )
349
+
350
+ /** Spacing. */
351
+ object AppSpacing {
352
+ ${numbers(tokens.spacing)}
353
+ }
354
+
355
+ /** Corner radii. */
356
+ object AppRadius {
357
+ ${numbers(tokens.radius)}
358
+ }
359
+
360
+ /** Text styles, in sp so they follow the user's font size setting. */
361
+ object AppTypography {
362
+ ${styles}
363
+ }
364
+
365
+ /** The text styles mapped onto Material 3 type roles, so stock components use them. */
366
+ val AppMaterialTypography = Typography().copy(
367
+ ${slots}
368
+ )
369
+
370
+ /** The radii mapped onto Material 3 shape roles. */
371
+ val AppShapes = Shapes(
372
+ ${shapes}
373
+ )
374
+ `
375
+ }
376
+
377
+ /** Window background and other XML-side colors, with a night variant. @param {Tokens} tokens @param {'light' | 'dark'} mode */
378
+ function androidColorResources(tokens, mode) {
379
+ const items = Object.entries(tokens.colors)
380
+ .map(([name, c]) => ` <color name="token_${kebab(name).replace(/-/g, '_')}">#${argb(c[mode])}</color>`)
381
+ .join('\n')
382
+ return `<?xml version="1.0" encoding="utf-8"?>
383
+ <!-- ${banner(SOURCE)} -->
384
+ <resources>
385
+ ${items}
386
+ </resources>
387
+ `
388
+ }
389
+
390
+ // ---------- Web ----------
391
+
392
+ /** @param {Rgba} c */
393
+ const cssColor = ([r, g, b, a]) => `#${hex2(r)}${hex2(g)}${hex2(b)}${a === 255 ? '' : hex2(a)}`.toLowerCase()
394
+
395
+ /** @param {number} px */
396
+ const rem = (px) => `${Number((px / 16).toFixed(4))}rem`
397
+
398
+ /** @param {Tokens} tokens */
399
+ function css(tokens) {
400
+ const colorVars = (/** @type {'light' | 'dark'} */ mode, /** @type {string} */ indent) =>
401
+ Object.entries(tokens.colors)
402
+ .map(([name, c]) => `${indent}--color-${kebab(name)}: ${cssColor(c[mode])};`)
403
+ .join('\n')
404
+ const own = new Set(Object.keys(tokens.colors).map(kebab))
405
+ const aliases = Object.entries(SHADCN_ALIASES)
406
+ .filter(([alias]) => !own.has(alias))
407
+ .map(([alias, target]) => ` --color-${alias}: var(--color-${target});`)
408
+ .join('\n')
409
+ const numbers = (/** @type {Record<string, number>} */ group, /** @type {string} */ prefix) =>
410
+ Object.entries(group)
411
+ .map(([name, value]) => ` --${prefix}-${kebab(name)}: ${value >= 9999 ? 'calc(infinity * 1px)' : `${value}px`};`)
412
+ .join('\n')
413
+ const type = Object.entries(tokens.typography)
414
+ .map(([name, t]) =>
415
+ [
416
+ ` --text-${kebab(name)}: ${rem(t.size)};`,
417
+ ` --text-${kebab(name)}--line-height: ${rem(t.lineHeight)};`,
418
+ ` --text-${kebab(name)}--font-weight: ${t.weight};`,
419
+ ].join('\n'),
420
+ )
421
+ .join('\n')
422
+
423
+ return `/* ${banner(SOURCE)} */
424
+
425
+ /*
426
+ * Tailwind theme: bg-primary, text-on-surface, p-md, gap-lg, rounded-lg, text-title, ...
427
+ * Dark values apply when <html> has the "dark" class (see AppearanceEffect).
428
+ */
429
+ @theme {
430
+ ${colorVars('light', ' ')}
431
+
432
+ /* shadcn/ui names, mapped onto the tokens above */
433
+ ${aliases}
434
+
435
+ ${numbers(tokens.spacing, 'spacing')}
436
+
437
+ ${numbers(tokens.radius, 'radius')}
438
+
439
+ ${type}
440
+
441
+ --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
442
+ }
443
+
444
+ .dark {
445
+ color-scheme: dark;
446
+ ${colorVars('dark', ' ')}
447
+ }
448
+ `
449
+ }
@@ -0,0 +1,122 @@
1
+ // @ts-check
2
+ import { CliError } from '../lib/log.js'
3
+
4
+ /** @param {string} source */
5
+ export const banner = (source) =>
6
+ `GENERATED by geeknative from ${source}. Do not edit: change the contract, then run \`make gen\`.`
7
+
8
+ /** Where generated code lands in each app. */
9
+ export const OUT = {
10
+ iosGenerated: 'apps/ios/App/Generated',
11
+ iosIdentity: 'apps/ios/Config/Identity.xcconfig',
12
+ androidKotlin: 'apps/android/app/src/main/kotlin/app/generated',
13
+ androidRes: 'apps/android/app/src/main/res',
14
+ androidIdentity: 'apps/android/app/identity.properties',
15
+ webGenerated: 'apps/web/src/generated',
16
+ }
17
+
18
+ /** Identifiers that would break generated Swift, Kotlin or TypeScript. */
19
+ const RESERVED = new Set(
20
+ (
21
+ 'as any break case catch class continue default defer delete do else enum export extension false fun func ' +
22
+ 'function if import in init interface internal is let new nil null object operator package private protocol ' +
23
+ 'public repeat return self some static struct super switch this throw true try typealias typeof val var void ' +
24
+ 'when where while with yield'
25
+ ).split(' '),
26
+ )
27
+
28
+ /**
29
+ * Throws if `name` can't be used as an identifier on every platform.
30
+ * @param {string} name
31
+ * @param {string} where e.g. "contracts/tokens.json color"
32
+ */
33
+ export function assertIdentifier(name, where) {
34
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(name)) {
35
+ throw new CliError(`${where} "${name}": names must be camelCase letters and digits, starting lowercase.`)
36
+ }
37
+ if (RESERVED.has(name)) {
38
+ throw new CliError(`${where} "${name}" is a reserved word in Swift, Kotlin or TypeScript. Pick another name.`)
39
+ }
40
+ }
41
+
42
+ /** "settings.about.appVersion" → "settingsAboutAppVersion" @param {string} key */
43
+ export const camelKey = (key) =>
44
+ key
45
+ .split('.')
46
+ .map((part, i) => (i === 0 ? part : part[0].toUpperCase() + part.slice(1)))
47
+ .join('')
48
+
49
+ /** "settings.about.appVersion" → "settings_about_app_version" @param {string} key */
50
+ export const snakeKey = (key) =>
51
+ key
52
+ .split('.')
53
+ .map((part) => part.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase())
54
+ .join('_')
55
+
56
+ /** "onSurfaceVariant" → "on-surface-variant" @param {string} name */
57
+ export const kebab = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
58
+
59
+ /** @param {string} text */
60
+ export function swiftString(text) {
61
+ return `"${text
62
+ .replace(/\\/g, '\\\\')
63
+ .replace(/"/g, '\\"')
64
+ .replace(/\n/g, '\\n')
65
+ .replace(/\r/g, '\\r')
66
+ .replace(/\t/g, '\\t')
67
+ // eslint-disable-next-line no-control-regex
68
+ .replace(/[\u0000-\u001f]/g, (c) => `\\u{${c.charCodeAt(0).toString(16)}}`)}"`
69
+ }
70
+
71
+ /** @param {string} text */
72
+ export function kotlinString(text) {
73
+ return `"${text
74
+ .replace(/\\/g, '\\\\')
75
+ .replace(/"/g, '\\"')
76
+ .replace(/\$/g, '\\$')
77
+ .replace(/\n/g, '\\n')
78
+ .replace(/\r/g, '\\r')
79
+ .replace(/\t/g, '\\t')}"`
80
+ }
81
+
82
+ /** Single-line doc comment text. @param {string} text */
83
+ export const docText = (text) => text.replace(/\s*\n\s*/g, ' ').replace(/\*\//g, '* /')
84
+
85
+ /**
86
+ * Parses "#RRGGBB" or "#RRGGBBAA" into [r, g, b, a], each 0-255.
87
+ * @param {unknown} value
88
+ * @param {string} where
89
+ * @returns {[number, number, number, number]}
90
+ */
91
+ export function parseHex(value, where) {
92
+ if (typeof value !== 'string' || !/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(value)) {
93
+ throw new CliError(`${where} must be a hex color like "#4F46E5" or "#4F46E5CC" (got ${JSON.stringify(value)}).`)
94
+ }
95
+ const hex = value.slice(1)
96
+ const channel = (/** @type {number} */ i) => parseInt(hex.slice(i, i + 2), 16)
97
+ return [channel(0), channel(2), channel(4), hex.length === 8 ? channel(6) : 255]
98
+ }
99
+
100
+ /** @param {number} n */
101
+ export const hex2 = (n) => n.toString(16).padStart(2, '0').toUpperCase()
102
+
103
+ /**
104
+ * JSON formatted the way Xcode writes it (sorted keys, " : " separators), so Xcode
105
+ * doesn't rewrite generated catalogs when the project is opened.
106
+ * @param {unknown} value
107
+ * @param {string} [indent]
108
+ * @returns {string}
109
+ */
110
+ export function xcodeJson(value, indent = '') {
111
+ const next = `${indent} `
112
+ if (Array.isArray(value)) {
113
+ if (value.length === 0) return '[]'
114
+ return `[\n${value.map((item) => next + xcodeJson(item, next)).join(',\n')}\n${indent}]`
115
+ }
116
+ if (value && typeof value === 'object') {
117
+ const entries = Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
118
+ if (entries.length === 0) return '{}'
119
+ return `{\n${entries.map(([k, v]) => `${next}${JSON.stringify(k)} : ${xcodeJson(v, next)}`).join(',\n')}\n${indent}}`
120
+ }
121
+ return JSON.stringify(value)
122
+ }