@camp.dev/bones 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,12 +32,14 @@ import "@camp.dev/bones/css";
32
32
 
33
33
  ## Entry points
34
34
 
35
- | Import | Contents |
36
- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
37
- | `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
38
- | `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
39
- | `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
40
- | `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
35
+ | Import | Contents |
36
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
37
+ | `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
38
+ | `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
39
+ | `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
40
+ | `@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. |
41
+ | `@camp.dev/bones/server` | `streamBones` and the wire-protocol primitives: stream a shell with busy boundaries, then flush each region's content out of order as it resolves. |
42
+ | `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
41
43
 
42
44
  React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it.
43
45
 
@@ -129,6 +131,25 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b
129
131
 
130
132
  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.
131
133
 
134
+ ## Without React
135
+
136
+ `<bones-boundary>` manages the loading state for any stack. Set `busy` when a request starts and clear it when the response lands. The element waits 200 ms before showing bones and keeps them for at least 400 ms, then crossfades to content with the View Transitions API where available.
137
+
138
+ ```html
139
+ <script type="module">
140
+ import "@camp.dev/bones/element";
141
+ </script>
142
+
143
+ <bones-boundary busy>
144
+ <h2>Title</h2>
145
+ <p>Body copy.</p>
146
+ </bones-boundary>
147
+ ```
148
+
149
+ `@camp.dev/bones/element` is a bare specifier. A browser cannot resolve it on its own, so this snippet needs a bundler or an import map. To load the element straight from a CDN in a plain HTML file, see the URL form on the [bones-boundary docs page](https://github.com/campdotdev/bones/blob/main/apps/docs/content/docs/api/bones-boundary.mdx).
150
+
151
+ Pair it with `auto.css` for zero-markup skeletons, or with `data-bone` markup from `boneAttributes`. The element is also exported for React as `<BonesBoundary>` from `@camp.dev/bones/react`.
152
+
132
153
  ## Development
133
154
 
134
155
  ```bash
package/dist/css/auto.css CHANGED
@@ -1090,3 +1090,13 @@
1090
1090
  }
1091
1091
  }
1092
1092
  }
