@waveso/docs 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,8 +69,8 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
69
69
 
70
70
  | | At most |
71
71
  | --- | --- |
72
- | Everything the quick start ships, gzipped | 14.6 KB |
73
- | Search dialog and router wiring | 9.5 KB |
72
+ | Everything the quick start ships, gzipped | 14.9 KB |
73
+ | Search dialog and router wiring | 9.8 KB |
74
74
  | Navigation: one sidebar, open and closed | 3.1 KB |
75
75
  | Table of contents | 1 KB |
76
76
  | Copy-button runtime | 1.1 KB |
@@ -78,7 +78,7 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
78
78
  | hast over the wire vs HTML, code and tables | 1.12× |
79
79
  | Highlighting vs no highlighting | 2.00× |
80
80
 
81
- The first row is the honest total: a reader who lands on a page of your documentation downloads under 14.6 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.
81
+ The first row is the honest total: a reader who lands on a page of your documentation downloads under 14.9 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.
82
82
 
83
83
  The one real cost is the middle pair: shipping a tree instead of a string is about 20% more brotli on a prose page, and about 12% on a page with code and tables, where Shiki's token spans dominate both representations equally. That is the price of never handing markup to `dangerouslySetInnerHTML`, and it is the first number a skeptical reviewer should ask for.
84
84
 
@@ -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
+ | `DocsExplore` | `react/explore` | "Where to go next": a question per row and the page that answers it. `docs.Page` renders it when a page declares `explore` |
206
207
  | `DocsPager` | `react/pager` | Links to the pages either side of this one. `docs.Page` renders it; `pager: false` on the route omits it |
207
208
  | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
