1import type { ThemeAppearance } from "./themePalettes.js";
2
3export type ThemePreviewColors = Readonly<{
4 canvas: string;
5 accent: string;
6 messageAction: string;
7}>;
8
9/** The standard T3 Code artwork is not a built-in theme, so its preview colors live here. */
10export const STANDARD_THEME_PREVIEW_COLORS: Readonly<Record<ThemeAppearance, ThemePreviewColors>> =
11 {
12 light: {
13 canvas: "#fcfcfc",
14 accent: "#f4f4f5",
15 messageAction: "#4f46e5",
16 },
17 dark: {
18 canvas: "#0a0a0a",
19 accent: "#1c1c1f",
20 messageAction: "#8b9cff",
21 },
22 };
23
24export type ThemePreviewRenderSpec = Readonly<{
25 baseTarget: string;
26 baseWeight: number;
27 accent: Readonly<{
28 center: readonly [x: number, y: number];
29 middleOffset: number;
30 middleOpacity: number;
31 endOffset: number;
32 }>;
33 action: Readonly<{
34 center: readonly [x: number, y: number];
35 startOpacity: number;
36 endOffset: number;
37 }>;
38 scale: number;
39 blurAt56Px: number;
40}>;
41
42/** Shared geometry and falloff for the web and native theme preview orbs. */
43export const THEME_PREVIEW_RENDER_SPECS: Readonly<Record<ThemeAppearance, ThemePreviewRenderSpec>> =
44 {
45 light: {
46 baseTarget: "#ffffff",
47 baseWeight: 0.8,
48 accent: {
49 center: [0.72, 0.22],
50 middleOffset: 0.28,
51 middleOpacity: 0.72,
52 endOffset: 0.58,
53 },
54 action: {
55 center: [0.18, 0.82],
56 startOpacity: 0.45,
57 endOffset: 0.55,
58 },
59 scale: 1.1,
60 blurAt56Px: 3,
61 },
62 dark: {
63 baseTarget: "#09090b",
64 baseWeight: 0.8,
65 accent: {
66 center: [0.28, 0.78],
67 middleOffset: 0.28,
68 middleOpacity: 0.62,
69 endOffset: 0.58,
70 },
71 action: {
72 center: [0.82, 0.18],
73 startOpacity: 0.45,
74 endOffset: 0.55,
75 },
76 scale: 1.1,
77 blurAt56Px: 3,
78 },
79 };
80
81type Oklab = Readonly<{ l: number; a: number; b: number }>;
82
83const OKLCH_PATTERN = /^oklch\(\s*([\d.]+)\s+([\d.]+)\s+(-?[\d.]+)/;
84const HEX_PATTERN = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;
85
86function srgbToLinear(value: number): number {
87 return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
88}
89
90function linearToSrgb(value: number): number {
91 const converted = value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
92 return Math.round(Math.min(1, Math.max(0, converted)) * 255);
93}
94
95function parseOklab(value: string): Oklab | null {
96 const oklch = OKLCH_PATTERN.exec(value);
97 if (oklch) {
98 const lightness = Number(oklch[1]);
99 const chroma = Number(oklch[2]);
100 const hue = (Number(oklch[3]) * Math.PI) / 180;
101 return { l: lightness, a: chroma * Math.cos(hue), b: chroma * Math.sin(hue) };
102 }
103
104 const hex = HEX_PATTERN.exec(value);
105 if (!hex) return null;
106 const red = srgbToLinear(Number.parseInt(hex[1]!, 16) / 255);
107 const green = srgbToLinear(Number.parseInt(hex[2]!, 16) / 255);
108 const blue = srgbToLinear(Number.parseInt(hex[3]!, 16) / 255);
109 const lRoot = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
110 const mRoot = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
111 const sRoot = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
112 return {
113 l: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot,
114 a: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot,
115 b: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot,
116 };
117}
118
119function oklabToHex(color: Oklab): string {
120 const lPrime = color.l + 0.3963377774 * color.a + 0.2158037573 * color.b;
121 const mPrime = color.l - 0.1055613458 * color.a - 0.0638541728 * color.b;
122 const sPrime = color.l - 0.0894841775 * color.a - 1.291485548 * color.b;
123 const l = lPrime ** 3;
124 const m = mPrime ** 3;
125 const s = sPrime ** 3;
126 const channels = [
127 linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
128 linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
129 linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
130 ];
131 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
132}
133
134export function mixThemePreviewBase(colors: ThemePreviewColors, mode: ThemeAppearance): string {
135 const spec = THEME_PREVIEW_RENDER_SPECS[mode]!;
136 const canvas = parseOklab(colors.canvas);
137 const target = parseOklab(spec.baseTarget);
138 if (!canvas || !target) return colors.canvas;
139 const targetWeight = 1 - spec.baseWeight;
140 return oklabToHex({
141 l: canvas.l * spec.baseWeight + target.l * targetWeight,
142 a: canvas.a * spec.baseWeight + target.a * targetWeight,
143 b: canvas.b * spec.baseWeight + target.b * targetWeight,
144 });
145}