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
+313 -17

No files matched your search

+1
View File
@@ -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;
+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 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<TreeNode | null>(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<TreeNode | null>(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 @@
<LoadingIndicator />
{/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 -->
{#if showWelcomeTitle}
<WelcomeTitle />
+6 -3
View File
@@ -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;
/**
+3
View File
@@ -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',
+57 -10
View File
@@ -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,19 +54,32 @@ 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(() => {
entranceCtx = gsap.context(() => {
// Hide all lines and plasma beams initially
lineObjects.forEach((line) => {
line.graphics.alpha = 0;
@@ -254,4 +279,26 @@ export function runEntranceAnimations(ctrl: SkillTreeController, opts: EntranceO
});
}
});
});
// 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;
right: 1rem;
bottom: 1rem;
z-index: var(--z-toast);
z-index: var(--z-update-toast);
display: flex;
gap: 0.75rem;
align-items: center;