208
209
  | `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
@@ -215,6 +216,40 @@ Every component takes data as props, and every module that imports from `next/*`
215
216
 
216
217
  `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.
217
218
 
219
+ ### Where to go next
220
+
221
+ A sidebar is a structure; this is a router. It says *why* a reader would go somewhere, which no tree of titles can. A page opts in from its frontmatter:
222
+
223
+ ```yaml
224
+ ---
225
+ title: How it fits together
226
+ explore:
227
+ - question: How a person is recognised across servers
228
+ href: ./identity.md
229
+ - question: What happens when the network fails
230
+ href: ./delivery.md
231
+ - question: Where the source lives
232
+ href: https://github.com/example/repo
233
+ title: GitHub
234
+ ---
235
+ ```
236
+
237
+ | Field | Type | What it is |
238
+ | --- | --- | --- |
239
+ | `question` | `string` | What the reader might want to know. The row's left half |
240
+ | `href` | `string` | Where the answer is. Checked against the same allowlist as every other link |
241
+ | `title` | `string` | The link's text. Defaults to the destination's title **in the navigation** |
242
+
243
+ **The link text comes from the navigation**, so renaming a page updates every block pointing at it — the same source the sidebar and the pager read. Name a `title` only where the tree cannot answer: an external link, or a page kept out of the navigation. An `href` that resolves to neither stops the build and says which of the two fixes to reach for, rather than rendering a URL where a sentence should be.
244
+
245
+ It renders above the pager: this is the semantic answer, the pager is the linear one.
246
+
247
+ It wears **the panel** — a framed block with a header and an inset surface, styled by `.wave-docs-panel`, `.wave-docs-panel__header`, `.wave-docs-panel__title`, `.wave-docs-panel__actions` and `.wave-docs-panel__body`. That is a shared primitive rather than this component's furniture, so a code frame can wear it next without a second copy of the same rules. The two radii are not independent: the inner one is the outer minus the panel's padding, or the corners run at different curvatures and the surface reads as pasted onto the frame instead of set into it. `--wave-docs-radius-lg` is picked so the arithmetic lands on `--wave-docs-radius`.
248
+
249
+ The panel also exports `--wave-docs-panel-inset` for anything placed inside `__body`: the header's title sits at the frame's padding, but body content sits at that padding *plus the body's own border*, so the two columns miss each other by a pixel per border unless the inset is used.
250
+
251
+ `DocsExplore` takes `steps` (each with `question`, `href` and a resolved `title`), plus `heading`, `Link`, `externalLabel` and `className`. The heading defaults to `'Where to go next'` and is `explore` in `labels`. Under a 40rem container the question and its answer stack instead of sharing a row.
252
+
218
253
  ### The pager
219
254
 
220
255
  `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.
@@ -686,6 +721,48 @@ your own `:root`, after the import:
686
721
  }
687
722
  ```
688
723
 
724
+ ### Corners
725
+
726
+ Three tiers, all derived from one root, so a box's radius is decided by what
727
+ *kind* of box it is rather than by how big it happens to be.
728
+
729
+ | Token | What takes it |
730
+ | --- | --- |
731
+ | `--wave-docs-radius-sm` | Inline chips, small controls, and focus rings drawn on those |
732
+ | `--wave-docs-radius` | Controls, overlays, and the panel's inset surface |
733
+ | `--wave-docs-radius-lg` | Every block in the reading flow, and the panel's outer edge |
734
+
735
+ **Retune all three from one line.** `--wave-docs-radius-base` is the root and
736
+ the other three are `calc()` off it, so a host already running `@waveso/ui`
737
+ points this package at their scale and every corner follows — including any
738
+ theme that moves it:
739
+
740
+ ```css
741
+ :root {
742
+ --wave-docs-radius-base: var(--radius);
743
+ }
744
+ ```
745
+
746
+ The numbers are `@waveso/ui`'s to begin with. A page running both should not
747
+ show two radius scales a few pixels apart, and taking theirs is how that is
748
+ guaranteed rather than kept in step by hand.
749
+
750
+ ⚠️ **`--wave-docs-radius-step` is not decoration.** The panel's inset surface
751
+ takes the base radius and its frame takes `--wave-docs-radius-lg`, which is the
752
+ base plus one step — so the two corners are concentric only while the frame's
753
+ *padding* is that same step, which is why it is paid out of the token rather
754
+ than written as `4px`. Move the root and both stay true; hard-code the padding
755
+ and they drift the first time anyone retunes the scale.
756
+
757
+ Where the browser supports `corner-shape`, every corner this package draws
758
+ becomes a squircle and the root moves up, because a squircle reads tighter than
759
+ a circular arc at the same radius. `@waveso/ui` makes the same move to the same
760
+ value. Elsewhere it is an ordinary rounded corner at the original scale — an
761
+ enhancement, never a dependency. The shaping is scoped to elements this package
762
+ owns rather than applied with `*`: this stylesheet is mounted inside somebody
763
+ else's page, and reshaping the host's corners is the same trespass as claiming
764
+ `html`.
765
+
689
766
  That works because **everything this stylesheet declares lives in a `@layer`** —
690
767
  `theme` for the tokens, `base` for element resets, `components` for the classes
691
768
  — and unlayered CSS outranks every layer regardless of specificity.
@@ -16,6 +16,33 @@ declare const CODE_COPY_ATTRIBUTE = "data-wave-docs-copy";
16
16
  * tab stop where a control should be.
17
17
  */
18
18
  declare const CODE_READY_ATTRIBUTE = "data-wave-docs-code-ready";
