@stacksjs/defaults 0.75.360 → 0.75.362

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 (209) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +4 -4
  3. package/ai/skills/stacks-payments/SKILL.md +28 -0
  4. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  5. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  6. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  7. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  8. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  9. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  10. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  11. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  12. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  16. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  17. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  18. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  19. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  20. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  21. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  22. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  24. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  27. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  28. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  29. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  32. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  37. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  38. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  39. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  40. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  42. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  43. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  56. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  57. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  58. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  59. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  69. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  70. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  111. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  120. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  121. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  122. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  124. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  125. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  126. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  127. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  128. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  129. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  130. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  134. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  141. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  147. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  149. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  175. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  186. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  187. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  188. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  189. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  190. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  191. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  192. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  194. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  195. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  196. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  199. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  201. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  205. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  206. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  207. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  208. package/ide/vscode/package.json +1 -1
  209. package/package.json +2 -2
@@ -1,12 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from 'node:fs';
4
+ import { isExplainerHtml, checkExplainerHtml } from '../explainer/check.mjs';
5
+ import { analyzeSvgPath } from '../renderers/shared/svg-path-analysis.mjs';
6
+ import { textUnits } from '../renderers/shared/utils.mjs';
7
+ import { createHash } from 'node:crypto';
4
8
  import path from 'node:path';
