@camp.dev/bones 0.2.0 → 0.4.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 CHANGED
@@ -14,7 +14,7 @@ Bones skips the duplication. You write your markup once and it handles both stat
14
14
 
15
15
  - Works in Server Components. No hooks, no context, no `'use client'`.
16
16
  - One component handles both loading and loaded states.
17
- - Pass a promise as a prop. Bones wires up Suspense for you.
17
+ - Pass data or a promise. A pending promise suspends to your `<Suspense>` boundary; `forceBones` renders the skeleton.
18
18
  - Skeletons are pure CSS, themed with custom properties.
19
19
  - Loading elements get `aria-busy="true"` automatically.
20
20
 
@@ -32,12 +32,14 @@ import "@camp.dev/bones/css";
32
32
 
33
33
  ## Entry points
34
34
 
35
- | Import | Contents |
36
- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
37
- | `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
38
- | `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
39
- | `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
40
- | `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
35
+ | Import | Contents |
36
+ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
37
+ | `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `isMinMax`, `BonesBoundary` |
38
+ | `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
39
+ | `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
40
+ | `@camp.dev/bones/element` | `<bones-boundary>`, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. `precision="measured"` draws pixel-accurate per-line bones measured from the content. |
41
+ | `@camp.dev/bones/server` | `streamBones` and the wire-protocol primitives: stream a shell with busy boundaries, then flush each region's content out of order as it resolves. |
42
+ | `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
41
43
 
42
44
  React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it.
43
45
 
@@ -63,21 +65,23 @@ function ProfileCard({ user }: { user: Promise<User> | User }) {
63
65
  }
64
66
  ```
65
67
 
66
- Wrap components that receive promises in `<Bones>`. It creates a Suspense boundary and generates the skeleton fallback for you:
68
+ Pass the promise to the component, and reuse the same component with `forceBones` as the fallback:
67
69
 
68
70
  ```tsx
69
- import { Bones } from "@camp.dev/bones/react";
71
+ import { forceBones } from "@camp.dev/bones/react";
72
+ import { Suspense } from "react";
70
73
 
71
74
  export default function Page() {
75
+ const user = fetchUser();
72
76
  return (
73
- <Bones>
74
- <ProfileCard user={fetchUser()} />
75
- </Bones>
77
+ <Suspense fallback={<ProfileCard user={forceBones} />}>
78
+ <ProfileCard user={user} />
79
+ </Suspense>
76
80
  );
77
81
  }
78
82
  ```
79
83
 
80
- While the promise is pending, `<Bones>` renders the same `<ProfileCard>` tree with skeletons visible. Once it resolves, the real content swaps in.
84
+ While the promise is pending, the fallback renders `<ProfileCard>` with skeletons visible. Once it resolves, the real content swaps in. There is no separate skeleton component to keep in sync — the fallback is the component.
81
85
 
82
86
  ## Bone types
83
87
 
@@ -97,17 +101,21 @@ import { createBones, forceBones } from "@camp.dev/bones/react";
97
101
  <ProfileCard user={forceBones} />;
98
102
  ```
99
103
 
100
- To force an entire subtree into skeleton mode at once, wrap it with `<BonesForce>`:
104
+ To force a subtree into skeleton mode at once, pass `forceBones` to each component in it:
101
105
 
102
106
  ```tsx
103
- import { BonesForce } from "@camp.dev/bones/react";
107
+ <ProfileCard user={forceBones} />
108
+ <PostList posts={forceBones} />
109
+ ```
110
+
111
+ `forceBones` only forces the component it's passed to. A component that derives child props from its own data must forward it itself, such as `PostList` mapping items into cards. `repeat` yields `undefined` for items that don't exist yet:
104
112
 
105
- <BonesForce>
106
- <ProfileCard />
107
- <PostList />
108
- </BonesForce>;
113
+ ```tsx
114
+ <PostCard key={item?.id ?? i} post={item ?? forceBones} />
109
115
  ```
110
116
 
