SPB Git

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%
4.2 KB · 120 lines typescript
Raw Blame History
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