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:
@@ -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
|
||||||
|
~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 #}
|
||||||
|
<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.
|
||||||
Reference in New Issue
Block a user