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,1864 @@
1
+ /**
2
+ * Settings view (E21.D — skeleton).
3
+ *
4
+ * Third view alongside Story Map and Kanban. Editors for Workflow (E21.E
5
+ * status editor, E21.F transition editor) and Board (E21.G kanban-column
6
+ * mapping) plug into this shell. This skeleton renders a section
7
+ * scaffold + empty bodies; following etappes fill the bodies.
8
+ *
9
+ * The view is NOT a modal — it owns its host area, gets mounted/unmounted
10
+ * by main.js's view-toggle (E21.D wiring), and stays in sync with the
11
+ * store via subscribe + rerender (same pattern as Kanban).
12
+ *
13
+ * UMD-wrapped so the same source works in the browser AND can be
14
+ * require()-d in Node tests.
15
+ */
16
+ (function (root, factory) {
17
+ if (typeof module === "object" && module.exports) {
18
+ module.exports = factory(require("./core.js"), require("./dnd.js"));
19
+ } else {
20
+ (root.STORYMAP = root.STORYMAP || {}).viewSettings =
21
+ factory(root.STORYMAP.core, root.STORYMAP.dnd);
22
+ }
23
+ }(typeof self !== "undefined" ? self : this, function (core, dnd) {
24
+ "use strict";
25
+
26
+ const LOCAL_ACTOR = { type: "human", id: "local", name: "Local" };
27
+
28
+ // SM-2 — DoR/DoD editor constants (ported from dialog-definitions-settings).
29
+ const DDS_DRAG_TYPE = "vs-defs-item";
30
+ const DDS_ITEM_ID_PREFIX = "vs-def-";
31
+ function freshDefItemId() {
32
+ return DDS_ITEM_ID_PREFIX + Math.random().toString(36).slice(2, 10);
33
+ }
34
+
35
+ // E21.E: Auto-id generator for new statuses. Kebab-case "new-status" with
36
+ // a short random suffix so identical names don't clash.
37
+ function freshStatusId(existing) {
38
+ let i = 1;
39
+ let candidate = "new-status";
40
+ const ids = new Set((existing || []).map(s => s.id));
41
+ while (ids.has(candidate)) {
42
+ i += 1;
43
+ candidate = "new-status-" + i;
44
+ }
45
+ return candidate;
46
+ }
47
+
48
+ // ---- DOM helpers ---------------------------------------------------
49
+
50
+ function el(tag, attrs, ...children) {
51
+ const node = document.createElement(tag);
52
+ if (attrs) {
53
+ for (const k of Object.keys(attrs)) {
54
+ if (k === "class") node.className = attrs[k];
55
+ else if (k === "dataset") {
56
+ for (const dk of Object.keys(attrs.dataset)) node.dataset[dk] = attrs.dataset[dk];
57
+ }
58
+ else if (k === "html") node.innerHTML = attrs[k];
59
+ else if (k === "text") node.textContent = attrs[k];
60
+ else if (k === "style" && typeof attrs.style === "object") Object.assign(node.style, attrs.style);
61
+ else node.setAttribute(k, attrs[k]);
62
+ }
63
+ }
64
+ for (const c of children) {
65
+ if (c == null) continue;
66
+ if (typeof c === "string") node.appendChild(document.createTextNode(c));
67
+ else node.appendChild(c);
68
+ }
69
+ return node;
70
+ }
71
+
72
+ // ---- Sections ------------------------------------------------------
73
+ //
74
+ // Each section is an isolated render function so future etappes can
75
+ // swap one out without touching the shell. The contract:
76
+ // renderXxx(project, ctx) → DOM element
77
+ // ctx exposes `store`, `flashStatus`, etc. — the same shape main.js
78
+ // builds for ticket-modal and dialogs.
79
+
80
+ // ---- E21.E status editor -----------------------------------------
81
+ //
82
+ // Pure reorder helper — exported so tests don't have to drive the dnd
83
+ // module to validate the order math.
84
+ function reorderStatuses(allStatuses, sourceId, targetId, before) {
85
+ if (sourceId === targetId) return allStatuses.slice();
86
+ const fromIdx = allStatuses.findIndex(s => s.id === sourceId);
87
+ if (fromIdx < 0) return allStatuses.slice();
88
+ const next = allStatuses.slice();
89
+ const [moved] = next.splice(fromIdx, 1);
90
+ let insertAt = next.findIndex(s => s.id === targetId);
91
+ if (insertAt < 0) insertAt = next.length;
92
+ if (!before) insertAt += 1;
93
+ next.splice(insertAt, 0, moved);
94
+ return next;
95
+ }
96
+
97
+ //
98
+ // Live-apply pattern: discrete actions (Add, Delete, Reorder, category-
99
+ // change) dispatch updateProject immediately; the name input commits on
100
+ // blur (avoids a commit per keystroke). External commits re-render the
101
+ // section, but the actively edited input is skipped — focus-guard so the
102
+ // user doesn't lose their caret mid-edit (mirrors the ticket-modal
103
+ // pattern).
104
+
105
+ function commitStatuses(ctx, nextStatuses) {
106
+ if (!ctx || !ctx.store) return;
107
+ try {
108
+ const cur = ctx.store.get().project.workflow || {};
109
+ const nextWf = Object.assign({}, cur, { statuses: nextStatuses });
110
+ ctx.store.updateProject({ workflow: nextWf }, LOCAL_ACTOR);
111
+ } catch (err) {
112
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
113
+ }
114
+ }
115
+
116
+ function renderStatusRow(status, ctx, allStatuses, idx) {
117
+ const row = el("div", {
118
+ class: "vs-status-row",
119
+ dataset: { statusId: status.id }
120
+ });
121
+ const grip = el("span", { class: "vs-grip", text: "⋮⋮", title: "Drag to reorder" });
122
+ const nameInput = el("input", {
123
+ type: "text",
124
+ class: "vs-status-name",
125
+ value: status.name
126
+ });
127
+ nameInput.addEventListener("blur", () => {
128
+ const v = String(nameInput.value || "").trim();
129
+ if (!v || v === status.name) {
130
+ nameInput.value = status.name; // revert on empty / unchanged
131
+ return;
132
+ }
133
+ const next = allStatuses.slice();
134
+ next[idx] = Object.assign({}, status, { name: v });
135
+ commitStatuses(ctx, next);
136
+ });
137
+
138
+ const categorySelect = el("select", { class: "vs-status-category" });
139
+ for (const cat of (core.STATUS_CATEGORIES || ["todo", "doing", "blocked", "done"])) {
140
+ const opt = el("option", { value: cat, text: cat });
141
+ if (cat === status.category) opt.selected = true;
142
+ categorySelect.appendChild(opt);
143
+ }
144
+ categorySelect.addEventListener("change", () => {
145
+ const next = allStatuses.slice();
146
+ next[idx] = Object.assign({}, status, { category: categorySelect.value });
147
+ commitStatuses(ctx, next);
148
+ });
149
+
150
+ const delBtn = el("button", {
151
+ class: "vs-status-delete",
152
+ title: "Delete status",
153
+ text: "×"
154
+ });
155
+ delBtn.addEventListener("click", (ev) => {
156
+ ev.stopPropagation();
157
+ // Soft warning if the status is currently in use — but allow the
158
+ // delete; normalizeWorkflow will leave the ticket's status string in
159
+ // place (it just no longer maps to a workflow entry). The user can
160
+ // re-add the status if needed.
161
+ const snap = ctx.store ? ctx.store.get() : null;
162
+ const inUse = snap && (snap.tickets || []).some(t => !t.isDeleted && t.status === status.id);
163
+ if (inUse && typeof confirm === "function") {
164
+ if (!confirm(`Status "${status.name}" is still used by tickets. Delete anyway?`)) return;
165
+ }
166
+ const next = allStatuses.filter((_, i) => i !== idx);
167
+ commitStatuses(ctx, next);
168
+ });
169
+
170
+ row.appendChild(grip);
171
+ row.appendChild(nameInput);
172
+ row.appendChild(categorySelect);
173
+ row.appendChild(delBtn);
174
+
175
+ // E21.E reorder: row is both drag-source and drop-target. onDrop
176
+ // rebuilds the order array and commits — focus-guard in renderInto
177
+ // ensures the rerender doesn't fight an active edit on another row.
178
+ if (dnd && typeof dnd.enableDraggable === "function") {
179
+ dnd.enableDraggable(row, { dragType: "vs-status", dragId: status.id });
180
+ dnd.enableDropTarget(row, {
181
+ accepts: ["vs-status"],
182
+ onEnter: (e) => e.classList.add("vs-status-drop-target"),
183
+ onLeave: (e) => e.classList.remove("vs-status-drop-target"),
184
+ onDrop: ({ id, event }) => {
185
+ row.classList.remove("vs-status-drop-target");
186
+ if (id === status.id) return;
187
+ const rect = row.getBoundingClientRect();
188
+ const before = (event && typeof event.clientY === "number")
189
+ ? event.clientY < rect.top + rect.height / 2 : true;
190
+ const next = reorderStatuses(allStatuses, id, status.id, before);
191
+ commitStatuses(ctx, next);
192
+ }
193
+ });
194
+ }
195
+ return row;
196
+ }
197
+
198
+ function renderStatusEditor(workflow, ctx) {
199
+ const statuses = (workflow && workflow.statuses) || [];
200
+ const wrap = el("div", { class: "vs-status-editor" });
201
+ const rowsHost = el("div", { class: "vs-status-rows" });
202
+ statuses.forEach((s, idx) => {
203
+ rowsHost.appendChild(renderStatusRow(s, ctx, statuses, idx));
204
+ });
205
+ wrap.appendChild(rowsHost);
206
+ const add = el("button", {
207
+ class: "vs-status-add",
208
+ title: "Add a new status",
209
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Status</span>'
210
+ });
211
+ add.addEventListener("click", (ev) => {
212
+ ev.stopPropagation();
213
+ const id = freshStatusId(statuses);
214
+ const next = statuses.concat([{ id: id, name: "New Status", category: "doing" }]);
215
+ commitStatuses(ctx, next);
216
+ });
217
+ wrap.appendChild(add);
218
+ return wrap;
219
+ }
220
+
221
+ // ---- E23.C transition reorder + auto-sort -----------------------
222
+ //
223
+ // Two flavors: explicit DnD-driven reorder (pure helper, mirrors the
224
+ // status one) AND auto-sort so transitions follow the status order after
225
+ // any status reorder. User wants both: "Transitions sollten in der
226
+ // gleichen Reihenfolge wie die Statuses erscheinen, wenn der Nutzer die
227
+ // Statuses umsortiert hat."
228
+
229
+ function reorderTransitions(allTransitions, sourceId, targetId, before) {
230
+ if (sourceId === targetId) return allTransitions.slice();
231
+ const fromIdx = allTransitions.findIndex(t => t.id === sourceId);
232
+ if (fromIdx < 0) return allTransitions.slice();
233
+ const next = allTransitions.slice();
234
+ const [moved] = next.splice(fromIdx, 1);
235
+ let insertAt = next.findIndex(t => t.id === targetId);
236
+ if (insertAt < 0) insertAt = next.length;
237
+ if (!before) insertAt += 1;
238
+ next.splice(insertAt, 0, moved);
239
+ return next;
240
+ }
241
+
242
+ function sortTransitionsByStatusOrder(transitions, statuses) {
243
+ if (!Array.isArray(transitions) || transitions.length === 0) return [];
244
+ const order = new Map();
245
+ (statuses || []).forEach((s, i) => order.set(s.id, i));
246
+ const indexOf = (tr) => order.has(tr.toStatus) ? order.get(tr.toStatus) : Number.MAX_SAFE_INTEGER;
247
+ // Decorate with original index for stable sort, then sort, then strip.
248
+ return transitions
249
+ .map((tr, i) => ({ tr, orderKey: indexOf(tr), origIdx: i }))
250
+ .sort((a, b) => (a.orderKey - b.orderKey) || (a.origIdx - b.origIdx))
251
+ .map(x => x.tr);
252
+ }
253
+
254
+ // ---- E21.F transition editor ------------------------------------
255
+ //
256
+ // Pattern mirrors the status editor: live-apply on discrete actions, on-
257
+ // blur for the name input. The source-multiselect lives in a sub-host
258
+ // that toggles visibility based on `allowFromAny`.
259
+
260
+ function commitTransitions(ctx, nextTransitions) {
261
+ if (!ctx || !ctx.store) return;
262
+ try {
263
+ const cur = ctx.store.get().project.workflow || {};
264
+ const nextWf = Object.assign({}, cur, { transitions: nextTransitions });
265
+ ctx.store.updateProject({ workflow: nextWf }, LOCAL_ACTOR);
266
+ } catch (err) {
267
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
268
+ }
269
+ }
270
+
271
+ function freshTransitionId(existing) {
272
+ let i = 1;
273
+ let candidate = "tr-" + i;
274
+ const ids = new Set((existing || []).map(t => t.id));
275
+ while (ids.has(candidate)) { i += 1; candidate = "tr-" + i; }
276
+ return candidate;
277
+ }
278
+
279
+ // Build a patched transition by replacing one field. Pure for testability.
280
+ function patchTransitionAt(allTransitions, idx, field, value) {
281
+ const next = allTransitions.slice();
282
+ next[idx] = Object.assign({}, next[idx], { [field]: value });
283
+ return next;
284
+ }
285
+
286
+ function renderTransitionRow(tr, ctx, allStatuses, allTransitions, idx) {
287
+ const row = el("div", {
288
+ class: "vs-transition-row",
289
+ dataset: { transitionId: tr.id }
290
+ });
291
+
292
+ // Header line: grip + name + delete (delete on the right).
293
+ const header = el("div", { class: "vs-transition-header" });
294
+ const grip = el("span", { class: "vs-grip", text: "⋮⋮", title: "Drag to reorder" });
295
+ const nameInput = el("input", {
296
+ type: "text",
297
+ class: "vs-transition-name",
298
+ value: tr.name,
299
+ placeholder: "Transition name"
300
+ });
301
+ nameInput.addEventListener("blur", () => {
302
+ const v = String(nameInput.value || "").trim();
303
+ if (!v || v === tr.name) { nameInput.value = tr.name; return; }
304
+ commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "name", v));
305
+ });
306
+ const delBtn = el("button", {
307
+ class: "vs-transition-delete",
308
+ title: "Delete transition",
309
+ text: "×"
310
+ });
311
+ delBtn.addEventListener("click", (ev) => {
312
+ ev.stopPropagation();
313
+ const next = allTransitions.filter((_, i) => i !== idx);
314
+ commitTransitions(ctx, next);
315
+ });
316
+ header.appendChild(grip);
317
+ header.appendChild(nameInput);
318
+ header.appendChild(delBtn);
319
+
320
+ // Body line: target + gate + allow-from-any toggle.
321
+ const body = el("div", { class: "vs-transition-body" });
322
+ const arrow = el("span", { class: "vs-transition-arrow", text: "→" });
323
+ const targetSel = el("select", { class: "vs-transition-target", title: "Target status" });
324
+ for (const s of allStatuses) {
325
+ const opt = el("option", { value: s.id, text: s.name || s.id });
326
+ if (s.id === tr.toStatus) opt.selected = true;
327
+ targetSel.appendChild(opt);
328
+ }
329
+ targetSel.addEventListener("change", () => {
330
+ commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "toStatus", targetSel.value));
331
+ });
332
+
333
+ const gateLabel = el("span", { class: "vs-transition-gate-label", text: "Gate:" });
334
+ const gateSel = el("select", { class: "vs-transition-gate", title: "Gate to enforce" });
335
+ for (const g of ["", "DoR", "DoD"]) {
336
+ const opt = el("option", { value: g, text: g || "None" });
337
+ if ((tr.requireGate || "") === g) opt.selected = true;
338
+ gateSel.appendChild(opt);
339
+ }
340
+ gateSel.addEventListener("change", () => {
341
+ const v = gateSel.value === "" ? null : gateSel.value;
342
+ commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "requireGate", v));
343
+ });
344
+
345
+ const anyWrap = el("label", { class: "vs-transition-any-wrap", title: "Allow from any source status" });
346
+ const anyCb = el("input", { type: "checkbox", class: "vs-transition-any" });
347
+ anyCb.checked = !!tr.allowFromAny;
348
+ anyCb.addEventListener("change", () => {
349
+ commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "allowFromAny", anyCb.checked));
350
+ });
351
+ anyWrap.appendChild(anyCb);
352
+ anyWrap.appendChild(document.createTextNode(" From any"));
353
+
354
+ body.appendChild(arrow);
355
+ body.appendChild(targetSel);
356
+ body.appendChild(gateLabel);
357
+ body.appendChild(gateSel);
358
+ body.appendChild(anyWrap);
359
+
360
+ // Sources sub-section: one checkbox per status. Visible only when the
361
+ // transition is NOT allowFromAny.
362
+ const sources = el("div", {
363
+ class: "vs-transition-sources",
364
+ dataset: { hint: "Pick source statuses" }
365
+ });
366
+ sources.hidden = !!tr.allowFromAny;
367
+ const fromSet = new Set(tr.fromStatuses || []);
368
+ for (const s of allStatuses) {
369
+ const lbl = el("label", { class: "vs-transition-source-item" });
370
+ const cbx = el("input", { type: "checkbox", dataset: { sourceId: s.id } });
371
+ cbx.checked = fromSet.has(s.id);
372
+ cbx.addEventListener("change", () => {
373
+ // Collect every checked source checkbox in this row's source host
374
+ // and commit the resulting array — using the DOM as source-of-truth
375
+ // is robust against fast multi-clicks before a rerender.
376
+ const checked = Array.from(sources.querySelectorAll("input[type='checkbox']"))
377
+ .filter(c => c.checked).map(c => c.dataset.sourceId);
378
+ commitTransitions(ctx, patchTransitionAt(allTransitions, idx, "fromStatuses", checked));
379
+ });
380
+ lbl.appendChild(cbx);
381
+ lbl.appendChild(document.createTextNode(" " + (s.name || s.id)));
382
+ sources.appendChild(lbl);
383
+ }
384
+
385
+ row.appendChild(header);
386
+ row.appendChild(body);
387
+ row.appendChild(sources);
388
+
389
+ // E23.C — DnD-Reorder analog to the status editor.
390
+ if (dnd && typeof dnd.enableDraggable === "function") {
391
+ dnd.enableDraggable(row, { dragType: "vs-transition", dragId: tr.id });
392
+ dnd.enableDropTarget(row, {
393
+ accepts: ["vs-transition"],
394
+ onEnter: (e) => e.classList.add("vs-transition-drop-target"),
395
+ onLeave: (e) => e.classList.remove("vs-transition-drop-target"),
396
+ onDrop: ({ id, event }) => {
397
+ row.classList.remove("vs-transition-drop-target");
398
+ if (id === tr.id) return;
399
+ const rect = row.getBoundingClientRect();
400
+ const before = (event && typeof event.clientY === "number")
401
+ ? event.clientY < rect.top + rect.height / 2 : true;
402
+ const next = reorderTransitions(allTransitions, id, tr.id, before);
403
+ commitTransitions(ctx, next);
404
+ }
405
+ });
406
+ }
407
+ return row;
408
+ }
409
+
410
+ function renderTransitionEditor(workflow, ctx) {
411
+ const statuses = (workflow && workflow.statuses) || [];
412
+ const rawTransitions = Array.isArray(workflow && workflow.transitions) ? workflow.transitions : [];
413
+ // E23.C: display-time sort by status order. Persisted order in the
414
+ // snapshot is left untouched; render simply projects the visual order
415
+ // that matches the user's mental model ("transitions follow statuses").
416
+ const transitions = sortTransitionsByStatusOrder(rawTransitions, statuses);
417
+ const wrap = el("div", { class: "vs-transition-editor" });
418
+ const rowsHost = el("div", { class: "vs-transition-rows" });
419
+ transitions.forEach((tr, idx) => {
420
+ rowsHost.appendChild(renderTransitionRow(tr, ctx, statuses, transitions, idx));
421
+ });
422
+ wrap.appendChild(rowsHost);
423
+ const add = el("button", {
424
+ class: "vs-transition-add",
425
+ title: "Add a new transition",
426
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Transition</span>'
427
+ });
428
+ add.addEventListener("click", (ev) => {
429
+ ev.stopPropagation();
430
+ const id = freshTransitionId(transitions);
431
+ // Default target: first status. requireGate=null, allowFromAny=true.
432
+ const firstStatusId = statuses[0] ? statuses[0].id : "";
433
+ const newTr = {
434
+ id: id,
435
+ name: "New Transition",
436
+ fromStatuses: [],
437
+ toStatus: firstStatusId,
438
+ requireGate: null,
439
+ allowFromAny: true
440
+ };
441
+ commitTransitions(ctx, transitions.concat([newTr]));
442
+ });
443
+ wrap.appendChild(add);
444
+ return wrap;
445
+ }
446
+
447
+ function renderWorkflowSection(project, ctx) {
448
+ const wf = (project && project.workflow) || core.STORYMAPPER_DEFAULT_WORKFLOW;
449
+ return el("section", { class: "vs-section", dataset: { section: "workflow" } },
450
+ el("header", { class: "vs-section-header" },
451
+ el("h2", { text: "Workflow" }),
452
+ el("p", { class: "vs-section-hint",
453
+ text: "Statuses + transitions between them. Drag to reorder, pick a category, click + to add." })
454
+ ),
455
+ el("div", { class: "vs-section-body" },
456
+ el("h3", { class: "vs-subhead", text: "Statuses" }),
457
+ renderStatusEditor(wf, ctx),
458
+ el("h3", { class: "vs-subhead", text: "Transitions" }),
459
+ renderTransitionEditor(wf, ctx)
460
+ )
461
+ );
462
+ }
463
+
464
+ // ---- E21.G board editor -----------------------------------------
465
+ //
466
+ // Edits `project.boards.kanban.columns`. UI: per column a card with name
467
+ // input, status-chip list, and delete button. Above the columns a
468
+ // bucket of "unassigned" status chips (statuses not in any column).
469
+ // Status chips are draggable between buckets — each drop reassigns the
470
+ // status to exactly one column.
471
+
472
+ function commitBoardColumns(ctx, nextColumns) {
473
+ if (!ctx || !ctx.store) return;
474
+ try {
475
+ const cur = ctx.store.get().project.boards || {};
476
+ const nextKanban = Object.assign({}, cur.kanban || {}, { columns: nextColumns });
477
+ const nextBoards = Object.assign({}, cur, { kanban: nextKanban });
478
+ ctx.store.updateProject({ boards: nextBoards }, LOCAL_ACTOR);
479
+ } catch (err) {
480
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
481
+ }
482
+ }
483
+
484
+ function freshColumnId(existing) {
485
+ let i = 1;
486
+ let candidate = "col-" + i;
487
+ const ids = new Set((existing || []).map(c => c.id));
488
+ while (ids.has(candidate)) { i += 1; candidate = "col-" + i; }
489
+ return candidate;
490
+ }
491
+
492
+ // Pure: ensure statusId lives only inside targetColumnId. No-op when the
493
+ // status was already in the target (preserves its existing position).
494
+ function assignStatusToColumn(columns, statusId, targetColumnId) {
495
+ return columns.map(c => {
496
+ if (c.id === targetColumnId) {
497
+ const ids = c.statusIds || [];
498
+ // Already there → keep order untouched (functional no-op).
499
+ if (ids.includes(statusId)) return Object.assign({}, c, { statusIds: ids.slice() });
500
+ return Object.assign({}, c, { statusIds: ids.concat([statusId]) });
501
+ }
502
+ // Non-target: strip the status (no-op if it wasn't here).
503
+ return Object.assign({}, c, {
504
+ statusIds: (c.statusIds || []).filter(s => s !== statusId)
505
+ });
506
+ });
507
+ }
508
+
509
+ // Pure: status entries from the workflow that are NOT in any column.
510
+ function unassignedStatuses(statuses, columns) {
511
+ const assigned = new Set((columns || []).flatMap(c => c.statusIds || []));
512
+ return (statuses || []).filter(s => !assigned.has(s.id));
513
+ }
514
+
515
+ function renderStatusChip(status, ctx) {
516
+ const chip = el("span", {
517
+ class: "vs-board-chip",
518
+ dataset: { statusId: status.id, statusCategory: status.category || "doing" },
519
+ text: status.name || status.id
520
+ });
521
+ if (dnd && typeof dnd.enableDraggable === "function") {
522
+ dnd.enableDraggable(chip, { dragType: "vs-board-status", dragId: status.id });
523
+ }
524
+ return chip;
525
+ }
526
+
527
+ function renderBoardColumn(col, ctx, statuses, allColumns) {
528
+ const card = el("div", {
529
+ class: "vs-board-column",
530
+ dataset: { columnId: col.id }
531
+ });
532
+ const header = el("div", { class: "vs-board-column-header" });
533
+ const nameInput = el("input", {
534
+ type: "text",
535
+ class: "vs-board-column-name",
536
+ value: col.name || "",
537
+ placeholder: "Column name"
538
+ });
539
+ nameInput.addEventListener("blur", () => {
540
+ const v = String(nameInput.value || "").trim();
541
+ if (!v || v === col.name) { nameInput.value = col.name || ""; return; }
542
+ const next = allColumns.map(c => c.id === col.id ? Object.assign({}, c, { name: v }) : c);
543
+ commitBoardColumns(ctx, next);
544
+ });
545
+ const delBtn = el("button", {
546
+ class: "vs-board-column-delete",
547
+ title: "Delete column (statuses become unassigned)",
548
+ text: "×"
549
+ });
550
+ delBtn.addEventListener("click", (ev) => {
551
+ ev.stopPropagation();
552
+ const next = allColumns.filter(c => c.id !== col.id);
553
+ commitBoardColumns(ctx, next);
554
+ });
555
+ header.appendChild(nameInput);
556
+ header.appendChild(delBtn);
557
+ card.appendChild(header);
558
+
559
+ // Status chips inside the column. We render them in the order they
560
+ // appear in column.statusIds (caller can rearrange via drag in the
561
+ // future; for now order tracks insertion).
562
+ const chipsHost = el("div", { class: "vs-board-chips" });
563
+ const byStatusId = new Map(statuses.map(s => [s.id, s]));
564
+ for (const sid of (col.statusIds || [])) {
565
+ const s = byStatusId.get(sid);
566
+ if (!s) continue; // stale ref — normalize will drop it
567
+ chipsHost.appendChild(renderStatusChip(s, ctx));
568
+ }
569
+ card.appendChild(chipsHost);
570
+
571
+ // Column is a drop target — dropping a chip here reassigns it.
572
+ if (dnd && typeof dnd.enableDropTarget === "function") {
573
+ dnd.enableDropTarget(card, {
574
+ accepts: ["vs-board-status"],
575
+ onEnter: (e) => e.classList.add("vs-board-drop-target"),
576
+ onLeave: (e) => e.classList.remove("vs-board-drop-target"),
577
+ onDrop: ({ id }) => {
578
+ card.classList.remove("vs-board-drop-target");
579
+ const next = assignStatusToColumn(allColumns, id, col.id);
580
+ commitBoardColumns(ctx, next);
581
+ }
582
+ });
583
+ }
584
+ return card;
585
+ }
586
+
587
+ function renderBoardEditor(project, ctx) {
588
+ const statuses = (project.workflow && project.workflow.statuses) || [];
589
+ const columns = (project.boards && project.boards.kanban && project.boards.kanban.columns) || [];
590
+ const wrap = el("div", { class: "vs-board-editor" });
591
+
592
+ // Unassigned bucket — shows statuses that aren't in any column. Also a
593
+ // drop target: dropping a chip here removes it from all columns.
594
+ const orphans = unassignedStatuses(statuses, columns);
595
+ const bucket = el("div", { class: "vs-board-unassigned" });
596
+ const bucketLabel = el("div", { class: "vs-board-unassigned-label",
597
+ text: "Unassigned statuses" });
598
+ const bucketChips = el("div", { class: "vs-board-chips" });
599
+ for (const s of orphans) bucketChips.appendChild(renderStatusChip(s, ctx));
600
+ if (orphans.length === 0) {
601
+ bucketChips.appendChild(el("span", { class: "vs-board-empty",
602
+ text: "Every status is mapped to a column." }));
603
+ }
604
+ bucket.appendChild(bucketLabel);
605
+ bucket.appendChild(bucketChips);
606
+ // Drop-target: "remove from every column" by mapping to a sentinel id
607
+ // that doesn't exist → assignStatusToColumn just strips it.
608
+ if (dnd && typeof dnd.enableDropTarget === "function") {
609
+ dnd.enableDropTarget(bucket, {
610
+ accepts: ["vs-board-status"],
611
+ onEnter: (e) => e.classList.add("vs-board-drop-target"),
612
+ onLeave: (e) => e.classList.remove("vs-board-drop-target"),
613
+ onDrop: ({ id }) => {
614
+ bucket.classList.remove("vs-board-drop-target");
615
+ // Strip the status from all columns; no target column means
616
+ // nothing is appended → status becomes unassigned.
617
+ const next = columns.map(c => Object.assign({}, c, {
618
+ statusIds: (c.statusIds || []).filter(s => s !== id)
619
+ }));
620
+ commitBoardColumns(ctx, next);
621
+ }
622
+ });
623
+ }
624
+ wrap.appendChild(bucket);
625
+
626
+ // Columns row.
627
+ const colsHost = el("div", { class: "vs-board-columns" });
628
+ for (const c of columns) {
629
+ colsHost.appendChild(renderBoardColumn(c, ctx, statuses, columns));
630
+ }
631
+ wrap.appendChild(colsHost);
632
+
633
+ // Add column.
634
+ const add = el("button", {
635
+ class: "vs-board-add-column",
636
+ title: "Add a new column",
637
+ html: '<span class="sm-add-icon">+</span><span class="sm-add-label">Add Column</span>'
638
+ });
639
+ add.addEventListener("click", (ev) => {
640
+ ev.stopPropagation();
641
+ const id = freshColumnId(columns);
642
+ const next = columns.concat([{ id: id, name: "New Column", statusIds: [] }]);
643
+ commitBoardColumns(ctx, next);
644
+ });
645
+ wrap.appendChild(add);
646
+ return wrap;
647
+ }
648
+
649
+ function renderBoardSection(project, ctx) {
650
+ return el("section", { class: "vs-section", dataset: { section: "board" } },
651
+ el("header", { class: "vs-section-header" },
652
+ el("h2", { text: "Kanban Board" }),
653
+ el("p", { class: "vs-section-hint",
654
+ text: "Group statuses into Kanban columns. Drag a status chip between buckets to remap." })
655
+ ),
656
+ el("div", { class: "vs-section-body" },
657
+ renderBoardEditor(project, ctx)
658
+ )
659
+ );
660
+ }
661
+
662
+ // ---- SM-47: Link-Types editor ----------------------------------------
663
+ //
664
+ // List the project's linkTypes catalogue with inline-editable label and
665
+ // inverseLabel inputs, semantic-picker dropdown, color-picker, and
666
+ // delete-button. Same live-apply pattern as the rest of view-settings:
667
+ // discrete actions (add/remove/semantic-change/color-change) commit
668
+ // immediately; text inputs commit on-blur (caret-stable across the
669
+ // rerender via focus-guard in renderInto).
670
+
671
+ function commitLinkTypes(ctx, nextLinkTypes) {
672
+ if (!ctx || !ctx.store) return;
673
+ try {
674
+ ctx.store.updateProject({ linkTypes: nextLinkTypes }, LOCAL_ACTOR);
675
+ } catch (err) {
676
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
677
+ }
678
+ }
679
+
680
+ function renderLinkTypeRow(lt, ctx, allLinkTypes, idx) {
681
+ const row = el("div", { class: "vs-linktype-row", dataset: { linktypeId: lt.id } });
682
+
683
+ const idDisp = el("span", { class: "vs-linktype-id", text: lt.id, title: "Stable ID (referenced by links — not editable)" });
684
+
685
+ const labelInput = el("input", { type: "text", class: "vs-linktype-label", value: lt.label });
686
+ labelInput.addEventListener("blur", () => {
687
+ const v = String(labelInput.value || "").trim();
688
+ if (!v || v === lt.label) { labelInput.value = lt.label; return; }
689
+ const next = allLinkTypes.slice();
690
+ next[idx] = Object.assign({}, lt, { label: v });
691
+ commitLinkTypes(ctx, next);
692
+ });
693
+
694
+ const inverseInput = el("input", { type: "text", class: "vs-linktype-inverse", value: lt.inverseLabel });
695
+ inverseInput.addEventListener("blur", () => {
696
+ const v = String(inverseInput.value || "").trim();
697
+ if (!v || v === lt.inverseLabel) { inverseInput.value = lt.inverseLabel; return; }
698
+ const next = allLinkTypes.slice();
699
+ next[idx] = Object.assign({}, lt, { inverseLabel: v });
700
+ commitLinkTypes(ctx, next);
701
+ });
702
+
703
+ const semSelect = el("select", { class: "vs-linktype-semantic" });
704
+ const semantics = (core.LINK_SEMANTICS || ["precedence", "blocking", "sequence", "containment", "validation", "freeform"]);
705
+ for (const sem of semantics) {
706
+ const opt = el("option", { value: sem, text: sem });
707
+ if (sem === lt.semantic) opt.selected = true;
708
+ semSelect.appendChild(opt);
709
+ }
710
+ semSelect.addEventListener("change", () => {
711
+ const next = allLinkTypes.slice();
712
+ next[idx] = Object.assign({}, lt, { semantic: semSelect.value });
713
+ commitLinkTypes(ctx, next);
714
+ });
715
+
716
+ const colorInput = el("input", { type: "color", class: "vs-linktype-color", value: lt.color || "#888888" });
717
+ colorInput.addEventListener("change", () => {
718
+ const next = allLinkTypes.slice();
719
+ next[idx] = Object.assign({}, lt, { color: colorInput.value });
720
+ commitLinkTypes(ctx, next);
721
+ });
722
+
723
+ const delBtn = el("button", { class: "vs-linktype-delete", title: "Delete link type", text: "×" });
724
+ delBtn.addEventListener("click", (ev) => {
725
+ ev.stopPropagation();
726
+ // Confirm if the link-type is currently referenced by any link.
727
+ const snap = ctx.store ? ctx.store.get() : null;
728
+ let inUse = 0;
729
+ if (snap) {
730
+ for (const t of snap.tickets || []) {
731
+ for (const l of t.links || []) if (l.linkTypeId === lt.id) inUse++;
732
+ }
733
+ }
734
+ if (inUse > 0 && typeof confirm === "function") {
735
+ if (!confirm("Link type \"" + lt.label + "\" is used in " + inUse + " link(s). Delete anyway? The links themselves stay but lose their resolved label.")) return;
736
+ }
737
+ const next = allLinkTypes.filter((_, i) => i !== idx);
738
+ commitLinkTypes(ctx, next);
739
+ });
740
+
741
+ row.appendChild(idDisp);
742
+ row.appendChild(labelInput);
743
+ row.appendChild(inverseInput);
744
+ row.appendChild(semSelect);
745
+ row.appendChild(colorInput);
746
+ row.appendChild(delBtn);
747
+ return row;
748
+ }
749
+
750
+ function renderLinkTypeAddRow(ctx, allLinkTypes) {
751
+ const wrap = el("div", { class: "vs-linktype-add-wrap" });
752
+ const btn = el("button", { class: "btn vs-linktype-add", text: "+ Add Link Type" });
753
+ btn.addEventListener("click", () => {
754
+ // Generate a unique id (custom-N).
755
+ let n = 1, newId = "custom-" + n;
756
+ while (allLinkTypes.some(lt => lt.id === newId)) { n++; newId = "custom-" + n; }
757
+ const next = allLinkTypes.concat([{
758
+ id: newId, label: "Custom " + n, inverseLabel: "Custom " + n, semantic: "freeform"
759
+ }]);
760
+ commitLinkTypes(ctx, next);
761
+ });
762
+ wrap.appendChild(btn);
763
+ return wrap;
764
+ }
765
+
766
+ function renderLinkTypesEditor(project, ctx) {
767
+ const linkTypes = (project && project.linkTypes) || [];
768
+ const wrap = el("div", { class: "vs-linktypes-editor" });
769
+ // Column headers (above the rows) so labels are self-documenting.
770
+ const head = el("div", { class: "vs-linktype-row vs-linktype-head" });
771
+ head.appendChild(el("span", { class: "vs-linktype-id", text: "ID" }));
772
+ head.appendChild(el("span", { class: "vs-linktype-col-label", text: "Label" }));
773
+ head.appendChild(el("span", { class: "vs-linktype-col-label", text: "Inverse label" }));
774
+ head.appendChild(el("span", { class: "vs-linktype-col-label", text: "Semantic" }));
775
+ head.appendChild(el("span", { class: "vs-linktype-col-label", text: "Color" }));
776
+ head.appendChild(el("span", { class: "vs-linktype-col-label", text: "" }));
777
+ wrap.appendChild(head);
778
+ const rowsHost = el("div", { class: "vs-linktype-rows" });
779
+ linkTypes.forEach((lt, i) => rowsHost.appendChild(renderLinkTypeRow(lt, ctx, linkTypes, i)));
780
+ wrap.appendChild(rowsHost);
781
+ wrap.appendChild(renderLinkTypeAddRow(ctx, linkTypes));
782
+ return wrap;
783
+ }
784
+
785
+ function renderLinkTypesSection(project, ctx) {
786
+ return el("section", { class: "vs-section", dataset: { section: "links" } },
787
+ el("header", { class: "vs-section-header" },
788
+ el("h2", { text: "Link Types" }),
789
+ el("p", { class: "vs-section-hint",
790
+ text: "Typed relations between tickets (predecessor, blocks, contains, …). The semantic controls runtime behaviour (cycle-check) and inverse-label rendering. Deleting a type doesn't delete its links — they stay but lose the catalogue-resolved label." })
791
+ ),
792
+ el("div", { class: "vs-section-body" },
793
+ renderLinkTypesEditor(project, ctx)
794
+ )
795
+ );
796
+ }
797
+
798
+ // ---- SM-7: Ticket-Types editor ---------------------------------------
799
+ //
800
+ // project.ticketTypes is a simple string[] (e.g. ["epic", "user-story",
801
+ // "bug", "technical-task-backend", "technical-task-ui"]). The editor is
802
+ // a sortable list of name inputs with on-blur rename, + Add, ×, and DnD-
803
+ // reorder. Live-apply: every discrete action commits via store.updateProject.
804
+ // normalizeProject reseeds defaults if the list ends up empty, which
805
+ // protects against lock-out — but we still confirm deletion when the user
806
+ // tries to remove a type that's currently in use.
807
+
808
+ function commitTicketTypes(ctx, nextTypes) {
809
+ if (!ctx || !ctx.store) return;
810
+ try {
811
+ ctx.store.updateProject({ ticketTypes: nextTypes }, LOCAL_ACTOR);
812
+ } catch (err) {
813
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
814
+ }
815
+ }
816
+
817
+ function countTicketsOfType(snapshot, typeName) {
818
+ if (!snapshot) return 0;
819
+ let n = 0;
820
+ for (const t of (snapshot.tickets || [])) if (t.type === typeName && !t.isDeleted) n++;
821
+ return n;
822
+ }
823
+
824
+ function renderTicketTypeRow(typeName, ctx, allTypes, idx) {
825
+ const row = el("div", { class: "vs-tickettype-row", dataset: { typeName: typeName } });
826
+ row.appendChild(el("span", { class: "vs-grip", text: "⋮⋮" }));
827
+ const input = el("input", { type: "text", class: "vs-tickettype-name", value: typeName });
828
+ input.addEventListener("blur", () => {
829
+ const v = String(input.value || "").trim();
830
+ if (!v || v === typeName) { input.value = typeName; return; }
831
+ if (allTypes.indexOf(v) >= 0 && v !== typeName) {
832
+ if (ctx.flashStatus) ctx.flashStatus("type \"" + v + "\" already exists", { kind: "error" });
833
+ input.value = typeName;
834
+ return;
835
+ }
836
+ const next = allTypes.slice();
837
+ next[idx] = v;
838
+ commitTicketTypes(ctx, next);
839
+ });
840
+
841
+ const del = el("button", { class: "vs-tickettype-delete", title: "Delete ticket type", text: "×" });
842
+ del.addEventListener("click", (ev) => {
843
+ ev.stopPropagation();
844
+ const snap = ctx.store ? ctx.store.get() : null;
845
+ const inUse = countTicketsOfType(snap, typeName);
846
+ if (inUse > 0 && typeof confirm === "function") {
847
+ if (!confirm("Ticket type \"" + typeName + "\" is used by " + inUse + " ticket(s). They will keep their type label even after deletion. Delete anyway?")) return;
848
+ }
849
+ const next = allTypes.filter((_, i) => i !== idx);
850
+ commitTicketTypes(ctx, next);
851
+ });
852
+
853
+ row.appendChild(input);
854
+ row.appendChild(del);
855
+ return row;
856
+ }
857
+
858
+ function reorderTicketTypes(types, sourceName, targetName, before) {
859
+ const list = types.slice();
860
+ const srcIdx = list.indexOf(sourceName);
861
+ if (srcIdx < 0) return list;
862
+ const [moved] = list.splice(srcIdx, 1);
863
+ let dstIdx = list.indexOf(targetName);
864
+ if (dstIdx < 0) { list.push(moved); return list; }
865
+ list.splice(before ? dstIdx : dstIdx + 1, 0, moved);
866
+ return list;
867
+ }
868
+
869
+ function attachTicketTypeRowDnD(row, typeName, allTypes, ctx) {
870
+ if (!dnd) return;
871
+ dnd.enableDraggable(row, { dragType: "vs-tickettype", dragId: typeName });
872
+ dnd.enableDropTarget(row, {
873
+ accepts: ["vs-tickettype"],
874
+ onMove: (info) => {
875
+ if (info.draggedId === typeName) return;
876
+ row.classList.add("vs-tickettype-drop-target");
877
+ },
878
+ onLeave: () => row.classList.remove("vs-tickettype-drop-target"),
879
+ onDrop: (info) => {
880
+ row.classList.remove("vs-tickettype-drop-target");
881
+ if (info.draggedId === typeName) return;
882
+ const rect = row.getBoundingClientRect();
883
+ const before = info.clientY < rect.top + rect.height / 2;
884
+ const next = reorderTicketTypes(allTypes, info.draggedId, typeName, before);
885
+ commitTicketTypes(ctx, next);
886
+ }
887
+ });
888
+ }
889
+
890
+ function renderTicketTypesEditor(project, ctx) {
891
+ const types = (project && project.ticketTypes) || [];
892
+ const wrap = el("div", { class: "vs-tickettypes-editor" });
893
+ const rowsHost = el("div", { class: "vs-tickettype-rows" });
894
+ types.forEach((name, i) => {
895
+ const row = renderTicketTypeRow(name, ctx, types, i);
896
+ attachTicketTypeRowDnD(row, name, types, ctx);
897
+ rowsHost.appendChild(row);
898
+ });
899
+ wrap.appendChild(rowsHost);
900
+ const addBtn = el("button", { class: "btn vs-tickettype-add", text: "+ Add Ticket Type" });
901
+ addBtn.addEventListener("click", () => {
902
+ let n = 1, name = "custom-type-" + n;
903
+ while (types.indexOf(name) >= 0) { n++; name = "custom-type-" + n; }
904
+ commitTicketTypes(ctx, types.concat([name]));
905
+ });
906
+ wrap.appendChild(addBtn);
907
+ return wrap;
908
+ }
909
+
910
+ function renderTicketTypesSection(project, ctx) {
911
+ return el("section", { class: "vs-section", dataset: { section: "types" } },
912
+ el("header", { class: "vs-section-header" },
913
+ el("h2", { text: "Ticket Types" }),
914
+ el("p", { class: "vs-section-hint",
915
+ text: "The types tickets can have (epic / user-story / bug / …). Rename on blur. Deleting a type does not retype the tickets that use it. If the list becomes empty, the default set is reseeded automatically." })
916
+ ),
917
+ el("div", { class: "vs-section-body" },
918
+ renderTicketTypesEditor(project, ctx)
919
+ )
920
+ );
921
+ }
922
+
923
+ // ---- SM-9: Type Config editor (entityTypeConfig per ticket-type) -----
924
+ //
925
+ // project.entityTypeConfig maps ticket-type → flags that govern what the
926
+ // ticket-detail modal shows or allows. Defaults: every flag = true except
927
+ // allowParentEpic = false for type === "epic" (epics never nest under
928
+ // an epic). The editor: type-picker dropdown + 7 toggles. Live-apply:
929
+ // each toggle change commits via store.updateProject. The type "epic"
930
+ // is special — its allowParentEpic toggle is always disabled (engine
931
+ // hard-codes false), surfaced as a read-only marker.
932
+
933
+ // Module-state: which type is currently being edited. Resets to first
934
+ // ticketType on every fresh mount.
935
+ let _typeConfigSelectedType = null;
936
+
937
+ const TYPE_CONFIG_FLAGS = [
938
+ { key: "allowParentEpic", label: "Allow parent epic",
939
+ hint: "Ticket may live under an epic via the contains-link." },
940
+ { key: "showAcceptanceCriteria", label: "Show acceptance criteria",
941
+ hint: "Section appears in the ticket detail modal." },
942
+ { key: "showDefinitionOfReady", label: "Show Definition of Ready",
943
+ hint: "DoR checklist section appears in the modal." },
944
+ { key: "showDefinitionOfDone", label: "Show Definition of Done",
945
+ hint: "DoD checklist section appears in the modal." },
946
+ { key: "showRelease", label: "Show release picker",
947
+ hint: "Release dropdown appears in the position fields." },
948
+ { key: "showProcessStep", label: "Show process-step picker",
949
+ hint: "Process-step dropdown appears in the position fields." },
950
+ { key: "showLinks", label: "Show links section",
951
+ hint: "Links section appears in the modal (forward + backward)." },
952
+ // SM-59 — test-type sections. SM-100: only offered for the matching test
953
+ // type; the engine hard-locks them to false for every other type, so
954
+ // showing the toggle elsewhere would just confuse the user.
955
+ { key: "showPrerequisites", label: "Show prerequisites",
956
+ hint: "Prerequisites checklist appears in the modal.",
957
+ appliesTo: ["test-definition"] },
958
+ { key: "showSteps", label: "Show steps editor",
959
+ hint: "3-column step table (Step / Data / Expected).",
960
+ appliesTo: ["test-definition"] },
961
+ { key: "showExecutionSteps", label: "Show execution steps",
962
+ hint: "6-column run table with actual + status + note.",
963
+ appliesTo: ["test-execution"] },
964
+ { key: "showTestOutcome", label: "Show outcome section",
965
+ hint: "Derived outcome + manual override picker.",
966
+ appliesTo: ["test-execution"] }
967
+ ];
968
+
969
+ function commitTypeConfig(ctx, nextConfig) {
970
+ if (!ctx || !ctx.store) return;
971
+ try {
972
+ ctx.store.updateProject({ entityTypeConfig: nextConfig }, LOCAL_ACTOR);
973
+ } catch (err) {
974
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
975
+ }
976
+ }
977
+
978
+ function renderTypeConfigEditor(project, ctx) {
979
+ const wrap = el("div", { class: "vs-typeconfig-editor" });
980
+ const types = (project && project.ticketTypes) || [];
981
+ if (!types.length) {
982
+ wrap.appendChild(el("div", { class: "vs-empty",
983
+ text: "No ticket types defined yet — add some in the Ticket Types tab first." }));
984
+ return wrap;
985
+ }
986
+ // Choose the type to edit. Default to the first ticketType; preserve
987
+ // user selection across rerenders so an external commit doesn't drop them.
988
+ if (!_typeConfigSelectedType || types.indexOf(_typeConfigSelectedType) < 0) {
989
+ _typeConfigSelectedType = types[0];
990
+ }
991
+ const currentType = _typeConfigSelectedType;
992
+
993
+ // Type-picker row.
994
+ const pickerRow = el("div", { class: "vs-typeconfig-picker-row" });
995
+ pickerRow.appendChild(el("label", { class: "tm-label", text: "Type" }));
996
+ const picker = el("select", { class: "vs-typeconfig-type-select" });
997
+ for (const t of types) {
998
+ const opt = el("option", { value: t, text: t });
999
+ if (t === currentType) opt.selected = true;
1000
+ picker.appendChild(opt);
1001
+ }
1002
+ picker.addEventListener("change", () => {
1003
+ _typeConfigSelectedType = picker.value;
1004
+ // Re-render the editor to reflect the new selection.
1005
+ const newEditor = renderTypeConfigEditor(project, ctx);
1006
+ wrap.parentNode.replaceChild(newEditor, wrap);
1007
+ });
1008
+ pickerRow.appendChild(picker);
1009
+ wrap.appendChild(pickerRow);
1010
+
1011
+ // Resolve the effective config so defaults render correctly when a type
1012
+ // has no override yet.
1013
+ const effective = (typeof core.getEntityTypeConfig === "function")
1014
+ ? core.getEntityTypeConfig(project, currentType)
1015
+ : null;
1016
+ if (!effective) {
1017
+ wrap.appendChild(el("div", { class: "vs-empty", text: "Core helper missing." }));
1018
+ return wrap;
1019
+ }
1020
+
1021
+ // Flag-toggle list.
1022
+ const flagList = el("div", { class: "vs-typeconfig-flags" });
1023
+ for (const flag of TYPE_CONFIG_FLAGS) {
1024
+ // SM-100: test-flags only render for their matching type. The engine
1025
+ // hard-locks them to false elsewhere, so showing the control would
1026
+ // mislead the user into thinking it could be flipped.
1027
+ if (flag.appliesTo && flag.appliesTo.indexOf(currentType) < 0) continue;
1028
+ // Epic-special: allowParentEpic is hard-coded false for type "epic".
1029
+ const isEpicLock = (currentType === "epic" && flag.key === "allowParentEpic");
1030
+ const row = el("div", { class: "vs-typeconfig-flag" + (isEpicLock ? " vs-typeconfig-flag-locked" : ""),
1031
+ dataset: { flagKey: flag.key } });
1032
+ const cb = el("input", { type: "checkbox", class: "vs-typeconfig-flag-input" });
1033
+ cb.checked = effective[flag.key] !== false;
1034
+ if (isEpicLock) cb.disabled = true;
1035
+ cb.addEventListener("change", () => {
1036
+ // Read the current override (if any) so we patch onto it; otherwise
1037
+ // start fresh from the defaults.
1038
+ const allCfg = Object.assign({}, project.entityTypeConfig || {});
1039
+ const typeCfg = Object.assign({}, allCfg[currentType] || {});
1040
+ typeCfg[flag.key] = !!cb.checked;
1041
+ allCfg[currentType] = typeCfg;
1042
+ commitTypeConfig(ctx, allCfg);
1043
+ });
1044
+ const lbl = el("label", { class: "vs-typeconfig-flag-label" });
1045
+ lbl.appendChild(cb);
1046
+ lbl.appendChild(el("span", { class: "vs-typeconfig-flag-text", text: flag.label }));
1047
+ row.appendChild(lbl);
1048
+ if (flag.hint) row.appendChild(el("div", { class: "vs-typeconfig-flag-hint", text: flag.hint }));
1049
+ if (isEpicLock) row.appendChild(el("div", { class: "vs-typeconfig-flag-locked-note",
1050
+ text: "Locked: epics never nest under another epic." }));
1051
+ flagList.appendChild(row);
1052
+ }
1053
+ wrap.appendChild(flagList);
1054
+ return wrap;
1055
+ }
1056
+
1057
+ function renderTypeConfigSection(project, ctx) {
1058
+ return el("section", { class: "vs-section", dataset: { section: "typeconfig" } },
1059
+ el("header", { class: "vs-section-header" },
1060
+ el("h2", { text: "Type Config" }),
1061
+ el("p", { class: "vs-section-hint",
1062
+ text: "Per ticket-type: which sections appear in the detail modal and whether the type may live under an epic. Pick a type above, toggle the flags — changes apply live to every modal of that type." })
1063
+ ),
1064
+ el("div", { class: "vs-section-body" },
1065
+ renderTypeConfigEditor(project, ctx)
1066
+ )
1067
+ );
1068
+ }
1069
+
1070
+ // ---- SM-8: Labels editor ---------------------------------------------
1071
+ //
1072
+ // project.labels is normalized to `[{id, name, color}]`. ticket.labels
1073
+ // remains a string[] of label-names — the modal renders ticket labels
1074
+ // by looking up the catalogue color via name match. Editor: sortable
1075
+ // rows with name-input (on-blur), color-picker (on-change), delete (with
1076
+ // confirm if labels are referenced).
1077
+
1078
+ function commitLabels(ctx, nextLabels) {
1079
+ if (!ctx || !ctx.store) return;
1080
+ try {
1081
+ ctx.store.updateProject({ labels: nextLabels }, LOCAL_ACTOR);
1082
+ } catch (err) {
1083
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "save failed", { kind: "error" });
1084
+ }
1085
+ }
1086
+
1087
+ function countLabelUses(snapshot, labelName) {
1088
+ if (!snapshot) return 0;
1089
+ let n = 0;
1090
+ for (const t of (snapshot.tickets || [])) {
1091
+ if (t.isDeleted) continue;
1092
+ const labels = t.labels || [];
1093
+ for (const l of labels) {
1094
+ const lname = typeof l === "string" ? l : (l && l.name) || "";
1095
+ if (lname === labelName) { n++; break; }
1096
+ }
1097
+ }
1098
+ return n;
1099
+ }
1100
+
1101
+ function renderLabelRow(label, ctx, allLabels, idx) {
1102
+ const row = el("div", { class: "vs-label-row", dataset: { labelId: label.id } });
1103
+ row.appendChild(el("span", { class: "vs-grip", text: "⋮⋮" }));
1104
+
1105
+ const swatch = el("span", { class: "vs-label-swatch" });
1106
+ swatch.style.backgroundColor = label.color || "#999999";
1107
+
1108
+ const nameInput = el("input", { type: "text", class: "vs-label-name", value: label.name });
1109
+ nameInput.addEventListener("blur", () => {
1110
+ const v = String(nameInput.value || "").trim();
1111
+ if (!v || v === label.name) { nameInput.value = label.name; return; }
1112
+ const next = allLabels.slice();
1113
+ next[idx] = Object.assign({}, label, { name: v });
1114
+ commitLabels(ctx, next);
1115
+ });
1116
+
1117
+ const colorInput = el("input", { type: "color", class: "vs-label-color", value: label.color || "#999999" });
1118
+ colorInput.addEventListener("change", () => {
1119
+ const next = allLabels.slice();
1120
+ next[idx] = Object.assign({}, label, { color: colorInput.value });
1121
+ commitLabels(ctx, next);
1122
+ });
1123
+
1124
+ const del = el("button", { class: "vs-label-delete", title: "Delete label", text: "×" });
1125
+ del.addEventListener("click", (ev) => {
1126
+ ev.stopPropagation();
1127
+ const snap = ctx.store ? ctx.store.get() : null;
1128
+ const inUse = countLabelUses(snap, label.name);
1129
+ if (inUse > 0 && typeof confirm === "function") {
1130
+ if (!confirm("Label \"" + label.name + "\" is used on " + inUse + " ticket(s). Delete from the catalogue? Tickets keep the label string but lose the color.")) return;
1131
+ }
1132
+ const next = allLabels.filter((_, i) => i !== idx);
1133
+ commitLabels(ctx, next);
1134
+ });
1135
+
1136
+ row.appendChild(swatch);
1137
+ row.appendChild(nameInput);
1138
+ row.appendChild(colorInput);
1139
+ row.appendChild(del);
1140
+ return row;
1141
+ }
1142
+
1143
+ function reorderLabels(labels, sourceId, targetId, before) {
1144
+ const list = labels.slice();
1145
+ const srcIdx = list.findIndex(l => l.id === sourceId);
1146
+ if (srcIdx < 0) return list;
1147
+ const [moved] = list.splice(srcIdx, 1);
1148
+ let dstIdx = list.findIndex(l => l.id === targetId);
1149
+ if (dstIdx < 0) { list.push(moved); return list; }
1150
+ list.splice(before ? dstIdx : dstIdx + 1, 0, moved);
1151
+ return list;
1152
+ }
1153
+
1154
+ function attachLabelRowDnD(row, label, allLabels, ctx) {
1155
+ if (!dnd) return;
1156
+ dnd.enableDraggable(row, { dragType: "vs-label", dragId: label.id });
1157
+ dnd.enableDropTarget(row, {
1158
+ accepts: ["vs-label"],
1159
+ onMove: (info) => {
1160
+ if (info.draggedId === label.id) return;
1161
+ row.classList.add("vs-label-drop-target");
1162
+ },
1163
+ onLeave: () => row.classList.remove("vs-label-drop-target"),
1164
+ onDrop: (info) => {
1165
+ row.classList.remove("vs-label-drop-target");
1166
+ if (info.draggedId === label.id) return;
1167
+ const rect = row.getBoundingClientRect();
1168
+ const before = info.clientY < rect.top + rect.height / 2;
1169
+ const next = reorderLabels(allLabels, info.draggedId, label.id, before);
1170
+ commitLabels(ctx, next);
1171
+ }
1172
+ });
1173
+ }
1174
+
1175
+ function renderLabelsEditor(project, ctx) {
1176
+ const labels = (project && project.labels) || [];
1177
+ const wrap = el("div", { class: "vs-labels-editor" });
1178
+ if (!labels.length) {
1179
+ wrap.appendChild(el("div", { class: "vs-empty",
1180
+ text: "No labels yet. Add one below — they'll become available in the ticket-detail modal's Labels field." }));
1181
+ }
1182
+ const rowsHost = el("div", { class: "vs-label-rows" });
1183
+ labels.forEach((label, i) => {
1184
+ const row = renderLabelRow(label, ctx, labels, i);
1185
+ attachLabelRowDnD(row, label, labels, ctx);
1186
+ rowsHost.appendChild(row);
1187
+ });
1188
+ wrap.appendChild(rowsHost);
1189
+ const addBtn = el("button", { class: "btn vs-label-add", text: "+ Add Label" });
1190
+ addBtn.addEventListener("click", () => {
1191
+ let n = 1, name = "label-" + n;
1192
+ while (labels.some(l => l.name === name)) { n++; name = "label-" + n; }
1193
+ commitLabels(ctx, labels.concat([{ name: name, color: "#999999" }]));
1194
+ });
1195
+ wrap.appendChild(addBtn);
1196
+ return wrap;
1197
+ }
1198
+
1199
+ function renderLabelsSection(project, ctx) {
1200
+ return el("section", { class: "vs-section", dataset: { section: "labels" } },
1201
+ el("header", { class: "vs-section-header" },
1202
+ el("h2", { text: "Labels" }),
1203
+ el("p", { class: "vs-section-hint",
1204
+ text: "Catalogue of labels (name + color) that can be applied to tickets. Tickets reference labels by name — renaming here does not retroactively update existing tickets." })
1205
+ ),
1206
+ el("div", { class: "vs-section-body" },
1207
+ renderLabelsEditor(project, ctx)
1208
+ )
1209
+ );
1210
+ }
1211
+
1212
+ // ---- SM-2: DoR/DoD definitions editor ----------------------------
1213
+ //
1214
+ // Ported from the deprecated dialog-definitions-settings.js (removed in SM-3).
1215
+ // Same UI shape: global DoR/DoD blocks + per-ticket-type configuration
1216
+ // (enable toggle, mode-picker inherit/append/override, items list).
1217
+ //
1218
+ // Live-apply pattern matching the rest of view-settings: each discrete
1219
+ // user action (add/remove/reorder/required-toggle/mode-change/enable-
1220
+ // toggle/label-blur) re-reads the entire section's DOM via collectFormState
1221
+ // and commits a single store.updateProject({definitions, workflow,
1222
+ // entityTypeConfig}) — the workflow+entityTypeConfig changes piggyback so
1223
+ // the gate-enabled toggle stays in lockstep with the ticket-modal's
1224
+ // showDefinitionOfReady/Done visibility flags.
1225
+ //
1226
+ // Pure helpers (readFormState, collectFormState) are exported for tests.
1227
+
1228
+ function cssEscape(s) {
1229
+ if (typeof window !== "undefined" && window.CSS && typeof window.CSS.escape === "function") {
1230
+ return window.CSS.escape(s);
1231
+ }
1232
+ return String(s || "").replace(/[^a-zA-Z0-9_-]/g, "\\$&");
1233
+ }
1234
+
1235
+ function cloneDefItem(it) {
1236
+ return {
1237
+ id: typeof it.id === "string" && it.id ? it.id : freshDefItemId(),
1238
+ label: typeof it.label === "string" ? it.label : "",
1239
+ required: it.required !== false
1240
+ };
1241
+ }
1242
+
1243
+ /**
1244
+ * Per-(type, gate) state: enabled flag is derived from the workflow's
1245
+ * `transitions[].requireGate`; mode + items come from `definitions.<gate>
1246
+ * .byType[type]` (missing → inherit).
1247
+ */
1248
+ function readGateState(project, type, statusKey, gateLabel) {
1249
+ const wf = core.getWorkflowForType(project, type);
1250
+ const trans = Array.isArray(wf.transitions) ? wf.transitions : [];
1251
+ const match = trans.find(t => t.toStatus === statusKey);
1252
+ const enabled = !!(match && match.requireGate === gateLabel);
1253
+ const defsBlock = (statusKey === "ready")
1254
+ ? (project.definitions && project.definitions.ready)
1255
+ : (project.definitions && project.definitions.done);
1256
+ const perType = defsBlock && defsBlock.byType && defsBlock.byType[type];
1257
+ let mode = "inherit", items = [];
1258
+ if (perType) {
1259
+ if (Array.isArray(perType.overridden)) { mode = "override"; items = perType.overridden.map(cloneDefItem); }
1260
+ else if (Array.isArray(perType.appended)) { mode = "append"; items = perType.appended.map(cloneDefItem); }
1261
+ }
1262
+ return { enabled, mode, items };
1263
+ }
1264
+
1265
+ function readFormState(project) {
1266
+ const types = {};
1267
+ (project.ticketTypes || []).forEach((t) => {
1268
+ types[t] = {
1269
+ dor: readGateState(project, t, "ready", "DoR"),
1270
+ dod: readGateState(project, t, "done", "DoD")
1271
+ };
1272
+ });
1273
+ return {
1274
+ globalDor: ((project.definitions && project.definitions.ready && project.definitions.ready.global) || []).map(cloneDefItem),
1275
+ globalDod: ((project.definitions && project.definitions.done && project.definitions.done.global) || []).map(cloneDefItem),
1276
+ types: types
1277
+ };
1278
+ }
1279
+
1280
+ function readItemsFrom(host) {
1281
+ if (!host) return [];
1282
+ const rows = host.querySelectorAll(".vs-def-item-row");
1283
+ const out = [];
1284
+ rows.forEach((row) => {
1285
+ const labelInput = row.querySelector("input[type='text']");
1286
+ const checkbox = row.querySelector("input[type='checkbox']");
1287
+ const label = (labelInput && labelInput.value || "").trim();
1288
+ if (!label) return; // drop empty rows
1289
+ out.push({
1290
+ id: row.dataset.itemId || freshDefItemId(),
1291
+ label: label,
1292
+ required: !!(checkbox && checkbox.checked)
1293
+ });
1294
+ });
1295
+ return out;
1296
+ }
1297
+
1298
+ /**
1299
+ * Read the section's full DOM into a patch `{ definitions, workflow,
1300
+ * entityTypeConfig }`. Workflow's `byType[type].transitions` for `ready`
1301
+ * and `done` are replaced; other transitions in the array are preserved.
1302
+ */
1303
+ function collectFormState(sectionEl, project) {
1304
+ const gDor = readItemsFrom(sectionEl.querySelector("[data-vs-def-section='global-dor']"));
1305
+ const gDod = readItemsFrom(sectionEl.querySelector("[data-vs-def-section='global-dod']"));
1306
+
1307
+ // SM-38: preserve byType entries for types whose blocks are NOT in the
1308
+ // DOM (the dropdown-based UI renders one type at a time). Without
1309
+ // pre-seeding, switching the dropdown would silently delete the
1310
+ // per-type definitions of every other type on the next commit.
1311
+ const baseDefs = project.definitions || {};
1312
+ const baseDefsReadyByType = (baseDefs.ready && baseDefs.ready.byType) || {};
1313
+ const baseDefsDoneByType = (baseDefs.done && baseDefs.done.byType) || {};
1314
+ const newDefs = {
1315
+ ready: { global: gDor, byType: Object.assign({}, baseDefsReadyByType) },
1316
+ done: { global: gDod, byType: Object.assign({}, baseDefsDoneByType) }
1317
+ };
1318
+
1319
+ const baseWf = project.workflow || {};
1320
+ const newWf = {
1321
+ statuses: Array.isArray(baseWf.statuses) ? baseWf.statuses.slice() : undefined,
1322
+ transitions: Array.isArray(baseWf.transitions) ? baseWf.transitions.slice() : baseWf.transitions,
1323
+ byType: Object.assign({}, baseWf.byType || {})
1324
+ };
1325
+
1326
+ const baseEtc = project.entityTypeConfig || {};
1327
+ const newEtc = Object.assign({}, baseEtc);
1328
+
1329
+ // SM-38: iterate the DOM blocks present (could be just one in dropdown
1330
+ // mode, or all of them in legacy/test scenarios). Pull the type from
1331
+ // dataset rather than from project.ticketTypes.
1332
+ const blocks = sectionEl.querySelectorAll(".vs-def-type-block[data-vs-def-type]");
1333
+ blocks.forEach((block) => {
1334
+ const type = block.dataset.vsDefType;
1335
+ if (!type) return;
1336
+ const dorEnabled = !!(block.querySelector("[data-vs-def-enabled='dor']") || {}).checked;
1337
+ const dodEnabled = !!(block.querySelector("[data-vs-def-enabled='dod']") || {}).checked;
1338
+
1339
+ const prevByType = newWf.byType[type] || {};
1340
+ const prevTrans = Array.isArray(prevByType.transitions) ? prevByType.transitions : [];
1341
+ const otherTrans = prevTrans.filter(t => t.toStatus !== "ready" && t.toStatus !== "done");
1342
+ newWf.byType[type] = Object.assign({}, prevByType, {
1343
+ transitions: otherTrans.concat([
1344
+ { id: "to-ready", name: "→ Ready", fromStatuses: [], toStatus: "ready",
1345
+ requireGate: dorEnabled ? "DoR" : null, allowFromAny: true },
1346
+ { id: "to-done", name: "→ Done", fromStatuses: [], toStatus: "done",
1347
+ requireGate: dodEnabled ? "DoD" : null, allowFromAny: true }
1348
+ ])
1349
+ });
1350
+
1351
+ // SM-38 follow-up: DO NOT couple entityTypeConfig.show* to the Required
1352
+ // toggle. Whether the DoR/DoD section appears in the ticket modal is a
1353
+ // separate concern from whether the gate is enforced on transitions.
1354
+ // newEtc[type] stays at its pre-existing value (default true), so per-
1355
+ // type DoR/DoD checklists remain visible as human reminders even when
1356
+ // not gate-enforced. Users who want to hide the section can set
1357
+ // entityTypeConfig.showDefinitionOf* via project_update.
1358
+
1359
+ // For the RENDERED type, the byType entry must reflect the current UI
1360
+ // state — including "deleted" when mode=inherit. The pre-seed from
1361
+ // baseDefs handles non-rendered types; here we explicitly drop /
1362
+ // override entries for this visible type, INDEPENDENTLY of whether
1363
+ // the gate is enforced ("Required" off + items present = stored
1364
+ // reminder items the modal shows but no gate fires).
1365
+ const dorMode = (block.querySelector("[data-vs-def-mode='dor']") || { value: "inherit" }).value;
1366
+ if (dorMode !== "inherit") {
1367
+ const items = readItemsFrom(block.querySelector("[data-vs-def-items='dor']"));
1368
+ newDefs.ready.byType[type] = (dorMode === "override") ? { overridden: items } : { appended: items };
1369
+ } else {
1370
+ delete newDefs.ready.byType[type];
1371
+ }
1372
+ const dodMode = (block.querySelector("[data-vs-def-mode='dod']") || { value: "inherit" }).value;
1373
+ if (dodMode !== "inherit") {
1374
+ const items = readItemsFrom(block.querySelector("[data-vs-def-items='dod']"));
1375
+ newDefs.done.byType[type] = (dodMode === "override") ? { overridden: items } : { appended: items };
1376
+ } else {
1377
+ delete newDefs.done.byType[type];
1378
+ }
1379
+ });
1380
+
1381
+ return { definitions: newDefs, workflow: newWf, entityTypeConfig: newEtc };
1382
+ }
1383
+
1384
+ /**
1385
+ * Live-apply commit: read the section DOM, build the patch, dispatch
1386
+ * updateProject. Errors surface via flashStatus.
1387
+ */
1388
+ function commitDefinitions(anyDescendantEl, ctx) {
1389
+ if (!ctx || !ctx.store) return;
1390
+ try {
1391
+ // SM-40: DoR + DoD now live in two separate .vs-section elements.
1392
+ // collectFormState needs the .vs-root container so it can iterate
1393
+ // .vs-def-type-block + .vs-def-section across both sections. Resolve
1394
+ // .vs-root by walking up from whichever descendant was passed.
1395
+ const rootEl = anyDescendantEl && anyDescendantEl.closest
1396
+ ? (anyDescendantEl.closest(".vs-root") || anyDescendantEl)
1397
+ : anyDescendantEl;
1398
+ const cur = ctx.store.get().project;
1399
+ const patch = collectFormState(rootEl, cur);
1400
+ ctx.store.updateProject(patch, LOCAL_ACTOR);
1401
+ } catch (err) {
1402
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "settings save failed", { kind: "error" });
1403
+ }
1404
+ }
1405
+
1406
+ // ---- DOM builders ------------------------------------------------
1407
+
1408
+ function renderDefItemRow(item, listHost, sectionEl, ctx) {
1409
+ const row = el("div", { class: "vs-def-item-row" });
1410
+ row.dataset.itemId = item.id;
1411
+ const grip = el("span", { class: "vs-def-item-grip", title: "Drag to reorder", text: "⋮⋮" });
1412
+ const reqLabel = el("label", { class: "vs-def-item-required-wrap", title: "Required to pass the gate" });
1413
+ const reqInput = el("input", { type: "checkbox" });
1414
+ if (item.required) reqInput.setAttribute("checked", "checked");
1415
+ reqInput.addEventListener("change", () => commitDefinitions(sectionEl, ctx));
1416
+ const reqHint = el("span", { class: "vs-def-item-required-hint", text: "required" });
1417
+ reqLabel.appendChild(reqInput); reqLabel.appendChild(reqHint);
1418
+ const labelInput = el("input", {
1419
+ type: "text", class: "vs-def-item-label",
1420
+ value: item.label || "", placeholder: "Item label"
1421
+ });
1422
+ labelInput.addEventListener("blur", () => commitDefinitions(sectionEl, ctx));
1423
+ const remove = el("button", {
1424
+ class: "vs-def-item-remove btn", type: "button",
1425
+ title: "Remove item", text: "×"
1426
+ });
1427
+ remove.addEventListener("click", () => {
1428
+ row.remove();
1429
+ commitDefinitions(sectionEl, ctx);
1430
+ });
1431
+ row.appendChild(grip);
1432
+ row.appendChild(reqLabel);
1433
+ row.appendChild(labelInput);
1434
+ row.appendChild(remove);
1435
+
1436
+ if (dnd && typeof dnd.enableDraggable === "function") {
1437
+ const listId = listHost.dataset.listId;
1438
+ dnd.enableDraggable(row, { dragType: DDS_DRAG_TYPE + ":" + listId, dragId: item.id });
1439
+ dnd.enableDropTarget(row, {
1440
+ accepts: [DDS_DRAG_TYPE + ":" + listId],
1441
+ onEnter: (e) => e.classList.add("vs-def-item-drop-target"),
1442
+ onLeave: (e) => e.classList.remove("vs-def-item-drop-target"),
1443
+ onDrop: ({ id, event }) => {
1444
+ row.classList.remove("vs-def-item-drop-target");
1445
+ const list = row.parentNode;
1446
+ if (!list) return;
1447
+ const dragged = list.querySelector('.vs-def-item-row[data-item-id="' + cssEscape(id) + '"]');
1448
+ if (!dragged || dragged === row) return;
1449
+ const rect = row.getBoundingClientRect();
1450
+ const before = (event && typeof event.clientY === "number")
1451
+ ? event.clientY < rect.top + rect.height / 2 : true;
1452
+ if (before) list.insertBefore(dragged, row);
1453
+ else list.insertBefore(dragged, row.nextSibling);
1454
+ commitDefinitions(sectionEl, ctx);
1455
+ }
1456
+ });
1457
+ }
1458
+ return row;
1459
+ }
1460
+
1461
+ function renderDefItemsList(host, items, listId, sectionEl, ctx) {
1462
+ host.dataset.listId = listId;
1463
+ host.classList.add("vs-def-items-list");
1464
+ items.forEach((it) => host.appendChild(renderDefItemRow(it, host, sectionEl, ctx)));
1465
+ const addBtn = el("button", {
1466
+ class: "btn vs-def-add-item", type: "button", text: "+ Add Item"
1467
+ });
1468
+ addBtn.addEventListener("click", () => {
1469
+ const row = renderDefItemRow({ id: freshDefItemId(), label: "", required: true }, host, sectionEl, ctx);
1470
+ host.insertBefore(row, addBtn);
1471
+ const input = row.querySelector("input[type='text']");
1472
+ if (input) input.focus();
1473
+ // No commit yet — empty label rows are dropped by readItemsFrom anyway.
1474
+ // The commit will fire on label blur once the user types something.
1475
+ });
1476
+ host.appendChild(addBtn);
1477
+ }
1478
+
1479
+ function renderGlobalDefSection(host, label, sectionKey, items, sectionEl, ctx) {
1480
+ const wrap = el("section", { class: "vs-def-block" });
1481
+ wrap.dataset.vsDefSection = sectionKey;
1482
+ wrap.appendChild(el("h4", { class: "vs-def-block-title", text: label }));
1483
+ const list = el("div");
1484
+ wrap.appendChild(list);
1485
+ renderDefItemsList(list, items, sectionKey, sectionEl, ctx);
1486
+ host.appendChild(wrap);
1487
+ }
1488
+
1489
+ function renderGateBlock(typeBlock, gateLabel, gateKey, state, sectionEl, ctx) {
1490
+ const wrap = el("div", { class: "vs-def-gate-block" });
1491
+ wrap.dataset.vsDefGate = gateKey;
1492
+ const header = el("div", { class: "vs-def-gate-header" });
1493
+ const enabled = el("input", { type: "checkbox" });
1494
+ enabled.dataset.vsDefEnabled = gateKey;
1495
+ if (state.enabled) enabled.setAttribute("checked", "checked");
1496
+ const enabledLabel = el("label", { class: "vs-def-gate-toggle" });
1497
+ enabledLabel.appendChild(enabled);
1498
+ // SM-38 follow-up: the toggle was previously labelled "DoR required for
1499
+ // this type" and gated the visibility of the entire body — that conflated
1500
+ // two concerns. "Required" now means ONLY gate enforcement (must check
1501
+ // off required items before the transition). The items list itself stays
1502
+ // editable regardless, so humans get a checklist reminder even when the
1503
+ // gate isn't enforced.
1504
+ enabledLabel.appendChild(el("span", { text: " Enforce " + gateLabel + " gate (block transition until required items checked)" }));
1505
+ header.appendChild(enabledLabel);
1506
+ wrap.appendChild(header);
1507
+
1508
+ // Body is always visible — items list is editable whether or not the gate
1509
+ // is enforced.
1510
+ const body = el("div", { class: "vs-def-gate-body" });
1511
+ const modeRow = el("div", { class: "vs-def-mode-row" });
1512
+ modeRow.appendChild(el("span", { class: "vs-def-mode-label", text: "Items" }));
1513
+ const sel = el("select");
1514
+ sel.dataset.vsDefMode = gateKey;
1515
+ for (const [val, lbl] of [
1516
+ ["inherit", "Inherit global"],
1517
+ ["append", "Append to global"],
1518
+ ["override", "Override global"]
1519
+ ]) {
1520
+ const opt = el("option", { value: val, text: lbl });
1521
+ if (val === state.mode) opt.setAttribute("selected", "selected");
1522
+ sel.appendChild(opt);
1523
+ }
1524
+ modeRow.appendChild(sel);
1525
+ body.appendChild(modeRow);
1526
+
1527
+ // SM-38 follow-up: items list is ALWAYS visible when the gate is enabled
1528
+ // (previously hidden when mode=inherit, which made the per-type +Add Item
1529
+ // UI hard to discover — users assumed per-type was on/off only). In
1530
+ // inherit mode the items are stored but not applied; switching mode to
1531
+ // append or override activates them. A hint explains this.
1532
+ const list = el("div");
1533
+ list.dataset.vsDefItems = gateKey;
1534
+ renderDefItemsList(list, state.items, "type-" + typeBlock.dataset.vsDefType + "-" + gateKey, sectionEl, ctx);
1535
+ body.appendChild(list);
1536
+ const hint = el("p", { class: "vs-def-mode-hint",
1537
+ text: "Inherit: per-type items are stored but ignored. Append: added to globals. Override: replaces globals." });
1538
+ body.appendChild(hint);
1539
+
1540
+ sel.addEventListener("change", () => {
1541
+ commitDefinitions(sectionEl, ctx);
1542
+ });
1543
+ enabled.addEventListener("change", () => {
1544
+ commitDefinitions(sectionEl, ctx);
1545
+ });
1546
+
1547
+ wrap.appendChild(body);
1548
+ typeBlock.appendChild(wrap);
1549
+ }
1550
+
1551
+ function renderTypeBlock(host, type, typeState, rootEl, ctx, gateKey) {
1552
+ const block = el("section", { class: "vs-def-type-block" });
1553
+ block.dataset.vsDefType = type;
1554
+ block.appendChild(el("h4", { class: "vs-def-type-title", text: type }));
1555
+ // SM-40: gateKey filters which gate(s) this block renders. When omitted
1556
+ // (legacy callers), both gates are rendered (no behaviour change for
1557
+ // any code still passing only 5 args). When passed as "dor" or "dod",
1558
+ // only that gate's block is rendered — used by the split DoR/DoD
1559
+ // sections.
1560
+ if (!gateKey || gateKey === "dor") {
1561
+ renderGateBlock(block, "Definition of Ready", "dor", typeState.dor, rootEl, ctx);
1562
+ }
1563
+ if (!gateKey || gateKey === "dod") {
1564
+ renderGateBlock(block, "Definition of Done", "dod", typeState.dod, rootEl, ctx);
1565
+ }
1566
+ host.appendChild(block);
1567
+ }
1568
+
1569
+ /**
1570
+ * SM-40: helper for a single-gate Definitions section. `gateKey` ∈ {"dor","dod"};
1571
+ * `sectionLabel`/`globalLabel` are display strings; `globalKey` = "global-dor"
1572
+ * or "global-dod" (matches the readItemsFrom selector); `globalItems` is the
1573
+ * state slice for that gate's globals. Renders one .vs-section with the
1574
+ * single-gate globals + the type-picker + a single-gate per-type block.
1575
+ */
1576
+ function renderSingleGateSection(project, ctx, rootEl, opts) {
1577
+ const { gateKey, sectionId, sectionLabel, globalLabel, globalKey, globalItems, typeStateKey } = opts;
1578
+ const section = el("section", { class: "vs-section vs-def-root", dataset: { section: sectionId } });
1579
+ section.appendChild(el("header", { class: "vs-section-header" },
1580
+ el("h2", { text: sectionLabel }),
1581
+ el("p", { class: "vs-section-hint",
1582
+ text: "Checklist gating ticket transitions for this stage. Globals apply to every ticket type; per-type configuration can append to or override them." })
1583
+ ));
1584
+ const body = el("div", { class: "vs-section-body" });
1585
+ section.appendChild(body);
1586
+
1587
+ const state = readFormState(project);
1588
+
1589
+ const globals = el("div", { class: "vs-def-globals" });
1590
+ globals.appendChild(el("h3", { class: "vs-subhead", text: "Global items" }));
1591
+ renderGlobalDefSection(globals, globalLabel, globalKey, globalItems, rootEl, ctx);
1592
+ body.appendChild(globals);
1593
+
1594
+ // SM-38: per-type configuration is now a Type-Picker + single edit-block.
1595
+ // Switching the picker re-renders the block for the newly-selected type
1596
+ // (UI-only, no commit). Edits inside the block still live-apply via
1597
+ // commitDefinitions, which now preserves byType-entries for types whose
1598
+ // block is NOT in the DOM (see collectFormState).
1599
+ if ((project.ticketTypes || []).length > 0) {
1600
+ body.appendChild(el("h3", { class: "vs-subhead", text: "Per-type configuration" }));
1601
+ const perType = el("div", { class: "vs-def-per-type" });
1602
+
1603
+ const pickerRow = el("div", { class: "vs-def-type-picker-row" });
1604
+ pickerRow.appendChild(el("label", { class: "vs-def-type-picker-label", text: "Configure for type:" }));
1605
+ const picker = el("select", { class: "vs-def-type-picker" });
1606
+ (project.ticketTypes || []).forEach((t) => {
1607
+ picker.appendChild(el("option", { value: t, text: t }));
1608
+ });
1609
+ pickerRow.appendChild(picker);
1610
+ perType.appendChild(pickerRow);
1611
+
1612
+ const blockHost = el("div", { class: "vs-def-type-host" });
1613
+ perType.appendChild(blockHost);
1614
+
1615
+ function renderBlockFor(type) {
1616
+ blockHost.innerHTML = "";
1617
+ renderTypeBlock(
1618
+ blockHost, type,
1619
+ state.types[type] || { dor: { enabled: false, mode: "inherit", items: [] },
1620
+ dod: { enabled: false, mode: "inherit", items: [] } },
1621
+ rootEl, ctx, gateKey
1622
+ );
1623
+ }
1624
+
1625
+ // Default selection = first ticketType. Switching the picker is a
1626
+ // pure UI re-render; no commit fires.
1627
+ const firstType = project.ticketTypes[0];
1628
+ picker.value = firstType;
1629
+ renderBlockFor(firstType);
1630
+
1631
+ picker.addEventListener("change", () => {
1632
+ renderBlockFor(picker.value);
1633
+ });
1634
+
1635
+ body.appendChild(perType);
1636
+ }
1637
+ return section;
1638
+ }
1639
+
1640
+ function renderDorSection(project, ctx, rootEl) {
1641
+ const state = readFormState(project);
1642
+ return renderSingleGateSection(project, ctx, rootEl, {
1643
+ gateKey: "dor",
1644
+ sectionId: "dor",
1645
+ sectionLabel: "Definition of Ready",
1646
+ globalLabel: "Global Definition of Ready items",
1647
+ globalKey: "global-dor",
1648
+ globalItems: state.globalDor
1649
+ });
1650
+ }
1651
+
1652
+ function renderDodSection(project, ctx, rootEl) {
1653
+ const state = readFormState(project);
1654
+ return renderSingleGateSection(project, ctx, rootEl, {
1655
+ gateKey: "dod",
1656
+ sectionId: "dod",
1657
+ sectionLabel: "Definition of Done",
1658
+ globalLabel: "Global Definition of Done items",
1659
+ globalKey: "global-dod",
1660
+ globalItems: state.globalDod
1661
+ });
1662
+ }
1663
+
1664
+ // ---- Mount / render -----------------------------------------------
1665
+
1666
+ // SM-41: module-state for the active tab. Reset to "workflow" on every
1667
+ // fresh mount (no persist across open/close — bewusste User-Entscheidung).
1668
+ // Survives renderInto re-runs (live-sync), so external commits don't snap
1669
+ // the user back to Workflow.
1670
+ // ---------------------------------------------------------------------------
1671
+ // SM-107 — read-only "Rules per type" (derived from the catalog)
1672
+ // ---------------------------------------------------------------------------
1673
+
1674
+ // Pure: walk the transition-rule catalog and report, per ticket type, which
1675
+ // rules are enabled (enabledFor true) vs N/A. Read-only derivation from
1676
+ // core.TRANSITION_RULES + the project's entityTypeConfig — no persistence.
1677
+ // This is the UI face of the SM-102 lockstep invariant: a rule shows as N/A
1678
+ // exactly when its backing field is configured off for that type.
1679
+ function activeRulesForType(type, project) {
1680
+ const catalog = (core && core.TRANSITION_RULES) || {};
1681
+ return Object.keys(catalog).map((id) => {
1682
+ const rule = catalog[id];
1683
+ const enabled = typeof rule.enabledFor === "function"
1684
+ ? !!rule.enabledFor(type, project || {})
1685
+ : true;
1686
+ return { id: id, label: (rule && rule.label) || id, enabled: enabled };
1687
+ });
1688
+ }
1689
+
1690
+ function renderRulesSection(project, ctx) {
1691
+ const types = (project && Array.isArray(project.ticketTypes) && project.ticketTypes.length)
1692
+ ? project.ticketTypes
1693
+ : (core.DEFAULT_TICKET_TYPES || []);
1694
+ const bodyChildren = [];
1695
+ for (const type of types) {
1696
+ const block = el("div", { class: "vs-rules-type", dataset: { type: type } });
1697
+ block.appendChild(el("h3", { class: "vs-subhead", text: type }));
1698
+ const list = el("ul", { class: "vs-rules-list" });
1699
+ for (const r of activeRulesForType(type, project)) {
1700
+ const li = el("li", {
1701
+ class: "vs-rules-item" + (r.enabled ? " active" : " inactive"),
1702
+ dataset: { ruleId: r.id, enabled: r.enabled ? "1" : "0" }
1703
+ });
1704
+ li.appendChild(el("span", { class: "vs-rules-state", text: r.enabled ? "✓" : "—" }));
1705
+ li.appendChild(el("code", { class: "vs-rules-id selectable", text: r.id }));
1706
+ li.appendChild(el("span", { class: "vs-rules-label", text: r.label }));
1707
+ if (!r.enabled) li.appendChild(el("span", { class: "vs-rules-na", text: "N/A for this type" }));
1708
+ list.appendChild(li);
1709
+ }
1710
+ block.appendChild(list);
1711
+ bodyChildren.push(block);
1712
+ }
1713
+ return el("section", { class: "vs-section", dataset: { section: "rules" } },
1714
+ el("header", { class: "vs-section-header" },
1715
+ el("h2", { text: "Rules" }),
1716
+ el("p", { class: "vs-section-hint",
1717
+ text: "Read-only. Which transition-gate rules apply to each ticket type, derived from the rule catalog + this project's type config. A rule shows N/A when its backing field (e.g. DoD section) is hidden for that type — the SM-102 lockstep invariant made visible." })
1718
+ ),
1719
+ el("div", { class: "vs-section-body" }, ...bodyChildren));
1720
+ }
1721
+
1722
+ let _activeTab = "workflow";
1723
+
1724
+ const SETTINGS_TABS = [
1725
+ { id: "workflow", label: "Workflow" },
1726
+ { id: "dor", label: "Definition of Ready" },
1727
+ { id: "dod", label: "Definition of Done" },
1728
+ { id: "board", label: "Kanban Board" },
1729
+ { id: "links", label: "Link Types" },
1730
+ { id: "types", label: "Ticket Types" },
1731
+ { id: "typeconfig", label: "Type Config" },
1732
+ { id: "labels", label: "Labels" },
1733
+ { id: "rules", label: "Rules" }
1734
+ ];
1735
+
1736
+ function renderSettingsTabBar(activeId, onSelect) {
1737
+ const bar = el("div", { class: "vs-tab-bar", role: "tablist" });
1738
+ for (const tab of SETTINGS_TABS) {
1739
+ const btn = el("button", {
1740
+ class: "vs-tab-btn" + (tab.id === activeId ? " active" : ""),
1741
+ type: "button", role: "tab",
1742
+ text: tab.label
1743
+ });
1744
+ btn.dataset.vsTab = tab.id;
1745
+ btn.setAttribute("aria-selected", tab.id === activeId ? "true" : "false");
1746
+ btn.addEventListener("click", () => onSelect(tab.id));
1747
+ bar.appendChild(btn);
1748
+ }
1749
+ return bar;
1750
+ }
1751
+
1752
+ function setActiveTab(rootEl, tabId) {
1753
+ _activeTab = tabId;
1754
+ const sections = rootEl.querySelectorAll(":scope > .vs-section");
1755
+ sections.forEach((s) => {
1756
+ if (s.dataset.section === tabId) s.removeAttribute("hidden");
1757
+ else s.setAttribute("hidden", "");
1758
+ });
1759
+ const buttons = rootEl.querySelectorAll(".vs-tab-bar > .vs-tab-btn");
1760
+ buttons.forEach((b) => {
1761
+ const active = b.dataset.vsTab === tabId;
1762
+ b.classList.toggle("active", active);
1763
+ b.setAttribute("aria-selected", active ? "true" : "false");
1764
+ });
1765
+ }
1766
+
1767
+ function renderInto(host, store, ctx) {
1768
+ const snap = store.get();
1769
+ const project = (snap && snap.project) || null;
1770
+ // Focus-Guard: if the user is currently typing in a name input inside
1771
+ // the view, skip a full rerender — otherwise they lose caret position
1772
+ // and the in-flight edit. The blur-commit handler will trigger the
1773
+ // next external commit anyway, which then DOES re-render.
1774
+ const active = document.activeElement;
1775
+ if (active && host.contains(active) && active.matches
1776
+ && active.matches("input.vs-status-name, input.vs-transition-name, input.vs-board-column-name, input.vs-def-item-label, input.vs-linktype-label, input.vs-linktype-inverse, input.vs-tickettype-name, input.vs-label-name")) {
1777
+ return;
1778
+ }
1779
+ // Renderers and event handlers expect ctx.store on the ctx object;
1780
+ // mount() takes store as a separate parameter, so merge it here so
1781
+ // we don't sprinkle store-threading through every helper.
1782
+ const fullCtx = Object.assign({}, ctx || {}, { store: store });
1783
+ const root = el("div", { class: "vs-root" });
1784
+ if (!project) {
1785
+ root.appendChild(el("p", { class: "vs-empty",
1786
+ text: "Load a project to configure its workflow and board." }));
1787
+ } else {
1788
+ // SM-41: tab-bar first, then the four sections. Only the section
1789
+ // matching _activeTab is visible; the others are hidden via the
1790
+ // hidden attribute. Tab-click toggles via setActiveTab WITHOUT
1791
+ // re-running renderInto (DOM-identity + DnD-Registrierungen bleiben).
1792
+ root.appendChild(renderSettingsTabBar(_activeTab, (tabId) => setActiveTab(root, tabId)));
1793
+ root.appendChild(renderWorkflowSection(project, fullCtx));
1794
+ root.appendChild(renderDorSection(project, fullCtx, root));
1795
+ root.appendChild(renderDodSection(project, fullCtx, root));
1796
+ root.appendChild(renderBoardSection(project, fullCtx));
1797
+ root.appendChild(renderLinkTypesSection(project, fullCtx));
1798
+ root.appendChild(renderTicketTypesSection(project, fullCtx));
1799
+ root.appendChild(renderTypeConfigSection(project, fullCtx));
1800
+ root.appendChild(renderLabelsSection(project, fullCtx));
1801
+ root.appendChild(renderRulesSection(project, fullCtx));
1802
+ // Apply the initial visibility AFTER all sections are appended so
1803
+ // setActiveTab can find them via :scope > .vs-section.
1804
+ setActiveTab(root, _activeTab);
1805
+ }
1806
+ host.innerHTML = "";
1807
+ host.appendChild(root);
1808
+ }
1809
+
1810
+ /**
1811
+ * Mount the settings view into `host`. Returns `{unmount}`. Subscribes
1812
+ * to the store so external commits re-render the sections (E21.E/F/G
1813
+ * editors rely on this for live-sync). When no store is provided yet
1814
+ * (no project loaded), renders the empty state once.
1815
+ */
1816
+ function mount(host, store, ctx) {
1817
+ ctx = ctx || {};
1818
+ // SM-41: fresh mount resets the active tab to "workflow". Closing and
1819
+ // re-opening the Settings overlay (Project ▸ Settings…) returns the
1820
+ // user to the first tab — no persist. Live-sync re-runs of renderInto
1821
+ // do NOT pass through here, so they keep the user's current tab.
1822
+ _activeTab = "workflow";
1823
+ // SM-9: also reset the per-type editor's selection so a fresh open
1824
+ // always lands on the first ticket-type.
1825
+ _typeConfigSelectedType = null;
1826
+ if (!store) {
1827
+ host.innerHTML = "";
1828
+ host.appendChild(el("div", { class: "vs-root" },
1829
+ el("p", { class: "vs-empty",
1830
+ text: "Load a project to configure its workflow and board." })));
1831
+ return { unmount: () => { host.innerHTML = ""; } };
1832
+ }
1833
+ function rerender() { renderInto(host, store, ctx); }
1834
+ rerender();
1835
+ const unsub = store.subscribe(rerender);
1836
+ return {
1837
+ unmount: () => {
1838
+ try { unsub(); } catch (_) { /* ignore */ }
1839
+ host.innerHTML = "";
1840
+ }
1841
+ };
1842
+ }
1843
+
1844
+ return {
1845
+ mount,
1846
+ reorderStatuses,
1847
+ reorderTransitions,
1848
+ sortTransitionsByStatusOrder,
1849
+ assignStatusToColumn,
1850
+ unassignedStatuses,
1851
+ // SM-2 — DoR/DoD editor exports
1852
+ readFormState,
1853
+ collectFormState,
1854
+ // SM-40 split — two separate single-gate section renderers replace
1855
+ // the old combined renderDefinitionsSection.
1856
+ renderDorSection,
1857
+ renderDodSection,
1858
+ // SM-7 + SM-8 — pure reorder helpers exposed for unit testing
1859
+ reorderTicketTypes,
1860
+ reorderLabels,
1861
+ // SM-107 — read-only rules-per-type derivation
1862
+ activeRulesForType
1863
+ };
1864
+ }));