@waveso/docs 0.6.0 → 0.7.1

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
@@ -80,6 +80,25 @@
80
80
  --wave-docs-fg-subtle: oklch(0.54 0.015 262);
81
81
  --wave-docs-border: oklch(0.91 0.006 262);
82
82
  --wave-docs-border-strong: oklch(0.84 0.008 262);
83
+ /* The hero's grid, and the one place this package paints the brand ramp
84
+ * rather than a neutral.
85
+ *
86
+ * Wave 200 and Wave 300 from `@waveso/ui` verbatim — #AEDDFF and #7BC3FF.
87
+ *
88
+ * ⚠️ THE RIGHT STEP ON THE RAMP DEPENDS ON THE MASK, AND THE MASK CHANGED.
89
+ * It walked up to 300/400 while a radial gradient was holding the middle of
90
+ * the header at a few per cent — the numbers were chosen against a picture
91
+ * that was mostly suppressed. The radial is gone, so the same tokens paint
92
+ * at full strength across the whole header and read far heavier than they
93
+ * did. One step back is the same apparent weight.
94
+ *
95
+ * ⚠️ NOT `--wave-docs-border`. The grid is decoration behind a radial mask
96
+ * that leaves it near-invisible where the words are; a border is a boundary
97
+ * a reader has to be able to see. Sharing one token would tie a decorative
98
+ * tint to a functional contrast ratio, and the next change to either would
99
+ * be wrong for the other. */
100
+ --wave-docs-hero-grid: oklch(0.876 0.068 240);
101
+ --wave-docs-hero-grid-strong: oklch(0.793 0.112 245);
83
102
 
84
103
  /* Wave Blue #0074DE, darkened to #006EC8: the brand value clears 4.5:1 on
85
104
  * white but only 4.26:1 on `accent-subtle`, which is what the active sidebar
@@ -125,23 +144,47 @@
125
144
 
126
145
  --wave-docs-radius: 0.5rem;
127
146
  --wave-docs-radius-sm: 0.375rem;
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;
147
+ /* The shell. These five plus `--wave-docs-measure` are the whole settable
148
+ * layout surface; the gutter and the drawer width are literals, because each
149
+ * appears once and an ordinary override is already the cleanest tool for
150
+ * them. */
133
151
  --wave-docs-sidebar-width: 16rem;
152
+ /* The button the reader sees. The strip around it is `auto` — this plus 4px
153
+ * of padding on every side — so setting this is the whole of resizing the
154
+ * control, and the hit area grows with it rather than being a second number
155
+ * to keep in step. */
156
+ --wave-docs-trigger-width: 1.25rem;
134
157
  --wave-docs-toc-width: 15rem;
135
- --wave-docs-shell-width: 100rem;
158
+
159
+ /*
160
+ * Where our sticky chrome starts, and the one thing only the host knows.
161
+ *
162
+ * A documentation section inside an application sits under that
163
+ * application's own bar. If theirs is sticky, ours has to start below it —
164
+ * and nothing in this package can measure that. This is the whole
165
+ * mechanical fix for it. A `standalone`/`embedded` mode flag was considered
166
+ * and rejected: it would have been an alias for this one line, which the
167
+ * host has to know the value of either way.
168
+ *
169
+ * ⚠️ `0rem`, NOT `0`, AND THE UNIT IS LOAD-BEARING. It is read inside
170
+ * `calc(100dvh - …)` on both sticky columns, and `calc(100dvh - 0)` is
171
+ * invalid at computed-value time — a unitless zero would not resolve to "no
172
+ * change", it would kill the `max-height` outright.
173
+ */
174
+ --wave-docs-chrome-offset: 0rem;
136
175
 
137
176
  /* How far below the top of the scrollport an anchored heading parks.
138
177
  *
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);
178
+ * Derived, not set, and one derivation rather than two. It carried a
179
+ * `--wave-docs-bar-height` term as well, because the sidebar used to take a
180
+ * strip shape *above* the content below 64rem and a heading had to clear
181
+ * that too. The sidebar is beside the content at every width now, so
182
+ * nothing of ours is ever over a heading.
183
+ *
184
+ * ⚠️ THE HOST TERM IS NOT OPTIONAL. An earlier draft made the desktop value
185
+ * a flat `1rem`, which parks a heading underneath the host bar of the very
186
+ * persona `--wave-docs-chrome-offset` exists for. */
187
+ --wave-docs-scroll-padding: calc(var(--wave-docs-chrome-offset) + 1rem);
145
188
  --wave-docs-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo,
146
189
  Consolas, "Liberation Mono", monospace;
147
190
 
@@ -168,8 +211,8 @@
168
211
  * all, a 1440px viewport produced a ~140-character line.
169
212
  *
