@waveso/docs 0.1.0 → 0.2.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +111 -22
  3. package/dist/docs-error.d.ts +74 -0
  4. package/dist/docs-error.js +40 -0
  5. package/dist/frontmatter.d.ts +39 -7
  6. package/dist/frontmatter.js +51 -24
  7. package/dist/highlighter.d.ts +2 -2
  8. package/dist/highlighter.js +3 -2
  9. package/dist/map-pooled.d.ts +26 -0
  10. package/dist/map-pooled.js +45 -0
  11. package/dist/meta.d.ts +7 -3
  12. package/dist/meta.js +61 -15
  13. package/dist/next.d.ts +41 -19
  14. package/dist/next.js +117 -21
  15. package/dist/plugins/rehype-capture-toc.js +26 -15
  16. package/dist/plugins/rehype-code-language.d.ts +24 -0
  17. package/dist/plugins/rehype-code-language.js +48 -0
  18. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  19. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  20. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  21. package/dist/plugins/rehype-flatten-roots.js +39 -0
  22. package/dist/plugins/remark-doc-links.d.ts +12 -1
  23. package/dist/plugins/remark-doc-links.js +147 -20
  24. package/dist/react/markdown-components.js +71 -6
  25. package/dist/react/search-dialog.d.ts +23 -7
  26. package/dist/react/search-dialog.js +46 -29
  27. package/dist/react/toc.js +28 -5
  28. package/dist/react/youtube.js +6 -4
  29. package/dist/render.d.ts +43 -9
  30. package/dist/render.js +112 -50
  31. package/dist/search-index.d.ts +32 -14
  32. package/dist/search-index.js +45 -51
  33. package/dist/search-options.d.ts +32 -1
  34. package/dist/search-options.js +66 -3
  35. package/dist/section-boundary.d.ts +17 -0
  36. package/dist/section-boundary.js +43 -0
  37. package/dist/source.d.ts +13 -1
  38. package/dist/source.js +152 -56
  39. package/dist/styles.css +236 -90
  40. package/dist/types.d.ts +41 -27
  41. package/package.json +13 -12
package/dist/styles.css CHANGED
@@ -14,9 +14,9 @@
14
14
  * the classes. Both win, and NOT because of source order — everything this file
15
15
  * declares lives in a `@layer`, and unlayered CSS beats every layer regardless
16
16
  * of specificity. That distinction is the whole fix: the dark token block is
17
- * `:root:not([data-theme='light'])`, which is (0,2,0), so an unlayered `:root`
18
- * at (0,1,0) used to lose no matter where it was loaded — the cascade never got
19
- * as far as order. A consumer had to write `:root:root:root` to outrank us.
17
+ * `:root[data-theme='dark']`, which is (0,2,0), so an unlayered `:root` at
18
+ * (0,1,0) would lose no matter where it was loaded — the cascade never gets as
19
+ * far as order. A consumer would have to write `:root:root:root` to outrank us.
20
20
  *
21
21
  * `@tailwindcss/typography` is deliberately not used. `.prose` pins
22
22
  * `max-width: 65ch` — wrong inside a three-column docs shell — and styles
@@ -24,12 +24,6 @@
24
24
  * below does the same job without the fight.
25
25
  */
26
26
 
27
- /* Register this package's compiled components as Tailwind sources, so a
28
- * consumer using Tailwind gets any utility class we ship. It belongs here, not
29
- * in the app: a consumer-side `@source "../node_modules/@waveso/docs"` hardcodes
30
- * the relative depth of node_modules and breaks under pnpm's nested store. */
31
- @source "./";
32
-
33
27
  /* Explicit, so the order does not silently depend on which block appears
34
28
  * first. `theme` holds the tokens, `base` the element resets, `components`
35
29
  * the classes. A consumer's own unlayered CSS outranks all three. */
@@ -38,10 +32,17 @@
38
32
  /* ---------------------------------------------------------------------------
39
33
  * Tokens
40
34
  *
35
+ * ⚠️ DARK IS OPT-IN, NEVER AUTOMATIC. This sheet paints a region of somebody
36
+ * else's page, and it cannot see that page's background. Installing the dark
37
+ * ramp from `prefers-color-scheme` alone put near-white text (1.23:1) on the
38
+ * white page of every light-only site with a /docs section — and on every site
39
+ * using next-themes, whose default `attribute="class"` sets `.dark` and never
40
+ * `data-theme`. So: `[data-theme='dark']` or `.dark` switches, and a host that
41
+ * genuinely wants to follow the OS says so with `[data-theme='system']`.
42
+ *
41
43
  * The dark block is spelled twice on purpose: `@media` and an attribute
42
- * selector cannot be combined into one selector list. `data-theme` wins over
43
- * the OS in both directions — `[data-theme='light']` opts out of a dark system,
44
- * `[data-theme='dark']` opts into dark on a light one.
44
+ * selector cannot be combined into one selector list. The two copies are
45
+ * asserted identical by `src/styles.test.ts`.
45
46
  * ------------------------------------------------------------------------ */
