| 1 | #!/usr/bin/env node |
| 2 | |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodePath from "node:path"; |
| 5 | import tailwindColors from "tailwindcss/colors"; |
| 6 | import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes"; |
| 7 | |
| 8 | import { |
| 9 | createMobileThemeVariables, |
| 10 | getMobileThemeColors, |
| 11 | getMobileThemeVariables, |
| 12 | DEFAULT_MOBILE_THEME_ID, |
| 13 | type MobileThemeAppearance, |
| 14 | } from "../src/lib/mobileTheme.ts"; |
| 15 | |
| 16 | const APPEARANCES = ["light", "dark"] as const; |
| 17 | const GENERATED_CSS_PATH = NodePath.resolve(import.meta.dirname, "../generated-uniwind-themes.css"); |
| 18 | const GENERATED_NAMES_PATH = NodePath.resolve( |
| 19 | import.meta.dirname, |
| 20 | "../generated-uniwind-theme-names.json", |
| 21 | ); |
| 22 | const GENERATED_DEFAULT_VARIABLES_PATH = NodePath.resolve( |
| 23 | import.meta.dirname, |
| 24 | "../generated-uniwind-default-theme-variables.json", |
| 25 | ); |
| 26 | const GENERATED_CLERK_THEME_PATH = NodePath.resolve(import.meta.dirname, "../clerk-theme.json"); |
| 27 | |
| 28 | type TailwindColorFamily = keyof typeof tailwindColors; |
| 29 | type TailwindColorShade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950; |
| 30 | |
| 31 | const color = (family: TailwindColorFamily, shade?: TailwindColorShade, opacity = 1): string => { |
| 32 | const familyColors = tailwindColors[family]; |
| 33 | const value = |
| 34 | typeof familyColors === "string" |
| 35 | ? shade === undefined |
| 36 | ? familyColors |
| 37 | : undefined |
| 38 | : shade === undefined |
| 39 | ? undefined |
| 40 | : familyColors[String(shade) as keyof typeof familyColors]; |
| 41 | if (value === undefined) { |
| 42 | throw new Error(`Unknown Tailwind color ${family}${shade === undefined ? "" : `-${shade}`}.`); |
| 43 | } |
| 44 | if (opacity === 1) return value; |
| 45 | |
| 46 | const percentage = Number((opacity * 100).toFixed(4)); |
| 47 | const oklch = /^oklch\((.*)\)$/.exec(value); |
| 48 | if (oklch) return `oklch(${oklch[1]} / ${percentage}%)`; |
| 49 | if (value === "#fff") return `rgb(255 255 255 / ${percentage}%)`; |
| 50 | if (value === "#000") return `rgb(0 0 0 / ${percentage}%)`; |
| 51 | return `color-mix(in srgb, ${value} ${percentage}%, transparent)`; |
| 52 | }; |
| 53 | |
| 54 | // These replace the remaining dark:* utility pairs. A registered palette theme is |
| 55 | // neither literally `light` nor `dark`, so appearance-sensitive values must also be |
| 56 | // represented as semantic variables for custom themes. |
| 57 | const ADAPTIVE_COLORS: Readonly<Record<string, readonly [light: string, dark: string]>> = { |
| 58 | "--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)], |
| 59 | "--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)], |
| 60 | "--color-adaptive-amber-500-a12-a16": [color("amber", 500, 0.12), color("amber", 500, 0.16)], |
| 61 | "--color-adaptive-amber-500-a10-400-a10": [color("amber", 500, 0.1), color("amber", 400, 0.1)], |
| 62 | "--color-adaptive-amber-500-a25-400-a25": [color("amber", 500, 0.25), color("amber", 400, 0.25)], |
| 63 | "--color-adaptive-amber-700-300": [color("amber", 700), color("amber", 300)], |
| 64 | "--color-adaptive-amber-700-400": [color("amber", 700), color("amber", 400)], |
| 65 | "--color-adaptive-amber-800-200": [color("amber", 800), color("amber", 200)], |
| 66 | "--color-adaptive-blue-50-blue-400-a14": [color("blue", 50), color("blue", 400, 0.14)], |
| 67 | "--color-adaptive-blue-300-a50-blue-400-a28": [color("blue", 300, 0.5), color("blue", 400, 0.28)], |
| 68 | "--color-adaptive-blue-500-a20-blue-400-a15": [color("blue", 500, 0.2), color("blue", 400, 0.15)], |
| 69 | "--color-adaptive-blue-500-400": [color("blue", 500), color("blue", 400)], |
| 70 | "--color-adaptive-blue-600-400": [color("blue", 600), color("blue", 400)], |
| 71 | "--color-adaptive-black-a10-a25": [ |
| 72 | color("black", undefined, 0.1), |
| 73 | color("black", undefined, 0.25), |
| 74 | ], |
| 75 | "--color-adaptive-black-a15-a35": [ |
| 76 | color("black", undefined, 0.15), |
| 77 | color("black", undefined, 0.35), |
| 78 | ], |
| 79 | "--color-adaptive-black-a2p5-white-a2p5": [ |
| 80 | color("black", undefined, 0.025), |
| 81 | color("white", undefined, 0.025), |
| 82 | ], |
| 83 | "--color-adaptive-emerald-500-a12-a16": [ |
| 84 | color("emerald", 500, 0.12), |
| 85 | color("emerald", 500, 0.16), |
| 86 | ], |
| 87 | "--color-adaptive-emerald-600-400": [color("emerald", 600), color("emerald", 400)], |
| 88 | "--color-adaptive-emerald-700-300": [color("emerald", 700), color("emerald", 300)], |
| 89 | "--color-adaptive-indigo-500-a12-a16": [color("indigo", 500, 0.12), color("indigo", 500, 0.16)], |
| 90 | "--color-adaptive-indigo-600-300": [color("indigo", 600), color("indigo", 300)], |
| 91 | "--color-adaptive-indigo-700-300": [color("indigo", 700), color("indigo", 300)], |
| 92 | "--color-adaptive-neutral-100-900": [color("neutral", 100), color("neutral", 900)], |
| 93 | "--color-adaptive-neutral-200-700-a60": [color("neutral", 200), color("neutral", 700, 0.6)], |
| 94 | "--color-adaptive-neutral-200-800": [color("neutral", 200), color("neutral", 800)], |
| 95 | "--color-adaptive-neutral-200-a70-white-a8": [ |
| 96 | color("neutral", 200, 0.7), |
| 97 | color("white", undefined, 0.08), |
| 98 | ], |
| 99 | "--color-adaptive-neutral-200-white-a6": [color("neutral", 200), color("white", undefined, 0.06)], |
| 100 | "--color-adaptive-neutral-200-white-a8": [color("neutral", 200), color("white", undefined, 0.08)], |
| 101 | "--color-adaptive-neutral-200-a80-white-a8": [ |
| 102 | color("neutral", 200, 0.8), |
| 103 | color("white", undefined, 0.08), |
| 104 | ], |
| 105 | "--color-adaptive-neutral-300-a60-white-a12": [ |
| 106 | color("neutral", 300, 0.6), |
| 107 | color("white", undefined, 0.12), |
| 108 | ], |
| 109 | "--color-adaptive-neutral-400-500": [color("neutral", 400), color("neutral", 500)], |
| 110 | "--color-adaptive-neutral-400-a60-500-a60": [ |
| 111 | color("neutral", 400, 0.6), |
| 112 | color("neutral", 500, 0.6), |
| 113 | ], |
| 114 | "--color-adaptive-neutral-400-a80-500-a80": [ |
| 115 | color("neutral", 400, 0.8), |
| 116 | color("neutral", 500, 0.8), |
| 117 | ], |
| 118 | "--color-adaptive-neutral-500-a10-a16": [color("neutral", 500, 0.1), color("neutral", 500, 0.16)], |
| 119 | "--color-adaptive-neutral-500-400": [color("neutral", 500), color("neutral", 400)], |
| 120 | "--color-adaptive-neutral-500-500": [color("neutral", 500), color("neutral", 500)], |
| 121 | "--color-adaptive-neutral-600-300": [color("neutral", 600), color("neutral", 300)], |
| 122 | "--color-adaptive-neutral-600-400": [color("neutral", 600), color("neutral", 400)], |
| 123 | "--color-adaptive-neutral-950-50": [color("neutral", 950), color("neutral", 50)], |
| 124 | "--color-adaptive-neutral-950-a5-white-a5": [ |
| 125 | color("neutral", 950, 0.05), |
| 126 | color("white", undefined, 0.05), |
| 127 | ], |
| 128 | "--color-adaptive-neutral-950-a10-white-a10": [ |
| 129 | color("neutral", 950, 0.1), |
| 130 | color("white", undefined, 0.1), |
| 131 | ], |
| 132 | "--color-adaptive-red-50-950-a80": [color("red", 50), color("red", 950, 0.8)], |
| 133 | "--color-adaptive-red-200-800": [color("red", 200), color("red", 800)], |
| 134 | "--color-adaptive-red-600-a80-400-a80": [color("red", 600, 0.8), color("red", 400, 0.8)], |
| 135 | "--color-adaptive-red-700-300": [color("red", 700), color("red", 300)], |
| 136 | "--color-adaptive-rose-100-500-a18": [color("rose", 100), color("rose", 500, 0.18)], |
| 137 | "--color-adaptive-rose-100-a80-500-a12": [color("rose", 100, 0.8), color("rose", 500, 0.12)], |
| 138 | "--color-adaptive-rose-300-a70-400-a28": [color("rose", 300, 0.7), color("rose", 400, 0.28)], |
| 139 | "--color-adaptive-rose-500-a12-a16": [color("rose", 500, 0.12), color("rose", 500, 0.16)], |
| 140 | "--color-adaptive-rose-500-400": [color("rose", 500), color("rose", 400)], |
| 141 | "--color-adaptive-rose-600-400": [color("rose", 600), color("rose", 400)], |
| 142 | "--color-adaptive-rose-700-300": [color("rose", 700), color("rose", 300)], |
| 143 | "--color-adaptive-sky-500-a12-a16": [color("sky", 500, 0.12), color("sky", 500, 0.16)], |
| 144 | "--color-adaptive-sky-500-a10-400-a10": [color("sky", 500, 0.1), color("sky", 400, 0.1)], |
| 145 | "--color-adaptive-sky-500-a25-400-a25": [color("sky", 500, 0.25), color("sky", 400, 0.25)], |
| 146 | "--color-adaptive-sky-600-400": [color("sky", 600), color("sky", 400)], |
| 147 | "--color-adaptive-sky-700-300": [color("sky", 700), color("sky", 300)], |
| 148 | "--color-adaptive-violet-500-a12-a16": [color("violet", 500, 0.12), color("violet", 500, 0.16)], |
| 149 | "--color-adaptive-violet-600-400": [color("violet", 600), color("violet", 400)], |
| 150 | "--color-adaptive-violet-700-300": [color("violet", 700), color("violet", 300)], |
| 151 | "--color-adaptive-white-neutral-950-a70": [color("white"), color("neutral", 950, 0.7)], |
| 152 | "--color-adaptive-zinc-500-a12-a16": [color("zinc", 500, 0.12), color("zinc", 500, 0.16)], |
| 153 | "--color-adaptive-zinc-500-400": [color("zinc", 500), color("zinc", 400)], |
| 154 | "--color-adaptive-zinc-600-300": [color("zinc", 600), color("zinc", 300)], |
| 155 | }; |
| 156 | |
| 157 | export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) => |
| 158 | APPEARANCES.map((appearance) => `${themeId}-${appearance}`), |
| 159 | ); |
| 160 | |
| 161 | const adaptiveVariablesFor = (appearance: MobileThemeAppearance) => |
| 162 | Object.fromEntries( |
| 163 | Object.entries(ADAPTIVE_COLORS).map(([name, [light, dark]]) => [ |
| 164 | name, |
| 165 | appearance === "light" ? light : dark, |
| 166 | ]), |
| 167 | ); |
| 168 | |
| 169 | const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance) => ({ |
| 170 | ...getMobileThemeVariables(themeId, appearance), |
| 171 | ...adaptiveVariablesFor(appearance), |
| 172 | ...clerkVariablesFor(appearance), |
| 173 | }); |
| 174 | |
| 175 | // Clerk's native screens use one build-time palette per appearance. Custom |
| 176 | // profile pages must match it even when the rest of the app uses a named theme. |
| 177 | const clerkColorsFor = (appearance: MobileThemeAppearance) => { |
| 178 | // Native authentication uses plain cards, rather than tonal settings groups. |
| 179 | const variables = createMobileThemeVariables( |
| 180 | getMobileThemeColors(DEFAULT_MOBILE_THEME_ID, appearance), |
| 181 | appearance, |
| 182 | ); |
| 183 | return { |
| 184 | primary: variables["--color-primary"], |
| 185 | background: variables["--color-sheet-solid"], |
| 186 | input: variables["--color-input"], |
| 187 | danger: variables["--color-danger-foreground"], |
| 188 | success: appearance === "dark" ? "#34d399" : "#059669", |
| 189 | warning: variables["--color-warning-foreground"], |
| 190 | foreground: variables["--color-foreground"], |
| 191 | mutedForeground: variables["--color-foreground-muted"], |
| 192 | primaryForeground: variables["--color-primary-foreground"], |
| 193 | inputForeground: variables["--color-foreground"], |
| 194 | neutral: variables["--color-secondary"], |
| 195 | border: variables["--color-border"], |
| 196 | ring: variables["--color-focus"], |
| 197 | muted: variables["--color-subtle"], |
| 198 | shadow: variables["--color-primary-shadow"], |
| 199 | }; |
| 200 | }; |
| 201 | |
| 202 | const clerkVariablesFor = (appearance: MobileThemeAppearance) => { |
| 203 | const colors = clerkColorsFor(appearance); |
| 204 | return { |
| 205 | "--color-clerk-page": colors.background.toLowerCase(), |
| 206 | "--color-clerk-foreground": colors.foreground.toLowerCase(), |
| 207 | "--color-clerk-foreground-muted": colors.mutedForeground.toLowerCase(), |
| 208 | "--color-clerk-border": colors.border, |
| 209 | "--color-clerk-danger": colors.danger.toLowerCase(), |
| 210 | }; |
| 211 | }; |
| 212 | |
| 213 | const renderVariant = (name: string, variables: Readonly<Record<string, string>>) => { |
| 214 | const declarations = Object.entries(variables) |
| 215 | .map(([variable, value]) => ` ${variable}: ${value};`) |
| 216 | .join("\n"); |
| 217 | return ` @variant ${name} {\n${declarations}\n }`; |
| 218 | }; |
| 219 | |
| 220 | export const renderUniwindThemesCSS = () => { |
| 221 | const variants = [ |
| 222 | ...APPEARANCES.map((appearance) => |
| 223 | renderVariant(appearance, { |
| 224 | ...getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, appearance), |
| 225 | ...adaptiveVariablesFor(appearance), |
| 226 | ...clerkVariablesFor(appearance), |
| 227 | }), |
| 228 | ), |
| 229 | ...BUILT_IN_THEME_IDS.flatMap((themeId) => |
| 230 | APPEARANCES.map((appearance) => |
| 231 | renderVariant(`${themeId}-${appearance}`, variablesFor(themeId, appearance)), |
| 232 | ), |
| 233 | ), |
| 234 | ]; |
| 235 | return [ |
| 236 | "/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */", |
| 237 | "@layer theme {", |
| 238 | " :root {", |
| 239 | variants.join("\n\n"), |
| 240 | " }", |
| 241 | "}", |
| 242 | "", |
| 243 | ].join("\n"); |
| 244 | }; |
| 245 | |
| 246 | export const renderDefaultThemeVariablesJSON = () => |
| 247 | `${JSON.stringify( |
| 248 | Object.fromEntries( |
| 249 | APPEARANCES.map((appearance) => [ |
| 250 | appearance, |
| 251 | getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, appearance), |
| 252 | ]), |
| 253 | ), |
| 254 | null, |
| 255 | 2, |
| 256 | )}\n`; |
| 257 | |
| 258 | export const getGeneratedUniwindThemeOutputs = (): ReadonlyArray< |
| 259 | readonly [filename: string, contents: string] |
| 260 | > => [ |
| 261 | [GENERATED_CSS_PATH, renderUniwindThemesCSS()], |
| 262 | [GENERATED_NAMES_PATH, `${JSON.stringify(customThemeNames, null, 2)}\n`], |
| 263 | [GENERATED_DEFAULT_VARIABLES_PATH, renderDefaultThemeVariablesJSON()], |
| 264 | [ |
| 265 | GENERATED_CLERK_THEME_PATH, |
| 266 | `${JSON.stringify({ colors: clerkColorsFor("light"), darkColors: clerkColorsFor("dark"), design: { borderRadius: 18 } }, null, 2)}\n`, |
| 267 | ], |
| 268 | ]; |
| 269 | |
| 270 | const writeFileAtomically = (filename: string, contents: string) => { |
| 271 | const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null; |
| 272 | if (current === contents) return; |
| 273 | |
| 274 | const temporaryFilename = `${filename}.${process.pid}.tmp`; |
| 275 | try { |
| 276 | NodeFS.writeFileSync(temporaryFilename, contents); |
| 277 | NodeFS.renameSync(temporaryFilename, filename); |
| 278 | } finally { |
| 279 | if (NodeFS.existsSync(temporaryFilename)) NodeFS.unlinkSync(temporaryFilename); |
| 280 | } |
| 281 | }; |
| 282 | |
| 283 | if (import.meta.main) { |
| 284 | const checkOnly = process.argv.includes("--check"); |
| 285 | for (const [filename, contents] of getGeneratedUniwindThemeOutputs()) { |
| 286 | if (checkOnly) { |
| 287 | const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null; |
| 288 | if (current !== contents) { |
| 289 | console.error( |
| 290 | `${NodePath.relative(process.cwd(), filename)} is stale. Run vp run --filter @t3tools/mobile generate.`, |
| 291 | ); |
| 292 | process.exitCode = 1; |
| 293 | } |
| 294 | continue; |
| 295 | } |
| 296 | // Metro watches the generated CSS. Replacing a complete temporary file keeps |
| 297 | // Tailwind from compiling a partially rewritten theme file. |
| 298 | writeFileAtomically(filename, contents); |
| 299 | } |
| 300 | } |