diff --git a/docs/accessibility/baseline.md b/docs/accessibility/baseline.md new file mode 100644 index 00000000..537fc371 --- /dev/null +++ b/docs/accessibility/baseline.md @@ -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. diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 44e0bfff..4848fd9b 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -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'; @@ -98,7 +98,7 @@ const FLOW_EDIT_KEYS: ReadonlySet = 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 { @@ -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) { @@ -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 { diff --git a/src/output/TranscriptPresenter.ts b/src/output/TranscriptPresenter.ts index 1639a2ea..583d29ac 100644 --- a/src/output/TranscriptPresenter.ts +++ b/src/output/TranscriptPresenter.ts @@ -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'; @@ -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'; @@ -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}`; } diff --git a/src/presentation/capabilities.ts b/src/presentation/capabilities.ts new file mode 100644 index 00000000..74f19f67 --- /dev/null +++ b/src/presentation/capabilities.ts @@ -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'; +} diff --git a/src/presentation/environment.ts b/src/presentation/environment.ts index ba799129..a42be686 100644 --- a/src/presentation/environment.ts +++ b/src/presentation/environment.ts @@ -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; } diff --git a/src/ui/palette.ts b/src/ui/palette.ts index dd521847..b1c4c56b 100644 --- a/src/ui/palette.ts +++ b/src/ui/palette.ts @@ -1,3 +1,5 @@ +import {colorLevel} from '../presentation/capabilities.js'; + export interface RgbColor { red: number; green: number; @@ -26,10 +28,13 @@ 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). */ 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`; } diff --git a/tests/accessibilityBaseline.test.ts b/tests/accessibilityBaseline.test.ts new file mode 100644 index 00000000..7d9b5e70 --- /dev/null +++ b/tests/accessibilityBaseline.test.ts @@ -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(patch: Record, 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)); +});