mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
* docs(examples): the inspiration page, with films and remixes to start from The Examples page becomes Inspiration: two rows of films made with HyperFrames, then 41 remixes of popular videos, each crediting and linking the original post, with how to remix it in the Studio app. The rows play one film at a time, since a second playing film drops the page from 120 to 30 fps, and they get a Pause button. The block and template tables stay. * docs(examples): remixes honour reduced motion; one film while the dialog is open Remix previews now follow reduced motion (read on the first render, stopped if it turns on), and each film is a button that plays it on purpose with sound. The film rows stop their previews while a full film plays in the dialog, so only one film plays at a time. Two remixes get the credit handles the app lists for them. * docs(examples): a motion setting change keeps remix cards as they are The shelf keeps the reduced-motion setting in a ref, so a change no longer re-renders it and remounts every card: a playing remix, its focus and its open instructions stay. * docs(examples): one film at a time across the whole Inspiration page The dialog paused only the film-row previews, so a chosen remix kept playing behind it. Any video that starts with sound now makes each remix card stop its own, and the film rows rest their previews while a film with sound plays anywhere on the page. * docs(examples): remix cards and film-row controls in the docs palette The remix cards used Tailwind zinc greys and white, the film rows the old green focus ring and fixed tints; they now use the palette tokens, so they sit on the beige ground and in the Night theme like the rest.
216 lines
9.2 KiB
JavaScript
216 lines
9.2 KiB
JavaScript
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,
|
|
autoplays,
|
|
findMotionGuardViolations,
|
|
splitComponents,
|
|
} from "./check-docs-snippet-motion.mjs";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
|
|
const GUARDED = `export const Grid = () => {
|
|
const [reducedMotion, setReducedMotion] = useState(
|
|
() =>
|
|
typeof window !== "undefined" &&
|
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
|
);
|
|
useEffect(() => {
|
|
if (!reducedMotion || !gridRef.current) return;
|
|
for (const video of gridRef.current.querySelectorAll("video")) {
|
|
video.pause();
|
|
video.removeAttribute("src");
|
|
video.load();
|
|
}
|
|
}, [reducedMotion]);
|
|
return <video autoPlay={!reducedMotion} />;
|
|
};
|
|
`;
|
|
|
|
test("a guarded component passes", () => {
|
|
assert.deepEqual(findMotionGuardViolations(GUARDED), []);
|
|
});
|
|
|
|
test("reading the preference after mount is caught — the first-paint fetch", () => {
|
|
const lateRead = GUARDED.replace(
|
|
/const \[reducedMotion[\s\S]*?\);\n/,
|
|
"const [reducedMotion, setReducedMotion] = useState(false);\n",
|
|
);
|
|
const problems = findMotionGuardViolations(lateRead);
|
|
assert.equal(problems.length, 1);
|
|
assert.match(problems[0], /lazy initializer/);
|
|
});
|
|
|
|
test("false -> true with no active stop is caught", () => {
|
|
const noStop = GUARDED.replace(/\s*video\.pause\(\);[\s\S]*?video\.load\(\);/, "");
|
|
const problems = findMotionGuardViolations(noStop);
|
|
assert.equal(problems.length, 1);
|
|
assert.match(problems[0], /does not pause an element or abort its resource/);
|
|
});
|
|
|
|
test("dropping only load() is still caught", () => {
|
|
assert.equal(findMotionGuardViolations(GUARDED.replace(" video.load();\n", "")).length, 1);
|
|
});
|
|
|
|
// Both gaps below were found by review on #2977, against an earlier whole-file
|
|
// version of this check that passed all of the cases above.
|
|
|
|
test("a lazy initializer for unrelated state does not satisfy the preference read", () => {
|
|
const decoupled = GUARDED.replace(
|
|
/const \[reducedMotion[\s\S]*?\);\n/,
|
|
"const [id] = useState(() => makeId());\n const [reducedMotion, setReducedMotion] = useState(false);\n" +
|
|
' useEffect(() => setReducedMotion(window.matchMedia("(prefers-reduced-motion: reduce)").matches), []);\n',
|
|
);
|
|
const problems = findMotionGuardViolations(decoupled);
|
|
assert.equal(
|
|
problems.length,
|
|
1,
|
|
"a lazy initializer anywhere must not vouch for the media query",
|
|
);
|
|
assert.match(problems[0], /lazy initializer/);
|
|
});
|
|
|
|
test("a second unguarded component cannot ride in on the first one's guard", () => {
|
|
const twoComponents = `${GUARDED}
|
|
export const OtherGrid = () => {
|
|
return <video autoPlay={true} />;
|
|
};
|
|
`;
|
|
const components = splitComponents(twoComponents);
|
|
assert.deepEqual(
|
|
components.map((component) => component.name),
|
|
["Grid", "OtherGrid"],
|
|
);
|
|
assert.deepEqual(findMotionGuardViolations(components[0].body), []);
|
|
assert.equal(findMotionGuardViolations(components[1].body).length, 2);
|
|
});
|
|
|
|
// Both below were found by running these functions rather than reading them,
|
|
// on the approval pass for #2977. Each fails silently: a component that
|
|
// `autoplays` misses is filtered out before any requirement runs, so the gate
|
|
// reports zero problems instead of a violation.
|
|
|
|
test("a bare autoPlay attribute counts, however the element is wrapped", () => {
|
|
assert.equal(autoplays("<video autoPlay muted />"), true);
|
|
assert.equal(autoplays("<video src={s} autoPlay/>"), true);
|
|
assert.equal(autoplays("<video\n autoPlay\n/>"), true);
|
|
});
|
|
|
|
test("a component that is not exported cannot inherit the one above it", () => {
|
|
const sneaky = `${GUARDED}
|
|
const Sneaky = () => <video autoPlay={true} />;
|
|
`;
|
|
const components = splitComponents(sneaky);
|
|
assert.deepEqual(
|
|
components.map((component) => component.name),
|
|
["Grid", "Sneaky"],
|
|
);
|
|
assert.equal(findMotionGuardViolations(components[1].body).length, 2);
|
|
});
|
|
|
|
test("forwarding a caller's autoPlay prop does not make a component owe the guard", () => {
|
|
assert.equal(autoplays('<video autoPlay={autoPlay} preload="metadata" />'), false);
|
|
assert.equal(autoplays("({ autoPlay = false }) => <video autoPlay={autoPlay} />"), false);
|
|
assert.equal(
|
|
autoplays("({ autoPlay = false, loop = false }) => <video autoPlay={autoPlay} />"),
|
|
false,
|
|
);
|
|
assert.equal(autoplays("<video autoPlay={!reduced} />"), true);
|
|
assert.equal(autoplays('<video controls muted preload="metadata" />'), false);
|
|
});
|
|
|
|
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");
|
|
// startBoth must play both elements...
|
|
const startBoth = source.match(/const startBoth = \(\) => \{([\s\S]*?)\n {2}\};/);
|
|
assert.ok(startBoth, "found startBoth");
|
|
assert.match(startBoth[1], /refVideo\.current\?\.play\(\)/, "reference is started");
|
|
assert.match(startBoth[1], /repVideo\.current\?\.play\(\)/, "replica is started too");
|
|
// ...and the voluntary-play control must actually route through it, not play
|
|
// the reference alone (scoped to toggleSound so a helper left defined but
|
|
// unused can't satisfy the check — the exact regression this guards).
|
|
const toggle = source.match(/const toggleSound = \(\) => \{([\s\S]*?)\n {2}\};/);
|
|
assert.ok(toggle, "found toggleSound");
|
|
assert.match(toggle[1], /startBoth\(\)/, "voluntary play goes through startBoth");
|
|
// 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");
|
|
// Offscreen release must reset the control's state, or a card unmuted before it
|
|
// scrolled away returns reading "Sound on" over a paused, sourceless pair.
|
|
const teardown = source.match(/if \(!inView\) \{([\s\S]*?)return;\n {4}\}/);
|
|
assert.ok(teardown, "found the offscreen teardown");
|
|
assert.match(teardown[1], /setMuted\(true\)/, "offscreen release resets muted state");
|
|
});
|
|
|
|
// 40 of HoverVideo's 53 uses sit inside a link (11 of 23 examples cards are
|
|
// <a href>, 29 of 30 thirty-days cards are <Card href>). Its control's click must
|
|
// not bubble to that link, or pressing it — mouse or keyboard — navigates away
|
|
// instead of toggling sound. This bug escaped static review and the gate; it only
|
|
// surfaced by driving the live preview, so pin it.
|
|
test("HoverVideo's control does not bubble its click to a wrapping link", () => {
|
|
const source = readFileSync(join(here, "../docs/snippets/hover-video.jsx"), "utf8");
|
|
const onClick = source.match(/onClick=\{\(e\) => \{([\s\S]*?)\n {8}\}\}/);
|
|
assert.ok(onClick, "found the control's click handler");
|
|
assert.match(onClick[1], /e\.preventDefault\(\)/, "default navigation is prevented");
|
|
assert.match(
|
|
onClick[1],
|
|
/e\.stopPropagation\(\)/,
|
|
"the click does not bubble to a wrapping link",
|
|
);
|
|
});
|
|
|
|
// Mintlify prefixes caller classes before evaluating snippets. Interpolating
|
|
// them after literal classes can join the boundary tokens and lose aspect-video.
|
|
test("HoverVideo preserves the caller's sizing classes at the Mintlify boundary", () => {
|
|
const source = readFileSync(join(here, "../docs/snippets/hover-video.jsx"), "utf8");
|
|
const wrapper = source.match(/<div\s+ref=\{wrapRef\}([\s\S]*?)>/);
|
|
assert.ok(wrapper, "found the preview's layout wrapper");
|
|
assert.match(
|
|
wrapper[1],
|
|
/className=\{className\}/,
|
|
"forward sizing classes without interpolation",
|
|
);
|
|
assert.match(
|
|
wrapper[1],
|
|
/position:\s*"relative"/,
|
|
"anchor the absolute video inside the sized wrapper",
|
|
);
|
|
assert.match(wrapper[1], /overflow:\s*"hidden"/, "clip the preview inside its card");
|
|
});
|
|
|
|
test("the catalog player keeps native controls until the CDN player upgrades it", () => {
|
|
const source = readFileSync(join(here, "../docs/snippets/example-player.jsx"), "utf8");
|
|
assert.match(source, /createElement\("hyperframes-player"\)/);
|
|
assert.match(source, /player\.setAttribute\("controls", ""\)/);
|
|
assert.match(
|
|
source,
|
|
/\.hfp-poster\{width:100%;height:100%\}/,
|
|
"fit large posters in the published player instead of cropping them",
|
|
);
|
|
assert.match(source, /loading="lazy"/);
|
|
assert.ok(
|
|
source.indexOf("appendChild(fallback)") < source.indexOf("appendChild(script)"),
|
|
"native controls exist before the CDN request",
|
|
);
|
|
assert.match(
|
|
source,
|
|
/fallback\.readyState >= 1/,
|
|
"upgrade also handles metadata arriving before the CDN script",
|
|
);
|
|
});
|