170
213
  * `--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. */
214
+ * shell already constrains the column. One of the six settable layout
215
+ * tokens. */
173
216
  --wave-docs-measure: 46rem;
174
217
  }
175
218
 
@@ -185,6 +228,11 @@
185
228
  --wave-docs-fg-subtle: oklch(0.665 0.015 265);
186
229
  --wave-docs-border: oklch(0.31 0.014 265);
187
230
  --wave-docs-border-strong: oklch(0.4 0.016 265);
231
+ /* Wave 900 and Wave 800. The light pair would be a white grid on a dark
232
+ * page — a ramp is not a colour, and the tint has to come from the end
233
+ * that belongs on this ground. */
234
+ --wave-docs-hero-grid: oklch(0.284 0.088 255);
235
+ --wave-docs-hero-grid-strong: oklch(0.351 0.118 255);
188
236
 
189
237
  --wave-docs-accent: oklch(0.72 0.14 248);
190
238
  --wave-docs-accent-hover: oklch(0.8 0.12 248);
@@ -228,6 +276,8 @@
228
276
  --wave-docs-fg-subtle: oklch(0.665 0.015 265);
229
277
  --wave-docs-border: oklch(0.31 0.014 265);
230
278
  --wave-docs-border-strong: oklch(0.4 0.016 265);
279
+ --wave-docs-hero-grid: oklch(0.284 0.088 255);
280
+ --wave-docs-hero-grid-strong: oklch(0.351 0.118 255);
231
281
 
232
282
  --wave-docs-accent: oklch(0.72 0.14 248);
233
283
  --wave-docs-accent-hover: oklch(0.8 0.12 248);
@@ -259,6 +309,28 @@
259
309
  * ------------------------------------------------------------------------ */
260
310
 
261
311
  @layer base {
312
+ /*
313
+ * ⚠️ THE SHELL DEPENDED ON THE HOST SHIPPING A RESET, AND MOST HOSTS DO.
314
+ *
315
+ * `.wave-docs-sidebar__link` is `width: 100%` with `0.5rem` of inline
316
+ * padding. Under `content-box` that is a 272px box in a 256px track, so the
317
+ * external-link icon `justify-content: space-between` pins to the far end
318
+ * lands 8px outside the sidebar and is clipped in half — measured, on the
319
+ * site in `site/`. Tailwind's preflight and every normalize-style reset set
320
+ * `border-box` globally, so the overflow was invisible in every project that
321
+ * has one, and visible on the only site here that ships no CSS at all.
322
+ *
323
+ * Scoped to this package's own namespace rather than to `*`. The prose
324
+ * renders a consumer's components as well as ours, and a global reset
325
+ * arriving inside a stylesheet they imported for their documentation is not
326
+ * ours to write — but every element carrying a `wave-docs-` class is one this
327
+ * package emitted and sized.
328
+ */
329
+ [class^='wave-docs-'],
330
+ [class*=' wave-docs-'] {
331
+ box-sizing: border-box;
332
+ }
333
+
262
334
  /*
263
335
  * The ground the ramp is composed against.
264
336
  *
@@ -315,6 +387,8 @@
315
387
  .wave-docs-sidebar,
316
388
  .wave-docs-toc,
317
389
  .wave-docs-skip-link,
390
+ .wave-docs-hero,
391
+ .wave-docs-layout__sidebar,
318
392
  .wave-docs-search-trigger,
319
393
  .wave-docs-search-dialog {
320
394
  font-family: var(--wave-docs-font-sans);
@@ -415,6 +489,13 @@
415
489
  * shipped, so in practice every consumer's first override was the same
416
490
  * container CSS — a default the package should have carried itself. */
417
491
  max-width: var(--wave-docs-measure);
492
+ /* ⚠️ AND CENTRED IN ITS TRACK, WHICH IS WHERE THE SHELL'S OLD CAP WENT.
493
+ * The shell used to be capped and centred, which pushed the sidebar off the
494
+ * page's inline start edge and left the closed navigation visible in the
495
+ * centring margin. The reading column is the thing that should not stretch,
496
+ * so it is the thing that is capped — and centring it here is what keeps a
497
+ * 2560px display from pinning the text against the navigation. */
498
+ margin-inline: auto;
418
499
  }
419
500
 
420
501
  .wave-docs-prose > * + * {
@@ -627,6 +708,33 @@
627
708
 
628
709
  @layer components {
629
710
  .wave-docs-table-scroll {
711
+ /*
712
+ * One cell, three things in it: the table and the two edge overlays below.
713
+ * `grid` rather than `relative` + `absolute` because an absolutely
714
+ * positioned child of a scroll container scrolls with the content, and
715
+ * these have to hold the scrollport's edges.
716
+ */
717
+ display: grid;
718
+ /*
719
+ * ⚠️ `max-content`, NOT `1fr`, AND THE DIFFERENCE IS WHETHER THE END EDGE
720
+ * EXISTS. A `1fr` track resolves against the scroll *port*, so the cell is
721
+ * narrower than the table it holds — and a sticky element can only travel
722
+ * inside its containing block, so the end overlay scrolled out of view and
723
+ * the shadow measured 0/255 at every scroll position. `max-content` makes
724
+ * the cell as wide as the table, which is the span both edges have to reach
725
+ * across.
726
+ *
727
+ * ⚠️ AND `max-content` IS THE *MIN*, WHICH IS THE ONLY SPELLING THAT DOES
728
+ * BOTH JOBS. A bare `max-content` track is exactly the table's width, so
729
+ * `width: 100%` resolved against that and a table narrower than the reading
730
+ * column stopped filling it — a bordered box with empty space down its
731
+ * inline end. `minmax(100%, max-content)` does not fix it either: a track
732
+ * only grows toward its max when there is free space, and inside a scroll
733
+ * container there is none, so it stayed at the port's width and the end
734
+ * overlay lost its containing block again. As the *floor*, `max-content`
735
+ * wins when the table is wider and `1fr` takes over when it is not.
736
+ */
737
+ grid-template-columns: minmax(max-content, 1fr);
630
738
  overflow-x: auto;
631
739
  border: 1px solid var(--wave-docs-border);
632
740
  border-radius: var(--wave-docs-radius);
@@ -667,7 +775,159 @@
667
775
  outline-offset: 2px;
668
776
  }
669
777
 
778
+ /*
779
+ * ⚠️ THE SAME AFFORDANCE, PAINTED ABOVE THE CONTENT INSTEAD OF BEHIND IT.
780
+ *
781
+ * The gradients above are a *background*, and an element's background is by
782
+ * definition below everything inside it — so the sticky `thead th`, which
783
+ * carries its own opaque fill, and every inline `<code>` chip, which carries
784
+ * another, punched holes in the shadow exactly where a wide table is most
785
+ * likely to be cut off. Found in a screenshot; no test could see it, because
786
+ * every assertion about the shadow was about declarations rather than pixels.
787
+ *
788
+ * These two overlay it. They share the table's grid cell, stick to the
789
+ * scrollport edges, and sit at `z-index: 2` — above `thead th`, which is 1,
790
+ * and above the chips, which are in flow.
791
+ *
792
+ * `opacity: 0` is the base state and it is load-bearing: see the `@supports`
793
+ * block below.
794
+ */
795
+ .wave-docs-table-scroll::before,
796
+ .wave-docs-table-scroll::after {
797
+ content: '';
798
+ grid-area: 1 / 1;
799
+ position: sticky;
800
+ z-index: 2;
801
+ width: 0.75rem;
802
+ opacity: 0;
803
+ pointer-events: none;
804
+ }
805
+
806
+ /*
807
+ * ⚠️ THE TAPER IS A MASK, AND THE SHAPE WAS HALF THE ORIGINAL BUG.
808
+ *
809
+ * The background version used `radial-gradient(farthest-side at 0 50%)`,
810
+ * which reads well — soft at the top and bottom, strongest through the middle
811
+ * — and whose falloff is *proportional to the box*. On a tall table that puts
812
+ * the strength at the vertical centre and nothing at the ends: sampled on a
813
+ * real one, 5–9/255 of darkening beside the first body rows and 1/255 beside
814
+ * the header, which is where a reader looks first.
815
+ *
816
+ * So the horizontal falloff is a linear gradient, even down the whole height,
817
+ * and the taper is a mask with a *bounded* ramp. Proportional while the table
818
+ * is short — a quarter of the height at each end, which is the oval the
819
+ * original had — and capped at 5rem once it is tall, so a long table softens
820
+ * over its first and last 80px instead of over its first and last third.
821
+ * Measured at the cap: 0/255 at the very top edge, ~14 across the header row,
822
+ * 24 through the body. Soft where a shadow should be soft, present where a
823
+ * reader is looking.
824
+ */
825
+ .wave-docs-table-scroll::before,
826
+ .wave-docs-table-scroll::after {
827
+ mask-image: linear-gradient(
828
+ to bottom,
829
+ transparent 0,
830
+ rgb(0 0 0 / 0.55) min(8%, 1.25rem),
831
+ #000 min(24%, 5rem),
832
+ #000 calc(100% - min(24%, 5rem)),
833
+ rgb(0 0 0 / 0.55) calc(100% - min(8%, 1.25rem)),
834
+ transparent 100%
835
+ );
836
+ }
837
+
838
+ .wave-docs-table-scroll::before {
839
+ justify-self: start;
840
+ inset-inline-start: 0;
841
+ background: linear-gradient(
842
+ to right,
843
+ var(--wave-docs-scroll-shadow),
844
+ transparent
845
+ );
846
+ }
847
+
848
+ .wave-docs-table-scroll::after {
849
+ justify-self: end;
850
+ inset-inline-end: 0;
851
+ background: linear-gradient(
852
+ to left,
853
+ var(--wave-docs-scroll-shadow),
854
+ transparent
855
+ );
856
+ }
857
+
858
+ /*
859
+ * ⚠️ AN INACTIVE TIMELINE IS WHAT MAKES THIS CORRECT, NOT JUST CLEVER.
860
+ *
861
+ * A scroll timeline is *inactive* when its scroll container has no scrollable
862
+ * overflow in the axis, and an animation with an inactive timeline does not
863
+ * apply — so the base `opacity: 0` wins and a table that fits shows no shadow
864
+ * at all. No query asks whether it scrolls; the answer falls out of whether
865
+ * there is anything to scroll.
866
+ *
867
+ * ⚠️ `nearest`, NOT `self`. `self` names the element the animation is on, and
868
+ * the element here is a pseudo-element *inside* the scroll container rather
869
+ * than the container itself — so `scroll(self inline)` was inactive at every
870
+ * width, on every table, and the shadow simply never appeared. It looked
871
+ * exactly like the feature being unsupported: the `@supports` matched, both
872
+ * animations were listed on the pseudo, and the computed opacity was 0.
873
+ *
874
+ * Where it does overflow, scroll position alone drives the fade: no listener,
875
+ * no state, no hydration, on a table a reader may well meet before JS lands.
876
+ *
877
+ * Firefox skips this block — the feature is still behind
878
+ * `layout.css.scroll-driven-animations.enabled` as of 152, and is an Interop
879
+ * 2026 priority — and keeps the background gradients above, which is exactly
880
+ * what it renders today. The two are mutually exclusive on purpose: the
881
+ * `background: none` here is what stops a supporting browser darkening each
882
+ * edge twice.
883
+ */
884
+ @supports (animation-timeline: scroll()) {
885
+ .wave-docs-table-scroll {
886
+ background: none;
887
+ }
888
+
889
+ .wave-docs-table-scroll::before,
890
+ .wave-docs-table-scroll::after {
891
+ animation-timeline: scroll(nearest inline);
892
+ animation-timing-function: linear;
893
+ animation-fill-mode: both;
894
+ }
895
+
896
+ .wave-docs-table-scroll::before {
897
+ animation-name: wave-docs-scroll-shadow-start;
898
+ }
899
+
900
+ .wave-docs-table-scroll::after {
901
+ animation-name: wave-docs-scroll-shadow-end;
902
+ }
903
+ }
904
+
905
+ /* Absent while the start edge is at rest, present once it is not. */
906
+ @keyframes wave-docs-scroll-shadow-start {
907
+ 0%,
908
+ 2% {
909
+ opacity: 0;
910
+ }
911
+ 8%,
912
+ 100% {
913
+ opacity: 1;
914
+ }
915
+ }
916
+
917
+ /* Present until the end edge is reached. */
918
+ @keyframes wave-docs-scroll-shadow-end {
919
+ 0%,
920
+ 92% {
921
+ opacity: 1;
922
+ }
923
+ 98%,
924
+ 100% {
925
+ opacity: 0;
926
+ }
927
+ }
928
+
670
929
  .wave-docs-table {
930
+ grid-area: 1 / 1;
671
931
  width: 100%;
672
932
  /*
673
933
  * A FLOOR, NOT A SIZING MODE — and `width: 100%` is not one.
@@ -732,7 +992,20 @@
732
992
  white-space: nowrap;
733
993
  }
734
994
 
735
- .wave-docs-table :is(thead, tbody tr + tr) {
995
+ /*
996
+ * ⚠️ `tbody tr + tr` ONLY. `thead` WAS IN THIS SELECTOR AND DOUBLED THE FRAME.
997
+ *
998
+ * The header is the first thing in the table, so its `border-block-start`
999
+ * landed directly against `.wave-docs-table-scroll`'s own border with nothing
1000
+ * between them: measured at the top-left corner, two adjacent rows of
1001
+ * `--wave-docs-border` instead of one, which reads as a thick or doubled
1002
+ * line rather than as a frame.
1003
+ *
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`.
1007
+ */
1008
+ .wave-docs-table tbody tr + tr {
736
1009
  border-block-start: 1px solid var(--wave-docs-border);
737
1010
  }
