RFC-0028: Image placeholders and fit
- Status: Accepted (2026-10-17; the directions were decided in the companion research)
- Author: Halil (Madraka), drafted with Claude · Date: 2026-10-17
- Related:
- RFC-0010 (media library), RFC-0020 (surfaces), RFC-0025 (
Lightbox), RFC-0027 (carouselgallery); docs/design/images.md, the research: placeholder techniques compared, how mismatched ratios are handled elsewhere, and the decisions taken here (sources there).
- RFC-0010 (media library), RFC-0020 (surfaces), RFC-0025 (
Summary
Images get a calm placeholder that needs no JavaScript (a tiny blurred LQIP over the image’s dominant colour), painted on a wrapper rather than on the <img>. They also get a fit that suits their frame: cover steered by a focal point, contain, blur-fill (the image whole, its bars filled with itself enlarged and blurred), color-fill and natural. Three optional AssetImage fields carry what this needs (color, alpha, focal). Two current bugs go away: LQIPs showing through transparent images, and blocky LQIPs in contain bars.
Motivation
- Agents and people put photographs of every shape into frames of fixed ratios (cards, heroes, galleries, the lightbox). Today a portrait photo in a 16:9 slide is either cropped blind or shown with flat bars.
- The placeholder sits on the
<img>itself, so it shows through transparent PNGs after they load and fillscontainbars with a blocky 32 px image. - Only Studio uploads get an LQIP; nothing records a colour, a transparency flag or where the subject is.
Proposal
1. Contract (asset.schema.json, AssetImage, all optional)
"color": { "type": "string", "pattern": "^#[0-9a-f]{6}$", "description": "Dominant colour, lowercase #rrggbb: the placeholder under the LQIP and the color-fill bars." },"alpha": { "type": "boolean", "enum": [true], "description": "The image has transparent pixels (read from its header at ingest). Absent means opaque." },"focal": { "type": "object", "additionalProperties": false, "required": ["x", "y"], "properties": { "x": { "type": "number", "minimum": 0, "maximum": 1 }, "y": { "type": "number", "minimum": 0, "maximum": 1 } }, "description": "Where the subject is, from the top-left: cover crops keep it in view." }blurhashstays, unused and documented as such. The LQIP’s target tightens to 24 px and about 400 B (the 4096-character limit stays, so old assets remain valid).alphais read from the file’s header at ingest (PNG colour type ortRNS, WebP VP8X, AVIFauxC), on every path: Studio, agents, imports.
2. The placeholder
<img … style="background-color:#a3b18a;background-image:url("data:image/svg+xml,…");background-size:100% 100%">- The placeholder is the
<img>’s own background: the colour, then the LQIP drawn inside a small SVG whosefeGaussianBlur(withedgeMode="duplicate"and alpha forced opaque, so the edges stay crisp) smooths it. The SVG covers the box (preserveAspectRatio="xMidYMid slice"). The image paints over it; no script and no fade, and with JavaScript off nothing is hidden. - Under
object-fit: containthat same layer shows in the bars: the image enlarged and blurred. This is blur-fill, with no wrapper and no second download (§3). - Images with
alphaget no placeholder at all (bug 1). Bars never show the raw, blocky LQIP (bug 2): it is always blurred. - No new markup: an earlier draft wrapped every image in a
<span>. The background keeps every theme’s selectors and layouts as they are.
3. Fit modes
fit |
What it does | Default for |
|---|---|---|
cover |
fills the frame, cropping around focal (and gravity when transformations are on) |
an Image with a ratio, cards, heroes |
contain |
the whole image, bars in the surrounding surface | screenshots inside text |
blur-fill |
the whole image, bars filled by itself enlarged and blurred, from the LQIP already in the page | the Lightbox stage, gallery carousels |
color-fill |
the whole image, bars in its color |
logos, transparent images |
natural |
the image at its own ratio, no frame | images without a ratio |
fitis a catalog prop ofImage(no contract change: like RFC-0024/0025/0027 components, the catalog checks props). Per-use focal points onImageRefwait until a page needs them.- Blur-fill is the placeholder layer of §2 left visible around a contained image. It is decorative (a background), so it is gone in forced colours. No second image download and no
backdrop-filter.
4. Checks and budgets
- Placeholders up to about 24 KB per page; beyond that, the renderer uses the colour only.
render_pageaddsimage-fit: an image cropped bycoverwith no focal point where the ratios differ a lot; an LQIP on a transparent image; placeholders over budget.
5. Server-side placeholders
Agent and import uploads get an LQIP and a colour through the Cloudflare Images binding when the deployment has one (a 16–24 px WebP). Otherwise Studio computes them on upload, and cloudhal media placeholders fills old assets.
Invariants
- No image is ever hidden waiting for a script.
- A placeholder never shows after its image has loaded, transparent pixels included.
- Nothing the reader needs is cropped away silently: a cover crop keeps the focal point, and the checks flag blind crops.
Alternatives considered
| Option | Pros | Cons | Why not |
|---|---|---|---|
| BlurHash / ThumbHash | ~30 B, good quality | Needs a canvas decode in the visitor’s browser (or server decoding) | Breaks the no-JavaScript rule; a 300 B LQIP looks as good once blurred |
| CSS-only gradient LQIP | ~20 B, no script | Very coarse | Too coarse for photographs |
| Blur-fill with a second, blurred image | Sharper fill | A second download per image | The LQIP already in the page is enough when blurred |
backdrop-filter |
One element | Costly, and it blurs what lies behind it, not the image | Static blur of a tiny source is cheap |
fit and focal on every ImageRef |
Per-use control | A contract change for every image use | The asset’s focal point covers most cases |
Compatibility & migration
- All new fields are optional, and old assets render as today with the colour missing. No
schemaVersionchange. - The renderer’s markup is unchanged; only the
<img>’s inline background differs. - Rollback: restore the old background and drop the catalog prop; the fields stay harmless.
Security & policy impact
focalis set through the existing policy-checked asset update; no new scope.- The Images binding is billed beyond its free allowance; turning it on for a deployment is the owner’s decision, never an agent’s.
Rollout
| Phase | Scope |
|---|---|
| 28a | Contract color/alpha/focal; alpha header parsers; Studio adds colour; the blurred placeholder; bugs 1 and 2; budget |
| 28b | Fit modes and focal point in the catalog, CSS and transformations; image-fit check; gallery and lightbox default to blur-fill |
| 28c | The backfill command, drawing in a local Chrome (shipped); placeholders through the Images binding at upload time (only when a deployment turns it on) |
| 28d | Optional fade-in; optional gravity=auto focal suggestions — decided against (see Status) |
Open questions
None; docs/design/images.md decided them. Whether the live deployment gets the Images binding is asked of the owner at 28c.
Status
- 28a shipped.
- Contract.
AssetImagegainscolor,alphaandfocal, with fixtures for each; TypeScript and Rust are regenerated. - Ingest. Every path reads
alphafrom the file header: PNG colour type ortRNS, WebP VP8X,ALPHor the VP8L alpha bit, a GIF transparency flag, an AVIF alphaauxC. An SVG always counts as transparent. - Studio.
- Uploads send a 24 px LQIP and the dominant colour (the mean of the visible pixels).
- The LQIP drops the ~450 B ICC profile Chrome’s canvas embeds, so a typical one goes from ~780 to ~140 characters.
- The API accepts
image.color.
- Renderer.
placeholderStyledraws the colour and the blurred LQIP as the image’s background, nothing for transparent images. A per-page budget (PLACEHOLDER_BUDGET, 24 KB,MediaContext.budget) falls back to the colour. Both bugs are gone, and gallery and lightbox bars already show the blurred image.
- Contract.
- 28b shipped.
- Catalog.
Imagetakesfit(cover,contain,blur-fill,color-fill,natural).naturalwith a ratio is refused. - Renderer.
- The placeholder follows the fit:
coverandblur-fillcover the box, the cover crop leaning towards the focal point;containpaints only where the picture will be;color-fillputs the colour under it, and a transparent image keeps its colour.
- A cover image with a focal point gets
object-position. - Images inside a
gallerycarousel default toblur-fill. - The lightbox stage is one fixed height, each picture whole with blurred sides, so moving between portrait and landscape never jumps.
- Transformations stay
fit=scale-down, so they never crop and need nogravity.
- The placeholder follows the fit:
- Focal point.
- People set it in Studio: on the file’s page, the new
FocalPointPickerblock (click the subject or use the arrow keys, with wide, square and tall crop previews) saves with the form. - Agents use
update_assetwithfocal(null clears it); the API takesfocalonPUT /assets/:id. - Changing placeholders keeps the measured fields and the focal point. Before this, they reset
animatedandalpha.
- People set it in Studio: on the file’s page, the new
- Check.
render_pageaddsimage-fit: a cover frame that keeps under 60% of a picture with no focal point. - Guidance.
get_designsays when to set a focal point or use blur-fill and color-fill.
- Catalog.
- 28c shipped (no Images binding). The owner chose a local backfill over turning on the Cloudflare Images binding for the live deployment, so nothing new is billed.
- Command.
cloudhal media placeholders [--remote] [--all] [--dry-run]draws missing placeholders in the local Chrome thatrender_pageuses, with the same helpers Studio runs (leanWebp,dominantColor, now in@cloudhal/core-ts/media).- It covers images without a colour: agent uploads, imports,
cloudhal push. - Transparent images get their colour only.
- Measured fields and the focal point stay.
- Without
--remoteit works on the project’s SQLite. With it, it uses the deployment (deployment token):GET /api/admin/sites/:id/media-placeholderslists the images, the bytes come from the existing admin media route, andPUT …/media-placeholders/:assetIdstores the result (CAS onrev, both fields validated).
- It covers images without a colour: agent uploads, imports,
- Not done. Drawing placeholders through an Images binding at upload time stays possible for deployments that want it, but is not built. It waits until a deployment turns the binding on.
- Command.
- 28d decided against; RFC-0028 is complete.
- Fade-in. The placeholder is the image’s own background (§2), so fading the image would fade its placeholder with it. A crossfade needs a second layer: a wrapper element in every theme’s markup, or a script that rewrites the DOM after load. Either brings back what §2 avoided, for a cosmetic gain; the image keeps painting straight over its blurred placeholder.
gravity=autofocal suggestions. Cloudflare’s automatic gravity chooses a crop but does not say where the subject is, and Cloudhal’s transformations never crop (fit=scale-down), so there is nothing to read back. Focal points stay with people (Studio) and agents (update_asset);render_page’simage-fitpoints at the crops that need one.