@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
@@ -1,10 +1,24 @@
1
- import type { ControlSize } from "@charcuterie/tokens"
1
+ import type {
2
+ CategoricalIndex,
3
+ ControlSize,
4
+ } from "@charcuterie/tokens"
5
+ import {
6
+ CATEGORICAL_INDEX_COUNT,
7
+ CATEGORICAL_INDEXES,
8
+ } from "@charcuterie/tokens"
2
9
  import type { CSSProperties, ReactNode } from "react"
3
10
 
11
+ import { getAccentEdgeClassName } from "../Card/cardAccentEdge.ts"
12
+ import {
13
+ CATEGORICAL_CONTENT_CLASS,
14
+ CATEGORICAL_HOVER_BORDER_CLASS,
15
+ CATEGORICAL_HOVER_CLASS,
16
+ } from "../categoricalStyles.ts"
4
17
  import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
18
  import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
6
19
  import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
7
20
  import {
21
+ TILE_ACCENT_EDGE_PADDING_CLASS,
8
22
  TILE_BOX_CLASS,
9
23
  TILE_COLUMNS_CLASS,
10
24
  TILE_GAP_CLASS,
@@ -17,6 +31,20 @@ import { toClassName } from "../toClassName.ts"
17
31
  import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
18
32
 
19
33
  export type ActionTileItem = {
34
+ /**
35
+ * Which of the ten hues this tile wears, overriding the one its
36
+ * position would have given it.
37
+ *
38
+ * Reach for it when a tile's colour means something outside the
39
+ * set — a destination that is red everywhere else in the app, a
40
+ * pair that must not drift apart when a third tile is inserted
41
+ * between them. An overridden tile keeps its hue and the rest go
42
+ * on walking the palette in order, so naming one does not force
43
+ * naming all of them.
44
+ *
45
+ * Ignored when the set is `accent="none"`.
46
+ */
47
+ categorical?: CategoricalIndex
20
48
  /**
21
49
  * A line of help under the name. Drawn one step down the type ramp
22
50
  * in `content-muted`, **inside** the tile — so `getByRole` computes
@@ -36,10 +64,15 @@ export type ActionTileItem = {
36
64
  */
37
65
  href?: string
38
66
  /**
39
- * Leading content, above the name. A `Badge`, an app's own icon, a
40
- * count.
67
+ * Leading content, drawn **beside** the name rather than above it.
68
+ * A glyph, a `Badge`, a count.
69
+ *
70
+ * Takes the tile's hue, so an app passes a `stroke="currentColor"`
71
+ * path and states no colour of its own. Size is the app's: around
72
+ * 22px at `md` and 30px at `lg` reads the way the fleet's
73
+ * hand-rolled tool cards did.
41
74
  *
42
- * `aria-hidden` by construction: the name it sits above is inside
75
+ * `aria-hidden` by construction: the name it sits beside is inside
43
76
  * the same tile, so an announced icon is the label read twice.
44
77
  */
45
78
  icon?: ReactNode
@@ -57,7 +90,23 @@ export type ActionTileItem = {
57
90
  value: string
58
91
  }
59
92
 
93
+ /**
94
+ * Where a tile's colour comes from.
95
+ *
96
+ * - `auto` — the set walks the ten-hue categorical palette in
97
+ * order, so eight tiles get eight colours and no call site does
98
+ * any work. An item may still name its own with `categorical`.
99
+ * - `none` — no bar, no hue, and the neutral `border-strong`
100
+ * hover every other box uses.
101
+ */
102
+ export type ActionTilesAccent = "auto" | "none"
103
+
60
104
  export type ActionTilesProps = {
105
+ /**
106
+ * Whether the set colours itself. `auto` by default — see
107
+ * {@link ActionTilesAccent}.
108
+ */
109
+ accent?: ActionTilesAccent
61
110
  className?: string
62
111
  items: readonly ActionTileItem[]
63
112
  /** The set's accessible name. Required. */
@@ -77,9 +126,51 @@ export type ActionTilesProps = {
77
126
  }
78
127
 
79
128
  /**
80
- * A set of ACTIONS drawn as tiles — a bordered card carrying a name
81
- * and a line of help, in a grid that gains columns with its
82
- * container.
129
+ * The gap between the icon and the name it sits beside.
130
+ *
131
+ * Local to this component rather than in `tileStyles.ts`, because a
132
+ * radio tile's inner layout is a control beside a label and has no
133
+ * head row to space. The shared file owns the BOX; this is what goes
134
+ * in it.
135
+ */
136
+ const HEAD_GAP_CLASS: Record<ControlSize, string> = {
137
+ sm: "gap-2",
138
+ md: "gap-2.5",
139
+ lg: "gap-3",
140
+ }
141
+
142
+ /** The gap between the head row and the hint under it. */
143
+ const STACK_GAP_CLASS: Record<ControlSize, string> = {
144
+ sm: "gap-1",
145
+ md: "gap-1",
146
+ lg: "gap-2",
147
+ }
148
+
149
+ /**
150
+ * The hue a tile wears when it did not name one: its position in
151
+ * the set, wrapping at ten.
152
+ *
153
+ * Position rather than `getCategoricalIndex(value)`. That helper
154
+ * hashes a key so a row keeps its colour forever across a list
155
+ * nobody controls the order of — right for Docket's labels, wrong
156
+ * here, because a hash of two tile values can collide and hand a
157
+ * two-tile set the same colour twice. A tile set is short, ordered,
158
+ * and written out in the source; the eye reads it as a row, so the
159
+ * palette is walked as one.
160
+ */
161
+ const getTileCategorical = (
162
+ item: ActionTileItem,
163
+ position: number,
164
+ ): CategoricalIndex =>
165
+ item.categorical ??
166
+ (CATEGORICAL_INDEXES[
167
+ position % CATEGORICAL_INDEX_COUNT
168
+ ] as CategoricalIndex)
169
+
170
+ /**
171
+ * A set of ACTIONS drawn as tiles — a bordered card carrying a
172
+ * colour bar, an icon beside its name, and a line of help, in a
173
+ * grid that gains columns with its container.
83
174
  *
84
175
  * This is `RadioGroup`'s `itemShape="tile"` with the radio taken
85
176
  * away. The
@@ -108,6 +199,30 @@ export type ActionTilesProps = {
108
199
  * why QueuePilot's "Queue type" chooser is here and its Tonight
109
200
  * activity filter is not.
110
201
  *
202
+ * ### The colour is the point, and it is not decoration
203
+ *
204
+ * The first version of this component was neutral, and the owner's
205
+ * verdict on it was *"very boring, not colorful"*. Three apps had
206
+ * already answered the question themselves — mux-magic, Gallery
207
+ * Downloader and points-market all colour their tiles by hand — so
208
+ * the paint is now the library's and every one of them can stop.
209
+ *
210
+ * A tile wears a **bar down its leading edge**, drawn by `Card`'s
211
+ * accent-edge pseudo-element so a tile and a card on the same page
212
+ * are the same bar rather than two that nearly match. Its icon takes
213
+ * the same hue, and the box hovers in it too. The hue comes from the
214
+ * ten-wide categorical palette, taken **in order**, which is why a
215
+ * set of eight needs no colour props at all. See
216
+ * [the tile paint record](../../../../docs/decisions/2026-09-02-an-action-tile-is-coloured-and-the-icon-sits-beside-the-name.md)
217
+ * for the four other paints that were drawn and rejected.
218
+ *
219
+ * `categorical` and not `intent`, for the reason
220
+ * [`cardAccentEdge.ts`](../Card/cardAccentEdge.ts) gives: an intent
221
+ * is a *claim* — `danger` says what happens if you press the thing —
222
+ * and "Builder" beside "Jobs" is making no claim about either. Ten
223
+ * numbered hues say only "these are different from each other",
224
+ * which is exactly what a tile set means.
225
+ *
111
226
  * ### Why this is not a `Button` with a `className`
112
227
  *
113
228
  * A `Button` is sized by `h-(--control-height-md)` and carries **no
@@ -130,6 +245,7 @@ export type ActionTilesProps = {
130
245
  * ability to Tab to the second tile.
131
246
  */
132
247
  export const ActionTiles = ({
248
+ accent = "auto",
133
249
  className,
134
250
  items,
135
251
  label,
@@ -157,8 +273,8 @@ export const ActionTiles = ({
157
273
  } as CSSProperties
158
274
  }
159
275
  >
160
- {items.map(
161
- ({
276
+ {items.map((item, position) => {
277
+ const {
162
278
  hint,
163
279
  href,
164
280
  icon,
@@ -166,127 +282,175 @@ export const ActionTiles = ({
166
282
  isExternal = false,
167
283
  label: tileLabel,
168
284
  value,
169
- }) => {
170
- const tileClassName = toClassName(
171
- // `flex`, not `inline-flex`: a grid item stretches to its
172
- // track, and an inline box would sit at max-content
173
- // inside it. `min-w-0` with `wrap-anywhere` below,
174
- // because a grid item's automatic minimum is its
175
- // min-content width — one unbroken token would otherwise
176
- // set the track's floor and shove the grid out of its
177
- // container.
178
- "flex min-w-0 flex-col gap-0.5 text-start wrap-anywhere transition-colors duration-(--duration-fast) ease-standard",
179
- TILE_BOX_CLASS,
180
- TILE_PADDING_CLASS[size],
181
- TILE_TEXT_SIZE_CLASS[size],
182
- // Disabled turns the whole tile down with `opacity-60`,
183
- // the same family treatment as `Checkbox`, `Switch` and
184
- // the radio tile, rather than fading the border to a
185
- // token that disappeared on a pale theme.
186
- isDisabled
187
- ? "cursor-not-allowed opacity-60"
188
- : "cursor-pointer hover:border-border-strong",
189
- FOCUS_RING_CLASS,
190
- )
285
+ } = item
191
286
 
192
- const content = (
193
- <>
194
- {icon === undefined ? null : (
195
- <span aria-hidden className="flex">
196
- {icon}
197
- </span>
198
- )}
287
+ const categorical =
288
+ accent === "none"
289
+ ? null
290
+ : getTileCategorical(item, position)
199
291
 
200
- <span className="font-semibold text-content-primary">
201
- {tileLabel}
202
- </span>
292
+ const tileClassName = toClassName(
293
+ // `flex`, not `inline-flex`: a grid item stretches to its
294
+ // track, and an inline box would sit at max-content
295
+ // inside it. `min-w-0` with `wrap-anywhere` below,
296
+ // because a grid item's automatic minimum is its
297
+ // min-content width — one unbroken token would otherwise
298
+ // set the track's floor and shove the grid out of its
299
+ // container.
300
+ "flex min-w-0 flex-col text-start wrap-anywhere transition-colors duration-(--duration-fast) ease-standard",
301
+ STACK_GAP_CLASS[size],
302
+ TILE_BOX_CLASS,
303
+ TILE_PADDING_CLASS[size],
304
+ TILE_TEXT_SIZE_CLASS[size],
305
+ // The bar is an overlay and takes no space in the box
306
+ // model, so the tile makes room for it or the text lands
307
+ // on top of it.
308
+ categorical === null
309
+ ? null
310
+ : toClassName(
311
+ getAccentEdgeClassName({ categorical }),
312
+ TILE_ACCENT_EDGE_PADDING_CLASS[size],
313
+ ),
314
+ // Disabled turns the whole tile down with `opacity-60`,
315
+ // the same family treatment as `Checkbox`, `Switch` and
316
+ // the radio tile, rather than fading the border to a
317
+ // token that disappeared on a pale theme. It also drops
318
+ // every hover class: a tile that cannot be pressed must
319
+ // not light up under the pointer.
320
+ isDisabled
321
+ ? "cursor-not-allowed opacity-60"
322
+ : toClassName(
323
+ "cursor-pointer",
324
+ categorical === null
325
+ ? "hover:border-border-strong"
326
+ : toClassName(
327
+ CATEGORICAL_HOVER_BORDER_CLASS[
328
+ categorical
329
+ ],
330
+ CATEGORICAL_HOVER_CLASS[categorical]
331
+ .ghost,
332
+ ),
333
+ ),
334
+ FOCUS_RING_CLASS,
335
+ )
203
336
 
204
- {hint === undefined ? null : (
337
+ const content = (
338
+ <>
339
+ {/* The head row. The icon sits BESIDE the name rather
340
+ than above it, which is how all three apps that had
341
+ grown this card by hand drew it, and how the owner
342
+ asked for it back: "I like how Mux-Magic looks today
343
+ with the icon to the left of the title." */}
344
+ <span
345
+ className={toClassName(
346
+ "flex min-w-0 items-center",
347
+ HEAD_GAP_CLASS[size],
348
+ )}
349
+ >
350
+ {icon === undefined ? null : (
205
351
  <span
352
+ aria-hidden
206
353
  className={toClassName(
207
- "font-normal text-content-muted",
208
- TILE_HINT_TEXT_CLASS[size],
354
+ "flex shrink-0",
355
+ categorical === null
356
+ ? null
357
+ : CATEGORICAL_CONTENT_CLASS[
358
+ categorical
359
+ ],
209
360
  )}
210
361
  >
211
- {hint}
362
+ {icon}
212
363
  </span>
213
364
  )}
214
365
 
215
- {isExternal && href !== undefined ? (
216
- <VisuallyHidden>
217
- {newTabLabel}
218
- </VisuallyHidden>
219
- ) : null}
220
- </>
221
- )
222
-
223
- if (href !== undefined && !isDisabled) {
224
- // An external destination or a fragment is never the
225
- // router's — pushing `https://…` or `#credits` onto the
226
- // history stack navigates the SPA to a route that does
227
- // not exist. Same destination test as `ButtonLink`.
228
- const LinkElement =
229
- isExternal || !getIsRoutedHref(href)
230
- ? "a"
231
- : RouterLink
366
+ <span className="min-w-0 font-semibold text-content-primary">
367
+ {tileLabel}
368
+ </span>
369
+ </span>
232
370
 
233
- return (
234
- <LinkElement
235
- className={tileClassName}
236
- href={href}
237
- key={value}
238
- // `noreferrer` alongside `noopener` on purpose:
239
- // `noopener` closes the `window.opener` hole, and
240
- // `noreferrer` is what keeps a private app's URL out
241
- // of the destination's logs.
242
- rel={
243
- isExternal
244
- ? "noopener noreferrer"
245
- : undefined
246
- }
247
- target={isExternal ? "_blank" : undefined}
371
+ {hint === undefined ? null : (
372
+ <span
373
+ className={toClassName(
374
+ "font-normal text-content-muted",
375
+ TILE_HINT_TEXT_CLASS[size],
376
+ )}
248
377
  >
249
- {content}
250
- </LinkElement>
251
- )
252
- }
378
+ {hint}
379
+ </span>
380
+ )}
253
381
 
254
- if (href !== undefined) {
255
- // `isDisabled` drops `href` entirely rather than shipping
256
- // a focusable anchor that silently ignores clicks — an
257
- // anchor with no `href` is inert by the platform's own
258
- // rules, and the explicit `role`/`aria-disabled` keeps it
259
- // *announced* as a link that is currently unavailable
260
- // rather than vanishing from the tree. `ButtonLink`'s
261
- // rule, unchanged.
262
- return (
263
- // biome-ignore lint/a11y/useValidAnchor: dropping `href` is the point. A disabled anchor that keeps one is focusable and silently ignores clicks; without one it is inert and out of the tab order by the platform's own rules, and the explicit `role`/`aria-disabled` keeps it announced rather than removed from the tree. `ButtonLink` makes the same call.
264
- <a
265
- aria-disabled="true"
266
- className={tileClassName}
267
- key={value}
268
- role="link"
269
- >
270
- {content}
271
- </a>
272
- )
273
- }
382
+ {isExternal && href !== undefined ? (
383
+ <VisuallyHidden>{newTabLabel}</VisuallyHidden>
384
+ ) : null}
385
+ </>
386
+ )
387
+
388
+ if (href !== undefined && !isDisabled) {
389
+ // An external destination or a fragment is never the
390
+ // router's — pushing `https://…` or `#credits` onto the
391
+ // history stack navigates the SPA to a route that does
392
+ // not exist. Same destination test as `ButtonLink`.
393
+ const LinkElement =
394
+ isExternal || !getIsRoutedHref(href)
395
+ ? "a"
396
+ : RouterLink
274
397
 
275
398
  return (
276
- <button
399
+ <LinkElement
277
400
  className={tileClassName}
278
- disabled={isDisabled}
401
+ href={href}
279
402
  key={value}
280
- onClick={() => {
281
- onChoose?.(value)
282
- }}
283
- type="button"
403
+ // `noreferrer` alongside `noopener` on purpose:
404
+ // `noopener` closes the `window.opener` hole, and
405
+ // `noreferrer` is what keeps a private app's URL out
406
+ // of the destination's logs.
407
+ rel={
408
+ isExternal
409
+ ? "noopener noreferrer"
410
+ : undefined
411
+ }
412
+ target={isExternal ? "_blank" : undefined}
284
413
  >
285
414
  {content}
286
- </button>
415
+ </LinkElement>
287
416
  )
288
- },
289
- )}
417
+ }
418
+
419
+ if (href !== undefined) {
420
+ // `isDisabled` drops `href` entirely rather than shipping
421
+ // a focusable anchor that silently ignores clicks — an
422
+ // anchor with no `href` is inert by the platform's own
423
+ // rules, and the explicit `role`/`aria-disabled` keeps it
424
+ // *announced* as a link that is currently unavailable
425
+ // rather than vanishing from the tree. `ButtonLink`'s
426
+ // rule, unchanged.
427
+ return (
428
+ // biome-ignore lint/a11y/useValidAnchor: dropping `href` is the point. A disabled anchor that keeps one is focusable and silently ignores clicks; without one it is inert and out of the tab order by the platform's own rules, and the explicit `role`/`aria-disabled` keeps it announced rather than removed from the tree. `ButtonLink` makes the same call.
429
+ <a
430
+ aria-disabled="true"
431
+ className={tileClassName}
432
+ key={value}
433
+ role="link"
434
+ >
435
+ {content}
436
+ </a>
437
+ )
438
+ }
439
+
440
+ return (
441
+ <button
442
+ className={tileClassName}
443
+ disabled={isDisabled}
444
+ key={value}
445
+ onClick={() => {
446
+ onChoose?.(value)
447
+ }}
448
+ type="button"
449
+ >
450
+ {content}
451
+ </button>
452
+ )
453
+ })}
290
454
  </div>
291
455
  )
292
456
  }
package/src/Main/Main.mdx CHANGED
@@ -148,7 +148,7 @@ region, so the table is announced as a thing rather than as loose content.
148
148
  position. Without it, Safari and Firefox scroll to `<main>` and leave the next Tab back up
149
149
  in the header — which looks exactly like a skip link that works.
150
150
 
151
- ## `scrollKey` — the reader's place, kept
151
+ ## The reader's place, kept
152
152
 
153
153
  <Canvas of={MainStories.ScrollMemory} />
154
154
 
@@ -166,17 +166,45 @@ changes it. The owner, on Docket:
166
166
  > "If I scroll, click on an item, then go back, it resets me to the top. […] It should
167
167
  > remember where the scroll position was previously. Isn't this handled by Chrome already?"
168
168
 
169
- Pass the history entry's key and it is:
169
+ **There is no prop for it.** The history entry arrives on a context, and
170
+ `ReactRouterAdapter` puts it at the app root beside the link seam:
170
171
 
171
172
  ```tsx
172
- const location = useLocation()
173
+ import { ReactRouterAdapter } from "@charcuterie/ui/react-router"
173
174
 
174
- <Main scrollKey={location.key}>
175
- <Outlet />
176
- </Main>
175
+ <BrowserRouter>
176
+ <ReactRouterAdapter>
177
+ <Routes>…</Routes>
178
+ </ReactRouterAdapter>
179
+ </BrowserRouter>
177
180
  ```
178
181
 
179
- Omit it and nothing is remembered, which is right for a page with no history to remember.
182
+ It was a prop for one release — `scrollKey` — and that release is the argument against a
183
+ prop. One app wired it and the other three carried on losing the reader's place with the
184
+ library sitting right there supporting it. Render no provider and nothing is remembered,
185
+ which is right for an app with no history to remember.
186
+
187
+ An app on another router wires `ScrollMemoryProvider` itself, with any value that identifies
188
+ the history **entry** rather than the URL:
189
+
190
+ ```tsx
191
+ <ScrollMemoryProvider entry={{ key: historyKey, path: location }}>
192
+ <App />
193
+ </ScrollMemoryProvider>
194
+ ```
195
+
196
+ ### A filter is not a navigation
197
+
198
+ A filter chip, an expanded group and a selected tab each write a search param, and
199
+ `setSearchParams` **navigates** — so each one mints a history entry the memory has never
200
+ seen. Read as "unseen ⇒ start at the top", pressing a filter throws the reader to the top of
201
+ the list they were reading. That shipped, and it is why an entry remembers its **path**:
202
+
203
+ | The entry | What happens |
204
+ | --- | --- |
205
+ | One we have seen | Its remembered offset. Back and Forward land where you left them. |
206
+ | New, **same** path | Nothing. The scrollport is left exactly where the reader put it. |
207
+ | New, **different** path | The top, which is what a browser does for a new document. |
180
208
 
181
209
  ### It has to wait for the content
182
210