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.
- package/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- 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
|
+
}());
|