117
+ For content that has no bone markup at all, wrap it in [`<bones-boundary force>`](https://github.com/campdotdev/bones/blob/main/apps/docs/content/docs/api/bones-boundary.mdx) and let `auto.css` draw leaf bones.
118
+
111
119
  ## Automatic skeletons
112
120
 
113
121
  For markup you haven't wired up with `bone()` — third-party components, server-rendered HTML, anything without explicit attributes — import the auto stylesheet. It imports `/css` itself, so this one file is a complete setup:
@@ -127,7 +135,26 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b
127
135
 
128
136
  `[data-bones-auto="off"]` opts a subtree out — useful for a status message you want to stay readable while its container skeletonizes. Explicit `data-bone` markup is left alone; `auto.css` only styles elements neither `bone()` nor a manual `data-bone` attribute has already claimed.
129
137
 
130
- Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` also has to sit on an ancestor of the `aria-busy` element set directly on it, it has no effect. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.
138
+ Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` works on the `aria-busy` element itself or on any ancestor. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, every bone shimmers, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.
139
+
140
+ ## Without React
141
+
142
+ `<bones-boundary>` manages the loading state for any stack. Set `busy` when a request starts and clear it when the response lands. The element waits 200 ms before showing bones and keeps them for at least 400 ms, then crossfades to content with the View Transitions API where available.
143
+
144
+ ```html
145
+ <script type="module">
146
+ import "@camp.dev/bones/element";
147
+ </script>
148
+
149
+ <bones-boundary busy>
150
+ <h2>Title</h2>
151
+ <p>Body copy.</p>
152
+ </bones-boundary>
153
+ ```
154
+
155
+ `@camp.dev/bones/element` is a bare specifier. A browser cannot resolve it on its own, so this snippet needs a bundler or an import map. To load the element straight from a CDN in a plain HTML file, see the URL form on the [bones-boundary docs page](https://github.com/campdotdev/bones/blob/main/apps/docs/content/docs/api/bones-boundary.mdx).
156
+
157
+ Pair it with `auto.css` for zero-markup skeletons, or with `data-bone` markup from `boneAttributes`. The element is also exported for React as `<BonesBoundary>` from `@camp.dev/bones/react`.
131
158
 
132
159
  ## Development
133
160
 
package/dist/css/auto.css CHANGED
@@ -44,7 +44,7 @@
44
44
  select *,
45
45
  object *
46
46
  ) {
47
- color: transparent;
47
+ color: rgb(from currentcolor r g b / 0);
48
48
  position: relative;
49
49
  min-width: 4ch;
50
50
  min-height: 1lh;
@@ -144,7 +144,7 @@
144
144
  progress,
145
145
  meter
146
146
  ):not([data-bone], [data-bone] *, [data-bones-auto="off"], [data-bones-auto="off"] *) {
147
- color: transparent;
147
+ color: rgb(from currentcolor r g b / 0);
148
148
  background-color: var(--bone-base);
149
149
  border-radius: var(--bone-radius);
150
150
  border-color: transparent;
@@ -283,10 +283,13 @@
283
283
  }
284
284
 
285
285
  @layer bones-auto {
286
- /* Auto bones default to shimmer. bones.css only animates inside a
287
- [data-bone-animate] scope; a zero-markup page has no such attribute, so
288
- the default lives here and the scopes below mirror bones.css for pages
289
- that do set it. */
286
+ /* Auto bones default to shimmer. bones.css has its own default, but its
287
+ rules match [data-bone] elements only and never the unmarked leaves
288
+ targeted here, so the default is restated for them, and the scopes below
289
+ mirror bones.css for pages that set the attribute (BON-16). In those
290
+ scopes, :is(:scope, :scope *) lets the attribute
291
+ sit on the aria-busy element itself: a scoped selector's implicit :scope
292
+ prefix matches strict descendants only (BON-17). */
290
293
  [aria-busy="true"]
291
294
  :not(:has(*)):not(
292
295
  [data-bone],
@@ -374,7 +377,7 @@
374
377
  }
375
378
 
376
379
  @scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) {
377
- [aria-busy="true"]
380
+ [aria-busy="true"]:is(:scope, :scope *)
378
381
  :not(:has(*)):not(
379
382
  [data-bone],
380
383
  [data-bone] *,
@@ -403,7 +406,7 @@
403
406
  select *,
404
407
  object *
405
408
  )::before,
406
- [aria-busy="true"]
409
+ [aria-busy="true"]:is(:scope, :scope *)
407
410
  :not(:has(*)):not(
408
411
  [data-bone],
409
412
  [data-bone] *,
@@ -432,7 +435,7 @@
432
435
  select *,
433
436
  object *
434
437
  )::after,
435
- [aria-busy="true"]
438
+ [aria-busy="true"]:is(:scope, :scope *)
436
439
  :is(
437
440
  img,
438
441
  svg,
@@ -461,7 +464,7 @@
461
464
  }
462
465
 
463
466
  @media (prefers-reduced-motion: reduce) {
464
- [aria-busy="true"]
467
+ [aria-busy="true"]:is(:scope, :scope *)
465
468
  :not(:has(*)):not(
466
469
  [data-bone],
467
470
  [data-bone] *,
@@ -490,7 +493,7 @@
490
493
  select *,
491
494
  object *
492
495
  )::before,
493
- [aria-busy="true"]
496
+ [aria-busy="true"]:is(:scope, :scope *)
494
497
  :not(:has(*)):not(
495
498
  [data-bone],
496
499
  [data-bone] *,
@@ -519,7 +522,7 @@
519
522
  select *,
520
523
  object *
521
524
  )::after,
522
- [aria-busy="true"]
525
+ [aria-busy="true"]:is(:scope, :scope *)
523
526
  :is(
524
527
  img,
525
528
  svg,
@@ -544,7 +547,7 @@
544
547
  }
545
548
 
546
549
  @media (forced-colors: active) {
547
- [aria-busy="true"]
550
+ [aria-busy="true"]:is(:scope, :scope *)
548
551
  :not(:has(*)):not(
549
552
  [data-bone],
550
553
  [data-bone] *,
@@ -573,7 +576,7 @@
573
576
  select *,
574
577
  object *
575
578
  )::after,
576
- [aria-busy="true"]
579
+ [aria-busy="true"]:is(:scope, :scope *)
577
580
  :is(
578
581
  img,
579
582
  svg,
@@ -598,7 +601,7 @@
598
601
  }
599
602
 
600
603
  @scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) {
601
- [aria-busy="true"]
604
+ [aria-busy="true"]:is(:scope, :scope *)
602
605
  :not(:has(*)):not(
603
606
  [data-bone],
604
607
  [data-bone] *,
@@ -627,7 +630,7 @@
627
630
  select *,
628
631
  object *
629
632
  )::before,
630
- [aria-busy="true"]
633
+ [aria-busy="true"]:is(:scope, :scope *)
631
634
  :not(:has(*)):not(
632
635
  [data-bone],
633
636
  [data-bone] *,
@@ -656,7 +659,7 @@
656
659
  select *,
657
660
  object *
658
661
  )::after,
659
- [aria-busy="true"]
662
+ [aria-busy="true"]:is(:scope, :scope *)
660
663
  :is(
661
664
  img,
662
665
  svg,
@@ -680,7 +683,7 @@
680
683
  }
681
684
 
682
685
  @media (prefers-reduced-motion: reduce) {
683
- [aria-busy="true"]
686
+ [aria-busy="true"]:is(:scope, :scope *)
684
687
  :not(:has(*)):not(
685
688
  [data-bone],
686
689
  [data-bone] *,
@@ -709,7 +712,7 @@
709
712
  select *,
710
713
  object *
711
714
  )::before,
712
- [aria-busy="true"]
715
+ [aria-busy="true"]:is(:scope, :scope *)
713
716
  :not(:has(*)):not(
714
717
  [data-bone],
715
718
  [data-bone] *,
@@ -738,7 +741,7 @@
738
741
  select *,
739
742
  object *
740
743
  )::after,
741
- [aria-busy="true"]
744
+ [aria-busy="true"]:is(:scope, :scope *)
742
745
  :is(
743
746
  img,
744
747
  svg,
@@ -763,7 +766,7 @@
763
766
  }
764
767
 
765
768
  @media (forced-colors: active) {
766
- [aria-busy="true"]
769
+ [aria-busy="true"]:is(:scope, :scope *)
767
770
  :not(:has(*)):not(
768
771
  [data-bone],
769
772
  [data-bone] *,
@@ -792,7 +795,7 @@
792
795
  select *,
793
796
  object *
794
797
  )::after,
795
- [aria-busy="true"]
798
+ [aria-busy="true"]:is(:scope, :scope *)
796
799
  :is(
797
800
  img,
798
801
  svg,
@@ -817,7 +820,7 @@
817
820
  }
818
821
 
819
822
  @scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) {
820
- [aria-busy="true"]
823
+ [aria-busy="true"]:is(:scope, :scope *)
821
824
  :not(:has(*)):not(
822
825
  [data-bone],
823
826
  [data-bone] *,
@@ -846,7 +849,7 @@
846
849
  select *,
847
850
  object *
848
851
  )::before,
849
- [aria-busy="true"]
852
+ [aria-busy="true"]:is(:scope, :scope *)
850
853
  :not(:has(*)):not(
851
854
  [data-bone],
852
855
  [data-bone] *,
@@ -875,7 +878,7 @@
875
878
  select *,
876
879
  object *
877
880
  )::after,
878
- [aria-busy="true"]
881
+ [aria-busy="true"]:is(:scope, :scope *)
879
882
  :is(
880
883
  img,
881
884
  svg,
@@ -899,7 +902,7 @@
899
902
  }
900
903
 
901
904
  @media (forced-colors: active) {
902
- [aria-busy="true"]
905
+ [aria-busy="true"]:is(:scope, :scope *)
903
906
  :not(:has(*)):not(
904
907
  [data-bone],
905
908
  [data-bone] *,
@@ -928,7 +931,7 @@
928
931
  select *,
929
932
  object *
930
933
  )::after,
931
- [aria-busy="true"]
934
+ [aria-busy="true"]:is(:scope, :scope *)
932
935
  :is(
933
936
  img,
934
937
  svg,
@@ -1090,3 +1093,13 @@
1090
1093
  }
1091
1094
  }
1092
1095
  }
1096
+
1097
+ @layer bones-auto {
1098
+ /* precision="measured" hides a boundary's content with inherited
1099
+ visibility from the shadow side. Visibility, unlike display, can be
1100
+ switched back on by a descendant, so the opt-out contract survives
1101
+ measured mode: exempt subtrees stay visible under the overlay. */
1102
+ bones-boundary[data-bones-measured] [data-bones-auto="off"] {
1103
+ visibility: visible;
1104
+ }
1105
+ }
@@ -1,19 +1,18 @@
1
1
  :root {
2
- --bone-base: rgba(0, 0, 0, 0.12);
3
- --bone-highlight: rgba(0, 0, 0, 0.06);
2
+ /* Bones derive their color from the text color in effect at the bone, so
3
+ they inherit the page's own contrast guarantee on any background in any
4
+ scheme (BON-13). currentColor resolves where var() is used, not here.
5
+ The alpha reset matters: bones hide their content by zeroing the alpha
6
+ of the color property (never with the transparent keyword, which would
7
+ discard the channels), and the mix restores it. */
8
+ --bone-base: color-mix(in srgb, rgb(from currentcolor r g b / 1) 12%, transparent);
9
+ --bone-highlight: color-mix(in srgb, rgb(from currentcolor r g b / 1) 6%, transparent);
4
10
  --bone-radius: 4px;
5
11
  --bone-duration: 1.5s;
6
12
  }
7
13
 
8
- @media (prefers-color-scheme: dark) {
9
- :root {
10
- --bone-base: rgba(255, 255, 255, 0.12);
11
- --bone-highlight: rgba(255, 255, 255, 0.06);
12
- }
13
- }
14
-
15
14
  [data-bone="text"] {
16
- color: transparent;
15
+ color: rgb(from currentcolor r g b / 0);
17
16
  position: relative;
18
17
  min-width: 4ch;
19
18
  min-height: 1lh;
@@ -72,7 +71,7 @@
72
71
 
73
72
  img[data-bone="block"],
74
73
  video[data-bone="block"] {
75
- color: transparent;
74
+ color: rgb(from currentcolor r g b / 0);
76
75
  }
77
76
 
78
77
  [data-bone="container"] {
@@ -111,11 +110,43 @@ video[data-bone="block"] {
111
110
  }
112
111
  }
113
112
 
114
- @scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) {
113
+ /* Marked bones shimmer by default, the same as auto bones and the measured
114
+ overlay (BON-16). data-bone-animate="none" is the opt-out; the @scope
115
+ blocks below override this at higher specificity, so their order relative
116
+ to this rule does not matter. */
117
+ [data-bone="text"]::after,
118
+ [data-bone="block"],
119
+ [data-bone="container"]::before {
120
+ animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
121
+ background: linear-gradient(
122
+ 90deg,
123
+ var(--bone-base) 25%,
124
+ var(--bone-highlight) 50%,
125
+ var(--bone-base) 75%
126
+ );
127
+ background-size: 200% 100%;
128
+ }
129
+
130
+ @media (prefers-reduced-motion: reduce) {
115
131
  [data-bone="text"]::after,
116
- [data-bone="text"]::before,
117
132
  [data-bone="block"],
118
133
  [data-bone="container"]::before {
134
+ animation: bone-pulse 2s ease-in-out infinite;
135
+ background: var(--bone-base);
136
+ background-size: auto;
137
+ }
138
+ }
139
+
140
+ /* :is(:scope, :scope *) lets the attribute work from the bone itself, not
141
+ only from a wrapper: a scoped selector's implicit :scope prefix matches
142
+ strict descendants only, so a plain compound never matches the scope root
143
+ (BON-17). The reduced-motion override sits inside each animated scope so it
144
+ matches at the scope's own specificity; "none" has no override because none
145
+ still means none. */
146
+ @scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) {
147
+ [data-bone="text"]:is(:scope, :scope *)::after,
148
+ [data-bone="block"]:is(:scope, :scope *),
149
+ [data-bone="container"]:is(:scope, :scope *)::before {
119
150
  animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
120
151
  background: linear-gradient(
121
152
  90deg,
@@ -125,32 +156,42 @@ video[data-bone="block"] {
125
156
  );
126
157
  background-size: 200% 100%;
127
158
  }
159
+
160
+ @media (prefers-reduced-motion: reduce) {
161
+ [data-bone="text"]:is(:scope, :scope *)::after,
162
+ [data-bone="block"]:is(:scope, :scope *),
163
+ [data-bone="container"]:is(:scope, :scope *)::before {
164
+ animation: bone-pulse 2s ease-in-out infinite;
165
+ background: var(--bone-base);
166
+ background-size: auto;
167
+ }
168
+ }
128
169
  }
129
170
 
130
171
  @scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) {
131
- [data-bone="text"]::after,
132
- [data-bone="text"]::before,
133
- [data-bone="block"],
134
- [data-bone="container"]::before {
172
+ [data-bone="text"]:is(:scope, :scope *)::after,
173
+ [data-bone="block"]:is(:scope, :scope *),
174
+ [data-bone="container"]:is(:scope, :scope *)::before {
135
175
  animation: bone-pulse var(--bone-duration) ease-in-out infinite;
136
176
  background: var(--bone-base);
137
177
  background-size: auto;
138
178
  }
179
+
180
+ @media (prefers-reduced-motion: reduce) {
181
+ [data-bone="text"]:is(:scope, :scope *)::after,
182
+ [data-bone="block"]:is(:scope, :scope *),
183
+ [data-bone="container"]:is(:scope, :scope *)::before {
184
+ animation: bone-pulse 2s ease-in-out infinite;
185
+ }
186
+ }
139
187
  }
140
188
 
141
189
  @scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) {
142
- [data-bone="text"]::after,
143
- [data-bone="text"]::before,
144
- [data-bone="block"],
145
- [data-bone="container"]::before {
190
+ [data-bone="text"]:is(:scope, :scope *)::after,
191
+ [data-bone="block"]:is(:scope, :scope *),
192
+ [data-bone="container"]:is(:scope, :scope *)::before {
146
193
  animation: none;
147
194
  background: var(--bone-base);
148
195
  background-size: auto;
149
196
  }
150
197
  }
151
-
152
- @media (prefers-reduced-motion: reduce) {
153
- [aria-busy="true"] {
154
- animation: bone-pulse 2s ease-in-out infinite;
155
- }
156
- }
@@ -0,0 +1,26 @@
1
+ //#region src/element/boundary.d.ts
2
+ declare const DEFAULT_DELAY = 200;
3
+ declare const DEFAULT_MIN_DURATION = 400;
4
+ declare const Base: typeof HTMLElement;
5
+ declare class BonesBoundary extends Base {
6
+ #private;
7
+ static readonly observedAttributes: string[];
8
+ get busy(): boolean;
9
+ set busy(value: boolean);
10
+ get force(): boolean;
11
+ set force(value: boolean);
12
+ get delay(): number;
13
+ set delay(value: number);
14
+ get minDuration(): number;
15
+ set minDuration(value: number);
16
+ get transition(): "auto" | "none";
17
+ set transition(value: "auto" | "none");
18
+ get precision(): "css" | "measured";
19
+ set precision(value: "css" | "measured");
20
+ get showing(): boolean;
21
+ connectedCallback(): void;
22
+ disconnectedCallback(): void;
23
+ attributeChangedCallback(name: string): void;
24
+ }
25
+ //#endregion
26
+ export { BonesBoundary, DEFAULT_DELAY, DEFAULT_MIN_DURATION };