Skip to content
Source
Sign inSign up

generate-uniwind-themes.mts

300 lines13.0 KB

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// Named palette themes need semantic appearance variables because their names are neither light nor dark.
55const ADAPTIVE_COLORS: Readonly<Record<string, readonly [light: string, dark: string]>> = {
56 "--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)],
57 "--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)],
58 "--color-adaptive-amber-500-a12-a16": [color("amber", 500, 0.12), color("amber", 500, 0.16)],
59 "--color-adaptive-amber-500-a10-400-a10": [color("amber", 500, 0.1), color("amber", 400, 0.1)],
60 "--color-adaptive-amber-500-a25-400-a25": [color("amber", 500, 0.25), color("amber", 400, 0.25)],
61 "--color-adaptive-amber-700-300": [color("amber", 700), color("amber", 300)],
62 "--color-adaptive-amber-700-400": [color("amber", 700), color("amber", 400)],
63 "--color-adaptive-amber-800-200": [color("amber", 800), color("amber", 200)],
64 "--color-adaptive-blue-50-blue-400-a14": [color("blue", 50), color("blue", 400, 0.14)],
65 "--color-adaptive-blue-300-a50-blue-400-a28": [color("blue", 300, 0.5), color("blue", 400, 0.28)],
66 "--color-adaptive-blue-500-a20-blue-400-a15": [color("blue", 500, 0.2), color("blue", 400, 0.15)],
67 "--color-adaptive-blue-500-400": [color("blue", 500), color("blue", 400)],
68 "--color-adaptive-blue-600-400": [color("blue", 600), color("blue", 400)],
69 "--color-adaptive-black-a10-a25": [
70 color("black", undefined, 0.1),
71 color("black", undefined, 0.25),
72 ],
73 "--color-adaptive-black-a15-a35": [
74 color("black", undefined, 0.15),
75 color("black", undefined, 0.35),
76 ],
77 "--color-adaptive-black-a2p5-white-a2p5": [
78 color("black", undefined, 0.025),
79 color("white", undefined, 0.025),
80 ],
81 "--color-adaptive-emerald-500-a12-a16": [
82 color("emerald", 500, 0.12),
83 color("emerald", 500, 0.16),
84 ],
85 "--color-adaptive-emerald-600-400": [color("emerald", 600), color("emerald", 400)],
86 "--color-adaptive-emerald-700-300": [color("emerald", 700), color("emerald", 300)],
87 "--color-adaptive-fuchsia-500-a50-300-a40": [
88 color("fuchsia", 500, 0.5),
89 color("fuchsia", 300, 0.4),
90 ],
91 "--color-adaptive-fuchsia-600-300": [color("fuchsia", 600), color("fuchsia", 300)],
92 "--color-adaptive-indigo-500-a12-a16": [color("indigo", 500, 0.12), color("indigo", 500, 0.16)],
93 "--color-adaptive-indigo-600-300": [color("indigo", 600), color("indigo", 300)],
94 "--color-adaptive-indigo-700-300": [color("indigo", 700), color("indigo", 300)],
95 "--color-adaptive-neutral-100-900": [color("neutral", 100), color("neutral", 900)],
96 "--color-adaptive-neutral-200-700-a60": [color("neutral", 200), color("neutral", 700, 0.6)],
97 "--color-adaptive-neutral-200-800": [color("neutral", 200), color("neutral", 800)],
98 "--color-adaptive-neutral-200-a70-white-a8": [
99 color("neutral", 200, 0.7),
100 color("white", undefined, 0.08),
101 ],
102 "--color-adaptive-neutral-200-white-a6": [color("neutral", 200), color("white", undefined, 0.06)],
103 "--color-adaptive-neutral-200-white-a8": [color("neutral", 200), color("white", undefined, 0.08)],
104 "--color-adaptive-neutral-200-a80-white-a8": [
105 color("neutral", 200, 0.8),
106 color("white", undefined, 0.08),
107 ],
108 "--color-adaptive-neutral-300-a60-white-a12": [
109 color("neutral", 300, 0.6),
110 color("white", undefined, 0.12),
111 ],
112 "--color-adaptive-neutral-400-500": [color("neutral", 400), color("neutral", 500)],
113 "--color-adaptive-neutral-400-a60-500-a60": [
114 color("neutral", 400, 0.6),
115 color("neutral", 500, 0.6),
116 ],
117 "--color-adaptive-neutral-400-a80-500-a80": [
118 color("neutral", 400, 0.8),
119 color("neutral", 500, 0.8),
120 ],
121 "--color-adaptive-neutral-500-a10-a16": [color("neutral", 500, 0.1), color("neutral", 500, 0.16)],
122 "--color-adaptive-neutral-500-400": [color("neutral", 500), color("neutral", 400)],
123 "--color-adaptive-neutral-500-500": [color("neutral", 500), color("neutral", 500)],
124 "--color-adaptive-neutral-600-300": [color("neutral", 600), color("neutral", 300)],
125 "--color-adaptive-neutral-600-400": [color("neutral", 600), color("neutral", 400)],
126 "--color-adaptive-neutral-950-50": [color("neutral", 950), color("neutral", 50)],
127 "--color-adaptive-neutral-950-a5-white-a5": [
128 color("neutral", 950, 0.05),
129 color("white", undefined, 0.05),
130 ],
131 "--color-adaptive-neutral-950-a10-white-a10": [
132 color("neutral", 950, 0.1),
133 color("white", undefined, 0.1),
134 ],
135 "--color-adaptive-red-50-950-a80": [color("red", 50), color("red", 950, 0.8)],
136 "--color-adaptive-red-200-800": [color("red", 200), color("red", 800)],
137 "--color-adaptive-red-600-a80-400-a80": [color("red", 600, 0.8), color("red", 400, 0.8)],
138 "--color-adaptive-red-700-300": [color("red", 700), color("red", 300)],
139 "--color-adaptive-rose-100-500-a18": [color("rose", 100), color("rose", 500, 0.18)],
140 "--color-adaptive-rose-100-a80-500-a12": [color("rose", 100, 0.8), color("rose", 500, 0.12)],
141 "--color-adaptive-rose-300-a70-400-a28": [color("rose", 300, 0.7), color("rose", 400, 0.28)],
142 "--color-adaptive-rose-500-a12-a16": [color("rose", 500, 0.12), color("rose", 500, 0.16)],
143 "--color-adaptive-rose-500-400": [color("rose", 500), color("rose", 400)],
144 "--color-adaptive-rose-600-400": [color("rose", 600), color("rose", 400)],
145 "--color-adaptive-rose-700-300": [color("rose", 700), color("rose", 300)],
146 "--color-adaptive-sky-500-a12-a16": [color("sky", 500, 0.12), color("sky", 500, 0.16)],
147 "--color-adaptive-sky-500-a10-400-a10": [color("sky", 500, 0.1), color("sky", 400, 0.1)],
148 "--color-adaptive-sky-500-a25-400-a25": [color("sky", 500, 0.25), color("sky", 400, 0.25)],
149 "--color-adaptive-sky-600-400": [color("sky", 600), color("sky", 400)],
150 "--color-adaptive-sky-700-300": [color("sky", 700), color("sky", 300)],
151 "--color-adaptive-violet-500-a12-a16": [color("violet", 500, 0.12), color("violet", 500, 0.16)],
152 "--color-adaptive-violet-600-400": [color("violet", 600), color("violet", 400)],
153 "--color-adaptive-violet-700-300": [color("violet", 700), color("violet", 300)],
154 "--color-adaptive-white-neutral-950-a70": [color("white"), color("neutral", 950, 0.7)],
155 "--color-adaptive-zinc-500-a12-a16": [color("zinc", 500, 0.12), color("zinc", 500, 0.16)],
156 "--color-adaptive-zinc-500-400": [color("zinc", 500), color("zinc", 400)],
157 "--color-adaptive-zinc-600-300": [color("zinc", 600), color("zinc", 300)],
158};
159​
160export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) =>
161 APPEARANCES.map((appearance) => `${themeId}-${appearance}`),
162);
163​
164const adaptiveVariablesFor = (appearance: MobileThemeAppearance) =>
165 Object.fromEntries(
166 Object.entries(ADAPTIVE_COLORS).map(([name, [light, dark]]) => [
167 name,
168 appearance === "light" ? light : dark,
169 ]),
170 );
171​
172const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance) => ({
173 ...getMobileThemeVariables(themeId, appearance),
174 ...adaptiveVariablesFor(appearance),
175 ...clerkVariablesFor(appearance),
176});
177​
178// Profile pages must match Clerk's build-time palette even with a named app theme.
179const clerkColorsFor = (appearance: MobileThemeAppearance) => {
180 const variables = createMobileThemeVariables(
181 getMobileThemeColors(DEFAULT_MOBILE_THEME_ID, appearance),
182 appearance,
183 );
184 return {
185 primary: variables["--color-primary"],
186 background: variables["--color-sheet-solid"],
187 input: variables["--color-input"],
188 danger: variables["--color-danger-foreground"],
189 success: appearance === "dark" ? "#34d399" : "#059669",
190 warning: variables["--color-warning-foreground"],
191 foreground: variables["--color-foreground"],
192 mutedForeground: variables["--color-foreground-muted"],
193 primaryForeground: variables["--color-primary-foreground"],
194 inputForeground: variables["--color-foreground"],
195 neutral: variables["--color-secondary"],
196 border: variables["--color-border"],
197 ring: variables["--color-focus"],
198 muted: variables["--color-subtle"],
199 shadow: variables["--color-primary-shadow"],
200 };
201};
202​
203const clerkVariablesFor = (appearance: MobileThemeAppearance) => {
204 const colors = clerkColorsFor(appearance);
205 return {
206 "--color-clerk-page": colors.background.toLowerCase(),
207 "--color-clerk-foreground": colors.foreground.toLowerCase(),
208 "--color-clerk-foreground-muted": colors.mutedForeground.toLowerCase(),
209 "--color-clerk-border": colors.border,
210 "--color-clerk-danger": colors.danger.toLowerCase(),
211 };
212};
213​
214const renderVariant = (name: string, variables: Readonly<Record<string, string>>) => {
215 const declarations = Object.entries(variables)
216 .map(([variable, value]) => ` ${variable}: ${value};`)
217 .join("\n");
218 return ` @variant ${name} {\n${declarations}\n }`;
219};
220​
221export const renderUniwindThemesCSS = () => {
222 const variants = [
223 ...APPEARANCES.map((appearance) =>
224 renderVariant(appearance, {
225 ...getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, appearance),
226 ...adaptiveVariablesFor(appearance),
227 ...clerkVariablesFor(appearance),
228 }),
229 ),
230 ...BUILT_IN_THEME_IDS.flatMap((themeId) =>
231 APPEARANCES.map((appearance) =>
232 renderVariant(`${themeId}-${appearance}`, variablesFor(themeId, appearance)),
233 ),
234 ),
235 ];
236 return [
237 "/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */",
238 "@layer theme {",
239 " :root {",
240 variants.join("\n\n"),
241 " }",
242 "}",
243 "",
244 ].join("\n");
245};
246​
247export const renderDefaultThemeVariablesJSON = () =>
248 `${JSON.stringify(
249 Object.fromEntries(
250 APPEARANCES.map((appearance) => [
251 appearance,
252 getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, appearance),
253 ]),
254 ),
255 null,
256 2,
257 )}\n`;
258​
259export const getGeneratedUniwindThemeOutputs = (): ReadonlyArray<
260 readonly [filename: string, contents: string]
261> => [
262 [GENERATED_CSS_PATH, renderUniwindThemesCSS()],
263 [GENERATED_NAMES_PATH, `${JSON.stringify(customThemeNames, null, 2)}\n`],
264 [GENERATED_DEFAULT_VARIABLES_PATH, renderDefaultThemeVariablesJSON()],
265 [
266 GENERATED_CLERK_THEME_PATH,
267 `${JSON.stringify({ colors: clerkColorsFor("light"), darkColors: clerkColorsFor("dark"), design: { borderRadius: 18 } }, null, 2)}\n`,
268 ],
269];
270​
271const writeFileAtomically = (filename: string, contents: string) => {
272 const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null;
273 if (current === contents) return;
274​
275 const temporaryFilename = `${filename}.${process.pid}.tmp`;
276 try {
277 NodeFS.writeFileSync(temporaryFilename, contents);
278 NodeFS.renameSync(temporaryFilename, filename);
279 } finally {
280 if (NodeFS.existsSync(temporaryFilename)) NodeFS.unlinkSync(temporaryFilename);
281 }
282};
283​
284if (import.meta.main) {
285 const checkOnly = process.argv.includes("--check");
286 for (const [filename, contents] of getGeneratedUniwindThemeOutputs()) {
287 if (checkOnly) {
288 const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null;
289 if (current !== contents) {
290 console.error(
291 `${NodePath.relative(process.cwd(), filename)} is stale. Run vp run --filter @t3tools/mobile generate.`,
292 );
293 process.exitCode = 1;
294 }
295 continue;
296 }
297 // Atomic replacement prevents Metro from compiling partial CSS.
298 writeFileAtomically(filename, contents);
299 }