@charcuterie/ui 3.0.0 → 3.2.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 (130) hide show
  1. package/README.md +10 -0
  2. package/dist/Badge/Badge.d.ts +33 -3
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +20 -2
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Board/Board.d.ts +119 -0
  7. package/dist/Board/Board.d.ts.map +1 -0
  8. package/dist/Board/Board.js +165 -0
  9. package/dist/Board/Board.js.map +1 -0
  10. package/dist/Board/BoardCard.d.ts +120 -0
  11. package/dist/Board/BoardCard.d.ts.map +1 -0
  12. package/dist/Board/BoardCard.js +77 -0
  13. package/dist/Board/BoardCard.js.map +1 -0
  14. package/dist/Board/BoardLaneList.d.ts +103 -0
  15. package/dist/Board/BoardLaneList.d.ts.map +1 -0
  16. package/dist/Board/BoardLaneList.js +69 -0
  17. package/dist/Board/BoardLaneList.js.map +1 -0
  18. package/dist/Board/boardMove.d.ts +116 -0
  19. package/dist/Board/boardMove.d.ts.map +1 -0
  20. package/dist/Board/boardMove.js +116 -0
  21. package/dist/Board/boardMove.js.map +1 -0
  22. package/dist/Board/useBoardDrag.d.ts +44 -0
  23. package/dist/Board/useBoardDrag.d.ts.map +1 -0
  24. package/dist/Board/useBoardDrag.js +237 -0
  25. package/dist/Board/useBoardDrag.js.map +1 -0
  26. package/dist/Checkbox/Checkbox.d.ts.map +1 -1
  27. package/dist/Checkbox/Checkbox.js +15 -4
  28. package/dist/Checkbox/Checkbox.js.map +1 -1
  29. package/dist/DataTable/DataTable.d.ts +159 -0
  30. package/dist/DataTable/DataTable.d.ts.map +1 -0
  31. package/dist/DataTable/DataTable.js +197 -0
  32. package/dist/DataTable/DataTable.js.map +1 -0
  33. package/dist/DatePicker/DateGrid.d.ts +79 -0
  34. package/dist/DatePicker/DateGrid.d.ts.map +1 -0
  35. package/dist/DatePicker/DateGrid.js +220 -0
  36. package/dist/DatePicker/DateGrid.js.map +1 -0
  37. package/dist/DatePicker/DatePicker.d.ts +138 -0
  38. package/dist/DatePicker/DatePicker.d.ts.map +1 -0
  39. package/dist/DatePicker/DatePicker.js +384 -0
  40. package/dist/DatePicker/DatePicker.js.map +1 -0
  41. package/dist/DatePicker/datePresets.d.ts +29 -0
  42. package/dist/DatePicker/datePresets.d.ts.map +1 -0
  43. package/dist/DatePicker/datePresets.js +28 -0
  44. package/dist/DatePicker/datePresets.js.map +1 -0
  45. package/dist/DatePicker/parseDateInput.d.ts +106 -0
  46. package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
  47. package/dist/DatePicker/parseDateInput.js +419 -0
  48. package/dist/DatePicker/parseDateInput.js.map +1 -0
  49. package/dist/DatePicker/plainDate.d.ts +167 -0
  50. package/dist/DatePicker/plainDate.d.ts.map +1 -0
  51. package/dist/DatePicker/plainDate.js +272 -0
  52. package/dist/DatePicker/plainDate.js.map +1 -0
  53. package/dist/Dialog/Dialog.d.ts +12 -2
  54. package/dist/Dialog/Dialog.d.ts.map +1 -1
  55. package/dist/Dialog/Dialog.js +2 -2
  56. package/dist/Dialog/Dialog.js.map +1 -1
  57. package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
  58. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
  59. package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
  60. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
  61. package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
  62. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
  63. package/dist/MarkdownEditor/markdownCommands.js +379 -0
  64. package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
  65. package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
  66. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
  67. package/dist/MarkdownEditor/markdownSpans.js +303 -0
  68. package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
  69. package/dist/Modal/Modal.d.ts +6 -2
  70. package/dist/Modal/Modal.d.ts.map +1 -1
  71. package/dist/Modal/Modal.js +1 -1
  72. package/dist/Modal/Modal.js.map +1 -1
  73. package/dist/Overlay/OverlayPanel.d.ts +13 -2
  74. package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
  75. package/dist/Overlay/OverlayPanel.js +5 -1
  76. package/dist/Overlay/OverlayPanel.js.map +1 -1
  77. package/dist/categoricalStyles.d.ts +53 -0
  78. package/dist/categoricalStyles.d.ts.map +1 -0
  79. package/dist/categoricalStyles.js +195 -0
  80. package/dist/categoricalStyles.js.map +1 -0
  81. package/dist/index.d.ts +22 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +11 -0
  84. package/dist/index.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/Badge/Badge.mdx +46 -0
  87. package/src/Badge/Badge.stories.tsx +98 -4
  88. package/src/Badge/Badge.test.tsx +157 -2
  89. package/src/Badge/Badge.tsx +49 -3
  90. package/src/Board/Board.mdx +199 -0
  91. package/src/Board/Board.stories.tsx +707 -0
  92. package/src/Board/Board.test.tsx +310 -0
  93. package/src/Board/Board.tsx +333 -0
  94. package/src/Board/BoardCard.tsx +290 -0
  95. package/src/Board/BoardLaneList.tsx +275 -0
  96. package/src/Board/boardMove.test.ts +213 -0
  97. package/src/Board/boardMove.ts +225 -0
  98. package/src/Board/useBoardDrag.ts +413 -0
  99. package/src/Checkbox/Checkbox.tsx +34 -3
  100. package/src/DataTable/DataTable.mdx +123 -0
  101. package/src/DataTable/DataTable.stories.tsx +411 -0
  102. package/src/DataTable/DataTable.test.tsx +386 -0
  103. package/src/DataTable/DataTable.tsx +497 -0
  104. package/src/DatePicker/DateGrid.tsx +467 -0
  105. package/src/DatePicker/DatePicker.mdx +170 -0
  106. package/src/DatePicker/DatePicker.stories.tsx +268 -0
  107. package/src/DatePicker/DatePicker.test.tsx +455 -0
  108. package/src/DatePicker/DatePicker.tsx +852 -0
  109. package/src/DatePicker/datePresets.ts +34 -0
  110. package/src/DatePicker/parseDateInput.test.ts +187 -0
  111. package/src/DatePicker/parseDateInput.ts +629 -0
  112. package/src/DatePicker/plainDate.test.ts +375 -0
  113. package/src/DatePicker/plainDate.ts +465 -0
  114. package/src/Dialog/Dialog.stories.tsx +48 -0
  115. package/src/Dialog/Dialog.test.tsx +30 -0
  116. package/src/Dialog/Dialog.tsx +13 -1
  117. package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
  118. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
  119. package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
  120. package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
  121. package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
  122. package/src/MarkdownEditor/markdownCommands.ts +566 -0
  123. package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
  124. package/src/MarkdownEditor/markdownSpans.ts +458 -0
  125. package/src/Modal/Modal.tsx +7 -1
  126. package/src/Overlay/OverlayPanel.tsx +17 -1
  127. package/src/argTypes.storyHelpers.ts +39 -0
  128. package/src/categoricalStyles.ts +244 -0
  129. package/src/index.ts +89 -0
  130. package/src/sourceRules.test.ts +52 -1
