Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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/measured-bones.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@camp.dev/bones": minor
---

Add `precision="measured"` to `<bones-boundary>`: the element measures its rendered content with `Range.getClientRects()` and draws pixel-accurate per-line overlay bones in a shadow root, re-measuring on resize and falling back to the `auto.css` path when there is nothing to measure. The React wrapper gains a matching `precision` prop, and `auto.css` keeps `[data-bones-auto="off"]` subtrees visible under the overlay.
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ import "@camp.dev/bones/css";

## Entry points

| Import | Contents |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
| `@camp.dev/bones/element` | `<bones-boundary>`, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. |
| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
| Import | Contents |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
| `@camp.dev/bones/element` | `<bones-boundary>`, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. `precision="measured"` draws pixel-accurate per-line bones measured from the content. |
| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |

React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it.

Expand Down
32 changes: 24 additions & 8 deletions apps/docs/content/docs/api/bones-boundary.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,14 @@ The second script waits for the definition instead of trusting script order. A c

## Attributes

| Attribute | Property | Type | Default | Description |
| -------------- | ------------- | -------------------- | -------- | ------------------------------------------------------------------ |
| `busy` | `busy` | boolean | absent | The subtree is loading. |
| `force` | `force` | boolean | absent | Show bones now and keep them until removed. Skips both timers. |
| `delay` | `delay` | number (ms) | `200` | How long `busy` must stay set before bones show. |
| `min-duration` | `minDuration` | number (ms) | `400` | Once shown, bones stay at least this long. |
| `transition` | `transition` | `"auto"` or `"none"` | `"auto"` | Whether hiding bones runs inside `document.startViewTransition()`. |
| Attribute | Property | Type | Default | Description |
| -------------- | ------------- | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `busy` | `busy` | boolean | absent | The subtree is loading. |
| `force` | `force` | boolean | absent | Show bones now and keep them until removed. Skips both timers. |
| `delay` | `delay` | number (ms) | `200` | How long `busy` must stay set before bones show. |
| `min-duration` | `minDuration` | number (ms) | `400` | Once shown, bones stay at least this long. |
| `transition` | `transition` | `"auto"` or `"none"` | `"auto"` | Whether hiding bones runs inside `document.startViewTransition()`. |
| `precision` | `precision` | `"css"` or `"measured"` | `"css"` | How bones are drawn while showing. `"measured"` draws per-line overlay bones measured from the rendered content. |

Every property reflects to its attribute. A `delay` or `min-duration` that is missing, negative, or not a number falls back to the default. The read-only `showing` property is `true` while bones are visible.

