@waveso/docs 0.9.1 → 0.11.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/dist/styles.css CHANGED
@@ -142,8 +142,51 @@
142
142
  * no shadow, so the dark ramp deepens it rather than reusing it. */
143
143
  --wave-docs-shadow: oklch(0 0 0 / 0.3);
144
144
 
145
- --wave-docs-radius: 0.5rem;
146
- --wave-docs-radius-sm: 0.375rem;
145
+ /*
146
+ * ⚠️ ONE ROOT AND THREE TIERS, DERIVED — AND THE NUMBERS ARE `@waveso/ui`'s.
147
+ *
148
+ * A site running both this package and the component library should not
149
+ * have two radius scales a few pixels apart, and the way to guarantee that
150
+ * is to take theirs rather than to keep ours in step by hand. `--radius`
151
+ * there is `0.625rem` with `--radius-sm/md/lg` at `calc(root ∓ --spacing)`;
152
+ * this is the same construction with the step written out, because
153
+ * `--spacing` is Tailwind's and this package does not depend on Tailwind.
154
+ *
155
+ * ⚠️ AND THE ROOT IS THE OVERRIDE POINT, WHICH IS THE WHOLE REASON IT
156
+ * EXISTS. A host already running `@waveso/ui` writes one line —
157
+ * `--wave-docs-radius-base: var(--radius)` — and the docs follow their
158
+ * app's corners, including any theme that moves them. Overriding three
159
+ * tokens separately is three chances to break the concentric arithmetic
160
+ * below.
161
+ *
162
+ * Which tier a box takes is decided by what *kind* of box it is, never by
163
+ * how big it happens to be:
164
+ *
165
+ * `-sm` inline chips, small controls, and focus rings drawn on those
166
+ * base controls, overlays, and the panel's inset surface
167
+ * `-lg` every block in the reading flow, and the panel's outer edge
168
+ *
169
+ * ⚠️ THE BLOCK TIER WAS SPLIT BEFORE THIS. Callouts, images and video
170
+ * embeds sat at the base radius while a code frame and a table sat at 19px,
171
+ * so two blocks a paragraph apart disagreed by eleven pixels. 19 was
172
+ * measured off a reference site, which is a fine way to pick a number and a
173
+ * bad way to pick a system.
174
+ */
175
+ --wave-docs-radius-base: 0.625rem;
176
+ /*
177
+ * The step between tiers, and the panel's padding is the same number
178
+ * *by construction* below rather than by coincidence: the panel's inset
179
+ * surface has to be its frame's radius minus its own padding, or the two
180
+ * corners run at different curvatures.
181
+ */
182
+ --wave-docs-radius-step: 0.25rem;
183
+ --wave-docs-radius-sm: calc(
184
+ var(--wave-docs-radius-base) - var(--wave-docs-radius-step)
185
+ );
186
+ --wave-docs-radius: var(--wave-docs-radius-base);
187
+ --wave-docs-radius-lg: calc(
188
+ var(--wave-docs-radius-base) + var(--wave-docs-radius-step)
189
+ );
147
190
  /* The shell. These five plus `--wave-docs-measure` are the whole settable
148
191
  * layout surface; the gutter and the drawer width are literals, because each
149
192
  * appears once and an ordinary override is already the cleanest tool for
@@ -217,6 +260,25 @@
217
260
  }
218
261
 
219
262
  /* Only for a host that has explicitly delegated the choice to the OS. */
263
+ /*
264
+ * ⚠️ A SQUIRCLE READS TIGHTER THAN A CIRCULAR ARC AT THE SAME RADIUS, so
265
+ * where `corner-shape` is live the root moves up to restore the roundness
266
+ * the numbers were chosen for. `@waveso/ui` does the same, to the same
267
+ * value, and the two have to agree or a page running both shows two
268
+ * different corners on the same screen.
269
+ *
270
+ * Only the root moves. Every tier is a `calc()` off it and the panel's
271
+ * padding is the step, so the concentric arithmetic survives untouched — and
272
+ * a browser without squircles keeps the original scale rather than a scale
273
+ * tuned for a shape it cannot draw.
274
+ */
275
+ @supports (corner-shape: squircle) {
276
+ :root {
277
+ --wave-docs-radius-base: 1rem;
278
+ }
279
+ }
280
+
281
+
220
282
  @media (prefers-color-scheme: dark) {
221
283
  :root[data-theme='system'] {
222
284
  color-scheme: dark;
@@ -382,12 +444,20 @@
382
444
  * Declared at all because inheriting means a host that never set a family
383
445
  * renders its documentation in the UA serif. `--wave-docs-font-sans: inherit`
384
446
  * gives the host's typeface back in one line.
447
+ *
448
+ * ⚠️ EVERY NEW ROOT HAS TO BE ADDED HERE, AND ONE ALREADY WAS NOT. The pager
449
+ * sits inside `<main>` but outside `.wave-docs-prose` — `DocContent` owns
450
+ * that class — so it matched nothing in this list and shipped its page titles
451
+ * in Times. Caught in a screenshot, not by a test: nothing here fails when a
452
+ * root is missing, it just renders wrong.
385
453
  */
386
454
  .wave-docs-prose,
387
455
  .wave-docs-sidebar,
388
456
  .wave-docs-toc,
389
457
  .wave-docs-skip-link,
390
458
  .wave-docs-hero,
459
+ .wave-docs-explore,
460
+ .wave-docs-pager,
391
461
  .wave-docs-layout__sidebar,
392
462
  .wave-docs-search-trigger,
393
463
  .wave-docs-search-dialog {
@@ -652,11 +722,27 @@
652
722
  accent-color: var(--wave-docs-accent);
653
723
  }
654
724
 
725
+ /*
726
+ * ⚠️ A FULL BORDER, AND IT WAS A 3px RULE DOWN THE INLINE START.
727
+ *
728
+ * A quote is a block set apart from the prose, and every other block set
729
+ * apart from the prose here — a callout, a code frame, a table, an embed —
730
+ * is a box. A single edge made it the one exception, and next to a callout
731
+ * two paragraphs away it read as a different kind of thing rather than as a
732
+ * quieter one.
733
+ *
734
+ * The same box as `.wave-docs-callout`, minus the hue: same padding, same
735
+ * block radius, a plain border instead of a tinted one and no accent edge.
736
+ * That is the relationship — a callout is a quote with a colour, and now it
737
+ * looks like one.
738
+ */
655
739
  .wave-docs-prose blockquote {
656
- padding-inline-start: 1rem;
657
- border-inline-start: 3px solid var(--wave-docs-border-strong);
740
+ /* The callout's padding, so the two line their text up with each other. */
741
+ padding: 0.875rem 1rem;
742
+ border: 1px solid var(--wave-docs-border);
743
+ border-radius: var(--wave-docs-radius-lg);
658
744
  color: var(--wave-docs-fg-muted);
659
- /* Not italic. The rule and the muted colour already say "quotation"; a
745
+ /* Not italic. The box and the muted colour already say "quotation"; a
660
746
  * long italic passage is measurably slower to read, and markdown authors
661
747
  * use blockquotes for asides and notes, not only for speech. */
662
748
  font-style: normal;
@@ -686,7 +772,7 @@
686
772
  .wave-docs-image {
687
773
  max-width: 100%;
688
774
  height: auto;
689
- border-radius: var(--wave-docs-radius);
775
+ border-radius: var(--wave-docs-radius-lg);
690
776
  }
691
777
 
692
778
  /* Inline code only. `.shiki` owns everything inside a `<pre>`. */
@@ -702,6 +788,49 @@
702
788
  }
703
789
  }
704
790
 
791
+ /* ---------------------------------------------------------------------------
792
+ * Squircle corners
793
+ *
794
+ * `corner-shape` draws every `border-radius` as a continuous superellipse
795
+ * rather than a circular arc — softer, and what `@waveso/ui` renders — and
796
+ * degrades to ordinary rounding where it is unsupported. Chrome has it today;
797
+ * Safari and Firefox do not, so this is an enhancement and never a dependency.
798
+ * ------------------------------------------------------------------------ */
799
+
800
+ @layer components {
801
+ @supports (corner-shape: squircle) {
802
+ /*
803
+ * ⚠️ SCOPED TO ELEMENTS THIS PACKAGE OWNS, AND NOT `*`.
804
+ *
805
+ * `@waveso/ui` can say `*` because it is the application's own stylesheet.
806
+ * This one is mounted inside somebody else's page, and a bare `*` would
807
+ * reshape every corner the host drew — the same class of trespass as
808
+ * claiming `html` or `body`, which this file already refuses.
809
+ *
810
+ * The attribute pair matches our classes wherever they sit in a class
811
+ * list. The prose elements are named separately because they are the
812
+ * markdown author's tags rather than ours: an inline `<code>` chip, a
813
+ * `<kbd>`, an excluded `<pre>`. They are rounded by rules scoped to
814
+ * `.wave-docs-prose`, so they are ours to shape.
815
+ */
816
+ :is([class^='wave-docs-'], [class*=' wave-docs-']),
817
+ .wave-docs-prose :is(blockquote, code, kbd, pre) {
818
+ corner-shape: squircle;
819
+ }
820
+
821
+ /*
822
+ * ⚠️ PILLS AND DOTS OPT BACK OUT. A squircle at `9999px` is a lozenge with
823
+ * flattened ends rather than a pill, and a squircled dot is a rounded
824
+ * square. Both are pseudo-elements, which `[class]` selectors cannot
825
+ * reach, so they are named.
826
+ */
827
+ .wave-docs-layout__sidebar-trigger::before,
828
+ .wave-docs-layout__sidebar-trigger::after {
829
+ corner-shape: round;
830
+ }
831
+ }
832
+ }
833
+
705
834
  /* ---------------------------------------------------------------------------
706
835
  * Tables
707
836
  * ------------------------------------------------------------------------ */
@@ -737,7 +866,21 @@
737
866
  grid-template-columns: minmax(max-content, 1fr);
738
867
  overflow-x: auto;
739
868
  border: 1px solid var(--wave-docs-border);
740
- border-radius: var(--wave-docs-radius);
869
+ /*
870
+ * ⚠️ THE PANEL'S OUTER RADIUS ON A BOX THAT IS NOT A PANEL, AND THAT IS
871
+ * DELIBERATE.
872
+ *
873
+ * A table wore `.wave-docs-panel` for a while — frame, band, inset card.
874
+ * The panel exists to separate *chrome* from *content*, and a table's
875
+ * header row is content: setting the body into a card away from its own
876
+ * header cost three vertical rules down each side, stopped the dividers
877
+ * short of the box and narrowed the reading width, on the densest element
878
+ * on a page. Full-width dividers are what let an eye track a row across.
879
+ *
880
+ * The radius is what is kept, so a table and a code block still read as two
881
+ * of one family without the table pretending to chrome it has not got.
882
+ */
883
+ border-radius: var(--wave-docs-radius-lg);
741
884
  scrollbar-width: thin;
742
885
  scrollbar-color: var(--wave-docs-border-strong) transparent;
743
886
  /*
@@ -770,8 +913,21 @@
770
913
  right / 0.75rem 100% no-repeat scroll;
771
914
  }
772
915
 
916
+ /*
917
+ * A table wears the panel: the outer frame, and its scroll region as the
918
+ * inset surface. There is no header row — a GFM table has no caption to put
919
+ * in one — so this is the frame alone, which is the point. It is what makes
920
+ * a table, a code block and "where to go next" read as three of one thing
921
+ * rather than three boxes that happen to be near each other.
922
+ */
773
923
  .wave-docs-table-scroll:focus-visible {
774
924
  outline: 2px solid var(--wave-docs-accent);
925
+ /*
926
+ * Positive, unlike the `<pre>`'s. This element *is* the scroll container,
927
+ * and an element's own overflow does not clip its own outline — so the ring
928
+ * draws into the frame's 4px band around it, which is exactly where there
929
+ * is room for it.
930
+ */
775
931
  outline-offset: 2px;
776
932
  }
777
933
 
@@ -992,6 +1148,24 @@
992
1148
  white-space: nowrap;
993
1149
  }
