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,2530 @@
1
+ /**
2
+ * Story-Map renderer — 3-Ebenen-Modell (E9c + E9j).
3
+ *
4
+ * ProcessSteps → horizontale Spalten (Feature-Backbone)
5
+ * Releases → vertikale Zeilen
6
+ * pro (Release × ProcessStep)-Zelle: Epic-Karten als Container,
7
+ * unter jeder Epic ein Story-Sub-Grid.
8
+ * Backlog-Section am Boden ist die einzige Holding-Area: Gruppe
9
+ * "Unscheduled" (kein Release) + pro Release "Scheduled for X but
10
+ * unplaced" für Tickets mit Release, aber noch ohne Zell-Position.
11
+ *
12
+ * Pure-Funktionen (testbar ohne DOM):
13
+ * computeStoryMapLayout(snapshot) → { rows, columns, releases, backlog }
14
+ * applyEpicDrop(store, epicId, targetProcessStepId, actor)
15
+ * applyStoryDrop(store, storyId, targetEpicId, actor)
16
+ * applyBacklogDrop(store, ticketId, ticketType, actor)
17
+ * applyReleaseReorder(store, orderedReleaseIds, actor)
18
+ * applyProcessStepReorder(store, orderedStepIds, actor)
19
+ *
20
+ * Mount API:
21
+ * mount(host, store, opts?)
22
+ * opts.onAddTicket({releaseId, processStepId, epicId?})
23
+ * opts.onTicketClick(ticketId)
24
+ *
25
+ * Drag-and-Drop: Pointer-Events via dnd.js. Zwei Kontexte:
26
+ * "epic", "story"
27
+ *
28
+ * Alle Layout-Werte als Konstanten oben — keine Magic Numbers im Code.
29
+ */
30
+ (function (root, factory) {
31
+ if (typeof module === "object" && module.exports) module.exports = factory(require("./core.js"), require("./dnd.js"), require("./renderer-card.js"), require("./card-animate.js"), require("./filter.js"));
32
+ else (root.STORYMAP = root.STORYMAP || {}).rendererStoryMap = factory(
33
+ root.STORYMAP && root.STORYMAP.core,
34
+ root.STORYMAP && root.STORYMAP.dnd,
35
+ root.STORYMAP && root.STORYMAP.rendererCard,
36
+ root.STORYMAP && root.STORYMAP.cardAnimate,
37
+ root.STORYMAP && root.STORYMAP.filter
38
+ );
39
+ }(typeof self !== "undefined" ? self : this, function (core, dnd, rendererCard, cardAnimate, filterMod) {
40
+ "use strict";
41
+
42
+ if (!core) throw new Error("renderer-storymap: core module missing");
43
+ if (!dnd) throw new Error("renderer-storymap: dnd module missing");
44
+ if (!rendererCard) throw new Error("renderer-storymap: renderer-card module missing");
45
+ // filterMod is optional — older callers / tests may mount without it.
46
+ // When absent, all tickets and releases are visible (no-op filter).
47
+
48
+ // ---- Konstanten (keine Magic Numbers) -------------------------------
49
+
50
+ const STORY_MAP_LAYOUT = {
51
+ // SM-277: fixed width of the release title column. The title truncates with
52
+ // an ellipsis past this, and the progress/pill/Add-release badges line up in
53
+ // stable columns across all release rows (no more fluttering). Flows to CSS
54
+ // as `--sm-release-label-w` (set on .sm-grid in renderInto).
55
+ RELEASE_LABEL_WIDTH_PX: 300,
56
+ // SM-55 follow-up: clean integer multipliers for Epic widths.
57
+ //
58
+ // INVARIANT: STORY_CARD_WIDTH_PX is the canonical "1 unit". It is the
59
+ // exact width of a standalone (loose) ticket card AND the exact width
60
+ // of a 1-sub-col Epic card. An N-sub-col Epic card is exactly
61
+ // N × STORY_CARD_WIDTH_PX wide. Story cards INSIDE an Epic become
62
+ // slightly narrower (epic-width - padding - inter-card gap) / N so
63
+ // they fit inside the Epic's clean N-unit width. The grid column unit
64
+ // BACKBONE_COL_WIDTH_PX is STORY_CARD_WIDTH_PX + 2×CELL_PADDING_PX so
65
+ // cell content area equals the Epic-card width with breathing room.
66
+ //
67
+ // Story card width — SINGLE source is rendererCard.CARD_WIDTH_PX, so the
68
+ // story map and dependency graph can't drift (bumping it there widens both).
69
+ STORY_CARD_WIDTH_PX: rendererCard.CARD_WIDTH_PX,
70
+ CELL_PADDING_PX: 4, // .sm-cell padding (each side); mirrors css
71
+ // SM-159: one grid unit = STORY_CARD (280) + one inter-column gap (8) = 288.
72
+ // A cell with N columns is N units wide; its content is N×280 cards +
73
+ // (N-1)×8 gaps + 2×4 cell-padding = 288N — an EXACT fit, no residual.
74
+ //
75
+ // It used to be 256 (= card + 2×pad + 8px slack, SM-62). That slack was
76
+ // added PER UNIT to keep the old flex-WRAP layout from wrapping on
77
+ // sub-pixel rounding — but since SM-136 the epic row is `nowrap`, so the
78
+ // slack only over-allocated each column and left an accumulating ~8px×N
79
+ // gap on the right (user-reported). `.sm-epic-card`/`.sm-loose-card` are
80
+ // flex-shrinkable and `.sm-cell` has overflow-x:auto, so a ≤1px sub-pixel
81
+ // overflow is absorbed without a scrollbar.
82
+ BACKBONE_COL_WIDTH_PX: rendererCard.CARD_WIDTH_PX + 8, // card + TICKET_GAP_PX (8)
83
+ // SM-272: a collapsed process-step column shrinks to this fixed narrow
84
+ // width (independent of how many epics it holds) — fixes the runaway-wide
85
+ // columns. Its cells show a compact "N epics" indicator instead of cards.
86
+ COLLAPSED_COL_WIDTH_PX: 72,
87
+ TICKET_GAP_PX: 8,
88
+ // SM-131: uniform grid — feste Höhen, gespiegelt als CSS-Custom-Props
89
+ // (--sm-card-h, --sm-epic-header-h) auf .sm-grid.
90
+ CARD_HEIGHT_PX: 72, // feste Story-Karten-Höhe (alle Karten gleich; Badges einzeilig dank 280px-Breite)
91
+ EPIC_HEADER_HEIGHT_PX: 56, // 2-Zeilen-Titel + Key + Padding
92
+ COLLAPSED_RELEASE_HEIGHT_PX: 40,
93
+ ZOOM_MIN: 0.5,
94
+ ZOOM_MAX: 1.5,
95
+ ZOOM_STEP: 0.1,
96
+ ZOOM_DEFAULT: 1.0,
97
+ // SM-135/136 (Schlanke-Säulen-Umbau): die alten Wrap-Konstanten
98
+ // (TICKETS_PER_COLUMN_DEFAULT, LOOSE_TICKETS_PER_COLUMN, EPICS_PER_ROW)
99
+ // und STORIES_GRID_GAP_PX sind entfallen — jede Epic-/Loose-Säule ist
100
+ // einspaltig und wächst beliebig nach unten, es gibt keinen Sub-Spalten-
101
+ // Wrap und keine Inter-Column-Gap-Mathematik mehr.
102
+ // Stories-Grid padding (mirrors .sm-stories-grid CSS) — wird genutzt um
103
+ // die innere Story-Karten-Breite = STORY_CARD - 2×PAD zu berechnen.
104
+ STORIES_GRID_PADDING_PX: 6,
105
+ FLIP_ANIM_MS: 180,
106
+ // SM-193: the backlog grows unbounded downward as tickets pile up. The wrap
107
+ // is driven by the WHOLE backlog (all sub-lists combined): once the total
108
+ // passes BACKLOG_WRAP_THRESHOLD, each long list balances into card-width
109
+ // columns of at most BACKLOG_TICKETS_PER_COLUMN cards. Total-based, because a
110
+ // backlog split across "Unscheduled" + per-release groups (e.g. 9 + 2 = 11)
111
+ // must still wrap even though no single sub-list exceeds the threshold
112
+ // (user-reported regression on the first per-list implementation).
113
+ BACKLOG_WRAP_THRESHOLD: 10,
114
+ BACKLOG_TICKETS_PER_COLUMN: 6,
115
+ // SM-274/SM-275: Map-Eingang (Bodenstreifen) — Sammelbereich für unzugeordnete
116
+ // Tickets (releaseId leer). Spalten-Flow wie in den Zellen (E9k): Karten
117
+ // füllen vertikal, brechen nach rechts um, eigener horizontaler Scroll.
118
+ // Die Zeilenzahl ist VIEWPORT-reaktiv (SM-275): so viele Zeilen, wie unter
119
+ // den Releases bis zum Fensterboden passen — der Rest bricht nach rechts um.
120
+ // So nutzt der Streifen den vertikalen Raum, ohne über den Viewport hinaus
121
+ // zu wachsen. TRAY_ROWS_DEFAULT ist nur noch der Fallback (vor dem ersten
122
+ // Layout-Measure / in JSDOM / bei sehr hohem Board).
123
+ TRAY_ROWS_DEFAULT: 2,
124
+ TRAY_BODY_PADDING_PX: 8, // mirrors .sm-tray-body padding (each side)
125
+ TRAY_RESIZE_DEBOUNCE_MS: 150, // SM-275: debounce window-resize → recompute rows
126
+ // SM-223 (a): `content-visibility: auto` on cells skips layout/paint of
127
+ // off-screen cells — a real win on very large boards, but it makes
128
+ // WebKit/Gecko estimate off-screen cell heights (contain-intrinsic-size) and
129
+ // only settle the row height when the cell scrolls in (a visible jump; Blink
130
+ // hides it by rendering eagerly). Gate it by board size so normal boards are
131
+ // pixel-correct and only genuinely large boards (SM-223's 500+ target) pay
132
+ // the jump for the perf. renderInto toggles `.sm-grid-virtualized`.
133
+ VIRTUALIZE_TICKET_THRESHOLD: 400
134
+ };
135
+
136
+ // Type→Cluster mapping zog mit renderTicketCard nach renderer-card.js um.
137
+
138
+ const DRAG_TYPES = {
139
+ EPIC: "epic",
140
+ STORY: "story",
141
+ PROCESS_STEP: "process-step"
142
+ };
143
+ // Legacy alias retained so existing tests that imported DRAG_FORMATS keep
144
+ // compiling — the underlying drag mechanic switched from HTML5-DT-MIME
145
+ // strings to a pointer-events-based string-type registry.
146
+ const DRAG_FORMATS = DRAG_TYPES;
147
+
148
+ const ACTOR_LOCAL = { type: "human", id: "local", name: "Local" };
149
+
150
+ // ---- Pure layout -----------------------------------------------------
151
+
152
+ // SM-221: instrumentation — how many times the (O(R×S×T)) base layout was
153
+ // actually computed. The drag-layout cache (computeBaseLayout) drives this to
154
+ // ≤1 per drag; tests assert it. Reset/read via the exposed test hooks.
155
+ let _layoutComputeCount = 0;
156
+ // SM-223: instrumentation — how many times renderInto built the FULL grid.
157
+ // The drag-incremental path drives this to 0 per mid-drag pointermove.
158
+ let _gridBuildCount = 0;
159
+
160
+ function computeStoryMapLayout(snapshot, opts) {
161
+ _layoutComputeCount += 1;
162
+ opts = opts || {};
163
+ const filter = opts.filter || null;
164
+ // SM-82: when a filter is active, we use the filter module to decide
165
+ // per-ticket visibility AND per-release visibility. Without filterMod
166
+ // (older bundles / tests), the predicates are no-ops.
167
+ // SM-191: the unified smart-bar passes a query-engine-backed predicate as
168
+ // opts.matchTicket. It AND-combines with the SM-82 chip filter so both stay
169
+ // usable (a query narrows the chip-filtered set, and vice versa).
170
+ const matchTicket = (typeof opts.matchTicket === "function") ? opts.matchTicket : null;
171
+ const filterMatch = (filterMod && filter)
172
+ ? (t) => filterMod.matchesFilter(t, filter, filterCtx)
173
+ : (_t) => true;
174
+ const matches = matchTicket ? (t) => filterMatch(t) && matchTicket(t) : filterMatch;
175
+ const visibleRelease = (filterMod && filter)
176
+ ? (r) => filterMod.releaseVisible(r, filter)
177
+ : (_r) => true;
178
+ const filterCtx = (filterMod && filter) ? filterMod.buildReleaseCtx(snapshot) : null;
179
+
180
+ // SM-252: newest release on TOP. Releases sort DESCENDING by sortOrder so
181
+ // the most recent (highest sortOrder, appended by createRelease) heads the
182
+ // map and old releases sink to the bottom — the board no longer grows
183
+ // top-heavy with completed releases over time. Story-Map only; the Kanban
184
+ // swimlanes (computeKanbanLayout) keep their own order.
185
+ const releases = (snapshot.releases || [])
186
+ .filter(r => !r.isDeleted && visibleRelease(r))
187
+ .slice()
188
+ .sort((a, b) => (b.sortOrder || 0) - (a.sortOrder || 0));
189
+ const processSteps = (snapshot.processSteps || [])
190
+ .filter(p => !p.isDeleted)
191
+ .slice()
192
+ .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
193
+
194
+ // SM-239: each release row carries its X/Y progress over non-epic work
195
+ // items (the single releaseProgress source, shared with the Kanban).
196
+ const rows = releases.map(r => ({
197
+ id: r.id, name: r.name, kind: "release", entity: r,
198
+ progress: core.releaseProgress(snapshot, r.id)
199
+ }));
200
+ const columns = processSteps.map(p => ({ id: p.id, name: p.name, kind: "processStep", entity: p }));
201
+
202
+ // SM-253: tickets with a release but no process-step (not in any cell) are
203
+ // shown per-release in a holding strip — NOT in a global backlog. Grouped
204
+ // by release here so each release row can render + accept its own strip.
205
+ const partial = core.tickets.partiallyAssignedByRelease(snapshot);
206
+
207
+ const releasesOut = {};
208
+ for (const r of releases) {
209
+ const cells = {};
210
+ for (const p of processSteps) {
211
+ // SM-82: filter epics + stories per-ticket. When an epic is hidden
212
+ // but its stories aren't, the surviving stories slide into the
213
+ // cell's loose-list (so they're not orphaned to the backlog).
214
+ const allEpics = core.tickets.epicsInCell(snapshot, r.id, p.id);
215
+ const epicEntries = [];
216
+ const evictedStories = [];
217
+ for (const epic of allEpics) {
218
+ const allStories = core.tickets.storiesInEpic(snapshot, epic.id);
219
+ const visibleStories = allStories.filter(matches);
220
+ if (matches(epic)) {
221
+ epicEntries.push({ epic: epic, stories: visibleStories });
222
+ } else {
223
+ // Epic hidden — evicted stories appear in cell-loose, not backlog.
224
+ for (const s of visibleStories) evictedStories.push(s);
225
+ }
226
+ }
227
+ const looseFromCell = core.tickets.looseTicketsInCell(snapshot, r.id, p.id)
228
+ .filter(matches);
229
+ const loose = looseFromCell.concat(evictedStories);
230
+ cells[p.id] = { epics: epicEntries, loose };
231
+ }
232
+ // SM-253: per-release holding zone (release set, no process-step). Epic
233
+ // child stories are filtered here too (where `matches` lives) so an active
234
+ // status/type filter doesn't leak hidden stories into a holding-strip epic.
235
+ const g = partial.get(r.id) || { epics: [], stories: [] };
236
+ const unplaced = {
237
+ epics: g.epics.filter(matches).map(epic => ({
238
+ epic: epic,
239
+ stories: core.tickets.storiesInEpic(snapshot, epic.id).filter(matches)
240
+ })),
241
+ stories: g.stories.filter(matches)
242
+ };
243
+ releasesOut[r.id] = { release: r, cells, unplaced };
244
+ }
245
+
246
+ // SM-253: the global Backlog section is gone from the Story Map. Release-LESS
247
+ // tickets (orphan stories, backlog epics) are NOT shown here anymore — they
248
+ // live in the Kanban. Release-assigned-but-unplaced tickets now sit in their
249
+ // release's per-row holding zone (releasesOut[rid].unplaced). `partiallyAssigned`
250
+ // stays in the layout for data consumers/tests; the renderer no longer draws it.
251
+ const partiallyAssigned = releases
252
+ .map(r => {
253
+ const g = partial.get(r.id) || { epics: [], stories: [] };
254
+ return {
255
+ release: r,
256
+ epics: g.epics.filter(matches),
257
+ stories: g.stories.filter(matches)
258
+ };
259
+ })
260
+ .filter(g => g.epics.length > 0 || g.stories.length > 0);
261
+
262
+ const backlog = { epics: [], orphans: [], partiallyAssigned };
263
+
264
+ // SM-135 (Schlanke-Säulen-Umbau): jedes Epic ist eine schlanke
265
+ // Ein-Spalten-Säule fester Breite (STORY_CARD_WIDTH_PX). Die Cell-Breite
266
+ // ist damit schlicht die ANZAHL der Säulen × Backbone-Col-Unit, NICHT
267
+ // mehr eine Sub-Col-Summe pro Epic.
268
+ //
269
+ // Säulen pro Cell = #Epics + (lose Tickets vorhanden ? 1 : 0) — die
270
+ // losen Tickets bekommen ihre eigene Säule neben den Epics (SM-136).
271
+ // Multiplier = max über alle Releases (alle Release-Rows teilen sich die
272
+ // Backbone-Spaltenbreite). Stories wachsen innerhalb ihrer Epic-Säule
273
+ // beliebig nach unten (kein Wrap, kein 5er-Cap) und vergrößern damit nur
274
+ // die Höhe, nie die Breite.
275
+ const columnMultipliers = new Map();
276
+ for (const p of processSteps) {
277
+ let maxCols = 1;
278
+ for (const r of releases) {
279
+ const cell = releasesOut[r.id].cells[p.id];
280
+ const looseCol = (cell.loose && cell.loose.length > 0) ? 1 : 0;
281
+ const cols = cell.epics.length + looseCol;
282
+ if (cols > maxCols) maxCols = cols;
283
+ }
284
+ columnMultipliers.set(p.id, Math.max(1, maxCols));
285
+ }
286
+
287
+ return { rows, columns, releases: releasesOut, backlog, columnMultipliers };
288
+ }
289
+
290
+ // ---- SM-274: Map-Eingang (Bodenstreifen) -----------------------------
291
+
292
+ /**
293
+ * Pure layout for the Map-Eingang: collect ALL release-LESS tickets (the work
294
+ * that SM-253 dropped from the map and that the Kanban can't show for epics)
295
+ * and chunk them into column-flow columns of `rows` cards each — cards fill a
296
+ * column top-to-bottom, then the next column starts to the right.
297
+ *
298
+ * items flat list (epics first, then orphan stories), each already
299
+ * sorted by sortOrder within its group.
300
+ * columns 2D array: columns[k] is the k-th vertical column (≤ rows cards).
301
+ * rows the effective row count used for chunking (≥ 1).
302
+ * count items.length.
303
+ *
304
+ * opts.rows number of rows per column (default TRAY_ROWS_DEFAULT).
305
+ * opts.matches optional per-ticket predicate (filter parity with the cells);
306
+ * defaults to "everything visible".
307
+ */
308
+ function computeUnassignedTrayLayout(snapshot, opts) {
309
+ opts = opts || {};
310
+ const rows = Math.max(1, Math.floor(opts.rows || STORY_MAP_LAYOUT.TRAY_ROWS_DEFAULT));
311
+ const matches = (typeof opts.matches === "function") ? opts.matches : (_t) => true;
312
+ const epics = core.tickets.backlogEpics(snapshot).filter(matches);
313
+ const orphans = core.tickets.orphanStories(snapshot).filter(matches);
314
+ const items = epics.concat(orphans);
315
+ const columns = [];
316
+ for (let i = 0; i < items.length; i += rows) columns.push(items.slice(i, i + rows));
317
+ return { items, columns, rows, count: items.length };
318
+ }
319
+
320
+ /**
321
+ * SM-275: how many tray rows fit in the space BELOW the grid, inside the
322
+ * viewport. Pure (testable): given the scroller height, the grid height and
323
+ * the tray header height, return floor(available / cardSlot). Falls back to
324
+ * TRAY_ROWS_DEFAULT when there is no room yet (pre-layout / JSDOM measure = 0)
325
+ * or when the board already fills the viewport (a tall board → compact inbox,
326
+ * the rest wraps to the right with horizontal scroll).
327
+ */
328
+ function computeTrayRows(scrollerH, gridH, headerH) {
329
+ const cardSlot = STORY_MAP_LAYOUT.CARD_HEIGHT_PX + STORY_MAP_LAYOUT.TICKET_GAP_PX;
330
+ const avail = (scrollerH || 0) - (gridH || 0) - (headerH || 0)
331
+ - STORY_MAP_LAYOUT.TRAY_BODY_PADDING_PX * 2;
332
+ if (avail < cardSlot) return STORY_MAP_LAYOUT.TRAY_ROWS_DEFAULT;
333
+ return Math.max(1, Math.floor(avail / cardSlot));
334
+ }
335
+
336
+ // SM-275: measure the live DOM and apply the viewport-reactive row count to a
337
+ // mounted tray's card grid. The cards are a flat list; grid-auto-flow:column +
338
+ // grid-template-rows do the column wrapping, so only the row template changes —
339
+ // no DOM re-chunk. No-op when the tray is collapsed (no body/grid present).
340
+ function sizeTrayBody(host, tray) {
341
+ if (!host || !tray) return;
342
+ const scroller = host.parentElement; // #view-host (the scroll container)
343
+ const mapGrid = host.querySelector(":scope > .sm-grid");
344
+ // Width is handled purely in CSS now: the strip is a block child of the
345
+ // width:max-content grid, so it fills the full map scroll width on its own.
346
+ // Only the row count is genuinely dynamic (depends on the live viewport).
347
+ const grid = tray.querySelector(".sm-tray-grid");
348
+ if (!grid) return; // collapsed → nothing more to size
349
+ const header = tray.querySelector(".sm-tray-header");
350
+ // The tray is now a child of .sm-grid, so mapGrid.offsetHeight INCLUDES the
351
+ // tray — using it directly would feed the tray's own height back into its
352
+ // row count. Sum the grid's other children (backbone + release rows) instead
353
+ // to get the height of everything ABOVE the tray. Non-circular.
354
+ let releasesH = 0;
355
+ if (mapGrid) {
356
+ for (const child of mapGrid.children) {
357
+ if (child !== tray) releasesH += child.offsetHeight;
358
+ }
359
+ }
360
+ const rows = computeTrayRows(
361
+ scroller ? scroller.clientHeight : 0,
362
+ releasesH,
363
+ header ? header.offsetHeight : 0
364
+ );
365
+ grid.style.gridTemplateRows = "repeat(" + rows + ", " + STORY_MAP_LAYOUT.CARD_HEIGHT_PX + "px)";
366
+ }
367
+
368
+ // ---- Drop logic ------------------------------------------------------
369
+
370
+ function applyEpicDrop(store, epicId, targetProcessStepId, actor, targetReleaseId) {
371
+ const cur = store.get().tickets.find(t => t.id === epicId);
372
+ if (!cur) return;
373
+ // If targetReleaseId is omitted (test-style 3-arg call), keep current.
374
+ const releaseId = targetReleaseId !== undefined
375
+ ? (targetReleaseId || null)
376
+ : (cur.position && cur.position.releaseId);
377
+ store.moveTicket(epicId, {
378
+ releaseId,
379
+ processStepId: targetProcessStepId || null,
380
+ epicId: null,
381
+ sortOrder: (cur.position && cur.position.sortOrder) || 0
382
+ }, actor || ACTOR_LOCAL);
383
+ }
384
+
385
+ /** Drag-to-Backlog: clears releaseId (and for stories epicId). */
386
+ function applyBacklogDrop(store, ticketId, ticketType, actor) {
387
+ const cur = store.get().tickets.find(t => t.id === ticketId);
388
+ if (!cur) return;
389
+ store.moveTicket(ticketId, {
390
+ releaseId: null,
391
+ processStepId: null,
392
+ epicId: null,
393
+ sortOrder: (cur.position && cur.position.sortOrder) || 0
394
+ }, actor || ACTOR_LOCAL);
395
+ }
396
+
397
+ function applyStoryDrop(store, storyId, targetEpicId, actor) {
398
+ const cur = store.get().tickets.find(t => t.id === storyId);
399
+ if (!cur) return;
400
+ store.moveTicket(storyId, {
401
+ releaseId: cur.position && cur.position.releaseId,
402
+ processStepId: cur.position && cur.position.processStepId,
403
+ epicId: targetEpicId || null,
404
+ sortOrder: (cur.position && cur.position.sortOrder) || 0
405
+ }, actor || ACTOR_LOCAL);
406
+ }
407
+
408
+ function applyReleaseReorder(store, orderedReleaseIds, actor) {
409
+ store.reorderReleases(orderedReleaseIds, actor || ACTOR_LOCAL);
410
+ }
411
+
412
+ function applyProcessStepReorder(store, orderedStepIds, actor) {
413
+ store.reorderProcessSteps(orderedStepIds, actor || ACTOR_LOCAL);
414
+ }
415
+
416
+ // ---- SM-20 Phase A: Position-Only Fast-Path -------------------------
417
+ //
418
+ // The naive renderer wipes `host.innerHTML` and rebuilds the whole DOM on
419
+ // every `applyRemote`. Even though FLIP smoothly animates ticket-card
420
+ // positions afterwards, the structural shells (.sm-cell, .sm-backbone,
421
+ // .sm-release-row, .sm-backlog-section) get rebuilt every time — the user
422
+ // perceives that as a flash UNDER the animation.
423
+ //
424
+ // Phase A short-circuits the most common applyRemote case (reorder via
425
+ // MCP `reorder_tickets`): if the diff between prev and next snapshot is
426
+ // a pure within-container sort-order change for one or more tickets, we
427
+ // skip the wipe-and-rebuild entirely. Instead we re-order the existing
428
+ // card DOM nodes inside their existing parent containers. FLIP captures
429
+ // its rects beforehand and animates the move just like before — but now
430
+ // the structural shell keeps its DOM identity, so there's no flash.
431
+ //
432
+ // Cross-container moves, ticket additions/deletions, structural changes
433
+ // (new release, new ps, AC edits, …) bail out via `null` return and the
434
+ // caller falls back to the existing full-rerender + animate path.
435
+
436
+ function cssEscape(s) {
437
+ return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
438
+ }
439
+
440
+ // SM-52: epic containment is a `contains` link FROM the epic, not
441
+ // `ticket.position.epicId` (which is always null after the migration).
442
+ // Mirrors shared/core.js CONTAINS_LINK_TYPE_ID.
443
+ const CONTAINS_LINK_TYPE_ID = "contains";
444
+
445
+ /**
446
+ * Build a `Map<storyId, epicId>` of current containment from the epics'
447
+ * outgoing `contains` links. The applyRemote fast-path diffs pass this so a
448
+ * (re)assignment registers as a container change. Without it, containerKeyOf
449
+ * falls back to the legacy position.epicId (always null post-SM-52), and a
450
+ * story joining an epic was silently classified as "no change" — the bug
451
+ * where stories never appeared under epics over live-sync.
452
+ */
453
+ function containerMapOf(snap) {
454
+ const m = new Map();
455
+ for (const t of ((snap && snap.tickets) || [])) {
456
+ if (t.isDeleted || t.type !== "epic" || !t.links) continue;
457
+ for (const l of t.links) {
458
+ if ((l.linkTypeId || l.type) === CONTAINS_LINK_TYPE_ID) m.set(l.targetTicketId, t.id);
459
+ }
460
+ }
461
+ return m;
462
+ }
463
+
464
+ /**
465
+ * Stable key identifying the DOM container a ticket lives in. Two tickets
466
+ * with the same key share a parent in the rendered DOM. `containerMap`
467
+ * (optional Map<storyId, epicId> from containerMapOf) is the canonical SM-52
468
+ * containment source; the position.epicId fallback is legacy-only.
469
+ */
470
+ function containerKeyOf(ticket, containerMap) {
471
+ const p = ticket.position || {};
472
+ const epicId = (containerMap && containerMap.get(ticket.id))
473
+ || (typeof p.epicId === "string" ? p.epicId : null);
474
+ if (epicId) return "epic:" + epicId;
475
+ if (p.releaseId && p.processStepId) {
476
+ return (ticket.type === "epic" ? "cell-epics:" : "cell-loose:")
477
+ + p.releaseId + "/" + p.processStepId;
478
+ }
479
+ if (p.releaseId) return "backlog-rel:" + p.releaseId;
480
+ return "backlog-orphan";
481
+ }
482
+
483
+ /**
484
+ * True iff `a` and `b` differ only in `position` (and meta — updatedAt/
485
+ * version/updatedBy which always advance on writes). Everything that
486
+ * affects rendering OUTSIDE of where the card lives is compared.
487
+ */
488
+ function ticketEqualsIgnoringPosition(a, b) {
489
+ if (a.title !== b.title) return false;
490
+ if (a.type !== b.type) return false;
491
+ if (a.description !== b.description) return false;
492
+ if (a.status !== b.status) return false;
493
+ if (a.isDeleted !== b.isDeleted) return false;
494
+ // SM-52: `links` carry the `contains` containment edge (epic membership)
495
+ // AND the dependency badges rendered on the card. A change here must force
496
+ // the structural/full-rerender path — otherwise assigning a story to an
497
+ // epic (or any link edit) is invisible over live-sync.
498
+ if (JSON.stringify(a.links || []) !== JSON.stringify(b.links || [])) return false;
499
+ if (JSON.stringify(a.labels || []) !== JSON.stringify(b.labels || [])) return false;
500
+ if (JSON.stringify(a.acceptanceCriteria || []) !== JSON.stringify(b.acceptanceCriteria || [])) return false;
501
+ if (JSON.stringify((a.definitionOfReady && a.definitionOfReady.items) || []) !==
502
+ JSON.stringify((b.definitionOfReady && b.definitionOfReady.items) || [])) return false;
503
+ if (JSON.stringify((a.definitionOfDone && a.definitionOfDone.items) || []) !==
504
+ JSON.stringify((b.definitionOfDone && b.definitionOfDone.items) || [])) return false;
505
+ return true;
506
+ }
507
+
508
+ /**
509
+ * Returns `Map<containerKey, orderedTicketIds[]>` for the affected
510
+ * containers iff the prev→next diff is a pure within-container sort-order
511
+ * change (no structural changes, no field edits, no cross-container
512
+ * moves, no add/remove). Returns `null` otherwise so the caller falls
513
+ * back to the regular full-rerender path.
514
+ */
515
+ function diffSortOnlyByContainer(prevSnap, nextSnap) {
516
+ if (!prevSnap || !nextSnap) return null;
517
+ if ((prevSnap.releases || []).length !== (nextSnap.releases || []).length) return null;
518
+ if ((prevSnap.processSteps || []).length !== (nextSnap.processSteps || []).length) return null;
519
+ // Releases/process-steps: any name/order/etc. change → structural rerender.
520
+ for (let i = 0; i < (prevSnap.releases || []).length; i++) {
521
+ if (JSON.stringify(scrubMeta(prevSnap.releases[i])) !==
522
+ JSON.stringify(scrubMeta(nextSnap.releases[i]))) return null;
523
+ }
524
+ for (let i = 0; i < (prevSnap.processSteps || []).length; i++) {
525
+ if (JSON.stringify(scrubMeta(prevSnap.processSteps[i])) !==
526
+ JSON.stringify(scrubMeta(nextSnap.processSteps[i]))) return null;
527
+ }
528
+ if ((prevSnap.tickets || []).length !== (nextSnap.tickets || []).length) return null;
529
+ const prevMap = containerMapOf(prevSnap); // SM-52 containment
530
+ const nextMap = containerMapOf(nextSnap);
531
+ const prevById = new Map((prevSnap.tickets || []).map(t => [t.id, t]));
532
+ const affected = new Set();
533
+ for (const next of (nextSnap.tickets || [])) {
534
+ const prev = prevById.get(next.id);
535
+ if (!prev) return null; // added ticket = structural
536
+ if (!ticketEqualsIgnoringPosition(prev, next)) return null;
537
+ const prevKey = containerKeyOf(prev, prevMap);
538
+ const nextKey = containerKeyOf(next, nextMap);
539
+ if (prevKey !== nextKey) return null; // cross-container move = full rerender
540
+ const prevOrder = (prev.position && prev.position.sortOrder) || 0;
541
+ const nextOrder = (next.position && next.position.sortOrder) || 0;
542
+ if (prevOrder !== nextOrder) affected.add(nextKey);
543
+ }
544
+ if (affected.size === 0) return new Map(); // truly nothing changed
545
+ const groups = new Map();
546
+ for (const key of affected) {
547
+ const ids = (nextSnap.tickets || [])
548
+ .filter(t => !t.isDeleted && containerKeyOf(t, nextMap) === key)
549
+ .sort((a, b) => ((a.position && a.position.sortOrder) || 0)
550
+ - ((b.position && b.position.sortOrder) || 0))
551
+ .map(t => t.id);
552
+ groups.set(key, ids);
553
+ }
554
+ return groups;
555
+ }
556
+
557
+ function scrubMeta(entity) {
558
+ const out = {};
559
+ for (const k of Object.keys(entity)) {
560
+ if (k === "updatedAt" || k === "updatedBy" || k === "version" || k === "createdAt" || k === "createdBy") continue;
561
+ out[k] = entity[k];
562
+ }
563
+ return out;
564
+ }
565
+
566
+ /**
567
+ * Locate the existing DOM container for a given container-key. Returns
568
+ * `null` if the container isn't currently in the DOM (e.g. the cell-loose
569
+ * host doesn't exist because the cell had no loose tickets) — caller
570
+ * then falls back to a full rerender.
571
+ */
572
+ function findContainerForKey(host, key) {
573
+ if (key.indexOf("epic:") === 0) {
574
+ const epicId = key.slice("epic:".length);
575
+ const card = host.querySelector('.sm-epic-card[data-ticket-id="' + cssEscape(epicId) + '"]');
576
+ return card ? card.querySelector(".sm-stories-grid") : null;
577
+ }
578
+ if (key.indexOf("cell-epics:") === 0 || key.indexOf("cell-loose:") === 0) {
579
+ const isEpics = key.indexOf("cell-epics:") === 0;
580
+ const path = key.slice(isEpics ? "cell-epics:".length : "cell-loose:".length);
581
+ const slash = path.indexOf("/");
582
+ const releaseId = path.slice(0, slash);
583
+ const psId = path.slice(slash + 1);
584
+ const cell = host.querySelector(
585
+ '.sm-cell[data-release-id="' + cssEscape(releaseId) +
586
+ '"][data-process-step-id="' + cssEscape(psId) + '"]'
587
+ );
588
+ return cell ? cell.querySelector(isEpics ? ".sm-cell-epics" : ".sm-cell-loose") : null;
589
+ }
590
+ // backlog-* — Phase A bails so the existing rerender handles them.
591
+ return null;
592
+ }
593
+
594
+ /**
595
+ * SM-20 Phase B — detect commits whose only effect on the layout is
596
+ * changing per-ticket CONTENT fields (title/status/AC/DoR/DoD/labels)
597
+ * without changing positions, structure, or the ticket set. Returns a
598
+ * `Set<ticketId>` of changed cards, or `null` to bail.
599
+ *
600
+ * Used together with Phase A so applyRemote-driven `ticket_update`,
601
+ * `check_dod_item`, `change_ticket_status`, etc. don't trigger a full
602
+ * host.innerHTML rebuild — we patch the existing card DOM in place.
603
+ */
604
+ function diffCardContentOnly(prevSnap, nextSnap) {
605
+ if (!prevSnap || !nextSnap) return null;
606
+ if ((prevSnap.releases || []).length !== (nextSnap.releases || []).length) return null;
607
+ if ((prevSnap.processSteps || []).length !== (nextSnap.processSteps || []).length) return null;
608
+ for (let i = 0; i < (prevSnap.releases || []).length; i++) {
609
+ if (JSON.stringify(scrubMeta(prevSnap.releases[i])) !==
610
+ JSON.stringify(scrubMeta(nextSnap.releases[i]))) return null;
611
+ }
612
+ for (let i = 0; i < (prevSnap.processSteps || []).length; i++) {
613
+ if (JSON.stringify(scrubMeta(prevSnap.processSteps[i])) !==
614
+ JSON.stringify(scrubMeta(nextSnap.processSteps[i]))) return null;
615
+ }
616
+ if ((prevSnap.tickets || []).length !== (nextSnap.tickets || []).length) return null;
617
+ const prevMap = containerMapOf(prevSnap); // SM-52 containment
618
+ const nextMap = containerMapOf(nextSnap);
619
+ const prevById = new Map((prevSnap.tickets || []).map(t => [t.id, t]));
620
+ const changed = new Set();
621
+ for (const next of (nextSnap.tickets || [])) {
622
+ const prev = prevById.get(next.id);
623
+ if (!prev) return null; // add → structural
624
+ // isDeleted toggling is a STRUCTURAL change — the card must enter or
625
+ // leave the DOM, which Phase B's in-place patch can't do. Defer to
626
+ // the full-rerender + morph path, which inserts/removes nodes.
627
+ if (prev.isDeleted !== next.isDeleted) return null;
628
+ // Container must be IDENTICAL — otherwise the card belongs under a
629
+ // different parent (cell, epic, backlog) and Phase A (or full rerender)
630
+ // handles the move. SM-52: containment comes from the contains-link map.
631
+ if (containerKeyOf(prev, prevMap) !== containerKeyOf(next, nextMap)) return null;
632
+ const prevOrder = (prev.position && prev.position.sortOrder) || 0;
633
+ const nextOrder = (next.position && next.position.sortOrder) || 0;
634
+ if (prevOrder !== nextOrder) return null; // pure position change → Phase A
635
+ // Compare every visible card surface. Anything else → bail
636
+ // (e.g. labels change, comments — keep this list aligned with
637
+ // ticketEqualsIgnoringPosition so we don't miss something the card
638
+ // doesn't render today but might tomorrow).
639
+ const samples = ["title", "type", "status", "description"];
640
+ let cardChanged = false;
641
+ for (const k of samples) {
642
+ if (prev[k] !== next[k]) { cardChanged = true; break; }
643
+ }
644
+ if (!cardChanged) {
645
+ // SM-52: links changed but container didn't → a dependency-badge edit
646
+ // on a card whose epic membership is unchanged. Re-patch the card.
647
+ if (JSON.stringify(prev.links || []) !== JSON.stringify(next.links || [])) cardChanged = true;
648
+ else if (JSON.stringify(prev.labels || []) !== JSON.stringify(next.labels || [])) cardChanged = true;
649
+ else if (JSON.stringify(prev.acceptanceCriteria || []) !== JSON.stringify(next.acceptanceCriteria || [])) cardChanged = true;
650
+ else if (JSON.stringify((prev.definitionOfReady && prev.definitionOfReady.items) || []) !==
651
+ JSON.stringify((next.definitionOfReady && next.definitionOfReady.items) || [])) cardChanged = true;
652
+ else if (JSON.stringify((prev.definitionOfDone && prev.definitionOfDone.items) || []) !==
653
+ JSON.stringify((next.definitionOfDone && next.definitionOfDone.items) || [])) cardChanged = true;
654
+ }
655
+ if (cardChanged) changed.add(next.id);
656
+ }
657
+ return changed; // may be empty for a no-op echo
658
+ }
659
+
660
+ /**
661
+ * Patch every card DOM node listed in `ticketIds` to match the data in
662
+ * `snap`. Returns `true` if every card was found in the DOM and patched;
663
+ * `false` if any card is missing (caller falls back to full rerender).
664
+ */
665
+ function applyCardContentPatch(host, ticketIds, snap, opts) {
666
+ const byId = new Map((snap.tickets || []).map(t => [t.id, t]));
667
+ for (const id of ticketIds) {
668
+ const ticket = byId.get(id);
669
+ if (!ticket) return false;
670
+ // Epic cards have their own DOM structure (.sm-epic-card) — Phase B
671
+ // skips them by bailing whenever an epic is in the changed set. They
672
+ // still get a full rerender on edits; common edits hit story cards.
673
+ if (ticket.type === "epic") return false;
674
+ const card = host.querySelector('.sm-story-card[data-ticket-id="' + cssEscape(id) + '"]');
675
+ if (!card) return false;
676
+ rendererCard.patchTicketCardInPlace(card, ticket,
677
+ _linkIndex && _linkIndex.get(ticket.id) || null,
678
+ _lastExecIndex && _lastExecIndex.get(ticket.id) || null,
679
+ { onTicketClick: opts && opts.onTicketClick });
680
+ }
681
+ return true;
682
+ }
683
+
684
+ /**
685
+ * Re-arrange the existing card DOM nodes inside each affected container
686
+ * so that their order matches `groups`. Returns `false` if any required
687
+ * container or card can't be found in the current DOM (caller falls back
688
+ * to the regular rerender then).
689
+ */
690
+ function applySortOnlyReshuffle(host, groups) {
691
+ for (const [key, orderedIds] of groups) {
692
+ const container = findContainerForKey(host, key);
693
+ if (!container) return false;
694
+ for (let i = 0; i < orderedIds.length; i++) {
695
+ const id = orderedIds[i];
696
+ const card = container.querySelector(
697
+ ':scope > [data-ticket-id="' + cssEscape(id) + '"]'
698
+ );
699
+ if (!card) return false;
700
+ const slot = container.children[i];
701
+ if (slot !== card) container.insertBefore(card, slot || null);
702
+ }
703
+ }
704
+ return true;
705
+ }
706
+
707
+ // ---- SM-20 Phase C: morphTree DOM reconciler ------------------------
708
+ //
709
+ // Naive renderInto builds a fresh grid off-DOM and replaces the host's
710
+ // entire subtree (`host.innerHTML = ""; host.appendChild(grid)`). Phase
711
+ // A/B short-circuit the most common pure-reorder and pure-content cases,
712
+ // but `ticket_create`, `release_create`, label-rename, and the rare
713
+ // mixed-change still take the slow path. Phase C swaps the slow path
714
+ // for an in-place morph: the existing `.sm-grid` is mutated to match
715
+ // the freshly-built one, preserving DOM identity for every node whose
716
+ // stable key matches. Listeners, dnd registrations and CSS state all
717
+ // survive — the user no longer sees a structural refresh-flash.
718
+ //
719
+ // `morphTree` is a lightweight morphdom: it walks two element trees in
720
+ // parallel, pairs children by `keyOf`, reuses paired nodes, inserts new
721
+ // children where the new tree introduces them, removes old children
722
+ // that are absent in the new tree, and recursively morphs the paired
723
+ // pair. For leaf elements (no element children, just text), it copies
724
+ // textContent directly. Attributes are diffed and patched.
725
+ //
726
+ // Keys we recognize (in priority order):
727
+ // data-ticket-id — story + epic cards
728
+ // data-process-step-id — backbone columns
729
+ // data-release-id — release rows + labels
730
+ // data-section — singleton structural blocks we tag below
731
+ // .sm-cell[data-release-id][data-process-step-id]
732
+ // — cells (combined key)
733
+
734
+ function keyOf(el) {
735
+ if (!el || el.nodeType !== 1) return null;
736
+ const ds = el.dataset || {};
737
+ const cl = el.classList;
738
+ if (!cl) return null;
739
+ // .sm-release-row and .sm-release-label-row BOTH carry data-release-id —
740
+ // disambiguate by class before falling through to the generic dataset
741
+ // keys below.
742
+ if (cl.contains("sm-release-label-row") && ds.releaseId) return "rel-label-row:" + ds.releaseId;
743
+ if (cl.contains("sm-release-cells") && ds.releaseId) return "rel-cells-row:" + ds.releaseId;
744
+ if (cl.contains("sm-release-unplaced-row") && ds.releaseId) return "rel-unplaced:" + ds.releaseId; // SM-253
745
+ if (cl.contains("sm-release-label") && ds.releaseId) return "rel-label:" + ds.releaseId;
746
+ if (cl.contains("sm-cell") && ds.releaseId && ds.processStepId) {
747
+ // SM-272: a collapsed cell renders an "N epics" indicator instead of the
748
+ // epic cards — give it a DISTINCT key so the morph fully swaps the node
749
+ // (clean content change + fresh/no drag listeners) instead of partial-
750
+ // morphing card children into a count.
751
+ return (cl.contains("sm-cell-collapsed") ? "cell-collapsed:" : "cell:")
752
+ + ds.releaseId + "/" + ds.processStepId;
753
+ }
754
+ // SM-79 followup: shadow cards (live-preview placeholders during a
755
+ // drag projection) carry the SAME ticket-id as the real card. Without
756
+ // a key distinction, morphTree pairs the shadow node with the new real
757
+ // node after a drop — reusing the shadow's DOM (which has NO drag
758
+ // listener, see renderer-card.js l. 194 "if (isShadow) return card")
759
+ // and discarding the freshly built real card. Result: the post-drop
760
+ // card is undraggable and the SECOND drag silently no-ops. Give shadow
761
+ // cards their own key so morphTree treats them as distinct entities;
762
+ // when the shadow→real transition happens at drop, the shadow is
763
+ // REMOVED and the real card is INSERTED fresh with its listener.
764
+ if (ds.ticketId) {
765
+ return cl.contains("sm-card-shadow")
766
+ ? "ticket-shadow:" + ds.ticketId
767
+ : "ticket:" + ds.ticketId;
768
+ }
769
+ if (ds.processStepId) return (cl.contains("sm-col-collapsed") ? "ps-collapsed:" : "ps:") + ds.processStepId;
770
+ if (ds.releaseId) return "rel:" + ds.releaseId;
771
+ if (ds.section) return "section:" + ds.section;
772
+ // Last-resort: class-based singleton keys.
773
+ if (cl.contains("sm-backbone")) return "section:backbone";
774
+ if (cl.contains("sm-backbone-corner")) return "section:backbone-corner";
775
+ if (cl.contains("sm-add-col")) return "section:add-col";
776
+ if (cl.contains("sm-add-release-btn")) return "section:add-release-btn";
777
+ if (cl.contains("sm-stories-grid")) return "stories-grid";
778
+ if (cl.contains("sm-cell-epics")) return "cell-epics";
779
+ if (cl.contains("sm-cell-loose")) return "cell-loose";
780
+ if (cl.contains("sm-add-ticket")) return "cell-add-ticket";
781
+ if (cl.contains("sm-card-info")) return "card-info";
782
+ if (cl.contains("sm-card-meta")) return "card-meta";
783
+ if (cl.contains("sm-story-key")) return "story-key";
784
+ if (cl.contains("sm-story-title")) return "story-title";
785
+ if (cl.contains("sm-card-status")) return "card-status";
786
+ if (cl.contains("sm-badge-dor")) return "badge-dor";
787
+ if (cl.contains("sm-badge-dod")) return "badge-dod";
788
+ if (cl.contains("sm-col-grip")) return "col-grip";
789
+ if (cl.contains("sm-col-label")) return "col-label";
790
+ if (cl.contains("sm-epic-header")) return "epic-header";
791
+ return null;
792
+ }
793
+
794
+ /**
795
+ * Copy attributes + dataset from `newEl` onto `oldEl`. Attributes
796
+ * present on `oldEl` but not on `newEl` are removed. Inline `style`
797
+ * is overwritten wholesale via `style.cssText`.
798
+ */
799
+ function morphAttrs(oldEl, newEl) {
800
+ if (oldEl.tagName !== newEl.tagName) return false; // signal: can't morph
801
+ // dataset
802
+ const newDs = newEl.dataset || {};
803
+ const oldDs = oldEl.dataset || {};
804
+ for (const k of Object.keys(newDs)) {
805
+ if (k === "birthRender") continue; // SM-20 instrumentation — never overwrite
806
+ if (oldDs[k] !== newDs[k]) oldEl.dataset[k] = newDs[k];
807
+ }
808
+ for (const k of Object.keys(oldDs)) {
809
+ if (k === "birthRender") continue; // SM-20 instrumentation — never delete
810
+ if (!(k in newDs)) delete oldEl.dataset[k];
811
+ }
812
+ // className. SM-79: preserve transient drag-state classes that the
813
+ // dnd layer toggles imperatively — they are NOT part of the freshly-
814
+ // rendered off-DOM tree, so a naive `oldEl.className = newEl.className`
815
+ // strips them mid-drag and the user never sees the drop-highlight or
816
+ // the dragging-dim.
817
+ if (oldEl.className !== newEl.className) {
818
+ const preserved = [];
819
+ if (oldEl.classList.contains("sm-drop-target")) preserved.push("sm-drop-target");
820
+ if (oldEl.classList.contains("sm-dragging")) preserved.push("sm-dragging");
821
+ oldEl.className = newEl.className;
822
+ for (const c of preserved) oldEl.classList.add(c);
823
+ }
824
+ // Other attributes (excluding class + style which we handle separately)
825
+ for (const attr of newEl.attributes) {
826
+ const name = attr.name;
827
+ if (name === "class" || name === "style") continue;
828
+ if (name.startsWith("data-")) continue; // already handled via dataset
829
+ if (oldEl.getAttribute(name) !== attr.value) oldEl.setAttribute(name, attr.value);
830
+ }
831
+ for (const attr of Array.from(oldEl.attributes)) {
832
+ const name = attr.name;
833
+ if (name === "class" || name === "style") continue;
834
+ if (name.startsWith("data-")) continue;
835
+ if (!newEl.hasAttribute(name)) oldEl.removeAttribute(name);
836
+ }
837
+ // style: overwrite whole cssText (cheaper than per-property diff for our cases)
838
+ if (oldEl.style.cssText !== newEl.style.cssText) oldEl.style.cssText = newEl.style.cssText;
839
+ return true;
840
+ }
841
+
842
+ /**
843
+ * Recursively morph `oldEl` to match `newEl`. Reuses DOM identity for
844
+ * keyed children. Returns the morphed element (which is `oldEl`).
845
+ *
846
+ * The two roots must already be paired by the caller — typically by tag
847
+ * or by the host directly. For child reconciliation, we use `keyOf`.
848
+ */
849
+ function morphTree(oldEl, newEl) {
850
+ if (!oldEl || !newEl) return oldEl;
851
+ if (oldEl.nodeType !== 1 || newEl.nodeType !== 1) return oldEl;
852
+ if (oldEl.tagName !== newEl.tagName) {
853
+ // Can't morph different tag types — replace entirely.
854
+ if (oldEl.parentNode) oldEl.parentNode.replaceChild(newEl, oldEl);
855
+ return newEl;
856
+ }
857
+ morphAttrs(oldEl, newEl);
858
+ // Leaf with text content only — no element children — copy text wholesale.
859
+ if (newEl.children.length === 0) {
860
+ if (oldEl.textContent !== newEl.textContent) oldEl.textContent = newEl.textContent;
861
+ return oldEl;
862
+ }
863
+ // Build a key→node map of old children for quick lookup.
864
+ const oldByKey = new Map();
865
+ const oldUnkeyed = [];
866
+ for (const child of Array.from(oldEl.children)) {
867
+ const k = keyOf(child);
868
+ if (k != null) oldByKey.set(k, child);
869
+ else oldUnkeyed.push(child);
870
+ }
871
+ const seenKeys = new Set();
872
+ const seenUnkeyed = new Set(); // tracks which unkeyed olds got paired
873
+ let unkeyedIdx = 0;
874
+ // Walk new children left-to-right; insert/move into oldEl at the right spot.
875
+ let cursor = oldEl.firstChild;
876
+ for (const newChild of Array.from(newEl.children)) {
877
+ const k = keyOf(newChild);
878
+ let pair = null;
879
+ if (k != null && oldByKey.has(k)) {
880
+ pair = oldByKey.get(k);
881
+ seenKeys.add(k);
882
+ } else if (k == null && unkeyedIdx < oldUnkeyed.length) {
883
+ const candidate = oldUnkeyed[unkeyedIdx++];
884
+ // Position-paired unkeyed nodes must share tag name; on mismatch
885
+ // we DON'T pair (leave candidate for the cleanup loop to remove).
886
+ if (candidate.tagName === newChild.tagName) {
887
+ pair = candidate;
888
+ seenUnkeyed.add(candidate);
889
+ }
890
+ }
891
+ if (pair) {
892
+ // Move pair into the cursor position if it's not already there.
893
+ if (pair !== cursor) oldEl.insertBefore(pair, cursor);
894
+ morphTree(pair, newChild);
895
+ cursor = pair.nextSibling;
896
+ } else {
897
+ // Brand-new child — insert before cursor (or append if cursor is null).
898
+ oldEl.insertBefore(newChild, cursor);
899
+ // cursor stays the same (newChild is now before it).
900
+ }
901
+ }
902
+ // Remove any old keyed children that weren't paired.
903
+ for (const [k, oldChild] of oldByKey) {
904
+ if (!seenKeys.has(k) && oldChild.parentNode === oldEl) {
905
+ oldEl.removeChild(oldChild);
906
+ }
907
+ }
908
+ // Remove every old unkeyed child that didn't end up paired — covers both
909
+ // "advanced past it due to tag mismatch" and "ran out of new children".
910
+ for (const child of oldUnkeyed) {
911
+ if (!seenUnkeyed.has(child) && child.parentNode === oldEl) {
912
+ oldEl.removeChild(child);
913
+ }
914
+ }
915
+ return oldEl;
916
+ }
917
+
918
+ // ---- SM-20 instrumentation (opt-in) ----------------------------------
919
+ //
920
+ // OFF by default. Toggle in DevTools console:
921
+ // window.STORYMAP_DEBUG_RENDER = true // enable
922
+ // window.STORYMAP_DEBUG_RENDER = false // disable
923
+ // (Takes effect on the NEXT renderInto — refresh isn't needed.)
924
+ //
925
+ // When enabled, every keyed structural node gets a `data-birth-render`
926
+ // attribute on first creation. The Phase-C morph deliberately skips this
927
+ // attribute when copying dataset (see morphAttrs), so a survivor keeps
928
+ // its original birth-render while a fresh replacement shows the current
929
+ // counter. After each render, a console summary line lists per-entity-
930
+ // type preserved/fresh counts so a real DOM rebuild stands out.
931
+ //
932
+ // Found Phase D of SM-20 (FLIP-selector-collision) with this — keep it
933
+ // around for future investigations rather than ripping it out.
934
+ let _renderCounter = 0;
935
+
936
+ function debugRenderOn() {
937
+ return typeof window !== "undefined" && !!window.STORYMAP_DEBUG_RENDER;
938
+ }
939
+
940
+ function tagBirth(node) {
941
+ if (!debugRenderOn()) return node;
942
+ if (!node || node.nodeType !== 1) return node;
943
+ if (node.dataset && !node.dataset.birthRender) {
944
+ node.dataset.birthRender = String(_renderCounter);
945
+ }
946
+ return node;
947
+ }
948
+
949
+ function instrumentationReport(host, renderId) {
950
+ if (!debugRenderOn()) return;
951
+ if (typeof console === "undefined" || typeof console.log !== "function") return;
952
+ const groups = [
953
+ [".sm-grid", "grid"],
954
+ [".sm-backbone", "backbone"],
955
+ [".sm-backbone-col", "ps-cols"],
956
+ [".sm-release-row", "rel-rows"],
957
+ [".sm-release-label-row", "label-rows"],
958
+ [".sm-cell", "cells"],
959
+ [".sm-release-unplaced-row", "rel-unplaced"],
960
+ [".sm-epic-card", "epic-cards"],
961
+ [".sm-story-card", "story-cards"]
962
+ ];
963
+ const rid = String(renderId);
964
+ const lines = [];
965
+ for (const [sel, label] of groups) {
966
+ const nodes = host.querySelectorAll(sel);
967
+ if (nodes.length === 0) continue;
968
+ let preserved = 0, fresh = 0;
969
+ for (const n of nodes) {
970
+ if (n.dataset.birthRender === rid) fresh++;
971
+ else preserved++;
972
+ }
973
+ lines.push(label + ": " + preserved + "p/" + fresh + "f");
974
+ }
975
+ console.log("[SM-20] render #" + renderId + " — " + lines.join(", "));
976
+ }
977
+
978
+ // ---- DOM helper ------------------------------------------------------
979
+
980
+ function el(tag, props, children) {
981
+ const e = document.createElement(tag);
982
+ if (props) {
983
+ for (const k of Object.keys(props)) {
984
+ if (k === "class") e.className = props[k];
985
+ else if (k === "dataset") for (const d of Object.keys(props[k])) e.dataset[d] = props[k][d];
986
+ else if (k === "style") Object.assign(e.style, props[k]);
987
+ else if (k === "text") e.textContent = props[k];
988
+ else if (k === "html") e.innerHTML = props[k];
989
+ else if (k.startsWith("on") && typeof props[k] === "function") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
990
+ else if (k === "draggable") e.draggable = !!props[k];
991
+ else e.setAttribute(k, props[k]);
992
+ }
993
+ }
994
+ if (children) for (const c of children) if (c != null) e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
995
+ return e;
996
+ }
997
+
998
+ // ---- Card renderers --------------------------------------------------
999
+
1000
+ // Story-Karten kommen aus der geteilten renderer-card.js — wir reichen
1001
+ // Story-Map-spezifische Optionen (fixe Card-Breite, projection-clear in
1002
+ // onEnd) durch und nutzen das gleiche DOM-Markup wie Kanban.
1003
+ function renderStoryCard(t, opts, isShadow, widthOverride) {
1004
+ return tagBirth(rendererCard.renderTicketCard(t, {
1005
+ onTicketClick: opts && opts.onTicketClick,
1006
+ // SM-158: ticket-key links to the full-page editor.
1007
+ projectId: currentSnapshot && currentSnapshot.project && currentSnapshot.project.id,
1008
+ dragType: isShadow ? null : DRAG_TYPES.STORY,
1009
+ dragId: t.id,
1010
+ dragOnEnd: isShadow ? null : (() => { if (_dragProjection) setDragProjection(null); }),
1011
+ isShadow: isShadow,
1012
+ width: typeof widthOverride === "number" ? widthOverride : STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX,
1013
+ linkInfo: _linkIndex && _linkIndex.get(t.id) || null,
1014
+ // SM-60: test-definition cards get the last-execution badge.
1015
+ lastExecution: _lastExecIndex && _lastExecIndex.get(t.id) || null
1016
+ }));
1017
+ }
1018
+
1019
+ /**
1020
+ * Build the effective story-list for an epic given the active drag
1021
+ * projection: origin removed if its current epic equals this one, and
1022
+ * the dragged ticket inserted as a shadow at insertionIndex if this
1023
+ * epic is the projection target.
1024
+ */
1025
+ function effectiveStoriesFor(epic, baseStories, snapshot) {
1026
+ if (!_dragProjection) return baseStories.map(s => ({ ticket: s, shadow: false }));
1027
+ const out = baseStories
1028
+ .filter(s => s.id !== _dragProjection.ticketId)
1029
+ .map(s => ({ ticket: s, shadow: false }));
1030
+ const tgt = _dragProjection.target;
1031
+ if (tgt && tgt.type === "epic" && tgt.epicId === epic.id) {
1032
+ const draggedTicket = (snapshot.tickets || []).find(t => t.id === _dragProjection.ticketId);
1033
+ if (draggedTicket) {
1034
+ const idx = Math.max(0, Math.min(out.length, _dragProjection.insertionIndex || 0));
1035
+ out.splice(idx, 0, { ticket: draggedTicket, shadow: true });
1036
+ }
1037
+ }
1038
+ return out;
1039
+ }
1040
+
1041
+ function renderEpicCard(entry, opts) {
1042
+ const epic = entry.epic;
1043
+ const isShadow = !!entry.shadow;
1044
+ // SM-84: epic-level collapse mirrors SM-83's release-level collapse.
1045
+ // opts.collapsedEpics is a Set<epicId>; shadow epics (live-drag preview)
1046
+ // are never collapsed regardless of their real state — the user is
1047
+ // currently interacting with them and needs to see the destination.
1048
+ const isCollapsed = !isShadow
1049
+ && !!(opts && opts.collapsedEpics && opts.collapsedEpics.has && opts.collapsedEpics.has(epic.id));
1050
+ const card = tagBirth(el("div", {
1051
+ class: "sm-epic-card sm-cluster-plum sm-status-" + epic.status
1052
+ + (isShadow ? " sm-card-shadow" : "")
1053
+ + (isCollapsed ? " sm-epic-card-collapsed" : ""),
1054
+ dataset: { ticketId: epic.id, ticketType: "epic" }
1055
+ }));
1056
+ const header = el("div", { class: "sm-epic-header" });
1057
+ const info = el("div", { class: "sm-epic-info" });
1058
+ info.appendChild(el("span", { class: "sm-epic-title", text: epic.title }));
1059
+ // (SM-238's compact epic-header "X/Y" badge was removed — it crowded the
1060
+ // header next to the "+"/chevron and confused the add affordance. Release
1061
+ // progress is shown by the per-release X/Y badge, which is enough.)
1062
+ if (epic.ticketKey) {
1063
+ // SM-158: epic key links to the full-page editor too. Reuse the shared
1064
+ // helper but keep the .sm-epic-key class for existing styling/selectors.
1065
+ const pid = currentSnapshot && currentSnapshot.project && currentSnapshot.project.id;
1066
+ const keyEl = rendererCard.buildTicketKeyEl(epic.ticketKey, pid);
1067
+ keyEl.classList.add("sm-epic-key");
1068
+ info.appendChild(keyEl);
1069
+ }
1070
+ header.appendChild(info);
1071
+ // SM-49: link badges on epic header (epics participate in dependencies too).
1072
+ const epicLinkInfo = _linkIndex && _linkIndex.get(epic.id) || null;
1073
+ if (epicLinkInfo && (epicLinkInfo.forward > 0 || epicLinkInfo.backward > 0)) {
1074
+ const badges = el("div", { class: "sm-epic-badges" });
1075
+ if (epicLinkInfo.forward > 0) {
1076
+ const sem = rendererCard.dominantSemantic(epicLinkInfo.forwardSemantics);
1077
+ badges.appendChild(rendererCard.buildLinkBadge("forward", epicLinkInfo.forward, sem, epicLinkInfo.forwardTargetKeys, epicLinkInfo));
1078
+ }
1079
+ if (epicLinkInfo.backward > 0) {
1080
+ const sem = rendererCard.dominantSemantic(epicLinkInfo.backwardSemantics);
1081
+ badges.appendChild(rendererCard.buildLinkBadge("backward", epicLinkInfo.backward, sem, epicLinkInfo.backwardSourceKeys, epicLinkInfo));
1082
+ }
1083
+ header.appendChild(badges);
1084
+ }
1085
+ // SM-84: collapse chevron at the rightmost end of the header — sits AFTER
1086
+ // the link-badges so the badges keep their familiar position. ▼ = expanded,
1087
+ // ▶ = collapsed. Click stopPropagation so the surrounding card's dblclick-
1088
+ // to-edit handler doesn't fire. Shadows skip the chevron — they're drag
1089
+ // preview, never user-interactive.
1090
+ if (!isShadow) {
1091
+ // SM-138: Add-„+" im Header (oben rechts) statt am Karten-Boden — so
1092
+ // verbraucht der Add-Affordance keine Karten-Zeile und bricht das Raster
1093
+ // nicht. Öffnet onAddTicket mit position.epicId vorbelegt.
1094
+ const addBtn = el("button", {
1095
+ class: "sm-epic-add",
1096
+ type: "button",
1097
+ title: "Add story under this epic",
1098
+ text: "+"
1099
+ });
1100
+ addBtn.addEventListener("click", (ev) => {
1101
+ ev.stopPropagation();
1102
+ if (opts && typeof opts.onAddTicket === "function") {
1103
+ opts.onAddTicket({
1104
+ releaseId: epic.position && epic.position.releaseId,
1105
+ processStepId: epic.position && epic.position.processStepId,
1106
+ epicId: epic.id,
1107
+ type: "user-story"
1108
+ });
1109
+ }
1110
+ });
1111
+ header.appendChild(addBtn);
1112
+ const chevron = el("button", {
1113
+ class: "sm-epic-chevron",
1114
+ type: "button",
1115
+ title: isCollapsed ? "Expand epic" : "Collapse epic",
1116
+ text: isCollapsed ? "▶" : "▼"
1117
+ });
1118
+ chevron.setAttribute("aria-expanded", isCollapsed ? "false" : "true");
1119
+ chevron.addEventListener("click", (ev) => {
1120
+ ev.stopPropagation();
1121
+ if (opts && typeof opts.onToggleEpicCollapsed === "function") {
1122
+ opts.onToggleEpicCollapsed(epic.id);
1123
+ }
1124
+ });
1125
+ header.appendChild(chevron);
1126
+ }
1127
+ card.appendChild(header);
1128
+
1129
+ const effStories = effectiveStoriesFor(epic, entry.stories || [], currentSnapshot);
1130
+ // SM-135 (Schlanke-Säulen-Umbau): jedes Epic ist eine schlanke
1131
+ // Ein-Spalten-Säule fester Breite (= STORY_CARD_WIDTH_PX, exakt die
1132
+ // Breite eines losen Tickets). Stories stapeln einspaltig vertikal und
1133
+ // wachsen beliebig nach unten — KEIN Sub-Spalten-Wrap, KEIN 5er-Cap mehr.
1134
+ // Damit fluchten Header und jede Story-Zeile über alle Epic-Säulen einer
1135
+ // Release-Row hinweg (Ausrichtung by construction).
1136
+ const epicW = STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX;
1137
+ card.style.width = epicW + "px";
1138
+ if (effStories.length > 0) {
1139
+ // Eine Spalte: inside-width = epicW − 2×Grid-Padding (kein inter-col-Gap,
1140
+ // weil es nur eine Spalte gibt; der vertikale Abstand kommt vom grid-gap).
1141
+ const insideStoryW = epicW - 2 * STORY_MAP_LAYOUT.STORIES_GRID_PADDING_PX;
1142
+ const grid = el("div", {
1143
+ class: "sm-stories-grid" + (isCollapsed ? " sm-stories-grid-collapsed" : "")
1144
+ });
1145
+ grid.style.gridAutoFlow = "row";
1146
+ grid.style.gridTemplateColumns = insideStoryW + "px";
1147
+ // Feste Karten-Höhe pro Zeile; gridAutoRows wächst beliebig mit der
1148
+ // Story-Zahl (kein repeat(N)-Cap, kein reservierter Leerraum).
1149
+ grid.style.gridAutoRows = "var(--sm-card-h)";
1150
+ // SM-84: collapsed on initial render starts at max-height:0 (mirrors
1151
+ // the SM-83 cells-row pattern). Toggling owns the transition; this is
1152
+ // just the "already collapsed at page-load" starting state.
1153
+ if (isCollapsed) grid.style.maxHeight = "0px";
1154
+ for (const it of effStories) grid.appendChild(renderStoryCard(it.ticket, opts, it.shadow, insideStoryW));
1155
+ card.appendChild(grid);
1156
+ }
1157
+
1158
+ // SM-138: Der „+ Add"-Button wanderte vom Karten-Boden in den Epic-Header
1159
+ // (oben rechts, siehe oben) — kein Boden-Button mehr.
1160
+
1161
+ // Double-click on header opens the epic-detail modal. Single-click stays
1162
+ // a no-op so it doesn't compete with the drag-gesture threshold.
1163
+ header.addEventListener("dblclick", (ev) => {
1164
+ ev.stopPropagation();
1165
+ if (opts && typeof opts.onTicketClick === "function") opts.onTicketClick(epic.id);
1166
+ });
1167
+ if (isShadow) return card; // preview-only — no DnD on shadows
1168
+ dnd.enableDraggable(card, {
1169
+ dragType: DRAG_TYPES.EPIC,
1170
+ dragId: epic.id,
1171
+ onEnd: () => { if (_dragProjection) setDragProjection(null); }
1172
+ });
1173
+
1174
+ // The epic card itself is a drop-target for stories (re-assign to this epic).
1175
+ dnd.enableDropTarget(card, {
1176
+ accepts: [DRAG_TYPES.STORY],
1177
+ onEnter: (el) => el.classList.add("sm-drop-target"),
1178
+ onLeave: (el) => el.classList.remove("sm-drop-target"),
1179
+ onMove: ({ id, clientY }) => {
1180
+ // Compute insertion index from cursor Y vs. existing story rects.
1181
+ const grid = card.querySelector(".sm-stories-grid");
1182
+ let insertionIndex = 0;
1183
+ if (grid) {
1184
+ const cards = Array.from(grid.querySelectorAll(".sm-story-card"));
1185
+ // Exclude the current shadow card from the index search.
1186
+ const realCards = cards.filter(c => !c.classList.contains("sm-card-shadow"));
1187
+ insertionIndex = realCards.length;
1188
+ for (let i = 0; i < realCards.length; i++) {
1189
+ const r = realCards[i].getBoundingClientRect();
1190
+ if (clientY < r.top + r.height / 2) { insertionIndex = i; break; }
1191
+ }
1192
+ }
1193
+ setDragProjection({
1194
+ ticketId: id,
1195
+ target: { type: "epic", epicId: epic.id },
1196
+ insertionIndex: insertionIndex
1197
+ });
1198
+ },
1199
+ onDrop: ({ type, id }) => {
1200
+ if (type === DRAG_TYPES.STORY && typeof opts.onStoryDrop === "function") {
1201
+ const idx = (_dragProjection && _dragProjection.target
1202
+ && _dragProjection.target.type === "epic"
1203
+ && _dragProjection.target.epicId === epic.id)
1204
+ ? _dragProjection.insertionIndex : undefined;
1205
+ opts.onStoryDrop(id, epic.id, idx);
1206
+ // SM-84: dropping a story on a collapsed epic auto-expands the
1207
+ // epic so the user sees the new story land. Without this the
1208
+ // story would silently sit inside the still-collapsed grid.
1209
+ if (isCollapsed && opts && typeof opts.onToggleEpicCollapsed === "function") {
1210
+ opts.onToggleEpicCollapsed(epic.id);
1211
+ }
1212
+ }
1213
+ setDragProjection(null);
1214
+ }
1215
+ });
1216
+
1217
+ return card;
1218
+ }
1219
+
1220
+ // ---- Cell / Row / Backbone renderers ---------------------------------
1221
+
1222
+ // SM-272: compact cell for a collapsed column — just an "N epics" indicator
1223
+ // (N = epics mapped into this Release×Step cell). No cards, no add-button, no
1224
+ // drop target; the column is a narrow overview strip until expanded again.
1225
+ function renderCollapsedCell(releaseId, processStepId, cellData) {
1226
+ const nEpics = ((cellData && cellData.epics) || []).length;
1227
+ const nLoose = ((cellData && cellData.loose) || []).length;
1228
+ const cell = tagBirth(el("div", {
1229
+ class: "sm-cell sm-cell-collapsed",
1230
+ dataset: { releaseId, processStepId }
1231
+ }));
1232
+ // Show the epic count, plus a loose-ticket marker so a cell that holds only
1233
+ // loose (epic-less) work doesn't collapse to an empty strip (review).
1234
+ const parts = [];
1235
+ if (nEpics > 0) parts.push(nEpics + " epic" + (nEpics === 1 ? "" : "s"));
1236
+ if (nLoose > 0) parts.push(nLoose + " loose");
1237
+ if (parts.length) {
1238
+ cell.appendChild(el("span", {
1239
+ class: "sm-cell-collapsed-count",
1240
+ text: parts.join(" · "),
1241
+ title: parts.join(" · ") + " in this cell — expand the column to see them"
1242
+ }));
1243
+ }
1244
+ return cell;
1245
+ }
1246
+
1247
+ function renderCell(releaseId, processStepId, cellData, opts) {
1248
+ const epicEntries = (cellData && cellData.epics) || [];
1249
+ const looseList = effectiveLooseFor(releaseId, processStepId, (cellData && cellData.loose) || [], currentSnapshot);
1250
+ const cell = tagBirth(el("div", {
1251
+ class: "sm-cell",
1252
+ dataset: { releaseId, processStepId }
1253
+ }));
1254
+ // SM-136 (Schlanke-Säulen-Umbau): Epic-Säulen UND die Loose-Säule stehen
1255
+ // in EINER horizontalen, nicht-wrappenden Reihe (.sm-cell-epics) neben-
1256
+ // einander. Die Reihe wird auch dann gebaut, wenn es nur lose Tickets gibt.
1257
+ const hasEpics = epicEntries.length > 0;
1258
+ const hasLoose = looseList.length > 0;
1259
+ if (hasEpics || hasLoose) {
1260
+ const row = el("div", { class: "sm-cell-epics" });
1261
+ for (const entry of epicEntries) row.appendChild(renderEpicCard(entry, opts));
1262
+ if (hasLoose) {
1263
+ // Lose Tickets bekommen ihre EIGENE schlanke Säule rechts neben den
1264
+ // Epics — mit einem Header-Band auf Epic-Header-Höhe, damit die erste
1265
+ // lose Karte exakt mit der ersten Story-Zeile der Epics fluchtet
1266
+ // (Baseline by construction). Single-Column, wächst nach unten.
1267
+ const looseCard = el("div", { class: "sm-loose-card" });
1268
+ looseCard.style.width = STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX + "px";
1269
+ const looseHeader = el("div", { class: "sm-loose-header" });
1270
+ looseHeader.appendChild(el("span", { class: "sm-loose-title", text: "Unassigned" }));
1271
+ looseCard.appendChild(looseHeader);
1272
+ const insideW = STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX
1273
+ - 2 * STORY_MAP_LAYOUT.STORIES_GRID_PADDING_PX;
1274
+ const looseHost = el("div", {
1275
+ class: "sm-cell-loose",
1276
+ title: "Loose tickets — not bound to any epic in this cell"
1277
+ });
1278
+ looseHost.style.gridTemplateColumns = insideW + "px";
1279
+ for (const it of looseList) {
1280
+ looseHost.appendChild(renderStoryCard(it.ticket, opts, it.shadow, insideW));
1281
+ }
1282
+ looseCard.appendChild(looseHost);
1283
+ row.appendChild(looseCard);
1284
+ }
1285
+ cell.appendChild(row);
1286
+ }
1287
+ // Ghost-style add button at the bottom of every cell (sticky to bottom).
1288
+ const add = el("button", {
1289
+ class: "sm-add-ticket",
1290
+ title: "Add ticket in this cell",
1291
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Item</span>'
1292
+ });
1293
+ add.addEventListener("click", (ev) => {
1294
+ ev.stopPropagation();
1295
+ if (opts && typeof opts.onAddTicket === "function") {
1296
+ opts.onAddTicket({ releaseId, processStepId });
1297
+ }
1298
+ });
1299
+ cell.appendChild(add);
1300
+
1301
+ // Cells accept BOTH epic- and story-drops.
1302
+ // - Epic on cell: move into this (release, processStep) at projected
1303
+ // insertion index — works for inter-cell move AND intra-cell reorder.
1304
+ // - Story on cell (but not on an epic-card): place as loose ticket in
1305
+ // the cell — clears epicId, keeps releaseId+processStepId.
1306
+ dnd.enableDropTarget(cell, {
1307
+ accepts: [DRAG_TYPES.EPIC, DRAG_TYPES.STORY],
1308
+ onEnter: (el) => el.classList.add("sm-drop-target"),
1309
+ onLeave: (el) => el.classList.remove("sm-drop-target"),
1310
+ onMove: ({ type, id, clientX, clientY }) => {
1311
+ if (type === DRAG_TYPES.EPIC) {
1312
+ // Insertion index across the side-by-side epic row (.sm-cell-epics).
1313
+ // Compare against real (non-shadow) epic-card mid-X.
1314
+ const host = cell.querySelector(".sm-cell-epics");
1315
+ let insertionIndex = 0;
1316
+ if (host) {
1317
+ const real = Array.from(host.children).filter(c => !c.classList.contains("sm-card-shadow"));
1318
+ insertionIndex = real.length;
1319
+ for (let i = 0; i < real.length; i++) {
1320
+ const r = real[i].getBoundingClientRect();
1321
+ if (clientX < r.left + r.width / 2) { insertionIndex = i; break; }
1322
+ }
1323
+ }
1324
+ setDragProjection({
1325
+ ticketId: id,
1326
+ target: { type: "cell-epics", releaseId, processStepId },
1327
+ insertionIndex: insertionIndex
1328
+ });
1329
+ return;
1330
+ }
1331
+ if (type !== DRAG_TYPES.STORY) return;
1332
+ // Insertion index over the loose-list only.
1333
+ const host = cell.querySelector(".sm-cell-loose");
1334
+ let insertionIndex = 0;
1335
+ if (host) {
1336
+ const real = Array.from(host.children).filter(c => !c.classList.contains("sm-card-shadow"));
1337
+ insertionIndex = real.length;
1338
+ for (let i = 0; i < real.length; i++) {
1339
+ const r = real[i].getBoundingClientRect();
1340
+ if (clientY < r.top + r.height / 2) { insertionIndex = i; break; }
1341
+ }
1342
+ }
1343
+ setDragProjection({
1344
+ ticketId: id,
1345
+ target: { type: "cell", releaseId, processStepId },
1346
+ insertionIndex: insertionIndex
1347
+ });
1348
+ },
1349
+ onDrop: ({ type, id }) => {
1350
+ if (type === DRAG_TYPES.EPIC && typeof opts.onEpicDrop === "function") {
1351
+ const idx = (_dragProjection && _dragProjection.target
1352
+ && _dragProjection.target.type === "cell-epics"
1353
+ && _dragProjection.target.releaseId === releaseId
1354
+ && _dragProjection.target.processStepId === processStepId)
1355
+ ? _dragProjection.insertionIndex : undefined;
1356
+ opts.onEpicDrop(id, processStepId, releaseId, idx);
1357
+ } else if (type === DRAG_TYPES.STORY && typeof opts.onCellStoryDrop === "function") {
1358
+ const idx = (_dragProjection && _dragProjection.target
1359
+ && _dragProjection.target.type === "cell"
1360
+ && _dragProjection.target.releaseId === releaseId
1361
+ && _dragProjection.target.processStepId === processStepId)
1362
+ ? _dragProjection.insertionIndex : undefined;
1363
+ opts.onCellStoryDrop(id, releaseId, processStepId, idx);
1364
+ }
1365
+ setDragProjection(null);
1366
+ }
1367
+ });
1368
+ return cell;
1369
+ }
1370
+
1371
+ /**
1372
+ * Build the effective loose-list for a cell given the active drag
1373
+ * projection: origin removed if its current cell equals this one, and
1374
+ * the dragged ticket inserted as a shadow at insertionIndex if this
1375
+ * cell is the projection target.
1376
+ */
1377
+ function effectiveLooseFor(releaseId, processStepId, base, snapshot) {
1378
+ const proj = _dragProjection;
1379
+ if (!proj) return base.map(t => ({ ticket: t, shadow: false }));
1380
+ const out = base
1381
+ .filter(t => t.id !== proj.ticketId)
1382
+ .map(t => ({ ticket: t, shadow: false }));
1383
+ const tgt = proj.target;
1384
+ if (tgt && tgt.type === "cell"
1385
+ && tgt.releaseId === releaseId
1386
+ && tgt.processStepId === processStepId) {
1387
+ const draggedTicket = (snapshot.tickets || []).find(t => t.id === proj.ticketId);
1388
+ if (draggedTicket && draggedTicket.type !== "epic") {
1389
+ const idx = Math.max(0, Math.min(out.length, proj.insertionIndex || 0));
1390
+ out.splice(idx, 0, { ticket: draggedTicket, shadow: true });
1391
+ }
1392
+ }
1393
+ return out;
1394
+ }
1395
+
1396
+ /**
1397
+ * Erzeugt `grid-template-columns`-String für Backbone und Cells-Row:
1398
+ * pro PS-Spalte eine `calc(multiplier * var(--sm-backbone-col-w))`-
1399
+ * Sektion + Add-Column-Slot am rechten Rand (variable Breite). Es gibt
1400
+ * KEINE führende Release-Label-Spalte mehr — das Release-Label sitzt in
1401
+ * einer eigenen Zeile unter den Cells.
1402
+ */
1403
+ function gridTemplateForColumns(columns, columnMultipliers, withAddSlot, collapsedColumns) {
1404
+ const parts = [];
1405
+ for (const c of columns) {
1406
+ // SM-272: a collapsed column is a fixed narrow strip — NOT the epic-count-
1407
+ // driven columnMultiplier width that made busy columns run very wide.
1408
+ if (collapsedColumns && collapsedColumns.has && collapsedColumns.has(c.id)) {
1409
+ parts.push(STORY_MAP_LAYOUT.COLLAPSED_COL_WIDTH_PX + "px");
1410
+ } else {
1411
+ const m = (columnMultipliers && columnMultipliers.get(c.id)) || 1;
1412
+ parts.push("calc(" + m + " * var(--sm-backbone-col-w))");
1413
+ }
1414
+ }
1415
+ if (withAddSlot) parts.push("auto");
1416
+ return parts.join(" ");
1417
+ }
1418
+
1419
+ // SM-245: compute the new process-step order from the CURRENT snapshot (the
1420
+ // source of truth) + the drag projection's beforeId — NOT from a captured
1421
+ // `columns` array. The Phase-C morph reuses backbone columns by ps:id and
1422
+ // keeps their ORIGINAL dnd registration, so a column's onDrop closure holds
1423
+ // the pre-drag column order; reading it persisted a stale (no-op) order and
1424
+ // the column snapped back. Ticket-reorder (onStoryDrop) is robust for exactly
1425
+ // this reason — it reads store.get(). Pure + exported for tests.
1426
+ function processStepOrder(snapshot) {
1427
+ return ((snapshot && snapshot.processSteps) || [])
1428
+ .filter(p => !p.isDeleted)
1429
+ .slice()
1430
+ .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0))
1431
+ .map(p => p.id);
1432
+ }
1433
+ function sameIdOrder(a, b) {
1434
+ return a.length === b.length && a.every((v, i) => v === b[i]);
1435
+ }
1436
+ function reorderedProcessStepIds(snapshot, draggedId, beforeId) {
1437
+ const out = processStepOrder(snapshot).filter(id => id !== draggedId);
1438
+ if (beforeId == null) { out.push(draggedId); return out; }
1439
+ const at = out.indexOf(beforeId);
1440
+ if (at < 0) out.push(draggedId);
1441
+ else out.splice(at, 0, draggedId);
1442
+ return out;
1443
+ }
1444
+
1445
+ function renderBackbone(columns, columnMultipliers, opts) {
1446
+ const collapsedColumns = (opts && opts.collapsedColumns) || null;
1447
+ const backbone = tagBirth(el("div", { class: "sm-backbone" }));
1448
+ backbone.style.gridTemplateColumns = gridTemplateForColumns(columns, columnMultipliers, true, collapsedColumns);
1449
+ // No corner spacer anymore — there's no release-label column on the left.
1450
+ const proj = _dragProjection;
1451
+ const isPsProjection = proj && proj.target && proj.target.type === "process-step";
1452
+ for (const c of columns) {
1453
+ const isShadow = isPsProjection && proj.ticketId === c.id;
1454
+ const isColCollapsed = !!(collapsedColumns && collapsedColumns.has && collapsedColumns.has(c.id));
1455
+ const col = tagBirth(el("div", {
1456
+ class: "sm-backbone-col" + (isShadow ? " sm-col-shadow" : "") + (isColCollapsed ? " sm-col-collapsed" : ""),
1457
+ dataset: { processStepId: c.id },
1458
+ title: "Click to edit · drag to reorder"
1459
+ }));
1460
+ // SM-272: collapse chevron (▼ expanded / ▶ collapsed) at the column head.
1461
+ // stopPropagation so it doesn't open the edit dialog; toggles the per-
1462
+ // project collapsedColumns set + re-renders (width + cell content change).
1463
+ const colChevron = el("button", {
1464
+ class: "sm-col-chevron",
1465
+ type: "button",
1466
+ title: isColCollapsed ? "Expand column" : "Collapse column",
1467
+ text: isColCollapsed ? "▶" : "▼"
1468
+ });
1469
+ colChevron.setAttribute("aria-expanded", isColCollapsed ? "false" : "true");
1470
+ colChevron.addEventListener("click", (ev) => {
1471
+ ev.stopPropagation();
1472
+ if (opts && typeof opts.onToggleColumnCollapsed === "function") opts.onToggleColumnCollapsed(c.id);
1473
+ if (typeof _rerender === "function") _rerender();
1474
+ });
1475
+ col.appendChild(colChevron);
1476
+ // Grip-handle as a visual cue that the column is draggable.
1477
+ col.appendChild(el("span", { class: "sm-col-grip", text: "⋮⋮" }));
1478
+ col.appendChild(el("span", { class: "sm-col-label", text: c.name }));
1479
+ col.addEventListener("click", (ev) => {
1480
+ ev.stopPropagation();
1481
+ if (opts && typeof opts.onProcessStepClick === "function") opts.onProcessStepClick(c.id);
1482
+ });
1483
+ dnd.enableDraggable(col, {
1484
+ dragType: DRAG_TYPES.PROCESS_STEP,
1485
+ dragId: c.id,
1486
+ onEnd: () => { if (_dragProjection) setDragProjection(null); }
1487
+ });
1488
+ dnd.enableDropTarget(col, {
1489
+ accepts: [DRAG_TYPES.PROCESS_STEP],
1490
+ onEnter: (el) => el.classList.add("sm-col-drop-target"),
1491
+ onLeave: (el) => el.classList.remove("sm-col-drop-target"),
1492
+ onMove: ({ id, clientX }) => {
1493
+ // Project insertion based on cursor-X vs. column-rect midpoint:
1494
+ // left half → insert before this col, right half → insert before
1495
+ // the NEXT col (or append if this is the last column).
1496
+ const rect = col.getBoundingClientRect();
1497
+ const insertBefore = clientX < rect.left + rect.width / 2;
1498
+ let beforeId;
1499
+ if (insertBefore) {
1500
+ beforeId = c.id;
1501
+ } else {
1502
+ const idx = columns.findIndex(x => x.id === c.id);
1503
+ const next = columns[idx + 1];
1504
+ beforeId = next ? next.id : null;
1505
+ }
1506
+ // Dragging col over its own slot → no-op projection (beforeId same as id).
1507
+ if (beforeId === id) return;
1508
+ setDragProjection({
1509
+ ticketId: id,
1510
+ target: { type: "process-step", beforeId }
1511
+ });
1512
+ },
1513
+ onDrop: ({ id }) => {
1514
+ if (typeof opts.onProcessStepReorder !== "function") { setDragProjection(null); return; }
1515
+ // SM-245: recompute from the live snapshot + projection beforeId, NOT
1516
+ // from the captured `columns` (a morph-reused stale closure may hold
1517
+ // the pre-drag order → no-op → snap-back).
1518
+ const proj = _dragProjection;
1519
+ if (!proj || !proj.target || proj.target.type !== "process-step") { setDragProjection(null); return; }
1520
+ const order = reorderedProcessStepIds(currentSnapshot, id, proj.target.beforeId);
1521
+ // Skip a no-op drop (order unchanged) so it doesn't create a redundant
1522
+ // undo entry / persist (review [minor]).
1523
+ if (order.includes(id) && !sameIdOrder(order, processStepOrder(currentSnapshot))) {
1524
+ opts.onProcessStepReorder(order);
1525
+ }
1526
+ setDragProjection(null);
1527
+ }
1528
+ });
1529
+ backbone.appendChild(col);
1530
+ }
1531
+ if (columns.length === 0) {
1532
+ backbone.appendChild(el("div", { class: "sm-backbone-col sm-empty", text: "(no process steps)" }));
1533
+ }
1534
+
1535
+ // Add-Column-Dropzone am rechten Rand: Click → +Process-Step-Dialog,
1536
+ // ODER Drop von Epic → +Process-Step-Dialog + automatischer Move des
1537
+ // Epics in die neue Column.
1538
+ const addCol = el("div", {
1539
+ class: "sm-backbone-col sm-add-col",
1540
+ title: "Click to add a new process step, or drag an epic here"
1541
+ });
1542
+ addCol.appendChild(el("span", { class: "sm-add-icon", text: "+" }));
1543
+ addCol.appendChild(el("span", { class: "sm-add-col-label", text: "Add step" }));
1544
+ addCol.addEventListener("click", (ev) => {
1545
+ ev.stopPropagation();
1546
+ if (opts && typeof opts.onAddProcessStep === "function") opts.onAddProcessStep();
1547
+ });
1548
+ dnd.enableDropTarget(addCol, {
1549
+ accepts: [DRAG_TYPES.EPIC, DRAG_TYPES.STORY, DRAG_TYPES.PROCESS_STEP],
1550
+ onEnter: (el) => el.classList.add("sm-drop-target"),
1551
+ onLeave: (el) => el.classList.remove("sm-drop-target"),
1552
+ onMove: ({ type, id }) => {
1553
+ // PS drop on add-col = "insert at end". Project accordingly so the
1554
+ // user sees the column slide to the end in real time.
1555
+ if (type !== DRAG_TYPES.PROCESS_STEP) return;
1556
+ setDragProjection({
1557
+ ticketId: id,
1558
+ target: { type: "process-step", beforeId: null }
1559
+ });
1560
+ },
1561
+ onDrop: ({ type, id }) => {
1562
+ if (type === DRAG_TYPES.PROCESS_STEP) {
1563
+ if (typeof opts.onProcessStepReorder === "function") {
1564
+ // SM-245: drop on the add-col = append. Recompute from the live
1565
+ // snapshot (robust against a morph-reused stale closure).
1566
+ const order = reorderedProcessStepIds(currentSnapshot, id, null);
1567
+ if (order.includes(id) && !sameIdOrder(order, processStepOrder(currentSnapshot))) {
1568
+ opts.onProcessStepReorder(order);
1569
+ }
1570
+ }
1571
+ setDragProjection(null);
1572
+ return;
1573
+ }
1574
+ if (typeof opts.onAddProcessStepWithTicket === "function") {
1575
+ opts.onAddProcessStepWithTicket(id, type);
1576
+ }
1577
+ }
1578
+ });
1579
+ backbone.appendChild(addCol);
1580
+
1581
+ return backbone;
1582
+ }
1583
+
1584
+ /** Cells-Zeile pro Release — nur die Cells, kein Label mehr. */
1585
+ function renderReleaseCellsRow(row, releaseData, columns, columnMultipliers, opts, isCollapsed) {
1586
+ const collapsedColumns = (opts && opts.collapsedColumns) || null;
1587
+ const releaseStatus = (row.entity && row.entity.status) || null;
1588
+ const r = tagBirth(el("div", {
1589
+ class: "sm-release-row sm-release-cells" + (isCollapsed ? " sm-release-collapsed-cells" : ""),
1590
+ dataset: { releaseId: row.id, status: releaseStatus || "" }
1591
+ }));
1592
+ r.style.gridTemplateColumns = gridTemplateForColumns(columns, columnMultipliers, true, collapsedColumns);
1593
+ // SM-83: collapsed releases on initial render get an inline max-height:0
1594
+ // so the row doesn't paint at full height before JS hooks up. Toggling
1595
+ // owns the transition animation; this is just the "already collapsed
1596
+ // at page-load" starting state.
1597
+ if (isCollapsed) r.style.maxHeight = "0px";
1598
+ if (columns.length === 0) {
1599
+ r.appendChild(el("div", { class: "sm-cell sm-empty", text: "Add process steps to map epics into columns." }));
1600
+ } else {
1601
+ for (const c of columns) {
1602
+ const cellData = releaseData.cells[c.id] || { epics: [], loose: [] };
1603
+ // SM-272: a collapsed column shows a compact epic-count per cell.
1604
+ if (collapsedColumns && collapsedColumns.has && collapsedColumns.has(c.id)) {
1605
+ r.appendChild(renderCollapsedCell(row.id, c.id, cellData));
1606
+ } else {
1607
+ r.appendChild(renderCell(row.id, c.id, cellData, opts));
1608
+ }
1609
+ }
1610
+ }
1611
+ // Filler on the right to occupy the add-step slot from the backbone.
1612
+ r.appendChild(el("div", { class: "sm-row-filler" }));
1613
+ return r;
1614
+ }
1615
+
1616
+ /**
1617
+ * Label-Zeile pro Release: linksbündiges Release-Label (klickbar →
1618
+ * Edit-Dialog). Wenn `showAddRelease === true`, kommt zusätzlich ein
1619
+ * `+ Add Release`-Button daneben. SM-252: das ist jetzt die OBERSTE
1620
+ * Release-Zeile (neueste-oben), nicht mehr die unterste.
1621
+ *
1622
+ * SM-83: Chevron-Toggle vor dem Label klappt die Cells-Row der Release
1623
+ * ein/aus. Collapsed-State ist View-only (lebt in opts.collapsedReleases),
1624
+ * Persistenz via main.js (localStorage).
1625
+ */
1626
+ function renderReleaseLabelRow(row, showAddRelease, opts, isCollapsed) {
1627
+ // SM-80: surface release.status as data-status so CSS can dim completed
1628
+ // releases and a pill can sit next to the label.
1629
+ const releaseStatus = (row.entity && row.entity.status) || null;
1630
+ // SM-169: the header (chevron + name + pill) is built by the SHARED
1631
+ // component so the Kanban swimlanes render releases identically.
1632
+ const trailing = [];
1633
+ if (showAddRelease) {
1634
+ const add = el("button", {
1635
+ class: "sm-add-release-btn",
1636
+ title: "Add another release on top",
1637
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Release</span>'
1638
+ });
1639
+ add.addEventListener("click", (ev) => {
1640
+ ev.stopPropagation();
1641
+ if (opts && typeof opts.onAddRelease === "function") opts.onAddRelease();
1642
+ });
1643
+ trailing.push(add);
1644
+ }
1645
+ const rowEl = tagBirth(rendererCard.renderReleaseLabelRow({
1646
+ id: row.id,
1647
+ name: row.name,
1648
+ status: releaseStatus,
1649
+ progress: row.progress, // SM-239
1650
+ collapsed: isCollapsed,
1651
+ onToggleCollapsed: (id) => {
1652
+ if (opts && typeof opts.onToggleReleaseCollapsed === "function") opts.onToggleReleaseCollapsed(id);
1653
+ },
1654
+ onLabelClick: (id) => {
1655
+ if (opts && typeof opts.onReleaseClick === "function") opts.onReleaseClick(id);
1656
+ },
1657
+ trailing
1658
+ }));
1659
+ // SM-253: the release header is a drop target — dropping a card here
1660
+ // re-homes it to THIS release as unplaced (release set, no process-step),
1661
+ // so it shows in the release's holding strip. This is the always-present
1662
+ // unplace target (the holding strip only renders when non-empty). Clicks
1663
+ // still open the edit dialog (dnd uses a drag threshold).
1664
+ dnd.enableDropTarget(rowEl, {
1665
+ accepts: [DRAG_TYPES.EPIC, DRAG_TYPES.STORY],
1666
+ onEnter: (el) => el.classList.add("sm-drop-target"),
1667
+ onLeave: (el) => el.classList.remove("sm-drop-target"),
1668
+ onDrop: ({ id, type }) => {
1669
+ if (opts && typeof opts.onReleaseUnplacedDrop === "function") opts.onReleaseUnplacedDrop(id, type, row.id);
1670
+ setDragProjection(null);
1671
+ }
1672
+ });
1673
+ return rowEl;
1674
+ }
1675
+
1676
+ // SM-253: per-release holding strip for release-assigned-but-unplaced tickets
1677
+ // (release set, no process-step). Cards are drag SOURCES (drag into a cell to
1678
+ // place them); the strip is also a drop target (re-home here, unplaced).
1679
+ // Rendered only when the release has such tickets — no empty-strip clutter.
1680
+ function renderReleaseUnplacedRow(row, unplaced, opts) {
1681
+ const strip = tagBirth(el("div", { class: "sm-release-unplaced-row", dataset: { releaseId: row.id } }));
1682
+ strip.appendChild(el("div", { class: "sm-release-unplaced-label",
1683
+ text: "Unplaced — drag into a step" }));
1684
+ const list = el("div", { class: "sm-release-unplaced-list" });
1685
+ for (const e of unplaced.epics) {
1686
+ list.appendChild(renderEpicCard({ epic: e.epic, stories: e.stories }, opts));
1687
+ }
1688
+ for (const story of unplaced.stories) {
1689
+ list.appendChild(renderStoryCard(story, opts));
1690
+ }
1691
+ strip.appendChild(list);
1692
+ dnd.enableDropTarget(strip, {
1693
+ accepts: [DRAG_TYPES.EPIC, DRAG_TYPES.STORY],
1694
+ onEnter: (el) => el.classList.add("sm-drop-target"),
1695
+ onLeave: (el) => el.classList.remove("sm-drop-target"),
1696
+ onDrop: ({ id, type }) => {
1697
+ if (opts && typeof opts.onReleaseUnplacedDrop === "function") opts.onReleaseUnplacedDrop(id, type, row.id);
1698
+ setDragProjection(null);
1699
+ }
1700
+ });
1701
+ return strip;
1702
+ }
1703
+
1704
+ // SM-193: insertion index for a drop into the (possibly multi-column) backlog
1705
+ // list. `rects` are the non-shadow card bounding boxes in DOM order — which IS
1706
+ // the flat column-major order (CSS columns fill top→bottom, then the next
1707
+ // column). A single column reduces to the plain cursor-Y midpoint walk. For
1708
+ // multiple columns we FIRST pick the column the cursor is over (by X), then do
1709
+ // the Y-midpoint walk WITHIN that column — without this the pure-Y walk matches
1710
+ // a card at the top of a later column and drops land visually wrong.
1711
+ function columnAwareInsertionIndex(rects, x, y) {
1712
+ if (!rects || rects.length === 0) return 0;
1713
+ const cols = []; // [{ left, right, items: [{idx, top, bottom}] }] grouped by left edge
1714
+ for (let i = 0; i < rects.length; i++) {
1715
+ const r = rects[i];
1716
+ let col = null;
1717
+ for (let k = 0; k < cols.length; k++) { if (Math.abs(cols[k].left - r.left) < 1) { col = cols[k]; break; } }
1718
+ if (!col) { col = { left: r.left, right: r.right, items: [] }; cols.push(col); }
1719
+ col.right = Math.max(col.right, r.right);
1720
+ col.items.push({ idx: i, top: r.top, bottom: r.bottom });
1721
+ }
1722
+ cols.sort((a, b) => a.left - b.left);
1723
+ // Column whose horizontal band is nearest the cursor X (0 distance = inside).
1724
+ let target = cols[0], best = Infinity;
1725
+ for (let k = 0; k < cols.length; k++) {
1726
+ const c = cols[k];
1727
+ const dist = x < c.left ? c.left - x : (x > c.right ? x - c.right : 0);
1728
+ if (dist < best) { best = dist; target = c; }
1729
+ }
1730
+ // First card in that column (DOM order = top→bottom) whose mid-Y is below y.
1731
+ for (let j = 0; j < target.items.length; j++) {
1732
+ const it = target.items[j];
1733
+ if (y < (it.top + it.bottom) / 2) return it.idx;
1734
+ }
1735
+ // Below every card in this column → after its last card, which in flat
1736
+ // column-major order is the first card of the next column.
1737
+ return target.items[target.items.length - 1].idx + 1;
1738
+ }
1739
+
1740
+
1741
+ // currentSnapshot is captured per-mount so card renderers (epic story-lists,
1742
+ // the per-release holding strip) can grab fresh story-lists from the snapshot.
1743
+ let currentSnapshot = null;
1744
+ // SM-49: per-render link-index built once at the top of renderInto and
1745
+ // consumed by every renderStoryCard / renderEpicCard call further down.
1746
+ // O(N+links) up-front beats O(links) per card.
1747
+ let _linkIndex = null;
1748
+ // SM-60: last-execution per test-definition. Same pattern as _linkIndex —
1749
+ // compute once per renderInto, then look up per card.
1750
+ let _lastExecIndex = null;
1751
+
1752
+ // _dragProjection drives live-reorder rendering during an active drag.
1753
+ // Shape: { ticketId, target: { type:"epic", epicId } | { type:"backlog" },
1754
+ // insertionIndex }. `_rerender` is wired by mount() so the
1755
+ // projection setter can trigger a full re-render.
1756
+ let _dragProjection = null;
1757
+ let _rerender = null;
1758
+
1759
+ // SM-274: collapse-state of the Map-Eingang (Bodenstreifen). Module-level so
1760
+ // it survives the tray's rebuild-on-every-render (the render reads it; the
1761
+ // header toggle flips it + triggers _rerender) — no reliance on DOM identity.
1762
+ let _trayCollapsed = false;
1763
+
1764
+ // SM-221: drag-layout cache. The base story-map layout (computeStoryMapLayout,
1765
+ // O(Releases×Steps×Tickets)) is recomputed on EVERY pointermove today because
1766
+ // setDragProjection → _rerender → renderInto recomputes it. During a drag the
1767
+ // snapshot does not change, so we compute it ONCE and reuse it; only the pure
1768
+ // projection helpers (applyEpicCellProjection / applyProcessStepProjection /
1769
+ // effectiveStoriesFor) run per move on top of the cached base. Keyed on the
1770
+ // (snapshot, filter, matchTicket) REFERENCES — any store commit produces a new
1771
+ // snapshot reference, so a commit mid-drag (WS-push from MCP) auto-invalidates.
1772
+ let _baseLayoutCache = null; // { snap, filter, matchTicket, layout } | null
1773
+
1774
+ function computeBaseLayout(snap, filter, matchTicket) {
1775
+ if (_baseLayoutCache
1776
+ && _baseLayoutCache.snap === snap
1777
+ && _baseLayoutCache.filter === filter
1778
+ && _baseLayoutCache.matchTicket === matchTicket) {
1779
+ return _baseLayoutCache.layout;
1780
+ }
1781
+ const layout = computeStoryMapLayout(snap, { filter: filter, matchTicket: matchTicket });
1782
+ _baseLayoutCache = { snap: snap, filter: filter, matchTicket: matchTicket, layout: layout };
1783
+ return layout;
1784
+ }
1785
+
1786
+ function setDragProjection(p) {
1787
+ // Reference-equality fast path so onMove tick that produces the same
1788
+ // {target, insertionIndex/beforeId} doesn't trigger a re-render.
1789
+ if (p && _dragProjection
1790
+ && p.ticketId === _dragProjection.ticketId
1791
+ && p.insertionIndex === _dragProjection.insertionIndex
1792
+ && p.target && _dragProjection.target
1793
+ && p.target.type === _dragProjection.target.type
1794
+ && p.target.epicId === _dragProjection.target.epicId
1795
+ && p.target.beforeId === _dragProjection.target.beforeId
1796
+ && p.target.releaseId === _dragProjection.target.releaseId
1797
+ && p.target.processStepId === _dragProjection.target.processStepId) {
1798
+ return;
1799
+ }
1800
+ // SM-221: drag-end (p == null) drops the cache so the drop render recomputes
1801
+ // the base layout once ("Recalc erst beim Drop"). Drop/cancel/end all funnel
1802
+ // here via clearDragProjection / onEnd, so this is the single clear point.
1803
+ if (!p) _baseLayoutCache = null;
1804
+ const prev = _dragProjection;
1805
+ _dragProjection = p;
1806
+ // SM-223 (d): mid-drag (same dragged ticket, projection → projection) the
1807
+ // affected containers are rebuilt + morphed in place — the full grid
1808
+ // build+morph (the actual per-move cost) is skipped. Drag start (prev
1809
+ // null), drag end (p null) and unsupported shapes take the full path.
1810
+ if (p && prev && p.ticketId === prev.ticketId
1811
+ && tryIncrementalDragUpdate(prev, p)) {
1812
+ return;
1813
+ }
1814
+ if (typeof _rerender === "function") _rerender();
1815
+ }
1816
+
1817
+ /**
1818
+ * Apply an active epic-on-cell drag projection: remove the dragged epic
1819
+ * from every cell it currently occupies, then insert it as a shadow
1820
+ * entry into the target cell at insertionIndex. Pure-ish — the layout's
1821
+ * `releases.{rid}.cells.{psid}.epics` arrays are cloned and replaced.
1822
+ */
1823
+ function applyEpicCellProjection(layout, snapshot) {
1824
+ const proj = _dragProjection;
1825
+ if (!proj || !proj.target || proj.target.type !== "cell-epics") return layout;
1826
+ const draggedId = proj.ticketId;
1827
+ const tgtRel = proj.target.releaseId;
1828
+ const tgtPs = proj.target.processStepId;
1829
+ const insertionIndex = proj.insertionIndex || 0;
1830
+ const draggedTicket = (snapshot.tickets || []).find(t => t.id === draggedId);
1831
+ if (!draggedTicket || draggedTicket.type !== "epic") return layout;
1832
+ // SM-97: keep the dragged epic in its HOME cell during cross-cell projections.
1833
+ // The dnd layer styles the origin DOM with .sm-dragging for a ghost effect;
1834
+ // crucially the source cell does NOT shrink. Removing the epic would
1835
+ // shrink the source by one card's height + (when the epic has stories)
1836
+ // its sub-grid, shifting the whole layout. The cursor stays at a fixed
1837
+ // window-Y while the layout moves under it, so the next pointermove
1838
+ // re-projects to a cell the user is no longer aiming at — most visibly
1839
+ // for cross-release moves. Intra-cell reorder (source == target) still
1840
+ // filters so the shadow can be re-inserted at the projected index.
1841
+ const homeRel = draggedTicket.position && draggedTicket.position.releaseId;
1842
+ const homePs = draggedTicket.position && draggedTicket.position.processStepId;
1843
+
1844
+ const newReleases = {};
1845
+ for (const rid of Object.keys(layout.releases)) {
1846
+ const src = layout.releases[rid];
1847
+ const newCells = {};
1848
+ for (const psid of Object.keys(src.cells)) {
1849
+ const srcCell = src.cells[psid];
1850
+ const isHomeCell = (rid === homeRel && psid === homePs);
1851
+ const isTargetCell = (rid === tgtRel && psid === tgtPs);
1852
+ const isCrossCell = !(homeRel === tgtRel && homePs === tgtPs);
1853
+ let epics;
1854
+ if (isHomeCell && isCrossCell && !isTargetCell) {
1855
+ // Cross-cell, this is the home cell, not the target → keep epic.
1856
+ epics = srcCell.epics;
1857
+ } else {
1858
+ // Either intra-cell reorder, or non-home cell, or home cell that
1859
+ // also happens to be the target — filter out so we can re-insert
1860
+ // the shadow at the projected index without duplicating.
1861
+ epics = srcCell.epics.filter(e => e.epic.id !== draggedId);
1862
+ }
1863
+ if (isTargetCell) {
1864
+ const idx = Math.max(0, Math.min(epics.length, insertionIndex));
1865
+ epics = epics.slice();
1866
+ epics.splice(idx, 0, {
1867
+ epic: draggedTicket,
1868
+ stories: core.tickets.storiesInEpic(snapshot, draggedId),
1869
+ shadow: true
1870
+ });
1871
+ }
1872
+ newCells[psid] = Object.assign({}, srcCell, { epics });
1873
+ }
1874
+ newReleases[rid] = Object.assign({}, src, { cells: newCells });
1875
+ }
1876
+ return Object.assign({}, layout, { releases: newReleases });
1877
+ }
1878
+
1879
+ /**
1880
+ * Apply an active process-step drag projection to the layout's columns
1881
+ * array — origin column is removed and re-inserted at the projected
1882
+ * position (before `beforeId`, or appended if beforeId == null). Cells
1883
+ * follow automatically because renderReleaseRow iterates `columns`.
1884
+ */
1885
+ function applyProcessStepProjection(layout) {
1886
+ const proj = _dragProjection;
1887
+ if (!proj || !proj.target || proj.target.type !== "process-step") return layout;
1888
+ const draggedId = proj.ticketId;
1889
+ const beforeId = proj.target.beforeId;
1890
+ const cols = layout.columns.slice();
1891
+ const origIdx = cols.findIndex(c => c.id === draggedId);
1892
+ if (origIdx < 0) return layout;
1893
+ const [dragged] = cols.splice(origIdx, 1);
1894
+ if (beforeId == null) {
1895
+ cols.push(dragged);
1896
+ } else {
1897
+ const insertIdx = cols.findIndex(c => c.id === beforeId);
1898
+ if (insertIdx < 0) cols.push(dragged);
1899
+ else cols.splice(insertIdx, 0, dragged);
1900
+ }
1901
+ return Object.assign({}, layout, { columns: cols });
1902
+ }
1903
+ function clearDragProjection() { setDragProjection(null); }
1904
+
1905
+ // ---- SM-223 (d): drag-incremental DOM update --------------------------
1906
+ //
1907
+ // SM-221 removed the per-move layout recompute, but the dragMove benchmark
1908
+ // showed the REAL per-move cost is renderInto building a fresh full grid +
1909
+ // Phase-C morphing every card (~200ms at N=500). Mid-drag, a projection
1910
+ // change only affects a handful of containers: the previous target, the new
1911
+ // target, and (for epic-cell drags) the dragged epic's home cell. We rebuild
1912
+ // ONLY those subtrees with the SAME builder the full path uses (renderCell
1913
+ // reads the live _dragProjection) and morph them in place — byte-identical
1914
+ // DOM by construction, at a fraction of the work.
1915
+ // Unsupported shapes (process-step column reorder, lookup misses, a commit
1916
+ // mid-drag) return false and fall back to the full render path.
1917
+
1918
+ // Wired by mount(): { host, opts } — the same wrappedOpts renderInto uses.
1919
+ let _dragRenderCtx = null;
1920
+
1921
+ /**
1922
+ * The containers a projection touches: a list of cell keys and/or the
1923
+ * backlog section. Returns null when the projection type can't be handled
1924
+ * incrementally (e.g. process-step column reorder).
1925
+ */
1926
+ function projectionContainers(proj, snap) {
1927
+ const t = (proj && proj.target) || {};
1928
+ if (t.type === "epic") {
1929
+ const epic = (snap.tickets || []).find(x => x.id === t.epicId);
1930
+ if (!epic) return null;
1931
+ const rid = epic.position && epic.position.releaseId;
1932
+ const pid = epic.position && epic.position.processStepId;
1933
+ // SM-253: a fully-placed epic → its cell. A partially-assigned epic (in a
1934
+ // holding strip, no process-step) has no cell to morph incrementally —
1935
+ // bail to the full render path so its drag preview still updates.
1936
+ if (!(rid && pid)) return null;
1937
+ return { cells: [{ releaseId: rid, processStepId: pid }] };
1938
+ }
1939
+ if (t.type === "cell") {
1940
+ return { cells: [{ releaseId: t.releaseId, processStepId: t.processStepId }] };
1941
+ }
1942
+ if (t.type === "cell-epics") {
1943
+ // applyEpicCellProjection touches the target cell AND the dragged
1944
+ // epic's home cell (SM-97 keep-in-home semantics) — rebuild both.
1945
+ const dragged = (snap.tickets || []).find(x => x.id === proj.ticketId);
1946
+ const hrid = dragged && dragged.position && dragged.position.releaseId;
1947
+ const hpid = dragged && dragged.position && dragged.position.processStepId;
1948
+ if (!hrid || !hpid) return null; // partially-assigned epic dragged over cells — full path
1949
+ return {
1950
+ cells: [
1951
+ { releaseId: t.releaseId, processStepId: t.processStepId },
1952
+ { releaseId: hrid, processStepId: hpid }
1953
+ ]
1954
+ };
1955
+ }
1956
+ // SM-253: no more "backlog" projection target (the global backlog is gone).
1957
+ return null;
1958
+ }
1959
+
1960
+ /**
1961
+ * Incrementally apply a projection change prev → next on the live DOM.
1962
+ * Returns true when handled (full render skipped). Both projections'
1963
+ * containers are rebuilt so the shadow disappears from the old target and
1964
+ * appears at the new one.
1965
+ */
1966
+ function tryIncrementalDragUpdate(prev, next) {
1967
+ const ctx = _dragRenderCtx;
1968
+ if (!ctx || !ctx.host || !ctx.store || !_baseLayoutCache) return false;
1969
+ // A commit mid-drag produces a new snapshot reference → the cached base
1970
+ // no longer matches → full render (which rebuilds the cache). Guard
1971
+ // against the STORE's snapshot, not currentSnapshot: the Phase-A/B
1972
+ // fast-paths handle applyRemote commits WITHOUT renderInto, so
1973
+ // currentSnapshot and the cache would both be stale together and an
1974
+ // incremental morph would visually revert the remote change (review
1975
+ // finding on SM-223).
1976
+ const snap = ctx.store.get();
1977
+ if (!snap || _baseLayoutCache.snap !== snap) return false;
1978
+ const a = projectionContainers(prev, snap);
1979
+ const b = projectionContainers(next, snap);
1980
+ if (!a || !b) return false;
1981
+ const cellKeys = new Map();
1982
+ for (const c of a.cells.concat(b.cells)) {
1983
+ cellKeys.set(c.releaseId + "" + c.processStepId, c);
1984
+ }
1985
+ // The projected layout for cell-epics targets; a no-op for the others.
1986
+ const layout = applyEpicCellProjection(_baseLayoutCache.layout, snap);
1987
+ for (const c of cellKeys.values()) {
1988
+ const sel = '.sm-cell[data-release-id="' + cssEscape(c.releaseId) +
1989
+ '"][data-process-step-id="' + cssEscape(c.processStepId) + '"]';
1990
+ const oldCell = ctx.host.querySelector(sel);
1991
+ const rel = layout.releases[c.releaseId];
1992
+ const cellData = rel && rel.cells && rel.cells[c.processStepId];
1993
+ if (!oldCell || !cellData) return false;
1994
+ morphTree(oldCell, renderCell(c.releaseId, c.processStepId, cellData, ctx.opts));
1995
+ }
1996
+ return true;
1997
+ }
1998
+
1999
+ /**
2000
+ * Wenn ProcessSteps existieren aber keine Release, zeigen wir leere
2001
+ * Phantom-Cells unter dem Backbone + eine Label-Zeile mit reinem
2002
+ * "+ Add Release"-Button (kein vorhandenes Label, nur die CTA).
2003
+ */
2004
+ function renderPhantomReleaseRows(columns, columnMultipliers, opts) {
2005
+ const collapsedColumns = (opts && opts.collapsedColumns) || null;
2006
+ const cells = el("div", { class: "sm-release-row sm-release-cells sm-phantom-row" });
2007
+ cells.style.gridTemplateColumns = gridTemplateForColumns(columns, columnMultipliers, true, collapsedColumns);
2008
+ for (const c of columns) {
2009
+ cells.appendChild(el("div", {
2010
+ class: "sm-cell sm-phantom-cell",
2011
+ dataset: { processStepId: c.id }
2012
+ }));
2013
+ }
2014
+ cells.appendChild(el("div", { class: "sm-row-filler" }));
2015
+
2016
+ const labelRow = el("div", { class: "sm-release-label-row sm-phantom-label-row" });
2017
+ const btn = el("button", {
2018
+ class: "sm-add-release-btn sm-phantom-cta",
2019
+ title: "Add release",
2020
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Release</span>'
2021
+ });
2022
+ btn.addEventListener("click", (ev) => {
2023
+ ev.stopPropagation();
2024
+ if (opts && typeof opts.onAddRelease === "function") opts.onAddRelease();
2025
+ });
2026
+ labelRow.appendChild(btn);
2027
+ // SM-192: label (heading) above the cells, consistent with real releases.
2028
+ return [labelRow, cells];
2029
+ }
2030
+
2031
+ // SM-274/SM-276: render the Map-Eingang (Bodenstreifen). Returns the `.sm-tray`
2032
+ // node or null when there is nothing unassigned (no empty strip clutters the
2033
+ // map). SM-276: cards are drag sources (drop on a cell/epic = schedule via the
2034
+ // existing cell handlers) and the strip is itself a drop target (drop a
2035
+ // scheduled card here = unassign it back to the inbox).
2036
+ function renderUnassignedTray(snapshot, opts) {
2037
+ // Filter parity with the cells: SM-82 chip filter AND-combined with the
2038
+ // SM-191 query predicate, mirroring computeStoryMapLayout.
2039
+ const filter = (opts && opts.filter) || null;
2040
+ const matchTicket = (opts && typeof opts.matchTicket === "function") ? opts.matchTicket : null;
2041
+ const filterCtx = (filterMod && filter) ? filterMod.buildReleaseCtx(snapshot) : null;
2042
+ const filterMatch = (filterMod && filter)
2043
+ ? (t) => filterMod.matchesFilter(t, filter, filterCtx)
2044
+ : (_t) => true;
2045
+ const matches = matchTicket ? (t) => filterMatch(t) && matchTicket(t) : filterMatch;
2046
+
2047
+ const tray = computeUnassignedTrayLayout(snapshot, {
2048
+ rows: STORY_MAP_LAYOUT.TRAY_ROWS_DEFAULT,
2049
+ matches: matches
2050
+ });
2051
+ if (tray.count === 0) return null;
2052
+
2053
+ const onToggle = () => {
2054
+ _trayCollapsed = !_trayCollapsed;
2055
+ if (typeof _rerender === "function") _rerender();
2056
+ };
2057
+
2058
+ const header = el("div", { class: "sm-tray-header" }, [
2059
+ el("button", {
2060
+ class: "sm-tray-toggle", type: "button",
2061
+ "aria-expanded": _trayCollapsed ? "false" : "true",
2062
+ title: _trayCollapsed ? "Eingang ausklappen" : "Eingang einklappen",
2063
+ onclick: onToggle
2064
+ }, [ _trayCollapsed ? "▸" : "▾" ]),
2065
+ el("span", { class: "sm-tray-title", text: "Unassigned" }),
2066
+ el("span", { class: "sm-tray-count", text: String(tray.count) })
2067
+ ]);
2068
+
2069
+ const root = el("div", {
2070
+ class: "sm-tray" + (_trayCollapsed ? " sm-tray-collapsed" : ""),
2071
+ dataset: { section: "unassigned-tray" }
2072
+ }, [ header ]);
2073
+
2074
+ // SM-276: the strip is a drop target — dropping a scheduled card here clears
2075
+ // its release (unassign back to the inbox). Accepts both stories and epics.
2076
+ dnd.enableDropTarget(root, {
2077
+ accepts: [DRAG_TYPES.STORY, DRAG_TYPES.EPIC],
2078
+ onDrop: ({ id }) => {
2079
+ if (typeof opts.onTrayDrop === "function") opts.onTrayDrop(id);
2080
+ }
2081
+ });
2082
+
2083
+ if (!_trayCollapsed) {
2084
+ const cardGrid = el("div", {
2085
+ class: "sm-tray-grid",
2086
+ style: {
2087
+ gridTemplateRows: "repeat(" + tray.rows + ", " + STORY_MAP_LAYOUT.CARD_HEIGHT_PX + "px)",
2088
+ gridAutoColumns: STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX + "px"
2089
+ }
2090
+ });
2091
+ for (const t of tray.items) {
2092
+ // SM-276: epics drag as EPIC (drop on a cell → schedule via onEpicDrop),
2093
+ // everything else as STORY (drop on a cell/epic → onCellStoryDrop /
2094
+ // onStoryDrop). Clearing any leftover drag projection on end.
2095
+ cardGrid.appendChild(rendererCard.renderTicketCard(t, {
2096
+ onTicketClick: opts && opts.onTicketClick,
2097
+ projectId: snapshot && snapshot.project && snapshot.project.id,
2098
+ width: STORY_MAP_LAYOUT.STORY_CARD_WIDTH_PX,
2099
+ dragType: t.type === "epic" ? DRAG_TYPES.EPIC : DRAG_TYPES.STORY,
2100
+ dragId: t.id,
2101
+ dragOnEnd: () => { if (_dragProjection) setDragProjection(null); },
2102
+ linkInfo: _linkIndex && _linkIndex.get(t.id) || null,
2103
+ lastExecution: _lastExecIndex && _lastExecIndex.get(t.id) || null
2104
+ }));
2105
+ }
2106
+ // No inline max-height: the row count (set by sizeTrayBody after mount)
2107
+ // drives the body height, so the strip fills the available vertical space
2108
+ // without overflowing the viewport. grid-template-rows starts at the
2109
+ // fallback (tray.rows) and is updated to the viewport-reactive count.
2110
+ const body = el("div", { class: "sm-tray-body" }, [ cardGrid ]);
2111
+ root.appendChild(body);
2112
+ }
2113
+ return root;
2114
+ }
2115
+
2116
+ function renderInto(host, store, opts) {
2117
+ const snap = store.get();
2118
+ currentSnapshot = snap;
2119
+ _linkIndex = rendererCard.computeLinkIndex(snap); // SM-49
2120
+ _lastExecIndex = rendererCard.computeLastExecutionIndex(snap); // SM-60
2121
+ _renderCounter += 1; // SM-20 instrumentation: unique render id
2122
+ _gridBuildCount += 1; // SM-223 instrumentation: full grid builds
2123
+ // SM-221: base layout from the drag-cache (computed once per snapshot);
2124
+ // the projection helpers run on top per pointermove without recomputing it.
2125
+ // We canonicalise the key here (falsy filter → null; non-function matchTicket
2126
+ // → null, mirroring computeStoryMapLayout's own coercion) so a drag's
2127
+ // rerenders always hit the same cache entry.
2128
+ const baseLayout = computeBaseLayout(snap, opts.filter || null,
2129
+ (typeof opts.matchTicket === "function") ? opts.matchTicket : null);
2130
+ const layout = applyProcessStepProjection(applyEpicCellProjection(baseLayout, snap));
2131
+
2132
+ // Belt + Suspenders: scrub stale drag-artifacts from an incomplete
2133
+ // previous drag (orphan ghost, stuck sm-dragging class).
2134
+ dnd.scrubArtifacts();
2135
+ // Wipe drop-target registry ONLY on the first render (no existing grid
2136
+ // in the host yet). Subsequent renders go through the Phase-C morph
2137
+ // path, which reuses existing DOM nodes — and with them their original
2138
+ // dnd registrations. Wiping the registry mid-flight would orphan the
2139
+ // surviving cells/cards and break drag-and-drop until the next remount.
2140
+ // Trade-off: the freshly-built (and then discarded by morph) NEW nodes
2141
+ // still call enableDropTarget during the build, leaving a small number
2142
+ // of dangling registry entries per render. Acceptable for now; address
2143
+ // by switching dnd._targets to a WeakMap if it grows into a real issue.
2144
+ const hasExistingGrid = !!host.querySelector(":scope > .sm-grid");
2145
+ if (!hasExistingGrid) dnd.clearAll();
2146
+
2147
+ const grid = tagBirth(el("div", { class: "sm-grid" }));
2148
+ grid.style.setProperty("--sm-release-label-w", STORY_MAP_LAYOUT.RELEASE_LABEL_WIDTH_PX + "px");
2149
+ grid.style.setProperty("--sm-backbone-col-w", STORY_MAP_LAYOUT.BACKBONE_COL_WIDTH_PX + "px");
2150
+ // SM-223 (a) gate: only virtualize cells (content-visibility) on very large
2151
+ // boards, where the layout/paint win outweighs the WebKit/Gecko row-height
2152
+ // jump. Normal boards render every cell eagerly → pixel-correct heights.
2153
+ if ((snap.tickets || []).length > STORY_MAP_LAYOUT.VIRTUALIZE_TICKET_THRESHOLD) {
2154
+ grid.classList.add("sm-grid-virtualized");
2155
+ }
2156
+
2157
+ grid.appendChild(renderBackbone(layout.columns, layout.columnMultipliers, opts));
2158
+ // SM-83: opts.collapsedReleases is a Set<releaseId> of releases whose
2159
+ // cells-row should be collapsed. The cells-row stays in the DOM (only
2160
+ // visually hidden via .sm-release-collapsed-cells) so the toggle can
2161
+ // animate max-height. Label row is always rendered.
2162
+ const collapsed = (opts && opts.collapsedReleases) || null;
2163
+ layout.rows.forEach((row, idx) => {
2164
+ const isCollapsed = !!(collapsed && collapsed.has && collapsed.has(row.id));
2165
+ // SM-192: the label is a HEADING — render it ABOVE its cells so all the
2166
+ // release's tickets appear below the release bar.
2167
+ // SM-252: newest-on-top → the '+ Add Release' button lives on the TOP row
2168
+ // (a new release appends with the highest sortOrder and lands on top).
2169
+ const showAddRelease = idx === 0;
2170
+ const releaseData = layout.releases[row.id];
2171
+ grid.appendChild(renderReleaseLabelRow(row, showAddRelease, opts, isCollapsed));
2172
+ grid.appendChild(renderReleaseCellsRow(row, releaseData, layout.columns, layout.columnMultipliers, opts, isCollapsed));
2173
+ // SM-253: per-release holding zone for release-assigned-but-unplaced
2174
+ // tickets — only when it has any (no clutter), and not while collapsed.
2175
+ const unplaced = releaseData && releaseData.unplaced;
2176
+ if (!isCollapsed && unplaced && (unplaced.epics.length > 0 || unplaced.stories.length > 0)) {
2177
+ grid.appendChild(renderReleaseUnplacedRow(row, unplaced, opts));
2178
+ }
2179
+ });
2180
+ // Phantom-rows when at least one ProcessStep exists but no Release yet —
2181
+ // without them the user sees only the backbone header floating with
2182
+ // nothing under it.
2183
+ if (layout.rows.length === 0 && layout.columns.length > 0) {
2184
+ for (const node of renderPhantomReleaseRows(layout.columns, layout.columnMultipliers, opts)) {
2185
+ grid.appendChild(node);
2186
+ }
2187
+ }
2188
+ // SM-253: the global Backlog section is gone — release-assigned-unplaced
2189
+ // tickets live in per-release holding strips (rendered in the rows loop);
2190
+ // release-less tickets are visible only in the Kanban.
2191
+
2192
+ // SM-274: Map-Eingang (Bodenstreifen) — sticky bottom strip for release-less
2193
+ // tickets. It is the LAST child of .sm-grid: the grid is width:max-content
2194
+ // (the full map scroll width), so a plain block child gets width:auto = that
2195
+ // width for free — the strip's background spans the whole map with pure CSS,
2196
+ // no measured width. Its own body is overflow-x:auto (a scroll container),
2197
+ // so the cards do NOT feed back into the grid's max-content width. Morph
2198
+ // reconciles it by its data-section key like any other section.
2199
+ const trayNode = renderUnassignedTray(snap, opts);
2200
+ if (trayNode) grid.appendChild(trayNode);
2201
+
2202
+ // SM-20 Phase C — morph the existing .sm-grid in place instead of wiping
2203
+ // the host. Preserves DOM identity for every keyed node that survived
2204
+ // the diff (cells, ps-cols, release-rows, cards, …) so the user no
2205
+ // longer sees a structural refresh-flash for ticket_create / release_
2206
+ // create / label-rename etc. The first render (no existing grid yet)
2207
+ // takes the appendChild path so the initial mount is unchanged.
2208
+ const oldGrid = host.querySelector(":scope > .sm-grid");
2209
+ if (oldGrid) {
2210
+ morphTree(oldGrid, grid);
2211
+ } else {
2212
+ host.innerHTML = "";
2213
+ host.appendChild(grid);
2214
+ }
2215
+ // SM-275: viewport-reactive row count — measure the LIVE tray (after morph,
2216
+ // the surviving node is the one in the DOM, not `trayNode`).
2217
+ const liveTray = host.querySelector(".sm-tray");
2218
+ if (liveTray) sizeTrayBody(host, liveTray);
2219
+ instrumentationReport(host, _renderCounter);
2220
+ }
2221
+
2222
+ // SM-239: in-place refresh of the per-release X/Y badges after the Phase-A/B
2223
+ // fast-paths, which keep the release rows' DOM identity. A child status change
2224
+ // shifts the count without moving a card, so the badge must be repainted
2225
+ // explicitly. The full-rerender path builds them fresh and doesn't need this.
2226
+ function refreshReleaseProgressBadges(host, snap) {
2227
+ host.querySelectorAll(".sm-release-label-row[data-release-id]").forEach((row) => {
2228
+ const relId = row.dataset.releaseId;
2229
+ if (relId) rendererCard.updateReleaseProgressBadge(row, core.releaseProgress(snap, relId));
2230
+ });
2231
+ }
2232
+
2233
+ // ---- Mount API -------------------------------------------------------
2234
+
2235
+ function mount(host, store, opts) {
2236
+ opts = opts || {};
2237
+ const actor = opts.actor || ACTOR_LOCAL;
2238
+ // Persistenz-Strategie: wenn opts.persistMove gegeben ist (main.js
2239
+ // setzt das im HTTP-Modus), nutzen wir das für ALLE Drop-Operationen.
2240
+ // Sonst gehen wir den lokalen Store-Pfad (für Tests + lokale Adapter).
2241
+ function moveVia(ticketId, position) {
2242
+ if (typeof opts.persistMove === "function") {
2243
+ return opts.persistMove(ticketId, position);
2244
+ }
2245
+ store.moveTicket(ticketId, position, actor);
2246
+ }
2247
+ /**
2248
+ * Ticket-Reorder — mirrors movesVia for processSteps. Takes orderedIds
2249
+ * + scope (the target container's releaseId/processStepId/epicId) and
2250
+ * dispatches to opts.persistReorderTickets (HTTP) or the local store
2251
+ * fallback. Mirrors the exact pattern of onProcessStepReorder.
2252
+ */
2253
+ function reorderVia(orderedIds, scope) {
2254
+ if (typeof opts.persistReorderTickets === "function") {
2255
+ return opts.persistReorderTickets(orderedIds, scope);
2256
+ }
2257
+ store.reorderTickets(orderedIds, scope || null, actor);
2258
+ }
2259
+ function getPos(id) {
2260
+ const t = store.get().tickets.find(t => t.id === id);
2261
+ return (t && t.position) || {};
2262
+ }
2263
+
2264
+ /**
2265
+ * Build the new orderedIds list for a reorder operation: peers (sorted
2266
+ * ascending by sortOrder, dragged ticket already removed) with the
2267
+ * dragged ticket inserted at insertionIndex.
2268
+ */
2269
+ function buildOrderedIds(peers, draggedId, insertionIndex) {
2270
+ const idx = Math.max(0, Math.min(peers.length, insertionIndex != null ? insertionIndex : peers.length));
2271
+ const ids = peers.map(t => t.id);
2272
+ ids.splice(idx, 0, draggedId);
2273
+ return ids;
2274
+ }
2275
+ const wrappedOpts = {
2276
+ // SM-82: forward filter spec to renderInto → computeStoryMapLayout.
2277
+ filter: opts.filter || null,
2278
+ // SM-191: forward the smart-bar query predicate too.
2279
+ matchTicket: opts.matchTicket || null,
2280
+ // SM-83: forward collapsed-releases set + toggle callback.
2281
+ collapsedReleases: opts.collapsedReleases || null,
2282
+ onToggleReleaseCollapsed: opts.onToggleReleaseCollapsed,
2283
+ // SM-84: same pattern for per-epic collapse.
2284
+ collapsedEpics: opts.collapsedEpics || null,
2285
+ onToggleEpicCollapsed: opts.onToggleEpicCollapsed,
2286
+ // SM-272: per-project process-step COLUMN collapse.
2287
+ collapsedColumns: opts.collapsedColumns || null,
2288
+ onToggleColumnCollapsed: opts.onToggleColumnCollapsed,
2289
+ onTicketClick: opts.onTicketClick,
2290
+ onAddTicket: opts.onAddTicket,
2291
+ onAddRelease: opts.onAddRelease,
2292
+ onAddProcessStep: opts.onAddProcessStep,
2293
+ onAddProcessStepWithTicket: opts.onAddProcessStepWithTicket,
2294
+ onReleaseClick: opts.onReleaseClick,
2295
+ onProcessStepClick: opts.onProcessStepClick,
2296
+ onProcessStepReorder: opts.onProcessStepReorder,
2297
+ onEpicDrop: (epicId, psId, releaseId, insertionIndex) => {
2298
+ const snap = store.get();
2299
+ const dragged = snap.tickets.find(t => t.id === epicId);
2300
+ if (!dragged) return;
2301
+ const cur = dragged.position || {};
2302
+ const tgtRel = releaseId !== undefined ? (releaseId || null) : (cur.releaseId || null);
2303
+ const tgtPs = psId || null;
2304
+ const peers = (tgtRel && tgtPs)
2305
+ ? core.tickets.epicsInCell(snap, tgtRel, tgtPs).filter(t => t.id !== epicId)
2306
+ : [];
2307
+ const orderedIds = buildOrderedIds(peers, epicId, insertionIndex);
2308
+ reorderVia(orderedIds, { releaseId: tgtRel, processStepId: tgtPs, epicId: null });
2309
+ },
2310
+ onStoryDrop: (storyId, targetEpicId, insertionIndex) => {
2311
+ const snap = store.get();
2312
+ const dragged = snap.tickets.find(t => t.id === storyId);
2313
+ if (!dragged) return;
2314
+ const cur = dragged.position || {};
2315
+ let releaseId = cur.releaseId || null;
2316
+ let processStepId = cur.processStepId || null;
2317
+ if (targetEpicId) {
2318
+ const ep = snap.tickets.find(t => t.id === targetEpicId);
2319
+ if (ep && ep.position) {
2320
+ releaseId = ep.position.releaseId || null;
2321
+ processStepId = ep.position.processStepId || null;
2322
+ }
2323
+ }
2324
+ const peers = targetEpicId
2325
+ ? core.tickets.storiesInEpic(snap, targetEpicId).filter(t => t.id !== storyId)
2326
+ : [];
2327
+ const orderedIds = buildOrderedIds(peers, storyId, insertionIndex);
2328
+ reorderVia(orderedIds, { releaseId, processStepId, epicId: targetEpicId || null });
2329
+ },
2330
+ // SM-253: re-home a dragged card to a release as UNPLACED (release set,
2331
+ // no process-step, no epic) — fired by the release header + holding strip.
2332
+ onReleaseUnplacedDrop: (ticketId, _type, releaseId) => {
2333
+ moveVia(ticketId, { releaseId: releaseId || null, processStepId: null, epicId: null });
2334
+ },
2335
+ onCellStoryDrop: (storyId, releaseId, processStepId, insertionIndex) => {
2336
+ const snap = store.get();
2337
+ const dragged = snap.tickets.find(t => t.id === storyId);
2338
+ if (!dragged) return;
2339
+ const peers = (releaseId && processStepId)
2340
+ ? core.tickets.looseTicketsInCell(snap, releaseId, processStepId).filter(t => t.id !== storyId)
2341
+ : [];
2342
+ const orderedIds = buildOrderedIds(peers, storyId, insertionIndex);
2343
+ reorderVia(orderedIds, { releaseId: releaseId || null, processStepId: processStepId || null, epicId: null });
2344
+ },
2345
+ // SM-276: drop a scheduled card onto the Map-Eingang → clear its release
2346
+ // (unassign back to the inbox). Mirrors applyBacklogDrop; sortOrder is
2347
+ // preserved so the card keeps its slot among the release-less tickets.
2348
+ onTrayDrop: (ticketId) => {
2349
+ const cur = store.get().tickets.find(t => t.id === ticketId);
2350
+ if (!cur) return;
2351
+ moveVia(ticketId, {
2352
+ releaseId: null,
2353
+ processStepId: null,
2354
+ epicId: null,
2355
+ sortOrder: (cur.position && cur.position.sortOrder) || 0
2356
+ });
2357
+ }
2358
+ };
2359
+ function rerender() { renderInto(host, store, wrappedOpts); }
2360
+ _rerender = rerender;
2361
+ // SM-223 (d): context for the drag-incremental path — the SAME host +
2362
+ // wrappedOpts the full render uses, so subtree rebuilds are identical.
2363
+ // store is the staleness authority (see tryIncrementalDragUpdate).
2364
+ _dragRenderCtx = { host: host, opts: wrappedOpts, store: store };
2365
+ rerender();
2366
+ // SM-275: keep the Map-Eingang's row count in sync with the viewport. A
2367
+ // debounced window-resize re-measures and re-applies the row template to
2368
+ // the mounted tray (cheap — no full re-render, no DOM re-chunk). Guarded
2369
+ // for non-browser environments (JSDOM tests have no global `window`).
2370
+ let _resizeTimer = null;
2371
+ function onResize() {
2372
+ if (_resizeTimer) clearTimeout(_resizeTimer);
2373
+ _resizeTimer = setTimeout(function () {
2374
+ const tray = host.querySelector(":scope > .sm-tray");
2375
+ if (tray) sizeTrayBody(host, tray);
2376
+ }, STORY_MAP_LAYOUT.TRAY_RESIZE_DEBOUNCE_MS);
2377
+ }
2378
+ const _win = (typeof window !== "undefined") ? window : null;
2379
+ if (_win) _win.addEventListener("resize", onResize);
2380
+ // E24 — animate cards on external commits (MCP / WS pushes). Local
2381
+ // commits keep the instant rerender, since they already get direct
2382
+ // user feedback (drag-ghost, modal save, ...).
2383
+ let prevSnap = store.get();
2384
+ // E24.E — Story-Map animates THREE entity types: tickets (both story
2385
+ // and epic cards share `[data-ticket-id]`), process-step columns,
2386
+ // and release rows. The renderer registers the data-* attrs on the
2387
+ // respective DOM nodes; the helper does the FLIP + pulse per type.
2388
+ // SM-20-Followup: each entity-type's selector must match EXACTLY ONE node
2389
+ // per id, otherwise the FLIP helper's Map.set-by-id overwrites and every
2390
+ // matched node gets compared to a wrong prev-rect. data-process-step-id
2391
+ // sits on both .sm-backbone-col AND every .sm-cell — scope to the col.
2392
+ // data-release-id sits on .sm-release-row.sm-release-cells AND on
2393
+ // .sm-release-label-row AND on .sm-release-label — scope to the cells-row.
2394
+ const STORYMAP_ENTITY_TYPES = [
2395
+ { listKey: "tickets", selector: "[data-ticket-id]", idAttr: "ticketId" },
2396
+ { listKey: "processSteps", selector: ".sm-backbone-col[data-process-step-id]", idAttr: "processStepId" },
2397
+ { listKey: "releases", selector: ".sm-release-row[data-release-id]", idAttr: "releaseId" }
2398
+ ];
2399
+ const unsub = store.subscribe(function (snap, reason) {
2400
+ if (reason === "applyRemote" && cardAnimate) {
2401
+ // SM-20 Phase A — try the position-only fast path first. When an
2402
+ // applyRemote is a pure sort-order reshuffle inside one or more
2403
+ // existing containers, we move cards directly in the DOM (no
2404
+ // host.innerHTML wipe, no structural rebuild) and let FLIP animate
2405
+ // their travel. The shells (cells, backbone, release rows) keep
2406
+ // their DOM identity, so the screen no longer flashes underneath
2407
+ // the FLIP. Any non-trivial structural change → null → fallback.
2408
+ const groups = diffSortOnlyByContainer(prevSnap, snap);
2409
+ let handled = false;
2410
+ if (groups) {
2411
+ if (groups.size === 0) {
2412
+ // No effective change (a remote echo with identical positions).
2413
+ handled = true;
2414
+ } else {
2415
+ const ticketSelector = "[data-ticket-id]";
2416
+ const prevRects = cardAnimate.captureRectsBySelector(host, ticketSelector, "ticketId");
2417
+ if (applySortOnlyReshuffle(host, groups)) {
2418
+ cardAnimate.flipFromCapturedSelector(host, prevRects, ticketSelector, "ticketId");
2419
+ const touchedIds = new Set();
2420
+ for (const [, ids] of groups) for (const id of ids) touchedIds.add(id);
2421
+ cardAnimate.pulseEntities(host, touchedIds, '[data-ticket-id="', '"]');
2422
+ handled = true;
2423
+ }
2424
+ // applySortOnlyReshuffle returned false → a card or container
2425
+ // wasn't in the DOM where we expected it; fall through to the
2426
+ // full-rerender path so we never silently lose a change.
2427
+ }
2428
+ }
2429
+ // SM-20 Phase B — content-only patch fast-path. Hit by `ticket_update`,
2430
+ // `check_dod_item`, `change_ticket_status`, etc.: a ticket's content
2431
+ // changed but its container + position are unchanged. We patch the
2432
+ // existing card DOM in place — no host.innerHTML wipe.
2433
+ if (!handled) {
2434
+ const changed = diffCardContentOnly(prevSnap, snap);
2435
+ if (changed) {
2436
+ if (changed.size === 0) {
2437
+ handled = true;
2438
+ } else if (applyCardContentPatch(host, changed, snap, wrappedOpts)) {
2439
+ cardAnimate.pulseEntities(host, changed, '[data-ticket-id="', '"]');
2440
+ handled = true;
2441
+ }
2442
+ }
2443
+ }
2444
+ if (!handled) {
2445
+ cardAnimate.animateExternalCommitMulti({
2446
+ host: host,
2447
+ prevSnap: prevSnap,
2448
+ nextSnap: snap,
2449
+ rerender: rerender,
2450
+ entityTypes: STORYMAP_ENTITY_TYPES
2451
+ });
2452
+ } else {
2453
+ // SM-239: the Phase-A/B fast-paths don't rebuild the release rows, so
2454
+ // refresh the X/Y progress badges in place (a child status change can
2455
+ // shift a release's done-count without moving any card).
2456
+ refreshReleaseProgressBadges(host, snap);
2457
+ }
2458
+ } else {
2459
+ rerender();
2460
+ }
2461
+ prevSnap = snap;
2462
+ });
2463
+ // Controller is a callable function (unsubscribes when invoked) with
2464
+ // attached methods so existing callers like `app.unmountStoryMap()`
2465
+ // keep working while new code can do `ctrl.setDragProjection(...)`.
2466
+ const ctrl = function () {
2467
+ unsub();
2468
+ // SM-275: drop the resize listener so a torn-down host isn't measured.
2469
+ if (_win) _win.removeEventListener("resize", onResize);
2470
+ if (_resizeTimer) clearTimeout(_resizeTimer);
2471
+ // SM-153: reset drag state on unmount so the next mount can't render a
2472
+ // phantom shadow-card left over from an aborted drag (Kanban does the
2473
+ // same). Set the vars directly — clearDragProjection() would trigger a
2474
+ // rerender against a host we're tearing down.
2475
+ _dragProjection = null;
2476
+ _rerender = null;
2477
+ _baseLayoutCache = null; // SM-221: don't leak a stale layout into the next mount
2478
+ _dragRenderCtx = null; // SM-223: don't morph into a torn-down host
2479
+ };
2480
+ ctrl.unsubscribe = unsub;
2481
+ ctrl.setDragProjection = setDragProjection;
2482
+ ctrl.clearDragProjection = clearDragProjection;
2483
+ ctrl._wrappedOpts = wrappedOpts; // test-only handle
2484
+ return ctrl;
2485
+ }
2486
+
2487
+ return {
2488
+ STORY_MAP_LAYOUT,
2489
+ DRAG_TYPES,
2490
+ DRAG_FORMATS,
2491
+ computeStoryMapLayout,
2492
+ computeUnassignedTrayLayout, // SM-274 — exposed for tests
2493
+ computeTrayRows, // SM-275 — exposed for tests
2494
+ applyEpicDrop,
2495
+ applyStoryDrop,
2496
+ applyBacklogDrop,
2497
+ applyReleaseReorder,
2498
+ applyProcessStepReorder,
2499
+ // SM-20 Phase A — exposed for tests
2500
+ containerKeyOf,
2501
+ diffSortOnlyByContainer,
2502
+ findContainerForKey,
2503
+ applySortOnlyReshuffle,
2504
+ // SM-20 Phase B — exposed for tests
2505
+ diffCardContentOnly,
2506
+ applyCardContentPatch,
2507
+ // SM-20 Phase C — exposed for tests
2508
+ morphTree,
2509
+ keyOf,
2510
+ // SM-97 — exposed for tests: drive applyEpicCellProjection in isolation.
2511
+ applyEpicCellProjection,
2512
+ // SM-193 — exposed for tests: column-aware backlog insertion index.
2513
+ columnAwareInsertionIndex,
2514
+ // SM-245 — exposed for tests: pure process-step reorder from snapshot + beforeId.
2515
+ reorderedProcessStepIds,
2516
+ _setDragProjectionForTests: function (p) { _dragProjection = p; },
2517
+ // SM-221 — exposed for tests: count + reset base-layout computations, and
2518
+ // inspect/clear the drag-layout cache.
2519
+ _getLayoutComputeCount: function () { return _layoutComputeCount; },
2520
+ _resetLayoutComputeCount: function () { _layoutComputeCount = 0; },
2521
+ _peekBaseLayoutCache: function () { return _baseLayoutCache; },
2522
+ // SM-223 — exposed for tests: count full grid builds (the drag-incremental
2523
+ // path keeps this at 0 per mid-drag move) and force a full render to prove
2524
+ // DOM identity between the incremental and the full path.
2525
+ _getGridBuildCount: function () { return _gridBuildCount; },
2526
+ _resetGridBuildCount: function () { _gridBuildCount = 0; },
2527
+ _rerenderForTests: function () { if (typeof _rerender === "function") _rerender(); },
2528
+ mount
2529
+ };
2530
+ }));