Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/block-bones-cover-loaded-images.md
Original file line number Diff line number Diff line change
@@ -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 `<img>` 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`.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ test-results
docs/superpowers/
.superpowers
.worktrees
packages/bones/tests/browser/__screenshots__/**/*-darwin.png
packages/bones/tests/browser/__screenshots__/**/*-darwin.png
packages/bones/.vitest-attachments/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b
</section>
```

`[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.

Expand Down
2 changes: 1 addition & 1 deletion packages/bones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b
</section>
```

`[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.

Expand Down
11 changes: 11 additions & 0 deletions packages/bones/src/css/auto.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion packages/bones/src/css/bones.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
transition: none;
}

[data-bone="container"] {
Expand Down
93 changes: 93 additions & 0 deletions packages/bones/tests/browser/img-occlusion.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLImageElement> {
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<void> {
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(
`<div style="${PAGE}"><img src="${RED_PNG}" width="48" height="48" alt="" /></div>`,
);
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(
`<section aria-busy="true" data-bone-animate="none" style="${PAGE}"><img src="${RED_PNG}" width="48" height="48" alt="" /></section>`,
);
await expectBonePixel(img);
});

test("bones.css: a loaded image marked as a block bone paints its bone color", async () => {
const img = await mountImage(
`<div data-bone-animate="none" style="${PAGE}"><img data-bone="block" src="${RED_PNG}" width="48" height="48" alt="" /></div>`,
);
await expectBonePixel(img);
});
Loading