Skill Tracer
Skills › Archify › archify › architecture-delta.mjs

architecture-delta.mjs

1318 lines · archify/delta/architecture-delta.mjs at a406594

1import { softwareNoticesHtml } from '../renderers/shared/software-notices.mjs';
2import { parseRepositoryRemote } from '../renderers/shared/repository-location.mjs';
3
4const COMPARATOR_VERSION = 1;
5const CANONICAL_VERSION = 1;
6
7export class ArchitectureDeltaError extends Error {
8 constructor(code, message, details = {}) {
9 super(message);
10 this.name = 'ArchitectureDeltaError';
11 this.code = code;
12 this.details = details;
13 }
16const codepointOrder = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
17const sorted = (values) => [...values].sort((left, right) => codepointOrder(String(left), String(right)));
19function canonical(value) {
20 if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
21 if (value && typeof value === 'object') {
22 return `{${Object.keys(value).sort(codepointOrder).map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
23 }
24 return JSON.stringify(value);
27const equal = (left, right) => canonical(left) === canonical(right);
29function sortedObjects(values) {
30 return [...values].sort((left, right) => codepointOrder(canonical(left), canonical(right)));
33function sortedBy(values, keyFor) {
34 return [...values].sort((left, right) => codepointOrder(String(keyFor(left)), String(keyFor(right))));
37function normalizeRepository(repository) {
38 if (!repository) return undefined;
39 const location = parseRepositoryRemote(repository.url, { authored: true, provider: repository.provider });
40 const url = location?.url || String(repository.url || '');
41 return {
42 url: location?.provider === 'github' || location?.provider === 'gitlab' ? url.toLowerCase() : url,
43 revision: String(repository.revision || '').toLowerCase(),
44 ...(repository.provider !== undefined ? { provider: repository.provider } : {}),
45 ...(repository.link_mode !== undefined ? { link_mode: repository.link_mode } : {}),
46 };
49const PROVENANCE_FIELDS = ['url', 'revision', 'provider', 'link_mode'];
51function provenanceSide(repository) {
52 if (!repository) return null;
53 return {
54 revision: repository.revision,
55 ...(repository.provider !== undefined ? { provider: repository.provider } : {}),
56 ...(repository.link_mode !== undefined ? { linkMode: repository.link_mode } : {}),
57 };
60function provenanceChange(base, head) {
61 if (equal(base, head)) return undefined;
62 const changedFields = !base || !head
63 ? ['/repository']
64 : PROVENANCE_FIELDS
65 .filter((field) => !equal(base[field], head[field]))
66 .map((field) => `/${field}`)
67 .sort(codepointOrder);
68 return {
69 changedFields,
70 base: provenanceSide(base),
71 head: provenanceSide(head),
72 };
75function normalizeComponent(component) {
76 return {
77 ...component,
78 ...(Array.isArray(component.sources) ? { sources: sortedObjects(component.sources) } : {}),
79 };
82function normalizeBoundary(boundary) {
83 return { ...boundary, wraps: sorted(boundary.wraps || []) };
86export function canonicalArchitecture(diagram) {
87 const meta = { ...(diagram.meta || {}) };
88 delete meta.output;
89 if (meta.repository) meta.repository = normalizeRepository(meta.repository);
90 return {
91 schema_version: diagram.schema_version,
92 diagram_type: diagram.diagram_type,
93 meta,
94 ...(diagram.layout ? { layout: diagram.layout } : {}),
95 components: sortedBy((diagram.components || []).map(normalizeComponent), (component) => component.id),
96 boundaries: sortedBy((diagram.boundaries || []).map(normalizeBoundary), boundaryKey),
97 connections: sortedBy(diagram.connections || [], (connection) => connection.id || ''),
98 ...(diagram.cards ? { cards: diagram.cards } : {}),
99 };
102export function canonicalArchitectureJson(diagram) {
103 return canonical(canonicalArchitecture(diagram));
106function fail(code, message, details) {
107 throw new ArchitectureDeltaError(code, message, details);
110function requireComparableShape(diagram, side) {
111 if (diagram?.schema_version !== 1) {
112 fail('delta/schema-version-mismatch', `${side} must use schema_version 1.`, { side, path: '/schema_version', actual: diagram?.schema_version });
113 }
114 if (diagram?.diagram_type !== 'architecture') {
115 fail('delta/type-mismatch', `${side} must use diagram_type architecture.`, { side, path: '/diagram_type', actual: diagram?.diagram_type });
116 }
119function stableIndex(items, collection, side, missingCode = 'delta/stable-id-required') {
120 const index = new Map();
121 const missing = [];
122 const duplicates = [];
123 (items || []).forEach((item, itemIndex) => {
124 if (!item?.id) missing.push(`/${collection}/${itemIndex}/id`);
125 else if (index.has(item.id)) duplicates.push(item.id);
126 else index.set(item.id, item);
127 });
128 if (missing.length) {
129 fail(missingCode, `${side} ${collection} require authored stable ids for comparison.`, {
130 side,
131 paths: sorted(missing),
132 supportedFixes: [`add a unique id to every ${collection} item`],
133 });
134 }
135 if (duplicates.length) {
136 fail('delta/duplicate-stable-id', `${side} ${collection} contain duplicate ids.`, {
137 side,
138 collection,
139 ids: sorted(new Set(duplicates)),
140 supportedFixes: [`make every ${collection} id unique`],
141 });
142 }
143 return index;
146const boundaryKey = (boundary) => `${boundary.kind}\u001f${boundary.label}`;
148function boundaryIndex(boundaries, side) {
149 const index = new Map();
150 const ambiguous = [];
151 for (const boundary of boundaries || []) {
152 const key = boundaryKey(boundary);
153 if (index.has(key)) ambiguous.push(`${boundary.kind}:${boundary.label}`);
154 else index.set(key, boundary);
155 }
156 if (ambiguous.length) {
157 fail('delta/boundary-key-ambiguous', `${side} boundary kind + label keys must be unique.`, {
158 side,
159 boundaries: sorted(new Set(ambiguous)),
160 supportedFixes: ['rename one duplicate boundary or add stable boundary ids in a future schema version'],
161 });
162 }
163 return index;
166function normalizedField(item, field) {
167 const value = item?.[field];
168 if (field === 'sources' && Array.isArray(value)) return sortedObjects(value);
169 if (field === 'wraps' && Array.isArray(value)) return sorted(value);
170 return value;
173function fieldChanges(before, after, groups) {
174 const classifications = [];
175 const changedFields = [];
176 for (const [classification, fields] of Object.entries(groups)) {
177 const changed = fields.filter((field) => !equal(normalizedField(before, field), normalizedField(after, field)));
178 if (changed.length) classifications.push(classification);
179 changedFields.push(...changed.map((field) => `/${field}`));
180 }
181 return { classifications: sorted(classifications), changedFields: sorted(changedFields) };
184const COMPONENT_FIELDS = {
185 semantic: ['type', 'label', 'sublabel', 'tag', 'brand', 'icon'],
186 evidence: ['sources'],
187 geometry: ['row', 'col', 'pos', 'size'],
188};
189const CONNECTION_FIELDS = {
190 topology: ['from', 'to'],
191 semantic: ['label', 'variant'],
192 geometry: ['fromSide', 'toSide', 'route', 'via', 'labelAt', 'labelDx', 'labelDy', 'labelSegment', 'width'],
193};
194const BOUNDARY_FIELDS = { scope: ['wraps'], geometry: ['pad'] };
196function statusFor(classifications, kind) {
197 if (classifications.some((value) => ['topology', 'semantic', 'scope'].includes(value))) return 'changed';
198 if (classifications.includes('evidence')) return 'evidence-changed';
199 if (classifications.includes('geometry')) return kind === 'connection' ? 'rerouted' : kind === 'component' ? 'moved' : 'geometry-changed';
200 return 'same';
203function compareEntities(baseIndex, headIndex, kind, groups, describe) {
204 const changes = [];
205 const identityClassification = kind === 'connection' ? 'topology' : kind === 'boundary' ? 'scope' : 'semantic';
206 for (const id of sorted(new Set([...baseIndex.keys(), ...headIndex.keys()]))) {
207 const base = baseIndex.get(id);
208 const head = headIndex.get(id);
209 if (!base) changes.push({ ...describe(id, undefined, head), status: 'added', classifications: [identityClassification], changedFields: [] });
210 else if (!head) changes.push({ ...describe(id, base, undefined), status: 'removed', classifications: [identityClassification], changedFields: [] });
211 else {
212 const fields = fieldChanges(base, head, groups);
213 const status = statusFor(fields.classifications, kind);
214 if (status !== 'same') changes.push({ ...describe(id, base, head), status, ...fields });
215 }
216 }
217 return changes;
220function summaryFor(changes, shape) {
221 const summary = Object.fromEntries(shape.map((key) => [key, 0]));
222 for (const change of changes) {
223 const key = change.status.replace(/-([a-z])/g, (_all, letter) => letter.toUpperCase());
224 if (Object.hasOwn(summary, key)) summary[key] += 1;
225 }
226 return summary;
229function presentationChanged(base, head) {
230 const basePresentation = {
231 title: base.meta?.title,
232 locale: base.meta?.locale,
233 subtitle: base.meta?.subtitle,
234 animation: base.meta?.animation,
235 visual_preset: base.meta?.visual_preset,
236 quality_profile: base.meta?.quality_profile,
237 engineering_profile: base.meta?.engineering_profile,
238 legend: base.meta?.legend,
239 viewBox: base.meta?.viewBox,
240 layout: base.layout,
241 cards: base.cards,
242 };
243 const headPresentation = {
244 title: head.meta?.title,
245 locale: head.meta?.locale,
246 subtitle: head.meta?.subtitle,
247 animation: head.meta?.animation,
248 visual_preset: head.meta?.visual_preset,
249 quality_profile: head.meta?.quality_profile,
250 engineering_profile: head.meta?.engineering_profile,
251 legend: head.meta?.legend,
252 viewBox: head.meta?.viewBox,
253 layout: head.layout,
254 cards: head.cards,
255 };
256 return !equal(basePresentation, headPresentation);
259export function compareArchitecture(base, head, evidence = {}) {
260 requireComparableShape(base, 'base');
261 requireComparableShape(head, 'head');
262 const baseComponents = stableIndex(base.components, 'components', 'base');
263 const headComponents = stableIndex(head.components, 'components', 'head');
264 const shared = sorted([...baseComponents.keys()].filter((id) => headComponents.has(id)));
265 if (!shared.length) {
266 fail('delta/no-shared-component-id', 'The snapshots share no component id, so Archify cannot prove that they describe the same system.', {
267 supportedFixes: ['preserve at least one authored component id across snapshots'],
268 });
269 }
271 const baseConnections = stableIndex(base.connections, 'connections', 'base', 'delta/relationship-id-required');
272 const headConnections = stableIndex(head.connections, 'connections', 'head', 'delta/relationship-id-required');
273 const baseBoundaries = boundaryIndex(base.boundaries, 'base');
274 const headBoundaries = boundaryIndex(head.boundaries, 'head');
276 const baseRepository = normalizeRepository(base.meta?.repository);
277 const headRepository = normalizeRepository(head.meta?.repository);
278 const identity = (repository) => parseRepositoryRemote(repository.url, { authored: true, provider: repository.provider })?.identity || repository.url;
279 if (baseRepository && headRepository && identity(baseRepository) !== identity(headRepository)) {
280 fail('delta/repository-mismatch', 'The snapshots name different repositories.', {
281 baseRepository: baseRepository.url,
282 headRepository: headRepository.url,
283 supportedFixes: ['compare snapshots from the same repository or remove repository evidence from both inputs'],
284 });
285 }
286 const proofLevel = baseRepository && headRepository
287 && evidence.baseVerified && evidence.headVerified
288 && /^[a-f0-9]{40}$/.test(baseRepository.revision)
289 && /^[a-f0-9]{40}$/.test(headRepository.revision)
290 ? 'revision-pinned'
291 : 'authored';
293 const components = compareEntities(baseComponents, headComponents, 'component', COMPONENT_FIELDS, (id, before, after) => ({
294 id,
295 baseLabel: before?.label,
296 headLabel: after?.label,
297 }));
298 const connections = compareEntities(baseConnections, headConnections, 'connection', CONNECTION_FIELDS, (id, before, after) => ({
299 id,
300 ...(before ? { base: { from: before.from, to: before.to, label: before.label || '' } } : {}),
301 ...(after ? { head: { from: after.from, to: after.to, label: after.label || '' } } : {}),
302 }));
303 const boundaries = compareEntities(baseBoundaries, headBoundaries, 'boundary', BOUNDARY_FIELDS, (_key, before, after) => ({
304 key: `${(after || before).kind}:${(after || before).label}`,
305 kind: (after || before).kind,
306 label: (after || before).label,
307 }));
308 const provenance = provenanceChange(baseRepository, headRepository);
309 const provenanceChanged = Boolean(provenance);
311 return {
312 schemaVersion: 1,
313 ok: true,
314 command: 'compare',
315 type: 'architecture',
316 comparatorVersion: COMPARATOR_VERSION,
317 canonicalVersion: CANONICAL_VERSION,
318 completeness: 'complete',
319 proofLevel,
320 base: {
321 title: base.meta?.title || '',
322 ...(evidence.baseRawSha256 ? { rawSha256: evidence.baseRawSha256 } : {}),
323 ...(evidence.baseSemanticSha256 ? { semanticSha256: evidence.baseSemanticSha256 } : {}),
324 ...(Number.isInteger(evidence.baseBytes) ? { bytes: evidence.baseBytes } : {}),
325 ...(baseRepository?.revision ? { revision: baseRepository.revision } : {}),
326 },
327 head: {
328 title: head.meta?.title || '',
329 ...(evidence.headRawSha256 ? { rawSha256: evidence.headRawSha256 } : {}),
330 ...(evidence.headSemanticSha256 ? { semanticSha256: evidence.headSemanticSha256 } : {}),
331 ...(Number.isInteger(evidence.headBytes) ? { bytes: evidence.headBytes } : {}),
332 ...(headRepository?.revision ? { revision: headRepository.revision } : {}),
333 },
334 summary: {
335 components: summaryFor(components, ['added', 'changed', 'evidenceChanged', 'removed', 'moved']),
336 connections: summaryFor(connections, ['added', 'changed', 'removed', 'rerouted']),
337 boundaries: summaryFor(boundaries, ['added', 'changed', 'removed', 'geometryChanged']),
338 presentationChanged: presentationChanged(base, head),
339 provenanceChanged,
340 },
341 changes: { components, connections, boundaries },
342 ...(provenance ? { provenance } : {}),
343 identity: {
344 components: 'components[].id',
345 connections: 'connections[].id (required)',
346 boundaries: 'boundaries[].kind + boundaries[].label (derived)',
347 },
348 view: { visualPreset: head.meta?.visual_preset || 'classic' },
349 limitations: [
350 'Authored Architecture IR only; no runtime impact, causality, risk, or mergeability is inferred.',
351 'Boundary identity is conservatively derived from kind + label.',
352 ],
353 };
356function esc(value) {
357 return String(value ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
360const safeJson = (value) => JSON.stringify(value, null, 2).replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026');
362export function extractArchitectureSvg(html) {
363 const match = html.match(/<svg viewBox="0 0 [^"]+" role="img"[\s\S]*?<\/svg>/);
364 if (!match) fail('delta/svg-missing', 'A validated Architecture artifact did not contain its primary SVG.');
365 return match[0];
368export function extractArtifactCss(html) {
369 const match = html.match(/<style>([\s\S]*?)<\/style>/);
370 if (!match) fail('delta/css-missing', 'A validated Architecture artifact did not contain its stylesheet.');
371 return match[1];
374function changeMap(changes) {
375 return new Map(changes.map((change) => [change.id, change]));
378function boundaryChangeMap(changes) {
379 return new Map(changes.map((change) => [`${change.kind}:${esc(change.label)}`, change]));
382function addState(tag, change, side, forcedState) {
383 const append = (attributes) => tag.endsWith('/>')
384 ? tag.replace(/\/>$/, `${attributes}/>`)
385 : tag.replace(/>$/, `${attributes}>`);
386 if (!change && !forcedState) return append(' data-delta-state="same"');
387 let state = forcedState || change.status;
388 if (change?.status === 'added' && side === 'base') state = 'same';
389 if (change?.status === 'removed' && side === 'head') state = 'same';
390 const classes = change?.classifications?.join(',') || '';
391 return append(` data-delta-state="${esc(state)}"${classes ? ` data-delta-classifications="${esc(classes)}"` : ''}`);
394function markerFor(state) {
395 return ({ added: '+', removed: '−', changed: '~', moved: '↔', 'moved-from': '↔', rerouted: '↔', 'geometry-changed': '↔', 'evidence-changed': 'E' })[state] || '';
398function addNodeMarker(group, state) {
399 const symbol = markerFor(state);
400 if (!symbol) return group;
401 const box = group.match(/<rect x="([^"]+)" y="([^"]+)" width="([^"]+)"/);
402 if (!box) return group;
403 const x = Number(box[1]) + Number(box[3]) - 9;
404 const y = Number(box[2]) + 9;
405 return group.replace(/<\/g>$/, `\n <g class="delta-node-marker" aria-hidden="true"><circle cx="${x}" cy="${y}" r="8"/><text x="${x}" y="${y + 3}" text-anchor="middle">${symbol}</text></g>\n </g>`);
408function prefixSvgIds(svg, prefix) {
409 const ids = [...svg.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
410 let result = svg.replace(/(\s)id="([^"]+)"/g, (_match, space, id) => `${space}id="${prefix}-${id}"`);
411 for (const id of ids) {
412 result = result.replaceAll(`url(#${id})`, `url(#${prefix}-${id})`).replaceAll(`href="#${id}"`, `href="#${prefix}-${id}"`);
413 }
414 result = result.replace(/aria-labelledby="([^"]+)"/g, (_match, value) => `aria-labelledby="${value.split(/\s+/).map((id) => `${prefix}-${id}`).join(' ')}"`);
415 return result;
418function staticize(svg) {
419 return svg.replaceAll('tabindex="0" role="button"', 'role="group"').replaceAll(' aria-pressed="false"', '').replaceAll('aria-label="Focus ', 'aria-label="');
422function nodeGroupRanges(svg) {
423 const ranges = [];
424 const opener = /<g\s+[^>]*\bdata-node-id="([^"]+)"[^>]*>/g;
425 let open;
426 while ((open = opener.exec(svg))) {
427 const tags = /<\/?g\b[^>]*>/g;
428 tags.lastIndex = open.index;
429 let depth = 0;
430 let tag;
431 while ((tag = tags.exec(svg))) {
432 depth += tag[0].startsWith('</') ? -1 : 1;
433 if (depth === 0) {
434 ranges.push({ id: open[1], start: open.index, end: tags.lastIndex });
435 opener.lastIndex = tags.lastIndex;
436 break;
437 }
438 }
439 }
440 return ranges;
443function transformNodeGroups(svg, transform) {
444 const ranges = nodeGroupRanges(svg);
445 let cursor = 0;
446 const parts = [];
447 for (const range of ranges) {
448 parts.push(svg.slice(cursor, range.start));
449 parts.push(transform(svg.slice(range.start, range.end), range.id));
450 cursor = range.end;
451 }
452 parts.push(svg.slice(cursor));
453 return parts.join('');
456const BOUNDARY_FRAME_RE = /<rect data-graph-role="structural-frame"[^>]*data-composition-frame-kind="([^"]+)"[^>]*data-composition-frame-label="([^"]+)"[^>]*\/>/g;
457const BOUNDARY_LABEL_RE = /<g data-graph-role="structural-frame-label"[^>]*data-composition-frame-kind="([^"]+)"[^>]*data-composition-frame-label="([^"]+)"[^>]*>[\s\S]*?<\/g>/g;
459function boundaryElements(svg) {
460 const collect = (pattern, part) => [...svg.matchAll(pattern)].map((match) => ({
461 start: match.index,
462 end: match.index + match[0].length,
463 markup: match[0],
464 key: `${match[1]}:${match[2]}`,
465 part,
466 }));
467 return [
468 ...collect(BOUNDARY_FRAME_RE, 'frame'),
469 ...collect(BOUNDARY_LABEL_RE, 'label'),
470 ].sort((left, right) => left.start - right.start);
473function transformBoundaryElements(svg, transform) {
474 let cursor = 0;
475 const parts = [];
476 for (const element of boundaryElements(svg)) {
477 parts.push(svg.slice(cursor, element.start));
478 parts.push(transform(element.markup, element.key, element.part));
479 cursor = element.end;
480 }
481 parts.push(svg.slice(cursor));
482 return parts.join('');
485export function annotateArchitectureSideSvg(svg, receipt, side) {
486 const nodes = changeMap(receipt.changes.components);
487 const edges = changeMap(receipt.changes.connections);
488 const boundaries = boundaryChangeMap(receipt.changes.boundaries);
489 let result = transformBoundaryElements(svg, (markup, key, part) => {
490 const change = boundaries.get(key);
491 if (!change) return markup;
492 if (part === 'frame') {
493 return addState(markup, change, side)
494 .replace(/\/>$/, () => ` data-delta-boundary-key="${esc(change.key)}"/>`);
495 }
496 return markup.replace(
497 /<text[^>]*>/,
498 (tag) => tag.replace(/>$/, () => ` data-delta-state="${change.status}" data-delta-boundary-state="${change.status}" data-delta-boundary-key="${esc(change.key)}">`),
499 );
500 });
501 result = transformNodeGroups(result, (group, id) => {
502 const change = nodes.get(id);
503 if ((side === 'base' && change?.status === 'added') || (side === 'head' && change?.status === 'removed')) return group;
504 const tagged = group.replace(/^<g[^>]+>/, (tag) => addState(tag, change, side));
505 return addNodeMarker(tagged, change?.status);
506 });
507 result = result.replace(/<(?:path|g)\s+[^>]*\bdata-edge-id="([^"]+)"[^>]*>/g, (tag, id) => addState(tag, edges.get(id), side));
508 return prefixSvgIds(staticize(result), side);
511function elementById(svg, kind, id) {
512 const safe = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
513 if (kind === 'node') {
514 const range = nodeGroupRanges(svg).find((candidate) => candidate.id === id);
515 return range ? svg.slice(range.start, range.end) : '';
516 }
517 const path = svg.match(new RegExp(`<path\\s+[^>]*\\bdata-edge-id="${safe}"[^>]*/>`))?.[0] || '';
518 const label = svg.match(new RegExp(`<g\\s+[^>]*\\bdata-edge-id="${safe}"[^>]*>[\\s\\S]*?<\\/g>`))?.[0] || '';
519 return [path, label].filter(Boolean).join('\n');
522function forceElementState(markup, state, classifications = []) {
523 let result;
524 if (markup.includes('data-node-id=')) {
525 result = markup.replace(/^<g\s+[^>]*>/, (tag) => addState(tag, { classifications }, 'delta', state));
526 } else {
527 result = markup
528 .replace(/<path\s+[^>]*\bdata-edge-id="[^"]+"[^>]*\/>/g, (tag) => addState(tag, { classifications }, 'delta', state))
529 .replace(/<g\s+[^>]*\bdata-edge-id="[^"]+"[^>]*>/g, (tag) => addState(tag, { classifications }, 'delta', state));
530 }
531 result = result.replace(/\bid="node-/, 'id="base-node-');
532 if (markup.includes('data-node-id=')) result = addNodeMarker(result, state);
533 return result;
536function boundaryMarkupByKey(svg, key) {
537 return boundaryElements(svg)
538 .filter((element) => element.key === key)
539 .map((element) => element.markup)
540 .join('\n');
543function boundaryMarkupParts(markup) {
544 const parts = { frame: '', label: '' };
545 for (const element of boundaryElements(markup)) parts[element.part] = element.markup;
546 return parts;
549function forceBoundaryState(markup, state, key, classifications = []) {
550 return markup
551 .replace(/^<rect[^>]+\/>/, (tag) => addState(tag, { classifications }, 'delta', state).replace(/\/>$/, () => ` data-delta-boundary-key="${esc(key)}"/>`))
552 .replace(
553 /<rect data-graph-role="structural-frame-label-mask"[^>]*\/>/,
554 (tag) => addState(tag, { classifications }, 'delta', state)
555 .replace(/\/>$/, () => ` data-delta-boundary-state="${state}" data-delta-boundary-mask-key="${esc(key)}"/>`),
556 )
557 .replace(/<text[^>]*>/, (tag) => tag.replace(/>$/, () => ` data-delta-state="${state}" data-delta-boundary-state="${state}" data-delta-boundary-key="${esc(key)}">`));
560function viewBoxSize(svg) {
561 const match = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
562 return match ? [Number(match[1]), Number(match[2])] : [0, 0];
565function edgeSymbolMarkup(markup, state) {
566 const symbol = markerFor(state);
567 const point = markup.match(/data-composition-points="([\d.-]+),([\d.-]+)/);
568 const edgeId = markup.match(/\bdata-edge-id="([^"]+)"/)?.[1];
569 if (!symbol || !point) return '';
570 return `<text class="delta-edge-marker" data-delta-state="${state}"${edgeId ? ` data-edge-id="${esc(edgeId)}"` : ''} x="${Number(point[1]) + 9}" y="${Number(point[2]) - 7}" aria-hidden="true">${symbol}</text>`;
573function boundarySymbolMarkup(markup, state) {
574 const symbol = markerFor(state);
575 const frame = markup.match(/<rect[^>]*\bx="([\d.-]+)"\s+y="([\d.-]+)"\s+width="([\d.-]+)"/);
576 if (!symbol || !frame) return '';
577 const x = Number(frame[1]) + Number(frame[3]) - 12;
578 const y = Number(frame[2]) + 16;
579 return `<text class="delta-boundary-marker" data-delta-state="${state}" x="${x}" y="${y}" text-anchor="middle" aria-hidden="true">${symbol}</text>`;
582export function buildDeltaSvg(baseSvg, headSvg, receipt) {
583 const [baseW, baseH] = viewBoxSize(baseSvg);
584 const [headW, headH] = viewBoxSize(headSvg);
585 // Baseline paths and their definitions must travel together in a namespace
586 // distinct from the current snapshot before the final Delta prefix is added.
587 const baseRelationshipsSvg = prefixSvgIds(baseSvg, 'base');
588 const nodes = changeMap(receipt.changes.components);
589 const edges = changeMap(receipt.changes.connections);
590 const boundaries = boundaryChangeMap(receipt.changes.boundaries);
591 const baseNodePhantoms = [];
592 const baseEdgePhantoms = [];
593 const baseBoundaryFramePhantoms = [];
594 const baseBoundaryLabelPhantoms = [];
595 const edgeMarkers = [];
596 const boundaryMarkers = [];
598 for (const change of nodes.values()) {
599 if (change.status === 'removed') baseNodePhantoms.push(forceElementState(elementById(baseSvg, 'node', change.id), 'removed', change.classifications));
600 else if (change.classifications.includes('geometry')) baseNodePhantoms.push(forceElementState(elementById(baseSvg, 'node', change.id), 'moved-from', change.classifications));
601 }
602 for (const change of edges.values()) {
603 if (change.status === 'removed' || change.classifications.includes('topology')) {
604 const phantom = forceElementState(elementById(baseRelationshipsSvg, 'edge', change.id), 'removed', change.classifications);
605 baseEdgePhantoms.push(phantom);
606 edgeMarkers.push(edgeSymbolMarkup(phantom, 'removed'));
607 } else if (change.classifications.includes('geometry')) {
608 const phantom = forceElementState(elementById(baseRelationshipsSvg, 'edge', change.id), 'moved-from', change.classifications);
609 baseEdgePhantoms.push(phantom);
610 edgeMarkers.push(edgeSymbolMarkup(phantom, 'moved-from'));
611 }
612 }
613 for (const change of boundaries.values()) {
614 const renderedKey = `${change.kind}:${esc(change.label)}`;
615 if (change.status === 'removed') {
616 const phantom = forceBoundaryState(boundaryMarkupByKey(baseSvg, renderedKey), 'removed', change.key, change.classifications);
617 const parts = boundaryMarkupParts(phantom);
618 baseBoundaryFramePhantoms.push(parts.frame);
619 baseBoundaryLabelPhantoms.push(parts.label);
620 boundaryMarkers.push(boundarySymbolMarkup(phantom, 'removed'));
621 }
622 else if (change.status === 'changed' || change.status === 'geometry-changed') {
623 const phantom = forceBoundaryState(boundaryMarkupByKey(baseSvg, renderedKey), 'moved-from', change.key, change.classifications);
624 const parts = boundaryMarkupParts(phantom);
625 baseBoundaryFramePhantoms.push(parts.frame);
626 baseBoundaryLabelPhantoms.push(parts.label);
627 }
628 }
630 // Insert generated markup literally: authored labels can contain replacement
631 // tokens such as $$, $&, and $` that string replacements would interpret.
632 let delta = annotateArchitectureSideSvg(headSvg, receipt, 'head');
633 if (baseEdgePhantoms.length) {
634 const baseDefinitions = baseRelationshipsSvg.match(/<defs>([\s\S]*?)<\/defs>/)?.[1] || '';
635 delta = delta.replace('</defs>', () => `${baseDefinitions}</defs>`);
636 }
637 delta = delta.replace(/^<svg[^>]+>/, (tag) => tag.replace(/viewBox="[^"]+"/, `viewBox="0 0 ${Math.max(baseW, headW) + 24} ${Math.max(baseH, headH) + 24}"`));
638 delta = delta.replace(' <!-- Boundaries (behind everything) -->', () => ` <!-- Baseline boundary frame phantoms -->\n${baseBoundaryFramePhantoms.filter(Boolean).join('\n')}\n\n <!-- Boundaries (behind everything) -->`);
639 delta = delta.replace(' <!-- Connection paths (before components for correct z-order) -->', () => ` <!-- Baseline relationship phantoms -->\n${baseEdgePhantoms.join('\n')}\n\n <!-- Connection paths (before components for correct z-order) -->`);
640 delta = delta.replace(' <!-- Components -->', () => ` <!-- Baseline boundary label phantoms (below current components) -->\n${baseBoundaryLabelPhantoms.filter(Boolean).join('\n')}\n\n <!-- Baseline removed and move-from component phantoms -->\n${baseNodePhantoms.join('\n')}\n\n <!-- Components -->`);
642 for (const change of edges.values()) {
643 if (change.status === 'added' || change.status === 'changed' || change.status === 'rerouted') {
644 const current = elementById(delta, 'edge', change.id);
645 edgeMarkers.push(edgeSymbolMarkup(current, change.status === 'changed' && change.classifications.includes('topology') ? 'added' : change.status));
646 }
647 }
648 for (const change of boundaries.values()) {
649 if (!['added', 'changed', 'geometry-changed'].includes(change.status)) continue;
650 const renderedKey = `${change.kind}:${esc(change.label)}`;
651 boundaryMarkers.push(boundarySymbolMarkup(boundaryMarkupByKey(delta, renderedKey), change.status));
652 }
653 delta = delta.replace(' <!-- Legend -->', () => ` <!-- Delta relationship symbols -->\n${edgeMarkers.filter(Boolean).join('\n')}\n\n <!-- Delta boundary symbols -->\n${boundaryMarkers.filter(Boolean).join('\n')}\n\n <!-- Legend -->`);
654 return prefixSvgIds(staticize(delta), 'delta');
657export function architectureDeltaChangeRows(receipt) {
658 const rows = [];
659 for (const change of receipt.changes.components) rows.push({ ...change, kind: 'Component', kindKey: 'component', key: `component:${change.id}`, id: change.id });
660 for (const change of receipt.changes.connections) rows.push({ ...change, kind: 'Relationship', kindKey: 'relationship', key: `relationship:${change.id}`, id: change.id });
661 for (const change of receipt.changes.boundaries) rows.push({ ...change, kind: 'Boundary', kindKey: 'boundary', key: `boundary:${change.key}`, id: change.key });
662 return rows.sort((left, right) => codepointOrder(`${left.status}:${left.kind}:${left.id}`, `${right.status}:${right.kind}:${right.id}`));
665function reviewPrimaryStates(row) {
666 if (row.kindKey === 'component' && row.classifications.includes('geometry')) return [row.status, 'moved-from'];
667 if (row.kindKey === 'relationship' && row.status === 'changed' && row.classifications.includes('topology')) return ['changed', 'removed'];
668 if (row.kindKey === 'relationship' && row.classifications.includes('geometry')) return ['moved-from', row.status];
669 if (row.kindKey === 'boundary' && ['changed', 'geometry-changed'].includes(row.status)) return [row.status, 'moved-from'].sort();
670 return [row.status];
673function reviewIdentity(row) {
674 const attribute = row.kindKey === 'component' ? 'data-node-id' : row.kindKey === 'relationship' ? 'data-edge-id' : 'data-delta-boundary-key';
675 return { attribute, value: esc(row.id) };
678function reviewTargetTags(deltaMarkup, row) {
679 const { attribute, value } = reviewIdentity(row);
680 const safeValue = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
681 const identity = new RegExp(`\\b${attribute}="${safeValue}"`);
682 return [...deltaMarkup.matchAll(/<([a-z][\w:-]*)\s+[^>]*>/g)]
683 .map((match) => ({ name: match[1], tag: match[0] }))
684 .filter(({ tag }) => identity.test(tag));
687function primaryReviewTags(deltaMarkup, row) {
688 const tagName = row.kindKey === 'component' ? 'g' : row.kindKey === 'relationship' ? 'path' : 'rect';
689 return reviewTargetTags(deltaMarkup, row).filter(({ name }) => name === tagName).map(({ tag }) => tag);
692function reviewTargetSignature(tags) {
693 return tags.map(({ name, tag }) => {
694 const state = tag.match(/\bdata-delta-state="([^"]+)"/)?.[1] || '';
695 const classifications = tag.match(/\bdata-delta-classifications="([^"]*)"/)?.[1] || '';
696 return `${name}:${state}:${classifications}`;
697 }).sort(codepointOrder).join('|');
700function expectedReviewTargetSignature(row) {
701 const classifications = row.classifications.join(',');
702 const descriptors = [];
703 if (row.kindKey === 'component') {
704 for (const state of reviewPrimaryStates(row)) descriptors.push(`g:${state}:${classifications}`);
705 } else if (row.kindKey === 'boundary') {
706 for (const state of reviewPrimaryStates(row)) {
707 descriptors.push(`rect:${state}:${classifications}`, `text:${state}:`);
708 }
709 } else {
710 const forms = row.status === 'added'
711 ? [{ state: 'added', marker: 'added', label: row.head?.label }]
712 : row.status === 'removed'
713 ? [{ state: 'removed', marker: 'removed', label: row.base?.label }]
714 : row.classifications.includes('topology')
715 ? [
716 { state: 'removed', marker: 'removed', label: row.base?.label },
717 { state: 'changed', marker: 'added', label: row.head?.label },
718 ]
719 : row.classifications.includes('geometry')
720 ? [
721 { state: 'moved-from', marker: 'moved-from', label: row.base?.label },
722 { state: row.status, marker: row.status, label: row.head?.label },
723 ]
724 : [{ state: 'changed', marker: 'changed', label: row.head?.label }];
725 for (const form of forms) {
726 descriptors.push(`path:${form.state}:${classifications}`, `text:${form.marker}:`);
727 if (form.label) descriptors.push(`g:${form.state}:${classifications}`);
728 }
729 }
730 return descriptors.sort(codepointOrder).join('|');
733const total = (summary, key) => summary.components[key] + summary.connections[key] + summary.boundaries[key];
735function provenanceDetail(provenance) {
736 if (provenance === undefined) return 'Repository metadata changed; field details are unavailable in this receipt.';
737 const value = (side, field) => {
738 if (!side) return 'undeclared';
739 if (field === '/revision') return side.revision?.slice(0, 8) || 'none';
740 if (field === '/provider') return side.provider ?? 'automatic';
741 if (field === '/link_mode') return side.linkMode ?? 'default';
742 if (field === '/repository') return 'declared';
743 return 'changed';
744 };
745 return provenance.changedFields.map((field) => {
746 if (field === '/url') return '/url: repository location changed';
747 return `${field}: ${value(provenance.base, field)} → ${value(provenance.head, field)}`;
748 }).join(' · ');
751export function renderArchitectureDeltaHtml({ receipt, baseSvg, deltaSvg, headSvg, baseHtml = '', headHtml = '', artifactCss }) {
752 const rows = architectureDeltaChangeRows(receipt);
753 const changed = total(receipt.summary, 'changed');
754 const proof = receipt.proofLevel === 'revision-pinned' ? 'REVISION-PINNED INPUTS' : 'AUTHORED SNAPSHOTS';
755 const provenanceChanged = receipt.summary.provenanceChanged === true;
756 const provenanceFields = receipt.provenance?.changedFields?.join(', ') || 'repository metadata';
757 const provenanceDescription = provenanceChanged ? provenanceDetail(receipt.provenance) : '';
758 const provenanceNotice = provenanceChanged
759 ? `<aside class="provenance-change" data-provenance-changed="true" data-provenance-fields="${esc(provenanceFields)}"><strong>Repository provenance changed</strong><span>${esc(provenanceDescription)}</span></aside>`
760 : '';
761 const rowHtml = rows.length ? rows.map((row, index) => {
762 const label = row.headLabel || row.baseLabel || row.head?.label || row.base?.label || row.label || row.id;
763 const targetSignature = expectedReviewTargetSignature(row);
764 return `<li data-change-status="${esc(row.status)}"><button class="change-row" type="button" data-change-index="${index}" data-change-key="${esc(row.key)}" data-change-kind="${esc(row.kindKey)}" data-change-id="${esc(row.id)}" data-change-label="${esc(label)}" data-change-status="${esc(row.status)}" data-change-classifications="${esc(row.classifications.join(', '))}" data-change-target-signature="${esc(targetSignature)}"><span class="token">${esc(markerFor(row.status) || '~')}</span><span>${esc(row.kind)}</span><strong>${esc(label)}</strong><code>${esc(row.id)}</code><span>${esc(row.classifications.join(', '))}</span><span>${esc(row.changedFields.join(', ') || 'identity')}</span></button></li>`;
765 }).join('\n') : provenanceChanged
766 ? '<li class="empty">No component, relationship, or boundary changes; repository provenance changed.</li>'
767 : '<li class="empty">No authored architecture changes.</li>';
768 const baseView = baseHtml
769 ? `<iframe class="snapshot-frame" title="Before architecture explorer" srcdoc="${esc(baseHtml)}"></iframe>`
770 : baseSvg;
771 const headView = headHtml
772 ? `<iframe class="snapshot-frame" title="After architecture explorer" srcdoc="${esc(headHtml)}"></iframe>`
773 : headSvg;
774 const html = `<!doctype html>
775<html lang="en" data-theme="dark" data-preset="${esc(receipt.view.visualPreset)}">
776<head><meta charset="utf-8">${softwareNoticesHtml}<meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(receipt.head.title)} Architecture Delta</title>
777<style>
778${artifactCss}
779:root{color-scheme:dark;--d-add:#34d399;--d-remove:#fb7185;--d-change:#fbbf24;--d-move:#7dd3fc;--d-focus:#7dd3fc;--d-ink:#e6edf5;--d-muted:#8aa0b5;--d-line:#25384a}
780*{box-sizing:border-box}body{margin:0;overflow-x:hidden;background:#071019;color:var(--d-ink);font-family:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace}.proof-page{width:min(1600px,calc(100vw - 64px));margin:auto;padding:30px 0 42px}.proof-head{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:28px;align-items:end;padding-bottom:20px;border-bottom:1px solid var(--d-line)}.eyebrow{margin:0 0 8px;color:#7dd3fc;font:700 11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.14em}.proof-head h1{margin:0;font-size:clamp(32px,4vw,56px);line-height:.96;letter-spacing:-.04em}.subtitle{margin:12px 0 0;color:var(--d-muted);font-size:14px}.metrics{display:flex;gap:9px}.metric{min-width:86px;padding:11px 13px;border:1px solid var(--d-line);border-radius:8px;background:#0b1722}.metric strong{display:block;font:700 23px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.metric span{display:block;margin-top:6px;color:var(--d-muted);font:700 9px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.1em}.add strong{color:var(--d-add)}.remove strong{color:var(--d-remove)}.change strong{color:var(--d-change)}
781.proof-tools{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:16px 0 10px}.view-switch{display:inline-flex;padding:3px;border:1px solid var(--d-line);border-radius:8px;background:#0a141e}.view-switch button,.utility,.review-step{border:0;border-radius:6px;background:transparent;color:var(--d-muted);padding:8px 14px;font:700 11px/1 ui-monospace,SFMono-Regular,Menlo,monospace;cursor:pointer}.view-switch button[aria-selected="true"]{background:#173047;color:#fff}.utility{border:1px solid var(--d-line)}.view-switch button:focus-visible,.utility:focus-visible,.review-step:focus-visible,.change-row:focus-visible{outline:2px solid var(--d-focus);outline-offset:2px}.utility:disabled,.review-step:disabled{cursor:not-allowed;opacity:.45}.legend{display:flex;gap:16px;color:var(--d-muted);font:650 10px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.legend span{display:inline-flex;align-items:center;gap:6px}.legend i{width:22px;border-top:3px solid currentColor}.legend .add{color:var(--d-add)}.legend .remove{color:var(--d-remove)}.legend .remove i{border-top-style:dashed}.legend .change{color:var(--d-change)}.legend .change i{border-top-style:dotted}.legend .move{color:var(--d-move)}.legend .move i{border-top-style:double}
782.provenance-change{display:flex;align-items:center;gap:12px;margin:14px 0 0;padding:10px 13px;border:1px solid rgba(125,211,252,.45);border-radius:8px;background:rgba(125,211,252,.07);color:var(--d-muted);font:650 11px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace}.provenance-change strong{color:var(--d-focus)}.provenance-change span{margin-left:auto;color:var(--d-muted)}
783.review-strip{display:grid;grid-template-columns:auto auto auto auto minmax(0,1fr);align-items:center;gap:5px;margin:0 0 10px;padding:7px 8px;border-block:1px solid var(--d-line);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}.review-step{min-height:34px;border:1px solid var(--d-line);padding-inline:11px}.review-step[aria-pressed="true"]{border-color:var(--d-focus);color:var(--d-ink)}.review-status{min-width:0;padding-left:9px;color:var(--d-muted);font-size:10px;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.review-status strong{color:var(--d-ink);font-weight:750}.review-status[data-state="unavailable"]{color:var(--d-remove)}
784.canvas{overflow:hidden;border:1px solid var(--d-line);border-radius:10px;background:#09141e;padding:12px;min-height:520px}.canvas svg{display:block;width:100%;height:auto;max-height:72vh}.canvas[hidden]{display:none}.snapshot-frame{display:block;width:100%;height:min(76vh,920px);min-height:620px;border:0;border-radius:6px;background:#071019}.canvas[data-view="base"],.canvas[data-view="head"]{padding:0}.canvas[data-view="delta"] [data-delta-state="same"]{opacity:.38}.canvas[data-delta-review-active]{--review-same-opacity:.14;--review-change-opacity:.28}.canvas[data-delta-review-active] [data-delta-state="same"]{opacity:var(--review-same-opacity)!important}.canvas[data-delta-review-active] [data-delta-state]:not([data-delta-state="same"]):not([data-delta-review-current]){opacity:var(--review-change-opacity)!important}.canvas[data-delta-review-active] [data-delta-review-current]{opacity:1!important;transition:opacity .16s ease-out}g[data-node-id][data-delta-state="added"]>rect:last-of-type{stroke:var(--d-add)!important;stroke-width:3!important}g[data-node-id][data-delta-state="removed"]>rect:last-of-type{stroke:var(--d-remove)!important;stroke-width:3!important;stroke-dasharray:7 5}g[data-node-id][data-delta-state="changed"]>rect:last-of-type{stroke:var(--d-change)!important;stroke-width:3!important;stroke-dasharray:2 3}g[data-node-id][data-delta-state="moved"]>rect:last-of-type,g[data-node-id][data-delta-state="moved-from"]>rect:last-of-type{stroke:var(--d-move)!important;stroke-width:3!important;stroke-dasharray:8 3 2 3}g[data-node-id][data-delta-state="moved-from"],path[data-delta-state="moved-from"]{opacity:.42}path[data-delta-state="added"]{stroke:var(--d-add)!important;stroke-width:3!important}path[data-delta-state="removed"]{stroke:var(--d-remove)!important;stroke-width:3!important;stroke-dasharray:7 5!important}path[data-delta-state="changed"]{stroke:var(--d-change)!important;stroke-width:3!important;stroke-dasharray:2 3!important}path[data-delta-state="rerouted"],path[data-delta-state="moved-from"]{stroke:var(--d-move)!important;stroke-width:2.5!important;stroke-dasharray:8 3 2 3!important}.delta-node-marker circle{fill:#071019;stroke:currentColor;stroke-width:1.5}.delta-node-marker text,.delta-edge-marker,.delta-boundary-marker{fill:currentColor;font:800 9px ui-monospace,SFMono-Regular,Menlo,monospace}[data-delta-state="added"] .delta-node-marker,.delta-edge-marker[data-delta-state="added"],.delta-boundary-marker[data-delta-state="added"]{color:var(--d-add)}[data-delta-state="removed"] .delta-node-marker,.delta-edge-marker[data-delta-state="removed"],.delta-boundary-marker[data-delta-state="removed"]{color:var(--d-remove)}[data-delta-state="changed"] .delta-node-marker,.delta-edge-marker[data-delta-state="changed"],.delta-boundary-marker[data-delta-state="changed"]{color:var(--d-change)}[data-delta-state="moved"] .delta-node-marker,[data-delta-state="moved-from"] .delta-node-marker,.delta-edge-marker[data-delta-state="moved-from"],.delta-edge-marker[data-delta-state="rerouted"],.delta-boundary-marker[data-delta-state="geometry-changed"]{color:var(--d-move)}.delta-edge-marker,.delta-boundary-marker{paint-order:stroke;stroke:#071019;stroke-width:3px}
785rect[data-graph-role="structural-frame"][data-delta-state="added"]{stroke:var(--d-add)!important;stroke-width:2.5!important}rect[data-graph-role="structural-frame"][data-delta-state="removed"]{stroke:var(--d-remove)!important;stroke-width:2.5!important;stroke-dasharray:7 5!important}rect[data-graph-role="structural-frame"][data-delta-state="changed"]{stroke:var(--d-change)!important;stroke-width:2.5!important;stroke-dasharray:2 3!important}rect[data-graph-role="structural-frame"][data-delta-state="moved-from"]{stroke:var(--d-move)!important;stroke-width:2!important;stroke-dasharray:8 3 2 3!important;opacity:.42}text[data-delta-boundary-state="added"]{fill:var(--d-add)!important}text[data-delta-boundary-state="removed"]{fill:var(--d-remove)!important}text[data-delta-boundary-state="changed"]{fill:var(--d-change)!important}text[data-delta-boundary-state="moved-from"]{fill:var(--d-move)!important;opacity:.55}
786details{margin-top:12px;border:1px solid var(--d-line);border-radius:9px;background:#0a141e}summary{padding:13px 15px;cursor:pointer;font-weight:700}.changes{list-style:none;margin:0;padding:0 8px 8px}.changes li{border-top:1px solid rgba(138,160,181,.16)}.change-row{display:grid;grid-template-columns:30px 90px minmax(140px,1fr) minmax(100px,.7fr) minmax(120px,.8fr) minmax(140px,1.2fr);gap:10px;width:100%;margin:0;padding:9px 7px;border:0;border-radius:5px;background:transparent;color:inherit;font:inherit;font-size:11px;text-align:left;align-items:baseline;cursor:pointer}.change-row:hover{background:rgba(125,211,252,.06)}.change-row[aria-current="step"]{background:rgba(125,211,252,.1);box-shadow:inset 0 0 0 1px var(--d-focus)}.change-row:disabled{cursor:default}.token{font:800 13px/1 ui-monospace,SFMono-Regular,Menlo,monospace}.changes code,.change-row>span:last-child{color:var(--d-muted)}.proof-foot{display:flex;justify-content:space-between;gap:24px;margin-top:14px;color:var(--d-muted);font:650 10px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace}
787html[data-theme="dark"] body{background:#071019!important;background-image:none!important}html[data-theme="light"]{color-scheme:light;--d-ink:#10283c;--d-muted:#587187;--d-line:#c8d6e2;--d-focus:#006b8f}html[data-theme="light"] body{background:#eef3f7!important;background-image:none!important;color:var(--d-ink)}html[data-theme="light"] .metric,html[data-theme="light"] .view-switch,html[data-theme="light"] details{background:#fff}html[data-theme="light"] .canvas{background:#f8fbfd}html[data-theme="light"] .view-switch button[aria-selected="true"]{background:#dbeaf5;color:#10283c}html[data-theme="light"] .delta-node-marker circle{fill:#fff}html[data-preset="blueprint"] body{background-image:none!important}
788@media(max-width:760px){.proof-page{width:100%;padding:12px}.proof-head{grid-template-columns:1fr;gap:14px;align-items:start}.proof-head h1{font-size:32px}.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));width:100%}.metric{min-width:0}.provenance-change{align-items:flex-start;flex-wrap:wrap}.provenance-change span{width:100%;margin-left:0}.proof-tools{align-items:stretch;flex-wrap:wrap;gap:8px}.view-switch{display:flex;flex:1 1 100%}.view-switch button{flex:1;padding-inline:8px}.legend{flex-wrap:wrap;gap:8px}.proof-tools>div:last-child{margin-left:auto}.review-strip{grid-template-columns:auto auto auto auto}.review-status{grid-column:1/-1;padding:4px 2px 0}.canvas{min-height:0;padding:6px;overflow:auto}.canvas svg{min-width:720px;max-height:none}.snapshot-frame{min-width:720px}.changes{overflow-x:auto}.change-row{min-width:820px}.proof-foot{flex-direction:column;gap:4px}}
789@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}.canvas[data-delta-review-active] [data-delta-review-current]{transition:none!important}}@media print{body{min-width:0;background:#fff;color:#111}.proof-page{width:100%;padding:0}.proof-tools,.review-strip,details{display:none!important}.canvas{display:none!important}.canvas[data-view="delta"]{display:block!important;border:0}.canvas[data-view="delta"] [data-delta-state="same"]{opacity:1!important;transition:none!important}.canvas[data-delta-review-active]{--review-same-opacity:1;--review-change-opacity:1}.canvas[data-delta-review-active] [data-delta-review-current]{opacity:1!important;transition:none!important}.proof-foot{color:#444}}
790</style></head>
791<body><main class="proof-page"><header class="proof-head"><div><p class="eyebrow">ARCHITECTURE DELTA · ${proof}</p><h1>See what changed<br>before you merge.</h1><p class="subtitle">${esc(receipt.base.title)} → ${esc(receipt.head.title)}</p></div><div class="metrics"><div class="metric add"><strong>${total(receipt.summary, 'added')}</strong><span>ADDED</span></div><div class="metric remove"><strong>${total(receipt.summary, 'removed')}</strong><span>REMOVED</span></div><div class="metric change"><strong>${changed}</strong><span>CHANGED</span></div></div></header>${provenanceNotice}
792<div class="proof-tools"><div class="view-switch" role="tablist" aria-label="Architecture snapshot"><button role="tab" data-target="base" aria-selected="false">Before</button><button role="tab" data-target="delta" aria-selected="true">Delta</button><button role="tab" data-target="head" aria-selected="false">After</button></div><div class="legend"><span class="add"><i></i>+ ADD</span><span class="remove"><i></i>− DEL</span><span class="change"><i></i>~ MOD</span><span class="move"><i></i>↔ MOVE</span></div><div><button class="utility" id="export-svg" type="button">Export SVG</button> <button class="utility" id="share-card" type="button">Share Card</button> <button class="utility" id="preset" type="button">Preset</button> <button class="utility" id="theme" type="button">Theme</button></div></div>
793<nav class="review-strip" aria-label="Authored change review"><button class="review-step" id="review-overview" type="button" disabled>Overview</button><button class="review-step" id="review-previous" type="button" aria-label="Previous authored change" disabled>←</button><button class="review-step" id="review-play" type="button" aria-pressed="false"${rows.length ? '' : ' disabled'}>Review</button><button class="review-step" id="review-next" type="button" aria-label="Next authored change" disabled>→</button><div class="review-status" id="review-status" role="status" aria-live="polite">Overview · ${rows.length} authored ${provenanceChanged ? 'graph changes · provenance changed' : 'changes'}</div></nav>
794<section class="canvas" data-view="base" hidden>${baseView}</section><section class="canvas" data-view="delta">${deltaSvg}</section><section class="canvas" data-view="head" hidden>${headView}</section>
795<details${rows.length <= 10 ? ' open' : ''}><summary>Exact authored changes · ${rows.length}</summary><ul class="changes">${rowHtml}</ul></details>
796<footer class="proof-foot"><span>Stable IDs only · completeness: complete · ${proof}</span><span>Authored IR only · no risk or mergeability inference</span></footer></main>
797<script id="archify-compare-receipt" type="application/json">${safeJson(receipt)}</script>
798<script>(()=>{
799 const REVIEW_DWELL_MS = 1400;
800 const tabs = [...document.querySelectorAll('[role="tab"]')];
801 const views = [...document.querySelectorAll('[data-view]')];
802 const deltaCanvas = document.querySelector('[data-view="delta"]');
803 const rowButtons = [...document.querySelectorAll('.change-row')];
804 const overviewButton = document.querySelector('#review-overview');
805 const previousButton = document.querySelector('#review-previous');
806 const playButton = document.querySelector('#review-play');
807 const nextButton = document.querySelector('#review-next');
808 const status = document.querySelector('#review-status');
809 const receiptNode = document.querySelector('#archify-compare-receipt');
810 const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
811 const exportCss = ${safeJson(artifactCss)};
812 let activeIndex = -1;
813 let playbackToken = 0;
814 let playbackTimer = 0;
815 let playing = false;
816 let reviewAvailable = false;
817 let reviewSources = [];
819 function show(id) {
820 tabs.forEach((tab) => tab.setAttribute('aria-selected', String(tab.dataset.target === id)));
821 views.forEach((view) => { view.hidden = view.dataset.view !== id; });
822 }
824 function stopPlayback() {
825 playbackToken += 1;
826 if (playbackTimer) window.clearTimeout(playbackTimer);
827 playbackTimer = 0;
828 playing = false;
829 playButton.setAttribute('aria-pressed', 'false');
830 playButton.textContent = activeIndex === rowButtons.length - 1 && rowButtons.length ? 'Replay' : 'Review';
831 status.setAttribute('aria-live', 'polite');
832 }
834 function failReview() {
835 stopPlayback();
836 reviewAvailable = false;
837 activeIndex = -1;
838 deltaCanvas?.removeAttribute('data-delta-review-active');
839 deltaCanvas?.querySelectorAll('[data-delta-review-current]').forEach((element) => element.removeAttribute('data-delta-review-current'));
840 rowButtons.forEach((row) => {
841 row.disabled = true;
842 row.tabIndex = -1;
843 row.removeAttribute('aria-current');
844 });
845 [overviewButton, previousButton, playButton, nextButton].forEach((button) => { button.disabled = true; });
846 status.dataset.state = 'unavailable';
847 status.textContent = 'Review unavailable · compare identity mismatch';
848 }
850 function receiptRows(receipt) {
851 const definitions = [
852 { collection: 'components', kind: 'component', id: 'id', statuses: ['added', 'changed', 'evidence-changed', 'removed', 'moved'] },
853 { collection: 'connections', kind: 'relationship', id: 'id', statuses: ['added', 'changed', 'removed', 'rerouted'] },
854 { collection: 'boundaries', kind: 'boundary', id: 'key', statuses: ['added', 'changed', 'removed', 'geometry-changed'] },
855 ];
856 const rows = [];
857 for (const definition of definitions) {
858 const changes = receipt.changes[definition.collection];
859 if (!Array.isArray(changes)) return null;
860 for (const change of changes) {
861 if (!change || typeof change !== 'object' || Array.isArray(change)) return null;
862 const id = change[definition.id];
863 if (typeof id !== 'string' || !id || !definition.statuses.includes(change.status)) return null;
864 if (!Array.isArray(change.classifications) || !change.classifications.length || change.classifications.some((value) => typeof value !== 'string')) return null;
865 if (new Set(change.classifications).size !== change.classifications.length) return null;
866 if (!Array.isArray(change.changedFields) || change.changedFields.some((value) => typeof value !== 'string')) return null;
867 rows.push({ ...change, kind: definition.kind, key: definition.kind + ':' + id, id });
868 }
869 }
870 return rows;
871 }
873 function exactTargets(row) {
874 const attribute = row.dataset.changeKind === 'component'
875 ? 'data-node-id'
876 : row.dataset.changeKind === 'relationship'
877 ? 'data-edge-id'
878 : 'data-delta-boundary-key';
879 return [...deltaCanvas.querySelectorAll('[' + attribute + ']')]
880 .filter((element) => element.getAttribute(attribute) === row.dataset.changeId);
881 }
883 function targetSignature(matches) {
884 return matches.map((element) => {
885 const tag = element.tagName.toLowerCase();
886 const state = element.dataset.deltaState || '';
887 const classifications = element.dataset.deltaClassifications || '';
888 return tag + ':' + state + ':' + classifications;
889 }).sort().join('|');
890 }
892 function targetsMatch(source, row, matches) {
893 if (!source || !matches.length || !row.dataset.changeTargetSignature) return false;
894 if (row.dataset.changeKind !== source.kind || row.dataset.changeId !== source.id || row.dataset.changeStatus !== source.status) return false;
895 if (row.dataset.changeClassifications !== source.classifications.join(', ')) return false;
896 return row.dataset.changeTargetSignature === targetSignature(matches);
897 }
899 function validateReview() {
900 try {
901 if (document.querySelectorAll('#archify-compare-receipt').length !== 1 || !receiptNode) return false;
902 if (!deltaCanvas || !['base', 'delta', 'head'].every((id) => document.querySelectorAll('[data-view="' + id + '"]').length === 1)) return false;
903 if (deltaCanvas.children.length !== 1 || deltaCanvas.firstElementChild?.tagName.toLowerCase() !== 'svg') return false;
904 const receipt = JSON.parse(receiptNode.textContent);
905 if (!receipt || receipt.schemaVersion !== 1 || receipt.command !== 'compare' || receipt.completeness !== 'complete' || !receipt.changes) return false;
906 const sources = receiptRows(receipt);
907 if (!sources || sources.length !== rowButtons.length) return false;
908 const sourceByKey = new Map(sources.map((source) => [source.key, source]));
909 if (sourceByKey.size !== sources.length) return false;
910 const seen = new Set();
911 const orderedSources = [];
912 for (let index = 0; index < rowButtons.length; index += 1) {
913 const row = rowButtons[index];
914 const source = sourceByKey.get(row.dataset.changeKey);
915 if (!source || seen.has(source.key) || row.dataset.changeIndex !== String(index)) return false;
916 const matches = exactTargets(row);
917 if (!targetsMatch(source, row, matches)) return false;
918 seen.add(source.key);
919 orderedSources.push(source);
920 }
921 if (seen.size !== sourceByKey.size) return false;
922 reviewSources = orderedSources;
923 return true;
924 } catch {
925 return false;
926 }
927 }
929 function updateControls() {
930 overviewButton.disabled = !reviewAvailable || activeIndex < 0;
931 previousButton.disabled = !reviewAvailable || activeIndex <= 0;
932 nextButton.disabled = !reviewAvailable || activeIndex < 0 || activeIndex >= rowButtons.length - 1;
933 }
935 function selectChange(index, fromPlayback = false) {
936 if (!reviewAvailable || index < 0 || index >= rowButtons.length) return false;
937 const row = rowButtons[index];
938 const matches = exactTargets(row);
939 if (!targetsMatch(reviewSources[index], row, matches)) { failReview(); return false; }
940 show('delta');
941 deltaCanvas.querySelectorAll('[data-delta-review-current]').forEach((element) => element.removeAttribute('data-delta-review-current'));
942 matches.forEach((element) => element.setAttribute('data-delta-review-current', 'true'));
943 deltaCanvas.setAttribute('data-delta-review-active', 'true');
944 rowButtons.forEach((button, rowIndex) => {
945 button.tabIndex = rowIndex === index ? 0 : -1;
946 if (rowIndex === index) button.setAttribute('aria-current', 'step');
947 else button.removeAttribute('aria-current');
948 });
949 activeIndex = index;
950 updateControls();
951 const kind = row.dataset.changeKind.charAt(0).toUpperCase() + row.dataset.changeKind.slice(1);
952 status.textContent = String(index + 1).padStart(2, '0') + ' / ' + String(rowButtons.length).padStart(2, '0') + ' · ' + kind + ' · ' + row.dataset.changeLabel + ' [' + row.dataset.changeId + '] · ' + row.dataset.changeClassifications;
953 if (fromPlayback) status.setAttribute('aria-live', 'off');
954 return true;
955 }
957 function overview() {
958 if (!reviewAvailable) { failReview(); return; }
959 stopPlayback();
960 activeIndex = -1;
961 deltaCanvas.removeAttribute('data-delta-review-active');
962 deltaCanvas.querySelectorAll('[data-delta-review-current]').forEach((element) => element.removeAttribute('data-delta-review-current'));
963 rowButtons.forEach((row, index) => {
964 row.tabIndex = index === 0 ? 0 : -1;
965 row.removeAttribute('aria-current');
966 });
967 show('delta');
968 status.removeAttribute('data-state');
969 status.textContent = 'Overview · ' + rowButtons.length + ' authored changes';
970 updateControls();
971 }
973 function schedulePlayback(token) {
974 if (!playing || token !== playbackToken || activeIndex >= rowButtons.length - 1) {
975 if (activeIndex >= rowButtons.length - 1) stopPlayback();
976 return;
977 }
978 playbackTimer = window.setTimeout(() => {
979 if (!playing || token !== playbackToken) return;
980 if (!selectChange(activeIndex + 1, true)) return;
981 schedulePlayback(token);
982 }, REVIEW_DWELL_MS);
983 }
985 function startReview() {
986 if (!reviewAvailable) return;
987 if (playing) { stopPlayback(); return; }
988 stopPlayback();
989 if (!selectChange(0, false)) return;
990 if (reducedMotion.matches || rowButtons.length < 2) return;
991 playing = true;
992 const token = ++playbackToken;
993 playButton.textContent = 'Pause';
994 playButton.setAttribute('aria-pressed', 'true');
995 status.setAttribute('aria-live', 'off');
996 schedulePlayback(token);
997 }
999 function canonicalDeltaSvg() {
1000 const source = deltaCanvas?.querySelector(':scope > svg');
1001 if (!source) throw new Error('Canonical Delta SVG is unavailable.');
1002 const clone = source.cloneNode(true);
1003 const metadata = document.createElementNS('http://www.w3.org/2000/svg', 'metadata');
1004 metadata.setAttribute('id', 'archify-software-notices');
1005 metadata.textContent = JSON.parse(document.getElementById('archify-software-notices').textContent);
1006 clone.insertBefore(metadata, clone.firstChild);
1007 clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
1008 clone.removeAttribute('style');
1009 clone.querySelectorAll('[data-delta-review-current]').forEach((element) => element.removeAttribute('data-delta-review-current'));
1010 const style = document.createElementNS('http://www.w3.org/2000/svg', 'style');
1011 style.textContent = exportCss + '\\n' +
1012 '[data-delta-state="same"]{opacity:.38}' +
1013 '[data-delta-state="added"]{--delta:#34d399}' +
1014 '[data-delta-state="removed"]{--delta:#fb7185}' +
1015 '[data-delta-state="changed"]{--delta:#fbbf24}' +
1016 '[data-delta-state="moved"],[data-delta-state="moved-from"],[data-delta-state="rerouted"],[data-delta-state="geometry-changed"]{--delta:#7dd3fc}' +
1017 'g[data-node-id][data-delta-state]:not([data-delta-state="same"])>rect:last-of-type{stroke:var(--delta)!important;stroke-width:3!important}' +
1018 'path[data-delta-state]:not([data-delta-state="same"]){stroke:var(--delta)!important;stroke-width:3!important}' +
1019 'rect[data-graph-role="structural-frame"][data-delta-state]:not([data-delta-state="same"]){stroke:var(--delta)!important;stroke-width:2.5!important}' +
1020 'rect[data-graph-role="structural-frame"][data-delta-state="changed"]{stroke-dasharray:2 3!important}' +
1021 '[data-delta-state="removed"],[data-delta-state="moved-from"]{stroke-dasharray:7 5!important}' +
1022 '[data-delta-state="moved-from"]{opacity:.42}' +
1023 '[data-delta-state]:not([data-delta-state="same"]) .delta-node-marker,.delta-edge-marker[data-delta-state],.delta-boundary-marker[data-delta-state]{color:var(--delta)}' +
1024 'text[data-delta-boundary-state="added"]{fill:#34d399!important}text[data-delta-boundary-state="removed"]{fill:#fb7185!important}text[data-delta-boundary-state="changed"]{fill:#fbbf24!important}text[data-delta-boundary-state="moved-from"]{fill:#7dd3fc!important;opacity:.55}' +
1025 '.delta-node-marker circle{fill:#071019;stroke:currentColor;stroke-width:1.5}.delta-node-marker text,.delta-edge-marker,.delta-boundary-marker{fill:currentColor;font:800 9px ui-monospace,SFMono-Regular,Menlo,monospace}';
1026 clone.insertBefore(style, clone.firstChild);
1027 // The XML declaration pins UTF-8: without it, consumers that guess an
1028 // encoding instead of defaulting to UTF-8 mangle non-ASCII text.
1029 return '<?xml version="1.0" encoding="UTF-8"?>\\n' + new XMLSerializer().serializeToString(clone);
1032 function artifactName(suffix) {
1033 const title = String(JSON.parse(receiptNode.textContent).head.title || 'architecture-delta')
1034 .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'architecture-delta';
1035 return title + suffix;
1038 function downloadBlob(blob, name) {
1039 const url = URL.createObjectURL(blob);
1040 const link = document.createElement('a');
1041 link.href = url;
1042 link.download = name;
1043 document.body.appendChild(link);
1044 link.click();
1045 link.remove();
1046 window.setTimeout(() => URL.revokeObjectURL(url), 1000);
1049 function recordExport(format, blob, dimensions) {
1050 document.documentElement.dataset.archifyDeltaExport = JSON.stringify({
1051 ok: true,
1052 format,
1053 bytes: blob.size,
1054 mime: blob.type,
1055 ...(dimensions || {}),
1056 });
1059 function exportCanonicalSvg() {
1060 const blob = new Blob([canonicalDeltaSvg()], { type: 'image/svg+xml;charset=utf-8' });
1061 downloadBlob(blob, artifactName('-architecture-delta.svg'));
1062 recordExport('svg', blob);
1063 return blob;
1066 function loadSvgImage(blob) {
1067 return new Promise((resolve, reject) => {
1068 const url = URL.createObjectURL(blob);
1069 const image = new Image();
1070 image.onload = () => { URL.revokeObjectURL(url); resolve(image); };
1071 image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('Could not rasterize canonical Delta SVG.')); };
1072 image.src = url;
1073 });
1076 function pngBlob(canvas) {
1077 return new Promise((resolve, reject) => canvas.toBlob((blob) => {
1078 if (blob) resolve(blob);
1079 else reject(new Error('Canvas returned no Share Card PNG.'));
1080 }, 'image/png'));
1083 async function shareCard() {
1084 const receipt = JSON.parse(receiptNode.textContent);
1085 const canvas = document.createElement('canvas');
1086 canvas.width = 1200;
1087 canvas.height = 630;
1088 const ctx = canvas.getContext('2d');
1089 if (!ctx) throw new Error('2D canvas context unavailable for Architecture Delta Share Card.');
1090 ctx.fillStyle = '#071019';
1091 ctx.fillRect(0, 0, 1200, 630);
1092 ctx.fillStyle = '#0b1722';
1093 ctx.fillRect(34, 28, 1132, 574);
1094 ctx.strokeStyle = '#25384a';
1095 ctx.lineWidth = 2;
1096 ctx.strokeRect(34, 28, 1132, 574);
1097 ctx.font = '700 18px ui-monospace, SFMono-Regular, Menlo, monospace';
1098 ctx.fillStyle = '#7dd3fc';
1099 ctx.fillText('ARCHITECTURE DELTA', 66, 68);
1100 ctx.font = '700 34px ui-monospace, SFMono-Regular, Menlo, monospace';
1101 ctx.fillStyle = '#e6edf5';
1102 const title = String(receipt.head.title || 'Architecture').slice(0, 46) + ' Architecture Delta';
1103 ctx.fillText(title.slice(0, 58), 66, 112);
1104 ctx.font = '700 17px ui-monospace, SFMono-Regular, Menlo, monospace';
1105 ctx.fillStyle = '#8aa0b5';
1106 const componentLine = 'COMPONENTS +' + receipt.summary.components.added + ' ~' + receipt.summary.components.changed + ' −' + receipt.summary.components.removed;
1107 const connectionLine = 'CONNECTIONS +' + receipt.summary.connections.added + ' ~' + receipt.summary.connections.changed + ' −' + receipt.summary.connections.removed;
1108 const boundaryLine = 'BOUNDARY SCOPE +' + receipt.summary.boundaries.added + ' ~' + receipt.summary.boundaries.changed + ' −' + receipt.summary.boundaries.removed;
1109 ctx.fillText(componentLine, 66, 151);
1110 ctx.fillText(connectionLine, 420, 151);
1111 ctx.fillText(boundaryLine, 786, 151);
1112 ctx.font = '650 14px ui-monospace, SFMono-Regular, Menlo, monospace';
1113 const graphChanges = ['components', 'connections', 'boundaries'].reduce((sum, collection) => sum + (receipt.changes[collection] || []).length, 0);
1114 const provenanceSummary = ${safeJson(provenanceChanged ? `provenance changed · ${provenanceFields}` : '')};
1115 const movementSummary = '↔ moved ' + receipt.summary.components.moved + ' · rerouted ' + receipt.summary.connections.rerouted + ' · presentation ' + (receipt.summary.presentationChanged ? 'changed' : 'unchanged');
1116 const secondary = graphChanges === 0
1117 ? receipt.summary.provenanceChanged
1118 ? 'No graph changes · ' + provenanceSummary
1119 : 'No authored architecture changes · ' + movementSummary
1120 : movementSummary + (provenanceSummary ? ' · ' + provenanceSummary : '');
1121 const proofLine = receipt.proofLevel === 'revision-pinned'
1122 ? 'REV ' + String(receipt.base.revision).slice(0, 8) + ' → ' + String(receipt.head.revision).slice(0, 8) + ' · REVISION-PINNED INPUTS'
1123 : 'AUTHORED SNAPSHOTS';
1124 function fitCanvasLine(text, maxWidth) {
1125 if (ctx.measureText(text).width <= maxWidth) return text;
1126 let low = 0;
1127 let high = text.length;
1128 while (low < high) {
1129 const middle = Math.ceil((low + high) / 2);
1130 if (ctx.measureText(text.slice(0, middle) + '…').width <= maxWidth) low = middle;
1131 else high = middle - 1;
1133 return text.slice(0, low).trimEnd() + '…';
1135 const proofWidth = ctx.measureText(proofLine).width;
1136 const secondaryWidth = Math.max(0, 1044 - proofWidth);
1137 ctx.fillText(fitCanvasLine(secondary, secondaryWidth), 66, 181);
1138 ctx.textAlign = 'right';
1139 ctx.fillText(proofLine, 1134, 181);
1140 ctx.textAlign = 'left';
1141 ctx.fillStyle = '#09141e';
1142 ctx.fillRect(66, 206, 1068, 358);
1143 ctx.strokeStyle = '#25384a';
1144 ctx.strokeRect(66, 206, 1068, 358);
1145 const svgBlob = new Blob([canonicalDeltaSvg()], { type: 'image/svg+xml;charset=utf-8' });
1146 const image = await loadSvgImage(svgBlob);
1147 const ratio = Math.min(1024 / image.width, 322 / image.height);
1148 const width = image.width * ratio;
1149 const height = image.height * ratio;
1150 ctx.drawImage(image, 600 - width / 2, 385 - height / 2, width, height);
1151 ctx.font = '650 12px ui-monospace, SFMono-Regular, Menlo, monospace';
1152 ctx.fillStyle = '#8aa0b5';
1153 ctx.fillText('Stable authored IDs · static complete Delta · no risk or mergeability inference', 66, 588);
1154 return pngBlob(canvas);
1157 async function downloadShareCard() {
1158 const blob = await shareCard();
1159 downloadBlob(blob, artifactName('-architecture-delta-share-card.png'));
1160 recordExport('share-card', blob, { width: 1200, height: 630 });
1161 return blob;
1164 window.Archify = window.Archify || {};
1165 window.Archify.deltaExport = { canonicalSvg: canonicalDeltaSvg, shareCard, exportSvg: exportCanonicalSvg, downloadShareCard };
1166 window.Archify.exportMenu = {
1167 shareCard,
1168 run(format) {
1169 if (format === 'svg') return exportCanonicalSvg();
1170 if (format === 'share-card') return downloadShareCard();
1171 throw new Error('Unknown Architecture Delta export format: ' + format);
1172 },
1173 };
1175 tabs.forEach((tab, index) => {
1176 tab.addEventListener('click', () => { stopPlayback(); show(tab.dataset.target); });
1177 tab.addEventListener('keydown', (event) => {
1178 if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
1179 event.preventDefault();
1180 stopPlayback();
1181 const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
1182 tabs[next].focus();
1183 show(tabs[next].dataset.target);
1184 });
1185 });
1187 rowButtons.forEach((row, index) => {
1188 row.tabIndex = index === 0 ? 0 : -1;
1189 row.addEventListener('click', () => { stopPlayback(); selectChange(index); });
1190 row.addEventListener('keydown', (event) => {
1191 if (event.key === 'Enter' || event.key === ' ') {
1192 event.preventDefault();
1193 stopPlayback();
1194 selectChange(index);
1195 return;
1197 if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return;
1198 event.preventDefault();
1199 stopPlayback();
1200 const next = event.key === 'Home' ? 0 : event.key === 'End' ? rowButtons.length - 1 : (index + (event.key === 'ArrowDown' ? 1 : -1) + rowButtons.length) % rowButtons.length;
1201 rowButtons.forEach((button, rowIndex) => { button.tabIndex = rowIndex === next ? 0 : -1; });
1202 rowButtons[next].focus();
1203 });
1204 });
1206 overviewButton.addEventListener('click', overview);
1207 previousButton.addEventListener('click', () => { stopPlayback(); selectChange(activeIndex - 1); });
1208 nextButton.addEventListener('click', () => { stopPlayback(); selectChange(activeIndex + 1); });
1209 playButton.addEventListener('click', startReview);
1210 document.addEventListener('focusin', (event) => {
1211 if (playing && event.target.closest('button')) stopPlayback();
1212 });
1213 document.addEventListener('visibilitychange', () => { if (document.hidden) stopPlayback(); });
1214 window.addEventListener('beforeprint', overview);
1215 reducedMotion.addEventListener('change', () => { if (reducedMotion.matches) stopPlayback(); });
1216 document.addEventListener('keydown', (event) => {
1217 if (event.key === 'Escape' && activeIndex >= 0) {
1218 event.preventDefault();
1219 overview();
1220 playButton.focus();
1222 });
1224 document.querySelector('#theme').addEventListener('click', () => { document.documentElement.dataset.theme = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; });
1225 const presets = ['classic', 'signal-flow', 'blueprint'];
1226 document.querySelector('#preset').addEventListener('click', () => { const now = document.documentElement.dataset.preset; document.documentElement.dataset.preset = presets[(presets.indexOf(now) + 1) % presets.length]; });
1227 document.querySelector('#export-svg').addEventListener('click', exportCanonicalSvg);
1228 document.querySelector('#share-card').addEventListener('click', () => { downloadShareCard().catch((error) => window.alert(error.message)); });
1230 reviewAvailable = validateReview();
1231 if (!reviewAvailable) failReview();
1232 else {
1233 status.removeAttribute('data-state');
1234 updateControls();
1236})();</script></body></html>`;
1237 return html.replace(/[ \t]+$/gm, '');
1240export function validateArchitectureDeltaHtml(html, receipt) {
1241 const failures = [];
1242 const rows = architectureDeltaChangeRows(receipt);
1243 const deltaMarkup = html.match(/<section class="canvas" data-view="delta">([\s\S]*?)<\/section>/)?.[1] || '';
1244 const svgTags = [...deltaMarkup.matchAll(/<\/?svg\b[^>]*>/g)];
1245 let svgDepth = 0;
1246 let svgRoots = 0;
1247 let rootStart = -1;
1248 let rootEnd = -1;
1249 let svgBalanced = true;
1250 for (const match of svgTags) {
1251 if (match[0].startsWith('</')) {
1252 svgDepth -= 1;
1253 if (svgDepth < 0) svgBalanced = false;
1254 if (svgDepth === 0) rootEnd = match.index + match[0].length;
1255 } else {
1256 if (svgDepth === 0) {
1257 svgRoots += 1;
1258 if (rootStart < 0) rootStart = match.index;
1260 svgDepth += 1;
1263 if (!['base', 'delta', 'head'].every((id) => (html.match(new RegExp(`<section class="canvas" data-view="${id}"`, 'g')) || []).length === 1)) failures.push('expected one Before, Delta, and After canvas');
1264 if ((html.match(/class="snapshot-frame" title="Before architecture explorer"/g) || []).length !== 1
1265 || (html.match(/class="snapshot-frame" title="After architecture explorer"/g) || []).length !== 1) {
1266 failures.push('Before and After must preserve one complete architecture explorer each');
1268 if (!svgBalanced || svgDepth !== 0 || svgRoots !== 1 || deltaMarkup.slice(0, rootStart).trim() || deltaMarkup.slice(rootEnd).trim()) failures.push('expected exactly one root SVG in the Delta canvas');
1269 if ((html.match(/id="archify-compare-receipt"/g) || []).length !== 1) failures.push('expected exactly one embedded compare receipt');
1270 const provenanceChanged = receipt.summary?.provenanceChanged === true;
1271 const provenanceNotices = (html.match(/data-provenance-changed="true"/g) || []).length;
1272 const provenanceFields = receipt.provenance?.changedFields;
1273 const legacyProvenance = receipt.provenance === undefined;
1274 const validProvenanceFields = Array.isArray(provenanceFields) && provenanceFields.length > 0;
1275 const provenanceDescription = provenanceChanged && (legacyProvenance || validProvenanceFields) ? provenanceDetail(receipt.provenance) : '';
1276 const provenanceFieldLabel = legacyProvenance ? 'repository metadata' : validProvenanceFields ? provenanceFields.join(', ') : '';
1277 if (provenanceNotices !== (provenanceChanged ? 1 : 0)
1278 || (!provenanceChanged && !legacyProvenance)
1279 || (provenanceChanged && !legacyProvenance && !validProvenanceFields)
1280 || (provenanceChanged && !html.includes(`data-provenance-fields="${esc(provenanceFieldLabel)}"`))
1281 || (provenanceChanged && !html.includes(`<span>${esc(provenanceDescription)}</span>`))) {
1282 failures.push('provenance change notice does not match the receipt');
1284 if (provenanceChanged && rows.length === 0 && html.includes('No authored architecture changes.')) failures.push('provenance-only delta claims no authored changes');
1285 if (!html.includes('aria-label="Authored change review"')) failures.push('missing exact-ID change navigator');
1286 if ((html.match(/class="change-row"/g) || []).length !== rows.length) failures.push('change navigator row count does not match the receipt');
1287 if (!html.includes('id="export-svg"') || !html.includes('id="share-card"')
1288 || !html.includes('window.Archify.deltaExport = { canonicalSvg: canonicalDeltaSvg, shareCard')) {
1289 failures.push('missing canonical Delta SVG or Share Card export contract');
1291 for (const [index, row] of rows.entries()) {
1292 const safeKey = esc(row.key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1293 const rowMatches = [...html.matchAll(new RegExp(`<button class="change-row"[^>]*data-change-key="${safeKey}"[^>]*>`, 'g'))].map((match) => match[0]);
1294 if (rowMatches.length !== 1) failures.push(`expected exactly one change row ${row.key}`);
1295 const targets = reviewTargetTags(deltaMarkup, row);
1296 if (!targets.length) failures.push(`missing Delta identity ${row.key}`);
1297 if (targets.some(({ tag }) => !/\bdata-delta-state="[^"]+"/.test(tag))) failures.push(`missing Delta target state ${row.key}`);
1298 const signature = reviewTargetSignature(targets);
1299 const expectedSignature = expectedReviewTargetSignature(row);
1300 const storedSignature = rowMatches[0]?.match(/\bdata-change-target-signature="([^"]*)"/)?.[1];
1301 if (!signature || signature !== expectedSignature || storedSignature !== expectedSignature) failures.push(`ambiguous Delta target signature ${row.key}`);
1302 if (rowMatches[0]?.match(/\bdata-change-index="([^"]+)"/)?.[1] !== String(index)) failures.push(`incorrect change row order ${row.key}`);
1303 const primary = primaryReviewTags(deltaMarkup, row);
1304 const states = primary.map((tag) => tag.match(/\bdata-delta-state="([^"]+)"/)?.[1]).filter(Boolean).sort();
1305 if (JSON.stringify(states) !== JSON.stringify(reviewPrimaryStates(row).sort())) failures.push(`ambiguous Delta identity ${row.key}`);
1306 const classifications = row.classifications.join(',');
1307 if (primary.some((tag) => tag.match(/\bdata-delta-classifications="([^"]*)"/)?.[1] !== classifications)) failures.push(`conflicting Delta classification ${row.key}`);
1309 // Before/After embed the complete existing explorer runtime. Validate claims
1310 // made by the Delta shell itself, not implementation vocabulary inside an
1311 // escaped srcdoc script (for example, "safe scale" in image export code).
1312 const deltaShell = html.replace(/<iframe\b[^>]*><\/iframe>/g, '');
1313 if (/\b(?:SAFE|LOW RISK|MERGEABLE|NO IMPACT|VERIFIED PR)\b/i.test(deltaShell)) failures.push('contains a forbidden risk or mergeability claim');
1314 if (/\b(?:NaN|Infinity)\b/.test(html)) failures.push('contains non-finite output');
1315 if (receipt.completeness !== 'complete') failures.push('receipt is not complete');
1316 if (failures.length) fail('delta/artifact-invalid', `Architecture Delta artifact failed validation: ${failures.join('; ')}.`, { failures });
1317 return { ok: true, checksPassed: 10, checkCount: 10 };