@waveso/docs 0.8.0 → 0.9.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,119 @@
1
1
  # @waveso/docs
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 216f2df: **The search dialog says how to drive it.** A magnifier on the trigger and in
8
+ the input, and a footer carrying `↑` `↓` Select · `↵` Open · `Esc` Close.
9
+
10
+ ⚠️ AND IT DELETES A BUTTON THAT SAID `Close` IN HARDCODED ENGLISH. Every other
11
+ user-facing string in this package had been lifted to a prop; that one was
12
+ missed, in the one dialog a reader cannot leave without it — so a Portuguese
13
+ site rendered a Portuguese dialog with an English way out. It is `closeLabel`
14
+ now, alongside `selectLabel` and `openLabel`.
15
+
16
+ The key-caps beside them are glyphs and stay untranslated: an arrow is an
17
+ arrow, and `Esc` is `Esc` on a Portuguese keyboard. The props are the verbs,
18
+ which are not.
19
+
20
+ ⚠️ THE DISMISS CONTROL IS A BUTTON, NOT A THIRD HINT. Under `(hover: none) and
21
+ (pointer: coarse)` the two hints are hidden — on the same reasoning as the
22
+ trigger's `⌘K`, that an instruction to press a key is one a reader on a phone
23
+ cannot follow — and that leaves this as the only pointer route out of the
24
+ dialog. It is not hidden with them.
25
+
26
+ ⚠️ IT ALSO MOVED PAST THE RESULTS. The button it replaces sat in the input row,
27
+ so one Tab from the query landed on _Close_ rather than on the first result —
28
+ past every answer the reader had just asked for. Document order is tab order
29
+ here, and a test now holds it.
30
+
31
+ The hints are `aria-hidden`: they describe the pointer-free path through a
32
+ listbox that a screen reader already exposes through `role`,
33
+ `aria-activedescendant` and `aria-posinset`, so announcing them adds two lines
34
+ of symbols and no information. The magnifiers are hidden for the reason the
35
+ trigger's own label is pinned — named from content, that button once announced
36
+ as "Search Ctrl K".
37
+
38
+ Published sizes rise with it: the quick start's total 14 → 14.3 KB, the search
39
+ dialog 9.3 → 9.5 KB. Budgets raised in `size-budget.json` with the reason.
40
+
41
+ ⚠️ AND THE TRIGGER NOW SHARES A COLUMN WITH THE TREE. It sits directly above
42
+ the navigation, so its magnifier is the first thing in the same column as every
43
+ folder and page marker below it and its label starts the same column as every
44
+ title — and both were out, measured at 1280px by 3px and 11px. The trigger was
45
+ spaced as a standalone control: 10px of inline padding against the rows' 8px,
46
+ and a 16px gap against their 8px. It carries a 1px border the rows do not, so
47
+ the fix is `calc(0.5rem - 1px)` rather than `0.5rem` — matching the number
48
+ instead of the content edge leaves it 1px out and looks fixed in a screenshot.
49
+ A browser test measures both columns against the tree's.
50
+
51
+ ## The trigger's shortcut is plain text again
52
+
53
+ `⌘K` was a bordered chip inside a bordered, filled control — a chip on a chip,
54
+ sharing its fill, for a hint nobody clicks. It kept that border only because
55
+ the footer's key-caps were added to the same rule; the two are separate now,
56
+ and a test holds them apart. There was never a `background` on either: the
57
+ trigger's own `--wave-docs-bg-subtle` showed through, which is what made the
58
+ border read as a filled shape.
59
+
60
+ ⚠️ AND THE `⌘` IS ITS OWN ELEMENT, BECAUSE CSS CANNOT SELECT A CHARACTER.
61
+ Measured in the shipped mono stack at 12px, the glyph carries 6.39px of ink
62
+ against the `K`'s 8.75px — a third short of the letter beside it, in one string
63
+ at one size. `1.45em` on the symbol brings it to 9.26px — a hair
64
+ taller than the letter, which is what makes the two read as one mark. `Ctrl` is
65
+ gated out of that rule by an attribute: it is a word set in the same face as
66
+ the `K`, and scaling it makes the hint shout.
67
+
68
+ ⚠️ AND SIZE ALONE LEAVES IT FLOATING. `⌘` is drawn around the font's
69
+ mathematical axis rather than standing on the baseline like a capital, so at
70
+ that size its ink centre sits 2.24px above the `K`'s while inline layout aligns
71
+ the two by baseline. `vertical-align: -0.13em` drops it. Measured off a render
72
+ at 8x: both ink boxes centre on the same pixel, with the symbol 5% the taller.
73
+
74
+ New public class names: `.wave-docs-search-glyph`, `.wave-docs-search-footer`,
75
+ `.wave-docs-search-hint`, `.wave-docs-search-kbd`, `.wave-docs-search-trigger-mod`.
76
+ `.wave-docs-search-close` survives, restyled — it is a footer control now, not
77
+ a bordered button in the input row.
78
+
79
+ ### Patch Changes
80
+
81
+ - 5647418: Sidebar groups stop collapsing behind the reader.
82
+
83
+ Expand three sections, click a page, and two of them shut. Or: open a section,
84
+ read a page in it, open another section, read a page in _that_ — and the first
85
+ one closes. Both reported from real use, and both the same defect.
86
+
87
+ ⚠️ THE CAUSE WAS `setToggled({})` ON EVERY NAVIGATION. The reader's own state
88
+ and the route's default share one map — `toggled[key] ?? hasActive` — so
89
+ clearing it does not "reset the tree to its default" in any useful sense. The
90
+ default is _open only what holds the current page_, so wiping the map collapses
91
+ everything the reader had deliberately opened.
92
+
93
+ Navigation now opens whatever holds the page just arrived at, and closes
94
+ nothing. A group closes when the reader closes it.
95
+
96
+ ⚠️ AND IT RECORDS `true` RATHER THAN DELETING THE KEY, WHICH IS THE HALF THAT
97
+ IS EASY TO GET WRONG. Deleting also reopens the group — it falls back to
98
+ `hasActive` — and looks correct for exactly one navigation. Read a page in one
99
+ section, then a page in another, and the first section has no entry left and no
100
+ longer holds the route, so it shuts. That is the second report, reproduced by
101
+ the obvious fix.
102
+
103
+ The state is also seeded from the first route rather than starting empty, so a
104
+ group open at first paint is open by _record_ rather than by inference. Without
105
+ it, landing on a deep page from a search result and clicking away collapses the
106
+ section you arrived in, while one you had opened by hand would have stayed.
107
+
108
+ Kept from the old reset: a group collapsed an hour ago must not hide the page
109
+ just navigated to. It is reopened explicitly.
110
+
111
+ It costs 60 gzipped bytes on the sidebar bundle — a walk that names the groups
112
+ holding the current route. The line it replaces cost nothing, which is the
113
+ point: the cheapest possible reset was also the one that threw away everything
114
+ the reader had opened. Published sizes rise with it: the quick start's total
115
+ 14.3 → 14.5 KB, the navigation 3 → 3.1 KB.
116
+
3
117
  ## 0.8.0
