@starlove/orbit 1.4.0 → 2.0.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 (329) hide show
  1. package/Dockerfile +10 -8
  2. package/README.md +146 -164
  3. package/dist/full/csp.txt +1 -0
  4. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
  5. package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
  6. package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
  7. package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
  8. package/dist/full/fonts/Mona_Sans/README.txt +10 -0
  9. package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
  10. package/dist/full/fonts/Newsreader/README.txt +8 -0
  11. package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
  12. package/dist/full/index.html +36 -653
  13. package/dist/full/js/app.BmuponPJ.js +32 -0
  14. package/dist/full/js/app.BmuponPJ.js.map +1 -0
  15. package/dist/full/js/chunk.1fJGQGbW.js +2 -0
  16. package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
  17. package/dist/full/js/chunk.5D7JO2zp.js +2 -0
  18. package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
  19. package/dist/full/js/chunk.BrNn_D79.js +2 -0
  20. package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
  21. package/dist/full/js/chunk.CokJXIqM.js +2 -0
  22. package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
  23. package/dist/full/js/chunk.D_Haqy6R.js +2 -0
  24. package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
  25. package/dist/full/js/chunk.RO0jsH6I.js +15 -0
  26. package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
  27. package/dist/full/js/chunk.dqWOEtBM.js +2 -0
  28. package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
  29. package/dist/full/js/warm.f260bbbc.js +15 -0
  30. package/dist/full/orbits-favicon.png +0 -0
  31. package/dist/full/orbits-logo-bg-wide.png +0 -0
  32. package/dist/full/orbits-logo-bg.png +0 -0
  33. package/dist/full/orbits-logo.png +0 -0
  34. package/dist/full/styles.2b154ffe.css +1 -0
  35. package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
  36. package/dist/full/version.json +2 -2
  37. package/docs/AGENT_GUIDE.md +114 -0
  38. package/docs/UPDATING.md +249 -64
  39. package/lib/adapters/indexeddb/blobs.d.ts +3 -0
  40. package/lib/adapters/indexeddb/blobs.js +54 -0
  41. package/lib/adapters/indexeddb/database.d.ts +60 -0
  42. package/lib/adapters/indexeddb/database.js +126 -0
  43. package/lib/adapters/indexeddb/index.d.ts +5 -0
  44. package/lib/adapters/indexeddb/index.js +3 -0
  45. package/lib/adapters/indexeddb/locks.d.ts +14 -0
  46. package/lib/adapters/indexeddb/locks.js +40 -0
  47. package/lib/adapters/indexeddb/store.d.ts +40 -0
  48. package/lib/adapters/indexeddb/store.js +424 -0
  49. package/lib/adapters/indexeddb/tables.d.ts +43 -0
  50. package/lib/adapters/indexeddb/tables.js +127 -0
  51. package/lib/adapters/memory/blobs.d.ts +3 -0
  52. package/lib/adapters/memory/blobs.js +44 -0
  53. package/lib/adapters/memory/board.d.ts +41 -0
  54. package/lib/adapters/memory/board.js +521 -0
  55. package/lib/adapters/memory/index.d.ts +2 -0
  56. package/lib/adapters/memory/index.js +2 -0
  57. package/lib/adapters/memory/search.d.ts +6 -0
  58. package/lib/adapters/memory/search.js +30 -0
  59. package/lib/adapters/memory/store.d.ts +34 -0
  60. package/lib/adapters/memory/store.js +266 -0
  61. package/lib/adapters/sqlite/backups.d.ts +53 -0
  62. package/lib/adapters/sqlite/backups.js +232 -0
  63. package/lib/adapters/sqlite/blobs.d.ts +14 -0
  64. package/lib/adapters/sqlite/blobs.js +109 -0
  65. package/lib/adapters/sqlite/board.d.ts +77 -0
  66. package/lib/adapters/sqlite/board.js +328 -0
  67. package/lib/adapters/sqlite/database.d.ts +67 -0
  68. package/lib/adapters/sqlite/database.js +285 -0
  69. package/lib/adapters/sqlite/index.d.ts +3 -0
  70. package/lib/adapters/sqlite/index.js +2 -0
  71. package/lib/adapters/sqlite/lock.d.ts +11 -0
  72. package/lib/adapters/sqlite/lock.js +34 -0
  73. package/lib/adapters/sqlite/records.d.ts +54 -0
  74. package/lib/adapters/sqlite/records.js +546 -0
  75. package/lib/adapters/sqlite/schema.d.ts +54 -0
  76. package/lib/adapters/sqlite/schema.js +276 -0
  77. package/lib/adapters/sqlite/store.d.ts +39 -0
  78. package/lib/adapters/sqlite/store.js +385 -0
  79. package/lib/browser/in-page.d.ts +31 -0
  80. package/lib/browser/in-page.js +46 -0
  81. package/lib/browser/old-preview.d.ts +13 -0
  82. package/lib/browser/old-preview.js +80 -0
  83. package/lib/browser/preview-board.d.ts +8 -0
  84. package/lib/browser/preview-board.js +69 -0
  85. package/lib/cli/main.d.ts +7 -0
  86. package/lib/cli/main.js +343 -0
  87. package/lib/cli/orbit.d.ts +8 -0
  88. package/lib/cli/orbit.js +25 -0
  89. package/lib/client/http.d.ts +38 -0
  90. package/lib/client/http.js +334 -0
  91. package/lib/client/local.d.ts +26 -0
  92. package/lib/client/local.js +190 -0
  93. package/lib/client/object-url.d.ts +8 -0
  94. package/lib/client/object-url.js +21 -0
  95. package/lib/client/types.d.ts +268 -0
  96. package/lib/client/types.js +8 -0
  97. package/lib/commands/agent-tools.d.ts +31 -0
  98. package/lib/commands/agent-tools.js +100 -0
  99. package/lib/commands/index.d.ts +56 -0
  100. package/lib/commands/index.js +298 -0
  101. package/lib/commands/parse.d.ts +73 -0
  102. package/lib/commands/parse.js +231 -0
  103. package/lib/commands/present.d.ts +134 -0
  104. package/lib/commands/present.js +198 -0
  105. package/lib/commands/session.d.ts +85 -0
  106. package/lib/commands/session.js +138 -0
  107. package/lib/commands/verbs.d.ts +35 -0
  108. package/lib/commands/verbs.js +842 -0
  109. package/lib/core/actors.d.ts +18 -0
  110. package/lib/core/actors.js +45 -0
  111. package/lib/core/agent.d.ts +128 -0
  112. package/lib/core/agent.js +323 -0
  113. package/lib/core/boards.d.ts +113 -0
  114. package/lib/core/boards.js +288 -0
  115. package/lib/core/bootstrap.d.ts +47 -0
  116. package/lib/core/bootstrap.js +100 -0
  117. package/lib/core/bytes.d.ts +10 -0
  118. package/lib/core/bytes.js +52 -0
  119. package/lib/core/card-paths.d.ts +25 -0
  120. package/lib/core/card-paths.js +44 -0
  121. package/lib/core/context.d.ts +60 -0
  122. package/lib/core/context.js +42 -0
  123. package/lib/core/cross-board.d.ts +81 -0
  124. package/lib/core/cross-board.js +228 -0
  125. package/lib/core/describe.d.ts +21 -0
  126. package/lib/core/describe.js +81 -0
  127. package/lib/core/errors.d.ts +159 -0
  128. package/lib/core/errors.js +160 -0
  129. package/lib/core/files.d.ts +79 -0
  130. package/lib/core/files.js +262 -0
  131. package/lib/core/history.d.ts +20 -0
  132. package/lib/core/history.js +53 -0
  133. package/lib/core/image-types.d.ts +16 -0
  134. package/lib/core/image-types.js +36 -0
  135. package/lib/core/model.d.ts +263 -0
  136. package/lib/core/model.js +34 -0
  137. package/lib/core/nebula-link.d.ts +14 -0
  138. package/lib/core/nebula-link.js +39 -0
  139. package/lib/core/relations.d.ts +58 -0
  140. package/lib/core/relations.js +112 -0
  141. package/lib/core/search.d.ts +38 -0
  142. package/lib/core/search.js +92 -0
  143. package/lib/core/sessions.d.ts +44 -0
  144. package/lib/core/sessions.js +158 -0
  145. package/lib/core/snapshot-schema.d.ts +295 -0
  146. package/lib/core/snapshot-schema.js +82 -0
  147. package/lib/core/snapshots.d.ts +75 -0
  148. package/lib/core/snapshots.js +409 -0
  149. package/lib/core/states.d.ts +25 -0
  150. package/lib/core/states.js +126 -0
  151. package/lib/core/text.d.ts +40 -0
  152. package/lib/core/text.js +113 -0
  153. package/lib/core/tickets.d.ts +69 -0
  154. package/lib/core/tickets.js +311 -0
  155. package/lib/core/trello.d.ts +17 -0
  156. package/lib/core/trello.js +269 -0
  157. package/lib/core/verdicts.d.ts +28 -0
  158. package/lib/core/verdicts.js +92 -0
  159. package/lib/core/views.d.ts +95 -0
  160. package/lib/core/views.js +186 -0
  161. package/lib/http/app.d.ts +46 -0
  162. package/lib/http/app.js +491 -0
  163. package/lib/mcp-server.d.ts +10 -0
  164. package/lib/mcp-server.js +120 -0
  165. package/lib/node/assets.d.ts +7 -0
  166. package/lib/node/assets.js +68 -0
  167. package/lib/node/data-folder.d.ts +21 -0
  168. package/lib/node/data-folder.js +34 -0
  169. package/lib/node/env.d.ts +38 -0
  170. package/lib/node/env.js +49 -0
  171. package/lib/node/host-check.d.ts +24 -0
  172. package/lib/node/host-check.js +53 -0
  173. package/lib/node/host-services.d.ts +9 -0
  174. package/lib/node/host-services.js +150 -0
  175. package/lib/node/local.d.ts +22 -0
  176. package/lib/node/local.js +61 -0
  177. package/lib/node/migrate.d.ts +53 -0
  178. package/lib/node/migrate.js +635 -0
  179. package/lib/node/package.d.ts +7 -0
  180. package/lib/node/package.js +14 -0
  181. package/lib/node/repo-board.d.ts +49 -0
  182. package/lib/node/repo-board.js +245 -0
  183. package/lib/node/serve.d.ts +8 -0
  184. package/lib/node/serve.js +106 -0
  185. package/lib/orbit.d.ts +47 -0
  186. package/lib/orbit.js +44 -0
  187. package/lib/quiet-sqlite-warning.d.ts +1 -0
  188. package/lib/quiet-sqlite-warning.js +18 -0
  189. package/lib/store/blob-path.d.ts +6 -0
  190. package/lib/store/blob-path.js +12 -0
  191. package/lib/store/conformance.d.ts +52 -0
  192. package/lib/store/conformance.js +1449 -0
  193. package/lib/store/search-words.d.ts +10 -0
  194. package/lib/store/search-words.js +19 -0
  195. package/lib/store/types.d.ts +452 -0
  196. package/lib/store/types.js +34 -0
  197. package/llms.txt +63 -0
  198. package/package.json +109 -25
  199. package/src/cli/orbit.js +11 -658
  200. package/src/mcp-server.js +16 -876
  201. package/SKILL-ORBIT.md +0 -151
  202. package/build.mjs +0 -210
  203. package/dist/full/js/app.62905fcb.js +0 -952
  204. package/dist/full/js/app.62905fcb.js.map +0 -7
  205. package/dist/full/styles.4e60d824.css +0 -1
  206. package/docs/AGENT_PROTOCOL.md +0 -424
  207. package/docs/MCP_SETUP.md +0 -54
  208. package/docs/ORBIT_DISPATCH.md +0 -280
  209. package/docs/SS-Orbit-Dark.png +0 -0
  210. package/docs/SS-Orbit-Light.png +0 -0
  211. package/docs/mcp-client.example.json +0 -13
  212. package/docs/setup/orbit-init-wizard.md +0 -174
  213. package/public/apple-touch-icon.png +0 -0
  214. package/public/cursors/live_arrow.cur +0 -0
  215. package/public/cursors/live_link.cur +0 -0
  216. package/public/cursors/live_move.cur +0 -0
  217. package/public/cursors/live_select.cur +0 -0
  218. package/public/cursors/live_unavail.cur +0 -0
  219. package/public/favicon-32x32.png +0 -0
  220. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  221. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  222. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  223. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  224. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  225. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  226. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  227. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  228. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  229. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  230. package/public/fonts/Chakra_Petch/OFL.txt +0 -93
  231. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  232. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  233. package/public/fonts/Inter/OFL.txt +0 -93
  234. package/public/fonts/Inter/README.txt +0 -118
  235. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  236. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  237. package/public/fonts/JetBrains_Mono/README.txt +0 -79
  238. package/public/fonts/OpenRing-Regular.ttf +0 -0
  239. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  240. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  241. package/public/fonts/Open_Sans/README.txt +0 -100
  242. package/public/fonts/Orbitron/OFL.txt +0 -93
  243. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  244. package/public/fonts/Orbitron/README.txt +0 -68
  245. package/public/fonts/Rajdhani/OFL.txt +0 -93
  246. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  247. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  248. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  249. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  250. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  251. package/public/fonts/runescape-uf.ttf +0 -0
  252. package/public/index.html +0 -769
  253. package/public/js/actor-labels.js +0 -65
  254. package/public/js/api.js +0 -39
  255. package/public/js/app.js +0 -45
  256. package/public/js/artifact-drawer.js +0 -79
  257. package/public/js/board-export.js +0 -161
  258. package/public/js/board-menu.js +0 -374
  259. package/public/js/card-actions.js +0 -545
  260. package/public/js/config.js +0 -20
  261. package/public/js/create-card.js +0 -190
  262. package/public/js/dom.js +0 -19
  263. package/public/js/drawer.js +0 -273
  264. package/public/js/font-preference.js +0 -229
  265. package/public/js/format.js +0 -433
  266. package/public/js/help-menu.js +0 -31
  267. package/public/js/icons.js +0 -16
  268. package/public/js/kanban.js +0 -694
  269. package/public/js/local-backend.js +0 -1015
  270. package/public/js/main.js +0 -161
  271. package/public/js/modal.js +0 -33
  272. package/public/js/motion-preference.js +0 -64
  273. package/public/js/panel.js +0 -58
  274. package/public/js/router.js +0 -134
  275. package/public/js/search.js +0 -56
  276. package/public/js/settings-tabs.js +0 -7
  277. package/public/js/settings.js +0 -1634
  278. package/public/js/sse.js +0 -159
  279. package/public/js/starscape.js +0 -332
  280. package/public/js/state.js +0 -129
  281. package/public/js/text-editing.js +0 -128
  282. package/public/js/theme-preference.js +0 -95
  283. package/public/js/ticket-detail.js +0 -1516
  284. package/public/js/ticket-typeahead.js +0 -49
  285. package/public/js/toast.js +0 -89
  286. package/public/js/topbar-notes.js +0 -155
  287. package/public/js/transport.js +0 -22
  288. package/public/js/typeahead.js +0 -206
  289. package/public/js/unread.js +0 -121
  290. package/public/js/update-check.js +0 -58
  291. package/public/js/url-routes.js +0 -94
  292. package/public/orbits-favicon.png +0 -0
  293. package/public/orbits-logo-bg-wide.png +0 -0
  294. package/public/orbits-logo-bg.png +0 -0
  295. package/public/orbits-logo.png +0 -0
  296. package/public/styles.css +0 -7709
  297. package/src/core/agent.js +0 -387
  298. package/src/core/agents-md.js +0 -109
  299. package/src/core/artifacts.js +0 -149
  300. package/src/core/attachments.js +0 -238
  301. package/src/core/auth.js +0 -85
  302. package/src/core/backups.js +0 -142
  303. package/src/core/board.js +0 -148
  304. package/src/core/boards.js +0 -409
  305. package/src/core/bootstrap.js +0 -170
  306. package/src/core/css-bundle.js +0 -30
  307. package/src/core/db.js +0 -315
  308. package/src/core/delete-board.js +0 -163
  309. package/src/core/dispatch.js +0 -1007
  310. package/src/core/events.js +0 -314
  311. package/src/core/http.js +0 -115
  312. package/src/core/migrate.js +0 -198
  313. package/src/core/paths.js +0 -55
  314. package/src/core/provision-repo-board.js +0 -119
  315. package/src/core/queries.js +0 -246
  316. package/src/core/registry.js +0 -211
  317. package/src/core/relations.js +0 -163
  318. package/src/core/review-verdicts.js +0 -138
  319. package/src/core/router.js +0 -556
  320. package/src/core/search.js +0 -183
  321. package/src/core/seed.js +0 -234
  322. package/src/core/snapshots.js +0 -692
  323. package/src/core/states.js +0 -167
  324. package/src/core/system-picker.js +0 -109
  325. package/src/core/tickets.js +0 -400
  326. package/src/core/util.js +0 -106
  327. package/src/core/version-check.js +0 -154
  328. package/src/mcp/orbit-client.js +0 -287
  329. package/src/server.js +0 -115
