Files
hyperframes/registry/components/press-ripple/press-ripple.html
Miguel Ángel 9734578e60 feat(registry): bring back the video-primitive moves (#3169)
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.
2026-08-10 18:46:03 -04:00

374 lines
15 KiB
HTML
Vendored

<!doctype html>
<!--
press-ripple: HyperFrames video primitive (product demo / payoff)
A cursor decel-arrives from off-stage (power2.out, it arrives, never passes
through), lands a few px off-center on a caller-positioned target zone, the
target and cursor compress in lockstep, release with two ink ripple rings,
then the cursor exits off-stage the way it came. The pressed state and the
ripple aftermath carry the hold; the cursor never camps on the label.
The target is a slot: the element marked data-slot="target" is the press
surface. Its default content is a token-styled pill carrying the label
variable; replace the slot's children to press anything else. The slot
wrapper flips data-state="idle" to data-state="pressed" at the press, so
custom slot content can style its own pressed look.
Variables:
label: text inside the default pill (ignored when the slot is replaced)
target_x, target_y: zone center as a percent of the host box
press_at: seconds from mount start when the compression begins
cursor: light or dark pointer
accent: green, blue, or violet ink (ripple rings + pressed pill fill)
exit: none, fade, or up (default none; holds end films)
Envelope (elastic HOLD, never time-scaled):
IN = entrance settle, cursor arrival, press, release ripple, cursor exit
HOLD = the rest of D, completely still on the pressed state
OUT = none by default; 0.45s fade or fade-up when exit is set
press_at clamps into the window that keeps the whole gesture inside D.
Sync point: the press lands at press_at (default 1.4s).
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 press-ripple key.
-->
<html
lang="en"
data-composition-id="press-ripple"
data-composition-duration="3"
data-composition-variables='[
{ "id": "label", "type": "string", "role": "content", "label": "Label", "description": "Text inside the default pill target.", "default": "Get started" },
{ "id": "target_x", "type": "number", "role": "layout", "label": "Target X", "description": "Zone center, percent of host width.", "default": 50, "min": 8, "max": 92, "step": 1, "unit": "%" },
{ "id": "target_y", "type": "number", "role": "layout", "label": "Target Y", "description": "Zone center, percent of host height.", "default": 50, "min": 8, "max": 92, "step": 1, "unit": "%" },
{ "id": "press_at", "type": "number", "role": "timing", "label": "Press at", "description": "Seconds from mount start when the press begins.", "default": 1.4, "min": 0.9, "max": 10, "step": 0.1, "unit": "s" },
{ "id": "cursor", "type": "enum", "role": "style", "label": "Cursor", "description": "Pointer tone.", "default": "light", "options": [{ "value": "light", "label": "Light" }, { "value": "dark", "label": "Dark" }] },
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Contract color for the ripple ink and pressed fill.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
{ "id": "exit", "type": "enum", "role": "style", "label": "Exit", "description": "How the frame leaves. None holds the pressed state.", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Fade up" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Press Ripple</title>
</head>
<body>
<template>
<div id="root" data-composition-id="press-ripple" data-duration="3" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
color: var(--fg, #f8fafc);
font-family: var(--font-body, Inter, system-ui, sans-serif);
pointer-events: none;
}
.pr-clip {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--bg, #0b0c0e);
}
.pr-stage {
position: absolute;
inset: 0;
}
/* Caller-positioned target zone; --pr-x / --pr-y come from the
target_x / target_y variables. */
.pr-zone {
position: absolute;
left: calc(var(--pr-x, 50) * 1cqw);
top: calc(var(--pr-y, 50) * 1cqh);
transform: translate(-50%, -50%);
}
/* Ink ripple rings, centered under the slot, hidden until release.
The ink mixes the accent toward --fg so the rings carry real
weight against the host background on both light and dark
themes; thickness reads as drawn ink, not a hairline. */
.pr-ring {
position: absolute;
left: 50%;
top: 50%;
width: 20cqmin;
height: 20cqmin;
margin: -10cqmin 0 0 -10cqmin;
border: 0.85cqmin solid
color-mix(in srgb, var(--pr-accent, #71f5a7) 72%, var(--fg, #f8fafc));
border-radius: 50%;
opacity: 0;
will-change: transform, opacity;
}
/* GSAP owns this wrapper's opacity/y (entrance) and scale (press). */
.pr-actor {
position: relative;
will-change: transform, opacity;
}
.pr-pill {
display: inline-flex;
align-items: center;
padding: var(--space-2, 2.6cqmin) calc(var(--space-2, 2.6cqmin) * 2.1);
border: 0.22cqmin solid var(--border, #343a46);
border-radius: 999px;
background: var(--surface, #14171c);
color: var(--fg, #f8fafc);
font-family: var(--font-body, Inter, system-ui, sans-serif);
font-size: 4.6cqmin;
font-weight: 600;
line-height: 1;
letter-spacing: -0.015em;
white-space: nowrap;
}
.pr-target-slot[data-state="pressed"] .pr-pill {
border-color: color-mix(in srgb, var(--pr-accent, #71f5a7) 72%, var(--fg, #f8fafc));
background: var(--pr-accent, #71f5a7);
color: var(--bg, #0b0c0e);
}
/* Pointer: SVG tip sits exactly at this box's top-left corner, so
the box position places the tip itself. GSAP moves it with x/y
transforms only (never left/top: layout props snap to integer
device pixels under the seek-by-frame capture engine) and scales
it about the tip (press). */
.pr-cursor {
position: absolute;
left: 0;
top: 0;
width: 7cqmin;
height: 7cqmin;
transform-origin: 0 0;
filter: drop-shadow(0 0.4cqmin 0.9cqmin rgba(2, 6, 23, 0.45));
will-change: transform, opacity;
}
.pr-cursor svg {
display: block;
width: 100%;
height: 100%;
}
</style>
<div
id="press-ripple-clip"
class="pr-clip clip"
data-start="0"
data-duration="3"
data-track-index="0"
>
<div class="pr-stage">
<div class="pr-zone">
<div class="pr-ring" aria-hidden="true"></div>
<div class="pr-ring" aria-hidden="true"></div>
<div class="pr-actor">
<div class="pr-target-slot" data-slot="target" data-state="idle">
<span class="pr-pill"></span>
</div>
</div>
</div>
<div class="pr-cursor" aria-hidden="true">
<svg viewBox="0 0 20 20">
<path
class="pr-cursor-path"
d="M0.6 0.6 L0.6 14.6 L4.4 11.3 L6.7 16.8 L9.4 15.6 L7.1 10.2 L12.4 9.8 Z"
stroke-width="1.1"
stroke-linejoin="round"
></path>
</svg>
</div>
</div>
</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");
var stage = root.querySelector(".pr-stage");
var zone = root.querySelector(".pr-zone");
var rings = root.querySelectorAll(".pr-ring");
var actor = root.querySelector(".pr-actor");
var slot = root.querySelector(".pr-target-slot");
var pill = root.querySelector(".pr-pill");
var cursor = root.querySelector(".pr-cursor");
var cursorPath = root.querySelector(".pr-cursor-path");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
function num(value, fallback, min, max) {
var n = Number(value);
if (!isFinite(n)) n = fallback;
return Math.min(max, Math.max(min, n));
}
var labelText =
vars.label == null ? "Get started" : String(vars.label).trim().replace(/\s+/g, " ");
var targetX = num(vars.target_x, 50, 8, 92);
var targetY = num(vars.target_y, 50, 8, 92);
var cursorTone = vars.cursor === "dark" ? "dark" : "light";
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
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";
// The bundler mirrors composition variables as scoped CSS custom
// props, so this unit's own accent variable shadows the contract
// --accent token inside the subtree ("blue" is a valid CSS color
// and would render pure blue). When the shadow is present, fall
// back to the literal contract value instead of var(--accent).
var computedAccent = getComputedStyle(root).getPropertyValue("--accent").trim();
if (
computedAccent === "green" ||
computedAccent === "blue" ||
computedAccent === "violet"
) {
accentColors.blue = "#61a8ff";
}
pill.textContent = labelText;
root.style.setProperty("--pr-accent", accentColors[accent]);
root.style.setProperty("--pr-x", String(targetX));
root.style.setProperty("--pr-y", String(targetY));
if (cursorTone === "dark") {
cursorPath.setAttribute("fill", "#111827");
cursorPath.setAttribute("stroke", "#f8fafc");
} else {
cursorPath.setAttribute("fill", "#f8fafc");
cursorPath.setAttribute("stroke", "#0f172a");
}
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "3"));
var OUT = exit === "none" ? 0 : 0.45;
// The whole press tail (compress + release ripple + cursor exit)
// is ~1.05s; keep it inside D, then keep the OUT window after it.
var pressAt = num(vars.press_at, 1.4, 0.9, Math.max(0.9, duration - 1.05 - OUT));
// Human aim: the tip lands a few px off the zone's true center.
var landX = targetX + 0.55;
var landY = targetY + 0.85;
var arriveEnd = pressAt - 0.16;
var arriveStart = Math.max(0, arriveEnd - 0.78);
// Motion runs on transforms only (x/y px), never left/top: layout
// props snap to integer device pixels under the seek-by-frame
// capture engine. Stage size is read once, synchronously, at load
// (deterministic: fixed viewport per render) to convert the
// percent-based land/off-stage positions into px.
var stageW = root.clientWidth || 1920;
var stageH = root.clientHeight || 1080;
function xAt(percent) {
return (percent / 100) * stageW;
}
function yAt(percent) {
return (percent / 100) * stageH;
}
var tl = gsap.timeline({ paused: true });
// IN: target settles in.
tl.fromTo(
actor,
{ opacity: 0, y: "2.6cqh" },
{ opacity: 1, y: 0, duration: 0.5, ease: "power3.out" },
0,
);
// Cursor decel-arrives from off-stage bottom-right. power2.out to
// an exact endpoint: it arrives, it never passes through.
tl.fromTo(
cursor,
{ x: xAt(114), y: yAt(112) },
{
x: xAt(landX),
y: yAt(landY),
duration: arriveEnd - arriveStart,
ease: "power2.out",
},
arriveStart,
);
// Press: target and cursor compress in lockstep (cursor about its
// tip), then release with a soft spring.
tl.to([actor, cursor], { scale: 0.93, duration: 0.12, ease: "power1.in" }, pressAt);
tl.set(slot, { attr: { "data-state": "pressed" } }, pressAt + 0.12);
tl.to(
[actor, cursor],
{ scale: 1, duration: 0.42, ease: "back.out(1.8)" },
pressAt + 0.14,
);
// Ink ripple rings from under the slot: real weight (thick ink,
// fg-mixed contrast, wide travel), still just two rings.
tl.fromTo(
rings,
{ scale: 0.16, autoAlpha: 0.95 },
{
scale: 2.6,
autoAlpha: 0,
duration: 0.66,
ease: "power2.out",
stagger: 0.11,
immediateRender: false,
},
pressAt + 0.1,
);
// The cursor exits off-stage after the release (accelerating,
// power2.in) so it never occludes the label during the hold; the
// pressed state and the ripple aftermath carry the frame. Both
// endpoints are explicit so reverse seeks restore the landing.
tl.fromTo(
cursor,
{ x: xAt(landX), y: yAt(landY) },
{
x: xAt(112),
y: yAt(110),
duration: 0.55,
ease: "power2.in",
immediateRender: false,
},
pressAt + 0.5,
);
// HOLD: dead-still on the pressed state; elastic with D.
// OUT: only when the host opts in; frame roots own transitions.
if (exit !== "none") {
var outStart = Math.max(pressAt + 1.05, duration - OUT);
var outVars = { opacity: 0, duration: OUT, ease: "power2.in" };
if (exit === "up") outVars.y = "-4cqh";
tl.to(stage, outVars, outStart);
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["press-ripple"] = tl;
})();
</script>
</div>
</template>
</body>
</html>