From ffc9188466882680722cc28ed07b6e14bb3c8067 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:36:05 +0000 Subject: [PATCH 1/8] fix: use container queries for MediaBlock responsive sizing Switch MediaBlock from viewport-based breakpoints (md:, lg:) to CSS container query breakpoints (@sm:, @lg:) so responsive image sizing adapts to the parent container width instead of the viewport. This fixes incorrect sizing when MediaBlock is embedded in narrower containers like the blog post layout (max-w-[48rem]). Also update InlineMediaBlock sizes attribute to use conservative container-relative pixel estimates instead of viewport-relative vw units. Changes: - MediaBlock: Add @container to wrapper div, use @sm:/@lg: container query classes instead of md:/lg: viewport breakpoints - MediaBlock: Remove unused cssVariables import - InlineMediaBlock: Replace vw-based sizes hints with container-aware pixel estimates Closes #757 Co-authored-by: Rachel Fryan --- src/blocks/InlineMedia/Component.tsx | 13 +++++++++++-- src/blocks/Media/Component.tsx | 24 +++++++++++++----------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx index 88d32bcb..d4d8d753 100644 --- a/src/blocks/InlineMedia/Component.tsx +++ b/src/blocks/InlineMedia/Component.tsx @@ -19,6 +19,16 @@ const verticalAlignClasses = { baseline: 'align-baseline', } +// Maps percentage width to a conservative pixel estimate for the sizes attribute. +// Uses container-relative values instead of viewport-relative (vw) so the browser +// picks an appropriately sized image when the block is inside a narrower container. +const sizesForWidth: Record = { + '25': '(max-width: 640px) 25vw, 192px', // ~25% of a ~768px container + '50': '(max-width: 640px) 50vw, 384px', // ~50% of a ~768px container + '75': '(max-width: 640px) 75vw, 576px', // ~75% of a ~768px container + '100': '(max-width: 640px) 100vw, 768px', // full container width +} + type WidthSize = keyof typeof widthClasses function isWidthSize(size: string): size is WidthSize { @@ -50,8 +60,7 @@ export const InlineMediaComponent = ({ } else if (isWidthSize(resolvedSize)) { sizeClass = widthClasses[resolvedSize] imgSizeClass = 'w-full h-auto' - // Approximate sizes hint for responsive images - sizes = `${resolvedSize}vw` + sizes = sizesForWidth[resolvedSize] ?? '100vw' } else if (isFixedHeight) { imgSizeClass = 'h-full w-auto' sizes = '96px' diff --git a/src/blocks/Media/Component.tsx b/src/blocks/Media/Component.tsx index fe16d260..df598c30 100644 --- a/src/blocks/Media/Component.tsx +++ b/src/blocks/Media/Component.tsx @@ -6,11 +6,8 @@ import { cn } from '@/utilities/ui' import type { MediaBlock as MediaBlockProps } from '@/payload-types' import { Media } from '@/components/Media' -import { cssVariables } from '@/cssVariables' import getTextColorFromBgColor from '@/utilities/getTextColorFromBgColor' -const { breakpoints } = cssVariables - type Props = MediaBlockProps & { isLayoutBlock: boolean captionClassName?: string @@ -36,14 +33,17 @@ export const MediaBlockComponent = (props: Props) => { const bgColorClass = `bg-${backgroundColor}` const textColor = getTextColorFromBgColor(backgroundColor) + // Uses container query breakpoints (@sm, @md, @lg) so sizing responds to the + // parent container width rather than the viewport. This ensures correct + // behavior when the block is embedded in a narrower container (e.g. post layout). const getImageSizeClasses = () => { switch (imageSize) { case 'small': - return 'max-w-xs md:max-w-sm lg:max-w-md' + return 'max-w-xs @sm:max-w-sm @lg:max-w-md' case 'medium': - return 'max-w-sm md:max-w-lg lg:max-w-2xl' + return 'max-w-sm @sm:max-w-lg @lg:max-w-2xl' case 'large': - return 'max-w-md md:max-w-2xl lg:max-w-4xl' + return 'max-w-md @sm:max-w-2xl @lg:max-w-4xl' case 'full': return 'max-w-full' case 'original': @@ -52,16 +52,17 @@ export const MediaBlockComponent = (props: Props) => { } } - // sizes prop hints to browser what image width to request based on imageSize setting - // Uses breakpoints from cssVariables for consistency with other image components + // sizes prop hints to the browser what image width to request. + // Uses conservative estimates based on the container size the block will + // actually occupy, rather than the full viewport width. const getSizesForImageSize = () => { switch (imageSize) { case 'small': - return `(max-width: ${breakpoints.md}px) 100vw, 384px` // max-w-sm = 24rem = 384px + return '(max-width: 640px) 100vw, 384px' // small caps at max-w-md = 28rem = 448px case 'medium': - return `(max-width: ${breakpoints.md}px) 100vw, 672px` // max-w-2xl = 42rem = 672px + return '(max-width: 640px) 100vw, 672px' // medium caps at max-w-2xl = 42rem = 672px case 'large': - return `(max-width: ${breakpoints.md}px) 100vw, 896px` // max-w-4xl = 56rem = 896px + return '(max-width: 640px) 100vw, 896px' // large caps at max-w-4xl = 56rem = 896px case 'full': return '100vw' case 'original': @@ -75,6 +76,7 @@ export const MediaBlockComponent = (props: Props) => {
Date: Fri, 12 Jun 2026 09:09:47 -0700 Subject: [PATCH 2/8] Make comments more concise and use breakpoint variable instead of px value --- src/blocks/InlineMedia/Component.tsx | 5 ++--- src/blocks/Media/Component.tsx | 19 ++++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx index d4d8d753..2fde53d3 100644 --- a/src/blocks/InlineMedia/Component.tsx +++ b/src/blocks/InlineMedia/Component.tsx @@ -19,9 +19,8 @@ const verticalAlignClasses = { baseline: 'align-baseline', } -// Maps percentage width to a conservative pixel estimate for the sizes attribute. -// Uses container-relative values instead of viewport-relative (vw) so the browser -// picks an appropriately sized image when the block is inside a narrower container. +// Maps percentage width to a sizes attribute, using container-relative pixel +// estimates so the browser picks an appropriately sized image in narrow containers. const sizesForWidth: Record = { '25': '(max-width: 640px) 25vw, 192px', // ~25% of a ~768px container '50': '(max-width: 640px) 50vw, 384px', // ~50% of a ~768px container diff --git a/src/blocks/Media/Component.tsx b/src/blocks/Media/Component.tsx index df598c30..a9916421 100644 --- a/src/blocks/Media/Component.tsx +++ b/src/blocks/Media/Component.tsx @@ -6,8 +6,11 @@ import { cn } from '@/utilities/ui' import type { MediaBlock as MediaBlockProps } from '@/payload-types' import { Media } from '@/components/Media' +import { cssVariables } from '@/cssVariables' import getTextColorFromBgColor from '@/utilities/getTextColorFromBgColor' +const { breakpoints } = cssVariables + type Props = MediaBlockProps & { isLayoutBlock: boolean captionClassName?: string @@ -33,9 +36,8 @@ export const MediaBlockComponent = (props: Props) => { const bgColorClass = `bg-${backgroundColor}` const textColor = getTextColorFromBgColor(backgroundColor) - // Uses container query breakpoints (@sm, @md, @lg) so sizing responds to the - // parent container width rather than the viewport. This ensures correct - // behavior when the block is embedded in a narrower container (e.g. post layout). + // Container query breakpoints (@sm, @md, @lg) so sizing tracks the parent + // container width rather than the viewport, e.g. when embedded in a post layout. const getImageSizeClasses = () => { switch (imageSize) { case 'small': @@ -52,17 +54,16 @@ export const MediaBlockComponent = (props: Props) => { } } - // sizes prop hints to the browser what image width to request. - // Uses conservative estimates based on the container size the block will - // actually occupy, rather than the full viewport width. + // Hints the image width to request, using conservative estimates based on the + // container size the block actually occupies rather than the full viewport. const getSizesForImageSize = () => { switch (imageSize) { case 'small': - return '(max-width: 640px) 100vw, 384px' // small caps at max-w-md = 28rem = 448px + return `(max-width: ${breakpoints.sm}px) 100vw, 384px` // small caps at max-w-md = 28rem = 448px case 'medium': - return '(max-width: 640px) 100vw, 672px' // medium caps at max-w-2xl = 42rem = 672px + return `(max-width: ${breakpoints.sm}px) 100vw, 672px` // medium caps at max-w-2xl = 42rem = 672px case 'large': - return '(max-width: 640px) 100vw, 896px' // large caps at max-w-4xl = 56rem = 896px + return `(max-width: ${breakpoints.sm}px) 100vw, 896px` // large caps at max-w-4xl = 56rem = 896px case 'full': return '100vw' case 'original': From b5cd929b28f55d72625f82846103337624bed968 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Fri, 12 Jun 2026 09:12:23 -0700 Subject: [PATCH 3/8] Update Inline Media components to use variable breakpoint --- src/blocks/InlineMedia/Component.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx index 2fde53d3..1fd13ecb 100644 --- a/src/blocks/InlineMedia/Component.tsx +++ b/src/blocks/InlineMedia/Component.tsx @@ -1,8 +1,11 @@ import type { InlineMediaBlock } from '@/payload-types' import { Media } from '@/components/Media' +import { cssVariables } from '@/cssVariables' import { cn } from '@/utilities/ui' +const { breakpoints } = cssVariables + type Props = Omit const widthClasses = { @@ -22,10 +25,10 @@ const verticalAlignClasses = { // Maps percentage width to a sizes attribute, using container-relative pixel // estimates so the browser picks an appropriately sized image in narrow containers. const sizesForWidth: Record = { - '25': '(max-width: 640px) 25vw, 192px', // ~25% of a ~768px container - '50': '(max-width: 640px) 50vw, 384px', // ~50% of a ~768px container - '75': '(max-width: 640px) 75vw, 576px', // ~75% of a ~768px container - '100': '(max-width: 640px) 100vw, 768px', // full container width + '25': `(max-width: ${breakpoints.sm}px) 25vw, 192px`, // ~25% of a ~768px container + '50': `(max-width: ${breakpoints.sm}px) 50vw, 384px`, // ~50% of a ~768px container + '75': `(max-width: ${breakpoints.sm}px) 75vw, 576px`, // ~75% of a ~768px container + '100': `(max-width: ${breakpoints.sm}px) 100vw, 768px`, // full container width } type WidthSize = keyof typeof widthClasses From 3a352c3482b2ca78437b96db377cb7bd53f98cd3 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Mon, 15 Jun 2026 19:11:14 -0700 Subject: [PATCH 4/8] fix: size MediaBlock relative to its container with a matching sizes hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch from fixed breakpoint caps to container-relative `cqw` widths (with a px floor) so each image size stays visually distinct in any container — full-width, blog body, or a narrow grid column. Derive the `sizes` attribute from the same formula and thread column context from Content blocks through RichText, so the downloaded image matches what's rendered. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/blocks/Content/Component.tsx | 6 +- src/blocks/InlineMedia/Component.tsx | 25 +++-- src/blocks/Media/Component.tsx | 39 ++++---- src/components/RichText/index.tsx | 90 ++++++++++-------- src/utilities/mediaSizes.ts | 133 +++++++++++++++++++++++++++ 5 files changed, 218 insertions(+), 75 deletions(-) create mode 100644 src/utilities/mediaSizes.ts diff --git a/src/blocks/Content/Component.tsx b/src/blocks/Content/Component.tsx index a50bfb99..67fafe82 100644 --- a/src/blocks/Content/Component.tsx +++ b/src/blocks/Content/Component.tsx @@ -1,6 +1,7 @@ import RichText from '@/components/RichText' import type { ContentBlock as ContentBlockProps } from '@/payload-types' import getTextColorFromBgColor from '@/utilities/getTextColorFromBgColor' +import { buildColumnSizes } from '@/utilities/mediaSizes' import { cn } from '@/utilities/ui' export const ContentBlockComponent = (props: ContentBlockProps) => { @@ -46,9 +47,12 @@ export const ContentBlockComponent = (props: ContentBlockProps) => {
{columns?.map((col, index) => { const { richText } = col + const columnSizes = buildColumnSizes(colsSpanClass[index] ?? '') return (
- {richText && } + {richText && ( + + )}
) })} diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx index 1fd13ecb..a2207640 100644 --- a/src/blocks/InlineMedia/Component.tsx +++ b/src/blocks/InlineMedia/Component.tsx @@ -1,12 +1,13 @@ import type { InlineMediaBlock } from '@/payload-types' import { Media } from '@/components/Media' -import { cssVariables } from '@/cssVariables' +import { buildImageSizes, type ContainerSizes, FULL_WIDTH_CONTAINER } from '@/utilities/mediaSizes' import { cn } from '@/utilities/ui' -const { breakpoints } = cssVariables - -type Props = Omit +type Props = Omit & { + // Column context from a multi-column layout, so `sizes` matches the real container width. + containerSizes?: ContainerSizes +} const widthClasses = { '25': 'w-1/4', @@ -22,15 +23,6 @@ const verticalAlignClasses = { baseline: 'align-baseline', } -// Maps percentage width to a sizes attribute, using container-relative pixel -// estimates so the browser picks an appropriately sized image in narrow containers. -const sizesForWidth: Record = { - '25': `(max-width: ${breakpoints.sm}px) 25vw, 192px`, // ~25% of a ~768px container - '50': `(max-width: ${breakpoints.sm}px) 50vw, 384px`, // ~50% of a ~768px container - '75': `(max-width: ${breakpoints.sm}px) 75vw, 576px`, // ~75% of a ~768px container - '100': `(max-width: ${breakpoints.sm}px) 100vw, 768px`, // full container width -} - type WidthSize = keyof typeof widthClasses function isWidthSize(size: string): size is WidthSize { @@ -44,6 +36,7 @@ export const InlineMediaComponent = ({ size = 'original', fixedHeight, caption, + containerSizes, }: Props) => { if (!media || typeof media === 'number' || typeof media === 'string') { return null @@ -62,7 +55,11 @@ export const InlineMediaComponent = ({ } else if (isWidthSize(resolvedSize)) { sizeClass = widthClasses[resolvedSize] imgSizeClass = 'w-full h-auto' - sizes = sizesForWidth[resolvedSize] ?? '100vw' + // Width is `resolvedSize`% of the container, so match `sizes` to that fraction. + sizes = buildImageSizes(containerSizes ?? FULL_WIDTH_CONTAINER, { + percent: Number(resolvedSize), + floorPx: 0, + }) } else if (isFixedHeight) { imgSizeClass = 'h-full w-auto' sizes = '96px' diff --git a/src/blocks/Media/Component.tsx b/src/blocks/Media/Component.tsx index a9916421..f8f3b99e 100644 --- a/src/blocks/Media/Component.tsx +++ b/src/blocks/Media/Component.tsx @@ -6,10 +6,13 @@ import { cn } from '@/utilities/ui' import type { MediaBlock as MediaBlockProps } from '@/payload-types' import { Media } from '@/components/Media' -import { cssVariables } from '@/cssVariables' import getTextColorFromBgColor from '@/utilities/getTextColorFromBgColor' - -const { breakpoints } = cssVariables +import { + buildImageSizes, + type ContainerSizes, + FULL_WIDTH_CONTAINER, + IMAGE_SIZE_SPECS, +} from '@/utilities/mediaSizes' type Props = MediaBlockProps & { isLayoutBlock: boolean @@ -17,6 +20,8 @@ type Props = MediaBlockProps & { className?: string imgClassName?: string staticImage?: StaticImageData + // Column context from a multi-column layout, so `sizes` matches the real container width. + containerSizes?: ContainerSizes } export const MediaBlockComponent = (props: Props) => { @@ -31,21 +36,21 @@ export const MediaBlockComponent = (props: Props) => { alignContent = 'left', backgroundColor, imageSize = 'original', + containerSizes, } = props const bgColorClass = `bg-${backgroundColor}` const textColor = getTextColorFromBgColor(backgroundColor) - // Container query breakpoints (@sm, @md, @lg) so sizing tracks the parent - // container width rather than the viewport, e.g. when embedded in a post layout. + // `cqw` sizes the image relative to the block's `@container`. Keep in sync with IMAGE_SIZE_SPECS. const getImageSizeClasses = () => { switch (imageSize) { case 'small': - return 'max-w-xs @sm:max-w-sm @lg:max-w-md' + return 'max-w-[max(16rem,50cqw)]' case 'medium': - return 'max-w-sm @sm:max-w-lg @lg:max-w-2xl' + return 'max-w-[max(20rem,75cqw)]' case 'large': - return 'max-w-md @sm:max-w-2xl @lg:max-w-4xl' + return 'max-w-[max(24rem,90cqw)]' case 'full': return 'max-w-full' case 'original': @@ -54,22 +59,10 @@ export const MediaBlockComponent = (props: Props) => { } } - // Hints the image width to request, using conservative estimates based on the - // container size the block actually occupies rather than the full viewport. const getSizesForImageSize = () => { - switch (imageSize) { - case 'small': - return `(max-width: ${breakpoints.sm}px) 100vw, 384px` // small caps at max-w-md = 28rem = 448px - case 'medium': - return `(max-width: ${breakpoints.sm}px) 100vw, 672px` // medium caps at max-w-2xl = 42rem = 672px - case 'large': - return `(max-width: ${breakpoints.sm}px) 100vw, 896px` // large caps at max-w-4xl = 56rem = 896px - case 'full': - return '100vw' - case 'original': - default: - return '100vw' - } + const spec = IMAGE_SIZE_SPECS[imageSize ?? 'original'] + if (!spec) return '100vw' + return buildImageSizes(containerSizes ?? FULL_WIDTH_CONTAINER, spec) } return ( diff --git a/src/components/RichText/index.tsx b/src/components/RichText/index.tsx index 3f8eae50..736d63a0 100644 --- a/src/components/RichText/index.tsx +++ b/src/components/RichText/index.tsx @@ -46,6 +46,7 @@ import type { SponsorsBlock as SponsorsBlockProps, } from '@/payload-types' import { handleReferenceURL } from '@/utilities/handleReferenceURL' +import type { ContainerSizes } from '@/utilities/mediaSizes' import { cn } from '@/utilities/ui' type LinkDocRelationTo = (typeof LINK_ENABLED_COLLECTIONS)[number] @@ -113,52 +114,67 @@ const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => { return url || '/' } -const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({ - ...defaultConverters, - ...LinkJSXConverter({ internalDocToHref }), - // if block has two variants - to make TS happy we fallback to the default for the block variant - blocks: { - blogList: ({ node }) => , - buttonBlock: ({ node }) => , - calloutBlock: ({ node }) => , - documentBlock: ({ node }) => , - eventList: ({ node }) => , - eventTable: ({ node }) => , - singleEvent: ({ node }) => , - genericEmbed: ({ node }) => ( - - ), - headerBlock: ({ node }) => , - imageText: ({ node }) => , - mediaBlock: ({ node }) => ( - - ), - singleBlogPost: ({ node }) => ( - - ), - sponsorsBlock: ({ node }) => , - }, - inlineBlocks: { - inlineMedia: ({ node }) => , - }, -}) +// Built as a factory so the Media converters can close over `containerSizes`, +// which a multi-column Content block passes down for container-accurate `sizes`. +const makeJsxConverters = + (containerSizes?: ContainerSizes): JSXConvertersFunction => + ({ defaultConverters }) => ({ + ...defaultConverters, + ...LinkJSXConverter({ internalDocToHref }), + // if block has two variants - to make TS happy we fallback to the default for the block variant + blocks: { + blogList: ({ node }) => , + buttonBlock: ({ node }) => , + calloutBlock: ({ node }) => , + documentBlock: ({ node }) => ( + + ), + eventList: ({ node }) => , + eventTable: ({ node }) => , + singleEvent: ({ node }) => ( + + ), + genericEmbed: ({ node }) => ( + + ), + headerBlock: ({ node }) => , + imageText: ({ node }) => , + mediaBlock: ({ node }) => ( + + ), + singleBlogPost: ({ node }) => ( + + ), + sponsorsBlock: ({ node }) => ( + + ), + }, + inlineBlocks: { + inlineMedia: ({ node }) => ( + + ), + }, + }) type Props = { data: SerializedEditorState enableGutter?: boolean + // Column context from a multi-column layout, forwarded to embedded Media blocks. + containerSizes?: ContainerSizes } & React.HTMLAttributes export default function RichText(props: Props) { - const { className, enableGutter = true, ...rest } = props + const { className, enableGutter = true, containerSizes, ...rest } = props return ( = { + small: { percent: 50, floorPx: 256 }, // max-w-[max(16rem,50cqw)] + medium: { percent: 75, floorPx: 320 }, // max-w-[max(20rem,75cqw)] + large: { percent: 90, floorPx: 384 }, // max-w-[max(24rem,90cqw)] + full: { percent: 100, floorPx: 0 }, // max-w-full + original: null, // natural size +} + +// Fallback when there's no column context (single-column / full-width). +export const FULL_WIDTH_CONTAINER: ContainerSizes = { + segments: [{ minViewport: 0, width: 'full' }], +} + +const GRID_BREAKPOINTS: { key: string; vw: number; inner: number | 'full'; total: number }[] = [ + { key: 'base', vw: 0, inner: 'full', total: 6 }, + { key: 'sm', vw: breakpoints.sm, inner: containerInnerWidth.sm, total: 6 }, + { key: 'md', vw: breakpoints.md, inner: containerInnerWidth.md, total: 6 }, + { key: 'lg', vw: breakpoints.lg, inner: containerInnerWidth.lg, total: 12 }, + { key: 'xl', vw: breakpoints.xl, inner: containerInnerWidth.xl, total: 12 }, + { key: '2xl', vw: breakpoints['2xl'], inner: containerInnerWidth['2xl'], total: 12 }, +] + +function columnWidth(innerWidth: number, span: number, total: number): number { + const track = (innerWidth - (total - 1) * GRID_GAP_PX) / total + return Math.round(track * span + (span - 1) * GRID_GAP_PX) +} + +function parseSpans(spanClass: string): Record { + const spans: Record = { base: 6 } + // optional `bp:` prefix (sm/md/lg/xl/2xl) followed by `col-span-N` + const re = /(?:(sm|md|lg|xl|2xl):)?col-span-(\d+)/g + let match: RegExpExecArray | null + while ((match = re.exec(spanClass))) { + spans[match[1] ?? 'base'] = Number(match[2]) + } + return spans +} + +// Resolves a Content column's responsive `col-span-*` classes to its width per viewport. +export function buildColumnSizes(spanClass: string): ContainerSizes { + const spans = parseSpans(spanClass) + const segments: ContainerSizes['segments'] = [] + let currentSpan = spans.base + for (const bp of GRID_BREAKPOINTS) { + if (spans[bp.key] != null) currentSpan = spans[bp.key] + const width: number | 'full' = + bp.inner === 'full' || currentSpan >= bp.total + ? 'full' + : columnWidth(bp.inner, currentSpan, bp.total) + const last = segments[segments.length - 1] + if (!last || last.width !== width) segments.push({ minViewport: bp.vw, width }) + } + return { segments } +} + +// The final (unbounded) interval drops its condition to become the `sizes` default. +function emitSizes(intervals: { upper: number | null; value: string }[]): string { + return intervals + .map((iv, idx) => + idx === intervals.length - 1 || iv.upper == null + ? iv.value + : `(max-width: ${iv.upper}px) ${iv.value}`, + ) + .join(', ') +} + +function mergeIntervals( + intervals: { upper: number | null; value: string }[], +): { upper: number | null; value: string }[] { + const merged: { upper: number | null; value: string }[] = [] + for (const iv of intervals) { + const last = merged[merged.length - 1] + if (last && last.value === iv.value) last.upper = iv.upper + else merged.push({ ...iv }) + } + return merged +} + +// Builds a `sizes` attribute mirroring the cqw CSS: min(container, max(floorPx, percent%)). +export function buildImageSizes(container: ContainerSizes, spec: SizeSpec): string { + const { percent, floorPx } = spec + // In a 'full' segment the container tracks the viewport; floor and percent cross here. + const floorCrossover = percent > 0 ? Math.round((floorPx * 100) / percent) : 0 + + const boundaries = new Set(container.segments.map((s) => s.minViewport)) + if (floorCrossover > 0) boundaries.add(floorCrossover) + const sorted = [...boundaries].sort((a, b) => a - b) + + const widthAt = (vw: number): number | 'full' => { + let active: ContainerSizes['segments'][number] | undefined + for (const s of container.segments) if (s.minViewport <= vw) active = s + return active ? active.width : 'full' + } + + const renderedAt = (vw: number): string => { + const w = widthAt(vw) + if (w === 'full') return vw < floorCrossover ? `${floorPx}px` : `${percent}vw` + return `${Math.min(w, Math.max(floorPx, Math.round((percent / 100) * w)))}px` + } + + const intervals = sorted.map((start, i) => ({ + upper: i + 1 < sorted.length ? sorted[i + 1] : null, + value: renderedAt(start), + })) + return emitSizes(mergeIntervals(intervals)) +} From 1e141361631662d51d93646ba734496d7b3a108c Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Mon, 15 Jun 2026 19:21:54 -0700 Subject: [PATCH 5/8] Simplify buildImageSizes --- src/utilities/mediaSizes.ts | 65 +++++++++---------------------------- 1 file changed, 16 insertions(+), 49 deletions(-) diff --git a/src/utilities/mediaSizes.ts b/src/utilities/mediaSizes.ts index 9a0cf1ad..f9270b49 100644 --- a/src/utilities/mediaSizes.ts +++ b/src/utilities/mediaSizes.ts @@ -80,54 +80,21 @@ export function buildColumnSizes(spanClass: string): ContainerSizes { return { segments } } -// The final (unbounded) interval drops its condition to become the `sizes` default. -function emitSizes(intervals: { upper: number | null; value: string }[]): string { - return intervals - .map((iv, idx) => - idx === intervals.length - 1 || iv.upper == null - ? iv.value - : `(max-width: ${iv.upper}px) ${iv.value}`, - ) - .join(', ') -} - -function mergeIntervals( - intervals: { upper: number | null; value: string }[], -): { upper: number | null; value: string }[] { - const merged: { upper: number | null; value: string }[] = [] - for (const iv of intervals) { - const last = merged[merged.length - 1] - if (last && last.value === iv.value) last.upper = iv.upper - else merged.push({ ...iv }) - } - return merged -} - // Builds a `sizes` attribute mirroring the cqw CSS: min(container, max(floorPx, percent%)). -export function buildImageSizes(container: ContainerSizes, spec: SizeSpec): string { - const { percent, floorPx } = spec - // In a 'full' segment the container tracks the viewport; floor and percent cross here. - const floorCrossover = percent > 0 ? Math.round((floorPx * 100) / percent) : 0 - - const boundaries = new Set(container.segments.map((s) => s.minViewport)) - if (floorCrossover > 0) boundaries.add(floorCrossover) - const sorted = [...boundaries].sort((a, b) => a - b) - - const widthAt = (vw: number): number | 'full' => { - let active: ContainerSizes['segments'][number] | undefined - for (const s of container.segments) if (s.minViewport <= vw) active = s - return active ? active.width : 'full' - } - - const renderedAt = (vw: number): string => { - const w = widthAt(vw) - if (w === 'full') return vw < floorCrossover ? `${floorPx}px` : `${percent}vw` - return `${Math.min(w, Math.max(floorPx, Math.round((percent / 100) * w)))}px` - } - - const intervals = sorted.map((start, i) => ({ - upper: i + 1 < sorted.length ? sorted[i + 1] : null, - value: renderedAt(start), - })) - return emitSizes(mergeIntervals(intervals)) +export function buildImageSizes( + { segments }: ContainerSizes, + { percent, floorPx }: SizeSpec, +): string { + return segments + .map((s, i) => { + const value = + s.width === 'full' + ? floorPx + ? `max(${floorPx}px, ${percent}vw)` + : `${percent}vw` + : `${Math.min(s.width, Math.max(floorPx, Math.round((percent / 100) * s.width)))}px` + const next = segments[i + 1] + return next ? `(max-width: ${next.minViewport}px) ${value}` : value + }) + .join(', ') } From f7a2f9d822a1e3d05665f4f170cc894e0749121e Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Mon, 15 Jun 2026 19:35:14 -0700 Subject: [PATCH 6/8] Undo overly complicated image size calculations --- src/blocks/Content/Component.tsx | 6 +- src/blocks/InlineMedia/Component.tsx | 17 ++--- src/blocks/Media/Component.tsx | 20 +----- src/components/RichText/index.tsx | 90 ++++++++++-------------- src/utilities/mediaSizes.ts | 100 --------------------------- 5 files changed, 45 insertions(+), 188 deletions(-) delete mode 100644 src/utilities/mediaSizes.ts diff --git a/src/blocks/Content/Component.tsx b/src/blocks/Content/Component.tsx index 67fafe82..a50bfb99 100644 --- a/src/blocks/Content/Component.tsx +++ b/src/blocks/Content/Component.tsx @@ -1,7 +1,6 @@ import RichText from '@/components/RichText' import type { ContentBlock as ContentBlockProps } from '@/payload-types' import getTextColorFromBgColor from '@/utilities/getTextColorFromBgColor' -import { buildColumnSizes } from '@/utilities/mediaSizes' import { cn } from '@/utilities/ui' export const ContentBlockComponent = (props: ContentBlockProps) => { @@ -47,12 +46,9 @@ export const ContentBlockComponent = (props: ContentBlockProps) => {
{columns?.map((col, index) => { const { richText } = col - const columnSizes = buildColumnSizes(colsSpanClass[index] ?? '') return (
- {richText && ( - - )} + {richText && }
) })} diff --git a/src/blocks/InlineMedia/Component.tsx b/src/blocks/InlineMedia/Component.tsx index a2207640..0897c690 100644 --- a/src/blocks/InlineMedia/Component.tsx +++ b/src/blocks/InlineMedia/Component.tsx @@ -1,13 +1,9 @@ import type { InlineMediaBlock } from '@/payload-types' import { Media } from '@/components/Media' -import { buildImageSizes, type ContainerSizes, FULL_WIDTH_CONTAINER } from '@/utilities/mediaSizes' import { cn } from '@/utilities/ui' -type Props = Omit & { - // Column context from a multi-column layout, so `sizes` matches the real container width. - containerSizes?: ContainerSizes -} +type Props = Omit const widthClasses = { '25': 'w-1/4', @@ -36,7 +32,6 @@ export const InlineMediaComponent = ({ size = 'original', fixedHeight, caption, - containerSizes, }: Props) => { if (!media || typeof media === 'number' || typeof media === 'string') { return null @@ -47,7 +42,9 @@ export const InlineMediaComponent = ({ let sizeClass = '' let imgSizeClass = 'w-auto h-auto' - let sizes = '100vw' + // Images are lazy-loaded, so `sizes="auto"` lets the browser pick the resolution + // from the actual rendered width set by the width/height classes below. + const sizes = 'auto' const isFixedHeight = resolvedSize === 'fixed-height' && fixedHeight if (resolvedSize === 'original') { @@ -55,14 +52,8 @@ export const InlineMediaComponent = ({ } else if (isWidthSize(resolvedSize)) { sizeClass = widthClasses[resolvedSize] imgSizeClass = 'w-full h-auto' - // Width is `resolvedSize`% of the container, so match `sizes` to that fraction. - sizes = buildImageSizes(containerSizes ?? FULL_WIDTH_CONTAINER, { - percent: Number(resolvedSize), - floorPx: 0, - }) } else if (isFixedHeight) { imgSizeClass = 'h-full w-auto' - sizes = '96px' } const positionClasses = isFloat diff --git a/src/blocks/Media/Component.tsx b/src/blocks/Media/Component.tsx index f8f3b99e..3846f394 100644 --- a/src/blocks/Media/Component.tsx +++ b/src/blocks/Media/Component.tsx @@ -7,12 +7,6 @@ import type { MediaBlock as MediaBlockProps } from '@/payload-types' import { Media } from '@/components/Media' import getTextColorFromBgColor from '@/utilities/getTextColorFromBgColor' -import { - buildImageSizes, - type ContainerSizes, - FULL_WIDTH_CONTAINER, - IMAGE_SIZE_SPECS, -} from '@/utilities/mediaSizes' type Props = MediaBlockProps & { isLayoutBlock: boolean @@ -20,8 +14,6 @@ type Props = MediaBlockProps & { className?: string imgClassName?: string staticImage?: StaticImageData - // Column context from a multi-column layout, so `sizes` matches the real container width. - containerSizes?: ContainerSizes } export const MediaBlockComponent = (props: Props) => { @@ -36,13 +28,13 @@ export const MediaBlockComponent = (props: Props) => { alignContent = 'left', backgroundColor, imageSize = 'original', - containerSizes, } = props const bgColorClass = `bg-${backgroundColor}` const textColor = getTextColorFromBgColor(backgroundColor) - // `cqw` sizes the image relative to the block's `@container`. Keep in sync with IMAGE_SIZE_SPECS. + // `cqw` sizes the image relative to the block's `@container`; the browser reads the + // rendered width via `sizes="auto"` (images are lazy-loaded) to pick the resolution. const getImageSizeClasses = () => { switch (imageSize) { case 'small': @@ -59,12 +51,6 @@ export const MediaBlockComponent = (props: Props) => { } } - const getSizesForImageSize = () => { - const spec = IMAGE_SIZE_SPECS[imageSize ?? 'original'] - if (!spec) return '100vw' - return buildImageSizes(containerSizes ?? FULL_WIDTH_CONTAINER, spec) - } - return (
{ )} resource={media} src={staticImage} - sizes={getSizesForImageSize()} + sizes="auto" /> {caption && (
diff --git a/src/components/RichText/index.tsx b/src/components/RichText/index.tsx index 736d63a0..3f8eae50 100644 --- a/src/components/RichText/index.tsx +++ b/src/components/RichText/index.tsx @@ -46,7 +46,6 @@ import type { SponsorsBlock as SponsorsBlockProps, } from '@/payload-types' import { handleReferenceURL } from '@/utilities/handleReferenceURL' -import type { ContainerSizes } from '@/utilities/mediaSizes' import { cn } from '@/utilities/ui' type LinkDocRelationTo = (typeof LINK_ENABLED_COLLECTIONS)[number] @@ -114,67 +113,52 @@ const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => { return url || '/' } -// Built as a factory so the Media converters can close over `containerSizes`, -// which a multi-column Content block passes down for container-accurate `sizes`. -const makeJsxConverters = - (containerSizes?: ContainerSizes): JSXConvertersFunction => - ({ defaultConverters }) => ({ - ...defaultConverters, - ...LinkJSXConverter({ internalDocToHref }), - // if block has two variants - to make TS happy we fallback to the default for the block variant - blocks: { - blogList: ({ node }) => , - buttonBlock: ({ node }) => , - calloutBlock: ({ node }) => , - documentBlock: ({ node }) => ( - - ), - eventList: ({ node }) => , - eventTable: ({ node }) => , - singleEvent: ({ node }) => ( - - ), - genericEmbed: ({ node }) => ( - - ), - headerBlock: ({ node }) => , - imageText: ({ node }) => , - mediaBlock: ({ node }) => ( - - ), - singleBlogPost: ({ node }) => ( - - ), - sponsorsBlock: ({ node }) => ( - - ), - }, - inlineBlocks: { - inlineMedia: ({ node }) => ( - - ), - }, - }) +const jsxConverters: JSXConvertersFunction = ({ defaultConverters }) => ({ + ...defaultConverters, + ...LinkJSXConverter({ internalDocToHref }), + // if block has two variants - to make TS happy we fallback to the default for the block variant + blocks: { + blogList: ({ node }) => , + buttonBlock: ({ node }) => , + calloutBlock: ({ node }) => , + documentBlock: ({ node }) => , + eventList: ({ node }) => , + eventTable: ({ node }) => , + singleEvent: ({ node }) => , + genericEmbed: ({ node }) => ( + + ), + headerBlock: ({ node }) => , + imageText: ({ node }) => , + mediaBlock: ({ node }) => ( + + ), + singleBlogPost: ({ node }) => ( + + ), + sponsorsBlock: ({ node }) => , + }, + inlineBlocks: { + inlineMedia: ({ node }) => , + }, +}) type Props = { data: SerializedEditorState enableGutter?: boolean - // Column context from a multi-column layout, forwarded to embedded Media blocks. - containerSizes?: ContainerSizes } & React.HTMLAttributes export default function RichText(props: Props) { - const { className, enableGutter = true, containerSizes, ...rest } = props + const { className, enableGutter = true, ...rest } = props return ( = { - small: { percent: 50, floorPx: 256 }, // max-w-[max(16rem,50cqw)] - medium: { percent: 75, floorPx: 320 }, // max-w-[max(20rem,75cqw)] - large: { percent: 90, floorPx: 384 }, // max-w-[max(24rem,90cqw)] - full: { percent: 100, floorPx: 0 }, // max-w-full - original: null, // natural size -} - -// Fallback when there's no column context (single-column / full-width). -export const FULL_WIDTH_CONTAINER: ContainerSizes = { - segments: [{ minViewport: 0, width: 'full' }], -} - -const GRID_BREAKPOINTS: { key: string; vw: number; inner: number | 'full'; total: number }[] = [ - { key: 'base', vw: 0, inner: 'full', total: 6 }, - { key: 'sm', vw: breakpoints.sm, inner: containerInnerWidth.sm, total: 6 }, - { key: 'md', vw: breakpoints.md, inner: containerInnerWidth.md, total: 6 }, - { key: 'lg', vw: breakpoints.lg, inner: containerInnerWidth.lg, total: 12 }, - { key: 'xl', vw: breakpoints.xl, inner: containerInnerWidth.xl, total: 12 }, - { key: '2xl', vw: breakpoints['2xl'], inner: containerInnerWidth['2xl'], total: 12 }, -] - -function columnWidth(innerWidth: number, span: number, total: number): number { - const track = (innerWidth - (total - 1) * GRID_GAP_PX) / total - return Math.round(track * span + (span - 1) * GRID_GAP_PX) -} - -function parseSpans(spanClass: string): Record { - const spans: Record = { base: 6 } - // optional `bp:` prefix (sm/md/lg/xl/2xl) followed by `col-span-N` - const re = /(?:(sm|md|lg|xl|2xl):)?col-span-(\d+)/g - let match: RegExpExecArray | null - while ((match = re.exec(spanClass))) { - spans[match[1] ?? 'base'] = Number(match[2]) - } - return spans -} - -// Resolves a Content column's responsive `col-span-*` classes to its width per viewport. -export function buildColumnSizes(spanClass: string): ContainerSizes { - const spans = parseSpans(spanClass) - const segments: ContainerSizes['segments'] = [] - let currentSpan = spans.base - for (const bp of GRID_BREAKPOINTS) { - if (spans[bp.key] != null) currentSpan = spans[bp.key] - const width: number | 'full' = - bp.inner === 'full' || currentSpan >= bp.total - ? 'full' - : columnWidth(bp.inner, currentSpan, bp.total) - const last = segments[segments.length - 1] - if (!last || last.width !== width) segments.push({ minViewport: bp.vw, width }) - } - return { segments } -} - -// Builds a `sizes` attribute mirroring the cqw CSS: min(container, max(floorPx, percent%)). -export function buildImageSizes( - { segments }: ContainerSizes, - { percent, floorPx }: SizeSpec, -): string { - return segments - .map((s, i) => { - const value = - s.width === 'full' - ? floorPx - ? `max(${floorPx}px, ${percent}vw)` - : `${percent}vw` - : `${Math.min(s.width, Math.max(floorPx, Math.round((percent / 100) * s.width)))}px` - const next = segments[i + 1] - return next ? `(max-width: ${next.minViewport}px) ${value}` : value - }) - .join(', ') -} From bea6ec5b37aac05883a3148a39f621bb3ee2f89c Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Mon, 15 Jun 2026 20:12:33 -0700 Subject: [PATCH 7/8] feat: add Extra small MediaBlock size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `xsmall` image size (33cqw, 12rem floor) below `small`, addressing the "additional, smaller size" request in #757. No migration needed — the select is stored as plain text, validated in the app layer. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/blocks/Media/Component.tsx | 2 ++ src/endpoints/seed/blocks/media-blocks.ts | 38 +++++++++++++++++++++++ src/fields/imageSize.ts | 1 + src/payload-types.ts | 2 +- 4 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/blocks/Media/Component.tsx b/src/blocks/Media/Component.tsx index 3846f394..87335664 100644 --- a/src/blocks/Media/Component.tsx +++ b/src/blocks/Media/Component.tsx @@ -37,6 +37,8 @@ export const MediaBlockComponent = (props: Props) => { // rendered width via `sizes="auto"` (images are lazy-loaded) to pick the resolution. const getImageSizeClasses = () => { switch (imageSize) { + case 'xsmall': + return 'max-w-[max(12rem,33cqw)]' case 'small': return 'max-w-[max(16rem,50cqw)]' case 'medium': diff --git a/src/endpoints/seed/blocks/media-blocks.ts b/src/endpoints/seed/blocks/media-blocks.ts index 9e97e595..0d0131c9 100644 --- a/src/endpoints/seed/blocks/media-blocks.ts +++ b/src/endpoints/seed/blocks/media-blocks.ts @@ -2,6 +2,44 @@ import type { Media } from '@/payload-types' import { RequiredDataFromCollectionSlug } from 'payload' export const mediaBlocks = (image: Media): RequiredDataFromCollectionSlug<'pages'>['layout'] => [ + { + media: image.id, + caption: { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'An extra-small media block with left alignment', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: 'start', + indent: 0, + type: 'paragraph', + version: 1, + textFormat: 0, + textStyle: '', + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }, + backgroundColor: 'brand-100', + alignContent: 'left', + imageSize: 'xsmall', + blockType: 'mediaBlock', + }, { media: image.id, caption: { diff --git a/src/fields/imageSize.ts b/src/fields/imageSize.ts index ea75dd4e..f12d8c15 100644 --- a/src/fields/imageSize.ts +++ b/src/fields/imageSize.ts @@ -8,6 +8,7 @@ export const imageSizeField: (label: string) => Field = (label) => ({ defaultValue: 'original', options: [ { label: 'Original (Natural size)', value: 'original' }, + { label: 'Extra small', value: 'xsmall' }, { label: 'Small', value: 'small' }, { label: 'Medium', value: 'medium' }, { label: 'Large', value: 'large' }, diff --git a/src/payload-types.ts b/src/payload-types.ts index f2d0a2ea..ce769d49 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1427,7 +1427,7 @@ export interface MediaBlock { /** * Controls the maximum width of the image with responsive behavior. Original uses the image's natural size. Sizes automatically adapt for different screen sizes. */ - imageSize?: ('original' | 'small' | 'medium' | 'large' | 'full') | null; + imageSize?: ('original' | 'xsmall' | 'small' | 'medium' | 'large' | 'full') | null; id?: string | null; blockName?: string | null; blockType: 'mediaBlock'; From 3727b680a96b5fa387e27914da5dd06dfdebbec8 Mon Sep 17 00:00:00 2001 From: rchlfryn Date: Tue, 16 Jun 2026 09:35:50 -0700 Subject: [PATCH 8/8] Add tests --- .../client/blocks/InlineMedia.client.test.tsx | 52 ++++++++++++++ .../client/blocks/MediaBlock.client.test.tsx | 67 +++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 __tests__/client/blocks/InlineMedia.client.test.tsx create mode 100644 __tests__/client/blocks/MediaBlock.client.test.tsx diff --git a/__tests__/client/blocks/InlineMedia.client.test.tsx b/__tests__/client/blocks/InlineMedia.client.test.tsx new file mode 100644 index 00000000..3b14017a --- /dev/null +++ b/__tests__/client/blocks/InlineMedia.client.test.tsx @@ -0,0 +1,52 @@ +import { InlineMediaComponent } from '@/blocks/InlineMedia/Component' +import type { Media } from '@/payload-types' +import '@testing-library/jest-dom' +import { render } from '@testing-library/react' + +// Stand in for the next/image-backed Media component and record the props it receives. +const mockMedia = jest.fn((_props: { sizes?: string }) => null) + +jest.mock('../../../src/components/Media', () => ({ + Media: (props: { sizes?: string }) => mockMedia(props), +})) + +// InlineMedia only renders when `media` is a resolved object. +const media: Media = { id: 1, tenant: 1, alt: 'test', updatedAt: '', createdAt: '' } + +beforeEach(() => mockMedia.mockClear()) + +describe('InlineMediaComponent', () => { + it.each([ + ['25', 'w-1/4'], + ['50', 'w-1/2'], + ['75', 'w-3/4'], + ['100', 'w-full'], + ] as const)('sizes the %s%% block to a container fraction (%s)', (size, expectedClass) => { + const { container } = render() + expect(container.firstElementChild).toHaveClass(expectedClass) + }) + + it('renders original at natural size', () => { + const { container } = render() + expect(container.firstElementChild).toHaveClass('max-w-fit') + }) + + it.each(['25', '100', 'original'] as const)( + 'requests resolution from the rendered width via sizes="auto" (%s)', + (size) => { + render() + expect(mockMedia).toHaveBeenCalledWith(expect.objectContaining({ sizes: 'auto' })) + }, + ) + + it('renders a fixed-height image with sizes="auto"', () => { + render() + expect(mockMedia).toHaveBeenCalledWith(expect.objectContaining({ sizes: 'auto' })) + }) + + it('renders nothing when the media relationship is unresolved', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + expect(mockMedia).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/client/blocks/MediaBlock.client.test.tsx b/__tests__/client/blocks/MediaBlock.client.test.tsx new file mode 100644 index 00000000..f1d083ae --- /dev/null +++ b/__tests__/client/blocks/MediaBlock.client.test.tsx @@ -0,0 +1,67 @@ +import { MediaBlockComponent } from '@/blocks/Media/Component' +import '@testing-library/jest-dom' +import { render } from '@testing-library/react' +import { ComponentProps } from 'react' + +// Stand in for the next/image-backed Media component and record the props it receives. +const mockMedia = jest.fn((_props: { sizes?: string }) => null) + +jest.mock('../../../src/components/Media', () => ({ + Media: (props: { sizes?: string }) => mockMedia(props), +})) + +jest.mock('../../../src/components/RichText', () => ({ + __esModule: true, + default: () => null, +})) + +type Props = ComponentProps + +beforeEach(() => mockMedia.mockClear()) + +function renderBlock(imageSize?: Props['imageSize']) { + const { container } = render( + , + ) + // The size class lives on the div that wraps the image (the only `.gap-2` element). + return container.querySelector('.gap-2') +} + +describe('MediaBlockComponent', () => { + it.each([ + ['xsmall', 'max-w-[max(12rem,33cqw)]'], + ['small', 'max-w-[max(16rem,50cqw)]'], + ['medium', 'max-w-[max(20rem,75cqw)]'], + ['large', 'max-w-[max(24rem,90cqw)]'], + ['full', 'max-w-full'], + ] as const)('sizes the %s block relative to its container (%s)', (imageSize, expectedClass) => { + const sizeWrapper = renderBlock(imageSize) + expect(sizeWrapper).toHaveClass(expectedClass) + // non-original sizes fill the container up to their cap + expect(sizeWrapper).toHaveClass('w-full') + }) + + it('renders original at natural size without filling the container', () => { + const sizeWrapper = renderBlock('original') + expect(sizeWrapper).toHaveClass('max-w-fit') + expect(sizeWrapper).not.toHaveClass('w-full') + }) + + it('defaults to original when no size is set', () => { + expect(renderBlock(undefined)).toHaveClass('max-w-fit') + }) + + it.each(['xsmall', 'small', 'medium', 'large', 'full', 'original'] as const)( + 'requests resolution from the rendered width via sizes="auto" (%s)', + (imageSize) => { + renderBlock(imageSize) + expect(mockMedia).toHaveBeenCalledWith(expect.objectContaining({ sizes: 'auto' })) + }, + ) +})