Files
hyperframes/registry/components/toggle-flip/toggle-flip.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

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>