Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(publisher): derive <img sizes> from the layout — no user knob, e…
…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>
  • Loading branch information
DavidBabinec and claude committed Jun 11, 2026
commit 09f286f9f15c36e8a9ce251cdc04ceccd1f32255
2 changes: 1 addition & 1 deletion docs/features/media.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ media_assets row created, variants_json populated for raster sources

SVG uploads are sanitized and stored as originals only. GIF uploads also stay original-only so animation is preserved; the responsive WebP ladder is generated only for JPEG, PNG, and WebP uploads.

The ladder encodes one WebP per target width (64 / 320 / 640 / 1024 / 1600 / 2048) **below** the source's intrinsic width — never upscaled — plus one rung **at** the intrinsic width. That top rung exists so `srcset` can be built from variants alone: the original file (often a multi-MB PNG) never appears as a srcset candidate, because a high-DPI display asking for more pixels than the largest sub-intrinsic rung would otherwise select it (`sizes="1280px"` on a 2x screen requests 2560 device px). `buildMediaSrcset` (publisher) and `buildVariantSrcset` (admin surfaces) both enforce the variants-only rule at render time, and lazy images emit `sizes="auto, <fallback>"` so Chromium-based browsers select by the actual rendered width.
The ladder encodes one WebP per target width (64 / 320 / 640 / 1024 / 1600 / 2048) **below** the source's intrinsic width — never upscaled — plus one rung **at** the intrinsic width. That top rung exists so `srcset` can be built from variants alone: the original file (often a multi-MB PNG) never appears as a srcset candidate, because a high-DPI display asking for more pixels than the largest sub-intrinsic rung would otherwise select it (`sizes="1280px"` on a 2x screen requests 2560 device px). `buildMediaSrcset` (publisher) and `buildVariantSrcset` (admin surfaces) both enforce the variants-only rule at render time. The `sizes` attribute has no user knob: the publisher's `resolveAutoSizes` (`src/core/publisher/sizesResolver.ts`) derives it from the layout it generates the CSS for — pixel caps, `%`/`vw` widths, px paddings, and grid column tracks all compose into exact CSS math per viewport tier (e.g. `(max-width: 375px) 100vw, min(33.33vw - 16px, 410.67px)`); constructs it can't model (flex rows, auto-fit grids) degrade to the container width, which only ever over-fetches, never blurs. Lazy images prefix the `auto` keyword so Chromium-based browsers select by the actual rendered width.

Ladder edge rules: the intrinsic rung is clamped so neither output dimension exceeds WebP's hard 16383px cap (a 900×17000 screenshot gets a clamped top rung instead of a failed job); images smaller than every target width get **no variants** and publish as plain pixel-exact `src` (small icons are never force-re-encoded to lossy WebP). The Tier-3 delegate path emits **declared widths only** — the host never synthesizes an intrinsic-width URL the delegate's allowlist might reject; the largest declared sub-intrinsic width is that ladder's ceiling.

Expand Down
2 changes: 1 addition & 1 deletion docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ src/core/publisher/
├── frameworkCss.ts — site framework CSS (spacing scale, typography)
├── userStylesheets.ts — site-level user stylesheets
├── siteCssBundle.ts — hash-named bundle composition (reset + framework + style)
├── sizesResolver.ts — `<img sizes>` auto-resolution from viewport contexts
├── sizesResolver.ts — `<img sizes>` derived from the layout: linear width model (caps, fractions, grid tracks) per viewport tier
├── dynamicDetection.ts — Single walker for the 4 auto-detection rules; powers Layers A and C
└── utils.ts — escapeHtml, isSafeUrl, sanitiseCssValue (re-exported from @core/css-sanitize)

