@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/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
+ }
@@ -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;
@@ -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
+ }