@anchrd/intel-ui 0.40.0 → 0.42.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,19 +1,17 @@
1
1
  import type { BoardTask } from "@anchrd/intel-contract/board";
2
- import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
3
- import { ChevronLeft, Lock, Plus, Square, SquareCheck } from "lucide-react";
2
+ import { Lock, Plus, Square, SquareCheck } from "lucide-react";
4
3
  import { useState } from "react";
5
- import { useAssigneeLabel } from "@/board/board-assignee/board-assignee.ts";
4
+ import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
5
+ import { BoardChip } from "@/board/board-chip/board-chip.tsx";
6
6
  import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
7
+ import { StatusChip } from "@/board/board-status/board-status.tsx";
7
8
  import {
8
9
  DropdownMenu,
9
10
  DropdownMenuContent,
10
11
  DropdownMenuItem,
11
- DropdownMenuRadioGroup,
12
- DropdownMenuRadioItem,
13
12
  DropdownMenuTrigger,
14
13
  } from "@/components/ui/dropdown-menu.tsx";
15
14
  import { useI18n } from "@/i18n/i18n-context.tsx";
16
- import { initials } from "@/user-name/user-name.ts";
17
15
  import { type LinkKind, linksOf, progressOf, type TaskLink } from "./board-task.ts";
18
16
 
