@waveso/docs 0.6.0 → 0.7.1

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,184 @@
1
1
  # @waveso/docs
2
2
 
3
+ ## 0.7.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 43af6e9: The back-to-top link appears when there is something to go back to.
8
+
9
+ It sat at the foot of the table of contents on every page, including at the top
10
+ of one, offering to return a reader to where they already were. It now fades in
11
+ between 25dvh and 35dvh of scroll and fades back out on the way up.
12
+
13
+ No JavaScript was added to do it. The reveal is a scroll-driven animation, so
14
+ scroll position alone drives it — no listener, no state, no re-render per
15
+ frame, and correct before the component has hydrated. `DocsToc` is the smallest
16
+ client component this package ships and it has not grown by a byte.
17
+
18
+ `visibility` moves with the fade, so the link leaves the tab order while it is
19
+ invisible rather than sitting there as a focus target nobody can see — and it
20
+ rejoins only once it is legible, not at the first pixel of the fade.
21
+
22
+ Where the timeline cannot run the link is simply always present, exactly as it
23
+ was: Firefox has not shipped scroll-driven animations, a page too short to
24
+ scroll leaves the timeline inactive, and so does a host that scrolls an inner
25
+ pane rather than the document. Nothing hides a control on the strength of a
26
+ feature the engine did not run.
27
+
28
+ ## 0.7.0
29
+
30
+ ### Minor Changes
31
+
32
+ - da25315: **A page becomes a landing page by declaring its actions.** `actions` in the
33
+ frontmatter turns `title` and `description` into a page header with those links
34
+ beneath them:
35
+
36
+ ```yaml
37
+ ---
38
+ title: Wave Docs
39
+ description: Markdown documentation for Next.js.
40
+ actions:
41
+ - label: Quick start
42
+ href: /getting-started
43
+ - label: GitHub
44
+ href: https://github.com/waveso/docs
45
+ variant: secondary
46
+ ---
47
+ ```
48
+
49
+ Leave it off and the page is exactly what it was: `description` stays a `<meta>`
50
+ tag and the title is the first thing in the prose. That is the whole of the
51
+ adaptation for the two shapes this package serves — documentation that is the
52
+ entire site puts a hero on its index, and documentation mounted at `/docs`
53
+ inside an application that already has a marketing page leaves `actions` off.
54
+ There is no mode, no `standalone` flag and nothing to configure.
55
+
56
+ Actions are primary-then-secondary by position, so the common case needs no
57
+ `variant`. Anything that leaves the site gets a plain `<a>` rather than the
58
+ router link, plus `target`, `rel` and a screen-reader suffix; `mailto:` and
59
+ `tel:` get none of that, because they open no tab. Unsafe hrefs fail the build
60
+ rather than reaching an `<a>` — frontmatter was the one door into an `href` that
61
+ bypassed `isSafeHref`.
62
+
63
+ `DocsHero` is exported at `@waveso/docs/react/hero`, and `DocAction` from
64
+ `@waveso/docs/types`.
65
+
66
+ ⚠️ A HERO PAGE MUST NOT ALSO WRITE ITS OWN `# Title`. `render` normally prepends
67
+ an `<h1>` from `frontmatter.title`; on a hero page the hero renders that heading
68
+ instead, because the tagline and the actions have to sit beneath it. Writing one
69
+ in the body as well ships two `h1`s — the same duplication `titleHeading` has
70
+ always warned about.
71
+
72
+ The background is a rotated line grid drawn with `repeating-linear-gradient`
73
+ rather than an inlined SVG: no data URI in the stylesheet, and the lines are
74
+ `--wave-docs-hero-grid` and `--wave-docs-hero-grid-strong` — Wave 200 and Wave
75
+ 300 from `@waveso/ui` in the light theme, Wave 900 and Wave 800 in the dark one
76
+ — so they follow the theme. They are tokens of their own rather than the border
77
+ colours, because a decorative tint must not be tied to a functional contrast
78
+ ratio.
79
+
80
+ - 915729f: **`docs.Layout` renders no header, and the navigation is one sidebar at every
81
+ width.** A full-width sticky bar is the one element that competes with a host
82
+ application's own for the viewport's top edge, and two of the sites using this
83
+ have one — measured against a fixed 64px host bar, ours landed on top of theirs
84
+ at every width. The chrome it held lives in the sidebar now:
85
+
86
+ ```
87
+ .wave-docs-shell the query container
88
+ └─ .wave-docs-layout the grid
89
+ ├─ .wave-docs-layout__sidebar paints nothing, and moves
90
+ │ ├─ …__sidebar-nav the surface, and the one border
91
+ │ └─ …__sidebar-trigger a 44px strip — paints nothing at rest
92
+ ├─ .wave-docs-layout__sidebar-scrim
93
+ ├─ .wave-docs-layout__main
94
+ └─ .wave-docs-layout__toc
95
+ ```
96
+
97
+ Pressing the trigger translates the sidebar by exactly the navigation's width,
98
+ so it leaves the page entirely and the trigger's outer edge lands on the inline
99
+ start edge. There is no drawer, no `<dialog>`, no second control and no second
100
+ copy of the tree.
101
+
102
+ **Nothing this package renders is anchored to the viewport.** The sidebar is a
103
+ grid item, the trigger is a flex child of it, and the scrim is `absolute` inside
104
+ `.wave-docs-layout` — so every one of them resolves against a box this package
105
+ owns and _your_ layout placed. `position: fixed` is the thing to avoid, and the
106
+ reason is specific: a fixed element is anchored to the viewport you share with
107
+ it, your navbar is in the same viewport, and neither can detect the other. The
108
+ search dialog is the one exception and always was.
109
+
110
+ **There is not one width-based `@media` query left.** Every breakpoint is
111
+ `@container`. `@media` asks how wide the _screen_ is, which is the wrong
112
+ question for a package mounted at `/docs` inside an application that owns the
113
+ rest of the page: put this in a 700px panel on a 1920px monitor and `@media`
114
+ says "wide", the sidebar takes its 16rem column, and the reading column comes
115
+ out around 60px. Two shapes fall out of the container width — beside the
116
+ article, or over it behind a scrim with `inert`, Escape, click-to-dismiss and
117
+ focus moved in and restored.
118
+
119
+ Migration, in full:
120
+
121
+ | Was | Becomes |
122
+ | ------------------------------------------------------------- | ------------------------------------------------------ |
123
+ | `<docs.Layout title={<Brand/>}>` | Delete it. The index page's title brands the docs |
124
+ | `<docs.Layout actions={<ThemeToggle/>}>` | Render it in your own layout, around `docs.Layout` |
125
+ | `--wave-docs-header-height` set so sticky columns clear a bar | `--wave-docs-chrome-offset`, same value |
126
+ | `--wave-docs-header-height` set to size the bar | Nothing sizes a bar. There is no bar |
127
+ | `--wave-docs-shell-width` set to cap the shell | Nothing. `--wave-docs-measure` caps the reading column |
128
+ | `actions` as the client-search escape hatch | `search={false}` plus your own `DocsSearch` |
129
+ | A `rootMargin` you relied on defaulting to `-80px` | Pass it explicitly |
130
+ | `.wave-docs-layout__header` / `__title` / `__actions` | Not rendered |
131
+ | `.wave-docs-layout__sidebar > .wave-docs-sidebar` | The tree is a grandchild now |
132
+
133
+ ⚠️ `--wave-docs-header-height` DID TWO JOBS AND ONLY ONE SURVIVES. It sized the
134
+ header, and it was the offset both sticky columns parked below. Nothing of this
135
+ package's sits above the content any more, so the sizing job is gone; the offset
136
+ job is `--wave-docs-chrome-offset`, and a host with their own fixed bar still
137
+ needs to set it or the sidebar and the table of contents park underneath it.
138
+
139
+ ⚠️ `--wave-docs-chrome-offset` DEFAULTS TO `0rem`, WITH A UNIT, AND THE UNIT IS
140
+ LOAD-BEARING. It is read inside `calc(100dvh - …)`, where a unitless `0` is
141
+ invalid at computed-value time — the height on the sidebar and the `max-height`
142
+ on the table of contents would die rather than resolve to no change.
143
+
144
+ ⚠️ `--wave-docs-shell-width` IS GONE RATHER THAN RENAMED. It capped the whole
145
+ shell and centred it, which pushed the sidebar's inline start 480px in from the
146
+ screen on a 2560px display — and left a _closed_ navigation parked in the
147
+ centring margin instead of off the page. The sidebar owns the page's inline
148
+ start edge at every width now, which is what makes "closed" mean off the screen
149
+ by construction, and the reading column is what is capped.
150
+
151
+ `DocsToc`'s `rootMargin` default changes from `'-80px 0px -60% 0px'` to
152
+ `'0px 0px -60% 0px'`. The 80px reserved room for a sticky header that is no
153
+ longer rendered; a host whose own chrome overlays the content passes the prop.
154
+
155
+ `DocsLayoutProps` is three props — `children`, `search` and `labels`.
156
+
157
+ Two behaviours are lost with the drawer and are worth knowing: the navigation no
158
+ longer opens before hydration (it was a server-rendered
159
+ `<button command="show-modal">`), and the client bundle grows about 500 bytes
160
+ for the containment work `<dialog>` used to give for free.
161
+
162
+ ### Patch Changes
163
+
164
+ - 1313e77: The shell no longer depends on the host shipping a CSS reset.
165
+
166
+ ⚠️ `box-sizing` WAS THE HOST'S TO SET, AND ALMOST EVERY HOST SETS IT.
167
+ `.wave-docs-sidebar__link` is `width: 100%` with `0.5rem` of inline padding, so
168
+ under `content-box` it is a 272px box in a 256px track — and the external-link
169
+ icon that `justify-content: space-between` pins to the far end renders 8px
170
+ outside the sidebar, clipped in half. Tailwind's preflight and every
171
+ normalize-style reset declare `border-box` globally, so this was invisible in
172
+ every project that has one, and visible on the only site here that ships no CSS
173
+ at all. `box-sizing: border-box` now applies to elements carrying a
174
+ `wave-docs-` class, scoped to this package's own namespace rather than to `*`,
175
+ because the prose renders a consumer's components too.
176
+
177
+ Three README claims were wrong and are corrected: `react/*` is ten subpaths and
178
+ not nine, three modules import from `next/*` and not two, and the twenty-two
179
+ chrome labels break down into six groups rather than the five that summed to
180
+ nineteen.
181
+
3
182
  ## 0.6.0
