- commit
- 992b3a90f1c97b46bc956e321c14f830f701e39b
- parent
- f2ce04bd8ac19e4b1f6429359f36060d585a735d
- author
- Dami <dami.etoile@gmail.com>
| A | src/ | 109 | +++++++++++++++++++++++++++ |
| M | src/ | 113 | ---------------------------- |
| A | src/ | 87 | ++++++++++++++++++++++ |
| M | src/ | 119 | +++++++++++++++++++++++++----- |
| A | tests/ | 37 | +++++++++ |
@@ -0,0 +1,109 @@
1+const OPEN_DURATION = 280;2+const SHUTTER_DURATION = 140;3+const DROP_DURATION = 200;4+const DROP_STAGGER = 35;5+const EXIT_DURATION = 230;6+const EXIT_ITEM_DURATION = 170;7+const EXIT_STAGGER = 25;8+const SLIDE_DURATION = 110;9+const PRESS_DURATION = 126;10+const ITEM_PITCH = 68;11+const ITEM_HEIGHT = 54;12+13+const clamp = (value, min = 0, max = 1) => Math.max(min, Math.min(max, value));14+15+export function easeOutCubic(value) {16+ const t = clamp(value);17+ return 1 - ((1 - t) ** 3);18+}19+20+export function easeInCubic(value) {21+ return clamp(value) ** 3;22+}23+24+export function easeOutBack(value) {25+ const t = clamp(value);26+ const overshoot = 1.70158;27+ return 1 + (overshoot + 1) * ((t - 1) ** 3) + overshoot * ((t - 1) ** 2);28+}29+30+function progress(now, start, duration) {31+ if (!Number.isFinite(start)) return 1;32+ return clamp((now - start) / duration);33+}34+35+function staggerProgress(now, start, index, duration, stagger) {36+ return progress(now, start + index * stagger, duration);37+}38+39+/**40+ * Derive all pause menu choreography from timestamps. This keeps render code41+ * declarative and gives keyboard, pointer, and reduced-motion tests one pure42+ * contract to inspect.43+ */44+export function pauseMotionFrame({45+ now = 0,46+ openedAt = 0,47+ phase = 'idle',48+ exitStartedAt = 0,49+ itemCount = 0,50+ selected = 0,51+ previousSelected = selected,52+ selectedAt = 0,53+ pressedIndex = -1,54+ pressedAt = 0,55+ reducedMotion = false,56+} = {}) {57+ const openElapsed = Math.max(0, now - openedAt);58+ const opening = !reducedMotion && phase === 'opening';59+ const exiting = !reducedMotion && phase === 'exiting';60+ const openProgress = reducedMotion ? 1 : progress(now, openedAt, OPEN_DURATION);61+ const exitProgress = reducedMotion ? 1 : progress(now, exitStartedAt, EXIT_DURATION);62+ const titleProgress = reducedMotion ? 1 : progress(now, openedAt + 60, 160);63+ const itemProgress = Array.from({ length: itemCount }, (_, index) => {64+ if (reducedMotion) return 1;65+ if (exiting) {66+ const reverseIndex = itemCount - 1 - index;67+ return 1 - easeInCubic(staggerProgress(now, exitStartedAt, reverseIndex, EXIT_ITEM_DURATION, EXIT_STAGGER));68+ }69+ return clamp(easeOutBack(staggerProgress(now, openedAt + 80, index, DROP_DURATION, DROP_STAGGER)));70+ });71+ const selectionProgress = reducedMotion || selected === previousSelected72+ ? 173+ : progress(now, selectedAt, SLIDE_DURATION);74+ const slideProgress = reducedMotion || selected === previousSelected75+ ? 176+ : easeOutBack(selectionProgress);77+ const selectedPosition = previousSelected + (selected - previousSelected) * slideProgress;78+ const pawHop = reducedMotion ? 0 : Math.sin(Math.PI * selectionProgress) * 16;79+ const pressProgress = pressedIndex < 0 || reducedMotion80+ ? 081+ : progress(now, pressedAt, PRESS_DURATION);82+ const pressOffset = pressedIndex < 0 ? 0 : Math.sin(Math.PI * pressProgress) * 5;83+ return {84+ opening,85+ exiting,86+ done: phase === 'exiting' ? exitProgress >= 1 : openProgress >= 1,87+ openProgress,88+ exitProgress,89+ shutterProgress: exiting ? 1 - easeInCubic(exitProgress) : easeOutBack(openProgress),90+ titleProgress,91+ itemProgress,92+ selectedPosition,93+ pawHop,94+ pressProgress,95+ pressOffset,96+ active: phase !== 'idle' || (now - selectedAt < SLIDE_DURATION) || (pressedIndex >= 0 && now - pressedAt < PRESS_DURATION),97+ };98+}99+100+export const pauseMotionTimings = Object.freeze({101+ open: OPEN_DURATION,102+ exit: EXIT_DURATION,103+ slide: SLIDE_DURATION,104+ press: PRESS_DURATION,105+ drop: DROP_DURATION,106+ stagger: DROP_STAGGER,107+});108+109+export const pauseMotionLayout = Object.freeze({ pitch: ITEM_PITCH, height: ITEM_HEIGHT });@@ -1,116 +1,3 @@
1-import { drawBoard } from "./board.js";2-import { drawIndex } from "./index.js";3-import {4- drawInlineActions,5- drawKeycap,6- drawSentenceTiles,7- drawStackedAction,8- drawTouchControls,9-} from "./stage-hud.js";10-11-export function renderPauseScene(ctx, view = {}) {12- const { width: w, height: h } = view;13- const {14- boardState,15- boardGame,16- boardPalette = {},17- boardGlyphs = {},18- boardFrame,19- pausedFrom,20- indexLayout,21- indexNavigator,22- indexFacing,23- indexPopNodeId,24- indexPopStart,25- indexLockedNode,26- title,27- pauseItems = [],28- selected = 0,29- reducedMotion = false,30- } = view;31- if (pausedFrom === "index" && indexLayout && indexNavigator) {32- view.onIndexGeometry?.(33- drawIndex(ctx, indexLayout, indexNavigator, {34- availableWidth: w,35- availableHeight: h,36- cat: boardGlyphs.MIAU || "🐈",37- catFacing: indexFacing,38- popNodeId: indexPopNodeId,39- popProgress: indexPopStart40- ? Math.min(1, (view.now - indexPopStart) / 520)41- : 1,42- reducedMotion,43- hideBanner: true,44- hideFooter: true,45- lockedNode: indexLockedNode,46- }),47- );48- } else if (pausedFrom === "title" && title) {49- title.draw(ctx, { width: w, height: h, now: view.now, reducedMotion });50- } else if (boardState) {51- const cell = Math.min(52- 96,53- Math.max(54- 22,55- Math.floor(56- Math.min((w - 80) / boardState.width, (h - 320) / boardState.height),57- ),58- ),59- );60- drawBoard(ctx, boardState, {61- x: Math.floor((w - boardState.width * cell) / 2),62- y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)),63- cell,64- game: boardGame,65- palette: boardPalette,66- glyphs: boardGlyphs,67- frame: boardFrame,68- });69- }70- if (pausedFrom === "index") {71- ctx.fillStyle = "#0F0E0D";72- ctx.fillRect(0, 0, w, Math.max(96, h * 0.16));73- ctx.fillRect(0, h - Math.max(72, h * 0.12), w, Math.max(72, h * 0.12));74- }75- ctx.fillStyle = "rgba(0,0,0,.74)";76- ctx.fillRect(0, 0, w, h);77- ctx.textAlign = "center";78- ctx.textBaseline = "middle";79- ctx.fillStyle = "#FF4B3E";80- ctx.font = "700 13px Recursive, monospace";81- ctx.fillText("PAUSED", w / 2, Math.max(75, h * 0.12));82- pauseItems.forEach((item, i) => {83- const y = h * 0.24 + i * 54;84- ctx.fillStyle = "#000000";85- ctx.fillRect(w / 2 - 154, y - 21, 320, 54);86- ctx.fillStyle = i === selected ? "#FF4B3E" : "#232120";87- ctx.fillRect(w / 2 - 160, y - 27, 320, 54);88- ctx.fillStyle = "#0F0E0D";89- ctx.font = "800 20px 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif";90- ctx.textAlign = "right";91- ctx.fillText(i === selected ? "🐾" : "", w / 2 - 176, y + 1);92- ctx.fillStyle = i === selected ? "#0F0E0D" : "#F2EEE6";93- ctx.font = "800 13px Recursive, monospace";94- ctx.textAlign = "center";95- ctx.fillText(item.label, w / 2, y + 1);96- });97- ctx.textAlign = "left";98- ctx.fillStyle = "#8E877C";99- ctx.font = "500 11px Recursive, monospace";100- if (pausedFrom !== "index" && pausedFrom !== "title")101- ctx.fillText(102- String(boardState?.hint || "Choose an action").trim(),103- 32,104- h - 86,105- );106- const coarsePointer = view.coarsePointer ?? false;107- if (w > 760 && !coarsePointer) {108- drawKeycap(ctx, "↑↓", "choose", 32, h - 46);109- drawKeycap(ctx, "ENTER", "select", w / 2 - 4, h - 46, "right");110- drawKeycap(ctx, "ESC", "back", w - 32, h - 46, "right");111- } else drawTouchControls(ctx, w, h, coarsePointer);112-}113-1141 export function renderWinScene(ctx, view = {}) {1152 const { width: w, height: h } = view;1163 const {@@ -0,0 +1,87 @@
1+import { drawBoard } from "./board.js";2+import { drawIndex } from "./index.js";3+import { drawKeycap, drawTouchControls } from "./stage-hud.js";4+import { pauseMotionFrame, pauseMotionLayout } from "./menu-motion.js";5+6+export function renderPauseScene(ctx, view = {}) {7+ const { width: w, height: h } = view;8+ const {9+ boardState, boardGame, boardPalette = {}, boardGlyphs = {}, boardFrame,10+ pausedFrom, indexLayout, indexNavigator, indexFacing, indexPopNodeId,11+ indexPopStart, indexLockedNode, title, pauseItems = [], selected = 0,12+ reducedMotion = false, pauseMotion,13+ } = view;14+ const motion = pauseMotionFrame({ ...(pauseMotion || {}), now: view.now,15+ itemCount: pauseItems.length, selected, reducedMotion });16+ if (pausedFrom === "index" && indexLayout && indexNavigator) {17+ view.onIndexGeometry?.(drawIndex(ctx, indexLayout, indexNavigator, {18+ availableWidth: w, availableHeight: h, cat: boardGlyphs.MIAU || "🐈",19+ catFacing: indexFacing, popNodeId: indexPopNodeId,20+ popProgress: indexPopStart ? Math.min(1, (view.now - indexPopStart) / 520) : 1,21+ reducedMotion, hideBanner: true, hideFooter: true, lockedNode: indexLockedNode,22+ }));23+ } else if (pausedFrom === "title" && title) {24+ title.draw(ctx, { width: w, height: h, now: view.now, reducedMotion });25+ } else if (boardState) {26+ const cell = Math.min(96, Math.max(22, Math.floor(Math.min(27+ (w - 80) / boardState.width, (h - 320) / boardState.height,28+ ))));29+ drawBoard(ctx, boardState, { x: Math.floor((w - boardState.width * cell) / 2),30+ y: Math.max(118, Math.floor((h - boardState.height * cell) / 2 - 30)), cell,31+ game: boardGame, palette: boardPalette, glyphs: boardGlyphs, frame: boardFrame });32+ }33+ if (pausedFrom === "index") {34+ ctx.fillStyle = "#0F0E0D";35+ ctx.fillRect(0, 0, w, Math.max(96, h * 0.16));36+ ctx.fillRect(0, h - Math.max(72, h * 0.12), w, Math.max(72, h * 0.12));37+ }38+ ctx.fillStyle = `rgba(0,0,0,${0.74 * motion.openProgress})`;39+ ctx.fillRect(0, 0, w, h);40+ const shutterHeight = h * 0.62 * motion.shutterProgress;41+ ctx.fillStyle = "#0F0E0D";42+ ctx.fillRect(0, (h - shutterHeight) / 2, w, shutterHeight);43+ ctx.strokeStyle = "#F2EEE6";44+ ctx.lineWidth = 1;45+ ctx.beginPath();46+ ctx.moveTo(0, (h - shutterHeight) / 2); ctx.lineTo(w, (h - shutterHeight) / 2);47+ ctx.moveTo(0, (h + shutterHeight) / 2); ctx.lineTo(w, (h + shutterHeight) / 2);48+ ctx.stroke();49+ ctx.textAlign = "center"; ctx.textBaseline = "middle";50+ ctx.fillStyle = "#FF4B3E"; ctx.font = "700 13px Recursive, monospace";51+ const pauseTitle = "PAUSED".slice(0, Math.ceil(6 * motion.titleProgress));52+ ctx.globalAlpha = motion.titleProgress;53+ ctx.fillText(pauseTitle, w / 2, Math.max(75, h * 0.12));54+ ctx.globalAlpha = 1;55+ const itemTop = h * 0.24;56+ const selectedY = itemTop + motion.selectedPosition * pauseMotionLayout.pitch;57+ if (pauseItems.length && motion.openProgress > 0) {58+ ctx.fillStyle = "#FF4B3E";59+ ctx.fillRect(w / 2 - 160, selectedY - 27, 320, 54);60+ }61+ pauseItems.forEach((item, i) => {62+ const itemMotion = motion.itemProgress[i] ?? 1;63+ const y = itemTop + i * pauseMotionLayout.pitch - (1 - itemMotion) * 22;64+ ctx.globalAlpha = itemMotion;65+ ctx.fillStyle = "#000000"; ctx.fillRect(w / 2 - 154, y - 21, 320, 54);66+ ctx.fillStyle = i === selected ? "rgba(255,75,62,0)" : "#232120";67+ ctx.fillRect(w / 2 - 160, y - 27, 320, 54);68+ ctx.fillStyle = "#0F0E0D";69+ ctx.font = "800 20px 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif";70+ ctx.textAlign = "right";71+ ctx.fillText(i === Math.round(motion.selectedPosition) ? "🐾" : "", w / 2 - 176,72+ y + 1 - (i === Math.round(motion.selectedPosition) ? motion.pawHop : 0));73+ ctx.fillStyle = i === selected ? "#0F0E0D" : "#F2EEE6";74+ ctx.font = "800 13px Recursive, monospace"; ctx.textAlign = "center";75+ ctx.fillText(item.label, w / 2, y + 1 + (i === selected ? motion.pressOffset : 0));76+ ctx.globalAlpha = 1;77+ });78+ ctx.textAlign = "left"; ctx.fillStyle = "#8E877C"; ctx.font = "500 11px Recursive, monospace";79+ if (pausedFrom !== "index" && pausedFrom !== "title")80+ ctx.fillText(String(boardState?.hint || "Choose an action").trim(), 32, h - 86);81+ const coarsePointer = view.coarsePointer ?? false;82+ if (w > 760 && !coarsePointer) {83+ drawKeycap(ctx, "↑↓", "choose", 32, h - 46);84+ drawKeycap(ctx, "ENTER", "select", w / 2 - 4, h - 46, "right");85+ drawKeycap(ctx, "ESC", "back", w - 32, h - 46, "right");86+ } else drawTouchControls(ctx, w, h, coarsePointer);87+}@@ -6,7 +6,9 @@ import { winScene } from "./scenes/win.js";
66 import { drawIndex } from "./render/index.js";77 import { renderProofScene } from "./render/stage-proof.js";88 import { nativeHorizontalFacing } from './render/facing.js';9-import { renderPauseScene, renderWinScene } from './render/stage-overlays.js';9+import { renderWinScene } from './render/stage-overlays.js';10+import { renderPauseScene } from './render/stage-pause.js';11+import { pauseMotionTimings, pauseMotionLayout } from './render/menu-motion.js';1012 import { overlayInputForKey, pauseMenuItems } from "./stage/dispatch.js";1113 1214 const SCENES = {@@ -97,6 +99,15 @@ export function createStage(options = {}) {
9799 let workshopOrigin = "pause";98100 let giftOrigin = "title";99101 let selected = 0;102+ let previousSelected = 0;103+ let selectedAt = 0;104+ let pressedIndex = -1;105+ let pressedAt = 0;106+ let pauseMotionPhase = 'idle';107+ let pauseOpenedAt = 0;108+ let pauseExitStartedAt = 0;109+ let pauseExitTimer = null;110+ let pauseMotionGeneration = 0;100111 let pauseStatus = { soundOn: true, musicOn: false };101112 const pauseItems = pauseMenuItems("proof", pauseStatus);102113 let transitionStart = 0;@@ -217,9 +228,15 @@ export function createStage(options = {}) {
217228 const stepActive = stepPopStart && now - stepPopStart < 240;218229 const indexActive =219230 scene === "index" && indexPopStart && now - indexPopStart < 520;231+ if (scene === "pause" && pauseMotionPhase === "opening" && now - pauseOpenedAt >= pauseMotionTimings.open)232+ pauseMotionPhase = "idle";233+ const pauseActive =234+ scene === "pause" && (pauseMotionPhase !== "idle" ||235+ (pressedIndex >= 0 && now - pressedAt < pauseMotionTimings.press) ||236+ now - selectedAt < pauseMotionTimings.slide);220237 if (221238 !reducedMotion &&222- (winActive || titleActive || stepActive || indexActive)239+ (winActive || titleActive || stepActive || indexActive || pauseActive)223240 )224241 requestAnimationFrame(animateVisuals);225242 }@@ -294,6 +311,15 @@ export function createStage(options = {}) {
294311 boardPalette, boardGlyphs, boardFrame, pausedFrom, indexLayout,295312 indexNavigator, indexFacing, indexPopNodeId, indexPopStart,296313 indexLockedNode, title, pauseItems, selected, reducedMotion,314+ pauseMotion: {315+ phase: pauseMotionPhase,316+ openedAt: pauseOpenedAt,317+ exitStartedAt: pauseExitStartedAt,318+ previousSelected,319+ selectedAt,320+ pressedIndex,321+ pressedAt,322+ },297323 coarsePointer: globalThis.matchMedia?.("(pointer: coarse)").matches ?? false,298324 onIndexGeometry: (geometry) => { indexGeometry = geometry; },299325 });@@ -344,9 +370,20 @@ export function createStage(options = {}) {
344370 if (id === "pause" && scene !== "pause") {345371 pausedFrom = scene;346372 selected = 0;373+ previousSelected = 0;374+ selectedAt = performance.now();375+ pressedIndex = -1;376+ pressedAt = 0;377+ pauseMotionPhase = "opening";378+ pauseOpenedAt = performance.now();379+ pauseExitStartedAt = 0;380+ pauseMotionGeneration += 1;381+ if (pauseExitTimer) clearTimeout(pauseExitTimer);382+ pauseExitTimer = null;347383 pauseItems.splice(0, pauseItems.length, ...pauseMenuItems(pausedFrom, pauseStatus));348384 }349385 scene = id;386+ if (id !== "pause") pauseMotionPhase = "idle";350387 const spec = SCENES[id] || proofScene;351388 eyebrow.textContent = spec.eyebrow;352389 titleEl.textContent = (spec.title || "").replace(@@ -362,13 +399,17 @@ export function createStage(options = {}) {
362399 : id === "workshop" && workshop?.accessibilityText ? workshop.accessibilityText()363400 : `${spec.eyebrow || ""} ${titleEl.textContent}`.trim());364401 render();365- if (!reducedMotion && (id === "title" || id === "win" || id === "index"))402+ if (!reducedMotion && (id === "title" || id === "win" || id === "index" || id === "pause"))366403 requestAnimationFrame(animateVisuals);367404 transitionStart = performance.now();368405 const generation = ++transitionGeneration;369406 requestAnimationFrame((time) => drawTransition(time, generation));370407 }371408 function hide() {409+ if (scene === "pause") {410+ beginPauseExit("resume");411+ return api;412+ }372413 transitionStart = 0;373414 transitionGeneration += 1;374415 root.hidden = true;@@ -419,33 +460,72 @@ export function createStage(options = {}) {
419460 return api;420461 }421462 function setSelected(index) {422- selected = Math.max(0, index);463+ const next = Math.max(0, Math.min(pauseItems.length - 1, index));464+ if (next !== selected) {465+ previousSelected = selected;466+ selected = next;467+ selectedAt = performance.now();468+ }423469 root.dataset.selected = String(selected);470+ render();424471 }425- function activatePauseSelection() {426- const action = pauseItems[selected]?.id;427- if (action === "resume") hide();472+ function runPauseAction(action) {473+ pauseMotionPhase = "idle";474+ pressedIndex = -1;475+ if (action === "resume") hideImmediately();428476 else if (action === "restart") options.onPauseAction?.("restart");429477 else if (action === "index") show("index");430478 else if (action === "workshop") show("workshop");431479 else if (action === "title") show("title");432- else if (action === "sound") options.onPauseAction?.("sound");433- else if (action === "music") options.onPauseAction?.("music");480+ }481+ function hideImmediately() {482+ transitionStart = 0;483+ transitionGeneration += 1;484+ root.hidden = true;485+ scene = pausedFrom;486+ root.dataset.scene = scene;487+ render();488+ }489+ function beginPauseExit(action) {490+ if (pauseMotionPhase === "exiting") return true;491+ pauseMotionPhase = "exiting";492+ pauseExitStartedAt = performance.now();493+ const generation = ++pauseMotionGeneration;494+ if (reducedMotion) {495+ runPauseAction(action);496+ return true;497+ }498+ pauseExitTimer = setTimeout(() => {499+ if (generation !== pauseMotionGeneration || scene !== "pause") return;500+ pauseExitTimer = null;501+ runPauseAction(action);502+ }, pauseMotionTimings.exit);503+ requestAnimationFrame(animateVisuals);504+ return true;505+ }506+ function activatePauseSelection() {507+ const action = pauseItems[selected]?.id;508+ if (action === "sound" || action === "music") {509+ pressedIndex = selected;510+ pressedAt = performance.now();511+ options.onPauseAction?.(action);512+ render();513+ requestAnimationFrame(animateVisuals);514+ } else if (action) beginPauseExit(action);434515 return Boolean(action);435516 }436517 function dispatchOverlayIntent(intent = {}) {437518 const type = intent.type || intent;438519 if (scene === "pause") {520+ if (pauseMotionPhase === "exiting") return true;439521 if (type === "move") {440- if (intent.dir === "up")441- selected = (selected + pauseItems.length - 1) % pauseItems.length;442- if (intent.dir === "down") selected = (selected + 1) % pauseItems.length;443- render();522+ if (intent.dir === "up") setSelected((selected + pauseItems.length - 1) % pauseItems.length);523+ if (intent.dir === "down") setSelected((selected + 1) % pauseItems.length);444524 return true;445525 }446526 if (type === "confirm") return activatePauseSelection();447527 if (type === "menu") {448- hide();528+ beginPauseExit("resume");449529 return true;450530 }451531 return false;@@ -515,7 +595,7 @@ export function createStage(options = {}) {
515595 ].includes(event.key)516596 )517597 event.stopPropagation();518- if (event.key === "Escape") {598+ if (event.key === "Escape" || (scene === "pause" && event.key.toLowerCase() === "z")) {519599 dispatchOverlayIntent({ type: "menu" });520600 event.preventDefault();521601 return;@@ -686,12 +766,15 @@ export function createStage(options = {}) {
686766 }687767 if (688768 scene === "pause" &&769+ pauseMotionPhase !== "exiting" &&689770 x >= innerWidth / 2 - 160 &&690771 x <= innerWidth / 2 + 160691772 ) {692- const index = Math.round((y - innerHeight * 0.24) / 54);693- if (index >= 0 && index < pauseItems.length) {694- selected = index;773+ const relativeY = y - innerHeight * 0.24;774+ const index = Math.floor(relativeY / pauseMotionLayout.pitch);775+ const withinTile = relativeY - index * pauseMotionLayout.pitch;776+ if (index >= 0 && index < pauseItems.length && withinTile >= 0 && withinTile <= pauseMotionLayout.height) {777+ setSelected(index);695778 activatePauseSelection();696779 }697780 }@@ -0,0 +1,37 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import { pauseMotionFrame, pauseMotionTimings } from "../src/render/menu-motion.js";4+5+test("pause menu drops items on a 35ms stagger and settles at the end", () => {6+ const start = 1000;7+ const early = pauseMotionFrame({ now: start + 180, openedAt: start, phase: "opening", itemCount: 3 });8+ assert.ok(early.itemProgress[0] > early.itemProgress[1]);9+ assert.ok(early.itemProgress[1] > early.itemProgress[2]);10+ const settled = pauseMotionFrame({ now: start + pauseMotionTimings.open, openedAt: start, phase: "idle", itemCount: 3 });11+ assert.deepEqual(settled.itemProgress, [1, 1, 1]);12+ assert.equal(settled.shutterProgress, 1);13+});14+15+test("pause navigation slides the highlight and hops the paw", () => {16+ const frame = pauseMotionFrame({17+ now: 1055, openedAt: 0, phase: "idle", itemCount: 4,18+ previousSelected: 0, selected: 1, selectedAt: 1000,19+ });20+ assert.ok(frame.selectedPosition > 0 && frame.selectedPosition < 1.1);21+ assert.ok(frame.pawHop > 0);22+});23+24+test("pause exit reverses item order and locks input choreography", () => {25+ const frame = pauseMotionFrame({26+ now: 1100, openedAt: 0, phase: "exiting", exitStartedAt: 1000,27+ itemCount: 3,28+ });29+ assert.ok(frame.itemProgress[2] < frame.itemProgress[0]);30+ assert.equal(frame.exiting, true);31+ assert.equal(frame.done, false);32+ const reduced = pauseMotionFrame({33+ now: 1000, phase: "exiting", exitStartedAt: 0, itemCount: 3, reducedMotion: true,34+ });35+ assert.equal(reduced.done, true);36+ assert.deepEqual(reduced.itemProgress, [1, 1, 1]);37+});