@waveso/docs 0.9.0 → 0.10.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,231 @@
1
1
  # @waveso/docs
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f52cb99: **Every page links to the ones either side of it.** `docs.Page` renders a pager
8
+ under the prose. Nothing is authored: a page gets one by being in the
9
+ navigation.
10
+
11
+ ⚠️ THE ORDER IS THE SIDEBAR'S, NOT THE SLUG LIST'S. `generateStaticParams` has
12
+ every route in it and no opinion about their order; `meta.json` is where the
13
+ author said what comes next, and it is what the reader is looking at. Two
14
+ orderings of the same pages is two answers to one question, and they drift the
15
+ first time a `meta.json` moves — so the pager reads the same tree `DocsSidebar`
16
+ renders and flattens it. A pager that disagrees with the column beside it is
17
+ impossible by construction.
18
+
19
+ Separators and external links are not stops: a separator is a label with
20
+ nowhere to go, and a "next page" that lands on npm has ended the sequence
21
+ rather than continued it. A group with an `index.md` contributes its own page
22
+ before its children, which is the order its rows appear in.
23
+
24
+ ⚠️ A PAGE OUTSIDE THE TREE GETS NO PAGER, RATHER THAN THE FIRST ONE. `-1` from
25
+ `findIndex` reads as "just before the beginning", so an unguarded lookup hands
26
+ every draft and every route rendered outside the navigation the same first page
27
+ as its "next" — confidently wrong on exactly the pages nobody checks.
28
+
29
+ ⚠️ AND AN EMPTY CELL AT EACH END, NOT A MISSING ONE. The two links share a grid
30
+ row; drop the absent side and the survivor slides into the first track, so the
31
+ first page of a site puts "Next" on the left and every other page puts it on
32
+ the right. The one page where the position moves is the one a reader sees
33
+ first.
34
+
35
+ Nothing renders at all when there is no neighbour either side — a one-page site
36
+ would otherwise get a navigation landmark containing nothing.
37
+
38
+ A chevron on the outer edge of each link points the way it goes.
39
+
40
+ ⚠️ AND "OUTWARD" MIRRORS. Under `dir="rtl"` the grid's first track is on the
41
+ right, so the _previous_ link moves there and its arrow has to point right —
42
+ the reverse of the rule that draws it. Same trap as the sidebar's chevron, in a
43
+ component built after it, and `[dir='rtl']` again rather than `:dir(rtl)`.
44
+
45
+ No client JavaScript: two links, two captions and two glyphs, rendered on the
46
+ server. Each
47
+ link is named by direction _and_ destination — "Previous: Installation" — since
48
+ a link announced as a bare title says nothing about which way it goes. The
49
+ landmark is named too, because a page now carries three of them.
50
+
51
+ New: `DocsPager` at `@waveso/docs/react/pager`, `pager: false` on
52
+ `createDocsRoute` to omit it, and `previousPage`, `nextPage` and `pagination`
53
+ in `labels`.
54
+
55
+ ### Patch Changes
56
+
57
+ - 09a9369: The sidebar grip says whether it has anything to do.
58
+
59
+ Blue when the navigation is hidden or the pointer is on it; grey when the
60
+ sidebar is open and untouched. One rule at every width — a closed sidebar is
61
+ the same request for attention on a phone as on a desktop.
62
+
63
+ ⚠️ `:not([data-state='open'])`, NOT `[data-state='closed']`. The attribute is
64
+ absent until the reader chooses, and a server-rendered page has none — so
65
+ matching only the explicit value leaves every first paint below 64rem showing a
66
+ grey grip in front of hidden navigation, which is the one moment the cue is
67
+ for. Above 64rem the default inverts, and a second rule says so.
68
+
69
+ ⚠️ AND THE COLOUR IS A CUSTOM PROPERTY, NOT A SELECTOR FIGHT. Three things want
70
+ to set it — the resting style, the pointer, and the sidebar's state — and the
71
+ state lives on an _ancestor_, so `.sidebar[data-state] .trigger::before`
72
+ outranks `.trigger:hover::before` by a whole class. Written as backgrounds that
73
+ is a rule which silently kills hover on the one state that still needs it.
74
+ Properties settle it by inheritance: the state sets them on the sidebar, the
75
+ trigger sets them on itself under `:hover`, and a value on the element always
76
+ beats one it inherited. No specificity ladder, no `!important`.
77
+
78
+ The grip is also a full pill now rather than a rounded rectangle.
79
+
80
+ ⚠️ TESTED AS MARKUP, BECAUSE A MOUNTED COMPONENT CANNOT SHOW THE UNTOUCHED
81
+ STATE. `DocsNav` resolves the mode on mount and writes `data-state`
82
+ immediately, so every React fixture is already explicit. Two wrong versions
83
+ passed the whole suite against that — matching the explicit value, and dropping
84
+ the wide-layout reset — and both are caught now by a server-shaped fixture with
85
+ no attribute at all.
86
+
87
+ Also fixed here: the focus-indicator guard looked up one rule per selector, and
88
+ a selector may legitimately appear in several. The trigger now has one rule
89
+ setting properties on focus and another drawing the ring; taking "the first" or
90
+ "the last" is a coin flip on file order, so it reads every rule and asks for
91
+ one to declare an outline.
92
+
93
+ ## 0.9.2
94
+
95
+ ### Patch Changes
96
+
97
+ - 6b84354: A separator now rules off the block above it.
98
+
99
+ It ends one section as much as it names the next, and 1rem of margin was not
100
+ saying so — the gap read as "these two lists are a bit far apart" rather than as
101
+ a division.
102
+
103
+ ⚠️ THE RULE AND THE LABEL SHARE THE LIST'S OWN EDGE, WHICH IS ALSO THE ROWS'. A
104
+ row is full-bleed — its hover surface spans the whole column, and so does the
105
+ search field above it — so a rule on that edge divides the column, while an
106
+ inset one floats inside it. The label sits on the same line, because a heading
107
+ and the rule above it reading as one object is the whole reason the rule exists.
108
+
109
+ The label keeps the rows' own content edge — the marker column when there is
110
+ one, the words when `icons={false}` removes it. Those are the same number: a
111
+ row's `padding-inline` is what both modes have in common, so matching it lands
112
+ on whichever is there, with no query and nothing threaded to the stylesheet.
113
+
114
+ ⚠️ AND NOT ABOVE THE FIRST CHILD. A `meta.json` may open with
115
+ `"---Reference---"`, and on that tree the very first thing in the navigation
116
+ would otherwise be a hairline above nothing.
117
+
118
+ The label also drops from `font-weight: 650` to `500`. At 650 it was heavier
119
+ than the group titles it sits under — a divider out-shouting the navigation it
120
+ divides. Weight rather than colour, because there is no lighter colour to
121
+ reach for: `--wave-docs-fg-subtle` is already the lightest text token at 5.05:1
122
+ against WCAG 1.4.3's 4.5:1 floor, and `--wave-docs-border` — the rule's own
123
+ colour — measures 1.31:1 and is a line colour, not a text one.
124
+
125
+ - 7e67c5a: The table of contents marks the last section when you reach it.
126
+
127
+ Scroll to the foot of a page whose final section is short and nothing happened:
128
+ the entry stayed on the section _above_, and the last one could only be
129
+ highlighted by clicking its own link.
130
+
131
+ ⚠️ NO `rootMargin` FIXES THIS, WHICH IS WHY IT LOOKED LIKE A TUNING PROBLEM.
132
+ The default makes the top 40% of the viewport the region that counts as
133
+ current, and that is right while there is document left to scroll — a heading
134
+ rises into the band and takes the highlight. At the end there is none. A short
135
+ trailing section sits on screen, fully readable, below a band it can never
136
+ enter, while the heading above it is still _inside_ that band. The observer was
137
+ giving a correct answer to the wrong question. Any band smaller than the
138
+ viewport has this hole; a bigger one only trades it for a highlight that jumps
139
+ early.
140
+
141
+ So the end of the document is handled as what it is — a place where scrolling
142
+ stops answering — and the last heading takes the highlight there. Scroll up and
143
+ the band has it straight back, without waiting for a heading to cross.
144
+
145
+ ⚠️ AND BOTH INPUTS GO THROUGH ONE RESOLVER, WHICH IS THE HALF THAT IS EASY TO
146
+ MISS. Left as two `setActiveId` calls they race, and the observer wins — it
147
+ fires last and it still likes the heading above. The first version of this fix
148
+ was measured doing exactly nothing for that reason.
149
+
150
+ ⚠️ ONLY WHEN THE DOCUMENT ACTUALLY SCROLLS. On a page that fits, "scrolled to
151
+ the bottom" is true at rest, and the last section would be current before the
152
+ reader had read a word of the first.
153
+
154
+ The listener is `passive` and reads two numbers — no `getBoundingClientRect`,
155
+ no layout flush. Like every other scroll reader here it watches the document; a
156
+ host that scrolls an inner pane keeps the observer's behaviour and loses only
157
+ this tail case.
158
+
159
+ `toc` grows 0.88 → 1 KB, and the published total 14.5 → 14.6 KB. It is still
160
+ the smallest client component in the package.
161
+
162
+ ## 0.9.1
163
+
164
+ ### Patch Changes
165
+
166
+ - 80d3db4: The sidebar's handle and the search shortcut stop competing with the page.
167
+
168
+ **The handle is 16 × 56 and faded.** It was 20 × 80 at full strength — a solid
169
+ slab beside the reading column, for a control nobody looks at while reading. It
170
+ now sits at 40% until a pointer or a caret reaches it, the same treatment as the
171
+ tree's markers.
172
+
173
+ ⚠️ BOTH MARKS FADE, NOT THE PILL ALONE. Fading `::before` by itself leaves crisp
174
+ dots on a washed-out slab, which reads louder than the solid grip it replaced.
175
+
176
+ ⚠️ AND NOT ON THE `<button>`, which would take the focus ring down with it —
177
+ `opacity` applies to the whole element, outline included, so a keyboard reader
178
+ would get a 40% indicator on the control they had just moved to. It is on the
179
+ two pseudo-elements, and `:focus-visible` restores both.
180
+
181
+ ⚠️ THE TAP TARGET IS UNCHANGED BY ANY OF IT. The button is the whole strip and
182
+ runs the height of the column; the pill is paint. At 16px plus 4px of padding a
183
+ side the strip is 24px wide — WCAG 2.5.8's minimum to the pixel, and a test now
184
+ says so, because the next narrowing is the one that fails it.
185
+
186
+ ⚠️ THE DOTS DO NOT SCALE WITH THE PILL. They are `box-shadow` offsets on one
187
+ element, so they span 18px whatever the pill does. Found by shortening it to
188
+ 16px tall and watching the outer two render outside it.
189
+
190
+ **`⌘K` is levelled with `Search`.** Equal `font-size` in two families is not
191
+ equal type: the label is `ui-sans-serif` and the badge `ui-monospace`, which
192
+ draws 0.7292px of cap per px. `1.012em` is what puts the `K`'s cap on the `S`'s.
193
+
194
+ ⚠️ AND THE SYMBOL IS `1.369em`, THE MEASURED INK RATIO — NOT A HAIR MORE. At
195
+ `1.45em` the `⌘` stood 6% above the cap line, and the badge read as _bigger
196
+ type_ than the label: 88 device px of ink against the word's 85, its top three
197
+ higher, its centre 1.5 out. Same letter height, louder cluster. At the ratio,
198
+ `Search` and `⌘K` measure 85 and 85 and share a centre to the device pixel.
199
+
200
+ ⚠️ AND `line-height: 0` ON THE SYMBOL, WHICH IS WHAT MADE THE TWO CENTRE. A line
201
+ box is as tall as the tallest inline box in it, so the 20.5px glyph made the
202
+ `<kbd>` 20.55px against the label's 17 — and flex centres them by their _boxes_,
203
+ so the `K` rode 1.9px high inside a box the symbol had stretched.
204
+
205
+ Also fixed: the focus-indicator test looked its selectors up with `indexOf`, so
206
+ `…:focus-visible` matched inside `…:focus-visible::before` — a different rule,
207
+ about pseudo-elements, with no business declaring an outline. It reported the
208
+ trigger as having no focus indicator while the trigger's own rule sat further
209
+ down the file declaring one.
210
+
211
+ - 27f63ec: Sidebar separators keep the case their author wrote them in.
212
+
213
+ ⚠️ `text-transform: uppercase` ON A STRING THIS PACKAGE DOES NOT OWN. A
214
+ separator's text comes from a consumer's `meta.json` — `"---Reference---"` —
215
+ and restyling it is this package rewriting words in a language it cannot read.
216
+ Portuguese `Referência` shipped as `REFERÊNCIA`. Turkish trades its dotted and
217
+ dotless `i` for each other under a naive uppercase. No CJK script has a case to
218
+ transform at all, so those authors got the `letter-spacing` and none of the
219
+ effect it existed to rescue.
220
+
221
+ The string was already a prop. Its shape was not, and there was no way to turn
222
+ this off short of overriding the rule.
223
+
224
+ Reading as a divider rather than as another row is done by size, weight and a
225
+ subtle colour — none of which touch a character. `letter-spacing` goes with the
226
+ caps: it is there to make uppercase legible, and on sentence case it only reads
227
+ as loose.
228
+
3
229
  ## 0.9.0