46
47
 
47
48
  /*
@@ -56,16 +57,22 @@
56
57
  * ~0.555 and still clear 4.5:1 on `bg-subtle`; `fg-muted` moved down to keep
57
58
  * three visibly distinct steps rather than two-and-a-half.
58
59
  *
59
- * The dark ramp is fg (0.93) / fg-muted (0.72) / fg-subtle (0.64), and its
60
+ * The dark ramp is fg (0.93) / fg-muted (0.72) / fg-subtle (0.665), and its
60
61
  * `accent-subtle` sits at 0.26 rather than 0.30 — `fg-subtle` paints the search
61
62
  * breadcrumb, which lands on `accent-subtle` in the highlighted result, and a
62
63
  * lighter tint there would force `fg-subtle` up into `fg-muted`.
63
64
  */
64
65
  @layer theme {
65
66
  /* Layered so a consumer's unlayered `:root` outranks these without having
66
- * to out-specify `:root:not([data-theme='light'])`. Inside this layer the
67
- * usual rules still apply, so the dark blocks keep beating the light one. */
67
+ * to out-specify `:root[data-theme='dark']`. Inside this layer the usual
68
+ * rules still apply, so the dark blocks keep beating the light one. */
68
69
  :root {
70
+ /* Native scrollbars, form controls and the canvas behind an overscroll are
71
+ * painted by the UA, not by us; without this they stay light on a dark
72
+ * page. It is declared alongside the ramp it belongs to, so the two can
73
+ * never disagree. */
74
+ color-scheme: light;
75
+
69
76
  --wave-docs-bg: oklch(1 0 0);
70
77
  --wave-docs-bg-subtle: oklch(0.985 0.002 260);
71
78
  --wave-docs-fg: oklch(0.25 0.02 262);
@@ -84,7 +91,11 @@
84
91
 
85
92
  --wave-docs-code-bg: oklch(0.97 0.003 262);
86
93
  --wave-docs-code-fg: oklch(0.32 0.03 300);
87
- --wave-docs-code-block-bg: oklch(0.985 0.002 260);
94
+ /* Darker than `bg-subtle`: this one has to read as a surface against the
95
+ * page (1.07:1) rather than merge into it, because it is the only thing
96
+ * separating a code block from the prose around it apart from a 1px
97
+ * border. */
98
+ --wave-docs-code-block-bg: oklch(0.975 0.003 262);
88
99
 
89
100
  /* Each accent is the label colour on its own tinted background, so each one
90
101
  * is a 4.5:1 pair in its own right — not merely legible against the page. */
@@ -94,14 +105,17 @@
94
105
  --wave-docs-callout-tip-bg: oklch(0.97 0.03 156);
95
106
  --wave-docs-callout-important: oklch(0.55 0.19 300);
96
107
  --wave-docs-callout-important-bg: oklch(0.97 0.025 300);
97
- --wave-docs-callout-warning: oklch(0.53 0.14 72);
98
- --wave-docs-callout-warning-bg: oklch(0.97 0.04 82);
108
+ --wave-docs-callout-warning: oklch(0.53 0.14 80);
109
+ --wave-docs-callout-warning-bg: oklch(0.97 0.04 80);
99
110
  --wave-docs-callout-caution: oklch(0.55 0.2 24);
100
111
  --wave-docs-callout-caution-bg: oklch(0.97 0.025 24);
101
112
 
102
113
  --wave-docs-radius: 0.5rem;
103
114
  --wave-docs-radius-sm: 0.375rem;
104
- --wave-docs-header-height: 4rem;
115
+ /* How far below the top of the scrollport an anchored heading parks —
116
+ * i.e. the height of the host's sticky header. Set it to 0 if there is no
117
+ * such header; nothing else in this sheet reads it. */
118
+ --wave-docs-scroll-padding: 4rem;
105
119
  --wave-docs-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo,
106
120
  Consolas, "Liberation Mono", monospace;
107
121
 
@@ -110,13 +124,16 @@
110
124
  0 0 0 4px var(--wave-docs-accent);
111
125
  }
112
126
 
127
+ /* Only for a host that has explicitly delegated the choice to the OS. */
113
128
  @media (prefers-color-scheme: dark) {
114
- :root:not([data-theme='light']) {
129
+ :root[data-theme='system'] {
130
+ color-scheme: dark;
131
+
115
132
  --wave-docs-bg: oklch(0.19 0.015 265);
116
133
  --wave-docs-bg-subtle: oklch(0.23 0.015 265);
117
134
  --wave-docs-fg: oklch(0.93 0.008 265);
118
135
  --wave-docs-fg-muted: oklch(0.72 0.014 265);
119
- --wave-docs-fg-subtle: oklch(0.64 0.015 265);
136
+ --wave-docs-fg-subtle: oklch(0.665 0.015 265);
120
137
  --wave-docs-border: oklch(0.31 0.014 265);
121
138
  --wave-docs-border-strong: oklch(0.4 0.016 265);
122
139
 
@@ -135,19 +152,28 @@
135
152
  --wave-docs-callout-tip-bg: oklch(0.27 0.05 156);
136
153
  --wave-docs-callout-important: oklch(0.76 0.14 300);
137
154
  --wave-docs-callout-important-bg: oklch(0.28 0.06 300);
138
- --wave-docs-callout-warning: oklch(0.82 0.14 82);
139
- --wave-docs-callout-warning-bg: oklch(0.29 0.05 72);
155
+ --wave-docs-callout-warning: oklch(0.82 0.14 80);
156
+ --wave-docs-callout-warning-bg: oklch(0.29 0.05 80);
140
157
  --wave-docs-callout-caution: oklch(0.72 0.16 24);
141
158
  --wave-docs-callout-caution-bg: oklch(0.28 0.06 24);
142
159
  }
143
160
  }
144
161
 
145
- :root[data-theme='dark'] {
162
+ /* `.dark` as well as `[data-theme='dark']`: next-themes defaults to
163
+ * `attribute="class"`, and that default is what most consumers ship. */
164
+ :root[data-theme='dark'],
165
+ :root.dark {
166
+ color-scheme: dark;
167
+
146
168
  --wave-docs-bg: oklch(0.19 0.015 265);
147
169
  --wave-docs-bg-subtle: oklch(0.23 0.015 265);
148
170
  --wave-docs-fg: oklch(0.93 0.008 265);
149
171
  --wave-docs-fg-muted: oklch(0.72 0.014 265);
150
- --wave-docs-fg-subtle: oklch(0.64 0.015 265);
172
+ /* 0.665, not 0.64: `fg-subtle` paints the list markers and heading
173
+ * anchors, and inside a callout it composes against `callout-*-bg`, where
174
+ * 0.64 measured 4.21-4.47:1. It still clears 4.5:1 on `accent-subtle`,
175
+ * which is the constraint pulling it the other way. */
176
+ --wave-docs-fg-subtle: oklch(0.665 0.015 265);
151
177
  --wave-docs-border: oklch(0.31 0.014 265);
152
178
  --wave-docs-border-strong: oklch(0.4 0.016 265);
153
179
 
@@ -166,33 +192,47 @@
166
192
  --wave-docs-callout-tip-bg: oklch(0.27 0.05 156);
167
193
  --wave-docs-callout-important: oklch(0.76 0.14 300);
168
194
  --wave-docs-callout-important-bg: oklch(0.28 0.06 300);
169
- --wave-docs-callout-warning: oklch(0.82 0.14 82);
170
- --wave-docs-callout-warning-bg: oklch(0.29 0.05 72);
195
+ --wave-docs-callout-warning: oklch(0.82 0.14 80);
196
+ --wave-docs-callout-warning-bg: oklch(0.29 0.05 80);
171
197
  --wave-docs-callout-caution: oklch(0.72 0.16 24);
172
198
  --wave-docs-callout-caution-bg: oklch(0.28 0.06 24);
173
199
  }
174
-
175
- /* ---------------------------------------------------------------------------
176
- * Base
177
- * ------------------------------------------------------------------------ */
178
200
  }
179
201
 
180
- @layer base {
181
- html {
182
- /* Without this, every `#anchor` link parks its heading underneath the
183
- * sticky header. Match it to your header's height. */
184
- scroll-padding-top: var(--wave-docs-header-height);
185
- }
202
+ /* ---------------------------------------------------------------------------
203
+ * Base
204
+ * ------------------------------------------------------------------------ */
186
205
 
187
- /* Smooth scrolling is opt-in by the reader's OS setting, never by us:
188
- * animated scrolling triggers nausea and migraine in people with vestibular
189
- * disorders. Declared here rather than in JavaScript so there is exactly one
190
- * place that decides it. */
191
- @media (prefers-reduced-motion: no-preference) {
192
- html {
193
- scroll-behavior: smooth;
194
- }
195
- }
206
+ @layer base {
207
+ /*
208
+ * The ground the ramp is composed against.
209
+ *
210
+ * A foreground token is only as legible as the background under it, and this
211
+ * sheet cannot see the host's. Painting our own containers — never `body`,
212
+ * which belongs to the host — is what makes every ratio in
213
+ * `src/styles.test.ts` a fact about the shipped page rather than about a
214
+ * background nobody declared.
215
+ */
216
+ .wave-docs-prose,
217
+ .wave-docs-sidebar,
218
+ .wave-docs-toc {
219
+ background: var(--wave-docs-bg);
220
+ color: var(--wave-docs-fg);
221
+ /* Inert unless the container is itself the scrollport, which a sidebar or
222
+ * a TOC pane usually is. Headings carry the matching `scroll-margin-top`
223
+ * for the far commoner case where the document scrolls. */
224
+ scroll-padding-top: var(--wave-docs-scroll-padding);
225
+ }
226
+
227
+ /*
228
+ * No `scroll-behavior: smooth` anywhere, and not for the usual reason.
229
+ * Next 16 only suppresses smooth scrolling across a route change when
230
+ * `<html>` carries `data-scroll-behavior="smooth"` — an attribute only the
231
+ * host can set — so a package-level `html { scroll-behavior: smooth }`
232
+ * animates every navigation and makes dev builds warn about a stylesheet
233
+ * inside `node_modules`. Hosts that want it opt in on their own `<html>`,
234
+ * with that attribute, behind `prefers-reduced-motion: no-preference`.
235
+ */
196
236
  }
197
237
 
198
238
  /* ---------------------------------------------------------------------------
@@ -239,7 +279,14 @@
239
279
  * plain `:focus` for the same reason. */
240
280
  .wave-docs-skip-link:focus {
241
281
  transform: translateY(0);
242
- outline: none;
282
+ /* Transparent rather than `none`, here and at every other ring in this
283
+ * file: forced-colors mode zeroes `box-shadow` and honours `outline`, so
284
+ * `outline: none` would strictly remove the UA indicator and put nothing
285
+ * back. A transparent outline is invisible normally and repainted in a
286
+ * system colour there. The explicit `forced-colors` block at the end of
287
+ * this file is the belt to this pair of braces. */
288
+ outline: 2px solid transparent;
289
+ outline-offset: 2px;
243
290
  box-shadow: var(--wave-docs-ring);
244
291
  }
245
292
 
@@ -259,6 +306,12 @@
259
306
  color: var(--wave-docs-fg);
260
307
  font-size: 1rem;
261
308
  line-height: 1.75;
309
+ /* Inherited by every descendant, which is the point: a sha256 digest in a
310
+ * paragraph, a long word in a heading or a list item each pushed the page
311
+ * to a 553-784px scrollWidth at the 320px viewport WCAG 1.4.10 Reflow
312
+ * tests at. `break-word` and not `word-break: break-all`, which would also
313
+ * chop ordinary prose mid-syllable. */
314
+ overflow-wrap: break-word;
262
315
  /* No `max-width` on purpose — the docs shell owns column width. */
263
316
  }
264
317
 
@@ -272,7 +325,7 @@
272
325
  font-weight: 650;
273
326
  line-height: 1.25;
274
327
  text-wrap: balance;
275
- scroll-margin-top: var(--wave-docs-header-height);
328
+ scroll-margin-top: var(--wave-docs-scroll-padding);
276
329
  }
277
330
 
278
331
  .wave-docs-prose > :is(h2, h3, h4, h5, h6) {
@@ -306,6 +359,12 @@
306
359
  text-underline-offset: 0.2em;
307
360
  text-decoration-thickness: 1px;
308
361
  border-radius: 2px;
362
+ /* `anywhere`, not `break-word`: a link label is often one unbreakable
363
+ * token (`/api/v1/some-long-path`), and `break-word` only breaks a word
364
+ * that would overflow a line of its own. A bare autolinked URL already
365
+ * breaks after `/` under UAX#14 without help — which is why no
366
+ * `word-break` appears anywhere in this file. */
367
+ overflow-wrap: anywhere;
309
368
  }
310
369
 
311
370
  .wave-docs-prose a:hover {
@@ -317,7 +376,8 @@
317
376
  .wave-docs-sidebar :is(a, button):focus-visible,
318
377
  .wave-docs-toc a:focus-visible,
319
378
  .wave-docs-youtube__facade:focus-visible {
320
- outline: none;
379
+ outline: 2px solid transparent;
380
+ outline-offset: 2px;
321
381
  box-shadow: var(--wave-docs-ring);
322
382
  }
323
383
 
@@ -409,7 +469,8 @@
409
469
  }
410
470
 
411
471
  .wave-docs-table-scroll:focus-visible {
412
- outline: none;
472
+ outline: 2px solid transparent;
473
+ outline-offset: 2px;
413
474
  box-shadow: var(--wave-docs-ring);
414
475
  }
415
476
 
@@ -475,50 +536,69 @@
475
536
  /* ---------------------------------------------------------------------------
476
537
  * Shiki
477
538
  *
478
- * Dual-theme promotion. Shiki writes the light colour as an INLINE style on
479
- * every token span and the dark one beside it as `--shiki-dark`, so switching
480
- * themes means overriding an inline style. `!important` is the only thing in
481
- * CSS that outranks one this is a specificity requirement, not a preference,
482
- * and removing it silently leaves dark mode rendering light-theme code.
539
+ * Dual-theme promotion, driven entirely by custom properties. `render.ts`
540
+ * passes `defaultColor: false`, so Shiki writes NO inline `color` at all each
541
+ * token span carries `--shiki-light` and `--shiki-dark` and nothing else, and
542
+ * picking a theme is an ordinary cascade problem solved by an ordinary
543
+ * selector. No `!important` is involved, and none may be reintroduced: it would
544
+ * outrank the consumer's own overrides for no benefit.
545
+ *
546
+ * Scoped to `.wave-docs-prose`, and inside `@layer components` like everything
547
+ * else here. A bare, unlayered `.shiki` beat every layer on any page that also
548
+ * renders Shiki output from another package, silently repainting it.
483
549
  * ------------------------------------------------------------------------ */
484
550
 
485
- .shiki {
486
- padding: 1rem 1.125rem;
487
- border: 1px solid var(--wave-docs-border);
488
- border-radius: var(--wave-docs-radius);
489
- background-color: var(--wave-docs-code-block-bg);
490
- overflow-x: auto;
491
- font-family: var(--wave-docs-font-mono);
492
- font-size: 0.875rem;
493
- line-height: 1.7;
494
- tab-size: 2;
495
- }
551
+ @layer components {
552
+ .wave-docs-prose .shiki {
553
+ padding: 1rem 1.125rem;
554
+ border: 1px solid var(--wave-docs-border);
555
+ border-radius: var(--wave-docs-radius);
556
+ /* Our token, not `--shiki-light-bg`: the code block is part of the page's
557
+ * surface ramp, and a theme's own background is whatever GitHub chose. */
558
+ background-color: var(--wave-docs-code-block-bg);
559
+ overflow-x: auto;
560
+ font-family: var(--wave-docs-font-mono);
561
+ font-size: 0.875rem;
562
+ line-height: 1.7;
563
+ tab-size: 2;
564
+ }
496
565
 
497
- .shiki code {
498
- display: grid;
499
- font-family: inherit;
500
- }
566
+ .wave-docs-prose .shiki,
567
+ .wave-docs-prose .shiki span {
568
+ color: var(--shiki-light);
569
+ }
501
570
 
502
- .shiki .line {
503
- min-height: 1lh;
504
- }
571
+ /* Shiki puts `tabindex="0"` on the `<pre>` so a keyboard reader can scroll a
572
+ * wide block — which makes it the one focusable element in this package that
573
+ * had no focus style. */
574
+ .wave-docs-prose .shiki:focus-visible {
575
+ outline: 2px solid transparent;
576
+ outline-offset: 2px;
577
+ box-shadow: var(--wave-docs-ring);
578
+ }
505
579
 
506
- @media (prefers-color-scheme: dark) {
507
- :root:not([data-theme='light']) .shiki,
508
- :root:not([data-theme='light']) .shiki span {
509
- color: var(--shiki-dark) !important;
580
+ .wave-docs-prose .shiki code {
581
+ display: grid;
582
+ font-family: inherit;
510
583
  }
511
- :root:not([data-theme='light']) .shiki {
512
- background-color: var(--shiki-dark-bg) !important;
584
+
585
+ .wave-docs-prose .shiki .line {
586
+ min-height: 1lh;
513
587
  }
514
- }
515
588
 
516
- :root[data-theme='dark'] .shiki,
517
- :root[data-theme='dark'] .shiki span {
518
- color: var(--shiki-dark) !important;
519
- }
520
- :root[data-theme='dark'] .shiki {
521
- background-color: var(--shiki-dark-bg) !important;
589
+ @media (prefers-color-scheme: dark) {
590
+ :root[data-theme='system'] .wave-docs-prose .shiki,
591
+ :root[data-theme='system'] .wave-docs-prose .shiki span {
592
+ color: var(--shiki-dark);
593
+ }
594
+ }
595
+
596
+ :root[data-theme='dark'] .wave-docs-prose .shiki,
597
+ :root[data-theme='dark'] .wave-docs-prose .shiki span,
598
+ :root.dark .wave-docs-prose .shiki,
599
+ :root.dark .wave-docs-prose .shiki span {
600
+ color: var(--shiki-dark);
601
+ }
522
602
  }
523
603
 
524
604
  /* ---------------------------------------------------------------------------
@@ -851,7 +931,8 @@
851
931
  .wave-docs-search-trigger:focus-visible,
852
932
  .wave-docs-search-close:focus-visible,
853
933
  .wave-docs-search-result-link:focus-visible {
854
- outline: none;
934
+ outline: 2px solid transparent;
935
+ outline-offset: 2px;
855
936
  box-shadow: var(--wave-docs-ring);
856
937
  }
857
938
 
@@ -905,6 +986,22 @@
905
986
  gap: 0.5rem;
906
987
  padding: 0.75rem;
907
988
  border-block-end: 1px solid var(--wave-docs-border);
989
+ /* Matches the dialog's corners, so the focus ring below traces them
990
+ * instead of poking out of the rounded frame. */
991
+ border-start-start-radius: var(--wave-docs-radius);
992
+ border-start-end-radius: var(--wave-docs-radius);
993
+ }
994
+
995
+ /* The ring belongs to the row, not the input. A 2px ring drawn around a
996
+ * borderless full-width input reads as an error state, and the previous
997
+ * answer — "the dialog frame is the indicator" — was a static 1.31:1 border
998
+ * that looks identical focused and unfocused (WCAG 2.4.7 wants a change of
999
+ * appearance, 1.4.11 wants 3:1; `accent` on `bg` is 5.16:1 / 7.50:1).
1000
+ * `outline-offset: -2px` draws it inward, because the dialog clips with
1001
+ * `overflow: hidden` and an outward ring would be sliced off. */
1002
+ .wave-docs-search-input-row:has(.wave-docs-search-input:focus-visible) {
1003
+ outline: 2px solid var(--wave-docs-accent);
1004
+ outline-offset: -2px;
908
1005
  }
909
1006
 
910
1007
  .wave-docs-search-input {
@@ -918,9 +1015,10 @@
918
1015
  font-size: 1rem;
919
1016
  }
920
1017
 
1018
+ /* Not `outline: 2px solid transparent` like every other control here: the
1019
+ * row above already draws a real outline, and a second one on the input
1020
+ * would double up in forced-colors mode. */
921
1021
  .wave-docs-search-input:focus {
922
- /* The dialog frame is the focus indicator here; a ring on a full-width
923
- * borderless input reads as an error state. */
924
1022
  outline: none;
925
1023
  }
926
1024
 
@@ -956,9 +1054,16 @@
956
1054
  }
957
1055
 
958
1056
  /* Driven by `aria-selected` through the active class the dialog sets — the
959
- * combobox keeps focus in the input, so `:focus` never lands here. */
1057
+ * combobox keeps focus in the input and every result is `tabindex="-1"`, so
1058
+ * `:focus-visible` can never fire here and the tint IS the whole indicator.
1059
+ * A tint alone is 1.12:1 light / 1.19:1 dark; WCAG 1.4.11 asks 3:1 of a
1060
+ * state indicator, so the outline carries it (`accent` on `accent-subtle` is
1061
+ * 4.60:1 / 6.30:1) and the tint is left to do the soft work. Inset by 2px so
1062
+ * it stays inside the scrolling results list. */
960
1063
  .wave-docs-search-result-active {
961
1064
  background: var(--wave-docs-accent-subtle);
1065
+ outline: 2px solid var(--wave-docs-accent);
1066
+ outline-offset: -2px;
962
1067
  }
963
1068
 
964
1069
  .wave-docs-search-result-link {
@@ -1031,3 +1136,44 @@
1031
1136
  white-space: nowrap;
1032
1137
  }
1033
1138
  }
1139
+
1140
+ /* ---------------------------------------------------------------------------
1141
+ * Forced colours
1142
+ *
1143
+ * Windows High Contrast and its kin drop `box-shadow` entirely and replace
1144
+ * every colour with one from the user's palette. Every focus indicator in this
1145
+ * package is a `box-shadow` ring, so without this block the package would
1146
+ * *remove* the UA outline from every focusable surface it styles and draw
1147
+ * nothing in its place — a strictly worse page than shipping no CSS at all.
1148
+ *
1149
+ * `Highlight` rather than a token: user-chosen system colours are the whole
1150
+ * point of the mode, and `--wave-docs-accent` is forced to something else
1151
+ * anyway. This list must stay in step with every `:focus-visible` rule above;
1152
+ * `src/styles.test.ts` fails if one appears without its entry here.
1153
+ * ------------------------------------------------------------------------ */
1154
+
1155
+ @layer components {
1156
+ @media (forced-colors: active) {
1157
+ .wave-docs-skip-link:focus,
1158
+ .wave-docs-prose a:focus-visible,
1159
+ .wave-docs-sidebar :is(a, button):focus-visible,
1160
+ .wave-docs-toc a:focus-visible,
1161
+ .wave-docs-youtube__facade:focus-visible,
1162
+ .wave-docs-table-scroll:focus-visible,
1163
+ .wave-docs-prose .shiki:focus-visible,
1164
+ .wave-docs-search-trigger:focus-visible,
1165
+ .wave-docs-search-close:focus-visible,
1166
+ .wave-docs-search-result-link:focus-visible {
1167
+ outline: 3px solid Highlight;
1168
+ outline-offset: 2px;
1169
+ }
1170
+
1171
+ /* Inward, like their normal-mode rules: one is clipped by the dialog, the
1172
+ * other by the scrolling results list. */
1173
+ .wave-docs-search-input-row:has(.wave-docs-search-input:focus-visible),
1174
+ .wave-docs-search-result-active {
1175
+ outline: 2px solid Highlight;
1176
+ outline-offset: -2px;
1177
+ }
1178
+ }
1179
+ }
package/dist/types.d.ts CHANGED
@@ -7,17 +7,24 @@ import { Root } from "hast";
7
7
  * Consumers extend this with their own schema — {@link DocsConfig.frontmatterSchema} —
8
8
  * and the extra fields flow through the generic parameter on {@link DocFile}
9
9
  * and friends rather than widening this interface.
10
+ *
11
+ * The optionals are spelled `?: T | undefined` because a schema written with
12
+ * `.optional()` instead of `.exactOptional()` infers exactly that shape, and
13
+ * under `exactOptionalPropertyTypes` the narrow spelling rejects it — as a
14
+ * nine-line error through Standard Schema's internals at the config, plus a
15
+ * `Property 'audience' does not exist on type 'DocFrontmatter'` at every read
16
+ * site, because the inference then collapses to this default.
10
17
  */
11
18
  interface DocFrontmatter {
12
19
  /** Page title. Used for `<h1>` fallbacks, `<title>`, and search. */
13
20
  title: string;
14
21
  /** One-line summary. Used for `<meta name="description">` and search. */
15
- description?: string;
22
+ description?: string | undefined;
16
23
  /**
17
24
  * Sidebar label, when it should differ from {@link DocFrontmatter.title}.
18
25
  * Sidebars are narrow; page ancestors are not.
19
26
  */
20
- label?: string;
27
+ label?: string | undefined;
21
28
  /**
22
29
  * Excluded from navigation, search and `generateStaticParams`.
23
30
  *
@@ -25,18 +32,18 @@ interface DocFrontmatter {
25
32
  * production builds, so branching on it would hide drafts in exactly the
26
33
  * place reviewers look. Gate on an explicit config flag instead.
27
34
  */
28
- draft?: boolean;
35
+ draft?: boolean | undefined;
29
36
  /**
30
37
  * Previous URLs for this page, relative to the docs base path
31
38
  * (e.g. `['old-name', 'legacy/old-name']`). The Next adapter turns these
32
39
  * into permanent redirects so a rename never becomes a silent 404.
33
40
  */
34
- aliases?: string[];
41
+ aliases?: string[] | undefined;
35
42
  /**
36
43
  * Sort weight within its directory, for directories without a `meta.json`.
37
44
  * Lower sorts first; pages without an order sort last, alphabetically.
38
45
  */
39
- order?: number;
46
+ order?: number | undefined;
40
47
  }
41
48
  /**
42
49
  * A single documentation page discovered on disk.
@@ -75,7 +82,7 @@ interface DocNavPage {
75
82
  interface DocNavGroup {
76
83
  type: 'group';
77
84
  title: string;
78
- href?: string;
85
+ href?: string | undefined;
79
86
  children: DocNavNode[];
80
87
  }
81
88
  /** A non-interactive heading between groups, from `"---Label---"` in meta.json. */
@@ -108,12 +115,12 @@ type DocNavNode = DocNavPage | DocNavGroup | DocNavSeparator | DocNavLink;
108
115
  */
109
116
  interface DocsMeta {
110
117
  /** Directory title, shown as the group heading. Defaults to the dirname. */
111
- title?: string;
118
+ title?: string | undefined;
112
119
  /** Ordered entries. Omit to sort by frontmatter `order`, then alphabetically. */
113
120
  pages?: Array<string | {
114
121
  title: string;
115
122
  href: string;
116
- }>;
123
+ }> | undefined;
117
124
  }
