docs(solutions): capture Grav cropResize-vs-cropZoom cover-image gotcha

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
This commit is contained in:
2026-07-07 23:49:58 +02:00
co-authored by Claude Opus 4.8
parent 5276768e12
commit 838f237ef5
@@ -0,0 +1,120 @@
---
title: "Grav cropResize fits-inside, not crop-to-fill — blurry cover/banner images"
date: 2026-07-07
category: ui-bugs
module: intotheeast-theme
problem_type: ui_bug
component: rails_view
symptoms:
- "Trip banner/cover renders blurry and badly cropped even though the source photo looks high-res in the post"
- "A portrait phone photo appears as a thin, upscaled horizontal sliver in a wide banner strip"
- "Cover derivative comes back at the source aspect ratio (e.g. 165x220 from a 1013x1350 portrait) instead of the requested strip"
root_cause: wrong_api
resolution_type: code_fix
severity: medium
tags: [grav, twig, medium, cropresize, cropzoom, srcset, retina, cover-image, object-fit]
---
# 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-2024` looked "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
~7001200px 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 because `cropResize` was still the
wrong operation — it emitted odd intermediate `srcset` widths (`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.
```twig
{# 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). Use `cropResize` only 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/grav` script that runs the op and calls
`getimagesize()` 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 `srcset`
candidate when `cover.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. See
`tests/ui/trip/trip-header.spec.js` (portrait-source regression on
`us-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 with `cropResize` across the feature sessions and
`cropZoom` was 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.