feat(catalog): browse the catalog as a live grid, not a static index (#4051)

* feat(catalog): browse the catalog as a live grid, not a static index

The Catalog overview page now mounts a browse grid (posters, tags,
per-category rows, expandable sections) generated over the existing
~380 registry items, instead of one static hero video plus two links.
docs/snippets/catalog-gallery.jsx and docs/catalog-gallery.css are
ported from a reference implementation of this same UX; the CSS keeps
its original comments as-is (NO_ESSAY_COMMENTS_OK: vendored file).

scripts/sync-docs-catalog.mjs and build-docs-gallery-nav.mjs are new,
generating the gallery's data and the Catalog tab's nav directly from
this repo's registry/*/registry-item.json and docs.json, so no sparse
clone or snapshot of another project is needed. scripts/check-docs-catalog.mjs
proves every catalog page from before this change still exists, on
disk and in the sidebar, with an identical page list.

No catalog page or snippet is removed; the previous overview content
(catalog-overview-player.jsx) is unused on this page but stays in the
repo.

* refactor(catalog): share the docs-catalog helpers across the sync scripts

Three independent adversarial reviews (reuse, quality, efficiency) all
converged on the same shape: sync-docs-catalog.mjs, build-docs-gallery-nav.mjs
and check-docs-catalog.mjs each hand-wrote the same docs-root resolution,
the same catalog-gallery-data.mdx parse, and the same Catalog-tab lookup.
Extracted into scripts/docs-catalog-shared.mjs.

sync-docs-catalog.mjs also ran build-docs-gallery-nav.mjs as a dynamic
import purely for its side effect, forcing a same-process re-read and
re-parse of the data file it had just written. build-docs-gallery-nav.mjs
now exports buildNav(docs), called directly with the path already
resolved; its CLI entry is guarded for standalone use.

slug() dropped the /^-+|-+$/ trim in favor of the character-scan trim
packages/core/src/tokenSlug.ts already uses for the same reason (CodeQL
flags that regex as polynomial ReDoS, js/polynomial-redos); not imported
directly since tokenSlug.ts is compiled TS with a different domain
(CSS variable naming) and no existing scripts/*.mjs imports packages/core
source at plain node runtime.

Fixed one bug found while re-verifying after the refactor: the recursive
nav walk wasn't idempotent against its own generated output — re-running
sync-docs-catalog.mjs a second time saw the "Catalog" wrapper buildNav()
adds and misattributed every flat, single-section group under it, 9
groups collapsing to 7. walk() now treats "Catalog" as transparent, the
same way it already treated "Overview". Verified: three consecutive runs
from a fresh origin/main checkout now produce identical 9 groups / 400
items each time, and check-docs-catalog.mjs still passes.

* chore(catalog): scrub an internal decision code from a code comment

* fix(catalog): stop losing a section's label when its nav is flattened

An independent adversarial review found real data loss: build-docs-gallery-nav.mjs
collapsed a group with exactly one section into a flat page list (a clean sidebar
for the common case), but sync-docs-catalog.mjs's walk() derives an item's section
from nav depth, so a second sync run over that flattened nav could no longer
recover the section name and silently fell back to the group's own label. Data &
charts / Data was the one real casualty in this catalog; Carousels and Blocks
happened to already have a section named the same as their group, so the loss
was invisible there.

Fix: only flatten when the section label equals the group label, so nothing
non-recoverable is thrown away. Verified with a deliberate mutation (reverted the
condition, reproduced the exact bug from a clean origin/main checkout, confirmed
the new check-docs-catalog.mjs assertion goes red, then restored the fix and
confirmed 3 consecutive runs produce identical, correct section labels).

check-docs-catalog.mjs now cross-checks every item's section against the nav
itself (not just its href), closing the gap the review found: the old check only
proved the page list matched, never that section identity survived a re-sync.

Also deletes docs/snippets/catalog-overview-player.jsx: unreferenced anywhere in
the repo now that catalog/index.mdx mounts CatalogGallery instead (confirmed with
a repo-wide grep before removing).

* chore(catalog): allowlist the deliberate catalog-overview-player.jsx deletion

* feat(catalog): live-hover preview, fuller sidebar, landing order

Three product asks against the open PR's Mintlify preview, plus one
review finding:

- Sidebar: every top-level Catalog group now defaults to expanded, so
  the column shows real content instead of nine collapsed rows over
  an empty column. Subsections stay collapsed one level down, the
  same depth the reference site itself uses.
- Landing order: Carousels moves to first position (3D motion, not
  yet in this repo's registry, will sort first once a separate
  initiative lands it - the priority list already accounts for it).
  Only the pinned group shows two rows instead of one, matching the
  reference site's own convention rather than every group.
- Hover-to-play: items whose composition is live-capable (a paused,
  seekable GSAP timeline, no sub-composition mount, no WebGPU) get a
  real hover-preview wired to their own composition HTML, extracted
  once as a static sidecar file from data this repo already serves
  at each item's own doc page. Items with a recorded preview video
  fall back to that; everything else shows its poster. Verified no
  WebGL/three.js item is ever marked live (238 live, 30 video, 132
  poster-only of 400).
- The vendored player CDN reference was already fixed to @latest in
  the prior commit; noting it here since a reviewer flagged the same
  line independently on the prior head.

docs/public/catalog/gallery-live/**/*.html (238 files, NO_ESSAY_COMMENTS_OK)
are generated sidecars: each one is a composition's existing HTML, already
tracked verbatim in docs/public/catalog/{blocks,components}/*.json, written
out as a plain file so the hover-preview snippet can fetch it as text. Not
hand-authored; their comments are the original composition authors', copied
unchanged. Regenerated by scripts/sync-docs-catalog.mjs on every run.

* refactor(catalog): merge the item and preview passes, one group-label source

itemFrom() and walk() computed the same group-label fallback expression
independently; itemFrom now returns it once alongside the item instead
of the caller re-deriving it. The item/preview construction and the
separate "for (const item of items)" preview pass are merged into one
walk() as items are built, dropping the mutate-then-delete item.live
scratch field. Byte-identical generated output, confirmed by diff.

* chore(catalog): scrub the private upstream repo name from code comments

An independent adversarial review found the same leak class as an
earlier fix (commit cfe14f813, an internal decision code) in four
provenance comments naming a private individual's private GitHub
repository and an exact commit sha, in a public repo. Replaced with
a functional description of the port; no identifying reference to
the source repo or its owner remains in tracked file content.

The PR description had the same reference (a live markdown link);
edited directly on the open PR, no code change needed for that half.

* chore(catalog): restore the adapted-from-elsewhere signal without the name

* fix(docs-catalog): fail hard when the page-list merge-base diff cannot be computed

* fix(catalog): mark canvas-draw-element items unsupported instead of dropping their payload

* fix(catalog): collapse every sidebar group, fill column with descriptions

Mintlify's group schema has no description field, so each group's blurb is
a CSS ::after keyed off the sidebar's own data-title/data-group-tag attributes instead.

NO_ESSAY_COMMENTS_OK: false positive.
The real /* */ block is 3 lines. The hook flags plain CSS declaration lines
(long selectors plus content:var() strings) as comments; they are code, not prose.

* feat(catalog): fold the 3D-motion group into the browse grid

Regenerated after merging feat/ad93-3d-motion-catalog: 407 items, 10

groups, 3D motion leading and pinned on the landing order.

* chore(catalog): regenerate hover sidecars for newly-live-eligible items

heygen-avatar-promo-card, hw-write-title, slack-notification-ad, orbit-card.

NO_ESSAY_COMMENTS_OK: generated composition HTML, not hand-authored.

* fix(docs-catalog): the page-list check should catch loss, not additions

It was written for a pure refactor (this PR's original scope: same pages,

different nav shape) so identical-set was the right proof. Stacking a

sibling feature branch on top legitimately adds pages; the real invariant

is nothing lost and no duplicates, so only that half stays an assertion.

* fix(catalog): mount the player from each item's own payload, no CDN needed

Root cause of the grey Carousels/3D-motion tiles: those registry items

never had a CDN poster/video, and the .html hover sidecar 404s on the

deployed docs host (Mintlify drops .html/.js/.css from docs/public with

no error). Cards now fetch the same repo-served .json payload the detail

page already uses, mount a player, seek to 3s and rest there, play on

hover -- viewport-gated, capped per tier (DOM vs WebGL) so a heavy row

can't blow the page's GPU budget. Also fixes a real regression: the

canvas-draw-element marker file was making detail pages embed a broken

player (srcdoc=undefined) instead of falling back correctly; it now

renders an honest tile naming the required flag. Deletes the now-dead

.html sidecar mechanism and its vendored gating helper.

* fix(catalog): move player-mount consts inside the component scope

Mintlify's snippet bundler drops top-level const/function declarations that sit
outside the exported binding, so every reference to MAX_DOM_PLAYERS and
MAX_WEBGL_PLAYERS threw ReferenceError on the deployed build, silently killing
the mount before any fetch ran.

Also stop suppressing the title placeholder for player-mode cards: it must
stay visible until a frame actually paints, not disappear into an empty box
the moment the observer decides to mount.

* fix(catalog): make mount failures loud and add a bundler-scope check

Widen mount's try/catch to cover the entire body, including the tier lookup
that previously threw outside it -- any failure now logs once and leaves the
titled placeholder in place instead of silently dropping into an empty box.

Add a check-docs-catalog.mjs assertion (via the TypeScript compiler API) that
fails the build if catalog-gallery.jsx gains a top-level const or function
outside its exported component, so the next contributor cannot reintroduce
the bundler-drop bug silently.

* fix(catalog): fall back to placeholder when a preview never reaches ready

A player that neither errors nor fires ready within a few seconds left a bare
mounted element with nothing painted. Also fixes unmount() keying its map
lookup by the host DOM node while mount() stores state under item.href,
which meant scrolled-away previews never freed their tier cap slot.

* docs(catalog): trim an essay-length comment in docs-catalog-shared.mjs

* fix(catalog): drop the WebGPU tile message, keep the headline mounted

A WebGPU-gapped item showed a dedicated "Needs WebGPU" card instead of
falling back to its poster/video like every other unrenderable-live
item already does; previewFor() now treats every preview gap the
same way, so the tile draws whatever media the item has instead of a
special-cased message. Also memoises MotionWord so the headline
animation keeps its component identity across hover/filter re-renders
instead of remounting and restarting.

* chore(catalog): regenerate catalog data and nav after retargeting to main

The seven 3D-motion items only exist on the 3D-motion-catalog branch;
regenerating against main drops their dead nav/gallery entries so the
grid ships over the existing 400 items with no broken link or empty
tile. They come back automatically once that branch lands and this
generator runs again.

* fix(docs): clear format, lint and complexity gates on the catalog PR

Format/Preflight/Windows-tests were failing on two unformatted files;
ran oxfmt to fix. Fallow's high-crap-score gate was newly flagging six
pre-existing functions that only became "new" findings because the
branch was retargeted directly onto main. Collapsed textureSampleWord's
23-branch if-chain into an equivalent lookup array (verified identical
output against 1547 real registry slugs plus adversarial ordering
cases), and exempted the remaining pre-existing, unauthored findings
using this repo's established fallow-ignore-next-line and
.fallowrc.jsonc health.ignore conventions.

* fix(catalog): drop the stale index prose, give the new item a CDN preview

The old static-index page's "Use an item" tutorial and group tour sat
unstyled below the new browse grid and named groups that no longer
exist; the grid already shows the current groups, so the block is
removed rather than rewritten. Kept the required Related topics
section.

heygen-avatar-promo-card now carries a poster/video CDN preview like
every other item, so its gallery tile has something to draw at rest.

* fix(catalog): correct an inaccurate fallow-exemption justification

A reviewer caught that the generate-catalog-payloads.ts exemption
claimed the file was unmodified by this PR when it is not; describe
the actual change instead.

* fix(catalog): style the Related topics section instead of leaving it bare

The page renders in Mintlify's frame mode, which drops normal prose
styling, so the plain markdown heading and links below the gallery
rendered as unstyled grey text with no spacing. Replaced with a small
styled section matching the gallery's own visual language (shared
--hf-* theme tokens, same page gutter), rather than dropping the links.

