Files
wehub-resource-sync 85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:58:35 +08:00

780 lines
25 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Scene 07 — HyperFrames Video Kinetic Text Pivot</title>
<!--
HyperFrames composition.tsx.
Choreography (4 phases, 6.5 seconds total):
0.17 0.87s "HyperFrames" 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 &lt;video class="clip"&gt; (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/gsap@3.14.2/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:
&lt;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&gt;&lt;/video&gt; -->
<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>