From 1ae2f3198558c304be5fa2f28794a0bf5608c212 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Thu, 27 Aug 2026 05:56:45 -0400 Subject: [PATCH] fix: make data-bone-animate work from the element that carries it (BON-17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The animation variants live in @scope blocks, and a scoped selector's implicit :scope prefix matches strict descendants only — so the attribute was a silent no-op on the marked bone itself and on the aria-busy element of an auto region. The scoped selectors now include the scope root via :is(:scope, :scope *), with browser regression tests for both stylesheets and updated docs. Co-Authored-By: Claude Fable 5 --- .changeset/animate-on-carrier.md | 5 ++ README.md | 2 +- apps/docs/content/docs/examples.mdx | 2 +- apps/docs/content/docs/styling.mdx | 2 +- packages/bones/README.md | 2 +- packages/bones/src/css/auto.css | 46 +++++----- packages/bones/src/css/bones.css | 28 +++--- .../tests/browser/adaptive-colors.test.ts | 3 +- .../tests/browser/animation-scope.test.ts | 90 +++++++++++++++++++ 9 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 .changeset/animate-on-carrier.md create mode 100644 packages/bones/tests/browser/animation-scope.test.ts diff --git a/.changeset/animate-on-carrier.md b/.changeset/animate-on-carrier.md new file mode 100644 index 0000000..86b9c25 --- /dev/null +++ b/.changeset/animate-on-carrier.md @@ -0,0 +1,5 @@ +--- +"@camp.dev/bones": patch +--- + +`data-bone-animate` now takes effect from the element that carries it: a marked bone can animate itself, and an `aria-busy` region can be stilled or switched from its own tag instead of a wrapper. The animation variants live in `@scope` blocks, and a scoped selector's implicit `:scope` prefix matches strict descendants only, so the attribute was a silent no-op on the scope root itself. The scoped selectors now include the root via `:is(:scope, :scope *)`. diff --git a/README.md b/README.md index 42330cc..f7c74b7 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,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. -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` also has to sit on an ancestor of the `aria-busy` element — set directly on it, it has no effect. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies. +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`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies. ## Without React diff --git a/apps/docs/content/docs/examples.mdx b/apps/docs/content/docs/examples.mdx index ab1fbd2..05aa82a 100644 --- a/apps/docs/content/docs/examples.mdx +++ b/apps/docs/content/docs/examples.mdx @@ -157,7 +157,7 @@ function TagList({ pokemon }: { pokemon: Promise }) { ## Skeleton animations -Skeletons are static by default. Add `data-bone-animate` to a parent element to animate them. +Skeletons are static by default. Add `data-bone-animate` to an element to animate the bones inside it. The attribute also works on a bone itself and on the `aria-busy` element of an automatic region. ### Shimmer diff --git a/apps/docs/content/docs/styling.mdx b/apps/docs/content/docs/styling.mdx index 5728df5..8e919b0 100644 --- a/apps/docs/content/docs/styling.mdx +++ b/apps/docs/content/docs/styling.mdx @@ -58,7 +58,7 @@ One case needs a manual override: text that depends on its background for contra ## Animations -Add `data-bone-animate` to any parent element to animate skeletons inside it: +Add `data-bone-animate` to an element to animate the bones inside it. The attribute also works on a bone itself and on the `aria-busy` element of an automatic region. - `shimmer` - horizontal highlight sweep - `pulse` - gentle opacity fade (also kicks in automatically for `prefers-reduced-motion: reduce`) diff --git a/packages/bones/README.md b/packages/bones/README.md index cd7a97f..9d2f942 100644 --- a/packages/bones/README.md +++ b/packages/bones/README.md @@ -129,7 +129,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. -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` also has to sit on an ancestor of the `aria-busy` element — set directly on it, it has no effect. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies. +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`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies. ## Without React diff --git a/packages/bones/src/css/auto.css b/packages/bones/src/css/auto.css index 4001183..5e3fac4 100644 --- a/packages/bones/src/css/auto.css +++ b/packages/bones/src/css/auto.css @@ -286,7 +286,9 @@ /* Auto bones default to shimmer. bones.css only animates inside a [data-bone-animate] scope; a zero-markup page has no such attribute, so the default lives here and the scopes below mirror bones.css for pages - that do set it. */ + that do set it. In those scopes, :is(:scope, :scope *) lets the attribute + sit on the aria-busy element itself: a scoped selector's implicit :scope + prefix matches strict descendants only (BON-17). */ [aria-busy="true"] :not(:has(*)):not( [data-bone], @@ -374,7 +376,7 @@ } @scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) { - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -403,7 +405,7 @@ select *, object * )::before, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -432,7 +434,7 @@ select *, object * )::after, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :is( img, svg, @@ -461,7 +463,7 @@ } @media (prefers-reduced-motion: reduce) { - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -490,7 +492,7 @@ select *, object * )::before, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -519,7 +521,7 @@ select *, object * )::after, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :is( img, svg, @@ -544,7 +546,7 @@ } @media (forced-colors: active) { - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -573,7 +575,7 @@ select *, object * )::after, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :is( img, svg, @@ -598,7 +600,7 @@ } @scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) { - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -627,7 +629,7 @@ select *, object * )::before, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -656,7 +658,7 @@ select *, object * )::after, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :is( img, svg, @@ -680,7 +682,7 @@ } @media (prefers-reduced-motion: reduce) { - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -709,7 +711,7 @@ select *, object * )::before, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -738,7 +740,7 @@ select *, object * )::after, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :is( img, svg, @@ -763,7 +765,7 @@ } @media (forced-colors: active) { - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -792,7 +794,7 @@ select *, object * )::after, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :is( img, svg, @@ -817,7 +819,7 @@ } @scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) { - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -846,7 +848,7 @@ select *, object * )::before, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -875,7 +877,7 @@ select *, object * )::after, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :is( img, svg, @@ -899,7 +901,7 @@ } @media (forced-colors: active) { - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :not(:has(*)):not( [data-bone], [data-bone] *, @@ -928,7 +930,7 @@ select *, object * )::after, - [aria-busy="true"] + [aria-busy="true"]:is(:scope, :scope *) :is( img, svg, diff --git a/packages/bones/src/css/bones.css b/packages/bones/src/css/bones.css index 9e569a3..1e86b0e 100644 --- a/packages/bones/src/css/bones.css +++ b/packages/bones/src/css/bones.css @@ -110,11 +110,15 @@ video[data-bone="block"] { } } +/* :is(:scope, :scope *) lets the attribute work from the bone itself, not + only from a wrapper: a scoped selector's implicit :scope prefix matches + strict descendants only, so a plain compound never matches the scope root + (BON-17). */ @scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) { - [data-bone="text"]::after, - [data-bone="text"]::before, - [data-bone="block"], - [data-bone="container"]::before { + [data-bone="text"]:is(:scope, :scope *)::after, + [data-bone="text"]:is(:scope, :scope *)::before, + [data-bone="block"]:is(:scope, :scope *), + [data-bone="container"]:is(:scope, :scope *)::before { animation: bone-shimmer var(--bone-duration) ease-in-out infinite; background: linear-gradient( 90deg, @@ -127,10 +131,10 @@ video[data-bone="block"] { } @scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) { - [data-bone="text"]::after, - [data-bone="text"]::before, - [data-bone="block"], - [data-bone="container"]::before { + [data-bone="text"]:is(:scope, :scope *)::after, + [data-bone="text"]:is(:scope, :scope *)::before, + [data-bone="block"]:is(:scope, :scope *), + [data-bone="container"]:is(:scope, :scope *)::before { animation: bone-pulse var(--bone-duration) ease-in-out infinite; background: var(--bone-base); background-size: auto; @@ -138,10 +142,10 @@ video[data-bone="block"] { } @scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) { - [data-bone="text"]::after, - [data-bone="text"]::before, - [data-bone="block"], - [data-bone="container"]::before { + [data-bone="text"]:is(:scope, :scope *)::after, + [data-bone="text"]:is(:scope, :scope *)::before, + [data-bone="block"]:is(:scope, :scope *), + [data-bone="container"]:is(:scope, :scope *)::before { animation: none; background: var(--bone-base); background-size: auto; diff --git a/packages/bones/tests/browser/adaptive-colors.test.ts b/packages/bones/tests/browser/adaptive-colors.test.ts index 5dfc2bc..c2814a0 100644 --- a/packages/bones/tests/browser/adaptive-colors.test.ts +++ b/packages/bones/tests/browser/adaptive-colors.test.ts @@ -42,8 +42,7 @@ test("image block bone hides its alt text but keeps the inherited channels", () test("auto.css text leaf bar derives from the inherited text color", () => { // The data-bone-animate="none" wrapper collapses the shimmer gradient to a // solid background-color; without it the bar's paint lives in - // background-image. The attribute must wrap the busy region — its @scope - // does not take effect from the aria-busy element itself. + // background-image. const root = mount( `

some copy

`, ); diff --git a/packages/bones/tests/browser/animation-scope.test.ts b/packages/bones/tests/browser/animation-scope.test.ts new file mode 100644 index 0000000..e88d3f9 --- /dev/null +++ b/packages/bones/tests/browser/animation-scope.test.ts @@ -0,0 +1,90 @@ +import { afterEach, expect, test } from "vite-plus/test"; +import "../../src/css/auto.css"; + +// --------------------------------------------------------------------------- +// BON-17: data-bone-animate must take effect from the element that carries it, +// not only from a wrapper. The animation variants live in @scope blocks, and a +// scoped selector's implicit :scope prefix only matches strict descendants — +// so the attribute was a silent no-op on the marked bone itself and on the +// aria-busy element of an auto region. +// --------------------------------------------------------------------------- + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function mount(html: string): HTMLElement { + document.body.insertAdjacentHTML("beforeend", html); + return document.body.lastElementChild as HTMLElement; +} + +// --- marked bones (bones.css, pulled in via auto.css's @import) ------------- + +test("data-bone-animate on the marked bone itself takes effect", () => { + const root = mount(`
hidden
`); + const bar = getComputedStyle(root.querySelector("span")!, "::after"); + expect(bar.animationName).toBe("bone-shimmer"); +}); + +test("data-bone-animate on a wrapper still animates marked bones", () => { + const root = mount(`
hidden
`); + const bar = getComputedStyle(root.querySelector("span")!, "::after"); + expect(bar.animationName).toBe("bone-pulse"); +}); + +test("a marked bone opts out of a wrapper's animation with its own attribute", () => { + const root = mount( + `
hidden
`, + ); + const bar = getComputedStyle(root.querySelector("span")!, "::after"); + expect(bar.animationName).toBe("none"); +}); + +test("a block bone carrying the attribute animates itself", () => { + const root = mount( + `
avatar
`, + ); + const img = getComputedStyle(root.querySelector("img")!); + expect(img.animationName).toBe("bone-pulse"); +}); + +// --- auto bones (aria-busy regions) ----------------------------------------- + +test("data-bone-animate='none' on the aria-busy element stops the default shimmer", () => { + const root = mount( + `

some copy

`, + ); + const bar = getComputedStyle(root.querySelector("p")!, "::after"); + expect(bar.animationName).toBe("none"); +}); + +test("data-bone-animate='pulse' on the aria-busy element switches its leaves", () => { + const root = mount( + `

some copy

`, + ); + const bar = getComputedStyle(root.querySelector("p")!, "::after"); + expect(bar.animationName).toBe("bone-pulse"); +}); + +test("data-bone-animate='none' on the aria-busy element also stills replaced elements", () => { + const root = mount( + `
avatar
`, + ); + expect(getComputedStyle(root.querySelector("img")!).animationName).toBe("none"); +}); + +test("data-bone-animate on a wrapper still governs an auto region", () => { + const root = mount( + `

some copy

`, + ); + const bar = getComputedStyle(root.querySelector("p")!, "::after"); + expect(bar.animationName).toBe("none"); +}); + +test("an inner value on the aria-busy element beats an outer wrapper", () => { + const root = mount( + `

some copy

`, + ); + const bar = getComputedStyle(root.querySelector("p")!, "::after"); + expect(bar.animationName).toBe("none"); +});