@waveso/docs 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/styles.css CHANGED
@@ -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
  /*
@@ -1436,6 +1733,18 @@
1436
1733
  color: var(--wave-docs-fg-subtle);
1437
1734
  }
1438
1735
 
1736
+ .wave-docs-toc__title {
1737
+ margin: 0 0 0.5rem;
1738
+ /* Aligns with the link text below it. The links carry a 2px rail on their
1739
+ * inline start and 0.75rem of padding inside it; this has no rail, so it
1740
+ * takes the rail's width as padding instead. */
1741
+ padding-inline-start: calc(0.75rem + 2px);
1742
+ color: var(--wave-docs-fg);
1743
+ font-size: 0.8125rem;
1744
+ font-weight: 600;
1745
+ letter-spacing: 0.01em;
1746
+ }
1747
+
1439
1748
  .wave-docs-toc__link:hover {
1440
1749
  color: var(--wave-docs-fg);
1441
1750
  border-inline-start-color: var(--wave-docs-border-strong);
@@ -1457,8 +1766,8 @@
1457
1766
  /* ---------------------------------------------------------------------------
1458
1767
  * Layout
1459
1768
  *
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.
1769
+ * The shell `docs.Layout` renders. Every class here is public API from 0.3.0
1770
+ * and changes only in a release that carries the migration.
1462
1771
  *
1463
1772
  * These are the first width-based media queries in this file. Before them there
1464
1773
  * were none at all — every `@media` was `prefers-color-scheme`,
@@ -1468,45 +1777,93 @@
1468
1777
 
1469
1778
  @layer components {
1470
1779
  /*
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.
1780
+ * ONE SIDEBAR, AT EVERY WIDTH, AND IT IS A SHELL OF TWO.
1781
+ *
1782
+ * .wave-docs-shell the query container
1783
+ * └─ .wave-docs-layout the grid
1784
+ * ├─ .wave-docs-layout__sidebar paints nothing, and moves
1785
+ * │ ├─ …__sidebar-nav the surface, and the one border
1786
+ * │ └─ …__sidebar-trigger the strip — paints nothing at rest
1787
+ * ├─ .wave-docs-layout__sidebar-scrim
1788
+ * ├─ .wave-docs-layout__main
1789
+ * └─ .wave-docs-layout__toc
1790
+ *
1791
+ * There is no drawer, no dialog, no second control and no second copy of the
1792
+ * tree. Pressing the trigger translates the sidebar; the trigger rides on the
1793
+ * navigation's outer edge because it is the next flex item, not because a
1794
+ * number says so.
1795
+ *
1796
+ * ⚠️ `@container`, AND EVERY WIDTH-BASED `@media` IS GONE WITH IT.
1797
+ *
1798
+ * This package is mounted at `/docs` inside applications that own the rest of
1799
+ * the page. `@media` asks how wide the *screen* is, which is the wrong
1800
+ * question: a host who puts this in a 700px panel on a 1920px monitor gets
1801
+ * the wide layout and a reading column of about 60px. That is the same
1802
+ * failure as the fixed header — a collision arriving from a direction no
1803
+ * breakpoint can see. `@container` asks how wide the box we were *given* is,
1804
+ * which is the question with an answer.
1805
+ *
1806
+ * ⚠️ AND THAT IS WHY THERE IS AN OUTER ELEMENT. A container query never
1807
+ * matches its own container, so with `container-type` on the grid, no rule
1808
+ * inside a query could touch `grid-template-columns` — which is exactly the
1809
+ * declaration the layout has to change. One wrapper, and every rule below is
1810
+ * addressable.
1473
1811
  */
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
1812
  /*
1495
- * ⚠️ `contents`, NOT `none`, AND THAT IS NOT A STYLE PREFERENCE.
1813
+ * ⚠️ NO `max-width` AND NO `margin-inline: auto` HERE, AND THAT IS A FIX
1814
+ * RATHER THAN AN OMISSION.
1496
1815
  *
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.
1816
+ * This box was capped at `--wave-docs-shell-width` and centred. Below the cap
1817
+ * the layout's inline start edge and the viewport's are the same place, so
1818
+ * `translate: calc(var(--wave-docs-trigger-width) - 100%)` put the closed
1819
+ * navigation off the screen. Above it they are not: on a 2000px viewport the
1820
+ * shell starts at 200, and the closed navigation parked at −56…200 two
1821
+ * hundred pixels of it still on screen, beside a trigger sitting 200px in
1822
+ * from the edge. Measured.
1503
1823
  *
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 }`.
1824
+ * The sidebar owns the page's inline start edge at every width now, so
1825
+ * "closed" means off the screen by construction rather than by coincidence.
1826
+ * What was capped is capped where it belongs: `.wave-docs-prose` holds
1827
+ * `--wave-docs-measure` and centres itself in the track, so a 2560px display
1828
+ * gets a reading column between its navigation and its table of contents
1829
+ * instead of text pinned against one of them.
1507
1830
  */
1508
- .wave-docs-layout__sidebar {
1509
- display: contents;
1831
+ .wave-docs-shell {
1832
+ container-type: inline-size;
1833
+ container-name: wave-docs;
1834
+ }
1835
+
1836
+ .wave-docs-layout {
1837
+ /* Absolutely positioned children of ours resolve against this, so nothing
1838
+ * of ours resolves against the viewport and nothing of ours can land on a
1839
+ * host's chrome. */
1840
+ position: relative;
1841
+ display: grid;
1842
+ /*
1843
+ * ⚠️ `auto`, AND THE SIDEBAR DECIDES WHAT IT MEANS. The track takes the
1844
+ * sidebar's *outer* size, and the sidebar sets that with a negative margin:
1845
+ * 44px whenever the navigation is off the page or covering the article,
1846
+ * its full width only when it is meant to sit beside it. So one track
1847
+ * expresses both modes and both states, and the layout never has to know
1848
+ * which it is in.
1849
+ *
1850
+ * A fixed `var(--wave-docs-trigger-width)` here was the first attempt, and
1851
+ * it could not grow — push mode silently behaved exactly like cover mode.
1852
+ * Measured at 1440px: the article started at 68px in both states.
1853
+ *
1854
+ * In cover mode the article's measure is untouched by the toggle, because
1855
+ * the track never changes: 330px at 390px whether the navigation is open or
1856
+ * shut. In push mode it necessarily changes — that is what push means, and
1857
+ * it is why cover exists for the widths where 330px is all there is.
1858
+ */
1859
+ grid-template-columns: auto minmax(0, 1fr);
1860
+ gap: 0 0.5rem;
1861
+ /* Nothing on the inline start: the trigger's track owns that edge. */
1862
+ padding-inline: 0 0.5rem;
1863
+ /* Without this, `max-width` at the cap applies to the *content* box and the
1864
+ * shell renders 48px wider than the token says — measured 1648 against a
1865
+ * declared 1600. */
1866
+ box-sizing: border-box;
1510
1867
  }
1511
1868
 
1512
1869
  .wave-docs-layout__toc {
@@ -1526,259 +1883,713 @@
1526
1883
  * and removing `min-width: 0` alone passes; only removing both reproduces the
1527
1884
  * overflow. They are kept as a pair because they fail in opposite directions
1528
1885
  * — 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.
1886
+ * and `min-width: 0` protects a track someone respells.
1536
1887
  */
1537
1888
  .wave-docs-layout__main {
1538
1889
  min-width: 0;
1539
1890
  padding-block: 2rem 4rem;
1891
+ /*
1892
+ * ⚠️ EVERY `z-index` IN THE ARTICLE IS TRAPPED HERE, AND ONE OF THEM WAS
1893
+ * PUNCHING THROUGH THE SCRIM.
1894
+ *
1895
+ * A sticky table header is `z-index: 1`, and so is the scrim. Same stacking
1896
+ * context, equal z-index, and the header comes later in the document — so
1897
+ * with the navigation open over a table on a phone, the header row was the
1898
+ * one thing on the page that stayed bright. Measured on the installation
1899
+ * page.
1900
+ *
1901
+ * Raising the scrim would have fixed that one element and left the next
1902
+ * one. `isolation: isolate` makes the article its own stacking context, so
1903
+ * nothing inside it — ours or a consumer's own content — can be compared
1904
+ * against the scrim at all. The article resolves as a whole, at one layer,
1905
+ * underneath.
1906
+ */
1907
+ isolation: isolate;
1540
1908
  }
1541
1909
 
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
- }
1910
+ /* -------------------------------------------------------------------------
1911
+ * The hero
1912
+ *
1913
+ * A page header a landing page opts into by declaring `actions` in its
1914
+ * frontmatter. Every other page renders none of this.
1915
+ * ---------------------------------------------------------------------- */
1549
1916
 
1917
+ .wave-docs-hero {
1918
+ position: relative;
1550
1919
  /*
1551
- * ⚠️ THE MOST DELETABLE-LOOKING LINE IN THIS FILE. IT IS NOT DELETABLE.
1920
+ * ⚠️ BOTH, AND FOR TWO DIFFERENT REASONS. `isolate` keeps the grid's
1921
+ * `z-index: -1` inside the hero instead of dropping it behind the page;
1922
+ * `clip` holds the 150% rotated layer inside the header rather than letting
1923
+ * it run down the article. `clip` rather than `hidden` so no scroll
1924
+ * container is created and nothing sticky below is affected.
1925
+ */
1926
+ isolation: isolate;
1927
+ overflow: clip;
1928
+ /*
1929
+ * ⚠️ THE NEGATIVE MARGIN CANCELS `.wave-docs-layout__main`'s
1930
+ * `padding-block-start`, AND THE PADDING PUTS IT BACK INSIDE.
1552
1931
  *
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.
1932
+ * The grid is meant to reach the top of the article column. Left in the
1933
+ * flow the hero starts 2rem down measured at y=32 against a main at y=0 —
1934
+ * so the pattern began with a band of blank page above it. Full-bleed
1935
+ * upwards, and the space the header wants is its own padding rather than
1936
+ * its parent's.
1561
1937
  *
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.
1938
+ * Horizontally there is nothing to cancel: `__main` carries no inline
1939
+ * padding, so the hero already spans it exactly.
1565
1940
  */
1566
- dialog.wave-docs-layout__drawer:not(:modal) {
1567
- display: contents;
1568
- }
1941
+ margin-block-start: -2rem;
1942
+ margin-block-end: 3rem;
1943
+ padding-block: 5rem 3.5rem;
1944
+ }
1569
1945
 
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
- }
1946
+ /*
1947
+ * ⚠️ FOUR GRADIENTS, NOT AN SVG. The reference for this is a 100px SVG tile
1948
+ * of twenty stroked lines, inlined as a data URI. Drawn as gradients it is
1949
+ * the same picture with three properties that matter more than the fidelity:
1950
+ * not one byte of data URI in the stylesheet, the line colour is a token so
1951
+ * it follows the theme instead of being hardcoded black, and the tile is two
1952
+ * numbers rather than twenty coordinates.
1953
+ *
1954
+ * The grid is 20px with every fifth line emphasised, offset 15px so the
1955
+ * strong lines land at 15, 115, 215 — the tile boundary the SVG drew at 15.5
1956
+ * with a 1px stroke. Strong pairs are listed first because a background image
1957
+ * list paints first-on-top.
1958
+ */
1959
+ .wave-docs-hero::before {
1960
+ content: '';
1961
+ position: absolute;
1962
+ /*
1963
+ * Bigger than the box and offset by a quarter, so a 3° rotation cannot
1964
+ * expose a corner. The overflow is clipped by the header.
1965
+ */
1966
+ inset-block-start: -25%;
1967
+ inset-inline-start: -25%;
1968
+ width: 150%;
1969
+ height: 150%;
1970
+ /* Below the scrims, which are at -1. */
1971
+ z-index: -2;
1972
+ pointer-events: none;
1973
+ rotate: -3deg;
1974
+ background-image:
1975
+ repeating-linear-gradient(
1976
+ to right,
1977
+ transparent 0 15px,
1978
+ var(--wave-docs-hero-grid-strong) 15px 16px,
1979
+ transparent 16px 100px
1980
+ ),
1981
+ repeating-linear-gradient(
1982
+ to bottom,
1983
+ transparent 0 15px,
1984
+ var(--wave-docs-hero-grid-strong) 15px 16px,
1985
+ transparent 16px 100px
1986
+ ),
1987
+ repeating-linear-gradient(
1988
+ to right,
1989
+ transparent 0 15px,
1990
+ var(--wave-docs-hero-grid) 15px 16px,
1991
+ transparent 16px 20px
1992
+ ),
1993
+ repeating-linear-gradient(
1994
+ to bottom,
1995
+ transparent 0 15px,
1996
+ var(--wave-docs-hero-grid) 15px 16px,
1997
+ transparent 16px 20px
1998
+ );
1588
1999
  }
1589
2000
 
1590
- @media (min-width: 80rem) {
2001
+ /*
2002
+ * ⚠️ THREE SCRIMS IN THE PAGE'S OWN COLOUR, PAINTED OVER THE GRID — NOT A
2003
+ * MASK.
2004
+ *
2005
+ * Every earlier version of this masked the grid layer: two or three gradients
2006
+ * multiplied with `mask-composite: intersect`. It worked, and it was never
2007
+ * smooth. Mask layers multiply *alphas*, so the falloffs compound — two
2008
+ * gentle ramps crossing make a hard shoulder, and one arithmetic slip turns
2009
+ * the whole thing into a ring. Painting the page colour on top instead is
2010
+ * ordinary alpha compositing of a solid: the ramps blend rather than
2011
+ * multiply, and each one can be reasoned about on its own.
2012
+ *
2013
+ * It also means the percentages are of the header, not of a layer 150% its
2014
+ * size — which is what three passes went wrong on.
2015
+ *
2016
+ * A separate element from the grid because this one must not rotate. The
2017
+ * grid is at −3°; a scrim tilted with it would put its own edge on a slant.
2018
+ *
2019
+ * Painted first-on-top, so the order below reads outermost-last:
2020
+ *
2021
+ * 1. the reading scrim — a soft ellipse over the words
2022
+ * 2. the bottom fade — nothing at the top, solid before the prose
2023
+ *
2024
+ * The corner vignette is not among them: it is the `box-shadow` below,
2025
+ * because a gradient cannot be given a corner radius and this shape needs
2026
+ * one.
2027
+ */
2028
+ .wave-docs-hero::after {
2029
+ content: '';
2030
+ position: absolute;
2031
+ inset: 0;
2032
+ z-index: -1;
2033
+ pointer-events: none;
2034
+ background-image:
2035
+ radial-gradient(
2036
+ ellipse 52% 58% at 38% 48%,
2037
+ var(--wave-docs-bg) 0 25%,
2038
+ transparent 80%
2039
+ ),
2040
+ linear-gradient(to bottom, transparent 40%, var(--wave-docs-bg) 90%);
1591
2041
  /*
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.
2042
+ * ⚠️ THE VIGNETTE IS A SHADOW, NOT A RADIAL BECAUSE A RADIAL HAS NO
2043
+ * CORNER RADIUS.
2044
+ *
2045
+ * `radial-gradient` is only ever a circle or an ellipse: its shape is two
2046
+ * radii and nothing else, so an elliptical vignette pinches the middle of
2047
+ * every edge and opens the corners. An inset `box-shadow` with blur and
2048
+ * spread fades inward from a *rounded rectangle*, which is the shape this
2049
+ * wants — and `border-radius` is the knob the gradient never had.
2050
+ *
2051
+ * ⚠️ `inset: -4rem`, SO THE BOX'S OWN CORNERS FALL OUTSIDE THE HEADER. One
2052
+ * `border-radius` sets both the shape the shadow fades from and the shape
2053
+ * the box is clipped to; oversizing the box puts the clip out of sight and
2054
+ * leaves only the inner shape doing any work.
2055
+ *
2056
+ * `corner-shape` turns that rounded rectangle into a true squircle. It is
2057
+ * Chrome 139+ and degrades to an ordinary radius everywhere else, which is
2058
+ * the same picture with slightly rounder corners — a progressive
2059
+ * enhancement in the honest sense rather than a fallback nobody checked.
1596
2060
  */
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
- }
2061
+ inset: -5rem;
2062
+ border-radius: 14rem;
2063
+ corner-shape: superellipse(3);
2064
+ /*
2065
+ * ⚠️ THE SPREAD HAS TO CLEAR THE OVERHANG BEFORE IT DOES ANY WORK. The box
2066
+ * sits 5rem outside the header on every side, so a spread of 5rem is solid
2067
+ * only as far as the header's rim and the whole fade happens out of sight.
2068
+ * 9rem puts the solid band 4rem inside, and the 10rem blur carries it in
2069
+ * from there.
2070
+ */
2071
+ box-shadow: inset 0 0 10rem 9rem var(--wave-docs-bg);
1603
2072
 
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
- }
2073
+ /*
2074
+ * ⚠️ EVERY PERCENTAGE ABOVE IS OF THIS LAYER, NOT OF THE HEADER — AND THE
2075
+ * LAYER IS HALF AS BIG AGAIN.
2076
+ *
2077
+ * The header occupies the middle third of it, from 33% to 67% on both axes.
2078
+ * Written as `125% 100%` the radial's edge landed a long way outside the
2079
+ * header, so what showed inside it was all near-centre: 15px of blank along
2080
+ * every edge before the pattern faded up, measured. `83%` and `67%` are
2081
+ * those numbers divided by the 1.5 — the same ellipse, expressed against
2082
+ * the box it is actually seen through.
2083
+ *
2084
+ * The two linear layers are the edge fade, and they are why the pattern
2085
+ * dissolves into the page on all four sides instead of being cut off by
2086
+ * `overflow: clip`. Each one is transparent exactly at the header's edge —
2087
+ * 33% and 67% of the layer — and opaque a few per cent inside it, so the
2088
+ * ramp happens within the header rather than outside where nothing is seen.
2089
+ * The vertical one is asymmetric on purpose: it holds full only to the
2090
+ * midpoint before falling away, so the grid has faded out well before the
2091
+ * prose begins.
2092
+ *
2093
+ * ⚠️ THE RADIAL AND THESE PULL IN OPPOSITE DIRECTIONS, WHICH IS THE POINT.
2094
+ * The radial is transparent at the centre and opaque outward; these are
2095
+ * opaque inside and transparent at the rim. Multiplied, what survives is a
2096
+ * band between the two — a halo around the words that fades at the page
2097
+ * and fades again where the reading starts.
2098
+ */
1616
2099
  }
