@hiroleague/taskmanager 0.0.1 → 0.0.2

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 (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -52
  3. package/dist/assets/architecture-YZFGNWBL-C1MoQeSs.js +1 -0
  4. package/dist/assets/{architectureDiagram-Q4EWVU46-DSQ1_74_.js → architectureDiagram-Q4EWVU46-DUEfvDBu.js} +1 -1
  5. package/dist/assets/{blockDiagram-DXYQGD6D-DfOGNphI.js → blockDiagram-DXYQGD6D-DQzEOPT2.js} +1 -1
  6. package/dist/assets/{chunk-2KRD3SAO-9yt00aGC.js → chunk-2KRD3SAO-C2e-_49I.js} +1 -1
  7. package/dist/assets/{chunk-4TB4RGXK-DF8yJBFl.js → chunk-4TB4RGXK-AZq3s1Dh.js} +1 -1
  8. package/dist/assets/{chunk-67CJDMHE-5wFKo04G.js → chunk-67CJDMHE-B1-M78qu.js} +1 -1
  9. package/dist/assets/{chunk-7N4EOEYR-BRRGX_NC.js → chunk-7N4EOEYR-D7mYFpz-.js} +1 -1
  10. package/dist/assets/{chunk-AA7GKIK3-DUZv_pNI.js → chunk-AA7GKIK3-VWI9k39i.js} +1 -1
  11. package/dist/assets/{chunk-CIAEETIT-mA5aM_d7.js → chunk-CIAEETIT-hnu4zamm.js} +1 -1
  12. package/dist/assets/{chunk-FOC6F5B3-B-cqGCPC.js → chunk-FOC6F5B3-BJsh9nO9.js} +1 -1
  13. package/dist/assets/{chunk-K5T4RW27-BLRDzioh.js → chunk-K5T4RW27-BLIPdXaZ.js} +1 -1
  14. package/dist/assets/{chunk-KGLVRYIC-CTkQSeKy.js → chunk-KGLVRYIC-DvaW2TkT.js} +1 -1
  15. package/dist/assets/{chunk-LIHQZDEY-Cf34Nu3J.js → chunk-LIHQZDEY-CUsM0M11.js} +1 -1
  16. package/dist/assets/{chunk-ORNJ4GCN-D3uXgbay.js → chunk-ORNJ4GCN-CfluNV0_.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-syQho5jf.js → chunk-OYMX7WX6-CkWzw4JX.js} +1 -1
  18. package/dist/assets/{classDiagram-6PBFFD2Q-CotFZI8-.js → classDiagram-6PBFFD2Q-Dx_f-9b7.js} +1 -1
  19. package/dist/assets/{classDiagram-v2-HSJHXN6E-DAPzeDGn.js → classDiagram-v2-HSJHXN6E-CSfvZ-nt.js} +1 -1
  20. package/dist/assets/clone-CXokakwV.js +1 -0
  21. package/dist/assets/{dagre-rhyPjnsQ.js → dagre-Do0eD9eI.js} +1 -1
  22. package/dist/assets/{dagre-KV5264BT-BBqulDtd.js → dagre-KV5264BT-lveZDhBf.js} +1 -1
  23. package/dist/assets/{diagram-5BDNPKRD-Ky3EXXj0.js → diagram-5BDNPKRD-Dq5yM_uY.js} +1 -1
  24. package/dist/assets/{diagram-G4DWMVQ6-t7LbT0Uz.js → diagram-G4DWMVQ6-D-SYOmKm.js} +1 -1
  25. package/dist/assets/{diagram-MMDJMWI5-CdnLXEMx.js → diagram-MMDJMWI5-lU5t9BZA.js} +1 -1
  26. package/dist/assets/{diagram-TYMM5635-CnzTqJBM.js → diagram-TYMM5635-6tfUbY3R.js} +1 -1
  27. package/dist/assets/{erDiagram-SMLLAGMA-BN5eJerP.js → erDiagram-SMLLAGMA-dx09stuy.js} +1 -1
  28. package/dist/assets/{flatten-C5NL-f24.js → flatten-B2BZ0pzY.js} +1 -1
  29. package/dist/assets/{flowDiagram-DWJPFMVM-CbFskc8S.js → flowDiagram-DWJPFMVM-CJi2WISS.js} +1 -1
  30. package/dist/assets/gitGraph-7Q5UKJZL-BXTuQaDM.js +1 -0
  31. package/dist/assets/{gitGraphDiagram-UUTBAWPF-wpqI2kyI.js → gitGraphDiagram-UUTBAWPF-Bjj94M12.js} +1 -1
  32. package/dist/assets/{graphlib-COiJG5Qv.js → graphlib-BIlXYGdM.js} +1 -1
  33. package/dist/assets/{index-lyyIVcc_.js → index-CZZuue3D.js} +5 -5
  34. package/dist/assets/info-OMHHGYJF-BeeKt8-X.js +1 -0
  35. package/dist/assets/{infoDiagram-42DDH7IO-BbvTdpSV.js → infoDiagram-42DDH7IO-wq_opQKO.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Epc23N_0.js → ishikawaDiagram-UXIWVN3A-Cnc1bwBo.js} +1 -1
  37. package/dist/assets/{kanban-definition-6JOO6SKY-C8dW_26n.js → kanban-definition-6JOO6SKY-CwHbIze0.js} +1 -1
  38. package/dist/assets/{mermaid-parser.core-6Tn8epr_.js → mermaid-parser.core-DrLhKJ48.js} +2 -2
  39. package/dist/assets/{mindmap-definition-QFDTVHPH-CvpNtrKT.js → mindmap-definition-QFDTVHPH-DswAJiEd.js} +1 -1
  40. package/dist/assets/packet-4T2RLAQJ-DQ-H9_jd.js +1 -0
  41. package/dist/assets/pie-ZZUOXDRM-BSj0Jsyj.js +1 -0
  42. package/dist/assets/{pieDiagram-DEJITSTG-eENymoXZ.js → pieDiagram-DEJITSTG-DgQTCddl.js} +1 -1
  43. package/dist/assets/radar-PYXPWWZC-B7-oRPFL.js +1 -0
  44. package/dist/assets/{reduce-BDOBPIXr.js → reduce-Uumu9GdR.js} +1 -1
  45. package/dist/assets/{requirementDiagram-MS252O5E-CmRO3hLp.js → requirementDiagram-MS252O5E-D1moa23Z.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-FGHM5R23-B7qNcwNo.js → sequenceDiagram-FGHM5R23-Dvhj7HGn.js} +1 -1
  47. package/dist/assets/{stateDiagram-FHFEXIEX-CYfGMoR8.js → stateDiagram-FHFEXIEX-Dx5CjenB.js} +1 -1
  48. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CO1W_n55.js → stateDiagram-v2-QKLJ7IA2-C_PkrTdc.js} +1 -1
  49. package/dist/assets/{timeline-definition-GMOUNBTQ-CQWqDPGG.js → timeline-definition-GMOUNBTQ-z-IncVmK.js} +1 -1
  50. package/dist/assets/treeView-SZITEDCU-CFXle9Az.js +1 -0
  51. package/dist/assets/treemap-W4RFUUIX-CAW3vWh8.js +1 -0
  52. package/dist/assets/{vennDiagram-DHZGUBPP-BjTbuhcb.js → vennDiagram-DHZGUBPP-CT1ehozU.js} +1 -1
  53. package/dist/assets/wardley-RL74JXVD-7q3ju4kc.js +1 -0
  54. package/dist/assets/{wardleyDiagram-NUSXRM2D-DNhPIFCg.js → wardleyDiagram-NUSXRM2D-D-kouujI.js} +1 -1
  55. package/dist/assets/{xychartDiagram-5P7HB3ND-BDblAZ11.js → xychartDiagram-5P7HB3ND-D1lnM0pL.js} +1 -1
  56. package/dist/index.html +1 -1
  57. package/package.json +101 -92
  58. package/scripts/postinstall-message.mjs +160 -0
  59. package/scripts/stubs/node-domexception/index.cjs +18 -0
  60. package/scripts/stubs/node-domexception/package.json +7 -0
  61. package/skills/hiro-task-manager-cli/SKILL.md +97 -0
  62. package/skills/hiro-task-manager-cli/reference/boards.md +143 -0
  63. package/skills/hiro-task-manager-cli/reference/cli-access-policy.md +72 -0
  64. package/skills/hiro-task-manager-cli/reference/errors.md +85 -0
  65. package/skills/hiro-task-manager-cli/reference/lists.md +106 -0
  66. package/skills/hiro-task-manager-cli/reference/releases.md +87 -0
  67. package/skills/hiro-task-manager-cli/reference/search.md +38 -0
  68. package/skills/hiro-task-manager-cli/reference/statuses.md +25 -0
  69. package/skills/hiro-task-manager-cli/reference/tasks.md +144 -0
  70. package/skills/hiro-task-manager-cli/reference/trash.md +50 -0
  71. package/src/cli/bootstrap/launcher.test.ts +66 -0
  72. package/src/cli/bootstrap/launcher.ts +375 -35
  73. package/src/cli/bootstrap/program.ts +4 -0
  74. package/src/cli/bootstrap/runtime.test.ts +15 -0
  75. package/src/cli/bootstrap/runtime.ts +27 -1
  76. package/src/cli/commands/query.ts +56 -56
  77. package/src/cli/commands/server.ts +27 -19
  78. package/src/cli/handlers/boards.test.ts +669 -669
  79. package/src/cli/handlers/cli-wiring.test.ts +1 -1
  80. package/src/cli/handlers/search.test.ts +374 -374
  81. package/src/cli/handlers/search.ts +17 -17
  82. package/src/cli/handlers/server.test.ts +55 -13
  83. package/src/cli/handlers/server.ts +16 -3
  84. package/src/cli/lib/api-client.test.ts +35 -2
  85. package/src/cli/lib/api-client.ts +43 -10
  86. package/src/cli/lib/cli-http-errors.test.ts +85 -85
  87. package/src/cli/lib/command-helpers.ts +161 -154
  88. package/src/cli/lib/config.ts +4 -0
  89. package/src/cli/lib/launcherUi.ts +166 -0
  90. package/src/cli/lib/process.test.ts +24 -5
  91. package/src/cli/lib/process.ts +86 -55
  92. package/src/cli/ports/process.ts +8 -2
  93. package/src/cli/subprocess.real-stack.test.ts +611 -598
  94. package/src/cli/subprocess.smoke.test.ts +954 -969
  95. package/src/cli/types/config.ts +2 -6
  96. package/src/client/components/auth/AuthScreen.tsx +3 -3
  97. package/src/client/components/board/BoardStatsChips.tsx +233 -233
  98. package/src/client/components/board/BoardStatsContext.tsx +41 -41
  99. package/src/client/components/board/boardHeaderButtonStyles.ts +38 -38
  100. package/src/client/components/board/shortcuts/useBoardShortcutKeydown.ts +49 -49
  101. package/src/client/components/board/useBoardCanvasPanScroll.ts +108 -108
  102. package/src/client/components/board/useBoardTaskContainerDroppableReact.ts +33 -33
  103. package/src/client/components/board/useBoardTaskSortableReact.ts +26 -26
  104. package/src/client/components/multi-select.tsx +1206 -1206
  105. package/src/client/components/routing/BoardPage.tsx +20 -20
  106. package/src/client/components/routing/NavigationRegistrar.tsx +13 -13
  107. package/src/client/components/settings/SettingsPage.tsx +1 -1
  108. package/src/client/components/task/TaskCard.tsx +643 -643
  109. package/src/client/components/ui/badge.tsx +49 -49
  110. package/src/client/components/ui/button.tsx +65 -65
  111. package/src/client/components/ui/command.tsx +193 -193
  112. package/src/client/components/ui/dialog.tsx +163 -163
  113. package/src/client/components/ui/input-group.tsx +155 -155
  114. package/src/client/components/ui/input.tsx +19 -19
  115. package/src/client/components/ui/popover.tsx +87 -87
  116. package/src/client/components/ui/separator.tsx +28 -28
  117. package/src/client/components/ui/textarea.tsx +18 -18
  118. package/src/client/index.css +248 -248
  119. package/src/client/lib/appNavigate.ts +16 -16
  120. package/src/client/lib/taskCardDate.ts +111 -111
  121. package/src/client/lib/utils.ts +6 -6
  122. package/src/server/auth.ts +351 -302
  123. package/src/server/bootstrapDev.ts +11 -2
  124. package/src/server/bootstrapInstalled.ts +6 -1
  125. package/src/server/index.ts +33 -7
  126. package/src/server/migrations/013_cli_policy_and_provenance.ts +2 -2
  127. package/src/server/migrations/019_cli_global_create_board_default_on.ts +14 -0
  128. package/src/server/migrations/registry.ts +43 -41
  129. package/src/server/parseBootstrapProfile.ts +42 -0
  130. package/src/server/storage/cliPolicy.ts +2 -1
  131. package/src/shared/runtimeConfig.ts +256 -237
  132. package/src/shared/runtimeIdentity.test.ts +47 -0
  133. package/src/shared/runtimeIdentity.ts +35 -0
  134. package/src/shared/serverStatus.ts +21 -0
  135. package/src/shared/skillsInstall.ts +71 -0
  136. package/src/shared/terminalColors.ts +24 -0
  137. package/dist/assets/architecture-YZFGNWBL-3h1eIYfB.js +0 -1
  138. package/dist/assets/clone-BRQpYu_n.js +0 -1
  139. package/dist/assets/gitGraph-7Q5UKJZL-CG8f8JF7.js +0 -1
  140. package/dist/assets/info-OMHHGYJF-C8_SHoRO.js +0 -1
  141. package/dist/assets/packet-4T2RLAQJ-BvpAX0kJ.js +0 -1
  142. package/dist/assets/pie-ZZUOXDRM-Ow26Yf-E.js +0 -1
  143. package/dist/assets/radar-PYXPWWZC-e_ron5jQ.js +0 -1
  144. package/dist/assets/treeView-SZITEDCU-DsEr3xeq.js +0 -1
  145. package/dist/assets/treemap-W4RFUUIX-DV7nk2AB.js +0 -1
  146. package/dist/assets/wardley-RL74JXVD-CrrFU9AE.js +0 -1
