@anchrd/intel-ui 0.45.0 → 0.46.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.45.0",
3
+ "version": "0.46.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -25,3 +25,24 @@ export function useAssigneeLabel(id: string | null): string {
25
25
  // replacement for the rule underneath it.
26
26
  return own ?? resolved ?? i18n.t("node.someUser");
27
27
  }
28
+
29
+ /**
30
+ * The three tones the access summary already uses for its circles, so an assignee looks like the
31
+ * same kind of thing as a person on the share screen (#724).
32
+ *
33
+ * ⚠️ **Chosen by the ID, not by a position.** The access summary can count places in a row; a card
34
+ * stands alone, and there is no row to count in. Hashing the id means the same person keeps the same
35
+ * colour on every card and every board, which is the only property that makes a colour worth having
36
+ * here — a colour that moves is noise.
37
+ */
38
+ const PERSON_TONES = [
39
+ "bg-muted text-muted-foreground",
40
+ "bg-accent text-accent-foreground",
41
+ "bg-secondary text-secondary-foreground",
42
+ ] as const;
43
+
44
+ export function personTone(id: string): string {
45
+ let sum = 0;
46
+ for (const character of id) sum = (sum + character.charCodeAt(0)) % 4096;
47
+ return PERSON_TONES[sum % PERSON_TONES.length] ?? PERSON_TONES[0];
48
+ }
@@ -1,10 +1,12 @@
1
+ import { UserRound } from "lucide-react";
1
2
  import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
2
3
  import { useI18n } from "@/i18n/i18n-context.tsx";
4
+ import { cn } from "@/lib/utils";
3
5
  import { initials } from "@/user-name/user-name.ts";
4
- import { useAssigneeLabel } from "./board-assignee.ts";
6
+ import { personTone, useAssigneeLabel } from "./board-assignee.ts";
5
7
 
6
8
  /**
7
- * The circle for whoever a card is for, and the way to take the assignment off.
9
+ * The circle for whoever a card is for, and the way to give it to somebody else.
8
10
  *
9
11
  * ⚠️ **One truth for two surfaces.** The opened card and the table both draw it; written twice they
10
12
  * would drift, and the drift would be an accessibility bug on exactly one of them.
@@ -12,8 +14,10 @@ import { useAssigneeLabel } from "./board-assignee.ts";
12
14
  * ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed from the access
13
15
  * summary, the field stays `assigneeId`. A row of circles would imply a second data model.
14
16
  *
15
- * ⚠️ **Only ever drawn for somebody.** "Nobody yet" as a chip is a placeholder for an absence, and
16
- * an absence needs no place on screen (#692).
17
+ * ⚠️ **Drawn even for nobody, since #724** and that reverses #692 on purpose rather than by
18
+ * accident. A text chip saying "nobody yet" was a placeholder for an absence and earned no room. An
19
+ * empty circle is not that: it is the one control on the row that says the card COULD be given to
20
+ * somebody, and the card nobody owns is exactly the one that needs it.
17
21
  *
18
22
  * ⚠️ **The circle OPENS the picker; it no longer clears on click** (#723). A control that
19
23
  * removes an assignment on the same click somebody uses to change one is a control that
@@ -29,7 +33,12 @@ export function AssigneeChip({
29
33
  boardId,
30
34
  onPick,
31
35
  }: {
32
- id: string;
36
+ /**
37
+ * ⚠️ `null` DRAWS, it does not skip (#724). An empty circle with a person in it is the one place
38
+ * on the row that says "this could be given to somebody" — and without it there is nothing to
39
+ * press on a card nobody owns, which is exactly the card that needs an owner.
40
+ */
41
+ id: string | null;
33
42
  boardId: string;
34
43
  onPick(assigneeId: string | null): void;