4
183
 
5
184
  ### Minor Changes
@@ -404,7 +583,7 @@
404
583
 
405
584
  Every subpath in `exports` is listed in the README, and so is every runtime name each one exports — `manifest.test.ts` enumerates both against the built output and fails the build on a name this README does not mention. That test is new in this release, and writing it immediately found five: `DOCS_ERROR_PREFIX`, `DEFAULT_DOCS_THEMES`, `CALLOUT_TYPES`, `defaultMarkdownComponents` and `DOCS_CONTENT_ID` were all public and documented nowhere.
406
585
 
407
- **The default page is worth looking at.** A 46rem measure and a system font stack, a 1.2 minor-third type scale, tables that scroll instead of shredding the layout, one focus `outline` in place of five `box-shadow` rings (which also deletes the forced-colors block that existed to patch them), and a responsive shell with breakpoints at 64/80/100rem. The element tree, the five layout tokens and the breakpoints are frozen in `docs/adr/001-shell-contract.md`.
586
+ **The default page is worth looking at.** A 46rem measure and a system font stack, a 1.2 minor-third type scale, tables that scroll instead of shredding the layout, one focus `outline` in place of five `box-shadow` rings (which also deletes the forced-colors block that existed to patch them), and a responsive shell with breakpoints at 64/80/100rem. The element tree, the five layout tokens and the breakpoints are frozen.
408
587
 