19
+ /**
20
+ * The copy button's three icons, as Lucide paths.
21
+ *
22
+ * ⚠️ PATHS FROM THE SAME SET AS EVERY OTHER ICON HERE, AND THEY WERE FONT
23
+ * GLYPHS. The button rendered `⧉` and swapped in `✓` and `×` through CSS
24
+ * `content` — three characters drawn by whatever font resolved, at whatever
25
+ * weight and baseline that font has, beside a sidebar, a pager and a search
26
+ * dialog that are all Lucide at `stroke-width: 2`. It read as a different
27
+ * icon set because it was one.
28
+ *
29
+ * `copy`, `check` and `x`, on the 24×24 grid the rest of the package uses.
30
+ * Lucide draws its `copy` as a `<rect>` plus a `<path>`; the rect is written
31
+ * here as the path it is, so a frame's icons are one shape of node and the
32
+ * builder stays a list of `d` strings — the same shape `NAV_ICON_PATHS` has.
33
+ */
34
+ declare const CODE_ICON_PATHS: Record<'copy' | 'check' | 'x', string[]>;
35
+ /**
36
+ * The three states the button draws, in the order they are emitted.
37
+ *
38
+ * `idle` is what a reader sees; the runtime writes `data-copied` on the button
39
+ * and the stylesheet swaps which of the three is displayed. `display`, not
40
+ * `visibility`: the button is `visibility: hidden` until the runtime attaches,
41
+ * and `visibility` inherits — a child setting it back to `visible` would show
42
+ * an icon inside a button that is meant to be invisible and out of the tab
43
+ * order.
44
+ */
45
+ declare const CODE_ICON_STATES: readonly [readonly ["idle", "copy"], readonly ["copied", "check"], readonly ["failed", "x"]];
19
46
  /**
20
47
  * Whether a tree contains a code frame.
21
48
  *
@@ -26,4 +53,4 @@ declare const CODE_READY_ATTRIBUTE = "data-wave-docs-code-ready";
26
53
  */
27
54
  declare function hasCodeFrame(tree: Root): boolean;
28
55
  //#endregion
29
- export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_READY_ATTRIBUTE, hasCodeFrame };
56
+ export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_ICON_PATHS, CODE_ICON_STATES, CODE_READY_ATTRIBUTE, hasCodeFrame };
@@ -16,6 +16,41 @@ const CODE_COPY_ATTRIBUTE = "data-wave-docs-copy";
16
16
  */
17
17
  const CODE_READY_ATTRIBUTE = "data-wave-docs-code-ready";
