@alikhalilll/a-smart-skeleton 0.0.0-stage → 2.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +356 -2
  3. package/dist/_chunks/core.js +2314 -0
  4. package/dist/_chunks/core.js.map +1 -0
  5. package/dist/_chunks/index.d.ts +431 -0
  6. package/dist/core/index.cjs +67 -0
  7. package/dist/core/index.d.cts +2 -0
  8. package/dist/core/index.d.ts +2 -0
  9. package/dist/core/index.js +2 -0
  10. package/dist/element/index.cjs +47 -0
  11. package/dist/element/index.cjs.map +1 -0
  12. package/dist/element/index.d.cts +24 -0
  13. package/dist/element/index.d.ts +24 -0
  14. package/dist/element/index.js +46 -0
  15. package/dist/element/index.js.map +1 -0
  16. package/dist/index.cjs +512 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +204 -0
  19. package/dist/index.d.ts +204 -0
  20. package/dist/index.js +441 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/nuxt/index.cjs +36 -0
  23. package/dist/nuxt/index.cjs.map +1 -0
  24. package/dist/nuxt/index.d.cts +30 -0
  25. package/dist/nuxt/index.d.ts +30 -0
  26. package/dist/nuxt/index.js +36 -0
  27. package/dist/nuxt/index.js.map +1 -0
  28. package/dist/resolver/index.cjs +26 -0
  29. package/dist/resolver/index.cjs.map +1 -0
  30. package/dist/resolver/index.d.cts +19 -0
  31. package/dist/resolver/index.d.ts +19 -0
  32. package/dist/resolver/index.js +26 -0
  33. package/dist/resolver/index.js.map +1 -0
  34. package/dist/styles.css +136 -0
  35. package/package.json +144 -3
  36. package/src/components/ASmartSkeleton.vue +228 -0
  37. package/src/components/ASmartSkeletonBox.vue +23 -0
  38. package/src/components/ASmartSkeletonProvider.vue +34 -0
  39. package/src/components/ASmartSkeletonSuspense.vue +142 -0
  40. package/src/composables/useSmartSkeleton.ts +20 -0
  41. package/src/composables/useStyles.ts +8 -0
  42. package/src/core/boot.ts +63 -0
  43. package/src/core/canvas.ts +7 -0
  44. package/src/core/constants.ts +76 -0
  45. package/src/core/debug.ts +154 -0
  46. package/src/core/dev.ts +53 -0
  47. package/src/core/fade.ts +490 -0
  48. package/src/core/ghost.ts +236 -0
  49. package/src/core/images.ts +28 -0
  50. package/src/core/index.ts +71 -0
  51. package/src/core/inline.generated.ts +9 -0
  52. package/src/core/measure.ts +571 -0
  53. package/src/core/memory.ts +146 -0
  54. package/src/core/motion.ts +12 -0
  55. package/src/core/plan.ts +368 -0
  56. package/src/core/reveal.ts +89 -0
  57. package/src/core/reveals.ts +39 -0
  58. package/src/core/shapes.ts +84 -0
  59. package/src/core/shimmer.ts +229 -0
  60. package/src/core/skeleton.ts +43 -0
  61. package/src/core/snapshot.ts +159 -0
  62. package/src/core/styles.ts +187 -0
  63. package/src/core/sweep.ts +203 -0
  64. package/src/core/theme.ts +162 -0
  65. package/src/element/index.ts +59 -0
  66. package/src/index.ts +27 -0
  67. package/src/nuxt/index.ts +59 -0
  68. package/src/resolver/index.ts +35 -0
  69. package/src/types.ts +100 -0
  70. package/src/vue/Skeleton.ts +275 -0
  71. package/src/vue/Suspense.ts +157 -0
  72. package/src/vue/config.ts +78 -0
  73. package/src/vue/index.ts +7 -0
  74. package/web-types.json +256 -0
