@waveso/docs 0.3.0 → 0.4.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,33 @@
1
1
  # @waveso/docs
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - The search dialog, corrected by using it. Five defects a reader meets and no test could see, and one rename that follows from the largest of them.
8
+
9
+ **`maxResults` is now `pageSize`, and it is a window rather than a ceiling.** It capped the list at 20 (8 in 0.2.0): on a _six-page_ site "docs" matches 18, so results were unreachable, and the live region announced the slice as though it were the total — not a smaller truth but a false one.
10
+
11
+ The cap was justified by a claim nobody had measured, and measuring it did not support the claim. On a 300-page corpus (2,100 records) a MiniSearch query costs **1.3–3.0 ms**, and rendering _every_ matching row costs **40 ms**, 128 ms at 4× CPU throttle. The search was never the cost; the DOM only becomes one in the thousands.
12
+
13
+ So the list pages. Twenty rows render, and another twenty each time the reader scrolls near the end — the DOM stays bounded and nothing is withheld. The keyboard widens the window too, or `aria-activedescendant` points at an option that is not in the DOM. `aria-setsize` carries the real total on every option, because a listbox rendering 20 of 2,100 that says "20 of 20" tells a reader they have reached the end when they have not.
14
+
15
+ **Migration:** rename the prop. `maxResults={20}` becomes `pageSize={20}`, and it now means "reveal this many at a time" rather than "never show more than this".
16
+
17
+ **The dialog sizes to its results.** It was 32rem tall in every state — measured 514px with no query, 514px with eight results, 514px with none, of which 392px was an empty results area, so a reader typed into a box floating at the top of a large blank rectangle. A flex container defaults to `align-items: stretch`, so the dialog stretched to the viewport and `max-height` capped it at a constant instead of being the ceiling it was written to be. It is content-height now, and starts scrolling at the same 32rem.
18
+
19
+ **Hovering a half-visible result no longer yanks the list.** Pointing at a row clipped by the top or bottom edge set the active option, which fired the scroll-into-view meant for the arrow keys: the row snapped flush, the whole list moved under the cursor, and the cursor was then over a different row. Measured as a 28px jump. Only the keyboard scrolls now, and a new result set explicitly returns to the top — which that effect had been doing by accident.
20
+
21
+ **Every result row says where it lands, in the same words.** The second line was a breadcrumb of page and heading names, except on a page's own record — whose heading _is_ its page title — which got no second line at all rather than repeat itself. On a six-page site that is six of twenty-nine records, so the list came out ragged and the barest rows said the least: a row reading only "Wave Docs" told a reader nothing about what it opened.
22
+
23
+ Every row now shows the page it lands on: the route, without its anchor. The anchor is slugged from the heading printed directly above it, so it spent the line restating line one. The link keeps it, so a hit still deep-links to its section.
24
+
25
+ **Breaking in rendered output:** `.wave-docs-search-result-breadcrumb`, `.wave-docs-search-result-crumb` and `.wave-docs-search-result-crumb-separator` are replaced by a single `.wave-docs-search-result-location`, because a breadcrumb it is not. A screen reader still hears the words — a route read aloud is punctuation — so the option's `aria-label` carries "Layout tokens, Styling" while the visible line carries the address.
26
+
27
+ **A query is at least two characters**, settable with `minQueryLength`. Measured on this package's own documentation: `a` matches 100% of the corpus, `i` 97%, `s` 93%. One character is not a query, it is a reader halfway through typing one, and answering it with everything teaches them that search returns noise. Below the floor nothing runs — no search, no index request — and the dialog says "Keep typing" rather than sitting there answering nothing.
28
+
29
+ Two rather than three, and the difference matters on a docs site: three would refuse `ts`, `js`, `id`, `h1` and `px`, each a real query here and each selective — 10%, 17%, 14%, 3%, 0%. The noise is at one character, so that is where the floor goes.
30
+
3
31
  ## 0.3.0
4
32
 
5
33
  ### Minor Changes
