mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
* feat(registry): add cinemagraph — a still photograph with one living region * feat(registry): add ai-answer-rank — a mock AI answer that re-ranks to put your brand in it A 1080x1080, 9s block for answer-engine and AI-visibility marketing. A prompt types into a chat card, the assistant answers with a ranked list of three picks, then re-ranks: the promoted brand slides into its slot (brandRank 1-3), the picks below step down, the third fades out of the list, and the brand row lands with the one accent highlight and a drawn check. Monochrome placeholder UI; prompt, lead-in, pick names, brand, rank, accent, ink and ground are variables. * feat(registry): add caret swap text effect * feat(registry): add pendulum-swing component Add a motion-primitive component that hangs content from a fixed pivot and lets it settle with a damped oscillation, like a hanging sign coming to rest on its hook. The swing rides a single unitless custom property (--hf-swing-angle) that the timeline eases 1 -> 0, so only rotation animates and it stays sub-pixel under the seek-by-frame capture engine. The demo drives it with a damped-oscillation ease (a cosine inside an exponential decay, normalised to land exactly upright) so the sign passes through center several times, each pass smaller, before it rests. Four enum variables: pivot (top-center/left/right), amplitude (gentle/standard/wide), direction (right/left), tone (ink/paper/accent, each a bg/fg pair to keep AA contrast on any frame). The snippet is recipe-only and does not own its timeline; its demo carries the same variable declaration. Ran npx hyperframes lint/validate on both files, regenerated registry.json via scripts/generate-registry-items.ts and the docs via scripts/generate-catalog-pages.ts. * fix(registry): drop hand-added cinemagraph index entry; catalog-publish adds it * style(registry): format pendulum-swing with oxfmt * fix(registry): keep pendulum-swing demo caption clear of the swinging sign * fix(registry): ship the ad-template editing contract for ai-answer-rank --------- Co-authored-by: Carlos Miranda Levy <carlosmirandalevy@gmail.com> Co-authored-by: tugrawork-creator <319556794+tugrawork-creator@users.noreply.github.com> Co-authored-by: Akshar Patel <aksharcommit@gmail.com> Co-authored-by: rottenpen <12029924+rottenpen@users.noreply.github.com>
207 lines
9.1 KiB
TypeScript
207 lines
9.1 KiB
TypeScript
/**
|
||
* Make a component's catalog preview answer its variables panel.
|
||
*
|
||
* A component ships two HTML files. The snippet is what the page hands you to
|
||
* paste: it carries `data-composition-variables`, a script that turns a chosen
|
||
* value into a CSS custom property, and CSS written against those properties.
|
||
* `demo.html` stages that component against a background and registers the
|
||
* GSAP timeline that makes the preview move.
|
||
*
|
||
* The catalog preview is built from the demo, and the demo is a hand-authored
|
||
* copy rather than a reference. The copies drifted: of the 168 components that
|
||
* declare variables, 166 demos have no declaration and no reader, so the
|
||
* preview renders constants and the panel cannot move it whatever is picked.
|
||
*
|
||
* There is no single repair, because components come in two shapes. See
|
||
* `snippetOwnsItsMotion` for the split and how it was established. This module
|
||
* handles the shape where the demo owns the motion: it keeps the demo's markup
|
||
* and timeline and layers on the three things the copy lost.
|
||
*
|
||
* 1. the declaration, copied verbatim from the snippet's root
|
||
* 2. the snippet's `<style>`, appended so its var()-driven rules win on
|
||
* document order over the demo's hardcoded copies
|
||
* 3. the snippet's `<script>`, which sets the properties that CSS reads
|
||
*
|
||
* Nothing in the demo's DOM is moved or rewritten, which is what keeps the
|
||
* animation intact.
|
||
*/
|
||
|
||
const DECLARATION = /data-composition-variables\s*=\s*'(\[[\s\S]*?\])'/;
|
||
/** The variables a composition declares on its root, or none when it declares nothing readable. */
|
||
export function declaredVariables(html: string): unknown[] {
|
||
const raw = DECLARATION.exec(html)?.[1];
|
||
if (!raw) return [];
|
||
try {
|
||
const parsed: unknown = JSON.parse(raw);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
const STYLE_BLOCK = /<style\b[^>]*>[\s\S]*?<\/style>/g;
|
||
const SCRIPT_BLOCK = /<script\b(?![^>]*\bsrc=)[^>]*>[\s\S]*?<\/script>/g;
|
||
const DECLARING_TAG = /<[a-zA-Z][\w-]*\b[^>]*data-composition-variables[\s\S]*?>/;
|
||
const COMPOSITION_ROOT_TAG = /<[a-zA-Z][\w-]*\b[^>]*\bdata-composition-id\b[^>]*>/;
|
||
const INLINE_SCRIPT_OPEN = /<script\b(?![^>]*\bsrc=)[^>]*>/i;
|
||
// Existence checks use their own non-global copy on purpose: `.test()` on a
|
||
// /g regex advances its lastIndex, and these run in a loop over every
|
||
// component, so a shared one would start mid-string and miss on later items.
|
||
const HAS_INLINE_SCRIPT = /<script\b(?![^>]*\bsrc=)[^>]*>[\s\S]*?<\/script>/;
|
||
const BODY_CLOSE = /<\/body>/i;
|
||
|
||
/**
|
||
* Does the snippet bring its own motion, or only a recipe for it?
|
||
*
|
||
* Components come in two shapes and the preview has to be built differently
|
||
* for each. 123 of the 168 that declare variables register their own paused
|
||
* GSAP timeline: those are whole pieces, and the preview is best built from the
|
||
* snippet, which then carries markup, variables and motion together. The other
|
||
* 45 are markup plus a commented recipe, and it is the demo that animates them,
|
||
* so those keep the demo and have the variable machinery layered on.
|
||
*
|
||
* Getting this backwards is not subtle. Build a self-contained component from
|
||
* its demo with the snippet layered on and it keeps rendering its defaults;
|
||
* build a recipe-only one from its snippet and the preview holds still. Both
|
||
* were observed before this split existed.
|
||
*
|
||
* Comments are stripped first, because the recipe is written as one.
|
||
*/
|
||
export function snippetOwnsItsMotion(snippetHtml: string): boolean {
|
||
const live = snippetHtml.replace(/<!--[\s\S]*?-->/g, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
||
return live.includes("__timelines") && live.includes("gsap.timeline");
|
||
}
|
||
|
||
/** Components whose authored stage is needed for a working preview. */
|
||
export const PREFER_AUTHORED_DEMO = new Set([
|
||
// Their snippets render still frames; keep the demos that supply their motion.
|
||
"ascii-render-pass",
|
||
"star-rating-fill",
|
||
// Dark text needs the demo's light canvas and its 1280 × 720 framing.
|
||
"caret-swap",
|
||
]);
|
||
|
||
/** The classes the snippet hangs its declaration on, which its script targets. */
|
||
function declaringClasses(snippetHtml: string): string[] {
|
||
const declaring = DECLARING_TAG.exec(snippetHtml);
|
||
const classAttr = declaring ? /class\s*=\s*"([^"]*)"/.exec(declaring[0]) : null;
|
||
return (classAttr?.[1] ?? "").split(/\s+/).filter(Boolean);
|
||
}
|
||
|
||
function openingTagWithClass(html: string, cls: string): string | null {
|
||
const escaped = cls.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
const pattern = new RegExp(
|
||
`<[a-zA-Z][\\w-]*\\b[^>]*class\\s*=\\s*"[^"]*\\b${escaped}\\b[^"]*"[^>]*>`,
|
||
);
|
||
return pattern.exec(html)?.[0] ?? null;
|
||
}
|
||
|
||
/**
|
||
* Where to hang the declaration in the demo.
|
||
*
|
||
* It does not have to be the same element the snippet used: the runtime merges
|
||
* the declared defaults of every `[data-composition-variables]` in the
|
||
* document, so any host in the demo resolves identically. What does matter is
|
||
* that the snippet's script finds its targets, and it finds them by the
|
||
* component's own class, which the demo's copy still carries.
|
||
*
|
||
* The component's own element is preferred over the composition root, because
|
||
* that keeps the payload shaped like the markup a reader would paste.
|
||
*/
|
||
function findDeclarationHost(demoHtml: string, snippetHtml: string): string | null {
|
||
const onOwnClass = declaringClasses(snippetHtml)
|
||
.map((cls) => openingTagWithClass(demoHtml, cls))
|
||
.find(Boolean);
|
||
return onOwnClass ?? compositionRootTag(demoHtml);
|
||
}
|
||
|
||
function compositionRootTag(html: string): string | null {
|
||
return COMPOSITION_ROOT_TAG.exec(html)?.[0] ?? null;
|
||
}
|
||
|
||
function withDeclarationAttribute(tag: string, declaration: string): string {
|
||
const selfClosing = tag.endsWith("/>");
|
||
const body = tag.slice(0, selfClosing ? -2 : -1);
|
||
return `${body} data-composition-variables='${declaration}'${selfClosing ? "/>" : ">"}`;
|
||
}
|
||
|
||
export type LayerResult =
|
||
| { applied: true; html: string }
|
||
| { applied: false; html: string; reason: string };
|
||
|
||
/**
|
||
* Everything that has to hold before a demo can be layered.
|
||
*
|
||
* A table rather than a chain of guards, so the refusals read as a list of
|
||
* conditions with their messages beside them. A component whose preview cannot
|
||
* be made to answer its panel keeps the preview it has rather than getting a
|
||
* half-applied one.
|
||
*/
|
||
const REQUIREMENTS: { fails: (demo: string, snippet: string) => boolean; reason: string }[] = [
|
||
{ fails: (_d, s) => !DECLARATION.test(s), reason: "snippet declares no variables" },
|
||
{ fails: (d) => DECLARATION.test(d), reason: "demo already declares its variables" },
|
||
{ fails: (d) => !BODY_CLOSE.test(d), reason: "demo has no </body> to append to" },
|
||
{ fails: (_d, s) => !HAS_INLINE_SCRIPT.test(s), reason: "snippet has no reader script" },
|
||
{
|
||
fails: (d, s) => !findDeclarationHost(d, s),
|
||
reason: "demo has nowhere to hang the declaration",
|
||
},
|
||
];
|
||
|
||
/** Why this pair cannot be layered, or null when it can. */
|
||
function refuseReason(demoHtml: string, snippetHtml: string): string | null {
|
||
return REQUIREMENTS.find((r) => r.fails(demoHtml, snippetHtml))?.reason ?? null;
|
||
}
|
||
|
||
/**
|
||
* The snippet's own inline blocks, which are the only ones that travel.
|
||
*
|
||
* A `src=` script is a shared dependency the demo already loads, and copying it
|
||
* would re-run a library.
|
||
*/
|
||
function inlineBlocks(snippetHtml: string, pattern: RegExp): string[] {
|
||
return snippetHtml.match(pattern) ?? [];
|
||
}
|
||
|
||
/** Layer a component snippet's variable machinery onto its demo. */
|
||
export function layerVariablesOntoDemo(demoHtml: string, snippetHtml: string): LayerResult {
|
||
const reason = refuseReason(demoHtml, snippetHtml);
|
||
if (reason) return { applied: false, html: demoHtml, reason };
|
||
|
||
const declaration = DECLARATION.exec(snippetHtml)?.[1] as string;
|
||
const host = findDeclarationHost(demoHtml, snippetHtml) as string;
|
||
|
||
const styles = inlineBlocks(snippetHtml, STYLE_BLOCK);
|
||
const scripts = inlineBlocks(snippetHtml, SCRIPT_BLOCK);
|
||
|
||
// Order matters twice, in opposite directions.
|
||
//
|
||
// The styles go last, so the snippet's var()-driven rules win over the
|
||
// demo's hardcoded copies on document order.
|
||
//
|
||
// The script goes FIRST, ahead of the demo's own. Many component scripts
|
||
// rebuild their subtree from the resolved values, and the demo's timeline
|
||
// captures element references when it is built. Running the snippet's script
|
||
// afterwards swapped those elements out from under a live timeline, which
|
||
// animated detached nodes and left the preview frozen: nine previews that
|
||
// used to move went static that way before this ordering was fixed.
|
||
const html = withStylesAppended(withScriptsFirst(demoHtml, scripts), styles).replace(
|
||
host,
|
||
withDeclarationAttribute(host, declaration),
|
||
);
|
||
|
||
return { applied: true, html };
|
||
}
|
||
|
||
function withScriptsFirst(demoHtml: string, scripts: string[]): string {
|
||
const firstDemoScript = INLINE_SCRIPT_OPEN.exec(demoHtml)?.[0];
|
||
const block = scripts.join("\n");
|
||
return firstDemoScript
|
||
? demoHtml.replace(firstDemoScript, `${block}\n${firstDemoScript}`)
|
||
: demoHtml.replace(BODY_CLOSE, `${block}\n</body>`);
|
||
}
|
||
|
||
function withStylesAppended(html: string, styles: string[]): string {
|
||
return html.replace(BODY_CLOSE, `${styles.join("\n")}\n</body>`);
|
||
}
|