@alikhalilll/a-skeleton 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alikhalilll
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,324 @@
1
+ # @alikhalilll/a-skeleton
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.
4
+
5
+ ```vue
6
+ <ASkeleton :loading="isFetching">
7
+ <UserProfileCard :data="user" />
8
+ </ASkeleton>
9
+ ```
10
+
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.
12
+
13
+ ---
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm add @alikhalilll/a-skeleton
19
+ ```
20
+
21
+ ```ts
22
+ import '@alikhalilll/a-skeleton/styles.css';
23
+ ```
24
+
25
+ ### Nuxt 3 / 4
26
+
27
+ ```ts
28
+ // nuxt.config.ts
29
+ export default defineNuxtConfig({
30
+ modules: ['@alikhalilll/a-skeleton/nuxt'],
31
+ css: ['@alikhalilll/a-skeleton/styles.css'],
32
+ });
33
+ ```
34
+
35
+ Auto-imports `<ASkeleton>`, `<ASkeletonLayer>`, `<ASkeletonBlock>`.
36
+
37
+ ### Vue + Vite (unplugin-vue-components)
38
+
39
+ ```ts
40
+ // vite.config.ts
41
+ import Components from 'unplugin-vue-components/vite';
42
+ import ASkeletonResolver from '@alikhalilll/a-skeleton/resolver';
43
+
44
+ export default { plugins: [Components({ resolvers: [ASkeletonResolver()] })] };
45
+ ```
46
+
47
+ ---
48
+
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. |
63
+
64
+ Types: `ASkeletonProps`, `ASkeletonLayerProps`, `ASkeletonBlockProps`, `ASkeletonSlots`, `CachedShape`, `ShapeNode`, `ShapeNodeType`, `SkeletonAnimation`, `SkeletonFallback`, `UseSkeletonOptions`, `UseSkeletonReturn`, `ShapeProbeOptions`, `WalkOptions`, `BuildOptions`.
65
+
66
+ ---
67
+
68
+ ## Recipes
69
+
70
+ ### Recipe 1 — `<ASkeleton>` · the default wrapper
71
+
72
+ Drop it in, pass `loading`, you're done.
73
+
74
+ ```vue
75
+ <script setup lang="ts">
76
+ import { ref, computed } from 'vue';
77
+
78
+ const user = ref(null);
79
+ const loading = computed(() => user.value === null);
80
+
81
+ async function load() {
82
+ user.value = await fetch('/api/me').then((r) => r.json());
83
+ }
84
+ load();
85
+ </script>
86
+
87
+ <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. -->
91
+ <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
+
95
+ <div class="flex-1">
96
+ <h3>{{ user?.name }}</h3>
97
+ <p>{{ user?.bio }}</p>
98
+ </div>
99
+ </div>
100
+ </ASkeleton>
101
+ </template>
102
+ ```
103
+
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.
105
+
106
+ ### Recipe 2 — `<ASkeletonBlock>` · hand-crafted from primitives
107
+
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.
109
+
110
+ ```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>
122
+ ```
123
+
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). |
133
+
134
+ ### Recipe 3 — `useSkeleton()` + `<ASkeletonLayer>` · DIY orchestration
135
+
136
+ When you want the probe + cache + reactivity but don't want the wrapper. The composable returns a reactive `shape`; `<ASkeletonLayer>` replays it.
137
+
138
+ ```vue
139
+ <script setup lang="ts">
140
+ import { computed, ref } from 'vue';
141
+ import { useSkeleton } from '@alikhalilll/a-skeleton';
142
+
143
+ const props = defineProps<{ userId: string }>();
144
+ const user = ref(null);
145
+ const loading = computed(() => user.value === null);
146
+ const containerRef = ref<HTMLElement | null>(null);
147
+
148
+ const { shape, captureNow, clear } = useSkeleton({
149
+ cacheKey: `user-card:${props.userId}`,
150
+ // While loading, target is null → no capture. When real content mounts,
151
+ // target returns the wrapper → ResizeObserver + capture.
152
+ target: () => (loading.value ? null : containerRef.value),
153
+ persist: true,
154
+ });
155
+
156
+ fetchUser(props.userId).then((u) => (user.value = u));
157
+ </script>
158
+
159
+ <template>
160
+ <div ref="containerRef">
161
+ <ASkeletonLayer v-if="loading" :shape="shape" />
162
+ <UserCard v-else :data="user" />
163
+ </div>
164
+ </template>
165
+ ```
166
+
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 }`.
170
+
171
+ **`<ASkeletonLayer>` props** — `shape?: CachedShape`, `animation?: 'shimmer' \| 'pulse' \| 'none'`, `class?`.
172
+
173
+ ### Recipe 4 — Pure utilities · build your own flow
174
+
175
+ `walkDom`, `buildStructuralSkeleton`, `fingerprintSlot` are pure functions. Use them when none of the components fit.
176
+
177
+ ```ts
178
+ import {
179
+ walkDom,
180
+ buildStructuralSkeleton,
181
+ fingerprintSlot,
182
+ setCached,
183
+ getCached,
184
+ } from '@alikhalilll/a-skeleton';
185
+
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);
189
+
190
+ // Derive a default cacheKey from a slot
191
+ const key = fingerprintSlot(slots.default?.()); // 'UserCard' or 'div' or 'anonymous'
192
+
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
+ ```
199
+
200
+ ### Recipe 5 — `useShapeProbe()` · probe without the cache
201
+
202
+ Use when you maintain the cache externally (e.g. Pinia store, server-rendered geometry).
203
+
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
+ ```
214
+
215
+ ---
216
+
217
+ ## `<ASkeleton>` props
218
+
219
+ | Prop | Type | Default | Description |
220
+ | ------------- | -------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------- |
221
+ | `loading` | `boolean` | — | When `true`, show the skeleton. |
222
+ | `cacheKey` | `string` | slot's name | Identifier for the shape cache. Pass explicitly when one component renders different shapes by 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. |
230
+
231
+ ### Slots
232
+
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. |
237
+
238
+ ### DOM escape hatches
239
+
240
+ Mark elements during the walk:
241
+
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). |
246
+
247
+ ### Cache helpers
248
+
249
+ ```ts
250
+ import { getCached, setCached, clearCached } from '@alikhalilll/a-skeleton';
251
+
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);
256
+ ```
257
+
258
+ ---
259
+
260
+ ## Theming
261
+
262
+ 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.
263
+
264
+ ```css
265
+ /* Per-tenant override — applies to anything inside .tenant-acme */
266
+ .tenant-acme {
267
+ --ak-skeleton-block: hsl(220 30% 18%);
268
+ --ak-skeleton-shimmer: hsl(220 60% 60% / 0.35);
269
+ --ak-skeleton-radius: 0.5rem;
270
+ --ak-skeleton-duration: 2s;
271
+ }
272
+ ```
273
+
274
+ ```vue
275
+ <!-- Or inline, scoped to one tree -->
276
+ <ASkeleton :loading style="--ak-skeleton-block: hotpink; --ak-skeleton-radius: 9999px;">
277
+ <UserCard />
278
+ </ASkeleton>
279
+ ```
280
+
281
+ ### Tokens
282
+
283
+ | Variable | Default (dark) | Used for |
284
+ | ----------------------------- | ------------------------------------- | --------------------------------------------------------------- |
285
+ | `--ak-skeleton-block` | `hsl(var(--ak-ui-muted) / 0.55)` | Base block colour (top of the vertical gradient). |
286
+ | `--ak-skeleton-block-soft` | `hsl(var(--ak-ui-muted) / 0.32)` | Fade colour (bottom of the vertical gradient). |
287
+ | `--ak-skeleton-shimmer` | `hsl(var(--ak-ui-foreground) / 0.08)` | Moving-highlight colour of the shimmer sweep. |
288
+ | `--ak-skeleton-radius` | `0.375rem` | Default border radius for blocks (text bars use 60% of this). |
289
+ | `--ak-skeleton-duration` | `1.6s` | Animation duration (shimmer + pulse). |
290
+ | `--ak-skeleton-pulse-opacity` | `0.48` | Min opacity at the trough of the pulse cycle. |
291
+ | `--ak-skeleton-shimmer-angle` | `110deg` | Sweep angle of the shimmer pseudo-element (90deg = horizontal). |
292
+ | `--ak-skeleton-ring` | `hsl(var(--ak-ui-foreground) / 0.04)` | 1px inset ring that gives blocks definition without a border. |
293
+
294
+ 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).
295
+
296
+ ---
297
+
298
+ ## How the two-layer flow works
299
+
300
+ 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.
301
+ 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`.
302
+ 3. **Next loading flip** — cache hit. Positioned blocks render absolutely inside a layer sized to the previously measured bounding box. Pixel-aligned to ±1px.
303
+
304
+ ## Performance
305
+
306
+ Designed for components with hundreds of leaf elements (busy dashboards, long lists, dense forms). Cost is bounded at every layer:
307
+
308
+ - **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).
309
+ - **Min-size filter** — `minNodeSize` (default 4 px) drops hairlines / spacer dots.
310
+ - **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.
311
+ - **Batched DOM reads** — `getBoundingClientRect` + `getComputedStyle` happen in one top-down pass with no intervening writes. One layout up front instead of one per element.
312
+ - **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.
313
+ - **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.
314
+ - **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.
315
+
316
+ ## Limitations
317
+
318
+ - 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.
319
+ - 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.
320
+ - 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.
321
+
322
+ ## License
323
+
324
+ MIT © alikhalilll