@starlove/orbit 1.0.1

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 (86) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +34 -0
  3. package/README.md +216 -0
  4. package/SKILL-ORBIT.md +94 -0
  5. package/build.mjs +204 -0
  6. package/dist/full/apple-touch-icon.png +0 -0
  7. package/dist/full/cursors/live_arrow.cur +0 -0
  8. package/dist/full/cursors/live_link.cur +0 -0
  9. package/dist/full/cursors/live_move.cur +0 -0
  10. package/dist/full/cursors/live_select.cur +0 -0
  11. package/dist/full/cursors/live_unavail.cur +0 -0
  12. package/dist/full/favicon-32x32.png +0 -0
  13. package/dist/full/index.html +314 -0
  14. package/dist/full/js/app.70be7d06.js +554 -0
  15. package/dist/full/js/app.70be7d06.js.map +7 -0
  16. package/dist/full/orbits-favicon.png +0 -0
  17. package/dist/full/orbits-logo-bg.png +0 -0
  18. package/dist/full/orbits-logo.png +0 -0
  19. package/dist/full/styles.c2e3d20a.css +4892 -0
  20. package/dist/full/version.json +6 -0
  21. package/docs/AGENT_PROTOCOL.md +379 -0
  22. package/docs/MCP_SETUP.md +49 -0
  23. package/docs/mcp-client.example.json +13 -0
  24. package/package.json +62 -0
  25. package/pnpm-workspace.yaml +2 -0
  26. package/public/apple-touch-icon.png +0 -0
  27. package/public/cursors/live_arrow.cur +0 -0
  28. package/public/cursors/live_link.cur +0 -0
  29. package/public/cursors/live_move.cur +0 -0
  30. package/public/cursors/live_select.cur +0 -0
  31. package/public/cursors/live_unavail.cur +0 -0
  32. package/public/favicon-32x32.png +0 -0
  33. package/public/index.html +314 -0
  34. package/public/js/api.js +35 -0
  35. package/public/js/app.js +35 -0
  36. package/public/js/board-menu.js +145 -0
  37. package/public/js/config.js +19 -0
  38. package/public/js/create-card.js +153 -0
  39. package/public/js/dom.js +19 -0
  40. package/public/js/drawer.js +137 -0
  41. package/public/js/format.js +108 -0
  42. package/public/js/kanban.js +475 -0
  43. package/public/js/local-backend.js +934 -0
  44. package/public/js/main.js +142 -0
  45. package/public/js/modal.js +31 -0
  46. package/public/js/router.js +114 -0
  47. package/public/js/search.js +53 -0
  48. package/public/js/settings-tabs.js +7 -0
  49. package/public/js/settings.js +931 -0
  50. package/public/js/sse.js +156 -0
  51. package/public/js/starscape.js +315 -0
  52. package/public/js/state.js +54 -0
  53. package/public/js/ticket-detail.js +766 -0
  54. package/public/js/toast.js +74 -0
  55. package/public/js/transport.js +22 -0
  56. package/public/js/unread.js +121 -0
  57. package/public/orbits-favicon.png +0 -0
  58. package/public/orbits-logo-bg.png +0 -0
  59. package/public/orbits-logo.png +0 -0
  60. package/public/styles.css +4892 -0
  61. package/src/cli/orbit.js +438 -0
  62. package/src/core/agent.js +262 -0
  63. package/src/core/auth.js +49 -0
  64. package/src/core/backups.js +136 -0
  65. package/src/core/board.js +112 -0
  66. package/src/core/boards.js +310 -0
  67. package/src/core/bootstrap.js +162 -0
  68. package/src/core/db.js +237 -0
  69. package/src/core/delete-board.js +53 -0
  70. package/src/core/events.js +314 -0
  71. package/src/core/http.js +81 -0
  72. package/src/core/paths.js +25 -0
  73. package/src/core/provision-repo-board.js +113 -0
  74. package/src/core/queries.js +246 -0
  75. package/src/core/registry.js +207 -0
  76. package/src/core/relations.js +163 -0
  77. package/src/core/router.js +414 -0
  78. package/src/core/search.js +70 -0
  79. package/src/core/seed.js +226 -0
  80. package/src/core/snapshots.js +255 -0
  81. package/src/core/states.js +167 -0
  82. package/src/core/system-picker.js +93 -0
  83. package/src/core/tickets.js +344 -0
  84. package/src/core/util.js +105 -0
  85. package/src/mcp-server.js +773 -0
  86. package/src/server.js +48 -0
