storymapper 0.1.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 (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. package/skill/reference/workflows.md +203 -0
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Kanban-Renderer (E11).
3
+ *
4
+ * Operative Sicht auf das Projekt: eine Lane pro Status, jede Lane zeigt
5
+ * alle non-epic Tickets in diesem Status, sortiert nach `sortOrder`.
6
+ * Epics gehören nicht in die Kanban-Ansicht (sie sind Container, keine
7
+ * Work-Items).
8
+ *
9
+ * Reorder-Mechanik (siehe Memory [[lists-are-sortable-by-default]]):
10
+ * - Drag zwischen Lanes: Status-Wechsel via POST /tickets/:tid/status.
11
+ * DoR/DoD-Gates auf dem Server greifen; bei 422 (`{kind, missing}`)
12
+ * wird das Modal-Banner-Pattern wiederverwendet (flashStatus + Revert).
13
+ * - Drag innerhalb einer Lane: Sortier-Reorder via POST /tickets/reorder
14
+ * OHNE scope. Der orderedIds-Set sind alle Tickets dieser Lane in
15
+ * neuer Reihenfolge. Server setzt sortOrder = idx (Container-Felder
16
+ * bleiben unverändert). Das wirkt rückwirkend auf die Story-Map-
17
+ * Sortierung innerhalb des Epic, weil beide Views auf demselben
18
+ * sortOrder-Feld lesen.
19
+ *
20
+ * Mount API:
21
+ * mount(host, store, opts) → { unmount }
22
+ * opts.onTicketClick(ticketId) — Doppelklick öffnet Detail-Modal
23
+ * opts.onTicketStatusChange(id, newStatus) → Promise<void>
24
+ * opts.onLaneReorder(laneStatus, orderedIds) → Promise<void>
25
+ *
26
+ * Alle Layout-Werte in KANBAN_LAYOUT (keine Magic Numbers im Code).
27
+ */
28
+ (function (root, factory) {
29
+ if (typeof module === "object" && module.exports) module.exports = factory(
30
+ require("./core.js"), require("./dnd.js"), require("./renderer-card.js"), require("./card-animate.js"), require("./filter.js"));
31
+ else (root.STORYMAP = root.STORYMAP || {}).rendererKanban = factory(
32
+ root.STORYMAP && root.STORYMAP.core,
33
+ root.STORYMAP && root.STORYMAP.dnd,
34
+ root.STORYMAP && root.STORYMAP.rendererCard,
35
+ root.STORYMAP && root.STORYMAP.cardAnimate,
36
+ root.STORYMAP && root.STORYMAP.filter
37
+ );
38
+ }(typeof self !== "undefined" ? self : this, function (core, dnd, rendererCard, cardAnimate, filterMod) {
39
+ "use strict";
40
+
41
+ if (!core) throw new Error("renderer-kanban: core module missing");
42
+ if (!dnd) throw new Error("renderer-kanban: dnd module missing");
43
+ if (!rendererCard) throw new Error("renderer-kanban: renderer-card module missing");
44
+ // filterMod is optional — older callers / tests may mount without it.
45
+
46
+ // ---- Konstanten ----------------------------------------------------
47
+ const KANBAN_LAYOUT = {
48
+ LANE_MIN_WIDTH_PX: 260,
49
+ CARD_GAP_PX: 6,
50
+ HEADER_HEIGHT_PX: 40,
51
+ LANE_PADDING_PX: 10,
52
+ CARD_PADDING_PX: 8
53
+ };
54
+
55
+ // Drag-type that's exclusive to the Kanban view (so a card dragged inside
56
+ // Kanban doesn't accidentally trigger a Story-Map drop-target).
57
+ const DRAG_TYPE = "kanban-card";
58
+
59
+ // SM-170: which workflow status-categories show the card-aging badge. Aging
60
+ // is a signal for ACTIVE work that's sitting too long — backlog/ready (todo)
61
+ // and done are excluded (a backlog item aging is normal; a done one is moot).
62
+ const AGING_CATEGORIES = ["doing", "blocked"];
63
+
64
+ // ---- Drag-projection state (SM-27) --------------------------------
65
+ //
66
+ // Mirrors the Story-Map's `_dragProjection` pattern (see
67
+ // renderer-storymap.js#effectiveStoriesFor). While a card is being
68
+ // dragged, `_dragProjection` holds `{ticketId, columnId, insertionIndex}`
69
+ // — the renderer pulls the dragged ticket out of every lane and inserts
70
+ // a `.sm-card-shadow` placeholder at the projection target. Setting and
71
+ // clearing the projection triggers a re-render, so the lane visibly
72
+ // reflows under the cursor instead of waiting for the drop.
73
+ let _dragProjection = null;
74
+ let _rerender = null;
75
+ let _linkIndex = null; // SM-49: per-render cache
76
+ let _lastExecIndex = null; // SM-60: per-render cache (last-execution per test-definition)
77
+ let _projectId = null; // SM-158: for ticket-key → editor links
78
+ let _agingStatuses = null; // SM-170: Set<statusId> whose cards show the aging badge
79
+
80
+ function setProjection(p) {
81
+ if (_dragProjection && p
82
+ && _dragProjection.ticketId === p.ticketId
83
+ && _dragProjection.swimlaneKey === p.swimlaneKey
84
+ && _dragProjection.columnId === p.columnId
85
+ && _dragProjection.insertionIndex === p.insertionIndex) {
86
+ return; // no-op: same projection, avoid render loops
87
+ }
88
+ _dragProjection = p;
89
+ if (typeof _rerender === "function") _rerender();
90
+ }
91
+
92
+ function clearProjection() {
93
+ if (_dragProjection === null) return;
94
+ _dragProjection = null;
95
+ if (typeof _rerender === "function") _rerender();
96
+ }
97
+
98
+ /**
99
+ * Compute a cell's tickets after applying the active drag projection. The
100
+ * dragged ticket is removed from every cell and re-inserted as a shadow at
101
+ * insertionIndex in the target cell (same swimlane + same column). Returns
102
+ * `[{ticket, shadow}]` pairs. Cross-swimlane drags never project a shadow —
103
+ * the Kanban only moves within a release row (release changes live in the Map).
104
+ */
105
+ function effectiveCellTickets(swimlaneKey, columnId, cellTickets, snapshot) {
106
+ if (!_dragProjection) return cellTickets.map(t => ({ ticket: t, shadow: false }));
107
+ const out = cellTickets
108
+ .filter(t => t.id !== _dragProjection.ticketId)
109
+ .map(t => ({ ticket: t, shadow: false }));
110
+ if (_dragProjection.swimlaneKey === swimlaneKey && _dragProjection.columnId === columnId) {
111
+ const dragged = (snapshot.tickets || []).find(t => t.id === _dragProjection.ticketId);
112
+ if (dragged) {
113
+ const idx = Math.max(0, Math.min(out.length, _dragProjection.insertionIndex || 0));
114
+ out.splice(idx, 0, { ticket: dragged, shadow: true });
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // Type→Cluster mapping kommt aus renderer-card.js (geteilt mit Story-Map).
121
+
122
+ // ---- DOM-Helper ----------------------------------------------------
123
+ function el(tag, props, children) {
124
+ const e = document.createElement(tag);
125
+ if (props) {
126
+ for (const k of Object.keys(props)) {
127
+ if (k === "class") e.className = props[k];
128
+ else if (k === "dataset") for (const d of Object.keys(props[k])) e.dataset[d] = props[k][d];
129
+ else if (k === "style") Object.assign(e.style, props[k]);
130
+ else if (k === "text") e.textContent = props[k];
131
+ else if (k === "html") e.innerHTML = props[k];
132
+ else if (k.startsWith("on") && typeof props[k] === "function") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
133
+ else e.setAttribute(k, props[k]);
134
+ }
135
+ }
136
+ if (children) for (const c of children) if (c != null) e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
137
+ return e;
138
+ }
139
+
140
+ // ---- Pure layout ---------------------------------------------------
141
+
142
+ // Sentinel key for the "no release / unscheduled" swimlane.
143
+ const NO_RELEASE_KEY = "__none__";
144
+
145
+ /**
146
+ * SM-169: group all non-epic tickets into a (release × status-column) grid.
147
+ * Returns:
148
+ * { columns: [{id, name, statusIds, status?, orphan?}],
149
+ * swimlanes: [{ release: {id,name,status} | null, key, collapsed,
150
+ * count, cells: {[columnId]: ticket[]} }] }
151
+ *
152
+ * One swimlane per release that holds ≥1 (filtered) ticket — ordered by the
153
+ * release sortOrder — plus a trailing "No release" swimlane (releaseId=null)
154
+ * that is ALWAYS present (it is the unscheduled/backlog home + hosts the
155
+ * "+ Add Item" button). `columns` is the board's Kanban columns (E21.C) plus
156
+ * synthetic trailing columns for any status that no column covers. A status
157
+ * column's `status` shortcut is set only for 1-status columns (backward-compat
158
+ * with `[data-status]` selectors). `opts.collapsedReleases` is a Set of
159
+ * release-ids whose swimlane should render collapsed (cells hidden).
160
+ */
161
+ function computeKanbanLayout(snapshot, opts) {
162
+ opts = opts || {};
163
+ const filter = opts.filter || null;
164
+ const collapsed = opts.collapsedReleases instanceof Set ? opts.collapsedReleases : new Set();
165
+ // SM-82: per-ticket filter (incl. hideCompletedReleases).
166
+ const filterCtx = (filterMod && filter) ? filterMod.buildReleaseCtx(snapshot) : null;
167
+ // SM-191: smart-bar query predicate, AND-combined with the chip filter.
168
+ const matchTicket = (typeof opts.matchTicket === "function") ? opts.matchTicket : null;
169
+ const filterMatch = (filterMod && filter)
170
+ ? (t) => filterMod.matchesFilter(t, filter, filterCtx)
171
+ : (_t) => true;
172
+ const matches = matchTicket ? (t) => filterMatch(t) && matchTicket(t) : filterMatch;
173
+
174
+ const board = snapshot && snapshot.project && snapshot.project.boards
175
+ && snapshot.project.boards.kanban;
176
+ const fallbackStatuses = core.DEFAULT_STATUSES.slice();
177
+ const baseColumns = (board && Array.isArray(board.columns) && board.columns.length > 0)
178
+ ? board.columns
179
+ : fallbackStatuses.map(s => ({ id: "col-" + s, name: s, statusIds: [s] }));
180
+
181
+ const tickets = (snapshot.tickets || [])
182
+ .filter(t => !t.isDeleted && core.isBoardWorkItem(t.type) && matches(t));
183
+ tickets.sort((a, b) => ((a.position && a.position.sortOrder) || 0)
184
+ - ((b.position && b.position.sortOrder) || 0));
185
+
186
+ // Full column set = board columns + synthetic columns for stranded statuses.
187
+ const columns = baseColumns.map(col => {
188
+ const c = { id: col.id, name: col.name, statusIds: (col.statusIds || []).slice() };
189
+ if (c.statusIds.length === 1) c.status = c.statusIds[0];
190
+ return c;
191
+ });
192
+ const covered = new Set(columns.flatMap(c => c.statusIds));
193
+ const orphanStatuses = [];
194
+ for (const t of tickets) {
195
+ if (!covered.has(t.status) && orphanStatuses.indexOf(t.status) < 0) orphanStatuses.push(t.status);
196
+ }
197
+ for (const s of orphanStatuses) {
198
+ columns.push({ id: "col-orphan-" + s, name: s, statusIds: [s], status: s, orphan: true });
199
+ }
200
+
201
+ const buildCells = (laneTickets) => {
202
+ const cells = {};
203
+ for (const col of columns) {
204
+ const set = new Set(col.statusIds);
205
+ cells[col.id] = laneTickets.filter(t => set.has(t.status));
206
+ }
207
+ return cells;
208
+ };
209
+ const relIdOf = (t) => (t.position && t.position.releaseId) || null;
210
+
211
+ const swimlanes = [];
212
+ const releases = (snapshot.releases || []).filter(r => !r.isDeleted)
213
+ .slice().sort((a, b) => ((a.sortOrder || 0) - (b.sortOrder || 0)));
214
+ for (const r of releases) {
215
+ const lt = tickets.filter(t => relIdOf(t) === r.id);
216
+ if (lt.length === 0) continue; // only release rows with work
217
+ swimlanes.push({
218
+ release: r, key: r.id, collapsed: collapsed.has(r.id),
219
+ count: lt.length, progress: core.releaseProgress(snapshot, r.id), // SM-239
220
+ cells: buildCells(lt)
221
+ });
222
+ }
223
+ // "No release" swimlane — always present (unscheduled home + add button).
224
+ const noRel = tickets.filter(t => relIdOf(t) === null);
225
+ swimlanes.push({
226
+ release: null, key: NO_RELEASE_KEY, collapsed: collapsed.has(NO_RELEASE_KEY),
227
+ count: noRel.length, progress: core.releaseProgress(snapshot, null), // SM-239
228
+ cells: buildCells(noRel)
229
+ });
230
+
231
+ return { columns, swimlanes };
232
+ }
233
+
234
+ // ---- Card renderer -------------------------------------------------
235
+ //
236
+ // Wir nutzen die geteilte Card-Komponente (renderer-card.js) — gleiches
237
+ // DOM-Markup, gleiches CSS (`.sm-story-card`) wie in der Story-Map. Damit
238
+ // sind beide Views visuell konsistent und Selection-Verhalten beim Drag
239
+ // (.sm-story-card { user-select: none }) wirkt automatisch auch hier.
240
+
241
+ function renderCard(ticket, opts, isShadow) {
242
+ const card = rendererCard.renderTicketCard(ticket, {
243
+ onTicketClick: isShadow ? null : (opts && opts.onTicketClick),
244
+ projectId: _projectId, // SM-158: ticket-key → editor link
245
+ dragType: isShadow ? null : DRAG_TYPE,
246
+ dragId: ticket.id,
247
+ // Clear the projection after the drag ends (drop OR cancel), so the
248
+ // next render shows the real state without the stale shadow.
249
+ dragOnEnd: isShadow ? null : (() => clearProjection()),
250
+ isShadow: !!isShadow,
251
+ // SM-49: dependency badges (forward + backward).
252
+ linkInfo: _linkIndex && _linkIndex.get(ticket.id) || null,
253
+ // SM-60: test-definition cards get the last-execution badge.
254
+ lastExecution: _lastExecIndex && _lastExecIndex.get(ticket.id) || null,
255
+ // SM-170: aging badge for cards in active (doing/blocked) statuses.
256
+ showAge: !!(_agingStatuses && _agingStatuses.has(ticket.status))
257
+ // No width → card stretches to lane width (flex layout).
258
+ });
259
+ // SM-30: right-click on a non-shadow card opens a promote/demote menu.
260
+ // We suppress the native browser menu unconditionally; the actual menu
261
+ // wiring happens in main.js (opts.onCardContextMenu).
262
+ if (!isShadow && opts && typeof opts.onCardContextMenu === "function") {
263
+ card.addEventListener("contextmenu", (ev) => {
264
+ ev.preventDefault();
265
+ ev.stopPropagation();
266
+ opts.onCardContextMenu(ticket, ev);
267
+ });
268
+ }
269
+ return card;
270
+ }
271
+
272
+ // gridTemplateColumns shared by the header row + every swimlane's cells row
273
+ // so the column boundaries line up vertically across all release rows.
274
+ function gridTemplate(columns) {
275
+ return "repeat(" + columns.length + ", minmax("
276
+ + KANBAN_LAYOUT.LANE_MIN_WIDTH_PX + "px, 1fr))";
277
+ }
278
+
279
+ // One (release × status-column) cell — a drop target holding that release's
280
+ // cards in that column's statuses.
281
+ function renderCell(swimlane, column, opts, snapshot) {
282
+ const ds = { columnId: column.id, swimlaneKey: swimlane.key };
283
+ if (column.status) ds.status = column.status;
284
+ if (swimlane.release) ds.releaseId = swimlane.release.id;
285
+ const cell = el("div", { class: "km-cell", dataset: ds });
286
+
287
+ const cellTickets = swimlane.cells[column.id] || [];
288
+ const effective = effectiveCellTickets(swimlane.key, column.id, cellTickets, snapshot);
289
+ for (const entry of effective) cell.appendChild(renderCard(entry.ticket, opts, entry.shadow));
290
+
291
+ // "+ Add Item" lives on the No-release swimlane's backlog column only —
292
+ // new tickets start in backlog with no release and walk the workflow.
293
+ const isBacklogCol = (column.statusIds || []).includes("backlog");
294
+ if (swimlane.release === null && isBacklogCol) {
295
+ const add = el("button", {
296
+ class: "km-add-item",
297
+ title: "Add a new ticket to the backlog",
298
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Item</span>'
299
+ });
300
+ add.addEventListener("click", (ev) => {
301
+ ev.stopPropagation();
302
+ if (opts && typeof opts.onAddItem === "function") opts.onAddItem("backlog");
303
+ });
304
+ cell.appendChild(add);
305
+ }
306
+
307
+ const targetReleaseId = swimlane.release ? swimlane.release.id : null;
308
+ dnd.enableDropTarget(cell, {
309
+ accepts: [DRAG_TYPE],
310
+ onEnter: (e) => e.classList.add("km-cell-drop-target"),
311
+ onLeave: (e) => e.classList.remove("km-cell-drop-target"),
312
+ onMove: ({ id, clientY }) => {
313
+ // SM-169: a Kanban move never changes the release — that's a planning
314
+ // action (Map). Don't project a shadow into another release's row.
315
+ const src = (snapshot.tickets || []).find(t => t.id === id);
316
+ const srcRel = (src && src.position && src.position.releaseId) || null;
317
+ if (srcRel !== targetReleaseId) return;
318
+ const cards = Array.from(cell.querySelectorAll(".sm-story-card"))
319
+ .filter(c => !c.classList.contains("sm-dragging")
320
+ && !c.classList.contains("sm-card-shadow"));
321
+ let idx = cards.length;
322
+ for (let i = 0; i < cards.length; i++) {
323
+ const r = cards[i].getBoundingClientRect();
324
+ if (clientY < r.top + r.height / 2) { idx = i; break; }
325
+ }
326
+ setProjection({ ticketId: id, swimlaneKey: swimlane.key, columnId: column.id, insertionIndex: idx });
327
+ },
328
+ onDrop: ({ id }) => {
329
+ cell.classList.remove("km-cell-drop-target");
330
+ const src = (snapshot.tickets || []).find(t => t.id === id);
331
+ const srcRel = (src && src.position && src.position.releaseId) || null;
332
+ const sourceStatus = src && src.status;
333
+ const insertionIndex = (_dragProjection
334
+ && _dragProjection.swimlaneKey === swimlane.key
335
+ && _dragProjection.columnId === column.id)
336
+ ? _dragProjection.insertionIndex : 0;
337
+ clearProjection();
338
+ // Cross-swimlane drop → reject (snap back). Release changes live in the
339
+ // Map; the Kanban only moves a ticket within its own release row.
340
+ if (srcRel !== targetReleaseId) return;
341
+ const statusSet = new Set(column.statusIds || []);
342
+ const staysInColumn = sourceStatus && statusSet.has(sourceStatus);
343
+ if (!staysInColumn && (column.statusIds || []).length > 0) {
344
+ if (typeof opts.onTicketStatusChange === "function") {
345
+ opts.onTicketStatusChange(id, column.statusIds[0]);
346
+ }
347
+ } else {
348
+ const peers = cellTickets.filter(t => t.id !== id);
349
+ const ordered = peers.slice();
350
+ ordered.splice(Math.max(0, Math.min(ordered.length, insertionIndex)), 0, { id });
351
+ const orderedIds = ordered.map(t => t.id);
352
+ if (typeof opts.onLaneReorder === "function") {
353
+ opts.onLaneReorder(sourceStatus || (column.statusIds || [])[0], orderedIds);
354
+ }
355
+ }
356
+ }
357
+ });
358
+ return cell;
359
+ }
360
+
361
+ // A per-release column-header row (Backlog / Ready / … / Done). SM-169
362
+ // repeats this inside every expanded swimlane so the column→status mapping
363
+ // stays visible no matter how far you scroll (no single floating header).
364
+ function renderColumnHeader(columns) {
365
+ const head = el("div", { class: "km-col-header",
366
+ style: { gridTemplateColumns: gridTemplate(columns) } });
367
+ for (const col of columns) {
368
+ head.appendChild(el("div", {
369
+ class: "km-col-head-cell" + (col.orphan ? " km-col-orphan" : ""),
370
+ text: col.name || (col.statusIds || []).join(", ")
371
+ }));
372
+ }
373
+ return head;
374
+ }
375
+
376
+ // One release swimlane: a collapsible release header + a cells row. The
377
+ // header is the SAME component the Story-Map uses (renderer-card.js#
378
+ // renderReleaseLabelRow) — identical chevron, name, completed/cancelled
379
+ // pill + strikethrough across both views (SM-169 visual unification).
380
+ function renderSwimlane(swimlane, columns, opts, snapshot) {
381
+ const rel = swimlane.release;
382
+ const ds = { swimlaneKey: swimlane.key };
383
+ if (rel) ds.releaseId = rel.id;
384
+ if (rel && rel.status) ds.status = rel.status;
385
+ const lane = el("div", {
386
+ class: "km-swimlane" + (swimlane.collapsed ? " km-swimlane-collapsed" : ""),
387
+ dataset: ds
388
+ });
389
+
390
+ const toggle = () => {
391
+ if (opts && typeof opts.onSwimlaneCollapse === "function") opts.onSwimlaneCollapse(swimlane.key);
392
+ };
393
+ const header = rendererCard.renderReleaseLabelRow({
394
+ id: swimlane.key,
395
+ name: rel ? rel.name : "No release",
396
+ status: rel ? rel.status : null,
397
+ progress: swimlane.progress, // SM-239: X/Y replaces the raw count, same source as the Map
398
+ collapsed: swimlane.collapsed,
399
+ onToggleCollapsed: toggle,
400
+ // Real releases: label click opens the edit dialog (Map parity). The
401
+ // synthetic "No release" row has nothing to edit → no label handler
402
+ // (toggle via the chevron, exactly like the Map).
403
+ onLabelClick: (rel && opts && typeof opts.onReleaseClick === "function")
404
+ ? () => opts.onReleaseClick(rel.id)
405
+ : null
406
+ });
407
+ lane.appendChild(header);
408
+
409
+ if (!swimlane.collapsed) {
410
+ // Each expanded release carries its own column header + cells grid, so
411
+ // the columns line up vertically (shared grid template) and read as
412
+ // continuous tinted bands from the header down through the cards.
413
+ lane.appendChild(renderColumnHeader(columns));
414
+ const cells = el("div", {
415
+ class: "km-swimlane-cells",
416
+ style: { gridTemplateColumns: gridTemplate(columns) }
417
+ });
418
+ for (const col of columns) cells.appendChild(renderCell(swimlane, col, opts, snapshot));
419
+ lane.appendChild(cells);
420
+ }
421
+ return lane;
422
+ }
423
+
424
+ function cssEscape(s) {
425
+ return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
426
+ }
427
+
428
+ function renderInto(host, store, opts) {
429
+ // Clear stale drag artifacts from a previous mount before rebuilding.
430
+ dnd.scrubArtifacts();
431
+ dnd.clearAll();
432
+
433
+ const snap = store.get();
434
+ _linkIndex = rendererCard.computeLinkIndex(snap); // SM-49
435
+ _lastExecIndex = rendererCard.computeLastExecutionIndex(snap); // SM-60
436
+ _projectId = snap.project && snap.project.id; // SM-158
437
+ // SM-170: which statuses get the aging badge (doing/blocked categories).
438
+ _agingStatuses = new Set();
439
+ const wfStatuses = (snap.project && snap.project.workflow && snap.project.workflow.statuses) || [];
440
+ for (const st of wfStatuses) {
441
+ if (st && AGING_CATEGORIES.indexOf(st.category) >= 0) _agingStatuses.add(st.id);
442
+ }
443
+ const layout = computeKanbanLayout(snap, {
444
+ filter: opts.filter || null,
445
+ matchTicket: opts.matchTicket || null, // SM-191 smart-bar query filter
446
+ collapsedReleases: opts.collapsedReleases
447
+ });
448
+ // Vertical stack of release swimlanes; each expanded one carries its own
449
+ // repeated column header (SM-169) so the status columns stay labelled.
450
+ const board = el("div", { class: "km-board" });
451
+
452
+ for (const swimlane of layout.swimlanes) {
453
+ board.appendChild(renderSwimlane(swimlane, layout.columns, opts, snap));
454
+ }
455
+ host.innerHTML = "";
456
+ host.appendChild(board);
457
+ }
458
+
459
+ // ---- Mount API -----------------------------------------------------
460
+
461
+ function mount(host, store, opts) {
462
+ opts = opts || {};
463
+ function rerender() { renderInto(host, store, opts); }
464
+ _rerender = rerender;
465
+ rerender();
466
+ // E24: animate cards when the commit came from outside (applyRemote
467
+ // → MCP / WS push). Local commits already have their own visual
468
+ // feedback (drag-ghost, dialog dismiss) so we skip the animation
469
+ // for them — otherwise the user's own click would feel laggy.
470
+ let prevSnap = store.get();
471
+ const unsub = store.subscribe(function (snap, reason) {
472
+ if (reason === "applyRemote" && cardAnimate) {
473
+ cardAnimate.animateExternalCommit({
474
+ host: host,
475
+ prevSnap: prevSnap,
476
+ nextSnap: snap,
477
+ rerender: rerender
478
+ });
479
+ } else {
480
+ rerender();
481
+ }
482
+ prevSnap = snap;
483
+ });
484
+ return {
485
+ unmount: () => {
486
+ unsub();
487
+ _rerender = null;
488
+ _dragProjection = null;
489
+ dnd.clearAll();
490
+ host.innerHTML = "";
491
+ }
492
+ };
493
+ }
494
+
495
+ return {
496
+ KANBAN_LAYOUT,
497
+ DRAG_TYPE,
498
+ NO_RELEASE_KEY,
499
+ computeKanbanLayout,
500
+ effectiveCellTickets,
501
+ setProjection,
502
+ clearProjection,
503
+ mount
504
+ };
505
+ }));