1import {
2 BUILT_IN_THEMES,
3 T3_CHAT_THEME,
4 T3_CODE_LIGHT_THEME_COLORS,
5 T3_CODE_DARK_THEME_COLORS,
6 getThemeColorsForAppearance,
7 MOBILE_DEFAULT_THEME_ID,
8 MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS,
9 type MobileThemeId as SharedMobileThemeId,
10 type ThemeAppearance,
11 type ThemeColors,
12} from "@t3tools/shared/themePalettes";
13import {
14 STANDARD_THEME_PREVIEW_COLORS,
15 type ThemePreviewColors,
16} from "@t3tools/shared/themePreview";
17
18export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID;
19export const MOBILE_THEME_IDS = [...SHARED_MOBILE_THEME_IDS, "material-you"] as const;
20export type MobileThemeId = SharedMobileThemeId | "material-you";
21export type MobileThemeAppearance = ThemeAppearance;
22export type MobileThemeMode = MobileThemeAppearance | "system";
23export type MobileThemeIds = Readonly<Record<MobileThemeAppearance, MobileThemeId>>;
24
25export const MOBILE_THEME_OPTIONS: ReadonlyArray<{
26 readonly id: MobileThemeId;
27 readonly label: string;
28}> = [
29 { id: DEFAULT_MOBILE_THEME_ID, label: "The Knife" },
30 { id: "material-you", label: "Material You" },
31 ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })),
32];
33
34// Closed set: every key `createMobileThemeVariables` writes. Reads of a
35// misspelled variable then fail to compile instead of yielding undefined.
36export type MobileThemeVariable = keyof ReturnType<typeof createMobileThemeVariables>;
37export type MobileThemeVariables = Readonly<Record<MobileThemeVariable, string>>;
38
39export function normalizeMobileThemeId(value: unknown): MobileThemeId {
40 return typeof value === "string" && (MOBILE_THEME_IDS as readonly string[]).includes(value)
41 ? (value as MobileThemeId)
42 : DEFAULT_MOBILE_THEME_ID;
43}
44
45export function normalizeMobileThemeMode(value: unknown): MobileThemeMode {
46 return value === "light" || value === "dark" || value === "system" ? value : "system";
47}
48
49export function resolveMobileThemeIds(preferences: {
50 readonly themeId?: unknown;
51 readonly lightThemeId?: unknown;
52 readonly darkThemeId?: unknown;
53}): MobileThemeIds {
54 const legacyThemeId = normalizeMobileThemeId(preferences.themeId);
55 return {
56 light:
57 preferences.lightThemeId === undefined
58 ? legacyThemeId
59 : normalizeMobileThemeId(preferences.lightThemeId),
60 dark:
61 preferences.darkThemeId === undefined
62 ? legacyThemeId
63 : normalizeMobileThemeId(preferences.darkThemeId),
64 };
65}
66
67export function createMobileThemeSelectionPatch(
68 themeIds: MobileThemeIds,
69 activeAppearance: MobileThemeAppearance,
70 selectedAppearance: MobileThemeAppearance,
71 value: MobileThemeId,
72) {
73 const nextThemeIds: MobileThemeIds = {
74 light: selectedAppearance === "light" ? value : themeIds.light,
75 dark: selectedAppearance === "dark" ? value : themeIds.dark,
76 };
77 return {
78 lightThemeId: nextThemeIds.light,
79 darkThemeId: nextThemeIds.dark,
80 // Keep older OTA bundles on the theme for the appearance currently in use.
81 themeId: nextThemeIds[activeAppearance],
82 };
83}
84
85export function createMobileThemePairPatch(value: MobileThemeId) {
86 return {
87 lightThemeId: value,
88 darkThemeId: value,
89 themeId: value,
90 };
91}
92
93const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/;
94
95function linearToSrgb(value: number): number {
96 const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
97 return Math.round(Math.min(1, Math.max(0, converted)) * 255);
98}
99
100/** React Native does not accept OKLCH ColorValues, so palettes cross the app boundary as sRGB. */
101export function themeColorToNativeColor(value: string): string {
102 const match = OKLCH_PATTERN.exec(value);
103 if (!match) return value;
104
105 const lightness = Number(match[1]);
106 const chroma = Number(match[2]);
107 const hue = (Number(match[3]) * Math.PI) / 180;
108 const alpha = match[4] === undefined ? 1 : Number(match[4]);
109 const a = chroma * Math.cos(hue);
110 const b = chroma * Math.sin(hue);
111 const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b;
112 const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b;
113 const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b;
114 const l = lPrime ** 3;
115 const m = mPrime ** 3;
116 const s = sPrime ** 3;
117 const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
118 const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
119 const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
120
121 return alpha < 1
122 ? `rgba(${red}, ${green}, ${blue}, ${Number(alpha.toFixed(4))})`
123 : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
124}
125
126export function nativeColors(colors: ThemeColors): ThemeColors {
127 return Object.fromEntries(
128 Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]),
129 ) as ThemeColors;
130}
131
132function withAlpha(color: string, alpha: number): string {
133 const hex = color.startsWith("#") ? color.slice(1) : "";
134 if (hex.length !== 6) return color;
135 const [red, green, blue] = [0, 2, 4].map((offset) =>
136 Number.parseInt(hex.slice(offset, offset + 2), 16),
137 );
138 return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
139}
140
141function rgbChannels(color: string): readonly [number, number, number] | null {
142 const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})(?:[\da-f]{2})?$/i.exec(color);
143 if (!match) return null;
144 const [, red = "0", green = "0", blue = "0"] = match;
145 return [Number.parseInt(red, 16), Number.parseInt(green, 16), Number.parseInt(blue, 16)];
146}
147
148/**
149 * An opaque form of a theme colour, composited over the surface behind it. Native chip drawing
150 * parses only opaque hex — an `rgba()` string falls back to a default that is nothing like the
151 * colour asked for — so a translucent role like `--color-border` has to be flattened first.
152 */
153export function flattenThemeColor(color: string, surface: string): string {
154 const alphaHex = /^#([\da-f]{6})([\da-f]{2})$/i.exec(color);
155 if (alphaHex) {
156 const channels = rgbChannels(color)!;
157 return flattenThemeColor(
158 `rgba(${channels[0]}, ${channels[1]}, ${channels[2]}, ${Number.parseInt(alphaHex[2]!, 16) / 255})`,
159 surface,
160 );
161 }
162 const match = /^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\s*\)$/i.exec(
163 color.trim(),
164 );
165 if (!match) return color;
166 const alpha = match[4] === undefined ? 1 : Number(match[4]);
167 const behind = rgbChannels(surface) ?? [0, 0, 0];
168 const channels = [match[1], match[2], match[3]].map((channel, index) =>
169 Math.max(0, Math.min(255, Math.round(Number(channel) * alpha + behind[index]! * (1 - alpha)))),
170 );
171 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
172}
173
174function relativeLuminance(channels: readonly [number, number, number]): number {
175 const [red, green, blue] = channels.map((channel) => {
176 const value = channel / 255;
177 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
178 });
179 return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
180}
181
182function contrastRatio(
183 first: readonly [number, number, number],
184 second: readonly [number, number, number],
185): number {
186 const firstLuminance = relativeLuminance(first);
187 const secondLuminance = relativeLuminance(second);
188 return (
189 (Math.max(firstLuminance, secondLuminance) + 0.05) /
190 (Math.min(firstLuminance, secondLuminance) + 0.05)
191 );
192}
193
194/** Keep the theme tint while adjusting lightness to contrast with every supplied surface. */
195export function themeColorWithContrast(
196 accent: string,
197 surface: string | ReadonlyArray<string>,
198 minimum = 4.5,
199): string {
200 const accentChannels = rgbChannels(accent);
201 const surfaceChannels = (typeof surface === "string" ? [surface] : surface)
202 .map(rgbChannels)
203 .filter((channels) => channels !== null);
204 const minimumContrast = (channels: readonly [number, number, number]) =>
205 Math.min(...surfaceChannels.map((surface) => contrastRatio(channels, surface)));
206 if (
207 !accentChannels ||
208 surfaceChannels.length === 0 ||
209 minimumContrast(accentChannels) >= minimum
210 ) {
211 return accent;
212 }
213
214 const black = [0, 0, 0] as const;
215 const white = [255, 255, 255] as const;
216 const target = minimumContrast(black) >= minimumContrast(white) ? black : white;
217 let readable: readonly [number, number, number] = target;
218 let lowerAmount = 0;
219 let upperAmount = 1;
220 for (let index = 0; index < 12; index += 1) {
221 const amount = (lowerAmount + upperAmount) / 2;
222 const candidate: readonly [number, number, number] = [
223 Math.round(accentChannels[0] + (target[0] - accentChannels[0]) * amount),
224 Math.round(accentChannels[1] + (target[1] - accentChannels[1]) * amount),
225 Math.round(accentChannels[2] + (target[2] - accentChannels[2]) * amount),
226 ];
227 if (minimumContrast(candidate) >= minimum) {
228 readable = candidate;
229 upperAmount = amount;
230 } else {
231 lowerAmount = amount;
232 }
233 }
234 return `#${readable.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
235}
236
237export function themeColorWithAlpha(color: string, alpha: number): string {
238 const channels = rgbChannels(color);
239 if (channels) {
240 return `rgba(${channels[0]}, ${channels[1]}, ${channels[2]}, ${alpha})`;
241 }
242 const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/.exec(color);
243 return rgb ? `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` : color;
244}
245
246export function createMobileThemeVariables(
247 colors: ThemeColors,
248 appearance: MobileThemeAppearance,
249 groupedCardColor = colors.surface,
250 prominentAction: readonly [string, string] = [colors.text, colors.canvas],
251) {
252 const c = nativeColors(colors);
253 const groupedCard = themeColorToNativeColor(groupedCardColor);
254 const textSurfaces = [
255 c.canvas,
256 c.surface,
257 c.surfaceRaised,
258 c.chrome,
259 c.sidebar,
260 c.secondary,
261 groupedCard,
262 ];
263 return {
264 "--color-screen": c.canvas,
265 "--color-sheet": withAlpha(c.chrome, 0.98),
266 "--color-sheet-solid": c.chrome,
267 "--color-card": c.surface,
268 "--color-grouped-card": groupedCard,
269 "--color-card-alt": c.surfaceRaised,
270 "--color-card-translucent": withAlpha(c.surface, 0.8),
271 "--color-thread-canvas": c.canvas,
272 "--color-thread-selected": c.sidebarRowActive,
273 "--color-thread-selected-foreground": c.sidebarForeground,
274 "--color-thread-selected-foreground-muted": themeColorWithContrast(
275 c.sidebarMutedForeground,
276 c.sidebarRowActive,
277 ),
278 "--color-thread-hover": c.sidebarRowHover,
279 "--color-row-hover": c.toolbarControlHover,
280 "--color-composer-panel": themeColorWithAlpha(c.canvas, appearance === "dark" ? 0.92 : 0.88),
281 "--color-composer-surface": themeColorWithAlpha(
282 groupedCard,
283 appearance === "dark" ? 0.9 : 0.94,
284 ),
285 "--color-composer-border": themeColorWithAlpha(
286 c.border,
287 groupedCard === c.surface ? (appearance === "dark" ? 0.46 : 0.54) : 0.8,
288 ),
289 "--color-foreground": c.text,
290 "--color-foreground-secondary": themeColorWithContrast(c.textMuted, textSurfaces),
291 "--color-foreground-muted": themeColorWithContrast(c.mutedForeground, textSurfaces),
292 "--color-foreground-tertiary": c.secondaryLabel,
293 "--color-border": c.border,
294 "--color-focus": c.focus,
295 "--color-border-subtle": withAlpha(c.border, 0.7),
296 "--color-separator": withAlpha(c.border, 0.55),
297 "--color-subtle": c.muted,
298 "--color-subtle-strong": c.secondary,
299 "--color-inline-skill-background": c.accentSurface,
300 "--color-inline-skill-border": withAlpha(c.accent, 0.42),
301 "--color-inline-skill-foreground": c.accentSurfaceForeground,
302 "--color-primary": c.messageAction,
303 "--color-prominent-action": themeColorToNativeColor(prominentAction[0]),
304 "--color-prominent-action-foreground": themeColorToNativeColor(prominentAction[1]),
305 "--color-primary-foreground": c.messageActionForeground,
306 "--color-primary-text": themeColorWithContrast(c.messageAction, textSurfaces),
307 "--color-primary-shadow": "#000000",
308 "--color-secondary": c.secondary,
309 "--color-secondary-foreground": c.secondaryForeground,
310 "--color-secondary-border": c.border,
311 "--color-switch-active-track": c.messageAction,
312 "--color-switch-active-thumb": c.messageActionForeground,
313 "--color-switch-inactive-track": c.secondary,
314 "--color-switch-inactive-thumb": c.mutedForeground,
315 "--color-warning": c.warningSurface,
316 "--color-warning-border": withAlpha(c.warning, 0.32),
317 "--color-warning-foreground": c.warningForeground,
318 "--color-danger": c.errorSurface,
319 "--color-danger-border": withAlpha(c.error, 0.32),
320 "--color-danger-foreground": c.errorForeground,
321 "--color-update": c.updateSurface,
322 "--color-update-foreground": c.updateForeground,
323 "--color-input": c.surface,
324 "--color-input-border": c.input,
325 "--color-sidebar-search": c.sidebarControlSurface,
326 "--color-placeholder": themeColorWithContrast(c.placeholder, textSurfaces),
327 "--color-icon": c.text,
328 "--color-icon-muted": c.iconMuted,
329 "--color-icon-subtle": c.secondaryLabel,
330 "--color-header": withAlpha(c.toolbar, 0.97),
331 "--color-header-foreground": c.toolbarForeground,
332 "--color-header-border": c.toolbarBorder,
333 "--color-glass-surface": withAlpha(c.surfaceOverlay, 0.74),
334 "--color-glass-fallback": themeColorWithAlpha(groupedCard, appearance === "dark" ? 0.9 : 0.94),
335 "--color-glass-tint": withAlpha(c.surfaceOverlay, 0.22),
336 "--color-status-bar": c.canvas,
337 "--color-md-body": c.text,
338 "--color-md-strong": c.text,
339 "--color-md-link": themeColorWithContrast(c.messageAction, c.canvas),
340 "--color-md-blockquote-border": c.border,
341 "--color-md-blockquote-bg": c.muted,
342 "--color-md-code-bg": c.codeBackground,
343 "--color-md-code-text": c.codeForeground,
344 "--color-md-user-code-bg": withAlpha(c.messageForeground, 0.18),
345 "--color-md-user-code-text": c.messageForeground,
346 "--color-md-user-fence-bg": withAlpha("#000000", appearance === "dark" ? 0.28 : 0.16),
347 "--color-md-user-fence-text": c.messageForeground,
348 "--color-md-hr": c.border,
349 "--color-user-bubble": c.messageSurface,
350 "--color-user-bubble-foreground": c.messageForeground,
351 "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78),
352 "--color-user-bubble-skill-foreground": themeColorWithContrast(
353 c.messageAction,
354 c.messageSurface,
355 ),
356 "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22),
357 "--color-drawer": c.sidebar,
358 "--color-drawer-foreground": c.sidebarForeground,
359 "--color-drawer-foreground-muted": themeColorWithContrast(c.sidebarMutedForeground, [
360 c.sidebar,
361 c.sidebarRowHover,
362 ]),
363 "--color-drawer-border": c.sidebarBorder,
364 "--color-drawer-shadow": withAlpha("#000000", appearance === "dark" ? 0.32 : 0.12),
365 "--color-dot-separator": withAlpha(c.textMuted, 0.35),
366 "--color-wordmark": c.text,
367 "--color-chevron": withAlpha(c.textMuted, 0.42),
368 };
369}
370
371export const MOBILE_THEME_VARIABLE_NAMES = Object.keys(
372 createMobileThemeVariables(T3_CHAT_THEME.colors, "light"),
373) as ReadonlyArray<MobileThemeVariable>;
374
375export function getMobileThemeColors(
376 themeId: SharedMobileThemeId,
377 appearance: MobileThemeAppearance,
378): ThemeColors {
379 if (themeId === DEFAULT_MOBILE_THEME_ID) {
380 return appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS;
381 }
382 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME;
383 return getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
384}
385
386export function getMobileThemeVariables(
387 themeId: SharedMobileThemeId,
388 appearance: MobileThemeAppearance,
389 overrides: Partial<MobileThemeVariables> | null = null,
390): MobileThemeVariables {
391 const colors = getMobileThemeColors(themeId, appearance);
392 // Mobile groups and floating controls need a tonal fill, rather than the
393 // nearly neutral raised surface used by desktop outlined cards.
394 const groupedCard =
395 themeId === DEFAULT_MOBILE_THEME_ID
396 ? appearance === "light"
397 ? colors.toolbarControlHover
398 : colors.sidebarRowActive
399 : themeId === "t3-chat"
400 ? appearance === "light"
401 ? colors.toolbarControl
402 : colors.surface
403 : colors.muted;
404 const mobileColors =
405 themeId === DEFAULT_MOBILE_THEME_ID
406 ? {
407 ...colors,
408 messageSurface: flattenThemeColor(
409 themeColorWithAlpha(
410 appearance === "dark" ? colors.sidebarRowActive : colors.border,
411 0.3,
412 ),
413 colors.messageSurface,
414 ),
415 }
416 : colors;
417 const baseVariables = createMobileThemeVariables(
418 mobileColors,
419 appearance,
420 groupedCard,
421 themeId === "t3-chat" && appearance === "dark"
422 ? [colors.messageActionForeground, colors.messageAction]
423 : themeId !== DEFAULT_MOBILE_THEME_ID && themeId !== "t3-chat"
424 ? [colors.messageAction, colors.messageActionForeground]
425 : undefined,
426 );
427
428 // The complete base record guarantees that optional overrides cannot leave a token undefined.
429 return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables;
430}
431
432export function getMobileThemePreviewColors(
433 themeId: MobileThemeId,
434 appearance: MobileThemeAppearance,
435): ThemePreviewColors {
436 if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you")
437 return STANDARD_THEME_PREVIEW_COLORS[appearance];
438 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME;
439 const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
440 return {
441 canvas: themeColorToNativeColor(colors.canvas),
442 accent: themeColorToNativeColor(colors.accent),
443 messageAction: themeColorToNativeColor(colors.messageAction),
444 };
445}