mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-08-31 02:41:44 +00:00
docs: add the shared page components and the Reference Project (#2977)
* docs: add the shared page components Adds the six React snippets the rebuilt documentation pages compose against, plus the styles they need. Nothing imports them yet, so this lands with no user-visible change and no navigation churn. - DocsVideo / ShowcaseWall — the film player and the Showcase grid - LiveReferenceProject — embeds the Reference Project via <hyperframes-player> - WorkflowChooser, AgentAction, and the two grid snippets The scrub indicator is a timecode bubble rather than a thumbnail. Mounting a second <video> with the same src to drive a preview frame made every page carrying a film download the whole file twice, which is not worth a thumbnail. * docs: add the Reference Project example One real 10-second project the documentation can point at instead of describing a hypothetical one: a live capture of example.com, synthesised narration, and caption timings measured from that narration. It passes its own gates — `hyperframes lint` clean, `hyperframes check` passed, 28/28 text checks WCAG AA. No page imports it yet, so this lands without touching navigation. Only the two WAV masters exceed the repository's 500 KB non-LFS limit, so only those go through LFS. The MP3 stings and the capture PNG stay plain, which keeps the example usable after a clone without `git lfs pull`. `bun run docs:bundle-reference` regenerates the single-file embed the Introduction page loads from the CDN. * docs: keep the Reference Project verification report The Examples page links this file twice — as "What changed after review" and as "The real verification report" — in the section that makes the project's brief, source, revision notes, and checks public end to end. It is a published artifact, not leftover scaffolding. * docs: state the Reference Project embed's isolation contract The composition is fetched from the CDN and handed to the player as a blob: URL, which inherits the docs origin, and <hyperframes-player> sandboxes its iframe with allow-scripts + allow-same-origin. So the embedded composition runs with script access to this origin. That is a consequence of how the player works — it drives seeking through the iframe's document, which a cross-origin frame does not expose — not something this component can fix. Serving the CDN URL directly would isolate the frame and break playback. The guard is therefore the source, so the comment says so out loud: src must stay a first-party path we publish, never user- or community-supplied HTML. * fix(docs): resolve reduced-motion on the first render, and the embed's dep gap Both defects from Rames Jusso's review on #2977. Neither is visible today because nothing imports these files yet, which is what makes them cheap now. **Reduced motion resolved one paint too late, in all three grids.** `useState(false)` plus a `matchMedia` read in an effect meant the first committed render always emitted `<video src autoPlay loop>`; a reduce-motion visitor had 6 + 8 + 4 tiles already fetching before the attributes came off. `autoPlay` also overrides `preload="metadata"`, so those were the files, not metadata probes — and dropping `src` with no following `load()` is not a reliable abort. A lazy initializer knows the answer on the first render. **LiveReferenceProject never sent the initial variables.** The sending effect read `playerRef.current`, assigned by the effect above it on the commit where `compositionSrc` lands — a commit with nothing in the sending effect's dep array. So it ran once against a null ref and never again. It looked correct only because the three defaults match what the composition already renders. Also from the same review: - The object URL could outlive its revoke: once the body resolves, `abort()` no longer stops the chain, so the blob could be minted after cleanup ran with `objectUrl` still undefined. Same `cancelled` guard the effect above uses. - `postMessage` targeted `"*"` while the isolation comment argues the frame is same-origin. Naming `window.location.origin` turns that prose guard into an enforced one. - Nothing reached a terminal state when the player script never arrived: `whenDefined()` does not reject, and a later mount reuses the tag without its error listener. A CSP rule or content blocker never fires `error` at all. A deadline covers every path instead of sitting on "Loading…" forever. - `loadFailed` was never cleared, so one transient failure stuck. - The README claimed a clone works without `git lfs pull`. It does for the visuals; both WAVs are pointers and they are the bed and the voiceover, so the captions would play over silence. Says so now. - The bundler stripped trailing whitespace document-wide while inlining the runtime, which reaches inside script template literals where those spaces are data. It also assumed a literal `<head>` and would silently ship an embed with no `<base>`. Strip removed, anchor asserted. Copilot's five "missing hook imports" comments are wrong — Mintlify pre-injects the hooks, and `TemplateCard.jsx`, cited as the counter-example, uses the `export function` form the same page says is unsupported. * fix(docs): stop preview loops when Reduce Motion is turned on mid-session Miguel's changes-requested on #2977. He is right about the mechanism: dropping `src` and `autoPlay` through React props neither pauses a playing element nor aborts its selected resource, so a visitor who turned Reduce Motion on with the page already open kept every tile running. Measured in a browser rather than argued from the spec, same clip, same sequence: playing paused=false t=2.90 readyState=4 networkState=1 React props only paused=false t=3.90 readyState=4 networkState=1 + pause/removeAttr/load paused=true t=0 readyState=0 networkState=0 The middle row is the bug: time still advancing, resource still held. Rames' follow-up asked for a remount-to-poster instead, because a video that ends with `src` removed holds its last frame and `poster` only paints before playback begins. `load()` covers that too — it drops readyState to HAVE_NOTHING, which is precisely the state that paints the poster. Confirmed side by side on screen: the React-props-only tile sits on an arbitrary mid-clip frame, the pause/load tile shows the poster again. So no remount is needed. The guard cannot be shared as code — Mintlify compiles each snippet in isolation and forbids one importing another — so it is copy-pasted into all three grids. A duplicated invariant is the kind that rots, and a rendering test would mean adding React to a repo that only carries it inside packages/studio, plus mocking Mintlify's hook-injection contract with a mock that can stay green while the page breaks. `scripts/check-docs-snippet-motion.mjs` asserts the source instead, wired into `bun run lint`, with unit tests covering both edges. That gate immediately found `docs/snippets/TemplateCard.jsx`: autoplays with no reduced-motion handling at all. It is imported by zero pages, and it uses the `export function` form Mintlify's constraints page says is unsupported, so it would not work if it were. Deleted rather than fixed. * refactor(scripts): split the motion guard into named predicates fallow flagged findMotionGuardViolations at CRAP 42 — a finding this branch introduced, so it gets fixed rather than suppressed, same as the catalog generator earlier in the stack. The two conditions are now their own predicates behind a small requirements table, which drops the branch count under the threshold and makes each rule readable on its own line. Same output, same tests. * fix(docs): move the stop effect above ShowcaseWall's early return Rames' changes-requested on `e1a03c63`. The effect I added in the previous commit landed below `if (open) return`, so `ShowcaseWall` called five hooks on the grid render and four once a tile was open. That is a conditional hook: clicking a tile — the component's primary interaction — threw "Rendered fewer hooks than expected". Worth naming why it landed in one of three. `workflow-chooser` and `advanced-path-grid` have no early return, so the same paste position was fine there. `ShowcaseWall` is the only one with a conditional return and it got the same copy. That is the duplication cost this script's own header warns about, showing up in the commit that added the script. **The bespoke gate could not have caught it, and now the generic one does.** `.oxlintrc.json` already loaded the `react` plugin and never excluded `docs/` — only `.prettierignore` does, which is why formatting is not a finding here but linting reaches these files. Naming the two hook rules in an override scoped to `docs/snippets/**` reports this bug directly, and also reports the `compositionSrc` dependency gap from round one that was found by reading. Verified both ways: reintroducing the conditional hook produces `react-hooks(rules-of-hooks)`, and `bunx oxlint .` is clean repo-wide, so nothing lit up in `packages/studio`. **Two holes in the script itself, both from the same review.** It matched whole files while the invariant is per component, so a second unguarded grid in `docs-video.jsx` would have ridden in on `ShowcaseWall`'s guard. It now splits by component. That immediately surfaced the distinction between a component that decides to autoplay and one that forwards its caller's `autoPlay` prop — `DocsVideo` only ever plays because a reader clicked, so it does not owe a preference check. And `readsPreferenceLazily` never tied its halves: any lazy initializer plus the media-query string anywhere in the file passed, which is the original bug satisfying the check written to prevent it. The query now has to sit inside the initializer's own expression. Both holes have tests. fallow is clean at 0 introduced. * fix(scripts): close the two silent gaps in the motion gate Both from Rames' approval pass on #2977, and both found by running these functions rather than reading them. Both fail the same quiet way: a component `autoplays` misses is filtered out before any requirement runs, so the gate reports zero problems instead of a violation. `autoplays` had become narrower than the version it replaced. Excluding the `autoPlay={autoPlay}` passthrough was right, but the replacement only matched `autoPlay={` or `autoPlay` alone on a line, so `<video autoPlay muted />` on one line slipped through. Restored the old breadth. Two things are stripped first rather than one — the passthrough, and the prop's own default in the signature, which is a declaration and not a use. Without the second strip, `DocsVideo` is asked to own a decision it only forwards. `splitComponents` anchored on `^export`, so anything not exported folded into the previous exported component and inherited its guard. Same hole as the whole-file match, narrowed from file scope to non-export scope. The anchor no longer requires `export`. Ten tests now, including his exact examples for both. * docs: remove the live-composition embed and its build apparatus The Introduction no longer carries the embed (removed in #2979), and nothing else used any of this: the 200-line snippet, 26 CSS rules, the bundler that built the single-file HTML for the CDN, its npm script, and the README section explaining how to regenerate it. The Reference Project itself stays — Examples, Developers, and Go further all link to it as the worked example; only the interactive embed of it is gone. This also retires the isolation contract I documented two rounds ago. That comment existed because the embed handed CDN HTML to a same-origin blob; with the embed gone there is no such surface to reason about, which is a better outcome than a comment explaining why it was acceptable. * docs: remove the AgentAction snippet Its only consumer is gone. The Quickstart now shows the agent instruction in a plain fence instead, because this component rendered a Copy button and never displayed the request — a reader copied text they could not read, which is the wrong shape for the one affordance a non-technical visitor depends on. Mintlify fences already carry a copy button and show their contents.
This commit is contained in:
parent
0a70ba6717
commit
edfe66a953
6
.gitattributes
vendored
6
.gitattributes
vendored
@ -47,3 +47,9 @@ registry/**/*.html linguist-vendored
|
||||
.claude/skills/**/*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
.agents/skills/**/*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
.agents/skills/**/*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# The documentation Reference Project ships real voice and music. Only the WAV
|
||||
# masters exceed the repository's 500 KB non-LFS limit, so only those are routed
|
||||
# through LFS — the small MP3 stings stay plain so the example still clones and
|
||||
# renders without `git lfs pull`.
|
||||
examples/docs-reference-project/**/*.wav filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -97,6 +97,8 @@ examples/*
|
||||
!examples/k8s-jobs/**
|
||||
!examples/gcp-cloud-run
|
||||
!examples/gcp-cloud-run/**
|
||||
!examples/docs-reference-project
|
||||
!examples/docs-reference-project/**
|
||||
# …but never the local smoke run's build/render artifacts.
|
||||
examples/gcp-cloud-run/scripts/gcp-smoke-artifacts/
|
||||
packages/studio/data/
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"plugins": ["react", "typescript"],
|
||||
"plugins": ["react", "react-hooks", "typescript"],
|
||||
"ignorePatterns": [
|
||||
".scratch/",
|
||||
"dist/",
|
||||
@ -24,7 +24,19 @@
|
||||
],
|
||||
"excludeFiles": ["**/*.test.ts", "**/*.test.tsx", "packages/cli/src/telemetry/feedback.ts"],
|
||||
"rules": {
|
||||
"no-console": ["error", { "allow": ["error", "warn"] }]
|
||||
"no-console": [
|
||||
"error",
|
||||
{
|
||||
"allow": ["error", "warn"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["docs/snippets/**/*.jsx", "docs/snippets/**/*.tsx"],
|
||||
"rules": {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
776
docs/custom.css
776
docs/custom.css
@ -1,36 +1,38 @@
|
||||
/* HyperFrames Design System — Mintlify Theme Overrides */
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap');
|
||||
|
||||
/* ── TT Norms Pro (matches hyperframes.heygen.com) ── */
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap");
|
||||
|
||||
@font-face {
|
||||
font-family: 'TT Norms Pro';
|
||||
src: url('https://www-static-assets.heygen.com/fonts/tt-norms/TT_Norms_Pro_Normal.woff2') format('woff2');
|
||||
font-family: "TT Norms Pro";
|
||||
src: url("https://www-static-assets.heygen.com/fonts/tt-norms/TT_Norms_Pro_Normal.woff2")
|
||||
format("woff2");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'TT Norms Pro';
|
||||
src: url('https://www-static-assets.heygen.com/fonts/tt-norms/TT_Norms_Pro_Medium.woff2') format('woff2');
|
||||
font-family: "TT Norms Pro";
|
||||
src: url("https://www-static-assets.heygen.com/fonts/tt-norms/TT_Norms_Pro_Medium.woff2")
|
||||
format("woff2");
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'TT Norms Pro';
|
||||
src: url('https://www-static-assets.heygen.com/fonts/tt-norms/TT_Norms_Pro_DemiBold.woff2') format('woff2');
|
||||
font-family: "TT Norms Pro";
|
||||
src: url("https://www-static-assets.heygen.com/fonts/tt-norms/TT_Norms_Pro_DemiBold.woff2")
|
||||
format("woff2");
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'TT Norms Pro';
|
||||
src: url('https://www-static-assets.heygen.com/fonts/tt-norms/TT_Norms_Pro_Bold.woff2') format('woff2');
|
||||
font-family: "TT Norms Pro";
|
||||
src: url("https://www-static-assets.heygen.com/fonts/tt-norms/TT_Norms_Pro_Bold.woff2")
|
||||
format("woff2");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@ -57,6 +59,14 @@
|
||||
--hf-text-tertiary: #999999;
|
||||
--hf-heading: #0a0a0a;
|
||||
--hf-code-bg: #ffffff;
|
||||
--hf-ui-border: #eeeeee;
|
||||
--hf-sidebar-rail: #eeeeee;
|
||||
--hf-sidebar-text: #707070;
|
||||
--hf-floating-input-shadow: 0 8px 28px rgba(10, 10, 10, 0.08), 0 1px 4px rgba(10, 10, 10, 0.08);
|
||||
--mintlify-slot-header-height: 3rem;
|
||||
--hf-brand: #16785b;
|
||||
--hf-brand-soft: rgba(22, 120, 91, 0.1);
|
||||
--hf-video-accent: #3ce6ac;
|
||||
|
||||
--hf-accent-green: #1a7a0a;
|
||||
--hf-accent-green-light: rgba(26, 122, 10, 0.07);
|
||||
@ -85,6 +95,12 @@
|
||||
--hf-text-tertiary: #666666;
|
||||
--hf-heading: #f5f5f5;
|
||||
--hf-code-bg: #141414;
|
||||
--hf-ui-border: #2a2a2a;
|
||||
--hf-sidebar-rail: #2a2a2a;
|
||||
--hf-sidebar-text: #a0a0a0;
|
||||
--hf-floating-input-shadow: 0 8px 28px rgba(0, 0, 0, 0.4), 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
--hf-brand: #3ce6ac;
|
||||
--hf-brand-soft: rgba(60, 230, 172, 0.12);
|
||||
|
||||
--hf-accent-green: #22c55e;
|
||||
--hf-accent-green-light: rgba(34, 197, 94, 0.1);
|
||||
@ -102,14 +118,32 @@
|
||||
/* ── Typography ── */
|
||||
|
||||
body {
|
||||
font-family: 'TT Norms Pro', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-family:
|
||||
"TT Norms Pro",
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'TT Norms Pro', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family:
|
||||
"TT Norms Pro",
|
||||
"Inter",
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
h1,
|
||||
h2 {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@ -117,19 +151,76 @@ h3 {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* Code font — IBM Plex Mono (loaded via Google Fonts above) */
|
||||
code, pre, pre code, kbd,
|
||||
[class*="code"],
|
||||
[class*="Code"] {
|
||||
font-family: 'IBM Plex Mono', 'SF Mono', 'Fira Code', monospace;
|
||||
/* ── Workflow routing ── */
|
||||
.hf-workflow-routes {
|
||||
margin: 1.5rem 0;
|
||||
border-top: 1px solid var(--hf-border-color);
|
||||
}
|
||||
|
||||
/* ── Code blocks ── */
|
||||
.hf-workflow-route {
|
||||
display: grid;
|
||||
grid-template-columns: 11rem minmax(0, 1fr);
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--hf-border-color);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--hf-code-bg) !important;
|
||||
border: 1px solid var(--hf-border-color) !important;
|
||||
border-radius: 8px !important;
|
||||
.hf-workflow-route:hover .hf-workflow-route-title {
|
||||
color: var(--hf-accent-green);
|
||||
}
|
||||
|
||||
.hf-workflow-route video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: #000;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hf-workflow-route-title {
|
||||
display: block;
|
||||
color: var(--hf-heading);
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.hf-workflow-route-copy {
|
||||
display: block;
|
||||
margin-top: 0.3rem;
|
||||
color: var(--hf-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.hf-workflow-route {
|
||||
grid-template-columns: 7rem minmax(0, 1fr);
|
||||
gap: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Code font — IBM Plex Mono (loaded via Google Fonts above).
|
||||
*
|
||||
* Deliberately scoped to real code elements. The previous rule also matched
|
||||
* [class*="code"] / [class*="Code"], which caught any element whose class
|
||||
* merely contained that substring — including every Accordion, whose wrapper
|
||||
* carries the Tailwind utility `dark:bg-codeblock`. That set the whole
|
||||
* accordion (and, by inheritance, its title and prose) in monospace, so plain
|
||||
* sentences rendered like terminal output. Syntax tokens inside `pre` inherit
|
||||
* from `pre`, so they do not need a selector of their own. */
|
||||
code,
|
||||
pre,
|
||||
pre code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: "IBM Plex Mono", "SF Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
/* ── Selection ── */
|
||||
@ -140,12 +231,245 @@ pre {
|
||||
|
||||
/* ── Links ── */
|
||||
|
||||
a:not([class]) {
|
||||
color: var(--hf-accent-blue);
|
||||
#content-area :where(p, li, td, blockquote) a:not([class]) {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--hf-brand) 72%, transparent);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:not([class]):hover {
|
||||
opacity: 0.85;
|
||||
#content-area :where(p, li, td, blockquote) a:not([class]):hover {
|
||||
border-bottom-color: var(--hf-brand);
|
||||
color: var(--hf-brand);
|
||||
}
|
||||
|
||||
/* ── Documentation navigation ── */
|
||||
|
||||
/*
|
||||
* Keep Aspen's full-width header, but place its desktop controls on one row:
|
||||
* logo → section tabs → search → actions. Mobile keeps Mintlify's layout.
|
||||
*/
|
||||
@media (min-width: 1024px) {
|
||||
#navbar {
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
#navbar > div:has(.nav-tabs) {
|
||||
display: grid;
|
||||
grid-template-columns: max-content max-content minmax(10.25rem, 1fr) max-content;
|
||||
column-gap: 1rem;
|
||||
align-items: center;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
#navbar > div:has(.nav-tabs) > .relative,
|
||||
#navbar > div:has(.nav-tabs) > .relative > div:first-child,
|
||||
#navbar > div:has(.nav-tabs) > .relative > div:first-child > div:first-child {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
#navbar > div:has(.nav-tabs) > .relative > div:first-child > div:first-child > div:first-child {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#navbar div:has(> .nav-tabs) {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
height: 3rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#navbar .nav-tabs {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
/* Use the same quiet pill treatment as Mintlify's own documentation. */
|
||||
#navbar .nav-tabs-item {
|
||||
height: 2.25rem !important;
|
||||
padding: 0 0.8rem;
|
||||
border-radius: 999px;
|
||||
color: var(--hf-text);
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
color 140ms ease;
|
||||
}
|
||||
|
||||
#navbar .nav-tabs-item:hover {
|
||||
background: color-mix(in srgb, var(--hf-heading) 6%, transparent);
|
||||
color: var(--hf-heading);
|
||||
}
|
||||
|
||||
#navbar .nav-tabs-item.text-primary {
|
||||
background: color-mix(in srgb, var(--hf-heading) 9%, transparent);
|
||||
color: var(--hf-heading) !important;
|
||||
}
|
||||
|
||||
#navbar .nav-tabs-item > .absolute.bottom-0 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#navbar div:has(> #search-bar-entry) {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#navbar .topbar-right-container {
|
||||
grid-column: 4;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
#sidebar-content {
|
||||
top: 3rem !important;
|
||||
height: calc(100vh - 3rem) !important;
|
||||
}
|
||||
|
||||
/* Keep the repository compact, but retain its useful live star count. */
|
||||
#navbar a[title="heygen-com/hyperframes"] {
|
||||
width: 7.5rem;
|
||||
min-width: 7.5rem;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#navbar a[title="heygen-com/hyperframes"] > span.truncate {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#navbar a[title="heygen-com/hyperframes"] > span:not(.truncate) {
|
||||
display: flex !important;
|
||||
color: var(--hf-heading);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/*
|
||||
* Mintlify removes the live count for roughly one animation frame during
|
||||
* client-side navigation. Keep a truthful placeholder in its place so the
|
||||
* control does not collapse while the live count reloads.
|
||||
*/
|
||||
#navbar a[title="heygen-com/hyperframes"]:not(:has(> span:not(.truncate)))::after {
|
||||
color: var(--hf-heading);
|
||||
content: "★";
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* The native gutter was almost 15px wide. Keep the rail quiet and compact. */
|
||||
#navigation-items {
|
||||
scrollbar-color: color-mix(in srgb, var(--hf-text) 30%, transparent) transparent;
|
||||
scrollbar-gutter: auto !important;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
#navigation-items::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
#navigation-items::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#navigation-items::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--hf-text) 30%, transparent);
|
||||
}
|
||||
|
||||
#navigation-items::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--hf-text) 46%, transparent);
|
||||
}
|
||||
|
||||
/* Preserve a useful search field on smaller desktop widths. */
|
||||
@media (min-width: 1024px) and (max-width: 1199px) {
|
||||
#navbar #assistant-entry {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Use main's quieter structural border color across Aspen surfaces. */
|
||||
#body-content [class*="border-gray-200"],
|
||||
#body-content [class*="border-gray-100"] {
|
||||
border-color: var(--hf-ui-border) !important;
|
||||
}
|
||||
|
||||
/* Keep the floating agent input noticeable against the page background. */
|
||||
#body-content .chat-assistant-floating-input > div > div {
|
||||
border-color: var(--hf-border-color-light) !important;
|
||||
background-color: var(--hf-background-light) !important;
|
||||
box-shadow: var(--hf-floating-input-shadow);
|
||||
}
|
||||
|
||||
/*
|
||||
* Match the navigation rhythm and active-page rail used on the main docs.
|
||||
* Each link owns one rail segment, so the current page can highlight only
|
||||
* its segment. These rules are visual only; groups remain non-collapsible.
|
||||
*/
|
||||
#navigation-items .sidebar-group-header {
|
||||
color: var(--hf-text);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
#navigation-items .sidebar-group {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
#navigation-items .sidebar-group::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
#navigation-items .sidebar-group > li {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#navigation-items .sidebar-group > li + li {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
#navigation-items .sidebar-group > li > a {
|
||||
width: calc(100% - 1rem);
|
||||
margin-left: 1rem;
|
||||
padding: 0.375rem 0.75rem 0.375rem 1rem;
|
||||
border-left: 1px solid var(--hf-sidebar-rail);
|
||||
border-radius: 0;
|
||||
background: transparent !important;
|
||||
color: var(--hf-sidebar-text);
|
||||
}
|
||||
|
||||
#navigation-items .sidebar-group > li > a:hover {
|
||||
background: transparent !important;
|
||||
color: var(--hf-heading);
|
||||
}
|
||||
|
||||
#navigation-items .sidebar-group > li[data-active="true"] > a,
|
||||
#navigation-items .sidebar-group > li[data-active-nav-item="true"] > a,
|
||||
#navigation-items .sidebar-group > li > a[aria-current="page"] {
|
||||
border-left-color: currentColor;
|
||||
color: var(--hf-heading);
|
||||
}
|
||||
|
||||
/* Aspen inserts horizontal separators; main uses the same space without a rule. */
|
||||
#navigation-items div:has(> .sidebar-group) + div {
|
||||
height: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#navigation-items div:has(> .sidebar-group) + div > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* Aspen assumes a 96px header and fades scrolled navigation before it reaches
|
||||
* that boundary. Our header is 48px; align the sticky sidebar to it and let
|
||||
* links clip exactly at the header instead of disappearing early.
|
||||
*/
|
||||
#navigation-items,
|
||||
#navigation-items [data-id] {
|
||||
-webkit-mask-image: none !important;
|
||||
mask-image: none !important;
|
||||
}
|
||||
|
||||
/* Catalog texture examples */
|
||||
@ -181,7 +505,7 @@ a:not([class]):hover {
|
||||
|
||||
.hf-texture-preview-word {
|
||||
color: #fff;
|
||||
font-family: Impact, 'Arial Black', sans-serif;
|
||||
font-family: Impact, "Arial Black", sans-serif;
|
||||
font-size: 42px;
|
||||
line-height: 0.9;
|
||||
letter-spacing: 0;
|
||||
@ -203,9 +527,7 @@ a:not([class]):hover {
|
||||
border-radius: 8px;
|
||||
padding: 22px;
|
||||
margin: 20px 0 24px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.54) 1px, transparent 1px),
|
||||
#f1f1f1;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.54) 1px, transparent 1px), #f1f1f1;
|
||||
background-size: 44px 100%;
|
||||
}
|
||||
|
||||
@ -244,7 +566,7 @@ a:not([class]):hover {
|
||||
|
||||
.hf-texture-animate-word {
|
||||
color: #181818;
|
||||
font-family: Impact, 'Arial Black', sans-serif;
|
||||
font-family: Impact, "Arial Black", sans-serif;
|
||||
font-size: 92px;
|
||||
line-height: 0.9;
|
||||
letter-spacing: 0;
|
||||
@ -280,6 +602,11 @@ a:not([class]):hover {
|
||||
margin: 20px 0 40px;
|
||||
}
|
||||
|
||||
.hf-texture-example-groups > div,
|
||||
.hf-texture-example-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hf-texture-example-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
@ -337,7 +664,7 @@ a:not([class]):hover {
|
||||
|
||||
.hf-texture-example-word {
|
||||
color: #181818;
|
||||
font-family: Impact, 'Arial Black', sans-serif;
|
||||
font-family: Impact, "Arial Black", sans-serif;
|
||||
font-size: 54px;
|
||||
line-height: 0.9;
|
||||
letter-spacing: 0;
|
||||
@ -376,6 +703,378 @@ a:not([class]):hover {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Portrait proof clips should read as examples inside the page, not become the
|
||||
* page. Landscape films keep the full content width; only explicitly marked
|
||||
* portrait media gets this compact, centered treatment. */
|
||||
.hf-portrait-video {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: min(100%, 18rem);
|
||||
max-height: min(32rem, 68vh);
|
||||
margin: 0.75rem auto 0;
|
||||
border-radius: 0.5rem;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
/* Full narrated films use a quiet custom control layer over the native video
|
||||
* engine. Preview loops remain plain muted videos. */
|
||||
.hf-docs-video-block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hf-docs-video-block[data-portrait="true"] {
|
||||
width: min(100%, 18rem);
|
||||
margin: 0.75rem auto 0;
|
||||
}
|
||||
|
||||
.hf-docs-video-block[data-portrait="true"] .hf-docs-video {
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: min(32rem, 68vh);
|
||||
}
|
||||
|
||||
/*
|
||||
* Mintlify loads custom React snippets after the page shell. Reserve the
|
||||
* film's real shape so the unloaded component never flashes as a thin Frame
|
||||
* rail, then let the hydrated player and chapter guide size themselves.
|
||||
*/
|
||||
.hf-docs-video-frame {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
background: #070707;
|
||||
}
|
||||
|
||||
.hf-docs-video-frame:has(.hf-docs-video-block) {
|
||||
aspect-ratio: auto;
|
||||
overflow: visible;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hf-docs-video {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
background: #070707;
|
||||
color: #fff;
|
||||
isolation: isolate;
|
||||
outline: none;
|
||||
}@media (max-width: 720px) {}
|
||||
|
||||
@media (max-width: 520px) {}
|
||||
|
||||
.hf-docs-video:focus-visible {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--hf-video-accent) 78%, transparent);
|
||||
}
|
||||
|
||||
.hf-docs-video video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 !important;
|
||||
background: #070707;
|
||||
object-fit: contain;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hf-docs-video-hero-play {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.35rem;
|
||||
height: 3.35rem;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 50%;
|
||||
background: rgba(8, 8, 8, 0.56);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.3);
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.hf-docs-video-hero-play:hover {
|
||||
border-color: var(--hf-video-accent);
|
||||
background: rgba(8, 8, 8, 0.78);
|
||||
}
|
||||
|
||||
.hf-docs-video-hero-play:focus-visible,
|
||||
.hf-docs-video-control:focus-visible,
|
||||
.hf-docs-video-rate:focus-visible,
|
||||
.hf-docs-video-progress:focus-visible {
|
||||
outline: 2px solid var(--hf-video-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hf-docs-video-hero-icon {
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hf-docs-video-hero-icon svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-left: 0.1rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.hf-docs-video-controls {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 3.75rem 1rem 0.78rem;
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.84));
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease,
|
||||
visibility 0s linear;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.hf-docs-video-controls[data-visible="false"] {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(0.4rem);
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 160ms ease,
|
||||
visibility 0s linear 160ms;
|
||||
}
|
||||
|
||||
.hf-docs-video-progress {
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Timecode bubble that tracks the pointer along the progress bar. It is a label,
|
||||
not a thumbnail: rendering a second <video> here made every page with a film
|
||||
download the whole file twice. */
|
||||
.hf-docs-video-scrub-preview {
|
||||
position: absolute;
|
||||
bottom: 3.6rem;
|
||||
left: clamp(2rem, var(--hf-video-preview-x), calc(100% - 2rem));
|
||||
border-radius: 0.35rem;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.38);
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
transition:
|
||||
opacity 120ms ease,
|
||||
transform 120ms ease;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.hf-docs-video-scrub-preview[data-visible="false"] {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 0.3rem);
|
||||
}
|
||||
|
||||
.hf-docs-video-scrub-preview span {
|
||||
display: block;
|
||||
padding: 0.16rem 0.4rem;
|
||||
color: #fff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hf-docs-video-progress::-webkit-slider-runnable-track {
|
||||
height: 0.22rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--hf-video-accent) 0,
|
||||
var(--hf-video-accent) var(--hf-video-progress),
|
||||
rgba(255, 255, 255, 0.34) var(--hf-video-progress),
|
||||
rgba(255, 255, 255, 0.34) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.hf-docs-video-progress::-moz-range-track {
|
||||
height: 0.22rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.34);
|
||||
}
|
||||
|
||||
.hf-docs-video-progress::-moz-range-progress {
|
||||
height: 0.22rem;
|
||||
border-radius: 999px;
|
||||
background: var(--hf-video-accent);
|
||||
}
|
||||
|
||||
.hf-docs-video-progress::-webkit-slider-thumb {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
margin-top: -0.315rem;
|
||||
border: 2px solid #121212;
|
||||
border-radius: 50%;
|
||||
background: var(--hf-video-accent);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.hf-docs-video-progress::-moz-range-thumb {
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border: 2px solid #121212;
|
||||
border-radius: 50%;
|
||||
background: var(--hf-video-accent);
|
||||
}
|
||||
|
||||
.hf-docs-video-control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hf-docs-video-control,
|
||||
.hf-docs-video-rate {
|
||||
display: grid;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: 0.45rem;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hf-docs-video-control:hover,
|
||||
.hf-docs-video-rate:hover {
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hf-docs-video-control svg {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.hf-docs-video-rate {
|
||||
min-width: 2.75rem;
|
||||
padding: 0 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.hf-docs-video-time {
|
||||
margin-left: 0.2rem;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hf-docs-video-time span {
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
|
||||
.hf-docs-video-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.hf-docs-video-spinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
margin: -1rem 0 0 -1rem;
|
||||
border: 2px solid rgba(255, 255, 255, 0.28);
|
||||
border-top-color: var(--hf-video-accent);
|
||||
border-radius: 50%;
|
||||
animation: hf-docs-video-spin 700ms linear infinite;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
@keyframes hf-docs-video-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.hf-docs-video-hero-play {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.hf-docs-video-hero-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.hf-docs-video-controls {
|
||||
gap: 0.25rem;
|
||||
padding: 2.75rem 0.6rem 0.42rem;
|
||||
}
|
||||
|
||||
.hf-docs-video-scrub-preview {
|
||||
bottom: 3.2rem;
|
||||
}
|
||||
|
||||
.hf-docs-video-time {
|
||||
margin-left: 0;
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
|
||||
.hf-docs-video-rate {
|
||||
min-width: 2.45rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hf-docs-video-controls {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.hf-docs-video-spinner {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.hf-docs-video-scrub-preview {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hf-texture-preview-panel {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@ -410,3 +1109,10 @@ a:not([class]):hover {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide the section/group label ("Start here", etc.) that the theme renders
|
||||
* above every page title. It repeats the sidebar grouping and adds noise on
|
||||
* every page; each page leads with its own H1 instead. */
|
||||
.eyebrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
export function TemplateCard({ id, title, description, href, portrait }) {
|
||||
const [hovering, setHovering] = React.useState(false);
|
||||
|
||||
const imgSrc = `https://static.heygen.ai/hyperframes-oss/docs/images/templates/${id}.png`;
|
||||
const videoSrc = `https://static.heygen.ai/hyperframes-oss/docs/images/templates/${id}.mp4`;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="not-prose group block rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden transition-shadow hover:shadow-lg no-underline"
|
||||
onMouseEnter={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
>
|
||||
<div
|
||||
className="relative overflow-hidden bg-gray-100 dark:bg-gray-800"
|
||||
style={{ aspectRatio: portrait ? "9/16" : "16/9" }}
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={`${title} example`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
style={{
|
||||
opacity: hovering ? 0 : 1,
|
||||
transition: "opacity 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
{hovering && (
|
||||
<video
|
||||
src={videoSrc}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white m-0">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-0">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function TemplateGrid({ children }) {
|
||||
return (
|
||||
<div
|
||||
className="not-prose grid gap-4"
|
||||
style={{ gridTemplateColumns: "repeat(2, 1fr)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
docs/snippets/advanced-path-grid.jsx
Normal file
146
docs/snippets/advanced-path-grid.jsx
Normal file
@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Four always-visible next steps for people who already have a project.
|
||||
*
|
||||
* The videos are proof, not controls: every destination remains a normal link.
|
||||
*/
|
||||
export const AdvancedPathGrid = () => {
|
||||
const CDN = "https://static.heygen.ai/hyperframes-oss/docs/images/showcase";
|
||||
const paths = [
|
||||
{
|
||||
title: "Direct the agent better",
|
||||
detail: "Change the story, source material, or several scenes with one clear revision.",
|
||||
action: "Give better direction",
|
||||
href: "/prompting/overview",
|
||||
video: "https://static.heygen.ai/hyperframes-oss/docs/images/showcase/go-further-agent-revision-loop-v1.mp4",
|
||||
poster: "https://static.heygen.ai/hyperframes-oss/docs/images/showcase/go-further-agent-revision-loop-v1.jpg",
|
||||
},
|
||||
{
|
||||
title: "Edit in Studio",
|
||||
detail: "Change visible design, text, media, timing, and animation in the live project.",
|
||||
action: "Open the Studio guide",
|
||||
href: "/studio",
|
||||
video: `${CDN}/studio-direct-edit-loop-v2.mp4`,
|
||||
poster: `${CDN}/studio-direct-edit-loop-v2.jpg`,
|
||||
},
|
||||
{
|
||||
title: "Build richer compositions",
|
||||
detail: "Add project media and reusable Catalog scenes before adapting them to your work.",
|
||||
action: "Use Assets and Catalog",
|
||||
href: "/studio/assets-and-blocks",
|
||||
video: "https://static.heygen.ai/hyperframes-oss/docs/images/showcase/studio-assets-catalog-loop-v1.mp4",
|
||||
poster: "https://static.heygen.ai/hyperframes-oss/docs/images/showcase/studio-assets-catalog-loop-v1.jpg",
|
||||
},
|
||||
{
|
||||
title: "Check, render, and share",
|
||||
detail:
|
||||
"Validate the project, render through Studio, an agent, or the CLI, and review the file.",
|
||||
action: "Finish the project",
|
||||
href: "/guides/export-and-share",
|
||||
video: `${CDN}/studio-check-render-loop-v2.mp4`,
|
||||
poster: `${CDN}/studio-check-render-loop-v2.jpg`,
|
||||
},
|
||||
];
|
||||
// Lazy initializer, not a post-mount effect: with useState(false) the first
|
||||
// committed render emits <video src autoPlay loop> and only then drops the
|
||||
// attributes, so a reduce-motion visitor has already started fetching every
|
||||
// tile. autoPlay also overrides preload="metadata", and removing src without
|
||||
// a following load() is not a reliable abort. CSS cannot reach any of this.
|
||||
const gridRef = useRef(null);
|
||||
const [reducedMotion, setReducedMotion] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const onChange = (event) => setReducedMotion(event.matches);
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
// React can drop src/autoPlay/loop from the DOM, but neither pauses a playing
|
||||
// element nor aborts its selected resource: a media element keeps its current
|
||||
// resource until the load algorithm is re-invoked, and `autoplay` only governs
|
||||
// the first play. So a visitor who turns Reduce Motion on mid-session would
|
||||
// otherwise keep every tile playing and downloading. Stop them for real.
|
||||
useEffect(() => {
|
||||
if (!reducedMotion || !gridRef.current) return;
|
||||
for (const video of gridRef.current.querySelectorAll("video")) {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
}
|
||||
}, [reducedMotion]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gridRef}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 260px), 1fr))",
|
||||
gap: "1rem",
|
||||
margin: "1.5rem 0",
|
||||
}}
|
||||
>
|
||||
{paths.map((path) => (
|
||||
<article
|
||||
key={path.href}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--border-color, rgba(128, 128, 128, 0.22))",
|
||||
borderRadius: "12px",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={reducedMotion ? undefined : path.video}
|
||||
poster={path.poster}
|
||||
autoPlay={!reducedMotion}
|
||||
muted
|
||||
loop={!reducedMotion}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
aspectRatio: "16 / 9",
|
||||
objectFit: "cover",
|
||||
background: "#000",
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: "0.9rem 1rem 1rem" }}>
|
||||
<strong style={{ display: "block", fontSize: "1rem", lineHeight: 1.35 }}>
|
||||
{path.title}
|
||||
</strong>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: "0.35rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: 1.5,
|
||||
opacity: 0.72,
|
||||
}}
|
||||
>
|
||||
{path.detail}
|
||||
</span>
|
||||
<a
|
||||
href={path.href}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
fontWeight: 600,
|
||||
color: "inherit",
|
||||
textDecoration: "none",
|
||||
}}
|
||||
>
|
||||
{path.action} →
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
577
docs/snippets/docs-video.jsx
Normal file
577
docs/snippets/docs-video.jsx
Normal file
@ -0,0 +1,577 @@
|
||||
export const DocsVideo = ({
|
||||
src,
|
||||
poster,
|
||||
title,
|
||||
autoPlay = false,
|
||||
loop = false,
|
||||
portrait = false,
|
||||
}) => {
|
||||
const videoRef = useRef(null);
|
||||
const playerRef = useRef(null);
|
||||
const hideTimerRef = useRef(null);
|
||||
const progressFrameRef = useRef(null);
|
||||
const [enhanced, setEnhanced] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [playbackRate, setPlaybackRate] = useState(1);
|
||||
const [controlsVisible, setControlsVisible] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [fullscreenSupported, setFullscreenSupported] = useState(false);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [scrubbing, setScrubbing] = useState(false);
|
||||
const [previewTime, setPreviewTime] = useState(0);
|
||||
const [previewPosition, setPreviewPosition] = useState(0);
|
||||
|
||||
const formatTime = (seconds) => {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remaining = Math.floor(seconds % 60);
|
||||
return `${minutes}:${String(remaining).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const clearHideTimer = () => {
|
||||
if (hideTimerRef.current) {
|
||||
window.clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const revealControls = () => {
|
||||
setControlsVisible(true);
|
||||
clearHideTimer();
|
||||
hideTimerRef.current = window.setTimeout(() => setControlsVisible(false), 2200);
|
||||
};
|
||||
|
||||
const togglePlayback = async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused || video.ended) {
|
||||
if (video.ended) video.currentTime = 0;
|
||||
setWaiting(true);
|
||||
try {
|
||||
await video.play();
|
||||
} catch {
|
||||
setWaiting(false);
|
||||
setPlaying(false);
|
||||
}
|
||||
} else {
|
||||
video.pause();
|
||||
setControlsVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.muted && video.volume === 0) video.volume = 0.8;
|
||||
video.muted = !video.muted;
|
||||
setMuted(video.muted);
|
||||
};
|
||||
|
||||
const seek = (event) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const nextTime = Number(event.target.value);
|
||||
video.currentTime = nextTime;
|
||||
setCurrentTime(nextTime);
|
||||
};
|
||||
|
||||
const updateScrubPreview = (event, seekMainVideo = false) => {
|
||||
if (!duration) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||
const nextTime = ratio * duration;
|
||||
setPreviewing(true);
|
||||
setPreviewTime(nextTime);
|
||||
setPreviewPosition(ratio * 100);
|
||||
|
||||
if (seekMainVideo) {
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.currentTime = nextTime;
|
||||
setCurrentTime(nextTime);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cyclePlaybackRate = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const rates = [1, 1.25, 1.5, 2];
|
||||
const currentIndex = rates.indexOf(video.playbackRate);
|
||||
const nextRate = rates[(currentIndex + 1) % rates.length];
|
||||
video.playbackRate = nextRate;
|
||||
setPlaybackRate(nextRate);
|
||||
};
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const player = playerRef.current;
|
||||
const video = videoRef.current;
|
||||
if (!player || typeof document === "undefined") return;
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
} else if (player.requestFullscreen) {
|
||||
await player.requestFullscreen();
|
||||
} else if (video?.webkitEnterFullscreen) {
|
||||
video.webkitEnterFullscreen();
|
||||
}
|
||||
} catch {
|
||||
// The normal inline player remains fully usable when fullscreen is blocked.
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyboard = (event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (event.key === " " || event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
togglePlayback();
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
video.currentTime = Math.max(0, video.currentTime - 5);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
video.currentTime = Math.min(duration || video.duration || 0, video.currentTime + 5);
|
||||
} else if (event.key.toLowerCase() === "m") {
|
||||
event.preventDefault();
|
||||
toggleMute();
|
||||
} else if (event.key.toLowerCase() === "f") {
|
||||
event.preventDefault();
|
||||
toggleFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setEnhanced(true);
|
||||
setFullscreenSupported(
|
||||
Boolean(playerRef.current?.requestFullscreen || videoRef.current?.webkitEnterFullscreen),
|
||||
);
|
||||
return () => {
|
||||
clearHideTimer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return undefined;
|
||||
const syncFullscreen = () => setFullscreen(document.fullscreenElement === playerRef.current);
|
||||
document.addEventListener("fullscreenchange", syncFullscreen);
|
||||
return () => document.removeEventListener("fullscreenchange", syncFullscreen);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
clearHideTimer();
|
||||
if (!playing) return undefined;
|
||||
hideTimerRef.current = window.setTimeout(() => setControlsVisible(false), 2200);
|
||||
return clearHideTimer;
|
||||
}, [playing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return undefined;
|
||||
const updateProgress = () => {
|
||||
const video = videoRef.current;
|
||||
if (video && !video.paused) setCurrentTime(video.currentTime);
|
||||
progressFrameRef.current = window.requestAnimationFrame(updateProgress);
|
||||
};
|
||||
progressFrameRef.current = window.requestAnimationFrame(updateProgress);
|
||||
return () => {
|
||||
if (progressFrameRef.current) window.cancelAnimationFrame(progressFrameRef.current);
|
||||
progressFrameRef.current = null;
|
||||
};
|
||||
}, [playing]);
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
const replaying = duration > 0 && currentTime >= duration - 0.15;
|
||||
return (
|
||||
<div className="hf-docs-video-block" data-portrait={portrait ? "true" : "false"}>
|
||||
<div
|
||||
ref={playerRef}
|
||||
className="hf-docs-video"
|
||||
role="region"
|
||||
aria-label={title}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboard}
|
||||
onPointerMove={revealControls}
|
||||
onPointerLeave={() => setControlsVisible(false)}
|
||||
onFocus={revealControls}
|
||||
onBlur={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) setControlsVisible(false);
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
aria-label={title}
|
||||
src={src}
|
||||
poster={poster}
|
||||
autoPlay={autoPlay}
|
||||
loop={loop}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
controls={!enhanced}
|
||||
onClick={togglePlayback}
|
||||
onDoubleClick={toggleFullscreen}
|
||||
onLoadedMetadata={(event) => {
|
||||
const nextDuration = event.currentTarget.duration || 0;
|
||||
setDuration(nextDuration);
|
||||
setMuted(event.currentTarget.muted);
|
||||
}}
|
||||
onDurationChange={(event) => setDuration(event.currentTarget.duration || 0)}
|
||||
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onPlaying={() => setWaiting(false)}
|
||||
onWaiting={() => setWaiting(true)}
|
||||
onCanPlay={() => setWaiting(false)}
|
||||
onEnded={() => {
|
||||
setPlaying(false);
|
||||
setControlsVisible(true);
|
||||
}}
|
||||
onVolumeChange={(event) => setMuted(event.currentTarget.muted)}
|
||||
/>
|
||||
|
||||
{enhanced && (
|
||||
<>
|
||||
{!playing && (currentTime <= 0.2 || replaying) && (
|
||||
<button
|
||||
type="button"
|
||||
className="hf-docs-video-hero-play"
|
||||
onClick={togglePlayback}
|
||||
aria-label={replaying ? "Replay video" : "Play video"}
|
||||
>
|
||||
<span className="hf-docs-video-hero-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M8 5.5v13l10-6.5z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{waiting && playing && <span className="hf-docs-video-spinner" aria-label="Loading" />}
|
||||
|
||||
<div
|
||||
className="hf-docs-video-controls"
|
||||
data-visible={controlsVisible ? "true" : "false"}
|
||||
>
|
||||
<div
|
||||
className="hf-docs-video-scrub-preview"
|
||||
data-visible={previewing ? "true" : "false"}
|
||||
style={{ "--hf-video-preview-x": `${previewPosition}%` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span>{formatTime(previewTime)}</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="hf-docs-video-progress"
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
step="0.01"
|
||||
value={Math.min(currentTime, duration || 0)}
|
||||
aria-label="Video progress"
|
||||
aria-valuetext={`${formatTime(currentTime)} of ${formatTime(duration)}`}
|
||||
onChange={seek}
|
||||
onPointerEnter={updateScrubPreview}
|
||||
onPointerMove={(event) =>
|
||||
updateScrubPreview(event, scrubbing || event.buttons === 1)
|
||||
}
|
||||
onPointerDown={(event) => {
|
||||
setScrubbing(true);
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
updateScrubPreview(event, true);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
setScrubbing(false);
|
||||
if (event.pointerType !== "mouse") setPreviewing(false);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
setScrubbing(false);
|
||||
setPreviewing(false);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (!scrubbing) setPreviewing(false);
|
||||
}}
|
||||
style={{ "--hf-video-progress": `${progress}%` }}
|
||||
/>
|
||||
|
||||
<div className="hf-docs-video-control-row">
|
||||
<button
|
||||
type="button"
|
||||
className="hf-docs-video-control"
|
||||
onClick={togglePlayback}
|
||||
aria-label={playing ? "Pause video" : "Play video"}
|
||||
>
|
||||
{playing ? (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 5h4v14H7zm6 0h4v14h-4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 5.5v13l10-6.5z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="hf-docs-video-control"
|
||||
onClick={toggleMute}
|
||||
aria-label={muted ? "Unmute video" : "Mute video"}
|
||||
>
|
||||
{muted ? (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 9v6h4l5 4V5L8 9zm11.5 1.1 1.4-1.4 1.6 1.6 1.6-1.6 1.4 1.4-1.6 1.6 1.6 1.6-1.4 1.4-1.6-1.6-1.6 1.6-1.4-1.4 1.6-1.6z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 9v6h4l5 4V5L8 9zm11 1.2v3.6c1-.5 1.7-1.5 1.7-2.8S16 10.7 15 10.2zm0-4v2.1c2.2.6 3.7 2.5 3.7 4.7s-1.5 4.1-3.7 4.7v2.1c3.3-.7 5.7-3.5 5.7-6.8S18.3 6.9 15 6.2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<span className="hf-docs-video-time" aria-hidden="true">
|
||||
{formatTime(currentTime)} <span>/</span> {formatTime(duration)}
|
||||
</span>
|
||||
|
||||
<span className="hf-docs-video-spacer" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="hf-docs-video-rate"
|
||||
onClick={cyclePlaybackRate}
|
||||
aria-label={`Playback speed ${playbackRate} times`}
|
||||
>
|
||||
{playbackRate}×
|
||||
</button>
|
||||
|
||||
{fullscreenSupported && (
|
||||
<button
|
||||
type="button"
|
||||
className="hf-docs-video-control"
|
||||
onClick={toggleFullscreen}
|
||||
aria-label={fullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 3H6v3H3v2h5zm8 0v5h5V6h-3V3zM3 16v2h3v3h2v-5zm13 0v5h2v-3h3v-2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 8h2V5h3V3H3zm13-5v2h3v3h2V3zM5 16H3v5h5v-2H5zm14 3h-3v2h5v-5h-2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShowcaseWall = () => {
|
||||
const CDN = "https://static.heygen.ai/hyperframes-oss/docs/images/showcase";
|
||||
const films = [
|
||||
{
|
||||
id: "grading",
|
||||
tile: "tile-grading-v2",
|
||||
full: "full-grading-4k-v2",
|
||||
title: "Colour grading and media effects",
|
||||
note: "Real footage graded with LUTs, curves, and scopes — inside Studio.",
|
||||
length: "48s",
|
||||
},
|
||||
{
|
||||
id: "variables",
|
||||
title: "One shoot, many cuts",
|
||||
note: "The same footage rendered as several vertical videos from one project.",
|
||||
length: "44s",
|
||||
},
|
||||
{
|
||||
id: "music",
|
||||
title: "Cut to the music",
|
||||
note: "A track analysed for beats and sections, then everything snapped to that grid.",
|
||||
length: "90s",
|
||||
},
|
||||
{
|
||||
id: "prvideo",
|
||||
title: "A pull request, explained",
|
||||
note: "A code change turned into a review anyone on the team can watch.",
|
||||
length: "32s",
|
||||
},
|
||||
{
|
||||
id: "timeline",
|
||||
title: "Editing on a timeline",
|
||||
note: "Trimming, splitting, and retiming a project by hand.",
|
||||
length: "34s",
|
||||
},
|
||||
{
|
||||
id: "hypecard",
|
||||
title: "A year in review",
|
||||
note: "Personal data turned into a shareable card, generated per person.",
|
||||
length: "27s",
|
||||
},
|
||||
];
|
||||
const [openId, setOpenId] = useState(null);
|
||||
// Lazy initializer, not a post-mount effect: with useState(false) the first
|
||||
// committed render emits <video src autoPlay loop> and only then drops the
|
||||
// attributes, so a reduce-motion visitor has already started fetching every
|
||||
// tile. autoPlay also overrides preload="metadata", and removing src without
|
||||
// a following load() is not a reliable abort. CSS cannot reach any of this.
|
||||
const wallRef = useRef(null);
|
||||
const [reduced, setReduced] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const onChange = (event) => setReduced(event.matches);
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
// React can drop src/autoPlay/loop from the DOM, but neither pauses a playing
|
||||
// element nor aborts its selected resource: a media element keeps its current
|
||||
// resource until the load algorithm is re-invoked, and `autoplay` only governs
|
||||
// the first play. So a visitor who turns Reduce Motion on mid-session would
|
||||
// otherwise keep every tile playing and downloading. Stop them for real.
|
||||
useEffect(() => {
|
||||
if (!reduced || !wallRef.current) return;
|
||||
for (const video of wallRef.current.querySelectorAll("video")) {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
}
|
||||
}, [reduced]);
|
||||
|
||||
const open = films.find((film) => film.id === openId) || null;
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
<div style={{ margin: "1.5rem 0" }}>
|
||||
<DocsVideo
|
||||
key={open.id}
|
||||
title={open.title}
|
||||
src={`${CDN}/${open.full || `full-${open.id}`}.mp4`}
|
||||
poster={`${CDN}/${open.tile || `tile-${open.id}`}.jpg`}
|
||||
autoPlay
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: "1rem",
|
||||
marginTop: "0.6rem",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: "0.875rem", opacity: 0.75 }}>
|
||||
<strong>{open.title}</strong> · {open.length} · made with HyperFrames
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId(null)}
|
||||
style={{
|
||||
fontSize: "0.8125rem",
|
||||
padding: "0.35rem 0.7rem",
|
||||
borderRadius: "7px",
|
||||
border: "1px solid currentColor",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
opacity: 0.7,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
← Back to all films
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wallRef}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||
gap: "0.85rem",
|
||||
margin: "1.5rem 0",
|
||||
}}
|
||||
>
|
||||
{films.map((film) => (
|
||||
<button
|
||||
key={film.id}
|
||||
type="button"
|
||||
onClick={() => setOpenId(film.id)}
|
||||
aria-label={`Play ${film.title}, ${film.length}`}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: 0,
|
||||
border: "1px solid rgba(128,128,128,0.28)",
|
||||
borderRadius: "11px",
|
||||
overflow: "hidden",
|
||||
background: "transparent",
|
||||
color: "inherit",
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<video
|
||||
src={reduced ? undefined : `${CDN}/${film.tile || `tile-${film.id}`}.mp4`}
|
||||
poster={`${CDN}/${film.tile || `tile-${film.id}`}.jpg`}
|
||||
autoPlay={!reduced}
|
||||
muted
|
||||
loop={!reduced}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{
|
||||
width: "100%",
|
||||
aspectRatio: "16 / 9",
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
background: "#000",
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ display: "block", padding: "0.7rem 0.85rem 0.85rem" }}>
|
||||
<span style={{ display: "block", fontWeight: 600, fontSize: "0.9375rem" }}>
|
||||
{film.title}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.8125rem",
|
||||
opacity: 0.7,
|
||||
marginTop: "0.15rem",
|
||||
}}
|
||||
>
|
||||
{film.note}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.75rem",
|
||||
opacity: 0.55,
|
||||
marginTop: "0.35rem",
|
||||
}}
|
||||
>
|
||||
▶ Play · {film.length}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
79
docs/snippets/quickstart-continuation-grid.jsx
Normal file
79
docs/snippets/quickstart-continuation-grid.jsx
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Three equally valid ways to continue from the first playable version.
|
||||
*
|
||||
* The choices are intentionally not links: discovery happens only at the end
|
||||
* of the page.
|
||||
*/
|
||||
export const QuickstartContinuationGrid = () => {
|
||||
const paths = [
|
||||
{
|
||||
title: "Ask the agent",
|
||||
detail: "Revise the project or render it in the same chat.",
|
||||
instruction: "Make the title larger, then render another version.",
|
||||
},
|
||||
{
|
||||
title: "Open Studio",
|
||||
detail: "Point at the same project and make a visible edit.",
|
||||
instruction: "npx hyperframes preview",
|
||||
},
|
||||
{
|
||||
title: "Use the CLI",
|
||||
detail: "Preview or render directly from the project folder.",
|
||||
instruction: "npx hyperframes render --output video.mp4",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ margin: "1.5rem 0" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 190px), 1fr))",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{paths.map((path) => (
|
||||
<article
|
||||
key={path.title}
|
||||
style={{
|
||||
padding: "0.8rem 0.85rem 0.9rem",
|
||||
border: "1px solid var(--border-color, rgba(128, 128, 128, 0.22))",
|
||||
borderRadius: "10px",
|
||||
}}
|
||||
>
|
||||
<strong style={{ display: "block", fontSize: "0.95rem", lineHeight: 1.35 }}>
|
||||
{path.title}
|
||||
</strong>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: "0.3rem",
|
||||
minHeight: "2.7em",
|
||||
fontSize: "0.825rem",
|
||||
lineHeight: 1.45,
|
||||
opacity: 0.72,
|
||||
}}
|
||||
>
|
||||
{path.detail}
|
||||
</span>
|
||||
<code
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: "0.65rem",
|
||||
padding: "0.55rem 0.65rem",
|
||||
overflowWrap: "anywhere",
|
||||
whiteSpace: "normal",
|
||||
fontSize: "0.72rem",
|
||||
lineHeight: 1.45,
|
||||
borderRadius: "8px",
|
||||
background: "var(--code-block-background, rgba(128, 128, 128, 0.1))",
|
||||
}}
|
||||
>
|
||||
{path.instruction}
|
||||
</code>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
131
docs/snippets/workflow-chooser.jsx
Normal file
131
docs/snippets/workflow-chooser.jsx
Normal file
@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The human workflow router.
|
||||
*
|
||||
* Every route begins with source material a person already recognizes. The
|
||||
* compact loops prove the kind of result before the click; the destination
|
||||
* guide carries the complete example and instructions.
|
||||
*/
|
||||
export const WorkflowChooser = () => {
|
||||
// Keep data inside the component. Mintlify compiles only the named export and
|
||||
// drops module-level constants from snippet files.
|
||||
const CDN = "https://static.heygen.ai/hyperframes-oss/docs/images/showcase";
|
||||
const routes = [
|
||||
{
|
||||
title: "Show a product or website",
|
||||
bring: "Bring a URL, launch brief, or product story.",
|
||||
href: "/guides/product-launch-video",
|
||||
video: `${CDN}/wfv2-product-launch.mp4`,
|
||||
poster: `${CDN}/wfv2-product-launch.jpg`,
|
||||
},
|
||||
{
|
||||
title: "Explain an idea",
|
||||
bring: "Bring notes, an article, a script, or one rough thought.",
|
||||
href: "/guides/faceless-explainer",
|
||||
video: `${CDN}/wfv2-explainer.mp4`,
|
||||
poster: `${CDN}/wfv2-explainer.jpg`,
|
||||
},
|
||||
{
|
||||
title: "Work with existing footage",
|
||||
bring: "Bring a talking-head, interview, or podcast clip.",
|
||||
href: "/guides/captions-and-recuts",
|
||||
video: `${CDN}/wfv2-captions.mp4`,
|
||||
poster: `${CDN}/wfv2-captions.jpg`,
|
||||
},
|
||||
{
|
||||
title: "Explain a pull request",
|
||||
bring: "Bring a GitHub pull request link.",
|
||||
href: "/guides/pr-to-video",
|
||||
video: `${CDN}/wfv2-pr.mp4`,
|
||||
poster: `${CDN}/wfv2-pr.jpg`,
|
||||
},
|
||||
{
|
||||
title: "Make a short motion graphic",
|
||||
bring: "Bring a message, number, quote, chart, or logo.",
|
||||
href: "/guides/motion-graphics",
|
||||
video: `${CDN}/wfv2-motion.mp4`,
|
||||
poster: `${CDN}/wfv2-motion.jpg`,
|
||||
},
|
||||
{
|
||||
title: "Cut to music",
|
||||
bring: "Bring a track and any photos or video you want to use.",
|
||||
href: "/guides/music-to-video",
|
||||
video: `${CDN}/wfv2-music.mp4`,
|
||||
poster: `${CDN}/wfv2-music.jpg`,
|
||||
},
|
||||
{
|
||||
title: "Build a presentation",
|
||||
bring: "Bring an outline, pitch, report, or existing deck.",
|
||||
href: "/guides/slideshow",
|
||||
video: "https://static.heygen.ai/hyperframes-oss/docs/images/showcase/wfv2-slideshow.mp4",
|
||||
poster: "https://static.heygen.ai/hyperframes-oss/docs/images/showcase/wfv2-slideshow.jpg",
|
||||
},
|
||||
{
|
||||
title: "Direct a custom video",
|
||||
bring: "Bring the outcome you want and whatever source material you have.",
|
||||
href: "/guides/general-video",
|
||||
video: `${CDN}/wfv2-general.mp4`,
|
||||
poster: `${CDN}/wfv2-general.jpg`,
|
||||
},
|
||||
];
|
||||
|
||||
// Lazy initializer, not a post-mount effect: with useState(false) the first
|
||||
// committed render emits <video src autoPlay loop> and only then drops the
|
||||
// attributes, so a reduce-motion visitor has already started fetching every
|
||||
// tile. autoPlay also overrides preload="metadata", and removing src without
|
||||
// a following load() is not a reliable abort. CSS cannot reach any of this.
|
||||
const gridRef = useRef(null);
|
||||
const [reducedMotion, setReducedMotion] = useState(
|
||||
() =>
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const onChange = (event) => setReducedMotion(event.matches);
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
// React can drop src/autoPlay/loop from the DOM, but neither pauses a playing
|
||||
// element nor aborts its selected resource: a media element keeps its current
|
||||
// resource until the load algorithm is re-invoked, and `autoplay` only governs
|
||||
// the first play. So a visitor who turns Reduce Motion on mid-session would
|
||||
// otherwise keep every tile playing and downloading. Stop them for real.
|
||||
useEffect(() => {
|
||||
if (!reducedMotion || !gridRef.current) return;
|
||||
for (const video of gridRef.current.querySelectorAll("video")) {
|
||||
video.pause();
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
}
|
||||
}, [reducedMotion]);
|
||||
|
||||
return (
|
||||
<div className="hf-workflow-routes" ref={gridRef}>
|
||||
{routes.map((route) => (
|
||||
<a
|
||||
key={route.href}
|
||||
href={route.href}
|
||||
aria-label={`${route.title}. ${route.bring}`}
|
||||
className="hf-workflow-route"
|
||||
>
|
||||
<video
|
||||
src={reducedMotion ? undefined : route.video}
|
||||
poster={route.poster}
|
||||
autoPlay={!reducedMotion}
|
||||
muted
|
||||
loop={!reducedMotion}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
<span className="hf-workflow-route-title">{route.title}</span>
|
||||
<span className="hf-workflow-route-copy">{route.bring}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
examples/docs-reference-project/.gitignore
vendored
Normal file
8
examples/docs-reference-project/.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
.work/
|
||||
.hyperframes/
|
||||
.transcode-cache/
|
||||
node_modules/
|
||||
renders/
|
||||
capture/
|
||||
live/
|
||||
snapshots/
|
||||
88
examples/docs-reference-project/BRIEF.md
Normal file
88
examples/docs-reference-project/BRIEF.md
Normal file
@ -0,0 +1,88 @@
|
||||
---
|
||||
workflow: product-launch-video
|
||||
flow: automation
|
||||
storyboard: no
|
||||
message: "example.com is the domain you are allowed to use in documentation examples."
|
||||
audience: "Someone meeting the domain for the first time — usually a developer writing docs."
|
||||
destination: "Docs page hero — embedded, 16:9"
|
||||
aspect: "16:9"
|
||||
format: 1920x1080
|
||||
length: "10s"
|
||||
language: en
|
||||
angle: "Show it as it is. The real captured page is the proof; the copy is the page's own language."
|
||||
voice: "River (ElevenLabs, SAz9YHcvj6GT2YYXdXww) — calm, unhurried, low-key confidence"
|
||||
music: "gentle warm piano bed, quiet under the narration"
|
||||
captions: "phrase-level, fixed lower band, one group at a time"
|
||||
style_preset: "derived from the live capture (no preset remix — the site's own three tokens are the palette)"
|
||||
---
|
||||
|
||||
# Brief — a 10-second product intro for example.com
|
||||
|
||||
## Intent
|
||||
|
||||
One request: _Using `/hyperframes`, make a 10-second product intro for
|
||||
`https://example.com`._
|
||||
|
||||
The intent layer confirmed source, length and aspect, then asked the only thing the
|
||||
request did not settle — **sell it, or show it as it is.** Answer: show it. So the
|
||||
site's own captured page is the hero asset, and every word on screen is the page's own
|
||||
language rather than marketing written over it.
|
||||
|
||||
## The factual source
|
||||
|
||||
`https://example.com` is a two-sentence page. Captured 2026-08-03 with
|
||||
`npx hyperframes capture`:
|
||||
|
||||
| Element | Verbatim text |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `<h1>` | Example Domain |
|
||||
| `<p>` | This domain is for use in documentation examples without needing permission. Avoid use in operations. |
|
||||
| `<a>` | Learn more → `https://iana.org/domains/example` |
|
||||
|
||||
Brand tokens read off the live page, not invented:
|
||||
canvas `#EEEEEE`, ink `#000000` at `opacity: .8`, link/accent `#334488`, type
|
||||
`system-ui, sans-serif`.
|
||||
|
||||
## Structure — one scene, 10.0s
|
||||
|
||||
Narration is the spine; every reveal is cued to a spoken phrase. Timings below are the
|
||||
**measured** word timings from the narration, not estimates.
|
||||
|
||||
| t | beat |
|
||||
| ----------- | ---------------------------------------------------------------------- |
|
||||
| 0.10 → 1.00 | the accent rule draws; the frame asserts itself |
|
||||
| 1.00 → 1.68 | "Example Domain" rises out of a mask — VO: _"This is…"_ |
|
||||
| 1.55 → 2.33 | brand rule draws, then the address in mono — VO: _"…example.com."_ |
|
||||
| 2.66 → 3.51 | the real captured page travels in at exactly 1:1 — VO: _"The domain…"_ |
|
||||
| 4.55 → 5.15 | an accent marker draws down the page's own words — VO: _"…examples."_ |
|
||||
| 5.15 → 6.42 | held read — the composition reads still while the VO carries |
|
||||
| 6.42 → 7.10 | the supporting line lands — VO: _"…no permission needed."_ |
|
||||
| 8.03 → 10.0 | captions clear; the frame holds as a clean, still end card |
|
||||
|
||||
## Assets
|
||||
|
||||
| Asset | Provenance |
|
||||
| ------------------------ | ------------------------------------------------------------------------------ |
|
||||
| `assets/example-com.png` | `npx hyperframes capture https://example.com` |
|
||||
| `assets/narration.wav` | ElevenLabs **River** `SAz9YHcvj6GT2YYXdXww`, via `/media-use` |
|
||||
| `assets/bgm.wav` | HeyGen audio catalog, 10.0s bed, ingested from the v1 project via `/media-use` |
|
||||
| `assets/sfx-whoosh.mp3` | `/media-use` bundled library — `whoosh-short`, 0.57s |
|
||||
| `assets/sfx-tick.mp3` | `/media-use` bundled library — `click-soft`, 0.37s |
|
||||
|
||||
## Design truth
|
||||
|
||||
- The page is `#EEEEEE`. The composition canvas is one step down (`#E9E9E7`) so the
|
||||
captured plate reads as a **lifted surface** instead of dissolving into the ground.
|
||||
That is the one derived value; everything else is the page's own.
|
||||
- Inter 900 / 400 for the lockup, IBM Plex Mono 400 for the address. Both are in the
|
||||
renderer's embedded bundle at exactly those weights, so type is byte-identical in
|
||||
preview and render.
|
||||
- The plate never scales and never rotates. A 1x capture has no headroom above 1:1;
|
||||
it travels on `x` only, so the page's own 16px type renders pixel-exact.
|
||||
|
||||
## Deliberately not in this video
|
||||
|
||||
- No CTA button. The page's one action is a plain text link that says "Learn more";
|
||||
drawing it as a pill with an arrow would be invented UI.
|
||||
- No sheen, no bloom, no drifting camera, no texture overlays. One accent device
|
||||
(the rule / bar / marker family) carries the whole graphic language.
|
||||
165
examples/docs-reference-project/README.md
Normal file
165
examples/docs-reference-project/README.md
Normal file
@ -0,0 +1,165 @@
|
||||
# example.com — 10-second product intro
|
||||
|
||||
The HyperFrames docs **Reference Project**. One real project, built end to end from one
|
||||
real request:
|
||||
|
||||
> Using `/hyperframes`, make a 10-second product intro for `https://example.com`.
|
||||
|
||||
**1920×1080 · 10.000s · 30 fps · 300 frames · one scene · one caption overlay.**
|
||||
|
||||
Everything in here is real: the plate is a live capture of `example.com`, the copy is
|
||||
the page's own wording, the narration is synthesised speech, and the caption timings are
|
||||
measured word timings from that narration. Nothing is mocked, and no command output in
|
||||
these files is invented.
|
||||
|
||||
---
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
bun run dev # Studio preview (long-running — keep it in the background)
|
||||
bun run check # lint + runtime + layout + motion + contrast, one command
|
||||
bun run render # → renders/video.mp4
|
||||
```
|
||||
|
||||
The scripts pin an exact CLI version so this project re-renders identically over time.
|
||||
To move the pin up: `npx hyperframes@latest upgrade --project . --check`, then drop
|
||||
`--check` to apply, then re-run `bun run check`.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
| ---------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `index.html` | the composition — root timeline, the whole scene, all four audio tracks |
|
||||
| `compositions/captions.html` | the caption overlay, wired as a sub-composition on track 2 |
|
||||
| `BRIEF.md` | the confirmed intent the workflow was handed |
|
||||
| `frame.md` | the design spec — palette, type, the plate rule, the caption skin |
|
||||
| `STORYBOARD.md` | the plan: `## Video direction` plus one time-coded shot sequence |
|
||||
| `SCRIPT.md` | locked narration, the voice, and the exact commands that regenerate it |
|
||||
| `VERIFICATION.md` | what passed, what changed from v1, and the two framework bugs found on the way |
|
||||
| `transcript.json` | Whisper word timings for `assets/narration.wav` — the source of every caption cue |
|
||||
|
||||
## The five variables
|
||||
|
||||
Declared on `<html>` as `data-composition-variables`, so the same composition can front
|
||||
another site without touching the HTML:
|
||||
|
||||
| id | type | default |
|
||||
| ---------------- | ------ | --------------------------------------------------------------- |
|
||||
| `title` | string | `Example Domain` |
|
||||
| `accent` | color | `#334488` |
|
||||
| `supportingLine` | string | `For use in documentation examples without needing permission.` |
|
||||
| `siteUrl` | string | `example.com` |
|
||||
| `pageImage` | string | `assets/example-com.png` |
|
||||
|
||||
They are wired **declaratively** — no `getVariables()` call anywhere in this project:
|
||||
|
||||
```html
|
||||
<h1 data-var-text="title">Example Domain</h1>
|
||||
<!-- text substitution -->
|
||||
<img data-var-src="pageImage" src="assets/example-com.png" />
|
||||
<!-- src substitution -->
|
||||
```
|
||||
|
||||
```css
|
||||
background: var(--accent, #334488); /* the runtime publishes every scalar variable
|
||||
as a --<id> custom property on the root */
|
||||
```
|
||||
|
||||
Override at render time:
|
||||
|
||||
```bash
|
||||
npx hyperframes render --variables '{"title":"Acme Docs","accent":"#0f766e"}'
|
||||
npx hyperframes render --variables-file rows.json --strict-variables
|
||||
```
|
||||
|
||||
Two gotchas worth knowing, both learned here:
|
||||
|
||||
1. **Keep the `data-composition-variables` JSON pure ASCII.** It sits on `<html>`, which
|
||||
is consumed before `<meta charset>` takes effect, so a literal em dash in a `default`
|
||||
renders as `â€"`. Use the JSON escape `\u2014` instead — verified by snapshot. Body text
|
||||
and sub-composition text are unaffected.
|
||||
2. **Put `data-var-src` before `src`.** `lint`'s `missing_local_asset` scan matches the
|
||||
last `src=` in the tag, and `data-var-src` also ends in `src`, so the reverse order
|
||||
makes it read the variable id as a filename and fail.
|
||||
|
||||
## Audio
|
||||
|
||||
Four tracks, each a plain `<audio>` element — the framework owns playback, so this
|
||||
project never calls `play()`, `pause()` or seeks.
|
||||
|
||||
| Track | Element | Asset | Baseline | Notes |
|
||||
| ----- | ------------- | ---------------- | -------- | ------------------------------------------ |
|
||||
| 8 | `#bgm` | `bgm.wav` | `0.34` | ducked / recovered / faded on the timeline |
|
||||
| 9 | `#vo` | `narration.wav` | `1.0` | starts at `1.00s`, runs `6.97s` |
|
||||
| 10 | `#sfx-plate` | `sfx-whoosh.mp3` | `0.20` | at `2.62s`, under the plate's travel |
|
||||
| 11 | `#sfx-marker` | `sfx-tick.mp3` | `0.16` | at `4.54s`, on the marker draw |
|
||||
|
||||
<!-- The two WAVs are the only assets over the repository's 500 KB non-LFS
|
||||
limit, so they are the only ones stored as pointers. -->
|
||||
|
||||
Both WAVs are Git LFS pointers, so **run `git lfs pull` before rendering**. Without
|
||||
it the bed and the voiceover are 130-byte stubs and the captions — timed from
|
||||
`narration.wav` — play over silence. The two MP3 stings and the capture PNG are
|
||||
stored plainly and need no extra step.
|
||||
|
||||
The bed is not a static level. Volume is **keyframed on the timeline**, which the runtime
|
||||
probes and applies identically in preview and render:
|
||||
|
||||
```js
|
||||
tl.to("#bgm", { volume: 0.13, duration: 0.6 }, 0.7); // duck under the voice
|
||||
tl.to("#bgm", { volume: 0.3, duration: 0.9 }, 7.7); // recover after the last word
|
||||
tl.to("#bgm", { volume: 0, duration: 1.0 }, 9.0); // out under the end card
|
||||
```
|
||||
|
||||
`data-volume` is only the baseline for elements no tween touches.
|
||||
|
||||
## Captions
|
||||
|
||||
`compositions/captions.html`, mounted on track 2 for the full 10s. It follows the
|
||||
captions overlay doctrine literally, and the discipline is worth copying:
|
||||
|
||||
- **Three groups for three spoken sentences.** Phrase-level, not word-level churn.
|
||||
- **Fixed position, always.** One full-width absolute container, `text-align: center`,
|
||||
`bottom: 64px`. No `left: 50% + translateX(-50%)` (it clips at canvas edges), no
|
||||
per-group placement, no random offsets.
|
||||
- **A structurally reserved band.** `index.html`'s stage ends at `bottom: var(--band)`
|
||||
(`184px` = 17%), so nothing in the artwork can ever collide with a caption. Verified
|
||||
with `check --caption-zone "x0=0;y0=.83;x1=1;y1=1;severity=error;…"`.
|
||||
- **One group visible at a time, provably.** Group _n_'s hard kill sits at the exact time
|
||||
group _n+1_ starts, so before that instant only _n_ can be non-zero and after it _n_ is
|
||||
killed outright.
|
||||
- **Emphasis by luminance only.** Each word lights `#9a9a9a → #ffffff` on its own
|
||||
measured onset and stays lit, so the phrase fills in with the voice. No scale pop, no
|
||||
colour flash, no scatter exit, no marker effects, no labels.
|
||||
- **`fitTextFontSize` as the overflow guard**, so an edited or translated phrase shrinks
|
||||
instead of wrapping up out of the band.
|
||||
|
||||
## Media provenance
|
||||
|
||||
The project keeps only the assets used by the composition:
|
||||
|
||||
| Shipped asset | Source |
|
||||
| ------------------------ | ----------------------------------------------------------- |
|
||||
| `assets/example-com.png` | `npx hyperframes capture https://example.com` |
|
||||
| `assets/narration.wav` | ElevenLabs **River**, `eleven_multilingual_v2` |
|
||||
| `assets/bgm.wav` | HeyGen audio catalog, 10.0-second bed |
|
||||
| `assets/sfx-whoosh.mp3` | `/media-use` bundled library — `whoosh-short`, 0.57 seconds |
|
||||
| `assets/sfx-tick.mp3` | `/media-use` bundled library — `click-soft`, 0.37 seconds |
|
||||
|
||||
Generated capture folders, frame dumps, contact sheets, and compiled docs embeds are not
|
||||
committed. The docs copy of the verified render is served from the HyperFrames media CDN.
|
||||
|
||||
## Learning path through the composition
|
||||
|
||||
Read `index.html` top to bottom; it is ordered deliberately:
|
||||
|
||||
1. `data-composition-variables` on `<html>` — the parameters.
|
||||
2. `#root` custom properties — the palette, and the `--band` reservation.
|
||||
3. Track 0 `#bg` — why a full-bleed fill rides on a clip layer and never on `#root`.
|
||||
4. Track 1 `#stage` — a two-column flex stage that stops above the caption band.
|
||||
5. `.page` — `object-fit: none` + `object-position`, the 1:1 plate rule.
|
||||
6. Track 2 — the sub-composition host, and the three ids that must match exactly.
|
||||
7. The `<audio>` elements — one per track, `<video>`-free, framework-owned.
|
||||
8. The timeline — load states first, then one tween per narration cue, with the two
|
||||
deliberate holds left visibly empty.
|
||||
70
examples/docs-reference-project/SCRIPT.md
Normal file
70
examples/docs-reference-project/SCRIPT.md
Normal file
@ -0,0 +1,70 @@
|
||||
# SCRIPT — example.com intro
|
||||
|
||||
**Voice:** River — ElevenLabs, voice id `SAz9YHcvj6GT2YYXdXww`
|
||||
**Model:** `eleven_multilingual_v2` · `mp3_44100_128` → 44.1 kHz mono WAV
|
||||
**Voice direction:** Calm and unhurried. Low-key confidence, no sell. Read it the way
|
||||
you would read a footnote you happen to find interesting.
|
||||
|
||||
The whole script is **one** synthesis call so the sentence-to-sentence prosody is real
|
||||
rather than three clips butted together. The `**Time:**` values below are not
|
||||
estimates — they are the measured word timings from
|
||||
`npx hyperframes transcribe assets/narration.wav --model small.en`, offset by the
|
||||
narration clip's `data-start` of `1.00s`.
|
||||
|
||||
---
|
||||
|
||||
## Line 1 — Name it (Frame 1)
|
||||
|
||||
**Time:** 1.10 – 2.81s
|
||||
**Delivery:** Flat, factual. The period is real; let it land.
|
||||
|
||||
This is example dot com.
|
||||
|
||||
## Line 2 — What it is for (Frame 1)
|
||||
|
||||
**Time:** 2.81 – 5.38s
|
||||
**Delivery:** Slightly warmer. "Reserved" carries the sentence.
|
||||
|
||||
The domain reserved for documentation examples.
|
||||
|
||||
## Line 3 — The payoff (Frame 1)
|
||||
|
||||
**Time:** 5.48 – 7.68s
|
||||
**Delivery:** The lift of the piece, but quiet. Space before "no permission needed."
|
||||
|
||||
Use it in your docs — no permission needed.
|
||||
|
||||
---
|
||||
|
||||
## Spoken vs. captioned
|
||||
|
||||
One deliberate divergence: the TTS text says **"example dot com"** because that is how
|
||||
the string is pronounced; the caption reads **"example.com"** because that is how the
|
||||
string is written. Whisper transcribed the spoken form back as `example.com,`, which is
|
||||
why the caption group's word timing for it spans a full 1.27s.
|
||||
|
||||
## Regenerating
|
||||
|
||||
```bash
|
||||
# 1 — synthesize (ElevenLabs, via the /media-use audio engine)
|
||||
node -e '
|
||||
import("'"$HOME"'/.claude/skills/media-use/audio/scripts/lib/tts.mjs").then(m =>
|
||||
m.synthesizeOne({
|
||||
provider: "elevenlabs",
|
||||
voiceId: "SAz9YHcvj6GT2YYXdXww",
|
||||
text: "This is example dot com. The domain reserved for documentation examples. Use it in your docs — no permission needed.",
|
||||
wavAbs: process.cwd() + "/.work/narration.raw",
|
||||
hyperframesDir: process.cwd(),
|
||||
}).then(r => console.log(r)))'
|
||||
|
||||
# 2 — normalize to 44.1k mono PCM
|
||||
ffmpeg -y -i .work/narration.raw -ac 1 -ar 44100 -c:a pcm_s16le assets/narration.wav
|
||||
|
||||
# 3 — word timings for the caption groups
|
||||
npx hyperframes transcribe assets/narration.wav --model small.en
|
||||
```
|
||||
|
||||
Step 3 rewrites `.work/transcript.json`. If the timings move, the caption group
|
||||
boundaries in `compositions/captions.html` and the `WORDS` table in this file's
|
||||
consumer must move with them — they are hand-committed on purpose so the composition
|
||||
has no build step.
|
||||
89
examples/docs-reference-project/STORYBOARD.md
Normal file
89
examples/docs-reference-project/STORYBOARD.md
Normal file
@ -0,0 +1,89 @@
|
||||
---
|
||||
format: 1920x1080
|
||||
duration: 10s
|
||||
message: "example.com is the domain you are allowed to use in documentation examples."
|
||||
arc: Name → Purpose → Proof → Permission
|
||||
audience: "A developer writing documentation who needs a safe example domain"
|
||||
mode: autonomous
|
||||
music: "gentle warm piano bed, quiet under the narration"
|
||||
captions: "phrase-level, fixed lower band"
|
||||
---
|
||||
|
||||
## Video direction
|
||||
|
||||
**Palette system** — from `frame.md`. Canvas `#E9E9E7`; the captured plate keeps the
|
||||
page's own `#EEEEEE`; ink `#1B1B1B`; supporting copy `#5A5C63`; one accent `#334488`
|
||||
used _only_ for the three rule instances (top rule · brand bar · page marker) and the
|
||||
address. No fourth accent use.
|
||||
|
||||
**Motion grammar + reveal model** — every reveal is cued to a measured word timing from
|
||||
`assets/narration.wav`. Long-tail eases only: `power4.out` for the two structural
|
||||
reveals (title, plate), `power3.out` for copy, `expo.out` for rules drawing. Nothing
|
||||
front-loads: at t=0 the frame holds one 6px rule and nothing else, and the last reveal
|
||||
lands at 7.10s — inside the final 30%.
|
||||
|
||||
**Rhythm / held-frame allocation** — two deliberate holds. `5.15 → 6.42` is a mid-film
|
||||
breather: everything visible has resolved and the narration carries alone. `8.03 → 10.0`
|
||||
is the end card: the caption band clears and the frame goes completely still. Neither
|
||||
hold drifts, breathes, or pans. A held read beats bad motion, and the still tail
|
||||
doubles as the poster frame for docs embeds.
|
||||
|
||||
**Negative list** — no CTA button (the page's action is a text link, not a pill); no
|
||||
sheen sweep; no ambient bloom; no grid or paper texture; no gradient mesh; no drifting
|
||||
or breathing camera; no plate scale or 3D rotation of any kind (a 1x capture has no
|
||||
headroom above 1:1); no per-word caption scale-pop, colour flashing, or scatter exit;
|
||||
no label that repeats something already on screen. Both failure modes are banned by
|
||||
name: **slideshow** (front-load then freeze) and **screensaver** (everything floating
|
||||
independently).
|
||||
|
||||
---
|
||||
|
||||
## Frame 1 — example.com, as it is
|
||||
|
||||
- scene: The name in heavy type beside the real captured page, at 1:1
|
||||
- duration: 10s
|
||||
- poster: 9s
|
||||
- transition_in: cut
|
||||
- status: animated
|
||||
- voiceover: "This is example dot com. The domain reserved for documentation examples. Use it in your docs — no permission needed."
|
||||
- src: index.html
|
||||
- blueprint: device-surface-showcase (Adapt)
|
||||
- focal: assets/example-com.png
|
||||
- roles: example-com.png = supporting surface (the evidence) · type lockup = primary
|
||||
- sfx: whoosh-short @2.62 (plate travels in) · click-soft @4.54 (marker draws)
|
||||
|
||||
**Adapt:** keep device-surface-showcase's signature move — _the captured surface itself
|
||||
is the proof, delivered as a lifted plate_ — but drop the blueprint's push-in entirely.
|
||||
The blueprint assumes a surface with zoom headroom; this one is a 1x screenshot, so the
|
||||
push-in is replaced by a translate-only entrance and the plate is then read _in place_
|
||||
by the accent marker. The film moves the eye, not the camera.
|
||||
|
||||
Scene 1 (0.00–1.00s): canvas and one soft upper-left light wash. A 6px accent rule
|
||||
draws left→right across the top edge in 0.9s and that is the only thing on screen.
|
||||
Full-bleed, one depth layer. Nothing else exists yet — the narration has not started.
|
||||
|
||||
Scene 2 (1.00–2.33s): as the VO says _"This is…"_, "Example Domain" rises out of a
|
||||
mask (two lines, 132px/900) in the left column. The 200×8 brand bar draws under it at
|
||||
1.55s, and the mono address rises at 1.78s exactly as the VO reaches _"…example.com."_
|
||||
Asymmetric 60/40, content in the left 640px, three depth layers (wash · type · rule).
|
||||
|
||||
Scene 3 (2.33–3.51s): on _"The domain…"_ the captured page travels in from the right on
|
||||
`x` only (140px → 0) and lands at exactly 1:1, 1000×400, right-aligned and
|
||||
vertically centred against the lockup. `whoosh-short` sits under the travel. The plate
|
||||
lifts off the canvas on a single soft shadow; it does not tilt and it does not scale.
|
||||
|
||||
Scene 4 (3.51–5.15s): held while the VO reads _"…reserved for documentation…"_, then on
|
||||
_"…examples."_ a 5px accent marker draws top→bottom beside the page's own paragraph,
|
||||
inside the plate. `click-soft` on the draw. This is the film's argument — the words
|
||||
being quoted are the page's, not ours. Nothing else changes.
|
||||
|
||||
Scene 5 (5.15–6.42s): **held read.** Everything on screen has resolved. The VO carries
|
||||
_"Use it in your docs…"_ alone. No motion at all.
|
||||
|
||||
Scene 6 (6.42–7.10s): on _"…no permission needed."_ the supporting line rises into the
|
||||
left column under the address, in muted ink at 38px. Final reveal, final 30% of the
|
||||
shot.
|
||||
|
||||
Scene 7 (7.10–10.00s): the last caption group plays out and clears at 8.03s. From 8.03
|
||||
the frame is a completely still end card — lockup, plate, marker, no captions. This is
|
||||
the frame every docs page will screenshot.
|
||||
285
examples/docs-reference-project/VERIFICATION.md
Normal file
285
examples/docs-reference-project/VERIFICATION.md
Normal file
@ -0,0 +1,285 @@
|
||||
# Verification report — v1 → v2
|
||||
|
||||
Project: `examples/docs-reference-project` · **1920×1080 · 10.000s · 30 fps · 300 frames**
|
||||
CLI: `hyperframes@0.7.90` (project pin bumped from `0.7.88` during this pass and
|
||||
re-verified — see "Toolchain" below).
|
||||
|
||||
---
|
||||
|
||||
## 1. Gate results
|
||||
|
||||
All commands were run in the project directory. These are the actual results.
|
||||
|
||||
| Gate | Result |
|
||||
| ----------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `npx hyperframes lint --verbose` | **0 errors, 0 warnings** (2 files scanned) |
|
||||
| `npx hyperframes check` | **passed** — `ok: true` |
|
||||
| › lint | 0 errors · 0 warnings · 1 info |
|
||||
| › runtime | 0 errors · 0 warnings · 0 info |
|
||||
| › layout | 0 errors · 0 warnings · 1 info |
|
||||
| › motion | 0 findings (no `*.motion.json` sidecars in this project) |
|
||||
| › contrast | 0 errors · 0 warnings · 0 info |
|
||||
| `check --caption-zone "…y0=.83…severity=error"` | **passed** — 0 caption-band collisions across 8 seek points |
|
||||
| `npx hyperframes snapshot --at …` (19 frames) | captured + inspected; 3 contact sheets |
|
||||
| `npx hyperframes render` | **passed** — H.264 1080p + AAC stereo, exactly 10.000s |
|
||||
|
||||
Final media inspection: 1920×1080 H.264, 30 fps, AAC stereo at 48 kHz,
|
||||
10.000 seconds. Mean volume is −22.9 dB and the peak is −3.8 dB.
|
||||
|
||||
### The two info-level findings, and why they stay
|
||||
|
||||
Neither gates the exit code. Both are deliberate.
|
||||
|
||||
1. `pointer_events_none` on `compositions/captions.html` → `#root`.
|
||||
The caption overlay spans the whole canvas above the artwork, so its root must be
|
||||
click-through or nothing underneath is selectable in Studio. The pills themselves
|
||||
carry `pointer-events: auto`, so the editable content _is_ selectable — which is
|
||||
exactly what the finding's own fix hint asks for. Keeping `pointer-events: none` is
|
||||
correct; the alternative is an invisible full-canvas div that eats every click.
|
||||
|
||||
2. `container_overflow` on `#title` at `t=0.556`, inside `span.title-mask`.
|
||||
This is the mask doing its job: the title starts at `yPercent: 106` and rises into
|
||||
view, so for the first ~0.7s its box is below the mask it is clipped by. Marking the
|
||||
mask `data-layout-allow-overflow` would silence it, but that attribute is inherited
|
||||
and would also disable `text-clipping`, `content-cramped-container` and
|
||||
`foreground-over-panel` on the hero title for the whole composition. A transient info
|
||||
finding is the cheaper price. The finding is reported once, at one sample.
|
||||
|
||||
### What "inspected snapshots" means here
|
||||
|
||||
19 frames on the **30 fps frame grid** (not arbitrary decimals), chosen to cover every
|
||||
beat plus both caption cuts and the caption clear:
|
||||
|
||||
`0 · 0.533 · 1.3 · 1.967 · 2.6 · 2.8 · 2.867 · 3.133 · 4.5 · 5.0 · 5.433 · 5.5 · 5.733 · 6.4 · 7.1 · 7.933 · 8.067 · 9.5 · 9.967`
|
||||
|
||||
The root declares `data-fps="30"`, so those are real frame times. Requesting an
|
||||
off-grid time (e.g. `2.833`) quantises to the nearest frame and renders `2.800` twice —
|
||||
which briefly looked like a caption bleed until it was measured. It was not one.
|
||||
|
||||
Checked and confirmed:
|
||||
|
||||
- The plate lands at 1:1 and the page's real 16px body copy is legible — the headline,
|
||||
the full sentence, and the `Learn more` link all read.
|
||||
- The accent marker draws beside the page's own paragraph without touching the link
|
||||
below it (6px clearance, measured).
|
||||
- Both caption cuts hand off cleanly. At `2.800` only group 0 is drawn (fading out); at
|
||||
`2.867` only group 1 (fading in). Same at `5.433` / `5.500`. This also holds by
|
||||
construction: group _n_'s hard kill sits at exactly group _n+1_'s start, so before that
|
||||
instant only _n_ can be non-zero and at/after it _n_ is set to `opacity: 0;
|
||||
visibility: hidden`.
|
||||
- Captions clear by `8.033s`: sampling the whole caption band at `8.067s` gives a
|
||||
darkest pixel of `233` — pure canvas, nothing drawn. The frame then holds a still,
|
||||
caption-free end card through the final frame at `9.967s`.
|
||||
- No black frame, no blank panel, no clipped text, no element in the caption band.
|
||||
|
||||
`snapshot`'s optional Gemini frame-description pass failed (`API key not valid`) — the
|
||||
ambient `GEMINI_API_KEY` is rejected. That is an optional annotation, not a gate; the
|
||||
empty `descriptions.md` was deleted rather than shipped as a wall of identical errors.
|
||||
Frames were inspected directly.
|
||||
|
||||
---
|
||||
|
||||
## 2. v1 → v2
|
||||
|
||||
v1 is the verified original at `quickstart/example-intro`: same request, same source,
|
||||
same 10.0s / 1920×1080 output. v2 keeps its concept — _show the site as it is, the real
|
||||
page is the proof_ — and its split composition. What changed, and why.
|
||||
|
||||
### 2.1 The capture actually reads now — the one that mattered
|
||||
|
||||
v1's biggest defect was invisible in the source and obvious in the render. The capture
|
||||
was 1440×810 displayed inside a 940×529 card: **0.65×**. The page's 16px body text
|
||||
rendered at roughly 10px in a 1080p frame, so the "real captured page" — the entire
|
||||
argument of the video — was an unreadable grey smudge with a tiny cluster in one corner.
|
||||
|
||||
v2 shows the capture at **exactly 1:1**. A fresh 1920×1080 1x capture is displayed
|
||||
through a 1000×400 window with `object-fit: none; object-position: -300px -104px`, so the
|
||||
page's own type renders at the size it renders at in a browser. Three hard rules fall out
|
||||
of that, and they are written into `frame.md`:
|
||||
|
||||
- the plate **never scales** (a 1x capture has no headroom above 1:1),
|
||||
- the plate **never rotates** (v1 tilted it `rotationY: -10° → -4° → -1.5°`, resampling
|
||||
the page text for the entire shot),
|
||||
- the entrance is **`x` translate + opacity only**.
|
||||
|
||||
`object-fit: none` also means the `<img>` box is exactly 1000×400 instead of a 1920×1080
|
||||
element hanging out of an `overflow: hidden` parent, so it needs no
|
||||
`data-layout-allow-overflow` and trips no layout finding.
|
||||
|
||||
### 2.2 Decorative noise removed
|
||||
|
||||
| Removed from v1 | Why |
|
||||
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| 160px background grid **and** 4px dot "paper" | Two textures stacked under a third layer (the light wash). Visible busywork on a flat page. |
|
||||
| The sheen sweep across the card (`7.55 → 8.65s`) | A stock shine gimmick. It decorated the evidence instead of reading it. |
|
||||
| The ambient accent bloom (`5.0s`, then `scale 1.05`) | Fired 2.5s after the plate landed, cued to nothing. |
|
||||
| `https://example.com` caption under the card | A label repeating the `example.com` line already on screen, in near-invisible grey. |
|
||||
| The `Learn more →` pill button | Invented UI. The page's action is a plain text link — and it is already visible _in the plate_, as itself. |
|
||||
| The 1.9s `rotationY` "settle" + glow scale at the end | Lazy breathing. v2 holds completely still instead. |
|
||||
|
||||
What replaced them is **one** device with a job: a single 5px accent marker that draws
|
||||
down the left edge of the page's own content block, on the narration cue
|
||||
_"…documentation examples."_ It is the film's argument — _those are the page's words, not
|
||||
ours_ — and it is the only new graphic element in v2.
|
||||
|
||||
### 2.3 Narration, captions and pacing (new in v2)
|
||||
|
||||
v1 was silent except for a music bed, so nothing cued anything; its beats were spaced by
|
||||
feel (`0.1 · 0.45 · 1.55 · 2.5 · 3.9 · 5.0 · 6.5 · 7.55`).
|
||||
|
||||
v2 has narration, and **every visual cue is a measured word timing**, not an estimate:
|
||||
`assets/narration.wav` → `npx hyperframes transcribe --model small.en` →
|
||||
`transcript.json` → the tween positions in `index.html` and the group boundaries in
|
||||
`compositions/captions.html`. Three phrase captions for three sentences, fixed position,
|
||||
one visible at a time, per-word emphasis by luminance only.
|
||||
|
||||
Pacing consequence: v1 front-loaded the lockup then idled with decoration. v2's reveals
|
||||
land at `1.00 · 1.55 · 1.78 · 2.66 · 4.55 · 6.42` — the last one inside the final 30% —
|
||||
with two **deliberately empty** holds (`5.15 → 6.42` mid-film breather, `8.03 → 10.0`
|
||||
still end card). The holds are left visibly empty in the timeline source, with comments
|
||||
saying so, because a held read beats bad motion.
|
||||
|
||||
### 2.4 Determinism fix: non-embedded font weights
|
||||
|
||||
v1 asked for `font-weight: 500` on IBM Plex Mono and `600` on Inter. The renderer embeds
|
||||
`inter` at **400/700/900** and `ibm-plex-mono` at **400/700** only, so both requests were
|
||||
synthesised or substituted on a clean render machine — preview and output could disagree.
|
||||
v2 uses only bundled weights: Inter 900 (title) / 400 (body), IBM Plex Mono 400
|
||||
(address), Inter 700 (captions). `frame.md` states the rule so an editor cannot
|
||||
reintroduce it.
|
||||
|
||||
### 2.5 Parameterisation (new in v2)
|
||||
|
||||
v1 had no variables — it was a one-off. v2 declares five with useful defaults and wires
|
||||
them declaratively (`data-var-text`, `data-var-src`, `var(--accent)`), with no
|
||||
`getVariables()` call anywhere, so the composition can front another site by flags alone.
|
||||
|
||||
### 2.6 Palette: one derived value, stated as derived
|
||||
|
||||
v1's brief claimed the palette was read from the capture, then set the canvas to
|
||||
`#eeeeee` — the page's own background. The plate therefore had the same fill as the
|
||||
ground and only its border separated them, which is why it read as a faint rectangle.
|
||||
|
||||
v2 keeps ink `#1b1b1b` and accent `#334488` from the captured page (its CSS literally
|
||||
says `a { color:#348 }`) and steps the **canvas** down to `#e9e9e7`
|
||||
so the plate has something to lift off. `frame.md` labels that as the one derived value
|
||||
rather than pretending it was read.
|
||||
|
||||
### 2.7 One thing v2 gives up
|
||||
|
||||
v1's title was two hand-split `<span>`s waterfalling in 0.17s apart — a nicer reveal than
|
||||
v2's single masked rise. That split is incompatible with `data-var-text`, which replaces
|
||||
an element's own text and cannot drive per-line spans. v2 trades the waterfall for a
|
||||
title that is actually a parameter. Called out here because it is a real regression, not
|
||||
an oversight.
|
||||
|
||||
---
|
||||
|
||||
## 3. Two framework bugs found, with reproductions
|
||||
|
||||
Both were hit while building this project and both are fixed _in_ this project. Neither
|
||||
is a blocker for it.
|
||||
|
||||
### 3.1 `window.getComputedStyle()` throws inside a sub-composition
|
||||
|
||||
**Severity: high** — the failure is near-silent and ships broken video.
|
||||
|
||||
The captions doctrine (`media-use/audio/references/captions/authoring.md` → "Self-lint
|
||||
after building timeline") prescribes this snippet verbatim:
|
||||
|
||||
```js
|
||||
var computed = window.getComputedStyle(el);
|
||||
```
|
||||
|
||||
Inside a sub-composition it raises `TypeError: Illegal invocation`. The script dies
|
||||
mid-self-lint, so `window.__timelines["captions"] = tl` never runs, the runtime waits out
|
||||
its registration timeout, and the render captures whatever DOM state the throw left
|
||||
behind. Observed symptom: caption group 0 frozen at ~14% opacity for the whole video,
|
||||
with `check` reporting it only indirectly as 30 `contrast_aa_failure` errors against a
|
||||
background of `rgb(205,205,203)` — a colour that exists nowhere in the design.
|
||||
|
||||
Root cause, measured from inside a sub-composition script:
|
||||
|
||||
```
|
||||
window === globalThis → false
|
||||
window.getComputedStyle === globalThis.getComputedStyle → true
|
||||
window.__hyperframes.fitTextFontSize → function
|
||||
window.__timelines → object
|
||||
```
|
||||
|
||||
The runtime evaluates a sub-composition's inline script with a **`window` wrapper
|
||||
object**. Property reads and writes proxy through to the real window, which is why
|
||||
`__timelines` and `__hyperframes` work. But retrieving the _same_ native function through
|
||||
the wrapper and calling it as a method makes the wrapper the receiver, and native code
|
||||
rejects it.
|
||||
|
||||
Fix in this project (`compositions/captions.html`), with the reason in a comment:
|
||||
|
||||
```js
|
||||
var computed = getComputedStyle(el); // bare — not window.getComputedStyle
|
||||
```
|
||||
|
||||
`globalThis.getComputedStyle(el)` also works. Suggested upstream actions: make the
|
||||
wrapper bind native `Window` methods, and fix the snippet in the captions doctrine —
|
||||
every agent that follows it inside a sub-composition inherits this bug.
|
||||
|
||||
### 3.2 `lint`'s `missing_local_asset` mis-parses `data-var-src`
|
||||
|
||||
**Severity: low** — loud, harmless, one-line workaround.
|
||||
|
||||
```html
|
||||
<img src="assets/example-com.png" data-var-src="pageImage" />
|
||||
```
|
||||
|
||||
fails lint with `missing_local_asset: <img> element references local file(s) not found in
|
||||
the project: pageImage`. The rule's regex is
|
||||
`/<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi`; the greedy `[^>]*`
|
||||
takes the **last** `src=` in the tag, and `data-var-src` ends in `src` with a `-` before
|
||||
it, so `\b` matches and the variable id is read as a filename.
|
||||
|
||||
Workaround used here: author `data-var-src` **before** `src`. Suggested upstream fix:
|
||||
require a whitespace or `"` boundary before `src` (`(?<=[\s"'])src\s*=`), or explicitly
|
||||
skip `data-var-src`.
|
||||
|
||||
---
|
||||
|
||||
## 4. One documented authoring constraint
|
||||
|
||||
Not a bug, but a real trap worth stating: **keep `data-composition-variables` pure
|
||||
ASCII.** It lives on `<html>`, which is consumed before `<meta charset>` is in effect, so
|
||||
a literal em dash in a `default` renders as `â€"`. Verified both ways by snapshot: the
|
||||
literal character mojibakes, the JSON escape `\u2014` renders a correct em dash. Text in
|
||||
the document body and inside a sub-composition `<template>` is unaffected (the runtime
|
||||
`fetch`es sub-compositions and decodes them as UTF-8).
|
||||
|
||||
v2 ships ASCII-only variable defaults and states the rule in a comment in `index.html`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Toolchain
|
||||
|
||||
The project's `package.json` pinned `hyperframes@0.7.88`. Per the CLI's own upgrade
|
||||
protocol the pin was probed before any render-affecting command:
|
||||
|
||||
```
|
||||
npx hyperframes@latest upgrade --project . --check
|
||||
→ would bump project scripts 0.7.88 → 0.7.90
|
||||
```
|
||||
|
||||
Applied, then verified: `npx hyperframes check` passes on **0.7.90**. A passing check
|
||||
confirms the compositions still validate on the new version — not that output is
|
||||
frame-identical to the old pin. The project now runs on `0.7.90`; `hyperframes info`
|
||||
reports `updateAvailable: false`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Blockers
|
||||
|
||||
**None.** `lint` and `check` pass with zero errors and zero warnings, snapshots are
|
||||
captured and inspected, and the final MP4 passed the media gate.
|
||||
|
||||
Two non-blocking environment notes: the ambient `GEMINI_API_KEY` is rejected by the API,
|
||||
so `snapshot`'s optional vision descriptions are unavailable; and `hyperframes feedback`
|
||||
was not sent, because the CLI's protocol sends it only after verifying a successful
|
||||
render. Both framework findings in §3 are written up here in reproducible form so they
|
||||
can be filed with that render.
|
||||
3
examples/docs-reference-project/assets/bgm.wav
Normal file
3
examples/docs-reference-project/assets/bgm.wav
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8935eb87c960b062ad9c42bce3f4e12408f389dfb82acba986999e62db76fe5b
|
||||
size 1920078
|
||||
BIN
examples/docs-reference-project/assets/example-com.png
Normal file
BIN
examples/docs-reference-project/assets/example-com.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
3
examples/docs-reference-project/assets/narration.wav
Normal file
3
examples/docs-reference-project/assets/narration.wav
Normal file
@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4fca03080a6626474df0deda3f6b1b679321a4b6c813ec2876ac8baae476fd49
|
||||
size 614478
|
||||
BIN
examples/docs-reference-project/assets/sfx-tick.mp3
Normal file
BIN
examples/docs-reference-project/assets/sfx-tick.mp3
Normal file
Binary file not shown.
BIN
examples/docs-reference-project/assets/sfx-whoosh.mp3
Normal file
BIN
examples/docs-reference-project/assets/sfx-whoosh.mp3
Normal file
Binary file not shown.
241
examples/docs-reference-project/compositions/captions.html
Normal file
241
examples/docs-reference-project/compositions/captions.html
Normal file
@ -0,0 +1,241 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>captions — example.com intro</title>
|
||||
<!-- This head is metadata for the source file only. The runtime clones ONLY
|
||||
the <template> contents, so every style, node and script that has to
|
||||
exist at render time lives inside it. GSAP is deliberately not
|
||||
re-imported here: the host page already provides the global, and a
|
||||
second <script src> would be a render-time network fetch. -->
|
||||
</head>
|
||||
<body>
|
||||
<template>
|
||||
<style>
|
||||
/* Root is styled by #root, never by a class: the compiler scopes each
|
||||
sub-composition's CSS to its data-composition-id, which would turn a
|
||||
root class selector into a descendant selector that cannot match. */
|
||||
#root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Fixed lower band. Full-width absolute + text-align, NOT
|
||||
left:50% + translateX(-50%) — the latter clips at canvas edges.
|
||||
bottom:64px puts the pill at y 934–1016, inside the 184px band the
|
||||
main composition reserves, so a caption can never cover the artwork. */
|
||||
.group {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 64px;
|
||||
text-align: center;
|
||||
/* the root is click-through so the artwork underneath stays
|
||||
selectable in Studio; the pills themselves are not */
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.pill {
|
||||
display: inline-block;
|
||||
max-width: 1304px;
|
||||
padding: 16px 32px;
|
||||
border-radius: 12px;
|
||||
background: #1b1b1b;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 700; /* bundled weight */
|
||||
font-size: 42px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.005em;
|
||||
text-align: center;
|
||||
}
|
||||
/* Emphasis by luminance only. A word starts read-pending and stays lit
|
||||
once spoken — no scale pop, no colour flash, no scatter exit. Both
|
||||
states clear WCAG AA against #1b1b1b (6.1:1 idle, 17.2:1 lit). */
|
||||
.w {
|
||||
color: #9a9a9a;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="captions"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-layout-allow-caption-zone
|
||||
></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Phrase groups. start/end/s/e are absolute composition seconds, taken
|
||||
// from `npx hyperframes transcribe assets/narration.wav --model small.en`
|
||||
// plus the narration clip's data-start of 1.00s. Three groups for three
|
||||
// spoken sentences: stable, one visible at a time, never re-grouped.
|
||||
var GROUPS = [
|
||||
{
|
||||
start: 1.1,
|
||||
end: 2.81,
|
||||
words: [
|
||||
{ t: "This", s: 1.1, e: 1.36 },
|
||||
{ t: "is", s: 1.36, e: 1.54 },
|
||||
// spoken "example dot com"; written as the string it is
|
||||
{ t: "example.com.", s: 1.54, e: 2.81 },
|
||||
],
|
||||
},
|
||||
{
|
||||
start: 2.81,
|
||||
end: 5.48,
|
||||
words: [
|
||||
{ t: "The", s: 2.81, e: 2.95 },
|
||||
{ t: "domain", s: 2.95, e: 3.24 },
|
||||
{ t: "reserved", s: 3.24, e: 3.56 },
|
||||
{ t: "for", s: 3.74, e: 3.81 },
|
||||
{ t: "documentation", s: 3.88, e: 4.62 },
|
||||
{ t: "examples.", s: 4.74, e: 5.38 },
|
||||
],
|
||||
},
|
||||
{
|
||||
start: 5.48,
|
||||
end: 8.03,
|
||||
words: [
|
||||
{ t: "Use", s: 5.48, e: 5.55 },
|
||||
{ t: "it", s: 5.62, e: 5.68 },
|
||||
{ t: "in", s: 5.68, e: 5.8 },
|
||||
{ t: "your", s: 5.8, e: 6.03 },
|
||||
{ t: "docs —", s: 6.09, e: 6.36 },
|
||||
{ t: "no", s: 6.48, e: 6.54 },
|
||||
{ t: "permission", s: 6.54, e: 6.77 },
|
||||
{ t: "needed.", s: 7.17, e: 7.68 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
var IDLE = "#9a9a9a";
|
||||
var LIT = "#ffffff";
|
||||
// Back-to-back phrases: the narration has no pause between sentences
|
||||
// (group 0 ends the exact instant group 1 begins), so the handoff is a
|
||||
// quick fade-down / fade-up rather than a crossfade. Keeping them
|
||||
// short holds the dip at a sentence boundary under ~0.2s while still
|
||||
// guaranteeing only one group is ever drawn on a given frame.
|
||||
var ENTER = 0.18;
|
||||
var EXIT = 0.1;
|
||||
|
||||
var root = document.getElementById("root");
|
||||
var api = window.__hyperframes;
|
||||
|
||||
// Build the DOM synchronously — one group element, one span per word.
|
||||
GROUPS.forEach(function (group, gi) {
|
||||
var groupEl = document.createElement("div");
|
||||
groupEl.id = "cg-" + gi;
|
||||
groupEl.className = "group";
|
||||
|
||||
var pill = document.createElement("div");
|
||||
pill.className = "pill";
|
||||
|
||||
var text = group.words
|
||||
.map(function (w) {
|
||||
return w.t;
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
// Overflow guard: shrink the phrase until it fits one line rather
|
||||
// than letting it wrap up out of the caption band.
|
||||
if (api && typeof api.fitTextFontSize === "function") {
|
||||
var fit = api.fitTextFontSize(text, {
|
||||
fontFamily: "Inter",
|
||||
fontWeight: 700,
|
||||
maxWidth: 1240,
|
||||
baseFontSize: 42,
|
||||
minFontSize: 30,
|
||||
step: 2,
|
||||
});
|
||||
if (fit && fit.fontSize) pill.style.fontSize = fit.fontSize + "px";
|
||||
}
|
||||
|
||||
group.words.forEach(function (w, wi) {
|
||||
if (wi > 0) pill.appendChild(document.createTextNode(" "));
|
||||
var span = document.createElement("span");
|
||||
span.id = "cw-" + gi + "-" + wi;
|
||||
span.className = "w";
|
||||
span.textContent = w.t;
|
||||
pill.appendChild(span);
|
||||
});
|
||||
|
||||
groupEl.appendChild(pill);
|
||||
root.appendChild(groupEl);
|
||||
});
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
|
||||
GROUPS.forEach(function (group, gi) {
|
||||
var groupEl = document.getElementById("cg-" + gi);
|
||||
|
||||
// enter — fromTo, not from: the host re-seeks this sub-composition
|
||||
// every time its slot becomes visible, and from() can desync.
|
||||
tl.fromTo(
|
||||
groupEl,
|
||||
{ autoAlpha: 0, y: 14 },
|
||||
{ autoAlpha: 1, y: 0, duration: ENTER, ease: "power3.out" },
|
||||
group.start,
|
||||
);
|
||||
|
||||
// progressive fill — each word lights on its own measured onset and
|
||||
// stays lit, so the phrase completes with the voice.
|
||||
group.words.forEach(function (w, wi) {
|
||||
tl.fromTo(
|
||||
"#cw-" + gi + "-" + wi,
|
||||
{ color: IDLE },
|
||||
{ color: LIT, duration: 0.14, ease: "none" },
|
||||
w.s,
|
||||
);
|
||||
});
|
||||
|
||||
// exit, then the mandatory hard kill exactly at group.end so no group
|
||||
// can bleed into the next one or into the end card.
|
||||
tl.to(
|
||||
groupEl,
|
||||
{ autoAlpha: 0, y: -8, duration: EXIT, ease: "power2.in" },
|
||||
group.end - EXIT,
|
||||
);
|
||||
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
|
||||
});
|
||||
|
||||
// Self-lint: prove every group is really gone after its own end.
|
||||
//
|
||||
// Call getComputedStyle BARE, not as window.getComputedStyle(el).
|
||||
// Inside a sub-composition the runtime evaluates this script with a
|
||||
// `window` wrapper object (window !== globalThis, though property
|
||||
// reads/writes proxy through to the real window). Retrieving the
|
||||
// native function through the wrapper and calling it as a method
|
||||
// makes the wrapper the receiver, so it throws
|
||||
// `TypeError: Illegal invocation`, the timeline is never registered,
|
||||
// and the render ships whatever DOM state the throw left behind.
|
||||
GROUPS.forEach(function (group, gi) {
|
||||
var el = document.getElementById("cg-" + gi);
|
||||
if (!el) return;
|
||||
tl.seek(group.end + 0.01);
|
||||
var computed = getComputedStyle(el);
|
||||
if (computed.opacity !== "0" && computed.visibility !== "hidden") {
|
||||
console.warn(
|
||||
"[caption-lint] group " +
|
||||
gi +
|
||||
" still visible at t=" +
|
||||
(group.end + 0.01).toFixed(2) +
|
||||
"s",
|
||||
);
|
||||
}
|
||||
});
|
||||
tl.seek(0);
|
||||
|
||||
window.__timelines["captions"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
101
examples/docs-reference-project/frame.md
Normal file
101
examples/docs-reference-project/frame.md
Normal file
@ -0,0 +1,101 @@
|
||||
---
|
||||
name: example-com-intro
|
||||
source: https://example.com (live capture, 2026-08-03)
|
||||
format: 1920x1080
|
||||
colors:
|
||||
canvas: "#E9E9E7" # the page's #EEEEEE stepped down ~3% so the plate lifts off it
|
||||
surface: "#EEEEEE" # the captured page's own background — the plate
|
||||
ink: "#1B1B1B" # the page's #000 at .8 opacity, resolved
|
||||
ink_muted: "#5A5C63" # supporting copy
|
||||
accent: "#334488" # the page's own link colour
|
||||
hairline: "#D6D6D6" # plate edge
|
||||
caption_bg: "#1B1B1B"
|
||||
caption_idle: "#9A9A9A"
|
||||
caption_active: "#FFFFFF"
|
||||
fonts:
|
||||
display: "Inter" # 900 only
|
||||
body: "Inter" # 400 only
|
||||
mono: "IBM Plex Mono" # 400 only
|
||||
type_ramp:
|
||||
title: "132px / 1.02 / -0.035em / 900"
|
||||
address: "42px / 1 / 0 / 400 mono"
|
||||
support: "38px / 1.45 / -0.005em / 400"
|
||||
caption: "42px / 1.2 / -0.005em / 700"
|
||||
radius: "10px"
|
||||
motion:
|
||||
ease: "power4.out for reveals, power3.out for copy, expo.out for rules"
|
||||
reveal: "cued to the narration's measured word timings — never front-loaded"
|
||||
idle: "none; the film holds still rather than drifting"
|
||||
---
|
||||
|
||||
# frame.md — example.com intro
|
||||
|
||||
## Where the palette comes from
|
||||
|
||||
Nothing here is chosen by taste. `capture/extracted/tokens.json` reports three colours
|
||||
on the live page — `#EEEEEE`, `#000000`, `#334488` — and its CSS resolves them:
|
||||
|
||||
```css
|
||||
body {
|
||||
background: #eee;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
div {
|
||||
opacity: 0.8;
|
||||
} /* so #000 ink actually reads as ~#333 */
|
||||
a {
|
||||
color: #348;
|
||||
} /* → #334488 */
|
||||
```
|
||||
|
||||
One value is **derived, not read**: the composition canvas. If the frame were also
|
||||
`#EEEEEE`, the captured plate would have the same fill as the ground and only its
|
||||
border would separate them. Stepping the canvas down to `#E9E9E7` gives the plate
|
||||
somewhere to lift from, which is why its shadow reads at all.
|
||||
|
||||
## Type
|
||||
|
||||
Only weights the renderer actually embeds:
|
||||
|
||||
| Role | Family | Weight | Why |
|
||||
| ------- | ------------- | ------ | ---------------------------------------------------- |
|
||||
| Title | Inter | 900 | bundled; the lockup's whole job is mass |
|
||||
| Body | Inter | 400 | bundled |
|
||||
| Address | IBM Plex Mono | 400 | bundled; mono says "this is a literal string" |
|
||||
| Caption | Inter | 700 | bundled; the heaviest weight that is not the title's |
|
||||
|
||||
Weights 500 and 600 are **not** in the embedded bundle. Asking for them means the
|
||||
render machine synthesises or substitutes, and preview stops matching output. Snap to
|
||||
400 / 700 / 900.
|
||||
|
||||
## The one graphic device
|
||||
|
||||
A single accent family, three instances, each with a job:
|
||||
|
||||
1. **Top rule** — 6px, full bleed. The frame asserting itself at t=0.1.
|
||||
2. **Brand bar** — 200×8px under the title. Separates the name from the address.
|
||||
3. **Page marker** — 5px vertical, inside the plate, beside the page's own content block.
|
||||
This is the film's argument: _those are the page's words, not ours._
|
||||
|
||||
Anything that is not one of those three is not in this composition. No sheen, no
|
||||
ambient bloom, no grid or paper texture, no gradient mesh, no drifting camera.
|
||||
|
||||
## The plate rule
|
||||
|
||||
The capture is a 1x, 1920×1080 screenshot. It is displayed through a 1000×400 window
|
||||
with `object-fit: none`, so the page renders at exactly 1:1 and its real 16px body
|
||||
text is as crisp as it is in a browser.
|
||||
|
||||
Consequences, and they are hard rules:
|
||||
|
||||
- **Never scale the plate.** Any zoom past 1:1 resamples a 1x capture into mush.
|
||||
- **Never rotate the plate.** A 3D tilt does the same thing more expensively.
|
||||
- Entrance is `x` translate + opacity only.
|
||||
|
||||
## Caption skin
|
||||
|
||||
Fixed lower band, `184px` tall (17% of the canvas), reserved structurally — the stage
|
||||
ends where the band begins, so nothing can ever collide with it. Dark pill, centred,
|
||||
one phrase group visible at a time, per-word emphasis by luminance only
|
||||
(`#9A9A9A → #FFFFFF`). No colour flashing, no scale popping, no scatter exits, no
|
||||
random placement.
|
||||
13
examples/docs-reference-project/hyperframes.json
Normal file
13
examples/docs-reference-project/hyperframes.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
|
||||
"registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
|
||||
"paths": {
|
||||
"blocks": "compositions",
|
||||
"components": "compositions/components",
|
||||
"assets": "assets"
|
||||
},
|
||||
"media": {
|
||||
"autoProxy": true
|
||||
},
|
||||
"authoringSkill": "product-launch-video"
|
||||
}
|
||||
370
examples/docs-reference-project/index.html
Normal file
370
examples/docs-reference-project/index.html
Normal file
@ -0,0 +1,370 @@
|
||||
<!doctype html>
|
||||
<html
|
||||
lang="en"
|
||||
data-composition-variables='[
|
||||
{ "id": "title", "type": "string", "label": "Title", "default": "Example Domain", "placeholder": "the page heading, verbatim", "maxLength": 26 },
|
||||
{ "id": "accent", "type": "color", "label": "Accent", "default": "#334488" },
|
||||
{ "id": "supportingLine", "type": "string", "label": "Supporting line", "default": "For use in documentation examples without needing permission.", "placeholder": "one sentence, verbatim from the page", "maxLength": 90 },
|
||||
{ "id": "siteUrl", "type": "string", "label": "Address", "default": "example.com", "placeholder": "bare domain, no scheme", "maxLength": 32 },
|
||||
{ "id": "pageImage", "type": "string", "label": "Captured page (1x)", "default": "assets/example-com.png", "placeholder": "a 1920x1080 1x capture" }
|
||||
]'
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>example.com — 10s intro</title>
|
||||
<!-- Keep `data-composition-variables` (on <html>, above) pure ASCII.
|
||||
The <html> element's attributes are consumed before this <meta charset>
|
||||
is in effect, so a literal em dash in a `default` renders as mojibake
|
||||
(â€"). Use a JSON escape instead: \u2014 survives, verified by
|
||||
snapshot. Text in the document body and inside a sub-composition's
|
||||
<template> is unaffected; both decode as UTF-8 normally. -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #e9e9e7;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Palette. Every value is read off the live capture except --canvas,
|
||||
which is example.com's own #EEEEEE stepped down ~3% so the captured
|
||||
plate has something to lift off. See frame.md.
|
||||
|
||||
--accent is not declared here: the runtime publishes every scalar
|
||||
variable as a --<id> custom property on the composition root, so
|
||||
`var(--accent, #334488)` follows the declared default and any
|
||||
--variables override, with the literal as a plain-HTML fallback.
|
||||
------------------------------------------------------------------ */
|
||||
#root {
|
||||
--canvas: #e9e9e7;
|
||||
--surface: #eeeeee;
|
||||
--ink: #1b1b1b;
|
||||
--ink-muted: #5a5c63;
|
||||
--hairline: #d6d6d6;
|
||||
/* the caption band, reserved structurally rather than by eye */
|
||||
--band: 184px;
|
||||
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
font-family: "Inter", sans-serif;
|
||||
color: var(--ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---------- track 0 · ground ---------- */
|
||||
.ground {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--canvas);
|
||||
}
|
||||
.wash {
|
||||
position: absolute;
|
||||
left: -260px;
|
||||
top: -320px;
|
||||
width: 1500px;
|
||||
height: 1200px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
closest-side,
|
||||
rgba(255, 255, 255, 0.82),
|
||||
rgba(255, 255, 255, 0)
|
||||
);
|
||||
}
|
||||
.top-rule {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 1920px;
|
||||
height: 6px;
|
||||
background: var(--accent, #334488);
|
||||
transform-origin: 0% 50%;
|
||||
}
|
||||
|
||||
/* ---------- track 1 · stage ----------
|
||||
The stage stops where the caption band starts, so no amount of copy
|
||||
can ever collide with a caption. Nothing is positioned by eye. */
|
||||
.stage-grid {
|
||||
position: absolute;
|
||||
left: 120px;
|
||||
right: 120px;
|
||||
top: 0;
|
||||
bottom: var(--band);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
/* left · the lockup (640px) */
|
||||
.col-type {
|
||||
flex: 0 0 640px;
|
||||
}
|
||||
.title-mask {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.title {
|
||||
display: block;
|
||||
font-size: 132px;
|
||||
font-weight: 900; /* bundled weight — 500/600 are not embedded */
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.brand-bar {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 8px;
|
||||
margin-top: 34px;
|
||||
background: var(--accent, #334488);
|
||||
transform-origin: 0% 50%;
|
||||
}
|
||||
.address {
|
||||
display: block;
|
||||
margin-top: 30px;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 42px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
color: var(--accent, #334488);
|
||||
}
|
||||
.support {
|
||||
display: block;
|
||||
margin-top: 34px;
|
||||
max-width: 640px;
|
||||
font-size: 38px;
|
||||
font-weight: 400;
|
||||
line-height: 1.45;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* right · the real captured page (1000px) ----------
|
||||
object-fit:none renders the 1920×1080 capture at its intrinsic size and
|
||||
object-position crops to the window, so the page's own 16px body text is
|
||||
pixel-exact 1:1. That is also why the plate never scales and never
|
||||
rotates: a 1x capture has no headroom above 1:1. */
|
||||
.col-plate {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
.plate {
|
||||
position: relative;
|
||||
width: 1000px;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 28px 64px -18px rgba(27, 27, 27, 0.3),
|
||||
0 2px 5px rgba(27, 27, 27, 0.06);
|
||||
}
|
||||
.page {
|
||||
display: block;
|
||||
width: 998px;
|
||||
height: 398px;
|
||||
object-fit: none;
|
||||
object-position: -300px -104px;
|
||||
}
|
||||
/* the film's one argument: those are the page's words, not ours.
|
||||
Measured against the capture — the content block sits at x 384–1131,
|
||||
y 162–260 of the 1920×1080 screenshot, i.e. 84,58 → 831,156 in here. */
|
||||
.page-marker {
|
||||
position: absolute;
|
||||
left: 56px;
|
||||
top: 58px;
|
||||
width: 5px;
|
||||
height: 98px;
|
||||
border-radius: 2px;
|
||||
background: var(--accent, #334488);
|
||||
transform-origin: 50% 0%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="main"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-fps="30"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<!-- track 0 · ground. The fill lives on a clip layer, never on #root:
|
||||
the producer can drop the root element's own background. -->
|
||||
<div id="bg" class="clip" data-start="0" data-duration="10" data-track-index="0">
|
||||
<div class="ground"></div>
|
||||
<div class="wash" data-layout-allow-overflow></div>
|
||||
<div id="top-rule" class="top-rule"></div>
|
||||
</div>
|
||||
|
||||
<!-- track 1 · stage -->
|
||||
<div id="stage" class="clip" data-start="0" data-duration="10" data-track-index="1">
|
||||
<div class="stage-grid">
|
||||
<div class="col-type">
|
||||
<div class="title-mask">
|
||||
<h1 id="title" class="title" data-var-text="title">Example Domain</h1>
|
||||
</div>
|
||||
<span id="brand-bar" class="brand-bar"></span>
|
||||
<span id="address" class="address" data-var-text="siteUrl">example.com</span>
|
||||
<p id="support" class="support" data-var-text="supportingLine">
|
||||
For use in documentation examples without needing permission.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-plate">
|
||||
<div id="plate" class="plate">
|
||||
<!-- data-var-src binds the source to the `pageImage` variable; the
|
||||
authored src is the fallback. Keep data-var-src BEFORE src:
|
||||
`lint`'s missing_local_asset scan matches the last `src=` in
|
||||
the tag, and `data-var-src` ends in `src` too, so the reverse
|
||||
order makes it read the variable id as a file path. -->
|
||||
<img
|
||||
id="page"
|
||||
class="page"
|
||||
data-var-src="pageImage"
|
||||
src="assets/example-com.png"
|
||||
alt="The example.com landing page: the heading Example Domain, one sentence of body copy, and a Learn more link."
|
||||
/>
|
||||
<div id="page-marker" class="page-marker"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- track 2 · captions overlay (sub-composition) -->
|
||||
<div
|
||||
id="captions-slot"
|
||||
data-composition-id="captions"
|
||||
data-composition-src="compositions/captions.html"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-track-index="2"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
></div>
|
||||
|
||||
<!-- audio. The framework owns playback; never call play()/pause()/seek. -->
|
||||
<audio
|
||||
id="bgm"
|
||||
src="assets/bgm.wav"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-track-index="8"
|
||||
data-volume="0.34"
|
||||
></audio>
|
||||
<audio
|
||||
id="vo"
|
||||
src="assets/narration.wav"
|
||||
data-start="1"
|
||||
data-duration="6.97"
|
||||
data-track-index="9"
|
||||
data-volume="1"
|
||||
></audio>
|
||||
<audio
|
||||
id="sfx-plate"
|
||||
src="assets/sfx-whoosh.mp3"
|
||||
data-start="2.62"
|
||||
data-duration="0.6"
|
||||
data-track-index="10"
|
||||
data-volume="0.2"
|
||||
></audio>
|
||||
<audio
|
||||
id="sfx-marker"
|
||||
src="assets/sfx-tick.mp3"
|
||||
data-start="4.54"
|
||||
data-duration="0.4"
|
||||
data-track-index="11"
|
||||
data-volume="0.16"
|
||||
></audio>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// One paused timeline, built synchronously, keyed to data-composition-id.
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
|
||||
// Load states. Everything that animates in is hidden explicitly, so a
|
||||
// paused seek to t=0 renders the true first frame. Initial transforms are
|
||||
// set here rather than in CSS: a CSS transform plus a GSAP tween on the
|
||||
// same property fight each other (lint: gsap_css_transform_conflict).
|
||||
gsap.set("#top-rule", { scaleX: 0 });
|
||||
gsap.set("#title", { yPercent: 106 });
|
||||
gsap.set("#brand-bar", { scaleX: 0 });
|
||||
gsap.set(["#address", "#support"], { autoAlpha: 0, y: 20 });
|
||||
gsap.set("#plate", { autoAlpha: 0, x: 140 });
|
||||
gsap.set("#page-marker", { scaleY: 0 });
|
||||
|
||||
// Every cue below is a measured word timing from assets/narration.wav
|
||||
// (see SCRIPT.md), not an estimate.
|
||||
|
||||
// 0.10s — the frame asserts itself. Narration has not started.
|
||||
tl.to("#top-rule", { scaleX: 1, duration: 0.9, ease: "power3.out" }, 0.1);
|
||||
|
||||
// 1.00s — VO "This is…" · the name rises out of its mask.
|
||||
tl.to("#title", { yPercent: 0, duration: 0.68, ease: "power4.out" }, 1.0);
|
||||
|
||||
// 1.55s / 1.78s — the brand rule, then VO "…example.com." lands the address.
|
||||
tl.to("#brand-bar", { scaleX: 1, duration: 0.44, ease: "expo.out" }, 1.55);
|
||||
tl.to("#address", { autoAlpha: 1, y: 0, duration: 0.55, ease: "power3.out" }, 1.78);
|
||||
|
||||
// 2.66s — VO "The domain…" · the real page travels in. x and opacity ONLY.
|
||||
tl.to("#plate", { autoAlpha: 1, x: 0, duration: 0.85, ease: "power4.out" }, 2.66);
|
||||
|
||||
// 4.55s — VO "…examples." · the marker draws beside the page's own content block.
|
||||
tl.to("#page-marker", { scaleY: 1, duration: 0.6, ease: "power2.out" }, 4.55);
|
||||
|
||||
// 5.15 → 6.42s — held read. Deliberately empty; a held frame beats bad motion.
|
||||
|
||||
// 6.42s — VO "…no permission needed." · the last reveal, in the final 30%.
|
||||
tl.to("#support", { autoAlpha: 1, y: 0, duration: 0.68, ease: "power3.out" }, 6.42);
|
||||
|
||||
// 8.03 → 10.00s — still end card. No tween. This is the poster frame.
|
||||
|
||||
// Audio: the bed ducks under the narration, recovers, then fades out.
|
||||
// Volume keyframes are probed off the timeline and applied identically in
|
||||
// preview and render; data-volume is only the static baseline.
|
||||
tl.to("#bgm", { volume: 0.13, duration: 0.6, ease: "sine.inOut" }, 0.7);
|
||||
tl.to("#bgm", { volume: 0.3, duration: 0.9, ease: "sine.inOut" }, 7.7);
|
||||
tl.to("#bgm", { volume: 0, duration: 1.0, ease: "sine.in" }, 9.0);
|
||||
|
||||
window.__timelines["main"] = tl;
|
||||
|
||||
// The documentation embed can change three safe variables without
|
||||
// rebuilding the project. Only the parent iframe is trusted, and each
|
||||
// value is allowlisted and bounded before it touches the composition.
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window.parent) return;
|
||||
const message = event.data;
|
||||
if (!message || message.type !== "hyperframes-docs:variables") return;
|
||||
|
||||
const variables = message.variables;
|
||||
if (!variables || typeof variables !== "object") return;
|
||||
|
||||
if (typeof variables.title === "string") {
|
||||
document.getElementById("title").textContent = variables.title.slice(0, 42);
|
||||
}
|
||||
if (typeof variables.supportingLine === "string") {
|
||||
document.getElementById("support").textContent = variables.supportingLine.slice(0, 90);
|
||||
}
|
||||
if (typeof variables.accent === "string" && /^#[0-9a-fA-F]{6}$/.test(variables.accent)) {
|
||||
document.getElementById("root").style.setProperty("--accent", variables.accent);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
5
examples/docs-reference-project/meta.json
Normal file
5
examples/docs-reference-project/meta.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "docs-reference-project",
|
||||
"name": "docs-reference-project",
|
||||
"createdAt": "2026-08-03T09:05:27.101Z"
|
||||
}
|
||||
11
examples/docs-reference-project/package.json
Normal file
11
examples/docs-reference-project/package.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "docs-reference-project",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npx --yes hyperframes@0.7.90 preview",
|
||||
"check": "npx --yes hyperframes@0.7.90 check",
|
||||
"render": "npx --yes hyperframes@0.7.90 render",
|
||||
"publish": "npx --yes hyperframes@0.7.90 publish"
|
||||
}
|
||||
}
|
||||
87
examples/docs-reference-project/transcript.json
Normal file
87
examples/docs-reference-project/transcript.json
Normal file
@ -0,0 +1,87 @@
|
||||
[
|
||||
{
|
||||
"text": "This",
|
||||
"start": 0.1,
|
||||
"end": 0.36
|
||||
},
|
||||
{
|
||||
"text": "is",
|
||||
"start": 0.36,
|
||||
"end": 0.54
|
||||
},
|
||||
{
|
||||
"text": "example.com,",
|
||||
"start": 0.54,
|
||||
"end": 1.81
|
||||
},
|
||||
{
|
||||
"text": "the",
|
||||
"start": 1.81,
|
||||
"end": 1.95
|
||||
},
|
||||
{
|
||||
"text": "domain",
|
||||
"start": 1.95,
|
||||
"end": 2.24
|
||||
},
|
||||
{
|
||||
"text": "reserved",
|
||||
"start": 2.24,
|
||||
"end": 2.56
|
||||
},
|
||||
{
|
||||
"text": "for",
|
||||
"start": 2.74,
|
||||
"end": 2.81
|
||||
},
|
||||
{
|
||||
"text": "documentation",
|
||||
"start": 2.88,
|
||||
"end": 3.62
|
||||
},
|
||||
{
|
||||
"text": "examples.",
|
||||
"start": 3.74,
|
||||
"end": 4.38
|
||||
},
|
||||
{
|
||||
"text": "Use",
|
||||
"start": 4.48,
|
||||
"end": 4.55
|
||||
},
|
||||
{
|
||||
"text": "it",
|
||||
"start": 4.62,
|
||||
"end": 4.68
|
||||
},
|
||||
{
|
||||
"text": "in",
|
||||
"start": 4.68,
|
||||
"end": 4.8
|
||||
},
|
||||
{
|
||||
"text": "your",
|
||||
"start": 4.8,
|
||||
"end": 5.03
|
||||
},
|
||||
{
|
||||
"text": "docs,",
|
||||
"start": 5.09,
|
||||
"end": 5.36
|
||||
},
|
||||
{
|
||||
"text": "no",
|
||||
"start": 5.48,
|
||||
"end": 5.54
|
||||
},
|
||||
{
|
||||
"text": "permission",
|
||||
"start": 5.54,
|
||||
"end": 5.77
|
||||
},
|
||||
{
|
||||
"text": "needed.",
|
||||
"start": 6.17,
|
||||
"end": 6.68
|
||||
}
|
||||
]
|
||||
@ -26,12 +26,13 @@
|
||||
"sync-schemas:check": "tsx scripts/sync-schemas.ts --check",
|
||||
"sync:package-subpaths": "node scripts/package-subpaths.mjs --write",
|
||||
"check:package-subpaths": "node scripts/package-subpaths.mjs",
|
||||
"lint": "bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:gcp-cloud-run-dockerfile && bun run check:package-cycles && bun run check:package-subpaths && bun run check:cli-process-ownership && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-skill-mirror.mjs",
|
||||
"lint": "bun run check:docs-snippet-motion && bun run check:tracked-artifacts && bun run check:workspace-contracts && bun run check:gcp-cloud-run-dockerfile && bun run check:package-cycles && bun run check:package-subpaths && bun run check:cli-process-ownership && oxlint . && tsx scripts/lint-skills.ts && node scripts/check-skill-mirror.mjs",
|
||||
"check:gcp-cloud-run-dockerfile": "bun run --cwd packages/gcp-cloud-run test:dockerfile-workspaces",
|
||||
"lint:skills": "tsx scripts/lint-skills.ts",
|
||||
"check:skill-mirror": "node scripts/check-skill-mirror.mjs",
|
||||
"lint:fix": "oxlint --fix .",
|
||||
"check:tracked-artifacts": "node scripts/check-tracked-artifacts.mjs",
|
||||
"check:docs-snippet-motion": "node scripts/check-docs-snippet-motion.mjs",
|
||||
"check:workspace-contracts": "node scripts/check-workspace-contracts.mjs",
|
||||
"check:package-cycles": "node scripts/check-package-cycles.mjs",
|
||||
"check:cli-process-ownership": "node scripts/check-cli-process-ownership.mjs",
|
||||
@ -47,7 +48,7 @@
|
||||
"player:perf": "bun run --filter @hyperframes/player perf",
|
||||
"format:check": "oxfmt --check .",
|
||||
"knip": "knip",
|
||||
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
|
||||
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
|
||||
"test:skills": "node --test 'skills/**/*.test.mjs'",
|
||||
"generate:previews": "tsx scripts/generate-template-previews.ts",
|
||||
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
|
||||
|
||||
147
scripts/check-docs-snippet-motion.mjs
Normal file
147
scripts/check-docs-snippet-motion.mjs
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Docs snippets that autoplay preview loops must honour prefers-reduced-motion
|
||||
* on BOTH edges, and the guard cannot be shared as code.
|
||||
*
|
||||
* Mintlify compiles each file in `docs/snippets/` in isolation and forbids one
|
||||
* snippet importing another, so the guard is necessarily copy-pasted into every
|
||||
* grid that autoplays. A duplicated invariant is exactly the kind that rots, so
|
||||
* it is asserted here instead.
|
||||
*
|
||||
* Two distinct failures, both real, both found in review on #2977:
|
||||
*
|
||||
* First paint — `useState(false)` plus a `matchMedia` read in an effect means
|
||||
* the first committed render emits `<video src autoPlay loop>` and only then
|
||||
* pulls the attributes. `autoPlay` overrides `preload="metadata"`, so those
|
||||
* are the files, not metadata probes. A lazy initializer knows on render one.
|
||||
*
|
||||
* Runtime change — dropping `src` and `autoPlay` via React props neither
|
||||
* pauses a playing element nor aborts its selected resource: a media element
|
||||
* keeps its resource until the load algorithm is re-invoked, and `autoplay`
|
||||
* only governs the first play. Turning Reduce Motion on mid-session would
|
||||
* otherwise leave every tile playing and downloading.
|
||||
*
|
||||
* A rendering test would mean adding React to a repository that only carries it
|
||||
* inside `packages/studio`, and mocking Mintlify's hook-injection contract — a
|
||||
* mock that can stay green while the real page breaks. This asserts the source
|
||||
* instead, which is what actually regresses.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SNIPPETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "docs", "snippets");
|
||||
|
||||
/**
|
||||
* Split a snippet into its exported components.
|
||||
*
|
||||
* The invariant is per component, not per file: `docs-video.jsx` already holds
|
||||
* two, so a whole-file match lets a second unguarded grid ride in on the first
|
||||
* one's guard.
|
||||
*/
|
||||
export function splitComponents(source) {
|
||||
const starts = [...source.matchAll(/^(?:export\s+)?(?:const|function)\s+(\w+)\s*[=(]/gm)];
|
||||
return starts.map((match, index) => ({
|
||||
name: match[1],
|
||||
body: source.slice(match.index, starts[index + 1]?.index ?? source.length),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* A component needs the guard only if it *decides* to autoplay.
|
||||
*
|
||||
* `DocsVideo` forwards its caller's `autoPlay` prop and only ever plays because
|
||||
* a reader clicked, so it is not the component that owes a preference check —
|
||||
* whoever passes the prop is.
|
||||
*/
|
||||
export function autoplays(source) {
|
||||
const decidedElsewhere = source
|
||||
// Forwarding the caller's prop.
|
||||
.replace(/autoPlay=\{\s*autoPlay\s*\}/g, "")
|
||||
// The prop's own default in the signature, which is a declaration, not a use.
|
||||
.replace(/\bautoPlay\s*=\s*(?:true|false)\s*(?=[,}])/g, "");
|
||||
return /\bautoPlay(?=[\s/>=])/.test(decidedElsewhere);
|
||||
}
|
||||
|
||||
/** The text between the parentheses of one `useState(` call. */
|
||||
function argumentAt(source, openParen) {
|
||||
let depth = 0;
|
||||
let index = openParen;
|
||||
do {
|
||||
depth += Number(source[index] === "(") - Number(source[index] === ")");
|
||||
index += 1;
|
||||
} while (depth > 0 && index < source.length);
|
||||
return source.slice(openParen + 1, index - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* The preference must be known on the first render, and both halves have to be
|
||||
* the same expression. A lazy initializer for unrelated state, plus the media
|
||||
* query read in a mount effect, is the original bug — so the query has to sit
|
||||
* inside the initializer, not merely nearby.
|
||||
*/
|
||||
export function readsPreferenceLazily(source) {
|
||||
return [...source.matchAll(/useState\(/g)]
|
||||
.map((call) => argumentAt(source, call.index + "useState".length))
|
||||
.some(
|
||||
(argument) =>
|
||||
/^\s*\(\s*\)\s*=>/.test(argument) && argument.includes("prefers-reduced-motion"),
|
||||
);
|
||||
}
|
||||
|
||||
/** React props alone neither pause an element nor abort its resource. */
|
||||
export function stopsPlaybackActively(source) {
|
||||
return (
|
||||
source.includes(".pause()") &&
|
||||
source.includes(".load()") &&
|
||||
/removeAttribute\(\s*"src"/.test(source)
|
||||
);
|
||||
}
|
||||
|
||||
const REQUIREMENTS = [
|
||||
{
|
||||
holds: readsPreferenceLazily,
|
||||
problem:
|
||||
"reads prefers-reduced-motion after mount instead of inside a useState lazy initializer, " +
|
||||
"so the first committed render autoplays before the preference is known",
|
||||
},
|
||||
{
|
||||
holds: stopsPlaybackActively,
|
||||
problem:
|
||||
"never actively stops playback when the preference flips to reduce; " +
|
||||
"dropping src/autoPlay through React props does not pause an element or abort its resource — " +
|
||||
'pause(), removeAttribute("src") and load() are all required',
|
||||
},
|
||||
];
|
||||
|
||||
export function findMotionGuardViolations(source) {
|
||||
return REQUIREMENTS.filter((rule) => !rule.holds(source)).map((rule) => rule.problem);
|
||||
}
|
||||
|
||||
export function auditSnippets(dir = SNIPPETS_DIR) {
|
||||
return readdirSync(dir)
|
||||
.filter((name) => /\.(?:jsx|tsx)$/.test(name))
|
||||
.flatMap((name) =>
|
||||
splitComponents(readFileSync(join(dir, name), "utf8"))
|
||||
.filter((component) => autoplays(component.body))
|
||||
.map((component) => ({
|
||||
name,
|
||||
component: component.name,
|
||||
problems: findMotionGuardViolations(component.body),
|
||||
})),
|
||||
)
|
||||
.filter((finding) => finding.problems.length > 0);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const failures = auditSnippets();
|
||||
if (failures.length > 0) {
|
||||
console.error("Docs snippets that autoplay must honour prefers-reduced-motion:\n");
|
||||
for (const { name, component, problems } of failures) {
|
||||
for (const problem of problems)
|
||||
console.error(` docs/snippets/${name} → ${component} — ${problem}`);
|
||||
}
|
||||
console.error("\nSee the header of scripts/check-docs-snippet-motion.mjs for why.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
123
scripts/check-docs-snippet-motion.test.mjs
Normal file
123
scripts/check-docs-snippet-motion.test.mjs
Normal file
@ -0,0 +1,123 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
auditSnippets,
|
||||
autoplays,
|
||||
findMotionGuardViolations,
|
||||
splitComponents,
|
||||
} from "./check-docs-snippet-motion.mjs";
|
||||
|
||||
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(), []);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user