@@ -1,643 +1,643 @@
1
- import {
2
- memo,
3
- useLayoutEffect,
4
- useRef,
5
- type CSSProperties,
6
- type ReactNode,
7
- } from "react";
8
- import { Bot, Check, Clock } from "lucide-react";
9
- import {
10
- NONE_TASK_PRIORITY_VALUE,
11
- priorityDisplayLabel,
12
- formatTaskIdForDisplay,
13
- taskDisplayTitleOnCard,
14
- type Board,
15
- type Task,
16
- type TaskPriorityDefinition,
17
- type TaskStatus,
18
- } from "../../../shared/models";
19
- import { useBoardKeyboardNavOptional } from "@/components/board/shortcuts/BoardKeyboardNavContext";
20
- import {
21
- getTaskCardViewSpec,
22
- type TaskCardViewMode,
23
- } from "@/store/preferences";
24
- import { cn } from "@/lib/utils";
25
- import {
26
- formatTaskCardDateTooltip,
27
- getTaskCardRelativeDateParts,
28
- getTaskCardTimeline,
29
- } from "@/lib/taskCardDate";
30
- import { clampTaskTitleInput } from "../../../shared/taskTitle";
31
-
32
- function taskCardBodyPaddingClass(viewMode: TaskCardViewMode): string {
33
- return viewMode === "small" ? "px-2 py-2" : "px-2.5 py-2";
34
- }
35
-
36
- function previewBody(body: string, max = 100): string {
37
- const plain = body.replace(/\s+/g, " ").trim();
38
- if (!plain) return "";
39
- return plain.length > max ? `${plain.slice(0, max)}…` : plain;
40
- }
41
-
42
- function autoSizeInlineTitleTextarea(textarea: HTMLTextAreaElement | null): void {
43
- if (!textarea) return;
44
- textarea.style.height = "auto";
45
- const computed = window.getComputedStyle(textarea);
46
- const lineHeight = Number.parseFloat(computed.lineHeight) || 20;
47
- const paddingY =
48
- (Number.parseFloat(computed.paddingTop) || 0) +
49
- (Number.parseFloat(computed.paddingBottom) || 0);
50
- const borderY =
51
- (Number.parseFloat(computed.borderTopWidth) || 0) +
52
- (Number.parseFloat(computed.borderBottomWidth) || 0);
53
- const maxHeight = lineHeight * 3 + paddingY + borderY;
54
- const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
55
- textarea.style.height = `${nextHeight}px`;
56
- textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
57
- }
58
-
59
- function statusAriaLabel(status: TaskStatus): string {
60
- switch (status) {
61
- case "open":
62
- return "Open";
63
- case "in-progress":
64
- return "In progress";
65
- case "closed":
66
- return "Closed";
67
- default:
68
- return status || "Status";
69
- }
70
- }
71
-
72
- function OpenStatusCircle() {
73
- return (
74
- <span
75
- className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center"
76
- aria-hidden
77
- >
78
- <span className="size-3.5 rounded-full border-2 border-muted-foreground/55 bg-transparent" />
79
- </span>
80
- );
81
- }
82
-
83
- function TaskStatusIndicator({ status }: { status: TaskStatus }) {
84
- const label = statusAriaLabel(status);
85
- return (
86
- <span
87
- className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center"
88
- aria-hidden
89
- >
90
- {status === "open" ? (
91
- <span
92
- className="size-3.5 rounded-full border-2 border-muted-foreground/55 bg-transparent"
93
- title={label}
94
- />
95
- ) : null}
96
- {status === "in-progress" ? (
97
- <span
98
- className="size-3.5 rounded-full bg-amber-400 shadow-sm dark:bg-amber-500"
99
- title={label}
100
- />
101
- ) : null}
102
- {status === "closed" ? (
103
- <span
104
- className="flex size-3.5 items-center justify-center rounded-full bg-emerald-500 text-white shadow-sm dark:bg-emerald-600"
105
- title={label}
106
- >
107
- <Check className="size-2.5 stroke-[3]" aria-hidden />
108
- </span>
109
- ) : null}
110
- {status !== "open" &&
111
- status !== "in-progress" &&
112
- status !== "closed" ? (
113
- <span
114
- className="size-3.5 rounded-full bg-muted-foreground/35"
115
- title={label}
116
- />
117
- ) : null}
118
- </span>
119
- );
120
- }
121
-
122
- interface TaskCardProps {
123
- task: Task;
124
- taskPriorities: TaskPriorityDefinition[];
125
- viewMode: TaskCardViewMode;
126
- /** Display label for `task.groupId` (resolved from board definitions). */
127
- groupLabel: string;
128
- /** Optional release chip (same styling as priority when color is set). */
129
- releasePill?: { label: string; color?: string | null } | null;
130
- onOpen: () => void;
131
- /** When true, render an inline title-only editor instead of the normal task title. */
132
- editingTitle?: boolean;
133
- titleDraft?: string;
134
- onTitleDraftChange?: (value: string) => void;
135
- onTitleCommit?: () => void;
136
- onTitleCancel?: () => void;
137
- titleEditBusy?: boolean;
138
- /** When set, only for `open` tasks: click the empty circle to complete (does not open the editor). */
139
- onCompleteFromCircle?: (anchorEl: HTMLElement) => void;
140
- /** When true, dim the card to indicate it's being dragged. */
141
- isDragging?: boolean;
142
- /**
143
- * When false (default), this card registers its root for keyboard scroll targeting.
144
- * SortableTaskRow sets true so only the row wrapper registers once per task.
145
- */
146
- skipNavRegistration?: boolean;
147
- }
148
-
149
- /** Shown when the task was created by the CLI principal (Phase 2 provenance). */
150
- function CliCreatedIndicator({
151
- task,
152
- compact,
153
- }: {
154
- task: Task;
155
- /** Smaller icon for dense / small card layout. */
156
- compact?: boolean;
157
- }) {
158
- if (task.createdByPrincipal !== "cli") return null;
159
- const tip =
160
- task.createdByLabel?.trim() || "Created via hirotm CLI";
161
- return (
162
- <span
163
- className="inline-flex shrink-0 text-muted-foreground"
164
- title={tip}
165
- aria-label={tip}
166
- >
167
- <Bot
168
- className={compact ? "size-3" : "size-3.5"}
169
- strokeWidth={2}
170
- aria-hidden
171
- />
172
- </span>
173
- );
174
- }
175
-
176
- /** Light priority swatches need a border so the pill stays visible on light card backgrounds. */
177
- function isVeryLightPriorityBackground(color: string): boolean {
178
- const c = color.trim().toLowerCase();
179
- return c === "#ffffff" || c === "#fff" || c === "white";
180
- }
181
-
182
- /** Resolved release label/color for a task, or null when unassigned / unknown id. */
183
- export function taskReleasePill(
184
- board: Pick<Board, "releases">,
185
- task: Pick<Task, "releaseId">,
186
- ): { label: string; color?: string | null } | null {
187
- const rid = task.releaseId;
188
- if (rid == null) return null;
189
- const r = board.releases.find((x) => x.releaseId === rid);
190
- if (!r) return null;
191
- return { label: r.name, color: r.color };
192
- }
193
-
194
- /** Bold “r” + space before release name in chips (priority pills stay unchanged). */
195
- function ReleaseChipPrefix(): ReactNode {
196
- return (
197
- <>
198
- <span className="font-bold">r&nbsp;&nbsp;</span>
199
- </>
200
- );
201
- }
202
-
203
- function releaseChipTitle(label: string): string {
204
- return `r ${label.trim()}`;
205
- }
206
-
207
- /**
208
- * Open/created vs closed timestamp for large/larger task cards.
209
- * Returns a bare `<span>` — the caller decides placement (metadata row, inline after preview, etc.).
210
- */
211
- function TaskCardTimelineChip({
212
- task,
213
- className,
214
- }: {
215
- task: Task;
216
- className?: string;
217
- }) {
218
- const timeline = getTaskCardTimeline(task);
219
- if (!timeline) return null;
220
- const { label: compact, showRecentDot } = getTaskCardRelativeDateParts(timeline.iso);
221
- if (!compact) return null;
222
- const tip = formatTaskCardDateTooltip(timeline.kind, timeline.iso);
223
- return (
224
- <span
225
- className={cn(
226
- "inline-flex items-center gap-0.5 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground/80",
227
- className,
228
- )}
229
- title={tip}
230
- aria-label={tip}
231
- >
232
- {showRecentDot ? (
233
- <span
234
- className="size-1.5 shrink-0 rounded-full bg-blue-500 dark:bg-blue-400"
235
- aria-hidden
236
- />
237
- ) : (
238
- <Clock className="size-2.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
239
- )}
240
- {compact}
241
- </span>
242
- );
243
- }
244
-
245
- function PriorityPill({
246
- label,
247
- color,
248
- prefixNode,
249
- }: {
250
- label: string;
251
- color: string;
252
- /** Rendered before the display label (not passed through `priorityDisplayLabel`). */
253
- prefixNode?: ReactNode;
254
- }) {
255
- const displayLabel = priorityDisplayLabel(label);
256
- if (!displayLabel) return null;
257
- const light = isVeryLightPriorityBackground(color);
258
- return (
259
- <span
260
- className={cn(
261
- "inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
262
- light
263
- ? "border border-border/70 text-foreground"
264
- : "text-black/85",
265
- )}
266
- title={prefixNode != null ? releaseChipTitle(label) : label}
267
- style={{
268
- backgroundColor: color,
269
- }}
270
- >
271
- {prefixNode}
272
- {displayLabel}
273
- </span>
274
- );
275
- }
276
-
277
- /**
278
- * Content section shared by both open and non-open card layouts.
279
- * Extracted so the two branches render identical markup for the text area.
280
- */
281
- function TaskCardContent({
282
- task,
283
- taskPriorities,
284
- viewMode,
285
- groupLabel,
286
- releasePill,
287
- preview,
288
- onOpen,
289
- editingTitle = false,
290
- titleDraft = "",
291
- onTitleDraftChange,
292
- onTitleCommit,
293
- onTitleCancel,
294
- titleEditBusy = false,
295
- }: {
296
- task: Task;
297
- taskPriorities: TaskPriorityDefinition[];
298
- viewMode: TaskCardViewMode;
299
- groupLabel: string;
300
- releasePill?: { label: string; color?: string | null } | null;
301
- preview: string;
302
- onOpen: () => void;
303
- editingTitle?: boolean;
304
- titleDraft?: string;
305
- onTitleDraftChange?: (value: string) => void;
306
- onTitleCommit?: () => void;
307
- onTitleCancel?: () => void;
308
- titleEditBusy?: boolean;
309
- }) {
310
- const viewSpec = getTaskCardViewSpec(viewMode);
311
- const priorityRow = taskPriorities.find((p) => p.priorityId === task.priorityId);
312
- // Default builtin `none` is not surfaced as a chip (same UX as “no priority”).
313
- const showPriorityPill =
314
- priorityRow != null && priorityRow.value !== NONE_TASK_PRIORITY_VALUE;
315
- const showReleasePill =
316
- releasePill != null && releasePill.label.trim().length > 0;
317
- const titleInputRef = useRef<HTMLTextAreaElement>(null);
318
- const titleBlurModeRef = useRef<"commit" | "cancel">("commit");
319
-
320
- useLayoutEffect(() => {
321
- if (!editingTitle) return;
322
- titleInputRef.current?.focus();
323
- titleInputRef.current?.select();
324
- titleBlurModeRef.current = "commit";
325
- }, [editingTitle, task.taskId]);
326
-
327
- useLayoutEffect(() => {
328
- if (!editingTitle) return;
329
- autoSizeInlineTitleTextarea(titleInputRef.current);
330
- }, [editingTitle, titleDraft]);
331
-
332
- return (
333
- <div
334
- className="min-w-0 flex-1 text-left"
335
- onClick={editingTitle ? undefined : onOpen}
336
- >
337
- {editingTitle ? (
338
- <div className="flex min-w-0 gap-1.5">
339
- {task.emoji ? (
340
- <span
341
- className="shrink-0 text-lg leading-tight"
342
- aria-hidden
343
- >
344
- {task.emoji}
345
- </span>
346
- ) : null}
347
- {/* Inline rename auto-fits up to three lines without reselecting text while typing. */}
348
- <textarea
349
- ref={titleInputRef}
350
- rows={3}
351
- className={cn(
352
- "min-w-0 flex-1 resize-y rounded border border-input bg-background px-2 py-1 text-foreground select-text",
353
- viewSpec.titleClassName,
354
- )}
355
- value={titleDraft}
356
- disabled={titleEditBusy}
357
- onChange={(e) => {
358
- // Auto-grow up to three lines, then keep the native resize handle available.
359
- autoSizeInlineTitleTextarea(e.currentTarget);
360
- onTitleDraftChange?.(clampTaskTitleInput(e.target.value));
361
- }}
362
- onPointerDown={(e) => e.stopPropagation()}
363
- onClick={(e) => e.stopPropagation()}
364
- onBlur={() => {
365
- if (titleBlurModeRef.current === "cancel") {
366
- titleBlurModeRef.current = "commit";
367
- return;
368
- }
369
- onTitleCommit?.();
370
- }}
371
- onKeyDown={(e) => {
372
- if (e.key === "Enter") {
373
- e.preventDefault();
374
- onTitleCommit?.();
375
- }
376
- if (e.key === "Escape") {
377
- e.preventDefault();
378
- titleBlurModeRef.current = "cancel";
379
- onTitleCancel?.();
380
- }
381
- }}
382
- />
383
- </div>
384
- ) : (
385
- <div
386
- className={cn(
387
- "flex min-w-0 items-start gap-1.5",
388
- viewMode === "small" && "items-center",
389
- )}
390
- >
391
- <div
392
- className={cn(
393
- "min-w-0 flex-1 font-medium",
394
- viewSpec.titleClassName,
395
- )}
396
- >
397
- {taskDisplayTitleOnCard(task)}
398
- </div>
399
- {viewMode === "small" ? (
400
- <CliCreatedIndicator task={task} compact />
401
- ) : null}
402
- </div>
403
- )}
404
- {viewMode !== "small" ? (
405
- <div className="mt-1 flex flex-wrap items-center gap-2 text-[10px]">
406
- <span className="uppercase tracking-wide text-muted-foreground/80">
407
- {groupLabel}
408
- </span>
409
- {showPriorityPill && priorityRow ? (
410
- <PriorityPill label={priorityRow.label} color={priorityRow.color} />
411
- ) : null}
412
- {showReleasePill && releasePill?.color ? (
413
- <PriorityPill
414
- label={releasePill.label}
415
- color={releasePill.color}
416
- prefixNode={<ReleaseChipPrefix />}
417
- />
418
- ) : showReleasePill ? (
419
- <span
420
- className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
421
- title={releaseChipTitle(releasePill!.label)}
422
- >
423
- <ReleaseChipPrefix />
424
- {releasePill!.label}
425
- </span>
426
- ) : null}
427
- {viewMode === "large" || viewMode === "larger" ? (
428
- <span
429
- className="text-muted-foreground/55"
430
- title={`Task id ${formatTaskIdForDisplay(task.taskId)}`}
431
- >
432
- #{formatTaskIdForDisplay(task.taskId)}
433
- </span>
434
- ) : null}
435
- <CliCreatedIndicator task={task} />
436
- {/* Date chip in metadata row when there is no preview body below (large mode). */}
437
- {!preview && (viewMode === "large" || viewMode === "larger") ? (
438
- <TaskCardTimelineChip task={task} className="ml-auto" />
439
- ) : null}
440
- </div>
441
- ) : null}
442
- {/* Larger mode: preview body + date at bottom-right.
443
- An invisible inline spacer reserves room on the last text line;
444
- when text fills that line the spacer wraps, creating vertical space.
445
- The date is absolutely positioned at bottom-right over the spacer.
446
- No line-clamp here — JS truncation (previewBody) controls length,
447
- avoiding the double-ellipsis caused by CSS clamp + JS "…". */}
448
- {preview && (viewMode === "large" || viewMode === "larger") ? (
449
- <p className="relative mt-1 text-xs text-muted-foreground">
450
- {preview}
451
- {getTaskCardTimeline(task) != null ? (
452
- <>
453
- {/* Reserve width for clock + longest relative labels (e.g. “3 days ago”). */}
454
- <span
455
- className="inline-block h-4 w-24 select-none align-bottom"
456
- aria-hidden
457
- >
458
- {"\u00A0"}
459
- </span>
460
- <TaskCardTimelineChip
461
- task={task}
462
- className="absolute bottom-0 right-0"
463
- />
464
- </>
465
- ) : null}
466
- </p>
467
- ) : preview ? (
468
- <div
469
- className={cn(
470
- "mt-1 text-xs text-muted-foreground",
471
- viewSpec.previewClassName,
472
- )}
473
- >
474
- {preview}
475
- </div>
476
- ) : null}
477
- </div>
478
- );
479
- }
480
-
481
- const TASK_CARD_INLINE_PADDING_REM = 0.625;
482
- const TASK_CARD_STATUS_SLOT_REM = 1.375;
483
- const TASK_CARD_RIGHT_SLOT_REM = 0.25;
484
-
485
- function openTaskContentRailStyle(viewMode: TaskCardViewMode): CSSProperties {
486
- const inlinePaddingRem = viewMode === "small" ? 0.5 : TASK_CARD_INLINE_PADDING_REM;
487
- const blockPaddingRem = viewMode === "small" ? 0.375 : 0.5;
488
- return {
489
- // Keep the movement distance and content width derived from the same slots
490
- // so view-mode changes do not expose part of the hidden open circle or shift it vertically.
491
- ["--task-card-inline-padding" as string]: `${inlinePaddingRem}rem`,
492
- ["--task-card-block-padding" as string]: `${blockPaddingRem}rem`,
493
- ["--task-card-status-slot" as string]: `${TASK_CARD_STATUS_SLOT_REM}rem`,
494
- ["--task-card-right-slot" as string]: `${TASK_CARD_RIGHT_SLOT_REM}rem`,
495
- width:
496
- "calc(100% - var(--task-card-status-slot) - var(--task-card-right-slot))",
497
- };
498
- }
499
-
500
- // Memoized to avoid re-rendering every card on each drag-over event
501
- export const TaskCard = memo(function TaskCard({
502
- task,
503
- taskPriorities,
504
- viewMode,
505
- groupLabel,
506
- releasePill = null,
507
- onOpen,
508
- editingTitle = false,
509
- titleDraft,
510
- onTitleDraftChange,
511
- onTitleCommit,
512
- onTitleCancel,
513
- titleEditBusy = false,
514
- onCompleteFromCircle,
515
- isDragging,
516
- skipNavRegistration = false,
517
- }: TaskCardProps) {
518
- const nav = useBoardKeyboardNavOptional();
519
- const rootRef = useRef<HTMLDivElement>(null);
520
- const viewSpec = getTaskCardViewSpec(viewMode);
521
-
522
- useLayoutEffect(() => {
523
- if (skipNavRegistration || !nav) return;
524
- const el = rootRef.current;
525
- if (el) nav.registerTaskElement(task.taskId, el);
526
- return () => {
527
- nav.registerTaskElement(task.taskId, null);
528
- };
529
- }, [nav, skipNavRegistration, task.taskId]);
530
-
531
- const preview = viewSpec.showDescriptionPreview
532
- ? previewBody(task.body, viewSpec.previewMaxLength)
533
- : "";
534
- const canCompleteFromCircle =
535
- task.status === "open" && onCompleteFromCircle !== undefined;
536
- const isOpenTask = task.status === "open";
537
- const openTaskRailStyle = openTaskContentRailStyle(viewMode);
538
- const bodyPaddingClass = taskCardBodyPaddingClass(viewMode);
539
-
540
- return (
541
- <div
542
- ref={rootRef}
543
- data-task-card-root
544
- data-task-id={task.taskId}
545
- onPointerEnter={(e) => {
546
- if (e.pointerType !== "mouse" || skipNavRegistration || !nav) return;
547
- nav.setHoveredTaskId(task.taskId);
548
- }}
549
- onPointerLeave={(e) => {
550
- if (e.pointerType !== "mouse" || skipNavRegistration || !nav) return;
551
- nav.setHoveredTaskId(null);
552
- }}
553
- onPointerDown={() => {
554
- if (editingTitle) return;
555
- // Clicking into a task should make it current before any editor/dialog opens.
556
- nav?.selectTask(task.taskId);
557
- }}
558
- className={cn(
559
- "group/task-card relative w-full overflow-hidden rounded-md border border-border bg-task-card text-sm text-task-card-foreground shadow-sm transition-colors select-none",
560
- "hover:bg-accent/45",
561
- task.color && "border-l-4",
562
- isDragging && "opacity-40",
563
- )}
564
- style={task.color ? { borderLeftColor: task.color } : undefined}
565
- >
566
- {isOpenTask ? (
567
- <div
568
- className={cn("relative", bodyPaddingClass)}
569
- style={openTaskRailStyle}
570
- >
571
- {canCompleteFromCircle ? (
572
- <button
573
- type="button"
574
- data-task-complete-button
575
- className={cn(
576
- "absolute left-[var(--task-card-inline-padding)] top-[var(--task-card-block-padding)] inline-flex w-[var(--task-card-status-slot)] items-start justify-start rounded-sm opacity-0 outline-none transition-opacity duration-150 ring-offset-background pointer-events-none group-hover/task-card:pointer-events-auto group-hover/task-card:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring",
577
- )}
578
- aria-label="Mark complete"
579
- title="Mark complete"
580
- onPointerDown={(e) => e.stopPropagation()}
581
- onClick={(e) => {
582
- e.stopPropagation();
583
- // Completing from the card is still a task interaction, so keep
584
- // this task current before applying the status change.
585
- nav?.selectTask(task.taskId);
586
- onCompleteFromCircle(e.currentTarget);
587
- }}
588
- >
589
- <OpenStatusCircle />
590
- </button>
591
- ) : (
592
- <div className="absolute left-[var(--task-card-inline-padding)] top-[var(--task-card-block-padding)] inline-flex w-[var(--task-card-status-slot)] items-start justify-start">
593
- <TaskStatusIndicator status={task.status} />
594
- </div>
595
- )}
596
- <div
597
- className={cn(
598
- "min-w-0 translate-x-0 transition-transform duration-150 ease-out group-hover/task-card:translate-x-[var(--task-card-status-slot)]",
599
- )}
600
- >
601
- <TaskCardContent
602
- task={task}
603
- taskPriorities={taskPriorities}
604
- viewMode={viewMode}
605
- groupLabel={groupLabel}
606
- releasePill={releasePill}
607
- preview={preview}
608
- onOpen={onOpen}
609
- editingTitle={editingTitle}
610
- titleDraft={titleDraft}
611
- onTitleDraftChange={onTitleDraftChange}
612
- onTitleCommit={onTitleCommit}
613
- onTitleCancel={onTitleCancel}
614
- titleEditBusy={titleEditBusy}
615
- />
616
- </div>
617
- </div>
618
- ) : (
619
- // Non-open tasks: normal flex layout with status always visible.
620
- <div className={cn("flex gap-2", bodyPaddingClass)}>
621
- <div className="shrink-0">
622
- <TaskStatusIndicator status={task.status} />
623
- </div>
624
- <TaskCardContent
625
- task={task}
626
- taskPriorities={taskPriorities}
627
- viewMode={viewMode}
628
- groupLabel={groupLabel}
629
- releasePill={releasePill}
630
- preview={preview}
631
- onOpen={onOpen}
632
- editingTitle={editingTitle}
633
- titleDraft={titleDraft}
634
- onTitleDraftChange={onTitleDraftChange}
635
- onTitleCommit={onTitleCommit}
636
- onTitleCancel={onTitleCancel}
637
- titleEditBusy={titleEditBusy}
638
- />
639
- </div>
640
- )}
641
- </div>
642
- );
643
- });
1
+ import {
2
+ memo,
3
+ useLayoutEffect,
4
+ useRef,
5
+ type CSSProperties,
6
+ type ReactNode,
7
+ } from "react";
8
+ import { Bot, Check, Clock } from "lucide-react";
9
+ import {
10
+ NONE_TASK_PRIORITY_VALUE,
11
+ priorityDisplayLabel,
12
+ formatTaskIdForDisplay,
13
+ taskDisplayTitleOnCard,
14
+ type Board,
15
+ type Task,
16
+ type TaskPriorityDefinition,
17
+ type TaskStatus,
18
+ } from "../../../shared/models";
19
+ import { useBoardKeyboardNavOptional } from "@/components/board/shortcuts/BoardKeyboardNavContext";
20
+ import {
21
+ getTaskCardViewSpec,
22
+ type TaskCardViewMode,
23
+ } from "@/store/preferences";
24
+ import { cn } from "@/lib/utils";
25
+ import {
26
+ formatTaskCardDateTooltip,
27
+ getTaskCardRelativeDateParts,
28
+ getTaskCardTimeline,
29
+ } from "@/lib/taskCardDate";
30
+ import { clampTaskTitleInput } from "../../../shared/taskTitle";
31
+
32
+ function taskCardBodyPaddingClass(viewMode: TaskCardViewMode): string {
33
+ return viewMode === "small" ? "px-2 py-2" : "px-2.5 py-2";
34
+ }
35
+
36
+ function previewBody(body: string, max = 100): string {
37
+ const plain = body.replace(/\s+/g, " ").trim();
38
+ if (!plain) return "";
39
+ return plain.length > max ? `${plain.slice(0, max)}…` : plain;
40
+ }
41
+
42
+ function autoSizeInlineTitleTextarea(textarea: HTMLTextAreaElement | null): void {
43
+ if (!textarea) return;
44
+ textarea.style.height = "auto";
45
+ const computed = window.getComputedStyle(textarea);
46
+ const lineHeight = Number.parseFloat(computed.lineHeight) || 20;
47
+ const paddingY =
48
+ (Number.parseFloat(computed.paddingTop) || 0) +
49
+ (Number.parseFloat(computed.paddingBottom) || 0);
50
+ const borderY =
51
+ (Number.parseFloat(computed.borderTopWidth) || 0) +
52
+ (Number.parseFloat(computed.borderBottomWidth) || 0);
53
+ const maxHeight = lineHeight * 3 + paddingY + borderY;
54
+ const nextHeight = Math.min(textarea.scrollHeight, maxHeight);
55
+ textarea.style.height = `${nextHeight}px`;
56
+ textarea.style.overflowY = textarea.scrollHeight > maxHeight ? "auto" : "hidden";
57
+ }
58
+
59
+ function statusAriaLabel(status: TaskStatus): string {
60
+ switch (status) {
61
+ case "open":
62
+ return "Open";
63
+ case "in-progress":
64
+ return "In progress";
65
+ case "closed":
66
+ return "Closed";
67
+ default:
68
+ return status || "Status";
69
+ }
70
+ }
71
+
72
+ function OpenStatusCircle() {
73
+ return (
74
+ <span
75
+ className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center"
76
+ aria-hidden
77
+ >
78
+ <span className="size-3.5 rounded-full border-2 border-muted-foreground/55 bg-transparent" />
79
+ </span>
80
+ );
81
+ }
82
+
83
+ function TaskStatusIndicator({ status }: { status: TaskStatus }) {
84
+ const label = statusAriaLabel(status);
85
+ return (
86
+ <span
87
+ className="mt-0.5 inline-flex size-4 shrink-0 items-center justify-center"
88
+ aria-hidden
89
+ >
90
+ {status === "open" ? (
91
+ <span
92
+ className="size-3.5 rounded-full border-2 border-muted-foreground/55 bg-transparent"
93
+ title={label}
94
+ />
95
+ ) : null}
96
+ {status === "in-progress" ? (
97
+ <span
98
+ className="size-3.5 rounded-full bg-amber-400 shadow-sm dark:bg-amber-500"
99
+ title={label}
100
+ />
101
+ ) : null}
102
+ {status === "closed" ? (
103
+ <span
104
+ className="flex size-3.5 items-center justify-center rounded-full bg-emerald-500 text-white shadow-sm dark:bg-emerald-600"
105
+ title={label}
106
+ >
107
+ <Check className="size-2.5 stroke-[3]" aria-hidden />
108
+ </span>
109
+ ) : null}
110
+ {status !== "open" &&
111
+ status !== "in-progress" &&
112
+ status !== "closed" ? (
113
+ <span
114
+ className="size-3.5 rounded-full bg-muted-foreground/35"
115
+ title={label}
116
+ />
117
+ ) : null}
118
+ </span>
119
+ );
120
+ }
121
+
122
+ interface TaskCardProps {
123
+ task: Task;
124
+ taskPriorities: TaskPriorityDefinition[];
125
+ viewMode: TaskCardViewMode;
126
+ /** Display label for `task.groupId` (resolved from board definitions). */
127
+ groupLabel: string;
128
+ /** Optional release chip (same styling as priority when color is set). */
129
+ releasePill?: { label: string; color?: string | null } | null;
130
+ onOpen: () => void;
131
+ /** When true, render an inline title-only editor instead of the normal task title. */
132
+ editingTitle?: boolean;
133
+ titleDraft?: string;
134
+ onTitleDraftChange?: (value: string) => void;
135
+ onTitleCommit?: () => void;
136
+ onTitleCancel?: () => void;
137
+ titleEditBusy?: boolean;
138
+ /** When set, only for `open` tasks: click the empty circle to complete (does not open the editor). */
139
+ onCompleteFromCircle?: (anchorEl: HTMLElement) => void;
140
+ /** When true, dim the card to indicate it's being dragged. */
141
+ isDragging?: boolean;
142
+ /**
143
+ * When false (default), this card registers its root for keyboard scroll targeting.
144
+ * SortableTaskRow sets true so only the row wrapper registers once per task.
145
+ */
146
+ skipNavRegistration?: boolean;
147
+ }
148
+
149
+ /** Shown when the task was created by the CLI principal (Phase 2 provenance). */
150
+ function CliCreatedIndicator({
151
+ task,
152
+ compact,
153
+ }: {
154
+ task: Task;
155
+ /** Smaller icon for dense / small card layout. */
156
+ compact?: boolean;
157
+ }) {
158
+ if (task.createdByPrincipal !== "cli") return null;
159
+ const tip =
160
+ task.createdByLabel?.trim() || "Created via hirotm CLI";
161
+ return (
162
+ <span
163
+ className="inline-flex shrink-0 text-muted-foreground"
164
+ title={tip}
165
+ aria-label={tip}
166
+ >
167
+ <Bot
168
+ className={compact ? "size-3" : "size-3.5"}
169
+ strokeWidth={2}
170
+ aria-hidden
171
+ />
172
+ </span>
173
+ );
174
+ }
175
+
176
+ /** Light priority swatches need a border so the pill stays visible on light card backgrounds. */
177
+ function isVeryLightPriorityBackground(color: string): boolean {
178
+ const c = color.trim().toLowerCase();
179
+ return c === "#ffffff" || c === "#fff" || c === "white";
180
+ }
181
+
182
+ /** Resolved release label/color for a task, or null when unassigned / unknown id. */
183
+ export function taskReleasePill(
184
+ board: Pick<Board, "releases">,
185
+ task: Pick<Task, "releaseId">,
186
+ ): { label: string; color?: string | null } | null {
187
+ const rid = task.releaseId;
188
+ if (rid == null) return null;
189
+ const r = board.releases.find((x) => x.releaseId === rid);
190
+ if (!r) return null;
191
+ return { label: r.name, color: r.color };
192
+ }
193
+
194
+ /** Bold “r” + space before release name in chips (priority pills stay unchanged). */
195
+ function ReleaseChipPrefix(): ReactNode {
196
+ return (
197
+ <>
198
+ <span className="font-bold">r&nbsp;&nbsp;</span>
199
+ </>
200
+ );
201
+ }
202
+
203
+ function releaseChipTitle(label: string): string {
204
+ return `r ${label.trim()}`;
205
+ }
206
+
207
+ /**
208
+ * Open/created vs closed timestamp for large/larger task cards.
209
+ * Returns a bare `<span>` — the caller decides placement (metadata row, inline after preview, etc.).
210
+ */
211
+ function TaskCardTimelineChip({
212
+ task,
213
+ className,
214
+ }: {
215
+ task: Task;
216
+ className?: string;
217
+ }) {
218
+ const timeline = getTaskCardTimeline(task);
219
+ if (!timeline) return null;
220
+ const { label: compact, showRecentDot } = getTaskCardRelativeDateParts(timeline.iso);
221
+ if (!compact) return null;
222
+ const tip = formatTaskCardDateTooltip(timeline.kind, timeline.iso);
223
+ return (
224
+ <span
225
+ className={cn(
226
+ "inline-flex items-center gap-0.5 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground/80",
227
+ className,
228
+ )}
229
+ title={tip}
230
+ aria-label={tip}
231
+ >
232
+ {showRecentDot ? (
233
+ <span
234
+ className="size-1.5 shrink-0 rounded-full bg-blue-500 dark:bg-blue-400"
235
+ aria-hidden
236
+ />
237
+ ) : (
238
+ <Clock className="size-2.5 shrink-0 opacity-90" strokeWidth={2} aria-hidden />
239
+ )}
240
+ {compact}
241
+ </span>
242
+ );
243
+ }
244
+
245
+ function PriorityPill({
246
+ label,
247
+ color,
248
+ prefixNode,
249
+ }: {
250
+ label: string;
251
+ color: string;
252
+ /** Rendered before the display label (not passed through `priorityDisplayLabel`). */
253
+ prefixNode?: ReactNode;
254
+ }) {
255
+ const displayLabel = priorityDisplayLabel(label);
256
+ if (!displayLabel) return null;
257
+ const light = isVeryLightPriorityBackground(color);
258
+ return (
259
+ <span
260
+ className={cn(
261
+ "inline-flex max-w-full items-center rounded-full px-2 py-0.5 text-[10px] font-medium",
262
+ light
263
+ ? "border border-border/70 text-foreground"
264
+ : "text-black/85",
265
+ )}
266
+ title={prefixNode != null ? releaseChipTitle(label) : label}
267
+ style={{
268
+ backgroundColor: color,
269
+ }}
270
+ >
271
+ {prefixNode}
272
+ {displayLabel}
273
+ </span>
274
+ );
275
+ }
276
+
277
+ /**
278
+ * Content section shared by both open and non-open card layouts.
279
+ * Extracted so the two branches render identical markup for the text area.
280
+ */
281
+ function TaskCardContent({
282
+ task,
283
+ taskPriorities,
284
+ viewMode,
285
+ groupLabel,
286
+ releasePill,
287
+ preview,
288
+ onOpen,
289
+ editingTitle = false,
290
+ titleDraft = "",
291
+ onTitleDraftChange,
292
+ onTitleCommit,
293
+ onTitleCancel,
294
+ titleEditBusy = false,
295
+ }: {
296
+ task: Task;
297
+ taskPriorities: TaskPriorityDefinition[];
298
+ viewMode: TaskCardViewMode;
299
+ groupLabel: string;
300
+ releasePill?: { label: string; color?: string | null } | null;
301
+ preview: string;
302
+ onOpen: () => void;
303
+ editingTitle?: boolean;
304
+ titleDraft?: string;
305
+ onTitleDraftChange?: (value: string) => void;
306
+ onTitleCommit?: () => void;
307
+ onTitleCancel?: () => void;
308
+ titleEditBusy?: boolean;
309
+ }) {
310
+ const viewSpec = getTaskCardViewSpec(viewMode);
311
+ const priorityRow = taskPriorities.find((p) => p.priorityId === task.priorityId);
312
+ // Default builtin `none` is not surfaced as a chip (same UX as “no priority”).
313
+ const showPriorityPill =
314
+ priorityRow != null && priorityRow.value !== NONE_TASK_PRIORITY_VALUE;
315
+ const showReleasePill =
316
+ releasePill != null && releasePill.label.trim().length > 0;
317
+ const titleInputRef = useRef<HTMLTextAreaElement>(null);
318
+ const titleBlurModeRef = useRef<"commit" | "cancel">("commit");
319
+
320
+ useLayoutEffect(() => {
321
+ if (!editingTitle) return;
322
+ titleInputRef.current?.focus();
323
+ titleInputRef.current?.select();
324
+ titleBlurModeRef.current = "commit";
325
+ }, [editingTitle, task.taskId]);
326
+
327
+ useLayoutEffect(() => {
328
+ if (!editingTitle) return;
329
+ autoSizeInlineTitleTextarea(titleInputRef.current);
330
+ }, [editingTitle, titleDraft]);
331
+
332
+ return (
333
+ <div
334
+ className="min-w-0 flex-1 text-left"
335
+ onClick={editingTitle ? undefined : onOpen}
336
+ >
337
+ {editingTitle ? (
338
+ <div className="flex min-w-0 gap-1.5">
339
+ {task.emoji ? (
340
+ <span
341
+ className="shrink-0 text-lg leading-tight"
342
+ aria-hidden
343
+ >
344
+ {task.emoji}
345
+ </span>
346
+ ) : null}
347
+ {/* Inline rename auto-fits up to three lines without reselecting text while typing. */}
348
+ <textarea
349
+ ref={titleInputRef}
350
+ rows={3}
351
+ className={cn(
352
+ "min-w-0 flex-1 resize-y rounded border border-input bg-background px-2 py-1 text-foreground select-text",
353
+ viewSpec.titleClassName,
354
+ )}
355
+ value={titleDraft}
356
+ disabled={titleEditBusy}
357
+ onChange={(e) => {
358
+ // Auto-grow up to three lines, then keep the native resize handle available.
359
+ autoSizeInlineTitleTextarea(e.currentTarget);
360
+ onTitleDraftChange?.(clampTaskTitleInput(e.target.value));
361
+ }}
362
+ onPointerDown={(e) => e.stopPropagation()}
363
+ onClick={(e) => e.stopPropagation()}
364
+ onBlur={() => {
365
+ if (titleBlurModeRef.current === "cancel") {
366
+ titleBlurModeRef.current = "commit";
367
+ return;
368
+ }
369
+ onTitleCommit?.();
370
+ }}
371
+ onKeyDown={(e) => {
372
+ if (e.key === "Enter") {
373
+ e.preventDefault();
374
+ onTitleCommit?.();
375
+ }
376
+ if (e.key === "Escape") {
377
+ e.preventDefault();
378
+ titleBlurModeRef.current = "cancel";
379
+ onTitleCancel?.();
380
+ }
381
+ }}
382
+ />
383
+ </div>
384
+ ) : (
385
+ <div
386
+ className={cn(
387
+ "flex min-w-0 items-start gap-1.5",
388
+ viewMode === "small" && "items-center",
389
+ )}
390
+ >
391
+ <div
392
+ className={cn(
393
+ "min-w-0 flex-1 font-medium",
394
+ viewSpec.titleClassName,
395
+ )}
396
+ >
397
+ {taskDisplayTitleOnCard(task)}
398
+ </div>
399
+ {viewMode === "small" ? (
400
+ <CliCreatedIndicator task={task} compact />
401
+ ) : null}
402
+ </div>
403
+ )}
404
+ {viewMode !== "small" ? (
405
+ <div className="mt-1 flex flex-wrap items-center gap-2 text-[10px]">
406
+ <span className="uppercase tracking-wide text-muted-foreground/80">
407
+ {groupLabel}
408
+ </span>
409
+ {showPriorityPill && priorityRow ? (
410
+ <PriorityPill label={priorityRow.label} color={priorityRow.color} />
411
+ ) : null}
412
+ {showReleasePill && releasePill?.color ? (
413
+ <PriorityPill
414
+ label={releasePill.label}
415
+ color={releasePill.color}
416
+ prefixNode={<ReleaseChipPrefix />}
417
+ />
418
+ ) : showReleasePill ? (
419
+ <span
420
+ className="rounded-full border border-border/70 px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
421
+ title={releaseChipTitle(releasePill!.label)}
422
+ >
423
+ <ReleaseChipPrefix />
424
+ {releasePill!.label}
425
+ </span>
426
+ ) : null}
427
+ {viewMode === "large" || viewMode === "larger" ? (
428
+ <span
429
+ className="text-muted-foreground/55"
430
+ title={`Task id ${formatTaskIdForDisplay(task.taskId)}`}
431
+ >
432
+ #{formatTaskIdForDisplay(task.taskId)}
433
+ </span>
434
+ ) : null}
435
+ <CliCreatedIndicator task={task} />
436
+ {/* Date chip in metadata row when there is no preview body below (large mode). */}
437
+ {!preview && (viewMode === "large" || viewMode === "larger") ? (
438
+ <TaskCardTimelineChip task={task} className="ml-auto" />
439
+ ) : null}
440
+ </div>
441
+ ) : null}
442
+ {/* Larger mode: preview body + date at bottom-right.
443
+ An invisible inline spacer reserves room on the last text line;
444
+ when text fills that line the spacer wraps, creating vertical space.
445
+ The date is absolutely positioned at bottom-right over the spacer.
446
+ No line-clamp here — JS truncation (previewBody) controls length,
447
+ avoiding the double-ellipsis caused by CSS clamp + JS "…". */}
448
+ {preview && (viewMode === "large" || viewMode === "larger") ? (
449
+ <p className="relative mt-1 text-xs text-muted-foreground">
450
+ {preview}
451
+ {getTaskCardTimeline(task) != null ? (
452
+ <>
453
+ {/* Reserve width for clock + longest relative labels (e.g. “3 days ago”). */}
454
+ <span
455
+ className="inline-block h-4 w-24 select-none align-bottom"
456
+ aria-hidden
457
+ >
458
+ {"\u00A0"}
459
+ </span>
460
+ <TaskCardTimelineChip
461
+ task={task}
462
+ className="absolute bottom-0 right-0"
463
+ />
464
+ </>
465
+ ) : null}
466
+ </p>
467
+ ) : preview ? (
468
+ <div
469
+ className={cn(
470
+ "mt-1 text-xs text-muted-foreground",
471
+ viewSpec.previewClassName,
472
+ )}
473
+ >
474
+ {preview}
475
+ </div>
476
+ ) : null}
477
+ </div>
478
+ );
479
+ }
480
+
481
+ const TASK_CARD_INLINE_PADDING_REM = 0.625;
482
+ const TASK_CARD_STATUS_SLOT_REM = 1.375;
483
+ const TASK_CARD_RIGHT_SLOT_REM = 0.25;
484
+
485
+ function openTaskContentRailStyle(viewMode: TaskCardViewMode): CSSProperties {
486
+ const inlinePaddingRem = viewMode === "small" ? 0.5 : TASK_CARD_INLINE_PADDING_REM;
487
+ const blockPaddingRem = viewMode === "small" ? 0.375 : 0.5;
488
+ return {
489
+ // Keep the movement distance and content width derived from the same slots
490
+ // so view-mode changes do not expose part of the hidden open circle or shift it vertically.
491
+ ["--task-card-inline-padding" as string]: `${inlinePaddingRem}rem`,
492
+ ["--task-card-block-padding" as string]: `${blockPaddingRem}rem`,
493
+ ["--task-card-status-slot" as string]: `${TASK_CARD_STATUS_SLOT_REM}rem`,
494
+ ["--task-card-right-slot" as string]: `${TASK_CARD_RIGHT_SLOT_REM}rem`,
495
+ width:
496
+ "calc(100% - var(--task-card-status-slot) - var(--task-card-right-slot))",
497
+ };
498
+ }
499
+
500
+ // Memoized to avoid re-rendering every card on each drag-over event
501
+ export const TaskCard = memo(function TaskCard({
502
+ task,
503
+ taskPriorities,
504
+ viewMode,
505
+ groupLabel,
506
+ releasePill = null,
507
+ onOpen,
508
+ editingTitle = false,
509
+ titleDraft,
510
+ onTitleDraftChange,
511
+ onTitleCommit,
512
+ onTitleCancel,
513
+ titleEditBusy = false,
514
+ onCompleteFromCircle,
515
+ isDragging,
516
+ skipNavRegistration = false,
517
+ }: TaskCardProps) {
518
+ const nav = useBoardKeyboardNavOptional();
519
+ const rootRef = useRef<HTMLDivElement>(null);
520
+ const viewSpec = getTaskCardViewSpec(viewMode);
521
+
522
+ useLayoutEffect(() => {
523
+ if (skipNavRegistration || !nav) return;
524
+ const el = rootRef.current;
525
+ if (el) nav.registerTaskElement(task.taskId, el);
526
+ return () => {
527
+ nav.registerTaskElement(task.taskId, null);
528
+ };
529
+ }, [nav, skipNavRegistration, task.taskId]);
530
+
531
+ const preview = viewSpec.showDescriptionPreview
532
+ ? previewBody(task.body, viewSpec.previewMaxLength)
533
+ : "";
534
+ const canCompleteFromCircle =
535
+ task.status === "open" && onCompleteFromCircle !== undefined;
536
+ const isOpenTask = task.status === "open";
537
+ const openTaskRailStyle = openTaskContentRailStyle(viewMode);
538
+ const bodyPaddingClass = taskCardBodyPaddingClass(viewMode);
539
+
540
+ return (
541
+ <div
542
+ ref={rootRef}
543
+ data-task-card-root
544
+ data-task-id={task.taskId}
545
+ onPointerEnter={(e) => {
546
+ if (e.pointerType !== "mouse" || skipNavRegistration || !nav) return;
547
+ nav.setHoveredTaskId(task.taskId);
548
+ }}
549
+ onPointerLeave={(e) => {
550
+ if (e.pointerType !== "mouse" || skipNavRegistration || !nav) return;
551
+ nav.setHoveredTaskId(null);
552
+ }}
553
+ onPointerDown={() => {
554
+ if (editingTitle) return;
555
+ // Clicking into a task should make it current before any editor/dialog opens.
556
+ nav?.selectTask(task.taskId);
557
+ }}
558
+ className={cn(
559
+ "group/task-card relative w-full overflow-hidden rounded-md border border-border bg-task-card text-sm text-task-card-foreground shadow-sm transition-colors select-none",
560
+ "hover:bg-accent/45",
561
+ task.color && "border-l-4",
562
+ isDragging && "opacity-40",
563
+ )}
564
+ style={task.color ? { borderLeftColor: task.color } : undefined}
565
+ >
566
+ {isOpenTask ? (
567
+ <div
568
+ className={cn("relative", bodyPaddingClass)}
569
+ style={openTaskRailStyle}
570
+ >
571
+ {canCompleteFromCircle ? (
572
+ <button
573
+ type="button"
574
+ data-task-complete-button
575
+ className={cn(
576
+ "absolute left-[var(--task-card-inline-padding)] top-[var(--task-card-block-padding)] inline-flex w-[var(--task-card-status-slot)] items-start justify-start rounded-sm opacity-0 outline-none transition-opacity duration-150 ring-offset-background pointer-events-none group-hover/task-card:pointer-events-auto group-hover/task-card:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring",
577
+ )}
578
+ aria-label="Mark complete"
579
+ title="Mark complete"
580
+ onPointerDown={(e) => e.stopPropagation()}
581
+ onClick={(e) => {
582
+ e.stopPropagation();
583
+ // Completing from the card is still a task interaction, so keep
584
+ // this task current before applying the status change.
585
+ nav?.selectTask(task.taskId);
586
+ onCompleteFromCircle(e.currentTarget);
587
+ }}
588
+ >
589
+ <OpenStatusCircle />
590
+ </button>
591
+ ) : (
592
+ <div className="absolute left-[var(--task-card-inline-padding)] top-[var(--task-card-block-padding)] inline-flex w-[var(--task-card-status-slot)] items-start justify-start">
593
+ <TaskStatusIndicator status={task.status} />
594
+ </div>
595
+ )}
596
+ <div
597
+ className={cn(
598
+ "min-w-0 translate-x-0 transition-transform duration-150 ease-out group-hover/task-card:translate-x-[var(--task-card-status-slot)]",
599
+ )}
600
+ >
601
+ <TaskCardContent
602
+ task={task}
603
+ taskPriorities={taskPriorities}
604
+ viewMode={viewMode}
605
+ groupLabel={groupLabel}
606
+ releasePill={releasePill}
607
+ preview={preview}
608
+ onOpen={onOpen}
609
+ editingTitle={editingTitle}
610
+ titleDraft={titleDraft}
611
+ onTitleDraftChange={onTitleDraftChange}
612
+ onTitleCommit={onTitleCommit}
613
+ onTitleCancel={onTitleCancel}
614
+ titleEditBusy={titleEditBusy}
615
+ />
616
+ </div>
617
+ </div>
618
+ ) : (
619
+ // Non-open tasks: normal flex layout with status always visible.
620
+ <div className={cn("flex gap-2", bodyPaddingClass)}>
621
+ <div className="shrink-0">
622
+ <TaskStatusIndicator status={task.status} />
623
+ </div>
624
+ <TaskCardContent
625
+ task={task}
626
+ taskPriorities={taskPriorities}
627
+ viewMode={viewMode}
628
+ groupLabel={groupLabel}
629
+ releasePill={releasePill}
630
+ preview={preview}
631
+ onOpen={onOpen}
632
+ editingTitle={editingTitle}
633
+ titleDraft={titleDraft}
634
+ onTitleDraftChange={onTitleDraftChange}
635
+ onTitleCommit={onTitleCommit}
636
+ onTitleCancel={onTitleCancel}
637
+ titleEditBusy={titleEditBusy}
638
+ />
639
+ </div>
640
+ )}
641
+ </div>
642
+ );
643
+ });