@@ -1,694 +0,0 @@
1
- // Kanban board rendering plus the drag-to-pan interaction. `renderBoard()`
2
- // rebuilds every column from current state and rewires drop zones, card
3
- // click/drag handlers, and the per-column "+" button. Card detail is
4
- // owned by ticket-detail.js.
5
-
6
- import { kanban } from "./dom.js";
7
- import { state, visibleStatesForProject, ticketsForProject } from "./state.js";
8
- import {
9
- escapeHtml,
10
- ticketLabel,
11
- canonicalTicketType,
12
- typeLabel,
13
- priorityLabel,
14
- priorityKeyFor,
15
- renderLabels,
16
- renderMarkdown
17
- } from "./format.js";
18
- import { api, withBoardQuery } from "./api.js";
19
- import { navigate } from "./router.js";
20
- import { closeCreateFlyout, openCreateFlyout } from "./create-card.js";
21
- import { load } from "./app.js";
22
- import { bindCardContextMenuDelegated, closeCardActionMenu, createCardActionHandlers, setCardActionDragInFlight } from "./card-actions.js";
23
- import { unreadCount } from "./unread.js";
24
-
25
- // Lookup for the current board's tickets, keyed by id. Refreshed on every
26
- // renderBoard() so the single, persistent set of delegated listeners can
27
- // resolve a card's ticket in O(1) without re-binding anything per render.
28
- let boardTicketsById = new Map();
29
- // Created once (lazily) and reused — handlers are stateless w.r.t. a given card.
30
- let cardActionHandlers = null;
31
- // HTML5 DnD ends with a synthetic click on the drag source; this flag lets the
32
- // delegated click handler swallow that one click so a drop doesn't also open
33
- // the ticket drawer. Module-scoped because there's now one shared listener.
34
- let suppressClickAfterDrag = false;
35
-
36
- // renderBoard() is hot: it runs on every drag-drop, every SSE board event, and
37
- // every settings toggle. Keep it to a single innerHTML write plus a Map refresh.
38
- // All interaction wiring lives in enableKanbanInteractions(), attached ONCE to
39
- // the persistent `kanban` element (see below), so re-rendering never re-binds
40
- // per-card/column/button listeners or rescans the ticket list.
41
- export function renderBoard() {
42
- const states = visibleStatesForProject();
43
- const tickets = ticketsForProject();
44
- boardTicketsById = new Map(tickets.map((ticket) => [ticket.id, ticket]));
45
- kanban.innerHTML = states.map((column) => renderColumn(column, tickets)).join("");
46
- }
47
-
48
- function toggleCardExpansion(card) {
49
- if (!card) return;
50
- const id = card.dataset.ticketId;
51
- const willExpand = !state.expandedCardIds.has(id);
52
- if (willExpand) {
53
- state.expandedCardIds.add(id);
54
- // Build the expandable body (markdown parse + labels + relations) the first
55
- // time a card opens. Collapsed cards — the overwhelming majority on render —
56
- // never pay this cost. Once built it stays cached in the DOM for re-toggles.
57
- if (!card.querySelector(".card-expandable")) {
58
- const ticket = boardTicketsById.get(id);
59
- const meta = card.querySelector(".card-meta");
60
- if (ticket && meta) meta.insertAdjacentHTML("afterend", renderCardExpandable(ticket));
61
- }
62
- } else {
63
- state.expandedCardIds.delete(id);
64
- }
65
- card.classList.toggle("is-expanded", willExpand);
66
- const toggleBtn = card.querySelector("button.card-expand-trigger");
67
- if (toggleBtn) toggleBtn.setAttribute("aria-expanded", willExpand ? "true" : "false");
68
- }
69
-
70
- // One-time setup: attach delegated listeners to the persistent board container.
71
- // Because events bubble to `kanban`, these survive every innerHTML swap, so
72
- // renderBoard() does zero listener work regardless of card count.
73
- export function enableKanbanInteractions() {
74
- cardActionHandlers = cardActionHandlers || createCardActionHandlers({ navigator: navigate });
75
-
76
- // --- Drag & drop (HTML5 DnD events bubble, so one set covers all cards) ---
77
- let dragOverColumn = null;
78
- const setDragOverColumn = (column) => {
79
- if (dragOverColumn === column) return;
80
- if (dragOverColumn) dragOverColumn.classList.remove("drag-over");
81
- dragOverColumn = column;
82
- if (dragOverColumn) dragOverColumn.classList.add("drag-over");
83
- };
84
-
85
- kanban.addEventListener("dragstart", (event) => {
86
- const card = event.target instanceof Element ? event.target.closest(".card") : null;
87
- if (!card) return;
88
- closeCardActionMenu();
89
- setCardActionDragInFlight(true);
90
- event.dataTransfer.setData("text/plain", card.dataset.ticketId);
91
- event.dataTransfer.effectAllowed = "move";
92
- });
93
-
94
- kanban.addEventListener("dragover", (event) => {
95
- const column = event.target instanceof Element ? event.target.closest(".column") : null;
96
- if (!column) return;
97
- event.preventDefault();
98
- setDragOverColumn(column);
99
- });
100
-
101
- kanban.addEventListener("dragend", () => {
102
- setCardActionDragInFlight(false);
103
- setDragOverColumn(null);
104
- suppressClickAfterDrag = true;
105
- // If no synthetic click follows (e.g. drag cancelled oddly), don't eat the
106
- // next real click.
107
- setTimeout(() => {
108
- suppressClickAfterDrag = false;
109
- }, 0);
110
- });
111
-
112
- kanban.addEventListener("drop", async (event) => {
113
- const column = event.target instanceof Element ? event.target.closest(".column") : null;
114
- if (!column) return;
115
- event.preventDefault();
116
- setDragOverColumn(null);
117
- const ticketId = event.dataTransfer.getData("text/plain");
118
- if (!ticketId) return;
119
- closeCreateFlyout();
120
- await api(withBoardQuery(`/api/tickets/${ticketId}`), {
121
- method: "PATCH",
122
- body: { state_id: column.dataset.stateId }
123
- });
124
- // Refresh board only; do not open the ticket drawer (DnD also fires a
125
- // synthetic click on the card — suppressed by suppressClickAfterDrag).
126
- await load();
127
- });
128
-
129
- // --- Clicks (precedence: add-button > expand toggle > link > epic header > card) ---
130
- kanban.addEventListener("click", (event) => {
131
- const target = event.target instanceof Element ? event.target : event.target?.parentElement;
132
- if (!target) return;
133
-
134
- const addBtn = target.closest("[data-add-to-state]");
135
- if (addBtn) {
136
- event.stopPropagation();
137
- openCreateFlyout(addBtn.dataset.addToState || "");
138
- return;
139
- }
140
-
141
- const toggle = target.closest("button.card-expand-trigger");
142
- if (toggle) {
143
- event.stopPropagation();
144
- event.preventDefault();
145
- toggleCardExpansion(toggle.closest(".card"));
146
- return;
147
- }
148
-
149
- // Anchors inside a card keep their native behavior.
150
- if (target.closest("a")) {
151
- event.stopPropagation();
152
- return;
153
- }
154
-
155
- const epicHeader = target.closest(".epic-mini-header[data-ticket-id]");
156
- if (epicHeader) {
157
- closeCreateFlyout();
158
- navigate({ boardId: state.boardId, view: "ticket", ticketId: epicHeader.dataset.ticketId });
159
- return;
160
- }
161
-
162
- const card = target.closest(".card");
163
- if (!card) return;
164
- if (suppressClickAfterDrag) {
165
- suppressClickAfterDrag = false;
166
- event.preventDefault();
167
- event.stopPropagation();
168
- return;
169
- }
170
- closeCreateFlyout();
171
- navigate({ boardId: state.boardId, view: "ticket", ticketId: card.dataset.ticketId });
172
- });
173
-
174
- // --- Context menu ---
175
- bindCardContextMenuDelegated(kanban, (id) => boardTicketsById.get(id), {
176
- handlers: cardActionHandlers,
177
- onBeforeOpen: closeCreateFlyout
178
- });
179
- }
180
-
181
- export function renderBoardSelection() {
182
- kanban.querySelectorAll(".card.selected").forEach((card) => card.classList.remove("selected"));
183
- if (!state.selectedTicketId) return;
184
- kanban
185
- .querySelectorAll(`.card[data-ticket-id="${CSS.escape(state.selectedTicketId)}"]`)
186
- .forEach((card) => card.classList.add("selected"));
187
- }
188
-
189
- function renderEmptyState(columnName) {
190
- const name = columnName.toLowerCase();
191
- let flair = {
192
- icon: `<svg class="empty-state-icon" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12" opacity="0.5" /></svg>`,
193
- title: "Nothing here",
194
- subtitle: "Tasks will appear when ready"
195
- };
196
-
197
- if (name === "ai ready") {
198
- flair = {
199
- icon: `<svg class="empty-state-icon ai-sparkle" viewBox="0 0 24 24">
200
- <path d="M12 3L14.5 9L21 11.5L14.5 14L12 20L9.5 14L3 11.5L9.5 9L12 3Z" fill="currentColor" opacity="0.65" />
201
- <circle cx="12" cy="11.5" r="1.8" fill="#fff" />
202
- </svg>`,
203
- title: "Ready for agents",
204
- subtitle: "Drop unblocked cards here — the claim-next tool picks them up"
205
- };
206
- } else if (name.includes("progress")) {
207
- flair = {
208
- icon: `<svg class="empty-state-icon orbit" viewBox="0 0 100 100">
209
- <circle cx="50" cy="50" r="10" fill="currentColor" opacity="0.9" />
210
- <circle class="ring" cx="50" cy="50" r="30" stroke="currentColor" stroke-dasharray="4 6" />
211
- <circle class="satellite" cx="80" cy="50" r="4" fill="currentColor" />
212
- </svg>`,
213
- title: "Work in progress",
214
- subtitle: "Active work will appear here"
215
- };
216
- } else if (name.includes("review")) {
217
- flair = {
218
- icon: `<svg class="empty-state-icon star" viewBox="0 0 24 24">
219
- <path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z" fill="currentColor" />
220
- </svg>`,
221
- title: "Under review",
222
- subtitle: "Quality gate before merge"
223
- };
224
- } else if (name.includes("todo") || name.includes("backlog")) {
225
- flair = {
226
- icon: `<svg class="empty-state-icon ai-sparkle" viewBox="0 0 24 24">
227
- <path d="M12 3L14.5 9L21 11.5L14.5 14L12 20L9.5 14L3 11.5L9.5 9L12 3Z" fill="currentColor" opacity="0.5" />
228
- <path d="M19 8L20 10L21 8L20 6L19 8ZM19 16L20 18L21 16L20 14L19 16ZM5 8L6 10L7 8L6 6L5 8Z" fill="currentColor" />
229
- </svg>`,
230
- title: "Ready for launch",
231
- subtitle: "Create a card manually or task an AI to generate tasks"
232
- };
233
- } else if (name.includes("done") || name.includes("finished")) {
234
- flair = {
235
- icon: `<svg class="empty-state-icon" viewBox="0 0 24 24">
236
- <circle cx="12" cy="12" r="10" />
237
- <path d="M8 12L11 15L16 9" />
238
- </svg>`,
239
- title: "Done",
240
- subtitle: "Move completed tickets here"
241
- };
242
- } else if (name.includes("cancel")) {
243
- flair = {
244
- icon: `<svg class="empty-state-icon" viewBox="0 0 24 24">
245
- <circle cx="12" cy="12" r="10" opacity="0.2" />
246
- <path d="M15 9L9 15M9 9l6 6" />
247
- </svg>`,
248
- title: "Not moving forward",
249
- subtitle: "Parked or replaced"
250
- };
251
- }
252
-
253
- return `
254
- <div class="column-empty-state">
255
- <div class="empty-state-visual">
256
- <div class="empty-state-glow"></div>
257
- ${flair.icon}
258
- </div>
259
- <div class="empty-state-title">${escapeHtml(flair.title)}</div>
260
- <div class="empty-state-subtitle">${escapeHtml(flair.subtitle)}</div>
261
- </div>
262
- `;
263
- }
264
-
265
- function renderColumn(column, tickets) {
266
- const columnTickets = tickets.filter((ticket) => ticket.state_id === column.id);
267
- const isEmpty = columnTickets.length === 0;
268
- // The "AI Ready" lane is a contract between humans and agents: cards here
269
- // are the queue the claim-next MCP tool pulls from. Mark it so humans can
270
- // instantly see where agent-facing staging lives.
271
- const isAiReady = column.name === "AI Ready";
272
- const columnClasses = ["column", "lane"];
273
- if (isAiReady) columnClasses.push("column--ai-ready");
274
- const accentAttr = isAiReady ? ' data-accent="ai-ready"' : "";
275
-
276
- return `
277
- <section class="${columnClasses.join(" ")}" data-flat${accentAttr} data-state-id="${column.id}" data-state-name="${escapeHtml(column.name)}">
278
- <div class="column-head lane-head">
279
- <h2 class="lane-title"${isAiReady ? ' title="Agent staging — cards here are picked up by the claim-next tool when unblocked"' : ""}>
280
- ${isAiReady ? '<span class="agent-dot" aria-hidden="true"></span>' : ""}${escapeHtml(column.name)}
281
- </h2>
282
- <div class="column-head-actions lane-actions">
283
- <button type="button" class="column-add-btn" data-variant="plus" data-add-to-state="${escapeHtml(column.id)}" title="New card">+</button>
284
- <span class="count">${columnTickets.length}</span>
285
- </div>
286
- </div>
287
- ${isEmpty ? renderEmptyState(column.name) : renderColumnBody(columnTickets, tickets)}
288
- <button type="button" class="add-card-phantom" data-add-to-state="${escapeHtml(column.id)}">+ Add a card</button>
289
- </section>
290
- `;
291
- }
292
-
293
- /**
294
- * Lay out the cards inside a column with epic-aware grouping. Children of an
295
- * epic that's also in this column slide under the epic; children whose epic
296
- * lives in a different column appear under a thin "mini epic header" so the
297
- * relationship is still visible. Children get pulled to their group even if
298
- * the user dropped them out of order — the grouping always wins for clarity.
299
- */
300
- function renderColumnBody(columnTickets, allTickets) {
301
- const epicById = new Map(
302
- allTickets
303
- .filter((t) => canonicalTicketType(t.type) === "epic")
304
- .map((t) => [t.id, t])
305
- );
306
- const epicIdsInColumn = new Set(
307
- columnTickets
308
- .filter((t) => canonicalTicketType(t.type) === "epic")
309
- .map((t) => t.id)
310
- );
311
- const childrenByEpicId = new Map();
312
- for (const t of columnTickets) {
313
- if (t.parent_ticket_id && epicById.has(t.parent_ticket_id)) {
314
- const list = childrenByEpicId.get(t.parent_ticket_id) || [];
315
- list.push(t);
316
- childrenByEpicId.set(t.parent_ticket_id, list);
317
- }
318
- }
319
-
320
- const rendered = new Set();
321
- const blocks = [];
322
- for (const t of columnTickets) {
323
- if (rendered.has(t.id)) continue;
324
-
325
- if (canonicalTicketType(t.type) === "epic") {
326
- const children = childrenByEpicId.get(t.id) || [];
327
- if (children.length) {
328
- const childrenHtml = children.map((c) => renderCard(c, { asEpicChild: true })).join("");
329
- blocks.push(`${renderCard(t)}<div class="epic-children">${childrenHtml}</div>`);
330
- children.forEach((c) => rendered.add(c.id));
331
- } else {
332
- blocks.push(renderCard(t));
333
- }
334
- rendered.add(t.id);
335
- continue;
336
- }
337
-
338
- // Defer children whose epic is in this column — they'll render under the
339
- // epic when the loop reaches it. Without this skip, a child iterated before
340
- // its epic would render standalone here, then again inside epic-children
341
- // (rendered.add happens at the epic, which may come later in updated_at order).
342
- if (t.parent_ticket_id && epicIdsInColumn.has(t.parent_ticket_id)) {
343
- continue;
344
- }
345
-
346
- if (t.parent_ticket_id && epicById.has(t.parent_ticket_id) && !epicIdsInColumn.has(t.parent_ticket_id)) {
347
- const epic = epicById.get(t.parent_ticket_id);
348
- const siblings = childrenByEpicId.get(t.parent_ticket_id) || [];
349
- const childrenHtml = siblings.map((c) => renderCard(c, { asEpicChild: true })).join("");
350
- blocks.push(`${renderEpicMiniHeader(epic)}<div class="epic-children">${childrenHtml}</div>`);
351
- siblings.forEach((c) => rendered.add(c.id));
352
- continue;
353
- }
354
-
355
- blocks.push(renderCard(t));
356
- rendered.add(t.id);
357
- }
358
-
359
- return blocks.join("");
360
- }
361
-
362
- function renderEpicMiniHeader(epic) {
363
- return `
364
- <div class="epic-mini-header" data-ticket-id="${escapeHtml(epic.id)}" title="Open epic">
365
- <div class="card-type-id type-pill-epic">
366
- ${renderTypeIcon("epic")}
367
- <span class="card-type-label">EPIC</span>
368
- </div>
369
- <span class="ticket-number">${escapeHtml(ticketLabel(epic))}</span>
370
- <span class="epic-mini-title">${escapeHtml(epic.title)}</span>
371
- </div>
372
- `;
373
- }
374
-
375
- /** Pill mirroring the structure/feel of the Type pill, colored per priority. */
376
- export function renderPriorityPill(priority) {
377
- const key = priorityKeyFor(priority);
378
- const label = priorityLabel(priority);
379
- return `<div class="card-priority-id priority-pill" data-variant="${escapeHtml(key)}"><span class="card-priority-label">${escapeHtml(label)}</span></div>`;
380
- }
381
-
382
- export function renderTypeIcon(type) {
383
- const canonical = canonicalTicketType(type);
384
- let icon = "";
385
- let color = "currentColor";
386
-
387
- switch (canonical) {
388
- case "epic":
389
- color = "#c084fc"; // Purple
390
- icon = `<path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z" fill="currentColor" />`;
391
- break;
392
- case "feature":
393
- color = "#4ade80"; // Green
394
- icon = `<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2v16z" fill="currentColor" />`;
395
- break;
396
- case "bug":
397
- color = "#f87171"; // Red
398
- icon = `<circle cx="12" cy="12" r="10" /><path d="M12 8v4M12 16h.01" />`;
399
- break;
400
- default: // task
401
- color = "#60a5fa"; // Blue
402
- icon = `<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12l2 2 4-4" />`;
403
- }
404
-
405
- return `
406
- <svg class="card-type-icon" viewBox="0 0 24 24" style="color: ${color}">
407
- ${icon}
408
- </svg>
409
- `;
410
- }
411
-
412
- function renderCard(ticket, { asEpicChild = false } = {}) {
413
- const canonicalType = canonicalTicketType(ticket.type);
414
- const priorityKey = priorityKeyFor(ticket.priority);
415
- const typeText = typeLabel(ticket.type).toUpperCase();
416
- const isExpanded = state.expandedCardIds.has(ticket.id);
417
- const hasDescription = Boolean(String(ticket.description || "").trim());
418
- const hasExpandable = hasDescription || (ticket.labels && ticket.labels.length) || ticket.parent_ticket || ticket.child_count;
419
-
420
- const triggerInner = `
421
- <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
422
- <div class="card-type-id type-pill-${escapeHtml(canonicalType)}">
423
- ${renderTypeIcon(ticket.type)}
424
- <span class="card-type-label">${escapeHtml(typeText)}</span>
425
- </div>
426
- ${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
427
- ${hasExpandable ? `<svg class="card-expand-chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>` : ""}
428
- `;
429
-
430
- const unreadN = unreadCount(ticket);
431
- const unreadDot = unreadN === 0
432
- ? ""
433
- : unreadN === 1
434
- ? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
435
- : `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
436
-
437
- return `
438
- <article class="card type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}${isExpanded ? " is-expanded" : ""}${asEpicChild ? " is-epic-child" : ""} ${ticket.id === state.selectedTicketId ? "selected" : ""}" data-variant="${escapeHtml(canonicalType)}" draggable="true" data-ticket-id="${ticket.id}">
439
- ${unreadDot}
440
- <h3>${escapeHtml(ticket.title)}</h3>
441
-
442
- <div class="card-meta">
443
- ${hasExpandable
444
- ? `<button type="button" class="card-expand-trigger" data-variant="card-accordion" data-card-toggle aria-expanded="${isExpanded ? "true" : "false"}">${triggerInner}</button>`
445
- : `<div class="card-expand-trigger card-expand-trigger--static">${triggerInner}</div>`}
446
- </div>
447
-
448
- ${hasExpandable && isExpanded ? renderCardExpandable(ticket) : ""}
449
- </article>
450
- `;
451
- }
452
-
453
- // The expandable body holds the only genuinely expensive per-card work: a full
454
- // markdown parse of the description. It's rendered eagerly only for cards that
455
- // are already expanded; collapsed cards get it injected on first open (see
456
- // toggleCardExpansion). Kept as its own function so both paths share one source.
457
- function renderCardExpandable(ticket) {
458
- const hasDescription = Boolean(String(ticket.description || "").trim());
459
- return `
460
- <div class="card-expandable">
461
- ${hasDescription ? `<div class="card-description markdown-body">${renderMarkdown(ticket.description)}</div>` : ""}
462
- ${renderLabels(ticket.labels)}
463
- ${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
464
- ${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
465
- </div>
466
- `;
467
- }
468
-
469
- /**
470
- * Click-and-drag horizontal panning on the kanban container.
471
- * Skipped when the pointer starts on a .card (cards own their own click + HTML5 DnD),
472
- * or on any interactive control inside a column head.
473
- */
474
- export function enableKanbanDragScroll() {
475
- const DRAG_THRESHOLD = 4; // px before we consider it a real drag (avoids eating clicks)
476
- const LINE_SCROLL_PX = 16;
477
- const PAGE_SCROLL_PX = () => Math.max(kanban.clientWidth, 1);
478
- let active = false;
479
- let dragged = false;
480
- let startX = 0;
481
- let startScroll = 0;
482
- let pointerId = 0;
483
-
484
- const shouldIgnore = (target) => {
485
- if (!(target instanceof Element)) return false;
486
- // Let cards keep their click/drag behavior untouched.
487
- if (target.closest(".card")) return true;
488
- // Don't hijack interactive controls that might land in the board area later.
489
- if (target.closest("a, button, input, select, textarea, [draggable='true']")) return true;
490
- return false;
491
- };
492
-
493
- const wheelPixels = (value, mode) => {
494
- if (mode === WheelEvent.DOM_DELTA_LINE) return value * LINE_SCROLL_PX;
495
- if (mode === WheelEvent.DOM_DELTA_PAGE) return value * PAGE_SCROLL_PX();
496
- return value;
497
- };
498
-
499
- kanban.addEventListener(
500
- "wheel",
501
- (event) => {
502
- if (event.defaultPrevented || event.ctrlKey) return;
503
- const horizontalDelta = event.deltaX || (event.shiftKey ? event.deltaY : 0);
504
- if (!horizontalDelta) return;
505
- const horizontal = Math.abs(horizontalDelta) >= Math.abs(event.deltaY) || event.shiftKey;
506
- if (!horizontal || kanban.scrollWidth <= kanban.clientWidth) return;
507
-
508
- event.preventDefault();
509
- kanban.scrollLeft += wheelPixels(horizontalDelta, event.deltaMode);
510
- },
511
- { passive: false }
512
- );
513
-
514
- kanban.addEventListener("pointerdown", (event) => {
515
- if (event.button !== 0) return;
516
- // Clear stale drag-click suppression on any fresh primary press — even over
517
- // a card — so a both-button / two-finger pan that emitted no trailing click
518
- // can't swallow the next genuine click.
519
- dragged = false;
520
- if (shouldIgnore(event.target)) return;
521
-
522
- active = true;
523
- pointerId = event.pointerId;
524
- startX = event.clientX;
525
- startScroll = kanban.scrollLeft;
526
- kanban.classList.add("is-grabbing");
527
- });
528
-
529
- kanban.addEventListener("pointermove", (event) => {
530
- if (!active || event.pointerId !== pointerId) return;
531
- const delta = event.clientX - startX;
532
- if (!dragged && Math.abs(delta) > DRAG_THRESHOLD) {
533
- dragged = true;
534
- // Capture so movement outside the element still pans smoothly.
535
- try {
536
- kanban.setPointerCapture(pointerId);
537
- } catch {
538
- /* no-op: some browsers can't capture here */
539
- }
540
- }
541
- if (dragged) {
542
- event.preventDefault();
543
- kanban.scrollLeft = startScroll - delta;
544
- }
545
- });
546
-
547
- const endDrag = (event) => {
548
- if (!active || (event && event.pointerId !== pointerId)) return;
549
- active = false;
550
- kanban.classList.remove("is-grabbing");
551
- try {
552
- kanban.releasePointerCapture(pointerId);
553
- } catch {
554
- /* no-op */
555
- }
556
- };
557
-
558
- kanban.addEventListener("pointerup", endDrag);
559
- kanban.addEventListener("pointercancel", endDrag);
560
- kanban.addEventListener("pointerleave", endDrag);
561
-
562
- // Suppress the synthetic click at the end of a real drag so empty-area panning
563
- // doesn't accidentally trigger downstream click handlers.
564
- kanban.addEventListener(
565
- "click",
566
- (event) => {
567
- if (dragged) {
568
- event.stopPropagation();
569
- event.preventDefault();
570
- dragged = false;
571
- }
572
- },
573
- true
574
- );
575
-
576
- // --- Both-button mouse drag + two-finger touch drag -----------------------
577
- // A second panning affordance that works ANYWHERE on the board, including
578
- // directly over cards. A plain left-click / single-finger tap is left to the
579
- // cards (so they still open + drag); holding *both* mouse buttons, or dragging
580
- // with *two fingers*, pans the board horizontally without engaging a card.
581
- let bothBtnPanning = false;
582
- let bothBtnLastX = 0;
583
- let suppressContextMenu = false;
584
-
585
- // Pressing the second mouse button (buttons bitmask 1|2 === 3) arms the pan
586
- // and cancels any single-button drag already in flight (see `active` above).
587
- kanban.addEventListener("pointerdown", (event) => {
588
- if (event.pointerType !== "mouse" || (event.buttons & 3) !== 3) return;
589
- bothBtnPanning = true;
590
- bothBtnLastX = event.clientX;
591
- suppressContextMenu = true;
592
- active = false; // hand control off from the left-button drag
593
- kanban.classList.add("is-grabbing");
594
- });
595
-
596
- kanban.addEventListener("pointermove", (event) => {
597
- if (event.pointerType !== "mouse") return;
598
- const bothDown = (event.buttons & 3) === 3;
599
- if (bothDown) {
600
- event.preventDefault();
601
- if (!bothBtnPanning) {
602
- bothBtnPanning = true;
603
- bothBtnLastX = event.clientX;
604
- suppressContextMenu = true;
605
- active = false;
606
- kanban.classList.add("is-grabbing");
607
- return;
608
- }
609
- const delta = event.clientX - bothBtnLastX;
610
- if (delta) {
611
- kanban.scrollLeft -= delta;
612
- bothBtnLastX = event.clientX;
613
- dragged = true; // eat the trailing synthetic click
614
- }
615
- } else if (bothBtnPanning) {
616
- bothBtnPanning = false;
617
- kanban.classList.remove("is-grabbing");
618
- }
619
- });
620
-
621
- kanban.addEventListener("pointerup", (event) => {
622
- if (event.pointerType === "mouse" && bothBtnPanning && (event.buttons & 3) !== 3) {
623
- bothBtnPanning = false;
624
- kanban.classList.remove("is-grabbing");
625
- }
626
- });
627
-
628
- // Right-click handling on the board:
629
- // - after a both-button pan, swallow the trailing context menu;
630
- // - otherwise suppress the native browser menu everywhere EXCEPT over a card,
631
- // which owns its own context menu (see bindCardContextMenuDelegated).
632
- kanban.addEventListener("contextmenu", (event) => {
633
- if (suppressContextMenu) {
634
- event.preventDefault();
635
- event.stopPropagation();
636
- suppressContextMenu = false;
637
- return;
638
- }
639
- const onCard = event.target instanceof Element && event.target.closest(".card");
640
- if (!onCard) {
641
- event.preventDefault();
642
- event.stopPropagation();
643
- }
644
- });
645
-
646
- // Two-finger touch pan. Touch events (not pointer events) are used here so we
647
- // can reliably preventDefault the browser's native two-finger scroll/zoom.
648
- let touchPanning = false;
649
- let touchPanLastX = 0;
650
- const twoFingerCentroidX = (touches) => (touches[0].clientX + touches[1].clientX) / 2;
651
-
652
- kanban.addEventListener(
653
- "touchstart",
654
- (event) => {
655
- // Fresh gesture — clear any leftover drag-click suppression so a real tap
656
- // is never swallowed when the prior two-finger pan emitted no click.
657
- dragged = false;
658
- if (event.touches.length === 2) {
659
- touchPanning = true;
660
- touchPanLastX = twoFingerCentroidX(event.touches);
661
- active = false; // cancel any single-finger drag that started
662
- kanban.classList.add("is-grabbing");
663
- } else {
664
- touchPanning = false;
665
- }
666
- },
667
- { passive: true }
668
- );
669
-
670
- kanban.addEventListener(
671
- "touchmove",
672
- (event) => {
673
- if (!touchPanning || event.touches.length !== 2) return;
674
- const x = twoFingerCentroidX(event.touches);
675
- const delta = x - touchPanLastX;
676
- if (delta) {
677
- event.preventDefault();
678
- kanban.scrollLeft -= delta;
679
- touchPanLastX = x;
680
- dragged = true; // eat the trailing tap so a card doesn't open
681
- }
682
- },
683
- { passive: false }
684
- );
685
-
686
- const endTouchPan = (event) => {
687
- if (event.touches.length < 2 && touchPanning) {
688
- touchPanning = false;
689
- kanban.classList.remove("is-grabbing");
690
- }
691
- };
692
- kanban.addEventListener("touchend", endTouchPan, { passive: true });
693
- kanban.addEventListener("touchcancel", endTouchPan, { passive: true });
694
- }