@@ -26,8 +26,39 @@ interface SearchDialogProps {
26
26
  placeholder?: string | undefined;
27
27
  /** Accessible name for the dialog. Defaults to `'Search documentation'`. */
28
28
  dialogLabel?: string | undefined;
29
- /** Maximum results rendered. Defaults to 8. */
30
- maxResults?: number | undefined;
29
+ /**
30
+ * How many results to render at a time. Defaults to 20.
31
+ *
32
+ * ⚠️ NOT A CAP. Every match is reachable — the list renders this many, then
33
+ * another `pageSize` each time the reader scrolls near the end, so the DOM
34
+ * stays bounded without anything being withheld.
35
+ *
36
+ * This was `maxResults`, and it was a hard ceiling of 8. On a *six-page*
37
+ * site "docs" matches 18, so ten results simply could not be reached, and
38
+ * the live region announced "8 results" — not a smaller truth but a false
39
+ * one. The ceiling was justified by a claim nobody had measured, and the
40
+ * measurement did not support it: on a 300-page corpus (2,100 records) a
41
+ * query costs 1.3–3.0 ms and rendering *every* row costs 40 ms, 128 ms at
42
+ * 4x CPU throttle. Paging exists to keep that worst case from ever being
43
+ * reached, not because the search cannot find things.
44
+ */
45
+ pageSize?: number | undefined;
46
+ /**
47
+ * Shortest query that runs. Defaults to 2.
48
+ *
49
+ * A single character is not a query — measured on this package's own docs,
50
+ * "a" matches 100% of the corpus, "i" 97%, "s" 93%. Answering those wastes a
51
+ * render and, worse, teaches a reader mid-word that search returns noise.
52
+ *
53
+ * ⚠️ TWO, NOT THREE, AND THE DIFFERENCE MATTERS ON A DOCS SITE. Three would
54
+ * refuse `ts`, `js`, `id`, `h1`, `px` — every one a real query here, and each
55
+ * one selective: 10%, 17%, 14%, 3%, 0%. The noise is at one character, so
56
+ * that is where the floor goes.
57
+ *
58
+ * A word like `is` still matches 83%; that is a stopword problem rather than
59
+ * a length one, and `miniSearchOptions.processTerm` is the tool for it.
60
+ */
61
+ minQueryLength?: number | undefined;
31
62
  /** Input debounce in milliseconds. Defaults to 120. */
32
63
  debounceMs?: number | undefined;
33
64
  /** Extra class names for the trigger button, e.g. a navbar's own layout. */
@@ -64,6 +95,6 @@ interface SearchDialogProps {
64
95
  * portalled to `document.body`, so a navbar's stacking context cannot trap
65
96
  * it behind the page.
66
97
  */
67
- declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, maxResults, debounceMs, className, miniSearchOptions }: SearchDialogProps): ReactNode;
98
+ declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions }: SearchDialogProps): ReactNode;
68
99
  //#endregion
69
100
  export { SearchDialog, SearchDialogProps };
@@ -26,10 +26,31 @@ const FOCUSABLE_SELECTOR = [
26
26
  * portalled to `document.body`, so a navbar's stacking context cannot trap
27
27
  * it behind the page.
28
28
  */
29
- function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", maxResults = 8, debounceMs = 120, className, miniSearchOptions }) {
29
+ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions }) {
30
30
  const [isOpen, setIsOpen] = useState(false);
31
31
  const [query, setQuery] = useState("");
32
32
  const [hits, setHits] = useState([]);
33
+ /**
34
+ * How many of `hits` are rendered.
35
+ *
36
+ * `hits` holds every match; this is the window. It grows by `pageSize` when
37
+ * the reader scrolls near the end, and whenever the keyboard walks past it —
38
+ * so an option always exists for `aria-activedescendant` to point at.
39
+ */
40
+ const [visibleCount, setVisibleCount] = useState(pageSize);
41
+ /**
42
+ * Whether the active option moved because of a key, rather than a pointer.
43
+ *
44
+ * ⚠️ THE SCROLL-INTO-VIEW BELOW MUST NOT RUN FOR A HOVER. Pointing at a row
45
+ * that is half-clipped by the top or bottom edge set the active index, which
46
+ * scrolled that row flush — moving the whole list under the cursor, which
47
+ * then landed on a different row. Measured: hovering the visible sliver of a
48
+ * clipped row jumped the list 28px.
49
+ *
50
+ * A ref rather than state: it records how the *last* change happened and must
51
+ * not itself cause a render.
52
+ */
53
+ const movedByKeyboard = useRef(false);
33
54
  const [activeIndex, setActiveIndex] = useState(0);
34
55
  const [status, setStatus] = useState("idle");
35
56
  const [shortcutHint, setShortcutHint] = useState("");
@@ -135,9 +156,10 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
135
156
  }, [isOpen]);
