Skip to content
Source
Sign inSign up
Open History to see the last commit

mobileTheme.ts

518 lines19.8 KB

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: "T3 Code" },
30 { id: "material-you", label: "Material You" },
31 ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })),
32];
33​
34export type MobileThemeVariable = keyof ReturnType<typeof createMobileThemeVariables>;
35export type MobileThemeVariables = Readonly<Record<MobileThemeVariable, string>>;
36​
37function normalizeMobileThemeId(value: unknown): MobileThemeId {
38 return typeof value === "string" && (MOBILE_THEME_IDS as readonly string[]).includes(value)
39 ? (value as MobileThemeId)
40 : DEFAULT_MOBILE_THEME_ID;
42​
43export function normalizeMobileThemeMode(value: unknown): MobileThemeMode {
44 return value === "light" || value === "dark" || value === "system" ? value : "system";
46​
47export function resolveMobileThemeIds(preferences: {
48 readonly themeId?: unknown;
49 readonly lightThemeId?: unknown;
50 readonly darkThemeId?: unknown;
51}): MobileThemeIds {
52 const legacyThemeId = normalizeMobileThemeId(preferences.themeId);
53 return {
54 light:
55 preferences.lightThemeId === undefined
56 ? legacyThemeId
57 : normalizeMobileThemeId(preferences.lightThemeId),
58 dark:
59 preferences.darkThemeId === undefined
60 ? legacyThemeId
61 : normalizeMobileThemeId(preferences.darkThemeId),
62 };
64​
65export function createMobileThemeSelectionPatch(
66 themeIds: MobileThemeIds,
67 activeAppearance: MobileThemeAppearance,
68 selectedAppearance: MobileThemeAppearance,
69 value: MobileThemeId,
70) {
71 const nextThemeIds: MobileThemeIds = {
72 light: selectedAppearance === "light" ? value : themeIds.light,
73 dark: selectedAppearance === "dark" ? value : themeIds.dark,
74 };
75 return {
76 lightThemeId: nextThemeIds.light,
77 darkThemeId: nextThemeIds.dark,
78 themeId: nextThemeIds[activeAppearance],
79 };
81​
82export function createMobileThemePairPatch(value: MobileThemeId) {
83 return {
84 lightThemeId: value,
85 darkThemeId: value,
86 themeId: value,
87 };
89​
90const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/;
91​
92function linearToSrgb(value: number): number {
93 const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
94 return Math.round(Math.min(1, Math.max(0, converted)) * 255);
96​
97function oklabToLinearSrgb(lightness: number, a: number, b: number) {
98 const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
99 const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
100 const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
101 return [
102 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
103 -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
104 -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
105 ] as const;
107​
108function oklabToSrgb(lightness: number, a: number, b: number) {
109 const [red, green, blue] = oklabToLinearSrgb(lightness, a, b);
110 return [linearToSrgb(red), linearToSrgb(green), linearToSrgb(blue)] as const;
112​
113/** React Native does not accept OKLCH ColorValues, so palettes cross the app boundary as sRGB. */
114export function themeColorToNativeColor(value: string): string {
115 const match = OKLCH_PATTERN.exec(value);
116 if (!match) return value;
117​
118 const lightness = Number(match[1]);
119 const chroma = Number(match[2]);
120 const hue = (Number(match[3]) * Math.PI) / 180;
121 const alpha = match[4] === undefined ? 1 : Number(match[4]);
122 const [red, green, blue] = oklabToSrgb(lightness, chroma * Math.cos(hue), chroma * Math.sin(hue));
123​
124 return alpha < 1
125 ? `rgba(${red}, ${green}, ${blue}, ${Number(alpha.toFixed(4))})`
126 : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
128​
129export function nativeColors(colors: ThemeColors): ThemeColors {
130 return Object.fromEntries(
131 Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]),
132 ) as ThemeColors;
134​
135function withAlpha(color: string, alpha: number): string {
136 const hex = color.startsWith("#") ? color.slice(1) : "";
137 if (hex.length !== 6) return color;
138 const [red, green, blue] = [0, 2, 4].map((offset) =>
139 Number.parseInt(hex.slice(offset, offset + 2), 16),
140 );
141 return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
143​
144function rgbChannels(color: string): readonly [number, number, number] | null {
145 const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})(?:[\da-f]{2})?$/i.exec(color);
146 if (!match) return null;
147 const [, red = "0", green = "0", blue = "0"] = match;
148 return [Number.parseInt(red, 16), Number.parseInt(green, 16), Number.parseInt(blue, 16)];
150​
151/** Native chips require opaque hex; flatten translucent roles. */
152export function flattenThemeColor(color: string, surface: string): string {
153 const alphaHex = /^#([\da-f]{6})([\da-f]{2})$/i.exec(color);
154 if (alphaHex) {
155 const channels = rgbChannels(color)!;
156 return flattenThemeColor(
157 `rgba(${channels[0]}, ${channels[1]}, ${channels[2]}, ${Number.parseInt(alphaHex[2]!, 16) / 255})`,
158 surface,
159 );
160 }
161 const match = /^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\s*\)$/i.exec(
162 color.trim(),
163 );
164 if (!match) return color;
165 const alpha = match[4] === undefined ? 1 : Number(match[4]);
166 const behind = rgbChannels(surface) ?? [0, 0, 0];
167 const channels = [match[1], match[2], match[3]].map((channel, index) =>
168 Math.max(0, Math.min(255, Math.round(Number(channel) * alpha + behind[index]! * (1 - alpha)))),
169 );
170 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
172​
173function srgbToLinear(channel: number): number {
174 const value = channel / 255;
175 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
177​
178function srgbToOklab(channels: readonly [number, number, number]) {
179 const [red = 0, green = 0, blue = 0] = channels.map(srgbToLinear);
180 const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
181 const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
182 const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
183 return [
184 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
185 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
186 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
187 ] as const;
189​
190function relativeLuminance(channels: readonly [number, number, number]): number {
191 const [red, green, blue] = channels.map(srgbToLinear);
192 return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!;
194​
195export function isDarkThemeColor(color: string): boolean {
196 const channels = rgbChannels(color);
197 return channels !== null && relativeLuminance(channels) < 0.18;
199​
200function contrastRatio(
201 first: readonly [number, number, number],
202 second: readonly [number, number, number],
203): number {
204 const firstLuminance = relativeLuminance(first);
205 const secondLuminance = relativeLuminance(second);
206 return (
207 (Math.max(firstLuminance, secondLuminance) + 0.05) /
208 (Math.min(firstLuminance, secondLuminance) + 0.05)
209 );
211​
212export function themeColorContrastRatio(first: string, second: string): number {
213 const firstChannels = rgbChannels(first);
214 const secondChannels = rgbChannels(second);
215 return firstChannels && secondChannels ? contrastRatio(firstChannels, secondChannels) : 1;
217​
218export function themeColorWithContrast(
219 accent: string,
220 surface: string | ReadonlyArray<string>,
221 minimum = 4.5,
222): string {
223 const accentChannels = rgbChannels(accent);
224 const surfaceChannels = (typeof surface === "string" ? [surface] : surface)
225 .map(rgbChannels)
226 .filter((channels) => channels !== null);
227 const minimumContrast = (channels: readonly [number, number, number]) =>
228 Math.min(...surfaceChannels.map((surface) => contrastRatio(channels, surface)));
229 if (
230 !accentChannels ||
231 surfaceChannels.length === 0 ||
232 minimumContrast(accentChannels) >= minimum
233 ) {
234 return accent;
235 }
236​
237 const black = [0, 0, 0] as const;
238 const white = [255, 255, 255] as const;
239 const target = minimumContrast(black) >= minimumContrast(white) ? black : white;
240 let readable: readonly [number, number, number] = target;
241 let lowerAmount = 0;
242 let upperAmount = 1;
243 for (let index = 0; index < 12; index += 1) {
244 const amount = (lowerAmount + upperAmount) / 2;
245 const candidate: readonly [number, number, number] = [
246 Math.round(accentChannels[0] + (target[0] - accentChannels[0]) * amount),
247 Math.round(accentChannels[1] + (target[1] - accentChannels[1]) * amount),
248 Math.round(accentChannels[2] + (target[2] - accentChannels[2]) * amount),
249 ];
250 if (minimumContrast(candidate) >= minimum) {
251 readable = candidate;
252 upperAmount = amount;
253 } else {
254 lowerAmount = amount;
255 }
256 }
257 return `#${readable.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
259​
260/**
261 * Unlike `themeColorWithContrast`, which blends toward black or white and greys the color out,
262 * this moves only OKLCH lightness and takes the most chroma sRGB allows at each step, so a vivid
263 * accent stays vivid at the contrast it needs.
264 */
265export function themeAccentWithContrast(
266 accent: string,
267 surfaces: ReadonlyArray<string>,
268 minimum = 4.5,
269): string {
270 const accentChannels = rgbChannels(accent);
271 const surfaceChannels = surfaces.map(rgbChannels).filter((channels) => channels !== null);
272 const minimumContrast = (channels: readonly [number, number, number]) =>
273 Math.min(...surfaceChannels.map((surface) => contrastRatio(channels, surface)));
274 if (
275 !accentChannels ||
276 surfaceChannels.length === 0 ||
277 minimumContrast(accentChannels) >= minimum
278 ) {
279 return accent;
280 }
281​
282 const [lightness, a, b] = srgbToOklab(accentChannels);
283 const hue = Math.atan2(b, a);
284 const inGamut = (channels: ReadonlyArray<number>) =>
285 channels.every((channel) => channel >= -1e-4 && channel <= 1 + 1e-4);
286 const mostVivid = (stepLightness: number) => {
287 let chroma = 0;
288 let lower = 0;
289 let upper = Math.max(Math.hypot(a, b), 0.2);
290 for (let index = 0; index < 20; index += 1) {
291 const candidate = (lower + upper) / 2;
292 if (
293 inGamut(
294 oklabToLinearSrgb(stepLightness, candidate * Math.cos(hue), candidate * Math.sin(hue)),
295 )
296 ) {
297 chroma = candidate;
298 lower = candidate;
299 } else {
300 upper = candidate;
301 }
302 }
303 return oklabToSrgb(stepLightness, chroma * Math.cos(hue), chroma * Math.sin(hue));
304 };
305​
306 const lighten = minimumContrast([255, 255, 255]) >= minimumContrast([0, 0, 0]);
307 const step = lighten ? 0.002 : -0.002;
308 for (let next = lightness; lighten ? next <= 1 : next >= 0; next += step) {
309 const candidate = mostVivid(next);
310 if (minimumContrast(candidate) >= minimum) {
311 return `#${candidate.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
312 }
313 }
314 return lighten ? "#ffffff" : "#000000";
316​
317export function themeColorWithAlpha(color: string, alpha: number): string {
318 const channels = rgbChannels(color);
319 if (channels) {
320 return `rgba(${channels[0]}, ${channels[1]}, ${channels[2]}, ${alpha})`;
321 }
322 const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/.exec(color);
323 return rgb ? `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` : color;
325​
326export function createMobileThemeVariables(
327 colors: ThemeColors,
328 appearance: MobileThemeAppearance,
329 groupedCardColor = colors.surface,
330 prominentAction: readonly [string, string] = [colors.text, colors.canvas],
331) {
332 const c = nativeColors(colors);
333 const groupedCard = themeColorToNativeColor(groupedCardColor);
334 const textSurfaces = [
335 c.canvas,
336 c.surface,
337 c.surfaceRaised,
338 c.chrome,
339 c.sidebar,
340 c.secondary,
341 groupedCard,
342 ];
343 return {
344 "--color-screen": c.canvas,
345 "--color-sheet": withAlpha(c.chrome, 0.98),
346 "--color-sheet-solid": c.chrome,
347 "--color-card": c.surface,
348 "--color-grouped-card": groupedCard,
349 "--color-card-alt": c.surfaceRaised,
350 "--color-card-translucent": withAlpha(c.surface, 0.8),
351 "--color-thread-canvas": c.canvas,
352 "--color-thread-selected": c.sidebarRowActive,
353 "--color-thread-selected-foreground": c.sidebarForeground,
354 "--color-thread-selected-foreground-muted": themeColorWithContrast(
355 c.sidebarMutedForeground,
356 c.sidebarRowActive,
357 ),
358 "--color-thread-hover": c.sidebarRowHover,
359 "--color-row-hover": c.toolbarControlHover,
360 "--color-composer-panel": themeColorWithAlpha(c.canvas, appearance === "dark" ? 0.92 : 0.88),
361 "--color-composer-surface": themeColorWithAlpha(
362 groupedCard,
363 appearance === "dark" ? 0.9 : 0.94,
364 ),
365 "--color-composer-border": themeColorWithAlpha(
366 c.border,
367 groupedCard === c.surface ? (appearance === "dark" ? 0.46 : 0.54) : 0.8,
368 ),
369 "--color-foreground": c.text,
370 "--color-foreground-secondary": themeColorWithContrast(c.textMuted, textSurfaces),
371 "--color-foreground-muted": themeColorWithContrast(c.mutedForeground, textSurfaces),
372 "--color-foreground-tertiary": c.secondaryLabel,
373 "--color-border": c.border,
374 "--color-focus": c.focus,
375 "--color-border-subtle": withAlpha(c.border, 0.7),
376 "--color-separator": withAlpha(c.border, 0.55),
377 "--color-subtle": c.muted,
378 "--color-subtle-strong": c.secondary,
379 "--color-inline-skill-background": c.accentSurface,
380 "--color-inline-skill-border": withAlpha(c.accent, 0.42),
381 "--color-inline-skill-foreground": c.accentSurfaceForeground,
382 "--color-primary": c.messageAction,
383 "--color-prominent-action": themeColorToNativeColor(prominentAction[0]),
384 "--color-prominent-action-foreground": themeColorToNativeColor(prominentAction[1]),
385 "--color-primary-foreground": c.messageActionForeground,
386 "--color-primary-text": themeColorWithContrast(c.messageAction, textSurfaces),
387 "--color-primary-shadow": "#000000",
388 "--color-secondary": c.secondary,
389 "--color-secondary-foreground": c.secondaryForeground,
390 "--color-secondary-border": c.border,
391 "--color-switch-active-track": c.messageAction,
392 "--color-switch-active-thumb": c.messageActionForeground,
393 "--color-switch-inactive-track": c.secondary,
394 "--color-switch-inactive-thumb": c.mutedForeground,
395 "--color-warning": c.warningSurface,
396 "--color-warning-border": withAlpha(c.warning, 0.32),
397 "--color-warning-foreground": c.warningForeground,
398 "--color-danger": c.errorSurface,
399 "--color-danger-border": withAlpha(c.error, 0.32),
400 "--color-danger-foreground": c.errorForeground,
401 "--color-update": c.updateSurface,
402 "--color-update-foreground": c.updateForeground,
403 "--color-input": c.surface,
404 "--color-input-border": c.input,
405 "--color-sidebar-search": c.sidebarControlSurface,
406 "--color-placeholder": themeColorWithContrast(c.placeholder, textSurfaces),
407 "--color-icon": c.text,
408 "--color-icon-muted": c.iconMuted,
409 "--color-icon-subtle": c.secondaryLabel,
410 "--color-header": withAlpha(c.toolbar, 0.97),
411 "--color-header-foreground": c.toolbarForeground,
412 "--color-header-border": c.toolbarBorder,
413 "--color-glass-surface": withAlpha(c.surfaceOverlay, 0.74),
414 "--color-glass-fallback": themeColorWithAlpha(groupedCard, appearance === "dark" ? 0.9 : 0.94),
415 "--color-glass-tint": withAlpha(c.surfaceOverlay, 0.22),
416 "--color-status-bar": c.canvas,
417 "--color-md-body": c.text,
418 "--color-md-strong": c.text,
419 "--color-md-link": themeColorWithContrast(c.messageAction, c.canvas),
420 "--color-md-blockquote-border": c.border,
421 "--color-md-blockquote-bg": c.muted,
422 "--color-md-code-bg": c.codeBackground,
423 "--color-md-code-text": c.codeForeground,
424 "--color-md-user-code-bg": withAlpha(c.messageForeground, 0.18),
425 "--color-md-user-code-text": c.messageForeground,
426 "--color-md-user-fence-bg": withAlpha("#000000", appearance === "dark" ? 0.28 : 0.16),
427 "--color-md-user-fence-text": c.messageForeground,
428 "--color-md-hr": c.border,
429 "--color-user-bubble": c.messageSurface,
430 "--color-user-bubble-foreground": c.messageForeground,
431 "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78),
432 "--color-user-bubble-skill-foreground": themeColorWithContrast(
433 c.messageAction,
434 c.messageSurface,
435 ),
436 "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22),
437 "--color-drawer": c.sidebar,
438 "--color-drawer-foreground": c.sidebarForeground,
439 "--color-drawer-foreground-muted": themeColorWithContrast(c.sidebarMutedForeground, [
440 c.sidebar,
441 c.sidebarRowHover,
442 ]),
443 "--color-drawer-border": c.sidebarBorder,
444 "--color-drawer-shadow": withAlpha("#000000", appearance === "dark" ? 0.32 : 0.12),
445 "--color-dot-separator": withAlpha(c.textMuted, 0.35),
446 "--color-wordmark": c.text,
447 "--color-chevron": withAlpha(c.textMuted, 0.42),
448 };
450​
451export function getMobileThemeColors(
452 themeId: SharedMobileThemeId,
453 appearance: MobileThemeAppearance,
454): ThemeColors {
455 if (themeId === DEFAULT_MOBILE_THEME_ID) {
456 return appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS;
457 }
458 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME;
459 return getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
461​
462export function getMobileThemeVariables(
463 themeId: SharedMobileThemeId,
464 appearance: MobileThemeAppearance,
465 overrides: Partial<MobileThemeVariables> | null = null,
466): MobileThemeVariables {
467 const colors = getMobileThemeColors(themeId, appearance);
468 const groupedCard =
469 themeId === DEFAULT_MOBILE_THEME_ID
470 ? appearance === "light"
471 ? colors.toolbarControlHover
472 : colors.sidebarRowActive
473 : themeId === "t3-chat"
474 ? appearance === "light"
475 ? colors.toolbarControl
476 : colors.surface
477 : colors.muted;
478 const mobileColors =
479 themeId === DEFAULT_MOBILE_THEME_ID
480 ? {
481 ...colors,
482 messageSurface: flattenThemeColor(
483 themeColorWithAlpha(
484 appearance === "dark" ? colors.sidebarRowActive : colors.border,
485 0.3,
486 ),
487 colors.messageSurface,
488 ),
489 }
490 : colors;
491 const baseVariables = createMobileThemeVariables(
492 mobileColors,
493 appearance,
494 groupedCard,
495 themeId === "t3-chat" && appearance === "dark"
496 ? [colors.messageActionForeground, colors.messageAction]
497 : themeId !== DEFAULT_MOBILE_THEME_ID && themeId !== "t3-chat"
498 ? [colors.messageAction, colors.messageActionForeground]
499 : undefined,
500 );
501​
502 return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables;
504​
505export function getMobileThemePreviewColors(
506 themeId: MobileThemeId,
507 appearance: MobileThemeAppearance,
508): ThemePreviewColors {
509 if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you")
510 return STANDARD_THEME_PREVIEW_COLORS[appearance];
511 const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? T3_CHAT_THEME;
512 const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors;
513 return {
514 canvas: themeColorToNativeColor(colors.canvas),
515 accent: themeColorToNativeColor(colors.accent),
516 messageAction: themeColorToNativeColor(colors.messageAction),
517 };