18
18
  /**
19
+ * The copy button's three icons, as Lucide paths.
20
+ *
21
+ * ⚠️ PATHS FROM THE SAME SET AS EVERY OTHER ICON HERE, AND THEY WERE FONT
22
+ * GLYPHS. The button rendered `⧉` and swapped in `✓` and `×` through CSS
23
+ * `content` — three characters drawn by whatever font resolved, at whatever
24
+ * weight and baseline that font has, beside a sidebar, a pager and a search
25
+ * dialog that are all Lucide at `stroke-width: 2`. It read as a different
26
+ * icon set because it was one.
27
+ *
28
+ * `copy`, `check` and `x`, on the 24×24 grid the rest of the package uses.
29
+ * Lucide draws its `copy` as a `<rect>` plus a `<path>`; the rect is written
30
+ * here as the path it is, so a frame's icons are one shape of node and the
31
+ * builder stays a list of `d` strings — the same shape `NAV_ICON_PATHS` has.
32
+ */
33
+ const CODE_ICON_PATHS = {
34
+ copy: ["M10 8h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H10a2 2 0 0 1-2-2V10a2 2 0 0 1 2-2z", "M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"],
35
+ check: ["M20 6 9 17l-5-5"],
36
+ x: ["M18 6 6 18", "m6 6 12 12"]
37
+ };
38
+ /**
39
+ * The three states the button draws, in the order they are emitted.
40
+ *
41
+ * `idle` is what a reader sees; the runtime writes `data-copied` on the button
42
+ * and the stylesheet swaps which of the three is displayed. `display`, not
43
+ * `visibility`: the button is `visibility: hidden` until the runtime attaches,
44
+ * and `visibility` inherits — a child setting it back to `visible` would show
45
+ * an icon inside a button that is meant to be invisible and out of the tab
46
+ * order.
47
+ */
48
+ const CODE_ICON_STATES = [
49
+ ["idle", "copy"],
50
+ ["copied", "check"],
51
+ ["failed", "x"]
52
+ ];
53
+ /**
19
54
  * Whether a tree contains a code frame.
20
55
  *
21
56
  * `DocContent` asks before rendering the runtime, so a page with no fences
@@ -38,4 +73,4 @@ function isCodeFrame(node) {
38
73
  return node.properties[CODE_FRAME_ATTRIBUTE] !== void 0;
39
74
  }
40
75
  //#endregion
41
- export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_READY_ATTRIBUTE, hasCodeFrame };
76
+ export { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_ICON_PATHS, CODE_ICON_STATES, CODE_READY_ATTRIBUTE, hasCodeFrame };
@@ -33,6 +33,11 @@ declare const docFrontmatterSchema: z.ZodObject<{
33
33
  aliases: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
34
34
  order: z.ZodExactOptional<z.ZodNumber>;
35
35
  icon: z.ZodExactOptional<z.ZodString>;
36
+ explore: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
37
+ question: z.ZodString;
38
+ href: z.ZodString;
39
+ title: z.ZodExactOptional<z.ZodString>;
40
+ }, z.core.$strip>>>;
36
41
  actions: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
37
42
  label: z.ZodString;
38
43
  href: z.ZodString;
@@ -33,6 +33,11 @@ const docFrontmatterSchema = z.object({
33
33
  aliases: z.array(z.string()).exactOptional(),
34
34
  order: z.number().exactOptional(),
35
35
  icon: z.string().min(1).exactOptional(),
36
+ explore: z.array(z.object({
37
+ question: z.string().min(1),
38
+ href: z.string().min(1).refine(isSafeHref, "must be a safe URL (no javascript: or data:)"),
39
+ title: z.string().min(1).exactOptional()
40
+ })).exactOptional(),
36
41
  actions: z.array(z.object({
37
42
  label: z.string().min(1),
38
43
  href: z.string().min(1).refine(isSafeHref, "must be a safe URL (no javascript: or data:)"),
@@ -26,5 +26,18 @@ declare function neighbours(nodes: DocNavNode[], href: string): {
26
26
  previous?: NavStop;
27
27
  next?: NavStop;
28
28
  };
29
+ /**
30
+ * A step's link text: the one it named, or the navigation's name for the page.
31
+ *
32
+ * ⚠️ A THROW, NOT A FALLBACK TO THE HREF. A row reading "Where this runs and
33
+ * what that buys → /docs/infrastructure" is a URL where a sentence should be,
34
+ * and it renders perfectly — nothing else in the pipeline would notice. The
35
+ * frontmatter is authored and the author is right there, so the build stops and
36
+ * says which page and which of the two fixes to reach for.
37
+ */
38
+ declare function stepTitle(stops: NavStop[], step: {
39
+ href: string;
40
+ title?: string | undefined;
41
+ }): string | undefined;
29
42
  //#endregion
30
- export { NavStop, neighbours, readingOrder };
43
+ export { NavStop, neighbours, readingOrder, stepTitle };
package/dist/nav-order.js CHANGED
@@ -54,5 +54,19 @@ function neighbours(nodes, href) {
54
54
  ...next === void 0 ? {} : { next }
55
55
  };
56
56
  }
57
+ /**
58
+ * A step's link text: the one it named, or the navigation's name for the page.
59
+ *
60
+ * ⚠️ A THROW, NOT A FALLBACK TO THE HREF. A row reading "Where this runs and
61
+ * what that buys → /docs/infrastructure" is a URL where a sentence should be,
62
+ * and it renders perfectly — nothing else in the pipeline would notice. The
63
+ * frontmatter is authored and the author is right there, so the build stops and
64
+ * says which page and which of the two fixes to reach for.
65
+ */
66
+ function stepTitle(stops, step) {
67
+ if (step.title !== void 0) return step.title;
68
+ const here = normalize(step.href);
69
+ return stops.find((stop) => normalize(stop.href) === here)?.title;
70
+ }
57
71
  //#endregion
