Skip to content

Commit eafef85

Browse files
authored
Merge pull request #138 from pylon-code/upstream/2026-08-28-mobile-uniwind-themes
refactor(mobile): compile semantic themes for Uniwind
2 parents 3ed6520 + e3b4bd2 commit eafef85

112 files changed

Lines changed: 3731 additions & 1144 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 Pylon 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",
@@ -72,9 +69,9 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = {
7269
"--color-drawer-shadow": "rgba(0, 0, 0, 0.12)",
7370
"--color-dot-separator": "rgba(0, 0, 0, 0.2)",
7471
"--color-wordmark": "#262626",
75-
"--color-chevron": "rgba(0, 0, 0, 0.2)",
72+
"--color-chevron": "rgba(0, 0, 0, 0.2)"
7673
},
77-
dark: {
74+
"dark": {
7875
"--color-screen": "#0a0a0a",
7976
"--color-sheet": "rgba(14, 14, 14, 0.98)",
8077
"--color-sheet-solid": "#0e0e0e",
@@ -144,6 +141,6 @@ export const DEFAULT_MOBILE_THEME_VARIABLES = {
144141
"--color-drawer-shadow": "rgba(0, 0, 0, 0.32)",
145142
"--color-dot-separator": "rgba(255, 255, 255, 0.2)",
146143
"--color-wordmark": "#f5f5f5",
147-
"--color-chevron": "rgba(255, 255, 255, 0.2)",
148-
},
149-
} as const satisfies Readonly<Record<"light" | "dark", MobileThemeVariables>>;
144+
"--color-chevron": "rgba(255, 255, 255, 0.2)"
145+
}
146+
}
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: 1532 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: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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-100-500-a18": [color("amber", 100), color("amber", 500, 0.18)],
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-600-400": [color("amber", 600), color("amber", 400)],
62+
"--color-adaptive-amber-700-300": [color("amber", 700), color("amber", 300)],
63+
"--color-adaptive-amber-700-400": [color("amber", 700), color("amber", 400)],
64+
"--color-adaptive-amber-800-200": [color("amber", 800), color("amber", 200)],
65+
"--color-adaptive-amber-900-200": [color("amber", 900), 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-emerald-500-a12-a16": [
80+
color("emerald", 500, 0.12),
81+
color("emerald", 500, 0.16),
82+
],
83+
"--color-adaptive-emerald-600-400": [color("emerald", 600), color("emerald", 400)],
84+
"--color-adaptive-emerald-700-300": [color("emerald", 700), color("emerald", 300)],
85+
"--color-adaptive-indigo-500-a12-a16": [color("indigo", 500, 0.12), color("indigo", 500, 0.16)],
86+
"--color-adaptive-indigo-600-300": [color("indigo", 600), color("indigo", 300)],
87+
"--color-adaptive-indigo-700-300": [color("indigo", 700), color("indigo", 300)],
88+
"--color-adaptive-neutral-100-900": [color("neutral", 100), color("neutral", 900)],
89+
"--color-adaptive-neutral-100-a95-900-a95": [
90+
color("neutral", 100, 0.95),
91+
color("neutral", 900, 0.95),
92+
],
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-800-200": [color("neutral", 800), color("neutral", 200)],
124+
"--color-adaptive-neutral-700-200": [color("neutral", 700), color("neutral", 200)],
125+
"--color-adaptive-neutral-950-50": [color("neutral", 950), color("neutral", 50)],
126+
"--color-adaptive-red-50-950-a80": [color("red", 50), color("red", 950, 0.8)],
127+
"--color-adaptive-red-200-800": [color("red", 200), color("red", 800)],
128+
"--color-adaptive-red-600-a80-400-a80": [color("red", 600, 0.8), color("red", 400, 0.8)],
129+
"--color-adaptive-red-700-300": [color("red", 700), color("red", 300)],
130+
"--color-adaptive-rose-100-500-a18": [color("rose", 100), color("rose", 500, 0.18)],
131+
"--color-adaptive-rose-100-a80-500-a12": [color("rose", 100, 0.8), color("rose", 500, 0.12)],
132+
"--color-adaptive-rose-300-a70-400-a28": [color("rose", 300, 0.7), color("rose", 400, 0.28)],
133+
"--color-adaptive-rose-500-a12-a16": [color("rose", 500, 0.12), color("rose", 500, 0.16)],
134+
"--color-adaptive-rose-500-400": [color("rose", 500), color("rose", 400)],
135+
"--color-adaptive-rose-600-400": [color("rose", 600), color("rose", 400)],
136+
"--color-adaptive-rose-700-300": [color("rose", 700), color("rose", 300)],
137+
"--color-adaptive-rose-800-200": [color("rose", 800), color("rose", 200)],
138+
"--color-adaptive-sky-100-500-a18": [color("sky", 100), color("sky", 500, 0.18)],
139+
"--color-adaptive-sky-500-a12-a16": [color("sky", 500, 0.12), color("sky", 500, 0.16)],
140+
"--color-adaptive-sky-600-400": [color("sky", 600), color("sky", 400)],
141+
"--color-adaptive-sky-700-300": [color("sky", 700), color("sky", 300)],
142+
"--color-adaptive-sky-800-200": [color("sky", 800), color("sky", 200)],
143+
"--color-adaptive-violet-500-a12-a16": [color("violet", 500, 0.12), color("violet", 500, 0.16)],
144+
"--color-adaptive-violet-600-400": [color("violet", 600), color("violet", 400)],
145+
"--color-adaptive-violet-700-300": [color("violet", 700), color("violet", 300)],
146+
"--color-adaptive-white-neutral-950-a70": [color("white"), color("neutral", 950, 0.7)],
147+
"--color-adaptive-zinc-500-a12-a16": [color("zinc", 500, 0.12), color("zinc", 500, 0.16)],
148+
"--color-adaptive-zinc-500-400": [color("zinc", 500), color("zinc", 400)],
149+
"--color-adaptive-zinc-600-300": [color("zinc", 600), color("zinc", 300)],
150+
};
151+
152+
export const customThemeNames = BUILT_IN_THEME_IDS.flatMap((themeId) =>
153+
APPEARANCES.map((appearance) => `${themeId}-${appearance}`),
154+
);
155+
156+
const adaptiveVariablesFor = (appearance: MobileThemeAppearance) =>
157+
Object.fromEntries(
158+
Object.entries(ADAPTIVE_COLORS).map(([name, values]) => [
159+
name,
160+
values[appearance === "light" ? 0 : 1],
161+
]),
162+
);
163+
164+
const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance) => ({
165+
...getMobileThemeVariables(themeId, appearance),
166+
...adaptiveVariablesFor(appearance),
167+
});
168+
169+
const renderVariant = (name: string, variables: Readonly<Record<string, string>>) => {
170+
const declarations = Object.entries(variables)
171+
.map(([variable, value]) => ` ${variable}: ${value};`)
172+
.join("\n");
173+
return ` @variant ${name} {\n${declarations}\n }`;
174+
};
175+
176+
export const renderUniwindThemesCSS = () => {
177+
const variants = [
178+
renderVariant("light", adaptiveVariablesFor("light")),
179+
renderVariant("dark", adaptiveVariablesFor("dark")),
180+
...BUILT_IN_THEME_IDS.flatMap((themeId) =>
181+
APPEARANCES.map((appearance) =>
182+
renderVariant(`${themeId}-${appearance}`, variablesFor(themeId, appearance)),
183+
),
184+
),
185+
];
186+
return [
187+
"/* Generated by scripts/generate-uniwind-themes.mts. Do not edit manually. */",
188+
"@layer theme {",
189+
" :root {",
190+
variants.join("\n\n"),
191+
" }",
192+
"}",
193+
"",
194+
].join("\n");
195+
};
196+
197+
const readVariantBody = (css: string, appearance: MobileThemeAppearance): string => {
198+
const marker = `@variant ${appearance} {`;
199+
const markerIndex = css.indexOf(marker);
200+
if (markerIndex === -1) throw new Error(`Could not find ${marker} in global.css.`);
201+
202+
const openingBraceIndex = css.indexOf("{", markerIndex);
203+
let depth = 0;
204+
for (let index = openingBraceIndex; index < css.length; index += 1) {
205+
if (css[index] === "{") depth += 1;
206+
if (css[index] !== "}") continue;
207+
depth -= 1;
208+
if (depth === 0) return css.slice(openingBraceIndex + 1, index);
209+
}
210+
throw new Error(`Could not find the end of ${marker} in global.css.`);
211+
};
212+
213+
export const readDefaultThemeVariables = (css: string) =>
214+
Object.fromEntries(
215+
APPEARANCES.map((appearance) => {
216+
const body = readVariantBody(css, appearance);
217+
const variables = Object.fromEntries(
218+
MOBILE_THEME_VARIABLE_NAMES.map((name) => {
219+
const match = new RegExp(`^\\s*${name}:\\s*([^;]+);`, "mu").exec(body);
220+
if (!match?.[1]) {
221+
throw new Error(`Default ${appearance} theme is missing ${name}.`);
222+
}
223+
return [name, match[1].trim()];
224+
}),
225+
) as MobileThemeVariables;
226+
return [appearance, variables];
227+
}),
228+
) as Readonly<Record<MobileThemeAppearance, MobileThemeVariables>>;
229+
230+
export const renderDefaultThemeVariablesJSON = (css: string) =>
231+
`${JSON.stringify(readDefaultThemeVariables(css), null, 2)}\n`;
232+
233+
export const getGeneratedUniwindThemeOutputs = (): ReadonlyArray<
234+
readonly [filename: string, contents: string]
235+
> => [
236+
[GENERATED_CSS_PATH, renderUniwindThemesCSS()],
237+
[GENERATED_NAMES_PATH, `${JSON.stringify(customThemeNames, null, 2)}\n`],
238+
[
239+
GENERATED_DEFAULT_VARIABLES_PATH,
240+
renderDefaultThemeVariablesJSON(NodeFS.readFileSync(GLOBAL_CSS_PATH, "utf8")),
241+
],
242+
];
243+
244+
const writeFileAtomically = (filename: string, contents: string) => {
245+
const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null;
246+
if (current === contents) return;
247+
248+
const temporaryFilename = `${filename}.${process.pid}.tmp`;
249+
try {
250+
NodeFS.writeFileSync(temporaryFilename, contents);
251+
NodeFS.renameSync(temporaryFilename, filename);
252+
} finally {
253+
if (NodeFS.existsSync(temporaryFilename)) NodeFS.unlinkSync(temporaryFilename);
254+
}
255+
};
256+
257+
if (import.meta.main) {
258+
const checkOnly = process.argv.includes("--check");
259+
for (const [filename, contents] of getGeneratedUniwindThemeOutputs()) {
260+
if (checkOnly) {
261+
const current = NodeFS.existsSync(filename) ? NodeFS.readFileSync(filename, "utf8") : null;
262+
if (current !== contents) {
263+
console.error(
264+
`${NodePath.relative(process.cwd(), filename)} is stale. Run vp run --filter @t3tools/mobile generate.`,
265+
);
266+
process.exitCode = 1;
267+
}
268+
continue;
269+
}
270+
// Metro watches the generated CSS. Replacing a complete temporary file keeps
271+
// Tailwind from compiling a partially rewritten theme file.
272+
writeFileAtomically(filename, contents);
273+
}
274+
}

0 commit comments

Comments
 (0)