@charcuterie/ui 2.8.0 → 2.10.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 (80) hide show
  1. package/README.md +9 -0
  2. package/dist/Dialog/Dialog.d.ts.map +1 -1
  3. package/dist/Dialog/Dialog.js +3 -1
  4. package/dist/Dialog/Dialog.js.map +1 -1
  5. package/dist/Field/Field.d.ts.map +1 -1
  6. package/dist/Field/Field.js +1 -1
  7. package/dist/Field/Field.js.map +1 -1
  8. package/dist/Field/FieldGroup.d.ts.map +1 -1
  9. package/dist/Field/FieldGroup.js +1 -1
  10. package/dist/Field/FieldGroup.js.map +1 -1
  11. package/dist/Header/Header.d.ts +52 -0
  12. package/dist/Header/Header.d.ts.map +1 -0
  13. package/dist/Header/Header.js +55 -0
  14. package/dist/Header/Header.js.map +1 -0
  15. package/dist/Main/Main.d.ts +113 -0
  16. package/dist/Main/Main.d.ts.map +1 -0
  17. package/dist/Main/Main.js +123 -0
  18. package/dist/Main/Main.js.map +1 -0
  19. package/dist/Rail/Rail.d.ts +61 -0
  20. package/dist/Rail/Rail.d.ts.map +1 -0
  21. package/dist/Rail/Rail.js +64 -0
  22. package/dist/Rail/Rail.js.map +1 -0
  23. package/dist/Shell/Shell.d.ts +112 -0
  24. package/dist/Shell/Shell.d.ts.map +1 -0
  25. package/dist/Shell/Shell.js +116 -0
  26. package/dist/Shell/Shell.js.map +1 -0
  27. package/dist/Shell/contentWidth.d.ts +63 -0
  28. package/dist/Shell/contentWidth.d.ts.map +1 -0
  29. package/dist/Shell/contentWidth.js +74 -0
  30. package/dist/Shell/contentWidth.js.map +1 -0
  31. package/dist/Shell/shellContext.d.ts +36 -0
  32. package/dist/Shell/shellContext.d.ts.map +1 -0
  33. package/dist/Shell/shellContext.js +32 -0
  34. package/dist/Shell/shellContext.js.map +1 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -1
  36. package/dist/SortableTableHeader/SortableTableHeader.js +7 -1
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -1
  38. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  39. package/dist/Tooltip/Tooltip.js +5 -1
  40. package/dist/Tooltip/Tooltip.js.map +1 -1
  41. package/dist/index.d.ts +9 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +4 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  46. package/dist/mountStory.testHelpers.js +56 -0
  47. package/dist/mountStory.testHelpers.js.map +1 -1
  48. package/dist/viewport.testHelpers.d.ts +31 -0
  49. package/dist/viewport.testHelpers.d.ts.map +1 -0
  50. package/dist/viewport.testHelpers.js +34 -0
  51. package/dist/viewport.testHelpers.js.map +1 -0
  52. package/package.json +2 -2
  53. package/src/Dialog/Dialog.tsx +17 -1
  54. package/src/Field/Field.tsx +10 -2
  55. package/src/Field/FieldGroup.tsx +4 -2
  56. package/src/Header/Header.mdx +72 -0
  57. package/src/Header/Header.stories.tsx +221 -0
  58. package/src/Header/Header.test.tsx +122 -0
  59. package/src/Header/Header.tsx +118 -0
  60. package/src/Main/Main.mdx +149 -0
  61. package/src/Main/Main.stories.tsx +164 -0
  62. package/src/Main/Main.test.tsx +167 -0
  63. package/src/Main/Main.tsx +174 -0
  64. package/src/Rail/Rail.mdx +75 -0
  65. package/src/Rail/Rail.stories.tsx +177 -0
  66. package/src/Rail/Rail.test.tsx +150 -0
  67. package/src/Rail/Rail.tsx +102 -0
  68. package/src/Shell/Shell.mdx +186 -0
  69. package/src/Shell/Shell.stories.tsx +185 -0
  70. package/src/Shell/Shell.test.tsx +267 -0
  71. package/src/Shell/Shell.tsx +170 -0
  72. package/src/Shell/contentWidth.ts +89 -0
  73. package/src/Shell/shellContext.ts +41 -0
  74. package/src/SortableTableHeader/SortableTableHeader.tsx +6 -1
  75. package/src/Tooltip/Tooltip.tsx +5 -1
  76. package/src/index.ts +16 -0
  77. package/src/mountStory.testHelpers.ts +63 -0
  78. package/src/shell.storyHelpers.tsx +258 -0
  79. package/src/sourceRules.test.ts +17 -1
  80. package/src/viewport.testHelpers.ts +47 -0
