From da1c8e0674506e3736f375e4e89e4e2c15e806d9 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:21:37 +0530 Subject: [PATCH] Add Chroma color primitives with capability fallback --- docs/accessibility/baseline.md | 4 +- src/chroma/chroma.ts | 134 +++++++++++++++++++++++++++++++ src/chroma/escape.ts | 38 +++++++++ src/presentation/capabilities.ts | 3 +- src/status/shimmer.ts | 10 +-- src/ui/SettingsPanel.ts | 7 +- src/ui/palette.ts | 10 +-- tests/chroma.test.ts | 79 ++++++++++++++++++ 8 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 src/chroma/chroma.ts create mode 100644 src/chroma/escape.ts create mode 100644 tests/chroma.test.ts diff --git a/docs/accessibility/baseline.md b/docs/accessibility/baseline.md index 537fc37..b5ab2d3 100644 --- a/docs/accessibility/baseline.md +++ b/docs/accessibility/baseline.md @@ -9,7 +9,7 @@ Accessibility flows through the shared primitives (`ui/palette`, `ui/glyphs`, `u | Setting | Effect | | --- | --- | | `NO_COLOR=1` (non-empty) or `TERM=dumb` | `foreground()`/`background()` emit nothing. Bold, inverse and glyphs remain. | -| `NMSH_COLOR=none` / `truecolor` | Explicit override, wins over `NO_COLOR`/`TERM`. | +| `NMSH_COLOR=none` / `256` / `truecolor` | Explicit override, wins over `NO_COLOR`/`TERM`. `256` maps NMSh colors to the xterm 256 palette. | | `NMSH_ICONS=safe` | ASCII-safe glyph set (existing). | | `NMSH_REDUCED_MOTION=1` | Shimmer/activity glyph phase and the welcome blink stay still. Durations keep counting. Deterministic presentation implies it. | @@ -29,7 +29,7 @@ These are environment controls. A persisted setting would change the public conf - Met: keyboard operation in palette, Settings, draft panels; pointer-free navigation; safe glyph set; status glyphs; footer help derived from actions. - Known gaps: command-row background bands (`TranscriptPresenter`) are a color-only cue for "this is a command" under `NO_COLOR`; the prompt prefix is the remaining cue. Provider panels (prompt, welcome, suggestions) keep hand-written footers. -- Not supported: 256-color/16-color downconversion (planned with the Chroma work, #171) and a persisted reduced-motion setting. +- Not supported: automatic 256-color detection (only explicit `NMSH_COLOR=256`), 16-color output, and a persisted reduced-motion setting. ## Screen readers diff --git a/src/chroma/chroma.ts b/src/chroma/chroma.ts new file mode 100644 index 0000000..498a451 --- /dev/null +++ b/src/chroma/chroma.ts @@ -0,0 +1,134 @@ +import {UI_COLORS} from '../ui/palette.js'; +import {colorEscape, type Rgb} from './escape.js'; + +/** + * Chroma decides which color a cell gets; UI code decides where cells exist. + * Three categories stay distinct on purpose: + * - status: success/failure/working meaning ("did it work?") + * - theme: presentation colors (text tiers, accent, selection) + * - identity: a thing's own brand color (e.g. a language). It is never a status. + * Only NMSh-owned output goes through here; PTY output and /copy never do. + */ +export type StatusRole = 'success' | 'failure' | 'working'; +export type ThemeRole = 'primary' | 'secondary' | 'subtle' | 'accent' | 'separator' | 'selection'; + +export type ColorRef = + | {kind: 'solid'; rgb: Rgb} + | {kind: 'status'; role: StatusRole} + | {kind: 'theme'; role: ThemeRole} + | {kind: 'identity'; rgb: Rgb}; + +export const solid = (rgb: Rgb): ColorRef => ({kind: 'solid', rgb}); +export const status = (role: StatusRole): ColorRef => ({kind: 'status', role}); +export const theme = (role: ThemeRole): ColorRef => ({kind: 'theme', role}); +/** A thing's identity color. Carries no success/warning/failure meaning. */ +export const identity = (rgb: Rgb): ColorRef => ({kind: 'identity', rgb}); + +/** The NMSh brand lavender; the default direction, not a hard-coded assumption of the system. */ +export const BRAND_LAVENDER: Rgb = {red: 0xA6, green: 0x7C, blue: 0xF3}; + +const STATUS_COLORS: Record = {success: UI_COLORS.success, failure: UI_COLORS.failure, working: UI_COLORS.workingBase}; +const THEME_COLORS: Record = { + primary: UI_COLORS.primary, secondary: UI_COLORS.secondary, subtle: UI_COLORS.subtle, + accent: UI_COLORS.accent, separator: UI_COLORS.separator, selection: UI_COLORS.selection, +}; + +export function resolveColor(color: ColorRef): Rgb { + switch (color.kind) { + case 'solid': + case 'identity': return color.rgb; + case 'status': return STATUS_COLORS[color.role]; + case 'theme': return THEME_COLORS[color.role]; + } +} + +/** Only status colors mean something about success or failure. */ +export function statusMeaning(color: ColorRef): StatusRole | undefined { + return color.kind === 'status' ? color.role : undefined; +} + +// ---- Curves ----------------------------------------------------------------- + +export type Curve = 'linear' | 'ease-in' | 'ease-out' | 'ease-in-out'; + +/** Maps 0..1 to 0..1 with the chosen easing; input is clamped. */ +export function applyCurve(curve: Curve, amount: number): number { + const t = Math.max(0, Math.min(1, amount)); + switch (curve) { + case 'linear': return t; + case 'ease-in': return t * t; + case 'ease-out': return 1 - (1 - t) * (1 - t); + case 'ease-in-out': return t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2; + } +} + +// ---- Gradients -------------------------------------------------------------- + +export function mixRgb(from: Rgb, to: Rgb, amount: number): Rgb { + const t = Math.max(0, Math.min(1, amount)); + return { + red: Math.round(from.red + (to.red - from.red) * t), + green: Math.round(from.green + (to.green - from.green) * t), + blue: Math.round(from.blue + (to.blue - from.blue) * t), + }; +} + +export interface GradientStop { + /** Position 0..1. */ + at: number; + color: ColorRef; +} +export interface Gradient { + stops: readonly GradientStop[]; + /** Applied to the position before sampling, e.g. to bias toward one end. */ + curve?: Curve; +} + +/** A two-stop gradient from `from` to `to`. */ +export function linearGradient(from: ColorRef, to: ColorRef, curve?: Curve): Gradient { + return {stops: [{at: 0, color: from}, {at: 1, color: to}], curve}; +} + +/** Color at position 0..1. Stops may be given in any order; positions outside the stops clamp to the ends. */ +export function sampleGradient(gradient: Gradient, position: number): Rgb { + const stops = [...gradient.stops].sort((a, b) => a.at - b.at); + if (stops.length === 0) throw new Error('A gradient needs at least one stop'); + const t = applyCurve(gradient.curve ?? 'linear', position); + const first = stops[0]!; + const last = stops[stops.length - 1]!; + if (t <= first.at) return resolveColor(first.color); + if (t >= last.at) return resolveColor(last.color); + for (let index = 1; index < stops.length; index += 1) { + const upper = stops[index]!; + if (t <= upper.at) { + const lower = stops[index - 1]!; + const span = upper.at - lower.at; + return mixRgb(resolveColor(lower.color), resolveColor(upper.color), span === 0 ? 1 : (t - lower.at) / span); + } + } + return resolveColor(last.color); +} + +/** One color per cell across `count` cells, first cell at 0 and last at 1. */ +export function gradientCells(gradient: Gradient, count: number): Rgb[] { + return Array.from({length: count}, (_, index) => sampleGradient(gradient, count <= 1 ? 0 : index / (count - 1))); +} + +/** Perceptual-ish brightness 0..1 (Rec. 709 weights), for choosing readable text on a fill. */ +export function luminance(color: Rgb): number { + return (0.2126 * color.red + 0.7152 * color.green + 0.0722 * color.blue) / 255; +} + +// ---- Painting --------------------------------------------------------------- + +export const foregroundOf = (color: ColorRef): string => colorEscape(38, resolveColor(color)); +export const backgroundOf = (color: ColorRef): string => colorEscape(48, resolveColor(color)); + +/** + * Text with one foreground color per glyph. Under no-color the result is the + * plain text, so meaning must never depend on the gradient. + */ +export function gradientText(glyphs: readonly string[], gradient: Gradient): string { + const colors = gradientCells(gradient, glyphs.length); + return glyphs.map((glyph, index) => `${colorEscape(38, colors[index]!)}${glyph}`).join(''); +} diff --git a/src/chroma/escape.ts b/src/chroma/escape.ts new file mode 100644 index 0000000..db8053f --- /dev/null +++ b/src/chroma/escape.ts @@ -0,0 +1,38 @@ +import {colorLevel, type ColorLevel} from '../presentation/capabilities.js'; + +export interface Rgb { + red: number; + green: number; + blue: number; +} + +const CUBE_LEVELS = [0, 95, 135, 175, 215, 255] as const; + +function nearestCube(value: number): number { + let best = 0; + for (let index = 1; index < CUBE_LEVELS.length; index += 1) { + if (Math.abs(CUBE_LEVELS[index]! - value) < Math.abs(CUBE_LEVELS[best]! - value)) best = index; + } + return best; +} + +/** Nearest xterm 256-palette entry (6x6x6 cube or grayscale ramp) for a color. */ +export function rgbTo256(color: Rgb): number { + const r = nearestCube(color.red); + const g = nearestCube(color.green); + const b = nearestCube(color.blue); + const cube = {red: CUBE_LEVELS[r]!, green: CUBE_LEVELS[g]!, blue: CUBE_LEVELS[b]!}; + const average = Math.round((color.red + color.green + color.blue) / 3); + const grayStep = Math.max(0, Math.min(23, Math.round((average - 8) / 10))); + const grayValue = 8 + grayStep * 10; + const gray = {red: grayValue, green: grayValue, blue: grayValue}; + const distance = (a: Rgb) => (a.red - color.red) ** 2 + (a.green - color.green) ** 2 + (a.blue - color.blue) ** 2; + return distance(gray) < distance(cube) ? 232 + grayStep : 16 + 36 * r + 6 * g + b; +} + +/** SGR sequence for a foreground (38) or background (48) color at a capability level; empty when uncolored. */ +export function colorEscape(layer: 38 | 48, color: Rgb, level: ColorLevel = colorLevel()): string { + if (level === 'none') return ''; + if (level === 'ansi256') return `\u001B[${layer};5;${rgbTo256(color)}m`; + return `\u001B[${layer};2;${color.red};${color.green};${color.blue}m`; +} diff --git a/src/presentation/capabilities.ts b/src/presentation/capabilities.ts index 74f19f6..11647d5 100644 --- a/src/presentation/capabilities.ts +++ b/src/presentation/capabilities.ts @@ -3,11 +3,12 @@ * level; without one NMSh keeps its truecolor behavior. Raw PTY output is never * affected: this applies to colors NMSh itself emits. */ -export type ColorLevel = 'none' | 'truecolor'; +export type ColorLevel = 'none' | 'ansi256' | 'truecolor'; export function colorLevel(env: NodeJS.ProcessEnv = process.env): ColorLevel { const override = env.NMSH_COLOR?.toLowerCase(); if (override === '0' || override === 'none' || override === 'off') return 'none'; + if (override === '256') return 'ansi256'; if (override === 'truecolor') return 'truecolor'; if (env.NO_COLOR) return 'none'; if (env.TERM === 'dumb') return 'none'; diff --git a/src/status/shimmer.ts b/src/status/shimmer.ts index 3c5e60f..1e13a19 100644 --- a/src/status/shimmer.ts +++ b/src/status/shimmer.ts @@ -1,4 +1,5 @@ import {graphemes} from '../input/inputLayout.js'; +import {mixRgb} from '../chroma/chroma.js'; import {foreground, UI_COLORS, type RgbColor} from '../ui/palette.js'; /** Time for the wave to travel one wavelength; independent of text length. */ @@ -28,14 +29,7 @@ export function shimmerIntensity(elapsedMs: number, glyphIndex: number, _textLen return SHIMMER_FLOOR + (SHIMMER_CEILING - SHIMMER_FLOOR) * wave; } -export function interpolateRgb(from: RgbColor, to: RgbColor, amount: number): RgbColor { - const clamped = Math.max(0, Math.min(1, amount)); - return { - red: Math.round(from.red + (to.red - from.red) * clamped), - green: Math.round(from.green + (to.green - from.green) * clamped), - blue: Math.round(from.blue + (to.blue - from.blue) * clamped), - }; -} +export const interpolateRgb = mixRgb; export function shimmerText(text: string, elapsedMs: number, isActive: boolean): string { return shimmerTextWithColors(text, elapsedMs, isActive, UI_COLORS.workingBase, UI_COLORS.workingPeak); diff --git a/src/ui/SettingsPanel.ts b/src/ui/SettingsPanel.ts index 4104951..02ed432 100644 --- a/src/ui/SettingsPanel.ts +++ b/src/ui/SettingsPanel.ts @@ -14,6 +14,7 @@ import {providerLabel} from '../prompt/PromptPanel.js'; import {welcomeProvider} from '../output/WelcomeProviders.js'; import {PROMPT_STYLES, PROMPT_STYLE_LABELS} from '../prompt/powerline.js'; import {SUGGESTION_PROVIDERS} from '../suggestions/types.js'; +import {foregroundOf, status, theme} from '../chroma/chroma.js'; import {foreground, UI_COLORS} from './palette.js'; import {GLYPHS, getCurrentGlyphMode} from './glyphs.js'; import {framePanel, renderTabStrip} from './PanelShell.js'; @@ -309,9 +310,9 @@ function footerText(state: SettingsPanelState, row: SettingsRow | undefined): st } function toneColor(tone: StatusItem['tone']): string { - return tone === 'success' ? foreground(UI_COLORS.success) - : tone === 'warning' ? foreground(UI_COLORS.failure) - : tone === 'muted' ? SUBTLE : PRIMARY; + return foregroundOf(tone === 'success' ? status('success') + : tone === 'warning' ? status('failure') + : theme(tone === 'muted' ? 'subtle' : 'primary')); } function statusLines(sections: StatusSections, columns: number): string[] { diff --git a/src/ui/palette.ts b/src/ui/palette.ts index b1c4c56..310770d 100644 --- a/src/ui/palette.ts +++ b/src/ui/palette.ts @@ -1,4 +1,4 @@ -import {colorLevel} from '../presentation/capabilities.js'; +import {colorEscape} from '../chroma/escape.js'; export interface RgbColor { red: number; @@ -28,13 +28,11 @@ export const UI_COLORS = { selection: {red: 88, green: 96, blue: 145}, } as const satisfies Record; -/** Color escapes are empty when the terminal is not to be colored (NO_COLOR, TERM=dumb, NMSH_COLOR=none). */ +/** Color escapes follow the terminal capability: none, 256-color or truecolor (see presentation/capabilities). */ export function foreground(color: RgbColor): string { - if (colorLevel() === 'none') return ''; - return `\u001B[38;2;${color.red};${color.green};${color.blue}m`; + return colorEscape(38, color); } export function background(color: RgbColor): string { - if (colorLevel() === 'none') return ''; - return `\u001B[48;2;${color.red};${color.green};${color.blue}m`; + return colorEscape(48, color); } diff --git a/tests/chroma.test.ts b/tests/chroma.test.ts new file mode 100644 index 0000000..da715d0 --- /dev/null +++ b/tests/chroma.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + applyCurve, BRAND_LAVENDER, foregroundOf, gradientCells, gradientText, identity, linearGradient, luminance, resolveColor, sampleGradient, + solid, status, statusMeaning, theme, +} from '../src/chroma/chroma.js'; +import {colorEscape, rgbTo256} from '../src/chroma/escape.js'; +import {colorLevel} from '../src/presentation/capabilities.js'; +import {foreground, UI_COLORS} from '../src/ui/palette.js'; + +const red = {red: 255, green: 0, blue: 0}; +const blue = {red: 0, green: 0, blue: 255}; + +test('categories stay distinct: an identity color never carries status meaning', () => { + const language = identity(UI_COLORS.success); + assert.equal(statusMeaning(language), undefined); + assert.equal(statusMeaning(theme('accent')), undefined); + assert.equal(statusMeaning(solid(UI_COLORS.failure)), undefined); + assert.equal(statusMeaning(status('failure')), 'failure'); + assert.deepEqual(resolveColor(status('success')), UI_COLORS.success); + assert.deepEqual(resolveColor(theme('subtle')), UI_COLORS.subtle); +}); + +test('multi-stop gradients sample, clamp and accept unordered stops', () => { + const gradient = {stops: [{at: 1, color: solid(blue)}, {at: 0, color: solid(red)}, {at: 0.5, color: solid({red: 0, green: 255, blue: 0})}]}; + assert.deepEqual(sampleGradient(gradient, 0), red); + assert.deepEqual(sampleGradient(gradient, 0.5), {red: 0, green: 255, blue: 0}); + assert.deepEqual(sampleGradient(gradient, 1), blue); + assert.deepEqual(sampleGradient(gradient, 2), blue); + assert.deepEqual(sampleGradient(gradient, 0.25), {red: 128, green: 128, blue: 0}); + assert.deepEqual(gradientCells(linearGradient(solid(red), solid(blue)), 3)[1], {red: 128, green: 0, blue: 128}); + assert.deepEqual(gradientCells(linearGradient(solid(red), solid(blue)), 1), [red]); + assert.throws(() => sampleGradient({stops: []}, 0.5)); +}); + +test('curves are monotonic, bounded and hit both ends', () => { + for (const curve of ['linear', 'ease-in', 'ease-out', 'ease-in-out'] as const) { + assert.equal(applyCurve(curve, 0), 0); + assert.equal(applyCurve(curve, 1), 1); + let previous = -1; + for (let step = 0; step <= 20; step += 1) { + const value = applyCurve(curve, step / 20); + assert.ok(value >= previous && value >= 0 && value <= 1); + previous = value; + } + } + assert.ok(applyCurve('ease-in', 0.5) < 0.5 && applyCurve('ease-out', 0.5) > 0.5); + assert.equal(applyCurve('linear', 5), 1); +}); + +test('capability fallback: truecolor, 256-color and none', () => { + assert.equal(colorEscape(38, red, 'truecolor'), '\u001B[38;2;255;0;0m'); + assert.equal(colorEscape(48, red, 'ansi256'), '\u001B[48;5;196m'); + assert.equal(colorEscape(38, red, 'none'), ''); + assert.equal(rgbTo256({red: 0, green: 0, blue: 0}), 16); + assert.equal(rgbTo256({red: 255, green: 255, blue: 255}), 231); + const mid = rgbTo256({red: 128, green: 128, blue: 128}); + assert.ok(mid >= 232 && mid <= 255); + assert.equal(colorLevel({NMSH_COLOR: '256'}), 'ansi256'); +}); + +test('the palette follows the capability level and no-color yields plain gradient text', () => { + const saved = {NMSH_COLOR: process.env.NMSH_COLOR}; + try { + process.env.NMSH_COLOR = '256'; + assert.match(foreground(UI_COLORS.accent), /^\u001B\[38;5;\d+m$/u); + process.env.NMSH_COLOR = 'none'; + assert.equal(foregroundOf(theme('accent')), ''); + assert.equal(gradientText([...'abc'], linearGradient(solid(red), solid(blue))), 'abc'); + } finally { + if (saved.NMSH_COLOR === undefined) delete process.env.NMSH_COLOR; else process.env.NMSH_COLOR = saved.NMSH_COLOR; + } +}); + +test('brand lavender and luminance', () => { + assert.deepEqual(BRAND_LAVENDER, {red: 166, green: 124, blue: 243}); + assert.ok(luminance({red: 255, green: 255, blue: 255}) > luminance(BRAND_LAVENDER)); + assert.equal(luminance({red: 0, green: 0, blue: 0}), 0); +});