@anchrd/intel-ui 0.37.0 → 0.38.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@anchrd/intel-contract": "^0.22.0",
36
+ "@anchrd/intel-contract": "^0.23.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -47,12 +47,17 @@ import { selectedFrom } from "@/router/selection-search.ts";
47
47
  /**
48
48
  * What the plus can MAKE — derived from `NodeKind`, never written out again (#395).
49
49
  *
50
- * ⚠️ `attachment` is excluded because an attachment is uploaded rather than created, and `flow` is
51
- * added because a flow shares the tree without being a node (ADR-0004 §1). Both are decisions about
52
- * this menu; the LIST of node kinds is not, and a copy of it here goes stale in the direction that
53
- * already bit us the parked kinds stood in such a union long after nothing could make one.
50
+ * ⚠️ Three decisions about this MENU, each excluded by name: `attachment` because an attachment is
51
+ * uploaded rather than created, `task` because a task lives on a board and the API refuses one
52
+ * filed under a folder, and `flow` added because a flow shares the tree without being a node
53
+ * (ADR-0004 §1). The LIST of node kinds is not a decision here, and a copy of it would go stale in
54
+ * the direction that already bit us — the parked kinds stood in such a union long after nothing
55
+ * could make one.
56
+ *
57
+ * ⚠️ `task` was NOT excluded until #661, and the wide form carried it in silence from #647 onward:
58
+ * a menu entry that could only ever answer with a refusal. It is a type error now.
54
59
  */
55
- type NewKind = Exclude<NodeKind, "attachment"> | "flow";
60
+ type NewKind = Exclude<NodeKind, "attachment" | "task"> | "flow";
56
61
  type Creating = { parentId: string | null; kind: NewKind };
57
62
 
58
63
  // Attachments are inlined as base64, so the browser holds the file twice while it uploads.