4
230
 
5
231
  ### 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.5 KB |
72
+ | Everything the quick start ships, gzipped | 14.6 KB |
73
73
  | Search dialog and router wiring | 9.5 KB |
74
74
  | Navigation: one sidebar, open and closed | 3.1 KB |
75
- | Table of contents | 0.9 KB |
75
+ | Table of contents | 1 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.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.
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.
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
 
@@ -203,6 +203,7 @@ Every component takes data as props, and every module that imports from `next/*`
203
203
  | `DocContent` | `react/doc-content` | Renders a hast tree, inside `.wave-docs-prose`. Server Component |
204
204
  | `DocsHero` | `react/hero` | A landing page's header. `title`, `description`, `actions`, `Link`, `externalLabel`. Server Component |
205
205
  | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation`. `icons` controls the marker column — see [Sidebar icons](#sidebar-icons) |
206
+ | `DocsPager` | `react/pager` | Links to the pages either side of this one. `docs.Page` renders it; `pager: false` on the route omits it |
206
207
  | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
207
208
  | `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
208
209
  | `DocsLink` | `react/next-link` | `next/link`, adapted — pass it as `Link` when composing by hand |
@@ -214,6 +215,24 @@ Every component takes data as props, and every module that imports from `next/*`
214
215
 
215
216
  `DocsToc`'s `rootMargin` is the `IntersectionObserver` margin that decides how far above the viewport a heading counts as current; the default keeps the highlight on the section you are reading rather than the one about to arrive. `topLabel` is the back-to-top link at the end — it fades in once the reader is about a third of a screen down and fades out again on the way back, on a scroll timeline rather than a scroll listener, so the component ships no extra bytes to do it. Where that timeline cannot run — an engine without scroll-driven animations, a page too short to scroll, or a host that scrolls an inner pane rather than the document — the link is simply always there.
216
217
 
