feat: audit phase 6 (T1) — persistent HUD, skip intro, faster entrance

The audit's headline UX finding, measured on production: cold load showed a
black screen for ~5s, then a wordless constellation. At 9 seconds the number
of words on screen was zero — no name, no title, no hint that anything was
clickable. A recruiter could not learn one fact without clicking.

- New Hud.svelte: name, market-legible title and a one-line hook, always on
  screen, sourced from the origin node. Readable at ~1s instead of never.
  Added `tagline` to OriginNode so it lives with the rest of the content.
- New CoachHint.svelte: first-run "drag / scroll / click" hint. The icons
  gave no affordance that the tree was interactive at all.
- Skip intro control. runEntranceAnimations now returns a handle whose skip()
  enumerates the entrance's own nested GSAP context and calls progress(1) on
  each finite tween — which fires their onComplete, so node interactivity and
  the ambient pulses come up exactly as if the intro had played through.
  Verified: a node is clickable immediately after skipping.
- Returning visitors land on the settled tree (localStorage), reusing the
  same code path as reduced motion. The flag is set once the intro has
  actually been seen, not at mount — otherwise someone who bounced after a
  second would be marked as having seen it and never get it.
- Entrance timings cut: title 2.5s -> 1.6s, zoom 4.5s -> 2s, stagger 0.08 ->
  0.045 (that last one was 4.5s of cascade on its own across 56 nodes).

Two bugs found while building this:
- animation.ts hardcoded its timings as literals instead of reading
  config.ts TIMINGS, so editing config silently did nothing. Now derived.
- Skip and the coach hint were timed off a magic 3200ms, so Skip vanished
  while the intro was still running and "Click a node" appeared before the
  nodes did. Both now derive from the entrance's real computed length, which
  the handle exposes as durationMs.
- Skip sat bottom-right underneath the service-worker update toast (same z
  token, later in DOM order). Moved to bottom-centre; the toast got its own
  layer token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
TehRiehlDealandClaude Opus 5 committed 2026-08-09 01:24:13 -07:00
1 parent c4f1fd9f84
commit d8297cf903
8 files changed
+500 -204

No files matched your search

