@alikhalilll/a-smart-skeleton 1.0.0 → 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.
@@ -8,13 +8,30 @@ import {
8
8
  TEXT_TAGS,
9
9
  } from './constants';
10
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
+
11
23
  /** A box to draw, relative to the layer it was measured against. */
12
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';
13
28
  x: number;
14
29
  y: number;
15
30
  width: number;
16
31
  height: number;
17
32
  radius: string;
33
+ /** Present on `type === 'chrome'` bones — the container's own paint, captured verbatim from `getComputedStyle`. */
34
+ chrome?: SkeletonChrome;
18
35
  };
19
36
 
20
37
  type Rect = { left: number; right: number; top: number; bottom: number };
@@ -213,6 +230,17 @@ const bordered = (style: CSSStyleDeclaration) =>
213
230
  isSet(style[`border${side}Style`], 'none') && isSet(style[`border${side}Width`], '0px')
214
231
  );
215
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
+
216
244
  // An inset hairline along one side, drawn as a shadow, is a divider like a one-sided border
217
245
  const isRule = (shadow: string) => {
218
246
  const [x = 0, y = 0, blur = 0, spread = 0] = (
@@ -226,12 +254,71 @@ const isRule = (shadow: string) => {
226
254
  const shadowPaints = (boxShadow: string) =>
227
255
  isSet(boxShadow, 'none') && !boxShadow.split(/,(?![^(]*\))/).every(isRule);
228
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
+
229
280
  const isPainted = (style: CSSStyleDeclaration) =>
230
281
  (isSet(style.backgroundColor, 'transparent') && !TRANSPARENT.test(style.backgroundColor)) ||
231
282
  isSet(style.backgroundImage, 'none') ||
232
283
  shadowPaints(style.boxShadow) ||
233
284
  bordered(style);
234
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
+
235
322
  const isMedia = (element: Element) => MEDIA.has(element.localName);
236
323
 
237
324
  const isEmptyText = (element: Element) =>
@@ -330,6 +417,7 @@ function toBars(lines: Line[]): SkeletonRect[] {
330
417
  return merged.map((line) => {
331
418
  const height = line.barBottom - line.barTop;
332
419
  return {
420
+ type: 'text' as const,
333
421
  x: line.left,
334
422
  y: line.barTop,
335
423
  width: line.right - line.left,
@@ -412,6 +500,7 @@ export function measureSkeleton(
412
500
  if (visible) {
413
501
  const box = local(visible);
414
502
  blocks.push({
503
+ type: isMedia(element) ? 'media' : 'box',
415
504
  x: box.left,
416
505
  y: box.top,
417
506
  width: box.right - box.left,
@@ -430,6 +519,34 @@ export function measureSkeleton(
430
519
  continue;
431
520
  }
432
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
+
433
550
  const clips = style.overflowX !== 'visible' || style.overflowY !== 'visible';
434
551
  visit(element, clips ? clipWithin(clip, element.getBoundingClientRect(), style) : clip);
435
552
  }
package/src/core/plan.ts CHANGED
@@ -56,7 +56,7 @@ export type RevealSpec = {
56
56
  };
57
57
 
58
58
  /** A built-in reveal by name, or one of your own. */
59
- export type RevealMode = 'together' | 'sequence' | 'smart' | 'sweep' | RevealSpec;
59
+ export type RevealMode = 'together' | 'sequence' | 'smart' | 'sweep' | 'instant' | RevealSpec;
60
60
 
61
61
  export const SKELETON_DELAY = 150; // A skeleton up for less than this is a flash — smart holds it back
62
62
  const QUICK_FADE = 120; // A refetch, already familiar
@@ -1,5 +1,6 @@
1
1
  import { CASCADE_SPREAD, FADE_DURATION } from './constants';
2
2
  import {
3
+ instant,
3
4
  MAX_DELAY,
4
5
  MAX_DURATION,
5
6
  sequence,
@@ -10,10 +11,18 @@ import {
10
11
  } from './plan';
11
12
  import { sweep, SWEEP_SPREAD } from './sweep';
12
13
 
13
- const NAMED = { together, sequence, smart, sweep };
14
+ // `instant` is advertised as a string mode in the README and is exported as a
15
+ // `RevealSpec`, but was previously missing from this map, so passing
16
+ // `reveal="instant"` returned `undefined` and crashed `arrive()` with
17
+ // "Cannot read properties of undefined (reading 'hold')".
18
+ const NAMED = { together, sequence, smart, sweep, instant };
14
19
 
15
- export const revealSpec = (mode: RevealMode = 'smart'): RevealSpec =>
16
- typeof mode === 'string' ? NAMED[mode] : mode;
20
+ // Unknown strings fall back to `smart` instead of crashing — a stale template
21
+ // or a typo should quietly do the right thing, not blow up the reveal.
22
+ export const revealSpec = (mode: RevealMode = 'smart'): RevealSpec => {
23
+ if (typeof mode !== 'string') return mode;
24
+ return (NAMED as Record<string, RevealSpec>)[mode] ?? smart;
25
+ };
17
26
 
18
27
  /** Longest a pane can take to leave under `mode`, for a framework that has to know up front. */
19
28
  export function exitDuration(mode?: RevealMode) {
@@ -1,4 +1,4 @@
1
- import { ATTR_RECT, BASE, DURATIONS, HIGHLIGHT } from './constants';
1
+ import { ATTR_RECT, DURATIONS, HIGHLIGHT } from './constants';
2
2
  import type { SkeletonAnimation, SkeletonSpeed } from './theme';
3
3
  import { animationsOf, prefersReducedMotion } from './motion';
4
4
  import type { SkeletonRect } from './measure';
@@ -123,16 +123,35 @@ export function paceOf(element: Element): number | null {
123
123
  return (motion.duration * SWEEP) / (REFERENCE_WIDTH + MAX_BAND);
124
124
  }
125
125
 
126
- // In a shadow root, so a framework hydrating `layer` never meets bones it didn't render
127
- const shadow = (layer: HTMLElement) => layer.shadowRoot ?? layer.attachShadow({ mode: 'open' });
128
-
129
126
  /** The rect elements drawn into `layer`. */
130
- export const rectsOf = (layer: Element): HTMLElement[] =>
131
- layer.shadowRoot ? ([...layer.shadowRoot.children] as HTMLElement[]) : [];
127
+ export const rectsOf = (layer: Element): HTMLElement[] => [
128
+ ...layer.querySelectorAll<HTMLElement>(`:scope > [${ATTR_RECT}]`),
129
+ ];
130
+
131
+ /** Soft floor on skeleton bone corners — anything sharper than this reads as a glyph,
132
+ * not a placeholder, which fights the whole point of a skeleton. 4 px is the smallest
133
+ * radius that still reads as "rounded" at typical display DPI. Applied only to
134
+ * text / media / box bones; chrome bones carry the consumer's own radius verbatim. */
135
+ const MIN_BONE_RADIUS = 4;
136
+ const clampRadius = (radius: string): string =>
137
+ radius.replace(/(\d+(?:\.\d+)?)px/g, (_, raw) => {
138
+ const n = parseFloat(raw);
139
+ return Number.isFinite(n) && n < MIN_BONE_RADIUS ? `${MIN_BONE_RADIUS}px` : `${n}px`;
140
+ });
132
141
 
133
- /** Draws `rects` into `layer`, reusing the elements already there. */
142
+ /** Draws `rects` into `layer`, reusing the elements already there.
143
+ *
144
+ * Bones live in the light DOM (not a shadow root) so a consumer's stylesheet
145
+ * can reach them — selectors like `[ask-rect="text"]`, `[ask-rect="media"]`,
146
+ * `[ask-rect="box"]`, or the `--ask-base` / `--ask-highlight` custom
147
+ * properties — and theme the skeleton with the consumer's own tokens rather
148
+ * than plugin defaults. Positioning (x/y/width/height/radius) stays inline
149
+ * because it is per-bone; fill colour comes from the stylesheet.
150
+ *
151
+ * Added after mount, so Vue/React hydration never sees them.
152
+ */
134
153
  export function drawSkeleton(layer: HTMLElement, rects: SkeletonRect[]) {
135
- const root = shadow(layer);
154
+ const existing = rectsOf(layer);
136
155
  const rect = layer.getBoundingClientRect();
137
156
  const group = {
138
157
  left: rect.left + scrollX,
@@ -144,23 +163,59 @@ export function drawSkeleton(layer: HTMLElement, rects: SkeletonRect[]) {
144
163
  const motion = motionOf(layer);
145
164
 
146
165
  rects.forEach((part, index) => {
147
- let element = root.children[index] as HTMLElement | undefined;
166
+ let element = existing[index];
148
167
  if (!element) {
149
168
  element = document.createElement('div');
150
- element.setAttribute(ATTR_RECT, '');
151
- // Inline: no page stylesheet reaches into the shadow root
152
- element.style.cssText = `position:absolute;display:block;pointer-events:none;background-color:${BASE};background-repeat:no-repeat`;
153
- root.append(element);
169
+ // Positioning-only: the stylesheet owns colour so the consumer can retheme
170
+ element.style.cssText = `position:absolute;display:block;pointer-events:none;background-repeat:no-repeat`;
171
+ layer.append(element);
154
172
  }
173
+ // Type attribute set every pass — bone reuse across renders may change type
174
+ element.setAttribute(ATTR_RECT, part.type);
155
175
 
156
176
  Object.assign(element.style, {
157
177
  left: `${part.x}px`,
158
178
  top: `${part.y}px`,
159
179
  width: `${part.width}px`,
160
180
  height: `${part.height}px`,
161
- borderRadius: part.radius,
181
+ // Chrome reproduces the real element's border-radius exactly (0 stays 0);
182
+ // placeholder bones get a 4-px floor so sharp-cornered text / media / box
183
+ // bones still read as rounded skeletons, not glyphs of the real content.
184
+ borderRadius: part.type === 'chrome' ? part.radius : clampRadius(part.radius),
162
185
  });
163
186
 
187
+ if (part.type === 'chrome') {
188
+ // Chrome bones reproduce the container's painted surface (bg / borders / shadow),
189
+ // captured from the consumer's own CSS. Reset shimmer side-effects so an element
190
+ // reused across draws isn't left with a shimmer background from a previous pass,
191
+ // then apply the captured paint inline — the stylesheet default fill would obscure
192
+ // the real colour.
193
+ for (const anim of animationsOf(element)) anim.cancel();
194
+ element.style.backgroundImage = '';
195
+ element.style.backgroundSize = '';
196
+ element.style.backgroundRepeat = '';
197
+ const chrome = part.chrome;
198
+ element.style.background = chrome?.background ?? '';
199
+ element.style.borderTop = chrome?.borderTop ?? '';
200
+ element.style.borderRight = chrome?.borderRight ?? '';
201
+ element.style.borderBottom = chrome?.borderBottom ?? '';
202
+ element.style.borderLeft = chrome?.borderLeft ?? '';
203
+ element.style.boxShadow = chrome?.boxShadow ?? '';
204
+ // Chrome never shimmers — it's the outline, not a loading placeholder
205
+ element.dataset.motion = 'chrome';
206
+ return;
207
+ }
208
+
209
+ // Non-chrome bones: drop any chrome state left on a reused element, then shimmer.
210
+ if (element.dataset.motion === 'chrome') {
211
+ element.style.background = '';
212
+ element.style.borderTop = '';
213
+ element.style.borderRight = '';
214
+ element.style.borderBottom = '';
215
+ element.style.borderLeft = '';
216
+ element.style.boxShadow = '';
217
+ }
218
+
164
219
  // Restarted only when its place, group, the page's width or the motion changes
165
220
  const key = `${Math.round(group.left + part.x)}|${Math.round(part.y)}|${Math.round(part.height)}|${Math.round(group.left)}|${Math.round(group.width)}|${Math.round(group.height)}|${document.documentElement.clientWidth}|${motion.animation}|${motion.duration}`;
166
221
  if (element.dataset.motion !== key) {
@@ -169,5 +224,6 @@ export function drawSkeleton(layer: HTMLElement, rects: SkeletonRect[]) {
169
224
  }
170
225
  });
171
226
 
172
- while (root.children.length > rects.length) root.lastElementChild!.remove();
227
+ // Trim excess bones left over from a previous, longer draw
228
+ for (let i = existing.length - 1; i >= rects.length; i--) existing[i]!.remove();
173
229
  }
@@ -8,6 +8,7 @@ import {
8
8
  ATTR_LOADING,
9
9
  ATTR_OWN,
10
10
  ATTR_PANE,
11
+ ATTR_RECT,
11
12
  ATTR_REVEAL,
12
13
  ATTR_STATUS,
13
14
  ATTR_XRAY,
@@ -62,6 +63,25 @@ ask-reveal[loading]:not(:defined) {
62
63
  background-repeat: no-repeat;
63
64
  }
64
65
 
66
+ /*
67
+ * Bones drawn by \`drawSkeleton\` live in the light DOM (no shadow root), so a
68
+ * consumer can target them with their own stylesheet. Positioning stays inline
69
+ * (per-bone), but the fill colour reads from a CSS custom property so the
70
+ * consumer's theme flows through — set \`--ask-base\` on any ancestor to
71
+ * recolour every bone inside it, or use the type modifier to style text / media
72
+ * / box bones separately.
73
+ *
74
+ * Chrome bones are excluded: they reproduce the consumer's own container paint
75
+ * (background, border, shadow) that \`captureChrome\` snapshotted, so they must
76
+ * NOT get a generic fill underneath. Without the \`:not()\`, a bordered-only
77
+ * container (e.g. a footer with \`border-t\`) would show as a solid filled
78
+ * rectangle instead of just its captured divider.
79
+ */
80
+ [${ATTR_RECT}]:not([${ATTR_RECT}="chrome"]) {
81
+ background-color: var(--ask-base, var(--ask-block, var(--ak-skeleton-block, rgb(128 128 128 / 0.2))));
82
+ background-repeat: no-repeat;
83
+ }
84
+
65
85
  /* Containers keep their surface; what they hold is drawn by bones, measured before the first paint */
66
86
  @scope ([${ATTR_LOADING}]) to ([${ATTR_OWN}], [${ATTR_LAYER}]) {
67
87
  :scope,
package/web-types.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/web-types",
3
3
  "name": "@alikhalilll/a-smart-skeleton",
4
- "version": "1.0.0",
4
+ "version": "2.0.0",
5
5
  "js-types-syntax": "typescript",
6
6
  "description-markup": "markdown",
7
7
  "framework": "vue",