@stacksjs/defaults 0.75.361 → 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 (208) hide show
  1. package/ai/skills/stacks-ai/SKILL.md +22 -0
  2. package/ai/skills/stacks-native/CATALOG.md +2 -2
  3. package/ai/skills/stacks-technical-diagrams/LICENSE +202 -22
  4. package/ai/skills/stacks-technical-diagrams/LICENSE-MIT +22 -0
  5. package/ai/skills/stacks-technical-diagrams/NOTICE +26 -0
  6. package/ai/skills/stacks-technical-diagrams/SKILL.md +106 -266
  7. package/ai/skills/stacks-technical-diagrams/THIRD_PARTY_NOTICES.md +11 -2
  8. package/ai/skills/stacks-technical-diagrams/UPSTREAM.json +12 -0
  9. package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +2 -2
  10. package/ai/skills/stacks-technical-diagrams/assets/template.html +3655 -3798
  11. package/ai/skills/stacks-technical-diagrams/bin/archify.mjs +7290 -0
  12. package/ai/skills/stacks-technical-diagrams/bin/delivery-update.mjs +7 -0
  13. package/ai/skills/stacks-technical-diagrams/bin/diagrams +1 -0
  14. package/ai/skills/stacks-technical-diagrams/bin/finalize.mjs +1056 -0
  15. package/ai/skills/stacks-technical-diagrams/bin/open-artifact.mjs +72 -12
  16. package/ai/skills/stacks-technical-diagrams/bin/preview.mjs +348 -31
  17. package/ai/skills/stacks-technical-diagrams/bin/recover-output.mjs +41 -0
  18. package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +3 -2097
  19. package/ai/skills/stacks-technical-diagrams/bin/visual-check.mjs +2142 -157
  20. package/ai/skills/stacks-technical-diagrams/brand-marks/README.md +3 -3
  21. package/ai/skills/stacks-technical-diagrams/delta/architecture-delta.mjs +130 -33
  22. package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +25 -30
  23. package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +0 -20
  24. package/ai/skills/stacks-technical-diagrams/examples/archify-dev-activity.timeline.json +176 -0
  25. package/ai/skills/stacks-technical-diagrams/examples/archify-repository.tree.json +197 -0
  26. package/ai/skills/stacks-technical-diagrams/examples/async-job-roundtrip.sequence.json +1 -6
  27. package/ai/skills/stacks-technical-diagrams/examples/brand-aware-delivery.architecture.json +3 -16
  28. package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +2 -7
  29. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.base.architecture.json +2 -1
  30. package/ai/skills/stacks-technical-diagrams/examples/checkout-platform.head.architecture.json +2 -1
  31. package/ai/skills/stacks-technical-diagrams/examples/checkout-request.waterfall.json +20 -0
  32. package/ai/skills/stacks-technical-diagrams/examples/class-payment-processors-rendered.html +14987 -0
  33. package/ai/skills/stacks-technical-diagrams/examples/class-payments-rendered.html +14787 -0
  34. package/ai/skills/stacks-technical-diagrams/examples/dataflow-product-analytics.html +14880 -0
  35. package/ai/skills/stacks-technical-diagrams/examples/delivery.explainer.json +155 -0
  36. package/ai/skills/stacks-technical-diagrams/examples/deployment-release.lifecycle.json +57 -34
  37. package/ai/skills/stacks-technical-diagrams/examples/erd-orders-rendered.html +15112 -0
  38. package/ai/skills/stacks-technical-diagrams/examples/event-stream.dataflow.json +1 -6
  39. package/ai/skills/stacks-technical-diagrams/examples/example-rebuild.waterfall.json +212 -0
  40. package/ai/skills/stacks-technical-diagrams/examples/holiday-planning.workflow.json +143 -0
  41. package/ai/skills/stacks-technical-diagrams/examples/incident-response.workflow.json +1 -6
  42. package/ai/skills/stacks-technical-diagrams/examples/lifecycle-agent-run.html +14892 -0
  43. package/ai/skills/stacks-technical-diagrams/examples/locales/de.json +463 -0
  44. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.json +463 -0
  45. package/ai/skills/stacks-technical-diagrams/examples/locales/fr.partial.json +22 -0
  46. package/ai/skills/stacks-technical-diagrams/examples/locales/it.json +463 -0
  47. package/ai/skills/stacks-technical-diagrams/examples/locales/ja.json +463 -0
  48. package/ai/skills/stacks-technical-diagrams/examples/locales/pt.json +463 -0
  49. package/ai/skills/stacks-technical-diagrams/examples/locales/ru.json +463 -0
  50. package/ai/skills/stacks-technical-diagrams/examples/orders.erd.json +337 -0
  51. package/ai/skills/stacks-technical-diagrams/examples/payment-incident.timeline.json +24 -0
  52. package/ai/skills/stacks-technical-diagrams/examples/payment-platform.tree.json +22 -0
  53. package/ai/skills/stacks-technical-diagrams/examples/payment-processors.class.json +177 -0
  54. package/ai/skills/stacks-technical-diagrams/examples/payments.class.json +85 -0
  55. package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +1 -6
  56. package/ai/skills/stacks-technical-diagrams/examples/production-deployment.architecture.json +3 -23
  57. package/ai/skills/stacks-technical-diagrams/examples/release-delivery.workflow.json +1 -6
  58. package/ai/skills/stacks-technical-diagrams/examples/sequence-cache-miss-request.html +14895 -0
  59. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing-rendered.html +15369 -0
  60. package/ai/skills/stacks-technical-diagrams/examples/subscription-billing.erd.json +548 -0
  61. package/ai/skills/stacks-technical-diagrams/examples/timeline-archify-dev-activity-rendered.html +15002 -0
  62. package/ai/skills/stacks-technical-diagrams/examples/timeline-payment-incident-rendered.html +14772 -0
  63. package/ai/skills/stacks-technical-diagrams/examples/tree-archify-repository-rendered.html +15071 -0
  64. package/ai/skills/stacks-technical-diagrams/examples/tree-payment-platform-rendered.html +14810 -0
  65. package/ai/skills/stacks-technical-diagrams/examples/waterfall-checkout-request-rendered.html +14754 -0
  66. package/ai/skills/stacks-technical-diagrams/examples/waterfall-example-rebuild-rendered.html +14940 -0
  67. package/ai/skills/stacks-technical-diagrams/examples/web-app-rendered.html +14889 -0
  68. package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +1 -6
  69. package/ai/skills/stacks-technical-diagrams/examples/workflow-agent-tool-call-rendered.html +14907 -0
  70. package/ai/skills/stacks-technical-diagrams/explainer/browser-check.mjs +208 -0
  71. package/ai/skills/stacks-technical-diagrams/explainer/charts.mjs +70 -0
  72. package/ai/skills/stacks-technical-diagrams/explainer/check.mjs +168 -0
  73. package/ai/skills/stacks-technical-diagrams/explainer/diagrams.mjs +518 -0
  74. package/ai/skills/stacks-technical-diagrams/explainer/diff.mjs +124 -0
  75. package/ai/skills/stacks-technical-diagrams/explainer/inline.mjs +155 -0
  76. package/ai/skills/stacks-technical-diagrams/explainer/layout.mjs +35 -0
  77. package/ai/skills/stacks-technical-diagrams/explainer/page-bundle.mjs +16 -0
  78. package/ai/skills/stacks-technical-diagrams/explainer/parse.mjs +300 -0
  79. package/ai/skills/stacks-technical-diagrams/explainer/patch.mjs +117 -0
  80. package/ai/skills/stacks-technical-diagrams/explainer/prepare.mjs +131 -0
  81. package/ai/skills/stacks-technical-diagrams/explainer/reader.css +472 -0
  82. package/ai/skills/stacks-technical-diagrams/explainer/reader.js +667 -0
  83. package/ai/skills/stacks-technical-diagrams/explainer/render.mjs +224 -0
  84. package/ai/skills/stacks-technical-diagrams/explainer/revision.mjs +20 -0
  85. package/ai/skills/stacks-technical-diagrams/explainer/sources.mjs +318 -0
  86. package/ai/skills/stacks-technical-diagrams/explainer/validate.mjs +138 -0
  87. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/LICENSE +40 -0
  88. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/README.md +5 -0
  89. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/3DKXF73K.js +4 -0
  90. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/42UTT654.js +3 -0
  91. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/DKTLEL5E.js +1 -0
  92. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EEIX5ZYH.js +2 -0
  93. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/EJ5KOXYD.js +25 -0
  94. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/GUJTEFZK.js +1 -0
  95. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/IZOKQ2GT.js +4 -0
  96. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/L4LY553T.js +3 -0
  97. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/MSKIJHB2.js +2 -0
  98. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/RVARI57O.js +1 -0
  99. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UKSIWDDN.js +2 -0
  100. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/UPTXNAXN.js +2 -0
  101. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/XTDUIGUI.js +9 -0
  102. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/chunks/YAYTP6L5.js +9 -0
  103. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/core.js +1 -0
  104. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/pie.js +1 -0
  105. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/quadrantChart.js +1 -0
  106. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/radar.js +1 -0
  107. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/sankey.js +1 -0
  108. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/venn.js +1 -0
  109. package/ai/skills/stacks-technical-diagrams/explainer/vendor/pele/diagrams/xychart.js +1 -0
  110. package/ai/skills/stacks-technical-diagrams/explainer/writing.mjs +139 -0
  111. package/ai/skills/stacks-technical-diagrams/locales/README.md +40 -0
  112. package/ai/skills/stacks-technical-diagrams/locales/ar.json +488 -0
  113. package/ai/skills/stacks-technical-diagrams/locales/en.json +488 -0
  114. package/ai/skills/stacks-technical-diagrams/locales/es.json +488 -0
  115. package/ai/skills/stacks-technical-diagrams/locales/ko.json +488 -0
  116. package/ai/skills/stacks-technical-diagrams/locales/manifest.json +10 -0
  117. package/ai/skills/stacks-technical-diagrams/locales/zh-CN.json +488 -0
  118. package/ai/skills/stacks-technical-diagrams/locales/zh-TW.json +488 -0
  119. package/ai/skills/stacks-technical-diagrams/migrations/workflow-v2.mjs +1 -0
  120. package/ai/skills/stacks-technical-diagrams/recipes/scenarios.mjs +115 -51
  121. package/ai/skills/stacks-technical-diagrams/references/architecture-layout-repair.md +18 -0
  122. package/ai/skills/stacks-technical-diagrams/references/authoring-contract.md +453 -51
  123. package/ai/skills/stacks-technical-diagrams/references/authoring-defaults.md +57 -0
  124. package/ai/skills/stacks-technical-diagrams/references/brand-marks.md +57 -3
  125. package/ai/skills/stacks-technical-diagrams/references/delivery-contract.md +603 -58
  126. package/ai/skills/stacks-technical-diagrams/references/explainer-authoring.md +274 -0
  127. package/ai/skills/stacks-technical-diagrams/references/repository-authoring.md +126 -0
  128. package/ai/skills/stacks-technical-diagrams/references/update-awareness.md +13 -0
  129. package/ai/skills/stacks-technical-diagrams/references/viewer-runtime.md +18 -11
  130. package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +1 -1
  131. package/ai/skills/stacks-technical-diagrams/renderers/architecture/labels.mjs +2 -0
  132. package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +550 -424
  133. package/ai/skills/stacks-technical-diagrams/renderers/architecture/routing.mjs +1469 -0
  134. package/ai/skills/stacks-technical-diagrams/renderers/class/README.md +129 -0
  135. package/ai/skills/stacks-technical-diagrams/renderers/class/render-class.mjs +1095 -0
  136. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +89 -17
  137. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/dataflow-compiler.mjs +1605 -0
  138. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/joint-label-routing.mjs +165 -0
  139. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/label-capacity.mjs +81 -0
  140. package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +14 -474
  141. package/ai/skills/stacks-technical-diagrams/renderers/erd/README.md +246 -0
  142. package/ai/skills/stacks-technical-diagrams/renderers/erd/grid.mjs +150 -0
  143. package/ai/skills/stacks-technical-diagrams/renderers/erd/render-erd.mjs +1588 -0
  144. package/ai/skills/stacks-technical-diagrams/renderers/explainer/render-explainer.mjs +42 -0
  145. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +83 -94
  146. package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +990 -417
  147. package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +49 -17
  148. package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +735 -94
  149. package/ai/skills/stacks-technical-diagrams/renderers/shared/atomic-output.mjs +1982 -0
  150. package/ai/skills/stacks-technical-diagrams/renderers/shared/automatic-labels.mjs +290 -0
  151. package/ai/skills/stacks-technical-diagrams/renderers/shared/brand-marks.mjs +169 -26
  152. package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +266 -54
  153. package/ai/skills/stacks-technical-diagrams/renderers/shared/desktop-readability.mjs +106 -0
  154. package/ai/skills/stacks-technical-diagrams/renderers/shared/diagnostics.mjs +0 -0
  155. package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +268 -2
  156. package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +774 -124
  157. package/ai/skills/stacks-technical-diagrams/renderers/shared/i18n.mjs +289 -556
  158. package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +18 -1
  159. package/ai/skills/stacks-technical-diagrams/renderers/shared/legend.mjs +16 -6
  160. package/ai/skills/stacks-technical-diagrams/renderers/shared/node-card-style.mjs +32 -0
  161. package/ai/skills/stacks-technical-diagrams/renderers/shared/output-path.mjs +329 -229
  162. package/ai/skills/stacks-technical-diagrams/renderers/shared/owned-temp-directory.mjs +82 -0
  163. package/ai/skills/stacks-technical-diagrams/renderers/shared/path-semantics.mjs +1017 -0
  164. package/ai/skills/stacks-technical-diagrams/renderers/shared/portable-path.mjs +408 -0
  165. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-evidence.mjs +235 -79
  166. package/ai/skills/stacks-technical-diagrams/renderers/shared/repository-location.mjs +17 -4
  167. package/ai/skills/stacks-technical-diagrams/renderers/shared/route-quality.mjs +670 -0
  168. package/ai/skills/stacks-technical-diagrams/renderers/shared/sidecar-path.mjs +38 -0
  169. package/ai/skills/stacks-technical-diagrams/renderers/shared/software-notices.mjs +6 -0
  170. package/ai/skills/stacks-technical-diagrams/renderers/shared/spatial-grid.mjs +66 -0
  171. package/ai/skills/stacks-technical-diagrams/renderers/shared/svg-path-analysis.mjs +135 -0
  172. package/ai/skills/stacks-technical-diagrams/renderers/shared/text-fit.mjs +59 -3
  173. package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +63 -35
  174. package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +2 -5
  175. package/ai/skills/stacks-technical-diagrams/renderers/timeline/README.md +76 -0
  176. package/ai/skills/stacks-technical-diagrams/renderers/timeline/render-timeline.mjs +576 -0
  177. package/ai/skills/stacks-technical-diagrams/renderers/tree/README.md +96 -0
  178. package/ai/skills/stacks-technical-diagrams/renderers/tree/render-tree.mjs +573 -0
  179. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/README.md +73 -0
  180. package/ai/skills/stacks-technical-diagrams/renderers/waterfall/render-waterfall.mjs +413 -0
  181. package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +137 -23
  182. package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +18 -6
  183. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-compiler.mjs +2230 -710
  184. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-migration-geometry.mjs +17 -0
  185. package/ai/skills/stacks-technical-diagrams/renderers/workflow/workflow-placement-repair.mjs +94 -0
  186. package/ai/skills/stacks-technical-diagrams/schemas/README.md +87 -40
  187. package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +7 -33
  188. package/ai/skills/stacks-technical-diagrams/schemas/class.schema.json +345 -0
  189. package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +68 -2
  190. package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +14 -3
  191. package/ai/skills/stacks-technical-diagrams/schemas/erd.schema.json +381 -0
  192. package/ai/skills/stacks-technical-diagrams/schemas/explainer.schema.json +927 -0
  193. package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +23 -110
  194. package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +15 -4
  195. package/ai/skills/stacks-technical-diagrams/schemas/timeline.schema.json +160 -0
  196. package/ai/skills/stacks-technical-diagrams/schemas/tree.schema.json +141 -0
  197. package/ai/skills/stacks-technical-diagrams/schemas/waterfall.schema.json +147 -0
  198. package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +14 -3
  199. package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +876 -243
  200. package/ai/skills/stacks-technical-diagrams/scripts/check-update.mjs +1717 -0
  201. package/ai/skills/stacks-technical-diagrams/scripts/delivery-update-child.mjs +26 -0
  202. package/ai/skills/stacks-technical-diagrams/scripts/generate-brand-marks.mjs +141 -0
  203. package/ai/skills/stacks-technical-diagrams/scripts/generate-validators.mjs +105 -0
  204. package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +11 -1
  205. package/ai/skills/stacks-technical-diagrams/scripts/update-contract.mjs +187 -0
  206. package/ai/skills/stacks-technical-diagrams/skill-release.json +10 -0
  207. package/ide/vscode/package.json +1 -1
  208. package/package.json +2 -2
