diff --git a/.changeset/block-bones-cover-loaded-images.md b/.changeset/block-bones-cover-loaded-images.md new file mode 100644 index 0000000..f29d5a2 --- /dev/null +++ b/.changeset/block-bones-cover-loaded-images.md @@ -0,0 +1,5 @@ +--- +"@camp.dev/bones": patch +--- + +Block bones now cover an image that has already loaded. A loaded opaque image paints over its own background, so an `` with a resolved `src` inside a busy region, or one marked `data-bone="block"`, showed the picture instead of a bone. `auto.css` and `bones.css` now set `object-position` on block bones to push the picture out of its content box. The box and its bone background stay in place. Images, canvas, and video without native controls are covered. A loaded `iframe`, `embed`, or `object` still shows its content, because those elements ignore `object-position`. diff --git a/.gitignore b/.gitignore index 02d12ef..9defbda 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ test-results docs/superpowers/ .superpowers .worktrees -packages/bones/tests/browser/__screenshots__/**/*-darwin.png \ No newline at end of file +packages/bones/tests/browser/__screenshots__/**/*-darwin.png +packages/bones/.vitest-attachments/ diff --git a/README.md b/README.md index 287bd6e..5fcce09 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b ``` -`[data-bones-auto="off"]` opts a subtree out — useful for a status message you want to stay readable while its container skeletonizes. Explicit `data-bone` markup is left alone; `auto.css` only styles elements neither `bone()` nor a manual `data-bone` attribute has already claimed. +`[data-bones-auto="off"]` opts a subtree out — useful for a status message you want to stay readable while its container skeletonizes. Images, canvas, and video without native controls get a solid bone even after they have loaded, unless page CSS sets `object-position` on them, which outranks the layered rule the same way `color` does. A loaded `iframe`, `embed`, or `object` still shows its content, because those elements ignore `object-position`. Explicit `data-bone` markup is left alone; `auto.css` only styles elements neither `bone()` nor a manual `data-bone` attribute has already claimed. Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` works on the `aria-busy` element itself or on any ancestor. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, every bone shimmers, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies. diff --git a/packages/bones/README.md b/packages/bones/README.md index 84450bd..0ace5dd 100644 --- a/packages/bones/README.md +++ b/packages/bones/README.md @@ -133,7 +133,7 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b ``` -`[data-bones-auto="off"]` opts a subtree out — useful for a status message you want to stay readable while its container skeletonizes. Explicit `data-bone` markup is left alone; `auto.css` only styles elements neither `bone()` nor a manual `data-bone` attribute has already claimed. +`[data-bones-auto="off"]` opts a subtree out — useful for a status message you want to stay readable while its container skeletonizes. Images, canvas, and video without native controls get a solid bone even after they have loaded, unless page CSS sets `object-position` on them, which outranks the layered rule the same way `color` does. A loaded `iframe`, `embed`, or `object` still shows its content, because those elements ignore `object-position`. Explicit `data-bone` markup is left alone; `auto.css` only styles elements neither `bone()` nor a manual `data-bone` attribute has already claimed. Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` works on the `aria-busy` element itself or on any ancestor. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, every bone shimmers, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies. diff --git a/packages/bones/src/css/auto.css b/packages/bones/src/css/auto.css index dab0ef9..a026a9d 100644 --- a/packages/bones/src/css/auto.css +++ b/packages/bones/src/css/auto.css @@ -148,6 +148,17 @@ background-color: var(--bone-base); border-radius: var(--bone-radius); border-color: transparent; + /* A loaded image paints over its own background, so the bone color alone + does not cover it. Pushing the picture out of its content box leaves + the box and its background in place (BON-15). Only img, video, and + canvas honor this; a loaded iframe, embed, or object still shows, and + native video controls paint over the bone. object-position is + interpolable, so a page `transition: all` on images would slide the + picture out over the transition's duration; transition: none stops + that on the way in. On the way out the page's own transition applies, + and the picture reappears at the end of it instead of the start. */ + object-position: 9999px 0; + transition: none; } /* Width variance: text leaves cycle 85 / 100 / 92 / 60 so runs of lines diff --git a/packages/bones/src/css/bones.css b/packages/bones/src/css/bones.css index 5ac211d..0549c7c 100644 --- a/packages/bones/src/css/bones.css +++ b/packages/bones/src/css/bones.css @@ -70,8 +70,15 @@ } img[data-bone="block"], -video[data-bone="block"] { +video[data-bone="block"], +canvas[data-bone="block"] { color: rgb(from currentcolor r g b / 0); + /* A loaded picture paints over the bone's background-color; pushing it out + of the content box keeps the box and background visible (BON-15). + transition: none keeps a page `transition: all` from sliding the picture + out on the way in; see the matching comment in auto.css. */ + object-position: 9999px 0; + transition: none; } [data-bone="container"] { diff --git a/packages/bones/tests/browser/img-occlusion.test.ts b/packages/bones/tests/browser/img-occlusion.test.ts new file mode 100644 index 0000000..90b7f29 --- /dev/null +++ b/packages/bones/tests/browser/img-occlusion.test.ts @@ -0,0 +1,93 @@ +import { afterEach, expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import "../../src/css/auto.css"; +import { parseColor } from "./expect-color.ts"; + +// --------------------------------------------------------------------------- +// BON-15: a block bone must cover an image that has already loaded. A loaded +// opaque image paints over its own background-color, so setting the bone +// color on the img is not enough; the picture itself has to leave the box. +// Computed style cannot see paint, so these tests screenshot the element and +// sample its center pixel. +// --------------------------------------------------------------------------- + +/** A solid red PNG, drawn rather than hand-encoded so it always decodes. */ +const RED_PNG = (() => { + const canvas = document.createElement("canvas"); + canvas.width = 4; + canvas.height = 4; + const context = canvas.getContext("2d")!; + context.fillStyle = "rgb(255, 0, 0)"; + context.fillRect(0, 0, 4, 4); + return canvas.toDataURL("image/png"); +})(); + +const PAGE = "color: rgb(0, 0, 255); background: rgb(255, 255, 255)"; + +afterEach(() => { + document.body.innerHTML = ""; +}); + +async function mountImage(html: string): Promise { + document.body.insertAdjacentHTML("beforeend", html); + const img = document.body.querySelector("img")!; + await img.decode(); + return img; +} + +/** RGB of the pixel at the center of `el` as painted. */ +async function centerPixel(el: HTMLElement): Promise<[number, number, number]> { + // With save: false the runner returns the base64 string itself rather than + // writing a file; the `{ path, base64 }` shape is what a saved shot returns. + const shot: unknown = await page.elementLocator(el).screenshot({ base64: true, save: false }); + const base64 = typeof shot === "string" ? shot : (shot as { base64: string }).base64; + const png = new Image(); + png.src = `data:image/png;base64,${base64}`; + await png.decode(); + const canvas = document.createElement("canvas"); + canvas.width = png.width; + canvas.height = png.height; + const context = canvas.getContext("2d")!; + context.drawImage(png, 0, 0); + const { data } = context.getImageData( + Math.floor(png.width / 2), + Math.floor(png.height / 2), + 1, + 1, + ); + return [data[0], data[1], data[2]]; +} + +/** The bone's own color composited over the white page, per channel. */ +function expectedBonePixel(el: HTMLElement): [number, number, number] { + const [r, g, b, a] = parseColor(getComputedStyle(el).backgroundColor); + const over = (channel: number) => Math.round(channel * a + 255 * (1 - a)); + return [over(r), over(g), over(b)]; +} + +async function expectBonePixel(img: HTMLImageElement): Promise { + const actual = await centerPixel(img); + const expected = expectedBonePixel(img); + for (let i = 0; i < 3; i++) expect(Math.abs(actual[i] - expected[i])).toBeLessThanOrEqual(2); +} + +test("control: outside a busy region a loaded image paints its own pixels", async () => { + const img = await mountImage( + `
`, + ); + expect(await centerPixel(img)).toEqual([255, 0, 0]); +}); + +test("auto.css: a loaded image inside a busy region paints its bone color", async () => { + const img = await mountImage( + `
`, + ); + await expectBonePixel(img); +}); + +test("bones.css: a loaded image marked as a block bone paints its bone color", async () => { + const img = await mountImage( + `
`, + ); + await expectBonePixel(img); +});