4
118
 
5
119
  ### Minor Changes
package/README.md CHANGED
@@ -69,16 +69,16 @@ 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 KB |
73
- | Search dialog and router wiring | 9.3 KB |
74
- | Navigation: one sidebar, open and closed | 3 KB |
72
+ | Everything the quick start ships, gzipped | 14.5 KB |
73
+ | Search dialog and router wiring | 9.5 KB |
74
+ | Navigation: one sidebar, open and closed | 3.1 KB |
75
75
  | Table of contents | 0.9 KB |
76
76
  | Copy-button runtime | 1.1 KB |
77
77
  | hast over the wire vs HTML, prose page | 1.20× |
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 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.5 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
 
@@ -755,10 +755,17 @@ If your site sets Next's own `basePath` config, prefix `indexUrl` yourself: Next
755
755
  | `loadingLabel` | `string` | `'Loading the search index…'` | While the index is fetched |
756
756
  | `errorLabel` | `string` | `'Search is unavailable right now. Try reloading the page.'` | When it cannot be |
757
757
  | `emptyLabel` | `string` | `'No results for “{query}”.'` | No matches. `{query}` is what was typed |
758
+ | `selectLabel` | `string` | `'Select'` | Footer hint beside `↑` `↓` |
759
+ | `openLabel` | `string` | `'Open'` | Footer hint beside `↵` |
760
+ | `closeLabel` | `string` | `'Close'` | The footer's dismiss button, beside `Esc` |
758
761
  | `resultCountLabels` | `Partial<Record<Intl.LDMLPluralRule, string>>` | `{ one: '{count} result', other: '{count} results' }` | The live region, by plural category |
