@waveso/docs 0.2.0 → 0.3.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/README.md +490 -75
  3. package/dist/code-frame.d.ts +29 -0
  4. package/dist/code-frame.js +41 -0
  5. package/dist/code-meta.d.ts +48 -0
  6. package/dist/code-meta.js +72 -0
  7. package/dist/docs-content-id.d.ts +19 -0
  8. package/dist/docs-content-id.js +19 -0
  9. package/dist/docs-error.d.ts +2 -57
  10. package/dist/docs-error.js +3 -15
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/next.d.ts +153 -28
  14. package/dist/next.js +65 -33
  15. package/dist/plugins/rehype-capture-toc.js +26 -5
  16. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  17. package/dist/plugins/rehype-code-frame.js +88 -0
  18. package/dist/plugins/rehype-code-language.js +7 -1
  19. package/dist/react/code-runtime.d.ts +14 -0
  20. package/dist/react/code-runtime.js +161 -0
  21. package/dist/react/doc-content.d.ts +39 -2
  22. package/dist/react/doc-content.js +42 -10
  23. package/dist/react/layout.d.ts +44 -0
  24. package/dist/react/layout.js +65 -0
  25. package/dist/react/nav.d.ts +28 -0
  26. package/dist/react/nav.js +70 -0
  27. package/dist/react/nearest-scroll-top.d.ts +45 -0
  28. package/dist/react/nearest-scroll-top.js +44 -0
  29. package/dist/react/next-link.d.ts +34 -0
  30. package/dist/react/next-link.js +30 -0
  31. package/dist/react/next-nav.d.ts +11 -0
  32. package/dist/react/next-nav.js +32 -0
  33. package/dist/react/next-search.d.ts +22 -0
  34. package/dist/react/next-search.js +52 -0
  35. package/dist/react/search-dialog.d.ts +20 -8
  36. package/dist/react/search-dialog.js +15 -10
  37. package/dist/react/shell-labels.d.ts +43 -0
  38. package/dist/react/shell-labels.js +27 -0
  39. package/dist/react/sidebar.d.ts +38 -3
  40. package/dist/react/sidebar.js +104 -12
  41. package/dist/react/skip-link.d.ts +1 -9
  42. package/dist/react/skip-link.js +6 -5
  43. package/dist/react/toc.d.ts +12 -4
  44. package/dist/react/toc.js +18 -7
  45. package/dist/react/youtube.d.ts +31 -5
  46. package/dist/react/youtube.js +76 -54
  47. package/dist/render.d.ts +35 -1
  48. package/dist/render.js +35 -14
  49. package/dist/route-path.d.ts +46 -0
  50. package/dist/route-path.js +51 -0
  51. package/dist/search-index.d.ts +6 -23
  52. package/dist/search-index.js +6 -51
  53. package/dist/sitemap-limit.d.ts +34 -0
  54. package/dist/sitemap-limit.js +37 -0
  55. package/dist/source.d.ts +1 -23
  56. package/dist/source.js +40 -43
  57. package/dist/styles.css +939 -93
  58. package/dist/types.d.ts +11 -2
  59. package/package.json +58 -23
package/dist/styles.css CHANGED
@@ -110,18 +110,67 @@
110
110
  --wave-docs-callout-caution: oklch(0.55 0.2 24);
111
111
  --wave-docs-callout-caution-bg: oklch(0.97 0.025 24);
112
112
 
113
+ /* The table's "there is more this way" shadow.
114
+ *
115
+ * A token, not a literal: it was a hardcoded `oklch(0 0 0 / 0.12)`, and
116
+ * black at 12% over a `0.19` background is invisible — so in dark mode the
117
+ * one affordance telling a reader a table scrolls sideways was not there at
118
+ * all. A shadow darkens a light page and lightens a dark one; it cannot be
119
+ * a single colour. */
120
+ --wave-docs-scroll-shadow: oklch(0 0 0 / 0.12);
121
+ /* Elevation, for the one element that floats over the page. Same
122
+ * reasoning as `scroll-shadow`: a black shadow over a dark surface is
123
+ * no shadow, so the dark ramp deepens it rather than reusing it. */
124
+ --wave-docs-shadow: oklch(0 0 0 / 0.3);
125
+
113
126
  --wave-docs-radius: 0.5rem;
114
127
  --wave-docs-radius-sm: 0.375rem;
115
- /* How far below the top of the scrollport an anchored heading parks —
116
- * i.e. the height of the host's sticky header. Set it to 0 if there is no
117
- * such header; nothing else in this sheet reads it. */
118
- --wave-docs-scroll-padding: 4rem;
128
+ /* The shell, per `docs/adr/001-shell-contract.md`. These four plus
129
+ * `--wave-docs-measure` are the whole settable layout surface; the gutter
130
+ * and the drawer width are literals, because each appears once and an
131
+ * ordinary override is already the cleanest tool for them. */
132
+ --wave-docs-header-height: 3.5rem;
133
+ --wave-docs-sidebar-width: 16rem;
134
+ --wave-docs-toc-width: 15rem;
135
+ --wave-docs-shell-width: 100rem;
136
+
137
+ /* How far below the top of the scrollport an anchored heading parks.
138
+ *
139
+ * Derived, not set: it is the sticky header's height plus a rem of air, and
140
+ * the shell's sticky columns offset by the same token — so a host that
141
+ * changes the header height moves all three together. An earlier version of
142
+ * this comment said nothing else in the sheet read it, which stopped being
143
+ * true the moment the layout landed. */
144
+ --wave-docs-scroll-padding: calc(var(--wave-docs-header-height) + 1rem);
119
145
  --wave-docs-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo,
120
146
  Consolas, "Liberation Mono", monospace;
121
147
 
122
- /* Focus ring, shared by every interactive thing in the package. */
123
- --wave-docs-ring: 0 0 0 2px var(--wave-docs-bg),
124
- 0 0 0 4px var(--wave-docs-accent);
148
+ /* The body typeface.
149
+ *
150
+ * A default rather than an inheritance, because inheriting means a host
151
+ * that never set one renders documentation in the UA serif — which is what
152
+ * this package did until now, and which reads as broken rather than as
153
+ * unstyled.
154
+ *
155
+ * A host with its own typeface sets `--wave-docs-font-sans: inherit`, and
156
+ * the whole package follows `body` again. That is one line, and it is the
157
+ * documented opt-out; overriding the family on every element this sheet
158
+ * touches is not. */
159
+ --wave-docs-font-sans: ui-sans-serif, system-ui, -apple-system,
160
+ "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
161
+
162
+ /* Prose column width.
163
+ *
164
+ * 46rem is 736px at a 16px root, about 83 `ch` in the stack above — wider
165
+ * than a pure-prose measure wants, and deliberately: this column also
166
+ * carries code blocks, API tables and callouts, and narrowing it to suit
167
+ * paragraphs pushes those into horizontal scroll. Without any measure at
168
+ * all, a 1440px viewport produced a ~140-character line.
169
+ *
170
+ * `--wave-docs-measure: none` opts out entirely, for a host whose own
171
+ * shell already constrains the column. See `docs/adr/001-shell-contract.md`
172
+ * — this is one of the five settable layout tokens. */
173
+ --wave-docs-measure: 46rem;
125
174
  }
126
175
 
127
176
  /* Only for a host that has explicitly delegated the choice to the OS. */
@@ -142,6 +191,9 @@
142
191
  --wave-docs-accent-fg: oklch(0.18 0.02 265);
143
192
  --wave-docs-accent-subtle: oklch(0.26 0.06 248);
144
193
 
194
+ --wave-docs-scroll-shadow: oklch(1 0 0 / 0.16);
195
+ --wave-docs-shadow: oklch(0 0 0 / 0.6);
196
+
145
197
  --wave-docs-code-bg: oklch(0.26 0.016 265);
