@anchrd/intel-ui 0.38.0 → 0.40.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.
@@ -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,17 @@ 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
+ style={{ paddingRight: `${8 + Math.min(family.level, MAX_STRIPES) * 5}px` }}
123
+ >
124
+ {card.title}
125
+ </span>
92
126
  {card.dueDate === null ? null : (
93
127
  <span className="mt-1 block text-xs text-muted-foreground">
94
128
  {card.dueDate.slice(0, 10)}
@@ -99,6 +133,83 @@ function Card({
99
133
  );
100
134
  }
101
135
 
136
+ /**
137
+ * The depth pattern on the right edge of a card.
138
+ *
139
+ * ⚠️ **Right, where no text stands.** Jack's decision 2026-08-08: an indent costs width exactly
140
+ * where the title is, and by the third level the card is a strip. The stripes cost nothing a reader
141
+ * was using.
142
+ *
143
+ * ⚠️ **`aria-hidden`, and the words live on the BUTTON.** A `role="img"` with a label inside a
144
+ * `<button>` reaches nobody: a button's children are presentational in ARIA, and the button already
145
+ * carries a name of its own, which wins. The mark was drawn, the test was green, and the sentence
146
+ * existed for no one — see `labelOf` and `packages/ui/CLAUDE.md`.
147
+ */
148
+ function Stripes({ family, label }: { family: Family; label: string }) {
149
+ if (family.level === 0) return null;
150
+ return (
151
+ <span
152
+ aria-hidden="true"
153
+ // ⚠️ A tooltip BESIDE the accessible name, not instead of it: the name serves whoever cannot
154
+ // see the stripes, the tooltip whoever sees them and wonders what they mean.
155
+ //
156
+ // ⚠️ And therefore NOT `pointer-events-none`. A native `title` hangs on hover, and an element
157
+ // that is not a hit target never gets one — the attribute would sit there, the test would find
158
+ // it, and the tooltip would never appear once. The class is not needed either: this span lies
159
+ // inside the button, so a click reaches it anyway.
160
+ title={label}
161
+ className="absolute inset-y-1 right-1 flex gap-[2px]"
162
+ >
163
+ {levels(family.level).map((level) => (
164
+ <span
165
+ key={`${family.rootId}-${level}`}
166
+ // ⚠️ Each level a fainter step of the SAME tone, not a different tone. The hue answers
167
+ // "which family", the count answers "how deep" — one channel per question.
168
+ //
169
+ // ⚠️ Clamped, and the clamp survives its own reason. It was added because an unclamped
170
+ // ramp went negative — CSS pins that to zero and the deepest stripes are simply not drawn.
171
+ // Since the pattern caps at six the lowest raw value is 0.25, so the clamp no longer
172
+ // fires; it stays because it is the guard, not the arithmetic, that must hold if the cap
173
+ // ever moves.
174
+ className={`w-[3px] rounded-full ${toneClasses[family.tone - 1]}`}
175
+ style={{ opacity: Math.max(0.3, 1 - (level - 1) * 0.15) }}
176
+ />
177
+ ))}
178
+ </span>
179
+ );
180
+ }
181
+
182
+ /**
183
+ * ⚠️ **The pattern caps at six, the NAME does not.** Seven stripes are twenty pixels, and nobody
184
+ * counts them — past that the mark stops being a count and becomes texture. The accessible name
185
+ * keeps saying the real level, so nothing is lost, only unread pixels.
186
+ */
187
+ const MAX_STRIPES = 6;
188
+
189
+ /**
190
+ * `[1, 2, … min(depth, MAX_STRIPES)]` — the level each stripe stands for.
191
+ *
192
+ * ⚠️ The level, not the index. Both are the same number here, and the rule against index keys is
193
+ * about lists whose order can change; this one cannot, because stripe two is always level two.
194
+ */
195
+ function levels(depth: number): number[] {
196
+ return Array.from({ length: Math.min(depth, MAX_STRIPES) }, (_, step) => step + 1);
197
+ }
198
+
199
+ /**
200
+ * ⚠️ Written out rather than built from a template string. Tailwind reads class names statically,
201
+ * and `bg-board-family-${n}` is a name it never sees — the stripes would be invisible in the build
202
+ * and perfectly fine in the tests.
203
+ */
204
+ const toneClasses = [
205
+ "bg-board-family-1",
206
+ "bg-board-family-2",
207
+ "bg-board-family-3",
208
+ "bg-board-family-4",
209
+ "bg-board-family-5",
210
+ "bg-board-family-6",
211
+ ] as const;
212
+
102
213
  function cardById(board: BoardHandle, id: string): BoardTask | undefined {
103
214
  return board.columns.flatMap((entry) => entry.cards).find((card) => card.id === id);
104
215
  }
@@ -117,15 +228,28 @@ function Column({
117
228
  entry,
118
229
  board,
119
230
  onMove,
231
+ onOpen,
232
+ families,
233
+ columnOf,
120
234
  }: {
121
235
  entry: ColumnWithCards;
122
236
  board: BoardHandle;
123
237
  onMove(card: BoardTask, drop: Drop): void;
238
+ onOpen(taskId: string): void;
239
+ families: Map<string, Family>;
240
+ columnOf: Map<string, string>;
124
241
  }) {
125
242
  const i18n = useI18n();
126
243
  const [composing, setComposing] = useState(false);
127
244
  const [draft, setDraft] = useState("");
128
245
  const [failed, setFailed] = useState(false);
246
+ // ⚠️ The column is a drop target in its OWN right, and it means "append": the free area below
247
+ // the last card. Without it a board could never take a card into an empty column — there would be
248
+ // no card there to let go on.
249
+ const { setNodeRef: setDropRef } = useDroppable({
250
+ id: entry.column.id,
251
+ disabled: board.isWriting,
252
+ });
129
253
  // ⚠️ A column the board does not configure takes no new card: the server refuses a status that
130
254
  // names no column (`unknown_column`), so offering the control would promise a refusal.
131
255
  const addable = entry.unknown !== true;
@@ -155,6 +279,9 @@ function Column({
155
279
  startDate: null,
156
280
  dueDate: null,
157
281
  dependsOn: null,
282
+ // A card made in a column sits on the board itself. Making a SUBTASK is the same call with
283
+ // the parent named — that is the detail view's business (#671), not the column's.
284
+ parentTaskId: null,
158
285
  idempotencyKey: crypto.randomUUID(),
159
286
  })
160
287
  .then(close)
@@ -165,24 +292,9 @@ function Column({
165
292
 
166
293
  return (
167
294
  <section
295
+ ref={setDropRef}
168
296
  aria-label={entry.column.title}
169
297
  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
298
  >
187
299
  <h3 className="flex items-baseline gap-2 px-1 text-sm font-medium">
188
300
  {entry.column.title}
@@ -206,11 +318,27 @@ function Column({
206
318
  </button>
207
319
  ) : null}
208
320
  </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>
321
+ <SortableContext
322
+ items={entry.cards.map((card) => card.id)}
323
+ strategy={verticalListSortingStrategy}
324
+ >
325
+ <ul className="flex flex-col gap-2">
326
+ {entry.cards.map((card) => (
327
+ <Card
328
+ key={card.id}
329
+ card={card}
330
+ column={entry}
331
+ board={board}
332
+ onMove={onMove}
333
+ onOpen={onOpen}
334
+ family={families.get(card.id) ?? noFamily}
335
+ parentColumn={
336
+ card.parentTaskId === null ? undefined : columnOf.get(card.parentTaskId)
337
+ }
338
+ />
339
+ ))}
340
+ </ul>
341
+ </SortableContext>
214
342
  {entry.cards.length === 0 ? (
215
343
  <p className="px-1 text-xs text-muted-foreground">{i18n.t("board.columnEmpty")}</p>
216
344
  ) : null}
@@ -258,8 +386,26 @@ function Column({
258
386
  );
259
387
  }
260
388
 
261
- export function BoardKanban({ board }: { board: BoardHandle }) {
389
+ export function BoardKanban({
390
+ board,
391
+ onOpen,
392
+ }: {
393
+ board: BoardHandle;
394
+ onOpen(taskId: string): void;
395
+ }) {
262
396
  const i18n = useI18n();
397
+ // The card under the pointer, for the preview. Nothing else about the gesture is state.
398
+ const [carried, setCarried] = useState<string | null>(null);
399
+
400
+ /**
401
+ * ⚠️ **A distance before the drag starts, or the card stops being a button.** Without it every
402
+ * press is a gesture: `dnd-kit` swallows the click that follows, and opening a card by clicking
403
+ * it — the thing the card is mostly for — silently stops working.
404
+ */
405
+ const sensors = useSensors(
406
+ useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
407
+ useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
408
+ );
263
409
 
264
410
  if (board.isPending) {
265
411
  return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
@@ -285,11 +431,173 @@ export function BoardKanban({ board }: { board: BoardHandle }) {
285
431
  });
286
432
  };
287
433
 
434
+ /**
435
+ * What a finished gesture means as a write, or `null` for "nothing changed".
436
+ *
437
+ * ⚠️ Shared by the handler and the announcement on purpose. Computed twice they would drift, and
438
+ * the drift would be invisible: the screen reader would report a move nobody made, or stay quiet
439
+ * about one that happened.
440
+ *
441
+ * ⚠️ **The lock belongs HERE, not in the caller.** With it one level up, a write starting
442
+ * underneath a running gesture stopped the handler and left the announcement saying "moved to
443
+ * Offen" over zero writes — the drift the paragraph above claims to rule out, restored by the
444
+ * one line that was not shared.
445
+ */
446
+ const resolve = (event: Pick<DragEndEvent, "active" | "over">) => {
447
+ if (board.isWriting) return null;
448
+ const dragged = cardById(board, String(event.active.id));
449
+ if (dragged === undefined) return null;
450
+ const drop = dragEndToDrop(
451
+ String(event.active.id),
452
+ event.over === null ? null : String(event.over.id),
453
+ board.columns,
454
+ dropIsAfter(event.active.rect.current.translated, event.over?.rect),
455
+ );
456
+ return drop === null ? null : dropToWrite(dragged, board.columns, drop);
457
+ };
458
+
459
+ /**
460
+ * ⚠️ **ONE derivation for the whole board, not one per card** — and that has to include the
461
+ * lookup the LABEL needs, not only the families. `dnd-kit` re-renders on every pointer move while
462
+ * a card is carried, so anything per-card that scans the board is quadratic: measured at 800
463
+ * cards it was 36 ms per render before, and a `find` left in the label put the same shape back
464
+ * at 5 ms per 1600 cards.
465
+ */
466
+ const families = familiesOf(board.columns.flatMap((entry) => entry.cards));
467
+ const columnOf = new Map(
468
+ board.columns.flatMap((entry) => entry.cards.map((card) => [card.id, entry.column.title])),
469
+ );
470
+
471
+ const carriedCard = carried === null ? undefined : cardById(board, carried);
472
+
288
473
  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>
474
+ <DndContext
475
+ sensors={sensors}
476
+ // ⚠️ Corners, not centres. A card is far taller than the gap between two cards, so the closest
477
+ // CENTRE stays the same card across most of a column and the gesture feels stuck; the closest
478
+ // corner changes at the boundary the reader is aiming at.
479
+ collisionDetection={closestCorners}
480
+ // ⚠️ `dnd-kit`'s own wording announces the space bar, and no keyboard sensor is registered —
481
+ // pressing it does nothing. Announcing a gesture that is not there is worse than announcing
482
+ // none: the reader tries it, nothing happens, and the one that works is never mentioned.
483
+ accessibility={{
484
+ screenReaderInstructions: { draggable: i18n.t("board.dragInstructions") },
485
+ // ⚠️ `dnd-kit`'s own announcements read out the raw ids — "Draggable item a was moved over
486
+ // droppable area a" — and report a drop even when nothing was written. Both are replaced:
487
+ // the card is named by its TITLE, the move-over is silent because there is nothing about a
488
+ // position this code could say honestly, and the end says what actually happened.
489
+ announcements: {
490
+ onDragStart: ({ active }) => {
491
+ const card = cardById(board, String(active.id));
492
+ return card === undefined
493
+ ? undefined
494
+ : i18n.t("board.drag.lifted", { card: card.title });
495
+ },
496
+ onDragOver: () => undefined,
497
+ onDragMove: () => undefined,
498
+ onDragEnd: (event) => {
499
+ const write = resolve(event);
500
+ const card = cardById(board, String(event.active.id));
501
+ // ⚠️ Silence, not a sentence without a subject. A card that vanished mid-gesture — an
502
+ // answer arrived, somebody else archived it — has no name left to announce, and
503
+ // " put back" read out on its own says less than nothing.
504
+ if (card === undefined) return undefined;
505
+ if (write === null) return i18n.t("board.drag.putBack", { card: card.title });
506
+ return i18n.t("board.drag.moved", {
507
+ card: card.title,
508
+ column:
509
+ board.columns.find((entry) => entry.column.id === write.status)?.column.title ??
510
+ write.status,
511
+ });
512
+ },
513
+ onDragCancel: ({ active }) => {
514
+ const card = cardById(board, String(active.id));
515
+ return card === undefined
516
+ ? undefined
517
+ : i18n.t("board.drag.putBack", { card: card.title });
518
+ },
519
+ },
520
+ }}
521
+ onDragStart={(event) => setCarried(String(event.active.id))}
522
+ onDragCancel={() => setCarried(null)}
523
+ onDragEnd={(event) => {
524
+ setCarried(null);
525
+ // ⚠️ The SECOND lock lives inside `resolve`, so the announcement obeys it too — see there.
526
+ const write = resolve(event);
527
+ if (write === null) return;
528
+ void board.updateTask({
529
+ taskId: String(event.active.id),
530
+ status: write.status,
531
+ position: write.position,
532
+ idempotencyKey: `move-${event.active.id}-${write.status}-${write.position}`,
533
+ });
534
+ }}
535
+ >
536
+ <div className="flex gap-3 overflow-x-auto p-4" aria-busy={board.isWriting}>
537
+ {board.columns.map((column) => (
538
+ <Column
539
+ key={column.column.id}
540
+ entry={column}
541
+ board={board}
542
+ onMove={move}
543
+ onOpen={onOpen}
544
+ families={families}
545
+ columnOf={columnOf}
546
+ />
547
+ ))}
548
+ </div>
549
+ {/* ⚠️ The preview is what a drag looks like. Without it the card stays put and only the
550
+ cursor moves, and there is nothing on screen saying the gesture was picked up at all. */}
551
+ <DragOverlay>
552
+ {carriedCard === undefined ? null : (
553
+ <div className="w-64 rounded-md border border-border bg-card p-2 text-left text-sm shadow-lg">
554
+ {carriedCard.title}
555
+ </div>
556
+ )}
557
+ </DragOverlay>
558
+ </DndContext>
294
559
  );
295
560
  }
561
+
562
+ /** A card that belongs to no family — the answer when the board has not been read yet. */
563
+ const noFamily: Family = { level: 0, tone: 0, rootId: null };
564
+
565
+ /**
566
+ * What the card's button is CALLED.
567
+ *
568
+ * ⚠️ **This is where the depth becomes readable, and the only place it can be.** The stripes are
569
+ * `aria-hidden` because a button's children are presentational: a `role="img"` with a label inside
570
+ * one is never announced, and the button's own name wins in any case. The board already spends a
571
+ * colour axis on the status, so the family may not be a second one — a reader who does not see the
572
+ * difference has to be able to read it.
573
+ */
574
+ function labelOf(
575
+ i18n: ReturnType<typeof useI18n>,
576
+ card: BoardTask,
577
+ column: ColumnWithCards,
578
+ family: Family,
579
+ parentColumn: string | undefined,
580
+ ): string {
581
+ const name = i18n.t("board.cardLabel", { title: card.title, column: column.column.title });
582
+ if (family.level === 0) return name;
583
+ // ⚠️ Handed IN, not looked up. A `find` over the board here runs once per card on every render,
584
+ // and puts back the quadratic shape the one derivation above exists to remove.
585
+ return `${name} ${depthOf(i18n, family, parentColumn)}`;
586
+ }
587
+
588
+ /**
589
+ * What the depth pattern says, in words.
590
+ *
591
+ * ⚠️ Always the REAL level, even where the stripes have capped. The mark stops counting at six; the
592
+ * sentence does not, because it is the channel somebody relies on when they cannot count stripes.
593
+ */
594
+ function depthOf(
595
+ i18n: ReturnType<typeof useI18n>,
596
+ family: Family,
597
+ parentColumn: string | undefined,
598
+ ): string {
599
+ if (family.level === 0) return "";
600
+ return parentColumn === undefined
601
+ ? i18n.t("board.stripes.root", { level: family.level })
602
+ : i18n.t("board.stripes.under", { level: family.level, column: parentColumn });
603
+ }