@@ -852,7 +857,7 @@ function AddMenu(props: AddMenuProps) {
852
857
  const { label, onSelect } = props;
853
858
  const variant = props.variant ?? "icon";
854
859
  const i18n = useI18n();
855
- // ⚠️ Three groups, and the separators are the answer to what the plus promises: four kinds are
860
+ // ⚠️ Three groups, and the separators are the answer to what the plus promises: five kinds are
856
861
  // made here, an upload brings one thing in from the machine, an import brings a whole subtree
857
862
  // (#346). The icons come from the one kind-icon map rather than being named again — a second
858
863
  // `Folder` beside it is how the tree row and this menu start disagreeing.
@@ -860,6 +865,10 @@ function AddMenu(props: AddMenuProps) {
860
865
  { kind: "folder", labelKey: "tree.new.folder", Icon: kindIcons.folder },
861
866
  { kind: "document", labelKey: "tree.new.document", Icon: kindIcons.document },
862
867
  { kind: "table", labelKey: "tree.new.table", Icon: kindIcons.table },
868
+ // ⚠️ A board needs nothing else to be usable: an unconfigured one answers with three default
869
+ // columns (`DEFAULT_COLUMNS`, `packages/api/src/boards/boards.ts`), so unlike a table it has no
870
+ // second call here. A board with no columns would be a screen with nowhere to put a card.
871
+ { kind: "board", labelKey: "tree.new.board", Icon: kindIcons.board },
863
872
  { kind: "flow", labelKey: "tree.new.flow", Icon: kindIcons.flow },
864
873
  ];
865
874
  return (
@@ -0,0 +1,169 @@
1
+ import type {
2
+ BoardColumn,
3
+ BoardTask,
4
+ BoardTaskCreateInput,
5
+ BoardTaskFilter,
6
+ BoardTaskUpdateInput,
7
+ BoardView,
8
+ } from "@anchrd/intel-contract/board";
9
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10
+ import { useMemo } from "react";
11
+ import { useIntelRouterContext } from "@/router/router-context.ts";
12
+ import type { BoardHandle, ColumnWithCards } from "./board-data.types.ts";
13
+
14
+ export const boardKey = (boardId: string) => ["board", boardId] as const;
15
+
16
+ /**
17
+ * Whether this filter narrows anything at all.
18
+ *
19
+ * ⚠️ **`Object.keys(...).length` is the wrong question**, because clearing a control writes the key
20
+ * back with `undefined` rather than removing it — `{ status: undefined }` has one key and filters
21
+ * nothing. Read that as "filtering" and the table says "nothing matches this filter" about a board
22
+ * nobody is filtering, and a write pays a refetch it does not owe.
23
+ */
24
+ export function isFiltering(filter: Partial<BoardTaskFilter>): boolean {
25
+ return Object.values(filter).some((value) => value !== undefined);
26
+ }
27
+
28
+ /**
29
+ * Where a card lands when it is dropped between two neighbours.
30
+ *
31
+ * ⚠️ **The midpoint, and that is the whole reason `position` is a float.** With integers a drop
32
+ * would renumber every row below it, two people dropping at once would fight over rows neither
33
+ * touched, and a retry would move the card again. Here one card is written and nothing else is.
34
+ *
35
+ * The two edges are the cases a midpoint cannot express: dropped at the top there is nothing above,
36
+ * dropped at the bottom nothing below. `1` away from the neighbour keeps room for the next drop on
37
+ * that side without ever needing to renumber.
38
+ */
39
+ export function positionBetween(above: number | undefined, below: number | undefined): number {
40
+ if (above === undefined && below === undefined) return 1;
41
+ if (above === undefined) return (below ?? 1) - 1;
42
+ if (below === undefined) return above + 1;
43
+ return (above + below) / 2;
44
+ }
45
+
46
+ /**
47
+ * The board as the views draw it: columns in their configured order, each carrying its own cards
48
+ * sorted by position.
49
+ *
50
+ * ⚠️ **A card whose status names no column is kept, in a column of its own at the end.** Dropping
51
+ * it would be the quiet failure: somebody renames a column, every card in it stops being drawn, and
52
+ * the screen looks like the cards were deleted. A visible stray column is ugly and true.
53
+ */
54
+ export function columnsWithCards(view: BoardView): ColumnWithCards[] {
55
+ const byStatus = new Map<string, BoardTask[]>();
56
+ for (const task of view.tasks) {
57
+ const cards = byStatus.get(task.status);
58
+ if (cards === undefined) byStatus.set(task.status, [task]);
59
+ else cards.push(task);
60
+ }
61
+ const sorted = (cards: BoardTask[]) => [...cards].sort((a, b) => a.position - b.position);
62
+ const known: ColumnWithCards[] = view.columns.map((column) => ({
63
+ column,
64
+ cards: sorted(byStatus.get(column.id) ?? []),
65
+ }));
66
+ const orphaned = [...byStatus.keys()].filter(
67
+ (status) => !view.columns.some((column) => column.id === status),
68
+ );
69
+ const strays: ColumnWithCards[] = orphaned.map((status) => ({
70
+ column: { id: status, title: status, terminal: false } satisfies BoardColumn,
71
+ cards: sorted(byStatus.get(status) ?? []),
72
+ unknown: true,
73
+ }));
74
+ return [...known, ...strays];
75
+ }
76
+
77
+ /**
78
+ * Everything a board view needs, from one query and three mutations.
79
+ *
80
+ * ⚠️ **Every write answers with the whole board, and the answer is written straight into the
81
+ * cache.** The alternative — patch the cached board from the row that changed — is a second place
82
+ * that decides what a column contains, and the two drift the first time the server does something
83
+ * the client did not predict. A move, for instance, also gives the card a new position when none
84
+ * was sent.
85
+ */
86
+ export function useBoard(boardId: string, filter: Partial<BoardTaskFilter> = {}): BoardHandle {
87
+ const { data } = useIntelRouterContext();
88
+ const client = useQueryClient();
89
+ const key = boardKey(boardId);
90
+
91
+ /**
92
+ * ⚠️ **The filter belongs in the key, and `includeArchived` is only its most obvious member.**
93
+ * Without it every filtered view of a board shares one cache entry: switching a filter shows the
94
+ * previous selection's cards until the fetch returns, which reads as cards appearing and
95
+ * vanishing on their own.
96
+ *
97
+ * ⚠️ **The filter travels; it is not applied here.** A view that fetched everything and hid rows
98
+ * in the browser looks identical and is a different thing — `board-table.unit.tsx` therefore
99
+ * asserts the ARGUMENT of the second request, not just that a request happened.
100
+ */
101
+ const scoped = [...key, filter] as const;
102
+ const unfiltered = [...key, {}] as const;
103
+ const query = useQuery({
104
+ queryKey: scoped,
105
+ // `includeArchived` is spelled out because the provider takes the contract's OUTPUT type,
106
+ // where the default has already been applied and the field is therefore required.
107
+ queryFn: async () => await data.getBoard({ includeArchived: false, boardId, ...filter }),
108
+ });
109
+
110
+ /**
111
+ * Where a write's answer goes.
112
+ *
113
+ * ⚠️ **A write always answers with the board UNFILTERED, whatever this handle is filtered by.**
114
+ * All three write paths ask for the view with a fixed `{ boardId, includeArchived: false }`
115
+ * (`packages/api/src/boards/boards.ts`), and none of the three inputs even carries a filter field.
116
+ * So the answer belongs in the unfiltered entry — and settling it into the filtered one would put
117
+ * every card into a view that asked for some of them, with no error, because the write succeeded.
118
+ *
119
+ * ⚠️ **The filtered entry is therefore invalidated rather than patched.** This answer cannot say
120
+ * what the filter now matches: a card that was moved out of it has to disappear, one moved into it
121
+ * has to appear, and only the server knows which. One extra request per write under a filter, and
122
+ * an unfiltered board — the ordinary case — still pays none.
123
+ */
124
+ const settle = (view: BoardView) => {
125
+ client.setQueryData(unfiltered, view);
126
+ if (isFiltering(filter)) void client.invalidateQueries({ queryKey: scoped });
127
+ };
128
+
129
+ const createTask = useMutation({
130
+ mutationFn: async (input: Omit<BoardTaskCreateInput, "boardId">) =>
131
+ await data.createBoardTask({ ...input, boardId }),
132
+ onSuccess: settle,
133
+ });
134
+ const updateTask = useMutation({
135
+ mutationFn: async (input: BoardTaskUpdateInput) => await data.updateBoardTask(boardId, input),
136
+ onSuccess: settle,
137
+ });
138
+ const setColumns = useMutation({
139
+ mutationFn: async (input: { columns: BoardColumn[]; idempotencyKey: string }) =>
140
+ await data.updateBoard({ boardId, ...input }),
141
+ onSuccess: settle,
142
+ });
143
+
144
+ const columns = useMemo(
145
+ () => (query.data === undefined ? [] : columnsWithCards(query.data)),
146
+ [query.data],
147
+ );
148
+
149
+ return {
150
+ boardId,
151
+ title: query.data?.title ?? "",
152
+ columns,
153
+ isPending: query.isPending,
154
+ isError: query.isError,
155
+ // ⚠️ One flag for all three writes. A screen that disabled only the button it just pressed
156
+ // would let a second drag start while the first is in flight, and the second answer would
157
+ // overwrite the first — with no error, because both succeeded.
158
+ isWriting: createTask.isPending || updateTask.isPending || setColumns.isPending,
159
+ createTask: async (input) => {
160
+ await createTask.mutateAsync(input);
161
+ },
162
+ updateTask: async (input) => {
163
+ await updateTask.mutateAsync(input);
164
+ },
165
+ setColumns: async (input) => {
166
+ await setColumns.mutateAsync(input);
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,29 @@
1
+ import type {
2
+ BoardColumn,
3
+ BoardTask,
4
+ BoardTaskCreateInput,
5
+ BoardTaskUpdateInput,
6
+ } from "@anchrd/intel-contract/board";
7
+
8
+ // One column and the cards in it, in the order they are drawn.
9
+ export interface ColumnWithCards {
10
+ column: BoardColumn;
11
+ cards: BoardTask[];
12
+ // ⚠️ Set only on a column the board does not configure — cards whose status names nothing. It is
13
+ // drawn differently rather than hidden: a card nobody can see is a card somebody will look for.
14
+ unknown?: boolean;
15
+ }
16
+
17
+ export interface BoardHandle {
18
+ boardId: string;
19
+ title: string;
20
+ columns: ColumnWithCards[];
21
+ isPending: boolean;
22
+ isError: boolean;
23
+ isWriting: boolean;
24
+ // `boardId` is supplied by the handle, so a view cannot file a card on the wrong board by
25
+ // forgetting a field.
26
+ createTask(input: Omit<BoardTaskCreateInput, "boardId">): Promise<void>;
27
+ updateTask(input: BoardTaskUpdateInput): Promise<void>;
28
+ setColumns(input: { columns: BoardColumn[]; idempotencyKey: string }): Promise<void>;
29
+ }
@@ -0,0 +1,108 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract/board";
2
+ import { positionBetween } from "@/board/board-data/board-data.ts";
3
+ import type { ColumnWithCards } from "@/board/board-data/board-data.types.ts";
4
+
5
+ /**
6
+ * Where a dragged card was let go.
7
+ *
8
+ * ⚠️ **`index` counts into the column AS RENDERED — including the dragged card itself when it
9
+ * started there.** That is what a drop handler can actually measure: the card stays in the list
10
+ * while the gesture runs, and the browser reports the gap it hovers over. Reading it as an index
11
+ * into the list *without* the card is off by one for every downward move inside a column, and the
12
+ * symptom is a card landing one place short of where it was let go — which looks like a sloppy
13
+ * animation rather than a bug.
14
+ */
15
+ export interface Drop {
16
+ status: string;
17
+ index: number;
18
+ }
19
+
20
+ /**
21
+ * What a drop means as a write — the whole of the drag logic, kept out of the component so it can be
22
+ * proven without a browser.
23
+ *
24
+ * ⚠️ **`null` means "changed nothing", and the caller must not write.** A card let go where it
25
+ * already sits is the most common drop there is: somebody picks one up, thinks again, and puts it
26
+ * back. Writing anyway costs a round trip, a re-render, and — because the position would be
27
+ * recomputed — a card that visibly jumps for no reason.
28
+ *
29
+ * ⚠️ **The card itself is removed from the neighbour list first.** Without that, dragging a card
30
+ * one place down within its own column measures against its OWN old position: the midpoint lands
31
+ * between the card and its neighbour, which is where it already was. It would look like the drag
32
+ * did nothing, which is the same symptom as a broken drop handler and reads as one.
33
+ */
34
+ export function dropToWrite(
35
+ card: BoardTask,
36
+ columns: ColumnWithCards[],
37
+ drop: Drop,
38
+ ): { status: string; position: number } | null {
39
+ const target = columns.find((entry) => entry.column.id === drop.status);
40
+ if (target === undefined) return null;
41
+
42
+ const others = target.cards.filter((entry) => entry.id !== card.id);
43
+ const wasAt = target.cards.findIndex((entry) => entry.id === card.id);
44
+
45
+ // ⚠️ The card is counted out of its own gap. Dropped BELOW where it started, every card it passed
46
+ // has shifted up by one, so the index into the remaining cards is one lower. Above its start
47
+ // nothing moved and the index carries over unchanged.
48
+ const wanted = wasAt >= 0 && drop.index > wasAt ? drop.index - 1 : drop.index;
49
+ const index = Math.max(0, Math.min(wanted, others.length));
50
+
51
+ // Let go in its own gap: nothing moved, and writing anyway would recompute the position and make
52
+ // the card jump for no reason.
53
+ if (wasAt >= 0 && index === wasAt) return null;
54
+
55
+ return {
56
+ status: drop.status,
57
+ position: positionBetween(others[index - 1]?.position, others[index]?.position),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * The card a keyboard move should land on, given the one it starts from.
63
+ *
64
+ * ⚠️ Keyboard reachability is part of the first version here, not polish — dragging is a mouse
65
+ * gesture and a board that can only be operated by dragging is a board half the people cannot use.
66
+ * Left and right move between columns and keep the row; up and down move within one.
67
+ */
68
+ export function neighbourDrop(
69
+ card: BoardTask,
70
+ columns: ColumnWithCards[],
71
+ direction: "left" | "right" | "up" | "down",
72
+ ): Drop | null {
73
+ const columnIndex = columns.findIndex((entry) => entry.column.id === card.status);
74
+ const column = columns[columnIndex];
75
+ if (column === undefined) return null;
76
+ const row = column.cards.findIndex((entry) => entry.id === card.id);
77
+
78
+ if (direction === "left" || direction === "right") {
79
+ const next = columns[columnIndex + (direction === "left" ? -1 : 1)];
80
+ // ⚠️ No wrapping. A card that jumps from the last column to the first because somebody pressed
81
+ // the arrow once too often is a move nobody asked for, and the one they did ask for is lost.
82
+ if (next === undefined) return null;
83
+ return { status: next.column.id, index: Math.min(row, next.cards.length) };
84
+ }
85
+ const landing = row + (direction === "up" ? -1 : 1);
86
+ if (landing < 0 || landing >= column.cards.length) return null;
87
+ // ⚠️ Down needs one more than the row it lands on. `index` is a GAP in the list AS RENDERED, and
88
+ // moving down one place means ending up BEHIND the neighbour — that is the gap after it. Naming
89
+ // the neighbour's own gap names the gap the card already occupies, and `dropToWrite` then
90
+ // correctly answers "nothing changed" and swallows the move. Upwards the two coincide, which is
91
+ // why this reads as an asymmetry and is not one.
92
+ return { status: column.column.id, index: direction === "down" ? landing + 1 : landing };
93
+ }
94
+
95
+ /**
96
+ * Which gap a drop ON a card means — the half of the card the pointer is over.
97
+ *
98
+ * ⚠️ Without this the column is the only drop target there is, and every drop then means "append":
99
+ * `dropToWrite` computes a correct midpoint for any gap, but the component never asks it for one
100
+ * other than the end. On screen the card follows the pointer to the middle of the column and then
101
+ * jumps to the bottom, which reads as a broken drag rather than as a missing feature.
102
+ *
103
+ * The geometry is passed in rather than read here so the decision — upper half means before the
104
+ * card, lower half means after it — can be proven without a layout engine.
105
+ */
106
+ export function dropIndexOnCard(row: number, pointerY: number, box: DOMRect): number {
107
+ return pointerY < box.top + box.height / 2 ? row : row + 1;
108
+ }
@@ -0,0 +1,295 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract/board";
2
+ import { useState } from "react";
3
+ import type { BoardHandle, ColumnWithCards } from "@/board/board-data/board-data.types.ts";
4
+ 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";
10
+
11
+ function Card({
12
+ card,
13
+ column,
14
+ row,
15
+ board,
16
+ onMove,
17
+ }: {
18
+ card: BoardTask;
19
+ column: ColumnWithCards;
20
+ row: number;
21
+ board: BoardHandle;
22
+ onMove(card: BoardTask, drop: Drop): void;
23
+ }) {
24
+ const i18n = useI18n();
25
+ const locked = board.isWriting;
26
+ return (
27
+ <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
+ }}
49
+ >
50
+ {/* ⚠️ A real button, not an `<li role="button">`. The role override needs two lint
51
+ suppressions and still leaves a listitem that only *claims* to be operable; a button is
52
+ focusable, is announced as operable, and — the part that made the override look necessary
53
+ — may perfectly well be a drag source. */}
54
+ <button
55
+ 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
+ }}
63
+ onKeyDown={(event) => {
64
+ // ⚠️ Not while a write is in flight. Two moves in quick succession both read the same
65
+ // not-yet-updated `board.columns`, compute a position independently and both write — the
66
+ // second answer overwrites the first, with no error, because both succeeded. That is the
67
+ // race `isWriting` was introduced for in #651, and marking the board busy without
68
+ // refusing the input leaves it exactly as open as before.
69
+ if (locked) return;
70
+ // ⚠️ Only with a modifier. The arrows alone belong to whatever the reader is used to —
71
+ // scrolling the column, moving focus — and taking them would make a board that cannot be
72
+ // read without moving its cards.
73
+ if (!event.altKey) return;
74
+ const direction =
75
+ event.key === "ArrowLeft"
76
+ ? "left"
77
+ : event.key === "ArrowRight"
78
+ ? "right"
79
+ : event.key === "ArrowUp"
80
+ ? "up"
81
+ : event.key === "ArrowDown"
82
+ ? "down"
83
+ : null;
84
+ if (direction === null) return;
85
+ const drop = neighbourDrop(card, board.columns, direction);
86
+ if (drop === null) return;
87
+ event.preventDefault();
88
+ onMove(card, drop);
89
+ }}
90
+ >
91
+ <span className="block">{card.title}</span>
92
+ {card.dueDate === null ? null : (
93
+ <span className="mt-1 block text-xs text-muted-foreground">
94
+ {card.dueDate.slice(0, 10)}
95
+ </span>
96
+ )}
97
+ </button>
98
+ </li>
99
+ );
100
+ }
101
+
102
+ function cardById(board: BoardHandle, id: string): BoardTask | undefined {
103
+ return board.columns.flatMap((entry) => entry.cards).find((card) => card.id === id);
104
+ }
105
+
106
+ /**
107
+ * One column, with the way to add a card to it.
108
+ *
109
+ * ⚠️ **`group/column` sits on the section, and that is only safe because columns do not nest.**
110
+ * A named group compiles to a DESCENDANT selector: put it on something that also holds another
111
+ * column and hovering one card lights the add row of every column below it — the tree paid for that
112
+ * with #252, and `packages/ui/CLAUDE.md` carries the rule. Here the sections are siblings, so a
113
+ * card in one is never a descendant of another. `board-kanban.unit.tsx` asserts exactly that,
114
+ * structurally, because jsdom computes no hover.
115
+ */
116
+ function Column({
117
+ entry,
118
+ board,
119
+ onMove,
120
+ }: {
121
+ entry: ColumnWithCards;
122
+ board: BoardHandle;
123
+ onMove(card: BoardTask, drop: Drop): void;
124
+ }) {
125
+ const i18n = useI18n();
126
+ const [composing, setComposing] = useState(false);
127
+ const [draft, setDraft] = useState("");
128
+ const [failed, setFailed] = useState(false);
129
+ // ⚠️ A column the board does not configure takes no new card: the server refuses a status that
130
+ // names no column (`unknown_column`), so offering the control would promise a refusal.
131
+ const addable = entry.unknown !== true;
132
+
133
+ // ⚠️ One way out, and it clears the REFUSAL too. Left standing, the message outlives the attempt
134
+ // it belongs to: somebody types, is refused, presses Escape — and the column keeps a sentence
135
+ // about a card nobody is trying to create any more, until the next attempt happens to clear it.
136
+ const close = () => {
137
+ setComposing(false);
138
+ setDraft("");
139
+ setFailed(false);
140
+ };
141
+
142
+ const create = () => {
143
+ const title = draft.trim();
144
+ if (title.length === 0 || board.isWriting) return;
145
+ setFailed(false);
146
+ board
147
+ .createTask({
148
+ title,
149
+ status: entry.column.id,
150
+ // ⚠️ Spelled out because the handle takes the contract's OUTPUT type, where every default
151
+ // has already been applied and the fields are therefore required. The server decides the
152
+ // position — the end of this column — and a retry does not move the card again (#648).
153
+ assigneeId: null,
154
+ labels: [],
155
+ startDate: null,
156
+ dueDate: null,
157
+ dependsOn: null,
158
+ idempotencyKey: crypto.randomUUID(),
159
+ })
160
+ .then(close)
161
+ // ⚠️ Reported IN the column. A refusal shown only in a global banner leaves the reader
162
+ // looking at the column they typed into, which says nothing happened.
163
+ .catch(() => setFailed(true));
164
+ };
165
+
166
+ return (
167
+ <section
168
+ aria-label={entry.column.title}
169
+ 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
+ >
187
+ <h3 className="flex items-baseline gap-2 px-1 text-sm font-medium">
188
+ {entry.column.title}
189
+ <span className="text-xs text-muted-foreground tabular-nums">{entry.cards.length}</span>
190
+ {entry.unknown === true ? (
191
+ // ⚠️ A column the board does not configure — cards whose status names nothing. Drawn
192
+ // rather than hidden: a card nobody can see is a card somebody will look for.
193
+ <span className="text-xs text-muted-foreground">{i18n.t("board.unknownColumn")}</span>
194
+ ) : null}
195
+ {addable ? (
196
+ // ⚠️ The narrow-screen way in, and it is ALWAYS visible: hover is not a gesture a touch
197
+ // screen has, so the row below would be unreachable there. Above `md` this one steps
198
+ // aside for it.
199
+ <button
200
+ type="button"
201
+ aria-label={i18n.t("board.addCardIn", { column: entry.column.title })}
202
+ onClick={() => setComposing(true)}
203
+ className="ml-auto rounded-md px-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring md:hidden"
204
+ >
205
+ +
206
+ </button>
207
+ ) : null}
208
+ </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}
217
+ {addable ? (
218
+ composing ? (
219
+ <input
220
+ // The field exists because it was just asked for, and making the reader click a second
221
+ // time is the whole cost of the control.
222
+ // biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
223
+ autoFocus
224
+ value={draft}
225
+ aria-label={i18n.t("board.addCardIn", { column: entry.column.title })}
226
+ onChange={(event) => setDraft(event.target.value)}
227
+ onKeyDown={(event) => {
228
+ if (event.key === "Enter") {
229
+ event.preventDefault();
230
+ create();
231
+ }
232
+ if (event.key === "Escape") close();
233
+ }}
234
+ onBlur={() => {
235
+ if (draft.trim().length === 0) close();
236
+ }}
237
+ className="rounded-md border bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
238
+ />
239
+ ) : (
240
+ // ⚠️ `md:opacity-0` plus BOTH group states, not `hover` alone: a control that only exists
241
+ // while a pointer rests on the column does not exist for a keyboard at all. Focus inside
242
+ // the column brings it back, the same shape the tree uses for its plus.
243
+ <button
244
+ type="button"
245
+ onClick={() => setComposing(true)}
246
+ className="hidden rounded-md px-2 py-1 text-left text-sm text-muted-foreground outline-none transition-opacity hover:bg-muted focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-focus-within/column:opacity-100 group-hover/column:opacity-100 md:block md:opacity-0"
247
+ >
248
+ {i18n.t("board.addCard")}
249
+ </button>
250
+ )
251
+ ) : null}
252
+ {failed ? (
253
+ <p role="alert" className="px-1 text-xs text-destructive">
254
+ {i18n.t("board.createFailed")}
255
+ </p>
256
+ ) : null}
257
+ </section>
258
+ );
259
+ }
260
+
261
+ export function BoardKanban({ board }: { board: BoardHandle }) {
262
+ const i18n = useI18n();
263
+
264
+ if (board.isPending) {
265
+ return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
266
+ }
267
+ if (board.isError) {
268
+ return (
269
+ <p role="alert" className="p-6 text-sm text-destructive">
270
+ {i18n.t("board.failed")}
271
+ </p>
272
+ );
273
+ }
274
+
275
+ const move = (card: BoardTask, drop: Drop) => {
276
+ const write = dropToWrite(card, board.columns, drop);
277
+ // ⚠️ `null` means the card is where it already was. Writing anyway would recompute the position
278
+ // and make it jump — the most common "drop" there is somebody putting a card back.
279
+ if (write === null) return;
280
+ void board.updateTask({
281
+ taskId: card.id,
282
+ status: write.status,
283
+ position: write.position,
284
+ idempotencyKey: `move-${card.id}-${write.status}-${write.position}`,
285
+ });
286
+ };
287
+
288
+ 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>
294
+ );
295
+ }