136
157
  useEffect(() => {
137
158
  const trimmed = query.trim();
138
- if (trimmed === "") {
159
+ if (trimmed.length < minQueryLength) {
139
160
  setHits([]);
140
161
  setActiveIndex(0);
162
+ setVisibleCount(pageSize);
141
163
  return;
142
164
  }
143
165
  let isCancelled = false;
@@ -145,8 +167,9 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
145
167
  ensureIndex().then((index) => {
146
168
  if (isCancelled) return;
147
169
  setStatus("ready");
148
- setHits(index.search(trimmed).slice(0, maxResults).map(toSearchHit).filter(isSearchHit));
170
+ setHits(index.search(trimmed).map(toSearchHit).filter(isSearchHit));
149
171
  setActiveIndex(0);
172
+ setVisibleCount(pageSize);
150
173
  }, () => {
151
174
  if (!isCancelled) setStatus("error");
152
175
  });
@@ -158,11 +181,55 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
158
181
  }, [
159
182
  query,
160
183
  ensureIndex,
161
- maxResults,
162
- debounceMs
184
+ pageSize,
185
+ debounceMs,
186
+ minQueryLength
163
187
  ]);
188
+ /**
189
+ * Reveal another page when the reader nears the end of the list.
190
+ *
191
+ * A scroll handler rather than an `IntersectionObserver` on a sentinel: the
192
+ * scrollport is one element this component already holds a ref to, the test
193
+ * is one subtraction, and an observer would cost bytes on the largest client
194
+ * entry this package ships for no behaviour the reader can tell apart.
195
+ *
196
+ * `passive`, because this never calls `preventDefault` and a non-passive
197
+ * scroll listener blocks the compositor on every wheel event.
198
+ *
199
+ * No `isOpen` dependency, though the list does not exist while the dialog is
200
+ * closed: the effect returns early on a null ref, and `hits.length` going
201
+ * from 0 to N re-runs it — which happens after the list has mounted, because
202
+ * closing resets the query. The listener attaches exactly when there is
203
+ * something to scroll.
204
+ */
205
+ useEffect(() => {
206
+ const list = listRef.current;
207
+ if (list === null || visibleCount >= hits.length) return;
208
+ const onScroll = () => {
209
+ if (list.scrollHeight - list.scrollTop - list.clientHeight < list.clientHeight) setVisibleCount((count) => Math.min(count + pageSize, hits.length));
210
+ };
211
+ list.addEventListener("scroll", onScroll, { passive: true });
212
+ return () => list.removeEventListener("scroll", onScroll);
213
+ }, [
214
+ visibleCount,
215
+ hits.length,
216
+ pageSize
217
+ ]);
218
+ useEffect(() => {
219
+ if (activeIndex >= visibleCount) setVisibleCount(Math.min(activeIndex + 1, hits.length));
220
+ }, [
221
+ activeIndex,
222
+ visibleCount,
223
+ hits.length
224
+ ]);
225
+ useEffect(() => {
226
+ const list = listRef.current;
227
+ if (list !== null && list.scrollTop !== 0) list.scrollTop = 0;
228
+ }, [hits]);
164
229
  useEffect(() => {
165
230
  if (hits.length === 0) return;
231
+ if (!movedByKeyboard.current) return;
232
+ movedByKeyboard.current = false;
166
233
  (listRef.current?.querySelector(`#${CSS.escape(`${baseId}-option-${activeIndex}`)}`))?.scrollIntoView({ block: "nearest" });
167
234
  }, [
168
235
  activeIndex,
@@ -179,6 +246,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
179
246
  if (hits.length === 0) return;
180
247
  event.preventDefault();
181
248
  const delta = event.key === "ArrowDown" ? 1 : -1;
249
+ movedByKeyboard.current = true;
182
250
  setActiveIndex((index) => (index + delta + hits.length) % hits.length);
183
251
  return;
184
252
  }
@@ -250,11 +318,16 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
250
318
  className: "wave-docs-search-results",
251
319
  role: "listbox",
252
320
  "aria-label": dialogLabel,
253
- children: hits.map((hit, index) => /* @__PURE__ */ jsx(SearchResultOption, {
321
+ children: hits.slice(0, visibleCount).map((hit, index) => /* @__PURE__ */ jsx(SearchResultOption, {
254
322
  hit,
255
323
  id: optionId(index),
256
324
  isActive: index === activeIndex,
257
- onActivate: () => setActiveIndex(index),
325
+ setSize: hits.length,
326
+ posInSet: index + 1,
327
+ onActivate: () => {
328
+ movedByKeyboard.current = false;
329
+ setActiveIndex(index);
330
+ },
258
331
  onSelect: selectHit,
259
332
  ...Link === void 0 ? {} : { Link }
260
333
  }, hit.id))
@@ -262,40 +335,36 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
262
335
  /* @__PURE__ */ jsx(SearchStatus, {
263
336
  status,
264
337
  query: query.trim(),
265
- hitCount: hits.length
338
+ hitCount: hits.length,
339
+ minQueryLength
266
340
  })
267
341
  ]
268
342
  })
269
343
  }), document.body) : null] });