146
198
  --wave-docs-code-fg: oklch(0.86 0.04 300);
147
199
  --wave-docs-code-block-bg: oklch(0.17 0.015 265);
@@ -182,6 +234,9 @@
182
234
  --wave-docs-accent-fg: oklch(0.18 0.02 265);
183
235
  --wave-docs-accent-subtle: oklch(0.26 0.06 248);
184
236
 
237
+ --wave-docs-scroll-shadow: oklch(1 0 0 / 0.16);
238
+ --wave-docs-shadow: oklch(0 0 0 / 0.6);
239
+
185
240
  --wave-docs-code-bg: oklch(0.26 0.016 265);
186
241
  --wave-docs-code-fg: oklch(0.86 0.04 300);
187
242
  --wave-docs-code-block-bg: oklch(0.17 0.015 265);
@@ -213,6 +268,25 @@
213
268
  * `src/styles.test.ts` a fact about the shipped page rather than about a
214
269
  * background nobody declared.
215
270
  */
271
+ /*
272
+ * ⚠️ `.wave-docs-layout` IS IN THIS LIST, AND IT WAS NOT. Painting only the
273
+ * three content containers left the grid's own gutters — 1.5rem between each
274
+ * column, plus the inline padding — showing whatever the browser paints the
275
+ * canvas, because `body` is deliberately never touched.
276
+ *
277
+ * In light mode that is white against a white token and invisible. In dark
278
+ * mode the UA canvas and `--wave-docs-bg` are two different darks, so the
279
+ * sidebar and the table of contents rendered as slightly lighter panels
280
+ * floating on a darker page. Caught by looking at a screenshot of the real
281
+ * site; every contrast ratio in `styles.test.ts` was correct throughout,
282
+ * because each one was a fact about a container that *was* painted.
283
+ *
284
+ * The grid is a container this package owns, so painting it breaks no rule
285
+ * — `body` still belongs to the host, and a host whose page is a different
286
+ * colour overrides the token rather than fighting a `body` rule they did not
287
+ * write.
288
+ */
289
+ .wave-docs-layout,
216
290
  .wave-docs-prose,
217
291
  .wave-docs-sidebar,
218
292
  .wave-docs-toc {
@@ -224,6 +298,28 @@
224
298
  scroll-padding-top: var(--wave-docs-scroll-padding);
225
299
  }
226
300
 
301
+ /*
302
+ * The typeface, on every root this package owns.
303
+ *
304
+ * Separate from the grounding rule above because these six are not the same
305
+ * set: the dialog is portalled out of the prose and the trigger usually sits
306
+ * in the host's own header, so neither inherits from anything this sheet
307
+ * paints — while the skip link and the trigger have their own backgrounds and
308
+ * must not be handed the page one.
309
+ *
310
+ * Declared at all because inheriting means a host that never set a family
311
+ * renders its documentation in the UA serif. `--wave-docs-font-sans: inherit`
312
+ * gives the host's typeface back in one line.
313
+ */
314
+ .wave-docs-prose,
315
+ .wave-docs-sidebar,
316
+ .wave-docs-toc,
317
+ .wave-docs-skip-link,
318
+ .wave-docs-search-trigger,
319
+ .wave-docs-search-dialog {
320
+ font-family: var(--wave-docs-font-sans);
321
+ }
322
+
227
323
  /*
228
324
  * No `scroll-behavior: smooth` anywhere, and not for the usual reason.
229
325
  * Next 16 only suppresses smooth scrolling across a route change when
@@ -279,15 +375,18 @@
279
375
  * plain `:focus` for the same reason. */
280
376
  .wave-docs-skip-link:focus {
281
377
  transform: translateY(0);
282
- /* Transparent rather than `none`, here and at every other ring in this
283
- * file: forced-colors mode zeroes `box-shadow` and honours `outline`, so
284
- * `outline: none` would strictly remove the UA indicator and put nothing
285
- * back. A transparent outline is invisible normally and repainted in a
286
- * system colour there. The explicit `forced-colors` block at the end of
287
- * this file is the belt to this pair of braces. */
288
- outline: 2px solid transparent;
378
+ /* `outline`, here and at every other focus ring in this file, never
379
+ * `box-shadow`. Forced-colors mode drops `box-shadow` and forces
380
+ * `outline-color` to a system colour, so an outline is repainted for free
381
+ * where a shadow ring had to be restored by hand — which is what a 24-line
382
+ * `forced-colors` block used to do, for all eleven of them, with a test
383
+ * policing the list. An outline also follows the element's own
384
+ * `border-radius`, and shows the real surface through `outline-offset`
385
+ * instead of painting `--wave-docs-bg` as a spacer — which was a lie on the
386
+ * active search result, where the row is `accent-subtle` and the ring drew
387
+ * a white gap across it. */
388
+ outline: 2px solid var(--wave-docs-accent);
289
389
  outline-offset: 2px;
290
- box-shadow: var(--wave-docs-ring);
291
390
  }
292
391
 
293
392
  @media (prefers-reduced-motion: no-preference) {
@@ -312,47 +411,110 @@
312
411
  * tests at. `break-word` and not `word-break: break-all`, which would also
313
412
  * chop ordinary prose mid-syllable. */
314
413
  overflow-wrap: break-word;
315
- /* No `max-width` on purpose the docs shell owns column width. */
414
+ /* The comment here used to say the docs shell owns column width. No shell
415
+ * shipped, so in practice every consumer's first override was the same
416
+ * container CSS — a default the package should have carried itself. */
417
+ max-width: var(--wave-docs-measure);
316
418
  }
317
419
 
318
420
  .wave-docs-prose > * + * {
319
421
  margin-block-start: 1.25em;
320
422
  }
321
423
 
424
+ /* `pretty`, not `balance`: balance is for headings, where every line matters
425
+ * and there are two or three of them. On a paragraph it is capped at four
426
+ * lines in most engines anyway; `pretty` is the one that stops a paragraph
427
+ * ending on a single orphaned word. */
428
+ .wave-docs-prose p {
429
+ text-wrap: pretty;
430
+ }
431
+
432
+ /*
433
+ * A 1.2 minor third, four sizes, six unmistakable levels.
434
+ *
435
+ * The old scale stepped 1.50 / 1.20 / 1.18 / 1.06 / 1.00 — one enormous jump
436
+ * and then four nearly flat ones, so h3, h4 and h5 were interchangeable at a
437
+ * glance. h5 and h6 were the same size *and* weight as body text, differing
438
+ * only in colour: a coloured paragraph, not a level. They are an eyebrow now,
439
+ * which is a different axis (tracking and case) rather than a fifth size
440
+ * nobody can distinguish from the fourth.
441
+ *
442
+ * Leading falls as size rises — 1.15 through 1.5 — because one shared 1.25
443
+ * let the 36px h1 float apart and crowded the 16px h5. Tracking tightens the
444
+ * same way, for the same reason.
445
+ */
322
446
  .wave-docs-prose :is(h1, h2, h3, h4, h5, h6) {
323
- position: relative;
324
447
  color: var(--wave-docs-fg);
325
448
  font-weight: 650;
326
- line-height: 1.25;
327
449
  text-wrap: balance;
328
450
  scroll-margin-top: var(--wave-docs-scroll-padding);
329
451
  }
330
452
 
