spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tui/theme/gradient.ts4 * Description: OKLCH gradient interpolation for truecolor text — perceptually smooth, cached per length (TUI v2 §1.3).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910export type Rgb = readonly [number, number, number];1112// ── sRGB ↔ OKLab/OKLCH (Björn Ottosson's reference math, dependency-free) ──1314function srgbToLinear(channel: number): number {15 const c = channel / 255;16 return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);17}1819function linearToSrgb(channel: number): number {20 const c = channel <= 0.0031308 ? channel * 12.92 : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;21 return Math.max(0, Math.min(255, Math.round(c * 255)));22}2324interface OkLab {25 L: number;26 a: number;27 b: number;28}2930function rgbToOklab(rgb: Rgb): OkLab {31 const r = srgbToLinear(rgb[0]);32 const g = srgbToLinear(rgb[1]);33 const b = srgbToLinear(rgb[2]);34 const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);35 const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);36 const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);37 return {38 L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,39 a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,40 b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,41 };42}4344function oklabToRgb(lab: OkLab): Rgb {45 const l = Math.pow(lab.L + 0.3963377774 * lab.a + 0.2158037573 * lab.b, 3);46 const m = Math.pow(lab.L - 0.1055613458 * lab.a - 0.0638541728 * lab.b, 3);47 const s = Math.pow(lab.L - 0.0894841775 * lab.a - 1.291485548 * lab.b, 3);48 return [49 linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),50 linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),51 linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),52 ];53}5455/**56 * Interpolate two sRGB colors through OKLCH (hue-aware polar OKLab) — no57 * muddy midpoints, unlike naive RGB lerp (TUI v2 §1.3).58 */59export function mixOklch(from: Rgb, to: Rgb, t: number): Rgb {60 const a = rgbToOklab(from);61 const b = rgbToOklab(to);62 const chromaA = Math.hypot(a.a, a.b);63 const chromaB = Math.hypot(b.a, b.b);64 let hueA = Math.atan2(a.b, a.a);65 let hueB = Math.atan2(b.b, b.a);66 // Shortest-arc hue interpolation.67 if (hueB - hueA > Math.PI) hueA += 2 * Math.PI;68 else if (hueA - hueB > Math.PI) hueB += 2 * Math.PI;69 const L = a.L + (b.L - a.L) * t;70 const chroma = chromaA + (chromaB - chromaA) * t;71 const hue = hueA + (hueB - hueA) * t;72 return oklabToRgb({ L, a: chroma * Math.cos(hue), b: chroma * Math.sin(hue) });73}7475const stepCache = new Map<string, Rgb[]>();7677/** Precomputed color ramp for a (from,to,length) triple — cached (TUI v2: no math per frame). */78export function gradientSteps(from: Rgb, to: Rgb, length: number): Rgb[] {79 const key = `${from.join(",")}|${to.join(",")}|${length}`;80 const cached = stepCache.get(key);81 if (cached !== undefined) return cached;82 const steps: Rgb[] = [];83 const denominator = Math.max(1, length - 1);84 for (let i = 0; i < length; i += 1) {85 steps.push(mixOklch(from, to, i / denominator));86 }87 stepCache.set(key, steps);88 return steps;89}9091/** Paint a plain string with a truecolor OKLCH gradient. */92export function gradientText(text: string, from: Rgb, to: Rgb): string {93 if (text.length === 0) return text;94 const chars = [...text];95 const steps = gradientSteps(from, to, chars.length);96 let out = "";97 for (let i = 0; i < chars.length; i += 1) {98 const [r, g, b] = steps[i] as Rgb;99 out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`;100 }101 return `${out}\x1b[39m`;102}103104/**105 * One frame of the splash "gradient sweep": the ramp is phase-shifted by106 * `offset` characters, wrapping — advanced one step per render tick, never107 * by a free-running timer (TUI v2 §6 rule 1).108 */109export function gradientSweepFrame(text: string, from: Rgb, to: Rgb, offset: number): string {110 if (text.length === 0) return text;111 const chars = [...text];112 const steps = gradientSteps(from, to, chars.length);113 let out = "";114 for (let i = 0; i < chars.length; i += 1) {115 const [r, g, b] = steps[(i + offset) % steps.length] as Rgb;116 out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`;117 }118 return `${out}\x1b[39m`;119}120