"Why? It usually resizes itself."
That was my reply when an editor on the client's side reported, on 1 September, that the hero image on one page no longer filled its frame. There was a gap down the right side and along the bottom. I had reason to be surprised, because until then the images in that theme had always fit their frames. The theme's markup was also correct, and nothing in it had changed.
The template was the same and the result was different, and the answer turned out to be in the markup that reached the browser, not in the template.
A 768 image in an 880 frame
The measurement on the hero: frame 880x495, image 768x432, with the image pinned to the top left. Divide 768 by 880 and you get 0.8727. Divide 432 by 495 and you get 0.8727 again, so the image fills 87 percent on both axes. The two numbers match because the frame and the image share a ratio. The frame carries aspect-ratio: 16 / 9, and 768x432 is exactly 16:9 as well.
The image does not stretch toward the frame at all. It draws at its own size in the top left, and the leftover space becomes the gap on the right and along the bottom that the editor reported. In my notes I wrote the 87/87 pattern down as the signature of this problem: if you see it, look for a wrapper.
The class moves to <picture>
The wrapper came from Imagify. Imagify 2.3.1 on that site runs with display_nextgen set to 1 and display_nextgen_method set to picture, and that picture value is the cause. This mode rewrites every <img class="..."> into <picture class="..."><img></picture> and moves the class attribute onto the wrapper.
The frames in this theme put w-full h-full object-cover on the img. After the rewrite, the result looks roughly like this:
<!-- Simplified. What the template writes: -->
<span class="block overflow-hidden" style="aspect-ratio: 16 / 9;">
<img src="..." alt="" class="w-full h-full object-cover">
</span>
<!-- Simplified. What the DOM holds after Imagify and WP Rocket's lazyload: -->
<span class="block overflow-hidden" style="aspect-ratio: 16 / 9;">
<picture class="w-full h-full object-cover">
<img src="..." alt="" class="entered lazyloaded">
</picture>
</span><picture> is display: inline, so width: 100% and height: 100% do nothing. object-fit does nothing at all on it, because a picture is not a replaced element. Meanwhile the <img> inside, the element that actually draws the photo, is left with only entered lazyloaded from WP Rocket's lazyload. The three sizing classes are still on the page. They just sit on an element that cannot use them.
I have written before about <picture> being inline and object-fit landing on the wrong element. The difference is that back then I had written the <picture> into the template myself, and the fix was simply to remove it. Here the template only outputs a plain <img> inside the frame. A plugin rewrites that markup after the template is finished with it, so there is no <picture> in the template to remove.
Why it only broke now
The rewrite to <picture> needs a WebP to exist. That is why the problem only bites once that specific image has a WebP version, while every older upload was still served as a bare <img> and looked perfect. The editor's September upload was the first image Imagify had converted, so it was also the first to break. That is what made my reply sound reasonable: until that upload, every image in the theme was still a bare <img>.
So this was not a one-page bug. The theme has 24 templates that use the fixed-ratio frame plus object-cover, and each one will break on its own schedule as its images get converted. Every frame like that breaks silently, and only once its image has a WebP. In this theme, a single broken page is not a single bug.
Five rules outside any layer
The fix I shipped is five rules at the end of assets/css/input.css:
picture.w-full { display: block; width: 100%; }
picture.h-full { display: block; height: 100%; }
picture.object-cover > img,
picture.object-contain > img { display: block; width: 100%; height: 100%; }
picture.object-cover > img { object-fit: cover; }
picture.object-contain > img { object-fit: contain; }The rules are deliberately left out of any layer so they outrank the utility layer. Tailwind v4 puts its utilities inside a cascade layer, and a normal declaration outside any layer always beats a declaration inside one, whatever the specificity. The selectors are also scoped to wrappers that actually carry the class, so editor images inside post content stay untouched.
There was a one-click alternative I did not take: switching Imagify to display_nextgen_method = rewrite. That would also have worked, because the server is Apache with a writable .htaccess. I chose CSS because it lives in our own repo, survives anyone resetting the plugin, and covers any other plugin that does the same rewrite. The comment above the block records when it can be deleted: if Imagify moves to the rewrite method.
A rebuild checked by selectors
npm run build in the theme runs the Tailwind CLI from input.css to tailwind-compiled.css with --minify, and Tailwind is pinned at 4.2.4. The theme's node_modules did not survive my move to the Mac, so npm install had to run first. That install also corrected package-lock.json, which turned out to record ^4.1.14 while package.json pins 4.2.4 exactly. That is why the fix commit touches three files rather than two: input.css, tailwind-compiled.css, and package-lock.json.
I verified the rebuild by diffing the selector set, not the bytes. The result was 551 selectors before, 552 after, and none lost. The one gained was .gap-y-5, which means the previously committed artefact had been slightly stale against the markup.
Where the 768 came from
The Imagify fix commit is followed directly by another commit with the same timestamp, down to the second, dedicated to the hero's size. Its message notes that the hero's sizes hint claimed (min-width: 1024px) 48rem, while the hero sits inside a wrapper that caps at 880px. Under-declaring the width by 112px made the browser pick the 768w candidate for an 880px frame, so the image rendered slightly soft even once it filled the frame correctly.
Those numbers explain the 768 from the first measurement. The declared width was 880 minus 112, which is 768px, and the candidate the browser picked was 768w as well. An image drawn at its own size in an 880 frame is then 768 wide, exactly as measured, and 768 divided by 880 is the 87 percent from earlier.
The hero's sizes attribute now reads (min-width: 936px) 880px, 94vw. A comment in the template explains the 936: that is where the container stops being the constraint and 880 takes over. The arithmetic agrees, since 94 percent of 936 is 879.84, effectively 880.
Two old traps, walked into again
During the work I also walked into two traps that were already written down in my own notes.
The first was in curl. I hand-wrote -H 'Accept-Encoding: zstd, gzip, deflate, br' next to --compressed. From there, curl advertises zstd it cannot decode, the body is never written, and the symptom is -D producing headers while -o produces no file at all. The lesson is the same one already in my older note: use --compressed alone.
The second was at the edge. The deploy was correct, and origin had the fix in both the raw theme file and WP Rocket's min copy, yet the edge was entirely stale. The public page was still a HIT with the old sizes, and it pointed at the old ?ver= token whose CSS had none of the new rules. One purge_everything that I ran fixed both, because the stale HTML is what pins the old token. As long as the old HTML is served, the browser keeps asking for the old CSS.
Measure, do not eyeball
For this kind of fix, the way to verify is to measure: load the page, compare the frame's box with the image's box, and read getComputedStyle(img).objectFit. One more check is mandatory, which is asserting that no test stylesheet is still present, through document.getElementById(...). Without it, what you prove might be your own injection rather than the deployed CSS. A minimal example, with a neutral selector and id:
// Minimal example. The selector and id are neutral, adapt them to your page.
const frame = document.querySelector('.frame');
const img = frame.querySelector('img');
const f = frame.getBoundingClientRect();
const i = img.getBoundingClientRect();
console.log(i.width / f.width, i.height / f.height); // fill ratio on both axes
console.log(getComputedStyle(img).objectFit);
console.log(document.getElementById('test-stylesheet') === null); // must be trueMy notes only keep the method. The frame and image measurements after the fix were not recorded, so I am not writing any here. The sentence closest to a visual result is in that sizes commit message, which describes the image as still slightly soft even once it filled the frame correctly.
The title I gave my note on this case sums it up: a plugin can rewrite your markup after the template is finished with it. A correct template does not guarantee a correct DOM, and in a theme with 24 templates built on the same pattern, a report about one page turned out to concern all of them.