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,758 @@
1
+ /**
2
+ * Ticket-Detail-Modal — schwebt über der Seite (E10).
3
+ *
4
+ * Rendert ein editierbares Detail-Modal für ein Ticket. Felder:
5
+ * Title, Type, Status, Release, ProcessStep, Epic, Description,
6
+ * Acceptance-Criteria, DoR-Checklist, DoD-Checklist, Labels.
7
+ *
8
+ * Pattern: nutzt `showModal` aus `ui-shell.js` als Host und baut den
9
+ * Body via DOM-Manipulation im `onMount`-Callback (rich form statt
10
+ * HTML-String, weil dynamische Inputs + Live-Sync nötig sind).
11
+ *
12
+ * Live-Sync: solange das Modal offen ist, abonniert es Store-Commits.
13
+ * Auf jedes Commit wird das Ticket frisch aus dem Store gelesen und die
14
+ * Felder aktualisiert — AUSSER dem Feld, in dem der User gerade tippt
15
+ * (`document.activeElement`-Guard), sodass tippender Input nicht
16
+ * überschrieben wird.
17
+ *
18
+ * Persistenz: Save-Button feuert PUT /tickets/:tid für die "normalen"
19
+ * Felder. Status-Wechsel feuert separat POST /tickets/:tid/status
20
+ * (damit DoR/DoD-Gates auf dem Server greifen — bei 422 wird die
21
+ * Missing-Liste im Modal als Fehlerbanner angezeigt). DoR/DoD-Items
22
+ * werden INLINE bei Click via POST /tickets/:tid/dor/:itemId/{check,uncheck}
23
+ * persistiert (kein Save nötig — sofortiger Effekt).
24
+ *
25
+ * Mount API:
26
+ * openTicketModal(ctx, ticketId, opts) → { close }
27
+ * ctx.store ProjectStore-Instanz
28
+ * ctx.projectId string — für die HTTP-Pfade
29
+ * ctx.adapter adapter mit .name === "Http" oder Local
30
+ * ctx.httpReq(method, path, body) — async, throws on non-2xx (siehe main.js)
31
+ * ctx.reloadStore() — async, GET + applyRemote (nach Status/Item-Ops)
32
+ * ctx.applySnapshot(snap) — appliziert direkt zurückgegebenen Snapshot
33
+ * ctx.flashStatus(msg, opts?) — User-Feedback
34
+ * opts.onDelete(ticketId) — optional: separater Delete-Pfad (Soft-Delete via DELETE-Endpoint)
35
+ */
36
+ (function (root, factory) {
37
+ if (typeof module === "object" && module.exports) module.exports = factory(require("./core.js"), require("./dnd.js"), require("./ticket-form.js"));
38
+ else (root.STORYMAP = root.STORYMAP || {}).rendererTicketModal = factory(
39
+ root.STORYMAP && root.STORYMAP.core,
40
+ root.STORYMAP && root.STORYMAP.dnd,
41
+ root.STORYMAP && root.STORYMAP.ticketForm
42
+ );
43
+ }(typeof self !== "undefined" ? self : this, function (core, dnd, ticketForm) {
44
+ "use strict";
45
+
46
+ if (!core) throw new Error("renderer-ticket-modal: core module missing");
47
+ if (!dnd) throw new Error("renderer-ticket-modal: dnd module missing");
48
+ if (!ticketForm) throw new Error("renderer-ticket-modal: ticket-form module missing");
49
+
50
+ // SM-116 (Epic B / B1): the form builders + state helpers live in the shared
51
+ // ticket-form.js so the full-page editor (B2+) reuses them. This module is now
52
+ // a thin host — it pulls the builders into scope under their original names so
53
+ // every call site below is unchanged, and keeps only the modal-specific
54
+ // orchestration (showModal lifecycle, create/edit save, type-picker, starter).
55
+ const {
56
+ CONSTANTS, LOCAL_ACTOR, el, buildErrorBanner,
57
+ buildTitleField, buildTypeField, buildStatusField, buildPositionFields,
58
+ buildDescriptionField, buildChecklistSection, buildAcceptanceCriteriaSection, buildPrerequisitesSection,
59
+ buildStepsSection, buildLabelsField, buildTestExecutionHeader, buildExecutionMetadata,
60
+ buildExecutionStepsSection, buildOutcomeSection, buildLinksSection, syncFieldFromTicket,
61
+ pulseChangedFields, buildDraftTicket, collectFormState, buildUpdatePatch,
62
+ installInPlaceCommit, buildAttachmentsSection
63
+ } = ticketForm;
64
+
65
+ // SM-244: build the "Cancel ticket" action (or null). Cancel ≠ Delete —
66
+ // amber, not red; keeps the ticket visible with its history. Hidden for spec
67
+ // types (own lifecycle) and already-cancelled tickets. Cancelling an epic with
68
+ // open stories asks for confirmation (the cascade) before committing.
69
+ function maybeCancelAction(ctx, initial, ticketId) {
70
+ if (!initial) return null;
71
+ const isSpec = !core.isBoardWorkItem(initial.type) && initial.type !== "epic";
72
+ if (isSpec) return null;
73
+ const snap0 = ctx.store.get();
74
+ if (core.statusCategoryOf(snap0.project, initial.status) === "cancelled") return null;
75
+ // SM-244: an epic that is already DONE (its shipped work dominates the
76
+ // roll-up) can't be meaningfully cancelled — cancelling its (zero) open
77
+ // stories leaves it done. Hide the action instead of offering a no-op.
78
+ if (initial.type === "epic" && core.statusCategoryOf(snap0.project, initial.status) === "done") return null;
79
+
80
+ function doCancel() {
81
+ try {
82
+ ctx.store.cancelTicket(ticketId, LOCAL_ACTOR);
83
+ if (ctx.flashStatus) ctx.flashStatus("ticket cancelled", { kind: "ok" });
84
+ } catch (err) {
85
+ if (ctx.flashStatus) ctx.flashStatus(err.message || "cancel failed", { kind: "error" });
86
+ }
87
+ }
88
+
89
+ return [{
90
+ label: "Cancel ticket",
91
+ className: "warn",
92
+ onClick: () => {
93
+ const fresh = ctx.store.get();
94
+ if (initial.type === "epic") {
95
+ const st = core.epicChildStats(fresh, ticketId);
96
+ const open = st.doing + st.todo; // non-terminal contained stories
97
+ // Resolve uiShell the same way openTicketModal does (ctx → window
98
+ // fallback). Without it, degrade gracefully to a direct cancel.
99
+ const shell = ctx.uiShell
100
+ || (typeof window !== "undefined" && window.STORYMAP && window.STORYMAP.uiShell);
101
+ // SM-244: ALWAYS confirm an epic cancel (even with no contained stories)
102
+ // so the action gives clear feedback. The message names the cascade.
103
+ if (shell && typeof shell.showModal === "function") {
104
+ const sub = open > 0
105
+ ? "This also cancels " + open + " open " + (open === 1 ? "story" : "stories") + " contained in the epic."
106
+ : "This epic has no open contained stories — only the epic is cancelled.";
107
+ shell.showModal({
108
+ title: "Cancel epic?",
109
+ sub: sub,
110
+ // SM-278: these open a NEW modal into the single #modal-host. They
111
+ // MUST return false so showModal does not run its post-onClick
112
+ // close() — which does host.innerHTML="" and would destroy the very
113
+ // modal we just opened (the root cause of "epic cancel does nothing").
114
+ actions: [
115
+ { label: "Back", onClick: () => { openTicketModal(ctx, ticketId, {}); return false; } },
116
+ { label: "Cancel epic", className: "warn", onClick: () => doCancel() }
117
+ ]
118
+ });
119
+ // SM-278: return false so the outer action handler does NOT call
120
+ // close() and wipe the confirm modal we just opened into the host.
121
+ return false;
122
+ }
123
+ }
124
+ doCancel();
125
+ }
126
+ }];
127
+ }
128
+
129
+ // ---- Mount API ------------------------------------------------------
130
+
131
+ /**
132
+ * Open the ticket-detail modal for a ticket id. Returns { close }.
133
+ *
134
+ * The modal lifecycle:
135
+ * open → onMount fires → wire fields + subscribe to store
136
+ * close → unsubscribe + remove DOM (showModal handles DOM teardown)
137
+ *
138
+ * For Esc / click-outside / Cancel → close fn fires the showModal close.
139
+ * For Save → POST/PUT, await, then close on success (or stay open + show
140
+ * error banner on validation failure).
141
+ * For Delete → DELETE endpoint, await, then close.
142
+ */
143
+ function openTicketModal(ctx, ticketId, opts) {
144
+ opts = opts || {};
145
+ if (!ctx || !ctx.store) throw new Error("openTicketModal: ctx.store missing");
146
+ const showModal = (ctx.uiShell && ctx.uiShell.showModal)
147
+ || (typeof window !== "undefined" && window.STORYMAP && window.STORYMAP.uiShell && window.STORYMAP.uiShell.showModal);
148
+ if (typeof showModal !== "function") throw new Error("openTicketModal: showModal helper missing on uiShell");
149
+
150
+ // Create-Mode: ticketId === null. opts.draft = { type, releaseId, processStepId, epicId }.
151
+ const isCreate = (ticketId == null);
152
+
153
+ function readTicket() {
154
+ const snap = ctx.store.get();
155
+ return (snap.tickets || []).find(t => t.id === ticketId) || null;
156
+ }
157
+
158
+ const initial = isCreate
159
+ ? buildDraftTicket((opts.draft && opts.draft.type) || "user-story", opts.draft || {})
160
+ : readTicket();
161
+ if (!initial) {
162
+ if (ctx.flashStatus) ctx.flashStatus("ticket not found: " + ticketId, { kind: "error" });
163
+ return { close: () => {} };
164
+ }
165
+ // Effective config for this ticket type (resolves data-driven defaults).
166
+ const project = ctx.store.get().project || {};
167
+ const typeConfig = (typeof core.getEntityTypeConfig === "function")
168
+ ? core.getEntityTypeConfig(project, initial.type)
169
+ : { showAcceptanceCriteria: true, showDefinitionOfReady: true, showDefinitionOfDone: true, allowParentEpic: initial.type !== "epic", showRelease: true, showProcessStep: true };
170
+
171
+ const refs = { markDirty: () => {} }; // set after onMount fires
172
+ const actions = isCreate
173
+ ? [
174
+ { label: "Cancel", onClick: () => {} },
175
+ { label: "Create", primary: true, onClick: async (modal) => createOnSave(ctx, modal, initial, opts) }
176
+ ]
177
+ : [
178
+ { label: "Delete", destructive: true, onClick: async () => {
179
+ // SM-73: Delete used to rely on opts.onDelete being plumbed
180
+ // by every caller — Kanban and Dependencies didn't pass it,
181
+ // so the button silently no-op'd. Centralise the soft-delete
182
+ // here so it works regardless of how the modal was opened.
183
+ // opts.onDelete still wins when a caller wants a custom path.
184
+ try {
185
+ if (typeof opts.onDelete === "function") {
186
+ await opts.onDelete(ticketId);
187
+ } else if (ctx && ctx.store && typeof ctx.store.softDeleteTicket === "function") {
188
+ ctx.store.softDeleteTicket(ticketId, LOCAL_ACTOR);
189
+ if (ctx.flashStatus) ctx.flashStatus("ticket deleted", { kind: "ok" });
190
+ }
191
+ } catch (err) {
192
+ if (ctx && ctx.flashStatus) ctx.flashStatus(err.message || "delete failed", { kind: "error" });
193
+ console.error("[ticket-modal] delete failed:", err);
194
+ return false; // keep the modal open so the user sees the error
195
+ }
196
+ }
197
+ },
198
+ // SM-244: Cancel ticket — a deliberate non-implementation. NOT red
199
+ // (Delete stays red); cancel keeps the ticket visible with history.
200
+ // Hidden for spec types (own lifecycle) and already-cancelled tickets.
201
+ ...(maybeCancelAction(ctx, initial, ticketId) || []),
202
+ // SM-120/B5b: no Save button — every field commits in place (see
203
+ // installInPlaceCommit below). Just a Close action.
204
+ { label: "Close", onClick: () => {} }
205
+ ];
206
+
207
+ const handle = showModal({
208
+ title: isCreate
209
+ ? "New " + (initial.type || "ticket")
210
+ : (initial.ticketKey ? initial.ticketKey + " — " : "") + (initial.title || "(no title)"),
211
+ bodyHTML: '<div class="tm-body-host"></div>',
212
+ actions: actions,
213
+ onMount: (modal, close) => {
214
+ const host = modal.querySelector(".tm-body-host");
215
+ const snapshot = ctx.store.get();
216
+ const projectInner = snapshot.project || {};
217
+ const ticketTypes = (projectInner.ticketTypes && projectInner.ticketTypes.length)
218
+ ? projectInner.ticketTypes : core.DEFAULT_TICKET_TYPES;
219
+ // SM-244: the status dropdown must reflect the PROJECT workflow (which
220
+ // includes cancelled), not the hardcoded 5-status default — otherwise a
221
+ // cancelled ticket has no matching option and falls back to "backlog",
222
+ // and there is no way to reopen it. Reopen = pick an earlier status
223
+ // (ungated reverse move); cancel = pick cancelled (gate-free transition).
224
+ const statuses = (typeof core.getWorkflowForType === "function")
225
+ ? core.getWorkflowForType(projectInner, initial.type).statuses.map(s => (typeof s === "string" ? s : s.id))
226
+ : core.DEFAULT_STATUSES;
227
+
228
+ // SM-122/B7: turn the modal title into a link to the full-page editor
229
+ // (edit-mode only — the full page needs an existing ticket). Plain click
230
+ // navigates; right/middle-click opens a new tab — a real <a>.
231
+ if (!isCreate && ctx.projectId && ticketId) {
232
+ const h2 = modal.querySelector("h2");
233
+ if (h2) {
234
+ // SM-158: link by the human ticket-KEY (SM-140) when available, so
235
+ // the URL is memorable/type-able (the editor resolves key→id).
236
+ const keyForLink = initial.ticketKey || ticketId;
237
+ const href = "/editor?projectId=" + encodeURIComponent(ctx.projectId)
238
+ + "&ticketId=" + encodeURIComponent(keyForLink);
239
+ const link = el("a", { class: "tm-fullpage-link", href: href,
240
+ title: "Open this ticket in the full-page editor" });
241
+ link.textContent = h2.textContent;
242
+ h2.textContent = "";
243
+ h2.appendChild(link);
244
+ h2.appendChild(el("span", { class: "tm-fullpage-icon", title: "Open full view", text: " ↗" }));
245
+ }
246
+ }
247
+
248
+ // SM-120/B5b: in-place commit controller (installed after the body is
249
+ // built, edit-mode only). The section refs below call inplace.markDirty
250
+ // so add/remove/reorder/toggle in AC/DoR/DoD/prereqs/steps auto-commits.
251
+ let inplace = null;
252
+
253
+ host.appendChild(buildErrorBanner());
254
+ host.appendChild(buildTitleField(initial));
255
+ host.appendChild(buildTypeField(initial, ticketTypes));
256
+ host.appendChild(buildStatusField(initial, statuses, snapshot));
257
+ for (const row of buildPositionFields(initial, snapshot, typeConfig)) host.appendChild(row);
258
+ // SM-157: in edit-mode the description self-commits via inline ✓/✗ (and
259
+ // on focus-out), so free-text edits aren't lost if the user forgets the
260
+ // Save button. Create-mode collects the description on Create instead.
261
+ host.appendChild(buildDescriptionField(initial, isCreate ? {} : {
262
+ onCommit: (text) => ctx.store.updateTicket(ticketId, { description: text }, LOCAL_ACTOR)
263
+ }));
264
+ // SM-57: Test-Execution header pill + metadata strip. Only relevant in
265
+ // edit-mode — create-mode for test-execution goes through the dedicated
266
+ // starter dialog (openTestExecutionStarter), not this modal.
267
+ if (!isCreate && initial.type === "test-execution") {
268
+ host.appendChild(buildTestExecutionHeader(initial, snapshot, Object.assign({}, ctx, { openTicketModal })));
269
+ host.appendChild(buildExecutionMetadata(initial));
270
+ }
271
+
272
+ // SM-54: Prereqs + Steps + AC are all type-driven and re-rendered on
273
+ // type change in Create-mode. Encapsulated in a single helper so a
274
+ // Type switch can re-evaluate visibility uniformly.
275
+ let prereqsDirty = false, stepsDirty = false, acDirty = false;
276
+ function renderTypeDrivenSections(opts) {
277
+ const reseed = !!(opts && opts.reseed);
278
+ // Strip prior renders before re-mounting.
279
+ host.querySelectorAll('section.tm-ac-section').forEach(n => n.remove());
280
+ host.querySelectorAll('section.tm-prereqs').forEach(n => n.remove());
281
+ host.querySelectorAll('section.tm-test-steps').forEach(n => n.remove());
282
+ // Re-resolve typeConfig from the CURRENT form type so the visibility
283
+ // decisions follow the type the user has chosen, not the initial type.
284
+ const currentType = (modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]')
285
+ || { value: initial.type }).value || initial.type;
286
+ const liveTypeConfig = (typeof core.getEntityTypeConfig === "function")
287
+ ? core.getEntityTypeConfig(projectInner, currentType)
288
+ : typeConfig;
289
+ // SM-119: the description is a contentEditable div now (was a textarea),
290
+ // so match by the sync-key attribute, not the tag.
291
+ const descNode = host.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="description"]');
292
+ const descRow = descNode ? descNode.closest(".tm-row") : null;
293
+ const anchor = descRow ? descRow.nextSibling : null;
294
+ // Anchor everything below the description, preserving append order:
295
+ // Prereqs (if shown) → Steps (if shown) → AC (if shown).
296
+ function appendBelowDesc(node) {
297
+ if (anchor) host.insertBefore(node, anchor);
298
+ else host.appendChild(node);
299
+ }
300
+ if (liveTypeConfig.showPrerequisites) {
301
+ const prereqRefs = { markDirty: () => { prereqsDirty = true; if (inplace) inplace.markDirty(); } };
302
+ // In create-mode we start with an empty list; in edit-mode use the
303
+ // ticket's existing prerequisites. The user fills the list in by
304
+ // adding rows ("+ Add Prerequisite").
305
+ const seedTicket = isCreate ? { prerequisites: [] } : initial;
306
+ appendBelowDesc(buildPrerequisitesSection(seedTicket, prereqRefs));
307
+ }
308
+ if (liveTypeConfig.showSteps) {
309
+ const stepRefs = { markDirty: () => { stepsDirty = true; if (inplace) inplace.markDirty(); } };
310
+ const seedTicket = isCreate ? { steps: [] } : initial;
311
+ appendBelowDesc(buildStepsSection(seedTicket, stepRefs));
312
+ }
313
+ // SM-57: Execution-step + Outcome sections for type='test-execution'.
314
+ // In create-mode they're not rendered — the dedicated starter dialog
315
+ // (openTestExecutionStarter) clones the steps + sets refDef, so this
316
+ // modal only sees the finished execution.
317
+ if (!isCreate && liveTypeConfig.showExecutionSteps) {
318
+ const execRefs = { markDirty: () => { if (inplace) inplace.markDirty(); } };
319
+ appendBelowDesc(buildExecutionStepsSection(initial, execRefs));
320
+ }
321
+ if (!isCreate && liveTypeConfig.showTestOutcome) {
322
+ const outcomeRefs = { markDirty: () => { if (inplace) inplace.markDirty(); } };
323
+ appendBelowDesc(buildOutcomeSection(initial, outcomeRefs));
324
+ }
325
+ if (liveTypeConfig.showAcceptanceCriteria) {
326
+ const acRefs = { markDirty: () => { acDirty = true; if (inplace) inplace.markDirty(); } };
327
+ appendBelowDesc(buildAcceptanceCriteriaSection(initial, acRefs));
328
+ }
329
+ if (reseed) { prereqsDirty = false; stepsDirty = false; acDirty = false; }
330
+ }
331
+ renderTypeDrivenSections({ reseed: true });
332
+
333
+ // DoR + DoD: vollwertiger per-Ticket-Editor (E19-followup) —
334
+ // EDIT mode → vorhandene frozen Items werden ediertbar gerendert;
335
+ // Save persistiert via store.updateTicket({ definitionOfReady, ... }).
336
+ // CREATE mode → Items werden aus `resolveDefinitions(projectInner.defs,
337
+ // type)` vorgeseedet, der User kann sie editieren bevor er auf
338
+ // Create klickt. Beim Type-Wechsel im Modal wird nur die Section
339
+ // reseeded, wenn der User noch nichts daran geändert hat (Flag
340
+ // `dorDirty`/`dodDirty`).
341
+ let dorDirty = false, dodDirty = false;
342
+ function renderDorDodSections(opts) {
343
+ const reseed = !!(opts && opts.reseed);
344
+ // Remove any prior sections (re-render on type change or first mount).
345
+ host.querySelectorAll('section.tm-checklist').forEach(n => n.remove());
346
+ // Re-resolve typeConfig from the CURRENT form type.
347
+ const currentType = (modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]') || { value: initial.type }).value || initial.type;
348
+ const liveTypeConfig = (typeof core.getEntityTypeConfig === "function")
349
+ ? core.getEntityTypeConfig(projectInner, currentType)
350
+ : typeConfig;
351
+ let dorItems, dodItems;
352
+ if (isCreate) {
353
+ const resolved = (typeof core.resolveDefinitions === "function")
354
+ ? core.resolveDefinitions(projectInner.definitions, currentType)
355
+ : { ready: [], done: [] };
356
+ dorItems = resolved.ready.map(it => Object.assign({}, it, { checked: false }));
357
+ dodItems = resolved.done.map(it => Object.assign({}, it, { checked: false }));
358
+ } else {
359
+ dorItems = (initial.definitionOfReady || { items: [] }).items;
360
+ dodItems = (initial.definitionOfDone || { items: [] }).items;
361
+ }
362
+ const dorRefs = { markDirty: () => { dorDirty = true; if (inplace) inplace.markDirty(); } };
363
+ const dodRefs = { markDirty: () => { dodDirty = true; if (inplace) inplace.markDirty(); } };
364
+ if (liveTypeConfig.showDefinitionOfReady) {
365
+ host.insertBefore(buildChecklistSection("Definition of Ready", dorItems, "definitionOfReady", dorRefs), labelsRow);
366
+ }
367
+ if (liveTypeConfig.showDefinitionOfDone) {
368
+ host.insertBefore(buildChecklistSection("Definition of Done", dodItems, "definitionOfDone", dodRefs), labelsRow);
369
+ }
370
+ if (reseed) { dorDirty = false; dodDirty = false; }
371
+ }
372
+
373
+ // SM-238: rebuild the Status control on a type change so it swaps between
374
+ // the editable <select> (work items) and the read-only derived pill +
375
+ // breakdown (epics). Preserves the currently displayed status value.
376
+ function renderStatusField() {
377
+ const statusEl = modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="status"]');
378
+ const row = statusEl && statusEl.closest ? statusEl.closest(".tm-row") : null;
379
+ if (!row || !row.parentNode) return;
380
+ const currentType = (modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]') || { value: initial.type }).value || initial.type;
381
+ const shownStatus = (statusEl.value != null && statusEl.tagName === "SELECT")
382
+ ? statusEl.value : (statusEl.textContent || initial.status);
383
+ const freshTicket = Object.assign({}, initial, { type: currentType, status: shownStatus });
384
+ const snap = (ctx.store && typeof ctx.store.get === "function") ? ctx.store.get() : snapshot;
385
+ row.parentNode.replaceChild(buildStatusField(freshTicket, statuses, snap), row);
386
+ }
387
+
388
+ const labelsRow = buildLabelsField(initial);
389
+ host.appendChild(labelsRow);
390
+ renderDorDodSections({ reseed: true });
391
+
392
+ // SM-48: Links-Section. Append last (after DoR/DoD + Labels).
393
+ // In Create-mode the ticket has no id yet, so backward-links are
394
+ // empty and the + Add link button is hidden — the section still
395
+ // renders for visual parity.
396
+ if (typeConfig.showLinks !== false) {
397
+ host.appendChild(buildLinksSection(initial, snapshot, Object.assign({}, ctx, { uiShell: ctx.uiShell })));
398
+ }
399
+
400
+ // SM-185: Attachments — ticket-scoped only, edit-mode only (needs a
401
+ // ticket id), and only when the HTTP adapter is active (attachments are
402
+ // a REST feature; no server → no attachments). No project-level UI.
403
+ if (!isCreate && ticketId && ctx.attachmentsEnabled) {
404
+ host.appendChild(buildAttachmentsSection(ticketId, ctx));
405
+ }
406
+
407
+ // Type-Wechsel in Create-Mode reseedet die Items aus dem neuen
408
+ // Type — aber NUR wenn der User die jeweilige Liste noch nicht
409
+ // angefasst hat (sonst würde sein Edit verloren gehen).
410
+ if (isCreate) {
411
+ const typeEl = modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]');
412
+ if (typeEl) typeEl.addEventListener("change", () => {
413
+ renderStatusField(); // SM-238: swap select ↔ derived pill
414
+ // Re-render the type-driven sections (Prereqs, Steps, AC) — but
415
+ // preserve any user edits via the dirty flags. Currently we only
416
+ // skip the full re-render when a section is dirty AND visibility
417
+ // hasn't changed; for simplicity, if any of them is dirty, leave
418
+ // the existing sections as-is.
419
+ if (!prereqsDirty && !stepsDirty && !acDirty) {
420
+ renderTypeDrivenSections({ reseed: true });
421
+ }
422
+ if (!dorDirty && !dodDirty) {
423
+ renderDorDodSections({ reseed: true });
424
+ }
425
+ });
426
+ }
427
+
428
+ // Live-sync + in-place persistence: only in EDIT mode (Create collects
429
+ // on the Create button — no ticket-id to commit against yet).
430
+ if (!isCreate) {
431
+ // SM-120/B5b: wire in-place commit on the body host. Discrete controls
432
+ // commit on change, text fields on blur, sections via the refs above;
433
+ // status routes through the gate and a 422 lands in the error banner.
434
+ inplace = installInPlaceCommit(host, {
435
+ store: ctx.store, ticketId: ticketId, actor: LOCAL_ACTOR,
436
+ flashStatus: ctx.flashStatus,
437
+ onStatusError: (msg) => {
438
+ const b = host.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="_error_banner"]');
439
+ if (!b) return;
440
+ if (msg) { b.style.display = "block"; b.textContent = msg; }
441
+ else { b.style.display = "none"; b.textContent = ""; }
442
+ }
443
+ });
444
+ // SM-238: edit-mode type change (e.g. story → epic) swaps the status
445
+ // control and re-gates DoR/DoD. Fires on the type select directly,
446
+ // BEFORE the host-delegated in-place commit, reading the new type from
447
+ // the select value (the commit then persists it via store.updateTicket).
448
+ const editTypeEl = modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="type"]');
449
+ if (editTypeEl) editTypeEl.addEventListener("change", () => {
450
+ renderStatusField();
451
+ renderDorDodSections();
452
+ });
453
+ // SM-5: prevTicket snapshot lets us diff sync-keyed fields per
454
+ // commit and pulse exactly the ones the upstream changed. Only
455
+ // pulses when reason === "applyRemote" (external edit); a local
456
+ // commit gives the user direct feedback through their own typing.
457
+ let prevTicket = readTicket();
458
+ const unsubscribe = ctx.store.subscribe((snap, reason) => {
459
+ const fresh = readTicket();
460
+ if (!fresh) return;
461
+ syncFieldFromTicket(modal, fresh, ctx.store.get());
462
+ if (reason === "applyRemote") pulseChangedFields(modal, prevTicket, fresh);
463
+ prevTicket = fresh;
464
+ });
465
+ observeRemoval(modal, () => { unsubscribe(); if (inplace) inplace.detach(); });
466
+ }
467
+ }
468
+ });
469
+
470
+ return handle;
471
+ }
472
+
473
+ /**
474
+ * Watch for the modal's removal from DOM. When it disappears, fire onGone
475
+ * (used for unsubscribing without intercepting every close-path).
476
+ */
477
+ function observeRemoval(modalEl, onGone) {
478
+ if (typeof MutationObserver === "undefined") return;
479
+ const host = modalEl.parentNode && modalEl.parentNode.parentNode; // modal-host > overlay > modal
480
+ if (!host) return;
481
+ const mo = new MutationObserver(() => {
482
+ if (!host.contains(modalEl)) {
483
+ mo.disconnect();
484
+ try { onGone(); } catch (_) { /* ignore */ }
485
+ }
486
+ });
487
+ mo.observe(host, { childList: true, subtree: true });
488
+ }
489
+
490
+ // SM-120/B5b: the edit-mode Save path (editOnSave/hasNonStatusChanges/
491
+ // putTicket/postStatus) is gone — every field commits in place via
492
+ // installInPlaceCommit. Only the create-mode collect-on-Create remains.
493
+
494
+ /** Create action — POSTs a new ticket with the form's fields. */
495
+ async function createOnSave(ctx, modal, draftInitial, opts) {
496
+ const banner = modal.querySelector('[' + CONSTANTS.LIVE_SYNC_ATTR + '="_error_banner"]');
497
+ try {
498
+ const form = collectFormState(modal);
499
+ if (!form.title || !form.title.trim()) {
500
+ if (banner) {
501
+ banner.style.display = "block";
502
+ banner.textContent = "Title is required.";
503
+ }
504
+ return false;
505
+ }
506
+ const effectiveType = form.type || draftInitial.type;
507
+ // SM-70: Pending links live on draftInitial.links (mutated by the
508
+ // inline add-form). Required-target gate for test-definition is
509
+ // enforced HERE at create-time so the user gets immediate feedback
510
+ // (and never lands a half-broken ticket that can't be mark_ready'd).
511
+ const pendingLinks = Array.isArray(draftInitial.links) ? draftInitial.links : [];
512
+ if (effectiveType === "test-definition") {
513
+ const hasTarget = pendingLinks.some(l => l.linkTypeId === "tests");
514
+ if (!hasTarget) {
515
+ if (banner) {
516
+ banner.style.display = "block";
517
+ banner.textContent = "A test-definition must link to at least one ticket it tests. Add a 'tests' link in the Links section before saving.";
518
+ }
519
+ return false;
520
+ }
521
+ }
522
+ const body = {
523
+ type: effectiveType,
524
+ title: form.title,
525
+ description: form.description || "",
526
+ position: {
527
+ releaseId: form.position.releaseId || draftInitial.position.releaseId || null,
528
+ processStepId: form.position.processStepId || draftInitial.position.processStepId || null,
529
+ epicId: form.position.epicId || draftInitial.position.epicId || null,
530
+ sortOrder: draftInitial.position.sortOrder || 0
531
+ },
532
+ acceptanceCriteria: form.acceptanceCriteria || [],
533
+ labels: form.labels || []
534
+ };
535
+ // SM-70: forward the pending links to the server (strip the synthetic
536
+ // pending id; the server normalises and assigns a real one).
537
+ if (pendingLinks.length) {
538
+ body.links = pendingLinks.map(l => ({
539
+ linkTypeId: l.linkTypeId,
540
+ targetTicketId: l.targetTicketId,
541
+ createdBy: LOCAL_ACTOR
542
+ }));
543
+ }
544
+ // E19-followup: per-Ticket Items aus dem Editor an Create durchreichen.
545
+ // Wenn die Section gar nicht gerendert war (Gate off), bleibt der Server
546
+ // beim Default-Freeze (oder leeren Items).
547
+ if (form.definitionOfReady && Array.isArray(form.definitionOfReady.items)) {
548
+ body.definitionOfReady = form.definitionOfReady;
549
+ }
550
+ if (form.definitionOfDone && Array.isArray(form.definitionOfDone.items)) {
551
+ body.definitionOfDone = form.definitionOfDone;
552
+ }
553
+ // SM-54: pass test-definition fields through to create. `null` means
554
+ // the section wasn't rendered (type isn't test-definition or gate off)
555
+ // → server leaves the field at its empty default.
556
+ if (Array.isArray(form.prerequisites)) body.prerequisites = form.prerequisites;
557
+ if (Array.isArray(form.steps)) body.steps = form.steps;
558
+ await postCreateTicket(ctx, body);
559
+ if (ctx.flashStatus) ctx.flashStatus("ticket created", { kind: "ok" });
560
+ if (typeof opts.onCreated === "function") opts.onCreated();
561
+ } catch (err) {
562
+ console.error("[ticket-modal] create failed:", err);
563
+ if (banner) {
564
+ banner.style.display = "block";
565
+ banner.textContent = err.message || "create failed";
566
+ }
567
+ return false;
568
+ }
569
+ }
570
+
571
+ // E18.D: alle Persistenz-Shims kollabieren zu einem reinen Store-Mutate.
572
+ // Der Save-Subscriber in main.js debounced den Snapshot-PUT an den Adapter.
573
+ // Der Status-Wechsel läuft über `changeStatusGated`, das die DoR/DoD-Gates
574
+ // VOR dem Commit prüft (snapshot-PUT validiert nicht — by design).
575
+ // (LOCAL_ACTOR is declared near the top of the factory body so the
576
+ // SM-48 Links-section helpers — which run BEFORE the Persistenz-Shims —
577
+ // can reference it too.)
578
+
579
+ async function postCreateTicket(ctx, body) {
580
+ ctx.store.createTicket(body, LOCAL_ACTOR);
581
+ }
582
+
583
+ /**
584
+ * Show a type-picker modal first; on selection, opens the full ticket
585
+ * modal in CREATE mode with the chosen type. Same modal shape for every
586
+ * type — the type-config layer governs which sections are shown.
587
+ *
588
+ * @param {object} ctx — same as openTicketModal
589
+ * @param {object} draftCtx — { releaseId?, processStepId?, epicId? } — position context
590
+ * @param {object} [opts] — { onCreated }
591
+ */
592
+ function openTypePickerThenCreate(ctx, draftCtx, opts) {
593
+ opts = opts || {};
594
+ const showModal = (ctx.uiShell && ctx.uiShell.showModal)
595
+ || (typeof window !== "undefined" && window.STORYMAP && window.STORYMAP.uiShell && window.STORYMAP.uiShell.showModal);
596
+ if (typeof showModal !== "function") throw new Error("openTypePickerThenCreate: showModal missing on uiShell");
597
+ const project = ctx.store.get().project || {};
598
+ // SM-196: the "+ Add Item" picker excludes only the SPEC types
599
+ // (requirement/spec-module — authored through the requirements flow). Epics
600
+ // ARE offerable here: they're the story-map containers, created from a cell/
601
+ // release/backlog like any other card. (Bug fix: isBoardWorkItem also
602
+ // excludes epic, which wrongly hid it from the picker.)
603
+ const types = ((project.ticketTypes && project.ticketTypes.length)
604
+ ? project.ticketTypes : core.DEFAULT_TICKET_TYPES)
605
+ .filter(t => t === "epic" || core.isBoardWorkItem(t));
606
+
607
+ showModal({
608
+ title: "New ticket — pick type",
609
+ bodyHTML: '<div class="tm-type-picker"></div>',
610
+ actions: [
611
+ { label: "Cancel", onClick: () => {} }
612
+ ],
613
+ onMount: (modal, close) => {
614
+ const host = modal.querySelector(".tm-type-picker");
615
+ for (const type of types) {
616
+ const btn = el("button", { class: "tm-type-pick-btn", type: "button" });
617
+ btn.appendChild(el("span", { class: "tm-type-pick-name", text: type }));
618
+ btn.addEventListener("click", () => {
619
+ close();
620
+ // Defer to next tick so the picker DOM teardown is complete
621
+ // before showModal swaps in the create-modal (single modal-host).
622
+ Promise.resolve().then(() => {
623
+ // SM-71: test-execution gets a specialised starter dialog —
624
+ // step-cloning + the executes-link are wired via
625
+ // core.ops.startTestExecution, so the generic detail-modal
626
+ // create flow doesn't apply.
627
+ if (type === "test-execution") {
628
+ openTestExecutionStarter(ctx, draftCtx, opts);
629
+ return;
630
+ }
631
+ openTicketModal(ctx, null, {
632
+ // The PICKED type must win — draftCtx may carry a default type
633
+ // (e.g. the epic "+" passes type:"user-story") that must not
634
+ // override the user's explicit choice in the picker.
635
+ draft: Object.assign({}, draftCtx || {}, { type: type }),
636
+ onCreated: opts.onCreated
637
+ });
638
+ });
639
+ });
640
+ host.appendChild(btn);
641
+ }
642
+ }
643
+ });
644
+ }
645
+
646
+ /**
647
+ * SM-71: Dedicated starter dialog for test-execution. Required field is
648
+ * the test-definition to clone steps from. Optional: env tag. On Start
649
+ * the store-op `startTestExecution` runs — that handles step-cloning,
650
+ * the executes-link, and ticket-counter bump. After save the new
651
+ * execution ticket is opened in the detail modal so the user can
652
+ * immediately record per-step actuals.
653
+ */
654
+ function openTestExecutionStarter(ctx, draftCtx, opts) {
655
+ opts = opts || {};
656
+ const showModal = (ctx.uiShell && ctx.uiShell.showModal)
657
+ || (typeof window !== "undefined" && window.STORYMAP && window.STORYMAP.uiShell && window.STORYMAP.uiShell.showModal);
658
+ if (typeof showModal !== "function") throw new Error("openTestExecutionStarter: showModal missing on uiShell");
659
+
660
+ const snap = ctx.store.get();
661
+ const allDefs = (snap.tickets || []).filter(t => !t.isDeleted && t.type === "test-definition");
662
+
663
+ let selectedDefId = allDefs.length ? allDefs[0].id : null;
664
+
665
+ const startAction = {
666
+ label: "Start", primary: true,
667
+ onClick: async (modal) => {
668
+ const banner = modal.querySelector(".tm-test-exec-banner");
669
+ if (!selectedDefId) {
670
+ if (banner) {
671
+ banner.style.display = "block";
672
+ banner.textContent = "Pick a test-definition before starting.";
673
+ }
674
+ return false;
675
+ }
676
+ const envInput = modal.querySelector(".tm-test-exec-env");
677
+ const env = envInput ? (envInput.value || "").trim() : "";
678
+ try {
679
+ ctx.store.startTestExecution(selectedDefId, { env: env || undefined }, LOCAL_ACTOR);
680
+ if (ctx.flashStatus) ctx.flashStatus("test-execution started", { kind: "ok" });
681
+ // Re-read snapshot to find the just-pushed execution ticket
682
+ // (highest-keyed test-execution referencing the chosen definition).
683
+ const after = ctx.store.get();
684
+ const newExec = (after.tickets || []).slice().reverse()
685
+ .find(t => t.type === "test-execution" && t.referencedTestDefinitionId === selectedDefId);
686
+ if (newExec) {
687
+ Promise.resolve().then(() => openTicketModal(ctx, newExec.id, {
688
+ onDelete: opts.onDelete
689
+ }));
690
+ }
691
+ if (typeof opts.onCreated === "function") opts.onCreated();
692
+ } catch (err) {
693
+ if (banner) {
694
+ banner.style.display = "block";
695
+ banner.textContent = err.message || "start failed";
696
+ }
697
+ return false;
698
+ }
699
+ }
700
+ };
701
+
702
+ showModal({
703
+ title: "Start test execution",
704
+ bodyHTML: '<div class="tm-test-exec-host"></div>',
705
+ actions: [
706
+ { label: "Cancel", onClick: () => {} },
707
+ startAction
708
+ ],
709
+ onMount: (modal, close) => {
710
+ const host = modal.querySelector(".tm-test-exec-host");
711
+ const banner = el("div", { class: "tm-test-exec-banner", style: { display: "none" } });
712
+ host.appendChild(banner);
713
+
714
+ if (!allDefs.length) {
715
+ const empty = el("div", { class: "tm-test-exec-empty",
716
+ text: "No test-definitions exist in this project yet. Create one first, then come back here to record a run." });
717
+ host.appendChild(empty);
718
+ // Disable Start by blanking selectedDefId; the action handler bails.
719
+ selectedDefId = null;
720
+ return;
721
+ }
722
+
723
+ // Definition picker.
724
+ const defRow = el("div", { class: "tm-row" });
725
+ defRow.appendChild(el("label", { class: "tm-label", text: "Test-Definition" }));
726
+ const defSel = el("select", { class: "tm-test-exec-def-select" });
727
+ for (const def of allDefs) {
728
+ const optEl = el("option", { value: def.id,
729
+ text: (def.ticketKey || def.id) + " — " + (def.title || "") });
730
+ defSel.appendChild(optEl);
731
+ }
732
+ defSel.value = selectedDefId;
733
+ defSel.addEventListener("change", () => { selectedDefId = defSel.value; });
734
+ defRow.appendChild(defSel);
735
+ host.appendChild(defRow);
736
+
737
+ // Optional env field.
738
+ const envRow = el("div", { class: "tm-row" });
739
+ envRow.appendChild(el("label", { class: "tm-label", text: "Environment (optional)" }));
740
+ const envInput = el("input", { type: "text", class: "tm-test-exec-env",
741
+ placeholder: "e.g. staging, prod, local-chrome" });
742
+ envRow.appendChild(envInput);
743
+ host.appendChild(envRow);
744
+ }
745
+ });
746
+ }
747
+
748
+ return {
749
+ CONSTANTS,
750
+ openTicketModal,
751
+ openTypePickerThenCreate,
752
+ openTestExecutionStarter,
753
+ // exposed for testing — shared builders/state come from ticket-form,
754
+ // createOnSave stays here (host-only). SM-278: maybeCancelAction so the
755
+ // epic-confirm "return false" contract is pinned.
756
+ _internals: Object.assign({}, ticketForm, { createOnSave, maybeCancelAction })
757
+ };
758
+ }));