@anchrd/intel-ui 0.48.0 → 0.50.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.48.0",
3
+ "version": "0.50.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -33,7 +33,7 @@
33
33
  "typecheck": "tsc --noEmit"
34
34
  },
35
35
  "dependencies": {
36
- "@anchrd/intel-contract": "^0.27.0",
36
+ "@anchrd/intel-contract": "^0.28.0",
37
37
  "@blocknote/core": "^0.52.1",
38
38
  "@blocknote/react": "^0.52.1",
39
39
  "@blocknote/shadcn": "^0.52.1",
@@ -25,17 +25,11 @@ export function AssigneePicker({
25
25
  boardId,
26
26
  current,
27
27
  onPick,
28
- open,
29
- onOpenChange,
30
28
  children,
31
29
  }: {
32
30
  boardId: string;
33
31
  current: string | null;
34
32
  onPick(assigneeId: string | null): void;
35
- // Controlled only where somebody else decides when it opens — the plus menu does, the chip does
36
- // not. Left out, the trigger below governs it on its own.
37
- open?: boolean;
38
- onOpenChange?(next: boolean): void;
39
33
  // ⚠️ THE ANCHOR, and it is not optional decoration (#723). Radix positions a menu against its
40
34
  // trigger; the first version of this component had none, and the menu never appeared. Every test
41
35
  // stayed green, because jsdom has no layout and Testing Library finds a portal wherever it sits.
@@ -44,19 +38,34 @@ export function AssigneePicker({
44
38
  const i18n = useI18n();
45
39
  const { data } = useIntelRouterContext();
46
40
  const [query, setQuery] = useState("");
41
+ /**
42
+ * ⚠️ **The query is gated on being OPEN, and that is not an optimisation** (#757). Since the empty
43
+ * query became a real question, an ungated `useQuery` fires the moment the component mounts — and
44
+ * this component wraps every assignee circle on the screen. A board with forty cards would open
45
+ * forty requests for a menu nobody has touched.
46
+ *
47
+ * ⚠️ **Uncontrolled, since #750.** This used to accept an `open` prop for the plus menu, which
48
+ * opened the picker without a trigger of its own. That entry is gone — the circle is always on the
49
+ * row and opens the same menu — and the prop went with it rather than staying as a second way in
50
+ * that nothing drives and nothing keeps in step.
51
+ */
52
+ const [shown, setShown] = useState(false);
47
53
  const trimmed = query.trim();
54
+ /**
55
+ * ⚠️ **Enabled for an EMPTY query too, since #757.** Nothing typed asks a different question than
56
+ * one letter: it names the people who reach this board, a short set this installation already
57
+ * knows. One letter stays refused, because that IS a search and a bad one.
58
+ */
48
59
  const search = useQuery({
49
60
  queryKey: ["board-assignee-search", boardId, trimmed],
50
- enabled: trimmed.length >= 2,
61
+ enabled: shown && (trimmed.length === 0 || trimmed.length >= 2),
51
62
  queryFn: async () => await data.searchBoardAssignees(boardId, trimmed),
52
63
  });
53
64
  const found = search.data?.items ?? [];
65
+ const more = search.data?.more ?? 0;
54
66
 
55
67
  return (
56
- <DropdownMenu
57
- {...(open === undefined ? {} : { open })}
58
- {...(onOpenChange === undefined ? {} : { onOpenChange })}
59
- >
68
+ <DropdownMenu onOpenChange={setShown}>
60
69
  <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
61
70
  <DropdownMenuContent align="start" className="w-64">
62
71
  <div className="p-1">
@@ -67,7 +76,7 @@ export function AssigneePicker({
67
76
  aria-label={i18n.t("board.assignee.search")}
68
77
  placeholder={i18n.t("board.assignee.search")}
69
78
  onChange={(event) => setQuery(event.target.value)}
70
- onKeyDown={(event) => event.key === "Escape" && onOpenChange?.(false)}
79
+ onKeyDown={(event) => event.key === "Escape" && setShown(false)}
71
80
  className="w-full rounded-md border bg-background px-2 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
72
81
  />
73
82
  </div>
@@ -75,7 +84,6 @@ export function AssigneePicker({
75
84
  <>
76
85
  <DropdownMenuItem
77
86
  onSelect={() => {
78
- onOpenChange?.(false);
79
87
  onPick(null);
80
88
  }}
81
89
  >
@@ -84,12 +92,15 @@ export function AssigneePicker({
84
92
  <DropdownMenuSeparator />
85
93
  </>
86
94
  )}
87
- {trimmed.length < 2 ? (
95
+ {/* ⚠️ Only for the ONE-character case. An empty query now answers with suggestions, and
96
+ telling somebody to keep typing while showing them names would be a hint that contradicts
97
+ the list under it. */}
98
+ {trimmed.length === 1 ? (
88
99
  <p className="px-2 py-1.5 text-muted-foreground text-xs">
89
100
  {i18n.t("board.assignee.keepTyping")}
90
101
  </p>
91
102
  ) : null}
92
- {trimmed.length >= 2 && !search.isPending && found.length === 0 ? (
103
+ {trimmed.length !== 1 && !search.isPending && found.length === 0 ? (
93
104
  <p className="px-2 py-1.5 text-muted-foreground text-xs">
94
105
  {i18n.t("board.assignee.nobody")}
95
106
  </p>
@@ -98,7 +109,6 @@ export function AssigneePicker({
98
109
  <DropdownMenuItem
99
110
  key={person.id}
100
111
  onSelect={() => {
101
- onOpenChange?.(false);
102
112
  onPick(person.id);
103
113
  }}
104
114
  >
@@ -109,6 +119,13 @@ export function AssigneePicker({
109
119
  <span className="ml-auto truncate text-muted-foreground text-xs">{person.email}</span>
110
120
  </DropdownMenuItem>
111
121
  ))}
122
+ {/* ⚠️ The cap says so rather than hiding. Three names that look like the whole list teach
123
+ the reader that nobody else exists, which is the one thing this list must not say. */}
124
+ {more === 0 ? null : (
125
+ <p className="px-2 py-1.5 text-muted-foreground text-xs">
126
+ {i18n.t("board.assignee.more", { count: more })}
127
+ </p>
128
+ )}
112
129
  </DropdownMenuContent>
113
130
  </DropdownMenu>
114
131
  );
@@ -16,6 +16,7 @@ import { useState } from "react";
16
16
  import type { BoardHandle, ColumnWithCards } from "@/board/board-data/board-data.types.ts";
17
17
  import { type Family, familiesOf } from "@/board/board-stripes/board-stripes.ts";
18
18
  import { useI18n } from "@/i18n/i18n-context.tsx";
19
+ import { cn } from "@/lib/utils";
19
20
  import {
20
21
  type Drop,
21
22
  dragEndToDrop,
@@ -326,7 +327,13 @@ function Column({
326
327
  aria-label={entry.column.title}
327
328
  className="group/column flex w-64 shrink-0 flex-col gap-2"
328
329
  >
329
- <h3 className="flex items-baseline gap-2 px-1 text-sm font-medium">
330
+ <h3 className="flex items-center gap-2 px-1 text-sm font-medium">
331
+ {/* ⚠️ The dot is DECORATION and says so (#725). The column's name is right beside it in
332
+ words, so announcing the colour would repeat the name in a form nobody can act on — and
333
+ for somebody who cannot see it, a second announcement of "Backlog" is noise, not help.
334
+ ⚠️ An inline style, not a class: the value is chosen by a person at runtime, and Tailwind
335
+ only ships classes its scanner saw in the source. */}
336
+ <ColumnDot color={entry.column.color} />
330
337
  {entry.column.title}
331
338
  <span className="text-xs text-muted-foreground tabular-nums">{entry.cards.length}</span>
332
339
  {entry.unknown === true ? (
@@ -647,3 +654,27 @@ function depthOf(
647
654
  ? i18n.t("board.stripes.root", { level: family.level })
648
655
  : i18n.t("board.stripes.under", { level: family.level, column: parentColumn });
649
656
  }
657
+
658
+ /**
659
+ * The colour of a column, as a dot (#725).
660
+ *
661
+ * ⚠️ **One component for two places.** The kanban head draws it and so does a linked task in the
662
+ * open card; written twice they drift, and a dot that means one thing here and another there is
663
+ * worse than no dot.
664
+ *
665
+ * ⚠️ **Absent is a real state, not a missing value.** A board configured before colours existed has
666
+ * none, and painting it grey-by-default would claim somebody chose grey. The reader's own muted
667
+ * tone says "no colour set" without pretending otherwise.
668
+ */
669
+ export function ColumnDot({ color }: { color?: string | undefined }) {
670
+ return (
671
+ <span
672
+ aria-hidden="true"
673
+ className={cn(
674
+ "size-2 shrink-0 rounded-full",
675
+ color === undefined && "bg-muted-foreground/40",
676
+ )}
677
+ {...(color === undefined ? {} : { style: { backgroundColor: color } })}
678
+ />
679
+ );
680
+ }
@@ -1,4 +1,8 @@
1
- import { ARCHIVE_COLUMN_ID, type BoardColumn } from "@anchrd/intel-contract/board";
1
+ import {
2
+ ARCHIVE_COLUMN_ID,
3
+ type BoardColumn,
4
+ DEFAULT_COLUMN_COLOR,
5
+ } from "@anchrd/intel-contract/board";
2
6
  import { ChevronDown, ChevronUp, X } from "lucide-react";
3
7
  import { useState } from "react";
4
8
  import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
@@ -94,6 +98,23 @@ export function BoardSettings({ board, onClose }: { board: BoardHandle; onClose(
94
98
  <ul className="space-y-2">
95
99
  {columns.map((column, index) => (
96
100
  <li key={column.id} className="flex items-center gap-2">
101
+ {/* ⚠️ A free colour, not a palette (Jack's decision 2026-08-22) — see the contract
102
+ for why that is affordable here: the value only ever fills a dot.
103
+ ⚠️ The native `input type="color"`, not a built one. It is the platform's own
104
+ picker, it works with a keyboard and a screen reader without anybody writing that
105
+ part, and "kaufen statt bauen" is the rule this repository states for exactly this
106
+ case. */}
107
+ <input
108
+ type="color"
109
+ value={column.color ?? DEFAULT_COLUMN_COLOR}
110
+ aria-label={i18n.t("board.settings.columnColor", { column: column.title })}
111
+ onChange={(event) => {
112
+ const next = [...columns];
113
+ next[index] = { ...column, color: event.target.value };
114
+ setColumns(next);
115
+ }}
116
+ className="size-7 shrink-0 cursor-pointer rounded-md border bg-background outline-none focus-visible:ring-2 focus-visible:ring-ring"
117
+ />
97
118
  <input
98
119
  value={column.title}
99
120
  aria-label={i18n.t("board.settings.columnName", { column: column.title })}
@@ -18,6 +18,13 @@ export interface TaskLink {
18
18
  title: string | null;
19
19
  /** The column the linked task sits in, so its state is readable without opening it. */
20
20
  columnTitle: string | null;
21
+ /**
22
+ * The colour of that column (#725), so the row can show the state as a dot as well as a word.
23
+ *
24
+ * ⚠️ `undefined` is a real answer, not a gap: a board configured before colours existed has none,
25
+ * and a blocker outside this board has no column at all.
26
+ */
27
+ columnColor: string | undefined;
21
28
  /** Set on a subtask only: whether it sits in a column the board calls terminal. */
22
29
  done: boolean;
23
30
  }
@@ -57,6 +64,7 @@ export function linksOf(task: BoardTask, columns: ColumnWithCards[]): TaskLink[]
57
64
  id: card.id,
58
65
  title: card.title,
59
66
  columnTitle: column.title,
67
+ columnColor: column.color,
60
68
  done: column.terminal,
61
69
  });
62
70
  }
@@ -69,6 +77,7 @@ export function linksOf(task: BoardTask, columns: ColumnWithCards[]): TaskLink[]
69
77
  id: card.id,
70
78
  title: card.title,
71
79
  columnTitle: column.title,
80
+ columnColor: column.color,
72
81
  done: false,
73
82
  });
74
83
  }
@@ -85,6 +94,7 @@ export function linksOf(task: BoardTask, columns: ColumnWithCards[]): TaskLink[]
85
94
  // says "something outside this board"; the id says nothing at all.
86
95
  title: blocking?.card.title ?? null,
87
96
  columnTitle: blocking?.column.title ?? null,
97
+ columnColor: blocking?.column.color,
88
98
  done: false,
89
99
  });
90
100
  }
@@ -1,10 +1,11 @@
1
1
  import type { BoardTask } from "@anchrd/intel-contract/board";
2
- import { Lock, Plus, Square, SquareCheck } from "lucide-react";
2
+ import { Lock, Plus } from "lucide-react";
3
3
  import { useState } from "react";
4
4
  import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
5
5
  import { BoardChip } from "@/board/board-chip/board-chip.tsx";
6
6
  import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
7
7
  import { DatesChip } from "@/board/board-dates/board-dates.tsx";
8
+ import { ColumnDot } from "@/board/board-kanban/board-kanban.tsx";
8
9
  import { StatusChip } from "@/board/board-status/board-status.tsx";
9
10
  import {
10
11
  DropdownMenu,
@@ -13,24 +14,37 @@ import {
13
14
  DropdownMenuTrigger,
14
15
  } from "@/components/ui/dropdown-menu.tsx";
15
16
  import { useI18n } from "@/i18n/i18n-context.tsx";
16
- import { type LinkKind, linksOf, type TaskLink } from "./board-task.ts";
17
-
18
- const linkIcons: Record<LinkKind, typeof Lock> = {
19
- subtask: Square,
20
- blocker: Lock,
21
- blocked: Lock,
22
- };
17
+ import { linksOf, type TaskLink } from "./board-task.ts";
23
18
 
24
19
  /** One entry of the link list. The icon carries the kind; nothing writes the word out. */
25
20
  function LinkRow({ link, onOpen }: { link: TaskLink; onOpen(id: string): void }) {
26
21
  const i18n = useI18n();
27
- const Icon = link.kind === "subtask" && link.done ? SquareCheck : linkIcons[link.kind];
22
+ /**
23
+ * ⚠️ **A subtask shows its column as a DOT, not a tick** (#725). Jack's report of 2026-08-21 was
24
+ * that the open subtasks carry a checkmark in front of them that cannot be ticked. A checkbox
25
+ * promises an action this list does not have, and a card is finished by being moved, not by being
26
+ * ticked here.
27
+ *
28
+ * ⚠️ **The lock stays.** A blocker outside this board is not a state on a scale, it is something
29
+ * the reader may not open, and a colour cannot say that.
30
+ */
31
+ /**
32
+ * ⚠️ **The lock is about READABILITY, not about the kind**, and keying it on the kind was wrong in
33
+ * both directions: `blocked` is always a card of this board and always readable, while `blocker`
34
+ * may be any node anywhere — the one case where this answer holds no title and no column at all.
35
+ *
36
+ * `title === null` is exactly that case, and it is the same condition the row already uses one
37
+ * line down to say "outside this board". A dot there would be a colour for a column that does not
38
+ * exist.
39
+ */
40
+ const unreadable = link.title === null;
28
41
  return (
29
42
  <li className="flex items-center gap-2 border-b py-2 last:border-0">
30
- <Icon
31
- aria-hidden="true"
32
- className={`size-4 shrink-0 ${link.kind === "blocker" ? "text-destructive" : "text-muted-foreground"}`}
33
- />
43
+ {unreadable ? (
44
+ <Lock aria-hidden="true" className="size-4 shrink-0 text-destructive" />
45
+ ) : (
46
+ <ColumnDot color={link.columnColor} />
47
+ )}
34
48
  <button
35
49
  type="button"
36
50
  onClick={() => onOpen(link.id)}
package/src/feed/feed.tsx CHANGED
@@ -18,6 +18,21 @@ import { initials, useSessionUser } from "@/user-name/user-name.ts";
18
18
 
19
19
  const PageSize = 30;
20
20
 
21
+ // How many steps of the trail a card shows.
22
+ //
23
+ // ⚠️ The LAST ones, not the first, and that is the whole decision (#758). A path answers "where does
24
+ // this sit", and the answer is at its end: `… / system / Kundenwissen` says where `Kunde A` lives,
25
+ // `Agenten / Anton Anchrd / sys…` says where the tree begins, which every card shares. Cutting from
26
+ // the front is what `truncate` does on its own, so this has to be decided here rather than left to
27
+ // CSS.
28
+ const TrailSteps = 2;
29
+
30
+ /** The tail of a trail, with a leading ellipsis where something was cut. */
31
+ export function shortTrail(titles: readonly string[]): string {
32
+ if (titles.length <= TrailSteps) return titles.join(" / ");
33
+ return `… / ${titles.slice(-TrailSteps).join(" / ")}`;
34
+ }
35
+
21
36
  // Who to show. Only the first two reach the server; `people` is applied here because whether an
22
37
  // actor is a machine is Gate's answer and arrives with the names, not with the events.
23
38
  type Audience = "all" | "mine" | "people";
@@ -280,9 +295,17 @@ function FeedCard({
280
295
  <span className="text-sm leading-snug">
281
296
  {i18n.t(`feed.action.${event.action}`, { who, title: event.nodeTitle })}
282
297
  </span>
283
- <span className="flex items-center justify-between gap-3">
284
- <span className="truncate text-xs text-muted-foreground">
285
- {event.path.map((step) => step.title).join(" / ")}
298
+ {/* ⚠️ `min-w-0` is not decoration. A grid item defaults to `min-width: auto`, so without it
299
+ this row grows to whatever the path needs and pushes the button PAST the card's edge —
300
+ which is what a four-step path did on a phone. `truncate` below cannot save it either: it
301
+ shortens against the width it is given, and its parent was already too wide. */}
302
+ <span className="flex min-w-0 items-center justify-between gap-3">
303
+ <span
304
+ className="truncate text-xs text-muted-foreground"
305
+ // The whole path stays reachable where there is a pointer to rest.
306
+ title={event.path.map((step) => step.title).join(" / ")}
307
+ >
308
+ {shortTrail(event.path.map((step) => step.title))}
286
309
  </span>
287
310
  {/* ⚠️ A button, not a label. The whole point of the mark is getting there, and a span
288
311
  carries no focus — so the keyboard requirement could not be met by a surface that had
package/src/i18n/de.json CHANGED
@@ -87,6 +87,7 @@
87
87
  "board.assignSomebody": "Jemandem zuweisen",
88
88
  "board.assignee.clear": "Zuständigkeit entfernen",
89
89
  "board.assignee.keepTyping": "Mindestens zwei Zeichen tippen",
90
+ "board.assignee.more": "Hier nicht aufgeführt: {count}. Tippe, um zu suchen.",
90
91
  "board.assignee.nobody": "Niemand mit Zugriff auf dieses Board passt dazu",
91
92
  "board.assignee.search": "Person suchen",
92
93
  "board.backTo": "Zurück zu {board}",
@@ -131,6 +132,7 @@
131
132
  "board.moveTo": "in eine andere Spalte verschieben",
132
133
  "board.removeLabel": "dieses Label entfernen",
133
134
  "board.settings.addColumn": "+ Spalte",
135
+ "board.settings.columnColor": "Farbe der Spalte {column}",
134
136
  "board.settings.columnName": "Name der Spalte {column}",
135
137
  "board.settings.description": "Spalten und was das Board zeigt.",
136
138
  "board.settings.duplicateName": "Zwei Spalten heißen {column}. Namen müssen sie unterscheidbar machen.",
package/src/i18n/en.json CHANGED
@@ -87,6 +87,7 @@
87
87
  "board.assignSomebody": "Assign to somebody",
88
88
  "board.assignee.clear": "Remove the assignee",
89
89
  "board.assignee.keepTyping": "Type at least two characters",
90
+ "board.assignee.more": "Not listed here: {count}. Type to search.",
90
91
  "board.assignee.nobody": "Nobody with access to this board matches",
91
92
  "board.assignee.search": "Find a person",
92
93
  "board.backTo": "Back to {board}",
@@ -131,6 +132,7 @@
131
132
  "board.moveTo": "move it to another column",
132
133
  "board.removeLabel": "remove this label",
133
134
  "board.settings.addColumn": "+ Column",
135
+ "board.settings.columnColor": "Colour of the {column} column",
134
136
  "board.settings.columnName": "Name of the column {column}",
135
137
  "board.settings.description": "Columns and what the board shows.",
136
138
  "board.settings.duplicateName": "Two columns are called {column}. Names have to tell them apart.",
package/src/i18n/es.json CHANGED
@@ -87,6 +87,7 @@
87
87
  "board.assignSomebody": "Asignar a alguien",
88
88
  "board.assignee.clear": "Quitar el responsable",
89
89
  "board.assignee.keepTyping": "Escribe al menos dos caracteres",
90
+ "board.assignee.more": "No listados aquí: {count}. Escribe para buscar.",
90
91
  "board.assignee.nobody": "Nadie con acceso a este tablero coincide",
91
92
  "board.assignee.search": "Buscar una persona",
92
93
  "board.backTo": "Volver a {board}",
@@ -131,6 +132,7 @@
131
132
  "board.moveTo": "moverla a otra columna",
132
133
  "board.removeLabel": "quitar esta etiqueta",
133
134
  "board.settings.addColumn": "+ Columna",
135
+ "board.settings.columnColor": "Color de la columna {column}",
134
136
  "board.settings.columnName": "Nombre de la columna {column}",
135
137
  "board.settings.description": "Columnas y lo que muestra el tablero.",
136
138
  "board.settings.duplicateName": "Dos columnas se llaman {column}. Los nombres deben distinguirlas.",