+1
View File
@@ -59,6 +59,7 @@
--z-panel-toggle: 950; --z-panel-toggle: 950;
--z-modal: 1000; --z-modal: 1000;
--z-toast: 1100; --z-toast: 1100;
--z-update-toast: 1150;
--z-tree-nav: 1200; --z-tree-nav: 1200;
--z-skip-link: 2000; --z-skip-link: 2000;
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
// First-run interaction hint. The tree gives no affordance that it can be
// dragged, zoomed or clicked — the icons look decorative. Shown once, then
// remembered, so it never nags a returning visitor.
import { fade } from 'svelte/transition';
import { prefersReducedMotion } from '$lib/config';
const { visible = false, onDismiss }: { visible?: boolean; onDismiss?: () => void } = $props();
const duration = prefersReducedMotion() ? 0 : 400;
</script>
{#if visible}
<div class="coach" transition:fade={{ duration }} role="status">
<span>Drag to explore</span>
<span class="sep" aria-hidden="true">·</span>
<span>Scroll to zoom</span>
<span class="sep" aria-hidden="true">·</span>
<span>Click a node for details</span>
<button onclick={() => onDismiss?.()} aria-label="Dismiss hint">✕</button>
</div>
{/if}
<style>
.coach {
position: fixed;
bottom: var(--space-5);
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
border: 1px solid rgba(255, 215, 0, 0.35);
border-radius: var(--radius-pill);
background: rgba(10, 10, 20, 0.88);
color: var(--text-muted);
font-size: 0.8rem;
white-space: nowrap;
}
.sep {
color: var(--color-gold);
}
.coach button {
margin-left: var(--space-1);
padding: 0 0.25rem;
border: 0;
background: none;
color: var(--text-subtle);
font: inherit;
cursor: pointer;
}
.coach button:hover {
color: var(--color-gold);
}
@media (max-width: 767px) {
.coach {
font-size: 0.7rem;
gap: var(--space-1);
padding: var(--space-2);
}
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
// Always-visible identity.
//
// The measured problem this solves: on a cold load the site showed a black
// screen for ~5s, then a wordless constellation of icons. At 9 seconds the
// number of words on screen was zero — no name, no title, no indication of
// what any of it was. A recruiter could not learn a single fact without
// clicking. This puts the 5-second pitch on screen immediately and keeps it
// there, sourced from the same origin node as everything else.
import { treeNodes, type OriginNode } from '$lib/data';
const {
showSkip = false,
onSkip
}: {
/** True while the intro is still playing. */
showSkip?: boolean;
onSkip?: () => void;
} = $props();
const origin = treeNodes.find((n): n is OriginNode => n.category === 'origin');
</script>
{#if origin}
<div class="hud">
<p class="hud-name">{origin.fullName}</p>
<p class="hud-title">{origin.jobTitle}</p>
{#if origin.tagline}
<p class="hud-tagline">{origin.tagline}</p>
{/if}
</div>
{/if}
{#if showSkip}
<button class="skip-intro" onclick={() => onSkip?.()}>Skip intro</button>
{/if}
<style>
.hud {
position: fixed;
top: var(--space-5);
left: calc(var(--space-5) + 68px); /* clears the stats toggle */
z-index: var(--z-welcome);
max-width: min(30rem, calc(100vw - 8rem));
pointer-events: none;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.9);
}
.hud-name {
margin: 0;
font-family: var(--font-display);
font-size: 1.35rem;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1.1;
color: var(--color-gold);
}
.hud-title {
margin: 0.15rem 0 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
}
.hud-tagline {
margin: 0.2rem 0 0;
font-size: 0.78rem;
line-height: 1.4;
color: var(--text-muted);
}
.skip-intro {
position: fixed;
bottom: var(--space-5);
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
padding: var(--space-2) var(--space-4);
border: 1px solid var(--color-gold);
border-radius: var(--radius-pill);
background: rgba(10, 10, 20, 0.85);
color: var(--color-gold);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out);
}
.skip-intro:hover {
background: rgba(255, 215, 0, 0.15);
}
@media (max-width: 767px) {
.hud {
top: auto;
bottom: var(--space-5);
left: var(--space-4);
right: var(--space-4);
max-width: none;
}
.hud-name {
font-size: 1.1rem;
}
.hud-tagline {
display: none; /* too little room to be worth the noise */
}
}
</style>
+66 -3
View File
@@ -10,7 +10,9 @@
import NodeModal from './NodeModal.svelte'; import NodeModal from './NodeModal.svelte';
import StatsPanel from './StatsPanel.svelte'; import StatsPanel from './StatsPanel.svelte';
import TreeNavigator from './TreeNavigator.svelte'; import TreeNavigator from './TreeNavigator.svelte';
import { MOBILE_BREAKPOINT } from '$lib/config'; import Hud from './Hud.svelte';
import CoachHint from './CoachHint.svelte';
import { MOBILE_BREAKPOINT, prefersReducedMotion } from '$lib/config';
import { import {
BRANCH_IDS, BRANCH_IDS,
calculatePlayerLevel, calculatePlayerLevel,
@@ -34,6 +36,35 @@
let selectedNode = $state<TreeNode | null>(null); let selectedNode = $state<TreeNode | null>(null);
let modalVisible = $state(false); let modalVisible = $state(false);
let showWelcomeTitle = $state(true); let showWelcomeTitle = $state(true);
// Intro state. A returning visitor should land on the settled tree rather
// than sit through the entrance again.
const VISITED_KEY = 'atlas:visited';
let introPlaying = $state(false);
let showCoachHint = $state(false);
let skipEntrance: (() => void) | null = null;
function hasVisitedBefore() {
try {
return localStorage.getItem(VISITED_KEY) === '1';
} catch {
return false; // private mode / storage disabled
}
}
function markVisited() {
try {
localStorage.setItem(VISITED_KEY, '1');
} catch {
/* not important enough to surface */
}
}
function skipIntro() {
skipEntrance?.();
introPlaying = false;
showWelcomeTitle = false;
markVisited();
}
// Tooltip state // Tooltip state
let hoveredNode = $state<TreeNode | null>(null); let hoveredNode = $state<TreeNode | null>(null);
@@ -275,14 +306,36 @@
if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame); if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame);
}); });
// Sequenced entrance animation // Sequenced entrance animation. Returning visitors skip straight to the
runEntranceAnimations(controller, { // settled tree; first-timers get the intro plus a Skip control.
const returning = hasVisitedBefore();
introPlaying = !returning && !prefersReducedMotion();
const entrance = runEntranceAnimations(controller, {
isMobile, isMobile,
life, life,
skipIntro: returning,
onTitleDone: () => { onTitleDone: () => {
showWelcomeTitle = false; showWelcomeTitle = false;
} }
}); });
skipEntrance = entrance.skip;
// Show the interaction hint once the tree is actually there to interact
// with — timed off the real entrance length, not a guess, so "Click a
// node" never appears before the nodes do and Skip never disappears
// while the intro is still running.
if (!returning) {
const hintTimer = setTimeout(() => {
introPlaying = false;
showCoachHint = true;
// Only now has the visitor actually seen the intro. Marking at
// mount instead would flag someone who bounced after a second as
// having seen it, and they'd never get it.
markVisited();
}, entrance.durationMs + 300);
life.add(() => clearTimeout(hintTimer));
}
} catch (err) { } catch (err) {
// No WebGL, or a texture failed to load. Don't strand the user on a // No WebGL, or a texture failed to load. Don't strand the user on a
// spinner that never resolves — reveal the text version instead. // spinner that never resolves — reveal the text version instead.
@@ -351,6 +404,16 @@
<LoadingIndicator /> <LoadingIndicator />
{/if} {/if}
<!-- Persistent identity: name, title and the one-line hook, always on screen -->
<Hud showSkip={introPlaying} onSkip={skipIntro} />
<CoachHint
visible={showCoachHint}
onDismiss={() => {
showCoachHint = false;
}}
/>
<!-- Welcome Title Card --> <!-- Welcome Title Card -->
{#if showWelcomeTitle} {#if showWelcomeTitle}
<WelcomeTitle /> <WelcomeTitle />
+6 -3
View File
@@ -51,9 +51,12 @@ export const PARTICLE_COUNTS = {
/** Entrance-animation timings (seconds), split by device. */ /** Entrance-animation timings (seconds), split by device. */
export const TIMINGS = { export const TIMINGS = {
titleDuration: { desktop: 2.5, mobile: 1.5 }, titleDuration: { desktop: 1.6, mobile: 1.1 },
zoomDuration: { desktop: 4.5, mobile: 2.5 }, // Was 4.5s desktop. Nothing is readable until this finishes, and it is the
nodeStagger: { desktop: 0.08, mobile: 0.05 } // dominant term in time-to-first-information.
zoomDuration: { desktop: 2, mobile: 1.5 },
// 0.08 x 56 nodes was 4.5s of cascade on its own, dominating the entrance.
nodeStagger: { desktop: 0.045, mobile: 0.03 }
} as const; } as const;
/** /**
+3
View File
@@ -15,6 +15,8 @@ export interface OriginNode extends BaseNode {
category: 'origin'; category: 'origin';
fullName?: string; fullName?: string;
jobTitle?: string; jobTitle?: string;
/** One-line hook for the persistent HUD — the 5-second pitch. */
tagline?: string;
aboutMe?: string; aboutMe?: string;
location?: string; location?: string;
email?: string; email?: string;
@@ -89,6 +91,7 @@ export const treeNodes: TreeNode[] = [
icon: '', icon: '',
fullName: 'Kevin Riehl', fullName: 'Kevin Riehl',
jobTitle: 'Lead Software Engineer', jobTitle: 'Lead Software Engineer',
tagline: 'WebRTC telehealth · MediaPipe accessibility · air-gapped CI',
aboutMe: aboutMe:
"Software engineer with 5+ years building full-stack applications for Defense Health Agency programs. I've architected a WebRTC telehealth platform, shipped real-time gesture recognition that made chat usable for speech-impaired patients, and built the deployment tooling that unblocked a team after an air-gapped infrastructure migration. React, Node.js, NestJS, TypeScript, SQL.", "Software engineer with 5+ years building full-stack applications for Defense Health Agency programs. I've architected a WebRTC telehealth platform, shipped real-time gesture recognition that made chat usable for speech-impaired patients, and built the deployment tooling that unblocked a team after an air-gapped infrastructure migration. React, Node.js, NestJS, TypeScript, SQL.",
location: 'Kent, WA', location: 'Kent, WA',
+244 -197
View File
@@ -4,25 +4,37 @@
import { Container } from 'pixi.js'; import { Container } from 'pixi.js';
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { getDistanceFromOrigin } from '$lib/utils/tree'; import { getDistanceFromOrigin } from '$lib/utils/tree';
import { prefersReducedMotion } from '$lib/config'; import { prefersReducedMotion, TIMINGS, byDevice } from '$lib/config';
import { updateParallax } from './interaction'; import { updateParallax } from './interaction';
import type { SkillTreeController } from './controller'; import type { SkillTreeController } from './controller';
import type { Lifecycle } from './lifecycle'; import type { Lifecycle } from './lifecycle';
/** Handle returned by runEntranceAnimations. */
export interface EntranceHandle {
/** Fast-forward the intro to its finished state. */
skip: () => void;
/** Total intro length in ms, so the UI can time Skip and the hint off it. */
durationMs: number;
}
interface EntranceOpts { interface EntranceOpts {
isMobile: boolean; isMobile: boolean;
life: Lifecycle; life: Lifecycle;
/** Returning visitor — land on the settled tree instead of replaying it. */
skipIntro?: boolean;
/** Called when the welcome title should be removed (matches its CSS fade). */ /** Called when the welcome title should be removed (matches its CSS fade). */
onTitleDone: () => void; onTitleDone: () => void;
} }
export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceOpts) { export function runEntranceAnimations(
ctrl: SkillTreeController,
opts: EntranceOpts
): EntranceHandle {
const { isMobile } = opts; const { isMobile } = opts;
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl; const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl;
// Reduced motion: show the finished tree immediately. No zoom, no stagger, /** Put the tree straight into its settled, interactive state. */
// and critically no `repeat: -1` pulses — a static scene, not a faster one. function showFinishedTree() {
if (prefersReducedMotion()) {
treeContainer.scale.set(endScale); treeContainer.scale.set(endScale);
for (const obj of nodeObjects) { for (const obj of nodeObjects) {
@@ -42,216 +54,251 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
if (toggle) toggle.style.opacity = '1'; if (toggle) toggle.style.opacity = '1';
opts.onTitleDone(); opts.onTitleDone();
return;
} }
// Responsive animation timing // Reduced motion, or a returning visitor who has already seen the intro:
const titleDuration = isMobile ? 1.5 : 2.5; // no zoom, no stagger, and no `repeat: -1` pulses.
const zoomDuration = isMobile ? 2.5 : 4.5; if (prefersReducedMotion() || opts.skipIntro) {
const nodeStagger = isMobile ? 0.05 : 0.08; showFinishedTree();
return { skip: () => {}, durationMs: 0 };
}
// Timings come from config so there is a single place to tune them. They used
// to be duplicated here as literals, so editing config.ts did nothing.
const titleDuration = byDevice(TIMINGS.titleDuration, isMobile);
const zoomDuration = byDevice(TIMINGS.zoomDuration, isMobile);
const nodeStagger = byDevice(TIMINGS.nodeStagger, isMobile);
// The entrance gets its own nested GSAP context so Skip can enumerate exactly
// its tweens. Nested contexts register with the parent, so the lifecycle
// still tears everything down in one call.
let entranceCtx: gsap.Context | null = null;
// The entrance allocates ~340 finite tweens plus ~170 `repeat: -1` pulses, // The entrance allocates ~340 finite tweens plus ~170 `repeat: -1` pulses,
// some created inside an onComplete. GSAP restores an animation's context // some created inside an onComplete. GSAP restores an animation's context
// while running its callbacks, so wrapping the whole sequence here makes // while running its callbacks, so wrapping the whole sequence here makes
// even those late-created pulses killable via the lifecycle. // even those late-created pulses killable via the lifecycle.
opts.life.animate(() => { opts.life.animate(() => {
// Hide all lines and plasma beams initially entranceCtx = gsap.context(() => {
lineObjects.forEach((line) => { // Hide all lines and plasma beams initially
line.graphics.alpha = 0; lineObjects.forEach((line) => {
line.glowGraphics.alpha = 0; line.graphics.alpha = 0;
line.plasmaBeam.alpha = 0; line.glowGraphics.alpha = 0;
}); line.plasmaBeam.alpha = 0;
// Hide all nodes initially
nodeObjects.forEach((obj) => {
obj.graphics.alpha = 0;
obj.graphics.scale.set(0.5);
obj.glowGraphics.alpha = 0;
});
// Sort by distance from origin
const sortedNodes = [...nodeObjects].sort(
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
);
const sortedLines = [...lineObjects].sort(
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
);
// STEP 1: After title fades, show origin node first (overlap on mobile)
const originNode = sortedNodes.find((obj) => obj.node.id === 'origin');
const originDelay = isMobile ? titleDuration - 0.5 : titleDuration;
const originDuration = isMobile ? 0.3 : 0.6;
if (originNode && isMobile) {
originNode.graphics.scale.set(0.8); // Start slightly larger
}
if (originNode) {
gsap.to(originNode.graphics, {
alpha: 1,
duration: originDuration,
delay: originDelay,
ease: 'power2.out'
}); });
gsap.to(originNode.graphics.scale, {
x: 1, // Hide all nodes initially
y: 1, nodeObjects.forEach((obj) => {
duration: originDuration, obj.graphics.alpha = 0;
delay: originDelay, obj.graphics.scale.set(0.5);
ease: 'back.out(1.7)', obj.glowGraphics.alpha = 0;
onComplete: () => { });
if (originNode.graphics instanceof Container) {
originNode.graphics.eventMode = 'static'; // Sort by distance from origin
const sortedNodes = [...nodeObjects].sort(
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
);
const sortedLines = [...lineObjects].sort(
(a, b) => getDistanceFromOrigin(a.node) - getDistanceFromOrigin(b.node)
);
// STEP 1: After title fades, show origin node first (overlap on mobile)
const originNode = sortedNodes.find((obj) => obj.node.id === 'origin');
const originDelay = isMobile ? titleDuration - 0.5 : titleDuration;
const originDuration = isMobile ? 0.3 : 0.6;
if (originNode && isMobile) {
originNode.graphics.scale.set(0.8); // Start slightly larger
}
if (originNode) {
gsap.to(originNode.graphics, {
alpha: 1,
duration: originDuration,
delay: originDelay,
ease: 'power2.out'
});
gsap.to(originNode.graphics.scale, {
x: 1,
y: 1,
duration: originDuration,
delay: originDelay,
ease: 'back.out(1.7)',
onComplete: () => {
if (originNode.graphics instanceof Container) {
originNode.graphics.eventMode = 'static';
}
} }
} });
gsap.to(originNode.glowGraphics, {
alpha: 1,
duration: originDuration,
delay: originDelay,
ease: 'power2.out'
});
}
// STEP 2: Start zoom out after origin appears
const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3;
gsap.to(treeContainer.scale, {
x: endScale,
y: endScale,
duration: zoomDuration,
delay: zoomDelay,
ease: 'power1.out',
onUpdate: () => updateParallax(ctrl)
}); });
gsap.to(originNode.glowGraphics, {
alpha: 1,
duration: originDuration,
delay: originDelay,
ease: 'power2.out'
});
}
// STEP 2: Start zoom out after origin appears // STEP 3: Animate in the rest of the nodes (excluding origin)
const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3; const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin');
const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5;
gsap.to(treeContainer.scale, { otherNodes.forEach((obj, index) => {
x: endScale, const delay = nodeStartDelay + index * nodeStagger;
y: endScale,
duration: zoomDuration,
delay: zoomDelay,
ease: 'power1.out',
onUpdate: () => updateParallax(ctrl)
});
// STEP 3: Animate in the rest of the nodes (excluding origin) gsap.to(obj.graphics, {
const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin'); alpha: 1,
const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5; duration: 0.5,
delay,
otherNodes.forEach((obj, index) => { ease: 'power2.out'
const delay = nodeStartDelay + index * nodeStagger; });
gsap.to(obj.graphics.scale, {
gsap.to(obj.graphics, { x: 1,
alpha: 1, y: 1,
duration: 0.5, duration: 0.5,
delay, delay,
ease: 'power2.out' ease: 'back.out(1.7)',
}); onComplete: () => {
gsap.to(obj.graphics.scale, { if (obj.graphics instanceof Container) {
x: 1, obj.graphics.eventMode = 'static';
y: 1, }
duration: 0.5,
delay,
ease: 'back.out(1.7)',
onComplete: () => {
if (obj.graphics instanceof Container) {
obj.graphics.eventMode = 'static';
} }
} });
}); gsap.to(obj.glowGraphics, {
gsap.to(obj.glowGraphics, { alpha: 1,
alpha: 1, duration: 0.5,
duration: 0.5, delay,
delay, ease: 'power2.out'
ease: 'power2.out' });
// Start ambient pulse after node appears
gsap.to(obj.glowGraphics.scale, {
x: 1.4,
y: 1.4,
duration: 2 + Math.random() * 1,
delay: delay + 0.5,
ease: 'sine.inOut',
yoyo: true,
repeat: -1
});
}); });
// Start ambient pulse after node appears // Start origin pulse too
gsap.to(obj.glowGraphics.scale, { if (originNode) {
x: 1.4, gsap.to(originNode.glowGraphics.scale, {
y: 1.4, x: 1.4,
duration: 2 + Math.random() * 1, y: 1.4,
delay: delay + 0.5, duration: 2 + Math.random() * 1,
ease: 'sine.inOut', delay: titleDuration + 1,
yoyo: true, ease: 'sine.inOut',
repeat: -1 yoyo: true,
repeat: -1
});
}
// STEP 4: Animate lines and plasma beams as nodes appear
const lineStartDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.4;
sortedLines.forEach((line, index) => {
const delay = lineStartDelay + index * nodeStagger;
// Core line appears first
gsap.to(line.graphics, {
alpha: 1,
duration: 0.4,
delay,
ease: 'power2.out'
});
// Base glow appears with line
gsap.to(line.glowGraphics, {
alpha: 1,
duration: 0.6,
delay: delay + 0.1,
ease: 'power2.out'
});
// Plasma beam fades in, then breathes continuously
gsap.to(line.plasmaBeam, {
alpha: 1,
duration: 0.8,
delay: delay + 0.2,
ease: 'power2.out',
onComplete: () => {
const pulseDuration = 2.0 + Math.random() * 1.0; // Vary pulse speed
const minAlpha = 0.6 * line.intensity; // Minimum brightness - stays visible
gsap.to(line.plasmaBeam, {
alpha: minAlpha,
duration: pulseDuration,
ease: 'sine.inOut',
yoyo: true,
repeat: -1
});
const glowMinAlpha = 0.12 * line.intensity;
gsap.to(line.glowGraphics, {
alpha: glowMinAlpha,
duration: pulseDuration * 1.2,
ease: 'sine.inOut',
yoyo: true,
repeat: -1
});
}
});
}); });
// Clean up title card after animation (matches titleDuration)
const titleTimer = setTimeout(
() => {
opts.onTitleDone();
},
isMobile ? 1500 : 2500
);
opts.life.add(() => clearTimeout(titleTimer));
// Fade in stats panel toggle button with the tree
const toggleButton = document.querySelector('.stats-panel-toggle');
if (toggleButton) {
gsap.to(toggleButton, {
opacity: 1,
duration: 0.5,
delay: nodeStartDelay + otherNodes.length * nodeStagger * 0.5, // midway through node animation
ease: 'power2.out'
});
}
}); });
// Start origin pulse too
if (originNode) {
gsap.to(originNode.glowGraphics.scale, {
x: 1.4,
y: 1.4,
duration: 2 + Math.random() * 1,
delay: titleDuration + 1,
ease: 'sine.inOut',
yoyo: true,
repeat: -1
});
}
// STEP 4: Animate lines and plasma beams as nodes appear
const lineStartDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.4;
sortedLines.forEach((line, index) => {
const delay = lineStartDelay + index * nodeStagger;
// Core line appears first
gsap.to(line.graphics, {
alpha: 1,
duration: 0.4,
delay,
ease: 'power2.out'
});
// Base glow appears with line
gsap.to(line.glowGraphics, {
alpha: 1,
duration: 0.6,
delay: delay + 0.1,
ease: 'power2.out'
});
// Plasma beam fades in, then breathes continuously
gsap.to(line.plasmaBeam, {
alpha: 1,
duration: 0.8,
delay: delay + 0.2,
ease: 'power2.out',
onComplete: () => {
const pulseDuration = 2.0 + Math.random() * 1.0; // Vary pulse speed
const minAlpha = 0.6 * line.intensity; // Minimum brightness - stays visible
gsap.to(line.plasmaBeam, {
alpha: minAlpha,
duration: pulseDuration,
ease: 'sine.inOut',
yoyo: true,
repeat: -1
});
const glowMinAlpha = 0.12 * line.intensity;
gsap.to(line.glowGraphics, {
alpha: glowMinAlpha,
duration: pulseDuration * 1.2,
ease: 'sine.inOut',
yoyo: true,
repeat: -1
});
}
});
});
// Clean up title card after animation (matches titleDuration)
const titleTimer = setTimeout(
() => {
opts.onTitleDone();
},
isMobile ? 1500 : 2500
);
opts.life.add(() => clearTimeout(titleTimer));
// Fade in stats panel toggle button with the tree
const toggleButton = document.querySelector('.stats-panel-toggle');
if (toggleButton) {
gsap.to(toggleButton, {
opacity: 1,
duration: 0.5,
delay: nodeStartDelay + otherNodes.length * nodeStagger * 0.5, // midway through node animation
ease: 'power2.out'
});
}
}); });
// When the last node/line finishes appearing. Derived rather than guessed, so
// the Skip control and the coach hint stay in step with the real animation.
const lastIndex = Math.max(nodeObjects.length, lineObjects.length) - 1;
const durationMs = Math.round(
(byDevice(TIMINGS.titleDuration, isMobile) + 0.5 + lastIndex * nodeStagger + 0.5) * 1000
);
return {
durationMs,
skip() {
// progress(1) completes each finite tween AND fires its onComplete —
// which is what re-enables node interactivity and starts the ambient
// pulses. So skipping lands in the same state as watching it through.
// Infinite pulses are left alone; fast-forwarding them is meaningless.
for (const tween of entranceCtx?.getTweens() ?? []) {
if (tween.repeat() === -1) continue;
tween.progress(1);
}
}
};
} }
+1 -1
View File
@@ -226,7 +226,7 @@
position: fixed; position: fixed;
right: 1rem; right: 1rem;
bottom: 1rem; bottom: 1rem;
z-index: var(--z-toast); z-index: var(--z-update-toast);
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;