@0xgf/boneyard 1.0.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 +32 -0
- package/bin/boneyard.mjs +272 -0
- package/dist/extract.d.ts +15 -0
- package/dist/extract.js +380 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.js +57 -0
- package/dist/layout.d.ts +15 -0
- package/dist/layout.js +256 -0
- package/dist/react.d.ts +107 -0
- package/dist/react.js +146 -0
- package/dist/responsive.d.ts +34 -0
- package/dist/responsive.js +67 -0
- package/dist/runtime.d.ts +17 -0
- package/dist/runtime.js +38 -0
- package/dist/types.d.ts +170 -0
- package/dist/types.js +1 -0
- package/package.json +67 -0
package/dist/layout.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout engine — uses @chenglou/pretext for exact text measurement.
|
|
3
|
+
*
|
|
4
|
+
* Takes a SkeletonDescriptor (developer-defined component structure) and a
|
|
5
|
+
* width, then computes pixel-perfect bone positions using pretext for text
|
|
6
|
+
* and box-model arithmetic for containers.
|
|
7
|
+
*
|
|
8
|
+
* No DOM, no puppeteer, no build step. Describe your component, get bones.
|
|
9
|
+
*/
|
|
10
|
+
import { prepare, layout as pretextLayout } from '@chenglou/pretext';
|
|
11
|
+
function resolveSides(v) {
|
|
12
|
+
if (v === undefined)
|
|
13
|
+
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
14
|
+
if (typeof v === 'number')
|
|
15
|
+
return { top: v, right: v, bottom: v, left: v };
|
|
16
|
+
return { top: v.top ?? 0, right: v.right ?? 0, bottom: v.bottom ?? 0, left: v.left ?? 0 };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Compute skeleton bones from a descriptor at a given width.
|
|
20
|
+
* Uses pretext for all text measurement — no DOM needed.
|
|
21
|
+
*/
|
|
22
|
+
export function computeLayout(desc, width, name = 'component') {
|
|
23
|
+
const bones = [];
|
|
24
|
+
layoutNode(desc, 0, 0, width, bones);
|
|
25
|
+
let maxBottom = 0;
|
|
26
|
+
for (const b of bones) {
|
|
27
|
+
const bottom = b.y + b.h;
|
|
28
|
+
if (bottom > maxBottom)
|
|
29
|
+
maxBottom = bottom;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
name,
|
|
33
|
+
viewportWidth: width,
|
|
34
|
+
width,
|
|
35
|
+
height: round(maxBottom),
|
|
36
|
+
bones,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Recursively layout a node and its children, producing bones.
|
|
41
|
+
* Returns the height consumed (including margin).
|
|
42
|
+
*/
|
|
43
|
+
function layoutNode(desc, offsetX, offsetY, availableWidth, bones) {
|
|
44
|
+
const pad = resolveSides(desc.padding);
|
|
45
|
+
const mar = resolveSides(desc.margin);
|
|
46
|
+
const nodeX = offsetX + mar.left;
|
|
47
|
+
const nodeY = offsetY + mar.top;
|
|
48
|
+
const nodeWidth = clampWidth(desc.width !== undefined ? Math.min(desc.width, availableWidth) : availableWidth, desc.maxWidth, availableWidth);
|
|
49
|
+
const contentX = nodeX + pad.left;
|
|
50
|
+
const contentY = nodeY + pad.top;
|
|
51
|
+
const contentWidth = Math.max(0, nodeWidth - pad.left - pad.right);
|
|
52
|
+
if (isLeaf(desc)) {
|
|
53
|
+
const contentHeight = resolveLeafHeight(desc, contentWidth, pad);
|
|
54
|
+
const totalHeight = contentHeight + pad.top + pad.bottom;
|
|
55
|
+
// For single-line text, use intrinsic text width instead of full container width
|
|
56
|
+
let boneWidth = nodeWidth;
|
|
57
|
+
if (desc.text && desc.font && desc.lineHeight && contentHeight < desc.lineHeight * 1.5) {
|
|
58
|
+
const intrinsic = getIntrinsicWidth(desc, contentWidth);
|
|
59
|
+
boneWidth = Math.min(intrinsic, nodeWidth);
|
|
60
|
+
}
|
|
61
|
+
bones.push({
|
|
62
|
+
x: round(nodeX),
|
|
63
|
+
y: round(nodeY),
|
|
64
|
+
w: round(boneWidth),
|
|
65
|
+
h: round(totalHeight),
|
|
66
|
+
r: desc.borderRadius ?? 8,
|
|
67
|
+
});
|
|
68
|
+
return totalHeight + mar.top + mar.bottom;
|
|
69
|
+
}
|
|
70
|
+
const children = desc.children ?? [];
|
|
71
|
+
let innerHeight;
|
|
72
|
+
const display = desc.display ?? 'block';
|
|
73
|
+
const direction = desc.flexDirection ?? 'row';
|
|
74
|
+
if (display === 'flex' && direction === 'row') {
|
|
75
|
+
innerHeight = layoutFlexRow(desc, children, contentX, contentY, contentWidth, bones);
|
|
76
|
+
}
|
|
77
|
+
else if (display === 'flex' && direction === 'column') {
|
|
78
|
+
innerHeight = layoutFlexColumn(desc, children, contentX, contentY, contentWidth, bones);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
// Block layout with CSS margin collapsing
|
|
82
|
+
let y = 0;
|
|
83
|
+
let prevMarBottom = 0;
|
|
84
|
+
for (let i = 0; i < children.length; i++) {
|
|
85
|
+
const childMar = resolveSides(children[i].margin);
|
|
86
|
+
if (i > 0) {
|
|
87
|
+
// CSS collapses adjacent margins: gap = max(prev bottom, current top)
|
|
88
|
+
y -= Math.min(prevMarBottom, childMar.top);
|
|
89
|
+
}
|
|
90
|
+
y += layoutNode(children[i], contentX, contentY + y, contentWidth, bones);
|
|
91
|
+
prevMarBottom = childMar.bottom;
|
|
92
|
+
}
|
|
93
|
+
innerHeight = y;
|
|
94
|
+
}
|
|
95
|
+
const totalHeight = innerHeight + pad.top + pad.bottom;
|
|
96
|
+
return totalHeight + mar.top + mar.bottom;
|
|
97
|
+
}
|
|
98
|
+
/** Flex column: children stack vertically with gap */
|
|
99
|
+
function layoutFlexColumn(parent, children, contentX, contentY, contentWidth, bones) {
|
|
100
|
+
const gap = parent.rowGap ?? parent.gap ?? 0;
|
|
101
|
+
let y = 0;
|
|
102
|
+
for (let i = 0; i < children.length; i++) {
|
|
103
|
+
const h = layoutNode(children[i], contentX, contentY + y, contentWidth, bones);
|
|
104
|
+
y += h;
|
|
105
|
+
if (i < children.length - 1 && h > 0)
|
|
106
|
+
y += gap;
|
|
107
|
+
}
|
|
108
|
+
return y;
|
|
109
|
+
}
|
|
110
|
+
/** Flex row: two-pass layout with alignment */
|
|
111
|
+
function layoutFlexRow(parent, children, contentX, contentY, contentWidth, bones) {
|
|
112
|
+
if (children.length === 0)
|
|
113
|
+
return 0;
|
|
114
|
+
const gap = parent.columnGap ?? parent.gap ?? 0;
|
|
115
|
+
const justify = parent.justifyContent ?? 'flex-start';
|
|
116
|
+
const align = parent.alignItems ?? 'stretch';
|
|
117
|
+
// Phase 1: compute child widths
|
|
118
|
+
const childWidths = [];
|
|
119
|
+
let totalFixed = 0;
|
|
120
|
+
let flexCount = 0;
|
|
121
|
+
for (const child of children) {
|
|
122
|
+
if (child.width !== undefined) {
|
|
123
|
+
const w = clampWidth(child.width, child.maxWidth, contentWidth);
|
|
124
|
+
childWidths.push(w);
|
|
125
|
+
totalFixed += w;
|
|
126
|
+
}
|
|
127
|
+
else if (isContentSized(child)) {
|
|
128
|
+
let w = getIntrinsicWidth(child, contentWidth);
|
|
129
|
+
w = clampWidth(w, child.maxWidth, contentWidth);
|
|
130
|
+
childWidths.push(w);
|
|
131
|
+
totalFixed += w;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
childWidths.push(-1);
|
|
135
|
+
flexCount++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const totalGaps = Math.max(0, children.length - 1) * gap;
|
|
139
|
+
const remaining = Math.max(0, contentWidth - totalFixed - totalGaps);
|
|
140
|
+
const flexWidth = flexCount > 0 ? remaining / flexCount : 0;
|
|
141
|
+
for (let i = 0; i < childWidths.length; i++) {
|
|
142
|
+
if (childWidths[i] < 0) {
|
|
143
|
+
childWidths[i] = clampWidth(flexWidth, children[i].maxWidth, contentWidth);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Phase 2: measure heights
|
|
147
|
+
const childHeights = [];
|
|
148
|
+
for (let i = 0; i < children.length; i++) {
|
|
149
|
+
const temp = [];
|
|
150
|
+
childHeights.push(layoutNode(children[i], 0, 0, childWidths[i], temp));
|
|
151
|
+
}
|
|
152
|
+
const maxHeight = Math.max(...childHeights, 0);
|
|
153
|
+
// Phase 3: justify-content
|
|
154
|
+
const totalUsed = childWidths.reduce((s, w) => s + w, 0) + totalGaps;
|
|
155
|
+
let xStart = 0;
|
|
156
|
+
let extraGap = 0;
|
|
157
|
+
if (justify === 'flex-end') {
|
|
158
|
+
xStart = Math.max(0, contentWidth - totalUsed);
|
|
159
|
+
}
|
|
160
|
+
else if (justify === 'center') {
|
|
161
|
+
xStart = Math.max(0, (contentWidth - totalUsed) / 2);
|
|
162
|
+
}
|
|
163
|
+
else if (justify === 'space-between' && children.length > 1) {
|
|
164
|
+
const totalChildWidth = childWidths.reduce((s, w) => s + w, 0);
|
|
165
|
+
extraGap = Math.max(0, (contentWidth - totalChildWidth) / (children.length - 1)) - gap;
|
|
166
|
+
}
|
|
167
|
+
// Phase 4: position with alignment
|
|
168
|
+
let x = xStart;
|
|
169
|
+
for (let i = 0; i < children.length; i++) {
|
|
170
|
+
let yOff = 0;
|
|
171
|
+
if (align === 'center')
|
|
172
|
+
yOff = Math.max(0, (maxHeight - childHeights[i]) / 2);
|
|
173
|
+
else if (align === 'flex-end')
|
|
174
|
+
yOff = Math.max(0, maxHeight - childHeights[i]);
|
|
175
|
+
layoutNode(children[i], contentX + x, contentY + yOff, childWidths[i], bones);
|
|
176
|
+
x += childWidths[i];
|
|
177
|
+
if (i < children.length - 1)
|
|
178
|
+
x += gap + extraGap;
|
|
179
|
+
}
|
|
180
|
+
return maxHeight;
|
|
181
|
+
}
|
|
182
|
+
/** Is this a leaf that produces a bone? */
|
|
183
|
+
function isLeaf(desc) {
|
|
184
|
+
if (desc.leaf === true)
|
|
185
|
+
return true;
|
|
186
|
+
if (desc.text !== undefined)
|
|
187
|
+
return true;
|
|
188
|
+
if (desc.height !== undefined && (!desc.children || desc.children.length === 0))
|
|
189
|
+
return true;
|
|
190
|
+
if (desc.aspectRatio !== undefined && (!desc.children || desc.children.length === 0))
|
|
191
|
+
return true;
|
|
192
|
+
if (!desc.children || desc.children.length === 0)
|
|
193
|
+
return false;
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
function isContentSized(child) {
|
|
197
|
+
if (child.width !== undefined)
|
|
198
|
+
return false;
|
|
199
|
+
return child.text !== undefined || child.leaf === true;
|
|
200
|
+
}
|
|
201
|
+
function getIntrinsicWidth(child, maxAvailable) {
|
|
202
|
+
if (child.text && child.font && child.lineHeight) {
|
|
203
|
+
try {
|
|
204
|
+
const pad = resolveSides(child.padding);
|
|
205
|
+
const prepared = prepare(child.text, child.font);
|
|
206
|
+
const singleLine = child.lineHeight * 1.5; // tolerance for font metric differences
|
|
207
|
+
// Binary search for minimum width that keeps text on one line
|
|
208
|
+
let lo = 1, hi = maxAvailable;
|
|
209
|
+
while (hi - lo > 0.5) {
|
|
210
|
+
const mid = (lo + hi) / 2;
|
|
211
|
+
const r = pretextLayout(prepared, mid, child.lineHeight);
|
|
212
|
+
if (r.height <= singleLine)
|
|
213
|
+
hi = mid;
|
|
214
|
+
else
|
|
215
|
+
lo = mid;
|
|
216
|
+
}
|
|
217
|
+
return Math.ceil(hi) + 1 + pad.left + pad.right;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return maxAvailable;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (child.width !== undefined)
|
|
224
|
+
return child.width;
|
|
225
|
+
return maxAvailable;
|
|
226
|
+
}
|
|
227
|
+
/** Compute leaf height — pretext for text, arithmetic for everything else */
|
|
228
|
+
function resolveLeafHeight(desc, contentWidth, pad) {
|
|
229
|
+
if (desc.text && desc.font && desc.lineHeight) {
|
|
230
|
+
try {
|
|
231
|
+
const prepared = prepare(desc.text, desc.font);
|
|
232
|
+
const result = pretextLayout(prepared, contentWidth, desc.lineHeight);
|
|
233
|
+
return result.height;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return desc.lineHeight ?? 20;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (desc.height !== undefined) {
|
|
240
|
+
return Math.max(0, desc.height - pad.top - pad.bottom);
|
|
241
|
+
}
|
|
242
|
+
if (desc.aspectRatio && desc.aspectRatio > 0 && isFinite(desc.aspectRatio)) {
|
|
243
|
+
return contentWidth / desc.aspectRatio;
|
|
244
|
+
}
|
|
245
|
+
return 20;
|
|
246
|
+
}
|
|
247
|
+
function clampWidth(width, maxWidth, parentWidth) {
|
|
248
|
+
if (maxWidth === undefined)
|
|
249
|
+
return width;
|
|
250
|
+
return Math.min(width, maxWidth);
|
|
251
|
+
}
|
|
252
|
+
function round(n) {
|
|
253
|
+
if (!isFinite(n))
|
|
254
|
+
return 0;
|
|
255
|
+
return Math.round(n * 100) / 100;
|
|
256
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { SkeletonResult, ResponsiveBones, SnapshotConfig } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Register pre-generated bones so `<Skeleton name="...">` can auto-resolve them.
|
|
5
|
+
*
|
|
6
|
+
* Called by the generated `registry.js` file (created by `npx boneyard build`).
|
|
7
|
+
* Import it once in your app entry point:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* import './bones/registry'
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* Then every `<Skeleton name="blog-card">` automatically gets its bones — no
|
|
14
|
+
* `initialBones` prop needed.
|
|
15
|
+
*/
|
|
16
|
+
export declare function registerBones(map: Record<string, SkeletonResult | ResponsiveBones>): void;
|
|
17
|
+
export interface SkeletonProps {
|
|
18
|
+
/** When true, shows the skeleton. When false, shows children and extracts layout. */
|
|
19
|
+
loading: boolean;
|
|
20
|
+
/** Your component — rendered when not loading. The skeleton is extracted from it. */
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
/**
|
|
23
|
+
* Name this skeleton. When provided:
|
|
24
|
+
* - After each snapshot, bones are registered to `window.__PRESKEL_BONES[name]`
|
|
25
|
+
* - The `boneyard build` CLI reads this registry to generate bones JSON files
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* <Skeleton name="blog-card" loading={isLoading}>
|
|
29
|
+
* <BlogCard />
|
|
30
|
+
* </Skeleton>
|
|
31
|
+
*
|
|
32
|
+
* Then run: npx boneyard capture http://localhost:3000 --out ./src/bones
|
|
33
|
+
* Which writes: ./src/bones/blog-card.bones.json
|
|
34
|
+
*/
|
|
35
|
+
name?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Pre-generated bones for zero first-load flash.
|
|
38
|
+
* Accepts a single `SkeletonResult` or a `ResponsiveBones` map (from `boneyard build`).
|
|
39
|
+
*
|
|
40
|
+
* - Single: used regardless of viewport width
|
|
41
|
+
* - Responsive: boneyard picks the nearest breakpoint match for the current container width
|
|
42
|
+
*
|
|
43
|
+
* After the first real render, live `snapshotBones` measurements replace the initial bones.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* import blogBones from './src/bones/blog-card.bones.json'
|
|
47
|
+
* <Skeleton loading={isLoading} initialBones={blogBones}>
|
|
48
|
+
* <BlogCard />
|
|
49
|
+
* </Skeleton>
|
|
50
|
+
*/
|
|
51
|
+
initialBones?: SkeletonResult | ResponsiveBones;
|
|
52
|
+
/** Bone color (default: '#e0e0e0') */
|
|
53
|
+
color?: string;
|
|
54
|
+
/** Enable pulse animation (default: true) */
|
|
55
|
+
animate?: boolean;
|
|
56
|
+
/** Additional className for the container */
|
|
57
|
+
className?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Shown on the very first load if no cached bones and no initialBones.
|
|
60
|
+
* Unnecessary when initialBones is provided.
|
|
61
|
+
*/
|
|
62
|
+
fallback?: ReactNode;
|
|
63
|
+
/**
|
|
64
|
+
* Controls how boneyard extracts bones from your component's DOM.
|
|
65
|
+
* Override the default extraction rules to match your design system.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* // Treat <Card> root divs as leaves, skip icons and footers
|
|
69
|
+
* snapshotConfig={{
|
|
70
|
+
* leafTags: ['p', 'h1', 'h2', 'h3', 'li'],
|
|
71
|
+
* excludeSelectors: ['.icon', '[data-no-skeleton]', 'footer'],
|
|
72
|
+
* }}
|
|
73
|
+
*/
|
|
74
|
+
snapshotConfig?: SnapshotConfig;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Wrap any component to get automatic skeleton loading screens.
|
|
78
|
+
*
|
|
79
|
+
* How it works:
|
|
80
|
+
* 1. When loading=false, your children render normally.
|
|
81
|
+
* After paint, boneyard snapshots the exact bone positions from the DOM.
|
|
82
|
+
* 2. When loading=true, the cached bones are replayed as a skeleton overlay.
|
|
83
|
+
* 3. On the very first load (no cache yet):
|
|
84
|
+
* - With `initialBones`: shows pre-generated bones immediately, no flash
|
|
85
|
+
* - Without: shows the `fallback` prop
|
|
86
|
+
*
|
|
87
|
+
* For zero first-load flash, run `npx boneyard capture` to generate initialBones.
|
|
88
|
+
*
|
|
89
|
+
* @example Basic
|
|
90
|
+
* ```tsx
|
|
91
|
+
* import { Skeleton } from '@0xgf/boneyard/react'
|
|
92
|
+
*
|
|
93
|
+
* <Skeleton name="blog-card" loading={isLoading}>
|
|
94
|
+
* <BlogCard data={data} />
|
|
95
|
+
* </Skeleton>
|
|
96
|
+
* ```
|
|
97
|
+
*
|
|
98
|
+
* @example With pre-generated responsive bones (zero flash)
|
|
99
|
+
* ```tsx
|
|
100
|
+
* import blogBones from './src/bones/blog-card.bones.json'
|
|
101
|
+
*
|
|
102
|
+
* <Skeleton name="blog-card" loading={isLoading} initialBones={blogBones}>
|
|
103
|
+
* <BlogCard data={data} />
|
|
104
|
+
* </Skeleton>
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export declare function Skeleton({ loading, children, name, initialBones, color, animate, className, fallback, snapshotConfig, }: SkeletonProps): import("react/jsx-runtime").JSX.Element;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState, useEffect } from 'react';
|
|
3
|
+
import { snapshotBones } from './extract.js';
|
|
4
|
+
// ── Bones registry ──────────────────────────────────────────────────────────
|
|
5
|
+
// Module-level registry populated by registerBones() from the generated registry file.
|
|
6
|
+
// This lets <Skeleton name="x"> auto-resolve bones without an explicit initialBones prop.
|
|
7
|
+
const bonesRegistry = new Map();
|
|
8
|
+
/**
|
|
9
|
+
* Register pre-generated bones so `<Skeleton name="...">` can auto-resolve them.
|
|
10
|
+
*
|
|
11
|
+
* Called by the generated `registry.js` file (created by `npx boneyard build`).
|
|
12
|
+
* Import it once in your app entry point:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import './bones/registry'
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Then every `<Skeleton name="blog-card">` automatically gets its bones — no
|
|
19
|
+
* `initialBones` prop needed.
|
|
20
|
+
*/
|
|
21
|
+
export function registerBones(map) {
|
|
22
|
+
for (const [name, bones] of Object.entries(map)) {
|
|
23
|
+
bonesRegistry.set(name, bones);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Pick the right SkeletonResult from a responsive set for the current width */
|
|
27
|
+
function resolveResponsive(bones, width) {
|
|
28
|
+
if (!('breakpoints' in bones))
|
|
29
|
+
return bones;
|
|
30
|
+
const bps = Object.keys(bones.breakpoints).map(Number).sort((a, b) => a - b);
|
|
31
|
+
if (bps.length === 0)
|
|
32
|
+
return null;
|
|
33
|
+
// Largest breakpoint that fits (same logic as CSS min-width media queries)
|
|
34
|
+
const match = [...bps].reverse().find(bp => width >= bp) ?? bps[0];
|
|
35
|
+
return bones.breakpoints[match] ?? null;
|
|
36
|
+
}
|
|
37
|
+
/** Mix a hex color toward white by `amount` (0–1). */
|
|
38
|
+
function lightenHex(hex, amount) {
|
|
39
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
40
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
41
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
42
|
+
const nr = Math.round(r + (255 - r) * amount);
|
|
43
|
+
const ng = Math.round(g + (255 - g) * amount);
|
|
44
|
+
const nb = Math.round(b + (255 - b) * amount);
|
|
45
|
+
return `#${nr.toString(16).padStart(2, '0')}${ng.toString(16).padStart(2, '0')}${nb.toString(16).padStart(2, '0')}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Wrap any component to get automatic skeleton loading screens.
|
|
49
|
+
*
|
|
50
|
+
* How it works:
|
|
51
|
+
* 1. When loading=false, your children render normally.
|
|
52
|
+
* After paint, boneyard snapshots the exact bone positions from the DOM.
|
|
53
|
+
* 2. When loading=true, the cached bones are replayed as a skeleton overlay.
|
|
54
|
+
* 3. On the very first load (no cache yet):
|
|
55
|
+
* - With `initialBones`: shows pre-generated bones immediately, no flash
|
|
56
|
+
* - Without: shows the `fallback` prop
|
|
57
|
+
*
|
|
58
|
+
* For zero first-load flash, run `npx boneyard capture` to generate initialBones.
|
|
59
|
+
*
|
|
60
|
+
* @example Basic
|
|
61
|
+
* ```tsx
|
|
62
|
+
* import { Skeleton } from '@0xgf/boneyard/react'
|
|
63
|
+
*
|
|
64
|
+
* <Skeleton name="blog-card" loading={isLoading}>
|
|
65
|
+
* <BlogCard data={data} />
|
|
66
|
+
* </Skeleton>
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @example With pre-generated responsive bones (zero flash)
|
|
70
|
+
* ```tsx
|
|
71
|
+
* import blogBones from './src/bones/blog-card.bones.json'
|
|
72
|
+
*
|
|
73
|
+
* <Skeleton name="blog-card" loading={isLoading} initialBones={blogBones}>
|
|
74
|
+
* <BlogCard data={data} />
|
|
75
|
+
* </Skeleton>
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function Skeleton({ loading, children, name, initialBones, color = '#e0e0e0', animate = true, className, fallback, snapshotConfig, }) {
|
|
79
|
+
const containerRef = useRef(null);
|
|
80
|
+
const [cachedBones, setCachedBones] = useState(null);
|
|
81
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
82
|
+
// Track container width so responsive initialBones picks the right breakpoint
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const el = containerRef.current;
|
|
85
|
+
if (!el)
|
|
86
|
+
return;
|
|
87
|
+
const ro = new ResizeObserver(entries => {
|
|
88
|
+
setContainerWidth(Math.round(entries[0]?.contentRect.width ?? 0));
|
|
89
|
+
});
|
|
90
|
+
ro.observe(el);
|
|
91
|
+
setContainerWidth(Math.round(el.getBoundingClientRect().width));
|
|
92
|
+
return () => ro.disconnect();
|
|
93
|
+
}, []);
|
|
94
|
+
// After every non-loading render, snapshot the DOM and update the cache
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
if (loading || !containerRef.current)
|
|
97
|
+
return;
|
|
98
|
+
let raf1, raf2;
|
|
99
|
+
raf1 = requestAnimationFrame(() => {
|
|
100
|
+
raf2 = requestAnimationFrame(() => {
|
|
101
|
+
const el = containerRef.current;
|
|
102
|
+
if (!el)
|
|
103
|
+
return;
|
|
104
|
+
const firstChild = el.firstElementChild;
|
|
105
|
+
if (!firstChild)
|
|
106
|
+
return;
|
|
107
|
+
try {
|
|
108
|
+
const result = snapshotBones(firstChild, name ?? 'component', snapshotConfig);
|
|
109
|
+
setCachedBones(result);
|
|
110
|
+
// Register to global so `boneyard build` CLI can read it
|
|
111
|
+
if (name && typeof window !== 'undefined') {
|
|
112
|
+
const w = window;
|
|
113
|
+
w.__BONEYARD_BONES = w.__BONEYARD_BONES ?? {};
|
|
114
|
+
w.__BONEYARD_BONES[name] = result;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// keep existing cache on error
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
return () => {
|
|
123
|
+
cancelAnimationFrame(raf1);
|
|
124
|
+
cancelAnimationFrame(raf2);
|
|
125
|
+
};
|
|
126
|
+
}, [loading, name]);
|
|
127
|
+
// Active bones: live cache > explicit initialBones > registry lookup
|
|
128
|
+
const effectiveInitialBones = initialBones ?? (name ? bonesRegistry.get(name) : undefined);
|
|
129
|
+
const resolved = !cachedBones && effectiveInitialBones && containerWidth > 0
|
|
130
|
+
? resolveResponsive(effectiveInitialBones, containerWidth)
|
|
131
|
+
: null;
|
|
132
|
+
const activeBones = cachedBones ?? resolved;
|
|
133
|
+
const showSkeleton = loading && activeBones;
|
|
134
|
+
const showFallback = loading && !activeBones;
|
|
135
|
+
return (_jsxs("div", { ref: containerRef, className: className, style: { position: 'relative' }, children: [_jsx("div", { style: showSkeleton ? { visibility: 'hidden' } : undefined, children: showFallback ? fallback : children }), showSkeleton && (_jsx("div", { style: { position: 'absolute', inset: 0 }, children: _jsxs("div", { style: { position: 'relative', width: '100%', height: activeBones.height }, children: [activeBones.bones.map((b, i) => (_jsx("div", { style: {
|
|
136
|
+
position: 'absolute',
|
|
137
|
+
left: b.x,
|
|
138
|
+
top: b.y,
|
|
139
|
+
width: b.w,
|
|
140
|
+
height: b.h,
|
|
141
|
+
borderRadius: typeof b.r === 'string' ? b.r : `${b.r}px`,
|
|
142
|
+
// Container bones are rendered lighter so children stand out on top
|
|
143
|
+
backgroundColor: b.c ? lightenHex(color, 0.45) : color,
|
|
144
|
+
animation: animate ? 'boneyard-pulse 1.8s ease-in-out infinite' : undefined,
|
|
145
|
+
} }, i))), animate && (_jsx("style", { children: `@keyframes boneyard-pulse{0%,100%{background-color:${color}}50%{background-color:${lightenHex(color, 0.3)}}}` }))] }) }))] }));
|
|
146
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResponsiveDescriptor } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Extract a responsive descriptor from a rendered DOM element at multiple widths.
|
|
4
|
+
*
|
|
5
|
+
* Resizes the element's container to each breakpoint width, extracts the
|
|
6
|
+
* skeleton descriptor, and returns a ResponsiveDescriptor mapping.
|
|
7
|
+
*
|
|
8
|
+
* Use this at build time, in a test, or in a dev tool to pre-generate
|
|
9
|
+
* descriptors that work at every breakpoint. Ship the result as JSON.
|
|
10
|
+
*
|
|
11
|
+
* @example Browser dev tool / test:
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { extractResponsive } from '@0xgf/boneyard'
|
|
14
|
+
*
|
|
15
|
+
* // Render your component, then:
|
|
16
|
+
* const el = document.querySelector('.blog-post')!
|
|
17
|
+
* const descriptor = extractResponsive(el, [0, 768, 1024])
|
|
18
|
+
* console.log(JSON.stringify(descriptor, null, 2))
|
|
19
|
+
* // Save as blog-post-skeleton.json
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* @example Playwright / build script:
|
|
23
|
+
* ```ts
|
|
24
|
+
* for (const width of [375, 768, 1280]) {
|
|
25
|
+
* await page.setViewportSize({ width, height: 800 })
|
|
26
|
+
* await page.waitForTimeout(100)
|
|
27
|
+
* const desc = await page.evaluate(() => {
|
|
28
|
+
* const { extractResponsive } = require('boneyard')
|
|
29
|
+
* return extractResponsive(document.querySelector('.card')!)
|
|
30
|
+
* })
|
|
31
|
+
* }
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function extractResponsive(el: Element, breakpoints?: number[]): ResponsiveDescriptor;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { fromElement } from './extract.js';
|
|
2
|
+
/** Default breakpoints: mobile, tablet, desktop */
|
|
3
|
+
const DEFAULT_BREAKPOINTS = [0, 768, 1024];
|
|
4
|
+
/**
|
|
5
|
+
* Extract a responsive descriptor from a rendered DOM element at multiple widths.
|
|
6
|
+
*
|
|
7
|
+
* Resizes the element's container to each breakpoint width, extracts the
|
|
8
|
+
* skeleton descriptor, and returns a ResponsiveDescriptor mapping.
|
|
9
|
+
*
|
|
10
|
+
* Use this at build time, in a test, or in a dev tool to pre-generate
|
|
11
|
+
* descriptors that work at every breakpoint. Ship the result as JSON.
|
|
12
|
+
*
|
|
13
|
+
* @example Browser dev tool / test:
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { extractResponsive } from '@0xgf/boneyard'
|
|
16
|
+
*
|
|
17
|
+
* // Render your component, then:
|
|
18
|
+
* const el = document.querySelector('.blog-post')!
|
|
19
|
+
* const descriptor = extractResponsive(el, [0, 768, 1024])
|
|
20
|
+
* console.log(JSON.stringify(descriptor, null, 2))
|
|
21
|
+
* // Save as blog-post-skeleton.json
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @example Playwright / build script:
|
|
25
|
+
* ```ts
|
|
26
|
+
* for (const width of [375, 768, 1280]) {
|
|
27
|
+
* await page.setViewportSize({ width, height: 800 })
|
|
28
|
+
* await page.waitForTimeout(100)
|
|
29
|
+
* const desc = await page.evaluate(() => {
|
|
30
|
+
* const { extractResponsive } = require('boneyard')
|
|
31
|
+
* return extractResponsive(document.querySelector('.card')!)
|
|
32
|
+
* })
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function extractResponsive(el, breakpoints = DEFAULT_BREAKPOINTS) {
|
|
37
|
+
const container = el.parentElement;
|
|
38
|
+
if (!container) {
|
|
39
|
+
throw new Error('boneyard: element must have a parent to extract responsive descriptors');
|
|
40
|
+
}
|
|
41
|
+
// Save original styles
|
|
42
|
+
const originalWidth = container.style.width;
|
|
43
|
+
const originalOverflow = container.style.overflow;
|
|
44
|
+
const result = {};
|
|
45
|
+
// Prevent layout from overflowing during resize
|
|
46
|
+
container.style.overflow = 'hidden';
|
|
47
|
+
const sorted = [...breakpoints].sort((a, b) => a - b);
|
|
48
|
+
for (const bp of sorted) {
|
|
49
|
+
const targetWidth = bp === 0 ? 375 : bp;
|
|
50
|
+
// Resize container to simulate breakpoint
|
|
51
|
+
container.style.width = `${targetWidth}px`;
|
|
52
|
+
// Force synchronous layout recalc
|
|
53
|
+
void container.offsetHeight;
|
|
54
|
+
try {
|
|
55
|
+
result[bp] = fromElement(el);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Skip breakpoints that fail to extract
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Restore original styles
|
|
62
|
+
container.style.width = originalWidth;
|
|
63
|
+
container.style.overflow = originalOverflow;
|
|
64
|
+
// Force layout to settle back
|
|
65
|
+
void container.offsetHeight;
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime — renders skeleton bones to HTML.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { computeLayout, renderBones } from '@0xgf/boneyard'
|
|
6
|
+
*
|
|
7
|
+
* const skeleton = computeLayout(myDescriptor, containerWidth)
|
|
8
|
+
* element.innerHTML = renderBones(skeleton)
|
|
9
|
+
*/
|
|
10
|
+
import type { Bone, SkeletonResult } from './types.js';
|
|
11
|
+
export type { Bone, SkeletonResult };
|
|
12
|
+
export type { SkeletonDescriptor } from './types.js';
|
|
13
|
+
/**
|
|
14
|
+
* Render bones to an HTML string.
|
|
15
|
+
* Use for SSR, innerHTML, or any HTML-based rendering.
|
|
16
|
+
*/
|
|
17
|
+
export declare function renderBones(skel: SkeletonResult, color?: string, animate?: boolean): string;
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime — renders skeleton bones to HTML.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* import { computeLayout, renderBones } from '@0xgf/boneyard'
|
|
6
|
+
*
|
|
7
|
+
* const skeleton = computeLayout(myDescriptor, containerWidth)
|
|
8
|
+
* element.innerHTML = renderBones(skeleton)
|
|
9
|
+
*/
|
|
10
|
+
/** Mix a hex color toward white by `amount` (0–1). */
|
|
11
|
+
function lightenHex(hex, amount) {
|
|
12
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
13
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
14
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
15
|
+
const nr = Math.round(r + (255 - r) * amount);
|
|
16
|
+
const ng = Math.round(g + (255 - g) * amount);
|
|
17
|
+
const nb = Math.round(b + (255 - b) * amount);
|
|
18
|
+
return `#${nr.toString(16).padStart(2, '0')}${ng.toString(16).padStart(2, '0')}${nb.toString(16).padStart(2, '0')}`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Render bones to an HTML string.
|
|
22
|
+
* Use for SSR, innerHTML, or any HTML-based rendering.
|
|
23
|
+
*/
|
|
24
|
+
export function renderBones(skel, color, animate) {
|
|
25
|
+
const c = color ?? '#e0e0e0';
|
|
26
|
+
const shouldAnimate = animate !== false;
|
|
27
|
+
const lighter = lightenHex(c, 0.3);
|
|
28
|
+
const keyframes = shouldAnimate
|
|
29
|
+
? `<style>.boneyard-bone{animation:boneyard-pulse 1.8s ease-in-out infinite}@keyframes boneyard-pulse{0%,100%{background-color:${c}}50%{background-color:${lighter}}}</style>`
|
|
30
|
+
: '';
|
|
31
|
+
let html = `${keyframes}<div class="boneyard" style="position:relative;width:100%;height:${skel.height}px">`;
|
|
32
|
+
for (const b of skel.bones) {
|
|
33
|
+
const radius = typeof b.r === 'string' ? b.r : `${b.r}px`;
|
|
34
|
+
html += `<div class="boneyard-bone" style="position:absolute;left:${b.x}px;top:${b.y}px;width:${b.w}px;height:${b.h}px;border-radius:${radius};background-color:${c}"></div>`;
|
|
35
|
+
}
|
|
36
|
+
html += '</div>';
|
|
37
|
+
return html;
|
|
38
|
+
}
|