mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Restores the 208 catalog items reverted after their previews 404'd in production, this time on the payload mechanism rather than the .html files that caused the outage. The generator no longer writes a preview document to docs/public. That writer, and the machinery under it, existed only to produce files the docs host discards, so it is gone rather than bypassed. Items now embed the composition itself via a payload, which is what the previous change already does for the items that were already in the catalog. The variables explorer is parked, not restored: it drove its preview through the same unpublished .html path, so it would have shown an empty frame. Items that declare variables get the live player plus the static variables table, and reconnecting the explorer to payloads is a follow-up.
496 lines
20 KiB
HTML
Vendored
496 lines
20 KiB
HTML
Vendored
<!doctype html>
|
|
<!--
|
|
testimonial-proof-card: HyperFrames video primitive (proof-stats / testimonial)
|
|
|
|
A quote card: the quote reveals per measured line through a soft overflow
|
|
mask, an avatar disc (initials by default, an image via the avatar slot),
|
|
name and role in mono, an optional company mark as wide-tracked text; one
|
|
emphasis substring gets an accent underline drawn hand-style after the
|
|
quote lands (getTotalLength dash, the marker-highlight law). Dead-still
|
|
hold: nothing breathes, nothing loops.
|
|
|
|
Variables:
|
|
quote (string): the testimonial line. Wraps inside the card; each
|
|
rendered line reveals through its own mask.
|
|
name (string): attribution name (mono). Also seeds the avatar initials.
|
|
role (string): attribution role (mono, muted). Empty hides the line.
|
|
company (string): company mark as wide-tracked mono text at the byline
|
|
end. Empty hides it.
|
|
emphasis (string): first case-insensitive substring match inside quote,
|
|
expanded to whole words, gets the accent underline. Empty or unmatched
|
|
disables the draw.
|
|
accent (green | blue | violet): underline and glyph ink via the contract
|
|
token map (green --brand, blue --accent, violet --accent-2).
|
|
exit (none | fade | up, default none): frame roots own transitions;
|
|
holds end films.
|
|
|
|
Envelope, fixed IN and OUT with elastic HOLD only (never time-scaled):
|
|
CARD_BASE = 0.50s card surface settles up
|
|
LINE_BASE = 0.60s per-line rise through the mask, 0.18s stagger
|
|
BYLINE = 0.50s avatar + attribution fade up, overlapping quote end
|
|
DRAW_BASE = 0.55s underline draws after the last line lands
|
|
HOLD = max(0, D - phases), completely still
|
|
OUT_BASE = 0s for exit none, otherwise 0.45s
|
|
If D is shorter than the fixed phases, every phase compresses together.
|
|
|
|
Slot (before-after-wipe convention): the avatar disc is a named slot,
|
|
<div class="tq-avatar" data-slot="avatar">. Its default child is the
|
|
initials span; replace the slot's children in your installed copy with an
|
|
<img> to show a photo (sized to cover the disc automatically).
|
|
|
|
Mount contract: the runtime clones only this template. #root fills the
|
|
host box, establishes the container query basis, has no data-width or
|
|
data-height, and registers one paused timeline under the literal
|
|
testimonial-proof-card key.
|
|
-->
|
|
<html
|
|
lang="en"
|
|
data-composition-id="testimonial-proof-card"
|
|
data-composition-duration="4.5"
|
|
data-composition-variables='[
|
|
{ "id": "quote", "type": "string", "role": "content", "label": "Quote", "description": "The testimonial line. Wraps inside the card; each rendered line reveals through its own mask.", "default": "We shipped our launch video in a single afternoon" },
|
|
{ "id": "name", "type": "string", "role": "content", "label": "Name", "description": "Attribution name in mono. Also seeds the avatar initials.", "default": "Maya Chen" },
|
|
{ "id": "role", "type": "string", "role": "content", "label": "Role", "description": "Attribution role in muted mono. Empty hides the line.", "default": "Head of Product" },
|
|
{ "id": "company", "type": "string", "role": "content", "label": "Company", "description": "Company mark as wide-tracked mono text at the byline end. Empty hides it.", "default": "NORTHWIND" },
|
|
{ "id": "emphasis", "type": "string", "role": "content", "label": "Emphasis", "description": "First case-insensitive substring match in quote, expanded to whole words, gets the accent underline. Empty disables the draw.", "default": "single afternoon" },
|
|
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Underline and glyph ink from the contract token map.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
|
|
{ "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "Optional departure. Default none: the card holds until the frame cuts.", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Up" }] }
|
|
]'
|
|
>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Testimonial Proof Card</title>
|
|
</head>
|
|
<body>
|
|
<template>
|
|
<div id="root" data-composition-id="testimonial-proof-card" data-duration="4.5" data-fps="30">
|
|
<style>
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
#root {
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
container-type: size;
|
|
isolation: isolate;
|
|
display: grid;
|
|
place-items: center;
|
|
background: var(--bg, #0b0c0e);
|
|
color: var(--fg, #f8fafc);
|
|
font-family: var(--font-display, Inter, system-ui, sans-serif);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.tq-clip {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tq-card {
|
|
width: min(76cqw, 150cqh);
|
|
padding: 6cqmin 7cqmin;
|
|
border-radius: var(--radius, 24px);
|
|
border: 1px solid color-mix(in srgb, var(--border, #343a46) 60%, transparent);
|
|
background: color-mix(in srgb, var(--surface, #14171c) 88%, transparent);
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
.tq-glyph {
|
|
font-size: 9cqmin;
|
|
line-height: 0.6;
|
|
font-weight: 600;
|
|
color: var(--tq-accent, var(--brand, #71f5a7));
|
|
margin-bottom: 2.4cqmin;
|
|
}
|
|
|
|
.tq-quote {
|
|
margin: 0;
|
|
font-size: var(--tq-font-size, 5cqmin);
|
|
font-weight: 600;
|
|
line-height: 1.28;
|
|
letter-spacing: -0.02em;
|
|
text-wrap: balance;
|
|
}
|
|
|
|
/* Soft per-line mask. Negative margins cancel the padding so the
|
|
expanded clip box (room for descenders and the underline ink
|
|
below the baseline) never shifts layout. */
|
|
.tq-line-mask {
|
|
overflow: hidden;
|
|
padding: 0.12em 0.24em 0.3em;
|
|
margin: -0.12em -0.24em -0.3em;
|
|
}
|
|
|
|
.tq-line {
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
.tq-em {
|
|
position: relative;
|
|
display: inline-block;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tq-em-text {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Underline box below the emphasized words; explicit width/height
|
|
(svg is replaced), preserveAspectRatio="none" stretches the
|
|
authored path so the stroke keeps its hand-swiped nib weight. */
|
|
.tq-mark {
|
|
position: absolute;
|
|
left: -2%;
|
|
bottom: -0.14em;
|
|
width: 104%;
|
|
height: 0.24em;
|
|
overflow: visible;
|
|
z-index: 2;
|
|
}
|
|
|
|
.tq-byline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 3cqmin;
|
|
margin-top: 5cqmin;
|
|
will-change: transform, opacity;
|
|
}
|
|
|
|
.tq-avatar {
|
|
width: 10cqmin;
|
|
height: 10cqmin;
|
|
flex: none;
|
|
border-radius: 50%;
|
|
overflow: hidden;
|
|
display: grid;
|
|
place-items: center;
|
|
border: 1px solid color-mix(in srgb, var(--border, #343a46) 70%, transparent);
|
|
background: color-mix(in srgb, var(--tq-accent, #71f5a7) 16%, var(--surface, #14171c));
|
|
}
|
|
|
|
.tq-avatar img,
|
|
.tq-avatar video {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.tq-initials {
|
|
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
|
|
font-size: 3.4cqmin;
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
color: var(--fg, #f8fafc);
|
|
}
|
|
|
|
.tq-person {
|
|
display: grid;
|
|
gap: 0.8cqmin;
|
|
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
|
|
}
|
|
|
|
.tq-name {
|
|
font-size: 2.9cqmin;
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
|
|
.tq-role {
|
|
font-size: 2.4cqmin;
|
|
color: var(--muted, #94a3b8);
|
|
}
|
|
|
|
.tq-company {
|
|
margin-left: auto;
|
|
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, Consolas, monospace);
|
|
font-size: 2.3cqmin;
|
|
letter-spacing: 0.3em;
|
|
text-transform: uppercase;
|
|
color: var(--muted, #94a3b8);
|
|
}
|
|
</style>
|
|
|
|
<div
|
|
id="testimonial-proof-card-clip"
|
|
class="tq-clip clip"
|
|
data-start="0"
|
|
data-duration="4.5"
|
|
data-track-index="0"
|
|
>
|
|
<figure class="tq-card" style="margin: 0">
|
|
<div class="tq-glyph" aria-hidden="true">“</div>
|
|
<blockquote class="tq-quote" style="margin: 0"></blockquote>
|
|
<figcaption class="tq-byline">
|
|
<div class="tq-avatar" data-slot="avatar">
|
|
<span class="tq-initials"></span>
|
|
</div>
|
|
<div class="tq-person">
|
|
<span class="tq-name"></span>
|
|
<span class="tq-role"></span>
|
|
</div>
|
|
<span class="tq-company"></span>
|
|
</figcaption>
|
|
</figure>
|
|
</div>
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
var root = document.getElementById("root");
|
|
// Literal key: the flattening step strips data-composition-id off
|
|
// the mounted root, so reading it back would register "null".
|
|
var compositionId = "testimonial-proof-card";
|
|
var card = root.querySelector(".tq-card");
|
|
var quoteEl = root.querySelector(".tq-quote");
|
|
var bylineEl = root.querySelector(".tq-byline");
|
|
var vars =
|
|
window.__hyperframes && window.__hyperframes.getVariables
|
|
? window.__hyperframes.getVariables()
|
|
: {};
|
|
|
|
function str(value, fallback) {
|
|
return (value == null ? fallback : String(value)).trim().replace(/\s+/g, " ");
|
|
}
|
|
var quote = str(vars.quote, "We shipped our launch video in a single afternoon");
|
|
var name = str(vars.name, "Maya Chen");
|
|
var role = str(vars.role, "Head of Product");
|
|
var company = str(vars.company, "NORTHWIND");
|
|
var emphasis = str(vars.emphasis, "single afternoon");
|
|
|
|
var accentColors = {
|
|
green: "var(--brand, #71f5a7)",
|
|
blue: "var(--accent, #61a8ff)",
|
|
violet: "var(--accent-2, #c5a3ff)",
|
|
};
|
|
var accent = Object.prototype.hasOwnProperty.call(accentColors, vars.accent)
|
|
? vars.accent
|
|
: "green";
|
|
root.style.setProperty("--tq-accent", accentColors[accent]);
|
|
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
|
|
|
|
// Byline content. Empty strings hide their element.
|
|
root.querySelector(".tq-name").textContent = name;
|
|
var roleEl = root.querySelector(".tq-role");
|
|
if (role) roleEl.textContent = role;
|
|
else roleEl.setAttribute("hidden", "");
|
|
var companyEl = root.querySelector(".tq-company");
|
|
if (company) companyEl.textContent = company;
|
|
else companyEl.setAttribute("hidden", "");
|
|
|
|
// Avatar initials: only when the default initials span is still
|
|
// in the slot (a user-supplied img replaces the slot children).
|
|
var initialsEl = root.querySelector(".tq-avatar .tq-initials");
|
|
if (initialsEl) {
|
|
var parts = name.split(" ").filter(Boolean);
|
|
var initials = parts
|
|
.slice(0, 2)
|
|
.map(function (part) {
|
|
return Array.from(part)[0].toUpperCase();
|
|
})
|
|
.join("");
|
|
initialsEl.textContent = initials;
|
|
}
|
|
|
|
// Emphasis match expanded to whole words so the quote tokenizes
|
|
// cleanly into word spans (the underline never splits a word).
|
|
var matchStart = -1;
|
|
var matchEnd = -1;
|
|
if (emphasis.length > 0) {
|
|
var index = quote.toLowerCase().indexOf(emphasis.toLowerCase());
|
|
if (index >= 0) {
|
|
matchStart = quote.lastIndexOf(" ", index) + 1;
|
|
matchEnd = quote.indexOf(" ", index + emphasis.length);
|
|
if (matchEnd < 0) matchEnd = quote.length;
|
|
}
|
|
}
|
|
|
|
// Deterministic fit: font size rides 1/sqrt(chars) so the quote
|
|
// lands around two to three lines at any card width.
|
|
var characters = Math.max(1, Array.from(quote).length);
|
|
var fitted = Math.max(3.8, Math.min(7.2, 46 / Math.sqrt(characters)));
|
|
root.style.setProperty("--tq-font-size", fitted.toFixed(3) + "cqmin");
|
|
|
|
// Tokenize into inline-block word spans (emphasis is one nowrap
|
|
// token), measure wrapped lines, then rebuild one mask per line.
|
|
var tokens = [];
|
|
function pushWords(text) {
|
|
text.split(" ").forEach(function (wordText) {
|
|
if (!wordText) return;
|
|
var span = document.createElement("span");
|
|
span.style.display = "inline-block";
|
|
span.textContent = wordText;
|
|
tokens.push(span);
|
|
});
|
|
}
|
|
var pathEl = null;
|
|
var emEl = null;
|
|
if (matchStart >= 0) {
|
|
pushWords(quote.slice(0, matchStart));
|
|
emEl = document.createElement("span");
|
|
emEl.className = "tq-em";
|
|
var svgNS = "http://www.w3.org/2000/svg";
|
|
var svg = document.createElementNS(svgNS, "svg");
|
|
svg.setAttribute("class", "tq-mark");
|
|
svg.setAttribute("viewBox", "0 0 100 12");
|
|
svg.setAttribute("preserveAspectRatio", "none");
|
|
svg.setAttribute("aria-hidden", "true");
|
|
pathEl = document.createElementNS(svgNS, "path");
|
|
// Authored wobble, marker-highlight underline law: dash reveal
|
|
// from getTotalLength, never pathLength, never
|
|
// non-scaling-stroke; the viewBox stretch is the nib feel.
|
|
pathEl.setAttribute("d", "M 2 7 C 20 4.5, 46 8.5, 66 6 C 80 4.5, 92 7.5, 98 5.5");
|
|
pathEl.setAttribute("fill", "none");
|
|
pathEl.setAttribute("stroke-width", "4.5");
|
|
pathEl.setAttribute("stroke-linecap", "round");
|
|
pathEl.setAttribute("stroke-linejoin", "round");
|
|
// style.stroke, not the presentation attribute: var() only
|
|
// resolves as a CSS value.
|
|
pathEl.style.stroke = accentColors[accent];
|
|
svg.appendChild(pathEl);
|
|
var emText = document.createElement("span");
|
|
emText.className = "tq-em-text";
|
|
emText.textContent = quote.slice(matchStart, matchEnd);
|
|
emEl.appendChild(svg);
|
|
emEl.appendChild(emText);
|
|
tokens.push(emEl);
|
|
pushWords(quote.slice(matchEnd));
|
|
} else {
|
|
pushWords(quote);
|
|
}
|
|
tokens.forEach(function (token, i) {
|
|
if (i > 0) quoteEl.appendChild(document.createTextNode(" "));
|
|
quoteEl.appendChild(token);
|
|
});
|
|
quoteEl.setAttribute("aria-label", quote);
|
|
|
|
// Group tokens by rendered row, then rebuild per-line masks.
|
|
var rows = [];
|
|
var lastTop = null;
|
|
tokens.forEach(function (token) {
|
|
var top = token.offsetTop;
|
|
if (lastTop === null || Math.abs(top - lastTop) > 2) {
|
|
rows.push([]);
|
|
lastTop = top;
|
|
}
|
|
rows[rows.length - 1].push(token);
|
|
});
|
|
quoteEl.textContent = "";
|
|
var lineEls = rows.map(function (row) {
|
|
var mask = document.createElement("div");
|
|
mask.className = "tq-line-mask";
|
|
var line = document.createElement("div");
|
|
line.className = "tq-line";
|
|
row.forEach(function (token, i) {
|
|
if (i > 0) line.appendChild(document.createTextNode(" "));
|
|
line.appendChild(token);
|
|
});
|
|
mask.appendChild(line);
|
|
quoteEl.appendChild(mask);
|
|
return line;
|
|
});
|
|
|
|
// RETIME RANGE: fixed phases; HOLD is the only elastic span.
|
|
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4.5"));
|
|
var CARD_BASE = 0.5;
|
|
var LINE_BASE = 0.6;
|
|
var STAG_BASE = 0.18;
|
|
var LINES_AT_BASE = 0.15;
|
|
var BYLINE_BASE = 0.5;
|
|
var DRAW_BASE = 0.55;
|
|
var OUT_BASE = exit === "none" ? 0 : 0.45;
|
|
var quoteEndBase =
|
|
LINES_AT_BASE + Math.max(0, lineEls.length - 1) * STAG_BASE + LINE_BASE;
|
|
var bylineAtBase = Math.max(0, quoteEndBase - 0.15);
|
|
var drawAtBase = quoteEndBase + 0.1;
|
|
var inEndBase = Math.max(
|
|
CARD_BASE,
|
|
bylineAtBase + BYLINE_BASE,
|
|
pathEl ? drawAtBase + DRAW_BASE : 0,
|
|
);
|
|
var s = duration < inEndBase + OUT_BASE ? duration / (inEndBase + OUT_BASE) : 1;
|
|
var OUT = OUT_BASE * s;
|
|
var OUT_START = duration - OUT;
|
|
|
|
gsap.set(card, { opacity: 0, y: "4cqh" });
|
|
gsap.set(lineEls, { yPercent: 130, opacity: 0 });
|
|
gsap.set(bylineEl, { opacity: 0, y: "2.5cqh" });
|
|
|
|
var tl = gsap.timeline({ paused: true });
|
|
|
|
// IN: the card surface settles up.
|
|
tl.fromTo(
|
|
card,
|
|
{ opacity: 0, y: "4cqh" },
|
|
{ opacity: 1, y: "0cqh", duration: CARD_BASE * s, ease: "power3.out" },
|
|
0,
|
|
);
|
|
|
|
// Quote lines rise through their soft masks, top to bottom.
|
|
lineEls.forEach(function (line, i) {
|
|
tl.fromTo(
|
|
line,
|
|
{ yPercent: 130, opacity: 0 },
|
|
{
|
|
yPercent: 0,
|
|
opacity: 1,
|
|
duration: LINE_BASE * s,
|
|
ease: "power3.out",
|
|
},
|
|
(LINES_AT_BASE + i * STAG_BASE) * s,
|
|
);
|
|
});
|
|
|
|
// Byline fades up, overlapping the last line's landing.
|
|
tl.fromTo(
|
|
bylineEl,
|
|
{ opacity: 0, y: "2.5cqh" },
|
|
{ opacity: 1, y: "0cqh", duration: BYLINE_BASE * s, ease: "power3.out" },
|
|
bylineAtBase * s,
|
|
);
|
|
|
|
// DRAW: the underline after the quote lands; both dash endpoints
|
|
// explicit so any seek is correct without playing through. At
|
|
// dashoffset == length the round cap still paints a dot at the
|
|
// path start, so the ink is opacity 0 until the exact cue frame.
|
|
if (pathEl) {
|
|
var length = pathEl.getTotalLength();
|
|
pathEl.style.strokeDasharray = String(length);
|
|
gsap.set(pathEl, { strokeDashoffset: length, opacity: 0 });
|
|
tl.set(pathEl, { opacity: 1 }, drawAtBase * s);
|
|
tl.fromTo(
|
|
pathEl,
|
|
{ strokeDashoffset: length },
|
|
{ strokeDashoffset: 0, duration: DRAW_BASE * s, ease: "power2.inOut" },
|
|
drawAtBase * s,
|
|
);
|
|
}
|
|
|
|
// HOLD: dead still.
|
|
|
|
// OUT: optional departure; exit none holds until the frame cuts.
|
|
if (exit === "fade") {
|
|
tl.to(card, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_START);
|
|
} else if (exit === "up") {
|
|
tl.to(card, { opacity: 0, y: "-6cqh", duration: OUT, ease: "power2.in" }, OUT_START);
|
|
}
|
|
|
|
tl.seek(0);
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines[compositionId] = tl;
|
|
})();
|
|
</script>
|
|
</div>
|
|
</template>
|
|
</body>
|
|
</html>
|