From 560c48d46981e69211e225e88a3ef290b6972a21 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Fri, 28 Aug 2026 09:43:32 -0400 Subject: [PATCH 1/2] feat!: shimmer marked bones by default (BON-16) Marked bones (data-bone) were static unless a data-bone-animate scope opted in, while auto bones and the measured overlay shimmered by default. A page that mixed an aria-busy region with explicit data-bone markup showed shimmering auto bones beside frozen marked ones. bones.css now carries an unscoped shimmer rule for text, block, and container bones. The shimmer, pulse, and none scopes keep winning at higher specificity, so data-bone-animate="none" is the opt-out. The old reduced-motion rule pulsed the element carrying aria-busy, which stacked an opacity pulse on top of the bar's own animation. It is gone. The default rule and the shimmer and pulse scopes each downgrade to a slow pulse under prefers-reduced-motion: reduce, and none stays still, matching the overlay's contract. Tests: browser tests pin the default and the opt-outs by computed animation name, and a unit test pins the reduced-motion block by text, since the browser runner cannot emulate media features. The adaptive-colors fixtures now set data-bone-animate="none" so the bar's color stays in background-color, as the visual fixtures already did. Screenshot baselines are unchanged. Co-Authored-By: Claude Fable 5 --- .changeset/marked-bones-shimmer-by-default.md | 5 + README.md | 2 +- .../animations-demo/animations-demo.tsx | 5 +- apps/docs/content/docs/examples.mdx | 6 +- apps/docs/content/docs/styling.mdx | 15 +-- packages/bones/README.md | 2 +- packages/bones/src/css/bones.css | 59 ++++++++++-- packages/bones/tests/bones-css.test.ts | 95 +++++++++++++++++++ .../tests/browser/adaptive-colors.test.ts | 14 +-- .../tests/browser/default-animation.test.ts | 54 +++++++++++ 10 files changed, 230 insertions(+), 27 deletions(-) create mode 100644 .changeset/marked-bones-shimmer-by-default.md create mode 100644 packages/bones/tests/bones-css.test.ts create mode 100644 packages/bones/tests/browser/default-animation.test.ts diff --git a/.changeset/marked-bones-shimmer-by-default.md b/.changeset/marked-bones-shimmer-by-default.md new file mode 100644 index 0000000..287f914 --- /dev/null +++ b/.changeset/marked-bones-shimmer-by-default.md @@ -0,0 +1,5 @@ +--- +"@camp.dev/bones": minor +--- + +Marked bones (`data-bone`) now shimmer by default, matching auto bones and the measured overlay. Before this, `bones.css` only animated inside a `data-bone-animate` scope, so a page that mixed an `aria-busy` region with explicit `data-bone` markup showed shimmering auto bones beside frozen marked ones. Set `data-bone-animate="none"` on a bone, a wrapper, or `` to keep skeletons still. `"pulse"` still switches the animation. Under `prefers-reduced-motion: reduce`, the default and the `shimmer` and `pulse` scopes all downgrade to a slow pulse, and `none` stays still. This replaces the old reduced-motion rule, which pulsed the whole `aria-busy` element on top of the bar's own animation. diff --git a/README.md b/README.md index 2b99752..287bd6e 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,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` 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. +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. ## Without React diff --git a/apps/demo/components/animations-demo/animations-demo.tsx b/apps/demo/components/animations-demo/animations-demo.tsx index 4295c49..3207f13 100644 --- a/apps/demo/components/animations-demo/animations-demo.tsx +++ b/apps/demo/components/animations-demo/animations-demo.tsx @@ -9,8 +9,9 @@ export function AnimationsDemo() { title="Animations" description={ <> - Add data-bone-animate to any parent element. Set it on{" "} - <body> for app-wide animation, or scope it to individual sections. + Bones shimmer by default. Set data-bone-animate on any parent element to + switch to pulse or none. Use it on <body> for + the whole app, or scope it to individual sections. } > diff --git a/apps/docs/content/docs/examples.mdx b/apps/docs/content/docs/examples.mdx index 65295d2..0ab1694 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 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. +Skeletons shimmer by default. Set `data-bone-animate` on an element to change the animation for the bones inside it, or to `none` to keep them still. The attribute also works on a bone itself and on the `aria-busy` element of an automatic region. ### Shimmer @@ -183,10 +183,10 @@ Bones uses `pulse` automatically when `prefers-reduced-motion: reduce` is active ### Global animation -Set it on `` to animate every skeleton in your app: +Set it on `` to change every skeleton in your app: ```tsx - + ``` Individual sections can override the global animation: diff --git a/apps/docs/content/docs/styling.mdx b/apps/docs/content/docs/styling.mdx index 8e919b0..e53cd57 100644 --- a/apps/docs/content/docs/styling.mdx +++ b/apps/docs/content/docs/styling.mdx @@ -58,19 +58,20 @@ One case needs a manual override: text that depends on its background for contra ## Animations -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. +Bones shimmer by default. Set `data-bone-animate` on an element to change the animation for 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 +- `shimmer` - horizontal highlight sweep (the default) - `pulse` - gentle opacity fade (also kicks in automatically for `prefers-reduced-motion: reduce`) +- `none` - no animation ```tsx -{/* Animate a section */} -
+{/* Pulse one section */} +
-{/* Animate the entire app */} - +{/* Keep every skeleton in the app still */} + ``` Speed is controlled by `--bone-duration`. Shimmer colors use `--bone-base` and `--bone-highlight`. @@ -78,5 +79,5 @@ Speed is controlled by `--bone-duration`. Shimmer colors use `--bone-base` and ` ## Accessibility - Skeleton elements get `aria-busy="true"` automatically. -- `pulse` animation activates for users who prefer reduced motion. +- `pulse` replaces `shimmer` for users who prefer reduced motion. `none` stays still. - Skeleton pseudo-elements have `pointer-events: none`. diff --git a/packages/bones/README.md b/packages/bones/README.md index c082b47..84450bd 100644 --- a/packages/bones/README.md +++ b/packages/bones/README.md @@ -135,7 +135,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` 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. +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. ## Without React diff --git a/packages/bones/src/css/bones.css b/packages/bones/src/css/bones.css index 1e86b0e..c66ff5c 100644 --- a/packages/bones/src/css/bones.css +++ b/packages/bones/src/css/bones.css @@ -110,10 +110,41 @@ video[data-bone="block"] { } } +/* Marked bones shimmer by default, the same as auto bones and the measured + overlay (BON-16). data-bone-animate="none" is the opt-out; the @scope + blocks below override this at higher specificity, so their order relative + to this rule does not matter. */ +[data-bone="text"]::after, +[data-bone="text"]::before, +[data-bone="block"], +[data-bone="container"]::before { + animation: bone-shimmer var(--bone-duration) ease-in-out infinite; + background: linear-gradient( + 90deg, + var(--bone-base) 25%, + var(--bone-highlight) 50%, + var(--bone-base) 75% + ); + background-size: 200% 100%; +} + +@media (prefers-reduced-motion: reduce) { + [data-bone="text"]::after, + [data-bone="text"]::before, + [data-bone="block"], + [data-bone="container"]::before { + animation: bone-pulse 2s ease-in-out infinite; + background: var(--bone-base); + background-size: auto; + } +} + /* :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). */ + (BON-17). The reduced-motion override sits inside each animated scope so it + matches at the scope's own specificity; "none" has no override because none + still means none. */ @scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) { [data-bone="text"]:is(:scope, :scope *)::after, [data-bone="text"]:is(:scope, :scope *)::before, @@ -128,6 +159,17 @@ video[data-bone="block"] { ); background-size: 200% 100%; } + + @media (prefers-reduced-motion: reduce) { + [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 2s ease-in-out infinite; + background: var(--bone-base); + background-size: auto; + } + } } @scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) { @@ -139,6 +181,15 @@ video[data-bone="block"] { background: var(--bone-base); background-size: auto; } + + @media (prefers-reduced-motion: reduce) { + [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 2s ease-in-out infinite; + } + } } @scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) { @@ -151,9 +202,3 @@ video[data-bone="block"] { background-size: auto; } } - -@media (prefers-reduced-motion: reduce) { - [aria-busy="true"] { - animation: bone-pulse 2s ease-in-out infinite; - } -} diff --git a/packages/bones/tests/bones-css.test.ts b/packages/bones/tests/bones-css.test.ts new file mode 100644 index 0000000..3d9bc8e --- /dev/null +++ b/packages/bones/tests/bones-css.test.ts @@ -0,0 +1,95 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; + +// --------------------------------------------------------------------------- +// bones.css reduced-motion contract +// +// Marked bones shimmer by default (BON-16), and the shimmer/pulse scopes can +// switch that. Under prefers-reduced-motion: reduce every animated path must +// downgrade to a slow pulse, and data-bone-animate="none" must stay still. +// The browser runner cannot emulate media features, so this reads the shipped +// file and pins where the override lives, the same way boundary.test.ts pins +// the overlay's shadow sheet. +// --------------------------------------------------------------------------- + +const css = readFileSync(join(import.meta.dirname, "../src/css/bones.css"), "utf8").replace( + /\/\*[\s\S]*?\*\//g, + "", +); + +const REDUCED = "@media (prefers-reduced-motion: reduce)"; + +/** The brace-balanced block that starts at the first `marker` in `source`. */ +function blockAt(source: string, marker: string): string { + const start = source.indexOf(marker); + if (start === -1) throw new Error(`${marker} not found`); + const open = source.indexOf("{", start); + let depth = 0; + for (let i = open; i < source.length; i++) { + if (source[i] === "{") depth++; + else if (source[i] === "}" && --depth === 0) { + return source + .slice(start, i + 1) + .replace(/\s+/g, " ") + .trim(); + } + } + throw new Error(`unbalanced braces after ${marker}`); +} + +/** Every reduced-motion block in `source`, flattened. */ +function reducedMotionBlocks(source: string): string[] { + const blocks: string[] = []; + let rest = source; + while (rest.includes(REDUCED)) { + const block = blockAt(rest, REDUCED); + blocks.push(block); + rest = rest.slice(rest.indexOf(REDUCED) + REDUCED.length); + } + return blocks; +} + +/** The `@scope` block for one data-bone-animate value. */ +function scopeFor(value: string): string { + return blockAt(css, `@scope ([data-bone-animate="${value}"])`); +} + +describe("bones.css reduced-motion contract", () => { + test("the default shimmer downgrades to pulse for every bone kind", () => { + const outside = css.split("@scope")[0]; + const block = blockAt(outside, REDUCED); + for (const selector of [ + '[data-bone="text"]::after', + '[data-bone="text"]::before', + '[data-bone="block"]', + '[data-bone="container"]::before', + ]) { + expect(block).toContain(selector); + } + expect(block).toContain("animation: bone-pulse"); + }); + + test("the shimmer and pulse scopes downgrade to pulse inside the scope", () => { + for (const value of ["shimmer", "pulse"]) { + const scope = scopeFor(value); + const block = blockAt(scope, REDUCED); + expect(block).toContain(":is(:scope, :scope *)"); + expect(block).toContain("animation: bone-pulse"); + } + }); + + test("the none scope has no reduced-motion override: none still means none", () => { + expect(scopeFor("none")).not.toContain(REDUCED); + }); + + test("no reduced-motion block pulses the aria-busy element itself", () => { + // The old override animated whatever carried aria-busy, which stacked an + // opacity pulse on top of the bar's own animation instead of replacing it. + const blocks = reducedMotionBlocks(css); + expect(blocks.length).toBeGreaterThan(0); + for (const block of blocks) { + expect(block).not.toMatch(/\[aria-busy="true"\]\s*\{/); + } + }); +}); diff --git a/packages/bones/tests/browser/adaptive-colors.test.ts b/packages/bones/tests/browser/adaptive-colors.test.ts index c2814a0..004c38d 100644 --- a/packages/bones/tests/browser/adaptive-colors.test.ts +++ b/packages/bones/tests/browser/adaptive-colors.test.ts @@ -23,14 +23,19 @@ function mount(html: string): HTMLElement { const DARK_PAGE_TEXT = "color: rgb(238, 238, 238)"; test("text bone bar derives from the inherited text color", () => { - const root = mount(`
hidden
`); + // Marked bones shimmer by default (BON-16). The data-bone-animate="none" + // wrapper collapses the gradient to a solid background-color; without it + // the bar's paint lives in background-image. + const root = mount( + `
hidden
`, + ); const bar = getComputedStyle(root.querySelector("span")!, "::after"); expectColor(bar.backgroundColor, [238, 238, 238, 0.12]); }); test("image block bone hides its alt text but keeps the inherited channels", () => { const root = mount( - `
avatar
`, + `
avatar
`, ); const img = getComputedStyle(root.querySelector("img")!); // Alt text stays invisible… @@ -40,9 +45,6 @@ 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. const root = mount( `

