mirror of
https://github.com/hanzoai/frames.git
synced 2026-09-24 00:08:03 +00:00
Every SKILL.md already declared the new name — name: frames, frames-cli, frames-core, and so on — but the directories were never moved, so the tree carried frames-* in content and hyperframes-* on disk. Everything that resolves a skill by path missed: the core-skill list, the plugin marketplace pin, and the docs the CLI reads at runtime. Eight tests failed on main because the files they open do not exist. The directories now match what the files say they are, and the guide the claude-design contract reads takes the name that contract already used.
780 lines
25 KiB
HTML
780 lines
25 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=1920, height=1080" />
|
||
<title>Scene 07 — Frames Video Kinetic Text Pivot</title>
|
||
|
||
<!--
|
||
Frames composition.tsx.
|
||
|
||
Choreography (4 phases, 6.5 seconds total):
|
||
0.17 – 0.87s "Frames" badge fades in (top)
|
||
0.17 – 0.87s Demo screen card enters centered (scale 0.6 → 1)
|
||
2.20 – 3.00s Video slides left to 32% W (scale → 0.85); MP4 pops in on right
|
||
with 5-layer green depth stack and bouncy back.out(1.6)
|
||
3.00 – 3.86s MP4 breathes (±2% scale at ~1.9 Hz)
|
||
3.86 – 4.46s Video and MP4 slide off left + fade; typing stage fades in center
|
||
4.36 – 5.13s "HTML pages become video" types char-by-char (23 chars at 30 ch/s)
|
||
Accent words "pages" and "video" in brand green
|
||
4.83 – 5.43s Gradient pill (purple → green) scales in behind line 2 with glow halo
|
||
5.13 – 5.63s "frame by frame." types char-by-char inside the pill
|
||
0 – 6.50s Continuous: video float, cursor blink, stat breath
|
||
(each gated by time window)
|
||
so entry, continuous motion, and static tilt never overwrite each other
|
||
- Conditional rendering ({frame > X && ...}) replaced by permanent DOM +
|
||
opacity 0 + gates inside the scene-ticker onUpdate
|
||
- Cursor blink derived from tl.time() via Math.floor(t * 2) % 2
|
||
- Demo screen is a self-contained CSS scene (.video-scene); to showcase real
|
||
footage, drop a local mp4 back in as a muted <video class="clip"> (see
|
||
the note at the .video-content markup)
|
||
- 3D depth layers built once at composition setup with document.createElement
|
||
-->
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
|
||
|
||
<style>
|
||
:root {
|
||
--bg-dark: #0a0a18;
|
||
--text-primary: #ffffff;
|
||
--text-secondary: rgba(255, 255, 255, 0.65);
|
||
--brand-purple: #7c3aed;
|
||
--brand-purple-soft: #a78bfa;
|
||
--brand-green: #22c55e;
|
||
--brand-purple-glow: rgba(124, 58, 237, 1);
|
||
--brand-green-glow: rgba(34, 197, 94, 1);
|
||
}
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
html,
|
||
body {
|
||
margin: 0;
|
||
width: 1920px;
|
||
height: 1080px;
|
||
overflow: hidden;
|
||
background: var(--bg-dark);
|
||
font-family:
|
||
"Inter",
|
||
system-ui,
|
||
-apple-system,
|
||
sans-serif;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.stage {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ============================================================
|
||
BACKGROUND + AMBIENT GLOW
|
||
============================================================ */
|
||
.bg {
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
radial-gradient(ellipse at 50% 50%, rgba(40, 30, 80, 0.5), transparent 65%),
|
||
linear-gradient(135deg, #0a0a18 0%, #160a1f 100%);
|
||
}
|
||
.ambient-glow {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: 1536px;
|
||
height: 864px;
|
||
background: radial-gradient(
|
||
ellipse at center,
|
||
rgba(147, 51, 234, 0.13) 0%,
|
||
rgba(34, 197, 94, 0.08) 30%,
|
||
transparent 70%
|
||
);
|
||
filter: blur(80px);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ============================================================
|
||
BADGE
|
||
============================================================ */
|
||
.badge {
|
||
position: absolute;
|
||
top: 42px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
padding: 12px 32px;
|
||
font-size: 112px;
|
||
font-weight: 800;
|
||
color: var(--text-primary);
|
||
line-height: 1;
|
||
white-space: nowrap;
|
||
text-shadow:
|
||
0 0 32px rgba(248, 250, 252, 0.12),
|
||
0 0 72px rgba(24, 217, 232, 0.1);
|
||
will-change: transform, opacity;
|
||
/* initial opacity 0 + scale 0.9 via gsap.set */
|
||
}
|
||
.badge .accent {
|
||
color: var(--brand-purple-soft);
|
||
background: linear-gradient(135deg, var(--brand-purple-soft) 0%, var(--brand-purple) 72%);
|
||
-webkit-background-clip: text;
|
||
background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
filter: drop-shadow(0 0 28px rgba(124, 58, 237, 0.34));
|
||
}
|
||
|
||
/* ============================================================
|
||
VIDEO CARD — three nested wrappers
|
||
============================================================ */
|
||
.video-pos {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
width: 1040px;
|
||
height: 585px; /* 16:9 ratio, intentionally large for the pivot beat */
|
||
margin-left: -520px; /* so x: W/2 centers it */
|
||
margin-top: -292.5px;
|
||
will-change: transform, opacity;
|
||
}
|
||
.video-float {
|
||
width: 100%;
|
||
height: 100%;
|
||
will-change: transform;
|
||
}
|
||
.video-tilt {
|
||
width: 100%;
|
||
height: 100%;
|
||
transform-style: preserve-3d;
|
||
transform: rotateX(5deg) rotateY(15deg);
|
||
}
|
||
.video-content {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||
box-shadow:
|
||
0 30px 60px rgba(0, 0, 0, 0.5),
|
||
0 0 80px rgba(147, 51, 234, 0.2),
|
||
0 0 120px rgba(34, 197, 94, 0.15);
|
||
position: relative;
|
||
}
|
||
.video-scene {
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
radial-gradient(circle at 30% 30%, rgba(255, 200, 100, 0.4), transparent 50%),
|
||
linear-gradient(135deg, #1a3a5f 0%, #2a4570 50%, #3a2a5a 100%);
|
||
}
|
||
.video-scene::before {
|
||
content: "";
|
||
position: absolute;
|
||
top: 35%;
|
||
left: 40%;
|
||
width: 220px;
|
||
height: 220px;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, #fbbf24, #f97316);
|
||
opacity: 0.6;
|
||
filter: blur(8px);
|
||
}
|
||
.video-caption {
|
||
position: absolute;
|
||
bottom: 60px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
padding: 12px 24px;
|
||
background: rgba(0, 0, 0, 0.7);
|
||
border-radius: 8px;
|
||
color: var(--text-primary);
|
||
font-size: 34px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
.video-caption .accent {
|
||
color: var(--brand-green);
|
||
}
|
||
.video-reflection {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 40%;
|
||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, transparent 100%);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* ============================================================
|
||
MP4 STAT — three nested wrappers + depth stack
|
||
============================================================ */
|
||
.stat-pos {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
will-change: transform, opacity;
|
||
}
|
||
.stat-breath {
|
||
will-change: transform;
|
||
}
|
||
.stat-tilt {
|
||
transform-style: preserve-3d;
|
||
transform: rotateX(5deg) rotateY(-15deg);
|
||
}
|
||
.depth-stack {
|
||
position: relative;
|
||
display: inline-block;
|
||
font-size: 340px;
|
||
font-weight: 900;
|
||
letter-spacing: -0.03em;
|
||
line-height: 1;
|
||
}
|
||
.depth-stack .depth-layer.front {
|
||
position: relative;
|
||
color: var(--brand-green);
|
||
}
|
||
.depth-stack .depth-layer.back {
|
||
position: absolute;
|
||
top: var(--top);
|
||
left: var(--left);
|
||
color: rgba(34, 197, 94, var(--alpha));
|
||
}
|
||
|
||
/* ============================================================
|
||
TYPING STAGE — center, fades in during pivot
|
||
============================================================ */
|
||
.typing-stage {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
will-change: transform, opacity;
|
||
}
|
||
.typing-tilt {
|
||
perspective: 1200px;
|
||
transform-style: preserve-3d;
|
||
transform: rotateY(-15deg) rotateX(5deg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 30px;
|
||
}
|
||
.line1 {
|
||
display: flex;
|
||
align-items: center;
|
||
white-space: pre;
|
||
}
|
||
.seg {
|
||
font-size: 124px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
white-space: pre;
|
||
line-height: 1;
|
||
}
|
||
.seg.accent,
|
||
.seg.accent2 {
|
||
color: var(--brand-green);
|
||
}
|
||
|
||
.cursor {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 124px;
|
||
background-color: var(--text-primary);
|
||
margin-left: 8px;
|
||
vertical-align: middle;
|
||
transform: translateY(4px);
|
||
opacity: 0;
|
||
will-change: opacity;
|
||
}
|
||
.cursor.green {
|
||
background-color: var(--brand-green);
|
||
}
|
||
|
||
.line2-wrap {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px 64px;
|
||
}
|
||
.pill-bg {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%) scale(0);
|
||
width: calc(100% + 80px);
|
||
height: calc(100% + 40px);
|
||
border-radius: 80px;
|
||
background: linear-gradient(90deg, var(--brand-purple) 0%, var(--brand-green) 100%);
|
||
opacity: 0;
|
||
z-index: -1;
|
||
will-change: transform, opacity;
|
||
}
|
||
.pill-glow {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
width: calc(100% + 100px);
|
||
height: calc(100% + 60px);
|
||
border-radius: 80px;
|
||
background: radial-gradient(
|
||
ellipse at center,
|
||
var(--brand-purple-glow) 0%,
|
||
transparent 70%
|
||
);
|
||
opacity: 0;
|
||
filter: blur(30px);
|
||
z-index: -2;
|
||
will-change: opacity;
|
||
}
|
||
.line2-content {
|
||
display: flex;
|
||
align-items: center;
|
||
position: relative;
|
||
z-index: 1;
|
||
opacity: 0;
|
||
will-change: opacity;
|
||
}
|
||
.seg.line2 {
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
/* ============================================================
|
||
VIGNETTE
|
||
============================================================ */
|
||
.vignette {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.5) 100%);
|
||
pointer-events: none;
|
||
z-index: 400;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div
|
||
id="root"
|
||
data-composition-id="main"
|
||
data-start="0"
|
||
data-duration="6.5"
|
||
data-width="1920"
|
||
data-height="1080"
|
||
>
|
||
<div
|
||
class="stage clip"
|
||
data-start="0"
|
||
data-duration="6.5"
|
||
data-track-index="1"
|
||
id="scene-stage"
|
||
>
|
||
<div class="bg"></div>
|
||
<div class="ambient-glow"></div>
|
||
|
||
<div class="badge" id="badge">Hyper<span class="accent">Frames</span></div>
|
||
|
||
<!-- Video card -->
|
||
<div class="video-pos" id="video-pos" data-layout-allow-overflow>
|
||
<div class="video-float" id="video-float">
|
||
<div class="video-tilt">
|
||
<div class="video-content">
|
||
<div class="video-scene"></div>
|
||
<!-- To showcase real footage, add a local mp4 on top of .video-scene:
|
||
<video id="demo-video" class="clip" src="assets/demo.mp4"
|
||
style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover"
|
||
data-start="0.17" data-duration="3.69" data-track-index="2"
|
||
data-media-start="0" muted playsinline></video> -->
|
||
<div class="video-caption">
|
||
HTML, CSS and JS become <span class="accent">video</span>
|
||
</div>
|
||
<div class="video-reflection"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MP4 stat -->
|
||
<div class="stat-pos" id="stat-pos" data-layout-allow-overflow>
|
||
<div class="stat-breath" id="stat-breath">
|
||
<div class="stat-tilt">
|
||
<div class="depth-stack" id="depth-stack-97" data-text="MP4"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Typing -->
|
||
<div class="typing-stage" id="typing-stage">
|
||
<div class="typing-tilt">
|
||
<div class="line1">
|
||
<span class="seg main">HTML </span><span class="seg accent">pages</span
|
||
><span class="seg suffix"> become </span><span class="seg accent2">video</span
|
||
><span class="cursor cursor1" id="cursor1"></span>
|
||
</div>
|
||
<div class="line2-wrap">
|
||
<div class="pill-bg" id="pill-bg"></div>
|
||
<div class="pill-glow" id="pill-glow"></div>
|
||
<div class="line2-content">
|
||
<span class="seg line2">frame by frame.</span
|
||
><span class="cursor cursor2" id="cursor2"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="vignette"></div>
|
||
</div>
|
||
|
||
<script>
|
||
/* ================================================================
|
||
CONSTANTS
|
||
================================================================ */
|
||
const W = 1920,
|
||
H = 1080;
|
||
const TOTAL_DUR = 6.5;
|
||
|
||
/* ASR-anchored timing (the source source frame counts converted to seconds at 30fps). */
|
||
const TIMING = {
|
||
// Phase 1: video + badge enter
|
||
badgeAt: 0.17,
|
||
badgeDur: 0.7,
|
||
videoEntryAt: 0.17,
|
||
videoEntryDur: 0.7,
|
||
|
||
// Phase 2: video slides, MP4 appears
|
||
slideAt: 2.2,
|
||
videoSlideDur: 0.8,
|
||
videoSlideX: W * 0.29,
|
||
videoSlideScale: 0.92,
|
||
|
||
statEntryDur: 0.6,
|
||
statEntryX: W * 0.6,
|
||
statBreathAt: 3.0, // entry settles by here, breath begins
|
||
statBreathDur: 0.86, // until exit at 3.86
|
||
|
||
// Phase 3: pivot — both exit, typing begins
|
||
pivotAt: 3.86,
|
||
exitDur: 0.6,
|
||
videoExitX: -W * 0.5,
|
||
statExitX: -W * 0.7,
|
||
typingStageDur: 0.5,
|
||
|
||
// Phase 3 typing
|
||
typingStartAt: 4.36, // pivotAt + 0.5 s stage settle
|
||
typeRate: 30, // chars/sec
|
||
|
||
// Phase 4: pill
|
||
pillAt: 4.83, // typing reaches line2 at this time
|
||
pillDur: 0.6,
|
||
pillGlowDur: 0.8,
|
||
};
|
||
|
||
/* Text segments and boundaries */
|
||
const SEG = {
|
||
main: "HTML ",
|
||
accent: "pages",
|
||
suffix: " become ",
|
||
accent2: "video",
|
||
line2: "frame by frame.",
|
||
};
|
||
const BOUNDS = {
|
||
mainEnd: SEG.main.length, // 5
|
||
accentEnd: SEG.main.length + SEG.accent.length, // 10
|
||
suffixEnd: SEG.main.length + SEG.accent.length + SEG.suffix.length, // 18
|
||
accent2End: SEG.main.length + SEG.accent.length + SEG.suffix.length + SEG.accent2.length, // 23
|
||
line2End:
|
||
SEG.main.length +
|
||
SEG.accent.length +
|
||
SEG.suffix.length +
|
||
SEG.accent2.length +
|
||
SEG.line2.length, // 38
|
||
};
|
||
const TYPING_DUR = (BOUNDS.line2End - 0) / TIMING.typeRate; // ≈ 1.3 s
|
||
|
||
/* ================================================================
|
||
BUILD DOM — 5-layer depth stack for "MP4"
|
||
================================================================ */
|
||
const LAYER_COUNT = 5;
|
||
const OFFSET_X = 1;
|
||
const OFFSET_Y = 2;
|
||
const depthStack = document.getElementById("depth-stack-97");
|
||
const depthText = depthStack.dataset.text;
|
||
for (let i = 0; i < LAYER_COUNT; i++) {
|
||
const layer = document.createElement("div");
|
||
layer.className = "depth-layer " + (i === LAYER_COUNT - 1 ? "front" : "back");
|
||
layer.textContent = depthText;
|
||
if (i < LAYER_COUNT - 1) {
|
||
layer.setAttribute("aria-hidden", "true");
|
||
const alpha = 0.62 + 0.08 * (LAYER_COUNT - 2 - i);
|
||
layer.style.setProperty("--alpha", alpha);
|
||
layer.style.setProperty("--top", i * OFFSET_Y + "px");
|
||
layer.style.setProperty("--left", -i * OFFSET_X + "px");
|
||
}
|
||
depthStack.appendChild(layer);
|
||
}
|
||
|
||
/* ================================================================
|
||
INITIAL STATES (via gsap.set, before the timeline runs)
|
||
================================================================ */
|
||
gsap.set("#badge", { opacity: 0, scale: 0.9 });
|
||
|
||
// Video starts centered (x = W/2, y = H/2 via the .video-pos margin trick)
|
||
gsap.set("#video-pos", { x: W / 2, y: H / 2, scale: 0.6, opacity: 0 });
|
||
|
||
// Stat starts off-right + below + small + invisible
|
||
gsap.set("#stat-pos", { x: TIMING.statEntryX, y: H * 0.42 + 60, scale: 0.3, opacity: 0 });
|
||
|
||
gsap.set("#typing-stage", { scale: 0.9, opacity: 0 });
|
||
|
||
/* ================================================================
|
||
TIMELINE
|
||
================================================================ */
|
||
window.__timelines = window.__timelines || {};
|
||
const tl = gsap.timeline({ paused: true });
|
||
window.__timelines["main"] = tl;
|
||
|
||
/* ----------------------------------------------------------------
|
||
PHASE 1: Badge + Video entry
|
||
---------------------------------------------------------------- */
|
||
tl.to(
|
||
"#badge",
|
||
{
|
||
opacity: 1,
|
||
scale: 1,
|
||
duration: TIMING.badgeDur,
|
||
ease: "power3.out",
|
||
},
|
||
TIMING.badgeAt,
|
||
);
|
||
|
||
tl.to(
|
||
"#video-pos",
|
||
{
|
||
scale: 1,
|
||
opacity: 1,
|
||
duration: TIMING.videoEntryDur,
|
||
ease: "power3.out", // spring(stiffness:80, damping:15)
|
||
},
|
||
TIMING.videoEntryAt,
|
||
);
|
||
|
||
/* ----------------------------------------------------------------
|
||
PHASE 2: Video slides left, MP4 appears on right
|
||
---------------------------------------------------------------- */
|
||
tl.to(
|
||
"#video-pos",
|
||
{
|
||
x: TIMING.videoSlideX,
|
||
scale: TIMING.videoSlideScale,
|
||
duration: TIMING.videoSlideDur,
|
||
ease: "power3.out", // spring(stiffness:100, damping:18)
|
||
},
|
||
TIMING.slideAt,
|
||
);
|
||
|
||
tl.to(
|
||
"#stat-pos",
|
||
{
|
||
y: H * 0.42, // rises to final y
|
||
scale: 1,
|
||
opacity: 1,
|
||
duration: TIMING.statEntryDur,
|
||
ease: "back.out(1.6)", // spring(stiffness:150, damping:12)
|
||
},
|
||
TIMING.slideAt,
|
||
);
|
||
|
||
/* ----------------------------------------------------------------
|
||
PHASE 3: Pivot — video + stat exit; typing fades in
|
||
---------------------------------------------------------------- */
|
||
tl.to(
|
||
"#video-pos",
|
||
{
|
||
x: TIMING.videoExitX,
|
||
scale: 0.8,
|
||
opacity: 0,
|
||
duration: TIMING.exitDur,
|
||
ease: "power3.out", // spring(stiffness:120, damping:20)
|
||
},
|
||
TIMING.pivotAt,
|
||
);
|
||
|
||
tl.to(
|
||
"#stat-pos",
|
||
{
|
||
x: TIMING.statExitX,
|
||
scale: 0.8,
|
||
opacity: 0,
|
||
duration: TIMING.exitDur,
|
||
ease: "power3.out",
|
||
},
|
||
TIMING.pivotAt,
|
||
);
|
||
|
||
tl.to(
|
||
"#typing-stage",
|
||
{
|
||
scale: 1,
|
||
opacity: 1,
|
||
duration: TIMING.typingStageDur,
|
||
ease: "power3.out", // spring(stiffness:100, damping:15)
|
||
},
|
||
TIMING.pivotAt,
|
||
);
|
||
|
||
/* ----------------------------------------------------------------
|
||
PHASE 3 (continued): Character-by-character typing
|
||
---------------------------------------------------------------- */
|
||
const segMain = document.querySelector(".seg.main");
|
||
const segAccent = document.querySelector(".seg.accent");
|
||
const segSuffix = document.querySelector(".seg.suffix");
|
||
const segAccent2 = document.querySelector(".seg.accent2");
|
||
const segLine2 = document.querySelector(".seg.line2");
|
||
|
||
const typeProxy = { idx: 0 };
|
||
function typedIndexAt(t) {
|
||
if (t < TIMING.typingStartAt) return 0;
|
||
return Math.min(BOUNDS.line2End, Math.floor((t - TIMING.typingStartAt) * TIMING.typeRate));
|
||
}
|
||
|
||
function renderTypedText(i) {
|
||
const m = SEG.main.slice(0, Math.min(i, BOUNDS.mainEnd));
|
||
const a = SEG.accent.slice(0, Math.max(0, Math.min(i - BOUNDS.mainEnd, SEG.accent.length)));
|
||
const s = SEG.suffix.slice(
|
||
0,
|
||
Math.max(0, Math.min(i - BOUNDS.accentEnd, SEG.suffix.length)),
|
||
);
|
||
const a2 = SEG.accent2.slice(
|
||
0,
|
||
Math.max(0, Math.min(i - BOUNDS.suffixEnd, SEG.accent2.length)),
|
||
);
|
||
const l2 = SEG.line2.slice(
|
||
0,
|
||
Math.max(0, Math.min(i - BOUNDS.accent2End, SEG.line2.length)),
|
||
);
|
||
if (segMain.textContent !== m) segMain.textContent = m;
|
||
if (segAccent.textContent !== a) segAccent.textContent = a;
|
||
if (segSuffix.textContent !== s) segSuffix.textContent = s;
|
||
if (segAccent2.textContent !== a2) segAccent2.textContent = a2;
|
||
if (segLine2.textContent !== l2) segLine2.textContent = l2;
|
||
}
|
||
|
||
tl.to(
|
||
typeProxy,
|
||
{
|
||
idx: BOUNDS.line2End,
|
||
duration: TYPING_DUR,
|
||
ease: "none",
|
||
onUpdate: function () {
|
||
renderTypedText(Math.floor(typeProxy.idx));
|
||
},
|
||
},
|
||
TIMING.typingStartAt,
|
||
);
|
||
|
||
/* ----------------------------------------------------------------
|
||
PHASE 4: Gradient pill behind line 2
|
||
---------------------------------------------------------------- */
|
||
gsap.set("#pill-bg", { scaleX: 0, scaleY: 0.5, opacity: 0 });
|
||
gsap.set("#pill-glow", { opacity: 0 });
|
||
|
||
tl.to(
|
||
"#pill-bg",
|
||
{
|
||
scaleX: 1,
|
||
scaleY: 1,
|
||
opacity: 0.9,
|
||
duration: TIMING.pillDur,
|
||
ease: "power3.out", // spring(stiffness:80, damping:15)
|
||
},
|
||
TIMING.pillAt,
|
||
);
|
||
|
||
tl.to(
|
||
"#pill-glow",
|
||
{
|
||
opacity: 0.5,
|
||
duration: TIMING.pillGlowDur,
|
||
ease: "power2.out",
|
||
},
|
||
TIMING.pillAt,
|
||
);
|
||
|
||
tl.to(
|
||
".line2-content",
|
||
{
|
||
opacity: 1,
|
||
duration: 0.18,
|
||
ease: "power2.out",
|
||
},
|
||
TIMING.pillAt + 0.3,
|
||
);
|
||
|
||
/* ----------------------------------------------------------------
|
||
CONTINUOUS SCENE-TICKER — float, breath, cursor blink
|
||
All gated by time windows so they only fire when visible.
|
||
---------------------------------------------------------------- */
|
||
const videoFloat = document.getElementById("video-float");
|
||
const statBreathEl = document.getElementById("stat-breath");
|
||
const cursor1El = document.getElementById("cursor1");
|
||
const cursor2El = document.getElementById("cursor2");
|
||
|
||
tl.to(
|
||
{ tick: 0 },
|
||
{
|
||
tick: 1,
|
||
duration: TOTAL_DUR,
|
||
ease: "none",
|
||
onUpdate: function () {
|
||
const t = tl.time();
|
||
const idx = typedIndexAt(t);
|
||
renderTypedText(idx);
|
||
|
||
// Video float — only while video is visible (Phase 1 + 2)
|
||
if (t < TIMING.pivotAt) {
|
||
const floatY = Math.sin(t * 0.9) * 6;
|
||
gsap.set(videoFloat, { y: floatY });
|
||
}
|
||
|
||
// Stat breath — only while MP4 is settled and not yet exiting
|
||
if (t > TIMING.statBreathAt && t < TIMING.pivotAt) {
|
||
const breath = 1 + Math.sin((t - TIMING.statBreathAt) * 1.2) * 0.02;
|
||
gsap.set(statBreathEl, { scale: breath });
|
||
}
|
||
|
||
// Cursor blink — 1 Hz (Math.floor(t * 2) % 2 === 0 → on for 0.5s, off for 0.5s)
|
||
const blink = Math.floor(t * 2) % 2 === 0 ? 1 : 0;
|
||
|
||
// Cursor 1: visible while typing line 1 (idx < accent2End)
|
||
// Determine cursor color: green when typing accent segments, white otherwise.
|
||
if (t >= TIMING.typingStartAt && idx < BOUNDS.accent2End) {
|
||
const inAccent = idx >= BOUNDS.mainEnd && idx < BOUNDS.accentEnd;
|
||
const inAccent2 = idx >= BOUNDS.suffixEnd && idx < BOUNDS.accent2End;
|
||
const isGreen = inAccent || inAccent2;
|
||
cursor1El.style.opacity = blink;
|
||
if (cursor1El._lastGreen !== isGreen) {
|
||
cursor1El.classList.toggle("green", isGreen);
|
||
cursor1El._lastGreen = isGreen;
|
||
}
|
||
} else {
|
||
cursor1El.style.opacity = 0;
|
||
}
|
||
|
||
// Cursor 2: visible while typing line 2 (idx ≥ accent2End && idx < line2End)
|
||
if (idx >= BOUNDS.accent2End && idx < BOUNDS.line2End) {
|
||
cursor2El.style.opacity = blink;
|
||
} else {
|
||
cursor2El.style.opacity = 0;
|
||
}
|
||
},
|
||
},
|
||
0,
|
||
);
|
||
</script>
|
||
</body>
|
||
</html>
|