diff --git a/README.md b/README.md
index 4d42b63..0e232cb 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ Run from this package directory:
```sh
python3 scripts/build.py
-# Open dist/presentation-html-demo-0.1.0.html directly, fully offline.
+# Open dist/presentation-html-demo-0.3.0.html directly, fully offline.
npm ci --ignore-scripts
npx playwright install chromium
npm test
@@ -36,6 +36,14 @@ For architecture decomposition, modular products and inspect/reassemble interact
`assets/mechanism.html` is an original editable SVG example. Build it with `python3 scripts/build-mechanism.py`, then open `dist/mechanism-demo-0.2.0.html`. All four state controls and all three components are covered by `scripts/test-mechanism.mjs`; `npm test` runs both the original deck and this new example.
+## Runnable Anime.js library
+
+- [Element registry](references/registry-animejs-elements.md): SVG draw, motion path and spatial stagger.
+- [Composition registry](references/registry-animejs-compositions.md): dependency flow and assemble/explode/reassemble.
+- `assets/animejs/examples.js`: editable configurations; `gallery.js`: shared renderer and lifecycle; `index.html`: local source preview.
+
+Build with `python3 scripts/build-gallery.py` and open `dist/animejs-gallery-0.4.0.html`. This portable offline gallery includes Play, Replay, Settle and Seek for all five examples. `npm test` runs the deck, motion, mechanism and gallery suites. Registry coverage and source links are validated so new examples cannot silently go unlisted. Existing deck and mechanism artifact versions remain available.
+
## Licenses and scope
Original code/documentation: MIT, RootAtSkic and Hermes Agent. Reveal.js and Anime.js: MIT; Work Sans: SIL OFL 1.1. Notices are preserved alongside vendor bytes and embedded in the output. No copyrighted showcase artwork was scraped into the distributable. Research documents disclose unavailable pages and link-only references. See `references/examples.md` for attribution and reuse decisions.
diff --git a/SKILL.md b/SKILL.md
index ef8eaae..5ec9f5b 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -1,7 +1,7 @@
---
name: presentation-html
description: Build offline HTML decks with deliberate motion.
-version: 0.3.0
+version: 0.4.0
author: RootAtSkic, Hermes Agent
license: MIT
platforms: [linux, macos, windows]
@@ -30,6 +30,15 @@ Build editable, offline-first browser presentations using Reveal.js for navigati
- Read [research](references/research.md), [example catalog](references/examples.md) and [QA contract](references/qa.md) before changing the starter.
- Optional design/review skills may supplement this workflow; channel instructions remain channel-owned.
+## Read on demand: runnable Anime.js registries
+
+Select examples by the relationship they explain, not just their visual effect:
+
+- [Element registry](references/registry-animejs-elements.md): SVG connection drawing, motion-path travel and spatial stagger. Read when choosing a single technique.
+- [Composition registry](references/registry-animejs-compositions.md): dependency flow and assemble/explode/reassemble. Read when explaining a multi-part mechanism.
+
+Editable configurations, renderer and preview live in `assets/animejs/`. Build the single-file offline gallery with `python3 scripts/build-gallery.py` through `terminal`, then open `dist/animejs-gallery-0.4.0.html`. `npm test` exercises every registered example. Keep the registry ID, source, parameter constraints and verification coverage aligned when adding an example; do not load all examples into context unnecessarily. Gallery controls are for inspecting recipes, not mandatory controls for client slides. Wire slide departure/teardown explicitly when reusing a recipe.
+
## Read on demand: choreography and deterministic motion
Before adapting animation recipes, read [choreography](references/choreography.md): motion briefs, named timeline beats, paused ownership, deterministic seeking, SVG drawing/motion paths and spatial stagger. The starter exposes seek/replay/settle for authoring and QA without adding presentation controls. [Catalog patterns](references/catalog-patterns.md) records the three inspected `animejs` skills, source pins, license limits and rejected v3 examples. Tests exercise our original v4 equivalents; upstream snippets are not automatically compatible.
diff --git a/assets/animejs/examples.js b/assets/animejs/examples.js
new file mode 100644
index 0000000..7587ea2
--- /dev/null
+++ b/assets/animejs/examples.js
@@ -0,0 +1,8 @@
+/* Original examples; Anime.js v4.5.0. No third-party demo source. */
+window.animeExamples = [
+ {id:'svg-draw', kind:'element', title:'Draw a connection', description:'A trace reveals a directed connection; labels remain readable.', duration:900, path:'M 55 110 C 140 20 240 200 345 110', start:'Source', end:'Destination'},
+ {id:'motion-path', kind:'element', title:'Follow a route', description:'A packet follows the same coordinate space as its route.', duration:1200, path:'M 55 110 C 140 20 240 200 345 110', start:'Send', end:'Receive'},
+ {id:'spatial-stagger', kind:'element', title:'Propagate from the center', description:'Row-major cells activate outward from the center of a grid.', duration:350, delay:100, columns:5, rows:3},
+ {id:'dependency-flow', kind:'composition', title:'Explain a dependency', description:'Input connects to a check, then the checked result reaches output.', duration:600, labels:['Input','Check','Output']},
+ {id:'assemble-explode', kind:'composition', title:'Separate, inspect, reassemble', description:'Stable parts separate to expose their relationship, then return to one assembly.', duration:700, distance:105, labels:['Interface','Compute','Storage']}
+];
diff --git a/assets/animejs/gallery.css b/assets/animejs/gallery.css
new file mode 100644
index 0000000..1865bcd
--- /dev/null
+++ b/assets/animejs/gallery.css
@@ -0,0 +1 @@
+*{box-sizing:border-box}body{margin:0;background:#101823;color:#eef5ff;font:16px/1.5 system-ui,sans-serif}header,main,footer{max-width:1100px;margin:auto;padding:24px}h1{font-size:2.2rem;margin:0}h2{font-size:1.3rem}header p,article p{color:#bfcede}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,440px),1fr));gap:22px}article{padding:22px;border:1px solid #425065;border-radius:12px;background:#182333}article p{min-height:3em}svg{width:100%;display:block;background:#111c2b;border-radius:8px}svg text{fill:#eef5ff;font-size:14px}.guide{fill:none;stroke:#425065;stroke-width:5}.trace{fill:none;stroke:#69ded2;stroke-width:4}.cell{fill:#2a6d82;stroke:#85e7df;stroke-width:2}.packet{fill:#ffd787;stroke:#101823;stroke-width:2}.controls{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:16px}button{font:inherit;border:1px solid #8da4be;background:#263b52;color:#fff;border-radius:5px;padding:6px 12px;cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #ffd787;outline-offset:3px}label{display:flex;align-items:center;gap:6px}input{width:100px}output{display:block;color:#bfcede;font-size:.85rem;margin-top:8px}footer{color:#bfcede}summary{cursor:pointer}pre{white-space:pre-wrap}@media print{.controls,output{display:none}article{break-inside:avoid}main{display:block}}
\ No newline at end of file
diff --git a/assets/animejs/gallery.js b/assets/animejs/gallery.js
new file mode 100644
index 0000000..61ba517
--- /dev/null
+++ b/assets/animejs/gallery.js
@@ -0,0 +1,61 @@
+/* Each mounted card owns one paused finite timeline and abortable listeners. */
+(() => {
+ const ns='http://www.w3.org/2000/svg';
+ function node(tag,attrs={},text='') { const e=document.createElementNS(ns,tag); for(const [k,v] of Object.entries(attrs))e.setAttribute(k,v); e.textContent=text; return e; }
+ function mount(host,c) {
+ const abort=new AbortController(), signal=abort.signal, media=matchMedia('(prefers-reduced-motion: reduce)');
+ let destroyed=false;
+ host.innerHTML=`
Scenes start complete. Play or replay to inspect their sequence; seek pauses the scene. Reduced motion keeps every scene complete.
+
diff --git a/dist/animejs-gallery-manifest.json b/dist/animejs-gallery-manifest.json
new file mode 100644
index 0000000..f4b7743
--- /dev/null
+++ b/dist/animejs-gallery-manifest.json
@@ -0,0 +1,14 @@
+{
+ "file": "animejs-gallery-0.4.0.html",
+ "sha256": "eda1a94fc4a55642a90a42e70d7b5cbea3b2f2b73b818a0b941b61103a154d75",
+ "anime": "4.5.0",
+ "examples": 5,
+ "inputs": {
+ "assets/animejs/examples.js": "37d68cac1e1f38b1974811f9ba3527cf4a92b1c98509e8353e39f7e88b94512e",
+ "assets/animejs/gallery.css": "7bbf68e24e5d001b636b84faf76d682f1596618d7f6798866e19851d728ed04c",
+ "assets/animejs/gallery.js": "1e9cd1ee35e7584f2082eaddf2cc8def0e480048f9a85cb7578aa182682c0d88",
+ "assets/animejs/index.html": "d6d49f9d116d0a978ff0600dd78ed7ffe076fe5fbac364a73fc14243dd7907bc",
+ "assets/vendor/anime.umd.min.js": "8d5b3a58a1f64023a04a4cedeef135a2263b18da04b35177ac13438a0bad033b",
+ "assets/vendor/anime-LICENSE.md": "3f3e835a9952cfc2a6ca836fb95b3257c981f24fa3ef51249055acb09931897b"
+ }
+}
diff --git a/evidence/animejs-gallery-no-preference.png b/evidence/animejs-gallery-no-preference.png
new file mode 100644
index 0000000..33a29e1
Binary files /dev/null and b/evidence/animejs-gallery-no-preference.png differ
diff --git a/evidence/animejs-gallery-reduce.png b/evidence/animejs-gallery-reduce.png
new file mode 100644
index 0000000..33a29e1
Binary files /dev/null and b/evidence/animejs-gallery-reduce.png differ
diff --git a/evidence/animejs-gallery-test-report.json b/evidence/animejs-gallery-test-report.json
new file mode 100644
index 0000000..22418ef
--- /dev/null
+++ b/evidence/animejs-gallery-test-report.json
@@ -0,0 +1,45 @@
+{
+ "sha256": "eda1a94fc4a55642a90a42e70d7b5cbea3b2f2b73b818a0b941b61103a154d75",
+ "runs": [
+ {
+ "reducedMotion": "no-preference",
+ "examples": [
+ "svg-draw",
+ "motion-path",
+ "spatial-stagger",
+ "dependency-flow",
+ "assemble-explode"
+ ],
+ "deterministicSeek": true,
+ "reducedMotionSeekSuppressed": false,
+ "keyboardControls": true,
+ "replay": true,
+ "finalVisibility": true,
+ "liveReducedMotion": true,
+ "printSettle": true,
+ "cleanupAndRemount": true,
+ "errors": [],
+ "network": []
+ },
+ {
+ "reducedMotion": "reduce",
+ "examples": [
+ "svg-draw",
+ "motion-path",
+ "spatial-stagger",
+ "dependency-flow",
+ "assemble-explode"
+ ],
+ "deterministicSeek": false,
+ "reducedMotionSeekSuppressed": true,
+ "keyboardControls": true,
+ "replay": true,
+ "finalVisibility": true,
+ "liveReducedMotion": true,
+ "printSettle": true,
+ "cleanupAndRemount": true,
+ "errors": [],
+ "network": []
+ }
+ ]
+}
diff --git a/package-lock.json b/package-lock.json
index 9f93ac8..be6d930 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "presentation-html-skill",
- "version": "0.3.0",
+ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "presentation-html-skill",
- "version": "0.3.0",
+ "version": "0.4.0",
"dependencies": {
"@fontsource/work-sans": "5.2.8",
"animejs": "4.5.0",
diff --git a/package.json b/package.json
index 98005ab..a54bcee 100644
--- a/package.json
+++ b/package.json
@@ -1,10 +1,10 @@
{
"name": "presentation-html-skill",
- "version": "0.3.0",
+ "version": "0.4.0",
"private": true,
"scripts": {
"build": "python3 scripts/build.py",
- "test": "npm run build && node scripts/test.mjs && node scripts/test-motion.mjs && python3 scripts/build-mechanism.py && node scripts/test-mechanism.mjs"
+ "test": "npm run build && node scripts/test.mjs && node scripts/test-motion.mjs && python3 scripts/build-mechanism.py && node scripts/test-mechanism.mjs && python3 scripts/build-gallery.py && node scripts/test-gallery.mjs"
},
"devDependencies": {
"@playwright/test": "1.64.1"
diff --git a/references/registry-animejs-compositions.md b/references/registry-animejs-compositions.md
new file mode 100644
index 0000000..ff4e4c3
--- /dev/null
+++ b/references/registry-animejs-compositions.md
@@ -0,0 +1,18 @@
+# Anime.js composition registry
+
+Two bounded multi-part explanations. [Editable configurations](../assets/animejs/examples.js) feed the [shared renderer](../assets/animejs/gallery.js). Existing `assets/mechanism.html` remains independent and unchanged.
+
+| Recipe ID | Use / avoid | Configurable fields and sequence |
+| --- | --- | --- |
+| `dependency-flow` | Explain an input → check → output dependency. Avoid implying verified processing, timing or success from an illustrative sequence. | `duration` per edge, exactly three `labels`, `title`, `description`. Timeline labels `input` and `output` sequence two drawable connections; nodes and labels stay readable. Change renderer geometry for a different topology; adding labels alone does not add nodes. Final state shows both connections. |
+| `assemble-explode` | Preserve part identity while separating and rejoining a conceptual assembly. Avoid decorative exploding dashboards or presenting this as physically accurate CAD. | `duration` per outward/return move, `distance`, exactly three `labels`, `title`, `description`. Three SVG wrappers separate horizontally, hold for 400 ms, then reassemble. Shipped distance is 105; larger values may clip. Seek into the hold to inspect separation. Edit renderer for hold/geometry changes. Final state is assembled. No part-selection controls; use the separate mechanism demo for selection. |
+
+## Run, reuse and verify
+
+Build through `terminal` with `python3 scripts/build-gallery.py`, then open `dist/animejs-gallery-0.4.0.html`. Each card provides Play, Replay, Settle and keyboard-operable seeking. The [source entry](../assets/animejs/index.html) also runs with its sibling files and shared vendor intact.
+
+The [element registry](registry-animejs-elements.md#lifecycle-and-accessibility-contract) owns the shared lifecycle/accessibility contract and [source, provenance and test coverage](registry-animejs-elements.md#provenance-and-verified-coverage). Do not duplicate or weaken that contract for compositions: finite paused ownership, deterministic seeking, readable reduced-motion state, cleanup and offline operation all apply.
+
+Each composition owns one timeline. The assembly's hold is an explicit constant-value tween, not an empty timeline gap: direct backward seeking from the final frame into that hold must reproduce the same separated positions as forward seeking. Keep this regression test when changing timing. Do not layer a second animation owner onto the same property. When embedding in a deck, wire its controller's `settle()` to slide departure and `destroy()` to teardown; gallery lifecycle does not automatically bind to Reveal.
+
+The [browser test](../scripts/test-gallery.mjs) exercises both compositions alongside the three elements in normal and reduced motion, with artifact-hash-bound evidence. Retest changed geometry and labels, including intermediate frames. Cross-browser, screen-reader and exported-PDF acceptance are not established by these Chromium tests.
diff --git a/references/registry-animejs-elements.md b/references/registry-animejs-elements.md
new file mode 100644
index 0000000..ae4b5ac
--- /dev/null
+++ b/references/registry-animejs-elements.md
@@ -0,0 +1,38 @@
+# Anime.js element registry
+
+Bounded primitives for meaningful diagram motion. These are the three element records in [the executable configuration](../assets/animejs/examples.js), not a general-purpose animation catalog.
+
+| Recipe ID | Use / avoid | Configurable fields and geometry |
+| --- | --- | --- |
+| `svg-draw` | Use to reveal a connection in sequence. Avoid presenting drawing speed as measured latency, or tracing decorative outlines with no explanatory purpose. | `duration`, SVG `path`, `start`, `end`, `title`, `description`. Renderer uses `svg.createDrawable` with `draw: ['0 0','0 1']`; muted guide stays visible. Final trace is fully drawn. |
+| `motion-path` | Use when travel communicates direction. Avoid ambient infinite particles or claiming the fixed duration represents real throughput. | `duration`, `path`, `start`, `end`, `title`, `description`. `svg.createMotionPath` moves an origin-centered marker in the route's SVG coordinate system. Final packet stays at the endpoint. |
+| `spatial-stagger` | Use for activation spreading from a spatial origin. Avoid arbitrary stagger on prose lists or grids whose ordering means nothing. | Positive integer `columns`, `rows`, nonnegative `delay`, positive `duration`, `title`, `description`. Row-major cells and `[columns, rows]` grid are created together. Center-origin stagger changes opacity from .25 to 1. Keep dimensions modest and retest spacing; the shipped grid is 5×3. |
+
+For multi-stage examples see [composition registry](registry-animejs-compositions.md).
+
+## Editable source and offline use
+
+- [Example configurations](../assets/animejs/examples.js): titles, descriptions and recipe parameters.
+- [Renderer and lifecycle](../assets/animejs/gallery.js): SVG geometry and Anime v4 timelines; branches select by stable recipe `id`.
+- [Styles](../assets/animejs/gallery.css) and [source entry](../assets/animejs/index.html). Open the source entry with its sibling files intact, or build the portable release:
+
+```sh
+python3 scripts/build-gallery.py
+CHROMIUM_EXECUTABLE=/path/to/chrome node scripts/test-gallery.mjs
+```
+
+The dependency-free Python builder embeds the shared pinned Anime **4.5.0** UMD, CSS, configuration, runtime and Anime license into `dist/animejs-gallery-0.4.0.html`. It emits `dist/animejs-gallery-manifest.json` with artifact and input SHA-256 hashes. No CDN, external fonts, runtime fetches, bundler or Reveal dependency. Tests use the repository's existing Playwright dependency.
+
+For reuse, load vendor → configurations → runtime with a `` present, or extract `mount(host, config)` and the relevant recipe branch into your owner. The published `animeGallery.mount` returns `play()`, `replay()`, `seek(milliseconds)`, `settle()`, `state()`, `destroy()`. Keep recipe IDs unchanged when tuning configuration. `kind` is catalog metadata, not a renderer selector. Timing is in milliseconds; use positive finite durations, valid SVG paths inside the 400×220 viewBox, and short labels. This is an editable reference, not an untrusted-config validator.
+
+## Lifecycle and accessibility contract
+
+Each mount owns one finite, initially paused timeline; scenes start complete. Play resumes a paused seek or restarts a completed scene. Replay starts at zero. Seek pauses and clamps finite input to the timeline; non-finite input maps to zero. Settle pauses at the final frame. Repeated replay does not create instances. Before print, page hide, hidden document and live motion-preference changes settle the scene. When embedding in slides, additionally call `settle()` on slide departure; the standalone gallery has no slide-navigation listener. Destroy settles, aborts owned listeners, reverts the timeline and clears its host; repeated destroy is safe. Destroy before remounting the same host.
+
+Reduced motion forces the readable final state for play, replay and seek. Native buttons support Enter/Space; the labeled range supports keyboard seeking. A polite status output announces playing/paused/complete without frame-by-frame announcements. Essential explanations remain static. Static SVG parts are not fake buttons. Use the source's system font and high-contrast palette or retest your replacement.
+
+## Provenance and verified coverage
+
+All five recipes and gallery code are original examples written for this package, not copied from showcase or peer source. Anime.js itself remains the existing vendored dependency, with its license embedded by the builder; see [vendor license](../assets/vendor/anime-LICENSE.md). No third-party artwork or remote assets.
+
+[Browser test](../scripts/test-gallery.mjs) exercises all five recipes in normal and reduced-motion offline Chromium contexts: positive finite durations; normal-motion deterministic backward/forward seeking and clamping; real Enter/Space play/replay, range-key seeking, pointer settle; actual intermediate playback; natural completion; final SVG node visibility/bounds; live reduced-motion and print settlement; destroy/remount; no network attempts or console/page errors. Reduced motion tests assert final-state seek suppression rather than animated intermediate frames. Outputs: `evidence/animejs-gallery-test-report.json` and full-page normal/reduced screenshots. The report binds results to the built HTML hash. This does not establish cross-browser, screen-reader, production-slide-navigation or PDF-export acceptance. Test changed configurations and inspect their intermediate frames before adopting them.
diff --git a/scripts/build-gallery.py b/scripts/build-gallery.py
new file mode 100644
index 0000000..04cbc68
--- /dev/null
+++ b/scripts/build-gallery.py
@@ -0,0 +1,27 @@
+"""Dependency-free, offline gallery packager. Run from any directory."""
+from pathlib import Path
+import hashlib
+import html
+import json
+import re
+
+root = Path(__file__).resolve().parents[1]
+source = root / 'assets/animejs'
+vendor = root / 'assets/vendor/anime.umd.min.js'
+entries = [entry for entry in json.loads((root / 'vendor-manifest.json').read_text(encoding='utf-8'))
+ if entry['path'] == 'assets/vendor/anime.umd.min.js']
+if len(entries) != 1 or hashlib.sha256(vendor.read_bytes()).hexdigest() != entries[0]['sha256']:
+ raise ValueError('Missing, duplicate or mismatched Anime vendor fingerprint')
+text = (source / 'index.html').read_text()
+text = text.replace('', '')
+for name, file in [('../vendor/anime.umd.min.js', vendor), ('examples.js', source / 'examples.js'), ('gallery.js', source / 'gallery.js')]:
+ js = re.sub(r'//# sourceMappingURL=.*', '', file.read_text())
+ text = text.replace(f'', '')
+text = text.replace('ANIME_LICENSE', html.escape((root / 'assets/vendor/anime-LICENSE.md').read_text()))
+out = root / 'dist/animejs-gallery-0.4.0.html'
+out.parent.mkdir(exist_ok=True)
+out.write_text(text)
+inputs = [*sorted(source.glob('*')), vendor, root / 'assets/vendor/anime-LICENSE.md']
+report = {'file': out.name, 'sha256': hashlib.sha256(out.read_bytes()).hexdigest(), 'anime':'4.5.0', 'examples':5, 'inputs':{str(p.relative_to(root)):hashlib.sha256(p.read_bytes()).hexdigest() for p in inputs if p.is_file()}}
+(root / 'dist/animejs-gallery-manifest.json').write_text(json.dumps(report, indent=2) + '\n')
+print(json.dumps(report, indent=2))
diff --git a/scripts/test-gallery.mjs b/scripts/test-gallery.mjs
new file mode 100644
index 0000000..be0864e
--- /dev/null
+++ b/scripts/test-gallery.mjs
@@ -0,0 +1,60 @@
+import {chromium} from '@playwright/test';
+import fs from 'node:fs';import path from 'node:path';import {pathToFileURL} from 'node:url';import assert from 'node:assert/strict';import crypto from 'node:crypto';
+const root=path.resolve(import.meta.dirname,'..'),file=path.join(root,'dist/animejs-gallery-0.4.0.html');
+const report={sha256:crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'),runs:[]};
+assert.equal(report.sha256,JSON.parse(fs.readFileSync(path.join(root,'dist/animejs-gallery-manifest.json'))).sha256);
+const browser=await chromium.launch({headless:true,...(process.env.CHROMIUM_EXECUTABLE?{executablePath:process.env.CHROMIUM_EXECUTABLE}:{})});
+fs.mkdirSync(path.join(root,'evidence'),{recursive:true});
+try {for(const reducedMotion of ['no-preference','reduce']) {
+ const ctx=await browser.newContext({offline:true,reducedMotion,viewport:{width:1200,height:1000}}),page=await ctx.newPage(),errors=[],network=[];
+ page.on('pageerror',e=>errors.push(String(e)));page.on('console',m=>{if(m.type()==='error')errors.push(m.text());});page.on('request',r=>{if(!r.url().startsWith('file:')&&!r.url().startsWith('data:'))network.push(r.url());});
+ await page.goto(pathToFileURL(file).href);
+ const ids=await page.evaluate(()=>Object.keys(animeGallery.controllers));assert.equal(ids.length,5);
+ assert.equal(new Set(ids).size,ids.length,'unique recipe IDs');
+ const configs=await page.evaluate(()=>animeExamples.map(({id,kind})=>({id,kind})));
+ for(const [group,kind] of [['elements','element'],['compositions','composition']]){
+ const registry=path.join(root,`references/registry-animejs-${group}.md`),text=fs.readFileSync(registry,'utf8');
+ const listed=[...text.matchAll(/^\| `([^`]+)` \|/gm)].map(m=>m[1]);
+ assert.deepEqual(listed.sort(),configs.filter(c=>c.kind===kind).map(c=>c.id).sort(),'registry matches executable '+group);
+ for(const match of text.matchAll(/\]\(([^)]+)\)/g)){
+ const target=match[1].split('#')[0];if(target&&!target.includes('://'))assert.ok(fs.existsSync(path.resolve(path.dirname(registry),target)),'missing registry target '+target);
+ }
+ }
+ for(const id of ids){
+ const call=(method,arg)=>page.evaluate(({id,method,arg})=>animeGallery.controllers[id][method](arg),{id,method,arg});
+ const state=()=>call('state');
+ const visual=()=>page.locator('#'+id+' svg').evaluate(e=>e.outerHTML);
+ const final=await visual(),s=await state();assert.ok(Number.isFinite(s.duration)&&s.duration>0);assert.equal(s.time,s.duration);
+ if(reducedMotion==='no-preference'){
+ if(id==='assemble-explode'){
+ await call('settle');await call('seek',s.duration*.45);const hold=await visual();
+ const offsets=await page.locator('#assemble-explode [data-part]').evaluateAll(parts=>parts.map(e=>new DOMMatrix(getComputedStyle(e).transform).m41));
+ assert.deepEqual(offsets,[-105,0,105],'backward seek into hold must remain exploded');
+ await call('seek',0);await call('seek',s.duration*.45);assert.equal(await visual(),hold,'hold state independent of seek direction');
+ }
+ await call('seek',s.duration*.3);const earlier=await visual();await call('seek',s.duration*.8);const later=await visual();assert.notEqual(earlier,later);
+ await call('seek',s.duration*.3);assert.equal(await visual(),earlier);await call('seek',s.duration*.8);assert.equal(await visual(),later);
+ await call('seek',-100);assert.equal((await state()).time,0);await call('seek',s.duration+100);assert.equal((await state()).time,s.duration);
+ await page.locator('#'+id+' input').focus();await page.keyboard.press('Home');assert.equal((await state()).time,0);
+ await page.keyboard.press('ArrowRight');assert.ok((await state()).time>0);
+ }else {await call('seek',0);assert.equal(await visual(),final);}
+ for(const action of ['play','replay']){
+ await page.locator(`#${id} [data-action="${action}"]`).focus();await page.keyboard.press(action==='play'?'Enter':'Space');
+ if(reducedMotion==='no-preference'){await page.waitForTimeout(90);const running=await state();assert.ok(running.time>0&&running.time{const s=animeGallery.controllers[id].state();return s.paused&&s.time===s.duration;},id);
+ assert.equal(await visual(),final);
+ }
+ await call('replay');await page.locator(`#${id} [data-action="settle"]`).click();assert.equal(await visual(),final);assert.equal((await state()).paused,true);
+ const readable=await page.locator('#'+id+' svg').evaluate(svg=>[...svg.querySelectorAll('text,rect,circle')].every(e=>{const b=e.getBoundingClientRect(),r=svg.getBoundingClientRect();return Number(getComputedStyle(e).opacity)>0&&b.width>0&&b.left>=r.left-1&&b.right<=r.right+1&&b.top>=r.top-1&&b.bottom<=r.bottom+1;}));assert.ok(readable,id+' visible and within SVG');
+ }
+ await page.screenshot({path:path.join(root,`evidence/animejs-gallery-${reducedMotion}.png`),fullPage:true});
+ await page.evaluate(()=>Object.values(animeGallery.controllers).forEach(c=>c.replay()));await page.emulateMedia({reducedMotion:'reduce'});
+ await page.waitForFunction(()=>Object.values(animeGallery.controllers).every(c=>{const s=c.state();return s.paused&&s.time===s.duration;}));
+ assert.ok(await page.evaluate(()=>Object.values(animeGallery.controllers).every(c=>{const s=c.state();return s.paused&&s.time===s.duration;})));
+ await page.emulateMedia({reducedMotion:'no-preference'});await page.evaluate(()=>{Object.values(animeGallery.controllers).forEach(c=>c.replay());dispatchEvent(new Event('beforeprint'));});
+ assert.ok(await page.evaluate(()=>Object.values(animeGallery.controllers).every(c=>{const s=c.state();return s.paused&&s.time===s.duration;})));
+ await page.evaluate(()=>{for(const [id,c] of Object.entries(animeGallery.controllers)){c.replay();c.destroy();c.destroy();if(!c.state().destroyed||document.getElementById(id).children.length)throw Error('cleanup failed');}for(const config of animeExamples)animeGallery.controllers[config.id]=animeGallery.mount(document.getElementById(config.id),config);});
+ assert.equal(await page.locator('article button').count(),15);
+ assert.deepEqual(errors,[]);assert.deepEqual(network,[]);report.runs.push({reducedMotion,examples:ids,deterministicSeek:reducedMotion==='no-preference',reducedMotionSeekSuppressed:reducedMotion==='reduce',keyboardControls:true,replay:true,finalVisibility:true,liveReducedMotion:true,printSettle:true,cleanupAndRemount:true,errors,network});await ctx.close();
+ }fs.writeFileSync(path.join(root,'evidence/animejs-gallery-test-report.json'),JSON.stringify(report,null,2)+'\n');console.log(JSON.stringify(report,null,2));
+}finally{await browser.close();}