409
588
  **Three new harnesses, because the old ones could not see these failures.** A browser tier running real Chromium — jsdom reports every width as `0`, so the measure, the type scale, reflow and the table floor were unassertable. A smoke build of a real Next application against the published `exports` map, in both output modes. And `pnpm check:readme`, which type-checks every example in this README as one project: it immediately found `app/docs/layout.tsx` defined twice with different bodies, and an `imageResolver` example calling `imageSize(path)` when `image-size` v2 takes a `Uint8Array`.
410
589
 
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.5 KB |
72
+ | Everything the quick start ships, gzipped | 13.9 KB |
73
73
  | Search dialog and router wiring | 9.3 KB |
74
- | Navigation: sidebar and mobile drawer | 2.4 KB |
74
+ | Navigation: one sidebar, open and closed | 2.9 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.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 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.
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
 
@@ -140,7 +140,7 @@ content/docs/
140
140
  authentication.md
141
141
  ```
142
142
 
143
- That is a working documentation site: routing, a navigation sidebar, a table of contents, syntax highlighting, search, a mobile drawer and a skip link.
143
+ That is a working documentation site: routing, a navigation sidebar that opens and closes, a table of contents, syntax highlighting, search and a skip link.
144
144
 
145
145
  The search route is in the quick start rather than in a section further down because `docs.Layout` renders the search trigger by default — leave the route out and a reader gets a control that opens onto "Search is unavailable". If you genuinely do not want search, `export default function Layout(props) { return docs.Layout({ ...props, search: false }) }` drops both the trigger and this file. See [Search](#search) for tuning.
146
146
 
@@ -158,7 +158,7 @@ There is no root export. Every entry point is a subpath, so an import always nam
158
158
  | `@waveso/docs/render` | Node | `createDocsRenderer`, `resolveMarkdownLink` |
159
159
  | `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS`, `DEFAULT_DOCS_THEMES` |
160
160
  | `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex` |
161
- | `@waveso/docs/react/<name>` | Browser + RSC | Nine components, one per subpath — see [Components](#components) |
161
+ | `@waveso/docs/react/<name>` | Browser + RSC | Ten subpaths, one component each — see [Components](#components) |
162
162
  | `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter`, `z` |
163
163
  | `@waveso/docs/types` | Any | Every shared type. Type-only |
164
164
  | `@waveso/docs/errors` | Any | `DocsErrorCode`, `DocsError`, `isDocsError`, `DOCS_ERROR_PREFIX` |
@@ -172,15 +172,21 @@ That is about **weight, not about `node:fs`** — and the distinction matters, b
172
172
 
173
173
  ### Layout tokens
174
174
 
175
- Five custom properties size the shell, all layered so an unlayered `:root` of your own still wins. The full contract is in [`docs/adr/001-shell-contract.md`](./docs/adr/001-shell-contract.md).
175
+ Five custom properties size the shell, all layered so an unlayered `:root` of your own still wins. All six are public API: a name changes only in a release that carries the migration.
176
176
 
177
177
  | Token | Default | Controls |
178
178
  | --- | --- | --- |
179
179
  | `--wave-docs-measure` | `46rem` | Prose column width. `none` opts out |
180
- | `--wave-docs-header-height` | `3.5rem` | Header, and the offset sticky columns park below |
181
- | `--wave-docs-sidebar-width` | `16rem` | Sidebar track |
180
+ | `--wave-docs-sidebar-width` | `16rem` | The navigation's width |
181
+ | `--wave-docs-trigger-width` | `1.25rem` | The trigger's button. The strip around it is this plus 4px a side |
182
182
  | `--wave-docs-toc-width` | `15rem` | Table-of-contents track |
183
- | `--wave-docs-shell-width` | `100rem` | Maximum shell width |
183
+ | `--wave-docs-chrome-offset` | `0rem` | Where our sticky chrome starts, below a bar of yours |
184
+
185
+ **`--wave-docs-shell-width` was removed in 0.7.0, and it is the sidebar's edge that replaced it.** It capped the whole shell and centred it, which put the sidebar's inline start 480px in from the screen on a 2560px display — and, worse, left a *closed* navigation parked in the centring margin instead of off the page. The reading column is the thing that should not stretch, so `--wave-docs-measure` caps it and it centres itself in its track; the sidebar keeps the page's inline start edge at every width, which is what makes "closed" mean off the screen by construction.
186
+
187
+ **`--wave-docs-header-height` is gone in 0.7.0, and renaming it to one thing would have lost the other.** It sized the header *and* it was the offset both sticky columns parked below. The header is gone, and so is every shape that sat above the content, so the sizing half has nothing left to size. The offset half is the one that matters and it is `--wave-docs-chrome-offset`: `--wave-docs-chrome-offset: 4rem` starts our sidebar and our table of contents 4rem down, and feeds the scroll padding that keeps an anchored heading clear of your bar.
188
+
189
+ The default is `0rem` rather than `0`, and the unit is load-bearing: it is read inside `calc(100dvh - …)` on both sticky columns, and `calc(100dvh - 0)` is invalid at computed-value time — a unitless zero kills the `max-height` instead of resolving to no change.
184
190
 
185
191
  The shell has three breakpoints, in `rem` so they scale with the reader's base font size: the sidebar appears at **64rem**, the table of contents at **80rem**, and the whole grid stops growing at **100rem**. 64rem is arithmetic rather than taste — a 16rem sidebar plus a 46rem measure plus two 1.5rem gutters is 65rem, so anything narrower introduces the sidebar exactly where it starts eating the measure it frames.
186
192
 
@@ -190,11 +196,12 @@ Every subpath is enumerated in `exports` — there is no wildcard. A name that i
190
196
 
191
197
  ## Components
192
198
 
193
- Every component takes data as props, and two modules in `src/react/` import from `next/*` — `next-nav` for `usePathname` and `next-search` for `useRouter`, each named so the exception is visible in the file list. Everything else has `next/link` and `next/image` injected. That keeps the renderer host-agnostic and testable without a router. `DocsSearch` is the one exception, and it exists precisely so that the exception is ours rather than yours: it is the fifteen-line wrapper you would otherwise write around `SearchDialog`.
199
+ Every component takes data as props, and every module that imports from `next/*` is named for it — `next-nav` for `usePathname`, `next-search` for `useRouter`, `next-link` for `next/link` itself — so the exception is visible in the file list rather than three imports deep. Everything else has `next/link` and `next/image` injected. That keeps the renderer host-agnostic and testable without a router. `DocsSearch` is the one exception, and it exists precisely so that the exception is ours rather than yours: it is the fifteen-line wrapper you would otherwise write around `SearchDialog`.
194
200
 
195
201
  | Component | Subpath | Notes |
196
202
  | --- | --- | --- |
197
203
  | `DocContent` | `react/doc-content` | Renders a hast tree, inside `.wave-docs-prose`. Server Component |
204
+ | `DocsHero` | `react/hero` | A landing page's header. `title`, `description`, `actions`, `Link`, `externalLabel`. Server Component |
198
205
  | `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
199
206
  | `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
200
207
  | `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
@@ -205,53 +212,107 @@ Every component takes data as props, and two modules in `src/react/` import from
205
212
  | `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<main>`; `DOCS_CONTENT_ID` is that id. `docs.Layout` renders one |
206
213
  | `createMarkdownComponents` | `react/markdown-components` | The element → component map. `defaultMarkdownComponents` is the unwired one |
207
214
 
208
- `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.
209
216
 
210
217
  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.
211
218
 
212
219
  ### Layout
213
220
 
214
- `export default docs.Layout` — the one line from the [quick start](#quick-start) — is a Server Component that renders the whole shell: skip link, sticky header, sidebar column, mobile drawer, and the grid that arranges them. It reads the navigation tree and the search index URL itself, so there is nothing to fetch and nothing to pass.
221
+ `export default docs.Layout` — the one line from the [quick start](#quick-start) — is a Server Component that renders the whole shell: skip link, sidebar, search trigger, and the grid that arranges them. It reads the navigation tree and the search index URL itself, so there is nothing to fetch and nothing to pass.
222
+
223
+ **It renders no header, and that is deliberate.** Two kinds of site use this package: documentation mounted inside an application that already has a header, a navbar and its own search, and documentation that is the whole site. A full-width sticky bar of ours serves the second and fights the first — two stacked bars competing for the viewport's top edge, and two search boxes on one page, one of which knows nothing about the documentation.
224
+
225
+ So the sidebar is the chrome. It is a real grid item at every width, and it is the same shell on a phone and on a desktop.
226
+
227
+ **Nothing this package renders is anchored to the viewport.** The sidebar is a grid item, the trigger is a flex child of it, and the scrim is `position: absolute` inside `.wave-docs-layout` — so every one of them resolves against a box this package owns and *your* layout placed. `position: fixed` is the thing to avoid, and the reason is specific: a fixed element is anchored to the viewport you share with it, your navbar is in the same viewport, and neither can detect the other. The search dialog is the one exception and always was — top-layer, present only while open, and nothing collides with something that is not there.
215
228
 
216
229
  Your layout stays a Server Component. The two pieces that need a client — the navigation's `usePathname`, the search dialog — carry their own `'use client'` boundaries inside the package.
217
230
 
218
- To put your own chrome in the header, call it instead of re-exporting it:
231
+ Your own chrome goes *around* `docs.Layout`, in the layout file you already write — the same place `<html>` and `<body>` live. Call it instead of re-exporting it when you want to wrap it, configure it, or both:
219
232
 
220
233
  ```tsx
221
234
  import type { ReactNode } from 'react';
222
235
  import '@waveso/docs/styles.css';
223
236
  import { docs } from '@/lib/docs';
224
237
 
238
+ /** Yours: the header, theme toggle and repository link the rest of the site has. */
239
+ declare function SiteHeader(): ReactNode;
240
+
225
241
  export default function DocsLayout({ children }: { children: ReactNode }) {
226
242
  return (
227
- <docs.Layout
228
- title="Wave"
229
- actions={<a href="https://github.com/waveso/docs">GitHub</a>}
230
- >
231
- {children}
232
- </docs.Layout>
243
+ <>
244
+ <SiteHeader />
245
+ <docs.Layout search={{ placeholder: 'Search the docs' }}>
246
+ {children}
247
+ </docs.Layout>
248
+ </>
233
249
  );
234
250
  }