738
1011
  }
@@ -797,10 +1070,19 @@
797
1070
 
798
1071
  .wave-docs-code {
799
1072
  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;
1073
+ /*
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.
1078
+ *
1079
+ * Zeroing all four also took the *block* margin, and `.wave-docs-prose > *
1080
+ * + *` is the same specificity and declared earlier, so this won. Every
1081
+ * code block sat flush against whatever followed it: two fences touching,
1082
+ * a fence touching the paragraph under it. The flow spacing owns the block
1083
+ * axis; this rule owns the inline one.
1084
+ */
1085
+ margin-inline: 0;
804
1086
  }
805
1087
 
806
1088
  .wave-docs-code__title {
@@ -895,6 +1177,21 @@
895
1177
  /* A title bar already reserves the space, so there is nothing to reveal. */
896
1178
  .wave-docs-code:has(.wave-docs-code__title) .wave-docs-code__copy {
897
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;
898
1195
  }
899
1196
 
900
1197
  /*
@@ -1428,6 +1725,89 @@
1428
1725
  color: var(--wave-docs-fg);
1429
1726
  }
1430
1727
 
1728
+ /*
1729
+ * ⚠️ IT ONLY APPEARS ONCE THERE IS SOMETHING TO GO BACK TO, AND NOTHING IN
1730
+ * JAVASCRIPT DECIDES THAT.
1731
+ *
1732
+ * A scroll-driven animation, so scroll position alone drives it: no listener,
1733
+ * no state, no re-render on every frame of a scroll, and it is right before
1734
+ * hydration rather than after it. `toc` is the smallest client component this
1735
+ * package ships — 900 bytes, and its budget note exists to keep it that way —
1736
+ * so the alternative was to grow the one bundle that should never grow, in
1737
+ * order to recompute a number the compositor already has.
1738
+ *
1739
+ * ⚠️ `root`, NOT `nearest`. Above 80rem `.wave-docs-layout__toc` is itself a
1740
+ * scroll container (`overflow-y: auto`, so a long column of headings scrolls
1741
+ * in place), and `nearest` resolves to it. The animation would then track how
1742
+ * far the reader had scrolled the *table of contents*, which on almost every
1743
+ * page never scrolls at all — an inactive timeline, and a link that never
1744
+ * appears. Same shape as the `nearest`/`self` trap on the table shadow above,
1745
+ * arrived at from the opposite direction.
1746
+ *
1747
+ * ⚠️ AND THE RULE ABOVE IS THE FALLBACK, WHICH IS WHY NOTHING HERE OVERRIDES
1748
+ * IT. Three cases collapse into one: Firefox does not match this `@supports`
1749
+ * yet; a page too short to scroll leaves the timeline inactive; and so does a
1750
+ * host that scrolls an inner pane rather than the document. In all three the
1751
+ * link is simply always present, exactly as it was before this block existed.
1752
+ * Nothing hides a control on the strength of a feature the engine did not run.
1753
+ *
1754
+ * `dvh` rather than `px`: the link is redundant while the top of the document
1755
+ * is still on screen, and "still on screen" is a fraction of the viewport
1756
+ * rather than a constant. On a 900px window it fades in between 225px and
1757
+ * 315px of scroll. `dvh` because this sheet uses no `vh` anywhere, and the
1758
+ * reason it does not — the retracted mobile URL bar — costs nothing here: the
1759
+ * column this link lives in is `display: none` below 80rem.
1760
+ *
1761
+ * No `prefers-reduced-motion` gate, deliberately. That setting is about things
1762
+ * that move under a reader who did not ask them to; this is opacity, and the
1763
+ * reader's own scrolling is its clock.
1764
+ */
1765
+ @supports (animation-timeline: scroll()) {
1766
+ .wave-docs-toc__top {
1767
+ animation-timeline: scroll(root block);
1768
+ animation-timing-function: linear;
1769
+ animation-fill-mode: both;
1770
+ animation-name: wave-docs-toc-top-reveal;
1771
+ animation-range: 25dvh 35dvh;
1772
+ }
1773
+ }
1774
+
1775
+ /*
1776
+ * ⚠️ `visibility` KEEPS A LINK NOBODY CAN SEE OUT OF THE TAB ORDER, AND THE
1777
+ * MIDDLE FRAME IS WHAT DECIDES WHEN IT REJOINS.
1778
+ *
1779
+ * `opacity` alone leaves a fully focusable, fully clickable link sitting
1780
+ * invisibly at the foot of the rail: Tab reaches it and the focus ring is
1781
+ * drawn around nothing.
1782
+ *
1783
+ * `visibility` interpolates as a step with one exception — across an interval
1784
+ * where *either* endpoint is `visible` it is `visible` the whole way. Two
1785
+ * frames would therefore flip it one pixel past the threshold and hand the
1786
+ * link back to the tab order at an opacity of almost zero, which is the same
1787
+ * defect a frame later. Two adjacent `hidden` frames have no `visible`
1788
+ * endpoint between them, the exception does not apply, and the step lands at
1789
+ * 60% — where the link is legible.
1790
+ *
1791
+ * That middle frame sits exactly on the linear ramp, so it changes the fade
1792
+ * by nothing at all. Being the second `hidden` is its whole job.
1793
+ */
1794
+ @keyframes wave-docs-toc-top-reveal {
1795
+ 0% {
1796
+ opacity: 0;
1797
+ visibility: hidden;
1798
+ }
1799
+
1800
+ 60% {
1801
+ opacity: 0.6;
1802
+ visibility: hidden;
1803
+ }
1804
+
1805
+ 100% {
1806
+ opacity: 1;
1807
+ visibility: visible;
1808
+ }
1809
+ }
1810
+
1431
1811
  .wave-docs-sidebar__external {
1432
1812
  /* Baseline-ish against the label, and never a flex item that grows. */
1433
1813
  margin-inline-start: 0.25rem;
@@ -1436,6 +1816,18 @@
1436
1816
  color: var(--wave-docs-fg-subtle);
1437
1817
  }
1438
1818
 
1819
+ .wave-docs-toc__title {
1820
+ margin: 0 0 0.5rem;
1821
+ /* Aligns with the link text below it. The links carry a 2px rail on their
1822
+ * inline start and 0.75rem of padding inside it; this has no rail, so it
1823
+ * takes the rail's width as padding instead. */
1824
+ padding-inline-start: calc(0.75rem + 2px);
1825
+ color: var(--wave-docs-fg);
1826
+ font-size: 0.8125rem;
1827
+ font-weight: 600;
1828
+ letter-spacing: 0.01em;
1829
+ }
1830
+
1439
1831
  .wave-docs-toc__link:hover {
1440
1832
  color: var(--wave-docs-fg);
1441
1833
  border-inline-start-color: var(--wave-docs-border-strong);
@@ -1457,8 +1849,8 @@
1457
1849
  /* ---------------------------------------------------------------------------
1458
1850
  * Layout
1459
1851
  *
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.
1852
+ * The shell `docs.Layout` renders. Every class here is public API from 0.3.0
1853
+ * and changes only in a release that carries the migration.
1462
1854
  *
1463
1855
  * These are the first width-based media queries in this file. Before them there
1464
1856
  * were none at all — every `@media` was `prefers-color-scheme`,
@@ -1468,47 +1860,95 @@
1468
1860
 
1469
1861
  @layer components {
1470
1862
  /*
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.
1863
+ * ONE SIDEBAR, AT EVERY WIDTH, AND IT IS A SHELL OF TWO.
1864
+ *
1865
+ * .wave-docs-shell the query container
1866
+ * └─ .wave-docs-layout the grid
1867
+ * ├─ .wave-docs-layout__sidebar paints nothing, and moves
1868
+ * │ ├─ …__sidebar-nav the surface, and the one border
1869
+ * │ └─ …__sidebar-trigger the strip — paints nothing at rest
1870
+ * ├─ .wave-docs-layout__sidebar-scrim
1871
+ * ├─ .wave-docs-layout__main
1872
+ * └─ .wave-docs-layout__toc
1873
+ *
1874
+ * There is no drawer, no dialog, no second control and no second copy of the
1875
+ * tree. Pressing the trigger translates the sidebar; the trigger rides on the
1876
+ * navigation's outer edge because it is the next flex item, not because a
1877
+ * number says so.
1878
+ *
1879
+ * ⚠️ `@container`, AND EVERY WIDTH-BASED `@media` IS GONE WITH IT.
1880
+ *
1881
+ * This package is mounted at `/docs` inside applications that own the rest of
1882
+ * the page. `@media` asks how wide the *screen* is, which is the wrong
1883
+ * question: a host who puts this in a 700px panel on a 1920px monitor gets
1884
+ * the wide layout and a reading column of about 60px. That is the same
1885
+ * failure as the fixed header — a collision arriving from a direction no
1886
+ * breakpoint can see. `@container` asks how wide the box we were *given* is,
1887
+ * which is the question with an answer.
1888
+ *
1889
+ * ⚠️ AND THAT IS WHY THERE IS AN OUTER ELEMENT. A container query never
1890
+ * matches its own container, so with `container-type` on the grid, no rule
1891
+ * inside a query could touch `grid-template-columns` — which is exactly the
1892
+ * declaration the layout has to change. One wrapper, and every rule below is
1893
+ * addressable.
1473
1894
  */
1895
+ /*
1896
+ * ⚠️ NO `max-width` AND NO `margin-inline: auto` HERE, AND THAT IS A FIX
1897
+ * RATHER THAN AN OMISSION.
1898
+ *
1899
+ * This box was capped at `--wave-docs-shell-width` and centred. Below the cap
1900
+ * the layout's inline start edge and the viewport's are the same place, so
1901
+ * `translate: calc(var(--wave-docs-trigger-width) - 100%)` put the closed
1902
+ * navigation off the screen. Above it they are not: on a 2000px viewport the
1903
+ * shell starts at 200, and the closed navigation parked at −56…200 — two
1904
+ * hundred pixels of it still on screen, beside a trigger sitting 200px in
1905
+ * from the edge. Measured.
1906
+ *
1907
+ * The sidebar owns the page's inline start edge at every width now, so
1908
+ * "closed" means off the screen by construction rather than by coincidence.
1909
+ * What was capped is capped where it belongs: `.wave-docs-prose` holds
1910
+ * `--wave-docs-measure` and centres itself in the track, so a 2560px display
1911
+ * gets a reading column between its navigation and its table of contents
1912
+ * instead of text pinned against one of them.
1913
+ */
1914
+ .wave-docs-shell {
1915
+ container-type: inline-size;
1916
+ container-name: wave-docs;
1917
+ }
1918
+
1474
1919
  .wave-docs-layout {
1920
+ /* Absolutely positioned children of ours resolve against this, so nothing
1921
+ * of ours resolves against the viewport and nothing of ours can land on a
1922
+ * host's chrome. */
1923
+ position: relative;
1475
1924
  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. */
1925
+ /*
1926
+ * ⚠️ `auto`, AND THE SIDEBAR DECIDES WHAT IT MEANS. The track takes the
1927
+ * sidebar's *outer* size, and the sidebar sets that with a negative margin:
1928
+ * 44px whenever the navigation is off the page or covering the article,
1929
+ * its full width only when it is meant to sit beside it. So one track
1930
+ * expresses both modes and both states, and the layout never has to know
1931
+ * which it is in.
1932
+ *
1933
+ * A fixed `var(--wave-docs-trigger-width)` here was the first attempt, and
1934
+ * it could not grow — push mode silently behaved exactly like cover mode.
1935
+ * Measured at 1440px: the article started at 68px in both states.
1936
+ *
1937
+ * In cover mode the article's measure is untouched by the toggle, because
1938
+ * the track never changes: 330px at 390px whether the navigation is open or
1939
+ * shut. In push mode it necessarily changes — that is what push means, and
1940
+ * it is why cover exists for the widths where 330px is all there is.
1941
+ */
1942
+ grid-template-columns: auto minmax(0, 1fr);
1943
+ gap: 0 0.5rem;
1944
+ /* Nothing on the inline start: the trigger's track owns that edge. */
1945
+ padding-inline: 0 0.5rem;
1946
+ /* Without this, `max-width` at the cap applies to the *content* box and the
1947
+ * shell renders 48px wider than the token says — measured 1648 against a
1948
+ * declared 1600. */
1485
1949
  box-sizing: border-box;
1486
1950
  }
1487
1951
 
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
1952
  .wave-docs-layout__toc {
1513
1953
  display: none;
1514
1954
  }
@@ -1526,259 +1966,713 @@
1526
1966
  * and removing `min-width: 0` alone passes; only removing both reproduces the
1527
1967
  * overflow. They are kept as a pair because they fail in opposite directions
1528
1968
  * — 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.
1969
+ * and `min-width: 0` protects a track someone respells.
1536
1970
  */
1537
1971
  .wave-docs-layout__main {
1538
1972
  min-width: 0;
1539
1973
  padding-block: 2rem 4rem;
1974
+ /*
1975
+ * ⚠️ EVERY `z-index` IN THE ARTICLE IS TRAPPED HERE, AND ONE OF THEM WAS
1976
+ * PUNCHING THROUGH THE SCRIM.
1977
+ *
1978
+ * A sticky table header is `z-index: 1`, and so is the scrim. Same stacking
1979
+ * context, equal z-index, and the header comes later in the document — so
1980
+ * with the navigation open over a table on a phone, the header row was the
1981
+ * one thing on the page that stayed bright. Measured on the installation
1982
+ * page.
1983
+ *
1984
+ * Raising the scrim would have fixed that one element and left the next
1985
+ * one. `isolation: isolate` makes the article its own stacking context, so
1986
+ * nothing inside it — ours or a consumer's own content — can be compared
1987
+ * against the scrim at all. The article resolves as a whole, at one layer,
1988
+ * underneath.
1989
+ */
1990
+ isolation: isolate;
1540
1991
  }
1541
1992
 
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
- }
1993
+ /* -------------------------------------------------------------------------
1994
+ * The hero
1995
+ *
1996
+ * A page header a landing page opts into by declaring `actions` in its
1997
+ * frontmatter. Every other page renders none of this.
1998
+ * ---------------------------------------------------------------------- */
1549
1999
 
2000
+ .wave-docs-hero {
2001
+ position: relative;
1550
2002
  /*
1551
- * ⚠️ THE MOST DELETABLE-LOOKING LINE IN THIS FILE. IT IS NOT DELETABLE.
2003
+ * ⚠️ BOTH, AND FOR TWO DIFFERENT REASONS. `isolate` keeps the grid's
2004
+ * `z-index: -1` inside the hero instead of dropping it behind the page;
2005
+ * `clip` holds the 150% rotated layer inside the header rather than letting
2006
+ * it run down the article. `clip` rather than `hidden` so no scroll
2007
+ * container is created and nothing sticky below is affected.
2008
+ */
2009
+ isolation: isolate;
2010
+ overflow: clip;
2011
+ /*
2012
+ * ⚠️ THE NEGATIVE MARGIN CANCELS `.wave-docs-layout__main`'s
2013
+ * `padding-block-start`, AND THE PADDING PUTS IT BACK INSIDE.
1552
2014
  *
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.
2015
+ * The grid is meant to reach the top of the article column. Left in the
2016
+ * flow the hero starts 2rem down measured at y=32 against a main at y=0 —
2017
+ * so the pattern began with a band of blank page above it. Full-bleed
2018
+ * upwards, and the space the header wants is its own padding rather than
2019
+ * its parent's.
1561
2020
  *
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.
2021
+ * Horizontally there is nothing to cancel: `__main` carries no inline
2022
+ * padding, so the hero already spans it exactly.
1565
2023
  */
1566
- dialog.wave-docs-layout__drawer:not(:modal) {
1567
- display: contents;
1568
- }
2024
+ margin-block-start: -2rem;
2025
+ margin-block-end: 3rem;
2026
+ padding-block: 5rem 3.5rem;
2027
+ }
1569
2028
 
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
- }
2029
+ /*
2030
+ * ⚠️ FOUR GRADIENTS, NOT AN SVG. The reference for this is a 100px SVG tile
2031
+ * of twenty stroked lines, inlined as a data URI. Drawn as gradients it is
2032
+ * the same picture with three properties that matter more than the fidelity:
2033
+ * not one byte of data URI in the stylesheet, the line colour is a token so
2034
+ * it follows the theme instead of being hardcoded black, and the tile is two
2035
+ * numbers rather than twenty coordinates.
2036
+ *
2037
+ * The grid is 20px with every fifth line emphasised, offset 15px so the
2038
+ * strong lines land at 15, 115, 215 — the tile boundary the SVG drew at 15.5
2039
+ * with a 1px stroke. Strong pairs are listed first because a background image
2040
+ * list paints first-on-top.
2041
+ */
2042
+ .wave-docs-hero::before {
2043
+ content: '';
2044
+ position: absolute;
2045
+ /*
2046
+ * Bigger than the box and offset by a quarter, so a 3° rotation cannot
2047
+ * expose a corner. The overflow is clipped by the header.
2048
+ */
2049
+ inset-block-start: -25%;
2050
+ inset-inline-start: -25%;
2051
+ width: 150%;
2052
+ height: 150%;
2053
+ /* Below the scrims, which are at -1. */
2054
+ z-index: -2;
2055
+ pointer-events: none;
2056
+ rotate: -3deg;
2057
+ background-image:
2058
+ repeating-linear-gradient(
2059
+ to right,
2060
+ transparent 0 15px,
2061
+ var(--wave-docs-hero-grid-strong) 15px 16px,
2062
+ transparent 16px 100px
2063
+ ),
2064
+ repeating-linear-gradient(
2065
+ to bottom,
2066
+ transparent 0 15px,
2067
+ var(--wave-docs-hero-grid-strong) 15px 16px,
2068
+ transparent 16px 100px
2069
+ ),
2070
+ repeating-linear-gradient(
2071
+ to right,
2072
+ transparent 0 15px,
2073
+ var(--wave-docs-hero-grid) 15px 16px,
2074
+ transparent 16px 20px
2075
+ ),
2076
+ repeating-linear-gradient(
2077
+ to bottom,
2078
+ transparent 0 15px,
2079
+ var(--wave-docs-hero-grid) 15px 16px,
2080
+ transparent 16px 20px
2081
+ );
1588
2082
  }