@@ -8,8 +8,13 @@ import { test } from "vitest"
8
8
  import { mountStory } from "../mountStory.testHelpers.ts"
9
9
  import meta, * as stories from "./Badge.stories.tsx"
10
10
 
11
- const { Default, Interactive, Responsive } =
12
- composeStories(stories)
11
+ const {
12
+ Categorical,
13
+ CategoricalLabels,
14
+ Default,
15
+ Interactive,
16
+ Responsive,
17
+ } = composeStories(stories)
13
18
 
14
19
  test("the text is queryable, which is what an agent matches on", async () => {
15
20
  const { canvas } = await mountStory(Default)
@@ -151,3 +156,153 @@ test("the responsive board shows both modes at three widths", async () => {
151
156
  canvas.getAllByText(LONG_LABEL),
152
157
  ).toHaveLength(6)
153
158
  })
159
+
160
+ // ---------------------------------------------------------------
161
+ // The categorical family
162
+ // ---------------------------------------------------------------
163
+
164
+ /**
165
+ * The assertion this component most needs, and the one every other
166
+ * test here structurally cannot make.
167
+ *
168
+ * Docket shipped `--color-danger-9` — a Radix-style step Charcuterie
169
+ * has never had. It resolved to nothing, painted transparent, and
170
+ * passed every "is the badge rendered" check in the suite, because
171
+ * a rendered element with no background is still a rendered
172
+ * element. `getByText` cannot see it. `toBeVisible` cannot see it.
173
+ * The only thing that can is asking the browser what colour it
174
+ * actually painted.
175
+ */
176
+ const getPaintedColours = (badge: HTMLElement) => {
177
+ const style = globalThis.getComputedStyle(badge)
178
+
179
+ return {
180
+ background: style.backgroundColor,
181
+ border: style.borderTopColor,
182
+ text: style.color,
183
+ }
184
+ }
185
+
186
+ const TRANSPARENT = "rgba(0, 0, 0, 0)"
187
+
188
+ test("a categorical badge paints a real colour, not a name that resolves to nothing", async () => {
189
+ const { canvas } = await mountStory(
190
+ composeStory(
191
+ { args: { categorical: 5, children: "Homelab" } },
192
+ meta,
193
+ ),
194
+ )
195
+
196
+ const badge = canvas.getByText("Homelab")
197
+ .parentElement as HTMLElement
198
+
199
+ const painted = getPaintedColours(badge)
200
+
201
+ expect(painted.background).not.toBe(TRANSPARENT)
202
+
203
+ expect(painted.border).not.toBe(TRANSPARENT)
204
+
205
+ // …and it is *the token's* value, not merely some colour. A
206
+ // utility resolving to an inherited or UA default would clear the
207
+ // check above and still be the bug.
208
+ const expected = globalThis
209
+ .getComputedStyle(document.documentElement)
210
+ .getPropertyValue("--color-categorical-5-surface")
211
+ .trim()
212
+
213
+ expect(expected).toMatch(/^#[0-9A-Fa-f]{6}$/)
214
+
215
+ const [red, green, blue] = [1, 3, 5].map((offset) =>
216
+ Number.parseInt(expected.slice(offset, offset + 2), 16),
217
+ )
218
+
219
+ expect(painted.background).toBe(
220
+ `rgb(${red}, ${green}, ${blue})`,
221
+ )
222
+ })
223
+
224
+ test("ten indexes paint ten different colours", async () => {
225
+ // The DOM half of the distinctness gate in `@charcuterie/tokens`.
226
+ // That one measures the token values; this one measures what the
227
+ // browser did with them, which is where an index silently falling
228
+ // back to its neighbour's utility would show up.
229
+ const { canvas } = await mountStory(Categorical)
230
+
231
+ const solids = new Set(
232
+ canvas
233
+ .getAllByText(
234
+ /^(Red|Orange|Amber|Lime|Green|Teal|Blue|Indigo|Purple|Pink)$/,
235
+ )
236
+ .map(
237
+ (label: HTMLElement) =>
238
+ getPaintedColours(
239
+ label.parentElement as HTMLElement,
240
+ ).background,
241
+ ),
242
+ )
243
+
244
+ solids.delete(TRANSPARENT)
245
+
246
+ // Three appearances x ten indexes, and `outline` is transparent
247
+ // by design — so twenty painted fills, all different.
248
+ expect(solids.size).toBe(20)
249
+ })
250
+
251
+ test("the same label name always gets the same colour", async () => {
252
+ // The whole point of `getCategoricalIndex`: a hundred rows that
253
+ // predate the feature need no migration, and the answer must not
254
+ // move between renders, machines, or a server-rendered copy.
255
+ const first = await mountStory(CategoricalLabels)
256
+
257
+ const firstColour = getPaintedColours(
258
+ first.canvas.getByText("Homelab")
259
+ .parentElement as HTMLElement,
260
+ ).background
261
+
262
+ const second = await mountStory(CategoricalLabels)
263
+
264
+ expect(
265
+ getPaintedColours(
266
+ second.canvas.getByText("Homelab")
267
+ .parentElement as HTMLElement,
268
+ ).background,
269
+ ).toBe(firstColour)
270
+
271
+ // And two different names do not all collapse onto one colour,
272
+ // which a stable-but-useless hash would also satisfy.
273
+ expect(
274
+ getPaintedColours(
275
+ second.canvas.getByText("Errands")
276
+ .parentElement as HTMLElement,
277
+ ).background,
278
+ ).not.toBe(firstColour)
279
+ })
280
+
281
+ test("a categorical badge keeps every behaviour an intent badge has", async () => {
282
+ // `overflow`, `size` and the clipping readout are the component's,
283
+ // not the intent's — but a new prop threaded through a class map
284
+ // is exactly where that quietly stops being true.
285
+ const { canvasElement } = await mountStory(
286
+ composeStory(
287
+ {
288
+ args: {
289
+ categorical: 8,
290
+ children: LONG_LABEL,
291
+ overflow: "truncate",
292
+ },
293
+ },
294
+ meta,
295
+ ),
296
+ )
297
+
298
+ canvasElement.style.inlineSize = "10rem"
299
+
300
+ const badge =
301
+ canvasElement.firstElementChild as HTMLElement
302
+
303
+ await waitFor(() => {
304
+ expect(badge).toHaveAttribute("title", LONG_LABEL)
305
+ })
306
+
307
+ expect(badge.textContent).toBe(LONG_LABEL)
308
+ })
@@ -1,9 +1,13 @@
1
- import type { IntentName } from "@charcuterie/tokens"
1
+ import type {
2
+ CategoricalIndex,
3
+ IntentName,
4
+ } from "@charcuterie/tokens"
2
5
  import type {
3
6
  ComponentPropsWithRef,
4
7
  ReactNode,
5
8
  } from "react"
6
9
 
10
+ import { CATEGORICAL_APPEARANCE_CLASS } from "../categoricalStyles.ts"
7
11
  import type { BadgeSize } from "../controlStyles.ts"
8
12
  import { BADGE_SIZE_CLASS } from "../controlStyles.ts"
9
13
  import type { IntentAppearance } from "../intentStyles.ts"
@@ -13,6 +17,22 @@ import { useIsTextClipped } from "./useIsTextClipped.ts"
13
17
 
14
18
  export type BadgeProps = ComponentPropsWithRef<"span"> & {
15
19
  appearance?: Exclude<IntentAppearance, "ghost">
20
+ /**
21
+ * A numbered, **non-semantic** colour: 1–10, no meaning attached.
22
+ *
23
+ * For a badge whose colour the user picked — a Docket label, a
24
+ * project, a chart series — where `intent` would be a lie. A
25
+ * "Homelab" label is not a `danger`, and the palette is curated
26
+ * and contrast-gated in both schemes precisely so the user cannot
27
+ * pick one that is unreadable. `getCategoricalIndex(key)` from
28
+ * `@charcuterie/tokens` is the stable fallback for a row nobody
29
+ * has chosen a colour for yet.
30
+ *
31
+ * Mutually exclusive with `intent` **in the type**: a badge is one
32
+ * colour, and passing both is a question with no answer rather
33
+ * than a precedence rule to remember.
34
+ */
35
+ categorical?: CategoricalIndex
16
36
  children: ReactNode
17
37
  icon?: ReactNode
18
38
  intent?: IntentName
@@ -28,7 +48,14 @@ export type BadgeProps = ComponentPropsWithRef<"span"> & {
28
48
  */
29
49
  overflow?: "truncate" | "wrap"
30
50
  size?: BadgeSize
31
- }
51
+ /**
52
+ * The exclusivity, and it is deliberately the *only* thing this
53
+ * arm says. Both props stay declared above so `react-docgen` can
54
+ * see them and `storyControls.test.ts` can require a control for
55
+ * each; this arm's whole job is to refuse the case where both
56
+ * arrive with a value.
57
+ */
58
+ } & ({ categorical?: never } | { intent?: never })
32
59
 
33
60
  /**
34
61
  * Four repos, and rip-deck declares the **identical** `TONE_CLASS`
@@ -54,6 +81,16 @@ export type BadgeProps = ComponentPropsWithRef<"span"> & {
54
81
  * asserts the text is queryable, which is what an agent actually
55
82
  * matches on.
56
83
  *
84
+ * **`intent` or `categorical`, never both.** The two families are
85
+ * both seven roles wide and both paint a pill, and they answer
86
+ * opposite questions: an intent is a *claim* the design system
87
+ * makes (`danger` says what happens if you press the thing), and a
88
+ * categorical index is a claim about nothing at all — it is a
89
+ * colour a user picked for a label. Which is why the exclusivity is
90
+ * in the type rather than in a precedence rule: a badge is one
91
+ * colour, and `<Badge intent="danger" categorical={3}>` is a
92
+ * question with no answer.
93
+ *
57
94
  * **It is capped at its container.** `shrink-0` and
58
95
  * `whitespace-nowrap` alone let a long label paint straight through
59
96
  * the neighbouring column — no clipping, no error, just a pill lying
@@ -62,6 +99,7 @@ export type BadgeProps = ComponentPropsWithRef<"span"> & {
62
99
  */
63
100
  export const Badge = ({
64
101
  appearance = "soft",
102
+ categorical,
65
103
  children,
66
104
  className,
67
105
  icon,
@@ -87,7 +125,15 @@ export const Badge = ({
87
125
  "rounded-2xl"
88
126
  : "rounded-full",
89
127
  BADGE_SIZE_CLASS[size],
90
- INTENT_APPEARANCE_CLASS[intent][appearance],
128
+ // A ternary rather than a merged map: the two are indexed
129
+ // by different keys, and `undefined` is the only honest
130
+ // discriminant once the type has already refused the case
131
+ // where both arrive.
132
+ categorical === undefined
133
+ ? INTENT_APPEARANCE_CLASS[intent][appearance]
134
+ : CATEGORICAL_APPEARANCE_CLASS[categorical][
135
+ appearance
136
+ ],
91
137
  className,
92
138
  )}
93
139
  // Only when something is actually hidden. A tooltip on every
@@ -0,0 +1,199 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as BoardStories from "./Board.stories";
4
+
5
+ <Meta of={BoardStories} />
6
+
7
+ # Board
8
+
9
+ Lanes of cards, sized by their **container**, moved by keyboard or by pointer — and with
10
+ **no drag-and-drop dependency**.
11
+
12
+ <Canvas of={BoardStories.Default} />
13
+
14
+ ## Props
15
+
16
+ <Controls />
17
+
18
+ ## The container rule, and why it is the whole component
19
+
20
+ There is no media query anywhere in `Board`, `BoardCard` or `BoardLaneList`, and adding one
21
+ would be a defect rather than a shortcut. Two facts make window width useless here, and they
22
+ compound:
23
+
24
+ - **A lane in a three-up board is narrow on any monitor.** Three lanes across a 1600px window
25
+ is ~500px each — a phone's worth of room, on a 4K display, maximised.
26
+ - **A zoomed browser lies about pixels.** A 1500px window at 175% zoom is ~860 effective CSS
27
+ pixels, so the number a `@media` query reads is not the number of pixels anything has.
28
+
29
+ So there are **two nested containers**, answering two different questions:
30
+
31
+ | Container | Decides |
32
+ | --- | --- |
33
+ | the board's own box | how many lanes are on screen — three-up at `cq-lg`, otherwise one lane and a segmented control |
34
+ | each lane's wrapper | what shape a card is — two lines, one line, or its own card |
35
+
36
+ `Responsive` is the only honest way to show it: three fixed-width panels inside one browser
37
+ window that never moves. Resizing the viewport would prove nothing, because the thing that
38
+ changed is the component's container, not the window.
39
+
40
+ <Canvas of={BoardStories.Responsive} />
41
+
42
+ The narrow panel there is **not a phone**. It is the same board in a sidebar, in a split
43
+ window, or on a display somebody has zoomed to 175% — and a media query cannot tell any of
44
+ those apart from a maximised 4K window.
45
+
46
+ ### The three card shapes
47
+
48
+ Straight from the consumer requirement, which is specific because the owner answered *"which
49
+ row style?"* with **"all three, depending"**:
50
+
51
+ | The lane is | The card is |
52
+ | --- | --- |
53
+ | narrower than `cq-sm` (24rem) | **two lines** — title clamped to two, metadata underneath, trailing slot beside it |
54
+ | `cq-sm` to `cq-lg` | **one line** — title ellipsised, metadata right-aligned |
55
+ | `cq-lg` (48rem) and wider | **a card** — its own border, surface and elevation instead of a divider in a list |
56
+
57
+ It dissolved a bug rather than only satisfying a taste: a *fixed* two-line row collapsed its
58
+ title to `a…` inside a narrow lane, because the metadata beside it could not shrink. A card
59
+ sized by its container has no such state to be in.
60
+
61
+ ### The wrappers are load-bearing
62
+
63
+ A container query matches **descendants only**, never the element that declares the
64
+ container. So the lane's panel cannot be the container *and* take its own frame off at
65
+ `cq-lg`; the `@container` sits on a wrapper `<div>` and the panel inside it does the
66
+ querying. Same reason `EmptyState` has one. Written on one element it compiles, generates
67
+ real CSS, and silently never fires — which is why `sourceRules.test.ts` fails on it.
68
+
69
+ ## Moving a card, and the drag-and-drop decision
70
+
71
+ **No DnD library is taken as a dependency.** The full reasoning — licence, provenance, bundle
72
+ cost, and what a general DnD library would actually be buying for one card going into one
73
+ lane — is in
74
+ [the decision record](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-08-19-the-board-owns-the-move-and-takes-no-drag-and-drop-dependency.md).
75
+
76
+ The move is one control with two drivers, and they commit through the same `onMove`:
77
+
78
+ 1. **A `Menu` on every card**, listing the other lanes. This is the **primary** path, not an
79
+ accessible alternative to a real one. It is the only one that works from the keyboard, the
80
+ only one a screen reader can drive, the only one that works on touch without a long-press
81
+ convention nobody discovers — and the only one available in the Narrow View, where the
82
+ other lanes are not on screen to drop onto.
83
+ 2. **Pointer dragging from the same handle.** Pointer Events, so mouse, touch and pen are one
84
+ code path, with `setPointerCapture` so the drag survives the pointer leaving the card.
85
+ Escape abandons it.
86
+
87
+ <Canvas of={BoardStories.Interactive} />
88
+
89
+ Every move is announced in a `role="status"` region that names the **destination and the
90
+ position** — "Moved *X* to In Progress, position 3 of 6." That is the feedback a sighted user
91
+ gets free from watching the card land, and the thing almost every drag implementation forgets
92
+ to say out loud.
93
+
94
+ ### Why not `draggable="true"`
95
+
96
+ The zero-dependency instinct, and the wrong one. HTML5 drag-and-drop does not fire on touch at
97
+ all, its drag image is a browser-drawn ghost with no styling hook worth using, and
98
+ `dataTransfer` is readable only inside a real `drop` — which makes a live drop indicator
99
+ awkward. Pointer Events cost the same (nothing) and cover every input.
100
+
101
+ ### The move handle is a word by default
102
+
103
+ `Board` ships **no icon**, because a `⋮` renders as nothing where the font lacks it — this
104
+ sandbox's chromium, the kiosk image, the ePaper build
105
+ ([decision](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md)).
106
+ An app that already owns a glyph set passes `moveIcon` and buys back ~55px a row, which in a
107
+ 500px lane is the difference between twenty characters of title and thirty.
108
+
109
+ <Canvas of={BoardStories.AllVariants} />
110
+
111
+ **No `onMove` means read-only**, all or nothing: no handles, no menus, no drag. A per-card
112
+ `isMovable` would put the decision in the data, where a card that cannot move looks identical
113
+ to one nobody has thought about.
114
+
115
+ ## Where the line is drawn
116
+
117
+ `Board` owns lanes, counts, truncation, empty states and the move. It does **not** own:
118
+
119
+ | Not here | Because |
120
+ | --- | --- |
121
+ | **Data** | It is a view of a list the consumer owns. `onMove` hands back a `fromIndex`/`toIndex` pair; the consumer splices. |
122
+ | **Virtualisation** | A lane truncates by construction — `itemCount` is the true total, `items` is what you chose to paint, and the difference becomes "+ n more". *"Long lanes truncate with an honest `+ n more` rather than scrolling forever."* There is nothing left to window. |
123
+ | **The cross-lane "needs attention" panel** | It reaches across every lane, so it is not a lane; it is absent when empty, so the board cannot reserve room for it; and it is already `Alert`. Composing it keeps `Board` about lanes and keeps the banner reusable above a list that is not a board. |
124
+ | **Sorting and filtering** | Both are operations on the array you pass in. |
125
+
126
+ <Canvas of={BoardStories.InBoardScreen} />
127
+
128
+ That story is the copy-wholesale template: a page heading, an `Alert` for what needs
129
+ attention, and the board under it with `headingLevel={2}` so the outline stays whole.
130
+
131
+ ## `toIndex` is already corrected for the removal
132
+
133
+ The most common bug in a hand-rolled board, folded into the component instead:
134
+
135
+ ```tsx
136
+ <Board
137
+ label="Today"
138
+ lanes={lanes}
139
+ onMove={(move) => {
140
+ setLanes((current) =>
141
+ current.map((lane) => {
142
+ const kept =
143
+ lane.key === move.fromLaneKey
144
+ ? lane.items.filter((item) => item.key !== move.itemKey)
145
+ : lane.items
146
+
147
+ if (lane.key !== move.toLaneKey) {
148
+ return { ...lane, items: kept }
149
+ }
150
+
151
+ return {
152
+ ...lane,
153
+ items: [
154
+ ...kept.slice(0, move.toIndex),
155
+ moved,
156
+ ...kept.slice(move.toIndex),
157
+ ],
158
+ }
159
+ }),
160
+ )
161
+ }}
162
+ />
163
+ ```
164
+
165
+ No arithmetic. A within-lane move quoted against the un-spliced list is one too high for every
166
+ position below the origin, so the component subtracts it once, in
167
+ [`toSettledIndex`](https://github.com/Sawtaytoes/charcuterie/blob/master/packages/ui/src/Board/boardMove.ts),
168
+ which is unit-tested in Node against fixtures — including the tie where a card dropped back on
169
+ its own midpoint must be **no move at all**.
170
+
171
+ ## Accessibility
172
+
173
+ - The board is a **named landmark**; each lane is a `role="group"`, not a second landmark. A
174
+ board with four `region`s buries the page's real ones, and two boards on one page put two
175
+ landmarks called "Todo" in the document. Same call the
176
+ [accordion panel](https://github.com/Sawtaytoes/charcuterie/blob/master/docs/decisions/2026-07-31-an-accordion-panel-is-a-group-not-a-landmark.md)
177
+ made. It is a `<div role="group">` rather than a `<section role="group">` because axe's
178
+ `landmark-unique` matches `section[aria-labelledby]` on the element and audits it as a
179
+ landmark even after the role is overridden.
180
+ - **The priority bar's meaning is in words.** `accentIntent` paints it; `accentLabel` puts the
181
+ same fact in a `VisuallyHidden`, because colour alone fails WCAG 1.4.1 and is invisible to
182
+ every screen reader. Do the same for anything you put in `marks` — a bare coloured dot is
183
+ the identical failure.
184
+ - **`headingLevel` is yours.** Only the caller knows what the board sits under; an `<h3>`
185
+ directly below the page `<h1>` is an axe `heading-order` failure.
186
+ - Every move handle's accessible name says *which* card and *where it is now*. Thirty controls
187
+ named "Move" are thirty indistinguishable buttons to a screen reader and to
188
+ `getByRole("button", { name })`.
189
+
190
+ <Canvas of={BoardStories.AllStates} />
191
+
192
+ ## Never invent a token name
193
+
194
+ The colour families here are `--color-intent-<intent>-<role>`, plus `surface`, `content` and
195
+ `border`. **There is no numeric scale.** Docket's first board painted every priority bar with
196
+ `--color-danger-9`, a Radix-style name this token set has never had, so every bar was
197
+ transparent — while every "is it rendered" assertion passed and only looking at the image
198
+ caught it. `accentIntent` is typed as `IntentName` for exactly that reason: a wrong value is a
199
+ compile error rather than an invisible rectangle.