/** * KHAELOR * File: src/tui/theme.ts * Description: The signature "khaelis" theme (obsidian + magma) + light variant — capability ladder, NO_COLOR degradation, never color-alone (TUI v2 §1). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import type { Background, ColorDepth } from "./renderer/capabilities.js"; import { mixOklch } from "./theme/gradient.js"; /** * Style roles (TUI_DESIGN §12). Color reinforces state; it never carries it * alone — every state pairs a symbol or word, so monochrome (`NO_COLOR`, * `TERM=dumb`, non-TTY) simply renders roles as identity. */ export type StyleRole = | "text" | "dim" | "accent" | "accentDim" // the 2 Hz status-dot alternate shade — the only animation | "success" | "warning" | "error" | "code" | "violet" // thinking state, model segment, brand gradient start | "cyan" // reading state, branch segment, brand gradient end | "magenta" // verifying state | "teal" // process tools, context segment | "orange" // cost segment, waiting state | "bold" | "italic" | "strike" | "underline" | "invert" | "synKeyword" | "synString" | "synComment" | "synNumber" | "synFunction" | "synType"; /** Named per-character truecolor gradients (fallback: solid color → identity). */ export type GradientName = "brand" | "ember"; /** Subtle full-line background tints (truecolor only; identity elsewhere). */ export type LineTint = "add" | "del"; export interface Theme { name: string; colorDepth: ColorDepth; background: Background; /** Style `s` for `role`. Monochrome themes return `s` unchanged. */ paint(role: StyleRole, s: string): string; /** * Per-character truecolor gradient across the visible characters of a plain * (unstyled) string. ANSI-256/16 degrade to a solid brand color; monochrome * is identity — hierarchy never depends on the gradient (TUI_DESIGN §12). */ paintGradient(name: GradientName, s: string): string; /** Subtle diff-line background tint. Truecolor only; identity elsewhere. */ tintLine(kind: LineTint, s: string): string; } type Rgb = readonly [number, number, number]; interface Palette { text: Rgb; dim: Rgb; accent: Rgb; accentDim: Rgb; success: Rgb; warning: Rgb; error: Rgb; code: Rgb; violet: Rgb; cyan: Rgb; magenta: Rgb; teal: Rgb; orange: Rgb; synKeyword: Rgb; synString: Rgb; synComment: Rgb; synNumber: Rgb; synFunction: Rgb; synType: Rgb; } /** * khaelis — the signature dark palette: obsidian + magma (TUI v2 §1.1). * accent = ember #FF6B35, orange = ember-glow #FFB86B, teal = #2DD4BF. * Not another generic violet-cyan CLI theme — the warm accent "flows" * through phase ribbon, prompts, and gradients. */ const DARK: Palette = { text: [201, 209, 227], // #c9d1e3 dim: [91, 101, 122], // #5b657a accent: [255, 107, 53], // #ff6b35 — ember (magma) accentDim: [179, 73, 31], // ember, banked success: [126, 231, 135], // #7ee787 warning: [240, 180, 41], // #f0b429 error: [255, 92, 87], // #ff5c57 code: [154, 165, 206], violet: [187, 154, 247], // #bb9af7 cyan: [125, 207, 255], // #7dcfff magenta: [255, 121, 198], // #ff79c6 teal: [45, 212, 191], // #2dd4bf — cool counterpoint to the ember orange: [255, 184, 107], // #ffb86b — ember-glow synKeyword: [187, 154, 247], // violet synString: [126, 231, 135], // green synComment: [91, 101, 122], // dim synNumber: [255, 184, 107], // ember-glow synFunction: [125, 207, 255], // cyan synType: [45, 212, 191], // teal }; /** khaelor-light — same hue system, adjusted for light backgrounds. */ const LIGHT: Palette = { text: [46, 52, 64], dim: [122, 130, 144], accent: [46, 89, 168], accentDim: [130, 152, 199], success: [77, 124, 15], warning: [161, 98, 7], error: [190, 24, 60], code: [88, 96, 128], violet: [124, 58, 237], // #7c3aed cyan: [14, 116, 144], // #0e7490 magenta: [190, 24, 93], // #be185d teal: [15, 118, 110], // #0f766e orange: [194, 65, 12], // #c2410c synKeyword: [124, 58, 237], synString: [77, 124, 15], synComment: [122, 130, 144], synNumber: [194, 65, 12], synFunction: [14, 116, 144], synType: [4, 120, 87], // #047857 }; /** ANSI-256 quantization of the two palettes (precomputed, no math at paint time). */ const DARK_256: Record = { text: 189, dim: 60, accent: 202, // ember accentDim: 130, success: 114, warning: 178, error: 203, code: 146, violet: 141, cyan: 117, magenta: 212, teal: 43, orange: 215, // ember-glow synKeyword: 141, synString: 114, synComment: 60, synNumber: 215, synFunction: 117, synType: 43, }; const LIGHT_256: Record = { text: 237, dim: 245, accent: 25, accentDim: 110, success: 64, warning: 130, error: 161, code: 60, violet: 91, cyan: 30, magenta: 162, teal: 29, orange: 166, synKeyword: 91, synString: 64, synComment: 245, synNumber: 166, synFunction: 30, synType: 29, }; /** ANSI-16 role mapping (foreground SGR codes). */ const ANSI16: Record = { text: 39, // default foreground dim: 90, accent: 94, accentDim: 34, success: 32, warning: 33, error: 31, code: 36, violet: 95, cyan: 96, magenta: 35, teal: 36, orange: 33, synKeyword: 95, synString: 32, synComment: 90, synNumber: 33, synFunction: 96, synType: 36, }; /** Gradient endpoints per named gradient (roles resolved against the palette). */ const GRADIENTS: Record = { brand: ["violet", "cyan"], // The signature: ember → ember-glow, flowing across wordmark, phase ribbon, // active panel borders, and the context gauge (TUI v2 §1.3). ember: ["accent", "orange"], }; /** Diff-line background tints — subtle, truecolor only. */ const TINTS_DARK: Record = { add: [26, 42, 32], del: [46, 26, 32] }; const TINTS_LIGHT: Record = { add: [230, 246, 232], del: [250, 228, 232] }; const FORMAT_OPEN: Partial> = { bold: "\x1b[1m", italic: "\x1b[3m", strike: "\x1b[9m", underline: "\x1b[4m", invert: "\x1b[7m", }; const FORMAT_CLOSE: Partial> = { bold: "\x1b[22m", italic: "\x1b[23m", strike: "\x1b[29m", underline: "\x1b[24m", invert: "\x1b[27m", }; const CLOSE_FG = "\x1b[39m"; function isColorRole(role: StyleRole): role is keyof Palette { return !(role in FORMAT_OPEN); } export interface ResolveThemeOptions { colorDepth: ColorDepth; background?: Background; } /** * Resolve the theme for the detected capabilities. One exceptional default * (dark), a light variant when the background is detectably light, and full * monochrome degradation — `paint` is then the identity function, emitting * zero SGR codes (TUI_DESIGN §12). */ export function resolveTheme(options: ResolveThemeOptions): Theme { const background: Background = options.background ?? "dark"; const depth = options.colorDepth; const name = background === "dark" ? "khaelis" : "khaelor-light"; if (depth === "mono") { return { name: `${name} (mono)`, colorDepth: depth, background, paint: (_role, s) => s, paintGradient: (_name, s) => s, tintLine: (_kind, s) => s, }; } const palette = background === "dark" ? DARK : LIGHT; const palette256 = background === "dark" ? DARK_256 : LIGHT_256; const tints = background === "dark" ? TINTS_DARK : TINTS_LIGHT; const open = (role: keyof Palette): string => { if (depth === "truecolor") { const [r, g, b] = palette[role]; return `\x1b[38;2;${r};${g};${b}m`; } if (depth === "ansi256") return `\x1b[38;5;${palette256[role]}m`; const code = ANSI16[role]; return code === 39 ? "" : `\x1b[${code}m`; }; return { name, colorDepth: depth, background, paint(role: StyleRole, s: string): string { if (s === "") return s; if (isColorRole(role)) { const o = open(role); return o === "" ? s : o + s + CLOSE_FG; } return (FORMAT_OPEN[role] as string) + s + (FORMAT_CLOSE[role] as string); }, paintGradient(gradient: GradientName, s: string): string { if (s === "") return s; const [fromRole, toRole] = GRADIENTS[gradient]; if (depth !== "truecolor") { // Solid brand color — the gradient is truecolor-only polish. const o = open(fromRole); return o === "" ? s : o + s + CLOSE_FG; } const from = palette[fromRole]; const to = palette[toRole]; const chars = [...s]; const steps = Math.max(1, chars.length - 1); let out = ""; for (let i = 0; i < chars.length; i++) { // OKLCH interpolation — perceptually smooth, no muddy midpoints (TUI v2 §1.3). const [r, g, b] = mixOklch(from, to, i / steps); out += `\x1b[38;2;${r};${g};${b}m${chars[i] as string}`; } return out + CLOSE_FG; }, tintLine(kind: LineTint, s: string): string { if (s === "" || depth !== "truecolor") return s; const [r, g, b] = tints[kind]; return `\x1b[48;2;${r};${g};${b}m${s}\x1b[49m`; }, }; }