@waveso/docs 0.7.0 → 0.8.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,142 @@
1
1
  # @waveso/docs
2
2
 
3
+ ## 0.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 98f8041: **The sidebar tells a category from a page at a glance.** A folder on every
8
+ group, a page on every page.
9
+
10
+ Weight and a chevron were the only difference, and that is not enough to scan:
11
+ a `Reference` group sitting directly above an `Internals` page read as one
12
+ undifferentiated column, and a real tree interleaves the two a dozen times. A
13
+ silhouette is read before any word is.
14
+
15
+ Two inline SVG paths, in the same style as the disclosure chevron and the
16
+ external-link mark that were already there. The package still ships no icon
17
+ set and takes no icon dependency — a folder and a page are as generic as the
18
+ chevron beside them.
19
+
20
+ `icons={false}` on `DocsSidebar` turns them off, for a host whose own
21
+ navigation has a different vocabulary and does not want a second one.
22
+
23
+ ⚠️ AN EXTERNAL LINK'S MARK MOVED TO THE HEAD OF ITS ROW. It used to sit at the
24
+ far end, which cost twice: the leading slot had to be an empty box to stop the
25
+ column going ragged, and the trailing edge carried two unrelated meanings —
26
+ "opens elsewhere" on one row, "expands" on the next. Leading is what a row _is_;
27
+ trailing is what it _does_. With the mark moved, the only thing at the far end
28
+ of any row is a chevron, which is what makes a group legible from across the
29
+ column, and it leaves that edge free for a status dot or an overflow control
30
+ later. A browser test measures the alignment, because the claim is about a
31
+ column and a column is geometry.
32
+
33
+ The sr-only "(opens in a new tab)" did **not** move with it — the name is still
34
+ read as "GitHub, opens in a new tab" rather than the other way round.
35
+
36
+ ⚠️ AND `icons={false}` PUTS THE MARK BACK ON THE TRAILING EDGE. With no column
37
+ to lead, rendering nothing would leave a link that leaves your site looking
38
+ exactly like one that does not. Turning off a decorative column is not consent
39
+ to drop a warning.
40
+
41
+ The markers sit at `opacity: 0.4` and inherit their row's colour rather than
42
+ carrying a grey of their own, so the relationship holds at every weight — a
43
+ bold group title and a muted page title each get a marker a fixed step lighter
44
+ than themselves. Full strength on hover and on the current page.
45
+
46
+ New public class names: `.wave-docs-sidebar__icon` and
47
+ `.wave-docs-sidebar__label`. The label wrapper is the flex hook that lets a row
48
+ put a chevron at its far end, and it is present whether or not there is a
49
+ marker beside it.
50
+
51
+ ## Your own icons, by name
52
+
53
+ The three built-ins are defaults, not a set. Content names an icon; the host
54
+ maps the name to a component:
55
+
56
+ ```yaml
57
+ # content/internals.md
58
+ icon: wrench
59
+ ```
60
+
61
+ ```json
62
+ // content/reference/meta.json
63
+ { "title": "Reference", "icon": "book" }
64
+ ```
65
+
66
+ ```tsx
67
+ <docs.Layout icons={{ wrench: Wrench, book: Book }}>{children}</docs.Layout>
68
+ ```
69
+
70
+ Also on `DocsSidebar` and `DocsNav` for a hand-assembled shell.
71
+
72
+ ⚠️ A NAME, NEVER ART THIS PACKAGE SHIPS. Content is authored in YAML and JSON
73
+ and cannot carry a React element, and a docs package mounted inside someone
74
+ else's application must not stand its iconography next to theirs. The bundle
75
+ grows by a lookup, not by an icon set — and never will by one.
76
+
77
+ ⚠️ EVERY COMPONENT IN THE MAP MUST BE A CLIENT COMPONENT. `docs.Layout` is a
78
+ Server Component and the tree it hands the map to is not, so React serialises a
79
+ _reference_ to each icon; a server component cannot be one. Icons imported from
80
+ a library already satisfy this. The same boundary `search` documents.
81
+
82
+ A name with no entry in the map falls back to the built-in marker for that
83
+ node's type — a typo in one file leaves a folder where a book should be, not a
84
+ hole in the column.
85
+
86
+ ### Patch Changes
87
+
88
+ - 72ad371: The sidebar's disclosure chevron points at the group it belongs to.
89
+
90
+ `.wave-docs-sidebar__group-header` is `justify-content: space-between`, so the
91
+ chevron sits flush against the navigation's inline end with the label at the
92
+ other side of the row — and unrotated it aimed at the panel's border. Worse
93
+ than at nothing: a chevron at the _trailing_ edge of a row is the platform
94
+ idiom for "this takes you somewhere else", so it read as navigation on a
95
+ control that only opens a list in place. A collapsed group now points back at
96
+ its own label; an open one still points down at its children.
97
+
98
+ ⚠️ AND IT MIRRORS, WHICH A ROTATION DOES NOT DO ON ITS OWN. `rotate` is
99
+ physical — 180deg is left in every writing mode — while every other property
100
+ placing that row is logical. Under `dir="rtl"` the header mirrors, the chevron
101
+ moves to the inline start and the label lands to its right, so a single
102
+ unmirrored rotation would point it out of the panel on the other side: the same
103
+ defect, reflected.
104
+
105
+ ⚠️ THE MIRROR IS `[dir='rtl']`, NOT `:dir(rtl)`, AND THAT IS NOT A STYLE
106
+ PREFERENCE. Next compiles this stylesheet with lightningcss, which downlevels
107
+ `:dir(rtl)` into a hardcoded list of right-to-left _languages_ —
108
+ `:is(:lang(ae), :lang(ar), … :lang(yi))`. Direction is not language, and the
109
+ substitution is wrong in both directions: `<html dir="rtl" lang="en">` gets no
110
+ mirror, `lang="ar" dir="ltr"` gets one it never asked for. It is invisible from
111
+ inside this repo, because the tests inject the source text into a `<style>`
112
+ element where `:dir()` behaves perfectly — it was caught by measuring the built
113
+ site. The stylesheet now uses `:dir()` nowhere, and a test enforces that.
114
+
115
+ ## 0.7.1
116
+
117
+ ### Patch Changes
118
+
119
+ - 43af6e9: The back-to-top link appears when there is something to go back to.
120
+
121
+ It sat at the foot of the table of contents on every page, including at the top
122
+ of one, offering to return a reader to where they already were. It now fades in
123
+ between 25dvh and 35dvh of scroll and fades back out on the way up.
124
+
125
+ No JavaScript was added to do it. The reveal is a scroll-driven animation, so
126
+ scroll position alone drives it — no listener, no state, no re-render per
127
+ frame, and correct before the component has hydrated. `DocsToc` is the smallest
128
+ client component this package ships and it has not grown by a byte.
129
+
130
+ `visibility` moves with the fade, so the link leaves the tab order while it is
131
+ invisible rather than sitting there as a focus target nobody can see — and it
132
+ rejoins only once it is legible, not at the first pixel of the fade.
133
+
134
+ Where the timeline cannot run the link is simply always present, exactly as it
135
+ was: Firefox has not shipped scroll-driven animations, a page too short to
136
+ scroll leaves the timeline inactive, and so does a host that scrolls an inner
137
+ pane rather than the document. Nothing hides a control on the strength of a
138
+ feature the engine did not run.
139
+
3
140
  ## 0.7.0