118
125
  /**
119
126
  * A heading captured from the rendered tree.
@@ -244,12 +251,12 @@ href: string, from: DocLinkContext) => string | undefined;
244
251
  */
245
252
  type ImageResolver = (src: string, from: DocLinkContext) => Promise<{
246
253
  src: string;
247
- width?: number;
248
- height?: number;
254
+ width?: number | undefined;
255
+ height?: number | undefined;
249
256
  } | undefined> | {
250
257
  src: string;
251
- width?: number;
252
- height?: number;
258
+ width?: number | undefined;
259
+ height?: number | undefined;
253
260
  } | undefined;
254
261
  /**
255
262
  * How a documentation tree is read.
@@ -267,18 +274,18 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
267
274
  */
268
275
  contentDir: string;
269
276
  /** URL prefix the docs are mounted at. Defaults to `'/docs'`. */
270
- basePath?: string;
277
+ basePath?: string | undefined;
271
278
  /**
272
279
  * Include pages marked `draft: true`. Defaults to `false`.
273
280
  *
274
281
  * Drive this from your own env check — deliberately not `NODE_ENV`.
275
282
  */
276
- includeDrafts?: boolean;
283
+ includeDrafts?: boolean | undefined;
277
284
  /**
278
285
  * Fail the build when an internal link resolves to a page that does not
279
286
  * exist. Defaults to `true`; there is no good reason to turn it off.
280
287
  */
