Skip to content

Commit 018d7f2

Browse files
refactor(mobile): compile semantic themes for Uniwind (#7327)
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 9257bd8 commit 018d7f2

110 files changed

Lines changed: 3507 additions & 1092 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/mobile/src/lib/mobileDefaultTheme.ts renamed to apps/mobile/generated-uniwind-default-theme-variables.json

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
1-
import type { MobileThemeVariables } from "./mobileTheme";
2-
3-
/** The existing T3 Code mobile palette, retained as the upgrade-safe default. */
4-
export const DEFAULT_MOBILE_THEME_VARIABLES = {
5-
light: {
1+
{
2+
"light": {
63
"--color-screen": "#f2f2f7",
74
"--color-sheet": "rgba(242, 242, 247, 0.98)",
85
"--color-sheet-solid": "#f2f2f7",
@@ -67,9 +64,9 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = {
6764
"--color-drawer-shadow": "rgba(0, 0, 0, 0.12)",
6865
"--color-dot-separator": "rgba(0, 0, 0, 0.2)",
6966
"--color-wordmark": "#262626",
70-
"--color-chevron": "rgba(0, 0, 0, 0.2)",
67+
"--color-chevron": "rgba(0, 0, 0, 0.2)"
7168
},
72-
dark: {
69+
"dark": {
7370
"--color-screen": "#0a0a0a",
7471
"--color-sheet": "rgba(14, 14, 14, 0.98)",
7572
"--color-sheet-solid": "#0e0e0e",
@@ -134,6 +131,6 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = {
134131
"--color-drawer-shadow": "rgba(0, 0, 0, 0.32)",
135132
"--color-dot-separator": "rgba(255, 255, 255, 0.2)",
136133
"--color-wordmark": "#f5f5f5",
137-
"--color-chevron": "rgba(255, 255, 255, 0.2)",
138-
},
139-
} as const satisfies Readonly<Record<"light" | "dark", MobileThemeVariables>>;
134+
"--color-chevron": "rgba(255, 255, 255, 0.2)"
135+
}
136+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[
2+
"t3-chat-light",
3+
"t3-chat-dark",
4+
"grove-light",
5+
"grove-dark",
6+
"ocean-light",
7+
"ocean-dark",
8+
"ember-light",
9+
"ember-dark",
10+
"iris-light",
11+
"iris-dark"
12+
]

apps/mobile/generated-uniwind-themes.css

Lines changed: 1374 additions & 0 deletions
Large diffs are not rendered by default.

apps/mobile/global.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@import "tailwindcss";
22
@import "uniwind";
3+
@import "./generated-uniwind-themes.css";
34

45
/* ─── Theme tokens ──────────────────────────────────────────────────── */
56
@layer theme {

apps/mobile/metro.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const fs = require("node:fs");
22
const path = require("node:path");
33
const { getDefaultConfig } = require("expo/metro-config");
44
const { withUniwindConfig } = require("uniwind/metro");
5+
const extraThemes = require("./generated-uniwind-theme-names.json");
56

67
/** @type {import("expo/metro-config").MetroConfig} */
78
const config = getDefaultConfig(__dirname);
@@ -50,5 +51,6 @@ config.resolver = {
5051

5152
module.exports = withUniwindConfig(config, {
5253
cssEntryFile: "./global.css",
54+
extraThemes,
5355
polyfills: { rem: 14 },
5456
});

apps/mobile/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"config:prod": "APP_VARIANT=production expo config",
4040
"profile:android:hermes": "mkdir -p profiles/review && react-native profile-hermes profiles/review",
4141
"sync:pierre-icons": "node modules/t3-markdown-text/scripts/sync-pierre-file-icons.mjs",
42+
"generate": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/generate-uniwind-themes.mts",
4243
"test": "vp test run",
4344
"typecheck": "tsc --noEmit"
4445
},
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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+
getMobileThemeVariables,
10+
MOBILE_THEME_VARIABLE_NAMES,
11+
type MobileThemeAppearance,
12+
type MobileThemeVariables,
13+
} from "../src/lib/mobileTheme.ts";
14+
15+
const APPEARANCES = ["light", "dark"] as const;
16+
const GLOBAL_CSS_PATH = NodePath.resolve(import.meta.dirname, "../global.css");
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+
27+
type TailwindColorFamily = keyof typeof tailwindColors;
28+
type TailwindColorShade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950;
29+
30+
const color = (family: TailwindColorFamily, shade?: TailwindColorShade, opacity = 1): string => {
31+
const familyColors = tailwindColors[family];
32+
const value =
33+
typeof familyColors === "string"
34+
? shade === undefined
35+
? familyColors
36+
: undefined
37+
: shade === undefined
38+
? undefined
39+
: familyColors[String(shade) as keyof typeof familyColors];
40+
if (value === undefined) {
41+
throw new Error(`Unknown Tailwind color ${family}${shade === undefined ? "" : `-${shade}`}.`);
42+
}
43+
if (opacity === 1) return value;
44+
45+
const percentage = Number((opacity * 100).toFixed(4));
46+
const oklch = /^oklch\((.*)\)$/.exec(value);
47+
if (oklch) return `oklch(${oklch[1]} / ${percentage}%)`;
48+
if (value === "#fff") return `rgb(255 255 255 / ${percentage}%)`;
49+
if (value === "#000") return `rgb(0 0 0 / ${percentage}%)`;
50+
return `color-mix(in srgb, ${value} ${percentage}%, transparent)`;
51+
};
52+
53+
// These replace the remaining dark:* utility pairs. A registered palette theme is
54+
// neither literally `light` nor `dark`, so appearance-sensitive values must also be
55+
// represented as semantic variables for custom themes.
56+
const ADAPTIVE_COLORS = {
57+
"--color-adaptive-amber-50-950-a40": [color("amber", 50), color("amber", 950, 0.4)],
58+
"--color-adaptive-amber-200-900-a60": [color("amber", 200), color("amber", 900, 0.6)],
59+
"--color-adaptive-amber-500-a12-a16": [color("amber", 500, 0.12), color("amber", 500, 0.16)],
60+
"--color-adaptive-amber-700-300": [color("amber", 700), color("amber", 300)],
61+
"--color-adaptive-amber-700-400": [color("amber", 700), color("amber", 400)],
62+
"--color-adaptive-amber-800-200": [color("amber", 800), color("amber", 200)],
63+
"--color-adaptive-blue-50-blue-400-a14": [color("blue", 50), color("blue", 400, 0.14)],
64+
"--color-adaptive-blue-300-a50-blue-400-a28": [color("blue", 300, 0.5), color("blue", 400, 0.28)],
65+
"--color-adaptive-blue-500-a20-blue-400-a15": [color("blue", 500, 0.2), color("blue", 400, 0.15)],
66+
"--color-adaptive-blue-500-400": [color("blue", 500), color("blue", 400)],
67+
"--color-adaptive-blue-600-400": [color("blue", 600), color("blue", 400)],
68+
"--color-adaptive-black-a10-a25": [
69+
color("black", undefined, 0.1),
70+
color("black", undefined, 0.25),
71+
],
72+
"--color-adaptive-black-a15-a35": [
73+
color("black", undefined, 0.15),
74+
color("black", undefined, 0.35),
75+
],
76+
"--color-adaptive-emerald-500-a12-a16": [
77+
color("emerald", 500, 0.12),
78+
color("emerald", 500, 0.16),
79+
],
80+
"--color-adaptive-emerald-600-400": [color("emerald", 600), color("emerald", 400)],
81+
"--color-adaptive-emerald-700-300": [color("emerald", 700), color("emerald", 300)],
82+
"--color-adaptive-indigo-500-a12-a16": [color("indigo", 500, 0.12), color("indigo", 500, 0.16)],
83+
"--color-adaptive-indigo-600-300": [color("indigo", 600), color("indigo", 300)],
84+
"--color-adaptive-indigo-700-300": [color("indigo", 700), color("indigo", 300)],
85+
"--color-adaptive-neutral-100-900": [color("neutral", 100), color("neutral", 900)],
86+
"--color-adaptive-neutral-200-700-a60": [color("neutral", 200), color("neutral", 700, 0.6)],
87+
"--color-adaptive-neutral-200-800": [color("neutral", 200), color("neutral", 800)],
88+
"--color-adaptive-neutral-200-a70-white-a8": [
89+
color("neutral", 200, 0.7),
90+
color("white", undefined, 0.08),
91+
],
92+
"--color-adaptive-neutral-200-white-a6": [color("neutral", 200), color("white", undefined, 0.06)],
93+
"--color-adaptive-neutral-200-white-a8": [color("neutral", 200), color("white", undefined, 0.08)],
94+
"--color-adaptive-neutral-200-a80-white-a8": [
95+
color("neutral", 200, 0.8),
96+
color("white", undefined, 0.08),
97+
],
98+
"--color-adaptive-neutral-300-a60-white-a12": [
99+
color("neutral", 300, 0.6),
100+
color("white", undefined, 0.12),
101+
],
102+
"--color-adaptive-neutral-400-500": [color("neutral", 400), color("neutral", 500)],
103+
"--color-adaptive-neutral-400-a60-500-a60": [
104+
color("neutral", 400, 0.6),
105+
color("neutral", 500, 0.6),
106+
],
107+
"--color-adaptive-neutral-400-a80-500-a80": [
108+
color("neutral", 400, 0.8),
109+
color("neutral", 500, 0.8),
110+
],
111+
"--color-adaptive-neutral-500-a10-a16": [color("neutral", 500, 0.1), color("neutral", 500, 0.16)],
112+
"--color-adaptive-neutral-500-400": [color("neutral", 500), color("neutral", 400)],
113+
"--color-adaptive-neutral-500-500": [color("neutral", 500), color("neutral", 500)],
114+
"--color-adaptive-neutral-600-300": [color("neutral", 600), color("neutral", 300)],
115+
"--color-adaptive-neutral-600-400": [color("neutral", 600), color("neutral", 400)],
116+
"--color-adaptive-neutral-950-50": [color("neutral", 950), color("neutral", 50)],
117+
"--color-adaptive-red-50-950-a80": [color("red", 50), color("red", 950, 0.8)],
118+
"--color-adaptive-red-200-800": [color("red", 200), color("red", 800)],
119+
"--color-adaptive-red-600-a80-400-a80": [color("red", 600, 0.8), color("red", 400, 0.8)],
120+
"--color-adaptive-red-700-300": [color("red", 700), color("red", 300)],
121+
"--color-adaptive-rose-100-500-a18": [color("rose", 100), color("rose", 500, 0.18)],
122+
"--color-adaptive-rose-100-a80-500-a12": [color("rose", 100, 0.8), color("rose", 500, 0.12)],
123+
"--color-adaptive-rose-300-a70-400-a28": [color("rose", 300, 0.7), color("rose", 400, 0.28)],
124+
"--color-adaptive-rose-500-a12-a16": [color("rose", 500, 0.12), color("rose", 500, 0.16)],
125+
"--color-adaptive-rose-500-400": [color("rose", 500), color("rose", 400)],
126+
"--color-adaptive-rose-600-400": [color("rose", 600), color("rose", 400)],
127+
"--color-adaptive-rose-700-300": [color("rose", 700), color("rose", 300)],
128+
"--color-adaptive-sky-500-a12-a16": [color("sky", 500, 0.12), color("sky", 500, 0.16)],
129+
"--color-adaptive-sky-600-400": [color("sky", 600), color("sky", 400)],
130+
"--color-adaptive-sky-700-300": [color("sky", 700), color("sky", 300)],
131+
"--color-adaptive-violet-500-a12-a16": [color("violet", 500, 0.12), color("violet", 500, 0.16)],
132+
"--color-adaptive-violet-600-400": [color("violet", 600), color("violet", 400)],
133+
"--color-adaptive-violet-700-300": [color("violet", 700), color("violet", 300)],
134+
"--color-adaptive-white-neutral-950-a70": [color("white"), color("neutral", 950, 0.7)],
135+
"--color-adaptive-zinc-500-a12-a16": [color("zinc", 500, 0.12), color("zinc", 500, 0.16)],
136+
"--color-adaptive-zinc-500-400": [color("zinc", 500), color("zinc", 400)],
137+
"--color-adaptive-zinc-600-300": [color("zinc", 600), color("zinc", 300)],
138+
};
139+
140+
export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) =>
141+
APPEARANCES.map((appearance) => `${themeId}-${appearance}`),
142+
);
143+
144+
const adaptiveVariablesFor = (appearance: MobileThemeAppearance) =>
145+
Object.fromEntries(
146+
Object.entries(ADAPTIVE_COLORS).map(([name, values]) => [
147+
name,
148+
values[appearance === "light" ? 0 : 1],
149+
]),
150+
);
151+
152+
const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance) => ({
153+
...getMobileThemeVariables(themeId, appearance),
154+
...adaptiveVariablesFor(appearance),
155+
});
156+
157+
const renderVariant = (name: string, variables: Readonly<Record<string, string>>) => {
158+
const declarations = Object.entries(variables)
159+
.map(([variable, value]) => ` ${variable}: ${value};`)
160+
.join("\n");
161+
return ` @variant ${name} {\n${declarations}\n }`;
162+
};
163+
164+
export const renderUniwindThemesCSS = () => {
165+
const variants = [
166+
renderVariant("light", adaptiveVariablesFor("light")),
167+
renderVariant("dark", adaptiveVariablesFor("dark")),
168+
...BUILT_IN_THEME_IDS.flatMap((themeId) =>
169+
APPEARANCES.map((appearance) =>
170+
renderVariant(`${themeId}-${appearance}`, variablesFor(themeId, appearance)),
171+
),
172+
),
173+
];
174+
return [
175+
"/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */",
176+
"@layer theme {",
177+
" :root {",
178+
variants.join("\n\n"),
179+
" }",
180+
"}",
181+
"",
182+
].join("\n");
183+
};
184+
185+
const readVariantBody = (css: string, appearance: MobileThemeAppearance): string => {
186+
const marker = `@variant ${appearance} {`;
187+
const markerIndex = css.indexOf(marker);
188+
if (markerIndex === -1) throw new Error(`Could not find ${marker} in global.css.`);
189+
190+
const openingBraceIndex = css.indexOf("{", markerIndex);
191+
let depth = 0;
192+
for (let index = openingBraceIndex; index < css.length; index += 1) {
193+
if (css[index] === "{") depth += 1;
194+
if (css[index] !== "}") continue;
195+
depth -= 1;
196+
if (depth === 0) return css.slice(openingBraceIndex + 1, index);
197+
}
198+
throw new Error(`Could not find the end of ${marker} in global.css.`);
199+
};
200+
201+
export const readDefaultThemeVariables = (css: string) =>
202+
Object.fromEntries(
203+
APPEARANCES.map((appearance) => {
204+
const body = readVariantBody(css, appearance);
205+
const variables = Object.fromEntries(
206+
MOBILE_THEME_VARIABLE_NAMES.map((name) => {
207+
const match = new RegExp(`^\\s*${name}:\\s*([^;]+);`, "mu").exec(body);
208+
if (!match?.[1]) {
209+
throw new Error(`Default ${appearance} theme is missing ${name}.`);
210+
}
211+
return [name, match[1].trim()];
212+
}),
213+
) as MobileThemeVariables;
214+
return [appearance, variables];
215+
}),
216+
) as Readonly<Record<MobileThemeAppearance, MobileThemeVariables>>;
217+
218+
export const renderDefaultThemeVariablesJSON = (css: string) =>
219+
`${JSON.stringify(readDefaultThemeVariables(css), null, 2)}\n`;
220+
221+
export const getGeneratedUniwindThemeOutputs = (): ReadonlyArray<
222+
readonly [filename: string, contents: string]
223+
> => [
224+
[GENERATED_CSS_PATH, renderUniwindThemesCSS()],
225+
[GENERATED_NAMES_PATH, `${JSON.stringify(customThemeNames, null, 2)}\n`],
226+
[
227+
GENERATED_DEFAULT_VARIABLES_PATH,
228+
renderDefaultThemeVariablesJSON(NodeFS.readFileSync(GLOBAL_CSS_PATH, "utf8")),
229+
],
230+
];
231+
232+
const writeFileAtomically = (filename: string, contents: string) => {
233+
const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null;
234+
if (current === contents) return;
235+
236+
const temporaryFilename = `${filename}.${process.pid}.tmp`;
237+
try {
238+
NodeFS.writeFileSync(temporaryFilename, contents);
239+
NodeFS.renameSync(temporaryFilename, filename);
240+
} finally {
241+
if (NodeFS.existsSync(temporaryFilename)) NodeFS.unlinkSync(temporaryFilename);
242+
}
243+
};
244+
245+
if (import.meta.main) {
246+
const checkOnly = process.argv.includes("--check");
247+
for (const [filename, contents] of getGeneratedUniwindThemeOutputs()) {
248+
if (checkOnly) {
249+
const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null;
250+
if (current !== contents) {
251+
console.error(
252+
`${NodePath.relative(process.cwd(), filename)} is stale. Run vp run --filter @t3tools/mobile generate.`,
253+
);
254+
process.exitCode = 1;
255+
}
256+
continue;
257+
}
258+
// Metro watches the generated CSS. Replacing a complete temporary file keeps
259+
// Tailwind from compiling a partially rewritten theme file.
260+
writeFileAtomically(filename, contents);
261+
}
262+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as NodeFS from "node:fs";
2+
import * as NodePath from "node:path";
3+
import { describe, expect, it } from "vite-plus/test";
4+
5+
import {
6+
customThemeNames,
7+
getGeneratedUniwindThemeOutputs,
8+
readDefaultThemeVariables,
9+
renderUniwindThemesCSS,
10+
} from "./generate-uniwind-themes.mts";
11+
12+
describe("generate mobile Uniwind themes", () => {
13+
it("keeps the committed outputs current", () => {
14+
const staleOutputs = getGeneratedUniwindThemeOutputs()
15+
.filter(
16+
([filename, contents]) =>
17+
!NodeFS.existsSync(filename) || NodeFS.readFileSync(filename, "utf8") !== contents,
18+
)
19+
.map(([filename]) => NodePath.relative(import.meta.dirname, filename));
20+
21+
expect(
22+
staleOutputs,
23+
"Run `vp run --filter @t3tools/mobile generate` and commit the generated outputs.",
24+
).toEqual([]);
25+
});
26+
27+
it("registers every custom palette for both appearances", () => {
28+
expect(customThemeNames).toEqual([
29+
"t3-chat-light",
30+
"t3-chat-dark",
31+
"grove-light",
32+
"grove-dark",
33+
"ocean-light",
34+
"ocean-dark",
35+
"ember-light",
36+
"ember-dark",
37+
"iris-light",
38+
"iris-dark",
39+
]);
40+
41+
const stylesheet = renderUniwindThemesCSS();
42+
for (const themeName of customThemeNames) {
43+
expect(stylesheet.match(new RegExp(`@variant ${themeName} \\{`, "gu"))).toHaveLength(1);
44+
}
45+
});
46+
47+
it("generates the default runtime bridge from the authored CSS", () => {
48+
const css = NodeFS.readFileSync(NodePath.resolve(import.meta.dirname, "../global.css"), "utf8");
49+
const variables = readDefaultThemeVariables(css);
50+
51+
expect(variables.light["--color-screen"]).toBe("#f2f2f7");
52+
expect(variables.dark["--color-screen"]).toBe("#0a0a0a");
53+
expect(Object.keys(variables.light)).toEqual(Object.keys(variables.dark));
54+
});
55+
});

0 commit comments

Comments
 (0)