35
44
  }) {
@@ -39,7 +48,11 @@ export function AssigneeChip({
39
48
  <AssigneePicker boardId={boardId} current={id} onPick={onPick}>
40
49
  <button
41
50
  type="button"
42
- aria-label={i18n.t("board.changeAssignee", { name: label })}
51
+ aria-label={
52
+ id === null
53
+ ? i18n.t("board.assignSomebody")
54
+ : i18n.t("board.changeAssignee", { name: label })
55
+ }
43
56
  title={label}
44
57
  className="shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
45
58
  >
@@ -49,9 +62,12 @@ export function AssigneeChip({
49
62
  // label back would not add a second announcement, it would add none, while making the code
50
63
  // look as though the name were covered here rather than on the button.
51
64
  aria-hidden="true"
52
- className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
65
+ className={cn(
66
+ "flex size-7 items-center justify-center rounded-full text-xs font-medium ring-2 ring-background",
67
+ id === null ? "border border-dashed text-muted-foreground" : personTone(id),
68
+ )}
53
69
  >
54
- {initials(label)}
70
+ {id === null ? <UserRound aria-hidden="true" className="size-4" /> : initials(label)}
55
71
  </span>
56
72
  </button>
57
73
  </AssigneePicker>
@@ -1,3 +1,4 @@
1
+ import { cn } from "@/lib/utils";
1
2
  /**
2
3
  * A value on a card, and the way to take it off again.
3
4
  *
@@ -13,17 +14,31 @@ export function BoardChip({
13
14
  label,
14
15
  action,
15
16
  onClick,
17
+ tone = "outline",
16
18
  }: {
17
19
  label: string;
18
20
  action: string;
19
21
  onClick(): void;
22
+ /**
23
+ * ⚠️ **`inverted` is for labels and nothing else** (#724). Status, dates and the assignee say what
24
+ * a card IS; a label is what somebody chose to call it, and the row needs to tell the two apart at
25
+ * a glance. Filling every chip would take that difference away again.
26
+ */
27
+ tone?: "outline" | "inverted";
20
28
  }) {
21
29
  return (
22
30
  <button
23
31
  type="button"
24
32
  aria-label={`${label}, ${action}`}
25
33
  onClick={onClick}
26
- className="shrink-0 rounded-full border px-2.5 py-0.5 text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
34
+ className={cn(
35
+ "flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring",
36
+ tone === "outline" && "border hover:bg-muted",
37
+ // ⚠️ `foreground`/`background`, not literal black and white. The two swap with the theme on
38
+ // their own, so the label stays inverted in dark mode instead of turning into a black chip
39
+ // on a black ground.
40
+ tone === "inverted" && "bg-foreground text-background hover:bg-foreground/85",
41
+ )}
27
42
  >
28
43
  {label}
29
44
  </button>
@@ -31,14 +31,17 @@ export function BoardCrumbs({
31
31
  const rest = shown.slice(1);
32
32
 
33
33
  return (
34
- <nav aria-label={i18n.t("board.crumbs")} className="flex min-w-0 items-center gap-1.5 text-sm">
34
+ <nav
35
+ aria-label={i18n.t("board.crumbs")}
36
+ className="flex min-w-0 items-center gap-1.5 text-lg font-semibold"
37
+ >
35
38
  <button
36
39
  type="button"
37
40
  aria-label={i18n.t("board.backTo", { board: board?.title ?? "" })}
38
41
  onClick={() => onOpen(null)}
39
42
  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
43
  >
41
- <ChevronLeft aria-hidden="true" className="size-4" />
44
+ <ChevronLeft aria-hidden="true" className="size-5" />
42
45
  </button>
43
46
 
44
47
  <Step crumb={board} onOpen={onOpen} />
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The one place a card's two dates become the one thing a reader sees (#724).
3
+ *
4
+ * ⚠️ **Pure, and separate from the chip that draws it.** The rules here — which of the four
5
+ * combinations produces which text — are the part worth holding in a test without a DOM, and the
6
+ * part a second surface would otherwise re-derive slightly differently.
7
+ */
8
+
9
+ /**
10
+ * `TT.MM.JJJJ`, from the ISO timestamp the contract carries.
11
+ *
12
+ * ⚠️ Built from the date PART of the string, never from a `Date`. `new Date("2026-08-15T00:00:00Z")`
13
+ * is the 14th in every timezone west of London, and a card would show the day before the one
14
+ * somebody picked. The stored value is a calendar day; it is read as one.
15
+ */
16
+ export function germanDate(iso: string): string {
17
+ const [year, month, day] = iso.slice(0, 10).split("-");
18
+ return `${day}.${month}.${year}`;
19
+ }
20
+
21
+ /** What the `<input type="date">` in the popover needs: the same day, the other way round. */
22
+ export function isoDay(iso: string): string {
23
+ return iso.slice(0, 10);
24
+ }
25
+
26
+ /**
27
+ * The label of the single date chip, or `null` when there is nothing to say.
28
+ *
29
+ * ⚠️ **A range only when there IS one.** With one date set the chip carries that date alone: a
30
+ * range with an empty half ("15.08.2026 - ") reads as a value somebody forgot to finish, and a
31
+ * placeholder for the missing end is a placeholder for an absence (#692).
32
+ */
33
+ export function dateLabel(startDate: string | null, dueDate: string | null): string | null {
34
+ if (startDate !== null && dueDate !== null) {
35
+ return `${germanDate(startDate)} - ${germanDate(dueDate)}`;
36
+ }
37
+ if (startDate !== null) return germanDate(startDate);
38
+ if (dueDate !== null) return germanDate(dueDate);
39
+ return null;
40
+ }
@@ -0,0 +1,105 @@
1
+ import { CalendarDays } from "lucide-react";
2
+ import {
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuTrigger,
6
+ } from "@/components/ui/dropdown-menu.tsx";
7
+ import { useI18n } from "@/i18n/i18n-context.tsx";
8
+ import { dateLabel, isoDay } from "./board-dates.ts";
9
+
10
+ /**
11
+ * When a card runs, as one chip (#724).
12
+ *
13
+ * ⚠️ **One chip, not two.** It used to be `from …` and `due …` side by side, and that was two facts
14
+ * where a reader sees one: a card runs from a day to a day. The two are still edited and removed
15
+ * separately, but inside, where the popover can say which half it is changing.
16
+ *
17
+ * ⚠️ **The chip is the trigger.** A menu with no anchor never appears — that is `#723`, and it cost
18
+ * a whole release. `DropdownMenuContent` without a `DropdownMenuTrigger` in the same `DropdownMenu`
19
+ * is a finding in this repository.
20
+ */
21
+ export function DatesChip({
22
+ startDate,
23
+ dueDate,
24
+ write,
25
+ }: {
26
+ startDate: string | null;
27
+ dueDate: string | null;
28
+ write(input: { startDate?: string | null; dueDate?: string | null }): void;
29
+ }) {
30
+ const i18n = useI18n();
31
+ const label = dateLabel(startDate, dueDate);
32
+ if (label === null) return null;
33
+
34
+ // ⚠️ Midnight UTC, the same shape the plus menu writes. A card's dates are calendar days here, and
35
+ // a second format would make two cards written by two paths sort against each other.
36
+ const asStored = (day: string) => (day === "" ? null : `${day}T00:00:00.000Z`);
37
+
38
+ return (
39
+ <DropdownMenu>
40
+ <DropdownMenuTrigger
41
+ aria-label={`${label}, ${i18n.t("board.dates.edit")}`}
42
+ className="flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
43
+ >
44
+ <CalendarDays aria-hidden="true" className="size-3.5" />
45
+ {label}
46
+ </DropdownMenuTrigger>
47
+ <DropdownMenuContent align="start" className="w-60 p-2">
48
+ <div className="flex flex-col gap-2">
49
+ <Field
50
+ label={i18n.t("board.add.start")}
51
+ value={startDate}
52
+ clear={i18n.t("board.clearStart")}
53
+ onChange={(day) => write({ startDate: asStored(day) })}
54
+ />
55
+ <Field
56
+ label={i18n.t("board.add.due")}
57
+ value={dueDate}
58
+ clear={i18n.t("board.clearDue")}
59
+ onChange={(day) => write({ dueDate: asStored(day) })}
60
+ />
61
+ </div>
62
+ </DropdownMenuContent>
63
+ </DropdownMenu>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * One half of the range.
69
+ *
70
+ * ⚠️ **The remove button only exists while there is something to remove.** A permanently visible
71
+ * one is a control that does nothing on most cards, and a reader learns to ignore it.
72
+ */
73
+ function Field({
74
+ label,
75
+ value,
76
+ clear,
77
+ onChange,
78
+ }: {
79
+ label: string;
80
+ value: string | null;
81
+ clear: string;
82
+ onChange(day: string): void;
83
+ }) {
84
+ return (
85
+ <label className="flex items-center gap-2 text-xs">
86
+ <span className="w-14 shrink-0 text-muted-foreground">{label}</span>
87
+ <input
88
+ type="date"
89
+ value={value === null ? "" : isoDay(value)}
90
+ onChange={(event) => onChange(event.target.value)}
91
+ className="min-w-0 flex-1 rounded-md border bg-background px-2 py-1 tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring"
92
+ />
93
+ {value === null ? null : (
94
+ <button
95
+ type="button"
96
+ aria-label={`${label}, ${clear}`}
97
+ onClick={() => onChange("")}
98
+ className="shrink-0 rounded-md px-1 text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
99
+ >
100
+ ×
101
+ </button>
102
+ )}
103
+ </label>
104
+ );
105
+ }
@@ -96,10 +96,3 @@ export function linksOf(task: BoardTask, columns: ColumnWithCards[]): TaskLink[]
96
96
  (a.title ?? "").localeCompare(b.title ?? ""),
97
97
  );
98
98
  }
99
-
100
- /** How far the subtasks have got — `null` when there are none, so the view draws nothing. */
101
- export function progressOf(links: TaskLink[]): { done: number; total: number } | null {
102
- const subtasks = links.filter((link) => link.kind === "subtask");
103
- if (subtasks.length === 0) return null;
104
- return { done: subtasks.filter((link) => link.done).length, total: subtasks.length };
105
- }
@@ -5,6 +5,7 @@ import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
5
5
  import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
6
6
  import { BoardChip } from "@/board/board-chip/board-chip.tsx";
7
7
  import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
8
+ import { DatesChip } from "@/board/board-dates/board-dates.tsx";
8
9
  import { StatusChip } from "@/board/board-status/board-status.tsx";
9
10
  import {
10
11
  DropdownMenu,
@@ -13,7 +14,7 @@ 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, progressOf, type TaskLink } from "./board-task.ts";
17
+ import { type LinkKind, linksOf, type TaskLink } from "./board-task.ts";
17
18
 
18
19
  const linkIcons: Record<LinkKind, typeof Lock> = {
19
20
  subtask: Square,
@@ -66,7 +67,6 @@ export function BoardTaskDocument({
66
67
  const i18n = useI18n();
67
68
 
68
69
  const links = linksOf(task, board.columns);
69
- const progress = progressOf(links);
70
70
  const columns = board.columns.filter((entry) => entry.unknown !== true).map((e) => e.column);
71
71
  const column = columns.find((entry) => entry.id === task.status);
72
72
 
@@ -87,7 +87,11 @@ export function BoardTaskDocument({
87
87
 
88
88
  return (
89
89
  <div
90
- className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-6"
90
+ /* ⚠️ `pt-0`, and the 16px above the chips is the TITLE ROW's own `py-4` (#724). Adding padding
91
+ here as well would stack two gaps into 40px, which is what it was: the chips looked like a
92
+ section of their own instead of the second line of one head. `gap-6` below sets the 24px
93
+ that separates the head from the link list, which IS a section of its own. */
94
+ className="flex min-h-0 flex-1 flex-col gap-6 overflow-auto px-6 pt-0 pb-6"
91
95
  aria-busy={board.isWriting}
92
96
  >
93
97
  {/* ⚠️ No heading and no way back HERE. Both live in the title row above since #692: the path
@@ -103,43 +107,32 @@ export function BoardTaskDocument({
103
107
  write={write}
104
108
  />
105
109
 
106
- {/* ⚠️ Two chips when both dates are set, not a range. A range reads as one fact; these
107
- are two, and each is removed on its own. */}
108
- {task.startDate === null ? null : (
109
- <BoardChip
110
- label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
111
- action={i18n.t("board.clearStart")}
112
- onClick={() => write({ startDate: null })}
113
- />
114
- )}
115
- {task.dueDate === null ? null : (
116
- <BoardChip
117
- label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
118
- action={i18n.t("board.clearDue")}
119
- onClick={() => write({ dueDate: null })}
120
- />
121
- )}
110
+ {/* ⚠️ ONE chip, since #724. It was two, `from …` and `due …` two facts where a reader
111
+ sees one: a card runs from a day to a day. Each half is still edited and removed on its
112
+ own, but inside the popover, where the control can say which half it means. */}
113
+ <DatesChip startDate={task.startDate} dueDate={task.dueDate} write={write} />
122
114
 
123
115
  {task.labels.map((entry) => (
124
116
  <BoardChip
125
117
  key={entry}
126
118
  label={entry}
119
+ tone="inverted"
127
120
  action={i18n.t("board.removeLabel")}
128
121
  onClick={() => write({ labels: task.labels.filter((keep) => keep !== entry) })}
129
122
  />
130
123
  ))}
131
124
 
132
- {/* ⚠️ **Last, always.** It is the only round element in the row; standing between the chips
133
- it breaks the line, and a row without one simply ends earlier. */}
134
- {task.assigneeId === null ? null : (
135
- <AssigneeChip
136
- id={task.assigneeId}
137
- boardId={board.boardId}
138
- onPick={(assigneeId) => write({ assigneeId })}
139
- />
140
- )}
141
-
142
125
  <Adder task={task} board={board} write={write} />
126
+
127
+ {/* ⚠️ **Last, always, and after the plus** (#724). It is the only round element in the row;
128
+ standing between the chips it breaks the line. And it is drawn even when nobody has the
129
+ card: an empty circle is the one control on a row that says the card COULD be given to
130
+ somebody, which is precisely what an unowned card needs. */}
131
+ <AssigneeChip
132
+ id={task.assigneeId}
133
+ boardId={board.boardId}
134
+ onPick={(assigneeId) => write({ assigneeId })}
135
+ />
143
136
  </div>
144
137
 
145
138
  {/* ⚠️ ONE list, not three blocks: the icon carries the kind. And it exists only when there is
@@ -147,14 +140,11 @@ export function BoardTaskDocument({
147
140
  is missing; nothing at all tells them there is nothing, which is the truth (#692). */}
148
141
  {links.length === 0 ? null : (
149
142
  <section aria-label={i18n.t("board.links")} className="flex flex-col gap-1">
150
- <h2 className="flex items-center gap-2 text-sm font-medium">
151
- {i18n.t("board.links")}
152
- {progress === null ? null : (
153
- <span className="text-xs text-muted-foreground tabular-nums">
154
- {i18n.t("board.progress", { done: progress.done, total: progress.total })}
155
- </span>
156
- )}
157
- </h2>
143
+ {/* ⚠️ Secondary, and the counter is gone (#724). The heading is a label for the list
144
+ under it, not a section title competing with the card's name; and "0 of 2 done" was a
145
+ number nobody acts on the rows themselves say which are done, and they say it
146
+ without arithmetic. */}
147
+ <h2 className="text-xs text-muted-foreground">{i18n.t("board.links")}</h2>
158
148
  <ul className="flex flex-col">
159
149
  {links.map((link) => (
160
150
  <LinkRow key={`${link.kind}:${link.id}`} link={link} onOpen={onOpen} />
@@ -258,11 +248,14 @@ function Adder({
258
248
  return (
259
249
  <>
260
250
  <DropdownMenu>
251
+ {/* ⚠️ No border, square, and the icon at full strength (#724). A bordered pill here read
252
+ as one more chip, so the row looked like it carried a value called "+". Without the
253
+ border it is what it is: a control, quiet until the pointer is on it. */}
261
254
  <DropdownMenuTrigger
262
255
  aria-label={i18n.t("board.add")}
263
- className="rounded-full border px-2 py-0.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
256
+ className="flex size-6 shrink-0 items-center justify-center rounded-md text-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
264
257
  >
265
- <Plus aria-hidden="true" className="size-3" />
258
+ <Plus aria-hidden="true" className="size-4" />
266
259
  </DropdownMenuTrigger>
267
260
  <DropdownMenuContent align="start">
268
261
  {addable.map((entry) => (
package/src/i18n/de.json CHANGED
@@ -84,6 +84,7 @@
84
84
  "board.addCardIn": "Eine Aufgabe zu {column} hinzufügen",
85
85
  "board.addLabel": "Label hinzufügen",
86
86
  "board.addLink": "Verlinkung",
87
+ "board.assignSomebody": "Jemandem zuweisen",
87
88
  "board.assignee.clear": "Zuständigkeit entfernen",
88
89
  "board.assignee.keepTyping": "Mindestens zwei Zeichen tippen",
89
90
  "board.assignee.nobody": "Niemand mit Zugriff auf dieses Board passt dazu",
@@ -110,6 +111,7 @@
110
111
  "board.createFailed": "Die Aufgabe konnte nicht angelegt werden.",
111
112
  "board.crumbs": "Wo diese Karte sitzt",
112
113
  "board.crumbsFolded": "Die Ebenen dazwischen zeigen",
114
+ "board.dates.edit": "Zeitraum bearbeiten",
113
115
  "board.drag.lifted": "{card} aufgenommen",
114
116
  "board.drag.moved": "{card} nach {column} verschoben",
115
117
  "board.drag.putBack": "{card} zurückgelegt",
@@ -127,7 +129,6 @@
127
129
  "board.link.subtask": "Unteraufgabe",
128
130
  "board.links": "Verlinkt",
129
131
  "board.moveTo": "in eine andere Spalte verschieben",
130
- "board.progress": "{done} von {total} erledigt",
131
132
  "board.removeLabel": "dieses Label entfernen",
132
133
  "board.settings.addColumn": "+ Spalte",
133
134
  "board.settings.columnName": "Name der Spalte {column}",
package/src/i18n/en.json CHANGED
@@ -84,6 +84,7 @@
84
84
  "board.addCardIn": "Add a task to {column}",
85
85
  "board.addLabel": "Add a label",
86
86
  "board.addLink": "Link",
87
+ "board.assignSomebody": "Assign to somebody",
87
88
  "board.assignee.clear": "Remove the assignee",
88
89
  "board.assignee.keepTyping": "Type at least two characters",
89
90
  "board.assignee.nobody": "Nobody with access to this board matches",
@@ -110,6 +111,7 @@
110
111
  "board.createFailed": "The task could not be created.",
111
112
  "board.crumbs": "Where this card sits",
112
113
  "board.crumbsFolded": "Show the levels in between",
114
+ "board.dates.edit": "Edit the dates",
113
115
  "board.drag.lifted": "{card} picked up",
114
116
  "board.drag.moved": "{card} moved to {column}",
115
117
  "board.drag.putBack": "{card} put back",
@@ -127,7 +129,6 @@
127
129
  "board.link.subtask": "Subtask",
128
130
  "board.links": "Linked",
129
131
  "board.moveTo": "move it to another column",
130
- "board.progress": "{done} of {total} done",
131
132
  "board.removeLabel": "remove this label",
132
133
  "board.settings.addColumn": "+ Column",
133
134
  "board.settings.columnName": "Name of the column {column}",
package/src/i18n/es.json CHANGED
@@ -84,6 +84,7 @@
84
84
  "board.addCardIn": "Añadir una tarea a {column}",
85
85
  "board.addLabel": "Añadir etiqueta",
86
86
  "board.addLink": "Vínculo",
87
+ "board.assignSomebody": "Asignar a alguien",
87
88
  "board.assignee.clear": "Quitar el responsable",
88
89
  "board.assignee.keepTyping": "Escribe al menos dos caracteres",
89
90
  "board.assignee.nobody": "Nadie con acceso a este tablero coincide",
@@ -110,6 +111,7 @@
110
111
  "board.createFailed": "No se pudo crear la tarea.",
111
112
  "board.crumbs": "Dónde está esta tarjeta",
112
113
  "board.crumbsFolded": "Mostrar los niveles intermedios",
114
+ "board.dates.edit": "Editar las fechas",
113
115
  "board.drag.lifted": "{card} recogida",
114
116
  "board.drag.moved": "{card} movida a {column}",
115
117
  "board.drag.putBack": "{card} devuelta",
@@ -127,7 +129,6 @@
127
129
  "board.link.subtask": "Subtarea",
128
130
  "board.links": "Vinculado",
129
131
  "board.moveTo": "moverla a otra columna",
130
- "board.progress": "{done} de {total} hechas",
131
132
  "board.removeLabel": "quitar esta etiqueta",
132
133
  "board.settings.addColumn": "+ Columna",
133
134
  "board.settings.columnName": "Nombre de la columna {column}",
@@ -121,7 +121,12 @@ export function TitleRow({
121
121
  <div
122
122
  data-scrolled={scroll?.scrolled || undefined}
123
123
  className={cn(
124
- "relative z-10 flex shrink-0 items-start justify-between gap-5 px-6 py-4 transition-shadow",
124
+ "relative z-10 flex shrink-0 justify-between gap-5 px-6 py-4 transition-shadow",
125
+ // ⚠️ Two alignments for two shapes (#724). A document's name can carry a description under
126
+ // it, so its block starts at the top and the menu lines up with the FIRST line. A path is
127
+ // always one line, and starting it at the top leaves it sitting a hair above the icons it
128
+ // shares the row with. Centred, the two read as one line, which is what they are.
129
+ crumbs === undefined ? "items-start" : "items-center",
125
130
  (separated || scroll?.scrolled) && "shadow-[inset_0_-1px_0_var(--border)]",
126
131
  )}
127
132
  >