Files
intotheeast-com/docs/solutions/ui-bugs/grav-cropresize-vs-cropzoom-for-cover-images.md
m038andClaude Opus 4.8 838f237ef5 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
2026-07-07 23:49:58 +02:00

6.1 KiB
Raw Permalink Blame History

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
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
wrong_api code_fix medium
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.

{# 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).
  • 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.