Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pm_skills/project/file-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@
- `tests/example.test.js` — Unit tests (Waypoint, AnimationState, Path, EventBus, etc.); (DEF-30) `once` runs once when its callback emits the same event or two async emits hold it, and `off` with its callback removes it
- `tests/exampleProjects.test.js` — DEMO-01 living-fixture contract: every
- `tests/exportMinimisation.test.js` — DEF-23: a saved project and an HTML export carry only the images the project uses, and only the project file keeps their filenames
- `tests/goldenDrawLogs.test.js` — TST-02 draw-log goldens: 3 examples plus `authoredExtras` × 5 instants × editor/preview/export, plus play == seek, and app == player for every fixture, at Graphics scales 0.5, 1.6 and 8 with labels (DEF-34), and after an anchored crowd's waypoint moves (DEF-02); the camera fixture's eased first frame is held to its scale-1 translations; every frame composites the vector layer by name, at its display size even at pixel density 2 (TST-17); and a frame that throws at any restore of the vector layer, in the editor, on the export canvas and in the player, leaves the next frame as a freshly sized layer draws it, state included (DEF-36); and a pop, grow or pulse beacon scales its marker alike whether the transport runs or not, in all four hosts, while a marker set to always hide stays hidden (DEF-08)
- `tests/goldenDrawLogs.test.js` — TST-02 draw-log goldens: 3 examples plus `authoredExtras` × 5 instants × editor/preview/export, plus play == seek, and app == player for every fixture, at Graphics scales 0.5, 1.6 and 8 with labels (DEF-34), and after an anchored crowd's waypoint moves (DEF-02); the camera fixture's eased first frame is held to its scale-1 translations; every frame composites the vector layer by name, at its display size even at pixel density 2 (TST-17); a frame that throws at any restore of the vector layer, in the editor, on the export canvas and in the player, leaves the next frame as a freshly sized layer draws it, state included (DEF-36); a pop, grow or pulse beacon scales its marker alike whether the transport runs or not, in all four hosts, while a marker set to always hide stays hidden (DEF-08); and a video export draws its beacons whatever the author's reduced-motion setting, which the editor and the player still honour, including after an export (DEF-29)
- `tests/goldenFrames.test.js` — Scrub-vs-play golden harness: sequential/reverse/export-step == direct seek (full scene state incl. beacons); evaluation never mutates the timeline
- `tests/governance.test.js` — MIT metadata, exact dependency notices and approved security/support route contracts
- `tests/graphRouting.test.js` — Directed graph choices, backtrack avoidance, overflow-safe shares and stable 100-percent rounding
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4501,7 +4501,7 @@ Rows are ordered by priority.
| DEF-24 | SEG-024, SEG-004, SEG-013 | `src/examples/index.js:178, 189`; all three `docs/examples/*.zip`; `persistence.js:365, 477-503`; `MotionVisibilityService.js:399-445` | **All three shipped examples** (built by one drifted `snapshotFrom`) set `waypointVisibility:'show-on-progression'`, which is a *path*-visibility value, plus a non-canonical `pathWidth`. Load validates only the numeric motion fields, so **any string is accepted for all three visibility enums**. The Waypoints select then shows blank, and markers behave as always-show. | [C] unzip (all three); **[L]** `#waypoint-visibility` `selectedIndex=-1`, every marker at opacity 1 regardless of progress | Fix the values (default: `hide-before`, the canonical default); derive the envelope from the canonical defaults; validate the three visibility enums at load (map unknown values to the default); add an enum test. **The owner re-approves the ZIPs** (§20 Q14). | Examples model the real contract; persisted enums are closed | Low | S | CON-05 | **P2** |
| DEF-28 | SEG-004 | `persistence.js:1234-1236` | A failed autosave *restore* goes to the console only, and the next edit overwrites the recovery record | [C] reading | Announce it; keep the record until the user acts | Recovery honesty | Low | S | — | **P2** |
| DEF-27 | SEG-012 | `RenderingService.js:807, 879, 1036-1040, 1086-1095` | Instant Spotlight/AoV ignore camera zoom, and the tint overlay rect ignores background zoom and camera | [C] probe (ctx `scale` calls). **[L]** at 50% background zoom the tint `fillRect` covers the whole canvas (0,0,806×570) while the image is drawn at (202,143,403×285), so the margin is darkened. | Owner confirms the combination is supported (§20 Q17); one `imageRect()` | Consistent framing | Low | S | TST-02 | **P3** |
| DEF-29 | SEG-012, SEG-016 | `BeaconRenderer.js:957-965, 1102-1108` | The author's OS reduced-motion setting suppresses beacons **in baked video exports** | [C] reading | **Decided: the §20 Q7b default, accepted 2026-09-22 — ignore reduced motion when exporting video; keep it for the live player** (noted 2026-09-23). | Deterministic artefacts | Low | S | — | **P3** |
| DEF-29 | SEG-012, SEG-016 | `BeaconRenderer.js:957-965, 1102-1108` | The author's OS reduced-motion setting suppresses beacons **in baked video exports** | [C] reading | **Decided: the §20 Q7b default, accepted 2026-09-22 — ignore reduced motion when exporting video; keep it for the live player** (noted 2026-09-23). **2026-09-26 (DEF-29 PR): done.** The editor's render state says when a frame is a video export (`exportingVideo`), and only then does `BeaconRenderer.update` ignore the setting; the player never sets it. The hold now resets a held beacon rather than only rescaling it: once export frames synced every beacon, the hold left their last state drawn, frozen, in a reduced-motion editor after every export (found by the review). The reset also ends an older freeze, of a beacon mid-effect when the setting is switched on. | Deterministic artefacts | Low | S | — | **P3** |
| DEF-30 | SEG-027, SEG-007 | `perf-harness.js:58-73`; `EventBus.js:74-80` | The perf harness disables autosave *before* its refusal check, and says nothing. `EventBus.once` refires when its callback throws (no production caller). | [C] reading; [C] probe (Claude and Codex) | Reorder and restore; wrap `once` in try/finally. **2026-09-26 (DEF-30 PR): done.** The harness now refuses before it touches anything, so there is nothing to restore; a refused run had also resized the canvas. A run that fails part-way now says autosave stays off until a reload, as a finished one does. `once` unsubscribes before its callback runs, so it runs once when the callback throws, emits the same event or is held by two async emits, and `off` with the original callback removes it (SEG-007's second probe finding). Still open, from before: Clear All after a run deletes the restored recovery point (wish-list). | Tool safety; bus contract | Low | S | TST-10 pins the bus | **P3** |
| DEF-06 | SEG-004, SEG-005 | `projectReset.js:20`; `persistence.js:879, 898`; `wiringBus.js:116-120`; `undoRedo.js:387-391`; `sceneOutline.js:472-473` | Five "≥2 waypoints ?" guards skip `calculatePath`'s own reset, so **branch paths, route structure, the timeline cache and the anchor report survive Clear All** and near-empty loads. The leak is latent: nothing visible reads it today. | [C] harness; **[L]** after Clear All on `uon-open-day`: branchPaths=1 (318 points), `isLinear`=false, stale anchor report. However, **no ghost is drawn** (0 stroke calls) and the time display is correct. | Always call `calculatePath()` | No stale derived state for a future consumer to trip over | Low | S | TST-06 | **P3** (was P1; downgraded after runtime check) |
| DEF-33 | SEG-001, SEG-002 | `main.js:614-621`; `wiringBus.js:379-386`; `index.html:18-20` | The programmatic startup `pause()` runs after the pause listener is registered, so **every cold load announces "Animation paused"** through the polite live region (which sits outside the inert `#app`) | **[L]** `#announcer` sampled every 5 ms through startup: "Animation paused" at 92 ms, cleared at 2,082 ms | Pause before subscribing, or pass a `silent` flag for programmatic pauses; add a startup-announcement test. **2026-09-26 (DEF-33 PR): paused before subscribing.** The pause listener is the only one it skips, and the shell already shows Play and hides Pause. A restored session's "Previous session restored" had also been replaced at once by "Animation paused"; it now stays (Chromium 141). The tests boot the app cold and from a restored session. | No spurious screen-reader speech on load | Low | S | TST-01 | **P3** |
Expand Down
3 changes: 3 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,9 @@ class RoutePlotter {
// Stable visual sizing reference, separate from timingReference.
renderReference: this.renderReference,
interactiveLabels: !this._isExportMode,
// A video export bakes beacons as authored, ignoring the author's
// reduced-motion setting; the editor and the player honour it (DEF-29)
exportingVideo: this._isExportMode,

// Visible bounds for clipping (normalized 0-1 coordinates)
visibleBounds: this.getVisibleBounds(),
Expand Down
11 changes: 8 additions & 3 deletions src/services/BeaconRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -1078,8 +1078,11 @@ export class BeaconRenderer {
* @param {Object} animationEngine - Animation engine instance (schedule source)
* @param {Object} motionSettings - Motion visibility settings
* @param {Array} waypointProgressValues - Unused; kept for call-site stability
* @param {{ignoreReducedMotion?: boolean}} [options] - A video export bakes the beacons whatever the
* author's reduced-motion setting (DEF-29)
*/
update(adjustedTimelineMs, waypoints, animationEngine, motionSettings, waypointProgressValues = null) {
update(adjustedTimelineMs, waypoints, animationEngine, motionSettings, waypointProgressValues = null,
{ ignoreReducedMotion = false } = {}) {
if (!waypoints || !animationEngine) return;
this.updateCount += 1;

Expand Down Expand Up @@ -1115,10 +1118,12 @@ export class BeaconRenderer {
// prefers-reduced-motion. pulse/ripple loop continuously and glow is a
// ~3s radial bloom — all skipped (marker held static). pop/grow are brief
// one-shot reveal transitions and remain.
if (BeaconRenderer.prefersReducedMotion) {
if (BeaconRenderer.prefersReducedMotion && !ignoreReducedMotion) {
const beaconType = waypoint.beaconStyle;
if (beaconType === 'pulse' || beaconType === 'ripple' || beaconType === 'glow') {
beacon.scale = 1.0; // Hold marker at normal scale; skip the animated effect
// Held as if never synced, so nothing an export frame, or a frame
// from before the setting changed, stays drawn (DEF-29)
beacon.reset();
return;
}
}
Expand Down
10 changes: 7 additions & 3 deletions src/services/RenderingService.js
Original file line number Diff line number Diff line change
Expand Up @@ -1240,7 +1240,8 @@ export class RenderingService {
state.motionSettings.waypointVisibility !== WAYPOINT_VISIBILITY.ALWAYS_HIDE;
if (!shouldRenderBeacons) return;
svc.renderBeacons(ctx, state.waypoints, state.animationEngine, state.beaconAnimation, state.imageToCanvas, state.styles,
frame.applyMotion ? state.motionSettings : null, state.waypointProgressValues);
frame.applyMotion ? state.motionSettings : null, state.waypointProgressValues,
{ ignoreReducedMotion: state.exportingVideo === true });
},
},
{
Expand Down Expand Up @@ -2002,16 +2003,19 @@ export class RenderingService {
* @param {Object} styles - Style settings
* @param {Object} motionSettings - Motion visibility settings
* @param {Array} waypointProgressValues - Pre-calculated waypoint progress values
* @param {Object} [options] - Passed to `BeaconRenderer.update`
*/
renderBeacons(ctx, waypoints, animationEngine, beaconAnimation, imageToCanvas, styles, motionSettings = null, waypointProgressValues = null) {
renderBeacons(ctx, waypoints, animationEngine, beaconAnimation, imageToCanvas, styles, motionSettings = null,
waypointProgressValues = null, options = {}) {
if (!waypoints.length || !animationEngine) return;

// Sync beacons to the current timeline instant (pause-marker axis: raw
// time minus start handle and intro). Beacon phases are closed-form in
// timeline time, so play, scrub, and export all see identical beacons.
const adjustedTimelineMs = animationEngine.state.currentTime -
(animationEngine.startHandleTime || 0) - (animationEngine.introTime || 0);
this.beaconRenderer.update(adjustedTimelineMs, waypoints, animationEngine, motionSettings, waypointProgressValues);
this.beaconRenderer.update(adjustedTimelineMs, waypoints, animationEngine, motionSettings,
waypointProgressValues, options);

// Render beacons for each waypoint
const currentProgress = animationEngine.getPathProgress();
Expand Down
112 changes: 112 additions & 0 deletions tests/goldenDrawLogs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, test, expect, vi } from 'vitest';
import { bootApp } from './helpers/bootApp.js';
import { allowConsole } from './helpers/consoleGuard.js';
import { freezeClock, loadSnapshot } from './helpers/projectSnapshot.js';
import { differingLines, discardFrame, frameAt, setUpFrame, takeFrame } from './helpers/drawLog.js';
import { authoredExtrasProject } from './fixtures/authoredExtras.js';
import { buildExampleProjects } from '../src/examples/index.js';
import { loadExampleBackground } from '../src/app/backgroundLoading.js';
import { PlayerApp } from '../src/player/PlayerApp.js';
import { BeaconRenderer } from '../src/services/BeaconRenderer.js';
import { VideoExporter } from '../src/services/VideoExporter.js';

const goldenDir = join(dirname(fileURLToPath(import.meta.url)), 'goldens');
const UPDATING = process.env.UPDATE_DRAW_GOLDENS === '1';
Expand Down Expand Up @@ -399,6 +401,116 @@ describe('golden draw logs (TST-02)', () => {
}
});

describe('DEF-29: a video export ignores the author\'s reduced-motion setting', () => {
// Under prefers-reduced-motion the pulse, ripple and glow beacons are held
// still, which is right for the editor and the live player. A video is
// watched elsewhere, by people whose setting it cannot know, so it bakes
// the beacons as authored (§20 Q7b, accepted 2026-09-22). Open day's
// camera never zooms, so frames at one instant differ only by beacons.
const STILLED = new Map([['ex-uon-1', 'ripple'], ['ex-uon-2', 'pulse'], ['ex-uon-3', 'glow']]);

/** Open day with the stilled styles, or only `id`'s, so a check sees only its own beacon. */
function stilledFixture(id = null) {
const fixture = fixtures().find(each => each.id === 'uon-open-day');
for (const waypoint of fixture.project.waypoints) {
if (!STILLED.has(waypoint.id)) continue;
waypoint.beaconStyle = id === null || waypoint.id === id ? STILLED.get(waypoint.id) : 'none';
}
return fixture;
}

/**
* A quarter-second after `id`'s arrival, the frame drawn with reduced
* motion (from reset beacons, as when the setting is on from the start)
* and the frame drawn without it. A pulse shows only through its marker's
* scale, which a paused frame did not draw before DEF-08, so a pulse's
* frames are drawn with the transport running.
*/
function withAndWithoutReducedMotion(host, id) {
const engine = host.animationEngine;
const offset = (engine.startHandleTime || 0) + (engine.introTime || 0);
const schedule = engine.beaconSchedules.find(each => each.waypointId === id);
expect(schedule?.style).toBe(STILLED.get(id));
const progress = (schedule.arrivalMs + 250 + offset) / engine.state.duration;
const drawAt = () => {
const paused = frameAt(host, progress);
if (schedule.style !== 'pulse') return paused;
engine.play();
discardFrame();
host.render();
const playing = takeFrame(host);
engine.pause();
return playing;
};
const setting = BeaconRenderer.prefersReducedMotion;
try {
BeaconRenderer.prefersReducedMotion = true;
host.renderingService.resetBeacons();
const reduced = drawAt();
BeaconRenderer.prefersReducedMotion = false;
return { reduced, moving: drawAt() };
} finally {
BeaconRenderer.prefersReducedMotion = setting;
}
}

for (const [id, style] of STILLED) {
test(`the export canvas draws a ${style} as authored`, async () => {
const { app } = await exportAndPlayer(stilledFixture(id));
const { reduced, moving } = withAndWithoutReducedMotion(app, id);
expect(differingLines(reduced, moving), `export: ${style}`).toEqual([]);
});

test(`the editor and the exported player still hold a ${style} still`, async () => {
const app = await appWithFixture(stilledFixture(id));
enterMode(app, 'preview');
const { player } = await exportAndPlayer(stilledFixture(id));
for (const [label, host] of [['preview', app], ['player', player]]) {
const { reduced, moving } = withAndWithoutReducedMotion(host, id);
expect(differingLines(reduced, moving).length, `${label}: ${style}`).toBeGreaterThan(0);
}
});
}

// The editor's hold never syncs a beacon, so whatever an export frame
// left in one used to stay drawn, frozen, after the export ended.
for (const [outcome, lastProgress, error] of [
['finished', 1, null], ['cancelled', 0.15, 'Export cancelled'], ['failed', 0.15, 'encoder failed'],
]) {
test(`after a ${outcome} export, a reduced-motion editor draws what it drew before`, async () => {
const download = vi.spyOn(VideoExporter, 'downloadBlob').mockImplementation(() => {});
vi.stubGlobal('alert', vi.fn());
allowConsole(/export/i);
const setting = BeaconRenderer.prefersReducedMotion;
BeaconRenderer.prefersReducedMotion = true;
try {
const app = await appWithFixture(stilledFixture());
app.updateCanvasAspectRatio(); // the geometry leaving export mode restores
enterMode(app, 'preview');
const instants = [0.05, 0.15, 0.8, 1];
const before = instants.map(instant => frameAt(app, instant));
// The real exportVideo(), with only the encoder replaced
app.videoExporter = {
cancel() {},
async export({ renderFrame }) {
for (let step = 0; step <= 60; step += 1) await renderFrame((step / 60) * lastProgress);
if (error) throw new Error(error);
return new Blob(['video']);
},
};
await app.exportVideo();
instants.forEach((instant, index) => {
expect(differingLines(before[index], frameAt(app, instant)), `at ${instant}`).toEqual([]);
});
} finally {
BeaconRenderer.prefersReducedMotion = setting;
download.mockRestore();
vi.unstubAllGlobals();
}
});
}
});

describe('DEF-36: a frame that throws leaves nothing behind', () => {
// A renderer that throws part-way through the vector layer skips the
// `restore` of every save it had open: the camera's or the viewport's
Expand Down
Loading