@milehimikey/em 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/.claude/skills/event-modeling/SKILL.md +58 -404
  2. package/.claude/skills/event-modeling-conform/SKILL.md +77 -0
  3. package/.claude/skills/{event-modeling → event-modeling-conform}/reference/conform.md +39 -13
  4. package/.claude/skills/event-modeling-design/SKILL.md +139 -0
  5. package/.claude/skills/event-modeling-discover/SKILL.md +89 -0
  6. package/.claude/skills/{event-modeling → event-modeling-discover}/reference/extract.md +3 -2
  7. package/.claude/skills/event-modeling-implement/SKILL.md +40 -0
  8. package/.claude/skills/{event-modeling → event-modeling-implement}/reference/implement.md +3 -2
  9. package/.claude/skills/event-modeling-review/SKILL.md +58 -0
  10. package/.claude/skills/{event-modeling → event-modeling-shared}/reference/em-dsl.md +128 -84
  11. package/.claude/skills/{event-modeling → event-modeling-shared}/reference/methodology.md +6 -5
  12. package/.claude/skills/event-modeling-shared/reference/operating-principles.md +147 -0
  13. package/.claude/skills/{event-modeling → event-modeling-shared}/templates/model-readme.md +2 -2
  14. package/.claude/skills/{event-modeling → event-modeling-shared}/templates/slice.md +51 -8
  15. package/README.md +24 -4
  16. package/dist/catalog/build.js +9 -0
  17. package/dist/catalog/build.js.map +1 -1
  18. package/dist/catalog/docJoin.d.ts +15 -0
  19. package/dist/catalog/docJoin.js +8 -0
  20. package/dist/catalog/docJoin.js.map +1 -1
  21. package/dist/catalog/modelCollisionValidate.d.ts +22 -0
  22. package/dist/catalog/modelCollisionValidate.js +67 -0
  23. package/dist/catalog/modelCollisionValidate.js.map +1 -0
  24. package/dist/catalog/orphanedSliceDocValidate.d.ts +11 -0
  25. package/dist/catalog/orphanedSliceDocValidate.js +98 -0
  26. package/dist/catalog/orphanedSliceDocValidate.js.map +1 -0
  27. package/dist/catalog/sliceDoc.d.ts +15 -0
  28. package/dist/catalog/sliceDoc.js +18 -0
  29. package/dist/catalog/sliceDoc.js.map +1 -1
  30. package/dist/cli/changelogBuild.d.ts +12 -0
  31. package/dist/cli/changelogBuild.js +89 -0
  32. package/dist/cli/changelogBuild.js.map +1 -0
  33. package/dist/cli/ciInit.d.ts +42 -0
  34. package/dist/cli/ciInit.js +298 -0
  35. package/dist/cli/ciInit.js.map +1 -0
  36. package/dist/cli/conformSupersede.d.ts +32 -0
  37. package/dist/cli/conformSupersede.js +102 -0
  38. package/dist/cli/conformSupersede.js.map +1 -0
  39. package/dist/cli/coverage.d.ts +17 -10
  40. package/dist/cli/coverage.js +61 -16
  41. package/dist/cli/coverage.js.map +1 -1
  42. package/dist/cli/frontmatterSurgery.d.ts +28 -0
  43. package/dist/cli/frontmatterSurgery.js +66 -0
  44. package/dist/cli/frontmatterSurgery.js.map +1 -0
  45. package/dist/cli/markImplemented.js +6 -45
  46. package/dist/cli/markImplemented.js.map +1 -1
  47. package/dist/cli/ratify.d.ts +41 -0
  48. package/dist/cli/ratify.js +151 -0
  49. package/dist/cli/ratify.js.map +1 -0
  50. package/dist/cli/reratify.d.ts +33 -0
  51. package/dist/cli/reratify.js +137 -0
  52. package/dist/cli/reratify.js.map +1 -0
  53. package/dist/cli/skillCheck.d.ts +6 -0
  54. package/dist/cli/skillCheck.js +46 -0
  55. package/dist/cli/skillCheck.js.map +1 -1
  56. package/dist/cli/skillDirs.d.ts +15 -0
  57. package/dist/cli/skillDirs.js +34 -0
  58. package/dist/cli/skillDirs.js.map +1 -0
  59. package/dist/cli/skillSync.d.ts +10 -0
  60. package/dist/cli/skillSync.js +15 -0
  61. package/dist/cli/skillSync.js.map +1 -1
  62. package/dist/cli/sliceIndex.d.ts +3 -0
  63. package/dist/cli/sliceIndex.js +6 -2
  64. package/dist/cli/sliceIndex.js.map +1 -1
  65. package/dist/cli/sliceLink.d.ts +52 -0
  66. package/dist/cli/sliceLink.js +152 -0
  67. package/dist/cli/sliceLink.js.map +1 -0
  68. package/dist/cli/status.d.ts +261 -0
  69. package/dist/cli/status.js +536 -0
  70. package/dist/cli/status.js.map +1 -0
  71. package/dist/cli/usageLog.d.ts +75 -0
  72. package/dist/cli/usageLog.js +193 -0
  73. package/dist/cli/usageLog.js.map +1 -0
  74. package/dist/cli.js +501 -130
  75. package/dist/cli.js.map +1 -1
  76. package/dist/emit/diffJson.d.ts +9 -1
  77. package/dist/emit/diffJson.js +44 -1
  78. package/dist/emit/diffJson.js.map +1 -1
  79. package/dist/emit/freshnessJson.d.ts +6 -0
  80. package/dist/emit/freshnessJson.js +24 -0
  81. package/dist/emit/freshnessJson.js.map +1 -0
  82. package/dist/emit/json.d.ts +67 -8
  83. package/dist/emit/json.js +19 -3
  84. package/dist/emit/json.js.map +1 -1
  85. package/dist/emit/statusJson.d.ts +7 -0
  86. package/dist/emit/statusJson.js +39 -0
  87. package/dist/emit/statusJson.js.map +1 -0
  88. package/dist/emit/typespec.d.ts +26 -0
  89. package/dist/emit/typespec.js +356 -0
  90. package/dist/emit/typespec.js.map +1 -0
  91. package/dist/emit/usageReportJson.d.ts +3 -0
  92. package/dist/emit/usageReportJson.js +23 -0
  93. package/dist/emit/usageReportJson.js.map +1 -0
  94. package/dist/mcp/server.d.ts +1 -1
  95. package/dist/mcp/server.js +301 -11
  96. package/dist/mcp/server.js.map +1 -1
  97. package/dist/model/rules.d.ts +13 -0
  98. package/dist/model/rules.js +20 -0
  99. package/dist/model/rules.js.map +1 -1
  100. package/dist/model/validate.js +9 -1
  101. package/dist/model/validate.js.map +1 -1
  102. package/dist/parser/ast.d.ts +6 -0
  103. package/dist/parser/parser.js +20 -6
  104. package/dist/parser/parser.js.map +1 -1
  105. package/dist/render/drawNotes.d.ts +11 -1
  106. package/dist/render/drawNotes.js +20 -2
  107. package/dist/render/drawNotes.js.map +1 -1
  108. package/dist/render/render.js +31 -6
  109. package/dist/render/render.js.map +1 -1
  110. package/dist/render/serve.js +4 -0
  111. package/dist/render/serve.js.map +1 -1
  112. package/dist/render/sliceOverlay.d.ts +7 -2
  113. package/dist/render/sliceOverlay.js +19 -7
  114. package/dist/render/sliceOverlay.js.map +1 -1
  115. package/dist/render/sliceStatus.d.ts +7 -3
  116. package/dist/render/sliceStatus.js +21 -14
  117. package/dist/render/sliceStatus.js.map +1 -1
  118. package/dist/render/viewerHtml.js +1 -1
  119. package/dist/render/viewerHtml.js.map +1 -1
  120. package/dist/templates.d.ts +2 -2
  121. package/dist/templates.js +5 -5
  122. package/dist/util/markers.d.ts +16 -4
  123. package/dist/util/markers.js +23 -10
  124. package/dist/util/markers.js.map +1 -1
  125. package/package.json +2 -1
  126. /package/.claude/skills/{event-modeling → event-modeling-shared}/templates/conformance-report.md +0 -0
  127. /package/.claude/skills/{event-modeling → event-modeling-shared}/templates/state.md +0 -0