@@ -0,0 +1,667 @@
1
+ (() => {
2
+ 'use strict';
3
+ const root = document.documentElement;
4
+ const read = id => JSON.parse(document.getElementById(id).textContent);
5
+ const prepared = read('archify-explainer-prepared');
6
+ const diagrams = prepared.preparedDiagrams.diagrams || prepared.preparedDiagrams;
7
+ const words = read('explainer-reader-words');
8
+ const english = !root.lang.startsWith('zh');
9
+ const say = (en,zh) => english ? en : zh;
10
+ const byId = id => document.getElementById(id);
11
+ const all = (selector, within = document) => [...within.querySelectorAll(selector)];
12
+ let toastTimer;
13
+ function notify(text) { const toast=byId('toast');toast.textContent=text;toast.classList.add('visible');clearTimeout(toastTimer);toastTimer=setTimeout(()=>toast.classList.remove('visible'),3000); }
14
+ // Measured board layout. Each panel is measured at every column width, then each column template is
15
+ // simulated and the one with the least page height and blank area wins. Panels keep source order on
16
+ // a 1px row grid, so the DOM and reading order never change.
17
+ // Without JavaScript, or on narrow windows, the static spans from the renderer stay in force.
18
+ const board = document.querySelector('.board');
19
+ function layoutBoard() {
20
+ const panels = all(':scope>.panel',board);
21
+ const floor = textFloor();
22
+ // A diagram needs enough width for its smallest label to reach the reading floor.
23
+ const readableWidth = svg => {
24
+ const viewWidth = svg.viewBox.baseVal?.width || svg.getBoundingClientRect().width;
25
+ const smallest = Math.min(...all('text',svg).filter(text=>text.textContent.trim() && !text.closest('[data-legend]')).map(text=>(parseFloat(text.getAttribute('font-size')) || parseFloat(getComputedStyle(text).fontSize)) / secondary(text)));
26
+ return Number.isFinite(smallest) && smallest > 0 ? viewWidth * floor / smallest : 0;
27
+ };
28
+ // Beside its steps, the diagram column is exactly that wide; without room for both, it wraps above them.
29
+ // It grows no wider than the preview draws the diagram, so spare width goes to the steps.
30
+ all('.guided').forEach(guide=>{
31
+ const preview = guide.querySelector('.diagram-preview'), need = Math.ceil(readableWidth(preview.querySelector('svg')) + 2);
32
+ guide.style.setProperty('--guide-figure',need + 'px');
33
+ guide.style.setProperty('--guide-figure-max',Math.max(need,Math.ceil(parseFloat(getComputedStyle(preview).getPropertyValue('--native-width')) * 1.25 || 0)) + 'px');
34
+ });
35
+ const reset = () => { board.classList.remove('measured'); panels.forEach(panel=>{panel.style.gridColumn = '';panel.style.gridRow = '';}); };
36
+ if (window.innerWidth <= 760 || panels.length < 2) { reset(); return; }
37
+ const gap = parseFloat(getComputedStyle(board).columnGap) || 0;
38
+ const column = (board.clientWidth - gap * 11) / 12;
39
+ const px = span => span * column + (span - 1) * gap;
40
+ const readableSpan = panel => {
41
+ const svgs = all('.diagram-preview>svg',panel);
42
+ if (!svgs.length) return 0;
43
+ const body = panel.querySelector('.panel-body'), style = getComputedStyle(body);
44
+ const inset = panel.offsetWidth - body.clientWidth + parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
45
+ const need = Math.max(...svgs.map(svg=>readableWidth(svg) + inset));
46
+ return [3,4,5,6,7,8,9,12].find(span=>px(span) >= need) || 12;
47
+ };
48
+ const SPANS = [4,5,6,7,8,12];
49
+ board.classList.add('measuring');
50
+ const items = panels.map(panel=>{
51
+ const fixed = Number(panel.dataset.spanFixed) || 0;
52
+ const wide = fixed >= 9 || Number((panel.className.match(/\bspan(\d+)\b/) || [])[1]) >= 9;
53
+ const min = Math.max(4,Number(panel.dataset.spanMin) || 4,fixed && fixed < 9 ? fixed : 0,readableSpan(panel));
54
+ const height = {}, fits = {};
55
+ for (const span of SPANS) {
56
+ panel.style.width = px(span) + 'px';
57
+ height[span] = Math.ceil(panel.getBoundingClientRect().height);
58
+ fits[span] = span >= min && !all('.table-scroll,.diff-scroll,pre',panel).some(element=>element.scrollWidth > element.clientWidth + 1);
59
+ }
60
+ panel.style.width = '';
61
+ return {panel,wide,height,fits,text:panel.dataset.measure === 'text'};
62
+ });
63
+ board.classList.remove('measuring');
64
+ const width = board.clientWidth;
65
+ const excess = (item,span) => item.text ? Math.max(0,px(span) - 720) / 720 * px(span) * item.height[span] * .6 : 0;
66
+ // One set of column lines per section. Panels fill the shortest fitting column in source
67
+ // order; a panel that fits no column takes the full width and closes the section above it. The
68
+ // last panel of every column stretches to the section bottom, so column edges and bottoms align.
69
+ function simulate(template, section, forced = []) {
70
+ const lines = template.reduce((acc,span)=>[...acc,acc[acc.length - 1] + span],[1]);
71
+ const placed = [], options = [], chosen = [];
72
+ let cursor = 0, waste = 0, wide = 0, heights, last, previous;
73
+ const open = () => { heights = template.map(()=>0); last = template.map(()=>null); previous = {top:0,c:-1,height:0}; };
74
+ // heights[c] is the offset of the next panel in column c, so it includes one trailing gap.
75
+ const close = () => {
76
+ // A conclusion alone in its section takes the full width rather than leaving blank columns beside it.
77
+ const alone = last.filter(Boolean);
78
+ if (alone.length === 1 && alone[0] === placed[0] && placed.length === 1 && template.length > 1) {
79
+ Object.assign(alone[0],{line:1,span:12,height:alone[0].item.height[12]});
80
+ cursor += alone[0].height + gap;
81
+ open();
82
+ return;
83
+ }
84
+ const bottom = Math.max(...heights) - gap;
85
+ if (bottom <= 0) return;
86
+ template.forEach((span,c)=>{
87
+ waste += px(span) * (heights[c] ? bottom - (heights[c] - gap) : bottom * 1.6);
88
+ if (last[c]) last[c].height = cursor + bottom - last[c].top;
89
+ });
90
+ cursor += bottom + gap;
91
+ open();
92
+ };
93
+ open();
94
+ for (const [index,item] of section.entries()) {
95
+ const answer = item.panel.id === 'answer' && item === items[0];
96
+ const choices = template.map((span,c)=>({c,span})).filter(({c,span})=>template.length > 1 && !item.wide && item.fits[span] && (!answer || c === 0));
97
+ options.push(choices.length);
98
+ if (!choices.length) {
99
+ chosen.push(0);
100
+ close();
101
+ placed.push({item,line:1,span:12,top:cursor,height:item.height[12]});
102
+ wide += excess(item,12);
103
+ cursor += item.height[12] + gap;
104
+ continue;
105
+ }
106
+ // Reading order is top to bottom, then left to right: a panel never starts above the previous
107
+ // one, and starts clearly lower when placed to its left. The panel above it stretches to close the gap.
108
+ const start = c => Math.max(heights[c], c < previous.c ? previous.top + Math.min(160, previous.height / 2) : previous.top);
109
+ const bottom = ({c,span}) => start(c) + item.height[span];
110
+ const selected = choices[forced[index]] || choices.reduce((a,b)=>bottom(b) < bottom(a) - 1 ? b : a), {c,span} = selected;
111
+ chosen.push(choices.indexOf(selected));
112
+ const top = start(c);
113
+ // A gap with no panel above it to stretch is a visible hole, which reads worse than room inside a card.
114
+ if (top > heights[c]) { waste += px(span) * (top - heights[c]) * (last[c] ? 1 : 1.6); if (last[c]) last[c].height += top - heights[c]; }
115
+ const entry = {item,line:lines[c],span,top:cursor + top,height:item.height[span]};
116
+ placed.push(entry);
117
+ previous = {top,c,height:item.height[span]};
118
+ heights[c] = top + item.height[span] + gap;
119
+ last[c] = entry;
120
+ wide += excess(item,span);
121
+ }
122
+ close();
123
+ return {placed,options,chosen,height:cursor,cost:cursor * width + waste + wide};
124
+ }
125
+ // Greedy placement cannot see that a short fit now forces a hole later, so each panel's column is
126
+ // chosen by completing the rest of the section greedily for every option and keeping the cheapest.
127
+ function plan(template, section) {
128
+ let best = simulate(template,section);
129
+ for (let index = 0; index < section.length; index++) {
130
+ const prefix = best.chosen.slice(0,index);
131
+ for (let option = 0; option < best.options[index]; option++) {
132
+ const trial = simulate(template,section,[...prefix,option]);
133
+ if (trial.cost < best.cost - 1) best = trial;
134
+ }
135
+ }
136
+ return best;
137
+ }
138
+ // Full-width panels split the page into sections; each section picks its own column lines.
139
+ const templates = [[6,6],[5,7],[7,5],[4,8],[8,4],[4,4,4],[12]];
140
+ const sections = items.reduce((list,item)=>{ if (item.wide || list.at(-1)?.[0]?.wide) list.push([item]); else list.at(-1).push(item); return list; },[[]]).filter(section=>section.length);
141
+ let offset = 0;
142
+ for (const section of sections) {
143
+ const layout = templates.map(template=>plan(template,section)).reduce((a,b)=>b.cost < a.cost ? b : a);
144
+ layout.placed.forEach(({item,line,span,top,height})=>{
145
+ item.panel.style.gridColumn = `${line} / span ${span}`;
146
+ item.panel.style.gridRow = `${Math.round(offset + top) + 1} / span ${Math.max(1,Math.round(height))}`;
147
+ });
148
+ offset += layout.height;
149
+ }
150
+ board.classList.add('measured');
151
+ }
152
+ // Phase rows that step down share one lane width, which leaves a wide empty strip beside each row.
153
+ // In the preview each lane frame hugs its own nodes; the full Viewer keeps the native geometry.
154
+ function fitLaneFrames(svg) {
155
+ if (svg.closest('.bare-lanes')) return;
156
+ const frames = all('rect[data-composition-frame-kind="lane"]',svg);
157
+ if (frames.length < 2) return;
158
+ const nodes = all('[data-node-id]',svg).map(node=>node.getBBox());
159
+ frames.forEach(frame=>{
160
+ const y = frame.y.baseVal.value, bottom = y + frame.height.baseVal.value, label = frame.nextElementSibling?.tagName === 'text' ? frame.nextElementSibling : null;
161
+ const inside = nodes.filter(box=>box.y >= y && box.y + box.height <= bottom);
162
+ if (!inside.length) return;
163
+ const pad = 16, left = Math.min(...inside.map(box=>box.x)) - pad, right = Math.max(...inside.map(box=>box.x + box.width)) + pad;
164
+ const width = Math.max(right - left, (label?.getComputedTextLength() || 0) + 28);
165
+ frame.setAttribute('x',left); frame.setAttribute('width',width);
166
+ label?.setAttribute('x',left + 14);
167
+ });
168
+ }
169
+ // The legend and grid are hidden in the preview, so fit the viewBox to the drawing itself.
170
+ function cropDiagramPreviews() {
171
+ all('.diagram-preview>svg').forEach(svg=>{
172
+ if (svg.dataset.cropped) return;
173
+ fitLaneFrames(svg);
174
+ let box; try { box = svg.getBBox(); } catch { return; }
175
+ if (!box.width || !box.height) return;
176
+ const pad = 6;
177
+ svg.setAttribute('viewBox',[box.x - pad,box.y - pad,box.width + pad * 2,box.height + pad * 2].map(n=>Math.round(n)).join(' '));
178
+ svg.parentElement.style.setProperty('--native-width',Math.round(box.width + pad * 2) + 'px');
179
+ svg.dataset.cropped = 'true';
180
+ });
181
+ }
182
+ // Edge labels are secondary to node text and may sit a little below the reading floor.
183
+ const secondary = text => text.closest('[data-edge-id]') ? .7 : 1;
184
+ function textFloor() {
185
+ const bodySize = parseFloat(getComputedStyle(document.querySelector('.block-prose>p') || document.body).fontSize);
186
+ return window.innerWidth >= 1440 ? Math.max(10,bodySize * .75) : 10;
187
+ }
188
+ // A fit notice that appears or goes away changes a panel's height after the board was planned,
189
+ // so the board is planned again and the notices rechecked at the new widths.
190
+ function ensureDiagramReadability() { if (fitDiagrams()) { layoutBoard(); fitDiagrams(); } }
191
+ function fitDiagrams() {
192
+ const minimumText = textFloor();
193
+ let changed = false;
194
+ // Only a diagram beside its steps stays in view; stacked above them, it would slide over them.
195
+ all('.guided').forEach(guide=>guide.classList.toggle('beside',guide.firstElementChild.getBoundingClientRect().left >= guide.lastElementChild.getBoundingClientRect().right));
196
+ all('.diagram-preview>svg').forEach(svg=>{
197
+ // Measure the current available width, rather than retaining a wider viewport's floor.
198
+ svg.style.minWidth = '0px';
199
+ const sizes = all('text',svg).filter(text=>text.textContent.trim() && text.getBoundingClientRect().width > 0).map(text=>{const matrix = text.getScreenCTM();return matrix ? parseFloat(getComputedStyle(text).fontSize) / secondary(text) * (()=>{const trace=matrix.a**2+matrix.b**2+matrix.c**2+matrix.d**2;const determinant=matrix.a*matrix.d-matrix.b*matrix.c;return Math.sqrt(Math.max(0,(trace-Math.sqrt(Math.max(0,trace**2-4*determinant**2)))/2));})() : Infinity;});
200
+ const smallest = Math.min(...sizes);
201
+ const preview = svg.parentElement;
202
+ const needed = svg.getBoundingClientRect().width * minimumText / smallest;
203
+ const widened = smallest > 0 && smallest < minimumText && needed <= preview.clientWidth;
204
+ if (widened) preview.style.setProperty('--native-width',Math.ceil(needed / 1.25) + 'px');
205
+ const insufficient = smallest > 0 && smallest < minimumText && !widened;
206
+ if (insufficient) {
207
+ preview.dataset.diagramRequiredWidth = String(Math.ceil(svg.getBoundingClientRect().width * minimumText / smallest));
208
+ } else delete preview.dataset.diagramRequiredWidth;
209
+ preview.dataset.diagramFit = insufficient ? 'insufficient' : 'fit';
210
+ preview.dataset.diagramMinimumText = String(minimumText);
211
+ const status = svg.closest('.block-diagram').querySelector('.diagram-fit-status');
212
+ changed ||= status.hidden === insufficient;
213
+ status.hidden = !insufficient;
214
+ status.textContent = insufficient ? say('This diagram cannot fit this page width with readable text. Open the interactive view to inspect it.','此图在当前页宽下放不下且文字会太小,请打开交互图查看。') : '';
215
+ });
216
+ return changed;
217
+ }
218
+ const preference = matchMedia('(prefers-color-scheme: dark)');
219
+ let theme = 'auto';
220
+ function applyTheme() {
221
+ const effective = theme === 'auto' ? preference.matches ? 'dark' : 'light' : theme;
222
+ root.dataset.theme = effective;
223
+ all('[data-theme-choice]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.themeChoice === theme)));
224
+ all('.diagram-preview').forEach(preview=>preview.dataset.theme = effective);
225
+ if (viewer?.ready) sendViewer();
226
+ }
227
+ all('[data-theme-choice]').forEach(button=>button.addEventListener('click',()=>{theme = button.dataset.themeChoice; applyTheme();}));
228
+ preference.addEventListener?.('change',applyTheme);
229
+ // Toolbar menus, not native selects, so they match the toolbar: arrow keys move, Escape or Tab
230
+ // closes, and a click elsewhere closes.
231
+ function toolMenu(container, pick, current = () => null) {
232
+ const toggle = container.querySelector('[aria-haspopup]'), panel = container.querySelector('[role=menu]'), items = all('[role^=menuitem]',panel);
233
+ const set = open => { panel.hidden = !open; toggle.setAttribute('aria-expanded',String(open)); if (open) (current() || items[0]).focus(); };
234
+ toggle.addEventListener('click',()=>set(panel.hidden));
235
+ items.forEach(item=>item.addEventListener('click',()=>{ set(false); toggle.focus(); pick(item); }));
236
+ panel.addEventListener('keydown',event=>{
237
+ const index = items.indexOf(document.activeElement);
238
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); items[(index + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length].focus(); }
239
+ else if (event.key === 'Escape' || event.key === 'Tab') { set(false); if (event.key === 'Escape') toggle.focus(); }
240
+ });
241
+ document.addEventListener('click',event=>{ if (!panel.hidden && !container.contains(event.target)) set(false); });
242
+ }
243
+ // The author picks a default style; a reader's own choice is kept for every explainer page.
244
+ const styleToggle = document.querySelector('[data-style-toggle]'), styleMenu = document.querySelector('.style-options');
245
+ const styleItems = all('[data-style-choice]',styleMenu || document);
246
+ const setStyle = (style, remember) => {
247
+ if (!styleItems.some(item=>item.dataset.styleChoice === style)) return;
248
+ root.dataset.style = style;
249
+ styleItems.forEach(item=>item.setAttribute('aria-checked',String(item.dataset.styleChoice === style)));
250
+ styleToggle.querySelector('[data-style-label]').textContent = words.styles[style];
251
+ styleToggle.querySelector('.style-swatch').dataset.swatch = style;
252
+ if (remember) { try { localStorage.setItem('archify-explainer-style',style); } catch {} requestAnimationFrame(()=>{ layoutBoard(); ensureDiagramReadability(); }); }
253
+ };
254
+ try { const saved = localStorage.getItem('archify-explainer-style'); if (saved) setStyle(saved,false); } catch {}
255
+ toolMenu(document.querySelector('.style-menu'),item=>setStyle(item.dataset.styleChoice,true),()=>styleItems.find(item=>item.getAttribute('aria-checked') === 'true'));
256
+ toolMenu(document.querySelector('.export-menu'),item=>item.matches('[data-print]') ? window.print() : exportImage(item.dataset.exportImage));
257
+ // Long image: the page as laid out now, drawn through an SVG foreignObject at twice the pixel
258
+ // density. Controls, dialogs and walk-through bars are left out. Browsers that refuse to export a
259
+ // foreignObject canvas (Safari) get a notice pointing to Print / PDF.
260
+ async function pageImage(format = 'png') {
261
+ const shell = document.querySelector('.shell'), width = document.documentElement.clientWidth, height = Math.ceil(shell.getBoundingClientRect().height + 48);
262
+ const clone = root.cloneNode(true);
263
+ clone.querySelectorAll('script,dialog,#toast,.skip-link,.tools,.ask-button,.ask-floating,.walk-bar,.walk-start,.interactive,.diagram-fit-status').forEach(element=>element.remove());
264
+ clone.querySelectorAll('.reader-highlight').forEach(element=>element.classList.remove('reader-highlight'));
265
+ clone.querySelectorAll('.focusing').forEach(element=>element.classList.remove('focusing'));
266
+ // Inside a foreignObject the page's <html> is not :root, so theme and style rules would drop out;
267
+ // the root's own attribute selects the same element with the same specificity.
268
+ const sheet = clone.querySelector('head>style'); if (sheet) sheet.textContent = sheet.textContent.replace(/:root\b/g,'[data-archify-artifact]');
269
+ clone.style.width = `${width}px`;
270
+ const xhtml = new XMLSerializer().serializeToString(clone);
271
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><foreignObject x="0" y="0" width="${width}" height="${height}">${xhtml}</foreignObject></svg>`;
272
+ const image = new Image();
273
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
274
+ await image.decode();
275
+ const scale = Math.min(2,16000 / height), canvas = document.createElement('canvas');
276
+ canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale);
277
+ const context = canvas.getContext('2d');
278
+ context.fillStyle = getComputedStyle(document.body).backgroundColor; context.fillRect(0,0,canvas.width,canvas.height);
279
+ context.drawImage(image,0,0,canvas.width,canvas.height);
280
+ return new Promise((resolve,reject)=>canvas.toBlob(blob=>blob ? resolve(blob) : reject(new Error('empty')),`image/${format}`,.92));
281
+ }
282
+ const exportToggle = document.querySelector('[data-export-toggle]');
283
+ // PNG keeps text sharpest; WebP and JPG are smaller to share. A browser that cannot encode the
284
+ // chosen format returns PNG, and the file is named for what it actually holds.
285
+ // eslint-disable-next-line
286
+ async function exportImage(format) {
287
+ exportToggle.disabled = true;
288
+ try {
289
+ const blob = await pageImage(format), link = document.createElement('a'), extension = {'image/jpeg':'jpg','image/webp':'webp'}[blob.type] || 'png';
290
+ if (blob.type !== `image/${format}`) notify(say(`This browser cannot save ${format.toUpperCase()}; saved as PNG.`,`当前浏览器不支持导出 ${format.toUpperCase()},已改存为 PNG。`));
291
+ link.href = URL.createObjectURL(blob); link.download = `${(document.querySelector('h1')?.textContent || 'explainer').trim().replace(/[\\/:*?"<>|]+/g,' ').slice(0,80)}.${extension}`;
292
+ document.body.append(link); link.click(); link.remove(); setTimeout(()=>URL.revokeObjectURL(link.href),4000);
293
+ } catch { notify(say('This browser cannot export the page as an image. Use Print / PDF instead.','当前浏览器无法导出长图,请用"打印 / PDF"。')); }
294
+ finally { exportToggle.disabled = false; }
295
+ }
296
+ window.ArchifyExplainer = {pageImage};
297
+ // Only the isolated srcdoc copy receives this bridge. The payload remains byte-for-byte native.
298
+ const viewerDialog = byId('diagram-dialog');
299
+ let viewer = null;
300
+ let viewerOpener;
301
+ function validHash(diagram,hash) {
302
+ if(!hash) return true;
303
+ const nodes = new Set((diagram.semantic?.nodes || []).map(node=>node.id));
304
+ try {
305
+ if(hash.startsWith('focus=')) return nodes.has(decodeURIComponent(hash.slice(6)));
306
+ if(hash.startsWith('route=')) { const parts = hash.slice(6).split('~').map(decodeURIComponent); return parts.length === 2 && parts.every(id=>nodes.has(id)); }
307
+ } catch {}
308
+ return false;
309
+ }
310
+ // The Viewer follows the page's theme, and its editorial preset matches the editorial style.
311
+ function sendViewer() { if(!viewer?.ready) return; viewer.frame.contentWindow.postMessage({kind:'archify-explainer-host',token:viewer.token,theme:root.dataset.theme,preset:root.dataset.style === 'editorial' ? 'editorial' : 'classic',hash:viewer.hash,nodes:viewer.nodes,edges:viewer.edges},'*'); }
312
+ function openViewer(id,hash,opener) {
313
+ const diagram = diagrams[id];
314
+ if(!diagram || !validHash(diagram,hash)) { notify(say('Diagram target unavailable.','图中目标不可用。')); return; }
315
+ let requestedEdges = []; try { requestedEdges = JSON.parse(opener.dataset.diagramEdges || '[]'); } catch {}
316
+ requestedEdges = requestedEdges.filter(id=>(diagram.semantic?.edges || []).some(edge=>edge.id === id));
317
+ const selectedEdges = (diagram.semantic?.edges || []).filter(edge=>requestedEdges.includes(edge.id));
318
+ const requestedNodes = [...new Set(hash.startsWith('focus=') ? [decodeURIComponent(hash.slice(6))] : hash.startsWith('route=') ? hash.slice(6).split('~').map(decodeURIComponent) : selectedEdges.flatMap(edge=>[edge.from,edge.to]))];
319
+ viewerOpener = opener;
320
+ byId('diagram-dialog-title').textContent = byId(id).querySelector('.diagram-title').textContent;
321
+ viewerDialog.showModal(); document.body.classList.add('dialog-open');
322
+ if(viewer?.id === id) { viewer.hash = hash; viewer.nodes = requestedNodes; viewer.edges = requestedEdges; sendViewer(); return; }
323
+ viewer?.frame.remove();
324
+ const token = Array.from(crypto.getRandomValues(new Uint32Array(4))).join('-');
325
+ viewerDialog.dataset.nativeReady = 'false'; delete viewerDialog.dataset.nativeFocus;
326
+ const frame = document.createElement('iframe'); frame.addEventListener('load',()=>{viewerDialog.dataset.nativeLoaded = id;}); frame.title = byId('diagram-dialog-title').textContent;
327
+ frame.setAttribute('sandbox','allow-scripts allow-downloads allow-modals');
328
+ const script = `<script>(()=>{
329
+ const token=${JSON.stringify(token)};
330
+ let pending=null;
331
+ const settle=data=>requestAnimationFrame(()=>{
332
+ if(pending!==data)return;
333
+ if(Array.isArray(data.edges)&&data.edges.length&&Array.isArray(data.nodes)){
334
+ if(window.Archify?.focus?.setMany)window.Archify.focus.setMany(data.nodes,{toggle:false,mode:'selection',updateUrl:false});
335
+ document.querySelectorAll('[data-edge-id]').forEach(edge=>{if(data.edges.includes(edge.dataset.edgeId))edge.setAttribute('data-focus-match','');else edge.removeAttribute('data-focus-match');});
336
+ }
337
+ requestAnimationFrame(()=>requestAnimationFrame(()=>{
338
+ if(pending!==data)return;
339
+ pending=null;
340
+ const active=document.querySelector('svg[data-focus-active]');
341
+ const selected=Array.from(document.querySelectorAll('[data-focus-selected]')).map(node=>node.dataset.nodeId);
342
+ const matched=Array.from(document.querySelectorAll('[data-edge-id][data-focus-match]')).map(edge=>edge.dataset.edgeId);
343
+ parent.postMessage({kind:'archify-explainer-focus-result',token,nodes:[...new Set(selected)],edges:[...new Set(matched)],active:active?.dataset.focusActive||''},'*');
344
+ }));
345
+ });
346
+ // The native hash listeners restore focus before the explicit host selection.
347
+ addEventListener('hashchange',()=>{if(pending&&location.hash.slice(1)===pending.hash)settle(pending);});
348
+ addEventListener('message',event=>{
349
+ const data=event.data;
350
+ if(event.source!==parent||data?.kind!=='archify-explainer-host'||data.token!==token)return;
351
+ if(['light','dark'].includes(data.theme))document.documentElement.dataset.theme=data.theme;
352
+ if(['classic','editorial'].includes(data.preset))window.Archify?.preset?.apply?.(data.preset);
353
+ if(typeof data.hash!=='string'||!/^(?:|focus=[^&]+|route=[^&]+~[^&]+)$/.test(data.hash))return;
354
+ pending=data;
355
+ if(location.hash.slice(1)===data.hash)dispatchEvent(new HashChangeEvent('hashchange'));
356
+ else location.hash=data.hash;
357
+ });
358
+ addEventListener('keydown',event=>{if(event.key==='Escape')parent.postMessage({kind:'archify-explainer-escape',token},'*');},true);
359
+ const ready=()=>requestAnimationFrame(()=>requestAnimationFrame(()=>parent.postMessage({kind:'archify-explainer-ready',token},'*')));
360
+ if(document.readyState==='complete')ready();else addEventListener('load',ready,{once:true});
361
+ })();<\/script>`;
362
+ viewer = {id,frame,token,hash,nodes:requestedNodes,edges:requestedEdges,ready:false};
363
+ viewerDialog.querySelector('.dialog-loading').hidden = false;
364
+ viewerDialog.append(frame);
365
+ nativeHtml(diagram).then(html=>{
366
+ if (viewer?.frame !== frame) return;
367
+ const end = html.toLowerCase().lastIndexOf('</body>');
368
+ frame.srcdoc = end < 0 ? html + script : html.slice(0,end) + script + html.slice(end);
369
+ },()=>notify(say('This diagram could not be opened.','无法打开此交互图。')));
370
+ }
371
+ // The page stores each native Viewer gzip-compressed; inflate it once, on first open.
372
+ function nativeHtml(diagram) {
373
+ if (typeof diagram.html === 'string') return Promise.resolve(diagram.html);
374
+ const bytes = Uint8Array.from(atob(diagram.htmlGzip),character=>character.charCodeAt(0));
375
+ return diagram.inflating ||= new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'))).text();
376
+ }
377
+ function closeViewer() { if(viewerDialog.open) viewerDialog.close(); }
378
+ document.querySelector('[data-close-diagram]').addEventListener('click',closeViewer);
379
+ viewerDialog.addEventListener('close',()=>{ document.body.classList.remove('dialog-open'); viewerOpener?.focus({preventScroll:true}); });
380
+ window.addEventListener('message',event=>{ if(!viewer || event.source !== viewer.frame.contentWindow || event.data?.token !== viewer.token) return; if(event.data.kind === 'archify-explainer-ready') {viewer.ready = true; viewerDialog.dataset.nativeReady = 'true'; viewerDialog.querySelector('.dialog-loading').hidden = true; sendViewer();} else if(event.data.kind === 'archify-explainer-focus-result') {viewerDialog.dataset.nativeFocus = JSON.stringify({nodes:event.data.nodes,edges:event.data.edges,active:event.data.active});} else if(event.data.kind === 'archify-explainer-escape') closeViewer(); });
381
+ all('[data-open-diagram]').forEach(button=>button.addEventListener('click',()=>openViewer(button.dataset.openDiagram,'',button)));
382
+ // A node clicked in the preview opens the interactive view focused on it, with its links.
383
+ all('.diagram-preview').forEach(preview=>preview.addEventListener('click',event=>{
384
+ const node = event.target.closest('[data-node-id]')?.dataset.nodeId, block = preview.closest('.block');
385
+ if (node) openViewer(block.id,`focus=${encodeURIComponent(node)}`,block.querySelector('[data-open-diagram]'));
386
+ }));
387
+ // One spotlight for every text-to-diagram link: the named nodes and edges stay lit, the rest dims.
388
+ const previewOf = id => byId(id)?.querySelector('.diagram-preview');
389
+ function spotlight(preview, nodes = [], edges = []) {
390
+ if (!preview) return;
391
+ let matched = false;
392
+ all('[data-node-id],[data-edge-id]',preview).forEach(element=>{const hit = nodes.includes(element.dataset.nodeId) || edges.includes(element.dataset.edgeId); matched ||= hit; element.classList.toggle('reader-highlight',hit);});
393
+ preview.classList.toggle('focusing',matched);
394
+ }
395
+ const targetOf = link => {
396
+ const hash = link.dataset.diagramHash || '';
397
+ let edges = []; try { edges = JSON.parse(link.dataset.diagramEdges || '[]'); } catch {}
398
+ return {preview:previewOf(link.dataset.diagramLink),nodes:hash.startsWith('focus=') ? [decodeURIComponent(hash.slice(6))] : hash.startsWith('route=') ? hash.slice(6).split('~').map(decodeURIComponent) : [],edges};
399
+ };
400
+ all('[data-diagram-link]').forEach(link=>{
401
+ link.addEventListener('click',event=>{ event.preventDefault(); openViewer(link.dataset.diagramLink,link.dataset.diagramHash || '',link); });
402
+ // A walk-through owns the highlight while it runs.
403
+ const highlight = active => { if (walking) return; const {preview,nodes,edges} = targetOf(link); spotlight(preview,active ? nodes : [],active ? edges : []); };
404
+ // A step lights its part of the diagram from anywhere in it, its description included.
405
+ const hover = link.closest('.steps>li') || link;
406
+ hover.addEventListener('mouseenter',()=>highlight(true)); hover.addEventListener('mouseleave',()=>highlight(false)); link.addEventListener('focus',()=>highlight(true)); link.addEventListener('blur',()=>highlight(false));
407
+ });
408
+ // A name in the text that is also a node label is linked to that node, once per panel, both ways:
409
+ // hovering the word lights the node, hovering the node lights the word.
410
+ function linkTerms() {
411
+ const terms = new Map();
412
+ all('.diagram-preview').forEach(preview=>all('[data-node-id][data-node-label]',preview).forEach(node=>{
413
+ const label = node.dataset.nodeLabel.trim();
414
+ const target = {diagram:preview.closest('.block').id,node:node.dataset.nodeId};
415
+ if ((/\p{Script=Han}/u.test(label) ? label.length >= 2 : label.length >= 3) && !terms.has(label.toLowerCase())) terms.set(label.toLowerCase(),target);
416
+ // A plain-word ID ("server" for "Server :443") also names the node in English prose.
417
+ if (/^[a-z]{3,}$/.test(target.node) && !terms.has(target.node)) terms.set(target.node,target);
418
+ }));
419
+ if (!terms.size) return;
420
+ const pattern = new RegExp([...terms.keys()].sort((a,b)=>b.length - a.length).map(label=>label.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'))
421
+ .join('|'),'giu');
422
+ const word = /[\p{L}\p{N}_]/u;
423
+ all('.panel').forEach(panel=>{
424
+ const seen = new Set();
425
+ const walker = document.createTreeWalker(panel.querySelector('.panel-body') || panel,NodeFilter.SHOW_TEXT,{acceptNode:text=>text.parentElement.closest('.block-diagram,.block-chart,pre,code,a,button,svg,figcaption,.term,.diagram-semantics,thead') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT});
426
+ const texts = []; while (walker.nextNode()) texts.push(walker.currentNode);
427
+ for (const text of texts) {
428
+ const value = text.nodeValue, parts = []; let last = 0;
429
+ for (const match of value.matchAll(pattern)) {
430
+ const before = value[match.index - 1], after = value[match.index + match[0].length];
431
+ if (seen.has(match[0].toLowerCase()) || (!/\p{Script=Han}/u.test(match[0]) && ((before && word.test(before)) || (after && word.test(after))))) continue;
432
+ seen.add(match[0].toLowerCase()); parts.push(value.slice(last,match.index),match[0]); last = match.index + match[0].length;
433
+ }
434
+ if (!parts.length) continue;
435
+ const fragment = document.createDocumentFragment();
436
+ parts.forEach((part,index)=>{ if (index % 2 === 0) { fragment.append(part); return; } const target = terms.get(part.toLowerCase()), span = document.createElement('span'); span.className = 'term'; span.dataset.diagram = target.diagram; span.dataset.node = target.node; span.textContent = part; fragment.append(span); });
437
+ fragment.append(value.slice(last)); text.replaceWith(fragment);
438
+ }
439
+ });
440
+ all('.term').forEach(term=>{ const lit = on => spotlight(previewOf(term.dataset.diagram),on ? [term.dataset.node] : []); term.addEventListener('mouseenter',()=>lit(true)); term.addEventListener('mouseleave',()=>lit(false)); });
441
+ all('.diagram-preview').forEach(preview=>{
442
+ const id = preview.closest('.block').id;
443
+ preview.addEventListener('mouseover',event=>{ const node = event.target.closest('[data-node-id]')?.dataset.nodeId; all('.term').forEach(term=>term.classList.toggle('term-lit',term.dataset.diagram === id && term.dataset.node === node)); });
444
+ preview.addEventListener('mouseleave',()=>all('.term-lit').forEach(term=>term.classList.remove('term-lit')));
445
+ });
446
+ }
447
+ // Walk-through: step lists that point into a diagram, and sequence diagrams message by message.
448
+ // Each step lights its part of the diagram; arrow keys move, Escape ends.
449
+ let walking = null;
450
+ function walk(control, items, show) {
451
+ const stop = () => { walking?.end(); };
452
+ if (walking?.control === control) { stop(); return; }
453
+ stop();
454
+ let index = 0;
455
+ const bar = document.createElement('div'); bar.className = 'walk-bar';
456
+ bar.innerHTML = `<button type="button" data-walk="prev">← ${words.prev}</button><span class="walk-count"></span><button type="button" data-walk="next">${words.next} →</button><span class="walk-text" aria-live="polite"></span><button type="button" data-walk="end">${words.end}</button>`;
457
+ // A diagram's bar sits above the diagram, so it stays in view while a tall diagram scrolls past.
458
+ const figure = control.closest('figcaption')?.closest('figure'); if (figure) figure.before(bar); else control.after(bar);
459
+ // The bar takes the start button's place while the walk runs, so one set of controls shows.
460
+ const hadFocus = document.activeElement === control;
461
+ control.setAttribute('aria-pressed','true'); control.hidden = true;
462
+ if (hadFocus) bar.querySelector('[data-walk=next]').focus();
463
+ // The bar changes the panel height the board was planned with.
464
+ const replan = () => requestAnimationFrame(()=>{ layoutBoard(); ensureDiagramReadability(); });
465
+ replan();
466
+ const render = () => { bar.querySelector('.walk-count').textContent = `${index + 1} / ${items.length}`; bar.querySelector('.walk-text').textContent = show(index) || ''; };
467
+ const move = delta => { index = Math.max(0,Math.min(items.length - 1,index + delta)); render(); };
468
+ bar.addEventListener('click',event=>{ const action = event.target.closest('[data-walk]')?.dataset.walk; if (action === 'end') stop(); else if (action) move(action === 'next' ? 1 : -1); });
469
+ const keys = event => { if (event.target.closest('input,textarea') || viewerDialog.open) return; if (event.key === 'ArrowRight') { event.preventDefault(); move(1); } else if (event.key === 'ArrowLeft') { event.preventDefault(); move(-1); } else if (event.key === 'Escape') stop(); };
470
+ document.addEventListener('keydown',keys);
471
+ walking = {control,end:()=>{ const refocus = bar.contains(document.activeElement); document.removeEventListener('keydown',keys); bar.remove(); control.hidden = false; control.setAttribute('aria-pressed','false'); if (refocus) control.focus(); show(-1); walking = null; replan(); }};
472
+ render();
473
+ }
474
+ function setupWalks() {
475
+ all('ol.steps').forEach(list=>{
476
+ const steps = all(':scope>li',list).filter(item=>item.querySelector('[data-diagram-link]'));
477
+ if (steps.length < 2) return;
478
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'interactive walk-start'; button.textContent = `▶ ${words.walk}`; button.setAttribute('aria-pressed','false');
479
+ list.before(button);
480
+ button.addEventListener('click',()=>walk(button,steps,index=>{
481
+ steps.forEach((step,i)=>step.classList.toggle('walk-current',i === index));
482
+ all('.diagram-preview').forEach(preview=>spotlight(preview));
483
+ if (index < 0) return;
484
+ const {preview,nodes,edges} = targetOf(steps[index].querySelector('[data-diagram-link]'));
485
+ spotlight(preview,nodes,edges);
486
+ const box = preview?.getBoundingClientRect();
487
+ if (box && (box.bottom < 0 || box.top > innerHeight)) preview.scrollIntoView({block:'nearest',behavior:'smooth'});
488
+ }));
489
+ });
490
+ // A workflow no step list walks through can be walked node by node, in the order it was written,
491
+ // each node lit with the edges that lead into it.
492
+ const walked = new Set(all('ol.steps [data-diagram-link]').map(link=>link.dataset.diagramLink));
493
+ all('.block-diagram').forEach(block=>{
494
+ const diagram = diagrams[block.id], nodes = diagram?.semantic?.nodes || [], edges = diagram?.semantic?.edges || [];
495
+ if (diagram?.diagramType !== 'workflow' || walked.has(block.id) || nodes.length < 3) return;
496
+ const preview = block.querySelector('.diagram-preview');
497
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'walk-start'; button.textContent = `▶ ${words.walkFlow}`; button.setAttribute('aria-pressed','false');
498
+ block.querySelector('.diagram-title')?.after(button);
499
+ button.addEventListener('click',()=>walk(button,nodes,index=>{
500
+ if (index < 0) { spotlight(preview); return; }
501
+ const node = nodes[index], into = edges.filter(edge=>edge.to === node.id && (index === 0 || nodes.slice(0,index).some(seen=>seen.id === edge.from)));
502
+ spotlight(preview,[node.id,...into.map(edge=>edge.from)],into.map(edge=>edge.id));
503
+ const target = preview.querySelector(`[data-node-id="${CSS.escape(node.id)}"]`), box = target?.getBoundingClientRect();
504
+ if (box && (box.top < 0 || box.bottom > innerHeight - 80)) target.scrollIntoView({block:'center',behavior:'smooth'});
505
+ return node.label || node.id;
506
+ }));
507
+ });
508
+ all('.block-diagram').forEach(block=>{
509
+ if (diagrams[block.id]?.diagramType !== 'sequence') return;
510
+ const preview = block.querySelector('.diagram-preview'), messages = all('[data-edge-id]',preview);
511
+ if (messages.length < 2) return;
512
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'walk-start'; button.textContent = `▶ ${words.walkMessages}`; button.setAttribute('aria-pressed','false');
513
+ block.querySelector('.diagram-title')?.after(button);
514
+ const label = id => preview.querySelector(`[data-node-id="${CSS.escape(id)}"]`)?.dataset.nodeLabel || id;
515
+ button.addEventListener('click',()=>walk(button,messages,index=>{
516
+ if (index < 0) { spotlight(preview); return; }
517
+ const message = messages[index];
518
+ spotlight(preview,[message.dataset.edgeFrom,message.dataset.edgeTo],[message.dataset.edgeId]);
519
+ const box = message.getBoundingClientRect();
520
+ if (box.top < 0 || box.bottom > innerHeight - 80) message.scrollIntoView({block:'center',behavior:'smooth'});
521
+ return `${label(message.dataset.edgeFrom)} → ${label(message.dataset.edgeTo)}${say(': ',':')}${message.dataset.edgeLabel || ''}`;
522
+ }));
523
+ });
524
+ }
525
+ all('[data-copy]').forEach(button=>button.addEventListener('click',()=>{
526
+ const text = button.closest('figure').querySelector('pre code').textContent;
527
+ Promise.resolve(navigator.clipboard?.writeText(text)).then(()=>{button.textContent = words.copied;setTimeout(()=>{button.textContent = words.copy;},1600);},()=>notify(say('Copy failed.','复制失败。')));
528
+ }));
529
+ // Follow-up questions: a reader asks about a panel, or about selected text in it, and copies a
530
+ // message that names the page, the panel ID and the quote, so the agent can revise just that panel.
531
+ function setupAsk() {
532
+ const dialog = document.createElement('dialog'); dialog.className = 'ask-dialog';
533
+ const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
534
+ dialog.innerHTML = `<form method="dialog"><header><div><h2 id="ask-title">${words.ask}</h2><p class="ask-where"></p></div><button type="button" class="ask-close" data-ask-cancel aria-label="${words.askCancel}"><svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg></button></header><blockquote hidden></blockquote><div class="ask-chips" role="group" aria-label="${words.askQuestion}">${words.askChips.map(chip=>`<button type="button" data-chip>${chip}</button>`).join('')}</div><textarea rows="4" aria-labelledby="ask-title" placeholder="${words.askPlaceholder}"></textarea><footer><p class="ask-note">${words.askNote}</p><button type="button" data-ask-cancel>${words.askCancel}</button><button type="submit" class="primary">${words.askCopy}<kbd>${mac ? '⌘↵' : 'Ctrl ↵'}</kbd></button></footer></form>`;
535
+ document.body.append(dialog);
536
+ const area = dialog.querySelector('textarea'), quoteBox = dialog.querySelector('blockquote');
537
+ let context = null;
538
+ const open = (panel, quote = '') => {
539
+ const number = panel.id === 'answer' ? words.answer : panel.querySelector('.panel-id')?.textContent.trim();
540
+ const title = panel.id === 'answer' ? words.answer : panel.dataset.title || panel.querySelector('h2')?.textContent.trim();
541
+ context = {panel,quote,number,title};
542
+ dialog.querySelector('.ask-where').textContent = panel.id === 'answer' ? title : say(`Panel ${number} · ${title}`,`面板 ${number} · ${title}`);
543
+ all('[data-chip]',dialog).forEach(chip=>chip.setAttribute('aria-pressed','false'));
544
+ quoteBox.hidden = !quote; quoteBox.textContent = quote;
545
+ area.value = ''; dialog.showModal(); area.focus();
546
+ };
547
+ // A suggested question is added once; choosing it again takes it back out.
548
+ dialog.querySelectorAll('[data-chip]').forEach(chip=>chip.addEventListener('click',()=>{
549
+ const lines = area.value.split('\n').filter(line=>line.trim()), on = !lines.includes(chip.textContent);
550
+ area.value = (on ? [...lines,chip.textContent] : lines.filter(line=>line !== chip.textContent)).join('\n');
551
+ chip.setAttribute('aria-pressed',String(on)); area.focus();
552
+ }));
553
+ area.addEventListener('keydown',event=>{ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); dialog.querySelector('form').requestSubmit(); } });
554
+ dialog.querySelectorAll('[data-ask-cancel]').forEach(button=>button.addEventListener('click',()=>dialog.close()));
555
+ dialog.querySelector('form').addEventListener('submit',event=>{
556
+ event.preventDefault();
557
+ const {panel,quote,number,title} = context, page = document.querySelector('h1')?.textContent.trim();
558
+ const file = location.protocol === 'file:' ? decodeURIComponent(location.pathname) : location.href.split('#')[0];
559
+ const quoteMark = text => english ? `"${text}"` : `「${text}」`;
560
+ const lines = [
561
+ `${words.askPage}${say(': ',':')}${quoteMark(page)}`,
562
+ `${words.askFile}${say(': ',':')}${file}`,
563
+ `${words.askWhere}${say(': ',':')}${panel.id === 'answer' ? title : say(`Panel ${number} ${quoteMark(title)}`,`面板 ${number}${quoteMark(title)}`)}${say(` (ID: ${panel.id})`,`(ID: ${panel.id})`)}`,
564
+ ...(quote ? [`${words.askQuoteLabel}${say(': ',':')}${quoteMark(quote)}`] : []),
565
+ `${words.askQuestion}${say(': ',':')}${area.value.trim() || words.askChips[0]}`,
566
+ ...(panel.id === 'answer' ? [] : [words.askPatch]),
567
+ ];
568
+ const text = lines.join('\n');
569
+ Promise.resolve(navigator.clipboard?.writeText(text)).then(()=>{ dialog.close(); notify(words.askCopied); },()=>{ area.value = text; area.select(); });
570
+ });
571
+ all('.panel').forEach(panel=>{
572
+ const button = document.createElement('button'); button.type = 'button'; button.className = 'ask-button interactive'; button.textContent = words.ask;
573
+ button.addEventListener('click',()=>open(panel));
574
+ panel.querySelector('.panel-head')?.append(button);
575
+ });
576
+ const floating = document.createElement('button'); floating.type = 'button'; floating.className = 'ask-floating'; floating.textContent = words.askQuote; floating.hidden = true;
577
+ document.body.append(floating);
578
+ let selected = null;
579
+ document.addEventListener('selectionchange',()=>{
580
+ const selection = getSelection(), text = selection.toString().replace(/\s+/g,' ').trim();
581
+ const panel = selection.rangeCount ? selection.getRangeAt(0).commonAncestorContainer.parentElement?.closest?.('.panel') || (selection.getRangeAt(0).commonAncestorContainer.closest?.('.panel')) : null;
582
+ if (!panel || text.length < 2 || dialog.open) { floating.hidden = true; selected = null; return; }
583
+ const box = selection.getRangeAt(0).getBoundingClientRect();
584
+ selected = {panel,text:text.slice(0,400)};
585
+ floating.style.left = `${Math.max(8,Math.min(innerWidth - 120,box.left + box.width / 2 - 45 + scrollX))}px`;
586
+ floating.style.top = `${box.top + scrollY - 38}px`;
587
+ floating.hidden = false;
588
+ });
589
+ floating.addEventListener('mousedown',event=>event.preventDefault());
590
+ floating.addEventListener('click',()=>{ if (selected) open(selected.panel,selected.text); floating.hidden = true; });
591
+ }
592
+ // Source cards: hovering or focusing a citation shows where the claim comes from, with the pinned
593
+ // code excerpt for a repository source, so a reader can check it without leaving the page.
594
+ const glyph = path => `<svg viewBox="0 0 16 16"><path d="${path}"/></svg>`;
595
+ const sourceIcons = {
596
+ code:glyph('M5.5 5 2.5 8l3 3M10.5 5l3 3-3 3M9 3.5 7 12.5'),
597
+ web:glyph('M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2zM2 8h12M8 2c1.7 1.6 2.5 3.6 2.5 6S9.7 12.4 8 14C6.3 12.4 5.5 10.4 5.5 8S6.3 3.6 8 2z'),
598
+ note:glyph('M4 2.5h5.5l2.5 2.5v8.5H4zM9.5 2.5V5H12M6 8h4M6 10.5h4'),
599
+ };
600
+ function setupSourceCards() {
601
+ const sources = prepared.preparedSources?.sources || {};
602
+ const card = document.createElement('div'); card.className = 'source-card'; card.hidden = true; card.setAttribute('role','tooltip');
603
+ document.body.append(card);
604
+ let hideTimer;
605
+ const hide = () => { hideTimer = setTimeout(()=>{ card.hidden = true; },160); };
606
+ card.addEventListener('mouseenter',()=>clearTimeout(hideTimer)); card.addEventListener('mouseleave',hide);
607
+ const show = (link, id = decodeURIComponent(link.getAttribute('href').slice('#source-'.length))) => {
608
+ const source = sources[id] || prepared.document?.sources?.find(entry=>entry.id === id);
609
+ if (!source) return;
610
+ clearTimeout(hideTimer);
611
+ const escapeText = value => String(value ?? '').replace(/[&<>"]/g,character=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[character]));
612
+ // A repository source shows its pinned excerpt; a web page its host; any other note its text.
613
+ const host = (()=>{ try { return new URL(source.url).host.replace(/^www\./,''); } catch { return ''; } })();
614
+ const where = source.path ? `${source.path}${source.lines ? `:${source.lines.join('–')}` : ''}` : host;
615
+ const kind = source.path ? 'code' : source.url ? 'web' : 'note';
616
+ const lines = source.path && typeof source.text === 'string' ? source.text.split('\n') : [];
617
+ const first = source.lines?.[0] || 1;
618
+ const excerpt = lines.length ? `<pre><code>${lines.slice(0,14).map((line,index)=>`<span class="line"><i>${first + index}</i>${escapeText(line) || ' '}</span>`).join('')}${lines.length > 14 ? `<span class="line more"><i></i>…</span>` : ''}</code></pre>` : '';
619
+ const note = !source.path && (source.text || source.method) ? `<p class="source-note">${escapeText(source.text || '')}${source.method ? `<span>${escapeText(source.method)}</span>` : ''}</p>` : '';
620
+ const number = /^\d+$/.test(link.textContent.trim()) ? link.textContent.trim() : '';
621
+ card.innerHTML = `<header><span class="source-icon" data-kind="${kind}" aria-hidden="true">${sourceIcons[kind]}</span><span class="source-head"><b>${escapeText(source.title || source.path || id)}</b>${where ? `<span class="source-where">${escapeText(where)}</span>` : ''}</span>${number ? `<span class="source-number">${number}</span>` : ''}</header>${excerpt}${note}${source.revision || source.url ? `<footer>${source.revision ? `<span class="source-pin">${say('Pinned at','固定在')} <code>${escapeText(String(source.revision).slice(0,7))}</code></span>` : '<span></span>'}${source.url ? `<a href="${escapeText(source.url)}" target="_blank" rel="noopener noreferrer">${say('Open source','打开原文')} ↗</a>` : ''}</footer>` : ''}`;
622
+ card.hidden = false;
623
+ const box = link.getBoundingClientRect(), width = Math.min(520,innerWidth - 24);
624
+ card.style.width = `${width}px`;
625
+ card.style.left = `${Math.max(12,Math.min(innerWidth - width - 12,box.left - 24)) + scrollX}px`;
626
+ const below = box.bottom + 8 + card.offsetHeight < innerHeight;
627
+ card.style.top = `${(below ? box.bottom + 8 : box.top - 8 - card.offsetHeight) + scrollY}px`;
628
+ };
629
+ all('.cite a[href^="#source-"]').forEach(link=>{
630
+ link.addEventListener('mouseenter',()=>show(link)); link.addEventListener('focus',()=>show(link));
631
+ link.addEventListener('mouseleave',hide); link.addEventListener('blur',hide);
632
+ });
633
+ // A diagram node tied to a source opens the same card, so a code diagram doubles as a code tour.
634
+ all('.diagram-preview[data-node-sources]').forEach(preview=>{
635
+ let map = {}; try { map = JSON.parse(preview.dataset.nodeSources); } catch {}
636
+ all('[data-node-id]',preview).forEach(node=>{
637
+ const id = map[node.dataset.nodeId]; if (!id) return;
638
+ node.classList.add('has-source');
639
+ node.addEventListener('mouseenter',()=>show(node,id)); node.addEventListener('mouseleave',hide);
640
+ });
641
+ });
642
+ document.addEventListener('keydown',event=>{ if (event.key === 'Escape') card.hidden = true; });
643
+ }
644
+ // Checklist ticks are the reader's own progress; keep them per page and block across reloads.
645
+ all('.checklist').forEach(list=>{
646
+ const key = `archify-checklist:${root.dataset.artifact}:${list.closest('.block').id}`, boxes = all('input',list);
647
+ let saved = []; try { saved = JSON.parse(localStorage.getItem(key) || '[]'); } catch {}
648
+ boxes.forEach((box,index)=>{ box.checked = saved[index] === 1; box.addEventListener('change',()=>{ try { localStorage.setItem(key,JSON.stringify(boxes.map(b=>b.checked ? 1 : 0))); } catch {} }); });
649
+ });
650
+ // Native dialogs supply Escape, focus trapping and the accessible modal semantics.
651
+ const printDetails = new Map();
652
+ all('.diagram-semantics').forEach(details=>details.open = false);
653
+ window.addEventListener('beforeprint',()=>all('.diagram-semantics').forEach(details=>{printDetails.set(details,details.open);details.open = true;}));
654
+ window.addEventListener('afterprint',()=>{printDetails.forEach((open,details)=>details.open = open);printDetails.clear();});
655
+ root.classList.add('js');
656
+ applyTheme();
657
+ cropDiagramPreviews();
658
+ linkTerms();
659
+ setupWalks();
660
+ setupAsk();
661
+ setupSourceCards();
662
+ layoutBoard();
663
+ const relayout = () => { layoutBoard(); ensureDiagramReadability(); };
664
+ Promise.resolve(document.fonts?.ready).then(()=>requestAnimationFrame(()=>requestAnimationFrame(relayout)));
665
+ let resizeFrame;
666
+ window.addEventListener('resize',()=>{cancelAnimationFrame(resizeFrame);resizeFrame = requestAnimationFrame(relayout);});
667
+ })();