@camp.dev/bones 0.4.1 → 0.5.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/src/css/bones.css CHANGED
@@ -1,3 +1,56 @@
1
+ /* ---------------------------------------------------------------------------
2
+ @camp.dev/bones/css: the stylesheet is the API.
3
+
4
+ Everything keys on aria-busy="true". Under a busy element (or on it), a
5
+ leaf with no element children becomes a text bar and a replaced element or
6
+ form control becomes a block. Five data-bones-* attributes adjust that:
7
+ data-bones-type="text|block" overrides the inferred shape,
8
+ data-bones-lines="N" paints N stacked bars, data-bones-length="N" makes
9
+ the bar N characters wide, data-bones-auto="off" exempts a subtree from
10
+ inference, and data-bones-animate picks shimmer, pulse, or none. Outside a
11
+ busy region every attribute is inert.
12
+
13
+ Two cascade strengths, one activation condition. Keep this shape:
14
+ - Inferred rules live in @layer bones-auto. They match arbitrary page
15
+ elements, so page CSS must outrank them. The cost: a page color rule
16
+ keeps an inferred bone's text visible over its bar.
17
+ - Explicit rules (data-bones-type, data-bones-lines) are unlayered. The
18
+ author asked for that bone, so a page rule like h3 { color: #333 } must
19
+ not keep the text visible. They still need the busy scope.
20
+ - The busy scope is :is([aria-busy="true"], [aria-busy="true"] *). The
21
+ busy element itself is eligible, so aria-busy can sit on a component's
22
+ root and a single-leaf component still paints.
23
+ - Within each strength the data-bones-animate scopes beat the default
24
+ because :is(:scope, :scope *) puts them at higher specificity, not
25
+ because of source order. tests/browser/default-animation.test.ts and
26
+ animation-scope.test.ts pin this.
27
+ - The bar geometry is declared twice, once per strength, because a rule
28
+ is either in a layer or not. tests/bones-css.test.ts pins the copies
29
+ equal.
30
+ - The bar is a pseudo-element that inherits the element's padding, so
31
+ its content box is the element's content box and the bar sits inside a
32
+ badge's padding. clip-path: inset(0 round r) content-box gives it
33
+ rounded ends at the content edges as one path; border-radius could not,
34
+ because a radius shrinks by the padding on the way in, and a layered
35
+ mask (still used for the multi-line rows, which repeat) leaves seams
36
+ where the browser snaps each layer's tile to device pixels on its own.
37
+ Percentage padding resolves against a different box on the
38
+ pseudo-element and misses; length padding is exact.
39
+ The padding and box-sizing on the single bar are !important: a page
40
+ reset such as `*, *::before, *::after { box-sizing: border-box;
41
+ padding: 0 }` is unlayered, so it would otherwise beat the inferred copy
42
+ and push the bar back out to the padding edge. No page styles a bone's
43
+ own bar on purpose, and an important layered declaration wins over
44
+ every normal one.
45
+ - The enumerated data-bones-lines and data-bones-length blocks exist for
46
+ browsers without advanced attr(). Delete them once Safari ships attr()
47
+ with type().
48
+ - --bone-radius must carry a unit. --bones-r feeds it to min(), where a
49
+ unitless 0 is a <number> and invalidates the multi-line mask.
50
+ --------------------------------------------------------------------------- */
51
+
52
+ @layer bones-auto;
53
+
1
54
  :root {
2
55
  /* Bones derive their color from the text color in effect at the bone, so
3
56
  they inherit the page's own contrast guarantee on any background in any
@@ -11,149 +64,914 @@
11
64
  --bone-duration: 1.5s;
12
65
  }
13
66
 
14
- [data-bone="text"] {
15
- color: rgb(from currentcolor r g b / 0);
16
- position: relative;
17
- min-width: 4ch;
18
- min-height: 1lh;
19
- }
20
-
21
- [data-bone-line] {
22
- display: block;
23
- }
24
-
25
- [data-bone-line]:last-child {
26
- width: 69%;
67
+ @keyframes bone-shimmer {
68
+ 0% {
69
+ background-position: 200% 0;
70
+ }
71
+ 100% {
72
+ background-position: -200% 0;
73
+ }
27
74
  }
28
75
 
29
- [data-bone="text"]::before {
30
- content: "\200b";
76
+ @keyframes bone-pulse {
77
+ 0%,
78
+ 100% {
79
+ opacity: 1;
80
+ }
81
+ 50% {
82
+ opacity: 0.5;
83
+ }
31
84
  }
32
85
 
33
- [data-bone="text"]::after {
34
- content: "";
35
- position: absolute;
36
- inset-inline: 0;
37
- top: calc((1lh + 1cap) / 2 - 1ex);
38
- height: 1ex;
39
- background-color: var(--bone-base);
40
- border-radius: var(--bone-radius);
41
- pointer-events: none;
42
- }
86
+ /* Line count. Enumerated for every Baseline 2024 browser; advanced attr()
87
+ reads any value where it exists (Chrome 133, Firefox 155, Safari after
88
+ Interop 2026). Both tables set an inherited custom property, so they carry
89
+ the busy scope like every other rule here: an attribute on an element
90
+ outside a busy region must not reach the leaves of a busy region nested
91
+ below it. The defaults live in var() fallbacks at the point of use, never
92
+ as declarations in a higher-specificity rule that would beat these. */
93
+ :is([aria-busy="true"], [aria-busy="true"] *) {
94
+ &[data-bones-lines="2"] {
95
+ --bones-lines: 2;
96
+ }
97
+ &[data-bones-lines="3"] {
98
+ --bones-lines: 3;
99
+ }
100
+ &[data-bones-lines="4"] {
101
+ --bones-lines: 4;
102
+ }
103
+ &[data-bones-lines="5"] {
104
+ --bones-lines: 5;
105
+ }
106
+ &[data-bones-lines="6"] {
107
+ --bones-lines: 6;
108
+ }
109
+ &[data-bones-lines="7"] {
110
+ --bones-lines: 7;
111
+ }
112
+ &[data-bones-lines="8"] {
113
+ --bones-lines: 8;
114
+ }
43
115
 
44
- [data-bone="text"][style*="--bone-length"] {
45
- width: calc(var(--bone-length) * 1ch);
46
- }
116
+ /* Width in characters. Enumerated 1 through 40 for the same reason. */
117
+ &[data-bones-length="1"] {
118
+ --bones-length: 1;
119
+ }
120
+ &[data-bones-length="2"] {
121
+ --bones-length: 2;
122
+ }
123
+ &[data-bones-length="3"] {
124
+ --bones-length: 3;
125
+ }
126
+ &[data-bones-length="4"] {
127
+ --bones-length: 4;
128
+ }
129
+ &[data-bones-length="5"] {
130
+ --bones-length: 5;
131
+ }
132
+ &[data-bones-length="6"] {
133
+ --bones-length: 6;
134
+ }
135
+ &[data-bones-length="7"] {
136
+ --bones-length: 7;
137
+ }
138
+ &[data-bones-length="8"] {
139
+ --bones-length: 8;
140
+ }
141
+ &[data-bones-length="9"] {
142
+ --bones-length: 9;
143
+ }
144
+ &[data-bones-length="10"] {
145
+ --bones-length: 10;
146
+ }
147
+ &[data-bones-length="11"] {
148
+ --bones-length: 11;
149
+ }
150
+ &[data-bones-length="12"] {
151
+ --bones-length: 12;
152
+ }
153
+ &[data-bones-length="13"] {
154
+ --bones-length: 13;
155
+ }
156
+ &[data-bones-length="14"] {
157
+ --bones-length: 14;
158
+ }
159
+ &[data-bones-length="15"] {
160
+ --bones-length: 15;
161
+ }
162
+ &[data-bones-length="16"] {
163
+ --bones-length: 16;
164
+ }
165
+ &[data-bones-length="17"] {
166
+ --bones-length: 17;
167
+ }
168
+ &[data-bones-length="18"] {
169
+ --bones-length: 18;
170
+ }
171
+ &[data-bones-length="19"] {
172
+ --bones-length: 19;
173
+ }
174
+ &[data-bones-length="20"] {
175
+ --bones-length: 20;
176
+ }
177
+ &[data-bones-length="21"] {
178
+ --bones-length: 21;
179
+ }
180
+ &[data-bones-length="22"] {
181
+ --bones-length: 22;
182
+ }
183
+ &[data-bones-length="23"] {
184
+ --bones-length: 23;
185
+ }
186
+ &[data-bones-length="24"] {
187
+ --bones-length: 24;
188
+ }
189
+ &[data-bones-length="25"] {
190
+ --bones-length: 25;
191
+ }
192
+ &[data-bones-length="26"] {
193
+ --bones-length: 26;
194
+ }
195
+ &[data-bones-length="27"] {
196
+ --bones-length: 27;
197
+ }
198
+ &[data-bones-length="28"] {
199
+ --bones-length: 28;
200
+ }
201
+ &[data-bones-length="29"] {
202
+ --bones-length: 29;
203
+ }
204
+ &[data-bones-length="30"] {
205
+ --bones-length: 30;
206
+ }
207
+ &[data-bones-length="31"] {
208
+ --bones-length: 31;
209
+ }
210
+ &[data-bones-length="32"] {
211
+ --bones-length: 32;
212
+ }
213
+ &[data-bones-length="33"] {
214
+ --bones-length: 33;
215
+ }
216
+ &[data-bones-length="34"] {
217
+ --bones-length: 34;
218
+ }
219
+ &[data-bones-length="35"] {
220
+ --bones-length: 35;
221
+ }
222
+ &[data-bones-length="36"] {
223
+ --bones-length: 36;
224
+ }
225
+ &[data-bones-length="37"] {
226
+ --bones-length: 37;
227
+ }
228
+ &[data-bones-length="38"] {
229
+ --bones-length: 38;
230
+ }
231
+ &[data-bones-length="39"] {
232
+ --bones-length: 39;
233
+ }
234
+ &[data-bones-length="40"] {
235
+ --bones-length: 40;
236
+ }
47
237
 
48
- [data-bone="text"][style*="--bone-contained"] {
49
- min-width: unset;
50
- display: inline-flex;
51
- align-items: center;
52
- box-sizing: content-box;
238
+ @supports (x: attr(x type(*))) {
239
+ &[data-bones-lines] {
240
+ --bones-lines: attr(data-bones-lines type(<number>), 1);
241
+ }
242
+ &[data-bones-length] {
243
+ --bones-length: attr(data-bones-length type(<number>), 4);
244
+ }
245
+ }
53
246
  }
54
247
 
55
- [data-bone="text"][style*="--bone-contained"]::after {
56
- flex: 1;
57
- position: static;
58
- inset-inline: unset;
59
- top: unset;
60
- transform: none;
61
- }
248
+ /* ---------------------------------------------------------------------------
249
+ Inferred. Loses to page CSS.
250
+ --------------------------------------------------------------------------- */
62
251
 
63
- [data-bone="text"][style*="--bone-contained"][style*="--bone-length"] {
64
- min-width: calc(var(--bone-length) * 1ch);
65
- }
252
+ @layer bones-auto {
253
+ :is([aria-busy="true"], [aria-busy="true"] *) {
254
+ /* Text: any element with no child elements, minus explicit markup, opted
255
+ out subtrees, and the tags that get block treatment or nothing. */
256
+ &:not(:has(*)):not(
257
+ [data-bones-type],
258
+ [data-bones-type] *,
259
+ [data-bones-lines],
260
+ [data-bones-lines] *,
261
+ [data-bones-auto="off"],
262
+ [data-bones-auto="off"] *
263
+ ):not(
264
+ img,
265
+ svg,
266
+ video,
267
+ canvas,
268
+ picture,
269
+ iframe,
270
+ embed,
271
+ object,
272
+ audio,
273
+ button,
274
+ input,
275
+ select,
276
+ textarea,
277
+ progress,
278
+ meter,
279
+ hr,
280
+ br,
281
+ svg *,
282
+ picture *,
283
+ select *,
284
+ object *
285
+ ) {
286
+ --bones-bar-top: calc((1lh + 1cap) / 2 - 1ex);
287
+ --bones-r: max(0.01px, min(var(--bone-radius), 0.5ex));
288
+ --bones-inset: max(0px, var(--bones-r) - 1px);
289
+ color: rgb(from currentcolor r g b / 0);
290
+ position: relative;
291
+ min-width: calc(var(--bones-length, 4) * 1ch);
292
+ min-height: 1lh;
293
+ &::before {
294
+ content: "\200b";
295
+ }
296
+ /* min-width does nothing on an inline box. An empty inline leaf (a
297
+ <span> with no data yet) gets its width from an inline-block ::before
298
+ instead, so its bar has something to span. Non-empty leaves keep the
299
+ zero-width space, which adds no width and moves no text. */
300
+ &:empty::before {
301
+ content: "";
302
+ display: inline-block;
303
+ min-width: calc(var(--bones-length, 4) * 1ch);
304
+ }
305
+ &::after {
306
+ content: "";
307
+ position: absolute;
308
+ inset-inline: 0;
309
+ top: var(--bones-bar-top);
310
+ height: 1ex;
311
+ padding: inherit !important;
312
+ box-sizing: content-box !important;
313
+ clip-path: inset(0 round var(--bones-r)) content-box;
314
+ pointer-events: none;
315
+ animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
316
+ background: linear-gradient(
317
+ 90deg,
318
+ var(--bone-base) 25%,
319
+ var(--bone-highlight) 50%,
320
+ var(--bone-base) 75%
321
+ );
322
+ background-size: 200% 100%;
323
+ }
324
+ /* Width variance: block-level leaves cycle 85 / 100 / 92 / 60 so runs
325
+ of lines look organic. A cap, not a width, so an inline-block or a
326
+ flex-item leaf (a badge, a pill) keeps its content width instead of
327
+ stretching to a share of the row. */
328
+ &:nth-child(4n + 1) {
329
+ max-width: 85%;
330
+ }
331
+ &:nth-child(4n + 2) {
332
+ max-width: 100%;
333
+ }
334
+ &:nth-child(4n + 3) {
335
+ max-width: 92%;
336
+ }
337
+ &:nth-child(4n) {
338
+ max-width: 60%;
339
+ }
340
+ }
66
341
 
67
- [data-bone="block"] {
68
- display: inline-block;
69
- background-color: var(--bone-base);
70
- }
342
+ /* Block: replaced elements and form controls fill their own box. A text
343
+ bar across an avatar or a button looks wrong. Leafness is not required;
344
+ svg and select routinely have element children. A loaded image paints
345
+ over its own background, so the picture is pushed out of its content
346
+ box (BON-15); only img, video, and canvas honor that. transition: none
347
+ keeps a page `transition: all` from sliding it out on the way in. */
348
+ &:is(
349
+ img,
350
+ svg,
351
+ video,
352
+ canvas,
353
+ picture,
354
+ iframe,
355
+ embed,
356
+ object,
357
+ audio,
358
+ button,
359
+ input,
360
+ select,
361
+ textarea,
362
+ progress,
363
+ meter
364
+ ):not(
365
+ [data-bones-type],
366
+ [data-bones-type] *,
367
+ [data-bones-lines],
368
+ [data-bones-lines] *,
369
+ [data-bones-auto="off"],
370
+ [data-bones-auto="off"] *
371
+ ) {
372
+ color: rgb(from currentcolor r g b / 0);
373
+ border-radius: var(--bone-radius);
374
+ border-color: transparent;
375
+ object-position: 9999px 0;
376
+ transition: none;
377
+ animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
378
+ background: linear-gradient(
379
+ 90deg,
380
+ var(--bone-base) 25%,
381
+ var(--bone-highlight) 50%,
382
+ var(--bone-base) 75%
383
+ );
384
+ background-size: 200% 100%;
385
+ }
71
386
 
72
- img[data-bone="block"],
73
- video[data-bone="block"],
74
- canvas[data-bone="block"] {
75
- color: rgb(from currentcolor r g b / 0);
76
- /* A loaded picture paints over the bone's background-color; pushing it out
77
- of the content box keeps the box and background visible (BON-15).
78
- transition: none keeps a page `transition: all` from sliding the picture
79
- out on the way in; see the matching comment in auto.css. */
80
- object-position: 9999px 0;
81
- transition: none;
82
- }
387
+ /* An img with no src: Chrome renders it as inline alt text, ignores its
388
+ width and height, and draws a broken-image glyph. inline-block restores
389
+ the box; the transparent pixel replaces the alt rendering. Firefox and
390
+ Safari draw no glyph, so this is a Chrome fix that is harmless
391
+ elsewhere. A CSP that blocks data: images brings the glyph back. */
392
+ &:is(img):is(:not([src]), [src=""]):not(
393
+ [data-bones-type],
394
+ [data-bones-type] *,
395
+ [data-bones-lines],
396
+ [data-bones-lines] *,
397
+ [data-bones-auto="off"],
398
+ [data-bones-auto="off"] *
399
+ ) {
400
+ display: inline-block;
401
+ content: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
402
+ }
403
+ }
83
404
 
84
- [data-bone="container"] {
85
- position: relative;
86
- overflow: hidden;
87
- }
405
+ @media (prefers-reduced-motion: reduce) {
406
+ :is([aria-busy="true"], [aria-busy="true"] *) {
407
+ &:not(:has(*)):not(
408
+ [data-bones-type],
409
+ [data-bones-type] *,
410
+ [data-bones-lines],
411
+ [data-bones-lines] *,
412
+ [data-bones-auto="off"],
413
+ [data-bones-auto="off"] *
414
+ ):not(
415
+ img,
416
+ svg,
417
+ video,
418
+ canvas,
419
+ picture,
420
+ iframe,
421
+ embed,
422
+ object,
423
+ audio,
424
+ button,
425
+ input,
426
+ select,
427
+ textarea,
428
+ progress,
429
+ meter,
430
+ hr,
431
+ br,
432
+ svg *,
433
+ picture *,
434
+ select *,
435
+ object *
436
+ )::after,
437
+ &:is(
438
+ img,
439
+ svg,
440
+ video,
441
+ canvas,
442
+ picture,
443
+ iframe,
444
+ embed,
445
+ object,
446
+ audio,
447
+ button,
448
+ input,
449
+ select,
450
+ textarea,
451
+ progress,
452
+ meter
453
+ ):not(
454
+ [data-bones-type],
455
+ [data-bones-type] *,
456
+ [data-bones-lines],
457
+ [data-bones-lines] *,
458
+ [data-bones-auto="off"],
459
+ [data-bones-auto="off"] *
460
+ ) {
461
+ animation: bone-pulse 2s ease-in-out infinite;
462
+ background: var(--bone-base);
463
+ background-size: auto;
464
+ }
465
+ }
466
+ }
88
467
 
89
- [data-bone="container"]::before {
90
- content: "";
91
- position: absolute;
92
- inset: 0;
93
- background-color: var(--bone-base);
94
- z-index: 1;
95
- }
468
+ @media (forced-colors: active) {
469
+ :is([aria-busy="true"], [aria-busy="true"] *) {
470
+ &:not(:has(*)):not(
471
+ [data-bones-type],
472
+ [data-bones-type] *,
473
+ [data-bones-lines],
474
+ [data-bones-lines] *,
475
+ [data-bones-auto="off"],
476
+ [data-bones-auto="off"] *
477
+ ):not(
478
+ img,
479
+ svg,
480
+ video,
481
+ canvas,
482
+ picture,
483
+ iframe,
484
+ embed,
485
+ object,
486
+ audio,
487
+ button,
488
+ input,
489
+ select,
490
+ textarea,
491
+ progress,
492
+ meter,
493
+ hr,
494
+ br,
495
+ svg *,
496
+ picture *,
497
+ select *,
498
+ object *
499
+ )::after,
500
+ &:is(
501
+ img,
502
+ svg,
503
+ video,
504
+ canvas,
505
+ picture,
506
+ iframe,
507
+ embed,
508
+ object,
509
+ audio,
510
+ button,
511
+ input,
512
+ select,
513
+ textarea,
514
+ progress,
515
+ meter
516
+ ):not(
517
+ [data-bones-type],
518
+ [data-bones-type] *,
519
+ [data-bones-lines],
520
+ [data-bones-lines] *,
521
+ [data-bones-auto="off"],
522
+ [data-bones-auto="off"] *
523
+ ) {
524
+ background-color: GrayText;
525
+ forced-color-adjust: none;
526
+ }
527
+ }
528
+ }
96
529
 
97
- [data-bone="container"] > * {
98
- visibility: hidden;
99
- }
530
+ /* :is(:scope, :scope *) lets the attribute work from the bone itself, from
531
+ the busy element, or from any ancestor: a scoped selector's implicit
532
+ :scope prefix matches strict descendants only (BON-17). The reduced-motion
533
+ override sits inside each animated scope so it matches at the scope's own
534
+ specificity; "none" has no override because none still means none. */
535
+ @scope ([data-bones-animate="shimmer"]) to ([data-bones-animate]:not([data-bones-animate="shimmer"])) {
536
+ :is([aria-busy="true"], [aria-busy="true"] *):is(:scope, :scope *) {
537
+ &:not(:has(*)):not(
538
+ [data-bones-type],
539
+ [data-bones-type] *,
540
+ [data-bones-lines],
541
+ [data-bones-lines] *,
542
+ [data-bones-auto="off"],
543
+ [data-bones-auto="off"] *
544
+ ):not(
545
+ img,
546
+ svg,
547
+ video,
548
+ canvas,
549
+ picture,
550
+ iframe,
551
+ embed,
552
+ object,
553
+ audio,
554
+ button,
555
+ input,
556
+ select,
557
+ textarea,
558
+ progress,
559
+ meter,
560
+ hr,
561
+ br,
562
+ svg *,
563
+ picture *,
564
+ select *,
565
+ object *
566
+ )::after,
567
+ &:is(
568
+ img,
569
+ svg,
570
+ video,
571
+ canvas,
572
+ picture,
573
+ iframe,
574
+ embed,
575
+ object,
576
+ audio,
577
+ button,
578
+ input,
579
+ select,
580
+ textarea,
581
+ progress,
582
+ meter
583
+ ):not(
584
+ [data-bones-type],
585
+ [data-bones-type] *,
586
+ [data-bones-lines],
587
+ [data-bones-lines] *,
588
+ [data-bones-auto="off"],
589
+ [data-bones-auto="off"] *
590
+ ) {
591
+ animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
592
+ background: linear-gradient(
593
+ 90deg,
594
+ var(--bone-base) 25%,
595
+ var(--bone-highlight) 50%,
596
+ var(--bone-base) 75%
597
+ );
598
+ background-size: 200% 100%;
599
+ }
600
+ @media (prefers-reduced-motion: reduce) {
601
+ &:not(:has(*)):not(
602
+ [data-bones-type],
603
+ [data-bones-type] *,
604
+ [data-bones-lines],
605
+ [data-bones-lines] *,
606
+ [data-bones-auto="off"],
607
+ [data-bones-auto="off"] *
608
+ ):not(
609
+ img,
610
+ svg,
611
+ video,
612
+ canvas,
613
+ picture,
614
+ iframe,
615
+ embed,
616
+ object,
617
+ audio,
618
+ button,
619
+ input,
620
+ select,
621
+ textarea,
622
+ progress,
623
+ meter,
624
+ hr,
625
+ br,
626
+ svg *,
627
+ picture *,
628
+ select *,
629
+ object *
630
+ )::after,
631
+ &:is(
632
+ img,
633
+ svg,
634
+ video,
635
+ canvas,
636
+ picture,
637
+ iframe,
638
+ embed,
639
+ object,
640
+ audio,
641
+ button,
642
+ input,
643
+ select,
644
+ textarea,
645
+ progress,
646
+ meter
647
+ ):not(
648
+ [data-bones-type],
649
+ [data-bones-type] *,
650
+ [data-bones-lines],
651
+ [data-bones-lines] *,
652
+ [data-bones-auto="off"],
653
+ [data-bones-auto="off"] *
654
+ ) {
655
+ animation: bone-pulse 2s ease-in-out infinite;
656
+ background: var(--bone-base);
657
+ background-size: auto;
658
+ }
659
+ }
660
+ }
661
+ }
100
662
 
101
- @keyframes bone-shimmer {
102
- 0% {
103
- background-position: 200% 0;
663
+ @scope ([data-bones-animate="pulse"]) to ([data-bones-animate]:not([data-bones-animate="pulse"])) {
664
+ :is([aria-busy="true"], [aria-busy="true"] *):is(:scope, :scope *) {
665
+ &:not(:has(*)):not(
666
+ [data-bones-type],
667
+ [data-bones-type] *,
668
+ [data-bones-lines],
669
+ [data-bones-lines] *,
670
+ [data-bones-auto="off"],
671
+ [data-bones-auto="off"] *
672
+ ):not(
673
+ img,
674
+ svg,
675
+ video,
676
+ canvas,
677
+ picture,
678
+ iframe,
679
+ embed,
680
+ object,
681
+ audio,
682
+ button,
683
+ input,
684
+ select,
685
+ textarea,
686
+ progress,
687
+ meter,
688
+ hr,
689
+ br,
690
+ svg *,
691
+ picture *,
692
+ select *,
693
+ object *
694
+ )::after,
695
+ &:is(
696
+ img,
697
+ svg,
698
+ video,
699
+ canvas,
700
+ picture,
701
+ iframe,
702
+ embed,
703
+ object,
704
+ audio,
705
+ button,
706
+ input,
707
+ select,
708
+ textarea,
709
+ progress,
710
+ meter
711
+ ):not(
712
+ [data-bones-type],
713
+ [data-bones-type] *,
714
+ [data-bones-lines],
715
+ [data-bones-lines] *,
716
+ [data-bones-auto="off"],
717
+ [data-bones-auto="off"] *
718
+ ) {
719
+ animation: bone-pulse var(--bone-duration) ease-in-out infinite;
720
+ background: var(--bone-base);
721
+ background-size: auto;
722
+ }
723
+ @media (prefers-reduced-motion: reduce) {
724
+ &:not(:has(*)):not(
725
+ [data-bones-type],
726
+ [data-bones-type] *,
727
+ [data-bones-lines],
728
+ [data-bones-lines] *,
729
+ [data-bones-auto="off"],
730
+ [data-bones-auto="off"] *
731
+ ):not(
732
+ img,
733
+ svg,
734
+ video,
735
+ canvas,
736
+ picture,
737
+ iframe,
738
+ embed,
739
+ object,
740
+ audio,
741
+ button,
742
+ input,
743
+ select,
744
+ textarea,
745
+ progress,
746
+ meter,
747
+ hr,
748
+ br,
749
+ svg *,
750
+ picture *,
751
+ select *,
752
+ object *
753
+ )::after,
754
+ &:is(
755
+ img,
756
+ svg,
757
+ video,
758
+ canvas,
759
+ picture,
760
+ iframe,
761
+ embed,
762
+ object,
763
+ audio,
764
+ button,
765
+ input,
766
+ select,
767
+ textarea,
768
+ progress,
769
+ meter
770
+ ):not(
771
+ [data-bones-type],
772
+ [data-bones-type] *,
773
+ [data-bones-lines],
774
+ [data-bones-lines] *,
775
+ [data-bones-auto="off"],
776
+ [data-bones-auto="off"] *
777
+ ) {
778
+ animation: bone-pulse 2s ease-in-out infinite;
779
+ }
780
+ }
781
+ }
104
782
  }
105
- 100% {
106
- background-position: -200% 0;
783
+
784
+ @scope ([data-bones-animate="none"]) to ([data-bones-animate]:not([data-bones-animate="none"])) {
785
+ :is([aria-busy="true"], [aria-busy="true"] *):is(:scope, :scope *) {
786
+ &:not(:has(*)):not(
787
+ [data-bones-type],
788
+ [data-bones-type] *,
789
+ [data-bones-lines],
790
+ [data-bones-lines] *,
791
+ [data-bones-auto="off"],
792
+ [data-bones-auto="off"] *
793
+ ):not(
794
+ img,
795
+ svg,
796
+ video,
797
+ canvas,
798
+ picture,
799
+ iframe,
800
+ embed,
801
+ object,
802
+ audio,
803
+ button,
804
+ input,
805
+ select,
806
+ textarea,
807
+ progress,
808
+ meter,
809
+ hr,
810
+ br,
811
+ svg *,
812
+ picture *,
813
+ select *,
814
+ object *
815
+ )::after,
816
+ &:is(
817
+ img,
818
+ svg,
819
+ video,
820
+ canvas,
821
+ picture,
822
+ iframe,
823
+ embed,
824
+ object,
825
+ audio,
826
+ button,
827
+ input,
828
+ select,
829
+ textarea,
830
+ progress,
831
+ meter
832
+ ):not(
833
+ [data-bones-type],
834
+ [data-bones-type] *,
835
+ [data-bones-lines],
836
+ [data-bones-lines] *,
837
+ [data-bones-auto="off"],
838
+ [data-bones-auto="off"] *
839
+ ) {
840
+ animation: none;
841
+ background: var(--bone-base);
842
+ background-size: auto;
843
+ }
844
+ }
107
845
  }
108
846
  }
109
847
 
110
- @keyframes bone-pulse {
111
- 0%,
112
- 100% {
113
- opacity: 1;
848
+ /* ---------------------------------------------------------------------------
849
+ Explicit. Wins over page CSS. Same geometry as the inferred rules above;
850
+ the test suite pins the copies equal.
851
+ --------------------------------------------------------------------------- */
852
+
853
+ :is([aria-busy="true"], [aria-busy="true"] *) {
854
+ &:is([data-bones-type="text"], [data-bones-lines]) {
855
+ --bones-bar-top: calc((1lh + 1cap) / 2 - 1ex);
856
+ --bones-r: max(0.01px, min(var(--bone-radius), 0.5ex));
857
+ --bones-inset: max(0px, var(--bones-r) - 1px);
858
+ color: rgb(from currentcolor r g b / 0);
859
+ position: relative;
860
+ min-width: calc(var(--bones-length, 4) * 1ch);
861
+ min-height: calc(var(--bones-lines, 1) * 1lh);
862
+ &::before {
863
+ content: "\200b";
864
+ }
865
+ &::after {
866
+ content: "";
867
+ position: absolute;
868
+ inset-inline: 0;
869
+ top: var(--bones-bar-top);
870
+ height: 1ex;
871
+ padding: inherit !important;
872
+ box-sizing: content-box !important;
873
+ clip-path: inset(0 round var(--bones-r)) content-box;
874
+ pointer-events: none;
875
+ animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
876
+ background: linear-gradient(
877
+ 90deg,
878
+ var(--bone-base) 25%,
879
+ var(--bone-highlight) 50%,
880
+ var(--bone-base) 75%
881
+ );
882
+ background-size: 200% 100%;
883
+ }
114
884
  }
115
- 50% {
116
- opacity: 0.5;
885
+ &[data-bones-type="text"]:empty::before {
886
+ content: "";
887
+ display: inline-block;
888
+ min-width: calc(var(--bones-length, 4) * 1ch);
117
889
  }
118
- }
119
890
 
120
- /* Marked bones shimmer by default, the same as auto bones and the measured
121
- overlay (BON-16). data-bone-animate="none" is the opt-out; the @scope
122
- blocks below override this at higher specificity, so their order relative
123
- to this rule does not matter. */
124
- [data-bone="text"]::after,
125
- [data-bone="block"],
126
- [data-bone="container"]::before {
127
- animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
128
- background: linear-gradient(
129
- 90deg,
130
- var(--bone-base) 25%,
131
- var(--bone-highlight) 50%,
132
- var(--bone-base) 75%
133
- );
134
- background-size: 200% 100%;
135
- }
891
+ /* Width in characters. A modifier, not a type: an inferred leaf with a
892
+ length is still inferred. Unlayered so a page width rule does not beat
893
+ a length the author wrote into the markup. On an inline box width does
894
+ nothing; the empty leaf's ::before carries the length there. */
895
+ &[data-bones-length] {
896
+ width: calc(var(--bones-length, 4) * 1ch);
897
+ max-width: 100%;
898
+ }
136
899
 
137
- @media (prefers-reduced-motion: reduce) {
138
- [data-bone="text"]::after,
139
- [data-bone="block"],
140
- [data-bone="container"]::before {
141
- animation: bone-pulse 2s ease-in-out infinite;
142
- background: var(--bone-base);
143
- background-size: auto;
900
+ /* N lines in one element. ::after is the single bar above, moved to the
901
+ bottom row and shortened. ::before covers the rows above it and a
902
+ three-layer mask tiled every 1lh cuts it into bars: a stripe body inset
903
+ by the corner radius, and a left and a right cap, each a whole ellipse
904
+ (rx = radius, ry = half the bar) drawn one radius in from the end of a
905
+ half-width tile. The layers combine as a union, and the browser snaps
906
+ each layer's tile to device pixels on its own, so nothing visible may
907
+ sit on a tile edge: the ellipse's inner half hides under the body, its
908
+ outer half is the cap, and the body overlaps it by a pixel
909
+ (--bones-inset) so a snapped tile edge cannot open a gap. At the default
910
+ 4px the ends read as rounded corners; at 999px they clamp to a pill.
911
+ Because ::before fills
912
+ the box down to the last row, an element taller than N lines still gets
913
+ a bar on every row. */
914
+ &[data-bones-lines] {
915
+ display: block;
916
+ --bones-last: 60%;
917
+ &::before {
918
+ content: "";
919
+ position: absolute;
920
+ inset: 0 0 1lh 0;
921
+ padding: inherit;
922
+ box-sizing: content-box;
923
+ pointer-events: none;
924
+ animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
925
+ background: linear-gradient(
926
+ 90deg,
927
+ var(--bone-base) 25%,
928
+ var(--bone-highlight) 50%,
929
+ var(--bone-base) 75%
930
+ );
931
+ background-size: 200% 100%;
932
+ mask-image:
933
+ linear-gradient(
934
+ to bottom,
935
+ transparent var(--bones-bar-top),
936
+ #000 var(--bones-bar-top) calc(var(--bones-bar-top) + 1ex),
937
+ transparent calc(var(--bones-bar-top) + 1ex)
938
+ ),
939
+ radial-gradient(
940
+ ellipse var(--bones-r) 0.5ex at var(--bones-r) calc(var(--bones-bar-top) + 0.5ex),
941
+ #000 calc(100% - 0.5px),
942
+ transparent 100%
943
+ ),
944
+ radial-gradient(
945
+ ellipse var(--bones-r) 0.5ex at calc(100% - var(--bones-r))
946
+ calc(var(--bones-bar-top) + 0.5ex),
947
+ #000 calc(100% - 0.5px),
948
+ transparent 100%
949
+ );
950
+ mask-size:
951
+ calc(100% - 2 * var(--bones-inset)) 1lh,
952
+ 50% 1lh,
953
+ 50% 1lh;
954
+ mask-position:
955
+ var(--bones-inset) 0,
956
+ left 0,
957
+ right 0;
958
+ mask-repeat: repeat-y;
959
+ mask-origin: content-box;
960
+ mask-clip: content-box;
961
+ }
962
+ &::after {
963
+ top: auto;
964
+ bottom: calc(1lh - var(--bones-bar-top) - 1ex);
965
+ width: var(--bones-last);
966
+ }
144
967
  }
145
- }
146
968
 
147
- /* :is(:scope, :scope *) lets the attribute work from the bone itself, not
148
- only from a wrapper: a scoped selector's implicit :scope prefix matches
149
- strict descendants only, so a plain compound never matches the scope root
150
- (BON-17). The reduced-motion override sits inside each animated scope so it
151
- matches at the scope's own specificity; "none" has no override because none
152
- still means none. */
153
- @scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) {
154
- [data-bone="text"]:is(:scope, :scope *)::after,
155
- [data-bone="block"]:is(:scope, :scope *),
156
- [data-bone="container"]:is(:scope, :scope *)::before {
969
+ &[data-bones-type="block"] {
970
+ color: rgb(from currentcolor r g b / 0);
971
+ border-radius: var(--bone-radius);
972
+ border-color: transparent;
973
+ object-position: 9999px 0;
974
+ transition: none;
157
975
  animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
158
976
  background: linear-gradient(
159
977
  90deg,
@@ -163,11 +981,20 @@ canvas[data-bone="block"] {
163
981
  );
164
982
  background-size: 200% 100%;
165
983
  }
984
+ &[data-bones-type="block"] > * {
985
+ visibility: hidden;
986
+ }
987
+ &[data-bones-type="block"]:is(img):is(:not([src]), [src=""]) {
988
+ display: inline-block;
989
+ content: url("data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
990
+ }
991
+ }
166
992
 
167
- @media (prefers-reduced-motion: reduce) {
168
- [data-bone="text"]:is(:scope, :scope *)::after,
169
- [data-bone="block"]:is(:scope, :scope *),
170
- [data-bone="container"]:is(:scope, :scope *)::before {
993
+ @media (prefers-reduced-motion: reduce) {
994
+ :is([aria-busy="true"], [aria-busy="true"] *) {
995
+ &:is([data-bones-type="text"], [data-bones-lines])::after,
996
+ &[data-bones-lines]::before,
997
+ &[data-bones-type="block"] {
171
998
  animation: bone-pulse 2s ease-in-out infinite;
172
999
  background: var(--bone-base);
173
1000
  background-size: auto;
@@ -175,30 +1002,70 @@ canvas[data-bone="block"] {
175
1002
  }
176
1003
  }
177
1004
 
178
- @scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) {
179
- [data-bone="text"]:is(:scope, :scope *)::after,
180
- [data-bone="block"]:is(:scope, :scope *),
181
- [data-bone="container"]:is(:scope, :scope *)::before {
182
- animation: bone-pulse var(--bone-duration) ease-in-out infinite;
183
- background: var(--bone-base);
184
- background-size: auto;
1005
+ @media (forced-colors: active) {
1006
+ :is([aria-busy="true"], [aria-busy="true"] *) {
1007
+ &:is([data-bones-type="text"], [data-bones-lines])::after,
1008
+ &[data-bones-lines]::before,
1009
+ &[data-bones-type="block"] {
1010
+ background-color: GrayText;
1011
+ forced-color-adjust: none;
1012
+ }
185
1013
  }
1014
+ }
186
1015
 
187
- @media (prefers-reduced-motion: reduce) {
188
- [data-bone="text"]:is(:scope, :scope *)::after,
189
- [data-bone="block"]:is(:scope, :scope *),
190
- [data-bone="container"]:is(:scope, :scope *)::before {
191
- animation: bone-pulse 2s ease-in-out infinite;
1016
+ @scope ([data-bones-animate="shimmer"]) to ([data-bones-animate]:not([data-bones-animate="shimmer"])) {
1017
+ :is([aria-busy="true"], [aria-busy="true"] *):is(:scope, :scope *) {
1018
+ &:is([data-bones-type="text"], [data-bones-lines])::after,
1019
+ &[data-bones-lines]::before,
1020
+ &[data-bones-type="block"] {
1021
+ animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
1022
+ background: linear-gradient(
1023
+ 90deg,
1024
+ var(--bone-base) 25%,
1025
+ var(--bone-highlight) 50%,
1026
+ var(--bone-base) 75%
1027
+ );
1028
+ background-size: 200% 100%;
1029
+ }
1030
+ @media (prefers-reduced-motion: reduce) {
1031
+ &:is([data-bones-type="text"], [data-bones-lines])::after,
1032
+ &[data-bones-lines]::before,
1033
+ &[data-bones-type="block"] {
1034
+ animation: bone-pulse 2s ease-in-out infinite;
1035
+ background: var(--bone-base);
1036
+ background-size: auto;
1037
+ }
192
1038
  }
193
1039
  }
194
1040
  }
195
1041
 
196
- @scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) {
197
- [data-bone="text"]:is(:scope, :scope *)::after,
198
- [data-bone="block"]:is(:scope, :scope *),
199
- [data-bone="container"]:is(:scope, :scope *)::before {
200
- animation: none;
201
- background: var(--bone-base);
202
- background-size: auto;
1042
+ @scope ([data-bones-animate="pulse"]) to ([data-bones-animate]:not([data-bones-animate="pulse"])) {
1043
+ :is([aria-busy="true"], [aria-busy="true"] *):is(:scope, :scope *) {
1044
+ &:is([data-bones-type="text"], [data-bones-lines])::after,
1045
+ &[data-bones-lines]::before,
1046
+ &[data-bones-type="block"] {
1047
+ animation: bone-pulse var(--bone-duration) ease-in-out infinite;
1048
+ background: var(--bone-base);
1049
+ background-size: auto;
1050
+ }
1051
+ @media (prefers-reduced-motion: reduce) {
1052
+ &:is([data-bones-type="text"], [data-bones-lines])::after,
1053
+ &[data-bones-lines]::before,
1054
+ &[data-bones-type="block"] {
1055
+ animation: bone-pulse 2s ease-in-out infinite;
1056
+ }
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ @scope ([data-bones-animate="none"]) to ([data-bones-animate]:not([data-bones-animate="none"])) {
1062
+ :is([aria-busy="true"], [aria-busy="true"] *):is(:scope, :scope *) {
1063
+ &:is([data-bones-type="text"], [data-bones-lines])::after,
1064
+ &[data-bones-lines]::before,
1065
+ &[data-bones-type="block"] {
1066
+ animation: none;
1067
+ background: var(--bone-base);
1068
+ background-size: auto;
1069
+ }
203
1070
  }
204
1071
  }