@@ -1,11 +1,13 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Tags each element's rendered node group with its slice index, and embeds a
3
- // <metadata> block describing every slice's name and x-range in the SVG.
3
+ // <metadata> block describing every slice's name, x-range, and resolved design
4
+ // doc (as rendered HTML) in the SVG.
4
5
  //
5
6
  // This is what lets the storyboard mode in the live viewer (`em watch --serve`,
6
- // see src/render/serve.ts) highlight and pan/zoom to one slice at a time
7
- // entirely client-side, with no separate data fetch — everything the browser
8
- // needs travels inside the SVG it already loads.
7
+ // see src/render/serve.ts) highlight and pan/zoom to one slice at a time, and
8
+ // (MIL-153) open a flyout with a slice's own doc on click — entirely
9
+ // client-side, with no separate data fetch — everything the browser needs
10
+ // travels inside the SVG it already loads.
9
11
  //
10
12
  // A slice's x-range comes from its header-cell rect (headerCellId), not from
11
13
  // element rects — a slice can have empty rows, which would give an element-based
@@ -55,8 +57,12 @@ export function tagSliceAttrs(svg, model) {
55
57
  * `<g class="graph" transform="scale(...) translate(...)">` wrapper — not the root
56
58
  * SVG/viewBox space the served viewer sets `viewBox` in. Every x0/x1 here goes
57
59
  * through readGraphTransform()'s toRoot() before it's embedded, the same
58
- * conversion fitCanvas() applies to the edge-detour bbox for the same reason. */
59
- export function buildSliceOverlay(svg, grid, rects) {
60
+ * conversion fitCanvas() applies to the edge-detour bbox for the same reason.
61
+ *
62
+ * `docs` is each slice's resolved doc (or null), same order as `grid`'s columns —
63
+ * see render/sliceStatus.ts's `readSliceDocs`, the same resolution that colors the
64
+ * slice's header by status. */
65
+ export function buildSliceOverlay(svg, grid, rects, docs) {
60
66
  const transform = readGraphTransform(svg);
61
67
  const toRootX = (x) => transform.toRoot(x, 0).x;
62
68
  const slices = [];
@@ -64,7 +70,13 @@ export function buildSliceOverlay(svg, grid, rects) {
64
70
  const r = rects.get(headerCellId(c));
65
71
  if (!r)
66
72
  continue;
67
- slices.push({ index: c, name: grid.sliceNames[c], x0: toRootX(r.left), x1: toRootX(r.right) });
73
+ slices.push({
74
+ index: c,
75
+ name: grid.sliceNames[c],
76
+ x0: toRootX(r.left),
77
+ x1: toRootX(r.right),
78
+ docHtml: docs[c]?.html ?? null,
79
+ });
68
80
  }
69
81
  if (slices.length === 0)
70
82
  return "";
@@ -1 +1 @@
1
- {"version":3,"file":"sliceOverlay.js","sourceRoot":"","sources":["../../src/render/sliceOverlay.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sliceOverlay.js","sourceRoot":"","sources":["../../src/render/sliceOverlay.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,9 @@
1
1
  import { NormalizedModel } from "../model/model.js";
2
- /** One status per slice, same order as model.slices (and so the same order as
3
- * the header row's columns — see layout/grid.ts's sliceNames). null means no
4
- * doc was found (own or covering), or the doc has no `- **Status:** ...` line. */
2
+ import { SliceDoc } from "../catalog/sliceDoc.js";
3
+ /** Each slice's resolved design doc (own `slices/<key>.md`, or the MIL-121 covering doc, or
4
+ * null), same order as model.slices (and so the same order as the header row's columns —
5
+ * see layout/grid.ts's sliceNames). */
6
+ export declare function readSliceDocs(model: NormalizedModel, baseDir: string): (SliceDoc | null)[];
7
+ /** One status per slice, same order/resolution as readSliceDocs. null means no doc was found
8
+ * (own or covering), or the doc has no `- **Status:** ...` line. */
5
9
  export declare function readSliceStatuses(model: NormalizedModel, baseDir: string): (string | null)[];
@@ -1,7 +1,9 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Reads each slice's development status from its sibling design doc
3
- // (slices/<kebab-slug>.md, see .claude/skills/event-modeling/templates/slice.md),
4
- // for the main render pipeline (composeSvg) to color slice headers with.
2
+ // Reads each slice's sibling design doc (slices/<kebab-slug>.md, see
3
+ // .claude/skills/event-modeling/templates/slice.md), for the main render pipeline
4
+ // (composeSvg) to color slice headers by status with, and (MIL-153) to embed each
5
+ // slice's doc content in the live view's slice-click flyout — same resolved doc,
6
+ // two different things done with it, one fs pass.
5
7
  //
6
8
  // Same doc-path convention `em catalog` already uses (src/catalog/build.ts):
7
9
  // the *deduped* export key from computeRefs(), not a fresh kebabSlug(name) —
@@ -9,19 +11,19 @@
9
11
  // docs for them (first one gets the real file, the second honestly gets null).
10
12
  //
11
13
  // MIL-121 fallback: when a slice has no own `slices/<key>.md`, scan the `slices/` directory for
12
- // a sibling doc whose frontmatter `covers:` names this slice's key, and borrow ITS status. This
14
+ // a sibling doc whose frontmatter `covers:` names this slice's key, and borrow IT. This
13
15
  // is filename+frontmatter discovery only — same convention as the direct lookup above, and
14
16
  // still never reads `note` (the tested invariant test/catalog.e2e.test.ts pins for `em catalog`'s
15
17
  // parallel doc discovery) — a slice's `note "slices/<other>.md"` cross-binding declaration is
16
- // docJoin.ts's concern (`em export`/`em validate --slice-ready`), not this legend's.
18
+ // docJoin.ts's concern (`em export`/`em validate --slice-ready`), not this module's.
17
19
  import { existsSync, readdirSync, readFileSync } from "node:fs";
18
20
  import { join } from "node:path";
19
21
  import { computeRefs } from "../model/refs.js";
20
22
  import { parseSliceDoc } from "../catalog/sliceDoc.js";
21
- /** One status per slice, same order as model.slices (and so the same order as
22
- * the header row's columns — see layout/grid.ts's sliceNames). null means no
23
- * doc was found (own or covering), or the doc has no `- **Status:** ...` line. */
24
- export function readSliceStatuses(model, baseDir) {
23
+ /** Each slice's resolved design doc (own `slices/<key>.md`, or the MIL-121 covering doc, or
24
+ * null), same order as model.slices (and so the same order as the header row's columns —
25
+ * see layout/grid.ts's sliceNames). */
26
+ export function readSliceDocs(model, baseDir) {
25
27
  const { sliceKeys } = computeRefs(model);
26
28
  const slicesDir = join(baseDir, "slices");
27
29
  let coverage = null; // built lazily, only if ever needed
@@ -29,16 +31,21 @@ export function readSliceStatuses(model, baseDir) {
29
31
  const sliceKey = sliceKeys[i];
30
32
  const docPath = join(slicesDir, `${sliceKey}.md`);
31
33
  if (existsSync(docPath))
32
- return parseSliceDoc(readFileSync(docPath, "utf8")).status;
33
- coverage ??= readCoverageStatuses(slicesDir);
34
+ return parseSliceDoc(readFileSync(docPath, "utf8"));
35
+ coverage ??= readCoverageDocs(slicesDir);
34
36
  return coverage.get(sliceKey) ?? null;
35
37
  });
36
38
  }
39
+ /** One status per slice, same order/resolution as readSliceDocs. null means no doc was found
40
+ * (own or covering), or the doc has no `- **Status:** ...` line. */
41
+ export function readSliceStatuses(model, baseDir) {
42
+ return readSliceDocs(model, baseDir).map((doc) => doc?.status ?? null);
43
+ }
37
44
  /** Scans every `slices/*.md` file (sorted, for deterministic first-wins on an overlapping
38
- * `covers:` claim) and maps each covered slice key to that doc's status. Directory-scan, not
45
+ * `covers:` claim) and maps each covered slice key to that doc. Directory-scan, not
39
46
  * the single-file existsSync check every other doc lookup in em uses — the one precedent for
40
47
  * it is `em ledger`'s (src/cli/ledgerCheck.ts) own `readdirSync(slicesDir)` sweep. */
41
- function readCoverageStatuses(slicesDir) {
48
+ function readCoverageDocs(slicesDir) {
42
49
  const coverage = new Map();
43
50
  if (!existsSync(slicesDir))
44
51
  return coverage;
@@ -47,7 +54,7 @@ function readCoverageStatuses(slicesDir) {
47
54
  const doc = parseSliceDoc(readFileSync(join(slicesDir, file), "utf8"));
48
55
  for (const coveredKey of doc.covers) {
49
56
  if (!coverage.has(coveredKey))
50
- coverage.set(coveredKey, doc.status);
57
+ coverage.set(coveredKey, doc);
51
58
  }
52
59
  }
53
60
  return coverage;
@@ -1 +1 @@
1
- {"version":3,"file":"sliceStatus.js","sourceRoot":"","sources":["../../src/render/sliceStatus.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,oEAAoE;AACpE,kFAAkF;AAClF,yEAAyE;AACzE,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,+EAA+E;AAC/E,+EAA+E;AAC/E,EAAE;AACF,gGAAgG;AAChG,gGAAgG;AAChG,2FAA2F;AAC3F,kGAAkG;AAClG,8FAA8F;AAC9F,qFAAqF;AAErF,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAChE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAEvD;;mFAEmF;AACnF,MAAM,UAAU,iBAAiB,CAAC,KAAsB,EAAE,OAAe;IACvE,MAAM,EAAE,SAAS,EAAE,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IACzC,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC1C,IAAI,QAAQ,GAAsC,IAAI,CAAC,CAAC,oCAAoC;IAE5F,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;QACpC,MAAM,QAAQ,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;QAC9B,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,EAAE,GAAG,QAAQ,KAAK,CAAC,CAAC;QAClD,IAAI,UAAU,CAAC,OAAO,CAAC;YAAE,OAAO,aAAa,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;QACpF,QAAQ,KAAK,oBAAoB,CAAC,SAAS,CAAC,CAAC;QAC7C,OAAO,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC;IACxC,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;uFAGuF;AACvF,SAAS,oBAAoB,CAAC,SAAiB;IAC7C,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAyB,CAAC;IAClD,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC;QAAE,OAAO,QAAQ,CAAC;IAC5C,MAAM,KAAK,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC7E,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,aAAa,CAAC,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;QACvE,KAAK,MAAM,UAAU,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;YACpC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,UAAU,CAAC;gBAAE,QAAQ,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;QACtE,CAAC;IACH,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,CAAC"}
1
+ {"version":3,"file":"sliceStatus.js","sourceRoot":"","sources":["../../src/render/sliceStatus.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,qEAAqE;AACrE,kFAAkF;AAClF,kFAAkF;AAClF,iFAAiF;AACjF,kDAAkD;AAClD,EAAE;AACF,6EAA6E;AAC7E,6EAA6E;AAC7E,+EAA+E;AAC/E,+EAA+E;AAC/E,EAAE;AACF,gGAAgG;AAChG,wFAAwF;AACxF,2FAA2F;AAC3F,kGAAkG;AAClG,8FAA8F;AAC9F,qFAAqF;AAErF,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAChE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,aAAa,EAAY,MAAM,wBAAwB,CAAC;AAEjE;;wCAEwC;AACxC,MAAM,UAAU,aAAa,CAAC,KAAsB,EAAE,OAAe;IACnE,MAAM,EAAE,SAAS,EAAE,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC;IACzC,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC1C,IAAI,QAAQ,GAAiC,IAAI,CAAC,CAAC,oCAAoC;IAEvF,OAAO,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;QACpC,MAAM,QAAQ,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;QAC9B,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,EAAE,GAAG,QAAQ,KAAK,CAAC,CAAC;QAClD,IAAI,UAAU,CAAC,OAAO,CAAC;YAAE,OAAO,aAAa,CAAC,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;QAC7E,QAAQ,KAAK,gBAAgB,CAAC,SAAS,CAAC,CAAC;QACzC,OAAO,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC;IACxC,CAAC,CAAC,CAAC;AACL,CAAC;AAED;qEACqE;AACrE,MAAM,UAAU,iBAAiB,CAAC,KAAsB,EAAE,OAAe;IACvE,OAAO,aAAa,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,MAAM,IAAI,IAAI,CAAC,CAAC;AACzE,CAAC;AAED;;;uFAGuF;AACvF,SAAS,gBAAgB,CAAC,SAAiB;IACzC,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC7C,IAAI,CAAC,UAAU,CAAC,SAAS,CAAC;QAAE,OAAO,QAAQ,CAAC;IAC5C,MAAM,KAAK,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAC7E,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,aAAa,CAAC,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC;QACvE,KAAK,MAAM,UAAU,IAAI,GAAG,CAAC,MAAM,EAAE,CAAC;YACpC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,UAAU,CAAC;gBAAE,QAAQ,CAAC,GAAG,CAAC,UAAU,EAAE,GAAG,CAAC,CAAC;QAC/D,CAAC;IACH,CAAC;IACD,OAAO,QAAQ,CAAC;AAClB,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<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 #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 </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 <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\"></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 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\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}], from this load's <metadata>\n let rowLabels = null; // {x0, x1} | null, the swimlane label column's range\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 // 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 }\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 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 let x0 = rowLabels ? Math.min(rowLabels.x0, slice.x0) : slice.x0;\n let x1 = slice.x1;\n x0 = Math.max(natural.x, x0 - PAD);\n x1 = Math.min(natural.x + natural.w, x1 + PAD);\n return frameRect(x0, natural.y, Math.max(1, x1 - x0), natural.h);\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 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 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\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 stage.addEventListener(\"pointerdown\", function (e) {\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 stage.addEventListener(\"wheel\", function (e) {\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 (!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\" && reviewMode) { exitReview(); }\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<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 #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 </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 <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 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 // --- 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 // 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 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 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,0l2BAA0l2B,CAAC"}
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,w9nCAAw9nC,CAAC"}
@@ -1,6 +1,6 @@
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# --- Automation slice: only the read model it reads + the processor ---\nslice \"Payments To Process\" {\n view Payments To Process from \"Payment Requested\"\n processor Payment Gateway\n}\n\n# --- Command slice: the command the automation triggers + its event ---\nslice \"Capture Payment\" {\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";
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 | Implemented in | 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";
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 | Ratified by | Owner | Tracking | Implemented in | 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- **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- **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- {{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
  /** Same STARTER_EM content `em init` writes, titled from an arbitrary display name — used by
package/dist/templates.js CHANGED
@@ -33,14 +33,14 @@ slice "Manager Review" {
33
33
  ui Payment Dashboard @Manager
34
34
  }
35
35
 
36
- # --- Automation slice: only the read model it reads + the processor ---
36
+ # --- Read model the automation watches, in its own slice ---
37
37
  slice "Payments To Process" {
38
38
  view Payments To Process from "Payment Requested"
39
- processor Payment Gateway
40
39
  }
41
40
 
42
- # --- Command slice: the command the automation triggers + its event ---
41
+ # --- Automation slice: the processor, the command it triggers, and that command's event ---
43
42
  slice "Capture Payment" {
43
+ processor Payment Gateway from "Payments To Process"
44
44
  command Capture Payment
45
45
  event Payment Captured @Payment
46
46
  }
@@ -99,8 +99,8 @@ never connected to one another.
99
99
  \`em slice index {{model-name}}.em\` to (re)write the table below from the model and its
100
100
  slice docs; never hand-edit between the markers. -->
101
101
  <!-- GENERATED:slices:start -->
102
- | # | Slice | Pattern | Status | Implemented in | Design doc |
103
- |---|-------|---------|--------|----------------|------------|
102
+ | # | Slice | Pattern | Status | Ratified by | Owner | Tracking | Implemented in | Design doc |
103
+ |---|-------|---------|--------|-------------|-------|----------|----------------|------------|
104
104
  <!-- GENERATED:slices:end -->
105
105
 
106
106
  ## Status
@@ -1,13 +1,25 @@
1
+ /** Comment syntax the marker lines are written in: `"html"` (`<!-- ... -->`, the default —
2
+ * Markdown files) or `"hash"` (`# ...`, unterminated — YAML/shell-style files). */
3
+ export type MarkerStyle = "html" | "hash";
1
4
  /** Captures the exact start/end marker lines separately from whatever sits between them, so
2
5
  * replacement never depends on how much whitespace happened to separate them before — matters
3
6
  * for a freshly-added, still-empty marker pair (start line immediately followed by end line,
4
- * nothing in between to non-greedily match a leading "\n" against). */
5
- export declare function markerRegex(name: string): RegExp;
7
+ * nothing in between to non-greedily match a leading "\n" against). The `[^\n]*` tail on the
8
+ * start line lets a caller append trailing prose after `:start` (e.g. em-dsl.md's "-- run `npm
9
+ * run docs:generate` to refresh, do not hand-edit") without breaking the match. */
10
+ export declare function markerRegex(name: string, style?: MarkerStyle): RegExp;
6
11
  /**
7
12
  * Replace the body between the named marker pair in `content` with `newBody`, preserving the
8
13
  * marker lines themselves verbatim. Returns `null` (never throws) when the marker pair isn't
9
14
  * found in `content` — callers decide how to report that: a dev-tooling throw
10
15
  * (scripts/generate-skill-docs.ts), or a user-facing CLI error suggesting how to add the
11
- * markers (`em slice index`, src/cli/sliceIndex.ts).
16
+ * markers (`em slice index`, src/cli/sliceIndex.ts; `em ci init`, src/cli/ciInit.ts).
12
17
  */
13
- export declare function applyMarker(content: string, markerName: string, newBody: string): string | null;
18
+ export declare function applyMarker(content: string, markerName: string, newBody: string, style?: MarkerStyle): string | null;
19
+ /** The bare start/end marker lines for `markerName`, for a caller writing a fresh marker pair
20
+ * from scratch (e.g. `em ci init` creating a brand-new file, or `em ci init --check` reporting
21
+ * what a from-scratch file would contain). */
22
+ export declare function markerPair(markerName: string, style?: MarkerStyle): {
23
+ start: string;
24
+ end: string;
25
+ };
@@ -1,17 +1,22 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Shared marker-delimited region patcher: `<!-- GENERATED:<name>:start -->...
3
- // <!-- GENERATED:<name>:end -->`. Originally scripts/generate-skill-docs.ts's own local helper
4
- // (MIL-92, docs-generation only); extracted here so a real runtime command — `em slice index`
5
- // (MIL-98) — can reuse the exact same marker convention instead of re-deriving its own regex.
3
+ // <!-- GENERATED:<name>:end -->` (or, for a file whose format doesn't support HTML comments,
4
+ // `# GENERATED:<name>:start` ... `# GENERATED:<name>:end`). Originally
5
+ // scripts/generate-skill-docs.ts's own local helper (MIL-92, docs-generation only); extracted
6
+ // here so a real runtime command — `em slice index` (MIL-98) — can reuse the exact same marker
7
+ // convention instead of re-deriving its own regex. The `hash` style was added for `em ci init`
8
+ // (MIL-166), whose generated files are YAML (no `<!-- -->` comment syntax).
6
9
  // Pure string in, string (or null) out — no fs access, so both a dev script and a CLI command
7
10
  // can layer their own file-reading and error reporting on top.
8
11
  /** Captures the exact start/end marker lines separately from whatever sits between them, so
9
12
  * replacement never depends on how much whitespace happened to separate them before — matters
10
13
  * for a freshly-added, still-empty marker pair (start line immediately followed by end line,
11
- * nothing in between to non-greedily match a leading "\n" against). */
12
- export function markerRegex(name) {
13
- const start = `<!-- GENERATED:${name}:start[^\\n]*-->`;
14
- const end = `<!-- GENERATED:${name}:end -->`;
14
+ * nothing in between to non-greedily match a leading "\n" against). The `[^\n]*` tail on the
15
+ * start line lets a caller append trailing prose after `:start` (e.g. em-dsl.md's "-- run `npm
16
+ * run docs:generate` to refresh, do not hand-edit") without breaking the match. */
17
+ export function markerRegex(name, style = "html") {
18
+ const start = style === "hash" ? `# GENERATED:${name}:start[^\\n]*` : `<!-- GENERATED:${name}:start[^\\n]*-->`;
19
+ const end = style === "hash" ? `# GENERATED:${name}:end` : `<!-- GENERATED:${name}:end -->`;
15
20
  return new RegExp(`(${start})([\\s\\S]*?)(${end})`);
16
21
  }
17
22
  /**
@@ -19,12 +24,20 @@ export function markerRegex(name) {
19
24
  * marker lines themselves verbatim. Returns `null` (never throws) when the marker pair isn't
20
25
  * found in `content` — callers decide how to report that: a dev-tooling throw
21
26
  * (scripts/generate-skill-docs.ts), or a user-facing CLI error suggesting how to add the
22
- * markers (`em slice index`, src/cli/sliceIndex.ts).
27
+ * markers (`em slice index`, src/cli/sliceIndex.ts; `em ci init`, src/cli/ciInit.ts).
23
28
  */
24
- export function applyMarker(content, markerName, newBody) {
25
- const re = markerRegex(markerName);
29
+ export function applyMarker(content, markerName, newBody, style = "html") {
30
+ const re = markerRegex(markerName, style);
26
31
  if (!re.test(content))
27
32
  return null;
28
33
  return content.replace(re, (_m, open, _old, close) => `${open}\n${newBody}\n${close}`);
29
34
  }
35
+ /** The bare start/end marker lines for `markerName`, for a caller writing a fresh marker pair
36
+ * from scratch (e.g. `em ci init` creating a brand-new file, or `em ci init --check` reporting
37
+ * what a from-scratch file would contain). */
38
+ export function markerPair(markerName, style = "html") {
39
+ return style === "hash"
40
+ ? { start: `# GENERATED:${markerName}:start`, end: `# GENERATED:${markerName}:end` }
41
+ : { start: `<!-- GENERATED:${markerName}:start -->`, end: `<!-- GENERATED:${markerName}:end -->` };
42
+ }
30
43
  //# sourceMappingURL=markers.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"markers.js","sourceRoot":"","sources":["../../src/util/markers.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,8EAA8E;AAC9E,+FAA+F;AAC/F,8FAA8F;AAC9F,8FAA8F;AAC9F,8FAA8F;AAC9F,+DAA+D;AAE/D;;;wEAGwE;AACxE,MAAM,UAAU,WAAW,CAAC,IAAY;IACtC,MAAM,KAAK,GAAG,kBAAkB,IAAI,kBAAkB,CAAC;IACvD,MAAM,GAAG,GAAG,kBAAkB,IAAI,UAAU,CAAC;IAC7C,OAAO,IAAI,MAAM,CAAC,IAAI,KAAK,iBAAiB,GAAG,GAAG,CAAC,CAAC;AACtD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,OAAe,EAAE,UAAkB,EAAE,OAAe;IAC9E,MAAM,EAAE,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,IAAI,KAAK,OAAO,KAAK,KAAK,EAAE,CAAC,CAAC;AACzF,CAAC"}
1
+ {"version":3,"file":"markers.js","sourceRoot":"","sources":["../../src/util/markers.ts"],"names":[],"mappings":"AAAA,+BAA+B;AAC/B,8EAA8E;AAC9E,6FAA6F;AAC7F,uEAAuE;AACvE,8FAA8F;AAC9F,+FAA+F;AAC/F,+FAA+F;AAC/F,4EAA4E;AAC5E,8FAA8F;AAC9F,+DAA+D;AAM/D;;;;;oFAKoF;AACpF,MAAM,UAAU,WAAW,CAAC,IAAY,EAAE,QAAqB,MAAM;IACnE,MAAM,KAAK,GAAG,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,eAAe,IAAI,eAAe,CAAC,CAAC,CAAC,kBAAkB,IAAI,kBAAkB,CAAC;IAC/G,MAAM,GAAG,GAAG,KAAK,KAAK,MAAM,CAAC,CAAC,CAAC,eAAe,IAAI,MAAM,CAAC,CAAC,CAAC,kBAAkB,IAAI,UAAU,CAAC;IAC5F,OAAO,IAAI,MAAM,CAAC,IAAI,KAAK,iBAAiB,GAAG,GAAG,CAAC,CAAC;AACtD,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,WAAW,CAAC,OAAe,EAAE,UAAkB,EAAE,OAAe,EAAE,QAAqB,MAAM;IAC3G,MAAM,EAAE,GAAG,WAAW,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC;IAC1C,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IACnC,OAAO,OAAO,CAAC,OAAO,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,GAAG,IAAI,KAAK,OAAO,KAAK,KAAK,EAAE,CAAC,CAAC;AACzF,CAAC;AAED;;+CAE+C;AAC/C,MAAM,UAAU,UAAU,CAAC,UAAkB,EAAE,QAAqB,MAAM;IACxE,OAAO,KAAK,KAAK,MAAM;QACrB,CAAC,CAAC,EAAE,KAAK,EAAE,eAAe,UAAU,QAAQ,EAAE,GAAG,EAAE,eAAe,UAAU,MAAM,EAAE;QACpF,CAAC,CAAC,EAAE,KAAK,EAAE,kBAAkB,UAAU,YAAY,EAAE,GAAG,EAAE,kBAAkB,UAAU,UAAU,EAAE,CAAC;AACvG,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milehimikey/em",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "AI-friendly Event Modeling — a slice-first text DSL rendered to a strict, self-contained Graphviz grid (SVG/PNG)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,6 +68,7 @@
68
68
  "devDependencies": {
69
69
  "@types/node": "^22.0.0",
70
70
  "@types/pdfkit": "^0.17.6",
71
+ "@typespec/compiler": "^1.15.0",
71
72
  "jsdom": "^26.1.0",
72
73
  "tsx": "^4.19.0",
73
74
  "typescript": "^5.6.0",