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
36 changes: 36 additions & 0 deletions docs/accessibility/baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Accessibility and reduced-presentation baseline

This applies to NMSh-owned surfaces only: panels, the composer, status rows, help and the transcript chrome. Raw PTY output, archived command output and `/copy` are never restyled.

Accessibility flows through the shared primitives (`ui/palette`, `ui/glyphs`, `ui/actions`, `ui/formControls`, `presentation/environment`), not a separate renderer.

## Controls

| 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_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. |

These are environment controls. A persisted setting would change the public configuration schema and is left to the Settings work.

## Acceptance criteria

1. Every action is reachable by keyboard; the pointer only adds shortcuts (`ui/actions`).
2. The focused row is identifiable without color: a pointer glyph (`›`, or `>` in safe mode) or `>` in plain form controls.
3. Changed and error states are text (`(changed)`, `Error: ...`), not color alone (`ui/formControls`).
4. Success/failure rows carry distinct glyphs (`✔`/`✘`, or `+`/`x` in safe mode) in addition to color.
5. With `NO_COLOR`, no NMSh-generated color escape is written; layout is unchanged.
6. With reduced motion, no NMSh-owned decoration changes between frames unless state changes.
7. No width-changing animation.

## Audit result

- 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.

## Screen readers

NMSh redraws full-screen frames in the alternate screen. Terminals expose that to assistive technology inconsistently, so NMSh cannot guarantee screen-reader-friendly output. What it does provide is text-carried state and no pointer requirement; behavior with a specific terminal and reader has not been verified.
8 changes: 4 additions & 4 deletions src/app/TerminalApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import {shouldPassthrough} from '../passthrough/PassthroughPolicy.js';
import {layoutInput, graphemes} from '../input/inputLayout.js';
import {editText} from '../ui/formControls.js';
import {shimmerText} from '../status/shimmer.js';
import {isDeterministicPresentation, presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../presentation/environment.js';
import {isReducedMotion, presentationAnimationElapsed, presentationCompletionTime, presentationNow} from '../presentation/environment.js';
import {TaskProgress} from '../status/TaskProgress.js';
import {completedActivity, liveActivityParts} from '../status/activity.js';
import {extractFacts} from '../status/adapters.js';
Expand Down Expand Up @@ -98,7 +98,7 @@ const FLOW_EDIT_KEYS: ReadonlySet<Key['kind']> = new Set(['text', 'paste', 'back
const STOPPED = foreground({red: 198, green: 156, blue: 109});
const INFO = SECONDARY;
const RESET = '\u001B[0m';
const PASTE_ATOM_BACKGROUND = '\u001B[48;2;63;65;82m';
const PASTE_ATOM_BACKGROUND = background({red: 63, green: 65, blue: 82});
const STATUS_REFRESH_MS = 100;

export class TerminalApp {
Expand Down Expand Up @@ -2401,7 +2401,7 @@ export class TerminalApp {
* change. Blinks are skipped (not queued) while no welcome is present.
*/
private scheduleWelcomeBlink(): void {
if (this.stopped || isDeterministicPresentation()) return;
if (this.stopped || isReducedMotion()) return;
this.welcomeBlinkTimer = setTimeout(() => {
if (this.stopped) return;
if (!this.output.hasWelcome || this.passthrough) {
Expand Down Expand Up @@ -2680,7 +2680,7 @@ export class TerminalApp {
const isActive = (Date.now() - this.lastOutputTime) < 750;
const animationElapsed = presentationAnimationElapsed(elapsed);
const parts = liveActivityParts(this.running.command, elapsed, animationElapsed);
return `${shimmerText(parts.phrase, animationElapsed, isDeterministicPresentation() ? false : isActive)}${SECONDARY}${parts.duration}${RESET}`;
return `${shimmerText(parts.phrase, animationElapsed, isReducedMotion() ? false : isActive)}${SECONDARY}${parts.duration}${RESET}`;
}

private jumpAffordance(columns: number): string {
Expand Down
12 changes: 6 additions & 6 deletions src/output/TranscriptPresenter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {type StyledLine} from './AnsiOutputParser.js';
import {wrapStyledLine, type WrappedRow} from './viewport.js';
import {foreground, UI_COLORS} from '../ui/palette.js';
import {background, foreground, UI_COLORS} from '../ui/palette.js';
import {GLYPHS} from '../ui/glyphs.js';
import {displayWidth, repeatToWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js';
import {formatDuration} from '../status/commandTiming.js';
Expand All @@ -21,9 +21,9 @@ const SECONDARY = foreground(UI_COLORS.secondary);
const SUBTLE = foreground(UI_COLORS.subtle);
const RESET = '\u001B[0m';
/** Row surfaces: submitted command rows, hovered and focused disclosure rows. */
const COMMAND_SURFACE = '\u001B[48;2;38;38;48m';
const HOVER_SURFACE = '\u001B[48;2;45;45;55m';
const FOCUS_SURFACE = '\u001B[48;2;60;60;80m';
const COMMAND_SURFACE = background({red: 38, green: 38, blue: 48});
const HOVER_SURFACE = background({red: 45, green: 45, blue: 55});
const FOCUS_SURFACE = background({red: 60, green: 60, blue: 80});

/** Transcript presentation: Normal rows, or Chat with right-aligned command blocks. */
export type TranscriptLayout = 'normal' | 'chat';
Expand Down Expand Up @@ -470,7 +470,7 @@ export function renderHistoricalContext(context: HistoricalContextSnapshot, widt
}

function rgbStyle(foregroundColor?: Rgb, backgroundColor?: Rgb): string {
const fg = foregroundColor ? `\u001B[38;2;${foregroundColor.red};${foregroundColor.green};${foregroundColor.blue}m` : '';
const bg = backgroundColor ? `\u001B[48;2;${backgroundColor.red};${backgroundColor.green};${backgroundColor.blue}m` : '\u001B[49m';
const fg = foregroundColor ? foreground(foregroundColor) : '';
const bg = backgroundColor ? background(backgroundColor) : '\u001B[49m';
return `${fg}${bg}`;
}
15 changes: 15 additions & 0 deletions src/presentation/capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Terminal color capability for NMSh-owned UI. Only explicit signals lower the
* 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 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 === 'truecolor') return 'truecolor';
if (env.NO_COLOR) return 'none';
if (env.TERM === 'dumb') return 'none';
return 'truecolor';
}
13 changes: 11 additions & 2 deletions src/presentation/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ export function presentationCompletionTime(completedAt: Date): Date {
return isDeterministicPresentation() ? presentationNow() : completedAt;
}

/** Stable shimmer phase for visual captures without changing measured durations. */
/**
* True when NMSh-owned decorative motion should stay still: the user asked for
* reduced motion (NMSH_REDUCED_MOTION=1), or presentation is deterministic.
* Measured durations and shell behavior are never affected.
*/
export function isReducedMotion(): boolean {
return process.env.NMSH_REDUCED_MOTION === '1' || isDeterministicPresentation();
}

/** Stable shimmer/activity phase when motion is reduced, without changing measured durations. */
export function presentationAnimationElapsed(elapsedMs: number): number {
return isDeterministicPresentation() ? 0 : elapsedMs;
return isReducedMotion() ? 0 : elapsedMs;
}
5 changes: 5 additions & 0 deletions src/ui/palette.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {colorLevel} from '../presentation/capabilities.js';

export interface RgbColor {
red: number;
green: number;
Expand Down Expand Up @@ -26,10 +28,13 @@ 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). */
export function foreground(color: RgbColor): string {
if (colorLevel() === 'none') return '';
return `\u001B[38;2;${color.red};${color.green};${color.blue}m`;
}

export function background(color: RgbColor): string {
if (colorLevel() === 'none') return '';
return `\u001B[48;2;${color.red};${color.green};${color.blue}m`;
}
54 changes: 54 additions & 0 deletions tests/accessibilityBaseline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {colorLevel} from '../src/presentation/capabilities.js';
import {isReducedMotion, presentationAnimationElapsed} from '../src/presentation/environment.js';
import {background, foreground, UI_COLORS} from '../src/ui/palette.js';
import {shimmerText} from '../src/status/shimmer.js';
import {GLYPHS} from '../src/ui/glyphs.js';

function withEnv<T>(patch: Record<string, string | undefined>, run: () => T): T {
const saved = Object.fromEntries(Object.keys(patch).map(name => [name, process.env[name]]));
for (const [name, value] of Object.entries(patch)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; }
try { return run(); } finally {
for (const [name, value] of Object.entries(saved)) { if (value === undefined) delete process.env[name]; else process.env[name] = value; }
}
}
const CLEAN = {NO_COLOR: undefined, NMSH_COLOR: undefined, NMSH_REDUCED_MOTION: undefined, NMSH_DETERMINISTIC: undefined, TERM: 'xterm-256color'};

test('color level only drops on explicit signals', () => {
assert.equal(colorLevel({TERM: 'xterm-256color'}), 'truecolor');
assert.equal(colorLevel({}), 'truecolor');
assert.equal(colorLevel({NO_COLOR: '1'}), 'none');
assert.equal(colorLevel({NO_COLOR: ''}), 'truecolor');
assert.equal(colorLevel({TERM: 'dumb'}), 'none');
assert.equal(colorLevel({NO_COLOR: '1', NMSH_COLOR: 'truecolor'}), 'truecolor');
assert.equal(colorLevel({NMSH_COLOR: 'none'}), 'none');
});

test('palette helpers emit no color escapes under NO_COLOR', () => {
withEnv({...CLEAN}, () => assert.match(foreground(UI_COLORS.accent), /^\u001B\[38;2;/u));
withEnv({...CLEAN, NO_COLOR: '1'}, () => {
assert.equal(foreground(UI_COLORS.accent), '');
assert.equal(background(UI_COLORS.accent), '');
assert.equal(shimmerText('Running ls', 500, false).includes('\u001B'), false);
});
});

test('status cues survive without color', () => {
withEnv({NMSH_ICONS: 'safe'}, () => {
assert.notEqual(GLYPHS.success, GLYPHS.failure);
assert.match(GLYPHS.success + GLYPHS.failure, /^[\x20-\x7e]+$/u);
});
});

test('reduced motion freezes the animation phase but not measured time', () => {
withEnv({...CLEAN}, () => {
assert.equal(isReducedMotion(), false);
assert.equal(presentationAnimationElapsed(4321), 4321);
});
withEnv({...CLEAN, NMSH_REDUCED_MOTION: '1'}, () => {
assert.equal(isReducedMotion(), true);
assert.equal(presentationAnimationElapsed(4321), 0);
});
withEnv({...CLEAN, NMSH_DETERMINISTIC: '1'}, () => assert.equal(isReducedMotion(), true));
});
Loading