1093
+
1094
+ @layer bones-auto {
1095
+ /* precision="measured" hides a boundary's content with inherited
1096
+ visibility from the shadow side. Visibility, unlike display, can be
1097
+ switched back on by a descendant, so the opt-out contract survives
1098
+ measured mode: exempt subtrees stay visible under the overlay. */
1099
+ bones-boundary[data-bones-measured] [data-bones-auto="off"] {
1100
+ visibility: visible;
1101
+ }
1102
+ }
@@ -0,0 +1,26 @@
1
+ //#region src/element/boundary.d.ts
2
+ declare const DEFAULT_DELAY = 200;
3
+ declare const DEFAULT_MIN_DURATION = 400;
4
+ declare const Base: typeof HTMLElement;
5
+ declare class BonesBoundary extends Base {
6
+ #private;
7
+ static readonly observedAttributes: string[];
8
+ get busy(): boolean;
9
+ set busy(value: boolean);
10
+ get force(): boolean;
11
+ set force(value: boolean);
12
+ get delay(): number;
13
+ set delay(value: number);
14
+ get minDuration(): number;
15
+ set minDuration(value: number);
16
+ get transition(): "auto" | "none";
17
+ set transition(value: "auto" | "none");
18
+ get precision(): "css" | "measured";
19
+ set precision(value: "css" | "measured");
20
+ get showing(): boolean;
21
+ connectedCallback(): void;
22
+ disconnectedCallback(): void;
23
+ attributeChangedCallback(name: string): void;
24
+ }
25
+ //#endregion
26
+ export { BonesBoundary, DEFAULT_DELAY, DEFAULT_MIN_DURATION };
@@ -0,0 +1,220 @@
1
+ import { MeasuredOverlay } from "./overlay.mjs";
2
+ //#region src/element/boundary.ts
3
+ const DEFAULT_DELAY = 200;
4
+ const DEFAULT_MIN_DURATION = 400;
5
+ const UPGRADE_PROPERTIES = [
6
+ "busy",
7
+ "force",
8
+ "delay",
9
+ "minDuration",
10
+ "transition",
11
+ "precision"
12
+ ];
13
+ const swallow = () => {};
14
+ function parseMs(value, fallback) {
15
+ if (value === null || value.trim() === "") return fallback;
16
+ const ms = Number(value);
17
+ return Number.isFinite(ms) && ms >= 0 ? ms : fallback;
18
+ }
19
+ const Base = typeof HTMLElement === "undefined" ? class {} : HTMLElement;
20
+ var BonesBoundary = class extends Base {
21
+ static observedAttributes = [
22
+ "busy",
23
+ "force",
24
+ "aria-busy",
25
+ "inert",
26
+ "precision"
27
+ ];
28
+ #state = "idle";
29
+ #timer;
30
+ #connected = false;
31
+ #shownAt = 0;
32
+ #hideToken = 0;
33
+ #writingOutput = false;
34
+ #overlay = new MeasuredOverlay(this);
35
+ get busy() {
36
+ return this.hasAttribute("busy");
37
+ }
38
+ set busy(value) {
39
+ this.toggleAttribute("busy", Boolean(value));
40
+ }
41
+ get force() {
42
+ return this.hasAttribute("force");
43
+ }
44
+ set force(value) {
45
+ this.toggleAttribute("force", Boolean(value));
46
+ }
47
+ get delay() {
48
+ return parseMs(this.getAttribute("delay"), 200);
49
+ }
50
+ set delay(value) {
51
+ if (value === null || value === void 0) this.removeAttribute("delay");
52
+ else this.setAttribute("delay", String(value));
53
+ }
54
+ get minDuration() {
55
+ return parseMs(this.getAttribute("min-duration"), 400);
56
+ }
57
+ set minDuration(value) {
58
+ if (value === null || value === void 0) this.removeAttribute("min-duration");
59
+ else this.setAttribute("min-duration", String(value));
60
+ }
61
+ get transition() {
62
+ return this.getAttribute("transition") === "none" ? "none" : "auto";
63
+ }
64
+ set transition(value) {
65
+ if (value === "none") this.setAttribute("transition", "none");
66
+ else this.removeAttribute("transition");
67
+ }
68
+ get precision() {
69
+ return this.getAttribute("precision") === "measured" ? "measured" : "css";
70
+ }
71
+ set precision(value) {
72
+ if (value === "measured") this.setAttribute("precision", "measured");
73
+ else this.removeAttribute("precision");
74
+ }
75
+ get showing() {
76
+ return this.#state === "showing" || this.#state === "draining" || this.#state === "hiding";
77
+ }
78
+ connectedCallback() {
79
+ for (const name of UPGRADE_PROPERTIES) this.#upgradeProperty(name);
80
+ this.#connected = true;
81
+ this.#syncPrecision();
82
+ if (this.getAttribute("aria-busy") === "true" && !this.showing) {
83
+ this.#show();
84
+ if (!this.busy && !this.force) this.#evaluate();
85
+ return;
86
+ }
87
+ this.#evaluate();
88
+ }
89
+ disconnectedCallback() {
90
+ this.#overlay.pause();
91
+ this.#connected = false;
92
+ this.#clearTimer();
93
+ if (this.#state === "pending") this.#state = "idle";
94
+ else if (this.#state === "hiding") this.#state = "showing";
95
+ }
96
+ attributeChangedCallback(name) {
97
+ if (!this.#connected) return;
98
+ if (name === "aria-busy" || name === "inert") {
99
+ this.#defendOutput();
100
+ return;
101
+ }
102
+ if (name === "precision") {
103
+ this.#syncPrecision();
104
+ return;
105
+ }
106
+ this.#evaluate();
107
+ }
108
+ #defendOutput() {
109
+ if (this.#writingOutput) return;
110
+ if (this.#state !== "showing" && this.#state !== "draining") return;
111
+ if (this.getAttribute("aria-busy") === "true" && this.hasAttribute("inert")) return;
112
+ this.#writeOutput(true);
113
+ }
114
+ #syncPrecision() {
115
+ if (this.precision === "measured") {
116
+ this.#overlay.prepare();
117
+ if (this.showing) this.#overlay.activate();
118
+ } else this.#overlay.deactivate();
119
+ }
120
+ #upgradeProperty(name) {
121
+ if (!Object.prototype.hasOwnProperty.call(this, name)) return;
122
+ const self = this;
123
+ const value = self[name];
124
+ delete self[name];
125
+ self[name] = value;
126
+ }
127
+ #clearTimer() {
128
+ if (this.#timer !== void 0) clearTimeout(this.#timer);
129
+ this.#timer = void 0;
130
+ }
131
+ #evaluate() {
132
+ if (this.force) {
133
+ if (this.#state === "draining" || this.#state === "hiding") this.#resume();
134
+ else if (this.#state !== "showing") this.#show();
135
+ return;
136
+ }
137
+ if (this.busy) {
138
+ if (this.#state === "idle") {
139
+ const delay = this.delay;
140
+ if (delay === 0) this.#show();
141
+ else {
142
+ this.#state = "pending";
143
+ this.#timer = setTimeout(() => this.#show(), delay);
144
+ }
145
+ } else if (this.#state === "draining" || this.#state === "hiding") this.#resume();
146
+ return;
147
+ }
148
+ if (this.#state === "pending") {
149
+ this.#clearTimer();
150
+ this.#state = "idle";
151
+ } else if (this.#state === "showing" || this.#state === "draining") {
152
+ this.#clearTimer();
153
+ const remaining = this.#shownAt + this.minDuration - Date.now();
154
+ if (remaining <= 0) this.#hide();
155
+ else {
156
+ this.#state = "draining";
157
+ this.#timer = setTimeout(() => this.#hide(), remaining);
158
+ }
159
+ }
160
+ }
161
+ #resume() {
162
+ this.#clearTimer();
163
+ this.#state = "showing";
164
+ this.#writeOutput(true);
165
+ }
166
+ #show() {
167
+ this.#clearTimer();
168
+ this.#state = "showing";
169
+ this.#shownAt = Date.now();
170
+ this.#writeOutput(true);
171
+ if (this.precision === "measured") this.#overlay.activate();
172
+ this.dispatchEvent(new CustomEvent("bones:show", {
173
+ bubbles: true,
174
+ composed: true
175
+ }));
176
+ }
177
+ #hide() {
178
+ this.#clearTimer();
179
+ this.#state = "hiding";
180
+ const token = ++this.#hideToken;
181
+ const update = () => {
182
+ if (this.#state !== "hiding" || token !== this.#hideToken) return;
183
+ this.#state = "idle";
184
+ this.#writeOutput(false);
185
+ this.#overlay.deactivate();
186
+ this.dispatchEvent(new CustomEvent("bones:hide", {
187
+ bubbles: true,
188
+ composed: true
189
+ }));
190
+ };
191
+ if (this.#canTransition()) {
192
+ const transition = document.startViewTransition(update);
193
+ transition?.ready?.catch(swallow);
194
+ transition?.updateCallbackDone?.catch(swallow);
195
+ transition?.finished?.catch(swallow);
196
+ } else update();
197
+ }
198
+ #writeOutput(shown) {
199
+ this.#writingOutput = true;
200
+ try {
201
+ if (shown) {
202
+ this.setAttribute("aria-busy", "true");
203
+ this.toggleAttribute("inert", true);
204
+ } else {
205
+ this.removeAttribute("aria-busy");
206
+ this.removeAttribute("inert");
207
+ }
208
+ } finally {
209
+ this.#writingOutput = false;
210
+ }
211
+ }
212
+ #canTransition() {
213
+ if (this.transition === "none") return false;
214
+ if (typeof document.startViewTransition !== "function") return false;
215
+ if (typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return false;
216
+ return true;
217
+ }
218
+ };
219
+ //#endregion
220
+ export { BonesBoundary, DEFAULT_DELAY, DEFAULT_MIN_DURATION };
@@ -0,0 +1,14 @@
1
+ import { BonesBoundary, DEFAULT_DELAY, DEFAULT_MIN_DURATION } from "./boundary.mjs";
2
+
3
+ //#region src/element/index.d.ts
4
+ declare global {
5
+ interface HTMLElementTagNameMap {
6
+ "bones-boundary": BonesBoundary;
7
+ }
8
+ interface HTMLElementEventMap {
9
+ "bones:show": CustomEvent;
10
+ "bones:hide": CustomEvent;
11
+ }
12
+ }
13
+ //#endregion
14
+ export { BonesBoundary, DEFAULT_DELAY, DEFAULT_MIN_DURATION };
@@ -0,0 +1,6 @@
1
+ import { BonesBoundary, DEFAULT_DELAY, DEFAULT_MIN_DURATION } from "./boundary.mjs";
2
+ //#region src/element/index.ts
3
+ if (typeof customElements !== "undefined") if (customElements.get("bones-boundary")) console.warn("bones-boundary is already defined; @camp.dev/bones/element did not register");
4
+ else customElements.define("bones-boundary", BonesBoundary);
5
+ //#endregion
6
+ export { BonesBoundary, DEFAULT_DELAY, DEFAULT_MIN_DURATION };
@@ -0,0 +1,99 @@
1
+ //#region src/element/measure.ts
2
+ const BLOCK_TAGS = new Set([
3
+ "img",
4
+ "svg",
5
+ "video",
6
+ "canvas",
7
+ "picture",
8
+ "iframe",
9
+ "embed",
10
+ "object",
11
+ "audio",
12
+ "button",
13
+ "input",
14
+ "select",
15
+ "textarea",
16
+ "progress",
17
+ "meter"
18
+ ]);
19
+ const TEXT_BAR_SCALE = .55;
20
+ function verticalOverlap(a, b) {
21
+ return Math.min(a.top + a.height, b.top + b.height) - Math.max(a.top, b.top);
22
+ }
23
+ function mergeLineRects(rects) {
24
+ const sorted = [...rects].sort((a, b) => a.top - b.top || a.left - b.left);
25
+ const merged = [];
26
+ for (const rect of sorted) {
27
+ const line = merged.findLast((candidate) => {
28
+ if (verticalOverlap(candidate, rect) < Math.min(candidate.height, rect.height) / 2) return false;
29
+ return rect.left - (candidate.left + candidate.width) <= Math.max(candidate.height, rect.height) / 2;
30
+ });
31
+ if (line === void 0) {
32
+ merged.push({ ...rect });
33
+ continue;
34
+ }
35
+ const right = Math.max(line.left + line.width, rect.left + rect.width);
36
+ const bottom = Math.max(line.top + line.height, rect.top + rect.height);
37
+ line.left = Math.min(line.left, rect.left);
38
+ line.top = Math.min(line.top, rect.top);
39
+ line.width = right - line.left;
40
+ line.height = bottom - line.top;
41
+ }
42
+ return merged;
43
+ }
44
+ function isVisible(rect) {
45
+ return rect.width > 0 && rect.height > 0;
46
+ }
47
+ function toRect(rect) {
48
+ return {
49
+ left: rect.left,
50
+ top: rect.top,
51
+ width: rect.width,
52
+ height: rect.height
53
+ };
54
+ }
55
+ function measureBones(root) {
56
+ const doc = root.ownerDocument;
57
+ const blocks = [];
58
+ const textRects = [];
59
+ const visit = (node) => {
60
+ if (node.nodeType === Node.TEXT_NODE) {
61
+ if (node.data.trim() === "") return;
62
+ const range = doc.createRange();
63
+ range.selectNodeContents(node);
64
+ if (typeof range.getClientRects !== "function") return;
65
+ for (const rect of Array.from(range.getClientRects())) {
66
+ const converted = toRect(rect);
67
+ if (isVisible(converted)) textRects.push(converted);
68
+ }
69
+ return;
70
+ }
71
+ if (node.nodeType !== Node.ELEMENT_NODE) return;
72
+ const el = node;
73
+ if (el.getAttribute("data-bones-auto") === "off") return;
74
+ if (BLOCK_TAGS.has(el.localName)) {
75
+ const rect = toRect(el.getBoundingClientRect());
76
+ if (isVisible(rect)) blocks.push({
77
+ kind: "block",
78
+ ...rect
79
+ });
80
+ return;
81
+ }
82
+ for (const child of node.childNodes) visit(child);
83
+ };
84
+ for (const child of root.childNodes) visit(child);
85
+ const bones = [...blocks];
86
+ for (const line of mergeLineRects(textRects)) {
87
+ const height = line.height * TEXT_BAR_SCALE;
88
+ bones.push({
89
+ kind: "text",
90
+ left: line.left,
91
+ top: line.top + (line.height - height) / 2,
92
+ width: line.width,
93
+ height
94
+ });
95
+ }
96
+ return bones;
97
+ }
98
+ //#endregion
99
+ export { measureBones };
@@ -0,0 +1,184 @@
1
+ import { measureBones } from "./measure.mjs";
2
+ //#region src/element/overlay.ts
3
+ const OVERLAY_CSS = `
4
+ :host([precision="measured"]) {
5
+ display: block;
6
+ position: relative;
7
+ }
8
+ /* Hidden content still lays out, so re-measurement stays valid. Deliberately
9
+ not !important: outer-tree rules beat ::slotted, which is what lets the
10
+ auto.css opt-out rule re-show exempt subtrees (and lets an author
11
+ visibility rule on a direct child win — a documented edge). */
12
+ :host([data-bones-measured]) ::slotted(*) {
13
+ visibility: hidden;
14
+ }
15
+ [part~="overlay"] {
16
+ position: absolute;
17
+ inset: 0;
18
+ pointer-events: none;
19
+ }
20
+ [part~="bone"] {
21
+ position: absolute;
22
+ background: var(--bone-base, rgba(0, 0, 0, 0.12));
23
+ border-radius: var(--bone-radius, 4px);
24
+ }
25
+ @keyframes bone-shimmer {
26
+ 0% { background-position: 200% 0; }
27
+ 100% { background-position: -200% 0; }
28
+ }
29
+ @keyframes bone-pulse {
30
+ 0%, 100% { opacity: 1; }
31
+ 50% { opacity: 0.5; }
32
+ }
33
+ [part~="overlay"]:not([data-bone-animate]) [part~="bone"],
34
+ [part~="overlay"][data-bone-animate="shimmer"] [part~="bone"] {
35
+ animation: bone-shimmer var(--bone-duration, 1.5s) ease-in-out infinite;
36
+ background: linear-gradient(
37
+ 90deg,
38
+ var(--bone-base, rgba(0, 0, 0, 0.12)) 25%,
39
+ var(--bone-highlight, rgba(0, 0, 0, 0.06)) 50%,
40
+ var(--bone-base, rgba(0, 0, 0, 0.12)) 75%
41
+ );
42
+ background-size: 200% 100%;
43
+ }
44
+ [part~="overlay"][data-bone-animate="pulse"] [part~="bone"] {
45
+ animation: bone-pulse var(--bone-duration, 1.5s) ease-in-out infinite;
46
+ }
47
+ [part~="overlay"][data-bone-animate="none"] [part~="bone"] {
48
+ animation: none;
49
+ }
50
+ /* Matches the shimmer/pulse selectors above at equal (0,3,0) specificity so
51
+ this override always wins the cascade instead of losing to source order.
52
+ data-bone-animate="none" is deliberately excluded: none still means none. */
53
+ @media (prefers-reduced-motion: reduce) {
54
+ [part~="overlay"]:not([data-bone-animate]) [part~="bone"],
55
+ [part~="overlay"][data-bone-animate="shimmer"] [part~="bone"],
56
+ [part~="overlay"][data-bone-animate="pulse"] [part~="bone"] {
57
+ animation: bone-pulse 2s ease-in-out infinite;
58
+ background: var(--bone-base, rgba(0, 0, 0, 0.12));
59
+ background-size: auto;
60
+ }
61
+ }
62
+ `;
63
+ let sharedSheet;
64
+ function applyStyles(root) {
65
+ if ("adoptedStyleSheets" in root && typeof CSSStyleSheet !== "undefined" && "replaceSync" in CSSStyleSheet.prototype) {
66
+ if (sharedSheet === void 0) {
67
+ sharedSheet = new CSSStyleSheet();
68
+ sharedSheet.replaceSync(OVERLAY_CSS);
69
+ }
70
+ root.adoptedStyleSheets = [...root.adoptedStyleSheets, sharedSheet];
71
+ return;
72
+ }
73
+ const style = root.ownerDocument.createElement("style");
74
+ style.textContent = OVERLAY_CSS;
75
+ root.append(style);
76
+ }
77
+ var MeasuredOverlay = class {
78
+ #host;
79
+ #container;
80
+ #observer;
81
+ #mutations;
82
+ #active = false;
83
+ #ownsAutoOff = false;
84
+ constructor(host) {
85
+ this.#host = host;
86
+ }
87
+ get active() {
88
+ return this.#active;
89
+ }
90
+ prepare() {
91
+ if (this.#host.shadowRoot) return;
92
+ const root = this.#host.attachShadow({ mode: "open" });
93
+ root.append(this.#host.ownerDocument.createElement("slot"));
94
+ applyStyles(root);
95
+ }
96
+ activate() {
97
+ this.prepare();
98
+ if (!this.#active) {
99
+ this.#active = true;
100
+ if (!this.#host.hasAttribute("data-bones-auto")) {
101
+ this.#host.setAttribute("data-bones-auto", "off");
102
+ this.#ownsAutoOff = true;
103
+ }
104
+ this.#host.setAttribute("data-bones-measured", "");
105
+ }
106
+ if (!this.#renderBars()) {
107
+ this.deactivate();
108
+ return;
109
+ }
110
+ this.#observe();
111
+ }
112
+ deactivate() {
113
+ this.#unobserve();
114
+ this.#container?.remove();
115
+ this.#container = void 0;
116
+ if (!this.#active) return;
117
+ this.#active = false;
118
+ this.#host.removeAttribute("data-bones-measured");
119
+ if (this.#ownsAutoOff) {
120
+ this.#host.removeAttribute("data-bones-auto");
121
+ this.#ownsAutoOff = false;
122
+ }
123
+ }
124
+ pause() {
125
+ this.#unobserve();
126
+ }
127
+ #observe() {
128
+ if (typeof ResizeObserver !== "undefined" && !this.#observer) {
129
+ this.#observer = new ResizeObserver(() => this.#invalidate());
130
+ this.#observer.observe(this.#host);
131
+ }
132
+ if (typeof MutationObserver !== "undefined" && !this.#mutations) {
133
+ this.#mutations = new MutationObserver(() => this.#invalidate());
134
+ this.#mutations.observe(this.#host, {
135
+ childList: true,
136
+ subtree: true,
137
+ characterData: true
138
+ });
139
+ }
140
+ }
141
+ #unobserve() {
142
+ this.#observer?.disconnect();
143
+ this.#observer = void 0;
144
+ this.#mutations?.disconnect();
145
+ this.#mutations = void 0;
146
+ }
147
+ #invalidate() {
148
+ if (this.#active && !this.#renderBars()) this.deactivate();
149
+ }
150
+ #renderBars() {
151
+ const root = this.#host.shadowRoot;
152
+ if (!root) return false;
153
+ const bones = measureBones(this.#host);
154
+ if (bones.length === 0) return false;
155
+ const doc = this.#host.ownerDocument;
156
+ if (!this.#container) {
157
+ this.#container = doc.createElement("div");
158
+ this.#container.setAttribute("part", "overlay");
159
+ this.#container.setAttribute("aria-hidden", "true");
160
+ root.append(this.#container);
161
+ }
162
+ const animate = this.#host.closest("[data-bone-animate]")?.getAttribute("data-bone-animate");
163
+ if (animate) this.#container.setAttribute("data-bone-animate", animate);
164
+ else this.#container.removeAttribute("data-bone-animate");
165
+ const origin = this.#container.getBoundingClientRect();
166
+ const style = getComputedStyle(this.#container);
167
+ const layoutWidth = Number.parseFloat(style.width);
168
+ const layoutHeight = Number.parseFloat(style.height);
169
+ const scaleX = layoutWidth > 0 ? origin.width / layoutWidth : 1;
170
+ const scaleY = layoutHeight > 0 ? origin.height / layoutHeight : 1;
171
+ this.#container.replaceChildren(...bones.map((bone) => {
172
+ const bar = doc.createElement("div");
173
+ bar.setAttribute("part", `bone bone-${bone.kind}`);
174
+ bar.style.left = `${(bone.left - origin.left) / scaleX}px`;
175
+ bar.style.top = `${(bone.top - origin.top) / scaleY}px`;
176
+ bar.style.width = `${bone.width / scaleX}px`;
177
+ bar.style.height = `${bone.height / scaleY}px`;
178
+ return bar;
179
+ }));
180
+ return true;
181
+ }
182
+ };
183
+ //#endregion
184
+ export { MeasuredOverlay };
@@ -0,0 +1,40 @@
1
+ import { BonesBoundary as BonesBoundary$1 } from "../element/boundary.mjs";
2
+ import { HTMLAttributes, ReactNode, Ref } from "react";
3
+
4
+ //#region src/react/boundary.d.ts
5
+ interface ElementAttributes extends HTMLAttributes<HTMLElement> {
6
+ busy?: boolean;
7
+ force?: boolean;
8
+ delay?: number;
9
+ "min-duration"?: number;
10
+ transition?: "auto" | "none";
11
+ precision?: "css" | "measured";
12
+ class?: string;
13
+ ref?: Ref<BonesBoundary$1>;
14
+ [dataAttribute: `data-${string}`]: string | number | boolean | undefined;
15
+ }
16
+ declare module "react" {
17
+ namespace JSX {
18
+ interface IntrinsicElements {
19
+ "bones-boundary": ElementAttributes;
20
+ }
21
+ }
22
+ }
23
+ interface BonesBoundaryProps extends Omit<ElementAttributes, "min-duration" | "inert" | "aria-busy" | "class"> {
24
+ minDuration?: number;
25
+ onShow?: (event: CustomEvent) => void;
26
+ onHide?: (event: CustomEvent) => void;
27
+ }
28
+ declare function BonesBoundary({
29
+ busy,
30
+ force,
31
+ delay,
32
+ minDuration,
33
+ transition,
34
+ onShow,
35
+ onHide,
36
+ children,
37
+ ...rest
38
+ }: BonesBoundaryProps): ReactNode;
39
+ //#endregion
40
+ export { BonesBoundary, BonesBoundaryProps };
@@ -0,0 +1,21 @@
1
+ import { createElement } from "react";
2
+ //#region src/react/boundary.ts
3
+ function BonesBoundary({ busy, force, delay, minDuration, transition, onShow, onHide, children, ...rest }) {
4
+ return createElement("bones-boundary", {
5
+ ...rest,
6
+ busy: busy ? true : void 0,
7
+ force: force ? true : void 0,
8
+ delay,
9
+ "min-duration": minDuration,
10
+ transition,
11
+ ...force ? {
12
+ "aria-busy": "true",
13
+ inert: true
14
+ } : {},
15
+ suppressHydrationWarning: true,
16
+ "onbones:show": onShow,
17
+ "onbones:hide": onHide
18
+ }, children);
19
+ }
20
+ //#endregion
21
+ export { BonesBoundary };
@@ -1,4 +1,5 @@
1
1
  import { BoneOptions, BoneType, MinMax, isMinMax, minMax } from "../core/attributes.mjs";
