New ui-bugs learning: cropResize fits-inside (returns a source-aspect sliver), cropZoom crops-to-fill. Using cropResize for a cover/banner strip hands the browser a portrait sliver that object-fit:cover then upscales into a blur. Documents the fix, the empirical op comparison, and prevention (verify Medium op dimensions; guard retina upscaling; regression-test composition, not just the URL). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
6.1 KiB
title, date, category, module, problem_type, component, symptoms, root_cause, resolution_type, severity, tags
| title | date | category | module | problem_type | component | symptoms | root_cause | resolution_type | severity | tags | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images | 2026-07-07 | ui-bugs | intotheeast-theme | ui_bug | rails_view |
|
wrong_api | code_fix | medium |
|
Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images
Problem
The shared trip-cover macro produced a blurry, badly-composed banner/card image for any trip whose cover fell back to a portrait journal photo. It looked like a low-quality source, but the source was fine — the wrong Grav Medium operation was turning it into a tiny sliver that CSS then upscaled.
Symptoms
- Trip banner on
/trips/us-canada-mex-2024looked "horrendous" — soft and zoomed — while the same photo looked sharp inside the journal post. - The rendered
<img>derivative came back at the source aspect ratio, not the requested strip:cropResize(720, 220)on a 1013×1350 portrait produced a 165×220 image (0.75 ratio, matching the source), not a 720×220 strip. - The banner box (
.trip-header-banner img { object-fit: cover; height: 200px }) then upscaled that ~165px-wide sliver ~4× to fill the column → blur.
What Didn't Work
- Assuming it was source/image quality. The imported photos are only ~700–1200px wide (pixelfed served downscaled web renditions), but that alone did not explain the blur — the same file was sharp in the post.
- Capping the derivative width to avoid upscaling (
min(w, source_width)), as a first pass. This stopped Grav from re-encoding an upscaled JPEG, but the derivative was still a portrait sliver becausecropResizewas still the wrong operation — it emitted odd intermediatesrcsetwidths (1013w,1200w) without fixing the composition. It was treating a symptom.
Solution
Switch the cover operation from cropResize (fit-inside) to cropZoom
(crop-to-fill / cover), and make retina all-or-nothing so a narrow source is
never upscaled.
{# BEFORE — cropResize fits the source INSIDE the box, preserving its aspect
ratio, so a portrait comes back as a narrow sliver #}
<img src="{{ cover.cropResize(w, h).url }}"
srcset="{{ cover.cropResize(w, h).url }} {{ w }}w,
{{ cover.cropResize(w * 2, h * 2).url }} {{ (w * 2) }}w">
{# AFTER — cropZoom crops-to-fill, returning an actual w×h cover strip; the 2x
descriptor is emitted only when the source is genuinely >= 2w wide #}
<img src="{{ cover.cropZoom(w, h).url }}"
srcset="{{ cover.cropZoom(w, h).url }} {{ w }}w{% if cover.width >= (w * 2) %}, {{ cover.cropZoom(w * 2, h * 2).url }} {{ (w * 2) }}w{% endif %}">
Verified empirically against the running container (do not trust the method names from memory — Grav's op semantics are non-obvious):
| op | on a 1013×1350 portrait, target 720×220 | shape |
|---|---|---|
cropResize(720, 220) |
165×220 | fit-inside (source aspect kept) |
cropZoom(720, 220) |
720×220 | crop-to-fill (cover) ✅ |
resize(720, 220) |
720×220 | stretched/distorted ✗ |
Why This Works
Grav's Medium::cropResize($w, $h) scales the image to fit inside the
$w × $h box while preserving the source aspect ratio — for a tall portrait it
is bound by height, yielding a narrow image far smaller than $w. cropZoom
instead scales to cover the box and crops the overflow, so it always returns
exactly $w × $h with no distortion. A banner/card strip wants cover behavior,
so cropZoom is correct. Capping widths at cover.width prevents Grav from
re-encoding an upscaled derivative; combined with object-fit: cover on the
element, the browser gets a sharp strip at (or below) native resolution.
Note the imported photos cap at ~1440px wide, so cover.width >= 2w is usually
false for the wide banner — auto-picked covers render 1x-only (sharp on standard
displays; retina only engages for an explicitly-set wide landscape cover_image).
Prevention
- Choose the Grav Medium op by intent, and verify the output dimensions.
For a fixed-shape strip/thumbnail (banner, card, avatar) use
cropZoom(crop-to-fill). UsecropResizeonly when you actually want the whole image fit inside a bounding box (aspect preserved, letterbox-friendly). - Confirm Medium API behavior empirically before shipping rather than trusting
method names — a quick
php bin/gravscript that runs the op and callsgetimagesize()on the derivative catches fit-vs-fill surprises. (auto memory [claude]: this repo's standing guidance is to look up / verify Grav + plugin API behavior, never guess it.) - Guard retina descriptors against upscaling: only add the 2x
srcsetcandidate whencover.width >= 2 * targetWidth; never emit a derivative wider than the source. - Regression test the composition, not just the URL. Assert the loaded
banner image's natural aspect ratio is the wide strip ratio (e.g.
nw/nh > 3), which fails if a future edit reverts to a fit-inside sliver. Seetests/ui/trip/trip-header.spec.js(portrait-source regression onus-canada-mex-2024).
Related Issues
- Feature that introduced the macro:
docs/working/plans/2026-07-05-trip-description-and-hero.md(see the 2026-07-07 follow-up note). Session history shows the retina cover macro was built entirely withcropResizeacross the feature sessions andcropZoomwas never evaluated, so the bug was latent from inception and only surfaced when real portrait content hit the banner. (session history) - Backlog: full-resolution re-import of pixelfed photos —
docs/working/backlog.md(Content quality — luxury). The ~1440px source ceiling is why auto covers are 1x-only.