994
1150
 
1151
+ /*
1152
+ * ⚠️ AN EMPTY HEADER ROW DRAWS NO BAND, AND GFM PRODUCES ONE ROUTINELY.
1153
+ *
1154
+ * A GFM table *always* has a `<thead>` — the delimiter row is what makes it a
1155
+ * table at all — so an author who wants a plain two-column list of facts
1156
+ * writes `| | |` and gets a header of empty `<th>`s. With the card starting
1157
+ * at the body, that painted a bare strip of the frame's ground above it with
1158
+ * nothing in it: a panel with an empty header, which reads as a rendering
1159
+ * fault rather than as a table without column names.
1160
+ *
1161
+ * `:empty` and not a text check: GFM emits `<th></th>` for a blank cell, with
1162
+ * no whitespace inside it. A header with even one named column keeps its
1163
+ * band, which is the case the band exists for.
1164
+ */
1165
+ .wave-docs-table thead:not(:has(th:not(:empty))) {
1166
+ display: none;
1167
+ }
1168
+
995
1169
  /*
996
1170
  * ⚠️ `tbody tr + tr` ONLY. `thead` WAS IN THIS SELECTOR AND DOUBLED THE FRAME.
997
1171
  *
@@ -1001,9 +1175,9 @@
1001
1175
  * `--wave-docs-border` instead of one, which reads as a thick or doubled
1002
1176
  * line rather than as a frame.
1003
1177
  *
1004
- * The header's separator is the one below it, and that is drawn by the
1005
- * `inset 0 -1px 0` box-shadow on `thead th` — a shadow rather than a border
1006
- * so it survives the cell being `position: sticky`.
1178
+ * The header's separator is the one below it, drawn by the `inset 0 -1px 0`
1179
+ * box-shadow on `thead th` — a shadow rather than a border so it survives
1180
+ * the cell being `position: sticky`.
1007
1181
  */
