Every one of our personal-brand homepages had a section for the latest few articles — and every one of them was pure text. A headline, a date, an excerpt, a “Read post” link. No face. No frame from the video the article came from. Just words on a card, which is exactly what a template generator produces and exactly what a real human doesn’t. This is the fix, the standard, and the bug we found while shipping it.
The problem: text-only cards read as machine-made
Dennis put it plainly looking at camhazzard.com: there are so many AI-generated sites now that a homepage full of clean headline-and-excerpt cards, with zero photos or faces, reads as generic rather than human — even when a real 20-year-old dunker wrote every word of it. Most of these articles started life as a real conversation on video: an interview, a talk, a call with Dennis. The proof-of-human was sitting right there in the YouTube upload. It just never made it onto the card.
The fix isn’t cosmetic. A thumbnail is the fastest signal a visitor (or a crawling model) has that a real person exists behind the text. It also does the ordinary job a thumbnail always did — it gives someone a reason to click the third card instead of just the first.
The standard: every card gets a real photo, sourced in this order
| Priority | Source | Notes |
|---|---|---|
| 1 | Post already has a featured image | Most posts already have one — RankMath auto-generates a play-button overlay thumbnail from an embedded YouTube video. Just wire the card to it. |
| 2 | Pull the frame straight from YouTube | Browser-side fetch('https://i.ytimg.com/vi/<id>/maxresdefault.jpg'), fall back to hqdefault.jpg if the blob is under ~2000 bytes (a 404 serves a tiny placeholder). Upload via POST /wp-json/wp/v2/media, then PATCH the post’s featured_media. Never move the binary through model context — do the fetch-and-upload entirely inside the browser. |
| 3 | Real photo, last resort | If there was no video, use a real photo of the person/subject. Never a stock image, never a generated illustration — the entire point is proof of a human. |
Two layouts, depending on your card’s CSS
Every homepage in the fleet builds its blog-teaser cards the same rough way — a rounded box with padding, sitting inside a grid — but the card’s own overflow property decides which technique bolts a thumbnail on cleanly.
| Card has | Technique | How |
|---|---|---|
overflow:hidden |
Negative-margin bleed | Insert the image as the card’s first child, pull it to the card’s outer edges with margin:-Ypx -Xpx 4px -Xpx (matching the card’s own padding), give it top-corner border-radius matching the card, and let the card’s existing overflow:hidden do the clipping. Used on camhazzard.com. |
overflow:visible |
Wrap-in-body-div (recommended default) | Move every existing child into a new .card-body div, move the card’s original padding onto that div, set the card itself to padding:0;overflow:hidden, then prepend a full-width <img style="aspect-ratio:16/9;object-fit:cover;"> before the body div. More robust and artifact-proof — use this for all new builds. Used on nathanielstevens.com and dylan-haugen.com. |
The bug: new <img> tags can silently vanish from Elementor’s live render
What we saw
On two different Elementor 3.x sites (3.32.3 and 3.34.2, one with WP Rocket caching and one without, one editing a text-editor widget and one editing an html widget), writing new <img> tags directly into _elementor_data via a raw POST /wp-json/wp/v2/pages/<id> saved to the database every time (verified 200 responses and raw context=edit reads) — but the images never rendered on the live front end. We ruled out caching, widget type, and every individual <img> attribute (class, style, width/height, loading, decoding) one at a time. A completely bare <img src="…"> with zero other attributes still got stripped.
The fix
Route the write through Elementor’s own save pipeline instead of a raw REST/postmeta write. Open the real editor (/wp-admin/post.php?post=<id>&action=elementor), wait for the elementor/$e globals, walk elementor.documents.getCurrent().container to find the target widget’s Container, then run:
$e.run('document/elements/settings', { container: c, settings: { html: newHtml } });
await $e.run('document/save/update');
This worked immediately on nathanielstevens.com — same HTML string, only the save path changed. Rule going forward: any Elementor 3.x edit that adds a new <img> tag must be applied via $e.run, never via a raw REST postmeta write. Elementor 4.0.8 (camhazzard.com) did not exhibit this bug on the raw REST path, so it may be specific to the 3.x save/render pipeline.
A second gotcha: don’t use DOMParser on an HTML fragment
new DOMParser().parseFromString(html,'text/html') on a body-only fragment can auto-hoist a leading <style> tag into an implicitly-created <head>. If you then serialize with doc.body.innerHTML, that entire stylesheet silently disappears — no error, no warning, just gone. We caught this before saving on dylan-haugen.com by diffing style-tag counts before and after.
Fix: parse fragments with document.createRange().createContextualFragment(html) instead — it never promotes anything into a head, so every element (including <style> tags) stays exactly where it was in document order. Always verify a mutation by comparing tag-name counts (div, img, style, etc.) and normalized textContent before and after, before saving anything.
Implementation checklist for any new build
| Step | Do this |
|---|---|
| 1 | Confirm every featured post/card has a featured image; generate one from its YouTube embed if missing. |
| 2 | Check the card’s computed overflow value; pick negative-margin bleed or wrap-in-body-div accordingly (default to wrap-in-body-div for anything new). |
| 3 | Mutate the HTML with createContextualFragment, never DOMParser. Verify tag counts + textContent before saving. |
| 4 | On Elementor 3.x sites: save via $e.run('document/elements/settings') + document/save/update from the live editor, not a raw REST postmeta write. |
| 5 | Clear Elementor’s cache (Elementor → Tools → Clear Files & Data) and, if present, the page-cache plugin (e.g. WP Rocket). Verify with a cache-busted anonymous fetch, not just a logged-in view. |
Rollout status across the personal-brand fleet
This is now a required pattern for every personal-brand homepage we build or touch — not optional polish. Status as of this pass:
| Site | Status | Note |
|---|---|---|
| camhazzard.com | ✓ Live, verified | Negative-margin bleed technique, Elementor 4.0.8. |
| nathanielstevens.com | ✓ Live, verified | Wrap-in-body-div, saved via $e.run after the raw-REST img-stripping bug was found and fixed. |
| dylan-haugen.com | In progress | Content/links refreshed and saved; thumbnails prepared but not yet rendering live — needs the same $e.run fix, blocked on a fresh WP-admin login. |
| georgeleith.com | N/A | Homepage has no blog-teaser section to fix; its /blog/ archive already shows thumbnails natively via the theme loop. |
| Remaining ~193-site fleet | Queued | Standard added to the provisioning checklist for all new builds; backfilling existing sites is blocked on the same Application Password fleet-provisioning gap tracked elsewhere. |
See it working: camhazzard.com and nathanielstevens.com. Full build story and per-site status: the meta article below.