58
- export { neighbours, readingOrder };
72
+ export { neighbours, readingOrder, stepTitle };
package/dist/next.js CHANGED
@@ -3,12 +3,13 @@ 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
+ import { neighbours, readingOrder, stepTitle } from "./nav-order.js";
7
7
  import { findFunctionValuedOptions } from "./search-options.js";
8
8
  import { createMarkdownComponents } from "./react/markdown-components.js";
9
9
  import { DocContent } from "./react/doc-content.js";
10
10
  import { DocsHero } from "./react/hero.js";
11
11
  import { DocsPager } from "./react/pager.js";
12
+ import { DocsExplore } from "./react/explore.js";
12
13
  import { DocsToc } from "./react/toc.js";
13
14
  import { wrapNextLink } from "./react/link-adapter.js";
14
15
  import { createDocsRenderer } from "./render.js";
@@ -429,6 +430,25 @@ function createDocsRoute(options) {
429
430
  etag: `"${createHash("sha1").update(json).digest("hex")}"`
430
431
  } });
431
432
  };
433
+ /**
434
+ * A page's `next` rows with their link text filled in.
435
+ *
436
+ * ⚠️ THE ERROR IS THE POINT. Falling back to the href renders a URL where a
437
+ * sentence should be, on a page that builds cleanly — so an unresolvable row
438
+ * stops the build and names both fixes.
439
+ */
440
+ async function resolveExplore(doc) {
441
+ const stops = readingOrder(await requestScopedSource.nav());
442
+ return (doc.frontmatter.explore ?? []).map((step) => {
443
+ const title = stepTitle(stops, step);
444
+ if (title === void 0) throw docsError("invalid-frontmatter", `${doc.href}: the "explore" entry pointing at "${step.href}" has no title, and no page in the navigation owns that route. Point it at a page in the tree, or give the entry its own \`title\` — which is what an external link or a page kept out of the navigation needs.`);
445
+ return {
446
+ question: step.question,
447
+ href: step.href,
448
+ title
449
+ };
450
+ });
451
+ }
432
452
  async function renderRoute(segments) {
433
453
  const doc = await getPage(segments);
434
454
  if (doc === void 0) return (await loadNotFound())();
@@ -450,6 +470,11 @@ function createDocsRoute(options) {
450
470
  ...options.components
451
471
  },
452
472
  ...copyLabels === void 0 ? {} : { labels: copyLabels }
473
+ }), (doc.frontmatter.explore?.length ?? 0) === 0 ? null : createElement(DocsExplore, {
474
+ links: await resolveExplore(doc),
475
+ Link: link,
476
+ ...routeLabels?.explore === void 0 ? {} : { heading: routeLabels.explore },
477
+ ...routeLabels?.externalLink === void 0 ? {} : { externalLabel: routeLabels.externalLink }
453
478
  }), options.pager === false ? null : createElement(DocsPager, {
454
479
  ...neighbours(await requestScopedSource.nav(), doc.href),
455
480
  Link: link,
@@ -1,4 +1,4 @@
1
- import { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE } from "../code-frame.js";
1
+ import { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE, CODE_ICON_PATHS, CODE_ICON_STATES } from "../code-frame.js";
2
2
  import { parseCodeMeta } from "../code-meta.js";
3
3
  import { CONTINUE, SKIP, visit } from "unist-util-visit";
4
4
  //#region src/plugins/rehype-code-frame.ts
@@ -28,12 +28,17 @@ const rehypeCodeFrame = (options = {}) => {
28
28
  value: title
29
29
  }]
30
30
  });
