diff --git a/.changeset/gallery-cell-sizes.md b/.changeset/gallery-cell-sizes.md
new file mode 100644
index 0000000000..16abd63204
--- /dev/null
+++ b/.changeset/gallery-cell-sizes.md
@@ -0,0 +1,5 @@
+---
+"emdash": patch
+---
+
+Fixes the Portable Text `Gallery` component telling browsers each image fills the viewport. Its `sizes` attribute now describes one grid cell, using the gallery's column count and its two-column layout at 640px and below, so browsers stop downloading oversized images for multi-column galleries. Pass the new `sizes` prop to `Gallery` when it renders in a container narrower than the viewport.
diff --git a/packages/core/src/components/Gallery.astro b/packages/core/src/components/Gallery.astro
index fa2840ea26..c7d5a8541c 100644
--- a/packages/core/src/components/Gallery.astro
+++ b/packages/core/src/components/Gallery.astro
@@ -11,7 +11,12 @@ import { Image as AstroImage } from "astro:assets";
import type { ImageEmbed } from "../media/types.js";
import { getMediaProvider } from "../media/provider-loader.js";
import { buildRenderMediaUrl } from "../media/url.js";
-import { toAbsoluteMediaUrl, RESPONSIVE_BREAKPOINTS } from "../media/responsive.js";
+import {
+ toAbsoluteMediaUrl,
+ gallerySizes,
+ GALLERY_DEFAULT_COLUMNS,
+ RESPONSIVE_BREAKPOINTS,
+} from "../media/responsive.js";
import { getPublicOrigin } from "../api/public-url.js";
import { focalPointToObjectPosition } from "../media/focal-point.js";
@@ -43,6 +48,12 @@ export interface Props {
};
/** Render the LQIP placeholder as an inline background. Disable for strict CSP. */
placeholder?: boolean;
+ /**
+ * `sizes` attribute for every image in the gallery. Defaults to an estimate
+ * from the column count against the viewport; set it when the gallery renders
+ * in a narrower container, e.g. `"(max-width: 640px) 45vw, 220px"`.
+ */
+ sizes?: string;
}
/**
@@ -61,9 +72,11 @@ function generateSrcset(
.join(", ");
}
-const { node, placeholder = true } = Astro.props;
+const { node, placeholder = true, sizes: sizesOverride } = Astro.props;
const images = node?.images ?? [];
-const columns = node?.columns ?? 3;
+const columns = node?.columns ?? GALLERY_DEFAULT_COLUMNS;
+// Each image fills one grid cell, not the viewport.
+const cellSizes = sizesOverride ?? gallerySizes(columns);
if (!images.length) {
return null;
@@ -102,7 +115,7 @@ const resolvedImages = await Promise.all(
if (embed.getSrc) {
const maxWidth = width || 1200;
srcset = generateSrcset(embed.getSrc, maxWidth, aspectRatio);
- sizes = width ? `(min-width: ${width}px) ${width}px, 100vw` : "100vw";
+ sizes = cellSizes;
}
}
} catch (error) {
@@ -166,6 +179,7 @@ const resolvedImages = await Promise.all(
width={image.width!}
height={image.height!}
layout="constrained"
+ sizes={cellSizes}
style={image.objectPosition ? `object-position: ${image.objectPosition};` : undefined}
/>
) : (
diff --git a/packages/core/src/media/responsive.ts b/packages/core/src/media/responsive.ts
index fe126a0dd3..de13119f67 100644
--- a/packages/core/src/media/responsive.ts
+++ b/packages/core/src/media/responsive.ts
@@ -32,6 +32,29 @@ export function responsiveSizes(width: number | undefined): string {
return width ? `(min-width: ${width}px) ${width}px, 100vw` : "100vw";
}
+/** Column count `Gallery.astro` uses when a gallery block does not set one. */
+export const GALLERY_DEFAULT_COLUMNS = 3;
+
+/**
+ * Build the `sizes` attribute for one cell of a Portable Text gallery.
+ *
+ * Mirrors the gallery's CSS: `columns` equal cells with a `1rem` gap, and two
+ * columns at `640px` and below. Each cell is estimated against the viewport, so
+ * a gallery inside a narrower content column still gets a larger slot than it
+ * renders at; pass `sizes` to `Gallery` when the container width is known.
+ */
+export function gallerySizes(columns: number | undefined): string {
+ const count =
+ typeof columns === "number" && Number.isInteger(columns) && columns > 0
+ ? columns
+ : GALLERY_DEFAULT_COLUMNS;
+ return `(max-width: 640px) ${galleryCell(2)}, ${galleryCell(count)}`;
+}
+
+function galleryCell(columns: number): string {
+ return columns === 1 ? "100vw" : `calc((100vw - ${columns - 1}rem) / ${columns})`;
+}
+
/**
* Make a same-origin media URL absolute so Astro's image service can optimize it.
*
diff --git a/packages/core/tests/repro/gallery-sizes.render.test.ts b/packages/core/tests/repro/gallery-sizes.render.test.ts
new file mode 100644
index 0000000000..9dbd968ed1
--- /dev/null
+++ b/packages/core/tests/repro/gallery-sizes.render.test.ts
@@ -0,0 +1,122 @@
+/**
+ * The gallery's `sizes` must describe a grid cell, not the viewport: each
+ * image renders in one of `columns` cells (two at 640px and below), and a
+ * `100vw` estimate lets the browser pick a far larger srcset candidate than
+ * the cell needs (#2930).
+ */
+import { experimental_AstroContainer as AstroContainer } from "astro/container";
+import { describe, expect, test } from "vitest";
+
+import Gallery from "../../src/components/Gallery.astro";
+
+const testMediaProviders = [
+ {
+ id: "mock-gallery-images",
+ name: "Mock Gallery Images",
+ capabilities: { list: false, upload: false, delete: false, metadata: false },
+ createProvider: () => ({
+ id: "mock-gallery-images",
+ name: "Mock Gallery Images",
+ capabilities: { list: false, upload: false, delete: false, metadata: false },
+ getEmbed: (_value: unknown, options: { width?: number; height?: number } = {}) => ({
+ type: "image",
+ src: `https://img.example.com/original?w=${options.width ?? "auto"}`,
+ getSrc: ({ width, height }: { width?: number; height?: number } = {}) =>
+ `https://img.example.com/render?w=${width ?? "auto"}&h=${height ?? "auto"}`,
+ }),
+ }),
+ },
+];
+
+const providerGlobal = globalThis as typeof globalThis & {
+ __emdashTestMediaProviders?: typeof testMediaProviders;
+};
+providerGlobal.__emdashTestMediaProviders = [
+ ...(providerGlobal.__emdashTestMediaProviders ?? []),
+ ...testMediaProviders,
+];
+
+const locals = {
+ emdash: { getPublicMediaUrl: (k: string) => `/_emdash/api/media/file/${k}` },
+};
+
+const imgTags = (html: string) => html.match(/
]*>/g) ?? [];
+const attr = (tag: string, name: string) =>
+ tag.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1]?.replaceAll("&", "&");
+
+function providerImage(key: string) {
+ return {
+ _type: "image" as const,
+ _key: key,
+ asset: { _ref: `provider-${key}`, provider: "mock-gallery-images" },
+ alt: key,
+ width: 1600,
+ height: 1200,
+ };
+}
+
+async function renderGallery(props: Record) {
+ const container = await AstroContainer.create();
+ return container.renderToString(Gallery, { props, locals });
+}
+
+describe("Gallery sizes", () => {
+ test("provider images are sized to one of three default columns", async () => {
+ const html = await renderGallery({
+ node: { _type: "gallery", _key: "g", images: [providerImage("a"), providerImage("b")] },
+ });
+ const tags = imgTags(html);
+
+ expect(tags).toHaveLength(2);
+ for (const tag of tags) {
+ expect(attr(tag, "srcset")).toContain("https://img.example.com/render?w=640");
+ expect(attr(tag, "sizes")).toBe(
+ "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 2rem) / 3)",
+ );
+ }
+ });
+
+ test("the slot follows the block's column count", async () => {
+ const html = await renderGallery({
+ node: { _type: "gallery", _key: "g", columns: 4, images: [providerImage("a")] },
+ });
+
+ expect(attr(imgTags(html)[0]!, "sizes")).toBe(
+ "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 3rem) / 4)",
+ );
+ });
+
+ test("locally stored images with dimensions get the same cell estimate", async () => {
+ const html = await renderGallery({
+ node: {
+ _type: "gallery",
+ _key: "g",
+ images: [
+ {
+ _type: "image",
+ _key: "local",
+ asset: { _ref: "media-1", url: "/_emdash/api/media/file/local.jpg" },
+ alt: "local",
+ width: 1600,
+ height: 1200,
+ },
+ ],
+ },
+ });
+ const tag = imgTags(html)[0]!;
+
+ expect(attr(tag, "data-astro-image")).toBe("constrained");
+ expect(attr(tag, "sizes")).toBe(
+ "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 2rem) / 3)",
+ );
+ });
+
+ test("a consumer-provided sizes wins", async () => {
+ const html = await renderGallery({
+ node: { _type: "gallery", _key: "g", images: [providerImage("a")] },
+ sizes: "(max-width: 640px) 45vw, 220px",
+ });
+
+ expect(attr(imgTags(html)[0]!, "sizes")).toBe("(max-width: 640px) 45vw, 220px");
+ });
+});
diff --git a/packages/core/tests/unit/media/responsive.test.ts b/packages/core/tests/unit/media/responsive.test.ts
index 35379e6096..ff7b87b5a7 100644
--- a/packages/core/tests/unit/media/responsive.test.ts
+++ b/packages/core/tests/unit/media/responsive.test.ts
@@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest";
import {
RESPONSIVE_BREAKPOINTS,
buildResponsiveImage,
+ gallerySizes,
responsiveSizes,
responsiveWidths,
toAbsoluteMediaUrl,
@@ -42,6 +43,29 @@ describe("responsiveSizes", () => {
});
});
+describe("gallerySizes", () => {
+ it("estimates one cell of the grid, with two columns on narrow viewports", () => {
+ expect(gallerySizes(3)).toBe(
+ "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 2rem) / 3)",
+ );
+ expect(gallerySizes(5)).toBe(
+ "(max-width: 640px) calc((100vw - 1rem) / 2), calc((100vw - 4rem) / 5)",
+ );
+ });
+
+ it("uses the full viewport for a single column above the breakpoint", () => {
+ expect(gallerySizes(1)).toBe("(max-width: 640px) calc((100vw - 1rem) / 2), 100vw");
+ });
+
+ it("falls back to three columns for a missing or unusable count", () => {
+ const three = gallerySizes(3);
+ expect(gallerySizes(undefined)).toBe(three);
+ expect(gallerySizes(0)).toBe(three);
+ expect(gallerySizes(2.5)).toBe(three);
+ expect(gallerySizes(Number.NaN)).toBe(three);
+ });
+});
+
describe("buildResponsiveImage", () => {
const ABS = "https://cdn.example.com/a.jpg";