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/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 1e86b0e..5ac211d 100644 --- a/packages/bones/src/css/bones.css +++ b/packages/bones/src/css/bones.css @@ -110,13 +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="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="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, [data-bone="block"]:is(:scope, :scope *), [data-bone="container"]:is(:scope, :scope *)::before { animation: bone-shimmer var(--bone-duration) ease-in-out infinite; @@ -128,22 +156,38 @@ video[data-bone="block"] { ); background-size: 200% 100%; } + + @media (prefers-reduced-motion: reduce) { + [data-bone="text"]:is(:scope, :scope *)::after, + [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"])) { [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; } + + @media (prefers-reduced-motion: reduce) { + [data-bone="text"]:is(:scope, :scope *)::after, + [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"])) { [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; @@ -151,9 +195,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..f03799e --- /dev/null +++ b/packages/bones/tests/bones-css.test.ts @@ -0,0 +1,79 @@ +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}`); +} + +/** 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="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("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/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..f46c772 --- /dev/null +++ b/packages/bones/tests/browser/default-animation.test.ts @@ -0,0 +1,48 @@ +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"); +});