759
762
  | `locale` | `string` | `<html lang>`, then `'en'` | Language tag for those plural rules |
760
763
  | `miniSearchOptions` | `Partial<Options<SearchRecord>>` | — | See [Tuning](#tuning) |
761
764
 
765
+ The dialog's footer carries the three keyboard hints and the dismiss control. The key-caps beside them — `↑` `↓` `↵` `Esc` — are glyphs and are not translatable; the three props above are the verbs, which are.
766
+
767
+ Under `(hover: none) and (pointer: coarse)` the two hints are hidden, on the same reasoning as the trigger's `⌘K`: an instruction to press a key is one a reader on a phone cannot follow. `closeLabel`'s button is deliberately not hidden with them — on exactly those devices it is the only pointer route out of the dialog.
768
+
762
769
  **`pageSize` replaced `maxResults` in 0.4.0**, and the meaning changed with the name: `maxResults` was a hard ceiling of 8 that made results unreachable on a six-page site, and the live region announced the slice as though it were the total. `pageSize` is a window — every match is reachable by scrolling, and the count announced is the real one.
763
770
 
764
771
  `resultCountLabels` is keyed by plural category rather than being a singular and a plural, because most languages are not English: Polish takes four forms and Arabic six. `Intl.PluralRules` picks, and a category you do not list falls back to `other`.
@@ -95,6 +95,29 @@ interface SearchDialogProps {
95
95
  * `{query}` is replaced with what the reader typed.
96
96
  */
97
97
  emptyLabel?: string | undefined;
98
+ /**
99
+ * The arrow-keys hint in the footer. Defaults to `'Select'`.
100
+ *
101
+ * The key-caps beside it are glyphs and are not translatable — an arrow is an
102
+ * arrow, and `Esc` is `Esc` on a Portuguese keyboard. These props are the
103
+ * verbs, which are not.
104
+ */
105
+ selectLabel?: string | undefined;
106
+ /** The Enter hint in the footer. Defaults to `'Open'`. */
107
+ openLabel?: string | undefined;
108
+ /**
109
+ * The footer's dismiss control. Defaults to `'Close'`.
110
+ *
111
+ * ⚠️ THIS REPLACES A BUTTON THAT SAID `Close` IN HARDCODED ENGLISH — the one
112
+ * string in this package that was never lifted to a prop, in the one dialog a
113
+ * reader cannot leave without it.
114
+ *
115
+ * It names a real button rather than a third hint: under `pointer: coarse`
116
+ * the two hints beside it are hidden, because an instruction to press Esc is
117
+ * one a reader on a phone cannot follow — and that leaves this as the only
118
+ * pointer route out of the dialog.
119
+ */
120
+ closeLabel?: string | undefined;
98
121
  /**
99
122
  * The live region's announcement, by plural category. `{count}` is the total.
100
123
  *
@@ -151,6 +174,6 @@ interface SearchDialogProps {
151
174
  * portalled to `document.body`, so a navbar's stacking context cannot trap
152
175
  * it behind the page.
153
176
  */
154
- declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, resultCountLabels, locale }: SearchDialogProps): ReactNode;
177
+ declare function SearchDialog({ indexUrl, navigate, Link, triggerLabel, placeholder, dialogLabel, pageSize, minQueryLength, debounceMs, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, selectLabel, openLabel, closeLabel, resultCountLabels, locale }: SearchDialogProps): ReactNode;
155
178
  //#endregion
156
179
  export { SearchDialog, SearchDialogProps };
@@ -26,7 +26,7 @@ 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", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, resultCountLabels, locale }) {
29
+ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", placeholder = "Search documentation", dialogLabel = "Search documentation", pageSize = 20, minQueryLength = 2, debounceMs = 120, className, miniSearchOptions, hintLabel, shortQueryLabel, loadingLabel, errorLabel, emptyLabel, selectLabel = "Select", openLabel = "Open", closeLabel = "Close", resultCountLabels, locale }) {
30
30
  const [isOpen, setIsOpen] = useState(false);
