mhproto 0.8.0-preview.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 (124) hide show
  1. package/CONTRIBUTING.md +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +53 -0
  4. package/THIRD_PARTY_NOTICES.md +40 -0
  5. package/bin/mhproto.mjs +248 -0
  6. package/doc/context-design.md +43 -0
  7. package/doc/context-measurements.json +28 -0
  8. package/doc/guide.md +138 -0
  9. package/doc/publishing.md +93 -0
  10. package/doc/release-notes.md +23 -0
  11. package/doc/release-review-2026-10-02.md +127 -0
  12. package/doc/release-review.md +68 -0
  13. package/doc/viewer-design.md +80 -0
  14. package/package.json +69 -0
  15. package/skills/mhproto-discover/SKILL.md +14 -0
  16. package/skills/mhproto-implement/SKILL.md +14 -0
  17. package/skills/mhproto-reconcile/SKILL.md +14 -0
  18. package/skills/mhproto-specify/SKILL.md +16 -0
  19. package/skills/mhproto-specify/references/format.md +20 -0
  20. package/skills/mhproto-verify/SKILL.md +14 -0
  21. package/src/config.mjs +202 -0
  22. package/src/context.mjs +228 -0
  23. package/src/core.mjs +471 -0
  24. package/src/node-reporter.mjs +15 -0
  25. package/src/paths.mjs +51 -0
  26. package/src/server.mjs +290 -0
  27. package/src/verify.mjs +149 -0
  28. package/src/visuals.mjs +189 -0
  29. package/viewer/app.js +1463 -0
  30. package/viewer/diff.js +245 -0
  31. package/viewer/index.html +51 -0
  32. package/viewer/style.css +1003 -0
  33. package/viewer/vendor/LICENSE +21 -0
  34. package/viewer/vendor/NOTICE.txt +3372 -0
  35. package/viewer/vendor/README.md +75 -0
  36. package/viewer/vendor/build-evidence.json +2775 -0
  37. package/viewer/vendor/bundled-audit.json +88 -0
  38. package/viewer/vendor/bundled-packages.json +84 -0
  39. package/viewer/vendor/embedded-notices.txt +33 -0
  40. package/viewer/vendor/license-inventory.json +1456 -0
  41. package/viewer/vendor/licenses/braintree__sanitize-url-7.1.2.txt +21 -0
  42. package/viewer/vendor/licenses/chevrotain-13.2.0.txt +202 -0
  43. package/viewer/vendor/licenses/chevrotain-allstar-0.5.0.txt +16 -0
  44. package/viewer/vendor/licenses/chevrotain__cst-dts-gen-13.2.0.txt +202 -0
  45. package/viewer/vendor/licenses/chevrotain__gast-13.2.0.txt +202 -0
  46. package/viewer/vendor/licenses/chevrotain__regexp-to-ast-13.2.0.txt +202 -0
  47. package/viewer/vendor/licenses/chevrotain__utils-13.2.0.txt +202 -0
  48. package/viewer/vendor/licenses/cose-base-1.0.3.txt +21 -0
  49. package/viewer/vendor/licenses/cose-base-2.2.0.txt +21 -0
  50. package/viewer/vendor/licenses/cytoscape-3.34.3.txt +19 -0
  51. package/viewer/vendor/licenses/cytoscape-cose-bilkent-4.1.0.txt +21 -0
  52. package/viewer/vendor/licenses/cytoscape-fcose-2.2.0.txt +19 -0
  53. package/viewer/vendor/licenses/d3-7.9.0.txt +13 -0
  54. package/viewer/vendor/licenses/d3-array-2.12.1.txt +27 -0
  55. package/viewer/vendor/licenses/d3-array-3.2.4.txt +13 -0
  56. package/viewer/vendor/licenses/d3-axis-3.0.0.txt +13 -0
  57. package/viewer/vendor/licenses/d3-brush-3.0.0.txt +13 -0
  58. package/viewer/vendor/licenses/d3-chord-3.0.1.txt +13 -0
  59. package/viewer/vendor/licenses/d3-color-3.1.0.txt +13 -0
  60. package/viewer/vendor/licenses/d3-contour-4.0.2.txt +13 -0
  61. package/viewer/vendor/licenses/d3-delaunay-6.0.4.txt +14 -0
  62. package/viewer/vendor/licenses/d3-dispatch-3.0.1.txt +13 -0
  63. package/viewer/vendor/licenses/d3-drag-3.0.0.txt +13 -0
  64. package/viewer/vendor/licenses/d3-dsv-3.0.1.txt +13 -0
  65. package/viewer/vendor/licenses/d3-ease-3.0.1.txt +28 -0
  66. package/viewer/vendor/licenses/d3-fetch-3.0.1.txt +13 -0
  67. package/viewer/vendor/licenses/d3-force-3.0.0.txt +13 -0
  68. package/viewer/vendor/licenses/d3-format-3.1.2.txt +13 -0
  69. package/viewer/vendor/licenses/d3-geo-3.1.1.txt +34 -0
  70. package/viewer/vendor/licenses/d3-hierarchy-3.1.2.txt +13 -0
  71. package/viewer/vendor/licenses/d3-interpolate-3.0.1.txt +13 -0
  72. package/viewer/vendor/licenses/d3-path-1.0.9.txt +27 -0
  73. package/viewer/vendor/licenses/d3-path-3.1.0.txt +13 -0
  74. package/viewer/vendor/licenses/d3-polygon-3.0.1.txt +13 -0
  75. package/viewer/vendor/licenses/d3-quadtree-3.0.1.txt +13 -0
  76. package/viewer/vendor/licenses/d3-random-3.0.1.txt +13 -0
  77. package/viewer/vendor/licenses/d3-sankey-0.12.3.txt +27 -0
  78. package/viewer/vendor/licenses/d3-scale-4.0.2.txt +13 -0
  79. package/viewer/vendor/licenses/d3-scale-chromatic-3.1.0.txt +28 -0
  80. package/viewer/vendor/licenses/d3-selection-3.0.0.txt +13 -0
  81. package/viewer/vendor/licenses/d3-shape-1.3.7.txt +27 -0
  82. package/viewer/vendor/licenses/d3-shape-3.2.0.txt +13 -0
  83. package/viewer/vendor/licenses/d3-time-3.1.0.txt +13 -0
  84. package/viewer/vendor/licenses/d3-time-format-4.1.0.txt +13 -0
  85. package/viewer/vendor/licenses/d3-timer-3.0.1.txt +13 -0
  86. package/viewer/vendor/licenses/d3-transition-3.0.1.txt +13 -0
  87. package/viewer/vendor/licenses/d3-zoom-3.0.0.txt +13 -0
  88. package/viewer/vendor/licenses/dagre-d3-es-7.0.14.txt +23 -0
  89. package/viewer/vendor/licenses/dayjs-1.11.23.txt +21 -0
  90. package/viewer/vendor/licenses/delaunator-5.1.0.txt +15 -0
  91. package/viewer/vendor/licenses/dompurify-3.4.16.txt +202 -0
  92. package/viewer/vendor/licenses/elk-source-notice.txt +11 -0
  93. package/viewer/vendor/licenses/elkjs-0.9.3.txt +264 -0
  94. package/viewer/vendor/licenses/embedded-and-node-notices.txt +45 -0
  95. package/viewer/vendor/licenses/es-toolkit-1.52.0-1.txt +39 -0
  96. package/viewer/vendor/licenses/es-toolkit-1.52.0.txt +21 -0
  97. package/viewer/vendor/licenses/fastdom-1.0.12.txt +221 -0
  98. package/viewer/vendor/licenses/iconify__utils-3.1.7.txt +21 -0
  99. package/viewer/vendor/licenses/internmap-1.0.1.txt +13 -0
  100. package/viewer/vendor/licenses/internmap-2.0.3.txt +13 -0
  101. package/viewer/vendor/licenses/js-yaml-4.3.2.txt +21 -0
  102. package/viewer/vendor/licenses/katex-0.16.47.txt +21 -0
  103. package/viewer/vendor/licenses/khroma-2.1.0.txt +21 -0
  104. package/viewer/vendor/licenses/langium-4.4.0.txt +16 -0
  105. package/viewer/vendor/licenses/layout-base-1.0.2.txt +21 -0
  106. package/viewer/vendor/licenses/layout-base-2.0.1.txt +21 -0
  107. package/viewer/vendor/licenses/lodash-es-4.18.1.txt +47 -0
  108. package/viewer/vendor/licenses/marked-16.4.2.txt +44 -0
  109. package/viewer/vendor/licenses/mermaid-12.1.0.txt +21 -0
  110. package/viewer/vendor/licenses/mermaid-js__parser-2.0.1.txt +21 -0
  111. package/viewer/vendor/licenses/path-browserify-1.0.1.txt +20 -0
  112. package/viewer/vendor/licenses/robust-predicates-3.0.3.txt +24 -0
  113. package/viewer/vendor/licenses/roughjs-4.6.6.txt +21 -0
  114. package/viewer/vendor/licenses/stylis-4.4.0.txt +21 -0
  115. package/viewer/vendor/licenses/ts-dedent-2.3.0.txt +21 -0
  116. package/viewer/vendor/licenses/upsetjs__venn.js-2.0.0.txt +22 -0
  117. package/viewer/vendor/licenses/uuid-14.0.2.txt +9 -0
  118. package/viewer/vendor/licenses/vscode-jsonrpc-9.0.3.txt +11 -0
  119. package/viewer/vendor/licenses/vscode-languageserver-protocol-3.18.4.txt +11 -0
  120. package/viewer/vendor/licenses/vscode-languageserver-textdocument-1.0.15.txt +11 -0
  121. package/viewer/vendor/licenses/vscode-languageserver-types-3.18.4.txt +11 -0
  122. package/viewer/vendor/licenses/vscode-uri-3.1.0.txt +9 -0
  123. package/viewer/vendor/manifest.json +21 -0
  124. package/viewer/vendor/mermaid.min.js +7729 -0
