v5.2 — the Local & Private AI shop window: prerendered, hardened, adversarially reviewed - #72
Conversation
Complete portfolio redesign using ShadCN base-ui (Nova preset), Tailwind CSS v4, and Framer Motion as the sole animation library. - Replace 30+ custom CSS files (~37K lines) with Tailwind v4 + app.css (~200 lines) - Remove anime.js, GSAP, ScrollMagic, AOS — keep only Framer Motion - Add ShadCN components: Button, Card, Badge, Tabs, Accordion, Input, Textarea, etc. - Rebuild all 6 pages with ShadCN components and Matrix green theme - Restore full original content: 25-entry timeline, 6 services, 9 credentials, 21 tech items - Refined Matrix rain canvas (reduced density, JetBrains Mono font) - CRT scanlines as pure CSS utility - Green glow effects on cards, buttons, headings - Fix mobile visibility issues with MotionSection - Comprehensive Playwright e2e tests with content assertions - SPA fallback (404.html) for GitHub Pages routing
Theme System: - Dark/light theme toggle with ThemeContext + localStorage persistence - Light theme: "Circuit Board Schematic" — warm off-white with green trace accents - Dark theme: "Neon Terminal" — enhanced Matrix green with glass morphism Visual Effects: - Glass panel utility for readable text over Matrix rain - Vignette effect for cinematic dark-mode depth - CRT scanlines at 5% (dark) / near-invisible (light) - Matrix rain: 45% opacity dark, 8% light (watermark texture) - Green glow on all headings (h1/h2/h3) - Noise texture utility for atmospheric backgrounds Component Polish: - Cards: glass morphism with neon green glow hover, featured variant - Buttons: green glow shadow on primary, visible green border on outline, ripple hook - Badges: new cyan + amber variants for visual variety - Tabs: active glow shadow - Accordion: green left-border on expanded items - Separator: gradient fade (green center → transparent edges) - Inputs: green focus glow ring Layout: - Header: gradient green bottom line, theme toggle, motion entrance - Footer: gradient top line, terminal prompt with blinking cursor - Page transitions: blur + fade with custom easing Page Enhancements: - Home: larger hero with multi-layer glow, block cursor, scroll indicator, CTA pulse - Contact: LinkedIn video banner restored with green gradient overlay - Projects: category-colored left borders, featured card treatment - Services: glow-pulse CTA
HomePage: - Replace featured projects with interactive System Status Dashboard - Terminal-style skill bars with animated fill + glow effects - Stats grid (Projects, AI Models, Deployments, Uptime counters) - Recent activity feed with icon timeline - Animated number counters using Framer Motion useMotionValue ProjectsPage: - Featured projects section at top (moved from home) with star icon - Shows featured cards with glow treatment when "All" filter active Light Theme — "Circuit Board Schematic": - Circuit board grid pattern background (40px green trace lines) - Stronger green primary for better contrast on white - Defined card shadows with green tint - Crisp white cards with subtle green-edge shadows - Green-accented button shadows Playwright: - Screenshots now capture both dark AND light themes - Dark: original filename, Light: -light suffix - Updated home page test for system status dashboard
Header: - Remove y/blur page transition animation that caused "drag down" effect - Use simple opacity fade for route changes instead - Header bg + mobile nav bg now use bg-background (theme-aware) - Logo and nav link colors use text-primary (adapts to light/dark) Mobile Readability: - Matrix rain opacity reduced on mobile: 25% dark, 4% light (was 45%/8%) - Glass panel background opacity increased on mobile: 90% dark, 94% light - Backdrop blur increased to 24px on mobile for stronger frosting Light Theme Polish: - Glass panel more opaque with subtle green shadow - Responsive media query for mobile glass panel contrast - All hardcoded matrix-300 colors replaced with theme-aware primary Footer: - Hover colors now theme-aware (text-primary instead of text-matrix-300)
HomePage: - Hero wrapped in terminal window frame (title bar with traffic lights, "tom@matrix ~" prompt) — feels like opening an actual terminal session - Stats values bumped to text-3xl with glow-text treatment - Activity feed items now have category badges (AI, Web, Data, Dev) - Skill bars taller (h-2) for better visibility - New "NOW" section — currently working on status with cyan/amber badges - About quote wrapped in terminal output block ($ cat about.md) - Removed unnecessary desc from stats (cleaner) Light Theme Polish: - Grid lines tighter (32px) and slightly more visible (4% opacity) - Cards: white bg at 90% opacity, green-tinted borders, hover lifts -1px - h1 gets deeper green-black color for emphasis - Terminal window frames visible with green-tinted borders - Button shadows deeper with green tint - Gradient header/footer lines more visible in light mode - Muted backgrounds use faint green wash instead of grey
Homepage: - Tightened vertical spacing: hero py-16/20, scroll mt-6, NOW py-4, quote py-6 - Removes ~40px of dead space between hero and dashboard Projects Page: - Card grid gap increased from 16px to 20px for breathing room - Left border widened to 3px for more visible category color coding - Language color dots (TypeScript blue, Python blue, JS yellow, etc.) - Category badges per card (AI & ML, Web, Games, Creative, Personal) - Filter tab counts: "AI & ML (7)", "Web (4)", etc. - Featured header gets terminal-style treatment with gradient line Services Page: - Highlights now rendered as bullet list with green › markers - Each service card shows 3 key deliverables (data was defined but never used) - Adds substantial depth without changing layout
Typography: - Headings: Orbitron (angular sci-fi display font) — replaces JetBrains Mono - Body: Exo 2 (clean futuristic) — replaces Inter - Mono: Share Tech Mono — authentic terminal feel - Restores the original site's typographic personality that made it distinctive Readability (Dark Mode): - Foreground bumped from oklch(0.92) to oklch(0.95) — brighter white text - Muted-foreground from oklch(0.60) to oklch(0.65) — readable secondary text - Accent-foreground brightened for better contrast - Glass panel opacity increased to 88% (was 82%) with stronger 24px blur - Panel background darkened slightly for better text contrast Icons: - HugeIcons CDN added to react.html (https://use.hugeicons.com/font/icons.css) - Available via <i className="hgi hgi-icon-name" /> across the app - Lucide still works alongside for ShadCN component internals
…lity Homepage spacing: - Hero: pt-12/pb-6 (was py-16) — much less dead space below terminal - System status section: py-4 (was py-8) - System status header: mb-5 (was mb-8) - Stats grid bottom margin: mb-5 (was mb-8) - Total gap reduction: ~60px removed between hero and dashboard Typography: - Base font size: 15px mobile, 16px desktop (was browser default 16/16) - Line height: 1.65 (was 1.6) — better readability with Exo 2 Matrix rain readability: - Desktop dark opacity: 30% (was 40%) — much more subtle - Mobile dark opacity: 20% (was 25%) - Glass panel opacity: 92% (was 88%) with 32px blur (was 24px) - Mobile glass panel: 94% opacity - Text now clearly dominates over the rain effect
Homepage: - Remove SCROLL indicator — unnecessary space - Hero bottom padding tightened (pb-2/pb-4) - Green gradient separator lines between dashboard/NOW/quote sections - ChevronDown import removed Visual eye-tracking: - Dark card borders bumped from oklch(0.25/0.4) to oklch(0.30/0.5) — visible green tint guides the eye across card grids - Glass panel gets green-tinted border (0.28 chroma) with brighter top edge (0.40) — subtle depth cue like a monitor bezel
v3.5 → v4.0 personality restoration: Matrix Rain: - Desktop dark opacity: 30% → 35% (more visible through glass) - Glass panel opacity: 92% → 88% (lets rain breathe more) - The Matrix effect now "comes through the page" like v3.5 Card Borders — Green Glow: - Border chroma bumped for more visible green tint - Added brighter top-border highlight (terminal window feel) - Hover glow intensified: border brightens to 60% chroma, stronger shadow - Hover lift increased from -2px to -3px TimeTravel Link Restored: - Added to homepage about section (was prominent in v3.5, completely missing in v4.0) - Added to footer alongside social links - Links to: thomasjbutler.github.io/version-timetravel/ Projects Page: - Terminal-style header (>_ all_projects) above filter tabs - Matches the dashboard terminal headers from homepage Section Separators: - Homepage separators bumped from 15% to 25% opacity - More visible green gradient lines between sections
Project Data Model: - Added longDescription, highlights, status, images.gallery, links.video fields - Rich content for ModelViz, Morpheus, Matrix Arcade (others ready for future) - All new fields optional — backward compatible with existing projects ProjectDetailModal Component: - Full-screen dialog overlay with Matrix-themed styling - Category badge + language color dot + status indicator in header - Key Features section with checkmark list (from highlights array) - Image gallery grid (placeholder with "screenshots coming soon" state) - Video demo link card with play icon (when links.video is provided) - Tech stack badges - Footer with Live Demo + View Code buttons - Responsive — works on mobile Projects Page Integration: - All project cards now clickable (cursor-pointer) - Click opens ProjectDetailModal with full project details - CardFooter links (Live/Code) use stopPropagation to open external URLs - Featured cards also clickable - ShadCN Dialog component installed (base-ui) Future-ready: - images.gallery accepts array of screenshot URLs - links.video accepts demo video URL - highlights array for key selling points - status field for project state tracking
Accessibility:
- prefers-reduced-motion: kills all CSS animations/transitions
- MatrixRain canvas disabled entirely for reduced-motion users
404 Page ("There is no spoon"):
- Matrix-themed 404 with terminal badge, quote, and navigation CTAs
- Catch-all route in App.tsx renders NotFoundPage for unknown paths
- Terminal-style footer: $ find / -name "page" → no matches found
Page Meta Titles (SEO):
- Home: "Tom Butler — Full Stack AI Engineer"
- Projects: "Projects | Tom Butler"
- About: "About | Tom Butler"
- Services: "Services | Tom Butler"
- Contact: "Contact | Tom Butler"
- Updates: already had title
- 404: "404 — Page Not Found | Tom Butler"
Contact Form UX:
- Client-side form submission via fetch (no redirect to Formspree)
- Success state: green checkmark, "Message Sent", "back in 24-48 hours"
- Error state: inline error message above submit button
- Loading state: "Sending..." with disabled button
- "Send another message" button to reset
About Page Journey:
- Cards converted from 2-column grid to vertical timeline layout
- Connected with green left border line and timeline dots
- Slide-in animation from left (matches Updates page style)
Updates Page Year Grouping:
- 25 entries now grouped by year (2025, 2024, 2023, 2022, 2000s)
- Sticky year badge headers that float while scrolling through a year
- Much more scannable than a flat list
…eTravel doc
Project Descriptions (from GitHub READMEs):
- All 15 projects updated with accurate descriptions from actual repo READMEs
- Added longDescription + highlights for: ModelViz, AICodeGenerator, SQL-Ball,
Morpheus, ReviewBot Protocol, NewsPerspective, LFC News App, Matrix Arcade,
CSS Showcase
- Descriptions now match what each project actually does, not AI-generated guesses
Contact Page Layout:
- Social links (GitHub, LinkedIn, Buy Me a Coffee) wrapped in a Connect card
- Left column now fills properly to match the form card height
- Terminal-style "connect" header matches site design language
- Links shown as labelled text (not just icons) for better UX
Light Theme ("Circuit Board Schematic"):
- Grid lines bumped to 5% opacity (more visible circuit traces)
- Cards get subtle green left-border accent (2px)
- Glass panel has faint green tint (oklch 0.006 chroma)
- More distinct from dark theme while keeping green identity
Version TimeTravel:
- Created version-timetravel-update.md documenting v3.5 → v4.0 evolution
- Covers architecture changes, visual identity shift, technical decisions
- Ready for Tom to add as new entries in the TimeTravel project
Swap em dashes in user-visible copy for colons (label:detail), commas (asides), periods (sentence breaks), and pipes (browser tab titles). CSS and JSX dev comments left untouched.
Adds ImageLightbox for full-screen gallery viewing from the project detail modal, wires cover/gallery/video data into projects.ts, and tightens page copy. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The suite was 12/15 red before any v5 work, so nothing downstream could be verified. Causes were mechanical rather than logical: - vitest had no include/exclude, so it collected the Playwright e2e spec - tests imported components that had moved (components/Layout) or never existed (MatrixSpinner, SitemapPage, utils/keyboardNavigation) - App owned BrowserRouter, so router-wrapped tests nested two routers. Split out AppRoutes; App is now the Router plus AppRoutes - global vi.useFakeTimers() also fakes rAF and performance.now, which hangs waitFor and user-event. Removed; suites opt in locally - ContactPage.test was a Jest-era file testing validation copy and a useSEO hook that never shipped. Rewritten against the real Formspree form Providers now composes Theme + Accent + Fx + MotionConfig in one place, used by both main.tsx and the tests, so tests render the tree we actually ship. MotionConfig reducedMotion="user" is the guard the CSS media query cannot be: framer-motion animates via inline styles, so @media (prefers-reduced-motion) never touched it. Adds the in-page motion toggle the neurodiversity suite has always asserted and WCAG 2.2.2 requires for auto-playing motion, plus the skip link the a11y suite asserted and that never existed. Accessibility and neurodiversity suites now render the full shell rather than a bare page, so landmark and heading rules mean something. 58/58 green, type-check clean. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Rain: rewritten as a testable engine (src/lib/fx/rain-engine.ts) that parts around the pointer, ripples on pointerdown, and speeds up on burst(). The signed-off maths (quadratic parting falloff, 540px/s ripple ring, 2.2x burst decaying over 800ms) is locked by unit tests so the engine can be optimised without anyone eyeballing whether it still looks right. Measured at 60fps sustained (p50 16.7ms) at 1920x941 with the pointer sweeping and three concurrent ripples. Three optimisations got it there without changing a pixel: a precomputed colour LUT instead of building ~6000 rgba() strings per frame, squared-distance tests with sqrt only inside the radius, and left-aligned text with per-glyph half-widths instead of textAlign: center. DecodeText drives its sweep with imperative writes to span refs rather than per-frame state. The prototype picked scramble glyphs during render, which is impure: StrictMode's double render produced different text each pass. It also put aria-label on a bare span, which is prohibited ARIA — real text now lives in an sr-only span with the glyphs aria-hidden, so the scramble never reaches a screen reader. Dissolving the page-wide glass panel puts text over live rain, which is a real WCAG 1.4.3 failure at worst case (~1.3:1 behind a head glyph). .fx-scrim paints the page background back in behind text blocks: invisible against the design, but it keeps contrast honest. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Services gains the Local & Private AI lead card (spanning both columns, with the local_first flag), the WHY_LOCAL_AI section and its three stat tiles, and the new intro. About gains the current_focus panel. Projects' featured cards gain full-bleed covers with a generated fallback for the three without artwork. The timeline gains the 2026 "AI you can own" entry. The Run It Local strip lands on Home and Services. Services also stops advertising a different email address from the rest of the site. Two fixes worth calling out: The reading column now carries a scrim. Measured on the live page, the brightest pixel behind the Services intro was rgb(110,142,114) — a rain head glyph — giving 1.38:1 against body text where WCAG AA wants 4.5:1. The scrim is the page's own background, faded out at the left and right edges, so the rain still pours down the margins and nothing reads as a card: 1.38:1 -> 6.71:1. The lead card's wash is layered in CSS rather than applied with a Tailwind bg-gradient-* utility. .dark [data-slot="card"] paints its dark surface with the `background` shorthand, so a utility that sets background-image silently stripped it and exposed a near-white background-color underneath — the card rendered washed out in dark mode. UpdatesPage now sorts entries within a year, not just between years. The new 2026 entry led the timeline only because it happened to sit first in the array. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Palette is built on the existing base-ui Dialog, so the focus trap, focus restore, aria-modal, scroll lock and Esc come free — a hand-rolled div overlay (what the prototype uses) leaves the page behind it reachable by Tab and by a screen reader's virtual cursor. The list follows the APG combobox+listbox pattern: focus stays in the input and the active option is conveyed by aria-activedescendant, so options are <li> rather than <button> (focusable children inside a listbox trip axe's nested-interactive rule). The accent (red/blue pill) sets --primary on documentElement, not on a wrapper div: base-ui portals dialogs to document.body, so a wrapper would leave the palette and every modal un-accented. It also overrides --primary-foreground, without which the blue pill gives near-black text on a blue filled button. Boot intro is a real modal with a real skip button, focused on mount. The prototype ships a <span> as its skip affordance on an overlay that covers the page and locks scrolling — an assistive-tech user could be stranded there. Projects: covers centre their crop rather than anchoring to the top. The handoff says top, but these assets are mostly square title cards, and anchoring to the top slices the wordmark in half. categoryLabel/languageColors move into projects.ts — both ProjectsPage and ProjectDetailModal carried their own copy, and both were missing `mobile`, so Sanctuary's badge rendered the raw string. Verified in the browser: pointer parting and click ripples, ⌘K with arrow wrap, eggs hidden until three characters, konami, the pill re-tinting the rain, light theme, and the full reduced-motion contract (no boot intro, one static rain frame, final text, native cursor). Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Adds 32 tests: the rain's signed-off maths (parting curve, ripple ring, burst decay, palettes), the decode state machine, the buses, the palette's keyboard model, and DecodeText's a11y contract — including a StrictMode double-render check, which is the regression test for the impure-render bug the prototype had. Two test-infra fixes worth knowing: Colour rules are now off in the jsdom axe run. jsdom does no layout and no paint, so axe returns "incomplete" for contrast rather than a pass or a violation: the old test burned 20+ seconds proving nothing. Contrast is measured for real in the browser instead — that measurement is what caught the 1.38:1 failure. asyncUtilTimeout is raised to 8s. Routes are React.lazy and Vite transforms each page chunk on first import, which regularly exceeds Testing Library's 1000ms default and surfaces as a flaky "unable to find heading" rather than as the slow import it is. CLAUDE.md described an architecture that no longer exists (a src/css/ partial system, GSAP/AOS/ScrollMagic). Rewritten to the real one, with the traps that cost time this session. 15 files, 105 tests green (was 3 files, 23 tests). Type-check and build clean. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The decode layer holds the real string twice: once in an sr-only span for assistive tech, once as aria-hidden glyph spans. Screen readers only ever saw one, but a user selecting the hero heading and copying it got 'Hey, I'm TomHey, I'm Tom'. Making the glyph layer unselectable leaves the sr-only copy as the one that lands on the clipboard. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The rain made the page feel laggy — enough that it had to be switched off to use
the site. Measured on a retina viewport under a 4x CPU throttle, the page ran at
20fps with the rain and 57fps without, so the rain was the bottleneck, not a
symptom. It now holds 60fps with the rain on, under the same throttle.
Four things did it, none of which change what it looks like:
- Render at 1 device pixel per CSS pixel, never retina. The per-frame fade is a
full-canvas alpha fill, so its cost is exactly the pixel count: 7.5M pixels a
frame at DPR 2 on a 1728x1080 window, versus 1.9M now. At 40% opacity behind
content, the softness is invisible.
- Draw at 30fps. Falling glyphs read identically, and it halves the rain's share
of the main thread. Movement is time-scaled, so the fall speed is unchanged.
- Bound the streams (16-34 glyphs) instead of giving every column a
viewport-height trail. That was ~9,000 fillText per frame at 1080p; it is now
~3,000 — and it reads more like the Matrix, not less.
- Batch the head glow into one pass. shadowBlur was being set per glyph, and each
shadowed fillText is a gaussian on a scratch surface.
Pointer parting and click ripples are gone, as is the custom terminal cursor: they
made every mouse move do work on the main thread while the draw loop was already
running, which is exactly when the page needs to feel responsive. Removing the
cursor also removes `body { cursor: none }`, which was the most user-hostile thing
in the design.
Also: the resize listener was unthrottled, so dragging a window edge rebuilt every
drop and re-measured every glyph on every event. And OperatorConsole read
scrollHeight on every render — a synchronous layout, once per typed character.
Dark mode is retuned for a bright room rather than a dark one. It was only really
legible on a laptop in the evening. Body copy was L=0.65, cards were 70%
translucent over moving glyphs, borders were nearly invisible, and the scanlines
and vignette dimmed everything further. Measured against the worst-case backdrop,
body copy has gone from 1.38:1 (before any of this) to 10.2:1 — past AAA.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
NAV. Services now comes before Projects in the header, the mobile drawer and the
⌘K palette, and the hero's filled CTA is "What I Build" -> /services. Someone
arriving to hire Tom should hit the offer before the portfolio.
RESPONSIVE. The site had never been checked below 1728px. Verified clean at
320/375/414/768/1024/1440/1920 across all six routes — no horizontal overflow
anywhere. What was broken:
- The project filter tabs OVERLAPPED on a phone. TabsList is a fixed 32px box
and the seven tabs wrap below ~540px, so both rows had to share those 32px
while each tab sized itself against the container. Same on About's five tabs.
- The hero console silently ate its own opening lines at 375px: the script
wraps to ~13 lines there, in a 250px box with overflow:hidden and a scroll
pinned to the bottom, so `$ whoami` scrolled away and could not be scrolled
back. It now grows to fit.
- The rain was INVISIBLE on mobile. The reading-column scrim bled 3rem past
each side, so below ~1120px both of its soft edges fell off-screen and it
rendered fully opaque, edge to edge. Only body{overflow-x:hidden} hid the
overflow — and it was masking the tab bug too.
- The rain re-scattered on every scroll on mobile: the address bar showing and
hiding fires resize, and resize rebuilt every drop. It now only rebuilds when
the column count actually changes, and density adapts to the current width
instead of being captured once at mount.
- Hero used 100vh, which on mobile is the LARGE viewport, so the scroll cue sat
under the browser chrome. Now 100svh, minus a --header-h that can't drift.
- The modal's prev/next arrows appeared at 640px while the dialog is 672px
wide, so they sat on top of its content until ~800px.
- The palette had no height clamp: on a landscape phone its hint row and the
bottom of the list were off-screen and unreachable.
LOADING PATH. / was a redirect stub that bounced to react.html, costing every
cold visit a whole extra navigation — and the stub was what Google and LinkedIn
actually read, still describing a "Liverpool-based Full-Stack Developer". index.html
is now the app. Also: dropped two dead render-blocking stylesheets (Font Awesome
and HugeIcons were referenced only from an unrouted BlogCard and legacy src/js),
collapsed two overlapping Google Fonts requests into one preloaded link (the
@import in app.css was the worst of them — not discovered until app.css had
downloaded, so it serialised), and set the theme in an inline script so dark-mode
visitors stop getting a white flash.
MEDIA. The contact banner video was shipping 2,252 KB untransformed and autoplays
on load: 39 KB now. The Morpheus demo mp4 was 8,071 KB: 1,345 KB. Every img now
carries intrinsic width/height, so the projects grid stops reflowing as covers land.
ASSETS. favicon.ico did not exist and 404'd on every page load. Generated a real
icon set (favicon, apple-touch, 192, 512) and a 1200x630 og-image.png — the og:image
was an SVG, which X, LinkedIn, Facebook and Slack all refuse, so every share of the
site has been previewing blank. sitemap.xml moved to public/ (it was in the repo
root, which the deploy never publishes) with the dead /skills and /sitemap entries
removed and /updates added.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Accessibility 100, Best Practices 100, SEO 100, Agentic Browsing 100. Zero failed audits. CLS 0.127 -> 0.016. The CLS was the landing page being code-split. HomePage was lazy, so on a throttled connection the Suspense fallback rendered first and was then swapped for the real page — a large shift, and it was the whole score. The landing route is needed by every cold visit, so splitting it only ever bought a second round trip. It is now imported eagerly; every other route stays lazy. Fonts are self-hosted and bundled. They used to be fetched from Google twice over — a <link> in the HTML and an @import in app.css — both render-blocking requests to a third party. There are now zero third-party render-blocking resources on the page. Also: robots.txt and llms.txt did not exist (robots.txt was an outright SEO failure), and the scroll cue failed colour contrast at primary/60 — it is a real control with a label, so it has to be readable, not merely suggestive. One self-inflicted fix: making the console `height: auto` on mobile cured the clipping and introduced layout shift instead, because the box grew with every line typed and shoved the page down. It now reserves the height up front and scrolls. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Every image the site uses or needs, after auditing each reference and HTTP-checking
all 20 Cloudinary URLs. Nothing is broken; the gaps are assets that were never made.
The headline items:
- 4 project covers missing (2 of them featured, showing "cover incoming" tiles),
and Commercial Portfolio's cover is the logo SVG, which the modal skips — so
opening it shows no image at all.
- The 3 gallery GIFs total 19 MB and CANNOT be optimised: Cloudinary rejects the
transform with "Maximum total number of pixels in all frames is 50 Megapixels.
Requested 80.0". They need re-exporting as MP4.
- 11 of 15 existing covers are square title cards being centre-cropped into a 2:1
card band and a 16:9 modal hero.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Cursor-parting and click ripples return, with the original signed-off maths restored verbatim from 183b530 (radius 120, quadratic falloff to a 26px shove, ripple ring at 540px/s, band +-50px, life 1.15s, near-column speed lift 1.6x). The deleted partOffset/rippleBoost tests come back with it, so we can prove it returned unchanged. MEASURED, on the production build at 1728x1000 @DPR2 under a 4x CPU throttle: morph off draw 10.1ms 30% of the 33ms budget morph on, mouse sweeping draw 11.2ms 34% morph on + 3 ripples draw 9.2ms 28% About 1ms a frame. It is affordable because it adds no glyphs — it only moves and recolours ones that were already being drawn. Two things make that true, and they should not be undone: - The gate IS the optimisation. It runs only in the dark theme, with the rain on, on a fine pointer, with morphEnabled. When the gate is closed there are no pointer listeners on the page at all and the draw loop skips every distance calculation — so it costs literally nothing in light mode, on a phone, under reduced motion, or when switched off. - The head batch survives. Heads are still drawn in one state change; only the handful actually lit by the pointer break out of it (headBoost), which is bounded by the 120px radius — about fifteen glyphs, not a hundred. One deliberate deviation from the original: boosted TRAIL glyphs take the spark colour but no shadowBlur. The original set a shadow on every boosted trail glyph, and an expanding ripple ring can put several hundred glyphs in its band at once — each one its own gaussian. The colour shift alone reads as the ripple; the gaussians were what hurt. Flip it live with the new ⌘K command `toggle rain morph` (persisted to localStorage), which is how the numbers above were taken. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Measured: the soft-green eyebrow labels use text-primary/70, which composites to about 3.4:1 on the near-black background, under the 4.5:1 floor for text this small. It is a systemic pattern (13 files), so bumped to text-primary/90 (about 5.1:1) everywhere at once. The hero eyebrow (.fx-bootline) was worse at 65% opacity (3.07:1) and now carries real content (name, role, location), so it takes the full --primary. The /updates timeline badges were the worst offender and broke two rules at once: they used a six-colour rainbow from getCategoryColor (#F59E0B amber, #6366F1 indigo, #8B5CF6 purple, #EC4899 pink) that (a) is not the green-and-amber-only palette this site commits to, and (b) measured 1.95:1 for amber-on-white in the light theme. Replaced with the neutral secondary badge; the per-entry icon still differentiates the categories. getCategoryColor is now unused and its import is gone. 122 tests, 0 lint errors, 0 type errors. Hydration clean on /, /services and /updates. Labels verified legible in a screenshot. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
MotionToggle announced a contradiction to screen readers: aria-pressed={!fxEnabled}
(pressed when effects were OFF) alongside an aria-label that also inverted, so it read
as "Enable motion, pressed" while motion was disabled. A toggle button keeps a stable
name and carries its state in aria-pressed; aria-pressed now simply tracks whether
effects are on, and the label no longer flips. The visual tooltip stays actionable.
Dead CSS: .crt-scanlines, .vignette, .glass-panel and .noise-bg (~90 lines) were the
v3 versions, superseded by the .fx-* system and referenced by nothing in src/. The
--color-cyan and --color-amber tokens went with them: the badge variants that used
them are gone, and the only non-green hue now is --meter. CSS down ~2 KB.
122 tests, 0 lint errors, 0 type errors. Rain, scanlines and hero all still render.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The MotionToggle aria fix renamed the accessible name to 'Motion and effects' (sentence case), and the neurodiversity test matched a case-sensitive [aria-label*= 'motion']. The control is present and correctly labelled; the selector was too rigid. Now matches motion/animation/effects case-insensitively. 122 tests green. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…tton)
Clearing the last of the review findings.
The footer copyright year and the /updates "Years" stat both used new Date() at
render, which bakes the build year into the prerendered HTML and then recomputes on
the client: a hydration mismatch between 1 January and the next deploy. Both now
carry suppressHydrationWarning, which is exactly what React provides for legitimately
build-vs-client values.
The /updates stat also read "{4}+ Years Coding", which flatly contradicted the About
page ("started with HTML/CSS as a kid, 2000s"). The timeline it counts starts in 2022,
which is when the AI focus began, not when he started coding, so it now reads "Years
in AI". No more contradiction, and the number is honest.
The newsletter button said "Subscribe" but only toasted, because there is no Substack
URL yet: a button that looks subscribable and is not. With no URL it now reads "Coming
soon" and says so, and becomes a real Subscribe link the moment LINKS.substack is set.
"Launched Run It Local" softened to "Starting", since you cannot yet launch something
nobody can subscribe to.
Not changed, and why: the "£0 per token" vs "60-80% lower cost" pair is not a
contradiction, it is correctly qualified (marginal token cost is zero; total cost at
scale, including your hardware, is 60-80% lower). The FAQ JSON-LD answers are accurate
summaries of the page answers, not verbatim, which is acceptable. BOOT_SESSION_KEY is
now write-only but harmless. The one real remaining item, a modulepreload for lazy
route chunks, needs a build-manifest change and is deferred so it does not disturb the
verification agents currently reading dist/.
122 tests, 0 lint errors, 0 type errors.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…view was right, I was wrong) I dismissed this finding earlier as refuted, and I was wrong. My grep found the FAQ answer text in dist/services.html, but it was in the FAQPage JSON-LD script, not the visible body. The Sonnet confirmation pass caught it: base-ui omits a collapsed Accordion.Panel from the DOM entirely, so the prerendered page shipped 12 accordion triggers and ZERO answer panels. Every FAQ answer and every credential was invisible to a crawler or a no-JS visitor. The FAQ was mitigated by its JSON-LD; the credentials had no backup and were simply gone. keepMounted on the panel renders the content into the DOM while collapsed. Verified in the built output: FAQ answers and credentials are now in the visible prerendered body (12 panels), the collapsed panel measures 0px and is not visible, and hydration is clean. /services gained a large amount of crawlable text. The lesson for me: grep the visible body, not the whole HTML, or the JSON-LD masks a missing-content bug. 122 tests green. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The 154-agent confirmation run (before it hit the weekly limit) flagged these as still reproducing in the current code. Verified each and fixed: MEDIUM - FAQ JSON-LD answers were trimmed, reworded versions of the page copy for 8 of 9 questions, so an assistant reading the FAQPage schema would quote text that appears nowhere on the site. Now byte-for-byte verbatim, generated from content.ts, and the drift test asserts full q AND a parity (it only checked questions before). - The About "2025 / Current Focus" milestone was stale; refreshed to the real local/ private-AI positioning. Flagged in a comment (for Tom) the deeper tension between the "2010s: studied computing" milestone and the /updates timeline's 2022 start, which is a biographical question only he can settle, not something to invent. - "£0 per token" vs "60-80% lower cost" read as two savings numbers; the 60-80% note now says "lower TOTAL cost at scale, once your own hardware and power are counted", so the two figures are visibly measuring different things. LOW - The 404 shipped robots "index, follow" and a self-canonical to /404 (a URL that returns HTTP 404). Now noindex, no canonical. - prerender.mjs reads and overwrites dist/index.html, so a second run without a fresh vite build fed its own output back in. Added a guard with a clear message. - Em dashes removed from the ServicesTeaser copy; unused `meter` badge variant removed; three orphaned CSS modules (ErrorBoundary/SkeletonLoader/ContactForm) deleted; the unused writeFileSync import and the @css/@js/@images tsconfig aliases (pointing at deleted dirs) removed; the useHydrated doc comment corrected to describe what the gated controls actually render. 122 tests, 0 lint errors, 0 type errors. Verified in the build: 404 is noindex, the accordion answers and credentials are in the prerendered body, the newsletter button reads "Coming soon". Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The closing verification (Sonnet tail + Opus deep-dive + Fable 5 synthesis) confirmed 16 remaining items against the current code. Cleared all of them. Dead outbound links (verified 404 by live HTTP request, a class the code-focused lenses structurally could not find): - LFC News App card: 'Live' pointed at a dead Vercel URL and 'Code' at a private repo (404 for everyone but me). Repointed Live to the live deployment; dropped the private github link so the Code button no longer renders (as on the ISQ Agent card). - Timeline: Matrix Arcade GitHub (wrong slug), first-portfolio 'Visit Site' (dead subpath), and AI Comparison Showcase 'View Demo' (deployment gone) all 404'd. Fixed the two slugs; removed the dead demo, keeping the working GitHub link. Accessibility regression I introduced this session (c3869cd): - Enter on a project card's Live/Code link was hijacked by the card's role=button key handler and opened the modal instead of following the link (WCAG 2.1.1). CardFooter now stops keydown propagation as well as click. Verified with Playwright: Enter on a focused footer link is no longer preventDefaulted and the modal stays closed. Performance (the lazy-route hydration waterfall): - Prerendered route HTML shipped only main-*.js, so a lazy route's chunk was discovered only after the entry chunk executed. Enabled build.manifest and inject a <link rel="modulepreload"> per code-split route, so the browser fetches the route chunk in parallel. Home and services are eager, so they get none. A route naming a module the manifest can't resolve is now a hard build error, not a silent no-op. Contrast (WCAG AA, verified in the built CSS): - '$ cat about.md' terminal label was text-muted-foreground/50 (~3.15:1 dark, ~2.4:1 light). Now /80 (~5.9:1 / ~5.0:1). - .fx-news__note was a 60% color-mix (~4.14:1 dark, ~3.0:1 light). Now 82% (~6.8:1 / ~4.8:1). One theme-unscoped bump fixes both. Truthfulness and consistency: - '4+ Years in AI' contradicted the site's own timeline (2022 entry is 'Started My Coding Journey'; AI entries begin 2024). Anchored to 2024 -> 2+. - Run It Local was 'written every week' on the timeline but 'Coming soon' on the signup. Timeline now says 'starting', matching the button and content.ts. - GitHub handle casing unified to ThomasJButler across Header/Footer/HomePage. Hygiene: - Removed a tracked scratch probe (.pt-scroll-probe.mjs) and broadened the gitignore glob (.pt-*.mjs) to catch its siblings. - Dropped dead deps: @types/marked (marked was removed this session) and the unreferenced @fontsource-variable/geist and /inter. - Deleted the stale, truncated tsconfig comment about files this session deleted; rewrote performance.test.tsx to assert real page modules and drive a routed path instead of the removed /blog and deleted SkillsPage/BlogPage. 122 tests, 0 type errors, 0 lint errors. Build + prerender clean; all 7 routes plus 404.html hydrate clean in both themes (Playwright). Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Tom's move into software was sparked by GPT-3 before it went mainstream, so his development journey has been AI-oriented from the start. Count "Years in AI" from 2022 (4+), and rewrite the note to state that reason rather than reading the 2022 anchor as a contradiction of the timeline. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
|
Important Review skippedToo many files! This PR contains 118 files, which is 18 over the limit of 100. To get a review, narrow the scope: Upgrade to a paid plan to raise the limit. This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (12)
📒 Files selected for processing (223)
You can disable this status message by setting the ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…last The Claude Design exports fill gaps that were visible on the site, not cosmetic ones. The Kicker, ISQ Agent and Sanctuary had no images at all and rendered the "cover incoming" placeholder; two of the three are featured and sit at the top of /projects. /case-study had no images whatsoever. And og:image was never set per route, so all eight pages shared one social card. Local-first, deliberately. Cloudinary bills on delivery, so pointing dev at it spends credits on every reload. Everything heavy is served from public/img/, which is gitignored: it is ~11MB of iteration, not source. src/lib/assets.ts is the single swap point, because Cloudinary appends a generated suffix to the public_id (Morpheus5_pdcmvr.png) and the URLs therefore cannot be derived from the filenames. Swapping to Cloudinary is one file, not thirty call sites. Covers and galleries: - Covers for The Kicker, Sanctuary, ISQ Agent and Morpheus, plus five-image galleries each. Composed at 2.133:1, which is exactly the card band's ratio. - Morpheus's gallery was a single 8.8MB GIF. It is now five stills and a 72kB loop: more to look at, 2% of the bytes. Under the hood (new section in the project modal): - The loop shows the mechanic moving, the diagram shows where the data goes, the wireframe shows the screen it lands on. Read in order they answer "how does this actually work" without assuming you already know. - The loops are conceptual, not decorative: ISQ's flags a low-confidence answer amber, The Kicker's splits a probability. The loops: GIF to MP4, 5.9MB -> 228kB including poster frames (96% off), via ffmpeg. Playback is driven by an effect, not the autoplay attribute, for two reasons: autoplay is only read while the browser selects the resource, so flipping it after mount does nothing; and rendering it conditionally on motionOk (localStorage + a media query) would diverge server from client, which is React #418 and would silently discard the prerender. Verified both ways in a browser: motion on -> playing, reduced motion -> paused at 0 showing its poster. Figure sizes intrinsically (h-auto + real width/height) rather than taking an aspect box. The gallery can force 16:9 because its stills are 16:9. The diagrams are 1.602:1, so the same treatment would quietly crop ~10% off them, and what lives at the top and bottom of a data-flow diagram is its labels. Case study: a hero, the wireframe under "The approach", and the diagram above the five stages it illustrates. The page went from zero images to illustrated, and from 4,305 to 4,591 characters of crawlable text. Social cards: /services, /case-study and /contact now ship their own, absolute via SITE (LinkedIn and X reject a relative og:image and fall back to no preview at all). Everything else keeps the sitewide card. The TechArticle JSON-LD gets the image field it never had. Also dropped an em dash from og:image:alt. 122 tests, 0 type errors, 0 lint errors. Build and prerender clean; all 7 routes plus 404.html hydrate clean in both themes. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Featured projects are printed twice on /projects: once in the showcase, once in the full grid below. Both used the cover, so six projects put the identical image on screen twice. The grid card now borrows one of the project's own gallery stills. The pick is a hash of the project id, not Math.random(). This page is prerendered, so a random choice would have the server bake one image into the HTML and the hydrating client pick another: React resolves that by throwing the whole tree away (#418), silently undoing the prerender. Hashing the id keeps server and client agreeing while still giving each project a different still, and a card looks the same on every visit, which is what you want in a grid someone may scan twice. Verified: The Kicker shows the-kicker-05 in the grid against the-kicker.png in the showcase, Sanctuary shows sanctuary-01. Only applies when the showcase is actually on screen (the unfiltered view). Filter to "Web" and The Kicker appears once, and shows the cover it was designed with. GIFs are excluded, so the legacy ModelViz (5.5MB) and Matrix Arcade (4.7MB) gallery gifs cannot become thumbnails; those two and any featured project without a gallery keep their cover. 122 tests, 0 type errors, 0 lint errors, all 16 route/theme combos hydrate clean. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
… their copy
Covers, five-image galleries and (for the first two) a diagram and wireframe in
the Under the hood section. Matrix Arcade gets its title sting as a video with a
poster instead.
The copy was stale, and the new artwork is what caught it. Every correction
below is quoted from Tom's own repos, nothing estimated:
- The Matrix Arcade shipped v2.0, "The Full Arcade", and this site never noticed.
The record still described the six-game canvas version, so the card would have
read "Collection of 6 games" directly above a new gallery tile reading
"12+ games" and listing Frogger. The release notes say "Twelve games are now
live" and "Phaser 3 integration", so: 12 games, Phaser 3 behind the React
shell, the shared save and achievements, procedural audio, offline PWA.
Language corrected to TypeScript, which its own description already implied.
- SQL-Ball is a Svelte frontend over FastAPI with LangChain and ChromaDB. The
copy said "Built with ... React". The 22 leagues and 7,600+ matches are
confirmed by the repo and stay.
- AI Code Generator is Next.js 15 over FastAPI, not "Python Flask backend, and
React frontend". The 10 languages and the analyser are confirmed and stay.
Matrix Arcade's gallery was a single 4.8MB GIF (verified 4,845,800 bytes). Five
stills and a 392kB sting replace it, so the second of the three legacy gifs is
now gone. Only ModelViz's 5.5MB one is left.
videos[] is now Array<string | {src, poster}>. The union is deliberate: a bare
string still type-checks, so Morpheus's existing entry needed no migration, and
the four previously-wired projects are untouched as asked. preload stays
"metadata" for both shapes, because "auto" would pull the whole clip on modal
open, which for the Cloudinary-hosted one is billed delivery.
The sting was 624x624 square against a 16:9 poster, so it would have pillarboxed
in the modal's aspect-video box. Cropped to 624x350 centred (verified the
marquee is not clipped) and re-encoded: 4.4MB to 392kB.
Assets stay local and gitignored, as before. The 2x masters (3200px, 2-5MB each)
are downscaled to 1600/1640 for local serving: 66MB becomes 12MB. Upload the
masters, not these, when Cloudinary happens; assets.ts records that.
Because Matrix Arcade is featured and now has a gallery, altThumb() engages on
it for the first time: the showcase keeps the cover, the grid card shows
matrix-arcade-04.
122 tests, 0 type errors, 0 lint errors. Build and prerender clean, all 16
route/theme combos hydrate clean, all new assets serve, lightbox and video
verified in a browser.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…rift fixes An adversarial audit of the assets read every gallery tile and checked each claim against the repos. It caught two things in my own copy that were simply untrue, both traceable to trusting the artwork over the source: - "Procedural audio, no audio files shipped" is backwards. useSoundSystem.ts says "Pre-loaded Matrix Trilogy audio takes priority; procedural synthesis is the fallback", with roughly 40 mapped files. The gallery tile claims "No audio files to ship" and I put that in the copy without checking. Now describes the Trilogy kit with the synthesis layer behind it. - "GPT-4o powered code generation" moved AWAY from the truth: the previous copy said GPT-4 and was right. backend/config.py defaults to gpt-4-turbo-preview and the README says GPT-4. Reverted, and the topic badge with it. Four more corrections, all verified: - Game names were the folder ids, not the titles players see. gameRegistry.ts, the repo's stated single source of truth, calls 'snake-classic' Matrix Snake and 'matrix-cloud' Matrix Bird. - The project was called "LangChain Code Generator" here and nothing else: the repo, the page h1, the deployed title and every new tile say AI Code Generator. The card heading matched nothing the visitor then saw. LangChain stays in topics, where a dependency belongs. - SQL-Ball's language dot said Python beside a Svelte topic badge and copy reading "The frontend is Svelte". GitHub's breakdown is 40.6% Svelte to 21% Python. Added Svelte to languageColors too: it was missing, so the dot would have rendered a grey #666, reading as "unknown" rather than as the language. - The SQL-Ball wireframe caption promised "see the SQL it wrote", which is the one region that blueprint does not draw. Reworded to the three it does. Also refreshed two comments the previous commit made stale: altThumb() still described the Matrix Arcade gif it had just deleted, and assets.ts named one masters folder when there are now two. 122 tests, 0 type errors, 0 lint errors, build and prerender clean. Verified in the built bundle that every false string is gone and every correction present. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The hero forced every cover into an aspect-video box with object-cover. The
designed covers are 1600x750, i.e. 2.133:1 and wider than 16:9, so the browser
scaled them up until the height filled and then sliced 16.7% off the width, 8.3%
from each side. That is enough to behead the wordmark: MORPHEUS rendered as
ORPHEUS, SQL-Ball as QL-Ball, with the panel beside it bleeding off the frame.
The hardcoded width={1280} height={720} was also asserting a ratio the image did
not have.
When the cover is one we designed and whose size we know, the box now takes the
image's own ratio (h-auto plus true intrinsic width/height): nothing cropped,
nothing letterboxed, no layout shift. Verified in a browser that the rendered
box ratio equals the natural 2.133:1 for Morpheus and SQL-Ball.
Legacy Cloudinary covers keep the old treatment deliberately. Their intrinsic
size is not known here, so h-auto without correct width/height would reflow the
modal as each one loaded, and object-cover happens to suit them: ModelViz is an
800x800 render whose subject is centred, so the 16:9 crop takes only empty space.
hasDesignedCover() keys on the project id rather than the URL shape, because the
values in MEDIA become Cloudinary URLs eventually and a startsWith('/img/')
check would silently stop working that day, taking the correct ratio with it.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…nst source Nine artwork defects, each confirmed by reading the image and then the repo that the image makes a claim about. Ordered by severity, with the fix stated. The headline items are two tiles that assert things the products do not do: sql-ball-03 badges a static 2024-25 season dump as "LIVE ANALYTICS" (the real app labels that exact spot "2024-2025 SEASON") and quotes the dashboard's 1000-row fetch cap as the match count directly under the true 7,681+; and ai-code-generator-03 invents a "// POWERED BY GPT-4o" tagline for a backend that runs gpt-4-turbo-preview, with a decorative amber bolt on a button that has no icon in the real app. Also records what is verified CORRECT, which is most of it, because several tiles looked wrong and were not. The Matrix Arcade "12+ games / FROGGER" tile is accurate: the arcade shipped v2.0 and the site copy was the stale side. The root cause of the two GPT-4o items is upstream and worth fixing there: the app's own How It Works panel claims GPT-4o while its backend calls gpt-4-turbo-preview, so the artwork faithfully captured a claim the product was already making about itself. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Three things. 1. The 9 re-cut tiles are in. Every one addresses an item from design/ASSET-RECUT-LIST.md, and ai-code-generator-03 is exactly right: the invented "// POWERED BY GPT-4o" tagline and the decorative amber bolt are both gone, and the button now matches the real app's plain text. There was a trap here. The big new folder shipped the OLD, unfixed versions of all 9, because it was exported from the same base as the previous drop. Checksumming caught it; copying that folder wholesale would have silently undone the entire last round. The fixed set was merged in first, and a checksum guard now confirms all 9 files in public/img derive from the fixed masters rather than the stale ones. 2. The Matrix Arcade gets its roster: twelve real gameplay screenshots, one per game, in a new "The games" section. They are 1.6:1 captures whose HUD sits hard against the top and bottom edges, so they go through Figure and size intrinsically. Dropping them into the gallery's aspect-video grid would have shaved 10% and clipped the SCORE and FOOD readouts. Verified in a browser: 18 tiles measured, 0 cropped. Titles come from the arcade's own gameRegistry.ts, not the filenames. That distinction has already bitten once: 'snake-classic' is shown to players as Matrix Snake and 'matrix-cloud' as Matrix Bird. The heading is "The games", with no number in it. "The twelve games" would be a hardcoded count of exactly the kind that left this site advertising six games for a whole release after the arcade had shipped twelve. 3. Four asset folders became one. desigassetnexports, newdesignassetexports and 9fixedassets were all verified as filename subsets of the survivor before anything was deleted, so no unique file was lost. The survivor is renamed design-assets/ and gitignored, along with globs for whatever the next drop is called. 132MB of working tree gone, and git status is clean again. Also untracked two .DS_Store files that were committed before .DS_Store was added to .gitignore, so the ignore rule never applied to them. 122 tests, 0 type errors, 0 lint errors. Build and prerender clean, all 16 route/theme combos hydrate clean, all 12 titles correct, lightbox pages through the roster, no broken image requests. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…rvices opener
Wires 5 of the 16 new tiles. The other 11 are held, because I checked every
claim on every tile against the two repos and most of the ReviewBot set
describes software that does not exist.
ReviewBot Protocol had no LangGraph. `grep -ri langgraph` returns nothing
repo-wide; requirements.txt is fastapi, uvicorn, openai, pydantic, requests,
python-multipart, and the review is one chat.completions.create call with a
single system prompt. Its tile 03 draws a sequence diagram naming twelve
functions, none of which exist, and tile 04 puts "issue distribution from a
real run" over percentages nothing could have produced. The record here also
said TypeScript (the backend is Python, the frontend is Create React App in
plain JavaScript) and Next.js (absent). None of that came from Tom: the README
never claimed it. It was invented in projects.ts, and the artwork was briefed
from projects.ts, so the tiles rendered our own mistake at 3200px. Copy fixed,
images held.
ModelViz claims "keys never touch a server" on its cover, tile 05 and its
diagram. True for OpenAI only: app/api/{anthropic,google,perplexity} are real
proxy routes that read the key from an x-api-key header and forward it, because
those three refuse a browser origin. A privacy claim on a site that sells
privacy, so those three are held; the four real screenshots and the structural
wireframe ship. That retires the 5.5MB GIF, the last legacy one on the site.
Also dropped "real-time streaming", which nothing in the repo does.
hasDesignedCover is now an explicit list, not `id in MEDIA`. Those were the
same question until ModelViz, which has designed gallery art but is still on
its 16:9 cover; under the old test, adding it to MEDIA at all would have
silently rendered that cover at 2.133:1 and squashed it.
Prices move to £750-a-day multiples: £6,375, £12,750 and £18,375 at 8.5, 17
and 24.5 days, retainer £1,425, revision round £1,275. The tip behind this is
about precision, which reads as computed and holds an anchor, not about charm
endings like £5,950, which read as retail discounting and would undo the
repositioning that took the audit from £4,800 to £6,000. All five moved up.
The retired round numbers join the dead-price guard.
/services loses ~145px of empty screen and one of two near-duplicate openers.
The h2 is gone rather than hidden: CardTitle renders a div, so the cards
contribute no headings and no level is skipped; the section takes an aria-label
to stay a named region. The lead card now sits at 403px, above the fold.
Big Bang Canvas retired from the grid. The build and its timeline entry stay.
Verified: 122 tests, 0 type/lint errors, build and prerender clean, 14
route/theme hydration combos clean, 6 modal images 0 cropped, no stale price
anywhere in dist.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The re-cut list records what is wrong. This one records what to draw instead, so nothing has to be inferred from tiles that were themselves inferred from bad copy. Each project gets a verified fact sheet split into "true, use freely" and "false, do not draw", then per-file actions with replacement copy written out. ModelViz needs three tiles touched and the fix is one sentence in each: keys are stored only in the browser and never on a server, but three of the four providers refuse a browser origin so those calls are relayed. That is a real CORS constraint and it reads better than the absolute it replaces. ReviewBot needs all eight redrawn, so the brief leans on what the app actually is: three tabs (Paste Code, Upload File, Review PR), two status pills, one GPT-4o-mini call, inline comments back on the PR, and a mock fallback that runs free with no key. That last one is real and nothing has ever depicted it. The four prompt themes are given verbatim so a "what it checks" tile can be drawn without inventing percentages again. Also states the latitude explicitly: restyling into the Matrix language is expected, the real frontend is a light Tailwind page and nobody wants that reproduced. Structure and claims are what must match. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The eleven held tiles came back redrawn. I checked every claim against the two repos a second time before wiring any of them, and this time they all hold. ModelViz is fixed where it mattered. The cover, the how-it-works tile and the diagram all said keys never touch a server, which was true of OpenAI alone. They now say keys are stored only in the browser, and that the other three providers refuse a browser origin so those requests are relayed. The diagram gained a step 03 for it, and the amber stayed on the provider call, which is the actual metered boundary; the relay is green because it is still your own stateless code. R3F became Three.js, since @react-three/fiber is a dependency that is never imported and the 3D really comes from react-force-graph-3d. ReviewBot ships four gallery tiles rather than five. The OAuth screen and the LangGraph sequence diagram had nothing true left once the fiction came out, so they were cut instead of redrawn. What replaced them is better than what was asked for: tile 01 shows the three real entry points with the five real endpoints, and tile 02 is the mock-review fallback nothing had ever depicted, with the £0 in green because that is exactly what the palette rule is for. The what-it-checks tile now carries the four themes from the system prompt verbatim and no percentages, and the wireframe says "no charts, no dashboard" rather than quietly omitting the dashboard it used to invent. hasDesignedCover's list now matches MEDIA's keys again, so the old `id in MEDIA` shorthand would work. It stays explicit anyway: those were two different facts that happened to be equal, and the next part-wired project puts them back out of step with nothing to catch it. Verified: 122 tests, 0 type/lint errors, build and prerender clean, 14 route/theme hydration combos clean, both modals 15 local images 0 cropped, both covers 2.133 natural against a 2.134 box, and a grep over both modals for langgraph, langchain, oauth, risk score and the two privacy phrasings comes back empty. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…t tiles The record here claimed Azure OpenAI, AI Search, Python and Streamlit. Three of those four were wrong. requirements.txt is plain openai with OPENAI_MODEL set to gpt-4o-mini, storage is SQLite through SQLAlchemy with no search service anywhere, and the frontend is Next.js 16 with React 19, Tailwind 4 and shadcn. Only Python survived, and it stays as the language because GitHub's own breakdown puts it first at 264kB against 159kB of TypeScript. The article comparison is added rather than corrected. The app groups the same story across outlets and has the model analyse how their framing differs, and the site had never once mentioned it, which is odd given it is the feature the name promises. Corrected ahead of the artwork on purpose. A design brief gets written from this file, so a wrong record here is how the next round of tiles inherits a mistake. That is exactly what happened to ReviewBot. All eight tiles are held, and four things need a re-cut. The refresh arithmetic is wrong on two of them: they say about fourteen requests a refresh and roughly seven refreshes a day, but process_new_articles loops over us and gb, where us iterates seven categories and gb is a single batched request. That is eight requests, so about twelve refreshes. The tiles contradict themselves, because their own footnote explains the UK batching that makes fourteen stale. Tile 04's settings panel is clipped on both edges. Tile 03's caption describes a positive and a neutral article as "neutral and urgent". "116 backend tests" stays: it is the README's own figure. My independent count is 123, and the note records why both can be right (the suite runs under unittest, which collects only TestCase methods) so nobody later corrects a number that was never wrong. Worth saying that this is the best set delivered so far, and the first briefed from the repo rather than from the site. It knew about the X-News-Api-Key header, SQLite, AGPLv3 and the UK source batching, none of which this file ever said. Verified: 122 tests, 0 type/lint errors, build and prerender clean, no Streamlit or Azure anywhere in dist, and NewsPerspective correctly still on its Cloudinary cover at the 16:9 fallback rather than a designed 2.133:1. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…nary swap Three of the four re-cut items came back fixed. Tile 05 now says "7 US categories plus 1 batched UK request, about 8 a refresh" and "roughly twelve refreshes", which is the real arithmetic. Tile 04's settings panel is inside the frame, and its red button is now Remove Key rather than Save Key, so the red is doing the right job. Tile 03's caption describes the positive and neutral articles it actually shows. The diagram is held. A checksum against the superseded set showed it byte identical, so it was not re-exported with the rest and still prices a refresh at about 14 requests and seven refreshes a day. That was true before NewsAPI dropped country=gb and the UK fetch became one batched call. So the project wires with a wireframe and no diagram, and the gallery carries the corrected figure on tile 05. Also the asset audit Tom asked for. Both folders were compared against what assets.ts actually references. public/img was already exact, 92 of 92 with no orphans, and is now 99 of 99. design-assets had fourteen unreferenced files, but three of them turned out to be live: og-case-study, og-contact and og-services are per-route Open Graph images set in scripts/routes.mjs and served from the site root rather than through /img/, so a naive sweep would have deleted them and silently broken social previews on three routes. Removed the eight superseded NewsPerspective files. Left linkedin-template and the two Claude Design handover notes, which are not frontend assets but are not junk either. design/ASSET-MANIFEST.md is generated from assets.ts so it cannot drift. It lists every master to upload with its Cloudinary transform, and calls out the five entries with no PNG or GIF master: the four loop posters, which are frames out of the clips and are better generated by Cloudinary from the video, and matrix-arcade.mp4, whose master is still in ~/Downloads. Verified: 122 tests, 0 type/lint errors, build and prerender clean, public/img an exact 99/99 match with no orphans and no 404s, the modal 7 images 0 cropped with the cover at 2.133 natural against a 2.134 box, and no Streamlit, Azure, "14 requests" or "seven refreshes" anywhere in the modal. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
… News Four things, and one of them turned out to be a bigger tangle than it looked. The projects page had a single card named "The Kicker" whose repo, demo and description were all The Premier League Oracle's. They are two projects. The Oracle is public, live and deployed; The Kicker is a separate, later take on the same engine, styled as a tabloid broadsheet with ten AI columnists, whose repo still 404s and which has no deployment. So the working project was showing under the name of the unreleased one, and asking for the Oracle "back" was exactly right. Both now have their own card and, for the first time, ids that match their names: the-kicker keeps the broadsheet artwork, premier-league-oracle is new. The Kicker's links are now empty rather than borrowed, and its status is coming-soon, which is what a finished-but-unpublished project actually is. Its engine description was stale too: it claimed five statistical models feeding an XGBoost ensemble, which is the pre-swap engine. The five-model framing survives only in April plan docs; the live code goes through butlerFacade to the Butler model. Highlights corrected, captions deliberately not, because those images really do draw five models and fixing one without the other just moves the contradiction. Both are on the re-cut list. The Oracle's copy is straight from its README, including the figure that makes the project: RPS 0.2000 over 2,660 matches against a bookmaker ceiling of 0.1939. That is a measured loss to the market, published, with a CI gate that fails any change making it worse. It takes ProjectCover's generated fallback panel, which is the case that fallback was kept for. Agentic AI and Commercial Portfolio get four gallery tiles each. Both are featured, so the galleries also stop altThumb repeating a cover twice on the page. No project count on the Agentic card on purpose: the live site's header says six production-ready applications while its README lists five, so any number there is one a reader can check and find wrong. LFC News App retired. It read r/LiverpoolFC through an API whose terms changed under it, so it is not work Tom is continuing. Verified: 122 tests, 0 type/lint errors, build and prerender clean, public/img an exact 107/107 match, 17 cards with both football projects present and LFC gone, the Oracle modal carrying both RPS figures and no stale five-model claim, and no broken /img/ requests or hydration errors. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The v5 archive in version-timetravel needed a full set, and the spec covered
six pages. The case study did not exist in v4, so a set captured without it
would under-represent exactly what changed between the two versions.
Naming and shape were already right and are untouched: the spec's
{page}-{viewport}-{browser}{-light} pattern is byte-for-byte the convention
v4-screenshots uses, so the two sets sit side by side and diff cleanly.
Note for whoever runs this next: the eleven "page content checks" in this file
fail, and did before this change. They assert v4 copy that v5 removed on
purpose, the clearest being "Hey, I'm Tom", which now survives only inside a
comment in content.ts explaining that it used to be the hero and is not any
more. The screenshot tests themselves all pass. Left alone rather than patched
blind, because deciding what v5 should assert instead is a real job and not a
drive-by on a screenshot errand.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
… doc LINKS.substack is now a real URL, so NewsletterStrip swapped "Coming soon" for a working Subscribe link on both the home page and /services on its own. That was the whole point of building it that way: the button never claimed to subscribe anyone to a thing that did not exist, and now it does not have to. The publication is empty today and that is the right order to do it in. A subscribe box that collects addresses before the first post is how you have people waiting for it; a button that lies about what it does is the only version of this that costs anything. The Kicker and the Oracle artwork moved out of ASSET-RECUT-LIST.md into FOOTBALL-PROJECTS-AUGUST.md, parked until the season restarts. There are no live fixtures right now, so both apps photograph at their worst: empty match cards, a dead ticker, a prediction grid with nothing to predict. Shooting them now would produce artwork that undersells them and needs redoing anyway. That leaves the re-cut list as a live queue of things actually blocking the launch, which is what it is for. Nothing in the football doc blocks anything: both projects have their own card, both read true against their repos, and only the artwork is outstanding. Verified: 122 tests, 0 type/lint errors, build and prerender clean, the URL returns 200, and both prerendered pages carry the link with no "Coming soon" left anywhere. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The .NET/React Calendar is removed. It was a take-home for a job application Tom did not take, and on a page that now sells local AI it was the weakest thing in the grid. Sixteen cards left. Two low-priority items added to the re-cut list, both real working projects still sitting on a single stock cover while everything around them has a gallery, a diagram and a wireframe. CSS Showcase is the easiest set on the whole list, because the project is its own artwork: it is a CSS showcase, so the demos already are the visual and straight screenshots need no design work. Flagged its "30+ pages" claim for checking against the repo while someone is in there, since that is exactly the kind of count that goes stale quietly. Python Projects is the one most improved by simply running it. A collection described as mathematical tools, climate visualisation and games, shown as one static Lorenz attractor, is underselling itself; four real outputs would say more than any designed tile. Also answered the question the re-cut list could not answer at a glance: Round 3 is not a redo. Seven of its eight tiles are wired and live, and the only outstanding file is diagram-newsperspective.png, which came back byte-identical and so still contradicts the gallery tile next to it. That is now stated at the top of the section instead of being inferable from three separate items. Verified: 122 tests, 0 type/lint errors, build and prerender clean, the calendar gone from the rendered page, 16 cards. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Screenshots of the live app rather than designed tiles, taken while Claude Design was unavailable. Picked four of thirteen: the archive hero with the full version strip, the chronology rail beside a restored 2.5 with its mobile inset, v5 running inside the viewer, and the same viewer at 390 showing the responsive preview. The other nine are either portrait card crops, a loading state, or mobile captures at 0.46, none of which survive a 16:9 grid. They arrived at 2880x1800, which is 1.600 and not 16:9. Padded to 3200x1800 rather than cropped: a 10% centre-crop takes the header off the top of all four, and the app's own chrome samples at exactly #0D0D0D, so the bars are invisible. Nothing lost, nothing stretched, all four measure 1.778 in the grid. Copy rewritten from the app itself. It had no longDescription and no highlights, which for the one project that is literally an argument about how fast the work moved was underselling it badly. The good line is the app's own: every version still runs, unmodified, and where a build references assets that no longer exist the archive says so rather than quietly patching it. Kept "nine versions" and recorded why, because the numbers look inconsistent until you check. The hero, the stat line and the chronology rail all say nine; versions.ts holds ten and the thumbnail strip shows ten, because the commercial site is in the archive without being a version of this portfolio. Coherent, but noted in a comment and worth a glance during the QA pass. Left `language: 'JavaScript'` alone. The app's source is TypeScript, but GitHub's breakdown for the repo is CSS, HTML and JavaScript, because ten archived static sites dwarf the viewer that displays them. Deferring to GitHub is the rule the other entries follow, and it is a coloured dot. Verified: 122 tests, 0 type/lint errors, build and prerender clean, public/img an exact 111/111, four gallery tiles at 1.778 with zero cropped, no broken /img/ requests and no hydration errors. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The hero eyebrow read "// tom_butler · full stack ai engineer · york, uk" while the header wordmark two lines above it already read "> tom_butler". It was saying the name twice inside the same screenful. What is left is the part that appears nowhere else: what he does and where. The masters are now split by what happens to them next. design-assets/upload-to-cloudinary/ holds exactly the 111 files assets.ts references and nothing else, verified file by file in both directions rather than by eye: nothing missing, nothing extra. Nothing was deleted, because nothing turned out to be dead. Seven files were unused by the /img/ map and all seven have a reason to exist, so they moved to design-assets/not-for-upload/ instead. Three of them matter: og-case-study, og-contact and og-services are live per-route Open Graph images set in routes.mjs and served from the site root, because a social scraper wants an absolute URL on this domain. A sweep that only looked at what assets.ts references would have binned them and broken link previews on three routes without failing a single test. The rest are the held NewsPerspective diagram, a LinkedIn template and two design notes. Five files in the upload are the shipped versions rather than 2x masters: the four loop posters, which are frames out of the clips and never had one, and matrix-arcade.mp4, whose master is still in ~/Downloads. The manifest says so and suggests letting Cloudinary generate the posters from the video instead. Deliberately no README inside the upload folder, so "select all and drag" stays correct. ASSET-MANIFEST.md is regenerated from assets.ts and carries the per-file transforms. Verified: 122 tests, 0 type/lint errors, build and prerender clean, upload folder an exact 111/111 against assets.ts, public/img still an exact 111/111, the old eyebrow gone from the built output and the wordmark still present. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Two full handoff folders (design_handoff_v5_operator-v4-redesign, design_handoff_matrix_portfolioV2inprogress), the standalone HTML prototype and its cherry-pick note, a dead pre-index.html entry point, and a stale asset audit that predates this session's 111-file build-out are gone for good: fully superseded, nothing left in src/ points at them. PRICING.md, the v4 planning cluster and design/ (asset manifest, recut list, football-projects note) stay on disk but move to .gitignore: still real reference, just not something a public repo needs to carry.
…iTomatic
All 111 design assets swap from local /img/ paths to real Cloudinary
delivery URLs, built from a small transform-table helper instead of 111
hand-typed literals. The four loop clips (isq-agent, morpheus, sanctuary,
the-kicker) needed the animated-GIF-to-video route confirmed live in a
browser first: the obvious /video/upload/ swap 404s, the working one is
f_mp4 on the same /image/upload/ endpoint the GIF already lives on.
Sanctuary had `links: {}` and rendered no buttons anywhere. It now links
out to its waiting list and to AiTomatic, the company behind it — a new
`waitlist`/`company` pair on the links type rather than relabelling the
existing "Live Demo" button, which would have overclaimed a build that
doesn't exist yet. Fixed the AiTomatic link on the career timeline too:
it pointed at a domain that's no longer Tom's.
Replaces the old site on
mainwith the v5 rebuild: a commercial shop window for Local & Private AI ("AI you own, not AI you rent"), not a portfolio. React 19 + TypeScript + Tailwind v4, prerendered to real HTML at build time, and hardened by a multi-pass adversarial review.Positioning
Prerendering (SEO + GEO)
react-dom/static./serviceswent from 0 to ~10,000 characters of crawlable text: it was an empty<div id="root">to GPTBot, ClaudeBot and PerplexityBot, none of which run JavaScript.llms.txt, enforced by a drift test.404.htmlin both themes.Performance
<link rel="modulepreload">in the prerendered head (via Vite's build manifest), so the browser fetches the route chunk in parallel instead of waiting for the entry chunk to execute the dynamic import.Accessibility & correctness
The adversarial review
The last stretch was a deliberately hostile, multi-model review (10 independent lenses, each finding attacked by skeptics; then a closing Sonnet + Opus + synthesis pass). It caught real things the happy path missed: FAQ answers missing from the prerendered accordion, a
z-indexbug painting a black wash over the text it was meant to protect, two hydration-divergence bugs, a keyboard regression, and a cluster of dead outbound links only a live-HTTP check could find. All confirmed findings are fixed and verified.Verification
npm run build(client + SSR + prerender) clean; all routes over the text floor.Not in this PR (follow-ups)
modulepreloadchange is functionally correct and low-risk; its perf win is worth confirming with a Lighthouse medians-of-5 run before we treat the number as banked.https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN