Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/accessibility/baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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

Expand Down
134 changes: 134 additions & 0 deletions src/chroma/chroma.ts
Original file line number Diff line number Diff line change
@@ -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<StatusRole, Rgb> = {success: UI_COLORS.success, failure: UI_COLORS.failure, working: UI_COLORS.workingBase};
const THEME_COLORS: Record<ThemeRole, Rgb> = {
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('');
}
38 changes: 38 additions & 0 deletions src/chroma/escape.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
3 changes: 2 additions & 1 deletion src/presentation/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 2 additions & 8 deletions src/status/shimmer.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 4 additions & 3 deletions src/ui/SettingsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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[] {
Expand Down
10 changes: 4 additions & 6 deletions src/ui/palette.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {colorLevel} from '../presentation/capabilities.js';
import {colorEscape} from '../chroma/escape.js';

export interface RgbColor {
red: number;
Expand Down Expand Up @@ -28,13 +28,11 @@ export const UI_COLORS = {
selection: {red: 88, green: 96, blue: 145},
} as const satisfies Record<string, RgbColor>;

/** 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);
}
79 changes: 79 additions & 0 deletions tests/chroma.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading