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,2499 @@
1
+ /**
2
+ * storymap — bootstrap script.
3
+ *
4
+ * 1. pickAdapter() chooses Http / WindowStorage / LocalStorage / Memory.
5
+ * 2. Mounts the Project menu via ui-shell.mountMenuBar.
6
+ * 3. Loads project list, hydrates first project (or empty state).
7
+ * 4. Mounts the Story-Map renderer + subscribes to live-sync.
8
+ *
9
+ * All user dialogs go through ui-shell.showModal — never prompt/confirm/alert.
10
+ * All status feedback through ui-shell.flashStatus.
11
+ */
12
+ (function () {
13
+ "use strict";
14
+
15
+ const SM = window.STORYMAP;
16
+ if (!SM || !SM.core || !SM.store || !SM.adapters || !SM.uiShell || !SM.rendererStoryMap || !SM.rendererKanban || !SM.rendererDependencies || !SM.rendererTicketModal || !SM.viewSettings || !SM.viewRequirements) {
17
+ console.error("storymap bootstrap: missing modules", Object.keys(SM || {}));
18
+ return;
19
+ }
20
+
21
+ const { core, store, adapters, uiShell, rendererStoryMap, rendererKanban, rendererDependencies, rendererTicketModal, viewSettings, viewRequirements, projectIO } = SM;
22
+ const { ProjectStore, SKIP_PERSIST_REASONS } = store;
23
+ const { pickAdapter } = adapters;
24
+ const { showModal, flashStatus } = uiShell;
25
+
26
+ // E18.D: einheitlicher Actor für alle store-ops, die im UI gefeuert werden.
27
+ // MCP-Tools setzen ihren eigenen Actor im Server; hier landen nur lokale
28
+ // User-Klicks.
29
+ const LOCAL_ACTOR = { type: "human", id: "local", name: "Local" };
30
+
31
+ // ---- Constants -------------------------------------------------------
32
+
33
+ const BOOTSTRAP = {
34
+ PROJECT_ID_PATTERN: /^[a-zA-Z0-9_-]{1,100}$/,
35
+ DEFAULT_TICKET_TYPE: "user-story",
36
+ // Defaults bei Project-Create — ein neues Projekt ist sonst nicht
37
+ // arbeitsfähig (keine Cell sichtbar, kein Drag-Target). Diese Defaults
38
+ // sind im UI-Bootstrap-Pfad eingebaut; MCP / direkter REST-Call kann
39
+ // sie umgehen indem er den Standard-Pfad nicht nutzt.
40
+ DEFAULT_RELEASE_NAME: "v1.0",
41
+ DEFAULT_PROCESS_STEP_NAME: "Activities",
42
+ // E18.C: debounce-Fenster für den Save-Subscriber. Cmapper nutzt 300 ms —
43
+ // genug, um eine Drag-Sequenz aus vielen Mikro-Commits zu einem Save
44
+ // zusammenzufassen, ohne wahrnehmbare Latenz.
45
+ SAVE_DEBOUNCE_MS: 300
46
+ };
47
+
48
+ // ---- App state -------------------------------------------------------
49
+
50
+ const app = {
51
+ adapter: null,
52
+ store: null,
53
+ currentProjectId: null,
54
+ unsubscribeWs: null,
55
+ unmountStoryMap: null,
56
+ unmountKanban: null,
57
+ unmountDeps: null,
58
+ unmountSettings: null,
59
+ unmountRequirements: null,
60
+ activeView: readActiveViewFromStorage() || "storymap",
61
+ // SM-82: ticket filter (per-project). Hydrated from localStorage when
62
+ // a project loads; null means "no filter" (everything visible).
63
+ filter: null,
64
+ // SM-16: live free-text search. Highlights matches + fades non-matches
65
+ // across the active view (does NOT remove cards, unlike `filter`).
66
+ search: "",
67
+ // SM-191: the unified smart-bar's active JQL query result — a Set of
68
+ // matching ticket ids (REMOVES non-matching cards, like `filter`), or null
69
+ // when the bar is in plain free-text-search mode / empty.
70
+ queryMatch: null,
71
+ // SM-191: the active query string (kept so the match set can be recomputed
72
+ // on every commit — see the query-refresh subscriber). null = no query.
73
+ queryString: null
74
+ };
75
+ SM.app = app;
76
+
77
+ // ---- DOM helpers -----------------------------------------------------
78
+
79
+ const $ = (id) => document.getElementById(id);
80
+
81
+ function readActiveViewFromStorage() {
82
+ try {
83
+ const v = (typeof localStorage !== "undefined") && localStorage.getItem("storymap-active-view");
84
+ // E23.B: "settings" is no longer a persistent activeView; it's an
85
+ // overlay over whatever view is underneath. Legacy entries map back
86
+ // to storymap on next load.
87
+ return (v === "kanban" || v === "storymap" || v === "dependencies" || v === "requirements" || v === "processSteps" || v === "table") ? v : null;
88
+ } catch (_) { return null; }
89
+ }
90
+ function writeActiveViewToStorage(v) {
91
+ try { if (typeof localStorage !== "undefined") localStorage.setItem("storymap-active-view", v); } catch (_) { /* ignore */ }
92
+ }
93
+ function readLastProjectIdFromStorage() {
94
+ try {
95
+ const v = (typeof localStorage !== "undefined") && localStorage.getItem("storymap-last-project");
96
+ return (typeof v === "string" && v.length > 0) ? v : null;
97
+ } catch (_) { return null; }
98
+ }
99
+
100
+ // SM-82 — Per-project filter persistence.
101
+ function filterStorageKey(projectId) {
102
+ return "storymap-filter-" + projectId;
103
+ }
104
+ function readFilterFromStorage(projectId) {
105
+ if (!projectId) return null;
106
+ try {
107
+ if (typeof localStorage === "undefined") return null;
108
+ const v = localStorage.getItem(filterStorageKey(projectId));
109
+ if (!v) return null;
110
+ const parsed = JSON.parse(v);
111
+ // Empty filter ≈ null (no constraints active) — keeps app.filter falsy
112
+ // so the renderer skips the filter pipeline entirely.
113
+ if (!parsed.statuses && !parsed.types && !parsed.hideCompletedReleases) return null;
114
+ return parsed;
115
+ } catch (_) { return null; }
116
+ }
117
+ function writeFilterToStorage(projectId, filter) {
118
+ if (!projectId) return;
119
+ try {
120
+ if (typeof localStorage === "undefined") return;
121
+ const key = filterStorageKey(projectId);
122
+ if (!filter) localStorage.removeItem(key);
123
+ else localStorage.setItem(key, JSON.stringify(filter));
124
+ } catch (_) { /* ignore */ }
125
+ }
126
+
127
+ // SM-83 — Per-project release-collapse persistence. Each entry is a
128
+ // releaseId; presence means "row is collapsed (cells-row hidden)".
129
+ function releaseCollapseStorageKey(projectId) {
130
+ return "storymap-release-collapsed-" + projectId;
131
+ }
132
+ function readCollapsedReleasesFromStorage(projectId) {
133
+ const out = new Set();
134
+ if (!projectId) return out;
135
+ try {
136
+ if (typeof localStorage === "undefined") return out;
137
+ const v = localStorage.getItem(releaseCollapseStorageKey(projectId));
138
+ if (!v) return out;
139
+ const parsed = JSON.parse(v);
140
+ if (Array.isArray(parsed)) parsed.forEach(id => typeof id === "string" && out.add(id));
141
+ } catch (_) { /* ignore */ }
142
+ return out;
143
+ }
144
+ function writeCollapsedReleasesToStorage(projectId, set) {
145
+ if (!projectId) return;
146
+ try {
147
+ if (typeof localStorage === "undefined") return;
148
+ const key = releaseCollapseStorageKey(projectId);
149
+ if (!set || set.size === 0) localStorage.removeItem(key);
150
+ else localStorage.setItem(key, JSON.stringify(Array.from(set)));
151
+ } catch (_) { /* ignore */ }
152
+ }
153
+ // SM-169 — Per-project Kanban swimlane-collapse persistence. Separate from
154
+ // the Map's release-collapse (SM-83) — the Kanban groups by release into
155
+ // swimlanes and the collapse state is its own concern (it also tracks the
156
+ // synthetic "no release" row). Each entry is a swimlane key (a releaseId, or
157
+ // the renderer's NO_RELEASE_KEY sentinel); presence means "row collapsed".
158
+ function kanbanCollapseStorageKey(projectId) {
159
+ return "storymap-kanban-collapsed-" + projectId;
160
+ }
161
+ function readKanbanCollapsedFromStorage(projectId) {
162
+ if (!projectId) return null;
163
+ try {
164
+ if (typeof localStorage === "undefined") return null;
165
+ const v = localStorage.getItem(kanbanCollapseStorageKey(projectId));
166
+ if (v == null) return null; // null = no stored state yet (seed defaults)
167
+ const parsed = JSON.parse(v);
168
+ const out = new Set();
169
+ if (Array.isArray(parsed)) parsed.forEach(id => typeof id === "string" && out.add(id));
170
+ return out;
171
+ } catch (_) { return null; }
172
+ }
173
+ function writeKanbanCollapsedToStorage(projectId, set) {
174
+ if (!projectId) return;
175
+ try {
176
+ if (typeof localStorage === "undefined") return;
177
+ // Always write (even empty) so "user un-collapsed everything" is durable
178
+ // and we don't re-seed completed releases on the next load.
179
+ localStorage.setItem(kanbanCollapseStorageKey(projectId), JSON.stringify(Array.from(set || [])));
180
+ } catch (_) { /* ignore */ }
181
+ }
182
+ // First-view default: collapse every completed release's swimlane so a board
183
+ // with 100s of done tickets opens compact. The user's choice persists after.
184
+ function seedKanbanCollapsedDefault(snap) {
185
+ const out = new Set();
186
+ const releases = (snap && snap.releases) || [];
187
+ for (const r of releases) {
188
+ if (!r.isDeleted && r.status === "completed") out.add(r.id);
189
+ }
190
+ return out;
191
+ }
192
+
193
+ // SM-84 — Per-project epic-collapse persistence. Mirrors SM-83 for epics.
194
+ // Entry is an epicId; presence means "epic story-grid is hidden".
195
+ function epicCollapseStorageKey(projectId) {
196
+ return "storymap-epic-collapsed-" + projectId;
197
+ }
198
+ function readCollapsedEpicsFromStorage(projectId) {
199
+ const out = new Set();
200
+ if (!projectId) return out;
201
+ try {
202
+ if (typeof localStorage === "undefined") return out;
203
+ const v = localStorage.getItem(epicCollapseStorageKey(projectId));
204
+ if (!v) return out;
205
+ const parsed = JSON.parse(v);
206
+ if (Array.isArray(parsed)) parsed.forEach(id => typeof id === "string" && out.add(id));
207
+ } catch (_) { /* ignore */ }
208
+ return out;
209
+ }
210
+ function writeCollapsedEpicsToStorage(projectId, set) {
211
+ if (!projectId) return;
212
+ try {
213
+ if (typeof localStorage === "undefined") return;
214
+ const key = epicCollapseStorageKey(projectId);
215
+ if (!set || set.size === 0) localStorage.removeItem(key);
216
+ else localStorage.setItem(key, JSON.stringify(Array.from(set)));
217
+ } catch (_) { /* ignore */ }
218
+ }
219
+ // SM-272: per-project collapsed process-step COLUMNS.
220
+ function columnCollapseStorageKey(projectId) {
221
+ return "storymap-column-collapsed-" + projectId;
222
+ }
223
+ function readCollapsedColumnsFromStorage(projectId) {
224
+ const out = new Set();
225
+ if (!projectId) return out;
226
+ try {
227
+ if (typeof localStorage === "undefined") return out;
228
+ const v = localStorage.getItem(columnCollapseStorageKey(projectId));
229
+ if (!v) return out;
230
+ const parsed = JSON.parse(v);
231
+ if (Array.isArray(parsed)) parsed.forEach(id => typeof id === "string" && out.add(id));
232
+ } catch (_) { /* ignore */ }
233
+ return out;
234
+ }
235
+ function writeCollapsedColumnsToStorage(projectId, set) {
236
+ if (!projectId) return;
237
+ try {
238
+ if (typeof localStorage === "undefined") return;
239
+ const key = columnCollapseStorageKey(projectId);
240
+ if (!set || set.size === 0) localStorage.removeItem(key);
241
+ else localStorage.setItem(key, JSON.stringify(Array.from(set)));
242
+ } catch (_) { /* ignore */ }
243
+ }
244
+ function writeLastProjectIdToStorage(id) {
245
+ try {
246
+ if (typeof localStorage === "undefined") return;
247
+ if (id) localStorage.setItem("storymap-last-project", id);
248
+ else localStorage.removeItem("storymap-last-project");
249
+ } catch (_) { /* ignore */ }
250
+ }
251
+
252
+ // ---- HTTP request helper (delegates origin-id to adapter) ------------
253
+ // Direct REST endpoints used when adapter is HttpAdapter and we want a
254
+ // specific endpoint (e.g. POST /api/projects, POST /tickets).
255
+ async function httpReq(method, path, body) {
256
+ const res = await fetch(app.adapter.base + path, {
257
+ method,
258
+ headers: { "Content-Type": "application/json", "X-Origin-Id": app.adapter.originId },
259
+ body: body !== undefined ? JSON.stringify(body) : undefined
260
+ });
261
+ let data = null;
262
+ const text = await res.text();
263
+ if (text) { try { data = JSON.parse(text); } catch (_) { data = text; } }
264
+ if (!res.ok) {
265
+ const err = new Error((data && data.error) || ("HTTP " + res.status));
266
+ err.statusCode = res.status;
267
+ if (data && typeof data === "object") {
268
+ err.kind = data.kind; err.missing = data.missing;
269
+ }
270
+ throw err;
271
+ }
272
+ return data;
273
+ }
274
+
275
+ /**
276
+ * After a mutating HTTP call that the originating client made itself,
277
+ * pull a fresh snapshot and applyRemote() into the local store. The
278
+ * server's WS-push for this write is intentionally filtered out by the
279
+ * X-Origin-Id echo guard, so without this explicit pull the originating
280
+ * client would see no UI update for its own changes.
281
+ */
282
+ async function reloadStore() {
283
+ if (!app.currentProjectId || !app.store) return;
284
+ try {
285
+ const snap = await app.adapter.load(app.currentProjectId);
286
+ if (snap) app.store.applyRemote(snap);
287
+ } catch (e) {
288
+ console.warn("reloadStore failed:", e);
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Build the ctx object passed to the ticket-modal renderer. Wraps app
294
+ * state + adapter + helpers so the renderer doesn't need to know about
295
+ * `app`. Constructed lazily per call so it picks up the current
296
+ * projectId/adapter.
297
+ */
298
+ // SM-253: the newest release (highest sortOrder) — the Story Map's top row.
299
+ // Used as the default release for toolbar/menu-created tickets so they land
300
+ // in that release's holding strip instead of vanishing (no backlog anymore).
301
+ function newestReleaseId() {
302
+ if (!app.store) return null;
303
+ const rels = (app.store.get().releases || []).filter(r => !r.isDeleted);
304
+ if (rels.length === 0) return null;
305
+ return rels.reduce((a, b) => ((b.sortOrder || 0) > (a.sortOrder || 0) ? b : a)).id;
306
+ }
307
+
308
+ function buildTicketModalCtx() {
309
+ return {
310
+ store: app.store,
311
+ adapter: app.adapter,
312
+ projectId: app.currentProjectId,
313
+ uiShell: uiShell,
314
+ httpReq: httpReq,
315
+ reloadStore: reloadStore,
316
+ applySnapshot: (snap) => app.store.applyRemote(snap),
317
+ flashStatus: flashStatus,
318
+ // SM-185: attachments are a REST feature — only available with the HTTP
319
+ // adapter. The modal hides its dropzone when this is false.
320
+ attachmentsEnabled: !!(app.adapter && app.adapter.name === "Http"),
321
+ attachmentHref: (aid) => app.adapter.base
322
+ + "/api/projects/" + encodeURIComponent(app.currentProjectId)
323
+ + "/attachments/" + encodeURIComponent(aid),
324
+ listAttachments: async (ticketId) => {
325
+ const data = await httpReq("GET", "/api/projects/" + encodeURIComponent(app.currentProjectId)
326
+ + "/attachments?ticketId=" + encodeURIComponent(ticketId));
327
+ return (data && data.attachments) || [];
328
+ },
329
+ uploadAttachment: async (ticketId, file) => {
330
+ // SM-194: "none"/empty = project-level attachment → POST without ?ticketId
331
+ // (the "none" sentinel is a LIST filter only; on upload it must not become
332
+ // a literal ticket_id). A real ticketId scopes the upload to that ticket.
333
+ const scoped = ticketId != null && ticketId !== "" && ticketId !== "none";
334
+ const url = app.adapter.base + "/api/projects/" + encodeURIComponent(app.currentProjectId)
335
+ + "/attachments" + (scoped ? "?ticketId=" + encodeURIComponent(ticketId) : "");
336
+ // Send the raw bytes as an ArrayBuffer rather than the File object: this
337
+ // is deterministic across fetch implementations (a File body is fine in
338
+ // browsers but not every fetch — e.g. Node's undici — serializes it).
339
+ const bytes = (typeof file.arrayBuffer === "function") ? await file.arrayBuffer() : file;
340
+ const res = await fetch(url, {
341
+ method: "POST",
342
+ headers: {
343
+ "Content-Type": file.type || "application/octet-stream",
344
+ "X-Filename": encodeURIComponent(file.name),
345
+ "X-Origin-Id": app.adapter.originId
346
+ },
347
+ body: bytes
348
+ });
349
+ if (!res.ok) {
350
+ let d = null; try { d = JSON.parse(await res.text()); } catch (_) { /* non-JSON */ }
351
+ const e = new Error((d && d.error) || ("HTTP " + res.status));
352
+ e.statusCode = res.status;
353
+ throw e;
354
+ }
355
+ return (await res.json()).attachment;
356
+ },
357
+ deleteAttachment: async (aid) => httpReq("DELETE", "/api/projects/"
358
+ + encodeURIComponent(app.currentProjectId) + "/attachments/" + encodeURIComponent(aid))
359
+ };
360
+ }
361
+
362
+ // ---- Project loading -------------------------------------------------
363
+
364
+ /**
365
+ * Aktualisiert den Current-Project-Anchor im Header. Klick auf den Anchor
366
+ * öffnet den Load-Project-Dialog (cmapper-Pattern). Ohne aktives Projekt
367
+ * ist der Anchor leer + :empty in CSS versteckt.
368
+ */
369
+ function updateCurrentProjectIndicator() {
370
+ const anchor = $("current-project");
371
+ if (!anchor) return;
372
+ if (!app.currentProjectId || !app.store) {
373
+ anchor.textContent = "";
374
+ return;
375
+ }
376
+ const proj = app.store.get().project || {};
377
+ const name = proj.name || app.currentProjectId;
378
+ anchor.textContent = name;
379
+ anchor.title = "Project: " + app.currentProjectId + " — click to load another";
380
+ }
381
+
382
+ function el(tag, props) {
383
+ const e = document.createElement(tag);
384
+ for (const k of Object.keys(props || {})) {
385
+ if (k === "text") e.textContent = props[k];
386
+ else e.setAttribute(k, props[k]);
387
+ }
388
+ return e;
389
+ }
390
+
391
+ /**
392
+ * E18.C — Save-Subscriber (cmapper-Pattern). Jeder Store-Commit fließt
393
+ * (gedebounced) als kompletter Snapshot-PUT zurück an den Adapter.
394
+ * Skip-Reasons: `applyRemote` (Snapshot kam GERADE vom Server — re-saven
395
+ * würde ping-pongen) und `hydrate` (initial-load / Projektwechsel —
396
+ * gleicher Grund). Alle anderen Reasons (createTicket, updateProject,
397
+ * undo, redo, …) lösen einen Save aus.
398
+ *
399
+ * SM-214: die Mechanik (Debounce, SM-153-Race-Guard, ein Retry, pagehide-
400
+ * Flush via saveBeacon) lebt im geteilten Modul save-pipeline.js — gleiche
401
+ * Pipeline wie im Vollseiten-Editor. flushPendingSave landet auf `app`,
402
+ * damit E2E-Tests den Tab-Close-Pfad direkt treiben können.
403
+ */
404
+ function installSaveSubscriber() {
405
+ const pipeline = window.STORYMAP.savePipeline.createSavePipeline({
406
+ store: app.store,
407
+ adapter: () => app.adapter,
408
+ projectId: () => app.currentProjectId,
409
+ skipReasons: SKIP_PERSIST_REASONS,
410
+ debounceMs: BOOTSTRAP.SAVE_DEBOUNCE_MS,
411
+ onError: (err) => {
412
+ console.error("[save] failed:", err);
413
+ flashStatus("save failed: " + (err.message || err), { kind: "error" });
414
+ },
415
+ win: window
416
+ });
417
+ app.flushPendingSave = pipeline.flushPendingSave;
418
+ }
419
+
420
+ async function loadProject(projectId) {
421
+ if (app.unsubscribeWs) { app.unsubscribeWs(); app.unsubscribeWs = null; }
422
+ unmountActiveView();
423
+ const snap = await app.adapter.load(projectId);
424
+ if (!snap) { flashStatus("project not found: " + projectId, { kind: "error" }); return; }
425
+ app.currentProjectId = projectId;
426
+ writeLastProjectIdToStorage(projectId);
427
+ // SM-82: hydrate per-project filter from localStorage BEFORE mounting
428
+ // the view so the renderer starts with the correct filter applied.
429
+ app.filter = readFilterFromStorage(projectId);
430
+ refreshFilterBadge();
431
+ // SM-83: hydrate per-project release-collapse state.
432
+ app.collapsedReleases = readCollapsedReleasesFromStorage(projectId);
433
+ // SM-169: hydrate Kanban swimlane-collapse. No stored state yet → seed the
434
+ // default (completed releases collapsed) and persist it once.
435
+ {
436
+ const stored = readKanbanCollapsedFromStorage(projectId);
437
+ if (stored) {
438
+ app.kanbanCollapsedReleases = stored;
439
+ } else {
440
+ app.kanbanCollapsedReleases = seedKanbanCollapsedDefault(snap);
441
+ writeKanbanCollapsedToStorage(projectId, app.kanbanCollapsedReleases);
442
+ }
443
+ }
444
+ // SM-84: hydrate per-project epic-collapse state.
445
+ app.collapsedEpics = readCollapsedEpicsFromStorage(projectId);
446
+ app.collapsedColumns = readCollapsedColumnsFromStorage(projectId);
447
+ // SM-16 / SM-191: a new project starts with a clean smart-bar (no search,
448
+ // no active query filter).
449
+ resetSmartBar();
450
+ if (!app.store) {
451
+ app.store = new ProjectStore(snap);
452
+ installSaveSubscriber();
453
+ // SM-16: re-apply the search highlight after every commit (renderers
454
+ // re-render the host, dropping the dim/hit classes). Deferred a tick so
455
+ // it runs after the synchronous re-render.
456
+ app.store.subscribe(() => {
457
+ if (app.search) setTimeout(applySearchHighlight, 0);
458
+ });
459
+ // SM-191 (review fix): keep an active JQL query filter fresh. On every
460
+ // commit (local edit OR MCP/applyRemote) re-run the query; remount only
461
+ // when the match membership changed. Without this app.queryMatch goes
462
+ // stale and the storymap morph fast-path keeps now-non-matching cards.
463
+ app.store.subscribe((snap) => {
464
+ if (!app.queryString) return;
465
+ const engine = window.STORYMAP.queryEngine;
466
+ if (!engine) return;
467
+ const r = engine.queryTickets(snap, app.queryString);
468
+ if (r.error) return;
469
+ const next = new Set(r.tickets.map((t) => t.id));
470
+ if (setsEqual(next, app.queryMatch)) return;
471
+ app.queryMatch = next;
472
+ setSmartBarStatus(next.size + (next.size === 1 ? " match" : " matches"), "ok");
473
+ remountForQuery();
474
+ });
475
+ } else {
476
+ app.store.hydrate(snap);
477
+ }
478
+ // E22: Toolbar action buttons are gone; their disabled-state lives on
479
+ // the Edit menu items now via menu-item `disabled: () => ...` callbacks.
480
+ // The menu evaluates them lazily each time the dropdown opens, so no
481
+ // explicit refresh is needed here.
482
+
483
+ mountActiveView();
484
+ updateCurrentProjectIndicator();
485
+ // Keep the indicator in sync if the project name changes via MCP/WS.
486
+ app.store.subscribe(updateCurrentProjectIndicator);
487
+ flashStatus("loaded: " + projectId, { kind: "ok" });
488
+
489
+ // Live-sync if the adapter supports it.
490
+ if (typeof app.adapter.subscribe === "function") {
491
+ try {
492
+ app.unsubscribeWs = await app.adapter.subscribe(projectId, async () => {
493
+ const fresh = await app.adapter.load(projectId);
494
+ if (fresh) app.store.applyRemote(fresh);
495
+ });
496
+ // SM-153: close the subscribe-after-load gap — a change that landed
497
+ // between the initial load() and the subscription would otherwise be
498
+ // missed until the next event. applyRemote is a no-op on identical
499
+ // snapshots, so this is free when nothing changed.
500
+ if (app.currentProjectId === projectId) {
501
+ const gapFresh = await app.adapter.load(projectId);
502
+ if (gapFresh) app.store.applyRemote(gapFresh);
503
+ }
504
+ } catch (e) { console.warn("WS subscribe failed:", e); }
505
+ }
506
+ }
507
+ // Expose for the switch-request flow + E2E re-load (re-runs per-project
508
+ // hydration incl. the SM-169 swimlane-collapse seed).
509
+ app.loadProject = loadProject;
510
+
511
+ function unmountActiveView() {
512
+ if (app.unmountStoryMap) { app.unmountStoryMap(); app.unmountStoryMap = null; }
513
+ if (app.unmountKanban) { app.unmountKanban.unmount(); app.unmountKanban = null; }
514
+ if (app.unmountDeps) { app.unmountDeps.unmount(); app.unmountDeps = null; }
515
+ if (app.unmountRequirements) { app.unmountRequirements.unmount(); app.unmountRequirements = null; }
516
+ if (app.unmountProcessSteps) { app.unmountProcessSteps.unmount(); app.unmountProcessSteps = null; }
517
+ if (app.unmountTable) { app.unmountTable.unmount(); app.unmountTable = null; }
518
+ }
519
+
520
+ function mountActiveView() {
521
+ const mapHost = $("story-map-host");
522
+ const kanbanHost = $("kanban-host");
523
+ const depsHost = $("deps-host");
524
+ const reqHost = $("requirements-view-host");
525
+ const psHost = $("process-steps-host");
526
+ const tableHost = $("table-host");
527
+ mapHost.hidden = (app.activeView !== "storymap");
528
+ kanbanHost.hidden = (app.activeView !== "kanban");
529
+ if (depsHost) depsHost.hidden = (app.activeView !== "dependencies");
530
+ if (reqHost) reqHost.hidden = (app.activeView !== "requirements");
531
+ if (psHost) psHost.hidden = (app.activeView !== "processSteps");
532
+ if (tableHost) tableHost.hidden = (app.activeView !== "table");
533
+ // SM-293: the Table view has its OWN query line — showing the global
534
+ // smart-bar + filter row on top of it means two competing query inputs
535
+ // (user finding). Hide that toolbar row while the Table view is active.
536
+ const actionBar = document.querySelector(".toolbar-action");
537
+ if (actionBar) actionBar.hidden = (app.activeView === "table");
538
+ if (app.activeView === "kanban") {
539
+ app.unmountKanban = mountKanbanView();
540
+ } else if (app.activeView === "dependencies") {
541
+ app.unmountDeps = mountDependenciesView();
542
+ } else if (app.activeView === "requirements") {
543
+ app.unmountRequirements = mountRequirementsView();
544
+ } else if (app.activeView === "processSteps") {
545
+ app.unmountProcessSteps = mountProcessStepsView();
546
+ } else if (app.activeView === "table") {
547
+ app.unmountTable = mountTableView();
548
+ } else {
549
+ app.unmountStoryMap = mountStoryMapView();
550
+ }
551
+ applySearchHighlight();
552
+ }
553
+
554
+ // SM-16: dim non-matching cards + ring the matches across the active view.
555
+ // A DOM overlay (not a re-render) so it's instant and survives nothing —
556
+ // re-applied after every mount + (debounced) after store commits.
557
+ function applySearchHighlight() {
558
+ const q = app.search || "";
559
+ const hostId = app.activeView === "kanban" ? "kanban-host"
560
+ : app.activeView === "dependencies" ? "deps-host"
561
+ : "story-map-host";
562
+ const host = $(hostId);
563
+ if (!host) return;
564
+ // Epic cards (`.sm-epic-card`) carry data-ticket-id too — include them so a
565
+ // search for an epic's key/title rings the epic, not just its stories
566
+ // (SM-208). Their opacity cascades to the contained story cards, so an epic
567
+ // is NEVER dimmed: dimming a non-matching epic would also dim a matching
568
+ // story nested inside it. Only the ring (`sm-search-hit`) applies to epics.
569
+ const cards = host.querySelectorAll(
570
+ ".sm-story-card[data-ticket-id], .sm-epic-card[data-ticket-id]"
571
+ );
572
+ const byId = app.store ? indexTicketsById(app.store.get()) : null;
573
+ const matchesSearch = window.STORYMAP.filter.matchesSearch;
574
+ cards.forEach((card) => {
575
+ if (!q) {
576
+ card.classList.remove("sm-search-dim", "sm-search-hit");
577
+ return;
578
+ }
579
+ const ticket = byId && byId.get(card.getAttribute("data-ticket-id"));
580
+ const hit = matchesSearch(ticket, q);
581
+ const isEpic = card.classList.contains("sm-epic-card");
582
+ card.classList.toggle("sm-search-hit", !!hit);
583
+ card.classList.toggle("sm-search-dim", !isEpic && !hit);
584
+ });
585
+ }
586
+
587
+ function indexTicketsById(snap) {
588
+ const m = new Map();
589
+ for (const t of (snap && snap.tickets) || []) m.set(t.id, t);
590
+ return m;
591
+ }
592
+
593
+ // SM-191 — Unified smart-bar. ONE input: plain words behave as the SM-16
594
+ // free-text highlight; field syntax (query.looksLikeQuery) runs a structured
595
+ // JQL query (query engine) that FILTERS the active view to its matches, with
596
+ // an inline result count + parse/semantic error. The query AND-combines with
597
+ // the SM-82 chip filter (both stay usable). `app.queryMatch` is the match id
598
+ // Set; the renderers read it via opts.matchTicket = queryMatchPredicate().
599
+ function queryMatchPredicate() {
600
+ const set = app.queryMatch;
601
+ return set ? (t) => set.has(t.id) : null;
602
+ }
603
+ function setSmartBarStatus(text, kind) {
604
+ const el = $("smartbar-status");
605
+ if (!el) return;
606
+ el.textContent = text || "";
607
+ el.classList.remove("smartbar-status-error", "smartbar-status-ok");
608
+ if (text && kind) el.classList.add("smartbar-status-" + kind);
609
+ }
610
+ function remountForQuery() {
611
+ if (!app.store) return;
612
+ // SM-282: the Table view has its OWN query line and ignores the global
613
+ // smart-bar filter (matchTicket) — a remount here would only wipe the
614
+ // user's typed table query + results for zero filtering effect.
615
+ if (app.activeView === "table") return;
616
+ unmountActiveView();
617
+ mountActiveView();
618
+ }
619
+ function applySmartBar(raw) {
620
+ const trimmed = (raw == null ? "" : String(raw)).trim();
621
+ const Q = window.STORYMAP.query;
622
+ const engine = window.STORYMAP.queryEngine;
623
+ const wasFiltering = !!app.queryMatch;
624
+ // Field-aware detection: a query starts with a KNOWN field + operator, so
625
+ // plain searches ("find in files", "price < 100") stay simple search.
626
+ const fieldKeys = (engine && engine.QUERY_FIELDS) ? engine.QUERY_FIELDS.map((f) => f.key) : null;
627
+ if (trimmed && Q && engine && Q.looksLikeQuery(trimmed, fieldKeys)) {
628
+ const r = engine.queryTickets(app.store ? app.store.get() : { tickets: [] }, trimmed);
629
+ if (r.error) {
630
+ // Non-destructive: keep whatever the view currently shows, just report.
631
+ setSmartBarStatus(r.error.message, "error");
632
+ return;
633
+ }
634
+ // SM-293: skip the remount when the match set is unchanged — Enter fires
635
+ // immediately AND the pending 200ms debounce re-fires with the same
636
+ // value; without this guard the view would remount twice (flicker).
637
+ const nextMatch = new Set(r.tickets.map((t) => t.id));
638
+ const unchanged = app.queryString === trimmed && setsEqual(nextMatch, app.queryMatch);
639
+ app.search = "";
640
+ app.queryString = trimmed;
641
+ app.queryMatch = nextMatch;
642
+ setSmartBarStatus(r.tickets.length + (r.tickets.length === 1 ? " match" : " matches"), "ok");
643
+ if (!unchanged) remountForQuery();
644
+ applySearchHighlight(); // clear any stale highlight classes
645
+ return;
646
+ }
647
+ // Simple free-text mode (or empty input).
648
+ app.search = trimmed;
649
+ app.queryString = null;
650
+ setSmartBarStatus("", null);
651
+ if (wasFiltering) { app.queryMatch = null; remountForQuery(); }
652
+ applySearchHighlight();
653
+ }
654
+ function setsEqual(a, b) {
655
+ if (a === b) return true;
656
+ if (!a || !b || a.size !== b.size) return false;
657
+ for (const x of a) if (!b.has(x)) return false;
658
+ return true;
659
+ }
660
+ function resetSmartBar() {
661
+ app.search = "";
662
+ app.queryMatch = null;
663
+ app.queryString = null;
664
+ const input = $("ticket-search");
665
+ if (input) input.value = "";
666
+ setSmartBarStatus("", null);
667
+ }
668
+
669
+ /** Switch between Story-Map / Kanban / Dependencies without re-loading
670
+ * the project. Settings is an overlay (E23.B), not a view. */
671
+ function switchView(next) {
672
+ if (next !== "storymap" && next !== "kanban" && next !== "dependencies" && next !== "requirements" && next !== "processSteps" && next !== "table") return;
673
+ if (next === app.activeView) return;
674
+ unmountActiveView();
675
+ app.activeView = next;
676
+ writeActiveViewToStorage(next);
677
+ // SM-205: the active view is now indicated by the check-marked View-menu
678
+ // item (re-evaluated when the menu opens) — no toolbar buttons to update.
679
+ if (app.store) mountActiveView();
680
+ }
681
+
682
+ /**
683
+ * E23.B — Settings overlay. Sits on top of whatever view is currently
684
+ * active (Map or Kanban). Close button + Esc closes it; the underlying
685
+ * view never unmounts.
686
+ */
687
+ function openSettings() {
688
+ const host = $("settings-host");
689
+ if (!host) return;
690
+ if (!host.hidden) return; // already open
691
+ host.hidden = false;
692
+ // Reset host innerHTML so a previous close left no zombies.
693
+ host.innerHTML = "";
694
+ // Build close button + a content host for view-settings to render into.
695
+ const closeBtn = document.createElement("button");
696
+ closeBtn.className = "vs-overlay-close";
697
+ closeBtn.title = "Close settings (Esc)";
698
+ closeBtn.setAttribute("aria-label", "Close settings");
699
+ closeBtn.textContent = "×";
700
+ closeBtn.addEventListener("click", closeSettings);
701
+ host.appendChild(closeBtn);
702
+ const content = document.createElement("div");
703
+ content.className = "vs-overlay-content";
704
+ host.appendChild(content);
705
+ app.unmountSettings = viewSettings.mount(content, app.store, {
706
+ flashStatus: flashStatus
707
+ });
708
+ // Esc closes — install ONCE per open, removed on close.
709
+ app._settingsEscHandler = (ev) => {
710
+ if (ev.key === "Escape") { ev.preventDefault(); closeSettings(); }
711
+ };
712
+ document.addEventListener("keydown", app._settingsEscHandler, true);
713
+ }
714
+
715
+ function closeSettings() {
716
+ const host = $("settings-host");
717
+ if (!host || host.hidden) return;
718
+ if (app.unmountSettings) { app.unmountSettings.unmount(); app.unmountSettings = null; }
719
+ if (app._settingsEscHandler) {
720
+ document.removeEventListener("keydown", app._settingsEscHandler, true);
721
+ app._settingsEscHandler = null;
722
+ }
723
+ host.hidden = true;
724
+ host.innerHTML = "";
725
+ }
726
+
727
+ // SM-203 R-7: the Requirements reconciliation view — a FULL-PAGE view (like
728
+ // Map / Kanban / Dependencies), not an overlay. Source ingestion (attachment
729
+ // → Markdown) runs server-side, so the source pane only fills in HTTP mode.
730
+ // Requirement statements are contentEditable and mirror straight into their
731
+ // requirement ticket via store.updateTicket.
732
+ function mountRequirementsView() {
733
+ const host = $("requirements-view-host");
734
+ const fetchIngest = (app.adapter && app.adapter.name === "Http")
735
+ ? (aid) => httpReq("GET", "/api/projects/" + encodeURIComponent(app.currentProjectId)
736
+ + "/attachments/" + encodeURIComponent(aid) + "/ingest")
737
+ : null;
738
+ return viewRequirements.mount(host, app.store, {
739
+ flashStatus: flashStatus,
740
+ fetchIngest: fetchIngest,
741
+ // Click a traceability chip → open that ticket.
742
+ onOpenTicket: (id) => rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {}),
743
+ // Orphan/suspect requirement → spin up an implementing story + realises link.
744
+ // The create + link are two commits; if the link throws (validateLink),
745
+ // catch it so we don't leave an orphan ticket + an uncaught DOM-handler
746
+ // exception (review [major]).
747
+ onCreateImplementer: (req) => {
748
+ try {
749
+ const snap = app.store.createTicket({ type: "user-story", title: "Implement: " + req.title }, LOCAL_ACTOR);
750
+ const story = snap.tickets[snap.tickets.length - 1];
751
+ app.store.addLink(story.id, { linkTypeId: "realises", targetTicketId: req.id }, LOCAL_ACTOR);
752
+ flashStatus("Created " + story.ticketKey + " → realises " + req.ticketKey, { kind: "ok" });
753
+ } catch (e) { flashStatus(e.message || "create failed", { kind: "error" }); }
754
+ },
755
+ // Gap source section → create a requirement from it, contained in the module.
756
+ onCreateRequirement: (moduleId, sec) => {
757
+ try {
758
+ const snap = app.store.createTicket({
759
+ type: "requirement", title: sec.heading || sec.body || "Requirement",
760
+ sectionPath: sec.sectionPath, sourceAnchor: { sectionId: sec.sectionPath }
761
+ }, LOCAL_ACTOR);
762
+ const req = snap.tickets[snap.tickets.length - 1];
763
+ app.store.addLink(moduleId, { linkTypeId: "contains", targetTicketId: req.id }, LOCAL_ACTOR);
764
+ flashStatus("Created requirement " + req.ticketKey, { kind: "ok" });
765
+ } catch (e) { flashStatus(e.message || "create failed", { kind: "error" }); }
766
+ },
767
+ // Inline edit of the requirement statement → mirror into its ticket title.
768
+ onEditRequirement: (reqId, text) => {
769
+ try {
770
+ const t = (app.store.get().tickets || []).find(x => x.id === reqId);
771
+ if (!t || t.title === text) return;
772
+ app.store.updateTicket(reqId, { title: text }, LOCAL_ACTOR);
773
+ flashStatus("Updated " + (t.ticketKey || "requirement"), { kind: "ok" });
774
+ } catch (e) { flashStatus(e.message || "update failed", { kind: "error" }); }
775
+ }
776
+ });
777
+ }
778
+
779
+ // SM-282 — query-driven table over ALL tickets (incl. epics + unassigned).
780
+ // Soft dependency (progressive enhancement, same pattern as projectIO).
781
+ function mountTableView() {
782
+ const viewTable = SM.viewTable;
783
+ if (!viewTable) return { unmount() {} };
784
+ return viewTable.mount($("table-host"), app.store, {
785
+ onTicketClick: (id) => {
786
+ rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {});
787
+ },
788
+ onImport: () => openImportDialog() // SM-289/295: second entry point
789
+ });
790
+ }
791
+
792
+ // SM-289/295 — THE unified import dialog (project envelope OR ticket rows).
793
+ function openImportDialog() {
794
+ const dlg = SM.dialogTicketImport;
795
+ if (!dlg) return;
796
+ dlg.openImportDialog({
797
+ store: app.store, // may be null — project import works without one
798
+ showModal: showModal,
799
+ flashStatus: flashStatus,
800
+ actor: LOCAL_ACTOR,
801
+ listProjects: () => app.adapter.list(),
802
+ onImportProject: (snap, targetId, overwrite) => importSnapshot(snap, targetId, overwrite)
803
+ });
804
+ }
805
+
806
+ // SM-295 — THE unified export chooser (project JSON / tickets CSV).
807
+ function openExportChooser() {
808
+ const dlg = SM.dialogTicketImport;
809
+ if (!dlg || !app.store) return;
810
+ dlg.openExportDialog({
811
+ showModal: showModal,
812
+ onExportProject: () => exportCurrentProject(),
813
+ onExportTickets: () => {
814
+ // Current table state when mounted, else persisted config (SM-292).
815
+ if (app.unmountTable && app.unmountTable.exportCsv) { app.unmountTable.exportCsv(); return; }
816
+ if (SM.viewTable) SM.viewTable.exportTicketsCsv(app.store, app.currentProjectId);
817
+ }
818
+ });
819
+ }
820
+
821
+ function mountKanbanView() {
822
+ return rendererKanban.mount($("kanban-host"), app.store, {
823
+ filter: app.filter,
824
+ matchTicket: queryMatchPredicate(), // SM-191 smart-bar query filter
825
+ collapsedReleases: app.kanbanCollapsedReleases || new Set(),
826
+ onSwimlaneCollapse: (swimlaneKey) => {
827
+ if (!app.kanbanCollapsedReleases) app.kanbanCollapsedReleases = new Set();
828
+ if (app.kanbanCollapsedReleases.has(swimlaneKey)) app.kanbanCollapsedReleases.delete(swimlaneKey);
829
+ else app.kanbanCollapsedReleases.add(swimlaneKey);
830
+ writeKanbanCollapsedToStorage(app.currentProjectId, app.kanbanCollapsedReleases);
831
+ if (app.unmountKanban) { app.unmountKanban.unmount(); app.unmountKanban = null; }
832
+ app.unmountKanban = mountKanbanView();
833
+ },
834
+ onReleaseClick: (id) => openEditReleaseDialog(id),
835
+ onTicketClick: (id) => {
836
+ rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {});
837
+ },
838
+ onAddItem: (laneStatus) => {
839
+ // Type-picker → Create-Modal mit dem Lane-Status vor-konfiguriert.
840
+ // Position bleibt null (Orphan-Ticket im Backlog mit gewähltem Status).
841
+ rendererTicketModal.openTypePickerThenCreate(buildTicketModalCtx(), { status: laneStatus });
842
+ },
843
+ onTicketStatusChange: (ticketId, newStatus) => {
844
+ try {
845
+ app.store.changeStatusGated(ticketId, newStatus, LOCAL_ACTOR);
846
+ flashStatus("status: " + newStatus, { kind: "ok" });
847
+ } catch (err) {
848
+ if (err.kind && err.missing) {
849
+ flashStatus(err.kind + " gate blocks transition: missing " + err.missing.map(m => m.label || m.id).join(", "), { kind: "error" });
850
+ } else {
851
+ flashStatus(err.message || "status change failed", { kind: "error" });
852
+ }
853
+ }
854
+ },
855
+ onLaneReorder: (_laneStatus, orderedIds) => {
856
+ // Kanban-Reorder: NO scope — only sortOrder gets updated globally.
857
+ // Story-Map within an epic uses the SAME sortOrder field, so this
858
+ // reorder also affects the visible story order in that epic
859
+ // (intentional: Kanban is the operational priority view).
860
+ try {
861
+ app.store.reorderTickets(orderedIds, null, LOCAL_ACTOR);
862
+ } catch (err) {
863
+ flashStatus(err.message || "reorder failed", { kind: "error" });
864
+ }
865
+ },
866
+ onCardContextMenu: (ticket, ev) => openCardContextMenu(ticket, ev)
867
+ });
868
+ }
869
+
870
+ /**
871
+ * SM-50 — Dependencies view. Read-only DAG of all tickets + typed links.
872
+ * Single integration point: click-on-node opens the ticket detail modal,
873
+ * same as Story-Map and Kanban. Filters live inside the renderer.
874
+ */
875
+ function mountProcessStepsView() {
876
+ // SM-248/249: the Process-Step editor — journey maintenance as its own view.
877
+ return window.STORYMAP.viewProcessSteps.mount($("process-steps-host"), app.store, {
878
+ flashStatus: flashStatus,
879
+ // SM-248/256: double-click opens the SAME edit dialog the story-map
880
+ // backbone header uses (rename / status / delete) — DRY, no parallel UI.
881
+ onEditProcessStep: (id) => openEditProcessStepDialog(id)
882
+ });
883
+ }
884
+
885
+ function mountDependenciesView() {
886
+ return rendererDependencies.mount($("deps-host"), app.store, {
887
+ // SM-163: the board "Filter N" popover (status/type/hide-completed) now
888
+ // applies to the dependency view too. main.js remounts the active view on
889
+ // filter change, so this is fresh each time.
890
+ boardFilter: app.filter,
891
+ onTicketClick: (id) => {
892
+ rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {});
893
+ }
894
+ });
895
+ }
896
+
897
+ /**
898
+ * SM-30 — promote / demote popover. Right-click on a Kanban card opens
899
+ * a small menu with four pile-stack actions:
900
+ * • Top of Backlog / End of Backlog — force `status: backlog` AND park
901
+ * the ticket at the start/end of the backlog lane.
902
+ * • Top of Lane / End of Lane — keep current status, shuffle to
903
+ * the start/end of the current status's lane.
904
+ *
905
+ * All four actions go through `store.reorderTickets(orderedIds, null)`
906
+ * (and `store.changeStatus` for the backlog-forcing variants), so undo /
907
+ * redo + the WS-sync round-trip work identically to drag-drop.
908
+ */
909
+ function openCardContextMenu(ticket, ev) {
910
+ const items = [
911
+ { label: "Top of Backlog", onClick: () => moveToBacklog(ticket.id, "top") },
912
+ { label: "End of Backlog", onClick: () => moveToBacklog(ticket.id, "end") },
913
+ { label: "Top of Lane", onClick: () => moveWithinLane(ticket.id, "top") },
914
+ { label: "End of Lane", onClick: () => moveWithinLane(ticket.id, "end") }
915
+ ];
916
+ uiShell.showContextMenu({
917
+ clientX: ev.clientX,
918
+ clientY: ev.clientY,
919
+ items
920
+ });
921
+ }
922
+
923
+ function moveToBacklog(ticketId, where) {
924
+ try {
925
+ const snap = app.store.get();
926
+ const t = snap.tickets.find(x => x.id === ticketId);
927
+ if (!t) return;
928
+ // Bring it back to the backlog status first. Reverse transitions are
929
+ // ungated in the default workflow (E18.D); a custom workflow that
930
+ // refuses the move surfaces the gate error here.
931
+ if (t.status !== "backlog") {
932
+ try { app.store.changeStatusGated(ticketId, "backlog", LOCAL_ACTOR); }
933
+ catch (err) {
934
+ const msg = err.kind && err.missing
935
+ ? err.kind + " gate blocks → backlog: " + err.missing.map(m => m.label || m.id).join(", ")
936
+ : (err.message || "status change failed");
937
+ flashStatus(msg, { kind: "error" });
938
+ return;
939
+ }
940
+ }
941
+ // Re-read after the (possible) status change, then assemble peers.
942
+ const fresh = app.store.get();
943
+ const peers = fresh.tickets
944
+ .filter(x => !x.isDeleted && x.type !== "epic" && x.status === "backlog" && x.id !== ticketId)
945
+ .sort((a, b) => ((a.position && a.position.sortOrder) || 0)
946
+ - ((b.position && b.position.sortOrder) || 0));
947
+ const orderedIds = where === "top"
948
+ ? [ticketId, ...peers.map(p => p.id)]
949
+ : [...peers.map(p => p.id), ticketId];
950
+ app.store.reorderTickets(orderedIds, null, LOCAL_ACTOR);
951
+ flashStatus("moved to " + where + " of backlog", { kind: "ok" });
952
+ } catch (err) {
953
+ flashStatus(err.message || "move failed", { kind: "error" });
954
+ }
955
+ }
956
+
957
+ function moveWithinLane(ticketId, where) {
958
+ try {
959
+ const snap = app.store.get();
960
+ const t = snap.tickets.find(x => x.id === ticketId);
961
+ if (!t) return;
962
+ const peers = snap.tickets
963
+ .filter(x => !x.isDeleted && x.type !== "epic" && x.status === t.status && x.id !== ticketId)
964
+ .sort((a, b) => ((a.position && a.position.sortOrder) || 0)
965
+ - ((b.position && b.position.sortOrder) || 0));
966
+ const orderedIds = where === "top"
967
+ ? [ticketId, ...peers.map(p => p.id)]
968
+ : [...peers.map(p => p.id), ticketId];
969
+ app.store.reorderTickets(orderedIds, null, LOCAL_ACTOR);
970
+ flashStatus("moved to " + where + " of lane", { kind: "ok" });
971
+ } catch (err) {
972
+ flashStatus(err.message || "move failed", { kind: "error" });
973
+ }
974
+ }
975
+
976
+ /**
977
+ * SM-83 — Animate a release's cells-row collapse / expand.
978
+ *
979
+ * Canonical "animate height:auto" trick: max-height transitions only
980
+ * work between two known values, so we measure scrollHeight, fix the
981
+ * start value, force a reflow, then set the target value. On
982
+ * transitionend the inline max-height is cleared so future content
983
+ * growth (new tickets, etc.) isn't capped by the animation value.
984
+ *
985
+ * The cells-row stays in the DOM throughout (good for animation +
986
+ * keeps DnD listeners attached). The chevron icon + aria-expanded and
987
+ * the label-row's compact class are flipped synchronously so the
988
+ * label is in its target visual state from the first frame.
989
+ */
990
+ function animateReleaseCollapse(releaseId, willCollapse) {
991
+ const cells = document.querySelector('.sm-release-cells[data-release-id="' + releaseId + '"]');
992
+ const labelRow = document.querySelector('.sm-release-label-row[data-release-id="' + releaseId + '"]');
993
+ if (labelRow) labelRow.classList.toggle("sm-release-collapsed", willCollapse);
994
+ if (labelRow) {
995
+ const ch = labelRow.querySelector(".sm-release-chevron");
996
+ if (ch) {
997
+ ch.textContent = willCollapse ? "▶" : "▼";
998
+ ch.setAttribute("aria-expanded", willCollapse ? "false" : "true");
999
+ ch.setAttribute("title", willCollapse ? "Expand release" : "Collapse release");
1000
+ }
1001
+ }
1002
+ if (!cells) return;
1003
+ // Token guards against stale transitionend listeners — if the user
1004
+ // toggles again before the animation finishes, the previous
1005
+ // listener must NOT clobber the current inline max-height.
1006
+ const token = (cells._collapseToken = (cells._collapseToken || 0) + 1);
1007
+ if (willCollapse) {
1008
+ // Collapse: animate from natural height down to 0.
1009
+ const h = cells.scrollHeight;
1010
+ cells.style.maxHeight = h + "px";
1011
+ // Force reflow so the browser commits the start value before we
1012
+ // change it to 0 (otherwise the two updates batch into one paint
1013
+ // and the transition is skipped entirely — common Safari pitfall).
1014
+ void cells.offsetHeight;
1015
+ cells.classList.add("sm-release-collapsed-cells");
1016
+ // Schedule the target on the next frame so the browser definitely
1017
+ // sees a start state distinct from the end state.
1018
+ requestAnimationFrame(() => {
1019
+ if (cells._collapseToken !== token) return;
1020
+ cells.style.maxHeight = "0px";
1021
+ });
1022
+ } else {
1023
+ // Expand: animate from 0 → natural height. Remove the class FIRST
1024
+ // so opacity/padding/border transition back in alongside max-height.
1025
+ cells.classList.remove("sm-release-collapsed-cells");
1026
+ // Make sure we start at 0 (the inline value persisted from the
1027
+ // collapse animation, or the renderer's initial-collapsed mark).
1028
+ cells.style.maxHeight = "0px";
1029
+ void cells.offsetHeight;
1030
+ const target = cells.scrollHeight;
1031
+ requestAnimationFrame(() => {
1032
+ if (cells._collapseToken !== token) return;
1033
+ cells.style.maxHeight = target + "px";
1034
+ });
1035
+ const onEnd = (ev) => {
1036
+ if (ev.propertyName !== "max-height") return;
1037
+ if (cells._collapseToken !== token) return;
1038
+ // Clear inline max-height so future content growth (new cards
1039
+ // added later) isn't clipped at the post-animation value.
1040
+ cells.style.maxHeight = "";
1041
+ cells.removeEventListener("transitionend", onEnd);
1042
+ };
1043
+ cells.addEventListener("transitionend", onEnd);
1044
+ }
1045
+ }
1046
+
1047
+ /**
1048
+ * SM-84 — Animate an epic's story-grid collapse / expand.
1049
+ *
1050
+ * Mirrors animateReleaseCollapse but targets the .sm-stories-grid inside
1051
+ * the .sm-epic-card. Same scrollHeight → 0 / 0 → scrollHeight trick with
1052
+ * a token to guard against rapid re-toggles, and the same transitionend
1053
+ * cleanup so future content growth (new stories added) doesn't get
1054
+ * clipped at the post-animation value.
1055
+ *
1056
+ * Why scope to a per-card lookup rather than a single selector: an epic
1057
+ * can appear in the DOM multiple times when filters / live-drag shadows
1058
+ * are active; the data-ticket-id selector grabs the real (non-shadow)
1059
+ * card only. Shadow cards intentionally render uncollapsed and have no
1060
+ * chevron — see renderEpicCard's isShadow guard.
1061
+ */
1062
+ function animateEpicCollapse(epicId, willCollapse) {
1063
+ const card = document.querySelector('.sm-epic-card[data-ticket-id="' + epicId + '"]:not(.sm-card-shadow)');
1064
+ if (!card) return;
1065
+ card.classList.toggle("sm-epic-card-collapsed", willCollapse);
1066
+ const chevron = card.querySelector(".sm-epic-chevron");
1067
+ if (chevron) {
1068
+ chevron.textContent = willCollapse ? "▶" : "▼";
1069
+ chevron.setAttribute("aria-expanded", willCollapse ? "false" : "true");
1070
+ chevron.setAttribute("title", willCollapse ? "Expand epic" : "Collapse epic");
1071
+ }
1072
+ const grid = card.querySelector(".sm-stories-grid");
1073
+ if (!grid) return;
1074
+ const token = (grid._collapseToken = (grid._collapseToken || 0) + 1);
1075
+ if (willCollapse) {
1076
+ const h = grid.scrollHeight;
1077
+ grid.style.maxHeight = h + "px";
1078
+ void grid.offsetHeight;
1079
+ grid.classList.add("sm-stories-grid-collapsed");
1080
+ requestAnimationFrame(() => {
1081
+ if (grid._collapseToken !== token) return;
1082
+ grid.style.maxHeight = "0px";
1083
+ });
1084
+ } else {
1085
+ grid.classList.remove("sm-stories-grid-collapsed");
1086
+ grid.style.maxHeight = "0px";
1087
+ void grid.offsetHeight;
1088
+ const target = grid.scrollHeight;
1089
+ requestAnimationFrame(() => {
1090
+ if (grid._collapseToken !== token) return;
1091
+ grid.style.maxHeight = target + "px";
1092
+ });
1093
+ const onEnd = (ev) => {
1094
+ if (ev.propertyName !== "max-height") return;
1095
+ if (grid._collapseToken !== token) return;
1096
+ grid.style.maxHeight = "";
1097
+ grid.removeEventListener("transitionend", onEnd);
1098
+ };
1099
+ grid.addEventListener("transitionend", onEnd);
1100
+ }
1101
+ }
1102
+
1103
+ function mountStoryMapView() {
1104
+ return rendererStoryMap.mount($("story-map-host"), app.store, {
1105
+ filter: app.filter,
1106
+ matchTicket: queryMatchPredicate(), // SM-191 smart-bar query filter
1107
+ // SM-83: per-project release collapse state. Renderer reads the set
1108
+ // each render for the initial DOM (so a collapsed-on-page-load
1109
+ // release renders correctly). Toggling uses in-place DOM animation
1110
+ // — no remount — to slide the cells-row up/down and let adjacent
1111
+ // rows reflow naturally.
1112
+ collapsedReleases: app.collapsedReleases || new Set(),
1113
+ onToggleReleaseCollapsed: (releaseId) => {
1114
+ if (!app.collapsedReleases) app.collapsedReleases = new Set();
1115
+ const willCollapse = !app.collapsedReleases.has(releaseId);
1116
+ if (willCollapse) app.collapsedReleases.add(releaseId);
1117
+ else app.collapsedReleases.delete(releaseId);
1118
+ writeCollapsedReleasesToStorage(app.currentProjectId, app.collapsedReleases);
1119
+ animateReleaseCollapse(releaseId, willCollapse);
1120
+ },
1121
+ // SM-84: per-project epic collapse state. Same pattern as releases —
1122
+ // renderer reads the set for initial DOM (so a collapsed-on-load epic
1123
+ // renders with max-height:0), toggling owns the in-place animation.
1124
+ collapsedEpics: app.collapsedEpics || new Set(),
1125
+ onToggleEpicCollapsed: (epicId) => {
1126
+ if (!app.collapsedEpics) app.collapsedEpics = new Set();
1127
+ const willCollapse = !app.collapsedEpics.has(epicId);
1128
+ if (willCollapse) app.collapsedEpics.add(epicId);
1129
+ else app.collapsedEpics.delete(epicId);
1130
+ writeCollapsedEpicsToStorage(app.currentProjectId, app.collapsedEpics);
1131
+ animateEpicCollapse(epicId, willCollapse);
1132
+ },
1133
+ // SM-272: per-project process-step COLUMN collapse. The Set is mutated in
1134
+ // place + persisted; the renderer re-renders itself after the callback
1135
+ // (width + cell content change, so no CSS-only animation like release/epic).
1136
+ collapsedColumns: app.collapsedColumns || new Set(),
1137
+ onToggleColumnCollapsed: (psId) => {
1138
+ if (!app.collapsedColumns) app.collapsedColumns = new Set();
1139
+ if (app.collapsedColumns.has(psId)) app.collapsedColumns.delete(psId);
1140
+ else app.collapsedColumns.add(psId);
1141
+ writeCollapsedColumnsToStorage(app.currentProjectId, app.collapsedColumns);
1142
+ },
1143
+ onTicketClick: (id) => {
1144
+ // SM-73: opts.onDelete no longer required — openTicketModal
1145
+ // falls back to ctx.store.softDeleteTicket on its own.
1146
+ rendererTicketModal.openTicketModal(buildTicketModalCtx(), id, {});
1147
+ },
1148
+ onAddTicket: (cellCtx) => {
1149
+ // Per-cell "+ Add Item" button → type-picker → detail-modal (Create-Mode).
1150
+ rendererTicketModal.openTypePickerThenCreate(buildTicketModalCtx(), cellCtx);
1151
+ },
1152
+ onReleaseClick: (id) => openEditReleaseDialog(id),
1153
+ onProcessStepClick: (id) => openEditProcessStepDialog(id),
1154
+ onAddRelease: () => openNewReleaseDialog(),
1155
+ onAddProcessStep: () => openNewProcessStepDialog(),
1156
+ onAddProcessStepWithTicket: (ticketId) => openNewProcessStepDialog({ assignTicketId: ticketId }),
1157
+ /**
1158
+ * Persistenz-Pfad für alle DnD-Drop-Operationen. E18.D: lokale Mutation
1159
+ * via Store-Op; save-Subscriber persistiert debounced. Server schreibt
1160
+ * eine Revision pro flush, WS pusht Echo an andere Clients (eigener
1161
+ * Echo wird per Origin-Id gefiltert).
1162
+ */
1163
+ persistMove: (ticketId, position) => {
1164
+ try {
1165
+ app.store.moveTicket(ticketId, position, LOCAL_ACTOR);
1166
+ } catch (err) {
1167
+ flashStatus(err.message || "move failed", { kind: "error" });
1168
+ }
1169
+ },
1170
+ /**
1171
+ * Ticket-Reorder — mirrors onProcessStepReorder exactly. Takes an
1172
+ * orderedIds array and an optional scope ({releaseId, processStepId,
1173
+ * epicId}). Store renumbers atomically.
1174
+ */
1175
+ persistReorderTickets: (orderedIds, scope) => {
1176
+ if (!orderedIds || orderedIds.length === 0) return;
1177
+ try {
1178
+ app.store.reorderTickets(orderedIds, scope || null, LOCAL_ACTOR);
1179
+ } catch (err) {
1180
+ console.error("[reorder] persistReorderTickets failed:", err, "orderedIds:", orderedIds, "scope:", scope);
1181
+ flashStatus(err.message || "reorder failed", { kind: "error" });
1182
+ }
1183
+ },
1184
+ onProcessStepReorder: (orderedIds) => {
1185
+ try {
1186
+ app.store.reorderProcessSteps(orderedIds, LOCAL_ACTOR);
1187
+ } catch (err) {
1188
+ flashStatus(err.message || "reorder failed", { kind: "error" });
1189
+ }
1190
+ }
1191
+ });
1192
+ }
1193
+
1194
+ // ---- Dialog: New Project --------------------------------------------
1195
+
1196
+ function openNewProjectDialog() {
1197
+ // SM-195: allow attaching a source PRD/PLD right at project creation.
1198
+ // Attachments are a REST feature → only with the HTTP adapter. Files are
1199
+ // collected here and uploaded as project-level attachments AFTER the
1200
+ // project row exists (an upload failure never aborts the create).
1201
+ const isHttp = !!(app.adapter && app.adapter.name === "Http");
1202
+ const pendingFiles = [];
1203
+ const attachRowHTML = isHttp ? `
1204
+ <div class="modal-row np-attach-row">
1205
+ <label>Attachments</label>
1206
+ <div class="np-attach-wrap">
1207
+ <div class="np-attach-dropzone tm-attach-dropzone" id="np-dropzone">Drop a PRD/document here or click to attach (optional)</div>
1208
+ <input id="np-file" type="file" multiple class="tm-attach-input" style="display:none">
1209
+ <ul class="np-attach-list tm-attach-list" id="np-pending"></ul>
1210
+ </div>
1211
+ </div>
1212
+ ` : "";
1213
+ showModal({
1214
+ title: "New project",
1215
+ sub: "Project ID must match [a-zA-Z0-9_-] (1–100 chars). DoR/DoD-Definitionen können später in den Projekt-Einstellungen gepflegt werden.",
1216
+ bodyHTML: `
1217
+ <div class="modal-row">
1218
+ <label for="np-id">Project ID</label>
1219
+ <input id="np-id" type="text" autofocus>
1220
+ </div>
1221
+ <div class="modal-row">
1222
+ <label for="np-name">Display name</label>
1223
+ <input id="np-name" type="text">
1224
+ </div>
1225
+ <div class="modal-row">
1226
+ <label for="np-prefix">Ticket prefix</label>
1227
+ <input id="np-prefix" type="text" value="P" maxlength="10">
1228
+ </div>
1229
+ ${attachRowHTML}
1230
+ `,
1231
+ onMount: (modal) => {
1232
+ const idIn = modal.querySelector("#np-id");
1233
+ const nameIn = modal.querySelector("#np-name");
1234
+ idIn.addEventListener("input", () => {
1235
+ if (!nameIn.value) nameIn.value = idIn.value;
1236
+ });
1237
+ if (!isHttp) return;
1238
+ const drop = modal.querySelector("#np-dropzone");
1239
+ const fileIn = modal.querySelector("#np-file");
1240
+ const listEl = modal.querySelector("#np-pending");
1241
+ function renderPending() {
1242
+ listEl.innerHTML = "";
1243
+ pendingFiles.forEach((f, i) => {
1244
+ const li = document.createElement("li");
1245
+ li.className = "tm-attach-item";
1246
+ const name = document.createElement("span");
1247
+ name.className = "tm-attach-name";
1248
+ name.textContent = f.name;
1249
+ const del = document.createElement("button");
1250
+ del.type = "button"; del.className = "tm-attach-del"; del.textContent = "✕";
1251
+ del.title = "Remove";
1252
+ del.addEventListener("click", (ev) => { ev.preventDefault(); pendingFiles.splice(i, 1); renderPending(); });
1253
+ li.appendChild(name); li.appendChild(del);
1254
+ listEl.appendChild(li);
1255
+ });
1256
+ }
1257
+ function addFiles(fileList) {
1258
+ for (const f of fileList) pendingFiles.push(f);
1259
+ renderPending();
1260
+ }
1261
+ drop.addEventListener("click", () => fileIn.click());
1262
+ fileIn.addEventListener("change", () => { if (fileIn.files) addFiles(fileIn.files); fileIn.value = ""; });
1263
+ drop.addEventListener("dragover", (ev) => { ev.preventDefault(); drop.classList.add("tm-attach-dropzone-over"); });
1264
+ drop.addEventListener("dragleave", () => drop.classList.remove("tm-attach-dropzone-over"));
1265
+ drop.addEventListener("drop", (ev) => {
1266
+ ev.preventDefault(); drop.classList.remove("tm-attach-dropzone-over");
1267
+ if (ev.dataTransfer && ev.dataTransfer.files) addFiles(ev.dataTransfer.files);
1268
+ });
1269
+ },
1270
+ actions: [
1271
+ { label: "Cancel", onClick: () => {} },
1272
+ {
1273
+ label: "Create", primary: true,
1274
+ onClick: async (modal) => {
1275
+ const id = modal.querySelector("#np-id").value.trim();
1276
+ const name = modal.querySelector("#np-name").value.trim() || id;
1277
+ const prefix = (modal.querySelector("#np-prefix").value.trim() || "P").toUpperCase();
1278
+ if (!BOOTSTRAP.PROJECT_ID_PATTERN.test(id)) {
1279
+ flashStatus("invalid project id", { kind: "error" });
1280
+ return false;
1281
+ }
1282
+ try {
1283
+ if (app.adapter.name === "Http") {
1284
+ // SM-254: the server seeds the default release + process step on
1285
+ // create — the UI no longer double-seeds.
1286
+ await httpReq("POST", "/api/projects", { id, name, ticketPrefix: prefix });
1287
+ } else {
1288
+ // Local adapters have no server; seed via the shared helper (DRY).
1289
+ const seed = core.normalizeSnapshot({ project: { id, name, ticketPrefix: prefix } });
1290
+ const snap = core.seedDefaultScaffold(seed, { type: "human", id: "local", name: "Local" });
1291
+ await app.adapter.save(id, snap);
1292
+ }
1293
+ await loadProject(id);
1294
+ flashStatus("created project: " + id + " (with " + BOOTSTRAP.DEFAULT_RELEASE_NAME + " + " + BOOTSTRAP.DEFAULT_PROCESS_STEP_NAME + ")", { kind: "ok" });
1295
+ // SM-195: upload any dropped PRDs as project-level attachments now
1296
+ // that the project exists. A failure here does NOT abort the create
1297
+ // (the project is already there) — just report it.
1298
+ if (isHttp && pendingFiles.length) {
1299
+ const ctx = buildTicketModalCtx();
1300
+ let okCount = 0;
1301
+ for (const f of pendingFiles) {
1302
+ try { await ctx.uploadAttachment("none", f); okCount++; }
1303
+ catch (e) { flashStatus("attachment '" + f.name + "' failed: " + (e.message || "error"), { kind: "error" }); }
1304
+ }
1305
+ if (okCount) flashStatus("attached " + okCount + " document" + (okCount > 1 ? "s" : "") + " to " + id, { kind: "ok" });
1306
+ }
1307
+ } catch (err) {
1308
+ flashStatus(err.message || "create failed", { kind: "error" });
1309
+ return false;
1310
+ }
1311
+ }
1312
+ }
1313
+ ]
1314
+ });
1315
+ }
1316
+
1317
+ // ---- Dialog: New Release (E12) --------------------------------------
1318
+
1319
+ function openNewReleaseDialog() {
1320
+ const statusOpts = core.DEFAULT_RELEASE_STATUSES
1321
+ .map(s => `<option value="${escapeAttr(s)}"${s === "planning" ? " selected" : ""}>${escapeHtml(s)}</option>`).join("");
1322
+ showModal({
1323
+ title: "New release",
1324
+ sub: "Releases are the vertical slices of the Story Map (e.g. v1.0, MVP, Q3 milestone).",
1325
+ bodyHTML: `
1326
+ <div class="modal-row">
1327
+ <label for="nr-name">Name</label>
1328
+ <input id="nr-name" type="text" autofocus>
1329
+ </div>
1330
+ <div class="modal-row">
1331
+ <label for="nr-status">Status</label>
1332
+ <select id="nr-status">${statusOpts}</select>
1333
+ </div>
1334
+ <div class="modal-row">
1335
+ <label for="nr-desc">Description</label>
1336
+ <textarea id="nr-desc" rows="2" placeholder="Optional — what this release covers."></textarea>
1337
+ </div>
1338
+ `,
1339
+ actions: [
1340
+ { label: "Cancel", onClick: () => {} },
1341
+ {
1342
+ label: "Create", primary: true,
1343
+ onClick: async (modal) => {
1344
+ const name = modal.querySelector("#nr-name").value.trim();
1345
+ const status = modal.querySelector("#nr-status").value;
1346
+ const description = modal.querySelector("#nr-desc").value;
1347
+ if (!name) { flashStatus("name is required", { kind: "error" }); return false; }
1348
+ try {
1349
+ app.store.createRelease({ name, status, description }, LOCAL_ACTOR);
1350
+ flashStatus("release created", { kind: "ok" });
1351
+ } catch (err) {
1352
+ flashStatus(err.message || "create failed", { kind: "error" });
1353
+ return false;
1354
+ }
1355
+ }
1356
+ }
1357
+ ]
1358
+ });
1359
+ }
1360
+
1361
+ // SM-167: tickets positioned in a release that aren't done. Counted by
1362
+ // position.releaseId (NOT by what the board filter shows) — this is what the
1363
+ // release-completion warning reports. Sorted by sortOrder for a stable list.
1364
+ function openTicketsInRelease(snap, releaseId) {
1365
+ // SM-244: "open" = NOT terminal (terminal = done ∪ cancelled). A cancelled
1366
+ // ticket is closed work, so it no longer counts against completing a release.
1367
+ return (snap.tickets || [])
1368
+ .filter(t => !t.isDeleted && t.position && t.position.releaseId === releaseId
1369
+ && !core.isTerminalStatus(snap.project, t.status))
1370
+ .sort((a, b) => ((a.position && a.position.sortOrder) || 0) - ((b.position && b.position.sortOrder) || 0));
1371
+ }
1372
+
1373
+ // ---- Dialog: Edit Release (rename, status, dates, delete) -----------
1374
+
1375
+ function openEditReleaseDialog(releaseId) {
1376
+ const snap = app.store.get();
1377
+ const release = (snap.releases || []).find(r => r.id === releaseId);
1378
+ if (!release) { flashStatus("release not found", { kind: "error" }); return; }
1379
+ // SM-255: a project keeps ≥1 release — the last one can't be deleted.
1380
+ const isLastRelease = (snap.releases || []).filter(r => !r.isDeleted).length <= 1;
1381
+ const statusOpts = core.DEFAULT_RELEASE_STATUSES
1382
+ .map(s => `<option value="${escapeAttr(s)}"${s === release.status ? " selected" : ""}>${escapeHtml(s)}</option>`).join("");
1383
+ // SM-239: show the same X/Y progress (non-epic work items) as the Map/Kanban.
1384
+ const prog = core.releaseProgress(snap, releaseId);
1385
+ const progText = prog.total > 0
1386
+ ? ` · ${prog.done}/${prog.total} done${prog.complete ? " ✓" : ""}`
1387
+ : "";
1388
+ showModal({
1389
+ title: "Edit release",
1390
+ sub: `Release "${escapeHtml(release.name)}" — ${escapeHtml(release.id)}${progText}`,
1391
+ bodyHTML: `
1392
+ <div class="modal-row">
1393
+ <label for="er-name">Name</label>
1394
+ <input id="er-name" type="text" value="${escapeAttr(release.name)}">
1395
+ </div>
1396
+ <div class="modal-row">
1397
+ <label for="er-status">Status</label>
1398
+ <select id="er-status">${statusOpts}</select>
1399
+ </div>
1400
+ <div class="modal-row">
1401
+ <label for="er-desc">Description</label>
1402
+ <textarea id="er-desc" rows="2">${escapeHtml(release.description || "")}</textarea>
1403
+ </div>
1404
+ <div id="er-open-warning" class="er-open-warning" hidden></div>
1405
+ `,
1406
+ // SM-167: when "completed" is selected, reactively list the tickets that
1407
+ // are POSITIONED in this release and not done — incl. backlog-status ones
1408
+ // (the release-completion count is by position.releaseId, independent of
1409
+ // any board filter). Replaces the old window.confirm.
1410
+ onMount: (modal) => {
1411
+ const statusSel = modal.querySelector("#er-status");
1412
+ const warn = modal.querySelector("#er-open-warning");
1413
+ const refresh = () => {
1414
+ const completing = statusSel.value === "completed" && release.status !== "completed";
1415
+ const open = completing ? openTicketsInRelease(app.store.get(), releaseId) : [];
1416
+ if (open.length === 0) { warn.hidden = true; warn.innerHTML = ""; return; }
1417
+ const rows = open.map(t =>
1418
+ `<li><span class="er-open-key">${escapeHtml(t.ticketKey || t.id)}</span>` +
1419
+ `<span class="er-open-title">${escapeHtml(t.title || "")}</span>` +
1420
+ `<span class="er-open-status">${escapeHtml(t.status)}</span></li>`).join("");
1421
+ warn.innerHTML =
1422
+ `<div class="er-open-head">${open.length} ticket(s) are positioned in this release and still open ` +
1423
+ `(not done or cancelled; counted by release, independent of any filter). Completing the release leaves them in it:</div>` +
1424
+ `<ul class="er-open-list">${rows}</ul>`;
1425
+ warn.hidden = false;
1426
+ };
1427
+ statusSel.addEventListener("change", refresh);
1428
+ refresh();
1429
+ // SM-255: disable Delete when this is the last release (≥1 required).
1430
+ if (isLastRelease) {
1431
+ const delBtn = modal.querySelector(".btn.destructive");
1432
+ if (delBtn) {
1433
+ delBtn.setAttribute("disabled", "disabled");
1434
+ delBtn.title = "At least one release is required";
1435
+ }
1436
+ }
1437
+ },
1438
+ actions: [
1439
+ { label: "Cancel", onClick: () => {} },
1440
+ {
1441
+ label: "Delete", destructive: true,
1442
+ onClick: () => {
1443
+ if (isLastRelease) { // SM-255: guard even if the disabled button is bypassed
1444
+ flashStatus("at least one release is required", { kind: "error" });
1445
+ return false;
1446
+ }
1447
+ try {
1448
+ app.store.softDeleteRelease(releaseId, LOCAL_ACTOR);
1449
+ flashStatus("release deleted", { kind: "ok" });
1450
+ } catch (err) {
1451
+ flashStatus(err.message || "delete failed", { kind: "error" });
1452
+ return false;
1453
+ }
1454
+ }
1455
+ },
1456
+ {
1457
+ label: "Save", primary: true,
1458
+ onClick: (modal) => {
1459
+ const patch = {
1460
+ name: modal.querySelector("#er-name").value.trim(),
1461
+ status: modal.querySelector("#er-status").value,
1462
+ description: modal.querySelector("#er-desc").value
1463
+ };
1464
+ if (!patch.name) { flashStatus("name is required", { kind: "error" }); return false; }
1465
+ // SM-167: the open-ticket warning is now a reactive inline list in
1466
+ // the dialog (see onMount). It's informational, not blocking — the
1467
+ // user already saw which tickets are positioned in this release
1468
+ // before clicking Save, so completing proceeds without a second
1469
+ // browser-native confirm. Surface the count once more for the log.
1470
+ if (patch.status === "completed" && release.status !== "completed") {
1471
+ const stillOpen = openTicketsInRelease(app.store.get(), releaseId);
1472
+ if (stillOpen.length > 0) {
1473
+ flashStatus("release completed — " + stillOpen.length + " ticket(s) remain positioned in it", { kind: "ok" });
1474
+ }
1475
+ }
1476
+ try {
1477
+ app.store.updateRelease(releaseId, patch, LOCAL_ACTOR);
1478
+ if (!(patch.status === "completed" && release.status !== "completed")) {
1479
+ flashStatus("release updated", { kind: "ok" });
1480
+ }
1481
+ } catch (err) {
1482
+ flashStatus(err.message || "update failed", { kind: "error" });
1483
+ return false;
1484
+ }
1485
+ }
1486
+ }
1487
+ ]
1488
+ });
1489
+ }
1490
+
1491
+ // ---- Dialog: New Process Step (E12) --------------------------------
1492
+
1493
+ function openNewProcessStepDialog(opts) {
1494
+ opts = opts || {};
1495
+ // assignTicketId: works for both epic and non-epic tickets. For a
1496
+ // non-epic ticket the move clears epicId so it lands as a loose
1497
+ // ticket in the new column.
1498
+ const assignTicketId = opts.assignTicketId || opts.assignEpicId || null;
1499
+ showModal({
1500
+ title: assignTicketId ? "New process step + place ticket" : "New process step",
1501
+ sub: assignTicketId
1502
+ ? "Create a new process step. The dragged ticket will be moved into the new column on submit."
1503
+ : "Process steps form the horizontal Backbone — user activities or workflow stages (e.g. Onboarding, Daily Use, Reporting).",
1504
+ bodyHTML: `
1505
+ <div class="modal-row">
1506
+ <label for="nps-name">Name</label>
1507
+ <input id="nps-name" type="text" autofocus>
1508
+ </div>
1509
+ <div class="modal-row">
1510
+ <label for="nps-desc">Description</label>
1511
+ <textarea id="nps-desc" rows="2" placeholder="Optional."></textarea>
1512
+ </div>
1513
+ `,
1514
+ actions: [
1515
+ { label: "Cancel", onClick: () => {} },
1516
+ {
1517
+ label: "Create", primary: true,
1518
+ onClick: (modal) => {
1519
+ const name = modal.querySelector("#nps-name").value.trim();
1520
+ const description = modal.querySelector("#nps-desc").value;
1521
+ if (!name) { flashStatus("name is required", { kind: "error" }); return false; }
1522
+ try {
1523
+ app.store.createProcessStep({ name, description }, LOCAL_ACTOR);
1524
+ if (assignTicketId) {
1525
+ const snap = app.store.get();
1526
+ const newStepId = snap.processSteps[snap.processSteps.length - 1].id;
1527
+ const ticket = snap.tickets.find(t => t.id === assignTicketId);
1528
+ if (ticket) {
1529
+ app.store.moveTicket(assignTicketId, {
1530
+ releaseId: ticket.position && ticket.position.releaseId,
1531
+ processStepId: newStepId,
1532
+ epicId: null,
1533
+ sortOrder: 0
1534
+ }, LOCAL_ACTOR);
1535
+ }
1536
+ }
1537
+ flashStatus(assignTicketId
1538
+ ? "process step created — ticket placed"
1539
+ : "process step created", { kind: "ok" });
1540
+ } catch (err) {
1541
+ flashStatus(err.message || "create failed", { kind: "error" });
1542
+ return false;
1543
+ }
1544
+ }
1545
+ }
1546
+ ]
1547
+ });
1548
+ }
1549
+
1550
+ // ---- Dialog: Edit Process Step --------------------------------------
1551
+
1552
+ function openEditProcessStepDialog(stepId) {
1553
+ const snap = app.store.get();
1554
+ const step = (snap.processSteps || []).find(p => p.id === stepId);
1555
+ if (!step) { flashStatus("process step not found", { kind: "error" }); return; }
1556
+ showModal({
1557
+ title: "Edit process step",
1558
+ sub: `Step "${escapeHtml(step.name)}" — ${escapeHtml(step.id)}`,
1559
+ bodyHTML: `
1560
+ <div class="modal-row">
1561
+ <label for="eps-name">Name</label>
1562
+ <input id="eps-name" type="text" value="${escapeAttr(step.name)}">
1563
+ </div>
1564
+ <div class="modal-row">
1565
+ <label for="eps-desc">Description</label>
1566
+ <textarea id="eps-desc" rows="2">${escapeHtml(step.description || "")}</textarea>
1567
+ </div>
1568
+ `,
1569
+ actions: [
1570
+ { label: "Cancel", onClick: () => {} },
1571
+ {
1572
+ label: "Delete", destructive: true,
1573
+ onClick: () => {
1574
+ try {
1575
+ app.store.softDeleteProcessStep(stepId, LOCAL_ACTOR);
1576
+ flashStatus("process step deleted", { kind: "ok" });
1577
+ } catch (err) {
1578
+ flashStatus(err.message || "delete failed", { kind: "error" });
1579
+ return false;
1580
+ }
1581
+ }
1582
+ },
1583
+ {
1584
+ label: "Save", primary: true,
1585
+ onClick: (modal) => {
1586
+ const patch = {
1587
+ name: modal.querySelector("#eps-name").value.trim(),
1588
+ description: modal.querySelector("#eps-desc").value
1589
+ };
1590
+ if (!patch.name) { flashStatus("name is required", { kind: "error" }); return false; }
1591
+ try {
1592
+ app.store.updateProcessStep(stepId, patch, LOCAL_ACTOR);
1593
+ flashStatus("process step updated", { kind: "ok" });
1594
+ } catch (err) {
1595
+ flashStatus(err.message || "update failed", { kind: "error" });
1596
+ return false;
1597
+ }
1598
+ }
1599
+ }
1600
+ ]
1601
+ });
1602
+ }
1603
+
1604
+ // ---- Dialog: Delete Project (confirm) -------------------------------
1605
+
1606
+ /**
1607
+ * Load-Project-Dialog (cmapper-Pattern). Listet alle nicht-gelöschten
1608
+ * Projekte als Cards mit Counts. Klick auf Card → loadProject. Aktuelles
1609
+ * Projekt bekommt ein "current"-Badge und ist nicht klickbar. Pro Card
1610
+ * gibt's einen Delete-Button (außer für das aktuelle).
1611
+ */
1612
+ async function openLoadProjectDialog() {
1613
+ let ids;
1614
+ try { ids = await app.adapter.list(); }
1615
+ catch (err) { flashStatus(err.message || "list failed", { kind: "error" }); return; }
1616
+ if (!Array.isArray(ids)) ids = [];
1617
+
1618
+ // Fetch counts per project in parallel so the modal opens fast.
1619
+ const summaries = await Promise.all(ids.map(async (id) => {
1620
+ if (id === app.currentProjectId && app.store) {
1621
+ return { id, snap: app.store.get() };
1622
+ }
1623
+ try { return { id, snap: await app.adapter.load(id) }; }
1624
+ catch { return { id, snap: null }; }
1625
+ }));
1626
+ function countsText(s) {
1627
+ if (!s) return "—";
1628
+ const t = (s.tickets || []).filter(x => !x.isDeleted).length;
1629
+ const r = (s.releases || []).filter(x => !x.isDeleted).length;
1630
+ const p = (s.processSteps || []).filter(x => !x.isDeleted).length;
1631
+ return t + " tickets · " + r + " releases · " + p + " process steps";
1632
+ }
1633
+ function nameOf(s) {
1634
+ return (s && s.project && s.project.name) || "(unnamed)";
1635
+ }
1636
+ function descOf(s) {
1637
+ return (s && s.project && typeof s.project.description === "string") ? s.project.description : "";
1638
+ }
1639
+ // SM-209: recency without a server endpoint — the dialog already loads each
1640
+ // snapshot, so derive "last touched" as the max updatedAt/createdAt across the
1641
+ // project header and all of its entities. Works for Http/Local/Memory alike.
1642
+ function recencyOf(s) {
1643
+ if (!s) return 0;
1644
+ let max = 0;
1645
+ const bump = (e) => {
1646
+ if (!e) return;
1647
+ const v = Number(e.updatedAt || e.createdAt || 0);
1648
+ if (v > max) max = v;
1649
+ };
1650
+ bump(s.project);
1651
+ (s.tickets || []).forEach(bump);
1652
+ (s.releases || []).forEach(bump);
1653
+ (s.processSteps || []).forEach(bump);
1654
+ return max;
1655
+ }
1656
+ function fmtRecency(ms) {
1657
+ if (!ms) return "";
1658
+ const diff = Date.now() - ms;
1659
+ const day = 86400000;
1660
+ if (diff < 0) return "just now";
1661
+ if (diff < day) {
1662
+ try { return "today, " + new Date(ms).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); }
1663
+ catch (_e) { return "today"; }
1664
+ }
1665
+ if (diff < 2 * day) return "yesterday";
1666
+ if (diff < 7 * day) return Math.floor(diff / day) + " days ago";
1667
+ try { return new Date(ms).toLocaleDateString(); } catch (_e) { return ""; }
1668
+ }
1669
+
1670
+ // Enrich each summary once with the derived fields used for filtering + sort.
1671
+ summaries.forEach(it => {
1672
+ it.name = nameOf(it.snap);
1673
+ it.desc = descOf(it.snap);
1674
+ it.mtimeMs = recencyOf(it.snap);
1675
+ });
1676
+
1677
+ const SORT_KEY = "storymap-project-sort";
1678
+ function readSort() {
1679
+ try {
1680
+ const v = localStorage.getItem(SORT_KEY);
1681
+ if (v === "name-asc" || v === "name-desc" || v === "recent" || v === "oldest") return v;
1682
+ } catch (_e) {}
1683
+ return "recent";
1684
+ }
1685
+ function writeSort(v) { try { localStorage.setItem(SORT_KEY, v); } catch (_e) {} }
1686
+
1687
+ function cardHtml(it) {
1688
+ const isCurrent = it.id === app.currentProjectId;
1689
+ const safeId = escapeHtml(it.id);
1690
+ const safeName = escapeHtml(it.name);
1691
+ const counts = escapeHtml(countsText(it.snap));
1692
+ const recency = it.mtimeMs ? escapeHtml(fmtRecency(it.mtimeMs)) : "";
1693
+ return ''
1694
+ + '<div class="project-card' + (isCurrent ? ' current' : '') + '" data-id="' + safeId + '">'
1695
+ + '<div class="project-card-header">'
1696
+ + '<span class="project-card-name">' + safeName + '</span>'
1697
+ + '<span class="project-card-id">' + safeId + '</span>'
1698
+ + (isCurrent ? '<span class="project-card-current-badge">current</span>' : '')
1699
+ + '</div>'
1700
+ + '<div class="project-card-counts">' + counts + '</div>'
1701
+ + (it.desc ? '<div class="project-card-desc">' + escapeHtml(it.desc) + '</div>' : '')
1702
+ + (recency ? '<div class="project-card-mtime">' + recency + '</div>' : '')
1703
+ + (!isCurrent ? '<button class="btn btn-delete project-card-delete" data-delete="' + safeId + '" title="Delete project">Delete</button>' : '')
1704
+ + '</div>';
1705
+ }
1706
+
1707
+ const initialSort = readSort();
1708
+ const toolbarHTML = ''
1709
+ + '<div class="project-picker-toolbar">'
1710
+ + '<input type="text" class="project-search" id="proj-search" placeholder="Search name, id or description…" autocomplete="off" spellcheck="false">'
1711
+ + '<select class="project-sort" id="proj-sort">'
1712
+ + '<option value="name-asc"' + (initialSort === "name-asc" ? " selected" : "") + '>Name A→Z</option>'
1713
+ + '<option value="name-desc"' + (initialSort === "name-desc" ? " selected" : "") + '>Name Z→A</option>'
1714
+ + '<option value="recent"' + (initialSort === "recent" ? " selected" : "") + '>Newest first</option>'
1715
+ + '<option value="oldest"' + (initialSort === "oldest" ? " selected" : "") + '>Oldest first</option>'
1716
+ + '</select>'
1717
+ + '</div>';
1718
+
1719
+ showModal({
1720
+ title: "Projects",
1721
+ sub: "Click a card to switch. Each project is its own story map.",
1722
+ bodyHTML: ids.length
1723
+ ? toolbarHTML + '<div class="project-list-cards" id="proj-cards-host"></div>'
1724
+ : '<div class="project-list-empty">No projects yet — use <em>Project ▸ New project…</em>.</div>',
1725
+ actions: [{ label: "Close", onClick: () => {} }],
1726
+ onMount: (modal, close) => {
1727
+ const host = modal.querySelector("#proj-cards-host");
1728
+ const searchEl = modal.querySelector("#proj-search");
1729
+ const sortEl = modal.querySelector("#proj-sort");
1730
+ if (!host) return; // empty-state: nothing to wire.
1731
+
1732
+ const wireCard = (card) => {
1733
+ card.addEventListener("click", async (e) => {
1734
+ if (e.target.closest("[data-delete]")) return;
1735
+ const id = card.getAttribute("data-id");
1736
+ if (id === app.currentProjectId) { close(); return; }
1737
+ close();
1738
+ await loadProject(id);
1739
+ });
1740
+ const delBtn = card.querySelector("[data-delete]");
1741
+ if (delBtn) {
1742
+ delBtn.addEventListener("click", async (e) => {
1743
+ e.stopPropagation();
1744
+ const id = delBtn.getAttribute("data-delete");
1745
+ if (!confirm("Delete project \"" + id + "\"? (soft delete — restorable via revision history)")) return;
1746
+ try {
1747
+ if (app.adapter.name === "Http") await httpReq("DELETE", "/api/projects/" + encodeURIComponent(id));
1748
+ else await app.adapter.delete(id);
1749
+ flashStatus("deleted: " + id, { kind: "ok" });
1750
+ close();
1751
+ openLoadProjectDialog(); // re-open with refreshed list
1752
+ } catch (err) {
1753
+ flashStatus(err.message || "delete failed", { kind: "error" });
1754
+ }
1755
+ });
1756
+ }
1757
+ };
1758
+
1759
+ const render = () => {
1760
+ const q = (searchEl.value || "").trim().toLowerCase();
1761
+ const sort = sortEl.value;
1762
+ let items = summaries.slice();
1763
+ if (q) {
1764
+ items = items.filter(it =>
1765
+ it.name.toLowerCase().includes(q) ||
1766
+ it.id.toLowerCase().includes(q) ||
1767
+ (it.desc || "").toLowerCase().includes(q)
1768
+ );
1769
+ }
1770
+ items.sort((a, b) => {
1771
+ if (sort === "name-asc") return a.name.localeCompare(b.name);
1772
+ if (sort === "name-desc") return b.name.localeCompare(a.name);
1773
+ if (sort === "recent") return ((b.mtimeMs || 0) - (a.mtimeMs || 0)) || a.name.localeCompare(b.name);
1774
+ if (sort === "oldest") return ((a.mtimeMs || 0) - (b.mtimeMs || 0)) || a.name.localeCompare(b.name);
1775
+ return 0;
1776
+ });
1777
+ if (items.length === 0) {
1778
+ host.innerHTML = '<div class="project-list-empty-filtered">No projects match "' + escapeHtml(q) + '"</div>';
1779
+ return;
1780
+ }
1781
+ host.innerHTML = items.map(cardHtml).join("");
1782
+ host.querySelectorAll(".project-card").forEach(wireCard);
1783
+ };
1784
+
1785
+ searchEl.addEventListener("input", render);
1786
+ sortEl.addEventListener("change", () => { writeSort(sortEl.value); render(); });
1787
+ render(); // initial paint applies persisted sort + wires cards.
1788
+ setTimeout(() => { try { searchEl.focus(); } catch (_e) {} }, 0);
1789
+ }
1790
+ });
1791
+ }
1792
+
1793
+ /**
1794
+ * History-Dialog (E13, cmapper-Pattern). Listet die Revisions des aktiven
1795
+ * Projekts (reverse-chrono via storage.listRevisions). Pro Row:
1796
+ * Timestamp (parsed from YYYYMMDD-HHmmss-mmm), op-Pille, Actor, Restore-
1797
+ * Button. Restore feuert POST /revisions/:rev/restore und appliziert das
1798
+ * zurückgegebene Snapshot direkt.
1799
+ */
1800
+ // SM-194: project-level attachments (e.g. a source PRD that precedes the
1801
+ // tickets it will be decomposed into). Reuses the shared dropzone/list
1802
+ // component with the "none" sentinel = project-scope (ticket_id IS NULL).
1803
+ function openProjectAttachmentsDialog() {
1804
+ if (!app.currentProjectId) return;
1805
+ const ctx = buildTicketModalCtx();
1806
+ if (!ctx.attachmentsEnabled) {
1807
+ flashStatus("attachments need the HTTP server (not available in local mode)", { kind: "error" });
1808
+ return;
1809
+ }
1810
+ showModal({
1811
+ title: "Project attachments",
1812
+ sub: "Project-level reference documents (e.g. a source PRD). Not tied to any ticket.",
1813
+ bodyHTML: '<div class="tm-project-attach-host"></div>',
1814
+ actions: [{ label: "Close", onClick: () => {} }],
1815
+ onMount: (modal) => {
1816
+ const host = modal.querySelector(".tm-project-attach-host");
1817
+ if (host && SM.ticketForm && SM.ticketForm.buildAttachmentsSection) {
1818
+ host.appendChild(SM.ticketForm.buildAttachmentsSection("none", ctx));
1819
+ }
1820
+ }
1821
+ });
1822
+ }
1823
+
1824
+ async function openHistoryDialog() {
1825
+ if (!app.currentProjectId) return;
1826
+ let revisions;
1827
+ try {
1828
+ revisions = await httpReq("GET",
1829
+ "/api/projects/" + encodeURIComponent(app.currentProjectId) + "/revisions");
1830
+ } catch (err) {
1831
+ flashStatus(err.message || "could not load history", { kind: "error" });
1832
+ return;
1833
+ }
1834
+ if (!Array.isArray(revisions)) revisions = [];
1835
+
1836
+ function fmtRev(rev) {
1837
+ // YYYYMMDD-HHmmss-mmm[-NNNN] → "YYYY-MM-DD HH:mm:ss.mmm UTC"
1838
+ const m = String(rev).match(/^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-(\d{3})/);
1839
+ if (!m) return rev;
1840
+ return m[1] + "-" + m[2] + "-" + m[3] + " " + m[4] + ":" + m[5] + ":" + m[6] + "." + m[7] + " UTC";
1841
+ }
1842
+ function actorLabel(a) {
1843
+ if (!a) return "—";
1844
+ const kind = a.type || "?";
1845
+ const name = a.name || a.id || "";
1846
+ return name ? (kind + " · " + name) : kind;
1847
+ }
1848
+
1849
+ const rowsHtml = revisions.length
1850
+ ? revisions.map(r => ''
1851
+ + '<div class="hist-row" data-rev="' + escapeAttr(r.revision) + '">'
1852
+ + '<div class="hist-row-main">'
1853
+ + '<span class="hist-timestamp">' + escapeHtml(fmtRev(r.revision)) + '</span>'
1854
+ + '<span class="hist-op-pill">' + escapeHtml(r.op || "save") + '</span>'
1855
+ + '<span class="hist-actor">' + escapeHtml(actorLabel(r.actor)) + '</span>'
1856
+ + '</div>'
1857
+ + '<button class="btn hist-restore" data-restore="' + escapeAttr(r.revision) + '" title="Restore this revision">Restore</button>'
1858
+ + '</div>').join("")
1859
+ : '<div class="hist-empty">No revisions yet — make changes and they will be recorded here.</div>';
1860
+
1861
+ showModal({
1862
+ title: "History",
1863
+ sub: "Each save creates a revision. Restoring an older one writes a NEW revision (the restore itself is undoable).",
1864
+ bodyHTML: '<div class="hist-list">' + rowsHtml + '</div>',
1865
+ actions: [{ label: "Close", onClick: () => {} }],
1866
+ onMount: (modal, close) => {
1867
+ modal.querySelectorAll("[data-restore]").forEach(btn => {
1868
+ btn.addEventListener("click", async (e) => {
1869
+ e.stopPropagation();
1870
+ const rev = btn.getAttribute("data-restore");
1871
+ if (!confirm("Restore revision\n" + fmtRev(rev) + "?\n\n(creates a new revision, so this is undoable.)")) return;
1872
+ try {
1873
+ const result = await httpReq("POST",
1874
+ "/api/projects/" + encodeURIComponent(app.currentProjectId)
1875
+ + "/revisions/" + encodeURIComponent(rev) + "/restore");
1876
+ if (result && result.snapshot) app.store.applyRemote(result.snapshot);
1877
+ else await reloadStore();
1878
+ flashStatus("restored " + fmtRev(rev), { kind: "ok" });
1879
+ close();
1880
+ } catch (err) {
1881
+ flashStatus(err.message || "restore failed", { kind: "error" });
1882
+ }
1883
+ });
1884
+ });
1885
+ }
1886
+ });
1887
+ }
1888
+
1889
+ function openDeleteProjectDialog() {
1890
+ if (!app.currentProjectId) return;
1891
+ const pid = app.currentProjectId;
1892
+ showModal({
1893
+ title: "Delete project?",
1894
+ sub: `Project "${pid}" wird in den Soft-Delete-Status versetzt. Diese Aktion ist über die Revision-History rückgängig zu machen.`,
1895
+ actions: [
1896
+ { label: "Cancel", onClick: () => {} },
1897
+ {
1898
+ label: "Delete", destructive: true,
1899
+ onClick: async () => {
1900
+ try {
1901
+ if (app.adapter.name === "Http") {
1902
+ await httpReq("DELETE", "/api/projects/" + encodeURIComponent(pid));
1903
+ } else {
1904
+ await app.adapter.delete(pid);
1905
+ }
1906
+ app.currentProjectId = null;
1907
+ if (app.unmountStoryMap) { app.unmountStoryMap(); app.unmountStoryMap = null; }
1908
+ if (app.unmountKanban) { app.unmountKanban.unmount(); app.unmountKanban = null; }
1909
+ closeSettings(); // if the settings overlay was open, hide it too
1910
+ writeLastProjectIdToStorage(null);
1911
+ updateCurrentProjectIndicator();
1912
+ flashStatus("deleted: " + pid, { kind: "ok" });
1913
+ } catch (err) {
1914
+ flashStatus(err.message || "delete failed", { kind: "error" });
1915
+ return false;
1916
+ }
1917
+ }
1918
+ }
1919
+ ]
1920
+ });
1921
+ }
1922
+
1923
+ // ---- Menu Bar wiring -------------------------------------------------
1924
+
1925
+ // ---- SM-15: project Export / Import (JSON) --------------------------
1926
+
1927
+ function exportCurrentProject() {
1928
+ if (!app.currentProjectId || !app.store) {
1929
+ flashStatus("no project to export", { kind: "error" });
1930
+ return;
1931
+ }
1932
+ const snap = app.store.get();
1933
+ const text = projectIO.serializeProject(snap, { exportedAt: new Date().toISOString() });
1934
+ triggerDownload(projectIO.exportFilename(snap), text);
1935
+ flashStatus("exported " + (snap.project && snap.project.id), { kind: "ok" });
1936
+ }
1937
+
1938
+ // Thin browser glue: stream `text` to the user as a file download. Guarded
1939
+ // so a JSDOM smoke (no Blob/URL.createObjectURL) doesn't throw.
1940
+ function triggerDownload(filename, text) {
1941
+ try {
1942
+ const blob = new Blob([text], { type: "application/json" });
1943
+ const url = URL.createObjectURL(blob);
1944
+ const a = document.createElement("a");
1945
+ a.href = url;
1946
+ a.download = filename;
1947
+ document.body.appendChild(a);
1948
+ a.click();
1949
+ document.body.removeChild(a);
1950
+ setTimeout(() => { try { URL.revokeObjectURL(url); } catch (_) {} }, 1000);
1951
+ } catch (err) {
1952
+ flashStatus("download failed: " + (err.message || "error"), { kind: "error" });
1953
+ }
1954
+ }
1955
+
1956
+ // SM-295: the old file-picker-only project-import UI (no paste, no preview)
1957
+ // is gone — the unified Import… dialog hosts the project branch now.
1958
+
1959
+ // SM-295 (review): THROWS on failure — the import dialog owns the error
1960
+ // path (flash + keep the modal open so the pasted text is not lost).
1961
+ async function importSnapshot(snap, targetId, isOverwrite) {
1962
+ const pinned = projectIO.withProjectId(snap, targetId);
1963
+ if (app.adapter.name === "Http") {
1964
+ if (!isOverwrite) {
1965
+ await httpReq("POST", "/api/projects", {
1966
+ id: targetId,
1967
+ name: (pinned.project && pinned.project.name) || targetId,
1968
+ ticketPrefix: (pinned.project && pinned.project.ticketPrefix) || "P"
1969
+ });
1970
+ }
1971
+ await httpReq("PUT", "/api/projects/" + encodeURIComponent(targetId), pinned);
1972
+ } else {
1973
+ await app.adapter.save(targetId, pinned);
1974
+ }
1975
+ await loadProject(targetId);
1976
+ flashStatus("imported project: " + targetId, { kind: "ok" });
1977
+ }
1978
+
1979
+ function mountMenu() {
1980
+ uiShell.mountMenuBar($("menu-bar"), [
1981
+ {
1982
+ id: "project",
1983
+ label: "Project",
1984
+ items: [
1985
+ { label: "New project…", shortcut: "Mod+N", action: () => openNewProjectDialog() },
1986
+ { label: "Load project…", shortcut: "Mod+O", action: () => openLoadProjectDialog() },
1987
+ { type: "separator" },
1988
+ // SM-295: ONE import + ONE export entry (previously four). The
1989
+ // import dialog auto-detects project envelope vs. ticket rows;
1990
+ // the export chooser offers project JSON vs. tickets CSV.
1991
+ { label: "Import…", action: () => openImportDialog() },
1992
+ { label: "Export…",
1993
+ disabled: () => !app.currentProjectId || !app.store,
1994
+ action: () => openExportChooser() },
1995
+ { type: "separator" },
1996
+ { label: "Settings…",
1997
+ disabled: () => !app.currentProjectId,
1998
+ action: () => openSettings() },
1999
+ { label: "Attachments…",
2000
+ disabled: () => !app.currentProjectId,
2001
+ action: () => openProjectAttachmentsDialog() },
2002
+ { type: "separator" },
2003
+ {
2004
+ label: "Delete current project…",
2005
+ disabled: () => !app.currentProjectId,
2006
+ action: () => openDeleteProjectDialog()
2007
+ }
2008
+ ]
2009
+ },
2010
+ {
2011
+ // E22 — Edit menu. Soaks up the actions that used to live in the
2012
+ // toolbar (Undo/Redo, Add Ticket/Release/Process Step) plus History
2013
+ // (moved out of Project menu). Cut/Copy/Paste are placeholders for
2014
+ // now — storymap has no multi-select / clipboard concept yet, so
2015
+ // they're disabled. They occupy the slot so the menu reads like
2016
+ // the user's mental model from cmapper.
2017
+ id: "edit",
2018
+ label: "Edit",
2019
+ items: [
2020
+ { label: "Undo", shortcut: "Mod+Z",
2021
+ disabled: () => !(app.store && app.store.canUndo()),
2022
+ action: () => app.store && app.store.undo() },
2023
+ { label: "Redo", shortcut: "Mod+Shift+Z",
2024
+ disabled: () => !(app.store && app.store.canRedo()),
2025
+ action: () => app.store && app.store.redo() },
2026
+ { type: "separator" },
2027
+ { label: "Cut", shortcut: "Mod+X",
2028
+ disabled: () => true, // E22: placeholder; no selection model yet
2029
+ action: () => {} },
2030
+ { label: "Copy", shortcut: "Mod+C",
2031
+ disabled: () => true,
2032
+ action: () => {} },
2033
+ { label: "Paste", shortcut: "Mod+V",
2034
+ disabled: () => true,
2035
+ action: () => {} },
2036
+ { type: "separator" },
2037
+ { label: "Add Ticket…", shortcut: "Mod+T",
2038
+ disabled: () => !app.currentProjectId,
2039
+ // SM-253: default to the newest release (unplaced) so the ticket is
2040
+ // visible in the Story Map's holding strip — there is no backlog to
2041
+ // hold release-less tickets anymore.
2042
+ action: () => rendererTicketModal.openTypePickerThenCreate(
2043
+ buildTicketModalCtx(), { releaseId: newestReleaseId() }) },
2044
+ { label: "Add Release…",
2045
+ disabled: () => !app.currentProjectId,
2046
+ action: () => openNewReleaseDialog() },
2047
+ { label: "Add Process Step…",
2048
+ disabled: () => !app.currentProjectId,
2049
+ action: () => openNewProcessStepDialog() },
2050
+ { type: "separator" },
2051
+ { label: "History…",
2052
+ disabled: () => !app.currentProjectId,
2053
+ action: () => openHistoryDialog() }
2054
+ ]
2055
+ },
2056
+ {
2057
+ // SM-205 — View menu. View switching used to be toolbar toggle buttons;
2058
+ // now it's a top-level menu alongside Project + Edit. The active view
2059
+ // shows a ✓ in the check gutter — `checked` is a predicate re-evaluated
2060
+ // each time the dropdown is rebuilt on open.
2061
+ id: "view",
2062
+ label: "View",
2063
+ items: [
2064
+ viewMenuItem("storymap", "Map"),
2065
+ viewMenuItem("kanban", "Kanban"),
2066
+ viewMenuItem("table", "Table"),
2067
+ viewMenuItem("dependencies", "Dependencies"),
2068
+ viewMenuItem("requirements", "Requirements"),
2069
+ viewMenuItem("processSteps", "Process Steps"),
2070
+ { type: "separator" },
2071
+ // SM-271: surface the existing hideCompletedReleases board filter as a
2072
+ // discoverable View toggle (it was buried in the Filter popover). The
2073
+ // ✓ re-evaluates each time the dropdown opens; setFilter persists +
2074
+ // remounts so Map + Kanban hide completed releases immediately.
2075
+ {
2076
+ label: "Hide completed releases",
2077
+ checked: () => !!(app.filter && app.filter.hideCompletedReleases),
2078
+ action: () => {
2079
+ const cur = Object.assign({ statuses: null, types: null, hideCompletedReleases: false }, app.filter || {});
2080
+ cur.hideCompletedReleases = !cur.hideCompletedReleases;
2081
+ setFilter(cur);
2082
+ }
2083
+ }
2084
+ ]
2085
+ }
2086
+ ]);
2087
+ }
2088
+
2089
+ // SM-205 — one View-menu entry. `checked` drives the gutter ✓ (re-evaluated
2090
+ // each time the dropdown opens), so the label text stays in one column.
2091
+ function viewMenuItem(view, name) {
2092
+ return {
2093
+ label: name,
2094
+ checked: () => app.activeView === view,
2095
+ action: () => switchView(view)
2096
+ };
2097
+ }
2098
+
2099
+ function wireToolbar() {
2100
+ // E22 + SM-205: the toolbar no longer hosts action buttons OR view toggles
2101
+ // — those moved to the Edit and View menus. Only the current-project
2102
+ // indicator and the SM-82 filter button remain wired here.
2103
+ const cpAnchor = $("current-project");
2104
+ if (cpAnchor) cpAnchor.addEventListener("click", () => openLoadProjectDialog());
2105
+ // SM-82: filter button + popover.
2106
+ const filterBtn = $("btn-filter");
2107
+ if (filterBtn) {
2108
+ filterBtn.addEventListener("click", (ev) => {
2109
+ ev.stopPropagation();
2110
+ toggleFilterPopover(filterBtn);
2111
+ });
2112
+ }
2113
+ refreshFilterBadge();
2114
+ // SM-16: live ticket search (highlight matches, fade the rest).
2115
+ const searchInput = $("ticket-search");
2116
+ if (searchInput) {
2117
+ // SM-191: the search box is now the unified smart-bar (simple OR JQL).
2118
+ uiShell.wireDebounced(searchInput, () => {
2119
+ applySmartBar(searchInput.value || "");
2120
+ }, 200);
2121
+ searchInput.addEventListener("keydown", (ev) => {
2122
+ if (ev.key === "Escape") {
2123
+ searchInput.value = "";
2124
+ applySmartBar("");
2125
+ searchInput.blur();
2126
+ } else if (ev.key === "Enter") {
2127
+ // SM-293: Enter applies immediately (the autocomplete only consumes
2128
+ // Enter for an explicitly arrow-chosen suggestion).
2129
+ applySmartBar(searchInput.value || "");
2130
+ }
2131
+ });
2132
+ // SM-216: Jira-style autocomplete dropdown on the smart-bar. The keydown
2133
+ // listener is capture-phase (added inside attachAutocomplete), so when the
2134
+ // dropdown is open it intercepts Enter/Tab/Esc before the handlers above.
2135
+ const ac = window.STORYMAP.smartbarAutocomplete;
2136
+ if (ac && ac.attachAutocomplete) {
2137
+ // The returned handle (incl. detach()) is intentionally discarded:
2138
+ // wireToolbar() runs once at boot and the input lives for the page
2139
+ // lifetime, so there is nothing to tear down.
2140
+ ac.attachAutocomplete(searchInput, {
2141
+ getSnapshot: () => (app.store ? app.store.get() : { tickets: [] }),
2142
+ onAccept: () => applySmartBar(searchInput.value || "")
2143
+ });
2144
+ }
2145
+ }
2146
+ }
2147
+
2148
+ // ---- SM-82 Filter UI -------------------------------------------------
2149
+ //
2150
+ // The filter button toggles a fixed-positioned popover anchored under it.
2151
+ // The popover hosts checkboxes for statuses + types + a hide-completed
2152
+ // toggle. State lives in app.filter; localStorage is `storymap-filter-
2153
+ // <projectId>`. Filter changes trigger a remount of the active view so
2154
+ // the renderer picks up the new opts.filter.
2155
+
2156
+ function refreshFilterBadge() {
2157
+ const btn = $("btn-filter");
2158
+ const badge = $("filter-badge");
2159
+ if (!btn || !badge || !window.STORYMAP || !window.STORYMAP.filter) return;
2160
+ const n = window.STORYMAP.filter.countActiveRules(app.filter || {});
2161
+ btn.classList.toggle("active", n > 0);
2162
+ if (n > 0) { badge.hidden = false; badge.textContent = String(n); }
2163
+ else { badge.hidden = true; badge.textContent = "0"; }
2164
+ }
2165
+
2166
+ function setFilter(next) {
2167
+ // Empty/no-op filter → null (so renderer skips filter pipeline).
2168
+ const fmod = window.STORYMAP && window.STORYMAP.filter;
2169
+ const normalised = fmod ? fmod.normalizeFilter(next || {}) : (next || null);
2170
+ const active = normalised && (normalised.statuses || normalised.types || normalised.hideCompletedReleases);
2171
+ app.filter = active ? normalised : null;
2172
+ writeFilterToStorage(app.currentProjectId, app.filter);
2173
+ refreshFilterBadge();
2174
+ // Remount the active view so it picks up app.filter via opts.
2175
+ // SM-282: not the Table view — it ignores the chip filter (own query
2176
+ // line); a remount would only wipe the typed query for no effect. The
2177
+ // filter still applies on the next switch to Map/Kanban (fresh mount).
2178
+ if (app.store && app.activeView !== "table") {
2179
+ unmountActiveView();
2180
+ mountActiveView();
2181
+ }
2182
+ }
2183
+
2184
+ let _filterPopoverOpen = false;
2185
+ function toggleFilterPopover(anchorBtn) {
2186
+ if (_filterPopoverOpen) { closeFilterPopover(); return; }
2187
+ openFilterPopover(anchorBtn);
2188
+ }
2189
+ function closeFilterPopover() {
2190
+ const pop = $("filter-popover");
2191
+ if (pop && pop.parentNode) pop.parentNode.removeChild(pop);
2192
+ document.removeEventListener("click", _filterPopoverOutsideClick, true);
2193
+ document.removeEventListener("keydown", _filterPopoverEsc, true);
2194
+ _filterPopoverOpen = false;
2195
+ const btn = $("btn-filter");
2196
+ if (btn) btn.setAttribute("aria-expanded", "false");
2197
+ }
2198
+ function _filterPopoverOutsideClick(ev) {
2199
+ const pop = document.getElementById("filter-popover");
2200
+ const btn = document.getElementById("btn-filter");
2201
+ if (!pop) return;
2202
+ if (pop.contains(ev.target)) return;
2203
+ if (btn && btn.contains(ev.target)) return;
2204
+ closeFilterPopover();
2205
+ }
2206
+ function _filterPopoverEsc(ev) {
2207
+ if (ev.key === "Escape") { ev.stopPropagation(); closeFilterPopover(); }
2208
+ }
2209
+
2210
+ function openFilterPopover(anchorBtn) {
2211
+ const snap = app.store ? app.store.get() : null;
2212
+ if (!snap) return;
2213
+ const project = snap.project || {};
2214
+ const workflowStatuses = (project.workflow && project.workflow.statuses) || [];
2215
+ const ticketTypes = (project.ticketTypes && project.ticketTypes.length)
2216
+ ? project.ticketTypes : ["epic", "user-story", "bug"];
2217
+ const cur = app.filter || { statuses: null, types: null, hideCompletedReleases: false };
2218
+
2219
+ // Bulk-toggle helpers re-open the popover so checkbox visual state
2220
+ // reflects the new filter immediately.
2221
+ function reopen() {
2222
+ closeFilterPopover();
2223
+ setTimeout(() => openFilterPopover(anchorBtn), 0);
2224
+ }
2225
+
2226
+ const pop = document.createElement("div");
2227
+ pop.id = "filter-popover";
2228
+ pop.setAttribute("role", "dialog");
2229
+ pop.setAttribute("aria-label", "Filter tickets");
2230
+
2231
+ function section(title, body, bulkActions) {
2232
+ const sec = document.createElement("section");
2233
+ sec.className = "filter-popover-section";
2234
+ const headerRow = document.createElement("div");
2235
+ headerRow.className = "filter-popover-section-header";
2236
+ const h = document.createElement("div");
2237
+ h.className = "filter-popover-section-title";
2238
+ h.textContent = title;
2239
+ headerRow.appendChild(h);
2240
+ if (bulkActions) {
2241
+ const actions = document.createElement("div");
2242
+ actions.className = "filter-popover-section-actions";
2243
+ // Bulk-toggles: All / None for multi-select sections.
2244
+ const allBtn = document.createElement("button");
2245
+ allBtn.type = "button";
2246
+ allBtn.className = "filter-popover-bulk";
2247
+ allBtn.textContent = "All";
2248
+ allBtn.addEventListener("click", () => bulkActions.selectAll());
2249
+ const noneBtn = document.createElement("button");
2250
+ noneBtn.type = "button";
2251
+ noneBtn.className = "filter-popover-bulk";
2252
+ noneBtn.textContent = "None";
2253
+ noneBtn.addEventListener("click", () => bulkActions.selectNone());
2254
+ actions.appendChild(allBtn);
2255
+ actions.appendChild(noneBtn);
2256
+ headerRow.appendChild(actions);
2257
+ }
2258
+ sec.appendChild(headerRow);
2259
+ if (body) sec.appendChild(body);
2260
+ return sec;
2261
+ }
2262
+ function checkrow(labelText, checked, onChange) {
2263
+ const row = document.createElement("label");
2264
+ row.className = "filter-popover-checkrow";
2265
+ const cb = document.createElement("input");
2266
+ cb.type = "checkbox";
2267
+ cb.checked = !!checked;
2268
+ cb.addEventListener("change", () => onChange(cb.checked));
2269
+ const span = document.createElement("span");
2270
+ span.textContent = labelText;
2271
+ row.appendChild(cb);
2272
+ row.appendChild(span);
2273
+ return row;
2274
+ }
2275
+
2276
+ // Status section.
2277
+ const statusIds = workflowStatuses.map(s => typeof s === "string" ? s : s.id);
2278
+ const statusBody = document.createElement("div");
2279
+ for (const s of workflowStatuses) {
2280
+ const sid = typeof s === "string" ? s : s.id;
2281
+ const sName = typeof s === "string" ? s : (s.name || s.id);
2282
+ const checked = !!(cur.statuses && cur.statuses.indexOf(sid) >= 0);
2283
+ statusBody.appendChild(checkrow(sName, checked, (on) => {
2284
+ const list = (cur.statuses ? cur.statuses.slice() : []);
2285
+ const idx = list.indexOf(sid);
2286
+ if (on && idx < 0) list.push(sid);
2287
+ if (!on && idx >= 0) list.splice(idx, 1);
2288
+ cur.statuses = list;
2289
+ setFilter(cur);
2290
+ }));
2291
+ }
2292
+ pop.appendChild(section("Status", statusBody, {
2293
+ selectAll: () => { cur.statuses = statusIds.slice(); setFilter(cur); reopen(); },
2294
+ selectNone: () => { cur.statuses = null; setFilter(cur); reopen(); }
2295
+ }));
2296
+
2297
+ // Type section.
2298
+ const typeBody = document.createElement("div");
2299
+ for (const t of ticketTypes) {
2300
+ const checked = !!(cur.types && cur.types.indexOf(t) >= 0);
2301
+ typeBody.appendChild(checkrow(t, checked, (on) => {
2302
+ const list = (cur.types ? cur.types.slice() : []);
2303
+ const idx = list.indexOf(t);
2304
+ if (on && idx < 0) list.push(t);
2305
+ if (!on && idx >= 0) list.splice(idx, 1);
2306
+ cur.types = list;
2307
+ setFilter(cur);
2308
+ }));
2309
+ }
2310
+ pop.appendChild(section("Type", typeBody, {
2311
+ selectAll: () => { cur.types = ticketTypes.slice(); setFilter(cur); reopen(); },
2312
+ selectNone: () => { cur.types = null; setFilter(cur); reopen(); }
2313
+ }));
2314
+
2315
+ // Release-Status section: just one toggle.
2316
+ const relBody = document.createElement("div");
2317
+ relBody.appendChild(checkrow("Hide completed releases", cur.hideCompletedReleases, (on) => {
2318
+ cur.hideCompletedReleases = on;
2319
+ setFilter(cur);
2320
+ }));
2321
+ pop.appendChild(section("Releases", relBody));
2322
+
2323
+ // Footer: reset link.
2324
+ const footer = document.createElement("div");
2325
+ footer.className = "filter-popover-footer";
2326
+ const reset = document.createElement("button");
2327
+ reset.type = "button";
2328
+ reset.className = "filter-popover-reset";
2329
+ reset.textContent = "Reset filter";
2330
+ reset.addEventListener("click", () => {
2331
+ setFilter(null);
2332
+ closeFilterPopover();
2333
+ });
2334
+ footer.appendChild(reset);
2335
+ pop.appendChild(footer);
2336
+
2337
+ document.body.appendChild(pop);
2338
+
2339
+ // Anchor below the button, right-aligned to the button's right edge.
2340
+ const rect = anchorBtn.getBoundingClientRect();
2341
+ const popW = pop.offsetWidth || 280;
2342
+ let left = rect.right - popW;
2343
+ if (left < 8) left = 8;
2344
+ pop.style.left = left + "px";
2345
+ pop.style.top = (rect.bottom + 6) + "px";
2346
+
2347
+ _filterPopoverOpen = true;
2348
+ anchorBtn.setAttribute("aria-expanded", "true");
2349
+ // Outside-click + Esc handlers (capture phase so we win against bubbling).
2350
+ setTimeout(() => {
2351
+ document.addEventListener("click", _filterPopoverOutsideClick, true);
2352
+ document.addEventListener("keydown", _filterPopoverEsc, true);
2353
+ }, 0);
2354
+ }
2355
+
2356
+ /**
2357
+ * Global keyboard shortcuts. Skip when focus is in an editable element
2358
+ * so we don't intercept the user's actual text-input typing/undo.
2359
+ */
2360
+ function wireKeyboardShortcuts() {
2361
+ function isEditable(el) {
2362
+ if (!el) return false;
2363
+ const tag = (el.tagName || "").toLowerCase();
2364
+ if (tag === "input" || tag === "textarea" || tag === "select") return true;
2365
+ if (el.isContentEditable) return true;
2366
+ return false;
2367
+ }
2368
+ document.addEventListener("keydown", (ev) => {
2369
+ if (!app.store) return;
2370
+ if (isEditable(ev.target)) return;
2371
+ const mod = ev.metaKey || ev.ctrlKey;
2372
+ if (!mod) return;
2373
+ const key = (ev.key || "").toLowerCase();
2374
+ if (key === "z" && !ev.shiftKey) {
2375
+ ev.preventDefault();
2376
+ app.store.undo();
2377
+ } else if ((key === "z" && ev.shiftKey) || key === "y") {
2378
+ ev.preventDefault();
2379
+ app.store.redo();
2380
+ }
2381
+ }, true);
2382
+ }
2383
+
2384
+ /**
2385
+ * E20.C — MCP-driven project switch. When the server pushes a
2386
+ * `switch_request` frame (because a standalone MCP tool called
2387
+ * `request_switch_project`), show a confirm modal and route the verdict
2388
+ * back. Accept also triggers `loadProject(workspace)` so the user lands
2389
+ * on the prepared project immediately.
2390
+ */
2391
+ function wireSwitchRequestHandler() {
2392
+ if (!app.adapter || typeof app.adapter.onSwitchRequest !== "function") return;
2393
+ app.adapter.onSwitchRequest(({ workspace, reason, requestId }) => {
2394
+ // SM-29: if the requested project is already the one we're viewing,
2395
+ // there's nothing to switch to. Silently accept and short-circuit —
2396
+ // no modal interrupts the user for a no-op. The MCP caller still gets
2397
+ // its `{accepted:true}` resolution so callers that wait_seconds>0
2398
+ // don't time out.
2399
+ if (workspace && app.currentProjectId === workspace) {
2400
+ app.adapter.sendSwitchResponse(requestId, true);
2401
+ return;
2402
+ }
2403
+ const safeWorkspace = escapeHtml(workspace || "");
2404
+ const safeReason = reason ? escapeHtml(reason) : null;
2405
+ const bodyHTML = safeReason
2406
+ ? `<p>An MCP client wants to switch you to project <code>${safeWorkspace}</code>.</p><p class="modal-sub">${safeReason}</p>`
2407
+ : `<p>An MCP client wants to switch you to project <code>${safeWorkspace}</code>.</p>`;
2408
+ // If the user closes the modal via Esc / outside-click without
2409
+ // clicking either action, the server-side long-poll will time out
2410
+ // (default 30 s) — the MCP tool resolves with timedOut:true. We
2411
+ // accept that fallback rather than trying to monkey-patch the
2412
+ // modal's close handler.
2413
+ showModal({
2414
+ title: "Switch to another project?",
2415
+ sub: "Initiated by Claude / MCP",
2416
+ bodyHTML: bodyHTML,
2417
+ actions: [
2418
+ { label: "Cancel", onClick: () => {
2419
+ app.adapter.sendSwitchResponse(requestId, false);
2420
+ } },
2421
+ { label: "Switch", primary: true, onClick: async () => {
2422
+ app.adapter.sendSwitchResponse(requestId, true);
2423
+ try { await loadProject(workspace); }
2424
+ catch (err) { flashStatus("could not switch: " + err.message, { kind: "error" }); }
2425
+ } }
2426
+ ]
2427
+ });
2428
+ });
2429
+ }
2430
+
2431
+ // ---- Bootstrap -------------------------------------------------------
2432
+
2433
+ async function fetchBuildInfo() {
2434
+ // Only meaningful in Http-Mode — local modes have no server to ask.
2435
+ if (!app.adapter || app.adapter.name !== "Http") return;
2436
+ try {
2437
+ const res = await fetch(app.adapter.base + "/api/build-info");
2438
+ if (!res.ok) return;
2439
+ const info = await res.json();
2440
+ const el = $("build-tag");
2441
+ if (el && info.commit) {
2442
+ el.textContent = "build " + info.commit;
2443
+ el.title = (info.subject || info.commit) + (info.committedAt ? " · " + info.committedAt : "");
2444
+ }
2445
+ } catch (_) { /* ignore */ }
2446
+ }
2447
+
2448
+ async function init() {
2449
+ // SM-99: pickAdapter is async — probes /api/health on the current
2450
+ // origin before falling back to localStorage. The `?api=<url>`
2451
+ // override is still honoured first.
2452
+ app.adapter = await pickAdapter({ url: window.location.href });
2453
+ $("adapter-badge").textContent = app.adapter.name;
2454
+ fetchBuildInfo();
2455
+ mountMenu();
2456
+ wireToolbar();
2457
+ wireKeyboardShortcuts();
2458
+ wireSwitchRequestHandler();
2459
+ // SM-281: Shift+Scrollrad → horizontal. Safari übersetzt shift+wheel
2460
+ // nicht selbst; ohne das kommt eine Maus mit rein vertikalem Rad nie
2461
+ // an rechts außerhalb liegende Process-Steps.
2462
+ if (SM.wheelPan) SM.wheelPan.installShiftWheelPan($("view-host"));
2463
+ // SM-244: when the board page is restored from the browser bfcache (e.g.
2464
+ // navigating BACK from the full-page editor after cancelling/deleting a
2465
+ // ticket there), the WebSocket was frozen and the store is stale — reload
2466
+ // the current project from the server so the change is reflected.
2467
+ window.addEventListener("pageshow", (ev) => {
2468
+ if (ev && ev.persisted && app.currentProjectId) reloadStore();
2469
+ });
2470
+ try {
2471
+ const ids = await app.adapter.list();
2472
+ if (ids.length === 0) {
2473
+ flashStatus("no projects yet — Project ▸ New project…");
2474
+ } else {
2475
+ // Prefer the last-opened project (localStorage). If it's gone (deleted
2476
+ // or just doesn't exist on this server), fall back to the first one.
2477
+ const lastId = readLastProjectIdFromStorage();
2478
+ const target = (lastId && ids.indexOf(lastId) >= 0) ? lastId : ids[0];
2479
+ await loadProject(target);
2480
+ }
2481
+ } catch (err) {
2482
+ console.error(err);
2483
+ flashStatus("init failed: " + err.message, { kind: "error" });
2484
+ }
2485
+ }
2486
+
2487
+ // ---- Small helpers ---------------------------------------------------
2488
+
2489
+ function escapeHtml(s) {
2490
+ return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
2491
+ }
2492
+ function escapeAttr(s) { return escapeHtml(s); }
2493
+
2494
+ if (document.readyState === "loading") {
2495
+ document.addEventListener("DOMContentLoaded", init);
2496
+ } else {
2497
+ init();
2498
+ }
2499
+ }());