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,170 @@
1
+ /**
2
+ * save-pipeline.js — shared debounced whole-snapshot persistence (SM-214).
3
+ *
4
+ * Extracted from main.js#installSaveSubscriber and the SM-157 duplicate in
5
+ * renderer-ticket-editor.js so BOTH surfaces share one tested behaviour:
6
+ *
7
+ * - every store commit (except skipReasons: applyRemote/hydrate) schedules
8
+ * a debounced `adapter.save(projectId, snapshot)`;
9
+ * - SM-153 race guard: the project id is captured at SCHEDULE time, so a
10
+ * project switch inside the debounce window can never PUT snapshot A
11
+ * under project B;
12
+ * - a failed save is retried once after RETRY_MS; only the terminal
13
+ * failure surfaces via onError. A newer commit supersedes a pending
14
+ * RETRY (generation counter) — a stale retry never fires after a newer
15
+ * flush. (Two in-flight first-attempt PUTs can still land out of order;
16
+ * that is the accepted E18 last-write-wins envelope, unchanged here.);
17
+ * - flushPendingSave(): synchronous flush for pagehide/beforeunload.
18
+ * Prefers `adapter.saveBeacon` (fetch keepalive — survives the page
19
+ * teardown) and falls back to fire-and-forget `adapter.save`. Without
20
+ * it, closing the tab inside the debounce window silently loses the
21
+ * last commit.
22
+ *
23
+ * UMD-wrapped, dependency-free; pure timer logic — fully unit-testable.
24
+ */
25
+ (function (root, factory) {
26
+ if (typeof module === "object" && module.exports) module.exports = factory();
27
+ else (root.STORYMAP = root.STORYMAP || {}).savePipeline = factory();
28
+ }(typeof self !== "undefined" ? self : this, function () {
29
+ "use strict";
30
+
31
+ const SAVE_PIPELINE = {
32
+ DEBOUNCE_MS_DEFAULT: 300, // matches BOOTSTRAP.SAVE_DEBOUNCE_MS history
33
+ RETRY_MS_DEFAULT: 1200, // delay before retrying a failed PUT
34
+ MAX_RETRIES_DEFAULT: 3 // retry transient (network/5xx) failures a few times
35
+ };
36
+
37
+ /**
38
+ * createSavePipeline(opts) → { flushPendingSave, dispose }
39
+ *
40
+ * opts:
41
+ * store ProjectStore-like: subscribe((snap, reason) => …)
42
+ * adapter adapter object OR () => adapter (resolved per save)
43
+ * projectId fixed string OR () => current id (resolved at SCHEDULE time)
44
+ * skipReasons Set<string> of commit reasons that must not persist
45
+ * debounceMs / retryMs / maxRetries overrides for SAVE_PIPELINE values
46
+ * onError(err) terminal-failure callback (after the retry)
47
+ * win window-like; when it has addEventListener, pagehide +
48
+ * beforeunload are wired to flushPendingSave
49
+ */
50
+ function createSavePipeline(opts) {
51
+ opts = opts || {};
52
+ if (!opts.store || typeof opts.store.subscribe !== "function") {
53
+ throw new Error("createSavePipeline: opts.store with subscribe() is required");
54
+ }
55
+ const getAdapter = (typeof opts.adapter === "function") ? opts.adapter : function () { return opts.adapter; };
56
+ const getProjectId = (typeof opts.projectId === "function") ? opts.projectId : function () { return opts.projectId; };
57
+ const skip = (opts.skipReasons && typeof opts.skipReasons.has === "function") ? opts.skipReasons : null;
58
+ const debounceMs = Number.isFinite(opts.debounceMs) ? opts.debounceMs : SAVE_PIPELINE.DEBOUNCE_MS_DEFAULT;
59
+ const retryMs = Number.isFinite(opts.retryMs) ? opts.retryMs : SAVE_PIPELINE.RETRY_MS_DEFAULT;
60
+ const maxRetries = Number.isFinite(opts.maxRetries) ? opts.maxRetries : SAVE_PIPELINE.MAX_RETRIES_DEFAULT;
61
+ const onError = (typeof opts.onError === "function") ? opts.onError : function () {};
62
+ const win = opts.win || null;
63
+ const setTimeoutFn = (win && win.setTimeout) ? win.setTimeout.bind(win) : setTimeout;
64
+ const clearTimeoutFn = (win && win.clearTimeout) ? win.clearTimeout.bind(win) : clearTimeout;
65
+
66
+ let timer = null;
67
+ let pendingSnap = null;
68
+ let pendingProjectId = null;
69
+ // Each flush bumps the generation; a scheduled retry only fires while its
70
+ // generation is still current — a newer flush supersedes the stale retry.
71
+ let generation = 0;
72
+
73
+ function takePending() {
74
+ const snap = pendingSnap, pid = pendingProjectId;
75
+ pendingSnap = null; pendingProjectId = null;
76
+ if (timer) { clearTimeoutFn(timer); timer = null; }
77
+ return (snap && pid) ? { snap, pid } : null;
78
+ }
79
+
80
+ // SM-244: a genuine HTTP client rejection (4xx — bad request, payload too
81
+ // large, validation) is DETERMINISTIC — retrying can't help, so surface it
82
+ // immediately. Everything else (a network/connection error with no
83
+ // statusCode — e.g. a stale keep-alive socket reset — or a 5xx) is
84
+ // TRANSIENT: the retry runs on a fresh connection and usually succeeds.
85
+ // The whole-snapshot PUT is idempotent (replace), so re-sending is safe.
86
+ function isRetriable(err) {
87
+ const sc = err && err.statusCode;
88
+ if (typeof sc === "number" && sc >= 400 && sc < 500) return false;
89
+ return true;
90
+ }
91
+
92
+ function attemptSave(pid, snap, retriesLeft, gen) {
93
+ Promise.resolve()
94
+ .then(function () { return getAdapter().save(pid, snap); })
95
+ .catch(function (err) {
96
+ if (gen !== generation) return; // superseded by a newer flush
97
+ if (retriesLeft > 0 && isRetriable(err)) {
98
+ setTimeoutFn(function () {
99
+ if (gen !== generation) return; // superseded while waiting
100
+ attemptSave(pid, snap, retriesLeft - 1, gen);
101
+ }, retryMs);
102
+ return;
103
+ }
104
+ onError(err);
105
+ });
106
+ }
107
+
108
+ function flush() {
109
+ timer = null;
110
+ const p = takePending();
111
+ if (!p) return;
112
+ generation++;
113
+ attemptSave(p.pid, p.snap, maxRetries, generation);
114
+ }
115
+
116
+ /**
117
+ * Synchronous flush for page teardown. Returns true when a pending save
118
+ * was sent. NOTE: browser keepalive bodies are capped (~64 KiB in-flight);
119
+ * a very large snapshot may still be dropped by the browser — best-effort,
120
+ * strictly better than losing the commit unconditionally.
121
+ */
122
+ function flushPendingSave() {
123
+ const p = takePending();
124
+ if (!p) return false;
125
+ generation++; // cancel any in-flight retry
126
+ const adapter = getAdapter();
127
+ let sent = false;
128
+ if (adapter && typeof adapter.saveBeacon === "function") {
129
+ // saveBeacon returns false when keepalive can't carry the snapshot
130
+ // (e.g. body above the browser cap) — fall through to a plain save.
131
+ try { sent = adapter.saveBeacon(p.pid, p.snap) !== false; }
132
+ catch (_) { sent = false; }
133
+ }
134
+ if (!sent && adapter) {
135
+ try { Promise.resolve(adapter.save(p.pid, p.snap)).catch(function () {}); }
136
+ catch (_) { /* ignore */ }
137
+ }
138
+ return true;
139
+ }
140
+
141
+ const unsubscribe = opts.store.subscribe(function (snap, reason) {
142
+ if (skip && skip.has(reason)) return;
143
+ pendingSnap = snap;
144
+ // SM-153: capture at SCHEDULE time, not flush time.
145
+ pendingProjectId = getProjectId();
146
+ if (timer) clearTimeoutFn(timer);
147
+ timer = setTimeoutFn(flush, debounceMs);
148
+ });
149
+
150
+ const onPageHide = function () { flushPendingSave(); };
151
+ if (win && typeof win.addEventListener === "function") {
152
+ win.addEventListener("pagehide", onPageHide);
153
+ win.addEventListener("beforeunload", onPageHide);
154
+ }
155
+
156
+ function dispose() {
157
+ if (timer) { clearTimeoutFn(timer); timer = null; }
158
+ generation++;
159
+ if (typeof unsubscribe === "function") { try { unsubscribe(); } catch (_) {} }
160
+ if (win && typeof win.removeEventListener === "function") {
161
+ win.removeEventListener("pagehide", onPageHide);
162
+ win.removeEventListener("beforeunload", onPageHide);
163
+ }
164
+ }
165
+
166
+ return { flushPendingSave, dispose };
167
+ }
168
+
169
+ return { SAVE_PIPELINE, createSavePipeline };
170
+ }));
@@ -0,0 +1,162 @@
1
+ /**
2
+ * SM-216 — smart-bar autocomplete dropdown (browser UI).
3
+ *
4
+ * Attaches a Jira-style suggestion dropdown to a text input. Pure suggestion
5
+ * logic lives in shared/query-autocomplete.js; this module is just the DOM:
6
+ * render the candidates, keyboard-navigate and insert the chosen completion
7
+ * at the cursor. UMD-wrapped so the SAME file is require()d in jsdom tests
8
+ * and <script src>'d in the browser.
9
+ *
10
+ * Key semantics (SM-293): nothing is auto-highlighted. ↑/↓ choose, Enter
11
+ * accepts ONLY an explicitly chosen suggestion (otherwise it closes the menu
12
+ * and falls through so the input's own handler runs the query), Tab eagerly
13
+ * accepts the highlighted/first suggestion, Esc closes.
14
+ *
15
+ * attachAutocomplete(inputEl, { getSnapshot, onAccept }) → { detach, _refresh }
16
+ *
17
+ * getSnapshot() → the current project snapshot (feeds field VALUE suggestions).
18
+ * onAccept() → called after a completion is inserted (the smart-bar re-runs
19
+ * the query so the view + count update).
20
+ */
21
+ (function (global, factory) {
22
+ if (typeof module === "object" && module.exports) module.exports = factory();
23
+ else {
24
+ const ns = (global.STORYMAP = global.STORYMAP || {});
25
+ ns.smartbarAutocomplete = factory();
26
+ }
27
+ }(typeof window !== "undefined" ? window : globalThis, function () {
28
+ "use strict";
29
+
30
+ function attachAutocomplete(input, opts) {
31
+ opts = opts || {};
32
+ const doc = input.ownerDocument;
33
+ const ns = (input.ownerDocument.defaultView || global).STORYMAP || {};
34
+ const ac = ns.queryAutocomplete;
35
+ if (!ac) return { detach: function () {}, _refresh: function () {} };
36
+
37
+ const menu = doc.createElement("div");
38
+ menu.className = "smartbar-ac";
39
+ menu.setAttribute("role", "listbox");
40
+ menu.hidden = true;
41
+ doc.body.appendChild(menu);
42
+
43
+ let items = []; // current suggestion objects
44
+ let active = -1; // highlighted index
45
+
46
+ function snapshot() {
47
+ try { return (typeof opts.getSnapshot === "function" && opts.getSnapshot()) || { tickets: [] }; }
48
+ catch (_e) { return { tickets: [] }; }
49
+ }
50
+ function position() {
51
+ const r = input.getBoundingClientRect();
52
+ menu.style.left = r.left + "px";
53
+ menu.style.top = (r.bottom + 2) + "px";
54
+ menu.style.minWidth = r.width + "px";
55
+ }
56
+ function hide() { menu.hidden = true; menu.innerHTML = ""; items = []; active = -1; }
57
+
58
+ function render() {
59
+ const cursor = input.selectionStart == null ? input.value.length : input.selectionStart;
60
+ items = ac.suggest(input.value, cursor, snapshot());
61
+ if (!items.length) { hide(); return; }
62
+ // SM-293: NO auto-highlight. Enter must run the query, not silently
63
+ // accept a suggestion the user never chose — a suggestion becomes
64
+ // active only via explicit arrow navigation (Tab still accepts the
65
+ // first one without navigating).
66
+ active = -1;
67
+ menu.innerHTML = "";
68
+ items.forEach(function (s, i) {
69
+ const row = doc.createElement("div");
70
+ row.className = "smartbar-ac-item";
71
+ row.setAttribute("role", "option");
72
+ row.dataset.index = String(i);
73
+ const lab = doc.createElement("span");
74
+ lab.className = "smartbar-ac-label";
75
+ lab.textContent = s.label;
76
+ const kind = doc.createElement("span");
77
+ kind.className = "smartbar-ac-kind";
78
+ kind.textContent = s.kind;
79
+ row.appendChild(lab); row.appendChild(kind);
80
+ row.addEventListener("mousedown", function (ev) {
81
+ // mousedown (not click) so the input doesn't blur before we accept.
82
+ ev.preventDefault();
83
+ accept(i);
84
+ });
85
+ menu.appendChild(row);
86
+ });
87
+ position();
88
+ menu.hidden = false;
89
+ }
90
+
91
+ function highlight(next) {
92
+ if (!items.length) return;
93
+ active = (next + items.length) % items.length;
94
+ Array.prototype.forEach.call(menu.children, function (el, i) {
95
+ el.classList.toggle("active", i === active);
96
+ });
97
+ const el = menu.children[active];
98
+ if (el && el.scrollIntoView) el.scrollIntoView({ block: "nearest" });
99
+ }
100
+
101
+ function accept(i) {
102
+ const s = items[i];
103
+ if (!s) return;
104
+ const before = input.value.slice(0, s.replaceStart);
105
+ const afterText = input.value.slice(s.replaceEnd);
106
+ input.value = before + s.insertText + afterText;
107
+ const caret = (before + s.insertText).length;
108
+ input.setSelectionRange(caret, caret);
109
+ input.focus();
110
+ hide();
111
+ // chain: show the next set of suggestions (e.g. field → operator) and let
112
+ // the smart-bar re-run the (possibly now-complete) query.
113
+ if (typeof opts.onAccept === "function") opts.onAccept();
114
+ render();
115
+ }
116
+
117
+ function onKeydown(ev) {
118
+ if (menu.hidden) return; // let Esc etc. fall through to the smart-bar
119
+ if (ev.key === "ArrowDown") {
120
+ ev.preventDefault();
121
+ highlight(active === -1 ? 0 : active + 1);
122
+ } else if (ev.key === "ArrowUp") {
123
+ ev.preventDefault();
124
+ highlight(active === -1 ? items.length - 1 : active - 1);
125
+ } else if (ev.key === "Enter") {
126
+ // SM-293: Enter accepts ONLY an explicitly chosen suggestion.
127
+ // Without one it closes the dropdown and falls through, so the
128
+ // input's own handler runs the query.
129
+ if (active >= 0) { ev.preventDefault(); ev.stopPropagation(); accept(active); }
130
+ else hide();
131
+ } else if (ev.key === "Tab") {
132
+ // Tab keeps the eager behavior: accept the highlighted or first one.
133
+ ev.preventDefault(); ev.stopPropagation(); accept(active >= 0 ? active : 0);
134
+ } else if (ev.key === "Escape") {
135
+ ev.preventDefault(); ev.stopPropagation(); hide();
136
+ }
137
+ }
138
+ function onInput() { render(); }
139
+ function onBlur() { setTimeout(hide, 120); } // allow mousedown-accept first
140
+ function onFocus() { render(); }
141
+
142
+ input.addEventListener("keydown", onKeydown, true);
143
+ input.addEventListener("input", onInput);
144
+ input.addEventListener("focus", onFocus);
145
+ input.addEventListener("blur", onBlur);
146
+
147
+ return {
148
+ detach: function () {
149
+ input.removeEventListener("keydown", onKeydown, true);
150
+ input.removeEventListener("input", onInput);
151
+ input.removeEventListener("focus", onFocus);
152
+ input.removeEventListener("blur", onBlur);
153
+ if (menu.parentNode) menu.parentNode.removeChild(menu);
154
+ },
155
+ _menu: menu,
156
+ _refresh: render,
157
+ _accept: accept
158
+ };
159
+ }
160
+
161
+ return { attachAutocomplete: attachAutocomplete };
162
+ }));
@@ -0,0 +1,197 @@
1
+ /**
2
+ * ProjectStore — local mutable wrapper around a snapshot with undo/redo and
3
+ * the same applyRemote/hydrate semantics cmapper proved out.
4
+ *
5
+ * Three ways to install a fresh snapshot:
6
+ * - applySnapshot(snap) → user-driven (paste / load), commits as undo entry
7
+ * - applyRemote(snap) → WebSocket push, commits as undo entry but is a
8
+ * no-op when the incoming snapshot equals current
9
+ * - hydrate(snap) → initial load / project switch, WIPES undo stack
10
+ *
11
+ * applyRemote MUST NOT call hydrate — otherwise every WS round-trip nukes
12
+ * the user's undo history.
13
+ */
14
+ (function (root, factory) {
15
+ if (typeof module === "object" && module.exports) module.exports = factory(require("./core.js"));
16
+ else (root.STORYMAP = root.STORYMAP || {}).store = factory(root.STORYMAP && root.STORYMAP.core);
17
+ }(typeof self !== "undefined" ? self : this, function (core) {
18
+ "use strict";
19
+
20
+ if (!core) throw new Error("store.js: core module missing (load order: core.js BEFORE store.js)");
21
+
22
+ const MAX_UNDO = 200;
23
+
24
+ /**
25
+ * Named reasons that the store may pass to subscribers. The save-subscriber
26
+ * in main.js skips persistence on `applyRemote` (echo from WS) and `hydrate`
27
+ * (initial load / project switch); every other reason → debounced PUT.
28
+ * `op:*` is a stand-in for every dynamically wrapped core.ops entry
29
+ * (createTicket, updateProject, reorderTickets, …) — wrapOp uses the op
30
+ * name verbatim as the reason.
31
+ */
32
+ const STORE_REASONS = Object.freeze({
33
+ APPLY_REMOTE: "applyRemote",
34
+ HYDRATE: "hydrate",
35
+ APPLY_SNAPSHOT: "applySnapshot",
36
+ UNDO: "undo",
37
+ REDO: "redo"
38
+ });
39
+
40
+ /** Reasons the save-subscriber MUST NOT persist (would echo back to the
41
+ * server and cause a ping-pong). */
42
+ const SKIP_PERSIST_REASONS = Object.freeze(new Set([
43
+ STORE_REASONS.APPLY_REMOTE,
44
+ STORE_REASONS.HYDRATE
45
+ ]));
46
+
47
+ function deepEqual(a, b) {
48
+ return JSON.stringify(a) === JSON.stringify(b);
49
+ }
50
+
51
+ // SM-220: a cheap O(N) change-signal over the snapshot. Every op bumps the
52
+ // touched entity's monotonic `version` (and create/delete changes a
53
+ // collection length), so this integer signature differs for essentially
54
+ // every real mutation — letting `applyRemote` skip the expensive
55
+ // JSON.stringify deepEqual on the common WS-push (real-change) path and
56
+ // commit straight away. A signal MATCH is NOT proof of equality (versions +
57
+ // lengths can coincide), so the echo path still confirms with deepEqual:
58
+ // AC3 — same metadata, different content is still detected as a change.
59
+ function snapshotSignal(snap) {
60
+ if (!snap) return "0|0|0|0";
61
+ const t = snap.tickets || [], r = snap.releases || [], p = snap.processSteps || [];
62
+ let v = (snap.project && (snap.project.version | 0)) || 0;
63
+ for (let i = 0; i < t.length; i++) v += (t[i].version | 0);
64
+ for (let i = 0; i < r.length; i++) v += (r[i].version | 0);
65
+ for (let i = 0; i < p.length; i++) v += (p[i].version | 0);
66
+ return t.length + "|" + r.length + "|" + p.length + "|" + v;
67
+ }
68
+
69
+ function emptySnapshot(projectId) {
70
+ return core.normalizeSnapshot({
71
+ project: { id: projectId || "untitled", name: projectId || "Untitled" }
72
+ });
73
+ }
74
+
75
+ function ProjectStore(initial) {
76
+ this._snap = core.normalizeSnapshot(initial || emptySnapshot());
77
+ this._signal = snapshotSignal(this._snap); // SM-220: cached change-signal
78
+ this._past = [];
79
+ this._future = [];
80
+ this._listeners = new Set();
81
+ }
82
+
83
+ ProjectStore.prototype.get = function () {
84
+ return this._snap;
85
+ };
86
+
87
+ ProjectStore.prototype.subscribe = function (fn) {
88
+ this._listeners.add(fn);
89
+ return () => this._listeners.delete(fn);
90
+ };
91
+
92
+ ProjectStore.prototype._emit = function (reason) {
93
+ for (const fn of this._listeners) {
94
+ try { fn(this._snap, reason); } catch (_) { /* ignore listener errors */ }
95
+ }
96
+ };
97
+
98
+ // SM-240 (normalize-only-at-edges): _commit no longer runs the full
99
+ // normalizeSnapshot — every caller hands it an already-normalized snapshot.
100
+ // The OUTER entry points normalize (applySnapshot below, applyRemote,
101
+ // hydrate, the constructor); the op path doesn't need to: core.ops outputs
102
+ // are normalize-invariant (pinned per op by tests/test-core-cow.js (c)),
103
+ // and re-normalizing here would destroy the structural sharing COW just
104
+ // created (every entity object rebuilt → no reference equality, bloated
105
+ // undo stack).
106
+ ProjectStore.prototype._commit = function (next, reason) {
107
+ this._past.push(this._snap);
108
+ if (this._past.length > MAX_UNDO) this._past.shift();
109
+ this._future = [];
110
+ this._snap = next;
111
+ this._signal = snapshotSignal(this._snap); // SM-220: keep signal in lockstep with _snap
112
+ this._emit(reason || "commit");
113
+ };
114
+
115
+ // ---- The three install paths ------------------------------------------
116
+
117
+ ProjectStore.prototype.applySnapshot = function (snap) {
118
+ this._commit(core.normalizeSnapshot(snap), "applySnapshot");
119
+ };
120
+
121
+ ProjectStore.prototype.applyRemote = function (snap) {
122
+ const incoming = core.normalizeSnapshot(snap);
123
+ // SM-220: cheap signal first. A differing signal proves a real change —
124
+ // commit straight away, skipping the two full JSON.stringify passes. Only
125
+ // when the signal matches (true echo, or the pathological same-metadata
126
+ // case) do we pay the authoritative deepEqual. WS-echo no-op is preserved.
127
+ if (snapshotSignal(incoming) === this._signal && deepEqual(incoming, this._snap)) {
128
+ return; // confirmed echo → no-op, no undo entry
129
+ }
130
+ this._commit(incoming, "applyRemote");
131
+ };
132
+
133
+ ProjectStore.prototype.hydrate = function (snap) {
134
+ this._snap = core.normalizeSnapshot(snap || emptySnapshot());
135
+ this._signal = snapshotSignal(this._snap); // SM-220
136
+ this._past = [];
137
+ this._future = [];
138
+ this._emit("hydrate");
139
+ };
140
+
141
+ // ---- Undo / Redo ------------------------------------------------------
142
+
143
+ ProjectStore.prototype.canUndo = function () { return this._past.length > 0; };
144
+ ProjectStore.prototype.canRedo = function () { return this._future.length > 0; };
145
+
146
+ ProjectStore.prototype.undo = function () {
147
+ if (this._past.length === 0) return false;
148
+ this._future.push(this._snap);
149
+ this._snap = this._past.pop();
150
+ this._signal = snapshotSignal(this._snap); // SM-220
151
+ this._emit("undo");
152
+ return true;
153
+ };
154
+
155
+ ProjectStore.prototype.redo = function () {
156
+ if (this._future.length === 0) return false;
157
+ this._past.push(this._snap);
158
+ this._snap = this._future.pop();
159
+ this._signal = snapshotSignal(this._snap); // SM-220
160
+ this._emit("redo");
161
+ return true;
162
+ };
163
+
164
+ // ---- Op wrappers — each commit becomes a single undo entry -----------
165
+
166
+ function wrapOp(opName) {
167
+ return function (...args) {
168
+ const actor = args[args.length - 1] && typeof args[args.length - 1] === "object" && args[args.length - 1].type
169
+ ? args.pop()
170
+ : { type: "human", id: "local", name: "Local" };
171
+ const next = core.ops[opName](this._snap, ...args, actor);
172
+ this._commit(next, opName);
173
+ return this._snap;
174
+ };
175
+ }
176
+
177
+ for (const opName of Object.keys(core.ops)) {
178
+ ProjectStore.prototype[opName] = wrapOp(opName);
179
+ }
180
+
181
+ // E18.D: changeStatusGated validiert die Transition (DoR/DoD) VOR dem
182
+ // Commit. Wirft `{statusCode, kind, missing}` bei Verletzung — Callsite
183
+ // fängt ab und zeigt `flashStatus(err.kind + ...)`. Bei Erfolg läuft
184
+ // dieselbe `changeStatus`-Op wie sonst auch (1 Undo-Eintrag).
185
+ ProjectStore.prototype.changeStatusGated = function (ticketId, newStatus, actor) {
186
+ const ticket = this._snap.tickets.find(t => t.id === ticketId);
187
+ if (!ticket) {
188
+ const e = new Error("ticket not found: " + ticketId);
189
+ e.statusCode = 404;
190
+ throw e;
191
+ }
192
+ core.validateStatusTransition(ticket, newStatus, this._snap.project);
193
+ return this.changeStatus(ticketId, newStatus, actor);
194
+ };
195
+
196
+ return { ProjectStore, emptySnapshot, deepEqual, snapshotSignal, STORE_REASONS, SKIP_PERSIST_REASONS };
197
+ }));
@@ -0,0 +1,24 @@
1
+ /**
2
+ * ticket-editor-boot.js — page bootstrap for /editor (ticket-editor.html).
3
+ *
4
+ * SM-214: extracted from an inline <script> block — the SM-211 CSP pins
5
+ * `script-src 'self'`, which forbids inline scripts. Classic script tag,
6
+ * no UMD needed (browser-only entry point, like main.js).
7
+ */
8
+ (function () {
9
+ "use strict";
10
+ function boot() {
11
+ var SM = window.STORYMAP;
12
+ if (!SM || !SM.rendererTicketEditor) {
13
+ document.getElementById("editor-host").textContent = "editor bootstrap: missing modules";
14
+ return;
15
+ }
16
+ SM.rendererTicketEditor.bootstrap().catch(function (e) {
17
+ console.error("editor bootstrap failed:", e);
18
+ var h = document.getElementById("editor-host");
19
+ if (h) h.textContent = "editor failed to load: " + (e && e.message || e);
20
+ });
21
+ }
22
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
23
+ else boot();
24
+ }());