@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.
- package/README.md +248 -186
- package/dist/_chunks/core.js +142 -15
- package/dist/_chunks/core.js.map +1 -1
- package/dist/_chunks/index.d.ts +30 -5
- package/dist/core/index.d.cts +2 -2
- package/dist/core/index.d.ts +2 -2
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/styles.css +136 -2
- package/package.json +2 -2
- package/src/core/fade.ts +3 -3
- package/src/core/index.ts +1 -1
- package/src/core/inline.generated.ts +1 -1
- package/src/core/measure.ts +117 -0
- package/src/core/plan.ts +1 -1
- package/src/core/reveals.ts +12 -3
- package/src/core/shimmer.ts +71 -15
- package/src/core/styles.ts +20 -0
- package/web-types.json +1 -1
package/src/core/measure.ts
CHANGED
|
@@ -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
|
package/src/core/reveals.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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) {
|
package/src/core/shimmer.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ATTR_RECT,
|
|
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.
|
|
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
|
|
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 =
|
|
166
|
+
let element = existing[index];
|
|
148
167
|
if (!element) {
|
|
149
168
|
element = document.createElement('div');
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
element
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/styles.ts
CHANGED
|
@@ -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