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/adaptive-bone-colors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@camp.dev/bones": minor
---

Bone colors derive from the inherited text color instead of the OS color scheme. `--bone-base` defaults to the text color at 12% opacity and `--bone-highlight` at 6%, and the `prefers-color-scheme` media query is gone. Bones are now visible on any background the surrounding text is readable on. Before this, a dark-mode OS over a page that kept a light canvas painted white bones on white (BON-13). Overriding the custom properties works unchanged. Internally, bones hide their content by zeroing the alpha of `color` so the channels survive into the derivation, and the measured overlay's no-stylesheet fallbacks derive the same way. The stylesheets now use relative color syntax, which is Baseline 2024 (Chrome 119, Safari 18, Firefox 128).
28 changes: 15 additions & 13 deletions apps/docs/content/docs/styling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ All skeleton visuals are controlled by CSS custom properties. Override them glob

## CSS custom properties

| Property | Default (light) | Default (dark) | Description |
| ------------------ | --------------------- | --------------------------- | ------------------------------ |
| `--bone-base` | `rgba(0, 0, 0, 0.12)` | `rgba(255, 255, 255, 0.12)` | Skeleton background color |
| `--bone-highlight` | `rgba(0, 0, 0, 0.06)` | `rgba(255, 255, 255, 0.06)` | Highlight color (for shimmer) |
| `--bone-radius` | `4px` | `4px` | Border radius of skeleton bars |
| `--bone-duration` | `1.5s` | `1.5s` | Animation duration |
| Property | Default | Description |
| ------------------ | ----------------------------- | ------------------------------ |
| `--bone-base` | the text color at 12% opacity | Skeleton background color |
| `--bone-highlight` | the text color at 6% opacity | Highlight color (for shimmer) |
| `--bone-radius` | `4px` | Border radius of skeleton bars |
| `--bone-duration` | `1.5s` | Animation duration |

The color defaults derive from the text color in effect at the skeleton, so bones contrast with whatever background the text around them is readable on. A `#333`-on-white article gets translucent dark bars. The same markup inside a dark card with light text gets translucent light bars.

## Theming

Expand Down Expand Up @@ -41,18 +43,18 @@ Or use a CSS class:

## Dark mode

Dark mode works via `prefers-color-scheme`:
Dark mode needs no configuration. Because bones derive from the text color, a dark theme's light text produces light bones, whether the theme comes from `prefers-color-scheme` or a class-based toggle.

To give dark mode different bones anyway, override the properties in your dark styles:

```css
@media (prefers-color-scheme: dark) {
:root {
--bone-base: rgba(255, 255, 255, 0.12);
--bone-highlight: rgba(255, 255, 255, 0.06);
}
.dark {
--bone-base: rgba(255, 255, 255, 0.2);
--bone-highlight: rgba(255, 255, 255, 0.1);
}
```

Override these in your own dark mode styles if your app uses a class-based dark mode toggle.
One case needs a manual override: text that depends on its background for contrast, such as white text over a hero image. If the image is part of what loads, the derived white bone can vanish against the page behind it. Set `--bone-base` on that section to a color that works without the image.

## Animations

