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,361 @@
|
|
|
1
|
+
// SM-203 R-7: Requirements view — a DOORS-style module view.
|
|
2
|
+
//
|
|
3
|
+
// A scrollable document rendered as a multi-column table:
|
|
4
|
+
// SOURCE (the PRD, in document order — heading + the original prose) |
|
|
5
|
+
// ID (requirement key) | REQUIREMENT (the extracted statement) | COVERAGE.
|
|
6
|
+
// The SOURCE and REQUIREMENT text columns stay PURE so you can read the
|
|
7
|
+
// document and compare each slice against its source; all derived metadata
|
|
8
|
+
// (id, coverage, counts) lives in separate attribute columns. A source section
|
|
9
|
+
// that produced NO requirement is flagged `gap` (slicing-completeness).
|
|
10
|
+
//
|
|
11
|
+
// Per requirement you can expand the incoming traceability (clickable tickets
|
|
12
|
+
// that realise / test it) and act: an `orphan` offers "create implementing
|
|
13
|
+
// ticket"; a `gap` source section offers "create requirement".
|
|
14
|
+
//
|
|
15
|
+
// The pure `buildDocumentModel(snapshot, moduleId, sections)` is unit-tested;
|
|
16
|
+
// the DOM render + the async source fetch (injected via ctx.fetchIngest) are
|
|
17
|
+
// thin. ctx callbacks: fetchIngest, onOpenTicket, onCreateImplementer,
|
|
18
|
+
// onCreateRequirement.
|
|
19
|
+
//
|
|
20
|
+
// UMD-wrapped: same source runs in the browser AND require()s in Node tests.
|
|
21
|
+
(function (root, factory) {
|
|
22
|
+
if (typeof module === "object" && module.exports) {
|
|
23
|
+
module.exports = factory(require("./core.js"), require("./core/graph.js"));
|
|
24
|
+
} else {
|
|
25
|
+
(root.STORYMAP = root.STORYMAP || {}).viewRequirements = factory(
|
|
26
|
+
root.STORYMAP && root.STORYMAP.core,
|
|
27
|
+
root.STORYMAP && root.STORYMAP.coreGraph
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}(typeof self !== "undefined" ? self : this, function (core, coreGraph) {
|
|
31
|
+
"use strict";
|
|
32
|
+
|
|
33
|
+
const STATUSES = ["covered", "over-covered", "orphan", "suspect"];
|
|
34
|
+
|
|
35
|
+
// SM-206: persisted Source/Requirement split (fraction of the flexible width
|
|
36
|
+
// the SOURCE column takes; the rest goes to Requirement). ID + Coverage are
|
|
37
|
+
// fixed attribute columns. Default favours the Source so the document reads
|
|
38
|
+
// wide. Resizable via a divider on the Source header.
|
|
39
|
+
const VR_ID_W = 52, VR_COV_W = 112; // fixed attribute columns (px)
|
|
40
|
+
const VR_FRAC_KEY = "storymap-vr-source-frac";
|
|
41
|
+
function readSourceFrac() {
|
|
42
|
+
try { const v = parseFloat(localStorage.getItem(VR_FRAC_KEY)); if (v >= 0.2 && v <= 0.85) return v; } catch (_e) {}
|
|
43
|
+
return 0.6;
|
|
44
|
+
}
|
|
45
|
+
function writeSourceFrac(f) { try { localStorage.setItem(VR_FRAC_KEY, String(f)); } catch (_e) {} }
|
|
46
|
+
function clampFrac(f) { return Math.max(0.2, Math.min(0.85, f)); }
|
|
47
|
+
function docGridCols(frac) {
|
|
48
|
+
const f = clampFrac(frac);
|
|
49
|
+
return f.toFixed(3) + "fr " + VR_ID_W + "px " + (1 - f).toFixed(3) + "fr " + VR_COV_W + "px";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function el(tag, attrs, text) {
|
|
53
|
+
const d = document.createElement(tag);
|
|
54
|
+
if (attrs) for (const k in attrs) {
|
|
55
|
+
if (k === "class") d.className = attrs[k];
|
|
56
|
+
else if (k === "dataset") for (const dk in attrs[k]) d.dataset[dk] = attrs[k][dk];
|
|
57
|
+
else d.setAttribute(k, attrs[k]);
|
|
58
|
+
}
|
|
59
|
+
if (text != null) d.textContent = text;
|
|
60
|
+
return d;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- PURE: assemble the document model -----------------------------------
|
|
64
|
+
// With source sections → mode "doc": an ordered list of sections, each with
|
|
65
|
+
// its prose + the requirement(s) sliced from it (gap when none). Without
|
|
66
|
+
// source → mode "list": the requirement chain alone (sectionPath order).
|
|
67
|
+
// Incoming trace links are resolved to ticket summaries so they're clickable.
|
|
68
|
+
function buildDocumentModel(snapshot, moduleId, sections) {
|
|
69
|
+
const reqs = (moduleId && core.tickets) ? core.tickets.requirementsInModule(snapshot, moduleId) : [];
|
|
70
|
+
const cov = coreGraph.traceCoverage(snapshot, moduleId ? { moduleId } : {});
|
|
71
|
+
const statusById = new Map(cov.requirements.map(r => [r.id, r]));
|
|
72
|
+
const byId = new Map((snapshot.tickets || []).map(t => [t.id, t]));
|
|
73
|
+
const resolve = (ids) => (ids || []).map(id => {
|
|
74
|
+
const t = byId.get(id);
|
|
75
|
+
return t ? { id: t.id, ticketKey: t.ticketKey, title: t.title, type: t.type, status: t.status }
|
|
76
|
+
: { id: id, ticketKey: id, title: "", type: "", status: "" };
|
|
77
|
+
});
|
|
78
|
+
const requirements = reqs.map(r => {
|
|
79
|
+
const c = statusById.get(r.id) || { status: "orphan", realisedBy: [], testedBy: [] };
|
|
80
|
+
return {
|
|
81
|
+
id: r.id, ticketKey: r.ticketKey, sectionPath: r.sectionPath, title: r.title,
|
|
82
|
+
status: c.status, realisedBy: resolve(c.realisedBy), testedBy: resolve(c.testedBy),
|
|
83
|
+
anchorSectionId: (r.sourceAnchor && r.sourceAnchor.sectionId) || ""
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
// Group requirements under their document section by `sectionPath` — that is
|
|
87
|
+
// the field the section rows are matched on below. This is what makes the
|
|
88
|
+
// two-stage flow work (SM-207): one coarse slice → n atomic requirements, all
|
|
89
|
+
// sharing the slice's sectionPath but each carrying its own source char-span
|
|
90
|
+
// (sourceAnchor). Grouping on the anchor's sectionId instead would scatter
|
|
91
|
+
// those n requirements off their section whenever sectionId ≠ sectionPath,
|
|
92
|
+
// making them vanish from the doc view. Fall back to anchorSectionId only
|
|
93
|
+
// when a requirement has no sectionPath at all.
|
|
94
|
+
const bySection = new Map();
|
|
95
|
+
for (const r of requirements) {
|
|
96
|
+
const key = r.sectionPath || r.anchorSectionId;
|
|
97
|
+
if (!bySection.has(key)) bySection.set(key, []);
|
|
98
|
+
bySection.get(key).push(r);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (sections && sections.length) {
|
|
102
|
+
let sliced = 0, gaps = 0;
|
|
103
|
+
const secRows = sections.map(s => {
|
|
104
|
+
const rs = bySection.get(s.sectionPath) || [];
|
|
105
|
+
if (rs.length) sliced++; else gaps++;
|
|
106
|
+
return { sectionPath: s.sectionPath, level: s.level || 1, heading: s.heading || "(preamble)",
|
|
107
|
+
body: s.body || "", gap: rs.length === 0, requirements: rs };
|
|
108
|
+
});
|
|
109
|
+
return { mode: "doc", sections: secRows, summary: cov.summary,
|
|
110
|
+
slicing: { sections: sections.length, sliced: sliced, gaps: gaps } };
|
|
111
|
+
}
|
|
112
|
+
return { mode: "list", requirements: requirements, summary: cov.summary, slicing: null };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function summaryChips(summary) {
|
|
116
|
+
const wrap = el("div", { class: "vr-summary" });
|
|
117
|
+
wrap.appendChild(el("span", { class: "vr-chip vr-chip-total" }, "Σ " + summary.total));
|
|
118
|
+
const map = { covered: summary.covered, "over-covered": summary.overCovered,
|
|
119
|
+
orphan: summary.orphan, suspect: summary.suspect };
|
|
120
|
+
for (const st of STATUSES) {
|
|
121
|
+
if (!map[st]) continue;
|
|
122
|
+
wrap.appendChild(el("span", { class: "vr-chip vr-badge-" + st }, st + " " + map[st]));
|
|
123
|
+
}
|
|
124
|
+
return wrap;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The expandable detail under a requirement: its traceability + actions.
|
|
128
|
+
function requirementDetail(req, handlers) {
|
|
129
|
+
const detail = el("div", { class: "vr-detail" });
|
|
130
|
+
const chipLine = (links, rel) => {
|
|
131
|
+
const line = el("div", { class: "vr-trace-line" });
|
|
132
|
+
line.appendChild(el("span", { class: "vr-trace-rel" }, rel));
|
|
133
|
+
for (const t of links) {
|
|
134
|
+
const chip = el("button", { class: "vr-trace-chip", type: "button", title: "Open " + t.ticketKey });
|
|
135
|
+
chip.appendChild(el("span", { class: "vr-trace-key" }, t.ticketKey));
|
|
136
|
+
if (t.title) chip.appendChild(el("span", { class: "vr-trace-title" }, t.title));
|
|
137
|
+
chip.addEventListener("click", (ev) => { ev.stopPropagation(); if (handlers.onOpenTicket) handlers.onOpenTicket(t.id); });
|
|
138
|
+
line.appendChild(chip);
|
|
139
|
+
}
|
|
140
|
+
return line;
|
|
141
|
+
};
|
|
142
|
+
if (req.realisedBy.length) detail.appendChild(chipLine(req.realisedBy, "realised by"));
|
|
143
|
+
if (req.testedBy.length) detail.appendChild(chipLine(req.testedBy, "tested by"));
|
|
144
|
+
if (req.status === "orphan" && handlers.onCreateImplementer) {
|
|
145
|
+
const act = el("button", { class: "vr-action", type: "button" }, "+ implementing ticket");
|
|
146
|
+
act.addEventListener("click", (ev) => { ev.stopPropagation(); handlers.onCreateImplementer(req); });
|
|
147
|
+
detail.appendChild(act);
|
|
148
|
+
}
|
|
149
|
+
if (req.status === "suspect" && handlers.onCreateImplementer) {
|
|
150
|
+
const act = el("button", { class: "vr-action", type: "button" }, "+ implementing ticket");
|
|
151
|
+
act.addEventListener("click", (ev) => { ev.stopPropagation(); handlers.onCreateImplementer(req); });
|
|
152
|
+
detail.appendChild(act);
|
|
153
|
+
}
|
|
154
|
+
return detail;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- DOM: the multi-column document --------------------------------------
|
|
158
|
+
function renderDoc(container, model, handlers) {
|
|
159
|
+
container.innerHTML = "";
|
|
160
|
+
container.appendChild(summaryChips(model.summary));
|
|
161
|
+
|
|
162
|
+
const grid = el("div", { class: "vr-grid vr-grid-" + model.mode });
|
|
163
|
+
container.appendChild(grid);
|
|
164
|
+
|
|
165
|
+
const th = (txt) => el("div", { class: "vr-cell vr-th" }, txt);
|
|
166
|
+
if (model.mode === "doc") {
|
|
167
|
+
// Source header carries a draggable divider that re-splits Source vs
|
|
168
|
+
// Requirement (the two flexible columns). The chosen ratio persists.
|
|
169
|
+
const srcTh = th("Source"); srcTh.classList.add("vr-th-resizable");
|
|
170
|
+
const resizer = el("div", { class: "vr-resizer", title: "Drag to resize" });
|
|
171
|
+
srcTh.appendChild(resizer);
|
|
172
|
+
grid.appendChild(srcTh);
|
|
173
|
+
grid.appendChild(th("ID")); grid.appendChild(th("Requirement")); grid.appendChild(th("Coverage"));
|
|
174
|
+
grid.style.gridTemplateColumns = docGridCols(readSourceFrac());
|
|
175
|
+
let dragging = false;
|
|
176
|
+
const onMove = (ev) => {
|
|
177
|
+
if (!dragging) return;
|
|
178
|
+
const rect = grid.getBoundingClientRect();
|
|
179
|
+
const flexible = rect.width - VR_ID_W - VR_COV_W;
|
|
180
|
+
if (flexible <= 0) return;
|
|
181
|
+
const frac = clampFrac((ev.clientX - rect.left) / flexible);
|
|
182
|
+
grid.style.gridTemplateColumns = docGridCols(frac);
|
|
183
|
+
grid._vrFrac = frac;
|
|
184
|
+
};
|
|
185
|
+
const onUp = () => {
|
|
186
|
+
if (!dragging) return; dragging = false;
|
|
187
|
+
document.removeEventListener("pointermove", onMove);
|
|
188
|
+
document.removeEventListener("pointerup", onUp);
|
|
189
|
+
if (typeof grid._vrFrac === "number") writeSourceFrac(grid._vrFrac);
|
|
190
|
+
};
|
|
191
|
+
resizer.addEventListener("pointerdown", (ev) => {
|
|
192
|
+
ev.preventDefault(); ev.stopPropagation(); dragging = true;
|
|
193
|
+
document.addEventListener("pointermove", onMove);
|
|
194
|
+
document.addEventListener("pointerup", onUp);
|
|
195
|
+
});
|
|
196
|
+
} else {
|
|
197
|
+
grid.appendChild(th("ID")); grid.appendChild(th("Requirement")); grid.appendChild(th("Coverage"));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const reqCells = (r, startCls) => {
|
|
201
|
+
grid.appendChild(el("div", { class: "vr-cell vr-cell-id " + startCls }, r.ticketKey));
|
|
202
|
+
const reqCell = el("div", { class: "vr-cell vr-cell-req " + startCls });
|
|
203
|
+
// The requirement statement is editable in place; on commit it mirrors
|
|
204
|
+
// straight into its requirement TICKET (the requirement IS a ticket).
|
|
205
|
+
const textEl = el("div", { class: "vr-req-text", contenteditable: "true", spellcheck: "false" }, r.title);
|
|
206
|
+
textEl.addEventListener("mousedown", (ev) => ev.stopPropagation()); // don't toggle detail; place caret
|
|
207
|
+
textEl.addEventListener("keydown", (ev) => {
|
|
208
|
+
if (ev.key === "Enter") { ev.preventDefault(); textEl.blur(); }
|
|
209
|
+
if (ev.key === "Escape") { textEl.textContent = r.title; textEl.blur(); }
|
|
210
|
+
});
|
|
211
|
+
textEl.addEventListener("blur", () => {
|
|
212
|
+
const v = textEl.textContent.replace(/\s+/g, " ").trim();
|
|
213
|
+
if (v && v !== r.title && handlers.onEditRequirement) handlers.onEditRequirement(r.id, v);
|
|
214
|
+
else if (!v) textEl.textContent = r.title; // don't allow empty
|
|
215
|
+
});
|
|
216
|
+
reqCell.appendChild(textEl);
|
|
217
|
+
const detail = requirementDetail(r, handlers);
|
|
218
|
+
detail.style.display = "none";
|
|
219
|
+
reqCell.appendChild(detail);
|
|
220
|
+
grid.appendChild(reqCell);
|
|
221
|
+
// Coverage badge cell doubles as the expand/collapse control for the
|
|
222
|
+
// traceability detail (clicking the editable text must NOT toggle).
|
|
223
|
+
const cov = el("div", { class: "vr-cell vr-cell-cov vr-clickable " + startCls });
|
|
224
|
+
cov.appendChild(el("span", { class: "vr-badge vr-badge-" + r.status }, r.status));
|
|
225
|
+
cov.addEventListener("click", () => {
|
|
226
|
+
const open = detail.style.display === "none";
|
|
227
|
+
detail.style.display = open ? "" : "none";
|
|
228
|
+
reqCell.classList.toggle("vr-open", open);
|
|
229
|
+
});
|
|
230
|
+
grid.appendChild(cov);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
if (model.mode === "doc") {
|
|
234
|
+
if (!model.sections.length) {
|
|
235
|
+
grid.appendChild(el("div", { class: "vr-reqs-empty", style: "grid-column:1/-1" }, "Empty document."));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
for (const sec of model.sections) {
|
|
239
|
+
const lines = sec.requirements.length ? sec.requirements : [null];
|
|
240
|
+
lines.forEach((r, idx) => {
|
|
241
|
+
const startCls = idx === 0 ? "vr-section-start" : "";
|
|
242
|
+
// SOURCE cell — only on the first line of a section (heading + prose).
|
|
243
|
+
const src = el("div", { class: "vr-cell vr-cell-source " + startCls, dataset: { sectionPath: sec.sectionPath } });
|
|
244
|
+
if (idx === 0) {
|
|
245
|
+
const h = el("div", { class: "vr-h vr-h" + Math.min(6, sec.level) });
|
|
246
|
+
h.appendChild(el("span", { class: "vr-h-path" }, sec.sectionPath));
|
|
247
|
+
h.appendChild(el("span", { class: "vr-h-text" }, sec.heading));
|
|
248
|
+
src.appendChild(h);
|
|
249
|
+
if (sec.body) src.appendChild(el("div", { class: "vr-src-prose selectable" }, sec.body));
|
|
250
|
+
}
|
|
251
|
+
grid.appendChild(src);
|
|
252
|
+
if (r) {
|
|
253
|
+
reqCells(r, startCls);
|
|
254
|
+
} else {
|
|
255
|
+
// gap: no requirement extracted from this source section
|
|
256
|
+
grid.appendChild(el("div", { class: "vr-cell vr-cell-id " + startCls }, ""));
|
|
257
|
+
const gapCell = el("div", { class: "vr-cell vr-cell-req vr-gap-cell " + startCls });
|
|
258
|
+
gapCell.appendChild(el("span", { class: "vr-gap-text" }, "— no requirement —"));
|
|
259
|
+
if (handlers.onCreateRequirement) {
|
|
260
|
+
const act = el("button", { class: "vr-action", type: "button" }, "+ requirement");
|
|
261
|
+
act.addEventListener("click", () => handlers.onCreateRequirement(sec));
|
|
262
|
+
gapCell.appendChild(act);
|
|
263
|
+
}
|
|
264
|
+
grid.appendChild(gapCell);
|
|
265
|
+
const cov = el("div", { class: "vr-cell vr-cell-cov " + startCls });
|
|
266
|
+
cov.appendChild(el("span", { class: "vr-badge vr-badge-orphan" }, "gap"));
|
|
267
|
+
grid.appendChild(cov);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
} else {
|
|
272
|
+
if (!model.requirements.length) {
|
|
273
|
+
grid.appendChild(el("div", { class: "vr-reqs-empty", style: "grid-column:1/-1" },
|
|
274
|
+
handlers.emptyNote || "No requirements in this module yet."));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
for (const r of model.requirements) reqCells(r, "vr-section-start");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- mount ---------------------------------------------------------------
|
|
282
|
+
function mount(host, store, ctx) {
|
|
283
|
+
ctx = ctx || {};
|
|
284
|
+
const rootEl = el("div", { class: "vr-root" });
|
|
285
|
+
host.appendChild(rootEl);
|
|
286
|
+
|
|
287
|
+
const state = { moduleId: null, sectionsByModule: {}, sourceErr: null, fetching: {} };
|
|
288
|
+
|
|
289
|
+
function render() {
|
|
290
|
+
// Focus-guard: never stomp an in-progress inline edit (a contentEditable
|
|
291
|
+
// requirement statement). Defer the re-render until the field blurs.
|
|
292
|
+
const ae = typeof document !== "undefined" && document.activeElement;
|
|
293
|
+
if (ae && rootEl.contains(ae) && ae.isContentEditable) return;
|
|
294
|
+
const snap = store.get();
|
|
295
|
+
const modules = (core.tickets ? core.tickets.specModules(snap) : []);
|
|
296
|
+
rootEl.innerHTML = "";
|
|
297
|
+
|
|
298
|
+
const bar = el("div", { class: "vr-bar" });
|
|
299
|
+
bar.appendChild(el("span", { class: "vr-title" }, "Requirements"));
|
|
300
|
+
rootEl.appendChild(bar);
|
|
301
|
+
|
|
302
|
+
if (!modules.length) {
|
|
303
|
+
rootEl.appendChild(el("div", { class: "vr-empty" },
|
|
304
|
+
"No spec modules yet. Upload a PRD attachment and decompose it into requirements (via the agent / MCP)."));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (!state.moduleId || !modules.some(m => m.id === state.moduleId)) state.moduleId = modules[0].id;
|
|
308
|
+
const mod = modules.find(m => m.id === state.moduleId);
|
|
309
|
+
|
|
310
|
+
const picker = el("select", { class: "vr-module-picker" });
|
|
311
|
+
for (const m of modules) {
|
|
312
|
+
const o = el("option", { value: m.id }, m.title || m.ticketKey);
|
|
313
|
+
if (m.id === state.moduleId) o.selected = true;
|
|
314
|
+
picker.appendChild(o);
|
|
315
|
+
}
|
|
316
|
+
picker.addEventListener("change", () => { state.moduleId = picker.value; render(); });
|
|
317
|
+
bar.appendChild(picker);
|
|
318
|
+
if (!mod.sourceAttachmentId) bar.appendChild(el("span", { class: "vr-note" }, "no source bound"));
|
|
319
|
+
else if (state.sourceErr) bar.appendChild(el("span", { class: "vr-note vr-note-warn" }, "source error: " + state.sourceErr));
|
|
320
|
+
else if (!state.sectionsByModule[mod.id]) bar.appendChild(el("span", { class: "vr-note" }, ctx.fetchIngest ? "loading source…" : "source needs server"));
|
|
321
|
+
|
|
322
|
+
const body = el("div", { class: "vr-body" });
|
|
323
|
+
rootEl.appendChild(body);
|
|
324
|
+
|
|
325
|
+
const sections = state.sectionsByModule[mod.id] || [];
|
|
326
|
+
const model = buildDocumentModel(snap, mod.id, sections);
|
|
327
|
+
if (model.slicing) {
|
|
328
|
+
const sl = model.slicing;
|
|
329
|
+
bar.appendChild(el("span", { class: "vr-note " + (sl.gaps ? "vr-note-warn" : "vr-note-ok") },
|
|
330
|
+
sl.gaps ? (sl.gaps + " of " + sl.sections + " sections un-sliced") : ("all " + sl.sections + " sections sliced")));
|
|
331
|
+
}
|
|
332
|
+
renderDoc(body, model, {
|
|
333
|
+
onOpenTicket: ctx.onOpenTicket,
|
|
334
|
+
onEditRequirement: ctx.onEditRequirement,
|
|
335
|
+
onCreateImplementer: ctx.onCreateImplementer ? (req) => ctx.onCreateImplementer(req) : null,
|
|
336
|
+
onCreateRequirement: ctx.onCreateRequirement ? (sec) => ctx.onCreateRequirement(mod.id, sec) : null
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
if (mod.sourceAttachmentId && ctx.fetchIngest
|
|
340
|
+
&& !state.sectionsByModule[mod.id] && !state.fetching[mod.id]) {
|
|
341
|
+
state.fetching[mod.id] = true;
|
|
342
|
+
state.sourceErr = null;
|
|
343
|
+
ctx.fetchIngest(mod.sourceAttachmentId).then(res => {
|
|
344
|
+
state.sectionsByModule[mod.id] = (res && res.sections) || [];
|
|
345
|
+
state.fetching[mod.id] = false;
|
|
346
|
+
if (state.moduleId === mod.id) render();
|
|
347
|
+
}).catch(e => {
|
|
348
|
+
state.fetching[mod.id] = false;
|
|
349
|
+
state.sourceErr = (e && e.message) || "ingest failed";
|
|
350
|
+
if (state.moduleId === mod.id) render();
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const unsub = store.subscribe ? store.subscribe(() => render()) : null;
|
|
356
|
+
render();
|
|
357
|
+
return { unmount() { if (typeof unsub === "function") unsub(); host.innerHTML = ""; } };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return { mount: mount, buildDocumentModel: buildDocumentModel, docGridCols: docGridCols, clampFrac: clampFrac };
|
|
361
|
+
}));
|