@waveso/docs 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,660 @@
1
1
  # @waveso/docs
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3551951: **The search results and the sidebar say "there is more this way" the same way
8
+ the table does, and hide their scrollbars where they can.**
9
+
10
+ Four gradients on the search list, exactly as `.wave-docs-table-scroll` does it:
11
+ the two `local` covers are painted in the surface colour and travel _with_ the
12
+ rows, so each sits over its shadow only while that edge is at rest, and the two
13
+ `scroll` shadows are pinned to the box. A grey edge appears on precisely the side
14
+ that has rows off-screen, with no listener, no state and no hydration.
15
+
16
+ ⚠️ IT REPLACED A MASK, WHICH WAS THE WRONG TOOL TWICE OVER. A mask fades content
17
+ to _transparent_, so what showed through was the dialog's own white — a hole
18
+ rather than a shadow. And it cannot be conditional: CSS has no way to ask whether
19
+ there is anything above to scroll to, so the first and last rows were softened
20
+ even at rest.
21
+
22
+ ## The sidebar needs its shadow above the content, not behind it
23
+
24
+ ⚠️ `.wave-docs-sidebar` PAINTS ITS OWN GROUND, AND MUST. It is in the theme's
25
+ opt-in rule — the one that installs a ground wherever it installs a foreground
26
+ ramp — so a consumer mounting the tree alone gets one, and `styles.browser.test`
27
+ requires the shell to be a single surface. An element's background is painted
28
+ _below_ its children, so the four-gradient trick was covered by a child 480px
29
+ tall: measured 255/255 at the top edge with the nav scrolled — declared,
30
+ computed, and invisible.
31
+
32
+ So the navigation's overflow moved to an inner box, `.wave-docs-layout__sidebar-scroll`,
33
+ and the panel keeps only its edges.
34
+
35
+ ⚠️ THAT SPLIT IS THE FIX, AND EVERY VERSION BEFORE IT FOUGHT THE SCROLLER. An
36
+ absolutely positioned child of a scroll container is laid out against that
37
+ container's padding box and _joins its scrollable overflow_ — so nothing placed
38
+ inside a scroller is ever pinned to it. `position: sticky` only clamps, and a
39
+ clamp is still a thing that travels until it catches. With the scrolling on an
40
+ inner element the panel is an ordinary positioned box: `top: 0` and `bottom: 0`
41
+ are its own edges, and the bands have no scroll to have a position within.
42
+
43
+ The timeline is declared on the scroller and `timeline-scope`d up to the panel,
44
+ because a `scroll-timeline` name is visible to the declaring element's
45
+ descendants and these pseudo-elements belong to its parent.
46
+
47
+ ⚠️ AND THE SHADOW BELONGS TO THE PANEL RATHER THAN THE TREE, because
48
+ `.wave-docs-sidebar` paints an opaque ground — it is in the theme's opt-in rule,
49
+ and the shell has to be one surface — so a background on the scroller would be
50
+ painted underneath it. Measured 255/255 when it was.
51
+
52
+ ⚠️ AND THE NAV'S FADE IS A LENGTH, NOT A PERCENTAGE OF THE SCROLL. The table's
53
+ keyframes shape their fade in percentages, which are percentages of the
54
+ container's _total_ scroll range — fine on a table, wrong on a navigation. A nav
55
+ with 1000px of scroll turns the same `2%…8%` into 20px…80px, so the shadow spends
56
+ eighty pixels of scrolling arriving at full strength, which reads exactly like it
57
+ is moving with the content rather than pinned to the edge. `animation-range: 0
58
+ 1rem` makes it the same short distance whatever the nav's height, identical on a
59
+ six-page site and a three-hundred-page one, with plain fades for keyframes
60
+ because the shaping now lives in the range.
61
+
62
+ ⚠️ THE SCROLLBAR IS HIDDEN ONLY WHERE THE SHADOW EXISTS. Both live in the same
63
+ `@supports (animation-timeline: scroll())`, so Firefox — where scroll-driven
64
+ animations have not shipped — keeps the thin bar rather than losing the bar and
65
+ the shadow together. A scrollbar is the one cue that a column has more below it,
66
+ and taking it away before something replaces it trades a slab for nothing.
67
+
68
+ ⚠️ AND A SECOND TEST LEARNED THAT `getAnimations()` IS NOT "TRANSITIONS". A
69
+ scroll-driven animation's `finished` promise resolves when the _scroll position_
70
+ reaches the end, which is to say never on a panel sitting at the top — so
71
+ awaiting the whole list hung `sidebar.browser.test.tsx` for its full 15s timeout
72
+ the moment this shadow existed. `nav.browser.test.tsx` had already been bitten by
73
+ the table's; this is the second place the assumption lived.
74
+
75
+ - 70649b8: **Search results get a glyph, a breadcrumb, and no scrollbar.**
76
+
77
+ **A page or a section, as an icon.** Which one is read off the `href` rather
78
+ than a new index field — a record with an anchor is a section within a page and
79
+ one without is the page itself, which is what `buildSearchIndex` means by its
80
+ lead record — so the records did not grow by a byte.
81
+
82
+ ⚠️ AND THE ICON IS NOT DECORATION, IT IS THE ALIGNMENT. With no icon a result's
83
+ heading sat on the same column as the input's _magnifier_ while the input's own
84
+ text sat 24px right of it: three left edges where a reader expects two, which is
85
+ what made the dialog read as stacked levels rather than as a list under a field.
86
+ The icon takes the magnifier's column and the text takes the input's, so the two
87
+ rows are the same shape. Measured: 18px and 42px, both rows, plus the footer's
88
+ key caps on the 18.
89
+
90
+ **The second line is a breadcrumb.** The segments were always a trail from the
91
+ site's root to the page; slashes made them read as a URL to parse and `›` makes
92
+ them read as what they are. ⚠️ SAFE ONLY BECAUSE THAT LINE IS `aria-hidden` —
93
+ more than one screen reader pronounces the character, which is exactly why
94
+ `spokenName` joins with commas instead. The two lines carry the same fact in the
95
+ form each audience can use.
96
+
97
+ ⚠️ THE SEPARATOR IS THE PACKAGE'S OWN CHEVRON, NOT A CHARACTER, AND TWO
98
+ CHARACTERS WERE MEASURED AND REJECTED FIRST. `›` (U+203A) inks 4.93px tall in
99
+ the shipped mono face at 11px, against 6.32 for an `s` and 8.51 for a `b` — it
100
+ sat visibly below the words it separated. `❯` (U+276F) inks 8.03 and fixed the
101
+ height _on one machine_: `ui-monospace` resolves to SF Mono here, Consolas on
102
+ Windows, Liberation Mono on Linux, and a glyph missing from one of those falls
103
+ back to another face at another width. U+3009 already demonstrated exactly that,
104
+ measuring 11.24px against the 6.62 cell.
105
+
106
+ An SVG has no font to be missing from — and it is the same Lucide chevron the
107
+ sidebar and the pager draw, which is the real argument: everything else here is
108
+ that one set.
109
+
110
+ ⚠️ AND THE TRAIL IS SET IN THE SANS, NOT IN THE MONO IT STARTED IN — WHICH IS
111
+ WHAT MADE THE SEPARATOR RELIABLE. A monospace face advances every glyph one
112
+ cell, so the segments marched on a rigid grid an icon could not join, and its
113
+ metrics are whatever the machine resolves: SF Mono here, Consolas on Windows,
114
+ Liberation Mono on Linux. The cap height an inline glyph is sized against
115
+ therefore moved from reader to reader, which is the same portability problem
116
+ that ruled out `›` and `❯` as characters — arriving a second time through the
117
+ face rather than through the glyph. Proportional sans has ordinary spacing and
118
+ one set of metrics to match. `font-size` goes up a notch with it, because a
119
+ mono face reads larger at the same size and holding the number would have
120
+ shrunk the line.
121
+
122
+ ⚠️ AND THE `viewBox` IS CROPPED TO THE STROKE, WHICH IS WHAT RETIRED FOUR
123
+ CORRECTIONS. On Lucide's full 24 grid the chevron occupies x 9–15 and y 6–18,
124
+ so a square box around it was five sixths air across and half air down — and
125
+ every one of those gaps had to be subtracted back by hand: a `vertical-align`,
126
+ a negative `margin-inline`, a negative `margin-block`, and a size chosen to
127
+ make the surplus come out right. `8 5 8 14` is the painted extent, caps and
128
+ joins included. The box is the glyph, so it sits on the baseline the way a
129
+ letter does with nothing declared, and the air beside it is one positive
130
+ `margin-inline`.
131
+
132
+ ⚠️ AND IT CARRIES `overflow: visible`, WITHOUT WHICH THE POINT IS FLAT. An
133
+ `svg` clips to its viewport by default and this `viewBox` is exactly the
134
+ stroke's extent, so the round join at the tip lands on the box's own edge and
135
+ loses its outermost anti-aliased pixel. Nothing overlaps anything: what spills
136
+ is a fraction of a pixel.
137
+
138
+ ## The scrollbar is hidden, and it cannot be conditional
139
+
140
+ ⚠️ THERE IS NO CSS WAY TO SHOW A SCROLLBAR ONLY WHILE SCROLLING. That behaviour
141
+ is the platform's: macOS draws overlay scrollbars that fade in on scroll and out
142
+ after it, and this list gets it for free there. Windows and Linux draw a classic
143
+ one that is always present, and the only ways to make it come and go are a
144
+ JavaScript timer toggling a class or a scrollbar drawn from scratch in script —
145
+ which is what a `ScrollArea` component is, and neither belongs in a package whose
146
+ whole argument is what it does _not_ ship to a reader.
147
+
148
+ So it is hidden, as `@waveso/app` does with `.scrollbar-none`. What replaces it
149
+ is the keyboard rather than a fade: the footer says `↑ ↓ Select`, the list is
150
+ driven by `aria-activedescendant`, and arrowing past the last visible row scrolls
151
+ it and loads the next page.
152
+
153
+ ⚠️ AND `pageSize` STAYS A WINDOW, NOT A CAP. It was a hard ceiling of 8 once, and
154
+ that was removed with measurements: on a six-page site "docs" matches 18, so
155
+ results were unreachable and the live region announced "8 results" — not a
156
+ smaller truth but a false one. Rendering every row of a 300-page corpus costs
157
+ 40ms, 128ms at 4x throttle, which is what paging exists to avoid; the ceiling
158
+ was never what made it fast.
159
+
160
+ The size budget moves with the glyphs, and the figures the README and the
161
+ installation page publish move with it — a document here may not understate what
162
+ this package costs.
163
+
164
+ - 78053a6: **Nothing in the search dialog is selected until the reader selects it.**
165
+
166
+ Typing lit the first result the instant it arrived, so a row changed under a
167
+ reader for something they had not chosen — and on this package's accent that
168
+ reads as a decision already made. `activeIndex` starts at `-1` now: an arrow key
169
+ or the pointer moving over a row is what selects.
170
+
171
+ ⚠️ AND EVERY GUARD FOR IT ALREADY EXISTED, WHICH IS WHY THIS IS ONE NUMBER.
172
+ `hits[-1]` is `undefined`, so no `aria-activedescendant` is written and Enter
173
+ returns without opening anything. The one place that needed teaching is the
174
+ arrow keys: they wrap modulo the list, and `(-1 - 1 + n) % n` is the _second to
175
+ last_, so Up from a fresh query landed one short of the end.
176
+
177
+ **The active row is the table header's grey**, easing on the same 150ms the
178
+ sidebar's rows use. Moving the pointer over a result activates it, so hovering
179
+ _is_ this state; there is no second rule for it.
180
+
181
+ It has been four things: a 2px accent ring, which read as a component borrowed
182
+ from somewhere else and came and went as a reader arrowed; the trigger's border
183
+ pair, which meant bordering _every_ row to make one edge legible and turned a
184
+ list into a stack of cards; an accent tint, which was the same colour family as
185
+ the field above it. A row is a list item and its state is a colour — the field is
186
+ a control, and that is what wears the trigger's border.
187
+
188
+ ⚠️ `bg-subtle` ON `bg` IS ABOUT 1.02:1, SO THE TINT IS A HINT AND NOT AN
189
+ INDICATOR. The marker going `fg-subtle` to `fg` is the part that carries the
190
+ state — text contrast rather than non-text, and the sidebar's own hover. What
191
+ must not happen is the tint being left to carry it alone.
192
+
193
+ ## The field stops drawing a ring it could never put down
194
+
195
+ ⚠️ A TEXT INPUT MATCHES `:focus-visible` WHENEVER IT IS FOCUSED, HOWEVER FOCUS
196
+ ARRIVED — that is the spec, not a heuristic — and this dialog focuses its input
197
+ the moment it opens. So the accent ring was not a state, it was the field's
198
+ permanent appearance, and it stopped the field looking like the bordered grey
199
+ control the reader clicked to get there.
200
+
201
+ The edge darkens to `--wave-docs-border-strong` instead, which is what the
202
+ trigger does under the pointer, so the field is the trigger in both of its
203
+ states.
204
+
205
+ ⚠️ AND THE INDICATOR IS NOT LOST WITH IT, BECAUSE A TEXT FIELD HAS ONE OF ITS
206
+ OWN. The caret is the platform's focus indication for a text box, it is in this
207
+ field the whole time the dialog is open, and it is what a reader looks for to see
208
+ where typing goes. That is a different argument from the one this rule used to
209
+ reject: "the dialog frame is the indicator" was a _static_ border that looked
210
+ identical focused and unfocused, and indicated nothing. This edge changes.
211
+
212
+ ## The list fades at both edges
213
+
214
+ A mask on the scrollport, so the row being cut off is the one that softens. It
215
+ does not travel with the content: a mask paints against the element's own box, so
216
+ the two stops stay at the top and bottom of the _port_ while the rows move under
217
+ them.
218
+
219
+ ⚠️ IT IS UNCONDITIONAL, WHICH IS THE ONE THING TO KNOW. CSS cannot ask "is there
220
+ anything above this to scroll to" — the table's shadow answers that with a
221
+ scroll-driven animation whose timeline goes inactive when nothing overflows, and
222
+ a mask has no equivalent. So the first and last rows carry a little of it at
223
+ rest. At `1.25rem` against a row nearer three times that, it costs the top pixel
224
+ or two of a heading and buys never guillotining one.
225
+
226
+ ## The field, and the gap under it
227
+
228
+ **The field is the trigger, expanded.** Same border, same fill, same radius. It
229
+ was a border with no fill — two frames a few pixels apart — and briefly a fill
230
+ with no border, which is a tinted band rather than a control. The trigger has
231
+ always been both, and both together are what reads as a field.
232
+
233
+ ⚠️ THE PADDING IS NOT COPIED WITH THEM. The trigger pays `calc(0.5rem - 1px)`
234
+ because it is a compact control in a sidebar; the field pays
235
+ `calc(0.75rem - 1px)` because its glyph has to land on the column the results
236
+ and the footer sit on. The `- 1px` is the border either way — content inside a
237
+ bordered box starts a border further in.
238
+
239
+ The field takes the base radius and the result rows take `-sm`, which is the tier
240
+ system doing what it says: both are controls, and a result row is a list item.
241
+
242
+ ⚠️ AND THE GAP UNDER THE FIELD WAS PAID TWICE. The field's margin and the result
243
+ list's top padding both contributed, so the space between the input and the first
244
+ result was double the space above the input — a doubled gap in the one place a
245
+ reader's eye travels on every keystroke. The list drops its top padding; the
246
+ field keeps its margin, because an empty result list is `display: none` and a gap
247
+ paid from there would vanish on the query that matches nothing.
248
+
249
+ ### Patch Changes
250
+
251
+ - 688a84c: **The search dialog lines up on one column, and its input is a field rather
252
+ than a band.**
253
+
254
+ The input row was flush to the dialog's frame, so it could only round the two
255
+ corners it shared with it and needed a rule underneath to separate it from the
256
+ results. Inset by the same margin the results list uses, it is a box like they
257
+ are — all four corners rounded, and the gap does the separating.
258
+
259
+ ⚠️ AND ITS RADIUS IS THE DIALOG'S MINUS THE GAP, NOT THE DIALOG'S. A rounded box
260
+ inset inside a rounded box is concentric only at `outer - gap`; equal radii run
261
+ the two corners at different curvatures six pixels apart and the field reads as
262
+ pasted onto the dialog rather than set into it.
263
+
264
+ **One column, measured from the bottom up.** The footer's key caps are the
265
+ anchor — the one row whose left edge is a drawn object — and the results' text
266
+ and the input's magnifier are measured to it. All three now start 18px from the
267
+ dialog's inner edge.
268
+
269
+ ⚠️ BOXES, NOT INK. A cap's arrow sits its own border and `0.4em` of padding
270
+ inside the cap, so aligning the _glyphs_ would put every other row on a column
271
+ that moves whenever the footer's font size does.
272
+
273
+ ⚠️ AND THE INPUT ROW PAYS `calc(0.75rem - 1px)` BECAUSE IT IS THE ONE ROW WITH A
274
+ BORDER. Content inside a bordered box starts a border further in than content
275
+ inside an unbordered one, so equal padding misses by exactly that — the same
276
+ subtraction `--wave-docs-panel-inset` exists for.
277
+
278
+ ## 0.11.0
279
+
280
+ ### Minor Changes
281
+
282
+ - 5730af3: **The code frame wears the panel.** A fence is now an outer card holding a
283
+ header row — the filename or the language on the left, the copy button on the
284
+ right — and an inset surface with the code on it, dressed by the same
285
+ `.wave-docs-panel` rules as "where to go next".
286
+
287
+ ⚠️ THE `<figcaption>` STAYS A DIRECT CHILD OF THE `<figure>`, WHICH IS WHY THIS
288
+ HAS NO HEADER WRAPPER. A `figcaption` has to be the first or last child of its
289
+ figure; inside the `.wave-docs-panel__header` `<div>` that "where to go next"
290
+ uses it captions nothing, the markup is invalid, and a titled block loses the
291
+ accessible name it had. So the frame lays its header out on a grid and shares
292
+ the primitive's _insets_ rather than its header element. The button stays out of
293
+ the caption for the matching reason: a `<button>` inside a `<figcaption>`
294
+ contributes its accessible name to the figure's, so `swap.ts` would announce as
295
+ "swap.ts Copy code from swap.ts".
296
+
297
+ ⚠️ AND A TITLE IS WHAT DECIDES WHETHER THERE IS A FRAME AT ALL. With one, the
298
+ figure is a panel: a band carrying the filename and the copy button, and the
299
+ code set into a card below it. With none there is nothing to put in a band, so
300
+ the frame flattens away, the surface becomes the block, and the button sits on
301
+ the code — which is what Mintlify does, and what stops an unnamed fence from
302
+ carrying a reserved slot with nothing in it.
303
+
304
+ A language is not a title for this purpose. A fence declaring `ts` and no
305
+ filename is still an untitled fence, and a band holding a two-letter badge is
306
+ the same empty header with a word in it. `data-lang` stays on the figure for
307
+ anyone selecting on it.
308
+
309
+ One shape of markup, switched in the stylesheet rather than in the pipeline: two
310
+ markup paths mean two fixtures, and the one that is not on screen is the one
311
+ that rots.
312
+
313
+ **The copy button no longer hides until you hover.** It faded in on `:hover` or
314
+ `:focus-within` because it was positioned over the code and had nowhere of its
315
+ own to be. It has a slot in the header row now, and a reserved slot that stays
316
+ empty until you point at it reads as a rendering fault — so the reveal, the
317
+ `@media (hover: none)` exception that existed because a hover-only control does
318
+ not exist on a phone, and the reduced-motion guard on its transition all went
319
+ with it. It is still `visibility: hidden` until the runtime attaches, which is
320
+ the structural promise and is unchanged.
321
+
322
+ ## Three things the restructure moved, each of which was a defect
323
+
324
+ ⚠️ THE `<pre>` DRAWS NO FRAME OF ITS OWN ANY MORE. It carried the border, the
325
+ radius and the background; inside a `.wave-docs-panel__body` that carries all
326
+ three, that draws the frame twice one pixel apart. `pre:not(.shiki)` keeps its
327
+ own, because an excluded fence is never wrapped.
328
+
329
+ ⚠️ AND ITS INLINE PADDING IS `1rem` BECAUSE THAT IS `--wave-docs-panel-inset`,
330
+ not because it is a round number. The label sits at that inset plus the
331
+ surface's border; the code sits at the surface's border plus this. The
332
+ `1.125rem` it was put the first character 2px right of the filename above it —
333
+ visible, and attributable to nothing.
334
+
335
+ ⚠️ AND THE FOCUS RING ON THE `<pre>` IS INSET NOW. Shiki gives it
336
+ `tabindex="0"` so a keyboard reader can scroll a wide block, and the surface
337
+ around it is `overflow: hidden` so a square corner cannot poke through the
338
+ frame's rounded one — which clipped a `+2px` outline away to nothing.
339
+ `styles.test.ts` reads rules as text and would have gone on passing.
340
+
341
+ ## The panel grew two properties, both to settle a cascade rather than a taste
342
+
343
+ `--wave-docs-panel-surface` is the inset surface's ground, and its default lives
344
+ in a `var()` fallback rather than in a declaration. `.wave-docs-code__body` and
345
+ `.wave-docs-panel__body` are both one class, so source order decides and the
346
+ panel is declared later: a code frame asking for the darker code ground got the
347
+ panel's white. Moving the ground to a property did not fix it either — a frame
348
+ wears `.wave-docs-panel` _and_ `.wave-docs-code`, so both rules set that property
349
+ on the same element at the same specificity, and source order handed it back.
350
+ Measured twice as `oklch(1 0 0)` where `oklch(0.975 0.003 262)` was written. In
351
+ the fallback there is no declaration to lose to.
352
+
353
+ `--wave-docs-panel-header-row` floors the header. With a label the row is the
354
+ label's height and with none it is the button's, and a page mixing titled and
355
+ untitled fences showed two header heights.
356
+
357
+ The frame's markup now has one home — `codeFrameMarkup` — that the browser tier
358
+ mounts and the plugin tier asserts the pipeline agrees with. Written out
359
+ separately in both, a hand-written fixture goes on describing a frame the
360
+ pipeline stopped emitting while every assertion measuring it stays green.
361
+
362
+ - 793d0f7: **"Where to go next": a question per row, and the page that answers it.** A page
363
+ opts in from its frontmatter:
364
+
365
+ ```yaml
366
+ explore:
367
+ - question: How a person is recognised across servers
368
+ href: ./identity.md
369
+ - question: What happens when the network fails
370
+ href: ./delivery.md
371
+ ```
372
+
373
+ A sidebar is a structure; this is a router. It says _why_ a reader would go
374
+ somewhere, which no tree of titles can — so the same component is a landing
375
+ page's onboarding and an ordinary page's footnote.
376
+
377
+ ⚠️ THE LINK TEXT COMES FROM THE NAVIGATION. An `href` pointing at a page in the
378
+ tree takes that page's own name, so renaming it updates every block that points
379
+ at it — the same tree the sidebar and the pager read. Name a `title` only where
380
+ the tree cannot answer: an external link, or a page kept out of the navigation.
381
+
382
+ ⚠️ AND AN HREF THAT RESOLVES TO NEITHER STOPS THE BUILD. Falling back to the
383
+ href renders a URL where a sentence should be — "Where this runs and what that
384
+ buys → /docs/infrastructure" — on a page that builds cleanly, and nothing else
385
+ in the pipeline would notice. The frontmatter is authored and the author is
386
+ right there, so the error names the page and both fixes.
387
+
388
+ ⚠️ A LIST, NOT A TABLE, WHICH IS WHAT THE MARKDOWN IT REPLACES HAD TO BE. A
389
+ screen reader announces "table, 2 columns, 7 rows" for what is a list of links
390
+ with descriptions, and asks the reader to navigate it by cell. Two columns of
391
+ sentence-length questions are also cramped in a narrow box, where the rows stack
392
+ instead — a `@container` query, because a host can hand this a 500px panel on a
393
+ 1920px monitor.
394
+
395
+ One frame with hairlines between the rows rather than a stack of cards: they are
396
+ the same question asked several ways, and separate boxes say several unrelated
397
+ things. The rule is a _top_ border on every row but the first — `:last-child`
398
+ leaves a doubled line the moment anything is appended to the list.
399
+
400
+ ## And the frame is a primitive, not this component's furniture
401
+
402
+ `.wave-docs-panel` is a framed block with a header and an inset surface — the
403
+ outer card names the thing and carries its controls, the inner one holds the
404
+ content. "Where to go next" is the first thing to wear it; a code frame is the
405
+ obvious next, and two copies of the same three rules is how they drift apart.
406
+
407
+ ⚠️ THE TWO RADII ARE NOT INDEPENDENT NUMBERS. A rounded box inside a rounded box
408
+ only looks right when the inner radius is the outer one minus the gap between
409
+ them; anything else runs the corners at different curvatures and the inner box
410
+ reads as _pasted onto_ the frame rather than set into it. The new
411
+ `--wave-docs-radius-lg` is chosen so the arithmetic lands on an existing token:
412
+ `1rem` outer minus `0.5rem` of padding is `--wave-docs-radius`.
413
+
414
+ The header sits in the frame's padding and draws no rule of its own — the inset
415
+ surface below already draws the line, and a border there is a second one a pixel
416
+ away from the first.
417
+
418
+ ⚠️ AND THE PANEL EXPORTS `--wave-docs-panel-inset`, WHICH IS NOT THE SAME NUMBER
419
+ AS ITS PADDING. The title sits at the frame's padding; anything inside the body
420
+ sits at that padding _plus the body's own border_, so the two columns miss each
421
+ other by a pixel per border. Measured before it existed: the rows started 9px
422
+ right of the heading above them — a number that appears in no rule and reads as
423
+ a design decision. Exported rather than repeated, because the next component to
424
+ wear the panel has to make the same subtraction and will not think to.
425
+
426
+ It renders above the pager. The two answer different questions and both belong
427
+ there: this is the semantic answer, the pager the linear one.
428
+
429
+ A real `<h2>` names it, not an `aria-label` — a reader moving by heading would
430
+ pass straight over a region named only by an attribute.
431
+
432
+ ## The key is `explore`, and neither `next` nor `steps` would do
433
+
434
+ `next` is unusable in this package. `src/next.ts` is the Next.js adapter, so
435
+ two files one directory apart would carry the same name for entirely different
436
+ things, and `doc.frontmatter.next` would read like a routing hook rather than
437
+ a block of prose.
438
+
439
+ `steps` is wrong for a different reason: these rows are a _branch_, not a
440
+ sequence. A reader picks one and ignores the rest, and none of them is first.
441
+ A numbered "1 -> 2 -> 3" component is a real and separate thing worth building
442
+ later, and `steps` is the name it will need.
443
+
444
+ The rendered heading is still "Where to go next" — `next` in prose is fine,
445
+ it is only the identifier that had to move.
446
+
447
+ No client JavaScript. New: `DocsExplore` at `@waveso/docs/react/explore`,
448
+ `explore` in frontmatter, and `explore` in `labels`.
449
+
450
+ - c871d2f: **One radius scale, taken from `@waveso/ui`, retunable from a single line.**
451
+
452
+ Three tiers, all `calc()` off one root, so which corner a box gets is decided by
453
+ what _kind_ of box it is rather than by how big it happens to be:
454
+
455
+ | Token | What takes it |
456
+ | ----------------------- | ----------------------------------------------------------- |
457
+ | `--wave-docs-radius-sm` | inline chips, small controls, focus rings on those |
458
+ | `--wave-docs-radius` | controls, overlays, and the panel's inset surface |
459
+ | `--wave-docs-radius-lg` | every block in the reading flow, and the panel's outer edge |
460
+
461
+ ⚠️ THE BLOCK TIER WAS SPLIT. Callouts, images and video embeds sat at the base
462
+ radius while a code frame and a table sat at 19px, so two blocks a paragraph
463
+ apart disagreed by eleven pixels — and a table read as aggressively round next
464
+ to the callout above it. 19 was measured off a reference site, which is a fine
465
+ way to pick a number and a bad way to pick a _system_.
466
+
467
+ ⚠️ AND THE NUMBERS ARE `@waveso/ui`'s, NOT NEW ONES. A page running both this
468
+ package and the component library should not show two radius scales a few pixels
469
+ apart, and taking theirs is how that is guaranteed rather than kept in step by
470
+ hand. `--wave-docs-radius-base` is the override point: a host writes
471
+ `--wave-docs-radius-base: var(--radius)` and every corner here follows their app,
472
+ including any theme that moves it. Overriding three tokens separately would be
473
+ three chances to break the arithmetic below.
474
+
475
+ ⚠️ `--wave-docs-radius-step` IS LOAD-BEARING. The panel's inset surface takes the
476
+ base radius and its frame takes `-lg`, which is the base plus one step — so the
477
+ two corners are concentric only while the frame's _padding_ is that same step.
478
+ It is paid out of the token rather than written as `4px`, so moving the root
479
+ keeps both true. `--wave-docs-radius-panel` is gone with it: a token whose only
480
+ job is to be another token minus a constant is a number that can drift from its
481
+ own definition.
482
+
483
+ ## Squircles, where the browser has them
484
+
485
+ `corner-shape` renders every `border-radius` as a continuous superellipse rather
486
+ than a circular arc. `@waveso/ui` ships it and this now matches, including the
487
+ root bump both make under the same `@supports` — a squircle reads tighter at the
488
+ same radius, so the scale moves up to restore the roundness the numbers were
489
+ chosen for. Only the root moves; every tier and the panel's padding follow.
490
+
491
+ ⚠️ SCOPED TO ELEMENTS THIS PACKAGE OWNS, AND NOT `*`. `@waveso/ui` can say `*`
492
+ because it is the application's own stylesheet. This one is mounted inside
493
+ somebody else's page, and a bare `*` would reshape every corner the host drew —
494
+ the same trespass as claiming `html` or `body`, which this file already refuses.
495
+ Pills and dots opt back out, because a squircled pill is a lozenge.
496
+
497
+ ⚠️ AND THE SOURCE TEST FOR THE CONCENTRIC ARITHMETIC WENT VACUOUS ON THE WAY.
498
+ It regexed three `rem` literals out of the token block; with the tiers as
499
+ `calc()` the regex matched nothing, both sides defaulted to zero, and `0 - 0`
500
+ passed while asserting nothing. It is measured in computed pixels now, which is
501
+ also the only tier that can see the squircle bump. Two browser assertions that
502
+ built an expected radius out of `getPropertyValue` were the same mistake in a
503
+ different shape — a custom property is not computed to pixels, so it hands back
504
+ the `calc()` as written. They compare two computed corners to each other now,
505
+ which is the uniformity claim anyway.
506
+
507
+ ### Patch Changes
508
+
509
+ - 04b7de7: **A blockquote is a box, not a rule down one edge.**
510
+
511
+ Every other block set apart from the prose here — a callout, a code frame, a
512
+ table, an embed — is a bordered box. A single 3px edge made the quote the one
513
+ exception, so next to a callout two paragraphs away it read as a _different kind
514
+ of thing_ rather than as a quieter one.
515
+
516
+ It is the callout's box now, minus the hue: the same padding so their text lines
517
+ up, the same block radius, a plain border instead of a tinted one, and no accent
518
+ edge. That is the relationship — **a callout is a quote with a colour** — and it
519
+ finally looks like it.
520
+
521
+ Still not italic. The box and the muted colour already say "quotation", a long
522
+ italic passage is measurably slower to read, and markdown authors use
523
+ blockquotes for asides and notes rather than only for speech.
524
+
525
+ ⚠️ AND IT HAD TO JOIN THE SQUIRCLE LIST BY NAME. Corner shaping is scoped to
526
+ elements this package owns, matched by our own class prefix — and a `blockquote`
527
+ is the markdown author's tag, with no class of ours on it. It would have been
528
+ the one block on the page still drawing a circular arc.
529
+
530
+ - 975acba: **A callout draws one uniform border, not a 3px rule down its inline start.**
531
+
532
+ The stripe made a callout the exception among blocks set apart from the prose —
533
+ a code frame, a table, an embed and now a blockquote are all boxes with one
534
+ border all the way round — so beside any of them it read as a different kind of
535
+ thing rather than as a coloured one. A thick rule on one side also fights the
536
+ corner it runs into once the box is a squircle.
537
+
538
+ ⚠️ THE TYPE IS STILL NOT CONVEYED BY COLOUR ALONE. The stripe was never what
539
+ carried it: the icon and the label — "Note", "Warning" — are the non-colour
540
+ signals, and they are unchanged. The tinted border and ground stay as
541
+ reinforcement rather than as the whole message.
542
+
543
+ The table-of-contents rail keeps its 2px inline-start border. That is a
544
+ continuous line an active marker slides along, which is a navigation affordance
545
+ rather than the edge of a box, and a source guard names the two blocks it does
546
+ apply to.
547
+
548
+ - cd21631: **The copy button draws Lucide icons, like everything else here.**
549
+
550
+ It rendered `⧉`, and swapped in `✓` and `×` through CSS `content` — three
551
+ characters drawn by whatever font resolved, at whatever weight and baseline
552
+ that font has, beside a sidebar, a pager, a callout and a search dialog that
553
+ are all Lucide paths at `stroke-width: 2`. It read as a different icon set
554
+ because it was one. Now `copy`, `check` and `x` on the same 24×24 grid.
555
+
556
+ ⚠️ THE SWAP IS `display` AND NOT `visibility`, AND THAT IS NOT A PREFERENCE.
557
+ All three icons ship in the markup and the stylesheet picks one — there is no
558
+ component owning this button, so there is no state to re-render. But the button
559
+ is `visibility: hidden` until the runtime attaches, which is what keeps a reader
560
+ with no JavaScript from meeting a control that does nothing and keeps it out of
561
+ the tab order — and `visibility` inherits. An icon rule setting it back to
562
+ `visible` would draw a glyph inside a button that is meant to be invisible.
563
+ `display` does not inherit, so the button's own rule still governs all three.
564
+
565
+ Three icons per fence is fifty on a page with fifty of them, and the repeat is
566
+ why that is affordable: byte-identical every time, which is the case gzip
567
+ handles best. The quick start's gzipped payload did not move, and the
568
+ hast-over-the-wire ratio for code and tables went from 1.11× to 1.09×.
569
+
570
+ ## And it stops drawing a box around itself
571
+
572
+ No border and no ground, in any state. A bordered, filled 2rem box sitting on
573
+ the frame's own band is a third framed rectangle inside a frame that already has
574
+ two, for a control secondary to everything around it. The glyph is the whole
575
+ control, and hover moves its ink to the accent rather than putting a box behind
576
+ it — the same signal every other interactive surface here gives. A ground also
577
+ had nowhere to come from: the subtle ramp _is_ the frame's colour, so the hover
578
+ state it used to have was the colour the button was already sitting on.
579
+
580
+ ⚠️ AND IT HAD NO FOCUS INDICATOR AT ALL, WHICH THE BORDER WAS COVERING FOR. A
581
+ 1px box is not a focus indicator — it is there whether the control is focused or
582
+ not — so a keyboard reader tabbing onto this button got a `color` change and
583
+ nothing else, and it never appeared in the package's own inventory of focusable
584
+ surfaces. Taking the border away made that visible; it did not create it. There
585
+ is a real `:focus-visible` outline now, and a test that keeps it.
586
+
587
+ ⚠️ AND ONE TEST WAS PASSING BY ACCIDENT. `render.test.ts` asserted that a
588
+ GitHub alert produces no octicons by checking the _whole document_ for `<svg>`,
589
+ which was only ever true because nothing else in the pipeline emitted one. It
590
+ now checks the callout's own subtree, which is what it meant.
591
+
592
+ - d7333cb: **The site's pages stop hand-writing their own "Next" link.** Nineteen of them
593
+ ended with `Next: [Page](./page.md).`, which the pager has rendered from the
594
+ navigation tree since it landed — the same link, twice, a few hundred pixels
595
+ apart, and the hand-written one silently wrong the moment a page moved in the
596
+ tree.
597
+
598
+ Documentation-only: no package change.
599
+
600
+ - 6f0afb7: **A panel title is the size of a column header.**
601
+
602
+ "Where to go next" and `Enforced by` are the same kind of thing — the label a
603
+ reader's eye lands on before the content under it — and a page shows both. At
604
+ `0.875rem` against the table's `0.9375rem` they read as two levels rather than
605
+ as one.
606
+
607
+ The weight does not follow: a panel title names the block, a column header names
608
+ a column inside one, so the title stays a step heavier. A source guard pins the
609
+ pair, because two literals that have to agree are two literals that drift.
610
+
611
+ - 8c59155: The navigation stops being lopsided.
612
+
613
+ Its rows sat 16px from the inline start and 31px from the end. The 15px was
614
+ `scrollbar-gutter: stable`, which reserves the scrollbar's width whether or not
615
+ it is showing — and that reservation sits _inside_ the padding.
616
+
617
+ The jump it prevents is one 15px shift, the first time a nav grows past a
618
+ screen. What it cost was permanent and on every page.
619
+
620
+ ⚠️ AND IT IS A NO-OP ON macOS, WHICH IS WHY IT SURVIVED BEING LOOKED AT.
621
+ `scrollbar-gutter` does nothing where overlay scrollbars are the default, so the
622
+ asymmetry was invisible to everyone who built this and plain on every Windows
623
+ and Linux machine that opened it.
624
+
625
+ `scrollbar-width: thin` in its place, with `scrollbar-color`, so the scrollbar
626
+ that does appear is narrow and coloured rather than a UA slab against the
627
+ panel's edge. A browser test measures both insets and fails at 15px.
628
+
629
+ - e94bb67: **A table keeps one frame, at the panel's radius.**
630
+
631
+ It wore `.wave-docs-panel` for a while — outer frame, header band, inset card —
632
+ and that was wrong for a reason worth writing down rather than just undoing.
633
+
634
+ ⚠️ THE PANEL SEPARATES _CHROME_ FROM _CONTENT_, AND A TABLE'S HEADER ROW IS
635
+ CONTENT. "Where to go next" and a code frame both have chrome to put in the
636
+ band: a title, a language, a copy button. A table's `<thead>` is data. Setting
637
+ the body into a card away from its own header cost three vertical rules down
638
+ each side, stopped the row dividers short of the box and narrowed the reading
639
+ width — on the densest element on a page, for nothing gained. Full-width
640
+ dividers are what let an eye track a row across.
641
+
642
+ What is kept is the outer radius: `--wave-docs-radius-lg` rather than
643
+ `--wave-docs-radius`, so a table and a code block read as two of one family
644
+ without the table pretending to chrome it has not got.
645
+
646
+ ⚠️ AND AN EMPTY HEADER ROW NOW DRAWS NO BAND, WHICH GFM PRODUCES ROUTINELY. A
647
+ GFM table always has a `<thead>` — the delimiter row is what makes it a table —
648
+ so an author who wants a plain two-column list of facts writes `| | |` and gets
649
+ a header of empty `<th>`s, which rendered as a tinted strip with nothing in it.
650
+ `:empty` and not a text check: GFM emits `<th></th>` with no whitespace inside.
651
+ A header with even one named column keeps its band.
652
+
653
+ **And a fence with neither a title nor a language gets no band either.** The
654
+ header row is floored so titled and untitled fences match, which is right when
655
+ there is a label and wrong when there is not: a bare fence had only the copy
656
+ button, adrift in a strip of empty ground. The row is the button now.
657
+
3
658
  ## 0.10.0
4
659
 
5
660
  ### Minor Changes