31
- children.push(copyButton(title === void 0 ? copyLabel : copyFromLabel.replace("{title}", title)), node);
31
+ children.push(copyButton(title === void 0 ? copyLabel : copyFromLabel.replace("{title}", title)), {
32
+ type: "element",
33
+ tagName: "div",
34
+ properties: { className: ["wave-docs-panel__body", "wave-docs-code__body"] },
35
+ children: [node]
36
+ });
32
37
  parent.children[index] = {
33
38
  type: "element",
34
39
  tagName: "figure",
35
40
  properties: {
36
- className: ["wave-docs-code"],
41
+ className: ["wave-docs-panel", "wave-docs-code"],
37
42
  [CODE_FRAME_ATTRIBUTE]: "",
38
43
  ...language === void 0 ? {} : { "data-lang": language }
39
44
  },
@@ -60,15 +65,40 @@ function copyButton(label) {
60
65
  [CODE_COPY_ATTRIBUTE]: "",
61
66
  "aria-label": label
62
67
  },
63
- children: [{
68
+ children: CODE_ICON_STATES.map(([state, name]) => stateIcon(state, name))
69
+ };
70
+ }
71
+ /**
72
+ * One Lucide glyph, as hast.
73
+ *
74
+ * Decoration, so it is out of the accessibility tree — the button reads as its
75
+ * `aria-label` — and out of the search index by the same rule that drops the
76
+ * heading anchor icons.
77
+ */
78
+ function stateIcon(state, name) {
79
+ return {
80
+ type: "element",
81
+ tagName: "svg",
82
+ properties: {
83
+ className: ["wave-docs-code__copy-icon"],
84
+ "data-state": state,
85
+ "aria-hidden": "true",
86
+ focusable: "false",
87
+ viewBox: "0 0 24 24",
88
+ width: "16",
89
+ height: "16",
90
+ fill: "none",
91
+ stroke: "currentColor",
92
+ strokeWidth: "2",
93
+ strokeLinecap: "round",
94
+ strokeLinejoin: "round"
95
+ },
96
+ children: CODE_ICON_PATHS[name].map((d) => ({
64
97
  type: "element",
65
- tagName: "span",
66
- properties: { "aria-hidden": "true" },
67
- children: [{
68
- type: "text",
69
- value: "⧉"
70
- }]
71
- }]
98
+ tagName: "path",
99
+ properties: { d },
100
+ children: []
101
+ }))
72
102
  };
73
103
  }
74
104
  /** `code.data.meta` — read, never written. */
