diff --git a/docs/accessibility/baseline.md b/docs/accessibility/baseline.md index b5ab2d3..5abe2bf 100644 --- a/docs/accessibility/baseline.md +++ b/docs/accessibility/baseline.md @@ -34,3 +34,9 @@ These are environment controls. A persisted setting would change the public conf ## 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. + +## Motion + +`motion/motion.ts` is a pure engine: profiles per semantic state (`waiting`, `processing`, `streaming`, `transition`, `completion`, `failure`) sampled at an elapsed time. Reduced motion holds every profile still. It never schedules timers, changes width or touches PTY output. + +The shimmer (`status/shimmer.ts`) samples the `processing`/`streaming` profiles. The one existing activity timer is already bounded (100 ms), cleared on exit, and its renders are suppressed during passthrough, so no shared scheduler was added; one should arrive with a second animated consumer. `waiting`, `transition`, `completion` and `failure` have no consumer yet. diff --git a/src/motion/motion.ts b/src/motion/motion.ts new file mode 100644 index 0000000..5b4110c --- /dev/null +++ b/src/motion/motion.ts @@ -0,0 +1,78 @@ +import {applyCurve, type Curve} from '../chroma/chroma.js'; +import {isReducedMotion} from '../presentation/environment.js'; + +/** + * Restrained, pure motion: given elapsed time (from whichever clock the caller + * owns) a profile yields a 0..1 intensity for a cell. Chroma turns intensity + * into color; UI code decides where cells are. Nothing here changes width, + * schedules timers, or touches PTY output. + */ +export type MotionShape = + | 'sine' // symmetric breathing / traveling shimmer + | 'comet' // slow rise, fast fall + | 'tail' // fast rise, long tail + | 'pulse'; // quick rise, then settle to rest + +export type MotionSpread = + | 'uniform' // every cell moves together + | 'travel'; // the crest moves across cells + +export interface MotionProfile { + shape: MotionShape; + spread: MotionSpread; + /** Time for one full cycle (or the whole gesture when not repeating). */ + cycleMs: number; + /** Repeating profiles loop; the rest settle at 0 once the gesture ends. */ + repeat: boolean; + /** Cells per wavelength for `travel`. */ + wavelength?: number; + /** Shapes the finished waveform; linear when omitted. */ + curve?: Curve; +} + +export type MotionState = 'waiting' | 'processing' | 'streaming' | 'transition' | 'completion' | 'failure'; + +/** Which motion communicates which NMSh state. Reduced motion holds every one still. */ +export const MOTION_PROFILES: Readonly> = { + waiting: {shape: 'sine', spread: 'uniform', cycleMs: 3200, repeat: true}, + processing: {shape: 'sine', spread: 'travel', cycleMs: 1800, repeat: true, wavelength: 12}, + streaming: {shape: 'sine', spread: 'travel', cycleMs: 1200, repeat: true, wavelength: 12}, + transition: {shape: 'pulse', spread: 'uniform', cycleMs: 300, repeat: false, curve: 'ease-out'}, + completion: {shape: 'tail', spread: 'uniform', cycleMs: 700, repeat: false}, + failure: {shape: 'pulse', spread: 'uniform', cycleMs: 450, repeat: false}, +}; + +export function wrappedPhase(elapsedMs: number, cycleMs: number): number { + if (cycleMs <= 0) return 0; + return ((elapsedMs % cycleMs) + cycleMs) % cycleMs / cycleMs; +} + +/** Waveform value for a phase in [0,1). */ +function waveform(shape: MotionShape, phase: number): number { + switch (shape) { + case 'sine': return (1 + Math.cos(2 * Math.PI * phase)) / 2; + case 'comet': return phase < 0.8 ? phase / 0.8 : 1 - (phase - 0.8) / 0.2; + case 'tail': return phase < 0.15 ? phase / 0.15 : 1 - (phase - 0.15) / 0.85; + case 'pulse': return phase < 0.25 ? applyCurve('ease-out', phase / 0.25) : 1 - applyCurve('ease-in-out', (phase - 0.25) / 0.75); + } +} + +export interface SampleOptions { + /** Hold the profile still. Defaults to the user's reduced-motion setting. */ + reduced?: boolean; +} + +/** + * Intensity 0..1 of `cell` (its index along the text or bar) after `elapsedMs`. + * Reduced motion samples the profile at time zero for every call, so output is + * stable frame to frame. + */ +export function sampleMotion(profile: MotionProfile, elapsedMs: number, cell = 0, options: SampleOptions = {}): number { + const reduced = options.reduced ?? isReducedMotion(); + const elapsed = reduced ? 0 : elapsedMs; + if (!profile.repeat && !reduced && (elapsed < 0 || elapsed >= profile.cycleMs)) return 0; + const clock = profile.repeat ? wrappedPhase(elapsed, profile.cycleMs) : Math.max(0, Math.min(1, elapsed / profile.cycleMs)); + const shift = profile.spread === 'travel' ? cell / (profile.wavelength ?? 12) : 0; + const phase = ((clock - shift) % 1 + 1) % 1; + return applyCurve(profile.curve ?? 'linear', waveform(profile.shape, phase)); +} diff --git a/src/status/shimmer.ts b/src/status/shimmer.ts index 1e13a19..625ef85 100644 --- a/src/status/shimmer.ts +++ b/src/status/shimmer.ts @@ -1,31 +1,28 @@ import {graphemes} from '../input/inputLayout.js'; import {mixRgb} from '../chroma/chroma.js'; +import {MOTION_PROFILES, sampleMotion, wrappedPhase} from '../motion/motion.js'; import {foreground, UI_COLORS, type RgbColor} from '../ui/palette.js'; /** Time for the wave to travel one wavelength; independent of text length. */ -export const SHIMMER_CYCLE_MS = 1800; +export const SHIMMER_CYCLE_MS = MOTION_PROFILES.processing.cycleMs; /** Recent output quickens the wave slightly instead of pulsing the whole line. */ -export const ACTIVE_SHIMMER_CYCLE_MS = 1200; +export const ACTIVE_SHIMMER_CYCLE_MS = MOTION_PROFILES.streaming.cycleMs; /** Glyphs per wavelength: neighbours differ by 1/12 of a cycle. */ -export const SHIMMER_WAVELENGTH = 12; +export const SHIMMER_WAVELENGTH = MOTION_PROFILES.processing.wavelength!; /** Luminance stays inside this band so the effect reads as a soft sheen. */ const SHIMMER_FLOOR = 0.12; const SHIMMER_CEILING = 0.9; -export function wrappedPhase(elapsedMs: number, cycleMs: number): number { - if (cycleMs <= 0) return 0; - return ((elapsedMs % cycleMs) + cycleMs) % cycleMs / cycleMs; -} +export {wrappedPhase}; /** * Per-glyph luminance (0 base … 1 peak) of a smooth wave travelling left to - * right. Each glyph is phase-shifted from its neighbour by 1/wavelength, and - * the crest advances wavelength/cycle glyphs per millisecond, so a 100 ms + * right: the `processing` profile, or `streaming` while output is arriving. + * The crest advances wavelength/cycle glyphs per millisecond, so a 100 ms * frame moves it well under one glyph whatever the text length. */ export function shimmerIntensity(elapsedMs: number, glyphIndex: number, _textLength: number, isActive: boolean): number { - const phase = wrappedPhase(elapsedMs, isActive ? ACTIVE_SHIMMER_CYCLE_MS : SHIMMER_CYCLE_MS); - const wave = (1 + Math.cos(2 * Math.PI * (glyphIndex / SHIMMER_WAVELENGTH - phase))) / 2; + const wave = sampleMotion(MOTION_PROFILES[isActive ? 'streaming' : 'processing'], elapsedMs, glyphIndex); return SHIMMER_FLOOR + (SHIMMER_CEILING - SHIMMER_FLOOR) * wave; } diff --git a/tests/motion.test.ts b/tests/motion.test.ts new file mode 100644 index 0000000..57a7cdc --- /dev/null +++ b/tests/motion.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {MOTION_PROFILES, sampleMotion, wrappedPhase, type MotionProfile, type MotionState} from '../src/motion/motion.js'; +import {shimmerIntensity} from '../src/status/shimmer.js'; + +const STILL = {reduced: false}; + +test('every profile stays within 0..1 for any time and cell', () => { + for (const [state, profile] of Object.entries(MOTION_PROFILES) as Array<[MotionState, MotionProfile]>) { + for (let time = -500; time < 5000; time += 37) { + for (const cell of [0, 3, 11, 40]) { + const value = sampleMotion(profile, time, cell, STILL); + assert.ok(value >= 0 && value <= 1, `${state} t=${time} cell=${cell} -> ${value}`); + } + } + } +}); + +test('breathing is symmetric and uniform across cells', () => { + const waiting = MOTION_PROFILES.waiting; + const half = waiting.cycleMs / 2; + assert.equal(sampleMotion(waiting, 0, 0, STILL), 1); + assert.ok(Math.abs(sampleMotion(waiting, half, 0, STILL)) < 1e-9); + assert.ok(Math.abs(sampleMotion(waiting, 400, 0, STILL) - sampleMotion(waiting, waiting.cycleMs - 400, 0, STILL)) < 1e-9); + assert.equal(sampleMotion(waiting, 700, 0, STILL), sampleMotion(waiting, 700, 25, STILL)); +}); + +test('traveling shimmer moves its crest across cells over time', () => { + const crest = (time: number) => { + let best = 0; + for (let cell = 0; cell < 12; cell += 1) if (sampleMotion(MOTION_PROFILES.processing, time, cell, STILL) > sampleMotion(MOTION_PROFILES.processing, time, best, STILL)) best = cell; + return best; + }; + assert.ok(crest(0) !== crest(600)); +}); + +test('comet rises slowly and falls fast; tail rises fast and decays slowly', () => { + const comet: MotionProfile = {shape: 'comet', spread: 'uniform', cycleMs: 1000, repeat: true}; + const peak = sampleMotion(comet, 800, 0, STILL); + assert.equal(peak, 1); + assert.ok(sampleMotion(comet, 400, 0, STILL) < peak && sampleMotion(comet, 900, 0, STILL) < peak); + assert.ok(sampleMotion(comet, 900, 0, STILL) - sampleMotion(comet, 1000 - 1, 0, STILL) > 0.4, 'falls within the last 20%'); + const tail = MOTION_PROFILES.completion; + assert.equal(Math.round(sampleMotion(tail, 0.15 * tail.cycleMs, 0, STILL) * 1000), 1000); + assert.ok(sampleMotion(tail, 0.5 * tail.cycleMs, 0, STILL) > 0.5, 'long tail is still lit at half time'); +}); + +test('one-shot profiles settle at rest and do not repeat', () => { + for (const state of ['transition', 'completion', 'failure'] as const) { + const profile = MOTION_PROFILES[state]; + assert.equal(sampleMotion(profile, 0, 0, STILL), 0); + assert.equal(sampleMotion(profile, profile.cycleMs, 0, STILL), 0); + assert.equal(sampleMotion(profile, profile.cycleMs * 3.3, 0, STILL), 0); + assert.equal(sampleMotion(profile, -10, 0, STILL), 0); + } + const pulse = MOTION_PROFILES.failure; + assert.ok(sampleMotion(pulse, pulse.cycleMs * 0.25, 0, STILL) > 0.95); +}); + +test('reduced motion holds every profile still and is deterministic', () => { + for (const profile of Object.values(MOTION_PROFILES)) { + const first = sampleMotion(profile, 0, 4, {reduced: true}); + for (const time of [1, 250, 999, 12345]) assert.equal(sampleMotion(profile, time, 4, {reduced: true}), first); + } + const saved = process.env.NMSH_REDUCED_MOTION; + try { + process.env.NMSH_REDUCED_MOTION = '1'; + assert.equal(shimmerIntensity(777, 3, 10, false), shimmerIntensity(12345, 3, 10, false)); + } finally { + if (saved === undefined) delete process.env.NMSH_REDUCED_MOTION; else process.env.NMSH_REDUCED_MOTION = saved; + } +}); + +test('phase wraps and the shimmer is periodic', () => { + assert.equal(wrappedPhase(-250, 1000), 0.75); + assert.equal(shimmerIntensity(300, 2, 10, false), shimmerIntensity(300 + MOTION_PROFILES.processing.cycleMs, 2, 10, false)); +});