1#!/usr/bin/env node
2
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5import tailwindColors from "tailwindcss/colors";
6import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes";
7
8import {
9 createMobileThemeVariables,
10 getMobileThemeColors,
11 getMobileThemeVariables,
12 DEFAULT_MOBILE_THEME_ID,
13 type MobileThemeAppearance,
14} from "../src/lib/mobileTheme.ts";
15
16const APPEARANCES = ["light", "dark"] as const;
17const GENERATED_CSS_PATH = NodePath.resolve(import.meta.dirname, "../generated-uniwind-themes.css");
18const GENERATED_NAMES_PATH = NodePath.resolve(
19 import.meta.dirname,
20 "../generated-uniwind-theme-names.json",
21);
22const GENERATED_DEFAULT_VARIABLES_PATH = NodePath.resolve(
23 import.meta.dirname,
24 "../generated-uniwind-default-theme-variables.json",
25);
26const GENERATED_CLERK_THEME_PATH = NodePath.resolve(import.meta.dirname, "../clerk-theme.json");
27
28type TailwindColorFamily = keyof typeof tailwindColors;
29type TailwindColorShade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950;
30
31const 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.
57const 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
157export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) =>
158 APPEARANCES.map((appearance) => `${themeId}-${appearance}`),
159);
160
161const 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
169const 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.
177const 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
202const 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
213const 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
220export 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
246export 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
258export 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
270const 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
283if (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}