@@ -0,0 +1,53 @@
1
+ import { DocsLinkComponent } from "./markdown-components.js";
2
+ import { ReactNode } from "react";
3
+ //#region src/react/explore.d.ts
4
+ /** One row: a question, and the page that answers it. */
5
+ interface DocsExploreLink {
6
+ question: string;
7
+ href: string;
8
+ /** Resolved by the caller — from the navigation, or from the frontmatter. */
9
+ title: string;
10
+ }
11
+ interface DocsExploreProps {
12
+ links: DocsExploreLink[];
13
+ /** The block's heading. Defaults to `'Where to go next'`. */
14
+ heading?: string | undefined;
15
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
16
+ Link?: DocsLinkComponent | undefined;
17
+ /**
18
+ * Screen-reader suffix on a row whose answer is off-site. Defaults to
19
+ * `'(opens in a new tab)'`.
20
+ */
21
+ externalLabel?: string | undefined;
22
+ className?: string | undefined;
23
+ }
24
+ /**
25
+ * "Where to go next" — a question per row, and the page that answers it.
26
+ *
27
+ * The sidebar is a structure and this is a router: it says *why* a reader would
28
+ * go somewhere, which no tree of titles can. A page opts in by declaring
29
+ * `explore` in its frontmatter, so the same component is a landing page's
30
+ * onboarding and an ordinary page's footnote.
31
+ *
32
+ * ⚠️ `explore`, NOT `next` — AND NOT `steps` EITHER.
33
+ *
34
+ * `next` is unusable in this package: `src/next.ts` is the Next.js adapter, so
35
+ * two files one directory apart would carry the same name for entirely
36
+ * different things, and `doc.frontmatter.next` would read like a routing hook.
37
+ *
38
+ * `steps` is wrong for a second reason: these rows are a *branch*, not a
39
+ * sequence. A reader picks one and ignores the rest; none of them is first. A
40
+ * numbered "1 → 2 → 3" component is a real and separate thing worth building,
41
+ * and `steps` is the name it will need.
42
+ *
43
+ * ⚠️ A LIST, NOT A TABLE — which is what the markdown this replaces had to be.
44
+ * A screen reader announces "table, 2 columns, 7 rows" for what is a list of
45
+ * links with descriptions, and asks the reader to navigate it by cell. Two
46
+ * columns of sentence-length questions are also cramped on a phone, where this
47
+ * stacks instead.
48
+ *
49
+ * No client JavaScript: a heading and a list of links, rendered on the server.
50
+ */
51
+ declare function DocsExplore({ links, heading, Link, externalLabel, className }: DocsExploreProps): ReactNode;
52
+ //#endregion
53
+ export { DocsExplore, DocsExploreLink, DocsExploreProps };
@@ -0,0 +1,86 @@
1
+ import { opensInNewTab } from "../safe-href.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/react/explore.tsx
4
+ /**
5
+ * "Where to go next" — a question per row, and the page that answers it.
6
+ *
7
+ * The sidebar is a structure and this is a router: it says *why* a reader would
8
+ * go somewhere, which no tree of titles can. A page opts in by declaring
9
+ * `explore` in its frontmatter, so the same component is a landing page's
10
+ * onboarding and an ordinary page's footnote.
11
+ *
12
+ * ⚠️ `explore`, NOT `next` — AND NOT `steps` EITHER.
13
+ *
14
+ * `next` is unusable in this package: `src/next.ts` is the Next.js adapter, so
15
+ * two files one directory apart would carry the same name for entirely
16
+ * different things, and `doc.frontmatter.next` would read like a routing hook.
17
+ *
18
+ * `steps` is wrong for a second reason: these rows are a *branch*, not a
19
+ * sequence. A reader picks one and ignores the rest; none of them is first. A
20
+ * numbered "1 → 2 → 3" component is a real and separate thing worth building,
21
+ * and `steps` is the name it will need.
22
+ *
23
+ * ⚠️ A LIST, NOT A TABLE — which is what the markdown this replaces had to be.
24
+ * A screen reader announces "table, 2 columns, 7 rows" for what is a list of
25
+ * links with descriptions, and asks the reader to navigate it by cell. Two
26
+ * columns of sentence-length questions are also cramped on a phone, where this
27
+ * stacks instead.
28
+ *
29
+ * No client JavaScript: a heading and a list of links, rendered on the server.
30
+ */
31
+ function DocsExplore({ links, heading = "Where to go next", Link, externalLabel = "(opens in a new tab)", className }) {
32
+ if (links.length === 0) return null;
33
+ return /* @__PURE__ */ jsxs("section", {
34
+ className: [
35
+ "wave-docs-panel",
36
+ "wave-docs-explore",
37
+ className
38
+ ].filter(Boolean).join(" "),
39
+ "aria-labelledby": "wave-docs-explore-heading",
40
+ children: [/* @__PURE__ */ jsx("div", {
41
+ className: "wave-docs-panel__header",
42
+ children: /* @__PURE__ */ jsx("h2", {
43
+ className: "wave-docs-panel__title",
44
+ id: "wave-docs-explore-heading",
45
+ children: heading
46
+ })
47
+ }), /* @__PURE__ */ jsx("ul", {
48
+ className: "wave-docs-panel__body wave-docs-explore__list",
49
+ children: links.map((row) => /* @__PURE__ */ jsxs("li", {
50
+ className: "wave-docs-explore__item",
51
+ children: [/* @__PURE__ */ jsx("span", {
52
+ className: "wave-docs-explore__question",
53
+ children: row.question
54
+ }), /* @__PURE__ */ jsx(ExploreAnswer, {
55
+ row,
56
+ Link,
57
+ externalLabel
58
+ })]
59
+ }, row.href))
60
+ })]
61
+ });
62
+ }
63
+ function ExploreAnswer({ row, Link, externalLabel }) {
64
+ const className = "wave-docs-explore__answer";
65
+ if (opensInNewTab(row.href)) return /* @__PURE__ */ jsxs("a", {
66
+ className,
67
+ href: row.href,
68
+ target: "_blank",
69
+ rel: "noopener noreferrer",
70
+ children: [row.title, /* @__PURE__ */ jsxs("span", {
71
+ className: "wave-docs-sr-only",
72
+ children: [" ", externalLabel]
73
+ })]
74
+ });
75
+ return Link === void 0 ? /* @__PURE__ */ jsx("a", {
76
+ className,
77
+ href: row.href,
78
+ children: row.title
79
+ }) : /* @__PURE__ */ jsx(Link, {
80
+ className,
81
+ href: row.href,
82
+ children: row.title
83
+ });
84
+ }
85
+ //#endregion
86
+ export { DocsExplore };
@@ -110,6 +110,20 @@ function createImage(Image) {
110
110
  * "no tabindex on non-interactive elements", not a violation of it. A labelled
111
111
  * `<section>` is a `region` landmark, so the tab stop announces itself instead
112
112
  * of being a mystery stop in the tab order.
113
+ *
114
+ * ⚠️ ONE FRAME, AND IT WORE `.wave-docs-panel` FOR A WHILE.
115
+ *
116
+ * The panel — an outer frame, a header band, an inset card — exists to separate
117
+ * *chrome* from *content*. "Where to go next" and a code frame both have chrome
118
+ * to put in that band: a title, a language, a copy button. A table's header row
119
+ * is not chrome, it is data, and setting the body into a card away from its own
120
+ * header cost three vertical rules down each side, stopped the row dividers
121
+ * short of the box, and narrowed the reading width — on the densest element on
122
+ * a page, for nothing gained. Full-width dividers are what let an eye track a
123
+ * row across.
124
+ *
125
+ * It keeps the panel's outer radius, so a table and a code block still read as
126
+ * two of one family without the table pretending to have chrome it has not got.
113
127
  */
114
128
  function createTable(label) {
115
129
  return function MarkdownTable({ className, ...rest }) {
package/dist/react/nav.js CHANGED
@@ -86,21 +86,24 @@ function DocsNav({ nav, pathname, Link, label = "Documentation", children, close
86
86
  className: "wave-docs-layout__sidebar",
87
87
  ...state === void 0 ? {} : { "data-state": state ? "open" : "closed" },
88
88
  ...ready ? { "data-ready": "" } : {},
89
- children: [/* @__PURE__ */ jsxs("div", {
89
+ children: [/* @__PURE__ */ jsx("div", {
90
90
  ref: navRef,
91
91
  id: DOCS_NAV_ID,
92
92
  className: "wave-docs-layout__sidebar-nav",
93
93
  tabIndex: -1,
94
- children: [children, /* @__PURE__ */ jsx(DocsSidebar, {
95
- nav,
96
- pathname,
97
- label,
98
- Link,
99
- ...expandGroup === void 0 ? {} : { expandGroup },
100
- ...collapseGroup === void 0 ? {} : { collapseGroup },
101
- ...externalLink === void 0 ? {} : { externalLink },
102
- ...icons === void 0 ? {} : { icons }
103
- })]
94
+ children: /* @__PURE__ */ jsxs("div", {
95
+ className: "wave-docs-layout__sidebar-scroll",
96
+ children: [children, /* @__PURE__ */ jsx(DocsSidebar, {
97
+ nav,
98
+ pathname,
99
+ label,
100
+ Link,
101
+ ...expandGroup === void 0 ? {} : { expandGroup },
102
+ ...collapseGroup === void 0 ? {} : { collapseGroup },
103
+ ...externalLink === void 0 ? {} : { externalLink },
104
+ ...icons === void 0 ? {} : { icons }
105
+ })]
106
+ })
104
107
  }), /* @__PURE__ */ jsx("button", {
105
108
  type: "button",
106
109
  className: "wave-docs-layout__sidebar-trigger",