/** * KHAELOR * File: src/tui/motion.ts * Description: Motion doctrine — the six sanctioned animations as a tick-driven state machine, `--motion off` aware (TUI v2 §6). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ /** * The complete catalog — six effects, each carrying meaning, nothing else * (TUI v2 §6): splash sweep · phase-transition flash · approval-panel * breathing · gauge drain on compact · post-stream code colorization · * verify-strip contraction. */ export type MotionEffect = | "splash-sweep" | "phase-flash" | "panel-breath" | "gauge-drain" | "code-colorize" | "verify-contract"; /** Frames each transition runs (~16 ms ticks; ≤ 300 ms per rule 2). */ const EFFECT_FRAMES: Record = { "splash-sweep": 10, "phase-flash": 3, "panel-breath": Number.POSITIVE_INFINITY, // waiting indicator: ≤ 1 Hz, runs until decided "gauge-drain": 18, "code-colorize": 1, "verify-contract": 6, }; interface ActiveEffect { effect: MotionEffect; frame: number; } /** * Rules (TUI v2 §6): * 1. every animation advances ONLY inside the existing render tick — zero * extra timers, zero re-renders outside the frame budget; * 2. transitions ≤ 300 ms; waiting indicators ≤ 1 Hz; * 3. `enabled: false` (--motion off, NVIM, screen readers) reduces everything * to its final static state instantly. */ export class MotionController { #enabled: boolean; readonly #active = new Map(); constructor(options: { enabled: boolean }) { this.#enabled = options.enabled; } get enabled(): boolean { return this.#enabled; } setEnabled(enabled: boolean): void { this.#enabled = enabled; if (!enabled) this.#active.clear(); } /** Begin an effect under a stable key (e.g. `phase-flash:implement`). */ start(key: string, effect: MotionEffect): void { if (!this.#enabled) return; this.#active.set(key, { effect, frame: 0 }); } stop(key: string): void { this.#active.delete(key); } /** * Advance every active effect by one render tick; finished transitions are * dropped. Returns whether anything is still animating (the renderer may * skip scheduling extra frames when false). */ tick(): boolean { for (const [key, active] of this.#active) { active.frame += 1; if (active.frame >= EFFECT_FRAMES[active.effect]) this.#active.delete(key); } return this.#active.size > 0; } /** Current frame of an effect, or null when inactive/off (render static). */ frame(key: string): number | null { const active = this.#active.get(key); return active !== undefined ? active.frame : null; } /** * Breathing intensity for the approval panel: ±8% luminance over ~2 s. * Returns 0 when motion is off — the panel renders at rest. */ breathIntensity(key: string, ticksPerSecond = 60): number { const active = this.#active.get(key); if (active === undefined || active.effect !== "panel-breath") return 0; const period = 2 * ticksPerSecond; return 0.08 * Math.sin((2 * Math.PI * active.frame) / period); } }