Expand Down
6 changes: 6 additions & 0 deletions packages/bones/sandbox/auto.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
<title>bones auto.css sandbox</title>
<link rel="stylesheet" href="../src/css/auto.css" />
<style>
/* Follow the OS scheme so the sandbox exercises bones on both canvases.
Bone visibility no longer depends on it: colors derive from the text
color (BON-13). */
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, sans-serif;
max-width: 40rem;
Expand Down
5 changes: 3 additions & 2 deletions packages/bones/sandbox/boundary.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
<title>bones-boundary sandbox</title>
<link rel="stylesheet" href="../src/css/auto.css" />
<style>
/* bones.css picks --bone-base from prefers-color-scheme, so the page must
follow the scheme too or dark-mode bones paint white on white. */
/* Follow the OS scheme so the sandbox exercises bones on both canvases.
Bone visibility no longer depends on it: colors derive from the text
color (BON-13). */
:root {
color-scheme: light dark;
}
Expand Down
4 changes: 2 additions & 2 deletions packages/bones/src/css/auto.css
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
select *,
object *
) {
color: transparent;
color: rgb(from currentcolor r g b / 0);
position: relative;
min-width: 4ch;
min-height: 1lh;
Expand Down Expand Up @@ -144,7 +144,7 @@
progress,
meter
):not([data-bone], [data-bone] *, [data-bones-auto="off"], [data-bones-auto="off"] *) {
color: transparent;
color: rgb(from currentcolor r g b / 0);
background-color: var(--bone-base);
border-radius: var(--bone-radius);
border-color: transparent;
Expand Down
21 changes: 10 additions & 11 deletions packages/bones/src/css/bones.css
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
:root {
--bone-base: rgba(0, 0, 0, 0.12);
--bone-highlight: rgba(0, 0, 0, 0.06);
/* Bones derive their color from the text color in effect at the bone, so
they inherit the page's own contrast guarantee on any background in any
scheme (BON-13). currentColor resolves where var() is used, not here.
The alpha reset matters: bones hide their content by zeroing the alpha
of the color property (never with the transparent keyword, which would
discard the channels), and the mix restores it. */
--bone-base: color-mix(in srgb, rgb(from currentcolor r g b / 1) 12%, transparent);
--bone-highlight: color-mix(in srgb, rgb(from currentcolor r g b / 1) 6%, transparent);
--bone-radius: 4px;
--bone-duration: 1.5s;
}

@media (prefers-color-scheme: dark) {
:root {
--bone-base: rgba(255, 255, 255, 0.12);
--bone-highlight: rgba(255, 255, 255, 0.06);
}
}

[data-bone="text"] {
color: transparent;
color: rgb(from currentcolor r g b / 0);
position: relative;
min-width: 4ch;
min-height: 1lh;
Expand Down Expand Up @@ -72,7 +71,7 @@

img[data-bone="block"],
video[data-bone="block"] {
color: transparent;
color: rgb(from currentcolor r g b / 0);
}

[data-bone="container"] {
Expand Down
10 changes: 5 additions & 5 deletions packages/bones/src/element/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const OVERLAY_CSS = `
}
[part~="bone"] {
position: absolute;
background: var(--bone-base, rgba(0, 0, 0, 0.12));
background: var(--bone-base, color-mix(in srgb, rgb(from currentcolor r g b / 1) 12%, transparent));
border-radius: var(--bone-radius, 4px);
}
@keyframes bone-shimmer {
Expand All @@ -46,9 +46,9 @@ const OVERLAY_CSS = `
animation: bone-shimmer var(--bone-duration, 1.5s) ease-in-out infinite;
background: linear-gradient(
90deg,
var(--bone-base, rgba(0, 0, 0, 0.12)) 25%,
var(--bone-highlight, rgba(0, 0, 0, 0.06)) 50%,
var(--bone-base, rgba(0, 0, 0, 0.12)) 75%
var(--bone-base, color-mix(in srgb, rgb(from currentcolor r g b / 1) 12%, transparent)) 25%,
var(--bone-highlight, color-mix(in srgb, rgb(from currentcolor r g b / 1) 6%, transparent)) 50%,
var(--bone-base, color-mix(in srgb, rgb(from currentcolor r g b / 1) 12%, transparent)) 75%
);
background-size: 200% 100%;
}
Expand All @@ -66,7 +66,7 @@ const OVERLAY_CSS = `
[part~="overlay"][data-bone-animate="shimmer"] [part~="bone"],
[part~="overlay"][data-bone-animate="pulse"] [part~="bone"] {
animation: bone-pulse 2s ease-in-out infinite;
background: var(--bone-base, rgba(0, 0, 0, 0.12));
background: var(--bone-base, color-mix(in srgb, rgb(from currentcolor r g b / 1) 12%, transparent));
background-size: auto;
}
}
Expand Down
68 changes: 68 additions & 0 deletions packages/bones/tests/browser/adaptive-colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, expect, test } from "vite-plus/test";
import "../../src/css/auto.css";
import { expectColor, parseColor } from "./expect-color.ts";

// ---------------------------------------------------------------------------
// BON-13: bone colors derive from the inherited text color, not the OS
// scheme. A dark-mode OS over a page that stays light used to paint white
// bones on a white canvas; deriving from the text color inherits the page's
// own contrast guarantee instead. The trap: bones hide their content via the
// color property, so the derivation must survive that (alpha-zeroed
// currentColor, channels intact) rather than read a transparent currentColor.
// ---------------------------------------------------------------------------

afterEach(() => {
document.body.innerHTML = "";
});

function mount(html: string): HTMLElement {
document.body.insertAdjacentHTML("beforeend", html);
return document.body.lastElementChild as HTMLElement;
}

const DARK_PAGE_TEXT = "color: rgb(238, 238, 238)";

test("text bone bar derives from the inherited text color", () => {
const root = mount(`<div style="${DARK_PAGE_TEXT}"><span data-bone="text">hidden</span></div>`);
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(
`<div style="color: rgb(51, 51, 51)"><img data-bone="block" alt="avatar" width="48" height="48" /></div>`,
);
const img = getComputedStyle(root.querySelector("img")!);
// Alt text stays invisible…
expect(parseColor(img.color)[3]).toBe(0);
// …but the bone still paints from the inherited color, not from transparent.
expectColor(img.backgroundColor, [51, 51, 51, 0.12]);
});

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.
const root = mount(
`<div data-bone-animate="none"><section aria-busy="true" style="${DARK_PAGE_TEXT}"><p>some copy</p></section></div>`,
);
const bar = getComputedStyle(root.querySelector("p")!, "::after");
expectColor(bar.backgroundColor, [238, 238, 238, 0.12]);
});

test("auto.css block bone derives from the inherited text color", () => {
const root = mount(
`<div data-bone-animate="none"><section aria-busy="true" style="${DARK_PAGE_TEXT}"><img alt="avatar" width="48" height="48" /></section></div>`,
);
const img = getComputedStyle(root.querySelector("img")!);
expectColor(img.backgroundColor, [238, 238, 238, 0.12]);
});

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(`<div><span data-bone="text">hidden</span></div>`);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expectColor(bar.backgroundColor, [0, 0, 0, 0.12]);
});
20 changes: 20 additions & 0 deletions packages/bones/tests/browser/expect-color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { expect } from "vite-plus/test";

// Chromium serializes the same computed color as "rgba(r, g, b, a)" in the
// light DOM but "color(srgb r g b / a)" inside shadow roots and for relative
// colors. Compare channels, not strings.
export function parseColor(value: string): [number, number, number, number] {
let m = value.match(/^rgba?\((\d+), (\d+), (\d+)(?:, ([\d.]+))?\)$/);
if (m) return [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]];
m = value.match(/^color\(srgb ([\d.]+) ([\d.]+) ([\d.]+)(?: \/ ([\d.]+))?\)$/);
if (m) return [+m[1] * 255, +m[2] * 255, +m[3] * 255, m[4] === undefined ? 1 : +m[4]];
throw new Error(`unrecognized color: ${value}`);
}

export function expectColor(value: string, [r, g, b, a]: [number, number, number, number]): void {
const [vr, vg, vb, va] = parseColor(value);
expect(vr).toBeCloseTo(r, 0);
expect(vg).toBeCloseTo(g, 0);
expect(vb).toBeCloseTo(b, 0);
expect(va).toBeCloseTo(a, 2);
}
30 changes: 30 additions & 0 deletions packages/bones/tests/browser/overlay-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { afterEach, test } from "vite-plus/test";
import "../../src/element/index.ts";
import type { BonesBoundary } from "../../src/element/index.ts";
import { expectColor } from "./expect-color.ts";

// ---------------------------------------------------------------------------
// BON-13, shadow overlay edition: when no stylesheet is loaded, the measured
// bars fall back to the literals baked into the overlay's shadow CSS. Those
// fallbacks must derive from the host's inherited color like the stylesheet
// does — a fixed rgba(0,0,0,…) fallback disappears on a dark page.
// Deliberately no CSS imports in this file.
// ---------------------------------------------------------------------------

afterEach(() => {
document.body.innerHTML = "";
});

test("measured bars without stylesheets derive from the inherited color", () => {
document.body.insertAdjacentHTML(
"beforeend",
`<bones-boundary force precision="measured" transition="none" min-duration="0"
data-bone-animate="none"
style="width: 20ch; font: 16px/1.5 monospace; color: rgb(238, 238, 238);">
<p style="margin: 0">aaaa bbbb</p>
</bones-boundary>`,
);
const el = document.querySelector<BonesBoundary>("bones-boundary")!;
const bar = el.shadowRoot!.querySelector<HTMLElement>('[part~="bone"]')!;
expectColor(getComputedStyle(bar).backgroundColor, [238, 238, 238, 0.12]);
});
Loading