@anchrd/intel-ui 0.40.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.
@@ -1,20 +1,19 @@
1
1
  import type { BoardTaskFilter } from "@anchrd/intel-contract/board";
2
2
  import type { Node } from "@anchrd/intel-contract/node";
3
3
  import { useQuery, useQueryClient } from "@tanstack/react-query";
4
- import { useNavigate, useSearch } from "@tanstack/react-router";
5
4
  import type { ColumnVisibilityState } from "@tanstack/react-table";
6
5
  import { KanbanSquare, Settings, Table2 } from "lucide-react";
7
- import { Suspense, useCallback, useState } from "react";
6
+ import { Suspense, useState } from "react";
8
7
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
9
8
  import { useBoard } from "@/board/board-data/board-data.ts";
10
9
  import { BoardKanban } from "@/board/board-kanban/board-kanban.tsx";
10
+ import { useOpenTask } from "@/board/board-open-task/board-open-task.ts";
11
11
  import { BoardSettings } from "@/board/board-settings/board-settings.tsx";
12
12
  import { BoardTable } from "@/board/board-table/board-table.tsx";
13
13
  import { BoardTaskDocument } from "@/board/board-task/board-task.tsx";
14
14
  import { useI18n } from "@/i18n/i18n-context.tsx";
15
15
  import { NodeEditor } from "@/node-editor/node-editor.tsx";
16
16
  import { useIntelRouterContext } from "@/router/router-context.ts";
17
- import { openTaskFrom } from "@/router/selection-search.ts";
18
17
 
19
18
  type View = "kanban" | "table";
20
19
 
@@ -47,31 +46,7 @@ export function BoardPanel({ node }: { node: Node }) {
47
46
  */
48
47
  const [filter, setFilter] = useState<Partial<BoardTaskFilter>>({});
49
48
  const board = useBoard(node.id);
50
- const search = useSearch({ strict: false });
51
- const navigate = useNavigate();
52
- const openId = openTaskFrom(search);
53
-
54
- /**
55
- * ⚠️ **The address, not a piece of state.** A task opened into component state would have no link
56
- * and no back button — the browser's back would leave the board entirely, which is the one thing
57
- * somebody pressing it after opening a card does not want.
58
- */
59
- const open = useCallback(
60
- (taskId: string | null) =>
61
- void navigate({
62
- to: ".",
63
- // ⚠️ **Closing REPLACES, opening pushes.** Opening a card is a step somebody took and the
64
- // back button must undo it; closing is the undo itself. Pushed as well, the history holds
65
- // board → card → board, and one press of Back re-opens the card that was just closed —
66
- // exactly the behaviour the address was chosen to avoid.
67
- replace: taskId === null,
68
- search: (previous: Record<string, unknown>) =>
69
- taskId === null ? { ...previous, task: undefined } : { ...previous, task: taskId },
70
- }),
71
- // ⚠️ Stable, or the table's `useMemo` over its columns rebuilds on every render and the memo
72
- // is decoration.
73
- [navigate],
74
- );
49
+ const { openId, open } = useOpenTask();
75
50
 
76
51
  return (
77
52
  <div className="flex min-h-0 flex-1 flex-col overflow-hidden">
@@ -91,14 +66,20 @@ export function BoardPanel({ node }: { node: Node }) {
91
66
  <Icon aria-hidden="true" className="size-4" />
92
67
  </button>
93
68
  ))}
94
- <button
95
- type="button"
96
- aria-label={i18n.t("board.settings.open")}
97
- onClick={() => setSettingsOpen(true)}
98
- className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
99
- >
100
- <Settings aria-hidden="true" className="size-4" />
101
- </button>
69
+ {/* ⚠️ Gone while a card is open, like the view switch above it. It configures the BOARD,
70
+ and the line it sits in belongs to the card then — a control that acts on something the
71
+ reader is not looking at is worse than a missing one, because it looks like it acts on
72
+ what they see. */}
73
+ {openId === null ? (
74
+ <button
75
+ type="button"
76
+ aria-label={i18n.t("board.settings.open")}
77
+ onClick={() => setSettingsOpen(true)}
78
+ className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
79
+ >
80
+ <Settings aria-hidden="true" className="size-4" />
81
+ </button>
82
+ ) : null}
102
83
  </ActionSlot>