package/viewer/app.js ADDED
@@ -0,0 +1,1463 @@
1
+ import { compareModels, contractSnapshot, snapshotProject, canonical } from './diff.js';
2
+ const $ = (selector) => document.querySelector(selector);
3
+ const escape = (value) =>
4
+ String(value ?? '').replace(
5
+ /[&<>"']/g,
6
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
7
+ );
8
+ const prose = (value) =>
9
+ escape(value)
10
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
11
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
12
+ const raw = (value) => `<pre>${escape(JSON.stringify(value, null, 2))}</pre>`;
13
+ const embedded = $('#mhproto-model') ? JSON.parse($('#mhproto-model').textContent) : null;
14
+ const media = $('#mhproto-media') ? JSON.parse($('#mhproto-media').textContent) : {};
15
+ let previewChanged = false;
16
+ const disclosure = (title, body, open = false) =>
17
+ `<details class="disclosure"${open ? ' open' : ''}><summary>${escape(title)}</summary><div>${body}</div></details>`;
18
+ let project,
19
+ signature,
20
+ query = new URLSearchParams(location.search).get('search') ?? '',
21
+ generation = 0,
22
+ diagramSerial = 0,
23
+ diagramQueue = Promise.resolve();
24
+ const featureUrl = (cap) => '#/features/' + encodeURIComponent(cap.id);
25
+ const endpointUrl = (cap, op, rule) =>
26
+ featureUrl(cap) +
27
+ '/api/' +
28
+ encodeURIComponent(op.operationId) +
29
+ (rule ? '?rule=' + encodeURIComponent(rule) : '');
30
+ const checkUrl = (cap, check) => featureUrl(cap) + '/checks/' + encodeURIComponent(check.id);
31
+ const typeUrl = (cap, entity) => featureUrl(cap) + '/types/' + encodeURIComponent(entity.id);
32
+ const typeLink = (cap, entity) => {
33
+ const status = entityChange(cap, entity);
34
+ return `<a class="type-link${status ? ' change-link ' + status : ''}" href="${escape(typeUrl(cap, entity))}"${status ? ` title="${escape(pascal(status))} type"` : ''}>${escape(entity.name)}</a>`;
35
+ };
36
+ const method = (op) =>
37
+ `<span class="method ${escape(op.method.toLowerCase())}">${escape(op.method)}</span>`;
38
+ const clean = (value) => String(value ?? '').replace(/`|\*\*/g, '');
39
+ const checkTitle = (check) => (check.title ?? check.id).replace(/^(?:[A-Z][A-Z0-9.-]+\s+)+/, '');
40
+ const target = (cap, kind, id, extra = {}) => ({
41
+ capability: cap.id,
42
+ kind,
43
+ ...(id ? { id } : {}),
44
+ ...extra,
45
+ });
46
+ const sameTarget = (a, b) =>
47
+ ['capability', 'kind', 'id', 'scope', 'status', 'path'].every(
48
+ (k) => String(a?.[k] ?? '') === String(b?.[k] ?? ''),
49
+ );
50
+ // Attachment placement is owned by text/header components, never by schema rendering.
51
+ const attachmentPolicy = Object.freeze({
52
+ feature: 'text',
53
+ operation: 'text',
54
+ rule: 'text',
55
+ example: 'text',
56
+ check: 'text',
57
+ request: 'header',
58
+ response: 'header',
59
+ });
60
+ let attachmentOwners = new Set();
61
+ let entityIndex, entityProject;
62
+ let baseline = $('#mhproto-baseline') ? JSON.parse($('#mhproto-baseline').textContent) : null;
63
+ let baselineProject,
64
+ baselineEntities,
65
+ indexedBaseline,
66
+ comparisonChanges = [],
67
+ comparisonMap = new Map(),
68
+ comparisonEnabled = false,
69
+ comparisonError = '',
70
+ localBaseline = false,
71
+ baselineReadOnly = false,
72
+ comparisonFilter = 'all';
73
+ const changeKey = (change) => JSON.stringify([change.capability, change.kind, change.id]);
74
+ const changeUrl = (change) => '#/changes/' + encodeURIComponent(changeKey(change));
75
+ const changeFor = (cap, kind, id) => comparisonMap.get(JSON.stringify([cap?.id ?? null, kind, id]));
76
+ const changeBadge = (change) =>
77
+ comparisonEnabled && change
78
+ ? `<a class="change-badge ${escape(change.status)}" href="${escape(changeUrl(change))}">${escape(pascal(change.status))}</a>`
79
+ : '';
80
+ const baselineLabel = () => baseline?.label ?? 'Saved baseline';
81
+ function updateComparison() {
82
+ if (indexedBaseline !== baseline) {
83
+ baselineProject = baseline ? snapshotProject(baseline) : null;
84
+ baselineEntities = baselineProject ? buildEntityIndex(baselineProject) : null;
85
+ indexedBaseline = baseline;
86
+ }
87
+ comparisonChanges = baseline ? compareModels(baseline, project) : [];
88
+ comparisonMap = new Map(comparisonChanges.map((c) => [changeKey(c), c]));
89
+ }
90
+ function entityChange(cap, entity) {
91
+ if (!comparisonEnabled || !baselineEntities) return null;
92
+ const old = baselineEntities.caps.get(cap.id)?.get(entity.id);
93
+ return !old
94
+ ? 'added'
95
+ : JSON.stringify(canonical(old.schema)) !== JSON.stringify(canonical(entity.schema))
96
+ ? 'changed'
97
+ : null;
98
+ }
99
+ const schemaName = (schema) =>
100
+ schema?.$ref
101
+ ?.match(/^#\/components\/schemas\/([^/]+)$/)?.[1]
102
+ ?.replaceAll('~1', '/')
103
+ .replaceAll('~0', '~');
104
+ const pascal = (value) =>
105
+ (String(value).match(/[A-Za-z0-9]+/g) ?? ['Type'])
106
+ .map((s) => s[0].toUpperCase() + s.slice(1))
107
+ .join('');
108
+ const pointer = (value) => String(value).replaceAll('~', '~0').replaceAll('/', '~1');
109
+ const rootKey = (cap, op, part, status = '') =>
110
+ JSON.stringify([cap.id, op.operationId, part, status]);
111
+ function schemaChildren(s) {
112
+ const children = Object.entries(s?.properties ?? {}).map(([name, schema]) => ({
113
+ schema,
114
+ path: '/properties/' + pointer(name),
115
+ name: pascal(name),
116
+ field: name,
117
+ }));
118
+ if (s?.items && typeof s.items === 'object')
119
+ children.push({ schema: s.items, path: '/items', name: 'Item', field: '[]' });
120
+ if (s?.additionalProperties && typeof s.additionalProperties === 'object')
121
+ children.push({
122
+ schema: s.additionalProperties,
123
+ path: '/additionalProperties',
124
+ name: 'Value',
125
+ field: '[key]',
126
+ });
127
+ for (const kind of ['anyOf', 'oneOf', 'allOf', 'prefixItems'])
128
+ for (const [i, schema] of (s?.[kind] ?? []).entries())
129
+ children.push({ schema, path: `/${kind}/${i}`, name: 'Variant' + (i + 1), field: '' });
130
+ return children;
131
+ }
132
+ // This index is derived from the current model in memory. No schema copies enter agent packets.
133
+ function buildEntityIndex(model) {
134
+ const index = {
135
+ caps: new Map(),
136
+ nodes: new Map(),
137
+ roots: new Map(),
138
+ usage: new Map(),
139
+ entities: [],
140
+ };
141
+ const register = (cap, id, name, schema, source, derived = false) => {
142
+ const types = index.caps.get(cap.id),
143
+ nodes = index.nodes.get(cap.id);
144
+ if (types.has(id)) return types.get(id);
145
+ if (derived) {
146
+ const taken = new Set([...types.values()].map((e) => e.name));
147
+ let n = 1,
148
+ base = name;
149
+ while (taken.has(name)) name = base + 'Inline' + (n++ === 1 ? '' : n - 1);
150
+ }
151
+ const identity = (cap.files?.interface ?? cap.interface ?? cap.id) + '#' + id;
152
+ if (!index.usage.has(identity)) index.usage.set(identity, new Map());
153
+ const entity = {
154
+ id,
155
+ name,
156
+ schema,
157
+ source,
158
+ derived,
159
+ identity,
160
+ capability: cap.id,
161
+ references: new Map(),
162
+ usages: index.usage.get(identity),
163
+ };
164
+ types.set(id, entity);
165
+ if (schema && typeof schema === 'object') nodes.set(schema, entity);
166
+ index.entities.push(entity);
167
+ return entity;
168
+ };
169
+ const entityAt = (cap, schema) => {
170
+ const name = schemaName(schema);
171
+ return name ? index.caps.get(cap.id).get(name) : index.nodes.get(cap.id).get(schema);
172
+ };
173
+ const discover = (cap, node, owner, path = '', suffix = '') => {
174
+ if (!node || typeof node !== 'object' || node.$ref) return;
175
+ let current = owner;
176
+ if (path && (node.properties || node.type === 'object' || node.type?.includes?.('object')))
177
+ current = register(
178
+ cap,
179
+ '@' + owner.id + path,
180
+ owner.name + suffix,
181
+ node,
182
+ { kind: 'inline', parent: owner.name, path },
183
+ true,
184
+ );
185
+ for (const child of schemaChildren(node))
186
+ discover(
187
+ cap,
188
+ child.schema,
189
+ current,
190
+ current === owner ? path + child.path : child.path,
191
+ current === owner ? suffix + child.name : child.name,
192
+ );
193
+ };
194
+ for (const cap of model.capabilities) {
195
+ index.caps.set(cap.id, new Map());
196
+ index.nodes.set(cap.id, new WeakMap());
197
+ for (const [name, schema] of Object.entries(cap.openapi.components?.schemas ?? {}))
198
+ register(cap, name, name, schema, { kind: 'schema', file: cap.files?.interface });
199
+ }
200
+ for (const cap of model.capabilities) {
201
+ for (const entity of [...index.caps.get(cap.id).values()]) discover(cap, entity.schema, entity);
202
+ for (const op of orderedOperations(cap)) {
203
+ const addRoot = (part, schema, label, source, status = '') => {
204
+ if (schema === undefined) return;
205
+ const entity =
206
+ entityAt(cap, schema) ??
207
+ register(
208
+ cap,
209
+ '@operation/' + op.operationId + '/' + part + (status ? '/' + status : ''),
210
+ pascal(op.operationId) + label,
211
+ schema,
212
+ { ...source, operationId: op.operationId },
213
+ true,
214
+ );
215
+ index.roots.set(rootKey(cap, op, part, status), { entity, cap, op, part, status });
216
+ discover(cap, entity.schema, entity);
217
+ };
218
+ for (const location of ['path', 'query', 'header', 'cookie']) {
219
+ const params = (op.parameters ?? []).filter((p) => p.in === location);
220
+ if (params.length)
221
+ addRoot(location, parameterSchema(params), pascal(location), {
222
+ kind: 'parameters',
223
+ location,
224
+ });
225
+ }
226
+ addRoot('body', op.requestBody?.content?.['application/json']?.schema, 'Body', {
227
+ kind: 'body',
228
+ });
229
+ for (const [status, r] of Object.entries(op.responses ?? {}))
230
+ addRoot(
231
+ 'response',
232
+ r.content?.['application/json']?.schema,
233
+ 'Response' + pascal(status),
234
+ { kind: 'response', status },
235
+ status,
236
+ );
237
+ }
238
+ }
239
+ for (const entity of index.entities) {
240
+ const cap = model.capabilities.find((c) => c.id === entity.capability);
241
+ const visit = (schema, path = '') => {
242
+ const other = entityAt(cap, schema);
243
+ if (other && other !== entity) {
244
+ const ref = entity.references.get(other.identity) ?? { entity: other, fields: new Set() };
245
+ ref.fields.add(path || 'definition');
246
+ entity.references.set(other.identity, ref);
247
+ return;
248
+ }
249
+ if (schema?.$ref) {
250
+ const name = schemaName(schema);
251
+ const ref = index.caps.get(cap.id).get(name);
252
+ if (ref && ref !== entity) {
253
+ entity.references.set(ref.identity, {
254
+ entity: ref,
255
+ fields: new Set([path || 'definition']),
256
+ });
257
+ }
258
+ return;
259
+ }
260
+ for (const child of schemaChildren(schema))
261
+ visit(
262
+ child.schema,
263
+ path +
264
+ (child.field === '[]' || child.field === '[key]'
265
+ ? child.field
266
+ : child.field
267
+ ? (path ? '.' : '') + child.field
268
+ : ''),
269
+ );
270
+ };
271
+ visit(entity.schema);
272
+ }
273
+ for (const root of index.roots.values()) {
274
+ const role =
275
+ root.part === 'response'
276
+ ? 'Response ' + root.status
277
+ : root.part === 'body'
278
+ ? 'JSON body'
279
+ : pascal(root.part),
280
+ seen = new Set();
281
+ const visit = (entity) => {
282
+ if (seen.has(entity.identity)) return;
283
+ seen.add(entity.identity);
284
+ const key = JSON.stringify([root.cap.id, root.op.operationId]);
285
+ const use = entity.usages.get(key) ?? {
286
+ capability: root.cap.id,
287
+ operationId: root.op.operationId,
288
+ roles: new Set(),
289
+ };
290
+ use.roles.add(role);
291
+ entity.usages.set(key, use);
292
+ for (const ref of entity.references.values()) visit(ref.entity);
293
+ };
294
+ visit(root.entity);
295
+ }
296
+ return index;
297
+ }
298
+ function entityFor(cap, schema) {
299
+ const name = schemaName(schema);
300
+ return name
301
+ ? entityIndex.caps.get(cap.id)?.get(name)
302
+ : entityIndex.nodes.get(cap.id)?.get(schema);
303
+ }
304
+ function rootEntity(cap, op, part, status = '') {
305
+ return entityIndex.roots.get(rootKey(cap, op, part, status))?.entity;
306
+ }
307
+ function referencedSchemas(cap, schemas) {
308
+ const names = new Set();
309
+ const visit = (node) => {
310
+ if (!node || typeof node !== 'object') return;
311
+ const ref = node.$ref?.match(/^#\/components\/schemas\/(.+)$/)?.[1];
312
+ if (ref) {
313
+ const name = ref.replaceAll('~1', '/').replaceAll('~0', '~');
314
+ if (!names.has(name)) {
315
+ names.add(name);
316
+ visit(cap.openapi.components?.schemas?.[name]);
317
+ }
318
+ }
319
+ for (const [key, value] of Object.entries(node)) if (key !== '$ref') visit(value);
320
+ };
321
+ visit(schemas);
322
+ return names;
323
+ }
324
+ function visualsForSurface(t) {
325
+ const cap = project.capabilities.find((c) => c.id === t.capability),
326
+ op = cap.operations.find((o) => o.operationId === t.id);
327
+ const schemas =
328
+ t.kind === 'request'
329
+ ? [op?.requestBody, ...(op?.parameters ?? [])]
330
+ : t.kind === 'response'
331
+ ? op?.responses?.[t.status]
332
+ : null;
333
+ const names = referencedSchemas(cap, schemas);
334
+ return (project.visuals ?? []).filter(
335
+ (v) =>
336
+ sameTarget(v.target, t) ||
337
+ (['request', 'response'].includes(t.kind) &&
338
+ v.target.capability === t.capability &&
339
+ ((v.target.kind === 'field' &&
340
+ v.target.id === t.id &&
341
+ v.target.scope === t.kind &&
342
+ (!v.target.status || String(v.target.status) === String(t.status))) ||
343
+ (v.target.kind === 'schema' && names.has(v.target.id)))),
344
+ );
345
+ }
346
+ function visualGallery(t) {
347
+ const visuals = visualsForSurface(t);
348
+ return visuals.length
349
+ ? `<div class="visual-gallery">${visuals
350
+ .map((v) => {
351
+ let source =
352
+ v.url || (embedded ? media[v.id] : '/api/visuals/' + encodeURIComponent(v.id));
353
+ if (v.url) {
354
+ try {
355
+ const parsed = new URL(v.url);
356
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password) source = null;
357
+ } catch {
358
+ source = null;
359
+ }
360
+ }
361
+ const mime =
362
+ v.mime ??
363
+ {
364
+ png: 'image/png',
365
+ jpg: 'image/jpeg',
366
+ jpeg: 'image/jpeg',
367
+ webp: 'image/webp',
368
+ gif: 'image/gif',
369
+ pdf: 'application/pdf',
370
+ }[v.file?.split('.').pop().toLowerCase()];
371
+ const image = v.file && mime?.startsWith('image/') && source;
372
+ const context =
373
+ v.target.kind === 'field'
374
+ ? v.target.path
375
+ : v.target.kind === 'schema'
376
+ ? v.target.id
377
+ : null;
378
+ return `<figure class="visual">${context ? `<span class="visual-context">${escape(context)}</span>` : ''}<a href="${escape(source ?? '#')}" target="_blank" rel="noopener noreferrer"${embedded && mime === 'application/pdf' ? ` download="${escape(v.file.split('/').pop())}"` : ''}>${image ? `<img src="${escape(source)}" alt="${escape(v.title)}" loading="lazy">` : ''}<span>${escape(v.title)}${v.url ? ' ↗' : ''}</span></a>${v.caption ? `<figcaption>${escape(v.caption)}</figcaption>` : ''}</figure>`;
379
+ })
380
+ .join('')}</div>`
381
+ : '';
382
+ }
383
+ function attachmentBlock(
384
+ t,
385
+ content,
386
+ { tag = 'p', className = '', label = t.id ?? 'feature' } = {},
387
+ ) {
388
+ const placement = attachmentPolicy[t.kind];
389
+ if (!placement) throw new Error('No attachment control is permitted for ' + t.kind);
390
+ if ((placement === 'header') !== (tag === 'h3'))
391
+ throw new Error('Attachment control has an invalid placement');
392
+ const key = JSON.stringify([
393
+ t.capability,
394
+ t.kind,
395
+ t.id ?? '',
396
+ t.scope ?? '',
397
+ t.status ?? '',
398
+ t.path ?? '',
399
+ ]);
400
+ // Repeated read-only text may link the same target; only its first surface owns editing.
401
+ if (attachmentOwners.has(key)) return `<${tag} class="${escape(className)}">${content}</${tag}>`;
402
+ attachmentOwners.add(key);
403
+ return `<div class="attachment-block" data-visual-target="${escape(JSON.stringify(t))}"><${tag} class="attachment-zone ${escape(className)}" data-attachment-zone>${content}<button class="text-button add-visual" data-add-visual aria-label="Attach visual to ${escape(label)}" title="Attach visual">+</button></${tag}>${visualGallery(t)}<div class="visual-editor" hidden></div></div>`;
404
+ }
405
+
406
+ function route() {
407
+ const [pathname, params] = location.hash.slice(1).split('?');
408
+ const parts = pathname
409
+ .replace(/^\//, '')
410
+ .split('/')
411
+ .map((x) => {
412
+ try {
413
+ return decodeURIComponent(x);
414
+ } catch {
415
+ return x;
416
+ }
417
+ });
418
+ // Older exported links still lead to the feature overview.
419
+ const id = parts[0] === 'features' ? parts[1] : parts[0];
420
+ const cap = project.capabilities.find((c) => c.id === id) ?? project.capabilities[0];
421
+ return {
422
+ cap,
423
+ kind: parts[0] === 'changes' ? 'changes' : parts[0] === 'features' ? parts[2] : null,
424
+ id: parts[0] === 'changes' ? parts[1] : parts[3],
425
+ rule: new URLSearchParams(params).get('rule'),
426
+ compare: new URLSearchParams(params).get('compare') === '1',
427
+ };
428
+ }
429
+ function orderedOperations(cap) {
430
+ const order = cap.presentation?.operationOrder ?? [];
431
+ return [...cap.operations].sort(
432
+ (a, b) =>
433
+ (order.includes(a.operationId) ? order.indexOf(a.operationId) : 999) -
434
+ (order.includes(b.operationId) ? order.indexOf(b.operationId) : 999),
435
+ );
436
+ }
437
+ const operationPresentation = (cap, op) => cap.presentation?.operations?.[op.operationId] ?? {};
438
+ function operationRuleIds(cap, op) {
439
+ const presentation = operationPresentation(cap, op);
440
+ return [
441
+ ...new Set(
442
+ [
443
+ ...(op.rules ?? []),
444
+ ...(op['x-preconditions'] ?? []).map((p) => p.fails?.clause),
445
+ ...Object.values(op.responses ?? {}).flatMap((r) =>
446
+ (r['x-error-codes'] ?? []).map((e) => e.clause),
447
+ ),
448
+ ...(presentation.rules ?? []),
449
+ ...(presentation.ruleGroups ?? []).flatMap((g) => g.rules ?? []),
450
+ ].filter(Boolean),
451
+ ),
452
+ ];
453
+ }
454
+ function rulesFor(cap, op) {
455
+ const ids = operationRuleIds(cap, op);
456
+ return cap.rules.filter((r) => ids.includes(r.id));
457
+ }
458
+ function ruleLink(cap, id, label, currentOperation) {
459
+ const op =
460
+ currentOperation ?? orderedOperations(cap).find((o) => operationRuleIds(cap, o).includes(id));
461
+ return op
462
+ ? `<a href="${escape(endpointUrl(cap, op, id))}">${escape(label ?? id)}</a>`
463
+ : escape(label ?? id);
464
+ }
465
+
466
+ function resolveSchema(cap, schema, seen = new Set()) {
467
+ if (!schema?.$ref) return schema ?? {};
468
+ if (seen.has(schema.$ref)) return { type: 'object', description: 'Recursive object' };
469
+ let node = cap.openapi;
470
+ for (const part of schema.$ref.slice(2).split('/'))
471
+ node = node?.[part.replaceAll('~1', '/').replaceAll('~0', '~')];
472
+ const next = new Set(seen).add(schema.$ref);
473
+ return {
474
+ ...resolveSchema(cap, node, next),
475
+ ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== '$ref')),
476
+ };
477
+ }
478
+ function typeLabel(cap, input, depth = 0, seen = new Set()) {
479
+ if (input === false) return 'never';
480
+ if (input === true) return 'unknown';
481
+ if (input?.$ref && seen.has(input.$ref)) return '{...}';
482
+ const next = new Set(seen);
483
+ if (input?.$ref) next.add(input.$ref);
484
+ const s = resolveSchema(cap, input);
485
+ if (s.const !== undefined) return JSON.stringify(s.const);
486
+ if (s.enum) return s.enum.map((v) => JSON.stringify(v)).join(' | ');
487
+ if (s.anyOf || s.oneOf)
488
+ return (s.anyOf ?? s.oneOf).map((x) => typeLabel(cap, x, depth, next)).join(' | ');
489
+ if (s.allOf) return s.allOf.map((x) => typeLabel(cap, x, depth, next)).join(' & ');
490
+ if (s.type === 'array') return `Array<${typeLabel(cap, s.items, depth + 1, next)}>`;
491
+ if (s.properties)
492
+ return '{...}' + (Array.isArray(s.type) && s.type.includes('null') ? ' | null' : '');
493
+ const types = Array.isArray(s.type) ? s.type : [s.type ?? 'unknown'];
494
+ return types.map((t) => (t === 'integer' ? 'number' : t)).join(' | ');
495
+ }
496
+ function typeMarkup(cap, input, seen = new Set(), includeEntity = true) {
497
+ const entity = includeEntity ? entityFor(cap, input) : null,
498
+ s = resolveSchema(cap, input),
499
+ next = new Set(seen);
500
+ if (input?.$ref) next.add(input.$ref);
501
+ if (entity) {
502
+ const link = typeLink(cap, entity);
503
+ if (input?.$ref && seen.has(input.$ref))
504
+ return link + ' <span class="field-constraint">recursive</span>';
505
+ if (s.properties || s.type === 'object')
506
+ return (
507
+ link +
508
+ ' <span class="object-pill">{...}</span>' +
509
+ (Array.isArray(s.type) && s.type.includes('null') ? ' | null' : '')
510
+ );
511
+ if (s.type === 'array') return link + ` Array&lt;${typeMarkup(cap, s.items, next)}&gt;`;
512
+ const objects = nestedObjects(cap, input, seen);
513
+ return (
514
+ link +
515
+ (objects.length ? ' <span class="object-pill">{...}</span>' : '') +
516
+ ((s.anyOf ?? s.oneOf ?? []).some((x) => x.type === 'null') ? ' | null' : '')
517
+ );
518
+ }
519
+ if (s.anyOf || s.oneOf)
520
+ return (s.anyOf ?? s.oneOf).map((x) => typeMarkup(cap, x, next)).join(' | ');
521
+ if (s.allOf) return s.allOf.map((x) => typeMarkup(cap, x, next)).join(' & ');
522
+ if (s.type === 'array') return `Array&lt;${typeMarkup(cap, s.items, next)}&gt;`;
523
+ if (s.properties || s.type === 'object')
524
+ return (
525
+ '<span class="object-pill">{...}</span>' +
526
+ (Array.isArray(s.type) && s.type.includes('null') ? ' | null' : '')
527
+ );
528
+ return escape(typeLabel(cap, s));
529
+ }
530
+ function constraint(input) {
531
+ const parts = [];
532
+ if (input.format) parts.push(input.format);
533
+ if (input.type === 'integer') parts.push('integer');
534
+ if (input.minimum !== undefined && input.maximum !== undefined)
535
+ parts.push(`${input.minimum}–${input.maximum}`);
536
+ else {
537
+ if (input.minimum !== undefined) parts.push(`≥ ${input.minimum}`);
538
+ if (input.maximum !== undefined) parts.push(`≤ ${input.maximum}`);
539
+ }
540
+ if (input.maxLength !== undefined) parts.push(`max ${input.maxLength} chars`);
541
+ if (input.pattern)
542
+ parts.push(
543
+ input.pattern === '^\\d{4}-\\d{2}-\\d{2}$' ? 'YYYY-MM-DD' : 'pattern: ' + input.pattern,
544
+ );
545
+ return parts.length ? `<span class="field-constraint">${escape(parts.join(' · '))}</span>` : '';
546
+ }
547
+ function nestedObjects(cap, input, seen) {
548
+ if (input?.$ref && seen.has(input.$ref)) return [];
549
+ const next = new Set(seen);
550
+ if (input?.$ref) next.add(input.$ref);
551
+ const s = resolveSchema(cap, input);
552
+ if (s.properties) return [{ schema: input, suffix: '' }];
553
+ if (s.type === 'array')
554
+ return nestedObjects(cap, s.items, next).map((item) => ({
555
+ ...item,
556
+ suffix: item.suffix + '[]',
557
+ }));
558
+ return (s.anyOf ?? s.oneOf ?? s.allOf ?? []).flatMap((item) => nestedObjects(cap, item, next));
559
+ }
560
+ function objectSignature(cap, input, depth = 0, seen = new Set(), root = null) {
561
+ if (input?.$ref && seen.has(input.$ref))
562
+ return `<span class="field-type">${typeMarkup(cap, input, seen)}</span>`;
563
+ const next = new Set(seen);
564
+ if (input?.$ref) next.add(input.$ref);
565
+ const s = resolveSchema(cap, input);
566
+ const entity = root ?? entityFor(cap, input),
567
+ name = depth === 0 && entity ? typeLink(cap, entity) + ' ' : '';
568
+ if (!s.properties)
569
+ return `<div class="signature">${name}${typeMarkup(cap, s, next, false)}</div>`;
570
+ const oldCap = baselineProject?.capabilities.find((c) => c.id === cap.id),
571
+ oldEntity = entity && baselineEntities?.caps.get(cap.id)?.get(entity.id);
572
+ const old = oldCap && oldEntity ? resolveSchema(oldCap, oldEntity.schema) : null;
573
+ const fields = Object.entries(s.properties)
574
+ .map(([name, p]) => {
575
+ const resolved = resolveSchema(cap, p),
576
+ optional = (s.required ?? []).includes(name) ? '' : '?';
577
+ const label = `<span class="field-name">${escape(name + optional)}:</span> <span class="field-type">${typeMarkup(cap, p, next)}</span>${constraint(resolved)}`;
578
+ const status =
579
+ comparisonEnabled && old
580
+ ? old.properties?.[name] === undefined
581
+ ? 'added'
582
+ : JSON.stringify(
583
+ canonical({
584
+ schema: old.properties[name],
585
+ required: (old.required ?? []).includes(name),
586
+ }),
587
+ ) !==
588
+ JSON.stringify(
589
+ canonical({ schema: p, required: (s.required ?? []).includes(name) }),
590
+ )
591
+ ? 'changed'
592
+ : null
593
+ : comparisonEnabled && baseline && entity && !oldEntity
594
+ ? 'added'
595
+ : null;
596
+ const attrs = status ? ` data-change="${status}" title="${pascal(status)} field"` : '';
597
+ const marker = status ? `<span class="field-change">${pascal(status)}</span>` : '';
598
+ const objects = nestedObjects(cap, p, next);
599
+ if (objects.length && depth < 7 && !(p.$ref && next.has(p.$ref)))
600
+ return `<details class="inline-object"${attrs}><summary>${label}${marker}</summary><div>${objects.map((obj) => objectSignature(cap, obj.schema, depth + 1, next)).join('')}${resolved.description ? `<p class="field-note">${escape(resolved.description)}</p>` : ''}</div></details>`;
601
+ return `<div class="field"${attrs}>${label};${marker}</div>`;
602
+ })
603
+ .join('');
604
+ const removed =
605
+ comparisonEnabled && old
606
+ ? Object.entries(old.properties ?? {})
607
+ .filter(([name]) => !Object.hasOwn(s.properties, name))
608
+ .map(
609
+ ([name, p]) =>
610
+ `<div class="field" data-change="removed"><span class="field-name">${escape(name + ((old.required ?? []).includes(name) ? '' : '?'))}:</span> <span class="field-type">${escape(typeLabel(oldCap, p))}</span>;<span class="field-change">Removed</span></div>`,
611
+ )
612
+ .join('')
613
+ : '';
614
+ return `<div class="signature">${name}{<div class="signature-body">${fields}${removed}</div>}${Array.isArray(s.type) && s.type.includes('null') ? ' | null' : ''}</div>`;
615
+ }
616
+ function parameterSchema(parameters) {
617
+ return {
618
+ type: 'object',
619
+ properties: Object.fromEntries(parameters.map((p) => [p.name, p.schema ?? {}])),
620
+ required: parameters.filter((p) => p.required).map((p) => p.name),
621
+ };
622
+ }
623
+ function requestSignature(cap, op) {
624
+ const parts = [];
625
+ for (const location of ['path', 'query', 'header', 'cookie']) {
626
+ const params = (op.parameters ?? []).filter((p) => p.in === location);
627
+ if (params.length) {
628
+ const entity = rootEntity(cap, op, location);
629
+ parts.push(
630
+ `<p class="request-part">${escape(location.charAt(0).toUpperCase() + location.slice(1))}</p>${objectSignature(cap, entity.schema, 0, new Set(), entity)}`,
631
+ );
632
+ }
633
+ }
634
+ const body = op.requestBody?.content?.['application/json']?.schema;
635
+ if (body !== undefined)
636
+ parts.push(
637
+ `<p class="request-part">JSON body${op.requestBody.required ? '' : ' · optional'}</p>${objectSignature(cap, body, 0, new Set(), rootEntity(cap, op, 'body'))}`,
638
+ );
639
+ return parts.join('') || '<div class="signature">No parameters or body.</div>';
640
+ }
641
+ function successResponse(op) {
642
+ return (
643
+ Object.entries(op.responses ?? {}).find(([status]) => /^2\d\d$/.test(status)) ??
644
+ Object.entries(op.responses ?? {})[0] ?? ['—', {}]
645
+ );
646
+ }
647
+ function io(cap, op) {
648
+ const [status, res] = successResponse(op);
649
+ return `<div class="io-grid"><section>${attachmentBlock(target(cap, 'request', op.operationId), 'Request', { tag: 'h3', className: 'io-heading', label: 'Request' })}${requestSignature(cap, op)}</section><section>${attachmentBlock(target(cap, 'response', op.operationId, { status }), `Response <strong>${escape(status)}</strong>`, { tag: 'h3', className: 'io-heading', label: 'Response ' + status })}${res.content?.['application/json']?.schema !== undefined ? objectSignature(cap, res.content['application/json'].schema, 0, new Set(), rootEntity(cap, op, 'response', status)) : '<div class="signature">No response body.</div>'}</section></div>`;
650
+ }
651
+
652
+ function diagram(title, source) {
653
+ return `<figure class="diagram"><figcaption>${escape(title)}</figcaption><div class="diagram-canvas" data-mermaid="${escape(source)}" aria-label="${escape(title)}"><span class="section-note">Rendering diagram…</span></div><details class="diagram-source"><summary>Diagram source</summary><pre>${escape(source)}</pre></details></figure>`;
654
+ }
655
+ function markdownDiagrams(markdown, title) {
656
+ return [...String(markdown ?? '').matchAll(/```mermaid\s*\n([\s\S]*?)```/g)]
657
+ .map((match) => diagram(title, match[1].trim()))
658
+ .join('');
659
+ }
660
+ function operationDiagrams(cap, op) {
661
+ return (operationPresentation(cap, op).diagrams ?? [])
662
+ .map((d) => diagram(d.title, d.source))
663
+ .join('');
664
+ }
665
+ const diagrams = globalThis.mermaid;
666
+ if (diagrams)
667
+ diagrams.initialize({
668
+ startOnLoad: false,
669
+ securityLevel: 'strict',
670
+ suppressErrorRendering: true,
671
+ theme: 'base',
672
+ fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, sans-serif',
673
+ htmlLabels: false,
674
+ flowchart: { htmlLabels: false, useMaxWidth: true },
675
+ sequence: { useMaxWidth: true },
676
+ secure: ['secure', 'securityLevel', 'startOnLoad', 'htmlLabels', 'theme', 'themeVariables'],
677
+ themeVariables: {
678
+ primaryColor: '#ffffff',
679
+ primaryTextColor: '#141414',
680
+ primaryBorderColor: '#aaa',
681
+ lineColor: '#666',
682
+ secondaryColor: '#ffffff',
683
+ tertiaryColor: '#ffffff',
684
+ background: '#ffffff',
685
+ mainBkg: '#ffffff',
686
+ nodeBorder: '#aaa',
687
+ clusterBkg: '#ffffff',
688
+ clusterBorder: '#ddd',
689
+ actorBkg: '#ffffff',
690
+ actorBorder: '#aaa',
691
+ actorTextColor: '#141414',
692
+ signalColor: '#666',
693
+ signalTextColor: '#141414',
694
+ labelBoxBkgColor: '#fff',
695
+ labelBoxBorderColor: '#aaa',
696
+ labelTextColor: '#141414',
697
+ loopTextColor: '#141414',
698
+ noteBkgColor: '#fff',
699
+ noteBorderColor: '#aaa',
700
+ noteTextColor: '#141414',
701
+ textColor: '#141414',
702
+ fontSize: '12px',
703
+ },
704
+ });
705
+ async function renderDiagrams(version) {
706
+ for (const element of document.querySelectorAll('[data-mermaid]')) {
707
+ if (version !== generation || !element.isConnected) return;
708
+ if (element.dataset.rendered === 'true' || element.closest('details:not([open])')) continue;
709
+ try {
710
+ if (!diagrams) throw new Error('The Mermaid runtime is unavailable.');
711
+ const id = 'mhproto-diagram-' + ++diagramSerial;
712
+ const { svg } = await diagrams.render(id, element.dataset.mermaid);
713
+ if (version !== generation || !element.isConnected) return;
714
+ element.innerHTML = svg;
715
+ element.dataset.rendered = 'true';
716
+ } catch (error) {
717
+ if (element.isConnected)
718
+ element.innerHTML = `<p class="diagram-error">Could not render this diagram. ${escape(error.message.split('\n')[0])} Its source is available below.</p>`;
719
+ }
720
+ }
721
+ }
722
+
723
+ function evidenceResult(cap, check) {
724
+ if (!cap.evidence) return { status: 'unchecked' };
725
+ if (cap.evidence.stale || cap.evidence.changedDuringRun) return { status: 'stale' };
726
+ return cap.evidence.results.find((r) => r.id === check.id) ?? { status: 'unchecked' };
727
+ }
728
+ const statusLabel = (status) =>
729
+ ({ passing: 'Passed', failing: 'Failed', stale: 'Needs a new run', unchecked: 'Not run' })[
730
+ status
731
+ ] ?? status;
732
+ function checksList(cap, checks) {
733
+ return `<ul class="check-list">${checks
734
+ .map((c) => {
735
+ const r = evidenceResult(cap, c);
736
+ return `<li><a href="${escape(checkUrl(cap, c))}">${escape(checkTitle(c))}</a><span class="check-status ${r.status === 'failing' ? 'failing' : ''}">${escape(statusLabel(r.status))}</span></li>`;
737
+ })
738
+ .join('')}</ul>`;
739
+ }
740
+ function checksSection(cap, checks = cap.checks, rules = cap.rules) {
741
+ const passing = checks.filter((c) => evidenceResult(cap, c).status === 'passing');
742
+ const attention = checks.filter((c) => evidenceResult(cap, c).status !== 'passing');
743
+ const gaps = rules.filter((r) => !cap.checks.some((c) => c.rules?.includes(r.id)));
744
+ const summary = cap.evidence
745
+ ? `${passing.length} of ${checks.length} linked checks have current passing evidence.`
746
+ : 'No verification run recorded.';
747
+ return `<section class="checks-section"><h2>Checks</h2><p class="section-note">${escape(summary)}${gaps.length ? ` ${gaps.length} ${gaps.length === 1 ? 'rule still needs' : 'rules still need'} a linked check.` : ''}</p>${attention.length ? disclosure('Needs attention · ' + attention.length, checksList(cap, attention), true) : ''}${passing.length ? disclosure('Passing checks · ' + passing.length, checksList(cap, passing)) : ''}${gaps.length ? disclosure('Rules without checks · ' + gaps.length, `<ul class="check-list">${gaps.map((r) => `<li>${ruleLink(cap, r.id, cap.presentation?.ruleTitles?.[r.id] ?? clean(r.text.split('\n')[0]))}</li>`).join('')}</ul>`) : ''}${disclosure('Scope of this evidence', '<p class="section-note">A passing run establishes what the linked tests observed. It does not cover every case or live model behaviour. Tracked source changes make the evidence stale.</p>' + (cap.gaps ?? []).map((g) => `<p class="section-note">${escape(g)}</p>`).join(''))}</section>`;
748
+ }
749
+ function overview(cap) {
750
+ return `<header><h1>${escape(cap.title ?? cap.id)}</h1>${attachmentBlock(target(cap, 'feature'), escape(cap.description ?? 'Describe what this page loads, shows and lets the user do.'), { className: 'description', label: cap.title ?? cap.id })}${cap.url ? `<p class="feature-url"><span>URL</span><code>${escape(cap.url)}</code></p>` : ''}</header><section id="api"><h2>API</h2>${orderedOperations(
751
+ cap,
752
+ )
753
+ .map((op) => {
754
+ const info = operationPresentation(cap, op);
755
+ return `<article class="endpoint" data-operation="${escape(op.operationId)}"><h3 class="endpoint-heading">${method(op)}<a href="${escape(endpointUrl(cap, op))}">${escape(op.path)}</a></h3>${attachmentBlock(target(cap, 'operation', op.operationId), escape(op.summary ?? op.operationId), { className: 'endpoint-summary', label: op.path })}${io(cap, op)}<p class="behaviour-preview"><b>Behaviour.</b> ${escape(info.behaviour ?? clean(rulesFor(cap, op)[0]?.text ?? 'No behaviour rules linked yet.'))}</p></article>`;
756
+ })
757
+ .join('')}</section>${markdownDiagrams(cap.prose, 'Play states')}${checksSection(cap)}`;
758
+ }
759
+ function errorSection(cap, op) {
760
+ const errors = Object.entries(op.responses ?? {}).filter(([status]) => !/^2/.test(status));
761
+ if (!errors.length) return '';
762
+ const schema = errors
763
+ .map(([, r]) => r.content?.['application/json']?.schema)
764
+ .find((schema) => schema !== undefined);
765
+ return `<section><h2>Errors</h2>${schema !== undefined ? objectSignature(cap, schema) : ''}<table class="table"><thead><tr><th>Status</th><th>Code</th><th>When</th></tr></thead><tbody>${errors.flatMap(([status, r]) => (r['x-error-codes'] ?? [{ code: '—', when: clean(r.description) }]).map((e) => `<tr><td>${escape(status)}</td><td><code>${escape(e.code)}</code></td><td>${escape(e.when)}${e.clause ? ' · ' + ruleLink(cap, e.clause, 'Rule', op) : ''}</td></tr>`)).join('')}</tbody></table></section>`;
766
+ }
767
+ function scenario(cap, e) {
768
+ return `<article class="scenario"><h3>${escape(e.title ?? e.id)}</h3>${attachmentBlock(target(cap, 'example', e.id), ['given', 'when', 'then'].map((k) => `<div class="scenario-line"><b>${k.charAt(0).toUpperCase() + k.slice(1)}</b><span>${prose(e[k])}</span></div>`).join(''), { tag: 'div', className: 'scenario-text', label: e.title ?? e.id })}${e.request ? disclosure('Payload example', raw({ request: e.request, response: e.response })) : ''}</article>`;
769
+ }
770
+ function endpointPage(cap, op) {
771
+ const info = operationPresentation(cap, op),
772
+ rules = rulesFor(cap, op);
773
+ const examples = cap.examples.filter((e) => (e.operations ?? []).includes(op.operationId));
774
+ const checks = cap.checks.filter(
775
+ (c) =>
776
+ (c.rules ?? []).some((id) => rules.some((r) => r.id === id)) ||
777
+ (c.examples ?? []).some((id) => examples.some((e) => e.id === id)),
778
+ );
779
+ const groups = info.ruleGroups ?? [],
780
+ grouped = new Set(groups.flatMap((g) => g.rules));
781
+ const ruleList = (items) =>
782
+ `<ul class="rule-list">${items.map((r) => `<li id="${escape(r.id)}">${attachmentBlock(target(cap, 'rule', r.id), prose(r.text), { tag: 'div', className: 'rule-text', label: r.id })}<a class="rule-id" href="${escape(endpointUrl(cap, op, r.id))}">${escape(r.id)}</a></li>`).join('')}</ul>`;
783
+ return `<a class="back" href="${escape(featureUrl(cap))}">← ${escape(cap.title ?? cap.id)}</a><header><h1 class="endpoint-title">${method(op)} ${escape(op.path)}</h1>${attachmentBlock(target(cap, 'operation', op.operationId), escape(info.description ?? op.summary ?? op.operationId), { className: 'description', label: op.path })}</header><div class="page-actions"><button class="text-button" data-copy-url>Copy page link</button></div>${io(cap, op)}<section><h2>Behaviour</h2>${info.behaviour ? `<p class="description">${escape(info.behaviour)}</p>` : ''}${ruleList(rules.filter((r) => !grouped.has(r.id)))}${groups.map((group) => disclosure(group.title, ruleList(rules.filter((r) => group.rules.includes(r.id))))).join('')}</section>${operationDiagrams(cap, op)}${errorSection(cap, op)}${examples.length ? `<section><h2>Examples</h2>${examples.map((e) => scenario(cap, e)).join('')}</section>` : ''}${checksSection(cap, checks, rules)}${disclosure(
784
+ 'Payload examples',
785
+ raw({
786
+ request: op.requestBody?.content?.['application/json']?.example,
787
+ responses: Object.fromEntries(
788
+ Object.entries(op.responses ?? {})
789
+ .filter(([, r]) => r.content?.['application/json']?.example)
790
+ .map(([status, r]) => [status, r.content['application/json'].example]),
791
+ ),
792
+ }),
793
+ )}`;
794
+ }
795
+ function checkPage(cap, check) {
796
+ const r = evidenceResult(cap, check),
797
+ expected = new Set(check.testNames ?? []);
798
+ const observed = (r.tests ?? []).filter((t) => expected.has(t.name) || t.type === 'test:fail');
799
+ return `<a class="back" href="${escape(featureUrl(cap))}">← ${escape(cap.title ?? cap.id)}</a><h1>${escape(checkTitle(check))}</h1>${attachmentBlock(target(cap, 'check', check.id), escape(statusLabel(r.status)), { className: 'section-note ' + (r.status === 'failing' ? 'failing' : ''), label: checkTitle(check) })}${r.status === 'stale' ? '<p class="description">The tracked sources changed. Run this check again to refresh its evidence.</p>' : ''}${r.error ? `<p class="error">${escape(r.error)}</p>` : ''}${r.missing?.length ? `<p class="error">Expected tests did not run: ${escape(r.missing.join(', '))}</p>` : ''}${r.stderr ? disclosure('Error output', `<pre>${escape(r.stderr)}</pre>`, r.status === 'failing') : ''}${observed.length ? disclosure('Observed tests', observed.map((t) => `<p class="section-note">${t.skip ? 'Skipped' : t.todo ? 'TODO' : t.type === 'test:fail' ? 'Failed' : 'Passed'} · ${escape(t.name)}</p>`).join(''), r.status === 'failing') : ''}<h2>Behaviour checked</h2><ul class="check-list">${(check.rules ?? []).map((id) => `<li>${ruleLink(cap, id, clean(cap.rules.find((x) => x.id === id)?.text.split('\n')[0] ?? id))}</li>`).join('')}</ul>${disclosure('Run details', raw({ command: check.command, durationMs: r.durationMs, exitCode: r.exitCode }))}`;
800
+ }
801
+ function entityPage(cap, entity) {
802
+ const source = entity.source,
803
+ op = cap.operations.find((o) => o.operationId === source.operationId);
804
+ const context = entity.derived
805
+ ? source.kind === 'parameters'
806
+ ? `${pascal(source.location)} parameters for ${op.method} ${op.path}.`
807
+ : source.kind === 'body'
808
+ ? `JSON request body for ${op.method} ${op.path}.`
809
+ : source.kind === 'response'
810
+ ? `Response ${source.status} for ${op.method} ${op.path}.`
811
+ : `Object inside ${source.parent}.`
812
+ : entity.schema?.description;
813
+ const groups = project.capabilities
814
+ .flatMap((feature) => {
815
+ const uses = [...entity.usages.values()].filter((u) => u.capability === feature.id);
816
+ if (!uses.length) return [];
817
+ return `<section class="type-usage-group"><h3><a href="${escape(featureUrl(feature))}">${escape(feature.title ?? feature.id)} overview</a></h3><ul class="type-usage-list">${orderedOperations(
818
+ feature,
819
+ )
820
+ .filter((o) => uses.some((u) => u.operationId === o.operationId))
821
+ .map((o) => {
822
+ const use = uses.find((u) => u.operationId === o.operationId);
823
+ return `<li>${method(o)}<a href="${escape(endpointUrl(feature, o))}">${escape(o.path)}</a><span class="section-note">${escape([...use.roles].join(' · '))}</span></li>`;
824
+ })
825
+ .join('')}</ul></section>`;
826
+ })
827
+ .join('');
828
+ const parents = entityIndex.entities.filter(
829
+ (e) => e.identity !== entity.identity && e.references.has(entity.identity),
830
+ );
831
+ return `<a class="back" href="${escape(featureUrl(cap))}">← ${escape(cap.title ?? cap.id)}</a><header><h1>${escape(entity.name)}</h1>${context ? `<p class="description">${escape(context)}</p>` : ''}</header><div class="page-actions"><button class="text-button" data-copy-url>Copy page link</button></div>${objectSignature(cap, entity.schema, 0, new Set(), entity)}${entity.derived ? '' : visualGallery(target(cap, 'schema', entity.id))}<section><h2>Used in</h2>${groups || '<p class="section-note">No API endpoint currently uses this type.</p>'}</section>${
832
+ parents.length
833
+ ? `<section><h2>Referenced by types</h2><ul class="type-parent-list">${parents
834
+ .map((parent) => {
835
+ const feature = project.capabilities.find((c) => c.id === parent.capability),
836
+ fields = [...parent.references.get(entity.identity).fields];
837
+ return `<li>${typeLink(feature, parent)}<span class="section-note">${escape(fields.join(', '))}${feature.id !== cap.id ? ' · ' + escape(feature.title ?? feature.id) : ''}</span></li>`;
838
+ })
839
+ .join('')}</ul></section>`
840
+ : ''
841
+ }<p class="type-source section-note">${entity.derived ? 'Name derived for this view from its existing structure.' : 'Defined in ' + escape(cap.files.interface) + ' · ' + escape(entity.id)}</p>`;
842
+ }
843
+ function sourcesPage(cap) {
844
+ return `<a class="back" href="${escape(featureUrl(cap))}">← ${escape(cap.title ?? cap.id)}</a><h1>Sources</h1><p class="description">The feature reads these existing contract files. Edit them in your editor or through your agent.</p><table class="table"><tbody>${Object.entries(
845
+ cap.files,
846
+ )
847
+ .map(
848
+ ([kind, file]) =>
849
+ `<tr><td>${escape(kind)}</td><td class="source-path">${escape(file)}</td></tr>`,
850
+ )
851
+ .join(
852
+ '',
853
+ )}</tbody></table>${disclosure('Tracked implementation', raw(cap.sources))}${disclosure('Complete behaviour document', `<div class="rule-text">${prose(cap.prose)}</div>` + markdownDiagrams(cap.prose, 'State diagram'))}${disclosure('System map', `<div class="rule-text">${prose(project.system)}</div>` + markdownDiagrams(project.system, 'System diagram'))}${disclosure('Local commands', '<pre>mhproto check\nmhproto verify --capability ' + escape(cap.id) + '\nmhproto snapshot\nmhproto diff</pre>')}`;
854
+ }
855
+ const kindLabel = (kind) =>
856
+ ({
857
+ feature: 'Feature',
858
+ operation: 'API',
859
+ schema: 'Type',
860
+ rule: 'Behaviour',
861
+ example: 'Example',
862
+ check: 'Check',
863
+ visual: 'Visual',
864
+ system: 'System',
865
+ })[kind] ?? kind;
866
+ function changeTitle(c) {
867
+ const value = c.after ?? c.before;
868
+ return c.kind === 'operation'
869
+ ? value.method + ' ' + value.path
870
+ : c.kind === 'rule'
871
+ ? value.title === c.id
872
+ ? clean(value.text.split('\n')[0])
873
+ : value.title
874
+ : c.kind === 'schema'
875
+ ? c.id
876
+ : (value.title ?? (c.kind === 'system' ? 'System and project' : c.id));
877
+ }
878
+ function currentChangeUrl(c) {
879
+ if (c.status === 'removed') return null;
880
+ const cap = project.capabilities.find((x) => x.id === c.capability);
881
+ if (!cap) return featureUrl(project.capabilities[0]) + '/sources';
882
+ if (c.kind === 'operation') {
883
+ const op = cap.operations.find((o) => o.operationId === c.id);
884
+ return op && endpointUrl(cap, op);
885
+ }
886
+ if (c.kind === 'schema') {
887
+ const entity = entityIndex.caps.get(cap.id).get(c.id);
888
+ return entity && typeUrl(cap, entity);
889
+ }
890
+ if (c.kind === 'check') return checkUrl(cap, { id: c.id });
891
+ if (c.kind === 'rule' || c.kind === 'example') {
892
+ const ids = c.kind === 'example' ? c.after.operations : undefined,
893
+ op = orderedOperations(cap).find((o) =>
894
+ ids ? ids.includes(o.operationId) : operationRuleIds(cap, o).includes(c.id),
895
+ );
896
+ return op
897
+ ? endpointUrl(cap, op, c.kind === 'rule' ? c.id : undefined)
898
+ : featureUrl(cap) + '/sources';
899
+ }
900
+ if (c.kind === 'visual') {
901
+ const t = c.after.target;
902
+ if (['operation', 'request', 'response', 'field'].includes(t.kind)) {
903
+ const op = cap.operations.find((o) => o.operationId === t.id);
904
+ if (op) return endpointUrl(cap, op);
905
+ }
906
+ if (t.kind === 'schema') {
907
+ const entity = entityIndex.caps.get(cap.id).get(t.id);
908
+ if (entity) return typeUrl(cap, entity);
909
+ }
910
+ if (t.kind === 'check') return checkUrl(cap, { id: t.id });
911
+ }
912
+ return featureUrl(cap);
913
+ }
914
+ const fieldPath = (path) =>
915
+ path.length
916
+ ? path
917
+ .map((k) => (k === 'schema' ? 'Definition' : k === 'presentation' ? 'Page behaviour' : k))
918
+ .join(' → ')
919
+ : 'Definition';
920
+ function comparisonSetup() {
921
+ return `<details class="disclosure baseline-picker"${!baseline ? ' open' : ''}><summary>${baseline ? 'Change baseline' : 'Choose where this iteration starts'}</summary><div><label class="baseline-file">Compare with an earlier snapshot or preview<input id="baseline-file" type="file" accept=".json,.html,application/json,text/html"></label><p class="section-note">The selected file stays in this viewer. It is never executed.</p><button class="text-button" data-start-iteration${baselineReadOnly ? ' disabled' : ''}>Use current spec as baseline</button><p class="section-note">${baselineReadOnly ? 'Viewing a saved iteration. Start the viewer without --against to save a new baseline.' : embedded ? 'Save preview to keep this baseline with the exported file.' : 'Saves .mhproto/baseline.json for the local workspace.'}</p></div></details><p class="comparison-error error" role="status">${escape(comparisonError)}</p>`;
922
+ }
923
+ function changesPage(id) {
924
+ const changed = id ? comparisonMap.get(id) : null;
925
+ if (id && !changed)
926
+ return '<a class="back" href="#/changes">← Changes</a><h1>Change not found</h1><p class="description">This item is unchanged against the selected baseline. Open Changes to see the current comparison.</p>';
927
+ if (changed) {
928
+ const url = currentChangeUrl(changed),
929
+ format = (value, present) =>
930
+ !present ? '—' : typeof value === 'string' ? value : JSON.stringify(value, null, 2);
931
+ const rows = changed.fields
932
+ .map(
933
+ (f) =>
934
+ `<tr><th scope="row">${escape(fieldPath(f.path))}<span class="field-change">${escape(pascal(f.status))}</span></th><td><pre>${escape(format(f.before, f.beforePresent))}</pre></td><td><pre>${escape(format(f.after, f.afterPresent))}</pre></td></tr>`,
935
+ )
936
+ .join('');
937
+ return `<a class="back" href="#/changes">← Changes</a><h1>${escape(changeTitle(changed))}</h1><p class="section-note">${escape(pascal(changed.status))} · ${escape(kindLabel(changed.kind))} · ${escape(baselineLabel())} → Current spec</p><div class="page-actions"><button class="text-button" data-copy-url>Copy page link</button>${url ? `<a href="${escape(url)}">Open current ${escape(kindLabel(changed.kind).toLowerCase())}</a>` : '<span class="section-note">Removed from the current spec.</span>'}</div><div class="diff-table-wrap"><table class="table diff-table"><thead><tr><th>Field</th><th>Before</th><th>Now</th></tr></thead><tbody>${rows}</tbody></table></div>`;
938
+ }
939
+ const counts = ['added', 'changed', 'removed']
940
+ .map((s) => comparisonChanges.filter((c) => c.status === s).length + ' ' + s)
941
+ .join(' · ');
942
+ const filtered = comparisonChanges.filter(
943
+ (c) => comparisonFilter === 'all' || c.kind === comparisonFilter,
944
+ );
945
+ const groups = [...new Set(filtered.map((c) => c.capability))]
946
+ .map((id) => {
947
+ const cap =
948
+ project.capabilities.find((c) => c.id === id) ??
949
+ baselineProject?.capabilities.find((c) => c.id === id);
950
+ return `<section class="changes-group"><h2>${escape(cap?.title ?? cap?.id ?? 'Project')}</h2><ul class="changes-list">${filtered
951
+ .filter((c) => c.capability === id)
952
+ .map(
953
+ (c) =>
954
+ `<li><span class="change-status ${escape(c.status)}">${escape(pascal(c.status))}</span><div><a href="${escape(changeUrl(c))}">${escape(changeTitle(c))}</a><span class="change-kind">${escape(kindLabel(c.kind))}</span><p class="section-note">${escape(
955
+ c.status === 'changed'
956
+ ? c.fields
957
+ .slice(0, 2)
958
+ .map((f) => fieldPath(f.path))
959
+ .join(' · ') +
960
+ (c.fields.length > 2 ? ' · ' + (c.fields.length - 2) + ' more' : '')
961
+ : c.status === 'added'
962
+ ? 'New in this iteration.'
963
+ : 'Present in the baseline.',
964
+ )}</p></div></li>`,
965
+ )
966
+ .join('')}</ul></section>`;
967
+ })
968
+ .join('');
969
+ return `<h1>Changes</h1><p class="description">${baseline ? escape(baselineLabel()) + ' → Current spec' : 'Save the current spec before editing, or choose an earlier snapshot to compare.'}</p>${baseline?.createdAt ? `<p class="section-note">Baseline saved ${escape(new Date(baseline.createdAt).toLocaleString())}</p>` : ''}${baseline ? `<p class="change-counts">${counts}</p><div class="page-actions"><button class="text-button" data-copy-url>Copy page link</button><button class="text-button" data-download-snapshot>Download current snapshot</button></div>` : ''}${comparisonSetup()}${baseline && comparisonChanges.length ? `<label class="changes-filter">Show <select id="changes-filter">${['all', 'operation', 'schema', 'rule', 'feature', 'example', 'check', 'visual', 'system'].map((k) => `<option value="${k}"${comparisonFilter === k ? ' selected' : ''}>${k === 'all' ? 'All changes' : escape(kindLabel(k))}</option>`).join('')}</select></label>${groups || '<p class="empty">No changes in this category.</p>'}` : baseline ? '<p class="empty">No spec changes since this baseline.</p>' : ''}`;
970
+ }
971
+ function comparisonBar() {
972
+ return comparisonEnabled && baseline
973
+ ? `<div class="comparison-bar"><span>Comparing with ${escape(baselineLabel())}</span><a href="#/changes">${comparisonChanges.length} changes</a><button class="text-button" data-exit-comparison>Hide highlights</button></div>`
974
+ : '';
975
+ }
976
+ function decorateComparison(state) {
977
+ if (!comparisonEnabled || !baseline || state.kind === 'changes' || query) return;
978
+ const cap = state.cap,
979
+ pageChange =
980
+ state.kind === 'api'
981
+ ? changeFor(cap, 'operation', state.id)
982
+ : state.kind === 'types'
983
+ ? changeFor(cap, 'schema', state.id)
984
+ : state.kind === 'checks'
985
+ ? changeFor(cap, 'check', state.id)
986
+ : !state.kind
987
+ ? changeFor(cap, 'feature', cap.id)
988
+ : null;
989
+ if (pageChange) $('#content h1')?.insertAdjacentHTML('beforeend', changeBadge(pageChange));
990
+ for (const article of document.querySelectorAll('.endpoint[data-operation]'))
991
+ article
992
+ .querySelector('.endpoint-heading')
993
+ ?.insertAdjacentHTML(
994
+ 'beforeend',
995
+ changeBadge(changeFor(cap, 'operation', article.dataset.operation)),
996
+ );
997
+ for (const rule of cap.rules)
998
+ document
999
+ .getElementById(rule.id)
1000
+ ?.querySelector('.rule-id')
1001
+ ?.insertAdjacentHTML('afterend', changeBadge(changeFor(cap, 'rule', rule.id)));
1002
+ for (const link of document.querySelectorAll('.check-list>li>a')) {
1003
+ const id = decodeURIComponent(link.hash.split('/checks/')[1] ?? '');
1004
+ if (id) link.insertAdjacentHTML('afterend', changeBadge(changeFor(cap, 'check', id)));
1005
+ }
1006
+ }
1007
+ function searchResults() {
1008
+ const found = [],
1009
+ matches = (value) => JSON.stringify(value).toLowerCase().includes(query.toLowerCase());
1010
+ for (const cap of project.capabilities) {
1011
+ if (matches([cap.title, cap.description, cap.url]))
1012
+ found.push({ url: featureUrl(cap), title: cap.title, note: cap.description });
1013
+ for (const op of orderedOperations(cap))
1014
+ if (matches([op, operationPresentation(cap, op)]))
1015
+ found.push({
1016
+ url: endpointUrl(cap, op),
1017
+ title: op.method + ' ' + op.path,
1018
+ note: op.summary,
1019
+ });
1020
+ for (const rule of cap.rules)
1021
+ if (matches([rule, cap.presentation?.ruleTitles?.[rule.id]])) {
1022
+ const op = orderedOperations(cap).find((o) => operationRuleIds(cap, o).includes(rule.id));
1023
+ if (op)
1024
+ found.push({
1025
+ url: endpointUrl(cap, op, rule.id),
1026
+ title: cap.presentation?.ruleTitles?.[rule.id] ?? clean(rule.text.split('\n')[0]),
1027
+ note: op.method + ' ' + op.path,
1028
+ });
1029
+ }
1030
+ for (const check of cap.checks)
1031
+ if (matches(check))
1032
+ found.push({
1033
+ url: checkUrl(cap, check),
1034
+ title: checkTitle(check),
1035
+ note: cap.title + ' · check',
1036
+ });
1037
+ for (const example of cap.examples)
1038
+ if (matches(example)) {
1039
+ const op = orderedOperations(cap).find((o) =>
1040
+ (example.operations ?? []).includes(o.operationId),
1041
+ );
1042
+ if (op)
1043
+ found.push({
1044
+ url: endpointUrl(cap, op),
1045
+ title: example.title ?? example.id,
1046
+ note: op.method + ' ' + op.path + ' · example',
1047
+ });
1048
+ }
1049
+ for (const entity of entityIndex.caps.get(cap.id).values())
1050
+ if (matches([entity.name, entity.schema?.description]))
1051
+ found.push({
1052
+ url: typeUrl(cap, entity),
1053
+ title: entity.name,
1054
+ note: 'Type · ' + (cap.title ?? cap.id),
1055
+ });
1056
+ }
1057
+ return `<h1>Search</h1>${found.length ? found.map((f) => `<article class="search-result"><a href="${escape(f.url)}">${escape(f.title)}</a><p>${escape(f.note)}</p></article>`).join('') : '<p class="empty">No matches.</p>'}`;
1058
+ }
1059
+ function render() {
1060
+ attachmentOwners = new Set();
1061
+ if (entityProject !== project) {
1062
+ entityIndex = buildEntityIndex(project);
1063
+ entityProject = project;
1064
+ }
1065
+ const version = ++generation,
1066
+ state = route(),
1067
+ cap = state.cap;
1068
+ comparisonEnabled = state.compare || state.kind === 'changes';
1069
+ updateComparison();
1070
+ $('#project-name').textContent = project.name;
1071
+ $('#search').value = query;
1072
+ $('#home').href = 'https://mhproto.dev/';
1073
+ $('#features').innerHTML = project.capabilities
1074
+ .map(
1075
+ (c) =>
1076
+ `<a href="${escape(featureUrl(c))}" class="${c.id === cap.id ? 'active' : ''}"${c.id === cap.id ? ' aria-current="page"' : ''}>${escape(c.title ?? c.id)}</a>`,
1077
+ )
1078
+ .join('');
1079
+ $('#sources-link').href = featureUrl(cap) + '/sources';
1080
+ $('#changes-link').textContent = 'Changes' + (baseline ? ' · ' + comparisonChanges.length : '');
1081
+ let body;
1082
+ if (query) body = searchResults();
1083
+ else if (state.kind === 'changes') body = changesPage(state.id);
1084
+ else if (state.kind === 'api') {
1085
+ const op = cap.operations.find((o) => o.operationId === state.id);
1086
+ body = op
1087
+ ? endpointPage(cap, op)
1088
+ : `<a class="back" href="${escape(featureUrl(cap))}">← ${escape(cap.title)}</a><h1>Endpoint not found</h1>`;
1089
+ } else if (state.kind === 'checks') {
1090
+ const check = cap.checks.find((c) => c.id === state.id);
1091
+ body = check ? checkPage(cap, check) : '<h1>Check not found</h1>';
1092
+ } else if (state.kind === 'types') {
1093
+ const entity = entityIndex.caps.get(cap.id).get(state.id);
1094
+ body = entity
1095
+ ? entityPage(cap, entity)
1096
+ : `<a class="back" href="${escape(featureUrl(cap))}">← ${escape(cap.title ?? cap.id)}</a><h1>Type not found</h1>`;
1097
+ } else if (state.kind === 'sources') body = sourcesPage(cap);
1098
+ else body = overview(cap);
1099
+ $('#content').innerHTML = (state.kind === 'changes' ? '' : comparisonBar()) + body;
1100
+ decorateComparison(state);
1101
+ if (comparisonEnabled && baseline)
1102
+ for (const a of document.querySelectorAll('a[href^="#/features/"]')) {
1103
+ const [pathname, params] = a.getAttribute('href').split('?'),
1104
+ search = new URLSearchParams(params);
1105
+ search.set('compare', '1');
1106
+ a.setAttribute('href', pathname + '?' + search);
1107
+ }
1108
+ const stale = cap.evidence?.stale || cap.evidence?.changedDuringRun;
1109
+ $('#freshness').textContent = stale
1110
+ ? 'Evidence needs a new run'
1111
+ : cap.evidence
1112
+ ? 'Latest run · ' +
1113
+ new Date(cap.evidence.finishedAt).toLocaleString(undefined, {
1114
+ month: 'short',
1115
+ day: 'numeric',
1116
+ hour: '2-digit',
1117
+ minute: '2-digit',
1118
+ })
1119
+ : 'No verification run recorded';
1120
+ $('#view-mode').textContent = embedded ? 'Exported snapshot' : 'Local workspace';
1121
+ $('#read-mode').textContent = previewChanged
1122
+ ? 'Added to this preview · save to keep'
1123
+ : embedded
1124
+ ? 'Snapshot · visuals can be attached'
1125
+ : 'Visuals save to the workspace';
1126
+ $('#save-preview').hidden = !previewChanged;
1127
+ document.title =
1128
+ (state.kind === 'changes'
1129
+ ? 'Changes'
1130
+ : state.kind === 'api'
1131
+ ? cap.operations.find((o) => o.operationId === state.id)?.path
1132
+ : state.kind === 'types'
1133
+ ? (entityIndex.caps.get(cap.id).get(state.id)?.name ?? 'Type not found')
1134
+ : (cap.title ?? cap.id)) + ' · MHProto';
1135
+ if (state.rule && !query) {
1136
+ const target = document.getElementById(state.rule);
1137
+ for (let parent = target?.parentElement; parent; parent = parent.parentElement)
1138
+ if (parent.tagName === 'DETAILS') parent.open = true;
1139
+ target?.scrollIntoView?.({ block: 'start' });
1140
+ }
1141
+ diagramQueue = diagramQueue.catch(() => {}).then(() => renderDiagrams(version));
1142
+ globalThis.mhprotoReady = diagramQueue;
1143
+ return diagramQueue;
1144
+ }
1145
+ window.addEventListener('hashchange', () => {
1146
+ query = '';
1147
+ $('#search').value = '';
1148
+ window.scrollTo?.({ top: 0 });
1149
+ render();
1150
+ });
1151
+ document.addEventListener(
1152
+ 'toggle',
1153
+ (event) => {
1154
+ if (event.target.open && event.target.querySelector('[data-mermaid]:not([data-rendered])')) {
1155
+ diagramQueue = diagramQueue.catch(() => {}).then(() => renderDiagrams(generation));
1156
+ globalThis.mhprotoReady = diagramQueue;
1157
+ }
1158
+ },
1159
+ true,
1160
+ );
1161
+ $('#search').addEventListener('input', (event) => {
1162
+ query = event.target.value;
1163
+ render();
1164
+ });
1165
+ document.addEventListener('keydown', (event) => {
1166
+ if (event.key === '/' && !['INPUT', 'TEXTAREA'].includes(document.activeElement?.tagName)) {
1167
+ event.preventDefault();
1168
+ $('#search').focus();
1169
+ }
1170
+ });
1171
+ document.addEventListener('click', async (event) => {
1172
+ if (event.target.closest('[data-exit-comparison]')) {
1173
+ const [pathname, params] = location.hash.split('?'),
1174
+ search = new URLSearchParams(params);
1175
+ search.delete('compare');
1176
+ location.hash = pathname + (search.size ? '?' + search : '');
1177
+ return;
1178
+ }
1179
+ if (event.target.closest('[data-download-snapshot]')) {
1180
+ downloadFile(
1181
+ 'mhproto-snapshot.json',
1182
+ JSON.stringify(contractSnapshot(project, { label: project.name + ' snapshot' }), null, 2),
1183
+ 'application/json',
1184
+ );
1185
+ return;
1186
+ }
1187
+ const start = event.target.closest('[data-start-iteration]');
1188
+ if (start) {
1189
+ const task = async () => {
1190
+ start.disabled = true;
1191
+ comparisonError = '';
1192
+ try {
1193
+ if (embedded) {
1194
+ baseline = contractSnapshot(project);
1195
+ previewChanged = true;
1196
+ } else {
1197
+ const response = await fetch('/api/baseline', {
1198
+ method: 'POST',
1199
+ headers: { 'content-type': 'application/json' },
1200
+ body: '{}',
1201
+ });
1202
+ const value = await response.json();
1203
+ if (!response.ok) throw new Error(value.error ?? 'Could not save the baseline');
1204
+ baseline = value;
1205
+ }
1206
+ localBaseline = false;
1207
+ await render();
1208
+ } catch (error) {
1209
+ comparisonError = error.message;
1210
+ await render();
1211
+ }
1212
+ };
1213
+ globalThis.mhprotoComparisonReady = task();
1214
+ return;
1215
+ }
1216
+ const add = event.target.closest('[data-add-visual]');
1217
+ if (add) {
1218
+ const slot = add.closest('[data-visual-target]'),
1219
+ editor = slot.querySelector(':scope > .visual-editor');
1220
+ editor.hidden = false;
1221
+ add.hidden = true;
1222
+ editor.innerHTML = `<form class="visual-form"><label>Title<input name="title" required maxlength="200" placeholder="What this visual explains"></label><label>Screenshot or design file<input name="file" type="file" accept="image/png,image/jpeg,image/webp,image/gif,application/pdf"></label><p class="section-note">PNG, JPEG, WebP, GIF or PDF · up to 8 MB</p><label>Or design link<input name="url" type="url" placeholder="https://…"></label><label>Note <span class="section-note">optional</span><input name="caption" maxlength="1000" placeholder="State, version or source"></label><div class="visual-actions"><button class="text-button" type="submit">Attach</button><button class="text-button" type="button" data-cancel-visual>Cancel</button></div><p class="visual-message section-note" role="status"></p></form>`;
1223
+ editor.querySelector('[name=title]').focus();
1224
+ return;
1225
+ }
1226
+ const cancel = event.target.closest('[data-cancel-visual]');
1227
+ if (cancel) {
1228
+ const slot = cancel.closest('[data-visual-target]'),
1229
+ add = slot.querySelector(':scope > [data-attachment-zone] > [data-add-visual]');
1230
+ slot.querySelector(':scope > .visual-editor').hidden = true;
1231
+ add.hidden = false;
1232
+ add.focus();
1233
+ return;
1234
+ }
1235
+ const button = event.target.closest('[data-copy-url]');
1236
+ if (!button) return;
1237
+ try {
1238
+ await navigator.clipboard.writeText(location.href);
1239
+ button.textContent = 'Link copied';
1240
+ } catch {
1241
+ button.textContent = 'Copy the address from your browser';
1242
+ }
1243
+ });
1244
+ function downloadFile(name, content, type) {
1245
+ const link = document.createElement('a'),
1246
+ url = URL.createObjectURL(new Blob([content], { type }));
1247
+ link.href = url;
1248
+ link.download = name;
1249
+ link.click();
1250
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1251
+ }
1252
+ document.addEventListener('change', (event) => {
1253
+ if (event.target.id === 'changes-filter') {
1254
+ comparisonFilter = event.target.value;
1255
+ render();
1256
+ return;
1257
+ }
1258
+ if (event.target.id !== 'baseline-file') return;
1259
+ const file = event.target.files[0];
1260
+ if (!file) return;
1261
+ const task = async () => {
1262
+ try {
1263
+ if (file.size > 16 * 1024 * 1024)
1264
+ throw new Error('Choose a snapshot or preview up to 16 MB.');
1265
+ const text = await new Promise((resolve, reject) => {
1266
+ const reader = new FileReader();
1267
+ reader.onload = () => resolve(reader.result);
1268
+ reader.onerror = () => reject(new Error('Could not read the baseline'));
1269
+ reader.readAsText(file);
1270
+ });
1271
+ // Read only the marked JSON script. Never construct an HTML document from
1272
+ // imported previews: even inert HTML parsers may load remote resources.
1273
+ const match =
1274
+ /<script\b(?=[^>]*\s+id=["'](?:mhproto-model|bive-model)["'])[^>]*>([\s\S]*?)<\/script\s*>/i.exec(
1275
+ text,
1276
+ );
1277
+ const parsed = JSON.parse(text.trimStart().startsWith('<') ? (match?.[1] ?? 'null') : text);
1278
+ const next = contractSnapshot(parsed, {
1279
+ label: parsed?.label ?? file.name,
1280
+ createdAt: parsed?.createdAt ?? null,
1281
+ });
1282
+ buildEntityIndex(snapshotProject(next));
1283
+ baseline = next;
1284
+ localBaseline = true;
1285
+ comparisonError = '';
1286
+ if (embedded) previewChanged = true;
1287
+ await render();
1288
+ } catch (error) {
1289
+ comparisonError =
1290
+ error instanceof SyntaxError
1291
+ ? 'Could not read a MHProto baseline from this file.'
1292
+ : error.message;
1293
+ await render();
1294
+ }
1295
+ };
1296
+ globalThis.mhprotoComparisonReady = task();
1297
+ });
1298
+ const readData = (file) =>
1299
+ new Promise((resolve, reject) => {
1300
+ const reader = new FileReader();
1301
+ reader.onload = () => resolve(reader.result);
1302
+ reader.onerror = () => reject(new Error('Could not read this file'));
1303
+ reader.readAsDataURL(file);
1304
+ });
1305
+ function mediaValid(data) {
1306
+ const match = /^data:([^;]+);base64,(.+)$/.exec(data ?? '');
1307
+ if (!match) return false;
1308
+ const s = atob(match[2]);
1309
+ return (
1310
+ {
1311
+ 'image/png': () => s.startsWith('\x89PNG\r\n\x1a\n'),
1312
+ 'image/jpeg': () => s.startsWith('\xff\xd8\xff'),
1313
+ 'image/webp': () => s.startsWith('RIFF') && s.slice(8, 12) === 'WEBP',
1314
+ 'image/gif': () => /^GIF8[79]a/.test(s),
1315
+ 'application/pdf': () => s.startsWith('%PDF-'),
1316
+ }[match[1]]?.() ?? false
1317
+ );
1318
+ }
1319
+ document.addEventListener('submit', (event) => {
1320
+ if (!event.target.matches('.visual-form')) return;
1321
+ event.preventDefault();
1322
+ const form = event.target,
1323
+ slot = form.closest('[data-visual-target]');
1324
+ const task = async () => {
1325
+ const message = form.querySelector('.visual-message'),
1326
+ button = form.querySelector('[type=submit]');
1327
+ button.disabled = true;
1328
+ try {
1329
+ const file = form.elements.file.files[0],
1330
+ url = form.elements.url.value.trim();
1331
+ if (Boolean(file) === Boolean(url)) throw new Error('Choose one file or one design link.');
1332
+ if (url) {
1333
+ let parsed;
1334
+ try {
1335
+ parsed = new URL(url);
1336
+ } catch {}
1337
+ if (parsed?.protocol !== 'https:' || parsed.username || parsed.password)
1338
+ throw new Error('Use an HTTPS design link.');
1339
+ }
1340
+ if (file && (file.size === 0 || file.size > 8 * 1024 * 1024))
1341
+ throw new Error('Choose a file up to 8 MB.');
1342
+ const data = file ? await readData(file) : undefined;
1343
+ if (file && !mediaValid(data)) throw new Error('Use a valid PNG, JPEG, WebP, GIF or PDF.');
1344
+ const input = {
1345
+ target: JSON.parse(slot.dataset.visualTarget),
1346
+ title: form.elements.title.value.trim(),
1347
+ caption: form.elements.caption.value.trim(),
1348
+ ...(url ? { url } : { data }),
1349
+ };
1350
+ if (!input.title || input.title.length > 200) throw new Error('Give the visual a title.');
1351
+ if (embedded) {
1352
+ const id =
1353
+ globalThis.crypto?.randomUUID?.() ??
1354
+ 'visual-' + Date.now() + '-' + Math.random().toString(36).slice(2);
1355
+ const mime = file?.type,
1356
+ ext = {
1357
+ 'image/png': 'png',
1358
+ 'image/jpeg': 'jpg',
1359
+ 'image/webp': 'webp',
1360
+ 'image/gif': 'gif',
1361
+ 'application/pdf': 'pdf',
1362
+ }[mime];
1363
+ const visual = {
1364
+ id,
1365
+ title: input.title,
1366
+ caption: input.caption,
1367
+ target: input.target,
1368
+ kind: url || mime === 'application/pdf' ? 'design' : 'screenshot',
1369
+ ...(url ? { url } : { file: 'preview/assets/' + id + '.' + ext, mime }),
1370
+ };
1371
+ (project.visuals ??= []).push(visual);
1372
+ if (data) media[id] = data;
1373
+ previewChanged = true;
1374
+ await render();
1375
+ } else {
1376
+ const response = await fetch('/api/visuals', {
1377
+ method: 'POST',
1378
+ headers: { 'content-type': 'application/json' },
1379
+ body: JSON.stringify(input),
1380
+ });
1381
+ const result = await response.json();
1382
+ if (!response.ok) throw new Error(result.error ?? 'Could not attach the visual');
1383
+ await refresh(true);
1384
+ }
1385
+ } catch (error) {
1386
+ message.textContent = error.message;
1387
+ message.classList.add('error');
1388
+ button.disabled = false;
1389
+ }
1390
+ };
1391
+ globalThis.mhprotoAttachmentReady = task();
1392
+ });
1393
+ $('#save-preview').addEventListener('click', () => {
1394
+ const copy = document.documentElement.cloneNode(true),
1395
+ safe = (value) => JSON.stringify(value).replaceAll('<', '\\u003c');
1396
+ copy.querySelector('#mhproto-model').textContent = safe(project);
1397
+ let registry = copy.querySelector('#mhproto-media');
1398
+ if (!registry) {
1399
+ registry = document.createElement('script');
1400
+ registry.id = 'mhproto-media';
1401
+ registry.type = 'application/json';
1402
+ copy.querySelector('#mhproto-model').after(registry);
1403
+ }
1404
+ registry.textContent = safe(media);
1405
+ copy.querySelector('#content').textContent = 'Loading…';
1406
+ copy.querySelector('#features').textContent = '';
1407
+ copy.querySelector('#save-preview').hidden = true;
1408
+ let baselineNode = copy.querySelector('#mhproto-baseline');
1409
+ if (!baselineNode) {
1410
+ baselineNode = document.createElement('script');
1411
+ baselineNode.id = 'mhproto-baseline';
1412
+ baselineNode.type = 'application/json';
1413
+ copy.querySelector('#mhproto-model').after(baselineNode);
1414
+ }
1415
+ baselineNode.textContent = safe(baseline);
1416
+ const link = document.createElement('a'),
1417
+ url = URL.createObjectURL(
1418
+ new Blob(['<!doctype html>\n' + copy.outerHTML], { type: 'text/html' }),
1419
+ );
1420
+ link.href = url;
1421
+ link.download = 'mhproto-preview.html';
1422
+ link.click();
1423
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
1424
+ });
1425
+ async function refresh(initial = false) {
1426
+ try {
1427
+ if (embedded) {
1428
+ project = embedded;
1429
+ await render();
1430
+ return;
1431
+ }
1432
+ let response = await fetch('/api/model');
1433
+ if (!response.ok) response = await fetch('./model.json');
1434
+ if (!response.ok) throw new Error('Could not load the project');
1435
+ const next = await response.json();
1436
+ if (next.error) throw new Error(next.error);
1437
+ let nextBaseline = baseline;
1438
+ if (!localBaseline) {
1439
+ const r = await fetch('/api/baseline');
1440
+ if (r.ok) {
1441
+ nextBaseline = await r.json();
1442
+ baselineReadOnly = r.headers.get('x-mhproto-baseline-readonly') === 'true';
1443
+ } else if (r.status === 404) {
1444
+ const fallback = await fetch('./baseline.json');
1445
+ if (fallback.ok) nextBaseline = await fallback.json();
1446
+ else if (initial) nextBaseline = null;
1447
+ } else throw new Error('Could not read the comparison baseline');
1448
+ }
1449
+ if (nextBaseline) snapshotProject(nextBaseline);
1450
+ const nextSignature = JSON.stringify([next, nextBaseline]);
1451
+ if (initial || nextSignature !== signature) {
1452
+ project = next;
1453
+ baseline = nextBaseline;
1454
+ signature = nextSignature;
1455
+ await render();
1456
+ }
1457
+ } catch (error) {
1458
+ if (initial) $('#content').innerHTML = `<p class="error">${escape(error.message)}</p>`;
1459
+ else $('#freshness').textContent = 'Source read failed';
1460
+ }
1461
+ }
1462
+ await refresh(true);
1463
+ if (!embedded) setInterval(() => refresh(), 4000);