1617
2100
 
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
- }
2101
+ /*
2102
+ * The heading, and it is the page's `<h1>` `render` stands its own down
2103
+ * when a page declares `actions`. Sized here rather than inherited from
2104
+ * `.wave-docs-prose h1`, because it is outside the prose.
2105
+ */
2106
+ /*
2107
+ * The words, aligned with the prose beneath them. The header itself spans the
2108
+ * whole article track so the grid behind it is full-bleed; this is what keeps
2109
+ * the title from sitting 70px left of the first paragraph.
2110
+ */
2111
+ .wave-docs-hero__body {
2112
+ max-width: var(--wave-docs-measure);
2113
+ margin-inline: auto;
1626
2114
  }
1627
2115
 
1628
- /* -------------------------------------------------------------------------
1629
- * Header
1630
- * ---------------------------------------------------------------------- */
2116
+ .wave-docs-hero__title {
2117
+ margin: 0;
2118
+ color: var(--wave-docs-fg);
2119
+ font-size: clamp(2.25rem, 1.5rem + 3vw, 3.5rem);
2120
+ font-weight: 700;
2121
+ line-height: 1.1;
2122
+ letter-spacing: -0.02em;
2123
+ text-wrap: balance;
2124
+ }
1631
2125
 
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);
2126
+ /* `frontmatter.description`, which is otherwise only a `<meta>` tag. */
2127
+ .wave-docs-hero__tagline {
2128
+ margin: 1rem 0 0;
2129
+ max-width: 34rem;
2130
+ color: var(--wave-docs-fg-muted);
2131
+ font-size: 1.125rem;
2132
+ line-height: 1.6;
2133
+ text-wrap: pretty;
1641
2134
  }
