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,639 @@
1
+ /**
2
+ * Geteilte Card-Komponente (E11.1 — DRY-Refactor).
3
+ *
4
+ * Story-Map UND Kanban rendern dasselbe Ticket-Card-Element via diese
5
+ * Funktion. Konsistentes Aussehen + Verhalten, keine Verdopplung von
6
+ * DOM-Struktur, CSS-Klassen oder DnD-Verkabelung. CSS-Klassen-Namespace
7
+ * bleibt `.sm-story-card` (das war zuerst da, hat schon `user-select:none`
8
+ * und ist überall durchgestylt).
9
+ *
10
+ * API:
11
+ * renderTicketCard(ticket, opts) → HTMLElement
12
+ *
13
+ * opts.onTicketClick(id) — Doppelklick öffnet Detail-Modal
14
+ * opts.dragType?: string — wenn gesetzt, registriert dnd.enableDraggable
15
+ * opts.dragId?: string — überschreibt ticket.id (selten nötig)
16
+ * opts.dragOnEnd?: () => void — z.B. um Drag-Projection des Aufrufers zu clearen
17
+ * opts.isShadow?: boolean — Shadow-Variante (gestrichelte Border, kein DnD)
18
+ * opts.width?: number — explizite Breite (Story-Map setzt das,
19
+ * Kanban lässt's auf flexibel)
20
+ *
21
+ * Click stoppt propagation, damit ein Click auf einer Karte nicht an
22
+ * darüberliegende Drop-Targets durchschlägt.
23
+ */
24
+ (function (root, factory) {
25
+ if (typeof module === "object" && module.exports) module.exports = factory(require("./dnd.js"));
26
+ else (root.STORYMAP = root.STORYMAP || {}).rendererCard = factory(root.STORYMAP && root.STORYMAP.dnd);
27
+ }(typeof self !== "undefined" ? self : this, function (dnd) {
28
+ "use strict";
29
+ if (!dnd) throw new Error("renderer-card: dnd module missing");
30
+
31
+ // The canonical story-card width (px). SINGLE source of truth for every view
32
+ // that renders the shared .sm-story-card — the story map AND the dependency
33
+ // graph reference this, so widening the card can't drift between views. The
34
+ // story-map cell/backbone math and the deps node pitch derive from it; the
35
+ // card HEIGHT lives separately in the CSS var --sm-card-h.
36
+ const CARD_WIDTH_PX = 280;
37
+
38
+ // Type → Cluster mapping (cmapper-Palette, von Story-Map übernommen).
39
+ const TYPE_TO_CLUSTER = {
40
+ "epic": "plum",
41
+ "user-story": "green",
42
+ "technical-task": "blue",
43
+ "technical-task-backend": "blue",
44
+ "technical-task-ui": "teal",
45
+ "bug": "rust",
46
+ "spike": "amber",
47
+ "_default": "slate"
48
+ };
49
+ function clusterForType(t) { return TYPE_TO_CLUSTER[t] || TYPE_TO_CLUSTER._default; }
50
+
51
+ // ---- SM-170: Card-Aging ---------------------------------------------
52
+ // How long a card has sat in its current status. The badge only appears
53
+ // once a card crosses WARN (it's a flag for liegenbleiber, not a clock on
54
+ // every card), and the caller decides WHICH cards age (the Kanban only
55
+ // flags `doing`/`blocked` columns — see renderer-kanban). Thresholds in ms.
56
+ const CARD_AGE = {
57
+ WARN_MS: 3 * 24 * 60 * 60 * 1000, // 3 days → amber
58
+ STALE_MS: 7 * 24 * 60 * 60 * 1000 // 7 days → red
59
+ };
60
+ function humanizeAge(ms) {
61
+ const hours = ms / (60 * 60 * 1000);
62
+ if (hours < 24) return Math.max(1, Math.round(hours)) + "h";
63
+ const days = hours / 24;
64
+ if (days < 7) return Math.round(days) + "d";
65
+ return Math.round(days / 7) + "w";
66
+ }
67
+ // Returns the age-badge element, or null when the card is still fresh.
68
+ function buildAgeBadge(ticket, ageRef) {
69
+ const entered = typeof ticket.statusEnteredAt === "number"
70
+ ? ticket.statusEnteredAt
71
+ : (typeof ticket.createdAt === "number" ? ticket.createdAt : null);
72
+ if (entered == null) return null;
73
+ const age = Math.max(0, ageRef - entered);
74
+ if (age < CARD_AGE.WARN_MS) return null;
75
+ const tier = age >= CARD_AGE.STALE_MS ? "stale" : "warn";
76
+ return el("span", {
77
+ class: "sm-badge sm-card-age sm-card-age-" + tier,
78
+ title: "In this status for " + humanizeAge(age),
79
+ text: humanizeAge(age)
80
+ });
81
+ }
82
+
83
+ function el(tag, props, children) {
84
+ const e = document.createElement(tag);
85
+ if (props) {
86
+ for (const k of Object.keys(props)) {
87
+ if (k === "class") e.className = props[k];
88
+ else if (k === "dataset") for (const d of Object.keys(props[k])) e.dataset[d] = props[k][d];
89
+ else if (k === "style") Object.assign(e.style, props[k]);
90
+ else if (k === "text") e.textContent = props[k];
91
+ else if (k === "html") e.innerHTML = props[k];
92
+ else if (k.startsWith("on") && typeof props[k] === "function") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
93
+ else e.setAttribute(k, props[k]);
94
+ }
95
+ }
96
+ if (children) for (const c of children) if (c != null) e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
97
+ return e;
98
+ }
99
+
100
+ function checklistBadge(items) {
101
+ if (!items || items.length === 0) return "";
102
+ const done = items.filter(i => i.checked).length;
103
+ return done + "/" + items.length;
104
+ }
105
+
106
+ // ---- SM-49: link-badges (per-card dependency indicators) -----------
107
+ //
108
+ // computeLinkIndex(snapshot) → Map<ticketId, {
109
+ // forward, backward,
110
+ // forwardSemantics:Set, backwardSemantics:Set,
111
+ // forwardTargetIds:[], backwardSourceIds:[],
112
+ // forwardTargetKeys:[], backwardSourceKeys:[] }>
113
+ //
114
+ // O(N + sum(links)). Renderers call this once per mount-cycle, then pass
115
+ // the per-ticket entry via opts.linkInfo into renderTicketCard — keeps
116
+ // the card-level lookup O(1) and avoids quadratic re-scan-per-card.
117
+ function _ensureLinkEntry(idx, id) {
118
+ let v = idx.get(id);
119
+ if (!v) {
120
+ v = {
121
+ forward: 0, backward: 0,
122
+ forwardSemantics: new Set(), backwardSemantics: new Set(),
123
+ forwardTargetIds: [], backwardSourceIds: [],
124
+ forwardTargetKeys: [], backwardSourceKeys: []
125
+ };
126
+ idx.set(id, v);
127
+ }
128
+ return v;
129
+ }
130
+ function computeLinkIndex(snapshot) {
131
+ const idx = new Map();
132
+ const tickets = (snapshot && snapshot.tickets) || [];
133
+ const project = (snapshot && snapshot.project) || {};
134
+ const linkTypeById = new Map();
135
+ for (const lt of (project.linkTypes || [])) linkTypeById.set(lt.id, lt);
136
+ const ticketById = new Map();
137
+ for (const t of tickets) ticketById.set(t.id, t);
138
+ for (const t of tickets) {
139
+ if (!t.links || !t.links.length) continue;
140
+ for (const l of t.links) {
141
+ const lt = linkTypeById.get(l.linkTypeId);
142
+ const sem = (lt && lt.semantic) || "freeform";
143
+ const src = _ensureLinkEntry(idx, t.id);
144
+ src.forward++;
145
+ src.forwardSemantics.add(sem);
146
+ src.forwardTargetIds.push(l.targetTicketId);
147
+ const tgtTicket = ticketById.get(l.targetTicketId);
148
+ src.forwardTargetKeys.push(tgtTicket ? (tgtTicket.ticketKey || tgtTicket.id) : l.targetTicketId);
149
+ const tgt = _ensureLinkEntry(idx, l.targetTicketId);
150
+ tgt.backward++;
151
+ tgt.backwardSemantics.add(sem);
152
+ tgt.backwardSourceIds.push(t.id);
153
+ tgt.backwardSourceKeys.push(t.ticketKey || t.id);
154
+ }
155
+ }
156
+ return idx;
157
+ }
158
+
159
+ // Pick a single semantic for the badge color. If multiple semantics are
160
+ // mixed under the same direction, fall back to "mixed".
161
+ function dominantSemantic(semSet) {
162
+ if (!semSet || semSet.size === 0) return "freeform";
163
+ if (semSet.size === 1) return semSet.values().next().value;
164
+ return "mixed";
165
+ }
166
+
167
+ function buildLinkBadge(direction, count, semantic, keysList, hostRef) {
168
+ const arrow = direction === "backward" ? "←" : "→";
169
+ const badge = el("span", {
170
+ class: "sm-badge sm-link-badge sm-link-badge-" + direction + " sm-link-badge-sem-" + semantic,
171
+ dataset: { linkDirection: direction },
172
+ title: (direction === "backward" ? "Referenced by: " : "Links to: ") + keysList.join(", ")
173
+ });
174
+ badge.textContent = arrow + " " + count;
175
+ // Click pulses every card in the same host whose ticket-id is in the
176
+ // related-set. Host lookup walks up: card → host (sm-grid or km-board).
177
+ badge.addEventListener("click", (ev) => {
178
+ ev.stopPropagation();
179
+ const card = badge.closest(".sm-story-card");
180
+ const host = card && (card.closest(".sm-grid") || card.closest(".km-board") || card.parentNode);
181
+ if (!host) return;
182
+ const ids = direction === "backward" ? hostRef.backwardSourceIds : hostRef.forwardTargetIds;
183
+ for (const tid of ids) {
184
+ const targets = host.querySelectorAll('.sm-story-card[data-ticket-id="' + tid + '"]');
185
+ for (const t of targets) {
186
+ t.classList.remove("sm-card-flash");
187
+ // eslint-disable-next-line no-unused-expressions
188
+ void t.offsetWidth;
189
+ t.classList.add("sm-card-flash");
190
+ setTimeout(((node) => () => node.classList.remove("sm-card-flash"))(t), 900);
191
+ }
192
+ }
193
+ });
194
+ return badge;
195
+ }
196
+
197
+ // ---- SM-60: last-execution-outcome badge -----------------------------
198
+ //
199
+ // For type='test-definition' cards we surface the latest execution-run's
200
+ // outcome as a colour-coded pill in the card-meta row. Computation is
201
+ // index-based so renderers can build it once per mount and pass the
202
+ // per-card entry, the same way linkInfo flows in SM-49.
203
+ //
204
+ // computeLastExecutionIndex(snapshot) → Map<defId, {
205
+ // executionTicket, outcome, runAt, runByName, env
206
+ // }> | null
207
+ //
208
+ // Pure: doesn't touch the DOM. The renderers call this once per
209
+ // mount-cycle, then pipe the per-definition entry via opts.lastExecution
210
+ // into renderTicketCard.
211
+
212
+ /**
213
+ * SM-60: pick the most recent test-execution per test-definition.
214
+ * "Most recent" = highest `runAt`; ties broken by lexicographic ticketKey
215
+ * (deterministic). Soft-deleted executions are ignored.
216
+ */
217
+ function computeLastExecutionIndex(snapshot) {
218
+ const map = new Map();
219
+ if (!snapshot || !Array.isArray(snapshot.tickets)) return map;
220
+ const TICKETS = snapshot.tickets;
221
+ // First, group all 'executes' links source-by-target.
222
+ const byDef = new Map(); // defId → [executionTicket, ...]
223
+ for (const t of TICKETS) {
224
+ if (t.isDeleted || t.type !== "test-execution") continue;
225
+ if (!Array.isArray(t.links)) continue;
226
+ for (const l of t.links) {
227
+ const lt = l.linkTypeId || l.type;
228
+ if (lt !== "executes") continue;
229
+ const defId = l.targetTicketId;
230
+ if (!defId) continue;
231
+ const arr = byDef.get(defId) || [];
232
+ arr.push(t);
233
+ byDef.set(defId, arr);
234
+ }
235
+ }
236
+ for (const [defId, executions] of byDef) {
237
+ executions.sort((a, b) => {
238
+ const ra = typeof a.runAt === "number" ? a.runAt : 0;
239
+ const rb = typeof b.runAt === "number" ? b.runAt : 0;
240
+ if (rb !== ra) return rb - ra;
241
+ return String(b.ticketKey || "").localeCompare(String(a.ticketKey || ""));
242
+ });
243
+ const top = executions[0];
244
+ const outcome = _outcomeOf(top);
245
+ map.set(defId, {
246
+ executionTicket: top,
247
+ outcome: outcome,
248
+ runAt: top.runAt || null,
249
+ runByName: top.runBy && top.runBy.name ? top.runBy.name : null,
250
+ env: top.env || null
251
+ });
252
+ }
253
+ return map;
254
+ }
255
+
256
+ // Inline version of getEffectiveOutcome — kept local so the card module
257
+ // doesn't pull in core just for this. Mirrors core.getEffectiveOutcome:
258
+ // manual override wins; otherwise derive from execution steps.
259
+ const _OUTCOMES = ["pending", "passed", "failed", "blocked", "skipped"];
260
+ function _outcomeOf(execTicket) {
261
+ if (!execTicket) return "pending";
262
+ if (typeof execTicket.outcomeOverride === "string"
263
+ && _OUTCOMES.indexOf(execTicket.outcomeOverride) >= 0) {
264
+ return execTicket.outcomeOverride;
265
+ }
266
+ const steps = Array.isArray(execTicket.executionSteps) ? execTicket.executionSteps : [];
267
+ if (steps.length === 0) return "pending";
268
+ let allPassed = true;
269
+ let anyBlocked = false;
270
+ for (const s of steps) {
271
+ if (!s || typeof s.status !== "string") { allPassed = false; continue; }
272
+ if (s.status === "failed") return "failed";
273
+ if (s.status === "blocked") anyBlocked = true;
274
+ if (s.status !== "passed") allPassed = false;
275
+ }
276
+ if (anyBlocked) return "blocked";
277
+ return allPassed ? "passed" : "pending";
278
+ }
279
+
280
+ function _fmtRunAt(ms) {
281
+ if (typeof ms !== "number") return null;
282
+ try {
283
+ const d = new Date(ms);
284
+ const pad = (n) => String(n).padStart(2, "0");
285
+ return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
286
+ + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
287
+ } catch (_) { return null; }
288
+ }
289
+
290
+ /**
291
+ * Build the outcome pill for a test-execution card. The execution carries
292
+ * its own outcome (derived from steps or via manual override) — no
293
+ * external lookup needed. Tooltip surfaces run metadata (when, by whom,
294
+ * env) so the user gets context without opening the modal.
295
+ */
296
+ function buildExecutionOutcomeBadge(execTicket) {
297
+ const outcome = _outcomeOf(execTicket);
298
+ const tooltipParts = ["outcome: " + outcome];
299
+ const at = _fmtRunAt(execTicket && execTicket.runAt);
300
+ if (at) tooltipParts.push("ran " + at);
301
+ const runBy = execTicket && execTicket.runBy && execTicket.runBy.name;
302
+ if (runBy) tooltipParts.push("by " + runBy);
303
+ if (execTicket && execTicket.env) tooltipParts.push("env: " + execTicket.env);
304
+ return el("span", {
305
+ class: "sm-badge sm-exec-badge sm-exec-badge-" + outcome,
306
+ title: tooltipParts.join(" · "),
307
+ text: outcome
308
+ });
309
+ }
310
+
311
+ // SM-158: the ticket-KEY is a link to the full-page editor when a projectId
312
+ // is supplied (board → editor for every item, incl. epics). The editor
313
+ // resolves the key → internal id. Clicking it must not start a card drag (dnd
314
+ // ignores <a>) nor open the modal (stopPropagation on click). Without a
315
+ // projectId it falls back to a plain span (e.g. isolated unit tests).
316
+ function buildTicketKeyEl(ticketKey, projectId) {
317
+ if (!projectId) return el("span", { class: "sm-story-key", text: ticketKey });
318
+ return el("a", {
319
+ class: "sm-story-key sm-key-link",
320
+ href: "/editor?projectId=" + encodeURIComponent(projectId) + "&ticketId=" + encodeURIComponent(ticketKey),
321
+ title: "Open " + ticketKey + " in the full-page editor",
322
+ text: ticketKey,
323
+ onclick: function (e) { if (e && e.stopPropagation) e.stopPropagation(); }
324
+ });
325
+ }
326
+
327
+ /**
328
+ * Generic card shell (DRY core — SM-256/SM-248). Builds the shared
329
+ * `.sm-story-card` element used by BOTH the ticket renderer AND the
330
+ * process-step editor: same DOM, same dnd drag wiring, same dblclick
331
+ * contract — only the leading element (key/ordinal), the title, the
332
+ * optional meta row and the cluster colour differ. There is deliberately
333
+ * NO second card component.
334
+ *
335
+ * spec:
336
+ * cluster cluster colour key ("plum"/"teal"/…)
337
+ * statusClass? extra class on the card (tickets pass "sm-status-<status>")
338
+ * dataset? dataset object set on the card element
339
+ * width? explicit px width
340
+ * isShadow? preview-only variant (dashed border, NO listeners)
341
+ * keyEl? optional leading element (ticket-key link OR step ordinal)
342
+ * title the prominent title text
343
+ * meta? array of meta-row children; omitted/empty → no meta row
344
+ * onDblClick? dblclick handler (open detail modal / edit dialog)
345
+ * dragType?, dragId?, dragOnEnd? dnd.enableDraggable wiring
346
+ */
347
+ function renderCard(spec) {
348
+ spec = spec || {};
349
+ const isShadow = !!spec.isShadow;
350
+ const card = el("div", {
351
+ class: "sm-story-card sm-cluster-" + spec.cluster
352
+ + (spec.statusClass ? " " + spec.statusClass : "")
353
+ + (isShadow ? " sm-card-shadow" : ""),
354
+ dataset: spec.dataset || {}
355
+ });
356
+ if (typeof spec.width === "number") card.style.width = spec.width + "px";
357
+
358
+ const info = el("div", { class: "sm-card-info" });
359
+ if (spec.keyEl) info.appendChild(spec.keyEl);
360
+ info.appendChild(el("span", { class: "sm-story-title", text: spec.title || "" }));
361
+ card.appendChild(info);
362
+
363
+ if (spec.meta && spec.meta.length) {
364
+ const meta = el("div", { class: "sm-card-meta" });
365
+ for (const m of spec.meta) if (m) meta.appendChild(m);
366
+ card.appendChild(meta);
367
+ }
368
+
369
+ if (isShadow) return card; // preview-only — no listeners
370
+
371
+ if (typeof spec.onDblClick === "function") {
372
+ card.addEventListener("dblclick", (ev) => { ev.stopPropagation(); spec.onDblClick(); });
373
+ }
374
+ if (spec.dragType) {
375
+ dnd.enableDraggable(card, { dragType: spec.dragType, dragId: spec.dragId, onEnd: spec.dragOnEnd });
376
+ }
377
+ return card;
378
+ }
379
+
380
+ function renderTicketCard(t, opts) {
381
+ opts = opts || {};
382
+ // Meta row, in stable order: status pill, DoR/DoD, link badges, outcome, age.
383
+ const meta = [el("span", { class: "sm-card-status sm-status-pill-" + t.status, text: t.status })];
384
+ const dorBadge = checklistBadge(t.definitionOfReady && t.definitionOfReady.items);
385
+ const dodBadge = checklistBadge(t.definitionOfDone && t.definitionOfDone.items);
386
+ if (dorBadge) meta.push(el("span", { class: "sm-badge sm-badge-dor", title: "Definition of Ready", text: "DoR " + dorBadge }));
387
+ if (dodBadge) meta.push(el("span", { class: "sm-badge sm-badge-dod", title: "Definition of Done", text: "DoD " + dodBadge }));
388
+ // SM-49: dependency badges (forward + backward) if a linkIndex entry is supplied.
389
+ const linkInfo = opts.linkInfo;
390
+ if (linkInfo) {
391
+ if (linkInfo.forward > 0) {
392
+ const sem = dominantSemantic(linkInfo.forwardSemantics);
393
+ meta.push(buildLinkBadge("forward", linkInfo.forward, sem, linkInfo.forwardTargetKeys, linkInfo));
394
+ }
395
+ if (linkInfo.backward > 0) {
396
+ const sem = dominantSemantic(linkInfo.backwardSemantics);
397
+ meta.push(buildLinkBadge("backward", linkInfo.backward, sem, linkInfo.backwardSourceKeys, linkInfo));
398
+ }
399
+ }
400
+ // SM-60: outcome badge belongs on the test-execution card.
401
+ if (t.type === "test-execution") meta.push(buildExecutionOutcomeBadge(t));
402
+ // SM-170: aging badge (only when the caller opts in for this card).
403
+ if (opts.showAge) {
404
+ const ageRef = typeof opts.ageRef === "number" ? opts.ageRef : Date.now();
405
+ const ageBadge = buildAgeBadge(t, ageRef);
406
+ if (ageBadge) meta.push(ageBadge);
407
+ }
408
+ return renderCard({
409
+ cluster: clusterForType(t.type),
410
+ statusClass: "sm-status-" + t.status,
411
+ dataset: { ticketId: t.id, ticketType: t.type, ticketStatus: t.status },
412
+ width: typeof opts.width === "number" ? opts.width : undefined,
413
+ isShadow: !!opts.isShadow,
414
+ keyEl: t.ticketKey ? buildTicketKeyEl(t.ticketKey, opts.projectId) : null,
415
+ title: t.title || "",
416
+ meta: meta,
417
+ onDblClick: typeof opts.onTicketClick === "function" ? function () { opts.onTicketClick(t.id); } : null,
418
+ dragType: opts.dragType,
419
+ dragId: opts.dragId || t.id,
420
+ dragOnEnd: opts.dragOnEnd
421
+ });
422
+ }
423
+
424
+ /**
425
+ * SM-20 Phase B — patch an EXISTING `.sm-story-card` DOM node in place to
426
+ * reflect the latest ticket data, without destroying the node. Used by
427
+ * the renderers' content-only fast-path so applyRemote-driven field edits
428
+ * don't trigger a full host.innerHTML rebuild.
429
+ *
430
+ * Visible card surfaces that may change between renders:
431
+ * - cluster class (.sm-cluster-X) ← `t.type`
432
+ * - status class (.sm-status-Y) ← `t.status`
433
+ * - dataset: ticketType, ticketStatus
434
+ * - title text ← `t.title`
435
+ * - status pill text + class ← `t.status`
436
+ * - DoR badge text / presence ← `t.definitionOfReady.items`
437
+ * - DoD badge text / presence ← `t.definitionOfDone.items`
438
+ *
439
+ * ticketKey, drag-handlers, dblclick-handler, contextmenu-handler — all
440
+ * stable across edits (they were registered when the card was first
441
+ * created). We never touch them here.
442
+ */
443
+ function patchTicketCardInPlace(card, t, linkInfo, lastExecution, opts) {
444
+ if (!card || !t) return;
445
+ opts = opts || {};
446
+ const cluster = clusterForType(t.type);
447
+ const newClass = "sm-story-card sm-cluster-" + cluster + " sm-status-" + t.status
448
+ + (card.classList.contains("sm-card-shadow") ? " sm-card-shadow" : "")
449
+ + (card.classList.contains("sm-dragging") ? " sm-dragging" : "")
450
+ + (card.classList.contains("sm-card-flash") ? " sm-card-flash" : "");
451
+ if (card.className !== newClass) card.className = newClass;
452
+ if (card.dataset.ticketType !== t.type) card.dataset.ticketType = t.type;
453
+ if (card.dataset.ticketStatus !== t.status) card.dataset.ticketStatus = t.status;
454
+
455
+ const titleEl = card.querySelector(".sm-story-title");
456
+ if (titleEl && titleEl.textContent !== (t.title || "")) titleEl.textContent = t.title || "";
457
+
458
+ const statusEl = card.querySelector(".sm-card-status");
459
+ if (statusEl) {
460
+ const wantClass = "sm-card-status sm-status-pill-" + t.status;
461
+ if (statusEl.className !== wantClass) statusEl.className = wantClass;
462
+ if (statusEl.textContent !== t.status) statusEl.textContent = t.status;
463
+ }
464
+
465
+ const meta = card.querySelector(".sm-card-meta");
466
+ if (meta) {
467
+ patchBadge(meta, "sm-badge-dor", "DoR", t.definitionOfReady && t.definitionOfReady.items, "Definition of Ready");
468
+ patchBadge(meta, "sm-badge-dod", "DoD", t.definitionOfDone && t.definitionOfDone.items, "Definition of Done");
469
+ patchLinkBadges(meta, linkInfo);
470
+ // SM-60: keep the last-execution pill in sync. test-definition cards
471
+ // get/keep the badge; other types must NOT show it (e.g. type-change).
472
+ patchLastExecutionBadge(meta, t, lastExecution, opts);
473
+ }
474
+ }
475
+
476
+ function patchLastExecutionBadge(meta, t /* , lastExecution, opts */) {
477
+ // Remove any existing badge — cheaper than diffing colour/text.
478
+ const existing = meta.querySelector(".sm-exec-badge");
479
+ if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
480
+ // Badge lives only on test-execution cards now (SM-60 revision).
481
+ if (t.type !== "test-execution") return;
482
+ meta.appendChild(buildExecutionOutcomeBadge(t));
483
+ }
484
+
485
+ function patchLinkBadges(meta, linkInfo) {
486
+ // Pure remove-and-rebuild: link badges have click handlers that capture
487
+ // a hostRef, so we can't simply patch text. Drop both, then re-append.
488
+ meta.querySelectorAll(".sm-link-badge").forEach(b => b.parentNode && b.parentNode.removeChild(b));
489
+ if (!linkInfo) return;
490
+ if (linkInfo.forward > 0) {
491
+ const sem = dominantSemantic(linkInfo.forwardSemantics);
492
+ meta.appendChild(buildLinkBadge("forward", linkInfo.forward, sem, linkInfo.forwardTargetKeys, linkInfo));
493
+ }
494
+ if (linkInfo.backward > 0) {
495
+ const sem = dominantSemantic(linkInfo.backwardSemantics);
496
+ meta.appendChild(buildLinkBadge("backward", linkInfo.backward, sem, linkInfo.backwardSourceKeys, linkInfo));
497
+ }
498
+ }
499
+
500
+ function patchBadge(meta, badgeClass, label, items, tooltip) {
501
+ const text = checklistBadge(items);
502
+ let badge = meta.querySelector("." + badgeClass);
503
+ if (text) {
504
+ const want = label + " " + text;
505
+ if (!badge) {
506
+ // Append after any existing badges (preserve order: DoR then DoD).
507
+ const span = document.createElement("span");
508
+ span.className = "sm-badge " + badgeClass;
509
+ span.title = tooltip;
510
+ span.textContent = want;
511
+ meta.appendChild(span);
512
+ } else if (badge.textContent !== want) {
513
+ badge.textContent = want;
514
+ }
515
+ } else if (badge && badge.parentNode) {
516
+ badge.parentNode.removeChild(badge);
517
+ }
518
+ }
519
+
520
+ // ---- Shared release-label header (SM-169) --------------------------
521
+ //
522
+ // The Story-Map's stacked release rows AND the Kanban's release swimlanes
523
+ // render the release header identically through this one component, so the
524
+ // chevron, the clickable name, the completed/cancelled strikethrough + pill
525
+ // all look and behave the same in both views. The caller owns the outer
526
+ // container's dataset / lifecycle hooks (Map needs tagBirth + sync keys);
527
+ // this builds the `.sm-release-label-row` and appends the standard parts.
528
+ //
529
+ // spec:
530
+ // id release id (passed back to the callbacks)
531
+ // name display name
532
+ // status release.status ("completed"/"cancelled"/… or null)
533
+ // collapsed boolean — chevron glyph + collapsed row styling
534
+ // onToggleCollapsed(id) chevron click (collapse/expand)
535
+ // onLabelClick(id) label click (e.g. open edit dialog). Omit → the
536
+ // label is non-interactive (used for "No release").
537
+ // trailing optional array of extra elements appended after the pill
538
+ // (e.g. the Kanban count badge, or the Map "+ Add Release").
539
+ function renderReleaseLabelRow(spec) {
540
+ const row = el("div", {
541
+ class: "sm-release-label-row" + (spec.collapsed ? " sm-release-collapsed" : ""),
542
+ dataset: { releaseId: spec.id || "", status: spec.status || "" }
543
+ });
544
+ // SM-178: chevron + name + pill live in a `.sm-release-label-main` cluster
545
+ // so the Story-Map can make JUST that cluster sticky to the left edge
546
+ // (orientation while scrolling horizontally through wide process steps).
547
+ // The Kanban swimlane header uses the same DOM; only the Map pins it (CSS).
548
+ const main = el("div", { class: "sm-release-label-main" });
549
+ const chevron = el("button", {
550
+ class: "sm-release-chevron",
551
+ type: "button",
552
+ title: spec.collapsed ? "Expand release" : "Collapse release",
553
+ text: spec.collapsed ? "▶" : "▼"
554
+ });
555
+ chevron.setAttribute("aria-expanded", spec.collapsed ? "false" : "true");
556
+ chevron.addEventListener("click", (ev) => {
557
+ ev.stopPropagation();
558
+ if (typeof spec.onToggleCollapsed === "function") spec.onToggleCollapsed(spec.id);
559
+ });
560
+ main.appendChild(chevron);
561
+
562
+ const labelProps = { class: "sm-release-label", dataset: { status: spec.status || "" }, text: spec.name };
563
+ if (typeof spec.onLabelClick === "function") labelProps.title = "Click to edit release";
564
+ const label = el("div", labelProps);
565
+ if (typeof spec.onLabelClick === "function") {
566
+ label.addEventListener("click", (ev) => { ev.stopPropagation(); spec.onLabelClick(spec.id); });
567
+ } else {
568
+ label.style.cursor = "default";
569
+ }
570
+ main.appendChild(label);
571
+
572
+ // SM-277: fixed column order — title (fixed width) → progress count →
573
+ // status pill → trailing (Add release). Keeps the badges from fluttering as
574
+ // titles / counts / the completed pill change width across rows + re-renders.
575
+ // SM-239: X/Y progress badge over non-epic work items. Hidden for an empty
576
+ // release (total 0 → neutral, never green). Green (.complete) at done===total.
577
+ if (spec.progress && spec.progress.total > 0) {
578
+ main.appendChild(el("span", {
579
+ class: "sm-release-progress" + (spec.progress.complete ? " complete" : ""),
580
+ text: spec.progress.done + "/" + spec.progress.total,
581
+ title: spec.progress.done + " of " + spec.progress.total + " work items done"
582
+ }));
583
+ }
584
+ if (spec.status === "completed") {
585
+ main.appendChild(el("span", { class: "sm-release-pill sm-release-pill-completed", text: "Completed" }));
586
+ } else if (spec.status === "cancelled") {
587
+ main.appendChild(el("span", { class: "sm-release-pill sm-release-pill-cancelled", text: "Cancelled" }));
588
+ }
589
+ row.appendChild(main);
590
+ if (Array.isArray(spec.trailing)) for (const t of spec.trailing) if (t) row.appendChild(t);
591
+ return row;
592
+ }
593
+
594
+ // SM-239: patch an existing release-label-row's progress badge in place —
595
+ // used by the renderers' Phase-A/B fast-paths (which don't rebuild the row)
596
+ // so a child status change that shifts X/Y never leaves the badge stale.
597
+ function updateReleaseProgressBadge(rowEl, progress) {
598
+ if (!rowEl) return;
599
+ const main = rowEl.querySelector(".sm-release-label-main") || rowEl;
600
+ let badge = main.querySelector(".sm-release-progress");
601
+ if (!progress || progress.total <= 0) {
602
+ if (badge && badge.parentNode) badge.parentNode.removeChild(badge);
603
+ return;
604
+ }
605
+ const text = progress.done + "/" + progress.total;
606
+ if (!badge) {
607
+ badge = el("span", { class: "sm-release-progress", text: text });
608
+ main.appendChild(badge);
609
+ } else if (badge.textContent !== text) {
610
+ badge.textContent = text;
611
+ }
612
+ badge.classList.toggle("complete", !!progress.complete);
613
+ badge.title = progress.done + " of " + progress.total + " work items done";
614
+ }
615
+
616
+ return {
617
+ CARD_WIDTH_PX,
618
+ TYPE_TO_CLUSTER,
619
+ clusterForType,
620
+ checklistBadge,
621
+ CARD_AGE,
622
+ buildAgeBadge,
623
+ renderCard,
624
+ renderTicketCard,
625
+ renderReleaseLabelRow,
626
+ updateReleaseProgressBadge,
627
+ buildTicketKeyEl,
628
+ patchTicketCardInPlace,
629
+ // SM-49
630
+ computeLinkIndex,
631
+ dominantSemantic,
632
+ buildLinkBadge,
633
+ // SM-60 — outcome badge lives on test-execution cards
634
+ buildExecutionOutcomeBadge,
635
+ // Kept for compat / tests — still computes "latest exec per definition"
636
+ // even though the card renderer no longer surfaces it.
637
+ computeLastExecutionIndex
638
+ };
639
+ }));