@anchrd/intel 0.59.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.
Files changed (364) hide show
  1. package/README.md +496 -0
  2. package/bin/intel.mjs +134 -0
  3. package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
  4. package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
  5. package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
  6. package/dist/adapters/cloudflare/cloudflare.js +416 -0
  7. package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
  8. package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
  9. package/dist/adapters/content/content.d.ts +3 -0
  10. package/dist/adapters/content/content.js +128 -0
  11. package/dist/adapters/content/content.types.d.ts +38 -0
  12. package/dist/adapters/content/content.types.js +1 -0
  13. package/dist/adapters/db/db-audit.d.ts +62 -0
  14. package/dist/adapters/db/db-audit.js +150 -0
  15. package/dist/adapters/db/db-boards.d.ts +6 -0
  16. package/dist/adapters/db/db-boards.js +324 -0
  17. package/dist/adapters/db/db-feed.d.ts +30 -0
  18. package/dist/adapters/db/db-feed.js +276 -0
  19. package/dist/adapters/db/db-flows.d.ts +42 -0
  20. package/dist/adapters/db/db-flows.js +1311 -0
  21. package/dist/adapters/db/db-grants.d.ts +133 -0
  22. package/dist/adapters/db/db-grants.js +269 -0
  23. package/dist/adapters/db/db-indexing.d.ts +3 -0
  24. package/dist/adapters/db/db-indexing.js +200 -0
  25. package/dist/adapters/db/db-oauth.d.ts +3 -0
  26. package/dist/adapters/db/db-oauth.js +26 -0
  27. package/dist/adapters/db/db-prompts.d.ts +46 -0
  28. package/dist/adapters/db/db-prompts.js +144 -0
  29. package/dist/adapters/db/db.d.ts +6 -0
  30. package/dist/adapters/db/db.js +1687 -0
  31. package/dist/adapters/db/db.types.d.ts +16 -0
  32. package/dist/adapters/db/db.types.js +1 -0
  33. package/dist/adapters/document-converter/document-converter.d.ts +2 -0
  34. package/dist/adapters/document-converter/document-converter.js +26 -0
  35. package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
  36. package/dist/adapters/document-converter/document-converter.types.js +1 -0
  37. package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
  38. package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
  39. package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
  40. package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
  41. package/dist/adapters/index-queue/index-queue.d.ts +10 -0
  42. package/dist/adapters/index-queue/index-queue.js +12 -0
  43. package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
  44. package/dist/adapters/index-queue/index-queue.types.js +1 -0
  45. package/dist/adapters/json-schema/json-schema.d.ts +2 -0
  46. package/dist/adapters/json-schema/json-schema.js +77 -0
  47. package/dist/adapters/openid/openid.d.ts +5 -0
  48. package/dist/adapters/openid/openid.js +301 -0
  49. package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
  50. package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
  51. package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
  52. package/dist/adapters/remote-tools/remote-tools.js +129 -0
  53. package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
  54. package/dist/adapters/semantic-index/semantic-index.js +139 -0
  55. package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
  56. package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
  57. package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
  58. package/dist/adapters/session-cookie/session-cookie.js +87 -0
  59. package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
  60. package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
  61. package/dist/audit/audit.d.ts +2 -0
  62. package/dist/audit/audit.js +87 -0
  63. package/dist/audit/audit.types.d.ts +23 -0
  64. package/dist/audit/audit.types.js +1 -0
  65. package/dist/auth/auth.d.ts +2 -0
  66. package/dist/auth/auth.js +284 -0
  67. package/dist/auth/auth.types.d.ts +108 -0
  68. package/dist/auth/auth.types.js +1 -0
  69. package/dist/boards/boards.d.ts +44 -0
  70. package/dist/boards/boards.js +447 -0
  71. package/dist/boards/boards.types.d.ts +117 -0
  72. package/dist/boards/boards.types.js +1 -0
  73. package/dist/build/build.d.ts +2 -0
  74. package/dist/build/build.js +192 -0
  75. package/dist/build/build.types.d.ts +17 -0
  76. package/dist/build/build.types.js +1 -0
  77. package/dist/bundle/bundle.d.ts +4 -0
  78. package/dist/bundle/bundle.js +1194 -0
  79. package/dist/bundle/bundle.types.d.ts +33 -0
  80. package/dist/bundle/bundle.types.js +1 -0
  81. package/dist/cli/cli.d.ts +4 -0
  82. package/dist/cli/cli.js +262 -0
  83. package/dist/cli/cli.types.d.ts +14 -0
  84. package/dist/cli/cli.types.js +1 -0
  85. package/dist/contract/audit.d.ts +50 -0
  86. package/dist/contract/audit.js +72 -0
  87. package/dist/contract/board.d.ts +150 -0
  88. package/dist/contract/board.js +262 -0
  89. package/dist/contract/bundle.d.ts +147 -0
  90. package/dist/contract/bundle.js +139 -0
  91. package/dist/contract/contract.d.ts +112 -0
  92. package/dist/contract/contract.js +143 -0
  93. package/dist/contract/feed.d.ts +126 -0
  94. package/dist/contract/feed.js +149 -0
  95. package/dist/contract/flow-run.d.ts +346 -0
  96. package/dist/contract/flow-run.js +194 -0
  97. package/dist/contract/flow.d.ts +1057 -0
  98. package/dist/contract/flow.js +552 -0
  99. package/dist/contract/node.d.ts +551 -0
  100. package/dist/contract/node.js +456 -0
  101. package/dist/contract/share.d.ts +216 -0
  102. package/dist/contract/share.js +134 -0
  103. package/dist/contract/table.d.ts +171 -0
  104. package/dist/contract/table.js +117 -0
  105. package/dist/contract/tool.d.ts +122 -0
  106. package/dist/contract/tool.js +172 -0
  107. package/dist/feed/feed.d.ts +2 -0
  108. package/dist/feed/feed.js +77 -0
  109. package/dist/feed/feed.types.d.ts +23 -0
  110. package/dist/feed/feed.types.js +1 -0
  111. package/dist/flows/flows.d.ts +41 -0
  112. package/dist/flows/flows.js +2216 -0
  113. package/dist/flows/flows.types.d.ts +339 -0
  114. package/dist/flows/flows.types.js +1 -0
  115. package/dist/http/http.d.ts +9 -0
  116. package/dist/http/http.js +874 -0
  117. package/dist/http/http.types.d.ts +22 -0
  118. package/dist/http/http.types.js +1 -0
  119. package/dist/indexing/indexing.d.ts +6 -0
  120. package/dist/indexing/indexing.js +239 -0
  121. package/dist/indexing/indexing.types.d.ts +11 -0
  122. package/dist/indexing/indexing.types.js +1 -0
  123. package/dist/intel/intel.d.ts +3 -0
  124. package/dist/intel/intel.js +194 -0
  125. package/dist/intel/intel.types.d.ts +24 -0
  126. package/dist/intel/intel.types.js +1 -0
  127. package/dist/mcp/mcp.d.ts +2 -0
  128. package/dist/mcp/mcp.js +1292 -0
  129. package/dist/mcp/mcp.types.d.ts +25 -0
  130. package/dist/mcp/mcp.types.js +1 -0
  131. package/dist/nodes/document-links/document-links.d.ts +20 -0
  132. package/dist/nodes/document-links/document-links.js +57 -0
  133. package/dist/nodes/nodes.d.ts +2 -0
  134. package/dist/nodes/nodes.js +1667 -0
  135. package/dist/nodes/nodes.types.d.ts +511 -0
  136. package/dist/nodes/nodes.types.js +1 -0
  137. package/dist/prepare/prepare.d.ts +4 -0
  138. package/dist/prepare/prepare.js +16 -0
  139. package/dist/prepare/prepare.types.d.ts +9 -0
  140. package/dist/prepare/prepare.types.js +1 -0
  141. package/dist/prompts/prompts.d.ts +2 -0
  142. package/dist/prompts/prompts.js +65 -0
  143. package/dist/prompts/prompts.types.d.ts +71 -0
  144. package/dist/prompts/prompts.types.js +1 -0
  145. package/dist/shared/callers/callers.d.ts +28 -0
  146. package/dist/shared/callers/callers.js +30 -0
  147. package/dist/shared/csv/csv.d.ts +13 -0
  148. package/dist/shared/csv/csv.js +85 -0
  149. package/dist/shared/document-text/document-text.d.ts +21 -0
  150. package/dist/shared/document-text/document-text.js +31 -0
  151. package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
  152. package/dist/shared/gate-authorization/gate-authorization.js +26 -0
  153. package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
  154. package/dist/shared/grant-expiry/grant-expiry.js +26 -0
  155. package/dist/shared/intel-error/intel-error.d.ts +22 -0
  156. package/dist/shared/intel-error/intel-error.js +27 -0
  157. package/dist/shared/plain-title/plain-title.d.ts +16 -0
  158. package/dist/shared/plain-title/plain-title.js +45 -0
  159. package/dist/shared/problem-details/problem-details.d.ts +30 -0
  160. package/dist/shared/problem-details/problem-details.js +18 -0
  161. package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
  162. package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
  163. package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
  164. package/dist/shared/safe-return-path/safe-return-path.js +11 -0
  165. package/dist/shared/sha256/sha256.d.ts +2 -0
  166. package/dist/shared/sha256/sha256.js +14 -0
  167. package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
  168. package/dist/tools/tool-servers/tool-servers.js +177 -0
  169. package/dist/tools/tools.d.ts +2 -0
  170. package/dist/tools/tools.js +207 -0
  171. package/dist/tools/tools.types.d.ts +56 -0
  172. package/dist/tools/tools.types.js +1 -0
  173. package/examples/branding/favicon.svg +5 -0
  174. package/examples/branding/logo.svg +5 -0
  175. package/examples/branding/theme.css +16 -0
  176. package/examples/dev.vars.example +7 -0
  177. package/examples/intel.json +9 -0
  178. package/migrations/0000_intel_foundation.sql +203 -0
  179. package/migrations/0001_portal_native_tools.sql +21 -0
  180. package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
  181. package/migrations/0003_folder_permissions.sql +211 -0
  182. package/migrations/0004_subflow_runs.sql +14 -0
  183. package/migrations/0005_flow_node_cleanup.sql +28 -0
  184. package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
  185. package/migrations/0006_links_are_written_in_the_text.sql +20 -0
  186. package/migrations/0007_no_node_waits.sql +69 -0
  187. package/migrations/0008_three_layers.sql +130 -0
  188. package/migrations/0009_no_context_policy.sql +48 -0
  189. package/migrations/0010_no_run_waits_a_year.sql +27 -0
  190. package/migrations/0011_one_name_for_the_tree.sql +53 -0
  191. package/migrations/0012_table_snapshots.sql +29 -0
  192. package/migrations/0017_a_vector_per_card.sql +38 -0
  193. package/migrations/0018_no_context_policy_at_last.sql +97 -0
  194. package/migrations/0019_one_name_for_the_grants.sql +52 -0
  195. package/migrations/0020_cascade_purge_replay.sql +11 -0
  196. package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
  197. package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
  198. package/migrations/0023_a_board_and_its_tasks.sql +116 -0
  199. package/migrations/0024_the_archive_is_a_column.sql +33 -0
  200. package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
  201. package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
  202. package/migrations/0027_the_runs_of_every_flow.sql +27 -0
  203. package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
  204. package/package.json +118 -0
  205. package/src/contract/CLAUDE.md +152 -0
  206. package/src/contract/README.md +102 -0
  207. package/src/contract/audit.ts +85 -0
  208. package/src/contract/board.ts +306 -0
  209. package/src/contract/bundle.ts +157 -0
  210. package/src/contract/contract.ts +161 -0
  211. package/src/contract/feed.ts +165 -0
  212. package/src/contract/flow-run.ts +241 -0
  213. package/src/contract/flow.ts +675 -0
  214. package/src/contract/node.ts +583 -0
  215. package/src/contract/share.ts +196 -0
  216. package/src/contract/table.ts +178 -0
  217. package/src/contract/tool.ts +202 -0
  218. package/ui/README.md +110 -0
  219. package/ui/components.json +21 -0
  220. package/ui/index.html +13 -0
  221. package/ui/package.json +11 -0
  222. package/ui/scripts/lint-tokens.mjs +187 -0
  223. package/ui/src/access-summary/access-summary.tsx +385 -0
  224. package/ui/src/app/action-slot/action-slot.tsx +27 -0
  225. package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
  226. package/ui/src/app/app-tree/app-tree.tsx +1175 -0
  227. package/ui/src/app/app.tsx +87 -0
  228. package/ui/src/app/header-search/header-search.tsx +294 -0
  229. package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
  230. package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
  231. package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
  232. package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
  233. package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
  234. package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
  235. package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
  236. package/ui/src/app/tree-move/tree-move.tsx +427 -0
  237. package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
  238. package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
  239. package/ui/src/app/user-footer/user-footer.tsx +188 -0
  240. package/ui/src/app/view-menu/view-menu.tsx +115 -0
  241. package/ui/src/app-root/app-root.tsx +109 -0
  242. package/ui/src/app-root/app-root.types.ts +27 -0
  243. package/ui/src/archive/archive.tsx +417 -0
  244. package/ui/src/attachment-detail/attachment-detail.ts +52 -0
  245. package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
  246. package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
  247. package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
  248. package/ui/src/board/board-assignee/board-assignee.ts +48 -0
  249. package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
  250. package/ui/src/board/board-chip/board-chip.tsx +46 -0
  251. package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
  252. package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
  253. package/ui/src/board/board-data/board-data.ts +195 -0
  254. package/ui/src/board/board-data/board-data.types.ts +38 -0
  255. package/ui/src/board/board-dates/board-dates.ts +40 -0
  256. package/ui/src/board/board-dates/board-dates.tsx +105 -0
  257. package/ui/src/board/board-kanban/board-kanban.ts +162 -0
  258. package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
  259. package/ui/src/board/board-open-task/board-open-task.ts +32 -0
  260. package/ui/src/board/board-panel/board-panel.tsx +259 -0
  261. package/ui/src/board/board-settings/board-settings.tsx +226 -0
  262. package/ui/src/board/board-status/board-status.tsx +60 -0
  263. package/ui/src/board/board-stripes/board-stripes.ts +128 -0
  264. package/ui/src/board/board-table/board-table.ts +53 -0
  265. package/ui/src/board/board-table/board-table.tsx +610 -0
  266. package/ui/src/board/board-task/board-task.ts +108 -0
  267. package/ui/src/board/board-task/board-task.tsx +335 -0
  268. package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
  269. package/ui/src/branding/branding.tsx +17 -0
  270. package/ui/src/branding/custom-logo.ts +1 -0
  271. package/ui/src/branding/favicon.default.svg +5 -0
  272. package/ui/src/branding/favicon.svg +5 -0
  273. package/ui/src/components/ui/avatar.tsx +39 -0
  274. package/ui/src/components/ui/button.tsx +64 -0
  275. package/ui/src/components/ui/collapsible.tsx +20 -0
  276. package/ui/src/components/ui/command.tsx +160 -0
  277. package/ui/src/components/ui/dialog.tsx +143 -0
  278. package/ui/src/components/ui/dropdown-menu.tsx +187 -0
  279. package/ui/src/components/ui/input.tsx +21 -0
  280. package/ui/src/components/ui/popover.tsx +41 -0
  281. package/ui/src/components/ui/select.tsx +163 -0
  282. package/ui/src/components/ui/separator.tsx +26 -0
  283. package/ui/src/components/ui/sheet.tsx +136 -0
  284. package/ui/src/components/ui/sidebar.tsx +693 -0
  285. package/ui/src/components/ui/skeleton.tsx +13 -0
  286. package/ui/src/components/ui/switch.tsx +25 -0
  287. package/ui/src/components/ui/table.tsx +82 -0
  288. package/ui/src/components/ui/tabs.tsx +52 -0
  289. package/ui/src/components/ui/tooltip.tsx +51 -0
  290. package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
  291. package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
  292. package/ui/src/data/query-client.ts +18 -0
  293. package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
  294. package/ui/src/data/request-refusal/request-refusal.ts +51 -0
  295. package/ui/src/data/sign-in/sign-in.ts +97 -0
  296. package/ui/src/data/sign-in/sign-in.types.ts +15 -0
  297. package/ui/src/document-link/document-link.tsx +114 -0
  298. package/ui/src/document-markdown/document-markdown.ts +195 -0
  299. package/ui/src/editor-schema/editor-schema.ts +23 -0
  300. package/ui/src/entry-picker/entry-picker.tsx +238 -0
  301. package/ui/src/feed/feed.tsx +344 -0
  302. package/ui/src/file-preview/file-preview-view.tsx +496 -0
  303. package/ui/src/file-preview/file-preview.tsx +28 -0
  304. package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
  305. package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
  306. package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
  307. package/ui/src/file-preview/word-file-preview.tsx +5 -0
  308. package/ui/src/flow-runs/flow-runs.tsx +238 -0
  309. package/ui/src/flows/flows.tsx +1646 -0
  310. package/ui/src/flows/node-icon/node-icon.ts +34 -0
  311. package/ui/src/flows/node-palette/node-palette.tsx +214 -0
  312. package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
  313. package/ui/src/folder-contents/folder-contents.tsx +321 -0
  314. package/ui/src/folder-index/folder-index.ts +55 -0
  315. package/ui/src/frontmatter/frontmatter.tsx +193 -0
  316. package/ui/src/graph-pane/graph-pane.tsx +53 -0
  317. package/ui/src/hooks/use-mobile.ts +19 -0
  318. package/ui/src/i18n/custom.json +4 -0
  319. package/ui/src/i18n/de.json +609 -0
  320. package/ui/src/i18n/en.json +609 -0
  321. package/ui/src/i18n/es.json +609 -0
  322. package/ui/src/i18n/i18n-context.tsx +65 -0
  323. package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
  324. package/ui/src/i18n/i18n.ts +32 -0
  325. package/ui/src/i18n/i18n.types.ts +49 -0
  326. package/ui/src/kind-icon.ts +97 -0
  327. package/ui/src/lib/utils.ts +6 -0
  328. package/ui/src/main.tsx +68 -0
  329. package/ui/src/modal/modal.tsx +53 -0
  330. package/ui/src/node-details/node-details.tsx +545 -0
  331. package/ui/src/node-editor/node-editor.tsx +304 -0
  332. package/ui/src/node-graph/graph-notice.tsx +21 -0
  333. package/ui/src/node-graph/node-graph.ts +214 -0
  334. package/ui/src/node-graph/node-graph.tsx +295 -0
  335. package/ui/src/node-import/node-import.tsx +418 -0
  336. package/ui/src/node-save-error.ts +46 -0
  337. package/ui/src/node-source/node-source.tsx +128 -0
  338. package/ui/src/node-table/node-table.tsx +107 -0
  339. package/ui/src/nodes/nodes.tsx +240 -0
  340. package/ui/src/rename-resource/rename-resource.ts +80 -0
  341. package/ui/src/resolved-names/resolved-names.tsx +67 -0
  342. package/ui/src/resource-error.ts +33 -0
  343. package/ui/src/resource-menu/resource-menu.tsx +1156 -0
  344. package/ui/src/router/router-context.ts +6 -0
  345. package/ui/src/router/router.tsx +67 -0
  346. package/ui/src/router/router.types.ts +8 -0
  347. package/ui/src/router/selection-search.ts +88 -0
  348. package/ui/src/save-button/save-button.tsx +94 -0
  349. package/ui/src/section-hint/section-hint.tsx +40 -0
  350. package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
  351. package/ui/src/styles.css +189 -0
  352. package/ui/src/table-columns/table-columns.tsx +379 -0
  353. package/ui/src/theme/custom.css +1 -0
  354. package/ui/src/theme/theme-context.tsx +89 -0
  355. package/ui/src/theme/theme.ts +70 -0
  356. package/ui/src/time/relative-time.tsx +41 -0
  357. package/ui/src/time/time-context.tsx +78 -0
  358. package/ui/src/time/time.ts +165 -0
  359. package/ui/src/title-row/title-row.tsx +185 -0
  360. package/ui/src/tools/tools.tsx +546 -0
  361. package/ui/src/user-name/user-name.ts +61 -0
  362. package/ui/src/verbatim/verbatim.tsx +68 -0
  363. package/ui/tsconfig.json +17 -0
  364. package/ui/vite.config.ts +129 -0
