mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +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.
425 lines
18 KiB
HTML
Vendored
425 lines
18 KiB
HTML
Vendored
<!doctype html>
|
|
<!--
|
|
toggle-flip -- HyperFrames video primitive (ui-props / interaction / demonstrate)
|
|
|
|
Concept: an oversized UI toggle switch that flips with real physicality --
|
|
thumb travel with a slight overshoot, a track color crossfade from
|
|
var(--surface) to var(--brand), and a soft press-compress right before the
|
|
knob releases into its new state. One mechanic, one job: demonstrating a
|
|
state change. This is the reference prop of the ui-props family -- the
|
|
thing a cursor or touch actor "operates" in a composed scene, and it also
|
|
has to read alone.
|
|
|
|
Compiled-from evidence: fixture; the reference prop (video-primitives
|
|
catalog, ui-props / interaction / demonstrate shelf).
|
|
|
|
Use when: showing a feature, setting, or permission turning on/off, or any
|
|
binary state change a pointer actor (oversized-cursor, touch-indicator)
|
|
operates on. Composes with those pointer primitives, which anchor to this
|
|
file's [data-anchor="toggle-flip"] button.
|
|
|
|
Variables (declared in data-composition-variables below):
|
|
- direction ("on" | "off", default "on"): the flip's TARGET state.
|
|
"on" -> starts OFF (surface track), flips TO on (brand track).
|
|
"off" -> starts ON (brand track), flips TO off (surface track).
|
|
- label (string, default "Auto-save"): optional caption under the
|
|
toggle. Empty string hides the caption line entirely.
|
|
- size (number, percent of container width, default 40): toggle width
|
|
as a percentage of the host box's own width (cqw-driven, so it stays
|
|
proportional no matter what box a host composition gives it).
|
|
|
|
Envelope (fixed IN/OUT, elastic HOLD only -- never gsap.timeScale()):
|
|
IN_BASE = 0.90s stage settles in, anticipation press, then the flip
|
|
HOLD = elastic = max(0, D - (IN_BASE + OUT_BASE)); ambient glow
|
|
breathes gently to prove the frame is alive, or sits calm at
|
|
HOLD = 0 for short durations
|
|
OUT_BASE = 0.50s release fade
|
|
If D < IN_BASE + OUT_BASE, IN and OUT scale down together (never
|
|
time-scaled) so IN + OUT == D and HOLD == 0.
|
|
|
|
Sync point (fixed offset into IN, never inside the elastic HOLD): the flip
|
|
lands at FLIP_AT = 0.5s into an unscaled IN (scales proportionally with IN
|
|
when the envelope is compressed -- see RETIME RANGE below).
|
|
|
|
Sound cue: a soft click/tock foley fires at the flip sync point. The
|
|
primitive never plays audio -- it dispatches a `hf:sfx` CustomEvent
|
|
({ id: "click-soft", t: FLIP_AT }) that a scene's mix stage can catch and
|
|
route to the catalog SFX id of its choice.
|
|
|
|
Mount contract: this file is a MOUNTABLE SUB-COMPOSITION, not a standalone
|
|
composition. A host loads it via data-composition-src; the runtime only
|
|
clones <template> contents (everything outside <template>, including the
|
|
entire <head>, is discarded on mount) -- see
|
|
skills/hyperframes-core/references/sub-compositions.md. The root carries
|
|
no data-width/data-height: it is elastic, sized off whatever box the host
|
|
clip gives it (position:absolute; inset:0; container-type:size), so the
|
|
toggle reads correctly whether the host mounts it into a 960x540 slot or a
|
|
full 1920x1080 frame. The root is styled by #root, never a class --
|
|
composited renders scope this file's CSS to
|
|
[data-composition-id="toggle-flip"], and a rule keyed on the root's own
|
|
class would stop matching the root itself (sub-compositions.md, Pitfall
|
|
3). Variables are read via window.__hyperframes.getVariables() (not by
|
|
parsing this file's own <html> tag at runtime): once mounted,
|
|
document.documentElement is the HOST's <html>, not this one, so the
|
|
loader's declared-defaults-plus-per-instance-overrides table is the only
|
|
reliable source once the primitive is running inside a host page.
|
|
-->
|
|
<html
|
|
lang="en"
|
|
data-composition-variables='[
|
|
{ "id": "direction", "type": "enum", "role": "content", "label": "Flip direction", "description": "Target end state the flip lands on.", "default": "on", "options": [{ "value": "on", "label": "Off → On" }, { "value": "off", "label": "On → Off" }] },
|
|
{ "id": "label", "type": "string", "role": "content", "label": "Caption", "description": "Optional caption under the toggle. Blank hides the line.", "default": "Auto-save" },
|
|
{ "id": "size", "type": "number", "role": "layout", "label": "Size", "description": "Toggle width as a percent of the host box width.", "default": 40, "min": 20, "max": 70, "step": 1, "unit": "%" }
|
|
]'
|
|
>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Toggle Flip</title>
|
|
<!-- Metadata only for whoever opens this file directly -- the runtime
|
|
discards everything outside <template> on mount. The
|
|
data-composition-variables attribute above stays on <html> (not
|
|
inside <template>): the loader reads declared variable defaults
|
|
directly off the fetched document's root element, whether or not
|
|
the body is template-wrapped. -->
|
|
</head>
|
|
<body>
|
|
<template>
|
|
<div id="root" data-composition-id="toggle-flip" data-duration="4" data-fps="30">
|
|
<style>
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
/* Root: sized off the HOST box, never a fixed stage. inset:0 fills
|
|
whatever box the host clip gives it -- a 960x540 mounted slot,
|
|
a full 1920x1080 frame, anything in between. container-type
|
|
establishes the cqw/cqh basis every internal measurement below
|
|
is expressed in. Styled by #root, never a class -- see the
|
|
mount-contract note in the header comment. */
|
|
#root {
|
|
position: absolute;
|
|
inset: 0;
|
|
container-type: size;
|
|
isolation: isolate;
|
|
overflow: hidden;
|
|
background: var(--bg, #0b1120);
|
|
color: var(--fg, #f8fafc);
|
|
font-family: var(--font-body, Inter, system-ui, sans-serif);
|
|
}
|
|
|
|
.tf-clip {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
|
|
.tf-stage {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: var(--space-3, 3cqh);
|
|
opacity: 0;
|
|
}
|
|
|
|
.tf-label {
|
|
color: var(--muted, #94a3b8);
|
|
font-family: var(--font-display, Inter, system-ui, sans-serif);
|
|
font-weight: 650;
|
|
font-size: clamp(12px, 2.4cqw, 28px);
|
|
letter-spacing: 0.01em;
|
|
text-align: center;
|
|
}
|
|
|
|
.tf-label:empty {
|
|
display: none;
|
|
}
|
|
|
|
/* EDIT ZONE: geometry ratios. Track is a fixed 2.2:1 pill; the knob is
|
|
80% of the track's own height, inset 10% top/bottom. All of it is
|
|
expressed in cqw off --tf-size so it holds proportion at any size. */
|
|
.tf-toggle {
|
|
--tf-track-w: calc(var(--tf-size, 40) * 1cqw);
|
|
width: var(--tf-track-w);
|
|
aspect-ratio: 2.2 / 1;
|
|
position: relative;
|
|
display: block;
|
|
border: 0;
|
|
padding: 0;
|
|
background: transparent;
|
|
cursor: default;
|
|
/* transform is GSAP-owned below (press squeeze); no CSS transform here */
|
|
}
|
|
|
|
.tf-track {
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: 999px;
|
|
--tf-mix: 0; /* 0..100, GSAP-tweened: 0 = surface/off, 100 = brand/on */
|
|
border: 0.15cqw solid
|
|
color-mix(
|
|
in srgb,
|
|
var(--border, #334155) calc(100% - var(--tf-mix, 0) * 0.4%),
|
|
var(--brand, #22c55e) calc(var(--tf-mix, 0) * 0.4%)
|
|
);
|
|
background: color-mix(
|
|
in srgb,
|
|
var(--surface, #1e293b) calc((100 - var(--tf-mix, 0)) * 1%),
|
|
var(--brand, #22c55e) calc(var(--tf-mix, 0) * 1%)
|
|
);
|
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--border, #334155) 45%, transparent);
|
|
}
|
|
|
|
.tf-glow {
|
|
position: absolute;
|
|
inset: -14%;
|
|
border-radius: 999px;
|
|
background: radial-gradient(
|
|
closest-side,
|
|
color-mix(in srgb, var(--accent, #38bdf8) 55%, transparent),
|
|
transparent 72%
|
|
);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.tf-knob {
|
|
position: absolute;
|
|
top: calc(var(--tf-size, 40) * 0.0455cqw);
|
|
left: calc(var(--tf-size, 40) * 0.0455cqw);
|
|
width: calc(var(--tf-size, 40) * 0.3636cqw);
|
|
aspect-ratio: 1;
|
|
border-radius: 50%;
|
|
background: var(--surface, #f8fafc);
|
|
box-shadow: 0 0.35cqw 0.7cqw rgba(2, 6, 23, 0.4);
|
|
/* RETIME RANGE ends here for geometry; 150% is the exact travel a
|
|
2.2:1 track / 80%-of-height knob pair produces -- see header. GSAP
|
|
tweens x/scale directly below; no CSS transform authored here. */
|
|
}
|
|
</style>
|
|
|
|
<div
|
|
id="toggle-flip-clip"
|
|
class="tf-clip clip"
|
|
data-start="0"
|
|
data-duration="4"
|
|
data-track-index="0"
|
|
>
|
|
<div class="tf-stage">
|
|
<div class="tf-label"></div>
|
|
<button
|
|
class="tf-toggle"
|
|
type="button"
|
|
data-anchor="toggle-flip"
|
|
aria-pressed="false"
|
|
tabindex="-1"
|
|
>
|
|
<span class="tf-glow"></span>
|
|
<span class="tf-track"></span>
|
|
<span class="tf-knob"></span>
|
|
</button>
|
|
</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");
|
|
// Literal, not read off the DOM: once mounted, the framework's
|
|
// flattening step strips data-composition-id from the mounted
|
|
// root (see FLATTENED_INNER_ROOT_STRIP_ATTRS in
|
|
// packages/core/src/runtime/flattenedRoot.ts), so
|
|
// root.getAttribute("data-composition-id") would read null post-
|
|
// mount and silently register the timeline under the key
|
|
// "null" instead of "toggle-flip" -- the render then waits the
|
|
// full 45s sub-composition poll and gives up. Matches the
|
|
// convention in sub-compositions.md's own example and
|
|
// empty.html: hardcode the id this file is always mounted
|
|
// under.
|
|
var compositionId = "toggle-flip";
|
|
var stage = root.querySelector(".tf-stage");
|
|
var labelEl = root.querySelector(".tf-label");
|
|
var toggleEl = root.querySelector(".tf-toggle");
|
|
var trackEl = root.querySelector(".tf-track");
|
|
var knobEl = root.querySelector(".tf-knob");
|
|
var glowEl = root.querySelector(".tf-glow");
|
|
|
|
// EDIT ZONE: variable defaults come from data-composition-variables
|
|
// above (declared on <html>). window.__hyperframes.getVariables()
|
|
// returns those defaults already merged with any per-instance
|
|
// data-variable-values a host sets on the mounting clip -- see
|
|
// sub-compositions.md's "Per-Instance Variables" section. Add a
|
|
// variable in both places together (declaration above + fallback
|
|
// below).
|
|
var vars =
|
|
window.__hyperframes && window.__hyperframes.getVariables
|
|
? window.__hyperframes.getVariables()
|
|
: {};
|
|
|
|
// INVARIANT: only "on" | "off" ever reaches the renderer, whatever a
|
|
// bad override sends.
|
|
var direction = vars.direction === "off" ? "off" : "on";
|
|
var toOn = direction === "on";
|
|
var labelText = vars.label == null ? "" : String(vars.label);
|
|
// INVARIANT: size always clamps into the declared 20-70% range so an
|
|
// out-of-range override can't blow the toggle past its container.
|
|
var sizePct = Math.max(20, Math.min(70, Number(vars.size) || 40));
|
|
|
|
labelEl.textContent = labelText;
|
|
toggleEl.style.setProperty("--tf-size", String(sizePct));
|
|
|
|
// A project's own --dur-beat sets this primitive's ambient pace, so
|
|
// the HOLD breathing feels native to whatever theme is compiled in.
|
|
var beat = parseFloat(getComputedStyle(root).getPropertyValue("--dur-beat")) || 0.5;
|
|
var SHEEN_HALF = beat * 1.5;
|
|
|
|
// RETIME RANGE: these are the only numbers to touch for a global
|
|
// pacing retune. Do not reach for gsap.timeScale() -- HOLD below is
|
|
// the only elastic phase; IN and OUT are always this long (scaled
|
|
// down together only when D itself is shorter than IN_BASE+OUT_BASE).
|
|
var IN_BASE = 0.9;
|
|
var OUT_BASE = 0.5;
|
|
var STAGE_IN_BASE = 0.4;
|
|
var PRESS_LEAD_BASE = 0.2;
|
|
var FLIP_AT_BASE = 0.5; // sync point: fixed offset into IN, never into HOLD
|
|
var FLIP_DURATION_BASE = 0.25;
|
|
var GLOW_DURATION_BASE = 0.15;
|
|
|
|
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
|
|
var totalBase = IN_BASE + OUT_BASE;
|
|
var scale = duration < totalBase ? duration / totalBase : 1;
|
|
var IN = IN_BASE * scale;
|
|
var OUT = OUT_BASE * scale;
|
|
var STAGE_IN = STAGE_IN_BASE * scale;
|
|
var PRESS_LEAD = PRESS_LEAD_BASE * scale;
|
|
var FLIP_AT = FLIP_AT_BASE * scale;
|
|
var FLIP_DURATION = FLIP_DURATION_BASE * scale;
|
|
var GLOW_DURATION = GLOW_DURATION_BASE * scale;
|
|
var HOLD = Math.max(0, duration - (IN + OUT));
|
|
var HOLD_START = IN;
|
|
var OUT_START = IN + HOLD;
|
|
|
|
function fireSfx(id, t) {
|
|
root.dispatchEvent(
|
|
new CustomEvent("hf:sfx", { detail: { id: id, t: t }, bubbles: true }),
|
|
);
|
|
}
|
|
|
|
// Explicit both-endpoints state (gsap.set, never gsap.from()) so a
|
|
// seek to t=0 is always correct without playing the timeline.
|
|
var mixFrom = toOn ? 0 : 100;
|
|
var mixTo = toOn ? 100 : 0;
|
|
var xFrom = toOn ? "0%" : "150%";
|
|
var xTo = toOn ? "150%" : "0%";
|
|
gsap.set(stage, { opacity: 0 });
|
|
gsap.set(toggleEl, { scaleX: 1, scaleY: 1 });
|
|
gsap.set(knobEl, { x: xFrom, scale: 1 });
|
|
gsap.set(trackEl, { "--tf-mix": mixFrom });
|
|
gsap.set(glowEl, { opacity: 0 });
|
|
toggleEl.setAttribute("aria-pressed", toOn ? "false" : "true");
|
|
|
|
var tl = gsap.timeline({ paused: true });
|
|
|
|
// IN: stage settle
|
|
tl.to(stage, { opacity: 1, duration: STAGE_IN, ease: "power2.out" }, 0);
|
|
|
|
// anticipation press, resolves exactly as the flip begins
|
|
if (PRESS_LEAD > 0) {
|
|
var pressStart = Math.max(0, FLIP_AT - PRESS_LEAD);
|
|
var pressHalf = PRESS_LEAD / 2;
|
|
tl.to(
|
|
toggleEl,
|
|
{ scaleX: 0.94, scaleY: 1.06, duration: pressHalf, ease: "power1.inOut" },
|
|
pressStart,
|
|
);
|
|
tl.to(
|
|
toggleEl,
|
|
{ scaleX: 1, scaleY: 1, duration: pressHalf, ease: "power1.inOut" },
|
|
pressStart + pressHalf,
|
|
);
|
|
}
|
|
|
|
// the flip: knob travel with overshoot + track color crossfade, one
|
|
// shared sync point (FLIP_AT) so they always land together
|
|
tl.to(knobEl, { x: xTo, duration: FLIP_DURATION, ease: "back.out(1.9)" }, FLIP_AT);
|
|
tl.to(
|
|
trackEl,
|
|
{ "--tf-mix": mixTo, duration: FLIP_DURATION, ease: "back.out(1.9)" },
|
|
FLIP_AT,
|
|
);
|
|
tl.fromTo(
|
|
knobEl,
|
|
{ scale: 1 },
|
|
{
|
|
scale: 1.08,
|
|
duration: FLIP_DURATION * 0.45,
|
|
ease: "power1.out",
|
|
yoyo: true,
|
|
repeat: 1,
|
|
},
|
|
FLIP_AT,
|
|
);
|
|
tl.set(
|
|
toggleEl,
|
|
{ attr: { "aria-pressed": toOn ? "true" : "false" } },
|
|
FLIP_AT + FLIP_DURATION,
|
|
);
|
|
tl.call(
|
|
function () {
|
|
fireSfx("click-soft", FLIP_AT);
|
|
},
|
|
[],
|
|
FLIP_AT,
|
|
);
|
|
|
|
// soft glow pulse right as the flip settles
|
|
tl.fromTo(
|
|
glowEl,
|
|
{ opacity: 0 },
|
|
{
|
|
opacity: 1,
|
|
duration: GLOW_DURATION * 0.5,
|
|
ease: "power1.out",
|
|
yoyo: true,
|
|
repeat: 1,
|
|
},
|
|
FLIP_AT + FLIP_DURATION,
|
|
);
|
|
|
|
// HOLD: gentle ambient breathing so an elastic frame still reads
|
|
// alive; naturally absent when HOLD == 0. Finite repeat count per
|
|
// the determinism rule -- floor, never ceil, so it can't overshoot D.
|
|
if (HOLD > SHEEN_HALF) {
|
|
var breathRepeat = Math.max(0, Math.floor(HOLD / SHEEN_HALF) - 1);
|
|
tl.fromTo(
|
|
glowEl,
|
|
{ opacity: 0 },
|
|
{
|
|
opacity: 0.32,
|
|
duration: SHEEN_HALF,
|
|
ease: "sine.inOut",
|
|
yoyo: true,
|
|
repeat: breathRepeat,
|
|
},
|
|
HOLD_START + GLOW_DURATION,
|
|
);
|
|
}
|
|
|
|
// OUT: release fade
|
|
tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.out" }, OUT_START);
|
|
|
|
tl.seek(0);
|
|
|
|
window.__timelines = window.__timelines || {};
|
|
window.__timelines[compositionId] = tl;
|
|
})();
|
|
</script>
|
|
</div>
|
|
</template>
|
|
</body>
|
|
</html>
|