Expand Down
11 changes: 6 additions & 5 deletions src/__tests__/base-modules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,12 +415,13 @@ describe('base.image — render() specifics', () => {
// no editor-only chrome in published output). Tests needing <img> must supply src.

it('has only content and behavior module settings', () => {
// N4: responsive pipeline added `sizes`, `fetchPriority`, `decoding` knobs
// alongside the existing `src` / `loading` settings. Alt text is sourced
// from the library asset row (single source of truth) — no per-instance
// `alt` prop exists on the module.
// N4: responsive pipeline added `fetchPriority` / `decoding` knobs
// alongside the existing `src` / `loading` settings. `sizes` has NO knob —
// the publisher derives it from the layout (sizesResolver.ts). Alt text
// is sourced from the library asset row (single source of truth) — no
// per-instance `alt` prop exists on the module.
expect(Object.keys(ImageModule.schema).sort()).toEqual(
['decoding', 'fetchPriority', 'htmlAttributes', 'loading', 'sizes', 'src'],
['decoding', 'fetchPriority', 'htmlAttributes', 'loading', 'src'],
)
})

Expand Down
1 change: 0 additions & 1 deletion src/__tests__/module-engine/moduleConsolidation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ describe('F4 image alt escaping', () => {
{
src: '/uploads/hero.webp',
loading: 'lazy',
sizes: 'auto',
fetchPriority: 'auto',
decoding: 'async',
_resolvedMediaByKey: { src: makeMedia({ altText: 'a & b <c> "d"' }) },
Expand Down
36 changes: 16 additions & 20 deletions src/__tests__/modules/imageResponsiveAttrs.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
/**
* base.image responsive attributes — `sizes` resolution.
*
* `sizes='auto'` (the prop default) must emit the standards-based
* `auto, <fallback>` form on LAZY images: browsers that implement
* `sizes=auto` (Chrome 121+) pick by the image's actual rendered width,
* everyone else parses past the unknown keyword and uses the fallback.
* The spec only allows `auto` on `loading="lazy"` images, so eager images
* emit the fallback alone. Author-supplied `sizes` values are verbatim.
* `sizes` has no user knob: the publisher's layout resolver attaches
* `_resolvedAutoSizes` and the module emits it. LAZY images prefix the
* standards-based `auto` keyword: browsers that implement `sizes=auto`
* (Chrome 121+) pick by the image's actual rendered width, everyone else
* parses past the unknown keyword and uses the resolved fallback. The spec
* only allows `auto` on `loading="lazy"` images, so eager images emit the
* fallback alone.
*/
import { describe, expect, it } from 'bun:test'
import type { RenderResolvedMedia } from '@core/publisher'
Expand Down Expand Up @@ -50,28 +51,23 @@ function sizesAttr(html: string): string | null {
}

describe('base.image sizes resolution', () => {
it("lazy + sizes 'auto' with a publisher-resolved cap emits `auto, <cap>`", () => {
const html = renderImage({ loading: 'lazy', sizes: 'auto', _resolvedAutoSizes: '1280px' })
expect(sizesAttr(html)).toBe('auto, 1280px')
it('lazy with a publisher-resolved value emits `auto, <resolved>`', () => {
const html = renderImage({ loading: 'lazy', _resolvedAutoSizes: 'min(33.33vw - 16px, 410.67px)' })
expect(sizesAttr(html)).toBe('auto, min(33.33vw - 16px, 410.67px)')
})

it("lazy + sizes 'auto' without a resolved cap emits `auto, 100vw`", () => {
const html = renderImage({ loading: 'lazy', sizes: 'auto' })
it('lazy without a resolved value emits `auto, 100vw`', () => {
const html = renderImage({ loading: 'lazy' })
expect(sizesAttr(html)).toBe('auto, 100vw')
})

it("eager + sizes 'auto' emits the fallback alone (`auto` keyword is lazy-only)", () => {
const html = renderImage({ loading: 'eager', sizes: 'auto', _resolvedAutoSizes: '1280px' })
expect(sizesAttr(html)).toBe('1280px')
})

it('an author-supplied sizes value is emitted verbatim', () => {
const html = renderImage({ loading: 'lazy', sizes: '(min-width: 1024px) 50vw, 100vw' })
expect(sizesAttr(html)).toBe('(min-width: 1024px) 50vw, 100vw')
it('eager emits the resolved value alone (`auto` keyword is lazy-only)', () => {
const html = renderImage({ loading: 'eager', _resolvedAutoSizes: 'min(100vw, 1280px)' })
expect(sizesAttr(html)).toBe('min(100vw, 1280px)')
})

it('srcset never contains the original file', () => {
const html = renderImage({ loading: 'lazy', sizes: 'auto' })
const html = renderImage({ loading: 'lazy' })
const m = html.match(/srcset="([^"]*)"/)
expect(m).not.toBeNull()
expect(m![1]).not.toContain('.png')
Expand Down
Loading