@charcuterie/ui 2.9.0 → 2.10.1

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 (56) hide show
  1. package/README.md +9 -0
  2. package/dist/Header/Header.d.ts +52 -0
  3. package/dist/Header/Header.d.ts.map +1 -0
  4. package/dist/Header/Header.js +55 -0
  5. package/dist/Header/Header.js.map +1 -0
  6. package/dist/Main/Main.d.ts +113 -0
  7. package/dist/Main/Main.d.ts.map +1 -0
  8. package/dist/Main/Main.js +123 -0
  9. package/dist/Main/Main.js.map +1 -0
  10. package/dist/Rail/Rail.d.ts +61 -0
  11. package/dist/Rail/Rail.d.ts.map +1 -0
  12. package/dist/Rail/Rail.js +64 -0
  13. package/dist/Rail/Rail.js.map +1 -0
  14. package/dist/Shell/Shell.d.ts +112 -0
  15. package/dist/Shell/Shell.d.ts.map +1 -0
  16. package/dist/Shell/Shell.js +116 -0
  17. package/dist/Shell/Shell.js.map +1 -0
  18. package/dist/Shell/contentWidth.d.ts +63 -0
  19. package/dist/Shell/contentWidth.d.ts.map +1 -0
  20. package/dist/Shell/contentWidth.js +74 -0
  21. package/dist/Shell/contentWidth.js.map +1 -0
  22. package/dist/Shell/shellContext.d.ts +36 -0
  23. package/dist/Shell/shellContext.d.ts.map +1 -0
  24. package/dist/Shell/shellContext.js +32 -0
  25. package/dist/Shell/shellContext.js.map +1 -0
  26. package/dist/index.d.ts +9 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +4 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/viewport.testHelpers.d.ts +31 -0
  31. package/dist/viewport.testHelpers.d.ts.map +1 -0
  32. package/dist/viewport.testHelpers.js +34 -0
  33. package/dist/viewport.testHelpers.js.map +1 -0
  34. package/package.json +2 -2
  35. package/src/Header/Header.mdx +72 -0
  36. package/src/Header/Header.stories.tsx +221 -0
  37. package/src/Header/Header.test.tsx +122 -0
  38. package/src/Header/Header.tsx +118 -0
  39. package/src/Main/Main.mdx +149 -0
  40. package/src/Main/Main.stories.tsx +164 -0
  41. package/src/Main/Main.test.tsx +167 -0
  42. package/src/Main/Main.tsx +174 -0
  43. package/src/Rail/Rail.mdx +75 -0
  44. package/src/Rail/Rail.stories.tsx +177 -0
  45. package/src/Rail/Rail.test.tsx +150 -0
  46. package/src/Rail/Rail.tsx +102 -0
  47. package/src/Shell/Shell.mdx +186 -0
  48. package/src/Shell/Shell.stories.tsx +185 -0
  49. package/src/Shell/Shell.test.tsx +267 -0
  50. package/src/Shell/Shell.tsx +170 -0
  51. package/src/Shell/contentWidth.ts +89 -0
  52. package/src/Shell/shellContext.ts +41 -0
  53. package/src/index.ts +16 -0
  54. package/src/shell.storyHelpers.tsx +258 -0
  55. package/src/sourceRules.test.ts +17 -1
  56. package/src/viewport.testHelpers.ts +47 -0
package/README.md CHANGED
@@ -38,6 +38,15 @@ The router is **injected** rather than depended on — `RouterLinkProvider` at t
38
38
  with `@charcuterie/ui/react-router` as an optional subpath adapter and a plain `<a>` when
39
39
  nothing is provided. Recipe: **Guides/Routing** in Storybook.
40
40
 
41
+ **The unified app shell adds four**: `Shell`, `Header`, `Rail`, `Main` — the fleet's largest
42
+ single duplication, where ten of twelve UI repos hand-roll the page chrome and three of them
43
+ have a file named `AppShell.tsx` (two of those headers are a *byte-identical* class string,
44
+ arrived at independently). One `contentWidth` on `Shell` feeds both the header row and the
45
+ content column, `isSticky` writes `position` and `z-index` together, and a `Rail` collapses
46
+ by restyling rather than by rendering itself twice.
47
+ [Decision](../../docs/decisions/2026-08-10-the-app-shell-is-shell-header-rail-main.md);
48
+ `Shell`'s docs page carries three copy-wholesale templates.
49
+
41
50
  ## Install and wire
42
51
 