281
- assertLinks?: boolean;
288
+ assertLinks?: boolean | undefined;
282
289
  /**
283
290
  * Validates every page's frontmatter. Defaults to `docFrontmatterSchema`
284
291
  * from `@waveso/docs/frontmatter`.
@@ -301,21 +308,28 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
301
308
  *
302
309
  * Three things are worth knowing before you write one:
303
310
  *
304
- * - **The output must still satisfy {@link DocFrontmatter}.** `title` drives
305
- * the `<h1>` fallback and `<title>`, `draft` the visibility filter,
306
- * `aliases` the redirects, `order`/`label` the sidebar. A schema that
307
- * drops them is a compile error here, not a mystery at render time.
311
+ * - **The package's fields are not yours to drop.** `title` drives the
312
+ * `<h1>` fallback and `<title>`, `draft` the visibility filter, `aliases`
313
+ * the redirects, `order`/`label` the sidebar so all six are re-read from
314
+ * the YAML by `docFrontmatterSchema` and laid back over your output. A
315
+ * bare `z.object({ title, audience })` therefore cannot publish a draft or
316
+ * lose a redirect; it only costs you the six in the inferred type. Nothing
317
+ * in the type system could have caught that: `TFrontmatter extends
318
+ * DocFrontmatter` constrains `title` and nothing else, because the other
319
+ * five are optional. The price is that a `.default()`, `.transform()` or
320
+ * `.coerce` aimed at one of the six is not honoured — the YAML wins.
308
321
  * - **Unknown keys are stripped, by every validator worth using.** The
309
- * parsed frontmatter is exactly what the schema declares, so declare every
310
- * field you intend to read — extending
311
- * `docFrontmatterSchema` is the shortest way to keep the built-ins.
322
+ * parsed frontmatter is exactly what the schema declares plus the six, so
323
+ * declare every field you intend to read — extending
324
+ * `docFrontmatterSchema` is the shortest way to get the built-ins back in
325
+ * the type as well.
312
326
  * - **Identity is load-bearing.** The filesystem scan is memoised per
313
327
  * resolved config, and two schema objects are only "the same schema" when
314
328
  * they are the same object. Export one from a shared module (as above)
315
329
  * rather than building it inline in each route file, or each file pays for
316
330
  * its own scan.
317
331
  */
318
- frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter>;
332
+ frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter> | undefined;
319
333
  }
320
334
  /** {@link DocsConfig} with defaults applied. */
321
335
  interface ResolvedDocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
@@ -324,11 +338,11 @@ interface ResolvedDocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatte
324
338
  includeDrafts: boolean;
325
339
  assertLinks: boolean;
326
340
  /**
327
- * As supplied. Absent never explicitly `undefined`, per
328
- * `exactOptionalPropertyTypes` when the built-in `docFrontmatterSchema`
329
- * applies, so the default lives in one place: `parseFrontmatter`.
341
+ * As supplied. `resolveDocsConfig` omits the key rather than setting it to
342
+ * `undefined` when the built-in `docFrontmatterSchema` applies, so the
343
+ * default lives in exactly one place: `parseFrontmatter`.
330
344
  */
331
- frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter>;
345
+ frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter> | undefined;
332
346
  }
333
347
  //#endregion
334
348
  export { DocFile, DocFrontmatter, DocLinkContext, DocNavGroup, DocNavLink, DocNavNode, DocNavPage, DocNavSeparator, DocsConfig, DocsMeta, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig, SearchRecord, TocEntry };