@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,267 @@
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 "./Shell.stories.tsx"
14
+
15
+ const {
16
+ Default,
17
+ Interactive,
18
+ Responsive,
19
+ WithBothRails,
20
+ WithStartRail,
21
+ } = composeStories(stories)
22
+
23
+ afterAll(async () => {
24
+ await setViewport(DESKTOP)
25
+ })
26
+
27
+ /**
28
+ * The page's own `<header>`, found structurally rather than by
29
+ * role — and the reason is worth writing down.
30
+ *
31
+ * `getByRole("banner")` returns **three** matches on any page with
32
+ * two `Card`s in it, because `Card` renders a `<header>` and
33
+ * testing-library's role mapping does not implement the HTML-AAM
34
+ * scoping rule that makes a `<header>` inside `<section>` generic
35
+ * rather than a banner. Chromium's real accessibility tree does
36
+ * implement it — which is what Playwright, a screen reader and an
37
+ * agent actually read, and why axe files no `landmark-unique` here
38
+ * — so this is a testing-library artefact and not a fault in the
39
+ * page.
40
+ *
41
+ * Asserting through the artefact would mean either shipping a page
42
+ * with no cards on it or teaching the shell to work around a
43
+ * query library. Neither is the component's problem, so the test
44
+ * takes the shell's first `<header>`, which is the one it renders.
45
+ */
46
+ const getPageHeader = (canvasElement: HTMLElement) => {
47
+ const header = canvasElement.querySelector("header")
48
+
49
+ if (!header) {
50
+ throw new Error("The shell rendered no <header>.")
51
+ }
52
+
53
+ return header
54
+ }
55
+
56
+ /**
57
+ * The gate, stated as the browser sees it.
58
+ *
59
+ * `scrollWidth > clientWidth` on the documentElement is *exactly*
60
+ * "this page scrolls sideways" — no proxy, no computed-style
61
+ * inference. It is asserted rather than eyeballed because the
62
+ * causes are all invisible in a screenshot taken at the wrong
63
+ * width: a grid track that ignored `min-width: 0`, a `100vw` that
64
+ * counted the scrollbar gutter, a sticky header whose inner
65
+ * container is wider than the viewport.
66
+ */
67
+ const expectNoHorizontalScroll = async () => {
68
+ const { documentElement } = document
69
+
70
+ await expect(
71
+ documentElement.scrollWidth,
72
+ ).toBeLessThanOrEqual(documentElement.clientWidth)
73
+ }
74
+
75
+ test("the shell does not scroll sideways at 390px, with content that tries", async () => {
76
+ await setViewport(PHONE)
77
+
78
+ const { canvas, canvasElement } =
79
+ await mountStory(Responsive)
80
+
81
+ // The fixture is only a fixture if it is really there: an
82
+ // unbroken 130-character path, plus a table wider than the
83
+ // phone. A regression that deleted the content would otherwise
84
+ // turn this into a green no-op.
85
+ await expect(
86
+ canvas.getByText(/Some-Very-Long-Show-Name-S01E01/),
87
+ ).toBeVisible()
88
+
89
+ await expect(
90
+ canvas.getByRole("table"),
91
+ ).toBeInTheDocument()
92
+
93
+ // The third fixture: a closed drawer parked at
94
+ // `translateX(110%)`. It has to be really parked past the
95
+ // inline edge, because a transform leaves the box in the
96
+ // document's scrollable overflow region — which is why this
97
+ // shape survives every `min-width: 0` in the tree, and why the
98
+ // assertion below is on `documentElement.scrollWidth` rather
99
+ // than on any element's bounding rect.
100
+ const parkedDrawer = canvasElement.querySelector(
101
+ "#parked-drawer",
102
+ ) as HTMLElement
103
+
104
+ await expect(
105
+ parkedDrawer.getBoundingClientRect().left,
106
+ ).toBeGreaterThan(PHONE.width)
107
+
108
+ const shellRoot =
109
+ canvasElement.firstElementChild as HTMLElement
110
+
111
+ // The drawer resolves against `Shell`, not the initial
112
+ // containing block — which is the only reason the clip below
113
+ // reaches it. `overflow-x: clip` clips descendants whose
114
+ // containing-block chain runs through the clipper, and an
115
+ // absolutely positioned box with no positioned ancestor has no
116
+ // such chain: it lands its overflow straight on
117
+ // `documentElement`, where `document.body.scrollWidth` still
118
+ // reads clean.
119
+ //
120
+ // Measured before `Shell` took `position: relative`:
121
+ // `shellScroll: 390`, `bodyScroll: 390`, `docScroll: 742`.
122
+ await expect(parkedDrawer.offsetParent).toBe(shellRoot)
123
+
124
+ await expect(
125
+ globalThis.getComputedStyle(shellRoot).overflowX,
126
+ ).toBe("clip")
127
+
128
+ await expectNoAxeViolations(canvasElement)
129
+ })
130
+
131
+ test("both rails and a full page do not scroll sideways at 390px either", async () => {
132
+ await setViewport(PHONE)
133
+
134
+ const { canvasElement } = await mountStory(WithBothRails)
135
+
136
+ await expectNoHorizontalScroll()
137
+
138
+ await expectNoAxeViolations(canvasElement)
139
+ })
140
+
141
+ /**
142
+ * The other half of the same property. A shell that never
143
+ * overflows because it clipped everything would pass the test
144
+ * above; this is the assertion that the frame is actually laid
145
+ * out at the width it was given.
146
+ */
147
+ test("the shell fills the viewport at desktop width without overflowing it", async () => {
148
+ await setViewport(DESKTOP)
149
+
150
+ await mountStory(WithBothRails)
151
+
152
+ await expectNoHorizontalScroll()
153
+ })
154
+
155
+ test("the shell is one banner, one main, and two uniquely-named rails", async () => {
156
+ const { canvas, canvasElement } =
157
+ await mountStory(WithBothRails)
158
+
159
+ await expect(getPageHeader(canvasElement).tagName).toBe(
160
+ "HEADER",
161
+ )
162
+
163
+ expectAgentDrivable(canvas, { role: "main" })
164
+
165
+ // Named, so they are two things rather than two of the same
166
+ // thing — which is the whole reason `label` is required.
167
+ expectAgentDrivable(canvas, {
168
+ name: "Sections",
169
+ role: "navigation",
170
+ })
171
+
172
+ expectAgentDrivable(canvas, {
173
+ name: "Job detail",
174
+ role: "complementary",
175
+ })
176
+
177
+ await expectNoAxeViolations(canvasElement)
178
+ })
179
+
180
+ /**
181
+ * The rail is in the document **once**, at every width.
182
+ *
183
+ * This is the assertion that would fail the `hidden`/`lg:hidden`
184
+ * implementation mux-magic and mail-sifter both ship — where every
185
+ * control exists twice in the DOM at every viewport, so a test has
186
+ * to be defensively scoped and an agent finds phantom controls.
187
+ * Four links narrow, four links wide.
188
+ */
189
+ test("collapsing the rail does not duplicate its contents", async () => {
190
+ await setViewport(PHONE)
191
+
192
+ const phone = await mountStory(WithStartRail)
193
+
194
+ await expect(
195
+ phone.canvas.getAllByRole("link", { name: "Queue" }),
196
+ ).toHaveLength(1)
197
+
198
+ await setViewport(DESKTOP)
199
+
200
+ const desktop = await mountStory(WithStartRail)
201
+
202
+ await expect(
203
+ desktop.canvas.getAllByRole("link", { name: "Queue" }),
204
+ ).toHaveLength(1)
205
+ })
206
+
207
+ /**
208
+ * The skip link — missing from all ten of the fleet's hand-rolled
209
+ * shells, which is why it is the shell's job rather than the
210
+ * app's.
211
+ *
212
+ * The assertion is that it **moves focus into `<main>`**, not that
213
+ * an anchor exists. An `href` pointing at a non-focusable target
214
+ * scrolls the page and leaves the next Tab in the header, which
215
+ * looks like a working skip link and is not.
216
+ */
217
+ test("the first Tab reaches a skip link that lands focus in main", async () => {
218
+ const { canvas, canvasElement } =
219
+ await mountStory(Interactive)
220
+
221
+ const skipLink = expectAgentDrivable(canvas, {
222
+ name: "Skip to main content",
223
+ role: "link",
224
+ })
225
+
226
+ const main = canvas.getByRole("main")
227
+
228
+ await expect(skipLink).toHaveAttribute(
229
+ "href",
230
+ `#${main.id}`,
231
+ )
232
+
233
+ await userEvent.click(skipLink)
234
+
235
+ await expect(main).toHaveFocus()
236
+
237
+ await expectNoAxeViolations(canvasElement)
238
+ })
239
+
240
+ test("header and main are capped at the same width", async () => {
241
+ await setViewport(DESKTOP)
242
+
243
+ const { canvas, canvasElement } =
244
+ await mountStory(Default)
245
+
246
+ // The inner rows, not the landmarks: the `<header>` and
247
+ // `<main>` are both full-bleed on purpose, and it is the row
248
+ // inside each that carries the cap.
249
+ const headerRow = getPageHeader(canvasElement)
250
+ .firstElementChild as HTMLElement
251
+
252
+ const mainRow = canvas.getByRole("main")
253
+ .firstElementChild as HTMLElement
254
+
255
+ // points-market ships these two disagreeing — its header row is
256
+ // capped at 80rem and its `<main>` is not capped at all — so on
257
+ // a wide monitor the title sits above content that starts to
258
+ // its left. One `contentWidth`, read from one context, is the
259
+ // fix.
260
+ await expect(
261
+ globalThis.getComputedStyle(headerRow).maxInlineSize,
262
+ ).toBe(globalThis.getComputedStyle(mainRow).maxInlineSize)
263
+
264
+ await expect(
265
+ headerRow.getBoundingClientRect().width,
266
+ ).toBe(mainRow.getBoundingClientRect().width)
267
+ })
@@ -0,0 +1,170 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import {
3
+ type ComponentPropsWithRef,
4
+ type ReactNode,
5
+ useMemo,
6
+ } from "react"
7
+
8
+ import { toClassName } from "../toClassName.ts"
9
+ import {
10
+ type ContentWidth,
11
+ DEFAULT_CONTENT_WIDTH,
12
+ } from "./contentWidth.ts"
13
+ import { ShellContext } from "./shellContext.ts"
14
+
15
+ export type ShellProps = ComponentPropsWithRef<"div"> & {
16
+ contentWidth?: ContentWidth
17
+ skipLinkLabel?: string
18
+ }
19
+
20
+ /**
21
+ * The page frame: one `Header`, up to two `Rail`s, one `Main`.
22
+ *
23
+ * **The largest duplicated surface in the fleet.** Ten of twelve UI
24
+ * repos hand-roll this, and three of them have a file literally
25
+ * called `AppShell.tsx` — mail-sifter's and points-market's headers
26
+ * are a **byte-identical** string
27
+ * (`sticky top-0 z-40 border-b border-border-subtle bg-surface-base/90 backdrop-blur-md`),
28
+ * arrived at independently, and board-games' is the same structure
29
+ * with `sticky` dropped. The remaining seven spell it as
30
+ * `PageChrome`, `Header`, `DashboardHeader`, `Layout` (twice),
31
+ * `TitleBar`, and inline in `App.tsx`.
32
+ *
33
+ * Three things fall out of owning it once, and each is a bug
34
+ * somewhere in that list today:
35
+ *
36
+ * - **A skip link.** All ten are missing one. A keyboard user
37
+ * tabs through the whole header on every page.
38
+ * - **One width, not two.** `Header` and `Main` read the same
39
+ * `contentWidth` through context — see `shellContext.ts` for
40
+ * the app where they disagree.
41
+ * - **Sticky that is actually sticky.** mux-magic's `PageHeader`
42
+ * is *described* as sticky and sets only `z-index`; it never
43
+ * sets `position: sticky`, so the z-index does nothing and the
44
+ * header scrolls away. `Header`'s `isSticky` sets both or
45
+ * neither.
46
+ *
47
+ * ## The layout is one grid, and the rails do not duplicate
48
+ *
49
+ * Three columns at `md` and up — `auto minmax(0, 1fr) auto`, so an
50
+ * absent rail is a **zero-width track** rather than a conditional
51
+ * subtree — collapsing to a single column below it, where the
52
+ * children simply stack in DOM order. The same `Rail` element is
53
+ * in the document at every width; nothing is rendered twice behind
54
+ * `hidden`/`lg:hidden`.
55
+ *
56
+ * That matters beyond tidiness. mux-magic's `PageHeader` renders
57
+ * its entire control set twice (lines 290–345 and 356–410) and
58
+ * mail-sifter's `TriageQueue` does the same, so every action
59
+ * exists twice in the DOM at every viewport: tests have to be
60
+ * defensively scoped with `within(toolbar)`, and any agent driving
61
+ * the page sees phantom controls it cannot click.
62
+ *
63
+ * `minmax(0, 1fr)` on the middle track is the horizontal-scroll
64
+ * fix, not a formality. A grid track's automatic minimum is
65
+ * `min-content`, so **one long unbroken string in `Main` widens
66
+ * the whole page** — which is the bug plex-channels' narrow view
67
+ * has today. rip-deck's `BayGrid` documents the same fix from the
68
+ * other end.
69
+ *
70
+ * There is no `gap`: an empty `auto` track is 0 wide, but a `gap`
71
+ * beside it is not, so a shell with no rails would carry two
72
+ * mystery gutters. `Rail` and `Main` bring their own padding.
73
+ *
74
+ * ## `overflow-x: clip` — the structural guarantee
75
+ *
76
+ * `minmax(0, 1fr)` fixes the tracks and `Main`'s `wrap-anywhere`
77
+ * fixes the text, but neither reaches the shape that actually
78
+ * broke plex-channels: **a closed panel parked at
79
+ * `transform: translateX(110%)`**. A transform does not take a box
80
+ * out of the document's scrollable overflow region — and neither
81
+ * does `visibility: hidden` — so an off-screen drawer that looks
82
+ * absent still makes the page scroll to it. It is a favourite
83
+ * fleet pattern and no amount of `min-width: 0` touches it.
84
+ *
85
+ * `clip`, not `hidden`, and the two are not interchangeable here:
86
+ * `overflow: hidden` creates a **scroll container**, which would
87
+ * become the sticky containing block for `Header` and freeze it in
88
+ * place. `overflow-x: clip` creates no scrollport, so `Header`
89
+ * keeps sticking to the viewport, and `overflow-y` stays `visible`
90
+ * — a pair that is legal for `clip` and illegal for `hidden`,
91
+ * which would compute the other axis to `auto`.
92
+ *
93
+ * The trade is deliberate: content wider than the frame is
94
+ * **clipped rather than reachable by scrolling sideways**, which is
95
+ * exactly the outcome asked for. Content that genuinely needs to
96
+ * be wider owns a scroll container of its own — the pattern
97
+ * `Main.mdx` documents for tables.
98
+ *
99
+ * ### `relative` is what makes the clip reach anything
100
+ *
101
+ * `overflow-x: clip` only clips descendants whose **containing
102
+ * block chain passes through the clipping element**. An absolutely
103
+ * positioned element with no positioned ancestor resolves against
104
+ * the *initial* containing block instead, so it sails straight
105
+ * past every `overflow` in the tree and lands its overflow on
106
+ * `documentElement` — where `document.body.scrollWidth` still
107
+ * reads clean and only `document.documentElement.scrollWidth`
108
+ * shows it.
109
+ *
110
+ * That is not hypothetical: it is measured. Before this
111
+ * `relative`, the parked-drawer fixture reported `shellScroll:
112
+ * 390`, `bodyScroll: 390` and `docScroll: 742` at a 390px
113
+ * viewport — the shell dutifully clipping a box it had no
114
+ * authority over.
115
+ *
116
+ * The obvious candidate to own it, `Main`'s content column, does
117
+ * **not**: `container-type: inline-size` computes `contain` to
118
+ * `none` in Chromium and establishes no containing block for
119
+ * absolute positioning, which is measured too — the drawer's
120
+ * `offsetParent` was `BODY`. So `Shell` takes the job, which is
121
+ * also the honest place for it: the frame is what an app's parked
122
+ * chrome is parked against.
123
+ */
124
+ export const Shell = ({
125
+ children,
126
+ className,
127
+ contentWidth = DEFAULT_CONTENT_WIDTH,
128
+ skipLinkLabel = "Skip to main content",
129
+ ...divProps
130
+ }: ShellProps): ReactNode => {
131
+ const mainId = useUniqueId()
132
+
133
+ const shellContext = useMemo(
134
+ () => ({ contentWidth, mainId }),
135
+ [contentWidth, mainId],
136
+ )
137
+
138
+ return (
139
+ <ShellContext.Provider value={shellContext}>
140
+ <div
141
+ {...divProps}
142
+ className={toClassName(
143
+ "relative grid min-h-dvh grid-cols-1 grid-rows-[auto_auto_1fr_auto] overflow-x-clip bg-surface-base text-content-primary md:grid-cols-[auto_minmax(0,1fr)_auto] md:grid-rows-[auto_1fr]",
144
+ className,
145
+ )}
146
+ >
147
+ {/*
148
+ Hidden by transform rather than by `sr-only`, because
149
+ `focus-visible:not-sr-only` and `focus-visible:fixed`
150
+ both write `position` and which one wins is decided by
151
+ Tailwind's internal property order, not by the order they
152
+ are written in. A translate is one property, and a
153
+ keyboard user's first Tab landing on a visible control is
154
+ the entire feature.
155
+
156
+ `--layer-toast` rather than `--layer-sticky`: it has to
157
+ out-rank the header it is offering to skip past.
158
+ */}
159
+ <a
160
+ className="fixed top-0 start-0 z-[var(--layer-toast)] m-3 -translate-y-24 rounded-md border border-border-subtle bg-surface-raised px-3 py-2 font-medium text-content-primary text-sm shadow-medium focus-visible:translate-y-0"
161
+ href={`#${mainId}`}
162
+ >
163
+ {skipLinkLabel}
164
+ </a>
165
+
166
+ {children}
167
+ </div>
168
+ </ShellContext.Provider>
169
+ )
170
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * How wide the content column is allowed to get — the one number
3
+ * the fleet has never agreed on.
4
+ *
5
+ * Ten repos hand-roll their page chrome and **no two of them cap
6
+ * the column at the same width**: `max-w-5xl` (board-games,
7
+ * portly), `max-w-6xl` and `max-w-3xl` mixed inside one app
8
+ * (mux-magic), `max-w-7xl` (gallery-downloader, spoolbuddy),
9
+ * `max-w-[90rem]` (mail-sifter), `80rem` (points-market), a raw
10
+ * `1400px` in plain CSS (plex-channels), a JS-computed one
11
+ * (rip-deck), and none at all in three more. It is the least
12
+ * standardized value in the entire fleet.
13
+ *
14
+ * Two shapes are accepted, because the fleet genuinely has two
15
+ * cases and collapsing them is what produced the mess:
16
+ *
17
+ * 1. A **`screen.*` step** — the ordinary page. Named, so a
18
+ * reviewer reads `contentWidth="lg"` rather than deciding
19
+ * whether `max-w-6xl` was chosen or inherited.
20
+ * 2. A **`${number}rem` literal** — the runtime-computed case.
21
+ * rip-deck's `contentMaxWidthRem(columns)` widens the cap as
22
+ * the grid gains columns (1 → 56rem, 2 → 72rem, 3 → 106rem),
23
+ * and no fixed scale can express a number that is decided
24
+ * after the viewport is measured.
25
+ *
26
+ * `"full"` is the third answer and it is deliberate rather than a
27
+ * hole: a wall of poster tiles wants the whole window, and an app
28
+ * that wants that today reaches the same place by *omitting* the
29
+ * cap, which is indistinguishable from having forgotten it.
30
+ *
31
+ * **`max-w-*` is not used and must not be.** Tailwind v4 owns
32
+ * `--container-*` for that scale at *different* sizes than our
33
+ * `screen.*`, which is the collision that renamed our
34
+ * container-query scale to `--cq-*`
35
+ * ([decision](../../../../docs/decisions/2026-07-29-container-query-scale-is-cq-not-container.md)).
36
+ * The cap is therefore an inline `max-inline-size` reading
37
+ * `var(--screen-*)`, so it stays a token rather than becoming a
38
+ * literal the moment it is written down.
39
+ */
40
+
41
+ import { screen } from "@charcuterie/tokens"
42
+
43
+ /** `sm` | `md` | `lg` | `xl` | `2xl`, from the token scale. */
44
+ export type ScreenStep = keyof typeof screen
45
+
46
+ export type ContentWidth =
47
+ | ScreenStep
48
+ | "full"
49
+ | `${number}rem`
50
+
51
+ /**
52
+ * Derived from the scale rather than restated, so a step added to
53
+ * `screen.*` is accepted here without a second edit — and one
54
+ * removed stops typechecking at every call site.
55
+ */
56
+ const SCREEN_STEPS = new Set<string>(Object.keys(screen))
57
+
58
+ /**
59
+ * The `max-inline-size` a `contentWidth` resolves to, or
60
+ * `undefined` for `"full"` — which is the absence of the property
61
+ * rather than a very large value, so nothing has to be un-set.
62
+ */
63
+ export const toMaxInlineSize = (
64
+ contentWidth: ContentWidth,
65
+ ): string | undefined => {
66
+ if (contentWidth === "full") {
67
+ return undefined
68
+ }
69
+
70
+ if (SCREEN_STEPS.has(contentWidth)) {
71
+ return `var(--screen-${contentWidth})`
72
+ }
73
+
74
+ return contentWidth
75
+ }
76
+
77
+ /**
78
+ * The cap an app gets without asking, and it is deliberately
79
+ * **narrower** than most of what the fleet ships.
80
+ *
81
+ * The owner's complaint, verbatim: *"All these apps seem to be
82
+ * really narrow with a very large max-width. By that I mean 1
83
+ * column, but waaaaaaay too wide."* A single column of prose or
84
+ * form fields at `max-w-7xl` is one line of text the eye has to
85
+ * track across a monitor. `screen.lg` is 64rem — wide enough for a
86
+ * two-up card grid, narrow enough to read — and an app whose
87
+ * content genuinely fills a window says so, once, in one place.
88
+ */
89
+ export const DEFAULT_CONTENT_WIDTH: ContentWidth = "lg"
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What `Shell` tells `Header` and `Main` so they cannot disagree.
3
+ *
4
+ * This context exists because of one specific bug, present in the
5
+ * fleet today: **points-market's header is width-constrained and
6
+ * its `<main>` is not.** `AppShell.tsx` wraps the header row in
7
+ * `.pm-constrained` (80rem) and then renders
8
+ * `<main className="px-4 py-6 sm:px-6">` with no cap at all, so on
9
+ * a wide monitor the page title sits above content that starts to
10
+ * its left and runs past its right. Nothing errors, both values
11
+ * are individually reasonable, and the two were simply written on
12
+ * different days.
13
+ *
14
+ * A width that has to be stated twice will eventually be stated
15
+ * twice differently. So it is stated **once**, on `Shell`, and the
16
+ * two elements that have to agree read it from here. Either can
17
+ * still override for a genuine reason — a full-bleed hero `Main`
18
+ * under a constrained `Header` is a real design — but that now
19
+ * takes a prop somebody wrote on purpose.
20
+ *
21
+ * `mainId` rides along for the same reason: the skip link's `href`
22
+ * and the `<main>` it lands on are one value, generated once.
23
+ */
24
+
25
+ import { createContext } from "react"
26
+
27
+ import type { ContentWidth } from "./contentWidth.ts"
28
+
29
+ export type ShellContextValue = {
30
+ contentWidth: ContentWidth
31
+ mainId: string
32
+ }
33
+
34
+ /**
35
+ * `null` rather than a default object, so `Header` and `Main` can
36
+ * tell "no `Shell` above me" from "a `Shell` that chose the
37
+ * defaults" — the first case has to generate its own `<main>` id,
38
+ * the second must not.
39
+ */
40
+ export const ShellContext =
41
+ createContext<ShellContextValue | null>(null)
@@ -90,7 +90,12 @@ export const SortableTableHeader = ({
90
90
  >
91
91
  <button
92
92
  className={toClassName(
93
- "flex w-full cursor-pointer items-center gap-1.5 bg-transparent px-3 py-2 text-start font-medium text-content-secondary text-xs uppercase tracking-wide transition-colors duration-(--duration-fast) ease-standard",
93
+ // `text-sm` since 2026-08-10. This was the single most
94
+ // fragile combination in the package: `text-xs` at 12px,
95
+ // uppercased and letterspaced, which strips the word shapes
96
+ // readers use at small sizes. A column header is the label
97
+ // for everything under it, so it is content.
98
+ "flex w-full cursor-pointer items-center gap-1.5 bg-transparent px-3 py-2 text-start font-medium text-content-secondary text-sm uppercase tracking-wide transition-colors duration-(--duration-fast) ease-standard",
94
99
  "hover:text-content-primary",
95
100
  FOCUS_RING_CLASS,
96
101
  )}
@@ -168,7 +168,11 @@ export const Tooltip = ({
168
168
  // 2.9:1. The `solid`/`on-solid` roles exist precisely to
169
169
  // be a guaranteed couple, and the contrast gate checks
170
170
  // them as one.
171
- "z-[var(--layer-tooltip)] max-w-xs rounded-md bg-intent-neutral-solid px-2 py-1 text-intent-neutral-on-solid text-xs shadow-medium",
171
+ // `text-sm`, not `text-xs`: a tooltip body is the only
172
+ // copy explaining the control it points at, so it is
173
+ // content rather than fine print. It was the whole
174
+ // tooltip at 12px before 2026-08-10.
175
+ "z-[var(--layer-tooltip)] max-w-xs rounded-md bg-intent-neutral-solid px-2 py-1 text-intent-neutral-on-solid text-sm shadow-medium",
172
176
  className,
173
177
  )}
174
178
  // Portalled to `document.body`, the same reversal as the
package/src/index.ts CHANGED
@@ -113,6 +113,8 @@ export type { FieldGroupProps } from "./Field/FieldGroup.tsx"
113
113
  export { FieldGroup } from "./Field/FieldGroup.tsx"
114
114
  export type { FileDropZoneProps } from "./FileDropZone/FileDropZone.tsx"
115
115
  export { FileDropZone } from "./FileDropZone/FileDropZone.tsx"
116
+ export type { HeaderProps } from "./Header/Header.tsx"
117
+ export { Header } from "./Header/Header.tsx"
116
118
  export type { IconButtonProps } from "./IconButton/IconButton.tsx"
117
119
  export { IconButton } from "./IconButton/IconButton.tsx"
118
120
  export type { IntentAppearance } from "./intentStyles.ts"
@@ -142,6 +144,8 @@ export type {
142
144
  LogViewerProps,
143
145
  } from "./LogViewer/LogViewer.tsx"
144
146
  export { LogViewer } from "./LogViewer/LogViewer.tsx"
147
+ export type { MainProps } from "./Main/Main.tsx"
148
+ export { Main } from "./Main/Main.tsx"
145
149
  export type {
146
150
  MediaTileProps,
147
151
  MediaTileRatio,
@@ -174,6 +178,12 @@ export type {
174
178
  RadioItem,
175
179
  } from "./RadioGroup/RadioGroup.tsx"
176
180
  export { RadioGroup } from "./RadioGroup/RadioGroup.tsx"
181
+ export type {
182
+ RailLandmark,
183
+ RailProps,
184
+ RailSide,
185
+ } from "./Rail/Rail.tsx"
186
+ export { Rail } from "./Rail/Rail.tsx"
177
187
  export { AnchorLink } from "./RouterLink/AnchorLink.tsx"
178
188
  export {
179
189
  RouterLinkProvider,
@@ -196,6 +206,12 @@ export type {
196
206
  SelectProps,
197
207
  } from "./Select/Select.tsx"
198
208
  export { Select } from "./Select/Select.tsx"
209
+ export type {
210
+ ContentWidth,
211
+ ScreenStep,
212
+ } from "./Shell/contentWidth.ts"
213
+ export type { ShellProps } from "./Shell/Shell.tsx"
214
+ export { Shell } from "./Shell/Shell.tsx"
199
215
  export type {
200
216
  SkeletonProps,
201
217
  SkeletonShape,