103
84
  {/* ⚠️ Mounted only while open, so its draft is seeded once per opening. Handed an `open` flag
104
85
  instead, a draft re-seeded from the board would lose what somebody typed the moment a
@@ -183,7 +164,6 @@ function OpenTask({
183
164
  task={task}
184
165
  board={board}
185
166
  onOpen={onOpen}
186
- onClose={() => onOpen(null)}
187
167
  body={<TaskBody nodeId={task.id} />}
188
168
  />
189
169
  );
@@ -1,6 +1,6 @@
1
1
  import type { BoardTask } from "@anchrd/intel-contract/board";
2
2
  import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
3
- import { ChevronLeft, Lock, Plus, Square, SquareCheck } from "lucide-react";
3
+ import { Lock, Plus, Square, SquareCheck } from "lucide-react";
4
4
  import { useState } from "react";
5
5
  import { useAssigneeLabel } from "@/board/board-assignee/board-assignee.ts";
6
6
  import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
@@ -23,25 +23,46 @@ const linkIcons: Record<LinkKind, typeof Lock> = {
23
23
  };
24
24
 
25
25
  /**
26
- * Who the task is for, as the circle the access summary uses.
26
+ * The circle for whoever a card is for, and the way to take the assignment off.
27
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.
28
+ * ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed from the access
29
+ * summary, the field stays `assigneeId`. A row of circles here would imply a second data model that
30
+ * does not exist.
31
+ *
32
+ * ⚠️ **Only ever drawn for somebody.** "Nobody yet" as a chip is a placeholder for an absence, and
33
+ * an absence needs no place on screen (#692).
34
+ *
35
+ * ⚠️ **The NAME lives on the button, and the circle is hidden.** Children of a `<button>` are
36
+ * presentational in ARIA, so a label inside one is never announced and the button's own name wins.
37
+ * Wrapping the circle in a control therefore takes the person out of the accessibility tree unless
38
+ * the control says it too. That is the trap in `packages/ui/CLAUDE.md`, walked into by the very
39
+ * change that documented it.
30
40
  */
31
- function Assignee({ id }: { id: string | null }) {
41
+ function AssigneeChip({ id, clear }: { id: string; clear(): void }) {
32
42
  const i18n = useI18n();
33
43
  const label = useAssigneeLabel(id);
34
- if (id === null) {
35
- return <span className="text-xs text-muted-foreground">{label}</span>;
36
- }
37
44
  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}
45
+ <button
46
+ type="button"
47
+ aria-label={i18n.t("board.clearAssignee", { name: label })}
44
48
  title={label}
49
+ onClick={clear}
50
+ className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
51
+ >
52
+ <AssigneeCircle label={label} />
53
+ </button>
54
+ );
55
+ }
56
+
57
+ function AssigneeCircle({ label }: { label: string }) {
58
+ return (
59
+ <span
60
+ // ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
61
+ // person's name, and that reached nobody: it sits inside a `<button>`, whose children are
62
+ // presentational in ARIA. Giving it a role and a label back would not add a second announcement
63
+ // — it would add none, while making the code look as though the name were covered here rather
64
+ // than on the button. See `AssigneeChip` above and `packages/ui/CLAUDE.md`.
65
+ aria-hidden="true"
45
66
  className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
46
67
  >
47
68
  {initials(label)}
@@ -83,19 +104,15 @@ export function BoardTaskDocument({
83
104
  task,
84
105
  board,
85
106
  onOpen,
86
- onClose,
87
107
  body,
88
108
  }: {
89
109
  task: BoardTask;
90
110
  board: BoardHandle;
91
111
  onOpen(taskId: string): void;
92
- onClose(): void;
93
112
  /** The markdown surface, handed in so this component stays free of the editor's own loading. */
94
113
  body: React.ReactNode;
95
114
  }) {
96
115
  const i18n = useI18n();
97
- const [label, setLabel] = useState("");
98
- const [adding, setAdding] = useState(false);
99
116
 
100
117
  const links = linksOf(task, board.columns);
101
118
  const progress = progressOf(links);
@@ -117,33 +134,14 @@ export function BoardTaskDocument({
117
134
  });
118
135
  };
119
136
 
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
137
  return (
132
138
  <div
133
139
  className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-6"
134
140
  aria-busy={board.isWriting}
135
141
  >
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
-
142
+ {/* ⚠️ No heading and no way back HERE. Both live in the title row above since #692: the path
143
+ there ends in this card's name, so a heading would print it a second time, and an arrow
144
+ here would be a second control meaning what the one up there already means. */}
147
145
  {/* ⚠️ ONE line of chips, and no field labels. "Status:" in front of a chip that says "Backlog"
148
146
  says nothing the chip does not — and it turns a quiet head into a form. */}
149
147
  <div className="flex flex-wrap items-center gap-2">
@@ -173,10 +171,19 @@ export function BoardTaskDocument({
173
171
  </DropdownMenuContent>
174
172
  </DropdownMenu>
175
173
 
176
- <Assignee id={task.assigneeId} />
177
-
178
- {dates === null ? null : (
179
- <span className="rounded-full border px-3 py-1 text-xs tabular-nums">{dates}</span>
174
+ {/* ⚠️ Two chips when both dates are set, not a range. A range reads as one fact; these
175
+ are two, and each is removed on its own. */}
176
+ {task.startDate === null ? null : (
177
+ <DateChip
178
+ label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
179
+ remove={() => write({ startDate: null })}
180
+ />
181
+ )}
182
+ {task.dueDate === null ? null : (
183
+ <DateChip
184
+ label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
185
+ remove={() => write({ dueDate: null })}
186
+ />
180
187
  )}
181
188
 
182
189
  {task.labels.map((entry) => (
@@ -185,69 +192,41 @@ export function BoardTaskDocument({
185
192
  type="button"
186
193
  aria-label={i18n.t("board.removeLabel", { label: entry })}
187
194
  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"
195
+ className="rounded-full border px-3 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
189
196
  >
190
197
  {entry}
191
198
  </button>
192
199
  ))}
193
200
 
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>
201
+ {/* ⚠️ **Last, always.** It is the only round element in the row; standing between the chips
202
+ it breaks the line, and a row without one simply ends earlier. */}
203
+ {task.assigneeId === null ? null : (
204
+ <AssigneeChip id={task.assigneeId} clear={() => write({ assigneeId: null })} />
228
205
  )}
206
+
207
+ <Adder task={task} board={board} write={write} />
229
208
  </div>
230
209
 
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 : (
210
+ {/* ⚠️ ONE list, not three blocks: the icon carries the kind. And it exists only when there is
211
+ something in it. An empty section with a heading and a hint tells the reader that something
212
+ is missing; nothing at all tells them there is nothing, which is the truth (#692). */}
213
+ {links.length === 0 ? null : (
214
+ <section aria-label={i18n.t("board.links")} className="flex flex-col gap-1">
215
+ <h2 className="flex items-center gap-2 text-sm font-medium">
216
+ {i18n.t("board.links")}
217
+ {progress === null ? null : (
218
+ <span className="text-xs text-muted-foreground tabular-nums">
219
+ {i18n.t("board.progress", { done: progress.done, total: progress.total })}
220
+ </span>
221
+ )}
222
+ </h2>
243
223
  <ul className="flex flex-col">
244
224
  {links.map((link) => (
245
225
  <LinkRow key={`${link.kind}:${link.id}`} link={link} onOpen={onOpen} />
246
226
  ))}
247
227
  </ul>
248
- )}
249
- <LinkAdder task={task} board={board} />
250
- </section>
228
+ </section>
229
+ )}
251
230
 
252
231
  {/* The markdown text is the largest element of the page — everything above is the quiet part.
253
232
  ⚠️ The slot is not decoration: a test that asks whether the BODY drew something has to be
@@ -265,77 +244,135 @@ export function BoardTaskDocument({
265
244
  * and "waits for". That is `#371`: the reader decides WHAT they are linking once they know which
266
245
  * card they mean, not before.
267
246
  */
268
- function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
247
+
248
+ /** A date, and the way to take it off again. */
249
+ function DateChip({ label, remove }: { label: string; remove(): void }) {
250
+ return (
251
+ <button
252
+ type="button"
253
+ onClick={remove}
254
+ className="rounded-full border px-3 py-1 text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
255
+ >
256
+ {label}
257
+ </button>
258
+ );
259
+ }
260
+
261
+ /**
262
+ * What the card can still be given. The order matches the chips it produces.
263
+ *
264
+ * ⚠️ **No `assignee` here, and that is a gap rather than a decision.** Setting one needs a person to
265
+ * pick from, and this application can only ever DISPLAY principals it already sees in a grant: there
266
+ * is no list to choose from anywhere in the data provider. An entry that opens nothing is worse than
267
+ * a missing one, because it looks like the feature is there (`#700`).
268
+ */
269
+ const addable = ["label", "start", "due"] as const;
270
+ const linkable = ["subtask", "blocker"] as const;
271
+ type Addable = (typeof addable)[number] | (typeof linkable)[number];
272
+
273
+ /**
274
+ * ⚠️ **ONE plus for everything the card can carry**, and the kind is chosen afterwards. Two
275
+ * controls, one for fields and one for links, asked the reader to know which of the two a thing was
276
+ * before they could add it, and that is a question about our data model, not about their work.
277
+ */
278
+ function Adder({
279
+ task,
280
+ board,
281
+ write,
282
+ }: {
283
+ task: BoardTask;
284
+ board: BoardHandle;
285
+ write(input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>): void;
286
+ }) {
269
287
  const i18n = useI18n();
270
- const [kind, setKind] = useState<"subtask" | "blocker" | null>(null);
271
- const [title, setTitle] = useState("");
288
+ const [kind, setKind] = useState<Addable | null>(null);
289
+ const [draft, setDraft] = useState("");
290
+ const close = () => {
291
+ setKind(null);
292
+ setDraft("");
293
+ };
272
294
 
273
295
  const candidates = board.columns
274
296
  .flatMap((entry) => entry.cards)
275
297
  .filter((card) => card.id !== task.id && card.parentTaskId !== task.id);
276
298
 
277
- const close = () => {
278
- setKind(null);
279
- setTitle("");
299
+ const commit = () => {
300
+ const value = draft.trim();
301
+ if (value.length === 0 || board.isWriting) return close();
302
+ if (kind === "label") {
303
+ // ⚠️ Tags are REPLACED, not merged, so the whole list travels, and a duplicate would be
304
+ // written as one more entry rather than refused.
305
+ if (!task.labels.includes(value)) write({ labels: [...task.labels, value] });
306
+ }
307
+ if (kind === "start") write({ startDate: `${value}T00:00:00.000Z` });
308
+ if (kind === "due") write({ dueDate: `${value}T00:00:00.000Z` });
309
+ if (kind === "subtask") {
310
+ void board
311
+ .createTask({
312
+ title: value,
313
+ // A subtask starts in the same column as the task it belongs to: the first column would
314
+ // claim work was reset that never started.
315
+ status: task.status,
316
+ parentTaskId: task.id,
317
+ assigneeId: null,
318
+ labels: [],
319
+ startDate: null,
320
+ dueDate: null,
321
+ dependsOn: null,
322
+ idempotencyKey: crypto.randomUUID(),
323
+ })
324
+ .then(close);
325
+ return;
326
+ }
327
+ close();
280
328
  };
281
329
 
282
330
  return (
283
- <div className="flex items-center gap-2 pt-1">
331
+ <>
284
332
  <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">
333
+ <DropdownMenuTrigger
334
+ aria-label={i18n.t("board.add")}
335
+ className="rounded-full border px-2 py-1 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
336
+ >
286
337
  <Plus aria-hidden="true" className="size-3" />
287
- {i18n.t("board.addLink")}
288
338
  </DropdownMenuTrigger>
289
339
  <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>
340
+ {addable.map((entry) => (
341
+ <DropdownMenuItem key={entry} onSelect={() => setKind(entry)}>
342
+ {i18n.t(`board.add.${entry}`)}
343
+ </DropdownMenuItem>
344
+ ))}
345
+ {linkable.map((entry) => (
346
+ <DropdownMenuItem key={entry} onSelect={() => setKind(entry)}>
347
+ {i18n.t(`board.link.${entry}`)}
348
+ </DropdownMenuItem>
349
+ ))}
296
350
  </DropdownMenuContent>
297
351
  </DropdownMenu>
298
352
 
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" ? (
353
+ {kind === null || kind === "blocker" ? null : (
304
354
  <input
305
355
  // biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
306
356
  autoFocus
307
- value={title}
308
- aria-label={i18n.t("board.link.subtask")}
309
- onChange={(event) => setTitle(event.target.value)}
357
+ type={kind === "start" || kind === "due" ? "date" : "text"}
358
+ value={draft}
359
+ aria-label={i18n.t(kind === "subtask" ? "board.link.subtask" : `board.add.${kind}`)}
360
+ onChange={(event) => setDraft(event.target.value)}
310
361
  onKeyDown={(event) => {
311
362
  if (event.key === "Escape") close();
312
363
  if (event.key !== "Enter") return;
313
364
  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);
365
+ commit();
331
366
  }}
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"
367
+ className="w-36 rounded-full border bg-background px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
333
368
  />
334
- ) : kind === "blocker" ? (
369
+ )}
370
+
371
+ {kind !== "blocker" ? null : (
335
372
  <DropdownMenu open onOpenChange={(next) => !next && close()}>
336
373
  <DropdownMenuTrigger
337
374
  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"
375
+ className="rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
339
376
  >
340
377
  {i18n.t("board.link.blocker")}
341
378
  </DropdownMenuTrigger>
@@ -345,12 +382,7 @@ function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
345
382
  key={card.id}
346
383
  onSelect={() => {
347
384
  close();
348
- if (board.isWriting) return;
349
- void board.updateTask({
350
- taskId: task.id,
351
- dependsOn: card.id,
352
- idempotencyKey: crypto.randomUUID(),
353
- });
385
+ write({ dependsOn: card.id });
354
386
  }}
355
387
  >
356
388
  {card.title}
@@ -358,7 +390,7 @@ function LinkAdder({ task, board }: { task: BoardTask; board: BoardHandle }) {
358
390
  ))}
359
391
  </DropdownMenuContent>
360
392
  </DropdownMenu>
361
- ) : null}
362
- </div>
393
+ )}
394
+ </>
363
395
  );
364
396
  }
@@ -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
+ }
@@ -0,0 +1,28 @@
1
+ import FileViewer, { type ViewerOptions } from "@file-viewer/react";
2
+ import { useMemo } from "react";
3
+ import { useResolvedTheme } from "@/theme/theme-context.tsx";
4
+
5
+ export function FilePreviewView({ file, renderer }: { file: File; renderer: unknown }) {
6
+ const theme = useResolvedTheme();
7
+ const options = useMemo<ViewerOptions>(
8
+ () => ({
9
+ renderers: [renderer] as unknown as NonNullable<ViewerOptions["renderers"]>,
10
+ rendererMode: "replace",
11
+ builtinRenderers: "none",
12
+ autoRenderers: false,
13
+ styleIsolation: "shadow",
14
+ theme,
15
+ locale: "auto",
16
+ toolbar: {
17
+ download: false,
18
+ print: false,
19
+ exportHtml: false,
20
+ theme: false,
21
+ permissions: { download: false, print: false, exportHtml: false },
22
+ },
23
+ ui: { density: "compact", surfaceBackground: "transparent" },
24
+ }),
25
+ [renderer, theme],
26
+ );
27
+ return <FileViewer file={file} className="h-full min-h-64" options={options} />;
28
+ }
@@ -0,0 +1,28 @@
1
+ import { lazy, Suspense } from "react";
2
+ import type { AttachmentPreviewKind } from "@/attachment-viewer/attachment-viewer.tsx";
3
+
4
+ const previews = {
5
+ pdf: lazy(async () => ({ default: (await import("./pdf-file-preview.tsx")).PdfFilePreview })),
6
+ presentation: lazy(async () => ({
7
+ default: (await import("./presentation-file-preview.tsx")).PresentationFilePreview,
8
+ })),
9
+ spreadsheet: lazy(async () => ({
10
+ default: (await import("./spreadsheet-file-preview.tsx")).SpreadsheetFilePreview,
11
+ })),
12
+ word: lazy(async () => ({ default: (await import("./word-file-preview.tsx")).WordFilePreview })),
13
+ };
14
+
15
+ export function FilePreview({
16
+ file,
17
+ kind,
18
+ }: {
19
+ file: File;
20
+ kind: Exclude<AttachmentPreviewKind, "image" | "unsupported">;
21
+ }) {
22
+ const Preview = previews[kind];
23
+ return (
24
+ <Suspense fallback={null}>
25
+ <Preview file={file} />
26
+ </Suspense>
27
+ );
28
+ }