@alikhalilll/a-skeleton 1.1.0 → 1.2.1

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 (53) hide show
  1. package/.media/hero.png +0 -0
  2. package/.media/hero.svg +232 -0
  3. package/README.md +458 -172
  4. package/dist/index.cjs +3685 -840
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +530 -43
  7. package/dist/index.d.ts +530 -43
  8. package/dist/index.js +3666 -842
  9. package/dist/index.js.map +1 -1
  10. package/dist/nuxt/index.cjs +16 -1
  11. package/dist/nuxt/index.cjs.map +1 -1
  12. package/dist/nuxt/index.js +16 -1
  13. package/dist/nuxt/index.js.map +1 -1
  14. package/dist/resolver/index.cjs +16 -1
  15. package/dist/resolver/index.cjs.map +1 -1
  16. package/dist/resolver/index.js +16 -1
  17. package/dist/resolver/index.js.map +1 -1
  18. package/dist/styles.css +56 -11
  19. package/package.json +9 -3
  20. package/src/components/ASkeleton.vue +212 -113
  21. package/src/components/ASkeletonClone.vue +106 -0
  22. package/src/components/ASkeletonLayer.vue +20 -32
  23. package/src/components/CloneNode.ts +161 -0
  24. package/src/components/StructuralLayerNode.ts +157 -0
  25. package/src/components/icons.ts +45 -0
  26. package/src/components/variants/ASkeletonArticle.vue +33 -0
  27. package/src/components/variants/ASkeletonAvatar.vue +42 -0
  28. package/src/components/variants/ASkeletonButton.vue +37 -0
  29. package/src/components/variants/ASkeletonCard.vue +47 -0
  30. package/src/components/variants/ASkeletonChart.vue +56 -0
  31. package/src/components/variants/ASkeletonChip.vue +32 -0
  32. package/src/components/variants/ASkeletonDivider.vue +26 -0
  33. package/src/components/variants/ASkeletonForm.vue +32 -0
  34. package/src/components/variants/ASkeletonHeading.vue +47 -0
  35. package/src/components/variants/ASkeletonImage.vue +57 -0
  36. package/src/components/variants/ASkeletonInput.vue +33 -0
  37. package/src/components/variants/ASkeletonListItem.vue +40 -0
  38. package/src/components/variants/ASkeletonTable.vue +49 -0
  39. package/src/components/variants/ASkeletonText.vue +49 -0
  40. package/src/components/variants/ASkeletonVideo.vue +55 -0
  41. package/src/composables/useShapeProbe.ts +33 -9
  42. package/src/composables/useSkeleton.ts +33 -21
  43. package/src/composables/useSkeletonCache.ts +251 -22
  44. package/src/index.ts +48 -2
  45. package/src/nuxt/index.ts +16 -0
  46. package/src/resolver/index.ts +16 -0
  47. package/src/types.ts +118 -2
  48. package/src/utils/buildStructuralSkeleton.ts +400 -103
  49. package/src/utils/captureStyles.ts +378 -0
  50. package/src/utils/domRead.ts +143 -0
  51. package/src/utils/walkDom.ts +261 -16
  52. package/src/utils/walkStructural.ts +418 -0
  53. package/web-types.json +9 -3
package/README.md CHANGED
@@ -1,29 +1,36 @@
1
- # @alikhalilll/a-skeleton
1
+ # `@alikhalilll/a-skeleton`
2
2
 
3
- > Self-generating skeleton loaders for Vue 3 / Nuxt 3+. First paint mirrors the slot's HTML structure; second load replays a pixel-aligned shape captured from the real DOM. Themeable via CSS variables.
3
+ > A self-generating skeleton loader for Vue 3 / Nuxt 3+.
4
+ > Three rendering strategies (clone, mirror, structural) · pixel-identical computed-style snapshot ·
5
+ > per-line text geometry via `Range` API · 15 named variants · themeable via CSS variables · SSR-safe mirror mode.
4
6
 
5
- ```vue
6
- <ASkeleton :loading="isFetching">
7
- <UserProfileCard :data="user" />
8
- </ASkeleton>
9
- ```
7
+ <p align="center">
8
+ <img
9
+ src="./.media/hero.png"
10
+ alt="ASkeleton — a real profile card on the left, its shimmering skeleton replica on the right, captured and replayed pixel-aligned to the same layout"
11
+ width="820"
12
+ />
13
+ </p>
10
14
 
11
- That's the whole API for 90% of cases. For the other 10%, every layer the wrapper uses is a public export — composables, primitive blocks, pure utilities.
15
+ <p align="center">
16
+ <sub>One wrapper, three engines — clone (default, pixel-identical) · mirror (SSR-safe) · structural (normal flow).</sub>
17
+ </p>
12
18
 