218
+ ### The pager
219
+
220
+ `docs.Page` renders it under every page, so most sites never touch it. The order is the navigation's — flattened from the same tree `DocsSidebar` renders — so a pager that disagrees with the sidebar beside it is impossible. Nothing is authored: a page gets one by being in the tree, and a page outside it (a draft, or a route you render yourself) gets none.
221
+
222
+ Separators and external links are not stops. A separator is a label with nowhere to go, and a "next page" that lands on npm has ended the sequence rather than continued it. A directory with an `index.md` contributes its own page before its children, which is the order its rows appear in.
223
+
224
+ | Prop | Type | Default | What it is |
225
+ | --- | --- | --- | --- |
226
+ | `previous` | `NavStop` | — | The stop before this page. Omit at the beginning |
227
+ | `next` | `NavStop` | — | The stop after it. Omit at the end |
228
+ | `Link` | `DocsLinkComponent` | `<a>` | Client-side router link |
229
+ | `previousLabel` | `string` | `'Previous'` | Above the previous page's title |
230
+ | `nextLabel` | `string` | `'Next'` | Above the next page's title |
231
+ | `label` | `string` | `'Pagination'` | Accessible name for the landmark |
232
+ | `className` | `string` | — | Extra classes |
233
+
234
+ With neither neighbour it renders nothing at all, rather than an empty landmark. Set `pager: false` on `createDocsRoute` to omit it everywhere, and the three strings through `labels` — `previousPage`, `nextPage`, `pagination`.
235
+
217
236
  ### Sidebar icons
218
237
 
219
238
  Every row in the sidebar carries a marker at its head: a folder on a group, a page on a page, an arrow on a link that leaves your site. Weight and a chevron were the only difference before, and where categories and pages interleave that is not enough to scan.
