@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
@@ -0,0 +1,707 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+ import type { ReactNode } from "react"
3
+ import { useState } from "react"
4
+
5
+ import { Alert } from "../Alert/Alert.tsx"
6
+ import { Badge } from "../Badge/Badge.tsx"
7
+ import { Button } from "../Button/Button.tsx"
8
+ import {
9
+ ContainerBoard,
10
+ StoryCell,
11
+ StoryGrid,
12
+ } from "../board.storyHelpers.tsx"
13
+ import { EmptyState } from "../EmptyState/EmptyState.tsx"
14
+ import { MoreIcon } from "../icons.storyHelpers.tsx"
15
+ import { ProgressBar } from "../ProgressBar/ProgressBar.tsx"
16
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
17
+ import type { BoardMove } from "./Board.tsx"
18
+ import { Board } from "./Board.tsx"
19
+ import type { BoardItem } from "./BoardCard.tsx"
20
+ import type { BoardLane } from "./BoardLaneList.tsx"
21
+
22
+ /**
23
+ * **Invented, all of it.** The board this component was built for
24
+ * tracks a real household, and its prototype's fixture file is real
25
+ * data — which is exactly the thing that must never reach a
26
+ * published repo, and doubly never a committed screenshot, because a
27
+ * PNG is opaque to every grep and no text scrub can redact it.
28
+ *
29
+ * So this is a fictional studio's tooling backlog: long enough
30
+ * titles to make truncation happen, enough rows to make the "+ n
31
+ * more" line honest, and a lane with a live agent run in it.
32
+ */
33
+ const PROJECTS = {
34
+ atlas: "Atlas Ingest",
35
+ ferry: "Ferry Docs",
36
+ loom: "Loom Fleet",
37
+ signal: "Signal Kitchen",
38
+ } as const
39
+
40
+ /**
41
+ * Priority as an intent, written out once. There is no numeric
42
+ * colour scale in this token set — the families are
43
+ * `--color-intent-<intent>-<role>` — so a `p0`/`p1` map has to land
44
+ * on an intent name or it lands on nothing at all and paints
45
+ * transparent.
46
+ */
47
+ const PRIORITY = {
48
+ p0: { intent: "danger", label: "Priority 0" },
49
+ p1: { intent: "warning", label: "Priority 1" },
50
+ p2: { intent: "accent", label: "Priority 2" },
51
+ p3: { intent: "neutral", label: "Priority 3" },
52
+ } as const
53
+
54
+ const Mark = ({
55
+ intent,
56
+ name,
57
+ }: {
58
+ intent: "danger" | "success" | "warning"
59
+ name: string
60
+ }): ReactNode => (
61
+ <span
62
+ className={
63
+ intent === "success"
64
+ ? "block size-2 rounded-full bg-intent-success-solid"
65
+ : intent === "warning"
66
+ ? "block size-2 rounded-full bg-intent-warning-solid"
67
+ : "block size-2 rounded-full bg-intent-danger-solid"
68
+ }
69
+ >
70
+ <VisuallyHidden>{name}</VisuallyHidden>
71
+ </span>
72
+ )
73
+
74
+ /**
75
+ * An assignee. Two initials and a name nobody has to hover to hear —
76
+ * an avatar with no accessible name is a coloured circle, which is
77
+ * the fleet's most-repeated a11y defect after the unnamed icon
78
+ * button.
79
+ */
80
+ const Avatar = ({
81
+ initials,
82
+ name,
83
+ }: {
84
+ initials: string
85
+ name: string
86
+ }): ReactNode => (
87
+ <span className="grid size-5 shrink-0 place-items-center rounded-full bg-intent-info-surface font-medium text-[0.625rem] text-intent-info-content">
88
+ <span aria-hidden="true">{initials}</span>
89
+
90
+ <VisuallyHidden>{`Assigned to ${name}`}</VisuallyHidden>
91
+ </span>
92
+ )
93
+
94
+ const ProjectChip = ({
95
+ name,
96
+ }: {
97
+ name: string
98
+ }): ReactNode => (
99
+ <Badge appearance="outline" intent="neutral" size="sm">
100
+ {name}
101
+ </Badge>
102
+ )
103
+
104
+ const LabelChip = ({
105
+ name,
106
+ }: {
107
+ name: string
108
+ }): ReactNode => (
109
+ <Badge appearance="soft" intent="success" size="sm">
110
+ {name}
111
+ </Badge>
112
+ )
113
+
114
+ /**
115
+ * The live-run line. `ProgressBar` with its label hidden, because
116
+ * the phase text beside it already says what is happening — a
117
+ * visible "Adopting the new toolbar" twice is a screen-reader
118
+ * duplicate for no gain.
119
+ */
120
+ const RunLine = ({
121
+ elapsed,
122
+ percent,
123
+ phase,
124
+ }: {
125
+ elapsed: string
126
+ percent: number
127
+ phase: string
128
+ }): ReactNode => (
129
+ <span className="flex items-center gap-2">
130
+ <span className="min-w-0 flex-1 truncate text-intent-success-content">
131
+ {phase}
132
+ </span>
133
+
134
+ <ProgressBar
135
+ className="w-16 shrink-0"
136
+ intent="success"
137
+ label={`${phase} progress`}
138
+ size="sm"
139
+ value={percent}
140
+ />
141
+
142
+ <span className="shrink-0 tabular-nums">{elapsed}</span>
143
+ </span>
144
+ )
145
+
146
+ const FailedLine = ({
147
+ reason,
148
+ }: {
149
+ reason: string
150
+ }): ReactNode => (
151
+ <span className="text-intent-danger-content">
152
+ {reason}
153
+ </span>
154
+ )
155
+
156
+ const toItem = ({
157
+ assignee,
158
+ footer,
159
+ label = "agent-runnable",
160
+ marks,
161
+ priority,
162
+ project,
163
+ title,
164
+ }: {
165
+ assignee: [string, string]
166
+ footer?: ReactNode
167
+ label?: string
168
+ marks?: ReactNode
169
+ priority: keyof typeof PRIORITY
170
+ project: string
171
+ title: string
172
+ }): BoardItem => ({
173
+ accentIntent: PRIORITY[priority].intent,
174
+ accentLabel: PRIORITY[priority].label,
175
+ footer,
176
+ href: `#/task/${encodeURIComponent(title)}`,
177
+ key: title,
178
+ marks,
179
+ meta: (
180
+ <>
181
+ <ProjectChip name={project} />
182
+
183
+ <LabelChip name={label} />
184
+ </>
185
+ ),
186
+ title,
187
+ trailing: (
188
+ <Avatar initials={assignee[0]} name={assignee[1]} />
189
+ ),
190
+ })
191
+
192
+ const ADA: [string, string] = ["AD", "Ada"]
193
+
194
+ const WREN: [string, string] = ["WR", "Wren"]
195
+
196
+ const TODO_ITEMS: BoardItem[] = [
197
+ toItem({
198
+ assignee: WREN,
199
+ priority: "p0",
200
+ project: PROJECTS.signal,
201
+ title:
202
+ "Retire the second scheduler and fold its jobs into the broker",
203
+ }),
204
+ toItem({
205
+ assignee: ADA,
206
+ priority: "p1",
207
+ project: PROJECTS.loom,
208
+ title:
209
+ "One composed Storybook for every app in the fleet",
210
+ }),
211
+ toItem({
212
+ assignee: ADA,
213
+ marks: <Mark intent="warning" name="Stale" />,
214
+ priority: "p1",
215
+ project: PROJECTS.atlas,
216
+ title:
217
+ "Fingerprint duplicate uploads before they reach the queue",
218
+ }),
219
+ toItem({
220
+ assignee: WREN,
221
+ label: "physical",
222
+ priority: "p2",
223
+ project: PROJECTS.ferry,
224
+ title:
225
+ "Photograph the rack layout for the wiring appendix",
226
+ }),
227
+ toItem({
228
+ assignee: ADA,
229
+ priority: "p2",
230
+ project: PROJECTS.signal,
231
+ title:
232
+ "Move the nightly digest onto the shared template",
233
+ }),
234
+ toItem({
235
+ assignee: ADA,
236
+ priority: "p3",
237
+ project: PROJECTS.loom,
238
+ title: "Drop the four local overflow implementations",
239
+ }),
240
+ ]
241
+
242
+ const IN_PROGRESS_ITEMS: BoardItem[] = [
243
+ toItem({
244
+ assignee: ADA,
245
+ footer: (
246
+ <RunLine
247
+ elapsed="41m"
248
+ percent={64}
249
+ phase="Adopting the toolbar in 3 of 4 repos"
250
+ />
251
+ ),
252
+ marks: <Mark intent="success" name="Agent running" />,
253
+ priority: "p0",
254
+ project: PROJECTS.loom,
255
+ title: "Unify the page chrome across the fleet",
256
+ }),
257
+ toItem({
258
+ assignee: ADA,
259
+ footer: (
260
+ <FailedLine reason="Failed: the ingest token expired after 3 retries" />
261
+ ),
262
+ marks: <Mark intent="danger" name="Run failed" />,
263
+ priority: "p0",
264
+ project: PROJECTS.atlas,
265
+ title: "Link queue for the weekly digest",
266
+ }),
267
+ toItem({
268
+ assignee: WREN,
269
+ priority: "p1",
270
+ project: PROJECTS.signal,
271
+ title: "Wire the pantry sensors onto the new bridge",
272
+ }),
273
+ toItem({
274
+ assignee: ADA,
275
+ footer: (
276
+ <RunLine
277
+ elapsed="2h 06m"
278
+ percent={34}
279
+ phase="Fingerprinting batch 118 of 342"
280
+ />
281
+ ),
282
+ marks: <Mark intent="success" name="Agent running" />,
283
+ priority: "p1",
284
+ project: PROJECTS.atlas,
285
+ title: "Deduplicate the archive by content hash",
286
+ }),
287
+ toItem({
288
+ assignee: ADA,
289
+ marks: <Mark intent="danger" name="Blocked" />,
290
+ priority: "p2",
291
+ project: PROJECTS.ferry,
292
+ title: "Print farm queue — waiting on filament",
293
+ }),
294
+ ]
295
+
296
+ const REVIEW_ITEMS: BoardItem[] = [
297
+ toItem({
298
+ assignee: ADA,
299
+ priority: "p1",
300
+ project: PROJECTS.loom,
301
+ title:
302
+ "Merge manager rename, split-out and add-games flow",
303
+ }),
304
+ toItem({
305
+ assignee: ADA,
306
+ priority: "p1",
307
+ project: PROJECTS.signal,
308
+ title: "Usage rollup across every provider, one table",
309
+ }),
310
+ toItem({
311
+ assignee: WREN,
312
+ priority: "p2",
313
+ project: PROJECTS.atlas,
314
+ title: "Gallery downloader keeps its own store now",
315
+ }),
316
+ toItem({
317
+ assignee: ADA,
318
+ priority: "p2",
319
+ project: PROJECTS.loom,
320
+ title:
321
+ "Push-transport kit for the shared broker package",
322
+ }),
323
+ toItem({
324
+ assignee: ADA,
325
+ priority: "p2",
326
+ project: PROJECTS.ferry,
327
+ title:
328
+ "Un-pin the docs generator from the old renderer",
329
+ }),
330
+ toItem({
331
+ assignee: WREN,
332
+ priority: "p3",
333
+ project: PROJECTS.loom,
334
+ title: "Brace expansion no longer melts the search box",
335
+ }),
336
+ toItem({
337
+ assignee: ADA,
338
+ priority: "p3",
339
+ project: PROJECTS.signal,
340
+ title: "Generalise the app updater beyond one registry",
341
+ }),
342
+ toItem({
343
+ assignee: ADA,
344
+ priority: "p3",
345
+ project: PROJECTS.atlas,
346
+ title: "Container image auto-update, with a rollback",
347
+ }),
348
+ ]
349
+
350
+ const LANES: BoardLane[] = [
351
+ { items: TODO_ITEMS, key: "todo", label: "Todo" },
352
+ {
353
+ items: IN_PROGRESS_ITEMS,
354
+ key: "in-progress",
355
+ label: "In Progress",
356
+ },
357
+ {
358
+ actions: (
359
+ <Button appearance="ghost" intent="accent" size="sm">
360
+ Accept all
361
+ </Button>
362
+ ),
363
+ // Eight rendered, nineteen real — which is what makes the
364
+ // "+ 11 more" line true rather than decorative.
365
+ itemCount: 19,
366
+ items: REVIEW_ITEMS,
367
+ key: "review",
368
+ label: "Needs Review",
369
+ onShowMore: () => undefined,
370
+ },
371
+ ]
372
+
373
+ /**
374
+ * A **fixed** inline size, because the three-up layout is a
375
+ * measurement of the board's own box. A story that inherited the
376
+ * canvas width would show three lanes on one machine and one lane
377
+ * plus a picker on another, and the test asserting it would be
378
+ * asserting the window.
379
+ */
380
+ const Frame = ({
381
+ children,
382
+ inlineSize,
383
+ }: {
384
+ children: ReactNode
385
+ inlineSize: string
386
+ }): ReactNode => (
387
+ <div style={{ inlineSize }}>{children}</div>
388
+ )
389
+
390
+ const meta = {
391
+ title: "Components/Board",
392
+ component: Board,
393
+ parameters: { layout: "padded" },
394
+ args: {
395
+ label: "Today",
396
+ lanes: LANES,
397
+ },
398
+ } satisfies Meta<typeof Board>
399
+
400
+ export default meta
401
+
402
+ type Story = StoryObj<typeof meta>
403
+
404
+ /**
405
+ * Three lanes at the density a real board reaches: priority bars,
406
+ * project and label chips, assignee avatars, live run lines with
407
+ * elapsed times, a failed run, and an honest overflow.
408
+ *
409
+ * Read-only — there is no `onMove`, so there are no handles. That is
410
+ * a real mode rather than a story convenience: a board on a wall
411
+ * display should not be advertising affordances nobody can use.
412
+ */
413
+ export const Default: Story = {}
414
+
415
+ /**
416
+ * Read-only against movable, side by side.
417
+ *
418
+ * The difference is one prop, and it is deliberately all-or-nothing:
419
+ * a per-card `isMovable` would put the decision in the data, where a
420
+ * consumer has to remember to set it, and where a card that cannot
421
+ * move looks identical to one nobody has thought about.
422
+ */
423
+ export const AllVariants: Story = {
424
+ render: (boardProps) => (
425
+ <StoryGrid columns={1}>
426
+ <StoryCell
427
+ align="stretch"
428
+ label="read-only — no `onMove`, so no handles"
429
+ >
430
+ <Board {...boardProps} label="Today, read-only" />
431
+ </StoryCell>
432
+
433
+ <StoryCell
434
+ align="stretch"
435
+ label="movable — every card carries a Move handle"
436
+ >
437
+ <Board
438
+ {...boardProps}
439
+ label="Today, movable"
440
+ onMove={() => undefined}
441
+ />
442
+ </StoryCell>
443
+
444
+ <StoryCell
445
+ align="stretch"
446
+ label="movable, with the app's own `moveIcon` — ~55px a row cheaper"
447
+ >
448
+ <Board
449
+ {...boardProps}
450
+ label="Today, movable with an icon"
451
+ moveIcon={<MoreIcon />}
452
+ onMove={() => undefined}
453
+ />
454
+ </StoryCell>
455
+ </StoryGrid>
456
+ ),
457
+ }
458
+
459
+ /**
460
+ * The states a board is actually in, and the two nobody screenshots:
461
+ * a lane with nothing in it, and a lane truncated with more behind
462
+ * it.
463
+ *
464
+ * The empty lane is a real `EmptyState` with its own wording, not a
465
+ * grey "Empty". *"Real empty states"* was a stated requirement,
466
+ * because the empty lane is the one a person looks at most on a good
467
+ * day.
468
+ */
469
+ export const AllStates: Story = {
470
+ render: (boardProps) => (
471
+ <StoryGrid columns={1}>
472
+ <StoryCell
473
+ align="stretch"
474
+ label="72rem — wide enough for three-up; an empty lane, a full one, and a truncated one"
475
+ >
476
+ <Frame inlineSize="72rem">
477
+ <Board
478
+ {...boardProps}
479
+ label="Today, mixed lanes"
480
+ lanes={[
481
+ {
482
+ emptyState: (
483
+ <EmptyState
484
+ description="Pull something over from the backlog."
485
+ heading="Nothing chosen yet"
486
+ headingLevel={4}
487
+ size="sm"
488
+ />
489
+ ),
490
+ items: [],
491
+ key: "todo",
492
+ label: "Todo",
493
+ },
494
+ {
495
+ items: IN_PROGRESS_ITEMS,
496
+ key: "in-progress",
497
+ label: "In Progress",
498
+ },
499
+ {
500
+ itemCount: 19,
501
+ items: REVIEW_ITEMS,
502
+ key: "review",
503
+ label: "Needs Review",
504
+ onShowMore: () => undefined,
505
+ },
506
+ ]}
507
+ />
508
+ </Frame>
509
+ </StoryCell>
510
+ </StoryGrid>
511
+ ),
512
+ }
513
+
514
+ /**
515
+ * **The story that proves the whole design**, and the only honest
516
+ * way to show it: three fixed-width panels inside one unchanged
517
+ * browser window.
518
+ *
519
+ * The narrow panel is not a phone. It is the same board in a
520
+ * sidebar, in a split window, or on a display the owner has zoomed
521
+ * to 175% — and a media query cannot tell any of those from a
522
+ * maximised 4K window. Resizing the viewport would prove nothing
523
+ * here, because the thing that changed is the component's container.
524
+ *
525
+ * Watch two things flip independently. The board's own box decides
526
+ * whether the lanes are three-up or one-at-a-time behind a segmented
527
+ * control; each **lane's** box decides whether a card is two lines,
528
+ * one line, or a card of its own.
529
+ */
530
+ export const Responsive: Story = {
531
+ render: (boardProps) => (
532
+ <div className="flex flex-col gap-8">
533
+ <ContainerBoard>
534
+ {(width) => (
535
+ <Board
536
+ {...boardProps}
537
+ label={`Today at ${width}`}
538
+ />
539
+ )}
540
+ </ContainerBoard>
541
+
542
+ <div className="flex flex-col gap-2">
543
+ <span className="font-mono text-content-muted text-xs">
544
+ a lane past --cq-lg (48rem) — the rows become
545
+ cards
546
+ </span>
547
+
548
+ {/*
549
+ * A **lane** wider than 48rem: the third row shape, and the
550
+ * one it is easy to believe is unreachable. It is not. A
551
+ * board with one or two lanes gives each of them most of the
552
+ * board's width, and three lanes clear it on a 4K display —
553
+ * the frame here is 56rem, an ordinary content column.
554
+ */}
555
+ <Frame inlineSize="56rem">
556
+ <Board
557
+ label="Needs Review, one lane"
558
+ lanes={[
559
+ {
560
+ itemCount: 19,
561
+ items: REVIEW_ITEMS.slice(0, 4),
562
+ key: "review",
563
+ label: "Needs Review",
564
+ },
565
+ ]}
566
+ />
567
+ </Frame>
568
+ </div>
569
+ </div>
570
+ ),
571
+ }
572
+
573
+ /**
574
+ * The complete keyboard path, and it is the **primary** way to move
575
+ * a card rather than an accessible alternative to the real one.
576
+ *
577
+ * Tab to a card's `Move` handle, press Enter, arrow to a lane, press
578
+ * Enter again. The card moves, and a `role="status"` region says
579
+ * where it went and what position it landed in — the one thing a
580
+ * sighted user gets free from watching it land, and the thing every
581
+ * drag implementation forgets to say out loud.
582
+ *
583
+ * Dragging the same handle with a mouse, a finger or a pen does the
584
+ * same thing through the same callback. Escape abandons a drag in
585
+ * flight.
586
+ */
587
+ const MovableBoard = (): ReactNode => {
588
+ const [lanes, setLanes] = useState<BoardLane[]>([
589
+ { items: TODO_ITEMS, key: "todo", label: "Todo" },
590
+ {
591
+ items: IN_PROGRESS_ITEMS,
592
+ key: "in-progress",
593
+ label: "In Progress",
594
+ },
595
+ {
596
+ items: REVIEW_ITEMS.slice(0, 4),
597
+ key: "review",
598
+ label: "Needs Review",
599
+ },
600
+ ])
601
+
602
+ const applyMove = (move: BoardMove) => {
603
+ setLanes((current) => {
604
+ const moved = current
605
+ .find((lane) => lane.key === move.fromLaneKey)
606
+ ?.items.find((item) => item.key === move.itemKey)
607
+
608
+ if (!moved) {
609
+ return current
610
+ }
611
+
612
+ // `toIndex` already accounts for the removal, so this really
613
+ // is a splice out and a splice in with no arithmetic.
614
+ return current.map((lane) => {
615
+ const kept =
616
+ lane.key === move.fromLaneKey
617
+ ? lane.items.filter(
618
+ (item) => item.key !== move.itemKey,
619
+ )
620
+ : lane.items
621
+
622
+ if (lane.key !== move.toLaneKey) {
623
+ return { ...lane, items: kept }
624
+ }
625
+
626
+ return {
627
+ ...lane,
628
+ items: [
629
+ ...kept.slice(0, move.toIndex),
630
+ moved,
631
+ ...kept.slice(move.toIndex),
632
+ ],
633
+ }
634
+ })
635
+ })
636
+ }
637
+
638
+ return (
639
+ // Framed wide on purpose: the keyboard path is identical at
640
+ // every width, but a reader who moves a card wants to watch it
641
+ // land in the lane it went to rather than take the segmented
642
+ // control's word for it.
643
+ <Frame inlineSize="72rem">
644
+ <Board
645
+ label="Today, movable"
646
+ lanes={lanes}
647
+ moveIcon={<MoreIcon />}
648
+ onMove={applyMove}
649
+ />
650
+ </Frame>
651
+ )
652
+ }
653
+
654
+ export const Interactive: Story = {
655
+ render: () => <MovableBoard />,
656
+ }
657
+
658
+ /**
659
+ * Where it actually goes, and what the consumer still owns.
660
+ *
661
+ * The **"needs attention"** banner is not part of `Board`, and that
662
+ * is a decision rather than an omission: it reaches across every
663
+ * lane, so it is not a lane; it is absent when empty, so it is not
664
+ * furniture the board can reserve room for; and it is already
665
+ * `Alert` — the component this library shipped for exactly this
666
+ * shape. Composing it here rather than absorbing it keeps `Board`
667
+ * about lanes and cards, and keeps the banner reusable above a list
668
+ * that is not a board at all.
669
+ */
670
+ export const InBoardScreen: Story = {
671
+ render: (boardProps) => (
672
+ <div className="flex flex-col gap-5">
673
+ <div className="flex flex-col gap-1">
674
+ <h1 className="font-display font-semibold text-2xl text-content-primary">
675
+ Today
676
+ </h1>
677
+
678
+ <p className="text-content-secondary text-sm">
679
+ Across every project — 6 chosen, 5 running, 19
680
+ waiting on you
681
+ </p>
682
+ </div>
683
+
684
+ <Alert
685
+ description="Three things are stuck. Nothing below moves until they are dealt with."
686
+ details={[
687
+ "Run failed — Link queue for the weekly digest: the ingest token expired after 3 retries",
688
+ "Blocked — Print farm queue: waiting on filament",
689
+ "Stale — Fingerprint duplicate uploads: in Todo 19 days",
690
+ ]}
691
+ heading="Needs attention"
692
+ intent="danger"
693
+ label="Needs attention"
694
+ />
695
+
696
+ <Board
697
+ {...boardProps}
698
+ // The page's own `<h1>` is above it, so the lanes are `<h2>`
699
+ // — an `<h3>` here is an axe `heading-order` failure and a
700
+ // hole in the screen-reader outline.
701
+ headingLevel={2}
702
+ moveIcon={<MoreIcon />}
703
+ onMove={() => undefined}
704
+ />
705
+ </div>
706
+ ),
707
+ }