4
141
 
5
142
  ### 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 | 13.9 KB |
72
+ | Everything the quick start ships, gzipped | 14 KB |
73
73
  | Search dialog and router wiring | 9.3 KB |
74
- | Navigation: one sidebar, open and closed | 2.9 KB |
74
+ | Navigation: one sidebar, open and closed | 3 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 13.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.
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.
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
 
@@ -202,7 +202,7 @@ Every component takes data as props, and every module that imports from `next/*`
202
202
  | --- | --- | --- |
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
- | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
205
+ | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation`. `icons` controls the marker column — see [Sidebar icons](#sidebar-icons) |
206
206
  | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
207
207
  | `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
208
208
  | `DocsLink` | `react/next-link` | `next/link`, adapted — pass it as `Link` when composing by hand |
@@ -212,7 +212,49 @@ Every component takes data as props, and every module that imports from `next/*`
212
212
  | `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<main>`; `DOCS_CONTENT_ID` is that id. `docs.Layout` renders one |
213
213
  | `createMarkdownComponents` | `react/markdown-components` | The element → component map. `defaultMarkdownComponents` is the unwired one |
214
214
 
215
- `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.
215
+ `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
+ ### Sidebar icons
218
+
219
+ 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.
220
+
221
+ Three glyphs ship. **No icon set does**, and none ever will — this package is mounted inside applications that already have one, and a second vocabulary beside theirs is worse than none. Your own icons come in by name:
222
+
223
+ ```yaml
224
+ # content/reference/index.md
225
+ ---
226
+ title: Reference
227
+ icon: book
228
+ ---
229
+ ```
230
+
231
+ ```json
232
+ // content/reference/meta.json — for a directory with no index page,
233
+ // and for hand-written links
234
+ { "title": "Reference", "icon": "book", "pages": [{ "title": "npm", "href": "https://npmjs.com", "icon": "package" }] }
235
+ ```
236
+
237
+ ```tsx
238
+ import { DocsSidebar } from '@waveso/docs/react/sidebar';
239
+ import type { DocNavNode } from '@waveso/docs/types';
240
+
241
+ // Yours: `lucide-react`, your design system, or hand-written. Rendered with no
242
+ // props, in a 1rem box — `currentColor` and `100%` keep it in line with the
243
+ // built-ins and with the row it sits on.
244
+ const Book = () => (
245
+ <svg viewBox="0 0 24 24" width="100%" height="100%" fill="none" stroke="currentColor">
246
+ <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V2H6.5A2.5 2.5 0 0 0 4 4.5v15Z" />
247
+ </svg>
248
+ );
249
+
250
+ export function Nav({ nav, pathname }: { nav: DocNavNode[]; pathname: string }) {
251
+ return <DocsSidebar nav={nav} pathname={pathname} icons={{ book: Book }} />;
252
+ }
253
+ ```
254
+
255
+ A name with no entry in the map falls back to the built-in marker for that node's type — a typo in one file leaves a folder where a book should be, not a hole in the column. The component is rendered with no props, in a `1rem` box, and the built-ins use `currentColor`, so anything following those two conventions sits in line with them.
256
+
257
+ `icons={false}` removes the column entirely. The external-link mark moves back to the trailing edge there: turning off a decorative column is not consent to drop a warning that a link leaves your site.
216
258
 
217
259
  The two components the adapter injects take a little more than an `<a>` and an `<img>`. `DocsLinkProps` adds `prefetch` — passed straight to `next/link`, where `false` disables the hover and viewport paths both, so it is a stronger switch in the App Router than the name suggests. `DocsImageProps` carries `src`, `alt`, `width` and `height` — the four `next/image` refuses to render without — and adds `sizes`, `loading`, `decoding` and `fetchPriority`, forwarded to it; markdown carries none of them, so they come from your `imageResolver` or from a `components` override. `decoding` defaults to `async`, and `loading` to `lazy` — except on an image the author marked `eager`, which is usually the page's largest element.
218
260
 