@@ -0,0 +1,30 @@
1
+ import { DocNavNode } from "./types.js";
2
+ //#region src/nav-order.d.ts
3
+ /** A destination in the reading order. */
4
+ interface NavStop {
5
+ title: string;
6
+ href: string;
7
+ }
8
+ /**
9
+ * Every page in the tree, in the order a reader meets them.
10
+ *
11
+ * ⚠️ SEPARATORS AND EXTERNAL LINKS ARE NOT STOPS. A separator is a label with
12
+ * nowhere to go, and an external link leaves the documentation entirely — a
13
+ * "next page" that lands on npm has ended the sequence rather than continued
14
+ * it. Internal `link` entries *are* stops: they are hand-written entries
15
+ * pointing at pages of this site, and a reader clicking down the sidebar hits
16
+ * them like any other row.
17
+ *
18
+ * ⚠️ A GROUP CONTRIBUTES ITS OWN PAGE FIRST, WHEN IT HAS ONE. A directory with
19
+ * an `index.md` renders as a link *and* a disclosure, so the reading order is
20
+ * the group's page and then its children — which is the order the rows appear
21
+ * in, and the order someone reading the section would take them.
22
+ */
23
+ declare function readingOrder(nodes: DocNavNode[], into?: NavStop[]): NavStop[];
24
+ /** The stops either side of `href`, or `undefined` at each end of the sequence. */
25
+ declare function neighbours(nodes: DocNavNode[], href: string): {
26
+ previous?: NavStop;
27
+ next?: NavStop;
28
+ };
29
+ //#endregion
30
+ export { NavStop, neighbours, readingOrder };
@@ -0,0 +1,58 @@
1
+ //#region src/nav-order.ts
2
+ /** Trailing slashes are a routing detail, not a difference in identity. */
3
+ function normalize(href) {
4
+ return href.length > 1 ? href.replace(/\/+$/, "") : href;
5
+ }
6
+ /**
7
+ * Every page in the tree, in the order a reader meets them.
8
+ *
9
+ * ⚠️ SEPARATORS AND EXTERNAL LINKS ARE NOT STOPS. A separator is a label with
10
+ * nowhere to go, and an external link leaves the documentation entirely — a
11
+ * "next page" that lands on npm has ended the sequence rather than continued
12
+ * it. Internal `link` entries *are* stops: they are hand-written entries
13
+ * pointing at pages of this site, and a reader clicking down the sidebar hits
14
+ * them like any other row.
15
+ *
16
+ * ⚠️ A GROUP CONTRIBUTES ITS OWN PAGE FIRST, WHEN IT HAS ONE. A directory with
17
+ * an `index.md` renders as a link *and* a disclosure, so the reading order is
18
+ * the group's page and then its children — which is the order the rows appear
19
+ * in, and the order someone reading the section would take them.
20
+ */
21
+ function readingOrder(nodes, into = []) {
22
+ for (const node of nodes) switch (node.type) {
23
+ case "page":
24
+ into.push({
25
+ title: node.title,
26
+ href: node.href
27
+ });
28
+ break;
29
+ case "link":
30
+ if (!node.external) into.push({
31
+ title: node.title,
32
+ href: node.href
33
+ });
34
+ break;
35
+ case "group":
36
+ if (node.href !== void 0) into.push({
37
+ title: node.title,
38
+ href: node.href
39
+ });
40
+ readingOrder(node.children, into);
41
+ }
42
+ return into;
43
+ }
44
+ /** The stops either side of `href`, or `undefined` at each end of the sequence. */
45
+ function neighbours(nodes, href) {
46
+ const stops = readingOrder(nodes);
47
+ const here = normalize(href);
48
+ const at = stops.findIndex((stop) => normalize(stop.href) === here);
49
+ if (at === -1) return {};
50
+ const previous = stops[at - 1];
51
+ const next = stops[at + 1];
52
+ return {
53
+ ...previous === void 0 ? {} : { previous },
54
+ ...next === void 0 ? {} : { next }
55
+ };
56
+ }
57
+ //#endregion
58
+ export { neighbours, readingOrder };
package/dist/next.d.ts CHANGED
@@ -13,6 +13,16 @@ import { Options } from "minisearch";
13
13
  interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter> extends DocsConfig<TFrontmatter> {
14
14
  /** Overrides merged over the Next-flavoured defaults (`next/link` + `next/image`). */
15
15
  components?: MarkdownComponents | undefined;
16
+ /**
17
+ * Links to the pages either side of this one, under every page. Default on.
18
+ *
19
+ * The order is the navigation's, so it cannot disagree with the sidebar —
20
+ * see `nav-order.ts`. Nothing is authored: a page gets a pager by being in
21
+ * the tree, and a page outside it gets none.
22
+ *
23
+ * `false` omits it, for a host whose own layout already ends a page.
24
+ */
25
+ pager?: boolean | undefined;
16
26
  /** Reuse an existing Shiki highlighter. */
17
27
  highlighter?: DocsHighlighter | Promise<DocsHighlighter> | undefined;
18
28
  /** Grammars to load, when building the default highlighter. */
package/dist/next.js CHANGED
@@ -3,10 +3,12 @@ import { docsError } from "./docs-error.js";
3
3
  import { DOCS_CONTENT_ID } from "./docs-content-id.js";
4
4
  import { describeSuggestion } from "./link-suggestion.js";
5
5
  import { mapPooled } from "./map-pooled.js";
6
+ import { neighbours } from "./nav-order.js";
6
7
  import { findFunctionValuedOptions } from "./search-options.js";
7
8
  import { createMarkdownComponents } from "./react/markdown-components.js";
8
9
  import { DocContent } from "./react/doc-content.js";
9
10
  import { DocsHero } from "./react/hero.js";
11
+ import { DocsPager } from "./react/pager.js";
10
12
  import { DocsToc } from "./react/toc.js";
11
13
  import { wrapNextLink } from "./react/link-adapter.js";
12
14
  import { createDocsRenderer } from "./render.js";
@@ -448,6 +450,12 @@ function createDocsRoute(options) {
448
450
  ...options.components
449
451
  },
450
452
  ...copyLabels === void 0 ? {} : { labels: copyLabels }
453
+ }), options.pager === false ? null : createElement(DocsPager, {
454
+ ...neighbours(await requestScopedSource.nav(), doc.href),
455
+ Link: link,
456
+ ...routeLabels?.previousPage === void 0 ? {} : { previousLabel: routeLabels.previousPage },
457
+ ...routeLabels?.nextPage === void 0 ? {} : { nextLabel: routeLabels.nextPage },
458
+ ...routeLabels?.pagination === void 0 ? {} : { label: routeLabels.pagination }
451
459
  })), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, {
452
460
  entries: doc.toc,
453
461
  ...routeLabels?.toc === void 0 ? {} : { label: routeLabels.toc },
@@ -0,0 +1,42 @@
1
+ import { NavStop } from "../nav-order.js";
2
+ import { DocsLinkComponent } from "./markdown-components.js";
3
+ import { ReactNode } from "react";
4
+ //#region src/react/pager.d.ts
5
+ interface DocsPagerProps {
6
+ /** The stop before this page in the reading order. Omit at the beginning. */
7
+ previous?: NavStop | undefined;
8
+ /** The stop after it. Omit at the end. */
9
+ next?: NavStop | undefined;
10
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
11
+ Link?: DocsLinkComponent | undefined;
12
+ /** Above the previous page's title. Defaults to `'Previous'`. */
13
+ previousLabel?: string | undefined;
14
+ /** Above the next page's title. Defaults to `'Next'`. */
15
+ nextLabel?: string | undefined;
16
+ /**
17
+ * Accessible name for the landmark. Defaults to `'Pagination'`.
18
+ *
19
+ * Named because a page can hold more than one navigation landmark — this,
20
+ * the sidebar and the table of contents — and "navigation" three times is
21
+ * not a list anyone can steer by.
22
+ */
23
+ label?: string | undefined;
24
+ className?: string | undefined;
25
+ }
26
+ /**
27
+ * Links to the pages either side of this one in the reading order.
28
+ *
29
+ * Derived from the same tree `DocsSidebar` renders, so it cannot disagree with
30
+ * the column beside it — see `nav-order.ts`. Nothing here is authored: a page
31
+ * gets a pager by being in the navigation.
32
+ *
33
+ * ⚠️ NOTHING RENDERS AT ALL WHEN THERE IS NO NEIGHBOUR EITHER SIDE. A single
34
+ * page site, or a route outside the tree, would otherwise get an empty
35
+ * landmark — announced by a screen reader as a navigation region containing
36
+ * nothing, which is worse than the absence it is standing in for.
37
+ *
38
+ * No client JavaScript: two links and a heading, rendered on the server.
39
+ */
40
+ declare function DocsPager({ previous, next, Link, previousLabel, nextLabel, label, className }: DocsPagerProps): ReactNode;
41
+ //#endregion
42
+ export { DocsPager, DocsPagerProps };
@@ -0,0 +1,91 @@
1
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
2
+ //#region src/react/pager.tsx
3
+ /**
4
+ * Links to the pages either side of this one in the reading order.
5
+ *
6
+ * Derived from the same tree `DocsSidebar` renders, so it cannot disagree with
7
+ * the column beside it — see `nav-order.ts`. Nothing here is authored: a page
8
+ * gets a pager by being in the navigation.
9
+ *
10
+ * ⚠️ NOTHING RENDERS AT ALL WHEN THERE IS NO NEIGHBOUR EITHER SIDE. A single
11
+ * page site, or a route outside the tree, would otherwise get an empty
12
+ * landmark — announced by a screen reader as a navigation region containing
13
+ * nothing, which is worse than the absence it is standing in for.
14
+ *
15
+ * No client JavaScript: two links and a heading, rendered on the server.
16
+ */
17
+ function DocsPager({ previous, next, Link, previousLabel = "Previous", nextLabel = "Next", label = "Pagination", className }) {
18
+ if (previous === void 0 && next === void 0) return null;
19
+ return /* @__PURE__ */ jsxs("nav", {
20
+ "aria-label": label,
21
+ className: ["wave-docs-pager", className].filter(Boolean).join(" "),
22
+ children: [/* @__PURE__ */ jsx(PagerLink, {
23
+ stop: previous,
24
+ direction: "previous",
25
+ caption: previousLabel,
26
+ Link
27
+ }), /* @__PURE__ */ jsx(PagerLink, {
28
+ stop: next,
29
+ direction: "next",
30
+ caption: nextLabel,
31
+ Link
32
+ })]
33
+ });
34
+ }
35
+ function PagerLink({ stop, direction, caption, Link }) {
36
+ if (stop === void 0) return /* @__PURE__ */ jsx("div", { className: "wave-docs-pager__gap" });
37
+ const body = /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs("span", {
38
+ className: "wave-docs-pager__caption",
39
+ children: [
40
+ direction === "previous" ? /* @__PURE__ */ jsx(PagerChevron, {}) : null,
41
+ caption,
42
+ direction === "next" ? /* @__PURE__ */ jsx(PagerChevron, {}) : null
43
+ ]
44
+ }), /* @__PURE__ */ jsx("span", {
45
+ className: "wave-docs-pager__title",
46
+ children: stop.title
47
+ })] });
48
+ const props = {
49
+ className: "wave-docs-pager__link",
50
+ href: stop.href,
51
+ "data-direction": direction,
52
+ "aria-label": `${caption}: ${stop.title}`
53
+ };
54
+ return Link === void 0 ? /* @__PURE__ */ jsx("a", {
55
+ ...props,
56
+ children: body
57
+ }) : /* @__PURE__ */ jsx(Link, {
58
+ ...props,
59
+ children: body
60
+ });
61
+ }
62
+ /**
63
+ * The arrow on the outer edge of each link, pointing the way it goes.
64
+ *
65
+ * Decorative: the link is named "Previous: Installation" by `aria-label`, so
66
+ * this would only repeat a word already in the name — and `⌘`-style symbols
67
+ * read badly when they reach a screen reader at all.
68
+ *
69
+ * Which way it points is the stylesheet's, not this component's: it is a
70
+ * physical direction, and it mirrors under `dir="rtl"` where "previous" is on
71
+ * the right. Rotating it here would put that decision somewhere CSS cannot
72
+ * correct it.
73
+ */
74
+ function PagerChevron() {
75
+ return /* @__PURE__ */ jsx("svg", {
76
+ className: "wave-docs-pager__chevron",
77
+ "aria-hidden": "true",
78
+ focusable: "false",
79
+ viewBox: "0 0 24 24",
80
+ width: "16",
81
+ height: "16",
82
+ fill: "none",
83
+ stroke: "currentColor",
84
+ strokeWidth: "2",
85
+ strokeLinecap: "round",
86
+ strokeLinejoin: "round",
87
+ children: /* @__PURE__ */ jsx("path", { d: "m9 18 6-6-6-6" })
88
+ });
89
+ }
90
+ //#endregion
91
+ export { DocsPager };
@@ -70,6 +70,23 @@ interface DocsLabels {
70
70
  toc?: string | undefined;
71
71
  /** The link at the end of the TOC. Default `'Back to top'`. */
72
72
  backToTop?: string | undefined;
73
+ /**
74
+ * Above the previous page's title in the pager. Defaults to `'Previous'`.
75
+ *
76
+ * The direction, not the destination: the page's own title is the name, and
77
+ * these two words are what say which way it lies.
78
+ */
79
+ previousPage?: string | undefined;
80
+ /** The same for the next page. Defaults to `'Next'`. */
81
+ nextPage?: string | undefined;
82
+ /**
83
+ * Accessible name for the pager landmark. Defaults to `'Pagination'`.
84
+ *
85
+ * A page carries three navigation landmarks — the sidebar, the table of
86
+ * contents and this — and "navigation" three times is not a list anyone can
87
+ * steer by.
88
+ */
89
+ pagination?: string | undefined;
73
90
  /**
74
91
  * Screen-reader suffix on a link that opens a new tab.
75
92
  * Default `'(opens in a new tab)'`.
@@ -137,7 +154,7 @@ interface DocsLabels {
137
154
  * failure being guarded against is a key that is declared, documented and never
138
155
  * read, which type-checks perfectly.
139
156
  */
140
- declare const DOCS_LABEL_KEYS: readonly ["nav", "openNav", "closeNav", "skipToContent", "expandGroup", "collapseGroup", "toc", "backToTop", "externalLink", "table", "calloutNote", "calloutTip", "calloutImportant", "calloutWarning", "calloutCaution", "youtubeTitle", "youtubePlay", "youtubeHide", "copyCode", "copyCodeFrom", "copied", "copyFailed"];
157
+ declare const DOCS_LABEL_KEYS: readonly ["nav", "openNav", "closeNav", "skipToContent", "expandGroup", "collapseGroup", "toc", "backToTop", "externalLink", "table", "calloutNote", "calloutTip", "calloutImportant", "calloutWarning", "calloutCaution", "youtubeTitle", "youtubePlay", "youtubeHide", "copyCode", "copyCodeFrom", "previousPage", "nextPage", "pagination", "copied", "copyFailed"];
141
158
  /** The four the shell renders itself, resolved centrally. */
142
159
  type ShellLabelKey = 'nav' | 'openNav' | 'closeNav' | 'skipToContent';
143
160
  /**
@@ -27,6 +27,9 @@ const DOCS_LABEL_KEYS = [
27
27
  "youtubeHide",
28
28
  "copyCode",
29
29
  "copyCodeFrom",
30
+ "previousPage",
31
+ "nextPage",
32
+ "pagination",
30
33
  "copied",
31
34
  "copyFailed"
32
35
  ];
package/dist/react/toc.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { DOCS_CONTENT_ID } from "../docs-content-id.js";
3
- import { useEffect, useMemo, useRef, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/react/toc.tsx
6
6
  const DEFAULT_ROOT_MARGIN = "0px 0px -60% 0px";
@@ -48,14 +48,26 @@ function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MA
48
48
  lastIds.current = ids;
49
49
  setActiveId(void 0);
50
50
  }
51
+ const inBand = useRef(/* @__PURE__ */ new Set());
52
+ const atBottom = useRef(false);
53
+ const resolveActive = useCallback(() => {
54
+ if (atBottom.current) {
55
+ const last = ids[ids.length - 1];
56
+ if (last !== void 0) {
57
+ setActiveId(last);
58
+ return;
59
+ }
60
+ }
61
+ const next = ids.find((id) => inBand.current.has(id));
62
+ if (next !== void 0) setActiveId(next);
63
+ }, [ids]);
51
64
  useEffect(() => {
52
65
  if (ids.length === 0 || typeof IntersectionObserver === "undefined") return;
53
- const visible = /* @__PURE__ */ new Set();
66
+ const visible = inBand.current;
54
67
  const observer = new IntersectionObserver((records) => {
55
68
  for (const record of records) if (record.isIntersecting) visible.add(record.target.id);
56
69
  else visible.delete(record.target.id);
57
- const next = ids.find((id) => visible.has(id));
58
- if (next !== void 0) setActiveId(next);
70
+ resolveActive();
59
71
  }, {
60
72
  rootMargin,
61
73
  threshold: 0
@@ -81,7 +93,24 @@ function DocsToc({ entries, label = "On this page", rootMargin = DEFAULT_ROOT_MA
81
93
  if (frame !== void 0) cancelAnimationFrame(frame);
82
94
  observer.disconnect();
83
95
  };
84
- }, [ids, rootMargin]);
96
+ }, [
97
+ ids,
98
+ rootMargin,
99
+ resolveActive
100
+ ]);
101
+ useEffect(() => {
102
+ if (ids[ids.length - 1] === void 0 || typeof window === "undefined") return;
103
+ const onScroll = () => {
104
+ const scrollable = document.documentElement.scrollHeight - window.innerHeight;
105
+ const bottom = scrollable > 2 && window.scrollY >= scrollable - 2;
106
+ if (bottom === atBottom.current) return;
107
+ atBottom.current = bottom;
108
+ resolveActive();
109
+ };
110
+ window.addEventListener("scroll", onScroll, { passive: true });
111
+ onScroll();
112
+ return () => window.removeEventListener("scroll", onScroll);
113
+ }, [ids, resolveActive]);
85
114
  if (entries.length === 0) return null;
86
115
  return /* @__PURE__ */ jsxs("nav", {
87
116
  "aria-label": label,
package/dist/styles.css CHANGED
@@ -153,7 +153,7 @@
153
153
  * of padding on every side — so setting this is the whole of resizing the
154
154
  * control, and the hit area grows with it rather than being a second number
155
155
  * to keep in step. */
156
- --wave-docs-trigger-width: 1.25rem;
156
+ --wave-docs-trigger-width: 1rem;
157
157
  --wave-docs-toc-width: 15rem;
158
158
 
159
159
  /*
@@ -382,12 +382,19 @@
382
382
  * Declared at all because inheriting means a host that never set a family
383
383
  * renders its documentation in the UA serif. `--wave-docs-font-sans: inherit`
384
384
  * gives the host's typeface back in one line.
385
+ *
386
+ * ⚠️ EVERY NEW ROOT HAS TO BE ADDED HERE, AND ONE ALREADY WAS NOT. The pager
387
+ * sits inside `<main>` but outside `.wave-docs-prose` — `DocContent` owns
388
+ * that class — so it matched nothing in this list and shipped its page titles
389
+ * in Times. Caught in a screenshot, not by a test: nothing here fails when a
390
+ * root is missing, it just renders wrong.
385
391
  */
386
392
  .wave-docs-prose,
387
393
  .wave-docs-sidebar,
388
394
  .wave-docs-toc,
389
395
  .wave-docs-skip-link,
390
396
  .wave-docs-hero,
397
+ .wave-docs-pager,
391
398
  .wave-docs-layout__sidebar,
392
399
  .wave-docs-search-trigger,
393
400
  .wave-docs-search-dialog {
@@ -1764,18 +1771,85 @@
1764
1771
  rotate: 90deg;
1765
1772
  }
1766
1773
 
1774
+ /*
1775
+ * A separator ends the block above it as much as it names the block below,
1776
+ * and a rule says that in a way whitespace alone cannot: at 1rem of margin
1777
+ * the gap read as "these two lists are a bit far apart" rather than as a
1778
+ * division.
1779
+ *
1780
+ * ⚠️ THE RULE AND THE LABEL SHARE THE LIST'S OWN EDGE, WHICH IS ALSO THE
1781
+ * ROWS'. A row is full-bleed — its hover surface spans the whole column, and
1782
+ * so does the search field above it — so a rule on that edge divides the
1783
+ * column, while an inset one floats inside it. The label sits on the same
1784
+ * line, because a heading and the rule above it reading as one object is the
1785
+ * whole reason the rule is there.
1786
+ *
1787
+ * Measured before this: the label sat at 24px, the rows' *icon* column. It
1788
+ * headed a column of decoration, a step in from the words it names and a step
1789
+ * out from the edge — aligned with neither.
1790
+ *
1791
+ * ⚠️ AND NO RULE ON THE FIRST CHILD, which would draw a line above nothing. A
1792
+ * `meta.json` may open with `"---Reference---"`, and on that tree the very
1793
+ * first thing in the navigation would be a hairline.
1794
+ */
1767
1795
  .wave-docs-sidebar__separator-item {
1768
1796
  margin-block: 1rem 0.25rem;
1769
1797
  }
1770
1798
 
1799
+ .wave-docs-sidebar__separator-item:not(:first-child) {
1800
+ margin-block-start: 1rem;
1801
+ padding-block-start: 1rem;
1802
+ border-block-start: 1px solid var(--wave-docs-border);
1803
+ }
1804
+
1805
+ /*
1806
+ * ⚠️ NO `text-transform: uppercase`, AND IT USED TO HAVE ONE.
1807
+ *
1808
+ * A separator's text is authored — `"---Reference---"` in someone's
1809
+ * `meta.json` — so uppercasing it is this package rewriting words it does not
1810
+ * own, in a language it cannot read. Portuguese `Referência` came out as
1811
+ * `REFERÊNCIA`; Turkish loses the dotted and dotless `i` to each other, and
1812
+ * no CJK script has a case to transform at all, so those authors got the
1813
+ * `letter-spacing` and none of the effect it was there to rescue. And it
1814
+ * could not be turned off — the string is a prop, but its shape was ours.
1815
+ *
1816
+ * The job the caps were doing — read as a divider, not as a row — is already
1817
+ * done by the size, the weight and the subtle colour, none of which touch a
1818
+ * single character. `letter-spacing` went with them: it exists to make caps
1819
+ * legible, and on sentence case it only reads as loose.
1820
+ */
1821
+ /*
1822
+ * The label starts where a row's content starts — the marker column when
1823
+ * there is one, the words when there is not.
1824
+ *
1825
+ * ⚠️ THAT IS ONE NUMBER, NOT TWO, AND CHASING IT WITH `:has()` WAS THE WRONG
1826
+ * SHAPE. A row's own `padding-inline` is what both modes have in common: with
1827
+ * markers the icon sits on it, with `icons={false}` the text does. Matching
1828
+ * the row's padding lands on whichever is there, in both modes, with no
1829
+ * query and nothing threaded.
1830
+ *
1831
+ * The *rule* keeps the column's edge instead — it divides the column rather
1832
+ * than naming anything. See `__separator-item`.
1833
+ */
1771
1834
  .wave-docs-sidebar__separator {
1772
1835
  display: block;
1773
- padding-inline: 0.5rem;
1836
+ padding-inline-start: 0.5rem;
1774
1837
  color: var(--wave-docs-fg-subtle);
1775
1838
  font-size: 0.75rem;
1776
- font-weight: 650;
1777
- letter-spacing: 0.06em;
1778
- text-transform: uppercase;
1839
+ /*
1840
+ * ⚠️ THE WEIGHT IS THE LEVER HERE, NOT THE COLOUR. At 650 this label was
1841
+ * heavier than the group titles it sits under and read as the loudest thing
1842
+ * in the column — a divider out-shouting the navigation it divides.
1843
+ *
1844
+ * The obvious fix is a lighter colour, and there is none to reach for:
1845
+ * `--wave-docs-fg-subtle` is already the lightest text token at 5.05:1 on
1846
+ * the page, against WCAG 1.4.3's 4.5:1 floor for text this size — 0.55 of
1847
+ * slack. `--wave-docs-border`, which is what the rule above it is painted
1848
+ * with, measures 1.31:1 and is a line colour, not a text one.
1849
+ *
1850
+ * 500 drops it below the rows without touching the contrast at all.
1851
+ */
1852
+ font-weight: 500;
1779
1853
  }
1780
1854
 
1781
1855
  @media (prefers-reduced-motion: no-preference) {
@@ -1814,6 +1888,133 @@
1814
1888
  }
1815
1889
  }
1816
1890
 
1891
+ /* ---------------------------------------------------------------------------
1892
+ * Pager
1893
+ *
1894
+ * Links to the pages either side of this one, emitted by `docs.Page` at the
1895
+ * foot of the article.
1896
+ * ------------------------------------------------------------------------ */
1897
+
1898
+ @layer components {
1899
+ /*
1900
+ * ⚠️ TWO TRACKS, ALWAYS — WHICH IS WHY THE COMPONENT RENDERS AN EMPTY CELL
1901
+ * RATHER THAN NOTHING AT EACH END OF THE SEQUENCE. With `auto-fit` or a
1902
+ * plain flex row, the first page of a site puts its only link — `Next` — on
1903
+ * the left, and every other page puts it on the right. The reader learns the
1904
+ * position, and the one page where it moves is the one they see first.
1905
+ *
1906
+ * ⚠️ THE MEASURE *AND* THE CENTRING, WHICH IS TWO DECLARATIONS AND I SHIPPED
1907
+ * ONE. `.wave-docs-prose` is capped and centred inside `__main`, so a
1908
+ * `max-width` alone left this the right width in the wrong place: the
1909
+ * paragraphs began at 440px and the pager at 304px, a 136px step directly
1910
+ * under them. It belongs to the prose, so it takes both halves of what puts
1911
+ * the prose where it is.
1912
+ */
1913
+ .wave-docs-pager {
1914
+ display: grid;
1915
+ margin-inline: auto;
1916
+ /* `minmax(0, 1fr)`, never a bare `1fr`: that floors each track at its
1917
+ * content's min-content width, so a long page title pushes the pair past
1918
+ * the measure instead of wrapping inside it. */
1919
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
1920
+ gap: 0.75rem;
1921
+ max-width: var(--wave-docs-measure);
1922
+ margin-block-start: 3rem;
1923
+ }
1924
+
1925
+ .wave-docs-pager__link {
1926
+ display: flex;
1927
+ flex-direction: column;
1928
+ gap: 0.25rem;
1929
+ padding: 0.75rem 1rem;
1930
+ border: 1px solid var(--wave-docs-border);
1931
+ border-radius: var(--wave-docs-radius);
1932
+ color: var(--wave-docs-fg);
1933
+ text-decoration: none;
1934
+ }
1935
+
1936
+ /*
1937
+ * The next page is set to its own edge, so the pair reads outward from the
1938
+ * middle — the direction each one takes you. `text-align` and not
1939
+ * `align-items`, so a title that wraps to two lines stays aligned with
1940
+ * itself rather than ragging against the box.
1941
+ */
1942
+ .wave-docs-pager__link[data-direction='next'] {
1943
+ text-align: end;
1944
+ }
1945
+
1946
+ /*
1947
+ * ⚠️ THE ARROW POINTS OUTWARD, AND "OUTWARD" MIRRORS. Under `dir="rtl"` the
1948
+ * grid's first track is on the right, so the *previous* link moves there and
1949
+ * its arrow has to point right — the reverse of both rules above it. Same
1950
+ * shape as the sidebar's chevron, and the same reason it is `[dir='rtl']`
1951
+ * rather than `:dir(rtl)`: lightningcss rewrites the latter into a list of
1952
+ * right-to-left *languages*, which is not the same question.
1953
+ *
1954
+ * Source order is load-bearing in the middle pair: `[dir='rtl'] .chevron` and
1955
+ * `[data-direction='next'] .chevron` have the same specificity, so the
1956
+ * mirror only wins by being written second.
1957
+ */
1958
+ .wave-docs-pager__chevron {
1959
+ flex: none;
1960
+ /* Sized down from the 16 it is drawn at: beside 12px type a full-size
1961
+ * chevron is the loudest thing in the card. */
1962
+ inline-size: 0.875rem;
1963
+ block-size: 0.875rem;
1964
+ rotate: 180deg;
1965
+ }
1966
+
1967
+ .wave-docs-pager__link[data-direction='next'] .wave-docs-pager__chevron {
1968
+ rotate: 0deg;
1969
+ }
1970
+
1971
+ [dir='rtl'] .wave-docs-pager__chevron {
1972
+ rotate: 0deg;
1973
+ }
1974
+
1975
+ [dir='rtl']
1976
+ .wave-docs-pager__link[data-direction='next']
1977
+ .wave-docs-pager__chevron {
1978
+ rotate: 180deg;
1979
+ }
1980
+
1981
+ /* The arrow shares the caption's line, so the two read as one label and the
1982
+ * title below starts at the card's own edge. */
1983
+ .wave-docs-pager__caption {
1984
+ display: flex;
1985
+ align-items: center;
1986
+ gap: 0.25rem;
1987
+ color: var(--wave-docs-fg-subtle);
1988
+ font-size: 0.75rem;
1989
+ }
1990
+
1991
+ .wave-docs-pager__link[data-direction='next'] .wave-docs-pager__caption {
1992
+ justify-content: flex-end;
1993
+ }
1994
+
1995
+ .wave-docs-pager__title {
1996
+ font-weight: 600;
1997
+ }
1998
+
1999
+ .wave-docs-pager__link:hover {
2000
+ border-color: var(--wave-docs-border-strong);
2001
+ background: var(--wave-docs-bg-subtle);
2002
+ }
2003
+
2004
+ .wave-docs-pager__link:focus-visible {
2005
+ outline: 2px solid var(--wave-docs-accent);
2006
+ outline-offset: 2px;
2007
+ }
2008
+
2009
+ @media (prefers-reduced-motion: no-preference) {
2010
+ .wave-docs-pager__link {
2011
+ transition:
2012
+ background-color 150ms ease-out,
2013
+ border-color 150ms ease-out;
2014
+ }
2015
+ }
2016
+ }
2017
+
1817
2018
  /* ---------------------------------------------------------------------------
1818
2019
  * Table of contents
1819
2020
  * ------------------------------------------------------------------------ */
@@ -2560,6 +2761,47 @@
2560
2761
  * 20px transparent border for a while to answer to 44 while painting 24, and
2561
2762
  * a box the eye cannot see is a box the next person deletes.
2562
2763
  */
2764
+ /*
2765
+ * ⚠️ THE GRIP'S COLOUR IS A CUSTOM PROPERTY, NOT A SELECTOR FIGHT.
2766
+ *
2767
+ * Three things want to set it: the resting style, the reader's pointer, and
2768
+ * the sidebar's own state — and the state lives on an *ancestor*, so
2769
+ * `.sidebar[data-state] .trigger::before` outranks `.trigger:hover::before`
2770
+ * by a whole class. Written as backgrounds that is a rule that silently kills
2771
+ * hover on the one state that still needs it.
2772
+ *
2773
+ * Custom properties settle it by inheritance instead: the state sets them on
2774
+ * the sidebar, the trigger sets them on itself under `:hover`, and a value on
2775
+ * the element always beats one it inherited. No specificity, no `!important`,
2776
+ * and the hover rule stays two lines long.
2777
+ */
2778
+ .wave-docs-layout__sidebar {
2779
+ --wave-docs-trigger-fill: var(--wave-docs-border-strong);
2780
+ --wave-docs-trigger-ink: var(--wave-docs-fg-muted);
2781
+ --wave-docs-trigger-opacity: 0.4;
2782
+ }
2783
+
2784
+ /*
2785
+ * Blue means the grip is the thing to press; grey means it is at rest.
2786
+ *
2787
+ * The navigation is hidden and this is the way back to it — so a closed
2788
+ * sidebar lights the grip, exactly as the pointer does. Open, the grip has
2789
+ * nothing to offer that the visible column does not, and it goes quiet.
2790
+ *
2791
+ * ⚠️ `:not([data-state='open'])`, NOT `[data-state='closed']`. Below 64rem
2792
+ * the sidebar starts closed and the attribute is absent until the reader
2793
+ * chooses — a server-rendered page has no state at all. Matching only the
2794
+ * explicit value leaves every first paint on a phone showing a grey grip in
2795
+ * front of hidden navigation, which is the one moment the cue is for.
2796
+ *
2797
+ * The wide layout inverts the default, and overrides this below.
2798
+ */
2799
+ .wave-docs-layout__sidebar:not([data-state='open']) {
2800
+ --wave-docs-trigger-fill: var(--wave-docs-accent);
2801
+ --wave-docs-trigger-ink: var(--wave-docs-accent-fg);
2802
+ --wave-docs-trigger-opacity: 1;
2803
+ }
2804
+
2563
2805
  .wave-docs-layout__sidebar-trigger {
2564
2806
  flex: none;
2565
2807
  /*
@@ -2589,7 +2831,7 @@
2589
2831
  padding: 4px;
2590
2832
  border: 0;
2591
2833
  background: none;
2592
- color: var(--wave-docs-fg-muted);
2834
+ color: var(--wave-docs-trigger-ink);
2593
2835
  cursor: pointer;
2594
2836
  }
2595
2837
 
@@ -2607,9 +2849,16 @@
2607
2849
  .wave-docs-layout__sidebar-trigger::before {
2608
2850
  content: '';
2609
2851
  width: var(--wave-docs-trigger-width);
2610
- height: 5rem;
2611
- border-radius: var(--wave-docs-radius-sm);
2612
- background-color: var(--wave-docs-border-strong);
2852
+ /*
2853
+ * 16 x 56 — a 3.5:1 grip. It was 20 x 80, which at the old width read as a
2854
+ * slab; narrowing it made the length obvious. The floor is 18px, the span
2855
+ * of the three dots, and nothing here scales with the pill: see `::after`.
2856
+ */
2857
+ height: 3.5rem;
2858
+ /* A full pill: the radius is half the *short* side, so `9999px` clamps to
2859
+ * exactly that whatever the two dimensions become. */
2860
+ border-radius: 9999px;
2861
+ background-color: var(--wave-docs-trigger-fill);
2613
2862
  }
2614
2863
 
2615
2864
  /*
@@ -2631,11 +2880,49 @@
2631
2880
  translate: -50% -50%;
2632
2881
  border-radius: 50%;
2633
2882
  background: currentcolor;
2883
+ /*
2884
+ * ⚠️ THE DOTS DO NOT SCALE WITH THE PILL. They are `box-shadow` offsets on
2885
+ * one element, so ±7px is 18px of ink whatever `--wave-docs-trigger-width`
2886
+ * or the pill's height happen to be. Shorten the pill below 18px and the
2887
+ * outer two sit outside it, top and bottom — measured, not guessed.
2888
+ */
2634
2889
  box-shadow:
2635
2890
  0 -7px 0 currentcolor,
2636
2891
  0 7px 0 currentcolor;
2637
2892
  }
2638
2893
 
2894
+ /*
2895
+ * Faded at rest, full strength under a pointer or a caret — the same
2896
+ * treatment as the tree's markers, and for the same reason: this is a handle,
2897
+ * not a thing to look at while reading.
2898
+ *
2899
+ * ⚠️ BOTH MARKS, NOT THE PILL ALONE. Fading `::before` by itself leaves the
2900
+ * dots at full strength on a washed-out slab, which reads louder than the
2901
+ * solid grip it replaced — the opposite of the point.
2902
+ *
2903
+ * ⚠️ AND NOT ON THE BUTTON ITSELF, which would take the focus ring down with
2904
+ * it: `opacity` applies to the whole element, outline included, so a keyboard
2905
+ * reader would get a 40% indicator on a control they had just moved to.
2906
+ *
2907
+ * ⚠️ THE TAP TARGET IS UNTOUCHED BY ANY OF THIS. The `<button>` is the entire
2908
+ * strip and runs the height of the column; the pill is paint. Its 80px was
2909
+ * only ever ink, which is why 32px costs nothing a finger can feel.
2910
+ */
2911
+ .wave-docs-layout__sidebar-trigger::before,
2912
+ .wave-docs-layout__sidebar-trigger::after {
2913
+ opacity: var(--wave-docs-trigger-opacity);
2914
+ }
2915
+
2916
+ /* On the element, not on the pseudo-elements: a custom property set here is
2917
+ * inherited by both, and beats the sidebar's regardless of how specific the
2918
+ * state selector that set it was. */
2919
+ .wave-docs-layout__sidebar-trigger:hover,
2920
+ .wave-docs-layout__sidebar-trigger:focus-visible {
2921
+ --wave-docs-trigger-fill: var(--wave-docs-accent);
2922
+ --wave-docs-trigger-ink: var(--wave-docs-accent-fg);
2923
+ --wave-docs-trigger-opacity: 1;
2924
+ }
2925
+
2639
2926
  /*
2640
2927
  * ⚠️ THE STRIP'S HOVER LIGHTS THE BUTTON TOO, AND THAT IS WHAT MAKES IT ONE
2641
2928
  * CONTROL. Hovering 44px of strip while the button inside it stays grey reads
@@ -2648,11 +2935,6 @@
2648
2935
  var(--wave-docs-accent) 30%,
2649
2936
  transparent
2650
2937
  );
2651
- color: var(--wave-docs-accent-fg);
2652
- }
2653
-
2654
- .wave-docs-layout__sidebar-trigger:hover::before {
2655
- background-color: var(--wave-docs-accent);
2656
2938
  }
2657
2939
 
2658
2940
  @media (prefers-reduced-motion: no-preference) {
@@ -2667,7 +2949,8 @@
2667
2949
  */
2668
2950
  transition:
2669
2951
  background-color 120ms ease-out,
2670
- color 120ms ease-out;
2952
+ color 120ms ease-out,
2953
+ opacity 120ms ease-out;
2671
2954
  }