1589
2083
 
1590
- @media (min-width: 80rem) {
2084
+ /*
2085
+ * ⚠️ THREE SCRIMS IN THE PAGE'S OWN COLOUR, PAINTED OVER THE GRID — NOT A
2086
+ * MASK.
2087
+ *
2088
+ * Every earlier version of this masked the grid layer: two or three gradients
2089
+ * multiplied with `mask-composite: intersect`. It worked, and it was never
2090
+ * smooth. Mask layers multiply *alphas*, so the falloffs compound — two
2091
+ * gentle ramps crossing make a hard shoulder, and one arithmetic slip turns
2092
+ * the whole thing into a ring. Painting the page colour on top instead is
2093
+ * ordinary alpha compositing of a solid: the ramps blend rather than
2094
+ * multiply, and each one can be reasoned about on its own.
2095
+ *
2096
+ * It also means the percentages are of the header, not of a layer 150% its
2097
+ * size — which is what three passes went wrong on.
2098
+ *
2099
+ * A separate element from the grid because this one must not rotate. The
2100
+ * grid is at −3°; a scrim tilted with it would put its own edge on a slant.
2101
+ *
2102
+ * Painted first-on-top, so the order below reads outermost-last:
2103
+ *
2104
+ * 1. the reading scrim — a soft ellipse over the words
2105
+ * 2. the bottom fade — nothing at the top, solid before the prose
2106
+ *
2107
+ * The corner vignette is not among them: it is the `box-shadow` below,
2108
+ * because a gradient cannot be given a corner radius and this shape needs
2109
+ * one.
2110
+ */
2111
+ .wave-docs-hero::after {
2112
+ content: '';
2113
+ position: absolute;
2114
+ inset: 0;
2115
+ z-index: -1;
2116
+ pointer-events: none;
2117
+ background-image:
2118
+ radial-gradient(
2119
+ ellipse 52% 58% at 38% 48%,
2120
+ var(--wave-docs-bg) 0 25%,
2121
+ transparent 80%
2122
+ ),
2123
+ linear-gradient(to bottom, transparent 40%, var(--wave-docs-bg) 90%);
2124
+ /*
2125
+ * ⚠️ THE VIGNETTE IS A SHADOW, NOT A RADIAL — BECAUSE A RADIAL HAS NO
2126
+ * CORNER RADIUS.
2127
+ *
2128
+ * `radial-gradient` is only ever a circle or an ellipse: its shape is two
2129
+ * radii and nothing else, so an elliptical vignette pinches the middle of
2130
+ * every edge and opens the corners. An inset `box-shadow` with blur and
2131
+ * spread fades inward from a *rounded rectangle*, which is the shape this
2132
+ * wants — and `border-radius` is the knob the gradient never had.
2133
+ *
2134
+ * ⚠️ `inset: -4rem`, SO THE BOX'S OWN CORNERS FALL OUTSIDE THE HEADER. One
2135
+ * `border-radius` sets both the shape the shadow fades from and the shape
2136
+ * the box is clipped to; oversizing the box puts the clip out of sight and
2137
+ * leaves only the inner shape doing any work.
2138
+ *
2139
+ * `corner-shape` turns that rounded rectangle into a true squircle. It is
2140
+ * Chrome 139+ and degrades to an ordinary radius everywhere else, which is
2141
+ * the same picture with slightly rounder corners — a progressive
2142
+ * enhancement in the honest sense rather than a fallback nobody checked.
2143
+ */
2144
+ inset: -5rem;
2145
+ border-radius: 14rem;
2146
+ corner-shape: superellipse(3);
1591
2147
  /*
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.
2148
+ * ⚠️ THE SPREAD HAS TO CLEAR THE OVERHANG BEFORE IT DOES ANY WORK. The box
2149
+ * sits 5rem outside the header on every side, so a spread of 5rem is solid
2150
+ * only as far as the header's rim and the whole fade happens out of sight.
2151
+ * 9rem puts the solid band 4rem inside, and the 10rem blur carries it in
2152
+ * from there.
1596
2153
  */
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
- }
2154
+ box-shadow: inset 0 0 10rem 9rem var(--wave-docs-bg);
1603
2155
 
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
- }
2156
+ /*
2157
+ * ⚠️ EVERY PERCENTAGE ABOVE IS OF THIS LAYER, NOT OF THE HEADER — AND THE
2158
+ * LAYER IS HALF AS BIG AGAIN.
2159
+ *
2160
+ * The header occupies the middle third of it, from 33% to 67% on both axes.
2161
+ * Written as `125% 100%` the radial's edge landed a long way outside the
2162
+ * header, so what showed inside it was all near-centre: 15px of blank along
2163
+ * every edge before the pattern faded up, measured. `83%` and `67%` are
2164
+ * those numbers divided by the 1.5 — the same ellipse, expressed against
2165
+ * the box it is actually seen through.
2166
+ *
2167
+ * The two linear layers are the edge fade, and they are why the pattern
2168
+ * dissolves into the page on all four sides instead of being cut off by
2169
+ * `overflow: clip`. Each one is transparent exactly at the header's edge —
2170
+ * 33% and 67% of the layer — and opaque a few per cent inside it, so the
2171
+ * ramp happens within the header rather than outside where nothing is seen.
2172
+ * The vertical one is asymmetric on purpose: it holds full only to the
2173
+ * midpoint before falling away, so the grid has faded out well before the
2174
+ * prose begins.
2175
+ *
2176
+ * ⚠️ THE RADIAL AND THESE PULL IN OPPOSITE DIRECTIONS, WHICH IS THE POINT.
2177
+ * The radial is transparent at the centre and opaque outward; these are
2178
+ * opaque inside and transparent at the rim. Multiplied, what survives is a
2179
+ * band between the two — a halo around the words that fades at the page
2180
+ * and fades again where the reading starts.
2181
+ */
1616
2182
  }
