@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 +27 -6
- package/dist/css/auto.css +10 -0
- package/dist/element/boundary.d.mts +26 -0
- package/dist/element/boundary.mjs +220 -0
- package/dist/element/index.d.mts +14 -0
- package/dist/element/index.mjs +6 -0
- package/dist/element/measure.mjs +99 -0
- package/dist/element/overlay.mjs +184 -0
- package/dist/react/boundary.d.mts +40 -0
- package/dist/react/boundary.mjs +21 -0
- package/dist/react/index.d.mts +2 -1
- package/dist/react/index.mjs +2 -1
- package/dist/server/bootstrap.d.mts +4 -0
- package/dist/server/bootstrap.mjs +3 -0
- package/dist/server/index.d.mts +17 -0
- package/dist/server/index.mjs +57 -0
- package/package.json +7 -3
- package/src/css/auto.css +10 -0
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`
|
|
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 };
|
package/dist/react/index.d.mts
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/react/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
+
}
|