31
31
  const [query, setQuery] = useState("");
32
32
  const [hits, setHits] = useState([]);
@@ -53,7 +53,18 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
53
53
  const movedByKeyboard = useRef(false);
54
54
  const [activeIndex, setActiveIndex] = useState(0);
55
55
  const [status, setStatus] = useState("idle");
56
- const [shortcutHint, setShortcutHint] = useState("");
56
+ /**
57
+ * The trigger's shortcut, split rather than held as one string.
58
+ *
59
+ * ⚠️ THE `⌘` NEEDS ITS OWN `font-size` AND CSS CANNOT SELECT A CHARACTER.
60
+ * Measured in the shipped mono stack at 12px, its ink is 6.39px tall against
61
+ * the `K`'s 8.75px — so as one string the symbol sits visibly short of the
62
+ * letter beside it. Two nodes is the only way to scale one and not the other.
63
+ *
64
+ * `null` until the effect below resolves the platform: reading it during
65
+ * render would disagree with the server's markup and break hydration.
66
+ */
67
+ const [shortcut, setShortcut] = useState(null);
57
68
  const dialogRef = useRef(null);
58
69
  const triggerRef = useRef(null);
59
70
  const inputRef = useRef(null);
@@ -126,7 +137,10 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
126
137
  }, [isOpen, closeDialog]);
127
138
  useEffect(() => {
128
139
  const isApple = /mac|iphone|ipad|ipod/i.test(navigator.userAgent);
129
- setShortcutHint(isApple ? "⌘K" : "Ctrl K");
140
+ setShortcut({
141
+ modifier: isApple ? "⌘" : "Ctrl",
142
+ isSymbol: isApple
143
+ });
130
144
  }, []);
131
145
  useEffect(() => {
132
146
  if (isOpen) {
@@ -267,13 +281,21 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
267
281
  onClick: openDialog,
268
282
  onPointerEnter: warmIndex,
269
283
  onFocus: warmIndex,
270
- children: [/* @__PURE__ */ jsx("span", {
271
- className: "wave-docs-search-trigger-label",
272
- children: triggerLabel
273
- }), shortcutHint === "" ? null : /* @__PURE__ */ jsx("kbd", {
274
- className: "wave-docs-search-trigger-kbd",
275
- children: shortcutHint
276
- })]
284
+ children: [
285
+ /* @__PURE__ */ jsx(SearchGlyph, {}),
286
+ /* @__PURE__ */ jsx("span", {
287
+ className: "wave-docs-search-trigger-label",
288
+ children: triggerLabel
289
+ }),
290
+ shortcut === null ? null : /* @__PURE__ */ jsxs("kbd", {
291
+ className: "wave-docs-search-trigger-kbd",
292
+ children: [/* @__PURE__ */ jsx("span", {
293
+ className: "wave-docs-search-trigger-mod",
294
+ "data-symbol": shortcut.isSymbol ? "" : void 0,
295
+ children: shortcut.modifier
296
+ }), shortcut.isSymbol ? "K" : " K"]
297
+ })
298
+ ]
277
299
  }), isOpen ? createPortal(/* @__PURE__ */ jsx("div", {
278
300
  className: "wave-docs-search-backdrop",
279
301
  onMouseDown: (event) => {
@@ -288,7 +310,7 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
288
310
  children: [
289
311
  /* @__PURE__ */ jsxs("div", {
290
312
  className: "wave-docs-search-input-row",
291
- children: [/* @__PURE__ */ jsx("input", {
313
+ children: [/* @__PURE__ */ jsx(SearchGlyph, {}), /* @__PURE__ */ jsx("input", {
292
314
  ref: inputRef,
293
315
  className: "wave-docs-search-input",
294
316
  type: "text",
@@ -305,11 +327,6 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
305
327
  autoComplete: "off",
306
328
  autoCorrect: "off",
307
329
  spellCheck: false
308
- }), /* @__PURE__ */ jsx("button", {
309
- type: "button",
310
- className: "wave-docs-search-close",
311
- onClick: closeDialog,
312
- children: "Close"
313
330
  })]
314
331
  }),
315
332
  /* @__PURE__ */ jsx("div", {
@@ -346,11 +363,82 @@ function SearchDialog({ indexUrl, navigate, Link, triggerLabel = "Search", place
346
363
  },
347
364
  resultCountLabels,
348
365
  locale
366
+ }),
367
+ /* @__PURE__ */ jsxs("div", {
368
+ className: "wave-docs-search-footer",
369
+ children: [
370
+ /* @__PURE__ */ jsxs("span", {
371
+ className: "wave-docs-search-hint",
372
+ "aria-hidden": "true",
373
+ children: [
374
+ /* @__PURE__ */ jsx("kbd", {
375
+ className: "wave-docs-search-kbd",
376
+ children: "↑"
377
+ }),
378
+ /* @__PURE__ */ jsx("kbd", {
379
+ className: "wave-docs-search-kbd",
380
+ children: "↓"
381
+ }),
382
+ selectLabel
383
+ ]
384
+ }),
385
+ /* @__PURE__ */ jsxs("span", {
386
+ className: "wave-docs-search-hint",
387
+ "aria-hidden": "true",
388
+ children: [/* @__PURE__ */ jsx("kbd", {
389
+ className: "wave-docs-search-kbd",
390
+ children: "↵"
391
+ }), openLabel]
392
+ }),
393
+ /* @__PURE__ */ jsxs("button", {
394
+ type: "button",
395
+ className: "wave-docs-search-close",
396
+ "aria-label": closeLabel,
397
+ "aria-keyshortcuts": "Escape",
398
+ onClick: closeDialog,
399
+ children: [/* @__PURE__ */ jsx("kbd", {
400
+ className: "wave-docs-search-kbd",
401
+ children: "Esc"
402
+ }), /* @__PURE__ */ jsx("span", { children: closeLabel })]
403
+ })
404
+ ]
349
405
  })
350
406
  ]
351
407
  })
352
408
  }), document.body) : null] });
353
409
  }
