--- 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 `` 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 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 #} {# 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 #} ``` 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.