import { BUILT_IN_THEMES, getThemeColorsForAppearance, MOBILE_DEFAULT_THEME_ID, MOBILE_THEME_IDS as SHARED_MOBILE_THEME_IDS, type BuiltInThemeId, type MobileThemeId as SharedMobileThemeId, type ThemeAppearance, type ThemeColors, } from "@t3tools/shared/themePalettes"; import { STANDARD_THEME_PREVIEW_COLORS, type ThemePreviewColors, } from "@t3tools/shared/themePreview"; export const DEFAULT_MOBILE_THEME_ID = MOBILE_DEFAULT_THEME_ID; export const MOBILE_THEME_IDS = [...SHARED_MOBILE_THEME_IDS, "material-you"] as const; export type MobileThemeId = SharedMobileThemeId | "material-you"; export type MobileThemeAppearance = ThemeAppearance; export type MobileThemeMode = MobileThemeAppearance | "system"; export type MobileThemeIds = Readonly>; export const MOBILE_THEME_OPTIONS: ReadonlyArray<{ readonly id: MobileThemeId; readonly label: string; }> = [ { id: DEFAULT_MOBILE_THEME_ID, label: "T3 Code" }, { id: "material-you", label: "Material You" }, ...BUILT_IN_THEMES.map((theme) => ({ id: theme.id as MobileThemeId, label: theme.label })), ]; export type MobileThemeVariable = `--color-${string}`; export type MobileThemeVariables = Readonly>; export function normalizeMobileThemeId(value: unknown): MobileThemeId { return typeof value === "string" && (MOBILE_THEME_IDS as readonly string[]).includes(value) ? (value as MobileThemeId) : DEFAULT_MOBILE_THEME_ID; } export function normalizeMobileThemeMode(value: unknown): MobileThemeMode { return value === "light" || value === "dark" || value === "system" ? value : "system"; } export function resolveMobileThemeIds(preferences: { readonly themeId?: unknown; readonly lightThemeId?: unknown; readonly darkThemeId?: unknown; }): MobileThemeIds { const legacyThemeId = normalizeMobileThemeId(preferences.themeId); return { light: preferences.lightThemeId === undefined ? legacyThemeId : normalizeMobileThemeId(preferences.lightThemeId), dark: preferences.darkThemeId === undefined ? legacyThemeId : normalizeMobileThemeId(preferences.darkThemeId), }; } export function createMobileThemeSelectionPatch( themeIds: MobileThemeIds, activeAppearance: MobileThemeAppearance, selectedAppearance: MobileThemeAppearance, value: MobileThemeId, ) { const nextThemeIds: MobileThemeIds = { light: selectedAppearance === "light" ? value : themeIds.light, dark: selectedAppearance === "dark" ? value : themeIds.dark, }; return { lightThemeId: nextThemeIds.light, darkThemeId: nextThemeIds.dark, // Keep older OTA bundles on the theme for the appearance currently in use. themeId: nextThemeIds[activeAppearance], }; } export function createMobileThemePairPatch(value: MobileThemeId) { return { lightThemeId: value, darkThemeId: value, themeId: value, }; } const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)(?:\s*\/\s*([\d.]+))?\s*\)$/; function linearToSrgb(value: number): number { const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055; return Math.round(Math.min(1, Math.max(0, converted)) * 255); } /** React Native does not accept OKLCH ColorValues, so palettes cross the app boundary as sRGB. */ export function themeColorToNativeColor(value: string): string { const match = OKLCH_PATTERN.exec(value); if (!match) return value; const lightness = Number(match[1]); const chroma = Number(match[2]); const hue = (Number(match[3]) * Math.PI) / 180; const alpha = match[4] === undefined ? 1 : Number(match[4]); const a = chroma * Math.cos(hue); const b = chroma * Math.sin(hue); const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b; const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b; const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b; const l = lPrime ** 3; const m = mPrime ** 3; const s = sPrime ** 3; const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s); const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s); const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s); return alpha < 1 ? `rgba(${red}, ${green}, ${blue}, ${Number(alpha.toFixed(4))})` : `#${[red, green, blue].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } function nativeColors(colors: ThemeColors): ThemeColors { return Object.fromEntries( Object.entries(colors).map(([role, color]) => [role, themeColorToNativeColor(color)]), ) as ThemeColors; } function withAlpha(color: string, alpha: number): string { const hex = color.startsWith("#") ? color.slice(1) : ""; if (hex.length !== 6) return color; const [red, green, blue] = [0, 2, 4].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16), ); return `rgba(${red}, ${green}, ${blue}, ${alpha})`; } function rgbChannels(color: string): readonly [number, number, number] | null { const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); return match ? [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)] : null; } function relativeLuminance(channels: readonly [number, number, number]): number { const [red, green, blue] = channels.map((channel) => { const value = channel / 255; return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; }); return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!; } function contrastRatio( first: readonly [number, number, number], second: readonly [number, number, number], ): number { const firstLuminance = relativeLuminance(first); const secondLuminance = relativeLuminance(second); return ( (Math.max(firstLuminance, secondLuminance) + 0.05) / (Math.min(firstLuminance, secondLuminance) + 0.05) ); } /** Preserve the theme's action hue while making it readable as skill text on a message bubble. */ function readableMessageAccent(accent: string, surface: string): string { const accentChannels = rgbChannels(accent); const surfaceChannels = rgbChannels(surface); if ( !accentChannels || !surfaceChannels || contrastRatio(accentChannels, surfaceChannels) >= 4.5 ) { return accent; } const black = [0, 0, 0] as const; const white = [255, 255, 255] as const; const target = contrastRatio(black, surfaceChannels) >= contrastRatio(white, surfaceChannels) ? black : white; let readable: readonly [number, number, number] = target; let lowerAmount = 0; let upperAmount = 1; for (let index = 0; index < 12; index += 1) { const amount = (lowerAmount + upperAmount) / 2; const candidate: readonly [number, number, number] = [ Math.round(accentChannels[0] + (target[0] - accentChannels[0]) * amount), Math.round(accentChannels[1] + (target[1] - accentChannels[1]) * amount), Math.round(accentChannels[2] + (target[2] - accentChannels[2]) * amount), ]; if (contrastRatio(candidate, surfaceChannels) >= 4.5) { readable = candidate; upperAmount = amount; } else { lowerAmount = amount; } } return `#${readable.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; } export function themeColorWithAlpha(color: string, alpha: number): string { const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); if (hex) { return `rgba(${Number.parseInt(hex[1], 16)}, ${Number.parseInt(hex[2], 16)}, ${Number.parseInt(hex[3], 16)}, ${alpha})`; } const rgb = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/.exec(color); return rgb ? `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})` : color; } export function createMobileThemeVariables( colors: ThemeColors, appearance: MobileThemeAppearance, ): MobileThemeVariables { const c = nativeColors(colors); return { "--color-screen": c.canvas, "--color-sheet": withAlpha(c.chrome, 0.98), "--color-sheet-solid": c.chrome, "--color-card": c.surfaceRaised, "--color-card-alt": c.surface, "--color-card-translucent": withAlpha(c.surfaceRaised, 0.8), "--color-thread-canvas": c.surface, "--color-thread-selected": c.surfaceRaised, "--color-thread-selected-foreground": c.text, "--color-thread-selected-foreground-muted": c.textMuted, "--color-composer-panel": themeColorWithAlpha(c.surface, appearance === "dark" ? 0.92 : 0.88), "--color-composer-surface": themeColorWithAlpha( c.surfaceRaised, appearance === "dark" ? 0.9 : 0.94, ), "--color-composer-border": themeColorWithAlpha(c.border, appearance === "dark" ? 0.46 : 0.54), "--color-foreground": c.text, "--color-foreground-secondary": c.textMuted, "--color-foreground-muted": c.mutedForeground, "--color-foreground-tertiary": c.secondaryLabel, "--color-border": c.border, "--color-border-subtle": withAlpha(c.border, 0.7), "--color-separator": withAlpha(c.border, 0.55), "--color-subtle": c.muted, "--color-subtle-strong": c.secondary, "--color-inline-skill-background": c.accentSurface, "--color-inline-skill-border": withAlpha(c.accent, 0.42), "--color-inline-skill-foreground": c.accentSurfaceForeground, "--color-primary": c.accent, "--color-primary-foreground": c.accentForeground, "--color-primary-shadow": "#000000", "--color-secondary": c.secondary, "--color-secondary-foreground": c.secondaryForeground, "--color-secondary-border": c.border, "--color-switch-active-track": c.accent, "--color-switch-active-thumb": c.accentForeground, "--color-switch-inactive-track": c.secondary, "--color-switch-inactive-thumb": c.mutedForeground, "--color-warning": c.warningSurface, "--color-warning-border": withAlpha(c.warning, 0.32), "--color-warning-foreground": c.warningForeground, "--color-danger": c.errorSurface, "--color-danger-border": withAlpha(c.error, 0.32), "--color-danger-foreground": c.errorForeground, "--color-input": c.surfaceRaised, "--color-input-border": c.input, "--color-sidebar-search": c.sidebarControlSurface, "--color-placeholder": c.placeholder, "--color-icon": c.text, "--color-icon-muted": c.iconMuted, "--color-icon-subtle": c.secondaryLabel, "--color-header": withAlpha(c.toolbar, 0.97), "--color-header-border": c.toolbarBorder, "--color-glass-surface": withAlpha(c.surfaceOverlay, 0.74), "--color-glass-tint": withAlpha(c.surfaceOverlay, 0.22), "--color-status-bar": c.canvas, "--color-md-body": c.text, "--color-md-strong": c.toolbarForeground, "--color-md-link": c.accent, "--color-md-blockquote-border": c.border, "--color-md-blockquote-bg": c.muted, "--color-md-code-bg": c.codeBackground, "--color-md-code-text": c.codeForeground, "--color-md-user-code-bg": withAlpha(c.messageForeground, 0.18), "--color-md-user-code-text": c.messageForeground, "--color-md-user-fence-bg": withAlpha("#000000", appearance === "dark" ? 0.28 : 0.16), "--color-md-user-fence-text": c.messageForeground, "--color-md-hr": c.border, "--color-user-bubble": c.messageSurface, "--color-user-bubble-foreground": c.messageForeground, "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78), "--color-user-bubble-skill-foreground": readableMessageAccent( c.messageAction, c.messageSurface, ), "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22), "--color-drawer": withAlpha(c.sidebar, 0.99), "--color-drawer-shadow": withAlpha("#000000", appearance === "dark" ? 0.32 : 0.12), "--color-dot-separator": withAlpha(c.textMuted, 0.35), "--color-wordmark": c.text, "--color-chevron": withAlpha(c.textMuted, 0.42), }; } export const MOBILE_THEME_VARIABLE_NAMES = Object.keys( createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"), ) as ReadonlyArray; export function getMobileThemeVariables( themeId: BuiltInThemeId, appearance: MobileThemeAppearance, overrides: Partial | null = null, ): MobileThemeVariables { const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; const baseVariables = createMobileThemeVariables(colors, appearance); // The complete base record guarantees that optional overrides cannot leave a token undefined. return overrides ? ({ ...baseVariables, ...overrides } as MobileThemeVariables) : baseVariables; } export function getMobileThemePreviewColors( themeId: MobileThemeId, appearance: MobileThemeAppearance, ): ThemePreviewColors { if (themeId === DEFAULT_MOBILE_THEME_ID || themeId === "material-you") return STANDARD_THEME_PREVIEW_COLORS[appearance]; const theme = BUILT_IN_THEMES.find((candidate) => candidate.id === themeId) ?? BUILT_IN_THEMES[0]; const colors = getThemeColorsForAppearance(theme, appearance) ?? theme.colors; return { canvas: themeColorToNativeColor(colors.canvas), accent: themeColorToNativeColor(colors.accent), messageAction: themeColorToNativeColor(colors.messageAction), }; }