Files
skill-tree-portfolio/src/app.css
T
TehRiehlDealandClaude Opus 5 d8297cf903 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>
2026-08-09 01:24:13 -07:00

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;
}