Table of Contents
- Starting point: what I actually wanted
- Step 1: Writing the brief for a vibe coding tool
- Step 2: Why Stitch didn’t work out — and Claude stepped in
- Step 3: Claude built the whole static site
- Step 4: Converting to a WordPress theme
- Step 5: The hardcoded content problem — and the XML solution
- Step 6: The contact form
- Step 7: The events section
- Step 8: Accessibility audit with Ally + Claude
- Step 9: The media section evolution
- Step 10: The custom cursor
- Step 11: Updating the timeline from a podcast transcript
- What I learned
- A note on performance
- The stack
Until recently, I didn’t have a personal website.
I’d been putting it off for years. Partly because building something for yourself is always the last thing you make time for. But for me, another reason was that I’m very particular about design, and I couldn’t face having a site that I didn’t feel good looking at. A mediocre personal site felt worse than no site at all. Ridiculous, I know.
But as AI got better and better, I became curious to see if it was possible to build a WordPress site (I still wanted WordPress) with a beautiful design that framed the content I wanted on the site, along with a backend built according to best practices. Ideally the site would also be optimized for performance and accessibility, but I first wanted to see if I could get something up and running at all.
Here’s the story of how I built my site, including what worked, what didn’t work, and where Claude delivered beyond expectations, and where it needed clear direction to do things well.
It was a really fun and satisfying experience. This post documents the whole thing: the process, the decisions, the mistakes, the fixes, and what I learned. I’ve tried to include enough detail to be useful to anyone who wants to try something similar.
I hope this experience is helpful for others who are setting out to build WordPress sites with AI.
Starting Point: What I Actually Wanted
Before writing a single line of code, I needed to think through what the site should actually be. My requirements:
- A homepage with a hero section, short bio, and links to social profiles
- An About page with a visual timeline of my career – WordCamps I organized, founding Strattic, joining Elementor
- A Media & Appearances section to collect everything I’ve been on: podcasts, talks, webinars, written pieces – all in one place, filterable by type
- A Blog for longer writing
- A Contact page
- The final site needed to be WordPress, but I wanted to prototype the design first
- The aesthetic needed to feel like me: someone who has genuinely loved the internet since the early days. A nerd. Retro internet energy. Friendly.
Step 1: Writing the Brief for a Vibe Coding Tool
I first tried to build the prototype using Google Stitch to mock up the design. Rather than just typing something vague into it, I asked Claude to help me write a proper design brief first.
We went through a few quick questions: color palette direction, aesthetic vibe, whether I had a photo. My answers:
- Color palette: High contrast black with one bold accent color
- Aesthetic: Retro internet / early web nostalgia
- Photo: Yes, I’d provide one
I also told Claude I wanted the tagline to include “open source” so it’s not only focused on WordPress.
Here’s the prompt Claude wrote for the vibe coding tool:
Design a personal website for Miriam Schwab at miriamschwab.me. Miriam is a tech nerd and longtime open source and WordPress community figure — serial founder, speaker, ecosystem builder, and currently Head of WordPress at Elementor. She is friendly.
Aesthetic direction: High contrast black background with one bold accent color — consider electric violet, acid green, or hot coral as options. The overall vibe is retro internet nostalgia: think the energy of early web, HTML-era web design sensibility, but executed with modern craft and typographic polish. Pixel-adjacent details, monospace fonts as accents, maybe subtle grid or terminal-inspired textures — but not kitschy or ironic. This should feel like it was built by someone who has genuinely loved the web since the beginning.
And at the top of the brief, a critical technical requirement which I demanded to keep the site as web-friendly as possible:
Build this entirely in HTML, CSS, and vanilla JavaScript — no React, no frameworks, no component libraries. The code should be clean, semantic, and structured in a way that’s easy to port into a WordPress theme.
Step 2: Why Stitch Didn’t Work Out — and Claude Stepped In
I tried Stitch, and it started off promisingly. But it quickly began to lose coherence. The footer looked different on every page and no matter how hard I tried, there was always one page with a different footer. The fonts would change between screens. Even the placeholder image of me kept shifting. It felt like the tool was crashing in on itself — generating each page somewhat independently rather than maintaining a consistent design system across the whole site.
As I created more and more revisions to fix Stitch’s inability to be consistent, the situation got even worse!
I had enough, so I went over to Claude, my trusted AI collaborator, to see if it could come through.
It did.
Step 3: Claude Built the Whole Static Site
Claude took the brief and built a complete static prototype – a full HTML/CSS/JS site with every page, a consistent design system, and working interactive features. Because everything lived in a shared stylesheet and script file, there was no drift between pages. The fonts, spacing, colors, and components were identical everywhere.
What it built:
- A full-screen hero section with a dot-grid background pattern and subtle scanline overlay in dark mode
- Font pairing: Fraunces (a distinctive serif with personality) and IBM Plex Mono (monospace for the techy details)
- Accent color:
#FF3F00— a hot orange-red that felt right against the dark background - A
>prefix on the nav logo and./prefixes on nav links — small retro terminal details which I LOVE ❤️ - A blinking cursor after the tagline (I also loved this immediately)
- A vertical timeline with staggered scroll-in animations (love)
- A filterable media grid
- A blog listing and single post layout
- A contact form
- A working dark mode toggle that persists to localStorage and respects system preference on first visit
The design felt distinctive, not like an AI-generated template. The retro internet aesthetic came through without being ironic or kitschy. When I uploaded my headshot, it fit in naturally.
I asked Claude to build out the full set of standalone pages — About, Media, Blog, Contact, and a single blog post template. A few key decisions based on Claude’s suggestions and my preferences:
- The About page would have the full timeline; the homepage would show an abbreviated 3-item version with a “full story” link
- The Media section would be one unified filterable feed — all podcasts, talks, webinars, and written pieces together, with category filter tabs
- The blog would use the Block Editor in WordPress — clean, distraction-free, made for long-form writing
- All pages would share the same nav and footer
Claude built all six pages as separate HTML files, plus a shared style.css and script.js. The dark mode toggle worked consistently across all of them.
At this point everything was a static HTML prototype. Next up – converting it to a working WordPress site.
Step 4: Converting to a WordPress Theme
This was an interesting stage, and I was impressed by how Claude got a lot of the architecture thinking right. Converting a static HTML site to a WordPress theme isn’t just a find-and-replace operation; it requires rethinking which content is dynamic, what custom data structures are needed, and how WordPress’s template hierarchy maps to the page layouts.
Claude built the full theme, and one thing I appreciated throughout this process was how consistently it chose the native WordPress approach when possible rather than reaching for plugins or third-party dependencies. I always aimed for this approach when building websites – why create more overhead when native WP features can be leveraged.
| FEATURE | APPROACH |
|---|---|
| Page photography | WordPress native featured images |
| Contact form | wp_mail() + native AJAX — no form plugin needed |
| Lightbox/modal | Vanilla JavaScript — no library |
| Custom fields | ACF registered in code — field structure travels with the theme, not stored in the database separately |
This kept the theme clean, lightweight, and maintainable. There were no surprise plugin dependencies, no external JavaScript libraries to load, and no fields that would disappear if a plugin was deactivated.
Core theme files:
style.css— WordPress theme declarationfunctions.php— asset enqueuing, CPT registration, ACF field definitions, contact form AJAX handlerheader.php/footer.php— shared nav and footerfront-page.php— homepagepage-about.php,page-media.php,page-contact.php— page templatessingle.php— individual blog postsarchive.php— blog listingindex.php,page.php,404.php— fallbacks
A note on the Full Site Editor: A lot of people have asked if this site uses WordPress’s Full Site Editor (FSE). It doesn’t. This is a classic PHP theme — header.php, footer.php, functions.php, individual page templates — the way WordPress themes were built before Gutenberg. That was a deliberate choice: FSE and block themes are excellent for visual editing workflows, but for a site where I wanted precise control over every element and needed clean, lightweight output with no block markup, a classic theme was the right tool. It also meant Claude could write straightforward, readable PHP rather than working within the constraints of block theme architecture.
Three custom post types:
ms_timeline— for timeline items, with a Year/Date Range ACF field and Order support for sequencingms_media— for appearances, with ACF fields for platform, category, date, link URL, link label, and embed codems_event— for upcoming events, with ACF fields for date, date label (for ranges), location, and URL. (More on this in Step 7.)
ACF fields registered in code (I just needed to activate the free ACF plugin — no setup required):
acf_add_local_field_group( [
'key' => 'group_ms_media',
'title' => 'Media Item Details',
'fields' => [
[ 'name' => 'media_platform', 'type' => 'text' ],
[ 'name' => 'media_category', 'type' => 'select',
'choices' => ['podcast','talk','webinar','articles'] ],
[ 'name' => 'media_date', 'type' => 'date_picker' ],
[ 'name' => 'media_link', 'type' => 'url' ],
[ 'name' => 'media_link_label', 'type' => 'select' ],
[ 'name' => 'media_embed', 'type' => 'textarea' ],
],
// ...
] );
Claude packaged the theme as a zip file, so it was easily ready to upload and test out via Appearance → Themes → Add New.
Step 5: The Hardcoded Content Problem – and the XML Solution
The first version of the theme worked, but when I went into the WordPress admin to edit posts or pages, I couldn’t find the content! Turns out Claude had hardcoded the timeline items, media cards, hero bio, and about page intro text were in the PHP templates. Edits were only possible on the theme file level, which obviously defeats the purpose of having a WordPress site 😅
I told Claude: none of the content should be hardcoded. Everything should be editable from the WP admin.
Claude fixed this in two ways:
1. A custom Site Options page (Admin → Site Options) for all the static text that doesn’t fit naturally into WordPress’s post/page model: the hero tagline, hero bio, about page intro paragraphs, the “what I’m working on now” section, contact page text, and social media URLs. I eventually deprecated this too, since most of the content there could also fit into the native WP content management structure.
2. WordPress XML import files — this was one of my favorite parts of the whole process. Claude generated three separate XML files in WordPress’s WXR format, one each for timeline items, media appearances, and blog posts, all pre-populated with real content. I imported them directly via Tools → Import → WordPress and got a fully populated site in seconds. (This can be a great way to populate a site with dummy content too, by the way.)
One issue that emerged: WordPress’s sanitize_textarea_field() function was escaping apostrophes every time I saved the options form, turning “I’ve” into “I\\’ve” on each save. I reported this to Claude, and it identified the cause and switched to wp_unslash() + wp_kses_post() which handles apostrophes correctly without compromising security.
Step 6: The Contact Form
Rather than using a plugin, Claude built a native contact form using WordPress’s built-in wp_mail() function and AJAX:
- Server-side validation (not just client side)
wp_verify_nonce()for security- Submissions sent to whatever email is set in Settings → General → Administration Email
- Reply-To header so replies go directly to the sender
- Inline success/error message without page reload
The contact page was also supposed to pre-fill the subject line when someone clicks “book a meeting with me at Event Name” from an event card on the home page (see more about the Events content below), pulling the actual event title into both the link text and the subject field on arrival. This was Claude’s very cool idea.
However, when I tested the form, I found two issues, but Claude fixed both of them in a snap.
First, slashes were appearing in the subject line — “meeting at CloudFest\ 2026” instead of “meeting at CloudFest 2026.” This was a missing wp_unslash() call before sanitizing the form fields. An easy fix, but a good reminder that sanitization order matters in WordPress: unslash first, then sanitize.
Second, the “book a meeting” link on event cards wasn’t populating the subject field when it landed on the contact page. The URL was passing ?subject=Meeting+at+CloudFest correctly, but the contact page template wasn’t reading that URL parameter and putting it in the input field. A one-line fix – adding a value attribute that reads $_GET['subject'] – and it worked.
Step 7: The Events Section
I wanted a section on the homepage for upcoming conferences and events: where I’ll be, what I’m doing there, and a clear CTA to book a meeting.
Claude built a third custom post type — ms_event — with ACF fields for event date, date label (for ranges like “March 18–21”), location, event URL, and a content editor for describing what I and the team will be doing there.
The section only appears when there are upcoming events, and disappears automatically the day after the event date passes. No manual cleanup needed.
Step 8: Accessibility Audit with Ally + Claude
I ran an accessibility scan using Elementor’s Ally plugin, which identified several issues. Rather than fixing them manually, I brought the issue report to Claude and asked it to address everything at the theme level. I wanted to see how Claude handled this.
The issues Ally found:
Duplicate Navigation Labels — the <nav> element didn’t have an aria-label, so screen readers couldn’t distinguish the main navigation from other nav regions on the page.
Color contrast failing — the accent color #FF3F00 used for text elements like labels, links, and tags was failing WCAG AA contrast against the light background. The required minimum is 4.5:1 and it was coming in under that.
Decorative icons not hidden from screen readers — the SVG icons in the dark mode toggle had no aria-hidden attribute, so screen readers were announcing them as content.
Claude’s fixes:
- Added
aria-label="Primary"to the main nav - Created a new CSS variable
--accent-textspecifically for text — darkened to#B52B00in light mode (5.1:1 ratio) and lightened to#FF8C5Ain dark mode (4.8:1 ratio). The visual accent color for decorative use stayed the same; only text-color uses the accessible variant - Added
aria-hidden="true"andfocusable="false"to all decorative SVGs - Added
aria-pressedto the dark mode toggle button, updated dynamically via JavaScript - Added a skip-to-content link that appears on keyboard focus, jumping past the nav
- Wrapped all page content in
<main>landmarks - Added descriptive
aria-labelsto external links noting they open in a new tab - Added
:focus-visiblestyles for keyboard navigation
There were a couple of iterations getting the contrast exactly right. At one point a CSS restructuring accidentally moved the @import for Google Fonts below other CSS rules — but CSS @import must be the very first statement in a stylesheet, or browsers silently ignore it. This caused fonts to fall back to system fonts, making the site look lighter and washed out. Moving the import back to line 1 fixed it immediately. Later in the build, the @import approach was replaced entirely with an async loading pattern that eliminated a 1,200ms render-blocking penalty — but that’s a story for an upcoming dedicated post on how performance was improved on the site.
Step 9: The Media Section Evolution
The Media & Appearances section went through the most significant design evolution. It started as a 3-column grid of text-only cards, which worked fine. But as I added real podcast and event graphics, I could see it needed tweaking to better support the content, and present it in a useful way.
Featured images. I asked Claude to add support for artwork on media items. Rather than creating a new custom field, Claude used WordPress’s native featured image system (hurray for using built in functionality!) — simply adding 'thumbnail' to the CPT’s supports array. One line of code, no new UI, no extra field to fill in. Just the standard WordPress featured image panel in the editor, which everyone already knows how to use.
2-column grid. Switched from 3 columns to 2, with proper gaps and individual card borders. Description text is clamped to 3 lines using CSS so all cards in a row stay the same height regardless of content length.
New card UX. The original cards had a “play” link and a “listen/watch” link sitting awkwardly at the bottom. The redesign:
- Title always links to the individual post, no conditions
- Play button — a semi-transparent overlay on the image that appears on hover, with a play icon. Clicking it opens the embed in a lightbox modal built in vanilla JavaScript with no dependencies. The modal slides up from center, and closing it works via the X button, the dark backdrop, or the Escape key. The embed stops playing the moment the modal closes.
- Platform name — instead of a generic “listen” or “watch” link, the platform name itself (e.g. “WP Tavern”, “The WP Minute”) becomes the link, with a small external link icon
I love that Claude used a lightbox built in vanilla JS. There are plenty of lightbox libraries out there, but adding a third-party dependency for one feature would have introduced extra HTTP requests, potential version conflicts, and code that lives outside the theme’s control. A clean, self-contained implementation is the better choice, and worked great.
Ordering. Media items are sorted by the ACF media_date field (descending) rather than WordPress post date — so the most recent appearance always appears first regardless of when the post was created in the admin. This was important because I was adding a lot of older appearances that shouldn’t appear at the top.
Step 10: The Custom Cursor
I noticed the blinking cursor after the tagline and mentioned I’d love a cursor effect across the whole site. Claude suggested a dot + ring design — a small solid dot that sits exactly under the pointer, with a larger ring following behind with a slight lag.
The implementation used requestAnimationFrame with an easing factor for the lag effect, cursor: none !important to hide the system cursor, and a media query to restore default cursor behavior on touch devices. aria-hidden="true" on both cursor elements means screen readers ignore them entirely.
That cursor was hard to use, so I updated the cursor to one with subtle trailing dots. Guess if I love it lol. (I do 🙂)
Step 11: Updating the Timeline from a Podcast Transcript
I couldn’t face sitting and writing out the timeline section of my site (it’s hard to write about yourself), so I uploaded a PDF transcript of a podcast interview I had done where I’d told my full career story in my own words.
Claude read the transcript and rewrote all the timeline entries based on what I’d actually said — in my voice.
The transcript also produced two new timeline entries that I wouldn’t have considered adding! The updated timeline was delivered as a new XML import file, ready to go straight into Tools → Import.
Note to future self (and others if it’s helpful): WordPress’s importer checks the trash as well as live posts, so items I’d deleted but not permanently removed were flagged as “already exists.” Permanently emptying the trash before reimporting the updated content fixed it.
What I Learned
AI is good at iterative technical work. The back-and-forth of “build this, now fix this, now I changed my mind about this” is something Claude handled well. It kept track of the full context, remembered decisions made earlier, and didn’t need to be reminded what the project was. I wrote this post a week ago and have already created ten new versions of my site’s theme since then.
You still need to know what you want — but Claude is good at interpreting. The quality of output was proportional to the specificity of my direction. But even my less detailed prompts often got the results I hoped for, or generated output that was better than what I was aiming for! When I said “make the nav styling feel more distinctive,” Claude made sensible judgment calls about what that meant in the context of the design and brief. It wasn’t just executing instructions — it was interpreting intent, which makes all the difference.
Catching mistakes is part of the job. Claude made mistakes — hardcoded content, CSS import ordering, contrast values that were just barely off. But remediating issues was a pretty fast and tight cycle, since Claude generally got the fixes right on the first or second (sometimes it did go on a lot longer).
Different AI tools can play different roles. At one point during the performance work, I was stuck on why the Contact page was scoring badly in PageSpeed Insights. I brought the problem to Gemini to get a fresh read on the diagnostics, and it helped me see that the combination of render-blocking fonts and no fast LCP target was the real issue — not anything obvious. I brought those findings back to Claude to implement the fixes. Neither tool needed to do everything. And within Claude itself, I was running multiple separate conversations across different sessions — the project knowledge docs I maintained between sessions were what kept the context coherent, functioning almost like a shared memory across chats.
Native WordPress patterns are underrated. Claude’s preference for native WordPress solutions kept the theme clean throughout. Featured images, wp_mail(), vanilla JS, ACF in code — these choices meant no surprise dependencies, no mystery plugins, and a codebase that any WordPress developer could pick up and understand. This is exactly how I wanted the site to be built.
The XML imports were a helpful feature I wouldn’t have thought of utilizing. Having Claude generate WXR-format import files for all my content — timeline items, media appearances, blog posts — and deliver them ready to go into Tools → Import saved a lot of manual grunt work. It meant I went from an empty WordPress install to a fully populated site in a few minutes. This approach meant I didn’t just get a shell of a site, I got a site with real content that could be reviewed thoroughly.
A Note on Performance and documentation
Once the site was up and running, I went deep on PageSpeed Insights scores — Redis object caching, async font loading, LCP optimization, preload hints. It became its own project. The short version: every page on the site now scores 87–100 on mobile, with Accessibility, Best Practices, and SEO at 100 across the board. The longer version — what I tried, what failed, and what actually moved the needle — will get attention in its own post, which is coming.
There’s a whole other side to this project I haven’t touched yet: the documentation system I developed to make multi-session AI development actually work. As the site grew into a set of plugins as well, I learned a lot about what kinds of docs you need, why a decisions log is the most critical document you’ll ever write, and how to package your hard-won knowledge into reusable Claude skills so you don’t have to explain the same constraints twice. I’ll cover all of that in the next post.
The Stack
- AI assistant: Claude (Anthropic)
- Design prototype: Static HTML/CSS/JS built directly in Claude
- CMS: WordPress
- Theme: Custom, built by Claude in PHP/HTML/CSS/vanilla JS
- Custom fields: Advanced Custom Fields (free version)
- Accessibility audit: Elementor Ally
- Safety net: WP Rollback
- Hosting: Elementor Hosting
- Domain: miriamschwab.me
- AI in WordPress: Angie