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,2095 @@
1
+ /**
2
+ * ticket-form.js — Geteilte Ticket-Formular-Builder + State-Helfer.
3
+ *
4
+ * SM-116 (Epic B / B1): aus renderer-ticket-modal.js extrahiert, damit der
5
+ * Vollseiten-Editor (B2+) UND das Detail-Modal DIESELBEN Builder benutzen.
6
+ * Reine DOM-Bauer + State-Diff-Helfer — KEINE Modal-Annahmen, kein showModal.
7
+ * UMD-gewickelt: factory(core, dnd). In Node direkt require()-bar, im Browser
8
+ * als window.STORYMAP.ticketForm.
9
+ *
10
+ * Hinweis: buildTestExecutionHeader oeffnet beim Pill-Klick das referenzierte
11
+ * Test-Definition-Ticket ueber ctx.openTicketModal (vom Host injiziert) bzw.
12
+ * window.STORYMAP.rendererTicketModal.openTicketModal als Browser-Fallback.
13
+ */
14
+ (function (root, factory) {
15
+ if (typeof module === "object" && module.exports) module.exports = factory(require("./core.js"), require("./dnd.js"));
16
+ else (root.STORYMAP = root.STORYMAP || {}).ticketForm = factory(
17
+ root.STORYMAP && root.STORYMAP.core,
18
+ root.STORYMAP && root.STORYMAP.dnd
19
+ );
20
+ }(typeof self !== "undefined" ? self : this, function (core, dnd) {
21
+ "use strict";
22
+
23
+ if (!core) throw new Error("ticket-form: core module missing");
24
+ if (!dnd) throw new Error("ticket-form: dnd module missing");
25
+
26
+ const DRAG_TYPE_AC = "tm-ac-row";
27
+ const DRAG_TYPE_CHECK = "tm-check-row"; // E19-followup: DoR/DoD rows are drag-sortable
28
+ // SM-54: test-definition sections — Prerequisites + Steps. Distinct drag-types so
29
+ // a prereq row can't be dropped into the steps table and vice versa.
30
+ const DRAG_TYPE_PREREQ = "tm-prereq-row";
31
+ const DRAG_TYPE_TEST_STEP = "tm-test-step-row";
32
+
33
+ // E18.D: lokale Mutationen identifizieren sich als "Local"; Save-Subscriber
34
+ // in main.js persistiert debounced an den HTTP-Adapter.
35
+ const LOCAL_ACTOR = { type: "human", id: "local", name: "Local" };
36
+
37
+ // ---- Konstanten (keine Magic Numbers) -------------------------------
38
+ const CONSTANTS = {
39
+ FIELD_DEBOUNCE_MS: 500, // unbenutzt vorerst (Save ist explizit), reserviert
40
+ TITLE_MAX_LENGTH: 300,
41
+ DESCRIPTION_MAX_LENGTH: 50000,
42
+ LIVE_SYNC_ATTR: "data-sm-sync-key", // markiert Felder, die Live-Sync update darf
43
+ FIELD_PULSE_MS: 900, // SM-5: Modal-Field-Pulse bei externer Änderung
44
+ FIELD_PULSE_CLASS: "sm-card-flash" // reuse Card-Animate keyframe (SM-20 made it generic)
45
+ };
46
+
47
+ // ---- DOM-Helper -----------------------------------------------------
48
+ function el(tag, props, children) {
49
+ const e = document.createElement(tag);
50
+ if (props) {
51
+ for (const k of Object.keys(props)) {
52
+ if (k === "class") e.className = props[k];
53
+ else if (k === "dataset") for (const d of Object.keys(props[k])) e.dataset[d] = props[k][d];
54
+ else if (k === "style") Object.assign(e.style, props[k]);
55
+ else if (k === "text") e.textContent = props[k];
56
+ else if (k === "html") e.innerHTML = props[k];
57
+ else if (k.startsWith("on") && typeof props[k] === "function") e.addEventListener(k.slice(2).toLowerCase(), props[k]);
58
+ else e.setAttribute(k, props[k]);
59
+ }
60
+ }
61
+ if (children) for (const c of children) if (c != null) e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
62
+ return e;
63
+ }
64
+
65
+ // SM-119 (Epic B / B4-lite): the Description is a plain-text contentEditable
66
+ // block (grows with content, no scrollbar) instead of a fixed-height
67
+ // textarea. These helpers let the value read/write paths treat a
68
+ // contentEditable div and a form control (input/select/textarea) uniformly.
69
+ function isEditableNode(node) {
70
+ return !!(node && node.getAttribute && node.getAttribute("contenteditable") === "true");
71
+ }
72
+ // Read the field's text. For a contentEditable we prefer innerText (it
73
+ // renders block/<br> structure back to "\n" line breaks in real browsers);
74
+ // jsdom has no innerText, so we fall back to textContent there.
75
+ function readFieldText(node) {
76
+ if (!node) return "";
77
+ if (isEditableNode(node)) {
78
+ return (typeof node.innerText === "string") ? node.innerText : (node.textContent || "");
79
+ }
80
+ return node.value || "";
81
+ }
82
+ // Write the field's text. For a contentEditable we set textContent (a flat
83
+ // text node); with `white-space: pre-wrap` newlines render correctly and
84
+ // round-trip via readFieldText.
85
+ function writeFieldText(node, text) {
86
+ if (!node) return;
87
+ if (isEditableNode(node)) node.textContent = text;
88
+ else node.value = text;
89
+ }
90
+
91
+ // ---- Builders pro Feldgruppe (pure DOM-Bauer) ------------------------
92
+
93
+ function buildRow(labelText, controlEl, syncKey) {
94
+ if (syncKey && controlEl) controlEl.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, syncKey);
95
+ const lbl = el("label", { class: "tm-label", text: labelText });
96
+ const row = el("div", { class: "tm-row" }, [lbl, controlEl]);
97
+ return row;
98
+ }
99
+
100
+ function buildErrorBanner() {
101
+ const banner = el("div", { class: "tm-error-banner", style: { display: "none" } });
102
+ banner.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "_error_banner");
103
+ return banner;
104
+ }
105
+
106
+ // SM-158: opts.multiline renders the title as an auto-growing textarea so a
107
+ // long title WRAPS across the full width instead of being clipped by a
108
+ // single-line input (the full-page editor uses this for its title hero). The
109
+ // modal keeps the compact single-line input. Both expose `.value` + the
110
+ // "title" sync-key, so collectFormState / syncFieldFromTicket are unchanged.
111
+ function buildTitleField(ticket, opts) {
112
+ opts = opts || {};
113
+ if (opts.multiline) {
114
+ const ta = el("textarea", {
115
+ class: "tm-input tm-title-input",
116
+ rows: "1",
117
+ maxlength: CONSTANTS.TITLE_MAX_LENGTH
118
+ });
119
+ ta.value = ticket.title || "";
120
+ ta.addEventListener("input", function () { autoGrowTextarea(ta); });
121
+ setTimeout(function () { autoGrowTextarea(ta); }, 0);
122
+ return buildRow("Title", ta, "title");
123
+ }
124
+ const input = el("input", {
125
+ type: "text",
126
+ class: "tm-input",
127
+ value: ticket.title || "",
128
+ maxlength: CONSTANTS.TITLE_MAX_LENGTH
129
+ });
130
+ return buildRow("Title", input, "title");
131
+ }
132
+
133
+ function buildTypeField(ticket, ticketTypes) {
134
+ const select = el("select", { class: "tm-input" });
135
+ for (const t of ticketTypes) {
136
+ const opt = el("option", { value: t, text: t });
137
+ if (t === ticket.type) opt.setAttribute("selected", "selected");
138
+ select.appendChild(opt);
139
+ }
140
+ return buildRow("Type", select, "type");
141
+ }
142
+
143
+ // SM-238: human-readable derivation breakdown for an epic's rolled-up status.
144
+ function epicStatusBreakdownText(stats) {
145
+ const n = stats.total;
146
+ const noun = n === 1 ? "story" : "stories";
147
+ return "derived from " + n + " " + noun + " — " + stats.done + " done, "
148
+ + stats.doing + " in progress, " + stats.todo + " to do";
149
+ }
150
+
151
+ function buildStatusField(ticket, statuses, snapshot) {
152
+ // SM-238: an epic's status is DERIVED (roll-up) — show a read-only pill +
153
+ // the derivation breakdown instead of an editable dropdown. The pill carries
154
+ // the "status" sync-key so syncFieldFromTicket can repaint it live. No status
155
+ // patch is ever emitted for an epic: buildUpdatePatch has no status branch
156
+ // and the in-place commit strips status — so the empty read-back is harmless.
157
+ if (ticket.type === "epic") {
158
+ const stats = core.epicChildStats(snapshot || { tickets: [] }, ticket.id);
159
+ const pill = el("span", { class: "tm-status-derived", text: ticket.status });
160
+ pill.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "status");
161
+ pill.dataset.status = ticket.status;
162
+ const breakdown = el("div", { class: "tm-status-breakdown", text: epicStatusBreakdownText(stats) });
163
+ breakdown.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "status_breakdown");
164
+ const wrap = el("div", { class: "tm-status-derived-wrap" }, [pill, breakdown]);
165
+ return buildRow("Status", wrap, null);
166
+ }
167
+ const select = el("select", { class: "tm-input tm-status" });
168
+ for (const s of statuses) {
169
+ const opt = el("option", { value: s, text: s });
170
+ if (s === ticket.status) opt.setAttribute("selected", "selected");
171
+ select.appendChild(opt);
172
+ }
173
+ return buildRow("Status", select, "status");
174
+ }
175
+
176
+ function buildPositionFields(ticket, snapshot, typeConfig) {
177
+ typeConfig = typeConfig || {};
178
+ const releases = (snapshot.releases || []).filter(r => !r.isDeleted);
179
+ const processSteps = (snapshot.processSteps || []).filter(p => !p.isDeleted);
180
+ const epics = (snapshot.tickets || []).filter(t => t.type === "epic" && !t.isDeleted && t.id !== ticket.id);
181
+
182
+ function dropdown(name, items, currentId, withNone) {
183
+ const select = el("select", { class: "tm-input" });
184
+ if (withNone) {
185
+ const noneOpt = el("option", { value: "", text: "— none —" });
186
+ if (!currentId) noneOpt.setAttribute("selected", "selected");
187
+ select.appendChild(noneOpt);
188
+ }
189
+ for (const it of items) {
190
+ const opt = el("option", { value: it.id, text: it.name || it.title || it.id });
191
+ if (it.id === currentId) opt.setAttribute("selected", "selected");
192
+ select.appendChild(opt);
193
+ }
194
+ return select;
195
+ }
196
+
197
+ const pos = ticket.position || {};
198
+ // SM-77-followup: the container-epic lives in a `contains`-link from
199
+ // the epic (SM-52 canonical). `ticket.position.epicId` is always null
200
+ // after the SM-52 migration — reading it here used to silently break
201
+ // the Epic-dropdown and erased the container on every no-op save.
202
+ // SM-244 BUGFIX: a CREATE-mode draft has no id yet (so containerEpicIdOf
203
+ // can't find a link), but it CAN carry an intended parent in
204
+ // position.epicId (e.g. the epic card's "+" button). Fall back to it so the
205
+ // Epic dropdown is pre-selected and the contains-link is actually created.
206
+ // For existing tickets pos.epicId is always null, so this is a no-op there.
207
+ const containerEpicId = ((typeof core.containerEpicIdOf === "function")
208
+ ? core.containerEpicIdOf(snapshot, ticket.id) : null) || (pos.epicId || null);
209
+
210
+ // SM-67: when this ticket is contained by an epic, its release+
211
+ // processStep are inherited from the epic. The Release + ProcessStep
212
+ // dropdowns become read-only (disabled) with the epic's values pre-
213
+ // selected, and a hint shows which epic dictates them. Changing the
214
+ // Epic dropdown live-updates the disabled values; clearing it
215
+ // (— none —) re-enables them so the story can be detached + relocated.
216
+ const epicById = new Map(epics.map(e => [e.id, e]));
217
+ const initialContainer = (ticket.type !== "epic" && containerEpicId)
218
+ ? epicById.get(containerEpicId) : null;
219
+
220
+ const relSelect = dropdown("releaseId", releases,
221
+ initialContainer ? (initialContainer.position && initialContainer.position.releaseId) || "" : pos.releaseId,
222
+ true);
223
+ const psSelect = dropdown("processStepId", processSteps,
224
+ initialContainer ? (initialContainer.position && initialContainer.position.processStepId) || "" : pos.processStepId,
225
+ true);
226
+ const epicSelect = (typeConfig.allowParentEpic !== false)
227
+ ? dropdown("epicId", epics, containerEpicId, true) : null;
228
+
229
+ function hintText(epic) {
230
+ return epic ? ("Inherited from " + (epic.ticketKey || epic.title || epic.id)) : "";
231
+ }
232
+
233
+ function applyInheritance(epic) {
234
+ if (epic && epic.position) {
235
+ relSelect.value = epic.position.releaseId || "";
236
+ psSelect.value = epic.position.processStepId || "";
237
+ relSelect.disabled = true;
238
+ psSelect.disabled = true;
239
+ } else {
240
+ relSelect.disabled = false;
241
+ psSelect.disabled = false;
242
+ }
243
+ if (relHint) relHint.textContent = hintText(epic);
244
+ if (psHint) psHint.textContent = hintText(epic);
245
+ }
246
+
247
+ let relHint = null;
248
+ let psHint = null;
249
+ const rows = [];
250
+ if (typeConfig.showRelease !== false) {
251
+ rows.push(buildRow("Release", relSelect, "position.releaseId"));
252
+ relHint = el("div", { class: "tm-field-hint", text: hintText(initialContainer) });
253
+ rows.push(relHint);
254
+ }
255
+ if (typeConfig.showProcessStep !== false) {
256
+ rows.push(buildRow("Process Step", psSelect, "position.processStepId"));
257
+ psHint = el("div", { class: "tm-field-hint", text: hintText(initialContainer) });
258
+ rows.push(psHint);
259
+ }
260
+ if (epicSelect) {
261
+ rows.push(buildRow("Epic", epicSelect, "position.epicId"));
262
+ epicSelect.addEventListener("change", () => {
263
+ const v = epicSelect.value || "";
264
+ applyInheritance(v ? epicById.get(v) : null);
265
+ });
266
+ }
267
+ // Apply the initial inherited state if applicable.
268
+ if (initialContainer) applyInheritance(initialContainer);
269
+ return rows;
270
+ }
271
+
272
+ // SM-119: plain-text contentEditable description. Grows with content (no
273
+ // fixed height, no inner scrollbar) and stays a PLAIN string — paste is
274
+ // forced to text/plain so no HTML/styling creeps in, and the value is read
275
+ // back via readFieldText (innerText → "\n", textContent fallback). Shared
276
+ // builder → the modal AND the full-page editor get this automatically.
277
+ //
278
+ // SM-157: when `opts.onCommit(text)` is wired (edit-mode, the ticket exists),
279
+ // a Jira-style ✓/✗ pair APPEARS as soon as the text differs from the saved
280
+ // value, so free-text edits are committed deliberately and never silently
281
+ // lost. ✓ (or focus-out / Cmd+Enter) commits; ✗ (or Esc) reverts. Without
282
+ // onCommit (create-mode / pure render) the field is a plain draft input.
283
+ function buildDescriptionField(ticket, opts) {
284
+ opts = opts || {};
285
+ const ed = el("div", {
286
+ class: "tm-input tm-description-edit",
287
+ contenteditable: "true",
288
+ role: "textbox",
289
+ "aria-multiline": "true",
290
+ spellcheck: "true",
291
+ dataset: { placeholder: "Description…" }
292
+ });
293
+ ed.textContent = ticket.description || "";
294
+ // Force plain-text paste so the contentEditable never accumulates markup.
295
+ ed.addEventListener("paste", function (ev) {
296
+ const cd = ev.clipboardData || (typeof window !== "undefined" && window.clipboardData);
297
+ if (!cd) return;
298
+ ev.preventDefault();
299
+ const text = (cd.getData && (cd.getData("text/plain") || cd.getData("text"))) || "";
300
+ const sel = (typeof window !== "undefined" && window.getSelection) ? window.getSelection() : null;
301
+ if (sel && sel.rangeCount) {
302
+ const range = sel.getRangeAt(0);
303
+ range.deleteContents();
304
+ const node = document.createTextNode(text);
305
+ range.insertNode(node);
306
+ range.setStartAfter(node); range.setEndAfter(node);
307
+ sel.removeAllRanges(); sel.addRange(range);
308
+ } else {
309
+ ed.appendChild(document.createTextNode(text));
310
+ }
311
+ });
312
+ const row = buildRow("Description", ed, "description");
313
+
314
+ if (typeof opts.onCommit === "function") {
315
+ let baseline = ticket.description || "";
316
+ let reverting = false;
317
+ const actions = el("div", { class: "tm-desc-actions", style: { display: "none" } });
318
+ const saveBtn = el("button", { class: "tm-desc-save", type: "button", title: "Save (⌘/Ctrl+Enter)", text: "✓" });
319
+ const cancelBtn = el("button", { class: "tm-desc-cancel", type: "button", title: "Discard (Esc)", text: "✕" });
320
+ actions.appendChild(saveBtn);
321
+ actions.appendChild(cancelBtn);
322
+ row.appendChild(actions);
323
+
324
+ function isDirty() { return readFieldText(ed) !== baseline; }
325
+ function refresh() { actions.style.display = isDirty() ? "" : "none"; }
326
+ function commit() {
327
+ const text = readFieldText(ed);
328
+ if (text !== baseline) { baseline = text; opts.onCommit(text); }
329
+ refresh();
330
+ }
331
+ function revert() { reverting = true; writeFieldText(ed, baseline); refresh(); reverting = false; }
332
+
333
+ ed.addEventListener("input", refresh);
334
+ // Buttons use mousedown+preventDefault so clicking them does NOT blur the
335
+ // editable (which would otherwise fire the blur-commit first).
336
+ saveBtn.addEventListener("mousedown", function (e) { e.preventDefault(); });
337
+ saveBtn.addEventListener("click", function () { commit(); });
338
+ cancelBtn.addEventListener("mousedown", function (e) { e.preventDefault(); });
339
+ cancelBtn.addEventListener("click", function () { revert(); });
340
+ // Focus-out commits (the "focus change saves" behaviour the user expects),
341
+ // unless we're mid-revert.
342
+ ed.addEventListener("blur", function () { if (!reverting) commit(); });
343
+ ed.addEventListener("keydown", function (e) {
344
+ if (e.key === "Escape") { e.preventDefault(); revert(); if (ed.blur) ed.blur(); }
345
+ else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commit(); }
346
+ });
347
+ // Live-sync hook: when an external commit rewrites the field (see
348
+ // syncFieldFromTicket), reset the baseline so ✓/✗ don't show spuriously
349
+ // and a later blur doesn't re-commit a stale value.
350
+ ed.__tfSetBaseline = function (v) { baseline = v || ""; refresh(); };
351
+ }
352
+ return row;
353
+ }
354
+
355
+ /**
356
+ * E19-followup: DoR/DoD-Section ist ein vollwertiger per-Ticket-Editor
357
+ * (analog Acceptance Criteria). Pro Row: ⋮⋮-Grip · checked-Checkbox ·
358
+ * Label-Input · `required`-Toggle · Delete-Button. `+ Add Item` am Ende.
359
+ * Drag-sortierbar via dnd.js.
360
+ *
361
+ * collectFormState liest beim Save die Reihenfolge und alle Felder direkt
362
+ * aus dem DOM und persistiert sie als `definitionOfReady`/`definitionOfDone`
363
+ * via Store-Op. Kein inline-Persist mehr — Save-Subscriber kümmert sich.
364
+ */
365
+ function buildChecklistSection(title, items, syncKey, refs) {
366
+ const wrap = el("section", { class: "tm-checklist" });
367
+ wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, syncKey);
368
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: title }));
369
+ const list = el("div", { class: "tm-checklist-list" });
370
+ for (const item of (items || [])) {
371
+ list.appendChild(buildCheckRow(item, refs));
372
+ }
373
+ wrap.appendChild(list);
374
+ const addBtn = el("button", {
375
+ class: "tm-checklist-add",
376
+ type: "button",
377
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Item</span>'
378
+ });
379
+ addBtn.addEventListener("click", () => {
380
+ const fresh = { id: "ci-" + Math.random().toString(36).slice(2, 10), label: "", required: true, checked: false };
381
+ const row = buildCheckRow(fresh, refs);
382
+ list.appendChild(row);
383
+ const labelInput = row.querySelector(".tm-checkitem-text-input");
384
+ if (labelInput) labelInput.focus();
385
+ if (refs && refs.markDirty) refs.markDirty();
386
+ });
387
+ wrap.appendChild(addBtn);
388
+ return wrap;
389
+ }
390
+
391
+ function buildCheckRow(item, refs) {
392
+ const row = el("div", { class: "tm-checkitem-row" });
393
+ row.dataset.itemId = item.id;
394
+ const grip = el("span", { class: "tm-checkitem-grip", title: "Drag to reorder", text: "⋮⋮" });
395
+ const checked = el("input", { type: "checkbox", class: "tm-checkitem-checked" });
396
+ if (item.checked) checked.setAttribute("checked", "checked");
397
+ const labelInput = el("input", { type: "text", class: "tm-input tm-checkitem-text-input", value: item.label || "", placeholder: "Item label" });
398
+ const requiredLabel = el("label", { class: "tm-checkitem-required-wrap", title: "Required to pass the gate" });
399
+ const requiredInput = el("input", { type: "checkbox", class: "tm-checkitem-required" });
400
+ if (item.required !== false) requiredInput.setAttribute("checked", "checked");
401
+ requiredLabel.appendChild(requiredInput);
402
+ requiredLabel.appendChild(el("span", { class: "tm-checkitem-required-hint", text: "required" }));
403
+ const remove = el("button", { class: "tm-checkitem-remove", type: "button", title: "Remove item", text: "×" });
404
+ remove.addEventListener("click", () => { row.remove(); if (refs && refs.markDirty) refs.markDirty(); });
405
+ [checked, labelInput, requiredInput].forEach(el => {
406
+ el.addEventListener("change", () => { if (refs && refs.markDirty) refs.markDirty(); });
407
+ });
408
+ labelInput.addEventListener("input", () => { if (refs && refs.markDirty) refs.markDirty(); });
409
+ row.appendChild(grip);
410
+ row.appendChild(checked);
411
+ row.appendChild(labelInput);
412
+ row.appendChild(requiredLabel);
413
+ row.appendChild(remove);
414
+
415
+ if (dnd && typeof dnd.enableDraggable === "function") {
416
+ dnd.enableDraggable(row, { dragType: DRAG_TYPE_CHECK, dragId: item.id });
417
+ dnd.enableDropTarget(row, {
418
+ accepts: [DRAG_TYPE_CHECK],
419
+ onEnter: (e) => e.classList.add("tm-checkitem-drop-target"),
420
+ onLeave: (e) => e.classList.remove("tm-checkitem-drop-target"),
421
+ onDrop: ({ id, event }) => {
422
+ row.classList.remove("tm-checkitem-drop-target");
423
+ const list = row.parentNode;
424
+ if (!list) return;
425
+ const dragged = list.querySelector('.tm-checkitem-row[data-item-id="' + cssEscape(id) + '"]');
426
+ if (!dragged || dragged === row) return;
427
+ const rect = row.getBoundingClientRect();
428
+ const before = (event && typeof event.clientY === "number")
429
+ ? event.clientY < rect.top + rect.height / 2 : true;
430
+ if (before) list.insertBefore(dragged, row);
431
+ else list.insertBefore(dragged, row.nextSibling);
432
+ if (refs && refs.markDirty) refs.markDirty();
433
+ }
434
+ });
435
+ }
436
+ return row;
437
+ }
438
+
439
+ function buildAcceptanceCriteriaSection(ticket, refs) {
440
+ const wrap = el("section", { class: "tm-ac-section" });
441
+ wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "acceptanceCriteria");
442
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Acceptance Criteria" }));
443
+ const list = el("div", { class: "tm-ac-list" });
444
+ for (const ac of (ticket.acceptanceCriteria || [])) {
445
+ list.appendChild(buildAcRow(ac, refs));
446
+ }
447
+ wrap.appendChild(list);
448
+ const addBtn = el("button", {
449
+ class: "tm-ac-add",
450
+ type: "button",
451
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Criterion</span>'
452
+ });
453
+ addBtn.addEventListener("click", () => {
454
+ const newAc = { id: "ac-" + Math.random().toString(36).slice(2, 10), text: "", completed: false };
455
+ list.appendChild(buildAcRow(newAc, refs));
456
+ refs.markDirty();
457
+ });
458
+ wrap.appendChild(addBtn);
459
+ return wrap;
460
+ }
461
+
462
+ function buildAcRow(ac, refs) {
463
+ const row = el("div", { class: "tm-ac-row" });
464
+ // Grip-handle als visueller Drag-Indikator. Drag-Mechanik: pointerdown
465
+ // auf irgendeinem Teil der Row startet einen Drag (Inputs/Buttons sind
466
+ // durch dnd.js per `closest("button,input,...")`-Guard ausgenommen, sodass
467
+ // Tippen + Checkbox-Click + Remove weiter funktionieren). Der Grip selbst
468
+ // existiert nur als visueller Hinweis und non-input-Aufsetzpunkt.
469
+ const grip = el("span", { class: "tm-ac-grip", text: "⋮⋮", title: "Drag to reorder" });
470
+ const check = el("input", { type: "checkbox" });
471
+ if (ac.completed) check.setAttribute("checked", "checked");
472
+ const text = el("input", { type: "text", class: "tm-input tm-ac-text", value: ac.text || "" });
473
+ const remove = el("button", { class: "tm-ac-remove", type: "button", title: "Remove", text: "×" });
474
+ check.addEventListener("change", refs.markDirty);
475
+ text.addEventListener("input", refs.markDirty);
476
+ remove.addEventListener("click", () => { row.remove(); refs.markDirty(); });
477
+ row.dataset.acId = ac.id;
478
+ row.appendChild(grip);
479
+ row.appendChild(check);
480
+ row.appendChild(text);
481
+ row.appendChild(remove);
482
+
483
+ // Drag-Reorder: Row ist Drag-Source UND Drop-Target. onDrop bewegt die
484
+ // gezogene Row vor die Target-Row (oder ans Listenende, wenn Cursor im
485
+ // unteren Halb der Target-Row ist). collectFormState liest die neue
486
+ // Reihenfolge dann direkt aus dem DOM beim Save.
487
+ dnd.enableDraggable(row, { dragType: DRAG_TYPE_AC, dragId: ac.id });
488
+ dnd.enableDropTarget(row, {
489
+ accepts: [DRAG_TYPE_AC],
490
+ onEnter: (el) => el.classList.add("tm-ac-drop-target"),
491
+ onLeave: (el) => el.classList.remove("tm-ac-drop-target"),
492
+ onDrop: ({ id, event }) => {
493
+ row.classList.remove("tm-ac-drop-target");
494
+ const list = row.parentNode;
495
+ if (!list) return;
496
+ const dragged = list.querySelector('.tm-ac-row[data-ac-id="' + cssEscape(id) + '"]');
497
+ if (!dragged || dragged === row) return;
498
+ const rect = row.getBoundingClientRect();
499
+ const before = (event && typeof event.clientY === "number")
500
+ ? event.clientY < rect.top + rect.height / 2
501
+ : true;
502
+ if (before) list.insertBefore(dragged, row);
503
+ else list.insertBefore(dragged, row.nextSibling);
504
+ refs.markDirty();
505
+ }
506
+ });
507
+ return row;
508
+ }
509
+
510
+ function cssEscape(s) {
511
+ // Minimal CSS-attribute-selector escaper. dragged.dataset.acId is our id;
512
+ // we use it in [data-ac-id="..."]. Escape backslashes + double-quotes.
513
+ return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
514
+ }
515
+
516
+ // ---- SM-54: Prerequisites section (test-definition type) ------------
517
+ //
518
+ // Analog der DoR/DoD-Editor-Section: pro Row Grip + checked-Checkbox +
519
+ // Label-Input + required-Toggle + Delete-Button. "+ Add Prerequisite" am
520
+ // Ende. Drag-sortable via dnd.js mit eigenem drag-type. Empty-Label-Rows
521
+ // werden beim Collect verworfen (siehe collectPrerequisites).
522
+ function buildPrerequisitesSection(ticket, refs) {
523
+ const wrap = el("section", { class: "tm-prereqs" });
524
+ wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "prerequisites");
525
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Prerequisites" }));
526
+ const list = el("div", { class: "tm-prereq-list" });
527
+ for (const p of (ticket.prerequisites || [])) {
528
+ list.appendChild(buildPrereqRow(p, refs));
529
+ }
530
+ wrap.appendChild(list);
531
+ const addBtn = el("button", {
532
+ class: "tm-prereq-add",
533
+ type: "button",
534
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Prerequisite</span>'
535
+ });
536
+ addBtn.addEventListener("click", () => {
537
+ const fresh = { id: "tpre-" + Math.random().toString(36).slice(2, 10),
538
+ label: "", required: true, checked: false };
539
+ const row = buildPrereqRow(fresh, refs);
540
+ list.appendChild(row);
541
+ const labelInput = row.querySelector(".tm-prereq-text-input");
542
+ if (labelInput) labelInput.focus();
543
+ if (refs && refs.markDirty) refs.markDirty();
544
+ });
545
+ wrap.appendChild(addBtn);
546
+ return wrap;
547
+ }
548
+
549
+ function buildPrereqRow(item, refs) {
550
+ const row = el("div", { class: "tm-prereq-row" });
551
+ row.dataset.itemId = item.id;
552
+ const grip = el("span", { class: "tm-prereq-grip", title: "Drag to reorder", text: "⋮⋮" });
553
+ const checked = el("input", { type: "checkbox", class: "tm-prereq-checked" });
554
+ if (item.checked) checked.setAttribute("checked", "checked");
555
+ const labelInput = el("input", {
556
+ type: "text", class: "tm-input tm-prereq-text-input",
557
+ value: item.label || "", placeholder: "Prerequisite label"
558
+ });
559
+ const requiredLabel = el("label", { class: "tm-prereq-required-wrap", title: "Required" });
560
+ const requiredInput = el("input", { type: "checkbox", class: "tm-prereq-required" });
561
+ if (item.required !== false) requiredInput.setAttribute("checked", "checked");
562
+ requiredLabel.appendChild(requiredInput);
563
+ requiredLabel.appendChild(el("span", { class: "tm-prereq-required-hint", text: "required" }));
564
+ const remove = el("button", {
565
+ class: "tm-prereq-remove", type: "button", title: "Remove prerequisite", text: "×"
566
+ });
567
+ remove.addEventListener("click", () => {
568
+ row.remove();
569
+ if (refs && refs.markDirty) refs.markDirty();
570
+ });
571
+ [checked, requiredInput].forEach(input => {
572
+ input.addEventListener("change", () => { if (refs && refs.markDirty) refs.markDirty(); });
573
+ });
574
+ labelInput.addEventListener("input", () => { if (refs && refs.markDirty) refs.markDirty(); });
575
+
576
+ row.appendChild(grip);
577
+ row.appendChild(checked);
578
+ row.appendChild(labelInput);
579
+ row.appendChild(requiredLabel);
580
+ row.appendChild(remove);
581
+
582
+ if (dnd && typeof dnd.enableDraggable === "function") {
583
+ dnd.enableDraggable(row, { dragType: DRAG_TYPE_PREREQ, dragId: item.id });
584
+ dnd.enableDropTarget(row, {
585
+ accepts: [DRAG_TYPE_PREREQ],
586
+ onEnter: (e) => e.classList.add("tm-prereq-drop-target"),
587
+ onLeave: (e) => e.classList.remove("tm-prereq-drop-target"),
588
+ onDrop: ({ id, event }) => {
589
+ row.classList.remove("tm-prereq-drop-target");
590
+ const list = row.parentNode;
591
+ if (!list) return;
592
+ const dragged = list.querySelector('.tm-prereq-row[data-item-id="' + cssEscape(id) + '"]');
593
+ if (!dragged || dragged === row) return;
594
+ const rect = row.getBoundingClientRect();
595
+ const before = (event && typeof event.clientY === "number")
596
+ ? event.clientY < rect.top + rect.height / 2 : true;
597
+ if (before) list.insertBefore(dragged, row);
598
+ else list.insertBefore(dragged, row.nextSibling);
599
+ if (refs && refs.markDirty) refs.markDirty();
600
+ }
601
+ });
602
+ }
603
+ return row;
604
+ }
605
+
606
+ // ---- SM-54: Steps section (test-definition type) -------------------
607
+ //
608
+ // Tabelle mit Header (Step / Data / Expected Result) und einer Row pro
609
+ // Step. Pro Row: ⋮⋮-Grip + drei Text-Inputs + Delete-Button. "+ Add Step"
610
+ // am Ende. Drag-sortable via dnd.js mit eigenem drag-type. Empty-Rows
611
+ // (alle drei Felder leer) werden beim Collect verworfen.
612
+ function buildStepsSection(ticket, refs) {
613
+ const wrap = el("section", { class: "tm-test-steps" });
614
+ wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "steps");
615
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Steps" }));
616
+ // SM-54-followup: header grid mirrors the row grid exactly so labels
617
+ // sit above their columns. Empty cells fill grip + insert + delete
618
+ // positions; explicit grid-column avoids "STEP", "DATA",
619
+ // "EXPECTED RESULT" all landing in the first three columns.
620
+ const tableHead = el("div", { class: "tm-test-step-head" }, [
621
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-grip" }),
622
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-step", text: "Step" }),
623
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-data", text: "Data" }),
624
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-expected", text: "Expected Result" }),
625
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-insert" }),
626
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-remove" })
627
+ ]);
628
+ wrap.appendChild(tableHead);
629
+ const list = el("div", { class: "tm-test-step-list" });
630
+ for (const s of (ticket.steps || [])) {
631
+ list.appendChild(buildTestStepRow(s, refs, list));
632
+ }
633
+ wrap.appendChild(list);
634
+ const addBtn = el("button", {
635
+ class: "tm-test-step-add",
636
+ type: "button",
637
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Step</span>'
638
+ });
639
+ addBtn.addEventListener("click", () => {
640
+ const fresh = { id: "tstep-" + Math.random().toString(36).slice(2, 10),
641
+ step: "", data: "", expectedResult: "" };
642
+ const row = buildTestStepRow(fresh, refs, list);
643
+ list.appendChild(row);
644
+ const stepArea = row.querySelector(".tm-test-step-step");
645
+ if (stepArea) stepArea.focus();
646
+ if (refs && refs.markDirty) refs.markDirty();
647
+ });
648
+ wrap.appendChild(addBtn);
649
+ return wrap;
650
+ }
651
+
652
+ // Auto-grow a textarea to fit content. We cap at MAX_TEST_STEP_TA_HEIGHT_PX
653
+ // so a giant paste doesn't blow up the modal — past that, the textarea
654
+ // scrolls internally.
655
+ const MAX_TEST_STEP_TA_HEIGHT_PX = 240;
656
+ function autoGrowTextarea(ta) {
657
+ ta.style.height = "auto";
658
+ ta.style.height = Math.min(MAX_TEST_STEP_TA_HEIGHT_PX, Math.max(ta.scrollHeight, 32)) + "px";
659
+ }
660
+
661
+ function buildTestStepRow(item, refs, listRef) {
662
+ const row = el("div", { class: "tm-test-step-row" });
663
+ row.dataset.itemId = item.id;
664
+ const grip = el("span", { class: "tm-test-step-grip", title: "Drag to reorder", text: "⋮⋮" });
665
+ // SM-54-followup: multi-line textareas — test steps often need a
666
+ // paragraph or two, not a one-liner. Auto-grow on input, capped.
667
+ const stepArea = el("textarea", {
668
+ class: "tm-input tm-test-step-step", rows: "2", placeholder: "Action"
669
+ });
670
+ stepArea.value = item.step || "";
671
+ const dataArea = el("textarea", {
672
+ class: "tm-input tm-test-step-data", rows: "2", placeholder: "Data"
673
+ });
674
+ dataArea.value = item.data || "";
675
+ const expectedArea = el("textarea", {
676
+ class: "tm-input tm-test-step-expected", rows: "2", placeholder: "Expected result"
677
+ });
678
+ expectedArea.value = item.expectedResult || "";
679
+ // SM-54-followup: insert-step-above button so the user can splice a
680
+ // new step between two existing ones (not only append at the bottom).
681
+ const insertAbove = el("button", {
682
+ class: "tm-test-step-insert", type: "button",
683
+ title: "Insert step above this one", text: "↑+"
684
+ });
685
+ insertAbove.addEventListener("click", (ev) => {
686
+ ev.stopPropagation();
687
+ const fresh = { id: "tstep-" + Math.random().toString(36).slice(2, 10),
688
+ step: "", data: "", expectedResult: "" };
689
+ const newRow = buildTestStepRow(fresh, refs, listRef);
690
+ const list = listRef || row.parentNode;
691
+ if (list) list.insertBefore(newRow, row);
692
+ const ta = newRow.querySelector(".tm-test-step-step");
693
+ if (ta) ta.focus();
694
+ if (refs && refs.markDirty) refs.markDirty();
695
+ });
696
+ const remove = el("button", {
697
+ class: "tm-test-step-remove", type: "button", title: "Remove step", text: "×"
698
+ });
699
+ remove.addEventListener("click", () => {
700
+ row.remove();
701
+ if (refs && refs.markDirty) refs.markDirty();
702
+ });
703
+ [stepArea, dataArea, expectedArea].forEach(ta => {
704
+ ta.addEventListener("input", () => {
705
+ autoGrowTextarea(ta);
706
+ if (refs && refs.markDirty) refs.markDirty();
707
+ });
708
+ // Initial size after mount — defer so the element is in the DOM and
709
+ // scrollHeight reflects the actual content.
710
+ setTimeout(() => autoGrowTextarea(ta), 0);
711
+ });
712
+
713
+ row.appendChild(grip);
714
+ row.appendChild(stepArea);
715
+ row.appendChild(dataArea);
716
+ row.appendChild(expectedArea);
717
+ row.appendChild(insertAbove);
718
+ row.appendChild(remove);
719
+
720
+ if (dnd && typeof dnd.enableDraggable === "function") {
721
+ dnd.enableDraggable(row, { dragType: DRAG_TYPE_TEST_STEP, dragId: item.id });
722
+ dnd.enableDropTarget(row, {
723
+ accepts: [DRAG_TYPE_TEST_STEP],
724
+ onEnter: (e) => e.classList.add("tm-test-step-drop-target"),
725
+ onLeave: (e) => e.classList.remove("tm-test-step-drop-target"),
726
+ onDrop: ({ id, event }) => {
727
+ row.classList.remove("tm-test-step-drop-target");
728
+ const list = row.parentNode;
729
+ if (!list) return;
730
+ const dragged = list.querySelector('.tm-test-step-row[data-item-id="' + cssEscape(id) + '"]');
731
+ if (!dragged || dragged === row) return;
732
+ const rect = row.getBoundingClientRect();
733
+ const before = (event && typeof event.clientY === "number")
734
+ ? event.clientY < rect.top + rect.height / 2 : true;
735
+ if (before) list.insertBefore(dragged, row);
736
+ else list.insertBefore(dragged, row.nextSibling);
737
+ if (refs && refs.markDirty) refs.markDirty();
738
+ }
739
+ });
740
+ }
741
+ return row;
742
+ }
743
+
744
+ function buildLabelsField(ticket) {
745
+ const input = el("input", {
746
+ type: "text",
747
+ class: "tm-input",
748
+ value: (ticket.labels || []).join(", "),
749
+ placeholder: "comma-separated"
750
+ });
751
+ return buildRow("Labels", input, "labels");
752
+ }
753
+
754
+ // ---- SM-57: Test-Execution sections --------------------------------
755
+ //
756
+ // For type='test-execution' the modal renders three extra blocks:
757
+ // 1. A "executes → DEF-KEY: Title" pill at the very top of the body
758
+ // (clickable, opens the definition's modal).
759
+ // 2. A metadata strip (runAt / runBy / env) — read-only after creation.
760
+ // 3. A 6-column execution-steps table. step/data/expectedResult are
761
+ // frozen from the definition (rendered disabled). actualResult,
762
+ // status, note are editable and persist via store.updateTicket
763
+ // executionSteps.
764
+ // 4. An outcome section: derived display + manual-override picker.
765
+ //
766
+ // Add/remove buttons and drag-sort are deliberately absent — the step
767
+ // list is a snapshot of the definition and may not grow or shrink.
768
+
769
+ /**
770
+ * Find the test-definition this execution references. Returns null if
771
+ * the link is missing or the target has been deleted.
772
+ */
773
+ function resolveTestDefinition(execTicket, snapshot) {
774
+ if (!execTicket || !execTicket.referencedTestDefinitionId) return null;
775
+ const tickets = (snapshot && snapshot.tickets) || [];
776
+ const def = tickets.find(t => t.id === execTicket.referencedTestDefinitionId);
777
+ if (!def || def.isDeleted) return null;
778
+ return def;
779
+ }
780
+
781
+ /**
782
+ * Header pill: "executes → DEF-KEY: Title". Click opens the definition's
783
+ * modal (best-effort — if the definition was deleted between renders the
784
+ * click is a no-op). Returns null when the link is unresolved so the
785
+ * caller skips appending an empty pill.
786
+ */
787
+ function buildTestExecutionHeader(ticket, snapshot, ctx) {
788
+ const def = resolveTestDefinition(ticket, snapshot);
789
+ const wrap = el("section", { class: "tm-exec-header" });
790
+ wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "referencedTestDefinitionId");
791
+ if (!def) {
792
+ wrap.appendChild(el("span", {
793
+ class: "tm-exec-header-missing",
794
+ text: "executes → (definition unavailable)"
795
+ }));
796
+ return wrap;
797
+ }
798
+ const pill = el("button", {
799
+ class: "tm-exec-header-pill",
800
+ type: "button",
801
+ title: "Open the test-definition this run executes"
802
+ });
803
+ pill.appendChild(el("span", { class: "tm-exec-header-arrow", text: "executes →" }));
804
+ pill.appendChild(el("span", { class: "tm-exec-header-key", text: def.ticketKey || def.id }));
805
+ pill.appendChild(el("span", { class: "tm-exec-header-title", text: ": " + (def.title || "") }));
806
+ pill.addEventListener("click", (ev) => {
807
+ ev.stopPropagation();
808
+ // SM-116: this builder lives in the shared ticket-form module now, so it
809
+ // cannot see the modal's openTicketModal directly. The host injects it via
810
+ // ctx (Object.assign({}, ctx, { openTicketModal })); a window fallback keeps
811
+ // the browser working even when ctx was not augmented.
812
+ const opener = ctx && (ctx.openTicketModal
813
+ || (typeof window !== "undefined" && window.STORYMAP
814
+ && window.STORYMAP.rendererTicketModal
815
+ && window.STORYMAP.rendererTicketModal.openTicketModal));
816
+ if (typeof opener === "function") opener(ctx, def.id, {});
817
+ });
818
+ wrap.appendChild(pill);
819
+ return wrap;
820
+ }
821
+
822
+ /**
823
+ * Read-only metadata strip: runAt (formatted), runBy (actor name),
824
+ * env (string or "—"). Pure presentation; the values come from the
825
+ * starter dialog and aren't editable here.
826
+ */
827
+ function buildExecutionMetadata(ticket) {
828
+ function fmtTime(ms) {
829
+ if (typeof ms !== "number") return "—";
830
+ try {
831
+ const d = new Date(ms);
832
+ // Format: 2026-05-27 10:23 (ISO-ish, no timezone since we use local)
833
+ const pad = (n) => String(n).padStart(2, "0");
834
+ return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
835
+ + " " + pad(d.getHours()) + ":" + pad(d.getMinutes());
836
+ } catch (_) { return "—"; }
837
+ }
838
+ const wrap = el("section", { class: "tm-exec-meta" });
839
+ wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "executionMetadata");
840
+ function entry(label, value) {
841
+ const cell = el("div", { class: "tm-exec-meta-cell" });
842
+ cell.appendChild(el("span", { class: "tm-exec-meta-label", text: label }));
843
+ cell.appendChild(el("span", { class: "tm-exec-meta-value", text: value }));
844
+ return cell;
845
+ }
846
+ wrap.appendChild(entry("Run at", fmtTime(ticket.runAt)));
847
+ const runBy = ticket.runBy && ticket.runBy.name ? ticket.runBy.name : "—";
848
+ wrap.appendChild(entry("Run by", runBy));
849
+ wrap.appendChild(entry("Env", ticket.env || "—"));
850
+ return wrap;
851
+ }
852
+
853
+ /**
854
+ * 6-column step table. Header row + data rows. step/data/expectedResult
855
+ * are RENDERED but disabled — they're the frozen contract from the
856
+ * definition. actualResult + status + note are inline-editable and feed
857
+ * collectExecutionSteps via [data-sm-sync-key=executionSteps].
858
+ */
859
+ function buildExecutionStepsSection(ticket, refs) {
860
+ const wrap = el("section", { class: "tm-exec-steps" });
861
+ wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "executionSteps");
862
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Execution Steps" }));
863
+ const head = el("div", { class: "tm-exec-step-head" }, [
864
+ el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-step", text: "Step" }),
865
+ el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-data", text: "Data" }),
866
+ el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-expected", text: "Expected" }),
867
+ el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-actual", text: "Actual" }),
868
+ el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-status", text: "Status" }),
869
+ el("span", { class: "tm-exec-step-head-cell tm-exec-step-head-note", text: "Note" })
870
+ ]);
871
+ wrap.appendChild(head);
872
+ const list = el("div", { class: "tm-exec-step-list" });
873
+ for (const s of (ticket.executionSteps || [])) {
874
+ list.appendChild(buildExecutionStepRow(s, refs));
875
+ }
876
+ wrap.appendChild(list);
877
+ return wrap;
878
+ }
879
+
880
+ function buildExecutionStepRow(item, refs) {
881
+ const row = el("div", { class: "tm-exec-step-row" });
882
+ row.dataset.itemId = item.id;
883
+ // Read-only columns — disabled textareas so a user can still copy text.
884
+ function ro(extraClass, value) {
885
+ const ta = el("textarea", { class: "tm-input tm-exec-step-ro " + extraClass, rows: "2", disabled: "disabled" });
886
+ ta.value = value || "";
887
+ return ta;
888
+ }
889
+ const stepArea = ro("tm-exec-step-step", item.step);
890
+ const dataArea = ro("tm-exec-step-data", item.data);
891
+ const expectedArea = ro("tm-exec-step-expected", item.expectedResult);
892
+ const actualArea = el("textarea", {
893
+ class: "tm-input tm-exec-step-actual",
894
+ rows: "2",
895
+ placeholder: "Observed result"
896
+ });
897
+ actualArea.value = item.actualResult || "";
898
+ const statusSel = el("select", { class: "tm-input tm-exec-step-status" });
899
+ const STATUSES = core.TEST_EXEC_STEP_STATUSES
900
+ || ["pending", "passed", "failed", "blocked", "skipped"];
901
+ for (const s of STATUSES) {
902
+ const opt = el("option", { value: s, text: s });
903
+ if (s === (item.status || "pending")) opt.setAttribute("selected", "selected");
904
+ statusSel.appendChild(opt);
905
+ }
906
+ statusSel.dataset.status = item.status || "pending";
907
+ // Color the picker via a status-pill data attribute (CSS handles colors).
908
+ function reflectStatusColor() { statusSel.dataset.status = statusSel.value; }
909
+ statusSel.addEventListener("change", reflectStatusColor);
910
+ const noteArea = el("textarea", {
911
+ class: "tm-input tm-exec-step-note",
912
+ rows: "2",
913
+ placeholder: "Note"
914
+ });
915
+ noteArea.value = item.note || "";
916
+ [actualArea, statusSel, noteArea].forEach(node => {
917
+ node.addEventListener("change", () => { if (refs && refs.markDirty) refs.markDirty(); });
918
+ node.addEventListener("input", () => { if (refs && refs.markDirty) refs.markDirty(); });
919
+ });
920
+
921
+ row.appendChild(stepArea);
922
+ row.appendChild(dataArea);
923
+ row.appendChild(expectedArea);
924
+ row.appendChild(actualArea);
925
+ row.appendChild(statusSel);
926
+ row.appendChild(noteArea);
927
+ return row;
928
+ }
929
+
930
+ /**
931
+ * Outcome section: shows derived outcome + count + manual-override
932
+ * picker. Override values: "auto" (= use derived) + the five outcome
933
+ * enum values. When the user picks anything other than auto, the
934
+ * "manual override" hint becomes visible.
935
+ */
936
+ function buildOutcomeSection(ticket, refs) {
937
+ const wrap = el("section", { class: "tm-exec-outcome" });
938
+ wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "outcomeOverride");
939
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Outcome" }));
940
+
941
+ const derivedRow = el("div", { class: "tm-exec-outcome-derived" });
942
+ const steps = ticket.executionSteps || [];
943
+ const derived = (typeof core.deriveOutcome === "function")
944
+ ? core.deriveOutcome(steps) : "pending";
945
+ const passedCount = steps.filter(s => s && s.status === "passed").length;
946
+ derivedRow.appendChild(el("span", { class: "tm-exec-outcome-derived-label", text: "Derived:" }));
947
+ const derivedPill = el("span", {
948
+ class: "tm-exec-outcome-pill",
949
+ text: derived + " (" + passedCount + "/" + steps.length + " passed)"
950
+ });
951
+ derivedPill.dataset.outcome = derived;
952
+ derivedRow.appendChild(derivedPill);
953
+ wrap.appendChild(derivedRow);
954
+
955
+ const overrideRow = el("div", { class: "tm-exec-outcome-override-row" });
956
+ overrideRow.appendChild(el("span", { class: "tm-exec-outcome-derived-label", text: "Override:" }));
957
+ const sel = el("select", { class: "tm-input tm-exec-outcome-override" });
958
+ const cur = ticket.outcomeOverride || "auto";
959
+ const OPTIONS = ["auto"].concat(core.TEST_EXEC_OUTCOMES
960
+ || ["pending", "passed", "failed", "blocked", "skipped"]);
961
+ for (const v of OPTIONS) {
962
+ const opt = el("option", { value: v, text: v });
963
+ if (v === cur) opt.setAttribute("selected", "selected");
964
+ sel.appendChild(opt);
965
+ }
966
+ overrideRow.appendChild(sel);
967
+ const hint = el("span", { class: "tm-exec-outcome-override-hint", text: "manual override" });
968
+ if (cur === "auto") hint.style.display = "none";
969
+ overrideRow.appendChild(hint);
970
+ sel.addEventListener("change", () => {
971
+ hint.style.display = sel.value === "auto" ? "none" : "";
972
+ if (refs && refs.markDirty) refs.markDirty();
973
+ });
974
+ wrap.appendChild(overrideRow);
975
+ return wrap;
976
+ }
977
+
978
+ // ---- SM-48: Links-Section ------------------------------------------
979
+ //
980
+ // Two-pane editor at the bottom of the modal:
981
+ // Forward links — outgoing from this ticket. Editable (× removes, +
982
+ // opens add-link dialog).
983
+ // Backward links — incoming from any ticket whose links[] references
984
+ // this ticket. Read-only — flip them at the source ticket instead.
985
+ //
986
+ // Each row shows: link-type pill (color from project.linkTypes.color),
987
+ // inverse/forward label, target ticket-key + title. Click on the
988
+ // ticket-key portion opens that ticket's modal (best effort — if the
989
+ // target was just deleted the click is a no-op).
990
+ //
991
+ // Live-sync: `data-sm-sync-key=links` marks the host so the syncField
992
+ // pulse helpers + the existing observer rebuild it on external commits.
993
+
994
+ /**
995
+ * Resolve a linkType id to its catalogue entry. Returns a synthetic
996
+ * fallback for unknown ids so the UI keeps rendering (linkType deleted
997
+ * after the link was created — see SM-47 confirm).
998
+ */
999
+ function lookupLinkType(project, linkTypeId) {
1000
+ const list = (project && project.linkTypes) || [];
1001
+ const hit = list.find(lt => lt.id === linkTypeId);
1002
+ if (hit) return hit;
1003
+ return { id: linkTypeId, label: linkTypeId, inverseLabel: linkTypeId, semantic: "freeform" };
1004
+ }
1005
+
1006
+ /**
1007
+ * Find tickets that link TO `targetTicketId` — backward edges.
1008
+ * Pure: returns `[ { sourceTicket, link } ]`. Excludes the target itself
1009
+ * defensively (a ticket should never link to itself, but core.validateLink
1010
+ * already enforces that).
1011
+ */
1012
+ function findBackwardLinks(snapshot, targetTicketId) {
1013
+ const out = [];
1014
+ const tickets = (snapshot && snapshot.tickets) || [];
1015
+ for (const t of tickets) {
1016
+ if (!t.links || !t.links.length || t.id === targetTicketId) continue;
1017
+ for (const l of t.links) {
1018
+ if (l.targetTicketId === targetTicketId) out.push({ sourceTicket: t, link: l });
1019
+ }
1020
+ }
1021
+ return out;
1022
+ }
1023
+
1024
+ function buildLinkPill(linkType, kind) {
1025
+ const text = kind === "backward" ? (linkType.inverseLabel || linkType.label) : linkType.label;
1026
+ const pill = el("span", { class: "tm-link-pill tm-link-pill-" + kind, text: text });
1027
+ if (linkType.color) pill.style.backgroundColor = linkType.color;
1028
+ return pill;
1029
+ }
1030
+
1031
+ function buildTargetRef(targetTicket) {
1032
+ const span = el("span", { class: "tm-link-target" });
1033
+ if (!targetTicket) {
1034
+ span.appendChild(el("span", { class: "tm-link-key tm-link-missing", text: "(deleted)" }));
1035
+ return span;
1036
+ }
1037
+ span.appendChild(el("span", { class: "tm-link-key", text: targetTicket.ticketKey || targetTicket.id }));
1038
+ span.appendChild(el("span", { class: "tm-link-title", text: " " + (targetTicket.title || "") }));
1039
+ return span;
1040
+ }
1041
+
1042
+ function buildLinksSection(ticket, snapshot, ctx) {
1043
+ const wrap = el("section", { class: "tm-links" });
1044
+ wrap.setAttribute(CONSTANTS.LIVE_SYNC_ATTR, "links");
1045
+ // Stash ctx on the wrap so live-sync rebuilds re-wire +/× handlers.
1046
+ wrap.__tmCtx = ctx || null;
1047
+ renderLinksSectionInto(wrap, ticket, snapshot, ctx);
1048
+ return wrap;
1049
+ }
1050
+
1051
+ function renderLinksSectionInto(wrap, ticket, snapshot, ctx) {
1052
+ while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
1053
+ const project = (snapshot && snapshot.project) || {};
1054
+ const tickets = (snapshot && snapshot.tickets) || [];
1055
+ const findTicket = (id) => tickets.find(t => t.id === id) || null;
1056
+
1057
+ // SM-70: In create-mode (ticket.id == null) the inline add-form pushes
1058
+ // links onto ticket.links directly (no store), so createOnSave can pass
1059
+ // them as `body.links`. Re-render is triggered via this closure.
1060
+ const isPending = !ticket.id;
1061
+ const rerender = () => renderLinksSectionInto(wrap, ticket, snapshot, ctx);
1062
+
1063
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Links" }));
1064
+
1065
+ // Forward.
1066
+ const fwdHost = el("div", { class: "tm-links-list tm-links-forward" });
1067
+ const forwardLinks = (ticket.links || []);
1068
+ if (!forwardLinks.length) {
1069
+ fwdHost.appendChild(el("div", { class: "tm-links-empty", text: "No outgoing links." }));
1070
+ } else {
1071
+ for (const link of forwardLinks) {
1072
+ const lt = lookupLinkType(project, link.linkTypeId);
1073
+ const tgt = findTicket(link.targetTicketId);
1074
+ const row = el("div", { class: "tm-link-row", dataset: { linkId: link.id } });
1075
+ row.appendChild(buildLinkPill(lt, "forward"));
1076
+ row.appendChild(buildTargetRef(tgt));
1077
+ const del = el("button", { class: "tm-link-delete", title: "Remove link", text: "×" });
1078
+ del.addEventListener("click", (ev) => {
1079
+ ev.stopPropagation();
1080
+ if (isPending) {
1081
+ // Mutate the in-memory draft and re-render.
1082
+ ticket.links = (ticket.links || []).filter(l => l.id !== link.id);
1083
+ rerender();
1084
+ return;
1085
+ }
1086
+ if (!ctx || !ctx.store || !ticket.id) return;
1087
+ try {
1088
+ ctx.store.removeLink(ticket.id, link.id, LOCAL_ACTOR);
1089
+ } catch (err) {
1090
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "remove failed", { kind: "error" });
1091
+ }
1092
+ });
1093
+ row.appendChild(del);
1094
+ fwdHost.appendChild(row);
1095
+ }
1096
+ }
1097
+ wrap.appendChild(fwdHost);
1098
+
1099
+ // Inline add-form. Edit-mode → store.addLink. Create-mode → push onto
1100
+ // ticket.links (pending) and call rerender() so the new row shows up.
1101
+ wrap.appendChild(buildInlineAddLinkForm(ticket.id, snapshot, ctx, {
1102
+ pendingTicket: isPending ? ticket : null,
1103
+ onPendingChange: rerender
1104
+ }));
1105
+
1106
+ // Backward.
1107
+ const bwdHost = el("div", { class: "tm-links-list tm-links-backward" });
1108
+ bwdHost.appendChild(el("h5", { class: "tm-links-subhead", text: "Referenced by" }));
1109
+ const backward = ticket.id ? findBackwardLinks(snapshot, ticket.id) : [];
1110
+ if (!backward.length) {
1111
+ bwdHost.appendChild(el("div", { class: "tm-links-empty", text: "Nothing references this ticket." }));
1112
+ } else {
1113
+ for (const { sourceTicket, link } of backward) {
1114
+ const lt = lookupLinkType(project, link.linkTypeId);
1115
+ const row = el("div", { class: "tm-link-row tm-link-row-readonly" });
1116
+ row.appendChild(buildLinkPill(lt, "backward"));
1117
+ row.appendChild(buildTargetRef(sourceTicket));
1118
+ bwdHost.appendChild(row);
1119
+ }
1120
+ }
1121
+ wrap.appendChild(bwdHost);
1122
+ }
1123
+
1124
+ /**
1125
+ * Inline Add-Link form: lives at the bottom of the Links section in the
1126
+ * ticket-detail modal. Replaces the earlier popup-modal flow — the user
1127
+ * must never lose the ticket they're editing.
1128
+ *
1129
+ * Collapsed state: a "+ Add link" button.
1130
+ * Expanded state: Type-Select + searchable Target-Combobox (filter input +
1131
+ * popover list) + Add-button + Cancel-button. Submit calls store.addLink
1132
+ * and re-collapses the form so the user sees the new row in the list
1133
+ * above (live-sync rebuild handles that).
1134
+ */
1135
+ function buildInlineAddLinkForm(sourceTicketId, snapshot, ctx, pendingOpts) {
1136
+ const wrap = el("div", { class: "tm-link-add-wrap" });
1137
+ const trigger = el("button", { class: "btn tm-link-add", text: "+ Add link", type: "button" });
1138
+ const form = el("div", { class: "tm-link-add-form", style: { display: "none" } });
1139
+ wrap.appendChild(trigger);
1140
+ wrap.appendChild(form);
1141
+
1142
+ // SM-70: pending-mode. When sourceTicketId is null (Create), the form
1143
+ // mutates pendingOpts.pendingTicket.links and re-renders the section.
1144
+ const pendingTicket = pendingOpts && pendingOpts.pendingTicket;
1145
+ const onPendingChange = (pendingOpts && pendingOpts.onPendingChange) || (() => {});
1146
+
1147
+ const project = (snapshot && snapshot.project) || {};
1148
+ const allTickets = (snapshot && snapshot.tickets || []).filter(t => !t.isDeleted && t.id !== sourceTicketId);
1149
+ const linkTypes = (project.linkTypes && project.linkTypes.length) ? project.linkTypes : [];
1150
+
1151
+ // Track the selected target via a closure variable — the combobox keeps
1152
+ // the display string ("T-2 — Story-B") in the input but the underlying
1153
+ // id is what we send to addLink.
1154
+ let selectedTargetId = null;
1155
+
1156
+ // SM-70: pre-select the link-type that matches the source ticket's
1157
+ // semantic role. test-definition → 'tests'; test-execution → 'executes'.
1158
+ // Falls back to the first defined linkType.
1159
+ const sourceTicket = pendingTicket
1160
+ || (sourceTicketId ? allTickets.find(t => t.id === sourceTicketId) ||
1161
+ (snapshot && snapshot.tickets || []).find(t => t.id === sourceTicketId) : null);
1162
+ const sourceType = (sourceTicket && sourceTicket.type) || null;
1163
+ const preferredLinkTypeId =
1164
+ sourceType === "test-definition" && linkTypes.some(lt => lt.id === "tests") ? "tests"
1165
+ : sourceType === "test-execution" && linkTypes.some(lt => lt.id === "executes") ? "executes"
1166
+ : (linkTypes[0] && linkTypes[0].id);
1167
+
1168
+ // Type row.
1169
+ const typeRow = el("div", { class: "tm-link-add-row" });
1170
+ typeRow.appendChild(el("label", { class: "tm-label", text: "Type" }));
1171
+ const typeSel = el("select", { class: "tm-add-link-type" });
1172
+ for (const lt of linkTypes) {
1173
+ typeSel.appendChild(el("option", { value: lt.id, text: lt.label }));
1174
+ }
1175
+ if (preferredLinkTypeId) typeSel.value = preferredLinkTypeId;
1176
+ typeRow.appendChild(typeSel);
1177
+ form.appendChild(typeRow);
1178
+
1179
+ // Target combobox row: input + popover list of matches.
1180
+ const tgtRow = el("div", { class: "tm-link-add-row" });
1181
+ tgtRow.appendChild(el("label", { class: "tm-label", text: "Target" }));
1182
+ const comboWrap = el("div", { class: "tm-link-combo" });
1183
+ const filterInput = el("input", { type: "text", class: "tm-add-link-filter",
1184
+ placeholder: "Ticket key or title…", autocomplete: "off" });
1185
+ const popover = el("div", { class: "tm-link-combo-popover", style: { display: "none" } });
1186
+ comboWrap.appendChild(filterInput);
1187
+ comboWrap.appendChild(popover);
1188
+ tgtRow.appendChild(comboWrap);
1189
+ form.appendChild(tgtRow);
1190
+
1191
+ function refillPopover(term) {
1192
+ popover.innerHTML = "";
1193
+ const q = (term || "").trim().toLowerCase();
1194
+ let matched = 0;
1195
+ for (const t of allTickets) {
1196
+ const display = (t.ticketKey || t.id) + " — " + (t.title || "");
1197
+ if (q && display.toLowerCase().indexOf(q) < 0) continue;
1198
+ const opt = el("div", { class: "tm-link-combo-option", dataset: { ticketId: t.id }, text: display });
1199
+ opt.addEventListener("mousedown", (ev) => {
1200
+ // mousedown (not click) so it fires before input.blur hides the popover.
1201
+ ev.preventDefault();
1202
+ selectedTargetId = t.id;
1203
+ filterInput.value = display;
1204
+ popover.style.display = "none";
1205
+ });
1206
+ popover.appendChild(opt);
1207
+ matched++;
1208
+ if (matched >= 30) break; // cap rendered list — keep popover usable
1209
+ }
1210
+ popover.style.display = matched > 0 ? "block" : "none";
1211
+ }
1212
+ filterInput.addEventListener("focus", () => refillPopover(filterInput.value));
1213
+ filterInput.addEventListener("input", () => {
1214
+ selectedTargetId = null; // typing invalidates the prior selection
1215
+ refillPopover(filterInput.value);
1216
+ });
1217
+ filterInput.addEventListener("blur", () => {
1218
+ // Defer-hide so the option's mousedown can fire first.
1219
+ setTimeout(() => { popover.style.display = "none"; }, 80);
1220
+ });
1221
+
1222
+ // Action buttons (inline, not separate modal).
1223
+ const actions = el("div", { class: "tm-link-add-actions" });
1224
+ const cancelBtn = el("button", { class: "btn tm-link-add-cancel", text: "Cancel", type: "button" });
1225
+ const addBtn = el("button", { class: "btn tm-link-add-submit", text: "Add", type: "button" });
1226
+ actions.appendChild(cancelBtn);
1227
+ actions.appendChild(addBtn);
1228
+ form.appendChild(actions);
1229
+
1230
+ function reset() {
1231
+ filterInput.value = "";
1232
+ selectedTargetId = null;
1233
+ popover.style.display = "none";
1234
+ if (preferredLinkTypeId) typeSel.value = preferredLinkTypeId;
1235
+ else if (linkTypes.length) typeSel.value = linkTypes[0].id;
1236
+ }
1237
+ function collapse() {
1238
+ form.style.display = "none";
1239
+ trigger.style.display = "";
1240
+ reset();
1241
+ }
1242
+ function expand() {
1243
+ trigger.style.display = "none";
1244
+ form.style.display = "";
1245
+ reset();
1246
+ // Defer focus until the form is in the DOM.
1247
+ setTimeout(() => filterInput.focus(), 0);
1248
+ }
1249
+
1250
+ trigger.addEventListener("click", (ev) => { ev.stopPropagation(); expand(); });
1251
+ cancelBtn.addEventListener("click", (ev) => { ev.stopPropagation(); collapse(); });
1252
+ addBtn.addEventListener("click", (ev) => {
1253
+ ev.stopPropagation();
1254
+ const linkTypeId = typeSel.value;
1255
+ if (!linkTypeId) {
1256
+ if (ctx && ctx.flashStatus) ctx.flashStatus("pick a link type", { kind: "error" });
1257
+ return;
1258
+ }
1259
+ if (!selectedTargetId) {
1260
+ if (ctx && ctx.flashStatus) ctx.flashStatus("pick a target ticket from the list", { kind: "error" });
1261
+ return;
1262
+ }
1263
+ // SM-70: pending-mode pushes onto the draft ticket; the rerender call
1264
+ // builds a fresh forward-list including the new row.
1265
+ if (pendingTicket) {
1266
+ const pendingId = "ln-pending-" + Math.random().toString(36).slice(2, 10);
1267
+ pendingTicket.links = pendingTicket.links || [];
1268
+ // Avoid duplicates (same linkTypeId + targetTicketId).
1269
+ const dup = pendingTicket.links.some(l =>
1270
+ l.linkTypeId === linkTypeId && l.targetTicketId === selectedTargetId);
1271
+ if (dup) {
1272
+ if (ctx && ctx.flashStatus) ctx.flashStatus("link already added", { kind: "error" });
1273
+ return;
1274
+ }
1275
+ pendingTicket.links.push({
1276
+ id: pendingId,
1277
+ linkTypeId: linkTypeId,
1278
+ targetTicketId: selectedTargetId,
1279
+ createdAt: Date.now()
1280
+ });
1281
+ collapse();
1282
+ onPendingChange();
1283
+ return;
1284
+ }
1285
+ try {
1286
+ ctx.store.addLink(sourceTicketId, { linkTypeId, targetTicketId: selectedTargetId }, LOCAL_ACTOR);
1287
+ collapse();
1288
+ } catch (err) {
1289
+ if (ctx && ctx.flashStatus) ctx.flashStatus(err.message || "add failed", { kind: "error" });
1290
+ }
1291
+ });
1292
+
1293
+ return wrap;
1294
+ }
1295
+
1296
+ // ---- Field-Population (Live-Sync) ------------------------------------
1297
+
1298
+ /**
1299
+ * Update a single sync-keyed field's value from the latest ticket,
1300
+ * UNLESS the field is currently focused (user is typing). Skipping
1301
+ * focused fields prevents stomp.
1302
+ */
1303
+ function syncFieldFromTicket(modal, ticket, snapshot) {
1304
+ const focused = document.activeElement;
1305
+ function shouldSkip(elNode) { return elNode === focused; }
1306
+
1307
+ function pick(key) { return modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="' + key + '"]'); }
1308
+
1309
+ const titleEl = pick("title");
1310
+ if (titleEl && !shouldSkip(titleEl) && titleEl.value !== ticket.title) titleEl.value = ticket.title || "";
1311
+
1312
+ const typeEl = pick("type");
1313
+ if (typeEl && !shouldSkip(typeEl) && typeEl.value !== ticket.type) typeEl.value = ticket.type;
1314
+
1315
+ let statusEl = pick("status");
1316
+ if (statusEl && !shouldSkip(statusEl)) {
1317
+ // SM-238: if the ticket's type no longer matches the rendered control
1318
+ // (epic ⇄ work item, e.g. after an in-editor type change), swap it in
1319
+ // place — the editable <select> for work items, the read-only derived
1320
+ // pill for epics. This keeps the modal AND the full-page editor consistent
1321
+ // without each surface wiring its own type-change handler.
1322
+ const isEpic = ticket.type === "epic";
1323
+ const isPill = !!(statusEl.classList && statusEl.classList.contains("tm-status-derived"));
1324
+ if (isEpic !== isPill && statusEl.closest) {
1325
+ const row = statusEl.closest(".tm-row");
1326
+ if (row && row.parentNode) {
1327
+ const wf = (typeof core.getWorkflowForType === "function")
1328
+ ? core.getWorkflowForType(snapshot && snapshot.project, ticket.type) : null;
1329
+ const statuses = wf ? wf.statuses.map(s => (typeof s === "string" ? s : s.id)) : [ticket.status];
1330
+ row.parentNode.replaceChild(buildStatusField(ticket, statuses, snapshot), row);
1331
+ statusEl = pick("status");
1332
+ }
1333
+ }
1334
+ }
1335
+ if (statusEl && !shouldSkip(statusEl)) {
1336
+ // SM-238: the epic derived-status pill is a span, not a <select> — update
1337
+ // its textContent + recompute the breakdown from the fresh snapshot (a
1338
+ // child status change shifts the counts without touching the epic itself).
1339
+ if (statusEl.classList && statusEl.classList.contains("tm-status-derived")) {
1340
+ if (statusEl.textContent !== ticket.status) statusEl.textContent = ticket.status;
1341
+ statusEl.dataset.status = ticket.status;
1342
+ const bd = pick("status_breakdown");
1343
+ if (bd && snapshot && typeof core.epicChildStats === "function") {
1344
+ bd.textContent = epicStatusBreakdownText(core.epicChildStats(snapshot, ticket.id));
1345
+ }
1346
+ } else if (statusEl.value !== ticket.status) {
1347
+ statusEl.value = ticket.status;
1348
+ }
1349
+ }
1350
+
1351
+ const releaseEl = pick("position.releaseId");
1352
+ const rid = (ticket.position && ticket.position.releaseId) || "";
1353
+ if (releaseEl && !shouldSkip(releaseEl) && releaseEl.value !== rid) releaseEl.value = rid;
1354
+
1355
+ const psEl = pick("position.processStepId");
1356
+ const psid = (ticket.position && ticket.position.processStepId) || "";
1357
+ if (psEl && !shouldSkip(psEl) && psEl.value !== psid) psEl.value = psid;
1358
+
1359
+ // SM-152: the container epic lives in a `contains`-link (SM-52 —
1360
+ // ticket.position.epicId is always null). Reading position.epicId here set
1361
+ // the dropdown to "— none —" on every external commit and a follow-up save
1362
+ // then dropped the contains-link, silently detaching the story. Resolve via
1363
+ // containerEpicIdOf and re-run inheritance (dispatch change) so the
1364
+ // Release/ProcessStep dropdowns track the container.
1365
+ const epicEl = pick("position.epicId");
1366
+ if (epicEl && !shouldSkip(epicEl)) {
1367
+ const containerId = (snapshot && typeof core.containerEpicIdOf === "function")
1368
+ ? (core.containerEpicIdOf(snapshot, ticket.id) || "")
1369
+ : ((ticket.position && ticket.position.epicId) || "");
1370
+ if (epicEl.value !== containerId) {
1371
+ epicEl.value = containerId;
1372
+ // The change listener installed in buildPositionFields re-applies
1373
+ // inheritance using its own epicById closure.
1374
+ try { epicEl.dispatchEvent(new Event("change")); } catch (_) { /* ignore */ }
1375
+ }
1376
+ }
1377
+
1378
+ const descEl = pick("description");
1379
+ if (descEl && !shouldSkip(descEl) && readFieldText(descEl) !== (ticket.description || "")) {
1380
+ writeFieldText(descEl, ticket.description || "");
1381
+ // SM-157: keep the inline-confirm baseline in step with the external value.
1382
+ if (typeof descEl.__tfSetBaseline === "function") descEl.__tfSetBaseline(ticket.description || "");
1383
+ }
1384
+
1385
+ const labelsEl = pick("labels");
1386
+ const labelsValue = (ticket.labels || []).join(", ");
1387
+ if (labelsEl && !shouldSkip(labelsEl) && labelsEl.value !== labelsValue) labelsEl.value = labelsValue;
1388
+
1389
+ // SM-152: Acceptance Criteria — reflect external edits, but skip the
1390
+ // rebuild while the user is typing inside the section (focus-guard, same
1391
+ // as prerequisites/steps). Pairs with the buildUpdatePatch diff-guard so
1392
+ // an unrelated local save can't clobber a freshly-synced AC list.
1393
+ const acWrap = pick("acceptanceCriteria");
1394
+ if (acWrap && !(document.activeElement && acWrap.contains(document.activeElement))) {
1395
+ rebuildAcInPlace(acWrap, ticket.acceptanceCriteria || []);
1396
+ }
1397
+
1398
+ // DoR / DoD checklists: rebuild fully (state changes can be sparse). The
1399
+ // checkboxes never receive keyboard focus during typing (a click toggles
1400
+ // them), so we don't need a focus-aware diff here.
1401
+ const dorWrap = pick("definitionOfReady");
1402
+ const dodWrap = pick("definitionOfDone");
1403
+ if (dorWrap) rebuildChecklistInPlace(dorWrap, (ticket.definitionOfReady || { items: [] }).items, "DoR");
1404
+ if (dodWrap) rebuildChecklistInPlace(dodWrap, (ticket.definitionOfDone || { items: [] }).items, "DoD");
1405
+
1406
+ // SM-48: Links rebuild. Same focus-guard semantics as DoR/DoD — but
1407
+ // the Links section has no editable text inputs, so a flat rebuild
1408
+ // is safe even when the section is the active area (the +Add modal
1409
+ // is a separate overlay; closing it triggers the rebuild). Skip if
1410
+ // a target lookup needs `snapshot.tickets` and we have none.
1411
+ const linksWrap = pick("links");
1412
+ if (linksWrap && snapshot) {
1413
+ // ctx is not in scope here; the existing buildLinksSection accepts
1414
+ // an undefined ctx for read-only render — × and + buttons re-attach
1415
+ // only when called from the modal's own factory (with ctx). For the
1416
+ // live-sync path, render-only is enough: the user reopens the modal
1417
+ // for any local action and the modal carries ctx itself.
1418
+ renderLinksSectionInto(linksWrap, ticket, snapshot, linksWrap.__tmCtx || null);
1419
+ }
1420
+
1421
+ // SM-54: Prerequisites + Steps. Rebuild only if no input inside the
1422
+ // section currently has focus (don't stomp a typing user). The rebuild
1423
+ // re-uses buildPrereqRow / buildTestStepRow so the new rows get fresh
1424
+ // event listeners + dnd hooks.
1425
+ const prereqsWrap = pick("prerequisites");
1426
+ if (prereqsWrap && !(document.activeElement && prereqsWrap.contains(document.activeElement))) {
1427
+ rebuildPrereqsInPlace(prereqsWrap, ticket.prerequisites || []);
1428
+ }
1429
+ const stepsWrap = pick("steps");
1430
+ if (stepsWrap && !(document.activeElement && stepsWrap.contains(document.activeElement))) {
1431
+ rebuildTestStepsInPlace(stepsWrap, ticket.steps || []);
1432
+ }
1433
+ }
1434
+
1435
+ // SM-152: live-sync rebuild of the Acceptance-Criteria list. Mirrors
1436
+ // rebuildPrereqsInPlace: drop + rebuild rows with a no-op markDirty (the
1437
+ // "+ Criterion" trigger is omitted — the user reopens the modal to add).
1438
+ function rebuildAcInPlace(wrap, items) {
1439
+ while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
1440
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Acceptance Criteria" }));
1441
+ const list = el("div", { class: "tm-ac-list" });
1442
+ const refs = { markDirty: () => {} };
1443
+ for (const ac of items) list.appendChild(buildAcRow(ac, refs));
1444
+ wrap.appendChild(list);
1445
+ }
1446
+
1447
+ function rebuildPrereqsInPlace(wrap, items) {
1448
+ while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
1449
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Prerequisites" }));
1450
+ const list = el("div", { class: "tm-prereq-list" });
1451
+ const refs = { markDirty: () => {} };
1452
+ for (const item of items) list.appendChild(buildPrereqRow(item, refs));
1453
+ wrap.appendChild(list);
1454
+ // Note: live-sync rebuild skips the "+ Add" trigger to avoid wiring two
1455
+ // refs.markDirty into the same ticket-modal — the user reopens the modal
1456
+ // to add new items, which is consistent with how DoR/DoD live-sync works.
1457
+ }
1458
+
1459
+ function rebuildTestStepsInPlace(wrap, items) {
1460
+ while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
1461
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Steps" }));
1462
+ // SM-152: header must mirror buildStepsSection's 6-cell grid (grip + step
1463
+ // + data + expected + insert + remove) so the labels sit above the right
1464
+ // columns after an external commit — the old 3-cell head misaligned them.
1465
+ const tableHead = el("div", { class: "tm-test-step-head" }, [
1466
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-grip" }),
1467
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-step", text: "Step" }),
1468
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-data", text: "Data" }),
1469
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-expected", text: "Expected Result" }),
1470
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-insert" }),
1471
+ el("span", { class: "tm-test-step-head-cell tm-test-step-head-remove" })
1472
+ ]);
1473
+ wrap.appendChild(tableHead);
1474
+ const list = el("div", { class: "tm-test-step-list" });
1475
+ const refs = { markDirty: () => {} };
1476
+ for (const item of items) list.appendChild(buildTestStepRow(item, refs, list));
1477
+ wrap.appendChild(list);
1478
+ }
1479
+
1480
+ /**
1481
+ * SM-5: Pulse every sync-keyed field that differs between prev and next
1482
+ * ticket. Called only when the upstream subscribe sees `reason ===
1483
+ * "applyRemote"` (external edit — MCP or another browser); local commits
1484
+ * skip this because the user already has direct feedback from typing.
1485
+ * Reuses the `.sm-card-flash` keyframe (SM-20 widened that selector to
1486
+ * cover anything that gets the class).
1487
+ *
1488
+ * Returns the set of keys that pulsed, for tests + telemetry.
1489
+ */
1490
+ function diffSyncFieldKeys(prev, next) {
1491
+ const out = new Set();
1492
+ if (!prev || !next) return out;
1493
+ const simpleKeys = ["title", "type", "status", "description"];
1494
+ for (const k of simpleKeys) {
1495
+ if ((prev[k] || "") !== (next[k] || "")) out.add(k);
1496
+ }
1497
+ const prevPos = prev.position || {};
1498
+ const nextPos = next.position || {};
1499
+ if ((prevPos.releaseId || "") !== (nextPos.releaseId || "")) out.add("position.releaseId");
1500
+ if ((prevPos.processStepId || "") !== (nextPos.processStepId || "")) out.add("position.processStepId");
1501
+ if ((prevPos.epicId || "") !== (nextPos.epicId || "")) out.add("position.epicId");
1502
+ const prevLabels = (prev.labels || []).join(",");
1503
+ const nextLabels = (next.labels || []).join(",");
1504
+ if (prevLabels !== nextLabels) out.add("labels");
1505
+ if (JSON.stringify(prev.acceptanceCriteria || []) !== JSON.stringify(next.acceptanceCriteria || [])) {
1506
+ out.add("acceptanceCriteria");
1507
+ }
1508
+ const prevDor = JSON.stringify((prev.definitionOfReady && prev.definitionOfReady.items) || []);
1509
+ const nextDor = JSON.stringify((next.definitionOfReady && next.definitionOfReady.items) || []);
1510
+ if (prevDor !== nextDor) out.add("definitionOfReady");
1511
+ const prevDod = JSON.stringify((prev.definitionOfDone && prev.definitionOfDone.items) || []);
1512
+ const nextDod = JSON.stringify((next.definitionOfDone && next.definitionOfDone.items) || []);
1513
+ if (prevDod !== nextDod) out.add("definitionOfDone");
1514
+ // SM-48: pulse the Links section when forward links on this ticket change.
1515
+ // Backward-link changes (on other tickets' links[]) don't pulse — the
1516
+ // diff helper only sees this ticket's prev/next.
1517
+ const prevLinks = JSON.stringify(prev.links || []);
1518
+ const nextLinks = JSON.stringify(next.links || []);
1519
+ if (prevLinks !== nextLinks) out.add("links");
1520
+ // SM-54: pulse Prereqs / Steps sections when those fields change.
1521
+ if (JSON.stringify(prev.prerequisites || []) !== JSON.stringify(next.prerequisites || [])) {
1522
+ out.add("prerequisites");
1523
+ }
1524
+ if (JSON.stringify(prev.steps || []) !== JSON.stringify(next.steps || [])) {
1525
+ out.add("steps");
1526
+ }
1527
+ return out;
1528
+ }
1529
+
1530
+ function pulseChangedFields(modal, prevTicket, nextTicket) {
1531
+ const keys = diffSyncFieldKeys(prevTicket, nextTicket);
1532
+ for (const key of keys) {
1533
+ // Multiple matches are possible (theoretically) — pulse all of them.
1534
+ const nodes = modal.querySelectorAll('[' + CONSTANTS.LIVE_SYNC_ATTR + '="' + key + '"]');
1535
+ for (const node of nodes) {
1536
+ // Restart-the-animation pattern: remove → force reflow → re-add.
1537
+ node.classList.remove(CONSTANTS.FIELD_PULSE_CLASS);
1538
+ // eslint-disable-next-line no-unused-expressions
1539
+ void node.offsetWidth;
1540
+ node.classList.add(CONSTANTS.FIELD_PULSE_CLASS);
1541
+ setTimeout(function () { node.classList.remove(CONSTANTS.FIELD_PULSE_CLASS); }, CONSTANTS.FIELD_PULSE_MS);
1542
+ }
1543
+ }
1544
+ return keys;
1545
+ }
1546
+
1547
+ /**
1548
+ * E19-followup: Live-sync für DoR/DoD ist jetzt anders — die Section ist
1549
+ * ein voll editierbarer Editor mit Inputs. Wenn der User gerade darin
1550
+ * tippt (irgendein Input mit Focus), nicht stompen. Sonst Editor neu
1551
+ * rendern (Items kommen vom WS-Push). Focus-Guard analog Title-Input.
1552
+ */
1553
+ function rebuildChecklistInPlace(wrap, items, kind) {
1554
+ // Focus-Guard: wenn ein Input innerhalb der Section gerade Focus hat,
1555
+ // überspring den Rebuild — der User tippt gerade.
1556
+ const focused = document.activeElement;
1557
+ if (focused && wrap.contains(focused)) return;
1558
+ while (wrap.firstChild) wrap.removeChild(wrap.firstChild);
1559
+ wrap.appendChild(el("h4", { class: "tm-section-title", text: "Definition of " + (kind === "DoR" ? "Ready" : "Done") }));
1560
+ const list = el("div", { class: "tm-checklist-list" });
1561
+ const refs = { markDirty: () => {} };
1562
+ for (const item of items) list.appendChild(buildCheckRow(item, refs));
1563
+ wrap.appendChild(list);
1564
+ }
1565
+
1566
+ // ---- Diff: form-state → patch ---------------------------------------
1567
+
1568
+ function collectFormState(modal) {
1569
+ function pick(key) { return modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="' + key + '"]'); }
1570
+ // readFieldText handles both form controls (.value) and the contentEditable
1571
+ // description block (innerText/textContent). SM-119.
1572
+ function val(key) { const n = pick(key); return n ? readFieldText(n) : undefined; }
1573
+ function labelsArr() {
1574
+ const v = val("labels") || "";
1575
+ return v.split(",").map(s => s.trim()).filter(Boolean);
1576
+ }
1577
+ const acRows = Array.from(modal.querySelectorAll(".tm-ac-row"));
1578
+ const acceptanceCriteria = acRows.map(row => {
1579
+ const checkbox = row.querySelector('input[type="checkbox"]');
1580
+ const text = row.querySelector('input[type="text"]');
1581
+ return { id: row.dataset.acId, text: text.value, completed: !!checkbox.checked };
1582
+ });
1583
+ return {
1584
+ title: val("title"),
1585
+ type: val("type"),
1586
+ status: val("status"),
1587
+ description: val("description"),
1588
+ position: {
1589
+ releaseId: val("position.releaseId") || null,
1590
+ processStepId: val("position.processStepId") || null,
1591
+ epicId: val("position.epicId") || null
1592
+ },
1593
+ acceptanceCriteria: acceptanceCriteria,
1594
+ labels: labelsArr(),
1595
+ definitionOfReady: { items: collectChecklistItems(modal, "definitionOfReady") },
1596
+ definitionOfDone: { items: collectChecklistItems(modal, "definitionOfDone") },
1597
+ // SM-54: test-definition sections. `null` (= "not rendered") signals
1598
+ // buildUpdatePatch to leave the existing values alone; an array means
1599
+ // "this is the new full list".
1600
+ prerequisites: collectPrerequisites(modal),
1601
+ steps: collectTestSteps(modal),
1602
+ // SM-57: test-execution sections. Same null-vs-array convention.
1603
+ // outcomeOverride is `undefined` when the section isn't rendered;
1604
+ // null when the user picked "auto"; a string otherwise.
1605
+ executionSteps: collectExecutionSteps(modal),
1606
+ outcomeOverride: collectOutcomeOverride(modal)
1607
+ };
1608
+ }
1609
+
1610
+ /**
1611
+ * SM-57: Read execution-step rows. Returns `null` if the section isn't
1612
+ * rendered (wrong type / gate off). Steps are NEVER added or removed
1613
+ * here — we just read whatever's in the DOM. The frozen step/data/
1614
+ * expectedResult fields are sent back as-is (disabled inputs still carry
1615
+ * their value).
1616
+ */
1617
+ function collectExecutionSteps(modal) {
1618
+ const wrap = modal.querySelector('section.tm-exec-steps[' + CONSTANTS.LIVE_SYNC_ATTR + '="executionSteps"]');
1619
+ if (!wrap) return null;
1620
+ const rows = wrap.querySelectorAll(".tm-exec-step-row");
1621
+ const out = [];
1622
+ rows.forEach((row) => {
1623
+ const stepEl = row.querySelector(".tm-exec-step-step");
1624
+ const dataEl = row.querySelector(".tm-exec-step-data");
1625
+ const expectedEl = row.querySelector(".tm-exec-step-expected");
1626
+ const actualEl = row.querySelector(".tm-exec-step-actual");
1627
+ const statusEl = row.querySelector(".tm-exec-step-status");
1628
+ const noteEl = row.querySelector(".tm-exec-step-note");
1629
+ const noteVal = (noteEl && noteEl.value || "").trim();
1630
+ const item = {
1631
+ id: row.dataset.itemId,
1632
+ stepId: row.dataset.itemId, // frontend mirrors stepId == itemId on the run-row
1633
+ step: (stepEl && stepEl.value) || "",
1634
+ data: (dataEl && dataEl.value) || "",
1635
+ expectedResult: (expectedEl && expectedEl.value) || "",
1636
+ actualResult: (actualEl && actualEl.value) || "",
1637
+ status: (statusEl && statusEl.value) || "pending"
1638
+ };
1639
+ if (noteVal) item.note = noteVal;
1640
+ out.push(item);
1641
+ });
1642
+ return out;
1643
+ }
1644
+
1645
+ /**
1646
+ * SM-57: Read the outcome-override picker value. Returns:
1647
+ * - undefined → section not rendered (don't touch ticket.outcomeOverride)
1648
+ * - null → user picked "auto" (= clear the manual override)
1649
+ * - string → one of the enum values
1650
+ */
1651
+ function collectOutcomeOverride(modal) {
1652
+ const sel = modal.querySelector(".tm-exec-outcome-override");
1653
+ if (!sel) return undefined;
1654
+ const v = sel.value;
1655
+ if (v === "auto" || !v) return null;
1656
+ return v;
1657
+ }
1658
+
1659
+ /**
1660
+ * SM-54: Read Prerequisites rows from the DOM in current order. Empty-label
1661
+ * rows are dropped (analog DoR/DoD). Returns `null` if the section isn't
1662
+ * rendered (gate off or wrong type), so buildUpdatePatch can leave the
1663
+ * existing prerequisites untouched.
1664
+ */
1665
+ function collectPrerequisites(modal) {
1666
+ const wrap = modal.querySelector('section.tm-prereqs[' + CONSTANTS.LIVE_SYNC_ATTR + '="prerequisites"]');
1667
+ if (!wrap) return null;
1668
+ const rows = wrap.querySelectorAll(".tm-prereq-row");
1669
+ const out = [];
1670
+ rows.forEach((row) => {
1671
+ const labelInput = row.querySelector(".tm-prereq-text-input");
1672
+ const label = (labelInput && labelInput.value || "").trim();
1673
+ if (!label) return;
1674
+ const checkedInput = row.querySelector(".tm-prereq-checked");
1675
+ const requiredInput = row.querySelector(".tm-prereq-required");
1676
+ out.push({
1677
+ id: row.dataset.itemId,
1678
+ label: label,
1679
+ required: !!(requiredInput && requiredInput.checked),
1680
+ checked: !!(checkedInput && checkedInput.checked)
1681
+ });
1682
+ });
1683
+ return out;
1684
+ }
1685
+
1686
+ /**
1687
+ * SM-54: Read Steps rows from the DOM. A row whose THREE text fields are
1688
+ * all empty is dropped (empty placeholder added by "+ Add Step" but not
1689
+ * filled in). Returns `null` if the section isn't rendered.
1690
+ */
1691
+ function collectTestSteps(modal) {
1692
+ const wrap = modal.querySelector('section.tm-test-steps[' + CONSTANTS.LIVE_SYNC_ATTR + '="steps"]');
1693
+ if (!wrap) return null;
1694
+ const rows = wrap.querySelectorAll(".tm-test-step-row");
1695
+ const out = [];
1696
+ rows.forEach((row) => {
1697
+ const stepInput = row.querySelector(".tm-test-step-step");
1698
+ const dataInput = row.querySelector(".tm-test-step-data");
1699
+ const expectedInput = row.querySelector(".tm-test-step-expected");
1700
+ const step = (stepInput && stepInput.value || "").trim();
1701
+ const data = (dataInput && dataInput.value || "").trim();
1702
+ const expected = (expectedInput && expectedInput.value || "").trim();
1703
+ if (!step && !data && !expected) return;
1704
+ out.push({
1705
+ id: row.dataset.itemId,
1706
+ step: step,
1707
+ data: data,
1708
+ expectedResult: expected
1709
+ });
1710
+ });
1711
+ return out;
1712
+ }
1713
+
1714
+ /**
1715
+ * Read DoR/DoD rows from the DOM in current order. Empty-label rows are
1716
+ * dropped (analog dialog-definitions-settings). E19-followup.
1717
+ */
1718
+ function collectChecklistItems(modal, syncKey) {
1719
+ const wrap = modal.querySelector('section.tm-checklist[' + CONSTANTS.LIVE_SYNC_ATTR + '="' + syncKey + '"]');
1720
+ if (!wrap) return null; // section not rendered for this type (gate off)
1721
+ const rows = wrap.querySelectorAll(".tm-checkitem-row");
1722
+ const out = [];
1723
+ rows.forEach((row) => {
1724
+ const labelInput = row.querySelector(".tm-checkitem-text-input");
1725
+ const label = (labelInput && labelInput.value || "").trim();
1726
+ if (!label) return;
1727
+ const checkedInput = row.querySelector(".tm-checkitem-checked");
1728
+ const requiredInput = row.querySelector(".tm-checkitem-required");
1729
+ out.push({
1730
+ id: row.dataset.itemId,
1731
+ label: label,
1732
+ required: !!(requiredInput && requiredInput.checked),
1733
+ checked: !!(checkedInput && checkedInput.checked)
1734
+ });
1735
+ });
1736
+ return out;
1737
+ }
1738
+
1739
+ /**
1740
+ * Produce a "field patch" suitable for PUT /tickets/:tid (which uses
1741
+ * updateTicket allowing: title/description/type/status/position/
1742
+ * acceptanceCriteria/labels). Position keeps the existing sortOrder
1743
+ * (caller doesn't reorder from the modal).
1744
+ */
1745
+ function buildUpdatePatch(form, original) {
1746
+ const patch = {};
1747
+ if (form.title !== original.title) patch.title = form.title;
1748
+ if (form.type !== original.type) patch.type = form.type;
1749
+ if (form.description !== original.description) patch.description = form.description;
1750
+ const op = original.position || {};
1751
+ const fp = form.position || {};
1752
+ if ((fp.releaseId || null) !== (op.releaseId || null)
1753
+ || (fp.processStepId || null) !== (op.processStepId || null)
1754
+ || (fp.epicId || null) !== (op.epicId || null)) {
1755
+ patch.position = {
1756
+ releaseId: fp.releaseId || null,
1757
+ processStepId: fp.processStepId || null,
1758
+ epicId: fp.epicId || null,
1759
+ sortOrder: (op.sortOrder || 0)
1760
+ };
1761
+ }
1762
+ // SM-152: only include acceptanceCriteria if it actually changed vs the
1763
+ // original. Otherwise a save that touched some OTHER field would ship our
1764
+ // (possibly stale) local AC list and clobber an external AC edit. Compare
1765
+ // by content+order (ignore item ids — new local rows get random ids).
1766
+ const acSig = (lst) => JSON.stringify((lst || []).map(a => ({ text: a.text || "", completed: !!a.completed })));
1767
+ if (acSig(form.acceptanceCriteria) !== acSig(original.acceptanceCriteria)) {
1768
+ patch.acceptanceCriteria = form.acceptanceCriteria;
1769
+ }
1770
+ // SM-120: labels only when actually changed. (Was unconditional, which made
1771
+ // the in-place auto-commit fire on every interaction. The modal Save path is
1772
+ // unaffected — it gates on hasNonStatusChanges, which still sees a real
1773
+ // labels change.)
1774
+ if (JSON.stringify(form.labels || []) !== JSON.stringify(original.labels || [])) {
1775
+ patch.labels = form.labels;
1776
+ }
1777
+ // E19-followup: DoR/DoD-Items als per-Ticket-Patch. Wenn collectChecklistItems
1778
+ // null zurückgibt (Section gar nicht gerendert, z.B. gate off), keinen Patch
1779
+ // setzen — der Server bewahrt die existierenden Items.
1780
+ if (form.definitionOfReady && Array.isArray(form.definitionOfReady.items)) {
1781
+ if (!checklistsEqual(form.definitionOfReady.items,
1782
+ (original.definitionOfReady && original.definitionOfReady.items) || [])) {
1783
+ patch.definitionOfReady = form.definitionOfReady;
1784
+ }
1785
+ }
1786
+ if (form.definitionOfDone && Array.isArray(form.definitionOfDone.items)) {
1787
+ if (!checklistsEqual(form.definitionOfDone.items,
1788
+ (original.definitionOfDone && original.definitionOfDone.items) || [])) {
1789
+ patch.definitionOfDone = form.definitionOfDone;
1790
+ }
1791
+ }
1792
+ // SM-54: test-definition fields. `null` means the section wasn't rendered
1793
+ // → leave existing values alone. An array means "this is the new full list" —
1794
+ // include only when structurally different from the original.
1795
+ if (Array.isArray(form.prerequisites)) {
1796
+ if (!prereqsEqual(form.prerequisites, original.prerequisites || [])) {
1797
+ patch.prerequisites = form.prerequisites;
1798
+ }
1799
+ }
1800
+ if (Array.isArray(form.steps)) {
1801
+ if (!testStepsEqual(form.steps, original.steps || [])) {
1802
+ patch.steps = form.steps;
1803
+ }
1804
+ }
1805
+ // SM-57: test-execution fields.
1806
+ if (Array.isArray(form.executionSteps)) {
1807
+ if (!execStepsEqual(form.executionSteps, original.executionSteps || [])) {
1808
+ patch.executionSteps = form.executionSteps;
1809
+ }
1810
+ }
1811
+ // outcomeOverride: undefined = section not rendered (skip). null = clear
1812
+ // override (write only if currently non-null). string = set explicit value.
1813
+ if (form.outcomeOverride !== undefined) {
1814
+ const currentOverride = (typeof original.outcomeOverride === "string") ? original.outcomeOverride : null;
1815
+ const target = (form.outcomeOverride === null) ? null : form.outcomeOverride;
1816
+ if (currentOverride !== target) {
1817
+ // core.updateTicket maps null/`"auto"` → null clear; explicit value → set.
1818
+ patch.outcomeOverride = (target === null) ? "auto" : target;
1819
+ }
1820
+ }
1821
+ return patch;
1822
+ }
1823
+
1824
+ function execStepsEqual(a, b) {
1825
+ if (a.length !== b.length) return false;
1826
+ for (let i = 0; i < a.length; i++) {
1827
+ if (a[i].id !== b[i].id) return false;
1828
+ if ((a[i].actualResult || "") !== (b[i].actualResult || "")) return false;
1829
+ if ((a[i].status || "pending") !== (b[i].status || "pending")) return false;
1830
+ if ((a[i].note || "") !== (b[i].note || "")) return false;
1831
+ }
1832
+ return true;
1833
+ }
1834
+
1835
+ function prereqsEqual(a, b) {
1836
+ if (a.length !== b.length) return false;
1837
+ for (let i = 0; i < a.length; i++) {
1838
+ if (a[i].id !== b[i].id) return false;
1839
+ if ((a[i].label || "") !== (b[i].label || "")) return false;
1840
+ if ((a[i].required !== false) !== (b[i].required !== false)) return false;
1841
+ if (!!a[i].checked !== !!b[i].checked) return false;
1842
+ }
1843
+ return true;
1844
+ }
1845
+
1846
+ function testStepsEqual(a, b) {
1847
+ if (a.length !== b.length) return false;
1848
+ for (let i = 0; i < a.length; i++) {
1849
+ if (a[i].id !== b[i].id) return false;
1850
+ if ((a[i].step || "") !== (b[i].step || "")) return false;
1851
+ if ((a[i].data || "") !== (b[i].data || "")) return false;
1852
+ if ((a[i].expectedResult || "") !== (b[i].expectedResult || "")) return false;
1853
+ }
1854
+ return true;
1855
+ }
1856
+
1857
+ function checklistsEqual(a, b) {
1858
+ if (a.length !== b.length) return false;
1859
+ for (let i = 0; i < a.length; i++) {
1860
+ if (a[i].id !== b[i].id) return false;
1861
+ if ((a[i].label || "") !== (b[i].label || "")) return false;
1862
+ if ((a[i].required !== false) !== (b[i].required !== false)) return false;
1863
+ if (!!a[i].checked !== !!b[i].checked) return false;
1864
+ }
1865
+ return true;
1866
+ }
1867
+
1868
+ /**
1869
+ * Build a draft ticket for Create-Mode given a type + position context.
1870
+ * Returns a plain ticket-shaped object (no id, no ticketKey — server
1871
+ * assigns those on POST).
1872
+ */
1873
+ function buildDraftTicket(type, draftCtx) {
1874
+ const dc = draftCtx || {};
1875
+ return {
1876
+ id: null,
1877
+ ticketKey: "",
1878
+ type: type,
1879
+ title: "",
1880
+ description: "",
1881
+ status: dc.status || "backlog",
1882
+ position: {
1883
+ releaseId: dc.releaseId || null,
1884
+ processStepId: dc.processStepId || null,
1885
+ epicId: dc.epicId || null,
1886
+ sortOrder: 0
1887
+ },
1888
+ acceptanceCriteria: [],
1889
+ definitionOfReady: { items: [] },
1890
+ definitionOfDone: { items: [] },
1891
+ labels: [],
1892
+ links: [],
1893
+ comments: []
1894
+ };
1895
+ }
1896
+
1897
+ // ---- SM-120 (B5): in-place persistence -------------------------------
1898
+ //
1899
+ // Wire a host so every field change commits a Store-op directly — no Save
1900
+ // button. Discrete controls (select/checkbox) commit immediately; text
1901
+ // fields commit on focus-out; list-sections (AC/DoR/DoD/prereqs/steps) commit
1902
+ // via the returned `markDirty` (debounced, so per-keystroke typing coalesces
1903
+ // into one undo entry). Status routes through `changeStatusGated` so the
1904
+ // DoR/DoD gate fires; a 422 reverts the picker and reports via onStatusError,
1905
+ // leaving the page open. The description is intentionally excluded — it owns
1906
+ // its inline ✓/✗ (SM-157).
1907
+ //
1908
+ // opts: { store, ticketId, actor?, onStatusError?(msgOrNull), flashStatus?,
1909
+ // debounceMs?, win? }. Returns { markDirty, detach }.
1910
+ function installInPlaceCommit(host, opts) {
1911
+ opts = opts || {};
1912
+ const store = opts.store;
1913
+ const ticketId = opts.ticketId;
1914
+ const actor = opts.actor || LOCAL_ACTOR;
1915
+ const win = opts.win || (typeof window !== "undefined" ? window : null);
1916
+ const setT = (win && win.setTimeout) || (typeof setTimeout === "function" ? setTimeout : null);
1917
+ const clrT = (win && win.clearTimeout) || (typeof clearTimeout === "function" ? clearTimeout : null);
1918
+ const debounceMs = typeof opts.debounceMs === "number" ? opts.debounceMs : 450;
1919
+ const onStatusError = typeof opts.onStatusError === "function" ? opts.onStatusError : function () {};
1920
+ let timer = null;
1921
+
1922
+ function curTicket() {
1923
+ const snap = store.get();
1924
+ return (snap.tickets || []).find(t => t.id === ticketId) || null;
1925
+ }
1926
+
1927
+ function commitFields() {
1928
+ const cur = curTicket();
1929
+ if (!cur) return;
1930
+ const form = collectFormState(host);
1931
+ // The description self-commits via its inline ✓/✗; never clobber it here.
1932
+ form.description = cur.description;
1933
+ // Status is handled by the dedicated gated path — strip from the diff.
1934
+ form.status = cur.status;
1935
+ // SM-52: resolve the container epic so the position diff is
1936
+ // apples-to-apples (position.epicId is always null on the stored ticket).
1937
+ const containerId = (typeof core.containerEpicIdOf === "function")
1938
+ ? core.containerEpicIdOf(store.get(), ticketId) : null;
1939
+ const curCmp = Object.assign({}, cur, {
1940
+ position: Object.assign({}, cur.position || {}, { epicId: containerId })
1941
+ });
1942
+ const patch = buildUpdatePatch(form, curCmp);
1943
+ if (Object.keys(patch).length === 0) return;
1944
+ try { store.updateTicket(ticketId, patch, actor); }
1945
+ catch (e) { if (opts.flashStatus) opts.flashStatus("save failed: " + (e && e.message || e), { kind: "error" }); }
1946
+ }
1947
+
1948
+ function schedule() {
1949
+ if (!setT) { commitFields(); return; }
1950
+ if (timer && clrT) clrT(timer);
1951
+ timer = setT(commitFields, debounceMs);
1952
+ }
1953
+
1954
+ function commitStatus(sel) {
1955
+ const cur = curTicket();
1956
+ if (!cur) return;
1957
+ const next = sel.value;
1958
+ if (!next || next === cur.status) return;
1959
+ try {
1960
+ store.changeStatusGated(ticketId, next, actor);
1961
+ onStatusError(null);
1962
+ } catch (e) {
1963
+ sel.value = cur.status; // revert the picker; stay on the page
1964
+ const missing = (e && e.missing) ? e.missing.map(m => m.label || m.id).join(", ") : "";
1965
+ const kind = (e && e.kind) ? e.kind : "";
1966
+ onStatusError(kind ? (kind + " gate blocks this transition. Missing: " + missing)
1967
+ : ((e && e.message) || "transition blocked"));
1968
+ }
1969
+ }
1970
+
1971
+ function keyOf(node) {
1972
+ return node && node.getAttribute ? node.getAttribute(CONSTANTS.LIVE_SYNC_ATTR) : null;
1973
+ }
1974
+ function onChange(ev) {
1975
+ const t = ev.target;
1976
+ if (keyOf(t) === "status") { commitStatus(t); return; }
1977
+ commitFields(); // discrete control → immediate
1978
+ }
1979
+ function onFocusOut(ev) {
1980
+ const k = keyOf(ev.target);
1981
+ if (k === "description" || k === "status") return; // own handlers
1982
+ commitFields(); // text field blur → immediate
1983
+ }
1984
+
1985
+ host.addEventListener("change", onChange);
1986
+ host.addEventListener("focusout", onFocusOut);
1987
+
1988
+ return {
1989
+ markDirty: schedule,
1990
+ detach: function () {
1991
+ host.removeEventListener("change", onChange);
1992
+ host.removeEventListener("focusout", onFocusOut);
1993
+ if (timer && clrT) clrT(timer);
1994
+ }
1995
+ };
1996
+ }
1997
+
1998
+ // SM-185: Attachments section — a dropzone + list, bound to a ticket. Files
1999
+ // are reference docs (PRDs, designs); they ALWAYS belong to a ticket (no
2000
+ // free-floating attachments). Uses ctx helpers (HTTP-only): listAttachments /
2001
+ // uploadAttachment / deleteAttachment / attachmentHref / flashStatus.
2002
+ function formatBytes(n) {
2003
+ if (typeof n !== "number") return "";
2004
+ if (n < 1024) return n + " B";
2005
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
2006
+ return (n / (1024 * 1024)).toFixed(1) + " MB";
2007
+ }
2008
+ function buildAttachmentsSection(ticketId, ctx) {
2009
+ const section = el("section", { class: "tm-attachments-section" });
2010
+ section.appendChild(el("h4", { class: "tm-section-title", text: "Attachments" }));
2011
+ const drop = el("div", { class: "tm-attach-dropzone", text: "Drop a file here or click to attach" });
2012
+ const fileInput = el("input", { type: "file", class: "tm-attach-input", style: { display: "none" } });
2013
+ const listEl = el("ul", { class: "tm-attach-list" });
2014
+ section.appendChild(drop);
2015
+ section.appendChild(fileInput);
2016
+ section.appendChild(listEl);
2017
+
2018
+ function renderList(items) {
2019
+ listEl.innerHTML = "";
2020
+ if (!items || !items.length) {
2021
+ listEl.appendChild(el("li", { class: "tm-attach-empty", text: "No attachments yet." }));
2022
+ return;
2023
+ }
2024
+ for (const a of items) {
2025
+ const li = el("li", { class: "tm-attach-item", dataset: { attachmentId: a.id } });
2026
+ const link = el("a", { class: "tm-attach-name", href: ctx.attachmentHref(a.id), text: a.filename, title: "Download" });
2027
+ link.setAttribute("download", a.filename);
2028
+ li.appendChild(link);
2029
+ li.appendChild(el("span", { class: "tm-attach-size", text: formatBytes(a.size) }));
2030
+ const del = el("button", { class: "tm-attach-del", type: "button", title: "Remove attachment", text: "✕" });
2031
+ del.addEventListener("click", async (ev) => {
2032
+ ev.preventDefault(); ev.stopPropagation();
2033
+ try {
2034
+ await ctx.deleteAttachment(a.id);
2035
+ await refresh();
2036
+ if (ctx.flashStatus) ctx.flashStatus("attachment removed", { kind: "ok" });
2037
+ } catch (e) {
2038
+ if (ctx.flashStatus) ctx.flashStatus(e.message || "delete failed", { kind: "error" });
2039
+ }
2040
+ });
2041
+ li.appendChild(del);
2042
+ listEl.appendChild(li);
2043
+ }
2044
+ }
2045
+ async function refresh() {
2046
+ try { renderList(await ctx.listAttachments(ticketId)); }
2047
+ catch (_) { renderList([]); }
2048
+ }
2049
+ async function upload(file) {
2050
+ if (!file) return;
2051
+ if (ctx.flashStatus) ctx.flashStatus("uploading " + file.name + " …", { kind: "info" });
2052
+ try {
2053
+ await ctx.uploadAttachment(ticketId, file);
2054
+ await refresh();
2055
+ if (ctx.flashStatus) ctx.flashStatus("attached " + file.name, { kind: "ok" });
2056
+ } catch (e) {
2057
+ if (ctx.flashStatus) ctx.flashStatus(e.message || "upload failed", { kind: "error" });
2058
+ }
2059
+ }
2060
+ drop.addEventListener("click", () => fileInput.click());
2061
+ fileInput.addEventListener("change", () => {
2062
+ if (fileInput.files && fileInput.files[0]) upload(fileInput.files[0]);
2063
+ fileInput.value = "";
2064
+ });
2065
+ drop.addEventListener("dragover", (ev) => { ev.preventDefault(); drop.classList.add("tm-attach-dropzone-over"); });
2066
+ drop.addEventListener("dragleave", () => drop.classList.remove("tm-attach-dropzone-over"));
2067
+ drop.addEventListener("drop", (ev) => {
2068
+ ev.preventDefault();
2069
+ drop.classList.remove("tm-attach-dropzone-over");
2070
+ const f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0];
2071
+ if (f) upload(f);
2072
+ });
2073
+ refresh();
2074
+ return section;
2075
+ }
2076
+
2077
+ return {
2078
+ CONSTANTS, LOCAL_ACTOR, DRAG_TYPE_AC, DRAG_TYPE_CHECK,
2079
+ buildAttachmentsSection,
2080
+ DRAG_TYPE_PREREQ, DRAG_TYPE_TEST_STEP, MAX_TEST_STEP_TA_HEIGHT_PX, el,
2081
+ buildRow, buildErrorBanner, cssEscape, autoGrowTextarea,
2082
+ buildTitleField, buildTypeField, buildStatusField, buildPositionFields,
2083
+ buildDescriptionField, buildChecklistSection, buildCheckRow, buildAcceptanceCriteriaSection,
2084
+ buildAcRow, buildPrerequisitesSection, buildPrereqRow, buildStepsSection,
2085
+ buildTestStepRow, buildLabelsField, resolveTestDefinition, buildTestExecutionHeader,
2086
+ buildExecutionMetadata, buildExecutionStepsSection, buildExecutionStepRow, buildOutcomeSection,
2087
+ lookupLinkType, findBackwardLinks, buildLinkPill, buildTargetRef,
2088
+ buildLinksSection, renderLinksSectionInto, buildInlineAddLinkForm, syncFieldFromTicket,
2089
+ rebuildAcInPlace, rebuildPrereqsInPlace, rebuildTestStepsInPlace, diffSyncFieldKeys,
2090
+ pulseChangedFields, rebuildChecklistInPlace, buildDraftTicket, collectFormState,
2091
+ collectExecutionSteps, collectOutcomeOverride, collectPrerequisites, collectTestSteps,
2092
+ collectChecklistItems, buildUpdatePatch, execStepsEqual, prereqsEqual,
2093
+ testStepsEqual, checklistsEqual, installInPlaceCommit
2094
+ };
2095
+ }));