Changelog

I love that my site is a constant work in progress thanks to AI. I can add new features, content types, tweak things to improve them, easily fix bugs, and keep my site up to date with modern trends. Nothing holds me back from iterating as much as I want or need. A website shouldn’t be set-it-and-forget-it, but it was before AI because changes were so time consuming or expensive.

This page is the record of this ongoing process: the ideas that worked, the ones I walked back, the small fixes nobody else would ever notice. I like that my site is never really finished 🎉

For the current-state snapshot instead of the play-by-play, see how this site works.

27 changes shipped
v1.4.105 current version
March 2026 iterating since
built with AI, session by session

Every entry on this Changelog page is now directly linkable

Every entry in this changelog now has its own link. Hover over one and a small link icon shows up next to the version and date. Click it and the direct URL to that entry copies to your clipboard, ready to paste into a message or a post. Open one of those links and the page scrolls straight to the entry, expands it, clears any active filter so nothing hides it, and briefly highlights it so you can see where you landed.

The tricky part is that each entry is a native HTML details element, the browser’s built-in collapsible box, and browsers won’t open a collapsed one just because the URL points at it. A little JavaScript handles that: it reads the address when the page loads, opens the matching entry, and scrolls it into view with enough room above to clear the fixed nav bar. Each anchor comes from the entry’s own slug instead of its position in the list, so a link keeps working even as new entries pile up above it.

Deep-linking into a collapsed details element looks like it should work on its own. The anchor gets you to the element, but actually opening it is still up to your own code.

Made the footer links editable, and fixed a mobile bug on /plugins

The footer links (how this site works, social links, RSS) were hardcoded PHP, the same way the top nav was before it got wired up to a real WordPress menu. They’re an editable menu now too, from Appearance > Menus. Twitter/X, LinkedIn, and GitHub render as their own icons instead of text labels, matched automatically from the link’s own URL rather than whatever label gets typed into the menu item, so a new social link added later just picks up the right icon on its own.

The tagline under each card on /plugins was rendering with a garbled, partially clipped second line on phones, caught from an actual phone screenshot rather than a code review. Two elements inside the card were both set to grow and absorb the same leftover space. That competition broke the CSS truncation that’s supposed to cap the tagline at three lines, letting a half-visible ghost of the second line bleed through instead of a clean cut. Scoped the fix to just one of those elements, and the text renders clean now at every width tested, phone and desktop both.

A follow-up fix for a stray bullet before the first footer link plus the new icons, main.css and main.js now carry a cache-busting tag in their URL, tied to the theme’s version number. An unchanged version meant an unchanged URL: browsers kept serving the old file even after the new one was live. Switched that tag to the file’s own last-modified time instead, so a rebuild reaches visitors from here on, whether or not the visible version number moves.

“How this site works” page can now be edited without a theme redeploy

Everything below the intro on this page — the anatomy list, the plugin grid heading, the checklist, the closing paragraph — used to be hardcoded PHP. Editing any of it meant a code change and a full theme redeploy. It’s now a real WordPress admin panel: two drag-and-drop item lists (the anatomy timeline, the checklist) and a third panel that lets the four sections themselves be reordered by dragging, no code required for any of it going forward.

The plan going in was to use ACF’s Repeater and Flexible Content field types — until it turned out those are still ACF PRO-only, despite a persistent claim floating around that they went free in 2023. Free ACF only ships single-value field types (text, textarea, rich text, dropdowns), nothing repeatable. So the drag-and-drop UI is hand-built instead — and deliberately not built on jQuery UI Sortable either, even though WordPress bundles that by default. It runs directly on the browser’s own native HTML5 Drag and Drop API (draggable=”true” plus dragstart/dragover/drop events): no plugin, no JavaScript library, no WordPress-bundled tooling at all. Same API every “drag to reorder” interface on the web ultimately runs on under the hood.

Expanded AI site management capabilities by adding support for WP REST API

A conversation in the WordPress Core AI channel got me thinking about how narrow my Abilities plugin was: every ability in it was coded one-by-one: get these posts, update this meta, list these plugins. Anything WordPress’s REST API could already do that I hadn’t specifically written a wrapper for, comments, another plugin’s own data, half the fields on a post, wasn’t available to Claude at all.

So we added full REST API read access. Claude can call any registered REST route on this site with GET: core routes, a plugin’s own namespace, whatever’s there.

We implemented Write with strong limitations. Instead of coding a fresh ability every time I want Claude to touch something new, there’s one ability now that can call any route with a write method, but only after Claude spells out the exact request in plain language and I approve. A short list of routes are blocked no matter what I say in the moment: user accounts, deleting plugins, site settings, and permanent delete that skips trash.

