Typography:
- The codebase contained exactly one font-family declaration
(LoadingIndicator), naming 'Cinzel Decorative' and 'Trajan Pro' — neither
loaded, bundled, or present in static/. So every word on the site, in every
component, rendered in the browser's default serif. For a site whose whole
premise is atmosphere, that was the largest craft gap.
- Now a display/body pairing: self-hosted Cinzel Variable for the name,
headings, welcome title and tooltip titles; a system stack for body and UI
copy, which costs nothing to load and is far more legible than Times at the
13-14px the modal and stats panel use.
- @fontsource-variable/cinzel ships two subsets with unicode-range, so only
the 25.3KB latin file is fetched for this content (verified — latin-ext is
emitted but never requested). font-display: swap, so text never blocks.
Contrast (WCAG 1.4.3):
- Measured every grey against the panel background (#1a1a2e) rather than
eyeballing it. #666 is 2.97:1 and fails AA for normal text; it was used for
the modal close glyph, the tooltip hint, the branch count and the skill
years. Replaced with --text-subtle (#9a9aa8, 6.1:1).
- Correction to the audit: #888 was listed as failing. It measures 4.81:1 and
passes. Tokenised for consistency, not because it was broken.
--text-muted (#a8a8b8) is 7.3:1, not the ~9:1 I claimed.
Tokens:
- z-index was nine ad-hoc literals across seven files, with LoadingIndicator
and the modal backdrop both on 1000. Now a named scale, and no raw z-index
literal survives anywhere in src/ (verified against the live stylesheet).
- Added spacing, radius, motion and type tokens alongside the existing colour
ones, so the system covers more than colour.
Note: package-lock.json in this commit also carries pre-existing churn from
before this work; adding the font dependency entangled the two.
Verified in the production build: Cinzel loads and applies to headings,
computed body font resolves to system-ui, one font file fetched, zero raw
z-index literals in the rendered CSS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Brand/content phase. The engineering was never the weak part of this site;
the copy was. Decisions on title, bio angle, download figures and node copy
were the owner's — this implements them.
Identity:
- jobTitle was 'Software Development - Lead Associate', an internal HR grade
that means nothing to a recruiter or a search engine. Now 'Lead Software
Engineer' — a faithful translation, not a promotion.
- aboutMe opened with "Intermediate Software Engineer", grading himself down
in his own headline, and named the most generic possible stack
(React/Node/SQL/ShadCN/Docker). It now leads with the work almost nobody
else has done: WebRTC telehealth, real-time gesture recognition that made
chat usable for speech-impaired patients, and air-gapped deployment
tooling. Those were previously buried 3-4 clicks deep in leaf nodes and
appeared nowhere on the surface.
- Title/description/OG/Twitter copy realigned to match. JSON-LD needed no
change — it already derives from the origin node.
Node copy:
- The five job nodes had placeholder descriptions ('Junior developer at the
start of my career.', 'Where I am now.') that are the first thing a tooltip
shows. Each is now a one-line hook naming the most substantial thing from
that role.
- Normalised highlight punctuation: only exp-job5's bullets ended in periods.
Proof of work:
- `downloads` existed on ProjectNode and was used by zero nodes despite two
published PyPI packages. Populated with verified lifetime figures from
pypistats.org (tvdbAPI 2,277, tmdbAPI 784, checked 2026-08-09) and dated in
a comment. Lifetime, deliberately, not the recent rate — 27 and 12 in the
last month is not a number worth advertising.
- Nothing rendered the field. NodeModal now shows it beside the status badge,
and SeoContent renders it too so it's crawlable.
Data integrity:
- edu-degree's description said "Computer Science" while its own structured
fields said Applied Science / IT-Software Development. Its label was the
generic "University"; now Green River College.
- calculatePlayerLevel() hardcoded '2020-10-01', duplicating
exp-job1.startDate — two sources for one fact, free to drift. It now
derives the earliest start from the experience nodes, via an explicit
"Month YYYY" parser rather than handing a bare "October 2020" to
`new Date` (not a format engines are required to accept).
- Added the first tests for calculatePlayerLevel, previously the only
untested export in stats.ts. 14 -> 16 tests.
- Fixed the 'tool-vscod' id typo.
Corrections to the audit this phase acted on: the self-hosted repos at
git.tehriehldeal.com ARE publicly reachable (all three return 200
anonymously) — the earlier claim that recruiters couldn't reach them was
asserted without checking. All three live project sites are up too.
Verified in the production build: modal shows the new title and bio, the
downloads badge renders as "↓ 2,277 downloads", figures are crawlable in the
prerendered HTML, and no filler copy survives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Findings from a full frontend audit (static read + live instrumentation of
production). This covers the "bleeding" and performance phases.
Bug fixes:
- StatsPanel branch collapse/expand never re-rendered. Two Svelte 5 defects:
$state(new Set()) is not proxied (proxy.js bails on non-object/array
prototypes), so .add()/.delete() were untracked; and the Svelte 4
`x = x` reactivity idiom is a no-op in runes mode, where sources use a
strict `equals` comparator. Switched to SvelteSet and dropped the
self-assignment. Verified: aria-expanded now flips and rows 28<->24.
- The closed stats panel's gold box-shadow (8px offset + 80px blur) bled
~88px past its right edge, painting a permanent olive stripe down the
left of the viewport on every page view. Offset the closed transform
past the shadow.
- data.ts shipped a placeholder repoUrl (github.com/yourusername/...), a
live 404 on the node describing this very site. Also removed the
"Tailwind CSS" techStack claim; it is not a dependency of this project.
Performance:
- nodes.ts awaited Assets.load() inside the node loop, serialising ~56 icon
round-trips before the tree could render or accept input. Now one
parallel batch. Measured: all 78 tree icon requests start at ~28ms and
complete within ~618ms.
- isLoading was cleared right after app.init(), before a single texture
existed, so the spinner resolved onto an empty screen. Moved to after
the scene graph is built.
- Renderer left antialias on (costly, ~no benefit on soft glow circles)
and resolution/autoDensity at defaults (blurry on HiDPI). Now
antialias:false, resolution:min(dpr,2), autoDensity, high-performance.
- cacheAsTexture on the fully-static gradient (16 full-screen fills) and
vignette (13); interactiveChildren:false on background containers.
- Dust ticker allocated a fresh closure per frame via forEach; replaced
with a named function and an indexed loop.
Dead weight:
- static/{headshot,profile}.jpg.original (8.6MB, 83% of the static payload)
were committed and deployed verbatim by adapter-static, publicly
fetchable and referenced by nothing. Moved to a gitignored assets-src/
so the full-res sources stay on disk without shipping.
- precompress:true so nginx gzip_static/brotli_static have siblings to
serve (the origin currently sends everything uncompressed).
build/ 11.13MB -> 3.7MB. JS wire 727.6KB raw -> 232KB gzip / 202.7KB brotli.
svelte-check clean, 14/14 tests pass.
Note: the matching nginx change lives in the gitignored homelab/default.conf
and must be applied to the container separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds TehRiehl TCG Management (https://tcg.tehriehldeal.com) under the
Personal Tools branch at (-400, 140), continuing the existing arc past
TehRiehlBudget.
Sized `medium` rather than `small` so it reads as the most substantial
project on the tree without competing visually with the Projects hub node.
Ships a new monochrome card-stack icon following the existing convention
(white fill, 800px, tinted coral at runtime by PixiJS).
Described as a multi-TCG manager rather than a Pokemon app: the system is
game-agnostic, with games keyed on TCGCSV category ids and gameId/gameName
as first-class dimensions through the API and UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entire portfolio rendered only on the PixiJS canvas, so crawlers saw a
near-empty body and the site ranked poorly for non-personalized searches. Fixes:
- NEW SeoContent.svelte: screen-reader/crawler-only (.sr-only) semantic HTML
(h1 + bio + contact + Experience/Projects/Skills/Education), generated from
data.ts (single source of truth, reuses aggregateSkills). Prerenders into
build/index.html — verified it now contains the job/project/education text
that was previously canvas-only. Also a big screen-reader accessibility win.
- JSON-LD schema.org Person in +layout (name, jobTitle, alumniOf, knowsAbout,
sameAs LinkedIn/GitHub) — strong entity signal for name searches.
- Canonical URL; static/sitemap.xml + robots.txt Sitemap reference.
- Keyword-tuned <title>, description, og/twitter titles (name + role + stack).
- Demoted the transient welcome <h1> to a <div> so SeoContent's <h1> is the
single canonical heading. Bumped SW cache to v2.
.sr-only utility added to app.css. check/lint/test/build clean; verified no
visual regression and no runtime exceptions (background + canvas unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reproducible ImageMagick script that rebuilds static/og-preview.jpg from the
headshot + theme colours (with serif-font fallbacks so it runs on other
machines). Exposed as `npm run og:image`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Link previews need an og:image — the tags existed but the image was
commented out. Generated a 1200x630 branded card (static/og-preview.jpg,
~100KB: dark space gradient, circular headshot with gold ring, gold serif
title + tagline) and wired up:
- og:image (+ secure_url, type, width, height, alt)
- twitter:image (+ alt); twitter:card already summary_large_image
Absolute URLs point at https://tehriehldeal.com/og-preview.jpg so crawlers
(Slack/Discord/iMessage/Twitter/Facebook) can fetch it. Tags verified present
in the prerendered build/index.html.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 3 (behavior-preserving):
- Replace the 45-optional-field TreeNode interface with a tagged union over
category: OriginNode | ExperienceNode | ProjectNode | SkillNode | EducationNode,
sharing a BaseNode. Category-specific fields now live only on their member.
- Modal template narrows by category (category === 'experience' && ...) instead
of bare field-presence checks; equivalent at runtime since each field is
exclusive to its category (proven by the union compiling against the data).
- stats utils narrow correctly: aggregateSkills filters to SkillNode[] via a type
predicate; countByProficiency and getPlayerClass guard on category.
svelte-check: 0 errors (every data node conforms). lint/test/build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 2 (behavior-preserving):
- Add :root color tokens to app.css mirroring theme.ts cssVars
- Replace solid theme hex literals in SkillTree.svelte <style> with var(--..):
#ffd700 -> --color-gold (x17), #a8a8b8 -> --text-muted (x8),
#1a1a2e -> --panel-from, #0f0f1a -> --panel-to, #f9a825 -> --color-skills
- body background now var(--bg-space)
Values are identical to the literals; every referenced var has a :root
definition. rgba() shadows and one-off grays/badge colors left as-is.
check/lint/build all clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 1 of the cohesion refactor (behavior-preserving):
Single source of truth:
- New src/lib/theme.ts: category colors (moved from data.ts, re-exported
for back-compat), derived hex strings, nebula palette, UI colors, CSS var map
- New src/lib/config.ts: breakpoint, zoom, parallax, node radii, particle
counts, animation timings (previously scattered magic numbers)
- New src/lib/utils/tree.ts: getNodeById, getNodeRadius, getDistanceFromOrigin,
getBeamIntensity (pure helpers lifted out of onMount)
- New src/lib/utils/stats.ts: branch ids/labels, proficiency order, and the
stats-panel aggregation helpers (deduped from inline copies)
- SkillTree.svelte now imports all of the above; removed the duplicated
getCategoryColorHex, stats helpers, branch id/label lists, and inline magic numbers
- Populated src/lib/index.ts barrel
Bug fixes:
- data.ts: tmdbAPI pypiUrl pointed at tvdbAPI -> corrected to tmdbAPI
- Removed/ gated stray console.log noise (skill-tree-loaded, SW logs)
- Removed dead vars (maxAlpha, glowMaxAlpha) and unused catch binding
Tooling: ESLint + Prettier now clean; added Vitest unit tests for the new
pure utils (14 tests). svelte-check: 0 errors. build: ok.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Isolated reformat commit so subsequent logic changes have clean diffs.
No behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Games parent node branching off the top of the Projects node, with
MovieLoop (daily movie-chain web game built with React/NestJS) as its
first child. Add TehRiehlBudget (self-hosted personal finance app) as a
fourth child under Personal Tools, and stagger the Personal Tools
children coordinates so they no longer stack on a perfect vertical line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>