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