v5.2 — the Local & Private AI shop window: prerendered, hardened, adversarially reviewed#72
v5.2 — the Local & Private AI shop window: prerendered, hardened, adversarially reviewed#72ThomasJButler wants to merge 55 commits into
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
Turns the portfolio into something a buyer can act on. PRICING (/services). Three productised, fixed-fee offers — audit, local LLM setup, private RAG — with the audit flagged as the place to start. Fixed fees rather than a day rate: the buyer knows the downside before they commit, which is the actual barrier. Prices live in content.ts as `string | null`, and a null renders "Fixed fee · get a quote" rather than an invented number, so the page is honest today and becomes an anchor the moment Tom fills the figures in. FAQ (/services). The objections that stop someone emailing: is a local model actually as good, what hardware, what about GDPR — and "when should I NOT do this", which is the one that earns the trust. CASE STUDY (/case-study). A technical deep-dive on ISQ Agent: a private RAG agent that drafts supplier security questionnaires, grounds every answer in your own policy, cites its sources, and flags the weak ones for a human. Problem → approach → how it works → what it proves. It is my own work, not a client reference, so there is nothing to embellish — and the Results block renders only when CASE_STUDY.results has real measured figures. It is absent, not faked. HOME, reordered. Offer (three service teasers) and the argument (60-80% / 0 / £0) now sit directly under the hero; the skill bars and commit feed move below. A buyer gets the pitch before the dashboard; a developer still finds everything. PER-ROUTE SOCIAL META. The site had exactly one set of og tags — the homepage's — and LinkedIn, X and Slack do not run JavaScript. Sharing /services previewed as "Hey, I'm Tom", which matters now the commercial site links here. The build now writes dist/<route>/index.html per route with its own title, description, og:* and canonical. A direct hit on /services is also served its own file rather than going through the 404 fallback. Also: featured the AI & Agents Portfolio (its cover is one of the few real wide screenshots, and it makes the featured grid a clean 2x3). One real fix along the way: Reveal used to animate the <section> itself, so a landmark sat at opacity 0 until an IntersectionObserver fired. It now animates a div inside the section, so the landmark always occupies space and is never invisible to anything that isn't a scrolling human. The jsdom IntersectionObserver mock now reports elements as intersecting, which is what a real browser does. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Three things, all about the page the commercial site will link to. GitHub Pages was serving /services as an HTTP 404. The SPA-fallback trick (copy the entry to 404.html) renders the right page, but the status code is still 404 — which is what crawlers saw. The build now emits both services.html and services/index.html, so neither URL shape 404s and neither 301-redirects. ServicesPage is imported eagerly, like HomePage. Lazy-loading a landing page only buys a second round trip, and on a throttled connection the Suspense fallback rendered first and was then swapped for the real page — a layout shift on the one page that most needs to look composed. /services was scoring 0.82 on CLS; it is now clean. The "There is another way" link failed colour-contrast: at 1.69:1 against the surrounding copy it relied on colour alone to be distinguishable, where 3:1 is the floor. It is underlined now. Lighthouse, mobile, production build: / and /services both 100 accessibility, 100 best practices, 100 SEO, 100 agentic browsing. Zero failed audits on either. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…true
An audit pass over the branch, plus an adversarial review that produced 22
findings, of which the skeptics refuted 13 and confirmed 9.
Honesty (the important one). The case study's thesis is "nothing leaves the
building", and the same page renders Claude and Pinecone badges from the real
project data: ISQ Agent was built on Voyage + Pinecone + Claude. The claim was
simply false about that system. It now states what it actually ran on, and makes
the real argument instead, which is stronger: the model is a component behind an
interface, so the same pipeline runs against open models on your own hardware.
The repo and deployments are offline, so the dead "Code" link is gone from both
the case study and the project card.
Layout shift. Three separate causes, all measured:
- PageLoader reserved 60vh, so the footer painted inside the viewport and the
arriving route chunk shoved it down ~1,900px. That single move was 0.115 of
the 0.118-0.144 CLS on every lazy route. It now reserves a full screen, so
the footer starts below the fold and the shift scores nothing.
- The hero's decode effect sized each character slot by whatever scramble glyph
was in it, and half-width katakana are nothing like the latin they stand in
for, so the h1 re-wrapped every frame: 0.075 CLS on the LCP element. Each slot
is now sized by its final character with the glyph laid over the top.
- CLS 0.144 -> 0.029 (/about), 0.143 -> 0.027 (/case-study), 0.118 -> 0.003
(/projects, /contact), 0.067 -> 0.009 (/).
The rain morph switched itself off while you scrolled. mouseout bubbles, so the
window listener fired whenever the pointer crossed out of any element; scrolling
under a stationary cursor fires mouseout with no following mousemove to re-arm
the pointer, so the parting died mid-scroll and stayed dead until you jiggled the
mouse. Guarded on relatedTarget, which is null only on a real window exit.
Reproduced in a browser with stack traces before fixing.
Also: role="presentation" is prohibited on <video> (/contact was 99 a11y, now
100); sitemap.xml declared the namespace as sitemap.org rather than sitemaps.org,
which makes Search Console reject the whole file; llms.txt didn't know the case
study or the pricing existed.
Lighthouse, mobile, medians of 5: every route 100 a11y / 100 best-practices /
100 SEO. Performance 84-89, now bounded by the SPA's JS critical path rather
than by layout shift. 109 tests green.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…t survive measurement Initial payload 209.2 kB -> 162.6 kB gzipped (-22.3%). Lighthouse mobile, medians of 5: / 89->90, /services 89->91, /case-study 89->92, /about 90->92, /contact 91->92. Every route still 100 a11y / 100 best-practices / 100 SEO. What worked: Latin-only font subsets (-14.7 kB gz, the single best change). The bare fontsource entry points ship every subset, so 20 of 37 @font-face blocks were cyrillic, greek and vietnamese on a site that renders none of them. Six small subsets fell under Vite's inline limit and were base64'd into the stylesheet, where base64 does not compress: 18.6 kB of the CSS was base64 Cyrillic. CSS is 34.9 -> 20.1 kB gz, and it is on the critical path. LazyMotion + domAnimation + `m as motion` (-12.3 kB gz). domAnimation is enough: nothing here uses drag, layout or layoutId, and whileInView is a gesture feature, so Reveal still works (verified in a browser, not just asserted). `strict` is on, so reintroducing a plain motion.* throws rather than silently restoring the bundle. CommandPalette is lazy (-19.1 kB gz). It was mounted eagerly in Layout on every cold visit, dragging base-ui's Dialog and the whole floating-ui positioning stack into the entry chunk, to serve a feature behind ⌘K. What did not, and was reverted (this is the useful part): Preloading the fonts cost +740 ms LCP. Inlining critical CSS buys ~0 ms. Eager-loading the LCP cover on /projects cost 280 ms and 3 points. All three are standard advice, and all three are wrong *here*, for the same reason: the site is client-rendered, so nothing paints until the JS arrives, and any byte you promote onto the critical path is a byte competing with the JS that first paint is actually waiting on. /projects keeps loading="lazy" on its LCP image and keeps failing the lcp-lazy-loaded audit on purpose. The rationale is in CLAUDE.md so it doesn't get "fixed" again. Project covers now carry a srcset, capped at the existing w_800. The cap is the point: offering a w_1200 made a 2.6-DPR phone ask for the biggest candidate and fetch *more* than the flat w_800 did. Capped, it can only ever fetch fewer bytes. Also adds 4 regression tests for the rain morph, including the scroll case, and each was confirmed to fail against the old handler before being kept. 113 green. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The numbers on /services are one-line constants. This is why they are what they are: UK contract medians, the (small) London gap, the billable-days arithmetic that turns a £530/day contractor into a £750/day independent, and the floor below which you are earning less than nearly every AI contractor in Britain. Includes the evidence that argues against me: YunoJuno's booked-rate data puts UK AI work at £472/day. You should know that before you quote, not after. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Yorkshire-specific research (IT Jobs Watch, 6 months to 13 July 2026) says there is
no regional discount to apply, so two numbers go up.
Yorkshire AI contract median £550 (n=105). London £563. A 2.3% gap, and level
with the UK median. Yorkshire *Generative AI* is £575, above London's £565. And
Yorkshire AI rose +10% YoY while the UK fell 7%.
The northern discount is real for generalists and evaporates for specialists:
Yorkshire Python and Azure both sit ~9% under London on samples of 250-300, but AI
collapses to 2.3% and GenAI inverts. The geography penalty decays as you move from
a body in a seat to a specialist selling an outcome. Remote ML and GenAI contracts
pay £600, above London: nobody pays a location, they pay a capability.
Sector beats geography roughly five to one. Regulated buyers carry a 20-45% premium;
the London premium is 0-7%. NHS England, DWP, the FCA and 28 of the top-100 law
firms all have major Leeds operations, and none of them procure at "Yorkshire rates".
The decisive fact: a London consultancy already sells private AI *into Leeds* at a
published £6,500 diagnostic, £850/day and £10,000/month. It does not discount for
Yorkshire, so:
Audit £4,800 -> £6,000. It was 26% under that firm's published diagnostic.
Retainer £950 -> £1,400/month. £950 was under water: half a day of tuning is
£375 at target rate, leaving £575 for monitoring, updates and
re-indexing. Every published comparator is higher.
Builds held at £12,000 / £18,000. The "from" does the work.
Floor £500 -> £650 (internal, PRICING.md). £500 was the 25th percentile of the
Yorkshire AI market, and a 140-day year at £500 grosses less than the
Leeds *permanent* AI salary of £67,500, with none of the security.
Also adds the FAQ entry that answers the objection that will actually kill deals in
Leeds: "we already have Copilot." NHS England is rolling Copilot to 500,000+ staff
and a Yorkshire trust's AI policy names Copilot Chat as the approved alternative to
ChatGPT. The real competitor is an E5 licence, not OpenAI. The answer says plainly
that if Copilot does the job you should keep it, which is both true and more
persuasive than pretending otherwise.
PRICING.md records the counter-evidence too: YunoJuno's booked-rate data (£472/day),
the eroding scarcity premium (UK AI -7.2% YoY), and the fact that "Ollama" appears
in 9 UK contract ads, so this is a category being created rather than a market being
entered. It also records what NOT to claim: the NHS and law firms *can* lawfully use
OpenAI (the UK-US Data Bridge makes it lawful). The honest argument is friction and
liability, not prohibition, and a competent CIO would dismantle the other one.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
… the price
The site is now a shop window, so it is judged by what a buyer thinks in the first
two seconds and by what an AI search engine can read. It was failing both.
## To an AI search engine, this site was an empty div
dist/services.html shipped ZERO characters of body text. GPTBot, ClaudeBot and
PerplexityBot fetch HTML and do not run JavaScript, so every word Tom has written
was invisible to them, and no browser could paint anything until 139 kB of JS had
downloaded, parsed and run.
Every route is now prerendered at build time with react-dom/static's prerender
(which, unlike renderToString, awaits Suspense: four routes are React.lazy and
would otherwise have serialised a loading spinner). Zero new dependencies.
/services 0 -> 6,202 characters of crawlable text
/updates 0 -> 14,530
/ 0 -> 3,155
Two things had to change or the prerender would have been worthless. PageTransition
wraps <Outlet/> in initial={{opacity:0}}, so every route's entire body serialised as
style="opacity:0": crawlers would read the words and browsers would paint a blank
screen. And DecodeText rendered no visible characters at all (empty glyph spans over
a visibility:hidden sizer), which also meant the h1 (the LCP element) was invisible
for the first 640ms-1.4s of every visit. The decode now runs *over* real text rather
than instead of it.
Hydration is clean on all 7 routes in both themes, verified in a browser rather than
assumed. The two header toggles read localStorage during render, so the server
guessed wrong and React threw the whole tree away (#418), silently undoing the
prerender. They now render one fixed state until hydration finishes.
## The first 2.5 seconds were a black screen
The boot intro held an opaque, scroll-locked overlay for 2.2-2.9s, swallowed the
first click, and the real page had already rendered behind it. Lighthouse does not
emulate reduced motion, so it sat through the black screen too. It is gone from
arrival and survives on demand (⌘K, "replay intro"). Personality you choose to see
is charm; personality you cannot skip is a toll.
## The site was arguing against its own price list
Self-assessed skill bars ("React 95%"), a pulsing "open to work" dot, "Open to
full-time roles & freelance" on the contact page, "Uptime 99.9%" (of what?), an
About page headlined "Why I Love Programming", and "Buy me a coffee" sitting under
the phone number on the page where a £12,000 engagement starts. A consultant
charging £6,000 for an audit does not advertise that he would rather have a salary.
All replaced with evidence: the case study, and dated work you can go and check.
The hero now leads with the argument instead of a greeting: "AI you own, not AI you
rent." The og:image has said that for months, which was a sign the page was pointed
the wrong way round.
## Design: one system instead of a tic
The same eyebrow device (// snake_case + hairline) appeared 11 times, five of them
on the case study alone, and it inverted the heading hierarchy every time: the
decorative label was the h2 and the real heading an h3 beneath it, so `why_local_ai`
sat directly on top of "Why Local AI". One SectionHead now, human heading first, and
the hairline is gone entirely.
Amber earns its keep: green is what you own, amber is the meter running (per-token
bills, data leaving). It is the only non-green hue and it is never decorative, which
is exactly what the old cyan/amber badges were. The three green stat cards became
RentVsOwn, which shows what you are saved *from* rather than only what you gain.
## Bugs that were actually shipping
- The dark card hover glow was dead code. `:root [data-slot=card]:hover` has the
same specificity as the `.dark` rule, sits 14 lines later, and `:root` matches
<html class="dark">, so dark cards took the light treatment. Scoped `:not(.dark)`.
- `.fx-scrim::before` had z-index: 0, so the scrim (a 0.92-alpha near-black wash
whose entire job is to hide the rain behind text) painted ON TOP of that text.
Element children were rescued by a z-index:1 rule; bare text nodes were not, so
every scrimmed paragraph on the site was being read through a black veil.
- The light-theme header used dark-only tokens that never flip: pale grey nav on
near-white (~2.2:1) with a black active pill.
- Every glow hard-coded oklch(...145), so the accent pill recoloured the text and
left the site glowing green.
- `layout` and mode="popLayout" on the projects grid need framer-motion's layout
feature, which domAnimation does not ship: dead props since v5.1.
## Measured
Lighthouse mobile, medians of 5, Slow 4G + 4x CPU:
route perf a11y bp seo FCP LCP TBT CLS SI
/ 94 100 100 100 2.5s 2.5s 10ms 0.004 2.5s
/services 95 100 100 100 2.4s 2.4s 0ms 0 2.4s
/case-study 95 100 100 100 2.4s 2.4s 0ms 0.026 2.4s
/about 95 100 100 100 2.4s 2.4s 0ms 0 2.4s
/contact 95 100 100 100 2.4s 2.4s 0ms 0 2.4s
/updates 93 100 100 100 2.6s 2.6s 0ms 0.001 2.6s
/projects 88 100 100 100 2.0s 3.6s 0ms 0 2.0s
Speed Index 3.5-4.7s -> 2.0-2.6s. TBT is 0ms everywhere. Performance was 84-89.
Prerendering exposed a font-swap CLS of 0.089 on home, which had been hidden by the
text being invisible until JS ran (a worse bug concealing a smaller one). Orbitron
is 18.9% wider than Arial, measured with canvas measureText, so every heading
re-wrapped on font load. Metric-matched fallbacks: 0.089 -> 0.004.
Also: 2,969 lines of dead code deleted (unrouted pages, unused ui primitives, dead
hooks), unused deps dropped, robots.txt now welcomes the AI crawlers by name, and
structured data is per-route: /services carries a ProfessionalService with real
Offers and prices plus a FAQPage, which is exactly what an assistant quotes when
someone asks what this costs.
113 tests green.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…to you
Two of these were found by tooling that had never actually run, which is the theme.
`npm run lint` globbed `src/**/*.{js,ts}` and therefore never linted a single .tsx
file: the entire application. Pointed at `src`, it immediately found 9 errors,
mostly unused imports left behind by the v5.2 rewrites. Now 0 errors.
`npm run preview` served 404 for every route. The build chains an SSR step with
`--outDir .ssr`, and Vite was resolving preview's outDir to that instead of dist,
so the documented way to check a production build showed you an empty site. It
cost me a round of blank screenshots before I noticed. Pinned to `--outDir dist`.
/projects LCP: re-tested the eager-image conclusion, because prerendering
invalidated the reason behind it. The old finding was that `loading="eager"` could
not help since the <img> did not exist until React mounted; now the tag is in the
raw HTML and the preload scanner sees it immediately, so the experiment should have
won. It lost, and harder: 92 -> 87, LCP 2,873ms -> 3,804ms (medians of 5).
Discoverability was never the bottleneck, bandwidth is. A high-priority image does
not arrive sooner on a throttled link, it takes the pipe from the CSS and the JS.
Reverted, and the comment now records both experiments so nobody runs a third.
Visual pass over 28 screenshots (7 routes x 2 themes x 2 widths) found the hero
breaking as "not AI you / rent." on desktop, which strands the word the whole
headline turns on at the start of a line. Copy column 1.05fr -> 1.2fr and the cap
70px -> 62px; verified two lines at every width from 390 to 1920.
Killed two pieces of templated copy the screenshots exposed. /services closed on
"Let's Build Something Great" over "Free consultation. No obligation." which is the
closing block of every agency site ever built and reads as evasive directly under a
price list. And /contact still offered "a project, opportunity, or just a chat about
code": "opportunity" is what you write when you are hoping to be offered a job, and
it was the last thing a buyer read before a form that starts a five-figure
engagement.
Lighthouse mobile, medians of 5:
/services 99 (the page the commercial site links to)
/contact 96
/case-study 95
/about 95
/ 93
/updates 93
/projects 90
100 accessibility / best-practices / SEO everywhere. TBT 0ms everywhere. 113 tests,
0 lint errors, 0 type errors.
CLAUDE.md now documents the prerender contract and the four things that exist only
to keep the prerendered paint visible, because every one of them looks like dead
weight to someone tidying up.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Every value is read out of the running site rather than remembered: the hex was sampled by painting each token into a canvas and reading the pixel back, because the source is oklch and converting it by hand is how you end up with a brand colour that is subtly wrong everywhere. The asset list leads with the three projects that have no cover art. Two of them are featured, which means they render a generated fallback panel in the 3-across grid at the top of /projects: the first thing a buyer sees on that page currently looks unfinished. It also carries the rule that matters most, which is the one an asset brief usually omits: amber means the meter running (API bills, data leaving) and green means what you own. A third hue breaks the brand. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…review
The 10-lens review ran out of session before most of it finished (27 of 231 agents
completed), but the two findings that survived three skeptics each were both real,
both mine, and both proven with an A/B on the real production build rather than by
argument.
1. REDUCED-MOTION VISITORS LOST THE ENTIRE PRERENDER.
DecodeText rendered a different DOM shape depending on prefers-reduced-motion: a
flat <span class="ch ch--locked"> under reduce, a sizer/glyph pair otherwise. The
server cannot know that preference. framer-motion's useReducedMotion returns null
during a Node prerender, so the build always emitted the animated shape, and a
visitor with reduced motion hydrated a structurally different tree. React gave up
(#418) and rebuilt the whole root from scratch, throwing away the prerendered page
it had just painted.
The page still looked fine, which is exactly what made it dangerous. It silently
undid the prerender for the people least able to afford a slow, janky page, and it
defeated the very mechanism useHydrated was written to protect.
The branch was unnecessary anyway: `.ch--pending` is opacity 1 and the glyph already
carries its real character, so the animated markup IS the finished text. Under
reduced motion the effect simply never starts the rAF loop. One shape, always.
Verified: hydration is now clean on / , /services and /case-study under both
`reduce` and `no-preference`.
2. THE PAGE SCROLLED ITSELF TO THE TOP WHILE YOU WERE READING IT.
PageTransition recomputed `isFirst = !hasEntered` on every render. The mount effect
flips that module flag without re-rendering, so the *next* render of the same
instance saw isFirst go true -> false, which changed the layout effect's dependency
array and fired scrollTo({ top: 0 }) on a route that had not changed.
In the shipped site that meant: land on the home page, scroll down to read, and get
thrown back to the top the first time Layout re-renders. Toggling the theme did it.
Opening the command palette did it. And so did doing nothing whatsoever for 22
seconds, because the white-rabbit easter egg calls setRabbit(true) on a timer.
Frozen in a ref for the life of the instance, and the scroll effect is mount-only.
Verified in a browser: scroll to 1500, toggle the theme -> still 1500; wait 25s
doing nothing -> still 1500; navigate to another route -> 0, which is correct.
While in there, removed PageTransition's `if (reduced) return <div>` early return.
It was a second copy of bug 1 waiting to happen, in the worst possible place (it
wraps <Outlet/>, so it is the opacity of every route's whole body), and it was
redundant: MotionConfig reducedMotion="user" in Providers already suppresses the
transform. That is what MotionConfig is there for.
Both regression tests were confirmed to FAIL against the old code before being kept.
117 tests, 0 lint errors, 0 type errors. Lighthouse unchanged: /services 95,
/case-study 95, /about 95, /contact 95, / 93, /updates 93, /projects 89, and 100
a11y / best-practices / SEO everywhere.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Four of them were swept into the previous commit by a `git add -A`: two refute probes, a repro script, and __repro_pagetransition.test.tsx, which was quietly running as part of the suite. Gitignored the patterns so the next review cannot do it again. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
Every Cloudinary asset was fetched to see what actually ships. Three project gallery GIFs total 18.5 MB over the wire (ModelViz 5.3, Morpheus 8.6, Matrix Arcade 4.6), because Cloudinary refuses to transform them: they exceed its 50-megapixel all-frames limit, so they are served raw to anyone who opens those modals. As MP4 that is roughly 0.5-1 MB. public/logo.svg turns out to be 210 KB of traced raster, auto-generated paths from a bitmap rather than a drawn vector, and it is used nowhere. So the brand has no scalable mark at all, which is a problem the moment anything needs to exist outside a web page. And the brief had nothing for LinkedIn or YouTube, which is where the next six months of attention is going. A reusable LinkedIn post template is now ranked second of seven: it is the asset the largest number of people will actually see. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
"Nothing ever leaves the machine" is the absolutist version of the pitch. It is also false, and it is the kind of false that gets dismantled in the room: the model has to arrive from somewhere, Ollama checks for updates by default, and its API binds with no authentication at all. A competent CIO knows all three. Claiming zero packets and then being corrected costs the whole meeting. "Your data never leaves" is exact, survives scrutiny, and is still devastating against an API where every single query leaves the building permanently. The differentiator was never "no packet ever moves". It is "no customer data ever moves, and there is no third-party processor to assess". Two new FAQ entries: "Does anything leave the machine at all?" says plainly that pulling a model is a one-off, auditable download you choose the timing of, and that nothing of yours follows it. "How do you lock it down?" answers the question a locked-down network actually asks. And the hardening is now scope, not a caveat: "Locked down: bound to localhost, auth on the API, update checks off" is a line item on the Local LLM Setup. It is billable work, and volunteering it is what tells a compliance-minded buyer that you have done this before. Also fixes a live content drift the review caught. public/llms.txt, the file whose entire job is to tell AI crawlers what Tom charges, was still quoting the OLD prices: £4,800 for the audit and £950/month for the retainer, both of which were raised (to £6,000 and £1,400) after research showed a London firm sells the same diagnostic into Leeds at £6,500. Nothing failed and nothing warned. The first anyone would have known is a buyer quoting a price back at him that he no longer offers. So the prices and the FAQ now have a drift test, and it earned its keep immediately by finding a second gap: "What happens if you get hit by a bus?" was on the page but missing from the FAQPage JSON-LD, so the single most trust-building answer on the site was invisible to the assistants most likely to be asked that exact question. /services now ships 6,438 characters of crawlable text. 121 tests, 0 lint errors. Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
The re-verification workflow hit the weekly limit at 70 of 183 skeptics (and its
agents were corrupting each other by all running `npm run build` into one shared
dist/, which is also what made my own "build failed" earlier: a race, not a bug).
So I verified the high-severity findings directly against the real prerendered output
and fixed the ones that reproduced.
1. The prerendered home <h1> read as gibberish to a text-only crawler.
dist/index.html contained "AI you own,AAII yyoouu oowwnn,,not AI you rent." because
every character was present twice: once in the width-reserving sizer, once in the
visible glyph. The sizer now carries its character in a data attribute drawn with
`::before content: attr(data-c)`, which reserves the same width but is not a text
node, so it never reaches textContent. The headline now reads cleanly. CLS held at
0.004: the ::before reserves exactly the width the text node did.
2. 18 project cards were prerendered at opacity:0. Every card in the /projects grid is
a motion.div with initial={{opacity:0}}, which framer-motion serialises as inline
style, so the entire grid was invisible to a crawler and to anyone on a slow enough
connection to see the page before the bundle ran. Gated the initial state on
useHydrated, the same fix PageTransition already uses: visible in the prerender and
on the hydrating render, animated only on later filter changes a person can see.
3. Project cards could only be opened by mouse (WCAG 2.1.1, Level A). They were
clickable divs with no keyboard path, so a keyboard user could never open a project.
role="button", tabIndex, an aria-label, and Enter/Space now open the modal. Verified
in a browser: Tab reaches "Open details for ModelViz", Enter opens it.
4. Buttons had a focus ring of ring-ring/50, a half-transparent green that measured
~2.2:1 against the dark page and failed WCAG 1.4.11 (focus needs 3:1). Since
`outline-none` removes the browser default, that faint ring was the ONLY keyboard
cue. Now full-opacity --ring with a 2px offset in the page colour, so it reads
against both the button's green fill and the near-black behind it. (The other
controls already had a full-opacity border-ring, so only the button was affected.)
5. blog.html redirected to /react.html, which no longer exists: a redirect to a dead
page. Nothing internal links to /blog, so old inbound links now land on /updates
(the Dev Journey, the nearest thing to the old blog) instead of nowhere.
Also made the two dead STRUCTURED_PRICES / STRUCTURED_FAQ_COUNT exports live by having
the drift test import and assert against them, rather than grepping the file as text.
Refuted and left alone: the claim that /services FAQ answers are missing from the
prerendered HTML (base-ui renders accordion panels into the DOM; they are present).
122 tests, 0 lint errors, 0 type errors. Hydration clean on / and /projects in both
motion preferences. Hero unchanged.
Claude-Session: https://claude.ai/code/session_01MocCAN6KnoTE3CGyTrWpgN
…O footguns
More findings from the review, verified directly.
Titles: all seven pages set their own document.title, and all seven had drifted from
what the build prerenders. A crawler indexed "Local & Private AI for business", the
browser tab said "Full Stack AI Engineer, Local AI & On-Device Systems", and a shared
bookmark got the tab version. Now one effect in Layout sets the title from
titleForPath() in routes.mjs, the same table the prerender uses, so a client
navigation can only ever set a title the crawler already saw. The eight per-page
title effects are gone.
LinkedIn: the header and footer linked to linkedin.com/in/thomasbutleruk while
content.ts, the contact page and the JSON-LD sameAs all use /in/thomasjbutler. So the
nav sent people to a different handle than the structured data claims. Unified on the
canonical thomasjbutler. (Tom: confirm that is the right one.)
prerender.mjs, two latent footguns that would have detonated on a future edit:
- Every meta rewrite used `String.replace(re, "$1...$2")`, where a literal $ in a
title or description is read as a backreference. A price like "$15 per million
tokens" in any description would have mangled the tag, and a `$'` would have
injected the whole document. Switched to function replacements, which insert text
verbatim.
- The JSON-LD was stringified straight into a <script>; a string containing
"</script>" would close it early. Now escapes the slash.
structured-data: ProfessionalService (a LocalBusiness subtype) had no address, which
Google's rich-results test warns on. Added it.
Dead code: BlogList.tsx (imported animejs, which is not a dependency, and was routed
nowhere) and its orphaned animejs.d.ts, both deleted.
Refuted and left: the /services FAQ answers ARE in the prerendered HTML (base-ui
renders accordion panels), so that finding was wrong.
122 tests, 0 lint errors, 0 type errors. Titles verified to update correctly on
client-side navigation; hydration clean.
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 222 files, which is 122 over the limit of 100. To get a review, narrow the scope: Upgrade to a paid plan to raise the limit. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: ⛔ Files ignored due to path filters (16)
📒 Files selected for processing (326)
You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ 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
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