1008
1182
  .wave-docs-table tbody tr + tr {
1009
1183
  border-block-start: 1px solid var(--wave-docs-border);
@@ -1069,12 +1243,37 @@
1069
1243
  * ---------------------------------------------------------------------- */
1070
1244
 
1071
1245
  .wave-docs-code {
1072
- position: relative;
1073
1246
  /*
1074
- * ⚠️ `margin-inline`, NOT `margin`. The `<pre>` inside carries the border
1075
- * and the background; this figure is only a positioning context, and a
1076
- * default `<figure>` margin would indent the whole block by 40px which is
1077
- * what this rule was written for.
1247
+ * ⚠️ A GRID ON THE FIGURE ITSELF, NOT A `.wave-docs-panel__header` WRAPPER.
1248
+ *
1249
+ * A `<figcaption>` has to be a direct child of its `<figure>`. Wrapped in
1250
+ * the header `<div>` "where to go next" uses, it captions nothing: the
1251
+ * markup is invalid and a titled block loses the accessible name it had.
1252
+ * So the frame wears `.wave-docs-panel`, its surface wears
1253
+ * `.wave-docs-panel__body`, and the header row is laid out here — sharing
1254
+ * the primitive's insets rather than its header element.
1255
+ *
1256
+ * ⚠️ AND THE FIRST ROW IS FLOORED RATHER THAN LEFT TO ITS CONTENT. With a
1257
+ * label it is the label's height; with none it is the copy button's, and
1258
+ * those differ by 4px — enough that a page mixing titled and untitled
1259
+ * fences shows two header heights and reads as a rendering bug.
1260
+ */
1261
+ display: grid;
1262
+ grid-template-columns: minmax(0, 1fr) auto;
1263
+ grid-template-rows: minmax(var(--wave-docs-panel-header-row), auto) auto;
1264
+ align-items: center;
1265
+ /*
1266
+ * The code's own ground rather than the panel's default. A code block is
1267
+ * part of the surface ramp and a reader expects it darker than the page;
1268
+ * the panel paints `--wave-docs-bg` for a widget made of prose, which is
1269
+ * the right default and the wrong one here. Set on the frame so it reaches
1270
+ * the surface by inheritance — see the property's note on the panel.
1271
+ */
1272
+ --wave-docs-panel-surface: var(--wave-docs-code-block-bg);
1273
+ /*
1274
+ * ⚠️ `margin-inline`, NOT `margin`. A default `<figure>` margin would
1275
+ * indent the whole block by 40px — which is what this rule was written
1276
+ * for.
1078
1277
  *
1079
1278
  * Zeroing all four also took the *block* margin, and `.wave-docs-prose > *
1080
1279
  * + *` is the same specificity and declared earlier, so this won. Every
@@ -1085,68 +1284,113 @@
1085
1284
  margin-inline: 0;
1086
1285
  }
1087
1286
 
1088
- .wave-docs-code__title {
1089
- display: flex;
1090
- align-items: center;
1091
- /* Room for the button, which is absolutely positioned over this row. */
1092
- padding: 0.5rem 3rem 0.5rem 1.125rem;
1093
- border: 1px solid var(--wave-docs-border);
1094
- border-block-end: 0;
1095
- border-start-start-radius: var(--wave-docs-radius);
1096
- border-start-end-radius: var(--wave-docs-radius);
1097
- background-color: var(--wave-docs-bg-subtle);
1098
- color: var(--wave-docs-fg-muted);
1099
- font-family: var(--wave-docs-font-mono);
1100
- font-size: 0.8125rem;
1101
- }
1102
-
1103
- /* Square off the top of a `<pre>` that sits under a title bar.
1104
- *
1105
- * `.wave-docs-prose` first, even though `.wave-docs-code` is our class and
1106
- * only ever appears inside it: `styles.test.ts` requires every `.shiki` rule
1107
- * to name the prose scope, so the package can never style a code block it
1108
- * did not render. One form for that invariant is worth the extra selector. */
1109
1287
  /*
1110
- * ⚠️ THE `<pre>`'s USER-AGENT MARGIN, WHICH UNDID THE FRAME.
1288
+ * ⚠️ AN UNTITLED FENCE HAS NO FRAME AT ALL, AND THAT IS THE WHOLE RULE.
1111
1289
  *
1112
- * A `<pre>` defaults to `margin-block: 1em`, and at this block's 0.875rem
1113
- * that is 14px measured in Chromium on the real site, not reasoned about.
1114
- * So the title bar dropped its bottom border and squared its bottom corners
1115
- * to join the code below it, and then the browser pushed the two 14px apart:
1116
- * a caption floating over a gap, which is the one arrangement the frame was
1117
- * designed not to be.
1290
+ * With a title the figure is a panel: a band carrying the filename and the
1291
+ * copy button, and the code set into a card below it. With none there is
1292
+ * nothing to put in a band so the frame flattens away, the surface becomes
1293
+ * the block, and the button sits on the code.
1118
1294
  *
1119
- * It mis-seated the copy button too. The button is positioned against the
1120
- * `<figure>`, so on a fence with no title the margin slid the code down out
1121
- * from under it.
1295
+ * The language does not count as a title. A fence that declares `ts` and no
1296
+ * filename is still an untitled fence, and a band holding a two-letter badge
1297
+ * is the empty-header problem with a word in it. `data-lang` stays on the
1298
+ * figure for anyone selecting on it.
1122
1299
  *
1123
- * Invisible to every test here before the site existed: jsdom has no layout,
1124
- * and `styles.test.ts` reads rules as text. It took a screenshot of a real
1125
- * page to see it.
1300
+ * One shape of markup, switched here rather than in the pipeline: two markup
1301
+ * paths mean two fixtures, and the one that is not on screen is the one that
1302
+ * rots.
1126
1303
  */
1127
- .wave-docs-prose .wave-docs-code .shiki {
1128
- margin: 0;
1304
+ .wave-docs-code:not(:has(.wave-docs-code__title)) {
1305
+ /* The containing block for the button below. The grid has one child left
1306
+ * in flow, so it has nothing to lay out. */
1307
+ position: relative;
1308
+ display: block;
1309
+ padding: 0;
1310
+ border: 0;
1311
+ background: transparent;
1129
1312
  }
1130
1313
 
1131
- .wave-docs-prose .wave-docs-code:has(.wave-docs-code__title) .shiki {
1132
- border-start-start-radius: 0;
1133
- border-start-end-radius: 0;
1314
+ .wave-docs-code:not(:has(.wave-docs-code__title)) .wave-docs-code__body {
1315
+ /* It is the outer box now, so it takes the outer radius. Leaving it at the
1316
+ * card's would round an unframed block more tightly than a framed one. */
1317
+ border-radius: var(--wave-docs-radius-lg);
1134
1318
  }
1135
1319
 
1136
- .wave-docs-code__copy {
1320
+ .wave-docs-code:not(:has(.wave-docs-code__title)) .wave-docs-code__copy {
1321
+ /*
1322
+ * On the code rather than beside it. `z-index` because the `<pre>` is a
1323
+ * scroll container and paints its own content above a static sibling; the
1324
+ * button would otherwise disappear under a wide line rather than over it.
1325
+ */
1137
1326
  position: absolute;
1138
- top: 0.5rem;
1139
- inset-inline-end: 0.5rem;
1140
- display: inline-flex;
1141
- align-items: center;
1142
- justify-content: center;
1327
+ z-index: 1;
1328
+ inset-block-start: 0.375rem;
1329
+ inset-inline-end: 0.375rem;
1330
+ margin-inline-end: 0;
1331
+ }
1332
+
1333
+ /*
1334
+ * The label slot: the fence's `title="…"`, which is the only thing that goes
1335
+ * in it.
1336
+ One or the other and never both — `swap.ts` beside a `ts` badge
1337
+ * is the same fact twice, and the filename is the more precise half.
1338
+ */
1339
+ .wave-docs-code__title {
1340
+ grid-row: 1;
1341
+ grid-column: 1;
1342
+ /* The track is `minmax(0, 1fr)` so a long path can shrink it; these two let
1343
+ * the path wrap inside that track instead of pushing the button out of the
1344
+ * frame. */
1345
+ min-width: 0;
1346
+ overflow-wrap: anywhere;
1347
+ /*
1348
+ * ⚠️ THE `+ 1px` IS THE SURFACE'S BORDER, AND IT IS THE SAME SUBTRACTION
1349
+ * `.wave-docs-panel__header` MAKES — the reason the inset is exported at
1350
+ * all. The label's first character and the code's first character sit on
1351
+ * one column; without it they miss by exactly one border, which appears in
1352
+ * no rule and reads as a design decision.
1353
+ */
1354
+ padding-inline-start: calc(var(--wave-docs-panel-inset) + 1px);
1355
+ color: var(--wave-docs-fg-muted);
1356
+ font-family: var(--wave-docs-font-mono);
1357
+ font-size: 0.8125rem;
1358
+ }
1359
+
1360
+ .wave-docs-code__body {
1361
+ grid-column: 1 / -1;
1362
+ }
1363
+
1364
+ .wave-docs-code__copy {
1365
+ grid-row: 1;
1366
+ grid-column: 2;
1367
+ /*
1368
+ * Optically inset, not aligned to the label's column: a 2rem hit target
1369
+ * held 17px off the frame's edge reads as floating in the middle of the
1370
+ * row rather than as the control that ends it.
1371
+ */
1372
+ margin-inline-end: calc(var(--wave-docs-panel-inset) - 0.5rem);
1373
+ /* A grid, so the three state icons stack in one cell. */
1374
+ display: grid;
1375
+ place-items: center;
1376
+ /*
1377
+ * A 2rem hit target around a 1rem glyph. The box stays even though nothing
1378
+ * draws it at rest — a control smaller than the icon plus its padding is a
1379
+ * control people miss on a touch screen.
1380
+ */
1143
1381
  width: 2rem;
1144
1382
  height: 2rem;
1145
- border: 1px solid var(--wave-docs-border);
1383
+ /*
1384
+ * ⚠️ NO BORDER AND NO GROUND, IN ANY STATE. It had both, and a bordered,
1385
+ * filled 2rem box on the frame's own band is a third framed rectangle
1386
+ * inside a frame that already has two — for a control secondary to
1387
+ * everything around it. The glyph is the whole control; hover moves its
1388
+ * ink, not a box behind it.
1389
+ */
1390
+ border: 0;
1146
1391
  border-radius: var(--wave-docs-radius-sm);
1147
- background-color: var(--wave-docs-bg);
1148
- color: var(--wave-docs-fg-muted);
1149
- font-size: 0.875rem;
1392
+ background-color: transparent;
1393
+ color: var(--wave-docs-fg-subtle);
1150
1394
  cursor: pointer;
1151
1395
  /*
1152
1396
  * ⚠️ HIDDEN UNTIL THE RUNTIME SAYS OTHERWISE, AND THIS IS STRUCTURAL.
@@ -1158,71 +1402,88 @@
1158
1402
  * a control that is invisible, focusable, and does nothing.
1159
1403
  */
1160
1404
  visibility: hidden;
1161
- opacity: 0;
1162
- transition: opacity 120ms ease;
1163
- }
1164
-
1165
- @media (prefers-reduced-motion: reduce) {
1166
- .wave-docs-code__copy {
1167
- transition: none;
1168
- }
1169
1405
  }
1170
1406
 
1171
1407
  html[data-wave-docs-code-ready] .wave-docs-code__copy {
1172
1408
  visibility: visible;
1173
1409
  }
1174
1410
 
1175
- .wave-docs-code:hover .wave-docs-code__copy,
1176
- .wave-docs-code:focus-within .wave-docs-code__copy,
1177
- /* A title bar already reserves the space, so there is nothing to reveal. */
1178
- .wave-docs-code:has(.wave-docs-code__title) .wave-docs-code__copy {
1179
- opacity: 1;
1180
- /*
1181
- * ⚠️ CENTRED IN THE TITLE BAR, NOT INSET FROM THE FIGURE.
1182
- *
1183
- * The button is positioned against the `<figure>`, which is the whole code
1184
- * block, so a single `top` cannot be right for both shapes. Against a bare
1185
- * fence, `0.5rem` insets it from the top of the `<pre>`, which is correct.
1186
- * Against a title bar it measured 8px of space above and **0 below** — the
1187
- * button sitting flush on the bar's own border, which reads as misaligned
1188
- * rather than as inset.
1189
- *
1190
- * The bar is 40px and the button is 32px, so 4px is the centre. A `top:
1191
- * 50%` would be wrong here: the percentage resolves against the figure,
1192
- * which includes the code block below.
1193
- */
1194
- top: 0.25rem;
1195
- }
1411
+ /*
1412
+ * ⚠️ NO HOVER REVEAL, AND ITS ABSENCE IS THE POINT.
1413
+ *
1414
+ * The button used to fade in on `:hover` or `:focus-within`, because it was
1415
+ * absolutely positioned over the code and had nowhere of its own to be. It
1416
+ * has a slot in the header row now, and a reserved slot that is empty until
1417
+ * you point at it reads as a rendering fault rather than as restraint — so
1418
+ * the reveal, the `@media (hover: none)` exception that existed because a
1419
+ * hover-only control does not exist on a phone, and the reduced-motion guard
1420
+ * on its transition all went with it.
1421
+ */
1422
+
1196
1423
 
1197
1424
  /*
1198
- * A control that only appears on hover does not exist on a phone. This is
1199
- * the whole of the mobile story for copy, and it is one query.
1425
+ * ⚠️ INK ONLY NO GROUND ON HOVER EITHER. The accent is the same signal
1426
+ * every other interactive surface in this package gives, and it needs no box
1427
+ * behind it to be read. A ground here also had nowhere to come from: the
1428
+ * subtle ramp *is* the frame's colour, so the hover state this used to have
1429
+ * was the colour the button was already sitting on.
1200
1430
  */
1201
- @media (hover: none) {
1202
- .wave-docs-code__copy {
1203
- opacity: 1;
1204
- }
1431
+ .wave-docs-code__copy:hover {
1432
+ color: var(--wave-docs-accent);
1205
1433
  }
1206
1434
 
1207
- .wave-docs-code__copy:hover {
1208
- background-color: var(--wave-docs-bg-subtle);
1209
- color: var(--wave-docs-fg);
1435
+ /*
1436
+ * ⚠️ AND IT HAD NO FOCUS RULE AT ALL, WHICH THE BORDER WAS QUIETLY COVERING
1437
+ * FOR.
1438
+ *
1439
+ * A 1px box around a control is not a focus indicator — it is there whether
1440
+ * the control is focused or not — so a keyboard reader tabbing onto this
1441
+ * button got a `color` change and nothing else, and the package's own
1442
+ * inventory of focusable surfaces did not list it. Taking the border away
1443
+ * makes that visible; it does not create it.
1444
+ */
1445
+ .wave-docs-code__copy:focus-visible {
1446
+ outline: 2px solid var(--wave-docs-accent);
1447
+ outline-offset: 2px;
1210
1448
  }
1211
1449
 
1212
1450
  /* The success state, swapped by CSS rather than by React: no component owns
1213
1451
  * a button, so there is no state to re-render and nothing to hydrate. */
1452
+ /*
1453
+ * ⚠️ ONE OF THREE ICONS, SWAPPED BY `display` AND NOT BY `visibility`.
1454
+ *
1455
+ * All three ship in the markup — no component owns this button, so there is
1456
+ * no state to re-render and nothing to hydrate; the runtime writes one
1457
+ * attribute and the stylesheet picks.
1458
+ *
1459
+ * `visibility` is the wrong property here and would have been a real defect:
1460
+ * the button is `visibility: hidden` until the runtime attaches, and
1461
+ * `visibility` inherits, so a child setting it back to `visible` shows an
1462
+ * icon inside a button that is meant to be invisible and out of the tab
1463
+ * order. `display` does not inherit, so the button's own rule still wins.
1464
+ */
1465
+ .wave-docs-code__copy-icon {
1466
+ /* Stacked in one cell, so the button does not resize as the state
1467
+ * changes and the glyph does not shift by a pixel between them. */
1468
+ grid-area: 1 / 1;
1469
+ }
1470
+
1471
+ .wave-docs-code__copy-icon:not([data-state='idle']) {
1472
+ display: none;
1473
+ }
1474
+
1475
+ /* The success state, swapped by CSS rather than by React. */
1214
1476
  .wave-docs-code__copy[data-copied='true'] {
1215
- border-color: var(--wave-docs-callout-tip);
1216
1477
  color: var(--wave-docs-callout-tip);
1217
1478
  }
1218
1479
 
1219
- .wave-docs-code__copy[data-copied='true'] > * {
1220
- visibility: hidden;
1480
+ .wave-docs-code__copy[data-copied='true'] .wave-docs-code__copy-icon {
1481
+ display: none;
1221
1482
  }
1222
1483
 
1223
- .wave-docs-code__copy[data-copied='true']::after {
1224
- content: '\2713';
1225
- position: absolute;
1484
+ .wave-docs-code__copy[data-copied='true']
1485
+ .wave-docs-code__copy-icon[data-state='copied'] {
1486
+ display: block;
1226
1487
  }
1227
1488
 
1228
1489
  /*
@@ -1235,21 +1496,21 @@
1235
1496
  * phone over `http://192.168.x.x:3000` is not a secure context, so
1236
1497
  * `navigator.clipboard` is undefined and no amount of pressing helps. A
1237
1498
  * silent no-op is the worst possible answer to that.
1499
+ *
1500
+ * A cross, not a warning triangle: it pairs with the tick at the same
1501
+ * stroke weight, and the triangle reads as a page-level alert.
1238
1502
  */
1239
1503
  .wave-docs-code__copy[data-copied='false'] {
1240
- border-color: var(--wave-docs-callout-caution);
1241
1504
  color: var(--wave-docs-callout-caution);
1242
1505
  }
1243
1506
 
1244
- .wave-docs-code__copy[data-copied='false'] > * {
1245
- visibility: hidden;
1507
+ .wave-docs-code__copy[data-copied='false'] .wave-docs-code__copy-icon {
1508
+ display: none;
1246
1509
  }
1247
1510
 
1248
- .wave-docs-code__copy[data-copied='false']::after {
1249
- /* A cross, not a warning triangle: it pairs with the tick above at the
1250
- * same weight, and the triangle reads as a page-level alert. */
1251
- content: '\00d7';
1252
- position: absolute;
1511
+ .wave-docs-code__copy[data-copied='false']
1512
+ .wave-docs-code__copy-icon[data-state='failed'] {
1513
+ display: block;
1253
1514
  }
1254
1515
 
1255
1516
  /*
@@ -1280,7 +1541,7 @@
1280
1541
  .wave-docs-prose pre:not(.shiki) {
1281
1542
  padding: 1rem 1.125rem;
1282
1543
  border: 1px solid var(--wave-docs-border);
1283
- border-radius: var(--wave-docs-radius);
1544
+ border-radius: var(--wave-docs-radius-lg);
1284
1545
  background-color: var(--wave-docs-code-block-bg);
1285
1546
  overflow-x: auto;
1286
1547
  font-family: var(--wave-docs-font-mono);
@@ -1290,13 +1551,37 @@
1290
1551
  }
1291
1552
 
1292
1553
  .wave-docs-prose .shiki {
1293
- padding: 1rem 1.125rem;
1294
- border: 1px solid var(--wave-docs-border);
1295
- border-radius: var(--wave-docs-radius);
1296
- /* Our token, not `--shiki-light-bg`: the code block is part of the page's
1297
- * surface ramp, and a theme's own background is whatever GitHub chose. */
1298
- background-color: var(--wave-docs-code-block-bg);
1554
+ /*
1555
+ * ⚠️ NO BORDER, NO RADIUS AND NO BACKGROUND OF ITS OWN — THE PANEL'S
1556
+ * SURFACE CARRIES ALL THREE NOW. `rehypeCodeFrame` puts every highlighted
1557
+ * fence inside a `.wave-docs-panel__body`, so declaring them here draws a
1558
+ * second frame one pixel inside the first. `pre:not(.shiki)` above keeps
1559
+ * its own, because an excluded fence is never wrapped.
1560
+ *
1561
+ * ⚠️ AND THE INLINE PADDING IS `1rem` BECAUSE THAT IS
1562
+ * `--wave-docs-panel-inset`, NOT BECAUSE IT IS A ROUND NUMBER. The frame's
1563
+ * label sits at that inset plus the surface's border; the first character
1564
+ * of the code sits at the surface's border plus this. Equal, they land on
1565
+ * one column. The `1.125rem` this was put the code 2px right of the
1566
+ * filename above it — visible, and attributable to nothing.
1567
+ */
1568
+ padding: 1rem;
1569
+ /*
1570
+ * ⚠️ THE `<pre>` SCROLLS AND THE SURFACE AROUND IT CLIPS, WHICH IS WHY
1571
+ * THEY ARE TWO BOXES. One box cannot both round its corners with
1572
+ * `overflow: hidden` and scroll a wide line: the first value wins and the
1573
+ * line is cut off with no way to reach it.
1574
+ */
1299
1575
  overflow-x: auto;
1576
+ /*
1577
+ * ⚠️ THE `<pre>`'s USER-AGENT MARGIN, WHICH UNDOES THE FRAME.
1578
+ *
1579
+ * A `<pre>` defaults to `margin-block: 1em`, and at this block's 0.875rem
1580
+ * that is 14px — measured in Chromium on the real site, not reasoned
1581
+ * about. Left in, it pushes the code away from the surface holding it and
1582
+ * the frame reads as a caption floating over a gap.
1583
+ */
1584
+ margin: 0;
1300
1585
  font-family: var(--wave-docs-font-mono);
1301
1586
  font-size: 0.875rem;
1302
1587
  line-height: 1.7;
@@ -1313,7 +1598,17 @@
1313
1598
  * had no focus style. */
1314
1599
  .wave-docs-prose .shiki:focus-visible {
1315
1600
  outline: 2px solid var(--wave-docs-accent);
1316
- outline-offset: 2px;
1601
+ /*
1602
+ * ⚠️ NEGATIVE, BECAUSE THE SURFACE AROUND THIS ONE CLIPS.
1603
+ *
1604
+ * The `<pre>` fills `.wave-docs-panel__body`, which is `overflow: hidden`
1605
+ * so a square corner cannot poke through the frame's rounded one. An
1606
+ * outline drawn *outside* the `<pre>`'s border box is outside the
1607
+ * surface's content box as well, so the `2px` this was got clipped away
1608
+ * to nothing and the one focusable element in a code block had a focus
1609
+ * style that could not be seen. Inset, it lands on the code's own edge.
1610
+ */
1611
+ outline-offset: -2px;
1317
1612
  }
1318
1613
 
1319
1614
  .wave-docs-prose .shiki code {
@@ -1350,9 +1645,22 @@
1350
1645
  --callout-bg: var(--wave-docs-callout-note-bg);
1351
1646
 
1352
1647
  padding: 0.875rem 1rem;
1648
+ /*
1649
+ * ⚠️ ONE UNIFORM EDGE, AND THERE WAS A 3px ACCENT DOWN THE INLINE START.
1650
+ *
1651
+ * Every other block set apart from the prose here is a box with one border
1652
+ * all the way round; the accent edge made a callout the exception, and a
1653
+ * thick rule on one side of a squircled box also fights the corner it runs
1654
+ * into. A blockquote is this box without the hue, which is the relationship
1655
+ * — and a stripe on one of them broke it.
1656
+ *
1657
+ * ⚠️ AND THE TYPE IS STILL NOT CONVEYED BY COLOUR ALONE. The stripe was
1658
+ * never what carried it: the icon and the label — "Note", "Warning" — are
1659
+ * the non-colour signals, and they are unchanged. The tinted border and
1660
+ * ground remain as reinforcement rather than as the whole message.
1661
+ */
1353
1662
  border: 1px solid color-mix(in oklab, var(--callout-accent) 35%, transparent);
1354
- border-inline-start: 3px solid var(--callout-accent);
1355
- border-radius: var(--wave-docs-radius);
1663
+ border-radius: var(--wave-docs-radius-lg);
1356
1664
  background: var(--callout-bg);
1357
1665
  }
1358
1666
 
@@ -1405,7 +1713,7 @@
1405
1713
  position: relative;
1406
1714
  aspect-ratio: 16 / 9;
1407
1715
  overflow: hidden;
1408
- border-radius: var(--wave-docs-radius);
1716
+ border-radius: var(--wave-docs-radius-lg);
1409
1717
  background: oklch(0 0 0);
1410
1718
  }
1411
1719
 
@@ -1764,10 +2072,37 @@
1764
2072
  rotate: 90deg;
1765
2073
  }
1766
2074
 
2075
+ /*
2076
+ * A separator ends the block above it as much as it names the block below,
2077
+ * and a rule says that in a way whitespace alone cannot: at 1rem of margin
2078
+ * the gap read as "these two lists are a bit far apart" rather than as a
2079
+ * division.
2080
+ *
2081
+ * ⚠️ THE RULE AND THE LABEL SHARE THE LIST'S OWN EDGE, WHICH IS ALSO THE
2082
+ * ROWS'. A row is full-bleed — its hover surface spans the whole column, and
2083
+ * so does the search field above it — so a rule on that edge divides the
2084
+ * column, while an inset one floats inside it. The label sits on the same
2085
+ * line, because a heading and the rule above it reading as one object is the
2086
+ * whole reason the rule is there.
2087
+ *
2088
+ * Measured before this: the label sat at 24px, the rows' *icon* column. It
2089
+ * headed a column of decoration, a step in from the words it names and a step
2090
+ * out from the edge — aligned with neither.
2091
+ *
2092
+ * ⚠️ AND NO RULE ON THE FIRST CHILD, which would draw a line above nothing. A
2093
+ * `meta.json` may open with `"---Reference---"`, and on that tree the very
2094
+ * first thing in the navigation would be a hairline.
2095
+ */
1767
2096
  .wave-docs-sidebar__separator-item {
1768
2097
  margin-block: 1rem 0.25rem;
1769
2098
  }
1770
2099
 
2100
+ .wave-docs-sidebar__separator-item:not(:first-child) {
2101
+ margin-block-start: 1rem;
2102
+ padding-block-start: 1rem;
2103
+ border-block-start: 1px solid var(--wave-docs-border);
2104
+ }
2105
+
1771
2106
  /*
1772
2107
  * ⚠️ NO `text-transform: uppercase`, AND IT USED TO HAVE ONE.
1773
2108
  *
@@ -1784,12 +2119,38 @@
1784
2119
  * single character. `letter-spacing` went with them: it exists to make caps
1785
2120
  * legible, and on sentence case it only reads as loose.
1786
2121
  */
2122
+ /*
2123
+ * The label starts where a row's content starts — the marker column when
2124
+ * there is one, the words when there is not.
2125
+ *
2126
+ * ⚠️ THAT IS ONE NUMBER, NOT TWO, AND CHASING IT WITH `:has()` WAS THE WRONG
2127
+ * SHAPE. A row's own `padding-inline` is what both modes have in common: with
2128
+ * markers the icon sits on it, with `icons={false}` the text does. Matching
2129
+ * the row's padding lands on whichever is there, in both modes, with no
2130
+ * query and nothing threaded.
2131
+ *
2132
+ * The *rule* keeps the column's edge instead — it divides the column rather
2133
+ * than naming anything. See `__separator-item`.
2134
+ */
1787
2135
  .wave-docs-sidebar__separator {
1788
2136
  display: block;
1789
- padding-inline: 0.5rem;
2137
+ padding-inline-start: 0.5rem;
1790
2138
  color: var(--wave-docs-fg-subtle);
1791
2139
  font-size: 0.75rem;
1792
- font-weight: 650;
2140
+ /*
2141
+ * ⚠️ THE WEIGHT IS THE LEVER HERE, NOT THE COLOUR. At 650 this label was
2142
+ * heavier than the group titles it sits under and read as the loudest thing
2143
+ * in the column — a divider out-shouting the navigation it divides.
2144
+ *
2145
+ * The obvious fix is a lighter colour, and there is none to reach for:
2146
+ * `--wave-docs-fg-subtle` is already the lightest text token at 5.05:1 on
2147
+ * the page, against WCAG 1.4.3's 4.5:1 floor for text this size — 0.55 of
2148
+ * slack. `--wave-docs-border`, which is what the rule above it is painted
2149
+ * with, measures 1.31:1 and is a line colour, not a text one.
2150
+ *
2151
+ * 500 drops it below the rows without touching the contrast at all.
2152
+ */
2153
+ font-weight: 500;
1793
2154
  }
1794
2155
 
1795
2156
  @media (prefers-reduced-motion: no-preference) {
@@ -1828,6 +2189,374 @@
1828
2189
  }
1829
2190
  }
1830
2191
 
2192
+ /* ---------------------------------------------------------------------------
2193
+ * Panel
2194
+ *
2195
+ * A framed block with a header and an inset surface: the outer card names the
2196
+ * thing and carries its controls, the inner one holds the content.
2197
+ *
2198
+ * Its own section because it is not one component's furniture — "where to go
2199
+ * next" is the first thing to wear it and a code frame is the next, and two
2200
+ * copies of the same three rules is how they drift apart.
2201
+ * ------------------------------------------------------------------------ */
2202
+
2203
+ @layer components {
2204
+ /*
2205
+ * ⚠️ THE TWO RADII ARE NOT INDEPENDENT NUMBERS. A rounded box inside a
2206
+ * rounded box only looks right when the inner radius is the outer one minus
2207
+ * the gap between them — otherwise the corners run at different curvatures
2208
+ * and the inner box reads as *pasted onto* the frame rather than set into it.
2209
+ *
2210
+ * The tokens are chosen so that arithmetic lands on one of them: `1rem` outer
2211
+ * minus `0.5rem` of padding is `0.5rem`, which is `--wave-docs-radius`. Change
2212
+ * the padding and the body's radius has to move with it.
2213
+ */
2214
+ .wave-docs-panel {
2215
+ /*
2216
+ * ⚠️ THE STEP, NOT A LITERAL `4px` THAT HAPPENS TO MATCH IT. The inset
2217
+ * surface below takes the base radius and this frame takes `-lg`, which is
2218
+ * the base plus one step — so the surface's corner is concentric with the
2219
+ * frame's only while this padding *is* that step. Written as the token, a
2220
+ * host moving `--wave-docs-radius-base` keeps both true; written as `4px`,
2221
+ * the two drift the first time anyone retunes the scale.
2222
+ */
2223
+ padding: var(--wave-docs-radius-step);
2224
+ border: 1px solid var(--wave-docs-border);
2225
+ border-radius: var(--wave-docs-radius-lg);
2226
+ background: var(--wave-docs-bg-subtle);
2227
+ /*
2228
+ * ⚠️ THE INSET THE BODY'S CONTENT USES, AND IT IS NOT THE SAME NUMBER AS
2229
+ * THE HEADER'S. The title sits at the frame's padding; anything inside the
2230
+ * body sits at the frame's padding *plus the body's own border*, so the two
2231
+ * columns miss each other by a pixel per border. Measured before this: the
2232
+ * rows of "where to go next" started 9px right of the heading above them —
2233
+ * a number that appears in no rule, and looks like a design decision.
2234
+ *
2235
+ * Exported rather than repeated, because the next component to wear the
2236
+ * panel has to make the same subtraction and will not think to — and the
2237
+ * correction is applied to the *header* below rather than baked in here, so
2238
+ * a component wearing the panel writes the clean value and the panel keeps
2239
+ * its own arithmetic to itself.
2240
+ */
2241
+ --wave-docs-panel-inset: 1rem;
2242
+ /*
2243
+ * The header row's floor, exported for the same reason the inset is: the
2244
+ * code frame lays its header out on a grid rather than in
2245
+ * `.wave-docs-panel__header`, because a `<figcaption>` cannot live inside a
2246
+ * wrapper, and two components drawing the same row to two different heights
2247
+ * is how a primitive stops being one.
2248
+ */
2249
+ --wave-docs-panel-header-row: 2.25rem;
2250
+ /*
2251
+ * The surface's ground is a property a wearer sets on the frame — see the
2252
+ * `var()` fallback on `__body`, which is where its default lives.
2253
+ */
2254
+ }
2255
+
2256
+ /*
2257
+ * The header lives in the frame's padding rather than in a band of its own:
2258
+ * the inset surface below is what draws the line between them, so a border
2259
+ * here would be a second one.
2260
+ */
2261
+ .wave-docs-panel__header {
2262
+ display: flex;
2263
+ align-items: center;
2264
+ justify-content: space-between;
2265
+ gap: 1rem;
2266
+ /*
2267
+ * ⚠️ THE `+ 1px` IS THE BODY'S BORDER, AND IT IS WHAT PUTS THE TITLE ON THE
2268
+ * SAME COLUMN AS THE CONTENT BELOW IT. The title's box starts at the
2269
+ * frame's padding; the body's content starts at that padding plus the
2270
+ * body's own border, so equal insets miss each other by exactly one border.
2271
+ * Derived rather than written out, so moving `--wave-docs-panel-inset`
2272
+ * moves both.
2273
+ */
2274
+ min-height: var(--wave-docs-panel-header-row);
2275
+ padding-block: 0.5rem;
2276
+ padding-inline: calc(var(--wave-docs-panel-inset) + 1px);
2277
+ }
2278
+
2279
+ .wave-docs-panel__title {
2280
+ margin: 0;
2281
+ min-width: 0;
2282
+ color: var(--wave-docs-fg);
2283
+ /*
2284
+ * ⚠️ THE TABLE HEADER'S SIZE, DELIBERATELY — they are the same kind of
2285
+ * thing and a page shows both. This and a `<th>` are the label a reader's
2286
+ * eye lands on before the content under it, and 14px against the table's
2287
+ * 15px read as two different levels rather than as one. `styles.test.ts`
2288
+ * pins the pair, because two literals that have to agree are two literals
2289
+ * that drift.
2290
+ *
2291
+ * The weight does not follow. A panel title names the block; a column
2292
+ * header names a column inside one, so the title sits one step heavier.
2293
+ */
2294
+ font-size: 0.9375rem;
2295
+ font-weight: 650;
2296
+ letter-spacing: -0.005em;
2297
+ }
2298
+
2299
+ /* Whatever the header's right-hand controls turn out to be — a copy button, a
2300
+ * language switcher — they are quiet next to the title. */
2301
+ .wave-docs-panel__actions {
2302
+ flex: none;
2303
+ display: flex;
2304
+ align-items: center;
2305
+ gap: 0.5rem;
2306
+ color: var(--wave-docs-fg-subtle);
2307
+ font-size: 0.8125rem;
2308
+ }
2309
+
2310
+ .wave-docs-panel__body {
2311
+ /* See the radius note above: outer minus this box's inset. */
2312
+ border: 1px solid var(--wave-docs-border);
2313
+ /* The frame minus its padding: 12 - 4 = 8, which is the base radius. See
2314
+ * the token block for why that is one number and not two. */
2315
+ border-radius: var(--wave-docs-radius);
2316
+ /*
2317
+ * ⚠️ THE DEFAULT LIVES IN THE FALLBACK, NOT IN A DECLARATION ON
2318
+ * `.wave-docs-panel` — AND THAT IS THE WHOLE FIX, TWICE OVER.
2319
+ *
2320
+ * First: `.wave-docs-code__body` and `.wave-docs-panel__body` are both one
2321
+ * class, so source order decides and the panel is declared later. A code
2322
+ * frame asking for the darker code ground got the panel's white, on the
2323
+ * page, with both rules present and correct.
2324
+ *
2325
+ * Then, with the ground moved to a property: a frame wears
2326
+ * `.wave-docs-panel` *and* `.wave-docs-code`, so both rules set that
2327
+ * property on the same element at the same specificity — and source order
2328
+ * handed it back to the panel. Measured twice as `oklch(1 0 0)` where
2329
+ * `oklch(0.975 0.003 262)` was written.
2330
+ *
2331
+ * In the fallback there is no declaration to lose to: a wearer that sets
2332
+ * the property wins by having set it, and one that does not gets this.
2333
+ */
2334
+ background: var(--wave-docs-panel-surface, var(--wave-docs-bg));
2335
+ /* The frame already clips to its own corners; this keeps a child's square
2336
+ * corner — a `<pre>` — from poking through the inner one. */
2337
+ overflow: hidden;
2338
+ }
2339
+ }
2340
+
2341
+ /* ---------------------------------------------------------------------------
2342
+ * Where to go next
2343
+ *
2344
+ * A question per row and the page that answers it, from a page's `next`
2345
+ * frontmatter. Emitted by `docs.Page` above the pager.
2346
+ * ------------------------------------------------------------------------ */
2347
+
2348
+ @layer components {
2349
+ /*
2350
+ * One block, not a stack of cards. The rows belong to each other — they are
2351
+ * the same question asked seven ways — and seven bordered boxes says seven
2352
+ * unrelated things. One frame with hairlines between reads as a table of
2353
+ * routes, which is what it is.
2354
+ *
2355
+ * The measure and the centring, both: this belongs to the prose, and the
2356
+ * pager below it learned that the hard way.
2357
+ */
2358
+ .wave-docs-explore {
2359
+ max-width: var(--wave-docs-measure);
2360
+ margin-inline: auto;
2361
+ margin-block-start: 3rem;
2362
+ }
2363
+
2364
+ .wave-docs-explore__list {
2365
+ margin: 0;
2366
+ padding: 0;
2367
+ list-style: none;
2368
+ }
2369
+
2370
+ /*
2371
+ * ⚠️ THE RULE IS A TOP BORDER ON EVERY ROW BUT THE FIRST, NOT A BOTTOM ONE ON
2372
+ * EVERY ROW BUT THE LAST. `:last-child` is the wrong hook here: the list is
2373
+ * authored, and a `:last-child` rule leaves a doubled line under the final
2374
+ * row the moment anything is appended to it — including by a consumer's own
2375
+ * CSS. `+` says "between", which is the thing being described.
2376
+ */
2377
+ .wave-docs-explore__item {
2378
+ display: flex;
2379
+ align-items: baseline;
2380
+ justify-content: space-between;
2381
+ gap: 1rem 1.5rem;
2382
+ /* The inline half is the panel's, so a question lines up with the heading
2383
+ * above it — see `--wave-docs-panel-inset`. */
2384
+ padding: 1rem var(--wave-docs-panel-inset);
2385
+ }
2386
+
2387
+ .wave-docs-explore__item + .wave-docs-explore__item {
2388
+ border-block-start: 1px solid var(--wave-docs-border);
2389
+ }
2390
+
2391
+ /*
2392
+ * ⚠️ `min-width: 0` ON THE QUESTION, WHICH IS WHAT LETS IT WRAP. A flex item
2393
+ * floors at its content's min-content width, so a sentence-length question
2394
+ * pushes the answer off the row's inline end instead of taking two lines.
2395
+ */
2396
+ .wave-docs-explore__question {
2397
+ min-width: 0;
2398
+ color: var(--wave-docs-fg-muted);
2399
+ }
2400
+
2401
+ .wave-docs-explore__answer {
2402
+ flex: none;
2403
+ color: var(--wave-docs-accent);
2404
+ font-weight: 600;
2405
+ text-decoration: none;
2406
+ }
2407
+
2408
+ .wave-docs-explore__answer:hover {
2409
+ text-decoration: underline;
2410
+ }
2411
+
2412
+ .wave-docs-explore__answer:focus-visible {
2413
+ outline: 2px solid var(--wave-docs-accent);
2414
+ outline-offset: 2px;
2415
+ border-radius: var(--wave-docs-radius-sm);
2416
+ }
2417
+
2418
+ /*
2419
+ * ⚠️ A CONTAINER QUERY, AND THE CONTAINER IS THE SHELL — the same reasoning
2420
+ * as every other breakpoint in this file. Two columns of sentence-length
2421
+ * questions are cramped in a narrow *box*, which a host can hand us on a
2422
+ * 1920px monitor.
2423
+ */
2424
+ @container wave-docs (max-width: 40rem) {
2425
+ .wave-docs-explore__item {
2426
+ flex-direction: column;
2427
+ align-items: start;
2428
+ gap: 0.25rem;
2429
+ }
2430
+ }
2431
+ }
2432
+
2433
+ /* ---------------------------------------------------------------------------
2434
+ * Pager
2435
+ *
2436
+ * Links to the pages either side of this one, emitted by `docs.Page` at the
2437
+ * foot of the article.
2438
+ * ------------------------------------------------------------------------ */
2439
+
2440
+ @layer components {
2441
+ /*
2442
+ * ⚠️ TWO TRACKS, ALWAYS — WHICH IS WHY THE COMPONENT RENDERS AN EMPTY CELL
2443
+ * RATHER THAN NOTHING AT EACH END OF THE SEQUENCE. With `auto-fit` or a
2444
+ * plain flex row, the first page of a site puts its only link — `Next` — on
2445
+ * the left, and every other page puts it on the right. The reader learns the
2446
+ * position, and the one page where it moves is the one they see first.
2447
+ *
2448
+ * ⚠️ THE MEASURE *AND* THE CENTRING, WHICH IS TWO DECLARATIONS AND I SHIPPED
2449
+ * ONE. `.wave-docs-prose` is capped and centred inside `__main`, so a
2450
+ * `max-width` alone left this the right width in the wrong place: the
2451
+ * paragraphs began at 440px and the pager at 304px, a 136px step directly
2452
+ * under them. It belongs to the prose, so it takes both halves of what puts
2453
+ * the prose where it is.
2454
+ */
2455
+ .wave-docs-pager {
2456
+ display: grid;
2457
+ margin-inline: auto;
2458
+ /* `minmax(0, 1fr)`, never a bare `1fr`: that floors each track at its
2459
+ * content's min-content width, so a long page title pushes the pair past
2460
+ * the measure instead of wrapping inside it. */
2461
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
2462
+ gap: 0.75rem;
2463
+ max-width: var(--wave-docs-measure);
2464
+ margin-block-start: 3rem;
2465
+ }
2466
+
2467
+ .wave-docs-pager__link {
2468
+ display: flex;
2469
+ flex-direction: column;
2470
+ gap: 0.25rem;
2471
+ padding: 0.75rem 1rem;
2472
+ border: 1px solid var(--wave-docs-border);
2473
+ border-radius: var(--wave-docs-radius);
2474
+ color: var(--wave-docs-fg);
2475
+ text-decoration: none;
2476
+ }
2477
+
2478
+ /*
2479
+ * The next page is set to its own edge, so the pair reads outward from the
2480
+ * middle — the direction each one takes you. `text-align` and not
2481
+ * `align-items`, so a title that wraps to two lines stays aligned with
2482
+ * itself rather than ragging against the box.
2483
+ */
2484
+ .wave-docs-pager__link[data-direction='next'] {
2485
+ text-align: end;
2486
+ }
2487
+
2488
+ /*
2489
+ * ⚠️ THE ARROW POINTS OUTWARD, AND "OUTWARD" MIRRORS. Under `dir="rtl"` the
2490
+ * grid's first track is on the right, so the *previous* link moves there and
2491
+ * its arrow has to point right — the reverse of both rules above it. Same
2492
+ * shape as the sidebar's chevron, and the same reason it is `[dir='rtl']`
2493
+ * rather than `:dir(rtl)`: lightningcss rewrites the latter into a list of
2494
+ * right-to-left *languages*, which is not the same question.
2495
+ *
2496
+ * Source order is load-bearing in the middle pair: `[dir='rtl'] .chevron` and
2497
+ * `[data-direction='next'] .chevron` have the same specificity, so the
2498
+ * mirror only wins by being written second.
2499
+ */
2500
+ .wave-docs-pager__chevron {
2501
+ flex: none;
2502
+ /* Sized down from the 16 it is drawn at: beside 12px type a full-size
2503
+ * chevron is the loudest thing in the card. */
2504
+ inline-size: 0.875rem;
2505
+ block-size: 0.875rem;
2506
+ rotate: 180deg;
2507
+ }
2508
+
2509
+ .wave-docs-pager__link[data-direction='next'] .wave-docs-pager__chevron {
2510
+ rotate: 0deg;
2511
+ }
2512
+
2513
+ [dir='rtl'] .wave-docs-pager__chevron {
2514
+ rotate: 0deg;
2515
+ }
2516
+
2517
+ [dir='rtl']
2518
+ .wave-docs-pager__link[data-direction='next']
2519
+ .wave-docs-pager__chevron {
2520
+ rotate: 180deg;
2521
+ }
2522
+
2523
+ /* The arrow shares the caption's line, so the two read as one label and the
2524
+ * title below starts at the card's own edge. */
2525
+ .wave-docs-pager__caption {
2526
+ display: flex;
2527
+ align-items: center;
2528
+ gap: 0.25rem;
2529
+ color: var(--wave-docs-fg-subtle);
2530
+ font-size: 0.75rem;
2531
+ }
2532
+
2533
+ .wave-docs-pager__link[data-direction='next'] .wave-docs-pager__caption {
2534
+ justify-content: flex-end;
2535
+ }
2536
+
2537
+ .wave-docs-pager__title {
2538
+ font-weight: 600;
2539
+ }
2540
+
2541
+ .wave-docs-pager__link:hover {
2542
+ border-color: var(--wave-docs-border-strong);
2543
+ background: var(--wave-docs-bg-subtle);
2544
+ }
2545
+
2546
+ .wave-docs-pager__link:focus-visible {
2547
+ outline: 2px solid var(--wave-docs-accent);
2548
+ outline-offset: 2px;
2549
+ }
2550
+
2551
+ @media (prefers-reduced-motion: no-preference) {
2552
+ .wave-docs-pager__link {
2553
+ transition:
2554
+ background-color 150ms ease-out,
2555
+ border-color 150ms ease-out;
2556
+ }
2557
+ }
2558
+ }
2559
+
1831
2560
  /* ---------------------------------------------------------------------------
1832
2561
  * Table of contents
1833
2562
  * ------------------------------------------------------------------------ */
@@ -2554,9 +3283,25 @@
2554
3283
  /* Without this, reaching the end of the nav keeps scrolling — the article
2555
3284
  * jumps under the reader's cursor while they are looking at the sidebar. */
2556
3285
  overscroll-behavior: contain;
2557
- /* Reserves the scrollbar's width whether or not it is showing, so the
2558
- * column does not jump when the nav grows past a screen. */
2559
- scrollbar-gutter: stable;
3286
+ /*
3287
+ * ⚠️ NO `scrollbar-gutter: stable`, AND IT USED TO HAVE ONE.
3288
+ *
3289
+ * It reserved the scrollbar's width whether or not it was showing, so the
3290
+ * column would not jump when the nav grew past a screen. The jump it
3291
+ * prevents is rare and transient; what it cost was permanent and on every
3292
+ * page — measured at 1600px, the nav's content sat 16px from its inline
3293
+ * start and 31px from its end, because the reserved 15px sits *inside* the
3294
+ * padding. A sidebar visibly lopsided on every Windows and Linux machine,
3295
+ * to spare an occasional 15px shift on the same ones.
3296
+ *
3297
+ * It also does nothing where overlay scrollbars are the default — macOS —
3298
+ * so the asymmetry was invisible to anyone who built it.
3299
+ *
3300
+ * `thin`, so the scrollbar that does appear is narrow, and coloured rather
3301
+ * than left as the UA's default slab against the panel.
3302
+ */
3303
+ scrollbar-width: thin;
3304
+ scrollbar-color: var(--wave-docs-border-strong) transparent;
2560
3305
  }
2561
3306
 
2562
3307
  /* First child of the navigation, above the tree. */
@@ -2574,6 +3319,47 @@
2574
3319
  * 20px transparent border for a while to answer to 44 while painting 24, and
2575
3320
  * a box the eye cannot see is a box the next person deletes.
2576
3321
  */
3322
+ /*
3323
+ * ⚠️ THE GRIP'S COLOUR IS A CUSTOM PROPERTY, NOT A SELECTOR FIGHT.
3324
+ *
3325
+ * Three things want to set it: the resting style, the reader's pointer, and
3326
+ * the sidebar's own state — and the state lives on an *ancestor*, so
3327
+ * `.sidebar[data-state] .trigger::before` outranks `.trigger:hover::before`
3328
+ * by a whole class. Written as backgrounds that is a rule that silently kills
3329
+ * hover on the one state that still needs it.
3330
+ *
3331
+ * Custom properties settle it by inheritance instead: the state sets them on
3332
+ * the sidebar, the trigger sets them on itself under `:hover`, and a value on
3333
+ * the element always beats one it inherited. No specificity, no `!important`,
3334
+ * and the hover rule stays two lines long.
3335
+ */
3336
+ .wave-docs-layout__sidebar {
3337
+ --wave-docs-trigger-fill: var(--wave-docs-border-strong);
3338
+ --wave-docs-trigger-ink: var(--wave-docs-fg-muted);
3339
+ --wave-docs-trigger-opacity: 0.4;
3340
+ }
3341
+
3342
+ /*
3343
+ * Blue means the grip is the thing to press; grey means it is at rest.
3344
+ *
3345
+ * The navigation is hidden and this is the way back to it — so a closed
3346
+ * sidebar lights the grip, exactly as the pointer does. Open, the grip has
3347
+ * nothing to offer that the visible column does not, and it goes quiet.
3348
+ *
3349
+ * ⚠️ `:not([data-state='open'])`, NOT `[data-state='closed']`. Below 64rem
3350
+ * the sidebar starts closed and the attribute is absent until the reader
3351
+ * chooses — a server-rendered page has no state at all. Matching only the
3352
+ * explicit value leaves every first paint on a phone showing a grey grip in
3353
+ * front of hidden navigation, which is the one moment the cue is for.
3354
+ *
3355
+ * The wide layout inverts the default, and overrides this below.
3356
+ */
3357
+ .wave-docs-layout__sidebar:not([data-state='open']) {
3358
+ --wave-docs-trigger-fill: var(--wave-docs-accent);
3359
+ --wave-docs-trigger-ink: var(--wave-docs-accent-fg);
3360
+ --wave-docs-trigger-opacity: 1;
3361
+ }
3362
+
2577
3363
  .wave-docs-layout__sidebar-trigger {
2578
3364
  flex: none;
2579
3365
  /*
@@ -2603,7 +3389,7 @@
2603
3389
  padding: 4px;
2604
3390
  border: 0;
2605
3391
  background: none;
2606
- color: var(--wave-docs-fg-muted);
3392
+ color: var(--wave-docs-trigger-ink);
2607
3393
  cursor: pointer;
2608
3394
  }
2609
3395
 
@@ -2627,8 +3413,10 @@
2627
3413
  * of the three dots, and nothing here scales with the pill: see `::after`.
2628
3414
  */
2629
3415
  height: 3.5rem;
2630
- border-radius: var(--wave-docs-radius-sm);
2631
- background-color: var(--wave-docs-border-strong);
3416
+ /* A full pill: the radius is half the *short* side, so `9999px` clamps to
3417
+ * exactly that whatever the two dimensions become. */
3418
+ border-radius: 9999px;
3419
+ background-color: var(--wave-docs-trigger-fill);
2632
3420
  }
2633
3421
 
2634
3422
  /*
@@ -2680,14 +3468,17 @@
2680
3468
  */
2681
3469
  .wave-docs-layout__sidebar-trigger::before,
2682
3470
  .wave-docs-layout__sidebar-trigger::after {
2683
- opacity: 0.4;
3471
+ opacity: var(--wave-docs-trigger-opacity);
2684
3472
  }
2685
3473
 
2686
- .wave-docs-layout__sidebar-trigger:hover::before,
2687
- .wave-docs-layout__sidebar-trigger:hover::after,
2688
- .wave-docs-layout__sidebar-trigger:focus-visible::before,
2689
- .wave-docs-layout__sidebar-trigger:focus-visible::after {
2690
- opacity: 1;
3474
+ /* On the element, not on the pseudo-elements: a custom property set here is
3475
+ * inherited by both, and beats the sidebar's regardless of how specific the
3476
+ * state selector that set it was. */
3477
+ .wave-docs-layout__sidebar-trigger:hover,
3478
+ .wave-docs-layout__sidebar-trigger:focus-visible {
3479
+ --wave-docs-trigger-fill: var(--wave-docs-accent);
3480
+ --wave-docs-trigger-ink: var(--wave-docs-accent-fg);
3481
+ --wave-docs-trigger-opacity: 1;
2691
3482
  }
2692
3483
 
2693
3484
  /*
@@ -2702,11 +3493,6 @@
2702
3493
  var(--wave-docs-accent) 30%,
2703
3494
  transparent
2704
3495
  );
2705
- color: var(--wave-docs-accent-fg);
2706
- }
2707
-
2708
- .wave-docs-layout__sidebar-trigger:hover::before {
2709
- background-color: var(--wave-docs-accent);
2710
3496
  }
2711
3497
 
2712
3498
  @media (prefers-reduced-motion: no-preference) {
@@ -2830,6 +3616,18 @@
2830
3616
  margin-inline-end: 0;
2831
3617
  }
2832
3618
 
3619
+ /*
3620
+ * Unchosen means *open* up here, so the grip is at rest by default — the
3621
+ * mirror of the rule above, and the reason that one is written as
3622
+ * "not open" rather than "closed". A closed sidebar keeps the blue it
3623
+ * inherits from there; nothing needs restating.
3624
+ */
3625
+ .wave-docs-layout__sidebar:not([data-state='closed']) {
3626
+ --wave-docs-trigger-fill: var(--wave-docs-border-strong);
3627
+ --wave-docs-trigger-ink: var(--wave-docs-fg-muted);
3628
+ --wave-docs-trigger-opacity: 0.4;
3629
+ }
3630
+
2833
3631
  /* Nothing is covered, so nothing is scrimmed. */
2834
3632
  .wave-docs-layout__sidebar-scrim {
2835
3633
  display: none;