From 5faec77379024e8cbb0bd58efaf231c8d0ab3ddb Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sat, 29 Aug 2026 05:40:49 -0400 Subject: [PATCH 1/2] fix: cover an already-loaded image with its block bone (BON-15) A loaded opaque image paints over its own background-color, so an img with a resolved src inside a busy region, or one marked data-bone="block", showed the picture instead of a bone. Replaced elements render no pseudo-elements, which is why the background approach was used in the first place. Both stylesheets now set object-position: 9999px 0 on the block rule. With the default object-fit: fill the picture is pushed out of its content box and clipped, while the box and its bone background stay in place. img, video, and canvas honor it; iframe, embed, and object do not, and the README says so. The test screenshots the element and samples its center pixel, since computed style cannot see paint. A control outside a busy region must sample pure red, proving the harness sees the image. The runner writes each sample to __screenshots__/img-occlusion.test.ts/, now gitignored along with .vitest-attachments/. Co-Authored-By: Claude Fable 5 --- .changeset/block-bones-cover-loaded-images.md | 5 ++ .gitignore | 4 +- README.md | 2 +- packages/bones/README.md | 2 +- packages/bones/src/css/auto.css | 5 ++ packages/bones/src/css/bones.css | 3 + .../bones/tests/browser/img-occlusion.test.ts | 81 +++++++++++++++++++ 7 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 .changeset/block-bones-cover-loaded-images.md create mode 100644 packages/bones/tests/browser/img-occlusion.test.ts diff --git a/.changeset/block-bones-cover-loaded-images.md b/.changeset/block-bones-cover-loaded-images.md new file mode 100644 index 0000000..56e827f --- /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. Both stylesheets now push the picture out of its content box with `object-position`, which keeps the box and its bone background in place. This covers `img`, `video`, and `canvas`; a loaded `iframe`, `embed`, or `object` still shows its content, since those elements ignore `object-position`. diff --git a/.gitignore b/.gitignore index 02d12ef..71993c8 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,6 @@ 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/tests/browser/__screenshots__/img-occlusion.test.ts/ +packages/bones/.vitest-attachments/ diff --git a/README.md b/README.md index 287bd6e..42daf27 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, video, and canvas get a solid bone even after they have loaded. 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..d622747 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, video, and canvas get a solid bone even after they have loaded. 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..e214c5b 100644 --- a/packages/bones/src/css/auto.css +++ b/packages/bones/src/css/auto.css @@ -148,6 +148,11 @@ 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. */ + object-position: 9999px 0; } /* 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..0d0bb5f 100644 --- a/packages/bones/src/css/bones.css +++ b/packages/bones/src/css/bones.css @@ -72,6 +72,9 @@ img[data-bone="block"], video[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). */ + object-position: 9999px 0; } [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..fc92b98 --- /dev/null +++ b/packages/bones/tests/browser/img-occlusion.test.ts @@ -0,0 +1,81 @@ +import { afterEach, expect, test } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import "../../src/css/auto.css"; + +// --------------------------------------------------------------------------- +// 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"); +})(); + +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]> { + // The vendored runner has no `save: false`; the file it writes lands in + // __screenshots__/img-occlusion.test.ts/, which .gitignore covers. + const shot = await page.elementLocator(el).screenshot({ base64: true }); + const png = new Image(); + png.src = `data:image/png;base64,${shot.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]]; +} + +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 is covered by its bone", async () => { + const img = await mountImage( + `
`, + ); + const [r, g, b] = await centerPixel(img); + // --bone-base is the text color at 12% over white: a pale blue, never red. + expect(r).toBeLessThan(250); + expect(b).toBeGreaterThan(g); +}); + +test("bones.css: a loaded image marked as a block bone is covered by its bone", async () => { + const img = await mountImage( + `
`, + ); + const [r, g, b] = await centerPixel(img); + expect(r).toBeLessThan(250); + expect(b).toBeGreaterThan(g); +}); From e2889d43aa4ad19367a6912db3a4b28721e564be Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Sat, 29 Aug 2026 05:53:42 -0400 Subject: [PATCH 2/2] fix: harden the image push and tie the test to the bone color Review follow-ups on the BON-15 branch. object-position is interpolable, so a page `transition: all` on images would slide the picture out over the transition instead of hiding it at once. Both block rules set transition: none, which stops that on the way into the busy state. On the way out the page's own transition applies and the picture reappears at the end of it; the comments say so. bones.css now covers canvas[data-bone="block"] too, matching the changeset's claim and auto.css. The README no longer claims a solid bone for video with native controls (the controls paint over it) and notes that page CSS setting object-position outranks the layered auto rule, the same way color does. The test passes save: false, so the runner returns the base64 string and writes no file; the screenshot-directory ignore goes. The expected pixel is the element's computed background-color composited over white, within 2 per channel, instead of hand-tuned thresholds. Co-Authored-By: Claude Fable 5 --- .changeset/block-bones-cover-loaded-images.md | 2 +- .gitignore | 1 - README.md | 2 +- packages/bones/README.md | 2 +- packages/bones/src/css/auto.css | 8 +++- packages/bones/src/css/bones.css | 8 +++- .../bones/tests/browser/img-occlusion.test.ts | 44 ++++++++++++------- 7 files changed, 44 insertions(+), 23 deletions(-) diff --git a/.changeset/block-bones-cover-loaded-images.md b/.changeset/block-bones-cover-loaded-images.md index 56e827f..f29d5a2 100644 --- a/.changeset/block-bones-cover-loaded-images.md +++ b/.changeset/block-bones-cover-loaded-images.md @@ -2,4 +2,4 @@ "@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. Both stylesheets now push the picture out of its content box with `object-position`, which keeps the box and its bone background in place. This covers `img`, `video`, and `canvas`; a loaded `iframe`, `embed`, or `object` still shows its content, since those elements ignore `object-position`. +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 71993c8..9defbda 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,4 @@ docs/superpowers/ .superpowers .worktrees packages/bones/tests/browser/__screenshots__/**/*-darwin.png -packages/bones/tests/browser/__screenshots__/img-occlusion.test.ts/ packages/bones/.vitest-attachments/ diff --git a/README.md b/README.md index 42daf27..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. Images, video, and canvas get a solid bone even after they have loaded. 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. +`[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 d622747..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. Images, video, and canvas get a solid bone even after they have loaded. 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. +`[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 e214c5b..a026a9d 100644 --- a/packages/bones/src/css/auto.css +++ b/packages/bones/src/css/auto.css @@ -151,8 +151,14 @@ /* 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. */ + 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 0d0bb5f..0549c7c 100644 --- a/packages/bones/src/css/bones.css +++ b/packages/bones/src/css/bones.css @@ -70,11 +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). */ + 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 index fc92b98..90b7f29 100644 --- a/packages/bones/tests/browser/img-occlusion.test.ts +++ b/packages/bones/tests/browser/img-occlusion.test.ts @@ -1,6 +1,7 @@ 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 @@ -21,6 +22,8 @@ const RED_PNG = (() => { return canvas.toDataURL("image/png"); })(); +const PAGE = "color: rgb(0, 0, 255); background: rgb(255, 255, 255)"; + afterEach(() => { document.body.innerHTML = ""; }); @@ -34,11 +37,12 @@ async function mountImage(html: string): Promise { /** RGB of the pixel at the center of `el` as painted. */ async function centerPixel(el: HTMLElement): Promise<[number, number, number]> { - // The vendored runner has no `save: false`; the file it writes lands in - // __screenshots__/img-occlusion.test.ts/, which .gitignore covers. - const shot = await page.elementLocator(el).screenshot({ base64: true }); + // 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,${shot.base64}`; + png.src = `data:image/png;base64,${base64}`; await png.decode(); const canvas = document.createElement("canvas"); canvas.width = png.width; @@ -54,28 +58,36 @@ async function centerPixel(el: HTMLElement): Promise<[number, number, number]> { 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 is covered by its bone", async () => { +test("auto.css: a loaded image inside a busy region paints its bone color", async () => { const img = await mountImage( - `
`, + `
`, ); - const [r, g, b] = await centerPixel(img); - // --bone-base is the text color at 12% over white: a pale blue, never red. - expect(r).toBeLessThan(250); - expect(b).toBeGreaterThan(g); + await expectBonePixel(img); }); -test("bones.css: a loaded image marked as a block bone is covered by its bone", async () => { +test("bones.css: a loaded image marked as a block bone paints its bone color", async () => { const img = await mountImage( - `
`, + `
`, ); - const [r, g, b] = await centerPixel(img); - expect(r).toBeLessThan(250); - expect(b).toBeGreaterThan(g); + await expectBonePixel(img); });