@@ -32,6 +32,7 @@ declare const docFrontmatterSchema: z.ZodObject<{
32
32
  draft: z.ZodExactOptional<z.ZodBoolean>;
33
33
  aliases: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
34
34
  order: z.ZodExactOptional<z.ZodNumber>;
35
+ icon: z.ZodExactOptional<z.ZodString>;
35
36
  actions: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
36
37
  label: z.ZodString;
37
38
  href: z.ZodString;
@@ -32,6 +32,7 @@ const docFrontmatterSchema = z.object({
32
32
  draft: z.boolean().exactOptional(),
33
33
  aliases: z.array(z.string()).exactOptional(),
34
34
  order: z.number().exactOptional(),
35
+ icon: z.string().min(1).exactOptional(),
35
36
  actions: z.array(z.object({
36
37
  label: z.string().min(1),
37
38
  href: z.string().min(1).refine(isSafeHref, "must be a safe URL (no javascript: or data:)"),
package/dist/meta.d.ts CHANGED
@@ -10,9 +10,11 @@ import { z } from "zod";
10
10
  */
11
11
  declare const docsMetaSchema: z.ZodObject<{
12
12
  title: z.ZodExactOptional<z.ZodString>;
13
+ icon: z.ZodExactOptional<z.ZodString>;
13
14
  pages: z.ZodExactOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
14
15
  title: z.ZodString;
15
16
  href: z.ZodString;
17
+ icon: z.ZodExactOptional<z.ZodString>;
16
18
  }, z.core.$strict>]>>>;
17
19
  }, z.core.$strict>;
18
20
  /**
package/dist/meta.js CHANGED
@@ -25,9 +25,11 @@ const REST = "...";
25
25
  */
26
26
  const docsMetaSchema = z.strictObject({
27
27
  title: z.string().exactOptional(),
28
+ icon: z.string().min(1).exactOptional(),
28
29
  pages: z.array(z.union([z.string(), z.strictObject({
29
30
  title: z.string(),
30
- href: z.string().refine(isSafeHref, { message: "that is not a scheme this package will put in a link. Use http(s), mailto, tel, sms, ftp, irc, xmpp, news, feed, git or matrix — or a path, which needs no scheme at all." })
31
+ href: z.string().refine(isSafeHref, { message: "that is not a scheme this package will put in a link. Use http(s), mailto, tel, sms, ftp, irc, xmpp, news, feed, git or matrix — or a path, which needs no scheme at all." }),
32
+ icon: z.string().min(1).exactOptional()
31
33
  })])).exactOptional()
32
34
  });
33
35
  /**
@@ -93,7 +95,8 @@ function orderNavEntries(entries, meta, metaPath, depth) {
93
95
  type: "link",
94
96
  title: page.title,
95
97
  href: page.href,
96
- external: opensInNewTab(page.href)
98
+ external: opensInNewTab(page.href),
99
+ ...page.icon !== void 0 ? { icon: page.icon } : {}
97
100
  });
98
101
  continue;
99
102
  }
package/dist/next.d.ts CHANGED
@@ -3,6 +3,7 @@ import { DocsHighlighter, DocsLang, DocsTheme, DocsThemes } from "./highlighter.
3
3
  import { SerializableSearchOptions } from "./search-options.js";
4
4
  import { DocsLabels } from "./react/shell-labels.js";
5
5
  import { MarkdownComponents } from "./react/markdown-components.js";
6
+ import { DocsIconMap } from "./react/sidebar.js";
6
7
  import { DocsLayoutSearchProps } from "./react/layout.js";
7
8
  import { DocsSource } from "./source.js";
8
9
  import { ReactNode } from "react";
@@ -154,6 +155,19 @@ interface DocsLayoutProps {
154
155
  * mean naming one string cost you the other twenty-one.
155
156
  */
156
157
  labels?: DocsLabels | undefined;
158
+ /**
159
+ * The sidebar's marker column: `true` (default), `false`, or your own icons
160
+ * keyed by the `icon` names your content authors in frontmatter and
161
+ * `meta.json`. See `DocsSidebarProps.icons`.
162
+ *
163
+ * ⚠️ EVERY COMPONENT IN THE MAP MUST BE A CLIENT COMPONENT — the same
164
+ * boundary `search` documents at length. This is a Server Component handing
165
+ * props to a Client one, so React serialises a *reference* to a client
166
+ * component and cannot serialise a server one. Icons imported from a library
167
+ * already satisfy this; one defined inline in a server file fails the build
168
+ * at the boundary.
169
+ */
170
+ icons?: boolean | DocsIconMap | undefined;
157
171
  }
158
172
  /** Props Next hands a page in the App Router. */
159
173
  interface DocsPageProps {
package/dist/next.js CHANGED
@@ -468,7 +468,7 @@ function createDocsRoute(options) {
468
468
  async IndexPage() {
469
469
  return renderRoute([]);
470
470
  },
471
- async Layout({ children, search, labels }) {
471
+ async Layout({ children, search, labels, icons }) {
472
472
  const { DocsLayoutShell } = await import("./react/layout.js");
473
473
  const host = search === true || search === void 0 || search === false ? void 0 : search;
474
474
  const requestedOptions = host?.miniSearchOptions ?? options.miniSearchOptions;
@@ -485,7 +485,8 @@ function createDocsRoute(options) {
485
485
  nav: await requestScopedSource.nav(),
486
486
  searchIndexUrl,
487
487
  search: searchProps,
488
- ...shellLabels === void 0 ? {} : { labels: shellLabels }
488
+ ...shellLabels === void 0 ? {} : { labels: shellLabels },
489
+ ...icons === void 0 ? {} : { icons }
489
490
  });
490
491
  },
491
492
  async generateStaticParams() {
@@ -2,6 +2,7 @@ import { DocNavNode } from "../types.js";
2
2
  import { SerializableSearchOptions } from "../search-options.js";
3
3
  import { DocsLabels } from "./shell-labels.js";
4
4
  import { DocsSearchProps } from "./next-search.js";
5
+ import { DocsIconMap } from "./sidebar.js";
5
6
  import { ReactNode } from "react";
6
7
  //#region src/react/layout.d.ts
7
8
  /**
@@ -53,7 +54,21 @@ interface DocsLayoutShellProps {
53
54
  * passed either. Configuration that could not be configured.
54
55
  */
55
56
  labels?: DocsLabels | undefined;
57
+ /**
58
+ * The sidebar's marker column: `true` (default), `false`, or your own icons
59
+ * keyed by the `icon` names your content authors. See
60
+ * {@link DocsSidebarProps.icons}.
61
+ *
62
+ * ⚠️ EVERY COMPONENT IN THE MAP MUST BE A CLIENT COMPONENT. This shell is a
63
+ * Server Component and the tree it hands them to is not, so the map crosses
64
+ * that boundary — React can serialise a *reference* to a client component and
65
+ * cannot serialise a server one. Import your icons from a module carrying
66
+ * `'use client'` (every icon library does) and this is invisible; define one
67
+ * inline in a server file and the build fails at the boundary rather than
68
+ * here.
69
+ */
70
+ icons?: boolean | DocsIconMap | undefined;
56
71
  }
57
- declare function DocsLayoutShell({ children, nav, searchIndexUrl, search, labels }: DocsLayoutShellProps): ReactNode;
72
+ declare function DocsLayoutShell({ children, nav, searchIndexUrl, search, labels, icons }: DocsLayoutShellProps): ReactNode;
58
73
  //#endregion
59
74
  export { DocsLayoutSearchProps, DocsLayoutShell, DocsLayoutShellProps };
@@ -4,7 +4,7 @@ import { DocsNextNav } from "./next-nav.js";
4
4
  import { SkipLink } from "./skip-link.js";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
6
  //#region src/react/layout.tsx
7
- function DocsLayoutShell({ children, nav, searchIndexUrl, search = true, labels }) {
7
+ function DocsLayoutShell({ children, nav, searchIndexUrl, search = true, labels, icons }) {
8
8
  const text = resolveLabels(labels);
9
9
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SkipLink, { children: text.skipToContent }), /* @__PURE__ */ jsx("div", {
10
10
  className: "wave-docs-shell",
@@ -18,6 +18,7 @@ function DocsLayoutShell({ children, nav, searchIndexUrl, search = true, labels
18
18
  ...labels?.expandGroup === void 0 ? {} : { expandGroup: labels.expandGroup },
19
19
  ...labels?.collapseGroup === void 0 ? {} : { collapseGroup: labels.collapseGroup },
20
20
  ...labels?.externalLink === void 0 ? {} : { externalLink: labels.externalLink },
21
+ ...icons === void 0 ? {} : { icons },
21
22
  children: search === false ? null : /* @__PURE__ */ jsx(DocsSearch, {
22
23
  indexUrl: searchIndexUrl,
23
24
  ...search === true ? {} : search,
@@ -1,5 +1,6 @@
1
1
  import { DocNavNode } from "../types.js";
2
2
  import { DocsLinkComponent } from "./markdown-components.js";
3
+ import { DocsIconMap } from "./sidebar.js";
3
4
  import { ReactNode } from "react";
4
5
  //#region src/react/nav.d.ts
5
6
  /** The navigation's `id`, and the trigger's `aria-controls`. */
@@ -26,7 +27,9 @@ interface DocsNavProps {
26
27
  expandGroup?: string | undefined;
27
28
  collapseGroup?: string | undefined;
28
29
  externalLink?: string | undefined;
30
+ /** The marker column. See `DocsSidebarProps.icons`. */
31
+ icons?: boolean | DocsIconMap | undefined;
29
32
  }
30
- declare function DocsNav({ nav, pathname, Link, label, children, closeLabel, openLabel, expandGroup, collapseGroup, externalLink }: DocsNavProps): ReactNode;
33
+ declare function DocsNav({ nav, pathname, Link, label, children, closeLabel, openLabel, expandGroup, collapseGroup, externalLink, icons }: DocsNavProps): ReactNode;
31
34
  //#endregion
32
35
  export { DOCS_NAV_ID, DocsNav, DocsNavProps };
package/dist/react/nav.js CHANGED
@@ -5,7 +5,7 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/react/nav.tsx
6
6
  /** The navigation's `id`, and the trigger's `aria-controls`. */
7
7
  const DOCS_NAV_ID = "wave-docs-nav";
8
- function DocsNav({ nav, pathname, Link, label = "Documentation", children, closeLabel = "Close navigation", openLabel = "Open navigation", expandGroup, collapseGroup, externalLink }) {
8
+ function DocsNav({ nav, pathname, Link, label = "Documentation", children, closeLabel = "Close navigation", openLabel = "Open navigation", expandGroup, collapseGroup, externalLink, icons }) {
9
9
  const shellRef = useRef(null);
10
10
  const navRef = useRef(null);
11
11
  const returnFocusRef = useRef(null);
@@ -98,7 +98,8 @@ function DocsNav({ nav, pathname, Link, label = "Documentation", children, close
98
98
  Link,
99
99
  ...expandGroup === void 0 ? {} : { expandGroup },
100
100
  ...collapseGroup === void 0 ? {} : { collapseGroup },
101
- ...externalLink === void 0 ? {} : { externalLink }
101
+ ...externalLink === void 0 ? {} : { externalLink },
102
+ ...icons === void 0 ? {} : { icons }
102
103
  })]
103
104
  }), /* @__PURE__ */ jsx("button", {
104
105
  type: "button",
@@ -1,4 +1,5 @@
1
1
  import { DocNavNode } from "../types.js";
2
+ import { DocsIconMap } from "./sidebar.js";
2
3
  import { ReactNode } from "react";
3
4
  //#region src/react/next-nav.d.ts
4
5
  interface DocsNextNavProps {
@@ -13,7 +14,9 @@ interface DocsNextNavProps {
13
14
  expandGroup?: string | undefined;
14
15
  collapseGroup?: string | undefined;
15
16
  externalLink?: string | undefined;
17
+ /** The marker column. See `DocsSidebarProps.icons`. */
18
+ icons?: boolean | DocsIconMap | undefined;
16
19
  }
17
- declare function DocsNextNav({ nav, label, closeLabel, openLabel, children, expandGroup, collapseGroup, externalLink }: DocsNextNavProps): ReactNode;
20
+ declare function DocsNextNav({ nav, label, closeLabel, openLabel, children, expandGroup, collapseGroup, externalLink, icons }: DocsNextNavProps): ReactNode;
18
21
  //#endregion
19
22
  export { DocsNextNav, DocsNextNavProps };
@@ -19,7 +19,7 @@ import { usePathname } from "next/navigation";
19
19
  */
20
20
  /** Module scope: a fresh identity here remounts every nav link on every render. */
21
21
  const Link = wrapNextLink(NextLink);
22
- function DocsNextNav({ nav, label, closeLabel, openLabel, children, expandGroup, collapseGroup, externalLink }) {
22
+ function DocsNextNav({ nav, label, closeLabel, openLabel, children, expandGroup, collapseGroup, externalLink, icons }) {
23
23
  return /* @__PURE__ */ jsx(DocsNav, {
24
24
  nav,
25
25
  pathname: usePathname(),
@@ -30,6 +30,7 @@ function DocsNextNav({ nav, label, closeLabel, openLabel, children, expandGroup,
30
30
  ...expandGroup === void 0 ? {} : { expandGroup },
31
31
  ...collapseGroup === void 0 ? {} : { collapseGroup },
32
32
  ...externalLink === void 0 ? {} : { externalLink },
33
+ ...icons === void 0 ? {} : { icons },
33
34
  children
34
35
  });
35
36
  }
@@ -1,7 +1,17 @@
1
1
  import { DocNavNode } from "../types.js";
2
2
  import { DocsLinkComponent } from "./markdown-components.js";
3
- import { ReactNode } from "react";
3
+ import { ComponentType, ReactNode } from "react";
4
4
  //#region src/react/sidebar.d.ts
5
+ /**
6
+ * `icon` names to components, for `DocsSidebar`'s `icons` prop.
7
+ *
8
+ * The component is rendered with no props: an icon that needs configuration is
9
+ * a closure the host writes, not a contract this package invents. It should
10
+ * draw at the size it is given — the column is `1rem` square and the built-in
11
+ * markers use `currentColor`, so anything following those two conventions sits
12
+ * in line with them.
13
+ */
14
+ type DocsIconMap = Record<string, ComponentType>;
5
15
  interface DocsSidebarProps {
6
16
  /** The tree from `@waveso/docs/source`. */
7
17
  nav: DocNavNode[];
@@ -32,6 +42,44 @@ interface DocsSidebarProps {
32
42
  * The separating space is markup, so this is the sentence and nothing else.
33
43
  */
34
44
  externalLink?: string | undefined;
45
+ /**
46
+ * The marker column. `true` (default), `false`, or your own icons.
47
+ *
48
+ * Weight and a chevron were the only things separating a category from a
49
+ * page, and in a tree where the two interleave — a `Reference` group sitting
50
+ * directly above an `Internals` page — that is not enough to scan. A leading
51
+ * glyph gives the column a shape you read before you read any words.
52
+ *
53
+ * `false` renders no markers at all, for a host whose own navigation has a
54
+ * different vocabulary and does not want a second one. The external-link mark
55
+ * returns to the trailing edge there — turning off a decorative column is not
56
+ * consent to drop a warning.
57
+ *
58
+ * A **map** replaces the defaults with your components, keyed by the `icon`
59
+ * name authored in frontmatter or `meta.json`:
60
+ *
61
+ * ```tsx
62
+ * import { Book, Rocket } from 'lucide-react';
63
+ *
64
+ * <DocsSidebar nav={nav} pathname={pathname}
65
+ * icons={{ book: Book, rocket: Rocket }} />
66
+ * ```
67
+ *
68
+ * ```yaml
69
+ * # content/reference/index.md
70
+ * icon: book
71
+ * ```
72
+ *
73
+ * ⚠️ A NAME THE HOST RESOLVES, NEVER ART THIS PACKAGE SHIPS. Content is
74
+ * authored in YAML and JSON and cannot carry a React element, and a docs
75
+ * package mounted inside someone else's application must not put its
76
+ * iconography beside theirs. Three markers ship; everything else is yours.
77
+ *
78
+ * A name with no entry in the map falls back to the built-in marker for that
79
+ * node's type. A typo in one file leaves a folder where a book should be —
80
+ * not a hole in the column.
81
+ */
82
+ icons?: boolean | DocsIconMap | undefined;
35
83
  className?: string | undefined;
36
84
  }
37
85
  /**
@@ -76,6 +124,6 @@ interface DocsSidebarProps {
76
124
  * nothing prefetches locally whatever this says. Do not "fix" it back because
77
125
  * the network tab looks the same.
78
126
  */
79
- declare function DocsSidebar({ nav, pathname, Link, label, expandGroup, collapseGroup, externalLink, className }: DocsSidebarProps): ReactNode;
127
+ declare function DocsSidebar({ nav, pathname, Link, label, expandGroup, collapseGroup, externalLink, icons, className }: DocsSidebarProps): ReactNode;
80
128
  //#endregion
81
- export { DocsSidebar, DocsSidebarProps };
129
+ export { DocsIconMap, DocsSidebar, DocsSidebarProps };
@@ -66,7 +66,8 @@ function containsActive(node, pathname) {
66
66
  * nothing prefetches locally whatever this says. Do not "fix" it back because
67
67
  * the network tab looks the same.
68
68
  */
69
- function DocsSidebar({ nav, pathname, Link, label = "Docs", expandGroup, collapseGroup, externalLink, className }) {
69
+ function DocsSidebar({ nav, pathname, Link, label = "Docs", expandGroup, collapseGroup, externalLink, icons = true, className }) {
70
+ const iconMap = icons === false ? false : icons === true ? {} : icons;
70
71
  const text = {
71
72
  expandGroup: expandGroup ?? DEFAULT_SIDEBAR_LABELS.expandGroup,
72
73
  collapseGroup: collapseGroup ?? DEFAULT_SIDEBAR_LABELS.collapseGroup,
@@ -108,6 +109,7 @@ function DocsSidebar({ nav, pathname, Link, label = "Docs", expandGroup, collaps
108
109
  nodes: nav,
109
110
  depth: 0,
110
111
  keyPrefix: baseId,
112
+ icons: iconMap,
111
113
  pathname,
112
114
  Link,
113
115
  toggled,
@@ -157,7 +159,7 @@ function scrollableAncestor(element) {
157
159
  }
158
160
  return null;
159
161
  }
160
- function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, text, id }) {
162
+ function NavList({ nodes, depth, keyPrefix, icons, pathname, Link, toggled, onToggle, text, id }) {
161
163
  const holdsActive = nodes.some((node) => (node.type === "page" || node.type === "link" && !node.external) && isActiveHref(pathname, node.href));
162
164
  return /* @__PURE__ */ jsx("ul", {
163
165
  id,
@@ -175,31 +177,47 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, t
175
177
  }, key);
176
178
  case "link": return /* @__PURE__ */ jsx("li", {
177
179
  className: "wave-docs-sidebar__item",
178
- children: /* @__PURE__ */ jsx(NavLink, {
180
+ children: /* @__PURE__ */ jsxs(NavLink, {
179
181
  href: node.href,
180
182
  isExternal: node.external,
181
183
  isActive: !node.external && isActiveHref(pathname, node.href),
182
184
  isNearby: holdsActive,
183
185
  Link,
184
186
  externalLink: text.externalLink,
185
- children: node.title
187
+ icons,
188
+ children: [icons === false ? null : /* @__PURE__ */ jsx(NavIcon, {
189
+ type: "external",
190
+ name: node.icon,
191
+ icons
192
+ }), /* @__PURE__ */ jsx("span", {
193
+ className: "wave-docs-sidebar__label",
194
+ children: node.title
195
+ })]
186
196
  })
187
197
  }, key);
188
198
  case "page": return /* @__PURE__ */ jsx("li", {
189
199
  className: "wave-docs-sidebar__item",
190
- children: /* @__PURE__ */ jsx(NavLink, {
200
+ children: /* @__PURE__ */ jsxs(NavLink, {
191
201
  href: node.href,
192
202
  isExternal: false,
193
203
  isActive: isActiveHref(pathname, node.href),
194
204
  isNearby: holdsActive,
195
205
  Link,
196
- children: node.title
206
+ children: [icons === false ? null : /* @__PURE__ */ jsx(NavIcon, {
207
+ type: "file",
208
+ name: node.icon,
209
+ icons
210
+ }), /* @__PURE__ */ jsx("span", {
211
+ className: "wave-docs-sidebar__label",
212
+ children: node.title
213
+ })]
197
214
  })
198
215
  }, key);
199
216
  case "group": return /* @__PURE__ */ jsx(NavGroup, {
200
217
  node,
201
218
  itemKey: key,
202
219
  depth,
220
+ icons,
203
221
  pathname,
204
222
  Link,
205
223
  toggled,
@@ -211,7 +229,7 @@ function NavList({ nodes, depth, keyPrefix, pathname, Link, toggled, onToggle, t
211
229
  })
212
230
  });
213
231
  }
214
- function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle, text }) {
232
+ function NavGroup({ node, itemKey, depth, icons, pathname, Link, toggled, onToggle, text }) {
215
233
  const listId = `${itemKey}-list`;
216
234
  const hasActive = containsActive(node, pathname);
217
235
  const isOpen = toggled[itemKey] ?? hasActive;
@@ -227,17 +245,32 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle, tex
227
245
  "aria-expanded": isOpen,
228
246
  "aria-controls": isOpen ? listId : void 0,
229
247
  onClick: () => onToggle(itemKey, !isOpen),
230
- children: [/* @__PURE__ */ jsx("span", {
231
- className: "wave-docs-sidebar__group-title",
232
- children: node.title
233
- }), /* @__PURE__ */ jsx(Chevron, { isOpen })]
234
- }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(NavLink, {
248
+ children: [
249
+ icons === false ? null : /* @__PURE__ */ jsx(NavIcon, {
250
+ type: "folder",
251
+ name: node.icon,
252
+ icons
253
+ }),
254
+ /* @__PURE__ */ jsx("span", {
255
+ className: "wave-docs-sidebar__group-title",
256
+ children: node.title
257
+ }),
258
+ /* @__PURE__ */ jsx(Chevron, { isOpen })
259
+ ]
260
+ }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs(NavLink, {
235
261
  href: node.href,
236
262
  isExternal: false,
237
263
  isActive: isGroupActive,
238
264
  isNearby: hasActive,
239
265
  Link,
240
- children: node.title
266
+ children: [icons === false ? null : /* @__PURE__ */ jsx(NavIcon, {
267
+ type: "folder",
268
+ name: node.icon,
269
+ icons
270
+ }), /* @__PURE__ */ jsx("span", {
271
+ className: "wave-docs-sidebar__label",
272
+ children: node.title
273
+ })]
241
274
  }), /* @__PURE__ */ jsx("button", {
242
275
  type: "button",
243
276
  className: "wave-docs-sidebar__group-toggle",
@@ -252,6 +285,7 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle, tex
252
285
  nodes: node.children,
253
286
  depth: depth + 1,
254
287
  keyPrefix: itemKey,
288
+ icons,
255
289
  pathname,
256
290
  Link,
257
291
  toggled,
@@ -260,7 +294,7 @@ function NavGroup({ node, itemKey, depth, pathname, Link, toggled, onToggle, tex
260
294
  }) : null]
261
295
  });
262
296
  }
263
- function NavLink({ href, isExternal, isActive, isNearby = false, Link, externalLink = DEFAULT_SIDEBAR_LABELS.externalLink, children }) {
297
+ function NavLink({ href, isExternal, isActive, isNearby = false, Link, externalLink = DEFAULT_SIDEBAR_LABELS.externalLink, icons = {}, children }) {
264
298
  const className = "wave-docs-sidebar__link";
265
299
  if (isExternal) return /* @__PURE__ */ jsxs("a", {
266
300
  className,
@@ -269,7 +303,7 @@ function NavLink({ href, isExternal, isActive, isNearby = false, Link, externalL
269
303
  rel: "noopener noreferrer",
270
304
  children: [
271
305
  children,
272
- /* @__PURE__ */ jsx("svg", {
306
+ icons === false ? /* @__PURE__ */ jsx("svg", {
273
307
  className: "wave-docs-sidebar__external",
274
308
  "aria-hidden": "true",
275
309
  focusable: "false",
@@ -281,8 +315,8 @@ function NavLink({ href, isExternal, isActive, isNearby = false, Link, externalL
281
315
  strokeWidth: "2",
282
316
  strokeLinecap: "round",
283
317
  strokeLinejoin: "round",
284
- children: /* @__PURE__ */ jsx("path", { d: "M14 4h6v6M20 4l-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5" })
285
- }),
318
+ children: /* @__PURE__ */ jsx("path", { d: NAV_ICON_PATHS.external[0] })
319
+ }) : null,
286
320
  /* @__PURE__ */ jsxs("span", {
287
321
  className: "wave-docs-sr-only",
288
322
  children: [" ", externalLink]
@@ -303,6 +337,58 @@ function NavLink({ href, isExternal, isActive, isNearby = false, Link, externalL
303
337
  children
304
338
  });
305
339
  }
340
+ /**
341
+ * The glyph at the head of a row: a folder for a group, a page for a page.
342
+ *
343
+ * Weight and a chevron were the only things telling a category from a page, and
344
+ * where the two interleave — a `Reference` group directly above an `Internals`
345
+ * page — that is not enough to scan a column of twenty. A silhouette is read
346
+ * before any word is.
347
+ *
348
+ * An external link takes the third glyph, in the same leading slot. It used to
349
+ * carry that mark at the *far* end of its row, which cost twice: the leading
350
+ * slot then had to be an empty box to keep the column from going ragged, and
351
+ * the trailing edge held two unrelated meanings — "opens elsewhere" on one row,
352
+ * "expands" on the next. Leading is what a row *is*; trailing is what it
353
+ * *does*. With the mark moved, the only thing at the far end of any row is a
354
+ * chevron, which is what makes a group legible from across the column — and
355
+ * leaves that edge free for a status dot or an overflow control later.
356
+ *
357
+ * ⚠️ THE VISUAL MARK MOVED AND THE ANNOUNCED ONE DID NOT. The sr-only "(opens
358
+ * in a new tab)" stays after the link text, so the name is still read as
359
+ * "GitHub, opens in a new tab" rather than the other way round.
360
+ *
361
+ * Inline SVG, matching `Chevron` and the external mark rather than a font or a
362
+ * dependency — the package ships no icon set, and these two are as generic as
363
+ * the chevron beside them.
364
+ */
365
+ const NAV_ICON_PATHS = {
366
+ folder: ["M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"],
367
+ file: ["M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z", "M14 2v4a2 2 0 0 0 2 2h4"],
368
+ external: ["M14 4h6v6M20 4l-8 8M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"]
369
+ };
370
+ function NavIcon({ type, name, icons }) {
371
+ const Custom = name === void 0 ? void 0 : icons[name];
372
+ if (Custom !== void 0) return /* @__PURE__ */ jsx("span", {
373
+ className: "wave-docs-sidebar__icon",
374
+ "aria-hidden": "true",
375
+ children: /* @__PURE__ */ jsx(Custom, {})
376
+ });
377
+ return /* @__PURE__ */ jsx("svg", {
378
+ className: "wave-docs-sidebar__icon",
379
+ "aria-hidden": "true",
380
+ focusable: "false",
381
+ viewBox: "0 0 24 24",
382
+ width: "16",
383
+ height: "16",
384
+ fill: "none",
385
+ stroke: "currentColor",
386
+ strokeWidth: "2",
387
+ strokeLinecap: "round",
388
+ strokeLinejoin: "round",
389
+ children: NAV_ICON_PATHS[type].map((d) => /* @__PURE__ */ jsx("path", { d }, d))
390
+ });
391
+ }
306
392
  function Chevron({ isOpen }) {
307
393
  return /* @__PURE__ */ jsx("svg", {
308
394
  className: "wave-docs-sidebar__chevron",
package/dist/source.js CHANGED
@@ -381,17 +381,20 @@ function buildNav(dir, config) {
381
381
  const visible = index && isVisibleIn(index.doc, config) ? index : void 0;
382
382
  const title = groupTitle(child, visible?.doc);
383
383
  const href = visible?.doc.href;
384
+ const icon = child.meta?.icon ?? visible?.doc.frontmatter.icon;
384
385
  const group = {
385
386
  type: "group",
386
387
  title,
387
388
  children,
388
- ...href !== void 0 ? { href } : {}
389
+ ...href !== void 0 ? { href } : {},
390
+ ...icon !== void 0 ? { icon } : {}
389
391
  };
390
392
  const node = children.length === 0 && visible !== void 0 && href !== void 0 ? {
391
393
  type: "page",
392
394
  title,
393
395
  href,
394
- slug: visible.doc.slug
396
+ slug: visible.doc.slug,
397
+ ...icon !== void 0 ? { icon } : {}
395
398
  } : group;
396
399
  const order = visible?.doc.frontmatter.order;
397
400
  entries.push({
@@ -403,7 +406,8 @@ function buildNav(dir, config) {
403
406
  type: "page",
404
407
  title: navTitle(visible.doc),
405
408
  href,
406
- slug: visible.doc.slug
409
+ slug: visible.doc.slug,
410
+ ...visible.doc.frontmatter.icon !== void 0 ? { icon: visible.doc.frontmatter.icon } : {}
407
411
  } } : {},
408
412
  ...order !== void 0 ? { order } : {}
409
413
  });
@@ -420,7 +424,8 @@ function toPageEntry(page, config) {
420
424
  type: "page",
421
425
  title,
422
426
  href: page.doc.href,
423
- slug: page.doc.slug
427
+ slug: page.doc.slug,
428
+ ...page.doc.frontmatter.icon !== void 0 ? { icon: page.doc.frontmatter.icon } : {}
424
429
  },
425
430
  ...order !== void 0 ? { order } : {},
426
431
  ...isVisibleIn(page.doc, config) ? {} : { hidden: true }
package/dist/styles.css CHANGED
@@ -1575,6 +1575,71 @@
1575
1575
  font-weight: 600;
1576
1576
  }
1577
1577
 
1578
+ /*
1579
+ * The row is `justify-content: space-between`, so the label has to claim the
1580
+ * space or the icon and the text end up at opposite edges with a hole between
1581
+ * them. `min-width: 0` because a long title in a 240px column must be allowed
1582
+ * to shrink; without it the flex base is the text's min-content width and the
1583
+ * row overflows instead of wrapping.
1584
+ */
1585
+ .wave-docs-sidebar__label,
1586
+ .wave-docs-sidebar__group-title {
1587
+ flex: 1 1 auto;
1588
+ min-width: 0;
1589
+ }
1590
+
1591
+ /*
1592
+ * The type marker: a folder on a group, a page on a page.
1593
+ *
1594
+ * ⚠️ THE BOX IS DECLARED HERE, NOT LEFT TO THE SVG'S OWN WIDTH, because one
1595
+ * of these has no SVG in it. An external link carries its mark at the far end
1596
+ * of the row and needs no second one, so it gets an empty box — and an empty
1597
+ * `<span>` with no size is a column where three labels start 24px left of
1598
+ * every other label. Ragged is the defect this feature exists to remove.
1599
+ */
1600
+ .wave-docs-sidebar__icon {
1601
+ flex: none;
1602
+ inline-size: 1rem;
1603
+ block-size: 1rem;
1604
+ /*
1605
+ * ⚠️ `inherit` AND `opacity`, NOT A COLOUR OF ITS OWN. A fixed grey put a
1606
+ * bold group title next to a marker several steps lighter than it and a
1607
+ * muted page title next to one barely lighter, so the column read as two
1608
+ * different treatments. Fading whatever the row already is keeps one
1609
+ * relationship at every weight — and it is the reason the active row's
1610
+ * marker turns accent-blue with its label rather than staying grey.
1611
+ */
1612
+ color: inherit;
1613
+ opacity: 0.4;
1614
+ }
1615
+
1616
+ /*
1617
+ * A host's own icon, in the column's box.
1618
+ *
1619
+ * `grid` rather than `block`: the component inside is theirs and may be any
1620
+ * size, and a grid with `place-items: center` centres it in our 1rem square
1621
+ * without needing it to fill one. `> *` caps it at the box so an icon
1622
+ * authored at 24 does not push the row taller than every other row.
1623
+ */
1624
+ span.wave-docs-sidebar__icon {
1625
+ display: grid;
1626
+ place-items: center;
1627
+ }
1628
+
1629
+ .wave-docs-sidebar__icon > * {
1630
+ max-inline-size: 100%;
1631
+ max-block-size: 100%;
1632
+ }
1633
+
1634
+ /* Full strength the moment the row is the reader's own or under their
1635
+ * pointer. A current page whose marker stayed faded reads as
1636
+ * half-highlighted. */
1637
+ .wave-docs-sidebar__link:hover .wave-docs-sidebar__icon,
1638
+ .wave-docs-sidebar__group-button:hover .wave-docs-sidebar__icon,
1639
+ .wave-docs-sidebar__link[aria-current='page'] .wave-docs-sidebar__icon {
1640
+ opacity: 1;
1641
+ }
1642
+
1578
1643
  .wave-docs-sidebar__list:not([data-depth='0']) {
1579
1644
  margin-inline-start: 0.5rem;
1580
1645
  padding-inline-start: 0.5rem;
@@ -1646,11 +1711,55 @@
1646
1711
  color: var(--wave-docs-fg);
1647
1712
  }
1648
1713
 
1714
+ /*
1715
+ * The chevron points at the label it belongs to, not away from it.
1716
+ *
1717
+ * `.wave-docs-sidebar__group-header` is `justify-content: space-between`, so
1718
+ * this sits flush against the navigation's inline end with the label at the
1719
+ * other side of the row. The icon is Lucide's `chevron-right`, and unrotated
1720
+ * it therefore aimed at the panel's border — at nothing. Worse than nothing,
1721
+ * in fact: a chevron at the *trailing* edge of a row is the platform idiom
1722
+ * for "this takes you somewhere else", so it read as navigation on a control
1723
+ * that only opens a list in place.
1724
+ *
1725
+ * ⚠️ AND THAT IS WHY THERE IS A MIRROR RULE. `rotate` is physical — `180deg`
1726
+ * is left in every writing mode — while everything else positioning this row
1727
+ * is logical, so the header mirrors under `dir="rtl"` and a lone physical
1728
+ * rotation does not. Mirrored, the chevron moves to the inline start with the
1729
+ * label to its right, and 180deg would then point it out of the panel on the
1730
+ * other side. `0deg` is what "at the label" means there.
1731
+ *
1732
+ * ⚠️ `[dir='rtl']`, AND NOT `:dir(rtl)`, WHICH IS THE SELECTOR FOR THIS JOB
1733
+ * AND DOES NOT SURVIVE THE BUILD. Next compiles this sheet with lightningcss,
1734
+ * which downlevels `:dir(rtl)` into a hardcoded list of right-to-left
1735
+ * *languages*:
1736
+ *
1737
+ * .wave-docs-sidebar__chevron:is(:lang(ae), :lang(ar), … :lang(yi))
1738
+ *
1739
+ * Direction is not language. Measured on the site with `<html dir="rtl"
1740
+ * lang="en">`: `:dir(rtl)` matched the element, the rule was nowhere in the
1741
+ * served stylesheet, and the chevron kept pointing out of the panel. It fails
1742
+ * the other way too — `lang="ar" dir="ltr"` gets the mirror it did not ask
1743
+ * for. The attribute selector is plain CSS 2.1, so no pipeline has an opinion
1744
+ * about it, and it is what `rtl:` compiles to in every design system that
1745
+ * ships one.
1746
+ */
1649
1747
  .wave-docs-sidebar__chevron {
1650
1748
  flex: none;
1651
1749
  color: var(--wave-docs-fg-subtle);
1750
+ rotate: 180deg;
1652
1751
  }
1653
1752
 
1753
+ [dir='rtl'] .wave-docs-sidebar__chevron {
1754
+ rotate: 0deg;
1755
+ }
1756
+
1757
+ /*
1758
+ * Open is down in both directions, so this needs no mirror — and it must
1759
+ * come last, because the mirror above matches with the same specificity
1760
+ * (0,2,0 either way) and would otherwise hold a mirrored open group pointing
1761
+ * sideways.
1762
+ */
1654
1763
  .wave-docs-sidebar__chevron[data-open] {
1655
1764
  rotate: 90deg;
1656
1765
  }
@@ -1673,6 +1782,35 @@
1673
1782
  .wave-docs-sidebar__chevron {
1674
1783
  transition: rotate 150ms ease-out;
1675
1784
  }
1785
+
1786
+ /*
1787
+ * The row and its marker ease together, and the row is half of it.
1788
+ *
1789
+ * ⚠️ THE LINKS HAD NO TRANSITION AT ALL — the TOC's did, which is why this
1790
+ * looked like an oversight only in the sidebar. Fading the marker alone
1791
+ * would have been worse than fading nothing: the glyph would drift up to
1792
+ * full strength while the surface under it snapped, so the two halves of
1793
+ * one hover would visibly disagree.
1794
+ *
1795
+ * `background-color`, not `background`: the shorthand includes
1796
+ * `background-image`, and naming it here would put this rule in the way of
1797
+ * any consumer who paints one.
1798
+ */
1799
+ .wave-docs-sidebar__link,
1800
+ .wave-docs-sidebar__group-button,
1801
+ .wave-docs-sidebar__group-toggle {
1802
+ transition:
1803
+ background-color 150ms ease-out,
1804
+ color 150ms ease-out;
1805
+ }
1806
+
1807
+ /* `color` as well as `opacity`: the marker inherits its row's colour, and
1808
+ * on the current page that is a jump from muted grey to accent blue. */
1809
+ .wave-docs-sidebar__icon {
1810
+ transition:
1811
+ opacity 150ms ease-out,
1812
+ color 150ms ease-out;
1813
+ }
1676
1814
  }
1677
1815
  }
1678
1816
 
@@ -1725,6 +1863,89 @@
1725
1863
  color: var(--wave-docs-fg);
1726
1864
  }
1727
1865
 
1866
+ /*
1867
+ * ⚠️ IT ONLY APPEARS ONCE THERE IS SOMETHING TO GO BACK TO, AND NOTHING IN
1868
+ * JAVASCRIPT DECIDES THAT.
1869
+ *
1870
+ * A scroll-driven animation, so scroll position alone drives it: no listener,
1871
+ * no state, no re-render on every frame of a scroll, and it is right before
1872
+ * hydration rather than after it. `toc` is the smallest client component this
1873
+ * package ships — 900 bytes, and its budget note exists to keep it that way —
1874
+ * so the alternative was to grow the one bundle that should never grow, in
1875
+ * order to recompute a number the compositor already has.
1876
+ *
1877
+ * ⚠️ `root`, NOT `nearest`. Above 80rem `.wave-docs-layout__toc` is itself a
1878
+ * scroll container (`overflow-y: auto`, so a long column of headings scrolls
1879
+ * in place), and `nearest` resolves to it. The animation would then track how
1880
+ * far the reader had scrolled the *table of contents*, which on almost every
1881
+ * page never scrolls at all — an inactive timeline, and a link that never
1882
+ * appears. Same shape as the `nearest`/`self` trap on the table shadow above,
1883
+ * arrived at from the opposite direction.
1884
+ *
1885
+ * ⚠️ AND THE RULE ABOVE IS THE FALLBACK, WHICH IS WHY NOTHING HERE OVERRIDES
1886
+ * IT. Three cases collapse into one: Firefox does not match this `@supports`
1887
+ * yet; a page too short to scroll leaves the timeline inactive; and so does a
1888
+ * host that scrolls an inner pane rather than the document. In all three the
1889
+ * link is simply always present, exactly as it was before this block existed.
1890
+ * Nothing hides a control on the strength of a feature the engine did not run.
1891
+ *
1892
+ * `dvh` rather than `px`: the link is redundant while the top of the document
1893
+ * is still on screen, and "still on screen" is a fraction of the viewport
1894
+ * rather than a constant. On a 900px window it fades in between 225px and
1895
+ * 315px of scroll. `dvh` because this sheet uses no `vh` anywhere, and the
1896
+ * reason it does not — the retracted mobile URL bar — costs nothing here: the
1897
+ * column this link lives in is `display: none` below 80rem.
1898
+ *
1899
+ * No `prefers-reduced-motion` gate, deliberately. That setting is about things
1900
+ * that move under a reader who did not ask them to; this is opacity, and the
1901
+ * reader's own scrolling is its clock.
1902
+ */
1903
+ @supports (animation-timeline: scroll()) {
1904
+ .wave-docs-toc__top {
1905
+ animation-timeline: scroll(root block);
1906
+ animation-timing-function: linear;
1907
+ animation-fill-mode: both;
1908
+ animation-name: wave-docs-toc-top-reveal;
1909
+ animation-range: 25dvh 35dvh;
1910
+ }
1911
+ }
1912
+
1913
+ /*
1914
+ * ⚠️ `visibility` KEEPS A LINK NOBODY CAN SEE OUT OF THE TAB ORDER, AND THE
1915
+ * MIDDLE FRAME IS WHAT DECIDES WHEN IT REJOINS.
1916
+ *
1917
+ * `opacity` alone leaves a fully focusable, fully clickable link sitting
1918
+ * invisibly at the foot of the rail: Tab reaches it and the focus ring is
1919
+ * drawn around nothing.
1920
+ *
1921
+ * `visibility` interpolates as a step with one exception — across an interval
1922
+ * where *either* endpoint is `visible` it is `visible` the whole way. Two
1923
+ * frames would therefore flip it one pixel past the threshold and hand the
1924
+ * link back to the tab order at an opacity of almost zero, which is the same
1925
+ * defect a frame later. Two adjacent `hidden` frames have no `visible`
1926
+ * endpoint between them, the exception does not apply, and the step lands at
1927
+ * 60% — where the link is legible.
1928
+ *
1929
+ * That middle frame sits exactly on the linear ramp, so it changes the fade
1930
+ * by nothing at all. Being the second `hidden` is its whole job.
1931
+ */
1932
+ @keyframes wave-docs-toc-top-reveal {
1933
+ 0% {
1934
+ opacity: 0;
1935
+ visibility: hidden;
1936
+ }
1937
+
1938
+ 60% {
1939
+ opacity: 0.6;
1940
+ visibility: hidden;
1941
+ }
1942
+
1943
+ 100% {
1944
+ opacity: 1;
1945
+ visibility: visible;
1946
+ }
1947
+ }
1948
+
1728
1949
  .wave-docs-sidebar__external {
1729
1950
  /* Baseline-ish against the label, and never a flex item that grows. */
1730
1951
  margin-inline-start: 0.25rem;
package/dist/types.d.ts CHANGED
@@ -59,6 +59,20 @@ interface DocFrontmatter {
59
59
  * Lower sorts first; pages without an order sort last, alphabetically.
60
60
  */
61
61
  order?: number | undefined;
62
+ /**
63
+ * Sidebar marker for this page, as a name the *consumer* resolves.
64
+ *
65
+ * A name and not a component, because frontmatter is data: it is authored by
66
+ * whoever writes the page, in YAML, and cannot carry a React element. The
67
+ * host maps the name to their own icon via `DocsSidebar`'s `icons` prop, so
68
+ * the art belongs to whichever site is rendering — which is the only shape
69
+ * that serves a package mounted inside someone else's application.
70
+ *
71
+ * An unmapped name falls back to the default page marker rather than
72
+ * rendering nothing: a typo in one file should not knock a hole in the
73
+ * column.
74
+ */
75
+ icon?: string | undefined;
62
76
  /**
63
77
  * Calls to action, and the opt-in for the page's hero.
64
78
  *
@@ -122,6 +136,8 @@ interface DocNavPage {
122
136
  title: string;
123
137
  href: string;
124
138
  slug: string;
139
+ /** Marker name from the page's frontmatter. See {@link DocFrontmatter.icon}. */
140
+ icon?: string | undefined;
125
141
  }
126
142
  /**
127
143
  * A directory. `href` is present when the directory has an `index.md`, in
@@ -132,6 +148,8 @@ interface DocNavGroup {
132
148
  title: string;
133
149
  href?: string | undefined;
134
150
  children: DocNavNode[];
151
+ /** Marker name from the directory's `meta.json`. */
152
+ icon?: string | undefined;
135
153
  }
136
154
  /** A non-interactive heading between groups, from `"---Label---"` in meta.json. */
137
155
  interface DocNavSeparator {
@@ -144,6 +162,8 @@ interface DocNavLink {
144
162
  title: string;
145
163
  href: string;
146
164
  external: boolean;
165
+ /** Marker name from the `meta.json` entry that declared this link. */
166
+ icon?: string | undefined;
147
167
  }
148
168
  type DocNavNode = DocNavPage | DocNavGroup | DocNavSeparator | DocNavLink;
149
169
  /**
@@ -164,10 +184,17 @@ type DocNavNode = DocNavPage | DocNavGroup | DocNavSeparator | DocNavLink;
164
184
  interface DocsMeta {
165
185
  /** Directory title, shown as the group heading. Defaults to the dirname. */
166
186
  title?: string | undefined;
187
+ /**
188
+ * Sidebar marker for this directory, as a name the consumer resolves. See
189
+ * {@link DocFrontmatter.icon} — the same contract, declared where the
190
+ * directory is described rather than where a page is.
191
+ */
192
+ icon?: string | undefined;
167
193
  /** Ordered entries. Omit to sort by frontmatter `order`, then alphabetically. */
168
194
  pages?: Array<string | {
169
195
  title: string;
170
196
  href: string;
197
+ icon?: string | undefined;
171
198
  }> | undefined;
172
199
  }
173
200
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waveso/docs",
3
- "version": "0.7.0",
3
+ "version": "0.8.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": [