diff --git a/docs/reference/html-schema.mdx b/docs/reference/html-schema.mdx
index 178a72eb8..c9c6495a4 100644
--- a/docs/reference/html-schema.mdx
+++ b/docs/reference/html-schema.mdx
@@ -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 |
diff --git a/docs/snippets/replica-compare.jsx b/docs/snippets/replica-compare.jsx
index f3f09ee01..60fe6fb99 100644
--- a/docs/snippets/replica-compare.jsx
+++ b/docs/snippets/replica-compare.jsx
@@ -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;
diff --git a/scripts/check-docs-snippet-motion.test.mjs b/scripts/check-docs-snippet-motion.test.mjs
index 3bbd4e29e..821c66004 100644
--- a/scripts/check-docs-snippet-motion.test.mjs
+++ b/scripts/check-docs-snippet-motion.test.mjs
@@ -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");
+});