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:
1 parent
c4f1fd9f84
commit
d8297cf903
8 files changed
+313
-17
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user