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,423 @@
1
+ /**
2
+ * Storage adapters. Same async interface, four backends:
3
+ *
4
+ * HttpAdapter – Talks to a storymap server (PUT/POST/DELETE on
5
+ * the REST API + WS subscription on /ws). Picked
6
+ * explicitly via `?api=<url>` OR automatically
7
+ * when SM-99's `/api/health` probe succeeds on
8
+ * the same origin.
9
+ * WindowStorageAdapter – Claude artifact runtime (window.storage). Data
10
+ * lives in the artifact's persistent storage.
11
+ * LocalStorageAdapter – Standalone browser (localStorage). Per-browser.
12
+ * MemoryAdapter – Last-resort in-memory only.
13
+ *
14
+ * Each adapter exposes:
15
+ * load(projectId) → snapshot | null
16
+ * save(projectId, snapshot, opts) → { revision, savedAt, snapshot }
17
+ * list() → string[] (project ids)
18
+ * delete(projectId) → void
19
+ * subscribe(projectId, fn)? → unsubscribe-fn (HTTP only)
20
+ * name → "Http" | "Local" | "Memory" | "Window"
21
+ * close()? → cleanup (HTTP only)
22
+ *
23
+ * `pickAdapter(env)` is async and runs the priority chain:
24
+ * 1. ?api=<url> → HttpAdapter (explicit override)
25
+ * 2. http(s) origin + /api/health probe succeeds → HttpAdapter on origin
26
+ * 3. window.storage → WindowStorageAdapter
27
+ * 4. window.localStorage → LocalStorageAdapter
28
+ * 5. fall back to MemoryAdapter
29
+ *
30
+ * The /api/health probe (SM-99) verifies `body.name === "storymap"` so a
31
+ * different service on the same port doesn't get mistaken for the server.
32
+ * Probe timeout defaults to 500 ms (override via `env.probeTimeoutMs`).
33
+ */
34
+ (function (root, factory) {
35
+ if (typeof module === "object" && module.exports) module.exports = factory();
36
+ else (root.STORYMAP = root.STORYMAP || {}).adapters = factory();
37
+ }(typeof self !== "undefined" ? self : this, function () {
38
+ "use strict";
39
+
40
+ // SM-153: WebSocket auto-reconnect timing. Exponential backoff from BASE,
41
+ // capped at MAX, so live-sync recovers after a server restart / laptop sleep
42
+ // instead of silently dying on a dead socket.
43
+ const WS_RECONNECT_BASE_MS = 500;
44
+ const WS_RECONNECT_MAX_MS = 10000;
45
+
46
+ // ---- HttpAdapter ------------------------------------------------------
47
+
48
+ function HttpAdapter(baseUrl, opts) {
49
+ this.name = "Http";
50
+ this.base = baseUrl.replace(/\/$/, "");
51
+ this.originId = (opts && opts.originId) || ("client-" + Math.random().toString(36).slice(2, 10));
52
+ this._ws = null;
53
+ this._subscriptions = new Map(); // projectId → Set<fn>
54
+ this._wsReady = null;
55
+ // SM-153: reconnect state.
56
+ this._wantWs = false; // intent to stay connected (false after close())
57
+ this._reconnectTimer = null;
58
+ this._reconnectAttempts = 0;
59
+ this._connState = null; // last reported connection state
60
+ this._onConnState = null; // optional indicator callback
61
+ }
62
+
63
+ HttpAdapter.prototype._req = async function (method, path, body) {
64
+ const init = {
65
+ method,
66
+ headers: { "Content-Type": "application/json", "X-Origin-Id": this.originId }
67
+ };
68
+ if (body !== undefined) init.body = JSON.stringify(body);
69
+ const res = await fetch(this.base + path, init);
70
+ let data = null;
71
+ const text = await res.text();
72
+ if (text) { try { data = JSON.parse(text); } catch (_) { data = text; } }
73
+ if (!res.ok) {
74
+ const err = new Error((data && data.error) || ("HTTP " + res.status));
75
+ err.statusCode = res.status;
76
+ if (data && typeof data === "object") {
77
+ if (data.kind) err.kind = data.kind;
78
+ if (data.missing) err.missing = data.missing;
79
+ }
80
+ throw err;
81
+ }
82
+ return data;
83
+ };
84
+
85
+ HttpAdapter.prototype.list = async function () {
86
+ return await this._req("GET", "/api/projects");
87
+ };
88
+
89
+ HttpAdapter.prototype.load = async function (projectId) {
90
+ try { return await this._req("GET", "/api/projects/" + encodeURIComponent(projectId)); }
91
+ catch (err) { if (err.statusCode === 404) return null; throw err; }
92
+ };
93
+
94
+ HttpAdapter.prototype.save = async function (projectId, snapshot, opts) {
95
+ // E18.A: PUT /api/projects/:pid akzeptiert jetzt einen ganzen Snapshot
96
+ // (Cmapper-Pattern). Body = vollständiger Snapshot. Origin-Id-Header
97
+ // sorgt dafür, dass die eigene WS-Change-Notification beim Echo-Filter
98
+ // wegfällt. Server schreibt 1 Revision mit op="project_put".
99
+ return await this._req("PUT", "/api/projects/" + encodeURIComponent(projectId), snapshot);
100
+ };
101
+
102
+ HttpAdapter.prototype.delete = async function (projectId) {
103
+ await this._req("DELETE", "/api/projects/" + encodeURIComponent(projectId));
104
+ };
105
+
106
+ // SM-214: browsers cap the in-flight keepalive body (~64 KiB) and reject
107
+ // bigger ones with a TypeError — a snapshot above the cap must fall back to
108
+ // a plain fire-and-forget PUT (the pipeline does that when we return false).
109
+ const SAVE_BEACON_MAX_BODY_BYTES = 60 * 1024;
110
+
111
+ /**
112
+ * SM-214: fire-and-forget snapshot PUT for page teardown (pagehide /
113
+ * beforeunload). `keepalive: true` lets the request outlive the page —
114
+ * a normal fetch would be cancelled mid-flight. Returns true when the
115
+ * request was handed to fetch; false when keepalive can't carry it
116
+ * (no fetch, or body above the browser keepalive cap) — the caller then
117
+ * falls back to a regular save.
118
+ */
119
+ HttpAdapter.prototype.saveBeacon = function (projectId, snapshot) {
120
+ try {
121
+ const f = (typeof fetch === "function") ? fetch : null;
122
+ if (!f) return false;
123
+ const body = JSON.stringify(snapshot);
124
+ if (body.length > SAVE_BEACON_MAX_BODY_BYTES) return false;
125
+ f(this.base + "/api/projects/" + encodeURIComponent(projectId), {
126
+ method: "PUT",
127
+ keepalive: true,
128
+ headers: { "Content-Type": "application/json", "X-Origin-Id": this.originId },
129
+ body: body
130
+ }).catch(function () { /* teardown — nothing to report to */ });
131
+ return true;
132
+ } catch (_) { return false; }
133
+ };
134
+
135
+ HttpAdapter.prototype._ensureWs = function () {
136
+ if (this._wsReady) return this._wsReady;
137
+ const wsUrl = this.base.replace(/^http/, "ws") + "/ws";
138
+ const self = this;
139
+ self._wantWs = true;
140
+ this._wsReady = new Promise((resolve, reject) => {
141
+ const WS = (typeof WebSocket !== "undefined") ? WebSocket : null;
142
+ if (!WS) return reject(new Error("WebSocket not available in this environment"));
143
+ const ws = new WS(wsUrl);
144
+ ws.onopen = () => {
145
+ self._ws = ws;
146
+ self._reconnectAttempts = 0;
147
+ self._notifyConn(true);
148
+ // SM-153: a reconnect must restore every active subscription, else
149
+ // live-sync stays silently dead after the socket flapped.
150
+ for (const projectId of self._subscriptions.keys()) {
151
+ try { ws.send(JSON.stringify({ type: "subscribe", projectId, originId: self.originId })); }
152
+ catch (_) { /* ignore */ }
153
+ }
154
+ resolve(ws);
155
+ };
156
+ ws.onerror = (e) => reject(e);
157
+ ws.onmessage = (ev) => self._handleWsMessage(ev);
158
+ ws.onclose = () => {
159
+ self._ws = null;
160
+ self._wsReady = null;
161
+ self._notifyConn(false);
162
+ if (self._wantWs && self._subscriptions.size > 0) self._scheduleReconnect();
163
+ };
164
+ });
165
+ // Don't cache a rejected promise — let the next _ensureWs() retry cleanly.
166
+ this._wsReady.catch(() => { if (self._ws == null) self._wsReady = null; });
167
+ return this._wsReady;
168
+ };
169
+
170
+ HttpAdapter.prototype._scheduleReconnect = function () {
171
+ const self = this;
172
+ if (self._reconnectTimer) return;
173
+ self._reconnectAttempts += 1;
174
+ const delay = Math.min(WS_RECONNECT_MAX_MS,
175
+ WS_RECONNECT_BASE_MS * Math.pow(2, self._reconnectAttempts - 1));
176
+ self._reconnectTimer = setTimeout(function () {
177
+ self._reconnectTimer = null;
178
+ if (!self._wantWs) return;
179
+ self._ensureWs().catch(function () {
180
+ if (self._wantWs && self._subscriptions.size > 0) self._scheduleReconnect();
181
+ });
182
+ }, delay);
183
+ };
184
+
185
+ HttpAdapter.prototype._notifyConn = function (connected) {
186
+ if (connected === this._connState) return;
187
+ this._connState = connected;
188
+ if (typeof this._onConnState === "function") {
189
+ try { this._onConnState(connected); } catch (_) { /* ignore */ }
190
+ }
191
+ };
192
+
193
+ /** SM-153 — register a connection-state indicator callback (true=connected). */
194
+ HttpAdapter.prototype.onConnectionState = function (fn) { this._onConnState = fn; };
195
+
196
+ HttpAdapter.prototype._handleWsMessage = function (ev) {
197
+ let msg;
198
+ try { msg = JSON.parse(ev.data); } catch (_) { return; }
199
+ // SM-214: shape guard — the socket is a trust boundary. Frames that don't
200
+ // match the documented message shapes are dropped before any callback.
201
+ if (!msg || typeof msg !== "object" || typeof msg.type !== "string") return;
202
+ if (msg.type === "change") {
203
+ if (typeof msg.projectId !== "string") return;
204
+ const fns = this._subscriptions.get(msg.projectId);
205
+ if (!fns) return;
206
+ for (const fn of fns) {
207
+ try { fn(msg); } catch (_) { /* ignore */ }
208
+ }
209
+ return;
210
+ }
211
+ if (msg.type === "switch_request") {
212
+ // E20.C: MCP asks the user to switch projects. Route to the
213
+ // registered callback (set by main.js via onSwitchRequest); the
214
+ // callback decides Accept/Cancel and calls sendSwitchResponse.
215
+ if (typeof msg.requestId !== "string" || typeof msg.workspace !== "string") return;
216
+ if (typeof this._onSwitchRequest === "function") {
217
+ try { this._onSwitchRequest({ workspace: msg.workspace, reason: msg.reason, requestId: msg.requestId }); }
218
+ catch (_) { /* ignore */ }
219
+ }
220
+ return;
221
+ }
222
+ };
223
+
224
+ /** E20.C — register a single handler for incoming switch_request frames. */
225
+ HttpAdapter.prototype.onSwitchRequest = function (fn) {
226
+ this._onSwitchRequest = fn;
227
+ };
228
+
229
+ /** E20.C — send the user's verdict back to the server. Silent no-op when
230
+ * the socket isn't open (older callers may not have a requestId). */
231
+ HttpAdapter.prototype.sendSwitchResponse = function (requestId, accepted) {
232
+ if (typeof requestId !== "string" || !requestId) return;
233
+ if (!this._ws || this._ws.readyState !== 1 /* OPEN */) return;
234
+ try {
235
+ this._ws.send(JSON.stringify({ type: "switch_response", requestId, accepted: !!accepted }));
236
+ } catch (_) { /* ignore */ }
237
+ };
238
+
239
+ HttpAdapter.prototype.subscribe = async function (projectId, fn) {
240
+ const self = this;
241
+ const ws = await this._ensureWs();
242
+ ws.send(JSON.stringify({ type: "subscribe", projectId, originId: this.originId }));
243
+ if (!this._subscriptions.has(projectId)) this._subscriptions.set(projectId, new Set());
244
+ this._subscriptions.get(projectId).add(fn);
245
+ return () => {
246
+ const set = self._subscriptions.get(projectId);
247
+ if (set) {
248
+ set.delete(fn);
249
+ if (set.size === 0) self._subscriptions.delete(projectId);
250
+ }
251
+ // Send on the CURRENT socket (a reconnect may have replaced `ws`).
252
+ if (self._ws) {
253
+ try { self._ws.send(JSON.stringify({ type: "unsubscribe", projectId })); } catch (_) { /* ignore */ }
254
+ }
255
+ };
256
+ };
257
+
258
+ HttpAdapter.prototype.close = function () {
259
+ // SM-153: intentional close — stop auto-reconnect.
260
+ this._wantWs = false;
261
+ if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
262
+ if (this._ws) { try { this._ws.close(); } catch (_) {} }
263
+ this._ws = null; this._wsReady = null;
264
+ };
265
+
266
+ // ---- LocalStorageAdapter ---------------------------------------------
267
+
268
+ const LS_PREFIX = "storymap:project:";
269
+ const LS_INDEX = "storymap:projects";
270
+
271
+ function LocalStorageAdapter(store) {
272
+ this.name = "Local";
273
+ this.store = store || (typeof localStorage !== "undefined" ? localStorage : null);
274
+ if (!this.store) throw new Error("LocalStorageAdapter: localStorage not available");
275
+ }
276
+
277
+ LocalStorageAdapter.prototype.list = async function () {
278
+ const raw = this.store.getItem(LS_INDEX);
279
+ return raw ? JSON.parse(raw) : [];
280
+ };
281
+
282
+ LocalStorageAdapter.prototype.load = async function (projectId) {
283
+ const raw = this.store.getItem(LS_PREFIX + projectId);
284
+ return raw ? JSON.parse(raw) : null;
285
+ };
286
+
287
+ LocalStorageAdapter.prototype.save = async function (projectId, snapshot) {
288
+ this.store.setItem(LS_PREFIX + projectId, JSON.stringify(snapshot));
289
+ const list = await this.list();
290
+ if (list.indexOf(projectId) < 0) {
291
+ list.push(projectId);
292
+ this.store.setItem(LS_INDEX, JSON.stringify(list));
293
+ }
294
+ return { revision: String(Date.now()), savedAt: Date.now(), snapshot };
295
+ };
296
+
297
+ LocalStorageAdapter.prototype.delete = async function (projectId) {
298
+ this.store.removeItem(LS_PREFIX + projectId);
299
+ const list = (await this.list()).filter(x => x !== projectId);
300
+ this.store.setItem(LS_INDEX, JSON.stringify(list));
301
+ };
302
+
303
+ // ---- WindowStorageAdapter (Claude artifact) ---------------------------
304
+
305
+ function WindowStorageAdapter(ws) {
306
+ this.name = "Window";
307
+ this.ws = ws || (typeof window !== "undefined" && window.storage);
308
+ if (!this.ws) throw new Error("WindowStorageAdapter: window.storage not available");
309
+ }
310
+
311
+ WindowStorageAdapter.prototype.list = async function () {
312
+ const r = await this.ws.list("storymap:");
313
+ return r.map(k => k.replace(/^storymap:/, ""));
314
+ };
315
+
316
+ WindowStorageAdapter.prototype.load = async function (projectId) {
317
+ const r = await this.ws.get("storymap:" + projectId);
318
+ return r ? (typeof r === "string" ? JSON.parse(r) : r) : null;
319
+ };
320
+
321
+ WindowStorageAdapter.prototype.save = async function (projectId, snapshot) {
322
+ await this.ws.set("storymap:" + projectId, JSON.stringify(snapshot));
323
+ return { revision: String(Date.now()), savedAt: Date.now(), snapshot };
324
+ };
325
+
326
+ WindowStorageAdapter.prototype.delete = async function (projectId) {
327
+ await this.ws.delete("storymap:" + projectId);
328
+ };
329
+
330
+ // ---- MemoryAdapter ----------------------------------------------------
331
+
332
+ function MemoryAdapter() {
333
+ this.name = "Memory";
334
+ this._byId = new Map();
335
+ }
336
+
337
+ MemoryAdapter.prototype.list = async function () {
338
+ return Array.from(this._byId.keys()).sort();
339
+ };
340
+ MemoryAdapter.prototype.load = async function (projectId) {
341
+ return this._byId.has(projectId) ? JSON.parse(JSON.stringify(this._byId.get(projectId))) : null;
342
+ };
343
+ MemoryAdapter.prototype.save = async function (projectId, snapshot) {
344
+ this._byId.set(projectId, JSON.parse(JSON.stringify(snapshot)));
345
+ return { revision: String(Date.now()), savedAt: Date.now(), snapshot };
346
+ };
347
+ MemoryAdapter.prototype.delete = async function (projectId) {
348
+ this._byId.delete(projectId);
349
+ };
350
+
351
+ // ---- pickAdapter ------------------------------------------------------
352
+
353
+ const PROBE_TIMEOUT_MS_DEFAULT = 500;
354
+
355
+ /**
356
+ * SM-99 — probe `<origin>/api/health` to detect whether the page is being
357
+ * served by a storymap server. Resolves with the working `baseUrl` (the
358
+ * stripped-trailing-slash origin) on success; resolves with `null` on
359
+ * any failure (network, timeout, non-200, non-JSON, wrong identity).
360
+ *
361
+ * Identity check: `body.name === "storymap"` so we don't latch onto an
362
+ * unrelated server that happens to live on the same port.
363
+ */
364
+ async function probeStorymapOrigin(origin, opts) {
365
+ if (!origin) return null;
366
+ const timeoutMs = (opts && typeof opts.probeTimeoutMs === "number")
367
+ ? opts.probeTimeoutMs : PROBE_TIMEOUT_MS_DEFAULT;
368
+ const fetchImpl = (opts && opts.fetch) || (typeof fetch === "function" ? fetch : null);
369
+ if (!fetchImpl) return null;
370
+ const base = origin.replace(/\/$/, "");
371
+ const ctrl = (typeof AbortController === "function") ? new AbortController() : null;
372
+ const timer = (ctrl && setTimeout(() => ctrl.abort(), timeoutMs)) || null;
373
+ try {
374
+ const res = await fetchImpl(base + "/api/health",
375
+ ctrl ? { signal: ctrl.signal } : {});
376
+ if (!res || !res.ok) return null;
377
+ const body = await res.json().catch(() => null);
378
+ if (!body || body.name !== "storymap") return null;
379
+ return base;
380
+ } catch (_) {
381
+ return null;
382
+ } finally {
383
+ if (timer) clearTimeout(timer);
384
+ }
385
+ }
386
+
387
+ async function pickAdapter(env) {
388
+ env = env || {};
389
+ // 1. Explicit ?api=<url> override.
390
+ const url = env.url || (typeof window !== "undefined" ? window.location.href : "");
391
+ const params = new URLSearchParams((url.split("?")[1] || "").split("#")[0]);
392
+ const api = params.get("api");
393
+ if (api) return new HttpAdapter(api, env);
394
+ // 2. Default: probe the current http(s) origin for a storymap server.
395
+ const protocol = env.protocol
396
+ || (typeof window !== "undefined" && window.location && window.location.protocol)
397
+ || "";
398
+ if (protocol === "http:" || protocol === "https:") {
399
+ const origin = env.origin
400
+ || (typeof window !== "undefined" && window.location && window.location.origin)
401
+ || "";
402
+ const ok = await probeStorymapOrigin(origin, env);
403
+ if (ok) return new HttpAdapter(ok, env);
404
+ }
405
+ // 3-5. Fallback chain (unchanged from pre-SM-99).
406
+ const winStorage = env.windowStorage || (typeof window !== "undefined" && window.storage);
407
+ if (winStorage) return new WindowStorageAdapter(winStorage);
408
+ const ls = env.localStorage || (typeof localStorage !== "undefined" ? localStorage : null);
409
+ if (ls) return new LocalStorageAdapter(ls);
410
+ return new MemoryAdapter();
411
+ }
412
+
413
+ return {
414
+ HttpAdapter,
415
+ LocalStorageAdapter,
416
+ WindowStorageAdapter,
417
+ MemoryAdapter,
418
+ pickAdapter,
419
+ // SM-99 — exposed for tests
420
+ probeStorymapOrigin,
421
+ PROBE_TIMEOUT_MS_DEFAULT
422
+ };
423
+ }));