13
- ---
19
+ [![npm version](https://img.shields.io/npm/v/%40alikhalilll%2Fa-skeleton.svg?style=for-the-badge&label=npm&labelColor=0a0a0a&color=635bff)](https://www.npmjs.com/package/@alikhalilll/a-skeleton)
20
+ [![license](https://img.shields.io/npm/l/%40alikhalilll%2Fa-skeleton.svg?style=for-the-badge&labelColor=0a0a0a&color=635bff)](./LICENSE)
21
+ [![types](https://img.shields.io/npm/types/%40alikhalilll%2Fa-skeleton.svg?style=for-the-badge&labelColor=0a0a0a&color=635bff)](https://www.npmjs.com/package/@alikhalilll/a-skeleton)
22
+
23
+ ## Setup
14
24
 
15
- ## Install
25
+ ### Nuxt 3 / 4
16
26
 
17
27
  ```bash
18
28
  pnpm add @alikhalilll/a-skeleton
29
+ # npm install @alikhalilll/a-skeleton
30
+ # yarn add @alikhalilll/a-skeleton
31
+ # bun add @alikhalilll/a-skeleton
19
32
  ```
20
33
 
21
- ```ts
22
- import '@alikhalilll/a-skeleton/styles.css';
23
- ```
24
-
25
- ### Nuxt 3 / 4
26
-
27
34
  ```ts
28
35
  // nuxt.config.ts
29
36
  export default defineNuxtConfig({
@@ -32,9 +39,20 @@ export default defineNuxtConfig({
32
39
  });
33
40
  ```
34
41
 
35
- Auto-imports `<ASkeleton>`, `<ASkeletonLayer>`, `<ASkeletonBlock>`.
42
+ `<ASkeleton>`, `<ASkeletonLayer>`, `<ASkeletonBlock>`, and all variant primitives (`<ASkeletonCard>`, `<ASkeletonText>`, …) are auto-imported — no `import` statement needed in your `.vue` files.
43
+
44
+ ### Vue + Vite
36
45
 
37
- ### Vue + Vite (unplugin-vue-components)
46
+ ```bash
47
+ pnpm add @alikhalilll/a-skeleton
48
+ ```
49
+
50
+ ```ts
51
+ // main.ts
52
+ import '@alikhalilll/a-skeleton/styles.css';
53
+ ```
54
+
55
+ Optional auto-resolve via `unplugin-vue-components`:
38
56
 
39
57
  ```ts
40
58
  // vite.config.ts
@@ -46,30 +64,56 @@ export default { plugins: [Components({ resolvers: [ASkeletonResolver()] })] };
46
64
 
47
65
  ---
48
66
 
49
- ## Public API at a glance
50
-
51
- | Export | Kind | When to reach for it |
52
- | ----------------------------------------- | ---------- | -------------------------------------------------------------------------------- |
53
- | `<ASkeleton>` | Component | The default wrapper. Two-layer flow (structural pass → measured cache). |
54
- | `<ASkeletonLayer>` | Component | Render a `CachedShape` directly. Use when you don't want the wrapper. |
55
- | `<ASkeletonBlock>` | Component | Hand-crafted skeleton from primitive blocks. Flow-layout friendly. |
56
- | `<StructuralSkeleton>` | Component | Render a structural skeleton from any vnode tree (the cache-miss render path). |
57
- | `useSkeleton()` | Composable | Wire probe + cache + reactivity around your own DOM. Returns a reactive `shape`. |
58
- | `useShapeProbe()` | Composable | Lower-level. `ResizeObserver` + debounced capture; you manage the cache. |
59
- | `getCached` / `setCached` / `clearCached` | Function | Imperative cache primitives. Read / write / wipe entries by key. |
60
- | `walkDom()` | Function | Synchronous one-shot measurement. Returns a `CachedShape`. |
61
- | `buildStructuralSkeleton()` | Function | Pure: walk a vnode tree, return skeleton vnodes mirroring its layout. |
62
- | `fingerprintSlot()` | Function | Default `cacheKey` derivation — first non-comment vnode's component name. |
67
+ ## Why this component
63
68
 
64
- Types: `ASkeletonProps`, `ASkeletonLayerProps`, `ASkeletonBlockProps`, `ASkeletonSlots`, `CachedShape`, `ShapeNode`, `ShapeNodeType`, `SkeletonAnimation`, `SkeletonFallback`, `UseSkeletonOptions`, `UseSkeletonReturn`, `ShapeProbeOptions`, `WalkOptions`, `BuildOptions`.
69
+ - **Self-generating** wrap your real component and the engine derives the placeholder from the slot you're already rendering. No hand-drawn shimmer markup, no parallel "loading state" templates to maintain.
70
+ - **Three rendering strategies, one wrapper** — `mode="clone"` (default) snapshots `getComputedStyle()` for pixel-identical replay regardless of styling pipeline; `mode="mirror"` walks the vnode tree for SSR-safe placeholders; `useSkeleton()` + `<ASkeletonLayer>` for structural / normal-flow skeletons that reflow with their parent.
71
+ - **Comprehensive surface capture** — per-edge borders, per-corner radii, background colour + image, box-shadow, opacity, filter, transform, mix-blend-mode, typography — every visible CSS property carries through so the skeleton reads as the real element, not as a generic shimmer.
72
+ - **Per-line text geometry** — `Range.getClientRects()` captures the exact rendered text rect of every line. Wrapped paragraphs, centred multi-line headings, and RTL last-line positions replay 1:1 — no heuristics.
73
+ - **15 named variants** — `<ASkeletonCard>`, `<ASkeletonText>`, `<ASkeletonHeading>`, `<ASkeletonAvatar>`, `<ASkeletonImage>`, `<ASkeletonVideo>`, `<ASkeletonButton>`, `<ASkeletonInput>`, `<ASkeletonChip>`, `<ASkeletonListItem>`, `<ASkeletonTable>`, `<ASkeletonChart>`, `<ASkeletonForm>`, `<ASkeletonArticle>`, `<ASkeletonDivider>`. Each accepts `class` / inline `style` and shares the same animation + theming pipeline.
74
+ - **Bounded cost** — every walker enforces `maxDepth` / `maxNodes` / `minSize`. A 5 000-row table will not lock up the main thread. Captured nodes carry frozen pre-computed styles so the render loop is allocation-free.
75
+ - **Themeable via CSS variables** — `--ak-skel-base`, `--ak-skel-highlight`, `--ak-skel-radius`, `--ak-skel-duration`, `--ak-skel-pulse-min`, `--ak-skel-ring`, `--ak-skel-icon`. Override on `:root`, a wrapper class (multi-tenant), or inline.
76
+ - **Empty-interpolation aware** — `<h3>{{ data?.name }}</h3>` with null data still shimmers at the heading's natural rendered height. Both `mode="clone"` and `mode="mirror"` classify empty text-owner tags (`<h1>`-`<h6>`, `<p>`, `<span>`, …) as text bars rather than generic blocks.
77
+ - **`prefers-reduced-motion`** disables animation automatically.
78
+ - **SSR-safe (mirror)** — no `window` access during the structural pass; hydration is clean.
79
+ - **TypeScript-first** — every prop, slot, type, and composable fully typed; web-types ship for JetBrains IDEs.
65
80
 
66
81
  ---
67
82
 
68
- ## Recipes
83
+ ## Table of contents
84
+
85
+ - [Setup](#setup)
86
+ - [Quick start](#quick-start)
87
+ - [Three rendering strategies](#three-rendering-strategies)
88
+ - [Clone (default)](#clone-default)
89
+ - [Mirror](#mirror)
90
+ - [Structural / normal-flow](#structural--normal-flow)
91
+ - [Authoring rule](#authoring-rule)
92
+ - [API reference](#api-reference)
93
+ - [`<ASkeleton>` props](#askeleton-props)
94
+ - [Slots](#slots)
95
+ - [DOM escape hatches](#dom-escape-hatches)
96
+ - [Variant primitives](#variant-primitives)
97
+ - [`<ASkeletonBlock>`](#askeletonblock)
98
+ - [`<ASkeletonLayer>`](#askeletonlayer)
99
+ - [Composables](#composables)
100
+ - [Pure utilities](#pure-utilities)
101
+ - [Cache primitives](#cache-primitives)
102
+ - [Theming](#theming)
103
+ - [CSS custom properties](#css-custom-properties)
104
+ - [Multi-tenant + dark mode](#multi-tenant--dark-mode)
105
+ - [Animations](#animations)
106
+ - [Accessibility](#accessibility)
107
+ - [Performance](#performance)
108
+ - [SSR](#ssr)
109
+ - [TypeScript](#typescript)
110
+ - [Browser support](#browser-support)
111
+ - [Troubleshooting](#troubleshooting)
112
+ - [License](#license)
69
113
 
70
- ### Recipe 1 — `<ASkeleton>` · the default wrapper
114
+ ---
71
115
 
72
- Drop it in, pass `loading`, you're done.
116
+ ## Quick start
73
117
 
74
118
  ```vue
75
119
  <script setup lang="ts">
@@ -85,55 +129,79 @@ load();
85
129
  </script>
86
130
 
87
131
  <template>
88
- <ASkeleton :loading="loading" cache-key="user-card">
89
- <!-- Keep structure unconditional; gate per leaf so the structural pass has
90
- something to mirror during loading. -->
132
+ <ASkeleton :loading="loading">
133
+ <!-- Keep TAGS unconditional (the walker sees the same shape in both
134
+ states); gate per-leaf CONTENT via interpolation. -->
91
135
  <div class="flex items-start gap-4 p-4">
92
- <img v-if="user?.avatar" :src="user.avatar" class="size-16 rounded-full" />
93
- <div v-else class="size-16 rounded-full" />
94
-
136
+ <img
137
+ :src="user?.avatar"
138
+ :alt="user?.name ?? ''"
139
+ class="size-16 shrink-0 rounded-full object-cover"
140
+ />
95
141
  <div class="flex-1">
96
- <h3>{{ user?.name }}</h3>
97
- <p>{{ user?.bio }}</p>
142
+ <h3 class="text-base font-semibold">{{ user?.name }}</h3>
143
+ <p class="text-sm leading-relaxed">{{ user?.bio }}</p>
98
144
  </div>
99
145
  </div>
100
146
  </ASkeleton>
101
147
  </template>
102
148
  ```
103
149
 
104
- **Authoring rule** — gate **content**, not **structure**. The structural pass walks whatever the slot actually renders during loading. If you gate the whole template on `v-if="data"`, the walker sees a comment and falls back to a generic shimmer.
150
+ That's the whole API for most cases. Every layer underneath is also a public export see the recipes below.
151
+
152
+ ---
153
+
154
+ ## Three rendering strategies
155
+
156
+ Pick the one that matches how your styles get to the DOM:
105
157
 
106
- ### Recipe 2 `<ASkeletonBlock>` · hand-crafted from primitives
158
+ | Strategy | Surface | How geometry is derived | Best when |
159
+ | --------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
160
+ | **Clone** _(default)_ | `<ASkeleton :loading>` | Mounts the slot off-screen, snapshots every leaf's **computed style** via `getComputedStyle()`, replays as positioned divs each carrying its captured inline CSS. | Styles come from any pipeline — class names, inline `style`, CSS-in-JS, scoped styles. Pixel-identical. Client-side only. |
161
+ | **Mirror** | `<ASkeleton mode="mirror" :loading>` | Walks the slot's **vnode** tree at render time. Preserves every tag and `class`; leaves become shimmer bars. | SSR-safe placeholders (no DOM read needed) or when the static class is enough to drive the surface. |
162
+ | **Structural** | `useSkeleton()` + `<ASkeletonLayer>` | Walks the real **DOM** tree, preserves container tags + classes + layout CSS (`display`, `flex-*`, `grid-*`, `gap`, `padding`), replaces leaves with `<div class="a-skel">` in **normal flow**. | You want the skeleton to reflow with its parent, or you're orchestrating cache + capture outside a single wrapper instance. |
107
163
 
108
- For custom designs where you'd rather author the skeleton yourself than rely on auto-capture. Flow-layout friendly — composes with flex, grid, stacks.
164
+ All three share one cache module, one theming surface, and the same a11y baseline.
165
+
166
+ ### Clone (default)
167
+
168
+ The slot mounts off-screen inside a `visibility: hidden` capture host. After mount, `captureSnapshot()` reads `getComputedStyle()` for every element under the host — capturing the **final** background, per-edge border, per-corner radius, box-shadow, padding, opacity, filter, transform, typography — plus per-line text rects via `Range.getClientRects()`. `<ASkeletonClone>` then replays the snapshot as a tree of positioned divs each carrying its captured inline style.
109
169
 
110
170
  ```vue
111
- <template>
112
- <div v-if="loading" class="flex items-start gap-4 p-4">
113
- <ASkeletonBlock type="circle" :w="64" :h="64" />
114
- <div class="flex-1 space-y-2">
115
- <ASkeletonBlock type="text" :w="160" :h="18" />
116
- <ASkeletonBlock type="text" :w="100" :h="12" />
117
- <ASkeletonBlock type="text" :lines="3" :h="14" class="!mt-3" />
118
- </div>
119
- </div>
120
- <UserCard v-else :data="user" />
121
- </template>
171
+ <ASkeleton :loading="loading">
172
+ <SomeRichComponent />
173
+ </ASkeleton>
122
174
  ```
123
175
 
124
- | Prop | Type | Default | Notes |
125
- | ----------- | ------------------------------------------ | ----------- | ------------------------------------------------------------------- |
126
- | `type` | `'block' \| 'text' \| 'image' \| 'circle'` | `'block'` | `circle` defaults `border-radius: 50%`. |
127
- | `w` | `number \| string` | — | Width (number = px). |
128
- | `h` | `number \| string` | — | Height (number = px). |
129
- | `radius` | `number \| string` | — | Border radius (number = px). |
130
- | `lines` | `number` | `1` | For `type='text'`, render N stacked bars; last is 70% width. |
131
- | `animation` | `'shimmer' \| 'pulse' \| 'none'` | `'shimmer'` | Animation variant. |
132
- | `class` | `HTMLAttributes['class']` | — | Class on the root (single block, or the stack for multi-line text). |
176
+ The capture API is a public export — power-users can roll their own capture / replay flow:
177
+
178
+ ```ts
179
+ import { captureSnapshot, type CaptureSnapshot } from '@alikhalilll/a-skeleton';
180
+
181
+ const snap: CaptureSnapshot = captureSnapshot(myRootEl, {
182
+ maxDepth: 12,
183
+ maxNodes: 800,
184
+ minSize: 4,
185
+ });
186
+ ```
133
187
 
134
- ### Recipe 3 — `useSkeleton()` + `<ASkeletonLayer>` · DIY orchestration
188
+ ### Mirror
135
189
 
136
- When you want the probe + cache + reactivity but don't want the wrapper. The composable returns a reactive `shape`; `<ASkeletonLayer>` replays it.
190
+ Switch to `mode="mirror"` when you need the placeholder on the server, or when your styles already drive the surface correctly from class names alone:
191
+
192
+ ```vue
193
+ <ASkeleton mode="mirror" :loading="loading">
194
+ <SomeRichComponent />
195
+ </ASkeleton>
196
+ ```
197
+
198
+ `buildStructuralSkeleton()` walks the slot's vnode tree at render time, preserves every element with its real `class` / inline `style`, and replaces text-bearing leaves with `<span class="a-skel-text-content">` (transparent text + skeleton background, exact rendered width via `box-decoration-break: clone`). Atomic / interactive tags (`<img>`, `<button>`, `<svg>`) become `<div class="a-skel-block">` sized from their original class.
199
+
200
+ Text-owner tags with empty content (`<h3>{{ data?.name }}</h3>` during loading) auto-shimmer at the heading's natural rendered height — the walker injects a placeholder text-content span so the bar's height tracks the tag's font-size / line-height.
201
+
202
+ ### Structural / normal-flow
203
+
204
+ For cases where the wrapper isn't your unit of orchestration — caching shapes across instances, persisting between sessions, or wanting the skeleton to reflow with its parent — reach for `useSkeleton()` + `<ASkeletonLayer>`:
137
205
 
138
206
  ```vue
139
207
  <script setup lang="ts">
@@ -145,7 +213,7 @@ const user = ref(null);
145
213
  const loading = computed(() => user.value === null);
146
214
  const containerRef = ref<HTMLElement | null>(null);
147
215
 
148
- const { shape, captureNow, clear } = useSkeleton({
216
+ const { shape, clear } = useSkeleton({
149
217
  cacheKey: `user-card:${props.userId}`,
150
218
  // While loading, target is null → no capture. When real content mounts,
151
219
  // target returns the wrapper → ResizeObserver + capture.
@@ -158,170 +226,388 @@ fetchUser(props.userId).then((u) => (user.value = u));
158
226
 
159
227
  <template>
160
228
  <div ref="containerRef">
161
- <ASkeletonLayer v-if="loading" :shape="shape" />
229
+ <ASkeletonLayer v-if="loading && shape" :shape="shape" />
230
+ <ColdStartFallback v-else-if="loading" />
162
231
  <UserCard v-else :data="user" />
163
232
  </div>
164
233
  </template>
165
234
  ```
166
235
 
167
- **`useSkeleton` options** `cacheKey` (required), `target?`, `persist?`, `maxDepth?`, `maxNodes?`, `minSize?`, `resizeDebounceMs?`.
168
-
169
- **`useSkeleton` returns** — `{ shape: Readonly<Ref<CachedShape | undefined>>, captureNow: () => CachedShape | undefined, clear: () => void }`.
236
+ `walkStructural()` produces a frozen tree where containers preserve their tag, original `class`, and captured layout CSS (`display`, `flex-*`, `gap`, `padding`, `grid-*`, `box-sizing`), and leaves carry inline `width` / `height` + visual signals. `<ASkeletonLayer>` replays the tree in **normal flow**, so the skeleton lives inside its parent's layout instead of overlaying it with absolute coordinates — it reflows on viewport resize and never collapses to a flat positioned grid.
170
237
 
171
- **`<ASkeletonLayer>` props** `shape?: CachedShape`, `animation?: 'shimmer' \| 'pulse' \| 'none'`, `class?`.
238
+ Given real markup like:
172
239
 
173
- ### Recipe 4 — Pure utilities · build your own flow
240
+ ```html
241
+ <div class="flex flex-col gap-4 p-4">
242
+ <h3>…</h3>
243
+ <p>…</p>
244
+ <button>…</button>
245
+ </div>
246
+ ```
174
247
 
175
- `walkDom`, `buildStructuralSkeleton`, `fingerprintSlot` are pure functions. Use them when none of the components fit.
248
+ …the layer replays as:
249
+
250
+ ```html
251
+ <div
252
+ class="flex flex-col gap-4 p-4"
253
+ style="display: flex; flex-direction: column; gap: 16px; padding: 16px; box-sizing: border-box"
254
+ >
255
+ <div class="a-skel" style="width: 200px; height: 24px; …" />
256
+ <div class="a-skel" style="width: 280px; height: 16px; …" />
257
+ <div class="a-skel" style="width: 120px; height: 36px; …" />
258
+ </div>
259
+ ```
176
260
 
177
- ```ts
178
- import {
179
- walkDom,
180
- buildStructuralSkeleton,
181
- fingerprintSlot,
182
- setCached,
183
- getCached,
184
- } from '@alikhalilll/a-skeleton';
261
+ The original class is preserved so utility-first CSS still applies; the resolved layout CSS is inlined as a fallback so the skeleton looks correct even when the stylesheet isn't at the mount point.
185
262
 
186
- // Take a measurement yourself and stash it
187
- const shape = walkDom(containerEl, { maxDepth: 6, maxNodes: 500, minSize: 4 });
188
- setCached('user-card', shape, /* persist */ true);
263
+ The structural cache lives in its own `localStorage` namespace (`a-skeleton:s:` prefix, schema `v: 3`), so it can never collide on the same `cacheKey` with the flat `CachedShape` cache. Mismatched schema versions auto-purge on read.
189
264
 
190
- // Derive a default cacheKey from a slot
191
- const key = fingerprintSlot(slots.default?.()); // 'UserCard' or 'div' or 'anonymous'
265
+ ---
192
266
 
193
- // Render skeleton vnodes for an arbitrary tree (e.g. inside a render-function component)
194
- const skeletonVNodes = buildStructuralSkeleton(slots.default?.() ?? [], {
195
- animationClass: 'a-skel-block--anim-shimmer',
196
- maxNodes: 300,
197
- });
198
- ```
267
+ ## Authoring rule
199
268
 
200
- ### Recipe 5 `useShapeProbe()` · probe without the cache
269
+ Keep **tags** unconditional; gate per-leaf **content** via interpolation. Two safe patterns:
201
270
 
202
- Use when you maintain the cache externally (e.g. Pinia store, server-rendered geometry).
271
+ 1. **Always render the same tag**, gate its content.
272
+ - `<img :src="user?.avatar">` renders an `<img>` in both states (walker treats it as atomic → sized shimmer block).
273
+ - `<h3>{{ user?.name }}</h3>` renders an empty `<h3>` during loading and the walker auto-injects a placeholder shimmer bar at the heading's natural width.
274
+ 2. **Use explicit primitives** with `v-if` / `v-else` (see [`<ASkeletonBlock>`](#askeletonblock) and the [Variant primitives](#variant-primitives)) when the loading and loaded states genuinely have different markup.
203
275
 
204
- ```ts
205
- import { useShapeProbe } from '@alikhalilll/a-skeleton';
206
-
207
- useShapeProbe(() => containerRef.value, {
208
- maxDepth: 6,
209
- maxNodes: 500,
210
- resizeDebounceMs: 200,
211
- onCapture: (shape) => myStore.saveShape('user-card', shape),
212
- });
213
- ```
276
+ The pattern to **avoid** is swapping whole branches (`<img v-if><div v-else>`). The walker sees one shape now and a different shape later — the skeleton can't predict the placeholder geometry.
214
277
 
215
278
  ---
216
279
 
217
- ## `<ASkeleton>` props
280
+ ## API reference
218
281
 
219
- | Prop | Type | Default | Description |
220
- | ------------- | -------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
221
- | `loading` | `boolean` | | When `true`, show the skeleton. |
222
- | `cacheKey` | `string` | auto, per-instance | Identifier for the shape cache. Auto-generated as `"<slot-name>:<useId()>"` so each instance has its own slot. Pass explicitly to share a shape across instances, or when one instance renders different shapes per prop. |
223
- | `maxDepth` | `number` | `6` | Max recursion depth when capturing shape. |
224
- | `maxNodes` | `number` | `500` | Hard cap on captured / structural nodes. Walk bails out beyond this with `truncated: true`. |
225
- | `minNodeSize` | `number` | `4` | Skip elements smaller than this many CSS pixels (either axis) during capture. |
226
- | `persist` | `boolean` | `false` | Mirror captured shape to `localStorage` so first-visit-after-reload skips the cold-start fallback. |
227
- | `animation` | `'shimmer' \| 'pulse' \| 'none'` | `'shimmer'` | Animation variant. `prefers-reduced-motion` disables animation automatically. |
228
- | `fallback` | `'shimmer' \| 'block'` | `'shimmer'` | Default cache-miss UI when no `#fallback` slot is provided. |
229
- | `class` | `HTMLAttributes['class']` | | Class on the outer wrapper. |
282
+ ### `<ASkeleton>` props
283
+
284
+ | Prop | Type | Default | Description |
285
+ | ------------- | -------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
286
+ | `loading` | `boolean` | | When `true`, show the skeleton. |
287
+ | `mode` | `'clone' \| 'mirror'` | `'clone'` | Rendering strategy. `clone` snapshots `getComputedStyle()`; `mirror` walks the vnode tree (SSR-safe). |
288
+ | `cacheKey` | `string` | auto, per-instance | Auto-generated as `<slot-fingerprint>:<useId()>` so each instance has its own slot. Pass explicitly to share a captured shape across instances (e.g. a list of identical cards) or to differentiate prop-variant shapes from the same component. |
289
+ | `maxDepth` | `number` | `16` | Max recursion depth when capturing. |
290
+ | `maxNodes` | `number` | `600` | Hard cap on captured / structural nodes. Walks bail beyond this with `truncated: true` and a one-time `console.warn` per `cacheKey`. |
291
+ | `minNodeSize` | `number` | `4` | Skip elements smaller than this many CSS pixels (either axis). Drops hairlines / spacer dots. |
292
+ | `persist` | `boolean` | `false` | Mirror captured shape to `localStorage`. Schema-versioned; entries from older releases auto-purge on read. |
293
+ | `animation` | `'shimmer' \| 'pulse' \| 'none'` | `'shimmer'` | Animation variant. `prefers-reduced-motion` disables animation automatically. |
294
+ | `fallback` | `'shimmer' \| 'block'` | `'shimmer'` | Default cache-miss UI when no `#fallback` slot is provided (mirror mode only). |
295
+ | `class` | `HTMLAttributes['class']` | — | Class on the outer wrapper. |
230
296
 
231
297
  ### Slots
232
298
 
233
- | Slot | Description |
234
- | ---------- | ----------------------------------------------------------------------------------- |
235
- | `default` | The real content. Rendered when `loading` is false; measured to build the skeleton. |
236
- | `fallback` | Custom UI for cache misses. Defaults to a single full-width shimmer block. |
299
+ | Slot | Description |
300
+ | ---------- | ---------------------------------------------------------------------------------------------- |
301
+ | `default` | The real content. Rendered when `loading` is false; measured / mirrored to build the skeleton. |
302
+ | `fallback` | Custom UI for cache misses (mirror mode). Defaults to a single full-width shimmer block. |
237
303
 
238
304
  ### DOM escape hatches
239
305
 
240
- Mark elements during the walk:
306
+ Mark elements during the walk via data attributes — applies to all three strategies:
307
+
308
+ | Attribute | Effect |
309
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
310
+ | `data-skeleton-stop` | Stop recursing into this element — render as a single block carrying its outer `class` / `style`. |
311
+ | `data-skeleton-ignore` | Skip the element entirely (no block emitted). Use for decorative chrome (background SVGs, dividers, persistent badges) that should always render verbatim. |
312
+
313
+ Authoring rule for branded chrome — skeleton-ise **content**, not **chrome**. If a component's identity is its gradient background / decorative SVGs, wrap only the inner content with `<ASkeleton>` and let the container always render. Mark decorations with `data-skeleton-ignore` so the walker treats them as invisible.
314
+
315
+ ### Variant primitives
316
+
317
+ When auto-capture isn't the right fit (loading states without real content to wrap, very dense layouts, screens you'd rather author once), drop in a named variant. Each accepts `animation="pulse | shimmer | wave | none"`, `class`, and inline `style`. Every variant ships `role="status"`, `aria-busy="true"`, and a visually-hidden `<span class="a-skel-sr-only">Loading…</span>`.
318
+
319
+ | Component | Maps to | Key props |
320
+ | --------------------- | ------------------------------------------ | ------------------------------------------------------ |
321
+ | `<ASkeletonText>` | n stacked bars, last line shorter | `lines`, `width` |
322
+ | `<ASkeletonHeading>` | one bar sized to heading level | `level` (1–6), `width` |
323
+ | `<ASkeletonAvatar>` | circle / square / rounded | `size`, `shape` |
324
+ | `<ASkeletonImage>` | aspect-ratio rect + image-icon placeholder | `ratio`, `width`, `height`, `showIcon` |
325
+ | `<ASkeletonVideo>` | rect + play-icon placeholder | `ratio`, `width`, `height`, `showIcon` |
326
+ | `<ASkeletonButton>` | rounded rect, filled or outlined | `width`, `height`, `outlined` |
327
+ | `<ASkeletonInput>` | bordered rect with caret bar inside | `width`, `height` |
328
+ | `<ASkeletonChip>` | small pill | `width`, `height` |
329
+ | `<ASkeletonListItem>` | avatar + n text lines + trailing slot | `avatar`, `lines`, `trailing` |
330
+ | `<ASkeletonCard>` | media + heading + paragraph + actions | `media`, `heading`, `lines`, `actions`, `footerAvatar` |
331
+ | `<ASkeletonTable>` | header row + n × m body cells | `rows`, `columns`, `showHeader` |
332
+ | `<ASkeletonChart>` | n vertical bars of varying heights | `bars`, `height`, `showHeader` |
333
+ | `<ASkeletonForm>` | label + input pairs + submit | `fields`, `showSubmit` |
334
+ | `<ASkeletonArticle>` | heading + media + n paragraphs | `media`, `paragraphs`, `linesPerParagraph` |
335
+ | `<ASkeletonDivider>` | thin shimmer rule | `thickness` |
336
+
337
+ ### `<ASkeletonBlock>`
241
338
 
242
- | Attribute | Effect |
243
- | ---------------------- | --------------------------------------------------------------- |
244
- | `data-skeleton-stop` | Stop recursing into this element — render it as a single block. |
245
- | `data-skeleton-ignore` | Skip this element entirely (no block emitted). |
339
+ Single-block primitive for hand-crafted skeletons. Flow-layout friendly — composes with flex, grid, stacks.
246
340
 
247
- ### Cache helpers
341
+ ```vue
342
+ <template>
343
+ <div v-if="loading" class="flex items-start gap-4 p-4">
344
+ <ASkeletonBlock type="circle" :w="64" :h="64" />
345
+ <div class="flex-1 space-y-2">
346
+ <ASkeletonBlock type="text" :w="160" :h="18" />
347
+ <ASkeletonBlock type="text" :w="100" :h="12" />
348
+ <ASkeletonBlock type="text" :lines="3" :h="14" class="!mt-3" />
349
+ </div>
350
+ </div>
351
+ <UserCard v-else :data="user" />
352
+ </template>
353
+ ```
354
+
355
+ | Prop | Type | Default | Notes |
356
+ | ----------- | ------------------------------------------ | ----------- | ------------------------------------------------------------------- |
357
+ | `type` | `'block' \| 'text' \| 'image' \| 'circle'` | `'block'` | `circle` defaults `border-radius: 50%`. |
358
+ | `w` | `number \| string` | — | Width (number = px). |
359
+ | `h` | `number \| string` | — | Height (number = px). |
360
+ | `radius` | `number \| string` | — | Border radius (number = px). |
361
+ | `lines` | `number` | `1` | For `type='text'`, render N stacked bars; last is 70% width. |
362
+ | `animation` | `'shimmer' \| 'pulse' \| 'none'` | `'shimmer'` | Animation variant. |
363
+ | `class` | `HTMLAttributes['class']` | — | Class on the root (single block, or the stack for multi-line text). |
364
+
365
+ ### `<ASkeletonLayer>`
366
+
367
+ Renders a `StructuralShape` (from `useSkeleton()` or `walkStructural()`) in normal flow. The layer is a transparent shell — captured containers carry the layout.
368
+
369
+ | Prop | Type | Default | Description |
370
+ | ----------- | -------------------------------- | ----------- | --------------------------------- |
371
+ | `shape` | `StructuralShape \| undefined` | — | Renders nothing when `undefined`. |
372
+ | `animation` | `'shimmer' \| 'pulse' \| 'none'` | `'shimmer'` | Animation variant. |
373
+ | `class` | `HTMLAttributes['class']` | — | Class on the layer wrapper. |
374
+
375
+ ### Composables
376
+
377
+ #### `useSkeleton(options) → { shape, captureNow, clear }`
378
+
379
+ Wires the DOM probe + structural cache + reactivity around a target element. The reactive `shape` feeds `<ASkeletonLayer>`.
380
+
381
+ | Option | Type | Default | Description |
382
+ | ------------------ | --------------------------- | ------- | -------------------------------------------------------------------- |
383
+ | `cacheKey` | `string` | — | Required. Identifier for the shape cache. |
384
+ | `target` | `() => HTMLElement \| null` | — | Getter for the element to measure. Return `null` to disable capture. |
385
+ | `persist` | `boolean` | `false` | Mirror captured shape to `localStorage`. |
386
+ | `maxDepth` | `number` | `12` | Forwarded to `walkStructural`. |
387
+ | `maxNodes` | `number` | `500` | Forwarded to `walkStructural`. |
388
+ | `minSize` | `number` | `4` | Forwarded to `walkStructural`. |
389
+ | `resizeDebounceMs` | `number` | `150` | `ResizeObserver` re-capture debounce. |
390
+
391
+ Returns `{ shape: Readonly<Ref<StructuralShape | undefined>>, captureNow: () => StructuralShape | undefined, clear: () => void }`.
392
+
393
+ #### `useShapeProbe(getTarget, options)`
394
+
395
+ Lower-level — `ResizeObserver` + debounced capture without the cache. You manage persistence (Pinia, API, server-rendered geometry). The `capture` option lets you swap in any strategy:
248
396
 
249
397
  ```ts
250
- import { getCached, setCached, clearCached } from '@alikhalilll/a-skeleton';
398
+ import { useShapeProbe, walkStructural } from '@alikhalilll/a-skeleton';
251
399
 
252
- clearCached(); // wipe every shape
253
- clearCached('user-card'); // wipe one key
254
- setCached('user-card', shape, true); // write + persist
255
- const shape = getCached('user-card', true);
400
+ useShapeProbe(() => containerRef.value, {
401
+ maxDepth: 12,
402
+ resizeDebounceMs: 200,
403
+ capture: walkStructural, // default is walkDom (flat); pass walkStructural for the tree shape.
404
+ onCapture: (shape) => myStore.saveShape('user-card', shape),
405
+ });
256
406
  ```
257
407
 
258
- Persisted entries carry a schema version. Whenever the `ShapeNode` / `CachedShape` shape changes between releases the version is bumped, and `getCached` purges stale payloads on read — so an upgrade can't replay wrong geometry from a previous version's cache.
408
+ ### Pure utilities
409
+
410
+ When none of the components fit, drop down to the pure functions:
411
+
412
+ | Symbol | Returns | Purpose |
413
+ | ------------------------------------------ | ------------------------ | ------------------------------------------------------------------------------------- |
414
+ | `walkDom(el, options)` | `CachedShape` (flat) | One-shot synchronous capture — positioned-block model, root-relative absolute coords. |
415
+ | `walkStructural(el, options)` | `StructuralShape` (tree) | One-shot synchronous capture — preserves container layout, normal-flow replay. |
416
+ | `captureSnapshot(el, options)` | `CaptureSnapshot` (tree) | Comprehensive computed-style snapshot used by clone mode. |
417
+ | `buildStructuralSkeleton(vnodes, options)` | `VNode[]` | Mirror-mode renderer for any vnode tree (e.g. inside a render-function component). |
418
+ | `fingerprintSlot(vnodes)` | `string` | Slot-name fragment of the auto `cacheKey` (`'UserCard'`, `'div'`, or `'anonymous'`). |
419
+
420
+ ### Cache primitives
421
+
422
+ | Symbol | Namespace | Purpose |
423
+ | ------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------ |
424
+ | `getCached(key, persist)` | flat (`a-skeleton:` prefix, `v: 2`) | Lookup for the legacy flat-shape cache (mirror cache-replay path). |
425
+ | `setCached(key, value, persist)` | flat | Store a flat shape. |
426
+ | `clearCached(key?)` | flat **and** structural | Wipes both namespaces. Pass a `key` to drop one entry from each. |
427
+ | `getCachedStructural(key, persist)` | structural (`a-skeleton:s:` prefix, `v: 3`) | Lookup for the tree-shape cache (Recipe 3). |
428
+ | `setCachedStructural(key, value, persist)` | structural | Store a tree shape. |
429
+ | `clearCachedStructural(key?)` | structural only | Wipes only the structural namespace. |
430
+
431
+ Persisted entries carry a schema version. Mismatched versions auto-purge on read — upgrades can't replay wrong geometry from a previous version's cache.
259
432
 
260
433
  ---
261
434
 
262
435
  ## Theming
263
436
 
264
- Every visual primitive is a CSS variable prefixed `--ak-skeleton-`. Override on `:root`, on a wrapper class (multi-tenant), or inline. Defaults inherit from the shared `@alikhalilll/a-ui-base` token set so the skeleton matches whatever theme the rest of your app uses.
437
+ ### CSS custom properties
438
+
439
+ Set these on `:root` (or any ancestor) to retint every primitive:
440
+
441
+ | Token | Used for |
442
+ | --------------------- | ------------------------------------------------------------- |
443
+ | `--ak-skel-base` | Block fill (also used by text bars and variant primitives). |
444
+ | `--ak-skel-base-soft` | Secondary endpoint for variants that use a vertical gradient. |
445
+ | `--ak-skel-highlight` | Shimmer / wave sweep colour. |
446
+ | `--ak-skel-radius` | Default block border radius. |
447
+ | `--ak-skel-radius-sm` | Tighter radius for text bars, chips. |
448
+ | `--ak-skel-radius-lg` | Wider radius for cards, images. |
449
+ | `--ak-skel-duration` | Animation cycle length. |
450
+ | `--ak-skel-pulse-min` | Opacity at the trough of the pulse cycle. |
451
+ | `--ak-skel-ring` | Subtle 1-px inset ring colour. |
452
+ | `--ak-skel-icon` | Placeholder icon colour (image / video variants). |
453
+
454
+ Backward-compat aliases for v1 token names (`--ak-skeleton-block`, `--ak-skeleton-shimmer`, `--ak-skeleton-radius`, `--ak-skeleton-duration`, `--ak-skeleton-pulse-opacity`, `--ak-skeleton-ring`) are kept — existing consumer overrides continue to work.
265
455
 
266
456
  ```css
267
457
  /* Per-tenant override — applies to anything inside .tenant-acme */
268
458
  .tenant-acme {
269
- --ak-skeleton-block: hsl(220 30% 18%);
270
- --ak-skeleton-shimmer: hsl(220 60% 60% / 0.35);
271
- --ak-skeleton-radius: 0.5rem;
272
- --ak-skeleton-duration: 2s;
459
+ --ak-skel-base: hsl(220 30% 18%);
460
+ --ak-skel-highlight: hsl(220 60% 60% / 0.35);
461
+ --ak-skel-radius: 0.5rem;
462
+ --ak-skel-duration: 2s;
273
463
  }
274
464
  ```
275
465
 
276
466
  ```vue
277
467
  <!-- Or inline, scoped to one tree -->
278
- <ASkeleton :loading style="--ak-skeleton-block: hotpink; --ak-skeleton-radius: 9999px;">
468
+ <ASkeleton :loading style="--ak-skel-base: hotpink; --ak-skel-radius: 9999px;">
279
469
  <UserCard />
280
470
  </ASkeleton>
281
471
  ```
282
472
 
283
- ### Tokens
473
+ ### Multi-tenant + dark mode
474
+
475
+ Light / dark is driven entirely by the `--ak-skel-*` tokens. Three strategies, all built in:
284
476
 
285
- | Variable | Default (dark) | Used for |
286
- | ----------------------------- | ------------------------------------- | --------------------------------------------------------------- |
287
- | `--ak-skeleton-block` | `hsl(var(--ak-ui-muted) / 0.55)` | Base block colour (top of the vertical gradient). |
288
- | `--ak-skeleton-block-soft` | `hsl(var(--ak-ui-muted) / 0.32)` | Fade colour (bottom of the vertical gradient). |
289
- | `--ak-skeleton-shimmer` | `hsl(var(--ak-ui-foreground) / 0.08)` | Moving-highlight colour of the shimmer sweep. |
290
- | `--ak-skeleton-radius` | `0.375rem` | Default border radius for blocks (text bars use 60% of this). |
291
- | `--ak-skeleton-duration` | `1.6s` | Animation duration (shimmer + pulse). |
292
- | `--ak-skeleton-pulse-opacity` | `0.48` | Min opacity at the trough of the pulse cycle. |
293
- | `--ak-skeleton-shimmer-angle` | `110deg` | Sweep angle of the shimmer pseudo-element (90deg = horizontal). |
294
- | `--ak-skeleton-ring` | `hsl(var(--ak-ui-foreground) / 0.04)` | 1px inset ring that gives blocks definition without a border. |
477
+ - **`.dark` class scope** — apply `.dark` to any ancestor (Tailwind / shadcn / `nuxt-color-mode` convention). The package ships `:where(.dark) { --ak-skel-base: hsl(220 13% 22%) … }` so dark tokens kick in automatically.
478
+ - **`@media (prefers-color-scheme: dark)`** falls back to OS preference when no explicit `.dark` / `.light` class is on an ancestor.
479
+ - **Explicit `.light` class** wins back light tokens (consumer override).
295
480
 
296
- The `.light` scope overrides `--ak-skeleton-shimmer` to a brighter value so polarity stays correct (lighter sweep over the muted base in both light and dark mode).
481
+ Multi-tenant CSS can override per scope by setting the variables under a wrapper class. Variant-specific overrides also work e.g. `<ASkeletonImage style="--ak-skel-base: hsl(200 50% 90%)">` retints one instance.
297
482
 
298
483
  ---
299
484
 
300
- ## How the two-layer flow works
485
+ ## Animations
301
486
 
302
- 1. **Cache miss + loading** — `ASkeleton` walks the slot's vnode tree and renders a structural skeleton. Same tags, same `class` strings, atomic leaves (`<img>`, `<button>`) as shimmer blocks, text tags (`<h3>`, `<p>`) as bars. Tailwind utilities like `size-16` and `rounded-full` still apply, so the avatar circle and headline widths look right on the very first paint — without DOM measurement.
303
- 2. **Data arrives** the real slot renders. A `ResizeObserver` + `requestAnimationFrame` capture pass walks the DOM, freezing per-block `x/y/w/h`, border-radius, and multi-line counts into a `CachedShape` keyed by `cacheKey`.
304
- 3. **Next loading flip** cache hit. Positioned blocks render absolutely inside a layer sized to the previously measured bounding box. Pixel-aligned to ±1px.
487
+ | Value | What | Default? |
488
+ | --------- | ------------------------------------------------------------------ | -------- |
489
+ | `shimmer` | gradient sweep ::after, contained per block via `overflow: hidden` | yes |
490
+ | `pulse` | opacity 1 → `--ak-skel-pulse-min` → 1 over `--ak-skel-duration` | — |
491
+ | `wave` | gradient via `background-position` (sliding) | — |
492
+ | `none` | static blocks | — |
493
+
494
+ `prefers-reduced-motion: reduce` disables every animation automatically (`animation: none !important` + the shimmer pseudo-element drops to a static low-opacity overlay).
495
+
496
+ ---
497
+
498
+ ## Accessibility
499
+
500
+ - Every wrapper / layer / variant root carries `role="status"` while loading.
501
+ - `aria-busy="true"` mirrors the loading state.
502
+ - `aria-live="polite"` so screen readers announce the loading state without interrupting the user.
503
+ - A visually-hidden `<span class="a-skel-sr-only">Loading…</span>` ships with every variant primitive so the loading state is read out by screen readers.
504
+ - Every emitted shimmer surface carries `aria-hidden="true"` — the placeholder is decorative; the announcement is the wrapper's job.
505
+ - Mirror-mode skeletons disable `user-select` and `pointer-events` on the slot tree so the placeholder can't be interacted with mid-load.
506
+ - `prefers-reduced-motion` strips animation.
507
+
508
+ ---
305
509
 
306
510
  ## Performance
307
511
 
308
- Designed for components with hundreds of leaf elements (busy dashboards, long lists, dense forms). Cost is bounded at every layer:
512
+ Designed for components with hundreds of leaf elements busy dashboards, long lists, dense forms.
513
+
514
+ - **Walk budget** — every walker (`walkDom`, `walkStructural`, `captureSnapshot`) enforces `maxNodes` (defaults 500 / 500 / 800) and reports `truncated: true`. A 5 000-row table will not lock up the main thread. `<ASkeleton>` logs a one-time `console.warn` per `cacheKey` whenever a capture truncates so missing nodes surface during development.
515
+ - **Min-size filter** — `minSize` (default 4 px) drops hairlines / spacer dots.
516
+ - **One-layout reads** — `getBoundingClientRect()` + `getComputedStyle()` happen in a single top-down pass with no intervening writes. One layout up front, then cached values for the rest of the walk.
517
+ - **Allocation-free render** — captured nodes carry frozen pre-computed styles. The render loop reads them directly with no per-node function calls.
518
+ - **Debounced re-capture** — initial measurement via `requestAnimationFrame`; subsequent `ResizeObserver` callbacks debounced 150 ms so a drag-resize doesn't trigger a re-walk per frame.
519
+ - **CSS containment** — `.a-skeleton[data-loading]` uses `overflow: clip` + `contain: paint`, so shadows / filters / transforms can't bleed outside the box.
520
+ - **Composited shimmer** — only `transform: translateX(...)` changes each frame on the shimmer pseudo-element, with `will-change: transform` lifting blocks to their own compositor layer up front.
309
521
 
310
- - **Walk budget** — `walkDom` stops emitting after `maxNodes` (default 500) and returns `CachedShape.truncated: true`. A 5000-row table will not lock up the main thread. The structural pass enforces a separate cap (default 300). `<ASkeleton>` logs a one-time `console.warn` per `cacheKey` whenever a capture truncates, so missing nodes surface during development instead of silently replaying a clipped shape.
311
- - **Min-size filter** — `minNodeSize` (default 4 px) drops hairlines / spacer dots.
312
- - **Allocation-free render** — captured `ShapeNode`s carry frozen, pre-computed `style` and `lineStyles` objects. Per-type block class strings are pre-joined once per animation value. The cache-hit render loop reads them directly with no per-node function calls.
313
- - **Batched DOM reads** — `getBoundingClientRect` + `getComputedStyle` happen in one top-down pass with no intervening writes. One layout up front instead of one per element.
314
- - **Debounced re-capture**initial measurement via `requestAnimationFrame`. Subsequent `ResizeObserver` callbacks debounced 150 ms so a drag-resize doesn't trigger a re-walk per frame.
315
- - **CSS containment** — `.a-skeleton__layer` uses `contain: layout style paint` + `content-visibility: auto` so off-screen blocks skip rendering; `.a-skel-block` inside uses `contain: strict` so per-block shimmer animations never trigger sibling layout/paint.
316
- - **Composited shimmer** only `transform: translateX(...)` changes each frame on the shimmer pseudo-element, with `will-change: transform` to lift each block to its own compositor layer up front.
522
+ ---
523
+
524
+ ## SSR
525
+
526
+ - **`mode="mirror"`** is fully SSR-safethe walker runs on vnodes, no `window` access required.
527
+ - **`mode="clone"`** is client-side only (needs `getComputedStyle()` + a real DOM). The wrapper renders the slot's normal markup on the server; the snapshot + replay kick in on hydration.
528
+ - **`useSkeleton()`** runs in `onMounted` and bails out cleanly when `window` is undefined.
529
+
530
+ If you need a server-rendered placeholder before hydration finishes, use `mode="mirror"` or hand-craft with `<ASkeletonBlock>` / variant primitives.
531
+
532
+ ---
317
533
 
318
- ## Limitations
534
+ ## TypeScript
535
+
536
+ Import the public types from the main entry:
537
+
538
+ ```ts
539
+ import type {
540
+ ASkeletonProps,
541
+ ASkeletonSlots,
542
+ ASkeletonLayerProps,
543
+ ASkeletonBlockProps,
544
+ CachedShape,
545
+ ShapeNode,
546
+ ShapeNodeType,
547
+ StructuralShape,
548
+ StructuralNode,
549
+ ContainerNode,
550
+ LeafNode,
551
+ LeafKind,
552
+ CaptureSnapshot,
553
+ CapturedNode,
554
+ UseSkeletonOptions,
555
+ UseSkeletonReturn,
556
+ ShapeProbeOptions,
557
+ CaptureStrategy,
558
+ WalkOptions,
559
+ WalkStructuralOptions,
560
+ CaptureOptions,
561
+ BuildOptions,
562
+ SkeletonAnimation,
563
+ SkeletonFallback,
564
+ } from '@alikhalilll/a-skeleton';
565
+ ```
319
566
 
320
- - The structural pass mirrors what the slot's template actually renders during loading. Gate everything on `v-if="data"` and the walker sees only a comment — fall back to the generic shimmer.
321
- - Captured shapes are snapshots. `ResizeObserver` re-measures when the wrapper resizes (debounced 150 ms). If you resize _during_ a skeleton render, the cached shape replays unchanged.
322
- - SSR: the structural skeleton works during SSR (no `window` access needed). Pixel-aligned positioned blocks require a captured shape, which only happens client-side after mount.
323
- - Two `<ASkeleton>` instances rendering the same component get separate caches by default — the auto-generated key includes `useId()`, so the slot is per-instance. Pass an explicit `cacheKey` to share one captured shape across, e.g., a list of identical cards.
567
+ Slot prop types are inferable in templates:
568
+
569
+ ```vue
570
+ <ASkeleton #fallback>…</ASkeleton>
571
+ ```
572
+
573
+ ---
574
+
575
+ ## Browser support
576
+
577
+ Modern evergreen browsers — last two versions of Chrome, Edge, Firefox, Safari, and the matching mobile WebViews. Uses `Range.getClientRects()` (universal since 2017), `ResizeObserver` (universal since 2020), and CSS `contain: paint` (universal since 2022). No polyfills required.
578
+
579
+ The `overflow: clip` containment falls back to `overflow: hidden` on older browsers via the standard CSS cascade — no JavaScript fallback needed.
580
+
581
+ ---
582
+
583
+ ## Troubleshooting
584
+
585
+ **The skeleton is blank.**
586
+ Make sure your slot's tags are rendered unconditionally. If the slot is `<div v-if="data">…</div>`, the walker sees one comment during loading and falls back to a generic shimmer. Gate **content** per leaf via `{{ data?.field }}`, not the entire template on `v-if`.
587
+
588
+ **An empty `<h3>{{ data?.name }}</h3>` doesn't shimmer.**
589
+ This was the v1 walker's behaviour. v2+ classifies empty text-owner tags (`<h1>`-`<h6>`, `<p>`, `<span>`, …) as text bars rather than generic blocks, so the heading shimmers at its natural rendered height. Make sure you're on the latest version.
590
+
591
+ **My `<button>` loses its background colour.**
592
+ Mirror mode keeps real `bg-*` classes — the engine detects an explicit background and skips the skeleton fallback. If your button has no `bg-*` class and no inline `background`, the walker assumes you want the default skeleton fill. Either add `bg-emerald-600` (or whatever) explicitly, or wrap the inner text in `<ASkeletonBlock>` and let the real `<button>` render around it.
593
+
594
+ **The clone-mode replay drifts after a viewport resize.**
595
+ Clone mode captures absolute coordinates at the moment of snapshot. Resize-aware skeletons should use the structural strategy (`useSkeleton()` + `<ASkeletonLayer>`) — captured containers preserve their flex/grid layout and reflow with the parent.
596
+
597
+ **The structural-mode skeleton looks like a single block, not a tree.**
598
+ The cache may be stale from an older version. `localStorage` entries with schema version `v: 2` (from `walkDom`-flat-model) won't load as `v: 3` structural shapes. Call `clearCachedStructural()` once after upgrading, or wait for the auto-purge on next read.
599
+
600
+ **A decorative SVG / background image renders as a giant skeleton block.**
601
+ Mark it with `data-skeleton-ignore` so the walker treats it as invisible. The slot's chrome (gradients, decorative shapes) should always render verbatim; the skeleton is for **content**.
602
+
603
+ **The structural skeleton's flex/grid layout breaks when rendered in a different CSS context.**
604
+ Containers preserve both the original `class` (for utility CSS) and the resolved layout CSS as inline `style`. If your styles aren't present at the mount point, the inline `display: flex; flex-direction: column; gap: 16px;` fallback still drives the layout.
605
+
606
+ **TypeScript can't find `StructuralShape` / `walkStructural`.**
607
+ These are exported from the main entry from v2.0+. Re-run `pnpm install`, restart your TS server, and confirm the package version in `node_modules/@alikhalilll/a-skeleton/package.json` matches what's in your `package.json`.
608
+
609
+ ---
324
610
 
325
611
  ## License
326
612
 
327
- MIT © alikhalilll
613
+ [MIT](./LICENSE) © alikhalilll