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
6 changes: 6 additions & 0 deletions docs/accessibility/baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
78 changes: 78 additions & 0 deletions src/motion/motion.ts
Original file line number Diff line number Diff line change
@@ -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<Record<MotionState, MotionProfile>> = {
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));
}
19 changes: 8 additions & 11 deletions src/status/shimmer.ts
Original file line number Diff line number Diff line change
@@ -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;
}

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