1617
2183
 
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
- }
2184
+ /*
2185
+ * The heading, and it is the page's `<h1>` `render` stands its own down
2186
+ * when a page declares `actions`. Sized here rather than inherited from
2187
+ * `.wave-docs-prose h1`, because it is outside the prose.
2188
+ */
2189
+ /*
2190
+ * The words, aligned with the prose beneath them. The header itself spans the
2191
+ * whole article track so the grid behind it is full-bleed; this is what keeps
2192
+ * the title from sitting 70px left of the first paragraph.
2193
+ */
2194
+ .wave-docs-hero__body {
2195
+ max-width: var(--wave-docs-measure);
2196
+ margin-inline: auto;
1626
2197
  }
1627
2198
 
1628
- /* -------------------------------------------------------------------------
1629
- * Header
1630
- * ---------------------------------------------------------------------- */
2199
+ .wave-docs-hero__title {
2200
+ margin: 0;
2201
+ color: var(--wave-docs-fg);
2202
+ font-size: clamp(2.25rem, 1.5rem + 3vw, 3.5rem);
2203
+ font-weight: 700;
2204
+ line-height: 1.1;
2205
+ letter-spacing: -0.02em;
2206
+ text-wrap: balance;
2207
+ }
1631
2208
 
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);
2209
+ /* `frontmatter.description`, which is otherwise only a `<meta>` tag. */
2210
+ .wave-docs-hero__tagline {
2211
+ margin: 1rem 0 0;
2212
+ max-width: 34rem;
2213
+ color: var(--wave-docs-fg-muted);
2214
+ font-size: 1.125rem;
2215
+ line-height: 1.6;
2216
+ text-wrap: pretty;
1641
2217
  }
