@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.
@@ -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
+ }
@@ -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
  );
@@ -0,0 +1,60 @@
1
+ import type { BoardColumn } from "@anchrd/intel-contract/board";
2
+ import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
3
+ import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuRadioGroup,
8
+ DropdownMenuRadioItem,
9
+ DropdownMenuTrigger,
10
+ } from "@/components/ui/dropdown-menu.tsx";
11
+ import { useI18n } from "@/i18n/i18n-context.tsx";
12
+
13
+ /**
14
+ * The column a card sits in, and the way to move it.
15
+ *
16
+ * ⚠️ **One truth for two surfaces.** The opened card and the table both offer this, and the archive
17
+ * is an entry in it: written twice, one of them would eventually keep an entry the other dropped,
18
+ * and a reader would find a card archivable from one screen and not from the other. Exactly the
19
+ * reason `AssigneeChip` was pulled out beside it.
20
+ */
21
+ export function StatusChip({
22
+ status,
23
+ title,
24
+ columns,
25
+ write,
26
+ }: {
27
+ status: string;
28
+ title: string;
29
+ columns: BoardColumn[];
30
+ write(input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>): void;
31
+ }) {
32
+ const i18n = useI18n();
33
+ return (
34
+ <DropdownMenu>
35
+ <DropdownMenuTrigger
36
+ // ⚠️ The VALUE first, then what pressing it does. A label that says only "move to a column"
37
+ // replaces the one thing the pill is there to show, and that fact then exists on screen and
38
+ // nowhere else.
39
+ aria-label={`${title}, ${i18n.t("board.moveTo")}`}
40
+ className="shrink-0 rounded-full border px-2.5 py-0.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
41
+ >
42
+ {title}
43
+ </DropdownMenuTrigger>
44
+ <DropdownMenuContent align="start">
45
+ {/* ⚠️ Where a card SITS is a move, not a field edit: the server computes a position and
46
+ checks the cycles for it. Everything else on these screens is a plain change. */}
47
+ <DropdownMenuRadioGroup value={status} onValueChange={(next) => write({ status: next })}>
48
+ {columns.map((entry) => (
49
+ <DropdownMenuRadioItem key={entry.id} value={entry.id}>
50
+ {entry.title}
51
+ </DropdownMenuRadioItem>
52
+ ))}
53
+ <DropdownMenuRadioItem value={ARCHIVE_COLUMN_ID}>
54
+ {i18n.t("board.column.archive")}
55
+ </DropdownMenuRadioItem>
56
+ </DropdownMenuRadioGroup>
57
+ </DropdownMenuContent>
58
+ </DropdownMenu>
59
+ );
60
+ }