@@ -0,0 +1,571 @@
1
+ import { textContext } from './canvas';
2
+ import {
3
+ ATTR_BLOCK,
4
+ ATTR_COVERED,
5
+ ATTR_IGNORE,
6
+ ATTR_OWN,
7
+ MEDIA_TAGS,
8
+ TEXT_TAGS,
9
+ } from './constants';
10
+
11
+ /** Captured paint from a container — background / borders / box-shadow — reproduced on
12
+ * the chrome bone so the outline survives shape recall from storage, SSR pre-paint, and
13
+ * any path where the real DOM isn't visible under the layer. */
14
+ export type SkeletonChrome = {
15
+ background?: string;
16
+ borderTop?: string;
17
+ borderRight?: string;
18
+ borderBottom?: string;
19
+ borderLeft?: string;
20
+ boxShadow?: string;
21
+ };
22
+
23
+ /** A box to draw, relative to the layer it was measured against. */
24
+ export type SkeletonRect = {
25
+ /** Which kind of leaf this bone stands in for — exposed as `[ask-rect="<type>"]` so
26
+ * consumers can style text / media / box / chrome bones differently in their own CSS. */
27
+ type: 'text' | 'media' | 'box' | 'chrome';
28
+ x: number;
29
+ y: number;
30
+ width: number;
31
+ height: number;
32
+ radius: string;
33
+ /** Present on `type === 'chrome'` bones — the container's own paint, captured verbatim from `getComputedStyle`. */
34
+ chrome?: SkeletonChrome;
35
+ };
36
+
37
+ type Rect = { left: number; right: number; top: number; bottom: number };
38
+
39
+ // Corner radii in px: top left, top right, bottom right, bottom left
40
+ type Corners = [number, number, number, number];
41
+
42
+ // Overflow a box hides, which a bone must not paint past, with the corners it rounds; null while nothing clips
43
+ type Clip = (Rect & { corners: Corners }) | null;
44
+
45
+ const intersect = (clip: Clip, rect: Rect): Rect =>
46
+ clip
47
+ ? {
48
+ left: Math.max(clip.left, rect.left),
49
+ right: Math.min(clip.right, rect.right),
50
+ top: Math.max(clip.top, rect.top),
51
+ bottom: Math.min(clip.bottom, rect.bottom),
52
+ }
53
+ : rect;
54
+
55
+ const cornersOf = (rect: Rect): [number, number][] => [
56
+ [rect.left, rect.top],
57
+ [rect.right, rect.top],
58
+ [rect.right, rect.bottom],
59
+ [rect.left, rect.bottom],
60
+ ];
61
+
62
+ const near = (a: [number, number], b: [number, number]) =>
63
+ Math.abs(a[0] - b[0]) < 1 && Math.abs(a[1] - b[1]) < 1;
64
+
65
+ function radiiOf(style: CSSStyleDeclaration, rect: Rect): Corners {
66
+ const size = Math.min(rect.right - rect.left, rect.bottom - rect.top);
67
+ const px = (value: string) =>
68
+ value.endsWith('%') ? (parseFloat(value) / 100) * size : parseFloat(value) || 0;
69
+ return [
70
+ px(style.borderTopLeftRadius),
71
+ px(style.borderTopRightRadius),
72
+ px(style.borderBottomRightRadius),
73
+ px(style.borderBottomLeftRadius),
74
+ ];
75
+ }
76
+
77
+ // A clip within a clip: each corner rounds as whichever box's corner it is
78
+ function clipWithin(clip: Clip, rect: Rect, style: CSSStyleDeclaration): Clip {
79
+ const box = intersect(clip, rect);
80
+ const own = radiiOf(style, rect);
81
+ const outer = clip ? cornersOf(clip) : [];
82
+ const ownCorners = cornersOf(rect);
83
+ const corners = cornersOf(box).map((corner, i) =>
84
+ near(corner, ownCorners[i]!) ? own[i]! : clip && near(corner, outer[i]!) ? clip.corners[i]! : 0
85
+ ) as Corners;
86
+ // Sides by name: a DOMRect's are getters, which spreading leaves behind
87
+ const { left, right, top, bottom } = box;
88
+ return { left, right, top, bottom, corners };
89
+ }
90
+
91
+ // A block in a rounded corner of what clips it is cut by that corner, so takes it
92
+ function roundedBy(clip: Clip, box: Rect, own: string, style: CSSStyleDeclaration) {
93
+ if (!clip) return own;
94
+ const clipCorners = cornersOf(clip);
95
+ const touching = cornersOf(box).map(
96
+ (corner, i) => clip.corners[i]! > 0 && near(corner, clipCorners[i]!)
97
+ );
98
+ if (!touching.some(Boolean)) return own;
99
+ const mine = radiiOf(style, box);
100
+ return touching
101
+ .map((touches, i) => (touches ? Math.max(mine[i]!, clip.corners[i]!) : mine[i]!))
102
+ .map((r) => `${r}px`)
103
+ .join(' ');
104
+ }
105
+
106
+ // Clipped to a sliver, it isn't seen: a visually hidden label, say, cut to a 1px box
107
+ const clipped = (rect: Rect, clip: Clip): Rect | null => {
108
+ const box = intersect(clip, rect);
109
+ return box.right - box.left >= 2 && box.bottom - box.top >= 2 ? box : null;
110
+ };
111
+
112
+ type Line = {
113
+ left: number;
114
+ right: number;
115
+ top: number;
116
+ bottom: number;
117
+ fontSize: number;
118
+ /** Where the bar goes, by the typography rules — within the line's box. */
119
+ barTop: number;
120
+ barBottom: number;
121
+ /** The theme's `--ask-radius`, if it sets one. */
122
+ radius: string;
123
+ };
124
+
125
+ /** The font line each line of text is drawn from. */
126
+ export type TextTop = 'ascender' | 'cap-height' | 'x-height';
127
+ /** The font line each line of text is drawn down to. */
128
+ export type TextBottom = 'baseline' | 'descender';
129
+
130
+ export const DEFAULT_TEXT = {
131
+ top: 'ascender',
132
+ bottom: 'descender',
133
+ } as const satisfies { top: TextTop; bottom: TextBottom };
134
+
135
+ type Metrics = {
136
+ ascent: number; // the font's own box, above and below the baseline
137
+ descent: number;
138
+ ascender: number; // ink of b, d, h, l
139
+ capHeight: number;
140
+ xHeight: number;
141
+ descender: number; // ink of g, p, y
142
+ };
143
+
144
+ const metricsByFont = new Map<string, Metrics | null>();
145
+
146
+ // A range has no layout in jsdom
147
+ const lineRects = (range: Range) => [...(range.getClientRects?.() ?? [])];
148
+
149
+ function metricsOf(style: CSSStyleDeclaration): Metrics | null {
150
+ const font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
151
+ if (metricsByFont.has(font)) return metricsByFont.get(font)!;
152
+
153
+ const context = textContext();
154
+ let metrics: Metrics | null = null;
155
+ if (context) {
156
+ context.font = font;
157
+ const box = context.measureText('H');
158
+ const up = (text: string) => context!.measureText(text).actualBoundingBoxAscent;
159
+ metrics = {
160
+ ascent: box.fontBoundingBoxAscent,
161
+ descent: box.fontBoundingBoxDescent,
162
+ ascender: up('bdfhkl'),
163
+ capHeight: box.actualBoundingBoxAscent,
164
+ xHeight: up('xz'),
165
+ descender: context.measureText('gjpqy').actualBoundingBoxDescent,
166
+ };
167
+ }
168
+ metricsByFont.set(font, metrics);
169
+ return metrics;
170
+ }
171
+
172
+ const TOPS: Record<TextTop, keyof Metrics> = {
173
+ ascender: 'ascender',
174
+ 'cap-height': 'capHeight',
175
+ 'x-height': 'xHeight',
176
+ };
177
+
178
+ const rule = <T extends string>(
179
+ style: CSSStyleDeclaration,
180
+ name: string,
181
+ allowed: readonly T[],
182
+ fallback: T
183
+ ): T => {
184
+ const value = style.getPropertyValue(name).trim() as T;
185
+ return allowed.includes(value) ? value : fallback;
186
+ };
187
+
188
+ // Between the font lines `--ask-text-top` and `--ask-text-bottom` name, the baseline half-leading in
189
+ function barOf(rect: Rect, style: CSSStyleDeclaration, fontSize: number) {
190
+ const metrics = metricsOf(style);
191
+ if (!metrics) {
192
+ // No canvas to read the type with: a bar centred in the line
193
+ const height = Math.min(fontSize * 0.72, rect.bottom - rect.top);
194
+ const middle = (rect.top + rect.bottom) / 2;
195
+ return { barTop: middle - height / 2, barBottom: middle + height / 2 };
196
+ }
197
+
198
+ const top = rule(style, '--ask-text-top', Object.keys(TOPS) as TextTop[], DEFAULT_TEXT.top);
199
+ const bottom = rule(
200
+ style,
201
+ '--ask-text-bottom',
202
+ ['baseline', 'descender'] as const,
203
+ DEFAULT_TEXT.bottom
204
+ );
205
+
206
+ const box = metrics.ascent + metrics.descent;
207
+ const baseline = rect.top + (rect.bottom - rect.top - box) / 2 + metrics.ascent;
208
+ return {
209
+ barTop: baseline - metrics[TOPS[top]],
210
+ barBottom: baseline + (bottom === 'descender' ? metrics.descender : 0),
211
+ };
212
+ }
213
+
214
+ const MEDIA = new Set(MEDIA_TAGS);
215
+ const TEXT = new Set(TEXT_TAGS);
216
+
217
+ const LINE_GAP = 0.4; // of the font size — wider than a space, so one line of text stays one bar
218
+
219
+ // matches a zero alpha in both `rgba(0, 0, 0, 0)` and `color(srgb 0 0 0 / 0)`
220
+ const TRANSPARENT = /[,/]\s*0\)$/;
221
+
222
+ // Unset reads as "" outside a browser engine, which is no paint either
223
+ const isSet = (value: string, none: string) => value !== '' && value !== none;
224
+
225
+ // All the way round: a rule along one side divides, it doesn't make a box a chip
226
+ const SIDES = ['Top', 'Right', 'Bottom', 'Left'] as const;
227
+ const bordered = (style: CSSStyleDeclaration) =>
228
+ SIDES.every(
229
+ (side) =>
230
+ isSet(style[`border${side}Style`], 'none') && isSet(style[`border${side}Width`], '0px')
231
+ );
232
+
233
+ // Dividers (`border-t` on a footer, `border-b` on a heading) still need to show
234
+ // up in the skeleton, even though they don't make the container a solid block.
235
+ // This looser check feeds chrome capture; `bordered` above remains what
236
+ // `isPainted` reads so single-side borders keep their "divider" semantics for
237
+ // the isBlock classifier.
238
+ const anyBordered = (style: CSSStyleDeclaration) =>
239
+ SIDES.some(
240
+ (side) =>
241
+ isSet(style[`border${side}Style`], 'none') && isSet(style[`border${side}Width`], '0px')
242
+ );
243
+
244
+ // An inset hairline along one side, drawn as a shadow, is a divider like a one-sided border
245
+ const isRule = (shadow: string) => {
246
+ const [x = 0, y = 0, blur = 0, spread = 0] = (
247
+ shadow.replace(/rgba?\([^)]*\)|#\w+/g, '').match(/-?[\d.]+px/g) ?? []
248
+ ).map(parseFloat);
249
+ return (
250
+ shadow.includes('inset') && !blur && !spread && (x === 0) !== (y === 0) && Math.abs(x + y) <= 2
251
+ );
252
+ };
253
+
254
+ const shadowPaints = (boxShadow: string) =>
255
+ isSet(boxShadow, 'none') && !boxShadow.split(/,(?![^(]*\))/).every(isRule);
256
+
257
+ /** Splits `background-image` on its top-level commas (respecting nested parens, which
258
+ * gradient stops abuse heavily) and drops any layer that starts with `url(`. Returns
259
+ * an empty string when nothing survives — the caller treats that as "no background". */
260
+ function stripUrlLayers(bgImage: string): string {
261
+ const layers: string[] = [];
262
+ let depth = 0;
263
+ let start = 0;
264
+ for (let i = 0; i < bgImage.length; i++) {
265
+ const ch = bgImage[i];
266
+ if (ch === '(') depth++;
267
+ else if (ch === ')') depth--;
268
+ else if (ch === ',' && depth === 0) {
269
+ layers.push(bgImage.slice(start, i));
270
+ start = i + 1;
271
+ }
272
+ }
273
+ layers.push(bgImage.slice(start));
274
+ return layers
275
+ .map((l) => l.trim())
276
+ .filter((l) => l && !l.startsWith('url('))
277
+ .join(', ');
278
+ }
279
+
280
+ const isPainted = (style: CSSStyleDeclaration) =>
281
+ (isSet(style.backgroundColor, 'transparent') && !TRANSPARENT.test(style.backgroundColor)) ||
282
+ isSet(style.backgroundImage, 'none') ||
283
+ shadowPaints(style.boxShadow) ||
284
+ bordered(style);
285
+
286
+ /** Reads the painted chrome off a container so the skeleton can reproduce it exactly —
287
+ * background, four border sides, and box-shadow, but only the properties that are set.
288
+ * Returns `undefined` when nothing paints, so callers can skip emitting a chrome bone. */
289
+ function captureChrome(style: CSSStyleDeclaration): SkeletonChrome | undefined {
290
+ const chrome: SkeletonChrome = {};
291
+
292
+ const bg = style.backgroundColor;
293
+ if (isSet(bg, 'transparent') && !TRANSPARENT.test(bg)) chrome.background = bg;
294
+ // Keep gradient layers (Tailwind ring utilities, shadcn chrome, etc.) but drop
295
+ // `url(...)` layers — a hero bitmap re-downloaded into the skeleton is wasteful
296
+ // and almost always wrong.
297
+ const bgImage = style.backgroundImage;
298
+ if (isSet(bgImage, 'none')) {
299
+ const gradientsOnly = stripUrlLayers(bgImage);
300
+ if (gradientsOnly) {
301
+ chrome.background = chrome.background
302
+ ? `${gradientsOnly}, ${chrome.background}`
303
+ : gradientsOnly;
304
+ }
305
+ }
306
+
307
+ for (const side of SIDES) {
308
+ const width = style[`border${side}Width`];
309
+ const styleProp = style[`border${side}Style`];
310
+ const color = style[`border${side}Color`];
311
+ if (isSet(styleProp, 'none') && isSet(width, '0px') && parseFloat(width) > 0) {
312
+ const key = `border${side}` as 'borderTop' | 'borderRight' | 'borderBottom' | 'borderLeft';
313
+ chrome[key] = `${width} ${styleProp} ${color}`;
314
+ }
315
+ }
316
+
317
+ if (shadowPaints(style.boxShadow)) chrome.boxShadow = style.boxShadow;
318
+
319
+ return Object.keys(chrome).length ? chrome : undefined;
320
+ }
321
+
322
+ const isMedia = (element: Element) => MEDIA.has(element.localName);
323
+
324
+ const isEmptyText = (element: Element) =>
325
+ TEXT.has(element.localName) && !element.firstElementChild && !element.textContent?.trim();
326
+
327
+ /** Labels inside: one per piece of text, however many lines it wraps to or nodes it's split into. */
328
+ const countLabels = (element: Element) => {
329
+ const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
330
+ const range = document.createRange();
331
+ const pieces: DOMRect[][] = [];
332
+ let nodes = 0;
333
+ while (walker.nextNode()) {
334
+ const node = walker.currentNode;
335
+ if (node.nodeType === Node.TEXT_NODE) {
336
+ const parent = node.parentElement;
337
+ if (!node.textContent?.trim() || !parent) continue;
338
+ nodes++;
339
+ // Visually hidden text, read to screen readers only
340
+ const box = parent.getBoundingClientRect();
341
+ if (box.width <= 1 || box.height <= 1) continue;
342
+ range.selectNodeContents(node);
343
+ pieces.push(lineRects(range).filter((r) => r.width || r.height));
344
+ } else if (isEmptyText(node as Element)) {
345
+ nodes++;
346
+ pieces.push([...(node as Element).getClientRects()]);
347
+ }
348
+ }
349
+ // No layout to go by
350
+ if (!pieces.some((rects) => rects.length)) return nodes;
351
+
352
+ const reach = (parseFloat(getComputedStyle(element).fontSize) || 16) * LINE_GAP;
353
+ const beside = (a: DOMRect, b: DOMRect) =>
354
+ Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top) >= Math.min(a.height, b.height) / 2 &&
355
+ Math.max(a.left, b.left) - Math.min(a.right, b.right) <= reach;
356
+
357
+ // Pieces joined into labels: each starts as its own, and merges with any beside it
358
+ const label = pieces.map((_, i) => i);
359
+ const find = (i: number): number => (label[i] === i ? i : (label[i] = find(label[i]!)));
360
+ for (let i = 0; i < pieces.length; i++) {
361
+ for (let j = i + 1; j < pieces.length; j++) {
362
+ if (pieces[i]!.some((a) => pieces[j]!.some((b) => beside(a, b)))) {
363
+ label[find(j)] = find(i);
364
+ }
365
+ }
366
+ }
367
+ return new Set(pieces.map((_, i) => find(i))).size;
368
+ };
369
+
370
+ // A painted box around one label (a button, a chip) fills as a block, a card keeps its surface
371
+ const isBlock = (element: Element, style: CSSStyleDeclaration) =>
372
+ element.hasAttribute(ATTR_BLOCK) ||
373
+ isMedia(element) ||
374
+ (isPainted(style) && countLabels(element) <= 1);
375
+
376
+ // Media clipped by a rounded parent of the same size, like an avatar, takes the parent's corners
377
+ function radiusOf(element: Element, style: CSSStyleDeclaration) {
378
+ if (parseFloat(style.borderRadius) || !element.parentElement) {
379
+ return style.borderRadius || '0px';
380
+ }
381
+ const parent = element.parentElement;
382
+ const parentStyle = getComputedStyle(parent);
383
+ const a = element.getBoundingClientRect();
384
+ const b = parent.getBoundingClientRect();
385
+ const same = Math.abs(a.width - b.width) < 1 && Math.abs(a.height - b.height) < 1;
386
+ return same && parentStyle.overflow !== 'visible'
387
+ ? parentStyle.borderRadius
388
+ : style.borderRadius || '0px';
389
+ }
390
+
391
+ function toBars(lines: Line[]): SkeletonRect[] {
392
+ // Left to right, so each fragment only ever extends a bar to its left
393
+ lines.sort((a, b) => a.left - b.left);
394
+
395
+ const merged: Line[] = [];
396
+ for (const line of lines) {
397
+ const last = merged.find((m) => {
398
+ const overlap = Math.min(m.bottom, line.bottom) - Math.max(m.top, line.top);
399
+ const shorter = Math.min(m.bottom - m.top, line.bottom - line.top);
400
+ const gap = line.left - m.right;
401
+ return overlap >= shorter / 2 && gap <= Math.max(m.fontSize, line.fontSize) * LINE_GAP;
402
+ });
403
+
404
+ if (last) {
405
+ last.left = Math.min(last.left, line.left);
406
+ last.right = Math.max(last.right, line.right);
407
+ last.top = Math.min(last.top, line.top);
408
+ last.bottom = Math.max(last.bottom, line.bottom);
409
+ last.fontSize = Math.max(last.fontSize, line.fontSize);
410
+ last.barTop = Math.min(last.barTop, line.barTop);
411
+ last.barBottom = Math.max(last.barBottom, line.barBottom);
412
+ } else {
413
+ merged.push({ ...line });
414
+ }
415
+ }
416
+
417
+ return merged.map((line) => {
418
+ const height = line.barBottom - line.barTop;
419
+ return {
420
+ type: 'text' as const,
421
+ x: line.left,
422
+ y: line.barTop,
423
+ width: line.right - line.left,
424
+ height,
425
+ radius: line.radius || `${Math.round(height / 3)}px`,
426
+ };
427
+ });
428
+ }
429
+
430
+ /** Measures what `root` holds into rects relative to `layer`; without `mark`, `root` is left untouched. */
431
+ export function measureSkeleton(
432
+ root: HTMLElement,
433
+ layer: HTMLElement,
434
+ {
435
+ mark = true,
436
+ onCovered,
437
+ }: {
438
+ mark?: boolean;
439
+ /** What's drawn as one block, its own paint to hide — for a caller that can't mark it. */
440
+ onCovered?: (covered: Set<Element>) => void;
441
+ } = {}
442
+ ): SkeletonRect[] {
443
+ const origin = layer.getBoundingClientRect();
444
+ // Rects are post-transform; bones are drawn inside the same transform
445
+ const scaleX = layer.offsetWidth ? origin.width / layer.offsetWidth : 1;
446
+ const scaleY = layer.offsetHeight ? origin.height / layer.offsetHeight : 1;
447
+
448
+ const local = (rect: Rect) => ({
449
+ left: (rect.left - origin.left) / scaleX,
450
+ right: (rect.right - origin.left) / scaleX,
451
+ top: (rect.top - origin.top) / scaleY,
452
+ bottom: (rect.bottom - origin.top) / scaleY,
453
+ });
454
+
455
+ const blocks: SkeletonRect[] = [];
456
+ const lines: Line[] = [];
457
+ const covered = new Set<Element>();
458
+ const range = document.createRange();
459
+
460
+ const addLine = (rect: DOMRect, style: CSSStyleDeclaration, clip: Clip) => {
461
+ const box = clipped(rect, clip);
462
+ if (!box) return;
463
+ const fontSize = parseFloat(style.fontSize) || 16;
464
+ const line = local(box);
465
+ lines.push({
466
+ ...line,
467
+ fontSize,
468
+ ...barOf(line, style, fontSize),
469
+ radius: style.getPropertyValue('--ask-radius').trim(),
470
+ });
471
+ };
472
+
473
+ const addText = (node: Text, clip: Clip) => {
474
+ const start = node.data.search(/\S/);
475
+ if (start < 0 || !node.parentElement) return;
476
+ range.setStart(node, start);
477
+ range.setEnd(node, node.data.trimEnd().length);
478
+ const style = getComputedStyle(node.parentElement);
479
+ for (const rect of lineRects(range)) addLine(rect, style, clip);
480
+ };
481
+
482
+ const visit = (parent: Element, clip: Clip) => {
483
+ for (const node of parent.childNodes) {
484
+ if (node.nodeType === Node.TEXT_NODE) {
485
+ addText(node as Text, clip);
486
+ continue;
487
+ }
488
+ if (node.nodeType !== Node.ELEMENT_NODE) continue;
489
+
490
+ const element = node as Element;
491
+ if (element.hasAttribute(ATTR_IGNORE) || element.hasAttribute(ATTR_OWN)) {
492
+ continue;
493
+ }
494
+
495
+ const style = getComputedStyle(element);
496
+ if (style.display === 'none') continue;
497
+
498
+ if (isBlock(element, style)) {
499
+ const visible = clipped(element.getBoundingClientRect(), clip);
500
+ if (visible) {
501
+ const box = local(visible);
502
+ blocks.push({
503
+ type: isMedia(element) ? 'media' : 'box',
504
+ x: box.left,
505
+ y: box.top,
506
+ width: box.right - box.left,
507
+ height: box.bottom - box.top,
508
+ radius: roundedBy(clip, visible, radiusOf(element, style), style),
509
+ });
510
+ }
511
+ if (!isMedia(element)) covered.add(element);
512
+ continue;
513
+ }
514
+
515
+ if (isEmptyText(element)) {
516
+ for (const rect of element.getClientRects()) {
517
+ addLine(rect, style, clip);
518
+ }
519
+ continue;
520
+ }
521
+
522
+ // A painted container (a card, a chip) doesn't become a solid block — it still
523
+ // has interior bones to measure — but its own paint (bg / border / radius / shadow)
524
+ // is captured as a `chrome` bone so the outline survives shape recall and SSR
525
+ // pre-paint, when the real DOM isn't painted under the layer yet. Emitted BEFORE
526
+ // the recursion so it draws underneath the interior bones in document order.
527
+ //
528
+ // Single-side borders (`border-t` on a footer divider) deliberately fail
529
+ // `isPainted` — they're dividers, not chip paint — but they still need to
530
+ // appear in the skeleton, so `anyBordered` widens the capture trigger.
531
+ if (isPainted(style) || anyBordered(style)) {
532
+ const chrome = captureChrome(style);
533
+ if (chrome) {
534
+ const visible = clipped(element.getBoundingClientRect(), clip);
535
+ if (visible) {
536
+ const box = local(visible);
537
+ blocks.push({
538
+ type: 'chrome',
539
+ x: box.left,
540
+ y: box.top,
541
+ width: box.right - box.left,
542
+ height: box.bottom - box.top,
543
+ radius: roundedBy(clip, visible, radiusOf(element, style), style),
544
+ chrome,
545
+ });
546
+ }
547
+ }
548
+ }
549
+
550
+ const clips = style.overflowX !== 'visible' || style.overflowY !== 'visible';
551
+ visit(element, clips ? clipWithin(clip, element.getBoundingClientRect(), style) : clip);
552
+ }
553
+ };
554
+
555
+ visit(root, null);
556
+ onCovered?.(covered);
557
+
558
+ if (!mark) return [...blocks, ...toBars(lines)];
559
+
560
+ // Only touched when they change, so a MutationObserver watching `root` settles
561
+ for (const element of root.querySelectorAll(`[${ATTR_COVERED}]`)) {
562
+ if (!covered.has(element)) element.removeAttribute(ATTR_COVERED);
563
+ }
564
+ for (const element of covered) {
565
+ if (!element.hasAttribute(ATTR_COVERED)) {
566
+ element.setAttribute(ATTR_COVERED, '');
567
+ }
568
+ }
569
+
570
+ return [...blocks, ...toBars(lines)];
571
+ }