@anchrd/intel-ui 0.39.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/package.json +13 -2
  2. package/src/access-summary/access-summary.tsx +1 -11
  3. package/src/app/app-tree/app-tree.tsx +58 -0
  4. package/src/attachment-viewer/attachment-viewer.tsx +304 -0
  5. package/src/board/board-assignee/board-assignee.ts +18 -0
  6. package/src/board/board-crumbs/board-crumbs.ts +56 -0
  7. package/src/board/board-crumbs/board-crumbs.tsx +108 -0
  8. package/src/board/board-data/board-data.ts +31 -5
  9. package/src/board/board-data/board-data.types.ts +10 -1
  10. package/src/board/board-kanban/board-kanban.ts +63 -9
  11. package/src/board/board-kanban/board-kanban.tsx +409 -66
  12. package/src/board/board-open-task/board-open-task.ts +32 -0
  13. package/src/board/board-panel/board-panel.tsx +202 -31
  14. package/src/board/board-settings/board-settings.tsx +209 -0
  15. package/src/board/board-stripes/board-stripes.ts +128 -0
  16. package/src/board/board-table/board-table.ts +23 -105
  17. package/src/board/board-table/board-table.tsx +436 -135
  18. package/src/board/board-task/board-task.ts +105 -0
  19. package/src/board/board-task/board-task.tsx +396 -0
  20. package/src/board/board-title-row/board-title-row.tsx +68 -0
  21. package/src/file-preview/file-preview-view.tsx +28 -0
  22. package/src/file-preview/file-preview.tsx +28 -0
  23. package/src/file-preview/pdf-file-preview.tsx +5 -0
  24. package/src/file-preview/presentation-file-preview.tsx +21 -0
  25. package/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  26. package/src/file-preview/word-file-preview.tsx +5 -0
  27. package/src/i18n/de.json +81 -7
  28. package/src/i18n/en.json +81 -7
  29. package/src/i18n/es.json +81 -7
  30. package/src/nodes/nodes.tsx +26 -52
  31. package/src/resource-menu/resource-menu.tsx +18 -12
  32. package/src/router/selection-search.ts +17 -2
  33. package/src/styles.css +29 -25
  34. package/src/title-row/title-row.tsx +33 -6
  35. package/src/user-name/user-name.ts +17 -0
  36. package/vite.config.ts +111 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,14 +33,24 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@anchrd/intel-contract": "^0.23.0",
36
+ "@anchrd/intel-contract": "^0.24.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
40
+ "@dnd-kit/core": "^6.3.1",
41
+ "@dnd-kit/sortable": "^10.0.0",
42
+ "@dnd-kit/utilities": "^3.2.2",
43
+ "@file-viewer/react": "2.3.0",
44
+ "@file-viewer/renderer-pdf": "2.3.0",
45
+ "@file-viewer/renderer-presentation": "2.3.0",
46
+ "@file-viewer/renderer-spreadsheet": "2.3.2",
47
+ "@file-viewer/renderer-word": "2.3.1",
48
+ "@file-viewer/vite-plugin": "2.3.1",
40
49
  "@react-sigma/core": "^5.0.6",
41
50
  "@tailwindcss/vite": "^4.3.3",
42
51
  "@tanstack/react-query": "^5.101.4",
43
52
  "@tanstack/react-router": "^1.170.18",
53
+ "@tanstack/react-table": "^9.1.2",
44
54
  "@vitejs/plugin-react": "^6.0.4",
45
55
  "@xyflow/react": "^12.11.2",
46
56
  "class-variance-authority": "^0.7.1",
@@ -54,6 +64,7 @@
54
64
  "react": "^19.2.0",
55
65
  "react-aria-components": "^1.19.0",
56
66
  "react-dom": "^19.2.0",
67
+ "readable-stream": "2.3.8",
57
68
  "sigma": "^3.0.3",
58
69
  "tailwind-merge": "^3.6.0",
59
70
  "tailwindcss": "^4.3.3",
@@ -11,7 +11,7 @@ import {
11
11
  } from "@/components/ui/tooltip.tsx";
12
12
  import { useI18n } from "@/i18n/i18n-context.tsx";
13
13
  import { useIntelRouterContext } from "@/router/router-context.ts";
14
- import { useUserName } from "@/user-name/user-name.ts";
14
+ import { initials, useUserName } from "@/user-name/user-name.ts";
15
15
 
