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 (here’s why I keep one): 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 is built — or browse the plugins I’ve built for it.
This site can now show its own cards inside Claude
When an AI assistant looks something up on a website, it usually reads the answer and retells it in its own words. MCP Apps, an extension to the Model Context Protocol, lets a site send a small interface along with the answer, and the assistant shows it right in the chat. If you add this site to Claude as a connector, what you see now are cards from my site, in my site’s colors and fonts, in light or dark mode.
There are two cards. Searching or listing content through the MCP server shows the results as a card of posts and pages. A new get_about tool comes with a profile card that has my photo, role, bio, profile links, and a contact form. Claude can fill in a draft of the form from what you’ve told it in the chat, but only you can press Send. The message goes through the same contact endpoint agents already use, with the same rate limits.
This comes from version 1.68.0 of my Make My Site Agent-Ready plugin, and the design lives in my theme. The plugin already had an experimental card, but it was built on OpenAI’s older format, so a client following the MCP Apps specification wouldn’t have shown it at all. I rebuilt it to the specification first. Testing in Claude then showed that it can hold on to an old copy of a card even after the site has changed it, so each card’s address now includes a fingerprint of its contents, and a changed card gets a new address. So far I’ve only tested all of this in Claude.
The plugin draws a neutral card for any site, and my theme restyles it through the plugin's mmsar_mcp_ui_css filter. The fonts are built into the card, because a card can't load anything from the network.
Every page’s structured data now has its own address
Each page on this site now publishes its structured data at its own URL. Add .jsonld to a page’s address, like /about.jsonld, or use /index.jsonld for the homepage. It’s the same JSON-LD graph the page already carries in its <head>, served on its own as application/ld+json, so an agent that wants the facts about a page can fetch them directly instead of pulling them out of the HTML. Every page points to it with a <link rel="alternate" type="application/ld+json"> tag and a matching Link header.
This comes from version 1.65.0 of my Make My Site Agent-Ready plugin. It’s one half of a proposal on specification.website called Schemamap. I built the per-page part because it only uses registered standards, and left out the proposal’s index file and its rel="schemamap" link: Yoast SEO already serves /schemamap.xml on this site, and schemamap isn’t a registered link type. The same release adds an NLWeb discovery link to every page, so agents can find the site’s /ask endpoint without already knowing it’s there.
Building it showed a gap in my theme. The homepage’s upcoming events and the contact page’s description of the contact form were each printed as a separate JSON-LD block, outside Yoast SEO’s graph, so the new .jsonld addresses couldn’t see them. In theme 1.4.151 both moved into Yoast’s graph. The events are now a graph piece (MS_Schema_Events) that runs on the homepage, and the contact page’s WebPage node is typed as a ContactPage with an action that points at the form’s API. Each page now has one graph, and the events point to the same Person entry as everything else on the site.
Since 1.4.75 the events schema lived inside the homepage template, because events don't have pages of their own. A Yoast graph piece can run the same events query itself, so the template now only draws the cards.
Each post in a list is now a real heading
I checked how pagination is built on the Writing page and the topic pages, and it was already right. The page links sit in a labeled navigation area, the current page is marked as the current one, the arrows read out as “Previous page” and “Next page”, and each page of results has its own address that search engines can index. The problem was in the list of posts being paged through.
Each post in those lists was a stack of generic boxes. The title looked like a heading but wasn’t marked up as one, so the page had no headings at all below “Writing”, and someone using a screen reader couldn’t jump from one post to the next. Now each post is marked up as an article with its title as a real heading. That applies on the Writing page, the topic pages, search results, and the “Other posts” section at the end of every post.
Dates are now marked up as dates too, on those lists and on the post cards on the home page. Behind “Sep 2, 2026” there’s now the full date and time in a standard format, so search engines and AI agents reading the page don’t have to work it out from the text.
The pages look the same as before. The one difference is that long titles that run over two lines now break more evenly.
I went looking for problems in the pagination and didn't find any. They were in the list it was paging through.
YouTube videos now load when you press play
My Lighthouse Scanner plugin’s latest scan gave the Elementor MCP page a performance score of 53. The cause was one YouTube video near the bottom of the page. A YouTube embed loads the whole player as soon as the page opens, whether or not anyone presses play: almost 600 KB of JavaScript, plus four third-party cookies. Now the page shows the video’s thumbnail with a play button, and the player loads only when you click it, from YouTube’s privacy-enhanced youtube-nocookie.com address. The page went from 1.6 MB to 276 KB. Every YouTube video on the site works this way now, including the ones on the talk and podcast pages.
The home page was also slow to start loading, and the talks and podcasts turned out to be the reason. Each one asked YouTube or WordPress.tv for its embed code on every page view, because WordPress doesn’t save that answer when a theme asks for it directly. The Media & Talks page made 16 of those requests per visit and took more than four seconds to build on my local copy of the site. The theme now saves each answer for a week. On that copy, the home page dropped from 0.65 seconds to 0.05, and Media & Talks to under a tenth of a second.
The scan also flagged some accessibility problems. The live stats bar at the top of every page gave screen readers a label that didn’t contain the words you see, which breaks voice control: saying the visible text doesn’t click the link. The GitHub links on the plugin pages had the same problem. Pages that use the default template, like Privacy, had no main content area marked, so the “Skip to content” link went nowhere. Two bits of text were too faint to read easily: the “last push” date on plugin repo cards, and the selected filter button on Media & Talks in dark mode. All of that is fixed. One failure is left, on the “Ask about this site” button. That button comes from my AI Site Chat plugin, so the fix goes there.
The photo on the home page was also set up as if it filled the whole screen on phones, so phones downloaded a bigger file than they needed. On standard-resolution screens it’s now a 15 KB file instead of 56 KB.
The slow home page was waiting on YouTube. Every talk card asked YouTube for its embed code on every visit, and nothing saved the answer.
Agents that follow NLWeb can now find this site’s structured data
Yoast SEO publishes this site’s structured data as feeds that AI tools can take in one request instead of reading every page: one for posts, one for pages, one for plugins, and one for talks and podcasts. Until now, a crawler that follows NLWeb, Microsoft’s protocol for asking a site questions in plain language, had no reliable way to find them. The schema map, the file that’s supposed to point to those feeds, was written in a format of its own that a reader following the spec couldn’t use.
Version 1.60.0 of my Make My Site Agent-Ready plugin fixes that. Its map at /schema-map.xml now follows NLWeb’s Schema Feeds spec, and robots.txt points crawlers to both maps on the site: the plugin’s own, and the one Yoast SEO serves, which lists the feeds for each content type. The plugin’s /ask endpoint, which answers a question with a ranked list of pages from the site, is now listed in llms.txt with the site’s other endpoints. Before this, the old map was the only place that pointed to it.
Google Search Console reports that robots.txt line as “Syntax not understood”. Yoast SEO stopped adding it for that reason. Google ignores lines it doesn’t recognize, so crawling isn’t affected, but the warning looks alarming, so the plugin now has a checkbox to leave the line out. I’ve kept it on here.
The site’s tagline also changed. Agents see it in the structured data rather than on any page, and it said “Miriam’s Site”. It now says what’s here: posts, talks, and the WordPress plugins I build.
NLWeb's written spec and NLWeb's own crawler disagree on how a schema map labels what's in each feed. The spec wants a separate element; the crawler reads an attribute, which is what Yoast SEO writes. The plugin now writes both, so either one can read the map.
AI agents in your browser can now use this site’s tools
This site now supports WebMCP, a proposed web standard that lets a page tell an AI agent running inside your browser what it can do here. On every page, an agent can search the site, list what’s here (by topic too), read a page as Markdown, starting with the one you’re on, and get an overview of the whole site. On the contact page it can also fill in the form for you to check, or send me a message, which it’s told to confirm with you first.
The read tools are the same ones my site’s MCP server already offers to agents that connect from outside. Version 1.57.0 of my Make My Site Agent-Ready plugin reads them from that server and sends every call back to it, so there’s one set of tools and one set of rules. The two contact tools live in the theme, since the form is specific to this site.
Browser support is still experimental. Chrome runs WebMCP as an origin trial until March 2027, and this site is registered for it, so an agent in an ordinary copy of Chrome sees the tools with no special settings. In browsers without WebMCP, which is most of them for now, a few hundred bytes of script check for it and stop.
Every call an agent makes here shows up in my Agent Log under its own label, with what it asked for. That's the part I'm most curious about: whether anyone's browser agent actually uses it.
The privacy page now says what the agent log actually records
The agent request log section on the privacy page was out of date. It said the log only covered automated traffic, but it also records ordinary page views, so I can compare what AI agents read with what everyone reads. Those rows keep a shortened IP address that points to a network, not a device, and the page now spells out exactly what else they hold.
Two newer things are on the page too. As of today, when an agent searches this site through its MCP server, the search words are stored, with anything that looks like an email address or a phone number replaced first. That comes from version 1.56.0 of my Make My Site Agent-Ready plugin, which now logs what an agent asked each MCP tool for, not only which tool it called. The site also keeps a daily count of visits sent by AI assistants like ChatGPT and Perplexity, with nothing about the visitor in it.
Plugin and media pages now say what they describe
Every page under /plugins/ and /media/ was a plain web page in the site’s structured data. A crawler or an AI agent couldn’t tell a WordPress plugin from a WP Tavern episode or a WordCamp talk without reading the full content.
Plugin pages now carry a SoftwareApplication node in the page’s existing JSON-LD graph, built in functions.php. It has the plugin’s name and description, applicationCategory: DeveloperApplication, operatingSystem: WordPress, a free offer, and the Active or Archived status from the badge at the top of the page. Plugins with a public GitHub repo also get a SoftwareSourceCode node with the repo URL, its main language, and the date of the last push, read from the same cached data the repo card on the page already uses.
On media pages, podcasts become a PodcastEpisode with the show as its partOfSeries. Anything with a YouTube or WordPress.tv recording gets a VideoObject with the thumbnail, date, and video link. For a podcast, the video is attached to the episode. For a talk or webinar, it’s the page’s mainEntity. Talks that link to a write-up instead of a recording stay plain pages, because an Event would need a venue the site doesn’t store.
Every date comes from the Date field that already orders the Media page. Google’s Rich Results Test flagged the first version: a video’s uploadDate was a bare date, and Google wants a full date and time with a timezone. The field has no time in it, so the schema now writes midnight UTC on that day. The date itself didn’t change.
Both point back at the same Person node the rest of the site uses: as author on the plugins, and as contributor on the media, since on most of those I’m the guest.
The markdown version of every page here comes from my Make My Site Agent-Ready plugin, and for these pages it now carries the same facts in its frontmatter. The plugin gained a mmsar_frontmatter filter for this, and the theme uses it to add a plugin’s tagline, status, and repository, and a media item’s media_type, the show or event as platform, the media_date, the original source_url, and a video_url when there’s a recording. Before this, an agent reading a plugin’s markdown had no way to find its repo. The values come from the same functions that build the JSON-LD, so the markdown and the schema can’t disagree.
Every markdown file on the site also got a different summary line. The frontmatter used to carry an automatic excerpt: the opening words of the post, cut off mid-sentence, repeating the text right below it. Make My Site Agent-Ready can now write the description I wrote for search results as well, and a new setting lets any site running it choose the excerpt, the description, or both. On this site I switched the excerpt off, which took about a tenth off llms-full.txt, the file that bundles the whole site for agents.
There's no version number, download count, rating, or video length in the markup. The site has no source for any of them, and a made-up number in structured data is worse than a missing one.
Page numbers on Writing now make sense to a screen reader
The page numbers at the bottom of Writing and the category pages looked fine but gave a screen reader almost no information. The arrows had no text, so they were read as “right arrow” or skipped entirely, and the numbers were just “2” and “3” with nothing to say what they were. They’re now a labelled list of page links that reads “Previous page”, “Page 1”, “Page 3”, “Next page”. The box marking the page you’re on was white text on my bright orange, which is too faint at that size, so it’s on the darker orange now.
I also got rid of some pages that shouldn’t exist. WordPress answers addresses like /page/2/ on the homepage, or on any single page, with a copy of that page. Those now redirect to the real page. The actual archive pages keep their own URLs in search, which is what Google asks for.
My first fix for the homepage did nothing. On a static front page, WordPress quietly moves the page number into a different variable than it uses everywhere else.
The Plugins page now links to what I wrote about each plugin
The Plugins page was just the plugin tiles and a short intro. It now has a section underneath listing the posts I’ve written about each plugin, grouped by plugin, with the date each one went up. Plugins I haven’t written about yet are left out rather than showing an empty column.
The list builds itself from the links. Any post that links to a plugin’s page counts as writing about that plugin, so the next time I publish something that links to one, it shows up there on its own.
AI agents can now pull my whole site as one structured bundle
Make My Site Agent-Ready, my agent-readiness plugin, just picked up an Open Knowledge Format bundle at /okf/. It’s a different shape from /llms-full.txt, which dumps the entire site into one file. Here, every post and page gets its own small file instead: a few labeled facts up front (what it is, what it’s about, when it last changed), then the actual content. An agent that wants everything can now fetch it one page at a time instead of parsing HTML or downloading the whole site in a single request.
The version right before it adds something less flashy but more consequential: a tdm-reservation header on every page. It states whether I reserve the right to object to my content being mined for AI training. Under EU copyright law, staying silent counts as permission by default. I already say no to AI training through Content Signals in robots.txt, but that’s an informal convention most crawlers still ignore. This header is the form the law itself asks for. Neither this nor the OKF bundle touches anything a visitor sees; they’re headers and files only an agent would ever ask for.
The OKF bundle's first draft had one bug: WordPress kept redirecting /okf/index.md to add a trailing slash, because it didn't know the URL was already supposed to end in .md. Caught it in local testing before it ever reached this site.
The video pop-up now works properly with a keyboard
When you opened a video on the Media & Talks page, the pop-up looked right but didn’t hold onto your keyboard. Pressing Tab moved you onto links on the page behind it, which you couldn’t see, and closing the pop-up dropped you back at the top of the page instead of on the video you’d just watched. It now uses the browser’s own dialog element, so focus stays inside while it’s open and comes back to the play button when you close it. Clicking anywhere outside closes it too.
The contact form now colors each field green or red as you fill it in, rather than waiting until you press send. The red it used for errors was too dark to read against the dark theme, so I lightened that as well.
Fixed the plugin tiles on How this site is built
When I switched my plugin lists to GitHub-style tiles, the “How this site is built” page picked up the new tiles too, but it printed a stray bit of code at the top of each one and dropped the plugin name onto its own line. That page now uses the same GitHub panel as the homepage and the Plugins page, so all three match.
I also deleted the styles for the old plugin cards, which nothing uses anymore.
My plugin lists look like GitHub now
The Plugins page and the Plugins section on the homepage now show each plugin as a GitHub-style repo tile, with the language, star count, and last update pulled live from GitHub. The name links to the plugin’s page here, and “source” goes straight to the code.
The homepage section also got a short note on why these plugins exist in the first place.
My plugins are on the homepage now
The homepage has a Plugins section with all six plugins, placed right after the lab. To keep the page from getting longer, I cut the “Everything else I’ve written” list. It repeated the experiments strip above it, and the Writing page has all of it anyway.
Also fixed: with only one upcoming event, the events section showed the card on the left and an empty grey box on the right. A lone card now fills the row.
The red frame around my photo now shows on the homepage too
The About and Contact pages draw a thin accent-colored outline a few pixels off each edge of the hero photo. The homepage photo never had it, which I noticed only when the two sat next to each other.
The rule was already written. .photo-frame::after in main.css had the same outline: 1px solid var(--accent) with a 10px offset that .page-hero-photo::after uses, and it had presumably never rendered once. .photo-frame also carries overflow: hidden to hold the image inside its aspect-ratio box, and an element’s overflow clips its own pseudo-elements. The outline was being drawn 10px outside a box that was clipping at 0.
The fix moves the ring up one level to .hero-photo, the wrapper, which is already position: relative and measures exactly the same box, so the outline has nothing clipping it. .photo-frame keeps its overflow: hidden and its job.
Worth remembering for anything decorative built on ::before or ::after: an outline or box-shadow that reaches outside its element is at the mercy of that element’s own overflow, not just its parent’s.
Nothing was broken in the sense of throwing an error, and nothing had regressed. The CSS was correct, in the wrong place, and had been quietly invisible for as long as it existed. Those are the ones you only catch by putting two pages side by side.
The GitHub link on plugin pages now looks like GitHub
Every plugin page carried a grey strip near the top: the words “source code” on the left, a “view on GitHub” button on the right. It worked. Nothing about it suggested that clicking took you to a repo.
It’s now a card built to read as GitHub on sight. A dark band across the top holds the Octocat mark and the GitHub wordmark, and below it sits GitHub’s own repo icon, the miriamschwab/plugin-name path, and a Public badge. The markup lives in a new template part, template-parts/github-repo-card.php, rendered from single-ms_plugin.php off the plugin_github_url field that was already there.
The card also reads live from GitHub’s public API, so it shows the repo description, the primary language with its usual color dot, the star count, and the date of the last push. ms_get_github_repo_meta() in functions.php caches each repo for 12 hours and treats a failed request as a cache miss rather than retrying on the next page view. None of that data is required: without it the card still renders the repo path, the badge falls away, and the description falls back to the plugin’s own tagline.
One detail that isn’t a bug: the dark band stays dark in the light theme. That color is what does the recognizing, so it doesn’t follow the site’s light and dark toggle the way the rest of the card does. In dark mode it lifts to GitHub’s raised surface color instead, which the page background can’t swallow.
The first version of this card used only the site's own palette, on the theory that borrowing GitHub's colors would clash. It looked better than the old strip and still didn't say GitHub to anyone looking at it. The fix was to stop being polite about it and put the actual mark on an actual dark band.
Renamed my GitHub account, then found where the old one was hiding
My GitHub account moved from MirSquad to miriamschwab. GitHub redirects renamed-account URLs, so nothing broke — which is exactly the problem. Every stale link kept returning 200, and no link checker would ever flag one.
The worst instance was in the theme. ms_identity_same_as() in functions.php is the one place the GitHub profile is declared, because Yoast has no field for it, and it feeds the sameAs array in the Person schema. That array renders on all 82 URLs in my sitemap. So the single highest-reach identity claim on the site — the thing an AI follows to work out that this Miriam Schwab is the same one on GitHub — pointed at an account name I no longer use. Fixed in theme v1.4.129.
Seven more were in content: links in three posts and pages, and two earlier changelog entries. Those got patched directly and the markdown twins regenerated, so llms.txt, llms-full.txt and the api-catalog are clean too.
The part I want to remember: I crawled all 82 URLs to check, and the first pass reported only 38 of them affected. That read like the schema was conditional on something. It wasn’t — the crawl loop was failing intermittently and I’d taken its silence as a clean result. Re-running the 44 supposedly-clean URLs returned a hit on every one. A zero from a bulk script is not evidence of absence unless you’ve checked the script.
The old account name still appears in my project notes, on purpose. Those describe what happened in sessions that really did happen under that name; rewriting them would just make the record wrong in a new way. The rule is enforced going forward instead.
The first crawl said 38 of 82 pages were affected. It was actually all 82 — the loop had been failing silently and I'd read its zeros as clean.
The live stats band moved to the top of every page
The band of numbers that reports on this site — Lighthouse scores, how many abilities it registers for agents, and agent-ready score — used to sit above the footer. On the homepage that put it at about 98% scroll depth: below the newsletter form, above the copyright line. Effectively nobody would see it.
It now renders directly under the nav on every page, from a new template-parts/lab-strip.php pulled in by header.php instead of footer.php. If the point of the band is that the site reports its own condition, it should do that before it says anything else.
What’s in it
Six numbers: the four Lighthouse scores, an Ora agent-readiness score, and the count of abilities this site registers. Every one is a link now — the Lighthouse scores jump to the scan detail on How this site is built, the Ora score opens its live scorecard. A number you can click sitting next to one you can’t reads as an oversight.
The Ora score is the only figure here entered by hand. There’s no API to pull it from, so it lives in a theme setting and gets re-entered after each scan. Its tooltip says so, and it links out to the scorecard so the score can be confirmed.
The AI-agent count left the band because it made it too long.
The bug underneath
Every number was gated on the Lighthouse data. One line, $lab_ability = $lab_scores ? ... : 0, meant that if the scan history was missing or its cache had expired, the whole band vanished — including the abilities count and the Ora score, which have nothing to do with Lighthouse and are cheap to read. Each source is now independent: one with nothing to say drops its own item, and the band only disappears when everything is empty.
Moving it also surfaced a gap that had been sitting in the stylesheet for a while. The rule cancelling the fixed nav’s clearance was scoped to 640px, but the nav stops being fixed at 860px — so from 641 to 860px there was 60px of dead space under it. Invisible as slack above the hero; obvious once something was parked there.
Then it got quieter
At the top of the page it was shouting, so it lost about a quarter of its height and its numbers came down from 17px to 14px.
And it got three small pieces of motion: a gleam that crosses the band every twelve seconds or so, trailed by a handful of glints; a glide as it leaves on scroll, where the band resists the scroll rather than riding it, so it drifts up at about a third of scroll speed while fading; and a one-time staggered fade-in on load, so the stats arrive in sequence like a gauge populating.
One thing that fought back: the obvious way to hold an element in place while it animates away is position: sticky, and it doesn’t work here — body carries overflow-x: hidden, which makes a sticky element track the scroll one-for-one instead of pinning. Hence the counter-transform.
The accessibility rule almost broke it. The stylesheet has a global prefers-reduced-motion block that zeroes every animation's duration. For a normal animation that means "don't move." For a scroll-driven one it means "jump straight to the end state" — and the end state here is opacity 0. Left as it was, anyone browsing with reduced motion would have got a permanently invisible band. The motion rules now only exist for visitors who haven't asked for less of it, rather than being switched off by the global rule.
Every page now ends with the site’s own numbers
Every page on this site now ends with a strip of live numbers that are updated automatically from real stats: four Lighthouse scores, the number of AI abilities registered here, and the number of AI agents that have fetched the site.
The strip sits just above the footer. It appears on every page, including this one and the 404 page.
A caching mechanism was added so that the strip wouldn’t ironically negatively impact performance. The Lighthouse scores come out of a stored scan history that runs to about 285KB, and WordPress does not load a record that size on ordinary requests, so reading it to draw the strip would have added a 285KB database read to every page view on the site. ms_get_lighthouse_scores() now lifts the four numbers out once and keeps them in a short-lived cache. The large record is read only when that cache is empty.
I measured the difference on a copy of the site rather than assuming it. A cold request reads the big record once. Every request after that reads it zero times, and peaks 2MB lower in memory.
The How This Site is Built page also now pulls the scores near the top of this page from the same function.
The first version of the label read "live from this site" in a dim grey. It faded into the background, and it never said what the numbers were. It now reads "live site stats" at full text weight, and links to this page. Contrast went from roughly 6:1 to 17:1.
My 404 page now tells agents where to go instead
A 404 used to be a dead end for anything without eyes. The page returned a correct HTTP 404 status and a nicely set “Page not found”, which tells a person plenty and an AI agent nothing at all. The agent knew the URL was wrong. It had no way to work out what the right one was.
404.php now renders a two-column recovery block under the existing 404. On the left, “Try one of these” outputs the primary nav menu through wp_nav_menu() rather than a hardcoded list, so it can never drift out of sync with the real navigation. On the right, “If you’re a bot or an AI agent” points at /llms.txt, /openapi.json, /.well-known/mcp.json and /sitemap_index.xml.
Each of those first three links is wrapped in a mmsar_feature_enabled() check. If I ever deactivate Make My Site Agent-Ready, or switch off one of its features, the 404 page must not start sending agents from one dead end to another.
The reason this lives in the theme rather than the plugin is because the plugin already sends Link headers on every 404, mirrors them as <link> tags in the head, and serves a markdown or JSON body to any client that asks for one. None of that moved the scanner score I was looking at, because that check reads the rendered HTML body. It also shouldn’t have: the plugin’s whole design principle is that it adds nothing a visitor can see. Visible page content is the theme’s job.
The same release added 'author' support to all five custom post types in ms_register_cpts(), which sounds like housekeeping and was not.
Adding one word to five arrays fixed 53 wrongly attributed posts. None of my custom post types declared 'author' in supports, because no template displays an author, so it looked like dead configuration. WordPress still records post_author on every row, and my markdown twins publish that column straight into their frontmatter. Which meant 25 of my own pages were credited to "Schwab Elementor User", the account Elementor Hosting creates for its dashboard. Without author support there is no author box, no bulk edit dropdown, and no REST field, so there was no way to fix it from anywhere. The REST API doesn't even error on ?author=1, it just returns everything and drops the parameter in silence.
Updated stats bar to display useful info
Two of the four numbers under the heading on this page were not sharing any useful data: one was the theme version (the changelog page already shows that at the top), the other was the number of third party font requests, which has been zero ever since the fonts moved onto this server.
Those numbers have been replaced with how many named AI agents have fetched this site, and how many WordPress Abilities are registered on it.
The abilities number is not counted when the page loads. ms_get_ability_count() reads a total that ms_ability_count_refresh() worked out earlier, during a visit to wp-admin, and saved.
That detour exists because the answer changes depending on who asks. Some abilities register only for admin and REST requests, and others show up only when an agent is driving the site. A front end count comes to 75, wp-admin comes to 77, and an agent asking gets 80. All three are correct.
The admin screen that lists abilities shows 77, so that is what this page should say. Counting on the front end would have printed 75 instead. Both are correct, but we have to pick a number.
The agent number comes from this site’s own request log. That log records every hit on the machine readable versions of these pages, so plain browsers and command line tools end up in it too. Counting all of them would inflate the number. ms_get_ai_agent_count() keeps only the user agents that match a known list of AI crawlers, and saves the result for an hour rather than asking the log on every page load.
Both stats check their source before they print. If the number cannot be read, the stat drops out of the bar rather than showing a blank or a wrong figure.
I counted the abilities three times and got 105, 102, and 77. All three were right. The first two came from a test copy of this site, which runs a prerelease build carrying 27 abilities the live site does not have. The 77 came from the live admin screen, and that is the number this page now matches.
The homepage now says what this site actually is
The homepage was a straightforward personal site: name, bio, latest posts, events, talks. Nothing on it said that this site is also where I test AI and agent-readiness, which is a big part of why I built it. The evidence was all there already — markdown twins of every page, llms.txt, the agent-accessible contact endpoint, six plugins, thirty changelog entries — but it lived in the footer and the nav, where nobody landing on the homepage would find it.
So the homepage leads with it now. Under the hero there’s a new section, “A live lab for AI in WordPress”, with four tiles: the markdown twin of every page plus the llms.txt index, the token-gated contact API an agent can actually send through, the AI-readiness score going from 51 to 86, and the plugins. Below the tiles is a live line showing the most recent changelog entry, so the homepage shows the last thing that actually shipped instead of just claiming the site is a work in progress.
The AI write-ups got their own strip too, “What I’m testing”, rather than competing with everything else for the three “latest posts” slots. Both strips exclude whatever is already linked above them, so nothing shows up on the page twice. The section order changed as well: the lab and the experiments now come before “a bit about me”, which moved to the bottom.
The hero picked up an “ask me anything” button next to the social links, which opens the site chat with the input already focused.
Two older things surfaced while building this, both of which had been sitting there unnoticed. The hero bio runs through a global CSS reset that zeroes every paragraph margin, and nothing ever restored it. While the bio was a single paragraph that was invisible. The moment I added a second one, the two ran together with no gap, even though the editor showed them as clearly separate. The same block of inline CSS was also forcing my photo into a 260px square, tuned back when the bio was short. Against a two-paragraph bio the text column ran 313px taller than the photo, which left the photo stranded in whitespace. It is portrait and larger again.
The "ask me anything" button did nothing when I clicked it, and it took a trace to see why: it was working perfectly and then undoing itself. The chat plugin closes its panel whenever a click lands anywhere outside the widget. My button opened the panel, and then that same click carried on bubbling up to the document, where the plugin saw a click outside itself and closed what had just opened. Open and shut in one tick, which looks exactly like a dead button.
Yoast was deleting my robots.txt rules, and the settings screen said they were fine
Google Search Console told me it had crawled https://stg-3a6ivm.elementor.cloud/wp-admin/* and gotten a 404. Harmless on its own, but it pointed at something real: my robots.txt had no rules for /wp-admin/ at all. WordPress core writes those by default. Yoast strips them back out on every request.
I went to add them by hand, in the Additional Rules box of my Make My Site Agent-Ready plugin. Then I noticed the settings screen was showing me core’s wp-admin rules in its preview, even though the live robots.txt didn’t have them anywhere. If Yoast could delete those, what was stopping it from deleting mine?
Nothing, it turned out. Yoast removes that block with a regular expression that has no limit on how many matches it replaces, so it takes every copy in the file, not only the one WordPress wrote. My plugin was adding the Additional Rules early, at priority 99. Yoast runs at 99999. Anything I typed in that box was gone by the time the file reached a crawler, and the preview kept telling me it was there, because Yoast’s robots.txt code only runs on front-end requests and never on an admin screen. Silent deletion is bad. Silent deletion plus a screen confirming the opposite is worse.
The workaround is silly and it works: put the Allow line before the Disallow line and the regular expression stops matching. Line order inside a group means nothing to a robots.txt parser, which resolves by longest match. I didn’t want to leave that in place, though. In a year nobody would remember why those two lines were backwards, and one Yoast refactor would delete my rules again with no warning.
Version 1.12.1 of the plugin moves the Additional Rules to the very end of the filter chain. Nothing runs after them, so nothing can take them away, whichever SEO plugin is installed. I tested it against a local copy of this site with Yoast active: the plain three lines get deleted on 1.12.0 and survive on 1.12.1.
The preview is still lying, and that’s next. It should fetch the real robots.txt over HTTP and show me that, instead of re-running the same code in a context where half of it doesn’t load.
The settings screen showed me rules the served file didn't have, and I believed the screen. A preview that re-runs the same code in a different context isn't showing you the output, it's showing you a guess. I only caught it because core's wp-admin block was sitting right there in the preview while the real robots.txt had no trace of it.
My contact form is now an API that agents can call
The contact form on this site is now a documented API. GET /wp-json/miriamschwab/v1/contact returns the input schema and a single-use token; a POST to the same URL, with that token, sends the message. The GET response tells you exactly what to include. My own form posts to that same endpoint, so there is no separate agent path that can quietly rot while the human one keeps working. You can read more about how this works here: My contact form is now an agent-ready API
Agents do not have to be told it exists, either. The endpoint is listed in /llms.txt, /.well-known/api-catalog, and this site’s Agent Skills description, so anything surveying the site finds it alongside the content.
I built this for a conference talk about making sites agent-ready. Most of that conversation is about reading: give agents clean markup, structured data, markdown versions of your pages. Taking action is the harder half, and a contact form is the smallest useful example I had.
assets/js/webmcp.js registers two tools for agents running inside the browser. contact_miriam_schwab sends a message. prepare_contact_message fills in the form on the page and stops there, leaving the send button to you. Both descriptions tell the agent to prefer the second one whenever the form is on screen. A message to a real person cannot be unsent, so the agent drafts and the human decides.
People got something out of this too. page-contact.php now carries autocomplete tokens, per-field error messages wired up with aria-describedby and aria-invalid, and a role="status" region so a screen reader announces the result instead of leaving it on screen unread. The form works with JavaScript switched off now, which it did not before.
I first advertised the endpoint with a link relation I invented: rel="https://specification.website/rel/agent-action". It reads nicely and it is registered nowhere, so nothing on the web knows what it means. I replaced it with service-desc from RFC 8631, which already means "a description of this service lives here." An invented relation looks like a standard without being one, and this one was about to end up on a conference slide.
Published MS WordPress Abilities on GitHub
MS WordPress Abilities is public on GitHub now. It’s the plugin that registers WordPress’s Abilities API for this site and exposes it through MCP, so an agent like Claude can read posts, update media, check for updates, and stage a change for confirmation before it applies. Twenty-four abilities in total, still built for this site specifically, but the code is public now for anyone curious how it’s wired up.
Going through the plugin’s own page to add the GitHub link turned up something off on the how this site is built page: it credited the Make My Site Agent-Ready plugin with the MCP read and write tooling. That’s actually this plugin’s job. Make My Site Agent-Ready handles markdown twins and the skills index, not the read/write layer. Fixed the attribution while I was in there.
Fixing the GitHub link on this plugin's own page is what turned up a month-old mistake: the infra page had been crediting a different plugin for this one's job.
My agent-ready plugin now lets you switch off any part of it
Someone using my Make My Site Agent-Ready plugin asked for something reasonable: a way to turn off the parts they already handle themselves in different ways. For example, they manage their robots.txt and llms.txt elsewhere, and the plugin gave them no way to stop producing those files. Once I was making that one aspect optional, I figured I might as well give all the features an on-off toggle.
The interesting case was robots.txt. The plugin does two separate things there. It adds rules telling AI crawlers they’re welcome, and it routes the file through WordPress so those rules still apply on sites that have a static robots.txt sitting on the server. That second part is the one that quietly overrides what someone else set up. Switching the feature off now disables both, so a hand-maintained robots.txt is left alone.
security.txt got easier to fill in too. That’s the file a security researcher looks for when they find a problem on your site and want to tell you about it. You can now paste in your contact page URL, or an email address, and the plugin turns it into the right format. Before it was guessing what the contact page URL was, but that guess was wrong on any site without a page at that exact address.
Building this turned up three bugs I hadn’t noticed. My robots.txt had been listing the sitemap twice. The plugin was assuming my SEO plugin’s sitemap filename, so it would have pointed at a dead URL on sites using a different one. The code meant to cover sites with no SEO plugin at all called a WordPress function that doesn’t exist, which would have taken down robots.txt on those sites entirely. All of this was fixed in the latest version.
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.
Posts now point AI agents straight at their markdown twin
One of my own plugins already serves a clean markdown version of every post and page for AI agents to read. It can now also point to that markdown copy more directly — every enabled post and page can carry an optional pointer to it as structured data (JSON-LD), the same machine-readable format search engines and AI systems already parse.
Why bother? AI agents and crawlers supposedly read structured facts instead of scraping HTML — the same reasoning behind the crawler rules and content-usage signals I’ve already added to robots.txt. This is one more way to hand them something unambiguous instead of making them infer it from prose.
Turns out the Yoast SEO plugin’s Schema feature already declares almost everything a page’s structured data needs — type, title, dates, url. So instead of adding a second, separate block that repeated all of that, the markdown pointer now merges directly into my SEO plugin’s own existing block.
First version of this shipped with a check that only worked by luck — it looked at whether the SEO plugin was active by checking something that gets set when its code loads, but plugins don't necessarily load in a predictable order. On this site it happened to check too early, so the merge silently never ran at all — everything looked fine until I actually looked at the page source and caught it. Fixed by not depending on load order in the first place.
“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. More on why I did this: I gave Claude WP REST API access, with limitations.
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.
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.
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.
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.
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
.mdURLs 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
Linkresponse 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. Full write-up: How I improved my site’s AI-Readiness score from 51 to 86.
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.
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. This came out of the monthly routine I run against specification.website.
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.
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. Why I built it: Why my personal site now has a Changelog.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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 full story: How I built my personal WordPress site with Claude.
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.