235
251
  ```
236
252
 
253
+ If that header of yours is sticky, say how tall it is once and the shell's sticky columns start below it:
254
+
255
+ ```css
256
+ :root {
257
+ --wave-docs-chrome-offset: 4rem;
258
+ }
259
+ ```
260
+
237
261
  | Prop | Type | Default | |
238
262
  | --- | --- | --- | --- |
239
- | `title` | `ReactNode` | — | Brand, at the header start |
240
- | `actions` | `ReactNode` | — | Header end, after search |
263
+ | `children` | `ReactNode` | — | What `docs.Page` returns — the `<main>` and the TOC, as two siblings |
241
264
  | `search` | `boolean \| DocsSearchProps` | `true` | The search trigger. An object configures the dialog |
242
265
  | `labels` | `DocsLabels` | the route's | Overrides `createDocsRoute`'s labels, key by key |
243
266
 
244
- Five props, and two of them are small objects. That is deliberate, and it is the difference between this and an eleven-slot layout: everything else a docs shell gets asked for is already reachable. An announcement banner goes *above* `<docs.Layout>` in your own layout, because this does not own `<body>`. A content footer goes inside `children`. Sidebar links, social icons and separators are `DocNavNode`s you author in `meta.json`. The header bar was the one region nothing else could reach hence `actions`. Two node props can become a slots map later; a slots map cannot become two props.
267
+ Three props, and one of them is `children`. That is deliberate, and it is the difference between this and an eleven-slot layout: everything else a docs shell gets asked for is already reachable. An announcement banner goes *above* `<docs.Layout>` in your own layout, because this does not own `<body>`. A content footer goes inside `children`. Sidebar links, social icons and separators are `DocNavNode`s you author in `meta.json`. A theme toggle and a repository link go in the layout you write around this one.
268
+
269
+ **`title` and `actions` were removed in 0.7.0, with the header they lived in.** `title` was a brand slot, and a brand belongs to the index page's own title — content, authored and translatable, part of what the reader came for. The argument for `actions` was that the header bar was the one region nothing else could reach; there is no header bar, and the host wraps `docs.Layout` exactly as it already wraps `<html>` and `<body>`, so there is no region only this package can reach. The one place a host cannot reach through this prop list is *inside* the sidebar, and the answer to that is [composing the primitives yourself](#composing-it-yourself).
245
270
 
246
271
  `search` takes anything `DocsSearch` takes except `indexUrl`, which stays derived from your `basePath`. You do not need to repeat `miniSearchOptions` here to match `createDocsRoute` — the route's own value is forwarded, so the object that built the index is the object that queries it.
247
272
 
248
273
  `labels` belongs on `createDocsRoute` — see [Translating the chrome](#translating-the-chrome) — and this prop overrides it key by key, for a site with two shells or a section in another language.
249
274
 
250
- #### The mobile drawer
275
+ #### One sidebar, open and closed
276
+
277
+ There is no mobile version. The sidebar is a shell holding two things in a row:
278
+
279
+ ```
280
+ .wave-docs-shell the query container
281
+ └─ .wave-docs-layout the grid
282
+ ├─ .wave-docs-layout__sidebar paints nothing, and moves
283
+ │ ├─ …__sidebar-nav the surface, and the one border
284
+ │ └─ …__sidebar-trigger the strip — paints nothing at rest
285
+ ├─ .wave-docs-layout__sidebar-scrim
286
+ ├─ .wave-docs-layout__main
287
+ └─ .wave-docs-layout__toc
288
+ ```
251
289
 
252
- Below 64rem the sidebar is a `<dialog>` opened by a server-rendered `<button command="show-modal">` so it works on the first tap, before hydration, and with JavaScript disabled. Focus moves inside and Tab stays there, Escape closes it and returns focus to the trigger, a click on the backdrop dismisses it, and the page behind does not scroll. All of that is the browser's, not ours.
290
+ Pressing the trigger translates the shell by `calc(var(--wave-docs-trigger-width) - 100%)` "minus all of me, plus the trigger back" so the navigation goes entirely off the page and the trigger's outer edge lands exactly on the inline start edge. **The navigation's width appears nowhere in that expression**, so the two cannot drift apart. The trigger rides on the navigation's outer edge because it is the next flex item, not because a number says so.
253
291
 
254
- At 64rem and above the same element becomes the sticky column, via `display: contents`. One navigation in the DOM at every width: one landmark, one copy of the links in the payload, nothing to keep in step.
292
+ One navigation in the DOM at every width: one landmark, one copy of the links in the payload, nothing to keep in step.
293
+
294
+ #### It adapts to its container, not to your screen
295
+
296
+ **There is not one width-based `@media` query in this package.** Every breakpoint is `@container`, and that is not a stylistic preference — it is the difference between a docs theme and a component you can mount inside something else.
297
+
298
+ `@media` asks how wide the *screen* is. If you put this in a 700px panel on a 1920px monitor, `@media` says "wide", the sidebar takes its 16rem column, and the reading column comes out around 60px. `@container` asks how wide the box you *gave* it is, which is the question with an answer.
299
+
300
+ Two shapes fall out of that:
301
+
302
+ - **Push**, in a container 64rem or wider: the navigation sits beside the article, and opening or closing it changes the article's width.
303
+ - **Cover**, below that: the navigation sits on top of the article behind a scrim, and the article's measure never changes when you toggle.
304
+
305
+ Same markup, same classes, same control, same translate. The only thing that differs is whether the article gets out of the way.
306
+
307
+ Cover mode is a real overlay, so it ships what an overlay owes a reader: `inert` on everything the navigation covers, Escape to close, click-the-scrim to dismiss, and focus moving into the navigation and back out again. Those five were the browser's while this was a `<dialog>`; they are hand-written now, and skipping them is how an overlay becomes a keyboard trap in the wrong direction.
308
+
309
+ `--wave-docs-sidebar-mode` is how the component knows which shape it is in: the stylesheet declares it, the component reads it back with `getComputedStyle`. `matchMedia` cannot answer a container query, and duplicating the breakpoint in JavaScript is how the two drift.
310
+
311
+ #### Three states, and no flash
312
+
313
+ The server renders no `data-state` at all. That absence means "nobody has chosen yet", and CSS resolves it per mode — closed where the navigation would cover the article, open where it would sit beside it. So the first paint is already right at both shapes, with no JavaScript and nothing to correct. Once a reader presses the trigger their choice is explicit and wins at every width.
314
+
315
+ **This replaced a `<dialog>` drawer in 0.7.0.** Below 64rem the navigation used to be a modal opened by a second control, with `display: contents` above it so the same DOM could serve as the desktop column. It bought focus trapping, Escape and a scroll lock from the browser, and it worked before hydration. It cost two controls for one piece of navigation, a drawer that painted over the tree it contained, and a scroll-into-view that could never run on a phone — a closed `<dialog>` has no layout, so the current page was always below the fold. A closed sidebar is *moved*, not hidden, so that last one is structurally impossible now.
255
316
 
256
317
  #### Composing it yourself
257
318
 
@@ -286,7 +347,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
286
347
 
287
348
  `docs.Page` returns exactly this shape: the `<main>` and the table of contents as **two siblings**, not one wrapped element. They land as direct children of the grid, which is what puts them in separate columns — so if you compose your own page inside `docs.Layout`, return a fragment rather than a wrapper.
288
349
 
289
- **The two class names are load-bearing**, and they are the part of this that is easy to leave off. `wave-docs-layout__main` carries `min-width: 0`, without which a wide table pushes the whole document into horizontal scroll (measured: 1048px of document inside a 1024px viewport). `wave-docs-layout__toc` is what the grid reserves its third track with, via `:has()` — unclassed, the table of contents auto-places into the next row underneath the sidebar above 80rem, and renders inline on a phone instead of being hidden. Both are frozen in [`docs/adr/001-shell-contract.md`](docs/adr/001-shell-contract.md), so they are safe to write by hand.
350
+ **The two class names are load-bearing**, and they are the part of this that is easy to leave off. `wave-docs-layout__main` carries `min-width: 0`, without which a wide table pushes the whole document into horizontal scroll (measured: 1048px of document inside a 1024px viewport). `wave-docs-layout__toc` is what the grid reserves its third track with, via `:has()` — unclassed, the table of contents auto-places into the next row underneath the sidebar above 80rem, and renders inline on a phone instead of being hidden. Both names are public API and change only in a release that carries the migration, so they are safe to write by hand.
290
351
 
291
352
  The `null` is load-bearing too: `:has()` matches an empty `<aside>` exactly as well as a full one, so a page with no headings would give up 15rem to nothing.
292
353
 
@@ -300,6 +361,12 @@ label: Auth # sidebar label, when the title is too long
300
361
  draft: true # excluded from nav, search and static params
301
362
  order: 10 # sort weight where there is no meta.json
302
363
  aliases: [old-auth, legacy/auth] # former URLs → permanent redirects
364
+ actions: # calls to action — and the opt-in for a hero
365
+ - label: Quick start
366
+ href: /getting-started
367
+ - label: GitHub
368
+ href: https://github.com/waveso/docs
369
+ variant: secondary
303
370
  ---
304
371
  ```