5
- import { collectAmbiguousCorridors, collectBorderRuns, collectLabelRouteClearance, collectRouteRhythmIssues, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
9
+ import { collectAmbiguousCorridors, collectArrowheadCollisions, collectBorderRuns, collectLabelCanvasOverflow, collectLabelRouteClearance, collectRouteRhythmIssues, describeLabelCanvasOverflow, formatRect, forwardCollinearAnalysisSegments, minimumLabelRouteClearance, normalizeRoutePoints, routeBudgetMetrics } from '../renderers/shared/geometry.mjs';
6
10
  import {
7
11
  DESKTOP_READABILITY_VIEWPORT,
8
12
  DESKTOP_READER_DIAGRAM_WIDTH,
13
+ DECLARED_WIDE_READER_CONTRACT,
14
+ declaredWideReadabilityBudget,
9
15
  MIN_PROJECTED_NODE_TEXT_PX,
16
+ describeFixedWidthOverflow,
17
+ predictedFixedWidthOverflow,
10
18
  projectedNodeTextPx,
11
19
  } from '../renderers/shared/desktop-readability.mjs';
12
20
 
@@ -19,8 +27,11 @@ if (!input || input === '-h' || input === '--help') {
19
27
 
20
28
  const htmlPath = path.resolve(input);
21
29
  let html;
30
+ let artifact;
22
31
  try {
23
- html = fs.readFileSync(htmlPath, 'utf8');
32
+ const bytes = fs.readFileSync(htmlPath);
33
+ html = bytes.toString('utf8');
34
+ artifact = { sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.byteLength };
24
35
  } catch (err) {
25
36
  console.error(JSON.stringify({
26
37
  ok: false,
@@ -30,7 +41,15 @@ try {
30
41
  process.exit(1);
31
42
  }
32
43
 
44
+ if (isExplainerHtml(html)) {
45
+ const result = checkExplainerHtml(html, { file: htmlPath });
46
+ console.log(JSON.stringify(result, null, 2));
47
+ process.exit(result.ok ? 0 : 1);
48
+ }
49
+
33
50
  const checks = [];
51
+ const pathDiagnostics = [];
52
+ const pathAnalyses = new Map();
34
53
  let composition = {
35
54
  schemaVersion: 1,
36
55
  profile: 'standard',
@@ -38,10 +57,12 @@ let composition = {
38
57
  summary: { errors: 0, warnings: 0 },
39
58
  metrics: {
40
59
  properCrossings: 0,
60
+ resolvedCrossovers: 0,
41
61
  ambiguousCorridors: 0,
42
62
  containerBorderRuns: 0,
43
63
  labelRouteClearanceIssues: 0,
44
64
  minLabelRouteClearance: null,
65
+ labelCanvasOverflowIssues: 0,
45
66
  maxBends: 0,
46
67
  routesOverSuggestedBends: 0,
47
68
  maxStretch: null,
@@ -59,6 +80,34 @@ let composition = {
59
80
  issues: [],
60
81
  };
61
82
 
83
+ const NON_FINITE_TOKEN = /\b(?:NaN|undefined|Infinity)\b/;
84
+ // Consume comments/CDATA as whole tokens, including any tag-like prose.
85
+ const SVG_TAG_TOKEN = /<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<(\/?)([A-Za-z][\w:-]*)(?=[\s/>])(?:[^>"']|"[^"]*"|'[^']*')*>/g;
86
+ const AUTOMATIC_CROSSOVER_UNDERLAY_TAG = /<path\b[^>]*\bdata-graph-role="automatic-crossover-underlay"[^>]*\/>/i;
87
+ const HTML_VOID_ELEMENTS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
88
+ const SVG_HTML_INTEGRATION_POINTS = new Set(['foreignobject', 'desc', 'title']);
89
+ const HTML_ATTRIBUTE = /([A-Za-z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
90
+ const readerContract = readerContractFromHtml(html);
91
+ const NUMERIC_ATTRS = new Set([
92
+ 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'dx', 'dy', 'cx', 'cy', 'r', 'rx', 'ry', 'fx', 'fy',
93
+ 'fr', 'width', 'height', 'd', 'points', 'pathlength', 'transform', 'viewbox', 'offset',
94
+ 'opacity', 'fill-opacity', 'flood-opacity', 'stop-opacity', 'stroke-opacity', 'font-size',
95
+ 'font-size-adjust', 'font-weight', 'letter-spacing', 'word-spacing', 'kerning', 'stroke-width',
96
+ 'stroke-dasharray', 'stroke-dashoffset', 'stroke-miterlimit', 'textlength', 'startoffset',
97
+ 'rotate', 'markerwidth', 'markerheight', 'refx', 'refy', 'orient', 'patterntransform',
98
+ 'gradienttransform', 'filterres', 'stddeviation', 'basefrequency', 'numoctaves', 'seed',
99
+ 'surfacescale', 'diffuseconstant', 'specularconstant', 'specularexponent',
100
+ 'limitingconeangle', 'azimuth', 'elevation', 'pointsatx', 'pointsaty', 'pointsatz',
101
+ 'kernelmatrix', 'order', 'divisor', 'bias', 'targetx', 'targety', 'kernelunitlength',
102
+ 'scale', 'radius', 'k1', 'k2', 'k3', 'k4', 'tablevalues', 'slope', 'intercept',
103
+ 'amplitude', 'exponent',
104
+ ]);
105
+ const ELEMENT_NUMERIC_ATTRS = new Map([
106
+ ['fecolormatrix', new Set(['values'])],
107
+ ['fepointlight', new Set(['z'])],
108
+ ['fespotlight', new Set(['z'])],
109
+ ]);
110
+
62
111
  function addCheck(name, ok, details = []) {
63
112
  checks.push({ name, ok, details });
64
113
  }
@@ -71,19 +120,35 @@ if (svgMatches.length === 1) {
71
120
  const svgRoot = svg.match(/<svg\b[^>]*>/i)?.[0] || '';
72
121
  const svgAttrs = parseAttrs(svgRoot);
73
122
  const qualityProfile = svgAttrs['data-quality-profile'] || 'standard';
123
+ const workflowV2 = svgAttrs['data-layout-contract'] === 'readable-v2';
74
124
  const qualityGatesEnforced = svgAttrs['data-quality-gates'] !== 'advisory';
75
- addCheck('finite_svg', !/\b(?:NaN|undefined|Infinity|-Infinity)\b/.test(svg));
125
+ const nonFiniteAttrs = collectNonFiniteAttrs(svg);
126
+ addCheck('finite_svg', nonFiniteAttrs.length === 0, nonFiniteAttrs);
76
127
  const legendStart = svg.indexOf('<!-- Legend -->');
77
128
  const beforeLegend = legendStart >= 0 ? svg.slice(0, legendStart) : svg;
78
- const desktopReadabilityIssue = collectDesktopReadability(svgAttrs, beforeLegend);
79
- const arrows = collectArrows(beforeLegend);
129
+ const desktopReadability = collectDesktopReadability(svgAttrs, beforeLegend, readerContract);
130
+ const desktopReadabilityIssue = desktopReadability.issue;
131
+ // The browser gate measures this later; the geometry is already certain here.
132
+ const viewportHeightIssue = predictedFixedWidthOverflow({
133
+ viewBoxWidth: viewBoxSize(svgAttrs)[0],
134
+ viewBoxHeight: viewBoxSize(svgAttrs)[1],
135
+ readerFit: svgAttrs['data-reader-fit'] || null,
136
+ diagramType: svgAttrs['data-diagram-type'] || null,
137
+ });
138
+ const arrows = collectArrows(beforeLegend, workflowV2);
80
139
  const diagonal = arrows.flatMap((arrow) => diagonalStraightSegments(arrow).map((segment) => ({ arrow, ...segment })));
81
140
  addCheck(
82
141
  'orthogonal_arrows',
83
142
  diagonal.length === 0,
84
- diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: ${arrow.raw}`),
143
+ diagonal.map(({ arrow, segmentIndex }) => `${arrow.kind} ${arrow.index} segment ${segmentIndex + 1}: expected an orthogonal segment or an explicitly authored direct straight route; ${arrow.raw}`),
85
144
  );
86
- const relationshipCrossings = collectRelationshipCrossings(arrows);
145
+ const measuredRelationshipCrossings = collectRelationshipCrossings(arrows, workflowV2);
146
+ const resolvedCrossovers = measuredRelationshipCrossings.filter((hit) => (
147
+ hit.left.crossoverHalo && hit.right.crossoverHalo
148
+ ));
149
+ const relationshipCrossings = measuredRelationshipCrossings.filter((hit) => (
150
+ !hit.left.crossoverHalo || !hit.right.crossoverHalo
151
+ ));
87
152
  const compositionFrames = collectCompositionFrames(beforeLegend);
88
153
  const containerBorderRuns = collectBorderRuns({
89
154
  routedRelations: arrows
@@ -96,40 +161,74 @@ if (svgMatches.length === 1) {
96
161
  frames: compositionFrames,
97
162
  });
98
163
  const routedRelationships = arrows
99
- .filter((arrow) => arrow.from && arrow.to && arrow.routePoints.length)
164
+ .filter((arrow) => arrow.from && arrow.to && arrow.routeSubpaths.some(points => points.length > 1))
100
165
  .map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints }));
101
- const routeMetrics = routeBudgetMetrics({ routedRelations: routedRelationships });
102
- const routeRhythmIssues = collectRouteRhythmIssues({ routedRelations: routedRelationships });
103
- const ambiguousCorridors = collectAmbiguousCorridors({ routedRelations: routedRelationships });
166
+ const nodeRects = svgAttrs.transform ? [] : collectUntransformedNodeRects(beforeLegend);
167
+ const routeMetrics = subpathBudgetMetrics(routedRelationships);
168
+ // Architecture marks only one of its two Reader fits with its type.
169
+ const crowdedSides = svgAttrs['data-diagram-type'] === 'architecture' || svgAttrs['data-reader-primary-text'] === '14'
170
+ ? crowdedNodeSides(arrows, nodeRects) : [];
171
+ const routeRhythmIssues = subpathRhythmIssues(routedRelationships);
172
+ const ambiguousCorridors = subpathCorridors({
173
+ routedRelations: routedRelationships,
174
+ includeSharedEndpoints: (left, right) => workflowV2 || (left.independentPorts && right.independentPorts),
175
+ allowShortWorkflowTrunks: workflowV2,
176
+ includeSharedEndpointCounterflow: (left, right) => left.automaticWorkflowRoute && right.automaticWorkflowRoute,
177
+ });
178
+ const arrowheadCollisions = collectArrowheadCollisions({
179
+ routedRelations: routedRelationships.filter((entry) => workflowV2 || entry.relation.independentPorts)
180
+ .flatMap(entry => routeParts(entry).filter(part => part.targetEndpoint)),
181
+ allowShortWorkflowTrunks: workflowV2,
182
+ });
104
183
  const relationshipLabels = collectRelationshipLabelMasks(beforeLegend, arrows);
184
+ const leadingSpace = collectArchitectureLeadingSpace({
185
+ svgAttrs, fragment: beforeLegend, nodeRects, frames: compositionFrames,
186
+ arrows, labels: relationshipLabels,
187
+ });
188
+ const sequenceColumnSpace = collectSequenceColumnSpace({ svgAttrs, fragment: beforeLegend, nodeRects, arrows });
189
+ const sequenceNoteBorders = collectSequenceNoteBorders({ svgAttrs, fragment: beforeLegend, frames: compositionFrames, arrows });
105
190
  const labelClearanceThreshold = qualityProfile === 'showcase' ? 4 : 2;
106
- const labelRouteMeasurements = collectLabelRouteClearance({
191
+ const labelRouteMeasurements = subpathLabelClearance({
107
192
  labels: relationshipLabels,
108
193
  routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
109
194
  threshold: Number.MAX_VALUE,
110
195
  });
111
- const labelRouteClearance = collectLabelRouteClearance({
196
+ const labelRouteClearance = subpathLabelClearance({
112
197
  labels: relationshipLabels,
113
198
  routedRelations: arrows.map((arrow) => ({ relation: arrow, relationIndex: arrow.index, points: arrow.routePoints })),
114
199
  threshold: labelClearanceThreshold,
115
200
  });
201
+ // The renderers bound their own label rects, but `check` also re-measures an
202
+ // artifact it did not produce; see collectLabelCanvasOverflow in
203
+ // shared/geometry.mjs.
204
+ const labelCanvasOverflow = collectLabelCanvasOverflow({
205
+ labels: relationshipLabels,
206
+ viewBox: viewBoxRect(svgAttrs),
207
+ });
116
208
  const crossingIsError = qualityProfile === 'showcase';
117
209
  const corridorIsError = qualityProfile === 'showcase';
118
210
  const rhythmIsError = qualityProfile === 'showcase';
119
211
  const labelClearanceIsError = qualityProfile === 'showcase';
212
+ const labelContainmentIsError = qualityProfile === 'showcase';
120
213
  const desktopReadabilityIsError = qualityProfile === 'showcase';
214
+ // Certain geometry, but new: surface it as evidence first (CONTRIBUTING.md#product-and-compatibility-contracts).
215
+ const viewportHeightIsError = false;
121
216
  const compositionErrors = (qualityGatesEnforced ? containerBorderRuns.length : 0)
122
217
  + (crossingIsError ? relationshipCrossings.length : 0)
123
- + (corridorIsError ? ambiguousCorridors.length : 0)
218
+ + (corridorIsError ? ambiguousCorridors.length + arrowheadCollisions.length : 0)
124
219
  + (labelClearanceIsError ? labelRouteClearance.length : 0)
220
+ + (labelContainmentIsError ? labelCanvasOverflow.length : 0)
125
221
  + (rhythmIsError ? routeRhythmIssues.length : 0)
126
- + (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0);
222
+ + (desktopReadabilityIsError && desktopReadabilityIssue ? 1 : 0)
223
+ + (viewportHeightIsError && viewportHeightIssue ? 1 : 0);
127
224
  const compositionWarnings = (qualityGatesEnforced ? 0 : containerBorderRuns.length)
128
225
  + (crossingIsError ? 0 : relationshipCrossings.length)
129
- + (corridorIsError ? 0 : ambiguousCorridors.length)
226
+ + (corridorIsError ? 0 : ambiguousCorridors.length + arrowheadCollisions.length)
130
227
  + (labelClearanceIsError ? 0 : labelRouteClearance.length)
228
+ + (labelContainmentIsError ? 0 : labelCanvasOverflow.length)
131
229
  + (rhythmIsError ? 0 : routeRhythmIssues.length)
132
- + (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1);
230
+ + (desktopReadabilityIsError || !desktopReadabilityIssue ? 0 : 1)
231
+ + (viewportHeightIsError || !viewportHeightIssue ? 0 : 1);
133
232
  composition = {
134
233
  schemaVersion: 1,
135
234
  profile: qualityProfile,
@@ -140,17 +239,48 @@ if (svgMatches.length === 1) {
140
239
  },
141
240
  metrics: {
142
241
  properCrossings: relationshipCrossings.length,
242
+ resolvedCrossovers: resolvedCrossovers.length,
143
243
  ambiguousCorridors: ambiguousCorridors.length,
244
+ arrowheadCollisions: arrowheadCollisions.length,
144
245
  containerBorderRuns: containerBorderRuns.length,
145
246
  labelRouteClearanceIssues: labelRouteClearance.length,
146
- minLabelRouteClearance: labelRouteMeasurements.length
147
- ? Math.round(Math.min(...labelRouteMeasurements.map((hit) => hit.clearance)) * 10) / 10
148
- : null,
247
+ labelCanvasOverflowIssues: labelCanvasOverflow.length,
248
+ minLabelRouteClearance: minimumLabelRouteClearance(labelRouteMeasurements),
149
249
  desktopReadabilityIssues: desktopReadabilityIssue ? 1 : 0,
150
- minProjectedNodeTextPx: desktopReadabilityIssue?.projectedFontPx ?? null,
250
+ viewportHeightIssues: viewportHeightIssue ? 1 : 0,
251
+ minProjectedNodeTextPx: desktopReadability.evidence.minimumProjectedTextPx,
151
252
  ...roundedRouteMetrics(routeMetrics),
152
253
  },
153
254
  suggestedLimits: { bendsPerRelationship: 2, stretch: 1.35, segmentPx: 16, microSegmentPx: 8 },
255
+ // Perceptual review needs the affected relationships, not just a total.
256
+ // These are review evidence, not new pass/fail thresholds: a short, clear
257
+ // crossover can be preferable to a long crossing-free detour.
258
+ routeReview: {
259
+ crossings: resolvedCrossovers.map((hit) => {
260
+ const shared = [hit.left.from, hit.left.to].find((id) => id && (id === hit.right.from || id === hit.right.to));
261
+ return {
262
+ left: relationshipRecord(hit.left),
263
+ right: relationshipRecord(hit.right),
264
+ point: hit.point,
265
+ ...(shared ? { sharedNode: shared } : {}),
266
+ };
267
+ }),
268
+ ...(crowdedSides.length ? { crowdedSides } : {}),
269
+ detours: routedRelationships.flatMap((entry) => {
270
+ const metrics = subpathBudgetMetrics([entry]);
271
+ const blockers = directCorridorBlockers(entry.relation, nodeRects);
272
+ return metrics.routesOverSuggestedBends || metrics.routesOverSuggestedStretch ? [{
273
+ relationship: relationshipRecord(entry.relation),
274
+ bends: metrics.maxBends,
275
+ stretch: metrics.maxStretch == null ? null : Math.round(metrics.maxStretch * 1000) / 1000,
276
+ ...(blockers.length ? { directCorridorBlockers: blockers } : {}),
277
+ }] : [];
278
+ }),
279
+ },
280
+ leadingSpace,
281
+ ...(sequenceColumnSpace ? { sequenceColumnSpace } : {}),
282
+ ...(sequenceNoteBorders.length ? { sequenceNoteBorders } : {}),
283
+ desktopReadability: desktopReadability.evidence,
154
284
  issues: [
155
285
  ...containerBorderRuns.map((hit) => ({
156
286
  severity: qualityGatesEnforced ? 'error' : 'warning',
@@ -177,6 +307,20 @@ if (svgMatches.length === 1) {
177
307
  from: hit.start.map((value) => Math.round(value * 10) / 10),
178
308
  to: hit.end.map((value) => Math.round(value * 10) / 10),
179
309
  })),
310
+ ...labelCanvasOverflow.map((hit) => {
311
+ const label = hit.label?.label || hit.relation?.label || '';
312
+ return {
313
+ severity: labelContainmentIsError ? 'error' : 'warning',
314
+ code: 'composition/label-canvas-containment',
315
+ label,
316
+ relationship: relationshipRecord(hit.relation),
317
+ labelRect: roundedRect(hit.rect),
318
+ viewBox: hit.viewBox,
319
+ viewBoxOrigin: hit.viewBoxOrigin,
320
+ overflowPx: hit.overflowPx,
321
+ detail: `[composition/label-canvas-containment] ${qualityProfile} label "${label}" on ${relationshipName(hit.relation)} extends past the ${describeLabelCanvasOverflow(hit)} (label rect ${formatRect(hit.rect)}; viewBox ${hit.viewBox[0]}x${hit.viewBox[1]}${hit.viewBoxOrigin.some(Boolean) ? ` at ${hit.viewBoxOrigin[0]},${hit.viewBoxOrigin[1]}` : ''}) — use renderer-supported label controls (shorten the label or reorder participants for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or enlarge meta.viewBox.`,
322
+ };
323
+ }),
180
324
  ...relationshipCrossings.map((hit) => ({
181
325
  severity: crossingIsError ? 'error' : 'warning',
182
326
  code: 'composition/proper-crossing',
@@ -195,6 +339,15 @@ if (svgMatches.length === 1) {
195
339
  from: hit.overlapStart.map((value) => Math.round(value * 10) / 10),
196
340
  to: hit.overlapEnd.map((value) => Math.round(value * 10) / 10),
197
341
  })),
342
+ ...arrowheadCollisions.map((hit) => ({
343
+ severity: corridorIsError ? 'error' : 'warning',
344
+ code: 'composition/arrowhead-collision',
345
+ relationship: relationshipRecord(hit.left.relation),
346
+ otherRelationship: relationshipRecord(hit.right.relation),
347
+ distancePx: hit.distance,
348
+ minimumPx: hit.minimum,
349
+ endpoints: [hit.left.tip, hit.right.tip],
350
+ })),
198
351
  ...routeRhythmIssues.map((hit) => ({
199
352
  severity: rhythmIsError ? 'error' : 'warning',
200
353
  code: hit.code,
@@ -208,9 +361,16 @@ if (svgMatches.length === 1) {
208
361
  ...(desktopReadabilityIssue ? [{
209
362
  severity: desktopReadabilityIsError ? 'error' : 'warning',
210
363
  code: 'composition/desktop-readability',
364
+ ...(desktopReadabilityIssue.nodeId ? { nodeId: desktopReadabilityIssue.nodeId } : {}),
365
+ owner: desktopReadabilityIssue.owner,
211
366
  viewportWidth: DESKTOP_READABILITY_VIEWPORT.width,
212
367
  viewportHeight: DESKTOP_READABILITY_VIEWPORT.height,
213
- availableDiagramWidth: DESKTOP_READER_DIAGRAM_WIDTH,
368
+ availableDiagramWidth: desktopReadability.evidence.availableDiagramWidth,
369
+ budgetBasis: desktopReadability.evidence.budgetBasis,
370
+ readerContract: desktopReadability.evidence.readerContract,
371
+ requestedTargetPx: desktopReadability.evidence.requestedTargetPx,
372
+ requestedTargetMet: desktopReadability.evidence.requestedTargetMet,
373
+ budgetLimit: desktopReadability.evidence.limit,
214
374
  viewBoxWidth: desktopReadabilityIssue.viewBoxWidth,
215
375
  scale: desktopReadabilityIssue.scale,
216
376
  text: desktopReadabilityIssue.text,
@@ -219,13 +379,22 @@ if (svgMatches.length === 1) {
219
379
  projectedFontPx: desktopReadabilityIssue.projectedFontPx,
220
380
  minimumProjectedFontPx: MIN_PROJECTED_NODE_TEXT_PX,
221
381
  }] : []),
382
+ ...(viewportHeightIssue ? [{
383
+ severity: viewportHeightIsError ? 'error' : 'warning',
384
+ code: 'composition/viewport-height',
385
+ readerFit: svgAttrs['data-reader-fit'] || null,
386
+ viewBoxWidth: viewBoxSize(svgAttrs)[0],
387
+ viewBoxHeight: viewBoxSize(svgAttrs)[1],
388
+ ...viewportHeightIssue,
389
+ detail: `[composition/viewport-height] ${describeFixedWidthOverflow({ ...viewportHeightIssue, viewBoxWidth: viewBoxSize(svgAttrs)[0], viewBoxHeight: viewBoxSize(svgAttrs)[1] })}`,
390
+ }] : []),
222
391
  ],
223
392
  };
224
393
  addCheck(
225
394
  'label_route_clearance',
226
395
  !labelClearanceIsError || labelRouteClearance.length === 0,
227
396
  labelRouteClearance.map((hit) => (
228
- `[composition/label-route-clearance] ${qualityProfile} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${relationshipName(hit.labelRelation)} is ${Math.round(hit.clearance * 10) / 10}px from ${relationshipName(hit.otherRelation)} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]${hit.intersectionLength > 0 ? ` with ${Math.round(hit.intersectionLength * 10) / 10}px hidden by the mask` : ''} (minimum ${hit.threshold}px) - use renderer-supported label controls (message y for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or adjust the other relationship route/via/channel.`
397
+ `[composition/label-route-clearance] ${qualityProfile} label "${hit.label?.label || hit.labelRelation?.label || ''}" on ${relationshipName(hit.labelRelation)} is ${Math.round(hit.clearance * 10) / 10}px from ${relationshipName(hit.otherRelation)} segment ${hit.segmentIndex} [${formatPoint(hit.start)}] -> [${formatPoint(hit.end)}]${hit.intersectionLength > 0 ? ` with ${Math.round(hit.intersectionLength * 10) / 10}px hidden by the mask` : ''} (minimum ${hit.threshold}px) — use renderer-supported label controls (message y for sequence; otherwise labelAt, labelDx, labelDy, or labelSegment), or adjust the other relationship route/via/channel.`
229
398
  )),
230
399
  );
231
400
  addCheck(
@@ -237,10 +406,12 @@ if (svgMatches.length === 1) {
237
406
  );
238
407
  addCheck(
239
408
  'relationship_corridors',
240
- !corridorIsError || ambiguousCorridors.length === 0,
241
- ambiguousCorridors.map((hit) => (
409
+ !corridorIsError || ambiguousCorridors.length + arrowheadCollisions.length === 0,
410
+ [...ambiguousCorridors.map((hit) => (
242
411
  `[composition/ambiguous-corridor] ${qualityProfile} ${relationshipName(hit.left.relation)} shares a ${Math.round(hit.overlapLength * 10) / 10}px corridor with ${relationshipName(hit.right.relation)} at [${formatPoint(hit.overlapStart)}] -> [${formatPoint(hit.overlapEnd)}]`
243
- )),
412
+ )), ...arrowheadCollisions.map((hit) => (
413
+ `[composition/arrowhead-collision] ${qualityProfile} ${relationshipName(hit.left.relation)} and ${relationshipName(hit.right.relation)} have incoming arrowheads ${hit.distance}px apart (minimum ${hit.minimum}px) — enlarge or reposition the destination, or choose separate toSide ports.`
414
+ ))],
244
415
  );
245
416
  addCheck(
246
417
  'container_border_runs',
@@ -271,35 +442,88 @@ if (svgMatches.length === 1) {
271
442
  }
272
443
  }
273
444
 
445
+ if (pathDiagnostics.length) addCheck('svg_path_data', false, pathDiagnostics.map(item => item.message));
446
+
274
447
  const ok = checks.every((check) => check.ok) && composition.status !== 'fail';
275
- console.log(JSON.stringify({ ok, file: htmlPath, checks, composition }, null, 2));
448
+ console.log(JSON.stringify({ ok, file: htmlPath, artifact, checks, composition,
449
+ ...(pathDiagnostics.length ? { diagnostics: pathDiagnostics } : {}),
450
+ }, null, 2));
276
451
  // Let pending stdout writes drain: large receipts are asynchronous when piped.
277
452
  process.exitCode = ok ? 0 : 1;
278
453
 
279
- function collectArrows(fragment) {
454
+ function collectArrows(fragment, useActualPoints = false) {
280
455
  const arrows = [];
281
456
  let index = 0;
457
+ let pathIndex = 0;
458
+ let previousTag = null;
459
+ let previousTagEnd = 0;
282
460
 
283
- for (const tag of fragment.matchAll(/<(path|line)\b[^>]*>/gi)) {
461
+ for (const tag of fragment.matchAll(SVG_TAG_TOKEN)) {
462
+ const name = tag[2]?.toLowerCase();
463
+ if (tag[1] || !['path', 'line'].includes(name)) continue;
464
+ if (name === 'path') pathIndex += 1;
465
+ const tagStart = tag.index;
284
466
  const raw = tag[0];
467
+ const gap = previousTag ? fragment.slice(previousTagEnd, tagStart) : '';
468
+ const precedingUnderlay = previousTag
469
+ && /^\s*$/.test(gap)
470
+ && previousTag.name === 'path'
471
+ && previousTag.underlayAttrs;
472
+ previousTag = {
473
+ name: name,
474
+ underlayAttrs: name === 'path' && AUTOMATIC_CROSSOVER_UNDERLAY_TAG.test(raw)
475
+ ? parseAttrs(raw)
476
+ : null,
477
+ };
478
+ previousTagEnd = tagStart + raw.length;
285
479
  if (!/\bclass="[^"]*\ba-(?:default|emphasis|security|dashed)\b/.test(raw)) continue;
286
480
  if (!/\bmarker-end=/.test(raw)) continue;
287
481
  const attrs = parseAttrs(raw);
288
- const segments = tag[1].toLowerCase() === 'line'
289
- ? lineSegments(attrs)
290
- : pathSegments(attrs.d || '');
291
- const borderSegments = tag[1].toLowerCase() === 'line'
292
- ? segments
293
- : straightPathSegments(attrs.d || '');
482
+ const routeStrokeWidth = numberAttr(attrs, 'stroke-width');
483
+ const underlayStrokeWidth = precedingUnderlay ? numberAttr(precedingUnderlay, 'stroke-width') : NaN;
484
+ const verifiedCrossoverHalo = attrs['data-composition-crossover'] === 'halo'
485
+ && precedingUnderlay?.d === attrs.d
486
+ && precedingUnderlay?.fill === 'none'
487
+ && precedingUnderlay?.stroke === 'var(--mask)'
488
+ && precedingUnderlay?.['pointer-events'] === 'none'
489
+ && Number.isFinite(routeStrokeWidth)
490
+ && Number.isFinite(underlayStrokeWidth)
491
+ && underlayStrokeWidth >= routeStrokeWidth + 3;
492
+ const line = name === 'line';
493
+ const parsed = line ? null : inspectedPath(attrs, tag.index, pathIndex, 'relationship');
494
+ const segments = line ? lineSegments(attrs) : parsed.subpaths.flatMap(part => part.segments);
495
+ const borderSegments = line ? segments : parsed.subpaths.flatMap(part => part.straightSegments);
496
+ let routeSubpaths = line ? continuousRoutePoints(segments)
497
+ : parsed.subpaths.flatMap(part => continuousRoutePoints(part.straightSegments));
498
+ // Legacy single-path metadata remains supported. It cannot bridge actual moveto boundaries.
499
+ if (!useActualPoints && (line || parsed.subpaths.length === 1)) {
500
+ const authoredPoints = parseRoutePoints(attrs['data-composition-points']);
501
+ if (authoredPoints) routeSubpaths = [authoredPoints];
502
+ }
294
503
  arrows.push({
295
- kind: tag[1].toLowerCase(),
504
+ kind: name,
296
505
  index: index += 1,
297
506
  raw,
507
+ // Trust route intent only for a semantic edge with one visible direct
508
+ // segment. A stale marker on bent/curved geometry cannot waive the gate.
509
+ authoredStraight: attrs['data-composition-route'] === 'straight'
510
+ && Boolean(attrs['data-edge-from'] && attrs['data-edge-to'])
511
+ && segments.length === 1 && borderSegments.length === 1
512
+ && (name === 'line' || /^\s*M\s+[-+\d.eE]+\s+[-+\d.eE]+\s+L\s+[-+\d.eE]+\s+[-+\d.eE]+\s*$/.test(attrs.d || '')),
513
+ crossoverHalo: verifiedCrossoverHalo,
514
+ independentPorts: verifiedCrossoverHalo && attrs['data-composition-independent'] === 'true',
515
+ // Compatibility with first-round exports lacking a root layout contract.
516
+ // Readable-v2's root contract supersedes this narrower automatic-pair rule.
517
+ // This marker never certifies a crossover halo or waives a quality rule.
518
+ automaticWorkflowRoute: attrs['data-composition-routing'] === 'workflow-v2-auto',
519
+ junction: attrs['data-composition-junction'] || null,
520
+ width: routeStrokeWidth,
521
+ variant: raw.match(/\ba-(default|emphasis|security|dashed)\b/)?.[1] || 'default',
522
+ role: attrs['data-edge-role'],
298
523
  segments,
299
524
  borderSegments,
300
- routePoints: parseRoutePoints(attrs['data-composition-points']) || (
301
- borderSegments.length ? [borderSegments[0].start, ...borderSegments.map((segment) => segment.end)] : []
302
- ),
525
+ routePoints: routeSubpaths.length === 1 ? routeSubpaths[0] : [],
526
+ routeSubpaths,
303
527
  from: attrs['data-edge-from'] || attrs['data-composition-edge-from'],
304
528
  to: attrs['data-edge-to'] || attrs['data-composition-edge-to'],
305
529
  id: attrs['data-edge-id'] || attrs['data-composition-edge-id'],
@@ -312,6 +536,138 @@ function collectArrows(fragment) {
312
536
  return arrows;
313
537
  }
314
538
 
539
+ function continuousRoutePoints(segments) {
540
+ const parts = [];
541
+ for (const { start, end } of segments) {
542
+ const previous = parts.at(-1);
543
+ // 非共线 Q 不参与直线预算;其两端也不能被拼接成一条虚构直线。
544
+ if (previous && previous.at(-1)[0] === start[0] && previous.at(-1)[1] === start[1]) {
545
+ previous.push(end);
546
+ } else {
547
+ parts.push([start, end]);
548
+ }
549
+ }
550
+ // 保留没有直线的子路径位置,避免把前一片段误认作语义终点。
551
+ return parts.length ? parts : [[]];
552
+ }
553
+
554
+ function inspectedPath(attrs, offset, pathIndex, role) {
555
+ if (pathAnalyses.has(offset)) return pathAnalyses.get(offset);
556
+ const result = analyzeSvgPath(decodeNumericReferences(attrs.d || ''));
557
+ if (result.ok) {
558
+ pathAnalyses.set(offset, result);
559
+ return result;
560
+ }
561
+ const { code, ...evidence } = result.error;
562
+ const id = attrs.id || attrs['data-edge-id'] || attrs['data-composition-frame-id'];
563
+ pathDiagnostics.push({
564
+ code,
565
+ severity: 'error',
566
+ message: `${role} path ${pathIndex}${id ? ` (${id})` : ''}: ${evidence.reason} at d offset ${evidence.tokenOffset}.`,
567
+ subject: { role, pathIndex, ...(id ? { id } : {}) },
568
+ evidence,
569
+ supportedFixes: [code === 'artifact/svg-path-unsupported'
570
+ ? 'regenerate the inspected relationship or frame using M/L/H/V/Q/Z commands'
571
+ : 'correct the path data at the reported offset or regenerate it from the original diagram'],
572
+ });
573
+ const invalid = { ok: false, subpaths: [] };
574
+ pathAnalyses.set(offset, invalid);
575
+ return invalid;
576
+ }
577
+
578
+ // Keep relationship identity separate from its disconnected drawing pieces.
579
+ // Normalizing each piece also makes evidence segment indices stable across consumers.
580
+ function routeParts(entry) {
581
+ const subpaths = entry.relation.routeSubpaths || [entry.points];
582
+ let segmentOffset = 0;
583
+ return subpaths.flatMap((points, subpathIndex) => {
584
+ const normalized = normalizeRoutePoints(points);
585
+ const part = { ...entry, points: normalized, segmentOffset,
586
+ sourceEndpoint: subpathIndex === 0, targetEndpoint: subpathIndex === subpaths.length - 1 };
587
+ segmentOffset += Math.max(0, normalized.length - 1);
588
+ return normalized.length > 1 ? [part] : [];
589
+ });
590
+ }
591
+
592
+ function subpathBudgetMetrics(entries) {
593
+ if (entries.every(entry => entry.relation.routeSubpaths.length <= 1)) {
594
+ return routeBudgetMetrics({ routedRelations: entries });
595
+ }
596
+ const total = routeBudgetMetrics({ routedRelations: [] });
597
+ for (const entry of entries) {
598
+ const parts = routeParts(entry);
599
+ const metrics = routeBudgetMetrics({ routedRelations: parts });
600
+ metrics.maxBends = parts.reduce((sum, part) => sum + Math.max(0, part.points.length - 2), 0);
601
+ // A relationship may exceed a budget in several pieces; count its identity once.
602
+ metrics.routesOverSuggestedBends = Number(metrics.maxBends > 2);
603
+ metrics.routesOverSuggestedStretch = Number(metrics.routesOverSuggestedStretch > 0);
604
+ for (const [key, value] of Object.entries(metrics)) {
605
+ if (key.startsWith('max')) total[key] = value == null ? total[key] : Math.max(total[key] ?? value, value);
606
+ else if (key.startsWith('min')) total[key] = value == null ? total[key] : Math.min(total[key] ?? value, value);
607
+ else total[key] += value;
608
+ }
609
+ }
610
+ return total;
611
+ }
612
+
613
+ function subpathRhythmIssues(entries) {
614
+ return entries.flatMap(entry => routeParts(entry).flatMap(part => (
615
+ collectRouteRhythmIssues({ routedRelations: [part] }).map(issue => ({
616
+ ...issue, segmentIndex: issue.segmentIndex + part.segmentOffset,
617
+ }))
618
+ )));
619
+ }
620
+
621
+ function subpathCorridors({ routedRelations, ...options }) {
622
+ if (routedRelations.every(entry => entry.relation.routeSubpaths.length <= 1)) {
623
+ return collectAmbiguousCorridors({ routedRelations, ...options });
624
+ }
625
+ const hits = [];
626
+ const parts = routedRelations.map(routeParts);
627
+ for (let left = 0; left < parts.length; left += 1) {
628
+ for (let right = left + 1; right < parts.length; right += 1) {
629
+ let longest;
630
+ for (const a of parts[left]) for (const b of parts[right]) {
631
+ const hit = collectAmbiguousCorridors({ routedRelations: [a, b], ...options })[0];
632
+ if (hit && (!longest || hit.overlapLength > longest.overlapLength + 0.0001)) {
633
+ longest = { ...hit, leftSegment: hit.leftSegment + a.segmentOffset,
634
+ rightSegment: hit.rightSegment + b.segmentOffset };
635
+ }
636
+ }
637
+ if (longest) hits.push(longest);
638
+ }
639
+ }
640
+ return hits;
641
+ }
642
+
643
+ function subpathLabelClearance({ routedRelations, labels, threshold }) {
644
+ if (routedRelations.every(entry => entry.relation.routeSubpaths.length <= 1)) {
645
+ return collectLabelRouteClearance({ routedRelations, labels, threshold });
646
+ }
647
+ // The shared helper deduplicates by relationship identity. Measure each piece
648
+ // independently, then preserve one nearest hit per label/relationship pair.
649
+ const hits = [];
650
+ const seen = new Set();
651
+ for (const entry of routedRelations) {
652
+ const relation = entry.relation;
653
+ const identity = relation.key !== undefined ? `key:${relation.key}`
654
+ : relation.id ? `id:${relation.from || ''}\0${relation.to || ''}\0${relation.id}` : `index:${entry.relationIndex}`;
655
+ if (seen.has(identity) || !routeParts(entry).length) continue;
656
+ seen.add(identity);
657
+ const nearest = new Map();
658
+ for (const part of routeParts(entry)) {
659
+ for (const hit of collectLabelRouteClearance({ routedRelations: [part], labels, threshold })) {
660
+ const current = nearest.get(hit.labelRelationIndex);
661
+ if (!current || hit.clearance < current.clearance) nearest.set(hit.labelRelationIndex, {
662
+ ...hit, segmentIndex: hit.segmentIndex + part.segmentOffset,
663
+ });
664
+ }
665
+ }
666
+ hits.push(...nearest.values());
667
+ }
668
+ return hits;
669
+ }
670
+
315
671
  function collectRelationshipLabelMasks(fragment, arrows) {
316
672
  const labels = [];
317
673
  for (const match of fragment.matchAll(/<g\b[^>]*\bdata-edge-(?:key|id|from)="[^"]*"[^>]*>[\s\S]*?<\/g>/gi)) {
@@ -378,14 +734,24 @@ function parseRoutePoints(value) {
378
734
  return points.length >= 2 && points.every(isPoint) ? points : null;
379
735
  }
380
736
 
381
- function collectRelationshipCrossings(arrows) {
382
- const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length);
737
+ function collectRelationshipCrossings(arrows, includeSharedEndpoints = false) {
738
+ const relationships = arrows.filter((arrow) => arrow.from && arrow.to && arrow.segments.length).map(arrow => ({
739
+ ...arrow,
740
+ // Readable-v2 routePoints come from the visible path, never its metadata.
741
+ // A straight-through via is not a visual endpoint; preserve real bends.
742
+ segments: includeSharedEndpoints ? arrow.routeSubpaths.flatMap(forwardCollinearAnalysisSegments) : arrow.segments,
743
+ }));
383
744
  const crossings = [];
384
745
  for (let leftIndex = 0; leftIndex < relationships.length; leftIndex += 1) {
385
746
  const left = relationships[leftIndex];
386
747
  for (let rightIndex = leftIndex + 1; rightIndex < relationships.length; rightIndex += 1) {
387
748
  const right = relationships[rightIndex];
388
- if ([left.from, left.to].some((id) => id === right.from || id === right.to)) continue;
749
+ // A shared semantic endpoint does not make an interior X a junction
750
+ // when both paths opt into automatic workflow or independent-port checks.
751
+ if ([left.from, left.to].some((id) => id === right.from || id === right.to)
752
+ && !includeSharedEndpoints
753
+ && !(left.independentPorts && right.independentPorts)
754
+ && !(left.automaticWorkflowRoute && right.automaticWorkflowRoute)) continue;
389
755
  let point = null;
390
756
  for (const leftSegment of left.segments) {
391
757
  for (const rightSegment of right.segments) {
@@ -406,6 +772,81 @@ function relationshipName(arrow) {
406
772
  : `relationship "${arrow.from}" -> "${arrow.to}"`;
407
773
  }
408
774
 
775
+ // Review evidence only. Mask rectangles are the visible node bounds emitted by
776
+ // our renderers. Skip transformed ancestry rather than mixing coordinate spaces.
777
+ function collectUntransformedNodeRects(fragment) {
778
+ const groups = [];
779
+ const nodes = new Map();
780
+ for (const token of fragment.matchAll(SVG_TAG_TOKEN)) {
781
+ if (!token[2]) continue;
782
+ const name = token[2].toLowerCase();
783
+ if (name === 'g') {
784
+ if (token[1]) groups.pop();
785
+ else if (!/\/\s*>$/.test(token[0])) groups.push(parseAttrs(token[0]));
786
+ continue;
787
+ }
788
+ if (name !== 'rect' || token[1] || groups.some((group) => group.transform)) continue;
789
+ const attrs = parseAttrs(token[0]);
790
+ const owner = [...groups].reverse().find((group) => group['data-node-id']);
791
+ if (!owner || attrs.transform || !String(attrs.class || '').split(/\s+/).includes('c-mask')) continue;
792
+ const box = ['x', 'y', 'width', 'height'].map((key) => numberAttr(attrs, key));
793
+ if (!box.every(Number.isFinite) || box[2] <= 0 || box[3] <= 0) continue;
794
+ const id = owner['data-node-id'];
795
+ // Ambiguous ownership is not reliable evidence for moving a node.
796
+ nodes.set(id, nodes.has(id) ? null : { id, label: owner['data-node-label'] || id, box });
797
+ }
798
+ return [...nodes.values()].filter(Boolean);
799
+ }
800
+
801
+ function directCorridorBlockers(relation, nodes) {
802
+ const from = nodes.find((node) => node.id === relation.from);
803
+ const to = nodes.find((node) => node.id === relation.to);
804
+ if (!from || !to || from === to) return [];
805
+ const center = ({ box: [x, y, w, h] }) => [x + w / 2, y + h / 2];
806
+ const a = center(from);
807
+ const b = center(to);
808
+ const horizontal = Math.abs(a[1] - b[1]) < 0.01;
809
+ const vertical = Math.abs(a[0] - b[0]) < 0.01;
810
+ if (horizontal === vertical) return [];
811
+ const axis = horizontal ? 0 : 1;
812
+ const cross = 1 - axis;
813
+ const [first, last] = a[axis] < b[axis] ? [from, to] : [to, from];
814
+ const low = first.box[axis] + first.box[axis + 2];
815
+ const high = last.box[axis];
816
+ if (high <= low) return [];
817
+ return nodes.filter((node) => node !== from && node !== to
818
+ && node.box[axis] < high && node.box[axis] + node.box[axis + 2] > low
819
+ && node.box[cross] < a[cross] && node.box[cross] + node.box[cross + 2] > a[cross]);
820
+ }
821
+
822
+ // Automatic ports need a 16px corner gutter and 14px between neighbours, so a
823
+ // side facing more counterparts than that fits pushes routes onto other sides.
824
+ function crowdedNodeSides(arrows, nodes) {
825
+ const byId = new Map(nodes.map((node) => [node.id, node]));
826
+ const demand = new Map();
827
+ for (const arrow of arrows) {
828
+ const from = byId.get(arrow.from);
829
+ const to = byId.get(arrow.to);
830
+ if (!from || !to || from === to) continue;
831
+ for (const [node, other] of [[from, to], [to, from]]) {
832
+ const [x, y, w, h] = node.box;
833
+ const dx = other.box[0] + other.box[2] / 2 - (x + w / 2);
834
+ const dy = other.box[1] + other.box[3] / 2 - (y + h / 2);
835
+ const side = dx !== 0 && Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : (dy > 0 ? 'bottom' : 'top');
836
+ const key = `${node.id}\u0000${side}`;
837
+ const entry = demand.get(key) || { node, side, relationships: 0 };
838
+ entry.relationships += 1;
839
+ demand.set(key, entry);
840
+ }
841
+ }
842
+ return [...demand.values()].flatMap(({ node, side, relationships }) => {
843
+ const sidePx = side === 'left' || side === 'right' ? node.box[3] : node.box[2];
844
+ const neededPx = 32 + 14 * (relationships - 1);
845
+ return relationships > 1 && sidePx < neededPx
846
+ ? [{ node: node.id, label: node.label, side, relationships, sidePx, neededPx }] : [];
847
+ });
848
+ }
849
+
409
850
  function relationshipRecord(arrow) {
410
851
  const stableIndex = Number(arrow.key);
411
852
  return {
@@ -420,12 +861,16 @@ function relationshipRecord(arrow) {
420
861
 
421
862
  function collectCompositionFrames(fragment) {
422
863
  const frames = [];
423
- for (const match of fragment.matchAll(/<(rect|path|line)\b[^>]*>/gi)) {
864
+ let pathIndex = 0;
865
+ for (const match of fragment.matchAll(SVG_TAG_TOKEN)) {
866
+ const name = match[2]?.toLowerCase();
867
+ if (match[1] || !['rect', 'path', 'line'].includes(name)) continue;
868
+ if (name === 'path') pathIndex += 1;
424
869
  const attrs = parseAttrs(match[0]);
425
870
  const kind = attrs['data-composition-frame-kind'];
426
871
  if (!kind) continue;
427
872
  const identity = attrs['data-composition-frame-id'] || frames.length;
428
- if (match[1].toLowerCase() === 'rect') {
873
+ if (name === 'rect') {
429
874
  const frame = {
430
875
  kind,
431
876
  id: identity,
@@ -438,9 +883,9 @@ function collectCompositionFrames(fragment) {
438
883
  if ([frame.x, frame.y, frame.width, frame.height].every(Number.isFinite)) frames.push(frame);
439
884
  continue;
440
885
  }
441
- const segments = match[1].toLowerCase() === 'line'
886
+ const segments = name === 'line'
442
887
  ? lineSegments(attrs)
443
- : pathSegments(attrs.d || '');
888
+ : inspectedPath(attrs, match.index, pathIndex, 'structural-frame').subpaths.flatMap(part => part.segments);
444
889
  for (const [segmentIndex, segment] of segments.entries()) {
445
890
  frames.push({
446
891
  kind,
@@ -454,6 +899,207 @@ function collectCompositionFrames(fragment) {
454
899
  return frames;
455
900
  }
456
901
 
902
+ // Evidence for author review only. Automatic Architecture is identifiable by
903
+ // its intrinsic Reader contract; authored canvases must retain their geometry.
904
+ function collectArchitectureLeadingSpace({ svgAttrs, fragment, nodeRects, frames, arrows, labels }) {
905
+ const evidence = { measured: false, reviewSuggested: false };
906
+ if (svgAttrs['data-reader-fit'] !== 'intrinsic-height'
907
+ || svgAttrs['data-reader-primary-text'] !== '14'
908
+ || svgAttrs.transform
909
+ || [...fragment.matchAll(/<g\b[^>]*\btransform\s*=[^>]*>/gi)]
910
+ .some((match) => !/\bdata-semantic-sigil=/.test(match[0]))
911
+ || /<(?:path|line|rect|text)\b[^>]*\btransform\s*=/i.test(fragment)) return evidence;
912
+ const [originX, originY, width, height] = viewBoxRect(svgAttrs);
913
+ if (![originX, originY, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return evidence;
914
+ const nodeCount = [...fragment.matchAll(/<g\b[^>]*\bdata-node-id=/gi)].length;
915
+ if (!nodeRects.length || nodeRects.length !== nodeCount) return evidence;
916
+ const semanticArrows = arrows.filter((arrow) => arrow.from && arrow.to);
917
+ if (semanticArrows.some((arrow) => !arrow.routeSubpaths.some(points => points.length))) return evidence;
918
+
919
+ const occupied = nodeRects.map((node) => node.box[1]);
920
+ for (const frame of frames) {
921
+ const top = frame.shape === 'line'
922
+ ? Math.min(frame.start[1], frame.end[1]) : frame.y;
923
+ if (!Number.isFinite(top)) return evidence;
924
+ occupied.push(top);
925
+ }
926
+ // Boundary titles can protrude above their structural frame.
927
+ for (const match of fragment.matchAll(/<g\b[^>]*\bdata-graph-role="structural-frame-label"[^>]*>[\s\S]*?<rect\b[^>]*\bdata-graph-role="structural-frame-label-mask"[^>]*>/gi)) {
928
+ const rect = parseAttrs(match[0].match(/<rect\b[^>]*>/i)?.[0] || '');
929
+ const y = numberAttr(rect, 'y');
930
+ if (!Number.isFinite(y)) return evidence;
931
+ occupied.push(y);
932
+ }
933
+ for (const arrow of semanticArrows) {
934
+ for (const points of arrow.routeSubpaths) for (const point of points) occupied.push(point[1]);
935
+ }
936
+ for (const label of labels) occupied.push(label.rect.y);
937
+ if (!occupied.every(Number.isFinite)) return evidence;
938
+ const occupiedTop = Math.min(...occupied);
939
+ const gap = Math.max(0, occupiedTop - originY);
940
+ const heights = nodeRects.map((node) => node.box[3]).sort((a, b) => a - b);
941
+ const typicalNodeHeight = heights[Math.floor(heights.length / 2)];
942
+ const ratio = gap / height;
943
+ return {
944
+ measured: true,
945
+ emptyTopPx: Math.round(gap * 10) / 10,
946
+ emptyTopRatio: Math.round(ratio * 1000) / 1000,
947
+ occupiedTop: Math.round(occupiedTop * 10) / 10,
948
+ viewBoxTop: originY,
949
+ canvasHeight: height,
950
+ typicalNodeHeight,
951
+ reviewSuggested: gap > 2 * typicalNodeHeight && ratio > 0.2,
952
+ };
953
+ }
954
+
955
+ // Advisory only: fixed columns are a compatibility contract. Measure semantic
956
+ // content, including long labels/notes, rather than treating every wide canvas
957
+ // as wasted space. Auto-sized segment frames do not add a participant column.
958
+ function collectSequenceColumnSpace({ svgAttrs, fragment, nodeRects, arrows }) {
959
+ const columnFit = svgAttrs['data-sequence-column-fit'];
960
+ if (!['fixed', 'spread'].includes(columnFit)) return null;
961
+ const evidence = { measured: false, reviewSuggested: false, columnFit };
962
+ if (svgAttrs.transform) return evidence;
963
+ // Brand badges stay inside their participant box. Ignore only that subtree's
964
+ // transforms, including the preset path or nested fallback icon's scale.
965
+ const brandGroups = [];
966
+ for (const token of fragment.matchAll(SVG_TAG_TOKEN)) {
967
+ if (!token[2]) continue;
968
+ const name = token[2].toLowerCase();
969
+ if (token[1]) {
970
+ if (name === 'g') brandGroups.pop();
971
+ continue;
972
+ }
973
+ const attrs = parseAttrs(token[0]);
974
+ const inBrand = brandGroups.at(-1) === true || (name === 'g'
975
+ && Boolean(attrs['data-brand-mark'])
976
+ && String(attrs.class || '').split(/\s+/).includes('brand-mark'));
977
+ if (!inBrand && attrs.transform && ['g', 'path', 'line', 'rect', 'text'].includes(name)
978
+ && !(name === 'g' && attrs['data-semantic-sigil'])) return evidence;
979
+ if (name === 'g' && !/\/\s*>$/.test(token[0])) brandGroups.push(inBrand);
980
+ }
981
+ const [originX, , width, height] = viewBoxRect(svgAttrs);
982
+ if (![originX, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return evidence;
983
+ const nodeCount = [...fragment.matchAll(/<g\b[^>]*\bdata-node-id=/gi)].length;
984
+ if (!nodeRects.length || nodeCount !== nodeRects.length) return evidence;
985
+ const semanticArrows = arrows.filter((arrow) => arrow.from && arrow.to);
986
+ if (semanticArrows.some((arrow) => !arrow.routeSubpaths.some(points => points.length))) return evidence;
987
+ const rightEdges = nodeRects.map((node) => node.box[0] + node.box[2]);
988
+ for (const arrow of semanticArrows) {
989
+ for (const points of arrow.routeSubpaths) for (const point of points) rightEdges.push(point[0]);
990
+ }
991
+ // Label plates, activations and segment titles also reserve horizontal room.
992
+ for (const match of fragment.matchAll(/<rect\b[^>]*>/gi)) {
993
+ const attrs = parseAttrs(match[0]);
994
+ if (!String(attrs.class || '').split(/\s+/).includes('c-mask')) continue;
995
+ rightEdges.push(numberAttr(attrs, 'x') + numberAttr(attrs, 'width'));
996
+ }
997
+ for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
998
+ const attrs = parseAttrs(match[1]);
999
+ // A wrapped note places each line in a <tspan> with its own x. Any other
1000
+ // line offset is not measured here.
1001
+ const lines = /<tspan\b/i.test(match[2])
1002
+ ? [...match[2].matchAll(/<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/gi)]
1003
+ .map((line) => ({ attrs: { ...attrs, ...parseAttrs(line[1]) }, text: stripTags(line[2]).trim() }))
1004
+ : [{ attrs, text: stripTags(match[2]).trim() }];
1005
+ if (!lines.length || lines.some((line) => line.attrs.dx !== undefined)) return evidence;
1006
+ for (const line of lines) {
1007
+ const box = textBox(line.attrs, line.text);
1008
+ if (!box) return evidence;
1009
+ rightEdges.push(box.x2);
1010
+ }
1011
+ }
1012
+ if (!rightEdges.every(Number.isFinite)) return evidence;
1013
+ const occupiedRight = Math.max(...rightEdges);
1014
+ const gap = Math.max(0, originX + width - occupiedRight);
1015
+ const ratio = gap / width;
1016
+ const widths = nodeRects.map((node) => node.box[2]).sort((a, b) => a - b);
1017
+ const typicalParticipantWidth = widths[Math.floor(widths.length / 2)];
1018
+ return {
1019
+ measured: true,
1020
+ columnFit,
1021
+ participantCount: nodeCount,
1022
+ occupiedRight: Math.round(occupiedRight * 10) / 10,
1023
+ viewBoxLeft: originX,
1024
+ canvasWidth: width,
1025
+ emptyRightPx: Math.round(gap * 10) / 10,
1026
+ emptyRightRatio: Math.round(ratio * 1000) / 1000,
1027
+ typicalParticipantWidth,
1028
+ // Avoid stretching a small conversation merely to fill its canvas. These
1029
+ // conservative review thresholds never contribute errors or warnings.
1030
+ reviewSuggested: columnFit === 'fixed' && nodeCount >= 4
1031
+ && gap > 2 * typicalParticipantWidth && ratio > 0.25,
1032
+ };
1033
+ }
1034
+
1035
+ // Sequence notes are direct text children of a semantic message group; message
1036
+ // labels use a nested group. Inspect the emitted lines without changing layout.
1037
+ // This is advisory: phase ownership still belongs to the author.
1038
+ function collectSequenceNoteBorders({ svgAttrs, fragment, frames, arrows }) {
1039
+ if (!svgAttrs['data-sequence-column-fit'] || svgAttrs.transform) return [];
1040
+ const segments = frames.filter(frame => frame.kind === 'segment' && !frame.shape);
1041
+ if (!segments.length) return [];
1042
+ const issues = [], stack = [];
1043
+ for (const tag of fragment.matchAll(SVG_TAG_TOKEN)) {
1044
+ const name = tag[2]?.toLowerCase();
1045
+ if (name === 'g') {
1046
+ if (tag[1]) stack.pop();
1047
+ else if (!tag[0].endsWith('/>')) stack.push(parseAttrs(tag[0]));
1048
+ continue;
1049
+ }
1050
+ if (name === 'rect' && !tag[1]) {
1051
+ const attrs = parseAttrs(tag[0]);
1052
+ if (attrs['data-composition-frame-kind'] === 'segment' && (attrs.transform || stack.some(group => group.transform))) return [];
1053
+ }
1054
+ if (name !== 'text' || tag[1] || stack.some(attrs => attrs.transform)) continue;
1055
+ const owner = stack.at(-1), attrs = parseAttrs(tag[0]);
1056
+ if (!owner?.['data-edge-key'] || !['context', 'fine'].includes(attrs['data-detail']) || attrs.transform || attrs.rotate || attrs.dx || attrs.dy) continue;
1057
+ const index = Number(owner['data-edge-key']);
1058
+ if (!Number.isSafeInteger(index) || index < 0) continue;
1059
+ const arrow = arrows[index], arrowY = arrow?.routePoints?.[0]?.[1];
1060
+ if (!Number.isFinite(arrowY)) continue;
1061
+ const end = fragment.indexOf('</text>', tag.index + tag[0].length);
1062
+ if (end < 0) continue;
1063
+ const content = fragment.slice(tag.index + tag[0].length, end);
1064
+ let baseline = numberAttr(attrs, 'y');
1065
+ const boxes = [];
1066
+ const spans = [...content.matchAll(/<tspan\b([^>]*)>([\s\S]*?)<\/tspan>/gi)];
1067
+ let supported = true;
1068
+ for (const span of spans.length ? spans : [[null, '', content]]) {
1069
+ const line = parseAttrs(span[1]);
1070
+ if (line.y !== undefined || line.dx !== undefined || line.rotate !== undefined || line.transform !== undefined) { supported = false; break; }
1071
+ baseline += Number(line.dy || 0);
1072
+ const box = textBox({ ...attrs, ...line, y: String(baseline) }, stripTags(span[2]));
1073
+ if (!box || ![box.x1, box.x2, box.y1, box.y2].every(Number.isFinite)) { supported = false; break; }
1074
+ boxes.push(box);
1075
+ }
1076
+ if (!supported || !boxes.length) continue;
1077
+ const note = { x: Math.min(...boxes.map(b => b.x1)), y: Math.min(...boxes.map(b => b.y1)) };
1078
+ note.width = Math.max(...boxes.map(b => b.x2)) - note.x;
1079
+ note.height = Math.max(...boxes.map(b => b.y2)) - note.y;
1080
+ const bottom = note.y + note.height;
1081
+ for (const frame of segments) {
1082
+ const border = frame.y + frame.height;
1083
+ if (!(arrowY >= frame.y && arrowY < border && note.y < border && bottom > border + 0.5)) continue;
1084
+ const requiredTo = Math.ceil(bottom + 4);
1085
+ // A single-field fix may not swallow another message or stage, or push
1086
+ // the frame beyond an authored canvas. Otherwise ask for a joint review.
1087
+ const safe = /^\d+$/.test(String(frame.id)) && requiredTo <= viewBoxRect(svgAttrs)[1] + viewBoxRect(svgAttrs)[3] - 45
1088
+ && !arrows.some(other => other.routePoints.some(point => point[1] > border - 4 && point[1] < requiredTo + 4))
1089
+ && !segments.some(other => other !== frame && other.y < requiredTo && other.y + other.height > border);
1090
+ const segmentPath = `/segments/${frame.id}/to`;
1091
+ issues.push({
1092
+ code: 'sequence/note-segment-border', reviewSuggested: true,
1093
+ message: `Note on message "${owner['data-edge-label'] || owner['data-edge-id'] || index}" ends at y=${Math.ceil(bottom)}, crossing its segment bottom y=${border}. ${safe ? `Set ${segmentPath} to ${requiredTo} to keep the full note inside.` : 'Review the note and neighbouring phase boundaries together; keep the full note and message order.'}`,
1094
+ subject: { diagramType: 'sequence', path: `/messages/${index}/note` },
1095
+ evidence: { note, lines: boxes.length, messageY: arrowY, segment: { path: segmentPath, from: frame.y, to: border }, requiredTo },
1096
+ supportedFixes: safe ? [`set ${segmentPath} to ${requiredTo}`] : [],
1097
+ });
1098
+ }
1099
+ }
1100
+ return issues;
1101
+ }
1102
+
457
1103
  function frameName(frame) {
458
1104
  return `${frame.kind || 'frame'} "${frame.id}"`;
459
1105
  }
@@ -473,89 +1119,8 @@ function lineSegments(attrs) {
473
1119
  return [{ start, end }];
474
1120
  }
475
1121
 
476
- function pathSegments(d) {
477
- const points = pointsFromPath(d);
478
- const segments = [];
479
- for (let i = 1; i < points.length; i += 1) {
480
- segments.push({ start: points[i - 1], end: points[i] });
481
- }
482
- return segments;
483
- }
484
-
485
- // Border runs use exact visible primitives. Non-collinear Q curves are never
486
- // flattened into chords here: a tangent or sampled near-horizontal curve is
487
- // not a structural border run. A fully collinear Q remains a straight visible
488
- // primitive and is included.
489
- function straightPathSegments(d) {
490
- const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
491
- const segments = [];
492
- let i = 0;
493
- let command = '';
494
- let current = [0, 0];
495
- let start = null;
496
- while (i < tokens.length) {
497
- if (isCommand(tokens[i])) command = tokens[i++];
498
- if (!command) break;
499
- const absolute = command === command.toUpperCase();
500
- switch (command.toUpperCase()) {
501
- case 'M':
502
- case 'L': {
503
- let first = true;
504
- while (i + 1 < tokens.length && !isCommand(tokens[i])) {
505
- const point = [Number.parseFloat(tokens[i++]), Number.parseFloat(tokens[i++])];
506
- if (!point.every(Number.isFinite)) break;
507
- const next = absolute ? point : [current[0] + point[0], current[1] + point[1]];
508
- if (command.toUpperCase() === 'L' || !first) segments.push({ start: current, end: next });
509
- current = next;
510
- if (!start) start = current;
511
- first = false;
512
- }
513
- break;
514
- }
515
- case 'H': {
516
- while (i < tokens.length && !isCommand(tokens[i])) {
517
- const value = Number.parseFloat(tokens[i++]);
518
- if (!Number.isFinite(value)) break;
519
- const next = [absolute ? value : current[0] + value, current[1]];
520
- segments.push({ start: current, end: next });
521
- current = next;
522
- }
523
- break;
524
- }
525
- case 'V': {
526
- while (i < tokens.length && !isCommand(tokens[i])) {
527
- const value = Number.parseFloat(tokens[i++]);
528
- if (!Number.isFinite(value)) break;
529
- const next = [current[0], absolute ? value : current[1] + value];
530
- segments.push({ start: current, end: next });
531
- current = next;
532
- }
533
- break;
534
- }
535
- case 'Q': {
536
- while (i + 3 < tokens.length && !isCommand(tokens[i])) {
537
- const values = [0, 0, 0, 0].map(() => Number.parseFloat(tokens[i++]));
538
- if (!values.every(Number.isFinite)) break;
539
- const control = absolute ? values.slice(0, 2) : [current[0] + values[0], current[1] + values[1]];
540
- const end = absolute ? values.slice(2, 4) : [current[0] + values[2], current[1] + values[3]];
541
- if (Math.abs(crossProduct(current, control, end)) <= 1e-9) segments.push({ start: current, end });
542
- current = end;
543
- }
544
- break;
545
- }
546
- case 'Z':
547
- if (start) segments.push({ start: current, end: start });
548
- current = start || current;
549
- command = '';
550
- break;
551
- default:
552
- return [];
553
- }
554
- }
555
- return segments.filter(({ start: a, end: b }) => isPoint(a) && isPoint(b));
556
- }
557
-
558
1122
  function diagonalStraightSegments(arrow) {
1123
+ if (arrow.authoredStraight) return [];
559
1124
  return arrow.borderSegments.flatMap(({ start, end }, segmentIndex) => (
560
1125
  Math.abs(start[0] - end[0]) > 0.01 && Math.abs(start[1] - end[1]) > 0.01
561
1126
  ? [{ segmentIndex, start, end }]
@@ -579,7 +1144,7 @@ function collectLegendBoxes(fragment) {
579
1144
 
580
1145
  for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
581
1146
  const attrs = parseAttrs(match[1]);
582
- const box = textBox(attrs, stripTags(match[2]).trim());
1147
+ const box = textBox(attrs, stripTags(match[2]).trim(), legendTextWidth);
583
1148
  if (box) boxes.push(box);
584
1149
  }
585
1150
 
@@ -600,12 +1165,20 @@ function collectLegendCollisions(arrows, boxes) {
600
1165
  return collisions;
601
1166
  }
602
1167
 
603
- function textBox(attrs, text) {
1168
+ // The footprint a legend entry reserves is the renderer's own: textUnits() at
1169
+ // TEXT_ADVANCE_EM (renderers/shared/legend.mjs). The default estimate below stays
1170
+ // as it is for the readability projection, which is deliberately conservative.
1171
+ function legendTextWidth(text, fontSize) {
1172
+ const TEXT_ADVANCE_EM = 0.62;
1173
+ return Math.max(fontSize, textUnits(text) * fontSize * TEXT_ADVANCE_EM);
1174
+ }
1175
+
1176
+ function textBox(attrs, text, measure = estimatedTextWidth) {
604
1177
  const x = numberAttr(attrs, 'x');
605
1178
  const y = numberAttr(attrs, 'y');
606
1179
  const fontSize = Number.parseFloat(attrs['font-size'] || '10');
607
1180
  if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(fontSize)) return null;
608
- const width = estimatedTextWidth(text, fontSize);
1181
+ const width = measure(text, fontSize);
609
1182
  const anchor = attrs['text-anchor'] || 'start';
610
1183
  let x1 = x;
611
1184
  if (anchor === 'middle') x1 = x - width / 2;
@@ -619,35 +1192,128 @@ function textBox(attrs, text) {
619
1192
  };
620
1193
  }
621
1194
 
622
- function collectDesktopReadability(svgAttrs, fragment) {
1195
+ // A foreign artifact may author a legal non-zero viewBox origin, so containment
1196
+ // needs all four numbers; sizing checks read the trailing pair.
1197
+ function viewBoxRect(svgAttrs) {
623
1198
  const viewBox = String(svgAttrs.viewBox || '').trim().split(/[\s,]+/).map(Number);
624
- const viewBoxWidth = viewBox.length === 4 ? viewBox[2] : Number.NaN;
625
- if (!Number.isFinite(viewBoxWidth) || viewBoxWidth <= 0) return null;
626
- const scale = Math.min(1, DESKTOP_READER_DIAGRAM_WIDTH / viewBoxWidth);
627
- let worst = null;
628
- for (const match of fragment.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text>/gi)) {
629
- const primary = /\bdata-node-label(?:\s*=|\s|$)/i.test(match[1]);
630
- const boundary = /\bdata-boundary-label(?:\s*=|\s|$)/i.test(match[1]);
631
- const context = /\bdata-detail\s*=\s*"context"/i.test(match[1]);
632
- if (!primary && !boundary && !context) continue;
1199
+ return viewBox.length === 4 ? viewBox : [Number.NaN, Number.NaN, Number.NaN, Number.NaN];
1200
+ }
1201
+
1202
+ function viewBoxSize(svgAttrs) {
1203
+ return viewBoxRect(svgAttrs).slice(2);
1204
+ }
1205
+
1206
+ function hasAttribute(attrs, name) {
1207
+ return new RegExp('(?:^|\\s)' + name + '(?:\\s*=|\\s|$)', 'i').test(attrs);
1208
+ }
1209
+
1210
+ function readerContractFromHtml(source) {
1211
+ const markers = [...source.matchAll(/<meta\b[^>]*>/gi)]
1212
+ .map((match) => parseAttrs(match[0]))
1213
+ .filter((attrs) => attrs.name === 'archify-reader-contract');
1214
+ return markers.length === 1 && markers[0].content === DECLARED_WIDE_READER_CONTRACT
1215
+ ? DECLARED_WIDE_READER_CONTRACT
1216
+ : null;
1217
+ }
1218
+
1219
+ function collectDesktopReadability(svgAttrs, fragment, contract) {
1220
+ const [viewBoxWidth, viewBoxHeight] = viewBoxSize(svgAttrs);
1221
+ const requestedMinimumTextPx = Number.parseFloat(svgAttrs['data-reader-min-text'] || '');
1222
+ const entries = [];
1223
+ let invalidSemanticText = false;
1224
+ const groups = [];
1225
+ // Keep the complete ancestry, rather than a node-only stack: an edge can
1226
+ // share its context group with its label or nest that group (Sequence).
1227
+ for (const match of fragment.matchAll(/<!--[\s\S]*?(?:-->|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>|$)|<text\b([^>]*)>([\s\S]*?)<\/text>|<g\b[^>]*>|<\/g\s*>/gi)) {
1228
+ if (match[0].startsWith('<!')) continue;
1229
+ if (match[1] === undefined) {
1230
+ if (/^<\/g/i.test(match[0])) groups.pop();
1231
+ else if (!/\/\s*>$/.test(match[0])) groups.push(parseAttrs(match[0]));
1232
+ continue;
1233
+ }
633
1234
  const attrs = parseAttrs(match[1]);
1235
+ if (attrs['data-detail'] === 'fine' || groups.some((group) => group['data-detail'] === 'fine')) continue;
1236
+ const primary = hasAttribute(match[1], 'data-node-label');
1237
+ const boundary = hasAttribute(match[1], 'data-boundary-label');
1238
+ const context = attrs['data-detail'] === 'context'
1239
+ || groups.some((group) => group['data-detail'] === 'context');
1240
+ const nodeOwner = [...groups].reverse().find((group) => group['data-node-id']);
1241
+ const edgeOwner = [...groups].reverse().find((group) => group['data-edge-from'] && group['data-edge-to']);
1242
+ const owner = primary
1243
+ ? { kind: 'node', id: nodeOwner?.['data-node-id'] || null }
1244
+ : boundary ? { kind: 'boundary', id: null }
1245
+ : context && edgeOwner ? {
1246
+ kind: 'edge', id: edgeOwner['data-edge-id'] || null,
1247
+ from: edgeOwner['data-edge-from'], to: edgeOwner['data-edge-to'],
1248
+ }
1249
+ : context && nodeOwner ? { kind: 'node', id: nodeOwner['data-node-id'] }
1250
+ : null;
1251
+ // A context text with neither semantic owner is legend/fine/loose copy.
1252
+ if (!owner) continue;
634
1253
  const fontSize = Number.parseFloat(attrs['font-size'] || '');
635
- if (!Number.isFinite(fontSize)) continue;
636
- const projected = projectedNodeTextPx(fontSize, viewBoxWidth);
637
- if (projected >= MIN_PROJECTED_NODE_TEXT_PX) continue;
638
- const candidate = {
639
- viewBoxWidth,
640
- scale,
1254
+ if (!Number.isFinite(fontSize)) {
1255
+ invalidSemanticText = true;
1256
+ continue;
1257
+ }
1258
+ if (fontSize <= 0) invalidSemanticText = true;
1259
+ entries.push({
1260
+ ...(owner.kind === 'node' && owner.id ? { nodeId: owner.id } : {}),
1261
+ owner,
641
1262
  text: stripTags(match[2]).trim(),
642
- detail: primary
643
- ? 'primary'
644
- : boundary ? 'boundary' : 'context',
1263
+ detail: primary ? 'primary' : boundary ? 'boundary' : owner.kind === 'edge' ? 'edge' : 'context',
645
1264
  sourceFontPx: fontSize,
646
- projectedFontPx: projected,
647
- };
648
- if (!worst || candidate.projectedFontPx < worst.projectedFontPx) worst = candidate;
1265
+ });
649
1266
  }
650
- return worst;
1267
+ const minimumSourceTextPx = entries.length ? Math.min(...entries.map((entry) => entry.sourceFontPx)) : Number.NaN;
1268
+ const eligible = contract === DECLARED_WIDE_READER_CONTRACT
1269
+ && ['intrinsic-height', 'width-first'].includes(svgAttrs['data-reader-fit'])
1270
+ && Number.isFinite(requestedMinimumTextPx) && requestedMinimumTextPx > 0
1271
+ && !invalidSemanticText && entries.length > 0;
1272
+ const declared = eligible ? declaredWideReadabilityBudget({
1273
+ viewBoxWidth,
1274
+ viewBoxHeight,
1275
+ minimumSourceTextPx,
1276
+ requestedMinimumTextPx,
1277
+ }) : null;
1278
+ const availableDiagramWidth = declared?.guaranteedSvgWidth ?? DESKTOP_READER_DIAGRAM_WIDTH;
1279
+ const budgetBasis = declared ? 'recognized-declared-wide' : 'legacy-930';
1280
+ const scale = Number.isFinite(viewBoxWidth) && viewBoxWidth > 0
1281
+ ? Math.min(1, availableDiagramWidth / viewBoxWidth) : Number.NaN;
1282
+ const projected = entries.map((entry) => ({
1283
+ ...entry,
1284
+ viewBoxWidth,
1285
+ scale,
1286
+ projectedFontPx: projectedNodeTextPx(entry.sourceFontPx, viewBoxWidth, availableDiagramWidth),
1287
+ }));
1288
+ const worst = projected.reduce((current, entry) => (
1289
+ !current || entry.projectedFontPx < current.projectedFontPx ? entry : current
1290
+ ), null);
1291
+ const projectedMinimumTextPx = Number.isFinite(worst?.projectedFontPx) ? worst.projectedFontPx : null;
1292
+ const hardFloorMet = Number.isFinite(worst?.projectedFontPx)
1293
+ ? worst.projectedFontPx >= MIN_PROJECTED_NODE_TEXT_PX : null;
1294
+ const requestedTargetMet = worst && Number.isFinite(requestedMinimumTextPx)
1295
+ ? worst.projectedFontPx >= requestedMinimumTextPx : null;
1296
+ const evidence = {
1297
+ budgetBasis,
1298
+ readerContract: contract,
1299
+ availableDiagramWidth,
1300
+ actualBudgetPx: availableDiagramWidth,
1301
+ ...(declared ? {
1302
+ actualReaderWidth: declared.actualReaderWidth,
1303
+ desiredReaderWidth: declared.desiredReaderWidth,
1304
+ viewportCap: declared.viewportCap,
1305
+ limit: declared.limit,
1306
+ } : { limit: 'legacy' }),
1307
+ requestedTargetPx: Number.isFinite(requestedMinimumTextPx) ? requestedMinimumTextPx : null,
1308
+ requestedTargetMet,
1309
+ hardFloorPx: MIN_PROJECTED_NODE_TEXT_PX,
1310
+ hardFloorMet,
1311
+ minimumOwner: worst?.owner || null,
1312
+ minimumSourceTextPx: worst?.sourceFontPx ?? null,
1313
+ minimumProjectedTextPx: projectedMinimumTextPx,
1314
+ semanticTextCount: entries.length,
1315
+ };
1316
+ return { evidence, issue: worst && hardFloorMet === false ? worst : null };
651
1317
  }
652
1318
 
653
1319
  function estimatedTextWidth(text, fontSize) {
@@ -656,88 +1322,6 @@ function estimatedTextWidth(text, fontSize) {
656
1322
  return Math.max(fontSize, units * fontSize);
657
1323
  }
658
1324
 
659
- function pointsFromPath(d) {
660
- const tokens = d.match(/[MLHVQZmlhvqz]|[-+]?(?:\d*\.)?\d+(?:e[-+]?\d+)?/g) || [];
661
- const points = [];
662
- let i = 0;
663
- let command = '';
664
- let current = [0, 0];
665
- let start = null;
666
-
667
- while (i < tokens.length) {
668
- if (isCommand(tokens[i])) command = tokens[i++];
669
- if (!command) break;
670
-
671
- const absolute = command === command.toUpperCase();
672
- switch (command.toUpperCase()) {
673
- case 'M':
674
- case 'L': {
675
- while (i + 1 < tokens.length && !isCommand(tokens[i])) {
676
- const x = Number.parseFloat(tokens[i++]);
677
- const y = Number.parseFloat(tokens[i++]);
678
- if (!Number.isFinite(x) || !Number.isFinite(y)) break;
679
- current = absolute ? [x, y] : [current[0] + x, current[1] + y];
680
- points.push(current);
681
- if (!start) start = current;
682
- }
683
- break;
684
- }
685
- case 'H': {
686
- while (i < tokens.length && !isCommand(tokens[i])) {
687
- const x = Number.parseFloat(tokens[i++]);
688
- if (!Number.isFinite(x)) break;
689
- current = absolute ? [x, current[1]] : [current[0] + x, current[1]];
690
- points.push(current);
691
- }
692
- break;
693
- }
694
- case 'V': {
695
- while (i < tokens.length && !isCommand(tokens[i])) {
696
- const y = Number.parseFloat(tokens[i++]);
697
- if (!Number.isFinite(y)) break;
698
- current = absolute ? [current[0], y] : [current[0], current[1] + y];
699
- points.push(current);
700
- }
701
- break;
702
- }
703
- case 'Q': {
704
- while (i + 3 < tokens.length && !isCommand(tokens[i])) {
705
- const controlX = Number.parseFloat(tokens[i++]);
706
- const controlY = Number.parseFloat(tokens[i++]);
707
- const endX = Number.parseFloat(tokens[i++]);
708
- const endY = Number.parseFloat(tokens[i++]);
709
- if (![controlX, controlY, endX, endY].every(Number.isFinite)) break;
710
- const control = absolute
711
- ? [controlX, controlY]
712
- : [current[0] + controlX, current[1] + controlY];
713
- const end = absolute
714
- ? [endX, endY]
715
- : [current[0] + endX, current[1] + endY];
716
- const startPoint = current;
717
- for (let step = 1; step <= 8; step += 1) {
718
- const amount = step / 8;
719
- const remaining = 1 - amount;
720
- points.push([
721
- remaining * remaining * startPoint[0] + 2 * remaining * amount * control[0] + amount * amount * end[0],
722
- remaining * remaining * startPoint[1] + 2 * remaining * amount * control[1] + amount * amount * end[1],
723
- ]);
724
- }
725
- current = end;
726
- }
727
- break;
728
- }
729
- case 'Z': {
730
- if (start) points.push(start);
731
- break;
732
- }
733
- default:
734
- return [];
735
- }
736
- }
737
-
738
- return points.filter(isPoint);
739
- }
740
-
741
1325
  function properSegmentIntersection(a, b, c, d) {
742
1326
  const abC = crossProduct(a, b, c);
743
1327
  const abD = crossProduct(a, b, d);
@@ -813,20 +1397,69 @@ function padBox(box, padding) {
813
1397
  };
814
1398
  }
815
1399
 
1400
+ // Only geometry/numeric attributes are scanned: authored prose such as node
1401
+ // tags, <title> text, aria-labels, or data-* attributes may legitimately
1402
+ // mention "NaN" or "Infinity" without any coordinate being non-finite.
1403
+ function collectNonFiniteAttrs(svg) {
1404
+ const details = [];
1405
+ const stack = [];
1406
+ for (const match of svg.matchAll(SVG_TAG_TOKEN)) {
1407
+ if (!match[2]) continue;
1408
+ const element = match[2].toLowerCase();
1409
+ if (match[1]) {
1410
+ const index = stack.map(entry => entry.element).lastIndexOf(element);
1411
+ if (index >= 0) stack.length = index;
1412
+ continue;
1413
+ }
1414
+ const parent = stack[stack.length - 1];
1415
+ const inSvg = element === 'svg'
1416
+ || Boolean(parent?.inSvg && !SVG_HTML_INTEGRATION_POINTS.has(parent.element));
1417
+ if (inSvg) {
1418
+ for (const [name, value] of attrEntries(match[0])) {
1419
+ if (!isNumericAttr(element, name) || !NON_FINITE_TOKEN.test(decodeNumericReferences(value))) continue;
1420
+ details.push(`${match[2]} ${name}="${value}"`);
1421
+ }
1422
+ }
1423
+ // HTML void elements do not open a context; SVG self-closing tags do not
1424
+ // either. A nested <svg> inside foreignObject restores SVG checking.
1425
+ if (!(inSvg ? /\/\s*>$/.test(match[0]) : HTML_VOID_ELEMENTS.has(element))) {
1426
+ stack.push({ element, inSvg });
1427
+ }
1428
+ }
1429
+ return details;
1430
+ }
1431
+
1432
+ function decodeNumericReferences(value) {
1433
+ // Numeric references can encode every letter of NaN/Infinity/undefined.
1434
+ // Decode once, locally: other checks and diagnostic evidence keep raw values.
1435
+ return value.replace(/&#(?:x([0-9a-f]+)|([0-9]+));?/gi, (_, hex, decimal) => {
1436
+ const point = Number.parseInt(hex || decimal, hex ? 16 : 10);
1437
+ return point > 0 && point <= 0x10ffff && !(point >= 0xd800 && point <= 0xdfff)
1438
+ ? String.fromCodePoint(point) : '\uFFFD';
1439
+ });
1440
+ }
1441
+
1442
+ function isNumericAttr(elementName, attrName) {
1443
+ const normalizedAttr = attrName.toLowerCase();
1444
+ return NUMERIC_ATTRS.has(normalizedAttr)
1445
+ || ELEMENT_NUMERIC_ATTRS.get(elementName.toLowerCase())?.has(normalizedAttr);
1446
+ }
1447
+
1448
+ function attrEntries(tag) {
1449
+ return [...tag.matchAll(HTML_ATTRIBUTE)].map((match) => [
1450
+ match[1],
1451
+ match[2] ?? match[3] ?? match[4],
1452
+ ]);
1453
+ }
1454
+
816
1455
  function parseAttrs(tag) {
817
- const attrs = {};
818
- for (const match of tag.matchAll(/([\w:-]+)\s*=\s*"([^"]*)"/g)) attrs[match[1]] = match[2];
819
- return attrs;
1456
+ return Object.fromEntries(attrEntries(tag));
820
1457
  }
821
1458
 
822
1459
  function numberAttr(attrs, name) {
823
1460
  return Number.parseFloat(attrs[name]);
824
1461
  }
825
1462
 
826
- function isCommand(token) {
827
- return /^[A-Za-z]$/.test(token);
828
- }
829
-
830
1463
  function isPoint(point) {
831
1464
  return Array.isArray(point) && point.length === 2 && point.every(Number.isFinite);
832
1465
  }