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:
Ular Kimsanov 2026-08-04 00:37:48 -07:00 committed by GitHub
parent 0a70ba6717
commit edfe66a953
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 3508 additions and 98 deletions

6
.gitattributes vendored
View File

@ -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
View File

@ -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/

View File

@ -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"
}
}
]

View File

@ -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;
}

View File

@ -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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -0,0 +1,8 @@
.work/
.hyperframes/
.transcode-cache/
node_modules/
renders/
capture/
live/
snapshots/

View 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.

View 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.

View 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.

View 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.001.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.002.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.333.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.515.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.156.42s): **held read.** Everything on screen has resolved. The VO carries
_"Use it in your docs…"_ alone. No motion at all.
Scene 6 (6.427.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.1010.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.

View 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` |
| &nbsp;&nbsp; lint | 0 errors · 0 warnings · 1 info |
| &nbsp;&nbsp; runtime | 0 errors · 0 warnings · 0 info |
| &nbsp;&nbsp; layout | 0 errors · 0 warnings · 1 info |
| &nbsp;&nbsp; motion | 0 findings (no `*.motion.json` sidecars in this project) |
| &nbsp;&nbsp; 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.

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8935eb87c960b062ad9c42bce3f4e12408f389dfb82acba986999e62db76fe5b
size 1920078

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4fca03080a6626474df0deda3f6b1b679321a4b6c813ec2876ac8baae476fd49
size 614478

Binary file not shown.

Binary file not shown.

View 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 9341016, 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>

View 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.

View 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"
}

View 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 3841131,
y 162260 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>

View File

@ -0,0 +1,5 @@
{
"id": "docs-reference-project",
"name": "docs-reference-project",
"createdAt": "2026-08-03T09:05:27.101Z"
}

View 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"
}
}

View 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
}
]

View File

@ -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",

View 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);
}
}

View 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(), []);
});