Skip to content

feat(publisher): derive <img sizes> from the layout — remove the manual Sizes field#45

Merged
DavidBabinec merged 3 commits into
mainfrom
feat/layout-derived-sizes
Jun 13, 2026
Merged

feat(publisher): derive <img sizes> from the layout — remove the manual Sizes field#45
DavidBabinec merged 3 commits into
mainfrom
feat/layout-derived-sizes

Conversation

@DavidBabinec

Copy link
Copy Markdown
Contributor

Stacked on #40 (fix/responsive-image-selection) — retarget to main after #40 merges.

What this does

sizes becomes fully automatic and invisible to the user, as it should be in a visual editor: the publisher generates the site's CSS, so it computes the rendered width itself instead of asking the author to predict it. The "Sizes" text field is removed from the image module settings (nothing else wrote that prop — HTML import strips it as module-generated; stored values are ignored harmlessly).

How

The new resolver (src/core/publisher/sizesResolver.ts) walks root→image and models each node's width as min(…) over linear functions of the viewport (a·vw + b px) plus a max() floor for min-width. Pixel caps, %/vw widths, px paddings, and grid column tracks (px/%/fr with gaps) all stay linear, so the emitted value is exact CSS math, per breakpoint tier:

sizes="auto, min(33.33vw - 16px, 410.67px)"   ← 3-col grid in a 1280px container

Live measured: the same 3-col grid page at DPR 2 / 1280px viewport downloaded the 6 KB w1024 WebP. The chain for that layout: original bug → multi-MB PNG; #40 → ~122 KB (2048w); this PR → 6 KB. Lazy images keep the auto, prefix so Chromium selects by true rendered width even where the estimate had to bail.

Safety invariant

Anything the model can't express degrades by over-estimating (heavier download, never blurry): flex rows, auto-fit/minmax grids, sibling gridColumn spans, base.loop grids with unequal tracks, non-px paddings/floors.