1642
2135
 
1643
- .wave-docs-layout__header-inner {
2136
+ .wave-docs-hero__actions {
1644
2137
  display: flex;
1645
- align-items: center;
2138
+ flex-wrap: wrap;
1646
2139
  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;
2140
+ margin-block-start: 2rem;
1654
2141
  }
1655
2142
 
1656
- @media (min-width: 30rem) {
1657
- .wave-docs-layout__header-inner {
1658
- padding-inline: 1.5rem;
1659
- }
2143
+ .wave-docs-hero__action {
2144
+ display: inline-flex;
2145
+ align-items: center;
2146
+ /* 44px tall: the same minimum the sidebar's trigger answers to. */
2147
+ min-height: 2.75rem;
2148
+ padding-inline: 1.25rem;
2149
+ border: 1px solid transparent;
2150
+ border-radius: var(--wave-docs-radius);
2151
+ font-size: 0.9375rem;
2152
+ font-weight: 600;
2153
+ text-decoration: none;
1660
2154
  }
1661
2155
 
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;
2156
+ .wave-docs-hero__action[data-variant='primary'] {
2157
+ background-color: var(--wave-docs-accent);
2158
+ color: var(--wave-docs-accent-fg);
1670
2159
  }
1671
2160
 
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;
2161
+ .wave-docs-hero__action[data-variant='primary']:hover {
2162
+ background-color: var(--wave-docs-accent-hover);
1677
2163
  }
1678
2164
 
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;
2165
+ /*
2166
+ * The tinted one. `accent-subtle` is the same surface the active sidebar link
2167
+ * sits on, and its 4.5:1 pair is `accent` — so this is a contrast ratio the
2168
+ * token test already checks rather than a new pairing to argue about.
2169
+ */
2170
+ .wave-docs-hero__action[data-variant='secondary'] {
2171
+ background-color: var(--wave-docs-accent-subtle);
2172
+ color: var(--wave-docs-accent);
1686
2173
  }
1687
2174
 
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);
2175
+ .wave-docs-hero__action[data-variant='secondary']:hover {
2176
+ border-color: var(--wave-docs-accent);
2177
+ }
2178
+
2179
+ .wave-docs-hero__action:focus-visible {
2180
+ outline: 2px solid var(--wave-docs-accent);
2181
+ outline-offset: 2px;
2182
+ }
2183
+
2184
+ @media (prefers-reduced-motion: no-preference) {
2185
+ .wave-docs-hero__action {
2186
+ transition:
2187
+ background-color 120ms ease-out,
2188
+ border-color 120ms ease-out;
2189
+ }
2190
+ }
2191
+
2192
+ /* -------------------------------------------------------------------------
2193
+ * The sidebar
2194
+ * ---------------------------------------------------------------------- */
2195
+
2196
+ /*
2197
+ * It paints nothing. The navigation carries the surface and the border, the
2198
+ * trigger carries neither — so there is no third surface to keep in step with
2199
+ * the other two, which is what made the strip read as filled every previous
2200
+ * time this was drawn.
2201
+ */
2202
+ .wave-docs-layout__sidebar {
2203
+ display: flex;
2204
+ /* No gap: the trigger sits directly against the navigation's border. */
2205
+ gap: 0;
2206
+ width: max-content;
2207
+ justify-self: start;
2208
+ /*
2209
+ * ⚠️ THE COLUMN IN THE PAGE, NOT THE PANEL THAT FOLLOWS THE VIEWPORT, AND
2210
+ * KEEPING THOSE TWO APART IS THE POINT.
2211
+ *
2212
+ * This box was `position: sticky` with `height: 100dvh` — sized against the
2213
+ * viewport while positioned against the layout, which is two reference
2214
+ * frames in one element. The moment a host put anything above it, the two
2215
+ * disagreed by exactly that much: with the UA's 8px of `body` margin the
2216
+ * divider stopped 8px short of the top and ran 8px past the bottom, and it
2217
+ * snapped into place as soon as the page scrolled and sticky engaged.
2218
+ * Measured.
2219
+ *
2220
+ * Stretched to the grid row, it is exactly as tall as the docs region —
2221
+ * starting where the host put it and ending where it ends, symmetric at any
2222
+ * offset, with no token to set and nothing to keep in sync. The scrolling
2223
+ * half is `position: sticky` on the two children, which is where a viewport
2224
+ * measurement belongs.
2225
+ */
2226
+ align-self: stretch;
2227
+ /* So a page shorter than a screen does not leave the sticky panel hanging
2228
+ * below the column that draws its edge. */
2229
+ min-height: calc(100dvh - var(--wave-docs-chrome-offset));
2230
+ position: relative;
2231
+ /* Above the scrim, which is above the article. */
2232
+ z-index: 2;
1701
2233
  background: none;
1702
- color: var(--wave-docs-fg-muted);
1703
- cursor: pointer;
2234
+ border: 0;
2235
+ /*
2236
+ * ⚠️ THE BOX IS ALWAYS `max-content`; ONLY ITS CONTRIBUTION MOVES.
2237
+ *
2238
+ * This one declaration is the whole of "the article does not get out of the
2239
+ * way" — closed at any width, and open in cover mode. The box still paints
2240
+ * its full width, so in cover mode it overlays; it just does not ask the
2241
+ * grid for the room. Push mode drops it, and the same box takes its own
2242
+ * space with nothing else changing.
2243
+ */
2244
+ margin-inline-end: calc(-1 * var(--wave-docs-sidebar-width));
2245
+ /*
2246
+ * Exactly the navigation's width, so it goes entirely off the page and the
2247
+ * trigger's outer edge lands on the inline start edge — whatever the strip
2248
+ * happens to be.
2249
+ *
2250
+ * ⚠️ THE SAME TOKEN AS THE MARGIN ABOVE, AND DELIBERATELY. It was
2251
+ * `calc(var(--wave-docs-trigger-width) - 100%)` — "minus all of me, plus the
2252
+ * trigger back" — which avoided naming the navigation but only worked while
2253
+ * the strip had a width to name. The strip is `auto` now, so this is both
2254
+ * shorter and the only spelling that cannot drift: the two declarations
2255
+ * that move the sidebar read the same token.
2256
+ */
2257
+ translate: calc(-1 * var(--wave-docs-sidebar-width));
1704
2258
  }
1705
2259
 
1706
- .wave-docs-layout__nav-trigger:hover {
1707
- background-color: var(--wave-docs-bg-subtle);
1708
- color: var(--wave-docs-fg);
2260
+ /*
2261
+ * ⚠️ THREE STATES, NOT TWO, AND THE THIRD ONE IS WHY THERE IS NO FLASH.
2262
+ *
2263
+ * `data-state` absent means "nobody has chosen yet" — the server renders
2264
+ * that, and CSS decides per mode: closed where the navigation would cover the
2265
+ * article, open where it would sit beside it. So the first paint is already
2266
+ * right at both shapes, with no JavaScript and nothing to correct. Once the
2267
+ * reader presses the trigger their choice is explicit and wins at every
2268
+ * width.
2269
+ */
2270
+ .wave-docs-layout__sidebar[data-state='open'] {
2271
+ translate: 0;
2272
+ }
2273
+
2274
+ /*
2275
+ * ⚠️ NOT UNTIL THE SECOND FRAME. A transition declared at first paint
2276
+ * animates the resolved state *in* from wherever the initial one was — the
2277
+ * navigation visibly slides across on every page load. `data-ready` is set
2278
+ * after mount, so the first paint is a jump nobody sees and every later
2279
+ * change is animated.
2280
+ */
2281
+ @media (prefers-reduced-motion: no-preference) {
2282
+ .wave-docs-layout__sidebar[data-ready] {
2283
+ transition:
2284
+ translate 200ms ease,
2285
+ margin-inline-end 200ms ease;
2286
+ }
1709
2287
  }
1710
2288
 
1711
2289
  /* -------------------------------------------------------------------------
1712
- * Drawer
2290
+ * The navigation
1713
2291
  * ---------------------------------------------------------------------- */
1714
2292
 
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;
2293
+ /*
2294
+ * The only painted surface in the sidebar, and it has to be opaque: in cover
2295
+ * mode it sits on top of the article, and a translucent one would put two
2296
+ * columns of text through each other.
2297
+ */
2298
+ .wave-docs-layout__sidebar-nav {
2299
+ flex: none;
1720
2300
  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;
2301
+ width: var(--wave-docs-sidebar-width);
2302
+ padding: 2rem 1rem;
1726
2303
  border: 0;
2304
+ /*
2305
+ * Cover mode only, and it is a different thing from the divider in push
2306
+ * mode: there the line divides the page, here it is the edge of a panel
2307
+ * lying on top of one. A panel's edge is the panel's height, so this one
2308
+ * belongs on the panel.
2309
+ */
1727
2310
  border-inline-end: 1px solid var(--wave-docs-border);
1728
2311
  background-color: var(--wave-docs-bg);
1729
- color: var(--wave-docs-fg);
2312
+ /* The panel that follows the viewport: sticky, one screen tall, and its own
2313
+ * scroller. `dvh`, not `vh` — with a mobile URL bar showing, `vh` is taller
2314
+ * than the visible viewport and the last few nav items cannot be reached. */
2315
+ position: sticky;
2316
+ top: var(--wave-docs-chrome-offset);
2317
+ height: calc(100dvh - var(--wave-docs-chrome-offset));
1730
2318
  overflow-y: auto;
1731
- /* Reaching the end of the nav must not start scrolling the page behind. */
2319
+ /* Without this, reaching the end of the nav keeps scrolling the article
2320
+ * jumps under the reader's cursor while they are looking at the sidebar. */
1732
2321
  overscroll-behavior: contain;
2322
+ /* Reserves the scrollbar's width whether or not it is showing, so the
2323
+ * column does not jump when the nav grows past a screen. */
2324
+ scrollbar-gutter: stable;
1733
2325
  }
1734
2326
 
1735
- dialog.wave-docs-layout__drawer::backdrop {
1736
- background-color: oklch(0.2 0.02 262 / 0.55);
2327
+ /* First child of the navigation, above the tree. */
2328
+ .wave-docs-layout__search {
2329
+ margin-block-end: 1rem;
1737
2330
  }
1738
2331
 
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;
2332
+ /* -------------------------------------------------------------------------
2333
+ * The trigger
2334
+ * ---------------------------------------------------------------------- */
2335
+
2336
+ /*
2337
+ * ⚠️ NO BACKGROUND AND NO BORDER, IN ANY STATE. 44px wide because that is
2338
+ * Apple's minimum tap target and the strip *is* the target — it carried a
2339
+ * 20px transparent border for a while to answer to 44 while painting 24, and
2340
+ * a box the eye cannot see is a box the next person deletes.
2341
+ */
2342
+ .wave-docs-layout__sidebar-trigger {
2343
+ flex: none;
2344
+ /*
2345
+ * Sticky for the same reason the navigation is: the column around it is the
2346
+ * page's height now, and a control centred in *that* would sit halfway down
2347
+ * a long document instead of halfway down the screen.
2348
+ *
2349
+ * ⚠️ AND IT IS ALSO WHAT POSITIONS THE BUTTON. The button and the dots are
2350
+ * `position: absolute`, and an absolutely positioned box resolves against
2351
+ * the nearest *positioned* ancestor — `sticky` qualifies, `static` does not.
2352
+ * Without a positioned ancestor here they escape to the layout and paint a
2353
+ * slab across the page. Measured.
2354
+ */
2355
+ position: sticky;
2356
+ top: var(--wave-docs-chrome-offset);
2357
+ height: calc(100dvh - var(--wave-docs-chrome-offset));
2358
+ /*
2359
+ * ⚠️ `auto`, SO THE TARGET GROWS WITH THE BUTTON. The strip was a fixed
2360
+ * 2.75rem and the button was positioned inside it, which made the gap
2361
+ * between them a third number nobody set: 24px of button left 10px a side,
2362
+ * 16px left 14px. Sized by its content it is always the button plus 4px,
2363
+ * and `--wave-docs-trigger-width` is the only thing to change.
2364
+ */
2365
+ display: grid;
2366
+ place-items: center;
2367
+ width: auto;
2368
+ padding: 4px;
1747
2369
  border: 0;
1748
- border-radius: var(--wave-docs-radius-sm);
1749
2370
  background: none;
1750
2371
  color: var(--wave-docs-fg-muted);
1751
2372
  cursor: pointer;
1752
2373
  }
1753
2374
 
1754
- .wave-docs-layout__drawer-close:hover {
1755
- background-color: var(--wave-docs-bg-subtle);
1756
- color: var(--wave-docs-fg);
2375
+ /*
2376
+ * The button: `--wave-docs-trigger-width` × 80, and grey until hovered.
2377
+ *
2378
+ * ⚠️ IN FLOW, WHICH IS WHAT MAKES THE STRIP SIZE ITSELF. Absolutely
2379
+ * positioned it contributed nothing, so the strip needed its own width and
2380
+ * the gap between the two was whatever the subtraction happened to be. This
2381
+ * is the only in-flow child, so the strip is exactly this plus its 4px of
2382
+ * padding — one number, and the target follows the paint.
2383
+ *
2384
+ * `place-items: center` on the strip does the centring the insets used to.
2385
+ */
2386
+ .wave-docs-layout__sidebar-trigger::before {
2387
+ content: '';
2388
+ width: var(--wave-docs-trigger-width);
2389
+ height: 5rem;
2390
+ border-radius: var(--wave-docs-radius-sm);
2391
+ background-color: var(--wave-docs-border-strong);
1757
2392
  }
1758
2393
 
1759
2394
  /*
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.
2395
+ * Three dots. Measured off docsify.js.org rather than reasoned about: three
2396
+ * 4px circles 3px apart. One element and two `box-shadow` copies rather than
2397
+ * three spans no markup, and it follows `currentcolor` through the hover
2398
+ * state for free. 4 + 3 + 4 + 3 + 4 is 18px tall, so the offset is 7.
2399
+ *
2400
+ * ⚠️ `::after`, AND THE ORDER IS LOAD-BEARING. Neither pseudo-element carries
2401
+ * a `z-index`, so tree order decides which paints on top.
1763
2402
  */
1764
- html:has(dialog.wave-docs-layout__drawer:modal) {
1765
- overflow: hidden;
2403
+ .wave-docs-layout__sidebar-trigger::after {
2404
+ content: '';
2405
+ position: absolute;
2406
+ inset-block-start: 50%;
2407
+ inset-inline-start: 50%;
2408
+ width: 4px;
2409
+ height: 4px;
2410
+ translate: -50% -50%;
2411
+ border-radius: 50%;
2412
+ background: currentcolor;
2413
+ box-shadow:
2414
+ 0 -7px 0 currentcolor,
2415
+ 0 7px 0 currentcolor;
1766
2416
  }
1767
2417
 
1768
2418
  /*
1769
- * Drawer furniture, hidden once the drawer is a column.
2419
+ * ⚠️ THE STRIP'S HOVER LIGHTS THE BUTTON TOO, AND THAT IS WHAT MAKES IT ONE
2420
+ * CONTROL. Hovering 44px of strip while the button inside it stays grey reads
2421
+ * as a dead zone around a small target; Docsify pairs them for the same
2422
+ * reason.
2423
+ */
2424
+ .wave-docs-layout__sidebar-trigger:hover {
2425
+ background-color: color-mix(
2426
+ in oklab,
2427
+ var(--wave-docs-accent) 30%,
2428
+ transparent
2429
+ );
2430
+ color: var(--wave-docs-accent-fg);
2431
+ }
2432
+
2433
+ .wave-docs-layout__sidebar-trigger:hover::before {
2434
+ background-color: var(--wave-docs-accent);
2435
+ }
2436
+
2437
+ @media (prefers-reduced-motion: no-preference) {
2438
+ .wave-docs-layout__sidebar-trigger,
2439
+ .wave-docs-layout__sidebar-trigger::before,
2440
+ .wave-docs-layout__sidebar-trigger::after {
2441
+ /*
2442
+ * ⚠️ ON THE PSEUDO-ELEMENTS TOO, NOT ONLY THE BUTTON. The dots are
2443
+ * `background: currentcolor`, and an inherited colour changing does not
2444
+ * start a transition on a descendant that has not declared one — the
2445
+ * button would fade and the dots would snap.
2446
+ */
2447
+ transition:
2448
+ background-color 120ms ease-out,
2449
+ color 120ms ease-out;
2450
+ }
2451
+ }
2452
+
2453
+ /*
2454
+ * ⚠️ AN OUTLINE, NOT THE FILL. These shared the hover rule for one commit,
2455
+ * and `focus-indicators` caught it: a background change is the one thing
2456
+ * forced-colours mode discards, so a keyboard reader there would have had no
2457
+ * indicator at all.
2458
+ */
2459
+ .wave-docs-layout__sidebar-trigger:focus-visible {
2460
+ outline: 2px solid var(--wave-docs-accent);
2461
+ outline-offset: -2px;
2462
+ }
2463
+
2464
+ /* -------------------------------------------------------------------------
2465
+ * The scrim
1770
2466
  *
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`.
2467
+ * Cover mode only. `inert` keeps the keyboard out of what is underneath; this
2468
+ * is what tells everyone else.
2469
+ * ---------------------------------------------------------------------- */
2470
+
2471
+ .wave-docs-layout__sidebar-scrim {
2472
+ /* Absolute inside the layout rather than fixed to the viewport: a fixed
2473
+ * scrim would darken a host's own chrome along with our article. */
2474
+ position: absolute;
2475
+ inset: 0;
2476
+ z-index: 1;
2477
+ border: 0;
2478
+ padding: 0;
2479
+ background-color: oklch(0.2 0.02 262 / 0.55);
2480
+ opacity: 0;
2481
+ pointer-events: none;
2482
+ }
2483
+
2484
+ .wave-docs-layout__sidebar[data-state='open']
2485
+ ~ .wave-docs-layout__sidebar-scrim {
2486
+ opacity: 1;
2487
+ pointer-events: auto;
2488
+ }
2489
+
2490
+ @media (prefers-reduced-motion: no-preference) {
2491
+ .wave-docs-layout__sidebar-scrim {
2492
+ transition: opacity 200ms ease;
2493
+ }
2494
+ }
2495
+
2496
+ /* -------------------------------------------------------------------------
2497
+ * Cover and push
2498
+ *
2499
+ * The whole of what differs between a narrow container and a wide one. Same
2500
+ * markup, same classes, same control, same translate — only whether the
2501
+ * article gets out of the way.
2502
+ * ---------------------------------------------------------------------- */
2503
+
2504
+ /*
2505
+ * ⚠️ A CUSTOM PROPERTY, BECAUSE JAVASCRIPT CANNOT ASK A CONTAINER QUERY.
2506
+ * `matchMedia` answers about the viewport, which is the question this file
2507
+ * deliberately stopped asking — so the mode is declared here and read back
2508
+ * with `getComputedStyle`. One source of truth, and the breakpoint appears
2509
+ * nowhere in the component.
1776
2510
  */
1777
- @media (min-width: 64rem) {
1778
- .wave-docs-layout__nav-trigger,
1779
- .wave-docs-layout__drawer-close {
2511
+ .wave-docs-layout__sidebar {
2512
+ --wave-docs-sidebar-mode: cover;
2513
+ }
2514
+
2515
+ @container wave-docs (min-width: 64rem) {
2516
+ .wave-docs-layout__sidebar {
2517
+ --wave-docs-sidebar-mode: push;
2518
+ }
2519
+
2520
+ /*
2521
+ * ⚠️ THE DIVISION IS THE PAGE'S, SO IT IS DRAWN BY THE PAGE'S COLUMN.
2522
+ *
2523
+ * In push mode the sidebar is part of the layout and this line is what
2524
+ * separates navigation from article — so it runs the full height of the
2525
+ * docs region, top to bottom, wherever the host placed it. Drawn on the
2526
+ * navigation instead it would be one screen tall, which is only the same
2527
+ * thing when the layout happens to start at the viewport's top edge.
2528
+ *
2529
+ * `inset-inline-start` at the navigation's width rather than a border on
2530
+ * the shell: the shell's own inline end is past the trigger, and the
2531
+ * division belongs between the navigation and the article.
2532
+ */
2533
+ .wave-docs-layout__sidebar::after {
2534
+ content: '';
2535
+ position: absolute;
2536
+ inset-block: 0;
2537
+ inset-inline-start: var(--wave-docs-sidebar-width);
2538
+ width: 1px;
2539
+ background-color: var(--wave-docs-border);
2540
+ }
2541
+
2542
+ /*
2543
+ * Unchosen means open up here, where there is room for the navigation
2544
+ * beside the article rather than on top of it — and open in push mode is
2545
+ * the one combination that asks the grid for its own space.
2546
+ *
2547
+ * ⚠️ KEYED ON THE STATE, NOT THE MODE. Dropping the margin for the whole of
2548
+ * push mode leaves a 300px hole in the grid whenever the sidebar is closed:
2549
+ * the box is translated off the page and the track is still holding room
2550
+ * for it.
2551
+ */
2552
+ .wave-docs-layout__sidebar:not([data-state='closed']) {
2553
+ translate: 0;
2554
+ margin-inline-end: 0;
2555
+ }
2556
+
2557
+ /* Nothing is covered, so nothing is scrimmed. */
2558
+ .wave-docs-layout__sidebar-scrim {
1780
2559
  display: none;
1781
2560
  }
2561
+
2562
+ /* And the panel's edge gives way to the page's division above. */
2563
+ .wave-docs-layout__sidebar-nav {
2564
+ border-inline-end: 0;
2565
+ }
2566
+
2567
+ .wave-docs-layout {
2568
+ gap: 0 1.5rem;
2569
+ padding-inline: 0 1.5rem;
2570
+ }
2571
+ }
2572
+
2573
+ @container wave-docs (min-width: 80rem) {
2574
+ .wave-docs-layout:has(.wave-docs-layout__toc) {
2575
+ grid-template-columns: auto minmax(0, 1fr) var(--wave-docs-toc-width);
2576
+ }
2577
+
2578
+ .wave-docs-layout__toc {
2579
+ display: block;
2580
+ /* Same reason as the article: its contents cannot be weighed against the
2581
+ * scrim, only the column as a whole. */
2582
+ isolation: isolate;
2583
+ position: sticky;
2584
+ /* `align-self: start`, or the sticky box stretches to the row height and
2585
+ * has nothing left to travel within. */
2586
+ align-self: start;
2587
+ top: var(--wave-docs-chrome-offset);
2588
+ max-height: calc(100dvh - var(--wave-docs-chrome-offset));
2589
+ overflow-y: auto;
2590
+ overscroll-behavior: contain;
2591
+ padding-block: 2rem;
2592
+ }
1782
2593
  }
1783
2594
  }
1784
2595
 
@@ -1809,6 +2620,25 @@
1809
2620
  cursor: pointer;
1810
2621
  }
1811
2622
 
2623
+ /*
2624
+ * In the shell, the trigger fills whatever it is placed in — the width of the
2625
+ * strip below 64rem, the width of the column above it.
2626
+ *
2627
+ * ⚠️ THIS USED TO BE A THREE-STEP `max-width` LADDER (9rem, 11rem at 30rem,
2628
+ * 20rem at 48rem) AND EVERY NUMBER IN IT WAS HEADER ARITHMETIC. The trigger
2629
+ * is `width: 100%`, so in a flex row its base was the whole row; the ladder
2630
+ * existed to stop it starving the brand beside it, and at 390px it had
2631
+ * ellipsised "Wave Docs" to "Wa…". With no header there is no row to share
2632
+ * and no brand to leave room for, so the container decides.
2633
+ *
2634
+ * The bare `.wave-docs-search-trigger` keeps its own `max-width: 20rem` for
2635
+ * the standalone case — a host rendering `DocsSearch` in their own header,
2636
+ * where something else *is* competing for the row.
2637
+ */
2638
+ .wave-docs-search-trigger.wave-docs-layout__search {
2639
+ max-width: none;
2640
+ }
2641
+
1812
2642
  .wave-docs-search-trigger:hover {
1813
2643
  border-color: var(--wave-docs-border-strong);
1814
2644
  color: var(--wave-docs-fg);
@@ -1828,6 +2658,18 @@
1828
2658
  white-space: nowrap;
1829
2659
  }
1830
2660
 
2661
+ /*
2662
+ * ⚠️ HIDDEN WHERE THE SHORTCUT CANNOT BE PRESSED, WHICH IS NOT A WIDTH
2663
+ * QUESTION. `⌘K` is an instruction, and on a phone or a tablet it is one the
2664
+ * reader cannot follow — a 10-inch tablet at 1024px would keep it under any
2665
+ * breakpoint rule.
2666
+ */
2667
+ @media (hover: none) and (pointer: coarse) {
2668
+ .wave-docs-search-trigger-kbd {
2669
+ display: none;
2670
+ }
2671
+ }
2672
+
1831
2673
  .wave-docs-search-trigger-kbd {
1832
2674
  flex: none;
1833
2675
  padding: 0.05rem 0.35rem;