I also pulled out something that had been sitting in the plugin for months: a 30 second delay between staging a post edit and applying it. It existed to force a real pause before Claude went ahead and did things. But since then Claude states the change and waits for me every time, so no need for the delay anymore.

Fixed template bug that was blanking out alt text on /media/

All 38 images on /media/ were rendering with empty alt text, even though the actual media library entries almost all had good descriptions written for them. It wasn’t a content gap – template-parts/media-card.php was calling the_post_thumbnail() with a hardcoded 'alt' => '', overriding whatever alt text the attachment actually had. Removed the override in v1.4.88 so the real alt text comes through.

Two posts had a different version of the same problem in the other direction: the alt text was baked directly into the post content’s <img> tag at the moment the image was inserted, and it never picked up later edits to the media library. One post had good alt text sitting in the library that the post itself just wasn’t using – an easy patch. The other had no alt text anywhere, on a world map graphic showing where the company showed up at events last year – had to actually look at the image and write a real description before fixing anything. Thsi shows you can’t fully trust AI to do things 100% right in your site or work.

Also added a <header> landmark around the site nav (it had nav/main/footer landmarks but nothing wrapping the nav itself), and bumped the contact form and newsletter signup’s mobile font size from 13-14px to 16px – anything smaller triggers iOS Safari’s auto-zoom on focus, which is a small annoyance every time someone taps into a field on their phone.

The two broken images in old posts had opposite failure modes: one had good alt text that the post just wasn't using, the other had none anywhere. Grepping for empty alt attributes alone wouldn't have told them apart.

Filled out the favicon and app-icon set

The site had a 32px and 192px favicon plus an Apple touch icon, but three pieces were missing: an SVG version (scales cleanly to any size, and is the format modern browsers actually prefer when it’s offered), a maskable icon for when the site gets added to a phone’s home screen, and the web app manifest that ties the icon set together.

The maskable icon needed more than a resize. Android and iOS crop app icons into their own shapes – circle, squircle, whatever the current style is – using a centered safe zone that’s roughly 80% of the icon. The existing icon’s accent dot sat right at the edge, close enough that a circular crop would have clipped it. Built a new version with the same mark scaled down and centered with real padding, on a fully opaque background (the existing icon had transparent rounded corners, which is wrong for this specific use – the mask expects a solid square to crop from).

The interesting failure was favicon.ico itself. Browsers and crawlers request that exact path from the domain root directly, regardless of any <link> tag on the page – it’s one of a handful of paths with this baked-in convention. First attempt served it from a WordPress hook that would stream the file on request. It wouldn’t fire because some paths at the root get resolved before WordPress loads at all, so there was no hook, at any priority, that could catch it. The only real fix was getting an actual static file placed at the literal domain root, outside WordPress entirely. Same story for the web app manifest at its two conventional paths (/manifest.json and /site.webmanifest) – the manifest linked from the page already worked fine for real browsers, but a couple of the paths some tools check by convention needed physical copies of their own.

The first fix for favicon.ico tried to serve it from a WordPress hook. It never had a chance - some paths get resolved before WordPress even loads, so no amount of hook priority could have caught it. The fix was a real file, not more code.

Improved performance by self-hosting fonts, plus a few small CSS fixes

Fraunces and IBM Plex Mono used to load from Google Fonts’ CDN. They’re self-hosted now – 12 woff2 files living in the theme itself, covering just the latin and latin-ext character sets Google splits fonts into by default. Dropped the Cyrillic and Vietnamese sets entirely, since this is an English site and nothing on it needs those characters – that alone cut the font payload roughly in half. One less third-party connection the browser has to make before it can render text.

Three smaller CSS fixes rode along in the same release. scrollbar-gutter: stable reserves space for the scrollbar up front, so a page that grows past the point where a scrollbar appears doesn’t shift its whole layout sideways by the scrollbar’s width. The 404 page’s full-height layout switched from 100vh to 100dvh, which tracks the actual visible viewport on mobile Safari instead of the address bar’s maximum-collapsed height – fixes a small jump when the address bar shows or hides. And text-wrap: balance on headings plus text-wrap: pretty on paragraphs, which let the browser choose better line-break points instead of just wrapping wherever the text happens to run out of room.

This site now explains itself

Added two new pages: a standing overview of how the site is built, and a real page for every custom plugin I’ve written for it, each with its own URL instead of being buried in a repo nobody visits.

Both pull live data rather than static claims. The site-anatomy section counts entries per content type with wp_count_posts() instead of a hand-typed number, and the performance snapshot reads straight from the same stored history one of the plugins already uses to track scores over time, so it updates itself instead of me remembering to.