The multi-agent adversarial review of the first draft found 9 real defects, 6 of them violations of exactly that invariant — each one empirically reproduced by the verifiers against real browser layout, and all fixed + regression-tested here:

  • grid placement now counts only rendered siblings (hidden nodes occupy no grid cell) and bails for unequal tracks when placement is unpredictable
  • % grid tracks resolve against the full content box (gaps overflow; they don't shrink % tracks)
  • class conflicts resolve by styleRule.order (the published stylesheet's source order), not classIds order
  • node.inlineStyles (a literal style attribute in published HTML) now outrank classes in the model too
  • min-width floors emit max(Npx, …) instead of being silently ignored
  • tier collapse restricted to uniformly-nested media queries (mixed min/max directions cover disjoint ranges)
  • degenerate inputs can't emit negative lengths; candidate sets cap at 4 min() terms (multi-KB attributes were reproducible from deep %-cap chains)

Verification

  • 32 resolver unit tests with exact-string expectations (grid math, cascade fidelity, floors, collapse soundness, bloat cap), all TDD'd red-first.
  • Live e2e: built a capped 3-col grid page via the API, published, confirmed emitted markup and the 6 KB rung selection in a DPR-2 browser.
  • bun test (5375 pass / 0 fail), bun run build, bun run lint green.

🤖 Generated with Claude Code

DavidBabinec and others added 2 commits June 11, 2026 23:43
…nts-only srcset

Lighthouse kept reporting huge PNG downloads despite a full WebP srcset.
Root cause: buildMediaSrcset appended the ORIGINAL file as the largest
srcset candidate, and the variant ladder capped at 2048w. With
sizes="1280px", any display with DPR >= 1.7 needs > 2048 device px, so
the browser's only candidate big enough was the original — a 7.4 MB PNG
in the reported case (122 KB as its 2048w WebP). Lighthouse mobile
emulates DPR 2.625, so it selected the PNG on every image, every run.

The fix, in three parts:

- srcset is built from VARIANTS ONLY — the original never appears as a
  candidate. Applied to the publisher (buildMediaSrcset) and to the
  admin twin (buildVariantSrcset: editor canvas, media viewer, video
  poster preview), whose fallthrough now also prefers the largest
  variant over the original. The original survives in `src` only, which
  width-descriptor srcsets reserve for non-srcset browsers.

- the variant ladder gains one rung AT the source's intrinsic width, so
  the srcset's top candidate is a full-quality WebP and dropping the
  original costs no quality ceiling. The rung is clamped to WebP's hard
  16383px output cap (a 900x17000 screenshot encodes a clamped rung
  instead of failing the whole job), and images smaller than every
  target width keep zero variants — small pixel-art icons still publish
  as pixel-exact plain `src`, never force-re-encoded to lossy WebP. The
  Tier-3 delegate path intentionally emits declared widths only: the
  host must not synthesize URLs outside the plugin's schema-bounded
  (16..8192) widths contract.

- lazy images with the default sizes='auto' now emit
  `sizes="auto, <ancestor-cap-or-100vw>"` — Chrome 121+ selects by the
  image's actual rendered width (grids/cards stop over-fetching);
  everyone else parses past the unknown keyword to the fallback. The
  spec restricts `auto` to lazy images, so eager images emit the
  fallback alone, and author-supplied sizes stay verbatim.

Verified live end-to-end: published markup is WebP-only srcset topped by
the intrinsic rung with sizes="auto, 100vw", and a DPR-2 browser at
1280px viewport downloaded the 20 KB intrinsic WebP — the PNG was never
requested. Existing assets (uploaded before this change) have no
intrinsic rung; their srcset now tops at the 2048w WebP, which is still
strictly better than the original on every display. Re-upload to get the
full-resolution top rung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…xact math

The publisher generates the site's CSS, so it can compute what the browser
will lay out instead of asking the author to predict it. The image module's
"Sizes" text field is gone; the resolver always runs.

The v1 resolver only understood ancestor PIXEL caps — a 3-column grid in a
1280px container resolved to sizes="1280px", over-fetching ~3x on every
non-Chromium browser (and on eager images everywhere).

The new model (src/core/publisher/sizesResolver.ts) walks root→image and
expresses each node's width as min(...) over LINEAR functions of the
viewport (a·vw + b px), plus a px floor from min-width emitted as max(...):
px/%/vw width+maxWidth, px paddings (border-box), and grid column tracks
(px/%/fr, literal repeat(), column gaps) all compose exactly — the same
grid emits sizes="auto, min(33.33vw - 16px, 410.67px)" and a DPR-2 browser
downloads the 6KB w1024 rung instead of the 2048w one.

Cascade fidelity (each verified against real browser layout during the
adversarial review of this change):

- classes merge in styleRule.order — the published stylesheet's source
  order — not classIds order; node.inlineStyles merge last (they ship as a
  literal style attribute and outrank every class)
- percentage grid tracks resolve against the FULL content box (gaps
  overflow, they don't shrink % tracks)
- grid placement counts only RENDERED siblings (hidden nodes occupy no
  cell); unequal track lists bail to the container width when placement
  is unpredictable (sibling gridColumn spans, base.loop grids whose copies
  round-robin the tracks); equal track lists are exact regardless
- min-width floors emit max(Npx, ...) instead of being ignored; a non-px
  floor skips that node's narrowing
- tier collapse only under uniformly-nested media queries (all max-width
  or all min-width) — mixed directions cover disjoint ranges where
  dropping an equal-valued tier falls through to the wrong value
- degenerate inputs (overflowing % tracks) can no longer emit negative
  lengths, and candidate sets cap at 4 min() terms (dropping terms only
  ever over-estimates)

Everything the model can't express degrades in the safe direction — the
estimate may only grow (heavier download), never shrink (blurry render).
Flex rows stay content-driven and keep the container width.

Removing the sizes prop: nothing else wrote it (HTML import strips it as a
module-generated attribute), the editor field was a raw HTML attribute
string no visual-editor user could fill confidently, and stored values are
ignored harmlessly. Lazy images keep the `auto, <resolved>` form so
Chromium selects by true rendered width even where the estimate bailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 310cf499-79ed-4a52-9c85-29c05e56cb4c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/layout-derived-sizes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Base automatically changed from fix/responsive-image-selection to main June 12, 2026 06:29
…izes

# Conflicts:
#	docs/features/media.md
#	src/__tests__/modules/imageResponsiveAttrs.test.ts
#	src/modules/base/image/index.ts
@DavidBabinec DavidBabinec merged commit e404f68 into main Jun 13, 2026
5 checks passed
@DavidBabinec DavidBabinec deleted the feat/layout-derived-sizes branch June 13, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant