diff --git a/src/app.css b/src/app.css
index d8a4bcf..907d867 100644
--- a/src/app.css
+++ b/src/app.css
@@ -59,6 +59,7 @@
--z-panel-toggle: 950;
--z-modal: 1000;
--z-toast: 1100;
+ --z-update-toast: 1150;
--z-tree-nav: 1200;
--z-skip-link: 2000;
diff --git a/src/lib/components/CoachHint.svelte b/src/lib/components/CoachHint.svelte
new file mode 100644
index 0000000..d5bf79c
--- /dev/null
+++ b/src/lib/components/CoachHint.svelte
@@ -0,0 +1,68 @@
+
+
+{#if visible}
+
+ Drag to explore
+ ·
+ Scroll to zoom
+ ·
+ Click a node for details
+
+
+{/if}
+
+
diff --git a/src/lib/components/Hud.svelte b/src/lib/components/Hud.svelte
new file mode 100644
index 0000000..1a52542
--- /dev/null
+++ b/src/lib/components/Hud.svelte
@@ -0,0 +1,111 @@
+
+
+{#if origin}
+
+
{origin.fullName}
+
{origin.jobTitle}
+ {#if origin.tagline}
+
{origin.tagline}
+ {/if}
+
+{/if}
+
+{#if showSkip}
+
+{/if}
+
+
diff --git a/src/lib/components/SkillTree.svelte b/src/lib/components/SkillTree.svelte
index 9fc6dc9..d98193f 100644
--- a/src/lib/components/SkillTree.svelte
+++ b/src/lib/components/SkillTree.svelte
@@ -10,7 +10,9 @@
import NodeModal from './NodeModal.svelte';
import StatsPanel from './StatsPanel.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 {
BRANCH_IDS,
calculatePlayerLevel,
@@ -34,6 +36,35 @@
let selectedNode = $state(null);
let modalVisible = $state(false);
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
let hoveredNode = $state(null);
@@ -275,14 +306,36 @@
if (resizeFrame !== -1) cancelAnimationFrame(resizeFrame);
});
- // Sequenced entrance animation
- runEntranceAnimations(controller, {
+ // Sequenced entrance animation. Returning visitors skip straight to the
+ // settled tree; first-timers get the intro plus a Skip control.
+ const returning = hasVisitedBefore();
+ introPlaying = !returning && !prefersReducedMotion();
+
+ const entrance = runEntranceAnimations(controller, {
isMobile,
life,
+ skipIntro: returning,
onTitleDone: () => {
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) {
// No WebGL, or a texture failed to load. Don't strand the user on a
// spinner that never resolves — reveal the text version instead.
@@ -351,6 +404,16 @@
{/if}
+
+
+
+ {
+ showCoachHint = false;
+ }}
+/>
+
{#if showWelcomeTitle}
diff --git a/src/lib/config.ts b/src/lib/config.ts
index a7158da..bb79799 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -51,9 +51,12 @@ export const PARTICLE_COUNTS = {
/** Entrance-animation timings (seconds), split by device. */
export const TIMINGS = {
- titleDuration: { desktop: 2.5, mobile: 1.5 },
- zoomDuration: { desktop: 4.5, mobile: 2.5 },
- nodeStagger: { desktop: 0.08, mobile: 0.05 }
+ titleDuration: { desktop: 1.6, mobile: 1.1 },
+ // Was 4.5s desktop. Nothing is readable until this finishes, and it is the
+ // 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;
/**
diff --git a/src/lib/data.ts b/src/lib/data.ts
index 9d50553..55816e5 100644
--- a/src/lib/data.ts
+++ b/src/lib/data.ts
@@ -15,6 +15,8 @@ export interface OriginNode extends BaseNode {
category: 'origin';
fullName?: string;
jobTitle?: string;
+ /** One-line hook for the persistent HUD — the 5-second pitch. */
+ tagline?: string;
aboutMe?: string;
location?: string;
email?: string;
@@ -89,6 +91,7 @@ export const treeNodes: TreeNode[] = [
icon: '',
fullName: 'Kevin Riehl',
jobTitle: 'Lead Software Engineer',
+ tagline: 'WebRTC telehealth · MediaPipe accessibility · air-gapped CI',
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.",
location: 'Kent, WA',
diff --git a/src/lib/pixi/animation.ts b/src/lib/pixi/animation.ts
index ca01f14..a1c7506 100644
--- a/src/lib/pixi/animation.ts
+++ b/src/lib/pixi/animation.ts
@@ -4,25 +4,37 @@
import { Container } from 'pixi.js';
import { gsap } from 'gsap';
import { getDistanceFromOrigin } from '$lib/utils/tree';
-import { prefersReducedMotion } from '$lib/config';
+import { prefersReducedMotion, TIMINGS, byDevice } from '$lib/config';
import { updateParallax } from './interaction';
import type { SkillTreeController } from './controller';
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 {
isMobile: boolean;
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). */
onTitleDone: () => void;
}
-export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceOpts) {
+export function runEntranceAnimations(
+ ctrl: SkillTreeController,
+ opts: EntranceOpts
+): EntranceHandle {
const { isMobile } = opts;
const { treeContainer, nodeObjects, lineObjects, endScale } = ctrl;
- // Reduced motion: show the finished tree immediately. No zoom, no stagger,
- // and critically no `repeat: -1` pulses — a static scene, not a faster one.
- if (prefersReducedMotion()) {
+ /** Put the tree straight into its settled, interactive state. */
+ function showFinishedTree() {
treeContainer.scale.set(endScale);
for (const obj of nodeObjects) {
@@ -42,216 +54,251 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
if (toggle) toggle.style.opacity = '1';
opts.onTitleDone();
- return;
}
- // Responsive animation timing
- const titleDuration = isMobile ? 1.5 : 2.5;
- const zoomDuration = isMobile ? 2.5 : 4.5;
- const nodeStagger = isMobile ? 0.05 : 0.08;
+ // Reduced motion, or a returning visitor who has already seen the intro:
+ // no zoom, no stagger, and no `repeat: -1` pulses.
+ if (prefersReducedMotion() || opts.skipIntro) {
+ 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,
// some created inside an onComplete. GSAP restores an animation's context
// while running its callbacks, so wrapping the whole sequence here makes
// even those late-created pulses killable via the lifecycle.
opts.life.animate(() => {
- // Hide all lines and plasma beams initially
- lineObjects.forEach((line) => {
- line.graphics.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'
+ entranceCtx = gsap.context(() => {
+ // Hide all lines and plasma beams initially
+ lineObjects.forEach((line) => {
+ line.graphics.alpha = 0;
+ line.glowGraphics.alpha = 0;
+ line.plasmaBeam.alpha = 0;
});
- 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';
+
+ // 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,
+ 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
- const zoomDelay = isMobile ? titleDuration - 0.3 : titleDuration + 0.3;
+ // STEP 3: Animate in the rest of the nodes (excluding origin)
+ const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin');
+ const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5;
- gsap.to(treeContainer.scale, {
- x: endScale,
- y: endScale,
- duration: zoomDuration,
- delay: zoomDelay,
- ease: 'power1.out',
- onUpdate: () => updateParallax(ctrl)
- });
+ otherNodes.forEach((obj, index) => {
+ const delay = nodeStartDelay + index * nodeStagger;
- // STEP 3: Animate in the rest of the nodes (excluding origin)
- const otherNodes = sortedNodes.filter((obj) => obj.node.id !== 'origin');
- const nodeStartDelay = isMobile ? titleDuration - 0.2 : titleDuration + 0.5;
-
- otherNodes.forEach((obj, index) => {
- const delay = nodeStartDelay + index * nodeStagger;
-
- gsap.to(obj.graphics, {
- alpha: 1,
- duration: 0.5,
- delay,
- ease: 'power2.out'
- });
- gsap.to(obj.graphics.scale, {
- x: 1,
- y: 1,
- duration: 0.5,
- delay,
- ease: 'back.out(1.7)',
- onComplete: () => {
- if (obj.graphics instanceof Container) {
- obj.graphics.eventMode = 'static';
+ gsap.to(obj.graphics, {
+ alpha: 1,
+ duration: 0.5,
+ delay,
+ ease: 'power2.out'
+ });
+ gsap.to(obj.graphics.scale, {
+ x: 1,
+ 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, {
- alpha: 1,
- duration: 0.5,
- delay,
- ease: 'power2.out'
+ });
+ gsap.to(obj.glowGraphics, {
+ alpha: 1,
+ duration: 0.5,
+ delay,
+ 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
- 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 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'
+ });
+ }
});
-
- // 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);
+ }
+ }
+ };
}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index cd554ec..5f25dcd 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -226,7 +226,7 @@
position: fixed;
right: 1rem;
bottom: 1rem;
- z-index: var(--z-toast);
+ z-index: var(--z-update-toast);
display: flex;
gap: 0.75rem;
align-items: center;