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,455 @@
1
+ /**
2
+ * renderer-ticket-editor.js — Vollseiten-Ticket-Editor (Epic B / B2, SM-117).
3
+ *
4
+ * Bookmarkbare Seite `/editor?projectId=&ticketId=`. Lädt das Projekt in einen
5
+ * ProjectStore und rendert EIN Ticket type-aware mit DENSELBEN Buildern wie das
6
+ * Detail-Modal (frontend/js/ticket-form.js) — keine Builder-Duplikation. B2
7
+ * liefert Laden + type-aware Sektionen + /ws-Live-Sync; die In-Place-Persistenz
8
+ * (kein Save-Button) folgt in B5, daher ist `markDirty` hier noch ein No-Op.
9
+ *
10
+ * UMD-gewickelt: factory(core, ticketForm). `mount(host, store, ctx)` ist rein
11
+ * (jsdom-testbar); `bootstrap()` macht das I/O (pickAdapter → load → store →
12
+ * mount → WS) und wird von ticket-editor.html auf DOMContentLoaded gerufen.
13
+ */
14
+ (function (root, factory) {
15
+ if (typeof module === "object" && module.exports) module.exports = factory(require("./core.js"), require("./ticket-form.js"));
16
+ else (root.STORYMAP = root.STORYMAP || {}).rendererTicketEditor = factory(
17
+ root.STORYMAP && root.STORYMAP.core,
18
+ root.STORYMAP && root.STORYMAP.ticketForm
19
+ );
20
+ }(typeof self !== "undefined" ? self : this, function (core, ticketForm) {
21
+ "use strict";
22
+
23
+ if (!core) throw new Error("renderer-ticket-editor: core module missing");
24
+ if (!ticketForm) throw new Error("renderer-ticket-editor: ticket-form module missing");
25
+
26
+ const {
27
+ el, buildErrorBanner, buildTitleField, buildTypeField, buildStatusField,
28
+ buildPositionFields, buildDescriptionField, buildLabelsField,
29
+ buildAcceptanceCriteriaSection, buildChecklistSection,
30
+ buildPrerequisitesSection, buildStepsSection,
31
+ buildExecutionStepsSection, buildOutcomeSection,
32
+ buildTestExecutionHeader, buildExecutionMetadata,
33
+ buildLinksSection, buildAttachmentsSection, syncFieldFromTicket, LOCAL_ACTOR
34
+ } = ticketForm;
35
+
36
+ /**
37
+ * Parse the editor query string into { projectId, ticketId }. Accepts a raw
38
+ * search string ("?a=b") or a full href. Missing keys come back as "".
39
+ */
40
+ function parseEditorQuery(search) {
41
+ let qs = String(search || "");
42
+ const qIdx = qs.indexOf("?");
43
+ if (qIdx >= 0) qs = qs.slice(qIdx + 1);
44
+ const hIdx = qs.indexOf("#");
45
+ if (hIdx >= 0) qs = qs.slice(0, hIdx);
46
+ const params = new URLSearchParams(qs);
47
+ return { projectId: params.get("projectId") || "", ticketId: params.get("ticketId") || "" };
48
+ }
49
+
50
+ /**
51
+ * Resolve the effective type-config for a ticket (data-driven section
52
+ * visibility), with the same all-true defaults the modal uses as a fallback
53
+ * when core.getEntityTypeConfig isn't available.
54
+ */
55
+ function typeConfigFor(project, type) {
56
+ if (typeof core.getEntityTypeConfig === "function") return core.getEntityTypeConfig(project, type);
57
+ return {
58
+ showAcceptanceCriteria: true, showDefinitionOfReady: true, showDefinitionOfDone: true,
59
+ allowParentEpic: type !== "epic", showRelease: true, showProcessStep: true,
60
+ showLinks: true
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Render the ticket's fields into `host` using the shared ticket-form
66
+ * builders, in the same order the modal uses. Returns nothing; the caller
67
+ * owns `host`. `ctx` carries { projectId, store, flashStatus?, uiShell? } and
68
+ * is threaded into buildLinksSection (which needs store + flashStatus).
69
+ *
70
+ * Edit-mode only — the editor always opens an EXISTING ticket. Persistence is
71
+ * deferred to B5, so the builders get a no-op markDirty for now.
72
+ */
73
+ function renderTicketInto(host, ticket, snapshot, ctx) {
74
+ const project = (snapshot && snapshot.project) || {};
75
+ const typeConfig = typeConfigFor(project, ticket.type);
76
+ const ticketTypes = (project.ticketTypes && project.ticketTypes.length)
77
+ ? project.ticketTypes : core.DEFAULT_TICKET_TYPES;
78
+ // SM-244: status options from the project workflow (incl. cancelled), so a
79
+ // cancelled ticket shows "cancelled" and can be reopened by picking an
80
+ // earlier status — not the hardcoded 5-status default that hid cancelled.
81
+ const statuses = (typeof core.getWorkflowForType === "function")
82
+ ? core.getWorkflowForType(project, ticket.type).statuses.map(s => (typeof s === "string" ? s : s.id))
83
+ : core.DEFAULT_STATUSES;
84
+ // SM-120: the in-place controller (installed by mount) drives section commits
85
+ // through this shared refs.markDirty. Set after build — the builders read the
86
+ // property at event time, so a later assignment takes effect.
87
+ const refs = ctx.refs || { markDirty: function () {} };
88
+
89
+ // SM-118: content-first two-pane layout. The error banner spans full width
90
+ // at the top; below it `.te-main` (the content — title hero, description,
91
+ // checklists/steps) takes the room and `.te-side` holds the meta fields
92
+ // (type/status/position/labels/links).
93
+ host.appendChild(buildErrorBanner());
94
+ const layout = el("div", { class: "te-layout" });
95
+ const main = el("div", { class: "te-main" });
96
+ const side = el("aside", { class: "te-side" });
97
+ layout.appendChild(main);
98
+ layout.appendChild(side);
99
+ host.appendChild(layout);
100
+
101
+ // --- Main column: title hero + description + content sections ---
102
+ // SM-158: multiline title hero — wraps across the full width (no clipping).
103
+ const titleRow = buildTitleField(ticket, { multiline: true });
104
+ titleRow.classList.add("te-title-row"); // CSS renders this large + label-less
105
+ main.appendChild(titleRow);
106
+ // SM-157: the description self-commits via inline ✓/✗ (and focus-out).
107
+ main.appendChild(buildDescriptionField(ticket, ctx.onDescriptionCommit
108
+ ? { onCommit: ctx.onDescriptionCommit } : {}));
109
+
110
+ if (ticket.type === "test-execution") {
111
+ main.appendChild(buildTestExecutionHeader(ticket, snapshot, ctx));
112
+ main.appendChild(buildExecutionMetadata(ticket));
113
+ }
114
+ if (typeConfig.showPrerequisites) main.appendChild(buildPrerequisitesSection(ticket, refs));
115
+ if (typeConfig.showSteps) main.appendChild(buildStepsSection(ticket, refs));
116
+ if (typeConfig.showExecutionSteps) main.appendChild(buildExecutionStepsSection(ticket, refs));
117
+ if (typeConfig.showTestOutcome) main.appendChild(buildOutcomeSection(ticket, refs));
118
+ if (typeConfig.showAcceptanceCriteria) main.appendChild(buildAcceptanceCriteriaSection(ticket, refs));
119
+ if (typeConfig.showDefinitionOfReady) {
120
+ main.appendChild(buildChecklistSection("Definition of Ready",
121
+ (ticket.definitionOfReady || { items: [] }).items, "definitionOfReady", refs));
122
+ }
123
+ if (typeConfig.showDefinitionOfDone) {
124
+ main.appendChild(buildChecklistSection("Definition of Done",
125
+ (ticket.definitionOfDone || { items: [] }).items, "definitionOfDone", refs));
126
+ }
127
+ // SM-160: Links belong with the content (below the description), not in the
128
+ // compact meta sidebar — they're a list of related tickets, not a control.
129
+ if (typeConfig.showLinks !== false) {
130
+ main.appendChild(buildLinksSection(ticket, snapshot, ctx));
131
+ }
132
+ // SM-185: ticket-scoped attachments (same component as the modal). Only with
133
+ // the HTTP adapter (REST feature); the editor always loads an existing
134
+ // ticket so there's always a ticket id.
135
+ if (ctx.attachmentsEnabled && ctx.ticketId) {
136
+ main.appendChild(buildAttachmentsSection(ctx.ticketId, ctx));
137
+ }
138
+
139
+ // --- Sidebar: compact meta fields only ---
140
+ side.appendChild(buildTypeField(ticket, ticketTypes));
141
+ side.appendChild(buildStatusField(ticket, statuses, snapshot));
142
+ for (const row of buildPositionFields(ticket, snapshot, typeConfig)) side.appendChild(row);
143
+ side.appendChild(buildLabelsField(ticket));
144
+ // SM-244: editor action row — Delete (red) + Cancel ticket (amber), parity
145
+ // with the modal which has both.
146
+ const actionsRow = el("div", { class: "te-side-actions" });
147
+ const delBtn = buildDeleteButton(ctx, ticket);
148
+ const cancelBtn = maybeCancelButton(ctx, ticket);
149
+ if (delBtn) actionsRow.appendChild(delBtn);
150
+ if (cancelBtn) actionsRow.appendChild(cancelBtn);
151
+ if (actionsRow.children.length) side.appendChild(actionsRow);
152
+ }
153
+
154
+ // SM-244: full-page editor Delete button (red, destructive) — parity with the
155
+ // modal. Confirms first (uiShell modal when available, else window.confirm),
156
+ // soft-deletes, then navigates back if possible. Delete HIDES the ticket; the
157
+ // amber Cancel keeps it visible (scope reduction).
158
+ function buildDeleteButton(ctx, ticket) {
159
+ if (!ticket || !ctx || !ctx.store) return null;
160
+ const ticketId = ctx.ticketId;
161
+ const btn = el("button", { class: "btn destructive te-delete-action", type: "button", text: "Delete" });
162
+ function doDelete() {
163
+ try {
164
+ ctx.store.softDeleteTicket(ticketId, LOCAL_ACTOR);
165
+ if (ctx.flashStatus) ctx.flashStatus("ticket deleted", { kind: "ok" });
166
+ if (typeof window !== "undefined" && window.history && window.history.length > 1) window.history.back();
167
+ } catch (e) {
168
+ if (ctx.flashStatus) ctx.flashStatus((e && e.message) || "delete failed", { kind: "error" });
169
+ }
170
+ }
171
+ btn.addEventListener("click", () => {
172
+ const msg = "This hides the ticket. Use Cancel to drop scope but keep it visible.";
173
+ if (ctx.uiShell && typeof ctx.uiShell.showModal === "function") {
174
+ ctx.uiShell.showModal({
175
+ title: "Delete ticket?", sub: msg,
176
+ actions: [
177
+ { label: "Back", onClick: () => {} },
178
+ { label: "Delete", destructive: true, onClick: () => doDelete() }
179
+ ]
180
+ });
181
+ return;
182
+ }
183
+ if (typeof window !== "undefined" && typeof window.confirm === "function"
184
+ && !window.confirm("Delete this ticket? " + msg)) return;
185
+ doDelete();
186
+ });
187
+ return btn;
188
+ }
189
+
190
+ // SM-244: build the editor's "Cancel ticket" button (or null). Mirrors the
191
+ // modal: amber, hidden for spec types + already-cancelled tickets, asks to
192
+ // confirm an epic cascade (via uiShell.showModal when available, else
193
+ // window.confirm) before committing through store.cancelTicket.
194
+ function maybeCancelButton(ctx, ticket) {
195
+ if (!ticket || !ctx || !ctx.store) return null;
196
+ const isSpec = !core.isBoardWorkItem(ticket.type) && ticket.type !== "epic";
197
+ if (isSpec) return null;
198
+ const snap = ctx.store.get();
199
+ if (core.statusCategoryOf(snap.project, ticket.status) === "cancelled") return null;
200
+ // SM-244: a done epic can't be meaningfully cancelled (roll-up keeps it
201
+ // done) — hide the action rather than offer a no-op.
202
+ if (ticket.type === "epic" && core.statusCategoryOf(snap.project, ticket.status) === "done") return null;
203
+ const ticketId = ctx.ticketId;
204
+ const btn = el("button", { class: "btn warn te-cancel-action", type: "button", text: "Cancel ticket" });
205
+ function doCancel() {
206
+ try {
207
+ ctx.store.cancelTicket(ticketId, LOCAL_ACTOR);
208
+ if (ctx.flashStatus) ctx.flashStatus("ticket cancelled", { kind: "ok" });
209
+ // SM-244: close the full-page editor after cancelling (parity with the
210
+ // modal, which closes on cancel) by navigating back.
211
+ if (typeof window !== "undefined" && window.history && window.history.length > 1) window.history.back();
212
+ } catch (e) {
213
+ if (ctx.flashStatus) ctx.flashStatus((e && e.message) || "cancel failed", { kind: "error" });
214
+ }
215
+ }
216
+ btn.addEventListener("click", () => {
217
+ const fresh = ctx.store.get();
218
+ if (ticket.type === "epic") {
219
+ const st = core.epicChildStats(fresh, ticketId);
220
+ const open = st.doing + st.todo;
221
+ // SM-244: always confirm an epic cancel (feedback), naming the cascade.
222
+ const msg = open > 0
223
+ ? "This also cancels " + open + " open " + (open === 1 ? "story" : "stories") + " contained in the epic."
224
+ : "This epic has no open contained stories — only the epic is cancelled.";
225
+ if (ctx.uiShell && typeof ctx.uiShell.showModal === "function") {
226
+ ctx.uiShell.showModal({
227
+ title: "Cancel epic?", sub: msg,
228
+ actions: [
229
+ { label: "Back", onClick: () => {} },
230
+ { label: "Cancel epic", className: "warn", onClick: () => doCancel() }
231
+ ]
232
+ });
233
+ return;
234
+ }
235
+ if (typeof window !== "undefined" && typeof window.confirm === "function"
236
+ && !window.confirm("Cancel this epic? " + msg)) return;
237
+ }
238
+ doCancel();
239
+ });
240
+ return btn;
241
+ }
242
+
243
+ /**
244
+ * Mount the editor for `ctx.ticketId` against a ProjectStore. Renders a header
245
+ * (ticket-key + title) + the type-aware field body, then subscribes for
246
+ * live-sync: every store commit re-syncs the sync-keyed fields (focus-guard
247
+ * inside syncFieldFromTicket prevents stomping a field the user is editing).
248
+ *
249
+ * Returns { unmount } — call it to detach the store subscription.
250
+ */
251
+ function mount(host, store, ctx) {
252
+ ctx = ctx || {};
253
+ const ticketId = ctx.ticketId;
254
+ host.innerHTML = "";
255
+
256
+ function readTicket() {
257
+ const snap = store.get();
258
+ return (snap.tickets || []).find(t => t.id === ticketId) || null;
259
+ }
260
+
261
+ const root = el("div", { class: "te-root" });
262
+ host.appendChild(root);
263
+
264
+ const ticket = readTicket();
265
+ if (!ticket) {
266
+ root.appendChild(el("div", { class: "te-missing",
267
+ text: "Ticket not found: " + (ticketId || "(none)") }));
268
+ return { unmount: function () {} };
269
+ }
270
+
271
+ // SM-118: content-first — the header is a slim breadcrumb.
272
+ // SM-122/B7: a "← Board" link back to the board so the full-page editor
273
+ // isn't a dead end. The editable title is the hero in the main column.
274
+ const header = el("div", { class: "te-header" });
275
+ header.appendChild(el("a", { class: "te-back", href: "/", title: "Back to the board", text: "← Board" }));
276
+ header.appendChild(el("span", { class: "te-key", text: ticket.ticketKey || "" }));
277
+ root.appendChild(header);
278
+
279
+ const body = el("div", { class: "te-body" });
280
+ root.appendChild(body);
281
+ // SM-157: description self-commits to the store; bootstrap's save-subscriber
282
+ // then debounce-PUTs the snapshot so it survives a reload.
283
+ const onDescriptionCommit = function (text) {
284
+ try { store.updateTicket(ticketId, { description: text }, LOCAL_ACTOR); }
285
+ catch (e) { if (ctx.flashStatus) ctx.flashStatus("description save failed: " + (e && e.message || e), { kind: "error" }); }
286
+ };
287
+ // SM-120: full in-place persistence — every field commits a Store-op (no
288
+ // Save button). The shared `refs` is driven by installInPlaceCommit below.
289
+ const refs = { markDirty: function () {} };
290
+ renderTicketInto(body, ticket, store.get(), Object.assign({}, ctx, { store, onDescriptionCommit, refs }));
291
+
292
+ // SM-120: inline gate-error reporting reuses the error banner at the top.
293
+ function showStatusError(msg) {
294
+ const banner = body.querySelector('[data-sm-sync-key="_error_banner"]');
295
+ if (!banner) return;
296
+ if (!msg) { banner.style.display = "none"; banner.textContent = ""; return; }
297
+ banner.style.display = "block";
298
+ banner.textContent = msg;
299
+ }
300
+ const inplace = ticketForm.installInPlaceCommit(body, {
301
+ store: store, ticketId: ticketId, actor: LOCAL_ACTOR,
302
+ onStatusError: showStatusError, flashStatus: ctx.flashStatus
303
+ });
304
+ refs.markDirty = inplace.markDirty; // section edits now auto-commit
305
+
306
+ // Live-sync: re-sync fields on every commit. syncFieldFromTicket carries
307
+ // its own focus-guard + section rebuilds (AC / DoR / DoD / prereqs / steps
308
+ // / links), so an external edit (board, modal, MCP) reflects here.
309
+ const unsubscribe = store.subscribe(function () {
310
+ const fresh = readTicket();
311
+ if (!fresh) return;
312
+ syncFieldFromTicket(body, fresh, store.get());
313
+ });
314
+
315
+ return { unmount: function () {
316
+ try { unsubscribe(); } catch (_) { /* ignore */ }
317
+ try { inplace.detach(); } catch (_) { /* ignore */ }
318
+ } };
319
+ }
320
+
321
+ /**
322
+ * SM-157: debounced whole-snapshot save-subscriber (mirrors
323
+ * main.js#installSaveSubscriber). Persists every store commit EXCEPT
324
+ * applyRemote/hydrate (those came from the server — re-saving would
325
+ * ping-pong). Without it the editor store is local-only and edits vanish on
326
+ * reload.
327
+ */
328
+ // SM-214: delegates to the shared save-pipeline module (debounce, SM-153
329
+ // race guard, one retry, pagehide flush via saveBeacon) — the exact same
330
+ // pipeline main.js uses. Keeps the editor surface DRY with the map/kanban.
331
+ function installSaveSubscriber(store, adapter, projectId, SM, flashStatus, win) {
332
+ const sp = (SM && SM.savePipeline) ||
333
+ (typeof require === "function" ? require("./save-pipeline.js") : null);
334
+ if (!sp) throw new Error("renderer-ticket-editor: save-pipeline module missing (load order)");
335
+ const skip = (SM && SM.store && SM.store.SKIP_PERSIST_REASONS) || null;
336
+ return sp.createSavePipeline({
337
+ store: store,
338
+ adapter: adapter,
339
+ projectId: projectId,
340
+ skipReasons: skip,
341
+ onError: function (err) {
342
+ if (flashStatus) flashStatus("save failed: " + ((err && err.message) || err), { kind: "error" });
343
+ },
344
+ win: win
345
+ });
346
+ }
347
+
348
+ /**
349
+ * Page entry-point: parse the URL, pick an adapter, load the project into a
350
+ * ProjectStore, mount the editor, and wire /ws live-sync. Called from
351
+ * ticket-editor.html on DOMContentLoaded. Needs window.STORYMAP.{adapters,
352
+ * store, uiShell}. Resolves to the mount controller (or null on failure).
353
+ */
354
+ async function bootstrap(opts) {
355
+ opts = opts || {};
356
+ const win = (typeof window !== "undefined") ? window : {};
357
+ const SM = win.STORYMAP || {};
358
+ const adapters = opts.adapters || SM.adapters;
359
+ const ProjectStore = (opts.store && opts.store.ProjectStore) || (SM.store && SM.store.ProjectStore);
360
+ const uiShell = opts.uiShell || SM.uiShell;
361
+ const hostEl = opts.host || (win.document && win.document.getElementById("editor-host"));
362
+ const flashStatus = (uiShell && uiShell.flashStatus) || function (m) { /* no shell */ if (win.console) win.console.log("[editor] " + m); };
363
+
364
+ const href = opts.href || (win.location && win.location.href) || "";
365
+ const search = opts.search || (win.location && win.location.search) || href;
366
+ const q = parseEditorQuery(search);
367
+ if (!q.projectId || !q.ticketId) {
368
+ if (hostEl) hostEl.appendChild(el("div", { class: "te-missing", text: "Missing ?projectId= or ?ticketId= in the URL." }));
369
+ return null;
370
+ }
371
+
372
+ const adapter = await adapters.pickAdapter({ url: href });
373
+ const snap = await adapter.load(q.projectId);
374
+ if (!snap) {
375
+ if (hostEl) hostEl.appendChild(el("div", { class: "te-missing", text: "Project not found: " + q.projectId }));
376
+ return null;
377
+ }
378
+ const store = new ProjectStore(snap);
379
+
380
+ // SM-158: the URL's ticketId may be the human ticket-KEY (SM-140) or the
381
+ // internal id (t-…) — resolve either to the internal id so links built from
382
+ // the key (Jira-style, memorable, type-able) work. All store-ops below use
383
+ // the resolved internal id.
384
+ const t = (snap.tickets || []).find(x => x.id === q.ticketId || x.ticketKey === q.ticketId);
385
+ if (!t) {
386
+ if (hostEl) hostEl.appendChild(el("div", { class: "te-missing", text: "Ticket not found: " + q.ticketId }));
387
+ return null;
388
+ }
389
+ const ticketId = t.id;
390
+
391
+ // SM-157: persist store commits (e.g. the description inline-confirm) back
392
+ // to the server via a debounced whole-snapshot PUT — same cmapper pattern
393
+ // as the board (main.js#installSaveSubscriber). Without this the editor's
394
+ // store would be a local-only copy and edits would vanish on reload.
395
+ installSaveSubscriber(store, adapter, q.projectId, SM, flashStatus, win);
396
+
397
+ // SM-185: attachment helpers (HTTP adapter only — REST feature), so the
398
+ // full-page editor shows the same ticket-scoped dropzone as the modal.
399
+ const attBase = function (p) { return adapter.base + "/api/projects/" + encodeURIComponent(q.projectId) + p; };
400
+ const attHeaders = function () { return { "X-Origin-Id": adapter.originId }; };
401
+ const ctx = {
402
+ projectId: q.projectId, ticketId: ticketId, store, uiShell, flashStatus,
403
+ attachmentsEnabled: !!(adapter && adapter.name === "Http"),
404
+ attachmentHref: function (aid) { return attBase("/attachments/" + encodeURIComponent(aid)); },
405
+ listAttachments: async function (tid) {
406
+ const res = await fetch(attBase("/attachments?ticketId=" + encodeURIComponent(tid)));
407
+ if (!res.ok) return [];
408
+ const d = await res.json(); return (d && d.attachments) || [];
409
+ },
410
+ uploadAttachment: async function (tid, file) {
411
+ // Send raw bytes as an ArrayBuffer for cross-fetch determinism (a File
412
+ // body works in browsers but not every fetch impl), matching main.js.
413
+ const bytes = (typeof file.arrayBuffer === "function") ? await file.arrayBuffer() : file;
414
+ const res = await fetch(attBase("/attachments?ticketId=" + encodeURIComponent(tid)), {
415
+ method: "POST",
416
+ headers: Object.assign({ "Content-Type": file.type || "application/octet-stream", "X-Filename": encodeURIComponent(file.name) }, attHeaders()),
417
+ body: bytes
418
+ });
419
+ if (!res.ok) {
420
+ let d = null; try { d = JSON.parse(await res.text()); } catch (_) { /* non-JSON */ }
421
+ const e = new Error((d && d.error) || ("HTTP " + res.status)); e.statusCode = res.status; throw e;
422
+ }
423
+ return (await res.json()).attachment;
424
+ },
425
+ deleteAttachment: async function (aid) {
426
+ const res = await fetch(attBase("/attachments/" + encodeURIComponent(aid)), { method: "DELETE", headers: attHeaders() });
427
+ if (!res.ok && res.status !== 204) { const e = new Error("HTTP " + res.status); e.statusCode = res.status; throw e; }
428
+ }
429
+ };
430
+ const ctrl = mount(hostEl, store, ctx);
431
+
432
+ // Reflect the ticket key in the document title / tab.
433
+ if (win.document) win.document.title = (t.ticketKey ? t.ticketKey + " — " : "") + (t.title || "Editor") + " · Story Mapper";
434
+
435
+ // /ws live-sync: on any change, reload the snapshot and applyRemote.
436
+ if (typeof adapter.subscribe === "function") {
437
+ try {
438
+ await adapter.subscribe(q.projectId, async function () {
439
+ const fresh = await adapter.load(q.projectId);
440
+ if (fresh) store.applyRemote(fresh);
441
+ });
442
+ } catch (e) { if (win.console) win.console.warn("editor WS subscribe failed:", e); }
443
+ }
444
+
445
+ return ctrl;
446
+ }
447
+
448
+ return {
449
+ mount,
450
+ bootstrap,
451
+ parseEditorQuery,
452
+ // exposed for tests
453
+ _internals: { renderTicketInto, typeConfigFor }
454
+ };
455
+ }));