@charcuterie/ui 3.23.0 → 3.24.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 (62) hide show
  1. package/dist/Board/Board.d.ts +6 -3
  2. package/dist/Board/Board.d.ts.map +1 -1
  3. package/dist/Board/Board.js +19 -1
  4. package/dist/Board/Board.js.map +1 -1
  5. package/dist/Board/BoardCard.d.ts +42 -7
  6. package/dist/Board/BoardCard.d.ts.map +1 -1
  7. package/dist/Board/BoardCard.js +54 -8
  8. package/dist/Board/BoardCard.js.map +1 -1
  9. package/dist/Nav/Nav.d.ts +167 -0
  10. package/dist/Nav/Nav.d.ts.map +1 -0
  11. package/dist/Nav/Nav.js +165 -0
  12. package/dist/Nav/Nav.js.map +1 -0
  13. package/dist/Nav/NavLink.d.ts +43 -0
  14. package/dist/Nav/NavLink.d.ts.map +1 -0
  15. package/dist/Nav/NavLink.js +111 -0
  16. package/dist/Nav/NavLink.js.map +1 -0
  17. package/dist/Nav/navItems.d.ts +67 -0
  18. package/dist/Nav/navItems.d.ts.map +1 -0
  19. package/dist/Nav/navItems.js +104 -0
  20. package/dist/Nav/navItems.js.map +1 -0
  21. package/dist/Nav/useNavLayout.d.ts +86 -0
  22. package/dist/Nav/useNavLayout.d.ts.map +1 -0
  23. package/dist/Nav/useNavLayout.js +114 -0
  24. package/dist/Nav/useNavLayout.js.map +1 -0
  25. package/dist/NavBar/NavBar.d.ts +37 -111
  26. package/dist/NavBar/NavBar.d.ts.map +1 -1
  27. package/dist/NavBar/NavBar.js +30 -110
  28. package/dist/NavBar/NavBar.js.map +1 -1
  29. package/dist/index.d.ts +6 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -1
  32. package/dist/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/Board/Board.mdx +25 -2
  35. package/src/Board/Board.stories.tsx +52 -1
  36. package/src/Board/Board.test.tsx +82 -0
  37. package/src/Board/Board.tsx +24 -4
  38. package/src/Board/BoardCard.tsx +103 -21
  39. package/src/Board/boardMoveHandleContainer.test.ts +54 -0
  40. package/src/Nav/Nav.mdx +169 -0
  41. package/src/Nav/Nav.stories.tsx +402 -0
  42. package/src/Nav/Nav.test.tsx +338 -0
  43. package/src/Nav/Nav.tsx +406 -0
  44. package/src/Nav/NavLink.tsx +220 -0
  45. package/src/Nav/navItems.test.ts +163 -0
  46. package/src/Nav/navItems.ts +175 -0
  47. package/src/Nav/useNavLayout.ts +197 -0
  48. package/src/NavBar/NavBar.mdx +26 -0
  49. package/src/NavBar/NavBar.tsx +54 -240
  50. package/src/index.ts +20 -1
  51. package/src/sourceRules.test.ts +13 -1
  52. package/dist/NavBar/NavBarLink.d.ts +0 -43
  53. package/dist/NavBar/NavBarLink.d.ts.map +0 -1
  54. package/dist/NavBar/NavBarLink.js +0 -67
  55. package/dist/NavBar/NavBarLink.js.map +0 -1
  56. package/dist/NavBar/navBarCurrent.d.ts +0 -37
  57. package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
  58. package/dist/NavBar/navBarCurrent.js +0 -48
  59. package/dist/NavBar/navBarCurrent.js.map +0 -1
  60. package/src/NavBar/NavBarLink.tsx +0 -139
  61. package/src/NavBar/navBarCurrent.test.ts +0 -81
  62. package/src/NavBar/navBarCurrent.ts +0 -64
@@ -20,6 +20,7 @@ const {
20
20
  Interactive,
21
21
  MarkdownTitles,
22
22
  LinkedLaneHeadings,
23
+ MoveHandleByWidth,
23
24
  Responsive,
24
25
  } = composeStories(stories)