16
16
  type Principal = ResourceGrant["principal"];
17
17
 
@@ -47,16 +47,6 @@ function groupGrants(grants: ResourceGrant[], ownerIds: string[] = []): GrantGro
47
47
  return [...grouped.values()];
48
48
  }
49
49
 
50
- function initials(value: string): string {
51
- const parts = value
52
- .replace(/@.*$/, "")
53
- .split(/[\s._+-]+/)
54
- .filter(Boolean);
55
- if (parts.length === 0) return "?";
56
- if (parts.length === 1) return (parts[0]?.slice(0, 2) ?? "?").toUpperCase();
57
- return `${parts[0]?.[0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase();
58
- }
59
-
60
50
  function usePrincipalLabel(principal: Principal): string {
61
51
  const i18n = useI18n();
62
52
  const userName = useUserName(principal.type === "user" ? principal.id : null);
@@ -1,6 +1,7 @@
1
1
  import type { NodeKind } from "@anchrd/intel-contract/node";
2
2
  import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
3
3
  import { useNavigate, useRouterState } from "@tanstack/react-router";
4
+ import { zipSync } from "fflate";
4
5
  import {
5
6
  ChevronRight,
6
7
  CornerLeftUp,
@@ -80,6 +81,16 @@ async function fileBase64(file: File): Promise<string> {
80
81
  });
81
82
  }
82
83
 
84
+ function nativeUpload(file: File): boolean {
85
+ const name = file.name.toLowerCase();
86
+ return name.endsWith(".md") || name.endsWith(".csv");
87
+ }
88
+
89
+ async function singleFileBundle(file: File): Promise<Blob> {
90
+ const bytes = new Uint8Array(await file.arrayBuffer());
91
+ return new Blob([zipSync({ [file.name]: bytes }).slice().buffer], { type: "application/zip" });
92
+ }
93
+
83
94
  // A level is either a folder of the shared tree or the flows one flow calls. The second kind is
84
95
  // derived from that flow's graph, not from `parent_id` (ADR-0004 §3), which is why it is its own
85
96
  // level rather than another folder.
@@ -288,6 +299,43 @@ export function AppTree() {
288
299
  idempotencyKey: crypto.randomUUID(),
289
300
  });
290
301
  }
302
+ /**
303
+ * ⚠️ **A board's columns are written at creation, in the language of whoever made it** (#675).
304
+ * The server has a fallback for a board nobody configured, but it cannot know the reader's
305
+ * language — and columns are SHARED data: two people on one board must read the same names,
306
+ * and the settings dialog edits one list. Translating per reader would give them different
307
+ * boards. Written once, they are data anybody can rename.
308
+ *
309
+ * ⚠️ It is a second call and it may fail on its own. That is survivable and deliberately not
310
+ * repaired here: the board then answers with the server's fallback and is usable, which is
311
+ * the same state a board made over MCP is in. A failure that made the whole creation fail
312
+ * would trade a usable board for none.
313
+ */
314
+ if (kind === "board") {
315
+ try {
316
+ await data.updateBoard({
317
+ boardId: node.id,
318
+ columns: [
319
+ { id: "backlog", title: i18n.t("board.column.default.backlog"), terminal: false },
320
+ { id: "todo", title: i18n.t("board.column.default.todo"), terminal: false },
321
+ { id: "doing", title: i18n.t("board.column.default.doing"), terminal: false },
322
+ { id: "done", title: i18n.t("board.column.default.done"), terminal: true },
323
+ ],
324
+ idempotencyKey: crypto.randomUUID(),
325
+ });
326
+ } catch {
327
+ /**
328
+ * ⚠️ **Swallowed on purpose, and the `catch` is what makes that true.** Without it the
329
+ * failure travels out of the mutation: the node exists, but `onSuccess` never runs, the
330
+ * screen says "creating failed", and the obvious response — try again — makes a SECOND
331
+ * board, because nothing tells the reader the first one is already there.
332
+ *
333
+ * What is lost by swallowing is one thing and it is repairable: the board answers with
334
+ * the server's fallback columns instead of the ones in this reader's language. That is
335
+ * exactly the state a board made over MCP is in, and the settings dialog renames them.
336
+ */
337
+ }
338
+ }
291
339
  return {
292
340
  area: "/nodes" as const,
293
341
  id: node.id,
@@ -315,6 +363,16 @@ export function AppTree() {
315
363
 
316
364
  const upload = useMutation({
317
365
  mutationFn: async ({ parentId, file }: { parentId: string | null; file: File }) => {
366
+ if (nativeUpload(file)) {
367
+ const result = await data.importNodeBundle({
368
+ nodeId: parentId,
369
+ zip: await singleFileBundle(file),
370
+ idempotencyKey: crypto.randomUUID(),
371
+ });
372
+ const id = result.rootNodeIds[0];
373
+ if (!id) throw new Error("Native upload created no root node");
374
+ return { id, parentId };
375
+ }
318
376
  const contentBase64 = await fileBase64(file);
319
377
  const node = await data.createNodes({
320
378
  parentId,
@@ -0,0 +1,304 @@
1
+ import type { NodeDocument } from "@anchrd/intel-contract/node";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { unzipSync } from "fflate";
4
+ import { FileQuestion, Info, X } from "lucide-react";
5
+ import { Component, lazy, type ReactNode, Suspense, useEffect, useMemo, useState } from "react";
6
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
7
+ import { useI18n } from "@/i18n/i18n-context.tsx";
8
+ import { cn } from "@/lib/utils";
9
+ import { useIntelRouterContext } from "@/router/router-context.ts";
10
+ import { useDateTime } from "@/time/time-context.tsx";
11
+
12
+ const FilePreview = lazy(async () => ({
13
+ default: (await import("@/file-preview/file-preview.tsx")).FilePreview,
14
+ }));
15
+
16
+ export type AttachmentPreviewKind =
17
+ | "image"
18
+ | "pdf"
19
+ | "word"
20
+ | "spreadsheet"
21
+ | "presentation"
22
+ | "unsupported";
23
+
24
+ const extensions: Record<Exclude<AttachmentPreviewKind, "unsupported">, readonly string[]> = {
25
+ image: ["avif", "bmp", "gif", "jpeg", "jpg", "png", "svg", "webp"],
26
+ pdf: ["pdf"],
27
+ word: ["docx"],
28
+ spreadsheet: ["xlsx"],
29
+ presentation: ["pptx"],
30
+ };
31
+
32
+ export function attachmentPreviewKind(title: string, mediaType: string): AttachmentPreviewKind {
33
+ if (mediaType.startsWith("image/")) return "image";
34
+ if (mediaType === "application/pdf") return "pdf";
35
+ const extension = title.toLowerCase().split(".").at(-1) ?? "";
36
+ for (const [kind, supported] of Object.entries(extensions)) {
37
+ if (supported.includes(extension)) return kind as AttachmentPreviewKind;
38
+ }
39
+ return "unsupported";
40
+ }
41
+
42
+ export function formatFileSize(bytes: number): string {
43
+ if (bytes < 1_000) return `${bytes} B`;
44
+ if (bytes < 1_000_000) return `${(bytes / 1_000).toFixed(bytes < 10_000 ? 1 : 0)} KB`;
45
+ return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`;
46
+ }
47
+
48
+ type FormatDetail = {
49
+ label: "dimensions" | "pages" | "sections" | "slides" | "worksheets";
50
+ value: string;
51
+ };
52
+
53
+ export async function attachmentFormatDetail(
54
+ file: File,
55
+ kind: AttachmentPreviewKind,
56
+ ): Promise<FormatDetail | null> {
57
+ if (kind === "image") {
58
+ const bitmap = await createImageBitmap(file);
59
+ const value = `${bitmap.width} × ${bitmap.height} px`;
60
+ bitmap.close();
61
+ return { label: "dimensions", value };
62
+ }
63
+ const bytes = new Uint8Array(await file.arrayBuffer());
64
+ if (kind === "pdf") {
65
+ const source = new TextDecoder("latin1").decode(bytes);
66
+ const pages = source.match(/\/Type\s*\/Page\b/g)?.length ?? 0;
67
+ return pages > 0 ? { label: "pages", value: String(pages) } : null;
68
+ }
69
+ if (kind === "unsupported") return null;
70
+ const archive = unzipSync(bytes);
71
+ if (kind === "presentation") {
72
+ const slides = Object.keys(archive).filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name));
73
+ return { label: "slides", value: String(slides.length) };
74
+ }
75
+ const decoder = new TextDecoder();
76
+ if (kind === "spreadsheet") {
77
+ const workbook = archive["xl/workbook.xml"];
78
+ if (!workbook) return null;
79
+ const worksheets = decoder.decode(workbook).match(/<sheet\b/g)?.length ?? 0;
80
+ return { label: "worksheets", value: String(worksheets) };
81
+ }
82
+ const document = archive["word/document.xml"];
83
+ if (!document) return null;
84
+ const sections = Math.max(1, decoder.decode(document).match(/<w:sectPr\b/g)?.length ?? 0);
85
+ return { label: "sections", value: String(sections) };
86
+ }
87
+
88
+ export function AttachmentViewer({ document }: { document: NodeDocument }) {
89
+ const { data } = useIntelRouterContext();
90
+ const i18n = useI18n();
91
+ const [detailsOpen, setDetailsOpen] = useState(false);
92
+ const attachment = useQuery({
93
+ queryKey: ["attachment", document.node.id, document.version?.id],
94
+ queryFn: () => data.getNodeAttachment(document.node.id),
95
+ enabled: document.version !== null,
96
+ retry: false,
97
+ });
98
+ const file = useMemo(
99
+ () =>
100
+ attachment.data && document.version
101
+ ? new File([attachment.data], document.node.title, { type: document.version.mediaType })
102
+ : null,
103
+ [attachment.data, document.node.title, document.version],
104
+ );
105
+ const kind = document.version
106
+ ? attachmentPreviewKind(document.node.title, document.version.mediaType)
107
+ : "unsupported";
108
+
109
+ return (
110
+ <div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
111
+ <ActionSlot name="title-actions">
112
+ <button
113
+ type="button"
114
+ aria-label={i18n.t(detailsOpen ? "attachment.detailsHide" : "attachment.detailsShow")}
115
+ aria-expanded={detailsOpen}
116
+ onClick={() => setDetailsOpen((current) => !current)}
117
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-muted outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
118
+ >
119
+ <Info aria-hidden="true" className="size-4" />
120
+ </button>
121
+ </ActionSlot>
122
+ <div className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border bg-background">
123
+ <div className="relative min-h-0 min-w-0 flex-1 bg-muted/40">
124
+ {document.version === null ? (
125
+ <PreviewMessage message={i18n.t("attachment.empty")} />
126
+ ) : attachment.isPending ? (
127
+ <PreviewMessage message={i18n.t("common.loading")} />
128
+ ) : attachment.isError || !file ? (
129
+ <PreviewMessage error message={i18n.t("attachment.loadFailed")} />
130
+ ) : kind === "unsupported" ? (
131
+ <PreviewMessage message={i18n.t("attachment.unsupported")} />
132
+ ) : kind === "image" ? (
133
+ <ImagePreview file={file} title={document.node.title} />
134
+ ) : (
135
+ <ViewerErrorBoundary
136
+ key={`${document.node.id}:${document.version.id}`}
137
+ fallback={<PreviewMessage error message={i18n.t("attachment.loadFailed")} />}
138
+ >
139
+ <Suspense fallback={<PreviewMessage message={i18n.t("common.loading")} />}>
140
+ <FilePreview file={file} kind={kind} />
141
+ </Suspense>
142
+ </ViewerErrorBoundary>
143
+ )}
144
+ </div>
145
+ {detailsOpen ? (
146
+ <AttachmentDetails
147
+ document={document}
148
+ file={file}
149
+ footerInset={kind === "pdf"}
150
+ kind={kind}
151
+ close={() => setDetailsOpen(false)}
152
+ />
153
+ ) : null}
154
+ {detailsOpen && kind === "pdf" ? (
155
+ <div
156
+ aria-hidden="true"
157
+ className="pointer-events-none absolute inset-x-0 bottom-12 z-10 hidden border-t md:block"
158
+ />
159
+ ) : null}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
164
+
165
+ class ViewerErrorBoundary extends Component<
166
+ { children: ReactNode; fallback: ReactNode },
167
+ { failed: boolean }
168
+ > {
169
+ override state = { failed: false };
170
+
171
+ static getDerivedStateFromError() {
172
+ return { failed: true };
173
+ }
174
+
175
+ override render() {
176
+ return this.state.failed ? this.props.fallback : this.props.children;
177
+ }
178
+ }
179
+
180
+ function PreviewMessage({ message, error = false }: { message: string; error?: boolean }) {
181
+ return (
182
+ <div
183
+ role={error ? "alert" : undefined}
184
+ className={cn(
185
+ "grid h-full min-h-64 place-items-center p-8 text-center text-sm text-muted-foreground",
186
+ error && "text-destructive",
187
+ )}
188
+ >
189
+ <div className="max-w-sm space-y-3">
190
+ <FileQuestion aria-hidden="true" className="mx-auto size-9" />
191
+ <p>{message}</p>
192
+ </div>
193
+ </div>
194
+ );
195
+ }
196
+
197
+ function ImagePreview({ file, title }: { file: File; title: string }) {
198
+ const [url, setUrl] = useState("");
199
+ useEffect(() => {
200
+ const next = URL.createObjectURL(file);
201
+ setUrl(next);
202
+ return () => URL.revokeObjectURL(next);
203
+ }, [file]);
204
+ return url ? (
205
+ <div className="grid h-full min-h-64 place-items-center overflow-auto p-4">
206
+ <img src={url} alt={title} className="max-h-full max-w-full object-contain" />
207
+ </div>
208
+ ) : null;
209
+ }
210
+
211
+ function AttachmentDetails({
212
+ document,
213
+ file,
214
+ footerInset,
215
+ kind,
216
+ close,
217
+ }: {
218
+ document: NodeDocument;
219
+ file: File | null;
220
+ footerInset: boolean;
221
+ kind: AttachmentPreviewKind;
222
+ close(): void;
223
+ }) {
224
+ const i18n = useI18n();
225
+ const dateTime = useDateTime();
226
+ const version = document.version;
227
+ const formatDetail = useQuery({
228
+ queryKey: ["attachment-format-detail", document.node.id, version?.id],
229
+ queryFn: async () => (file ? await attachmentFormatDetail(file, kind) : null),
230
+ enabled: file !== null && kind !== "unsupported",
231
+ retry: false,
232
+ });
233
+ const rows = version
234
+ ? [
235
+ [i18n.t("attachment.details.name"), document.node.title],
236
+ [
237
+ i18n.t("attachment.details.type"),
238
+ i18n.t(
239
+ `attachment.kind.${attachmentPreviewKind(document.node.title, version.mediaType)}`,
240
+ ),
241
+ ],
242
+ [i18n.t("attachment.details.mimeType"), version.mediaType],
243
+ [
244
+ i18n.t("attachment.details.format"),
245
+ document.node.title.split(".").at(-1)?.toUpperCase() ?? "—",
246
+ ],
247
+ [i18n.t("attachment.details.size"), formatFileSize(version.size)],
248
+ [i18n.t("attachment.details.uploaded"), dateTime.at(version.createdAt)],
249
+ [i18n.t("attachment.details.uploadedBy"), version.createdBy],
250
+ [i18n.t("attachment.details.version"), String(version.sequence)],
251
+ ]
252
+ : [[i18n.t("attachment.details.name"), document.node.title]];
253
+ if (formatDetail.data) {
254
+ rows.push([i18n.t(`attachment.details.${formatDetail.data.label}`), formatDetail.data.value]);
255
+ }
256
+ return (
257
+ <aside
258
+ aria-label={i18n.t("attachment.details.title")}
259
+ className={cn(
260
+ "absolute inset-x-0 bottom-0 z-10 max-h-[80%] overflow-auto border-t bg-background shadow-lg md:static md:z-auto md:w-80 md:max-h-none md:shrink-0 md:border-l md:border-t-0 md:shadow-none",
261
+ // The PDF renderer alone owns a bottom toolbar; keep the desktop panel beside its content.
262
+ footerInset && "md:mb-12",
263
+ )}
264
+ >
265
+ <div className="flex items-center justify-between border-b px-5 py-4">
266
+ <h3 className="font-semibold">{i18n.t("attachment.details.title")}</h3>
267
+ <button
268
+ type="button"
269
+ aria-label={i18n.t("common.close")}
270
+ onClick={close}
271
+ className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
272
+ >
273
+ <X aria-hidden="true" className="size-4" />
274
+ </button>
275
+ </div>
276
+ <dl className="divide-y px-5">
277
+ {rows.map(([label, value]) => (
278
+ <div
279
+ key={label}
280
+ className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm"
281
+ >
282
+ <dt className="text-muted-foreground">{label}</dt>
283
+ <dd className="break-words">{value}</dd>
284
+ </div>
285
+ ))}
286
+ </dl>
287
+ {version ? (
288
+ <div className="border-t px-5 py-4">
289
+ <h4 className="mb-2 font-medium">{i18n.t("attachment.details.technical")}</h4>
290
+ <dl className="divide-y">
291
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm">
292
+ <dt className="text-muted-foreground">{i18n.t("attachment.details.storage")}</dt>
293
+ <dd>{i18n.t("attachment.details.private")}</dd>
294
+ </div>
295
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm">
296
+ <dt className="text-muted-foreground">{i18n.t("attachment.details.checksum")}</dt>
297
+ <dd className="font-mono text-xs">{`${version.contentHash.slice(0, 8)}…${version.contentHash.slice(-4)}`}</dd>
298
+ </div>
299
+ </dl>
300
+ </div>
301
+ ) : null}
302
+ </aside>
303
+ );
304
+ }
@@ -0,0 +1,18 @@
1
+ import { useI18n } from "@/i18n/i18n-context.tsx";
2
+ import { useUserName } from "@/user-name/user-name.ts";
3
+
4
+ /**
5
+ * What to call whoever a card is for.
6
+ *
7
+ * ⚠️ **One rule, two surfaces.** The table draws the name in a cell, the open card draws it in a
8
+ * circle, and both must answer the same way — `useUserName` resolves only the reader themselves, so
9
+ * for anybody else the honest answer is "a user account" and never the principal id the row carries
10
+ * (`user-name.ts`, #258). Written twice, the two would drift, and the drift would put an id on one
11
+ * of the two screens.
12
+ */
13
+ export function useAssigneeLabel(id: string | null): string {
14
+ const i18n = useI18n();
15
+ const name = useUserName(id);
16
+ if (id === null) return i18n.t("board.unassigned");
17
+ return name ?? i18n.t("node.someUser");
18
+ }
@@ -0,0 +1,56 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract/board";
2
+
3
+ /** One step of the path above an open card. The board is a step like any other. */
4
+ export interface Crumb {
5
+ /** The node id to open, or `null` for the board itself. */
6
+ taskId: string | null;
7
+ title: string;
8
+ }
9
+
10
+ /**
11
+ * The path from the board down to the open card, board first.
12
+ *
13
+ * ⚠️ **An unknown parent ENDS the walk rather than losing the card.** Under a filter a subtask can
14
+ * be in the answer while its parent is not (`board.ts` says so on `parentTaskId`). The path is then
15
+ * shorter than the tree, which is honest: it shows what this answer knows.
16
+ */
17
+ export function crumbsOf(taskId: string, boardTitle: string, tasks: BoardTask[]): Crumb[] {
18
+ const byId = new Map(tasks.map((task) => [task.id, task]));
19
+ const trail: Crumb[] = [];
20
+ const seen = new Set<string>();
21
+
22
+ /**
23
+ * ⚠️ **`seen` is the whole guard, and a second counter beside it would be decoration.** A loop in
24
+ * the parent chain ends the walk here because every id is visited once; a step limit could only
25
+ * fire where this already has, and a guard that cannot fire is one nobody can test.
26
+ */
27
+ let current = byId.get(taskId);
28
+ while (current !== undefined) {
29
+ if (seen.has(current.id)) break;
30
+ seen.add(current.id);
31
+ trail.unshift({ taskId: current.id, title: current.title });
32
+ const parentId = current.parentTaskId;
33
+ current = parentId === null ? undefined : byId.get(parentId);
34
+ }
35
+
36
+ return [{ taskId: null, title: boardTitle }, ...trail];
37
+ }
38
+
39
+ /**
40
+ * The path as it is drawn: board, then an ellipsis standing for what was left out, then the last
41
+ * `keep` steps.
42
+ *
43
+ * ⚠️ **The board and the open card are never hidden.** They are the two ends somebody navigates by:
44
+ * where they came from and where they are. Everything between them is what the ellipsis holds.
45
+ *
46
+ * ⚠️ **`hidden` is the LIST, not a count.** The three dots open it, so the view needs the entries
47
+ * themselves; handing over a number would force it to recompute what was dropped.
48
+ */
49
+ export function foldCrumbs(crumbs: Crumb[], keep = 1): { shown: Crumb[]; hidden: Crumb[] } {
50
+ // Board + the kept tail + at least one in between is what makes folding worth anything.
51
+ if (crumbs.length <= keep + 2) return { shown: crumbs, hidden: [] };
52
+ return {
53
+ shown: [...crumbs.slice(0, 1), ...crumbs.slice(crumbs.length - keep)],
54
+ hidden: crumbs.slice(1, crumbs.length - keep),
55
+ };
56
+ }
@@ -0,0 +1,108 @@
1
+ import { ChevronLeft } from "lucide-react";
2
+ import {
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuItem,
6
+ DropdownMenuTrigger,
7
+ } from "@/components/ui/dropdown-menu.tsx";
8
+ import { useI18n } from "@/i18n/i18n-context.tsx";
9
+ import { type Crumb, foldCrumbs } from "./board-crumbs.ts";
10
+
11
+ /**
12
+ * The path above an open card: back to the board, then every level down to this one.
13
+ *
14
+ * ⚠️ **The arrow belongs to the BOARD, the path to the cards.** Jack's decision 2026-08-21: one
15
+ * control that always means the same thing, and a path whose steps are each their own way back. An
16
+ * arrow that went one level up would mean something different on every card.
17
+ *
18
+ * ⚠️ **This replaces the heading rather than standing beside it.** The last step IS the card's name;
19
+ * drawn next to a heading it would be the same sentence twice.
20
+ */
21
+ export function BoardCrumbs({
22
+ crumbs,
23
+ onOpen,
24
+ }: {
25
+ crumbs: Crumb[];
26
+ onOpen(taskId: string | null): void;
27
+ }) {
28
+ const i18n = useI18n();
29
+ const { shown, hidden } = foldCrumbs(crumbs);
30
+ const board = shown[0];
31
+ const rest = shown.slice(1);
32
+
33
+ return (
34
+ <nav aria-label={i18n.t("board.crumbs")} className="flex min-w-0 items-center gap-1.5 text-sm">
35
+ <button
36
+ type="button"
37
+ aria-label={i18n.t("board.backTo", { board: board?.title ?? "" })}
38
+ onClick={() => onOpen(null)}
39
+ className="-ml-1 rounded-md p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
40
+ >
41
+ <ChevronLeft aria-hidden="true" className="size-4" />
42
+ </button>
43
+
44
+ <Step crumb={board} onOpen={onOpen} />
45
+
46
+ {hidden.length === 0 ? null : (
47
+ <>
48
+ <Separator />
49
+ <DropdownMenu>
50
+ {/* ⚠️ A menu, not a tooltip. On a touch screen there is no hover, and the levels behind
51
+ the dots are the only way to reach them — a hint that needs a pointer would put them
52
+ out of reach on the device where the path folds soonest. */}
53
+ <DropdownMenuTrigger
54
+ aria-label={i18n.t("board.crumbsFolded")}
55
+ className="rounded-md border px-1.5 leading-none text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
56
+ >
57
+
58
+ </DropdownMenuTrigger>
59
+ <DropdownMenuContent align="start">
60
+ {hidden.map((crumb) => (
61
+ <DropdownMenuItem key={crumb.taskId} onSelect={() => onOpen(crumb.taskId)}>
62
+ {crumb.title}
63
+ </DropdownMenuItem>
64
+ ))}
65
+ </DropdownMenuContent>
66
+ </DropdownMenu>
67
+ </>
68
+ )}
69
+
70
+ {rest.map((crumb, step) => (
71
+ <span key={crumb.taskId} className="flex min-w-0 items-center gap-1.5">
72
+ <Separator />
73
+ {/* The last step is where the reader IS: named, not offered as somewhere to go. */}
74
+ {step === rest.length - 1 ? (
75
+ <span className="truncate font-medium">{crumb.title}</span>
76
+ ) : (
77
+ <Step crumb={crumb} onOpen={onOpen} />
78
+ )}
79
+ </span>
80
+ ))}
81
+ </nav>
82
+ );
83
+ }
84
+
85
+ const Separator = () => (
86
+ <span aria-hidden="true" className="text-muted-foreground">
87
+ /
88
+ </span>
89
+ );
90
+
91
+ function Step({
92
+ crumb,
93
+ onOpen,
94
+ }: {
95
+ crumb: Crumb | undefined;
96
+ onOpen(taskId: string | null): void;
97
+ }) {
98
+ if (crumb === undefined) return null;
99
+ return (
100
+ <button
101
+ type="button"
102
+ onClick={() => onOpen(crumb.taskId)}
103
+ className="truncate rounded-sm text-muted-foreground outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
104
+ >
105
+ {crumb.title}
106
+ </button>
107
+ );
108
+ }