305
372
 
@@ -307,6 +374,18 @@ aliases: [old-auth, legacy/auth] # former URLs → permanent redirects
307
374
 
308
375
  `draft` is deliberately **not** tied to `NODE_ENV`. Preview deployments are production builds, so branching on it would hide drafts in exactly the place reviewers look — drive `includeDrafts` from your own environment check instead.
309
376
 
377
+ ### The hero
378
+
379
+ `actions` is the only thing that turns a page into a landing page. Declare it and `title` and `description` become a page header — a large heading, the description as a tagline under it, and these links beneath that. Leave it off and the page is exactly what it was: `description` stays a `<meta>` tag and the title is the first thing in the prose.
380
+
381
+ Each action takes a `label`, an `href` and an optional `variant` of `primary` or `secondary`. Omit the variant and the first action is the primary and the rest are secondary, which is the shape every landing page has. An `href` that leaves the site gets `target="_blank"`, `rel="noreferrer"` and a screen-reader suffix; `mailto:` and `tel:` do not, because they open no tab. Unsafe hrefs fail the build rather than reaching an `<a>`.
382
+
383
+ **That is the whole of the adaptation for the two shapes this package serves.** Documentation that is the entire site puts a hero on its index. Documentation mounted at `/docs` inside an application that already has a marketing page leaves `actions` off and gets an ordinary page. There is no mode, no `standalone` flag and nothing to configure — the opt-in lives in the file that wants it.
384
+
385
+ ⚠️ **A hero page must not also write its own `# Title`.** `render` normally prepends an `<h1>` from `frontmatter.title`; on a hero page the hero renders that heading instead, because the tagline and the actions have to sit beneath it. Writing one in the body as well ships two `h1`s — the same duplication `titleHeading` has always warned about.
386
+
387
+ The background is a rotated line grid under scrims painted in the page's own colour rather than behind a mask: a soft ellipse over the words, a bottom fade, and a vignette that closes at the corners. The vignette is an inset `box-shadow` rather than a gradient — a `radial-gradient` is only ever a circle or an ellipse, and this one needs a corner radius. `border-radius` is that knob, and `corner-shape: superellipse(3)` makes it a squircle where the browser supports it. Alpha compositing of a solid blends where mask layers multiply, so the falloff is smooth instead of compounding into a shoulder. It is drawn with `repeating-linear-gradient` rather than an inlined SVG — no data URI in the stylesheet. The lines are `--wave-docs-hero-grid` and `--wave-docs-hero-grid-strong`, which are Wave 200 and Wave 300 from `@waveso/ui` in the light theme and Wave 900 and Wave 800 in the dark one. They are tokens of their own rather than the border colours: the grid is decoration behind a mask that leaves it near-invisible where the words are, and a border is a boundary a reader has to be able to see.
388
+
310
389
  ### Your own fields