270
344
  }
271
345
  /** One result row: a real link, so middle-click and "open in new tab" work. */
272
- function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
346
+ function SearchResultOption({ hit, id, isActive, setSize, posInSet, onActivate, onSelect, Link }) {
273
347
  function handleClick(event) {
274
348
  if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
275
349
  event.preventDefault();
276
350
  onSelect(hit);
277
351
  }
278
- const trail = toBreadcrumbs(hit);
279
352
  const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("span", {
280
353
  className: "wave-docs-search-result-heading",
281
354
  children: hit.heading
282
- }), trail.length === 0 ? null : /* @__PURE__ */ jsx("span", {
283
- className: "wave-docs-search-result-breadcrumb",
284
- children: trail.map((crumb, index) => /* @__PURE__ */ jsxs("span", {
285
- className: "wave-docs-search-result-crumb",
286
- children: [index === 0 ? null : /* @__PURE__ */ jsx("span", {
287
- className: "wave-docs-search-result-crumb-separator",
288
- "aria-hidden": "true",
289
- children: "›"
290
- }), crumb.text]
291
- }, crumb.key))
355
+ }), /* @__PURE__ */ jsx("span", {
356
+ className: "wave-docs-search-result-location",
357
+ "aria-hidden": "true",
358
+ children: toDisplayPath(hit.href)
292
359
  })] });
