@anchrd/intel-ui 0.39.0 → 0.41.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 (36) hide show
  1. package/package.json +13 -2
  2. package/src/access-summary/access-summary.tsx +1 -11
  3. package/src/app/app-tree/app-tree.tsx +58 -0
  4. package/src/attachment-viewer/attachment-viewer.tsx +304 -0
  5. package/src/board/board-assignee/board-assignee.ts +18 -0
  6. package/src/board/board-crumbs/board-crumbs.ts +56 -0
  7. package/src/board/board-crumbs/board-crumbs.tsx +108 -0
  8. package/src/board/board-data/board-data.ts +31 -5
  9. package/src/board/board-data/board-data.types.ts +10 -1
  10. package/src/board/board-kanban/board-kanban.ts +63 -9
  11. package/src/board/board-kanban/board-kanban.tsx +409 -66
  12. package/src/board/board-open-task/board-open-task.ts +32 -0
  13. package/src/board/board-panel/board-panel.tsx +202 -31
  14. package/src/board/board-settings/board-settings.tsx +209 -0
  15. package/src/board/board-stripes/board-stripes.ts +128 -0
  16. package/src/board/board-table/board-table.ts +23 -105
  17. package/src/board/board-table/board-table.tsx +436 -135
  18. package/src/board/board-task/board-task.ts +105 -0
  19. package/src/board/board-task/board-task.tsx +396 -0
  20. package/src/board/board-title-row/board-title-row.tsx +68 -0
  21. package/src/file-preview/file-preview-view.tsx +28 -0
  22. package/src/file-preview/file-preview.tsx +28 -0
  23. package/src/file-preview/pdf-file-preview.tsx +5 -0
  24. package/src/file-preview/presentation-file-preview.tsx +21 -0
  25. package/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  26. package/src/file-preview/word-file-preview.tsx +5 -0
  27. package/src/i18n/de.json +81 -7
  28. package/src/i18n/en.json +81 -7
  29. package/src/i18n/es.json +81 -7
  30. package/src/nodes/nodes.tsx +26 -52
  31. package/src/resource-menu/resource-menu.tsx +18 -12
  32. package/src/router/selection-search.ts +17 -2
  33. package/src/styles.css +29 -25
  34. package/src/title-row/title-row.tsx +33 -6
  35. package/src/user-name/user-name.ts +17 -0
  36. package/vite.config.ts +111 -3
@@ -1,51 +1,62 @@
1
1
  import type { BoardTask } from "@anchrd/intel-contract/board";
2
+ import {
3
+ closestCorners,
4
+ DndContext,
5
+ type DragEndEvent,
6
+ DragOverlay,
7
+ MouseSensor,
8
+ TouchSensor,
9
+ useDroppable,
10
+ useSensor,
11
+ useSensors,
12
+ } from "@dnd-kit/core";
13
+ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
14
+ import { CSS } from "@dnd-kit/utilities";
2
15
  import { useState } from "react";
3
16
  import type { BoardHandle, ColumnWithCards } from "@/board/board-data/board-data.types.ts";
17
+ import { type Family, familiesOf } from "@/board/board-stripes/board-stripes.ts";
4
18
  import { useI18n } from "@/i18n/i18n-context.tsx";
5
- import { type Drop, dropIndexOnCard, dropToWrite, neighbourDrop } from "./board-kanban.ts";
6
-
7
- // The id of the card being dragged. `getData` is the payload the browser carries for us; nothing
8
- // else about the gesture needs to survive a re-render, so there is no state here.
9
- const CARRIED = "text/plain";
19
+ import {
20
+ type Drop,
21
+ dragEndToDrop,
22
+ dropIsAfter,
23
+ dropToWrite,
24
+ neighbourDrop,
25
+ } from "./board-kanban.ts";
10
26
 