2
2
  import { CreateBonesOptions, CreateBonesReturn, createBones, forceBones, readPromise } from "./create-bones.mjs";
3
3
  import { Bones, BonesForce } from "./bones.mjs";
4
- export { type BoneOptions, type BoneType, Bones, BonesForce, type CreateBonesOptions, type CreateBonesReturn, type MinMax, createBones, forceBones, isMinMax, minMax, readPromise };
4
+ import { BonesBoundary, BonesBoundaryProps } from "./boundary.mjs";
5
+ export { type BoneOptions, type BoneType, Bones, BonesBoundary, type BonesBoundaryProps, BonesForce, type CreateBonesOptions, type CreateBonesReturn, type MinMax, createBones, forceBones, isMinMax, minMax, readPromise };
@@ -1,4 +1,5 @@
1
1
  import { isMinMax, minMax } from "../core/attributes.mjs";
2
2
  import { createBones, forceBones, readPromise } from "./create-bones.mjs";
3
3
  import { Bones, BonesForce } from "./bones.mjs";
4
- export { Bones, BonesForce, createBones, forceBones, isMinMax, minMax, readPromise };
4
+ import { BonesBoundary } from "./boundary.mjs";
5
+ export { Bones, BonesBoundary, BonesForce, createBones, forceBones, isMinMax, minMax, readPromise };
@@ -0,0 +1,4 @@
1
+ //#region src/server/bootstrap.d.ts
2
+ declare const BOOTSTRAP_SCRIPT = "<script>function __bonesSwap(e,r){var t=document.querySelector('template[data-bones-chunk=\"'+e+'\"]'),n=document.querySelector('[data-bones-slot=\"'+e+'\"]');n?(t&&(n.replaceChildren(t.content),t.remove()),r&&n.setAttribute(\"data-bones-error\",\"\"),\"boolean\"==typeof n.busy?n.busy=!1:(n.removeAttribute(\"busy\"),n.removeAttribute(\"aria-busy\"),n.removeAttribute(\"inert\"))):t&&t.remove()}</script>";
3
+ //#endregion
4
+ export { BOOTSTRAP_SCRIPT };
@@ -0,0 +1,3 @@
1
+ const BOOTSTRAP_SCRIPT = `<script>function __bonesSwap(e,r){var t=document.querySelector('template[data-bones-chunk="'+e+'"]'),n=document.querySelector('[data-bones-slot="'+e+'"]');n?(t&&(n.replaceChildren(t.content),t.remove()),r&&n.setAttribute("data-bones-error",""),"boolean"==typeof n.busy?n.busy=!1:(n.removeAttribute("busy"),n.removeAttribute("aria-busy"),n.removeAttribute("inert"))):t&&t.remove()}<\/script>`;
2
+ //#endregion
3
+ export { BOOTSTRAP_SCRIPT };
@@ -0,0 +1,17 @@
1
+ import { BOOTSTRAP_SCRIPT } from "./bootstrap.mjs";
2
+
3
+ //#region src/server/index.d.ts
4
+ declare function renderBoundary(id: string, fallbackHtml: string, attrs?: string): string;
5
+ declare function renderChunk(id: string, html: string): string;
6
+ declare function renderErrorChunk(id: string, html?: string): string;
7
+ interface StreamBonesOptions {
8
+ /**
9
+ * Renders the error HTML for a rejected slot. Returning undefined (and
10
+ * throwing) falls back to the bare error chunk, which keeps the boundary's
11
+ * fallback children.
12
+ */
13
+ onError?: (id: string, error: unknown) => string | undefined;
14
+ }
15
+ declare function streamBones(shell: string, slots: Record<string, Promise<string>>, options?: StreamBonesOptions): ReadableStream<Uint8Array>;
16
+ //#endregion
17
+ export { BOOTSTRAP_SCRIPT, StreamBonesOptions, renderBoundary, renderChunk, renderErrorChunk, streamBones };
@@ -0,0 +1,57 @@
1
+ import { BOOTSTRAP_SCRIPT } from "./bootstrap.mjs";
2
+ //#region src/server/index.ts
3
+ const ID_PATTERN = /^[A-Za-z0-9_-]+$/;
4
+ function assertId(id) {
5
+ if (!ID_PATTERN.test(id)) throw new Error(`bones slot id ${JSON.stringify(id)} must match [A-Za-z0-9_-]+`);
6
+ }
7
+ function renderBoundary(id, fallbackHtml, attrs) {
8
+ assertId(id);
9
+ return `<bones-boundary busy aria-busy="true" inert data-bones-slot="${id}"${attrs === void 0 || attrs === "" ? "" : ` ${attrs}`}>${fallbackHtml}</bones-boundary>`;
10
+ }
11
+ function renderChunk(id, html) {
12
+ assertId(id);
13
+ return `<template data-bones-chunk="${id}">${html}</template><script>__bonesSwap("${id}")<\/script>`;
14
+ }
15
+ function renderErrorChunk(id, html) {
16
+ assertId(id);
17
+ if (html === void 0) return `<script>__bonesSwap("${id}",1)<\/script>`;
18
+ return `<template data-bones-chunk="${id}">${html}</template><script>__bonesSwap("${id}",1)<\/script>`;
19
+ }
20
+ function streamBones(shell, slots, options = {}) {
21
+ const ids = Object.keys(slots);
22
+ for (const id of ids) assertId(id);
23
+ const encoder = new TextEncoder();
24
+ let cancelled = false;
25
+ return new ReadableStream({
26
+ start(controller) {
27
+ const send = (html) => {
28
+ if (!cancelled) controller.enqueue(encoder.encode(html));
29
+ };
30
+ send(shell + BOOTSTRAP_SCRIPT);
31
+ if (ids.length === 0) {
32
+ controller.close();
33
+ return;
34
+ }
35
+ let pending = ids.length;
36
+ const settle = (chunk) => {
37
+ send(chunk);
38
+ pending -= 1;
39
+ if (pending === 0 && !cancelled) controller.close();
40
+ };
41
+ for (const id of ids) slots[id].then((html) => settle(renderChunk(id, html)), (error) => {
42
+ let html;
43
+ try {
44
+ html = options.onError?.(id, error);
45
+ } catch {
46
+ html = void 0;
47
+ }
48
+ settle(renderErrorChunk(id, html));
49
+ });
50
+ },
51
+ cancel() {
52
+ cancelled = true;
53
+ }
54
+ });
55
+ }
56
+ //#endregion
57
+ export { BOOTSTRAP_SCRIPT, renderBoundary, renderChunk, renderErrorChunk, streamBones };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camp.dev/bones",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Skeleton loaders designed for React Server Components and streaming.",
5
5
  "homepage": "https://github.com/campdotdev/bones#readme",