some copy

`, ); @@ -61,7 +63,7 @@ test("auto.css block bone derives from the inherited text color", () => { test("default text color keeps today's black-at-12% bones", () => { // Chromium's default color is black, so pages that never set a color get // exactly the value the library shipped before the derivation change. - const root = mount(`
hidden
`); + const root = mount(`
hidden
`); const bar = getComputedStyle(root.querySelector("span")!, "::after"); expectColor(bar.backgroundColor, [0, 0, 0, 0.12]); }); diff --git a/packages/bones/tests/browser/default-animation.test.ts b/packages/bones/tests/browser/default-animation.test.ts new file mode 100644 index 0000000..8bc7983 --- /dev/null +++ b/packages/bones/tests/browser/default-animation.test.ts @@ -0,0 +1,54 @@ +import { afterEach, expect, test } from "vite-plus/test"; +import "../../src/css/bones.css"; + +// --------------------------------------------------------------------------- +// BON-16: marked bones shimmer by default, the same as auto bones and the +// measured overlay. Before this, bones.css only animated inside a +// data-bone-animate scope, so a page mixing an aria-busy region with explicit +// data-bone markup showed shimmering auto bones beside frozen marked ones. +// data-bone-animate="none" is the opt-out. +// --------------------------------------------------------------------------- + +afterEach(() => { + document.body.innerHTML = ""; +}); + +function mount(html: string): HTMLElement { + document.body.insertAdjacentHTML("beforeend", html); + return document.body.lastElementChild as HTMLElement; +} + +test("a text bone shimmers with no data-bone-animate anywhere", () => { + const root = mount(`
hidden
`); + const bar = getComputedStyle(root.querySelector("span")!, "::after"); + expect(bar.animationName).toBe("bone-shimmer"); +}); + +test("a block bone shimmers with no data-bone-animate anywhere", () => { + const root = mount(`
avatar
`); + expect(getComputedStyle(root.querySelector("img")!).animationName).toBe("bone-shimmer"); +}); + +test("a container bone's cover shimmers with no data-bone-animate anywhere", () => { + const root = mount(`

