mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(registry): add 4 community components (#4906)
* 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>
This commit is contained in:
co-authored by
Carlos Miranda Levy
tugrawork-creator
Akshar Patel
rottenpen
parent
f16e509832
commit
04acbb4d9e
@@ -0,0 +1,24 @@
|
||||
# AI Answer Rank editing contract
|
||||
|
||||
## Surface ownership
|
||||
|
||||
This is a generic assistant answer card, not any real AI product. The website brand is the subject of the ranking: the `brand` slot carries its real name, and the re-rank moves it into the answer with the one accent highlight. The other picks are competitors, never the brand itself. The chat card and motion remain template-owned.
|
||||
|
||||
## Editable slots
|
||||
|
||||
Only defaults declared in `data-composition-variables` are editable:
|
||||
|
||||
- `prompt` and `lead`
|
||||
- `rowA`, `rowB`, and `rowC`, the original picks
|
||||
- `brand` and `brandRank`, the slot the brand lands in
|
||||
- `accent`, `ink`, and `ground`
|
||||
|
||||
Typed copy is length-locked to within 20% of the original. Pick and brand names stay short enough to fit one row.
|
||||
|
||||
## Safe editing mechanics
|
||||
|
||||
Call `set_template_variable_defaults` once with the existing variable ids and their new defaults. Do not directly edit or rewrite `index.html` or its `data-composition-variables` attribute; the imported declaration is HTML-entity-encoded JSON and the setter preserves that encoding. Never edit `__template_baseline__.html` or a duplicate composition file. Validate after the setter succeeds.
|
||||
|
||||
## Protected
|
||||
|
||||
Do not change the card chrome, monogram tiles, skeleton rules, layout, fonts, scene order, duration, timing, easing, typing cadence, or re-rank logic. Website colors may only reach the declared `accent`, `ink`, and `ground` slots; keep `ink` legible on `ground`. If a requested value has no declared variable, leave it unchanged.
|
||||
+559
@@ -0,0 +1,559 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
ai-answer-rank: "Is your brand in the AI answer?"
|
||||
|
||||
A mock AI chat card for answer-engine and AI-visibility marketing. The
|
||||
question types into the user's bubble, the assistant answers with a ranked
|
||||
list of three picks, holds, then re-ranks: the promoted brand slides into
|
||||
its slot, the rows below it step down, the last one drops out of the top
|
||||
three, and the brand row lands with the one accent highlight and a check.
|
||||
|
||||
The UI is placeholder material: ink at alpha steps over the ground, logos as
|
||||
rounded-square monograms, descriptions as skeleton rules. Accent lands on
|
||||
the brand row only.
|
||||
|
||||
Rank numbers belong to the slots, not the rows, so a row that moves keeps
|
||||
the list numbered 1-3 without swapping any text.
|
||||
-->
|
||||
<html
|
||||
lang="en"
|
||||
data-composition-variables='[
|
||||
{ "id": "prompt", "type": "string", "role": "content", "label": "Prompt", "description": "The question typed into the user bubble. Up to about 60 characters keeps it on one or two lines.", "default": "What's the best CRM for a small team?" },
|
||||
{ "id": "lead", "type": "string", "role": "content", "label": "Answer lead-in", "description": "The assistant line above the ranked list.", "default": "Here are the top picks:" },
|
||||
{ "id": "rowA", "type": "string", "role": "content", "label": "Pick 1", "description": "First pick in the original answer: a product other than the brand this video promotes.", "portrays": ["competitor_name"], "default": "Brand A" },
|
||||
{ "id": "rowB", "type": "string", "role": "content", "label": "Pick 2", "description": "Second pick in the original answer: a product other than the brand this video promotes.", "portrays": ["competitor_name"], "default": "Brand B" },
|
||||
{ "id": "rowC", "type": "string", "role": "content", "label": "Pick 3", "description": "Third pick in the original answer. It drops out of the top three when the brand lands.", "portrays": ["competitor_name"], "default": "Brand C" },
|
||||
{ "id": "brand", "type": "string", "role": "content", "label": "Brand", "description": "The brand this video promotes, by its real name. It slides into the answer and takes the highlight.", "portrays": ["subject_name"], "default": "Your brand" },
|
||||
{ "id": "brandRank", "type": "enum", "role": "content", "label": "Brand rank", "description": "The slot the brand lands in. Picks at and below it step down one place.", "portrays": ["recommended_position"], "default": "1", "options": [{ "value": "1", "label": "1st" }, { "value": "2", "label": "2nd" }, { "value": "3", "label": "3rd" }] },
|
||||
{ "id": "accent", "type": "color", "role": "style", "label": "Accent", "description": "Highlight, monogram and check on the brand row, the one accent element.", "default": "#16a34a" },
|
||||
{ "id": "ink", "type": "color", "role": "style", "label": "Ink", "description": "Text colour; every placeholder surface is this ink at an alpha step.", "default": "#18181b" },
|
||||
{ "id": "ground", "type": "color", "role": "style", "label": "Ground", "description": "Chat card surface; the page around it is the ground tinted toward the ink.", "default": "#fcfcfb" }
|
||||
]'
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1080, height=1080" />
|
||||
<title>AI Answer Rank</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 1080px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #efefee;
|
||||
}
|
||||
#aar-root {
|
||||
--aar-ink: #18181b;
|
||||
--aar-ground: #fcfcfb;
|
||||
--aar-accent: #16a34a;
|
||||
--aar-l1: color-mix(in srgb, var(--aar-ink) 72%, transparent);
|
||||
--aar-l2: color-mix(in srgb, var(--aar-ink) 45%, transparent);
|
||||
--aar-l3: color-mix(in srgb, var(--aar-ink) 18%, transparent);
|
||||
--aar-l4: color-mix(in srgb, var(--aar-ink) 8%, transparent);
|
||||
--aar-hair: color-mix(in srgb, var(--aar-ink) 14%, transparent);
|
||||
position: relative;
|
||||
width: 1080px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--aar-ink) 6%, var(--aar-ground));
|
||||
color: var(--aar-ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
#aar-scene {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
#aar-card {
|
||||
width: 920px;
|
||||
padding: 56px 56px 16px;
|
||||
border-radius: 44px;
|
||||
background: var(--aar-ground);
|
||||
border: 1px solid var(--aar-hair);
|
||||
box-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.04),
|
||||
0 30px 80px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* the bubble is sized by an invisible copy of the whole prompt, so typing never reflows the card */
|
||||
#aar-bubble {
|
||||
position: relative;
|
||||
align-self: flex-end;
|
||||
max-width: 720px;
|
||||
padding: 24px 32px;
|
||||
border-radius: 32px 32px 10px 32px;
|
||||
background: var(--aar-l4);
|
||||
font-size: 32px;
|
||||
line-height: 44px;
|
||||
letter-spacing: -0.2px;
|
||||
transform-origin: 100% 100%;
|
||||
}
|
||||
#aar-sizer {
|
||||
visibility: hidden;
|
||||
}
|
||||
#aar-typing {
|
||||
position: absolute;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
top: 24px;
|
||||
}
|
||||
#aar-caret {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 34px;
|
||||
margin-left: 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--aar-ink);
|
||||
vertical-align: -6px;
|
||||
}
|
||||
|
||||
#aar-answer {
|
||||
position: relative;
|
||||
margin-top: 48px;
|
||||
}
|
||||
#aar-lead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 48px;
|
||||
font-size: 34px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.4px;
|
||||
}
|
||||
#aar-star {
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
#aar-star path {
|
||||
fill: var(--aar-ink);
|
||||
}
|
||||
.aar-w {
|
||||
opacity: 0;
|
||||
}
|
||||
#aar-thinking {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 17px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.aar-dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--aar-l2);
|
||||
}
|
||||
|
||||
/* 40px of runway under slot three, masked, so the pick pushed out fades instead of being cut */
|
||||
#aar-list {
|
||||
position: relative;
|
||||
height: 448px;
|
||||
margin-top: 28px;
|
||||
overflow: hidden;
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 408px, transparent 448px);
|
||||
mask-image: linear-gradient(to bottom, #000 408px, transparent 448px);
|
||||
}
|
||||
.aar-rank {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 44px;
|
||||
height: 124px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--aar-l1);
|
||||
}
|
||||
.aar-row {
|
||||
position: absolute;
|
||||
left: 68px;
|
||||
right: 0;
|
||||
height: 124px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 26px;
|
||||
padding: 0 28px;
|
||||
border-radius: 28px;
|
||||
border: 1.5px solid var(--aar-hair);
|
||||
}
|
||||
.aar-hl {
|
||||
position: absolute;
|
||||
inset: -1.5px;
|
||||
border-radius: 28px;
|
||||
border: 3px solid var(--aar-accent);
|
||||
background: color-mix(in srgb, var(--aar-accent) 9%, transparent);
|
||||
}
|
||||
.aar-tile {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
border-radius: 18px;
|
||||
background: var(--aar-l1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.aar-tile-accent {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--aar-accent);
|
||||
}
|
||||
.aar-mono {
|
||||
position: relative;
|
||||
color: var(--aar-ground);
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.aar-meta {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.aar-name {
|
||||
font-size: 36px;
|
||||
font-weight: 600;
|
||||
line-height: 44px;
|
||||
letter-spacing: -0.5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.aar-skel {
|
||||
height: 12px;
|
||||
margin-top: 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--aar-l3);
|
||||
}
|
||||
#aar-check {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
#aar-check circle {
|
||||
fill: var(--aar-accent);
|
||||
}
|
||||
#aar-check-path {
|
||||
fill: none;
|
||||
stroke: var(--aar-ground);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.aar-hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="aar-root"
|
||||
data-composition-id="ai-answer-rank"
|
||||
data-start="0"
|
||||
data-width="1080"
|
||||
data-height="1080"
|
||||
data-duration="9"
|
||||
>
|
||||
<section id="aar-scene" class="clip" data-start="0" data-duration="9" data-track-index="1">
|
||||
<div id="aar-card" class="aar-hidden">
|
||||
<div id="aar-bubble" class="aar-hidden">
|
||||
<span id="aar-sizer">What's the best CRM for a small team?</span>
|
||||
<div id="aar-typing"><span id="aar-typed"></span><span id="aar-caret"></span></div>
|
||||
</div>
|
||||
|
||||
<div id="aar-answer">
|
||||
<div id="aar-thinking" class="aar-hidden">
|
||||
<div class="aar-dot"></div>
|
||||
<div class="aar-dot"></div>
|
||||
<div class="aar-dot"></div>
|
||||
</div>
|
||||
<div id="aar-lead">
|
||||
<svg id="aar-star" class="aar-hidden" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M12 0 C12.9 7 17 11.1 24 12 C17 12.9 12.9 17 12 24 C11.1 17 7 12.9 0 12 C7 11.1 11.1 7 12 0 Z"
|
||||
/>
|
||||
</svg>
|
||||
<span id="aar-lead-text">Here are the top picks:</span>
|
||||
</div>
|
||||
|
||||
<div id="aar-list">
|
||||
<div class="aar-rank aar-hidden" id="aar-rank-1" style="top: 0">1</div>
|
||||
<div class="aar-rank aar-hidden" id="aar-rank-2" style="top: 142px">2</div>
|
||||
<div class="aar-rank aar-hidden" id="aar-rank-3" style="top: 284px">3</div>
|
||||
|
||||
<div
|
||||
class="aar-row aar-hidden"
|
||||
id="aar-row-a"
|
||||
data-layout-allow-overflow
|
||||
style="top: 0"
|
||||
>
|
||||
<div class="aar-tile"><span class="aar-mono">BA</span></div>
|
||||
<div class="aar-meta">
|
||||
<div class="aar-name">Brand A</div>
|
||||
<div class="aar-skel" style="width: 58%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="aar-row aar-hidden"
|
||||
id="aar-row-b"
|
||||
data-layout-allow-overflow
|
||||
style="top: 142px"
|
||||
>
|
||||
<div class="aar-tile"><span class="aar-mono">BB</span></div>
|
||||
<div class="aar-meta">
|
||||
<div class="aar-name">Brand B</div>
|
||||
<div class="aar-skel" style="width: 44%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="aar-row aar-hidden"
|
||||
id="aar-row-c"
|
||||
data-layout-allow-overflow
|
||||
style="top: 284px"
|
||||
>
|
||||
<div class="aar-tile"><span class="aar-mono">BC</span></div>
|
||||
<div class="aar-meta">
|
||||
<div class="aar-name">Brand C</div>
|
||||
<div class="aar-skel" style="width: 51%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="aar-row aar-hidden"
|
||||
id="aar-row-brand"
|
||||
data-layout-allow-overflow
|
||||
style="top: 0"
|
||||
>
|
||||
<div class="aar-hl aar-hidden" id="aar-hl"></div>
|
||||
<div class="aar-tile">
|
||||
<div class="aar-tile-accent aar-hidden" id="aar-tile-accent"></div>
|
||||
<span class="aar-mono">YB</span>
|
||||
</div>
|
||||
<div class="aar-meta">
|
||||
<div class="aar-name">Your brand</div>
|
||||
<div class="aar-skel" style="width: 62%"></div>
|
||||
</div>
|
||||
<svg id="aar-check" class="aar-hidden" viewBox="0 0 56 56" aria-hidden="true">
|
||||
<circle cx="28" cy="28" r="28" />
|
||||
<path id="aar-check-path" d="M17 29 L24.5 36.5 L39 21" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var vars =
|
||||
window.__hyperframes && typeof window.__hyperframes.getVariables === "function"
|
||||
? window.__hyperframes.getVariables()
|
||||
: {};
|
||||
|
||||
function text(value, fallback, max) {
|
||||
if (typeof value !== "string") return fallback;
|
||||
var t = value.trim();
|
||||
if (!t) return fallback;
|
||||
return t.length > max ? t.slice(0, max) : t;
|
||||
}
|
||||
function color(value, fallback) {
|
||||
if (typeof value !== "string") return fallback;
|
||||
var c = value.trim();
|
||||
return c && CSS.supports("color", c) ? c : fallback;
|
||||
}
|
||||
function monogram(name) {
|
||||
var words = name.split(/\s+/).filter(Boolean);
|
||||
var letters =
|
||||
words.length > 1 ? words[0][0] + words[words.length - 1][0] : name.slice(0, 1);
|
||||
return letters.toUpperCase();
|
||||
}
|
||||
|
||||
var root = document.getElementById("aar-root");
|
||||
root.style.setProperty("--aar-ink", color(vars.ink, "#18181b"));
|
||||
root.style.setProperty("--aar-ground", color(vars.ground, "#fcfcfb"));
|
||||
root.style.setProperty("--aar-accent", color(vars.accent, "#16a34a"));
|
||||
|
||||
var prompt = text(vars.prompt, "What's the best CRM for a small team?", 90);
|
||||
var lead = text(vars.lead, "Here are the top picks:", 48);
|
||||
var names = [
|
||||
text(vars.rowA, "Brand A", 32),
|
||||
text(vars.rowB, "Brand B", 32),
|
||||
text(vars.rowC, "Brand C", 32),
|
||||
];
|
||||
var brand = text(vars.brand, "Your brand", 32);
|
||||
var slot = ["1", "2", "3"].indexOf(String(vars.brandRank));
|
||||
if (slot < 0) slot = 0;
|
||||
|
||||
var SLOT = 142;
|
||||
var rows = ["#aar-row-a", "#aar-row-b", "#aar-row-c"].map(function (sel, i) {
|
||||
var el = document.querySelector(sel);
|
||||
el.querySelector(".aar-name").textContent = names[i];
|
||||
el.querySelector(".aar-mono").textContent = monogram(names[i]);
|
||||
return el;
|
||||
});
|
||||
var brandRow = document.getElementById("aar-row-brand");
|
||||
brandRow.querySelector(".aar-name").textContent = brand;
|
||||
brandRow.querySelector(".aar-mono").textContent = monogram(brand);
|
||||
brandRow.style.top = slot * SLOT + "px";
|
||||
|
||||
document.getElementById("aar-sizer").textContent = prompt;
|
||||
var leadEl = document.getElementById("aar-lead-text");
|
||||
leadEl.textContent = "";
|
||||
var words = lead.split(/\s+/).map(function (word, i) {
|
||||
if (i > 0) leadEl.appendChild(document.createTextNode(" "));
|
||||
var span = document.createElement("span");
|
||||
span.className = "aar-w";
|
||||
span.textContent = word;
|
||||
leadEl.appendChild(span);
|
||||
return span;
|
||||
});
|
||||
|
||||
var checkPath = document.getElementById("aar-check-path");
|
||||
var checkLen = checkPath.getTotalLength();
|
||||
checkPath.style.strokeDasharray = String(checkLen);
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
|
||||
tl.fromTo(
|
||||
"#aar-card",
|
||||
{ autoAlpha: 0, y: 40 },
|
||||
{ autoAlpha: 1, y: 0, duration: 0.6, ease: "power3.out" },
|
||||
0.1,
|
||||
);
|
||||
tl.fromTo(
|
||||
"#aar-bubble",
|
||||
{ autoAlpha: 0, scale: 0.9 },
|
||||
{ autoAlpha: 1, scale: 1, duration: 0.3, ease: "back.out(1.6)" },
|
||||
0.5,
|
||||
);
|
||||
|
||||
/* uneven keystrokes read as a person typing; the budget keeps long prompts inside 9s */
|
||||
var KEY_GAPS = [
|
||||
0.05, 0.035, 0.06, 0.04, 0.03, 0.055, 0.045, 0.07, 0.035, 0.05, 0.04, 0.065,
|
||||
];
|
||||
var raw = 0;
|
||||
for (var n = 1; n < prompt.length; n += 1) raw += KEY_GAPS[n % KEY_GAPS.length];
|
||||
var squeeze = Math.min(1, 1.8 / Math.max(raw, 0.001));
|
||||
var TYPE_START = 0.8;
|
||||
var t = TYPE_START;
|
||||
tl.set("#aar-typed", { textContent: "" }, 0.001);
|
||||
tl.set("#aar-caret", { autoAlpha: 0 }, 0.001);
|
||||
tl.set("#aar-caret", { autoAlpha: 1 }, TYPE_START - 0.15);
|
||||
for (var c = 1; c <= prompt.length; c += 1) {
|
||||
tl.set("#aar-typed", { textContent: prompt.slice(0, c) }, t);
|
||||
t += KEY_GAPS[c % KEY_GAPS.length] * squeeze;
|
||||
}
|
||||
var typeEnd = t;
|
||||
tl.set("#aar-caret", { autoAlpha: 0 }, typeEnd + 0.2);
|
||||
|
||||
var THINK = typeEnd + 0.4;
|
||||
tl.fromTo(
|
||||
"#aar-thinking",
|
||||
{ autoAlpha: 0 },
|
||||
{ autoAlpha: 1, duration: 0.15, ease: "none" },
|
||||
THINK,
|
||||
);
|
||||
gsap.utils.toArray(".aar-dot").forEach(function (dot, i) {
|
||||
for (var b = 0; b < 2; b += 1) {
|
||||
var at = THINK + 0.1 + b * 0.32 + i * 0.08;
|
||||
tl.to(dot, { y: -9, duration: 0.14, ease: "sine.out" }, at);
|
||||
tl.to(dot, { y: 0, duration: 0.14, ease: "sine.in" }, at + 0.14);
|
||||
}
|
||||
});
|
||||
var LEAD = THINK + 0.85;
|
||||
tl.to("#aar-thinking", { autoAlpha: 0, duration: 0.12, ease: "none" }, LEAD - 0.12);
|
||||
tl.fromTo(
|
||||
"#aar-star",
|
||||
{ autoAlpha: 0, scale: 0.3, rotation: -90 },
|
||||
{ autoAlpha: 1, scale: 1, rotation: 0, duration: 0.45, ease: "back.out(2)" },
|
||||
LEAD,
|
||||
);
|
||||
words.forEach(function (w, i) {
|
||||
tl.set(w, { opacity: 1 }, LEAD + 0.12 + i * 0.07);
|
||||
});
|
||||
|
||||
var ROWS = LEAD + 0.25 + words.length * 0.07;
|
||||
rows.forEach(function (row, i) {
|
||||
var at = ROWS + i * 0.16;
|
||||
tl.fromTo(
|
||||
"#aar-rank-" + (i + 1),
|
||||
{ autoAlpha: 0 },
|
||||
{ autoAlpha: 1, duration: 0.3, ease: "none" },
|
||||
at,
|
||||
);
|
||||
tl.fromTo(
|
||||
row,
|
||||
{ autoAlpha: 0, y: 24 },
|
||||
{ autoAlpha: 1, y: 0, duration: 0.45, ease: "power3.out" },
|
||||
at,
|
||||
);
|
||||
});
|
||||
|
||||
/* picks step down rather than trade places, so the eye can follow each one to its new rank */
|
||||
var RERANK = ROWS + 0.32 + 0.45 + 0.95;
|
||||
rows.forEach(function (row, i) {
|
||||
if (i < slot) return;
|
||||
tl.to(row, { y: SLOT, duration: 0.55, ease: "power3.inOut" }, RERANK);
|
||||
if (i === 2) tl.to(row, { autoAlpha: 0, duration: 0.4, ease: "power1.in" }, RERANK + 0.1);
|
||||
});
|
||||
/* the brand waits until its slot is mostly clear, so it never lands on the row leaving it */
|
||||
tl.fromTo(
|
||||
brandRow,
|
||||
{ autoAlpha: 0, x: 96 },
|
||||
{ autoAlpha: 1, x: 0, duration: 0.55, ease: "power3.out" },
|
||||
RERANK + 0.34,
|
||||
);
|
||||
|
||||
var LAND = RERANK + 0.9;
|
||||
tl.fromTo(
|
||||
"#aar-hl",
|
||||
{ autoAlpha: 0 },
|
||||
{ autoAlpha: 1, duration: 0.3, ease: "power1.out" },
|
||||
LAND,
|
||||
);
|
||||
tl.fromTo(
|
||||
"#aar-tile-accent",
|
||||
{ autoAlpha: 0 },
|
||||
{ autoAlpha: 1, duration: 0.3, ease: "power1.out" },
|
||||
LAND,
|
||||
);
|
||||
tl.fromTo(
|
||||
"#aar-check",
|
||||
{ autoAlpha: 0, scale: 0.4 },
|
||||
{ autoAlpha: 1, scale: 1, duration: 0.42, ease: "back.out(2.2)" },
|
||||
LAND + 0.05,
|
||||
);
|
||||
tl.fromTo(
|
||||
checkPath,
|
||||
{ strokeDashoffset: checkLen },
|
||||
{ strokeDashoffset: 0, duration: 0.34, ease: "power2.out" },
|
||||
LAND + 0.2,
|
||||
);
|
||||
|
||||
tl.set({}, {}, 9);
|
||||
window.__timelines["ai-answer-rank"] = tl;
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "ai-answer-rank",
|
||||
"type": "hyperframes:block",
|
||||
"title": "AI Answer Rank",
|
||||
"description": "Is your brand in the AI answer? A question 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, the picks below step down, and the brand row lands with the one accent highlight and a check. For answer-engine and AI-visibility marketing",
|
||||
"tags": ["mock-ui", "ai", "chat", "ranking", "list", "marketing", "ad-template", "square"],
|
||||
"author": "Tugra Avci",
|
||||
"dimensions": {
|
||||
"width": 1080,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 9,
|
||||
"variables": [
|
||||
{
|
||||
"id": "prompt",
|
||||
"type": "string",
|
||||
"role": "content",
|
||||
"label": "Prompt",
|
||||
"description": "The question typed into the user bubble. Up to about 60 characters keeps it on one or two lines.",
|
||||
"default": "What's the best CRM for a small team?"
|
||||
},
|
||||
{
|
||||
"id": "lead",
|
||||
"type": "string",
|
||||
"role": "content",
|
||||
"label": "Answer lead-in",
|
||||
"description": "The assistant line above the ranked list.",
|
||||
"default": "Here are the top picks:"
|
||||
},
|
||||
{
|
||||
"id": "rowA",
|
||||
"type": "string",
|
||||
"role": "content",
|
||||
"label": "Pick 1",
|
||||
"description": "First pick in the original answer: a product other than the brand this video promotes.",
|
||||
"portrays": ["competitor_name"],
|
||||
"default": "Brand A"
|
||||
},
|
||||
{
|
||||
"id": "rowB",
|
||||
"type": "string",
|
||||
"role": "content",
|
||||
"label": "Pick 2",
|
||||
"description": "Second pick in the original answer: a product other than the brand this video promotes.",
|
||||
"portrays": ["competitor_name"],
|
||||
"default": "Brand B"
|
||||
},
|
||||
{
|
||||
"id": "rowC",
|
||||
"type": "string",
|
||||
"role": "content",
|
||||
"label": "Pick 3",
|
||||
"description": "Third pick in the original answer. It drops out of the top three when the brand lands.",
|
||||
"portrays": ["competitor_name"],
|
||||
"default": "Brand C"
|
||||
},
|
||||
{
|
||||
"id": "brand",
|
||||
"type": "string",
|
||||
"role": "content",
|
||||
"label": "Brand",
|
||||
"description": "The brand this video promotes, by its real name. It slides into the answer and takes the highlight.",
|
||||
"portrays": ["subject_name"],
|
||||
"default": "Your brand"
|
||||
},
|
||||
{
|
||||
"id": "brandRank",
|
||||
"type": "enum",
|
||||
"role": "content",
|
||||
"label": "Brand rank",
|
||||
"description": "The slot the brand lands in. Picks at and below it step down one place.",
|
||||
"portrays": ["recommended_position"],
|
||||
"default": "1",
|
||||
"options": [
|
||||
{
|
||||
"value": "1",
|
||||
"label": "1st"
|
||||
},
|
||||
{
|
||||
"value": "2",
|
||||
"label": "2nd"
|
||||
},
|
||||
{
|
||||
"value": "3",
|
||||
"label": "3rd"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "accent",
|
||||
"type": "color",
|
||||
"role": "style",
|
||||
"label": "Accent",
|
||||
"description": "Highlight, monogram and check on the brand row, the one accent element.",
|
||||
"default": "#16a34a"
|
||||
},
|
||||
{
|
||||
"id": "ink",
|
||||
"type": "color",
|
||||
"role": "style",
|
||||
"label": "Ink",
|
||||
"description": "Text colour; every placeholder surface is this ink at an alpha step.",
|
||||
"default": "#18181b"
|
||||
},
|
||||
{
|
||||
"id": "ground",
|
||||
"type": "color",
|
||||
"role": "style",
|
||||
"label": "Ground",
|
||||
"description": "Chat card surface; the page around it is the ground tinted toward the ink.",
|
||||
"default": "#fcfcfb"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "ai-answer-rank.html",
|
||||
"target": "compositions/ai-answer-rank.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "TEMPLATE.md",
|
||||
"target": "TEMPLATE.md",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
]
|
||||
}
|
||||
+298
@@ -0,0 +1,298 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<!--
|
||||
cinemagraph — a still photograph with ONE living region.
|
||||
|
||||
The frame is a frozen scene; a single masked window keeps moving — the classic
|
||||
cinemagraph gesture: the camera stops, the world keeps breathing. The living
|
||||
region here is the steam over a cup, drawn deterministically on a canvas from
|
||||
the seek time alone, with a period that divides the duration, so the last
|
||||
frame matches the first and the render loops seamlessly.
|
||||
|
||||
Using it with real media: keep the structure and swap the layers —
|
||||
1. Put your looping <video> (or the animated source) as the BASE layer.
|
||||
2. Put a freeze-frame still of it (an <img>) ABOVE, full-frame.
|
||||
3. Move #cmg-window (the feathered mask) onto the still so the window
|
||||
reveals the video beneath, and delete the placeholder scene + canvas.
|
||||
One living region per frame. Two is a gif.
|
||||
-->
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
background: #0b0e14;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root-cinemagraph {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
background: #0b0e14;
|
||||
--hf-ink: #f8fafc;
|
||||
}
|
||||
/* ---- the still photograph (placeholder scene, monochrome ink ramp) ---- */
|
||||
.cmg-photo {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
/* window light falling across the scene */
|
||||
.cmg-lightfall {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
115deg,
|
||||
color-mix(in srgb, var(--hf-ink) 8%, transparent) 0%,
|
||||
transparent 46%
|
||||
);
|
||||
}
|
||||
/* table surface */
|
||||
.cmg-table {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 320px;
|
||||
background: color-mix(in srgb, var(--hf-ink) 8%, transparent);
|
||||
border-top: 1px solid color-mix(in srgb, var(--hf-ink) 14%, transparent);
|
||||
}
|
||||
/* window frame in the far wall */
|
||||
.cmg-pane {
|
||||
position: absolute;
|
||||
left: 1210px;
|
||||
top: 130px;
|
||||
width: 420px;
|
||||
height: 520px;
|
||||
background: color-mix(in srgb, var(--hf-ink) 18%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--hf-ink) 14%, transparent);
|
||||
}
|
||||
.cmg-pane::before,
|
||||
.cmg-pane::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: color-mix(in srgb, var(--hf-ink) 14%, transparent);
|
||||
}
|
||||
.cmg-pane::before {
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
}
|
||||
.cmg-pane::after {
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
}
|
||||
/* saucer + cup, the photograph's subject */
|
||||
.cmg-saucer {
|
||||
position: absolute;
|
||||
left: 760px;
|
||||
bottom: 236px;
|
||||
width: 400px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--hf-ink) 45%, transparent);
|
||||
}
|
||||
.cmg-cup {
|
||||
position: absolute;
|
||||
left: 830px;
|
||||
bottom: 262px;
|
||||
width: 260px;
|
||||
height: 190px;
|
||||
border-radius: 0 0 118px 118px;
|
||||
background: color-mix(in srgb, var(--hf-ink) 72%, transparent);
|
||||
}
|
||||
.cmg-cup::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -64px;
|
||||
top: 18px;
|
||||
width: 88px;
|
||||
height: 96px;
|
||||
border: 22px solid color-mix(in srgb, var(--hf-ink) 72%, transparent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.cmg-rim {
|
||||
position: absolute;
|
||||
left: 822px;
|
||||
bottom: 436px;
|
||||
width: 276px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--hf-ink) 45%, transparent);
|
||||
}
|
||||
/* a closed book beside the cup */
|
||||
.cmg-book {
|
||||
position: absolute;
|
||||
left: 1180px;
|
||||
bottom: 250px;
|
||||
width: 330px;
|
||||
height: 44px;
|
||||
background: color-mix(in srgb, var(--hf-ink) 18%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--hf-ink) 14%, transparent);
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
.cmg-book::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
top: 12px;
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--hf-ink) 14%, transparent);
|
||||
}
|
||||
/* skeleton caption lines, bottom left — content lives here */
|
||||
.cmg-line {
|
||||
position: absolute;
|
||||
left: 160px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
background: color-mix(in srgb, var(--hf-ink) 18%, transparent);
|
||||
}
|
||||
/* ---- the ONE living region: a feathered window where steam moves ---- */
|
||||
#cmg-window {
|
||||
position: absolute;
|
||||
left: 790px;
|
||||
bottom: 430px;
|
||||
width: 340px;
|
||||
height: 420px;
|
||||
-webkit-mask-image: radial-gradient(ellipse 50% 50% at 50% 55%, #000 55%, transparent 78%);
|
||||
mask-image: radial-gradient(ellipse 50% 50% at 50% 55%, #000 55%, transparent 78%);
|
||||
}
|
||||
#cmg-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 340px;
|
||||
height: 420px;
|
||||
}
|
||||
/* still vignette over everything, part of the photograph */
|
||||
.cmg-vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse 78% 72% at 50% 46%,
|
||||
transparent 62%,
|
||||
rgba(4, 6, 10, 0.5) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root-cinemagraph"
|
||||
data-composition-id="cinemagraph"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
>
|
||||
<div class="cmg-photo" id="cmg-photo">
|
||||
<div class="cmg-lightfall"></div>
|
||||
<div class="cmg-pane"></div>
|
||||
<div class="cmg-table"></div>
|
||||
<div class="cmg-saucer"></div>
|
||||
<div class="cmg-cup"></div>
|
||||
<div class="cmg-rim"></div>
|
||||
<div class="cmg-book"></div>
|
||||
<div class="cmg-line" style="bottom: 150px; width: 460px"></div>
|
||||
<div class="cmg-line" style="bottom: 108px; width: 300px"></div>
|
||||
</div>
|
||||
<div id="cmg-window">
|
||||
<canvas id="cmg-canvas" width="340" height="420"></canvas>
|
||||
</div>
|
||||
<div class="cmg-vignette"></div>
|
||||
<div
|
||||
id="drv-cinemagraph"
|
||||
class="clip"
|
||||
data-start="0"
|
||||
data-duration="10"
|
||||
data-track-index="0"
|
||||
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none"
|
||||
></div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
window.__timelines = window.__timelines || {};
|
||||
|
||||
// Seeded PRNG — never Math.random()
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
var t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
var rng = mulberry32(1897);
|
||||
|
||||
var DUR = 10;
|
||||
// Each wisp cycles an integer number of times over DUR, so the frame at
|
||||
// t = DUR equals the frame at t = 0 and the render loops seamlessly.
|
||||
var WISPS = [];
|
||||
for (var i = 0; i < 14; i++) {
|
||||
WISPS.push({
|
||||
x0: 90 + rng() * 160, // birth x inside the window
|
||||
cycles: 1 + Math.floor(rng() * 2), // 1 or 2 full rises over DUR
|
||||
phase: rng(), // where in its cycle it starts
|
||||
sway: 14 + rng() * 26, // horizontal sway amplitude
|
||||
swayFreq: 1 + Math.floor(rng() * 2), // integer — keeps the loop seam clean
|
||||
width: 10 + rng() * 22, // wisp thickness
|
||||
rise: 300 + rng() * 80, // how far it climbs before dissolving
|
||||
alpha: 0.05 + rng() * 0.09,
|
||||
});
|
||||
}
|
||||
|
||||
var canvas = document.getElementById("cmg-canvas");
|
||||
var ctx = canvas.getContext("2d");
|
||||
var W = 340,
|
||||
H = 420;
|
||||
|
||||
// State proxy — GSAP animates t, the render function reads it.
|
||||
var st = { t: 0 };
|
||||
|
||||
function renderScene() {
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
for (var i = 0; i < WISPS.length; i++) {
|
||||
var w = WISPS[i];
|
||||
// p in [0,1): this wisp's position in its own rise cycle
|
||||
var p = (st.t / DUR) * w.cycles + w.phase;
|
||||
p = p - Math.floor(p);
|
||||
var y = H - 40 - p * w.rise;
|
||||
var sway = Math.sin(2 * Math.PI * (p * w.swayFreq + w.phase)) * w.sway * p;
|
||||
var x = w.x0 + sway;
|
||||
// born faint, fullest mid-rise, dissolves at the top
|
||||
var a = w.alpha * Math.sin(Math.PI * p);
|
||||
var r = w.width * (0.7 + p * 1.6);
|
||||
var g = ctx.createRadialGradient(x, y, 0, x, y, r * 2.4);
|
||||
g.addColorStop(0, "rgba(248,250,252," + a.toFixed(4) + ")");
|
||||
g.addColorStop(1, "rgba(248,250,252,0)");
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, r * 1.1, r * 2.2, sway * 0.004, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.to(st, { t: DUR, duration: DUR, ease: "none" }, 0);
|
||||
|
||||
window.__timelines["cinemagraph"] = tl;
|
||||
|
||||
// Render from the timeline only — no requestAnimationFrame.
|
||||
tl.eventCallback("onUpdate", renderScene);
|
||||
renderScene();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "cinemagraph",
|
||||
"type": "hyperframes:block",
|
||||
"title": "Cinemagraph",
|
||||
"description": "A still photograph with one living region: the frame is frozen while a single feathered window keeps moving - here, steam rising over a cup - drawn from the seek time alone with a period that divides the duration, so the render loops seamlessly. Swap the placeholder scene for a freeze-frame still masked over a looping video to cinemagraph real footage.",
|
||||
"dimensions": { "width": 1920, "height": 1080 },
|
||||
"duration": 10,
|
||||
"tags": ["effect", "cinemagraph", "photo", "loop", "freeze-frame"],
|
||||
"files": [
|
||||
{
|
||||
"path": "cinemagraph.html",
|
||||
"target": "compositions/cinemagraph.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Caret Swap
|
||||
|
||||
Mount compositions/components/caret-swap/caret-swap.html with
|
||||
data-composition-id="caret-swap". The transparent component fills its host.
|
||||
The bundled font must remain beside this file.
|
||||
|
||||
The reference's expand, collapse, pause and accelerating typing use a 30fps
|
||||
clock. Longer hosts extend the final hold; shorter hosts compress the phases
|
||||
together so the replacement is complete before the end. Text is measured
|
||||
after fonts load, and both phrases shrink together to stay inside the host.
|
||||
This is a single-line effect; line breaks in either phrase become spaces.
|
||||
|
||||
Native HyperFrames port prepared through Hyfrme from:
|
||||
https://github.com/Remocn/remocn/blob/3e03565f5c0001e143c2ed941eea7c3181f13260/registry/remocn/caret-swap/index.tsx
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Remocn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
-->
|
||||
<html
|
||||
lang="en"
|
||||
data-composition-id="caret-swap"
|
||||
data-composition-duration="3.3333333333333335"
|
||||
data-composition-variables='[
|
||||
{"id":"from_text","type":"string","role":"content","label":"From text","description":"The phrase covered by the expanding caret.","default":"Executing it?"},
|
||||
{"id":"to_text","type":"string","role":"content","label":"To text","description":"The replacement phrase, typed one character at a time.","default":"That's where everyone quits"},
|
||||
{"id":"font_size","type":"number","role":"style","label":"Font size","description":"Maximum size in pixels. Both phrases shrink together to fit the host.","default":72,"min":36,"max":160,"step":2,"unit":"px"},
|
||||
{"id":"font_weight","type":"enum","role":"style","label":"Font weight","description":"Weight used by both phrases. Every option is included in the bundled Geist font.","default":"400","options":[{"value":"400","label":"Regular"},{"value":"500","label":"Medium"},{"value":"600","label":"Semibold"},{"value":"700","label":"Bold"}]},
|
||||
{"id":"color","type":"color","role":"style","label":"Text color","description":"Color of both phrases.","default":"#171717"},
|
||||
{"id":"caret_color","type":"color","role":"style","label":"Caret top","description":"Top color of the caret gradient.","default":"#FF4DAA"},
|
||||
{"id":"caret_color_end","type":"color","role":"style","label":"Caret bottom","description":"Bottom color of the caret gradient.","default":"#8A5CF6"}
|
||||
]'
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Caret Swap</title>
|
||||
</head>
|
||||
<body>
|
||||
<template>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Caret Swap Geist";
|
||||
src: url("./Geist-Latin.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
#root,
|
||||
#root * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
pointer-events: none;
|
||||
color: var(--color, #171717);
|
||||
font-family: var(--font-display, "Caret Swap Geist", system-ui, sans-serif);
|
||||
}
|
||||
|
||||
.cs-clip {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cs-stage {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
white-space: pre;
|
||||
font-size: 72px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.cs-source,
|
||||
.cs-target-text {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.cs-spacer {
|
||||
flex-shrink: 0;
|
||||
width: 0.08em;
|
||||
height: 1.1em;
|
||||
margin-left: 0.06em;
|
||||
}
|
||||
|
||||
.cs-cover,
|
||||
.cs-caret {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 1.1em;
|
||||
border-radius: 0.03em;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--caret_color, #ff4daa),
|
||||
var(--caret_color_end, #8a5cf6)
|
||||
);
|
||||
}
|
||||
|
||||
.cs-cover {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
--cs-mask-stop: 100%;
|
||||
mask-image: linear-gradient(90deg, black var(--cs-mask-stop), transparent 100%);
|
||||
}
|
||||
|
||||
.cs-target {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cs-caret {
|
||||
left: 0;
|
||||
width: 0.08em;
|
||||
margin-left: 0.06em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="caret-swap"
|
||||
data-duration="3.3333333333333335"
|
||||
data-fps="30"
|
||||
>
|
||||
<div
|
||||
id="cs-clip"
|
||||
class="cs-clip clip"
|
||||
data-start="0"
|
||||
data-duration="3.3333333333333335"
|
||||
data-track-index="0"
|
||||
>
|
||||
<div id="cs-stage" class="cs-stage" role="img">
|
||||
<span
|
||||
id="cs-source"
|
||||
class="cs-source"
|
||||
aria-hidden="true"
|
||||
data-layout-allow-occlusion
|
||||
></span>
|
||||
<span class="cs-spacer" aria-hidden="true"></span>
|
||||
<span id="cs-cover" class="cs-cover" aria-hidden="true"></span>
|
||||
<span id="cs-target" class="cs-target" aria-hidden="true">
|
||||
<span id="cs-target-text" class="cs-target-text"></span>
|
||||
<span id="cs-caret" class="cs-caret"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/CustomEase.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var root = document.getElementById("root");
|
||||
var stage = document.getElementById("cs-stage");
|
||||
var source = document.getElementById("cs-source");
|
||||
var target = document.getElementById("cs-target");
|
||||
var targetText = document.getElementById("cs-target-text");
|
||||
var caret = document.getElementById("cs-caret");
|
||||
var cover = document.getElementById("cs-cover");
|
||||
// Read host timing before the runtime flattens its attributes.
|
||||
var mount = root.closest("[data-composition-id]") || root;
|
||||
var duration = Math.max(
|
||||
0.001,
|
||||
Number(mount.dataset.duration || mount.dataset.hfAuthoredDuration) || 100 / 30,
|
||||
);
|
||||
document.getElementById("cs-clip").dataset.duration = String(duration);
|
||||
var vars =
|
||||
window.__hyperframes && window.__hyperframes.getVariables
|
||||
? window.__hyperframes.getVariables()
|
||||
: {};
|
||||
var fromText = typeof vars.from_text === "string" ? vars.from_text : "Executing it?";
|
||||
var toText =
|
||||
typeof vars.to_text === "string" ? vars.to_text : "That's where everyone quits";
|
||||
fromText = fromText.replace(/[\r\n\u2028\u2029]+/g, " ");
|
||||
toText = toText.replace(/[\r\n\u2028\u2029]+/g, " ");
|
||||
var fontSize = Number(vars.font_size);
|
||||
fontSize = Number.isFinite(fontSize) ? Math.min(160, Math.max(36, fontSize)) : 72;
|
||||
var fontWeight = ["400", "500", "600", "700"].includes(vars.font_weight)
|
||||
? vars.font_weight
|
||||
: "400";
|
||||
|
||||
stage.style.fontSize = fontSize + "px";
|
||||
stage.style.fontWeight = fontWeight;
|
||||
stage.setAttribute("aria-label", fromText + " → " + toText);
|
||||
source.textContent = fromText;
|
||||
|
||||
var segments = Array.from(
|
||||
new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(toText),
|
||||
);
|
||||
var phrases = segments.map(function (part) {
|
||||
return toText.slice(0, part.index + part.segment.length);
|
||||
});
|
||||
|
||||
function geometry() {
|
||||
var anchorWidth = stage.getBoundingClientRect().width;
|
||||
targetText.textContent = toText;
|
||||
var targetWidth = targetText.getBoundingClientRect().width;
|
||||
var caretWidth = fontSize * 0.08;
|
||||
var gap = fontSize * 0.06;
|
||||
var shift = (anchorWidth - targetWidth - caretWidth - gap) / 2;
|
||||
var prefixes = phrases.map(function (phrase) {
|
||||
targetText.textContent = phrase;
|
||||
return targetText.getBoundingClientRect().width;
|
||||
});
|
||||
targetText.textContent = toText;
|
||||
var halfWidth = anchorWidth / 2;
|
||||
prefixes.forEach(function (prefix, index) {
|
||||
var start = -anchorWidth / 2 + (shift * (index + 1)) / phrases.length;
|
||||
halfWidth = Math.max(
|
||||
halfWidth,
|
||||
Math.abs(start),
|
||||
Math.abs(start + prefix + gap + caretWidth),
|
||||
);
|
||||
});
|
||||
return {
|
||||
anchorWidth: anchorWidth,
|
||||
caretWidth: caretWidth,
|
||||
shift: shift,
|
||||
prefixes: prefixes,
|
||||
width: halfWidth * 2,
|
||||
};
|
||||
}
|
||||
|
||||
function typeElapsed(count) {
|
||||
return (1 - Math.pow(0.96, count)) / 0.04;
|
||||
}
|
||||
|
||||
document.fonts.ready.then(function () {
|
||||
var layout = geometry();
|
||||
var fit = Math.min(
|
||||
1,
|
||||
(root.clientWidth * 0.9) / Math.max(1, layout.width),
|
||||
(root.clientHeight * 0.8) / (fontSize * 1.15),
|
||||
);
|
||||
fontSize *= fit;
|
||||
stage.style.fontSize = fontSize + "px";
|
||||
layout = geometry();
|
||||
|
||||
var typeEnd = 40 + typeElapsed(phrases.length);
|
||||
var timingScale = Math.min(1, (duration * 30) / (typeEnd + 8));
|
||||
function at(frame) {
|
||||
return (frame / 30) * timingScale;
|
||||
}
|
||||
|
||||
gsap.registerPlugin(CustomEase);
|
||||
var expandEase = CustomEase.create("cs-expand", "M0,0 C0.6,0 0.8,0.4 1,1");
|
||||
var collapseEase = CustomEase.create("cs-collapse", "M0,0 C0.2,0.7 0.3,1 1,1");
|
||||
gsap.set(source, { opacity: 1 });
|
||||
gsap.set(cover, {
|
||||
yPercent: -50,
|
||||
width: layout.caretWidth,
|
||||
x: layout.anchorWidth - layout.caretWidth,
|
||||
opacity: 0,
|
||||
"--cs-mask-stop": "100%",
|
||||
});
|
||||
gsap.set(target, { yPercent: -50, x: 0, opacity: 0 });
|
||||
gsap.set(caret, { yPercent: -50, x: 0, opacity: 1 });
|
||||
gsap.set(targetText, { textContent: "" });
|
||||
gsap.set(stage, { opacity: 1 });
|
||||
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
tl.set(cover, { opacity: 1 }, at(13));
|
||||
tl.fromTo(
|
||||
cover,
|
||||
{
|
||||
width: layout.caretWidth,
|
||||
x: layout.anchorWidth - layout.caretWidth,
|
||||
"--cs-mask-stop": "100%",
|
||||
},
|
||||
{
|
||||
width: layout.anchorWidth,
|
||||
x: 0,
|
||||
"--cs-mask-stop": "10%",
|
||||
duration: at(6),
|
||||
ease: expandEase,
|
||||
immediateRender: false,
|
||||
},
|
||||
at(20),
|
||||
);
|
||||
tl.set(source, { opacity: 0 }, at(26));
|
||||
tl.fromTo(
|
||||
cover,
|
||||
{ width: layout.anchorWidth, "--cs-mask-stop": "10%" },
|
||||
{
|
||||
width: layout.caretWidth,
|
||||
"--cs-mask-stop": "100%",
|
||||
duration: at(7),
|
||||
ease: collapseEase,
|
||||
immediateRender: false,
|
||||
},
|
||||
at(26),
|
||||
);
|
||||
tl.set(cover, { opacity: 0 }, at(40));
|
||||
tl.set(target, { opacity: 1 }, at(40));
|
||||
|
||||
phrases.forEach(function (phrase, index) {
|
||||
var time = at(40 + typeElapsed(index));
|
||||
tl.set(targetText, { textContent: phrase }, time);
|
||||
tl.set(caret, { x: layout.prefixes[index] }, time);
|
||||
tl.set(target, { x: (layout.shift * (index + 1)) / phrases.length }, time);
|
||||
});
|
||||
if (!phrases.length) tl.set(target, { x: layout.shift }, at(40));
|
||||
|
||||
for (
|
||||
var frame = typeEnd + 8, blink = 0;
|
||||
at(frame) < duration;
|
||||
frame += 8, blink += 1
|
||||
) {
|
||||
tl.set(caret, { opacity: blink % 2 }, at(frame));
|
||||
}
|
||||
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["caret-swap"] = tl;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</template>
|
||||
</body>
|
||||
</html>
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<!-- Caret Swap demo. Defaults and 100-frame timing follow the pinned Hyfrme reference. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1280, height=720" />
|
||||
<title>Caret Swap Demo</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#root {
|
||||
position: relative;
|
||||
}
|
||||
.cs-demo-background,
|
||||
.cs-demo-mount {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
}
|
||||
.cs-demo-background {
|
||||
background: #fdf6f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
data-composition-id="caret-swap-demo"
|
||||
data-start="0"
|
||||
data-width="1280"
|
||||
data-height="720"
|
||||
data-duration="3.3333333333333335"
|
||||
data-fps="30"
|
||||
>
|
||||
<div
|
||||
id="cs-demo-background"
|
||||
class="cs-demo-background clip"
|
||||
data-start="0"
|
||||
data-duration="3.3333333333333335"
|
||||
data-track-index="0"
|
||||
></div>
|
||||
<div
|
||||
id="cs-demo-mount"
|
||||
class="cs-demo-mount clip"
|
||||
data-composition-id="caret-swap"
|
||||
data-composition-src="./caret-swap.html"
|
||||
data-start="0"
|
||||
data-duration="3.3333333333333335"
|
||||
data-track-index="1"
|
||||
data-width="1280"
|
||||
data-height="720"
|
||||
></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["caret-swap-demo"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "caret-swap",
|
||||
"type": "hyperframes:component",
|
||||
"title": "Caret Swap",
|
||||
"description": "A gradient caret expands over one phrase, collapses to the left, then types its replacement with a blinking caret.",
|
||||
"tags": ["motion-primitive", "text-effects", "typography", "swap", "typewriter", "remocn-port"],
|
||||
"jobs": ["emphasize"],
|
||||
"family": "text-effects",
|
||||
"profile": "caret-swap",
|
||||
"author": "Remocn",
|
||||
"authorUrl": "https://github.com/Remocn/remocn",
|
||||
"license": "MIT AND OFL-1.1",
|
||||
"syncPoints": [
|
||||
{
|
||||
"id": "wipe-start",
|
||||
"phase": "in",
|
||||
"offset": 0.6666666666666666,
|
||||
"description": "The caret starts covering the first phrase in the default 100-frame demo. Shorter hosts compress the phases together."
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "caret-swap.html",
|
||||
"target": "compositions/components/caret-swap/caret-swap.html",
|
||||
"type": "hyperframes:snippet"
|
||||
},
|
||||
{
|
||||
"path": "Geist-Latin.woff2",
|
||||
"target": "compositions/components/caret-swap/Geist-Latin.woff2",
|
||||
"type": "hyperframes:asset"
|
||||
},
|
||||
{
|
||||
"path": "Geist-OFL.txt",
|
||||
"target": "compositions/components/caret-swap/Geist-OFL.txt",
|
||||
"type": "hyperframes:asset"
|
||||
}
|
||||
],
|
||||
"variables": [
|
||||
{
|
||||
"id": "from_text",
|
||||
"type": "string",
|
||||
"role": "content",
|
||||
"label": "From text",
|
||||
"description": "The phrase covered by the expanding caret.",
|
||||
"default": "Executing it?"
|
||||
},
|
||||
{
|
||||
"id": "to_text",
|
||||
"type": "string",
|
||||
"role": "content",
|
||||
"label": "To text",
|
||||
"description": "The replacement phrase, typed one character at a time.",
|
||||
"default": "That's where everyone quits"
|
||||
},
|
||||
{
|
||||
"id": "font_size",
|
||||
"type": "number",
|
||||
"role": "style",
|
||||
"label": "Font size",
|
||||
"description": "Maximum size in pixels. Both phrases shrink together to fit the host.",
|
||||
"default": 72,
|
||||
"min": 36,
|
||||
"max": 160,
|
||||
"step": 2,
|
||||
"unit": "px"
|
||||
},
|
||||
{
|
||||
"id": "font_weight",
|
||||
"type": "enum",
|
||||
"role": "style",
|
||||
"label": "Font weight",
|
||||
"description": "Weight used by both phrases. Every option is included in the bundled Geist font.",
|
||||
"default": "400",
|
||||
"options": [
|
||||
{
|
||||
"value": "400",
|
||||
"label": "Regular"
|
||||
},
|
||||
{
|
||||
"value": "500",
|
||||
"label": "Medium"
|
||||
},
|
||||
{
|
||||
"value": "600",
|
||||
"label": "Semibold"
|
||||
},
|
||||
{
|
||||
"value": "700",
|
||||
"label": "Bold"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "color",
|
||||
"type": "color",
|
||||
"role": "style",
|
||||
"label": "Text color",
|
||||
"description": "Color of both phrases.",
|
||||
"default": "#171717"
|
||||
},
|
||||
{
|
||||
"id": "caret_color",
|
||||
"type": "color",
|
||||
"role": "style",
|
||||
"label": "Caret top",
|
||||
"description": "Top color of the caret gradient.",
|
||||
"default": "#FF4DAA"
|
||||
},
|
||||
{
|
||||
"id": "caret_color_end",
|
||||
"type": "color",
|
||||
"role": "style",
|
||||
"label": "Caret bottom",
|
||||
"description": "Bottom color of the caret gradient.",
|
||||
"default": "#8A5CF6"
|
||||
}
|
||||
]
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=1920, height=1080" />
|
||||
<title>Pendulum Swing - Demo</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#demo {
|
||||
position: relative;
|
||||
width: 1920px;
|
||||
height: 1080px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #f7f7f8;
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
}
|
||||
.demo-frame {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
/* ponytail: scale on non-GSAP wrapper — safe, GSAP only animates
|
||||
--hf-swing-angle on the pendulum root below. */
|
||||
transform: scale(1.4);
|
||||
transform-origin: center center;
|
||||
}
|
||||
/* The hook the sign hangs from, drawn above the pivot for context. */
|
||||
.swing-rig {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.swing-hook {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #a1a1aa;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
/* Pinned to the frame bottom, clear of the sign at its widest swing. */
|
||||
.swing-label {
|
||||
position: absolute;
|
||||
bottom: 160px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #71717a;
|
||||
}
|
||||
.hf-catalog-pendulum-swing {
|
||||
display: inline-block;
|
||||
transform-origin: var(--hf-swing-origin, 50% 0%);
|
||||
transform: rotate(
|
||||
calc(var(--hf-swing-angle, 0) * var(--hf-swing-dir, 1) * var(--hf-swing-max, 34deg))
|
||||
);
|
||||
}
|
||||
.hf-catalog-pendulum-swing__body {
|
||||
display: inline-block;
|
||||
padding: 22px 40px;
|
||||
border-radius: 14px;
|
||||
background: var(--hf-swing-bg, #18181b);
|
||||
color: var(--hf-swing-fg, #fafafa);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 54px;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="demo"
|
||||
data-composition-id="pendulum-swing-demo"
|
||||
data-start="0"
|
||||
data-width="1920"
|
||||
data-height="1080"
|
||||
data-duration="5"
|
||||
>
|
||||
<div class="swing-label">Pendulum Swing</div>
|
||||
<div class="demo-frame">
|
||||
<div class="swing-rig">
|
||||
<div class="swing-hook"></div>
|
||||
<div
|
||||
class="hf-catalog-pendulum-swing"
|
||||
data-composition-variables='[
|
||||
{ "id": "pivot", "type": "enum", "role": "layout", "label": "Pivot", "description": "Where the content hangs from. The swing rotates about this point, so the top edge stays pinned.", "default": "top-center", "options": [{ "value": "top-center", "label": "Top center" }, { "value": "top-left", "label": "Top left" }, { "value": "top-right", "label": "Top right" }] },
|
||||
{ "id": "amplitude", "type": "enum", "role": "motion", "label": "Amplitude", "description": "How far the first swing reaches before it settles. Every later swing scales with it, so the pendulum still comes to rest upright.", "default": "standard", "options": [{ "value": "gentle", "label": "Gentle" }, { "value": "standard", "label": "Standard" }, { "value": "wide", "label": "Wide" }] },
|
||||
{ "id": "direction", "type": "enum", "role": "motion", "label": "Direction", "description": "Which side the pendulum swings in from: right lifts the first swing clockwise, left mirrors it.", "default": "right", "options": [{ "value": "right", "label": "Right" }, { "value": "left", "label": "Left" }] },
|
||||
{ "id": "tone", "type": "enum", "role": "style", "label": "Tone", "description": "Content colour: ink for light frames, paper for dark ones, accent rides --brand.", "default": "ink", "options": [{ "value": "ink", "label": "Ink" }, { "value": "paper", "label": "Paper" }, { "value": "accent", "label": "Accent" }] }
|
||||
]'
|
||||
>
|
||||
<span class="hf-catalog-pendulum-swing__body">Hanging Sign</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var vars =
|
||||
window.__hyperframes && window.__hyperframes.getVariables
|
||||
? window.__hyperframes.getVariables()
|
||||
: {};
|
||||
|
||||
// Unrecognised overrides return to their declared defaults.
|
||||
function pick(table, value, fallback) {
|
||||
return Object.prototype.hasOwnProperty.call(table, value) ? value : fallback;
|
||||
}
|
||||
|
||||
var pivots = {
|
||||
"top-center": "50% 0%",
|
||||
"top-left": "0% 0%",
|
||||
"top-right": "100% 0%",
|
||||
};
|
||||
var amplitudes = { gentle: "18deg", standard: "34deg", wide: "52deg" };
|
||||
var directions = { right: 1, left: -1 };
|
||||
// Each tone is a fill/text pair so the sign keeps AA contrast.
|
||||
var tones = {
|
||||
ink: { bg: "#18181b", fg: "#fafafa" },
|
||||
paper: { bg: "#fafafa", fg: "#18181b" },
|
||||
accent: { bg: "var(--brand, #34d399)", fg: "#0b0b0c" },
|
||||
};
|
||||
|
||||
var origin = pivots[pick(pivots, vars.pivot, "top-center")];
|
||||
var maxAngle = amplitudes[pick(amplitudes, vars.amplitude, "standard")];
|
||||
var dir = directions[pick(directions, vars.direction, "right")];
|
||||
var tone = tones[pick(tones, vars.tone, "ink")];
|
||||
|
||||
var roots = document.querySelectorAll(".hf-catalog-pendulum-swing");
|
||||
for (var i = 0; i < roots.length; i += 1) {
|
||||
var root = roots[i];
|
||||
root.style.setProperty("--hf-swing-origin", origin);
|
||||
root.style.setProperty("--hf-swing-max", maxAngle);
|
||||
root.style.setProperty("--hf-swing-dir", String(dir));
|
||||
root.style.setProperty("--hf-swing-bg", tone.bg);
|
||||
root.style.setProperty("--hf-swing-fg", tone.fg);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
const startTime = 0.4;
|
||||
// A pendulum does not snap upright in a single overshoot; it swings
|
||||
// through the rest point several times, each pass smaller, until
|
||||
// friction stills it. This ease is that motion: a cosine (the swings)
|
||||
// inside an exponential decay (the damping). It is normalised so the
|
||||
// tween still lands exactly upright (angle 0) at the end, and rotation
|
||||
// stays sub-pixel under the seek-by-frame capture engine.
|
||||
const swingSettle = (p) => 1 - Math.exp(-2.3 * p) * Math.cos(3.5 * Math.PI * p);
|
||||
tl.fromTo(
|
||||
".hf-catalog-pendulum-swing",
|
||||
{ "--hf-swing-angle": 1 },
|
||||
{
|
||||
"--hf-swing-angle": 0,
|
||||
duration: 2.9,
|
||||
ease: swingSettle,
|
||||
},
|
||||
startTime,
|
||||
);
|
||||
tl.set({}, {}, 5);
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["pendulum-swing-demo"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,130 @@
|
||||
<!--
|
||||
Pendulum Swing - motion primitive for HyperFrames.
|
||||
|
||||
Paste the markup, CSS and script into a composition. Drive the timeline
|
||||
integration from a paused GSAP timeline so renders remain deterministic.
|
||||
|
||||
Variables. The script reads each one, falls back to the declared default on
|
||||
anything missing or unrecognised, and writes the result as a custom property
|
||||
the CSS below consumes. The timeline recipe stays untouched by them: it keeps
|
||||
easing one unitless swing value from 1 down to 0, and these decide where the
|
||||
content hangs from, how far the first swing reaches, which way it enters and
|
||||
what colour it is.
|
||||
|
||||
- pivot (top-center | top-left | top-right, default top-center): where the
|
||||
content hangs from. The swing rotates about this point, so the pinned top
|
||||
edge never drifts.
|
||||
- amplitude (gentle | standard | wide, default standard): how far the first
|
||||
swing reaches, 18deg, 34deg or 52deg. Every later swing scales with it, so
|
||||
whatever the reach the pendulum still comes to rest upright.
|
||||
- direction (right | left, default right): which side the pendulum swings in
|
||||
from. right lifts the first swing clockwise, left mirrors it.
|
||||
- tone (ink | paper | accent, default ink): content colour. ink is
|
||||
near-black for light frames, paper near-white for dark ones, accent rides
|
||||
--brand.
|
||||
|
||||
On every default the result is identical to the original: a near-black sign
|
||||
hanging from its top centre, entering from the right through a 34deg swing.
|
||||
|
||||
Motion note. The swing is a damped oscillation about the pivot: the content
|
||||
passes through upright several times, each pass smaller, until it settles.
|
||||
It rides a single unitless custom property (--hf-swing-angle) that the recipe
|
||||
eases 1 -> 0, so rotation interpolates sub-pixel and stays smooth under the
|
||||
seek-by-frame capture engine; nothing here animates a layout or reflow
|
||||
property.
|
||||
-->
|
||||
|
||||
<div
|
||||
class="hf-catalog-pendulum-swing"
|
||||
data-composition-variables='[
|
||||
{ "id": "pivot", "type": "enum", "role": "layout", "label": "Pivot", "description": "Where the content hangs from. The swing rotates about this point, so the top edge stays pinned.", "default": "top-center", "options": [{ "value": "top-center", "label": "Top center" }, { "value": "top-left", "label": "Top left" }, { "value": "top-right", "label": "Top right" }] },
|
||||
{ "id": "amplitude", "type": "enum", "role": "motion", "label": "Amplitude", "description": "How far the first swing reaches before it settles. Every later swing scales with it, so the pendulum still comes to rest upright.", "default": "standard", "options": [{ "value": "gentle", "label": "Gentle" }, { "value": "standard", "label": "Standard" }, { "value": "wide", "label": "Wide" }] },
|
||||
{ "id": "direction", "type": "enum", "role": "motion", "label": "Direction", "description": "Which side the pendulum swings in from: right lifts the first swing clockwise, left mirrors it.", "default": "right", "options": [{ "value": "right", "label": "Right" }, { "value": "left", "label": "Left" }] },
|
||||
{ "id": "tone", "type": "enum", "role": "style", "label": "Tone", "description": "Content colour: ink for light frames, paper for dark ones, accent rides --brand.", "default": "ink", "options": [{ "value": "ink", "label": "Ink" }, { "value": "paper", "label": "Paper" }, { "value": "accent", "label": "Accent" }] }
|
||||
]'
|
||||
>
|
||||
<span class="hf-catalog-pendulum-swing__body">Hanging Sign</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hf-catalog-pendulum-swing {
|
||||
display: inline-block;
|
||||
/* The pendulum swings about the hanging pivot. The angle stays a unitless
|
||||
factor so the timeline can ease it 1 -> 0 (a damped oscillation through
|
||||
upright) while amplitude and direction, set once from the variables,
|
||||
decide the reach and the side. */
|
||||
transform-origin: var(--hf-swing-origin, 50% 0%);
|
||||
transform: rotate(
|
||||
calc(var(--hf-swing-angle, 0) * var(--hf-swing-dir, 1) * var(--hf-swing-max, 34deg))
|
||||
);
|
||||
}
|
||||
.hf-catalog-pendulum-swing__body {
|
||||
display: inline-block;
|
||||
padding: 22px 40px;
|
||||
border-radius: 14px;
|
||||
/* Tone sets a background/foreground pair so the sign stays legible whatever
|
||||
the frame: the fill is the tone colour, the text its paired contrast. */
|
||||
background: var(--hf-swing-bg, #18181b);
|
||||
color: var(--hf-swing-fg, #fafafa);
|
||||
font-family: Inter, system-ui, sans-serif;
|
||||
font-weight: 800;
|
||||
font-size: 54px;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var vars =
|
||||
window.__hyperframes && window.__hyperframes.getVariables
|
||||
? window.__hyperframes.getVariables()
|
||||
: {};
|
||||
|
||||
// Unrecognised overrides return to their declared defaults.
|
||||
function pick(table, value, fallback) {
|
||||
return Object.prototype.hasOwnProperty.call(table, value) ? value : fallback;
|
||||
}
|
||||
|
||||
var pivots = {
|
||||
"top-center": "50% 0%",
|
||||
"top-left": "0% 0%",
|
||||
"top-right": "100% 0%",
|
||||
};
|
||||
var amplitudes = { gentle: "18deg", standard: "34deg", wide: "52deg" };
|
||||
var directions = { right: 1, left: -1 };
|
||||
// Each tone is a fill/text pair so the sign keeps AA contrast on any frame.
|
||||
var tones = {
|
||||
ink: { bg: "#18181b", fg: "#fafafa" },
|
||||
paper: { bg: "#fafafa", fg: "#18181b" },
|
||||
accent: { bg: "var(--brand, #34d399)", fg: "#0b0b0c" },
|
||||
};
|
||||
|
||||
var origin = pivots[pick(pivots, vars.pivot, "top-center")];
|
||||
var maxAngle = amplitudes[pick(amplitudes, vars.amplitude, "standard")];
|
||||
var dir = directions[pick(directions, vars.direction, "right")];
|
||||
var tone = tones[pick(tones, vars.tone, "ink")];
|
||||
|
||||
var roots = document.querySelectorAll(".hf-catalog-pendulum-swing");
|
||||
for (var i = 0; i < roots.length; i += 1) {
|
||||
var root = roots[i];
|
||||
root.style.setProperty("--hf-swing-origin", origin);
|
||||
root.style.setProperty("--hf-swing-max", maxAngle);
|
||||
root.style.setProperty("--hf-swing-dir", String(dir));
|
||||
root.style.setProperty("--hf-swing-bg", tone.bg);
|
||||
root.style.setProperty("--hf-swing-fg", tone.fg);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Timeline integration. The swing settles like a real pendulum: it passes
|
||||
through upright several times, each pass smaller, until it comes to rest. That
|
||||
is a cosine (the swings) inside an exponential decay (the damping), normalised
|
||||
so the tween still lands exactly upright at the end.
|
||||
|
||||
const swingSettle = (p) => 1 - Math.exp(-2.3 * p) * Math.cos(3.5 * Math.PI * p);
|
||||
tl.fromTo('.hf-catalog-pendulum-swing', { '--hf-swing-angle': 1 }, { '--hf-swing-angle': 0, duration: 2.9, ease: swingSettle }, startTime);
|
||||
-->
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "pendulum-swing",
|
||||
"type": "hyperframes:component",
|
||||
"title": "Pendulum Swing",
|
||||
"description": "Content hangs from a fixed pivot and swings into rest with gravity-damped oscillation, like a hanging sign settling on its hook.",
|
||||
"tags": ["motion-primitive", "physics", "swing", "settle", "reveal"],
|
||||
"files": [
|
||||
{
|
||||
"path": "pendulum-swing.html",
|
||||
"target": "compositions/components/pendulum-swing.html",
|
||||
"type": "hyperframes:snippet"
|
||||
}
|
||||
],
|
||||
"variables": [
|
||||
{
|
||||
"id": "pivot",
|
||||
"type": "enum",
|
||||
"role": "layout",
|
||||
"label": "Pivot",
|
||||
"description": "Where the content hangs from. The swing rotates about this point, so the top edge stays pinned.",
|
||||
"default": "top-center",
|
||||
"options": [
|
||||
{ "value": "top-center", "label": "Top center" },
|
||||
{ "value": "top-left", "label": "Top left" },
|
||||
{ "value": "top-right", "label": "Top right" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "amplitude",
|
||||
"type": "enum",
|
||||
"role": "motion",
|
||||
"label": "Amplitude",
|
||||
"description": "How far the first swing reaches before it settles. Every later swing scales with it, so the pendulum still comes to rest upright.",
|
||||
"default": "standard",
|
||||
"options": [
|
||||
{ "value": "gentle", "label": "Gentle" },
|
||||
{ "value": "standard", "label": "Standard" },
|
||||
{ "value": "wide", "label": "Wide" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "direction",
|
||||
"type": "enum",
|
||||
"role": "motion",
|
||||
"label": "Direction",
|
||||
"description": "Which side the pendulum swings in from: right lifts the first swing clockwise, left mirrors it.",
|
||||
"default": "right",
|
||||
"options": [
|
||||
{ "value": "right", "label": "Right" },
|
||||
{ "value": "left", "label": "Left" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tone",
|
||||
"type": "enum",
|
||||
"role": "style",
|
||||
"label": "Tone",
|
||||
"description": "Content colour: ink for light frames, paper for dark ones, accent rides --brand.",
|
||||
"default": "ink",
|
||||
"options": [
|
||||
{ "value": "ink", "label": "Ink" },
|
||||
{ "value": "paper", "label": "Paper" },
|
||||
{ "value": "accent", "label": "Accent" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"preview": {
|
||||
"poster": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/pendulum-swing.png"
|
||||
}
|
||||
}
|
||||
@@ -72,17 +72,14 @@ export function snippetOwnsItsMotion(snippetHtml: string): boolean {
|
||||
return live.includes("__timelines") && live.includes("gsap.timeline");
|
||||
}
|
||||
|
||||
/**
|
||||
* Components that register a timeline but still render a still frame when the
|
||||
* preview is built from their snippet.
|
||||
*
|
||||
* Both were measured, not guessed: their previews moved before this change and
|
||||
* were static after, while every other self-contained component kept moving.
|
||||
* The cause is in the pieces themselves rather than in the rule, so they keep
|
||||
* the preview they had. That leaves their panel inert, which is the state they
|
||||
* were already in, rather than trading an inert panel for a frozen preview.
|
||||
*/
|
||||
export const SNIPPET_PREVIEW_RENDERS_STILL = new Set(["ascii-render-pass", "star-rating-fill"]);
|
||||
/** 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[] {
|
||||
|
||||
@@ -37,10 +37,7 @@ import {
|
||||
import { isLocalAsset } from "./registry-hosted-assets.ts";
|
||||
import { componentFiles } from "./catalog/component-files.ts";
|
||||
import { runAsCommand } from "./entrypoint.ts";
|
||||
import {
|
||||
snippetOwnsItsMotion,
|
||||
SNIPPET_PREVIEW_RENDERS_STILL,
|
||||
} from "./catalog/component-variables.ts";
|
||||
import { snippetOwnsItsMotion, PREFER_AUTHORED_DEMO } from "./catalog/component-variables.ts";
|
||||
import {
|
||||
clearPinnedVariableValues,
|
||||
externalizeDataUris,
|
||||
@@ -164,8 +161,7 @@ function snippetFileFor(item: CatalogItem): string | null {
|
||||
|
||||
function buildsFromSnippet(item: CatalogItem, snippetFile: string): boolean {
|
||||
return (
|
||||
snippetOwnsItsMotion(readFileSync(snippetFile, "utf-8")) &&
|
||||
!SNIPPET_PREVIEW_RENDERS_STILL.has(item.name)
|
||||
snippetOwnsItsMotion(readFileSync(snippetFile, "utf-8")) && !PREFER_AUTHORED_DEMO.has(item.name)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user