311
390
 
312
391
  Pass a `frontmatterSchema` and every `DocFile` and `RenderedDoc` carries your fields, inferred, with no type argument anywhere:
@@ -715,7 +794,7 @@ export function DocsSearchTrigger({ indexUrl }: { indexUrl: string }) {
715
794
  }
716
795
  ```
717
796
 
718
- Add the same function to your `createDocsRoute` call — `miniSearchOptions: { processTerm: stripDashes }` — so the index is built with it. Then turn the built-in trigger off and render yours in `actions`, in `app/docs/layout.tsx`:
797
+ Add the same function to your `createDocsRoute` call — `miniSearchOptions: { processTerm: stripDashes }` — so the index is built with it. Then turn the built-in trigger off and render yours in the layout you write around `docs.Layout`, in `app/docs/layout.tsx`:
719
798
 
720
799
  ```tsx
721
800
  import '@waveso/docs/styles.css';
@@ -725,17 +804,15 @@ import { docs } from '@/lib/docs';
725
804
 
726
805
  export default function DocsLayout({ children }: { children: ReactNode }) {
727
806
  return (
728
- <docs.Layout
729
- search={false}
730
- actions={<DocsSearchTrigger indexUrl={docs.searchIndexUrl} />}
731
- >
732
- {children}
733
- </docs.Layout>
807
+ <>
808
+ <DocsSearchTrigger indexUrl={docs.searchIndexUrl} />
809
+ <docs.Layout search={false}>{children}</docs.Layout>
810
+ </>
734
811
  );
735
812
  }
