@milehimikey/em 1.20.0 → 1.21.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/.claude/skills/event-modeling/SKILL.md +1 -1
- package/.claude/skills/event-modeling-conform/SKILL.md +1 -1
- package/.claude/skills/event-modeling-design/SKILL.md +23 -8
- package/.claude/skills/event-modeling-discover/SKILL.md +2 -2
- package/.claude/skills/event-modeling-discover/reference/extract.md +5 -1
- package/.claude/skills/event-modeling-engagement/SKILL.md +1 -1
- package/.claude/skills/event-modeling-implement/SKILL.md +1 -1
- package/.claude/skills/event-modeling-review/SKILL.md +19 -7
- package/.claude/skills/event-modeling-shared/reference/em-dsl.md +35 -3
- package/.claude/skills/event-modeling-shared/reference/methodology.md +35 -2
- package/.claude/skills/event-modeling-shared/reference/slice-doc-schema.md +2 -1
- package/.claude/skills/event-modeling-shared/templates/model-readme.md +10 -2
- package/.claude/skills/event-modeling-shared/templates/slice.md +7 -1
- package/.claude-plugin/marketplace.json +2 -2
- package/dist/catalog/build.js +6 -5
- package/dist/catalog/build.js.map +1 -1
- package/dist/catalog/coverage.d.ts +3 -0
- package/dist/catalog/coverage.js +12 -0
- package/dist/catalog/coverage.js.map +1 -1
- package/dist/catalog/docLocate.d.ts +17 -0
- package/dist/catalog/docLocate.js +97 -0
- package/dist/catalog/docLocate.js.map +1 -0
- package/dist/catalog/frontmatterCoherenceValidate.js +16 -1
- package/dist/catalog/frontmatterCoherenceValidate.js.map +1 -1
- package/dist/catalog/orphanedSliceDocValidate.js +15 -10
- package/dist/catalog/orphanedSliceDocValidate.js.map +1 -1
- package/dist/catalog/readSliceDoc.js +5 -4
- package/dist/catalog/readSliceDoc.js.map +1 -1
- package/dist/catalog/scenarioQualityValidate.js +2 -2
- package/dist/catalog/scenarioQualityValidate.js.map +1 -1
- package/dist/catalog/sliceDoc.d.ts +8 -0
- package/dist/catalog/sliceDoc.js +9 -0
- package/dist/catalog/sliceDoc.js.map +1 -1
- package/dist/catalog/sliceSections.d.ts +18 -1
- package/dist/catalog/sliceSections.js +38 -4
- package/dist/catalog/sliceSections.js.map +1 -1
- package/dist/cli/defer.js +3 -2
- package/dist/cli/defer.js.map +1 -1
- package/dist/cli/ledgerCheck.js +11 -8
- package/dist/cli/ledgerCheck.js.map +1 -1
- package/dist/cli/markImplemented.js +2 -2
- package/dist/cli/markImplemented.js.map +1 -1
- package/dist/cli/ratify.js +2 -2
- package/dist/cli/ratify.js.map +1 -1
- package/dist/cli/refsCheck.js +3 -2
- package/dist/cli/refsCheck.js.map +1 -1
- package/dist/cli/render-inputs.d.ts +1 -1
- package/dist/cli/render-inputs.js +3 -2
- package/dist/cli/render-inputs.js.map +1 -1
- package/dist/cli/review.js +2 -2
- package/dist/cli/review.js.map +1 -1
- package/dist/cli/revise.js +2 -2
- package/dist/cli/revise.js.map +1 -1
- package/dist/cli/skillDirs.d.ts +22 -0
- package/dist/cli/skillDirs.js +24 -0
- package/dist/cli/skillDirs.js.map +1 -1
- package/dist/cli/sliceConform.js +2 -1
- package/dist/cli/sliceConform.js.map +1 -1
- package/dist/cli/sliceIndex.d.ts +19 -3
- package/dist/cli/sliceIndex.js +81 -17
- package/dist/cli/sliceIndex.js.map +1 -1
- package/dist/cli/sliceNew.js +2 -0
- package/dist/cli/sliceNew.js.map +1 -1
- package/dist/cli/sliceStubAll.js +7 -2
- package/dist/cli/sliceStubAll.js.map +1 -1
- package/dist/cli/sliceSync.d.ts +12 -0
- package/dist/cli/sliceSync.js +42 -4
- package/dist/cli/sliceSync.js.map +1 -1
- package/dist/cli/status.d.ts +3 -4
- package/dist/cli/status.js +5 -4
- package/dist/cli/status.js.map +1 -1
- package/dist/cli/storylineSplit.d.ts +18 -0
- package/dist/cli/storylineSplit.js +110 -0
- package/dist/cli/storylineSplit.js.map +1 -0
- package/dist/cli/upgrade.js +39 -14
- package/dist/cli/upgrade.js.map +1 -1
- package/dist/cli/watchStoryline.d.ts +6 -0
- package/dist/cli/watchStoryline.js +10 -0
- package/dist/cli/watchStoryline.js.map +1 -1
- package/dist/cli.js +265 -67
- package/dist/cli.js.map +1 -1
- package/dist/emit/json.d.ts +22 -1
- package/dist/emit/json.js +12 -1
- package/dist/emit/json.js.map +1 -1
- package/dist/mcp/server.js +20 -6
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/edges.d.ts +1 -1
- package/dist/model/edges.js +8 -1
- package/dist/model/edges.js.map +1 -1
- package/dist/model/loadModel.d.ts +38 -0
- package/dist/model/loadModel.js +122 -0
- package/dist/model/loadModel.js.map +1 -0
- package/dist/model/model.d.ts +9 -4
- package/dist/model/model.js +4 -0
- package/dist/model/model.js.map +1 -1
- package/dist/model/rules.d.ts +23 -0
- package/dist/model/rules.js +23 -0
- package/dist/model/rules.js.map +1 -1
- package/dist/model/shapeLint.js +95 -0
- package/dist/model/shapeLint.js.map +1 -1
- package/dist/model/sliceDocIndex.js +13 -5
- package/dist/model/sliceDocIndex.js.map +1 -1
- package/dist/model/sliceDocPath.d.ts +19 -0
- package/dist/model/sliceDocPath.js +49 -0
- package/dist/model/sliceDocPath.js.map +1 -0
- package/dist/model/storylineMap.d.ts +20 -0
- package/dist/model/storylineMap.js +55 -0
- package/dist/model/storylineMap.js.map +1 -0
- package/dist/model/validate.d.ts +7 -0
- package/dist/model/validate.js +8 -0
- package/dist/model/validate.js.map +1 -1
- package/dist/parser/ast.d.ts +19 -0
- package/dist/parser/parser.d.ts +17 -1
- package/dist/parser/parser.js +91 -2
- package/dist/parser/parser.js.map +1 -1
- package/dist/pipeline.d.ts +16 -0
- package/dist/pipeline.js +19 -2
- package/dist/pipeline.js.map +1 -1
- package/dist/render/drawEdges.d.ts +5 -1
- package/dist/render/drawEdges.js +57 -4
- package/dist/render/drawEdges.js.map +1 -1
- package/dist/render/drawNotes.d.ts +4 -1
- package/dist/render/drawNotes.js +40 -10
- package/dist/render/drawNotes.js.map +1 -1
- package/dist/render/render.d.ts +11 -0
- package/dist/render/render.js +94 -11
- package/dist/render/render.js.map +1 -1
- package/dist/render/serve.d.ts +5 -0
- package/dist/render/serve.js +18 -2
- package/dist/render/serve.js.map +1 -1
- package/dist/render/sliceDiagram.js +3 -0
- package/dist/render/sliceDiagram.js.map +1 -1
- package/dist/render/sliceOverlay.d.ts +4 -5
- package/dist/render/sliceOverlay.js +4 -7
- package/dist/render/sliceOverlay.js.map +1 -1
- package/dist/render/sliceStatus.d.ts +8 -0
- package/dist/render/sliceStatus.js +22 -15
- package/dist/render/sliceStatus.js.map +1 -1
- package/dist/render/storylineBands.d.ts +0 -3
- package/dist/render/storylineBands.js +2 -22
- package/dist/render/storylineBands.js.map +1 -1
- package/dist/render/storylineDiagram.js +17 -69
- package/dist/render/storylineDiagram.js.map +1 -1
- package/dist/render/storylineMap.d.ts +18 -0
- package/dist/render/storylineMap.js +263 -0
- package/dist/render/storylineMap.js.map +1 -0
- package/dist/render/svgGeometry.d.ts +9 -0
- package/dist/render/svgGeometry.js +16 -0
- package/dist/render/svgGeometry.js.map +1 -1
- package/dist/render/viewerHtml.js +1 -1
- package/dist/render/viewerHtml.js.map +1 -1
- package/dist/templates.d.ts +1 -1
- package/dist/templates.js +10 -2
- package/dist/templates.js.map +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/shared/reference/em-dsl.md +35 -3
- package/plugin/shared/reference/methodology.md +35 -2
- package/plugin/shared/reference/slice-doc-schema.md +2 -1
- package/plugin/shared/templates/model-readme.md +10 -2
- package/plugin/shared/templates/slice.md +7 -1
- package/plugin/skills/conform/SKILL.md +1 -1
- package/plugin/skills/design/SKILL.md +23 -8
- package/plugin/skills/discover/SKILL.md +2 -2
- package/plugin/skills/discover/reference/extract.md +5 -1
- package/plugin/skills/engagement/SKILL.md +1 -1
- package/plugin/skills/event-modeling/SKILL.md +1 -1
- package/plugin/skills/implement/SKILL.md +1 -1
- package/plugin/skills/review/SKILL.md +19 -7
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// The storyline canvas (`em render --map`, owner ruling 2026-11-03): one self-contained HTML page
|
|
3
|
+
// with every storyline's real diagram in a draggable frame and the flow between storylines drawn
|
|
4
|
+
// as connectors between frames. No server, no external assets: open `storylines/map.html` from
|
|
5
|
+
// disk. Frame positions are remembered per model in the browser's localStorage ("Reset layout"
|
|
6
|
+
// forgets them); the first layout places root storylines left and every `after` storyline one
|
|
7
|
+
// column to the right of what it follows.
|
|
8
|
+
//
|
|
9
|
+
// GH #422: `em watch --serve` serves the same page live at /map (src/render/serve.ts) — built by
|
|
10
|
+
// this one function with `live: true`: each frame's open link drills into that storyline's own
|
|
11
|
+
// subset in the viewer (`/?svg=<frame.href>`), and the page listens on the server's /__events
|
|
12
|
+
// stream and reloads itself after every successful render (positions and camera survive the
|
|
13
|
+
// reload: they live in localStorage). No second canvas implementation.
|
|
14
|
+
/** GH #422: the served canvas follows the same /__events stream the viewer does (serve.ts) — a
|
|
15
|
+
* successful render reloads the page (positions and camera are in localStorage, so the layout
|
|
16
|
+
* holds); a failed render lands in the bar without touching the frames on screen. */
|
|
17
|
+
const LIVE_SCRIPT = `
|
|
18
|
+
// --- live (em watch --serve): reload on the server's push, keep the last good canvas on an error
|
|
19
|
+
const liveEl = document.getElementById("live"), errEl = document.getElementById("liveError");
|
|
20
|
+
const es = new EventSource("/__events");
|
|
21
|
+
es.onopen = () => { liveEl.textContent = "live"; };
|
|
22
|
+
es.onerror = () => { liveEl.textContent = "reconnecting…"; };
|
|
23
|
+
es.onmessage = () => { location.reload(); };
|
|
24
|
+
es.addEventListener("renderError", (e) => { errEl.textContent = e.data; errEl.hidden = false; errEl.title = e.data; });
|
|
25
|
+
`;
|
|
26
|
+
function esc(s) {
|
|
27
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
28
|
+
}
|
|
29
|
+
export function buildStorylineMapHtml(model, frames, map, opts = {}) {
|
|
30
|
+
const title = `${model.name} — storylines`;
|
|
31
|
+
const live = opts.live === true;
|
|
32
|
+
const data = {
|
|
33
|
+
model: model.name,
|
|
34
|
+
nodes: map.nodes.map((n) => ({ name: n.name, slug: n.slug, after: n.after })),
|
|
35
|
+
edges: map.edges.map((e) => ({
|
|
36
|
+
from: e.from,
|
|
37
|
+
to: e.to,
|
|
38
|
+
kind: e.kind,
|
|
39
|
+
labels: e.elementIds.map((id) => model.byId.get(id)?.name ?? id),
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
42
|
+
const frameHtml = frames
|
|
43
|
+
.map((f) => `<section class="frame" data-name="${esc(f.name)}" data-slug="${esc(f.slug)}">` +
|
|
44
|
+
`<header><span class="title">${esc(f.name)}</span><span class="meta">${f.sliceCount} slice${f.sliceCount === 1 ? "" : "s"}</span>` +
|
|
45
|
+
`<a class="open" href="${esc(f.href ?? `./${f.slug}.svg`)}"${live ? "" : ' target="_blank"'} title="${live ? "open this storyline in the live viewer" : "open this storyline's diagram"}">${live ? "open ›" : "↗"}</a></header>` +
|
|
46
|
+
`<div class="body">${f.svg}</div></section>`)
|
|
47
|
+
.join("\n");
|
|
48
|
+
// `</script>` can't appear in the JSON (names are model text; the escape keeps it safe anyway).
|
|
49
|
+
const json = JSON.stringify(data).replace(/<\//g, "<\\/");
|
|
50
|
+
return `<!doctype html>
|
|
51
|
+
<html lang="en">
|
|
52
|
+
<head>
|
|
53
|
+
<meta charset="utf-8" />
|
|
54
|
+
<title>${esc(title)}</title>
|
|
55
|
+
<style>
|
|
56
|
+
:root { color-scheme: light; }
|
|
57
|
+
html, body { height: 100%; margin: 0; }
|
|
58
|
+
body { font-family: system-ui, sans-serif; background: #f3f4f6; overflow: hidden; }
|
|
59
|
+
header.bar { position: fixed; top: 0; left: 0; right: 0; height: 40px; display: flex; align-items: center; gap: .75rem;
|
|
60
|
+
padding: 0 .9rem; background: #1f2933; color: #fff; font-size: 14px; z-index: 10; }
|
|
61
|
+
header.bar .title { font-weight: 600; }
|
|
62
|
+
header.bar .hint { opacity: .7; margin-left: auto; }
|
|
63
|
+
header.bar button { background: #334155; color: #fff; border: 0; border-radius: 4px; padding: .25rem .6rem; cursor: pointer; font-size: 13px; }
|
|
64
|
+
header.bar button:hover { background: #475569; }
|
|
65
|
+
header.bar .live { opacity: .7; }
|
|
66
|
+
header.bar .error { background: #b91c1c; color: #fff; padding: .2rem .6rem; border-radius: 4px; max-width: 40%;
|
|
67
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
68
|
+
header.bar .error[hidden] { display: none; }
|
|
69
|
+
#viewport { position: absolute; inset: 40px 0 0 0; overflow: hidden; cursor: grab; }
|
|
70
|
+
#viewport.panning { cursor: grabbing; }
|
|
71
|
+
#world { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
|
|
72
|
+
svg.links { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
|
|
73
|
+
svg.links text { font-size: 12px; font-family: system-ui, sans-serif; }
|
|
74
|
+
.frame { position: absolute; background: #fff; border: 2px solid #4a6fb5; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.12);
|
|
75
|
+
display: flex; flex-direction: column; overflow: hidden; }
|
|
76
|
+
.frame header { display: flex; align-items: center; gap: .6rem; padding: .35rem .6rem; background: #dbe7fb; color: #1e3a8a;
|
|
77
|
+
font-size: 13px; cursor: move; user-select: none; }
|
|
78
|
+
.frame header .title { font-weight: 700; }
|
|
79
|
+
.frame header .meta { opacity: .75; }
|
|
80
|
+
.frame header .open { margin-left: auto; color: #1e3a8a; text-decoration: none; font-weight: 700; cursor: pointer; }
|
|
81
|
+
.frame .body { flex: 1; min-height: 0; background: #fff; padding: 16px; box-sizing: border-box; }
|
|
82
|
+
.frame .body svg { width: 100%; height: 100%; display: block; }
|
|
83
|
+
.frame.dragging { box-shadow: 0 6px 24px rgba(0,0,0,.25); z-index: 5; }
|
|
84
|
+
</style>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
87
|
+
<header class="bar">
|
|
88
|
+
<span class="title">${esc(title)}</span>
|
|
89
|
+
<button id="fit" type="button">Fit</button>
|
|
90
|
+
<button id="reset" type="button">Reset layout</button>${live ? `\n <span class="live" id="live">live</span><span class="error" id="liveError" hidden></span>` : ""}
|
|
91
|
+
<span class="hint">drag a frame's title to move it · drag the background to pan · wheel to zoom${live ? " · click a storyline's “open ›” to step through it" : ""}</span>
|
|
92
|
+
</header>
|
|
93
|
+
<div id="viewport"><div id="world"><svg class="links" width="1" height="1">
|
|
94
|
+
<defs>
|
|
95
|
+
<marker id="m-after" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#4a6fb5"/></marker>
|
|
96
|
+
<marker id="m-reads" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="#b36b00"/></marker>
|
|
97
|
+
</defs>
|
|
98
|
+
<g id="link-paths"></g><g id="link-labels"></g>
|
|
99
|
+
</svg>
|
|
100
|
+
${frameHtml}
|
|
101
|
+
</div></div>
|
|
102
|
+
<script id="map-data" type="application/json">${json}</script>
|
|
103
|
+
<script>
|
|
104
|
+
(() => {
|
|
105
|
+
const DATA = JSON.parse(document.getElementById("map-data").textContent);
|
|
106
|
+
const KEY = "em-storyline-map:" + DATA.model;
|
|
107
|
+
const SCALE = 0.3; // diagram units -> frame pixels at zoom 1
|
|
108
|
+
const PAD = 16, HEAD = 34; // frame body padding (each side) and the title bar's height
|
|
109
|
+
const GAP_X = 160, GAP_Y = 90;
|
|
110
|
+
const viewport = document.getElementById("viewport");
|
|
111
|
+
const world = document.getElementById("world");
|
|
112
|
+
const links = world.querySelector("svg.links");
|
|
113
|
+
const paths = document.getElementById("link-paths");
|
|
114
|
+
const labels = document.getElementById("link-labels");
|
|
115
|
+
const frames = [...world.querySelectorAll(".frame")];
|
|
116
|
+
const byName = new Map(frames.map((f) => [f.dataset.name, f]));
|
|
117
|
+
|
|
118
|
+
// --- frame size from each diagram's own viewBox, so a 3-slice story is small and a 29-slice one is wide
|
|
119
|
+
for (const f of frames) {
|
|
120
|
+
const svg = f.querySelector("svg");
|
|
121
|
+
const vb = (svg && svg.getAttribute("viewBox") || "0 0 1200 600").split(/\\s+/).map(Number);
|
|
122
|
+
const w = Math.max(240, vb[2] * SCALE) + 2 * PAD, h = Math.max(120, vb[3] * SCALE) + 2 * PAD + HEAD;
|
|
123
|
+
f.style.width = w + "px"; f.style.height = h + "px";
|
|
124
|
+
if (svg) { svg.removeAttribute("width"); svg.removeAttribute("height"); svg.setAttribute("preserveAspectRatio", "xMinYMin meet"); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- state: positions + camera, remembered per model
|
|
128
|
+
let state = { pos: {}, cam: { x: 24, y: 24, s: 1 } };
|
|
129
|
+
try { const saved = JSON.parse(localStorage.getItem(KEY) || "null"); if (saved && saved.pos) state = saved; } catch {}
|
|
130
|
+
const save = () => { try { localStorage.setItem(KEY, JSON.stringify(state)); } catch {} };
|
|
131
|
+
|
|
132
|
+
function autoLayout() {
|
|
133
|
+
// column = depth along the after chain; rows stack within a column
|
|
134
|
+
const depth = new Map();
|
|
135
|
+
const nodeByName = new Map(DATA.nodes.map((n) => [n.name, n]));
|
|
136
|
+
const depthOf = (name, seen = new Set()) => {
|
|
137
|
+
if (depth.has(name)) return depth.get(name);
|
|
138
|
+
const n = nodeByName.get(name);
|
|
139
|
+
let d = 0;
|
|
140
|
+
if (n && n.after && nodeByName.has(n.after) && !seen.has(name)) { seen.add(name); d = depthOf(n.after, seen) + 1; }
|
|
141
|
+
depth.set(name, d); return d;
|
|
142
|
+
};
|
|
143
|
+
for (const n of DATA.nodes) depthOf(n.name);
|
|
144
|
+
const cols = new Map();
|
|
145
|
+
for (const n of DATA.nodes) { const d = depth.get(n.name) || 0; if (!cols.has(d)) cols.set(d, []); cols.get(d).push(n.name); }
|
|
146
|
+
let x = 0;
|
|
147
|
+
state.pos = {};
|
|
148
|
+
for (const d of [...cols.keys()].sort((a, b) => a - b)) {
|
|
149
|
+
let y = 0, colW = 0;
|
|
150
|
+
for (const name of cols.get(d)) {
|
|
151
|
+
const f = byName.get(name); if (!f) continue;
|
|
152
|
+
state.pos[f.dataset.slug] = { x, y };
|
|
153
|
+
y += f.offsetHeight + GAP_Y; colW = Math.max(colW, f.offsetWidth);
|
|
154
|
+
}
|
|
155
|
+
x += colW + GAP_X;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!frames.every((f) => state.pos[f.dataset.slug])) autoLayout();
|
|
159
|
+
|
|
160
|
+
function applyPositions() {
|
|
161
|
+
for (const f of frames) { const p = state.pos[f.dataset.slug] || { x: 0, y: 0 }; f.style.left = p.x + "px"; f.style.top = p.y + "px"; }
|
|
162
|
+
drawLinks();
|
|
163
|
+
}
|
|
164
|
+
function applyCamera() { world.style.transform = "translate(" + state.cam.x + "px," + state.cam.y + "px) scale(" + state.cam.s + ")"; }
|
|
165
|
+
|
|
166
|
+
// --- connectors: centre-to-centre line clipped to each frame's border; labels at the midpoint
|
|
167
|
+
function rectOf(f) { const p = state.pos[f.dataset.slug] || { x: 0, y: 0 }; return { x: p.x, y: p.y, w: f.offsetWidth, h: f.offsetHeight }; }
|
|
168
|
+
function exitPoint(r, tx, ty) {
|
|
169
|
+
const cx = r.x + r.w / 2, cy = r.y + r.h / 2, dx = tx - cx, dy = ty - cy;
|
|
170
|
+
if (dx === 0 && dy === 0) return { x: cx, y: cy };
|
|
171
|
+
const sx = dx !== 0 ? (r.w / 2) / Math.abs(dx) : Infinity, sy = dy !== 0 ? (r.h / 2) / Math.abs(dy) : Infinity;
|
|
172
|
+
const t = Math.min(sx, sy);
|
|
173
|
+
return { x: cx + dx * t, y: cy + dy * t };
|
|
174
|
+
}
|
|
175
|
+
function drawLinks() {
|
|
176
|
+
paths.innerHTML = ""; labels.innerHTML = "";
|
|
177
|
+
const NS = "http://www.w3.org/2000/svg";
|
|
178
|
+
for (const e of DATA.edges) {
|
|
179
|
+
const a = byName.get(e.from), b = byName.get(e.to); if (!a || !b) continue;
|
|
180
|
+
const ra = rectOf(a), rb = rectOf(b);
|
|
181
|
+
const bc = { x: rb.x + rb.w / 2, y: rb.y + rb.h / 2 }, ac = { x: ra.x + ra.w / 2, y: ra.y + ra.h / 2 };
|
|
182
|
+
const p1 = exitPoint(ra, bc.x, bc.y), p2 = exitPoint(rb, ac.x, ac.y);
|
|
183
|
+
const path = document.createElementNS(NS, "path");
|
|
184
|
+
path.setAttribute("d", "M" + p1.x + "," + p1.y + " L" + p2.x + "," + p2.y);
|
|
185
|
+
path.setAttribute("fill", "none");
|
|
186
|
+
path.setAttribute("stroke", e.kind === "after" ? "#4a6fb5" : "#b36b00");
|
|
187
|
+
path.setAttribute("stroke-width", e.kind === "after" ? "4" : "2");
|
|
188
|
+
if (e.kind !== "after") path.setAttribute("stroke-dasharray", "8 6");
|
|
189
|
+
path.setAttribute("marker-end", "url(#m-" + e.kind + ")");
|
|
190
|
+
paths.appendChild(path);
|
|
191
|
+
const mx = (p1.x + p2.x) / 2, my = (p1.y + p2.y) / 2;
|
|
192
|
+
const text = e.kind === "after" ? "after" : (e.labels.slice(0, 3).join(", ") + (e.labels.length > 3 ? " +" + (e.labels.length - 3) : ""));
|
|
193
|
+
const t = document.createElementNS(NS, "text");
|
|
194
|
+
t.setAttribute("x", mx); t.setAttribute("y", my - 6); t.setAttribute("text-anchor", "middle");
|
|
195
|
+
t.setAttribute("fill", e.kind === "after" ? "#4a6fb5" : "#7a4a00");
|
|
196
|
+
if (e.kind === "after") t.setAttribute("font-weight", "700");
|
|
197
|
+
t.setAttribute("paint-order", "stroke"); t.setAttribute("stroke", "#f3f4f6"); t.setAttribute("stroke-width", "4");
|
|
198
|
+
t.textContent = text;
|
|
199
|
+
labels.appendChild(t);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// --- interaction: drag frames by their header, pan the background, zoom with the wheel
|
|
204
|
+
let drag = null;
|
|
205
|
+
for (const f of frames) {
|
|
206
|
+
f.querySelector("header").addEventListener("pointerdown", (ev) => {
|
|
207
|
+
if (ev.target.closest("a")) return;
|
|
208
|
+
const p = state.pos[f.dataset.slug];
|
|
209
|
+
drag = { f, sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y };
|
|
210
|
+
f.classList.add("dragging"); f.setPointerCapture(ev.pointerId); ev.preventDefault(); ev.stopPropagation();
|
|
211
|
+
});
|
|
212
|
+
f.addEventListener("pointermove", (ev) => {
|
|
213
|
+
if (!drag || drag.f !== f) return;
|
|
214
|
+
state.pos[f.dataset.slug] = { x: drag.ox + (ev.clientX - drag.sx) / state.cam.s, y: drag.oy + (ev.clientY - drag.sy) / state.cam.s };
|
|
215
|
+
applyPositions();
|
|
216
|
+
});
|
|
217
|
+
const end = () => { if (drag && drag.f === f) { f.classList.remove("dragging"); drag = null; save(); } };
|
|
218
|
+
f.addEventListener("pointerup", end); f.addEventListener("pointercancel", end);
|
|
219
|
+
}
|
|
220
|
+
let pan = null;
|
|
221
|
+
viewport.addEventListener("pointerdown", (ev) => {
|
|
222
|
+
if (ev.target.closest(".frame")) return;
|
|
223
|
+
pan = { sx: ev.clientX, sy: ev.clientY, ox: state.cam.x, oy: state.cam.y };
|
|
224
|
+
viewport.classList.add("panning"); viewport.setPointerCapture(ev.pointerId);
|
|
225
|
+
});
|
|
226
|
+
viewport.addEventListener("pointermove", (ev) => {
|
|
227
|
+
if (!pan) return;
|
|
228
|
+
state.cam.x = pan.ox + (ev.clientX - pan.sx); state.cam.y = pan.oy + (ev.clientY - pan.sy); applyCamera();
|
|
229
|
+
});
|
|
230
|
+
const endPan = () => { if (pan) { pan = null; viewport.classList.remove("panning"); save(); } };
|
|
231
|
+
viewport.addEventListener("pointerup", endPan); viewport.addEventListener("pointercancel", endPan);
|
|
232
|
+
viewport.addEventListener("wheel", (ev) => {
|
|
233
|
+
ev.preventDefault();
|
|
234
|
+
const rect = viewport.getBoundingClientRect();
|
|
235
|
+
const mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
|
|
236
|
+
const factor = Math.exp(-ev.deltaY * 0.0015);
|
|
237
|
+
const s = Math.min(4, Math.max(0.05, state.cam.s * factor));
|
|
238
|
+
state.cam.x = mx - (mx - state.cam.x) * (s / state.cam.s);
|
|
239
|
+
state.cam.y = my - (my - state.cam.y) * (s / state.cam.s);
|
|
240
|
+
state.cam.s = s; applyCamera(); save();
|
|
241
|
+
}, { passive: false });
|
|
242
|
+
|
|
243
|
+
function fit() {
|
|
244
|
+
if (frames.length === 0) return;
|
|
245
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
246
|
+
for (const f of frames) { const r = rectOf(f); minX = Math.min(minX, r.x); minY = Math.min(minY, r.y); maxX = Math.max(maxX, r.x + r.w); maxY = Math.max(maxY, r.y + r.h); }
|
|
247
|
+
const vw = viewport.clientWidth, vh = viewport.clientHeight, pad = 32;
|
|
248
|
+
const s = Math.min(4, Math.max(0.05, Math.min((vw - 2 * pad) / (maxX - minX), (vh - 2 * pad) / (maxY - minY))));
|
|
249
|
+
state.cam = { s, x: pad - minX * s + ((vw - 2 * pad) - (maxX - minX) * s) / 2, y: pad - minY * s + ((vh - 2 * pad) - (maxY - minY) * s) / 2 };
|
|
250
|
+
applyCamera(); save();
|
|
251
|
+
}
|
|
252
|
+
document.getElementById("fit").addEventListener("click", fit);
|
|
253
|
+
document.getElementById("reset").addEventListener("click", () => { autoLayout(); applyPositions(); fit(); });
|
|
254
|
+
|
|
255
|
+
applyPositions();
|
|
256
|
+
if (!localStorage.getItem(KEY)) fit(); else applyCamera();
|
|
257
|
+
${live ? LIVE_SCRIPT : ""}})();
|
|
258
|
+
</script>
|
|
259
|
+
</body>
|
|
260
|
+
</html>
|
|
261
|
+
`;
|
|
262
|
+
}
|
|
263
|
+
//# sourceMappingURL=storylineMap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"storylineMap.js","sourceRoot":"","sources":["../../src/render/storylineMap.ts"],"names":[],"mappings":"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"}
|
|
@@ -10,6 +10,15 @@ export declare const NODE_GROUP: RegExp;
|
|
|
10
10
|
export declare const TITLE: RegExp;
|
|
11
11
|
/** Map each requested node id to its rectangle in SVG coordinates. */
|
|
12
12
|
export declare function parseNodeRects(svg: string, ids: Set<string>): Map<string, Rect>;
|
|
13
|
+
/** One `<text>` line inside a node group: its decoded text and baseline y (SVG user space). */
|
|
14
|
+
export interface TextRow {
|
|
15
|
+
text: string;
|
|
16
|
+
y: number;
|
|
17
|
+
}
|
|
18
|
+
/** The text lines of one node, top to bottom, with their baselines — how a box that lists
|
|
19
|
+
* alternatives (GH #278, one Graphviz HTML table per group) tells which row is which
|
|
20
|
+
* alternative, so an arrow or a marker can anchor to that alternative's own row (GH #324). */
|
|
21
|
+
export declare function parseNodeTextRows(svg: string, id: string): TextRow[];
|
|
13
22
|
/** A bounding box in the graph's own (post-transform) coordinate space. */
|
|
14
23
|
export interface Box {
|
|
15
24
|
minX: number;
|
|
@@ -40,6 +40,22 @@ export function parseNodeRects(svg, ids) {
|
|
|
40
40
|
}
|
|
41
41
|
return rects;
|
|
42
42
|
}
|
|
43
|
+
const TEXT = /<text\b[^>]*\by="(-?[\d.]+)"[^>]*>([\s\S]*?)<\/text>/g;
|
|
44
|
+
/** The text lines of one node, top to bottom, with their baselines — how a box that lists
|
|
45
|
+
* alternatives (GH #278, one Graphviz HTML table per group) tells which row is which
|
|
46
|
+
* alternative, so an arrow or a marker can anchor to that alternative's own row (GH #324). */
|
|
47
|
+
export function parseNodeTextRows(svg, id) {
|
|
48
|
+
for (const m of svg.matchAll(NODE_GROUP)) {
|
|
49
|
+
const body = m[1];
|
|
50
|
+
if (decode(TITLE.exec(body)?.[1] ?? "") !== id)
|
|
51
|
+
continue;
|
|
52
|
+
const rows = [];
|
|
53
|
+
for (const t of body.matchAll(TEXT))
|
|
54
|
+
rows.push({ text: decode(t[2]).trim(), y: Number(t[1]) });
|
|
55
|
+
return rows.sort((a, b) => a.y - b.y);
|
|
56
|
+
}
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
43
59
|
/**
|
|
44
60
|
* Graphviz wraps the graph in `transform="scale(sx sy) rotate(0) translate(tx ty)"`,
|
|
45
61
|
* applied right to left, so a graph-space point lands at `scale * (p + translate)`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"svgGeometry.js","sourceRoot":"","sources":["../../src/render/svgGeometry.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"svgGeometry.js","sourceRoot":"","sources":["../../src/render/svgGeometry.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,4DAA4D;AAC5D,EAAE;AACF,iFAAiF;AACjF,iFAAiF;AACjF,gFAAgF;AAChF,sBAAsB;AAWtB,6EAA6E;AAC7E,4EAA4E;AAC5E,+CAA+C;AAC/C,MAAM,CAAC,MAAM,UAAU,GAAG,6CAA6C,CAAC;AACxE,MAAM,CAAC,MAAM,KAAK,GAAG,4BAA4B,CAAC;AAClD,MAAM,KAAK,GAAG,kDAAkD,CAAC;AACjE,MAAM,GAAG,GAAG,0BAA0B,CAAC;AAEvC,sEAAsE;AACtE,MAAM,UAAU,cAAc,CAAC,GAAW,EAAE,GAAgB;IAC1D,MAAM,KAAK,GAAG,IAAI,GAAG,EAAgB,CAAC;IACtC,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC;QACzC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QAClB,MAAM,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;QAC/C,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YAAE,SAAS;QAC3B,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC/B,IAAI,CAAC,KAAK;YAAE,SAAS;QACrB,MAAM,IAAI,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QACrD,MAAM,EAAE,GAAa,EAAE,CAAC;QACxB,MAAM,EAAE,GAAa,EAAE,CAAC;QACxB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;YAC5C,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;YACjB,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACvB,CAAC;QACD,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QAC9B,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;QAC7B,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;QAC9B,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;QAC5B,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,CAAC;QAC/B,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;IAC9F,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAQD,MAAM,IAAI,GAAG,uDAAuD,CAAC;AAErE;;+FAE+F;AAC/F,MAAM,UAAU,iBAAiB,CAAC,GAAW,EAAE,EAAU;IACvD,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC;QACzC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QAClB,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,KAAK,EAAE;YAAE,SAAS;QACzD,MAAM,IAAI,GAAc,EAAE,CAAC;QAC3B,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC;YAAE,IAAI,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;QAC/F,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;IACxC,CAAC;IACD,OAAO,EAAE,CAAC;AACZ,CAAC;AAsBD;;;;;;;;;GASG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC5C,MAAM,EAAE,GAAG,gDAAgD,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IACjF,MAAM,EAAE,GAAG,yCAAyC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAC9D,MAAM,EAAE,GAAG,6CAA6C,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IAClE,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAChD,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAChD,OAAO,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC;AACxF,CAAC;AAED,MAAM,OAAO,GAAG,EAAE,CAAC,CAAC,8DAA8D;AAElF;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,GAAW,EAAE,GAAe;IACpD,IAAI,CAAC,GAAG;QAAE,OAAO,GAAG,CAAC;IACrB,MAAM,EAAE,GAAG,qEAAqE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC3F,MAAM,GAAG,GAAG,wBAAwB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC/C,MAAM,GAAG,GAAG,yBAAyB,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAChD,IAAI,CAAC,EAAE,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG;QAAE,OAAO,GAAG,CAAC,CAAC,uCAAuC;IAE5E,MAAM,EAAE,MAAM,EAAE,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAC;IAE3C,MAAM,IAAI,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACpB,MAAM,IAAI,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IACpB,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAClB,MAAM,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAClB,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC;QAAE,OAAO,GAAG,CAAC;IAEnC,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC;IAC3C,MAAM,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC;IAC3C,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAC1C,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAC1C,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAC1C,MAAM,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAE1C,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC;IACtD,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC;IACrD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC;IAC3D,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,OAAO,CAAC,CAAC,CAAC;IAC5D,MAAM,GAAG,GAAG,KAAK,GAAG,IAAI,CAAC;IACzB,MAAM,GAAG,GAAG,MAAM,GAAG,GAAG,CAAC;IACzB,IAAI,IAAI,KAAK,IAAI,IAAI,GAAG,KAAK,IAAI,IAAI,GAAG,KAAK,EAAE,IAAI,GAAG,KAAK,EAAE;QAAE,OAAO,GAAG,CAAC;IAE1E,OAAO,GAAG;SACP,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,UAAU,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC;SAC3D,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,WAAW,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,GAAG,EAAE,CAAC,CAAC,KAAK,CAAC;SAC5D,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,KAAK,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,IAAI,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;AAC1F,CAAC;AAED,SAAS,KAAK,CAAC,CAAS;IACtB,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC;AACnC,CAAC;AAED;+EAC+E;AAC/E,MAAM,UAAU,MAAM,CAAC,CAAS;IAC9B,OAAO,CAAC;SACL,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC;SACrB,OAAO,CAAC,OAAO,EAAE,GAAG,CAAC;SACrB,OAAO,CAAC,QAAQ,EAAE,GAAG,CAAC;SACtB,OAAO,CAAC,QAAQ,EAAE,GAAG,CAAC,CAAC;AAC5B,CAAC"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
// GENERATED from src/render/viewer.html by scripts/generate-viewer-html.ts — do not edit.
|
|
3
3
|
// After editing viewer.html, run: npm run viewer:generate
|
|
4
|
-
export const VIEWER_HTML = "<!doctype html>\n<!--\n The live viewer page, served at / by `em watch --serve` (src/render/serve.ts).\n\n This file is the SOURCE OF TRUTH for the viewer. It ships as the generated\n constant in src/render/viewerHtml.ts - after editing this file, run\n `npm run viewer:generate` to regenerate it (a sync test fails otherwise).\n\n Self-contained: no external assets, all state in the one script below, driven\n by SSE push - the browser reloads only when the server says the SVG changed.\n\n The SVG is inlined (fetch, DOMParser, validate, then swap) rather than\n embedded via an object tag: a truncated or malformed file is detected BEFORE\n touching the DOM, so a bad render can never blank the shared screen - the\n last good frame stays up behind an error banner while the viewer retries\n with backoff. A save that fails to render entirely (parse error) arrives as\n an SSE \"renderError\" and lands in the same banner over the intact diagram.\n Inline SVG also keeps note links clickable and hands the camera a real\n viewBox to drive.\n\n The camera IS the svg viewBox (cam = x/y/w/h): drag and wheel pan,\n ctrl+wheel (what a trackpad pinch emits) and two-pointer pinch zoom about\n the cursor, and flyTo() animates slice navigation and fit. Driving viewBox\n directly (not CSS transforms) keeps text crisp and makes the client-to-model\n coordinate math a single exact mapping.\n\n Review mode: a Prev/Next storyboard that highlights and flies to one slice\n at a time, reading the data-slice attributes and the em-slices metadata that\n src/render/sliceOverlay.ts embeds in every rendered SVG. Review state lives\n outside the load pipeline and is re-applied after every SSE-triggered swap -\n a facilitator hand-editing the model live must not lose their place.\n\n Doc flyout (MIL-153): clicking any element belonging to a slice opens a\n side panel with that slice's own design doc, rendered server-side to HTML\n and carried in the same em-slices metadata (no separate fetch — see\n src/render/sliceOverlay.ts's docHtml field). Independent of review mode: a\n slice click never moves the camera, and stepping/navigating never touches\n the flyout. Like review state, which slice's doc is open lives outside the\n load pipeline, refreshed (or closed, if the slice vanished) after every\n reload rather than reset by one.\n\n Storyline picker (GH #309, deferred from #277): whenever the model declares\n storylines, `em watch --serve` writes the full model plus every storyline's own\n `em render --storyline` subset side by side (src/cli.ts's watch action), each\n carrying the same `<metadata id=\"em-storylines\">` list of {name, file, current}\n (src/render/storylinePicker.ts). The picker reads that list and lets a facilitator\n switch which one is loaded — a plain navigation to this same page with a new `?svg=`,\n so it needs no new fetch/reload machinery of its own. `--storyline <name>` just picks\n which file is loaded first; the picker works the same with or without it.\n-->\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <title>Event Model — Live</title>\n <style>\n :root { color-scheme: light dark; }\n html, body { height: 100%; }\n body {\n margin: 0; font-family: system-ui, sans-serif; background: #fafafa;\n display: flex; flex-direction: column; overflow: hidden;\n }\n header {\n display: flex; align-items: center; gap: .75rem; flex: none;\n padding: .5rem .9rem; background: #1f2933; color: #fff; font-size: 14px;\n }\n header .dot { width: 9px; height: 9px; border-radius: 50%; background: #34c759; transition: background .3s; flex: none; }\n header .dot.stale { background: #f0a020; }\n header .stamp { margin-left: auto; opacity: .7; font-variant-numeric: tabular-nums; }\n header .zoom { display: flex; align-items: center; gap: .3rem; }\n #zoomPct { font-size: 12px; opacity: .7; min-width: 3.2em; text-align: center; font-variant-numeric: tabular-nums; }\n .btn {\n background: #3b4754; color: #fff; border: 0; border-radius: 4px;\n padding: .3rem .7rem; font-size: 13px; cursor: pointer; font-family: inherit;\n }\n .btn:hover { background: #4b5763; }\n .btn:disabled { opacity: .4; cursor: default; }\n .btn.active { background: #2f6fed; }\n #storylinePicker {\n background: #3b4754; color: #fff; border: 0; border-radius: 4px;\n padding: .3rem .5rem; font-size: 13px; cursor: pointer; font-family: inherit;\n }\n #storylinePicker[hidden] { display: none; }\n #storylinePicker option { color: #000; }\n #errorBanner {\n display: flex; align-items: center; gap: .6rem; flex: none;\n padding: .45rem .9rem; background: #8c2f28; color: #fff; font-size: 13px;\n }\n #errorBanner[hidden] { display: none; }\n #errorBanner button {\n margin-left: auto; background: none; border: 0; color: #fff;\n font-size: 16px; line-height: 1; cursor: pointer; opacity: .8;\n }\n #storyboard {\n display: flex; align-items: center; gap: .5rem; padding: .4rem .9rem; flex: none;\n background: #e8eaed; border-bottom: 1px solid #d0d3d7;\n }\n #storyboard[hidden] { display: none; }\n #storyboard .nav { background: none; border: 0; font-size: 18px; line-height: 1; cursor: pointer; color: #3b4754; padding: 0 .3rem; }\n #storyboard .nav:disabled { opacity: .3; cursor: default; }\n #filmstrip { display: flex; gap: .35rem; overflow-x: auto; }\n #filmstrip .slice {\n padding: .25rem .6rem; border-radius: 12px; background: #fff; border: 1px solid #d0d3d7;\n font-size: 12px; white-space: nowrap; cursor: pointer; color: #3b4754; font-family: inherit;\n }\n #filmstrip .slice.active { background: #2f6fed; border-color: #2f6fed; color: #fff; font-weight: 600; }\n #stage {\n flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden;\n touch-action: none; cursor: grab; user-select: none; -webkit-user-select: none;\n }\n #stage.dragging { cursor: grabbing; }\n #stage svg { width: 100%; height: 100%; display: block; }\n #docFlyout {\n position: absolute; top: 0; right: 0; bottom: 0; width: min(640px, 94%);\n background: #fff; border-left: 1px solid #d0d3d7; box-shadow: -2px 0 10px rgba(0, 0, 0, .15);\n display: flex; flex-direction: column; z-index: 5;\n transform: translateX(100%); transition: transform .2s ease;\n /* #stage sets these for its own drag-to-pan surface; the flyout is a normal\n panel — its text should be selectable and its cursor should look normal. */\n user-select: text; -webkit-user-select: text; cursor: auto; touch-action: auto;\n }\n #docFlyout.open { transform: translateX(0); }\n #docFlyout .docHead {\n display: flex; align-items: center; gap: .5rem; flex: none;\n padding: .55rem .9rem; background: #1f2933; color: #fff; font-size: 14px; font-weight: 600;\n }\n #docFlyout .docHead span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n #docFlyout .docHead button {\n margin-left: auto; background: none; border: 0; color: #fff;\n font-size: 18px; line-height: 1; cursor: pointer; opacity: .8; flex: none;\n }\n #docFlyout .docBody {\n /* min-height: 0 overrides a flex item's default min-height: auto, which would\n otherwise let this grow to fit all its content instead of scrolling — the\n actual cause of \"no way to scroll it\": without it the body just spills past\n the flyout's bottom edge, clipped invisibly by #stage's own overflow:hidden. */\n flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden;\n padding: .9rem 1.1rem; font-size: 13px; line-height: 1.55; color: #202124;\n }\n #docFlyout .docBody.empty { color: #5f6368; font-style: italic; }\n #docFlyout .docBody img { max-width: 100%; height: auto; display: block; margin: .5em 0; }\n #docFlyout .docBody :is(h1, h2, h3, h4, h5, h6) { margin: 1em 0 .35em; line-height: 1.3; }\n #docFlyout .docBody :is(h1, h2, h3, h4, h5, h6):first-child { margin-top: 0; }\n #docFlyout .docBody p, #docFlyout .docBody ul, #docFlyout .docBody ol { margin: .5em 0; }\n #docFlyout .docBody ul, #docFlyout .docBody ol { padding-left: 1.3em; }\n #docFlyout .docBody a { color: #2f6fed; }\n #docFlyout .docBody code { background: #f0f1f3; padding: .1em .3em; border-radius: 3px; font-size: 12px; }\n #docFlyout .docBody pre { background: #f0f1f3; padding: .6rem; border-radius: 4px; overflow-x: auto; }\n #docFlyout .docBody pre code { background: none; padding: 0; }\n #docFlyout .docBody blockquote {\n margin: .5em 0; padding-left: .8em; border-left: 3px solid #d0d3d7; color: #5f6368;\n }\n #docFlyout .docBody table { border-collapse: collapse; margin: .5em 0; }\n #docFlyout .docBody :is(th, td) { border: 1px solid #d0d3d7; padding: .3em .5em; }\n /* GH #402: section-aware slice-doc rendering — the frontmatter badge row, Given/When/Then\n scenario cards, INV-* chips, and field-table column styling catalog/sliceDocRender.ts and\n catalog/docBadge.ts build; see catalog/pages.ts for the static catalog's matching rules. */\n #docFlyout .docBody .doc-badge-row { display: flex; flex-wrap: wrap; gap: .4rem; margin: 0 0 1rem; }\n #docFlyout .docBody .doc-badge {\n display: inline-block; padding: .15rem .55rem; border-radius: 999px; font-size: 11px;\n font-weight: 600; letter-spacing: .01em; background: #e4e7eb; color: #3b4754;\n }\n #docFlyout .docBody .doc-badge--pattern { background: #e1ecff; color: #1d4ed8; }\n #docFlyout .docBody .doc-badge--version { background: #eef0f3; color: #52606d; font-variant-numeric: tabular-nums; }\n #docFlyout .docBody .doc-badge--status { background: #fef3cf; color: #8a6116; text-transform: capitalize; }\n #docFlyout .docBody .doc-badge--drift-in-sync { background: #d8f3dc; color: #1b7a3d; }\n #docFlyout .docBody .doc-badge--drift-uncertified, #docFlyout .docBody .doc-badge--drift-unpropagated-delta { background: #ffe9c7; color: #8a5300; }\n #docFlyout .docBody .doc-badge--drift-implemented-without-link { background: #fbd8d4; color: #9b2c20; }\n #docFlyout .docBody .doc-badge--drift-never-implemented { background: #e4e7eb; color: #52606d; }\n #docFlyout .docBody .scenario-card { border: 1px solid #d0d3d7; border-radius: 8px; margin: 1em 0; overflow: hidden; background: #fff; }\n #docFlyout .docBody .scenario-card-title { margin: 0; padding: .5rem .75rem; background: #f0f2f5; font-size: .95rem; border-bottom: 1px solid #d0d3d7; }\n #docFlyout .docBody .scenario-step { display: flex; gap: .6rem; padding: .45rem .75rem; border-top: 1px solid #eceef1; align-items: flex-start; }\n #docFlyout .docBody .scenario-step:first-of-type { border-top: 0; }\n #docFlyout .docBody .scenario-step-label {\n flex: none; min-width: 4.2em; font-size: 11px; font-weight: 700; text-transform: uppercase;\n letter-spacing: .04em; color: #52606d; padding-top: .15em;\n }\n #docFlyout .docBody .scenario-step--given .scenario-step-label { color: #1d4ed8; }\n #docFlyout .docBody .scenario-step--when .scenario-step-label { color: #8a5300; }\n #docFlyout .docBody .scenario-step--then .scenario-step-label { color: #1b7a3d; }\n #docFlyout .docBody .scenario-step-body { flex: 1 1 auto; }\n #docFlyout .docBody .scenario-step-body ul { margin: .2em 0; padding-left: 1.1em; }\n #docFlyout .docBody .inv-chip { display: inline-block; padding: .05em .4em; border-radius: 4px; font-size: .85em; font-family: ui-monospace, Menlo, monospace; }\n #docFlyout .docBody .inv-chip--def { background: #1f2933; color: #fff; scroll-margin-top: 1rem; }\n #docFlyout .docBody .inv-chip--ref { background: #eef0f3; color: #1d4ed8; text-decoration: none; }\n #docFlyout .docBody .inv-chip--ref:hover { text-decoration: underline; }\n #docFlyout .docBody .inv-chip--external { background: #eef0f3; color: #52606d; }\n #docFlyout .docBody table.field-table th.col-type, #docFlyout .docBody table.field-table td.col-type { font-family: ui-monospace, Menlo, monospace; font-size: .85em; color: #3b4754; white-space: nowrap; }\n #docFlyout .docBody table.field-table th.col-notes, #docFlyout .docBody table.field-table td.col-notes { color: #52606d; }\n #docFlyout .docBody table.field-table th.col-flag, #docFlyout .docBody table.field-table td.col-flag { text-align: center; white-space: nowrap; }\n </style>\n</head>\n<body>\n <header>\n <span class=\"dot\" id=\"dot\"></span>\n <span>Event Model — live</span>\n <button class=\"btn\" id=\"reviewToggle\" title=\"Step through one slice at a time\">Review mode</button>\n <select id=\"storylinePicker\" title=\"Filter the live view to one storyline\" hidden></select>\n <span class=\"zoom\">\n <button class=\"btn\" id=\"zoomOut\" aria-label=\"Zoom out\" title=\"Zoom out (-)\">−</button>\n <span id=\"zoomPct\">100%</span>\n <button class=\"btn\" id=\"zoomIn\" aria-label=\"Zoom in\" title=\"Zoom in (+)\">+</button>\n <button class=\"btn\" id=\"fitBtn\" title=\"Fit the whole diagram (0)\">Fit</button>\n </span>\n <span class=\"stamp\" id=\"stamp\">connecting…</span>\n </header>\n <div id=\"errorBanner\" hidden>\n <span id=\"errorText\"></span>\n <button id=\"errorDismiss\" aria-label=\"Dismiss\">×</button>\n </div>\n <nav id=\"storyboard\" hidden>\n <button class=\"nav\" id=\"prevSlice\" aria-label=\"Previous slice\">‹</button>\n <div id=\"filmstrip\"></div>\n <button class=\"nav\" id=\"nextSlice\" aria-label=\"Next slice\">›</button>\n </nav>\n <div id=\"stage\">\n <aside id=\"docFlyout\">\n <div class=\"docHead\">\n <span id=\"docFlyoutTitle\"></span>\n <button id=\"docFlyoutClose\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"docBody\" id=\"docFlyoutBody\"></div>\n </aside>\n </div>\n <script>\n // ?svg=<name>.svg picks the model file (default model.svg), so one server\n // serves any model without editing this page. The allowlist keeps the\n // value a bare *.svg filename inside the served directory — no path\n // separator, no drive/scheme colon — so it is safe to fetch (always\n // URI-encoded) and to display (via textContent only, never markup).\n // Spaces and unicode are fine: em watch prints whatever the output file\n // is called, and that URL has to work.\n const svgParam = new URLSearchParams(location.search).get(\"svg\");\n const SVG_FILE = svgParam && /^[^/\\\\:]+\\.svg$/.test(svgParam) ? svgParam : \"model.svg\";\n\n const stage = document.getElementById(\"stage\");\n const stamp = document.getElementById(\"stamp\");\n const dot = document.getElementById(\"dot\");\n const banner = document.getElementById(\"errorBanner\");\n const bannerText = document.getElementById(\"errorText\");\n const reviewToggle = document.getElementById(\"reviewToggle\");\n const storylinePicker = document.getElementById(\"storylinePicker\");\n const storyboard = document.getElementById(\"storyboard\");\n const filmstrip = document.getElementById(\"filmstrip\");\n const prevBtn = document.getElementById(\"prevSlice\");\n const nextBtn = document.getElementById(\"nextSlice\");\n const zoomPct = document.getElementById(\"zoomPct\");\n const docFlyout = document.getElementById(\"docFlyout\");\n const docFlyoutTitle = document.getElementById(\"docFlyoutTitle\");\n const docFlyoutBody = document.getElementById(\"docFlyoutBody\");\n const docFlyoutClose = document.getElementById(\"docFlyoutClose\");\n\n // --- review-mode state: outside the load pipeline on purpose, see the\n // file-level comment above — a reload re-applies it, never resets it. ---\n let reviewMode = false;\n let activeSlice = null; // slice index, or null for \"show all\"\n let slices = []; // [{index, name, x0, x1, docHtml}], from this load's <metadata>\n let rowLabels = null; // {x0, x1} | null, the swimlane label column's range\n\n // --- storyline picker (GH #309): outside the load pipeline isn't needed here — unlike\n // review/doc-flyout state, switching storylines navigates to a new page (see below), so\n // there's nothing to preserve across a reload. ---\n let storylineVariants = []; // [{name, file, current}], from this load's <metadata id=\"em-storylines\">, or []\n\n // --- doc flyout state: independent of review mode, outside the load\n // pipeline for the same reason review state is — see the file comment. ---\n let openSliceIndex = null; // slice index whose doc the flyout shows, or null when closed\n\n // --- camera state ---\n let svgEl = null; // the inline <svg>, null until the first good load\n let natural = null; // the loaded SVG's own viewBox: its full-fit frame\n let cam = null; // the current viewBox {x, y, w, h}\n // True while the user has never panned/zoomed (or pressed Fit last): a\n // reload then re-fits to the new natural box instead of pinning the old\n // absolute view onto a diagram that may have grown or shrunk.\n let atFit = true;\n\n function stageRect() { return stage.getBoundingClientRect(); }\n\n // Uniform px→model scale of the current view. The svg keeps the default\n // preserveAspectRatio (xMidYMid meet): one scale factor, centered.\n function scale() {\n const r = stageRect();\n return Math.max(cam.w / Math.max(1, r.width), cam.h / Math.max(1, r.height));\n }\n\n // Exact client-px → model-coords inverse of the \"meet\" mapping, so\n // zoom-about-cursor keeps the model point under the cursor exactly.\n function toSvg(cx, cy) {\n const r = stageRect();\n const s = scale();\n const ox = cam.x - (r.width * s - cam.w) / 2;\n const oy = cam.y - (r.height * s - cam.h) / 2;\n return { x: ox + (cx - r.left) * s, y: oy + (cy - r.top) * s };\n }\n\n // Fit a model-space rect into the stage aspect, centered.\n function frameRect(x, y, w, h) {\n const r = stageRect();\n const aspect = Math.max(1, r.width) / Math.max(1, r.height);\n let fw = w, fh = h;\n if (fw / fh < aspect) fw = fh * aspect; else fh = fw / aspect;\n return { x: x + (w - fw) / 2, y: y + (h - fh) / 2, w: fw, h: fh };\n }\n\n function fitCam() { return frameRect(natural.x, natural.y, natural.w, natural.h); }\n\n // New cam zoomed by the given factor about model point p, clamped to\n // 0.5×–30× of fit so the diagram is neither lost nor over-magnified.\n function zoomedCam(p, factor) {\n const fitW = fitCam().w;\n const w = Math.min(fitW * 2, Math.max(fitW / 30, cam.w / factor));\n const f = cam.w / w;\n return clampPanOf({\n x: p.x - (p.x - cam.x) / f,\n y: p.y - (p.y - cam.y) / f,\n w: cam.w / f,\n h: cam.h / f,\n });\n }\n\n // Keep at least half of min(view, diagram) in frame on each axis, so the\n // diagram can never be panned fully out of view.\n function clampPanOf(c) {\n if (!natural) return c;\n const kx = Math.min(c.w, natural.w) / 2;\n const ky = Math.min(c.h, natural.h) / 2;\n c.x = Math.max(natural.x - c.w + kx, Math.min(natural.x + natural.w - kx, c.x));\n c.y = Math.max(natural.y - c.h + ky, Math.min(natural.y + natural.h - ky, c.y));\n return c;\n }\n function clampPan() { cam = clampPanOf(cam); }\n\n function applyCam() {\n if (!svgEl || !cam) return;\n svgEl.setAttribute(\"viewBox\", cam.x + \" \" + cam.y + \" \" + cam.w + \" \" + cam.h);\n if (natural) zoomPct.textContent = Math.round((fitCam().w / cam.w) * 100) + \"%\";\n }\n\n function markMoved() { atFit = false; }\n\n // Animated camera move; any direct manipulation (drag/wheel/pinch) cancels\n // it and takes over from wherever the animation got to.\n let flyHandle = null;\n function flyTo(target) {\n cancelFly();\n if (!cam) { cam = target; applyCam(); return; }\n const from = { x: cam.x, y: cam.y, w: cam.w, h: cam.h };\n const t0 = performance.now();\n const DUR = 280;\n function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }\n function frame(now) {\n const t = Math.min(1, (now - t0) / DUR);\n const k = ease(t);\n cam = {\n x: from.x + (target.x - from.x) * k,\n y: from.y + (target.y - from.y) * k,\n w: from.w + (target.w - from.w) * k,\n h: from.h + (target.h - from.h) * k,\n };\n applyCam();\n flyHandle = t < 1 ? requestAnimationFrame(frame) : null;\n }\n flyHandle = requestAnimationFrame(frame);\n }\n function cancelFly() {\n if (flyHandle !== null) { cancelAnimationFrame(flyHandle); flyHandle = null; }\n }\n\n function goFit() {\n if (!natural) return;\n atFit = true;\n flyTo(fitCam());\n }\n\n function zoomStep(factor) {\n if (!cam) return;\n const r = stageRect();\n markMoved();\n flyTo(zoomedCam(toSvg(r.left + r.width / 2, r.top + r.height / 2), factor));\n }\n\n // --- load pipeline: fetch → parse → validate → swap. A failure at any\n // step keeps the last good frame on screen and retries with backoff. ---\n let loadId = 0; // only the latest load's result may touch the DOM\n let retryTimer = null;\n let retryAttempt = 0;\n const RETRY_MS = [500, 1000, 2000, 4000]; // then every 4s\n\n function reload() {\n const id = ++loadId; // supersedes any in-flight load and pending retry\n if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null; }\n dot.classList.add(\"stale\");\n fetch(encodeURIComponent(SVG_FILE) + \"?t=\" + Date.now(), { cache: \"no-store\" })\n .then(function (res) {\n if (!res.ok) throw new Error(\"HTTP \" + res.status);\n return res.text();\n })\n .then(function (text) {\n if (id !== loadId) return;\n const doc = new DOMParser().parseFromString(text, \"image/svg+xml\");\n const root = doc.documentElement;\n // Validate before touching the DOM — a truncated write mid-render or\n // a non-SVG response must not blank the shared screen.\n if (!root || root.localName !== \"svg\" || doc.querySelector(\"parsererror\")) {\n fail(\"SVG parse error\");\n return;\n }\n applyLoad(root);\n })\n .catch(function (err) {\n if (id !== loadId) return;\n fail(err && err.message ? err.message : String(err));\n });\n }\n\n function applyLoad(root) {\n const next = document.importNode(root, true);\n const vb = (next.getAttribute(\"viewBox\") || \"\").trim().split(/[\\s,]+/).map(Number);\n // CSS sizes the element; the viewBox is the camera.\n next.removeAttribute(\"width\");\n next.removeAttribute(\"height\");\n if (svgEl) svgEl.replaceWith(next); else stage.appendChild(next);\n svgEl = next;\n natural = vb.length === 4 && vb.every(isFinite) && vb[2] > 0 && vb[3] > 0\n ? { x: vb[0], y: vb[1], w: vb[2], h: vb[3] }\n : null;\n if (!natural) {\n // No usable viewBox (not something our renderer emits): frame the ink.\n const bb = next.getBBox();\n natural = { x: bb.x, y: bb.y, w: Math.max(1, bb.width), h: Math.max(1, bb.height) };\n }\n\n const metaEl = document.getElementById(\"em-slices\");\n let meta = null;\n if (metaEl) { try { meta = JSON.parse(metaEl.textContent); } catch (err) { meta = null; } }\n slices = (meta && meta.slices) || [];\n rowLabels = (meta && meta.rowLabels) || null;\n\n // GH #309: the storyline-picker list travels the same way em-slices does — read fresh\n // on every load (no survives-a-reload state to preserve; picking an option navigates).\n const storyMetaEl = document.getElementById(\"em-storylines\");\n let storyMeta = null;\n if (storyMetaEl) { try { storyMeta = JSON.parse(storyMetaEl.textContent); } catch (err) { storyMeta = null; } }\n storylineVariants = storyMeta || [];\n renderStorylinePicker();\n // The model may have changed shape (slice removed) between loads. But a\n // reload with NO metadata at all (a transient bad render) must not eject\n // the facilitator from review mode or lose their place — review state\n // survives every reload; the position is only remapped when there are\n // real slices to remap onto.\n if (slices.length) {\n if (activeSlice !== null && !slices.some(function (s) { return s.index === activeSlice; })) {\n activeSlice = slices[0].index;\n }\n if (reviewMode && activeSlice === null) activeSlice = slices[0].index;\n // Same survives-a-reload treatment for the doc flyout: refresh its\n // content (the doc may have changed) if its slice is still here,\n // close it if the slice is gone — but only on a reload that actually\n // carries real slice metadata, never on a transient bad render.\n if (openSliceIndex !== null) {\n if (slices.some(function (s) { return s.index === openSliceIndex; })) renderDocFlyout();\n else closeDoc();\n }\n }\n renderChrome();\n applyDim();\n\n // Camera across reloads: review mode re-flies to the (possibly moved)\n // active slice; an untouched/fitted camera re-fits to the new natural\n // box; otherwise the absolute view is preserved (including while the\n // active slice is temporarily missing from the metadata).\n cancelFly();\n if (reviewMode && activeSlice !== null &&\n slices.some(function (s) { return s.index === activeSlice; })) {\n flyTo(sliceFrame(activeSlice));\n } else if (atFit || !cam) {\n cam = fitCam();\n applyCam();\n } else {\n clampPan();\n applyCam();\n }\n\n banner.hidden = true;\n retryAttempt = 0;\n stamp.textContent = new Date().toLocaleTimeString();\n dot.classList.remove(\"stale\");\n }\n\n function fail(msg) {\n bannerText.textContent = \"Couldn't load \" + SVG_FILE + \" — \" + msg + \" · retrying…\";\n banner.hidden = false;\n const delay = RETRY_MS[Math.min(retryAttempt, RETRY_MS.length - 1)];\n retryAttempt += 1;\n retryTimer = setTimeout(reload, delay);\n }\n\n // --- review mode / storyboard ---\n\n // The union, in natural/model coordinates, of every element actually\n // belonging to slice `index` — lets sliceFrame zoom tightly around what a\n // slice contains instead of always keeping the model's FULL height in\n // frame. Always requiring the full height meant a model with few slices\n // but several swimlane rows could never zoom in tighter than Fit: letting\n // frameRect grow the frame's width to preserve that full height, on a\n // landscape stage, routinely produced a frame wider than the whole\n // diagram. getBoundingClientRect() + toSvg() reuses the exact camera math\n // already trusted for zoom-about-cursor, so this needs no new server-side\n // geometry or metadata. Returns null when there's no usable geometry yet\n // (nothing tagged, or a non-layout test environment) — sliceFrame falls\n // back to the old full-height framing in that case.\n function sliceContentBox(index) {\n if (!svgEl || !cam) return null;\n const nodes = svgEl.querySelectorAll('[data-slice=\"' + index + '\"]');\n let box = null;\n nodes.forEach(function (node) {\n const r = node.getBoundingClientRect();\n if (r.width <= 0 || r.height <= 0) return;\n const p0 = toSvg(r.left, r.top);\n const p1 = toSvg(r.right, r.bottom);\n const nx0 = Math.min(p0.x, p1.x), nx1 = Math.max(p0.x, p1.x);\n const ny0 = Math.min(p0.y, p1.y), ny1 = Math.max(p0.y, p1.y);\n box = box\n ? { x0: Math.min(box.x0, nx0), x1: Math.max(box.x1, nx1), y0: Math.min(box.y0, ny0), y1: Math.max(box.y1, ny1) }\n : { x0: nx0, x1: nx1, y0: ny0, y1: ny1 };\n });\n return box;\n }\n\n function sliceFrame(index) {\n let slice = null;\n for (const s of slices) if (s.index === index) slice = s;\n if (!slice || !natural) return fitCam();\n const PAD = 24; // breathing room around the framed slice\n const content = sliceContentBox(index);\n let x0 = rowLabels ? Math.min(rowLabels.x0, slice.x0) : slice.x0;\n let x1 = slice.x1;\n // Vertically, frame just the slice's own content when we can measure it;\n // otherwise fall back to the model's full height (every swimlane row).\n let y0 = natural.y, y1 = natural.y + natural.h;\n if (content) {\n x0 = Math.min(x0, content.x0);\n x1 = Math.max(x1, content.x1);\n y0 = content.y0;\n y1 = content.y1;\n }\n x0 = Math.max(natural.x, x0 - PAD);\n x1 = Math.min(natural.x + natural.w, x1 + PAD);\n y0 = Math.max(natural.y, y0 - PAD);\n y1 = Math.min(natural.y + natural.h, y1 + PAD);\n return frameRect(x0, y0, Math.max(1, x1 - x0), Math.max(1, y1 - y0));\n }\n\n // Populates the storyline <select> from this load's em-storylines metadata — \"Full\n // model\" plus one option per declared storyline, pre-selected on whichever is current.\n // Hidden entirely when the model declares none (storylineVariants is []).\n function renderStorylinePicker() {\n storylinePicker.hidden = storylineVariants.length === 0;\n storylinePicker.innerHTML = \"\";\n for (const v of storylineVariants) {\n const opt = document.createElement(\"option\");\n opt.value = v.file;\n opt.textContent = v.name;\n opt.selected = !!v.current;\n storylinePicker.appendChild(opt);\n }\n }\n\n function renderChrome() {\n storyboard.hidden = slices.length === 0;\n reviewToggle.classList.toggle(\"active\", reviewMode);\n reviewToggle.disabled = slices.length === 0;\n filmstrip.innerHTML = \"\";\n for (const s of slices) {\n const b = document.createElement(\"button\");\n b.className = \"slice\" + (s.index === activeSlice ? \" active\" : \"\");\n b.textContent = s.name;\n b.addEventListener(\"click\", function () { goTo(s.index); });\n filmstrip.appendChild(b);\n }\n const first = slices[0], last = slices[slices.length - 1];\n prevBtn.disabled = activeSlice === null || !first || activeSlice <= first.index;\n nextBtn.disabled = activeSlice === null || !last || activeSlice >= last.index;\n }\n\n function applyDim() {\n if (!svgEl) return;\n svgEl.querySelectorAll(\"[data-slice]\").forEach(function (node) {\n const dim = reviewMode && activeSlice !== null &&\n Number(node.getAttribute(\"data-slice\")) !== activeSlice;\n node.classList.toggle(\"em-slice-dim\", dim);\n });\n }\n\n // --- doc flyout: click a slice's own node to see its design doc, purely\n // an overlay — it never moves the camera or touches review state. ---\n function sliceByIndex(index) {\n for (const s of slices) if (s.index === index) return s;\n return null;\n }\n\n function renderDocFlyout() {\n const s = sliceByIndex(openSliceIndex);\n if (!s) { docFlyout.classList.remove(\"open\"); return; }\n docFlyoutTitle.textContent = s.name;\n docFlyoutBody.classList.toggle(\"empty\", !s.docHtml);\n if (s.docHtml) docFlyoutBody.innerHTML = s.docHtml;\n else docFlyoutBody.textContent = \"No design doc yet for this slice.\";\n docFlyout.classList.add(\"open\");\n }\n\n function openDoc(index) {\n openSliceIndex = index;\n renderDocFlyout();\n }\n\n function closeDoc() {\n openSliceIndex = null;\n docFlyout.classList.remove(\"open\");\n }\n\n function goTo(index) {\n activeSlice = index;\n renderChrome();\n applyDim();\n if (reviewMode) flyTo(sliceFrame(index));\n }\n\n function step(delta) {\n if (!slices.length) return;\n if (activeSlice === null) { goTo(slices[0].index); return; }\n const i = slices.findIndex(function (s) { return s.index === activeSlice; });\n const next = slices[Math.min(slices.length - 1, Math.max(0, i + delta))];\n if (next) goTo(next.index);\n }\n\n function exitReview() {\n reviewMode = false;\n renderChrome();\n applyDim();\n goFit();\n }\n\n reviewToggle.addEventListener(\"click\", function () {\n if (reviewMode) { exitReview(); return; }\n reviewMode = true;\n if (activeSlice === null && slices.length) activeSlice = slices[0].index;\n renderChrome();\n applyDim();\n if (activeSlice !== null) flyTo(sliceFrame(activeSlice));\n });\n prevBtn.addEventListener(\"click\", function () { step(-1); });\n nextBtn.addEventListener(\"click\", function () { step(1); });\n // GH #309: switching storylines is a plain navigation to this same page with a new\n // ?svg= — the chosen file is already on disk (em watch --serve writes every variant on\n // every build), so there's nothing to fetch or reload by hand.\n storylinePicker.addEventListener(\"change\", function () {\n location.search = \"svg=\" + encodeURIComponent(storylinePicker.value);\n });\n document.getElementById(\"zoomIn\").addEventListener(\"click\", function () { zoomStep(1.5); });\n document.getElementById(\"zoomOut\").addEventListener(\"click\", function () { zoomStep(1 / 1.5); });\n document.getElementById(\"fitBtn\").addEventListener(\"click\", goFit);\n document.getElementById(\"errorDismiss\").addEventListener(\"click\", function () {\n banner.hidden = true; // retries keep going quietly\n });\n docFlyoutClose.addEventListener(\"click\", closeDoc);\n\n // --- direct manipulation: drag pan, wheel pan, ctrl+wheel / pinch zoom ---\n const pointers = new Map(); // pointerId -> last client {x, y}\n let drag = null; // { id, startX, startY, active } — single-pointer pan\n let suppressClick = false; // swallow the click that ends a drag/pinch\n\n // The doc flyout is a normal interactive DOM panel stacked over the stage\n // (see #docFlyout's z-index) — its own scrolling, text selection, and link\n // clicks must behave normally, not get hijacked by the stage's direct-\n // manipulation gestures (drag-pan, wheel-pan/zoom, double-click-to-zoom).\n function overFlyout(e) {\n return !!(e.target.closest && e.target.closest(\"#docFlyout\"));\n }\n\n stage.addEventListener(\"pointerdown\", function (e) {\n if (overFlyout(e)) return;\n if (e.button !== 0) return;\n cancelFly(); // direct manipulation takes over from any animation\n pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });\n if (pointers.size === 1) {\n suppressClick = false;\n drag = { id: e.pointerId, startX: e.clientX, startY: e.clientY, active: false };\n } else {\n // Second pointer: pinch. Capture both so the gesture survives leaving\n // the stage; a pinch must never end in a note-link click.\n drag = null;\n suppressClick = true;\n stage.classList.add(\"dragging\");\n for (const id of pointers.keys()) {\n try { stage.setPointerCapture(id); } catch (err) { /* pointer already gone */ }\n }\n }\n });\n\n stage.addEventListener(\"pointermove\", function (e) {\n const pt = pointers.get(e.pointerId);\n if (!pt) return;\n if (pointers.size >= 2) { pinchMove(e, pt); return; }\n if (!drag || drag.id !== e.pointerId || !cam) { pt.x = e.clientX; pt.y = e.clientY; return; }\n if (!drag.active) {\n // Movement threshold before treating it as a drag — capturing the\n // pointer immediately would retarget the eventual click away from the\n // SVG's note links. Until then pt stays at the pointerdown position,\n // so the first panning move applies the full accumulated delta.\n if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return;\n drag.active = true;\n suppressClick = true;\n stage.setPointerCapture(e.pointerId);\n stage.classList.add(\"dragging\");\n }\n const s = scale();\n cam.x -= (e.clientX - pt.x) * s;\n cam.y -= (e.clientY - pt.y) * s;\n pt.x = e.clientX; pt.y = e.clientY;\n clampPan();\n applyCam();\n markMoved();\n });\n\n // Incremental pinch: pan by the midpoint's movement, then zoom about the\n // new midpoint by the ratio of pointer distances.\n function pinchMove(e, pt) {\n let other = null;\n for (const entry of pointers) if (entry[0] !== e.pointerId) other = entry[1];\n if (!other || !cam) { pt.x = e.clientX; pt.y = e.clientY; return; }\n const oldMid = { x: (pt.x + other.x) / 2, y: (pt.y + other.y) / 2 };\n const oldDist = Math.hypot(pt.x - other.x, pt.y - other.y);\n pt.x = e.clientX; pt.y = e.clientY;\n const newMid = { x: (pt.x + other.x) / 2, y: (pt.y + other.y) / 2 };\n const newDist = Math.hypot(pt.x - other.x, pt.y - other.y);\n const s = scale();\n cam.x -= (newMid.x - oldMid.x) * s;\n cam.y -= (newMid.y - oldMid.y) * s;\n if (oldDist > 0 && newDist > 0) cam = zoomedCam(toSvg(newMid.x, newMid.y), newDist / oldDist);\n clampPan();\n applyCam();\n markMoved();\n }\n\n function endPointer(e) {\n if (!pointers.has(e.pointerId)) return; // not a pointer we were tracking\n pointers.delete(e.pointerId);\n if (stage.hasPointerCapture && stage.hasPointerCapture(e.pointerId)) {\n stage.releasePointerCapture(e.pointerId);\n }\n if (drag && drag.id === e.pointerId) drag = null;\n if (pointers.size === 1) {\n // Pinch collapsed to one finger: continue as an already-active pan.\n const rest = pointers.entries().next().value;\n drag = { id: rest[0], startX: rest[1].x, startY: rest[1].y, active: true };\n }\n if (pointers.size === 0) stage.classList.remove(\"dragging\");\n }\n // On window, not the stage: a pointer that goes down on the stage but is\n // released elsewhere (before the drag threshold captures it) must still be\n // cleared, or its stale Map entry would misclassify every later gesture as\n // a pinch. endPointer ignores pointers it never tracked.\n window.addEventListener(\"pointerup\", endPointer);\n window.addEventListener(\"pointercancel\", endPointer);\n\n stage.addEventListener(\"click\", function (e) {\n if (suppressClick) { suppressClick = false; e.preventDefault(); e.stopPropagation(); }\n }, true);\n\n // A plain click landing on an element's own node group (not a note/issue/\n // divergence marker — those live in separate groups with no data-slice\n // ancestor) opens that slice's doc flyout. A drag/pinch's trailing click\n // never reaches here: the capturing listener above stops it first.\n stage.addEventListener(\"click\", function (e) {\n const node = e.target.closest && e.target.closest(\"[data-slice]\");\n if (node) openDoc(Number(node.getAttribute(\"data-slice\")));\n });\n\n stage.addEventListener(\"wheel\", function (e) {\n if (overFlyout(e)) return; // let the flyout's own overflow-y:auto scroll natively\n e.preventDefault(); // the page itself must never scroll or browser-zoom\n if (!cam) return;\n cancelFly();\n const k = e.deltaMode === 1 ? 16 : 1; // line-mode deltas (Firefox) → ~px\n if (e.ctrlKey) {\n // ctrl+wheel is also what a trackpad pinch emits.\n cam = zoomedCam(toSvg(e.clientX, e.clientY), Math.exp(-e.deltaY * k * 0.01));\n } else {\n const s = scale();\n cam.x += e.deltaX * k * s;\n cam.y += e.deltaY * k * s;\n clampPan();\n }\n applyCam();\n markMoved();\n }, { passive: false });\n\n stage.addEventListener(\"dblclick\", function (e) {\n if (overFlyout(e)) return;\n if (!cam) return;\n e.preventDefault();\n markMoved();\n flyTo(zoomedCam(toSvg(e.clientX, e.clientY), 2));\n });\n\n document.addEventListener(\"keydown\", function (e) {\n // Modified keys stay with the browser; shift is allowed (\"+\" needs it\n // on many layouts). Guard against future text inputs, too.\n if (e.ctrlKey || e.metaKey || e.altKey) return;\n const t = e.target;\n if (t && (t.tagName === \"INPUT\" || t.tagName === \"TEXTAREA\" || t.isContentEditable)) return;\n if (e.key === \"+\" || e.key === \"=\") { e.preventDefault(); zoomStep(1.5); }\n else if (e.key === \"-\") { e.preventDefault(); zoomStep(1 / 1.5); }\n else if (e.key === \"0\" || e.key === \"f\") { e.preventDefault(); goFit(); }\n else if (e.key === \"Escape\") {\n if (openSliceIndex !== null) closeDoc();\n else if (reviewMode) exitReview();\n }\n else if (reviewMode) {\n if (e.key === \"ArrowLeft\") { e.preventDefault(); step(-1); }\n else if (e.key === \"ArrowRight\") { e.preventDefault(); step(1); }\n else if (e.key === \"Home\") { e.preventDefault(); if (slices.length) goTo(slices[0].index); }\n else if (e.key === \"End\") { e.preventDefault(); if (slices.length) goTo(slices[slices.length - 1].index); }\n }\n });\n\n window.addEventListener(\"resize\", function () {\n if (!natural || !cam) return;\n // Keep what the presenter framed: an active slice re-frames to the new\n // aspect; a fitted view re-fits; a hand-placed camera stays put.\n if (reviewMode && activeSlice !== null) { cam = sliceFrame(activeSlice); applyCam(); }\n else if (atFit) { cam = fitCam(); applyCam(); }\n });\n\n reload();\n\n const es = new EventSource(\"/__events\");\n // onopen reloads only on REconnect (laptop sleep, server restart): pushes\n // sent in the meantime were missed. The initial connect skips it — the\n // eager reload() above is already in flight, and doubling it would fetch\n // and parse every page open twice.\n let firstOpen = true;\n es.onopen = function () {\n retryAttempt = 0;\n if (firstOpen) { firstOpen = false; return; }\n reload();\n };\n es.onmessage = function () { retryAttempt = 0; reload(); };\n es.onerror = function () { dot.classList.add(\"stale\"); };\n // A save that failed to render (parse/render error): the server pushes the\n // message, the last good diagram stays up, and the banner says why the\n // screen is stale — it must never silently show an outdated model. No\n // retry here: refetching can only return the old file; the next successful\n // render pushes a reload that clears the banner.\n es.addEventListener(\"renderError\", function (e) {\n if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null; }\n bannerText.textContent = \"Model didn't render — \" + e.data + \" — showing last good diagram\";\n banner.hidden = false;\n dot.classList.add(\"stale\");\n });\n </script>\n</body>\n</html>\n";
|
|
4
|
+
export const VIEWER_HTML = "<!doctype html>\n<!--\n The live viewer page, served at / by `em watch --serve` (src/render/serve.ts).\n\n This file is the SOURCE OF TRUTH for the viewer. It ships as the generated\n constant in src/render/viewerHtml.ts - after editing this file, run\n `npm run viewer:generate` to regenerate it (a sync test fails otherwise).\n\n Self-contained: no external assets, all state in the one script below, driven\n by SSE push - the browser reloads only when the server says the SVG changed.\n\n The SVG is inlined (fetch, DOMParser, validate, then swap) rather than\n embedded via an object tag: a truncated or malformed file is detected BEFORE\n touching the DOM, so a bad render can never blank the shared screen - the\n last good frame stays up behind an error banner while the viewer retries\n with backoff. A save that fails to render entirely (parse error) arrives as\n an SSE \"renderError\" and lands in the same banner over the intact diagram.\n Inline SVG also keeps note links clickable and hands the camera a real\n viewBox to drive.\n\n The camera IS the svg viewBox (cam = x/y/w/h): drag and wheel pan,\n ctrl+wheel (what a trackpad pinch emits) and two-pointer pinch zoom about\n the cursor, and flyTo() animates slice navigation and fit. Driving viewBox\n directly (not CSS transforms) keeps text crisp and makes the client-to-model\n coordinate math a single exact mapping.\n\n Review mode: a Prev/Next storyboard that highlights and flies to one slice\n at a time, reading the data-slice attributes and the em-slices metadata that\n src/render/sliceOverlay.ts embeds in every rendered SVG. Review state lives\n outside the load pipeline and is re-applied after every SSE-triggered swap -\n a facilitator hand-editing the model live must not lose their place.\n\n Doc flyout (MIL-153): clicking any element belonging to a slice opens a\n side panel with that slice's own design doc, rendered server-side to HTML\n and carried in the same em-slices metadata (no separate fetch — see\n src/render/sliceOverlay.ts's docHtml field). Independent of review mode: a\n slice click never moves the camera, and stepping/navigating never touches\n the flyout. Like review state, which slice's doc is open lives outside the\n load pipeline, refreshed (or closed, if the slice vanished) after every\n reload rather than reset by one.\n\n Storyline picker (GH #309, deferred from #277): whenever the model declares\n storylines, `em watch --serve` writes the full model plus every storyline's own\n `em render --storyline` subset side by side (src/cli.ts's watch action), each\n carrying the same `<metadata id=\"em-storylines\">` list of {name, file, current}\n (src/render/storylinePicker.ts). The picker reads that list and lets a facilitator\n switch which one is loaded — a plain navigation to this same page with a new `?svg=`,\n so it needs no new fetch/reload machinery of its own. `--storyline <name>` just picks\n which file is loaded first; the picker works the same with or without it.\n\n Storyline canvas (GH #422): when the model declares storylines the live session opens on\n the canvas the server serves at /map (src/render/storylineMap.ts, built live) and a frame's\n \"open ›\" lands here inside that storyline's subset. The \"‹ Storylines\" breadcrumb in the\n header goes back to /map; it shows exactly when the em-storylines metadata is present,\n which is exactly when the server has a canvas to serve. Review mode needs nothing new:\n a subset's em-slices metadata holds that storyline's slices only, so Prev/Next walk the\n chosen storyline and nothing else.\n-->\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\" />\n <title>Event Model — Live</title>\n <style>\n :root { color-scheme: light dark; }\n html, body { height: 100%; }\n body {\n margin: 0; font-family: system-ui, sans-serif; background: #fafafa;\n display: flex; flex-direction: column; overflow: hidden;\n }\n header {\n display: flex; align-items: center; gap: .75rem; flex: none;\n padding: .5rem .9rem; background: #1f2933; color: #fff; font-size: 14px;\n }\n header .dot { width: 9px; height: 9px; border-radius: 50%; background: #34c759; transition: background .3s; flex: none; }\n header .dot.stale { background: #f0a020; }\n header .stamp { margin-left: auto; opacity: .7; font-variant-numeric: tabular-nums; }\n header .zoom { display: flex; align-items: center; gap: .3rem; }\n #zoomPct { font-size: 12px; opacity: .7; min-width: 3.2em; text-align: center; font-variant-numeric: tabular-nums; }\n .btn {\n background: #3b4754; color: #fff; border: 0; border-radius: 4px;\n padding: .3rem .7rem; font-size: 13px; cursor: pointer; font-family: inherit;\n }\n .btn:hover { background: #4b5763; }\n .btn:disabled { opacity: .4; cursor: default; }\n .btn.active { background: #2f6fed; }\n #storylinePicker {\n background: #3b4754; color: #fff; border: 0; border-radius: 4px;\n padding: .3rem .5rem; font-size: 13px; cursor: pointer; font-family: inherit;\n }\n #storylinePicker[hidden] { display: none; }\n #canvasLink { text-decoration: none; }\n #canvasLink[hidden] { display: none; }\n #storylinePicker option { color: #000; }\n #errorBanner {\n display: flex; align-items: center; gap: .6rem; flex: none;\n padding: .45rem .9rem; background: #8c2f28; color: #fff; font-size: 13px;\n }\n #errorBanner[hidden] { display: none; }\n #errorBanner button {\n margin-left: auto; background: none; border: 0; color: #fff;\n font-size: 16px; line-height: 1; cursor: pointer; opacity: .8;\n }\n #storyboard {\n display: flex; align-items: center; gap: .5rem; padding: .4rem .9rem; flex: none;\n background: #e8eaed; border-bottom: 1px solid #d0d3d7;\n }\n #storyboard[hidden] { display: none; }\n #storyboard .nav { background: none; border: 0; font-size: 18px; line-height: 1; cursor: pointer; color: #3b4754; padding: 0 .3rem; }\n #storyboard .nav:disabled { opacity: .3; cursor: default; }\n #filmstrip { display: flex; gap: .35rem; overflow-x: auto; }\n #filmstrip .slice {\n padding: .25rem .6rem; border-radius: 12px; background: #fff; border: 1px solid #d0d3d7;\n font-size: 12px; white-space: nowrap; cursor: pointer; color: #3b4754; font-family: inherit;\n }\n #filmstrip .slice.active { background: #2f6fed; border-color: #2f6fed; color: #fff; font-weight: 600; }\n #stage {\n flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden;\n touch-action: none; cursor: grab; user-select: none; -webkit-user-select: none;\n }\n #stage.dragging { cursor: grabbing; }\n #stage svg { width: 100%; height: 100%; display: block; }\n #docFlyout {\n position: absolute; top: 0; right: 0; bottom: 0; width: min(640px, 94%);\n background: #fff; border-left: 1px solid #d0d3d7; box-shadow: -2px 0 10px rgba(0, 0, 0, .15);\n display: flex; flex-direction: column; z-index: 5;\n transform: translateX(100%); transition: transform .2s ease;\n /* #stage sets these for its own drag-to-pan surface; the flyout is a normal\n panel — its text should be selectable and its cursor should look normal. */\n user-select: text; -webkit-user-select: text; cursor: auto; touch-action: auto;\n }\n #docFlyout.open { transform: translateX(0); }\n #docFlyout .docHead {\n display: flex; align-items: center; gap: .5rem; flex: none;\n padding: .55rem .9rem; background: #1f2933; color: #fff; font-size: 14px; font-weight: 600;\n }\n #docFlyout .docHead span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n #docFlyout .docHead button {\n margin-left: auto; background: none; border: 0; color: #fff;\n font-size: 18px; line-height: 1; cursor: pointer; opacity: .8; flex: none;\n }\n #docFlyout .docBody {\n /* min-height: 0 overrides a flex item's default min-height: auto, which would\n otherwise let this grow to fit all its content instead of scrolling — the\n actual cause of \"no way to scroll it\": without it the body just spills past\n the flyout's bottom edge, clipped invisibly by #stage's own overflow:hidden. */\n flex: 1 1 auto; min-height: 0; overflow-y: auto; overflow-x: hidden;\n padding: .9rem 1.1rem; font-size: 13px; line-height: 1.55; color: #202124;\n }\n #docFlyout .docBody.empty { color: #5f6368; font-style: italic; }\n #docFlyout .docBody img { max-width: 100%; height: auto; display: block; margin: .5em 0; }\n #docFlyout .docBody :is(h1, h2, h3, h4, h5, h6) { margin: 1em 0 .35em; line-height: 1.3; }\n #docFlyout .docBody :is(h1, h2, h3, h4, h5, h6):first-child { margin-top: 0; }\n #docFlyout .docBody p, #docFlyout .docBody ul, #docFlyout .docBody ol { margin: .5em 0; }\n #docFlyout .docBody ul, #docFlyout .docBody ol { padding-left: 1.3em; }\n #docFlyout .docBody a { color: #2f6fed; }\n #docFlyout .docBody code { background: #f0f1f3; padding: .1em .3em; border-radius: 3px; font-size: 12px; }\n #docFlyout .docBody pre { background: #f0f1f3; padding: .6rem; border-radius: 4px; overflow-x: auto; }\n #docFlyout .docBody pre code { background: none; padding: 0; }\n #docFlyout .docBody blockquote {\n margin: .5em 0; padding-left: .8em; border-left: 3px solid #d0d3d7; color: #5f6368;\n }\n #docFlyout .docBody table { border-collapse: collapse; margin: .5em 0; }\n #docFlyout .docBody :is(th, td) { border: 1px solid #d0d3d7; padding: .3em .5em; }\n /* GH #402: section-aware slice-doc rendering — the frontmatter badge row, Given/When/Then\n scenario cards, INV-* chips, and field-table column styling catalog/sliceDocRender.ts and\n catalog/docBadge.ts build; see catalog/pages.ts for the static catalog's matching rules. */\n #docFlyout .docBody .doc-badge-row { display: flex; flex-wrap: wrap; gap: .4rem; margin: 0 0 1rem; }\n #docFlyout .docBody .doc-badge {\n display: inline-block; padding: .15rem .55rem; border-radius: 999px; font-size: 11px;\n font-weight: 600; letter-spacing: .01em; background: #e4e7eb; color: #3b4754;\n }\n #docFlyout .docBody .doc-badge--pattern { background: #e1ecff; color: #1d4ed8; }\n #docFlyout .docBody .doc-badge--version { background: #eef0f3; color: #52606d; font-variant-numeric: tabular-nums; }\n #docFlyout .docBody .doc-badge--status { background: #fef3cf; color: #8a6116; text-transform: capitalize; }\n #docFlyout .docBody .doc-badge--drift-in-sync { background: #d8f3dc; color: #1b7a3d; }\n #docFlyout .docBody .doc-badge--drift-uncertified, #docFlyout .docBody .doc-badge--drift-unpropagated-delta { background: #ffe9c7; color: #8a5300; }\n #docFlyout .docBody .doc-badge--drift-implemented-without-link { background: #fbd8d4; color: #9b2c20; }\n #docFlyout .docBody .doc-badge--drift-never-implemented { background: #e4e7eb; color: #52606d; }\n #docFlyout .docBody .scenario-card { border: 1px solid #d0d3d7; border-radius: 8px; margin: 1em 0; overflow: hidden; background: #fff; }\n #docFlyout .docBody .scenario-card-title { margin: 0; padding: .5rem .75rem; background: #f0f2f5; font-size: .95rem; border-bottom: 1px solid #d0d3d7; }\n #docFlyout .docBody .scenario-step { display: flex; gap: .6rem; padding: .45rem .75rem; border-top: 1px solid #eceef1; align-items: flex-start; }\n #docFlyout .docBody .scenario-step:first-of-type { border-top: 0; }\n #docFlyout .docBody .scenario-step-label {\n flex: none; min-width: 4.2em; font-size: 11px; font-weight: 700; text-transform: uppercase;\n letter-spacing: .04em; color: #52606d; padding-top: .15em;\n }\n #docFlyout .docBody .scenario-step--given .scenario-step-label { color: #1d4ed8; }\n #docFlyout .docBody .scenario-step--when .scenario-step-label { color: #8a5300; }\n #docFlyout .docBody .scenario-step--then .scenario-step-label { color: #1b7a3d; }\n #docFlyout .docBody .scenario-step-body { flex: 1 1 auto; }\n #docFlyout .docBody .scenario-step-body ul { margin: .2em 0; padding-left: 1.1em; }\n #docFlyout .docBody .inv-chip { display: inline-block; padding: .05em .4em; border-radius: 4px; font-size: .85em; font-family: ui-monospace, Menlo, monospace; }\n #docFlyout .docBody .inv-chip--def { background: #1f2933; color: #fff; scroll-margin-top: 1rem; }\n #docFlyout .docBody .inv-chip--ref { background: #eef0f3; color: #1d4ed8; text-decoration: none; }\n #docFlyout .docBody .inv-chip--ref:hover { text-decoration: underline; }\n #docFlyout .docBody .inv-chip--external { background: #eef0f3; color: #52606d; }\n #docFlyout .docBody table.field-table th.col-type, #docFlyout .docBody table.field-table td.col-type { font-family: ui-monospace, Menlo, monospace; font-size: .85em; color: #3b4754; white-space: nowrap; }\n #docFlyout .docBody table.field-table th.col-notes, #docFlyout .docBody table.field-table td.col-notes { color: #52606d; }\n #docFlyout .docBody table.field-table th.col-flag, #docFlyout .docBody table.field-table td.col-flag { text-align: center; white-space: nowrap; }\n </style>\n</head>\n<body>\n <header>\n <span class=\"dot\" id=\"dot\"></span>\n <span>Event Model — live</span>\n <button class=\"btn\" id=\"reviewToggle\" title=\"Step through one slice at a time\">Review mode</button>\n <a class=\"btn\" id=\"canvasLink\" href=\"/map\" title=\"Back to the storyline canvas\" hidden>‹ Storylines</a>\n <select id=\"storylinePicker\" title=\"Filter the live view to one storyline\" hidden></select>\n <span class=\"zoom\">\n <button class=\"btn\" id=\"zoomOut\" aria-label=\"Zoom out\" title=\"Zoom out (-)\">−</button>\n <span id=\"zoomPct\">100%</span>\n <button class=\"btn\" id=\"zoomIn\" aria-label=\"Zoom in\" title=\"Zoom in (+)\">+</button>\n <button class=\"btn\" id=\"fitBtn\" title=\"Fit the whole diagram (0)\">Fit</button>\n </span>\n <span class=\"stamp\" id=\"stamp\">connecting…</span>\n </header>\n <div id=\"errorBanner\" hidden>\n <span id=\"errorText\"></span>\n <button id=\"errorDismiss\" aria-label=\"Dismiss\">×</button>\n </div>\n <nav id=\"storyboard\" hidden>\n <button class=\"nav\" id=\"prevSlice\" aria-label=\"Previous slice\">‹</button>\n <div id=\"filmstrip\"></div>\n <button class=\"nav\" id=\"nextSlice\" aria-label=\"Next slice\">›</button>\n </nav>\n <div id=\"stage\">\n <aside id=\"docFlyout\">\n <div class=\"docHead\">\n <span id=\"docFlyoutTitle\"></span>\n <button id=\"docFlyoutClose\" aria-label=\"Close\">×</button>\n </div>\n <div class=\"docBody\" id=\"docFlyoutBody\"></div>\n </aside>\n </div>\n <script>\n // ?svg=<name>.svg picks the model file (default model.svg), so one server\n // serves any model without editing this page. The allowlist keeps the\n // value a bare *.svg filename inside the served directory — no path\n // separator, no drive/scheme colon — so it is safe to fetch (always\n // URI-encoded) and to display (via textContent only, never markup).\n // Spaces and unicode are fine: em watch prints whatever the output file\n // is called, and that URL has to work.\n const svgParam = new URLSearchParams(location.search).get(\"svg\");\n const SVG_FILE = svgParam && /^[^/\\\\:]+\\.svg$/.test(svgParam) ? svgParam : \"model.svg\";\n\n const stage = document.getElementById(\"stage\");\n const stamp = document.getElementById(\"stamp\");\n const dot = document.getElementById(\"dot\");\n const banner = document.getElementById(\"errorBanner\");\n const bannerText = document.getElementById(\"errorText\");\n const reviewToggle = document.getElementById(\"reviewToggle\");\n const storylinePicker = document.getElementById(\"storylinePicker\");\n const canvasLink = document.getElementById(\"canvasLink\");\n const storyboard = document.getElementById(\"storyboard\");\n const filmstrip = document.getElementById(\"filmstrip\");\n const prevBtn = document.getElementById(\"prevSlice\");\n const nextBtn = document.getElementById(\"nextSlice\");\n const zoomPct = document.getElementById(\"zoomPct\");\n const docFlyout = document.getElementById(\"docFlyout\");\n const docFlyoutTitle = document.getElementById(\"docFlyoutTitle\");\n const docFlyoutBody = document.getElementById(\"docFlyoutBody\");\n const docFlyoutClose = document.getElementById(\"docFlyoutClose\");\n\n // --- review-mode state: outside the load pipeline on purpose, see the\n // file-level comment above — a reload re-applies it, never resets it. ---\n let reviewMode = false;\n let activeSlice = null; // slice index, or null for \"show all\"\n let slices = []; // [{index, name, x0, x1, docHtml}], from this load's <metadata>\n let rowLabels = null; // {x0, x1} | null, the swimlane label column's range\n\n // --- storyline picker (GH #309): outside the load pipeline isn't needed here — unlike\n // review/doc-flyout state, switching storylines navigates to a new page (see below), so\n // there's nothing to preserve across a reload. ---\n let storylineVariants = []; // [{name, file, current}], from this load's <metadata id=\"em-storylines\">, or []\n\n // --- doc flyout state: independent of review mode, outside the load\n // pipeline for the same reason review state is — see the file comment. ---\n let openSliceIndex = null; // slice index whose doc the flyout shows, or null when closed\n\n // --- camera state ---\n let svgEl = null; // the inline <svg>, null until the first good load\n let natural = null; // the loaded SVG's own viewBox: its full-fit frame\n let cam = null; // the current viewBox {x, y, w, h}\n // True while the user has never panned/zoomed (or pressed Fit last): a\n // reload then re-fits to the new natural box instead of pinning the old\n // absolute view onto a diagram that may have grown or shrunk.\n let atFit = true;\n\n function stageRect() { return stage.getBoundingClientRect(); }\n\n // Uniform px→model scale of the current view. The svg keeps the default\n // preserveAspectRatio (xMidYMid meet): one scale factor, centered.\n function scale() {\n const r = stageRect();\n return Math.max(cam.w / Math.max(1, r.width), cam.h / Math.max(1, r.height));\n }\n\n // Exact client-px → model-coords inverse of the \"meet\" mapping, so\n // zoom-about-cursor keeps the model point under the cursor exactly.\n function toSvg(cx, cy) {\n const r = stageRect();\n const s = scale();\n const ox = cam.x - (r.width * s - cam.w) / 2;\n const oy = cam.y - (r.height * s - cam.h) / 2;\n return { x: ox + (cx - r.left) * s, y: oy + (cy - r.top) * s };\n }\n\n // Fit a model-space rect into the stage aspect, centered.\n function frameRect(x, y, w, h) {\n const r = stageRect();\n const aspect = Math.max(1, r.width) / Math.max(1, r.height);\n let fw = w, fh = h;\n if (fw / fh < aspect) fw = fh * aspect; else fh = fw / aspect;\n return { x: x + (w - fw) / 2, y: y + (h - fh) / 2, w: fw, h: fh };\n }\n\n function fitCam() { return frameRect(natural.x, natural.y, natural.w, natural.h); }\n\n // New cam zoomed by the given factor about model point p, clamped to\n // 0.5×–30× of fit so the diagram is neither lost nor over-magnified.\n function zoomedCam(p, factor) {\n const fitW = fitCam().w;\n const w = Math.min(fitW * 2, Math.max(fitW / 30, cam.w / factor));\n const f = cam.w / w;\n return clampPanOf({\n x: p.x - (p.x - cam.x) / f,\n y: p.y - (p.y - cam.y) / f,\n w: cam.w / f,\n h: cam.h / f,\n });\n }\n\n // Keep at least half of min(view, diagram) in frame on each axis, so the\n // diagram can never be panned fully out of view.\n function clampPanOf(c) {\n if (!natural) return c;\n const kx = Math.min(c.w, natural.w) / 2;\n const ky = Math.min(c.h, natural.h) / 2;\n c.x = Math.max(natural.x - c.w + kx, Math.min(natural.x + natural.w - kx, c.x));\n c.y = Math.max(natural.y - c.h + ky, Math.min(natural.y + natural.h - ky, c.y));\n return c;\n }\n function clampPan() { cam = clampPanOf(cam); }\n\n function applyCam() {\n if (!svgEl || !cam) return;\n svgEl.setAttribute(\"viewBox\", cam.x + \" \" + cam.y + \" \" + cam.w + \" \" + cam.h);\n if (natural) zoomPct.textContent = Math.round((fitCam().w / cam.w) * 100) + \"%\";\n }\n\n function markMoved() { atFit = false; }\n\n // Animated camera move; any direct manipulation (drag/wheel/pinch) cancels\n // it and takes over from wherever the animation got to.\n let flyHandle = null;\n function flyTo(target) {\n cancelFly();\n if (!cam) { cam = target; applyCam(); return; }\n const from = { x: cam.x, y: cam.y, w: cam.w, h: cam.h };\n const t0 = performance.now();\n const DUR = 280;\n function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }\n function frame(now) {\n const t = Math.min(1, (now - t0) / DUR);\n const k = ease(t);\n cam = {\n x: from.x + (target.x - from.x) * k,\n y: from.y + (target.y - from.y) * k,\n w: from.w + (target.w - from.w) * k,\n h: from.h + (target.h - from.h) * k,\n };\n applyCam();\n flyHandle = t < 1 ? requestAnimationFrame(frame) : null;\n }\n flyHandle = requestAnimationFrame(frame);\n }\n function cancelFly() {\n if (flyHandle !== null) { cancelAnimationFrame(flyHandle); flyHandle = null; }\n }\n\n function goFit() {\n if (!natural) return;\n atFit = true;\n flyTo(fitCam());\n }\n\n function zoomStep(factor) {\n if (!cam) return;\n const r = stageRect();\n markMoved();\n flyTo(zoomedCam(toSvg(r.left + r.width / 2, r.top + r.height / 2), factor));\n }\n\n // --- load pipeline: fetch → parse → validate → swap. A failure at any\n // step keeps the last good frame on screen and retries with backoff. ---\n let loadId = 0; // only the latest load's result may touch the DOM\n let retryTimer = null;\n let retryAttempt = 0;\n const RETRY_MS = [500, 1000, 2000, 4000]; // then every 4s\n\n function reload() {\n const id = ++loadId; // supersedes any in-flight load and pending retry\n if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null; }\n dot.classList.add(\"stale\");\n fetch(encodeURIComponent(SVG_FILE) + \"?t=\" + Date.now(), { cache: \"no-store\" })\n .then(function (res) {\n if (!res.ok) throw new Error(\"HTTP \" + res.status);\n return res.text();\n })\n .then(function (text) {\n if (id !== loadId) return;\n const doc = new DOMParser().parseFromString(text, \"image/svg+xml\");\n const root = doc.documentElement;\n // Validate before touching the DOM — a truncated write mid-render or\n // a non-SVG response must not blank the shared screen.\n if (!root || root.localName !== \"svg\" || doc.querySelector(\"parsererror\")) {\n fail(\"SVG parse error\");\n return;\n }\n applyLoad(root);\n })\n .catch(function (err) {\n if (id !== loadId) return;\n fail(err && err.message ? err.message : String(err));\n });\n }\n\n function applyLoad(root) {\n const next = document.importNode(root, true);\n const vb = (next.getAttribute(\"viewBox\") || \"\").trim().split(/[\\s,]+/).map(Number);\n // CSS sizes the element; the viewBox is the camera.\n next.removeAttribute(\"width\");\n next.removeAttribute(\"height\");\n if (svgEl) svgEl.replaceWith(next); else stage.appendChild(next);\n svgEl = next;\n natural = vb.length === 4 && vb.every(isFinite) && vb[2] > 0 && vb[3] > 0\n ? { x: vb[0], y: vb[1], w: vb[2], h: vb[3] }\n : null;\n if (!natural) {\n // No usable viewBox (not something our renderer emits): frame the ink.\n const bb = next.getBBox();\n natural = { x: bb.x, y: bb.y, w: Math.max(1, bb.width), h: Math.max(1, bb.height) };\n }\n\n const metaEl = document.getElementById(\"em-slices\");\n let meta = null;\n if (metaEl) { try { meta = JSON.parse(metaEl.textContent); } catch (err) { meta = null; } }\n slices = (meta && meta.slices) || [];\n rowLabels = (meta && meta.rowLabels) || null;\n\n // GH #309: the storyline-picker list travels the same way em-slices does — read fresh\n // on every load (no survives-a-reload state to preserve; picking an option navigates).\n const storyMetaEl = document.getElementById(\"em-storylines\");\n let storyMeta = null;\n if (storyMetaEl) { try { storyMeta = JSON.parse(storyMetaEl.textContent); } catch (err) { storyMeta = null; } }\n storylineVariants = storyMeta || [];\n renderStorylinePicker();\n // The model may have changed shape (slice removed) between loads. But a\n // reload with NO metadata at all (a transient bad render) must not eject\n // the facilitator from review mode or lose their place — review state\n // survives every reload; the position is only remapped when there are\n // real slices to remap onto.\n if (slices.length) {\n if (activeSlice !== null && !slices.some(function (s) { return s.index === activeSlice; })) {\n activeSlice = slices[0].index;\n }\n if (reviewMode && activeSlice === null) activeSlice = slices[0].index;\n // Same survives-a-reload treatment for the doc flyout: refresh its\n // content (the doc may have changed) if its slice is still here,\n // close it if the slice is gone — but only on a reload that actually\n // carries real slice metadata, never on a transient bad render.\n if (openSliceIndex !== null) {\n if (slices.some(function (s) { return s.index === openSliceIndex; })) renderDocFlyout();\n else closeDoc();\n }\n }\n renderChrome();\n applyDim();\n\n // Camera across reloads: review mode re-flies to the (possibly moved)\n // active slice; an untouched/fitted camera re-fits to the new natural\n // box; otherwise the absolute view is preserved (including while the\n // active slice is temporarily missing from the metadata).\n cancelFly();\n if (reviewMode && activeSlice !== null &&\n slices.some(function (s) { return s.index === activeSlice; })) {\n flyTo(sliceFrame(activeSlice));\n } else if (atFit || !cam) {\n cam = fitCam();\n applyCam();\n } else {\n clampPan();\n applyCam();\n }\n\n banner.hidden = true;\n retryAttempt = 0;\n stamp.textContent = new Date().toLocaleTimeString();\n dot.classList.remove(\"stale\");\n }\n\n function fail(msg) {\n bannerText.textContent = \"Couldn't load \" + SVG_FILE + \" — \" + msg + \" · retrying…\";\n banner.hidden = false;\n const delay = RETRY_MS[Math.min(retryAttempt, RETRY_MS.length - 1)];\n retryAttempt += 1;\n retryTimer = setTimeout(reload, delay);\n }\n\n // --- review mode / storyboard ---\n\n // The union, in natural/model coordinates, of every element actually\n // belonging to slice `index` — lets sliceFrame zoom tightly around what a\n // slice contains instead of always keeping the model's FULL height in\n // frame. Always requiring the full height meant a model with few slices\n // but several swimlane rows could never zoom in tighter than Fit: letting\n // frameRect grow the frame's width to preserve that full height, on a\n // landscape stage, routinely produced a frame wider than the whole\n // diagram. getBoundingClientRect() + toSvg() reuses the exact camera math\n // already trusted for zoom-about-cursor, so this needs no new server-side\n // geometry or metadata. Returns null when there's no usable geometry yet\n // (nothing tagged, or a non-layout test environment) — sliceFrame falls\n // back to the old full-height framing in that case.\n function sliceContentBox(index) {\n if (!svgEl || !cam) return null;\n const nodes = svgEl.querySelectorAll('[data-slice=\"' + index + '\"]');\n let box = null;\n nodes.forEach(function (node) {\n const r = node.getBoundingClientRect();\n if (r.width <= 0 || r.height <= 0) return;\n const p0 = toSvg(r.left, r.top);\n const p1 = toSvg(r.right, r.bottom);\n const nx0 = Math.min(p0.x, p1.x), nx1 = Math.max(p0.x, p1.x);\n const ny0 = Math.min(p0.y, p1.y), ny1 = Math.max(p0.y, p1.y);\n box = box\n ? { x0: Math.min(box.x0, nx0), x1: Math.max(box.x1, nx1), y0: Math.min(box.y0, ny0), y1: Math.max(box.y1, ny1) }\n : { x0: nx0, x1: nx1, y0: ny0, y1: ny1 };\n });\n return box;\n }\n\n function sliceFrame(index) {\n let slice = null;\n for (const s of slices) if (s.index === index) slice = s;\n if (!slice || !natural) return fitCam();\n const PAD = 24; // breathing room around the framed slice\n const content = sliceContentBox(index);\n let x0 = rowLabels ? Math.min(rowLabels.x0, slice.x0) : slice.x0;\n let x1 = slice.x1;\n // Vertically, frame just the slice's own content when we can measure it;\n // otherwise fall back to the model's full height (every swimlane row).\n let y0 = natural.y, y1 = natural.y + natural.h;\n if (content) {\n x0 = Math.min(x0, content.x0);\n x1 = Math.max(x1, content.x1);\n y0 = content.y0;\n y1 = content.y1;\n }\n x0 = Math.max(natural.x, x0 - PAD);\n x1 = Math.min(natural.x + natural.w, x1 + PAD);\n y0 = Math.max(natural.y, y0 - PAD);\n y1 = Math.min(natural.y + natural.h, y1 + PAD);\n return frameRect(x0, y0, Math.max(1, x1 - x0), Math.max(1, y1 - y0));\n }\n\n // Populates the storyline <select> from this load's em-storylines metadata — \"Full\n // model\" plus one option per declared storyline, pre-selected on whichever is current.\n // Hidden entirely when the model declares none (storylineVariants is []).\n function renderStorylinePicker() {\n storylinePicker.hidden = storylineVariants.length === 0;\n // GH #422: the breadcrumb back to the canvas comes and goes with the picker.\n canvasLink.hidden = storylineVariants.length === 0;\n storylinePicker.innerHTML = \"\";\n for (const v of storylineVariants) {\n const opt = document.createElement(\"option\");\n opt.value = v.file;\n opt.textContent = v.name;\n opt.selected = !!v.current;\n storylinePicker.appendChild(opt);\n }\n }\n\n function renderChrome() {\n storyboard.hidden = slices.length === 0;\n reviewToggle.classList.toggle(\"active\", reviewMode);\n reviewToggle.disabled = slices.length === 0;\n filmstrip.innerHTML = \"\";\n for (const s of slices) {\n const b = document.createElement(\"button\");\n b.className = \"slice\" + (s.index === activeSlice ? \" active\" : \"\");\n b.textContent = s.name;\n b.addEventListener(\"click\", function () { goTo(s.index); });\n filmstrip.appendChild(b);\n }\n const first = slices[0], last = slices[slices.length - 1];\n prevBtn.disabled = activeSlice === null || !first || activeSlice <= first.index;\n nextBtn.disabled = activeSlice === null || !last || activeSlice >= last.index;\n }\n\n function applyDim() {\n if (!svgEl) return;\n svgEl.querySelectorAll(\"[data-slice]\").forEach(function (node) {\n const dim = reviewMode && activeSlice !== null &&\n Number(node.getAttribute(\"data-slice\")) !== activeSlice;\n node.classList.toggle(\"em-slice-dim\", dim);\n });\n }\n\n // --- doc flyout: click a slice's own node to see its design doc, purely\n // an overlay — it never moves the camera or touches review state. ---\n function sliceByIndex(index) {\n for (const s of slices) if (s.index === index) return s;\n return null;\n }\n\n function renderDocFlyout() {\n const s = sliceByIndex(openSliceIndex);\n if (!s) { docFlyout.classList.remove(\"open\"); return; }\n docFlyoutTitle.textContent = s.name;\n docFlyoutBody.classList.toggle(\"empty\", !s.docHtml);\n if (s.docHtml) docFlyoutBody.innerHTML = s.docHtml;\n else docFlyoutBody.textContent = \"No design doc yet for this slice.\";\n docFlyout.classList.add(\"open\");\n }\n\n function openDoc(index) {\n openSliceIndex = index;\n renderDocFlyout();\n }\n\n function closeDoc() {\n openSliceIndex = null;\n docFlyout.classList.remove(\"open\");\n }\n\n function goTo(index) {\n activeSlice = index;\n renderChrome();\n applyDim();\n if (reviewMode) flyTo(sliceFrame(index));\n }\n\n function step(delta) {\n if (!slices.length) return;\n if (activeSlice === null) { goTo(slices[0].index); return; }\n const i = slices.findIndex(function (s) { return s.index === activeSlice; });\n const next = slices[Math.min(slices.length - 1, Math.max(0, i + delta))];\n if (next) goTo(next.index);\n }\n\n function exitReview() {\n reviewMode = false;\n renderChrome();\n applyDim();\n goFit();\n }\n\n reviewToggle.addEventListener(\"click\", function () {\n if (reviewMode) { exitReview(); return; }\n reviewMode = true;\n if (activeSlice === null && slices.length) activeSlice = slices[0].index;\n renderChrome();\n applyDim();\n if (activeSlice !== null) flyTo(sliceFrame(activeSlice));\n });\n prevBtn.addEventListener(\"click\", function () { step(-1); });\n nextBtn.addEventListener(\"click\", function () { step(1); });\n // GH #309: switching storylines is a plain navigation to this same page with a new\n // ?svg= — the chosen file is already on disk (em watch --serve writes every variant on\n // every build), so there's nothing to fetch or reload by hand.\n storylinePicker.addEventListener(\"change\", function () {\n location.search = \"svg=\" + encodeURIComponent(storylinePicker.value);\n });\n document.getElementById(\"zoomIn\").addEventListener(\"click\", function () { zoomStep(1.5); });\n document.getElementById(\"zoomOut\").addEventListener(\"click\", function () { zoomStep(1 / 1.5); });\n document.getElementById(\"fitBtn\").addEventListener(\"click\", goFit);\n document.getElementById(\"errorDismiss\").addEventListener(\"click\", function () {\n banner.hidden = true; // retries keep going quietly\n });\n docFlyoutClose.addEventListener(\"click\", closeDoc);\n\n // --- direct manipulation: drag pan, wheel pan, ctrl+wheel / pinch zoom ---\n const pointers = new Map(); // pointerId -> last client {x, y}\n let drag = null; // { id, startX, startY, active } — single-pointer pan\n let suppressClick = false; // swallow the click that ends a drag/pinch\n\n // The doc flyout is a normal interactive DOM panel stacked over the stage\n // (see #docFlyout's z-index) — its own scrolling, text selection, and link\n // clicks must behave normally, not get hijacked by the stage's direct-\n // manipulation gestures (drag-pan, wheel-pan/zoom, double-click-to-zoom).\n function overFlyout(e) {\n return !!(e.target.closest && e.target.closest(\"#docFlyout\"));\n }\n\n stage.addEventListener(\"pointerdown\", function (e) {\n if (overFlyout(e)) return;\n if (e.button !== 0) return;\n cancelFly(); // direct manipulation takes over from any animation\n pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });\n if (pointers.size === 1) {\n suppressClick = false;\n drag = { id: e.pointerId, startX: e.clientX, startY: e.clientY, active: false };\n } else {\n // Second pointer: pinch. Capture both so the gesture survives leaving\n // the stage; a pinch must never end in a note-link click.\n drag = null;\n suppressClick = true;\n stage.classList.add(\"dragging\");\n for (const id of pointers.keys()) {\n try { stage.setPointerCapture(id); } catch (err) { /* pointer already gone */ }\n }\n }\n });\n\n stage.addEventListener(\"pointermove\", function (e) {\n const pt = pointers.get(e.pointerId);\n if (!pt) return;\n if (pointers.size >= 2) { pinchMove(e, pt); return; }\n if (!drag || drag.id !== e.pointerId || !cam) { pt.x = e.clientX; pt.y = e.clientY; return; }\n if (!drag.active) {\n // Movement threshold before treating it as a drag — capturing the\n // pointer immediately would retarget the eventual click away from the\n // SVG's note links. Until then pt stays at the pointerdown position,\n // so the first panning move applies the full accumulated delta.\n if (Math.hypot(e.clientX - drag.startX, e.clientY - drag.startY) < 4) return;\n drag.active = true;\n suppressClick = true;\n stage.setPointerCapture(e.pointerId);\n stage.classList.add(\"dragging\");\n }\n const s = scale();\n cam.x -= (e.clientX - pt.x) * s;\n cam.y -= (e.clientY - pt.y) * s;\n pt.x = e.clientX; pt.y = e.clientY;\n clampPan();\n applyCam();\n markMoved();\n });\n\n // Incremental pinch: pan by the midpoint's movement, then zoom about the\n // new midpoint by the ratio of pointer distances.\n function pinchMove(e, pt) {\n let other = null;\n for (const entry of pointers) if (entry[0] !== e.pointerId) other = entry[1];\n if (!other || !cam) { pt.x = e.clientX; pt.y = e.clientY; return; }\n const oldMid = { x: (pt.x + other.x) / 2, y: (pt.y + other.y) / 2 };\n const oldDist = Math.hypot(pt.x - other.x, pt.y - other.y);\n pt.x = e.clientX; pt.y = e.clientY;\n const newMid = { x: (pt.x + other.x) / 2, y: (pt.y + other.y) / 2 };\n const newDist = Math.hypot(pt.x - other.x, pt.y - other.y);\n const s = scale();\n cam.x -= (newMid.x - oldMid.x) * s;\n cam.y -= (newMid.y - oldMid.y) * s;\n if (oldDist > 0 && newDist > 0) cam = zoomedCam(toSvg(newMid.x, newMid.y), newDist / oldDist);\n clampPan();\n applyCam();\n markMoved();\n }\n\n function endPointer(e) {\n if (!pointers.has(e.pointerId)) return; // not a pointer we were tracking\n pointers.delete(e.pointerId);\n if (stage.hasPointerCapture && stage.hasPointerCapture(e.pointerId)) {\n stage.releasePointerCapture(e.pointerId);\n }\n if (drag && drag.id === e.pointerId) drag = null;\n if (pointers.size === 1) {\n // Pinch collapsed to one finger: continue as an already-active pan.\n const rest = pointers.entries().next().value;\n drag = { id: rest[0], startX: rest[1].x, startY: rest[1].y, active: true };\n }\n if (pointers.size === 0) stage.classList.remove(\"dragging\");\n }\n // On window, not the stage: a pointer that goes down on the stage but is\n // released elsewhere (before the drag threshold captures it) must still be\n // cleared, or its stale Map entry would misclassify every later gesture as\n // a pinch. endPointer ignores pointers it never tracked.\n window.addEventListener(\"pointerup\", endPointer);\n window.addEventListener(\"pointercancel\", endPointer);\n\n stage.addEventListener(\"click\", function (e) {\n if (suppressClick) { suppressClick = false; e.preventDefault(); e.stopPropagation(); }\n }, true);\n\n // A plain click landing on an element's own node group (not a note/issue/\n // divergence marker — those live in separate groups with no data-slice\n // ancestor) opens that slice's doc flyout. A drag/pinch's trailing click\n // never reaches here: the capturing listener above stops it first.\n stage.addEventListener(\"click\", function (e) {\n const node = e.target.closest && e.target.closest(\"[data-slice]\");\n if (node) openDoc(Number(node.getAttribute(\"data-slice\")));\n });\n\n stage.addEventListener(\"wheel\", function (e) {\n if (overFlyout(e)) return; // let the flyout's own overflow-y:auto scroll natively\n e.preventDefault(); // the page itself must never scroll or browser-zoom\n if (!cam) return;\n cancelFly();\n const k = e.deltaMode === 1 ? 16 : 1; // line-mode deltas (Firefox) → ~px\n if (e.ctrlKey) {\n // ctrl+wheel is also what a trackpad pinch emits.\n cam = zoomedCam(toSvg(e.clientX, e.clientY), Math.exp(-e.deltaY * k * 0.01));\n } else {\n const s = scale();\n cam.x += e.deltaX * k * s;\n cam.y += e.deltaY * k * s;\n clampPan();\n }\n applyCam();\n markMoved();\n }, { passive: false });\n\n stage.addEventListener(\"dblclick\", function (e) {\n if (overFlyout(e)) return;\n if (!cam) return;\n e.preventDefault();\n markMoved();\n flyTo(zoomedCam(toSvg(e.clientX, e.clientY), 2));\n });\n\n document.addEventListener(\"keydown\", function (e) {\n // Modified keys stay with the browser; shift is allowed (\"+\" needs it\n // on many layouts). Guard against future text inputs, too.\n if (e.ctrlKey || e.metaKey || e.altKey) return;\n const t = e.target;\n if (t && (t.tagName === \"INPUT\" || t.tagName === \"TEXTAREA\" || t.isContentEditable)) return;\n if (e.key === \"+\" || e.key === \"=\") { e.preventDefault(); zoomStep(1.5); }\n else if (e.key === \"-\") { e.preventDefault(); zoomStep(1 / 1.5); }\n else if (e.key === \"0\" || e.key === \"f\") { e.preventDefault(); goFit(); }\n else if (e.key === \"Escape\") {\n if (openSliceIndex !== null) closeDoc();\n else if (reviewMode) exitReview();\n }\n else if (reviewMode) {\n if (e.key === \"ArrowLeft\") { e.preventDefault(); step(-1); }\n else if (e.key === \"ArrowRight\") { e.preventDefault(); step(1); }\n else if (e.key === \"Home\") { e.preventDefault(); if (slices.length) goTo(slices[0].index); }\n else if (e.key === \"End\") { e.preventDefault(); if (slices.length) goTo(slices[slices.length - 1].index); }\n }\n });\n\n window.addEventListener(\"resize\", function () {\n if (!natural || !cam) return;\n // Keep what the presenter framed: an active slice re-frames to the new\n // aspect; a fitted view re-fits; a hand-placed camera stays put.\n if (reviewMode && activeSlice !== null) { cam = sliceFrame(activeSlice); applyCam(); }\n else if (atFit) { cam = fitCam(); applyCam(); }\n });\n\n reload();\n\n const es = new EventSource(\"/__events\");\n // onopen reloads only on REconnect (laptop sleep, server restart): pushes\n // sent in the meantime were missed. The initial connect skips it — the\n // eager reload() above is already in flight, and doubling it would fetch\n // and parse every page open twice.\n let firstOpen = true;\n es.onopen = function () {\n retryAttempt = 0;\n if (firstOpen) { firstOpen = false; return; }\n reload();\n };\n es.onmessage = function () { retryAttempt = 0; reload(); };\n es.onerror = function () { dot.classList.add(\"stale\"); };\n // A save that failed to render (parse/render error): the server pushes the\n // message, the last good diagram stays up, and the banner says why the\n // screen is stale — it must never silently show an outdated model. No\n // retry here: refetching can only return the old file; the next successful\n // render pushes a reload that clears the banner.\n es.addEventListener(\"renderError\", function (e) {\n if (retryTimer !== null) { clearTimeout(retryTimer); retryTimer = null; }\n bannerText.textContent = \"Model didn't render — \" + e.data + \" — showing last good diagram\";\n banner.hidden = false;\n dot.classList.add(\"stale\");\n });\n </script>\n</body>\n</html>\n";
|
|
5
5
|
//# sourceMappingURL=viewerHtml.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"viewerHtml.js","sourceRoot":"","sources":["../../src/render/viewerHtml.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,0FAA0F;AAC1F,0DAA0D;AAC1D,MAAM,CAAC,MAAM,WAAW,GAAW,
|
|
1
|
+
{"version":3,"file":"viewerHtml.js","sourceRoot":"","sources":["../../src/render/viewerHtml.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,0FAA0F;AAC1F,0DAA0D;AAC1D,MAAM,CAAC,MAAM,WAAW,GAAW,y72CAAy72C,CAAC"}
|
package/dist/templates.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export declare const STARTER_EM = "model \"Order Fulfillment\"\n\npersona Customer\npersona Manager\n\ncontext Order\ncontext Payment\n\n# --- Command pattern: UI -> command -> event ---\nslice \"Browse Catalog\" {\n ui Product Catalog @Customer\n command Place Order\n event Order Placed @Order\n}\n\n# --- View pattern: event -> read model -> UI ---\nslice \"View Open Orders\" {\n view Open Orders from \"Order Placed\"\n ui Order List @Customer\n}\n\nslice \"Checkout\" {\n ui Checkout Screen @Customer\n command Submit Payment\n event Payment Requested @Payment\n}\n\n# --- View pattern: a human view of pending payments ---\nslice \"Manager Review\" {\n view Pending Payments from \"Payment Requested\"\n ui Payment Dashboard @Manager\n}\n\n# --- Read model the automation watches, in its own slice ---\nslice \"Payments To Process\" {\n view Payments To Process from \"Payment Requested\"\n}\n\n# --- Automation slice: the processor, the command it triggers, and that command's event ---\nslice \"Capture Payment\" {\n processor Payment Gateway from \"Payments To Process\"\n command Capture Payment\n event Payment Captured @Payment\n}\n\nslice \"Show Receipt\" {\n view Receipts from \"Payment Captured\"\n ui Receipt Screen @Customer\n}\n";
|
|
2
2
|
/** Raw copy of templates/model-readme.md, \`{{...}}\` placeholders intact — see scaffoldReadme(). */
|
|
3
|
-
export declare const MODEL_README_TEMPLATE = "# {{Model Name}}\n\n{{One-paragraph description of the business process(es) this event model covers.}}\n\n## Live view\nWhile modeling, run the live view so the team can watch the diagram update:\n\n```bash\nem watch {{model-name}}.em -o {{model-name}}.svg --serve # re-render + instant push-reload\n# then open the URL it prints (http://localhost:5173/?svg={{model-name}}.svg) and share the screen\n```\n\nPan/zoom to navigate the diagram (drag, scroll; **Fit** resets), and click **Review mode** in\nthe header for a slice-by-slice walkthrough. If a save fails to render, the viewer keeps the\nlast good diagram and shows an error banner until the next successful load.\n\nStatic render: `em render {{model-name}}.em -o {{model-name}}.svg`\n\n## Patterns legend\n- **State Change** \u2014 UI \u2192 Command \u2192 Event\n- **State View** \u2014 Event(s) \u2192 Read Model \u2192 UI\n- **Automation** \u2014 Read Model (slice before) \u2192 Processor + Command \u2192 Event, together\n- **Translation** \u2014 External input (or Read Model, slice before) \u2192 Translation + Command \u2192 Event, together\n\nBetween them these are the only legal connections: `ui \u2192 command`, `command \u2192 event`,\n`event \u2192 read model`, `read model \u2192 ui`, `read model \u2192 reaction`, `reaction \u2192 command`. A command\nnever reaches a read model directly \u2014 the event goes between them. Every slice is joined up at\nboth ends: something triggers each command (the screen it's issued from, or the reaction that\ntriggers it, also in this slice), and every event a command records is read by some read model \u2014 so each State\nChange slice is paired with the State View slice that projects its event. A read model repeated\nalong the timeline (`view X again`) shows the same projection at a later point; the instances are\nnever connected to one another.\n\n## Slices\n<!-- The canonical slice index \u2014 the ONE place slices are enumerated (the state file\n points here rather than keeping its own copy). Generated \u2014 run\n `em slice index {{model-name}}.em` to (re)write the table below from the model and its\n slice docs; never hand-edit between the markers. -->\n<!-- GENERATED:slices:start -->\n| # | Slice | Pattern | Status |
|
|
3
|
+
export declare const MODEL_README_TEMPLATE = "# {{Model Name}}\n\n{{One-paragraph description of the business process(es) this event model covers.}}\n\n## Live view\nWhile modeling, run the live view so the team can watch the diagram update:\n\n```bash\nem watch {{model-name}}.em -o {{model-name}}.svg --serve # re-render + instant push-reload\n# then open the URL it prints (http://localhost:5173/?svg={{model-name}}.svg) and share the screen\n```\n\nPan/zoom to navigate the diagram (drag, scroll; **Fit** resets), and click **Review mode** in\nthe header for a slice-by-slice walkthrough. If a save fails to render, the viewer keeps the\nlast good diagram and shows an error banner until the next successful load.\n\nStatic render: `em render {{model-name}}.em -o {{model-name}}.svg`\n\n## Patterns legend\n- **State Change** \u2014 UI \u2192 Command \u2192 Event\n- **State View** \u2014 Event(s) \u2192 Read Model \u2192 UI\n- **Automation** \u2014 Read Model (slice before) \u2192 Processor + Command \u2192 Event, together\n- **Translation** \u2014 External input (or Read Model, slice before) \u2192 Translation + Command \u2192 Event, together\n\nBetween them these are the only legal connections: `ui \u2192 command`, `command \u2192 event`,\n`event \u2192 read model`, `read model \u2192 ui`, `read model \u2192 reaction`, `reaction \u2192 command`. A command\nnever reaches a read model directly \u2014 the event goes between them. Every slice is joined up at\nboth ends: something triggers each command (the screen it's issued from, or the reaction that\ntriggers it, also in this slice), and every event a command records is read by some read model \u2014 so each State\nChange slice is paired with the State View slice that projects its event. A read model repeated\nalong the timeline (`view X again`) shows the same projection at a later point; the instances are\nnever connected to one another.\n\n## Storylines\n<!-- Generated \u2014 `em slice index {{model-name}}.em` writes one row per storyline between the\n markers (count, implemented, diagram); the canvas is `em render {{model-name}}.em --map`. -->\n<!-- GENERATED:storylines:start -->\n| Storyline | Slices | Implemented | Diagram |\n|-----------|--------|-------------|---------|\n<!-- GENERATED:storylines:end -->\n\n## Slices\n<!-- The canonical slice index \u2014 the ONE place slices are enumerated (the state file\n points here rather than keeping its own copy). Generated \u2014 run\n `em slice index {{model-name}}.em` to (re)write the table below from the model and its\n slice docs; never hand-edit between the markers. -->\n<!-- GENERATED:slices:start -->\n| # | Slice | Pattern | Status | Design doc |\n|---|-------|---------|--------|------------|\n<!-- GENERATED:slices:end -->\n\n## Status\nSee [`.event-modeling.md`](.event-modeling.md) for current phase, decisions, and open questions.\n";
|
|
4
4
|
/** Raw copy of templates/state.md, \`{{...}}\` placeholders intact — see scaffoldStateFile(). */
|
|
5
5
|
export declare const STATE_TEMPLATE = "<!--\nResumable progress state for an event model. Stored as <model>/.event-modeling.md\nThe skill reads this on `/event-modeling` (no arg) to resume where you left off.\nKeep it current at the end of every working session.\n-->\n\n# Event Modeling Progress \u2014 {{Model Name}}\n\n- **Model file:** `{{model-name}}.em`\n- **Em version:** {{X.Y.Z}}\n- **Current phase:** {{discover | extract | model | slice | implement | conform | review | validate}}\n- **Current step:** {{1\u20137, see methodology; or extraction round R1\u2013R7}}\n- **Last updated:** {{YYYY-MM-DD}}\n- **Last conformance:** {{YYYY-MM-DD @ <target-repo revision> \u2014 report: conformance/<date>-report.md | never}}\n- **Model version:** {{N | none}}\n- **Certified:** {{v<N> @ <target-repo revision> (YYYY-MM-DD) | never}}\n- **Last stakeholder review:** {{YYYY-MM-DD \u2014 attendees: see Participants | never}}\n\n## Session inputs\n- **Scope line:** {{one-line description of what's in/out of bounds for this model}}\n- **PRD / spec reference:** {{path, link, or \"none\"}}\n- **Headless/API model:** {{yes | no}}\n- **Source mode:** {{greenfield | extract-event-driven | extract-procedural}}\n- **Existing system refs:** {{repo paths, event-schema/topic locations, docs \u2014 or \"n/a\"}}\n\n## Participants\n<!-- Populate at session start. Live workshop: one human proxy relays questions to the room;\n attribute every answer/decision in the Decisions log to a named participant here. -->\n- {{Name}} \u2014 {{role}} \u2014 {{domain area}}\n\n## Extraction progress (existing-system models only \u2014 delete for greenfield)\n- [ ] R1. Candidate events (extracted/synthesized, filtered, confirmed)\n- [ ] R2. Timeline order (as-is narrative, actors/callers, first render)\n- [ ] R3. Commands / inputs\n- [ ] R4. Read models / outputs (validate from here)\n- [ ] R5. Boundaries & reactions (reaction shares its slice with the command it triggers)\n- [ ] R6. Gap & TBD reconciliation\n- [ ] R7. Convergence (render + validate clean, user confirmed as-is)\n\n## Steps completed\n- [ ] 1. Brainstorm events\n- [ ] 2. Plot / storyboard (personas + UI)\n- [ ] 3. Inputs (commands)\n- [ ] 4. Outputs (read models)\n- [ ] 5. Swimlanes & apply patterns\n- [ ] 6. Elaborate scenarios\n- [ ] 7. Evaluate completeness (`em validate` clean)\n\n## Decisions log\n<!-- Resolved choices, with the reasoning, so they aren't re-litigated. In a live workshop,\n attribute each entry to the participant who made the call (see Participants above).\n Every entry is a dated bullet (\"- YYYY-MM-DD: ...\"); em changelog skips any other line.\n Seam change request (a needed change to another model's public element; never edit that\n model, keep the open question blocked on its owners):\n - YYYY-MM-DD: seam change request \u2192 <producerKey>:<kind>.<slug> \u2014 <what> \u2014 <why> -->\n- {{YYYY-MM-DD}}: {{decision}} \u2014 {{why}} \u2014 by {{participant, if a live workshop}}\n\n## Usage log\n<!-- The team's only usage signal today (see docs/usage-data.md) \u2014 cheap and coarse on purpose.\n One line per session: phase(s) touched, and validate diagnostic *categories* hit (one of\n the exact fixed strings in docs/usage-data.md#categories, e.g. \"read model has no\n consumer\" \u2014 never the full instance message or any domain content). Append, never edit\n past entries. -->\n- {{YYYY-MM-DD}}: phases: {{discover | extract | model | slice | implement | conform | review | validate | watch, ...}} \u2014 validate: {{diagnostic category (docs/usage-data.md#categories), ... | none}}\n\n## Open questions / parking lot\n<!-- Unresolved items to bring back to the user. Never guess these. Metadata is optional and\n flat \u2014 add only what's known: source (ticket/conversation that spawned it), blocked on\n (who/what), revisit (when). -->\n- [ ] {{question}} \u2014 source: {{ticket/conversation link, optional}} \u2014 blocked on: {{who/what, optional}} \u2014 revisit: {{when, optional}}\n\n## Slice inventory\n<!-- Deliberately NOT a table: the canonical slice index (slice, pattern, status, doc link)\n lives in README.md's \"Slices\" section \u2014 one source of truth, updated there. This file\n only tracks resumable session state. -->\nSee `README.md` \u2192 **Slices** for the slice index and per-slice doc status.\n";
|
|
6
6
|
/** Raw copy of templates/constitution.md, \`{{...}}\` placeholders intact — see
|