2672
2955
  }
2673
2956
 
@@ -2775,6 +3058,18 @@
2775
3058
  margin-inline-end: 0;
2776
3059
  }
2777
3060
 
3061
+ /*
3062
+ * Unchosen means *open* up here, so the grip is at rest by default — the
3063
+ * mirror of the rule above, and the reason that one is written as
3064
+ * "not open" rather than "closed". A closed sidebar keeps the blue it
3065
+ * inherits from there; nothing needs restating.
3066
+ */
3067
+ .wave-docs-layout__sidebar:not([data-state='closed']) {
3068
+ --wave-docs-trigger-fill: var(--wave-docs-border-strong);
3069
+ --wave-docs-trigger-ink: var(--wave-docs-fg-muted);
3070
+ --wave-docs-trigger-opacity: 0.4;
3071
+ }
3072
+
2778
3073
  /* Nothing is covered, so nothing is scrimmed. */
2779
3074
  .wave-docs-layout__sidebar-scrim {
2780
3075
  display: none;
@@ -2945,20 +3240,64 @@
2945
3240
  border-radius: var(--wave-docs-radius-sm);
2946
3241
  }
2947
3242
 
3243
+ /*
3244
+ * ⚠️ THE LABEL'S SIZE, AND IT IS THE CAPS THAT HAD TO MATCH — NOT THE NUMBER.
3245
+ *
3246
+ * `Search` is `ui-sans-serif` and this is `ui-monospace`, and the same
3247
+ * `font-size` in two families does not give you the same letter. Measured at
3248
+ * 14px: the label's `S` carries 10.33px of ink and the mono `K` carries
3249
+ * 10.21px. A tenth of a pixel — invisible until you look, and then it is the
3250
+ * only thing you can see.
3251
+ *
3252
+ * `1.012em` is that tenth. Mono here draws 0.7292px of cap per px of
3253
+ * `font-size`, so 10.33px of cap needs 14.17px, which is 1.012 x the 14px it
3254
+ * sits beside. `em` rather than a `rem` constant so it tracks the label if
3255
+ * the trigger's size ever moves.
3256
+ *
3257
+ * ⚠️ IT IS A MEASURED CONSTANT AND IT BELONGS TO THESE TWO FAMILIES. Change
3258
+ * `--wave-docs-font-mono` or `--wave-docs-font-sans` and it is wrong — not
3259
+ * broken, just quietly a tenth of a pixel out again. Re-measure the caps, do
3260
+ * not re-derive from the declaration.
3261
+ *
3262
+ * Not in the shared rule above: the footer's caps sit in a 12px footer and
3263
+ * belong at 12px. This is the only `<kbd>` standing beside body-sized text.
3264
+ */
3265
+ .wave-docs-search-trigger-kbd {
3266
+ font-size: 1.012em;
3267
+ }
3268
+
2948
3269
  /*
2949
3270
  * ⚠️ THE SYMBOL IS SCALED AND THE WORD IS NOT — which is why this is a
2950
3271
  * separate element rather than one string in the `<kbd>`.
2951
3272
  *
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.
3273
+ * Measured in the shipped mono stack: `⌘` carries 6.39px of ink per 8.75px of
3274
+ * `K`, so at a shared `font-size` it sits a third short of the letter next to
3275
+ * it. `1.369em` is exactly that ratio, and it makes the two the same height.
3276
+ *
3277
+ * ⚠️ NOT A HAIR TALLER, WHICH IS WHERE THIS FIRST LANDED. `1.45em` gave the
3278
+ * symbol 6% on the `K` — invisible at 12px, and at this size 0.6px of ink
3279
+ * standing above the cap line. It made the whole badge read as *bigger type*
3280
+ * than the label beside it: measured against the word `Search`, 88 device px
3281
+ * of ink against 85, its top three device px higher and its centre 1.5 out.
3282
+ * Same letter height, louder cluster. The eye compares the clusters.
2955
3283
  *
2956
3284
  * `Ctrl` gets none of it: it is a word set in the same face as the `K`, and
2957
3285
  * scaling it would make the hint shout.
2958
3286
  */