11
27
  function Card({
12
28
  card,
13
29
  column,
14
- row,
15
30
  board,
16
31
  onMove,
32
+ onOpen,
33
+ family,
34
+ parentColumn,
17
35
  }: {
18
36
  card: BoardTask;
19
37
  column: ColumnWithCards;
20
- row: number;
21
38
  board: BoardHandle;
22
39
  onMove(card: BoardTask, drop: Drop): void;
40
+ onOpen(taskId: string): void;
41
+ family: Family;
42
+ /** The title of the column the parent card sits in, or `undefined` when there is none here. */
43
+ parentColumn: string | undefined;
23
44
  }) {
24
45
  const i18n = useI18n();
25
46
  const locked = board.isWriting;
47
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
48
+ id: card.id,
49
+ disabled: locked,
50
+ });
51
+
26
52
  return (
27
53
  <li
28
- onDragOver={(event) => {
29
- if (locked) return;
30
- event.preventDefault();
31
- event.dataTransfer.dropEffect = "move";
32
- }}
33
- onDrop={(event) => {
34
- if (locked) return;
35
- event.preventDefault();
36
- // ⚠️ The column below is a drop target too, and it means "append". Letting this event reach
37
- // it would overwrite the gap just measured with the end of the column — the bug this
38
- // handler exists to fix, restored by the bubbling.
39
- event.stopPropagation();
40
- const dragged = cardById(board, event.dataTransfer.getData(CARRIED));
41
- if (dragged === undefined) return;
42
- const index = dropIndexOnCard(
43
- row,
44
- event.clientY,
45
- event.currentTarget.getBoundingClientRect(),
46
- );
47
- onMove(dragged, { status: column.column.id, index });
48
- }}
54
+ ref={setNodeRef}
55
+ style={{ transform: CSS.Translate.toString(transform), transition }}
56
+ // ⚠️ The card keeps its place in the list while it is carried, and only fades. Removing it
57
+ // would collapse the gap it came from, every card below would jump up by one, and the drop
58
+ // target the pointer is over would change underneath the gesture.
59
+ className={isDragging ? "opacity-40" : undefined}
49
60
  >
50
61
  {/* ⚠️ A real button, not an `<li role="button">`. The role override needs two lint
51
62
  suppressions and still leaves a listitem that only *claims* to be operable; a button is
@@ -53,13 +64,20 @@ function Card({
53
64
  — may perfectly well be a drag source. */}
54
65
  <button
55
66
  type="button"
56
- draggable={!locked}
57
- aria-label={i18n.t("board.cardLabel", { title: card.title, column: column.column.title })}
58
- className="w-full cursor-grab rounded-md border border-border bg-card p-2 text-left text-sm shadow-xs focus-visible:outline-2 focus-visible:outline-ring"
59
- onDragStart={(event) => {
60
- event.dataTransfer.setData(CARRIED, card.id);
61
- event.dataTransfer.effectAllowed = "move";
62
- }}
67
+ {...attributes}
68
+ {...listeners}
69
+ // ⚠️ After the spread, deliberately. `attributes` carries `aria-roledescription="sortable"`,
70
+ // which announces a gesture this board does not offer from the keyboard.
71
+ aria-roledescription={undefined}
72
+ // ⚠️ The name carries everything this card cannot say any other way: that it OPENS (#671),
73
+ // how it moves without a pointer, and how DEEP it sits (#366). The stripes on the right are
74
+ // `aria-hidden` — a button's children are presentational, so no label inside one is ever
75
+ // announced, and the depth would exist in colour alone.
76
+ aria-label={labelOf(i18n, card, column, family, parentColumn)}
77
+ onClick={() => onOpen(card.id)}
78
+ // `relative` and `overflow-hidden` are for the depth pattern: it is positioned against this
79
+ // card, and nothing of it may spill past the rounded edge.
80
+ className="relative w-full cursor-grab overflow-hidden rounded-md border border-border bg-card p-2 text-left text-sm shadow-xs focus-visible:outline-2 focus-visible:outline-ring"
63
81
  onKeyDown={(event) => {
64
82
  // ⚠️ Not while a write is in flight. Two moves in quick succession both read the same
65
83
  // not-yet-updated `board.columns`, compute a position independently and both write — the
@@ -70,6 +88,12 @@ function Card({
70
88
  // ⚠️ Only with a modifier. The arrows alone belong to whatever the reader is used to —
71
89
  // scrolling the column, moving focus — and taking them would make a board that cannot be
72
90
  // read without moving its cards.
91
+ //
92
+ // ⚠️ This is kept INSTEAD of `dnd-kit`'s own keyboard sensor. That sensor lifts a card
93
+ // with the space bar and then steers it by collision detection, which needs measured
94
+ // geometry — a board whose columns are off screen to the right cannot be reached without
95
+ // scrolling first, and jsdom measures nothing at all, so the whole path would leave the
96
+ // suite. Alt and an arrow names the neighbour directly and is proven without a layout.
73
97
  if (!event.altKey) return;
74
98
  const direction =
75
99
  event.key === "ArrowLeft"
@@ -88,7 +112,22 @@ function Card({
88
112
  onMove(card, drop);
89
113
  }}
90
114
  >
91
- <span className="block">{card.title}</span>
115
+ <Stripes family={family} label={depthOf(i18n, family, parentColumn)} />
116
+ {/* ⚠️ The room on the right follows the STRIPES, not the level. A fixed `pr-6` holds four
117
+ stripes and the title runs under the fifth; but reserving room for the real level once
118
+ the pattern caps takes width from the title for stripes that are not drawn — at level 20
119
+ that is 76 px of a 240 px card, given away for nothing. */}
120
+ <span
121
+ className="block"
122
+ // ⚠️ `STRIPE_WIDTH`, not a number written twice. The reserve followed the OLD grid of
123
+ // 3px plus a 2px gap; since the stripes sit flush and 4px wide, `* 5` reserved up to 14px
124
+ // the title never needed, while the comment above claimed the reserve follows them.
125
+ style={{
126
+ paddingRight: `${8 + Math.min(family.level, MAX_STRIPES) * STRIPE_WIDTH}px`,
127
+ }}
128
+ >
129
+ {card.title}
130
+ </span>
92
131
  {card.dueDate === null ? null : (
93
132
  <span className="mt-1 block text-xs text-muted-foreground">
94
133
  {card.dueDate.slice(0, 10)}
@@ -99,6 +138,101 @@ function Card({
99
138
  );
100
139
  }
101
140
 
141
+ /**
142
+ * The depth pattern on the right edge of a card.
143
+ *
144
+ * ⚠️ **Right, where no text stands.** Jack's decision 2026-08-08: an indent costs width exactly
145
+ * where the title is, and by the third level the card is a strip. The stripes cost nothing a reader
146
+ * was using.
147
+ *
148
+ * ⚠️ **`aria-hidden`, and the words live on the BUTTON.** A `role="img"` with a label inside a
149
+ * `<button>` reaches nobody: a button's children are presentational in ARIA, and the button already
150
+ * carries a name of its own, which wins. The mark was drawn, the test was green, and the sentence
151
+ * existed for no one — see `labelOf` and `packages/ui/CLAUDE.md`.
152
+ */
153
+ function Stripes({ family, label }: { family: Family; label: string }) {
154
+ if (family.level === 0) return null;
155
+ return (
156
+ <span
157
+ aria-hidden="true"
158
+ // ⚠️ A tooltip BESIDE the accessible name, not instead of it: the name serves whoever cannot
159
+ // see the stripes, the tooltip whoever sees them and wonders what they mean.
160
+ //
161
+ // ⚠️ And therefore NOT `pointer-events-none`. A native `title` hangs on hover, and an element
162
+ // that is not a hit target never gets one — the attribute would sit there, the test would find
163
+ // it, and the tooltip would never appear once. The class is not needed either: this span lies
164
+ // inside the button, so a click reaches it anyway.
165
+ title={label}
166
+ // ⚠️ Flush and gapless, top to bottom. Inset from the edge and spaced apart, the stripes read
167
+ // as decoration somebody added; against the edge and touching, they read as one mark with a
168
+ // countable number of parts, which is what they are.
169
+ className="absolute inset-y-0 right-0 flex"
170
+ >
171
+ {levels(family.level).map((level) => (
172
+ <span
173
+ key={`${family.rootId}-${level}`}
174
+ // ⚠️ Each level a fainter step of the SAME tone, not a different tone. The hue answers
175
+ // "which family", the count answers "how deep" — one channel per question.
176
+ //
177
+ // ⚠️ Clamped, and the clamp survives its own reason. It was added because an unclamped
178
+ // ramp went negative — CSS pins that to zero and the deepest stripes are simply not drawn.
179
+ // Since the pattern caps at six the lowest raw value is 0.25, so the clamp no longer
180
+ // fires; it stays because it is the guard, not the arithmetic, that must hold if the cap
181
+ // ever moves.
182
+ // ⚠️ No rounding. A rounded stripe at full height leaves a light wedge at each end, and
183
+ // two of them beside one another look like a gap that is not there.
184
+ // ⚠️ The width as an inline STYLE, not a class. `w-[${…}px]` is a name Tailwind never
185
+ // sees — it reads class names statically, so the stripes would have no width in the build
186
+ // and be perfectly fine in the tests. An inline value is also the only way one number can
187
+ // rule both the mark and the room reserved for it.
188
+ className={toneClasses[family.tone - 1]}
189
+ style={{ width: `${STRIPE_WIDTH}px`, opacity: Math.max(0.3, 1 - (level - 1) * 0.15) }}
190
+ />
191
+ ))}
192
+ </span>
193
+ );
194
+ }
195
+
196
+ /**
197
+ * ⚠️ **The pattern caps at six, the NAME does not.** Seven stripes are twenty pixels, and nobody
198
+ * counts them — past that the mark stops being a count and becomes texture. The accessible name
199
+ * keeps saying the real level, so nothing is lost, only unread pixels.
200
+ */
201
+ const MAX_STRIPES = 6;
202
+
203
+ /**
204
+ * How wide one stripe is, in pixels.
205
+ *
206
+ * ⚠️ **One number, read by both the mark and the room reserved for it.** Written twice they drift,
207
+ * and the drift is silent: the title keeps its distance from stripes that are no longer that wide,
208
+ * or runs under ones that grew.
209
+ */
210
+ const STRIPE_WIDTH = 4;
211
+
212
+ /**
213
+ * `[1, 2, … min(depth, MAX_STRIPES)]` — the level each stripe stands for.
214
+ *
215
+ * ⚠️ The level, not the index. Both are the same number here, and the rule against index keys is
216
+ * about lists whose order can change; this one cannot, because stripe two is always level two.
217
+ */
218
+ function levels(depth: number): number[] {
219
+ return Array.from({ length: Math.min(depth, MAX_STRIPES) }, (_, step) => step + 1);
220
+ }
221
+
222
+ /**
223
+ * ⚠️ Written out rather than built from a template string. Tailwind reads class names statically,
224
+ * and `bg-board-family-${n}` is a name it never sees — the stripes would be invisible in the build
225
+ * and perfectly fine in the tests.
226
+ */
227
+ const toneClasses = [
228
+ "bg-board-family-1",
229
+ "bg-board-family-2",
230
+ "bg-board-family-3",
231
+ "bg-board-family-4",
232
+ "bg-board-family-5",
233
+ "bg-board-family-6",
234
+ ] as const;
235
+
102
236
  function cardById(board: BoardHandle, id: string): BoardTask | undefined {
103
237
  return board.columns.flatMap((entry) => entry.cards).find((card) => card.id === id);
104
238
  }
@@ -117,15 +251,28 @@ function Column({
117
251
  entry,
118
252
  board,
119
253
  onMove,
254
+ onOpen,
255
+ families,
256
+ columnOf,
120
257
  }: {
121
258
  entry: ColumnWithCards;
122
259
  board: BoardHandle;
123
260
  onMove(card: BoardTask, drop: Drop): void;
261
+ onOpen(taskId: string): void;
262
+ families: Map<string, Family>;
263
+ columnOf: Map<string, string>;
124
264
  }) {
125
265
  const i18n = useI18n();
126
266
  const [composing, setComposing] = useState(false);
127
267
  const [draft, setDraft] = useState("");
128
268
  const [failed, setFailed] = useState(false);
269
+ // ⚠️ The column is a drop target in its OWN right, and it means "append": the free area below
270
+ // the last card. Without it a board could never take a card into an empty column — there would be
271
+ // no card there to let go on.
272
+ const { setNodeRef: setDropRef } = useDroppable({
273
+ id: entry.column.id,
274
+ disabled: board.isWriting,
275
+ });
129
276
  // ⚠️ A column the board does not configure takes no new card: the server refuses a status that
130
277
  // names no column (`unknown_column`), so offering the control would promise a refusal.
131
278
  const addable = entry.unknown !== true;
@@ -155,6 +302,9 @@ function Column({
155
302
  startDate: null,
156
303
  dueDate: null,
157
304
  dependsOn: null,
305
+ // A card made in a column sits on the board itself. Making a SUBTASK is the same call with
306
+ // the parent named — that is the detail view's business (#671), not the column's.
307
+ parentTaskId: null,
158
308
  idempotencyKey: crypto.randomUUID(),
159
309
  })
160
310
  .then(close)
@@ -165,24 +315,9 @@ function Column({
165
315
 
166
316
  return (
167
317
  <section
318
+ ref={setDropRef}
168
319
  aria-label={entry.column.title}
169
320
  className="group/column flex w-64 shrink-0 flex-col gap-2"
170
- onDragOver={(event) => {
171
- if (board.isWriting) return;
172
- event.preventDefault();
173
- event.dataTransfer.dropEffect = "move";
174
- }}
175
- onDrop={(event) => {
176
- if (board.isWriting) return;
177
- event.preventDefault();
178
- const dragged = cardById(board, event.dataTransfer.getData(CARRIED));
179
- // ⚠️ Let go on the column itself rather than on one of its cards: the end of it. A card
180
- // drop stops the event before it arrives here, so this is the free area below the last
181
- // card — and there, "append" is what the gesture means.
182
- if (dragged !== undefined) {
183
- onMove(dragged, { status: entry.column.id, index: entry.cards.length });
184
- }
185
- }}
186
321
  >
187
322
  <h3 className="flex items-baseline gap-2 px-1 text-sm font-medium">
188
323
  {entry.column.title}
@@ -206,14 +341,32 @@ function Column({
206
341
  </button>
207
342
  ) : null}
208
343
  </h3>
209
- <ul className="flex flex-col gap-2">
210
- {entry.cards.map((card, row) => (
211
- <Card key={card.id} card={card} column={entry} row={row} board={board} onMove={onMove} />
212
- ))}
213
- </ul>
214
- {entry.cards.length === 0 ? (
215
- <p className="px-1 text-xs text-muted-foreground">{i18n.t("board.columnEmpty")}</p>
216
- ) : null}
344
+ <SortableContext
345
+ items={entry.cards.map((card) => card.id)}
346
+ strategy={verticalListSortingStrategy}
347
+ >
348
+ <ul className="flex flex-col gap-2">
349
+ {entry.cards.map((card) => (
350
+ <Card
351
+ key={card.id}
352
+ card={card}
353
+ column={entry}
354
+ board={board}
355
+ onMove={onMove}
356
+ onOpen={onOpen}
357
+ family={families.get(card.id) ?? noFamily}
358
+ parentColumn={
359
+ card.parentTaskId === null ? undefined : columnOf.get(card.parentTaskId)
360
+ }
361
+ />
362
+ ))}
363
+ </ul>
364
+ </SortableContext>
365
+ {/* ⚠️ **Nothing at all under an empty column.** The sentence said what the emptiness already
366
+ says, and the surface I first put in its place was an invention with a false reason: the
367
+ column is a drop target through `setDropRef` on the section above, with or without
368
+ anything drawn in it — `dragEndToDrop` answers `{ status, index: 0 }` for a column with no
369
+ cards, and `neighbourDrop` reaches one by index alone. */}
217
370
  {addable ? (
218
371
  composing ? (
219
372
  <input
@@ -258,8 +411,26 @@ function Column({
258
411
  );
259
412
  }
260
413
 
261
- export function BoardKanban({ board }: { board: BoardHandle }) {
414
+ export function BoardKanban({
415
+ board,
416
+ onOpen,
417
+ }: {
418
+ board: BoardHandle;
419
+ onOpen(taskId: string): void;
420
+ }) {
262
421
  const i18n = useI18n();
422
+ // The card under the pointer, for the preview. Nothing else about the gesture is state.
423
+ const [carried, setCarried] = useState<string | null>(null);
424
+
425
+ /**
426
+ * ⚠️ **A distance before the drag starts, or the card stops being a button.** Without it every
427
+ * press is a gesture: `dnd-kit` swallows the click that follows, and opening a card by clicking
428
+ * it — the thing the card is mostly for — silently stops working.
429
+ */
430
+ const sensors = useSensors(
431
+ useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
432
+ useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
433
+ );
263
434
 
264
435
  if (board.isPending) {
265
436
  return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
@@ -285,11 +456,183 @@ export function BoardKanban({ board }: { board: BoardHandle }) {
285
456
  });
286
457
  };
287
458
 
459
+ /**
460
+ * What a finished gesture means as a write, or `null` for "nothing changed".
461
+ *
462
+ * ⚠️ Shared by the handler and the announcement on purpose. Computed twice they would drift, and
463
+ * the drift would be invisible: the screen reader would report a move nobody made, or stay quiet
464
+ * about one that happened.
465
+ *
466
+ * ⚠️ **The lock belongs HERE, not in the caller.** With it one level up, a write starting
467
+ * underneath a running gesture stopped the handler and left the announcement saying "moved to
468
+ * Offen" over zero writes — the drift the paragraph above claims to rule out, restored by the
469
+ * one line that was not shared.
470
+ */
471
+ const resolve = (event: Pick<DragEndEvent, "active" | "over">) => {
472
+ if (board.isWriting) return null;
473
+ const dragged = cardById(board, String(event.active.id));
474
+ if (dragged === undefined) return null;
475
+ const drop = dragEndToDrop(
476
+ String(event.active.id),
477
+ event.over === null ? null : String(event.over.id),
478
+ board.columns,
479
+ dropIsAfter(event.active.rect.current.translated, event.over?.rect),
480
+ );
481
+ return drop === null ? null : dropToWrite(dragged, board.columns, drop);
482
+ };
483
+
484
+ /**
485
+ * ⚠️ **ONE derivation for the whole board, not one per card** — and that has to include the
486
+ * lookup the LABEL needs, not only the families. `dnd-kit` re-renders on every pointer move while
487
+ * a card is carried, so anything per-card that scans the board is quadratic: measured at 800
488
+ * cards it was 36 ms per render before, and a `find` left in the label put the same shape back
489
+ * at 5 ms per 1600 cards.
490
+ */
491
+ const families = familiesOf(board.columns.flatMap((entry) => entry.cards));
492
+ const columnOf = new Map(
493
+ board.columns.flatMap((entry) => entry.cards.map((card) => [card.id, entry.column.title])),
494
+ );
495
+
496
+ const carriedCard = carried === null ? undefined : cardById(board, carried);
497
+
288
498
  return (
289
- <div className="flex gap-3 overflow-x-auto p-4" aria-busy={board.isWriting}>
290
- {board.columns.map((column) => (
291
- <Column key={column.column.id} entry={column} board={board} onMove={move} />
292
- ))}
293
- </div>
499
+ <DndContext
500
+ sensors={sensors}
501
+ // ⚠️ Corners, not centres. A card is far taller than the gap between two cards, so the closest
502
+ // CENTRE stays the same card across most of a column and the gesture feels stuck; the closest
503
+ // corner changes at the boundary the reader is aiming at.
504
+ collisionDetection={closestCorners}
505
+ // ⚠️ `dnd-kit`'s own wording announces the space bar, and no keyboard sensor is registered —
506
+ // pressing it does nothing. Announcing a gesture that is not there is worse than announcing
507
+ // none: the reader tries it, nothing happens, and the one that works is never mentioned.
508
+ accessibility={{
509
+ screenReaderInstructions: { draggable: i18n.t("board.dragInstructions") },
510
+ // ⚠️ `dnd-kit`'s own announcements read out the raw ids — "Draggable item a was moved over
511
+ // droppable area a" — and report a drop even when nothing was written. Both are replaced:
512
+ // the card is named by its TITLE, the move-over is silent because there is nothing about a
513
+ // position this code could say honestly, and the end says what actually happened.
514
+ announcements: {
515
+ onDragStart: ({ active }) => {
516
+ const card = cardById(board, String(active.id));
517
+ return card === undefined
518
+ ? undefined
519
+ : i18n.t("board.drag.lifted", { card: card.title });
520
+ },
521
+ onDragOver: () => undefined,
522
+ onDragMove: () => undefined,
523
+ onDragEnd: (event) => {
524
+ const write = resolve(event);
525
+ const card = cardById(board, String(event.active.id));
526
+ // ⚠️ Silence, not a sentence without a subject. A card that vanished mid-gesture — an
527
+ // answer arrived, somebody else archived it — has no name left to announce, and
528
+ // " put back" read out on its own says less than nothing.
529
+ if (card === undefined) return undefined;
530
+ if (write === null) return i18n.t("board.drag.putBack", { card: card.title });
531
+ return i18n.t("board.drag.moved", {
532
+ card: card.title,
533
+ column:
534
+ board.columns.find((entry) => entry.column.id === write.status)?.column.title ??
535
+ write.status,
536
+ });
537
+ },
538
+ onDragCancel: ({ active }) => {
539
+ const card = cardById(board, String(active.id));
540
+ return card === undefined
541
+ ? undefined
542
+ : i18n.t("board.drag.putBack", { card: card.title });
543
+ },
544
+ },
545
+ }}
546
+ onDragStart={(event) => setCarried(String(event.active.id))}
547
+ onDragCancel={() => setCarried(null)}
548
+ onDragEnd={(event) => {
549
+ setCarried(null);
550
+ // ⚠️ The SECOND lock lives inside `resolve`, so the announcement obeys it too — see there.
551
+ const write = resolve(event);
552
+ if (write === null) return;
553
+ void board.updateTask({
554
+ taskId: String(event.active.id),
555
+ status: write.status,
556
+ position: write.position,
557
+ idempotencyKey: `move-${event.active.id}-${write.status}-${write.position}`,
558
+ });
559
+ }}
560
+ >
561
+ {/* ⚠️ **`min-h-0 flex-1`, or the scrollbar sits in the middle of the screen.** A box with no
562
+ height shrinks to its content, and the horizontal bar clings to the bottom of the tallest
563
+ column: on a board with few cards that looks broken. The panel above already offers the
564
+ full height; this is where it is taken.
565
+
566
+ ⚠️ **And NO `items-start` with it.** A stretched column paints nothing — the section
567
+ carries neither background nor border — but the rectangle it stretches to is what
568
+ `useDroppable` measures and `closestCorners` reckons against. Held to its content instead,
569
+ an empty column shrinks to its heading, and the area a card can be dropped into shrinks
570
+ with it. */}
571
+ <div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4" aria-busy={board.isWriting}>
572
+ {board.columns.map((column) => (
573
+ <Column
574
+ key={column.column.id}
575
+ entry={column}
576
+ board={board}
577
+ onMove={move}
578
+ onOpen={onOpen}
579
+ families={families}
580
+ columnOf={columnOf}
581
+ />
582
+ ))}
583
+ </div>
584
+ {/* ⚠️ The preview is what a drag looks like. Without it the card stays put and only the
585
+ cursor moves, and there is nothing on screen saying the gesture was picked up at all. */}
586
+ <DragOverlay>
587
+ {carriedCard === undefined ? null : (
588
+ <div className="w-64 rounded-md border border-border bg-card p-2 text-left text-sm shadow-lg">
589
+ {carriedCard.title}
590
+ </div>
591
+ )}
592
+ </DragOverlay>
593
+ </DndContext>
294
594
  );
295
595
  }
596
+
597
+ /** A card that belongs to no family — the answer when the board has not been read yet. */
598
+ const noFamily: Family = { level: 0, tone: 0, rootId: null };
599
+
600
+ /**
601
+ * What the card's button is CALLED.
602
+ *
603
+ * ⚠️ **This is where the depth becomes readable, and the only place it can be.** The stripes are
604
+ * `aria-hidden` because a button's children are presentational: a `role="img"` with a label inside
605
+ * one is never announced, and the button's own name wins in any case. The board already spends a
606
+ * colour axis on the status, so the family may not be a second one — a reader who does not see the
607
+ * difference has to be able to read it.
608
+ */
609
+ function labelOf(
610
+ i18n: ReturnType<typeof useI18n>,
611
+ card: BoardTask,
612
+ column: ColumnWithCards,
613
+ family: Family,
614
+ parentColumn: string | undefined,
615
+ ): string {
616
+ const name = i18n.t("board.cardLabel", { title: card.title, column: column.column.title });
617
+ if (family.level === 0) return name;
618
+ // ⚠️ Handed IN, not looked up. A `find` over the board here runs once per card on every render,
619
+ // and puts back the quadratic shape the one derivation above exists to remove.
620
+ return `${name} ${depthOf(i18n, family, parentColumn)}`;
621
+ }
622
+
623
+ /**
624
+ * What the depth pattern says, in words.
625
+ *
626
+ * ⚠️ Always the REAL level, even where the stripes have capped. The mark stops counting at six; the
627
+ * sentence does not, because it is the channel somebody relies on when they cannot count stripes.
628
+ */
629
+ function depthOf(
630
+ i18n: ReturnType<typeof useI18n>,
631
+ family: Family,
632
+ parentColumn: string | undefined,
633
+ ): string {
634
+ if (family.level === 0) return "";
635
+ return parentColumn === undefined
636
+ ? i18n.t("board.stripes.root", { level: family.level })
637
+ : i18n.t("board.stripes.under", { level: family.level, column: parentColumn });
638
+ }
@@ -0,0 +1,32 @@
1
+ import { useNavigate, useSearch } from "@tanstack/react-router";
2
+ import { useCallback } from "react";
3
+ import { openTaskFrom } from "@/router/selection-search.ts";
4
+
5
+ /**
6
+ * Which card is open, and the one way to change that.
7
+ *
8
+ * ⚠️ **One truth for two places.** The title row and the panel both open cards, and they are
9
+ * rendered in different subtrees: the row above the content, the panel inside it. Written twice,
10
+ * one of them would eventually push where the other replaces, and the back button would behave
11
+ * differently depending on which control was used.
12
+ */
13
+ export function useOpenTask(): { openId: string | null; open(taskId: string | null): void } {
14
+ const search = useSearch({ strict: false });
15
+ const navigate = useNavigate();
16
+
17
+ const open = useCallback(
18
+ (taskId: string | null) =>
19
+ void navigate({
20
+ to: ".",
21
+ // ⚠️ **Closing REPLACES, opening pushes.** Opening a card is a step somebody took and the
22
+ // back button must undo it; closing is the undo itself. Pushed as well, the history holds
23
+ // board → card → board, and one press of Back re-opens the card that was just closed.
24
+ replace: taskId === null,
25
+ search: (previous: Record<string, unknown>) =>
26
+ taskId === null ? { ...previous, task: undefined } : { ...previous, task: taskId },
27
+ }),
28
+ [navigate],
29
+ );
30
+
31
+ return { openId: openTaskFrom(search), open };
32
+ }