19
17
  const linkIcons: Record<LinkKind, typeof Lock> = {
@@ -22,33 +20,6 @@ const linkIcons: Record<LinkKind, typeof Lock> = {
22
20
  blocked: Lock,
23
21
  };
24
22
 
25
- /**
26
- * Who the task is for, as the circle the access summary uses.
27
- *
28
- * ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed, the field stays
29
- * `assigneeId`. A row of circles here would imply a second data model that does not exist.
30
- */
31
- function Assignee({ id }: { id: string | null }) {
32
- const i18n = useI18n();
33
- const label = useAssigneeLabel(id);
34
- if (id === null) {
35
- return <span className="text-xs text-muted-foreground">{label}</span>;
36
- }
37
- return (
38
- <span
39
- // ⚠️ `role="img"` and not a bare span: two initials are a picture of a name, and a plain
40
- // `span` carries no `aria-label` at all — the attribute is dropped, and the circle is then
41
- // read out as the two letters it happens to contain.
42
- role="img"
43
- aria-label={label}
44
- title={label}
45
- className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
46
- >
47
- {initials(label)}
48
- </span>
49
- );
50
- }
51
-
52
23
  /** One entry of the link list. The icon carries the kind; nothing writes the word out. */
53
24
  function LinkRow({ link, onOpen }: { link: TaskLink; onOpen(id: string): void }) {
54
25
  const i18n = useI18n();
@@ -83,19 +54,15 @@ export function BoardTaskDocument({
83
54
  task,
84
55
  board,
85
56
  onOpen,
86
- onClose,
87
57
  body,
88
58
  }: {
89
59
  task: BoardTask;
90
60
  board: BoardHandle;
91
61
  onOpen(taskId: string): void;
92
- onClose(): void;
93
62
  /** The markdown surface, handed in so this component stays free of the editor's own loading. */
94
63
  body: React.ReactNode;
95
64
  }) {
96
65
  const i18n = useI18n();
97
- const [label, setLabel] = useState("");
98
- const [adding, setAdding] = useState(false);
99
66
 
100
67
  const links = linksOf(task, board.columns);
101
68
  const progress = progressOf(links);
@@ -117,137 +84,79 @@ export function BoardTaskDocument({
117
84
  });
118
85
  };
119
86
 
120
- // ⚠️ A single date has to say WHICH one it is. Drawn bare, a start date and a due date look
121
- // identical — and reading a start date as a deadline is the expensive direction of that mistake.
122
- const dates =
123
- task.startDate !== null && task.dueDate !== null
124
- ? `${task.startDate.slice(0, 10)} – ${task.dueDate.slice(0, 10)}`
125
- : task.startDate !== null
126
- ? i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })
127
- : task.dueDate !== null
128
- ? i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })
129
- : null;
130
-
131
87
  return (
132
88
  <div
133
89
  className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-6"
134
90
  aria-busy={board.isWriting}
135
91
  >
136
- <button
137
- type="button"
138
- onClick={onClose}
139
- className="flex w-fit items-center gap-1 rounded-md text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
140
- >
141
- <ChevronLeft aria-hidden="true" className="size-4" />
142
- {board.title}
143
- </button>
144
-
145
- <h1 className="text-2xl font-semibold tracking-tight">{task.title}</h1>
146
-
92
+ {/* ⚠️ No heading and no way back HERE. Both live in the title row above since #692: the path
93
+ there ends in this card's name, so a heading would print it a second time, and an arrow
94
+ here would be a second control meaning what the one up there already means. */}
147
95
  {/* ⚠️ ONE line of chips, and no field labels. "Status:" in front of a chip that says "Backlog"
148
96
  says nothing the chip does not — and it turns a quiet head into a form. */}
149
97
  <div className="flex flex-wrap items-center gap-2">
150
- <DropdownMenu>
151
- <DropdownMenuTrigger
152
- aria-label={i18n.t("board.moveTo")}
153
- className="rounded-full border px-3 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
154
- >
155
- {column?.title ?? task.status}
156
- </DropdownMenuTrigger>
157
- <DropdownMenuContent align="start">
158
- <DropdownMenuRadioGroup
159
- value={task.status}
160
- // ⚠️ Where a task SITS is a move, not a field edit: the server computes a position and
161
- // checks the cycles for it. Everything else on this screen is a plain change.
162
- onValueChange={(next) => write({ status: next })}
163
- >
164
- {columns.map((entry) => (
165
- <DropdownMenuRadioItem key={entry.id} value={entry.id}>
166
- {entry.title}
167
- </DropdownMenuRadioItem>
168
- ))}
169
- <DropdownMenuRadioItem value={ARCHIVE_COLUMN_ID}>
170
- {i18n.t("board.column.archive")}
171
- </DropdownMenuRadioItem>
172
- </DropdownMenuRadioGroup>
173
- </DropdownMenuContent>
174
- </DropdownMenu>
175
-
176
- <Assignee id={task.assigneeId} />
98
+ <StatusChip
99
+ status={task.status}
100
+ title={column?.title ?? task.status}
101
+ columns={columns}
102
+ write={write}
103
+ />
177
104
 
178
- {dates === null ? null : (
179
- <span className="rounded-full border px-3 py-1 text-xs tabular-nums">{dates}</span>
105
+ {/* ⚠️ Two chips when both dates are set, not a range. A range reads as one fact; these
106
+ are two, and each is removed on its own. */}
107
+ {task.startDate === null ? null : (
108
+ <BoardChip
109
+ label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
110
+ action={i18n.t("board.clearStart")}
111
+ onClick={() => write({ startDate: null })}
112
+ />
113
+ )}
114
+ {task.dueDate === null ? null : (
115
+ <BoardChip
116
+ label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
117
+ action={i18n.t("board.clearDue")}
118
+ onClick={() => write({ dueDate: null })}
119
+ />
180
120
  )}
181
121
 
182
122
  {task.labels.map((entry) => (
183
- <button
123
+ <BoardChip
184
124
  key={entry}
185
- type="button"
186
- aria-label={i18n.t("board.removeLabel", { label: entry })}
125
+ label={entry}
126
+ action={i18n.t("board.removeLabel")}
187
127
  onClick={() => write({ labels: task.labels.filter((keep) => keep !== entry) })}
188
- className="rounded-full bg-muted px-3 py-1 text-xs outline-none hover:bg-muted/70 focus-visible:ring-2 focus-visible:ring-ring"
189
- >
190
- {entry}
191
- </button>
128
+ />
192
129
  ))}
193
130
 
194
- {adding ? (
195
- <input
196
- // biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
197
- autoFocus
198
- value={label}
199
- aria-label={i18n.t("board.addLabel")}
200
- onChange={(event) => setLabel(event.target.value)}
201
- onKeyDown={(event) => {
202
- if (event.key === "Escape") {
203
- setAdding(false);
204
- setLabel("");
205
- }
206
- if (event.key !== "Enter") return;
207
- event.preventDefault();
208
- const next = label.trim();
209
- // ⚠️ Tags are REPLACED, not merged, so the whole list travels — and a duplicate would
210
- // be written as one more entry rather than refused.
211
- if (next.length > 0 && !task.labels.includes(next)) {
212
- write({ labels: [...task.labels, next] });
213
- }
214
- setAdding(false);
215
- setLabel("");
216
- }}
217
- className="w-28 rounded-full border bg-background px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
218
- />
219
- ) : (
220
- <button
221
- type="button"
222
- aria-label={i18n.t("board.addLabel")}
223
- onClick={() => setAdding(true)}
224
- className="rounded-full border px-2 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
225
- >
226
- <Plus aria-hidden="true" className="size-3" />
227
- </button>
131
+ {/* ⚠️ **Last, always.** It is the only round element in the row; standing between the chips
132
+ it breaks the line, and a row without one simply ends earlier. */}
133
+ {task.assigneeId === null ? null : (
134
+ <AssigneeChip id={task.assigneeId} clear={() => write({ assigneeId: null })} />
228
135
  )}
136
+
137
+ <Adder task={task} board={board} write={write} />
229
138
  </div>
230
139
 
231
- {/* ⚠️ ONE list, not three blocks. The icon carries the kind box, lock, chain and the order
232
- is the decision documented on `linksOf`. */}
233
- <section aria-label={i18n.t("board.links")} className="flex flex-col gap-1">
234
- <h2 className="flex items-center gap-2 text-sm font-medium">
235
- {i18n.t("board.links")}
236
- {progress === null ? null : (
237
- <span className="text-xs text-muted-foreground tabular-nums">
238
- {i18n.t("board.progress", { done: progress.done, total: progress.total })}
239
- </span>
240
- )}
241
- </h2>
242
- {links.length === 0 ? null : (
140
+ {/* ⚠️ ONE list, not three blocks: the icon carries the kind. And it exists only when there is
141
+ something in it. An empty section with a heading and a hint tells the reader that something
142
+ is missing; nothing at all tells them there is nothing, which is the truth (#692). */}
143
+ {links.length === 0 ? null : (
144
+ <section aria-label={i18n.t("board.links")} className="flex flex-col gap-1">
145
+ <h2 className="flex items-center gap-2 text-sm font-medium">
146
+ {i18n.t("board.links")}
147
+ {progress === null ? null : (
148
+ <span className="text-xs text-muted-foreground tabular-nums">
149
+ {i18n.t("board.progress", { done: progress.done, total: progress.total })}
150
+ </span>
151
+ )}
152
+ </h2>
243
153
  <ul className="flex flex-col">
244
154
  {links.map((link) => (
245
155
  <LinkRow key={`${link.kind}:${link.id}`} link={link} onOpen={onOpen} />
246
156
  ))}
247
157
  </ul>
248
- )}
249
- <LinkAdder task={task} board={board} />
250
- </section>
158
+ </section>
159
+ )}
251
160
 
252
161
  {/* The markdown text is the largest element of the page — everything above is the quiet part.
253
162
  ⚠️ The slot is not decoration: a test that asks whether the BODY drew something has to be
@@ -265,77 +174,122 @@ export function BoardTaskDocument({
265
174
  * and "waits for". That is `#371`: the reader decides WHAT they are linking once they know which
266
175
  * card they mean, not before.
267
176
  */
268
- function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
177
+
178
+ /**
179
+ * What the card can still be given. The order matches the chips it produces.
180
+ *
181
+ * ⚠️ **No `assignee` here, and that is a gap rather than a decision.** Setting one needs a person to
182
+ * pick from, and this application can only ever DISPLAY principals it already sees in a grant: there
183
+ * is no list to choose from anywhere in the data provider. An entry that opens nothing is worse than
184
+ * a missing one, because it looks like the feature is there (`#700`).
185
+ */
186
+ const addable = ["label", "start", "due"] as const;
187
+ const linkable = ["subtask", "blocker"] as const;
188
+ type Addable = (typeof addable)[number] | (typeof linkable)[number];
189
+
190
+ /**
191
+ * ⚠️ **ONE plus for everything the card can carry**, and the kind is chosen afterwards. Two
192
+ * controls, one for fields and one for links, asked the reader to know which of the two a thing was
193
+ * before they could add it, and that is a question about our data model, not about their work.
194
+ */
195
+ function Adder({
196
+ task,
197
+ board,
198
+ write,
199
+ }: {
200
+ task: BoardTask;
201
+ board: BoardHandle;
202
+ write(input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>): void;
203
+ }) {
269
204
  const i18n = useI18n();
270
- const [kind, setKind] = useState<"subtask" | "blocker" | null>(null);
271
- const [title, setTitle] = useState("");
205
+ const [kind, setKind] = useState<Addable | null>(null);
206
+ const [draft, setDraft] = useState("");
207
+ const close = () => {
208
+ setKind(null);
209
+ setDraft("");
210
+ };
272
211
 
273
212
  const candidates = board.columns
274
213
  .flatMap((entry) => entry.cards)
275
214
  .filter((card) => card.id !== task.id && card.parentTaskId !== task.id);
276
215
 
277
- const close = () => {
278
- setKind(null);
279
- setTitle("");
216
+ const commit = () => {
217
+ const value = draft.trim();
218
+ if (value.length === 0 || board.isWriting) return close();
219
+ if (kind === "label") {
220
+ // ⚠️ Tags are REPLACED, not merged, so the whole list travels, and a duplicate would be
221
+ // written as one more entry rather than refused.
222
+ if (!task.labels.includes(value)) write({ labels: [...task.labels, value] });
223
+ }
224
+ if (kind === "start") write({ startDate: `${value}T00:00:00.000Z` });
225
+ if (kind === "due") write({ dueDate: `${value}T00:00:00.000Z` });
226
+ if (kind === "subtask") {
227
+ void board
228
+ .createTask({
229
+ title: value,
230
+ // A subtask starts in the same column as the task it belongs to: the first column would
231
+ // claim work was reset that never started.
232
+ status: task.status,
233
+ parentTaskId: task.id,
234
+ assigneeId: null,
235
+ labels: [],
236
+ startDate: null,
237
+ dueDate: null,
238
+ dependsOn: null,
239
+ idempotencyKey: crypto.randomUUID(),
240
+ })
241
+ .then(close);
242
+ return;
243
+ }
244
+ close();
280
245
  };
281
246
 
282
247
  return (
283
- <div className="flex items-center gap-2 pt-1">
248
+ <>
284
249
  <DropdownMenu>
285
- <DropdownMenuTrigger className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring">
250
+ <DropdownMenuTrigger
251
+ aria-label={i18n.t("board.add")}
252
+ className="rounded-full border px-2 py-0.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
253
+ >
286
254
  <Plus aria-hidden="true" className="size-3" />
287
- {i18n.t("board.addLink")}
288
255
  </DropdownMenuTrigger>
289
256
  <DropdownMenuContent align="start">
290
- <DropdownMenuItem onSelect={() => setKind("subtask")}>
291
- {i18n.t("board.link.subtask")}
292
- </DropdownMenuItem>
293
- <DropdownMenuItem onSelect={() => setKind("blocker")}>
294
- {i18n.t("board.link.blocker")}
295
- </DropdownMenuItem>
257
+ {addable.map((entry) => (
258
+ <DropdownMenuItem key={entry} onSelect={() => setKind(entry)}>
259
+ {i18n.t(`board.add.${entry}`)}
260
+ </DropdownMenuItem>
261
+ ))}
262
+ {linkable.map((entry) => (
263
+ <DropdownMenuItem key={entry} onSelect={() => setKind(entry)}>
264
+ {i18n.t(`board.link.${entry}`)}
265
+ </DropdownMenuItem>
266
+ ))}
296
267
  </DropdownMenuContent>
297
268
  </DropdownMenu>
298
269
 
299
- {/* ⚠️ **The two kinds are two different writes, and that is why the kind is asked FIRST.** A
300
- subtask is CREATED — where a task sits is the node tree, and moving an existing card under
301
- another needs `node_update` with the version it was last read at, which this answer does
302
- not carry. A blocker is a field on this card and only needs the card that is meant. */}
303
- {kind === "subtask" ? (
270
+ {kind === null || kind === "blocker" ? null : (
304
271
  <input
305
272
  // biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
306
273
  autoFocus
307
- value={title}
308
- aria-label={i18n.t("board.link.subtask")}
309
- onChange={(event) => setTitle(event.target.value)}
274
+ type={kind === "start" || kind === "due" ? "date" : "text"}
275
+ value={draft}
276
+ aria-label={i18n.t(kind === "subtask" ? "board.link.subtask" : `board.add.${kind}`)}
277
+ onChange={(event) => setDraft(event.target.value)}
310
278
  onKeyDown={(event) => {
311
279
  if (event.key === "Escape") close();
312
280
  if (event.key !== "Enter") return;
313
281
  event.preventDefault();
314
- const next = title.trim();
315
- if (next.length === 0 || board.isWriting) return;
316
- void board
317
- .createTask({
318
- title: next,
319
- // A subtask starts in the same column as the task it belongs to — the alternative
320
- // is the first column, which claims work was reset that never started.
321
- status: task.status,
322
- parentTaskId: task.id,
323
- assigneeId: null,
324
- labels: [],
325
- startDate: null,
326
- dueDate: null,
327
- dependsOn: null,
328
- idempotencyKey: crypto.randomUUID(),
329
- })
330
- .then(close);
282
+ commit();
331
283
  }}
332
- className="w-48 rounded-md border bg-background px-2 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
284
+ className="w-36 rounded-full border bg-background px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
333
285
  />
334
- ) : kind === "blocker" ? (
286
+ )}
287
+
288
+ {kind !== "blocker" ? null : (
335
289
  <DropdownMenu open onOpenChange={(next) => !next && close()}>
336
290
  <DropdownMenuTrigger
337
291
  aria-label={i18n.t("board.link.blocker")}
338
- className="rounded-md border px-2 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
292
+ className="rounded-full border px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
339
293
  >
340
294
  {i18n.t("board.link.blocker")}
341
295
  </DropdownMenuTrigger>
@@ -345,12 +299,7 @@ function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
345
299
  key={card.id}
346
300
  onSelect={() => {
347
301
  close();
348
- if (board.isWriting) return;
349
- void board.updateTask({
350
- taskId: task.id,
351
- dependsOn: card.id,
352
- idempotencyKey: crypto.randomUUID(),
353
- });
302
+ write({ dependsOn: card.id });
354
303
  }}
355
304
  >
356
305
  {card.title}
@@ -358,7 +307,7 @@ function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
358
307
  ))}
359
308
  </DropdownMenuContent>
360
309
  </DropdownMenu>
361
- ) : null}
362
- </div>
310
+ )}
311
+ </>
363
312
  );
364
313
  }
@@ -0,0 +1,68 @@
1
+ import type { Node } from "@anchrd/intel-contract/node";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { crumbsOf } from "@/board/board-crumbs/board-crumbs.ts";
4
+ import { BoardCrumbs } from "@/board/board-crumbs/board-crumbs.tsx";
5
+ import { useBoard } from "@/board/board-data/board-data.ts";
6
+ import { useOpenTask } from "@/board/board-open-task/board-open-task.ts";
7
+ import { useIntelRouterContext } from "@/router/router-context.ts";
8
+ import { TitleRow } from "@/title-row/title-row.tsx";
9
+
10
+ /**
11
+ * The one line above a board, and above a card opened on it.
12
+ *
13
+ * ⚠️ **The SAME `TitleRow`, with the path in place of the name.** A second header component for the
14
+ * open card would look identical today and drift tomorrow: the order of the right-hand group, the
15
+ * shadow on scroll and the slots are decided in exactly one place, and this keeps it that way (#53).
16
+ *
17
+ * ⚠️ **The menu then belongs to the CARD, not to the board.** Rename, move and archive act on what
18
+ * the reader is looking at. That is also where renaming a card happens, since the path carries its
19
+ * name and there is no heading to edit (#692).
20
+ */
21
+ export function BoardTitleRow({ node }: { node: Node }) {
22
+ const { openId, open } = useOpenTask();
23
+ const board = useBoard(node.id);
24
+ const { data } = useIntelRouterContext();
25
+
26
+ // ⚠️ The same key the open card's body already uses, so this costs no second request.
27
+ const task = useQuery({
28
+ queryKey: ["node", openId],
29
+ queryFn: () => data.getNode(openId ?? ""),
30
+ enabled: openId !== null,
31
+ });
32
+
33
+ const tasks = board.columns.flatMap((entry) => entry.cards);
34
+ const openTask = tasks.find((card) => card.id === openId);
35
+
36
+ // ⚠️ Until the card is in the ANSWER, the board's own row stands. Drawing a path around a name
37
+ // nobody knows yet would flicker a wrong one into place for a moment.
38
+ //
39
+ // ⚠️ The card's NODE is a different wait, and it must not gate the path: it is only needed for the
40
+ // menu, and holding the whole row for it would show the board's name first and swap it a moment
41
+ // later — two different headers in sequence for one navigation.
42
+ if (openId === null || openTask === undefined) {
43
+ return (
44
+ <TitleRow title={node.title} description={node.description} target={{ type: "node", node }} />
45
+ );
46
+ }
47
+
48
+ const crumbs = <BoardCrumbs crumbs={crumbsOf(openId, board.title, tasks)} onOpen={open} />;
49
+
50
+ /**
51
+ * ⚠️ **No menu at all while the card's node is missing**, rather than the board's. Rename, move
52
+ * and archive would otherwise act on the BOARD, and `archive` fires without asking: a control
53
+ * that is not there yet costs a moment, one that acts on the wrong thing costs the thing.
54
+ *
55
+ * ⚠️ **Missing covers two states, and deliberately treats them the same**: the read is still on
56
+ * its way, or it was refused. Neither gives this row a card to act on, and telling them apart
57
+ * here would only decide WHICH wrong menu to draw.
58
+ */
59
+ if (task.data === undefined) return <TitleRow title={openTask.title} crumbs={crumbs} />;
60
+
61
+ return (
62
+ <TitleRow
63
+ title={openTask.title}
64
+ crumbs={crumbs}
65
+ target={{ type: "node", node: task.data.node }}
66
+ />
67
+ );
68
+ }