From 41adee86300c757a57389722aa97b4e7355bb957 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 00:59:04 +0000 Subject: [PATCH 1/2] DEF-29: a video export bakes its beacons whatever the author's reduced motion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under prefers-reduced-motion the pulse, ripple and glow beacons were held still in every host, so an author with the setting on exported videos without them; a video export now draws them as authored, while the editor and the live player keep honouring the setting (§20 Q7b). Verify: 85 test files · 1,192 tests · shell 0 · build:check 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013dV8F9uaxT1v6tu3JFqTkf --- pm_skills/project/file-map.md | 2 +- ...action-and-auditability-plan-2026-09-22.md | 2 +- src/main.js | 3 + src/services/BeaconRenderer.js | 7 ++- src/services/RenderingService.js | 10 ++- tests/goldenDrawLogs.test.js | 63 +++++++++++++++++++ 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/pm_skills/project/file-map.md b/pm_skills/project/file-map.md index 6704205..128f791 100644 --- a/pm_skills/project/file-map.md +++ b/pm_skills/project/file-map.md @@ -329,7 +329,7 @@ - `tests/example.test.js` — Unit tests (Waypoint, AnimationState, Path, EventBus, etc.) - `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) +- `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 video export draws its beacons whatever the author's reduced-motion setting, which the editor and the player still honour (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 diff --git a/reviews/codebase-abstraction-and-auditability-plan-2026-09-22.md b/reviews/codebase-abstraction-and-auditability-plan-2026-09-22.md index 058e338..562bced 100644 --- a/reviews/codebase-abstraction-and-auditability-plan-2026-09-22.md +++ b/reviews/codebase-abstraction-and-auditability-plan-2026-09-22.md @@ -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. | 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 | 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 | No spurious screen-reader speech on load | Low | S | TST-01 | **P3** | diff --git a/src/main.js b/src/main.js index eeca1fd..fae8802 100644 --- a/src/main.js +++ b/src/main.js @@ -938,6 +938,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(), diff --git a/src/services/BeaconRenderer.js b/src/services/BeaconRenderer.js index b01142e..32c2671 100644 --- a/src/services/BeaconRenderer.js +++ b/src/services/BeaconRenderer.js @@ -1071,8 +1071,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; const { waypointVisibility } = motionSettings || {}; @@ -1107,7 +1110,7 @@ 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 diff --git a/src/services/RenderingService.js b/src/services/RenderingService.js index c370175..27539c2 100644 --- a/src/services/RenderingService.js +++ b/src/services/RenderingService.js @@ -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 }); }, }, { @@ -2002,8 +2003,10 @@ 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 @@ -2011,7 +2014,8 @@ export class RenderingService { // 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(); diff --git a/tests/goldenDrawLogs.test.js b/tests/goldenDrawLogs.test.js index e784103..32d18bb 100644 --- a/tests/goldenDrawLogs.test.js +++ b/tests/goldenDrawLogs.test.js @@ -48,6 +48,7 @@ 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'; const goldenDir = join(dirname(fileURLToPath(import.meta.url)), 'goldens'); const UPDATING = process.env.UPDATE_DRAW_GOLDENS === '1'; @@ -398,6 +399,68 @@ 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']]); + + function stilledFixture() { + const fixture = fixtures().find(each => each.id === 'uon-open-day'); + for (const waypoint of fixture.project.waypoints) { + if (STILLED.has(waypoint.id)) waypoint.beaconStyle = STILLED.get(waypoint.id); + } + return fixture; + } + + /** + * A quarter-second after each beacon'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. + */ + function withAndWithoutReducedMotion(host) { + const engine = host.animationEngine; + const offset = (engine.startHandleTime || 0) + (engine.introTime || 0); + const schedules = engine.beaconSchedules.filter(schedule => STILLED.has(schedule.waypointId)); + expect(schedules.map(schedule => schedule.style).sort()).toEqual(['glow', 'pulse', 'ripple']); + const setting = BeaconRenderer.prefersReducedMotion; + try { + return schedules.map(schedule => { + const progress = (schedule.arrivalMs + 250 + offset) / engine.state.duration; + BeaconRenderer.prefersReducedMotion = true; + host.renderingService.resetBeacons(); + const reduced = frameAt(host, progress); + BeaconRenderer.prefersReducedMotion = false; + const moving = frameAt(host, progress); + return { style: schedule.style, reduced, moving }; + }); + } finally { + BeaconRenderer.prefersReducedMotion = setting; + } + } + + test('the export canvas draws its beacons as authored', async () => { + const { app } = await exportAndPlayer(stilledFixture()); + for (const { style, reduced, moving } of withAndWithoutReducedMotion(app)) { + expect(differingLines(reduced, moving), `export: ${style}`).toEqual([]); + } + }); + + test('the editor and the exported player still hold them still', async () => { + const app = await appWithFixture(stilledFixture()); + enterMode(app, 'preview'); + const { player } = await exportAndPlayer(stilledFixture()); + for (const [label, host] of [['preview', app], ['player', player]]) { + for (const { style, reduced, moving } of withAndWithoutReducedMotion(host)) { + expect(differingLines(reduced, moving).length, `${label}: ${style}`).toBeGreaterThan(0); + } + } + }); + + }); + 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 From 509fa43952cac404f1794ca2e197dc0347f32733 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 02:37:34 +0000 Subject: [PATCH 2/2] DEF-29: a reduced-motion editor holds its beacons after an export too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review found export frames, now syncing every beacon, left their last ring or glow drawn and frozen in a reduced-motion editor once the export ended; the hold now resets a held beacon rather than only rescaling it, which also ends an older freeze when the setting is switched on mid-effect. The tests give each style its own fixture, draw the pulse while playing, and check the editor after a finished, cancelled and failed export. Verify: 85 test files · 1,199 tests · shell 0 · build:check 0 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013dV8F9uaxT1v6tu3JFqTkf --- pm_skills/project/file-map.md | 2 +- ...action-and-auditability-plan-2026-09-22.md | 2 +- src/services/BeaconRenderer.js | 4 +- tests/goldenDrawLogs.test.js | 110 +++++++++++++----- 4 files changed, 85 insertions(+), 33 deletions(-) diff --git a/pm_skills/project/file-map.md b/pm_skills/project/file-map.md index 128f791..106c116 100644 --- a/pm_skills/project/file-map.md +++ b/pm_skills/project/file-map.md @@ -329,7 +329,7 @@ - `tests/example.test.js` — Unit tests (Waypoint, AnimationState, Path, EventBus, etc.) - `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 video export draws its beacons whatever the author's reduced-motion setting, which the editor and the player still honour (DEF-29) +- `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 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 diff --git a/reviews/codebase-abstraction-and-auditability-plan-2026-09-22.md b/reviews/codebase-abstraction-and-auditability-plan-2026-09-22.md index 562bced..3ed0c81 100644 --- a/reviews/codebase-abstraction-and-auditability-plan-2026-09-22.md +++ b/reviews/codebase-abstraction-and-auditability-plan-2026-09-22.md @@ -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). **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. | 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 | 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 | No spurious screen-reader speech on load | Low | S | TST-01 | **P3** | diff --git a/src/services/BeaconRenderer.js b/src/services/BeaconRenderer.js index 32c2671..0587077 100644 --- a/src/services/BeaconRenderer.js +++ b/src/services/BeaconRenderer.js @@ -1113,7 +1113,9 @@ export class BeaconRenderer { 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; } } diff --git a/tests/goldenDrawLogs.test.js b/tests/goldenDrawLogs.test.js index 32d18bb..ef97645 100644 --- a/tests/goldenDrawLogs.test.js +++ b/tests/goldenDrawLogs.test.js @@ -42,6 +42,7 @@ 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'; @@ -49,6 +50,7 @@ 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'; @@ -407,58 +409,106 @@ describe('golden draw logs (TST-02)', () => { // 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']]); - function stilledFixture() { + /** 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)) waypoint.beaconStyle = STILLED.get(waypoint.id); + if (!STILLED.has(waypoint.id)) continue; + waypoint.beaconStyle = id === null || waypoint.id === id ? STILLED.get(waypoint.id) : 'none'; } return fixture; } /** - * A quarter-second after each beacon'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 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) { + function withAndWithoutReducedMotion(host, id) { const engine = host.animationEngine; const offset = (engine.startHandleTime || 0) + (engine.introTime || 0); - const schedules = engine.beaconSchedules.filter(schedule => STILLED.has(schedule.waypointId)); - expect(schedules.map(schedule => schedule.style).sort()).toEqual(['glow', 'pulse', 'ripple']); + 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 { - return schedules.map(schedule => { - const progress = (schedule.arrivalMs + 250 + offset) / engine.state.duration; - BeaconRenderer.prefersReducedMotion = true; - host.renderingService.resetBeacons(); - const reduced = frameAt(host, progress); - BeaconRenderer.prefersReducedMotion = false; - const moving = frameAt(host, progress); - return { style: schedule.style, reduced, moving }; - }); + BeaconRenderer.prefersReducedMotion = true; + host.renderingService.resetBeacons(); + const reduced = drawAt(); + BeaconRenderer.prefersReducedMotion = false; + return { reduced, moving: drawAt() }; } finally { BeaconRenderer.prefersReducedMotion = setting; } } - test('the export canvas draws its beacons as authored', async () => { - const { app } = await exportAndPlayer(stilledFixture()); - for (const { style, reduced, moving } of withAndWithoutReducedMotion(app)) { + 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 them still', async () => { - const app = await appWithFixture(stilledFixture()); - enterMode(app, 'preview'); - const { player } = await exportAndPlayer(stilledFixture()); - for (const [label, host] of [['preview', app], ['player', player]]) { - for (const { style, reduced, moving } of withAndWithoutReducedMotion(host)) { + 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', () => {