mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 01:56:04 +00:00
This reverts commit 3b53bfd2f7.
This commit is contained in:
@@ -1,495 +0,0 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user