geeknative 0.0.0-stage → 0.1.1
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.
- package/LICENSE +202 -0
- package/bin/geeknative.js +12 -0
- package/package.json +42 -4
- package/src/cli.js +61 -0
- package/src/codegen/app.js +105 -0
- package/src/codegen/icons.js +87 -0
- package/src/codegen/index.js +44 -0
- package/src/codegen/strings.js +308 -0
- package/src/codegen/tokens.js +449 -0
- package/src/codegen/util.js +122 -0
- package/src/commands/check.js +148 -0
- package/src/commands/create.js +82 -0
- package/src/commands/doctor.js +108 -0
- package/src/commands/gen.js +44 -0
- package/src/commands/parity.js +134 -0
- package/src/commands/run.js +91 -0
- package/src/lib/exec.js +77 -0
- package/src/lib/fsutil.js +92 -0
- package/src/lib/log.js +37 -0
- package/src/lib/managed.js +80 -0
- package/src/lib/project.js +52 -0
- package/src/lib/toolchains.js +126 -0
- package/template/.claude/agents/android-engineer.md +15 -0
- package/template/.claude/agents/ios-engineer.md +15 -0
- package/template/.claude/agents/web-engineer.md +15 -0
- package/template/.claude/settings.json +65 -0
- package/template/.geeknative/framework.mk +41 -0
- package/template/.geeknative/hooks/after-edit.mjs +28 -0
- package/template/.geeknative/hooks/parity-guard.mjs +49 -0
- package/template/.geeknative/hooks/subagent-context.mjs +24 -0
- package/template/.geeknative/playbooks/bug.md +7 -0
- package/template/.geeknative/playbooks/design.md +10 -0
- package/template/.geeknative/playbooks/engineering.md +9 -0
- package/template/.geeknative/playbooks/feature.md +11 -0
- package/template/.geeknative/playbooks/platform-specific.md +8 -0
- package/template/.geeknative/playbooks/product.md +12 -0
- package/template/.geeknative/templates/feature.md +49 -0
- package/template/.gemini/settings.json +5 -0
- package/template/.github/dependabot.yml +18 -0
- package/template/AGENTS.md +97 -0
- package/template/CLAUDE.md +9 -0
- package/template/DESIGN.md +65 -0
- package/template/Makefile +6 -0
- package/template/PRODUCT.md +40 -0
- package/template/README.md +28 -0
- package/template/_gitignore +33 -0
- package/template/apps/android/AGENTS.md +66 -0
- package/template/apps/android/CLAUDE.md +1 -0
- package/template/apps/android/app/build.gradle.kts +68 -0
- package/template/apps/android/app/identity.properties +4 -0
- package/template/apps/android/app/src/main/AndroidManifest.xml +24 -0
- package/template/apps/android/app/src/main/kotlin/app/App.kt +15 -0
- package/template/apps/android/app/src/main/kotlin/app/MainActivity.kt +49 -0
- package/template/apps/android/app/src/main/kotlin/app/core/AppContainer.kt +27 -0
- package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppCard.kt +30 -0
- package/template/apps/android/app/src/main/kotlin/app/core/designsystem/AppTheme.kt +35 -0
- package/template/apps/android/app/src/main/kotlin/app/core/navigation/AppNavigation.kt +40 -0
- package/template/apps/android/app/src/main/kotlin/app/core/navigation/Route.kt +13 -0
- package/template/apps/android/app/src/main/kotlin/app/features/home/HomeScreen.kt +67 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/Appearance.kt +22 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsRepository.kt +40 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsScreen.kt +113 -0
- package/template/apps/android/app/src/main/kotlin/app/features/settings/SettingsViewModel.kt +28 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/AppIdentity.kt +10 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/Icons.kt +15 -0
- package/template/apps/android/app/src/main/kotlin/app/generated/Tokens.kt +144 -0
- package/template/apps/android/app/src/main/res/drawable/ic_launcher_foreground.xml +17 -0
- package/template/apps/android/app/src/main/res/mipmap/ic_launcher.xml +10 -0
- package/template/apps/android/app/src/main/res/mipmap/ic_launcher_round.xml +10 -0
- package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +6 -0
- package/template/apps/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +6 -0
- package/template/apps/android/app/src/main/res/values/generated_colors.xml +15 -0
- package/template/apps/android/app/src/main/res/values/generated_strings.xml +16 -0
- package/template/apps/android/app/src/main/res/values/ic_launcher_background.xml +4 -0
- package/template/apps/android/app/src/main/res/values/themes.xml +7 -0
- package/template/apps/android/app/src/main/res/values-night/generated_colors.xml +15 -0
- package/template/apps/android/app/src/main/res/values-night/themes.xml +6 -0
- package/template/apps/android/app/src/test/kotlin/app/features/settings/SettingsTest.kt +90 -0
- package/template/apps/android/build.gradle.kts +5 -0
- package/template/apps/android/gradle/gradle-daemon-jvm.properties +12 -0
- package/template/apps/android/gradle/libs.versions.toml +37 -0
- package/template/apps/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/template/apps/android/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/template/apps/android/gradle.properties +7 -0
- package/template/apps/android/gradlew +248 -0
- package/template/apps/android/gradlew.bat +82 -0
- package/template/apps/android/settings.gradle.kts +29 -0
- package/template/apps/ios/AGENTS.md +70 -0
- package/template/apps/ios/App/AppMain.swift +13 -0
- package/template/apps/ios/App/Core/AppContainer.swift +22 -0
- package/template/apps/ios/App/Core/DesignSystem/AppCard.swift +15 -0
- package/template/apps/ios/App/Core/Navigation/Route.swift +4 -0
- package/template/apps/ios/App/Core/RootView.swift +32 -0
- package/template/apps/ios/App/Features/Home/HomeScreen.swift +35 -0
- package/template/apps/ios/App/Features/Settings/Appearance.swift +27 -0
- package/template/apps/ios/App/Features/Settings/SettingsRepository.swift +40 -0
- package/template/apps/ios/App/Features/Settings/SettingsScreen.swift +40 -0
- package/template/apps/ios/App/Features/Settings/SettingsViewModel.swift +23 -0
- package/template/apps/ios/App/Generated/AppIdentity.swift +9 -0
- package/template/apps/ios/App/Generated/Colors.xcassets/AccentColor.colorset/Contents.json +38 -0
- package/template/apps/ios/App/Generated/Colors.xcassets/Contents.json +6 -0
- package/template/apps/ios/App/Generated/Icons.swift +8 -0
- package/template/apps/ios/App/Generated/Localizable.xcstrings +127 -0
- package/template/apps/ios/App/Generated/Strings.swift +40 -0
- package/template/apps/ios/App/Generated/Tokens.swift +65 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png +0 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/template/apps/ios/App/Resources/Assets.xcassets/Contents.json +6 -0
- package/template/apps/ios/App.xcodeproj/project.pbxproj +298 -0
- package/template/apps/ios/App.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/template/apps/ios/App.xcodeproj/xcshareddata/xcschemes/App.xcscheme +89 -0
- package/template/apps/ios/AppTests/SettingsTests.swift +54 -0
- package/template/apps/ios/CLAUDE.md +1 -0
- package/template/apps/ios/Config/App.xcconfig +23 -0
- package/template/apps/ios/Config/AppTests.xcconfig +10 -0
- package/template/apps/ios/Config/Base.xcconfig +25 -0
- package/template/apps/ios/Config/Debug.xcconfig +9 -0
- package/template/apps/ios/Config/Identity.xcconfig +5 -0
- package/template/apps/ios/Config/Release.xcconfig +6 -0
- package/template/apps/web/AGENTS.md +63 -0
- package/template/apps/web/CLAUDE.md +1 -0
- package/template/apps/web/package.json +38 -0
- package/template/apps/web/public/favicon.svg +6 -0
- package/template/apps/web/src/core/AppContainer.ts +18 -0
- package/template/apps/web/src/core/AppearanceEffect.tsx +31 -0
- package/template/apps/web/src/core/designsystem/AppCard.tsx +6 -0
- package/template/apps/web/src/features/home/HomeScreen.tsx +15 -0
- package/template/apps/web/src/features/home/home.test.tsx +22 -0
- package/template/apps/web/src/features/settings/Appearance.ts +29 -0
- package/template/apps/web/src/features/settings/LocalStorageSettingsRepository.ts +21 -0
- package/template/apps/web/src/features/settings/SettingsRepository.ts +7 -0
- package/template/apps/web/src/features/settings/SettingsScreen.tsx +44 -0
- package/template/apps/web/src/features/settings/settings.test.ts +45 -0
- package/template/apps/web/src/features/settings/useSettingsViewModel.ts +42 -0
- package/template/apps/web/src/generated/app.ts +9 -0
- package/template/apps/web/src/generated/icons.ts +9 -0
- package/template/apps/web/src/generated/strings.ts +88 -0
- package/template/apps/web/src/generated/tokens.css +86 -0
- package/template/apps/web/src/routeTree.gen.ts +86 -0
- package/template/apps/web/src/router.tsx +23 -0
- package/template/apps/web/src/routes/__root.tsx +72 -0
- package/template/apps/web/src/routes/index.tsx +6 -0
- package/template/apps/web/src/routes/settings.tsx +9 -0
- package/template/apps/web/src/styles.css +9 -0
- package/template/apps/web/tsconfig.json +26 -0
- package/template/apps/web/tsr.config.json +3 -0
- package/template/apps/web/vite.config.ts +9 -0
- package/template/apps/web/vitest.config.ts +9 -0
- package/template/contracts/app.json +7 -0
- package/template/contracts/icons.json +5 -0
- package/template/contracts/strings/en.json +13 -0
- package/template/contracts/tokens.json +40 -0
- package/template/package.json +17 -0
- package/template/spec/decisions.md +10 -0
- package/template/spec/features/home.md +39 -0
- package/template/spec/features/settings.md +50 -0
- package/template/spec/navigation.md +16 -0
- package/template/spec/parity.md +29 -0
- 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
|
+
}
|