736
813
  ```
737
814
 
738
- `search={false}` omits the built-in trigger so yours is the only one, and it is also why the refusal is scoped to the forward: the route keeps the function for the index it builds on the server, and nothing crosses to the client but a string.
815
+ `search={false}` omits the built-in trigger so yours is the only one, and it is also why the refusal is scoped to the forward: the route keeps the function for the index it builds on the server, and nothing crosses to the client but a string. To put your trigger *inside* the sidebar rather than above it, [compose the shell yourself](#composing-it-yourself) — `docs.Layout` has no slot for it, deliberately.
739
816
 
740
817
  ### Building the index yourself
741
818
 
@@ -849,7 +926,7 @@ A link is skipped when it equals one of these or begins with one followed by `/`
849
926
 
850
927
  ### Translating the chrome
851
928
 
852
- Twenty-two strings, and every one of them is yours to set. They go on `createDocsRoute` rather than on `docs.Layout`, because they are not all rendered in the same place: four are the shell's, two the table of contents', nine come from the markdown component map, two are baked into the HTML by a rehype plugin at build time, and two are announced by a client-side runtime after a copy. A layout prop is upstream of the first four and nothing else.
929
+ Twenty-two strings, and every one of them is yours to set. They go on `createDocsRoute` rather than on `docs.Layout`, because they are not all rendered in the same place: four are the shell's, three the navigation tree's, two the table of contents', nine come from the markdown component map, two are baked into the HTML by a rehype plugin at build time, and two are announced by a client-side runtime after a copy. A layout prop is upstream of the first four and nothing else.
853
930
 
854
931
  ```ts