* refactor(catalog): use this repo's own CardGroup pattern for Related topics

An independent review caught that the hand-rolled CSS from the prior
commit duplicated a pattern this repo already has: docs/studio/index.mdx
styles its own Related topics section with Mintlify's built-in
CardGroup/Card components. Switched to that instead and dropped the
custom CSS. Also trimmed an over-long fallow-exemption comment another
reviewer flagged as narrating process rather than stating the invariant.

* refactor(catalog): drop unreachable player branch in gallery hover effect

* fix(catalog): keep nav landing page and recorded videos, free player slots, check in CI

The generator dropped catalog/index and replaced recorded videos with a flag notice.
A card scrolled away before ready released its player slot twice.
check-docs-catalog now runs in CI.

* fix(catalog): release player slots in one place, run catalog check on docs-only PRs

Unmount, error, timeout and catch now share one idempotent release.
The Lint job also runs when only docs or registry files change.

* fix(catalog): a superseded failed mount no longer marks the new card unavailable

* revert(catalog): move the CI wiring out of this PR

The CI change ships separately as chore(ci).

* fix(catalog): the generator prunes generated pages and keeps hand-written ones

Replaces the re-add guard: a group holding catalog/index survives with the index intact.
This commit is contained in:
Miguel Ángel
2026-09-18 15:10:35 -04:00
committed by GitHub
parent a7ec66b124
commit 9bb4f5cd57
31 changed files with 2707 additions and 719 deletions
+5
View File
@@ -966,6 +966,11 @@
// file-level fingerprint invalidation re-flags the inherited findings even at
// unchanged line numbers.
"packages/core/src/runtime/picker.ts",
// generate-catalog-payloads.ts: hostsOwnDirectory, parseArgs and main are
// untouched. buildPayload's canvas-draw-element branch now writes a marker
// via a small writePayload helper instead of dropping the payload, but at
// the same branch count — its flagged 14-branch CRAP score predates this PR.
"scripts/generate-catalog-payloads.ts",
],
},
}
+80
View File
@@ -0,0 +1,80 @@
/* Catalog presentation; Mintlify owns the surrounding page chrome. */
.hfc-gallery { color:var(--hf-text); font-family:inherit; }
.hfc-gallery * { box-sizing:border-box; }
.hfc-gallery :is(button,a):focus-visible { outline:2px solid var(--hf-brand); outline-offset:4px; }
.hfc-gallery button { font:inherit; cursor:pointer; }
.hfc-gallery .hfc-hero h1 { line-height:1.08; font-weight:600; letter-spacing:-.045em; margin:0 0 24px; color:var(--hf-heading); }
.hfc-gallery .hfc-hero > p { font-size:15px; line-height:1.7; color:var(--hf-text-secondary); max-width:560px; margin:0; }
.hfc-gallery .hfc-overview { display:flex; flex-direction:column; gap:44px; }
.hfc-gallery .hfc-section-head { display:flex; justify-content:space-between; align-items:center; gap:12px; margin-bottom:20px; }
.hfc-gallery .hfc-section-head h2 { display:flex; align-items:center; gap:12px; font-size:20px; line-height:1.3; font-weight:600; margin:0; color:var(--hf-heading); }
.hfc-gallery .hfc-section-head button { display:flex; align-items:center; justify-content:center; gap:6px; min-height:44px; min-width:44px; border:0; background:transparent; color:var(--hf-text-secondary); padding:6px 0 6px 8px; font-size:12px; white-space:nowrap; }
.hfc-gallery .hfc-caret { flex-shrink:0; transition:transform .16s ease; }
.hfc-gallery .hfc-section-head button[aria-expanded="true"] .hfc-caret { transform:rotate(180deg); }
.hfc-gallery .hfc-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:30px 20px; }
.hfc-gallery a.hfc-card,.hfc-gallery a.hfc-card:hover { display:block; min-width:0; text-decoration:none; color:inherit; border:0; background:transparent; box-shadow:none; transform:none; }
.hfc-gallery .hfc-media { position:relative; aspect-ratio:16/9; isolation:isolate; overflow:hidden; border-radius:12px; background:#101214; }
.hfc-gallery .hfc-media :is(img,video) { position:absolute; inset:0; width:100%; height:100%; object-fit:cover; margin:0; border:0; border-radius:0; }
.hfc-gallery .hfc-preview-host { position:absolute; inset:0; pointer-events:none; opacity:0; }
.hfc-gallery .hfc-preview-host[data-ready="true"] { opacity:1; }
.hfc-gallery .hfc-preview-host hyperframes-player { display:block; width:100%; height:100%; pointer-events:none; }
.hfc-gallery .hfc-fallback { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:24px; height:100%; text-align:center; color:#ccc; background:#141414; }
.hfc-gallery .hfc-fallback span { color:#999; font-size:10px; }
.hfc-gallery .hfc-fallback strong { font-size:18px; line-height:1.2; }
.hfc-gallery .hfc-unsupported { gap:6px; padding:20px; }
.hfc-gallery .hfc-unsupported span { font-size:11px; }
.hfc-gallery .hfc-unsupported code { font-size:11px; padding:2px 6px; border-radius:6px; background:#0006; color:#ccc; }
.hfc-gallery .hfc-card-body { padding:12px 2px 0; }
.hfc-gallery .hfc-card-title { display:flex; align-items:start; justify-content:space-between; gap:10px; }
.hfc-gallery .hfc-card-title h3 { font-size:15px; font-weight:500; line-height:1.4; margin:0; color:var(--hf-heading); }
.hfc-gallery .hfc-status { flex-shrink:0; padding:2px 7px; border-radius:20px; background:#38bdf812; color:#60a5da; font-size:11px; }
.hfc-gallery .hfc-meta { font-size:12px; line-height:1.5; margin:4px 0 0; color:var(--hf-text-secondary); }
.hfc-gallery .hfc-empty { padding:44px 20px; text-align:center; }
.hfc-gallery .hfc-empty h3 { font-size:18px; }
.hfc-gallery .hfc-empty p { color:var(--hf-text-secondary); font-size:13px; }
.hfc-gallery .hfc-empty button,.hfc-gallery .hfc-load { padding:10px 16px; border:1px solid var(--hf-border-color); border-radius:8px; color:var(--hf-text); background:var(--hf-background-light); font-size:12px; }
.hfc-gallery .hfc-load { display:block; margin:24px auto 0; }
/* Featured is a page, with the same selected treatment as nested item pages. */
#navigation-items:has(a[href="/catalog"]) .sidebar-group-header { display:none; }
#navigation-items .sidebar-group > li > a[href="/catalog"] { width:100%; margin-left:0; padding:6px 12px; border:0; border-radius:12px; }
#navigation-items .sidebar-group > li[data-active="true"] > a[href="/catalog"],
#navigation-items .sidebar-group > li[data-active-nav-item="true"] > a[href="/catalog"],
#navigation-items .sidebar-group > li > a[href="/catalog"][aria-current="page"] { color:var(--hf-brand); background:color-mix(in oklab,var(--hf-brand) 10%,transparent) !important; }
@media(max-width:1100px) and (min-width:601px){.hfc-gallery .hfc-grid{grid-template-columns:repeat(2,minmax(0,1fr));}}
@media(max-width:600px){.hfc-gallery .hfc-grid{grid-template-columns:minmax(0,1fr);}.hfc-gallery .hfc-card-title h3{font-size:16px;}}
@media(prefers-reduced-motion:reduce){.hfc-gallery *{transition:none !important;}}
#navigation-items a[href^="/catalog/"][aria-current="page"] { color:var(--hf-brand); background:color-mix(in oklab,var(--hf-brand) 10%,transparent) !important; }
/* Mintlify's group schema has no description field, so each group's blurb is a CSS
::after keyed by the sidebar's own data-title/data-group-tag. Reset stops it inheriting
onto nested subgroups. Keep labels in sync with sync-docs-catalog.mjs. */
#navigation-items li[data-group-tag] { --catalog-desc:""; }
#navigation-items li[data-group-tag]::after { content:var(--catalog-desc); display:block; padding:0 12px 8px 40px; font-size:11px; line-height:1.5; color:var(--hf-text-secondary); }
#navigation-items li[data-title="3D motion"][data-group-tag] { --catalog-desc:"Three.js scenes: orbiting cards, particle fields, and depth-lit product shots."; }
#navigation-items li[data-title="Carousels"][data-group-tag] { --catalog-desc:"Image cards that circle, orbit, or sweep a path across the frame."; }
#navigation-items li[data-title="Text & captions"][data-group-tag] { --catalog-desc:"Word-by-word caption reveals, highlights, and kinetic type."; }
#navigation-items li[data-title="Code"][data-group-tag] { --catalog-desc:"Code snippets that type, diff, highlight, and morph on screen."; }
#navigation-items li[data-title="Transitions"][data-group-tag] { --catalog-desc:"Cuts between shots: zooms, warps, glitches, light leaks."; }
#navigation-items li[data-title="Data & charts"][data-group-tag] { --catalog-desc:"Bar, line, and progress charts that animate their own data in."; }
#navigation-items li[data-title="Scenes & demos"][data-group-tag] { --catalog-desc:"Full scenes: app showcases, chat exchanges, product demos."; }
#navigation-items li[data-title="Motion & effects"][data-group-tag] { --catalog-desc:"General-purpose motion: hovers, pops, blurs, grids, paths."; }
#navigation-items li[data-title="Surfaces"][data-group-tag] { --catalog-desc:"Background textures and screen treatments: grain, halftone, glass."; }
#navigation-items li[data-title="Blocks"][data-group-tag] { --catalog-desc:"Flowcharts, sketched frames, speech bubbles, and outro cards."; }
/* Animated headline. */
.hfc-gallery .mw { display: inline-block; position: relative; white-space: nowrap; --phase: 0; --period: 6; --bl: .87em; }
.hfc-gallery .mw .ch { display: inline-block; position: relative; transform-origin: 50% 70%; background-image: var(--grad); background-size: calc(var(--period) * 1em) 100%; background-position: calc((var(--phase) * var(--period) - var(--x, 0)) * 1em) 0; -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: transparent; }
.hfc-gallery .mw .bl { display: inline-block; width: 0; height: 0; vertical-align: baseline; }
.hfc-gallery .mw .dsvg { position: absolute; left: var(--dot-x, 50%); top: var(--dot-y, .16em); width: calc(var(--dot-d, .15em) * 5); height: calc(var(--dot-d, .15em) * 5); margin: calc(var(--dot-d, .15em) * -2.5) 0 0 calc(var(--dot-d, .15em) * -2.5); overflow: visible; pointer-events: none; }
.hfc-gallery .mw .sq { position: absolute; display: block; left: 0; top: calc(var(--bl) + .05em); width: 100%; height: auto; overflow: visible; pointer-events: none; }
.hfc-gallery{padding:0 32px;max-width:1400px;margin:0 auto 48px;}
.hfc-gallery .hfc-hero{padding:48px 0;}
.hfc-gallery .hfc-hero h1{max-width:800px;font-size:clamp(34px,4vw,54px);}
.hfc-gallery .mw .dsvg{left:50%;top:.18em;width:.75em;height:.75em;margin:-.375em 0 0 -.375em;}
.hfc-gallery .mw .sq{top:.94em;}
.hfc-gallery .mw .ring{transform-origin:0 0;}
.hfc-gallery .mw .sq path{stroke-dasharray:220;stroke-dashoffset:220;}
@media(prefers-reduced-motion:reduce){.hfc-gallery .mw .sq path{stroke-dashoffset:0;}}
@media(max-width:600px){.hfc-gallery{padding:0 18px;}.hfc-gallery .hfc-hero{padding:28px 0;}}
+16 -63
View File
@@ -1,72 +1,25 @@
---
title: "Catalog"
sidebarTitle: "Featured"
mode: "frame"
description: "Browse reusable HyperFrames scenes, transitions, captions, overlays, effects, and components."
---
import { CatalogOverviewPlayer } from "/snippets/catalog-overview-player.jsx"
import { catalogGalleryData } from "/snippets/catalog-gallery-data.mdx";
import { CatalogGallery } from "/snippets/catalog-gallery.jsx";
The Catalog gives you reusable visuals you can add to a project instead of building each one from scratch.
<CatalogOverviewPlayer title="A fast tour of every HyperFrames Catalog section">
```html catalog-overview-v3.html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body {
margin: 0;
height: 100%;
overflow: hidden;
background: #05070b;
}
hyperframes-player {
display: block;
width: 100%;
height: 100%;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@latest/dist/hyperframes-player.global.js"></script>
</head>
<body>
<script type="module">
const item = await fetch("/public/catalog/blocks/ai-chat-reveal.json").then((response) =>
response.json(),
);
const player = document.createElement("hyperframes-player");
player.setAttribute("srcdoc", item.html);
player.setAttribute("controls", "");
player.setAttribute("autoplay", "");
player.setAttribute("muted", "");
player.setAttribute("loop", "");
document.body.appendChild(player);
</script>
</body>
</html>
```
</CatalogOverviewPlayer>
<CardGroup cols={2}>
<Card title="Blocks" icon="objects-column" href="/catalog/blocks/app-showcase">
Start with a larger scene or self-contained visual.
</Card>
<Card title="Components" icon="puzzle-piece" href="/catalog/components/caption-kinetic-slam">
Add a smaller effect or behavior inside an existing scene.
</Card>
</CardGroup>
## Use an item
1. Open an item and watch the motion preview.
2. Copy its plain-language request into your agent chat.
3. Replace the example content, then review it inside your complete video.
The tour covers Code Animations, Captions, HTML-in-Canvas, Social Overlays, Lower Thirds, Shader Transitions, CSS Transitions, Showcases, Data, Effects, and Blocks.
<CatalogGallery catalog={catalogGalleryData} />
## Related topics
- [Add Catalog items in Studio](/studio/assets-and-blocks)
- [Build richer compositions](/go-further)
- [Contribute a Catalog item](/contributing/catalog)
<CardGroup cols={3}>
<Card title="Add Catalog items in Studio" icon="wand-magic-sparkles" href="/studio/assets-and-blocks">
Drop a catalog block or component into a project from inside Studio.
</Card>
<Card title="Build richer compositions" icon="layer-group" href="/go-further">
Combine items, add your own motion, go past a single block.
</Card>
<Card title="Contribute a Catalog item" icon="code-branch" href="/contributing/catalog">
Add a new block or component to this catalog.
</Card>
</CardGroup>
+522 -480
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
<svg width="127" height="127" xmlns="http://www.w3.org/2000/svg">
<path d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z" fill="#E01E5A"/>
<path d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z" fill="#36C5F0"/>
<path d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z" fill="#2EB67D"/>
<path d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z" fill="#ECB22E"/>
</svg>

After

Width:  |  Height:  |  Size: 1019 B

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"unsupported":"canvas-draw-element"}
@@ -0,0 +1 @@
{"unsupported":"canvas-draw-element"}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"unsupported":"canvas-draw-element"}
@@ -0,0 +1 @@
{"unsupported":"canvas-draw-element"}
@@ -0,0 +1 @@
{"unsupported":"canvas-draw-element"}
@@ -0,0 +1 @@
{"unsupported":"canvas-draw-element"}
@@ -0,0 +1 @@
{"unsupported":"canvas-draw-element"}
@@ -0,0 +1 @@
{"unsupported":"canvas-draw-element"}
File diff suppressed because one or more lines are too long
+420
View File
@@ -0,0 +1,420 @@
// Ported verbatim from a reference implementation of this catalog UX. Consumes
// catalog-gallery-data.mdx from scripts/sync-docs-catalog.mjs.
export const CatalogGallery = ({ catalog, initialGroup = "", initialSection = "" }) => {
// Mintlify's snippet bundler only preserves the exported binding's own closure;
// sibling top-level const/function declarations in this file are dropped from the
// deployed build, so these must live inside the component (window.__hfDocsPlayerLoading
// still dedupes the actual script load across every instance).
const PLAYER_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/@hyperframes/player@latest/dist/hyperframes-player.global.js';
const REST_SECONDS = 3;
const MAX_DOM_PLAYERS = 6;
const MAX_WEBGL_PLAYERS = 1;
const READY_TIMEOUT_MS = 6000;
async function ensurePlayerDefined() {
if (customElements.get('hyperframes-player'))
return;
if (!window.__hfDocsPlayerLoading) {
window.__hfDocsPlayerLoading = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = PLAYER_SCRIPT_URL;
script.onload = resolve;
script.onerror = () => { script.remove(); delete window.__hfDocsPlayerLoading; reject(new Error('Player unavailable')); };
document.head.appendChild(script);
});
}
await window.__hfDocsPlayerLoading;
await customElements.whenDefined('hyperframes-player');
}
// Keep the child component type stable when gallery hover state changes.
const MotionWord = useMemo(() => function MotionWord() {
const root = useRef(null);
useEffect(() => {
const node = root.current;
const media = matchMedia('(prefers-reduced-motion: reduce)');
let observer, frame = 0, animations = [];
let visible = true, previous = 0, elapsed = 0;
const circle = [0, -10, 5.52, -10, 10, -5.52, 10, 0, 10, 5.52, 5.52, 10, 0, 10, -5.52, 10, -10, 5.52, -10, 0, -10, -5.52, -5.52, -10, 0, -10];
const spark = [0, -17, 1.6, -7, 7, -1.6, 17, 0, 7, 1.6, 1.6, 7, 0, 17, -1.6, 7, -7, 1.6, -17, 0, -7, -1.6, -1.6, -7, 0, -17];
const pathAt = m => { const a = circle.map((n, i) => n + (spark[i] - n) * m); return 'M' + a.slice(0, 2).join(' ') + 'C' + a.slice(2).join(' ') + 'Z'; };
const setup = () => {
cancelAnimationFrame(frame);
animations.forEach(a => a.cancel());
animations = [];
node.style.setProperty('--phase', 0);
node.querySelector('.dot').setAttribute('d', pathAt(0));
if (media.matches)
return;
const duration = 5600;
node.querySelectorAll('.ch').forEach((letter, i) => {
animations.push(letter.animate([
{ transform: 'translateY(0) rotate(0) skewX(0)', offset: 0 },
{ transform: `translateY(-18%) rotate(${i % 2 ? 6 : -6}deg)`, offset: .05 },
{ transform: 'translateY(4%) rotate(0)', offset: .12 },
{ transform: 'translateY(-2%)', offset: .19 },
{ transform: 'translateY(0) skewX(0)', offset: .3 },
{ transform: 'skewX(0)', offset: .43 },
{ transform: 'skewX(-12deg)', offset: .47 },
{ transform: 'skewX(2deg)', offset: .56 },
{ transform: 'skewX(0)', offset: .7 },
{ transform: 'translateY(0) rotate(0) skewX(0)', offset: 1 }
], { duration, delay: i * 60, iterations: Infinity, easing: 'ease-in-out' }));
});
animations.push(node.querySelector('.jump').animate([{ transform: 'translateY(0)' }, { transform: 'translateY(-45px)', offset: .09 }, { transform: 'translateY(0)', offset: .18 }, { transform: 'translateY(-7px)', offset: .22 }, { transform: 'translateY(0)', offset: .27 }, { transform: 'translateY(0)' }], { duration, iterations: Infinity, easing: 'ease-in-out' }));
animations.push(node.querySelector('.ring').animate([{ opacity: 0, transform: 'scale(.6)' }, { opacity: 0, transform: 'scale(.6)', offset: .13 }, { opacity: .9, transform: 'scale(.6)', offset: .14 }, { opacity: 0, transform: 'scale(3.8)', offset: .29 }, { opacity: 0 }], { duration, iterations: Infinity }));
const line = node.querySelector('.sq path');
line.style.strokeDasharray = '220';
animations.push(line.animate([{ strokeDashoffset: 220 }, { strokeDashoffset: 220, offset: .43 }, { strokeDashoffset: 0, offset: .58 }, { strokeDashoffset: 0, offset: .72 }, { strokeDashoffset: -220, offset: .84 }, { strokeDashoffset: -220 }], { duration, iterations: Infinity, easing: 'ease-in-out' }));
const tick = now => {
if (visible && !document.hidden) {
elapsed += previous ? Math.min(now - previous, 50) : 0;
const t = (elapsed % duration) / duration;
let morph = t < .43 ? 0 : t < .51 ? (t - .43) / .08 : t < .69 ? 1 : t < .77 ? 1 - (t - .69) / .08 : 0;
morph = morph * morph * (3 - 2 * morph);
const dot = node.querySelector('.dot');
dot.setAttribute('d', pathAt(morph));
dot.setAttribute('transform', 'rotate(' + (t < .43 ? 0 : Math.min((t - .43) / .34, 1) * 360) + ')');
node.style.setProperty('--phase', (elapsed % 9000) / 9000);
}
previous = now;
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
};
const measure = () => { const size = parseFloat(getComputedStyle(node).fontSize); node.style.setProperty('--period', node.offsetWidth * 1.7 / size); node.querySelectorAll('.ch').forEach(ch => ch.style.setProperty('--x', ch.offsetLeft / size)); };
const resize = new ResizeObserver(measure);
resize.observe(node);
measure();
document.fonts?.ready.then(measure);
const visibility = () => animations.forEach(a => visible && !document.hidden ? a.play() : a.pause());
observer = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; visibility(); });
observer.observe(node);
document.addEventListener('visibilitychange', visibility);
media.addEventListener('change', setup);
setup();
return () => { cancelAnimationFrame(frame); animations.forEach(a => a.cancel()); observer.disconnect(); resize.disconnect(); media.removeEventListener('change', setup); document.removeEventListener('visibilitychange', visibility); };
}, []);
return React.createElement("span", { className: "mw", ref: root, "aria-label": "Motion", style: { '--grad': 'linear-gradient(100deg,#3ce6ac 0%,#7cc4ff 25%,#b48cff 50%,#ffb35c 75%,#3ce6ac 100%)' } },
['M', 'o', 't', 'ı', 'o', 'n'].map((letter, i) => React.createElement("span", { key: i, className: 'ch' + (i === 3 ? ' i' : ''), "aria-hidden": "true" },
letter,
i === 3 && React.createElement("svg", { className: "dsvg", viewBox: "-50 -50 100 100" },
React.createElement("circle", { className: "ring", r: "10", fill: "none", stroke: "#7cc4ff", strokeWidth: "2", opacity: "0" }),
React.createElement("g", { className: "jump" },
React.createElement("path", { className: "dot", d: "M0 -10C5.52 -10 10 -5.52 10 0C10 5.52 5.52 10 0 10C-5.52 10 -10 5.52 -10 0C-10 -5.52 -5.52 -10 0 -10Z", fill: "#7cc4ff" }))))),
React.createElement("svg", { className: "sq", viewBox: "0 0 196 12", "aria-hidden": "true" },
React.createElement("path", { d: "M2 6Q18 -2 34 6T66 6T98 6T130 6T162 6T194 6", fill: "none", stroke: "#7cc4ff", strokeWidth: "2.4", strokeLinecap: "round" })));
}, []);
const [filters, setFilters] = useState({ q: "", group: initialGroup, section: initialSection, kind: "", sort: "featured" });
const [active, setActive] = useState(null);
const [reduced, setReduced] = useState(true);
const [limit, setLimit] = useState(24);
const [expandedGroups, setExpandedGroups] = useState({});
const resultsRef = useRef(null);
useEffect(() => {
const read = () => {
const params = new URLSearchParams(window.location.search);
setFilters({ q: params.get("q") || "", group: params.get("group") || initialGroup, section: params.get("section") || initialSection, kind: params.get("kind") || "", sort: params.get("sort") || "featured" });
setLimit(24);
setActive(null);
};
read();
window.addEventListener("popstate", read);
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const pointer = window.matchMedia("(hover: hover) and (pointer: fine)");
const motion = () => { setReduced(query.matches || !pointer.matches || navigator.maxTouchPoints > 0); setActive(null); };
const visibility = () => { if (document.hidden)
setActive(null); };
motion();
query.addEventListener("change", motion);
pointer.addEventListener("change", motion);
document.addEventListener("visibilitychange", visibility);
return () => { window.removeEventListener("popstate", read); query.removeEventListener("change", motion); pointer.removeEventListener("change", motion); document.removeEventListener("visibilitychange", visibility); };
}, [initialGroup, initialSection]);
// One effect owns the only active preview, including pending network requests.
useEffect(() => {
if (!active || reduced || document.hidden)
return;
const item = catalog.items.find(item => item.href === active);
if (item?.preview?.mode !== 'video')
return;
const host = resultsRef.current?.querySelector(`[data-preview-host="${CSS.escape(active)}"]`);
if (!host)
return;
let cancelled = false;
let element;
let timeout;
const abort = new AbortController();
const release = () => {
clearTimeout(timeout);
if (element) {
element.pause();
if (element.tagName === 'VIDEO') {
element.removeAttribute('src');
element.load();
}
element.remove();
element = null;
}
delete host.dataset.ready;
};
const fail = () => {
if (cancelled)
return;
cancelled = true;
abort.abort();
release();
host.dataset.state = 'unavailable';
};
const show = () => {
if (cancelled || !element)
return;
clearTimeout(timeout);
host.dataset.ready = 'true';
host.dataset.state = 'playing';
};
const start = async () => {
host.dataset.state = 'loading';
timeout = setTimeout(fail, 12000);
try {
element = document.createElement('video');
element.muted = true;
element.loop = true;
element.playsInline = true;
element.addEventListener('error', fail, { once: true });
element.addEventListener('playing', show, { once: true });
element.src = item.video;
if (cancelled) {
release();
return;
}
element.setAttribute('inert', '');
element.setAttribute('tabindex', '-1');
element.setAttribute('aria-hidden', 'true');
host.appendChild(element);
element.play().catch(fail);
}
catch {
if (!cancelled)
fail();
}
};
// Avoid starting a composition while the pointer merely crosses the grid.
const intent = setTimeout(start, 180);
const observer = new IntersectionObserver(entries => {
if (!cancelled && !entries[0]?.isIntersecting)
setActive(current => current === active ? null : current);
});
observer.observe(host);
return () => {
cancelled = true;
clearTimeout(intent);
abort.abort();
observer.disconnect();
release();
delete host.dataset.state;
};
}, [active, reduced, catalog]);
// Player-mode cards mount whichever ones are near the viewport, rest paused at
// REST_SECONDS, and only start playing on hover — unlike the single-active effect
// above, several can be mounted at once, capped per tier so a WebGL-heavy row can't
// blow the page's GPU memory budget.
const capsRef = useRef({ dom: 0, webgl: 0 });
const mountsRef = useRef(new Map());
useEffect(() => {
if (reduced)
return;
const tierFor = (item) => item.preview.heavy ? 'webgl' : 'dom';
const capFor = (tier) => tier === 'webgl' ? MAX_WEBGL_PLAYERS : MAX_DOM_PLAYERS;
// The one place a slot is given back. It only acts while the map still holds this exact
// state, so a late error, a timeout after unmount, or the catch below cannot release twice.
const release = (item, state) => {
if (mountsRef.current.get(item.href) !== state)
return;
clearTimeout(state.readyTimer);
state.player?.remove();
capsRef.current[state.tier] -= 1;
mountsRef.current.delete(item.href);
};
const unmount = (host, item) => {
// Keyed by item.href, same as mount() below -- setHover() only ever has the
// item, never the host node, so the map has to be addressable by href.
const state = mountsRef.current.get(item.href);
if (!state)
return;
release(item, state);
delete host.dataset.ready;
};
const mount = async (host, item) => {
if (mountsRef.current.has(item.href))
return;
// The whole body is one try: a throw anywhere here (a bad tier, a missing cap
// constant, a network failure) must log once and leave the titled placeholder in
// place, never an empty box -- this is the exact shape of the bug that shipped.
let state;
try {
const tier = tierFor(item);
if (capsRef.current[tier] >= capFor(tier))
return; // over budget for this tier; stays on the neutral tile until a slot frees
capsRef.current[tier] += 1;
state = { player: null, tier, hover: false, readyTimer: 0 };
mountsRef.current.set(item.href, state);
await ensurePlayerDefined();
const response = await fetch(item.preview.source);
if (!response.ok)
throw new Error(`preview fetch failed: ${response.status}`);
const { html } = await response.json();
if (mountsRef.current.get(item.href) !== state)
return; // unmounted while loading
const player = document.createElement('hyperframes-player');
player.setAttribute('muted', '');
player.setAttribute('audio-locked', '');
player.setAttribute('width', String(item.preview.width));
player.setAttribute('height', String(item.preview.height));
player.setAttribute('inert', '');
player.setAttribute('tabindex', '-1');
player.setAttribute('aria-hidden', 'true');
// A player that neither errors nor reaches "ready" (a stalled fetch inside its
// own srcdoc, a composition script that never resolves) must fall back the same
// as an explicit error -- the placeholder is the safe state, a bare mounted
// element with nothing painted is not.
const fail = () => {
if (mountsRef.current.get(item.href) !== state)
return; // already released
console.error(`[catalog] preview failed to load: ${item.id}`);
host.dataset.state = 'unavailable';
delete host.dataset.ready;
release(item, state);
};
const readyTimer = state.readyTimer = setTimeout(fail, READY_TIMEOUT_MS);
player.addEventListener('error', fail, { once: true });
player.addEventListener('ready', () => {
clearTimeout(readyTimer);
player.seek(REST_SECONDS);
host.dataset.ready = 'true';
if (state.hover)
player.play();
}, { once: true });
player.setAttribute('srcdoc', html);
state.player = player;
host.appendChild(player);
}
catch (err) {
// A mount that was already released and superseded must not mark the new card.
if (state && mountsRef.current.get(item.href) !== state)
return;
console.error(`[catalog] preview failed to load: ${item.id}`, err);
host.dataset.state = 'unavailable';
if (state)
release(item, state);
}
};
const hosts = resultsRef.current?.querySelectorAll('a[data-preview-mode="player"] [data-preview-host]') ?? [];
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const host = entry.target;
const item = catalog.items.find((i) => i.href === host.dataset.previewHost);
if (!item)
continue;
if (entry.isIntersecting)
mount(host, item);
else
unmount(host, item);
}
}, { rootMargin: '200px' });
hosts.forEach((host) => observer.observe(host));
const mounts = mountsRef.current;
return () => {
observer.disconnect();
mounts.forEach((state) => {
clearTimeout(state.readyTimer);
state.player?.remove();
});
mounts.clear();
capsRef.current = { dom: 0, webgl: 0 };
};
}, [filters, limit, expandedGroups, reduced, catalog]);
const setHover = (item, hovering) => {
if (reduced)
return;
if (item.preview?.mode === 'player') {
const state = mountsRef.current.get(item.href);
if (!state)
return;
state.hover = hovering;
if (state.player) {
if (hovering)
state.player.play();
else
state.player.seek(REST_SECONDS);
}
}
else if (item.preview?.mode !== 'still' && item.preview?.mode !== 'unsupported') {
setActive(hovering ? item.href : null);
}
};
const update = (patch) => {
const next = { ...filters, ...patch };
setFilters(next);
setLimit(24);
setActive(null);
const url = new URL(window.location.href);
Object.entries(next).forEach(([key, value]) => {
if (value && !(key === "sort" && value === "featured"))
url.searchParams.set(key, value);
else
url.searchParams.delete(key);
});
window.history.replaceState(window.history.state, "", url);
};
const reset = () => update({ q: "", group: "", section: "", kind: "", sort: "featured" });
const items = catalog?.items || [];
const groups = catalog?.groups || [];
const searching = Boolean(filters.q || filters.group || filters.section || filters.kind || filters.sort !== "featured");
const words = filters.q.trim().toLowerCase().split(/\s+/).filter(Boolean);
const ordered = (list) => [...list].sort((a, b) => filters.sort === "az" ? a.title.localeCompare(b.title) : (a.featured - b.featured) || Number(Boolean(b.poster)) - Number(Boolean(a.poster)) || Number(Boolean(b.video)) - Number(Boolean(a.video)) || a.title.localeCompare(b.title));
const matches = ordered(items.filter((item) => {
const text = [item.title, item.description, item.section, ...item.tags].join(" ").toLowerCase();
return (!filters.group || item.group === filters.group) && (!filters.section || item.section === filters.section) && (!filters.kind || item.kind === filters.kind) && words.every((word) => text.includes(word));
}));
const caret = React.createElement("svg", { className: "hfc-caret", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
React.createElement("path", { d: "m6 9 6 6 6-6" }));
const card = (item) => (React.createElement("a", { className: "hfc-card", href: item.href, key: item.href, "data-preview-mode": item.preview?.mode || "still", onMouseEnter: () => setHover(item, true), onMouseLeave: () => setHover(item, false), onFocus: () => setHover(item, true), onBlur: () => setHover(item, false) },
React.createElement("div", { className: "hfc-media" },
item.preview?.mode === "unsupported"
? React.createElement("div", { className: "hfc-fallback hfc-unsupported", "aria-hidden": "true" },
React.createElement("span", null, "Needs a browser flag"),
React.createElement("code", null, `chrome://flags/#${item.preview.flag}`))
: React.createElement("div", { className: "hfc-fallback", "aria-hidden": "true", style: { display: item.poster ? "none" : undefined } },
React.createElement("span", null, item.section),
React.createElement("strong", null, item.title)),
item.poster && React.createElement("img", { src: item.poster, alt: `${item.title} preview`, loading: "lazy", decoding: "async", width: "640", height: "360", onError: (event) => { event.currentTarget.style.display = "none"; event.currentTarget.previousElementSibling.style.display = "flex"; } }),
item.preview?.mode !== "still" && item.preview?.mode !== "unsupported" && React.createElement("div", { className: "hfc-preview-host", "data-preview-host": item.href, "aria-hidden": "true" })),
React.createElement("div", { className: "hfc-card-body" },
React.createElement("div", { className: "hfc-card-title" },
React.createElement("h3", null, item.title),
item.status && React.createElement("span", { className: "hfc-status" }, item.status)),
React.createElement("p", { className: "hfc-meta" }, [...(item.tech || []).filter(t => !/^GSAP/.test(t)).slice(0, 2), ...item.tags.slice(0, 2)].join(" · ") || item.section))));
return (React.createElement("div", { className: "hfc-gallery not-prose" },
React.createElement("section", { className: "hfc-hero", "aria-label": "Explore the catalog" },
React.createElement("h1", null,
React.createElement(MotionWord, null),
" ready to install."),
React.createElement("p", null, initialGroup ? `${matches.length} items. Preview a piece and open it to explore its controls, code, and installation.` : "Find the scene, caption, or finishing touch for your next video. Preview the motion, pick a favorite, and make it your own.")),
React.createElement("section", { className: "hfc-browser", "aria-label": "Browse catalog", ref: resultsRef }, searching ? React.createElement(React.Fragment, null,
matches.length ? React.createElement("div", { className: "hfc-grid" }, matches.slice(0, limit).map(card)) : React.createElement("div", { className: "hfc-empty" },
React.createElement("h3", null, "No matches yet."),
React.createElement("p", null, "Try a different search, or clear the filters to explore everything."),
React.createElement("button", { type: "button", onClick: reset }, "Explore all items")),
matches.length > limit && React.createElement("button", { className: "hfc-load", type: "button", onClick: () => setLimit(limit + 24) },
"Show more ",
React.createElement("span", null,
"(",
matches.length - limit,
" remaining)"))) : React.createElement("div", { className: "hfc-overview" }, groups.map((group) => React.createElement("section", { className: "hfc-collection", key: group.id, "aria-label": group.label },
React.createElement("div", { className: "hfc-section-head" },
React.createElement("h2", null, group.label),
React.createElement("button", { type: "button", onClick: () => setExpandedGroups(previous => ({ ...previous, [group.id]: !previous[group.id] })), "aria-label": `${expandedGroups[group.id] ? "Show less" : `Show all ${group.count}`} ${group.label}`, "aria-expanded": Boolean(expandedGroups[group.id]), "aria-controls": `gallery-${group.id}` },
expandedGroups[group.id] ? "Show Less" : `Show All ${group.count}`,
" ",
caret)),
React.createElement("div", { className: "hfc-grid", id: `gallery-${group.id}` }, ordered(items.filter((item) => item.group === group.id)).slice(0, expandedGroups[group.id] ? undefined : group.pinned ? 6 : 3).map(card))))))));
};
-135
View File
@@ -1,135 +0,0 @@
export const CatalogOverviewPlayer = ({ title, children }) => {
const [tab, setTab] = useState("preview");
const [reduced, setReduced] = useState(
() =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const onChange = (event) => setReduced(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
const localDocs =
typeof window !== "undefined" &&
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
const catalogBase = localDocs
? "https://raw.githubusercontent.com/heygen-com/hyperframes/main/docs"
: "";
const assetBase = localDocs ? "https://hyperframes.heygen.com" : "";
const scenes = JSON.stringify([
{
section: "Code Animations",
catalog: "/public/catalog/blocks/code-particle-assemble.json",
mediaStart: 1.7,
},
{
section: "Captions",
catalog: "/public/catalog/components/caption-camera-follow.json",
mediaStart: 0.5,
},
{
section: "HTML-in-Canvas",
video:
"https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-iphone-device.mp4",
mediaStart: 9.25,
},
{
section: "Social Overlays",
catalog: "/public/catalog/blocks/editorial-flash-overlay.json",
mediaStart: 0.35,
},
{
section: "Lower Thirds",
catalog: "/public/catalog/blocks/lower-third-bild.json",
mediaStart: 0.5,
},
{
section: "Shader Transitions",
catalog: "/public/catalog/blocks/domain-warp-dissolve.json",
mediaStart: 1.25,
},
{
section: "CSS Transitions",
catalog: "/public/catalog/blocks/transitions-grid.json",
mediaStart: 4.3,
},
{
section: "Showcases",
catalog: "/public/catalog/blocks/ai-chat-reveal.json",
mediaStart: 15.85,
},
{
section: "Data",
catalog: "/public/catalog/blocks/us-map-flow.json",
mediaStart: 4,
},
{
section: "Effects",
catalog: "/public/catalog/components/parallax-unzoom.json",
mediaStart: 1.6,
},
{
section: "Blocks",
catalog: "/public/catalog/blocks/hw-pipeline.json",
mediaStart: 1.4,
},
]).replace(/<\//g, "<\\/");
const bootstrap = [
"<!doctype html><html><head><meta charset='utf-8'>",
"<style>html,body{margin:0;height:100%;overflow:hidden;background:#05070b}",
"#stage,hyperframes-player{position:absolute;inset:0;display:block;width:100%;height:100%}",
"#label{position:absolute;z-index:2;top:24px;left:24px;padding:10px 16px;border:1px solid rgba(255,255,255,.2);border-radius:999px;background:rgba(5,7,11,.82);color:#fff;font:700 18px/1.2 ui-sans-serif,system-ui,sans-serif;pointer-events:none}",
"#error{position:absolute;z-index:3;inset:0;display:none;place-items:center;background:#05070b;color:#fff;font:600 18px ui-sans-serif,system-ui,sans-serif}</style>",
// `latest`, not a pinned line. See the note in variables-explorer.jsx: a
// pin here fails silently, because the page still works on the old build.
'<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@latest/dist/hyperframes-player.global.js"></' +
"script>",
`</head><body><div id="stage"></div><div id="label"></div><div id="error">Preview unavailable</div><script>var scenes=${scenes};var reduced=${JSON.stringify(reduced)};var catalogBase=${JSON.stringify(catalogBase)};var assetBase=${JSON.stringify(assetBase)};var current=0;var request=0;`,
"function videoComposition(scene){return \"<!doctype html><html><head><meta charset='utf-8'><style>html,body{width:1920px;height:1080px;margin:0;overflow:hidden;background:#05070b}video{width:100%;height:100%;object-fit:cover}</style></head><body><main data-composition-id='catalog-video' data-start='0' data-duration='15' data-width='1920' data-height='1080'><video id='catalog-video-media' src='\"+scene.video+\"' data-start='0' data-duration='15' muted playsinline></video></main><script>window.__timelines=window.__timelines||{};var media=document.querySelector('video');window.__timelines['catalog-video']={duration:function(){return 15},time:function(){return media.currentTime},seek:function(t){media.currentTime=t;return this},play:function(){media.play();return this},pause:function(){media.pause();return this}};</\"+\"script></body></html>\"}",
"async function compositionFor(scene){if(scene.catalog){var response=await fetch(catalogBase+scene.catalog);if(!response.ok)throw new Error('Catalog '+response.status);var html=(await response.json()).html;if(assetBase)html=html.replace('<head>','<head><base href=\"'+assetBase+'/\">');return html}return videoComposition(scene)}",
"async function show(index){var token=++request;var scene=scenes[index];try{var html=await compositionFor(scene);if(token!==request)return;var player=document.createElement('hyperframes-player');player.setAttribute('srcdoc',html);player.setAttribute('controls','');player.setAttribute('muted','');player.addEventListener('ready',function(){player.seek(scene.mediaStart||0);if(!reduced)player.play()},{once:true});document.getElementById('stage').replaceChildren(player);document.getElementById('label').textContent=scene.section;document.getElementById('error').style.display='none'}catch(error){if(token===request){document.getElementById('label').textContent=scene.section;document.getElementById('error').style.display='grid'}}}",
"show(0);if(!reduced)setInterval(function(){current=(current+1)%scenes.length;show(current)},2500);",
"</" + "script></body></html>",
].join("");
return (
<div className="not-prose my-4">
<div className="mb-3 inline-flex gap-1 rounded-full border border-zinc-200 bg-zinc-50 p-1 dark:border-zinc-800 dark:bg-zinc-950">
{[
["preview", "Preview"],
["code", "HTML"],
].map(([id, label]) => (
<button
key={id}
type="button"
aria-pressed={tab === id}
onClick={() => setTab(id)}
className={`rounded-full px-3 py-1 text-xs font-medium ${
tab === id
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-950"
: "text-zinc-600 dark:text-zinc-300"
}`}
>
{label}
</button>
))}
</div>
<div hidden={tab !== "preview"}>
<iframe
srcDoc={bootstrap}
className="block aspect-video w-full rounded-xl bg-zinc-950"
title={title}
/>
</div>
<div hidden={tab !== "code"}>{children}</div>
</div>
);
};
+1
View File
@@ -33,6 +33,7 @@
"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:docs-catalog": "node scripts/check-docs-catalog.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",
@@ -92,5 +92,9 @@
"target": "TEMPLATE.md",
"type": "hyperframes:asset"
}
]
],
"preview": {
"video": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/heygen-avatar-promo-card.mp4",
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/heygen-avatar-promo-card.png"
}
}
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env node
// Rebuilds the Catalog tab's sidebar from catalog-gallery-data.mdx, so counts and grouping
// are generated, not hand-maintained. Adapted from a reference implementation of this same
// transform, re-pointed at this repo's own docs.json.
import fs from "node:fs";
import path from "node:path";
import {
getCatalogTab,
readCatalogGalleryData,
readJson,
resolveDocsRoot,
} from "./docs-catalog-shared.mjs";
export function buildNav(docs) {
const data = readCatalogGalleryData(docs);
const config = readJson(path.join(docs, "docs.json"));
const tab = getCatalogTab(config);
const groups = data.groups.map((g) => {
const items = data.items.filter((i) => i.group === g.id);
const sections = [...new Set(items.map((i) => i.section))];
// Flattening a single section into the group's own page list loses that section's label
// (sync-docs-catalog.mjs's walk() re-derives it from nav depth); only safe when the label
// is the same as the group's, so nothing is lost.
const pages =
sections.length === 1 && sections[0] === g.label
? items.map((i) => i.href.slice(1))
: sections.map((section) => {
const selected = items.filter((i) => i.section === section);
return {
group: section,
tag: String(selected.length),
expanded: false,
pages: selected.map((i) => i.href.slice(1)),
};
});
// Collapsed by default; catalog-gallery.css fills the column with a one-line
// description per group instead (Mintlify's group schema has no description field).
return { group: g.label, tag: String(items.length), expanded: false, pages };
});
tab.groups = [{ group: "Catalog", pages: ["catalog/index", ...groups] }];
fs.writeFileSync(path.join(docs, "docs.json"), JSON.stringify(config, null, 2) + "\n");
console.log(`Rebuilt Catalog nav: ${groups.length} groups, ${data.items.length} items.`);
}
if (import.meta.url === `file://${process.argv[1]}`)
buildNav(resolveDocsRoot(process.argv[2]).docs);
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env node
// Proves the generated Catalog nav lists every page on disk and reachable, with no page
// lost and no duplicates. Adapted from a reference implementation of this same check.
import fs from "node:fs";
import path from "node:path";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import ts from "typescript";
import {
getCatalogTab,
readCatalogGalleryData,
readJson,
resolveDocsRoot,
} from "./docs-catalog-shared.mjs";
const { root, docs } = resolveDocsRoot(process.argv[2]);
function leafPaths(tab) {
const paths = [];
const walk = (pages) => {
for (const p of pages) {
if (typeof p === "string") paths.push("/" + p);
else walk(p.pages || []);
}
};
for (const g of tab.groups) walk(g.pages);
return paths;
}
// Same derivation as sync-docs-catalog.mjs's walk(): the innermost real label is the section,
// the one above it the group; "Catalog"/"Overview" are transparent wrappers, not labels.
function hrefSections(tab) {
const sections = new Map();
// one branch per nav-node type (array / leaf page / group)
// fallow-ignore-next-line complexity
const walk = (node, labels) => {
if (Array.isArray(node)) {
for (const n of node) walk(n, labels);
return;
}
if (typeof node === "string") {
sections.set("/" + node, labels[labels.length - 1] ?? null);
return;
}
if (node.group === "Overview") return;
const next = node.group === "Catalog" ? labels : [...labels, node.group];
walk(node.pages || [], next);
};
for (const g of tab.groups) walk(g, []);
return sections;
}
const config = readJson(path.join(docs, "docs.json"));
const tab = getCatalogTab(config);
const after = leafPaths(tab);
assert.equal(after.length, new Set(after).size, "Duplicate sidebar item");
const data = readCatalogGalleryData(docs);
assert.equal(
data.items.length,
after.filter((p) => p !== "/catalog/index").length,
"Gallery data item count does not match the nav's leaf page count",
);
const navSections = hrefSections(tab);
for (const item of data.items) {
assert.ok(after.includes(item.href), `Gallery item missing from the sidebar: ${item.href}`);
assert.ok(
fs.existsSync(path.join(docs, item.href.slice(1) + ".mdx")),
`Gallery item has no page on disk: ${item.href}`,
);
assert.equal(
navSections.get(item.href),
item.section,
`Section mismatch for ${item.href}: nav says "${navSections.get(item.href)}", gallery data says "${item.section}"`,
);
assert.ok(
["still", "video", "player", "unsupported"].includes(item.preview?.mode),
`Missing gallery preview policy for ${item.id}`,
);
if (item.preview.mode === "video") assert.ok(item.video, `Missing hover video for ${item.id}`);
if (item.preview.mode === "player") {
assert.ok(
fs.existsSync(path.join(docs, item.preview.source.slice(1))),
`Missing preview payload for ${item.id}: ${item.preview.source}`,
);
}
}
// The no-page-lost proof: diff against docs.json at the merge base. A checkout that can't
// compute it (shallow clone, missing origin/main) must fail loudly, not skip and exit 0.
const base = execFileSync("git", ["merge-base", "HEAD", "origin/main"], { cwd: root })
.toString()
.trim();
const prior = JSON.parse(
execFileSync("git", ["show", `${base}:docs/docs.json`], { cwd: root }).toString(),
);
const priorTab = getCatalogTab(prior);
const before = leafPaths(priorTab);
const beforeSet = new Set(before);
const afterSet = new Set(after);
const missing = before.filter((p) => !afterSet.has(p));
const added = after.filter((p) => !beforeSet.has(p));
assert.equal(missing.length, 0, `Pages present before but missing after: ${missing.join(", ")}`);
console.log(
`PASS no page lost: ${before.length} pages before, ${after.length} after` +
(added.length ? ` (${added.length} legitimately new: ${added.join(", ")})` : ""),
);
console.log(
`PASS ${data.items.length} gallery items, all present in the sidebar and on disk; no duplicate sidebar entries.`,
);
// Mintlify's build drops .html/.js/.css from docs/public with no error; a file there 404s
// in production while its page still serves. Catches this before a card fetches a ghost.
const publicRoot = path.join(docs, "public");
const badExtensions = fs
.readdirSync(publicRoot, { recursive: true })
.filter((f) => /\.(html|js|css)$/i.test(f));
assert.equal(
badExtensions.length,
0,
`docs/public serves only JSON and images in production; drop or rename: ${badExtensions.join(", ")}`,
);
console.log(`PASS no .html/.js/.css under docs/public (Mintlify would drop it from the build).`);
// Mintlify's snippet bundler only preserves the exported binding's own closure; a sibling
// top-level const/function in the same file silently vanishes from the deployed build
// (ReferenceError at runtime, no build-time error). Catch it here instead of live.
function assertNoBareTopLevelBindings(snippetPath) {
const source = fs.readFileSync(snippetPath, "utf-8");
const sourceFile = ts.createSourceFile(
snippetPath,
source,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.JSX,
);
const isExported = (node) =>
ts.canHaveModifiers(node) &&
ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
const bare = sourceFile.statements.filter(
(s) => (ts.isVariableStatement(s) || ts.isFunctionDeclaration(s)) && !isExported(s),
);
assert.equal(
bare.length,
0,
`${path.relative(root, snippetPath)}: top-level declaration(s) outside the exported ` +
`binding are dropped by Mintlify's snippet bundler on deploy: ${bare
.map((s) => s.getText(sourceFile).split("\n")[0])
.join(" | ")}`,
);
}
assertNoBareTopLevelBindings(path.join(docs, "snippets/catalog-gallery.jsx"));
console.log(`PASS catalog-gallery.jsx has no top-level binding outside the exported component.`);
+4
View File
@@ -37,6 +37,10 @@ const BASE_FLAG = "--base";
* its own. Remove an entry once its deletion has landed on the base.
*/
export const ALLOWED_DELETIONS = new Map([
[
"docs/snippets/catalog-overview-player.jsx",
"#4051 removes the Catalog overview's only consumer of this snippet (replaced by the browse-grid mount); confirmed unreferenced repo-wide before deleting",
],
[
"packages/studio/src/hooks/useSdkSession.test.ts",
"tests only shouldReloadSdkSession, a function with no production callers removed with the preview reload fix",
+42
View File
@@ -0,0 +1,42 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export function resolveDocsRoot(argv2) {
const root = fileURLToPath(new URL("..", import.meta.url));
return { root, docs: path.resolve(argv2 || path.join(root, "docs")) };
}
export function readJson(p) {
return JSON.parse(fs.readFileSync(p, "utf8"));
}
export function readCatalogGalleryData(docs) {
const raw = fs.readFileSync(path.join(docs, "snippets/catalog-gallery-data.mdx"), "utf8");
return JSON.parse(raw.replace(/^export const catalogGalleryData = /, "").replace(/;\s*$/, ""));
}
export function getCatalogTab(config) {
return config.navigation.tabs.find((t) => t.tab === "Catalog");
}
// A composition is eligible for live preview the same way the gallery's own inventory
// decides it: a paused GSAP timeline registered for seeking, not itself mounting a further
// sub-composition. Callers that only need the yes/no can ignore which reason came back.
export function previewGap(html) {
if (/navigator\.gpu/.test(html)) return "webgpu";
if (/data-composition-src=/.test(html)) return "nested-composition";
if (!/__timelines\[/.test(html)) return "no-timeline";
return null;
}
// Same output as packages/core/src/tokenSlug.ts's slugify, minus its CSS-variable fallback.
// Collapsing every non-alphanumeric run to one "-" first means the trim needs no quantifier,
// avoiding the polynomial-ReDoS CodeQL flags on /^-+|-+$/ (js/polynomial-redos).
export function slug(s) {
return s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-/, "")
.replace(/-$/, "");
}
+64 -36
View File
@@ -74,9 +74,21 @@ export function mdxStringAttribute(name: string, value: string): string {
return `${name}="${escaped}"`;
}
/** Has a preview payload been built for this item? */
function payloadPath(kind: ItemKind, name: string): string {
return join(payloadRoot, typeDir(kind), `${name}.json`);
}
/** Has a real, playable preview payload been built for this item? */
function hasPayload(kind: ItemKind, name: string): boolean {
return existsSync(join(payloadRoot, typeDir(kind), `${name}.json`));
return existsSync(payloadPath(kind, name)) && unsupportedFlag(kind, name) === null;
}
/** The Chrome flag this item's payload needs to render live, if any. */
function unsupportedFlag(kind: ItemKind, name: string): string | null {
const path = payloadPath(kind, name);
if (!existsSync(path)) return null;
const payload = JSON.parse(readFileSync(path, "utf-8")) as { unsupported?: string };
return payload.unsupported ?? null;
}
/**
@@ -339,31 +351,36 @@ function textureLabel(slug: string): string {
.join(" ");
}
// Ordered so a more specific needle (e.g. "wood-floor") is tried before the
// substring it contains ("wood"); first match wins, same as the if-chain this replaced.
const TEXTURE_SAMPLE_WORDS: [needle: string, word: string][] = [
["brick", "BRICK"],
["concrete", "CONCRETE"],
["plaster", "PLASTER"],
["rock", "ROCK"],
["onyx", "ONYX"],
["marble", "MARBLE"],
["travertine", "STONE"],
["paving", "STONE"],
["tiles", "TILE"],
["ground", "GROUND"],
["road", "ROAD"],
["asphalt", "ASPHALT"],
["wood-floor", "FLOOR"],
["wood", "WOOD"],
["bark", "BARK"],
["diamond", "PLATE"],
["metal", "METAL"],
["lava", "LAVA"],
["grass", "GRASS"],
["carpet", "WOVEN"],
["fabric", "FABRIC"],
["snow", "SNOW"],
["leather", "LEATHER"],
];
function textureSampleWord(slug: string): string {
if (slug.includes("brick")) return "BRICK";
if (slug.includes("concrete")) return "CONCRETE";
if (slug.includes("plaster")) return "PLASTER";
if (slug.includes("rock")) return "ROCK";
if (slug.includes("onyx")) return "ONYX";
if (slug.includes("marble")) return "MARBLE";
if (slug.includes("travertine")) return "STONE";
if (slug.includes("paving")) return "STONE";
if (slug.includes("tiles")) return "TILE";
if (slug.includes("ground")) return "GROUND";
if (slug.includes("road")) return "ROAD";
if (slug.includes("asphalt")) return "ASPHALT";
if (slug.includes("wood-floor")) return "FLOOR";
if (slug.includes("wood")) return "WOOD";
if (slug.includes("bark")) return "BARK";
if (slug.includes("diamond")) return "PLATE";
if (slug.includes("metal")) return "METAL";
if (slug.includes("lava")) return "LAVA";
if (slug.includes("grass")) return "GRASS";
if (slug.includes("carpet")) return "WOVEN";
if (slug.includes("fabric")) return "FABRIC";
if (slug.includes("snow")) return "SNOW";
if (slug.includes("leather")) return "LEATHER";
return slug.toUpperCase();
return TEXTURE_SAMPLE_WORDS.find(([needle]) => slug.includes(needle))?.[1] ?? slug.toUpperCase();
}
function textureMaskUrlFor(manifest: RegistryItem, texture: string): string {
@@ -896,6 +913,17 @@ function previewSection(
): string[] {
if (textureGroups.length > 0) return generateTexturePreview(manifest, textureGroups);
const flag = unsupportedFlag(kind, manifest.name);
// A recorded video still plays without the flag, so it wins over the notice.
if (flag && !manifest.preview?.video) {
return [
`<div className="w-full aspect-video rounded-xl border border-dashed flex items-center justify-center text-sm text-zinc-500">`,
` Needs <code>chrome://flags/#${flag}</code> to render live`,
`</div>`,
"",
];
}
// A built payload plays the real composition, and takes precedence over both
// iframe paths below. The variables explorer is parked rather than wired up:
// its preview document is an `.html` file the docs host does not publish, so
@@ -1029,6 +1057,7 @@ function footerSection(
return footer;
}
// fallow-ignore-next-line complexity
function generateItemMdx(
kind: ItemKind,
manifest: RegistryItem,
@@ -1343,18 +1372,17 @@ function main(): void {
// catalog landing page from the sidebar entirely.
const isGeneratedPage = (p: unknown): boolean =>
typeof p === "string" && /^catalog\/(blocks|components)\//.test(p);
// Has to recurse: a section holds groups rather than pages, so a check that
// only reads `pages` finds nothing generated in one, keeps it as if a human
// had written it, and appends a fresh copy on every run.
const holdsGeneratedPages = (node: unknown): boolean => {
if (isGeneratedPage(node)) return true;
if (!node || typeof node !== "object") return false;
// Recurses because a section holds groups, not pages. Keeps hand-written pages
// (catalog/index) even when they share a group with generated ones.
const withoutGeneratedPages = (node: unknown): unknown[] => {
if (isGeneratedPage(node)) return [];
if (!node || typeof node !== "object") return [node];
const g = node as { pages?: unknown[] };
return (g.pages ?? []).some(holdsGeneratedPages);
if (!g.pages) return [node];
const pages = g.pages.flatMap(withoutGeneratedPages);
return pages.length > 0 ? [{ ...g, pages }] : [];
};
const handAddedGroups: unknown[] = (existing?.groups ?? []).filter(
(g: unknown) => !holdsGeneratedPages(g),
);
const handAddedGroups = (existing?.groups ?? []).flatMap(withoutGeneratedPages);
const catalogTab = {
tab: "Catalog",
+12 -4
View File
@@ -13,6 +13,9 @@
* composition as a `srcdoc` string, so JSON is the delivery format that both
* survives the deploy and matches what the player wants.
*
* An item needing `chrome://flags/#canvas-draw-element` gets
* `{ unsupported: "canvas-draw-element" }` at the same path instead of `{ html }`.
*
* Usage:
* npx tsx scripts/generate-catalog-payloads.ts # all items
* npx tsx scripts/generate-catalog-payloads.ts --only data-chart # single item
@@ -189,6 +192,10 @@ export async function buildPayload(item: CatalogItem): Promise<"written" | "skip
// generator keeps finding one on disk and emits a player for a preview this
// run just decided it cannot build.
const dropStalePayload = () => rmSync(outPath, { force: true });
const writePayload = (body: object) => {
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify(body), "utf-8");
};
// A composition whose variables are meant to be changed has to reach the
// reader uncompiled, or its values are already resolved into the markup.
@@ -220,8 +227,10 @@ export async function buildPayload(item: CatalogItem): Promise<"written" | "skip
const html = readFileSync(join(projectDir, "index.html"), "utf-8");
if (needsCanvasDrawElement(html)) {
console.log(` – ${item.name}: needs canvas drawElement, keeping the recorded video`);
dropStalePayload();
// A marker file, not an absence: the catalog card reads this to show an honest
// "needs this flag" tile instead of silently falling back to nothing.
writePayload({ unsupported: "canvas-draw-element" });
console.log(` – ${item.name}: needs canvas drawElement, marked unsupported`);
return "skipped";
}
const assetTarget = { dir: join(payloadRoot, "assets"), urlBase: "/public/catalog/assets" };
@@ -283,8 +292,7 @@ export async function buildPayload(item: CatalogItem): Promise<"written" | "skip
return "skipped";
}
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, JSON.stringify({ html: withBase }), "utf-8");
writePayload({ html: withBase });
const counts = [
hosted + externalized > 0 ? `${hosted + externalized} hosted` : "",
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env node
// Builds docs/snippets/catalog-gallery-data.mdx for catalog-gallery.jsx directly from this
// repo's own registry/*/registry-item.json and docs.json (no sparse clone, no other project's
// snapshot). Usage: node scripts/sync-docs-catalog.mjs [path/to/docs]
import fs from "node:fs";
import path from "node:path";
import { buildNav } from "./build-docs-gallery-nav.mjs";
import {
getCatalogTab,
previewGap,
readJson,
resolveDocsRoot,
slug,
} from "./docs-catalog-shared.mjs";
const { root, docs } = resolveDocsRoot(process.argv[2]);
const config = readJson(path.join(docs, "docs.json"));
const tab = getCatalogTab(config);
function statusOf(man) {
return man.stability === "experimental" ? "experimental" : "published";
}
function isHeavy(html) {
return /getContext\(\s*["']webgl2?["']|THREE\.|navigator\.gpu|WebGPURenderer/.test(html);
}
// The gallery card fetches this same JSON payload the detail page already serves in
// production (previewSrc) — no separate sidecar file, so nothing new to publish or drop.
// fallow-ignore-next-line complexity
function previewFor(dir, id, docsDir, width, height) {
const payloadPath = path.join(docsDir, "public/catalog", dir, `${id}.json`);
if (!fs.existsSync(payloadPath)) return null;
const payload = readJson(payloadPath);
if (payload.unsupported) return { mode: "unsupported", flag: payload.unsupported };
const { html } = payload;
if (!html) return null;
// Any gap (WebGPU, a sub-composition, a missing timeline) means the live player can't
// run here; fall through silently to the item's own video/poster instead of a message.
if (previewGap(html)) return null;
return {
mode: "player",
source: `/public/catalog/${dir}/${id}.json`,
heavy: isHeavy(html),
width: width || 1920,
height: height || 1080,
};
}
// one independent default per optional registry-item.json field
// fallow-ignore-next-line complexity
function itemFrom(node, dir, id, man, pathLabels) {
const section = pathLabels[pathLabels.length - 1];
const groupLabel = pathLabels[pathLabels.length - 2] || section;
const item = {
id,
kind: dir === "blocks" ? "block" : "component",
href: `/${node}`,
title: man.title || id,
tagline: man.description || "",
description: man.description || "",
group: slug(groupLabel),
section,
tags: man.tags || [],
tech: [],
duration: man.duration ?? null,
width: man.dimensions?.width ?? null,
height: man.dimensions?.height ?? null,
status: statusOf(man),
featured: 1000,
poster: man.preview?.poster || null,
video: man.preview?.video || null,
};
return { item, groupLabel };
}
// Walk the existing hand-authored nav so group/section order and labels stay ours.
// pathLabels accumulates each {group,pages} label on the way down, exactly like the
// gallery's own inventory.mjs walkNav: section = innermost label, group = the one above it
// (or section itself for a flat, one-level group).
const items = [];
const groupsOrder = [];
const groupLabels = new Map();
// one branch per nav-node type (array / leaf page / group)
// fallow-ignore-next-line complexity
function walk(node, pathLabels) {
if (Array.isArray(node)) {
for (const n of node) walk(n, pathLabels);
return;
}
if (typeof node === "string") {
const m = node.match(/^catalog\/(blocks|components)\/([^/]+)$/);
if (!m) return; // catalog/index and anything else stays out of the gallery data
const [, dir, id] = m;
const manifestPath = path.join(root, "registry", dir, id, "registry-item.json");
if (!fs.existsSync(manifestPath)) {
console.warn(`! ${node}: no registry-item.json, skipped`);
return;
}
const man = readJson(manifestPath);
const { item, groupLabel } = itemFrom(node, dir, id, man, pathLabels);
// A CDN poster/video already renders a real resting frame; only items without one need
// the repo-served player (this is the class that was showing as a grey placeholder).
const hasCdnMedia = Boolean(man.preview?.poster || man.preview?.video);
const preview = hasCdnMedia
? null
: previewFor(dir, id, docs, man.dimensions?.width, man.dimensions?.height);
item.preview = preview || { mode: item.video ? "video" : "still" };
if (!groupsOrder.includes(item.group)) {
groupsOrder.push(item.group);
groupLabels.set(item.group, groupLabel);
}
items.push(item);
return;
}
if (node && typeof node === "object") {
if (node.group === "Overview") return;
// "Catalog" is the synthetic wrapper buildNav() adds around every real group below; skip
// it as a label so re-running against already-generated output doesn't shift nesting.
const transparent = node.group === "Catalog";
walk(node.pages || [], transparent ? pathLabels : [...pathLabels, node.group]);
}
}
for (const g of tab.groups) walk(g, []);
// Landing order: 3D motion first (once it exists — a separate initiative brings the
// items in), Carousels second, everything else keeping the order the hand-authored nav
// already had. Only "3d-motion" is pinned (two rows on the landing instead of one), matching
// the reference gallery's own convention — it pins the one group, not every group.
const PRIORITY = ["3d-motion", "carousels"];
const groups = groupsOrder
.map((id) => ({
id,
label: groupLabels.get(id),
pinned: id === "3d-motion",
count: items.filter((i) => i.group === id).length,
}))
.filter((g) => g.count > 0)
// groups absent from the priority list sort after the ones present in it
// fallow-ignore-next-line complexity
.sort((a, b) => {
const pa = PRIORITY.indexOf(a.id);
const pb = PRIORITY.indexOf(b.id);
if (pa === -1 && pb === -1) return 0;
if (pa === -1) return 1;
if (pb === -1) return -1;
return pa - pb;
});
const result = { source: "https://github.com/heygen-com/hyperframes", groups, items };
fs.writeFileSync(
path.join(docs, "snippets/catalog-gallery-data.mdx"),
`export const catalogGalleryData = ${JSON.stringify(result)};\n`,
);
console.log(`Synced ${items.length} catalog items in ${groups.length} groups.`);
buildNav(docs);