331
- .wave-docs-prose > :is(h2, h3, h4, h5, h6) {
453
+ /* Top margin scales with the size of the break the heading signals, so a new
454
+ * section reads as a bigger pause than a subsection. */
455
+ .wave-docs-prose > h2 {
456
+ margin-block-start: 2.5em;
457
+ }
458
+ .wave-docs-prose > h3 {
332
459
  margin-block-start: 2em;
333
460
  }
461
+ .wave-docs-prose > :is(h4, h5, h6) {
462
+ margin-block-start: 1.75em;
463
+ }
464
+ /* ...and a heading sits close to what it introduces. */
465
+ .wave-docs-prose :is(h1, h2, h3, h4, h5, h6) + * {
466
+ margin-block-start: 0.75em;
467
+ }
334
468
 
335
469
  .wave-docs-prose h1 {
336
- font-size: 2.25rem;
337
- letter-spacing: -0.02em;
470
+ /* The one fluid size. A fixed 2.0736rem h1 wraps an ordinary page title to
471
+ * three lines at 390px; everything below it stays fixed, because fluid type
472
+ * against a fixed measure means characters-per-line silently drifts and the
473
+ * measure token stops meaning what it says. */
474
+ font-size: clamp(1.75rem, 1.45rem + 1.3vw, 2.0736rem);
475
+ line-height: 1.15;
476
+ letter-spacing: -0.021em;
477
+ font-weight: 680;
338
478
  }
339
479
  .wave-docs-prose h2 {
340
- font-size: 1.5rem;
341
- letter-spacing: -0.015em;
342
- padding-block-end: 0.3em;
343
- border-block-end: 1px solid var(--wave-docs-border);
480
+ font-size: 1.44rem;
481
+ line-height: 1.25;
482
+ letter-spacing: -0.014em;
483
+ font-weight: 660;
344
484
  }
345
485
  .wave-docs-prose h3 {
346
- font-size: 1.25rem;
486
+ font-size: 1.2rem;
487
+ line-height: 1.35;
488
+ letter-spacing: -0.008em;
347
489
  }
348
490
  .wave-docs-prose h4 {
349
- font-size: 1.0625rem;
491
+ font-size: 1rem;
492
+ line-height: 1.45;
350
493
  }
494
+ /* An eyebrow, not a fifth size: at this depth the distinction has to come
495
+ * from case and tracking, because two more steps of a 1.2 scale would land
496
+ * inside a rounding error of the body text. */
351
497
  .wave-docs-prose :is(h5, h6) {
352
- font-size: 1rem;
498
+ font-size: 0.875rem;
499
+ line-height: 1.5;
500
+ letter-spacing: 0.02em;
501
+ text-transform: uppercase;
353
502
  color: var(--wave-docs-fg-muted);
354
503
  }
355
504
 
505
+ /*
506
+ * No rule under `h2`. A full-width hairline under every section heading is
507
+ * the single loudest "rendered GitHub README" signal a page can carry, and
508
+ * with no measure it ran the entire viewport. A long reference page that
509
+ * genuinely wants them is one attribute away, and shipping the opt-out in the
510
+ * same breath as the deletion is what stops someone forking the stylesheet
511
+ * to get it back:
512
+ */
513
+ .wave-docs-prose[data-rules] > h2 {
514
+ padding-block-end: 0.3em;
515
+ border-block-end: 1px solid var(--wave-docs-border);
516
+ }
517
+
356
518
  .wave-docs-prose a {
357
519
  color: var(--wave-docs-accent);
358
520
  text-decoration: underline;
@@ -376,9 +538,8 @@
376
538
  .wave-docs-sidebar :is(a, button):focus-visible,
377
539
  .wave-docs-toc a:focus-visible,
378
540
  .wave-docs-youtube__facade:focus-visible {
379
- outline: 2px solid transparent;
541
+ outline: 2px solid var(--wave-docs-accent);
380
542
  outline-offset: 2px;
381
- box-shadow: var(--wave-docs-ring);
382
543
  }
383
544
 
384
545
  .wave-docs-prose :is(ul, ol) {
@@ -414,7 +575,10 @@
414
575
  padding-inline-start: 1rem;
415
576
  border-inline-start: 3px solid var(--wave-docs-border-strong);
416
577
  color: var(--wave-docs-fg-muted);
417
- font-style: italic;
578
+ /* Not italic. The rule and the muted colour already say "quotation"; a
579
+ * long italic passage is measurably slower to read, and markdown authors
580
+ * use blockquotes for asides and notes, not only for speech. */
581
+ font-style: normal;
418
582
  }
419
583
 
420
584
  .wave-docs-prose hr {
@@ -466,21 +630,96 @@
466
630
  overflow-x: auto;
467
631
  border: 1px solid var(--wave-docs-border);
468
632
  border-radius: var(--wave-docs-radius);
633
+ scrollbar-width: thin;
634
+ scrollbar-color: var(--wave-docs-border-strong) transparent;
635
+ /*
636
+ * ⚠️ NOT DECORATIVE, AND NOT DELETABLE AS SUCH.
637
+ *
638
+ * This is the whole scroll affordance, and it is four gradients because
639
+ * that is what makes it stateless. The two `local` covers are painted in
640
+ * the page colour and scroll *with* the content, so each one sits over its
641
+ * shadow only while that edge is at rest; the two `scroll` shadows are
642
+ * pinned to the box. The result is a shadow that appears on precisely the
643
+ * side that has content off-screen, with no listener, no state and no
644
+ * hydration — on a table the reader may well meet before JS arrives.
645
+ */
646
+ background:
647
+ linear-gradient(to right, var(--wave-docs-bg) 30%, transparent) left /
648
+ 2rem 100% no-repeat local,
649
+ linear-gradient(to left, var(--wave-docs-bg) 30%, transparent) right /
650
+ 2rem 100% no-repeat local,
651
+ radial-gradient(
652
+ farthest-side at 0 50%,
653
+ var(--wave-docs-scroll-shadow),
654
+ transparent
655
+ )
656
+ left / 0.75rem 100% no-repeat scroll,
657
+ radial-gradient(
658
+ farthest-side at 100% 50%,
659
+ var(--wave-docs-scroll-shadow),
660
+ transparent
661
+ )
662
+ right / 0.75rem 100% no-repeat scroll;
469
663
  }
470
664
 
471
665
  .wave-docs-table-scroll:focus-visible {
472
- outline: 2px solid transparent;
666
+ outline: 2px solid var(--wave-docs-accent);
473
667
  outline-offset: 2px;
474
- box-shadow: var(--wave-docs-ring);
475
668
  }
476
669
 
477
670
  .wave-docs-table {
478
671
  width: 100%;
672
+ /*
673
+ * A FLOOR, NOT A SIZING MODE — and `width: 100%` is not one.
674
+ *
675
+ * Auto table layout floors at min-content, so `width: 100%` inside an
676
+ * `overflow-x: auto` container does not stop a table from being squeezed;
677
+ * it only stops it from being wider than the container. What actually
678
+ * collapses the columns is `.wave-docs-prose a { overflow-wrap: anywhere }`
679
+ * above: per CSS Text 3, `anywhere` affects *intrinsic* sizing and
680
+ * `break-word` does not, so a cell holding a link — i.e. every column of a
681
+ * real API table — reports a min-content width of about one character. The
682
+ * table then fits 320px, does not overflow, and renders rows five lines
683
+ * tall with one word per line.
684
+ *
685
+ * `width: max-content` is the obvious fix and is worse: it forbids a
686
+ * description cell from wrapping at all, so a table with one sentence in it
687
+ * is over 1600px wide at every viewport and you scroll sideways to read
688
+ * one sentence.
689
+ *
690
+ * A floor solves both. Below 40rem of available width the table stops
691
+ * shrinking and the scroll container takes over; above it, `width: 100%`
692
+ * takes over and the description column wraps as it should.
693
+ *
694
+ * `min-width: 40rem`, NOT `min(100%, 40rem)` — that spelling is a no-op at
695
+ * exactly the widths it is meant to govern, because the `100%` resolves
696
+ * against the box being floored: in a 318px scroll container it computes to
697
+ * `min(318px, 640px)` = 318px, i.e. the container's own width, so nothing
698
+ * ever overflows and the table squeezes exactly as before. The browser tier
699
+ * caught it at 318 vs 318.
700
+ *
701
+ * A consumer with an eight-column monster overrides `min-width` from their
702
+ * own unlayered rule and wins by the cascade contract this file already
703
+ * guarantees — so no token for it.
704
+ */
705
+ min-width: 40rem;
479
706
  border-collapse: collapse;
707
+ font-variant-numeric: tabular-nums;
480
708
  font-size: 0.9375rem;
481
709
  line-height: 1.6;
482
710
  }
483
711
 
712
+ /* `box-shadow`, not `border-block-end`: with `border-collapse: collapse` the
713
+ * border belongs to the table, not the cell, so it scrolls away from a
714
+ * sticky header instead of staying under it. */
715
+ .wave-docs-table thead th {
716
+ position: sticky;
717
+ top: 0;
718
+ z-index: 1;
719
+ background: var(--wave-docs-bg-subtle);
720
+ box-shadow: inset 0 -1px 0 var(--wave-docs-border);
721
+ }
722
+
484
723
  .wave-docs-table :is(th, td) {
485
724
  padding: 0.625rem 0.875rem;
486
725
  text-align: start;
@@ -549,6 +788,210 @@
549
788
  * ------------------------------------------------------------------------ */
550
789
 
551
790
  @layer components {
791
+ /* -------------------------------------------------------------------------
792
+ * Code frame
793
+ *
794
+ * The `<figure>` `rehypeCodeFrame` wraps every highlighted fence in: an
795
+ * optional title bar, a copy button, and the `<pre>` itself.
796
+ * ---------------------------------------------------------------------- */
797
+
798
+ .wave-docs-code {
799
+ position: relative;
800
+ /* The `<pre>` inside carries the border and background; the figure is only
801
+ * a positioning context, and a default `<figure>` margin would indent the
802
+ * whole block by 40px. */
803
+ margin: 0;
804
+ }
805
+
806
+ .wave-docs-code__title {
807
+ display: flex;
808
+ align-items: center;
809
+ /* Room for the button, which is absolutely positioned over this row. */
810
+ padding: 0.5rem 3rem 0.5rem 1.125rem;
811
+ border: 1px solid var(--wave-docs-border);
812
+ border-block-end: 0;
813
+ border-start-start-radius: var(--wave-docs-radius);
814
+ border-start-end-radius: var(--wave-docs-radius);
815
+ background-color: var(--wave-docs-bg-subtle);
816
+ color: var(--wave-docs-fg-muted);
817
+ font-family: var(--wave-docs-font-mono);
818
+ font-size: 0.8125rem;
819
+ }
820
+
821
+ /* Square off the top of a `<pre>` that sits under a title bar.
822
+ *
823
+ * `.wave-docs-prose` first, even though `.wave-docs-code` is our class and
824
+ * only ever appears inside it: `styles.test.ts` requires every `.shiki` rule
825
+ * to name the prose scope, so the package can never style a code block it
826
+ * did not render. One form for that invariant is worth the extra selector. */
827
+ /*
828
+ * ⚠️ THE `<pre>`'s USER-AGENT MARGIN, WHICH UNDID THE FRAME.
829
+ *
830
+ * A `<pre>` defaults to `margin-block: 1em`, and at this block's 0.875rem
831
+ * that is 14px — measured in Chromium on the real site, not reasoned about.
832
+ * So the title bar dropped its bottom border and squared its bottom corners
833
+ * to join the code below it, and then the browser pushed the two 14px apart:
834
+ * a caption floating over a gap, which is the one arrangement the frame was
835
+ * designed not to be.
836
+ *
837
+ * It mis-seated the copy button too. The button is positioned against the
838
+ * `<figure>`, so on a fence with no title the margin slid the code down out
839
+ * from under it.
840
+ *
841
+ * Invisible to every test here before the site existed: jsdom has no layout,
842
+ * and `styles.test.ts` reads rules as text. It took a screenshot of a real
843
+ * page to see it.
844
+ */
845
+ .wave-docs-prose .wave-docs-code .shiki {
846
+ margin: 0;
847
+ }
848
+
849
+ .wave-docs-prose .wave-docs-code:has(.wave-docs-code__title) .shiki {
850
+ border-start-start-radius: 0;
851
+ border-start-end-radius: 0;
852
+ }
853
+
854
+ .wave-docs-code__copy {
855
+ position: absolute;
856
+ top: 0.5rem;
857
+ inset-inline-end: 0.5rem;
858
+ display: inline-flex;
859
+ align-items: center;
860
+ justify-content: center;
861
+ width: 2rem;
862
+ height: 2rem;
863
+ border: 1px solid var(--wave-docs-border);
864
+ border-radius: var(--wave-docs-radius-sm);
865
+ background-color: var(--wave-docs-bg);
866
+ color: var(--wave-docs-fg-muted);
867
+ font-size: 0.875rem;
868
+ cursor: pointer;
869
+ /*
870
+ * ⚠️ HIDDEN UNTIL THE RUNTIME SAYS OTHERWISE, AND THIS IS STRUCTURAL.
871
+ *
872
+ * The button is in the HTML whether or not any JavaScript runs — a reader
873
+ * with scripts off, a `renderToStaticMarkup` consumer, anyone rendering
874
+ * the hast by hand. `visibility: hidden` also takes it out of the tab
875
+ * order, which `opacity: 0` would not: without it a keyboard reader meets
876
+ * a control that is invisible, focusable, and does nothing.
877
+ */
878
+ visibility: hidden;
879
+ opacity: 0;
880
+ transition: opacity 120ms ease;
881
+ }
882
+
883
+ @media (prefers-reduced-motion: reduce) {
884
+ .wave-docs-code__copy {
885
+ transition: none;
886
+ }
887
+ }
888
+
889
+ html[data-wave-docs-code-ready] .wave-docs-code__copy {
890
+ visibility: visible;
891
+ }
892
+
893
+ .wave-docs-code:hover .wave-docs-code__copy,
894
+ .wave-docs-code:focus-within .wave-docs-code__copy,
895
+ /* A title bar already reserves the space, so there is nothing to reveal. */
896
+ .wave-docs-code:has(.wave-docs-code__title) .wave-docs-code__copy {
897
+ opacity: 1;
898
+ }
899
+
900
+ /*
901
+ * A control that only appears on hover does not exist on a phone. This is
902
+ * the whole of the mobile story for copy, and it is one query.
903
+ */
904
+ @media (hover: none) {
905
+ .wave-docs-code__copy {
906
+ opacity: 1;
907
+ }
908
+ }
909
+
910
+ .wave-docs-code__copy:hover {
911
+ background-color: var(--wave-docs-bg-subtle);
912
+ color: var(--wave-docs-fg);
913
+ }
914
+
915
+ /* The success state, swapped by CSS rather than by React: no component owns
916
+ * a button, so there is no state to re-render and nothing to hydrate. */
917
+ .wave-docs-code__copy[data-copied='true'] {
918
+ border-color: var(--wave-docs-callout-tip);
919
+ color: var(--wave-docs-callout-tip);
920
+ }
921
+
922
+ .wave-docs-code__copy[data-copied='true'] > * {
923
+ visibility: hidden;
924
+ }
925
+
926
+ .wave-docs-code__copy[data-copied='true']::after {
927
+ content: '\2713';
928
+ position: absolute;
929
+ }
930
+
931
+ /*
932
+ * ⚠️ AND THE FAILURE STATE, WHICH HAD NO RULE AT ALL. The runtime has always
933
+ * written `data-copied="false"` and announced "Copy failed. Select the code
934
+ * and press Control or Command + C." — to a live region, so a screen-reader
935
+ * user was told and a sighted user saw a button that simply did nothing.
936
+ *
937
+ * The most common way to land here is not exotic: `next dev` reached from a
938
+ * phone over `http://192.168.x.x:3000` is not a secure context, so
939
+ * `navigator.clipboard` is undefined and no amount of pressing helps. A
940
+ * silent no-op is the worst possible answer to that.
941
+ */
942
+ .wave-docs-code__copy[data-copied='false'] {
943
+ border-color: var(--wave-docs-callout-caution);
944
+ color: var(--wave-docs-callout-caution);
945
+ }
946
+
947
+ .wave-docs-code__copy[data-copied='false'] > * {
948
+ visibility: hidden;
949
+ }
950
+
951
+ .wave-docs-code__copy[data-copied='false']::after {
952
+ /* A cross, not a warning triangle: it pairs with the tick above at the
953
+ * same weight, and the triangle reads as a page-level alert. */
954
+ content: '\00d7';
955
+ position: absolute;
956
+ }
957
+
958
+ /*
959
+ * The live region. Visually hidden rather than `display: none`, which most
960
+ * screen readers skip entirely — an announcement nobody hears is not one.
961
+ */
962
+ .wave-docs-code__status {
963
+ position: absolute;
964
+ width: 1px;
965
+ height: 1px;
966
+ margin: -1px;
967
+ padding: 0;
968
+ overflow: hidden;
969
+ clip-path: inset(50%);
970
+ white-space: nowrap;
971
+ border: 0;
972
+ }
973
+
974
+ /*
975
+ * A fence whose language is in `excludeLangs` never reaches Shiki, so it has
976
+ * none of the rules below: no background, no border, no padding, no radius,
977
+ * no monospace sizing and no horizontal scroll. It rendered as a wall of UA
978
+ * default text bleeding out of the reading column — `excludeLangs` shipped
979
+ * as half a feature, and this is the other half. Everything a consumer then
980
+ * renders in its place (a Mermaid diagram, say) overrides it from their own
981
+ * unlayered CSS, which beats every layer here.
982
+ */
983
+ .wave-docs-prose pre:not(.shiki) {
984
+ padding: 1rem 1.125rem;
985
+ border: 1px solid var(--wave-docs-border);
986
+ border-radius: var(--wave-docs-radius);
987
+ background-color: var(--wave-docs-code-block-bg);
988
+ overflow-x: auto;
989
+ font-family: var(--wave-docs-font-mono);
990
+ font-size: 0.875rem;
991
+ line-height: 1.7;
992
+ tab-size: 2;
993
+ }
994
+
552
995
  .wave-docs-prose .shiki {
553
996
  padding: 1rem 1.125rem;
554
997
  border: 1px solid var(--wave-docs-border);
@@ -572,9 +1015,8 @@
572
1015
  * wide block — which makes it the one focusable element in this package that
573
1016
  * had no focus style. */
574
1017
  .wave-docs-prose .shiki:focus-visible {
575
- outline: 2px solid transparent;
1018
+ outline: 2px solid var(--wave-docs-accent);
576
1019
  outline-offset: 2px;
577
- box-shadow: var(--wave-docs-ring);
578
1020
  }
579
1021
 
580
1022
  .wave-docs-prose .shiki code {
@@ -678,6 +1120,87 @@
678
1120
  border: 0;
679
1121
  background: none;
680
1122
  cursor: pointer;
1123
+ /* The UA disclosure triangle, which would sit on top of the thumbnail. */
1124
+ list-style: none;
1125
+ }
1126
+
1127
+ /* Safari still needs the pseudo-element form; it ignores `list-style` here. */
1128
+ .wave-docs-youtube__facade::-webkit-details-marker {
1129
+ display: none;
1130
+ }
1131
+
1132
+ /*
1133
+ * ⚠️ HIDDEN, NOT REMOVED, AND FOCUSABLE EITHER WAY. `display: none` here
1134
+ * would destroy the element the reader just activated, dropping focus to the
1135
+ * top of the document — which is the bug the previous, client-side version
1136
+ * needed a `useEffect` to paper over. Visually hidden keeps focus where the
1137
+ * reader put it and leaves a way to collapse the player again.
1138
+ */
1139
+ .wave-docs-youtube[open] .wave-docs-youtube__facade {
1140
+ position: absolute;
1141
+ width: 1px;
1142
+ height: 1px;
1143
+ margin: -1px;
1144
+ overflow: hidden;
1145
+ clip-path: inset(50%);
1146
+ white-space: nowrap;
1147
+ }
1148
+
1149
+ /*
1150
+ * ⚠️ AND VISIBLE AGAIN THE MOMENT IT HAS FOCUS. The rule above ran
1151
+ * unconditionally, so a keyboard reader who pressed Enter to start the video
1152
+ * was left holding focus on a 1×1 clipped element: the focus indicator
1153
+ * disappeared from the page entirely (WCAG 2.4.7), with no way to see that
1154
+ * the next Enter would collapse the player again. `youtube.browser.test.tsx`
1155
+ * asserts focus deliberately *stays* here, which made the hiding rule a
1156
+ * guaranteed failure rather than a possible one.
1157
+ *
1158
+ * Same shape as the skip link: off-screen until focused, a real control when
1159
+ * focused. It overlays the player rather than displacing it, because the
1160
+ * `<details>` has a fixed aspect ratio and reflowing the video under the
1161
+ * reader's cursor to reveal a button is its own defect.
1162
+ *
1163
+ * `:focus-visible` here, where the skip link 700 lines up deliberately uses
1164
+ * `:focus` — the divergence is the point. Clicking the thumbnail focuses this
1165
+ * summary as a side effect of opening the player, and a mouse user does not
1166
+ * want a "Hide video" control appearing over the video they just started.
1167
+ * The reader who needs it is the one who pressed Enter, which is exactly what
1168
+ * `:focus-visible` matches. Unlike the skip link, this element is also inert
1169
+ * to a pointer while hidden — it is 1px, under nothing — so there is no
1170
+ * unreachable-focused-control case to guard against.
1171
+ */
1172
+ .wave-docs-youtube[open] .wave-docs-youtube__facade:focus-visible {
1173
+ position: absolute;
1174
+ z-index: 1;
1175
+ inset-block-start: 0.5rem;
1176
+ inset-inline-start: 0.5rem;
1177
+ width: auto;
1178
+ height: auto;
1179
+ margin: 0;
1180
+ padding: 0.5rem 0.75rem;
1181
+ overflow: visible;
1182
+ clip-path: none;
1183
+ border-radius: var(--wave-docs-radius-sm);
1184
+ outline: 2px solid var(--wave-docs-accent);
1185
+ /* Inside, not outside: this control is inset 0.5rem over a black player,
1186
+ * and an offset ring would be drawn half onto the video and half off the
1187
+ * rounded corner of the frame. */
1188
+ outline-offset: -2px;
1189
+ background: var(--wave-docs-bg);
1190
+ color: var(--wave-docs-fg);
1191
+ font-size: 0.875rem;
1192
+ font-weight: 600;
1193
+ }
1194
+
1195
+ /*
1196
+ * The label follows the action. A `<summary>` that reads "Play video" while
1197
+ * activating it *stops* the video is a control that lies about what it does,
1198
+ * and the state is in the DOM already — no script needed to keep the two in
1199
+ * step.
1200
+ */
1201
+ .wave-docs-youtube[open] .wave-docs-youtube__label-play,
1202
+ .wave-docs-youtube:not([open]) .wave-docs-youtube__label-hide {
1203
+ display: none;
681
1204
  }
682
1205
 
683
1206
  .wave-docs-youtube__thumbnail {
@@ -739,6 +1262,22 @@
739
1262
 
740
1263
  /* Nested levels get a guide rail instead of raw indentation, so a deep tree
741
1264
  * stays readable at 240px wide. */
1265
+ /*
1266
+ * `__item` and `__group-title` are emitted by `sidebar.tsx` and had no rules
1267
+ * at all — markup with a public class name and no definition, which is the
1268
+ * worst of the three options: a consumer targeting it gets no baseline to
1269
+ * override, and a maintainer cannot tell whether it is a hook or a leftover.
1270
+ * They are hooks: the drawer styles items at a different density, and a group
1271
+ * title is the one place a sidebar needs a label that is not a link.
1272
+ */
1273
+ .wave-docs-sidebar__item {
1274
+ margin-block: 0.125rem;
1275
+ }
1276
+
1277
+ .wave-docs-sidebar__group-title {
1278
+ font-weight: 600;
1279
+ }
1280
+
742
1281
  .wave-docs-sidebar__list:not([data-depth='0']) {
743
1282
  margin-inline-start: 0.5rem;
744
1283
  padding-inline-start: 0.5rem;
@@ -865,17 +1404,36 @@
865
1404
  text-decoration: none;
866
1405
  }
867
1406
 
868
- /* Indent by heading depth rather than by nesting, so an h4 under an h2 with
869
- * no h3 between them still lines up. */
1407
+ /* Indent by heading depth rather than by nesting, so the rule survives a
1408
+ * document that skips a level. Only depth 3 exists — `rehypeCaptureToc`
1409
+ * captures h2 and h3 and nothing deeper, so the rules for 4, 5 and 6 that
1410
+ * used to sit here styled entries that can no longer be produced. */
870
1411
  .wave-docs-toc__link[data-depth='3'] {
871
1412
  padding-inline-start: 1.5rem;
872
1413
  }
873
- .wave-docs-toc__link[data-depth='4'] {
874
- padding-inline-start: 2.25rem;
1414
+
1415
+ /* The back-to-top link, styled as a peer of the entries rather than as a
1416
+ * floating control — it is the last stop in the rail's reading order. */
1417
+ .wave-docs-toc__top {
1418
+ display: inline-block;
1419
+ margin-block-start: 0.75rem;
1420
+ padding-inline-start: 0.75rem;
1421
+ border-inline-start: 2px solid transparent;
1422
+ color: var(--wave-docs-fg-subtle);
1423
+ font-size: 0.8125rem;
1424
+ text-decoration: none;
1425
+ }
1426
+
1427
+ .wave-docs-toc__top:hover {
1428
+ color: var(--wave-docs-fg);
875
1429
  }
876
- .wave-docs-toc__link[data-depth='5'],
877
- .wave-docs-toc__link[data-depth='6'] {
878
- padding-inline-start: 3rem;
1430
+
1431
+ .wave-docs-sidebar__external {
1432
+ /* Baseline-ish against the label, and never a flex item that grows. */
1433
+ margin-inline-start: 0.25rem;
1434
+ flex: none;
1435
+ vertical-align: -0.05em;
1436
+ color: var(--wave-docs-fg-subtle);
879
1437
  }
880
1438
 
881
1439
  .wave-docs-toc__link:hover {
@@ -896,6 +1454,334 @@
896
1454
  }
897
1455
  }
898
1456
 
1457
+ /* ---------------------------------------------------------------------------
1458
+ * Layout
1459
+ *
1460
+ * The shell `docs.Layout` renders. Every class here is fixed by
1461
+ * `docs/adr/001-shell-contract.md` and is public API from 0.3.0.
1462
+ *
1463
+ * These are the first width-based media queries in this file. Before them there
1464
+ * were none at all — every `@media` was `prefers-color-scheme`,
1465
+ * `prefers-reduced-motion` or `forced-colors` — so there was no mobile layout,
1466
+ * and no CSS for a layout component to use.
1467
+ * ------------------------------------------------------------------------ */
1468
+
1469
+ @layer components {
1470
+ /*
1471
+ * Mobile first, and single-column by construction: the sidebar and the TOC
1472
+ * are not laid out at all below 64rem, so nothing reserves a track for them.
1473
+ */
1474
+ .wave-docs-layout {
1475
+ display: grid;
1476
+ grid-template-columns: minmax(0, 1fr);
1477
+ gap: 0 1.5rem;
1478
+ /* 1rem on a phone, not 1.5rem: 48px of side padding is 15% of a 320px
1479
+ * screen, and it took the reading column to 272px — under the 280px this
1480
+ * package treats as the floor for a usable measure. Measured. */
1481
+ padding-inline: 1rem;
1482
+ /* Without this, `max-width` at the 100rem cap applies to the *content* box
1483
+ * and the shell renders 48px wider than the token says — measured 1648
1484
+ * against a declared 1600. */
1485
+ box-sizing: border-box;
1486
+ }
1487
+
1488
+ @media (min-width: 30rem) {
1489
+ .wave-docs-layout {
1490
+ padding-inline: 1.5rem;
1491
+ }
1492
+ }
1493
+
1494
+ /*
1495
+ * ⚠️ `contents`, NOT `none`, AND THAT IS NOT A STYLE PREFERENCE.
1496
+ *
1497
+ * The drawer `<dialog>` lives inside this wrapper, so that one nav DOM can
1498
+ * serve both breakpoints. An element inside a `display: none` subtree
1499
+ * generates no boxes at all — including a dialog promoted to the top layer —
1500
+ * so `showModal()` would open a drawer that paints nothing, on every
1501
+ * viewport narrower than 64rem. Which is to say: on the phones this drawer
1502
+ * exists for.
1503
+ *
1504
+ * `contents` makes the wrapper generate no box of its own, so it reserves no
1505
+ * grid track, while the closed dialog inside stays hidden by the UA's own
1506
+ * `dialog:not([open]) { display: none }`.
1507
+ */
1508
+ .wave-docs-layout__sidebar {
1509
+ display: contents;
1510
+ }
1511
+
1512
+ .wave-docs-layout__toc {
1513
+ display: none;
1514
+ }
1515
+
1516
+ /*
1517
+ * ⚠️ `min-width: 0`, AND `minmax(0, 1fr)` ON EVERY CONTENT TRACK BELOW.
1518
+ *
1519
+ * `1fr` is shorthand for `minmax(auto, 1fr)`, and `auto` floors at the
1520
+ * content's min-content width — so a wide table pushes the track past the
1521
+ * viewport and takes the whole document into horizontal scroll. Measured with
1522
+ * both removed: 1048px of document inside a 1024px viewport.
1523
+ *
1524
+ * MEASURED, AND WORTH KNOWING BEFORE YOU DELETE ONE: they are redundant with
1525
+ * each other, not jointly required. Removing `minmax(0, 1fr)` alone passes,
1526
+ * and removing `min-width: 0` alone passes; only removing both reproduces the
1527
+ * overflow. They are kept as a pair because they fail in opposite directions
1528
+ * — the track constraint protects a child that does not carry `min-width`,
1529
+ * and `min-width: 0` protects a track someone respells — and because the one
1530
+ * that is left looks arbitrary without the other beside it.
1531
+ *
1532
+ * No unit test can see any of this: the bug needs a wide child and a real
1533
+ * layout engine, and jsdom reports every width as 0.
1534
+ * `src/styles.browser.test.ts` is what stops a "simplification" here from
1535
+ * reintroducing it silently.
1536
+ */
1537
+ .wave-docs-layout__main {
1538
+ min-width: 0;
1539
+ padding-block: 2rem 4rem;
1540
+ }
1541
+
1542
+ /* 16rem sidebar + 46rem measure + two 1.5rem gutters = 65rem, so the sidebar
1543
+ * arrives when there is room for it and not one pixel sooner. A 60rem
1544
+ * breakpoint introduces it exactly where it starts eating the measure. */
1545
+ @media (min-width: 64rem) {
1546
+ .wave-docs-layout {
1547
+ grid-template-columns: var(--wave-docs-sidebar-width) minmax(0, 1fr);
1548
+ }
1549
+
1550
+ /*
1551
+ * ⚠️ THE MOST DELETABLE-LOOKING LINE IN THIS FILE. IT IS NOT DELETABLE.
1552
+ *
1553
+ * `display: contents` makes the dialog box vanish from layout **and from
1554
+ * the accessibility tree**, so the `<DocsSidebar>` inside it becomes the
1555
+ * sticky column directly. That is what lets one nav serve both breakpoints:
1556
+ * one landmark, one copy of the links in the payload, nothing to keep in
1557
+ * step. Remove it and the desktop sidebar disappears entirely — or, if
1558
+ * someone "fixes" that by rendering a second sidebar outside the dialog,
1559
+ * every page ships its nav twice and a screen-reader user gets two
1560
+ * identical navigation landmarks.
1561
+ *
1562
+ * `:not(:modal)` so the drawer still opens as a real modal at any width;
1563
+ * it is the same element, and a 64rem viewport with a 200% zoom is a
1564
+ * narrow one.
1565
+ */
1566
+ dialog.wave-docs-layout__drawer:not(:modal) {
1567
+ display: contents;
1568
+ }
1569
+
1570
+ .wave-docs-layout__sidebar {
1571
+ display: block;
1572
+ position: sticky;
1573
+ top: var(--wave-docs-header-height);
1574
+ /* `dvh`, not `vh`: with a mobile URL bar showing, `vh` is taller than the
1575
+ * visible viewport, so the last few nav items sit under the chrome and
1576
+ * cannot be reached. */
1577
+ max-height: calc(100dvh - var(--wave-docs-header-height));
1578
+ overflow-y: auto;
1579
+ /* Without this, reaching the end of the nav keeps scrolling — the article
1580
+ * jumps under the reader's cursor while they are looking at the sidebar. */
1581
+ overscroll-behavior: contain;
1582
+ /* Reserves the scrollbar's width whether or not it is showing, so the
1583
+ * column does not jump when the nav grows past a screen. Costs ~15px on
1584
+ * platforms with classic scrollbars; a jumping column costs more. */
1585
+ scrollbar-gutter: stable;
1586
+ padding-block: 2rem;
1587
+ }
1588
+ }
1589
+
1590
+ @media (min-width: 80rem) {
1591
+ /*
1592
+ * Inside the query on purpose. As a top-level rule this `:has()` reserves
1593
+ * `--wave-docs-toc-width` of nothing at every width — measured at 390px it
1594
+ * squeezed the article to 94px — because the TOC element is in the DOM at
1595
+ * all sizes and only its `display` changes.
1596
+ */
1597
+ .wave-docs-layout:has(.wave-docs-layout__toc) {
1598
+ grid-template-columns:
1599
+ var(--wave-docs-sidebar-width)
1600
+ minmax(0, 1fr)
1601
+ var(--wave-docs-toc-width);
1602
+ }
1603
+
1604
+ .wave-docs-layout__toc {
1605
+ display: block;
1606
+ position: sticky;
1607
+ /* `align-self: start`, or the sticky box stretches to the row height and
1608
+ * has nothing left to travel within. */
1609
+ align-self: start;
1610
+ top: var(--wave-docs-header-height);
1611
+ max-height: calc(100dvh - var(--wave-docs-header-height));
1612
+ overflow-y: auto;
1613
+ overscroll-behavior: contain;
1614
+ padding-block: 2rem;
1615
+ }
1616
+ }
1617
+
1618
+ /* One declaration, for the only screenshot anyone takes on a 2560px display:
1619
+ * uncapped, the sidebar pins to the far-left bezel and the TOC to the
1620
+ * far-right, about 1500px apart, with the text floating between them. */
1621
+ @media (min-width: 100rem) {
1622
+ .wave-docs-layout {
1623
+ max-width: var(--wave-docs-shell-width);
1624
+ margin-inline: auto;
1625
+ }
1626
+ }
1627
+
1628
+ /* -------------------------------------------------------------------------
1629
+ * Header
1630
+ * ---------------------------------------------------------------------- */
1631
+
1632
+ .wave-docs-layout__header {
1633
+ position: sticky;
1634
+ top: 0;
1635
+ /* Above the sticky sidebar and TOC, which establish their own stacking
1636
+ * contexts; below the search dialog, which portals to `<body>`. */
1637
+ z-index: 20;
1638
+ height: var(--wave-docs-header-height);
1639
+ background-color: var(--wave-docs-bg);
1640
+ border-block-end: 1px solid var(--wave-docs-border);
1641
+ }
1642
+
1643
+ .wave-docs-layout__header-inner {
1644
+ display: flex;
1645
+ align-items: center;
1646
+ gap: 0.75rem;
1647
+ height: 100%;
1648
+ /* Matches the grid's own cap and gutters, so the brand lines up with the
1649
+ * sidebar and the actions with the TOC rather than floating past both. */
1650
+ max-width: var(--wave-docs-shell-width);
1651
+ margin-inline: auto;
1652
+ padding-inline: 1rem;
1653
+ box-sizing: border-box;
1654
+ }
1655
+
1656
+ @media (min-width: 30rem) {
1657
+ .wave-docs-layout__header-inner {
1658
+ padding-inline: 1.5rem;
1659
+ }
1660
+ }
1661
+
1662
+ .wave-docs-layout__title {
1663
+ font-weight: 600;
1664
+ /* The brand is not a link target here and must not swallow the row: a long
1665
+ * product name would otherwise push search off the right edge. */
1666
+ min-width: 0;
1667
+ overflow: hidden;
1668
+ text-overflow: ellipsis;
1669
+ white-space: nowrap;
1670
+ }
1671
+
1672
+ /* Pushes search and actions to the end, and stays correct when `title` is
1673
+ * absent — which is why it is on the search wrapper rather than a margin on
1674
+ * the title that would not exist. */
1675
+ .wave-docs-layout__search {
1676
+ margin-inline-start: auto;
1677
+ }
1678
+
1679
+ .wave-docs-layout__actions {
1680
+ display: flex;
1681
+ align-items: center;
1682
+ gap: 0.5rem;
1683
+ /* When `search={false}` there is no auto margin ahead of this, so it
1684
+ * carries its own. Both set, only one ever has room to apply. */
1685
+ margin-inline-start: auto;
1686
+ }
1687
+
1688
+ .wave-docs-layout__nav-trigger {
1689
+ display: inline-flex;
1690
+ align-items: center;
1691
+ justify-content: center;
1692
+ /* 44px: the smallest target iOS and WCAG 2.5.8 both accept, and this is
1693
+ * the only control on a phone that reaches the rest of the site. */
1694
+ width: 2.75rem;
1695
+ height: 2.75rem;
1696
+ /* Optical alignment: the icon's own padding already insets it from the
1697
+ * screen edge, so the button hangs left of the text below it without this. */
1698
+ margin-inline-start: -0.75rem;
1699
+ border: 0;
1700
+ border-radius: var(--wave-docs-radius-sm);
1701
+ background: none;
1702
+ color: var(--wave-docs-fg-muted);
1703
+ cursor: pointer;
1704
+ }
1705
+
1706
+ .wave-docs-layout__nav-trigger:hover {
1707
+ background-color: var(--wave-docs-bg-subtle);
1708
+ color: var(--wave-docs-fg);
1709
+ }
1710
+
1711
+ /* -------------------------------------------------------------------------
1712
+ * Drawer
1713
+ * ---------------------------------------------------------------------- */
1714
+
1715
+ dialog.wave-docs-layout__drawer {
1716
+ /* A full-height panel against the inline start edge, not a centred box.
1717
+ * `margin` rather than `inset`, because a dialog is centred by `margin:
1718
+ * auto` in the UA sheet and only a margin overrides that. */
1719
+ margin: 0 auto 0 0;
1720
+ box-sizing: border-box;
1721
+ width: min(20rem, 85vw);
1722
+ max-width: none;
1723
+ height: 100dvh;
1724
+ max-height: none;
1725
+ padding: 1.5rem 1rem;
1726
+ border: 0;
1727
+ border-inline-end: 1px solid var(--wave-docs-border);
1728
+ background-color: var(--wave-docs-bg);
1729
+ color: var(--wave-docs-fg);
1730
+ overflow-y: auto;
1731
+ /* Reaching the end of the nav must not start scrolling the page behind. */
1732
+ overscroll-behavior: contain;
1733
+ }
1734
+
1735
+ dialog.wave-docs-layout__drawer::backdrop {
1736
+ background-color: oklch(0.2 0.02 262 / 0.55);
1737
+ }
1738
+
1739
+ .wave-docs-layout__drawer-close {
1740
+ display: inline-flex;
1741
+ align-items: center;
1742
+ justify-content: center;
1743
+ width: 2.75rem;
1744
+ height: 2.75rem;
1745
+ margin-block-end: 0.5rem;
1746
+ margin-inline-start: auto;
1747
+ border: 0;
1748
+ border-radius: var(--wave-docs-radius-sm);
1749
+ background: none;
1750
+ color: var(--wave-docs-fg-muted);
1751
+ cursor: pointer;
1752
+ }
1753
+
1754
+ .wave-docs-layout__drawer-close:hover {
1755
+ background-color: var(--wave-docs-bg-subtle);
1756
+ color: var(--wave-docs-fg);
1757
+ }
1758
+
1759
+ /*
1760
+ * ⚠️ SCOPED TO OUR DRAWER, DELIBERATELY. `html:has(dialog:modal)` would lock
1761
+ * the page for a consumer's own modal too — and unlock it again the moment
1762
+ * ours closed underneath theirs.
1763
+ */
1764
+ html:has(dialog.wave-docs-layout__drawer:modal) {
1765
+ overflow: hidden;
1766
+ }
1767
+
1768
+ /*
1769
+ * Drawer furniture, hidden once the drawer is a column.
1770
+ *
1771
+ * ⚠️ AFTER the rules it overrides, not up with the other 64rem block. Same
1772
+ * specificity means source order decides, and declared earlier this lost to
1773
+ * the `display: inline-flex` above at every width — leaving a menu button
1774
+ * and a close button sitting in a desktop layout that has nothing to open or
1775
+ * close. Measured at 1280px: `flex`, not `none`.
1776
+ */
1777
+ @media (min-width: 64rem) {
1778
+ .wave-docs-layout__nav-trigger,
1779
+ .wave-docs-layout__drawer-close {
1780
+ display: none;
1781
+ }
1782
+ }
1783
+ }
1784
+
899
1785
  /* ---------------------------------------------------------------------------
900
1786
  * Search
901
1787
  *
@@ -929,11 +1815,9 @@
929
1815
  }
930
1816
 
931
1817
  .wave-docs-search-trigger:focus-visible,
932
- .wave-docs-search-close:focus-visible,
933
- .wave-docs-search-result-link:focus-visible {
934
- outline: 2px solid transparent;
1818
+ .wave-docs-search-close:focus-visible {
1819
+ outline: 2px solid var(--wave-docs-accent);
935
1820
  outline-offset: 2px;
936
- box-shadow: var(--wave-docs-ring);
937
1821
  }
938
1822
 
939
1823
  /* The label is the flexible half of the trigger; the shortcut hint beside it
@@ -962,7 +1846,10 @@
962
1846
  justify-content: center;
963
1847
  /* Not centred: a dialog that grows downward from a fixed top does not
964
1848
  * shift under the reader as results stream in. */
965
- padding: 10vh 1rem 1rem;
1849
+ /* `dvh`, not `vh`: on a phone `vh` is the viewport with the URL bar
1850
+ * *retracted*, so a dialog sized against it is taller than what the reader
1851
+ * can actually see and its bottom edge sits under the browser chrome. */
1852
+ padding: 10dvh 1rem 1rem;
966
1853
  background: oklch(0.2 0.02 265 / 0.5);
967
1854
  backdrop-filter: blur(2px);
968
1855
  }
@@ -972,12 +1859,12 @@
972
1859
  flex-direction: column;
973
1860
  width: 100%;
974
1861
  max-width: 34rem;
975
- max-height: min(32rem, 80vh);
1862
+ max-height: min(32rem, 80dvh);
976
1863
  overflow: hidden;
977
1864
  border: 1px solid var(--wave-docs-border);
978
1865
  border-radius: var(--wave-docs-radius);
979
1866
  background: var(--wave-docs-bg);
980
- box-shadow: 0 24px 48px -12px oklch(0 0 0 / 0.3);
1867
+ box-shadow: 0 24px 48px -12px var(--wave-docs-shadow);
981
1868
  }
982
1869
 
983
1870
  .wave-docs-search-input-row {
@@ -1015,9 +1902,9 @@
1015
1902
  font-size: 1rem;
1016
1903
  }
1017
1904
 
1018
- /* Not `outline: 2px solid transparent` like every other control here: the
1019
- * row above already draws a real outline, and a second one on the input
1020
- * would double up in forced-colors mode. */
1905
+ /* The one `outline: none` in the file, and it is not a missing indicator:
1906
+ * the row around the input draws a real outline on `:has(:focus-visible)`,
1907
+ * and a second one on the input itself would double up. */
1021
1908
  .wave-docs-search-input:focus {
1022
1909
  outline: none;
1023
1910
  }
@@ -1136,44 +2023,3 @@
1136
2023
  white-space: nowrap;
1137
2024
  }
1138
2025
  }
1139
-
1140
- /* ---------------------------------------------------------------------------
1141
- * Forced colours
1142
- *
1143
- * Windows High Contrast and its kin drop `box-shadow` entirely and replace
1144
- * every colour with one from the user's palette. Every focus indicator in this
1145
- * package is a `box-shadow` ring, so without this block the package would
1146
- * *remove* the UA outline from every focusable surface it styles and draw
1147
- * nothing in its place — a strictly worse page than shipping no CSS at all.
1148
- *
1149
- * `Highlight` rather than a token: user-chosen system colours are the whole
1150
- * point of the mode, and `--wave-docs-accent` is forced to something else
1151
- * anyway. This list must stay in step with every `:focus-visible` rule above;
1152
- * `src/styles.test.ts` fails if one appears without its entry here.
1153
- * ------------------------------------------------------------------------ */
1154
-
1155
- @layer components {
1156
- @media (forced-colors: active) {
1157
- .wave-docs-skip-link:focus,
1158
- .wave-docs-prose a:focus-visible,
1159
- .wave-docs-sidebar :is(a, button):focus-visible,
1160
- .wave-docs-toc a:focus-visible,
1161
- .wave-docs-youtube__facade:focus-visible,
1162
- .wave-docs-table-scroll:focus-visible,
1163
- .wave-docs-prose .shiki:focus-visible,
1164
- .wave-docs-search-trigger:focus-visible,
1165
- .wave-docs-search-close:focus-visible,
1166
- .wave-docs-search-result-link:focus-visible {
1167
- outline: 3px solid Highlight;
1168
- outline-offset: 2px;
1169
- }
1170
-
1171
- /* Inward, like their normal-mode rules: one is clipped by the dialog, the
1172
- * other by the scrolling results list. */
1173
- .wave-docs-search-input-row:has(.wave-docs-search-input:focus-visible),
1174
- .wave-docs-search-result-active {
1175
- outline: 2px solid Highlight;
1176
- outline-offset: -2px;
1177
- }
1178
- }
1179
- }