@charcuterie/ui 3.34.0 → 4.1.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 (87) hide show
  1. package/dist/ActionTiles/ActionTiles.d.ts +66 -8
  2. package/dist/ActionTiles/ActionTiles.d.ts.map +1 -1
  3. package/dist/ActionTiles/ActionTiles.js +88 -11
  4. package/dist/ActionTiles/ActionTiles.js.map +1 -1
  5. package/dist/Main/Main.d.ts +18 -19
  6. package/dist/Main/Main.d.ts.map +1 -1
  7. package/dist/Main/Main.js +21 -10
  8. package/dist/Main/Main.js.map +1 -1
  9. package/dist/Main/ScrollMemoryProvider.d.ts +38 -0
  10. package/dist/Main/ScrollMemoryProvider.d.ts.map +1 -0
  11. package/dist/Main/ScrollMemoryProvider.js +30 -0
  12. package/dist/Main/ScrollMemoryProvider.js.map +1 -0
  13. package/dist/Main/scrollMemory.d.ts +54 -28
  14. package/dist/Main/scrollMemory.d.ts.map +1 -1
  15. package/dist/Main/scrollMemory.js +81 -40
  16. package/dist/Main/scrollMemory.js.map +1 -1
  17. package/dist/Main/scrollMemoryContext.d.ts +18 -0
  18. package/dist/Main/scrollMemoryContext.d.ts.map +1 -0
  19. package/dist/Main/scrollMemoryContext.js +18 -0
  20. package/dist/Main/scrollMemoryContext.js.map +1 -0
  21. package/dist/Main/useScrollMemory.d.ts +20 -18
  22. package/dist/Main/useScrollMemory.d.ts.map +1 -1
  23. package/dist/Main/useScrollMemory.js +69 -42
  24. package/dist/Main/useScrollMemory.js.map +1 -1
  25. package/dist/PortraitTiles/PortraitAvatar.d.ts +21 -0
  26. package/dist/PortraitTiles/PortraitAvatar.d.ts.map +1 -0
  27. package/dist/PortraitTiles/PortraitAvatar.js +29 -0
  28. package/dist/PortraitTiles/PortraitAvatar.js.map +1 -0
  29. package/dist/PortraitTiles/PortraitTiles.d.ts +162 -0
  30. package/dist/PortraitTiles/PortraitTiles.d.ts.map +1 -0
  31. package/dist/PortraitTiles/PortraitTiles.js +263 -0
  32. package/dist/PortraitTiles/PortraitTiles.js.map +1 -0
  33. package/dist/PortraitTiles/portraitColour.d.ts +49 -0
  34. package/dist/PortraitTiles/portraitColour.d.ts.map +1 -0
  35. package/dist/PortraitTiles/portraitColour.js +55 -0
  36. package/dist/PortraitTiles/portraitColour.js.map +1 -0
  37. package/dist/categoricalStyles.d.ts +27 -0
  38. package/dist/categoricalStyles.d.ts.map +1 -1
  39. package/dist/categoricalStyles.js +49 -0
  40. package/dist/categoricalStyles.js.map +1 -1
  41. package/dist/index.d.ts +7 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +3 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/reactRouter/ReactRouterAdapter.d.ts +43 -0
  46. package/dist/reactRouter/ReactRouterAdapter.d.ts.map +1 -0
  47. package/dist/reactRouter/ReactRouterAdapter.js +50 -0
  48. package/dist/reactRouter/ReactRouterAdapter.js.map +1 -0
  49. package/dist/reactRouter/index.d.ts +14 -8
  50. package/dist/reactRouter/index.d.ts.map +1 -1
  51. package/dist/reactRouter/index.js +13 -8
  52. package/dist/reactRouter/index.js.map +1 -1
  53. package/dist/tileStyles.d.ts +15 -0
  54. package/dist/tileStyles.d.ts.map +1 -1
  55. package/dist/tileStyles.js +26 -1
  56. package/dist/tileStyles.js.map +1 -1
  57. package/package.json +2 -2
  58. package/src/ActionTiles/ActionTiles.mdx +64 -6
  59. package/src/ActionTiles/ActionTiles.stories.tsx +107 -2
  60. package/src/ActionTiles/ActionTiles.test.tsx +205 -5
  61. package/src/ActionTiles/ActionTiles.tsx +274 -110
  62. package/src/Main/Main.mdx +35 -7
  63. package/src/Main/Main.stories.tsx +129 -64
  64. package/src/Main/Main.test.tsx +58 -0
  65. package/src/Main/Main.tsx +20 -20
  66. package/src/Main/ScrollMemoryProvider.tsx +48 -0
  67. package/src/Main/scrollMemory.test.ts +70 -16
  68. package/src/Main/scrollMemory.ts +98 -40
  69. package/src/Main/scrollMemoryContext.ts +21 -0
  70. package/src/Main/useScrollMemory.ts +83 -43
  71. package/src/PortraitTiles/PortraitAvatar.tsx +49 -0
  72. package/src/PortraitTiles/PortraitTiles.mdx +153 -0
  73. package/src/PortraitTiles/PortraitTiles.stories.tsx +403 -0
  74. package/src/PortraitTiles/PortraitTiles.test.tsx +399 -0
  75. package/src/PortraitTiles/PortraitTiles.tsx +577 -0
  76. package/src/PortraitTiles/portraitColour.test.ts +39 -0
  77. package/src/PortraitTiles/portraitColour.ts +67 -0
  78. package/src/RadioGroup/RadioGroup.stories.tsx +39 -0
  79. package/src/RouterLink/RouterLink.mdx +53 -16
  80. package/src/categoricalStyles.ts +57 -0
  81. package/src/index.ts +12 -0
  82. package/src/reactRouter/ReactRouterAdapter.stories.tsx +120 -0
  83. package/src/reactRouter/ReactRouterAdapter.test.tsx +92 -0
  84. package/src/reactRouter/ReactRouterAdapter.tsx +66 -0
  85. package/src/reactRouter/index.ts +14 -8
  86. package/src/sourceRules.test.ts +19 -3
  87. package/src/tileStyles.ts +30 -1