Expand Down Expand Up @@ -89,7 +90,21 @@ Render the output attributes yourself when you know a region is loading at reque
<bones-boundary busy aria-busy="true" inert>…</bones-boundary>
```

The stylesheet paints bones and the subtree is inert before the element upgrades. On upgrade the element adopts the showing state, and `min-duration` counts from that moment.
The stylesheet paints bones and the subtree is inert before the element upgrades. On upgrade the element adopts the showing state, and `min-duration` counts from that moment. With `precision="measured"`, the leaf bones auto.css painted before JavaScript ran are replaced by measured bars at upgrade time; the swap is the enhancement taking over.

## Measured bones

With `precision="measured"`, the element measures its rendered content while bones show and draws the skeleton from the measurements: one bar per rendered line of text, one block per image or form control. Bars live in a shadow root, so page CSS and frameworks never see them; style them with `::part(bone)`, `::part(bone-text)`, `::part(bone-block)`, and `::part(overlay)`, or through the `--bone-*` custom properties, which inherit into the overlay.

While the overlay shows, the element sets `data-bones-measured` and, unless the author already set it, `data-bones-auto="off"` on itself. It hides the content with inherited `visibility` and re-measures whenever its size changes. When it hides, the attributes the overlay set come off inside the same swap that removes `aria-busy`; an author-set `data-bones-auto` is never touched. If there is nothing to measure — an empty boundary, or an environment without layout — the element stays on the CSS path and `auto.css` draws leaf bones instead.

A measured boundary is `display: block; position: relative` (the overlay needs a positioned block container). This applies from upgrade, not from the first show.

Three edges to know about:

- Content hiding uses a non-`!important` `::slotted` rule. An author rule that sets `visibility` on a direct child of the boundary wins over it and that child stays visible. The same cascade behavior is what lets `auto.css` keep `[data-bones-auto="off"]` subtrees visible under the overlay — without `auto.css` on the page, opted-out subtrees hide with everything else.
- Re-measurement is size-driven. Content that changes while busy without resizing the boundary (a fixed-height container whose children are swapped) keeps its previous bars until the next resize. Content that scrolls inside the boundary (an `overflow: auto` box) also leaves bars at their pre-scroll positions, because scrolling fires no resize.
- Measurement compensates for scale transforms on the boundary or its ancestors, but not for rotation or skew. A rotated boundary draws its bars in the wrong place.

## React

Expand All @@ -116,6 +131,7 @@ function Profile({ loading, user }: { loading: boolean; user?: User }) {
| `delay` | `delay` | |
| `minDuration` | `min-duration` | |
| `transition` | `transition` | |
| `precision` | `precision` | `"css"` or `"measured"`. Enables measured bones (see above). |
| `onShow` | `bones:show` | Listener. Passing a function from a server component is an error, as with `onClick`. |
| `onHide` | `bones:hide` | Same. |

Expand Down
14 changes: 7 additions & 7 deletions packages/bones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,13 @@ import "@camp.dev/bones/css";

## Entry points

| Import | Contents |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
| `@camp.dev/bones/element` | `<bones-boundary>`, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. |
| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
| Import | Contents |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
| `@camp.dev/bones/element` | `<bones-boundary>`, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. `precision="measured"` draws pixel-accurate per-line bones measured from the content. |
| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |

React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it.

Expand Down
85 changes: 85 additions & 0 deletions packages/bones/sandbox/measured.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>measured bones sandbox</title>
<link rel="stylesheet" href="../src/css/auto.css" />
<style>
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, sans-serif;
max-width: 40rem;
margin: 2rem auto;
padding: 0 1rem;
}
img {
display: block;
}
fieldset {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: center;
margin-bottom: 1rem;
}
#frame {
resize: horizontal;
overflow: hidden;
border: 1px dashed light-dark(#ccc, #444);
padding: 1rem;
}
</style>
<!-- Run `vp pack` first, then serve this folder over http:
`python3 -m http.server` in packages/bones, then open
http://localhost:8000/sandbox/measured.html -->
<script type="module" src="../dist/element/index.mjs"></script>
</head>
<body>
<fieldset data-bones-auto="off">
<button id="toggle-busy">Toggle busy</button>
<button id="toggle-force">Toggle force</button>
<label>
precision
<select id="precision">
<option value="measured" selected>measured</option>
<option value="css">css</option>
</select>
</label>
<span>drag the dashed frame's corner to resize while bones show</span>
</fieldset>

<div id="frame">
<bones-boundary id="boundary" precision="measured" delay="0">
<h1>Measured bones</h1>
<p>
This paragraph wraps across several lines so each line gets its own bar, sized and
positioned from the rendered text itself rather than from a leaf-element guess.
</p>
<p>
A shorter second paragraph, with <strong>bold</strong> and <em>italic</em> runs inline.
</p>
<img src="https://placehold.co/120" alt="avatar" width="120" height="120" />
<button>Save</button>
<p data-bones-auto="off">
This opted-out status line stays readable while everything else is bones.
</p>
</bones-boundary>
</div>

<script type="module">
const boundary = document.getElementById("boundary");
document.getElementById("toggle-busy").addEventListener("click", () => {
boundary.busy = !boundary.busy;
});
document.getElementById("toggle-force").addEventListener("click", () => {
boundary.force = !boundary.force;
});
document.getElementById("precision").addEventListener("change", (event) => {
boundary.precision = event.target.value;
});
</script>
</body>
</html>
10 changes: 10 additions & 0 deletions packages/bones/src/css/auto.css
Original file line number Diff line number Diff line change
Expand Up @@ -1090,3 +1090,13 @@
}
}
}

@layer bones-auto {
/* precision="measured" hides a boundary's content with inherited
visibility from the shadow side. Visibility, unlike display, can be
switched back on by a descendant, so the opt-out contract survives
measured mode: exempt subtrees stay visible under the overlay. */
bones-boundary[data-bones-measured] [data-bones-auto="off"] {
visibility: visible;
}
}
Loading
Loading