@waveso/docs 0.11.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,280 @@
1
1
  # @waveso/docs
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3551951: **The search results and the sidebar say "there is more this way" the same way
8
+ the table does, and hide their scrollbars where they can.**
9
+
10
+ Four gradients on the search list, exactly as `.wave-docs-table-scroll` does it:
11
+ the two `local` covers are painted in the surface colour and travel _with_ the
12
+ rows, so each sits over its shadow only while that edge is at rest, and the two
13
+ `scroll` shadows are pinned to the box. A grey edge appears on precisely the side
14
+ that has rows off-screen, with no listener, no state and no hydration.
15
+
16
+ ⚠️ IT REPLACED A MASK, WHICH WAS THE WRONG TOOL TWICE OVER. A mask fades content
17
+ to _transparent_, so what showed through was the dialog's own white — a hole
18
+ rather than a shadow. And it cannot be conditional: CSS has no way to ask whether
19
+ there is anything above to scroll to, so the first and last rows were softened
20
+ even at rest.
21
+
22
+ ## The sidebar needs its shadow above the content, not behind it
23
+
24
+ ⚠️ `.wave-docs-sidebar` PAINTS ITS OWN GROUND, AND MUST. It is in the theme's
25
+ opt-in rule — the one that installs a ground wherever it installs a foreground
26
+ ramp — so a consumer mounting the tree alone gets one, and `styles.browser.test`
27
+ requires the shell to be a single surface. An element's background is painted
28
+ _below_ its children, so the four-gradient trick was covered by a child 480px
29
+ tall: measured 255/255 at the top edge with the nav scrolled — declared,
30
+ computed, and invisible.
31
+
32
+ So the navigation's overflow moved to an inner box, `.wave-docs-layout__sidebar-scroll`,
33
+ and the panel keeps only its edges.
34
+
35
+ ⚠️ THAT SPLIT IS THE FIX, AND EVERY VERSION BEFORE IT FOUGHT THE SCROLLER. An
36
+ absolutely positioned child of a scroll container is laid out against that
37
+ container's padding box and _joins its scrollable overflow_ — so nothing placed
38
+ inside a scroller is ever pinned to it. `position: sticky` only clamps, and a
39
+ clamp is still a thing that travels until it catches. With the scrolling on an
40
+ inner element the panel is an ordinary positioned box: `top: 0` and `bottom: 0`
41
+ are its own edges, and the bands have no scroll to have a position within.
42
+
43
+ The timeline is declared on the scroller and `timeline-scope`d up to the panel,
44
+ because a `scroll-timeline` name is visible to the declaring element's
45
+ descendants and these pseudo-elements belong to its parent.
46
+
47
+ ⚠️ AND THE SHADOW BELONGS TO THE PANEL RATHER THAN THE TREE, because
48
+ `.wave-docs-sidebar` paints an opaque ground — it is in the theme's opt-in rule,
49
+ and the shell has to be one surface — so a background on the scroller would be
50
+ painted underneath it. Measured 255/255 when it was.
51
+
52
+ ⚠️ AND THE NAV'S FADE IS A LENGTH, NOT A PERCENTAGE OF THE SCROLL. The table's
53
+ keyframes shape their fade in percentages, which are percentages of the
54
+ container's _total_ scroll range — fine on a table, wrong on a navigation. A nav
55
+ with 1000px of scroll turns the same `2%…8%` into 20px…80px, so the shadow spends
56
+ eighty pixels of scrolling arriving at full strength, which reads exactly like it
57
+ is moving with the content rather than pinned to the edge. `animation-range: 0
58
+ 1rem` makes it the same short distance whatever the nav's height, identical on a
59
+ six-page site and a three-hundred-page one, with plain fades for keyframes
60
+ because the shaping now lives in the range.
61
+
62
+ ⚠️ THE SCROLLBAR IS HIDDEN ONLY WHERE THE SHADOW EXISTS. Both live in the same
63
+ `@supports (animation-timeline: scroll())`, so Firefox — where scroll-driven
64
+ animations have not shipped — keeps the thin bar rather than losing the bar and
65
+ the shadow together. A scrollbar is the one cue that a column has more below it,
66
+ and taking it away before something replaces it trades a slab for nothing.
67
+
68
+ ⚠️ AND A SECOND TEST LEARNED THAT `getAnimations()` IS NOT "TRANSITIONS". A
69
+ scroll-driven animation's `finished` promise resolves when the _scroll position_
70
+ reaches the end, which is to say never on a panel sitting at the top — so
71
+ awaiting the whole list hung `sidebar.browser.test.tsx` for its full 15s timeout
72
+ the moment this shadow existed. `nav.browser.test.tsx` had already been bitten by
73
+ the table's; this is the second place the assumption lived.
74
+
75
+ - 70649b8: **Search results get a glyph, a breadcrumb, and no scrollbar.**
76
+
77
+ **A page or a section, as an icon.** Which one is read off the `href` rather
78
+ than a new index field — a record with an anchor is a section within a page and
79
+ one without is the page itself, which is what `buildSearchIndex` means by its
80
+ lead record — so the records did not grow by a byte.
81
+
82
+ ⚠️ AND THE ICON IS NOT DECORATION, IT IS THE ALIGNMENT. With no icon a result's
83
+ heading sat on the same column as the input's _magnifier_ while the input's own
84
+ text sat 24px right of it: three left edges where a reader expects two, which is
85
+ what made the dialog read as stacked levels rather than as a list under a field.
86
+ The icon takes the magnifier's column and the text takes the input's, so the two
87
+ rows are the same shape. Measured: 18px and 42px, both rows, plus the footer's
88
+ key caps on the 18.
89
+
90
+ **The second line is a breadcrumb.** The segments were always a trail from the
91
+ site's root to the page; slashes made them read as a URL to parse and `›` makes
92
+ them read as what they are. ⚠️ SAFE ONLY BECAUSE THAT LINE IS `aria-hidden` —
93
+ more than one screen reader pronounces the character, which is exactly why
94
+ `spokenName` joins with commas instead. The two lines carry the same fact in the
95
+ form each audience can use.
96
+
97
+ ⚠️ THE SEPARATOR IS THE PACKAGE'S OWN CHEVRON, NOT A CHARACTER, AND TWO
98
+ CHARACTERS WERE MEASURED AND REJECTED FIRST. `›` (U+203A) inks 4.93px tall in
99
+ the shipped mono face at 11px, against 6.32 for an `s` and 8.51 for a `b` — it
100
+ sat visibly below the words it separated. `❯` (U+276F) inks 8.03 and fixed the
101
+ height _on one machine_: `ui-monospace` resolves to SF Mono here, Consolas on
102
+ Windows, Liberation Mono on Linux, and a glyph missing from one of those falls
103
+ back to another face at another width. U+3009 already demonstrated exactly that,
104
+ measuring 11.24px against the 6.62 cell.
105
+
106
+ An SVG has no font to be missing from — and it is the same Lucide chevron the
107
+ sidebar and the pager draw, which is the real argument: everything else here is
108
+ that one set.
109
+
110
+ ⚠️ AND THE TRAIL IS SET IN THE SANS, NOT IN THE MONO IT STARTED IN — WHICH IS
111
+ WHAT MADE THE SEPARATOR RELIABLE. A monospace face advances every glyph one
112
+ cell, so the segments marched on a rigid grid an icon could not join, and its
113
+ metrics are whatever the machine resolves: SF Mono here, Consolas on Windows,
114
+ Liberation Mono on Linux. The cap height an inline glyph is sized against
115
+ therefore moved from reader to reader, which is the same portability problem
116
+ that ruled out `›` and `❯` as characters — arriving a second time through the
117
+ face rather than through the glyph. Proportional sans has ordinary spacing and
118
+ one set of metrics to match. `font-size` goes up a notch with it, because a
119
+ mono face reads larger at the same size and holding the number would have
120
+ shrunk the line.
121
+
122
+ ⚠️ AND THE `viewBox` IS CROPPED TO THE STROKE, WHICH IS WHAT RETIRED FOUR
123
+ CORRECTIONS. On Lucide's full 24 grid the chevron occupies x 9–15 and y 6–18,
124
+ so a square box around it was five sixths air across and half air down — and
125
+ every one of those gaps had to be subtracted back by hand: a `vertical-align`,
126
+ a negative `margin-inline`, a negative `margin-block`, and a size chosen to
127
+ make the surplus come out right. `8 5 8 14` is the painted extent, caps and
128
+ joins included. The box is the glyph, so it sits on the baseline the way a
129
+ letter does with nothing declared, and the air beside it is one positive
130
+ `margin-inline`.
131
+
132
+ ⚠️ AND IT CARRIES `overflow: visible`, WITHOUT WHICH THE POINT IS FLAT. An
133
+ `svg` clips to its viewport by default and this `viewBox` is exactly the
134
+ stroke's extent, so the round join at the tip lands on the box's own edge and
135
+ loses its outermost anti-aliased pixel. Nothing overlaps anything: what spills
136
+ is a fraction of a pixel.
137
+
138
+ ## The scrollbar is hidden, and it cannot be conditional
139
+
140
+ ⚠️ THERE IS NO CSS WAY TO SHOW A SCROLLBAR ONLY WHILE SCROLLING. That behaviour
141
+ is the platform's: macOS draws overlay scrollbars that fade in on scroll and out
142
+ after it, and this list gets it for free there. Windows and Linux draw a classic
143
+ one that is always present, and the only ways to make it come and go are a
144
+ JavaScript timer toggling a class or a scrollbar drawn from scratch in script —
145
+ which is what a `ScrollArea` component is, and neither belongs in a package whose
146
+ whole argument is what it does _not_ ship to a reader.
147
+
148
+ So it is hidden, as `@waveso/app` does with `.scrollbar-none`. What replaces it
149
+ is the keyboard rather than a fade: the footer says `↑ ↓ Select`, the list is
150
+ driven by `aria-activedescendant`, and arrowing past the last visible row scrolls
151
+ it and loads the next page.
152
+
153
+ ⚠️ AND `pageSize` STAYS A WINDOW, NOT A CAP. It was a hard ceiling of 8 once, and
154
+ that was removed with measurements: on a six-page site "docs" matches 18, so
155
+ results were unreachable and the live region announced "8 results" — not a
156
+ smaller truth but a false one. Rendering every row of a 300-page corpus costs
157
+ 40ms, 128ms at 4x throttle, which is what paging exists to avoid; the ceiling
158
+ was never what made it fast.
159
+
160
+ The size budget moves with the glyphs, and the figures the README and the
161
+ installation page publish move with it — a document here may not understate what
162
+ this package costs.
163
+
164
+ - 78053a6: **Nothing in the search dialog is selected until the reader selects it.**
165
+
166
+ Typing lit the first result the instant it arrived, so a row changed under a
167
+ reader for something they had not chosen — and on this package's accent that
168
+ reads as a decision already made. `activeIndex` starts at `-1` now: an arrow key
169
+ or the pointer moving over a row is what selects.
170
+
171
+ ⚠️ AND EVERY GUARD FOR IT ALREADY EXISTED, WHICH IS WHY THIS IS ONE NUMBER.
172
+ `hits[-1]` is `undefined`, so no `aria-activedescendant` is written and Enter
173
+ returns without opening anything. The one place that needed teaching is the
174
+ arrow keys: they wrap modulo the list, and `(-1 - 1 + n) % n` is the _second to
175
+ last_, so Up from a fresh query landed one short of the end.
176
+
177
+ **The active row is the table header's grey**, easing on the same 150ms the
178
+ sidebar's rows use. Moving the pointer over a result activates it, so hovering
179
+ _is_ this state; there is no second rule for it.
180
+
181
+ It has been four things: a 2px accent ring, which read as a component borrowed
182
+ from somewhere else and came and went as a reader arrowed; the trigger's border
183
+ pair, which meant bordering _every_ row to make one edge legible and turned a
184
+ list into a stack of cards; an accent tint, which was the same colour family as
185
+ the field above it. A row is a list item and its state is a colour — the field is
186
+ a control, and that is what wears the trigger's border.
187
+
188
+ ⚠️ `bg-subtle` ON `bg` IS ABOUT 1.02:1, SO THE TINT IS A HINT AND NOT AN
189
+ INDICATOR. The marker going `fg-subtle` to `fg` is the part that carries the
190
+ state — text contrast rather than non-text, and the sidebar's own hover. What
191
+ must not happen is the tint being left to carry it alone.
192
+
193
+ ## The field stops drawing a ring it could never put down
194
+
195
+ ⚠️ A TEXT INPUT MATCHES `:focus-visible` WHENEVER IT IS FOCUSED, HOWEVER FOCUS
196
+ ARRIVED — that is the spec, not a heuristic — and this dialog focuses its input
197
+ the moment it opens. So the accent ring was not a state, it was the field's
198
+ permanent appearance, and it stopped the field looking like the bordered grey
199
+ control the reader clicked to get there.
200
+
201
+ The edge darkens to `--wave-docs-border-strong` instead, which is what the
202
+ trigger does under the pointer, so the field is the trigger in both of its
203
+ states.
204
+
205
+ ⚠️ AND THE INDICATOR IS NOT LOST WITH IT, BECAUSE A TEXT FIELD HAS ONE OF ITS
206
+ OWN. The caret is the platform's focus indication for a text box, it is in this
207
+ field the whole time the dialog is open, and it is what a reader looks for to see
208
+ where typing goes. That is a different argument from the one this rule used to
209
+ reject: "the dialog frame is the indicator" was a _static_ border that looked
210
+ identical focused and unfocused, and indicated nothing. This edge changes.
211
+
212
+ ## The list fades at both edges
213
+
214
+ A mask on the scrollport, so the row being cut off is the one that softens. It
215
+ does not travel with the content: a mask paints against the element's own box, so
216
+ the two stops stay at the top and bottom of the _port_ while the rows move under
217
+ them.
218
+
219
+ ⚠️ IT IS UNCONDITIONAL, WHICH IS THE ONE THING TO KNOW. CSS cannot ask "is there
220
+ anything above this to scroll to" — the table's shadow answers that with a
221
+ scroll-driven animation whose timeline goes inactive when nothing overflows, and
222
+ a mask has no equivalent. So the first and last rows carry a little of it at
223
+ rest. At `1.25rem` against a row nearer three times that, it costs the top pixel
224
+ or two of a heading and buys never guillotining one.
225
+
226
+ ## The field, and the gap under it
227
+
228
+ **The field is the trigger, expanded.** Same border, same fill, same radius. It
229
+ was a border with no fill — two frames a few pixels apart — and briefly a fill
230
+ with no border, which is a tinted band rather than a control. The trigger has
231
+ always been both, and both together are what reads as a field.
232
+
233
+ ⚠️ THE PADDING IS NOT COPIED WITH THEM. The trigger pays `calc(0.5rem - 1px)`
234
+ because it is a compact control in a sidebar; the field pays
235
+ `calc(0.75rem - 1px)` because its glyph has to land on the column the results
236
+ and the footer sit on. The `- 1px` is the border either way — content inside a
237
+ bordered box starts a border further in.
238
+
239
+ The field takes the base radius and the result rows take `-sm`, which is the tier
240
+ system doing what it says: both are controls, and a result row is a list item.
241
+
242
+ ⚠️ AND THE GAP UNDER THE FIELD WAS PAID TWICE. The field's margin and the result
243
+ list's top padding both contributed, so the space between the input and the first
244
+ result was double the space above the input — a doubled gap in the one place a
245
+ reader's eye travels on every keystroke. The list drops its top padding; the
246
+ field keeps its margin, because an empty result list is `display: none` and a gap
247
+ paid from there would vanish on the query that matches nothing.
248
+
249
+ ### Patch Changes
250
+
251
+ - 688a84c: **The search dialog lines up on one column, and its input is a field rather
252
+ than a band.**
253
+
254
+ The input row was flush to the dialog's frame, so it could only round the two
255
+ corners it shared with it and needed a rule underneath to separate it from the
256
+ results. Inset by the same margin the results list uses, it is a box like they
257
+ are — all four corners rounded, and the gap does the separating.
258
+
259
+ ⚠️ AND ITS RADIUS IS THE DIALOG'S MINUS THE GAP, NOT THE DIALOG'S. A rounded box
260
+ inset inside a rounded box is concentric only at `outer - gap`; equal radii run
261
+ the two corners at different curvatures six pixels apart and the field reads as
262
+ pasted onto the dialog rather than set into it.
263
+
264
+ **One column, measured from the bottom up.** The footer's key caps are the
265
+ anchor — the one row whose left edge is a drawn object — and the results' text
266
+ and the input's magnifier are measured to it. All three now start 18px from the
267
+ dialog's inner edge.
268
+
269
+ ⚠️ BOXES, NOT INK. A cap's arrow sits its own border and `0.4em` of padding
270
+ inside the cap, so aligning the _glyphs_ would put every other row on a column
271
+ that moves whenever the footer's font size does.
272
+
273
+ ⚠️ AND THE INPUT ROW PAYS `calc(0.75rem - 1px)` BECAUSE IT IS THE ONE ROW WITH A
274
+ BORDER. Content inside a bordered box starts a border further in than content
275
+ inside an unbordered one, so equal padding misses by exactly that — the same
276
+ subtraction `--wave-docs-panel-inset` exists for.
277
+
3
278
  ## 0.11.0
