From d5c45de733720404f3e62aaa99dfee69a8112d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A5rd=20Farstad?= Date: Tue, 15 Sep 2026 16:21:01 +0200 Subject: [PATCH] feat(skills): add responsive-images skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the skills covered rendering images. Two passing sentences said Crystallize serves "responsive sizes" in WebP/AVIF; neither said how to use them. Building a srcset is close to the first thing a frontend developer does with Crystallize media, and no skill said how. New skill `responsive-images`, plus references/without-react.md for non-React frontends. Three findings drive the guidance, all measured rather than assumed: WebP beats AVIF here, nearly always. Across 347 same-width comparisons on two unrelated production tenants, WebP produced the smaller file 343 times, averaging 25-30% smaller; on one tenant AVIF averaged larger than the original JPEG. AVIF wins at equal visual quality in general, but payload is decided by file size at the quality the encoder actually targeted. So: ship one modern format, not both — two elements only guarantee the browser takes the first it supports, which is the larger file. The width ladder is per-image. Widths are drawn from 100/200/500/768/1024/ 1366/1600/1920/3200, but capped at the source image's own width: a 1200px original yields five sizes, a 500px original three. Eight distinct width sets appeared across 80 images. Hardcoding the ladder — or using the component's _availableSizes, which synthesizes URLs from the @{width} scheme — emits URLs that 404. Querying `size` is load-bearing. The reactjs-components Image compares the first AVIF and WebP variants to decide whether AVIF is worth emitting. Omit `size` from the query and the comparison cannot run, so it ships both formats — the more expensive outcome, silently. Also documented: why not to route these URLs through next/image or another framework optimizer (the variants are already static CDN files, so re-optimizing yields no smaller file while billing for the transform and the egress); that `sizes` has no default and omitting it makes the browser assume 100vw; the component's
/
wrapper; LCP and CLS handling; and focalPoint for cropping. Verified against @crystallize/reactjs-components v5.0.0 source and live Catalogue/Discovery responses. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- .../content-model/references/components.md | 2 +- .../skills/responsive-images/SKILL.md | 226 ++++++++++++++++++ .../references/without-react.md | 137 +++++++++++ 4 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 use-crystallize/skills/responsive-images/SKILL.md create mode 100644 use-crystallize/skills/responsive-images/references/without-react.md diff --git a/README.md b/README.md index 7912df7..fc160fc 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ bun type-check # TypeScript type checking Skills are plain markdown files in `use-crystallize/skills/` — no build step required. Each skill has a `SKILL.md` with YAML frontmatter and an optional `references/` directory with supporting docs. -Available skills: `content-model`, `data-creation`, `information-architecture`, `js-api-client`, `mass-operations`, `mutation`, `permissions`, `plugins`, `pricing`, `query`, `taxonomy`, `vector-ranking` — the directory itself is the authoritative list. +Available skills: `content-model`, `data-creation`, `information-architecture`, `js-api-client`, `mass-operations`, `mutation`, `permissions`, `plugins`, `pricing`, `query`, `responsive-images`, `taxonomy`, `vector-ranking` — the directory itself is the authoritative list. ## Using the Claude Plugin diff --git a/use-crystallize/skills/content-model/references/components.md b/use-crystallize/skills/content-model/references/components.md index 1c09efd..9784f10 100644 --- a/use-crystallize/skills/content-model/references/components.md +++ b/use-crystallize/skills/content-model/references/components.md @@ -42,7 +42,7 @@ Choosing the right component for each piece of **additional** data determines ho | Product demos, tutorials, promo videos | **Videos** | | Downloadable files (PDFs, ZIPs, etc.) | **Files** | -- **Images** → auto-transcoded to responsive sizes (Avif, WebP), served via Crystallize CDN. Supports multiple images per component (galleries, carousels). Each image gets `url`, `variants` (with width/height/size), `altText`, `caption`. Note: product variant images are built-in — use an Images component for product-level lifestyle photos, document hero images, or other non-variant media. +- **Images** → auto-transcoded to responsive sizes (Avif, WebP), served via Crystallize CDN. See [[responsive-images]] for rendering them efficiently — `srcset`, `sizes`, format choice and why not to use a framework image optimizer. Supports multiple images per component (galleries, carousels). Each image gets `url`, `variants` (with width/height/size), `altText`, `caption`. Note: product variant images are built-in — use an Images component for product-level lifestyle photos, document hero images, or other non-variant media. - **Videos** → auto-transcoded for web/mobile streaming, CDN-delivered. Upload or embed. - **Files** → upload files for download. Use for manuals, datasheets, certificates, whitepapers. diff --git a/use-crystallize/skills/responsive-images/SKILL.md b/use-crystallize/skills/responsive-images/SKILL.md new file mode 100644 index 0000000..6c3416e --- /dev/null +++ b/use-crystallize/skills/responsive-images/SKILL.md @@ -0,0 +1,226 @@ +--- +name: responsive-images +description: > + Render Crystallize images efficiently on the web — srcset, sizes, picture, format choice, lazy + loading, LCP and CLS. Use when building product galleries, hero images, thumbnails, grids or any + page that displays Crystallize media; when images are too heavy, the page scores badly on + Lighthouse or Core Web Vitals, or the wrong image size is being downloaded; when choosing between + AVIF, WebP and the original format; when deciding whether to use next/image or another framework + image component with Crystallize; or when querying image variants from the Catalogue or Discovery + API. Trigger on "srcset", "sizes", "responsive images", "picture element", "image optimization", + "image variants", "media.crystallize.com", "webp", "avif", "lazy loading", "fetchpriority", + "layout shift", "CLS", "LCP", "next/image", "image component", "@crystallize/reactjs-components", + "thumbnail", "image payload", "images too big". +metadata: + author: Crystallize + version: "1.0" +--- + +# Responsive Images with Crystallize + +Crystallize pre-generates every image at a ladder of widths in three formats and serves them from its +own CDN at `media.crystallize.com`. **The optimization has already happened.** Your job on the frontend +is to pick the smallest file that still looks right — not to re-encode anything. + +Almost all wasted image bandwidth on a Crystallize storefront comes from two mistakes, both covered +below: shipping AVIF because it is newer, and omitting `sizes`. + +> Measured on 2026-09-15 against two unrelated production tenants (347 format comparisons, 80 images), +> and read from `@crystallize/reactjs-components` v5.0.0. The width ladder and the AVIF/WebP result are +> observations about today's transcoder output, not contractual guarantees — re-measure before betting +> something load-bearing on them. + +## Rule 0: do not put another image optimizer in front of the CDN + +**Do not serve Crystallize images through `next/image`, or any other framework image pipeline, in its +default configuration.** It is the single most expensive mistake available here. + +The variants already exist as static files on Crystallize's CDN. Routing them through a hosting +platform's image optimizer: + +- produces **no smaller file** — it re-encodes an already-encoded derivative +- adds a proxy hop, and a transform on the first request for every unseen size +- bills you for image-optimization units and platform egress for bytes the Crystallize CDN already + serves + +Use a plain `` / `` pointing straight at `media.crystallize.com`. That is what the +component below emits. + +If a project is already committed to `next/image`, pass `unoptimized`, or give it a custom loader that +returns the `@{width}` URL — but at that point it is doing strictly less than the Crystallize `Image` +component, with more configuration. + +## What the API actually returns + +Each image carries a list of variants. Verified shape: + +``` +https://media.crystallize.com/{tenant}/{path}/@{width}/{filename}.{ext} +``` + +| | | +| ----------- | ------------------------------------------------------------- | +| **Widths** | drawn from `100, 200, 500, 768, 1024, 1366, 1600, 1920, 3200` | +| **Formats** | `avif` and `webp` always, plus the original (`jpeg` or `png`) | +| **Fields** | `url`, `key`, `width`, `height`, `size` | + +**The width ladder is capped at the source image's own width, so it differs per image.** A 1200px +original yields `100/200/500/768/1024`; a 500px original yields only `100/200/500`. In a sample of 80 +images, eight distinct width sets appeared. + +**Never hardcode the ladder.** Build `srcset` from the variants the API returned for _that_ image, or +you will emit URLs that 404 and a browser that picks them will show nothing. + +## Query the fields you need — including `size` + +```graphql +firstImage { + url + altText + width + height + variants { + url + width + height + size + } +} +``` + +**Always select `size`.** It is not decoration: the React component uses it to decide whether AVIF is +worth emitting (see below). Omit it and the component silently ships both formats, which on measured +data is the more expensive outcome. + +On **Discovery** you can also trim the payload server-side — `variants` takes arguments: + +```graphql +variants(types: "webp", minWidth: 400, maxWidth: 1600) { + url + width + size +} +``` + +Useful for a listing page that will never render a 3200px hero. Catalogue's `variants` takes no +arguments — filter client-side there. + +## Format: measure, do not assume AVIF + +Conventional advice says prefer AVIF. **On Crystallize's current transcoder settings that is usually +wrong.** + +| | | +| --------------------------------------------------------- | ----------- | +| Comparisons (2 tenants, same width, both formats present) | **347** | +| WebP produced the smaller file | **343** | +| AVIF produced the smaller file | **4** | +| Average advantage to WebP | **~25–30%** | + +AVIF generally wins at _equal visual quality_; what matters for payload is file size at the quality +the encoder actually targeted, and here WebP wins nearly every time. On one tenant AVIF averaged +larger than the original JPEG. + +So: **ship one modern format, not both.** Two `` elements do not make the page faster — the +browser takes the first it supports, and if that is the larger AVIF you have paid for the privilege. +Keep the original-format source as the compatibility fallback. + +The component already does this for you, _if_ you queried `size`. + +## Use the component + +`@crystallize/reactjs-components` v5 implements the above. Spread the API image object into it: + +```tsx +import { Image } from "@crystallize/reactjs-components"; + +; +``` + +What it does for you: + +- builds a `srcset` per format from `variants`, with `w` descriptors +- emits `` **only when the first AVIF variant is smaller than the first WebP + variant** — the comparison needs `size`, and falls back to emitting both when it is missing +- keeps a `` for the original format and a plain `src` on the `` as the fallback +- sets `width`/`height` from the largest variant, which is what prevents layout shift +- resolves `alt` as `alt` → `altText` (from the API) → `fallbackAlt` → `""` + +What it does **not** do, and you must: + +- **pass `sizes`** — it has no default, see below +- pass `loading` and `fetchPriority` — they forward to the ``, but nothing is set automatically + +Two surprises worth knowing before you style it: + +- It always renders `
` wrapping ``, **plus a `
` even when empty**. Budget + for that in CSS, or use the render-prop escape hatch. +- `_availableSizes` / `_availableFormats` synthesize URLs from the `@{width}` scheme instead of using + `variants`. Because the ladder is per-image, this can invent URLs that do not exist. Prefer passing + real `variants`. + +For full markup control, pass a function as `children` and receive +`{ srcSet, srcSetWebp, srcSetAvif, useAvif, useWebP, sizes, media, src, alt, width, height, originalFileExtension }`. + +Not using React? See [references/without-react.md](references/without-react.md). + +## `sizes` is the whole ballgame + +This is the most common and most expensive mistake on the list. + +`srcset` w-descriptors tell the browser **how wide each file is**. They say nothing about how big the +image will be on the page. `sizes` is what tells it that — and the browser must choose a file +_before_ it has laid the page out, so it cannot work this out on its own. + +**Omit `sizes` and the browser assumes `100vw`** — full viewport width. A 180px thumbnail on a +1440px-wide laptop then downloads the 1024px file. Every time, on every thumbnail. + +Write `sizes` to mirror the CSS that will actually size the element, gutters and column counts +included: + +```html + +sizes="(max-width: 600px) calc(100vw - 32px), (max-width: 1024px) calc(50vw - 24px), calc(25vw - 32px)" + + +sizes="100vw" + + +sizes="180px" +``` + +Checking it: open DevTools, find the ``, compare `currentSrc` against the element's rendered +width times the device pixel ratio. If `currentSrc` is much wider, `sizes` is lying. + +When `sizes` and the CSS disagree, the CSS wins visually and `sizes` wins on bandwidth — so a wrong +`sizes` costs you bytes with nothing to show for it. + +## LCP and CLS + +- **The hero is the LCP element.** Give it `loading="eager"` and `fetchPriority="high"`, and never + `loading="lazy"` — lazy-loading the LCP image reliably makes the score worse. +- **Everything below the fold gets `loading="lazy"`.** +- **Always render `width` and `height`** so the browser can reserve the box. The component does this + from the largest variant; if you hand-roll, do it yourself. This is the entire fix for image-driven + layout shift. +- **Do not lazy-load images already in the viewport** on first paint — the request starts later than + it needs to. + +## Failure modes + +| Symptom | Cause | Fix | +| ---------------------------------------- | ------------------------------------------------- | ------------------------------------------- | +| Thumbnails download 1024px files | `sizes` missing — browser assumed `100vw` | Write `sizes` to match the rendered width | +| Both AVIF and WebP `` emitted | `size` not selected in the GraphQL query | Add `size` to `variants` | +| Images heavier after "upgrading" to AVIF | AVIF is usually larger here | Ship WebP; keep the original as fallback | +| Some `srcset` URLs 404 | Width ladder hardcoded, or `_availableSizes` used | Build `srcset` from the returned `variants` | +| Page jumps as images load | No `width`/`height` on the `` | Render both; they set the aspect ratio | +| Poor LCP on the product page | Hero is `loading="lazy"` | `eager` + `fetchPriority="high"` | +| Image bill higher than expected | Images routed through a framework optimizer | Serve straight from `media.crystallize.com` | +| `alt` is empty everywhere | `altText` not selected, or not authored | Query `altText`; author it in the PIM | + +## Related skills + +[[query]] covers the Catalogue and Discovery queries these fields come from, and +[[content-model]] covers the Images component itself — including that product variant images are +built-in rather than components. [[mutation]] covers uploading images and setting `altText`. diff --git a/use-crystallize/skills/responsive-images/references/without-react.md b/use-crystallize/skills/responsive-images/references/without-react.md new file mode 100644 index 0000000..051cb38 --- /dev/null +++ b/use-crystallize/skills/responsive-images/references/without-react.md @@ -0,0 +1,137 @@ +# Responsive Crystallize Images Without React + +The `@crystallize/reactjs-components` `Image` component is React-only. Everywhere else — Svelte, Vue, +Astro, Remix loaders emitting plain HTML, email templates, server-rendered templates — build the same +markup by hand. This page is the fallback, not the recommendation: if you are in React, use the +component. + +## What you are reproducing + +```html + + + + … + +``` + +Four things make it correct: + +1. One `` per format, **modern format first** — the browser takes the first it supports. +2. `srcset` entries carry `w` descriptors matching the file's real pixel width. +3. `sizes` is present on every `` **and** describes the rendered width, not the file width. +4. `` carries `src` (fallback), `alt`, `width`, `height`. + +## Building it from the API response + +```ts +type Variant = { url: string; width: number; height?: number; size?: number }; + +const extOf = (url: string) => url.split(".").pop()!.toLowerCase(); + +const srcsetFor = (variants: Variant[], ext: string) => + variants + .filter((v) => extOf(v.url) === ext) + .sort((a, b) => a.width - b.width) + .map((v) => `${v.url} ${v.width}w`) + .join(", "); +``` + +**Build from the variants the API returned.** The width ladder is capped at the source image's own +width, so it differs per image — a hardcoded list produces URLs that 404. + +### Choosing the modern format + +Ship **one** modern format. Two `` elements do not make the page faster; the browser takes the +first it supports, and on Crystallize's current transcoder output that is usually the larger file. + +```ts +/** Pick the smaller of webp/avif at a common width; default to webp. */ +function pickModernFormat(variants: Variant[]): "webp" | "avif" { + const at = (ext: string) => + variants.filter((v) => extOf(v.url) === ext && v.size).sort((a, b) => a.width - b.width); + const [webp] = at("webp"); + const [avif] = at("avif"); + if (!webp?.size || !avif?.size) return "webp"; + return avif.size < webp.size ? "avif" : "webp"; +} +``` + +This mirrors what the React component does. It needs `size` in the query — without it, default to +`webp`, which wins the large majority of the time. + +The original format is whatever the source was, `jpeg` or `png`. Derive it rather than assuming: + +```ts +const originalExt = (image: { url: string }) => { + const ext = extOf(image.url); + return ext === "jpg" ? "jpeg" : ext; +}; +``` + +### Putting it together + +```ts +function pictureHtml(image: { url: string; altText?: string; variants: Variant[] }, sizes: string) { + const modern = pickModernFormat(image.variants); + const original = originalExt(image); + const biggest = [...image.variants].sort((a, b) => b.width - a.width)[0]; + + return ` + + + + ${image.altText ?? +`; +} +``` + +Escape `altText` for the target context — it is author-entered content, not a literal. + +## Art direction: a different crop per breakpoint + +`sizes` picks a _size_. When you need a different _image_ — a tall portrait crop on mobile, a wide one +on desktop — that is `media`, and it takes separate `` elements: + +```html + + + + … + +``` + +Two different crops are two different images in the PIM. Do not try to fake art direction with +`object-fit` alone — see focal point below for the case where one image is genuinely enough. + +## Focal point + +`focalPoint` (`{ x, y }`, normalized 0–1) is authored in the PIM and tells you where the subject is. +It is what makes a single image survive being cropped to different aspect ratios: + +```css +.card-image { + aspect-ratio: 4 / 3; + object-fit: cover; + object-position: var(--focal-x, 50%) var(--focal-y, 50%); +} +``` + +```ts +const style = image.focalPoint ? `--focal-x:${image.focalPoint.x * 100}%;--focal-y:${image.focalPoint.y * 100}%` : ""; +``` + +Without it, `object-fit: cover` crops from the centre and decapitates people in portrait shots. + +## Don't + +- **Don't route these URLs through a framework image optimizer.** The variants are already static + files on the CDN; re-optimizing yields no smaller file and bills you for the transform and the + egress. See Rule 0 in SKILL.md. +- **Don't omit `sizes`.** The browser then assumes `100vw` and downloads the largest candidate for a + 200px thumbnail. +- **Don't put `sizes` only on the ``.** Each `` needs it too; ``-only leaves the + `` elements defaulting to `100vw`. +- **Don't lazy-load the LCP image.** The hero gets `loading="eager"` and `fetchpriority="high"`. +- **Don't hardcode the width ladder.** It is capped per image at the source width.