6
6
  "bugs": {
@@ -18,11 +18,14 @@
18
18
  ],
19
19
  "type": "module",
20
20
  "sideEffects": [
21
- "*.css"
21
+ "*.css",
22
+ "./dist/element/index.mjs"
22
23
  ],
23
24
  "exports": {
24
25
  ".": "./dist/index.mjs",
26
+ "./element": "./dist/element/index.mjs",
25
27
  "./react": "./dist/react/index.mjs",
28
+ "./server": "./dist/server/index.mjs",
26
29
  "./package.json": "./package.json",
27
30
  "./css": {
28
31
  "style": "./src/css/bones.css",
@@ -45,6 +48,7 @@
45
48
  "@typescript/native-preview": "7.0.0-dev.20260328.1",
46
49
  "@vitest/coverage-v8": "^4.1.5",
47
50
  "jsdom": "^29.0.2",
51
+ "playwright": "^1.62.1",
48
52
  "react": "^19.2.5",
49
53
  "typescript": "^6.0.2",
50
54
  "vite-plus": "^0.1.14"
@@ -66,6 +70,6 @@
66
70
  "dev": "vp pack --watch",
67
71
  "test": "vp test",
68
72
  "check": "vp check",
69
- "health": "vp test --coverage && fallow health --root . --coverage coverage/coverage-final.json --format json --quiet"
73
+ "health": "vp test --project unit --coverage && fallow health --root . --coverage coverage/coverage-final.json --format json --quiet"
70
74
  }
71
75
  }
package/src/css/auto.css CHANGED
@@ -1090,3 +1090,13 @@
1090
1090
  }
1091
1091
  }
1092
1092
  }
1093
+
1094
+ @layer bones-auto {
1095
+ /* precision="measured" hides a boundary's content with inherited
1096
+ visibility from the shadow side. Visibility, unlike display, can be
1097
+ switched back on by a descendant, so the opt-out contract survives
1098
+ measured mode: exempt subtrees stay visible under the overlay. */
1099
+ bones-boundary[data-bones-measured] [data-bones-auto="off"] {
1100
+ visibility: visible;
1101
+ }
1102
+ }