43
52
  ```ts
@@ -0,0 +1,52 @@
1
+ import { type ComponentPropsWithRef, type ReactNode } from "react";
2
+ import { type ContentWidth } from "../Shell/contentWidth.ts";
3
+ export type HeaderProps = ComponentPropsWithRef<"header"> & {
4
+ actions?: ReactNode;
5
+ contentWidth?: ContentWidth;
6
+ heading?: string;
7
+ headingLevel?: 1 | 2;
8
+ isSticky?: boolean;
9
+ };
10
+ /**
11
+ * The page header — the `<header>` landmark, the app's name, and
12
+ * the controls that belong to the frame rather than to the page.
13
+ *
14
+ * **`isSticky` defaults to true, and it sets `position` as well as
15
+ * `z-index`.** That pairing is the whole point. mux-magic's
16
+ * `PageHeader` is documented as a sticky header and sets only
17
+ * `z-index` (`PageHeader.tsx:173`) — it is a flex-column shell that
18
+ * scrolls away like any other block, and the z-index it carries
19
+ * has nothing to stack against. The bug is invisible: the CSS is
20
+ * valid, the intent is stated in a comment, and nobody scrolls a
21
+ * component in a review. Here the two cannot come apart, because
22
+ * one boolean writes both.
23
+ *
24
+ * The z-index is `--layer-sticky` from the token scale, never a
25
+ * hand-picked number. The fleet's headers currently sit at `z-40`
26
+ * (mail-sifter, points-market), `z-50` (rip-deck), `z-10`
27
+ * (spoolbuddy) and `z-[100]` (mux-magic), with no ordering
28
+ * guarantee between a header and anything else — which is exactly
29
+ * what the scale exists to settle: `sticky` (100) is below
30
+ * `dropdown` (200), `modal` (400) and `toast` (500), so a menu
31
+ * opened *from* the header paints over it.
32
+ *
33
+ * ## The width comes from `Shell`
34
+ *
35
+ * The `<header>` itself is full-bleed — the border and the
36
+ * translucent fill run edge to edge — and only its **inner row** is
37
+ * capped, which is what makes a constrained header look right on an
38
+ * ultrawide. That cap is `Shell`'s `contentWidth`, not a second
39
+ * opinion: see `shellContext.ts` for the shipped app whose header
40
+ * and `<main>` disagree by 80rem.
41
+ *
42
+ * ## No invented colours
43
+ *
44
+ * `bg-surface-raised/85` + `backdrop-blur-md` + `border-border-subtle`.
45
+ * The fleet's copy of this line is `bg-surface-base/90`, which puts
46
+ * the header at the *same* colour as the page behind it and leans
47
+ * entirely on the blur to separate them — so it vanishes against a
48
+ * page that is not scrolled. `raised` means *more separated from
49
+ * base*, which is the role this element actually wants.
50
+ */
51
+ export declare const Header: ({ actions, children, className, contentWidth, heading, headingLevel, isSticky, ...headerProps }: HeaderProps) => ReactNode;
52
+ //# sourceMappingURL=Header.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Header.d.ts","sourceRoot":"","sources":["../../src/Header/Header.tsx"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EAEf,MAAM,OAAO,CAAA;AAEd,OAAO,EACL,KAAK,YAAY,EAGlB,MAAM,0BAA0B,CAAA;AAIjC,MAAM,MAAM,WAAW,GACrB,qBAAqB,CAAC,QAAQ,CAAC,GAAG;IAChC,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,YAAY,CAAC,EAAE,YAAY,CAAA;IAC3B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,YAAY,CAAC,EAAE,CAAC,GAAG,CAAC,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB,CAAA;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,eAAO,MAAM,MAAM,GAAI,iGASpB,WAAW,KAAG,SA4ChB,CAAA"}
@@ -0,0 +1,55 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext, } from "react";
3
+ import { DEFAULT_CONTENT_WIDTH, toMaxInlineSize, } from "../Shell/contentWidth.js";
4
+ import { ShellContext } from "../Shell/shellContext.js";
5
+ import { toClassName } from "../toClassName.js";
6
+ /**
7
+ * The page header — the `<header>` landmark, the app's name, and
8
+ * the controls that belong to the frame rather than to the page.
9
+ *
10
+ * **`isSticky` defaults to true, and it sets `position` as well as
11
+ * `z-index`.** That pairing is the whole point. mux-magic's
12
+ * `PageHeader` is documented as a sticky header and sets only
13
+ * `z-index` (`PageHeader.tsx:173`) — it is a flex-column shell that
14
+ * scrolls away like any other block, and the z-index it carries
15
+ * has nothing to stack against. The bug is invisible: the CSS is
16
+ * valid, the intent is stated in a comment, and nobody scrolls a
17
+ * component in a review. Here the two cannot come apart, because
18
+ * one boolean writes both.
19
+ *
20
+ * The z-index is `--layer-sticky` from the token scale, never a
21
+ * hand-picked number. The fleet's headers currently sit at `z-40`
22
+ * (mail-sifter, points-market), `z-50` (rip-deck), `z-10`
23
+ * (spoolbuddy) and `z-[100]` (mux-magic), with no ordering
24
+ * guarantee between a header and anything else — which is exactly
25
+ * what the scale exists to settle: `sticky` (100) is below
26
+ * `dropdown` (200), `modal` (400) and `toast` (500), so a menu
27
+ * opened *from* the header paints over it.
28
+ *
29
+ * ## The width comes from `Shell`
30
+ *
31
+ * The `<header>` itself is full-bleed — the border and the
32
+ * translucent fill run edge to edge — and only its **inner row** is
33
+ * capped, which is what makes a constrained header look right on an
34
+ * ultrawide. That cap is `Shell`'s `contentWidth`, not a second
35
+ * opinion: see `shellContext.ts` for the shipped app whose header
36
+ * and `<main>` disagree by 80rem.
37
+ *
38
+ * ## No invented colours
39
+ *
40
+ * `bg-surface-raised/85` + `backdrop-blur-md` + `border-border-subtle`.
41
+ * The fleet's copy of this line is `bg-surface-base/90`, which puts
42
+ * the header at the *same* colour as the page behind it and leans
43
+ * entirely on the blur to separate them — so it vanishes against a
44
+ * page that is not scrolled. `raised` means *more separated from
45
+ * base*, which is the role this element actually wants.
46
+ */
47
+ export const Header = ({ actions, children, className, contentWidth, heading, headingLevel = 1, isSticky = true, ...headerProps }) => {
48
+ const shell = useContext(ShellContext);
49
+ const Heading = `h${headingLevel}`;
50
+ const maxInlineSize = toMaxInlineSize(contentWidth ??
51
+ shell?.contentWidth ??
52
+ DEFAULT_CONTENT_WIDTH);
53
+ return (_jsx("header", { ...headerProps, className: toClassName("col-span-full row-start-1 border-border-subtle border-b bg-surface-raised/85 backdrop-blur-md", isSticky && "sticky top-0 z-[var(--layer-sticky)]", className), children: _jsxs("div", { className: "mx-auto flex w-full min-w-0 flex-wrap items-center gap-3 px-4 py-3 sm:px-6", style: { maxInlineSize }, children: [heading ? (_jsx(Heading, { className: "min-w-0 truncate font-semibold text-lg leading-tight", children: heading })) : null, children ? (_jsx("div", { className: "flex min-w-0 flex-1 items-center gap-2", children: children })) : null, actions ? (_jsx("div", { className: "ms-auto flex shrink-0 items-center gap-2", children: actions })) : null] }) }));
54
+ };
55
+ //# sourceMappingURL=Header.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Header.js","sourceRoot":"","sources":["../../src/Header/Header.tsx"],"names":[],"mappings":";AAAA,OAAO,EAGL,UAAU,GACX,MAAM,OAAO,CAAA;AAEd,OAAO,EAEL,qBAAqB,EACrB,eAAe,GAChB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAW/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,OAAO,EACP,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,OAAO,EACP,YAAY,GAAG,CAAC,EAChB,QAAQ,GAAG,IAAI,EACf,GAAG,WAAW,EACF,EAAa,EAAE;IAC3B,MAAM,KAAK,GAAG,UAAU,CAAC,YAAY,CAAC,CAAA;IAEtC,MAAM,OAAO,GAAG,IAAI,YAAY,EAAW,CAAA;IAE3C,MAAM,aAAa,GAAG,eAAe,CACnC,YAAY;QACV,KAAK,EAAE,YAAY;QACnB,qBAAqB,CACxB,CAAA;IAED,OAAO,CACL,oBACM,WAAW,EACf,SAAS,EAAE,WAAW,CACpB,+FAA+F,EAC/F,QAAQ,IAAI,sCAAsC,EAClD,SAAS,CACV,YAED,eACE,SAAS,EAAC,4EAA4E,EACtF,KAAK,EAAE,EAAE,aAAa,EAAE,aAEvB,OAAO,CAAC,CAAC,CAAC,CACT,KAAC,OAAO,IAAC,SAAS,EAAC,sDAAsD,YACtE,OAAO,GACA,CACX,CAAC,CAAC,CAAC,IAAI,EAEP,QAAQ,CAAC,CAAC,CAAC,CACV,cAAK,SAAS,EAAC,wCAAwC,YACpD,QAAQ,GACL,CACP,CAAC,CAAC,CAAC,IAAI,EAEP,OAAO,CAAC,CAAC,CAAC,CACT,cAAK,SAAS,EAAC,0CAA0C,YACtD,OAAO,GACJ,CACP,CAAC,CAAC,CAAC,IAAI,IACJ,GACC,CACV,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,113 @@
1
+ import { type ComponentPropsWithRef, type ReactNode } from "react";
2
+ import { type ContentWidth } from "../Shell/contentWidth.ts";
3
+ export type MainProps = ComponentPropsWithRef<"main"> & {
4
+ contentWidth?: ContentWidth;
5
+ };
6
+ /**
7
+ * The `<main>` landmark and the content column.
8
+ *
9
+ * ## The width is the whole component
10
+ *
11
+ * The owner's complaint, verbatim:
12
+ *
13
+ * > "All these apps seem to be really narrow with a very large
14
+ * > max-width. By that I mean 1 column, but waaaaaaay too wide. In
15
+ * > almost all cases, what I really want is something like Rip-Deck
16
+ * > where the app has a narrower main column but the wrapping grids
17
+ * > are all full-width in most of these apps only when you have too
18
+ * > many items."
19
+ *
20
+ * That is two requirements, and they pull opposite ways: a
21
+ * **narrow** column by default, which **widens** when the content
22
+ * is a grid with enough items to justify it. A fixed `max-w-7xl`
23
+ * answers neither — it is too wide for the one-column case, which
24
+ * is the complaint, and it is a hard ceiling on the many-items
25
+ * case.
26
+ *
27
+ * So the cap is a *value*, not a constant, and it arrives from
28
+ * three places in priority order: this component's own
29
+ * `contentWidth`, then `Shell`'s (the usual answer, so the header
30
+ * matches), then `screen.lg`.
31
+ *
32
+ * ### How it composes with the adaptive-columns hook
33
+ *
34
+ * rip-deck's `useLayoutColumns` — being lifted into this library
35
+ * separately — decides a column count from the viewport and folds
36
+ * it into a page cap with `contentMaxWidthRem(columns)`: 1 column →
37
+ * 56rem, 2 → 72rem, 3 → 106rem. It returns a **number of rem**, and
38
+ * `ContentWidth` accepts `` `${number}rem` `` for exactly this:
39
+ *
40
+ * ```tsx
41
+ * const { columns } = useLayoutColumns({ cardCount: bays.length })
42
+ *
43
+ * <Shell contentWidth={`${contentMaxWidthRem(columns)}rem`}>
44
+ * <Header heading="Rip Deck" />
45
+ * <Main>
46
+ * <BayGrid columns={columns} />
47
+ * </Main>
48
+ * </Shell>
49
+ * ```
50
+ *
51
+ * The seam is deliberately one prop and no hook import: this
52
+ * package must not depend on a layout hook to lay out a page, and
53
+ * the hook must not know that a `Shell` exists. Putting the value
54
+ * on `Shell` rather than on `Main` is what keeps the header's cap
55
+ * in step with the column's as the count changes.
56
+ *
57
+ * ## `@container`, and what `contain: inline-size` costs
58
+ *
59
+ * The content column establishes a container query, so an app's
60
+ * grids inside it respond to **the column's** width rather than
61
+ * the window's — a poster grid beside an open rail and the same
62
+ * grid full-bleed are the pair a media query cannot tell apart,
63
+ * and getting it wrong at intermediate widths is what the fleet's
64
+ * grids do today.
65
+ *
66
+ * `container-type: inline-size` implies `contain: layout style
67
+ * inline-size`, and both halves have consequences worth stating:
68
+ *
69
+ * - **The element may not be sized by its own contents.** Here
70
+ * that is free and in fact wanted: the column has a definite
71
+ * inline size from its parent — itself a grid item with
72
+ * `minmax(0, 1fr)` — so wide content can no longer widen it,
73
+ * which is half of why the page does not scroll sideways.
74
+ * - **`contain: layout` makes it a containing block for
75
+ * `position: fixed` descendants.** An app rendering a fixed
76
+ * element *inside* `Main` gets it positioned against the
77
+ * column, not the viewport. Every overlay in this library
78
+ * portals to `document.body` and is therefore unaffected
79
+ * ([decision](../../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md));
80
+ * an app's own fixed chrome belongs in `Shell`, beside `Main`
81
+ * rather than in it.
82
+ *
83
+ * ## Two more things it does, both one line
84
+ *
85
+ * `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
86
+ * merely the reading cursor — without it, Safari and Firefox scroll
87
+ * to `<main>` and leave the next Tab back in the header.
88
+ *
89
+ * `wrap-anywhere` so an unbroken string — a filesystem path, a
90
+ * hash, a URL — wraps instead of widening the page.
91
+ *
92
+ * **`anywhere`, not `break-word`, and the difference is the whole
93
+ * point.** Both break a long word onto the next line, so they look
94
+ * identical in a screenshot. Only `anywhere` also shrinks the
95
+ * element's **min-content size** — which is what a flex or grid
96
+ * item's automatic minimum resolves against, and therefore what
97
+ * decides whether the string can push its own column wider. With
98
+ * `break-word` the ink wraps while the intrinsic contribution
99
+ * stays the full length of the token, so the layout above it can
100
+ * still be forced open. This is the second of the two overflow
101
+ * sources found in plex-channels, and the nastier one: it produces
102
+ * **no overflowing element box at all**, so every
103
+ * `getBoundingClientRect()` in a test reads clean while the page
104
+ * scrolls sideways.
105
+ *
106
+ * It is the cheap half of the horizontal-scroll fix; the other
107
+ * halves are `minmax(0, 1fr)` and `overflow-x: clip` in `Shell`. A
108
+ * `<table>` is the one thing none of them can save: tables do not
109
+ * wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
110
+ * `Main.mdx` shows.
111
+ */
112
+ export declare const Main: ({ children, className, contentWidth, id, ...mainProps }: MainProps) => ReactNode;
113
+ //# sourceMappingURL=Main.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Main.d.ts","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EAEf,MAAM,OAAO,CAAA;AAEd,OAAO,EACL,KAAK,YAAY,EAGlB,MAAM,0BAA0B,CAAA;AAIjC,MAAM,MAAM,SAAS,GAAG,qBAAqB,CAAC,MAAM,CAAC,GAAG;IACtD,YAAY,CAAC,EAAE,YAAY,CAAA;CAC5B,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AACH,eAAO,MAAM,IAAI,GAAI,yDAMlB,SAAS,KAAG,SA0Cd,CAAA"}
@@ -0,0 +1,123 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useUniqueId } from "@charcuterie/logic";
3
+ import { useContext, } from "react";
4
+ import { DEFAULT_CONTENT_WIDTH, toMaxInlineSize, } from "../Shell/contentWidth.js";
5
+ import { ShellContext } from "../Shell/shellContext.js";
6
+ import { toClassName } from "../toClassName.js";
7
+ /**
8
+ * The `<main>` landmark and the content column.
9
+ *
10
+ * ## The width is the whole component
11
+ *
12
+ * The owner's complaint, verbatim:
13
+ *
14
+ * > "All these apps seem to be really narrow with a very large
15
+ * > max-width. By that I mean 1 column, but waaaaaaay too wide. In
16
+ * > almost all cases, what I really want is something like Rip-Deck
17
+ * > where the app has a narrower main column but the wrapping grids
18
+ * > are all full-width in most of these apps only when you have too
19
+ * > many items."
20
+ *
21
+ * That is two requirements, and they pull opposite ways: a
22
+ * **narrow** column by default, which **widens** when the content
23
+ * is a grid with enough items to justify it. A fixed `max-w-7xl`
24
+ * answers neither — it is too wide for the one-column case, which
25
+ * is the complaint, and it is a hard ceiling on the many-items
26
+ * case.
27
+ *
28
+ * So the cap is a *value*, not a constant, and it arrives from
29
+ * three places in priority order: this component's own
30
+ * `contentWidth`, then `Shell`'s (the usual answer, so the header
31
+ * matches), then `screen.lg`.
32
+ *
33
+ * ### How it composes with the adaptive-columns hook
34
+ *
35
+ * rip-deck's `useLayoutColumns` — being lifted into this library
36
+ * separately — decides a column count from the viewport and folds
37
+ * it into a page cap with `contentMaxWidthRem(columns)`: 1 column →
38
+ * 56rem, 2 → 72rem, 3 → 106rem. It returns a **number of rem**, and
39
+ * `ContentWidth` accepts `` `${number}rem` `` for exactly this:
40
+ *
41
+ * ```tsx
42
+ * const { columns } = useLayoutColumns({ cardCount: bays.length })
43
+ *
44
+ * <Shell contentWidth={`${contentMaxWidthRem(columns)}rem`}>
45
+ * <Header heading="Rip Deck" />
46
+ * <Main>
47
+ * <BayGrid columns={columns} />
48
+ * </Main>
49
+ * </Shell>
50
+ * ```
51
+ *
52
+ * The seam is deliberately one prop and no hook import: this
53
+ * package must not depend on a layout hook to lay out a page, and
54
+ * the hook must not know that a `Shell` exists. Putting the value
55
+ * on `Shell` rather than on `Main` is what keeps the header's cap
56
+ * in step with the column's as the count changes.
57
+ *
58
+ * ## `@container`, and what `contain: inline-size` costs
59
+ *
60
+ * The content column establishes a container query, so an app's
61
+ * grids inside it respond to **the column's** width rather than
62
+ * the window's — a poster grid beside an open rail and the same
63
+ * grid full-bleed are the pair a media query cannot tell apart,
64
+ * and getting it wrong at intermediate widths is what the fleet's
65
+ * grids do today.
66
+ *
67
+ * `container-type: inline-size` implies `contain: layout style
68
+ * inline-size`, and both halves have consequences worth stating:
69
+ *
70
+ * - **The element may not be sized by its own contents.** Here
71
+ * that is free and in fact wanted: the column has a definite
72
+ * inline size from its parent — itself a grid item with
73
+ * `minmax(0, 1fr)` — so wide content can no longer widen it,
74
+ * which is half of why the page does not scroll sideways.
75
+ * - **`contain: layout` makes it a containing block for
76
+ * `position: fixed` descendants.** An app rendering a fixed
77
+ * element *inside* `Main` gets it positioned against the
78
+ * column, not the viewport. Every overlay in this library
79
+ * portals to `document.body` and is therefore unaffected
80
+ * ([decision](../../../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md));
81
+ * an app's own fixed chrome belongs in `Shell`, beside `Main`
82
+ * rather than in it.
83
+ *
84
+ * ## Two more things it does, both one line
85
+ *
86
+ * `tabIndex={-1}` so `Shell`'s skip link moves **focus** and not
87
+ * merely the reading cursor — without it, Safari and Firefox scroll
88
+ * to `<main>` and leave the next Tab back in the header.
89
+ *
90
+ * `wrap-anywhere` so an unbroken string — a filesystem path, a
91
+ * hash, a URL — wraps instead of widening the page.
92
+ *
93
+ * **`anywhere`, not `break-word`, and the difference is the whole
94
+ * point.** Both break a long word onto the next line, so they look
95
+ * identical in a screenshot. Only `anywhere` also shrinks the
96
+ * element's **min-content size** — which is what a flex or grid
97
+ * item's automatic minimum resolves against, and therefore what
98
+ * decides whether the string can push its own column wider. With
99
+ * `break-word` the ink wraps while the intrinsic contribution
100
+ * stays the full length of the token, so the layout above it can
101
+ * still be forced open. This is the second of the two overflow
102
+ * sources found in plex-channels, and the nastier one: it produces
103
+ * **no overflowing element box at all**, so every
104
+ * `getBoundingClientRect()` in a test reads clean while the page
105
+ * scrolls sideways.
106
+ *
107
+ * It is the cheap half of the horizontal-scroll fix; the other
108
+ * halves are `minmax(0, 1fr)` and `overflow-x: clip` in `Shell`. A
109
+ * `<table>` is the one thing none of them can save: tables do not
110
+ * wrap, so a wide one goes in an `overflow-x-auto` wrapper, which
111
+ * `Main.mdx` shows.
112
+ */
113
+ export const Main = ({ children, className, contentWidth, id, ...mainProps }) => {
114
+ const shell = useContext(ShellContext);
115
+ // Always called, never conditionally: a `Main` outside a `Shell`
116
+ // still needs an id an app can point its own skip link at.
117
+ const fallbackId = useUniqueId();
118
+ const maxInlineSize = toMaxInlineSize(contentWidth ??
119
+ shell?.contentWidth ??
120
+ DEFAULT_CONTENT_WIDTH);
121
+ return (_jsx("main", { ...mainProps, className: toClassName("col-start-1 row-start-3 min-w-0 md:col-start-2 md:row-start-2", className), id: id ?? shell?.mainId ?? fallbackId, tabIndex: -1, children: _jsx("div", { className: "@container mx-auto flex w-full min-w-0 flex-col gap-6 wrap-anywhere px-4 py-6 sm:px-6", style: { maxInlineSize }, children: children }) }));
122
+ };
123
+ //# sourceMappingURL=Main.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Main.js","sourceRoot":"","sources":["../../src/Main/Main.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAChD,OAAO,EAGL,UAAU,GACX,MAAM,OAAO,CAAA;AAEd,OAAO,EAEL,qBAAqB,EACrB,eAAe,GAChB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAM/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,EACnB,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,EAAE,EACF,GAAG,SAAS,EACF,EAAa,EAAE;IACzB,MAAM,KAAK,GAAG,UAAU,CAAC,YAAY,CAAC,CAAA;IAEtC,iEAAiE;IACjE,2DAA2D;IAC3D,MAAM,UAAU,GAAG,WAAW,EAAE,CAAA;IAEhC,MAAM,aAAa,GAAG,eAAe,CACnC,YAAY;QACV,KAAK,EAAE,YAAY;QACnB,qBAAqB,CACxB,CAAA;IAED,OAAO,CACL,kBACM,SAAS,EACb,SAAS,EAAE,WAAW,CACpB,+DAA+D,EAC/D,SAAS,CACV,EACD,EAAE,EAAE,EAAE,IAAI,KAAK,EAAE,MAAM,IAAI,UAAU,EACrC,QAAQ,EAAE,CAAC,CAAC,YAaZ,cACE,SAAS,EAAC,uFAAuF,EACjG,KAAK,EAAE,EAAE,aAAa,EAAE,YAEvB,QAAQ,GACL,GACD,CACR,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,61 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from "react";
2
+ export type RailLandmark = "complementary" | "navigation";
3
+ export type RailSide = "end" | "start";
4
+ export type RailProps = ComponentPropsWithRef<"aside"> & {
5
+ label: string;
6
+ landmark?: RailLandmark;
7
+ side?: RailSide;
8
+ };
9
+ /**
10
+ * A side rail — the nav column, the filter panel, the drive list.
11
+ *
12
+ * `side` places it, `landmark` decides what it *is*, and `label`
13
+ * is required for a reason: two unnamed rails in one page are two
14
+ * landmarks of the same type with no way to tell them apart, which
15
+ * is axe's `landmark-unique` and, more to the point, is a screen
16
+ * reader announcing "complementary" twice. It is also what makes
17
+ * the rail scopeable — `getByRole("navigation", { name: "Sections" })`
18
+ * disambiguates a "Settings" link that also exists in the header.
19
+ *
20
+ * ## It collapses without duplicating itself
21
+ *
22
+ * Below `md` the shell is one column and the rail becomes a
23
+ * horizontally-scrolling strip above (`side="start"`) or below
24
+ * (`side="end"`) the content. Above it, the rail is a fixed-width
25
+ * column in the grid.
26
+ *
27
+ * **The same element, restyled — not a second copy.** The fleet's
28
+ * habit is to render the whole thing twice behind `hidden` and
29
+ * `lg:hidden`: mux-magic's `PageHeader` does it across two ~55-line
30
+ * blocks and mail-sifter's `TriageQueue` does it again. The cost is
31
+ * not bytes, it is that every control exists **twice in the DOM at
32
+ * every viewport** — so tests must be defensively scoped, an agent
33
+ * driving the page finds two of each button and cannot tell which
34
+ * is the visible one, and the two copies drift.
35
+ *
36
+ * A media query gets the whole job done here because only the
37
+ * rail's *position* changes, never its contents. plex-channels'
38
+ * `App.tsx` shows the pattern for when that is not true — a
39
+ * `useMediaQuery` over `matchMedia` through `useSyncExternalStore`,
40
+ * mounting exactly one instance and relocating it — and it is
41
+ * strictly more machinery for the same guarantee, so it is not what
42
+ * this component uses.
43
+ *
44
+ * The `md` breakpoint is 48rem, which is `screen.md`: Tailwind v4's
45
+ * default `--breakpoint-*` scale and this repo's `screen.*` scale
46
+ * carry the same five values, so `md:` and the token agree by
47
+ * construction rather than by coincidence maintained by hand.
48
+ *
49
+ * ## The narrow strip scrolls itself
50
+ *
51
+ * `overflow-x-auto` on the rail is what keeps a twelve-item nav
52
+ * from widening the *page* at 390px: the overflow is contained in
53
+ * an element that scrolls, which is the sanctioned answer for
54
+ * content that genuinely cannot wrap. `min-w-0` is on it for the
55
+ * same reason `Shell`'s middle track is `minmax(0, 1fr)` — a flex
56
+ * or grid child's automatic minimum is its content, and one long
57
+ * unbroken string otherwise pushes the whole layout out of the
58
+ * viewport.
59
+ */
60
+ export declare const Rail: ({ children, className, label, landmark, side, ...railProps }: RailProps) => ReactNode;
61
+ //# sourceMappingURL=Rail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Rail.d.ts","sourceRoot":"","sources":["../../src/Rail/Rail.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAId,MAAM,MAAM,YAAY,GAAG,eAAe,GAAG,YAAY,CAAA;AAEzD,MAAM,MAAM,QAAQ,GAAG,KAAK,GAAG,OAAO,CAAA;AAEtC,MAAM,MAAM,SAAS,GAAG,qBAAqB,CAAC,OAAO,CAAC,GAAG;IACvD,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,YAAY,CAAA;IACvB,IAAI,CAAC,EAAE,QAAQ,CAAA;CAChB,CAAA;AAUD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AACH,eAAO,MAAM,IAAI,GAAI,8DAOlB,SAAS,KAAG,SAkBd,CAAA"}
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { toClassName } from "../toClassName.js";
3
+ const LANDMARK_ELEMENT = {
4
+ complementary: "aside",
5
+ navigation: "nav",
6
+ };
7
+ /**
8
+ * A side rail — the nav column, the filter panel, the drive list.
9
+ *
10
+ * `side` places it, `landmark` decides what it *is*, and `label`
11
+ * is required for a reason: two unnamed rails in one page are two
12
+ * landmarks of the same type with no way to tell them apart, which
13
+ * is axe's `landmark-unique` and, more to the point, is a screen
14
+ * reader announcing "complementary" twice. It is also what makes
15
+ * the rail scopeable — `getByRole("navigation", { name: "Sections" })`
16
+ * disambiguates a "Settings" link that also exists in the header.
17
+ *
18
+ * ## It collapses without duplicating itself
19
+ *
20
+ * Below `md` the shell is one column and the rail becomes a
21
+ * horizontally-scrolling strip above (`side="start"`) or below
22
+ * (`side="end"`) the content. Above it, the rail is a fixed-width
23
+ * column in the grid.
24
+ *
25
+ * **The same element, restyled — not a second copy.** The fleet's
26
+ * habit is to render the whole thing twice behind `hidden` and
27
+ * `lg:hidden`: mux-magic's `PageHeader` does it across two ~55-line
28
+ * blocks and mail-sifter's `TriageQueue` does it again. The cost is
29
+ * not bytes, it is that every control exists **twice in the DOM at
30
+ * every viewport** — so tests must be defensively scoped, an agent
31
+ * driving the page finds two of each button and cannot tell which
32
+ * is the visible one, and the two copies drift.
33
+ *
34
+ * A media query gets the whole job done here because only the
35
+ * rail's *position* changes, never its contents. plex-channels'
36
+ * `App.tsx` shows the pattern for when that is not true — a
37
+ * `useMediaQuery` over `matchMedia` through `useSyncExternalStore`,
38
+ * mounting exactly one instance and relocating it — and it is
39
+ * strictly more machinery for the same guarantee, so it is not what
40
+ * this component uses.
41
+ *
42
+ * The `md` breakpoint is 48rem, which is `screen.md`: Tailwind v4's
43
+ * default `--breakpoint-*` scale and this repo's `screen.*` scale
44
+ * carry the same five values, so `md:` and the token agree by
45
+ * construction rather than by coincidence maintained by hand.
46
+ *
47
+ * ## The narrow strip scrolls itself
48
+ *
49
+ * `overflow-x-auto` on the rail is what keeps a twelve-item nav
50
+ * from widening the *page* at 390px: the overflow is contained in
51
+ * an element that scrolls, which is the sanctioned answer for
52
+ * content that genuinely cannot wrap. `min-w-0` is on it for the
53
+ * same reason `Shell`'s middle track is `minmax(0, 1fr)` — a flex
54
+ * or grid child's automatic minimum is its content, and one long
55
+ * unbroken string otherwise pushes the whole layout out of the
56
+ * viewport.
57
+ */
58
+ export const Rail = ({ children, className, label, landmark = "complementary", side = "start", ...railProps }) => {
59
+ const Element = LANDMARK_ELEMENT[landmark];
60
+ return (_jsx(Element, { ...railProps, "aria-label": label, className: toClassName("flex min-w-0 gap-2 overflow-x-auto border-border-subtle p-4 md:row-start-2 md:w-64 md:flex-col md:overflow-x-visible", side === "start"
61
+ ? "row-start-2 border-b md:col-start-1 md:border-b-0 md:border-e"
62
+ : "row-start-4 border-t md:col-start-3 md:border-t-0 md:border-s", className), children: children }));
63
+ };
64
+ //# sourceMappingURL=Rail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Rail.js","sourceRoot":"","sources":["../../src/Rail/Rail.tsx"],"names":[],"mappings":";AAKA,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAY/C,MAAM,gBAAgB,GAGlB;IACF,aAAa,EAAE,OAAO;IACtB,UAAU,EAAE,KAAK;CAClB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkDG;AACH,MAAM,CAAC,MAAM,IAAI,GAAG,CAAC,EACnB,QAAQ,EACR,SAAS,EACT,KAAK,EACL,QAAQ,GAAG,eAAe,EAC1B,IAAI,GAAG,OAAO,EACd,GAAG,SAAS,EACF,EAAa,EAAE;IACzB,MAAM,OAAO,GAAG,gBAAgB,CAAC,QAAQ,CAAC,CAAA;IAE1C,OAAO,CACL,KAAC,OAAO,OACF,SAAS,gBACD,KAAK,EACjB,SAAS,EAAE,WAAW,CACpB,sHAAsH,EACtH,IAAI,KAAK,OAAO;YACd,CAAC,CAAC,+DAA+D;YACjE,CAAC,CAAC,+DAA+D,EACnE,SAAS,CACV,YAEA,QAAQ,GACD,CACX,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,112 @@
1
+ import { type ComponentPropsWithRef, type ReactNode } from "react";
2
+ import { type ContentWidth } from "./contentWidth.ts";
3
+ export type ShellProps = ComponentPropsWithRef<"div"> & {
4
+ contentWidth?: ContentWidth;
5
+ skipLinkLabel?: string;
6
+ };
7
+ /**
8
+ * The page frame: one `Header`, up to two `Rail`s, one `Main`.
9
+ *
10
+ * **The largest duplicated surface in the fleet.** Ten of twelve UI
11
+ * repos hand-roll this, and three of them have a file literally
12
+ * called `AppShell.tsx` — mail-sifter's and points-market's headers
13
+ * are a **byte-identical** string
14
+ * (`sticky top-0 z-40 border-b border-border-subtle bg-surface-base/90 backdrop-blur-md`),
15
+ * arrived at independently, and board-games' is the same structure
16
+ * with `sticky` dropped. The remaining seven spell it as
17
+ * `PageChrome`, `Header`, `DashboardHeader`, `Layout` (twice),
18
+ * `TitleBar`, and inline in `App.tsx`.
19
+ *
20
+ * Three things fall out of owning it once, and each is a bug
21
+ * somewhere in that list today:
22
+ *
23
+ * - **A skip link.** All ten are missing one. A keyboard user
24
+ * tabs through the whole header on every page.
25
+ * - **One width, not two.** `Header` and `Main` read the same
26
+ * `contentWidth` through context — see `shellContext.ts` for
27
+ * the app where they disagree.
28
+ * - **Sticky that is actually sticky.** mux-magic's `PageHeader`
29
+ * is *described* as sticky and sets only `z-index`; it never
30
+ * sets `position: sticky`, so the z-index does nothing and the
31
+ * header scrolls away. `Header`'s `isSticky` sets both or
32
+ * neither.
33
+ *
34
+ * ## The layout is one grid, and the rails do not duplicate
35
+ *
36
+ * Three columns at `md` and up — `auto minmax(0, 1fr) auto`, so an
37
+ * absent rail is a **zero-width track** rather than a conditional
38
+ * subtree — collapsing to a single column below it, where the
39
+ * children simply stack in DOM order. The same `Rail` element is
40
+ * in the document at every width; nothing is rendered twice behind
41
+ * `hidden`/`lg:hidden`.
42
+ *
43
+ * That matters beyond tidiness. mux-magic's `PageHeader` renders
44
+ * its entire control set twice (lines 290–345 and 356–410) and
45
+ * mail-sifter's `TriageQueue` does the same, so every action
46
+ * exists twice in the DOM at every viewport: tests have to be
47
+ * defensively scoped with `within(toolbar)`, and any agent driving
48
+ * the page sees phantom controls it cannot click.
49
+ *
50
+ * `minmax(0, 1fr)` on the middle track is the horizontal-scroll
51
+ * fix, not a formality. A grid track's automatic minimum is
52
+ * `min-content`, so **one long unbroken string in `Main` widens
53
+ * the whole page** — which is the bug plex-channels' narrow view
54
+ * has today. rip-deck's `BayGrid` documents the same fix from the
55
+ * other end.
56
+ *
57
+ * There is no `gap`: an empty `auto` track is 0 wide, but a `gap`
58
+ * beside it is not, so a shell with no rails would carry two
59
+ * mystery gutters. `Rail` and `Main` bring their own padding.
60
+ *
61
+ * ## `overflow-x: clip` — the structural guarantee
62
+ *
63
+ * `minmax(0, 1fr)` fixes the tracks and `Main`'s `wrap-anywhere`
64
+ * fixes the text, but neither reaches the shape that actually
65
+ * broke plex-channels: **a closed panel parked at
66
+ * `transform: translateX(110%)`**. A transform does not take a box
67
+ * out of the document's scrollable overflow region — and neither
68
+ * does `visibility: hidden` — so an off-screen drawer that looks
69
+ * absent still makes the page scroll to it. It is a favourite
70
+ * fleet pattern and no amount of `min-width: 0` touches it.
71
+ *
72
+ * `clip`, not `hidden`, and the two are not interchangeable here:
73
+ * `overflow: hidden` creates a **scroll container**, which would
74
+ * become the sticky containing block for `Header` and freeze it in
75
+ * place. `overflow-x: clip` creates no scrollport, so `Header`
76
+ * keeps sticking to the viewport, and `overflow-y` stays `visible`
77
+ * — a pair that is legal for `clip` and illegal for `hidden`,
78
+ * which would compute the other axis to `auto`.
79
+ *
80
+ * The trade is deliberate: content wider than the frame is
81
+ * **clipped rather than reachable by scrolling sideways**, which is
82
+ * exactly the outcome asked for. Content that genuinely needs to
83
+ * be wider owns a scroll container of its own — the pattern
84
+ * `Main.mdx` documents for tables.
85
+ *
86
+ * ### `relative` is what makes the clip reach anything
87
+ *
88
+ * `overflow-x: clip` only clips descendants whose **containing
89
+ * block chain passes through the clipping element**. An absolutely
90
+ * positioned element with no positioned ancestor resolves against
91
+ * the *initial* containing block instead, so it sails straight
92
+ * past every `overflow` in the tree and lands its overflow on
93
+ * `documentElement` — where `document.body.scrollWidth` still
94
+ * reads clean and only `document.documentElement.scrollWidth`
95
+ * shows it.
96
+ *
97
+ * That is not hypothetical: it is measured. Before this
98
+ * `relative`, the parked-drawer fixture reported `shellScroll:
99
+ * 390`, `bodyScroll: 390` and `docScroll: 742` at a 390px
100
+ * viewport — the shell dutifully clipping a box it had no
101
+ * authority over.
102
+ *
103
+ * The obvious candidate to own it, `Main`'s content column, does
104
+ * **not**: `container-type: inline-size` computes `contain` to
105
+ * `none` in Chromium and establishes no containing block for
106
+ * absolute positioning, which is measured too — the drawer's
107
+ * `offsetParent` was `BODY`. So `Shell` takes the job, which is
108
+ * also the honest place for it: the frame is what an app's parked
109
+ * chrome is parked against.
110
+ */
111
+ export declare const Shell: ({ children, className, contentWidth, skipLinkLabel, ...divProps }: ShellProps) => ReactNode;
112
+ //# sourceMappingURL=Shell.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Shell.d.ts","sourceRoot":"","sources":["../../src/Shell/Shell.tsx"],"names":[],"mappings":"AACA,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,SAAS,EAEf,MAAM,OAAO,CAAA;AAGd,OAAO,EACL,KAAK,YAAY,EAElB,MAAM,mBAAmB,CAAA;AAG1B,MAAM,MAAM,UAAU,GAAG,qBAAqB,CAAC,KAAK,CAAC,GAAG;IACtD,YAAY,CAAC,EAAE,YAAY,CAAA;IAC3B,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuGG;AACH,eAAO,MAAM,KAAK,GAAI,mEAMnB,UAAU,KAAG,SAwCf,CAAA"}