The changelog and the new overview page now link to each other. This page is what changed and when; the new one is the current state, kept up to date in place rather than written up once and left to go stale.

Why: I check this site against a few outside checklists on a schedule, and wanted somewhere to explain not just what those checks catch but why I bothered fixing what they flagged. A one-off blog post about it goes stale the moment I ship the next fix. This page is built to just be updated instead.

One of the new page's live stats briefly showed a performance score of 6600 instead of 66 - a scale mismatch in scan data that had been sitting there for months, invisible until real numbers started rendering somewhere public.

Fixed three gaps found by specification.website

Ran the site against specification.website‘s MCP to audit the full checklist. Three fixes landed in theme v1.4.85: add_theme_support('automatic-feed-links') in functions.php, so the RSS feed at /feed/ finally gets a proper <link rel="alternate"> tag in the page head instead of only being linked from the footer (this should have been added when the theme was originally developed). Also added theme-color and color-scheme meta tags to header.php, using the site’s actual palette (#FAFAF8 light, #0D0D0D dark) so browser chrome and native form controls know which mode to expect before the page’s own dark-mode script runs.

Follow-up in v1.4.86: the two theme-color tags shipped in v1.4.85 only follow the OS’s light/dark setting, not this site’s manual toggle. Someone on a light OS who flips the toggle to dark got a dark page with a light-colored browser chrome, since the OS-scoped tag still matched. Added a third theme-color tag with no media attribute, placed ahead of the other two, and had the existing dark-mode init script (plus the toggle button’s click handler) keep it synced to whichever theme is actually showing. The two original tags stay in place as a fallback for visitors with JavaScript off.

Four images on the homepage looked like they had the same missing alt text problem. They didn't - they're deliberately wrapped in aria-hidden links with tabindex="-1", so the card title next to them carries the accessible name instead. Caught before turning a correct pattern into a redundant one. The theme-color follow-up had its own near-miss: testing found the browser chrome tint missing on mobile Chrome and desktop Safari, which looked like a bug but turned out to be normal - Chrome on iOS can't show it at all, and Safari has it behind a tab-layout setting most people never touch.

Improved site “agent-ready” score

Ran the site through isitagentready.com after the last round of spec-compliance work. It flagged three more gaps, and closing them turned this into the biggest update yet to the Make My Site Agent-Ready plugin – and we added one more accessibility fix to the theme along the way.

Theme v1.4.87: added prefers-reduced-motion support – a single CSS rule (animation-duration, transition-duration, scroll-behavior all collapsed to near-zero under the media query) neutralizes every animated element sitewide – the homepage fade-ins, the changelog’s pulsing “live” dot, the blinking hero cursor, the media modal – in one shot, rather than editing each one individually. The one motion effect that isn’t CSS – the mouse-following cursor trail, driven by requestAnimationFrame – got its own JavaScript check next to the existing touch-device check, so it simply doesn’t start for anyone with the preference set.

Make My Site Agent-Ready went from v1.3.3 to v1.4.3 across four releases:

  • v1.4.0 added /.well-known/api-catalog (a machine-readable index linking llms.txt, security.txt, the sitemap, and feed in one file) and Agent Skills discovery – a JSON index plus one real skill file teaching an agent how to use this site’s markdown endpoints instead of parsing HTML.
  • v1.4.1 fixed a bug live verification caught within minutes of installing v1.4.0: the plugin’s own broad rule for serving .md URLs was also matching the new Agent Skills file path and blocking it. Fixed by excluding /.well-known/ from that rule entirely, so it can’t happen again for any future endpoint added there either.
  • v1.4.2 added Link response headers (the HTTP-header equivalent of the <link> tag already in every page’s <head>) pointing agents at the api-catalog and Agent Skills index, sitewide.
  • v1.4.3 added Content Signals – a Content-Signal: line in robots.txt declaring, per AI crawler, whether it may index this content for search, use it for a live answer, or train a model on it.

The combined result: the isitagentready.com score went from 51 to 86.

The Content Signal values took a real conversation, not just a default. The instinct was to ask whether opting content into AI training would help with being recognized as a thought leader by AI systems. It doesn’t – training doesn’t preserve attribution, a model can’t cite a source it was trained on, and no crawler honors this signal yet regardless of the value chosen. What actually helps is the live-retrieval path (an assistant fetching this page in the moment and linking back), which is a separate setting entirely. Landed on: search yes, live AI input yes, training no.

We decided not to build one issue flagged in the scan because it’s too risky with too little reward: DNS-AID, which wants DNS records published under a _agents subdomain, signed with DNSSEC. That’s a registrar-level change, not a plugin fix, and a botched DNSSEC handoff can take a domain fully offline – a real risk for one line item on an otherwise-optional check.

Asked whether opting my own writing into AI training would help me get recognized as a thought leader by AI. The honest answer is no - training doesn't preserve attribution, so a model trained on this site couldn't cite it even if it wanted to. What actually gets a page cited is an assistant fetching it live and linking back, which was already covered by a different setting. So: searchable and readable by agents, yes. Trainable, no.

Events now speak Google’s language

Added Event JSON-LD to the homepage’s Upcoming Events loop. front-page.php builds a @graph of Event objects (name, url, startDate, endDate, location, attendance mode, performer, description) straight from the same ACF fields already driving the visible cards. Google can now potentially build rich result cards for talks I’m speaking at, without a second data source to maintain. Virtual events are auto-detected from the location text and marked VirtualLocation instead of Place.

There's no single-event template. ms_event posts don't get their own public pages, so the schema had to live inline in the homepage loop, the only place events are ever rendered.

Events stop asking you to state the obvious

event_end_date used to require an explicit value even for a single-day event. front-page.php now defaults the JSON-LD endDate to startDate when the field’s left blank. The ACF field stays around only for genuine multi-day events.

Noticed after filling out the event admin screen one too many times myself. The fix is a one-line fallback, but it's the kind of papercut you only feel once you're the one using your own forms.

Built this changelog, the page you’re reading

New ms_changelog post type plus a feed template styled after the About page’s timeline: version badges, a live stats strip computed at render time (entry count, current version, how long this has been going), and filterable categories. Entries here aren’t hand-written after the fact. After any session that ships something worth mentioning, a draft entry gets written straight into this post type for review before it goes live.

Kept the same everything-editable-from-wp-admin pattern the rest of the site uses for Events, Media, and Timeline, rather than hardcoding entries into the theme. Future entries are WordPress posts, not code.

Event titles now look like links, and can point anywhere

Event cards get a custom CTA label field. Set it, and the button links straight to that event’s own URL with your own text (e.g. “Register now”) instead of always falling back to “book a meeting with me.” Titles also got restyled in the accent color with a small ↗ marker so they read as clickable at a glance, instead of blending into the surrounding text.

Kept the "book a meeting" fallback rather than removing it. Some events on the list are conferences where I'm speaking and a meeting link makes sense, others need an actual registration link. The field just lets each event pick.

Fixed embeds and a nav gap on mobile

Standalone embed blocks (YouTube, Vimeo, etc.) were overflowing the viewport edge on phones. Fixed by wrapping them in an aspect-ratio:16/9 container with the iframe positioned to fill it, rather than the older padding-bottom percentage trick. Separately, a blank gap opened up under the nav on small screens: the page reserves 60px of top padding to clear the fixed nav bar, but the nav itself switches to position:relative at 640px, so that padding became pure dead space. Zeroed out below that breakpoint.

Launched a newsletter called The Diff

The Diff, a biweekly read on AI and WordPress, publishes as regular posts in a dedicated category rather than a custom post type. Every issue is automatically indexed, shows up in RSS, and appears in the blog archive and homepage Latest Posts with zero extra plumbing. A the_content filter auto-injects the newsletter masthead and subscribe button onto any post in that category, so there’s no manual step needed per issue.

The first version of the subscribe button linked to home_url('/#newsletter'), which navigated away from the post entirely before scrolling. Fixed to a same-page #newsletter anchor, since the signup form already lives in the footer of every page.

Brought back multi-column layouts and text highlights in posts

Dequeuing WordPress’s block-library CSS for performance had a side effect nobody noticed until writing a post that needed it. Column blocks stopped laying out side by side, and the highlight/mark tool’s colored background disappeared. Both got a small targeted CSS hook instead of re-enabling the block library wholesale. Restoring 30KB-plus of unrelated CSS for two rules felt like the wrong trade.

The mark highlight needed !important. The block editor writes its own inline background-color style directly on the element, which otherwise wins over anything in the stylesheet.

Added a newsletter signup, the boring way on purpose

The Kit signup form in the footer is a static HTML embed, not the async script Kit normally recommends. The async version creates a timing race: the script injects the form after the page has already rendered, so any CSS overrides applied before the form exists get silently ignored. Three different MutationObserver-based workarounds all failed or hurt performance before landing on the simplest option: paste the HTML directly into the template, so the form is already in the DOM before any script runs.

Reordered the homepage so the writing comes first

Latest Posts now sits directly under the hero instead of further down the page, with a “see all posts” button. The hero itself shrank to a square 260px photo with less padding, since its job is just to say who I am before you scroll to what I actually made.

Shrinking the hero took a few tries. Removing the min-height and padding wasn't enough on its own. A leftover max-width:510px on the bio text kept forcing extra line wraps that made the section look tall no matter what else changed.

Fonts stopped blocking the page from rendering

Google Fonts now load with the classic async trick: <link rel=preload as=style>, media=print swapped to all via onload, plus a <noscript> fallback for anyone without JS. That one change removes roughly 1,200ms of render-blocking time on every page, applied globally.

The onload attribute uses single quotes (onload="this.media='all'"), which is a landmine inside a PHP echo string: it throws a fatal error. The fix is to drop out of PHP mode entirely for that one tag rather than trying to escape the quotes.

Killed the light-mode flash on every page load

Dark mode preference was being applied by a deferred JS file, which runs after the page has already painted, so anyone in dark mode saw a flash of light mode on every navigation. Fixed by moving the localStorage check into a tiny inline script in <head>, so the dark attribute is set before the browser paints anything. About 150 bytes, no extra network request.

The media card spacing bug wasn’t a CSS problem

Spent ten separate CSS attempts trying to add breathing room above a card’s footer line before finding the actual cause. The description text uses -webkit-line-clamp to truncate at 3 lines, and that clamping mode collapses the element to zero height inside a flex container once flex-grow is removed, so any margin-bottom on it had nothing to push against. The fix is a plain empty <div style=”flex:1;min-height:20px”> sitting in the template between the description and the footer: a real DOM element with real height, which no CSS trick could substitute for.

Pages start loading before you click them

Added Speculation Rules (Chrome) and instant.page (everywhere else) so top-level pages and the latest post start prerendering or prefetching the moment you hover a link. By the time you click, most of the page is already there. Also dequeued wp-embed.min.js, a script WordPress loads on every page by default even though this theme never uses WordPress’s own embed blocks.

View Transitions, the page-fade feature from a couple of versions back, got removed here. It kept colliding with the theme's own scroll-in fade animations for a worse effect than either alone. instant.page and Speculation Rules deliver the snappy feeling without that conflict.

Raised the readability floor across the whole site

Body text sitewide now has a 15px, font-weight 500 floor. Anything smaller or lighter than that was hard to read on some screens. Also added a real hamburger menu for mobile nav, since the menu previously just wrapped awkwardly, and fixed a dark-mode contrast issue on the primary button (near-black text on the accent orange, roughly 8:1 contrast).

Stopped dark mode from double-animating on toggle

Installed the browser’s View Transitions API for smoother page navigations, but it collided with the theme’s own CSS color transitions on body, .site-nav and .site-footer. Both tried to fade the same elements at once, causing visible jitter in dark mode, worst on mobile. Fixed with a :root:has(::view-transition) selector that suppresses the theme’s own transitions only while a view transition is actually running.

This one didn't survive long. See a couple of entries down.

Made the media grid load faster and read better

The first appearance card in the grid now gets fetchpriority=”high” and loading=”eager” so the browser fetches it immediately instead of waiting in the lazy-load queue. It’s usually the largest visible image on the page. Card titles switched from <h3> to <h2> to fix a heading-order gap: the page’s <h1> had no <h2> underneath it. The active filter tab’s background also moved off the site’s decorative orange (–accent) onto the AA-compliant text color (–accent-text), since the decorative shade fails contrast for anything you’re supposed to read.

The modal player's aria-labelledby stopped announcing itself correctly to screen readers while hidden. Turns out aria-labelledby is ignored entirely on elements inside a hidden subtree. Switched to aria-label, updated by JS when the modal opens.

Started as a design brief and static HTML, not a WordPress site

This site started as a design brief (retro internet aesthetic, high contrast black, one bold accent color, Fraunces + IBM Plex Mono) which was turned into a static HTML/CSS/JS prototype. Built entirely through a conversation with Claude. Another vibe-coding tool tried first and lost consistency across pages: the footer looked different every time, fonts drifted, even the placeholder photo kept shifting, because each page was generated somewhat independently of the others. Claude held it together by keeping every page on one shared stylesheet and script file, so nothing could drift between them. Only once that static version was solid did it get converted into the actual theme, custom post types, ACF fields, and all, that everything since has been built on.

The first working version of the theme had almost nothing editable from wp-admin - the timeline, media cards, and bio text were all hardcoded straight into PHP templates. Rather than hand-fix each one, the content got regenerated as WordPress XML import files instead: three WXR files, one each for timeline items, media appearances, and posts, ready to load via Tools -> Import. Went from an empty install to a populated site in minutes, and that same import pattern is still how bulk content gets added today.