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>
216 lines
5.1 KiB
CSS
216 lines
5.1 KiB
CSS
/*
|
|
* Theme tokens — single source of truth for colours is src/lib/theme.ts.
|
|
* These custom properties mirror its `cssVars` map; keep the two in sync.
|
|
*/
|
|
:root {
|
|
--color-gold: #ffd700;
|
|
--color-experience: #4ecdc4;
|
|
--color-projects: #ff6b6b;
|
|
--color-skills: #f9a825;
|
|
--color-education: #dda0dd;
|
|
--bg-space: #05050a;
|
|
--panel-from: #1a1a2e;
|
|
--panel-to: #0f0f1a;
|
|
|
|
/*
|
|
* Text colours, with measured contrast against --panel-from (#1a1a2e):
|
|
* --text-primary 14.0:1
|
|
* --text-muted 7.3:1
|
|
* --text-subtle 6.1:1
|
|
* The previous #666 used for hints, counts and the modal close glyph was
|
|
* 2.97:1 — below the 4.5:1 AA threshold for normal text.
|
|
*/
|
|
--text-primary: #e8e8f0;
|
|
--text-muted: #a8a8b8;
|
|
--text-subtle: #9a9aa8;
|
|
|
|
/*
|
|
* Type. Cinzel is a Trajan-style inscriptional face — display sizes only;
|
|
* body copy stays on the system stack, which costs nothing to load and is
|
|
* far more legible at 13-14px. Before this, the single font-family
|
|
* declaration in the codebase named two fonts that were never loaded, so
|
|
* every word rendered in the browser's default serif.
|
|
*/
|
|
--font-display: 'Cinzel Variable', 'Cinzel', 'Trajan Pro', Georgia, serif;
|
|
--font-body: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
|
|
/* Spacing scale */
|
|
--space-1: 0.25rem;
|
|
--space-2: 0.5rem;
|
|
--space-3: 0.75rem;
|
|
--space-4: 1rem;
|
|
--space-5: 1.5rem;
|
|
--space-6: 2rem;
|
|
|
|
/* Radii */
|
|
--radius-sm: 6px;
|
|
--radius-md: 8px;
|
|
--radius-lg: 12px;
|
|
--radius-pill: 999px;
|
|
|
|
/*
|
|
* Layering. These were ad-hoc literals scattered across seven files, with
|
|
* LoadingIndicator and the modal backdrop both sitting on 1000.
|
|
*/
|
|
--z-welcome: 100;
|
|
--z-tooltip: 500;
|
|
--z-panel-backdrop: 899;
|
|
--z-panel: 900;
|
|
--z-panel-toggle: 950;
|
|
--z-modal: 1000;
|
|
--z-toast: 1100;
|
|
--z-update-toast: 1150;
|
|
--z-tree-nav: 1200;
|
|
--z-skip-link: 2000;
|
|
|
|
/* Motion */
|
|
--ease-out: cubic-bezier(0.4, 0, 0.2, 1);
|
|
--duration-fast: 0.15s;
|
|
--duration-base: 0.3s;
|
|
}
|
|
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
width: 100%;
|
|
height: 100%;
|
|
font-family: var(--font-body);
|
|
color: var(--text-primary);
|
|
margin: 0;
|
|
padding: 0;
|
|
overflow: hidden;
|
|
background: var(--bg-space);
|
|
}
|
|
|
|
/*
|
|
* Focus indicators. These used to live in SkillTree.svelte's *scoped* style
|
|
* block, so Svelte's scoping meant they never reached NodeModal or StatsPanel
|
|
* children — those fell back to the UA default outline, which is low-contrast
|
|
* on this background. Global is the only place this rule works.
|
|
*/
|
|
:focus-visible {
|
|
outline: 2px solid var(--color-gold);
|
|
outline-offset: 2px;
|
|
border-radius: 2px;
|
|
}
|
|
|
|
/*
|
|
* Reduced motion. The JS side (see config.prefersReducedMotion) skips the
|
|
* entrance and never creates the ambient GSAP loops; this covers the CSS
|
|
* animations and transitions those can't reach.
|
|
*/
|
|
@media (prefers-reduced-motion: reduce) {
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
scroll-behavior: auto !important;
|
|
}
|
|
}
|
|
|
|
/*
|
|
* Visually-hidden but available to screen readers and crawlers. Used for the
|
|
* text version of the portfolio (the interactive content is canvas-rendered).
|
|
*/
|
|
/*
|
|
* ...but focusable content inside it must become visible when focused, or a
|
|
* sighted keyboard user tabs through ~20 invisible stops with no idea where
|
|
* focus is (WCAG 2.4.7). Standard sr-only-focusable treatment.
|
|
*/
|
|
.sr-only:focus-within {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
z-index: var(--z-skip-link);
|
|
width: auto;
|
|
height: auto;
|
|
max-width: min(90vw, 60rem);
|
|
max-height: 90vh;
|
|
margin: 0;
|
|
overflow: auto;
|
|
clip: auto;
|
|
white-space: normal;
|
|
padding: 1.5rem;
|
|
background: linear-gradient(145deg, var(--panel-from) 0%, var(--panel-to) 100%);
|
|
border: 1px solid var(--color-gold);
|
|
border-radius: 0 0 8px 0;
|
|
color: #e8e8f0;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.sr-only:focus-within a {
|
|
color: var(--color-gold);
|
|
}
|
|
|
|
.sr-only:focus-within h1,
|
|
.sr-only:focus-within h2,
|
|
.sr-only:focus-within h3 {
|
|
font-family: var(--font-display);
|
|
letter-spacing: 0.02em;
|
|
color: var(--color-gold);
|
|
margin: 1rem 0 0.5rem;
|
|
}
|
|
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border: 0;
|
|
}
|
|
|
|
/*
|
|
* Un-hides the text version when the canvas can't run — no JS, or no WebGL.
|
|
* Applied by <noscript> (see +layout.svelte) and by the render-failure path in
|
|
* SkillTree.svelte. Also restores scrolling, which is disabled globally above
|
|
* for the canvas view.
|
|
*/
|
|
.sr-only.revealed {
|
|
position: static;
|
|
width: auto;
|
|
height: auto;
|
|
margin: 0;
|
|
overflow: visible;
|
|
clip: auto;
|
|
white-space: normal;
|
|
max-width: 60rem;
|
|
padding: 2rem 1.5rem;
|
|
color: #e8e8f0;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
html:has(.sr-only.revealed),
|
|
html:has(.sr-only.revealed) body {
|
|
overflow: auto;
|
|
}
|
|
|
|
.sr-only.revealed a {
|
|
color: var(--color-gold);
|
|
}
|
|
|
|
.sr-only.revealed h1,
|
|
.sr-only.revealed h2,
|
|
.sr-only.revealed h3 {
|
|
font-family: var(--font-display);
|
|
letter-spacing: 0.02em;
|
|
color: var(--color-gold);
|
|
margin: 1.25rem 0 0.5rem;
|
|
}
|
|
|
|
.sr-only.revealed ul {
|
|
padding-left: 1.25rem;
|
|
}
|