1642
2218
 
1643
- .wave-docs-layout__header-inner {
2219
+ .wave-docs-hero__actions {
1644
2220
  display: flex;
1645
- align-items: center;
2221
+ flex-wrap: wrap;
1646
2222
  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;
2223
+ margin-block-start: 2rem;
1654
2224
  }
1655
2225
 
1656
- @media (min-width: 30rem) {
1657
- .wave-docs-layout__header-inner {
1658
- padding-inline: 1.5rem;
1659
- }
2226
+ .wave-docs-hero__action {
2227
+ display: inline-flex;
2228
+ align-items: center;
2229
+ /* 44px tall: the same minimum the sidebar's trigger answers to. */
2230
+ min-height: 2.75rem;
2231
+ padding-inline: 1.25rem;
2232
+ border: 1px solid transparent;
2233
+ border-radius: var(--wave-docs-radius);
2234
+ font-size: 0.9375rem;
2235
+ font-weight: 600;
2236
+ text-decoration: none;
1660
2237
  }
1661
2238
 
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;
2239
+ .wave-docs-hero__action[data-variant='primary'] {
2240
+ background-color: var(--wave-docs-accent);
2241
+ color: var(--wave-docs-accent-fg);
1670
2242
  }
1671
2243
 
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;
2244
+ .wave-docs-hero__action[data-variant='primary']:hover {
2245
+ background-color: var(--wave-docs-accent-hover);
1677
2246
  }
1678
2247
 
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;
2248
+ /*
2249
+ * The tinted one. `accent-subtle` is the same surface the active sidebar link
2250
+ * sits on, and its 4.5:1 pair is `accent` — so this is a contrast ratio the
2251
+ * token test already checks rather than a new pairing to argue about.
2252
+ */
2253
+ .wave-docs-hero__action[data-variant='secondary'] {
2254
+ background-color: var(--wave-docs-accent-subtle);
2255
+ color: var(--wave-docs-accent);
1686
2256
  }
1687
2257
 
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);
2258
+ .wave-docs-hero__action[data-variant='secondary']:hover {
2259
+ border-color: var(--wave-docs-accent);
2260
+ }
2261
+
2262
+ .wave-docs-hero__action:focus-visible {
2263
+ outline: 2px solid var(--wave-docs-accent);
2264
+ outline-offset: 2px;
2265
+ }
2266
+
2267
+ @media (prefers-reduced-motion: no-preference) {
2268
+ .wave-docs-hero__action {
2269
+ transition:
2270
+ background-color 120ms ease-out,
2271
+ border-color 120ms ease-out;
2272
+ }
2273
+ }
2274
+
2275
+ /* -------------------------------------------------------------------------
2276
+ * The sidebar
2277
+ * ---------------------------------------------------------------------- */
2278
+
2279
+ /*
2280
+ * It paints nothing. The navigation carries the surface and the border, the
2281
+ * trigger carries neither — so there is no third surface to keep in step with
2282
+ * the other two, which is what made the strip read as filled every previous
2283
+ * time this was drawn.
2284
+ */
2285
+ .wave-docs-layout__sidebar {
2286
+ display: flex;
2287
+ /* No gap: the trigger sits directly against the navigation's border. */
2288
+ gap: 0;
2289
+ width: max-content;
2290
+ justify-self: start;
2291
+ /*
2292
+ * ⚠️ THE COLUMN IN THE PAGE, NOT THE PANEL THAT FOLLOWS THE VIEWPORT, AND
2293
+ * KEEPING THOSE TWO APART IS THE POINT.
2294
+ *
2295
+ * This box was `position: sticky` with `height: 100dvh` — sized against the
2296
+ * viewport while positioned against the layout, which is two reference
2297
+ * frames in one element. The moment a host put anything above it, the two
2298
+ * disagreed by exactly that much: with the UA's 8px of `body` margin the
2299
+ * divider stopped 8px short of the top and ran 8px past the bottom, and it
2300
+ * snapped into place as soon as the page scrolled and sticky engaged.
2301
+ * Measured.
2302
+ *
2303
+ * Stretched to the grid row, it is exactly as tall as the docs region —
2304
+ * starting where the host put it and ending where it ends, symmetric at any
2305
+ * offset, with no token to set and nothing to keep in sync. The scrolling
2306
+ * half is `position: sticky` on the two children, which is where a viewport
2307
+ * measurement belongs.
2308
+ */
2309
+ align-self: stretch;
2310
+ /* So a page shorter than a screen does not leave the sticky panel hanging
2311
+ * below the column that draws its edge. */
2312
+ min-height: calc(100dvh - var(--wave-docs-chrome-offset));
2313
+ position: relative;
2314
+ /* Above the scrim, which is above the article. */
2315
+ z-index: 2;
1701
2316
  background: none;
1702
- color: var(--wave-docs-fg-muted);
1703
- cursor: pointer;
2317
+ border: 0;
2318
+ /*
2319
+ * ⚠️ THE BOX IS ALWAYS `max-content`; ONLY ITS CONTRIBUTION MOVES.
2320
+ *
2321
+ * This one declaration is the whole of "the article does not get out of the
2322
+ * way" — closed at any width, and open in cover mode. The box still paints
2323
+ * its full width, so in cover mode it overlays; it just does not ask the
2324
+ * grid for the room. Push mode drops it, and the same box takes its own
2325
+ * space with nothing else changing.
2326
+ */
2327
+ margin-inline-end: calc(-1 * var(--wave-docs-sidebar-width));
2328
+ /*
2329
+ * Exactly the navigation's width, so it goes entirely off the page and the
2330
+ * trigger's outer edge lands on the inline start edge — whatever the strip
2331
+ * happens to be.
2332
+ *
2333
+ * ⚠️ THE SAME TOKEN AS THE MARGIN ABOVE, AND DELIBERATELY. It was
2334
+ * `calc(var(--wave-docs-trigger-width) - 100%)` — "minus all of me, plus the
2335
+ * trigger back" — which avoided naming the navigation but only worked while
2336
+ * the strip had a width to name. The strip is `auto` now, so this is both
2337
+ * shorter and the only spelling that cannot drift: the two declarations
2338
+ * that move the sidebar read the same token.
2339
+ */
2340
+ translate: calc(-1 * var(--wave-docs-sidebar-width));
1704
2341
  }
1705
2342
 
1706
- .wave-docs-layout__nav-trigger:hover {
1707
- background-color: var(--wave-docs-bg-subtle);
1708
- color: var(--wave-docs-fg);
2343
+ /*
2344
+ * ⚠️ THREE STATES, NOT TWO, AND THE THIRD ONE IS WHY THERE IS NO FLASH.
2345
+ *
2346
+ * `data-state` absent means "nobody has chosen yet" — the server renders
2347
+ * that, and CSS decides per mode: closed where the navigation would cover the
2348
+ * article, open where it would sit beside it. So the first paint is already
2349
+ * right at both shapes, with no JavaScript and nothing to correct. Once the
2350
+ * reader presses the trigger their choice is explicit and wins at every
2351
+ * width.
2352
+ */
2353
+ .wave-docs-layout__sidebar[data-state='open'] {
2354
+ translate: 0;
2355
+ }
2356
+
2357
+ /*
2358
+ * ⚠️ NOT UNTIL THE SECOND FRAME. A transition declared at first paint
2359
+ * animates the resolved state *in* from wherever the initial one was — the
2360
+ * navigation visibly slides across on every page load. `data-ready` is set
2361
+ * after mount, so the first paint is a jump nobody sees and every later
2362
+ * change is animated.
2363
+ */
2364
+ @media (prefers-reduced-motion: no-preference) {
2365
+ .wave-docs-layout__sidebar[data-ready] {
2366
+ transition:
2367
+ translate 200ms ease,
2368
+ margin-inline-end 200ms ease;
2369
+ }
1709
2370
  }
1710
2371
 
1711
2372
  /* -------------------------------------------------------------------------
1712
- * Drawer
2373
+ * The navigation
1713
2374
  * ---------------------------------------------------------------------- */
1714
2375
 
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;
2376
+ /*
2377
+ * The only painted surface in the sidebar, and it has to be opaque: in cover
2378
+ * mode it sits on top of the article, and a translucent one would put two
2379
+ * columns of text through each other.
2380
+ */
2381
+ .wave-docs-layout__sidebar-nav {
2382
+ flex: none;
1720
2383
  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;
2384
+ width: var(--wave-docs-sidebar-width);
2385
+ padding: 2rem 1rem;
1726
2386
  border: 0;
2387
+ /*
2388
+ * Cover mode only, and it is a different thing from the divider in push
2389
+ * mode: there the line divides the page, here it is the edge of a panel
2390
+ * lying on top of one. A panel's edge is the panel's height, so this one
2391
+ * belongs on the panel.
2392
+ */
1727
2393
  border-inline-end: 1px solid var(--wave-docs-border);
1728
2394
  background-color: var(--wave-docs-bg);
1729
- color: var(--wave-docs-fg);
2395
+ /* The panel that follows the viewport: sticky, one screen tall, and its own
2396
+ * scroller. `dvh`, not `vh` — with a mobile URL bar showing, `vh` is taller
2397
+ * than the visible viewport and the last few nav items cannot be reached. */
2398
+ position: sticky;
2399
+ top: var(--wave-docs-chrome-offset);
2400
+ height: calc(100dvh - var(--wave-docs-chrome-offset));
1730
2401
  overflow-y: auto;
1731
- /* Reaching the end of the nav must not start scrolling the page behind. */
2402
+ /* Without this, reaching the end of the nav keeps scrolling the article
2403
+ * jumps under the reader's cursor while they are looking at the sidebar. */
1732
2404
  overscroll-behavior: contain;
2405
+ /* Reserves the scrollbar's width whether or not it is showing, so the
2406
+ * column does not jump when the nav grows past a screen. */
2407
+ scrollbar-gutter: stable;
1733
2408
  }
1734
2409
 
1735
- dialog.wave-docs-layout__drawer::backdrop {
1736
- background-color: oklch(0.2 0.02 262 / 0.55);
2410
+ /* First child of the navigation, above the tree. */
2411
+ .wave-docs-layout__search {
2412
+ margin-block-end: 1rem;
1737
2413
  }
1738
2414
 
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;
2415
+ /* -------------------------------------------------------------------------
2416
+ * The trigger
2417
+ * ---------------------------------------------------------------------- */
2418
+
2419
+ /*
2420
+ * ⚠️ NO BACKGROUND AND NO BORDER, IN ANY STATE. 44px wide because that is
2421
+ * Apple's minimum tap target and the strip *is* the target — it carried a
2422
+ * 20px transparent border for a while to answer to 44 while painting 24, and
2423
+ * a box the eye cannot see is a box the next person deletes.
2424
+ */
2425
+ .wave-docs-layout__sidebar-trigger {
2426
+ flex: none;
2427
+ /*
2428
+ * Sticky for the same reason the navigation is: the column around it is the
2429
+ * page's height now, and a control centred in *that* would sit halfway down
2430
+ * a long document instead of halfway down the screen.
2431
+ *
2432
+ * ⚠️ AND IT IS ALSO WHAT POSITIONS THE BUTTON. The button and the dots are
2433
+ * `position: absolute`, and an absolutely positioned box resolves against
2434
+ * the nearest *positioned* ancestor — `sticky` qualifies, `static` does not.
2435
+ * Without a positioned ancestor here they escape to the layout and paint a
2436
+ * slab across the page. Measured.
2437
+ */
2438
+ position: sticky;
2439
+ top: var(--wave-docs-chrome-offset);
2440
+ height: calc(100dvh - var(--wave-docs-chrome-offset));
2441
+ /*
2442
+ * ⚠️ `auto`, SO THE TARGET GROWS WITH THE BUTTON. The strip was a fixed
2443
+ * 2.75rem and the button was positioned inside it, which made the gap
2444
+ * between them a third number nobody set: 24px of button left 10px a side,
2445
+ * 16px left 14px. Sized by its content it is always the button plus 4px,
2446
+ * and `--wave-docs-trigger-width` is the only thing to change.
2447
+ */
2448
+ display: grid;
2449
+ place-items: center;
2450
+ width: auto;
2451
+ padding: 4px;
1747
2452
  border: 0;
1748
- border-radius: var(--wave-docs-radius-sm);
1749
2453
  background: none;
1750
2454
  color: var(--wave-docs-fg-muted);
1751
2455
  cursor: pointer;
1752
2456
  }
1753
2457
 
1754
- .wave-docs-layout__drawer-close:hover {
1755
- background-color: var(--wave-docs-bg-subtle);
1756
- color: var(--wave-docs-fg);
2458
+ /*
2459
+ * The button: `--wave-docs-trigger-width` × 80, and grey until hovered.
2460
+ *
2461
+ * ⚠️ IN FLOW, WHICH IS WHAT MAKES THE STRIP SIZE ITSELF. Absolutely
2462
+ * positioned it contributed nothing, so the strip needed its own width and
2463
+ * the gap between the two was whatever the subtraction happened to be. This
2464
+ * is the only in-flow child, so the strip is exactly this plus its 4px of
2465
+ * padding — one number, and the target follows the paint.
2466
+ *
2467
+ * `place-items: center` on the strip does the centring the insets used to.
2468
+ */
2469
+ .wave-docs-layout__sidebar-trigger::before {
2470
+ content: '';
2471
+ width: var(--wave-docs-trigger-width);
2472
+ height: 5rem;
2473
+ border-radius: var(--wave-docs-radius-sm);
2474
+ background-color: var(--wave-docs-border-strong);
1757
2475
  }
1758
2476
 
1759
2477
  /*
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.
2478
+ * Three dots. Measured off docsify.js.org rather than reasoned about: three
2479
+ * 4px circles 3px apart. One element and two `box-shadow` copies rather than
2480
+ * three spans no markup, and it follows `currentcolor` through the hover
2481
+ * state for free. 4 + 3 + 4 + 3 + 4 is 18px tall, so the offset is 7.
2482
+ *
2483
+ * ⚠️ `::after`, AND THE ORDER IS LOAD-BEARING. Neither pseudo-element carries
2484
+ * a `z-index`, so tree order decides which paints on top.
1763
2485
  */
1764
- html:has(dialog.wave-docs-layout__drawer:modal) {
1765
- overflow: hidden;
2486
+ .wave-docs-layout__sidebar-trigger::after {
2487
+ content: '';
2488
+ position: absolute;
2489
+ inset-block-start: 50%;
2490
+ inset-inline-start: 50%;
2491
+ width: 4px;
2492
+ height: 4px;
2493
+ translate: -50% -50%;
2494
+ border-radius: 50%;
2495
+ background: currentcolor;
2496
+ box-shadow:
2497
+ 0 -7px 0 currentcolor,
2498
+ 0 7px 0 currentcolor;
1766
2499
  }
1767
2500
 
1768
2501
  /*
1769
- * Drawer furniture, hidden once the drawer is a column.
2502
+ * ⚠️ THE STRIP'S HOVER LIGHTS THE BUTTON TOO, AND THAT IS WHAT MAKES IT ONE
2503
+ * CONTROL. Hovering 44px of strip while the button inside it stays grey reads
2504
+ * as a dead zone around a small target; Docsify pairs them for the same
2505
+ * reason.
2506
+ */
2507
+ .wave-docs-layout__sidebar-trigger:hover {
2508
+ background-color: color-mix(
2509
+ in oklab,
2510
+ var(--wave-docs-accent) 30%,
2511
+ transparent
2512
+ );
2513
+ color: var(--wave-docs-accent-fg);
2514
+ }
2515
+
2516
+ .wave-docs-layout__sidebar-trigger:hover::before {
2517
+ background-color: var(--wave-docs-accent);
2518
+ }
2519
+
2520
+ @media (prefers-reduced-motion: no-preference) {
2521
+ .wave-docs-layout__sidebar-trigger,
2522
+ .wave-docs-layout__sidebar-trigger::before,
2523
+ .wave-docs-layout__sidebar-trigger::after {
2524
+ /*
2525
+ * ⚠️ ON THE PSEUDO-ELEMENTS TOO, NOT ONLY THE BUTTON. The dots are
2526
+ * `background: currentcolor`, and an inherited colour changing does not
2527
+ * start a transition on a descendant that has not declared one — the
2528
+ * button would fade and the dots would snap.
2529
+ */
2530
+ transition:
2531
+ background-color 120ms ease-out,
2532
+ color 120ms ease-out;
2533
+ }
2534
+ }
2535
+
2536
+ /*
2537
+ * ⚠️ AN OUTLINE, NOT THE FILL. These shared the hover rule for one commit,
2538
+ * and `focus-indicators` caught it: a background change is the one thing
2539
+ * forced-colours mode discards, so a keyboard reader there would have had no
2540
+ * indicator at all.
2541
+ */
2542
+ .wave-docs-layout__sidebar-trigger:focus-visible {
2543
+ outline: 2px solid var(--wave-docs-accent);
2544
+ outline-offset: -2px;
2545
+ }
2546
+
2547
+ /* -------------------------------------------------------------------------
2548
+ * The scrim
2549
+ *
2550
+ * Cover mode only. `inert` keeps the keyboard out of what is underneath; this
2551
+ * is what tells everyone else.
2552
+ * ---------------------------------------------------------------------- */
2553
+
2554
+ .wave-docs-layout__sidebar-scrim {
2555
+ /* Absolute inside the layout rather than fixed to the viewport: a fixed
2556
+ * scrim would darken a host's own chrome along with our article. */
2557
+ position: absolute;
2558
+ inset: 0;
2559
+ z-index: 1;
2560
+ border: 0;
2561
+ padding: 0;
2562
+ background-color: oklch(0.2 0.02 262 / 0.55);
2563
+ opacity: 0;
2564
+ pointer-events: none;
2565
+ }
2566
+
2567
+ .wave-docs-layout__sidebar[data-state='open']
2568
+ ~ .wave-docs-layout__sidebar-scrim {
2569
+ opacity: 1;
2570
+ pointer-events: auto;
2571
+ }
2572
+
2573
+ @media (prefers-reduced-motion: no-preference) {
2574
+ .wave-docs-layout__sidebar-scrim {
2575
+ transition: opacity 200ms ease;
2576
+ }
2577
+ }
2578
+
2579
+ /* -------------------------------------------------------------------------
2580
+ * Cover and push
1770
2581
  *
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`.
2582
+ * The whole of what differs between a narrow container and a wide one. Same
2583
+ * markup, same classes, same control, same translate only whether the
2584
+ * article gets out of the way.
2585
+ * ---------------------------------------------------------------------- */
2586
+
2587
+ /*
2588
+ * ⚠️ A CUSTOM PROPERTY, BECAUSE JAVASCRIPT CANNOT ASK A CONTAINER QUERY.
2589
+ * `matchMedia` answers about the viewport, which is the question this file
2590
+ * deliberately stopped asking — so the mode is declared here and read back
2591
+ * with `getComputedStyle`. One source of truth, and the breakpoint appears
2592
+ * nowhere in the component.
1776
2593
  */
1777
- @media (min-width: 64rem) {
1778
- .wave-docs-layout__nav-trigger,
1779
- .wave-docs-layout__drawer-close {
2594
+ .wave-docs-layout__sidebar {
2595
+ --wave-docs-sidebar-mode: cover;
2596
+ }
2597
+
2598
+ @container wave-docs (min-width: 64rem) {
2599
+ .wave-docs-layout__sidebar {
2600
+ --wave-docs-sidebar-mode: push;
2601
+ }
2602
+
2603
+ /*
2604
+ * ⚠️ THE DIVISION IS THE PAGE'S, SO IT IS DRAWN BY THE PAGE'S COLUMN.
2605
+ *
2606
+ * In push mode the sidebar is part of the layout and this line is what
2607
+ * separates navigation from article — so it runs the full height of the
2608
+ * docs region, top to bottom, wherever the host placed it. Drawn on the
2609
+ * navigation instead it would be one screen tall, which is only the same
2610
+ * thing when the layout happens to start at the viewport's top edge.
2611
+ *
2612
+ * `inset-inline-start` at the navigation's width rather than a border on
2613
+ * the shell: the shell's own inline end is past the trigger, and the
2614
+ * division belongs between the navigation and the article.
2615
+ */
2616
+ .wave-docs-layout__sidebar::after {
2617
+ content: '';
2618
+ position: absolute;
2619
+ inset-block: 0;
2620
+ inset-inline-start: var(--wave-docs-sidebar-width);
2621
+ width: 1px;
2622
+ background-color: var(--wave-docs-border);
2623
+ }
2624
+
2625
+ /*
2626
+ * Unchosen means open up here, where there is room for the navigation
2627
+ * beside the article rather than on top of it — and open in push mode is
2628
+ * the one combination that asks the grid for its own space.
2629
+ *
2630
+ * ⚠️ KEYED ON THE STATE, NOT THE MODE. Dropping the margin for the whole of
2631
+ * push mode leaves a 300px hole in the grid whenever the sidebar is closed:
2632
+ * the box is translated off the page and the track is still holding room
2633
+ * for it.
2634
+ */
2635
+ .wave-docs-layout__sidebar:not([data-state='closed']) {
2636
+ translate: 0;
2637
+ margin-inline-end: 0;
2638
+ }
2639
+
2640
+ /* Nothing is covered, so nothing is scrimmed. */
2641
+ .wave-docs-layout__sidebar-scrim {
1780
2642
  display: none;
1781
2643
  }
2644
+
2645
+ /* And the panel's edge gives way to the page's division above. */
2646
+ .wave-docs-layout__sidebar-nav {
2647
+ border-inline-end: 0;
2648
+ }
2649
+
2650
+ .wave-docs-layout {
2651
+ gap: 0 1.5rem;
2652
+ padding-inline: 0 1.5rem;
2653
+ }
2654
+ }
2655
+
2656
+ @container wave-docs (min-width: 80rem) {
2657
+ .wave-docs-layout:has(.wave-docs-layout__toc) {
2658
+ grid-template-columns: auto minmax(0, 1fr) var(--wave-docs-toc-width);
2659
+ }
2660
+
2661
+ .wave-docs-layout__toc {
2662
+ display: block;
2663
+ /* Same reason as the article: its contents cannot be weighed against the
2664
+ * scrim, only the column as a whole. */
2665
+ isolation: isolate;
2666
+ position: sticky;
2667
+ /* `align-self: start`, or the sticky box stretches to the row height and
2668
+ * has nothing left to travel within. */
2669
+ align-self: start;
2670
+ top: var(--wave-docs-chrome-offset);
2671
+ max-height: calc(100dvh - var(--wave-docs-chrome-offset));
2672
+ overflow-y: auto;
2673
+ overscroll-behavior: contain;
2674
+ padding-block: 2rem;
2675
+ }
1782
2676
  }
1783
2677
  }
1784
2678
 
@@ -1809,6 +2703,25 @@
1809
2703
  cursor: pointer;
1810
2704
  }
1811
2705
 
2706
+ /*
2707
+ * In the shell, the trigger fills whatever it is placed in — the width of the
2708
+ * strip below 64rem, the width of the column above it.
2709
+ *
2710
+ * ⚠️ THIS USED TO BE A THREE-STEP `max-width` LADDER (9rem, 11rem at 30rem,
2711
+ * 20rem at 48rem) AND EVERY NUMBER IN IT WAS HEADER ARITHMETIC. The trigger
2712
+ * is `width: 100%`, so in a flex row its base was the whole row; the ladder
2713
+ * existed to stop it starving the brand beside it, and at 390px it had
2714
+ * ellipsised "Wave Docs" to "Wa…". With no header there is no row to share
2715
+ * and no brand to leave room for, so the container decides.
2716
+ *
2717
+ * The bare `.wave-docs-search-trigger` keeps its own `max-width: 20rem` for
2718
+ * the standalone case — a host rendering `DocsSearch` in their own header,
2719
+ * where something else *is* competing for the row.
2720
+ */
2721
+ .wave-docs-search-trigger.wave-docs-layout__search {
2722
+ max-width: none;
2723
+ }
2724
+
1812
2725
  .wave-docs-search-trigger:hover {
1813
2726
  border-color: var(--wave-docs-border-strong);
1814
2727
  color: var(--wave-docs-fg);
@@ -1828,6 +2741,18 @@
1828
2741
  white-space: nowrap;
1829
2742
  }
1830
2743
 
2744
+ /*
2745
+ * ⚠️ HIDDEN WHERE THE SHORTCUT CANNOT BE PRESSED, WHICH IS NOT A WIDTH
2746
+ * QUESTION. `⌘K` is an instruction, and on a phone or a tablet it is one the
2747
+ * reader cannot follow — a 10-inch tablet at 1024px would keep it under any
2748
+ * breakpoint rule.
2749
+ */
2750
+ @media (hover: none) and (pointer: coarse) {
2751
+ .wave-docs-search-trigger-kbd {
2752
+ display: none;
2753
+ }
2754
+ }
2755
+
1831
2756
  .wave-docs-search-trigger-kbd {
1832
2757
  flex: none;
1833
2758
  padding: 0.05rem 0.35rem;