@@ -0,0 +1,680 @@
1
+ import type { BoardTask } from "@contract/board.ts";
2
+ import {
3
+ closestCorners,
4
+ DndContext,
5
+ type DragEndEvent,
6
+ DragOverlay,
7
+ MouseSensor,
8
+ TouchSensor,
9
+ useDroppable,
10
+ useSensor,
11
+ useSensors,
12
+ } from "@dnd-kit/core";
13
+ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
14
+ import { CSS } from "@dnd-kit/utilities";
15
+ import { useState } from "react";
16
+ import type { BoardHandle, ColumnWithCards } from "@/board/board-data/board-data.types.ts";
17
+ import { type Family, familiesOf } from "@/board/board-stripes/board-stripes.ts";
18
+ import { useI18n } from "@/i18n/i18n-context.tsx";
19
+ import { cn } from "@/lib/utils";
20
+ import {
21
+ type Drop,
22
+ dragEndToDrop,
23
+ dropIsAfter,
24
+ dropToWrite,
25
+ neighbourDrop,
26
+ } from "./board-kanban.ts";
27
+
28
+ function Card({
29
+ card,
30
+ column,
31
+ board,
32
+ onMove,
33
+ onOpen,
34
+ family,
35
+ parentColumn,
36
+ }: {
37
+ card: BoardTask;
38
+ column: ColumnWithCards;
39
+ board: BoardHandle;
40
+ onMove(card: BoardTask, drop: Drop): void;
41
+ onOpen(taskId: string): void;
42
+ family: Family;
43
+ /** The title of the column the parent card sits in, or `undefined` when there is none here. */
44
+ parentColumn: string | undefined;
45
+ }) {
46
+ const i18n = useI18n();
47
+ const locked = board.isWriting;
48
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
49
+ id: card.id,
50
+ disabled: locked,
51
+ });
52
+
53
+ return (
54
+ <li
55
+ ref={setNodeRef}
56
+ style={{ transform: CSS.Translate.toString(transform), transition }}
57
+ // ⚠️ The card keeps its place in the list while it is carried, and only fades. Removing it
58
+ // would collapse the gap it came from, every card below would jump up by one, and the drop
59
+ // target the pointer is over would change underneath the gesture.
60
+ className={isDragging ? "opacity-40" : undefined}
61
+ >
62
+ {/* ⚠️ A real button, not an `<li role="button">`. The role override needs two lint
63
+ suppressions and still leaves a listitem that only *claims* to be operable; a button is
64
+ focusable, is announced as operable, and — the part that made the override look necessary
65
+ — may perfectly well be a drag source. */}
66
+ <button
67
+ type="button"
68
+ {...attributes}
69
+ {...listeners}
70
+ // ⚠️ After the spread, deliberately. `attributes` carries `aria-roledescription="sortable"`,
71
+ // which announces a gesture this board does not offer from the keyboard.
72
+ aria-roledescription={undefined}
73
+ // ⚠️ The name carries everything this card cannot say any other way: that it OPENS (#671),
74
+ // how it moves without a pointer, and how DEEP it sits (#366). The stripes on the right are
75
+ // `aria-hidden` — a button's children are presentational, so no label inside one is ever
76
+ // announced, and the depth would exist in colour alone.
77
+ aria-label={labelOf(i18n, card, column, family, parentColumn)}
78
+ onClick={() => onOpen(card.id)}
79
+ // `relative` and `overflow-hidden` are for the depth pattern: it is positioned against this
80
+ // card, and nothing of it may spill past the rounded edge.
81
+ /* ⚠️ A quiet grey FIELD, not a box (#748). It carried `bg-card` plus `border-border` plus
82
+ `shadow-xs` — three means saying the same thing ("this is a card"), and on a white board
83
+ the three together read as a container rather than a surface. `bg-muted` is 0.97 against
84
+ a 1.0 ground in light and 0.269 against 0.145 in dark, so the card stands off its column
85
+ in both without a line around it.
86
+ ⚠️ The hover is what replaces the border: a surface still has to answer the pointer, and
87
+ without a line there is nothing else left to do it. */
88
+ className="relative w-full cursor-grab overflow-hidden rounded-md bg-muted p-2 text-left text-sm transition-colors hover:bg-muted/70 focus-visible:outline-2 focus-visible:outline-ring"
89
+ onKeyDown={(event) => {
90
+ // ⚠️ Not while a write is in flight. Two moves in quick succession both read the same
91
+ // not-yet-updated `board.columns`, compute a position independently and both write — the
92
+ // second answer overwrites the first, with no error, because both succeeded. That is the
93
+ // race `isWriting` was introduced for in #651, and marking the board busy without
94
+ // refusing the input leaves it exactly as open as before.
95
+ if (locked) return;
96
+ // ⚠️ Only with a modifier. The arrows alone belong to whatever the reader is used to —
97
+ // scrolling the column, moving focus — and taking them would make a board that cannot be
98
+ // read without moving its cards.
99
+ //
100
+ // ⚠️ This is kept INSTEAD of `dnd-kit`'s own keyboard sensor. That sensor lifts a card
101
+ // with the space bar and then steers it by collision detection, which needs measured
102
+ // geometry — a board whose columns are off screen to the right cannot be reached without
103
+ // scrolling first, and jsdom measures nothing at all, so the whole path would leave the
104
+ // suite. Alt and an arrow names the neighbour directly and is proven without a layout.
105
+ if (!event.altKey) return;
106
+ const direction =
107
+ event.key === "ArrowLeft"
108
+ ? "left"
109
+ : event.key === "ArrowRight"
110
+ ? "right"
111
+ : event.key === "ArrowUp"
112
+ ? "up"
113
+ : event.key === "ArrowDown"
114
+ ? "down"
115
+ : null;
116
+ if (direction === null) return;
117
+ const drop = neighbourDrop(card, board.columns, direction);
118
+ if (drop === null) return;
119
+ event.preventDefault();
120
+ onMove(card, drop);
121
+ }}
122
+ >
123
+ <Stripes family={family} label={depthOf(i18n, family, parentColumn)} />
124
+ {/* ⚠️ The room on the right follows the STRIPES, not the level. A fixed `pr-6` holds four
125
+ stripes and the title runs under the fifth; but reserving room for the real level once
126
+ the pattern caps takes width from the title for stripes that are not drawn — at level 20
127
+ that is 76 px of a 240 px card, given away for nothing. */}
128
+ <span
129
+ className="block"
130
+ // ⚠️ `STRIPE_WIDTH`, not a number written twice. The reserve followed the OLD grid of
131
+ // 3px plus a 2px gap; since the stripes sit flush and 4px wide, `* 5` reserved up to 14px
132
+ // the title never needed, while the comment above claimed the reserve follows them.
133
+ style={{
134
+ paddingRight: `${8 + Math.min(family.level, MAX_STRIPES) * STRIPE_WIDTH}px`,
135
+ }}
136
+ >
137
+ {card.title}
138
+ </span>
139
+ {card.dueDate === null ? null : (
140
+ <span className="mt-1 block text-xs text-muted-foreground">
141
+ {card.dueDate.slice(0, 10)}
142
+ </span>
143
+ )}
144
+ </button>
145
+ </li>
146
+ );
147
+ }
148
+
149
+ /**
150
+ * The depth pattern on the right edge of a card.
151
+ *
152
+ * ⚠️ **Right, where no text stands.** Jack's decision 2026-08-08: an indent costs width exactly
153
+ * where the title is, and by the third level the card is a strip. The stripes cost nothing a reader
154
+ * was using.
155
+ *
156
+ * ⚠️ **`aria-hidden`, and the words live on the BUTTON.** A `role="img"` with a label inside a
157
+ * `<button>` reaches nobody: a button's children are presentational in ARIA, and the button already
158
+ * carries a name of its own, which wins. The mark was drawn, the test was green, and the sentence
159
+ * existed for no one — see `labelOf` and `packages/intel/ui/CLAUDE.md`.
160
+ */
161
+ function Stripes({ family, label }: { family: Family; label: string }) {
162
+ if (family.level === 0) return null;
163
+ return (
164
+ <span
165
+ aria-hidden="true"
166
+ // ⚠️ A tooltip BESIDE the accessible name, not instead of it: the name serves whoever cannot
167
+ // see the stripes, the tooltip whoever sees them and wonders what they mean.
168
+ //
169
+ // ⚠️ And therefore NOT `pointer-events-none`. A native `title` hangs on hover, and an element
170
+ // that is not a hit target never gets one — the attribute would sit there, the test would find
171
+ // it, and the tooltip would never appear once. The class is not needed either: this span lies
172
+ // inside the button, so a click reaches it anyway.
173
+ title={label}
174
+ // ⚠️ Flush and gapless, top to bottom. Inset from the edge and spaced apart, the stripes read
175
+ // as decoration somebody added; against the edge and touching, they read as one mark with a
176
+ // countable number of parts, which is what they are.
177
+ className="absolute inset-y-0 right-0 flex"
178
+ >
179
+ {levels(family.level).map((level) => (
180
+ <span
181
+ key={`${family.rootId}-${level}`}
182
+ // ⚠️ Each level a fainter step of the SAME tone, not a different tone. The hue answers
183
+ // "which family", the count answers "how deep" — one channel per question.
184
+ //
185
+ // ⚠️ Clamped, and the clamp survives its own reason. It was added because an unclamped
186
+ // ramp went negative — CSS pins that to zero and the deepest stripes are simply not drawn.
187
+ // Since the pattern caps at six the lowest raw value is 0.25, so the clamp no longer
188
+ // fires; it stays because it is the guard, not the arithmetic, that must hold if the cap
189
+ // ever moves.
190
+ // ⚠️ No rounding. A rounded stripe at full height leaves a light wedge at each end, and
191
+ // two of them beside one another look like a gap that is not there.
192
+ // ⚠️ The width as an inline STYLE, not a class. `w-[${…}px]` is a name Tailwind never
193
+ // sees — it reads class names statically, so the stripes would have no width in the build
194
+ // and be perfectly fine in the tests. An inline value is also the only way one number can
195
+ // rule both the mark and the room reserved for it.
196
+ className={toneClasses[family.tone - 1]}
197
+ style={{ width: `${STRIPE_WIDTH}px`, opacity: Math.max(0.3, 1 - (level - 1) * 0.15) }}
198
+ />
199
+ ))}
200
+ </span>
201
+ );
202
+ }
203
+
204
+ /**
205
+ * ⚠️ **The pattern caps at six, the NAME does not.** Seven stripes are twenty pixels, and nobody
206
+ * counts them — past that the mark stops being a count and becomes texture. The accessible name
207
+ * keeps saying the real level, so nothing is lost, only unread pixels.
208
+ */
209
+ const MAX_STRIPES = 6;
210
+
211
+ /**
212
+ * How wide one stripe is, in pixels.
213
+ *
214
+ * ⚠️ **One number, read by both the mark and the room reserved for it.** Written twice they drift,
215
+ * and the drift is silent: the title keeps its distance from stripes that are no longer that wide,
216
+ * or runs under ones that grew.
217
+ */
218
+ const STRIPE_WIDTH = 4;
219
+
220
+ /**
221
+ * `[1, 2, … min(depth, MAX_STRIPES)]` — the level each stripe stands for.
222
+ *
223
+ * ⚠️ The level, not the index. Both are the same number here, and the rule against index keys is
224
+ * about lists whose order can change; this one cannot, because stripe two is always level two.
225
+ */
226
+ function levels(depth: number): number[] {
227
+ return Array.from({ length: Math.min(depth, MAX_STRIPES) }, (_, step) => step + 1);
228
+ }
229
+
230
+ /**
231
+ * ⚠️ Written out rather than built from a template string. Tailwind reads class names statically,
232
+ * and `bg-board-family-${n}` is a name it never sees — the stripes would be invisible in the build
233
+ * and perfectly fine in the tests.
234
+ */
235
+ const toneClasses = [
236
+ "bg-board-family-1",
237
+ "bg-board-family-2",
238
+ "bg-board-family-3",
239
+ "bg-board-family-4",
240
+ "bg-board-family-5",
241
+ "bg-board-family-6",
242
+ ] as const;
243
+
244
+ function cardById(board: BoardHandle, id: string): BoardTask | undefined {
245
+ return board.columns.flatMap((entry) => entry.cards).find((card) => card.id === id);
246
+ }
247
+
248
+ /**
249
+ * One column, with the way to add a card to it.
250
+ *
251
+ * ⚠️ **`group/column` sits on the section, and that is only safe because columns do not nest.**
252
+ * A named group compiles to a DESCENDANT selector: put it on something that also holds another
253
+ * column and hovering one card lights the add row of every column below it — the tree paid for that
254
+ * with #252, and `packages/intel/ui/CLAUDE.md` carries the rule. Here the sections are siblings, so a
255
+ * card in one is never a descendant of another. `board-kanban.unit.tsx` asserts exactly that,
256
+ * structurally, because jsdom computes no hover.
257
+ */
258
+ function Column({
259
+ entry,
260
+ board,
261
+ onMove,
262
+ onOpen,
263
+ families,
264
+ columnOf,
265
+ }: {
266
+ entry: ColumnWithCards;
267
+ board: BoardHandle;
268
+ onMove(card: BoardTask, drop: Drop): void;
269
+ onOpen(taskId: string): void;
270
+ families: Map<string, Family>;
271
+ columnOf: Map<string, string>;
272
+ }) {
273
+ const i18n = useI18n();
274
+ const [composing, setComposing] = useState(false);
275
+ const [draft, setDraft] = useState("");
276
+ const [failed, setFailed] = useState(false);
277
+ // ⚠️ The column is a drop target in its OWN right, and it means "append": the free area below
278
+ // the last card. Without it a board could never take a card into an empty column — there would be
279
+ // no card there to let go on.
280
+ const { setNodeRef: setDropRef } = useDroppable({
281
+ id: entry.column.id,
282
+ disabled: board.isWriting,
283
+ });
284
+ // ⚠️ A column the board does not configure takes no new card: the server refuses a status that
285
+ // names no column (`unknown_column`), so offering the control would promise a refusal.
286
+ const addable = entry.unknown !== true;
287
+
288
+ // ⚠️ One way out, and it clears the REFUSAL too. Left standing, the message outlives the attempt
289
+ // it belongs to: somebody types, is refused, presses Escape — and the column keeps a sentence
290
+ // about a card nobody is trying to create any more, until the next attempt happens to clear it.
291
+ const close = () => {
292
+ setComposing(false);
293
+ setDraft("");
294
+ setFailed(false);
295
+ };
296
+
297
+ const create = () => {
298
+ const title = draft.trim();
299
+ if (title.length === 0 || board.isWriting) return;
300
+ setFailed(false);
301
+ board
302
+ .createTask({
303
+ title,
304
+ status: entry.column.id,
305
+ // ⚠️ Spelled out because the handle takes the contract's OUTPUT type, where every default
306
+ // has already been applied and the fields are therefore required. The server decides the
307
+ // position — the end of this column — and a retry does not move the card again (#648).
308
+ assigneeId: null,
309
+ labels: [],
310
+ startDate: null,
311
+ dueDate: null,
312
+ dependsOn: null,
313
+ // A card made in a column sits on the board itself. Making a SUBTASK is the same call with
314
+ // the parent named — that is the detail view's business (#671), not the column's.
315
+ parentTaskId: null,
316
+ idempotencyKey: crypto.randomUUID(),
317
+ })
318
+ .then(close)
319
+ // ⚠️ Reported IN the column. A refusal shown only in a global banner leaves the reader
320
+ // looking at the column they typed into, which says nothing happened.
321
+ .catch(() => setFailed(true));
322
+ };
323
+
324
+ return (
325
+ <section
326
+ ref={setDropRef}
327
+ aria-label={entry.column.title}
328
+ className="group/column flex w-64 shrink-0 flex-col gap-2"
329
+ >
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} />
337
+ {entry.column.title}
338
+ <span className="text-xs text-muted-foreground tabular-nums">{entry.cards.length}</span>
339
+ {entry.unknown === true ? (
340
+ // ⚠️ A column the board does not configure — cards whose status names nothing. Drawn
341
+ // rather than hidden: a card nobody can see is a card somebody will look for.
342
+ <span className="text-xs text-muted-foreground">{i18n.t("board.unknownColumn")}</span>
343
+ ) : null}
344
+ {addable ? (
345
+ // ⚠️ The narrow-screen way in, and it is ALWAYS visible: hover is not a gesture a touch
346
+ // screen has, so the row below would be unreachable there. Above `md` this one steps
347
+ // aside for it.
348
+ <button
349
+ type="button"
350
+ aria-label={i18n.t("board.addCardIn", { column: entry.column.title })}
351
+ onClick={() => setComposing(true)}
352
+ className="ml-auto rounded-md px-2 text-sm outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring md:hidden"
353
+ >
354
+ +
355
+ </button>
356
+ ) : null}
357
+ </h3>
358
+ <SortableContext
359
+ items={entry.cards.map((card) => card.id)}
360
+ strategy={verticalListSortingStrategy}
361
+ >
362
+ <ul className="flex flex-col gap-2">
363
+ {entry.cards.map((card) => (
364
+ <Card
365
+ key={card.id}
366
+ card={card}
367
+ column={entry}
368
+ board={board}
369
+ onMove={onMove}
370
+ onOpen={onOpen}
371
+ family={families.get(card.id) ?? noFamily}
372
+ parentColumn={
373
+ card.parentTaskId === null ? undefined : columnOf.get(card.parentTaskId)
374
+ }
375
+ />
376
+ ))}
377
+ </ul>
378
+ </SortableContext>
379
+ {/* ⚠️ **Nothing at all under an empty column.** The sentence said what the emptiness already
380
+ says, and the surface I first put in its place was an invention with a false reason: the
381
+ column is a drop target through `setDropRef` on the section above, with or without
382
+ anything drawn in it — `dragEndToDrop` answers `{ status, index: 0 }` for a column with no
383
+ cards, and `neighbourDrop` reaches one by index alone. */}
384
+ {addable ? (
385
+ composing ? (
386
+ <input
387
+ // The field exists because it was just asked for, and making the reader click a second
388
+ // time is the whole cost of the control.
389
+ // biome-ignore lint/a11y/noAutofocus: it opens on a deliberate click, never on load
390
+ autoFocus
391
+ value={draft}
392
+ aria-label={i18n.t("board.addCardIn", { column: entry.column.title })}
393
+ onChange={(event) => setDraft(event.target.value)}
394
+ onKeyDown={(event) => {
395
+ if (event.key === "Enter") {
396
+ event.preventDefault();
397
+ create();
398
+ }
399
+ if (event.key === "Escape") close();
400
+ }}
401
+ onBlur={() => {
402
+ if (draft.trim().length === 0) close();
403
+ }}
404
+ className="rounded-md border bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
405
+ />
406
+ ) : (
407
+ // ⚠️ `md:opacity-0` plus BOTH group states, not `hover` alone: a control that only exists
408
+ // while a pointer rests on the column does not exist for a keyboard at all. Focus inside
409
+ // the column brings it back, the same shape the tree uses for its plus.
410
+ <button
411
+ type="button"
412
+ onClick={() => setComposing(true)}
413
+ className="hidden rounded-md px-2 py-1 text-left text-sm text-muted-foreground outline-none transition-opacity hover:bg-muted focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-focus-within/column:opacity-100 group-hover/column:opacity-100 md:block md:opacity-0"
414
+ >
415
+ {i18n.t("board.addCard")}
416
+ </button>
417
+ )
418
+ ) : null}
419
+ {failed ? (
420
+ <p role="alert" className="px-1 text-xs text-destructive">
421
+ {i18n.t("board.createFailed")}
422
+ </p>
423
+ ) : null}
424
+ </section>
425
+ );
426
+ }
427
+
428
+ export function BoardKanban({
429
+ board,
430
+ onOpen,
431
+ }: {
432
+ board: BoardHandle;
433
+ onOpen(taskId: string): void;
434
+ }) {
435
+ const i18n = useI18n();
436
+ // The card under the pointer, for the preview. Nothing else about the gesture is state.
437
+ const [carried, setCarried] = useState<string | null>(null);
438
+
439
+ /**
440
+ * ⚠️ **A distance before the drag starts, or the card stops being a button.** Without it every
441
+ * press is a gesture: `dnd-kit` swallows the click that follows, and opening a card by clicking
442
+ * it — the thing the card is mostly for — silently stops working.
443
+ */
444
+ const sensors = useSensors(
445
+ useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
446
+ useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
447
+ );
448
+
449
+ if (board.isPending) {
450
+ return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
451
+ }
452
+ if (board.isError) {
453
+ return (
454
+ <p role="alert" className="p-6 text-sm text-destructive">
455
+ {i18n.t("board.failed")}
456
+ </p>
457
+ );
458
+ }
459
+
460
+ const move = (card: BoardTask, drop: Drop) => {
461
+ const write = dropToWrite(card, board.columns, drop);
462
+ // ⚠️ `null` means the card is where it already was. Writing anyway would recompute the position
463
+ // and make it jump — the most common "drop" there is somebody putting a card back.
464
+ if (write === null) return;
465
+ void board.updateTask({
466
+ taskId: card.id,
467
+ status: write.status,
468
+ position: write.position,
469
+ idempotencyKey: `move-${card.id}-${write.status}-${write.position}`,
470
+ });
471
+ };
472
+
473
+ /**
474
+ * What a finished gesture means as a write, or `null` for "nothing changed".
475
+ *
476
+ * ⚠️ Shared by the handler and the announcement on purpose. Computed twice they would drift, and
477
+ * the drift would be invisible: the screen reader would report a move nobody made, or stay quiet
478
+ * about one that happened.
479
+ *
480
+ * ⚠️ **The lock belongs HERE, not in the caller.** With it one level up, a write starting
481
+ * underneath a running gesture stopped the handler and left the announcement saying "moved to
482
+ * Offen" over zero writes — the drift the paragraph above claims to rule out, restored by the
483
+ * one line that was not shared.
484
+ */
485
+ const resolve = (event: Pick<DragEndEvent, "active" | "over">) => {
486
+ if (board.isWriting) return null;
487
+ const dragged = cardById(board, String(event.active.id));
488
+ if (dragged === undefined) return null;
489
+ const drop = dragEndToDrop(
490
+ String(event.active.id),
491
+ event.over === null ? null : String(event.over.id),
492
+ board.columns,
493
+ dropIsAfter(event.active.rect.current.translated, event.over?.rect),
494
+ );
495
+ return drop === null ? null : dropToWrite(dragged, board.columns, drop);
496
+ };
497
+
498
+ /**
499
+ * ⚠️ **ONE derivation for the whole board, not one per card** — and that has to include the
500
+ * lookup the LABEL needs, not only the families. `dnd-kit` re-renders on every pointer move while
501
+ * a card is carried, so anything per-card that scans the board is quadratic: measured at 800
502
+ * cards it was 36 ms per render before, and a `find` left in the label put the same shape back
503
+ * at 5 ms per 1600 cards.
504
+ */
505
+ const families = familiesOf(board.columns.flatMap((entry) => entry.cards));
506
+ const columnOf = new Map(
507
+ board.columns.flatMap((entry) => entry.cards.map((card) => [card.id, entry.column.title])),
508
+ );
509
+
510
+ const carriedCard = carried === null ? undefined : cardById(board, carried);
511
+
512
+ return (
513
+ <DndContext
514
+ sensors={sensors}
515
+ // ⚠️ Corners, not centres. A card is far taller than the gap between two cards, so the closest
516
+ // CENTRE stays the same card across most of a column and the gesture feels stuck; the closest
517
+ // corner changes at the boundary the reader is aiming at.
518
+ collisionDetection={closestCorners}
519
+ // ⚠️ `dnd-kit`'s own wording announces the space bar, and no keyboard sensor is registered —
520
+ // pressing it does nothing. Announcing a gesture that is not there is worse than announcing
521
+ // none: the reader tries it, nothing happens, and the one that works is never mentioned.
522
+ accessibility={{
523
+ screenReaderInstructions: { draggable: i18n.t("board.dragInstructions") },
524
+ // ⚠️ `dnd-kit`'s own announcements read out the raw ids — "Draggable item a was moved over
525
+ // droppable area a" — and report a drop even when nothing was written. Both are replaced:
526
+ // the card is named by its TITLE, the move-over is silent because there is nothing about a
527
+ // position this code could say honestly, and the end says what actually happened.
528
+ announcements: {
529
+ onDragStart: ({ active }) => {
530
+ const card = cardById(board, String(active.id));
531
+ return card === undefined
532
+ ? undefined
533
+ : i18n.t("board.drag.lifted", { card: card.title });
534
+ },
535
+ onDragOver: () => undefined,
536
+ onDragMove: () => undefined,
537
+ onDragEnd: (event) => {
538
+ const write = resolve(event);
539
+ const card = cardById(board, String(event.active.id));
540
+ // ⚠️ Silence, not a sentence without a subject. A card that vanished mid-gesture — an
541
+ // answer arrived, somebody else archived it — has no name left to announce, and
542
+ // " put back" read out on its own says less than nothing.
543
+ if (card === undefined) return undefined;
544
+ if (write === null) return i18n.t("board.drag.putBack", { card: card.title });
545
+ return i18n.t("board.drag.moved", {
546
+ card: card.title,
547
+ column:
548
+ board.columns.find((entry) => entry.column.id === write.status)?.column.title ??
549
+ write.status,
550
+ });
551
+ },
552
+ onDragCancel: ({ active }) => {
553
+ const card = cardById(board, String(active.id));
554
+ return card === undefined
555
+ ? undefined
556
+ : i18n.t("board.drag.putBack", { card: card.title });
557
+ },
558
+ },
559
+ }}
560
+ onDragStart={(event) => setCarried(String(event.active.id))}
561
+ onDragCancel={() => setCarried(null)}
562
+ onDragEnd={(event) => {
563
+ setCarried(null);
564
+ // ⚠️ The SECOND lock lives inside `resolve`, so the announcement obeys it too — see there.
565
+ const write = resolve(event);
566
+ if (write === null) return;
567
+ void board.updateTask({
568
+ taskId: String(event.active.id),
569
+ status: write.status,
570
+ position: write.position,
571
+ idempotencyKey: `move-${event.active.id}-${write.status}-${write.position}`,
572
+ });
573
+ }}
574
+ >
575
+ {/* ⚠️ **`min-h-0 flex-1`, or the scrollbar sits in the middle of the screen.** A box with no
576
+ height shrinks to its content, and the horizontal bar clings to the bottom of the tallest
577
+ column: on a board with few cards that looks broken. The panel above already offers the
578
+ full height; this is where it is taken.
579
+
580
+ ⚠️ **And NO `items-start` with it.** A stretched column paints nothing — the section
581
+ carries neither background nor border — but the rectangle it stretches to is what
582
+ `useDroppable` measures and `closestCorners` reckons against. Held to its content instead,
583
+ an empty column shrinks to its heading, and the area a card can be dropped into shrinks
584
+ with it. */}
585
+ <div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4" aria-busy={board.isWriting}>
586
+ {board.columns.map((column) => (
587
+ <Column
588
+ key={column.column.id}
589
+ entry={column}
590
+ board={board}
591
+ onMove={move}
592
+ onOpen={onOpen}
593
+ families={families}
594
+ columnOf={columnOf}
595
+ />
596
+ ))}
597
+ </div>
598
+ {/* ⚠️ The preview is what a drag looks like. Without it the card stays put and only the
599
+ cursor moves, and there is nothing on screen saying the gesture was picked up at all. */}
600
+ <DragOverlay>
601
+ {carriedCard === undefined ? null : (
602
+ <div /* ⚠️ The same surface as the card it stands for (#748), plus the shadow that is the ONE
603
+ thing this element says beyond it: this one is lifted. A card that changed colour on
604
+ being picked up would read as a different card. */
605
+ className="w-64 rounded-md bg-muted p-2 text-left text-sm shadow-lg"
606
+ >
607
+ {carriedCard.title}
608
+ </div>
609
+ )}
610
+ </DragOverlay>
611
+ </DndContext>
612
+ );
613
+ }
614
+
615
+ /** A card that belongs to no family — the answer when the board has not been read yet. */
616
+ const noFamily: Family = { level: 0, tone: 0, rootId: null };
617
+
618
+ /**
619
+ * What the card's button is CALLED.
620
+ *
621
+ * ⚠️ **This is where the depth becomes readable, and the only place it can be.** The stripes are
622
+ * `aria-hidden` because a button's children are presentational: a `role="img"` with a label inside
623
+ * one is never announced, and the button's own name wins in any case. The board already spends a
624
+ * colour axis on the status, so the family may not be a second one — a reader who does not see the
625
+ * difference has to be able to read it.
626
+ */
627
+ function labelOf(
628
+ i18n: ReturnType<typeof useI18n>,
629
+ card: BoardTask,
630
+ column: ColumnWithCards,
631
+ family: Family,
632
+ parentColumn: string | undefined,
633
+ ): string {
634
+ const name = i18n.t("board.cardLabel", { title: card.title, column: column.column.title });
635
+ if (family.level === 0) return name;
636
+ // ⚠️ Handed IN, not looked up. A `find` over the board here runs once per card on every render,
637
+ // and puts back the quadratic shape the one derivation above exists to remove.
638
+ return `${name} ${depthOf(i18n, family, parentColumn)}`;
639
+ }
640
+
641
+ /**
642
+ * What the depth pattern says, in words.
643
+ *
644
+ * ⚠️ Always the REAL level, even where the stripes have capped. The mark stops counting at six; the
645
+ * sentence does not, because it is the channel somebody relies on when they cannot count stripes.
646
+ */
647
+ function depthOf(
648
+ i18n: ReturnType<typeof useI18n>,
649
+ family: Family,
650
+ parentColumn: string | undefined,
651
+ ): string {
652
+ if (family.level === 0) return "";
653
+ return parentColumn === undefined
654
+ ? i18n.t("board.stripes.root", { level: family.level })
655
+ : i18n.t("board.stripes.under", { level: family.level, column: parentColumn });
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
+ }