From 838f237ef5884758948fe9f2bf671f7c690b6643 Mon Sep 17 00:00:00 2001 From: Mischa Date: Tue, 7 Jul 2026 23:49:58 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K --- ...cropresize-vs-cropzoom-for-cover-images.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/solutions/ui-bugs/grav-cropresize-vs-cropzoom-for-cover-images.md diff --git a/docs/solutions/ui-bugs/grav-cropresize-vs-cropzoom-for-cover-images.md b/docs/solutions/ui-bugs/grav-cropresize-vs-cropzoom-for-cover-images.md new file mode 100644 index 0000000..fa6dc35 --- /dev/null +++ b/docs/solutions/ui-bugs/grav-cropresize-vs-cropzoom-for-cover-images.md @@ -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 `` 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.