75d058495200c361aa5ec61e2e66a105946d4a49
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
75d0584952 |
a11y: audit phase 3 — keyboard access, reduced motion, focus management
The site claimed comprehensive accessibility. It had a Level A keyboard failure, no reduced-motion support of any kind, and a dialog whose ARIA actively lied about the page state. Keyboard access to nodes (WCAG 2.1.1, Level A): - Nodes are PixiJS Containers with no DOM representation, so there was no way to reach or activate one by keyboard. The only keyboard vocabulary was pan/zoom/reset. New TreeNavigator.svelte renders the same data.ts as a real WAI-ARIA tree: roving tabindex, Up/Down traversal, Right/Left to expand and collapse, Home/End, Enter/Space to open the same modal a click opens. Focusing a row pans the canvas to that node so the two stay in sync. - The canvas advertised role="application", which tells screen readers to suppress their own navigation and forward every key — appropriate only for a widget implementing a full keyboard model, which it did not. Now role="img" with a description, still focusable for pan/zoom. - .sr-only content is revealed on :focus-within. Previously ~20 links were in the tab order while clipped to 1x1px, so a sighted keyboard user tabbed into nothing (WCAG 2.4.7). Added a skip link as the first focusable element. Focus management: - The modal set aria-modal="true" — telling AT that everything outside does not exist — while focus stayed on the canvas behind it and Tab walked the hidden text version underneath. Focus now moves into the dialog on open, is trapped there, and is restored to the trigger on close. - Removed two onkeydown Escape handlers (modal + stats backdrop) that could never fire: a role="presentation" div with no tabindex is never a keydown target. Their aria-labels were discarded by the role too. - The 300ms mobile touch-bleed guard was applied to every close path, so Escape or the X immediately after opening silently did nothing. It now guards only outside-dismissal, which is what it was for. Reduced motion (WCAG 2.3.3 / 2.2.2): - Previously respected nowhere, on a site built from a 4.5s forced zoom, an ~8s staggered entrance, ~480 looping tweens and a continuous dust ticker. The entrance now jumps to the finished state, the dust ticker is not registered, and all six ambient loops route through one guard that simply never creates them. Plus the CSS media query the GSAP tweens can't reach. Other: - StatsPanel used aria-hidden while containing tabindex="0" children — an axe aria-hidden-focus violation. Now `inert`, which removes it from the a11y tree and the tab order together. - Branch headers were div+role="button" handling Enter but not Space. Now real buttons with aria-controls. - The global keydown listener had no scope guard: it preventDefault()ed arrow keys unconditionally, so the 380px scrollable stats panel could not be scrolled by keyboard, and Ctrl combos were not excluded. Ctrl+wheel (browser and trackpad zoom) is no longer swallowed either. - Focus rings lived in SkillTree's *scoped* style block, so they never reached NodeModal or StatsPanel children. Moved to app.css. - Second <h1> in StatsPanel demoted; the page heading is SeoContent's. - getBeamIntensity used a truthiness check on yearsOfExperience, so 0 fell through to the default. Tests 16 -> 27: reduced-motion helper (all four branches), hierarchy building including cycle/orphan handling, and a data-integrity check that every parentId resolves to a real node. Verified in the production build: Tab reveals the text version with a visible focus ring; arrow keys traverse the tree and expand/collapse (rows 5->6); Enter opens the modal with focus landing on Close; Shift+Tab stays trapped; Escape restores focus to the originating tree item. Not verified end-to-end: the reduced-motion JS branch. This environment can't emulate the media query — patching matchMedia after navigation always races module init — so that branch is covered by unit tests and review, not observation. Worth a manual pass with DevTools > Rendering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
746d81b151 |
fix: audit phase 2 — teardown, failure paths, service worker, tooltip, resize
Correctness phase of the frontend audit. Nothing here is visible on a happy
path; it is what the app does when something goes wrong, when the window
changes, or when the component goes away.
Teardown (new src/lib/pixi/lifecycle.ts):
- Nothing was ever released: no app.destroy(), no ticker.remove(), no
killTweensOf, no onDestroy. ~480 GSAP tweens (most repeat:-1), a ticker
callback, and a dozen DOM listeners lived forever.
- 112 of those tweens are created inside an onComplete, so they don't exist
when a call-site sweep would run. gsap.context() handles exactly this:
GSAP records each animation's context on creation and restores it while
invoking that animation's callbacks (see _callback in gsap-core), so
late-created pulses land in the same context. One ctx.kill() gets all of
them. kill(), not revert(), so GSAP doesn't write properties back onto
objects about to be destroyed.
- All DOM listeners now go through life.listen(), which registers removal at
the same time. Previously 6 canvas listeners were never removed, each
closing over the whole ~713-object scene graph.
- The unmount race: cleanup used to be registered on the LAST line of an
async onMount, after the asset waterfall, so unmounting mid-load cleaned up
nothing. The lifecycle is now created before any await, with life.disposed
guards after each one.
- Hover tweens re-enter the context per event and use overwrite:'auto' so
rapid sweeps don't stack conflicting tweens.
Failure paths:
- onMount had no try/catch and no WebGL detection. Any init or asset failure
left the spinner up forever, the stats toggle at opacity:0, and Escape dead
(attachKeyboard never ran). Now caught, and it hands off to +page.svelte to
reveal the text version.
- There was no <noscript> anywhere, and SeoContent is clipped to 1x1px with
html/body overflow:hidden — so JS-disabled visitors got a pure black page.
A <noscript><style> block now un-hides it with no JS involved.
Service worker (moved static/ -> src/, so SvelteKit builds it):
- It can now use $service-worker, so precache comes from the real build
manifest instead of a hand-maintained 5-entry list that omitted all JS/CSS,
and the cache name embeds `version` so old caches actually get pruned.
- cache.addAll was atomic: one 404 failed the whole install silently. Now
per-asset with a warning on partial failure.
- The catch returned undefined for non-image assets, which makes
respondWith throw a TypeError — a soft offline failure became a hard one.
Every path now resolves to a Response.
- Unhashed assets (icons, headshot, resume PDF) were cache-first with no
expiry, so replacing the resume at the same URL never reached a returning
visitor. Those are network-first now; only hashed build output is
cache-first.
- Navigation responses were cached without a status check, so a 404/500 body
could become the offline fallback.
- Dropped skipWaiting() from install and added a real update prompt +
controllerchange reload. The SKIP_WAITING handler was previously dead code
and the "check for updates periodically" comment described an interval that
did not exist.
Tooltip:
- Wrote Svelte state on every canvas mousemove even with nothing hovered.
Now gated on hover and coalesced to one write per frame; raw coords are
still tracked cheaply so the first hover isn't positioned from stale data.
- Animated left/top (layout + paint per frame on an element with two
color-mix shadows); now translate3d.
- Listener moved to window, so coordinates no longer freeze when a drag
leaves the canvas.
- Flip logic measured a hardcoded 280x120; now measures the real element
(actual height is 113, so tall tooltips mis-flipped near the bottom edge).
- Dropped role="tooltip" + aria-live="polite", which announced the full
label and description on every hover. Nothing referenced it via
aria-describedby, so the role was inert. Now aria-hidden.
Resize:
- Star positions, gradient radius, vignette size and dust wrap bounds were
all baked at first paint, so enlarging the window left an unlit band. The
background is now rebuilt on a rAF-coalesced resize, under its own
sub-lifecycle. The view is shifted by half the delta rather than re-centred,
so the user's current view stays anchored.
- isTouchDevice used 'ontouchstart', true on touch-capable laptops and thus
permanently killing mouse hover. Now matchMedia('(hover: hover)').
Known gap: node radii still don't change when the 768px breakpoint is
crossed mid-session; re-creating all 57 nodes would reset interaction state.
Verified in the production build: resize 1278x1220 -> 1734x821 rebuilds with
zero errors, tooltip positions via transform and stays on screen, fallback
renders as a readable resume page. svelte-check clean, lint clean, 14/14.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
84112d8e42 |
refactor: extract PixiJS rendering into pixi/ modules
Phase 5 (behavior-preserving): decompose the ~1000-line onMount into focused
modules coordinated by a plain (non-reactive) controller object:
- pixi/types.ts LineObject / NodeObject / BackgroundLayers
- pixi/controller.ts shared mutable pan/zoom state + Pixi refs (NOT $state,
which would break PixiJS object identity)
- pixi/background.ts gradient/nebula/stars/dust/vignette + ambient loops
- pixi/lines.ts plasma-beam connections
- pixi/nodes.ts origin + regular nodes; collapses the duplicated pointer
handlers into one attachNodeInteraction helper
- pixi/interaction.ts pan/zoom/parallax + keyboard nav (return cleanups)
- pixi/animation.ts sequenced entrance animation
onMount is now a thin orchestrator. SkillTree.svelte: 1177 -> 274 lines
(2453 at the start of the refactor). Listener passivity, the hasMoved click
guard, eventMode-until-animated ordering, and the R-reset parallax rebaseline
are all preserved.
svelte-check: 0 errors/0 warnings. lint/test/build clean. Verified onMount runs
with no runtime exceptions (background + stats panel render). NOTE: pan/zoom/
node-click/keyboard need a manual real-browser check (headless can't paint nodes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|