@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -40,6 +40,12 @@
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@dnd-kit/sortable": "^10.0.0",
42
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",
43
49
  "@react-sigma/core": "^5.0.6",
44
50
  "@tailwindcss/vite": "^4.3.3",
45
51
  "@tanstack/react-query": "^5.101.4",
@@ -58,6 +64,7 @@
58
64
  "react": "^19.2.0",
59
65
  "react-aria-components": "^1.19.0",
60
66
  "react-dom": "^19.2.0",
67
+ "readable-stream": "2.3.8",
61
68
  "sigma": "^3.0.3",
62
69
  "tailwind-merge": "^3.6.0",
63
70
  "tailwindcss": "^4.3.3",
@@ -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.
@@ -352,6 +363,16 @@ export function AppTree() {
352
363
 
353
364
  const upload = useMutation({
354
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
+ }
355
376
  const contentBase64 = await fileBase64(file);
356
377
  const node = await data.createNodes({
357
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,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
+ }
@@ -119,7 +119,12 @@ function Card({
119
119
  that is 76 px of a 240 px card, given away for nothing. */}
120
120
  <span
121
121
  className="block"
122
- style={{ paddingRight: `${8 + Math.min(family.level, MAX_STRIPES) * 5}px` }}
122
+ // ⚠️ `STRIPE_WIDTH`, not a number written twice. The reserve followed the OLD grid of
123
+ // 3px plus a 2px gap; since the stripes sit flush and 4px wide, `* 5` reserved up to 14px
124
+ // the title never needed, while the comment above claimed the reserve follows them.
125
+ style={{
126
+ paddingRight: `${8 + Math.min(family.level, MAX_STRIPES) * STRIPE_WIDTH}px`,
127
+ }}
123
128
  >
124
129
  {card.title}
125
130
  </span>
@@ -158,7 +163,10 @@ function Stripes({ family, label }: { family: Family; label: string }) {
158
163
  // it, and the tooltip would never appear once. The class is not needed either: this span lies
159
164
  // inside the button, so a click reaches it anyway.
160
165
  title={label}
161
- className="absolute inset-y-1 right-1 flex gap-[2px]"
166
+ // ⚠️ Flush and gapless, top to bottom. Inset from the edge and spaced apart, the stripes read
167
+ // as decoration somebody added; against the edge and touching, they read as one mark with a
168
+ // countable number of parts, which is what they are.
169
+ className="absolute inset-y-0 right-0 flex"
162
170
  >
163
171
  {levels(family.level).map((level) => (
164
172
  <span
@@ -171,8 +179,14 @@ function Stripes({ family, label }: { family: Family; label: string }) {
171
179
  // Since the pattern caps at six the lowest raw value is 0.25, so the clamp no longer
172
180
  // fires; it stays because it is the guard, not the arithmetic, that must hold if the cap
173
181
  // ever moves.
174
- className={`w-[3px] rounded-full ${toneClasses[family.tone - 1]}`}
175
- style={{ opacity: Math.max(0.3, 1 - (level - 1) * 0.15) }}
182
+ // ⚠️ No rounding. A rounded stripe at full height leaves a light wedge at each end, and
183
+ // two of them beside one another look like a gap that is not there.
184
+ // ⚠️ The width as an inline STYLE, not a class. `w-[${…}px]` is a name Tailwind never
185
+ // sees — it reads class names statically, so the stripes would have no width in the build
186
+ // and be perfectly fine in the tests. An inline value is also the only way one number can
187
+ // rule both the mark and the room reserved for it.
188
+ className={toneClasses[family.tone - 1]}
189
+ style={{ width: `${STRIPE_WIDTH}px`, opacity: Math.max(0.3, 1 - (level - 1) * 0.15) }}
176
190
  />
177
191
  ))}
178
192
  </span>
@@ -186,6 +200,15 @@ function Stripes({ family, label }: { family: Family; label: string }) {
186
200
  */
187
201
  const MAX_STRIPES = 6;
188
202
 
203
+ /**
204
+ * How wide one stripe is, in pixels.
205
+ *
206
+ * ⚠️ **One number, read by both the mark and the room reserved for it.** Written twice they drift,
207
+ * and the drift is silent: the title keeps its distance from stripes that are no longer that wide,
208
+ * or runs under ones that grew.
209
+ */
210
+ const STRIPE_WIDTH = 4;
211
+
189
212
  /**
190
213
  * `[1, 2, … min(depth, MAX_STRIPES)]` — the level each stripe stands for.
191
214
  *
@@ -339,9 +362,11 @@ function Column({
339
362
  ))}
340
363
  </ul>
341
364
  </SortableContext>
342
- {entry.cards.length === 0 ? (
343
- <p className="px-1 text-xs text-muted-foreground">{i18n.t("board.columnEmpty")}</p>
344
- ) : null}
365
+ {/* ⚠️ **Nothing at all under an empty column.** The sentence said what the emptiness already
366
+ says, and the surface I first put in its place was an invention with a false reason: the
367
+ column is a drop target through `setDropRef` on the section above, with or without
368
+ anything drawn in it — `dragEndToDrop` answers `{ status, index: 0 }` for a column with no
369
+ cards, and `neighbourDrop` reaches one by index alone. */}
345
370
  {addable ? (
346
371
  composing ? (
347
372
  <input
@@ -533,7 +558,17 @@ export function BoardKanban({
533
558
  });
534
559
  }}
535
560
  >
536
- <div className="flex gap-3 overflow-x-auto p-4" aria-busy={board.isWriting}>
561
+ {/* ⚠️ **`min-h-0 flex-1`, or the scrollbar sits in the middle of the screen.** A box with no
562
+ height shrinks to its content, and the horizontal bar clings to the bottom of the tallest
563
+ column: on a board with few cards that looks broken. The panel above already offers the
564
+ full height; this is where it is taken.
565
+
566
+ ⚠️ **And NO `items-start` with it.** A stretched column paints nothing — the section
567
+ carries neither background nor border — but the rectangle it stretches to is what
568
+ `useDroppable` measures and `closestCorners` reckons against. Held to its content instead,
569
+ an empty column shrinks to its heading, and the area a card can be dropped into shrinks
570
+ with it. */}
571
+ <div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4" aria-busy={board.isWriting}>
537
572
  {board.columns.map((column) => (
538
573
  <Column
539
574
  key={column.column.id}
@@ -0,0 +1,32 @@
1
+ import { useNavigate, useSearch } from "@tanstack/react-router";
2
+ import { useCallback } from "react";
3
+ import { openTaskFrom } from "@/router/selection-search.ts";
4
+
5
+ /**
6
+ * Which card is open, and the one way to change that.
7
+ *
8
+ * ⚠️ **One truth for two places.** The title row and the panel both open cards, and they are
9
+ * rendered in different subtrees: the row above the content, the panel inside it. Written twice,
10
+ * one of them would eventually push where the other replaces, and the back button would behave
11
+ * differently depending on which control was used.
12
+ */
13
+ export function useOpenTask(): { openId: string | null; open(taskId: string | null): void } {
14
+ const search = useSearch({ strict: false });
15
+ const navigate = useNavigate();
16
+
17
+ const open = useCallback(
18
+ (taskId: string | null) =>
19
+ void navigate({
20
+ to: ".",
21
+ // ⚠️ **Closing REPLACES, opening pushes.** Opening a card is a step somebody took and the
22
+ // back button must undo it; closing is the undo itself. Pushed as well, the history holds
23
+ // board → card → board, and one press of Back re-opens the card that was just closed.
24
+ replace: taskId === null,
25
+ search: (previous: Record<string, unknown>) =>
26
+ taskId === null ? { ...previous, task: undefined } : { ...previous, task: taskId },
27
+ }),
28
+ [navigate],
29
+ );
30
+
31
+ return { openId: openTaskFrom(search), open };
32
+ }