25
26
 
@@ -467,3 +468,84 @@ test("draws a project's colour on the card's own edge", async () => {
467
468
  }),
468
469
  )
469
470
  })
471
+
472
+ /**
473
+ * The handle wears the gesture that can succeed, and nothing else.
474
+ *
475
+ * This is the bug QueuePilot shipped twice, from both directions. A
476
+ * `≡` grip in a one-lane board taught a drag with nowhere to land —
477
+ * *"There's no right-click or anything. How do I move these?"* — and
478
+ * taking the grip away at every width broke the wide board, where
479
+ * dragging is exactly what a person reaches for.
480
+ *
481
+ * Both affordances are in the DOM at both widths and CSS decides,
482
+ * so this asserts on what is PAINTED. A container query is the only
483
+ * honest way to ask: the two boards below sit in a 72rem frame and a
484
+ * 24rem frame inside one window that never moves.
485
+ */
486
+ test("a wide board's handle is the app's grip, not the word", async () => {
487
+ const { canvas } = await mountStory(MoveHandleByWidth)
488
+
489
+ const handle = within(
490
+ canvas.getByRole("region", {
491
+ name: "Today, wide enough to drag",
492
+ }),
493
+ ).getByRole("button", {
494
+ name: "Move Retire the second scheduler and fold its jobs into the broker, currently in Todo",
495
+ })
496
+
497
+ await expect(
498
+ within(handle).getByText("Move"),
499
+ ).not.toBeVisible()
500
+
501
+ await expect(handle.querySelector("svg")).toBeVisible()
502
+
503
+ // And it is still the SQUARE icon button it was before the word
504
+ // joined it in the DOM. `sizing` is a prop and cannot be two
505
+ // values, so the button is `control`-sized underneath and takes
506
+ // `ICON_CONTROL_SIZE_CLASS`'s width and `px-0` back at this width.
507
+ // Getting that wrong costs every card in every lane ~20px of
508
+ // title, silently — this board renders pixel-identically to the
509
+ // one before the change, and that is the assertion.
510
+ await expect(handle.offsetWidth).toBe(handle.offsetHeight)
511
+ })
512
+
513
+ test("a one-lane board's handle says Move, whatever the app passed", async () => {
514
+ const { canvas } = await mountStory(MoveHandleByWidth)
515
+
516
+ const handle = within(
517
+ canvas.getByRole("region", {
518
+ name: "Today, one lane at a time",
519
+ }),
520
+ ).getByRole("button", {
521
+ name: "Move Retire the second scheduler and fold its jobs into the broker, currently in Todo",
522
+ })
523
+
524
+ await expect(
525
+ within(handle).getByText("Move"),
526
+ ).toBeVisible()
527
+
528
+ await expect(
529
+ handle.querySelector("svg"),
530
+ ).not.toBeVisible()
531
+ })
532
+
533
+ /**
534
+ * The name is the same sentence at both widths, and that is the
535
+ * point of putting the whole of it in a `VisuallyHidden` while both
536
+ * visible affordances are `aria-hidden`.
537
+ *
538
+ * Left to the visible text, the handle would be called "Move X"
539
+ * wide and "Move Move X" narrow — one control with two names, one
540
+ * `getByRole` query that works in one layout and not the other, and
541
+ * a screen-reader user hearing the word twice.
542
+ */
543
+ test("the handle has one name at both widths", async () => {
544
+ const { canvas } = await mountStory(MoveHandleByWidth)
545
+
546
+ await expect(
547
+ canvas.getAllByRole("button", {
548
+ name: "Move Retire the second scheduler and fold its jobs into the broker, currently in Todo",
549
+ }),
550
+ ).toHaveLength(2)
551
+ })
@@ -44,12 +44,15 @@ export type BoardProps = {
44
44
  label: string
45
45
  lanes: readonly BoardLane[]
46
46
  /**
47
- * What the move handle shows instead of the word "Move". The
48
- * library ships no icons and no symbol glyphs in a default —
47
+ * What the move handle shows **while the lanes are side by side**.
48
+ * Below `cq-lg` the handle says "Move" whatever this is, because
49
+ * there is nowhere to drag to — see `BoardCard`.
50
+ *
51
+ * The library ships no icons and no symbol glyphs in a default —
49
52
  * they render as nothing where the font lacks them, which
50
53
  * includes this sandbox's chromium, the kiosk image and the
51
54
  * ePaper build — so the default is a word and an app that owns a
52
- * glyph set passes one. On a dense board it is worth ~55px a row.
55
+ * glyph set passes one. On a dense board it is worth ~35px a row.
53
56
  */
54
57
  moveIcon?: ReactNode
55
58
  /**
@@ -216,7 +219,24 @@ export const Board = ({
216
219
  return (
217
220
  <section
218
221
  aria-label={label}
219
- className={toClassName("@container", className)}
222
+ /*
223
+ * `@container/board`, and the NAME is what a card's move
224
+ * handle needs.
225
+ *
226
+ * An unnamed container query matches the nearest ancestor
227
+ * container, and between this box and a card there is a
228
+ * second one — the lane's, one level down, which decides what
229
+ * shape a card is. So a `cq-lg:` written inside `BoardCard`
230
+ * asks about the LANE, and the answer is inverted against the
231
+ * question the handle has to ask: in a three-up board a lane
232
+ * is ~280px, and in the Narrow View the single lane is the
233
+ * whole board. Naming this one lets the handle query past the
234
+ * lane to the box that actually decides how many lanes are on
235
+ * screen. Naming changes nothing else — a named container
236
+ * still answers an unnamed query, which is why the segmented
237
+ * control and the lanes below keep their plain `cq-lg:`.
238
+ */
239
+ className={toClassName("@container/board", className)}
220
240
  >
221
241
  <div className="flex flex-col gap-3">
222
242
  {/*
@@ -147,13 +147,16 @@ export type BoardCardProps = {
147
147
  item: BoardItem
148
148
  laneLabel: string
149
149
  /**
150
- * Shown on the move handle instead of the word "Move". The
151
- * library ships no icons, so the default is words — a `⋮` renders
152
- * as nothing where the font lacks it, which includes the kiosk
153
- * image and the ePaper build. An app that already owns a glyph
154
- * set passes one and buys back the ~55px per row that a word
155
- * costs, which in a 500px lane is the difference between twenty
156
- * characters of title and thirty.
150
+ * Shown on the move handle **while the lanes are side by side**,
151
+ * instead of the word "Move". Below the board's `cq-lg` the word
152
+ * comes back whatever this is — see the handle section below.
153
+ *
154
+ * The library ships no icons, so the default is words — a `⋮`
155
+ * renders as nothing where the font lacks it, which includes the
156
+ * kiosk image and the ePaper build. An app that already owns a
157
+ * glyph set passes one and buys back the ~35px per row that a
158
+ * word costs, which in a 500px lane is several characters of
159
+ * title.
157
160
  */
158
161
  moveIcon?: ReactNode
159
162
  /** The lanes this card can be sent to — never its own. */
@@ -205,6 +208,38 @@ export type BoardCardProps = {
205
208
  * on screen at all. Dragging from it moves the card directly. Both
206
209
  * commit through the same callback, so there is no second code path
207
210
  * to keep honest and neither is a fallback for the other.
211
+ *
212
+ * ### What it WEARS depends on whether a drag can succeed
213
+ *
214
+ * The two drivers are not equally available at every width, so the
215
+ * handle does not claim they are:
216
+ *
217
+ * | The board is | The handle shows | Because |
218
+ * | --- | --- | --- |
219
+ * | `cq-lg` and wider — lanes side by side | the app's `moveIcon` | the other lanes are on screen, so a drag has somewhere to land, and a grip glyph teaches that gesture |
220
+ * | narrower — one lane and a segmented control | the word **Move** | there is nothing on screen to drop onto, so the only gesture the glyph teaches is the one that cannot work |
221
+ *
222
+ * That table is a bug report. QueuePilot shipped a `≡` handle in a
223
+ * one-lane board and the owner could not move anybody: *"I can't
224
+ * seem to drag 'n drop the names from Everyone Else anywhere else.
225
+ * There's no right-click or anything. How do I move these?"* The
226
+ * first fix took the glyph away everywhere, which was the wrong
227
+ * half — *"I think the drag handles were fine, but now you have it
228
+ * in a 3-column mode, so dragging would work, but it has this
229
+ * 'move' button instead."*
230
+ *
231
+ * Both are in the DOM and CSS picks; neither is rendered from a
232
+ * measurement. A `ResizeObserver` would get the first paint wrong,
233
+ * and a kiosk Pi shows that. Both are `aria-hidden`, and the
234
+ * handle's whole accessible name comes from the `VisuallyHidden`
235
+ * beside them — so the name is the same sentence at every width,
236
+ * which is what keeps one `getByRole` query working in both
237
+ * layouts.
238
+ *
239
+ * The query is against the **board's** container, named `board`,
240
+ * not the lane's. A lane in a three-up board is narrow and the
241
+ * Narrow View's single lane is wide, so the nearer container
242
+ * answers this question exactly backwards.
208
243
  */
209
244
  export const BoardCard = ({
210
245
  item,
@@ -346,12 +381,29 @@ export const BoardCard = ({
346
381
  <Button
347
382
  appearance="ghost"
348
383
  intent="neutral"
349
- // `touch-none` so a touch drag is a drag
350
- // rather than a page scroll. Only the handle
351
- // needs it; the rest of the gesture is covered
352
- // by `preventDefault` on the captured
353
- // `pointermove`.
354
- className="touch-none opacity-70 group-hover:opacity-100"
384
+ className={toClassName(
385
+ // `touch-none` so a touch drag is a drag
386
+ // rather than a page scroll. Only the handle
387
+ // needs it; the rest of the gesture is
388
+ // covered by `preventDefault` on the
389
+ // captured `pointermove`.
390
+ "touch-none opacity-70 group-hover:opacity-100",
391
+ // `sizing` is a prop and cannot be two
392
+ // things at once, so the button stays
393
+ // `control`-sized — which is what the word
394
+ // needs — and takes `icon` sizing back at
395
+ // the width where the glyph is what is
396
+ // showing. These are the two utilities
397
+ // `ICON_CONTROL_SIZE_CLASS.sm` is, minus the
398
+ // height `CONTROL_SIZE_CLASS.sm` already
399
+ // set. Deterministic rather than a
400
+ // specificity coin-flip: Tailwind orders a
401
+ // variant after the utility it varies, which
402
+ // is the same thing the lane's own
403
+ // `cq-lg:p-0` relies on.
404
+ moveIcon != null &&
405
+ "@min-[48rem]/board:w-(--control-height-sm) @min-[48rem]/board:px-0",
406
+ )}
355
407
  onClick={() => {
356
408
  setIsMenuVisible(
357
409
  (isVisible) => !isVisible,
@@ -361,23 +413,53 @@ export const BoardCard = ({
361
413
  onStartDrag?.(pointerEvent)
362
414
  }}
363
415
  size="sm"
364
- sizing={moveIcon ? "icon" : "control"}
416
+ sizing="control"
365
417
  >
366
- {moveIcon ?? "Move"}
418
+ {moveIcon == null ? null : (
419
+ // `hidden` first, so a browser with no
420
+ // container-query support — and every test
421
+ // renderer that does not lay out — keeps the
422
+ // word, which is the affordance that works
423
+ // everywhere.
424
+ //
425
+ // `contents` rather than `inline`: the glyph
426
+ // stays a direct flex child of the button,
427
+ // so it centres the way it did when it was
428
+ // the button's only content.
429
+ <span
430
+ aria-hidden="true"
431
+ className="hidden @min-[48rem]/board:contents"
432
+ >
433
+ {moveIcon}
434
+ </span>
435
+ )}
436
+
437
+ <span
438
+ aria-hidden="true"
439
+ className={toClassName(
440
+ moveIcon != null &&
441
+ "@min-[48rem]/board:hidden",
442
+ )}
443
+ >
444
+ Move
445
+ </span>
367
446
 
368
447
  {/*
369
448
  * Thirty controls all named "Move" is thirty
370
449
  * indistinguishable buttons to a screen reader
371
450
  * and to `getByRole("button", { name })`, so
372
451
  * the accessible name says *which* card and
373
- * where it currently is. With an icon this is
374
- * the whole name; with the word it qualifies
375
- * it.
452
+ * where it currently is.
453
+ *
454
+ * The whole name lives here, and both visible
455
+ * affordances are `aria-hidden`, because which
456
+ * of them is painted is a CSS answer that the
457
+ * accessible name must not depend on — a
458
+ * handle called "Move X" wide and "Move Move
459
+ * X" narrow is one control with two names.
376
460
  */}
377
461
  <VisuallyHidden>
378
- {moveIcon
379
- ? `Move ${item.title}, currently in ${laneLabel}`
380
- : ` ${item.title}, currently in ${laneLabel}`}
462
+ {`Move ${item.title}, currently in ${laneLabel}`}
381
463
  </VisuallyHidden>
382
464
  </Button>
383
465
  }
@@ -0,0 +1,54 @@
1
+ import { readFile } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+
4
+ import { containerQuery } from "@charcuterie/tokens/src/scales.ts"
5
+ import { expect, test } from "vitest"
6
+
7
+ /**
8
+ * The move handle swaps its glyph for the word "Move" at the width
9
+ * where the lanes stop being side by side — and it has to be the
10
+ * SAME width the lanes themselves use, or there is a band where the
11
+ * board is one lane and the handle still teaches a drag.
12
+ *
13
+ * Two literals cannot be shared: a container query's threshold is
14
+ * resolved before custom properties exist, so
15
+ * `@container (min-inline-size: var(--cq-lg))` is invalid CSS and
16
+ * `48rem` has to be written out. The lanes get theirs from the
17
+ * generated `cq-lg` variant; the handle writes Tailwind's own
18
+ * `@min-[…]/board:`, because that is the only form that can name a
19
+ * container. This test is what keeps the two from drifting.
20
+ */
21
+ const directory = import.meta.dirname
22
+
23
+ test("the handle's own breakpoint is the scale's `cq-lg`, spelled out", async () => {
24
+ const source = await readFile(
25
+ join(directory, "BoardCard.tsx"),
26
+ "utf8",
27
+ )
28
+
29
+ const thresholds = [
30
+ ...source.matchAll(/@min-\[([^\]]+)\]\/board:/g),
31
+ ].map((match) => match[1])
32
+
33
+ // The mutation guard: a refactor that renames the variant would
34
+ // otherwise turn the assertion below into `[] === []`.
35
+ expect(thresholds.length).toBeGreaterThan(0)
36
+
37
+ expect([...new Set(thresholds)]).toEqual([
38
+ containerQuery.lg,
39
+ ])
40
+ })
41
+
42
+ /**
43
+ * The other half. A named query with no container of that name
44
+ * never matches anything — no error, no warning, and the handle
45
+ * silently keeps whichever affordance the base classes gave it.
46
+ */
47
+ test("the board declares the `board` container the handle queries", async () => {
48
+ const source = await readFile(
49
+ join(directory, "Board.tsx"),
50
+ "utf8",
51
+ )
52
+
53
+ expect(source).toContain('"@container/board"')
54
+ })
@@ -0,0 +1,169 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as NavStories from "./Nav.stories";
4
+
5
+ <Meta of={NavStories} />
6
+
7
+ # Nav
8
+
9
+ The app's destinations. A header row, a side rail, a collapsed rail, a strip along the foot
10
+ of a phone, or a panel behind one control — the same list, drawn where it fits.
11
+
12
+ > **This replaces [`NavBar`](?path=/docs/components-layout-navbar--docs)**, which shipped the
13
+ > header row on its own and is deprecated as of this release. `bar` is `Nav`'s default
14
+ > layout and behaves identically, down to the measured whole-row fold — see *`bar` folds
15
+ > whole* below.
16
+
17
+ <Canvas of={NavStories.Default} />
18
+
19
+ ## Props
20
+
21
+ <Controls />
22
+
23
+ ## What was actually missing
24
+
25
+ `Shell` / `Header` / `Rail` / `Main` shipped the page frame on 2026-08-10 and left **the
26
+ rail empty**. Twelve UI repos, and exactly two have a `<nav>` at all: Docket hand-rolled a
27
+ row of `NavLink`s with the collapse rule in its own stylesheet, Folio hand-rolled another,
28
+ and the other ten have no destinations anywhere.
29
+
30
+ The two that exist already disagree. Docket *wraps* below its breakpoint, so its header
31
+ stands three lines tall on a phone — which is not a decision anybody made, it is what
32
+ `flex-wrap` does when nobody writes the other rule.
33
+
34
+ ## Every item is a real link
35
+
36
+ <Canvas of={NavStories.AllVariants} />
37
+
38
+ `<a href>`, through the injected `RouterLinkProvider` — the same seam `TextLink` and
39
+ `ButtonLink` take, so an app injects its router once and every destination in the library
40
+ routes.
41
+
42
+ Not a `<button onClick={navigate}>`. That is what mux-magic and QueuePilot do today, it is
43
+ **identical in a screenshot**, and it loses middle-click, ctrl-click, "open in a new tab",
44
+ the status bar preview, and "copy link address". See
45
+ [buttons are actions, links are navigation](?path=/docs/guides-routing--docs).
46
+
47
+ An `isExternal` item is the exception the seam already knows: a plain anchor,
48
+ `target="_blank"`, `rel="noopener noreferrer"`. Pushing `https://…` onto a router's history
49
+ navigates the SPA to a route that does not exist.
50
+
51
+ ## `icon`, and `NavRailItem`
52
+
53
+ `NavItem` keeps `icon` optional, because a `bar` never takes the label away — Docket's nine
54
+ destinations are words, and inventing nine glyphs to satisfy a type would be worse than the
55
+ problem.
56
+
57
+ A rail is the other case. `railIcons` is one `--control-height-lg` square and shows nothing
58
+ else, so an item with no glyph is a blank square that still navigates, and there is no visual
59
+ defect to notice because the row reads as padding. **Annotate a rail's array as
60
+ `NavRailItem[]`** — the same shape with `icon` made required — and the type checker catches
61
+ it before somebody drags a window narrow.
62
+
63
+ `items` is deliberately **not** discriminated on `layout`. The main call site passes
64
+ `layout={navLayout.layout}` straight from `useNavLayout`, which is a variable and not a
65
+ literal, so a union that split the item type by layout would force a `switch` at the one
66
+ place this component exists to make short.
67
+
68
+ The library still ships no icons — lucide (ISC) is the fleet recommendation, and a glyph in
69
+ a default renders as nothing where the font lacks it.
70
+
71
+ ## `bar` folds whole, and it is measured
72
+
73
+ There is no `collapseAt` and no media query on this axis. The bar reads the width its links
74
+ actually have and folds when their sum stops fitting, so a longer label, a tenth destination,
75
+ a `data-density="kiosk"` or a reader at 175% browser zoom each move the fold on their own.
76
+ mux-magic collapses at a hardcoded `480px` and plex-channels at `760px`, and neither number
77
+ has a reason.
78
+
79
+ It folds **whole** rather than progressively, unlike `Toolbar`. `Toolbar`'s items are ranked;
80
+ a nav's order is the shape of the product — Docket's reads as the pipeline, Triage → Backlog
81
+ → Phases → Board → Archives — and half of that behind a button is a rule no reader can learn.
82
+
83
+ ⚠️ **A `bar` must not be sized by its own contents.** It measures its own box to decide what
84
+ to draw in it, so a container that shrink-wraps makes the answer its own input: put it in a
85
+ `minmax(0, 1fr)` grid track or a `flex-1 min-w-0` row.
86
+
87
+ ## The current destination is announced
88
+
89
+ <Canvas of={NavStories.AllStates} />
90
+
91
+ `aria-current="page"`, and a background. One without the other is WCAG 1.4.1, and a
92
+ `font-weight` swap alone is exactly that failure.
93
+
94
+ Matching is two rules:
95
+
96
+ 1. An exact path wins — `/board` on `/board`.
97
+ 2. Otherwise the **longest path-segment prefix** wins. `/board` matches `/board/inProgress`
98
+ and does **not** match `/boardgames`, because `games` is not a new segment. Longest, so
99
+ an app with `/settings` and `/settings/labels` marks the deeper one.
100
+
101
+ The root is exempt from rule 2: `/` prefixes every path in the app, so a permissive root
102
+ would mark Home current on every screen and the state would carry no information.
103
+
104
+ Query and fragment are stripped first. A destination and a filtered view of it are the same
105
+ place — `/board?lane=todo` is still Board, which is the case that breaks first in a real app.
106
+
107
+ **And exactly one item is current.** `getIsCurrentHref` answers for a single link, so a nav
108
+ holding both `/settings` and `/settings/labels` gets `true` twice while the reader is on the
109
+ deeper one — which is what `NavBar` shipped. `resolveActiveKey` is the whole-list answer and
110
+ takes the **deepest** match, by path rather than by array position, so the product's own
111
+ order never decides which destination lights up.
112
+
113
+ Both are exported, so an app can assert its own table without mounting anything.
114
+
115
+ ## Three widths, one rule — `useNavLayout`
116
+
117
+ <Canvas of={NavStories.Responsive} />
118
+
119
+ ```tsx
120
+ const nav = useNavLayout({ storageKey: "queuepilot-nav" })
121
+
122
+ // nav.layout: "rail" | "railIcons" | "menu"
123
+ // nav.isCollapsed, nav.mode, nav.setMode, nav.toggle
124
+ ```
125
+
126
+ | Width | `layout` | What the app renders |
127
+ | --- | --- | --- |
128
+ | `>= 64rem` | `rail` | a `Rail` holding a `Nav` |
129
+ | `>= 48rem` | `railIcons` | the same `Rail`, narrower |
130
+ | below | `menu` | a trigger in the `Header` |
131
+
132
+ `toggle()` overrides the width and the choice is remembered under `storageKey`. The
133
+ override does **not** reach the narrow state — `expanded` on a 390px screen is a 212px rail
134
+ beside 178px of content — and the remembered value is kept rather than cleared, so rotating
135
+ a tablet back to landscape restores the rail the reader asked for.
136
+
137
+ **Why the app still places it.** The placement genuinely differs and the rule does not. The
138
+ `menu` state puts a control in the header, which is a different corner of `Shell`'s grid
139
+ from a side rail, and no amount of restyling moves an element across a grid. So the library
140
+ owns *which* state is right, at what width, with what override, remembered where; the app
141
+ owns two `if`s.
142
+
143
+ ## The narrow panel is a `Popover`, not a `Menu`
144
+
145
+ <Canvas of={NavStories.Interactive} />
146
+
147
+ `Menu`'s items are `onSelect` callbacks on `<button>`s. That is right for what a menu
148
+ usually holds — undo, redo, a scheme cycle — and wrong for a destination, for every reason
149
+ in *Every item is a real link* above.
150
+
151
+ So `layout="menu"` is a `Popover`: anchored, dismissable, portalled to the body, and
152
+ `modal={false}` so the caret is not trapped. It is a disclosure, not a dialog somebody has
153
+ to answer. Escape and an outside press both close it.
154
+
155
+ Give the trigger a real name. A bare hamburger is a glyph, and a glyph is not a name — the
156
+ reason `IconButton` requires `label`.
157
+
158
+ ## When to use which layout
159
+
160
+ | Layout | For |
161
+ | --- | --- |
162
+ | `bar` | three to six destinations, and a grid that wants every pixel — ai-usage, folio, mux-magic, portly-controllers. Folds itself; needs no `useNavLayout`. |
163
+ | `rail` / `railIcons` | seven or more, or a pipeline you read down — Docket, mail-sifter, QueuePilot |
164
+ | `bottom` | three or four you switch between constantly, in the Narrow View only |
165
+ | `menu` | the Narrow View of a `rail` app |
166
+
167
+ `bottom` is deliberately not the default narrow answer. It costs a permanent strip of the
168
+ screen, and it earns that only when the destinations are few and the switching is constant.
169
+ An app you settle into for an evening wants the panel.