2959
3287
  .wave-docs-search-trigger-mod[data-symbol] {
2960
- font-size: 1.45em;
2961
- line-height: 1;
3288
+ font-size: 1.369em;
3289
+ /*
3290
+ * ⚠️ `0`, NOT `1` — THIS GLYPH MUST NOT SIZE THE LINE IT SITS ON. A line
3291
+ * box is as tall as the tallest inline box in it, so at `line-height: 1`
3292
+ * this 20.5px symbol made the `<kbd>` 20.55px tall against the label's 17,
3293
+ * and the two were centred as flex items by their *boxes*. Same cap height,
3294
+ * 1.9px apart on screen — measured off an 8x render, invisible in the CSS.
3295
+ *
3296
+ * At `0` the symbol contributes no height, the `<kbd>` keeps the line box
3297
+ * its own font gives it, and the `K` centres with the `S` beside it. The
3298
+ * glyph still paints in full: `line-height` bounds the line, not the ink.
3299
+ */
3300
+ line-height: 0;
2962
3301
  /*
2963
3302
  * ⚠️ AND IT HAS TO BE MOVED DOWN, WHICH `font-size` ALONE WILL NEVER DO.
2964
3303
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waveso/docs",
3
- "version": "0.9.0",
3
+ "version": "0.10.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": [
@@ -107,6 +107,10 @@
107
107
  "./react/youtube": {
108
108
  "types": "./dist/react/youtube.d.ts",
109
109
  "default": "./dist/react/youtube.js"
110
+ },
111
+ "./react/pager": {
112
+ "types": "./dist/react/pager.d.ts",
113
+ "default": "./dist/react/pager.js"
110
114
  }
111
115
  },
112
116
  "files": [