4
279
 
5
280
  ### Minor Changes
package/README.md CHANGED
@@ -69,8 +69,8 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
69
69
 
70
70
  | | At most |
71
71
  | --- | --- |
72
- | Everything the quick start ships, gzipped | 14.6 KB |
73
- | Search dialog and router wiring | 9.5 KB |
72
+ | Everything the quick start ships, gzipped | 14.9 KB |
73
+ | Search dialog and router wiring | 9.8 KB |
74
74
  | Navigation: one sidebar, open and closed | 3.1 KB |
75
75
  | Table of contents | 1 KB |
76
76
  | Copy-button runtime | 1.1 KB |
@@ -78,7 +78,7 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
78
78
  | hast over the wire vs HTML, code and tables | 1.12× |
79
79
  | Highlighting vs no highlighting | 2.00× |
80
80
 
81
- The first row is the honest total: a reader who lands on a page of your documentation downloads under 14.6 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.
81
+ The first row is the honest total: a reader who lands on a page of your documentation downloads under 14.9 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.
82
82
 
83
83
  The one real cost is the middle pair: shipping a tree instead of a string is about 20% more brotli on a prose page, and about 12% on a page with code and tables, where Shiki's token spans dominate both representations equally. That is the price of never handing markup to `dangerouslySetInnerHTML`, and it is the first number a skeptical reviewer should ask for.