@@ -0,0 +1,475 @@
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, statesForProject, ticketsForProject } from "./state.js";
8
+ import {
9
+ escapeHtml,
10
+ ticketLabel,
11
+ canonicalTicketType,
12
+ typeLabel,
13
+ priorityLabel,
14
+ priorityKeyFor,
15
+ renderLabels
16
+ } from "./format.js";
17
+ import { api, withBoardQuery } from "./api.js";
18
+ import { navigate } from "./router.js";
19
+ import { closeCreateFlyout, openCreateFlyout } from "./create-card.js";
20
+ import { load } from "./app.js";
21
+ import { unreadCount } from "./unread.js";
22
+
23
+ export function renderBoard() {
24
+ const states = statesForProject();
25
+ const tickets = ticketsForProject();
26
+ kanban.innerHTML = states.map((column) => renderColumn(column, tickets)).join("");
27
+
28
+ kanban.querySelectorAll(".column").forEach((column) => {
29
+ column.addEventListener("dragover", (event) => {
30
+ event.preventDefault();
31
+ column.classList.add("drag-over");
32
+ });
33
+ column.addEventListener("dragleave", () => column.classList.remove("drag-over"));
34
+ column.addEventListener("drop", async (event) => {
35
+ event.preventDefault();
36
+ column.classList.remove("drag-over");
37
+ const ticketId = event.dataTransfer.getData("text/plain");
38
+ if (!ticketId) return;
39
+ closeCreateFlyout();
40
+ await api(withBoardQuery(`/api/tickets/${ticketId}`), {
41
+ method: "PATCH",
42
+ body: { state_id: column.dataset.stateId }
43
+ });
44
+ // Refresh board only; do not open the ticket drawer (DnD also fires a
45
+ // synthetic click on the card — suppressed in the card listener).
46
+ await load();
47
+ });
48
+ });
49
+
50
+ kanban.querySelectorAll("[data-add-to-state]").forEach((btn) => {
51
+ btn.addEventListener("click", (event) => {
52
+ event.stopPropagation();
53
+ openCreateFlyout(btn.dataset.addToState || "");
54
+ });
55
+ });
56
+
57
+ kanban.querySelectorAll(".epic-mini-header[data-ticket-id]").forEach((header) => {
58
+ header.addEventListener("click", () => {
59
+ closeCreateFlyout();
60
+ navigate({
61
+ boardId: state.boardId,
62
+ view: "ticket",
63
+ ticketId: header.dataset.ticketId
64
+ });
65
+ });
66
+ });
67
+
68
+ kanban.querySelectorAll(".card").forEach((card) => {
69
+ // HTML5 DnD ends with a synthetic click on the source element; ignore that
70
+ // follow-up click so dropping a card does not also open the detail drawer.
71
+ let suppressClickAfterDrag = false;
72
+ card.addEventListener("click", async (event) => {
73
+ if (suppressClickAfterDrag) {
74
+ suppressClickAfterDrag = false;
75
+ event.preventDefault();
76
+ event.stopPropagation();
77
+ return;
78
+ }
79
+ closeCreateFlyout();
80
+ navigate({
81
+ boardId: state.boardId,
82
+ view: "ticket",
83
+ ticketId: card.dataset.ticketId
84
+ });
85
+ });
86
+ const toggleBtn = card.querySelector("button.card-expand-trigger");
87
+ if (toggleBtn) {
88
+ toggleBtn.addEventListener("click", (event) => {
89
+ event.stopPropagation();
90
+ event.preventDefault();
91
+ const id = card.dataset.ticketId;
92
+ if (state.expandedCardIds.has(id)) state.expandedCardIds.delete(id);
93
+ else state.expandedCardIds.add(id);
94
+ const expanded = card.classList.toggle("is-expanded");
95
+ toggleBtn.setAttribute("aria-expanded", expanded ? "true" : "false");
96
+ });
97
+ }
98
+ card.addEventListener("dragstart", (event) => {
99
+ event.dataTransfer.setData("text/plain", card.dataset.ticketId);
100
+ event.dataTransfer.effectAllowed = "move";
101
+ });
102
+ card.addEventListener("dragend", () => {
103
+ suppressClickAfterDrag = true;
104
+ // If no click follows (e.g. drag cancelled oddly), do not eat the next real click.
105
+ setTimeout(() => {
106
+ suppressClickAfterDrag = false;
107
+ }, 0);
108
+ });
109
+ });
110
+ }
111
+
112
+ function renderEmptyState(columnName) {
113
+ const name = columnName.toLowerCase();
114
+ let flair = {
115
+ icon: `<svg class="empty-state-icon" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12" opacity="0.5" /></svg>`,
116
+ title: "Nothing here",
117
+ subtitle: "Tasks will appear when ready"
118
+ };
119
+
120
+ if (name === "ai ready") {
121
+ flair = {
122
+ icon: `<svg class="empty-state-icon ai-sparkle" viewBox="0 0 24 24">
123
+ <path d="M12 3L14.5 9L21 11.5L14.5 14L12 20L9.5 14L3 11.5L9.5 9L12 3Z" fill="currentColor" opacity="0.65" />
124
+ <circle cx="12" cy="11.5" r="1.8" fill="#fff" />
125
+ </svg>`,
126
+ title: "Ready for agents",
127
+ subtitle: "Drop unblocked cards here — the claim-next tool picks them up"
128
+ };
129
+ } else if (name.includes("progress")) {
130
+ flair = {
131
+ icon: `<svg class="empty-state-icon orbit" viewBox="0 0 100 100">
132
+ <circle cx="50" cy="50" r="10" fill="currentColor" opacity="0.9" />
133
+ <circle class="ring" cx="50" cy="50" r="30" stroke="currentColor" stroke-dasharray="4 6" />
134
+ <circle class="satellite" cx="80" cy="50" r="4" fill="currentColor" />
135
+ </svg>`,
136
+ title: "Work in progress",
137
+ subtitle: "Active work will appear here"
138
+ };
139
+ } else if (name.includes("review")) {
140
+ flair = {
141
+ icon: `<svg class="empty-state-icon star" viewBox="0 0 24 24">
142
+ <path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z" fill="currentColor" />
143
+ </svg>`,
144
+ title: "Under review",
145
+ subtitle: "Quality gate before merge"
146
+ };
147
+ } else if (name.includes("todo") || name.includes("backlog")) {
148
+ flair = {
149
+ icon: `<svg class="empty-state-icon ai-sparkle" viewBox="0 0 24 24">
150
+ <path d="M12 3L14.5 9L21 11.5L14.5 14L12 20L9.5 14L3 11.5L9.5 9L12 3Z" fill="currentColor" opacity="0.5" />
151
+ <path d="M19 8L20 10L21 8L20 6L19 8ZM19 16L20 18L21 16L20 14L19 16ZM5 8L6 10L7 8L6 6L5 8Z" fill="currentColor" />
152
+ </svg>`,
153
+ title: "Ready for launch",
154
+ subtitle: "Create a card manually or task an AI to generate tasks"
155
+ };
156
+ } else if (name.includes("done") || name.includes("finished")) {
157
+ flair = {
158
+ icon: `<svg class="empty-state-icon" viewBox="0 0 24 24">
159
+ <circle cx="12" cy="12" r="10" />
160
+ <path d="M8 12L11 15L16 9" />
161
+ </svg>`,
162
+ title: "Done",
163
+ subtitle: "Move completed tickets here"
164
+ };
165
+ } else if (name.includes("cancel")) {
166
+ flair = {
167
+ icon: `<svg class="empty-state-icon" viewBox="0 0 24 24">
168
+ <circle cx="12" cy="12" r="10" opacity="0.2" />
169
+ <path d="M15 9L9 15M9 9l6 6" />
170
+ </svg>`,
171
+ title: "Not moving forward",
172
+ subtitle: "Parked or replaced"
173
+ };
174
+ }
175
+
176
+ return `
177
+ <div class="column-empty-state">
178
+ <div class="empty-state-visual">
179
+ <div class="empty-state-glow"></div>
180
+ ${flair.icon}
181
+ </div>
182
+ <div class="empty-state-title">${escapeHtml(flair.title)}</div>
183
+ <div class="empty-state-subtitle">${escapeHtml(flair.subtitle)}</div>
184
+ </div>
185
+ `;
186
+ }
187
+
188
+ function renderColumn(column, tickets) {
189
+ const columnTickets = tickets.filter((ticket) => ticket.state_id === column.id);
190
+ const isEmpty = columnTickets.length === 0;
191
+ // The "AI Ready" lane is a contract between humans and agents: cards here
192
+ // are the queue the claim-next MCP tool pulls from. Mark it so humans can
193
+ // instantly see where agent-facing staging lives.
194
+ const isAiReady = column.name === "AI Ready";
195
+ const columnClasses = ["column"];
196
+ if (isAiReady) columnClasses.push("column--ai-ready");
197
+
198
+ return `
199
+ <section class="${columnClasses.join(" ")}" data-state-id="${column.id}" data-state-name="${escapeHtml(column.name)}">
200
+ <div class="column-head">
201
+ <h2${isAiReady ? ' title="Agent staging — cards here are picked up by the claim-next tool when unblocked"' : ""}>
202
+ ${isAiReady ? '<span class="agent-dot" aria-hidden="true"></span>' : ""}${escapeHtml(column.name)}
203
+ </h2>
204
+ <div class="column-head-actions">
205
+ <button type="button" class="column-add-btn ghost" data-add-to-state="${escapeHtml(column.id)}" title="New card">+</button>
206
+ <span class="count">${columnTickets.length}</span>
207
+ </div>
208
+ </div>
209
+ ${isEmpty ? renderEmptyState(column.name) : renderColumnBody(columnTickets, tickets)}
210
+ <button type="button" class="add-card-phantom" data-add-to-state="${escapeHtml(column.id)}">+ Add a card</button>
211
+ </section>
212
+ `;
213
+ }
214
+
215
+ /**
216
+ * Lay out the cards inside a column with epic-aware grouping. Children of an
217
+ * epic that's also in this column slide under the epic; children whose epic
218
+ * lives in a different column appear under a thin "mini epic header" so the
219
+ * relationship is still visible. Children get pulled to their group even if
220
+ * the user dropped them out of order — the grouping always wins for clarity.
221
+ */
222
+ function renderColumnBody(columnTickets, allTickets) {
223
+ const epicById = new Map(
224
+ allTickets
225
+ .filter((t) => canonicalTicketType(t.type) === "epic")
226
+ .map((t) => [t.id, t])
227
+ );
228
+ const epicIdsInColumn = new Set(
229
+ columnTickets
230
+ .filter((t) => canonicalTicketType(t.type) === "epic")
231
+ .map((t) => t.id)
232
+ );
233
+ const childrenByEpicId = new Map();
234
+ for (const t of columnTickets) {
235
+ if (t.parent_ticket_id && epicById.has(t.parent_ticket_id)) {
236
+ const list = childrenByEpicId.get(t.parent_ticket_id) || [];
237
+ list.push(t);
238
+ childrenByEpicId.set(t.parent_ticket_id, list);
239
+ }
240
+ }
241
+
242
+ const rendered = new Set();
243
+ const blocks = [];
244
+ for (const t of columnTickets) {
245
+ if (rendered.has(t.id)) continue;
246
+
247
+ if (canonicalTicketType(t.type) === "epic") {
248
+ const children = childrenByEpicId.get(t.id) || [];
249
+ if (children.length) {
250
+ const childrenHtml = children.map((c) => renderCard(c, { asEpicChild: true })).join("");
251
+ blocks.push(`${renderCard(t)}<div class="epic-children">${childrenHtml}</div>`);
252
+ children.forEach((c) => rendered.add(c.id));
253
+ } else {
254
+ blocks.push(renderCard(t));
255
+ }
256
+ rendered.add(t.id);
257
+ continue;
258
+ }
259
+
260
+ if (t.parent_ticket_id && epicById.has(t.parent_ticket_id) && !epicIdsInColumn.has(t.parent_ticket_id)) {
261
+ const epic = epicById.get(t.parent_ticket_id);
262
+ const siblings = childrenByEpicId.get(t.parent_ticket_id) || [];
263
+ const childrenHtml = siblings.map((c) => renderCard(c, { asEpicChild: true })).join("");
264
+ blocks.push(`${renderEpicMiniHeader(epic)}<div class="epic-children">${childrenHtml}</div>`);
265
+ siblings.forEach((c) => rendered.add(c.id));
266
+ continue;
267
+ }
268
+
269
+ blocks.push(renderCard(t));
270
+ rendered.add(t.id);
271
+ }
272
+
273
+ return blocks.join("");
274
+ }
275
+
276
+ function renderEpicMiniHeader(epic) {
277
+ return `
278
+ <div class="epic-mini-header" data-ticket-id="${escapeHtml(epic.id)}" title="Open epic">
279
+ <div class="card-type-id type-pill-epic">
280
+ ${renderTypeIcon("epic")}
281
+ <span class="card-type-label">EPIC</span>
282
+ </div>
283
+ <span class="ticket-number">${escapeHtml(ticketLabel(epic))}</span>
284
+ <span class="epic-mini-title">${escapeHtml(epic.title)}</span>
285
+ </div>
286
+ `;
287
+ }
288
+
289
+ /** Pill mirroring the structure/feel of the Type pill, colored per priority. */
290
+ export function renderPriorityPill(priority) {
291
+ const key = priorityKeyFor(priority);
292
+ const label = priorityLabel(priority);
293
+ return `<div class="card-priority-id priority-pill priority-pill-${escapeHtml(key)}"><span class="card-priority-label">${escapeHtml(label)}</span></div>`;
294
+ }
295
+
296
+ export function renderTypeIcon(type) {
297
+ const canonical = canonicalTicketType(type);
298
+ let icon = "";
299
+ let color = "currentColor";
300
+
301
+ switch (canonical) {
302
+ case "epic":
303
+ color = "#c084fc"; // Purple
304
+ icon = `<path d="M12 2L14.5 9.5L22 12L14.5 14.5L12 22L9.5 14.5L2 12L9.5 9.5L12 2Z" fill="currentColor" />`;
305
+ break;
306
+ case "feature":
307
+ color = "#4ade80"; // Green
308
+ icon = `<path d="M19 21l-7-5-7 5V5a2 2 0 012-2h10a2 2 0 012 2v16z" fill="currentColor" />`;
309
+ break;
310
+ case "bug":
311
+ color = "#f87171"; // Red
312
+ icon = `<circle cx="12" cy="12" r="10" /><path d="M12 8v4M12 16h.01" />`;
313
+ break;
314
+ default: // task
315
+ color = "#60a5fa"; // Blue
316
+ icon = `<rect x="3" y="3" width="18" height="18" rx="2" /><path d="M9 12l2 2 4-4" />`;
317
+ }
318
+
319
+ return `
320
+ <svg class="card-type-icon" viewBox="0 0 24 24" style="color: ${color}">
321
+ ${icon}
322
+ </svg>
323
+ `;
324
+ }
325
+
326
+ function renderCard(ticket, { asEpicChild = false } = {}) {
327
+ const canonicalType = canonicalTicketType(ticket.type);
328
+ const priorityKey = priorityKeyFor(ticket.priority);
329
+ const typeText = typeLabel(ticket.type).toUpperCase();
330
+ const isExpanded = state.expandedCardIds.has(ticket.id);
331
+ const hasExpandable = (ticket.labels && ticket.labels.length) || ticket.parent_ticket || ticket.child_count;
332
+
333
+ const triggerInner = `
334
+ <span class="ticket-number">${escapeHtml(ticketLabel(ticket))}</span>
335
+ <div class="card-type-id type-pill-${escapeHtml(canonicalType)}">
336
+ ${renderTypeIcon(ticket.type)}
337
+ <span class="card-type-label">${escapeHtml(typeText)}</span>
338
+ </div>
339
+ ${state.showPriority ? renderPriorityPill(ticket.priority) : ""}
340
+ ${hasExpandable ? `<svg class="card-expand-chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>` : ""}
341
+ `;
342
+
343
+ const unreadN = unreadCount(ticket);
344
+ const unreadDot = unreadN === 0
345
+ ? ""
346
+ : unreadN === 1
347
+ ? `<span class="card-unread-dot" role="status" aria-label="1 unread update"></span>`
348
+ : `<span class="card-unread-dot has-count" role="status" aria-label="${unreadN} unread updates">${unreadN > 99 ? "99+" : unreadN}</span>`;
349
+
350
+ return `
351
+ <article class="card type-${escapeHtml(canonicalType)} priority-${escapeHtml(priorityKey)}${isExpanded ? " is-expanded" : ""}${asEpicChild ? " is-epic-child" : ""} ${ticket.id === state.selectedTicketId ? "selected" : ""}" draggable="true" data-ticket-id="${ticket.id}">
352
+ ${unreadDot}
353
+ <h3>${escapeHtml(ticket.title)}</h3>
354
+
355
+ <div class="card-meta">
356
+ ${hasExpandable
357
+ ? `<button type="button" class="card-expand-trigger" data-card-toggle aria-expanded="${isExpanded ? "true" : "false"}">${triggerInner}</button>`
358
+ : `<div class="card-expand-trigger card-expand-trigger--static">${triggerInner}</div>`}
359
+ </div>
360
+
361
+ ${hasExpandable ? `
362
+ <div class="card-expandable">
363
+ ${renderLabels(ticket.labels)}
364
+ ${ticket.parent_ticket ? `<div class="parent-line">-> ${escapeHtml(ticketLabel(ticket.parent_ticket))}</div>` : ""}
365
+ ${ticket.child_count ? `<div class="child-count">${ticket.child_count} feature${ticket.child_count === 1 ? "" : "s"} inside</div>` : ""}
366
+ </div>
367
+ ` : ""}
368
+ </article>
369
+ `;
370
+ }
371
+
372
+ /**
373
+ * Click-and-drag horizontal panning on the kanban container.
374
+ * Skipped when the pointer starts on a .card (cards own their own click + HTML5 DnD),
375
+ * or on any interactive control inside a column head.
376
+ */
377
+ export function enableKanbanDragScroll() {
378
+ const DRAG_THRESHOLD = 4; // px before we consider it a real drag (avoids eating clicks)
379
+ const LINE_SCROLL_PX = 16;
380
+ const PAGE_SCROLL_PX = () => Math.max(kanban.clientWidth, 1);
381
+ let active = false;
382
+ let dragged = false;
383
+ let startX = 0;
384
+ let startScroll = 0;
385
+ let pointerId = 0;
386
+
387
+ const shouldIgnore = (target) => {
388
+ if (!(target instanceof Element)) return false;
389
+ // Let cards keep their click/drag behavior untouched.
390
+ if (target.closest(".card")) return true;
391
+ // Don't hijack interactive controls that might land in the board area later.
392
+ if (target.closest("a, button, input, select, textarea, [draggable='true']")) return true;
393
+ return false;
394
+ };
395
+
396
+ const wheelPixels = (value, mode) => {
397
+ if (mode === WheelEvent.DOM_DELTA_LINE) return value * LINE_SCROLL_PX;
398
+ if (mode === WheelEvent.DOM_DELTA_PAGE) return value * PAGE_SCROLL_PX();
399
+ return value;
400
+ };
401
+
402
+ kanban.addEventListener(
403
+ "wheel",
404
+ (event) => {
405
+ if (event.defaultPrevented || event.ctrlKey) return;
406
+ const horizontalDelta = event.deltaX || (event.shiftKey ? event.deltaY : 0);
407
+ if (!horizontalDelta) return;
408
+ const horizontal = Math.abs(horizontalDelta) >= Math.abs(event.deltaY) || event.shiftKey;
409
+ if (!horizontal || kanban.scrollWidth <= kanban.clientWidth) return;
410
+
411
+ event.preventDefault();
412
+ kanban.scrollLeft += wheelPixels(horizontalDelta, event.deltaMode);
413
+ },
414
+ { passive: false }
415
+ );
416
+
417
+ kanban.addEventListener("pointerdown", (event) => {
418
+ if (event.button !== 0) return;
419
+ if (shouldIgnore(event.target)) return;
420
+
421
+ active = true;
422
+ dragged = false;
423
+ pointerId = event.pointerId;
424
+ startX = event.clientX;
425
+ startScroll = kanban.scrollLeft;
426
+ kanban.classList.add("is-grabbing");
427
+ });
428
+
429
+ kanban.addEventListener("pointermove", (event) => {
430
+ if (!active || event.pointerId !== pointerId) return;
431
+ const delta = event.clientX - startX;
432
+ if (!dragged && Math.abs(delta) > DRAG_THRESHOLD) {
433
+ dragged = true;
434
+ // Capture so movement outside the element still pans smoothly.
435
+ try {
436
+ kanban.setPointerCapture(pointerId);
437
+ } catch {
438
+ /* no-op: some browsers can't capture here */
439
+ }
440
+ }
441
+ if (dragged) {
442
+ event.preventDefault();
443
+ kanban.scrollLeft = startScroll - delta;
444
+ }
445
+ });
446
+
447
+ const endDrag = (event) => {
448
+ if (!active || (event && event.pointerId !== pointerId)) return;
449
+ active = false;
450
+ kanban.classList.remove("is-grabbing");
451
+ try {
452
+ kanban.releasePointerCapture(pointerId);
453
+ } catch {
454
+ /* no-op */
455
+ }
456
+ };
457
+
458
+ kanban.addEventListener("pointerup", endDrag);
459
+ kanban.addEventListener("pointercancel", endDrag);
460
+ kanban.addEventListener("pointerleave", endDrag);
461
+
462
+ // Suppress the synthetic click at the end of a real drag so empty-area panning
463
+ // doesn't accidentally trigger downstream click handlers.
464
+ kanban.addEventListener(
465
+ "click",
466
+ (event) => {
467
+ if (dragged) {
468
+ event.stopPropagation();
469
+ event.preventDefault();
470
+ dragged = false;
471
+ }
472
+ },
473
+ true
474
+ );
475
+ }