@@ -139,6 +139,45 @@ export const Tiles: Story = {
139
139
  },
140
140
  }
141
141
 
142
+ /**
143
+ * The tile at all three sizes.
144
+ *
145
+ * `AllVariants` above covers the three heights of a **row**, and
146
+ * every tile story took the default `md` — so the largest tile had
147
+ * no snapshot at all, and `TILE_PADDING_CLASS.lg` could be changed
148
+ * without a single pixel moving in the report. It was, on
149
+ * 2026-09-02, when `lg` grew to a landing-page tile's padding.
150
+ *
151
+ * The padding is shared with `ActionTiles` through `tileStyles.ts`,
152
+ * which is the promise this board is here to photograph.
153
+ */
154
+ export const TileSizes: Story = {
155
+ args: {
156
+ items: IMPORT_ITEMS,
157
+ itemShape: "tile",
158
+ label: "On import",
159
+ },
160
+ render: (controlProps) => (
161
+ <StorySection title="One box at three sizes, and the same box ActionTiles reads from.">
162
+ <StoryGrid columns={3}>
163
+ {(["sm", "md", "lg"] as const).map((size) => (
164
+ <StoryCell
165
+ align="stretch"
166
+ key={size}
167
+ label={size}
168
+ >
169
+ <RadioGroup
170
+ {...controlProps}
171
+ label={`On import at ${size}`}
172
+ size={size}
173
+ />
174
+ </StoryCell>
175
+ ))}
176
+ </StoryGrid>
177
+ </StorySection>
178
+ ),
179
+ }
180
+
142
181
  /**
143
182
  * A tile may lead with an icon. The library ships none — these are
144
183
  * story-only SVGs, exactly what an app passes — and the icon is
@@ -27,38 +27,68 @@ point — so an app that never imports this path never resolves it.
27
27
  yarn add @charcuterie/ui react-router
28
28
  ```
29
29
 
30
+ **One component at the root**, and it must sit **inside** the router:
31
+
30
32
  ```tsx
31
33
  // src/main.tsx
32
- import { RouterLinkProvider } from "@charcuterie/ui"
33
- import { ReactRouterLink } from "@charcuterie/ui/react-router"
34
+ import { ReactRouterAdapter } from "@charcuterie/ui/react-router"
34
35
  import { StrictMode } from "react"
35
36
  import { createRoot } from "react-dom/client"
36
- import {
37
- createBrowserRouter,
38
- RouterProvider,
39
- } from "react-router"
37
+ import { BrowserRouter } from "react-router"
40
38
 
41
- import { routes } from "./routes.tsx"
42
-
43
- const router = createBrowserRouter(routes)
39
+ import { App } from "./App.tsx"
44
40
 
45
41
  createRoot(document.getElementById("root")).render(
46
42
  <StrictMode>
47
- <RouterLinkProvider link={ReactRouterLink}>
48
- <RouterProvider router={router} />
49
- </RouterLinkProvider>
43
+ <BrowserRouter>
44
+ <ReactRouterAdapter>
45
+ <App />
46
+ </ReactRouterAdapter>
47
+ </BrowserRouter>
50
48
  </StrictMode>,
51
49
  )
52
50
  ```
53
51
 
54
- `RouterLinkProvider` goes **outside** `RouterProvider` here on purpose: it is plain React
55
- context and needs no router of its own, and putting it outermost means a link rendered by a
56
- route element and a link rendered by an app-level chrome component both see it.
52
+ Inside, because it reads `useLocation()`. Above the routes, because what it provides belongs
53
+ to the whole app — a link rendered by a route element and a link rendered by app-level
54
+ chrome both see it.
57
55
 
58
56
  Already on `react-router-dom` v7, as bambuddy is? Nothing changes. `react-router-dom` v7 is
59
57
  a re-export shim over `react-router`, and the adapter imports `Link` from the latter, so one
60
58
  import covers both installs.
61
59
 
60
+ ### Two seams, and why they arrive together
61
+
62
+ The adapter wires the **link seam** and the **scroll memory**, because a per-seam opt-in is
63
+ what the fleet has already failed at. `Main`'s scroll memory shipped as a `scrollKey` prop;
64
+ one app wired it, and the other three carried on losing the reader's place with the library
65
+ sitting right there supporting it. One name at the root means a seam added later arrives in
66
+ every app that already renders this, on its next release.
67
+
68
+ `RouterLinkProvider`, `ReactRouterLink` and `ScrollMemoryProvider` all stay exported for an
69
+ app that wants one half.
70
+
71
+ ### On `createBrowserRouter`
72
+
73
+ `RouterProvider` takes no children, so the adapter goes in the root route's element:
74
+
75
+ ```tsx
76
+ const routes = [
77
+ {
78
+ element: (
79
+ <ReactRouterAdapter>
80
+ <Outlet />
81
+ </ReactRouterAdapter>
82
+ ),
83
+ children: appRoutes,
84
+ },
85
+ ]
86
+ ```
87
+
88
+ An app that wants only the link seam can keep `RouterLinkProvider` outside `RouterProvider`
89
+ instead, as it did before this adapter existed — that provider is plain React context and
90
+ needs no router of its own.
91
+
62
92
  That is the whole integration. Every link in the app is now soft:
63
93
 
64
94
  ```tsx
@@ -166,6 +196,10 @@ asserts `getByRole("link", { name: … })` and its `href` with no router in the
166
196
  With a provider, inject a stand-in that marks what it was handed — `TextLink.stories.tsx`'s
167
197
  `Routed` story is exactly that, and `TextLink.test.tsx` reads it.
168
198
 
199
+ The scroll memory is the same: no provider, no memory. A test that wants it renders
200
+ `ScrollMemoryProvider` with a `{ key, path }` it controls, rather than a router — which is
201
+ what `Main.stories.tsx` does.
202
+
169
203
  ## API
170
204
 
171
205
  | Export | From | What |
@@ -175,4 +209,7 @@ With a provider, inject a stand-in that marks what it was handed — `TextLink.s
175
209
  | `RouterLinkComponent` / `RouterLinkProps` | `@charcuterie/ui` | The contract: `ComponentPropsWithRef<"a">` with `href` required. |
176
210
  | `AnchorLink` | `@charcuterie/ui` | The plain `<a href>` default. |
177
211
  | `getIsRoutedHref(href)` | `@charcuterie/ui` | The destination test above. |
178
- | `ReactRouterLink` | `@charcuterie/ui/react-router` | The one shipped adapter. Optional peer. |
212
+ | `ScrollMemoryProvider` | `@charcuterie/ui` | The scroll seam. `entry` is `{ key, path }` for the current history entry. |
213
+ | `ScrollEntry` | `@charcuterie/ui` | That pair's type. |
214
+ | `ReactRouterAdapter` | `@charcuterie/ui/react-router` | Both seams, wired. The one thing an app renders. Optional peer. |
215
+ | `ReactRouterLink` | `@charcuterie/ui/react-router` | The link adapter alone. Optional peer. |
@@ -242,3 +242,60 @@ export const CATEGORICAL_CONTENT_CLASS: Record<
242
242
  9: "text-categorical-9-content",
243
243
  10: "text-categorical-10-content",
244
244
  }
245
+
246
+ /**
247
+ * The hover BORDER, which `CATEGORICAL_HOVER_CLASS` deliberately
248
+ * does not carry.
249
+ *
250
+ * That map answers "what does this surface do under the pointer",
251
+ * and a `Badge` has no border state to change. A tile does: it is a
252
+ * bordered card, and the whole point of colouring one is that the
253
+ * box reacts in its own hue rather than in the neutral
254
+ * `border-strong` every other box uses. Kept separate rather than
255
+ * added as a fifth appearance key, because an appearance is a claim
256
+ * about the fill and this is not one.
257
+ *
258
+ * Written out in full for the reason this file opens with.
259
+ */
260
+ export const CATEGORICAL_HOVER_BORDER_CLASS: Record<
261
+ CategoricalIndex,
262
+ string
263
+ > = {
264
+ 1: "hover:border-categorical-1-border",
265
+ 2: "hover:border-categorical-2-border",
266
+ 3: "hover:border-categorical-3-border",
267
+ 4: "hover:border-categorical-4-border",
268
+ 5: "hover:border-categorical-5-border",
269
+ 6: "hover:border-categorical-6-border",
270
+ 7: "hover:border-categorical-7-border",
271
+ 8: "hover:border-categorical-8-border",
272
+ 9: "hover:border-categorical-9-border",
273
+ 10: "hover:border-categorical-10-border",
274
+ }
275
+
276
+ /**
277
+ * A halo the width of a ring, in the hue's SURFACE.
278
+ *
279
+ * `surface` and not `border`: this sits directly against the solid
280
+ * fill it surrounds, so it is a soft step out of the colour rather
281
+ * than a second line around it. `PortraitTiles` draws it around a
282
+ * face, where a hard outline reads as a frame and this reads as
283
+ * light.
284
+ *
285
+ * Written out in full for the reason this file opens with.
286
+ */
287
+ export const CATEGORICAL_RING_CLASS: Record<
288
+ CategoricalIndex,
289
+ string
290
+ > = {
291
+ 1: "ring-categorical-1-surface",
292
+ 2: "ring-categorical-2-surface",
293
+ 3: "ring-categorical-3-surface",
294
+ 4: "ring-categorical-4-surface",
295
+ 5: "ring-categorical-5-surface",
296
+ 6: "ring-categorical-6-surface",
297
+ 7: "ring-categorical-7-surface",
298
+ 8: "ring-categorical-8-surface",
299
+ 9: "ring-categorical-9-surface",
300
+ 10: "ring-categorical-10-surface",
301
+ }
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export type {
39
39
  export { Accordion } from "./Accordion/Accordion.tsx"
40
40
  export type {
41
41
  ActionTileItem,
42
+ ActionTilesAccent,
42
43
  ActionTilesProps,
43
44
  } from "./ActionTiles/ActionTiles.tsx"
44
45
  export { ActionTiles } from "./ActionTiles/ActionTiles.tsx"
@@ -129,7 +130,9 @@ export { copyText } from "./CopyButton/copyText.ts"
129
130
  export {
130
131
  CATEGORICAL_APPEARANCE_CLASS,
131
132
  CATEGORICAL_CONTENT_CLASS,
133
+ CATEGORICAL_HOVER_BORDER_CLASS,
132
134
  CATEGORICAL_HOVER_CLASS,
135
+ CATEGORICAL_RING_CLASS,
133
136
  CATEGORICAL_SOLID_FILL_CLASS,
134
137
  } from "./categoricalStyles.ts"
135
138
  export type { BadgeSize } from "./controlStyles.ts"
@@ -253,6 +256,9 @@ export type {
253
256
  export { LogViewer } from "./LogViewer/LogViewer.tsx"
254
257
  export type { MainProps } from "./Main/Main.tsx"
255
258
  export { Main } from "./Main/Main.tsx"
259
+ export type { ScrollMemoryProviderProps } from "./Main/ScrollMemoryProvider.tsx"
260
+ export { ScrollMemoryProvider } from "./Main/ScrollMemoryProvider.tsx"
261
+ export type { ScrollEntry } from "./Main/scrollMemory.ts"
256
262
  export type {
257
263
  MarkdownEditorProps,
258
264
  MarkdownImageUpload,
@@ -322,6 +328,12 @@ export type {
322
328
  export { Picker } from "./Picker/Picker.tsx"
323
329
  export type { PopoverProps } from "./Popover/Popover.tsx"
324
330
  export { Popover } from "./Popover/Popover.tsx"
331
+ export type {
332
+ PortraitTileItem,
333
+ PortraitTilesLayout,
334
+ PortraitTilesProps,
335
+ } from "./PortraitTiles/PortraitTiles.tsx"
336
+ export { PortraitTiles } from "./PortraitTiles/PortraitTiles.tsx"
325
337
  export type {
326
338
  ProgressBarProps,
327
339
  ProgressBarSize,
@@ -0,0 +1,120 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import {
3
+ MemoryRouter,
4
+ Route,
5
+ Routes,
6
+ useNavigate,
7
+ } from "react-router"
8
+
9
+ import { Button } from "../Button/Button.tsx"
10
+ import { Card } from "../Card/Card.tsx"
11
+ import { Header } from "../Header/Header.tsx"
12
+ import { Main } from "../Main/Main.tsx"
13
+ import { Shell } from "../Shell/Shell.tsx"
14
+ import { TextLink } from "../TextLink/TextLink.tsx"
15
+ import { ReactRouterAdapter } from "./ReactRouterAdapter.tsx"
16
+
17
+ /**
18
+ * The one component an app on react-router renders at its root.
19
+ *
20
+ * `Main.stories.tsx` proves the scroll memory against a hand-rolled
21
+ * history stack, which is the right subject for the mechanism. This
22
+ * one proves the **wiring**: a real router, a real `useLocation()`,
23
+ * and both seams arriving from one component — the piece that ships
24
+ * broken silently, because a page whose links still work and whose
25
+ * list still scrolls looks fine.
26
+ *
27
+ * `MemoryRouter` rather than `BrowserRouter`, so the story does not
28
+ * write to the Storybook page's own history.
29
+ */
30
+ const meta = {
31
+ component: ReactRouterAdapter,
32
+ parameters: {
33
+ layout: "fullscreen",
34
+ },
35
+ title: "Components/Layout/ReactRouterAdapter",
36
+ } satisfies Meta<typeof ReactRouterAdapter>
37
+
38
+ export default meta
39
+
40
+ type Story = StoryObj<typeof meta>
41
+
42
+ const EPISODE_TITLES = Array.from(
43
+ { length: 40 },
44
+ (_unused, index) => `Episode ${index + 1}`,
45
+ )
46
+
47
+ const BackControl = () => {
48
+ const navigate = useNavigate()
49
+
50
+ return (
51
+ <Button
52
+ appearance="outline"
53
+ onClick={() => {
54
+ void navigate(-1)
55
+ }}
56
+ size="sm"
57
+ >
58
+ Back
59
+ </Button>
60
+ )
61
+ }
62
+
63
+ const EpisodeList = () => (
64
+ <Main>
65
+ <div className="flex flex-col gap-3">
66
+ {EPISODE_TITLES.map((title) => (
67
+ <Card heading={title} key={title} padding="sm">
68
+ <TextLink href={`/library/${title}`}>
69
+ Open {title}
70
+ </TextLink>
71
+ </Card>
72
+ ))}
73
+ </div>
74
+ </Main>
75
+ )
76
+
77
+ const EpisodePage = () => (
78
+ <Main>
79
+ <Card heading="Episode">
80
+ <p className="text-content-secondary text-sm">
81
+ Short enough that the scrollport collapses behind
82
+ it. Press Back.
83
+ </p>
84
+ </Card>
85
+ </Main>
86
+ )
87
+
88
+ /**
89
+ * Scroll the list, press a link, then press Back.
90
+ *
91
+ * Nothing on this page is told about scrolling or about the router.
92
+ * `TextLink` becomes a soft navigation and `Main` keeps the
93
+ * reader's place because `ReactRouterAdapter` is above them both.
94
+ */
95
+ export const Default: Story = {
96
+ render: () => (
97
+ <MemoryRouter initialEntries={["/library"]}>
98
+ <ReactRouterAdapter>
99
+ <Shell>
100
+ <Header
101
+ actions={<BackControl />}
102
+ heading="Library"
103
+ />
104
+
105
+ <Routes>
106
+ <Route
107
+ element={<EpisodeList />}
108
+ path="/library"
109
+ />
110
+
111
+ <Route
112
+ element={<EpisodePage />}
113
+ path="/library/:episode"
114
+ />
115
+ </Routes>
116
+ </Shell>
117
+ </ReactRouterAdapter>
118
+ </MemoryRouter>
119
+ ),
120
+ }
@@ -0,0 +1,92 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { afterEach, test } from "vitest"
4
+
5
+ import { forgetScrollOffsets } from "../Main/scrollMemory.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import {
9
+ DESKTOP,
10
+ setViewport,
11
+ } from "../viewport.testHelpers.ts"
12
+ import * as stories from "./ReactRouterAdapter.stories.tsx"
13
+
14
+ const { Default } = composeStories(stories)
15
+
16
+ // The offsets are module state, so they outlive a mount. A test
17
+ // must not.
18
+ afterEach(() => {
19
+ forgetScrollOffsets()
20
+ })
21
+
22
+ /**
23
+ * The wiring, against a real router.
24
+ *
25
+ * `Main.test.tsx` proves the scroll mechanism against a hand-rolled
26
+ * history stack — the right subject for a `ResizeObserver` and a
27
+ * clamped offset. What it cannot prove is that the entry reaches
28
+ * `Main` at all when the value comes from `useLocation()` rather
29
+ * than from a story's own `useState`. That is the seam every app
30
+ * now depends on, and it is the kind that fails quietly: the links
31
+ * still navigate and the list still scrolls.
32
+ */
33
+ test("an app renders one component and gets both seams", async () => {
34
+ await setViewport(DESKTOP)
35
+
36
+ const { canvas } = await mountStory(Default)
37
+
38
+ const main = canvas.getByRole("main")
39
+
40
+ // The link seam: react-router's `<Link>` renders an `<a href>`,
41
+ // so this is also what an unwired app looks like — the assertion
42
+ // that separates them is the absence of a page load below.
43
+ const link = expectAgentDrivable(canvas, {
44
+ name: "Open Episode 12",
45
+ role: "link",
46
+ })
47
+
48
+ await expect(link).toHaveAttribute(
49
+ "href",
50
+ "/library/Episode 12",
51
+ )
52
+
53
+ main.scrollTop = 900
54
+
55
+ await waitFor(async () => {
56
+ await expect(main.scrollTop).toBe(900)
57
+ })
58
+
59
+ await new Promise((resolve) => {
60
+ globalThis.requestAnimationFrame(() => {
61
+ globalThis.requestAnimationFrame(resolve)
62
+ })
63
+ })
64
+
65
+ await userEvent.click(link)
66
+
67
+ // A soft navigation: the route changed inside the same document.
68
+ // A plain `<a>` would have left the story mounted and unchanged.
69
+ //
70
+ // `waitFor`, because the route commit is a state update and the
71
+ // click resolves before it. It passed locally and failed on CI
72
+ // for exactly that reason.
73
+ await waitFor(async () => {
74
+ await expect(
75
+ canvas.getByRole("heading", { name: "Episode" }),
76
+ ).toBeVisible()
77
+ })
78
+
79
+ await userEvent.click(
80
+ expectAgentDrivable(canvas, {
81
+ name: "Back",
82
+ role: "button",
83
+ }),
84
+ )
85
+
86
+ // The scroll seam, from the same one component.
87
+ await waitFor(async () => {
88
+ await expect(canvas.getByRole("main").scrollTop).toBe(
89
+ 900,
90
+ )
91
+ })
92
+ })
@@ -0,0 +1,66 @@
1
+ import type { ReactNode } from "react"
2
+ import { useLocation } from "react-router"
3
+
4
+ import { ScrollMemoryProvider } from "../Main/ScrollMemoryProvider.tsx"
5
+ import { RouterLinkProvider } from "../RouterLink/RouterLinkProvider.tsx"
6
+ import { ReactRouterLink } from "./ReactRouterLink.tsx"
7
+
8
+ export type ReactRouterAdapterProps = {
9
+ children?: ReactNode
10
+ }
11
+
12
+ /**
13
+ * Every seam this library has, wired to react-router, in one
14
+ * component at the app root.
15
+ *
16
+ * ```tsx
17
+ * <BrowserRouter>
18
+ * <ReactRouterAdapter>
19
+ * <Routes>…</Routes>
20
+ * </ReactRouterAdapter>
21
+ * </BrowserRouter>
22
+ * ```
23
+ *
24
+ * It must sit **inside** the router, because it reads
25
+ * `useLocation()`, and **outside** the routes, because what it
26
+ * provides belongs to the whole app.
27
+ *
28
+ * ## Why one component and not two providers
29
+ *
30
+ * There are two seams today — the link seam and the scroll memory —
31
+ * and the fleet proved that a per-app opt-in does not reach the
32
+ * fleet. `Main`'s scroll memory shipped as a `scrollKey` prop, one
33
+ * app wired it, and the other three kept losing the reader's place
34
+ * with the library sitting right there supporting it. A seam an app
35
+ * has to know about is a seam most apps will not have.
36
+ *
37
+ * So the root wiring is one name to remember, and a seam added later
38
+ * arrives in every app that already renders this on its next
39
+ * release. `RouterLinkProvider`, `ReactRouterLink` and
40
+ * `ScrollMemoryProvider` all stay exported for an app that wants one
41
+ * half, or that renders its own root for another reason.
42
+ *
43
+ * ## What it does NOT do
44
+ *
45
+ * It does not render a router, and it takes no route table. This
46
+ * library has no opinion about either, and an app that wants
47
+ * `createBrowserRouter` should keep it.
48
+ */
49
+ export const ReactRouterAdapter = ({
50
+ children,
51
+ }: ReactRouterAdapterProps): ReactNode => {
52
+ const location = useLocation()
53
+
54
+ return (
55
+ <RouterLinkProvider link={ReactRouterLink}>
56
+ <ScrollMemoryProvider
57
+ entry={{
58
+ key: location.key,
59
+ path: location.pathname,
60
+ }}
61
+ >
62
+ {children}
63
+ </ScrollMemoryProvider>
64
+ </RouterLinkProvider>
65
+ )
66
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * `@charcuterie/ui/react-router` — the react-router adapter for the
3
- * link seam.
2
+ * `@charcuterie/ui/react-router` — the react-router adapter for this
3
+ * library's seams.
4
4
  *
5
5
  * **A subpath export with an optional peer dependency**, and both
6
6
  * halves of that matter. The base package must stay router-free: the
@@ -13,17 +13,23 @@
13
13
  * asserts exactly that — the main entry is measured for what it
14
14
  * reaches, so this cannot leak into it by accident.
15
15
  *
16
- * The wiring is one provider at the app root; `Guides/Routing` in
16
+ * The wiring is one component at the app root; `Guides/Routing` in
17
17
  * Storybook is the copy-pasteable version.
18
18
  *
19
19
  * ```tsx
20
- * import { RouterLinkProvider } from "@charcuterie/ui"
21
- * import { ReactRouterLink } from "@charcuterie/ui/react-router"
20
+ * import { ReactRouterAdapter } from "@charcuterie/ui/react-router"
22
21
  *
23
- * <RouterLinkProvider link={ReactRouterLink}>
24
- * <RouterProvider router={router} />
25
- * </RouterLinkProvider>
22
+ * <BrowserRouter>
23
+ * <ReactRouterAdapter>
24
+ * <Routes>…</Routes>
25
+ * </ReactRouterAdapter>
26
+ * </BrowserRouter>
26
27
  * ```
28
+ *
29
+ * `ReactRouterLink` stays exported for an app that wires
30
+ * `RouterLinkProvider` itself.
27
31
  */
28
32
 
33
+ export type { ReactRouterAdapterProps } from "./ReactRouterAdapter.tsx"
34
+ export { ReactRouterAdapter } from "./ReactRouterAdapter.tsx"
29
35
  export { ReactRouterLink } from "./ReactRouterLink.tsx"
@@ -236,6 +236,14 @@ test("a container-query component is never storied in a shrink-to-fit cell", ()
236
236
  "Main",
237
237
  "MarkdownEditor",
238
238
  "MediaTile",
239
+ // `PortraitTiles` declares its container on the SET rather than
240
+ // on a tile, and that is the whole design: the question is not
241
+ // "how wide is this one face" but "does this picker have room to
242
+ // stack". A phone shows one full-width column of rows; a wall
243
+ // display shows a row of tall columns. Querying a tile would
244
+ // answer neither, because a track is about the same width in
245
+ // both.
246
+ "PortraitTiles",
239
247
  // `Stepper`'s container is the one that decides whether a
240
248
  // `horizontal` sequence is actually horizontal. Below `--cq-md`
241
249
  // it falls back to the vertical column, because four steps side
@@ -375,8 +383,10 @@ test("the barrel is the only place components are re-exported", async () => {
375
383
  // and `ButtonLink`, both a real `<a href>`, differing in paint
376
384
  // rather than semantics: +2 -> 38, on top of `AdaptiveGrid`'s 36.
377
385
  // `AnchorLink`,
378
- // `RouterLinkProvider`, and `ReactRouterLink` are the seam rather
379
- // than components anybody stories, and live in `RouterLink/` and
386
+ // `RouterLinkProvider`, `ReactRouterLink`, `ScrollMemoryProvider`
387
+ // and `ReactRouterAdapter` are the router seam rather
388
+ // than components anybody stories, and live in `RouterLink/`,
389
+ // `Main/` and
380
390
  // `reactRouter/` — outside this count by the `<Name>/<Name>.tsx`
381
391
  // rule, exactly as `Overlay/`'s parts are.
382
392
  // The unified app shell adds four: `Shell`, `Header`, `Rail`,
@@ -569,7 +579,13 @@ test("the barrel is the only place components are re-exported", async () => {
569
579
  // `isRange` is right there and wrong here for a reason that is
570
580
  // written down — a second text field is still a text field, while a
571
581
  // second thumb cannot share one `aria-valuenow`.
572
- expect(componentNames.length).toBe(64)
582
+ //
583
+ // `PortraitTiles` is +1 -> 65. It is the points-market kid picker:
584
+ // a face, a name and one number, which is a different question
585
+ // from `ActionTiles`' "what do you want to do" and would have
586
+ // meant one component whose icon is sometimes a 20px glyph beside
587
+ // a title and sometimes a 144px circle above it.
588
+ expect(componentNames.length).toBe(65)
573
589
 
574
590
  for (const name of componentNames) {
575
591
  expect(barrel).toContain(`export { ${name} }`)
package/src/tileStyles.ts CHANGED
@@ -38,7 +38,36 @@ export const TILE_PADDING_CLASS: Record<
38
38
  > = {
39
39
  sm: "px-3 py-2.5",
40
40
  md: "px-3.5 py-3",
41
- lg: "px-4 py-3.5",
41
+ // `lg` is the landing-page tile — mux-magic's "Pick a tool",
42
+ // Gallery Downloader's two cards — where the tile is the only
43
+ // thing on the page and reads as a panel rather than as a row.
44
+ // It was `px-4 py-3.5`, which is a control's padding wearing a
45
+ // card's name, and the owner called the result "very boring"
46
+ // when it shipped in QueuePilot.
47
+ lg: "px-6 py-5",
48
+ }
49
+
50
+ /**
51
+ * What a tile with an accent edge adds to its LEADING padding.
52
+ *
53
+ * The bar is drawn by `Card`'s accent-edge pseudo-element, which is
54
+ * an overlay rather than a border — so it takes no space in the box
55
+ * model and lands on top of the text unless the tile makes room for
56
+ * it. `Card` has the same arrangement and the same answer.
57
+ *
58
+ * A separate map rather than a second full padding scale, because
59
+ * only the leading side changes and the shared box is the promise
60
+ * the 2026-09-01 record made. It is applied ONLY when an edge is
61
+ * drawn: a radio tile has no bar, and giving it this padding would
62
+ * be dead space on the side its own control sits on.
63
+ */
64
+ export const TILE_ACCENT_EDGE_PADDING_CLASS: Record<
65
+ ControlSize,
66
+ string
67
+ > = {
68
+ sm: "ps-4",
69
+ md: "ps-4.5",
70
+ lg: "ps-7",
42
71
  }
43
72
 
44
73
  /**