293
360
  return /* @__PURE__ */ jsx("div", {
294
361
  id,
295
362
  className: isActive ? "wave-docs-search-result wave-docs-search-result-active" : "wave-docs-search-result",
296
363
  role: "option",
297
364
  "aria-selected": isActive,
298
- "aria-label": [hit.heading, ...trail.map((crumb) => crumb.text)].join(", "),
365
+ "aria-setsize": setSize,
366
+ "aria-posinset": posInSet,
367
+ "aria-label": spokenName(hit),
299
368
  tabIndex: -1,
300
369
  onPointerMove: onActivate,
301
370
  children: Link === void 0 ? /* @__PURE__ */ jsx("a", {
@@ -314,7 +383,7 @@ function SearchResultOption({ hit, id, isActive, onActivate, onSelect, Link }) {
314
383
  });
315
384
  }
316
385
  /** Loading, failure and empty states, plus a live region for hit counts. */
317
- function SearchStatus({ status, query, hitCount }) {
386
+ function SearchStatus({ status, query, hitCount, minQueryLength }) {
318
387
  let message = null;
319
388
  let modifier = "";
320
389
  if (status === "error") {
@@ -323,6 +392,9 @@ function SearchStatus({ status, query, hitCount }) {
323
392
  } else if (query === "") {
324
393
  message = "Start typing to search the documentation.";
325
394
  modifier = " wave-docs-search-status-hint";
395
+ } else if (query.length < minQueryLength) {
396
+ message = `Keep typing — ${minQueryLength} characters or more.`;
397
+ modifier = " wave-docs-search-status-hint";
326
398
  } else if (status !== "ready") {
327
399
  message = "Loading the search index…";
328
400
  modifier = " wave-docs-search-status-loading";
@@ -380,24 +452,47 @@ function isSearchHit(hit) {
380
452
  return hit !== void 0;
381
453
  }
382
454
  /**
383
- * Page title first, then the ancestor headings: `ancestors` deliberately excludes
384
- * the page title so the index does not carry it twice.
455
+ * The route, without its anchor, for display only.
385
456
  *
386
- * A page's lead record carries `heading === title` and no ancestors (see
387
- * `extractSearchRecords`), so its trail would be the one string already printed
388
- * above it. "Installation" over "Installation" is not a path, it is a bug that
389
- * reads as a rendering glitch such a hit gets no trail at all.
457
+ * ⚠️ THE ANCHOR IS NOISE HERE, AND ALMOST ALWAYS A REPEAT. A section's anchor
458
+ * is slugged from its heading, so `/docs/styling#layout-tokens` under a row
459
+ * whose first line already reads "Layout tokens" spends its width restating
460
+ * it and on a real site it is the part that pushes the line past the
461
+ * ellipsis.
462
+ *
463
+ * What the line is for is "which page does this land on", and the path answers
464
+ * that on its own. Two rows from the same page showing the same path is not
465
+ * ambiguity: they *are* the same page, and their headings above say which part.
466
+ *
467
+ * Display only. `hit.href` keeps the anchor, so the link still deep-links to
468
+ * the section — that is the whole point of section-scoped records.
390
469
  */
391
- function toBreadcrumbs(hit) {
392
- if (hit.ancestors.length === 0 && hit.heading === hit.title) return [];
393
- let trail = "";
394
- return [hit.title, ...hit.ancestors].map((text) => {
395
- trail = trail === "" ? text : `${trail}/${text}`;
396
- return {
397
- key: trail,
398
- text
399
- };
400
- });
470
+ function toDisplayPath(href) {
471
+ const hash = href.indexOf("#");
472
+ return hash === -1 ? href : href.slice(0, hash);
473
+ }
474
+ /**
475
+ * What a result is called when it is read aloud.
476
+ *
477
+ * Words, not the route the row displays. `/docs/styling#layout-tokens` is
478
+ * punctuation to a screen reader — spelled out slash by slash — so the visible
479
+ * line and the announced name deliberately carry the same fact in two forms:
480
+ * the route for a sighted reader scanning for where a hit lands, and
481
+ * "Layout tokens, Styling" for a listener.
482
+ *
483
+ * `ancestors` deliberately excludes the page title, so the page comes first
484
+ * here and the enclosing headings follow, outermost first.
485
+ *
486
+ * A page's own record carries `heading === title` and no ancestors, so its name
487
+ * is the heading alone — "Styling, Styling" is not a path, it is a stutter.
488
+ */
489
+ function spokenName(hit) {
490
+ if (hit.ancestors.length === 0 && hit.heading === hit.title) return hit.heading;
491
+ return [
492
+ hit.heading,
493
+ hit.title,
494
+ ...hit.ancestors
495
+ ].join(", ");
401
496
  }
402
497
  function trapFocus(root, event) {
403
498
  if (root === null) return;
package/dist/styles.css CHANGED
@@ -1844,6 +1844,19 @@
1844
1844
  z-index: 50;
1845
1845
  display: flex;
1846
1846
  justify-content: center;
1847
+ /*
1848
+ * ⚠️ `flex-start`, OR THE DIALOG IS ALWAYS 32rem TALL. A flex container
1849
+ * defaults to `align-items: stretch`, so the dialog stretched to the full
1850
+ * viewport height and `max-height` then capped it — at a constant. Measured
1851
+ * before this line: 514px with no query, 514px with eight results, 514px
1852
+ * with none, of which 392px was an empty results area. The reader typed
1853
+ * into a box floating at the top of a large blank rectangle.
1854
+ *
1855
+ * Aligned to the start, the dialog is as tall as its content and the
1856
+ * `max-height` below goes back to being what it says it is — a ceiling
1857
+ * where the list starts scrolling, not a height.
1858
+ */
1859
+ align-items: flex-start;
1847
1860
  /* Not centred: a dialog that grows downward from a fixed top does not
1848
1861
  * shift under the reader as results stream in. */
1849
1862
  /* `dvh`, not `vh`: on a phone `vh` is the viewport with the URL bar
@@ -1930,12 +1943,29 @@
1930
1943
  border-color: var(--wave-docs-border-strong);
1931
1944
  }
1932
1945
 
1946
+ /*
1947
+ * `1 1 auto` and not `0 1 auto`: once the dialog hits its `max-height` this
1948
+ * is the part that must take the remaining space and scroll, rather than the
1949
+ * list overflowing a box sized to the input. With the dialog content-sized
1950
+ * there is no free space to grow into, so it only bites at the ceiling —
1951
+ * which is exactly when it should.
1952
+ */
1933
1953
  .wave-docs-search-results {
1934
1954
  flex: 1 1 auto;
1935
1955
  overflow-y: auto;
1936
1956
  padding: 0.375rem;
1937
1957
  }
1938
1958
 
1959
+ /*
1960
+ * An empty results container has no padding to contribute. Without this it
1961
+ * adds 12px of nothing under the input on every keystroke that matches
1962
+ * nothing — small, and precisely the sort of gap that reads as a broken
1963
+ * layout rather than as an empty state.
1964
+ */
1965
+ .wave-docs-search-results:empty {
1966
+ display: none;
1967
+ }
1968
+
1939
1969
  .wave-docs-search-result {
1940
1970
  border-radius: var(--wave-docs-radius-sm);
1941
1971
  }
@@ -1969,20 +1999,39 @@
1969
1999
  line-height: 1.4;
1970
2000
  }
1971
2001
 
1972
- .wave-docs-search-result-breadcrumb {
1973
- color: var(--wave-docs-fg-subtle);
1974
- font-size: 0.75rem;
1975
- line-height: 1.4;
1976
- }
1977
-
1978
- /* A crumb never wraps mid-title: the trail reads as a path or not at all.
1979
- * The breadcrumb line itself is free to wrap between crumbs. */
1980
- .wave-docs-search-result-crumb {
2002
+ /*
2003
+ * Where the hit lands, as a route.
2004
+ *
2005
+ * ⚠️ RENAMED FROM `…-breadcrumb` / `…-crumb`, WHICH IS BREAKING AND
2006
+ * DELIBERATE. This used to be a trail of human names — "Installation ›
2007
+ * Requirements" — and a page's own record, whose heading IS its page title,
2008
+ * got no line at all rather than repeat itself. That left a list where some
2009
+ * rows had two lines and some had one.
2010
+ *
2011
+ * The first repair gave those rows the route and kept the trail on the
2012
+ * others, which was worse: one slot carrying two different kinds of thing,
2013
+ * so "Styling" under one row was a page and "/docs/styling" under the next
2014
+ * was an address. Now every row shows the route, always, and the class is
2015
+ * named for what it holds — a breadcrumb it is not.
2016
+ *
2017
+ * The words live in the option's `aria-label`, because a route read aloud is
2018
+ * punctuation.
2019
+ */
2020
+ .wave-docs-search-result-location {
2021
+ display: block;
2022
+ /* One line, ellipsised. A real site's routes reach
2023
+ * `/docs/api/reference/authentication#rotating-keys`, and a wrapped route
2024
+ * makes the row two different heights depending on its depth — the raggedness
2025
+ * this whole change exists to remove. */
2026
+ overflow: hidden;
1981
2027
  white-space: nowrap;
1982
- }
1983
-
1984
- .wave-docs-search-result-crumb-separator {
1985
- margin-inline: 0.25rem;
2028
+ text-overflow: ellipsis;
2029
+ color: var(--wave-docs-fg-subtle);
2030
+ font-family: var(--wave-docs-font-mono);
2031
+ /* Smaller than the trail was: a monospace face reads larger at the same
2032
+ * size, and this line is a reference rather than something to read. */
2033
+ font-size: 0.6875rem;
2034
+ line-height: 1.5;
1986
2035
  }
1987
2036
 
1988
2037
  .wave-docs-search-status {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waveso/docs",
3
- "version": "0.3.0",
3
+ "version": "0.4.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": [