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);
+});