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.