@@ -0,0 +1,167 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent } from "storybook/test"
3
+ import { afterAll, test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import {
9
+ DESKTOP,
10
+ PHONE,
11
+ setViewport,
12
+ } from "../viewport.testHelpers.ts"
13
+ import * as stories from "./Main.stories.tsx"
14
+
15
+ const { Default, Interactive, Responsive } =
16
+ composeStories(stories)
17
+
18
+ afterAll(async () => {
19
+ await setViewport(DESKTOP)
20
+ })
21
+
22
+ test("the content column is capped at the token, not at the window", async () => {
23
+ await setViewport(DESKTOP)
24
+
25
+ const { canvas } = await mountStory(Default)
26
+
27
+ const column = canvas.getByRole("main")
28
+ .firstElementChild as HTMLElement
29
+
30
+ // `screen.lg` — 64rem at the root 16px font size. The number is
31
+ // spelled out here rather than read back from the same
32
+ // `var()` the component wrote, because a test that asks the
33
+ // component what it did agrees with it by construction.
34
+ await expect(
35
+ globalThis.getComputedStyle(column).maxInlineSize,
36
+ ).toBe("1024px")
37
+
38
+ await expect(
39
+ column.getBoundingClientRect().width,
40
+ ).toBeLessThanOrEqual(1024)
41
+ })
42
+
43
+ /**
44
+ * The `@container` claim, asserted rather than assumed.
45
+ *
46
+ * A grid inside `Main` answers to the **content column**, which is
47
+ * a different width from the window's the moment a rail is open —
48
+ * the pair a media query cannot tell apart, and the reason the
49
+ * fleet's poster grids look wrong at intermediate widths.
50
+ */
51
+ test("main is the query container its content responds to", async () => {
52
+ await setViewport(DESKTOP)
53
+
54
+ const { canvas } = await mountStory(Default)
55
+
56
+ const column = canvas.getByRole("main")
57
+ .firstElementChild as HTMLElement
58
+
59
+ // On the column, not on `<main>`. `<main>` is as wide as its
60
+ // grid track (1184px beside the rail here); the column is
61
+ // capped at 1024px. A container declared on the wrong one of
62
+ // those measures a width the content never has.
63
+ await expect(
64
+ globalThis.getComputedStyle(column).containerType,
65
+ ).toBe("inline-size")
66
+
67
+ const grid = column.firstElementChild as HTMLElement
68
+
69
+ // Two-up, and that is the whole argument. The column's content
70
+ // box is 976px — past `--cq-md` (32rem), short of `--cq-xl`
71
+ // (64rem) — while the *window* is 1440px, so a `lg:grid-cols-3`
72
+ // media query would have drawn three slivers in 976px of space.
73
+ await expect(
74
+ globalThis
75
+ .getComputedStyle(grid)
76
+ .gridTemplateColumns.split(" "),
77
+ ).toHaveLength(2)
78
+ })
79
+
80
+ test("neither an unbroken path nor a wide table widens the page at 390px", async () => {
81
+ await setViewport(PHONE)
82
+
83
+ const { canvas, canvasElement } =
84
+ await mountStory(Responsive)
85
+
86
+ const path = canvas.getByText(
87
+ /Some-Very-Long-Show-Name-S01E01/,
88
+ )
89
+
90
+ // It wraps rather than overflowing. Without it the element is
91
+ // one 1,000px line.
92
+ await expect(
93
+ path.getBoundingClientRect().width,
94
+ ).toBeLessThanOrEqual(PHONE.width)
95
+
96
+ // `anywhere`, not `break-word` — a drift gate, because the two
97
+ // are indistinguishable in a screenshot and only `anywhere`
98
+ // shrinks the **min-content size** a flex or grid item's
99
+ // automatic minimum resolves against. Under `break-word` the ink
100
+ // wraps while the intrinsic contribution stays the full token
101
+ // length, so the string can still force a column open with **no
102
+ // overflowing element box** for a bounding-rect assertion to
103
+ // see. That is the failure shape found in plex-channels, and
104
+ // this line is what stops somebody "tidying" the utility back.
105
+ const column = canvas.getByRole("main")
106
+ .firstElementChild as HTMLElement
107
+
108
+ await expect(
109
+ globalThis.getComputedStyle(column).overflowWrap,
110
+ ).toBe("anywhere")
111
+
112
+ const scroller = canvas.getByRole("region", {
113
+ name: "Transfer log, scrollable",
114
+ })
115
+
116
+ // The table genuinely does not fit — otherwise the page-level
117
+ // assertion below passes for the wrong reason.
118
+ await expect(scroller.scrollWidth).toBeGreaterThan(
119
+ scroller.clientWidth,
120
+ )
121
+
122
+ const { documentElement } = document
123
+
124
+ await expect(
125
+ documentElement.scrollWidth,
126
+ ).toBeLessThanOrEqual(documentElement.clientWidth)
127
+
128
+ await expectNoAxeViolations(canvasElement)
129
+ })
130
+
131
+ /**
132
+ * `tabIndex={-1}` is the difference between a skip link that
133
+ * works and one that only scrolls. It must not put `<main>` in
134
+ * the tab order, and it must accept focus when sent there.
135
+ */
136
+ test("main takes focus from a skip link without joining the tab order", async () => {
137
+ await setViewport(DESKTOP)
138
+
139
+ const { canvas, canvasElement } =
140
+ await mountStory(Interactive)
141
+
142
+ const main = canvas.getByRole("main")
143
+
144
+ await expect(main).toHaveAttribute("tabindex", "-1")
145
+
146
+ await userEvent.click(
147
+ expectAgentDrivable(canvas, {
148
+ name: "Skip to main content",
149
+ role: "link",
150
+ }),
151
+ )
152
+
153
+ await expect(main).toHaveFocus()
154
+
155
+ // The next Tab leaves `<main>` rather than cycling back into
156
+ // the header, because the skip link put focus *inside* the page
157
+ // rather than merely scrolling to it.
158
+ await userEvent.tab()
159
+
160
+ await expect(main).not.toHaveFocus()
161
+
162
+ await expect(main.contains(document.activeElement)).toBe(
163
+ true,
164
+ )
165
+
166
+ await expectNoAxeViolations(canvasElement)
167
+ })
@@ -0,0 +1,174 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import {
3
+ type ComponentPropsWithRef,
4
+ type ReactNode,
5
+ useContext,
6
+ } from "react"
7
+
8
+ import {
9
+ type ContentWidth,
10
+ DEFAULT_CONTENT_WIDTH,
11
+ toMaxInlineSize,
12
+ } from "../Shell/contentWidth.ts"
13
+ import { ShellContext } from "../Shell/shellContext.ts"
14
+ import { toClassName } from "../toClassName.ts"
15
+
16
+ export type MainProps = ComponentPropsWithRef<"main"> & {
17
+ contentWidth?: ContentWidth
18
+ }
19
+
20
+ /**
21
+ * The `<main>` landmark and the content column.
22
+ *
23
+ * ## The width is the whole component
24
+ *
25
+ * The owner's complaint, verbatim:
26
+ *
27
+ * > "All these apps seem to be really narrow with a very large
28
+ * > max-width. By that I mean 1 column, but waaaaaaay too wide. In
29
+ * > almost all cases, what I really want is something like Rip-Deck
30
+ * > where the app has a narrower main column but the wrapping grids
31
+ * > are all full-width in most of these apps only when you have too
32
+ * > many items."
33
+ *
34
+ * That is two requirements, and they pull opposite ways: a
35
+ * **narrow** column by default, which **widens** when the content
36
+ * is a grid with enough items to justify it. A fixed `max-w-7xl`
37
+ * answers neither — it is too wide for the one-column case, which
38
+ * is the complaint, and it is a hard ceiling on the many-items
39
+ * case.
40
+ *
41
+ * So the cap is a *value*, not a constant, and it arrives from
42
+ * three places in priority order: this component's own
43
+ * `contentWidth`, then `Shell`'s (the usual answer, so the header
44
+ * matches), then `screen.lg`.
45
+ *
46
+ * ### How it composes with the adaptive-columns hook
47
+ *
48
+ * rip-deck's `useLayoutColumns` — being lifted into this library
49
+ * separately — decides a column count from the viewport and folds
50
+ * it into a page cap with `contentMaxWidthRem(columns)`: 1 column →
51
+ * 56rem, 2 → 72rem, 3 → 106rem. It returns a **number of rem**, and
52
+ * `ContentWidth` accepts `` `${number}rem` `` for exactly this:
53
+ *
54
+ * ```tsx
55
+ * const { columns } = useLayoutColumns({ cardCount: bays.length })
56
+ *
57
+ * <Shell contentWidth={`${contentMaxWidthRem(columns)}rem`}>
58
+ * <Header heading="Rip Deck" />
59
+ * <Main>
60
+ * <BayGrid columns={columns} />
61
+ * </Main>
62
+ * </Shell>
63
+ * ```
64
+ *
65
+ * The seam is deliberately one prop and no hook import: this
66
+ * package must not depend on a layout hook to lay out a page, and
67
+ * the hook must not know that a `Shell` exists. Putting the value
68
+ * on `Shell` rather than on `Main` is what keeps the header's cap
69
+ * in step with the column's as the count changes.
70
+ *
71
+ * ## `@container`, and what `contain: inline-size` costs
72
+ *
73
+ * The content column establishes a container query, so an app's
74
+ * grids inside it respond to **the column's** width rather than
75
+ * the window's — a poster grid beside an open rail and the same
76
+ * grid full-bleed are the pair a media query cannot tell apart,
77
+ * and getting it wrong at intermediate widths is what the fleet's
78
+ * grids do today.
79
+ *
80
+ * `container-type: inline-size` implies `contain: layout style
81
+ * inline-size`, and both halves have consequences worth stating:
82
+ *
83
+ * - **The element may not be sized by its own contents.** Here
84
+ * that is free and in fact wanted: the column has a definite
85
+ * inline size from its parent — itself a grid item with
86
+ * `minmax(0, 1fr)` — so wide content can no longer widen it,
87
+ * which is half of why the page does not scroll sideways.
88
+ * - **`contain: layout` makes it a containing block for
89
+ * `position: fixed` descendants.** An app rendering a fixed
90
+ * element *inside* `Main` gets it positioned against the
91
+ * column, not the viewport. Every overlay in this library
92
+ * portals to `document.body` and is therefore unaffected
93
+ * ([decision](../../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md));
94
+ * an app's own fixed chrome belongs in `Shell`, beside `Main`
95
+ * rather than in it.
96
+ *
97
+ * ## Two more things it does, both one line
98
+ *
99
+ * `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
100
+ * merely the reading cursor — without it, Safari and Firefox scroll
101
+ * to `<main>` and leave the next Tab back in the header.
102
+ *
103
+ * `wrap-anywhere` so an unbroken string — a filesystem path, a
104
+ * hash, a URL — wraps instead of widening the page.
105
+ *
106
+ * **`anywhere`, not `break-word`, and the difference is the whole
107
+ * point.** Both break a long word onto the next line, so they look
108
+ * identical in a screenshot. Only `anywhere` also shrinks the
109
+ * element's **min-content size** — which is what a flex or grid
110
+ * item's automatic minimum resolves against, and therefore what
111
+ * decides whether the string can push its own column wider. With
112
+ * `break-word` the ink wraps while the intrinsic contribution
113
+ * stays the full length of the token, so the layout above it can
114
+ * still be forced open. This is the second of the two overflow
115
+ * sources found in plex-channels, and the nastier one: it produces
116
+ * **no overflowing element box at all**, so every
117
+ * `getBoundingClientRect()` in a test reads clean while the page
118
+ * scrolls sideways.
119
+ *
120
+ * It is the cheap half of the horizontal-scroll fix; the other
121
+ * halves are `minmax(0, 1fr)` and `overflow-x: clip` in `Shell`. A
122
+ * `<table>` is the one thing none of them can save: tables do not
123
+ * wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
124
+ * `Main.mdx` shows.
125
+ */
126
+ export const Main = ({
127
+ children,
128
+ className,
129
+ contentWidth,
130
+ id,
131
+ ...mainProps
132
+ }: MainProps): ReactNode => {
133
+ const shell = useContext(ShellContext)
134
+
135
+ // Always called, never conditionally: a `Main` outside a `Shell`
136
+ // still needs an id an app can point its own skip link at.
137
+ const fallbackId = useUniqueId()
138
+
139
+ const maxInlineSize = toMaxInlineSize(
140
+ contentWidth ??
141
+ shell?.contentWidth ??
142
+ DEFAULT_CONTENT_WIDTH,
143
+ )
144
+
145
+ return (
146
+ <main
147
+ {...mainProps}
148
+ className={toClassName(
149
+ "col-start-1 row-start-3 min-w-0 md:col-start-2 md:row-start-2",
150
+ className,
151
+ )}
152
+ id={id ?? shell?.mainId ?? fallbackId}
153
+ tabIndex={-1}
154
+ >
155
+ {/*
156
+ `@container` is on the **capped column**, not on `<main>`,
157
+ and the difference is not cosmetic. `<main>` is as wide as
158
+ its grid track — 1184px beside an open rail on a 1440px
159
+ window — while the column inside it is capped at
160
+ `contentWidth`. Declaring the container on `<main>` makes
161
+ every `cq-*` inside answer to a width the content never
162
+ has: a grid would go three-up at `--cq-xl` while sitting in
163
+ 976px of space. The container has to be the box the content
164
+ is actually laid out in.
165
+ */}
166
+ <div
167
+ className="@container mx-auto flex w-full min-w-0 flex-col gap-6 wrap-anywhere px-4 py-6 sm:px-6"
168
+ style={{ maxInlineSize }}
169
+ >
170
+ {children}
171
+ </div>
172
+ </main>
173
+ )
174
+ }
@@ -0,0 +1,75 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as RailStories from "./Rail.stories";
4
+
5
+ <Meta of={RailStories} />
6
+
7
+ # Rail
8
+
9
+ A side column — the app's sections, a filter panel, the selected job's detail. `side` places
10
+ it, `landmark` decides what it is, and `label` is required.
11
+
12
+ <Canvas of={RailStories.Default} />
13
+
14
+ ## Props
15
+
16
+ <Controls />
17
+
18
+ ## `label` is required, and that is not pedantry
19
+
20
+ <Canvas of={RailStories.AllVariants} />
21
+
22
+ Two rails in one page are two landmarks of the same type. Unnamed, a screen reader
23
+ announces "complementary" twice with no way to tell them apart, axe files
24
+ `landmark-unique`, and `getByRole("navigation")` returns two matches — which is worse for an
25
+ agent than the rail not existing, because it picks one at random.
26
+
27
+ Named, the rail becomes the scope: `getByRole("navigation", { name: "Sections" })` is what
28
+ distinguishes its "Settings" link from the header's "Settings" button. Same trick `Card`'s
29
+ heading plays for one of nine identical "Start" buttons on a bay tower.
30
+
31
+ `landmark="navigation"` renders `<nav>`; the default `"complementary"` renders `<aside>`.
32
+ Pick the one the content actually is — a filter panel is not navigation.
33
+
34
+ ## It collapses without duplicating itself
35
+
36
+ <Canvas of={RailStories.Responsive} />
37
+
38
+ Below `md` (48rem, which is `screen.md`) the shell is one column and the rail becomes a
39
+ horizontally-scrolling strip: above the content for `side="start"`, below it for
40
+ `side="end"`. Above `md` it is a fixed-width column in the grid.
41
+
42
+ **The same element, restyled.** The fleet's habit is to render the whole thing twice behind
43
+ `hidden` and `lg:hidden` — mux-magic's `PageHeader` across two ~55-line blocks,
44
+ mail-sifter's `TriageQueue` again. The cost is not bytes: every control is in the DOM
45
+ **twice at every viewport**, so tests have to be defensively scoped with `within(toolbar)`,
46
+ any agent driving the page sees phantom controls it cannot click, and the two copies drift
47
+ apart. `Rail.test.tsx` counts the links at 390px and at 1440px and gets twelve both times.
48
+
49
+ A media query is enough here because only the rail's *position* changes, never its
50
+ contents. plex-channels' `App.tsx` shows the pattern for when that is not true — a
51
+ `useMediaQuery` over `matchMedia` through `useSyncExternalStore`, mounting exactly one
52
+ instance and relocating it — and it is strictly more machinery for the same guarantee.
53
+
54
+ The `md` breakpoint and `screen.md` agree by construction: Tailwind v4's default
55
+ `--breakpoint-*` scale and this repo's `screen.*` carry the same five values.
56
+
57
+ ## The strip scrolls itself
58
+
59
+ Twelve sections on a phone is what makes a naive rail widen the page. `overflow-x-auto`
60
+ keeps the overflow inside an element that scrolls, and `min-w-0` is on it for the same
61
+ reason `Shell`'s middle track is `minmax(0, 1fr)`: a flex or grid child's automatic minimum
62
+ is its content, so one long unbroken string otherwise pushes the layout out of the viewport.
63
+
64
+ A scrolling region has to be keyboard-reachable. It is here because it is a landmark full of
65
+ links, which is what axe's `scrollable-region-focusable` accepts — **a rail of plain
66
+ non-focusable text would not be**, and that is the honest limit of the pattern. Content
67
+ like that belongs in `Main`.
68
+
69
+ <Canvas of={RailStories.Interactive} />
70
+
71
+ ## Width
72
+
73
+ `md:w-64` (16rem) by default, which is what the fleet's nav columns settle on. Override it
74
+ with `className` — the rail's track is `auto`, so it takes whatever definite width the
75
+ element is given, and the content column absorbs the difference through its `minmax(0, 1fr)`.
@@ -0,0 +1,177 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { toStoryChoice } from "../argTypes.storyHelpers.ts"
4
+ import { Header } from "../Header/Header.tsx"
5
+ import { Main } from "../Main/Main.tsx"
6
+ import { Shell } from "../Shell/Shell.tsx"
7
+ import {
8
+ HeaderSchemeToggle,
9
+ PageContent,
10
+ RailDetail,
11
+ RailNavigation,
12
+ } from "../shell.storyHelpers.tsx"
13
+ import { Rail } from "./Rail.tsx"
14
+
15
+ const meta = {
16
+ title: "Components/Rail",
17
+ component: Rail,
18
+ parameters: { layout: "fullscreen" },
19
+ argTypes: {
20
+ landmark: toStoryChoice([
21
+ "complementary",
22
+ "navigation",
23
+ ] as const),
24
+ side: toStoryChoice(["start", "end"] as const),
25
+ },
26
+ args: {
27
+ landmark: "complementary",
28
+ side: "start",
29
+ },
30
+ } satisfies Meta<typeof Rail>
31
+
32
+ export default meta
33
+
34
+ type Story = StoryObj<typeof meta>
35
+
36
+ /**
37
+ * The sections column. `landmark="navigation"` because that is
38
+ * what it is, and `label` because a rail nobody can name is a
39
+ * rail an agent cannot scope to — `getByRole("navigation", {
40
+ * name: "Sections" })` is what distinguishes its "Settings" link
41
+ * from the header's "Settings" button.
42
+ */
43
+ export const Default: Story = {
44
+ args: {
45
+ label: "Sections",
46
+ landmark: "navigation",
47
+ },
48
+ render: (railProps) => (
49
+ <Shell>
50
+ <Header
51
+ actions={<HeaderSchemeToggle />}
52
+ heading="Comic Ingest"
53
+ />
54
+
55
+ <Rail {...railProps}>
56
+ <RailNavigation />
57
+ </Rail>
58
+
59
+ <Main>
60
+ <PageContent />
61
+ </Main>
62
+ </Shell>
63
+ ),
64
+ }
65
+
66
+ /**
67
+ * Both sides at once, and both kinds of landmark: a `<nav>` at
68
+ * the start for the app's sections, an `<aside>` at the end for
69
+ * the selected thing's detail.
70
+ *
71
+ * Two rails is where the required `label` stops being pedantry.
72
+ * Two unnamed landmarks of the same type are the same announcement
73
+ * twice to a screen reader and two identical matches to an agent,
74
+ * which is worse than one of them not existing.
75
+ */
76
+ export const AllVariants: Story = {
77
+ args: { label: "Sections", landmark: "navigation" },
78
+ render: (railProps) => (
79
+ <Shell>
80
+ <Header
81
+ actions={<HeaderSchemeToggle />}
82
+ heading="Rip Deck"
83
+ />
84
+
85
+ <Rail {...railProps}>
86
+ <RailNavigation />
87
+ </Rail>
88
+
89
+ <Main>
90
+ <PageContent />
91
+ </Main>
92
+
93
+ <Rail label="Job detail" side="end">
94
+ <RailDetail />
95
+ </Rail>
96
+ </Shell>
97
+ ),
98
+ }
99
+
100
+ /**
101
+ * **The collapse, and what it is not.** Narrow the window past
102
+ * 48rem (`screen.md`): the rail stops being a column and becomes
103
+ * a horizontally-scrolling strip above the content.
104
+ *
105
+ * It is the **same element**, restyled. The fleet's habit is to
106
+ * render the whole thing twice behind `hidden` / `lg:hidden` —
107
+ * mux-magic's `PageHeader` across two ~55-line blocks,
108
+ * mail-sifter's `TriageQueue` again — which puts every control in
109
+ * the DOM twice at every viewport. Count the links here at any
110
+ * width and there are four.
111
+ *
112
+ * The strip scrolls itself rather than widening the page, which
113
+ * is why twelve sections on a phone still leave
114
+ * `document.documentElement.scrollWidth` where it was.
115
+ */
116
+ export const Responsive: Story = {
117
+ args: { label: "Sections", landmark: "navigation" },
118
+ render: (railProps) => (
119
+ <Shell>
120
+ <Header heading="Twelve sections" />
121
+
122
+ <Rail {...railProps}>
123
+ {[
124
+ "Queue",
125
+ "Library",
126
+ "Transfers",
127
+ "Settings",
128
+ "Devices",
129
+ "Schedules",
130
+ "Notifications",
131
+ "Integrations",
132
+ "Storage",
133
+ "Diagnostics",
134
+ "Backups",
135
+ "About",
136
+ ].map((section) => (
137
+ <a
138
+ className="whitespace-nowrap rounded-md px-3 py-2 text-content-secondary text-sm hover:bg-intent-neutral-surface hover:text-content-primary"
139
+ href={`#${section.toLowerCase()}`}
140
+ key={section}
141
+ >
142
+ {section}
143
+ </a>
144
+ ))}
145
+ </Rail>
146
+
147
+ <Main>
148
+ <PageContent />
149
+ </Main>
150
+ </Shell>
151
+ ),
152
+ }
153
+
154
+ /**
155
+ * What an agent drives. The rail is a named landmark, so its
156
+ * links are reachable *through* it — the same trick `Card`'s
157
+ * heading plays for a bay's "Start" button.
158
+ */
159
+ export const Interactive: Story = {
160
+ args: { label: "Sections", landmark: "navigation" },
161
+ render: (railProps) => (
162
+ <Shell>
163
+ <Header
164
+ actions={<HeaderSchemeToggle />}
165
+ heading="Rip Deck"
166
+ />
167
+
168
+ <Rail {...railProps}>
169
+ <RailNavigation />
170
+ </Rail>
171
+
172
+ <Main>
173
+ <PageContent />
174
+ </Main>
175
+ </Shell>
176
+ ),
177
+ }