410
+ /**
411
+ * The magnifier, on the trigger and in the dialog's input row.
412
+ *
413
+ * Decorative in both places: the trigger carries its name in `aria-label` and
414
+ * the input carries its own, so this glyph would only ever repeat a word that
415
+ * is already there — announced as "search Search" and, on some engines, as the
416
+ * name of the character.
417
+ *
418
+ * Inline SVG rather than a `::before` glyph, matching every other icon in this
419
+ * package: generated content is announced by some screen-reader and browser
420
+ * pairs, which is the one thing `aria-hidden` cannot take back.
421
+ */
422
+ function SearchGlyph() {
423
+ return /* @__PURE__ */ jsxs("svg", {
424
+ className: "wave-docs-search-glyph",
425
+ "aria-hidden": "true",
426
+ focusable: "false",
427
+ viewBox: "0 0 24 24",
428
+ width: "16",
429
+ height: "16",
430
+ fill: "none",
431
+ stroke: "currentColor",
432
+ strokeWidth: "2",
433
+ strokeLinecap: "round",
434
+ strokeLinejoin: "round",
435
+ children: [/* @__PURE__ */ jsx("circle", {
436
+ cx: "11",
437
+ cy: "11",
438
+ r: "7"
439
+ }), /* @__PURE__ */ jsx("path", { d: "m20 20-3.5-3.5" })]
440
+ });
441
+ }
354
442
  /** One result row: a real link, so middle-click and "open in new tab" work. */