84
84
 
package/dist/react/nav.js CHANGED
@@ -86,21 +86,24 @@ function DocsNav({ nav, pathname, Link, label = "Documentation", children, close
86
86
  className: "wave-docs-layout__sidebar",
87
87
  ...state === void 0 ? {} : { "data-state": state ? "open" : "closed" },
88
88
  ...ready ? { "data-ready": "" } : {},
89
- children: [/* @__PURE__ */ jsxs("div", {
89
+ children: [/* @__PURE__ */ jsx("div", {
90
90
  ref: navRef,
91
91
  id: DOCS_NAV_ID,
92
92
  className: "wave-docs-layout__sidebar-nav",
93
93
  tabIndex: -1,
94
- children: [children, /* @__PURE__ */ jsx(DocsSidebar, {
95
- nav,
96
- pathname,
97
- label,
98
- Link,
99
- ...expandGroup === void 0 ? {} : { expandGroup },
100
- ...collapseGroup === void 0 ? {} : { collapseGroup },
101
- ...externalLink === void 0 ? {} : { externalLink },
102
- ...icons === void 0 ? {} : { icons }
103
- })]
94
+ children: /* @__PURE__ */ jsxs("div", {
95
+ className: "wave-docs-layout__sidebar-scroll",
96
+ children: [children, /* @__PURE__ */ jsx(DocsSidebar, {
97
+ nav,
98
+ pathname,
99
+ label,
100
+ Link,
101
+ ...expandGroup === void 0 ? {} : { expandGroup },
102
+ ...collapseGroup === void 0 ? {} : { collapseGroup },
103
+ ...externalLink === void 0 ? {} : { externalLink },
104
+ ...icons === void 0 ? {} : { icons }
105
+ })]
106
+ })
104
107
  }), /* @__PURE__ */ jsx("button", {
105
108
  type: "button",
106
109
  className: "wave-docs-layout__sidebar-trigger",
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { docsError } from "../docs-error.js";
3
3
  import { mergeSearchOptions } from "../search-options.js";
4
- import { useCallback, useEffect, useId, useRef, useState } from "react";
4
+ import { Fragment, useCallback, useEffect, useId, useRef, useState } from "react";
5
5
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { createPortal } from "react-dom";
7
7
  //#region src/react/search-dialog.tsx
@@ -51,7 +51,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
51
51
  * not itself cause a render.
52
52
  */
53
53
  const movedByKeyboard = useRef(false);
54
- const [activeIndex, setActiveIndex] = useState(0);
54
+ const [activeIndex, setActiveIndex] = useState(-1);
55
55
  const [status, setStatus] = useState("idle");
56
56
  /**
57
57
  * The trigger's shortcut, split rather than held as one string.
@@ -166,13 +166,13 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
166
166
  if (isOpen) return;
167
167
  setQuery("");
168
168
  setHits([]);
169
- setActiveIndex(0);
169
+ setActiveIndex(-1);
170
170
  }, [isOpen]);
171
171
  useEffect(() => {
172
172
  const trimmed = query.trim();
173
173
  if (trimmed.length < minQueryLength) {
174
174
  setHits([]);
175
- setActiveIndex(0);
175
+ setActiveIndex(-1);
176
176
  setVisibleCount(pageSize);
177
177
  return;
178
178
  }
@@ -182,7 +182,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
182
182
  if (isCancelled) return;
183
183
  setStatus("ready");
184
184
  setHits(index.search(trimmed).map(toSearchHit).filter(isSearchHit));
185
- setActiveIndex(0);
185
+ setActiveIndex(-1);
186
186
  setVisibleCount(pageSize);
187
187
  }, () => {
188
188
  if (!isCancelled) setStatus("error");
@@ -261,7 +261,10 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
261
261
  event.preventDefault();
262
262
  const delta = event.key === "ArrowDown" ? 1 : -1;
263
263
  movedByKeyboard.current = true;
264
- setActiveIndex((index) => (index + delta + hits.length) % hits.length);
264
+ setActiveIndex((index) => {
265
+ if (index < 0) return delta > 0 ? 0 : hits.length - 1;
266
+ return (index + delta + hits.length) % hits.length;
267
+ });
265
268
  return;
266
269
  }
267
270
  if (event.key === "Enter") {
@@ -446,13 +449,16 @@ function SearchResultOption({ hit, id, isActive, setSize, posInSet, onActivate,
446
449
  event.preventDefault();
447
450
  onSelect(hit);
448
451
  }
449
- const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
450
- className: "wave-docs-search-result-heading",
451
- children: hit.heading
452
- }), /* @__PURE__ */ jsx("span", {
453
- className: "wave-docs-search-result-location",
454
- "aria-hidden": "true",
455
- children: toDisplayPath(hit.href)
452
+ const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(ResultIcon, { section: hit.href.includes("#") }), /* @__PURE__ */ jsxs("span", {
453
+ className: "wave-docs-search-result-text",
454
+ children: [/* @__PURE__ */ jsx("span", {
455
+ className: "wave-docs-search-result-heading",
456
+ children: hit.heading
457
+ }), /* @__PURE__ */ jsx("span", {
458
+ className: "wave-docs-search-result-location",
459
+ "aria-hidden": "true",
460
+ children: toDisplaySegments(hit.href).map((segment, index, all) => /* @__PURE__ */ jsxs(Fragment, { children: [index === 0 ? null : /* @__PURE__ */ jsx(PathChevron, {}), segment] }, all.slice(0, index + 1).join("/")))
461
+ })]
456
462
  })] });
457
463
  return /* @__PURE__ */ jsx("div", {
458
464
  id,
@@ -599,9 +605,10 @@ function isSearchHit(hit) {
599
605
  * Display only. `hit.href` keeps the anchor, so the link still deep-links to
600
606
  * the section — that is the whole point of section-scoped records.
601
607
  */
602
- function toDisplayPath(href) {
608
+ function toDisplaySegments(href) {
603
609
  const hash = href.indexOf("#");
604
- return hash === -1 ? href : href.slice(0, hash);
610
+ const segments = (hash === -1 ? href : href.slice(0, hash)).split("/").filter(Boolean);
611
+ return segments.length === 0 ? ["/"] : segments;
605
612
  }
606
613
  /**
607
614
  * What a result is called when it is read aloud.
@@ -618,6 +625,71 @@ function toDisplayPath(href) {
618
625
  * A page's own record carries `heading === title` and no ancestors, so its name
619
626
  * is the heading alone — "Styling, Styling" is not a path, it is a stutter.
620
627
  */
628
+ /**
629
+ * A page or a section, as one Lucide glyph.
630
+ *
631
+ * ⚠️ THE PATHS ARE WRITTEN OUT RATHER THAN IMPORTED FROM THE SIDEBAR, WHICH
632
+ * HAS THE SAME PAGE ICON. `NAV_ICON_PATHS` lives in a different client
633
+ * component, and importing across the two would pull the whole navigation
634
+ * tree into the search dialog's bundle to reuse one string. The duplication is
635
+ * two path strings; the alternative is kilobytes.
636
+ */
637
+ const RESULT_ICON_PATHS = {
638
+ page: ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z", "M14 2v4a2 2 0 0 0 2 2h4"],
639
+ section: [
640
+ "M4 9h16",
641
+ "M4 15h16",
642
+ "m10 3-2 18",
643
+ "m16 3-2 18"
644
+ ]
645
+ };
646
+ /**
647
+ * The separator between two route segments.
648
+ *
649
+ * The same path the sidebar's group toggle and the pager draw, at the same
650
+ * `1rem` they draw it. Decorative, and inside a span that is already
651
+ * `aria-hidden` — the route is announced as words by `spokenName`, never read
652
+ * out as punctuation.
653
+ *
654
+ * ⚠️ THE `viewBox` IS CROPPED HORIZONTALLY AND THAT IS NOT A DIFFERENT GLYPH.
655
+ * `d` is Lucide's chevron unchanged and `8 0 8 24` keeps its vertical grid, so
656
+ * the stroke ratio and the ink are exactly what the sidebar and the pager
657
+ * paint. What it drops is the padding either side that the path never reaches:
658
+ * on the full grid a 24-unit box held six units of chevron, so five-sixths of
659
+ * this element was air and the stylesheet had to subtract it back with
660
+ * negative margins. Cropped, the box is the ink and the spacing beside it is
661
+ * one positive value. See `.wave-docs-search-result-sep`.
662
+ */
663
+ function PathChevron() {
664
+ return /* @__PURE__ */ jsx("svg", {
665
+ className: "wave-docs-search-result-sep",
666
+ "aria-hidden": "true",
667
+ focusable: "false",
668
+ viewBox: "8 5 8 14",
669
+ fill: "none",
670
+ stroke: "currentColor",
671
+ strokeWidth: "2",
672
+ strokeLinecap: "round",
673
+ strokeLinejoin: "round",
674
+ children: /* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" })
675
+ });
676
+ }
677
+ function ResultIcon({ section }) {
678
+ return /* @__PURE__ */ jsx("svg", {
679
+ className: "wave-docs-search-result-icon",
680
+ "aria-hidden": "true",
681
+ focusable: "false",
682
+ viewBox: "0 0 24 24",
683
+ width: "16",
684
+ height: "16",
685
+ fill: "none",
686
+ stroke: "currentColor",
687
+ strokeWidth: "2",
688
+ strokeLinecap: "round",
689
+ strokeLinejoin: "round",
690
+ children: RESULT_ICON_PATHS[section ? "section" : "page"].map((d) => /* @__PURE__ */ jsx("path", { d }, d))
691
+ });
692
+ }
621
693
  function spokenName(hit) {
622
694
  if (hit.ancestors.length === 0 && hit.heading === hit.title) return hit.heading;
623
695
  return [
package/dist/styles.css CHANGED
@@ -2173,7 +2173,16 @@
2173
2173
  */
2174
2174
  .wave-docs-sidebar__link,
2175
2175
  .wave-docs-sidebar__group-button,
2176
- .wave-docs-sidebar__group-toggle {
2176
+ .wave-docs-sidebar__group-toggle,
2177
+ /*
2178
+ * A search result is the same object as a sidebar row — a link that tints
2179
+ * under the pointer — so it eases the same way. Moving over a result
2180
+ * *activates* it, which is why there is no separate hover rule to ease:
2181
+ * the state below is both.
2182
+ */
2183
+ .wave-docs-search-result,
2184
+ .wave-docs-search-result-heading,
2185
+ .wave-docs-search-result-icon {
2177
2186
  transition:
2178
2187
  background-color 150ms ease-out,
2179
2188
  color 150ms ease-out;
@@ -3263,7 +3272,17 @@
3263
3272
  flex: none;
3264
3273
  box-sizing: border-box;
3265
3274
  width: var(--wave-docs-sidebar-width);
3266
- padding: 2rem 1rem;
3275
+ /*
3276
+ * ⚠️ NO PADDING AND NO OVERFLOW HERE — BOTH MOVED INTO
3277
+ * `.wave-docs-layout__sidebar-scroll`, AND THAT SPLIT IS THE POINT.
3278
+ *
3279
+ * This panel is now an ordinary positioned box that happens to be sticky,
3280
+ * so the edge shadows below resolve against it and have nothing to scroll
3281
+ * with. While it was the scroll container itself they had to be
3282
+ * `position: sticky` and cancel its padding by hand, and every version of
3283
+ * that fought the scroller rather than standing outside it.
3284
+ */
3285
+ overflow: hidden;
3267
3286
  border: 0;
3268
3287
  /*
3269
3288
  * Cover mode only, and it is a different thing from the divider in push
@@ -3279,10 +3298,6 @@
3279
3298
  position: sticky;
3280
3299
  top: var(--wave-docs-chrome-offset);
3281
3300
  height: calc(100dvh - var(--wave-docs-chrome-offset));
3282
- overflow-y: auto;
3283
- /* Without this, reaching the end of the nav keeps scrolling — the article
3284
- * jumps under the reader's cursor while they are looking at the sidebar. */
3285
- overscroll-behavior: contain;
3286
3301
  /*
3287
3302
  * ⚠️ NO `scrollbar-gutter: stable`, AND IT USED TO HAVE ONE.
3288
3303
  *
@@ -3299,11 +3314,196 @@
3299
3314
  *
3300
3315
  * `thin`, so the scrollbar that does appear is narrow, and coloured rather
3301
3316
  * than left as the UA's default slab against the panel.
3317
+ *
3318
+ * ⚠️ AND IT IS HIDDEN ONLY WHERE SOMETHING REPLACES IT — see the `@supports`
3319
+ * block below. A bar is the one cue that a column has more below it, so
3320
+ * taking it away before the shadow exists trades a slab for nothing.
3302
3321
  */
3303
3322
  scrollbar-width: thin;
3304
3323
  scrollbar-color: var(--wave-docs-border-strong) transparent;
3305
3324
  }
3306
3325
 
3326
+ .wave-docs-layout__sidebar-scroll {
3327
+ height: 100%;
3328
+ overflow-y: auto;
3329
+ /* Without this, reaching the end of the nav keeps scrolling — the article
3330
+ * jumps under the reader's cursor while they are looking at the sidebar. */
3331
+ overscroll-behavior: contain;
3332
+ padding: 2rem 1rem;
3333
+ scrollbar-width: inherit;
3334
+ scrollbar-color: inherit;
3335
+ }
3336
+
3337
+ /*
3338
+ * ⚠️ ABSOLUTE AGAINST THE PANEL, WHICH IS NOT THE SCROLLER — AND THAT IS THE
3339
+ * WHOLE REASON THIS FINALLY HOLDS STILL.
3340
+ *
3341
+ * These were `position: sticky` inside the scroll container, cancelling its
3342
+ * padding by hand, and they still read as moving. An absolutely positioned
3343
+ * child of a scroll container is laid out against that container's padding
3344
+ * box and joins its scrollable overflow, so nothing placed inside the
3345
+ * scroller is ever truly pinned to the panel; sticky only clamps it, and a
3346
+ * clamp is still a thing that travels until it catches.
3347
+ *
3348
+ * With the overflow moved to `.wave-docs-layout__sidebar-scroll`, the panel is
3349
+ * an ordinary positioned box. `top: 0` and `bottom: 0` are its own edges, and
3350
+ * there is no scroll for these to have a position within.
3351
+ *
3352
+ * ⚠️ AND THE SHADOW STILL BELONGS TO THE PANEL RATHER THAN THE TREE, because
3353
+ * `.wave-docs-sidebar` paints an opaque ground — it is in the theme's opt-in
3354
+ * rule, and the shell has to be one surface — so a background on the scroller
3355
+ * would be painted underneath it. Measured 255/255 when it was.
3356
+ */
3357
+ .wave-docs-layout__sidebar-nav::before,
3358
+ .wave-docs-layout__sidebar-nav::after {
3359
+ content: '';
3360
+ position: absolute;
3361
+ inset-inline: 0;
3362
+ z-index: 1;
3363
+ height: 0.75rem;
3364
+ opacity: 0;
3365
+ pointer-events: none;
3366
+ }
3367
+
3368
+ .wave-docs-layout__sidebar-nav::before {
3369
+ top: 0;
3370
+ /*
3371
+ * Radial, the shape the table and the search list use: strongest against
3372
+ * the edge it belongs to and falling away from it, rather than a flat ramp
3373
+ * across the band.
3374
+ */
3375
+ background: radial-gradient(
3376
+ farthest-side at 50% 0,
3377
+ var(--wave-docs-scroll-shadow),
3378
+ transparent
3379
+ );
3380
+ }
3381
+
3382
+ .wave-docs-layout__sidebar-nav::after {
3383
+ bottom: 0;
3384
+ background: radial-gradient(
3385
+ farthest-side at 50% 100%,
3386
+ var(--wave-docs-scroll-shadow),
3387
+ transparent
3388
+ );
3389
+ }
3390
+
3391
+ /*
3392
+ * ⚠️ AN INACTIVE TIMELINE IS WHAT MAKES THIS CORRECT. A scroll timeline is
3393
+ * inactive when its container has no scrollable overflow in the axis, and an
3394
+ * animation with an inactive timeline does not apply — so `opacity: 0` wins
3395
+ * and a nav that fits shows nothing at all. No query asks whether it scrolls;
3396
+ * the answer falls out of whether there is anything to scroll.
3397
+ *
3398
+ * ⚠️ `nearest`, NOT `self`: the animation is on a pseudo-element *inside* the
3399
+ * scroll container rather than on the container itself, and `self` would name
3400
+ * the pseudo — inactive at every height, which looks exactly like the feature
3401
+ * being unsupported.
3402
+ *
3403
+ * The scrollbar is hidden in here and nowhere else. Where this does not run —
3404
+ * Firefox, until scroll-driven animations ship — the thin bar above stays and
3405
+ * a reader keeps the cue rather than losing both.
3406
+ */
3407
+ @supports (animation-timeline: scroll()) {
3408
+ .wave-docs-layout__sidebar-scroll {
3409
+ scrollbar-width: none;
3410
+ }
3411
+
3412
+ .wave-docs-layout__sidebar-scroll::-webkit-scrollbar {
3413
+ display: none;
3414
+ }
3415
+
3416
+ /*
3417
+ * ⚠️ A NAMED TIMELINE, AND `scroll(nearest block)` WAS A REAL BUG RATHER
3418
+ * THAN A TIDIER SPELLING.
3419
+ *
3420
+ * `nearest` means the nearest ancestor *scroll container*, and a box is
3421
+ * only one when it actually has scrollable overflow. This nav is a screen
3422
+ * tall and on most sites its tree fits — measured `scrollHeight -
3423
+ * clientHeight === 0` at 1440×900 on this very site — so `nearest` walked
3424
+ * straight past it and found the document, which always scrolls.
3425
+ *
3426
+ * The result was the shadow keyed to the *page*: it faded in as the reader
3427
+ * scrolled the article, on a navigation with nothing hidden, while the
3428
+ * sticky panel was still travelling to its pinned position. Appearing and
3429
+ * moving at once, which is precisely what it looked like.
3430
+ *
3431
+ * Named, it can only ever be this element's own scroll. And the
3432
+ * inactive-timeline rule then does the right thing for free: a nav that
3433
+ * fits declares a timeline with no scroll range, the animation does not
3434
+ * apply, and `opacity: 0` wins.
3435
+ */
3436
+ /*
3437
+ * ⚠️ THE TIMELINE IS DECLARED ON THE SCROLLER AND *SCOPED* UP TO THE PANEL,
3438
+ * because the two are different elements now. A `scroll-timeline` name is
3439
+ * visible to the declaring element's descendants, and these pseudo-elements
3440
+ * belong to its parent — so without `timeline-scope` they would not find it
3441
+ * and would sit at `opacity: 0` for ever, looking exactly like the feature
3442
+ * being unsupported.
3443
+ */
3444
+ .wave-docs-layout__sidebar-nav {
3445
+ timeline-scope: --wave-docs-sidebar-nav-scroll;
3446
+ }
3447
+
3448
+ .wave-docs-layout__sidebar-scroll {
3449
+ scroll-timeline: --wave-docs-sidebar-nav-scroll block;
3450
+ }
3451
+
3452
+ .wave-docs-layout__sidebar-nav::before,
3453
+ .wave-docs-layout__sidebar-nav::after {
3454
+ animation-timeline: --wave-docs-sidebar-nav-scroll;
3455
+ animation-timing-function: linear;
3456
+ animation-fill-mode: both;
3457
+ }
3458
+
3459
+ /*
3460
+ * ⚠️ THE RANGE IS A LENGTH, NOT THE WHOLE SCROLL — AND THAT IS WHAT STOPS
3461
+ * THIS RAMPING WHILE THE READER SCROLLS.
3462
+ *
3463
+ * `wave-docs-scroll-shadow-start` shapes its fade in *percentages*, which
3464
+ * are percentages of the container's total scroll range. That is fine on a
3465
+ * table, which scrolls a few hundred pixels; on a navigation it is not. A
3466
+ * nav with 1000px of scroll turns the same `2%…8%` into 20px…80px, so the
3467
+ * shadow spends eighty pixels of scrolling getting to full strength —
3468
+ * arriving gradually, which reads exactly like it is moving with the
3469
+ * content rather than pinned to the edge.
3470
+ *
3471
+ * A fixed `1rem` makes it the same short distance whatever the nav's
3472
+ * height: present almost at once, and identical on a six-page site and a
3473
+ * three-hundred-page one. The keyframes are plain fades because the shaping
3474
+ * now lives in the range.
3475
+ */
3476
+ .wave-docs-layout__sidebar-nav::before {
3477
+ animation-name: wave-docs-scroll-shadow-appear;
3478
+ animation-range: 0 1rem;
3479
+ }
3480
+
3481
+ .wave-docs-layout__sidebar-nav::after {
3482
+ animation-name: wave-docs-scroll-shadow-vanish;
3483
+ animation-range: calc(100% - 1rem) 100%;
3484
+ }
3485
+ }
3486
+
3487
+ /* Absent at the start edge, present as soon as it is not. */
3488
+ @keyframes wave-docs-scroll-shadow-appear {
3489
+ from {
3490
+ opacity: 0;
3491
+ }
3492
+ to {
3493
+ opacity: 1;
3494
+ }
3495
+ }
3496
+
3497
+ /* Present until the end edge is reached. */
3498
+ @keyframes wave-docs-scroll-shadow-vanish {
3499
+ from {
3500
+ opacity: 1;
3501
+ }
3502
+ to {
3503
+ opacity: 0;
3504
+ }
3505
+ }
3506
+
3307
3507
  /* First child of the navigation, above the tree. */
3308
3508
  .wave-docs-layout__search {
3309
3509
  margin-block-end: 1rem;
@@ -3913,34 +4113,87 @@
3913
4113
  box-shadow: 0 24px 48px -12px var(--wave-docs-shadow);
3914
4114
  }
3915
4115
 
4116
+ /*
4117
+ * ⚠️ A FIELD SET INTO THE DIALOG, AND IT WAS A FULL-BLEED BAND WITH TWO
4118
+ * ROUNDED CORNERS AND A RULE UNDER IT.
4119
+ *
4120
+ * Flush to the frame it could only round the corners it shared with the
4121
+ * dialog, so it read as part of the chrome rather than as the one control in
4122
+ * it. Inset by the same margin the results list uses, it is a box like they
4123
+ * are, and the rule under it is redundant — the gap does that work.
4124
+ *
4125
+ * ⚠️ AND THE `- 1px` IS THIS BOX'S OWN BORDER, WHICH IS WHAT PUTS ITS GLYPH
4126
+ * ON THE SAME COLUMN AS EVERYTHING ELSE. Content inside a bordered box starts
4127
+ * a border further in than content inside an unbordered one, so equal padding
4128
+ * misses by exactly that. The same subtraction `--wave-docs-panel-inset`
4129
+ * documents, for the same reason.
4130
+ */
3916
4131
  .wave-docs-search-input-row {
3917
4132
  display: flex;
3918
4133
  align-items: center;
3919
4134
  gap: 0.5rem;
3920
- padding: 0.75rem;
3921
- border-block-end: 1px solid var(--wave-docs-border);
3922
- /* Matches the dialog's corners, so the focus ring below traces them
3923
- * instead of poking out of the rounded frame. */
3924
- border-start-start-radius: var(--wave-docs-radius);
3925
- border-start-end-radius: var(--wave-docs-radius);
3926
- }
3927
-
3928
- /* The ring belongs to the row, not the input. A 2px ring drawn around a
3929
- * borderless full-width input reads as an error state, and the previous
3930
- * answer "the dialog frame is the indicator" was a static 1.31:1 border
3931
- * that looks identical focused and unfocused (WCAG 2.4.7 wants a change of
3932
- * appearance, 1.4.11 wants 3:1; `accent` on `bg` is 5.16:1 / 7.50:1).
3933
- * `outline-offset: -2px` draws it inward, because the dialog clips with
3934
- * `overflow: hidden` and an outward ring would be sliced off. */
4135
+ margin: 0.375rem;
4136
+ /*
4137
+ * ⚠️ THE TRIGGER, EXPANDED SAME BORDER, SAME FILL, SAME RADIUS.
4138
+ *
4139
+ * A reader clicks a bordered, filled control and lands in this dialog, so
4140
+ * the thing their cursor is now in should be the same object. It was a
4141
+ * border with no fill (two frames a few pixels apart), then a fill with no
4142
+ * border (a tinted band, not a control); the trigger has always been both,
4143
+ * and both together are what reads as a field.
4144
+ *
4145
+ * ⚠️ THE PADDING IS NOT COPIED WITH THEM. The trigger pays
4146
+ * `calc(0.5rem - 1px)` because it is a compact control in a sidebar; this
4147
+ * pays `calc(0.75rem - 1px)` because its glyph has to land on the column
4148
+ * the results and the footer sit on. The `- 1px` is the border either way:
4149
+ * content inside a bordered box starts a border further in.
4150
+ *
4151
+ * ⚠️ AND THE RADIUS IS THE TRIGGER'S TIER, NOT THE ROWS'. Both are controls
4152
+ * on the base tier; a result row is a list item on `-sm`. So the field is
4153
+ * 16px and the rows below it are 12px, which is the tier system doing what
4154
+ * it says rather than two boxes disagreeing.
4155
+ */
4156
+ padding: 0.5rem calc(0.75rem - 1px);
4157
+ border: 1px solid var(--wave-docs-border);
4158
+ border-radius: var(--wave-docs-radius);
4159
+ background: var(--wave-docs-bg-subtle);
4160
+ }
4161
+
4162
+ /*
4163
+ * ⚠️ THE FIELD DARKENS ITS EDGE ON FOCUS — IT DOES NOT DRAW AN ACCENT RING,
4164
+ * AND IT CANNOT, BECAUSE IT COULD NEVER PUT ONE DOWN.
4165
+ *
4166
+ * A text input matches `:focus-visible` whenever it is focused, however
4167
+ * focus arrived — that is the spec, not a heuristic — and this dialog focuses
4168
+ * its input the moment it opens. So a 2px accent ring here is not a state, it
4169
+ * is the field's permanent appearance, and the field stops looking like the
4170
+ * bordered grey control the reader clicked to get here.
4171
+ *
4172
+ * ⚠️ AND THE INDICATOR IS NOT LOST WITH IT, BECAUSE A TEXT FIELD HAS ONE OF
4173
+ * ITS OWN. The caret is the platform's focus indication for a text box, it is
4174
+ * in this field the whole time the dialog is open, and it is the thing a
4175
+ * reader looks for when they want to know where typing goes. That is a
4176
+ * different argument from the one this rule used to reject — "the dialog
4177
+ * frame is the indicator" was a *static* border that looked identical focused
4178
+ * and unfocused, indicating nothing.
4179
+ *
4180
+ * The edge still changes, to the same `border-strong` the trigger takes under
4181
+ * the pointer, so the field is the trigger in both of its states.
4182
+ */
3935
4183
  .wave-docs-search-input-row:has(.wave-docs-search-input:focus-visible) {
3936
- outline: 2px solid var(--wave-docs-accent);
3937
- outline-offset: -2px;
4184
+ border-color: var(--wave-docs-border-strong);
3938
4185
  }
3939
4186
 
3940
4187
  .wave-docs-search-input {
3941
4188
  flex: 1 1 auto;
3942
4189
  min-width: 0;
3943
- padding: 0.375rem 0.25rem;
4190
+ /*
4191
+ * ⚠️ NO INLINE PADDING, BECAUSE THIS BOX'S EDGE IS A COLUMN THE RESULTS
4192
+ * MEASURE TO. Four pixels here put the typed text four pixels right of
4193
+ * every result heading below it, which is the sort of misalignment that
4194
+ * looks like a bug in the list rather than a padding on the field.
4195
+ */
4196
+ padding: 0.375rem 0;
3944
4197
  border: 0;
3945
4198
  background: none;
3946
4199
  color: var(--wave-docs-fg);
@@ -3973,7 +4226,18 @@
3973
4226
  display: flex;
3974
4227
  align-items: center;
3975
4228
  gap: 1rem;
3976
- padding: 0.5rem 0.75rem;
4229
+ /*
4230
+ * ⚠️ THE COLUMN EVERY OTHER ROW IN THIS DIALOG LINES UP ON, AND IT IS THE
4231
+ * ANCHOR RATHER THAN A FOLLOWER. The key caps are the one row whose left
4232
+ * edge is a drawn object, so the input's glyph and the results' text are
4233
+ * measured to it: `0.375rem` of inset plus `0.75rem` of padding is the same
4234
+ * 18px this pays in one go.
4235
+ *
4236
+ * Boxes, not ink. A cap's arrow sits its own border and `0.4em` of padding
4237
+ * inside the cap, so aligning the *glyphs* would put every other row on a
4238
+ * column that moves whenever the footer's font size does.
4239
+ */
4240
+ padding: 0.5rem 1.125rem;
3977
4241
  border-block-start: 1px solid var(--wave-docs-border);
3978
4242
  color: var(--wave-docs-fg-subtle);
3979
4243
  font-size: 0.75rem;
@@ -4059,7 +4323,75 @@
4059
4323
  .wave-docs-search-results {
4060
4324
  flex: 1 1 auto;
4061
4325
  overflow-y: auto;
4062
- padding: 0.375rem;
4326
+ /*
4327
+ * ⚠️ THE TABLE'S SCROLL SHADOW, TURNED ON ITS SIDE — AND IT REPLACED A
4328
+ * MASK, WHICH WAS THE WRONG TOOL TWICE OVER.
4329
+ *
4330
+ * A mask fades content to *transparent*, so what showed through was the
4331
+ * dialog's own white: a hole rather than a shadow. And it cannot be
4332
+ * conditional — CSS has no way to ask whether there is anything above to
4333
+ * scroll to — so the first and last rows were softened even at rest.
4334
+ *
4335
+ * Four gradients, exactly as `.wave-docs-table-scroll` does it. The two
4336
+ * `local` covers are painted in the surface colour and travel *with* the
4337
+ * rows, so each sits over its shadow only while that edge is at rest; the
4338
+ * two `scroll` shadows are pinned to the box. A grey edge appears on
4339
+ * precisely the side that has rows off-screen, with no listener, no state
4340
+ * and no hydration.
4341
+ */
4342
+ background:
4343
+ linear-gradient(to bottom, var(--wave-docs-bg) 30%, transparent) top /
4344
+ 100% 2rem no-repeat local,
4345
+ linear-gradient(to top, var(--wave-docs-bg) 30%, transparent) bottom /
4346
+ 100% 2rem no-repeat local,
4347
+ radial-gradient(
4348
+ farthest-side at 50% 0,
4349
+ var(--wave-docs-scroll-shadow),
4350
+ transparent
4351
+ )
4352
+ top / 100% 0.75rem no-repeat scroll,
4353
+ radial-gradient(
4354
+ farthest-side at 50% 100%,
4355
+ var(--wave-docs-scroll-shadow),
4356
+ transparent
4357
+ )
4358
+ bottom / 100% 0.75rem no-repeat scroll;
4359
+ /*
4360
+ * ⚠️ NO PADDING ON TOP, BECAUSE THE FIELD'S MARGIN IS ALREADY THAT GAP.
4361
+ * Both paid it, so the space between the input and the first result was
4362
+ * twice the space between the input and the top of the dialog — a
4363
+ * doubled gap in the one place a reader's eye travels on every keystroke.
4364
+ *
4365
+ * Kept on the *bottom*, where nothing else pays it, and on the sides,
4366
+ * where it is what lines a row's box up with the field's.
4367
+ *
4368
+ * ⚠️ AND IT IS THE FIELD THAT KEEPS ITS MARGIN RATHER THAN THIS THAT KEEPS
4369
+ * ITS PADDING. An empty result list is `display: none`, so a gap paid from
4370
+ * here would vanish on the query that matches nothing and leave the field
4371
+ * sitting on the footer's rule.
4372
+ */
4373
+ padding: 0 0.375rem 0.375rem;
4374
+ /*
4375
+ * ⚠️ NO SCROLLBAR, AND NO CSS WAY TO SHOW ONE ONLY WHILE SCROLLING.
4376
+ *
4377
+ * That behaviour is the platform's: macOS draws overlay scrollbars that
4378
+ * fade in on scroll and out after it, and this list gets it for free there.
4379
+ * Windows and Linux draw a classic one that is always present, and nothing
4380
+ * in CSS can make it come and go — the only ways are a JavaScript timer
4381
+ * that toggles a class, or a scrollbar drawn from scratch in script, which
4382
+ * is what a `ScrollArea` component is. Neither belongs in a package whose
4383
+ * whole argument is what it does *not* ship to a reader.
4384
+ *
4385
+ * So it is hidden, which is what `@waveso/app` does with `.scrollbar-none`.
4386
+ * What replaces it here is not a fade but the keyboard: the footer says
4387
+ * `↑ ↓ Select`, the list is driven by `aria-activedescendant`, and arrowing
4388
+ * past the last visible row scrolls it and loads the next page.
4389
+ */
4390
+ scrollbar-width: none;
4391
+ }
4392
+
4393
+ .wave-docs-search-results::-webkit-scrollbar {
4394
+ display: none;
4063
4395
  }
4064
4396
 
4065
4397
  /*
@@ -4076,29 +4408,104 @@
4076
4408
  border-radius: var(--wave-docs-radius-sm);
4077
4409
  }
4078
4410
 
4079
- /* Driven by `aria-selected` through the active class the dialog sets — the
4080
- * combobox keeps focus in the input and every result is `tabindex="-1"`, so
4081
- * `:focus-visible` can never fire here and the tint IS the whole indicator.
4082
- * A tint alone is 1.12:1 light / 1.19:1 dark; WCAG 1.4.11 asks 3:1 of a
4083
- * state indicator, so the outline carries it (`accent` on `accent-subtle` is
4084
- * 4.60:1 / 6.30:1) and the tint is left to do the soft work. Inset by 2px so
4085
- * it stays inside the scrolling results list. */
4411
+ /*
4412
+ * The active row, driven by `aria-selected` through the class the dialog
4413
+ * sets so the visual state and the announced state cannot drift apart.
4414
+ * Pointer and keyboard land on the same state: moving over a row activates
4415
+ * it, so hovering *is* this.
4416
+ *
4417
+ * ⚠️ A TINT AND AN INK, AND NO EDGE IN ANY STATE. It was a 2px accent ring,
4418
+ * and briefly the trigger's border pair. The ring read as a component
4419
+ * borrowed from somewhere else and one that comes and goes as a reader
4420
+ * arrows is worse than one that never moves; bordering *every* row to make
4421
+ * the active one's edge legible turned a list into a stack of cards. The
4422
+ * field above wears the trigger's border because the field is a control; a
4423
+ * row is a list item, and its state is a colour.
4424
+ *
4425
+ * ⚠️ THE INK IS NOT DECORATION — IT IS WHAT KEEPS THE STATE PERCEIVABLE. A
4426
+ * tint alone is 1.12:1 light and 1.19:1 dark, under the 3:1 WCAG 1.4.11 asks
4427
+ * of a state indicator. `accent` on `accent-subtle` is 4.60:1 / 6.30:1, which
4428
+ * is text contrast rather than non-text and is the same pair the sidebar's
4429
+ * current-page row has always shipped. What must not happen is the tint going
4430
+ * back to carrying this alone.
4431
+ */
4086
4432
  .wave-docs-search-result-active {
4087
4433
  background: var(--wave-docs-accent-subtle);
4088
- outline: 2px solid var(--wave-docs-accent);
4089
- outline-offset: -2px;
4090
4434
  }
4091
4435
 
4436
+ .wave-docs-search-result-active .wave-docs-search-result-heading,
4437
+ .wave-docs-search-result-active .wave-docs-search-result-icon {
4438
+ color: var(--wave-docs-accent);
4439
+ }
4440
+
4441
+ /*
4442
+ * ⚠️ A ROW OF TWO COLUMNS, AND IT WAS ONE STACKED COLUMN.
4443
+ *
4444
+ * The dialog reads as one grid only if the input and the results agree about
4445
+ * *both* their columns. With no icon, a result's heading sat on the same
4446
+ * column as the input's magnifier while the input's own text sat 24px right
4447
+ * of it — three left edges where a reader expects two, which is what made the
4448
+ * dialog look like stacked levels rather than a list under a field.
4449
+ *
4450
+ * The icon takes the magnifier's column and the text takes the input's, so
4451
+ * the two rows are the same shape and there are exactly two edges.
4452
+ */
4092
4453
  .wave-docs-search-result-link {
4093
4454
  display: flex;
4094
- flex-direction: column;
4095
- gap: 0.125rem;
4096
- padding: 0.5rem 0.625rem;
4455
+ align-items: flex-start;
4456
+ /* The input's own gap between its glyph and its text. */
4457
+ gap: 0.5rem;
4458
+ /* The list pays `0.375rem` around this, so `0.75rem` here lands the icon
4459
+ * on the dialog's 18px column. See the footer, which is the anchor. */
4460
+ padding: 0.5rem 0.75rem;
4097
4461
  border-radius: var(--wave-docs-radius-sm);
4098
4462
  color: var(--wave-docs-fg);
4099
4463
  text-decoration: none;
4100
4464
  }
4101
4465
 
4466
+ .wave-docs-search-result-icon {
4467
+ flex: none;
4468
+ /*
4469
+ * ⚠️ NUDGED DOWN, NOT CENTRED. `align-items: center` would centre this on
4470
+ * the *two-line* block, so it would sit between the heading and the route
4471
+ * rather than beside the heading — and it would move when a route wrapped.
4472
+ * A 1rem glyph on a 0.9375rem/1.4 line is 5px shorter than the line box, so
4473
+ * half of that puts it on the heading's optical centre and keeps it there.
4474
+ */
4475
+ margin-block-start: 0.15625rem;
4476
+ color: var(--wave-docs-fg-subtle);
4477
+ }
4478
+
4479
+ .wave-docs-search-result-text {
4480
+ display: flex;
4481
+ flex-direction: column;
4482
+ gap: 0.125rem;
4483
+ /* So a long heading ellipsises inside the row rather than widening it. */
4484
+ min-width: 0;
4485
+ }
4486
+
4487
+ /*
4488
+ * The separator between two route segments — the package's own chevron
4489
+ * rather than a character, so there is no font for it to be missing from.
4490
+ *
4491
+ * The `viewBox` is cropped to the painted ink, so the box is the glyph: it
4492
+ * sits on the baseline the way a letter does, with no alignment to correct.
4493
+ */
4494
+ .wave-docs-search-result-sep {
4495
+ height: 7px;
4496
+ width: auto;
4497
+ /*
4498
+ * The `viewBox` is exactly the stroke's extent, so the tip lands on the
4499
+ * box's own edge and an SVG clips at that edge by default — the point came
4500
+ * out flat. Nothing here overlaps: what spills is a fraction of a pixel of
4501
+ * anti-aliasing.
4502
+ */
4503
+ overflow: visible;
4504
+ /* The air either side. */
4505
+ margin-inline: 4px;
4506
+ color: var(--wave-docs-fg-subtle);
4507
+ }
4508
+
4102
4509
  .wave-docs-search-result-heading {
4103
4510
  font-size: 0.9375rem;
4104
4511
  font-weight: 600;
@@ -4133,10 +4540,21 @@
4133
4540
  white-space: nowrap;
4134
4541
  text-overflow: ellipsis;
4135
4542
  color: var(--wave-docs-fg-subtle);
4136
- font-family: var(--wave-docs-font-mono);
4137
- /* Smaller than the trail was: a monospace face reads larger at the same
4138
- * size, and this line is a reference rather than something to read. */
4139
- font-size: 0.6875rem;
4543
+ /*
4544
+ * The package's sans, the same face as everything else in the dialog.
4545
+ *
4546
+ * ⚠️ IT WAS MONOSPACE, AND THAT IS WHAT MADE THE SEPARATOR UNRELIABLE. A
4547
+ * mono face advances every glyph one cell, so the trail sat on a rigid
4548
+ * grid an icon could not join, and its metrics are whatever the machine
4549
+ * resolves — SF Mono here, Consolas on Windows, Liberation Mono on Linux —
4550
+ * so the cap height the chevron is sized against moved from reader to
4551
+ * reader. Set in the sans, the line has ordinary proportional spacing and
4552
+ * one set of metrics to match.
4553
+ */
4554
+ font-family: var(--wave-docs-font-sans);
4555
+ /* A notch up from the mono's `0.6875rem`: a monospace face reads larger at
4556
+ * the same size, so holding the number would have shrunk this line. */
4557
+ font-size: 0.75rem;
4140
4558
  line-height: 1.5;
4141
4559
  }
4142
4560
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waveso/docs",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Zero parser bytes in the browser: markdown docs for Next.js, built to hast in Node and rendered as your components",
5
5
  "type": "module",
6
6
  "sideEffects": [