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,384 @@
1
+ /**
2
+ * Card animations for MCP/WS-driven changes (E24).
3
+ *
4
+ * Used by Kanban, Story-Map, and Ticket-Modal renderers. Two effects:
5
+ *
6
+ * 1. FLIP: when a card moves between lanes / cells / sort-positions
7
+ * after an external commit, animate it sliding to its new spot
8
+ * instead of teleporting. Implementation is the classic FLIP pattern
9
+ * (First, Last, Invert, Play): capture rects before the rerender,
10
+ * then after the rerender apply an inverse transform and transition
11
+ * it back to identity.
12
+ *
13
+ * 2. Pulse: when a card's non-positional fields change (title, status,
14
+ * acceptance criteria, checklists, labels, etc.) we briefly add a
15
+ * .sm-card-flash class so the user sees "this card just got
16
+ * updated". Color comes from --accent so it stays on-theme.
17
+ *
18
+ * Local user actions don't trigger these — only remote commits (reason
19
+ * 'applyRemote') invoke the animation hooks. Local actions already have
20
+ * direct visual feedback (drag-ghost, dialog dismiss, …) and animating
21
+ * them on top would feel laggy.
22
+ *
23
+ * Pure helpers (`diffNonPositionChanges`) are testable in JSDOM. The
24
+ * timing-sensitive FLIP and pulse helpers are no-ops in environments
25
+ * without real layout (JSDOM returns 0×0 rects), so calling them in
26
+ * tests is safe.
27
+ *
28
+ * UMD-wrapped — usable in browser and Node tests.
29
+ */
30
+ (function (root, factory) {
31
+ if (typeof module === "object" && module.exports) {
32
+ module.exports = factory();
33
+ } else {
34
+ (root.STORYMAP = root.STORYMAP || {}).cardAnimate = factory();
35
+ }
36
+ }(typeof self !== "undefined" ? self : this, function () {
37
+ "use strict";
38
+
39
+ const FLIP_DURATION_MS = 320;
40
+ const FLIP_EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
41
+ const PULSE_CLASS = "sm-card-flash";
42
+ const PULSE_DURATION_MS = 900;
43
+ const PULSE_EPSILON_PX = 0.5; // ignore sub-pixel jitter from layout
44
+
45
+ /**
46
+ * Snapshot the position of every visible ticket card inside `hostEl`.
47
+ * Keyed by ticket id (`data-ticket-id` attribute). Used as the "First"
48
+ * pass of FLIP — call this BEFORE you wipe and rerender the host.
49
+ */
50
+ function captureCardRects(hostEl) {
51
+ return captureRectsBySelector(hostEl, ".sm-story-card[data-ticket-id]", "ticketId");
52
+ }
53
+
54
+ /**
55
+ * Generic version of captureCardRects (E24.E): walks an arbitrary
56
+ * selector inside `hostEl` and keys the bounding-rects by the named
57
+ * dataset property. Used to animate ticket cards, epic cards, process-
58
+ * step columns, release rows — anything with a stable data-* id.
59
+ */
60
+ function captureRectsBySelector(hostEl, selector, idAttr) {
61
+ const out = new Map();
62
+ if (!hostEl || typeof hostEl.querySelectorAll !== "function") return out;
63
+ const nodes = hostEl.querySelectorAll(selector);
64
+ for (const node of nodes) {
65
+ const id = node.dataset && node.dataset[idAttr];
66
+ if (!id) continue;
67
+ out.set(id, node.getBoundingClientRect());
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /**
73
+ * After the host has been rerendered, find each card that existed in
74
+ * `prevRects` and animate it from its old position to the new one.
75
+ * Newly inserted cards (no entry in prevRects) just appear in place.
76
+ * Returns the count of cards that actually moved (useful in tests).
77
+ */
78
+ function flipFromCaptured(hostEl, prevRects, opts) {
79
+ return flipFromCapturedSelector(hostEl, prevRects, ".sm-story-card[data-ticket-id]", "ticketId", opts);
80
+ }
81
+
82
+ /**
83
+ * Generic FLIP (E24.E): same idea as flipFromCaptured but parametrised
84
+ * on selector + idAttr. Powers epic / process-step / release animations.
85
+ */
86
+ function flipFromCapturedSelector(hostEl, prevRects, selector, idAttr, opts) {
87
+ if (!hostEl || !prevRects || prevRects.size === 0) return 0;
88
+ opts = opts || {};
89
+ const duration = opts.duration || FLIP_DURATION_MS;
90
+ const easing = opts.easing || FLIP_EASING;
91
+ let animated = 0;
92
+ const nodes = hostEl.querySelectorAll(selector);
93
+ for (const node of nodes) {
94
+ const id = node.dataset && node.dataset[idAttr];
95
+ if (!id) continue;
96
+ const prev = prevRects.get(id);
97
+ if (!prev) continue;
98
+ const next = node.getBoundingClientRect();
99
+ const dx = prev.left - next.left;
100
+ const dy = prev.top - next.top;
101
+ if (Math.abs(dx) < PULSE_EPSILON_PX && Math.abs(dy) < PULSE_EPSILON_PX) continue;
102
+ node.style.transition = "none";
103
+ node.style.transform = "translate(" + dx + "px, " + dy + "px)";
104
+ // eslint-disable-next-line no-unused-expressions
105
+ node.getBoundingClientRect();
106
+ node.style.transition = "transform " + duration + "ms " + easing;
107
+ node.style.transform = "";
108
+ setTimeout(function () {
109
+ node.style.transition = "";
110
+ node.style.transform = "";
111
+ }, duration + 60);
112
+ animated += 1;
113
+ }
114
+ return animated;
115
+ }
116
+
117
+ /**
118
+ * Briefly add the flash class to every card whose id is in `ticketIds`.
119
+ * The class triggers an --accent-toned keyframe in CSS. Caller must
120
+ * make sure the keyframe rule is loaded (frontend/css/storymap.css).
121
+ */
122
+ function pulseTickets(hostEl, ticketIds, opts) {
123
+ pulseEntities(hostEl, ticketIds, '[data-ticket-id="', '"]', opts);
124
+ }
125
+
126
+ /**
127
+ * Generic pulse (E24.E): briefly add the flash class to any element
128
+ * matching `[data-<idAttr>="<id>"]` for each id. `selectorPrefix`/
129
+ * `selectorSuffix` let callers narrow the selector if they want to
130
+ * only highlight cards (e.g. exclude container wrappers).
131
+ *
132
+ * For typical usage just pass the data-attribute selector pieces:
133
+ * pulseEntities(host, ids, '[data-ticket-id="', '"]')
134
+ */
135
+ function pulseEntities(hostEl, ids, selectorPrefix, selectorSuffix, opts) {
136
+ if (!hostEl || !ids) return;
137
+ opts = opts || {};
138
+ const cls = opts.className || PULSE_CLASS;
139
+ const duration = opts.duration || PULSE_DURATION_MS;
140
+ const list = (typeof ids.forEach === "function") ? ids : Array.from(ids);
141
+ list.forEach(function (id) {
142
+ if (!id) return;
143
+ const sel = selectorPrefix + cssEscape(id) + selectorSuffix;
144
+ const nodes = hostEl.querySelectorAll(sel);
145
+ for (const node of nodes) {
146
+ node.classList.remove(cls);
147
+ // eslint-disable-next-line no-unused-expressions
148
+ void node.offsetWidth; // force reflow → restart animation
149
+ node.classList.add(cls);
150
+ setTimeout(function () { node.classList.remove(cls); }, duration);
151
+ }
152
+ });
153
+ }
154
+
155
+ function cssEscape(s) {
156
+ return String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
157
+ }
158
+
159
+ /**
160
+ * Pure helper. Compare two snapshots and return the set of ticket ids
161
+ * whose non-positional fields changed. "Non-positional" = anything that
162
+ * doesn't affect which lane/cell a card lives in. Position changes are
163
+ * handled by the FLIP pass and don't need a pulse.
164
+ */
165
+ function diffNonPositionChanges(prevSnap, nextSnap) {
166
+ const out = new Set();
167
+ if (!prevSnap || !nextSnap) return out;
168
+ const prevById = new Map((prevSnap.tickets || []).map(function (t) { return [t.id, t]; }));
169
+ for (const nextT of (nextSnap.tickets || [])) {
170
+ const prevT = prevById.get(nextT.id);
171
+ if (!prevT) continue; // newly added; the render itself draws attention
172
+ if (nonPositionHash(prevT) !== nonPositionHash(nextT)) out.add(nextT.id);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * Pure helper. Returns the set of ticket ids that exist in nextSnap
179
+ * but NOT in prevSnap (and aren't tombstoned). When `prevSnap` is null
180
+ * (e.g. very first commit observed on mount), every visible ticket
181
+ * counts as new — matches the user's perspective of "I just opened
182
+ * this and these cards appeared".
183
+ *
184
+ * Used by the animation hook to pulse arriving cards so they don't
185
+ * silently materialise where the user wouldn't notice them.
186
+ */
187
+ function diffNewTickets(prevSnap, nextSnap) {
188
+ const out = new Set();
189
+ if (!nextSnap) return out;
190
+ const prevIds = new Set(prevSnap ? (prevSnap.tickets || []).map(function (t) { return t.id; }) : []);
191
+ for (const t of (nextSnap.tickets || [])) {
192
+ if (t.isDeleted) continue;
193
+ if (!prevIds.has(t.id)) out.add(t.id);
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Pure helper. Returns the set of ticket ids that were "touched" by
200
+ * the commit between prevSnap and nextSnap. Touched = the ticket is
201
+ * new, OR its `updatedAt`/`version` advanced, OR a structural compare
202
+ * sees any field change. This is the broadest "something happened to
203
+ * this ticket" signal — exactly what the user asked for: every MCP
204
+ * change to a ticket should pulse, no matter which field changed.
205
+ * Lane animation is handled separately by FLIP on rect-deltas.
206
+ */
207
+ function diffTouchedTickets(prevSnap, nextSnap) {
208
+ const out = new Set();
209
+ if (!nextSnap) return out;
210
+ const prevById = new Map();
211
+ if (prevSnap) {
212
+ for (const t of (prevSnap.tickets || [])) prevById.set(t.id, t);
213
+ }
214
+ for (const next of (nextSnap.tickets || [])) {
215
+ if (next.isDeleted) continue;
216
+ const prev = prevById.get(next.id);
217
+ if (!prev) { out.add(next.id); continue; } // new ticket
218
+ // updatedAt / version cover almost every server-side mutation;
219
+ // fall back to structural compare for the (rare) case where a
220
+ // mutation didn't bump them.
221
+ if (prev.updatedAt !== next.updatedAt) { out.add(next.id); continue; }
222
+ if (prev.version !== next.version) { out.add(next.id); continue; }
223
+ if (touchedHash(prev) !== touchedHash(next)) out.add(next.id);
224
+ }
225
+ return out;
226
+ }
227
+
228
+ /** Wide hash for change detection — includes position too, since the
229
+ * user wants every change to highlight (lane-move + glow combined). */
230
+ function touchedHash(t) {
231
+ return JSON.stringify({
232
+ type: t.type,
233
+ title: t.title,
234
+ description: t.description,
235
+ status: t.status,
236
+ position: t.position,
237
+ labels: t.labels,
238
+ acceptanceCriteria: t.acceptanceCriteria,
239
+ definitionOfReady: t.definitionOfReady,
240
+ definitionOfDone: t.definitionOfDone,
241
+ links: t.links,
242
+ comments: t.comments,
243
+ isDeleted: t.isDeleted
244
+ });
245
+ }
246
+
247
+ /**
248
+ * Generic "touched ids in this entity list" diff (E24.E). `listKey`
249
+ * picks the snapshot array (tickets/releases/processSteps). Same
250
+ * heuristic as diffTouchedTickets: new id, updatedAt bump, version
251
+ * bump, or structural diff. Used by the Story-Map renderer to
252
+ * highlight changed process-steps and releases on top of tickets.
253
+ */
254
+ function diffTouchedEntities(prevSnap, nextSnap, listKey) {
255
+ const out = new Set();
256
+ if (!nextSnap) return out;
257
+ const prevList = (prevSnap && prevSnap[listKey]) || [];
258
+ const nextList = (nextSnap && nextSnap[listKey]) || [];
259
+ const prevById = new Map();
260
+ for (const e of prevList) prevById.set(e.id, e);
261
+ for (const next of nextList) {
262
+ if (next.isDeleted) continue;
263
+ const prev = prevById.get(next.id);
264
+ if (!prev) { out.add(next.id); continue; }
265
+ if (prev.updatedAt !== next.updatedAt) { out.add(next.id); continue; }
266
+ if (prev.version !== next.version) { out.add(next.id); continue; }
267
+ if (JSON.stringify(prev) !== JSON.stringify(next)) out.add(next.id);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ /**
273
+ * Stable serialisation of every ticket field EXCEPT position metadata.
274
+ * `status` IS included — a status change without a corresponding lane
275
+ * move (e.g. story-map view where status isn't a layout axis) should
276
+ * still pulse the card so the user notices.
277
+ */
278
+ function nonPositionHash(t) {
279
+ return JSON.stringify({
280
+ type: t.type,
281
+ title: t.title,
282
+ description: t.description,
283
+ status: t.status,
284
+ labels: t.labels,
285
+ acceptanceCriteria: t.acceptanceCriteria,
286
+ definitionOfReady: t.definitionOfReady,
287
+ definitionOfDone: t.definitionOfDone,
288
+ links: t.links,
289
+ comments: t.comments,
290
+ isDeleted: t.isDeleted
291
+ });
292
+ }
293
+
294
+ /**
295
+ * High-level helper for renderer subscribe-callbacks. Captures rects
296
+ * + diff, runs the supplied rerender, then plays the animations. Call
297
+ * this only when the commit came from `applyRemote` — local commits
298
+ * already have direct visual feedback.
299
+ *
300
+ * animateExternalCommit({
301
+ * host,
302
+ * prevSnap, // store snapshot before this commit
303
+ * nextSnap, // current store snapshot
304
+ * rerender: () => renderInto(host, store, opts)
305
+ * })
306
+ */
307
+ function animateExternalCommit(args) {
308
+ const host = args.host;
309
+ const rerender = args.rerender;
310
+ if (!host || typeof rerender !== "function") {
311
+ if (typeof rerender === "function") rerender();
312
+ return;
313
+ }
314
+ const prevRects = captureCardRects(host);
315
+ const touchedIds = diffTouchedTickets(args.prevSnap, args.nextSnap);
316
+ rerender();
317
+ flipFromCaptured(host, prevRects);
318
+ if (touchedIds.size > 0) pulseTickets(host, touchedIds);
319
+ }
320
+
321
+ /**
322
+ * Multi-entity-type version of animateExternalCommit (E24.E). Pass an
323
+ * array of `entityTypes` like
324
+ *
325
+ * [
326
+ * { listKey: "tickets", selector: "[data-ticket-id]", idAttr: "ticketId" },
327
+ * { listKey: "processSteps", selector: "[data-process-step-id]", idAttr: "processStepId" },
328
+ * { listKey: "releases", selector: "[data-release-id]", idAttr: "releaseId" }
329
+ * ]
330
+ *
331
+ * The renderer (Story-Map) uses this to animate epic cards + story
332
+ * cards + process-step columns + release labels all in one rerender
333
+ * cycle. Each entity-type gets its own capture/diff/flip/pulse pass.
334
+ */
335
+ function animateExternalCommitMulti(args) {
336
+ const host = args.host;
337
+ const rerender = args.rerender;
338
+ const types = Array.isArray(args.entityTypes) ? args.entityTypes : [];
339
+ if (!host || typeof rerender !== "function") {
340
+ if (typeof rerender === "function") rerender();
341
+ return;
342
+ }
343
+ // Phase 1: capture rects + diff touched ids per entity type.
344
+ const passes = types.map(function (et) {
345
+ return {
346
+ et: et,
347
+ prevRects: captureRectsBySelector(host, et.selector, et.idAttr),
348
+ touched: diffTouchedEntities(args.prevSnap, args.nextSnap, et.listKey)
349
+ };
350
+ });
351
+ // Phase 2: rerender (DOM wipe).
352
+ rerender();
353
+ // Phase 3: FLIP + pulse per entity type.
354
+ for (const p of passes) {
355
+ flipFromCapturedSelector(host, p.prevRects, p.et.selector, p.et.idAttr);
356
+ if (p.touched.size > 0) {
357
+ const dataAttr = "data-" + camelToDashed(p.et.idAttr);
358
+ pulseEntities(host, p.touched, '[' + dataAttr + '="', '"]');
359
+ }
360
+ }
361
+ }
362
+
363
+ function camelToDashed(s) {
364
+ return String(s).replace(/[A-Z]/g, function (c) { return "-" + c.toLowerCase(); });
365
+ }
366
+
367
+ return {
368
+ FLIP_DURATION_MS,
369
+ PULSE_CLASS,
370
+ PULSE_DURATION_MS,
371
+ captureCardRects,
372
+ captureRectsBySelector,
373
+ flipFromCaptured,
374
+ flipFromCapturedSelector,
375
+ pulseTickets,
376
+ pulseEntities,
377
+ diffNonPositionChanges,
378
+ diffNewTickets,
379
+ diffTouchedTickets,
380
+ diffTouchedEntities,
381
+ animateExternalCommit,
382
+ animateExternalCommitMulti
383
+ };
384
+ }));