hidden

`); + const cover = getComputedStyle(root.querySelector("[data-bone]")!, "::before"); + expect(cover.animationName).toBe("bone-shimmer"); +}); + +test("data-bone-animate='none' on the bone itself opts out of the default", () => { + const root = mount(`
hidden
`); + const bar = getComputedStyle(root.querySelector("span")!, "::after"); + expect(bar.animationName).toBe("none"); +}); + +test("data-bone-animate='none' on a wrapper opts its bones out of the default", () => { + const root = mount(`
hidden
`); + const bar = getComputedStyle(root.querySelector("span")!, "::after"); + expect(bar.animationName).toBe("none"); +}); + +test("data-bone-animate='pulse' on a wrapper still replaces the default", () => { + const root = mount(`
hidden
`); + const bar = getComputedStyle(root.querySelector("span")!, "::after"); + expect(bar.animationName).toBe("bone-pulse"); +}); From b4c4d9e2eb86a50a3abc04857e1db9b132e50ae6 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Fri, 28 Aug 2026 09:53:23 -0400 Subject: [PATCH 2/2] refactor: drop the dead text ::before animation and tighten the CSS tests Review follow-ups on the BON-16 branch. [data-bone="text"]::before is a zero-width space with no background or size, so animating it paints nothing. It sat in every animation selector list since the second-line bar went away, and the new default copied it into an unconditional rule. Every list now targets ::after only. The aria-busy guard in bones-css.test.ts matched the selector only when it stood alone before a brace. bones.css has no aria-busy selector at all now, so the test asserts that directly and the block-collecting helper goes. default-animation.test.ts drops the pulse-wrapper case, which repeated animation-scope.test.ts verbatim. The auto.css comment that justified its default by bones.css lacking one now gives the real reason: its selectors target unmarked leaves. Co-Authored-By: Claude Fable 5 --- packages/bones/src/css/auto.css | 9 ++++--- packages/bones/src/css/bones.css | 7 ----- packages/bones/tests/bones-css.test.ts | 26 ++++--------------- .../tests/browser/default-animation.test.ts | 6 ----- 4 files changed, 10 insertions(+), 38 deletions(-) diff --git a/packages/bones/src/css/auto.css b/packages/bones/src/css/auto.css index 5e3fac4..dab0ef9 100644 --- a/packages/bones/src/css/auto.css +++ b/packages/bones/src/css/auto.css @@ -283,10 +283,11 @@ } @layer bones-auto { - /* 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. In those scopes, :is(:scope, :scope *) lets the attribute + /* Auto bones default to shimmer. bones.css has its own default, but its + rules match [data-bone] elements only and never the unmarked leaves + targeted here, so the default is restated for them, and the scopes below + mirror bones.css for pages that set the attribute (BON-16). 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"] diff --git a/packages/bones/src/css/bones.css b/packages/bones/src/css/bones.css index c66ff5c..5ac211d 100644 --- a/packages/bones/src/css/bones.css +++ b/packages/bones/src/css/bones.css @@ -115,7 +115,6 @@ video[data-bone="block"] { blocks below override this at higher specificity, so their order relative to this rule does not matter. */ [data-bone="text"]::after, -[data-bone="text"]::before, [data-bone="block"], [data-bone="container"]::before { animation: bone-shimmer var(--bone-duration) ease-in-out infinite; @@ -130,7 +129,6 @@ video[data-bone="block"] { @media (prefers-reduced-motion: reduce) { [data-bone="text"]::after, - [data-bone="text"]::before, [data-bone="block"], [data-bone="container"]::before { animation: bone-pulse 2s ease-in-out infinite; @@ -147,7 +145,6 @@ video[data-bone="block"] { still means none. */ @scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) { [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; @@ -162,7 +159,6 @@ video[data-bone="block"] { @media (prefers-reduced-motion: reduce) { [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 2s ease-in-out infinite; @@ -174,7 +170,6 @@ video[data-bone="block"] { @scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) { [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; @@ -184,7 +179,6 @@ video[data-bone="block"] { @media (prefers-reduced-motion: reduce) { [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 2s ease-in-out infinite; @@ -194,7 +188,6 @@ video[data-bone="block"] { @scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) { [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; diff --git a/packages/bones/tests/bones-css.test.ts b/packages/bones/tests/bones-css.test.ts index 3d9bc8e..f03799e 100644 --- a/packages/bones/tests/bones-css.test.ts +++ b/packages/bones/tests/bones-css.test.ts @@ -38,18 +38,6 @@ function blockAt(source: string, marker: string): string { throw new Error(`unbalanced braces after ${marker}`); } -/** Every reduced-motion block in `source`, flattened. */ -function reducedMotionBlocks(source: string): string[] { - const blocks: string[] = []; - let rest = source; - while (rest.includes(REDUCED)) { - const block = blockAt(rest, REDUCED); - blocks.push(block); - rest = rest.slice(rest.indexOf(REDUCED) + REDUCED.length); - } - return blocks; -} - /** The `@scope` block for one data-bone-animate value. */ function scopeFor(value: string): string { return blockAt(css, `@scope ([data-bone-animate="${value}"])`); @@ -61,7 +49,6 @@ describe("bones.css reduced-motion contract", () => { const block = blockAt(outside, REDUCED); for (const selector of [ '[data-bone="text"]::after', - '[data-bone="text"]::before', '[data-bone="block"]', '[data-bone="container"]::before', ]) { @@ -83,13 +70,10 @@ describe("bones.css reduced-motion contract", () => { expect(scopeFor("none")).not.toContain(REDUCED); }); - test("no reduced-motion block pulses the aria-busy element itself", () => { - // The old override animated whatever carried aria-busy, which stacked an - // opacity pulse on top of the bar's own animation instead of replacing it. - const blocks = reducedMotionBlocks(css); - expect(blocks.length).toBeGreaterThan(0); - for (const block of blocks) { - expect(block).not.toMatch(/\[aria-busy="true"\]\s*\{/); - } + test("nothing animates the aria-busy element itself", () => { + // The old reduced-motion override animated whatever carried aria-busy, + // which stacked an opacity pulse on top of the bar's own animation + // instead of replacing it. Bones target their bars, never the carrier. + expect(css).not.toContain('[aria-busy="true"]'); }); }); diff --git a/packages/bones/tests/browser/default-animation.test.ts b/packages/bones/tests/browser/default-animation.test.ts index 8bc7983..f46c772 100644 --- a/packages/bones/tests/browser/default-animation.test.ts +++ b/packages/bones/tests/browser/default-animation.test.ts @@ -46,9 +46,3 @@ test("data-bone-animate='none' on a wrapper opts its bones out of the default", const bar = getComputedStyle(root.querySelector("span")!, "::after"); expect(bar.animationName).toBe("none"); }); - -test("data-bone-animate='pulse' on a wrapper still replaces the default", () => { - const root = mount(`
hidden
`); - const bar = getComputedStyle(root.querySelector("span")!, "::after"); - expect(bar.animationName).toBe("bone-pulse"); -});