355
443
  function SearchResultOption({ hit, id, isActive, setSize, posInSet, onActivate, onSelect, Link }) {
356
444
  function handleClick(event) {
@@ -15,6 +15,24 @@ function normalizeHref(href) {
15
15
  function isActiveHref(pathname, href) {
16
16
  return normalizeHref(pathname) === normalizeHref(href);
17
17
  }
18
+ /**
19
+ * `{ [key]: true }` for every group the active page lives inside, keyed the way
20
+ * the tree renders — `${prefix}-${index}`, index counted over *all* siblings
21
+ * because that is what `NavList` does.
22
+ *
23
+ * Shaped as the state itself rather than as a list of keys so both callers can
24
+ * spread it: it seeds the map on mount and merges into it on every navigation,
25
+ * and neither needs a loop of its own.
26
+ */
27
+ function openAlong(nodes, pathname, prefix, into = {}) {
28
+ nodes.forEach((node, index) => {
29
+ if (node.type !== "group" || !containsActive(node, pathname)) return;
30
+ const key = `${prefix}-${index}`;
31
+ into[key] = true;
32
+ openAlong(node.children, pathname, key, into);
33
+ });
34
+ return into;
35
+ }
18
36
  /** Whether the active page lives anywhere under this node. */
19
37
  function containsActive(node, pathname) {
20
38
  switch (node.type) {
@@ -74,11 +92,14 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", expandGroup, collaps
74
92
  externalLink: externalLink ?? DEFAULT_SIDEBAR_LABELS.externalLink
75
93
  };
76
94
  const baseId = useId();
77
- const [toggled, setToggled] = useState({});
95
+ const [toggled, setToggled] = useState(() => openAlong(nav, pathname, baseId));
78
96
  const lastPathname = useRef(pathname);
79
97
  if (lastPathname.current !== pathname) {
80
98
  lastPathname.current = pathname;
81
- setToggled({});
99
+ setToggled((previous) => ({
100
+ ...previous,
101
+ ...openAlong(nav, pathname, baseId)
102
+ }));
82
103
  }
83
104
  const handleToggle = (key, isOpen) => {
84
105
  setToggled((previous) => ({
package/dist/styles.css CHANGED
@@ -2823,14 +2823,30 @@
2823
2823
  * ------------------------------------------------------------------------ */
2824
2824
 
2825
2825
  @layer components {
2826
+ /*
2827
+ * ⚠️ THE INLINE PADDING AND THE GAP ARE THE NAVIGATION'S, NOT THIS BUTTON'S,
2828
+ * AND THE `- 1px` IS THE WHOLE REASON THEY LOOK THE SAME.
2829
+ *
2830
+ * The trigger sits directly above the tree, so its magnifier is the first
2831
+ * thing in the same column as every folder and page marker below it, and its
2832
+ * label starts the same column as every title. Both were off — measured at
2833
+ * 1280px, the glyph by 3px and the label by 11px — because this button was
2834
+ * spaced as a standalone control: 10px of padding against the rows' 8px, and
2835
+ * a 16px gap against their 8px.
2836
+ *
2837
+ * The rows carry no border and this does, so matching `0.5rem` outright would
2838
+ * leave the glyph 1px out. `calc(0.5rem - 1px)` puts the *content* edge where
2839
+ * theirs is, which is the edge a reader sees. Block padding is untouched: it
2840
+ * sets this control's height, and nothing below it is the same height.
2841
+ */
2826
2842
  .wave-docs-search-trigger {
2827
2843
  display: flex;
2828
2844
  align-items: center;
2829
2845
  justify-content: space-between;
2830
- gap: 1rem;
2846
+ gap: 0.5rem;
2831
2847
  width: 100%;
2832
2848
  max-width: 20rem;
2833
- padding: 0.4375rem 0.625rem;
2849
+ padding: 0.4375rem calc(0.5rem - 1px);
2834
2850
  border: 1px solid var(--wave-docs-border);
2835
2851
  border-radius: var(--wave-docs-radius);
2836
2852
  background: var(--wave-docs-bg-subtle);
@@ -2871,14 +2887,26 @@
2871
2887
  outline-offset: 2px;
2872
2888
  }
2873
2889
 
2874
- /* The label is the flexible half of the trigger; the shortcut hint beside it
2875
- * must never be the thing that gets squeezed out at narrow widths. */
2890
+ /* The label is the flexible half of the trigger; neither the magnifier before
2891
+ * it nor the shortcut hint after it may be the thing squeezed out at narrow
2892
+ * widths. `flex: 1 1 auto` because the row is `space-between` and three
2893
+ * items would otherwise centre the middle one, leaving a gap on both sides of
2894
+ * a short label. */
2876
2895
  .wave-docs-search-trigger-label {
2896
+ flex: 1 1 auto;
2897
+ min-width: 0;
2877
2898
  overflow: hidden;
2878
2899
  text-overflow: ellipsis;
2879
2900
  white-space: nowrap;
2880
2901
  }
2881
2902
 
2903
+ /* Quieter than the words either side of it, in the trigger and in the dialog
2904
+ * alike — it is a category marker, not a control. */
2905
+ .wave-docs-search-glyph {
2906
+ flex: none;
2907
+ color: var(--wave-docs-fg-subtle);
2908
+ }
2909
+
2882
2910
  /*
2883
2911
  * ⚠️ HIDDEN WHERE THE SHORTCUT CANNOT BE PRESSED, WHICH IS NOT A WIDTH
2884
2912
  * QUESTION. `⌘K` is an instruction, and on a phone or a tablet it is one the
@@ -2891,16 +2919,61 @@
2891
2919
  }
2892
2920
  }
2893
2921
 
2894
- .wave-docs-search-trigger-kbd {
2922
+ .wave-docs-search-trigger-kbd,
2923
+ .wave-docs-search-kbd {
2895
2924
  flex: none;
2896
- padding: 0.05rem 0.35rem;
2897
- border: 1px solid var(--wave-docs-border-strong);
2898
- border-radius: var(--wave-docs-radius-sm);
2899
2925
  color: var(--wave-docs-fg-subtle);
2900
2926
  font-family: var(--wave-docs-font-mono);
2901
2927
  font-size: 0.75rem;
2902
2928
  }
2903
2929
 
2930
+ /*
2931
+ * The footer's caps are drawn as keys; the trigger's shortcut is not.
2932
+ *
2933
+ * They were one rule, and the trigger's read as a chip on a surface that is
2934
+ * already a chip — a bordered box inside a bordered box, sharing its fill,
2935
+ * for a hint nobody clicks. The footer's caps sit on the flat bottom of the
2936
+ * dialog and are the only thing there that has to look pressable.
2937
+ *
2938
+ * No `background` was ever set on either: the trigger's own
2939
+ * `--wave-docs-bg-subtle` showed through, which is what made the border read
2940
+ * as a filled shape. Removing the border is what removes the shape.
2941
+ */
2942
+ .wave-docs-search-kbd {
2943
+ padding: 0.05rem 0.35rem;
2944
+ border: 1px solid var(--wave-docs-border-strong);
2945
+ border-radius: var(--wave-docs-radius-sm);
2946
+ }
2947
+
2948
+ /*
2949
+ * ⚠️ THE SYMBOL IS SCALED AND THE WORD IS NOT — which is why this is a
2950
+ * separate element rather than one string in the `<kbd>`.
2951
+ *
2952
+ * Measured in the shipped mono stack at 12px: `⌘` has 6.39px of ink against
2953
+ * `K`'s 8.75px, so at a shared size it sits a third short of the letter next
2954
+ * to it. `1.35em` is that ratio, and it makes the two read as one mark.
2955
+ *
2956
+ * `Ctrl` gets none of it: it is a word set in the same face as the `K`, and
2957
+ * scaling it would make the hint shout.
2958
+ */
2959
+ .wave-docs-search-trigger-mod[data-symbol] {
2960
+ font-size: 1.45em;
2961
+ line-height: 1;
2962
+ /*
2963
+ * ⚠️ AND IT HAS TO BE MOVED DOWN, WHICH `font-size` ALONE WILL NEVER DO.
2964
+ *
2965
+ * `⌘` is drawn around the font's mathematical axis, not standing on the
2966
+ * baseline like a capital. Measured at this size: its ink runs from 11.24px
2967
+ * above the baseline to 1.98px *above* it — a centre 6.61px up — while the
2968
+ * `K` beside it runs 8.75px to 0, centred 4.38px up. Aligned by baseline,
2969
+ * as inline text is, the symbol therefore floats 2.24px high.
2970
+ *
2971
+ * `-0.13em` of this element's own 17.4px is 2.26px, which puts the two ink
2972
+ * centres within a rounding error of each other.
2973
+ */
2974
+ vertical-align: -0.13em;
2975
+ }
2976
+
2904
2977
  .wave-docs-search-backdrop {
2905
2978
  position: fixed;
2906
2979
  inset: 0;
@@ -2989,21 +3062,94 @@
2989
3062
  color: var(--wave-docs-fg-subtle);
2990
3063
  }
2991
3064
 
2992
- .wave-docs-search-close {
3065
+ /*
3066
+ * The keyboard footer.
3067
+ *
3068
+ * ⚠️ `flex: none`, BECAUSE THE LIST ABOVE IT IS `flex: 1 1 auto`. The dialog
3069
+ * is a column with a ceiling, and the results are what takes the slack and
3070
+ * scrolls; without this the footer is a second growable item and the two
3071
+ * share the overflow — a footer that drifts down the dialog as results
3072
+ * arrive, and a list that never quite reaches it.
3073
+ */
3074
+ .wave-docs-search-footer {
2993
3075
  flex: none;
2994
- padding: 0.3125rem 0.625rem;
2995
- border: 1px solid var(--wave-docs-border);
3076
+ display: flex;
3077
+ align-items: center;
3078
+ gap: 1rem;
3079
+ padding: 0.5rem 0.75rem;
3080
+ border-block-start: 1px solid var(--wave-docs-border);
3081
+ color: var(--wave-docs-fg-subtle);
3082
+ font-size: 0.75rem;
3083
+ }
3084
+
3085
+ .wave-docs-search-hint,
3086
+ .wave-docs-search-close {
3087
+ display: inline-flex;
3088
+ align-items: center;
3089
+ gap: 0.375rem;
3090
+ }
3091
+
3092
+ /*
3093
+ * ⚠️ A NEGATIVE MARGIN, BECAUSE `gap` CANNOT VARY PER PAIR. `↑` and `↓` are
3094
+ * one instruction and read as one object; the gap that separates a cap from
3095
+ * the word beside it is too wide between two caps that belong together. A
3096
+ * flex container has a single gap, so the only ways to tighten one pair are
3097
+ * an extra wrapper element or this — and a wrapper on every hint is markup
3098
+ * bought for spacing.
3099
+ *
3100
+ * `-0.25rem` against the `0.375rem` above leaves `0.125rem` between them.
3101
+ */
3102
+ .wave-docs-search-kbd + .wave-docs-search-kbd {
3103
+ margin-inline-start: -0.25rem;
3104
+ }
3105
+
3106
+ /*
3107
+ * The only cap holding a word rather than a glyph, and three letters at the
3108
+ * arrows' size made it the loudest thing in the footer. `line-height` pins
3109
+ * the box so it still sits at the same height as the caps beside it —
3110
+ * without it the smaller text shrinks the content box and the cap rides
3111
+ * high in a row of taller ones.
3112
+ */
3113
+ .wave-docs-search-close .wave-docs-search-kbd {
3114
+ font-size: 0.625rem;
3115
+ /* Solved, not guessed: the cap is 3.6px of border and padding plus its own
3116
+ * line box, and the 12px caps beside it measure 17.6px. 10px x 1.4 = 14,
3117
+ * and 14 + 3.6 is 17.6 — all four exactly the same height. */
3118
+ line-height: 1.4;
3119
+ }
3120
+
3121
+ /*
3122
+ * ⚠️ HIDDEN WHERE THE KEY CANNOT BE PRESSED — the same query, and the same
3123
+ * reason, as the trigger's `⌘K`. An instruction to press an arrow key is one
3124
+ * a reader on a phone cannot follow, and a 10-inch tablet at 1024px would
3125
+ * keep it under any width rule.
3126
+ *
3127
+ * The Close button beside them is deliberately NOT in here. It is the only
3128
+ * pointer dismiss affordance in the dialog, and on exactly the devices this
3129
+ * query names there is no Esc to fall back to.
3130
+ */
3131
+ @media (hover: none) and (pointer: coarse) {
3132
+ .wave-docs-search-hint {
3133
+ display: none;
3134
+ }
3135
+ }
3136
+
3137
+ /* Pushed to the far end, away from the two hints — the difference between a
3138
+ * control and a caption, said with position. */
3139
+ .wave-docs-search-close {
3140
+ margin-inline-start: auto;
3141
+ padding: 0.125rem 0.25rem;
3142
+ border: 0;
2996
3143
  border-radius: var(--wave-docs-radius-sm);
2997
3144
  background: none;
2998
3145
  color: var(--wave-docs-fg-muted);
2999
3146
  font: inherit;
3000
- font-size: 0.8125rem;
3147
+ font-size: 0.75rem;
3001
3148
  cursor: pointer;
3002
3149
  }
3003
3150
 
3004
3151
  .wave-docs-search-close:hover {
3005
3152
  color: var(--wave-docs-fg);
3006
- border-color: var(--wave-docs-border-strong);
3007
3153
  }
3008
3154
 
3009
3155
  /*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waveso/docs",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": [