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>