855
932
  // lib/docs-pt.ts
@@ -1051,9 +1128,9 @@ breaking — which is the part most packages leave unsaid until someone is angry
1051
1128
  | --- | --- |
1052
1129
  | Every subpath in `exports`, and every runtime name it exports | ✅ enforced by `manifest.test.ts` |
1053
1130
  | Exported types, including `DocsErrorCode`'s members | ✅ enforced by `error-taxonomy.test.ts` |
1054
- | **CSS class names** — `wave-docs-*`, and the shell's element tree | ✅ frozen in [ADR 001](docs/adr/001-shell-contract.md) |
1131
+ | **CSS class names** — `wave-docs-*`, and the shell's element tree | ✅ a name changes only in a release that carries the migration |
1055
1132
  | **The hast this emits** — element names, and the attributes on them | ✅ the same policy as the types |
1056
- | Layout tokens — the five custom properties above | ✅ |
1133
+ | Layout tokens — the six custom properties above | ✅ |
1057
1134
  | Anything reachable only through `dist/` internals, or a private module | ❌ |
1058
1135
 
1059
1136
  The two in bold are the ones usually omitted, and omitting them is how a
@@ -1147,7 +1224,7 @@ src/
1147
1224
  next.ts # The App Router adapter
1148
1225
  meta.ts # meta.json ordering
1149
1226
  plugins/ # remark/rehype plugins
1150
- react/ # Components. Only next-nav and next-search touch next/*
1227
+ react/ # Components. Only the next-* modules touch next/*
1151
1228
  styles.css # Theme tokens + prose styles
1152
1229
  ```
1153
1230
 
@@ -32,6 +32,14 @@ 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
+ actions: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
36
+ label: z.ZodString;
37
+ href: z.ZodString;
38
+ variant: z.ZodExactOptional<z.ZodEnum<{
39
+ primary: "primary";
40
+ secondary: "secondary";
41
+ }>>;
42
+ }, z.core.$strip>>>;
35
43
  }, z.core.$strip>;
36
44
  /**
37
45
  * Validate one file's frontmatter, or throw an error that names the file.
@@ -1,4 +1,5 @@
1
1
  import { docsError } from "./docs-error.js";
2
+ import { isSafeHref } from "./safe-href.js";
2
3
  import { z } from "zod";
3
4
  //#region src/frontmatter.ts
4
5
  /**
@@ -30,7 +31,12 @@ const docFrontmatterSchema = z.object({
30
31
  label: z.string().exactOptional(),
31
32
  draft: z.boolean().exactOptional(),
32
33
  aliases: z.array(z.string()).exactOptional(),
33
- order: z.number().exactOptional()
34
+ order: z.number().exactOptional(),
35
+ actions: z.array(z.object({
36
+ label: z.string().min(1),
37
+ href: z.string().min(1).refine(isSafeHref, "must be a safe URL (no javascript: or data:)"),
38
+ variant: z.enum(["primary", "secondary"]).exactOptional()
39
+ })).exactOptional()
34
40
  });
35
41
  /**
36
42
  * The package's own fields, every one of them optional, for the overlay pass