docs(examples): reduced-motion play starts both films; precise media-start layers

- replica-compare: the voluntary control now starts and pauses BOTH films (not
  just the reference), and the replica-sync effect attaches in view regardless of
  the preference, so a reduced-motion visitor who presses play sees the whole
  synchronized pair. Added a focused source-level assertion to the motion-check
  suite (the repo has no React runtime harness for snippets).
- html-schema: describe each layer precisely instead of grouping the CLI —
  timing compiler, HTML parser, producer audio, and 'hyperframes validate' read
  only data-media-start; runtime, Studio and 'hyperframes snapshot' read
  data-playback-start first (Studio also writes it).

Round-3 findings from Magi.
This commit is contained in:
ukimsanov
2026-08-05 10:41:34 -07:00
parent b541245f1a
commit 630c8900f5
3 changed files with 43 additions and 4 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ Audio has no visual lifecycle.
| Attribute | Applies to | Meaning |
| --- | --- | --- |
| `data-media-start` / `data-playback-start` | Video, audio, nested composition | Offset into the source file, used by trim and split. The two names are read by different layers with different precedence: the timing compiler and HTML parser read only `data-media-start`, while the runtime player, Studio, and the CLI read `data-playback-start` first and fall back to `data-media-start`. Studio writes `data-playback-start` for nested compositions and any element authored with it. Set **one**; authoring both is what causes the two layers to diverge. |
| `data-media-start` / `data-playback-start` | Video, audio, nested composition | Offset into the source file, used by trim and split. The two names are read by different layers, and not all of them agree. **Read only `data-media-start`:** the timing compiler, the HTML parser, the producer's audio extraction, and `hyperframes validate`. **Read `data-playback-start` first, falling back to `data-media-start`:** the runtime player, Studio, and `hyperframes snapshot` — and Studio also writes `data-playback-start` for nested compositions and any element authored with it. Set **one** value; authoring both is what makes these two groups diverge. |
| `data-playback-rate` | Video, audio, nested composition | Playback multiplier from `0.1` to `5` |
| `data-volume` | Video and audio | Static volume from `0` to `1` |
| `data-has-audio="true"` | Video | Declares that the video contributes audio |
+20 -3
View File
@@ -65,11 +65,13 @@ export const ReplicaCompare = ({
}
}, [reduced, inView]);
// Keep the replica (right) locked to the reference (left) clock.
// Keep the replica (right) locked to the reference (left) clock. Attached
// whenever the pair is in view — including under reduced motion — so that once
// the reference starts (autoplay, or a voluntary press) the replica follows.
useEffect(() => {
const a = refVideo.current;
const b = repVideo.current;
if (!a || !b || reduced || !inView) return;
if (!a || !b || !inView) return;
const resync = () => {
if (Number.isFinite(a.currentTime) && Math.abs((b.currentTime || 0) - a.currentTime) > 0.15) {
@@ -95,13 +97,28 @@ export const ReplicaCompare = ({
};
}, [reduced, inView]);
// Start/pause both films together — the sync effect keeps the replica locked to
// the reference clock once the reference is playing.
const startBoth = () => {
refVideo.current?.play().catch(() => {});
repVideo.current?.play().catch(() => {});
};
const pauseBoth = () => {
refVideo.current?.pause();
repVideo.current?.pause();
};
const toggleSound = () => {
const a = refVideo.current;
if (!a) return;
const next = !muted;
setMuted(next);
a.muted = next; // only the reference carries audio; replica stays silent
if (!next) a.play().catch(() => {});
if (!next) {
startBoth(); // voluntary play — starts the whole pair, incl. under reduced motion
} else if (reduced) {
pauseBoth(); // no autoplay to fall back to under reduced motion
}
};
const active = inView && !reduced;
@@ -1,5 +1,8 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";
import {
auditSnippets,
@@ -8,6 +11,8 @@ import {
splitComponents,
} from "./check-docs-snippet-motion.mjs";
const here = dirname(fileURLToPath(import.meta.url));
const GUARDED = `export const Grid = () => {
const [reducedMotion, setReducedMotion] = useState(
() =>
@@ -121,3 +126,20 @@ test("forwarding a caller's autoPlay prop does not make a component owe the guar
test("every autoplaying component in docs/snippets currently satisfies the guard", () => {
assert.deepEqual(auditSnippets(), []);
});
// Reduced motion disables autoplay, but the visible control must still start the
// whole comparison — not just the reference — or a reduced-motion visitor who
// presses it sees half the pair. Source-level, since the repo has no React
// runtime harness for docs snippets (this whole check is source-level for that
// reason).
test("ReplicaCompare's control starts both films and does not gate sync on reduced motion", () => {
const source = readFileSync(join(here, "../docs/snippets/replica-compare.jsx"), "utf8");
// The voluntary-play path starts both the reference and the replica.
assert.match(source, /refVideo\.current\?\.play\(\)/, "reference is started");
assert.match(source, /repVideo\.current\?\.play\(\)/, "replica is started too");
// The replica-sync effect attaches in view regardless of the preference, so a
// voluntary play under reduced motion still pulls the replica along.
const syncGate = source.match(/const b = repVideo\.current;\s*\n\s*if \(([^)]*)\) return;/);
assert.ok(syncGate, "found the replica-sync guard");
assert.doesNotMatch(syncGate[1], /reduced/, "sync must not early-return on reduced motion");
});