/** * KHAELOR * File: src/tui/theme/gradient.ts * Description: OKLCH gradient interpolation for truecolor text — perceptually smooth, cached per length (TUI v2 §1.3). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ export type Rgb = readonly [number, number, number]; // ── sRGB ↔ OKLab/OKLCH (Björn Ottosson's reference math, dependency-free) ── function srgbToLinear(channel: number): number { const c = channel / 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); } function linearToSrgb(channel: number): number { const c = channel <= 0.0031308 ? channel * 12.92 : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055; return Math.max(0, Math.min(255, Math.round(c * 255))); } interface OkLab { L: number; a: number; b: number; } function rgbToOklab(rgb: Rgb): OkLab { const r = srgbToLinear(rgb[0]); const g = srgbToLinear(rgb[1]); const b = srgbToLinear(rgb[2]); const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); return { L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, }; } function oklabToRgb(lab: OkLab): Rgb { const l = Math.pow(lab.L + 0.3963377774 * lab.a + 0.2158037573 * lab.b, 3); const m = Math.pow(lab.L - 0.1055613458 * lab.a - 0.0638541728 * lab.b, 3); const s = Math.pow(lab.L - 0.0894841775 * lab.a - 1.291485548 * lab.b, 3); return [ linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s), ]; } /** * Interpolate two sRGB colors through OKLCH (hue-aware polar OKLab) — no * muddy midpoints, unlike naive RGB lerp (TUI v2 §1.3). */ export function mixOklch(from: Rgb, to: Rgb, t: number): Rgb { const a = rgbToOklab(from); const b = rgbToOklab(to); const chromaA = Math.hypot(a.a, a.b); const chromaB = Math.hypot(b.a, b.b); let hueA = Math.atan2(a.b, a.a); let hueB = Math.atan2(b.b, b.a); // Shortest-arc hue interpolation. if (hueB - hueA > Math.PI) hueA += 2 * Math.PI; else if (hueA - hueB > Math.PI) hueB += 2 * Math.PI; const L = a.L + (b.L - a.L) * t; const chroma = chromaA + (chromaB - chromaA) * t; const hue = hueA + (hueB - hueA) * t; return oklabToRgb({ L, a: chroma * Math.cos(hue), b: chroma * Math.sin(hue) }); } const stepCache = new Map(); /** Precomputed color ramp for a (from,to,length) triple — cached (TUI v2: no math per frame). */ export function gradientSteps(from: Rgb, to: Rgb, length: number): Rgb[] { const key = `${from.join(",")}|${to.join(",")}|${length}`; const cached = stepCache.get(key); if (cached !== undefined) return cached; const steps: Rgb[] = []; const denominator = Math.max(1, length - 1); for (let i = 0; i < length; i += 1) { steps.push(mixOklch(from, to, i / denominator)); } stepCache.set(key, steps); return steps; } /** Paint a plain string with a truecolor OKLCH gradient. */ export function gradientText(text: string, from: Rgb, to: Rgb): string { if (text.length === 0) return text; const chars = [...text]; const steps = gradientSteps(from, to, chars.length); let out = ""; for (let i = 0; i < chars.length; i += 1) { const [r, g, b] = steps[i] as Rgb; out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`; } return `${out}\x1b[39m`; } /** * One frame of the splash "gradient sweep": the ramp is phase-shifted by * `offset` characters, wrapping — advanced one step per render tick, never * by a free-running timer (TUI v2 §6 rule 1). */ export function gradientSweepFrame(text: string, from: Rgb, to: Rgb, offset: number): string { if (text.length === 0) return text; const chars = [...text]; const steps = gradientSteps(from, to, chars.length); let out = ""; for (let i = 0; i < chars.length; i += 1) { const [r, g, b] = steps[(i + offset) % steps.length] as Rgb; out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`; } return `${out}\x1b[39m`; }