@formicoidea/labre-core 0.31.0 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (637) hide show
  1. package/dist/_pkgs/affine-block-attachment/attachment-block.js +1 -1
  2. package/dist/_pkgs/affine-block-callout/callout-block.d.ts +6 -0
  3. package/dist/_pkgs/affine-block-callout/callout-block.js +26 -4
  4. package/dist/_pkgs/affine-block-callout/callout-keymap.js +59 -19
  5. package/dist/_pkgs/affine-block-callout/commands/callout-to-paragraph.d.ts +20 -0
  6. package/dist/_pkgs/affine-block-callout/commands/callout-to-paragraph.js +60 -0
  7. package/dist/_pkgs/affine-block-callout/commands/split-callout.d.ts +17 -0
  8. package/dist/_pkgs/affine-block-callout/commands/split-callout.js +42 -0
  9. package/dist/_pkgs/affine-block-callout/configs/slash-menu.js +9 -6
  10. package/dist/_pkgs/affine-block-callout/emoji-margin.d.ts +9 -0
  11. package/dist/_pkgs/affine-block-callout/emoji-margin.js +36 -0
  12. package/dist/_pkgs/affine-block-callout/emoji-menu.d.ts +5 -1
  13. package/dist/_pkgs/affine-block-callout/emoji-menu.js +5 -0
  14. package/dist/_pkgs/affine-block-code/code-block-inline.js +1 -1
  15. package/dist/_pkgs/affine-block-code/code-block.d.ts +14 -0
  16. package/dist/_pkgs/affine-block-code/code-block.js +44 -6
  17. package/dist/_pkgs/affine-block-code/code-toolbar/components/code-toolbar.d.ts +2 -0
  18. package/dist/_pkgs/affine-block-code/code-toolbar/components/code-toolbar.js +23 -14
  19. package/dist/_pkgs/affine-block-code/code-toolbar/components/more-menu.d.ts +16 -0
  20. package/dist/_pkgs/affine-block-code/code-toolbar/components/more-menu.js +93 -0
  21. package/dist/_pkgs/affine-block-code/code-toolbar/config.js +54 -5
  22. package/dist/_pkgs/affine-block-code/code-toolbar/index.js +1 -1
  23. package/dist/_pkgs/affine-block-code/effects.d.ts +2 -0
  24. package/dist/_pkgs/affine-block-code/effects.js +2 -0
  25. package/dist/_pkgs/affine-block-code/highlight/affine-code-unit.js +6 -1
  26. package/dist/_pkgs/affine-block-code/index.d.ts +1 -0
  27. package/dist/_pkgs/affine-block-code/index.js +1 -0
  28. package/dist/_pkgs/affine-block-code/line-numbers.d.ts +25 -0
  29. package/dist/_pkgs/affine-block-code/line-numbers.js +27 -0
  30. package/dist/_pkgs/affine-block-code/styles.js +31 -0
  31. package/dist/_pkgs/affine-block-data-view/data-view-block.js +1 -0
  32. package/dist/_pkgs/affine-block-database/components/title/index.js +4 -1
  33. package/dist/_pkgs/affine-block-database/configs/slash-menu.js +28 -1
  34. package/dist/_pkgs/affine-block-database/data-source.js +1 -2
  35. package/dist/_pkgs/affine-block-database/database-block.js +13 -0
  36. package/dist/_pkgs/affine-block-database/external-data-source.js +1 -1
  37. package/dist/_pkgs/affine-block-database/properties/paste-url.d.ts +24 -0
  38. package/dist/_pkgs/affine-block-database/properties/paste-url.js +98 -0
  39. package/dist/_pkgs/affine-block-database/properties/rich-text/cell-renderer.js +9 -19
  40. package/dist/_pkgs/affine-block-database/properties/title/text.js +20 -25
  41. package/dist/_pkgs/affine-block-database/views/index.js +1 -0
  42. package/dist/_pkgs/affine-block-edgeless-text/edgeless-text-block.js +11 -1
  43. package/dist/_pkgs/affine-block-edgeless-text/edgeless-toolbar/config.d.ts +3 -0
  44. package/dist/_pkgs/affine-block-edgeless-text/edgeless-toolbar/config.js +48 -1
  45. package/dist/_pkgs/affine-block-edgeless-text/edgeless-toolbar/font-size.d.ts +17 -0
  46. package/dist/_pkgs/affine-block-edgeless-text/edgeless-toolbar/font-size.js +24 -0
  47. package/dist/_pkgs/affine-block-embed/embed-figma-block/embed-figma-service.js +1 -1
  48. package/dist/_pkgs/affine-block-embed/embed-iframe-block/configs/toolbar.js +2 -2
  49. package/dist/_pkgs/affine-block-embed-doc/common/doc-trashed.d.ts +9 -0
  50. package/dist/_pkgs/affine-block-embed-doc/common/doc-trashed.js +10 -0
  51. package/dist/_pkgs/affine-block-embed-doc/embed-linked-doc-block/configs/toolbar.js +1 -1
  52. package/dist/_pkgs/affine-block-embed-doc/embed-linked-doc-block/embed-linked-doc-block.js +8 -4
  53. package/dist/_pkgs/affine-block-embed-doc/embed-linked-doc-block/utils.js +1 -1
  54. package/dist/_pkgs/affine-block-embed-doc/embed-synced-doc-block/configs/toolbar.js +1 -1
  55. package/dist/_pkgs/affine-block-embed-doc/embed-synced-doc-block/embed-synced-doc-block.d.ts +4 -0
  56. package/dist/_pkgs/affine-block-embed-doc/embed-synced-doc-block/embed-synced-doc-block.js +23 -4
  57. package/dist/_pkgs/affine-block-frame/frame-manager.d.ts +13 -0
  58. package/dist/_pkgs/affine-block-frame/frame-manager.js +34 -2
  59. package/dist/_pkgs/affine-block-frame/frame-tool.js +1 -1
  60. package/dist/_pkgs/affine-block-image/components/page-image-block.js +1 -1
  61. package/dist/_pkgs/affine-block-image/image-edgeless-block.js +1 -1
  62. package/dist/_pkgs/affine-block-latex/styles.js +6 -1
  63. package/dist/_pkgs/affine-block-list/utils/forward-delete.js +1 -1
  64. package/dist/_pkgs/affine-block-note/commands/select-block.js +1 -1
  65. package/dist/_pkgs/affine-block-note/commands/select-blocks-between.js +1 -1
  66. package/dist/_pkgs/affine-block-note/move-block.js +1 -1
  67. package/dist/_pkgs/affine-block-note/note-edgeless-block.d.ts +9 -0
  68. package/dist/_pkgs/affine-block-note/note-edgeless-block.js +29 -1
  69. package/dist/_pkgs/affine-block-paragraph/utils/forward-delete.js +2 -2
  70. package/dist/_pkgs/affine-block-paragraph/utils/merge-with-prev.js +1 -1
  71. package/dist/_pkgs/affine-block-root/commands/pivot-commands.d.ts +37 -0
  72. package/dist/_pkgs/affine-block-root/commands/pivot-commands.js +168 -0
  73. package/dist/_pkgs/affine-block-root/commands/reading-commands.d.ts +23 -0
  74. package/dist/_pkgs/affine-block-root/commands/reading-commands.js +107 -0
  75. package/dist/_pkgs/affine-block-root/commands/tag-commands.d.ts +47 -0
  76. package/dist/_pkgs/affine-block-root/commands/tag-commands.js +159 -0
  77. package/dist/_pkgs/affine-block-root/configs/toolbar.d.ts +1 -0
  78. package/dist/_pkgs/affine-block-root/edgeless/configs/toolbar/more.js +10 -3
  79. package/dist/_pkgs/affine-block-root/edgeless/configs/toolbar/render-linked-doc.js +1 -1
  80. package/dist/_pkgs/affine-block-root/edgeless/edgeless-keyboard.js +27 -2
  81. package/dist/_pkgs/affine-block-root/edgeless/edgeless-root-block.js +11 -2
  82. package/dist/_pkgs/affine-block-root/edgeless/edgeless-root-service.js +14 -0
  83. package/dist/_pkgs/affine-block-root/edgeless/index.d.ts +1 -0
  84. package/dist/_pkgs/affine-block-root/edgeless/index.js +1 -0
  85. package/dist/_pkgs/affine-block-root/edgeless/reserved-keys.d.ts +21 -0
  86. package/dist/_pkgs/affine-block-root/edgeless/reserved-keys.js +34 -0
  87. package/dist/_pkgs/affine-block-root/edgeless/utils/clipboard-utils.js +1 -1
  88. package/dist/_pkgs/affine-block-root/edgeless/utils/clone-utils.d.ts +7 -1
  89. package/dist/_pkgs/affine-block-root/edgeless/utils/clone-utils.js +11 -2
  90. package/dist/_pkgs/affine-block-root/index.d.ts +4 -1
  91. package/dist/_pkgs/affine-block-root/index.js +4 -1
  92. package/dist/_pkgs/affine-block-root/keyboard/commands.d.ts +2 -0
  93. package/dist/_pkgs/affine-block-root/keyboard/commands.js +147 -0
  94. package/dist/_pkgs/affine-block-root/view.js +13 -4
  95. package/dist/_pkgs/affine-block-surface/adapters/markdown/element-adapter/type.js +1 -1
  96. package/dist/_pkgs/affine-block-surface/adapters/plain-text/element-adapter/type.js +1 -1
  97. package/dist/_pkgs/affine-block-surface/commands/reassociate-connectors.js +0 -1
  98. package/dist/_pkgs/affine-block-surface/effects.js +6 -0
  99. package/dist/_pkgs/affine-block-surface/element-model/index.d.ts +21 -3
  100. package/dist/_pkgs/affine-block-surface/element-model/index.js +14 -2
  101. package/dist/_pkgs/affine-block-surface/extensions/audit.d.ts +107 -0
  102. package/dist/_pkgs/affine-block-surface/extensions/audit.js +489 -0
  103. package/dist/_pkgs/affine-block-surface/extensions/crud-extension.d.ts +14 -0
  104. package/dist/_pkgs/affine-block-surface/extensions/crud-extension.js +37 -5
  105. package/dist/_pkgs/affine-block-surface/extensions/editor-anchored-panel.d.ts +133 -0
  106. package/dist/_pkgs/affine-block-surface/extensions/editor-anchored-panel.js +327 -0
  107. package/dist/_pkgs/affine-block-surface/extensions/element-renderer.js +1 -1
  108. package/dist/_pkgs/affine-block-surface/extensions/export-manager/export-manager.js +1 -1
  109. package/dist/_pkgs/affine-block-surface/extensions/index.d.ts +17 -0
  110. package/dist/_pkgs/affine-block-surface/extensions/index.js +17 -0
  111. package/dist/_pkgs/affine-block-surface/extensions/interchange-import.d.ts +172 -0
  112. package/dist/_pkgs/affine-block-surface/extensions/interchange-import.js +393 -0
  113. package/dist/_pkgs/affine-block-surface/extensions/interchange.d.ts +306 -0
  114. package/dist/_pkgs/affine-block-surface/extensions/interchange.js +64 -0
  115. package/dist/_pkgs/affine-block-surface/extensions/map-quality-widget.d.ts +146 -0
  116. package/dist/_pkgs/affine-block-surface/extensions/map-quality-widget.js +528 -0
  117. package/dist/_pkgs/affine-block-surface/extensions/map-quality.d.ts +109 -0
  118. package/dist/_pkgs/affine-block-surface/extensions/map-quality.js +84 -0
  119. package/dist/_pkgs/affine-block-surface/extensions/morph-toolbar.d.ts +248 -0
  120. package/dist/_pkgs/affine-block-surface/extensions/morph-toolbar.js +248 -0
  121. package/dist/_pkgs/affine-block-surface/extensions/reading-toolbar.d.ts +15 -0
  122. package/dist/_pkgs/affine-block-surface/extensions/reading-toolbar.js +158 -0
  123. package/dist/_pkgs/affine-block-surface/extensions/reading-widget.d.ts +145 -0
  124. package/dist/_pkgs/affine-block-surface/extensions/reading-widget.js +588 -0
  125. package/dist/_pkgs/affine-block-surface/extensions/reading.d.ts +494 -0
  126. package/dist/_pkgs/affine-block-surface/extensions/reading.js +767 -0
  127. package/dist/_pkgs/affine-block-surface/extensions/spotlight.d.ts +45 -0
  128. package/dist/_pkgs/affine-block-surface/extensions/spotlight.js +177 -0
  129. package/dist/_pkgs/affine-block-surface/extensions/svg-sketch.d.ts +107 -0
  130. package/dist/_pkgs/affine-block-surface/extensions/svg-sketch.js +958 -0
  131. package/dist/_pkgs/affine-block-surface/extensions/tags-toolbar.d.ts +10 -0
  132. package/dist/_pkgs/affine-block-surface/extensions/tags-toolbar.js +295 -0
  133. package/dist/_pkgs/affine-block-surface/extensions/validation-toolbar.d.ts +27 -0
  134. package/dist/_pkgs/affine-block-surface/extensions/validation-toolbar.js +361 -0
  135. package/dist/_pkgs/affine-block-surface/extensions/validation.d.ts +2129 -0
  136. package/dist/_pkgs/affine-block-surface/extensions/validation.js +4620 -0
  137. package/dist/_pkgs/affine-block-surface/extensions/violation-detail-widget.d.ts +330 -0
  138. package/dist/_pkgs/affine-block-surface/extensions/violation-detail-widget.js +1004 -0
  139. package/dist/_pkgs/affine-block-surface/extensions/violation-timeline.d.ts +80 -0
  140. package/dist/_pkgs/affine-block-surface/extensions/violation-timeline.js +123 -0
  141. package/dist/_pkgs/affine-block-surface/framework-background/def.d.ts +610 -0
  142. package/dist/_pkgs/affine-block-surface/framework-background/def.js +206 -0
  143. package/dist/_pkgs/affine-block-surface/framework-background/facts.d.ts +188 -0
  144. package/dist/_pkgs/affine-block-surface/framework-background/facts.js +284 -0
  145. package/dist/_pkgs/affine-block-surface/framework-background/index.d.ts +5 -0
  146. package/dist/_pkgs/affine-block-surface/framework-background/index.js +5 -0
  147. package/dist/_pkgs/affine-block-surface/framework-background/interaction.d.ts +12 -0
  148. package/dist/_pkgs/affine-block-surface/framework-background/interaction.js +24 -0
  149. package/dist/_pkgs/affine-block-surface/framework-background/labels.d.ts +99 -0
  150. package/dist/_pkgs/affine-block-surface/framework-background/labels.js +193 -0
  151. package/dist/_pkgs/affine-block-surface/framework-background/renderer.d.ts +8 -0
  152. package/dist/_pkgs/affine-block-surface/framework-background/renderer.js +399 -0
  153. package/dist/_pkgs/affine-block-surface/index.d.ts +1 -0
  154. package/dist/_pkgs/affine-block-surface/index.js +1 -0
  155. package/dist/_pkgs/affine-block-surface/renderer/canvas-renderer.d.ts +69 -3
  156. package/dist/_pkgs/affine-block-surface/renderer/canvas-renderer.js +333 -44
  157. package/dist/_pkgs/affine-block-surface/renderer/dom-renderer.js +18 -2
  158. package/dist/_pkgs/affine-block-surface/renderer/overlay.js +1 -1
  159. package/dist/_pkgs/affine-block-surface/tool/default-tool.js +8 -0
  160. package/dist/_pkgs/affine-block-surface/utils/font.js +9 -3
  161. package/dist/_pkgs/affine-block-surface/view.d.ts +17 -0
  162. package/dist/_pkgs/affine-block-surface/view.js +57 -2
  163. package/dist/_pkgs/affine-block-surface-ref/portal/note.js +2 -2
  164. package/dist/_pkgs/affine-block-table/commands.js +1 -1
  165. package/dist/_pkgs/affine-block-table/selection-controller.d.ts +6 -0
  166. package/dist/_pkgs/affine-block-table/selection-controller.js +24 -1
  167. package/dist/_pkgs/affine-block-table/table-block.js +8 -2
  168. package/dist/_pkgs/affine-block-table/table-cell.js +5 -6
  169. package/dist/_pkgs/affine-block-table/table-keymap.d.ts +18 -0
  170. package/dist/_pkgs/affine-block-table/table-keymap.js +41 -0
  171. package/dist/_pkgs/affine-block-table/utils.d.ts +7 -0
  172. package/dist/_pkgs/affine-block-table/utils.js +12 -0
  173. package/dist/_pkgs/affine-block-table/view.js +2 -0
  174. package/dist/_pkgs/affine-components/caption/captioned-block-component.js +2 -1
  175. package/dist/_pkgs/affine-components/context-menu/button.d.ts +7 -0
  176. package/dist/_pkgs/affine-components/context-menu/button.js +7 -2
  177. package/dist/_pkgs/affine-components/context-menu/input.d.ts +4 -0
  178. package/dist/_pkgs/affine-components/context-menu/input.js +12 -0
  179. package/dist/_pkgs/affine-components/context-menu/menu-all.d.ts +2 -0
  180. package/dist/_pkgs/affine-components/context-menu/menu-renderer.js +8 -1
  181. package/dist/_pkgs/affine-components/context-menu/sub-menu.js +3 -0
  182. package/dist/_pkgs/affine-components/edgeless-shape-color-picker/color-picker.js +1 -1
  183. package/dist/_pkgs/affine-components/embed-card-modal/embed-card-edit-modal.js +1 -1
  184. package/dist/_pkgs/affine-components/filterable-list/styles.js +1 -1
  185. package/dist/_pkgs/affine-components/highlight-dropdown-menu/highlight-duotone-icon.js +8 -8
  186. package/dist/_pkgs/affine-components/highlight-dropdown-menu/text-duotone-icon.js +11 -11
  187. package/dist/_pkgs/affine-components/icon-picker/affine-icon-picker-panel.d.ts +28 -0
  188. package/dist/_pkgs/affine-components/icon-picker/affine-icon-picker-panel.js +231 -0
  189. package/dist/_pkgs/affine-components/icon-picker/emoji-data.d.ts +40 -0
  190. package/dist/_pkgs/affine-components/icon-picker/emoji-data.js +99 -0
  191. package/dist/_pkgs/affine-components/icon-picker/emoji-picker-panel.d.ts +32 -0
  192. package/dist/_pkgs/affine-components/icon-picker/emoji-picker-panel.js +276 -0
  193. package/dist/_pkgs/affine-components/icon-picker/icon-data.d.ts +34 -0
  194. package/dist/_pkgs/affine-components/icon-picker/icon-data.js +83 -0
  195. package/dist/_pkgs/affine-components/icon-picker/icon-picker.d.ts +39 -0
  196. package/dist/_pkgs/affine-components/icon-picker/icon-picker.js +233 -0
  197. package/dist/_pkgs/affine-components/icon-picker/index.d.ts +8 -0
  198. package/dist/_pkgs/affine-components/icon-picker/index.js +15 -0
  199. package/dist/_pkgs/affine-components/icon-picker/recent-store.d.ts +8 -0
  200. package/dist/_pkgs/affine-components/icon-picker/recent-store.js +41 -0
  201. package/dist/_pkgs/affine-components/icon-picker/styles.d.ts +5 -0
  202. package/dist/_pkgs/affine-components/icon-picker/styles.js +166 -0
  203. package/dist/_pkgs/affine-components/icon-picker/types.d.ts +22 -0
  204. package/dist/_pkgs/affine-components/icon-picker/types.js +10 -0
  205. package/dist/_pkgs/affine-components/icons/text.js +426 -426
  206. package/dist/_pkgs/affine-components/open-doc-dropdown-menu/dropdown-menu.d.ts +1 -0
  207. package/dist/_pkgs/affine-components/peek/peekable.js +1 -1
  208. package/dist/_pkgs/affine-components/portal/portal.d.ts +1 -1
  209. package/dist/_pkgs/affine-components/toolbar/index.d.ts +1 -0
  210. package/dist/_pkgs/affine-components/toolbar/index.js +1 -0
  211. package/dist/_pkgs/affine-components/toolbar/menu-button.js +2 -1
  212. package/dist/_pkgs/affine-components/toolbar/picker-menu.d.ts +55 -0
  213. package/dist/_pkgs/affine-components/toolbar/picker-menu.js +47 -0
  214. package/dist/_pkgs/affine-components/toolbar/toolbar.js +11 -10
  215. package/dist/_pkgs/affine-foundation/view.js +11 -2
  216. package/dist/_pkgs/affine-fragment-frame-panel/body/frame-panel-body.js +2 -2
  217. package/dist/_pkgs/affine-fragment-frame-panel/header/frame-panel-header.js +2 -2
  218. package/dist/_pkgs/affine-fragment-frame-panel/header/frames-setting-menu.js +1 -1
  219. package/dist/_pkgs/affine-fragment-outline/mobile-outline-panel.js +1 -1
  220. package/dist/_pkgs/affine-gfx-brush/index.d.ts +1 -0
  221. package/dist/_pkgs/affine-gfx-brush/index.js +1 -0
  222. package/dist/_pkgs/affine-gfx-brush/renderer/dom/brush.d.ts +1 -0
  223. package/dist/_pkgs/affine-gfx-brush/renderer/dom/brush.js +11 -0
  224. package/dist/_pkgs/affine-gfx-brush/renderer/dom/highlighter.d.ts +1 -0
  225. package/dist/_pkgs/affine-gfx-brush/renderer/dom/highlighter.js +11 -0
  226. package/dist/_pkgs/affine-gfx-brush/renderer/dom/index.d.ts +2 -0
  227. package/dist/_pkgs/affine-gfx-brush/renderer/dom/index.js +2 -0
  228. package/dist/_pkgs/affine-gfx-brush/renderer/dom/shared.d.ts +14 -0
  229. package/dist/_pkgs/affine-gfx-brush/renderer/dom/shared.js +54 -0
  230. package/dist/_pkgs/affine-gfx-brush/renderer/index.d.ts +1 -0
  231. package/dist/_pkgs/affine-gfx-brush/renderer/index.js +4 -0
  232. package/dist/_pkgs/affine-gfx-brush/toolbar/components/pen/pen-tool-button.js +1 -1
  233. package/dist/_pkgs/affine-gfx-brush/view.d.ts +14 -0
  234. package/dist/_pkgs/affine-gfx-brush/view.js +29 -3
  235. package/dist/_pkgs/affine-gfx-connector/connector-manager.js +39 -16
  236. package/dist/_pkgs/affine-gfx-connector/connector-tool.d.ts +26 -1
  237. package/dist/_pkgs/affine-gfx-connector/connector-tool.js +12 -3
  238. package/dist/_pkgs/affine-gfx-connector/connector-watcher.js +1 -4
  239. package/dist/_pkgs/affine-gfx-connector/direction/direction-reveal.d.ts +123 -0
  240. package/dist/_pkgs/affine-gfx-connector/direction/direction-reveal.js +282 -0
  241. package/dist/_pkgs/affine-gfx-connector/direction/direction-widget.d.ts +65 -0
  242. package/dist/_pkgs/affine-gfx-connector/direction/direction-widget.js +395 -0
  243. package/dist/_pkgs/affine-gfx-connector/direction/index.d.ts +4 -0
  244. package/dist/_pkgs/affine-gfx-connector/direction/index.js +4 -0
  245. package/dist/_pkgs/affine-gfx-connector/direction/invert-direction.d.ts +72 -0
  246. package/dist/_pkgs/affine-gfx-connector/direction/invert-direction.js +185 -0
  247. package/dist/_pkgs/affine-gfx-connector/direction/typed-edge.d.ts +69 -0
  248. package/dist/_pkgs/affine-gfx-connector/direction/typed-edge.js +66 -0
  249. package/dist/_pkgs/affine-gfx-connector/effects.d.ts +2 -0
  250. package/dist/_pkgs/affine-gfx-connector/effects.js +2 -0
  251. package/dist/_pkgs/affine-gfx-connector/element-renderer/connector-dom/index.js +78 -41
  252. package/dist/_pkgs/affine-gfx-connector/element-renderer/index.js +1 -1
  253. package/dist/_pkgs/affine-gfx-connector/element-renderer/utils.js +1 -1
  254. package/dist/_pkgs/affine-gfx-connector/index.d.ts +1 -0
  255. package/dist/_pkgs/affine-gfx-connector/index.js +1 -0
  256. package/dist/_pkgs/affine-gfx-connector/toolbar/config.d.ts +50 -0
  257. package/dist/_pkgs/affine-gfx-connector/toolbar/config.js +68 -2
  258. package/dist/_pkgs/affine-gfx-connector/view.js +16 -0
  259. package/dist/_pkgs/affine-gfx-group/command/group-api.js +150 -62
  260. package/dist/_pkgs/affine-gfx-group/text/edgeless-group-title-editor.js +4 -1
  261. package/dist/_pkgs/affine-gfx-mindmap/adapter/markdown/index.js +1 -1
  262. package/dist/_pkgs/affine-gfx-mindmap/adapter/utils.js +1 -1
  263. package/dist/_pkgs/affine-gfx-mindmap/templates/index.js +583 -4
  264. package/dist/_pkgs/affine-gfx-mindmap/toolbar/mindmap-menu.js +23 -23
  265. package/dist/_pkgs/affine-gfx-mindmap/toolbar/mindmap-tool-button.js +5 -1
  266. package/dist/_pkgs/affine-gfx-mindmap/view/utils.js +1 -1
  267. package/dist/_pkgs/affine-gfx-note/overlay/overlay.js +1 -1
  268. package/dist/_pkgs/affine-gfx-note/toolbar/note-senior-button.js +1 -1
  269. package/dist/_pkgs/affine-gfx-pointer/tools/pan-tool.d.ts +16 -0
  270. package/dist/_pkgs/affine-gfx-pointer/tools/pan-tool.js +102 -32
  271. package/dist/_pkgs/affine-gfx-shape/commands.d.ts +2 -0
  272. package/dist/_pkgs/affine-gfx-shape/commands.js +33 -0
  273. package/dist/_pkgs/affine-gfx-shape/consts.js +1 -1
  274. package/dist/_pkgs/affine-gfx-shape/draggable/shape-draggable.js +1 -1
  275. package/dist/_pkgs/affine-gfx-shape/element-renderer/shape/polygon.js +17 -9
  276. package/dist/_pkgs/affine-gfx-shape/element-renderer/shape/utils.js +8 -3
  277. package/dist/_pkgs/affine-gfx-shape/element-renderer/shape-dom/index.js +48 -19
  278. package/dist/_pkgs/affine-gfx-shape/element-view.js +19 -12
  279. package/dist/_pkgs/affine-gfx-shape/index.d.ts +1 -1
  280. package/dist/_pkgs/affine-gfx-shape/index.js +1 -1
  281. package/dist/_pkgs/affine-gfx-shape/overlay/polygon-drawing-overlay.js +1 -1
  282. package/dist/_pkgs/affine-gfx-shape/overlay/polygon-vertex-editing-overlay.js +12 -7
  283. package/dist/_pkgs/affine-gfx-shape/overlay/shape-overlay.js +1 -1
  284. package/dist/_pkgs/affine-gfx-shape/polygon-tool.js +4 -2
  285. package/dist/_pkgs/affine-gfx-shape/shape-tool.js +1 -1
  286. package/dist/_pkgs/affine-gfx-shape/text/edgeless-shape-text-editor.d.ts +26 -0
  287. package/dist/_pkgs/affine-gfx-shape/text/edgeless-shape-text-editor.js +124 -15
  288. package/dist/_pkgs/affine-gfx-shape/view.js +3 -3
  289. package/dist/_pkgs/affine-gfx-template/builtin/other.js +66 -17
  290. package/dist/_pkgs/affine-gfx-template/toolbar/overlay-scrollbar.js +1 -1
  291. package/dist/_pkgs/affine-gfx-template/toolbar/template-panel.js +1 -1
  292. package/dist/_pkgs/affine-gfx-template/toolbar/template-tool-button.d.ts +2 -0
  293. package/dist/_pkgs/affine-gfx-template/toolbar/template-tool-button.js +18 -1
  294. package/dist/_pkgs/affine-gfx-text/edgeless-text-editor.js +9 -5
  295. package/dist/_pkgs/affine-gfx-text/toolbar/actions.js +1 -1
  296. package/dist/_pkgs/affine-inline-latex/command.js +46 -26
  297. package/dist/_pkgs/affine-inline-latex/latex-node/latex-node.d.ts +10 -0
  298. package/dist/_pkgs/affine-inline-latex/latex-node/latex-node.js +23 -0
  299. package/dist/_pkgs/affine-inline-link/command.js +1 -1
  300. package/dist/_pkgs/affine-inline-link/link-node/affine-link.js +4 -1
  301. package/dist/_pkgs/affine-inline-preset/adapters/html/html-inline.d.ts +3 -0
  302. package/dist/_pkgs/affine-inline-preset/adapters/html/html-inline.js +29 -1
  303. package/dist/_pkgs/affine-inline-preset/command/format-block.js +1 -1
  304. package/dist/_pkgs/affine-inline-preset/command/format-native.js +2 -2
  305. package/dist/_pkgs/affine-inline-preset/command/format-text.js +1 -1
  306. package/dist/_pkgs/affine-inline-preset/inline-spec.js +1 -1
  307. package/dist/_pkgs/affine-inline-reference/reference-node/reference-node.js +1 -1
  308. package/dist/_pkgs/affine-model/blocks/code/code-model.d.ts +1 -0
  309. package/dist/_pkgs/affine-model/blocks/code/code-model.js +1 -0
  310. package/dist/_pkgs/affine-model/elements/bpmn/node.d.ts +40 -7
  311. package/dist/_pkgs/affine-model/elements/bpmn/node.js +4 -2
  312. package/dist/_pkgs/affine-model/elements/bpmn/pool.d.ts +74 -17
  313. package/dist/_pkgs/affine-model/elements/bpmn/pool.js +55 -26
  314. package/dist/_pkgs/affine-model/elements/c4/board.d.ts +88 -0
  315. package/dist/_pkgs/affine-model/elements/c4/board.js +128 -0
  316. package/dist/_pkgs/affine-model/elements/c4/boundary.d.ts +63 -0
  317. package/dist/_pkgs/affine-model/elements/c4/boundary.js +134 -0
  318. package/dist/_pkgs/affine-model/elements/c4/index.d.ts +3 -0
  319. package/dist/_pkgs/affine-model/elements/c4/index.js +3 -0
  320. package/dist/_pkgs/affine-model/elements/c4/node.d.ts +97 -0
  321. package/dist/_pkgs/affine-model/elements/c4/node.js +130 -0
  322. package/dist/_pkgs/affine-model/elements/connector/connector.js +25 -1
  323. package/dist/_pkgs/affine-model/elements/context-map/context-map.d.ts +27 -0
  324. package/dist/_pkgs/affine-model/elements/context-map/context-map.js +94 -0
  325. package/dist/_pkgs/affine-model/elements/context-map/index.d.ts +1 -0
  326. package/dist/_pkgs/affine-model/elements/context-map/index.js +1 -0
  327. package/dist/_pkgs/affine-model/elements/core-domain/core-domain.d.ts +34 -17
  328. package/dist/_pkgs/affine-model/elements/core-domain/core-domain.js +28 -26
  329. package/dist/_pkgs/affine-model/elements/edgy/board.d.ts +32 -0
  330. package/dist/_pkgs/affine-model/elements/edgy/board.js +112 -0
  331. package/dist/_pkgs/affine-model/elements/edgy/facets.d.ts +12 -0
  332. package/dist/_pkgs/affine-model/elements/edgy/facets.js +25 -1
  333. package/dist/_pkgs/affine-model/elements/edgy/index.d.ts +1 -0
  334. package/dist/_pkgs/affine-model/elements/edgy/index.js +1 -0
  335. package/dist/_pkgs/affine-model/elements/event-storming/event-storming.d.ts +36 -0
  336. package/dist/_pkgs/affine-model/elements/event-storming/event-storming.js +103 -0
  337. package/dist/_pkgs/affine-model/elements/event-storming/index.d.ts +1 -0
  338. package/dist/_pkgs/affine-model/elements/event-storming/index.js +1 -0
  339. package/dist/_pkgs/affine-model/elements/framework-background/background.d.ts +42 -0
  340. package/dist/_pkgs/affine-model/elements/framework-background/background.js +42 -0
  341. package/dist/_pkgs/affine-model/elements/framework-background/index.d.ts +1 -0
  342. package/dist/_pkgs/affine-model/elements/framework-background/index.js +1 -0
  343. package/dist/_pkgs/affine-model/elements/index.d.ts +14 -1
  344. package/dist/_pkgs/affine-model/elements/index.js +4 -0
  345. package/dist/_pkgs/affine-model/elements/mindmap/mindmap.js +8 -3
  346. package/dist/_pkgs/affine-model/elements/mindmap/style.d.ts +10 -0
  347. package/dist/_pkgs/affine-model/elements/mindmap/style.js +8 -0
  348. package/dist/_pkgs/affine-model/elements/shape/api/polygon.js +2 -8
  349. package/dist/_pkgs/affine-model/elements/wardley/wardley.d.ts +21 -29
  350. package/dist/_pkgs/affine-model/elements/wardley/wardley.js +41 -48
  351. package/dist/_pkgs/affine-shared/adapters/html/block-adapter.js +1 -1
  352. package/dist/_pkgs/affine-shared/adapters/markdown/block-adapter.js +1 -1
  353. package/dist/_pkgs/affine-shared/adapters/markdown/delta-converter.d.ts +6 -1
  354. package/dist/_pkgs/affine-shared/adapters/markdown/delta-converter.js +161 -3
  355. package/dist/_pkgs/affine-shared/adapters/markdown/markdown.js +5 -1
  356. package/dist/_pkgs/affine-shared/adapters/notion-html/block-adapter.js +1 -1
  357. package/dist/_pkgs/affine-shared/adapters/notion-html/delta-converter.js +1 -1
  358. package/dist/_pkgs/affine-shared/adapters/plain-text/block-adapter.js +1 -1
  359. package/dist/_pkgs/affine-shared/adapters/plain-text/delta-converter.js +1 -1
  360. package/dist/_pkgs/affine-shared/adapters/types/delta-converter.d.ts +1 -0
  361. package/dist/_pkgs/affine-shared/adapters/utils/color.d.ts +24 -0
  362. package/dist/_pkgs/affine-shared/adapters/utils/color.js +283 -0
  363. package/dist/_pkgs/affine-shared/adapters/utils/index.d.ts +1 -0
  364. package/dist/_pkgs/affine-shared/adapters/utils/index.js +1 -0
  365. package/dist/_pkgs/affine-shared/commands/block-crud/get-selected-blocks.js +10 -2
  366. package/dist/_pkgs/affine-shared/commands/model-crud/draft-selected-models.js +1 -1
  367. package/dist/_pkgs/affine-shared/commands/selection/focus-block-end.js +1 -1
  368. package/dist/_pkgs/affine-shared/commands/selection/focus-block-start.js +1 -1
  369. package/dist/_pkgs/affine-shared/consts/index.d.ts +10 -0
  370. package/dist/_pkgs/affine-shared/consts/index.js +10 -0
  371. package/dist/_pkgs/affine-shared/services/artefact-catalogue-service.d.ts +55 -0
  372. package/dist/_pkgs/affine-shared/services/artefact-catalogue-service.js +35 -0
  373. package/dist/_pkgs/affine-shared/services/audit-service.d.ts +311 -0
  374. package/dist/_pkgs/affine-shared/services/audit-service.js +228 -0
  375. package/dist/_pkgs/affine-shared/services/command-usage-service.d.ts +31 -0
  376. package/dist/_pkgs/affine-shared/services/command-usage-service.js +109 -0
  377. package/dist/_pkgs/affine-shared/services/comment-service/block-comment-manager.js +1 -1
  378. package/dist/_pkgs/affine-shared/services/doc-display-meta-service.js +1 -1
  379. package/dist/_pkgs/affine-shared/services/edit-props-store.d.ts +7 -0
  380. package/dist/_pkgs/affine-shared/services/edit-props-store.js +11 -0
  381. package/dist/_pkgs/affine-shared/services/embed-iframe/embed-iframe-config.js +1 -1
  382. package/dist/_pkgs/affine-shared/services/font-loader/font-loader-service.d.ts +26 -1
  383. package/dist/_pkgs/affine-shared/services/font-loader/font-loader-service.js +124 -7
  384. package/dist/_pkgs/affine-shared/services/index.d.ts +7 -0
  385. package/dist/_pkgs/affine-shared/services/index.js +7 -0
  386. package/dist/_pkgs/affine-shared/services/pivot-materiality-publisher.d.ts +129 -0
  387. package/dist/_pkgs/affine-shared/services/pivot-materiality-publisher.js +312 -0
  388. package/dist/_pkgs/affine-shared/services/pivot-properties-service.d.ts +217 -0
  389. package/dist/_pkgs/affine-shared/services/pivot-properties-service.js +114 -0
  390. package/dist/_pkgs/affine-shared/services/telemetry-service/code-block.d.ts +1 -1
  391. package/dist/_pkgs/affine-shared/services/telemetry-service/lifecycle.d.ts +255 -1
  392. package/dist/_pkgs/affine-shared/services/telemetry-service/telemetry-service.d.ts +2 -2
  393. package/dist/_pkgs/affine-shared/services/telemetry-service/types.d.ts +6 -1
  394. package/dist/_pkgs/affine-shared/services/toolbar-service/action.d.ts +10 -0
  395. package/dist/_pkgs/affine-shared/services/toolbar-service/index.d.ts +1 -0
  396. package/dist/_pkgs/affine-shared/services/toolbar-service/index.js +1 -0
  397. package/dist/_pkgs/affine-shared/services/toolbar-service/layout.d.ts +134 -0
  398. package/dist/_pkgs/affine-shared/services/toolbar-service/layout.js +146 -0
  399. package/dist/_pkgs/affine-shared/services/toolbar-service/registry.d.ts +44 -0
  400. package/dist/_pkgs/affine-shared/services/toolbar-service/registry.js +161 -3
  401. package/dist/_pkgs/affine-shared/services/translation-service/index.d.ts +68 -0
  402. package/dist/_pkgs/affine-shared/services/translation-service/index.js +39 -0
  403. package/dist/_pkgs/affine-shared/services/universe-tag-defs-service.d.ts +155 -0
  404. package/dist/_pkgs/affine-shared/services/universe-tag-defs-service.js +256 -0
  405. package/dist/_pkgs/affine-shared/test-utils/affine-template.js +1 -1
  406. package/dist/_pkgs/affine-shared/utils/dom/point-to-range.d.ts +4 -0
  407. package/dist/_pkgs/affine-shared/utils/dom/point-to-range.js +58 -2
  408. package/dist/_pkgs/affine-shared/utils/dom/viewport.d.ts +60 -0
  409. package/dist/_pkgs/affine-shared/utils/dom/viewport.js +58 -0
  410. package/dist/_pkgs/affine-shared/utils/edgeless.js +1 -1
  411. package/dist/_pkgs/affine-shared/utils/file/filesys.d.ts +34 -0
  412. package/dist/_pkgs/affine-shared/utils/file/filesys.js +48 -7
  413. package/dist/_pkgs/affine-shared/utils/print-to-pdf.d.ts +21 -0
  414. package/dist/_pkgs/affine-shared/utils/print-to-pdf.js +166 -32
  415. package/dist/_pkgs/affine-shared/utils/zod-schema.d.ts +7 -0
  416. package/dist/_pkgs/affine-shared/utils/zod-schema.js +25 -1
  417. package/dist/_pkgs/affine-widget-drag-handle/utils.js +1 -1
  418. package/dist/_pkgs/affine-widget-drag-handle/watchers/edgeless-watcher.d.ts +19 -10
  419. package/dist/_pkgs/affine-widget-drag-handle/watchers/edgeless-watcher.js +122 -23
  420. package/dist/_pkgs/affine-widget-drag-handle/watchers/pointer-event-watcher.d.ts +2 -0
  421. package/dist/_pkgs/affine-widget-drag-handle/watchers/pointer-event-watcher.js +25 -4
  422. package/dist/_pkgs/affine-widget-edgeless-auto-connect/index.js +1 -1
  423. package/dist/_pkgs/affine-widget-edgeless-dragging-area/edgeless-dragging-area-rect.js +1 -1
  424. package/dist/_pkgs/affine-widget-edgeless-selected-rect/auto-complete-panel.js +10 -5
  425. package/dist/_pkgs/affine-widget-edgeless-selected-rect/edgeless-auto-complete.d.ts +15 -1
  426. package/dist/_pkgs/affine-widget-edgeless-selected-rect/edgeless-auto-complete.js +37 -32
  427. package/dist/_pkgs/affine-widget-edgeless-selected-rect/edgeless-element-link.js +5 -4
  428. package/dist/_pkgs/affine-widget-edgeless-selected-rect/edgeless-selected-rect.js +19 -9
  429. package/dist/_pkgs/affine-widget-edgeless-selected-rect/utils.d.ts +16 -2
  430. package/dist/_pkgs/affine-widget-edgeless-selected-rect/utils.js +19 -2
  431. package/dist/_pkgs/affine-widget-edgeless-toolbar/catalogue/artefact-catalogue-default.d.ts +14 -0
  432. package/dist/_pkgs/affine-widget-edgeless-toolbar/catalogue/artefact-catalogue-default.js +40 -0
  433. package/dist/_pkgs/affine-widget-edgeless-toolbar/catalogue/artefact-catalogue-widget.d.ts +158 -0
  434. package/dist/_pkgs/affine-widget-edgeless-toolbar/catalogue/artefact-catalogue-widget.js +572 -0
  435. package/dist/_pkgs/affine-widget-edgeless-toolbar/catalogue/catalogue-groups.d.ts +46 -0
  436. package/dist/_pkgs/affine-widget-edgeless-toolbar/catalogue/catalogue-groups.js +66 -0
  437. package/dist/_pkgs/affine-widget-edgeless-toolbar/catalogue/chord-format.d.ts +13 -0
  438. package/dist/_pkgs/affine-widget-edgeless-toolbar/catalogue/chord-format.js +50 -0
  439. package/dist/_pkgs/affine-widget-edgeless-toolbar/edgeless-toolbar.d.ts +6 -0
  440. package/dist/_pkgs/affine-widget-edgeless-toolbar/edgeless-toolbar.js +15 -5
  441. package/dist/_pkgs/affine-widget-edgeless-toolbar/effects.d.ts +2 -0
  442. package/dist/_pkgs/affine-widget-edgeless-toolbar/effects.js +2 -0
  443. package/dist/_pkgs/affine-widget-edgeless-toolbar/extension/index.d.ts +7 -0
  444. package/dist/_pkgs/affine-widget-edgeless-toolbar/index.d.ts +5 -0
  445. package/dist/_pkgs/affine-widget-edgeless-toolbar/index.js +5 -0
  446. package/dist/_pkgs/affine-widget-edgeless-toolbar/menu/command-menu.d.ts +57 -0
  447. package/dist/_pkgs/affine-widget-edgeless-toolbar/menu/command-menu.js +140 -0
  448. package/dist/_pkgs/affine-widget-edgeless-toolbar/view.js +9 -0
  449. package/dist/_pkgs/affine-widget-frame-title/edgeless-frame-title-editor.js +4 -1
  450. package/dist/_pkgs/affine-widget-keyboard-toolbar/icons.js +98 -98
  451. package/dist/_pkgs/affine-widget-keyboard-toolbar/keyboard-tool-panel.js +1 -1
  452. package/dist/_pkgs/affine-widget-linked-doc/import-doc/import-doc.js +1 -1
  453. package/dist/_pkgs/affine-widget-linked-doc/linked-doc-popover.js +2 -15
  454. package/dist/_pkgs/affine-widget-linked-doc/transformers/markdown.d.ts +10 -1
  455. package/dist/_pkgs/affine-widget-linked-doc/transformers/markdown.js +133 -6
  456. package/dist/_pkgs/affine-widget-linked-doc/transformers/utils.d.ts +10 -0
  457. package/dist/_pkgs/affine-widget-linked-doc/transformers/utils.js +120 -3
  458. package/dist/_pkgs/affine-widget-linked-doc/utils.d.ts +12 -0
  459. package/dist/_pkgs/affine-widget-linked-doc/utils.js +33 -0
  460. package/dist/_pkgs/affine-widget-page-dragging-area/index.js +1 -1
  461. package/dist/_pkgs/affine-widget-remote-selection/doc/doc-remote-selection.js +1 -1
  462. package/dist/_pkgs/affine-widget-remote-selection/edgeless/index.js +6 -4
  463. package/dist/_pkgs/affine-widget-slash-menu/slash-menu-popover.js +8 -4
  464. package/dist/_pkgs/affine-widget-slash-menu/utils.d.ts +9 -0
  465. package/dist/_pkgs/affine-widget-slash-menu/utils.js +24 -0
  466. package/dist/_pkgs/affine-widget-toolbar/toolbar.d.ts +6 -0
  467. package/dist/_pkgs/affine-widget-toolbar/toolbar.js +40 -8
  468. package/dist/_pkgs/affine-widget-toolbar/utils.d.ts +146 -3
  469. package/dist/_pkgs/affine-widget-toolbar/utils.js +744 -58
  470. package/dist/_pkgs/data-view/core/common/property-menu.js +3 -16
  471. package/dist/_pkgs/data-view/core/component/tags/multi-tag-select.d.ts +4 -0
  472. package/dist/_pkgs/data-view/core/component/tags/multi-tag-select.js +28 -1
  473. package/dist/_pkgs/data-view/core/data-view.d.ts +3 -1
  474. package/dist/_pkgs/data-view/core/data-view.js +25 -3
  475. package/dist/_pkgs/data-view/core/detail/field.js +2 -2
  476. package/dist/_pkgs/data-view/core/detail/selection.d.ts +4 -2
  477. package/dist/_pkgs/data-view/core/detail/selection.js +23 -7
  478. package/dist/_pkgs/data-view/core/effect.js +2 -0
  479. package/dist/_pkgs/data-view/core/filter/eval.js +36 -23
  480. package/dist/_pkgs/data-view/core/filter/filter-fn/matcher.d.ts +7 -0
  481. package/dist/_pkgs/data-view/core/filter/filter-fn/matcher.js +12 -0
  482. package/dist/_pkgs/data-view/core/group-by/compare-date-keys.d.ts +11 -0
  483. package/dist/_pkgs/data-view/core/group-by/compare-date-keys.js +55 -0
  484. package/dist/_pkgs/data-view/core/group-by/define.d.ts +6 -0
  485. package/dist/_pkgs/data-view/core/group-by/define.js +114 -0
  486. package/dist/_pkgs/data-view/core/group-by/matcher.d.ts +7 -0
  487. package/dist/_pkgs/data-view/core/group-by/matcher.js +13 -0
  488. package/dist/_pkgs/data-view/core/group-by/renderer/boolean-group.js +11 -11
  489. package/dist/_pkgs/data-view/core/group-by/renderer/date-group.d.ts +9 -0
  490. package/dist/_pkgs/data-view/core/group-by/renderer/date-group.js +97 -0
  491. package/dist/_pkgs/data-view/core/group-by/setting.js +86 -7
  492. package/dist/_pkgs/data-view/core/group-by/trait.d.ts +34 -0
  493. package/dist/_pkgs/data-view/core/group-by/trait.js +106 -10
  494. package/dist/_pkgs/data-view/core/utils/menu-title.js +17 -17
  495. package/dist/_pkgs/data-view/property-presets/multi-select/cell-renderer.js +3 -1
  496. package/dist/_pkgs/data-view/property-presets/number/define.js +2 -1
  497. package/dist/_pkgs/data-view/property-presets/select/cell-renderer.js +3 -1
  498. package/dist/_pkgs/data-view/view-presets/calendar/calendar-view-manager.d.ts +70 -0
  499. package/dist/_pkgs/data-view/view-presets/calendar/calendar-view-manager.js +449 -0
  500. package/dist/_pkgs/data-view/view-presets/calendar/define.d.ts +10 -0
  501. package/dist/_pkgs/data-view/view-presets/calendar/define.js +27 -0
  502. package/dist/_pkgs/data-view/view-presets/calendar/effect.d.ts +1 -0
  503. package/dist/_pkgs/data-view/view-presets/calendar/effect.js +4 -0
  504. package/dist/_pkgs/data-view/view-presets/calendar/index.d.ts +6 -0
  505. package/dist/_pkgs/data-view/view-presets/calendar/index.js +6 -0
  506. package/dist/_pkgs/data-view/view-presets/calendar/layout.d.ts +37 -0
  507. package/dist/_pkgs/data-view/view-presets/calendar/layout.js +138 -0
  508. package/dist/_pkgs/data-view/view-presets/calendar/pc/actions.d.ts +7 -0
  509. package/dist/_pkgs/data-view/view-presets/calendar/pc/actions.js +64 -0
  510. package/dist/_pkgs/data-view/view-presets/calendar/pc/dnd.d.ts +32 -0
  511. package/dist/_pkgs/data-view/view-presets/calendar/pc/dnd.js +159 -0
  512. package/dist/_pkgs/data-view/view-presets/calendar/pc/effect.d.ts +1 -0
  513. package/dist/_pkgs/data-view/view-presets/calendar/pc/effect.js +7 -0
  514. package/dist/_pkgs/data-view/view-presets/calendar/pc/hit-test.d.ts +1 -0
  515. package/dist/_pkgs/data-view/view-presets/calendar/pc/hit-test.js +28 -0
  516. package/dist/_pkgs/data-view/view-presets/calendar/pc/styles.d.ts +1 -0
  517. package/dist/_pkgs/data-view/view-presets/calendar/pc/styles.js +707 -0
  518. package/dist/_pkgs/data-view/view-presets/calendar/pc/view.d.ts +89 -0
  519. package/dist/_pkgs/data-view/view-presets/calendar/pc/view.js +849 -0
  520. package/dist/_pkgs/data-view/view-presets/calendar/renderer.d.ts +2 -0
  521. package/dist/_pkgs/data-view/view-presets/calendar/renderer.js +8 -0
  522. package/dist/_pkgs/data-view/view-presets/calendar/source.d.ts +8 -0
  523. package/dist/_pkgs/data-view/view-presets/calendar/source.js +3 -0
  524. package/dist/_pkgs/data-view/view-presets/calendar/types.d.ts +79 -0
  525. package/dist/_pkgs/data-view/view-presets/calendar/types.js +1 -0
  526. package/dist/_pkgs/data-view/view-presets/convert.js +28 -0
  527. package/dist/_pkgs/data-view/view-presets/effect.js +2 -0
  528. package/dist/_pkgs/data-view/view-presets/index.d.ts +2 -0
  529. package/dist/_pkgs/data-view/view-presets/index.js +3 -0
  530. package/dist/_pkgs/data-view/view-presets/kanban/kanban-view-manager.js +3 -1
  531. package/dist/_pkgs/data-view/view-presets/kanban/mobile/cell.js +2 -2
  532. package/dist/_pkgs/data-view/view-presets/table/collapsed-state.d.ts +18 -0
  533. package/dist/_pkgs/data-view/view-presets/table/collapsed-state.js +36 -0
  534. package/dist/_pkgs/data-view/view-presets/table/mobile/cell.js +2 -2
  535. package/dist/_pkgs/data-view/view-presets/table/mobile/group.d.ts +11 -0
  536. package/dist/_pkgs/data-view/view-presets/table/mobile/group.js +89 -4
  537. package/dist/_pkgs/data-view/view-presets/table/pc/cell.d.ts +3 -0
  538. package/dist/_pkgs/data-view/view-presets/table/pc/cell.js +11 -2
  539. package/dist/_pkgs/data-view/view-presets/table/pc/controller/hotkeys.d.ts +1 -0
  540. package/dist/_pkgs/data-view/view-presets/table/pc/controller/hotkeys.js +39 -33
  541. package/dist/_pkgs/data-view/view-presets/table/pc/group.d.ts +10 -0
  542. package/dist/_pkgs/data-view/view-presets/table/pc/group.js +86 -5
  543. package/dist/_pkgs/data-view/view-presets/table/pc-virtual/controller/hotkeys.d.ts +1 -0
  544. package/dist/_pkgs/data-view/view-presets/table/pc-virtual/controller/hotkeys.js +39 -33
  545. package/dist/_pkgs/data-view/view-presets/table/pc-virtual/group/top/group-title.js +18 -18
  546. package/dist/_pkgs/data-view/view-presets/table/pc-virtual/row/cell.d.ts +3 -0
  547. package/dist/_pkgs/data-view/view-presets/table/pc-virtual/row/cell.js +8 -0
  548. package/dist/_pkgs/data-view/view-presets/table/table-view-manager.js +3 -1
  549. package/dist/_pkgs/data-view/view-presets/table/utils.d.ts +3 -0
  550. package/dist/_pkgs/data-view/view-presets/table/utils.js +16 -1
  551. package/dist/_pkgs/data-view/widget-presets/tools/presets/view-options/view-options.js +3 -1
  552. package/dist/_pkgs/global/gfx/curve.js +4 -3
  553. package/dist/_pkgs/std/clipboard/clipboard.js +13 -3
  554. package/dist/_pkgs/std/clipboard/utils.d.ts +5 -0
  555. package/dist/_pkgs/std/clipboard/utils.js +10 -0
  556. package/dist/_pkgs/std/event/dispatcher.js +27 -1
  557. package/dist/_pkgs/std/event/keymap.js +16 -6
  558. package/dist/_pkgs/std/extension/command-registry.d.ts +475 -0
  559. package/dist/_pkgs/std/extension/command-registry.js +431 -0
  560. package/dist/_pkgs/std/extension/dnd/index.d.ts +1 -1
  561. package/dist/_pkgs/std/extension/index.d.ts +2 -0
  562. package/dist/_pkgs/std/extension/index.js +2 -0
  563. package/dist/_pkgs/std/extension/shortcut.js +2 -1
  564. package/dist/_pkgs/std/extension/translation-manifest.d.ts +49 -0
  565. package/dist/_pkgs/std/extension/translation-manifest.js +71 -0
  566. package/dist/_pkgs/std/gfx/grid.js +1 -1
  567. package/dist/_pkgs/std/gfx/index.d.ts +7 -3
  568. package/dist/_pkgs/std/gfx/index.js +6 -2
  569. package/dist/_pkgs/std/gfx/interactivity/gfx-view-event-handler.js +6 -1
  570. package/dist/_pkgs/std/gfx/interactivity/manager.d.ts +8 -0
  571. package/dist/_pkgs/std/gfx/interactivity/manager.js +18 -0
  572. package/dist/_pkgs/std/gfx/layer.d.ts +1 -1
  573. package/dist/_pkgs/std/gfx/layer.js +1 -2
  574. package/dist/_pkgs/std/gfx/model/surface/decorators/field.js +9 -0
  575. package/dist/_pkgs/std/gfx/model/surface/decorators/index.d.ts +2 -2
  576. package/dist/_pkgs/std/gfx/model/surface/decorators/index.js +2 -2
  577. package/dist/_pkgs/std/gfx/model/surface/decorators/local.d.ts +14 -0
  578. package/dist/_pkgs/std/gfx/model/surface/decorators/local.js +22 -0
  579. package/dist/_pkgs/std/gfx/model/surface/decorators/observer.d.ts +2 -0
  580. package/dist/_pkgs/std/gfx/model/surface/decorators/observer.js +40 -1
  581. package/dist/_pkgs/std/gfx/model/surface/element-model.d.ts +442 -0
  582. package/dist/_pkgs/std/gfx/model/surface/element-model.js +510 -25
  583. package/dist/_pkgs/std/gfx/model/surface/pivot.d.ts +48 -0
  584. package/dist/_pkgs/std/gfx/model/surface/pivot.js +34 -0
  585. package/dist/_pkgs/std/gfx/model/surface/role.d.ts +153 -0
  586. package/dist/_pkgs/std/gfx/model/surface/role.js +117 -0
  587. package/dist/_pkgs/std/gfx/model/surface/surface-model.d.ts +70 -0
  588. package/dist/_pkgs/std/gfx/model/surface/surface-model.js +305 -17
  589. package/dist/_pkgs/std/gfx/model/surface/tags.d.ts +77 -0
  590. package/dist/_pkgs/std/gfx/model/surface/tags.js +246 -0
  591. package/dist/_pkgs/std/gfx/perf.d.ts +10 -0
  592. package/dist/_pkgs/std/gfx/perf.js +30 -0
  593. package/dist/_pkgs/std/gfx/selection.js +1 -1
  594. package/dist/_pkgs/std/gfx/tool/tool-controller.d.ts +13 -0
  595. package/dist/_pkgs/std/gfx/tool/tool-controller.js +26 -0
  596. package/dist/_pkgs/std/gfx/viewport.d.ts +12 -0
  597. package/dist/_pkgs/std/gfx/viewport.js +20 -2
  598. package/dist/_pkgs/std/inline/extensions/inline-manager.js +1 -1
  599. package/dist/_pkgs/std/inline/extensions/markdown-matcher.js +1 -1
  600. package/dist/_pkgs/std/inline/services/event.d.ts +2 -0
  601. package/dist/_pkgs/std/inline/services/event.js +96 -21
  602. package/dist/_pkgs/std/utils/layer.js +2 -6
  603. package/dist/_pkgs/std/utils/tree.d.ts +14 -0
  604. package/dist/_pkgs/std/utils/tree.js +59 -8
  605. package/dist/_pkgs/std/view/element/gfx-block-component.js +23 -13
  606. package/dist/_pkgs/store/extension/workspace/workspace-meta.d.ts +10 -0
  607. package/dist/_pkgs/store/model/block/flat-sync-controller.js +2 -12
  608. package/dist/_pkgs/store/model/store/crud.js +10 -4
  609. package/dist/_pkgs/store/reactive/flat-native-y/index.d.ts +1 -1
  610. package/dist/_pkgs/store/reactive/flat-native-y/index.js +24 -2
  611. package/dist/commands.d.ts +58 -0
  612. package/dist/commands.js +66 -0
  613. package/dist/extensions/command-telemetry.d.ts +21 -0
  614. package/dist/extensions/command-telemetry.js +89 -0
  615. package/dist/extensions/command-usage.d.ts +23 -0
  616. package/dist/extensions/command-usage.js +37 -0
  617. package/dist/extensions/store.d.ts +9 -4
  618. package/dist/extensions/store.js +27 -24
  619. package/dist/extensions/view.d.ts +9 -4
  620. package/dist/extensions/view.js +35 -8
  621. package/dist/flags.d.ts +98 -21
  622. package/dist/flags.js +103 -22
  623. package/dist/frameworks.d.ts +45 -0
  624. package/dist/frameworks.js +186 -0
  625. package/dist/schemas.d.ts +8 -5
  626. package/dist/schemas.js +28 -32
  627. package/dist/shortcuts.d.ts +16 -16
  628. package/dist/shortcuts.js +8 -36
  629. package/dist/translations.d.ts +56 -0
  630. package/dist/translations.js +200 -0
  631. package/package.json +16 -4
  632. package/dist/_pkgs/affine-block-root/keyboard/shortcuts.d.ts +0 -8
  633. package/dist/_pkgs/affine-block-root/keyboard/shortcuts.js +0 -48
  634. package/dist/_pkgs/affine-gfx-shape/shortcuts.d.ts +0 -6
  635. package/dist/_pkgs/affine-gfx-shape/shortcuts.js +0 -28
  636. package/dist/_pkgs/data-view/core/utils/cache.d.ts +0 -5
  637. package/dist/_pkgs/data-view/core/utils/cache.js +0 -28
@@ -0,0 +1,4620 @@
1
+ import { createIdentifier } from '../../global/di/index.js';
2
+ import { Bound, lineIntersects } from '../../global/gfx/index.js';
3
+ import { GfxGroupLikeElementModel, GfxPrimitiveElementModel, InteractivityExtension, roleIsA, } from '../../std/gfx/index.js';
4
+ import { effect, signal } from '@preact/signals-core';
5
+ import { backgroundPlot } from '../framework-background/def.js';
6
+ import { backgroundAxisFact, backgroundTransitionBands, backgroundTransitionVisibleProps, } from '../framework-background/facts.js';
7
+ import { backgroundInVariant } from '../framework-background/labels.js';
8
+ import { Overlay, OverlayIdentifier } from '../renderer/overlay.js';
9
+ import { QualityNudgeIdentifier } from './map-quality.js';
10
+ import { ViolationTimeline } from './violation-timeline.js';
11
+ /**
12
+ * Severity `profile` gives `rule` — the rule's own when the profile is silent
13
+ * about it, or when there is no profile at all.
14
+ */
15
+ export function profileSeverity(rule, profile) {
16
+ // Own keys only: `rules` is an object literal shipped by a framework, so
17
+ // `rules['constructor']` must not resolve to something off the prototype.
18
+ return profile !== undefined && Object.hasOwn(profile.rules, rule.id)
19
+ ? profile.rules[rule.id]
20
+ : rule.severity;
21
+ }
22
+ /**
23
+ * The profile a background of `framework` is checked against when it names
24
+ * none: the one flagged {@link ValidationProfile.isDefault}, or — failing that
25
+ * — the first one registered, so a framework that forgot the flag still has a
26
+ * resolvable answer instead of silently losing its profiles.
27
+ */
28
+ export function defaultProfileOf(profiles, framework) {
29
+ let first;
30
+ for (const profile of profiles) {
31
+ if (profile.framework !== framework)
32
+ continue;
33
+ if (profile.isDefault)
34
+ return profile;
35
+ first ??= profile;
36
+ }
37
+ return first;
38
+ }
39
+ function indexProfiles(profiles) {
40
+ const byId = new Map();
41
+ const defaults = new Map();
42
+ for (const profile of profiles) {
43
+ byId.set(profile.id, profile);
44
+ if (defaults.has(profile.framework))
45
+ continue;
46
+ const fallback = defaultProfileOf(profiles, profile.framework);
47
+ if (fallback)
48
+ defaults.set(profile.framework, fallback);
49
+ }
50
+ return { byId, defaults };
51
+ }
52
+ /**
53
+ * The profile a finding of `rule` is judged by: the one its background NAMES,
54
+ * or the framework's default.
55
+ *
56
+ * A profile id belonging to another framework is ignored rather than honoured:
57
+ * a background carrying `bpmn.executable` says nothing about a Wardley rule,
58
+ * and a stale id left behind by a paste must never silence one.
59
+ */
60
+ function resolveProfile(rule, chosenId, index) {
61
+ const named = chosenId === undefined ? undefined : index.byId.get(chosenId);
62
+ if (named !== undefined && named.framework === rule.framework)
63
+ return named;
64
+ return index.defaults.get(rule.framework);
65
+ }
66
+ /**
67
+ * The profile id each role-carrying element names, in one pass — then the same
68
+ * for the ones that name none but SIT INSIDE one that does.
69
+ *
70
+ * Role-carrying only: a profile is a decision about a framework's background,
71
+ * and a neutral element cannot be one. That keeps the pass on exactly the same
72
+ * cheap guard the rule families already use.
73
+ */
74
+ function readChosenProfiles(elements) {
75
+ const chosen = new Map();
76
+ const roleCarrying = [];
77
+ for (const el of elements) {
78
+ if (el.role === undefined)
79
+ continue;
80
+ roleCarrying.push(el);
81
+ const id = el.validationProfile;
82
+ // Whatever a peer wrote: a client that got it wrong must not break
83
+ // evaluation on this one.
84
+ if (typeof id === 'string')
85
+ chosen.set(el.id, id);
86
+ }
87
+ // Nobody chose anything => no inheritance to compute, and not one bound read
88
+ // on the whole surface. The overwhelmingly common case: choosing the default
89
+ // profile back WRITES NOTHING (`setProfile` clears the field), so `chosen` is
90
+ // empty on every document whose author never left the default.
91
+ if (chosen.size > 0)
92
+ inheritChosenProfiles(roleCarrying, chosen);
93
+ return chosen;
94
+ }
95
+ /**
96
+ * A frame that named no profile inherits the one chosen on the frame it is
97
+ * DRAWN INSIDE — the innermost of them.
98
+ *
99
+ * ## The question this answers
100
+ *
101
+ * A profile is chosen per root instance (PF9.1), and a finding is judged by the
102
+ * profile of the instance it is ATTRIBUTED to ({@link applyProfiles}). Those two
103
+ * sentences only describe the same element while every attribution target is
104
+ * something the author can reach a picker on. They stop describing the same
105
+ * element the moment a framework frames a question against an INNER frame — a
106
+ * C4 boundary, a BPMN lane — and the picker is offered on the outer one alone,
107
+ * which is the arbitration C4's recette settled (PO, 28/08/2026: the board
108
+ * arbitrates the checklist, the boundary does not). Without this, raising a
109
+ * board to its review level would harden the eleven rules framed against the
110
+ * board and silently leave the two framed against a boundary at the default
111
+ * for ever.
112
+ *
113
+ * Inheritance is the general form of that fix and carries no framework name: an
114
+ * instance with no choice of its own is governed by the choice made on the
115
+ * frame it sits in, which is what a reader looking at the two nested rectangles
116
+ * already assumes. An instance that DOES name a profile keeps it — a nested
117
+ * frame can still be arbitrated separately the day a framework offers a picker
118
+ * on one.
119
+ *
120
+ * ## The arithmetic
121
+ *
122
+ * Centre-in-frame, innermost first, ties to the smaller id — the same reading
123
+ * {@link plotContaining}, the audit and the C4 export use to answer "which frame
124
+ * is this drawn on", so a board can never read two different answers to that
125
+ * question. The element BOX rather than the plot: a plot needs the frame's
126
+ * geometry declaration, which is a per-RULE datum, and this map is built once
127
+ * for every rule at once. The margin only matters to an artefact lying on a
128
+ * title band, and inheriting a level of requirement there is harmless where
129
+ * counting it as a member would not be.
130
+ *
131
+ * Cross-framework nesting resolves itself downstream and needs no test here:
132
+ * {@link resolveProfile} ignores a profile id belonging to another framework, so
133
+ * a frame that inherits a foreign one falls back to its own framework's default
134
+ * — exactly what it got before inheriting anything.
135
+ */
136
+ function inheritChosenProfiles(elements, chosen) {
137
+ const frames = elements
138
+ .filter(el => chosen.has(el.id))
139
+ .map(el => ({ id: el.id, bound: el.elementBound }));
140
+ // Read off the map BEFORE writing to it: an inherited id must never be
141
+ // inherited on again, or a chain of frames would depend on the walk order.
142
+ const inherited = [];
143
+ for (const el of elements) {
144
+ if (chosen.has(el.id))
145
+ continue;
146
+ const centre = el.elementBound.center;
147
+ let inner = null;
148
+ for (const frame of frames) {
149
+ if (!frame.bound.containsPoint(centre))
150
+ continue;
151
+ if (inner === null ||
152
+ isInside(frame.bound, inner.bound, frame.id, inner.id)) {
153
+ inner = frame;
154
+ }
155
+ }
156
+ if (inner !== null)
157
+ inherited.push([el.id, chosen.get(inner.id)]);
158
+ }
159
+ for (const [id, profileId] of inherited)
160
+ chosen.set(id, profileId);
161
+ }
162
+ /** Whether `a` is the tighter frame of the two — smaller area, then smaller id. */
163
+ function isInside(a, b, aId, bId) {
164
+ const areaA = a.w * a.h;
165
+ const areaB = b.w * b.h;
166
+ return areaA === areaB ? aId < bId : areaA < areaB;
167
+ }
168
+ /**
169
+ * Whether `rule` is `'off'` under EVERY profile in play on this surface — in
170
+ * which case it is not evaluated at all, which is what `'off'` is for.
171
+ *
172
+ * The default counts as in play unconditionally: a background naming nothing
173
+ * falls back to it, and the surface can gain one at any moment. So the
174
+ * short-circuit only fires when the answer cannot change, and errs towards
175
+ * evaluating — a missed skip costs a linear pass, a wrong skip costs a silent
176
+ * rule.
177
+ */
178
+ function isRuleSilent(rule, chosen, index) {
179
+ if (profileSeverity(rule, index.defaults.get(rule.framework)) !== 'off') {
180
+ return false;
181
+ }
182
+ for (const id of chosen.values()) {
183
+ const profile = index.byId.get(id);
184
+ // Unknown id, or another framework's: falls back to the default, already
185
+ // known to be off.
186
+ if (profile === undefined || profile.framework !== rule.framework)
187
+ continue;
188
+ if (profileSeverity(rule, profile) !== 'off')
189
+ return false;
190
+ }
191
+ return true;
192
+ }
193
+ /**
194
+ * Re-judge what a rule raised, background by background: rewrite each finding's
195
+ * severity to what its own profile says, and drop the ones the profile turned
196
+ * off.
197
+ *
198
+ * After the family, never before: only the family knows which background a
199
+ * finding was measured against ({@link Violation.backgroundId}), and that is
200
+ * precisely what names the profile. A family measuring against no background
201
+ * has no instance to read a choice from and falls back to the default.
202
+ */
203
+ function applyProfiles(rule, raised, chosen, index) {
204
+ const kept = [];
205
+ for (const violation of raised) {
206
+ const chosenId = violation.backgroundId === undefined
207
+ ? undefined
208
+ : chosen.get(violation.backgroundId);
209
+ const severity = profileSeverity(rule, resolveProfile(rule, chosenId, index));
210
+ if (severity === 'off')
211
+ continue;
212
+ violation.severity = severity;
213
+ kept.push(violation);
214
+ }
215
+ return kept;
216
+ }
217
+ /**
218
+ * Complain once per distinct problem. A family runs on every evaluation, so a
219
+ * bare `console.warn` about a malformed rule would fill the console at 8 Hz
220
+ * while somebody drags.
221
+ */
222
+ const warned = new Set();
223
+ function warnOnce(reason) {
224
+ if (warned.has(reason))
225
+ return;
226
+ warned.add(reason);
227
+ console.warn(`[validation] ${reason}`);
228
+ }
229
+ /**
230
+ * Every background instance on the surface carrying the rule's frame role.
231
+ *
232
+ * Empty when the rule declares no frame role, and empty on a board whose maps
233
+ * were all authored before that role existed — which is what makes an old
234
+ * document stay a sketch instead of lighting up.
235
+ */
236
+ function backgroundsOf(rule, elements) {
237
+ const backgroundRole = rule.backgroundRole;
238
+ if (backgroundRole === undefined)
239
+ return [];
240
+ const backgrounds = [];
241
+ for (const el of elements) {
242
+ if (el.role === undefined)
243
+ continue;
244
+ if (roleIsA(el.role, backgroundRole, rule.roles)) {
245
+ backgrounds.push({
246
+ id: el.id,
247
+ bound: el.elementBound,
248
+ props: el,
249
+ });
250
+ }
251
+ }
252
+ return backgrounds;
253
+ }
254
+ /**
255
+ * Every element on the surface acting as a framework background for a REAL-TIME
256
+ * rule.
257
+ *
258
+ * Recorded by the manager after each evaluation, and read on the next one — the
259
+ * one fact about a deleted background that nothing else can reconstruct. See
260
+ * {@link ValidationManager._backgrounds}.
261
+ *
262
+ * ## Why the moment is filtered here too
263
+ *
264
+ * This runs on the gesture path, once per evaluation, and it is a FULL surface
265
+ * walk per rule. `evaluateRules` already refuses to walk an on-demand rule, so
266
+ * leaving it in here would have handed the drawing budget back exactly what the
267
+ * second moment took away from it — measured at +58 % on the manager's tick with
268
+ * the two Wardley check-up rules registered, and invisible to a bench that times
269
+ * `evaluateRules` alone.
270
+ *
271
+ * It is also the right answer and not merely the cheap one: `_backgrounds`
272
+ * exists to decide whether an incremental REAL-TIME pass must fall back to a
273
+ * full sweep. An on-demand rule never takes part in that pass, so the frames it
274
+ * measures against have nothing to invalidate — and a framework whose only rules
275
+ * are on-demand has no incremental pass to protect in the first place.
276
+ */
277
+ export function backgroundElementIds(rules, elements) {
278
+ const ids = new Set();
279
+ for (const rule of rules) {
280
+ if (!isRealtime(rule))
281
+ continue;
282
+ for (const background of backgroundsOf(rule, elements))
283
+ ids.add(background.id);
284
+ }
285
+ return ids;
286
+ }
287
+ /**
288
+ * The instance a finding is attributed to: the one that CONTAINS the subject,
289
+ * failing that the nearest by edge-to-edge gap.
290
+ *
291
+ * Same question `evaluateElementInBackground` answers inline, asked by the
292
+ * families whose subjects normally ARE on the map — an inertia bar, an arrow, a
293
+ * pair of overlapping labels. Ties go to the smaller id so a persisted
294
+ * arbitration never depends on the order a `Y.Map` was rebuilt in — on BOTH
295
+ * halves of the question: a small chart dropped on a big one contains the same
296
+ * artefact twice, and returning whichever of the two the walk met first would
297
+ * have made the promise above true only of the near miss. Same walk and same
298
+ * tie-break as {@link containingBackground}, which asks the containment half
299
+ * alone.
300
+ */
301
+ function attributeBackground(bound, backgrounds) {
302
+ let containing = null;
303
+ let nearest = null;
304
+ let nearestDistance = Infinity;
305
+ for (const background of backgrounds) {
306
+ if (background.bound.contains(bound)) {
307
+ if (containing === null || background.id < containing.id) {
308
+ containing = background;
309
+ }
310
+ continue;
311
+ }
312
+ const distance = gapSquared(background.bound, bound);
313
+ if (distance < nearestDistance ||
314
+ (distance === nearestDistance &&
315
+ nearest !== null &&
316
+ background.id < nearest.id)) {
317
+ nearestDistance = distance;
318
+ nearest = background;
319
+ }
320
+ }
321
+ return containing ?? nearest;
322
+ }
323
+ /**
324
+ * The PLOT of one instance, in model coordinates — the element box minus the
325
+ * declared margin, i.e. the region the frame actually draws its content in.
326
+ *
327
+ * A rule that carries no {@link ValidationRule.background} declaration has no
328
+ * margin to subtract and the whole element box is its plot. That is the honest
329
+ * answer rather than a refusal: `backgroundRole` alone is a complete declaration
330
+ * for a family that only needs to know WHICH frame something is on, and
331
+ * demanding a geometry declaration to count elements inside a box would be
332
+ * asking a framework to declare axes its rule never reads (the same argument
333
+ * `relation-endpoints` makes about its own optional frame).
334
+ *
335
+ * ## The degenerate plot
336
+ *
337
+ * `backgroundPlot` subtracts the declared margins with no guard, so a frame
338
+ * dragged narrower than `margin.left + margin.right` yields a NEGATIVE extent —
339
+ * and a `Bound` of negative width contains no point at all, anywhere. Left
340
+ * alone, a membership family would then tally zero for a frame the user can see
341
+ * holding something, and a `min` rule would answer "this frame holds none" while
342
+ * pointing at one that visibly does. A false accusation whose only remedy —
343
+ * widen the frame — is nowhere in the sentence.
344
+ *
345
+ * So a plot that has collapsed falls back to the element box: the same answer
346
+ * this function already gives a rule that declares no geometry at all, and the
347
+ * one reading under which the artefact the user is looking at still counts.
348
+ * Silence would be defensible too; being wrong in the accusing direction is not.
349
+ */
350
+ function plotOf(def, frame) {
351
+ const bound = frame.bound;
352
+ if (def === undefined)
353
+ return bound;
354
+ const plot = backgroundPlot(def, bound.w, bound.h);
355
+ if (!(plot.width > 0) || !(plot.height > 0))
356
+ return bound;
357
+ return new Bound(bound.x + plot.x0, bound.y + plot.y0, plot.width, plot.height);
358
+ }
359
+ /**
360
+ * The instance an element BELONGS TO: the one whose plot contains its centre,
361
+ * and nothing else.
362
+ *
363
+ * ## The deliberate divergence from {@link attributeBackground}
364
+ *
365
+ * `attributeBackground` answers "which map is this finding about", and falls
366
+ * back to the NEAREST frame when none contains the subject — which is right for
367
+ * a finding that has already been raised and needs somewhere to be waived.
368
+ *
369
+ * This answers a different question: "is this artefact IN that frame". A
370
+ * nearest-fallback here would be a bug with a plausible face — a start event
371
+ * floating just outside a pool would satisfy "this pool has a start event", and
372
+ * a sequence flow from inside the pool to that floating task would read as
373
+ * staying inside it. Membership has to mean membership, or the two families
374
+ * built on it certify drawings that show the opposite of what they claim.
375
+ *
376
+ * The CENTRE, and the plot rather than the element box: the centre is where the
377
+ * eye puts an artefact (the reading {@link ElementInZoneDef.measure} already
378
+ * defaults to) and the plot is the region the frame draws in, so an artefact
379
+ * lying on the pool's own title band is not counted as being in the pool. Same
380
+ * resolution the audit uses to place an element, so a board never reads two
381
+ * different answers to "which frame is this on".
382
+ *
383
+ * Ties go to the smaller id, never to the order a `Y.Map` was rebuilt in — the
384
+ * invariant every attribution in this file holds, and it matters here for the
385
+ * same reason: the answer decides which frame a persisted arbitration is
386
+ * written on.
387
+ */
388
+ function plotContaining(centre, frames) {
389
+ let found = null;
390
+ for (const frame of frames) {
391
+ if (!frame.plot.containsPoint(centre))
392
+ continue;
393
+ if (found === null || frame.instance.id < found.id)
394
+ found = frame.instance;
395
+ }
396
+ return found;
397
+ }
398
+ /**
399
+ * Every instance of the rule's frame, each with its plot resolved once.
400
+ *
401
+ * A board carries units of frames and hundreds of subjects, so the margin
402
+ * arithmetic is done per FRAME and never per subject.
403
+ */
404
+ function framePlots(rule, elements) {
405
+ return backgroundsOf(rule, elements).map(instance => ({
406
+ instance,
407
+ plot: plotOf(rule.background, instance),
408
+ }));
409
+ }
410
+ /**
411
+ * Whether a rule whose whole question is asked ABOUT a frame forgot to name one
412
+ * — and, if so, says so once.
413
+ *
414
+ * `backgroundsOf` answers `[]` both for "this rule names no frame role" and for
415
+ * "this board carries no such frame", and the second is a legitimate silence
416
+ * every family in this file honours: a sketch drawn before the frame is a
417
+ * sketch. The first is a typo in the data, and left undistinguished it produces
418
+ * a rule that never fires and never says why — the failure the missing-bound
419
+ * warnings a few lines below exist to prevent, under a comment saying it is the
420
+ * worst thing declarative data can do.
421
+ *
422
+ * The two are distinguishable statically, so they are distinguished here, before
423
+ * a single element is walked.
424
+ */
425
+ function missingFrameRole(rule, family) {
426
+ if (rule.backgroundRole !== undefined)
427
+ return false;
428
+ warnOnce(`${family} rule "${rule.id}" names no frame role, and its whole question ` +
429
+ `is asked about a frame — the rule is not evaluated.`);
430
+ return true;
431
+ }
432
+ /**
433
+ * Build the finding of `rule` against `elementIds`. One place, so every family
434
+ * carries the rule's keys, its fallbacks and its background attribution the
435
+ * same way.
436
+ *
437
+ * `words` is how a family with several ways of failing says WHICH one did: the
438
+ * finding is still the rule's — same id, same severity, same arbitration — but
439
+ * the sentence the user reads is the one that fits their mistake.
440
+ */
441
+ function raise(rule, elementIds, backgroundId, words = rule) {
442
+ return {
443
+ ruleId: rule.id,
444
+ elementIds,
445
+ severity: rule.severity,
446
+ messageKey: words.messageKey,
447
+ ...(words.messageFallback !== undefined
448
+ ? { messageFallback: words.messageFallback }
449
+ : {}),
450
+ ...(words.suggestionKey ? { suggestion: words.suggestionKey } : {}),
451
+ ...(words.suggestionFallback !== undefined
452
+ ? { suggestionFallback: words.suggestionFallback }
453
+ : {}),
454
+ ...(backgroundId !== undefined ? { backgroundId } : {}),
455
+ };
456
+ }
457
+ /**
458
+ * "Is this element on the framework's background?"
459
+ *
460
+ * An element carrying `rule.appliesTo` (or a specialisation of it) whose bounds
461
+ * are not fully contained by any element carrying `rule.backgroundRole` is in
462
+ * violation. BOTH sides are roles — the engine never reads a shape type.
463
+ *
464
+ * When the surface carries NO background with that role there is no map to be
465
+ * outside of, so the rule yields nothing: a Wardley node dropped on a blank
466
+ * canvas is a sketch, not an error — and so is a map authored before the role
467
+ * existed.
468
+ *
469
+ * Cost: two linear passes, one `elementBound` per candidate. Backgrounds are
470
+ * counted in units, so the per-element cost is constant in practice.
471
+ */
472
+ function evaluateElementInBackground(rule, elements) {
473
+ const subjectRole = rule.appliesTo;
474
+ if (subjectRole === undefined)
475
+ return [];
476
+ const backgrounds = backgroundsOf(rule, elements);
477
+ if (backgrounds.length === 0)
478
+ return [];
479
+ const violations = [];
480
+ for (const el of elements) {
481
+ // Cheapest possible exit for a neutral element: no role, no evaluation.
482
+ if (el.role === undefined)
483
+ continue;
484
+ if (!roleIsA(el.role, subjectRole, rule.roles))
485
+ continue;
486
+ const bound = el.elementBound;
487
+ // One pass, and it answers two questions at once: is this element on ANY
488
+ // map (in which case there is nothing to report), and if not, which map is
489
+ // it nearest to. The second answer is the finding's `backgroundId`, and it
490
+ // has to be taken here — a board carries several maps, and by the time the
491
+ // violation reaches the UI there is no way left to tell which one the user
492
+ // was working on.
493
+ let nearest = null;
494
+ let nearestDistance = Infinity;
495
+ let framed = false;
496
+ for (const background of backgrounds) {
497
+ if (background.bound.contains(bound)) {
498
+ framed = true;
499
+ break;
500
+ }
501
+ const distance = gapSquared(background.bound, bound);
502
+ // Strictly nearer wins; an exact tie is broken by the smaller id, never
503
+ // by which background the surface happened to be walked in first.
504
+ // `backgroundId` decides where a PERSISTED decision gets written and read
505
+ // back, so it cannot depend on the iteration order of a Map rebuilt from
506
+ // a Y.Map on every load and every merge.
507
+ if (distance < nearestDistance ||
508
+ (distance === nearestDistance &&
509
+ nearest !== null &&
510
+ background.id < nearest.id)) {
511
+ nearestDistance = distance;
512
+ nearest = background;
513
+ }
514
+ }
515
+ if (framed)
516
+ continue;
517
+ violations.push(raise(rule, [el.id], nearest?.id));
518
+ }
519
+ return violations;
520
+ }
521
+ /**
522
+ * Squared width of the GAP between two bounds — zero when they touch or
523
+ * overlap, otherwise the distance from edge to edge. Squared because it is only
524
+ * ever compared against another one, so the square root would buy nothing and
525
+ * cost a call per background per violating element.
526
+ *
527
+ * Edges, not centres. "Nearest centre" reads plausibly and is wrong as soon as
528
+ * the maps differ in size: a component 20 units off the right edge of a large
529
+ * map is 900 units from its centre, so a small map 60 units further away wins —
530
+ * and the element gets attributed to a map it is nowhere near. Comparing gaps
531
+ * gives the answer the eye gives, whatever the two maps measure.
532
+ *
533
+ * A background that CONTAINS the element never reaches this: containment exits
534
+ * the loop first, and a framed element raises nothing at all.
535
+ */
536
+ function gapSquared(background, bound) {
537
+ const dx = Math.max(background.x - bound.maxX, bound.x - background.maxX, 0);
538
+ const dy = Math.max(background.y - bound.maxY, bound.y - background.maxY, 0);
539
+ return dx * dx + dy * dy;
540
+ }
541
+ /**
542
+ * How many points a CURVED segment is sampled into. A Wardley map is ~1600
543
+ * units wide and the tightest tolerance any rule declares is 24, so 32 chords
544
+ * put the sampling error two orders of magnitude below the thing being
545
+ * measured. Paid only by an element that actually carries tangents.
546
+ */
547
+ const CURVE_SAMPLES = 32;
548
+ /** A point on the cubic Bézier `p0 → p1` with control points `c0`, `c1`. */
549
+ function cubicAt(p0, c0, c1, p1, t) {
550
+ const u = 1 - t;
551
+ const a = u * u * u;
552
+ const b = 3 * u * u * t;
553
+ const c = 3 * u * t * t;
554
+ const d = t * t * t;
555
+ return [
556
+ a * p0[0] + b * c0[0] + c * c1[0] + d * p1[0],
557
+ a * p0[1] + b * c0[1] + c * c1[1] + d * p1[1],
558
+ ];
559
+ }
560
+ /** Anything but a rounding difference away from `p`. */
561
+ function departsFrom(candidate, p) {
562
+ return (isPoint(candidate) &&
563
+ (Math.abs(candidate[0] - p[0]) > 1e-6 ||
564
+ Math.abs(candidate[1] - p[1]) > 1e-6));
565
+ }
566
+ /**
567
+ * The POLYLINE a directional element exposes, in absolute model coordinates —
568
+ * `null` for anything that is not one.
569
+ *
570
+ * Duck-typed on purpose. The engine must not import a connector model: it knows
571
+ * roles and geometry, and "an element that runs from here to there" is
572
+ * geometry. Two sources, in order:
573
+ *
574
+ * 1. `absolutePath` — the ROUTED path, which is what the user sees and what a
575
+ * right-angled connector actually traces;
576
+ * 2. the free endpoints of an unattached edge, for an element the layout has
577
+ * not routed yet (a freshly pasted connector, a fixture in a unit test).
578
+ *
579
+ * An edge attached at both ends and never laid out has neither, and gets no
580
+ * verdict at all — silence, not a guess.
581
+ *
582
+ * ## Curved edges
583
+ *
584
+ * A routed path is not always a polyline. A curved connector stores its whole
585
+ * shape in TWO points carrying tangents (`absOut` on the first, `absIn` on the
586
+ * second) — so reading the bare coordinates gives the CHORD, and a bar sitting
587
+ * exactly on the drawn curve reads as 150 units off it. Wardley draws straight
588
+ * connectors, but the connector toolbar lets the mode be changed, and a rule
589
+ * that is confidently wrong about what the user can plainly see is worse than
590
+ * one that says nothing.
591
+ *
592
+ * Detected by geometry, not by mode: a point whose tangent departs from itself
593
+ * is a curve, whatever the element type calls it. The segment is then sampled
594
+ * ({@link CURVE_SAMPLES}) so every downstream test — distance, intersection —
595
+ * measures the drawn shape.
596
+ */
597
+ function elementPath(el) {
598
+ const edge = el;
599
+ const raw = edge.absolutePath;
600
+ if (Array.isArray(raw) && raw.length >= 2) {
601
+ const anchors = [];
602
+ for (const point of raw) {
603
+ const x = point?.[0];
604
+ const y = point?.[1];
605
+ if (typeof x !== 'number' || typeof y !== 'number')
606
+ return null;
607
+ anchors.push([x, y]);
608
+ }
609
+ const points = [anchors[0]];
610
+ for (let i = 1; i < anchors.length; i++) {
611
+ const from = anchors[i - 1];
612
+ const to = anchors[i];
613
+ const out = raw[i - 1]?.absOut;
614
+ const into = raw[i]?.absIn;
615
+ // Straight unless a tangent says otherwise — the common case, and the
616
+ // one that must not pay for the other.
617
+ if (!departsFrom(out, from) && !departsFrom(into, to)) {
618
+ points.push(to);
619
+ continue;
620
+ }
621
+ const c0 = isPoint(out) ? out : from;
622
+ const c1 = isPoint(into) ? into : to;
623
+ for (let s = 1; s <= CURVE_SAMPLES; s++) {
624
+ points.push(cubicAt(from, c0, c1, to, s / CURVE_SAMPLES));
625
+ }
626
+ }
627
+ return points;
628
+ }
629
+ const from = edge.source?.position;
630
+ const to = edge.target?.position;
631
+ if (isPoint(from) && isPoint(to))
632
+ return [from, to];
633
+ return null;
634
+ }
635
+ function isPoint(value) {
636
+ return (Array.isArray(value) &&
637
+ value.length >= 2 &&
638
+ typeof value[0] === 'number' &&
639
+ typeof value[1] === 'number');
640
+ }
641
+ /**
642
+ * Which way a directional element RUNS: the unit vector from where it starts to
643
+ * where it ends.
644
+ *
645
+ * End to end, not segment by segment: an elbowed arrow wanders, but what it
646
+ * says is where it came from and where it points. `null` for an element with no
647
+ * path, or one whose ends coincide — a zero-length arrow means nothing and is
648
+ * not a mistake worth a message.
649
+ */
650
+ function elementDirection(el) {
651
+ const path = elementPath(el);
652
+ if (path === null)
653
+ return null;
654
+ const [sx, sy] = path[0];
655
+ const [ex, ey] = path[path.length - 1];
656
+ const dx = ex - sx;
657
+ const dy = ey - sy;
658
+ const length = Math.hypot(dx, dy);
659
+ if (length === 0)
660
+ return null;
661
+ return [dx / length, dy / length];
662
+ }
663
+ /** Squared distance from `p` to the segment `a…b`. */
664
+ function pointSegmentDistanceSquared(p, a, b) {
665
+ const vx = b[0] - a[0];
666
+ const vy = b[1] - a[1];
667
+ const lengthSquared = vx * vx + vy * vy;
668
+ let t = 0;
669
+ if (lengthSquared > 0) {
670
+ t = ((p[0] - a[0]) * vx + (p[1] - a[1]) * vy) / lengthSquared;
671
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
672
+ }
673
+ const dx = p[0] - (a[0] + t * vx);
674
+ const dy = p[1] - (a[1] + t * vy);
675
+ return dx * dx + dy * dy;
676
+ }
677
+ /** Squared distance from `p` to the nearest point of the polyline `path`. */
678
+ function pointPathDistanceSquared(p, path) {
679
+ let best = Infinity;
680
+ for (let i = 1; i < path.length; i++) {
681
+ const d = pointSegmentDistanceSquared(p, path[i - 1], path[i]);
682
+ if (d < best)
683
+ best = d;
684
+ }
685
+ return best;
686
+ }
687
+ /** The centre of a bound, as a point. */
688
+ function centreOf(bound) {
689
+ return [bound.x + bound.w / 2, bound.y + bound.h / 2];
690
+ }
691
+ /**
692
+ * "Does this arrow run against the axis?"
693
+ *
694
+ * The subject's direction is confronted with the declared FORWARD sense of one
695
+ * axis of the frame it sits on (PF5.15). The verdict is an angle and nothing
696
+ * else: no shape type, no colour, no element type — an arrow is a subject
697
+ * because it carries the role, and it is wrong because of where it points.
698
+ *
699
+ * A subject with no direction ({@link elementDirection}) and a frame that
700
+ * declares no such axis both yield silence rather than a guess.
701
+ *
702
+ * Cost: one `elementBound` and one path read per subject, plus the frame
703
+ * attribution. Constant per element — the frames are counted in units.
704
+ */
705
+ function evaluateOrientationAgainstAxis(rule, elements) {
706
+ const subjectRole = rule.appliesTo;
707
+ const against = rule.against;
708
+ const def = rule.background;
709
+ if (subjectRole === undefined || against === undefined || def === undefined) {
710
+ return [];
711
+ }
712
+ const axis = backgroundAxisFact(def, against.axis);
713
+ if (axis === undefined)
714
+ return [];
715
+ const backgrounds = backgroundsOf(rule, elements);
716
+ // No frame on the board, no sense to run against: a lone arrow is a sketch.
717
+ if (backgrounds.length === 0)
718
+ return [];
719
+ // Beyond this angle from the axis' forward sense, the subject runs against
720
+ // it. Perpendicular (90°) plus the declared dead zone.
721
+ const limit = Math.cos(((90 + against.toleranceDeg) * Math.PI) / 180);
722
+ const violations = [];
723
+ for (const el of elements) {
724
+ if (el.role === undefined)
725
+ continue;
726
+ if (!roleIsA(el.role, subjectRole, rule.roles))
727
+ continue;
728
+ const direction = elementDirection(el);
729
+ if (direction === null)
730
+ continue;
731
+ const alignment = direction[0] * axis.forward[0] + direction[1] * axis.forward[1];
732
+ if (alignment >= limit)
733
+ continue;
734
+ violations.push(raise(rule, [el.id], attributeBackground(el.elementBound, backgrounds)?.id));
735
+ }
736
+ return violations;
737
+ }
738
+ /**
739
+ * "Is this straddling what it is supposed to be straddling — and, if the rule
740
+ * asks, posed on what it is supposed to be posed on?"
741
+ *
742
+ * The subject must STRADDLE one of the frame's declared transitions: its own
743
+ * extent along `boundaryAxis` has to intersect the transition band
744
+ * ({@link backgroundTransitionBands}), which is what "superimposed on the
745
+ * dashed line" is as geometry (see {@link AttachmentDef.boundaryAxis}).
746
+ *
747
+ * A rule may ALSO name a carrier (PF5.16), and then the subject must sit within
748
+ * `tolerance` of an element carrying that role — an edge, measured along its
749
+ * path. That half is optional, and Wardley's W2 stopped declaring it on the PO
750
+ * recette of 02/08/2026: a bar on a divider with no dependency under it is a
751
+ * legitimate map, not a mistake.
752
+ *
753
+ * One finding, and it says which half failed. Two requirements are still a
754
+ * single badge on a single symbol — two would be pedantry on a bar eight units
755
+ * wide — but they are two different mistakes with two different gestures to fix
756
+ * them, so the finding carries the words of the one that actually failed. When
757
+ * BOTH fail it reports the carrier: a symbol attached to nothing has to find
758
+ * something to be about before where it sits can mean anything.
759
+ *
760
+ * A rule that names a carrier stays silent while the board carries none: a map
761
+ * with no dependency yet is a map being drawn, not a map with a misplaced
762
+ * symbol.
763
+ *
764
+ * ## The boundary half asks nothing while the boundary is hidden
765
+ *
766
+ * The frontiers are read off the INSTANCE, not off the declaration alone
767
+ * ({@link backgroundTransitionBands}): a framework that lets the user switch its
768
+ * graduations off names the prop that shows them, and a frame with them off
769
+ * yields no band, so every subject on it passes.
770
+ *
771
+ * Proportionality, and the PO's own wording (25/08/2026): a user who has hidden
772
+ * the dashed columns is looking at a plain card, and telling them a bar is not
773
+ * astride a line they cannot see is a finding they cannot act on — the gesture
774
+ * it asks for would aim at nothing. Judge the drawing that is on the canvas,
775
+ * not the one the declaration could have painted. The rule is not weakened, it
776
+ * is scoped: turn the columns back on and every verdict comes back, on the same
777
+ * bars, in the same places.
778
+ */
779
+ function evaluateAttachment(rule, elements) {
780
+ const subjectRole = rule.appliesTo;
781
+ const attachment = rule.attachment;
782
+ if (subjectRole === undefined || attachment === undefined)
783
+ return [];
784
+ const carrierRole = attachment.carrierRole;
785
+ // A carrier is something with a PATH. Declaring anything else here produces a
786
+ // rule that can never fire; say so once instead of shrugging.
787
+ const carrierKind = carrierRole === undefined ? undefined : rule.roles[carrierRole]?.kind;
788
+ if (carrierKind !== undefined && carrierKind !== 'edge') {
789
+ warnOnce(`attachment rule "${rule.id}" names a "${carrierKind}" role ` +
790
+ `("${carrierRole}") as its carrier — only an "edge" role ` +
791
+ `has a path, and "posed on" is a distance to one, so this rule can ` +
792
+ `never fire.`);
793
+ return [];
794
+ }
795
+ const carriers = [];
796
+ const subjects = [];
797
+ for (const el of elements) {
798
+ if (el.role === undefined)
799
+ continue;
800
+ if (carrierRole !== undefined &&
801
+ roleIsA(el.role, carrierRole, rule.roles)) {
802
+ const path = elementPath(el);
803
+ if (path !== null)
804
+ carriers.push(path);
805
+ }
806
+ else if (roleIsA(el.role, subjectRole, rule.roles)) {
807
+ subjects.push(el);
808
+ }
809
+ }
810
+ if (subjects.length === 0)
811
+ return [];
812
+ if (carrierRole !== undefined && carriers.length === 0)
813
+ return [];
814
+ const backgrounds = backgroundsOf(rule, elements);
815
+ const tolerance = attachment.tolerance ?? 0;
816
+ const toleranceSquared = tolerance * tolerance;
817
+ // The transition test needs a frame to read the bands off, so a rule asking
818
+ // for one on a board with no frame simply does not ask.
819
+ const def = rule.background;
820
+ let axis = attachment.boundaryAxis !== undefined && def !== undefined
821
+ ? backgroundAxisFact(def, attachment.boundaryAxis)
822
+ : undefined;
823
+ if (axis !== undefined && def?.transitionBandWidth === undefined) {
824
+ // A width the frame never declared is not a width the engine may invent:
825
+ // guessing one would indict every subject on the board over a number
826
+ // nobody wrote down.
827
+ warnOnce(`attachment rule "${rule.id}" asks for the "${attachment.boundaryAxis}" ` +
828
+ `transitions, but its background declares no "transitionBandWidth" — ` +
829
+ `the boundary half of this rule is not evaluated.`);
830
+ axis = undefined;
831
+ }
832
+ // Bands are a function of the frame's bounds, and a board carries units of
833
+ // frames against hundreds of subjects: read once per frame, not per bar.
834
+ const bandsOf = new Map();
835
+ const violations = [];
836
+ for (const el of subjects) {
837
+ const bound = el.elementBound;
838
+ const centre = centreOf(bound);
839
+ let carried = carrierRole === undefined;
840
+ for (const path of carriers) {
841
+ if (pointPathDistanceSquared(centre, path) <= toleranceSquared) {
842
+ carried = true;
843
+ break;
844
+ }
845
+ }
846
+ const frame = attributeBackground(bound, backgrounds);
847
+ let inBand = true;
848
+ // The frontier the subject missed: the nearest one, which is the one the
849
+ // user was aiming at and the only one a suggestion can point back to.
850
+ let missed;
851
+ if (carried && axis !== undefined && frame !== null && def) {
852
+ let bands = bandsOf.get(frame.id);
853
+ if (bands === undefined) {
854
+ // `frame.props` is what makes the bands the ones the user can SEE: a
855
+ // frame whose graduations are switched off yields none, and the
856
+ // "no transition at all" branch below turns that into silence.
857
+ const all = backgroundTransitionBands(def, frame.bound, frame.props);
858
+ // A transition ACROSS a horizontal axis is a vertical band, i.e. an `x`.
859
+ bands = axis.orientation === 'horizontal' ? all.x : all.y;
860
+ bandsOf.set(frame.id, bands);
861
+ }
862
+ // The subject's own EXTENT across the axis, not a point inside it: the
863
+ // question is whether the frontier passes THROUGH the subject, and a
864
+ // centre cannot answer it for anything wider than a hair.
865
+ const horizontal = axis.orientation === 'horizontal';
866
+ const from = horizontal ? bound.x : bound.y;
867
+ const to = horizontal ? bound.maxX : bound.maxY;
868
+ const value = horizontal ? centre[0] : centre[1];
869
+ inBand = false;
870
+ let nearest = Infinity;
871
+ for (const band of bands) {
872
+ // Two intervals overlapping: the subject covers the frontier, or the
873
+ // frame's declared band covers the subject.
874
+ if (from <= band.max && to >= band.min) {
875
+ inBand = true;
876
+ break;
877
+ }
878
+ const away = Math.abs(value - band.at);
879
+ if (away < nearest) {
880
+ nearest = away;
881
+ missed = band;
882
+ }
883
+ }
884
+ // A frame with no transition at all asks nothing of the subject — and
885
+ // since the bands are read through the instance, "none" covers both a
886
+ // frame that declares no frontier and one whose frontiers the user has
887
+ // hidden.
888
+ if (bands.length === 0)
889
+ inBand = true;
890
+ }
891
+ if (carried && inBand)
892
+ continue;
893
+ // The words of the half that failed. Carrier first when a rule declares
894
+ // one: it is the more actionable of the two, and the only one that makes
895
+ // the other mean anything. A boundary-only rule has `carried` true and no
896
+ // `offBoundary`, so it says its own sentence — the only one it has.
897
+ const violation = raise(rule, [el.id], frame?.id, carried ? attachment.offBoundary : rule);
898
+ if (carried && missed !== undefined)
899
+ violation.boundaryId = missed.id;
900
+ violations.push(violation);
901
+ }
902
+ return violations;
903
+ }
904
+ /**
905
+ * How much shared extent counts as an overlap rather than a shared edge.
906
+ *
907
+ * Two boxes that TOUCH — a label ending exactly where a node begins — share no
908
+ * area and are perfectly readable, which is precisely what alignment and snap
909
+ * produce all day. The house `Bound.isOverlapWithBound` answers `true` for them
910
+ * (it compares against `+EPSILON`, so equality passes), so this asks for real
911
+ * shared extent instead. Small enough that a pixel of genuine overlap still
912
+ * counts, large enough that float noise on a snapped edge does not.
913
+ */
914
+ const OVERLAP_EPSILON = 1e-6;
915
+ /** Do these two bounds share AREA, as opposed to an edge? */
916
+ function boundsOverlap(a, b) {
917
+ return (b.maxX - a.minX > OVERLAP_EPSILON &&
918
+ a.maxX - b.minX > OVERLAP_EPSILON &&
919
+ b.maxY - a.minY > OVERLAP_EPSILON &&
920
+ a.maxY - b.minY > OVERLAP_EPSILON);
921
+ }
922
+ /**
923
+ * Do these two subjects actually collide, given each one's geometry?
924
+ *
925
+ * ## Known limit: a ROTATED node is measured by its bounding box
926
+ *
927
+ * `elementBound` is the axis-aligned box of a rotated element, so a 120×26
928
+ * label at 45° presents as roughly 103×103 — the same inflation this family
929
+ * refuses to accept for an edge, where the fix was to measure along the path.
930
+ * A rotated NODE has no equivalent cheap answer: it needs an oriented box and a
931
+ * separating-axis test on both sides of every pair, in the inner loop of the
932
+ * only super-linear family here.
933
+ *
934
+ * Deliberately not built, and deliberately written down. Rotating a Wardley
935
+ * node is not a gesture the framework has; the day a framework rotates its
936
+ * artefacts is the day this is worth the inner loop, and {@link OverlapSubject}
937
+ * is where the oriented box would go. Until then the failure mode is a
938
+ * false POSITIVE on a rotated pair — a warning too many, never a miss — which
939
+ * is the right way round for a readability rule that is only ever a warning.
940
+ * A test pins the behaviour so a change to it cannot be silent.
941
+ *
942
+ * A rotated `text` role holds the same line, and has to be MADE to: narrowing
943
+ * a rotated box to the band an unrotated renderer would draw in is how a miss
944
+ * gets built (at 180° the words are at the other end of it). So
945
+ * {@link textInkBound} hands a rotated element its whole box back.
946
+ */
947
+ function subjectsCollide(a, b, minPenetration) {
948
+ // Bounding boxes first: on a dense map almost every pair dies here, and this
949
+ // is the test the O(n²) sweep is really made of.
950
+ if (!boundsOverlap(a.bound, b.bound))
951
+ return false;
952
+ if (a.path === null && b.path === null) {
953
+ return (minPenetration <= 0 ||
954
+ boundsPenetration(a.bound, b.bound) > minPenetration);
955
+ }
956
+ // Two paths: zero-width lines, so there is no depth to measure and a declared
957
+ // crossing is reported as it always was.
958
+ if (a.path !== null && b.path !== null)
959
+ return pathsCross(a.path, b.path);
960
+ const path = (a.path ?? b.path);
961
+ const bound = a.path === null ? a.bound : b.bound;
962
+ if (!pathHitsBound(path, bound))
963
+ return false;
964
+ return minPenetration <= 0 || pathPenetration(path, bound) > minPenetration;
965
+ }
966
+ /**
967
+ * How far two overlapping boxes reach INTO each other: the smaller of the two
968
+ * axis overlaps, which is the distance one of them would have to move to come
969
+ * free. Corner-grazing gives a small number on both axes; a name written across
970
+ * a node gives the height of the letters.
971
+ */
972
+ function boundsPenetration(a, b) {
973
+ return Math.min(Math.min(a.maxX - b.minX, b.maxX - a.minX), Math.min(a.maxY - b.minY, b.maxY - a.minY));
974
+ }
975
+ /**
976
+ * The deepest any point of `path` gets under an edge of `bound` — 0 when the
977
+ * path only touches it, negative when it misses entirely.
978
+ *
979
+ * "Depth" is the distance to the NEAREST edge, so a link crossing a label
980
+ * lengthwise through the middle scores half the line height, and one clipping a
981
+ * corner scores almost nothing. Exact, not sampled: that distance is the
982
+ * minimum of four linear functions of the position along the segment, hence
983
+ * concave, so its maximum is attained either at an end of the segment or where
984
+ * two of the four swap places — at most eight candidates, all of them tested.
985
+ *
986
+ * Only ever reached by a pair that already collides, i.e. off the hot path of
987
+ * the sweep.
988
+ */
989
+ function pathPenetration(path, bound) {
990
+ const edges = (p) => [
991
+ p[0] - bound.minX,
992
+ bound.maxX - p[0],
993
+ p[1] - bound.minY,
994
+ bound.maxY - p[1],
995
+ ];
996
+ const depthAt = (from, to, t) => {
997
+ let depth = Infinity;
998
+ for (let k = 0; k < 4; k++) {
999
+ depth = Math.min(depth, from[k] + t * (to[k] - from[k]));
1000
+ }
1001
+ return depth;
1002
+ };
1003
+ let deepest = -Infinity;
1004
+ for (let i = 1; i < path.length; i++) {
1005
+ const from = edges(path[i - 1]);
1006
+ const to = edges(path[i]);
1007
+ deepest = Math.max(deepest, depthAt(from, to, 0), depthAt(from, to, 1));
1008
+ for (let m = 0; m < 4; m++) {
1009
+ for (let n = m + 1; n < 4; n++) {
1010
+ const slope = to[m] - from[m] - (to[n] - from[n]);
1011
+ if (slope === 0)
1012
+ continue;
1013
+ const t = (from[n] - from[m]) / slope;
1014
+ if (t <= 0 || t >= 1)
1015
+ continue;
1016
+ deepest = Math.max(deepest, depthAt(from, to, t));
1017
+ }
1018
+ }
1019
+ }
1020
+ return deepest;
1021
+ }
1022
+ /**
1023
+ * Advance width of one character, as a fraction of the font size, by CLASS.
1024
+ *
1025
+ * The engine measures no text: a canvas in the evaluation path would cost a
1026
+ * `measureText` per label per pass, and would make the verdict depend on which
1027
+ * fonts a host happens to have loaded — the same map would validate differently
1028
+ * in a headless report and in a browser. So the width is DECLARED, and read off
1029
+ * this table.
1030
+ *
1031
+ * ## Why a table and not one mean advance
1032
+ *
1033
+ * A single ratio was the first answer and it was wrong in the direction that
1034
+ * matters. Letters differ by a factor of FOUR — `i` is 0.24 em and `W` is 0.9 —
1035
+ * so an average tuned on `Customer` reads `utility` half as wide again as it is
1036
+ * drawn, and a link 15 units past the last letter of a lowercase name lands
1037
+ * inside the ghost. That is the very false positive this geometry exists to
1038
+ * remove, and it survived the first version for every narrow-lettered name.
1039
+ *
1040
+ * Five classes, still constant per character, still no canvas, still the same
1041
+ * answer on every host:
1042
+ *
1043
+ * | class | em | characters |
1044
+ * | --- | --- | --- |
1045
+ * | thin | 0.26 | `i l I j` and the punctuation, brackets and the SPACE |
1046
+ * | narrow | 0.35 | `f t r " *` |
1047
+ * | wide | 0.85 | `m w M W @ %` |
1048
+ * | capital | 0.62 | `A`–`Z`, the rest of them |
1049
+ * | full-width | 1.00 | anything from U+2E80 up: CJK, kana, hangul |
1050
+ * | nothing | 0.00 | combining marks, zero-width joiners, variation selectors |
1051
+ * | default | 0.53 | everything else, lowercase and digits |
1052
+ *
1053
+ * ## The precision, measured rather than claimed
1054
+ *
1055
+ * Against the real renderer at Inter 18, over the 28-name bench in
1056
+ * `integration-test/.../wardley-validation.spec.ts` — which prints every line
1057
+ * and fails outside ±15 % on ANY of them:
1058
+ *
1059
+ * | name | drawn | declared |
1060
+ * | --- | --- | --- |
1061
+ * | `utility` | 46.4 | 45.7 (−1 %) |
1062
+ * | `little` | 36.6 | 36.2 (−1 %) |
1063
+ * | `ERP` | 33.7 | 33.5 (−1 %) |
1064
+ * | `Customer` | 83.2 | 77.2 (−7 %) |
1065
+ * | `Payment gateway` | 152.0 | 144.9 (−5 %) |
1066
+ * | `Cloud` | 49.7 | 44.5 (**−11 %**, the worst of the 28) |
1067
+ * | `WWWWWWWWWW` | 170.8 | 153.0 (−10 %) |
1068
+ * | `付款` | 36.0 | 36.0 (0 %) |
1069
+ *
1070
+ * **Never wide, on any of the 28** — which is the property that matters: an
1071
+ * over-estimate is a ghost past the last letter, and a link crossing it is
1072
+ * precisely the false positive this geometry exists to remove. What is left is
1073
+ * a few units of silence at the end of a name, on the scale
1074
+ * {@link ValidationRule.minPenetration} is calibrated on.
1075
+ *
1076
+ * (The drawn figure itself moves by a few percent with the web font's loading
1077
+ * state — the other half of why the engine does not try to measure exactly.)
1078
+ */
1079
+ const TEXT_ADVANCE = /* @__PURE__ */ (() => {
1080
+ const table = Object.create(null);
1081
+ for (const char of " .,:;!|'`()[]{}/\\-ilIj")
1082
+ table[char] = 0.26;
1083
+ for (const char of 'ftr"*')
1084
+ table[char] = 0.35;
1085
+ for (const char of 'mwMW@%')
1086
+ table[char] = 0.85;
1087
+ return table;
1088
+ })();
1089
+ /** Width of one character, in em. See {@link TEXT_ADVANCE}. */
1090
+ function charAdvance(char) {
1091
+ const declared = TEXT_ADVANCE[char];
1092
+ if (declared !== undefined)
1093
+ return declared;
1094
+ const code = char.codePointAt(0) ?? 0;
1095
+ // Code points that draw nothing OF THEIR OWN, and so advance nothing: a
1096
+ // combining mark sits on the letter before it, a zero-width space or joiner
1097
+ // is not there at all, and a variation selector only says how to draw its
1098
+ // neighbour. Billed at full price they turn "élément" pasted from a Mac
1099
+ // (decomposed: nine code points, seven glyphs) into a fifth of a name's worth
1100
+ // of white paper — a false positive, which is the one thing this must not
1101
+ // manufacture. Before the full-width test, since U+FE0F is above it.
1102
+ if ((code >= 0x0300 && code <= 0x036f) ||
1103
+ (code >= 0x200b && code <= 0x200d) ||
1104
+ code === 0xfe0f) {
1105
+ return 0;
1106
+ }
1107
+ // Full-width scripts, where one character IS one em.
1108
+ if (code >= 0x2e80)
1109
+ return 1;
1110
+ // The rest of the capitals: wider than lowercase, narrower than `M`.
1111
+ if (code >= 65 && code <= 90)
1112
+ return 0.62;
1113
+ return 0.53;
1114
+ }
1115
+ /** Width of one line of text, in model units. */
1116
+ function lineWidth(line, fontSize) {
1117
+ let em = 0;
1118
+ for (const char of line)
1119
+ em += charAdvance(char);
1120
+ return em * fontSize;
1121
+ }
1122
+ /**
1123
+ * The box the TEXT of an element actually occupies, inside the box the element
1124
+ * was created with.
1125
+ *
1126
+ * A text element is created at a width that says nothing about its content — a
1127
+ * Wardley label is 120 to 200 units wide whether it reads "ERP" or "Customer
1128
+ * relationship management" — so its box carries empty margin on the side its
1129
+ * `textAlign` runs away from. Measuring that box makes the rule report things
1130
+ * the user cannot see, which is exactly the report they stop believing.
1131
+ *
1132
+ * Narrowed on the horizontal only, and never widened: the height stored on the
1133
+ * element IS the rendered block (the editor writes `lineHeight × lines` back on
1134
+ * every edit), and the renderer lays the lines out from the TOP of the box.
1135
+ * An element exposing no text is left exactly as it was, so a fixture or a
1136
+ * host element that carries none is measured by its box as before; one whose
1137
+ * text reads EMPTY gets a width of zero, and {@link evaluateNoOverlap} drops it
1138
+ * from the pass entirely — it draws nothing, so it collides with nothing.
1139
+ *
1140
+ * ## A ROTATED text is measured by its whole box
1141
+ *
1142
+ * `elementBound` is the axis-aligned box of a rotated element, and this cuts a
1143
+ * band out of it where an UNROTATED renderer would put the ink. At 180° the
1144
+ * words are at the other end of the box, so the band would be white paper and
1145
+ * the letters outside it: a MISS, not a warning too many. A rotated text
1146
+ * therefore keeps its whole box — over-reporting, which is the failure mode the
1147
+ * family already accepts for a rotated node, and the one a readability warning
1148
+ * can afford.
1149
+ */
1150
+ function textInkBound(el, bound) {
1151
+ const text = el;
1152
+ if (typeof text.fontSize !== 'number' || text.text == null)
1153
+ return bound;
1154
+ if (typeof text.rotate === 'number' && text.rotate !== 0)
1155
+ return bound;
1156
+ let longest = 0;
1157
+ // NFC first: the same name typed here and pasted from a Mac can arrive as the
1158
+ // same glyphs in a different number of code points, and a width that depends
1159
+ // on which one would be a rule that answers differently on identical text.
1160
+ for (const line of String(text.text).normalize('NFC').split('\n')) {
1161
+ longest = Math.max(longest, lineWidth(line, text.fontSize));
1162
+ }
1163
+ const w = Math.min(bound.w, longest);
1164
+ const x = text.textAlign === 'center'
1165
+ ? bound.x + (bound.w - w) / 2
1166
+ : text.textAlign === 'right'
1167
+ ? bound.maxX - w
1168
+ : bound.x;
1169
+ return new Bound(x, bound.y, w, bound.h);
1170
+ }
1171
+ function pathHitsBound(path, bound) {
1172
+ for (let i = 1; i < path.length; i++) {
1173
+ if (bound.containsPoint(path[i - 1]) || bound.containsPoint(path[i])) {
1174
+ return true;
1175
+ }
1176
+ if (bound.intersectLine(path[i - 1], path[i]) !== null)
1177
+ return true;
1178
+ }
1179
+ return false;
1180
+ }
1181
+ function pathsCross(a, b) {
1182
+ for (let i = 1; i < a.length; i++) {
1183
+ for (let j = 1; j < b.length; j++) {
1184
+ if (lineIntersects(a[i - 1], a[i], b[j - 1], b[j]))
1185
+ return true;
1186
+ }
1187
+ }
1188
+ return false;
1189
+ }
1190
+ /**
1191
+ * "Do these two things sit on top of each other?" (PF5.13)
1192
+ *
1193
+ * The first family that is not element-local: it evaluates PAIRS, so a finding
1194
+ * names two elements and neither of them alone is at fault. Which pairs is
1195
+ * declared data — `[label, node]`, `[label, link]` — and the geometry each side
1196
+ * is measured with follows the role's own `kind`, so an edge is measured along
1197
+ * its path and never by the bounding box of its diagonal.
1198
+ *
1199
+ * ## Cost, and the dirty set
1200
+ *
1201
+ * Naive it is O(p²) over the PARTICIPANTS — the elements carrying one of the
1202
+ * declared roles, never the whole surface — with every bound read exactly once
1203
+ * and a rectangle test as the inner loop. That is what the bench measures on
1204
+ * the reference map, and it fits.
1205
+ *
1206
+ * With an {@link IncrementalContext} it is O(|dirty| × p): findings that name
1207
+ * only untouched elements are carried over from the previous evaluation, and
1208
+ * only the couples involving something that MOVED are re-tested. A drag on a
1209
+ * dense map is one element against the participants, not the whole grid against
1210
+ * itself.
1211
+ */
1212
+ function evaluateNoOverlap(rule, elements, incremental) {
1213
+ const pairs = rule.overlap;
1214
+ if (pairs === undefined || pairs.length === 0)
1215
+ return [];
1216
+ // The distinct roles the pairs mention, and the pairs re-expressed as index
1217
+ // couples into that list — so the inner loop compares booleans, not strings.
1218
+ const slots = [];
1219
+ const couples = [];
1220
+ for (const [a, b] of pairs) {
1221
+ const ia = slots.indexOf(a) >= 0 ? slots.indexOf(a) : slots.push(a) - 1;
1222
+ const ib = slots.indexOf(b) >= 0 ? slots.indexOf(b) : slots.push(b) - 1;
1223
+ couples.push([ia, ib]);
1224
+ }
1225
+ const subjects = [];
1226
+ for (const el of elements) {
1227
+ // Neutral element: never a participant, whatever it sits on.
1228
+ if (el.role === undefined)
1229
+ continue;
1230
+ let matched = false;
1231
+ const filled = slots.map(slot => {
1232
+ const isA = roleIsA(el.role, slot, rule.roles);
1233
+ matched ||= isA;
1234
+ return isA;
1235
+ });
1236
+ if (!matched)
1237
+ continue;
1238
+ const kind = rule.roles[el.role]?.kind;
1239
+ // A `text` role is measured by the ink of its text, not by the box it was
1240
+ // created at; everything else by its bounds.
1241
+ const bound = kind === 'text' ? textInkBound(el, el.elementBound) : el.elementBound;
1242
+ // A text emptied of its words draws NOTHING, and a zero-width box is still
1243
+ // a vertical line that a wider box can be said to contain. Out of the pass:
1244
+ // it cannot make anything unreadable. Only a TEXT — a perfectly vertical
1245
+ // connector has a flat box too, and is measured along its path.
1246
+ if (kind === 'text' && bound.w === 0)
1247
+ continue;
1248
+ subjects.push({
1249
+ id: el.id,
1250
+ bound,
1251
+ // An `edge` role is measured along its path.
1252
+ path: kind === 'edge' ? elementPath(el) : null,
1253
+ slots: filled,
1254
+ });
1255
+ }
1256
+ if (subjects.length < 2)
1257
+ return [];
1258
+ const backgrounds = backgroundsOf(rule, elements);
1259
+ const declared = (a, b) => couples.some(([i, j]) => (a.slots[i] && b.slots[j]) || (a.slots[j] && b.slots[i]));
1260
+ const minPenetration = rule.minPenetration ?? 0;
1261
+ const found = [];
1262
+ const test = (a, b) => {
1263
+ if (!declared(a, b) || !subjectsCollide(a, b, minPenetration))
1264
+ return;
1265
+ // Sorted, so the same collision reads the same way every time — and the
1266
+ // frame is read off the SAME half whichever end the pair was reached from,
1267
+ // or a full pass and an incremental one could attribute one collision to
1268
+ // two different maps.
1269
+ const [first, second] = a.id < b.id ? [a, b] : [b, a];
1270
+ found.push(raise(rule, [first.id, second.id], attributeBackground(first.bound, backgrounds)?.id));
1271
+ };
1272
+ const sweep = () => {
1273
+ for (let i = 0; i < subjects.length; i++) {
1274
+ for (let j = i + 1; j < subjects.length; j++)
1275
+ test(subjects[i], subjects[j]);
1276
+ }
1277
+ return found;
1278
+ };
1279
+ if (incremental === undefined)
1280
+ return sweep();
1281
+ const { dirty } = incremental;
1282
+ /**
1283
+ * Anything that IS or WAS a frame sends the whole surface back through the
1284
+ * sweep.
1285
+ *
1286
+ * A frame is not a participant, so no pair-wise re-test ever reaches the
1287
+ * findings attributed to it — and moving, resizing or DELETING one changes
1288
+ * every one of those attributions, which is what decides the map-wide
1289
+ * arbitration and the profile in force. Deletion is the case that made this
1290
+ * necessary and the one a "is a current background dirty" test cannot see:
1291
+ * once the map is gone `backgrounds` is empty, nothing looks dirty, and the
1292
+ * findings measured against it would vanish from a live board.
1293
+ */
1294
+ const frameTouched = backgrounds.some(background => dirty.has(background.id)) ||
1295
+ incremental.previous.some(violation => violation.backgroundId !== undefined &&
1296
+ dirty.has(violation.backgroundId));
1297
+ if (frameTouched)
1298
+ return sweep();
1299
+ /**
1300
+ * ...and so does a change big enough that the shortcut is no longer one.
1301
+ *
1302
+ * The dirty path costs `dirtyParticipants × p`; the sweep costs `p²/2`. Past
1303
+ * the crossover the "optimisation" is several times the price of the thing it
1304
+ * replaces — measured at 8× and OUT of the 16 ms budget for a lasso drag over
1305
+ * a third of a 500-element map, against 2.2 ms for the sweep it was avoiding.
1306
+ * One comparison buys back the worst case entirely.
1307
+ */
1308
+ let dirtyParticipants = 0;
1309
+ const isDirty = subjects.map(subject => {
1310
+ const changed = dirty.has(subject.id);
1311
+ if (changed)
1312
+ dirtyParticipants++;
1313
+ return changed;
1314
+ });
1315
+ if (dirtyParticipants * 2 >= subjects.length)
1316
+ return sweep();
1317
+ // Only the couples involving something that changed. Everything else keeps
1318
+ // the verdict it already had.
1319
+ for (let i = 0; i < subjects.length; i++) {
1320
+ if (!isDirty[i])
1321
+ continue;
1322
+ for (let j = 0; j < subjects.length; j++) {
1323
+ if (j === i)
1324
+ continue;
1325
+ // A dirty–dirty couple is reachable from both ends and must be tested
1326
+ // once. The earlier index already tested it against everything, so only
1327
+ // look forward — no per-couple key, and nothing allocated in the loop
1328
+ // that is the whole cost of this path.
1329
+ if (j < i && isDirty[j])
1330
+ continue;
1331
+ test(subjects[i], subjects[j]);
1332
+ }
1333
+ }
1334
+ const alive = new Set(subjects.map(subject => subject.id));
1335
+ // The `backgroundId` is deliberately NOT filtered on here: a pair-wise
1336
+ // finding depends on its frame only for ATTRIBUTION, and any change to that
1337
+ // frame already took the whole surface through the sweep above. Dropping
1338
+ // findings on it as well is how a live overlap disappeared when its map was
1339
+ // deleted.
1340
+ const carried = incremental.previous.filter(violation => violation.ruleId === rule.id &&
1341
+ violation.elementIds.every(id => !dirty.has(id) && alive.has(id)));
1342
+ return [...carried, ...found];
1343
+ }
1344
+ /**
1345
+ * Hue windows, in degrees, in ascending order. The last one wraps back onto
1346
+ * red, which is why the table is read as "first upper bound above the hue".
1347
+ */
1348
+ const TONE_HUES = [
1349
+ [15, 'red'],
1350
+ [45, 'orange'],
1351
+ [70, 'yellow'],
1352
+ [160, 'green'],
1353
+ [200, 'teal'],
1354
+ [260, 'blue'],
1355
+ [300, 'purple'],
1356
+ [345, 'magenta'],
1357
+ [360, 'red'],
1358
+ ];
1359
+ /**
1360
+ * Tone words, longest first so a substring can never win over the name that
1361
+ * contains it. This is how a THEME TOKEN is classified — `--affine-palette-shape-red`
1362
+ * carries its own answer in its name, and reading it is the only way to classify
1363
+ * a variable the engine has no stylesheet to resolve.
1364
+ */
1365
+ const TONE_WORDS = [
1366
+ ['magenta', 'magenta'],
1367
+ ['purple', 'purple'],
1368
+ ['orange', 'orange'],
1369
+ ['yellow', 'yellow'],
1370
+ ['green', 'green'],
1371
+ ['white', 'grey'],
1372
+ ['black', 'grey'],
1373
+ ['blue', 'blue'],
1374
+ ['teal', 'teal'],
1375
+ ['grey', 'grey'],
1376
+ ['gray', 'grey'],
1377
+ ['red', 'red'],
1378
+ ];
1379
+ /** Below this saturation a colour reads as a grey whatever its hue says. */
1380
+ const GREY_SATURATION = 0.15;
1381
+ /** Below this alpha nothing is on screen, so there is nothing to judge. */
1382
+ const INVISIBLE_ALPHA = 0.08;
1383
+ /** `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa` → `[r, g, b, a]` in 0..1. */
1384
+ function parseHex(value) {
1385
+ const hex = value.slice(1);
1386
+ const short = hex.length === 3 || hex.length === 4;
1387
+ if (!short && hex.length !== 6 && hex.length !== 8)
1388
+ return null;
1389
+ const size = short ? 1 : 2;
1390
+ const channels = [];
1391
+ for (let i = 0; i < hex.length; i += size) {
1392
+ const chunk = hex.slice(i, i + size);
1393
+ const parsed = Number.parseInt(short ? chunk + chunk : chunk, 16);
1394
+ if (Number.isNaN(parsed))
1395
+ return null;
1396
+ channels.push(parsed / 255);
1397
+ }
1398
+ return [channels[0], channels[1], channels[2], channels[3] ?? 1];
1399
+ }
1400
+ /**
1401
+ * Which tone family a colour belongs to, or `undefined` when the engine cannot
1402
+ * honestly say.
1403
+ *
1404
+ * Two readings, in order: the NUMBER when the colour is a hex the process can
1405
+ * actually see, and the NAME when it is a theme token it cannot. Anything else —
1406
+ * `transparent`, a gradient, an `rgb()` triple, a variable with no tone in its
1407
+ * name — is unclassifiable, and unclassifiable means silence: a convention rule
1408
+ * that guessed would indict a user over a colour nobody here can see.
1409
+ */
1410
+ export function toneOf(value) {
1411
+ if (!value)
1412
+ return undefined;
1413
+ const raw = value.trim().toLowerCase();
1414
+ if (raw === '' || raw === 'transparent')
1415
+ return undefined;
1416
+ if (raw.startsWith('#')) {
1417
+ const rgba = parseHex(raw);
1418
+ if (rgba === null)
1419
+ return undefined;
1420
+ const [r, g, b, a] = rgba;
1421
+ if (a < INVISIBLE_ALPHA)
1422
+ return undefined;
1423
+ const max = Math.max(r, g, b);
1424
+ const min = Math.min(r, g, b);
1425
+ const lightness = (max + min) / 2;
1426
+ const span = max - min;
1427
+ // Saturation of the HSL model, and the two ends of the lightness scale
1428
+ // where hue stops meaning anything at all.
1429
+ const saturation = span === 0 ? 0 : span / (1 - Math.abs(2 * lightness - 1));
1430
+ if (saturation < GREY_SATURATION || lightness < 0.06 || lightness > 0.96) {
1431
+ return 'grey';
1432
+ }
1433
+ let hue;
1434
+ if (max === r)
1435
+ hue = ((g - b) / span) % 6;
1436
+ else if (max === g)
1437
+ hue = (b - r) / span + 2;
1438
+ else
1439
+ hue = (r - g) / span + 4;
1440
+ hue = (hue * 60 + 360) % 360;
1441
+ for (const [limit, tone] of TONE_HUES)
1442
+ if (hue < limit)
1443
+ return tone;
1444
+ return 'red';
1445
+ }
1446
+ for (const [word, tone] of TONE_WORDS)
1447
+ if (raw.includes(word))
1448
+ return tone;
1449
+ return undefined;
1450
+ }
1451
+ /**
1452
+ * The colour-bearing props the engine reads, duck-typed.
1453
+ *
1454
+ * Duck-typed for the same reason {@link elementPath} is: the engine must not
1455
+ * import a shape model, a text model or a connector model to answer a question
1456
+ * that is about pixels. An element that carries none of these has no colour to
1457
+ * be judged on and is skipped on the cheapest possible test.
1458
+ */
1459
+ const COLOUR_PROPS = ['fillColor', 'strokeColor', 'color'];
1460
+ /**
1461
+ * Every string a `Color` value can present. A theme PAIR (`{ light, dark }`)
1462
+ * yields both members: they are one decision seen under two themes, and the
1463
+ * caller treats them as alternatives rather than as two separate colours.
1464
+ */
1465
+ function colourStrings(value) {
1466
+ if (typeof value === 'string')
1467
+ return [value];
1468
+ if (value === null || typeof value !== 'object')
1469
+ return [];
1470
+ const out = [];
1471
+ for (const key of ['normal', 'light', 'dark']) {
1472
+ const member = value[key];
1473
+ if (typeof member === 'string')
1474
+ out.push(member);
1475
+ }
1476
+ return out;
1477
+ }
1478
+ /**
1479
+ * Whether this element is drawn in a tone the convention does not sanction.
1480
+ *
1481
+ * Per PROP, not per string: a `{ light, dark }` pair is ONE colour, so it
1482
+ * offends only when neither of its two members lands on a sanctioned tone. A
1483
+ * prop whose colour cannot be classified is skipped, and an element whose props
1484
+ * are all unclassifiable is never indicted.
1485
+ *
1486
+ * An unfilled shape's `fillColor` is deliberately ignored: it is stored, it is
1487
+ * not painted, and a rule about what the map LOOKS like has no business reading
1488
+ * a colour that is not on screen.
1489
+ */
1490
+ function departsFromTones(el, allowed) {
1491
+ const record = el;
1492
+ const filled = record['filled'];
1493
+ for (const prop of COLOUR_PROPS) {
1494
+ if (prop === 'fillColor' && filled === false)
1495
+ continue;
1496
+ let classified = false;
1497
+ let sanctioned = false;
1498
+ for (const raw of colourStrings(record[prop])) {
1499
+ const tone = toneOf(raw);
1500
+ if (tone === undefined)
1501
+ continue;
1502
+ classified = true;
1503
+ if (allowed.has(tone)) {
1504
+ sanctioned = true;
1505
+ break;
1506
+ }
1507
+ }
1508
+ if (classified && !sanctioned)
1509
+ return true;
1510
+ }
1511
+ return false;
1512
+ }
1513
+ /**
1514
+ * "Is this drawn in one of the tones the frame sanctions for it?" (PF13.8 / Q5)
1515
+ *
1516
+ * The sanctioned tones come from the FRAME's own palette, resolved through the
1517
+ * `@name` indirection every background label already uses — so the rule restates
1518
+ * no colour the background owns, and a host restyling the frame restyles the
1519
+ * convention with it. See {@link ToneConventionDef}.
1520
+ *
1521
+ * A rule whose named entries resolve to nothing classifiable warns once and
1522
+ * evaluates nothing: a convention with no reference colour is not a convention,
1523
+ * and indicting the whole board over a broken declaration is the one outcome
1524
+ * worse than silence.
1525
+ */
1526
+ function evaluateToneConvention(rule, elements) {
1527
+ const subjectRole = rule.appliesTo;
1528
+ const tone = rule.tone;
1529
+ const def = rule.background;
1530
+ if (subjectRole === undefined || tone === undefined || def === undefined) {
1531
+ return [];
1532
+ }
1533
+ const palette = def.chrome?.palette;
1534
+ const allowed = new Set();
1535
+ for (const name of tone.palette) {
1536
+ // Straight at the entry rather than through `backgroundColor('@name')`,
1537
+ // which answers a broken magenta for a name the palette does not carry —
1538
+ // and a magenta the rule never asked for would silently become a sanctioned
1539
+ // tone.
1540
+ const entry = palette?.[name];
1541
+ const classified = entry === undefined ? undefined : toneOf(entry);
1542
+ if (classified !== undefined)
1543
+ allowed.add(classified);
1544
+ }
1545
+ if (allowed.size === 0) {
1546
+ warnOnce(`tone-convention rule "${rule.id}" names no palette entry that resolves ` +
1547
+ `to a classifiable tone — it is not evaluated.`);
1548
+ return [];
1549
+ }
1550
+ const backgrounds = backgroundsOf(rule, elements);
1551
+ const violations = [];
1552
+ for (const el of elements) {
1553
+ if (el.role === undefined)
1554
+ continue;
1555
+ if (!roleIsA(el.role, subjectRole, rule.roles))
1556
+ continue;
1557
+ if (!departsFromTones(el, allowed))
1558
+ continue;
1559
+ violations.push(raise(rule, [el.id], attributeBackground(el.elementBound, backgrounds)?.id));
1560
+ }
1561
+ return violations;
1562
+ }
1563
+ /**
1564
+ * "Is most of what is on this map of one declared nature?" (PF13.8 / Q6)
1565
+ *
1566
+ * A remark about the MAP, so the finding names the BACKGROUND and nothing else:
1567
+ * no single element is at fault, and drawing a badge on one of two hundred
1568
+ * components would be an accusation aimed at a bystander.
1569
+ *
1570
+ * Counted PER MAP. A board carrying a map of activities beside a map of
1571
+ * practices holds two different answers, and a tally over the whole surface
1572
+ * would give both of them the wrong one.
1573
+ *
1574
+ * ## The gate
1575
+ *
1576
+ * A surface where NOT ONE subject carries the fact yields nothing, silently —
1577
+ * see {@link MajorityDef}. That is the state the Wardley `nature` is in today,
1578
+ * and the rule ships inert rather than waiting in a branch somewhere for
1579
+ * somebody to remember it.
1580
+ */
1581
+ function evaluateMajorityFact(rule, elements) {
1582
+ const subjectRole = rule.appliesTo;
1583
+ const majority = rule.majority;
1584
+ if (subjectRole === undefined || majority === undefined)
1585
+ return [];
1586
+ const backgrounds = backgroundsOf(rule, elements);
1587
+ // No frame on the board: no map for the remark to be about.
1588
+ if (backgrounds.length === 0)
1589
+ return [];
1590
+ const tally = new Map();
1591
+ for (const el of elements) {
1592
+ if (el.role === undefined)
1593
+ continue;
1594
+ if (!roleIsA(el.role, subjectRole, rule.roles))
1595
+ continue;
1596
+ const frame = attributeBackground(el.elementBound, backgrounds);
1597
+ if (frame === null)
1598
+ continue;
1599
+ let counts = tally.get(frame.id);
1600
+ if (counts === undefined) {
1601
+ counts = { subjects: 0, known: 0, matching: 0 };
1602
+ tally.set(frame.id, counts);
1603
+ }
1604
+ counts.subjects += 1;
1605
+ const fact = el[majority.fact];
1606
+ // Whatever a peer wrote, and whatever a framework has not written yet.
1607
+ if (typeof fact !== 'string' || fact === '')
1608
+ continue;
1609
+ counts.known += 1;
1610
+ if (fact === majority.value)
1611
+ counts.matching += 1;
1612
+ }
1613
+ const violations = [];
1614
+ // Sorted, so a board with two qualifying maps always reports them the same
1615
+ // way whichever order the surface happened to be walked in.
1616
+ for (const id of [...tally.keys()].sort()) {
1617
+ const counts = tally.get(id);
1618
+ // THE GATE: nothing here carries the fact, so there is no majority to be in.
1619
+ if (counts.known === 0)
1620
+ continue;
1621
+ // A strict majority of the SUBJECTS, not of the ones that happen to be
1622
+ // classified: half a map left unclassified is not a mandate.
1623
+ if (counts.matching * 2 <= counts.subjects)
1624
+ continue;
1625
+ violations.push(raise(rule, [id], id));
1626
+ }
1627
+ return violations;
1628
+ }
1629
+ /**
1630
+ * The two ids a typed edge points at, or `null` — the EXTRACTION alone, with no
1631
+ * opinion on what the pair means.
1632
+ *
1633
+ * Duck-typed exactly like {@link elementPath}, and for the same reason: the
1634
+ * engine knows roles and geometry, and must not import a connector model. An
1635
+ * end holding a bare `position` and no `id` is bound to nothing.
1636
+ *
1637
+ * `null` is the ADR's own guard, and it is not marginal — releasing the link
1638
+ * tool over empty canvas produces such an edge at any time, and a palette
1639
+ * sample of a stroke style is one by construction. An edge that relates nothing
1640
+ * is never evaluated.
1641
+ *
1642
+ * Whether a SELF-LOOP is one of the answers is a policy, and each family states
1643
+ * its own on top of this: see {@link boundEnds} and {@link endpointIds}.
1644
+ */
1645
+ function rawEndpointIds(el) {
1646
+ const edge = el;
1647
+ const source = edge.source?.id;
1648
+ const target = edge.target?.id;
1649
+ if (typeof source !== 'string' || typeof target !== 'string')
1650
+ return null;
1651
+ return [source, target];
1652
+ }
1653
+ /**
1654
+ * The two ids a typed edge BINDS, self-loops excluded.
1655
+ *
1656
+ * A self-loop states nothing about an ORDER: it is one element, compared with
1657
+ * itself, and no move can ever resolve the finding — so the families that read
1658
+ * a layout treat it exactly like an edge that relates nothing.
1659
+ */
1660
+ function boundEnds(el) {
1661
+ const ends = rawEndpointIds(el);
1662
+ if (ends === null || ends[0] === ends[1])
1663
+ return null;
1664
+ return ends;
1665
+ }
1666
+ /**
1667
+ * "Are these two in the order their relation states?" — W4 (`docs/adr/0010`).
1668
+ *
1669
+ * The first family whose subject is a RELATION. For every element carrying the
1670
+ * declared edge role, the two elements it binds are projected onto one axis of
1671
+ * the frame and compared in the order the edge states: for
1672
+ * `wardley:dependency`, the source is the consumer and must sit higher on the
1673
+ * visibility axis than what it needs.
1674
+ *
1675
+ * The order comes from the persisted `source → target` pair, never from the
1676
+ * geometry — that is the whole point, and the reason this rule can fire at all.
1677
+ * Every other input is declaration: which axis, which way it runs, how much
1678
+ * slack, all read off the frame the framework already declares.
1679
+ *
1680
+ * ## What it stays silent about, and why each one matters
1681
+ *
1682
+ * - **no frame on the board** — nothing declares an axis, so there is no order
1683
+ * to be in. A chain drawn on blank canvas is a sketch;
1684
+ * - **an edge with a free end** — it relates nothing (see {@link boundEnds});
1685
+ * - **an end whose element is gone** — a dangling id says nothing about a
1686
+ * layout;
1687
+ * - **a pair straddling TWO frames** — "higher than" is a question inside one
1688
+ * frame of reference, and two maps have two;
1689
+ * - **an edge carrying no role, or another role** — proportionality (PRD
1690
+ * principle 8). A change arrow is oriented too and is not a dependency.
1691
+ *
1692
+ * ## Cost
1693
+ *
1694
+ * Linear in the EDGES: one pass to index the elements by id, one pass over the
1695
+ * edges, and two constant-time frame attributions per edge. There is no graph
1696
+ * closure and no pair-wise sweep — the rule is about a relation somebody drew,
1697
+ * never about every couple of nodes that could have had one. A cycle A→B→A is
1698
+ * two edges and is judged as two edges: at least one of them is against the
1699
+ * order, and that one is reported.
1700
+ */
1701
+ function evaluateRelativeOrder(rule, elements) {
1702
+ const order = rule.relativeOrder;
1703
+ const def = rule.background;
1704
+ if (order === undefined || def === undefined)
1705
+ return [];
1706
+ const axis = backgroundAxisFact(def, order.axis);
1707
+ if (axis === undefined) {
1708
+ warnOnce(`relative-order rule "${rule.id}" measures along "${order.axis}", which ` +
1709
+ `its background does not declare — the rule is not evaluated.`);
1710
+ return [];
1711
+ }
1712
+ const backgrounds = backgroundsOf(rule, elements);
1713
+ // No frame, no frame of reference: a chain on blank canvas is a sketch.
1714
+ if (backgrounds.length === 0)
1715
+ return [];
1716
+ const byId = new Map();
1717
+ const edges = [];
1718
+ for (const el of elements) {
1719
+ byId.set(el.id, el);
1720
+ // Cheapest possible exit for a neutral element: no role, no evaluation.
1721
+ if (el.role === undefined)
1722
+ continue;
1723
+ if (roleIsA(el.role, order.edgeRole, rule.roles))
1724
+ edges.push(el);
1725
+ }
1726
+ if (edges.length === 0)
1727
+ return [];
1728
+ const horizontal = axis.orientation === 'horizontal';
1729
+ // How far along the axis' FORWARD sense a bound sits. One number, so the
1730
+ // comparison below reads the same whichever way the axis runs.
1731
+ const along = (bound) => {
1732
+ const centre = centreOf(bound);
1733
+ return centre[0] * axis.forward[0] + centre[1] * axis.forward[1];
1734
+ };
1735
+ const violations = [];
1736
+ for (const edge of edges) {
1737
+ const ends = boundEnds(edge);
1738
+ if (ends === null)
1739
+ continue;
1740
+ const [sourceId, targetId] = ends;
1741
+ const source = byId.get(sourceId);
1742
+ const target = byId.get(targetId);
1743
+ if (source === undefined || target === undefined)
1744
+ continue;
1745
+ const sourceBound = source.elementBound;
1746
+ const targetBound = target.elementBound;
1747
+ const frame = attributeBackground(sourceBound, backgrounds);
1748
+ // Two maps, two frames of reference: the question does not arise.
1749
+ if (frame === null)
1750
+ continue;
1751
+ if (attributeBackground(targetBound, backgrounds)?.id !== frame.id)
1752
+ continue;
1753
+ const ahead = along(sourceBound) - along(targetBound);
1754
+ const expected = order.expect === 'source-ahead' ? ahead : -ahead;
1755
+ const span = horizontal ? frame.bound.w : frame.bound.h;
1756
+ const tolerance = (order.toleranceRatio ?? 0) * span;
1757
+ if (expected >= -tolerance)
1758
+ continue;
1759
+ violations.push(raise(rule, [edge.id, sourceId, targetId].sort(), frame.id));
1760
+ }
1761
+ return violations;
1762
+ }
1763
+ /**
1764
+ * The two ids a typed edge points at, SELF-LOOP INCLUDED — the one difference
1765
+ * with {@link boundEnds}, and the reason this family reads the raw pair.
1766
+ *
1767
+ * `boundEnds` answers `null` for an edge whose two ends are the same element,
1768
+ * because an order rule genuinely has nothing to say about one: it would be
1769
+ * comparing an element with itself and no move could ever resolve the finding.
1770
+ * A GRAMMAR rule is the opposite case — a loop is a sentence about the same
1771
+ * artefact twice, which is exactly what {@link RelationEndpointsDef.forbidSelfLoop}
1772
+ * exists to arbitrate. Folding the loop into the null here would make that flag
1773
+ * unreachable, silently.
1774
+ */
1775
+ const endpointIds = rawEndpointIds;
1776
+ /**
1777
+ * The roles the sanctioned sentences speak of — the ALPHABET of the rule. See
1778
+ * {@link RelationEndpointsDef.allowed} for why an end outside it is silence.
1779
+ */
1780
+ function endpointAlphabet(allowed) {
1781
+ const roles = [];
1782
+ for (const triplet of allowed) {
1783
+ for (const role of [triplet.source, triplet.target]) {
1784
+ if (!roles.includes(role))
1785
+ roles.push(role);
1786
+ }
1787
+ }
1788
+ return roles;
1789
+ }
1790
+ /**
1791
+ * Whether `role` is one the rule's own framework DECLARES.
1792
+ *
1793
+ * The FALLBACK half of {@link RelationEndpointsDef.flagNeutral}'s vocabulary
1794
+ * test — used on its own only by a rule that declares no matrix, and composed
1795
+ * with the grammar's own alphabet when there is one (see the call site).
1796
+ *
1797
+ * A membership test on the vocabulary the rule already carries, and deliberately
1798
+ * not a prefix match on the `framework:` half of the id: the namespace is a
1799
+ * naming convention (`role.ts` types a role id as a bare string and says as
1800
+ * much), while the vocabulary is data the framework hands the engine. Reading
1801
+ * the data cannot be wrong about a framework that spells its own prefix
1802
+ * differently — `ddd-context-map` roles are `context-map:` — and it keeps this
1803
+ * file free of string surgery on ids it does not own.
1804
+ *
1805
+ * `roleIsA` against every declared role would give the same answer at a higher
1806
+ * price: a role outside the vocabulary has no parent chain to walk here, since
1807
+ * the walk reads `rule.roles`, so it can only ever match itself.
1808
+ *
1809
+ * `Object.hasOwn`, and never a bare index: `role` comes off a `Y.Map`, so it is
1810
+ * whatever a peer or an importer wrote, and an element persisted with
1811
+ * `role: 'toString'` would answer "declared" against a bag containing no such
1812
+ * entry. The same threat model {@link elementExceptions} guards with its
1813
+ * `Array.isArray`. Every other read of a role bag in this file goes through
1814
+ * `roleIsA`, which writes `defs[current]?.parent` and terminates harmlessly on
1815
+ * a prototype hit; this is the only place that tests for the PRESENCE of a key.
1816
+ */
1817
+ function declaresRole(rule, role) {
1818
+ return role !== undefined && Object.hasOwn(rule.roles, role);
1819
+ }
1820
+ /** The elements a finding names, deduplicated and sorted. */
1821
+ function namedElements(ids) {
1822
+ return Array.from(new Set(ids)).sort();
1823
+ }
1824
+ /**
1825
+ * "Does this relation say something the framework's grammar allows?"
1826
+ *
1827
+ * The second family whose subject is a RELATION, and the first that reads
1828
+ * nothing but MEANING: no axis, no coordinate, no distance. For every element
1829
+ * carrying the declared edge role it reads the persisted `source → target` pair
1830
+ * and the roles at the two ends, and confronts the sentence with the ones the
1831
+ * framework sanctions — an actor issues a command, a command lands on an
1832
+ * aggregate. A relation drawn upside-down on a Wardley map is a layout mistake;
1833
+ * a relation drawn between the wrong two artefacts is a modelling one, and no
1834
+ * amount of dragging fixes it.
1835
+ *
1836
+ * ## What it stays silent about, and why each one matters
1837
+ *
1838
+ * - **an edge carrying no role, or another role** — proportionality (PRD
1839
+ * principle 8): a free connector is not a claim. Unless the rule declares
1840
+ * {@link RelationEndpointsDef.flagNeutral}, which asks for exactly one kind of
1841
+ * role-less link back: one drawn between two artefacts of this framework's own
1842
+ * vocabulary, i.e. a relation the user drew and the model does not contain;
1843
+ * - **an edge with a free end** — it relates nothing;
1844
+ * - **an end whose element is gone** — a dangling id says nothing about a model;
1845
+ * - **an end OUTSIDE the alphabet of {@link RelationEndpointsDef.allowed}** —
1846
+ * the hard requirement of this family. A flow onto a hotspot, onto a neutral
1847
+ * shape, onto an artefact of another framework: the user is sketching, and a
1848
+ * grammar that indicted the sketch would be switched off within a day (see
1849
+ * {@link RelationEndpointsDef.allowed});
1850
+ * - **a self-loop, unless the framework asked** — see
1851
+ * {@link RelationEndpointsDef.forbidSelfLoop}. Silence here is TOTAL: a loop
1852
+ * the framework tolerates is not then judged against the matrix, where it
1853
+ * would fail for a reason nobody meant.
1854
+ *
1855
+ * ## One finding per edge, at most
1856
+ *
1857
+ * An edge already indicted for the sentence it says is not indicted again for
1858
+ * saying it twice: the duplicate and exclusivity passes only see relations that
1859
+ * got through the matrix. Two badges on one link, for one gesture to fix, is
1860
+ * pedantry the canvas cannot afford.
1861
+ *
1862
+ * ## The frame
1863
+ *
1864
+ * {@link ValidationRule.backgroundRole} is optional here and buys ATTRIBUTION
1865
+ * only — the map a finding is written against, so the map-wide arbitration has
1866
+ * somewhere to live (the `no-overlap` pattern). No background DECLARATION is
1867
+ * required: this family reads no fact off the frame, and demanding one would be
1868
+ * asking a framework to declare geometry its grammar never consults.
1869
+ *
1870
+ * ## Cost
1871
+ *
1872
+ * Linear in the elements plus linear in the EDGES: one indexing pass, one pass
1873
+ * over the edges, then one pass over the node pairs that actually carry more
1874
+ * than one relation — which on any real board is a handful. There is no
1875
+ * pair-wise sweep over the nodes: the rule is about relations somebody drew,
1876
+ * never about every couple that could have had one.
1877
+ *
1878
+ * An {@link IncrementalContext} is deliberately NOT honoured, exactly like
1879
+ * `relative-order-along-axis`: a dirty set names the elements that moved, and
1880
+ * this family's verdict does not depend on where anything is. Re-reading every
1881
+ * edge is the cost of the linear pass it already was.
1882
+ */
1883
+ function evaluateRelationEndpoints(rule, elements) {
1884
+ const endpoints = rule.endpoints;
1885
+ if (endpoints === undefined)
1886
+ return [];
1887
+ const byId = new Map();
1888
+ const edges = [];
1889
+ // Role-less connectors, kept only when the rule asked to hear about them —
1890
+ // and only the ones that are connectors at all, which the persisted pair is
1891
+ // the duck-typed proof of. A board is mostly neutral elements, and a rule
1892
+ // declaring no `flagNeutral` must not allocate one entry per rectangle.
1893
+ const neutral = [];
1894
+ for (const el of elements) {
1895
+ byId.set(el.id, el);
1896
+ // Cheapest possible exit for a neutral element: no role, no evaluation…
1897
+ if (el.role === undefined) {
1898
+ // …unless the rule is precisely about the links that carry none.
1899
+ if (endpoints.flagNeutral !== undefined && rawEndpointIds(el) !== null) {
1900
+ neutral.push(el);
1901
+ }
1902
+ continue;
1903
+ }
1904
+ if (roleIsA(el.role, endpoints.edgeRole, rule.roles)) {
1905
+ edges.push({ el, role: el.role });
1906
+ }
1907
+ }
1908
+ if (edges.length === 0 && neutral.length === 0)
1909
+ return [];
1910
+ const backgrounds = backgroundsOf(rule, elements);
1911
+ // An EMPTY matrix is a matrix that says nothing, which is the same claim as
1912
+ // declaring none — the reading `allowed`'s own header gives it — so it is
1913
+ // normalised to `undefined` here rather than left to mean an empty alphabet.
1914
+ // Left as one it would silence the rule WHOLE, self-loops and duplicates
1915
+ // included, since every end is outside an alphabet of no roles: exactly the
1916
+ // opposite of what a framework that lists no sentence and forbids loops
1917
+ // asked for. A matrix says nothing about the other three verdicts; it never
1918
+ // switches them off.
1919
+ const allowed = endpoints.allowed === undefined || endpoints.allowed.length === 0
1920
+ ? undefined
1921
+ : endpoints.allowed;
1922
+ const alphabet = allowed === undefined ? null : endpointAlphabet(allowed);
1923
+ // `roleIsA` walks a parent chain, and the two questions below ask it about
1924
+ // the same handful of roles once per edge on the board — a Context Mapping
1925
+ // board draws one vocabulary a hundred times over. Memoised for the duration
1926
+ // of THIS pass and no longer: the precedent is `evaluateNoOverlap`, which
1927
+ // precomputes each subject's slot memberships once instead of re-asking per
1928
+ // couple.
1929
+ const spoken = new Map();
1930
+ const speaks = (role) => {
1931
+ if (alphabet === null)
1932
+ return true;
1933
+ if (role === undefined)
1934
+ return false;
1935
+ const memo = spoken.get(role);
1936
+ if (memo !== undefined)
1937
+ return memo;
1938
+ const answer = alphabet.some(known => roleIsA(role, known, rule.roles));
1939
+ spoken.set(role, answer);
1940
+ return answer;
1941
+ };
1942
+ // Keyed by the whole triplet of roles, joined by NUL — which no vocabulary
1943
+ // contains — so no two sentences can ever fold into one answer.
1944
+ const sanctioned = new Map();
1945
+ const inMatrix = (edgeRole, sourceRole, targetRole) => {
1946
+ const key = `${edgeRole}\u0000${sourceRole}\u0000${targetRole}`;
1947
+ const memo = sanctioned.get(key);
1948
+ if (memo !== undefined)
1949
+ return memo;
1950
+ const answer = allowed.some(triplet => roleIsA(edgeRole, triplet.edge, rule.roles) &&
1951
+ roleIsA(sourceRole, triplet.source, rule.roles) &&
1952
+ roleIsA(targetRole, triplet.target, rule.roles));
1953
+ sanctioned.set(key, answer);
1954
+ return answer;
1955
+ };
1956
+ const exclusivePairs = endpoints.exclusivePairs ?? [];
1957
+ // Hoisted above the walk: the pair bookkeeping is a `DrawnRelation`, a sorted
1958
+ // id list and a map entry PER EDGE, and a rule that judges sentences only
1959
+ // would have allocated all three just to drop them. This runs on the gesture
1960
+ // path, inside the 16 ms the drawing has.
1961
+ const needsPairs = endpoints.forbidDuplicate === true || exclusivePairs.length > 0;
1962
+ const violations = [];
1963
+ // Every relation drawn between one UNORDERED pair of elements, which is the
1964
+ // grain both the duplicate and the exclusivity questions are asked at. Filled
1965
+ // in the same pass that judges the sentences, walked once afterwards.
1966
+ const between = new Map();
1967
+ for (const { el: edge, role } of edges) {
1968
+ const ends = endpointIds(edge);
1969
+ if (ends === null)
1970
+ continue;
1971
+ const [sourceId, targetId] = ends;
1972
+ const source = byId.get(sourceId);
1973
+ const target = byId.get(targetId);
1974
+ if (source === undefined || target === undefined)
1975
+ continue;
1976
+ // Outside the grammar entirely: the user is sketching.
1977
+ if (!speaks(source.role) || !speaks(target.role))
1978
+ continue;
1979
+ // A node, not the edge's own box: the box of a diagonal link spans both
1980
+ // ends and would attribute a relation to whichever map its corner grazes.
1981
+ const frameId = attributeBackground(source.elementBound, backgrounds)?.id;
1982
+ if (sourceId === targetId) {
1983
+ if (endpoints.forbidSelfLoop) {
1984
+ violations.push(raise(rule, namedElements([edge.id, sourceId, targetId]), frameId, endpoints.selfLoop ?? rule));
1985
+ }
1986
+ continue;
1987
+ }
1988
+ if (allowed !== undefined && !inMatrix(role, source.role, target.role)) {
1989
+ violations.push(raise(rule, namedElements([edge.id, sourceId, targetId]), frameId, endpoints.offMatrix ?? rule));
1990
+ continue;
1991
+ }
1992
+ if (!needsPairs)
1993
+ continue;
1994
+ const key = sourceId < targetId
1995
+ ? `${sourceId} ${targetId}`
1996
+ : `${targetId} ${sourceId}`;
1997
+ const drawn = between.get(key);
1998
+ const relation = {
1999
+ id: edge.id,
2000
+ role,
2001
+ sourceId,
2002
+ targetId,
2003
+ ids: namedElements([edge.id, sourceId, targetId]),
2004
+ ...(frameId !== undefined ? { frameId } : {}),
2005
+ };
2006
+ if (drawn === undefined)
2007
+ between.set(key, [relation]);
2008
+ else
2009
+ drawn.push(relation);
2010
+ }
2011
+ // Which roles a role-less link may be flagged BETWEEN.
2012
+ //
2013
+ // The rule's own ALPHABET when it declares a matrix — the roles its grammar
2014
+ // actually has endpoints for, which is the tightest honest reading and gets
2015
+ // `roleIsA` inheritance and the memo above for free. It also excludes, by
2016
+ // construction and with nothing for an author to remember, the roles a
2017
+ // framework declares for things that are not endpoints of anything: a
2018
+ // background role, a legend glyph, a lane. Presuming somebody meant to draw a
2019
+ // typed relation onto a POOL is exactly the over-firing this test exists to
2020
+ // prevent.
2021
+ //
2022
+ // `speaks` cannot do the job alone, and the reason is worth stating: with no
2023
+ // matrix it answers `true` for everything, `undefined` included, because
2024
+ // "outside the alphabet" has no meaning when there is no alphabet. A
2025
+ // matrix-less rule therefore falls back to membership in the vocabulary
2026
+ // ({@link declaresRole}), which stays purely the dictionary `roleIsA` walks —
2027
+ // the bag is NOT overloaded into a filter, so an author never has to shrink
2028
+ // their inheritance dictionary in order to change what gets flagged.
2029
+ const inVocabulary = (role) => alphabet === null ? declaresRole(rule, role) : speaks(role);
2030
+ // The NEUTRAL pass: a link carrying no role at all, drawn between two
2031
+ // artefacts this rule's grammar could have related. See
2032
+ // {@link RelationEndpointsDef.flagNeutral} — the gesture that produces one is
2033
+ // quick-connect, which stamps nothing, so the user has drawn a relation the
2034
+ // model does not contain.
2035
+ //
2036
+ // The edge is judged on WHAT it joins and never against the matrix: the matrix
2037
+ // says which sentences are legal, and this edge says no sentence at all. A
2038
+ // self-loop is skipped for the same reason `boundEnds` skips one everywhere
2039
+ // else — a link from an artefact to itself is not evidence that a typed
2040
+ // relation was meant.
2041
+ for (const el of neutral) {
2042
+ const ends = boundEnds(el);
2043
+ if (ends === null)
2044
+ continue;
2045
+ const [sourceId, targetId] = ends;
2046
+ const source = byId.get(sourceId);
2047
+ const target = byId.get(targetId);
2048
+ if (source === undefined || target === undefined)
2049
+ continue;
2050
+ if (!inVocabulary(source.role) || !inVocabulary(target.role))
2051
+ continue;
2052
+ violations.push(raise(rule, namedElements([el.id, sourceId, targetId]), attributeBackground(source.elementBound, backgrounds)?.id, endpoints.flagNeutral));
2053
+ }
2054
+ if (!needsPairs)
2055
+ return violations;
2056
+ // Sorted, so a board holding several offending pairs always reports them the
2057
+ // same way whichever order the surface happened to be walked in.
2058
+ for (const key of [...between.keys()].sort()) {
2059
+ const drawn = between.get(key);
2060
+ if (drawn.length < 2)
2061
+ continue;
2062
+ // By edge id, so the relation that keeps its innocence is the same one on
2063
+ // every peer and after every reload — never the one a Y.Map rebuild
2064
+ // happened to yield first.
2065
+ const relations = [...drawn].sort((a, b) => (a.id < b.id ? -1 : 1));
2066
+ if (endpoints.forbidDuplicate) {
2067
+ const seen = new Set();
2068
+ for (const relation of relations) {
2069
+ // Same role AND same ordered pair: A → B and B → A are two different
2070
+ // sentences, and a framework that wants them exclusive says so in
2071
+ // `exclusivePairs`.
2072
+ const sentence = `${relation.role} ${relation.sourceId} ${relation.targetId}`;
2073
+ // The FIRST one is not a duplicate of anything; every copy after it is.
2074
+ if (seen.has(sentence)) {
2075
+ violations.push(raise(rule, relation.ids, relation.frameId, endpoints.duplicate ?? rule));
2076
+ }
2077
+ seen.add(sentence);
2078
+ }
2079
+ }
2080
+ // Two declared pairs can both cover the same two relations (a pair written
2081
+ // on a parent role and one on its specialisation): one situation, one
2082
+ // finding.
2083
+ const reported = new Set();
2084
+ for (const [first, second] of exclusivePairs) {
2085
+ for (let i = 0; i < relations.length; i++) {
2086
+ for (let j = i + 1; j < relations.length; j++) {
2087
+ const a = relations[i];
2088
+ const b = relations[j];
2089
+ const matched = (roleIsA(a.role, first, rule.roles) &&
2090
+ roleIsA(b.role, second, rule.roles)) ||
2091
+ (roleIsA(a.role, second, rule.roles) &&
2092
+ roleIsA(b.role, first, rule.roles));
2093
+ if (!matched)
2094
+ continue;
2095
+ const couple = `${a.id} ${b.id}`;
2096
+ if (reported.has(couple))
2097
+ continue;
2098
+ reported.add(couple);
2099
+ // BOTH relations and both ends: either link is a legitimate
2100
+ // resolution, so neither may be left out of the finding, and the pair
2101
+ // of artefacts is what makes the two of them one situation.
2102
+ //
2103
+ // The frame is the SMALLER of the two the relations were attributed
2104
+ // to — the house tie-break, and it has to be one: each relation is
2105
+ // attributed by its OWN source node, so two links drawn from opposite
2106
+ // ends can name two frames, and taking the first one's would write a
2107
+ // map-wide arbitration wherever the surface happened to be walked.
2108
+ const frameId = b.frameId !== undefined &&
2109
+ (a.frameId === undefined || b.frameId < a.frameId)
2110
+ ? b.frameId
2111
+ : a.frameId;
2112
+ violations.push(raise(rule, namedElements([a.id, b.id, a.sourceId, a.targetId]), frameId, endpoints.exclusive ?? rule));
2113
+ }
2114
+ }
2115
+ }
2116
+ }
2117
+ return violations;
2118
+ }
2119
+ /**
2120
+ * The cited zones of one instance, as model-space rectangles.
2121
+ *
2122
+ * Ratios are of the PLOT — the element box minus the declared margin — so this
2123
+ * is the same resolution `backgroundBoundaryCoords` does for transitions, and
2124
+ * the reason a rule never restates a coordinate the declaration already owns.
2125
+ * In the rule's OWN citation order, so `boundaryId` names the first zone the
2126
+ * rule itself mentions rather than the first one the declaration happens to
2127
+ * list.
2128
+ *
2129
+ * ## Resolved against the INSTANCE, variants included
2130
+ *
2131
+ * A variant is a second reading of the same frame, and a zone that belongs to
2132
+ * the other reading is not drawn on this instance at all: it is not a region of
2133
+ * it either, and measuring against a quadrant nobody can see would indict an
2134
+ * artefact for sitting where the chart shows nothing. Same gate the renderer and
2135
+ * the label walk pass through, read off the same props.
2136
+ *
2137
+ * Silence, not {@link warnOnce}: a rule citing a zone the declaration does not
2138
+ * declare is a broken RULE and says so, but a rule citing a zone this INSTANCE
2139
+ * does not read is a perfectly good rule looking at a frame turned to its other
2140
+ * page.
2141
+ */
2142
+ function zoneRegions(def, zoneIds, frame) {
2143
+ const bound = frame.bound;
2144
+ const plot = backgroundPlot(def, bound.w, bound.h);
2145
+ const regions = [];
2146
+ for (const id of zoneIds) {
2147
+ const zone = (def.zones ?? []).find(candidate => candidate.id === id);
2148
+ if (zone === undefined)
2149
+ continue;
2150
+ if (!backgroundInVariant(def, zone.variants, frame.props))
2151
+ continue;
2152
+ regions.push({
2153
+ id,
2154
+ bound: new Bound(bound.x + plot.x0 + zone.rect.x * plot.width, bound.y + plot.y0 + zone.rect.y * plot.height, zone.rect.w * plot.width, zone.rect.h * plot.height),
2155
+ });
2156
+ }
2157
+ return regions;
2158
+ }
2159
+ /**
2160
+ * The instance a subject is judged against: the one that CONTAINS it.
2161
+ *
2162
+ * Not `attributeBackground`, and the difference is the point: "which zone is
2163
+ * this in" only means something for an artefact that is ON the map. An element
2164
+ * next to the card is outside every zone of it, which under `expect: 'inside'`
2165
+ * would make it a finding of THIS rule — a second, worse-worded version of
2166
+ * `element-in-background`, raised on artefacts the framework may perfectly well
2167
+ * allow off the frame. Off every frame is silence here, and whether that is
2168
+ * itself a mistake is a different rule's question.
2169
+ *
2170
+ * Ties go to the smaller id, never to the order a `Y.Map` was rebuilt in: the
2171
+ * answer decides where a persisted arbitration is written.
2172
+ */
2173
+ function containingBackground(bound, backgrounds) {
2174
+ let found = null;
2175
+ for (const background of backgrounds) {
2176
+ if (!background.bound.contains(bound))
2177
+ continue;
2178
+ if (found === null || background.id < found.id)
2179
+ found = background;
2180
+ }
2181
+ return found;
2182
+ }
2183
+ /**
2184
+ * "Is this artefact in the region it belongs in?" — the zone half of
2185
+ * `element-in-background`.
2186
+ *
2187
+ * The zones are the frame's own declared ones, named by id: a rule says
2188
+ * `zoneIds: ['core'], expect: 'outside'` and the geometry comes from the
2189
+ * declaration the renderer paints from. Nothing is restated, so a framework
2190
+ * that redraws its quadrants moves its rules with them.
2191
+ *
2192
+ * ## Not gated on the tint
2193
+ *
2194
+ * A zone is SEMANTIC; its `fillVisibleProp` only decides whether it is
2195
+ * coloured in. A user who switched the tints off to print a clean chart has not
2196
+ * told the tool that the Core quadrant stopped being the Core quadrant — the
2197
+ * labels, the axes and the whole point of the chart are still there.
2198
+ *
2199
+ * A deliberate contrast with `attachment`, which DOES read the instance's props
2200
+ * before judging a transition, and the two are not in tension: a transition is a
2201
+ * LINE, and a line the user has hidden is a line they cannot aim at, so the
2202
+ * gesture the finding asks for would point at nothing. A quadrant is a REGION of
2203
+ * a frame of reference that is still fully on screen. Judge the drawing that is
2204
+ * on the canvas — and a hidden tint does not take a quadrant off it.
2205
+ */
2206
+ function evaluateElementInZone(rule, elements) {
2207
+ const subjectRole = rule.appliesTo;
2208
+ const inZone = rule.inZone;
2209
+ const def = rule.background;
2210
+ if (subjectRole === undefined || inZone === undefined || def === undefined) {
2211
+ return [];
2212
+ }
2213
+ if (inZone.zoneIds.length === 0)
2214
+ return [];
2215
+ const declared = new Set((def.zones ?? []).map(zone => zone.id));
2216
+ const unknown = inZone.zoneIds.filter(id => !declared.has(id));
2217
+ if (unknown.length > 0) {
2218
+ // Measuring against the zones it happened to recognise would be a DIFFERENT
2219
+ // rule — a narrower union, a shorter list of forbidden ground — and nothing
2220
+ // on the canvas would show the difference.
2221
+ warnOnce(`element-in-zone rule "${rule.id}" names zone(s) its background does ` +
2222
+ `not declare (${unknown.join(', ')}) — the rule is not evaluated.`);
2223
+ return [];
2224
+ }
2225
+ const backgrounds = backgroundsOf(rule, elements);
2226
+ // No frame on the board, no zones to be in: an artefact on blank canvas is a
2227
+ // sketch.
2228
+ if (backgrounds.length === 0)
2229
+ return [];
2230
+ // Zones are a function of the frame's bounds AND of the reading it is turned
2231
+ // to, and a board carries units of frames against hundreds of subjects:
2232
+ // resolved once per frame, per variant. Both halves are in the key, so the
2233
+ // entry can never outlive the reading it was built for — an id alone would be
2234
+ // right only for as long as nobody moved this map out of one evaluation.
2235
+ const regionsOf = new Map();
2236
+ const extent = inZone.measure === 'extent';
2237
+ const violations = [];
2238
+ for (const el of elements) {
2239
+ if (el.role === undefined)
2240
+ continue;
2241
+ if (!roleIsA(el.role, subjectRole, rule.roles))
2242
+ continue;
2243
+ const bound = el.elementBound;
2244
+ const frame = containingBackground(bound, backgrounds);
2245
+ if (frame === null)
2246
+ continue;
2247
+ // Keyed by the frame AND the reading it is turned to, joined by NUL — which
2248
+ // no id and no variant name contains — so the two halves can never fold
2249
+ // into one entry.
2250
+ const variant = def.variantProp === undefined ? '' : String(frame.props[def.variantProp]);
2251
+ const key = `${frame.id}\u0000${variant}`;
2252
+ let regions = regionsOf.get(key);
2253
+ if (regions === undefined) {
2254
+ regions = zoneRegions(def, inZone.zoneIds, frame);
2255
+ regionsOf.set(key, regions);
2256
+ }
2257
+ // Every cited zone belongs to the frame's OTHER reading: this instance has
2258
+ // no such region, so there is nothing to be inside or outside of. Under
2259
+ // `expect: 'inside'` the alternative would be indicting every subject on a
2260
+ // frame that simply shows another page.
2261
+ if (regions.length === 0)
2262
+ continue;
2263
+ const centre = centreOf(bound);
2264
+ // `inside` asks for the subject to be WHOLLY in one zone; `outside` for it
2265
+ // to share no area with any. The border belongs to the zone in both
2266
+ // readings, so two neighbours never disagree about an artefact on their
2267
+ // frontier.
2268
+ const occupies = (region) => extent
2269
+ ? region.bound.contains(bound)
2270
+ : region.bound.containsPoint(centre);
2271
+ const touches = (region) => extent
2272
+ ? boundsOverlap(region.bound, bound)
2273
+ : region.bound.containsPoint(centre);
2274
+ if (inZone.expect === 'inside') {
2275
+ if (regions.some(occupies))
2276
+ continue;
2277
+ // The zone the subject missed: the nearest one, which is the one the user
2278
+ // was aiming at and the only one a suggestion can point back to.
2279
+ let nearest;
2280
+ let distance = Infinity;
2281
+ for (const region of regions) {
2282
+ const gap = gapSquared(region.bound, bound);
2283
+ if (gap < distance) {
2284
+ distance = gap;
2285
+ nearest = region;
2286
+ }
2287
+ }
2288
+ const violation = raise(rule, [el.id], frame.id);
2289
+ if (nearest !== undefined)
2290
+ violation.boundaryId = nearest.id;
2291
+ violations.push(violation);
2292
+ continue;
2293
+ }
2294
+ const trespassed = regions.find(touches);
2295
+ if (trespassed === undefined)
2296
+ continue;
2297
+ const violation = raise(rule, [el.id], frame.id);
2298
+ violation.boundaryId = trespassed.id;
2299
+ violations.push(violation);
2300
+ }
2301
+ return violations;
2302
+ }
2303
+ /**
2304
+ * "Does this artefact belong on THIS view?"
2305
+ *
2306
+ * The first family whose subject is the SHEET. Every other one starts from an
2307
+ * artefact — a node, a relation, a pair — and asks something about where it is
2308
+ * or what touches it. This one starts from a frame that has DECLARED what kind
2309
+ * of view it is, and judges what has been drawn on it against that declaration.
2310
+ *
2311
+ * The level is read off the instance, from the prop the rule names
2312
+ * ({@link ViewAdmissibilityDef.levelProp}) — the very idiom the background
2313
+ * primitives already use for labels and variants — so the engine learns which
2314
+ * views exist from the document rather than from a registry of its own.
2315
+ *
2316
+ * ## What it stays silent about, and why each one matters
2317
+ *
2318
+ * - **a frame that declares NO level** — the overwhelming majority, and every
2319
+ * frame in every document written before the prop existed. No fact, no rule
2320
+ * (PRD principle 8): a sheet whose author never said which view it is has
2321
+ * made no claim for anything to contradict, and a family that guessed would
2322
+ * light up every diagram already on disk.
2323
+ * - **a level the rule's table does not name**, or names with an empty list —
2324
+ * the rule has nothing to say about that view. That is what lets a framework
2325
+ * ship the levels that forbid something and stay quiet on the one that
2326
+ * forbids nothing, instead of a rule that can never fire.
2327
+ * - **an element carrying no role** — a rectangle somebody dropped on the sheet
2328
+ * to think with belongs to no notation, so no view can refuse it.
2329
+ * - **an element off every declared view** — it is on the canvas beside the
2330
+ * sheet, not on it, and a view judges what is drawn ON it.
2331
+ * - **the frame itself**, and any other frame of the same role: a view is not
2332
+ * an artefact drawn on itself.
2333
+ *
2334
+ * ## Which view judges an element
2335
+ *
2336
+ * The one whose plot contains its CENTRE ({@link plotContaining}) — membership,
2337
+ * with the same tie-break every attribution in this file uses, and never the
2338
+ * nearest-frame fallback: "this artefact is on that view" has to mean it, or a
2339
+ * box floating beside a context diagram would be indicted by it.
2340
+ *
2341
+ * A subject on two nested views is judged by ONE of them, the smaller id, which
2342
+ * is the same answer `role-count` and `edge-locality` give. Two views drawn on
2343
+ * top of each other is not a case C4 has, and inventing a second verdict for it
2344
+ * would mean inventing which of the two the author meant.
2345
+ *
2346
+ * ## Cost, and why it is realtime-safe
2347
+ *
2348
+ * One pass to collect the views (units per board), one plot resolved per view,
2349
+ * then ONE pass over the elements with one containment walk and at most
2350
+ * `forbidden.length` role tests each. Linear in the surface, no graph, no pair
2351
+ * walk, nothing quadratic — the same budget `element-in-background` and
2352
+ * `role-count` are measured on.
2353
+ */
2354
+ function evaluateViewAdmissibility(rule, elements) {
2355
+ const def = rule.admissibility;
2356
+ if (def === undefined)
2357
+ return [];
2358
+ if (missingFrameRole(rule, 'view-admissibility'))
2359
+ return [];
2360
+ // The views that have both DECLARED a level and given this rule something to
2361
+ // say about it. Resolved once, per frame — the plot arithmetic and the table
2362
+ // lookup must not be paid per subject.
2363
+ const views = [];
2364
+ const forbiddenOf = new Map();
2365
+ for (const frame of framePlots(rule, elements)) {
2366
+ const stated = frame.instance.props[def.levelProp];
2367
+ // A level is a non-empty string or it is nothing. The value comes off a
2368
+ // Y.Map, so it is whatever a peer, an importer or an older client wrote.
2369
+ if (typeof stated !== 'string' || stated === '')
2370
+ continue;
2371
+ // Own keys only: `forbidden` is an object literal shipped by a framework.
2372
+ if (!Object.hasOwn(def.forbidden, stated))
2373
+ continue;
2374
+ const forbidden = def.forbidden[stated];
2375
+ if (forbidden === undefined || forbidden.length === 0)
2376
+ continue;
2377
+ views.push(frame);
2378
+ forbiddenOf.set(frame.instance.id, forbidden);
2379
+ }
2380
+ // No view on the board has stated a level this rule judges: not one element
2381
+ // is walked.
2382
+ if (views.length === 0)
2383
+ return [];
2384
+ const violations = [];
2385
+ for (const el of elements) {
2386
+ // Cheapest possible exit for a neutral element: no role, no evaluation.
2387
+ const role = el.role;
2388
+ if (role === undefined)
2389
+ continue;
2390
+ // A view is never an artefact drawn on ITSELF — its own centre is inside
2391
+ // its own plot, so without this a rule forbidding the frame's own role
2392
+ // would indict every frame that declared a level.
2393
+ if (forbiddenOf.has(el.id))
2394
+ continue;
2395
+ const view = plotContaining(centreOf(el.elementBound), views);
2396
+ if (view === null)
2397
+ continue;
2398
+ const forbidden = forbiddenOf.get(view.id);
2399
+ if (forbidden === undefined)
2400
+ continue;
2401
+ // One finding per element, whichever forbidden role matched: the author has
2402
+ // drawn one box on the wrong sheet, and telling them twice about it would
2403
+ // be describing our table rather than their diagram.
2404
+ if (forbidden.some(entry => roleIsA(role, entry, rule.roles))) {
2405
+ violations.push(raise(rule, [el.id], view.id));
2406
+ }
2407
+ }
2408
+ return violations;
2409
+ }
2410
+ /**
2411
+ * "How many relations reach this node?" — the cardinality half of a grammar.
2412
+ *
2413
+ * One pass to count, one pass to judge. For every element carrying the declared
2414
+ * edge role, the persisted `source → target` pair adds one OUT to its source and
2415
+ * one IN to its target; then every subject node is confronted with the bounds
2416
+ * its rule declares.
2417
+ *
2418
+ * ## What it stays silent about, and why each one matters
2419
+ *
2420
+ * - **an edge with a free end** — it relates nothing, so it is not evidence of a
2421
+ * node being connected. A link the user has grabbed and not yet dropped must
2422
+ * not make a start event legal for the length of the gesture;
2423
+ * - **an end that is not a subject of this rule** — nothing to say about it;
2424
+ * - **a rule declaring no bound at all** — it asks nothing. It warns once, since
2425
+ * data that can never fire and never says why is the worst thing declarative
2426
+ * data can do.
2427
+ *
2428
+ * ## An end whose element is GONE, and why this family keeps the other half
2429
+ *
2430
+ * A dangling id counts against nobody, but the edge is not dropped: the node at
2431
+ * the surviving end really does have one relation leaving it, and no gesture the
2432
+ * user makes on THAT node changes the fact. This family counts the ends, never
2433
+ * the survivors.
2434
+ *
2435
+ * A deliberate divergence from the three families whose subject is the RELATION
2436
+ * — `relative-order-along-axis`, `relation-endpoints`, `edge-locality` — which
2437
+ * drop such an edge whole, because a sentence with a missing half is not a
2438
+ * sentence anybody can be told about. A COUNT has no such problem, and neither
2439
+ * does the traversal in {@link evaluateReachability}, which follows the arc to
2440
+ * an element that is not there and simply never reaches anything by it. Two
2441
+ * different questions, two different readings of the same broken pair.
2442
+ *
2443
+ * A SELF-LOOP counts on both sides: the node genuinely has one edge leaving and
2444
+ * one arriving, and that is what the document says. Whether a loop is legitimate
2445
+ * at all is `relation-endpoints`' question, asked with its own flag, and a
2446
+ * count that quietly ignored loops would let one launder a node past a `maxOut`.
2447
+ *
2448
+ * ## One finding per node
2449
+ *
2450
+ * The bounds are tested in a fixed order — in, then out; fewest, then most; and
2451
+ * {@link EdgeDegreeDef.eitherMin} LAST — and the first one that fails is the
2452
+ * finding. Five badges on one symbol for one situation is pedantry the canvas
2453
+ * cannot afford; the same argument `relation-endpoints` makes about not
2454
+ * indicting an edge twice.
2455
+ *
2456
+ * The disjunction goes last on purpose. A per-direction bound names the side the
2457
+ * user has to act on, and "nothing arrives here" is a more useful sentence than
2458
+ * "neither side has enough"; the disjunction only speaks when the four
2459
+ * conjunctive bounds have nothing to say. It is orthogonal to them, not a
2460
+ * substitute: a rule may cap one side and still ask for two on either.
2461
+ *
2462
+ * ## Cost
2463
+ *
2464
+ * Linear in the elements plus linear in the EDGES, with one hash lookup per end.
2465
+ * No pair-wise sweep and no closure: the rule is about the relations somebody
2466
+ * drew, never about the ones they could have drawn.
2467
+ */
2468
+ function evaluateEdgeDegree(rule, elements) {
2469
+ const subjectRole = rule.appliesTo;
2470
+ const degree = rule.degree;
2471
+ if (subjectRole === undefined || degree === undefined)
2472
+ return [];
2473
+ const { minIn, maxIn, minOut, maxOut, eitherMin } = degree;
2474
+ // A pattern satisfied by every node — one supplying no bound at all — would
2475
+ // indict the whole board, which is the loudest possible way for a typo in the
2476
+ // data to be wrong. Dropped rather than honoured, and said once.
2477
+ const pattern = degree.forbidPattern;
2478
+ const patternBounds = pattern === undefined
2479
+ ? null
2480
+ : [
2481
+ ['in', '>=', pattern.minIn],
2482
+ ['in', '<=', pattern.maxIn],
2483
+ ['out', '>=', pattern.minOut],
2484
+ ['out', '<=', pattern.maxOut],
2485
+ ].filter(([, , value]) => value !== undefined);
2486
+ if (patternBounds !== null && patternBounds.length === 0) {
2487
+ warnOnce(`edge-degree rule "${rule.id}" declares a forbidden pattern with no ` +
2488
+ `bound in it, which every node matches — the pattern is dropped.`);
2489
+ }
2490
+ const forbidden = patternBounds !== null && patternBounds.length > 0 ? patternBounds : null;
2491
+ if (minIn === undefined &&
2492
+ maxIn === undefined &&
2493
+ minOut === undefined &&
2494
+ maxOut === undefined &&
2495
+ eitherMin === undefined &&
2496
+ forbidden === null) {
2497
+ warnOnce(`edge-degree rule "${rule.id}" declares no bound at all — the rule is ` +
2498
+ `not evaluated.`);
2499
+ return [];
2500
+ }
2501
+ // In the surface's own walk order, so a board reports its offending nodes the
2502
+ // same way every other element-local family does.
2503
+ const subjects = [];
2504
+ const bySubjectId = new Map();
2505
+ const edges = [];
2506
+ for (const el of elements) {
2507
+ // Cheapest possible exit for a neutral element: no role, no evaluation.
2508
+ if (el.role === undefined)
2509
+ continue;
2510
+ if (roleIsA(el.role, subjectRole, rule.roles)) {
2511
+ const subject = { el, in: 0, out: 0 };
2512
+ subjects.push(subject);
2513
+ bySubjectId.set(el.id, subject);
2514
+ }
2515
+ if (roleIsA(el.role, degree.edgeRole, rule.roles))
2516
+ edges.push(el);
2517
+ }
2518
+ if (subjects.length === 0)
2519
+ return [];
2520
+ for (const edge of edges) {
2521
+ const ends = rawEndpointIds(edge);
2522
+ if (ends === null)
2523
+ continue;
2524
+ const source = bySubjectId.get(ends[0]);
2525
+ if (source !== undefined)
2526
+ source.out += 1;
2527
+ const target = bySubjectId.get(ends[1]);
2528
+ if (target !== undefined)
2529
+ target.in += 1;
2530
+ }
2531
+ // Only resolved when something is actually wrong: a conformant board is the
2532
+ // common case and must not pay for the frames it never names.
2533
+ let backgrounds = null;
2534
+ const violations = [];
2535
+ for (const subject of subjects) {
2536
+ const words = minIn !== undefined && subject.in < minIn
2537
+ ? (degree.tooFewIn ?? rule)
2538
+ : maxIn !== undefined && subject.in > maxIn
2539
+ ? (degree.tooManyIn ?? rule)
2540
+ : minOut !== undefined && subject.out < minOut
2541
+ ? (degree.tooFewOut ?? rule)
2542
+ : maxOut !== undefined && subject.out > maxOut
2543
+ ? (degree.tooManyOut ?? rule)
2544
+ : // LAST, and deliberately: a per-direction bound names the side
2545
+ // the user has to act on, which is a more useful sentence than
2546
+ // "neither side". The disjunction only speaks when the four
2547
+ // above have nothing to say.
2548
+ eitherMin !== undefined &&
2549
+ subject.in < eitherMin &&
2550
+ subject.out < eitherMin
2551
+ ? (degree.neither ?? rule)
2552
+ : // LAST of all, for the same reason: a bound that failed names
2553
+ // what is missing, and a forbidden zone names a shape. When
2554
+ // both have something to say, the missing thing is the more
2555
+ // actionable half.
2556
+ forbidden !== null &&
2557
+ forbidden.every(([side, test, value]) => test === '>='
2558
+ ? subject[side] >= value
2559
+ : subject[side] <= value)
2560
+ ? (pattern ?? rule)
2561
+ : null;
2562
+ if (words === null)
2563
+ continue;
2564
+ backgrounds ??= backgroundsOf(rule, elements);
2565
+ const frameId = attributeBackground(subject.el.elementBound, backgrounds)?.id;
2566
+ violations.push(raise(rule, [subject.el.id], frameId, words));
2567
+ }
2568
+ return violations;
2569
+ }
2570
+ /**
2571
+ * "How many of these does one frame hold?" — existence and uniqueness.
2572
+ *
2573
+ * The tally is per INSTANCE and anchored ON it, exactly like
2574
+ * {@link evaluateMajorityFact}: a board carrying three pools holds three
2575
+ * independent answers, the bracket lands on the pool the answer is about, and
2576
+ * the `map` exemption scope therefore covers that pool and no other.
2577
+ *
2578
+ * ## Containment, and only containment
2579
+ *
2580
+ * A subject belongs to the frame whose plot contains its CENTRE
2581
+ * ({@link plotContaining}) — deliberately not `attributeBackground`'s
2582
+ * nearest-fallback, which would let a start event floating beside a pool satisfy
2583
+ * "this pool has a start event". A subject inside no frame at all is counted by
2584
+ * nobody, and whether that is itself a mistake is `element-in-background`'s
2585
+ * question.
2586
+ *
2587
+ * ## Silence
2588
+ *
2589
+ * - **no frame on the board** — nothing to count into. An artefact on blank
2590
+ * canvas is a sketch, and a process sketched before its pool was drawn must
2591
+ * not light up;
2592
+ * - **a rule declaring neither bound** — it asks nothing, and warns once;
2593
+ * - **a frame the GUARD has not armed** — see {@link RoleCountDef.ifPresent}.
2594
+ * Not judged, rather than judged and found compliant: a conditional
2595
+ * requirement says nothing at all about the case its condition excludes.
2596
+ *
2597
+ * An EMPTY frame is not silence: a pool holding no start event is precisely what
2598
+ * `min` is for, so every instance gets an entry before the walk, not when a
2599
+ * subject happens to land on it. The guard is what turns that back into silence
2600
+ * for a notation where the artefact is optional on its own.
2601
+ *
2602
+ * ## Cost
2603
+ *
2604
+ * One pass over the elements, one plot per frame, one containment test per
2605
+ * subject per frame — the frames are counted in units, so this is constant per
2606
+ * element in practice, like every other attribution in this file.
2607
+ */
2608
+ function evaluateRoleCount(rule, elements) {
2609
+ const count = rule.roleCount;
2610
+ if (count === undefined)
2611
+ return [];
2612
+ if (missingFrameRole(rule, 'role-count'))
2613
+ return [];
2614
+ const { min, max } = count;
2615
+ if (min === undefined && max === undefined) {
2616
+ warnOnce(`role-count rule "${rule.id}" declares neither a minimum nor a maximum ` +
2617
+ `— the rule is not evaluated.`);
2618
+ return [];
2619
+ }
2620
+ const frames = framePlots(rule, elements);
2621
+ // No frame on the board: nothing for the count to be about.
2622
+ if (frames.length === 0)
2623
+ return [];
2624
+ const tally = new Map();
2625
+ for (const frame of frames)
2626
+ tally.set(frame.instance.id, 0);
2627
+ // The frames the GUARD has armed. Left null by a rule that declares none, so
2628
+ // an unguarded rule allocates nothing and reads nothing extra per element.
2629
+ const guard = count.ifPresent;
2630
+ const armed = guard === undefined ? null : new Set();
2631
+ for (const el of elements) {
2632
+ // Cheapest possible exit for a neutral element: no role, no evaluation.
2633
+ if (el.role === undefined)
2634
+ continue;
2635
+ const counts = count.exact === true
2636
+ ? el.role === count.subject
2637
+ : roleIsA(el.role, count.subject, rule.roles);
2638
+ // Not `else if`: nothing forbids a vocabulary where the guard role and the
2639
+ // counted role overlap, and an element that is both must arm the frame AND
2640
+ // be counted in it. Two independent questions about one element.
2641
+ const arms = guard !== undefined && roleIsA(el.role, guard, rule.roles);
2642
+ if (!counts && !arms)
2643
+ continue;
2644
+ // Resolved once for both questions: the containment walk is the expensive
2645
+ // half, and an element that is subject and guard at once must not pay twice.
2646
+ const frame = plotContaining(centreOf(el.elementBound), frames);
2647
+ // Off every frame: it neither counts towards a requirement nor arms one.
2648
+ // An artefact floating beside a pool is a draft, on both readings.
2649
+ if (frame === null)
2650
+ continue;
2651
+ if (counts)
2652
+ tally.set(frame.id, (tally.get(frame.id) ?? 0) + 1);
2653
+ if (arms)
2654
+ armed.add(frame.id);
2655
+ }
2656
+ const violations = [];
2657
+ // Sorted, so a board with several offending frames always reports them the
2658
+ // same way whichever order the surface happened to be walked in.
2659
+ for (const id of [...tally.keys()].sort()) {
2660
+ // A frame the guard has not armed is not judged at all — not walked, not
2661
+ // found compliant. That is the difference between "this notation requires
2662
+ // one" and "this notation requires one WHEN the other is there".
2663
+ if (armed !== null && !armed.has(id))
2664
+ continue;
2665
+ const held = tally.get(id);
2666
+ const words = min !== undefined && held < min
2667
+ ? (count.tooFew ?? rule)
2668
+ : max !== undefined && held > max
2669
+ ? (count.tooMany ?? rule)
2670
+ : null;
2671
+ if (words === null)
2672
+ continue;
2673
+ violations.push(raise(rule, [id], id, words));
2674
+ }
2675
+ return violations;
2676
+ }
2677
+ /**
2678
+ * "Does this relation stay inside one frame, or cross between two?"
2679
+ *
2680
+ * The third family whose subject is a RELATION, and the only one that reads the
2681
+ * frames rather than the roles: a sequence flow belongs to one pool, a message
2682
+ * flow exists only between two. The two ends carry the same roles in the legal
2683
+ * case and the illegal one, so nothing but the attribution can tell them apart.
2684
+ *
2685
+ * Each END is attributed on its own, by the centre of ITS element and never by
2686
+ * the edge's box — the box of a link between two pools spans both of them and
2687
+ * would answer whichever question was asked of it.
2688
+ *
2689
+ * ## What it stays silent about, and why each one matters
2690
+ *
2691
+ * - **no frame on the board** — a process sketched before anybody drew a pool is
2692
+ * a sketch, and this is the family a poolless board would otherwise light up
2693
+ * from end to end;
2694
+ * - **an edge with a free end, or an end whose element is gone** — it relates
2695
+ * nothing;
2696
+ * - **a self-loop** — one element compared with itself is always in its own
2697
+ * frame, so it says nothing about locality either way; whether a loop is legal
2698
+ * at all is `relation-endpoints`' question ({@link boundEnds});
2699
+ * - **an end inside NO frame** — the decisive one, and it is the same
2700
+ * proportionality {@link RelationEndpointsDef.allowed}'s alphabet applies: a
2701
+ * task dropped beside the pool is a draft, and a tool that answered "that flow
2702
+ * leaves the pool" would be indicting the act of sketching.
2703
+ *
2704
+ * ## The finding names the edge AND its two ends
2705
+ *
2706
+ * The `relative-order-along-axis` and `relation-endpoints` precedent, for the
2707
+ * same reason: the finding has two honest resolutions — move the artefact into
2708
+ * the frame it belongs to, or re-point the link — and the second is only
2709
+ * reachable from the edge. It is attributed to the SOURCE's frame, like every
2710
+ * other relation finding in this file.
2711
+ *
2712
+ * ## Cost
2713
+ *
2714
+ * Linear in the elements plus linear in the edges, with the plots resolved once
2715
+ * per frame.
2716
+ */
2717
+ function evaluateEdgeLocality(rule, elements) {
2718
+ const locality = rule.locality;
2719
+ if (locality === undefined)
2720
+ return [];
2721
+ if (missingFrameRole(rule, 'edge-locality'))
2722
+ return [];
2723
+ const frames = framePlots(rule, elements);
2724
+ // No frame on the board, no locality to be in or out of.
2725
+ if (frames.length === 0)
2726
+ return [];
2727
+ const byId = new Map();
2728
+ const edges = [];
2729
+ for (const el of elements) {
2730
+ byId.set(el.id, el);
2731
+ // Cheapest possible exit for a neutral element: no role, no evaluation.
2732
+ if (el.role === undefined)
2733
+ continue;
2734
+ if (roleIsA(el.role, locality.edgeRole, rule.roles))
2735
+ edges.push(el);
2736
+ }
2737
+ if (edges.length === 0)
2738
+ return [];
2739
+ const same = locality.mode === 'same-background';
2740
+ const violations = [];
2741
+ for (const edge of edges) {
2742
+ const ends = boundEnds(edge);
2743
+ if (ends === null)
2744
+ continue;
2745
+ const [sourceId, targetId] = ends;
2746
+ const source = byId.get(sourceId);
2747
+ const target = byId.get(targetId);
2748
+ if (source === undefined || target === undefined)
2749
+ continue;
2750
+ const sourceFrame = plotContaining(centreOf(source.elementBound), frames);
2751
+ if (sourceFrame === null)
2752
+ continue;
2753
+ const targetFrame = plotContaining(centreOf(target.elementBound), frames);
2754
+ if (targetFrame === null)
2755
+ continue;
2756
+ // Conformant when the two ends agree with the mode: one instance for
2757
+ // `same-background`, two for `cross-background`.
2758
+ const together = sourceFrame.id === targetFrame.id;
2759
+ if (together === same)
2760
+ continue;
2761
+ violations.push(raise(rule, namedElements([edge.id, sourceId, targetId]), sourceFrame.id));
2762
+ }
2763
+ return violations;
2764
+ }
2765
+ /**
2766
+ * "Can you get here from the start?" — the orphan question.
2767
+ *
2768
+ * The first family that builds a GRAPH. Every element carrying the declared edge
2769
+ * role contributes one directed arc, `source → target`, read off the persisted
2770
+ * pair and never off the geometry; a breadth-first walk leaves from every root
2771
+ * at once; and every subject the walk never reached is a finding.
2772
+ *
2773
+ * ## Silence when there is no root
2774
+ *
2775
+ * TOTAL, and it is the load-bearing decision of this family. A process with no
2776
+ * start event has every one of its tasks unreachable, so the rule would answer
2777
+ * an empty board with one finding per artefact on it — a wall of brackets whose
2778
+ * single real cause is a missing start event, which is `role-count`'s question
2779
+ * and is already asked once, on the frame. One mistake, one sentence.
2780
+ *
2781
+ * A root is seeded into the visited set, which is also what makes "a root is
2782
+ * never its own orphan" fall out rather than needing a second test.
2783
+ *
2784
+ * ## Two readings of "root"
2785
+ *
2786
+ * {@link ReachabilityDef.implicitRoots} adds the subjects nothing points at to
2787
+ * the set the walk leaves from — the second, silent way a notation with an
2788
+ * optional start marker says "the work begins here". Under it the gate above
2789
+ * means "no root of EITHER kind", and what is left is every subject that has an
2790
+ * incoming counted edge and that no root reaches: a ring entered from nowhere,
2791
+ * and a subject fed only by an edge whose own source the walk never leaves from
2792
+ * (a dangling id, or an element outside both roles). See that field for why the
2793
+ * second one is reported here and fixed elsewhere.
2794
+ *
2795
+ * The two readings share every line but the seeding, deliberately: the answer to
2796
+ * "can you get here" must not depend on which one a framework picked — including
2797
+ * for that second shape, which behaves identically either way.
2798
+ *
2799
+ * ## Direction, and the arcs it does not add
2800
+ *
2801
+ * The walk follows arcs FORWARD only. "Reachable from the start" is a statement
2802
+ * about the direction the process runs in; a task that only points BACK at the
2803
+ * chain is exactly the mistake this exists to find, and an undirected walk would
2804
+ * certify it. An edge with a free end, or an end whose element is gone, adds no
2805
+ * arc — it connects nothing.
2806
+ *
2807
+ * ## Cost, and the incremental story
2808
+ *
2809
+ * O(V + E): one indexing pass, one adjacency build, one traversal that visits
2810
+ * each node and each arc at most once.
2811
+ *
2812
+ * An {@link IncrementalContext} is deliberately NOT honoured, and could not
2813
+ * usefully be: reachability is a GLOBAL property, so re-pointing a single edge
2814
+ * can orphan or rescue an arbitrary number of nodes nowhere near it, and any
2815
+ * dirty edge invalidates the whole answer. A framework that finds the traversal
2816
+ * too expensive for the drawing path declares its rule `'on-demand'` — the
2817
+ * moment is the rule's to choose ({@link ValidationMoment}), and this family
2818
+ * stays agnostic about which one it is evaluated at.
2819
+ */
2820
+ function evaluateReachability(rule, elements) {
2821
+ const def = rule.reachability;
2822
+ if (def === undefined)
2823
+ return [];
2824
+ const subjects = [];
2825
+ const edges = [];
2826
+ // Seeded with the roots, which is what makes a root that is also a subject
2827
+ // reachable by definition rather than by a second test.
2828
+ const visited = new Set();
2829
+ const queue = [];
2830
+ for (const el of elements) {
2831
+ // Cheapest possible exit for a neutral element: no role, no evaluation.
2832
+ if (el.role === undefined)
2833
+ continue;
2834
+ if (roleIsA(el.role, def.rootRole, rule.roles)) {
2835
+ if (!visited.has(el.id)) {
2836
+ visited.add(el.id);
2837
+ queue.push(el.id);
2838
+ }
2839
+ }
2840
+ if (roleIsA(el.role, def.subjectRole, rule.roles))
2841
+ subjects.push(el);
2842
+ if (roleIsA(el.role, def.edgeRole, rule.roles))
2843
+ edges.push(el);
2844
+ }
2845
+ if (subjects.length === 0)
2846
+ return [];
2847
+ const implicit = def.implicitRoots === true;
2848
+ // Under the declared-roots-only reading the gate is answerable before a
2849
+ // single arc is built, which is the cheapest exit this family has. The
2850
+ // implicit reading cannot take it: whether there is a root at all is a fact
2851
+ // about the ARCS, so it has to wait for them.
2852
+ if (!implicit && queue.length === 0)
2853
+ return [];
2854
+ const next = new Map();
2855
+ // Every element some arc points AT — tracked only for the implicit reading,
2856
+ // which is the only one that needs an in-degree. A board on the declared
2857
+ // reading allocates nothing.
2858
+ const pointedAt = implicit ? new Set() : null;
2859
+ for (const edge of edges) {
2860
+ const ends = rawEndpointIds(edge);
2861
+ if (ends === null)
2862
+ continue;
2863
+ const outgoing = next.get(ends[0]);
2864
+ if (outgoing === undefined)
2865
+ next.set(ends[0], [ends[1]]);
2866
+ else
2867
+ outgoing.push(ends[1]);
2868
+ pointedAt?.add(ends[1]);
2869
+ }
2870
+ if (pointedAt !== null) {
2871
+ // A subject nothing points at IS a beginning — see
2872
+ // {@link ReachabilityDef.implicitRoots}. In the surface's walk order, and
2873
+ // through the same `visited` guard the declared roots use, so a subject
2874
+ // that is also a declared root is seeded exactly once.
2875
+ //
2876
+ // A SELF-LOOP disqualifies its own node, and rightly: something does point
2877
+ // at it. A ring of one is the smallest instance of the only defect this
2878
+ // reading still reports.
2879
+ for (const el of subjects) {
2880
+ if (pointedAt.has(el.id) || visited.has(el.id))
2881
+ continue;
2882
+ visited.add(el.id);
2883
+ queue.push(el.id);
2884
+ }
2885
+ }
2886
+ // THE GATE: no root of EITHER kind on the board, so nothing is unreachable —
2887
+ // see above. Under the implicit reading a board reaches this only when every
2888
+ // one of its subjects is pointed at, i.e. when they all sit in rings entered
2889
+ // from nowhere, and the question genuinely does not arise.
2890
+ if (queue.length === 0)
2891
+ return [];
2892
+ // Breadth-first from every root at once. `head` walks the queue instead of
2893
+ // `shift()`ing it: a shift is O(n) on a real array, which would turn an
2894
+ // O(V + E) traversal quadratic on the one board big enough to notice.
2895
+ for (let head = 0; head < queue.length; head++) {
2896
+ const outgoing = next.get(queue[head]);
2897
+ if (outgoing === undefined)
2898
+ continue;
2899
+ for (const id of outgoing) {
2900
+ if (visited.has(id))
2901
+ continue;
2902
+ visited.add(id);
2903
+ queue.push(id);
2904
+ }
2905
+ }
2906
+ // Only resolved when something is actually wrong — the conformant board pays
2907
+ // nothing for the frames it never names.
2908
+ let backgrounds = null;
2909
+ const violations = [];
2910
+ for (const el of subjects) {
2911
+ if (visited.has(el.id))
2912
+ continue;
2913
+ backgrounds ??= backgroundsOf(rule, elements);
2914
+ const frameId = attributeBackground(el.elementBound, backgrounds)?.id;
2915
+ violations.push(raise(rule, [el.id], frameId));
2916
+ }
2917
+ return violations;
2918
+ }
2919
+ /**
2920
+ * Zero-width characters: present in the string, absent from the page.
2921
+ *
2922
+ * A name made of one of these is a name nobody can read, and a user who pasted
2923
+ * one has an artefact that LOOKS unnamed and validates as named — the worst of
2924
+ * both, and impossible to debug from the canvas. `trim()` does not remove them
2925
+ * (they are not `WhiteSpace` in the spec), so they are stripped first.
2926
+ *
2927
+ * ESCAPED, never written as the characters themselves. A class holding the raw
2928
+ * code points is invisible in every diff, review UI and editor — which makes it
2929
+ * the one constant in this file that can be silently changed by a linter, a
2930
+ * paste or a codemod that strips zero-width characters, with nothing on screen
2931
+ * to show it happened. The escapes are: zero-width space, zero-width
2932
+ * non-joiner, zero-width joiner, word joiner, and the byte-order mark.
2933
+ */
2934
+ const INVISIBLE_CHARS = /[\u200B-\u200D\u2060\uFEFF]/g;
2935
+ /**
2936
+ * Whether a non-string value knows how to render ITSELF as text, as opposed to
2937
+ * inheriting `Object.prototype.toString` and answering `'[object Object]'`.
2938
+ *
2939
+ * The duck-typed test for "this is a text-bearing model": a `Y.Text` overrides
2940
+ * `toString`, and so does every wrapper the canvas could plausibly store in that
2941
+ * slot. A plain object does not, and neither does a number.
2942
+ *
2943
+ * An ARRAY is excluded even though it overrides `toString`, and that is the case
2944
+ * worth spelling out: a raw delta list is the shape a text most plausibly
2945
+ * degrades into, and `String([{ insert: 'x' }])` is `'[object Object]'` all over
2946
+ * again. A list of runs is not a name; a model that can render one is.
2947
+ */
2948
+ function rendersAsText(value) {
2949
+ if (typeof value !== 'object' || Array.isArray(value))
2950
+ return false;
2951
+ const own = value.toString;
2952
+ return typeof own === 'function' && own !== Object.prototype.toString;
2953
+ }
2954
+ /**
2955
+ * The words an element carries, or `''` — the EXTRACTION alone, with no opinion
2956
+ * on whether having none is a mistake.
2957
+ *
2958
+ * Duck-typed on `text`, exactly like {@link elementPath} and
2959
+ * {@link rawEndpointIds} are on their own properties, and for the same reason:
2960
+ * the engine knows roles and geometry and must not import an element model. That
2961
+ * one property is what a shape's `Y.Text`, a connector's label and a free text
2962
+ * element all expose, and `String()` is what turns any of them into the content
2963
+ * — the same conversion {@link textInkBound} has always relied on.
2964
+ *
2965
+ * NFC first, for the reason that function gives: the same name typed here and
2966
+ * pasted from elsewhere can arrive as the same glyphs in a different number of
2967
+ * code points. Then the invisibles, then the whitespace. What is left is what a
2968
+ * reader would see.
2969
+ *
2970
+ * ## What is not a name
2971
+ *
2972
+ * A value that is neither a string nor something that knows how to render itself
2973
+ * as one. `String({})` is `'[object Object]'` — eleven visible characters, which
2974
+ * would make an artefact carrying a malformed `text` read as NAMED, and named
2975
+ * with a word the user can neither see nor delete.
2976
+ *
2977
+ * `text` comes off a `Y.Map`, so it is whatever a peer or an importer wrote —
2978
+ * the same threat model {@link elementExceptions} guards with its `Array.isArray`
2979
+ * and {@link declaresRole} with its `Object.hasOwn`. The test is for an own
2980
+ * `toString`, which a `Y.Text` has and a plain object does not: it accepts every
2981
+ * shape the canvas actually stores without this file importing one of them.
2982
+ */
2983
+ function elementLabel(el) {
2984
+ const text = el.text;
2985
+ if (text == null)
2986
+ return '';
2987
+ if (typeof text !== 'string' && !rendersAsText(text))
2988
+ return '';
2989
+ return String(text).normalize('NFC').replace(INVISIBLE_CHARS, '').trim();
2990
+ }
2991
+ /**
2992
+ * "Does this artefact say what it is?"
2993
+ *
2994
+ * The first family that reads an element's WORDS. Every other one asks about
2995
+ * geometry, roles or relations, all of which a reader can partly infer from the
2996
+ * drawing; a box with nothing written in it is the one defect nothing recovers.
2997
+ *
2998
+ * ## What counts as absent
2999
+ *
3000
+ * No `text` at all, an empty one, one that is only whitespace, and one that is
3001
+ * only invisible code points — see {@link elementLabel}. Anything a reader would
3002
+ * see is a label, however short: judging the QUALITY of a name is not this
3003
+ * family's business and probably nobody's.
3004
+ *
3005
+ * ## Silence
3006
+ *
3007
+ * The usual proportionality: an element with no role is not a subject, and a
3008
+ * board carrying no subject raises nothing. There is no frame requirement —
3009
+ * {@link ValidationRule.backgroundRole} buys attribution only, the `no-overlap`
3010
+ * pattern, so a rule may name a frame to give its findings somewhere to be
3011
+ * waived and need not.
3012
+ *
3013
+ * ## The moment, and why a framework should think about it
3014
+ *
3015
+ * Naming is the one property a user changes by TYPING, one character at a time.
3016
+ * `text` is verdict-bearing only for a framework that registers a real-time rule
3017
+ * of this family — {@link verdictPropsOf} adds it then and only then — so a rule
3018
+ * declared `'on-demand'` costs the drawing path nothing at all, and a real-time
3019
+ * one wakes the debounced evaluation on every keystroke in every shape on the
3020
+ * board. Both are supported; the second is a choice a framework should make
3021
+ * deliberately.
3022
+ *
3023
+ * ## Cost
3024
+ *
3025
+ * One pass, one `text` read per subject. Linear, and paid only by the elements
3026
+ * carrying the subject role.
3027
+ */
3028
+ function evaluateLabelPresence(rule, elements) {
3029
+ const subjectRole = rule.appliesTo;
3030
+ const label = rule.label;
3031
+ if (subjectRole === undefined || label === undefined)
3032
+ return [];
3033
+ if (label.present !== true)
3034
+ return [];
3035
+ // Only resolved when something is actually wrong — a named board pays nothing
3036
+ // for the frames it never has to name.
3037
+ let backgrounds = null;
3038
+ const violations = [];
3039
+ for (const el of elements) {
3040
+ // Cheapest possible exit for a neutral element: no role, no evaluation.
3041
+ if (el.role === undefined)
3042
+ continue;
3043
+ if (!roleIsA(el.role, subjectRole, rule.roles))
3044
+ continue;
3045
+ if (elementLabel(el) !== '')
3046
+ continue;
3047
+ backgrounds ??= backgroundsOf(rule, elements);
3048
+ const frameId = attributeBackground(el.elementBound, backgrounds)?.id;
3049
+ violations.push(raise(rule, [el.id], frameId));
3050
+ }
3051
+ return violations;
3052
+ }
3053
+ const RULE_FAMILIES = {
3054
+ 'element-in-background': evaluateElementInBackground,
3055
+ 'orientation-against-axis': evaluateOrientationAgainstAxis,
3056
+ attachment: evaluateAttachment,
3057
+ 'no-overlap': evaluateNoOverlap,
3058
+ 'tone-convention': evaluateToneConvention,
3059
+ 'majority-fact': evaluateMajorityFact,
3060
+ 'relative-order-along-axis': evaluateRelativeOrder,
3061
+ 'relation-endpoints': evaluateRelationEndpoints,
3062
+ 'element-in-zone': evaluateElementInZone,
3063
+ 'edge-degree': evaluateEdgeDegree,
3064
+ 'role-count': evaluateRoleCount,
3065
+ 'edge-locality': evaluateEdgeLocality,
3066
+ reachability: evaluateReachability,
3067
+ 'label-presence': evaluateLabelPresence,
3068
+ 'view-admissibility': evaluateViewAdmissibility,
3069
+ };
3070
+ /** A rule the drawing path evaluates. `moment` absent means `'realtime'`. */
3071
+ function isRealtime(rule) {
3072
+ return (rule.moment ?? 'realtime') === 'realtime';
3073
+ }
3074
+ /** The exceptions an element carries, always an array, never a copy to keep. */
3075
+ export function elementExceptions(element) {
3076
+ const stored = element.validationExceptions;
3077
+ // The value comes out of a Y.Map, so it is whatever a peer wrote: a client
3078
+ // that got it wrong must not break evaluation on this one.
3079
+ return Array.isArray(stored) ? stored : [];
3080
+ }
3081
+ /** Whether `element` is excused from `ruleId`. */
3082
+ export function hasException(element, ruleId) {
3083
+ return elementExceptions(element).some(exception => exception?.ruleId === ruleId);
3084
+ }
3085
+ /**
3086
+ * Stamp `exemption` on the findings a user exception already covers.
3087
+ *
3088
+ * Called only when a rule actually raised something, so a conformant board — the
3089
+ * common case, and the one the 16 ms budget is measured on — pays nothing at
3090
+ * all for the feature.
3091
+ */
3092
+ function applyExceptions(rule, raised, elements) {
3093
+ const byId = new Map();
3094
+ for (const el of elements)
3095
+ byId.set(el.id, el);
3096
+ const excused = (id) => {
3097
+ if (id === undefined)
3098
+ return false;
3099
+ const el = byId.get(id);
3100
+ return el !== undefined && hasException(el, rule.id);
3101
+ };
3102
+ for (const violation of raised) {
3103
+ // EVERY indicted element must be excused: a rule indicting two elements of
3104
+ // which only one is excused is still a live finding.
3105
+ const perElement = violation.elementIds.every(excused);
3106
+ // Narrower scope first, so revoking walks from the local decision outward
3107
+ // and each click visibly changes the state instead of doing nothing.
3108
+ if (perElement)
3109
+ violation.exemption = 'element';
3110
+ // ...and the map scope reads THIS finding's own background, never "some
3111
+ // background on the board carries the exception". An arbitration made on
3112
+ // one map says nothing about the map next to it.
3113
+ else if (excused(violation.backgroundId))
3114
+ violation.exemption = 'map';
3115
+ // Nothing covers it (any more). Stamped rather than left alone because an
3116
+ // incremental pass CARRIES OVER finding objects from the last evaluation
3117
+ // (see {@link IncrementalContext}), and one that kept a stale `exemption`
3118
+ // would read as excused on a board where the exception has been revoked.
3119
+ else
3120
+ delete violation.exemption;
3121
+ }
3122
+ }
3123
+ /**
3124
+ * Run every rule over every element. Pure and synchronous — the unit of the
3125
+ * 16 ms budget (PF5.12), and the only thing the bench measures.
3126
+ *
3127
+ * `profiles` is the level of requirement in force (PF9). Omitted, every rule is
3128
+ * judged by its own declared severity, which is exactly what a framework
3129
+ * shipping no profile gets.
3130
+ *
3131
+ * `incremental` is what the caller knows about what CHANGED. Omitted — the
3132
+ * default, and what every test and the bench's full-evaluation case pass — the
3133
+ * whole surface is re-judged from scratch. Handed in, the pair-wise family
3134
+ * re-tests only the couples a change can have affected; the answer is the same,
3135
+ * it is just reached without walking the grid against itself.
3136
+ */
3137
+ export function evaluateRules(rules, elements, profiles = [], incremental) {
3138
+ return runRules(rules, elements, profiles, 'realtime', incremental);
3139
+ }
3140
+ /**
3141
+ * Run every rule of one MOMENT over every element. The body both
3142
+ * {@link evaluateRules} and {@link evaluateCheckup} are made of — one pipeline,
3143
+ * so a profile, an exception and a background attribution mean exactly the same
3144
+ * thing whichever moment a rule is declared at.
3145
+ *
3146
+ * The moment is tested INSIDE the loop rather than by filtering the array: one
3147
+ * property read per rule, no allocation, and the "an on-demand rule costs the
3148
+ * drawing path nothing" claim is true of every evaluation and not just of the
3149
+ * ones that bothered to filter.
3150
+ */
3151
+ function runRules(rules, elements, profiles, moment, incremental) {
3152
+ if (rules.length === 0)
3153
+ return [];
3154
+ // No profile registered => not a single extra read on the whole surface.
3155
+ const index = profiles.length > 0 ? indexProfiles(profiles) : null;
3156
+ const chosen = index ? readChosenProfiles(elements) : null;
3157
+ const violations = [];
3158
+ for (const rule of rules) {
3159
+ // The other moment's rules are not this pass's business — checked FIRST,
3160
+ // so an on-demand rule never reaches a profile lookup, let alone an
3161
+ // element (PF5.14).
3162
+ if ((isRealtime(rule) ? 'realtime' : 'on-demand') !== moment)
3163
+ continue;
3164
+ // `'off'` everywhere means never walked: the cheapest exit there is, taken
3165
+ // before a single element is touched.
3166
+ if (index && chosen && isRuleSilent(rule, chosen, index))
3167
+ continue;
3168
+ let raised = RULE_FAMILIES[rule.family](rule, elements, incremental);
3169
+ if (index && chosen && raised.length > 0) {
3170
+ raised = applyProfiles(rule, raised, chosen, index);
3171
+ }
3172
+ if (raised.length === 0)
3173
+ continue;
3174
+ // Exceptions are read LAST, and independently of the profile: an
3175
+ // arbitration a user made is theirs, and changing the level of requirement
3176
+ // never revokes one (PF9.3).
3177
+ applyExceptions(rule, raised, elements);
3178
+ violations.push(...raised);
3179
+ }
3180
+ return violations;
3181
+ }
3182
+ /** The registered rules a check-up runs — and nothing else ever evaluates. */
3183
+ export function onDemandRules(rules) {
3184
+ return rules.filter(rule => !isRealtime(rule));
3185
+ }
3186
+ /**
3187
+ * Run the ON-DEMAND rules over the surface (PF5.14). The exact mirror of
3188
+ * {@link evaluateRules}, for the moment the drawing path never touches.
3189
+ *
3190
+ * Synchronous and total: this is what a test, a host and a report call. The
3191
+ * manager drives the same pipeline one rule at a time so a long check-up can
3192
+ * yield the thread — see {@link ValidationManager.runCheckup} — and both reach
3193
+ * the same answer, because both are {@link runRules}.
3194
+ *
3195
+ * The findings are ordinary {@link Violation} objects and go through the same
3196
+ * profile and exception passes as any other. What makes them REMARKS rather
3197
+ * than verdicts is where they land: they never reach `violations$`, so they
3198
+ * never reach the timeline, the bracket or the badge. A framework declares them
3199
+ * `audit` on top of that, which is the severity for "collected, invisible to the
3200
+ * drawing user" — belt and braces, and the braces are structural.
3201
+ */
3202
+ export function evaluateCheckup(rules, elements, profiles = []) {
3203
+ return runRules(rules, elements, profiles, 'on-demand');
3204
+ }
3205
+ /**
3206
+ * How long a check-up may hold the thread before yielding it back.
3207
+ *
3208
+ * One frame, so a check-up that turns out to be expensive costs a dropped frame
3209
+ * at worst instead of a locked tab. A rule is the unit of interruption — the
3210
+ * smallest slice that has a meaning — so a single rule slower than this still
3211
+ * runs to completion and the next one waits for the following task.
3212
+ *
3213
+ * The rules shipped today cost well under a millisecond between them, so the
3214
+ * yield never fires in the delivered configuration — which would leave the
3215
+ * generation counter and the supersession path as code nothing reaches. It is
3216
+ * therefore a DEFAULT rather than a constant: {@link ValidationManager.checkupSliceMs}
3217
+ * is settable, and lowering it makes every rule a slice, which is how a test
3218
+ * exercises the yield and the race with the real rules instead of a stub.
3219
+ */
3220
+ export const CHECKUP_SLICE_MS = 16;
3221
+ /**
3222
+ * Grant `ruleId` an exception on `element`, unless it already has one.
3223
+ *
3224
+ * The write goes through the declared `@field()` accessor, so it lands in the
3225
+ * Y.Map, syncs to every peer, joins the undo stack and survives a copy — see the
3226
+ * note on `GfxPrimitiveElementModel.validationExceptions`.
3227
+ */
3228
+ export function grantException(element, ruleId, author, now = Date.now()) {
3229
+ if (hasException(element, ruleId))
3230
+ return;
3231
+ element.validationExceptions = [
3232
+ ...elementExceptions(element),
3233
+ // The timestamp is taken at the moment of the gesture, and an absent author
3234
+ // stays ABSENT rather than becoming an empty string.
3235
+ { ruleId, at: now, ...(author !== undefined ? { author } : {}) },
3236
+ ];
3237
+ }
3238
+ /** Remove `ruleId`'s exception from `element`, if it has one. */
3239
+ export function revokeException(element, ruleId) {
3240
+ const current = elementExceptions(element);
3241
+ const next = current.filter(exception => exception?.ruleId !== ruleId);
3242
+ if (next.length === current.length)
3243
+ return;
3244
+ if (next.length > 0) {
3245
+ element.validationExceptions = next;
3246
+ return;
3247
+ }
3248
+ // The last one goes, so the KEY goes. Assigning `undefined` through the
3249
+ // accessor would leave the key in the Y.Map holding an undefined value —
3250
+ // invisible through the getter, but synced to every peer and shipped in every
3251
+ // snapshot. `clearField` removes it, so an element whose exceptions were all
3252
+ // revoked really is indistinguishable from one that never had any, in the
3253
+ // document and not just in this tab.
3254
+ element.clearField('validationExceptions');
3255
+ }
3256
+ /**
3257
+ * The exceptions `element` ANSWERS FOR, i.e. the ones a menu on it should be
3258
+ * able to revoke.
3259
+ *
3260
+ * Same rule the canvas mark follows ({@link anchorOf}): an exception is
3261
+ * answered for by the outermost canvas group containing the element that
3262
+ * carries it, or by that element when it is not grouped. So the entry lives on
3263
+ * the whole Wardley component rather than on the bare node inside it, moves
3264
+ * down to the element the moment the group is dissolved, and appears on a
3265
+ * framework background for the map-wide arbitration it carries.
3266
+ *
3267
+ * Reads STORED exceptions, not current violations: an exception outlives the
3268
+ * violation that prompted it (move the component back onto the map and the
3269
+ * finding goes, the arbitration stays), and it is the arbitration the user is
3270
+ * asking to take back.
3271
+ */
3272
+ export function exceptionsAnchoredOn(element, surface) {
3273
+ const candidates = [element];
3274
+ if (element instanceof GfxGroupLikeElementModel) {
3275
+ for (const descendant of element.descendantElements) {
3276
+ if (descendant instanceof GfxPrimitiveElementModel) {
3277
+ candidates.push(descendant);
3278
+ }
3279
+ }
3280
+ }
3281
+ const anchored = [];
3282
+ for (const candidate of candidates) {
3283
+ const exceptions = elementExceptions(candidate);
3284
+ if (exceptions.length === 0)
3285
+ continue;
3286
+ // Anything whose anchor is somebody else belongs to somebody else's menu —
3287
+ // that is what keeps the entry on the outermost group and off its members.
3288
+ if (anchorOf(candidate.id, surface)?.id !== element.id)
3289
+ continue;
3290
+ for (const exception of exceptions) {
3291
+ if (exception?.ruleId) {
3292
+ anchored.push({ element: candidate, ruleId: exception.ruleId });
3293
+ }
3294
+ }
3295
+ }
3296
+ return anchored;
3297
+ }
3298
+ /** A framework registers its rules here; nothing else registers rules. */
3299
+ export const ValidationRuleIdentifier = createIdentifier('ValidationRule');
3300
+ /**
3301
+ * Register a framework's rules. Call it from the FLAG-GATED view extension —
3302
+ * validation is tooling, so a disabled framework must contribute nothing.
3303
+ *
3304
+ * ```ts
3305
+ * context.register(ValidationRuleExtension(WARDLEY_RULES));
3306
+ * ```
3307
+ */
3308
+ export function ValidationRuleExtension(rules) {
3309
+ return {
3310
+ setup: di => {
3311
+ for (const rule of rules) {
3312
+ di.addImpl(ValidationRuleIdentifier(rule.id), () => rule);
3313
+ }
3314
+ },
3315
+ };
3316
+ }
3317
+ /** A framework registers its profiles here; nothing else registers profiles. */
3318
+ export const ValidationProfileIdentifier = createIdentifier('ValidationProfile');
3319
+ /**
3320
+ * Register a framework's profiles. Call it from the FLAG-GATED view extension,
3321
+ * beside {@link ValidationRuleExtension}: a level of requirement is tooling, so
3322
+ * a disabled framework offers none — and the id already written on a background
3323
+ * simply goes unread until the flag comes back.
3324
+ *
3325
+ * ```ts
3326
+ * context.register(ValidationProfileExtension(WARDLEY_PROFILES));
3327
+ * ```
3328
+ */
3329
+ export function ValidationProfileExtension(profiles) {
3330
+ return {
3331
+ setup: di => {
3332
+ for (const profile of profiles) {
3333
+ di.addImpl(ValidationProfileIdentifier(profile.id), () => profile);
3334
+ }
3335
+ },
3336
+ };
3337
+ }
3338
+ /** A framework declares its root instances here, when no rule already does. */
3339
+ export const ValidationFrameworkIdentifier = createIdentifier('ValidationFramework');
3340
+ /**
3341
+ * Register a framework's root-instance declaration.
3342
+ *
3343
+ * ```ts
3344
+ * context.register(
3345
+ * ValidationFrameworkExtension([
3346
+ * { framework: 'estuarine', backgroundRole: 'estuarine:map', roles: ESTUARINE_ROLES },
3347
+ * ])
3348
+ * );
3349
+ * ```
3350
+ *
3351
+ * Keyed by framework AND role, so a framework with two kinds of board declares
3352
+ * both and neither silently replaces the other.
3353
+ */
3354
+ export function ValidationFrameworkExtension(defs) {
3355
+ return {
3356
+ setup: di => {
3357
+ for (const def of defs) {
3358
+ di.addImpl(ValidationFrameworkIdentifier(`${def.framework}|${def.backgroundRole}`), () => def);
3359
+ }
3360
+ },
3361
+ };
3362
+ }
3363
+ /** Shared answer for "this element is nobody's frame", allocated once. */
3364
+ const EMPTY_FRAMEWORKS = new Set();
3365
+ /**
3366
+ * The frameworks `role` is a ROOT INSTANCE of — the one test that decides
3367
+ * whether an element is somebody's frame, and the only one.
3368
+ *
3369
+ * TWO sources, and they answer the same question:
3370
+ *
3371
+ * - a registered RULE naming this role as the background it measures against.
3372
+ * A rule that frames against a role is a framework saying "this is my map",
3373
+ * with no second registry to keep in step;
3374
+ * - a registered {@link ValidationFrameworkDef}, for the framework whose whole
3375
+ * contribution is nudges and which therefore has no rule to be derived from.
3376
+ *
3377
+ * Pure, and exported, because every per-instance surface downstream — the
3378
+ * profile picker, the nudge checklist, the check-up — is built on this answer,
3379
+ * and a function taking its inputs is testable without a DI container, a
3380
+ * surface or an editor.
3381
+ *
3382
+ * Empty for a neutral element, and for a background authored before its role
3383
+ * existed: no role, no framework, no tooling.
3384
+ */
3385
+ export function frameworksOfRole(role, rules, frameworkDefs = []) {
3386
+ if (role === undefined)
3387
+ return EMPTY_FRAMEWORKS;
3388
+ const frameworks = new Set();
3389
+ for (const rule of rules) {
3390
+ if (rule.backgroundRole === undefined)
3391
+ continue;
3392
+ if (roleIsA(role, rule.backgroundRole, rule.roles)) {
3393
+ frameworks.add(rule.framework);
3394
+ }
3395
+ }
3396
+ for (const def of frameworkDefs) {
3397
+ if (roleIsA(role, def.backgroundRole, def.roles)) {
3398
+ frameworks.add(def.framework);
3399
+ }
3400
+ }
3401
+ // One shared answer for "this element is nobody's frame": the common case by
3402
+ // far, on every element of every board that carries no framework.
3403
+ return frameworks.size === 0 ? EMPTY_FRAMEWORKS : frameworks;
3404
+ }
3405
+ /** Recompute delay, so a drag re-evaluates once instead of once per frame. */
3406
+ const VALIDATION_DELAY_MS = 120;
3407
+ /**
3408
+ * Element props that can change a verdict, WHATEVER the registered rules are.
3409
+ * Everything else — `opacity` and the other `@local()` fields, colours,
3410
+ * labels — is ignored, so brushing a canvas (`SpotlightManager` writes
3411
+ * `opacity` on every element it dims) neither re-evaluates the surface nor
3412
+ * pushes the pending evaluation further away.
3413
+ *
3414
+ * A framework can add to this list without the engine learning its name: see
3415
+ * {@link verdictPropsOf}.
3416
+ */
3417
+ export const VERDICT_PROPS = [
3418
+ 'xywh',
3419
+ 'rotate',
3420
+ 'role',
3421
+ // The two ends of a typed edge ARE the relation's orientation since
3422
+ // `docs/adr/0010`, so re-pointing one — or reversing the edge, which writes
3423
+ // both — changes a verdict exactly as much as moving a node does. Without
3424
+ // these, an inversion would leave the finding on screen until the next
3425
+ // unrelated drag woke the engine.
3426
+ 'source',
3427
+ 'target',
3428
+ // A user exception changes a verdict as much as a move does: without this,
3429
+ // granting one on a peer's tab would leave the mark up until the next drag.
3430
+ 'validationExceptions',
3431
+ // Changing the level of requirement re-judges everything measured against
3432
+ // this background (PF9). Written on a background, read for every finding
3433
+ // attributed to it — and, like an exception, it can be DELETED (choosing the
3434
+ // default back removes the key), which is why `touchesVerdict` reads
3435
+ // `oldValues` too.
3436
+ 'validationProfile',
3437
+ ];
3438
+ /**
3439
+ * {@link VERDICT_PROPS} plus what the REGISTERED rules make verdict-bearing.
3440
+ *
3441
+ * A framework background can carry visibility toggles, and one of them may be
3442
+ * the toggle that shows the frontiers a rule measures against — hide the
3443
+ * dashed columns of a Wardley map and the findings about them stop being
3444
+ * findings ({@link backgroundTransitionBands}). Without this the toggle would be
3445
+ * as inert as a colour: the marks would stay on screen until some unrelated
3446
+ * drag happened to wake the engine, which is a board that lies about a document
3447
+ * that has already changed.
3448
+ *
3449
+ * Derived from the DECLARATIONS the engine was handed, never from a name it
3450
+ * knows: `backgroundTransitionVisibleProps` reads the prop each framework
3451
+ * writes beside the line it gates. A framework whose background declares no
3452
+ * toggle contributes nothing and the set is the constant.
3453
+ *
3454
+ * ## `text`, and why it is not in the constant
3455
+ *
3456
+ * `label-presence` is the one family whose verdict turns on an element's words,
3457
+ * and words are what a user changes by TYPING. Watched unconditionally, every
3458
+ * keystroke in every shape on the board would wake the debounced evaluation —
3459
+ * a cost paid by every document, for a question most of them never ask.
3460
+ *
3461
+ * So it is added exactly when a REAL-TIME rule of that family is registered.
3462
+ * The moment filter is deliberate and mirrors {@link backgroundElementIds}: an
3463
+ * on-demand rule is never evaluated on the gesture path, so waking that path for
3464
+ * it would hand back precisely what declaring the second moment bought — and a
3465
+ * framework that wants its naming checked while the user types says so by
3466
+ * declaring the rule real-time, which is the one place that decision belongs.
3467
+ */
3468
+ export function verdictPropsOf(rules) {
3469
+ const props = new Set(VERDICT_PROPS);
3470
+ for (const rule of rules) {
3471
+ if (rule.family === 'label-presence' && isRealtime(rule))
3472
+ props.add('text');
3473
+ // The level a VIEW declares decides which roles it admits, so changing it —
3474
+ // or clearing it back to "free sketch", which DELETES the key, which is why
3475
+ // `touchesVerdict` reads `oldValues` too — re-judges everything drawn on
3476
+ // that frame. Derived from the rule's own declaration and never from a name
3477
+ // the engine knows, exactly like the transition toggles below.
3478
+ if (rule.admissibility !== undefined) {
3479
+ props.add(rule.admissibility.levelProp);
3480
+ }
3481
+ if (rule.background === undefined)
3482
+ continue;
3483
+ for (const prop of backgroundTransitionVisibleProps(rule.background)) {
3484
+ props.add(prop);
3485
+ }
3486
+ }
3487
+ return props;
3488
+ }
3489
+ /**
3490
+ * Whether an `elementUpdated` payload can have changed a verdict.
3491
+ *
3492
+ * It has to read `oldValues` as well as `props`, because a Y.Map **delete**
3493
+ * fills only the former (`syncElementFromY` puts a key in `props` for `add` and
3494
+ * `update` only). `validationExceptions` is the first {@link VERDICT_PROPS}
3495
+ * entry whose normal life includes being removed — `xywh` and `rotate` are
3496
+ * never deleted, `role` is only ever added — so this branch had never been
3497
+ * exercised. Reading `props` alone means an UNDO of an exception, which deletes
3498
+ * the key, wakes nothing: the board keeps showing a finding as excused when the
3499
+ * document no longer says so, and its Revoke button becomes a no-op.
3500
+ *
3501
+ * A payload carrying neither is unreadable, so it counts as "might have": the
3502
+ * cost of a spurious re-evaluation is a debounce tick, the cost of a missed one
3503
+ * is a board that lies.
3504
+ */
3505
+ export function touchesVerdict(payload, watched = VERDICT_PROPS) {
3506
+ const { props, oldValues } = payload;
3507
+ if (!props && !oldValues)
3508
+ return true;
3509
+ for (const prop of watched) {
3510
+ if (props !== undefined && prop in props)
3511
+ return true;
3512
+ if (oldValues !== undefined && prop in oldValues)
3513
+ return true;
3514
+ }
3515
+ return false;
3516
+ }
3517
+ /**
3518
+ * Owns the evaluation and the reactive violation list. No-op until a framework
3519
+ * registers a rule.
3520
+ */
3521
+ export class ValidationManager extends InteractivityExtension {
3522
+ constructor() {
3523
+ super(...arguments);
3524
+ /**
3525
+ * Current violations. The reactive seam a host panel (wave 2) subscribes to;
3526
+ * wave 1 ships only the canvas affordance.
3527
+ */
3528
+ this.violations$ = signal([]);
3529
+ /**
3530
+ * Level 3 — what an AI audit reported, and ONLY that (PF14.1).
3531
+ *
3532
+ * A second signal rather than a marked subset of {@link violations$}, and the
3533
+ * reason is the invariant the whole audit seam exists to protect: **the
3534
+ * deterministic engine never depends on the AI**. Nothing in `evaluate()`
3535
+ * reads this signal, writes it or is slowed by it; nothing here is ever
3536
+ * recomputed, carried over by an incremental pass, or judged by a profile.
3537
+ * The 16 ms budget is measured on a function that cannot see this field.
3538
+ *
3539
+ * Merging the two lists and discriminating by severity would have been
3540
+ * cheaper to type and much more expensive to reason about: every consumer of
3541
+ * `violations$` — the overlay, the timeline, the bubble, the exception
3542
+ * toolbar, `userFacingViolations`, the incremental carry-over — would have
3543
+ * had to learn to skip entries it must never touch, and forgetting one of
3544
+ * them in some future slice would put a model's opinion behind a canvas
3545
+ * bracket. Two signals make that a type error instead of a review.
3546
+ *
3547
+ * The findings ARE violation objects (`AuditFinding` is assignable to
3548
+ * {@link Violation}, pinned by a test), so a host panel renders them with the
3549
+ * code it already has. They are session state, never persisted: an audit is
3550
+ * an opinion at a moment, and the document says what it contains, not what a
3551
+ * model thought of it on Tuesday.
3552
+ */
3553
+ this.auditFindings$ = signal([]);
3554
+ /**
3555
+ * Which violations are still FRESH, i.e. still owed the loud bracket.
3556
+ *
3557
+ * It lives here rather than inside the overlay because the affordance has two
3558
+ * halves on two different technologies — a canvas bracket that fades, and a
3559
+ * DOM badge that takes over — and they are mutually exclusive: the badge
3560
+ * appears only once the bracket is fully gone. Two copies of "when did I
3561
+ * first see this" would drift, and the handover would show both at once or
3562
+ * neither.
3563
+ *
3564
+ * Session state, never persisted: a document says which rules it breaks, not
3565
+ * when you happened to look at them.
3566
+ */
3567
+ this.timeline = new ViolationTimeline();
3568
+ /**
3569
+ * The last check-up (PF5.14), or `null` while none has been asked for.
3570
+ *
3571
+ * A SEPARATE signal from {@link violations$}, and that separation is the whole
3572
+ * design: a remark never reaches the timeline, the bracket or the badge,
3573
+ * because it never reaches the list those three read. "Outside the canvas
3574
+ * affordance" is therefore a property of the wiring, not a filter somebody has
3575
+ * to remember to apply.
3576
+ *
3577
+ * Reset to `null` on unmount and never persisted: a document says what it
3578
+ * contains, not when somebody last checked it.
3579
+ */
3580
+ this.checkup$ = signal(null);
3581
+ /**
3582
+ * The element whose Map quality panel is OPEN, or `null` — the seam between
3583
+ * the toolbar entry that asks for it (`validation-toolbar.ts`, PF7.11) and the
3584
+ * widget that draws it.
3585
+ *
3586
+ * A signal rather than a DOM call because the two live in different trees: the
3587
+ * entry is a lit template inside `editor-menu-button`'s shadow root, and the
3588
+ * panel is a widget on the root block. Session state, like every other "what
3589
+ * is open" in this file.
3590
+ */
3591
+ this.mapQualityFor$ = signal(null);
3592
+ this._pending = null;
3593
+ /**
3594
+ * Bumped by every {@link runCheckup}, so a run that yielded the thread can
3595
+ * tell it has been superseded and drop its partial results on the floor
3596
+ * instead of overwriting a newer answer.
3597
+ */
3598
+ this._checkupGeneration = 0;
3599
+ /**
3600
+ * How long {@link runCheckup} may hold the thread between two rules.
3601
+ *
3602
+ * Settable rather than fixed so the interruption and the supersession are
3603
+ * TESTABLE with the rules a framework actually ships: at the shipped default
3604
+ * the Wardley check-up costs half a millisecond and never yields, which would
3605
+ * leave the whole race path unreached by anything. Dropped to zero, every rule
3606
+ * becomes a slice.
3607
+ */
3608
+ this.checkupSliceMs = CHECKUP_SLICE_MS;
3609
+ /**
3610
+ * Element ids added, removed or updated since the last verdict — the dirty
3611
+ * set of PF5.13. Filled by the subscriptions, drained by every evaluation.
3612
+ */
3613
+ this._dirty = new Set();
3614
+ /** Whether {@link violations$} holds a verdict a dirty set can build on. */
3615
+ this._evaluated = false;
3616
+ /**
3617
+ * The ids that were framework BACKGROUNDS at the last evaluation.
3618
+ *
3619
+ * `no-overlap` already sends the surface back through a full sweep when a
3620
+ * frame is touched, and it finds a DELETED one by looking for its id in the
3621
+ * previous findings — the only trace a departed element leaves. That trace
3622
+ * does not exist when the frame's profile put the rule on `'off'`: the
3623
+ * findings measured against it were dropped by `applyProfiles`, so `previous`
3624
+ * is empty, the deletion looks like an ordinary one, and the overlap that
3625
+ * comes back to life under the default profile is never reported. No family
3626
+ * can close that hole — an element that is gone from the surface AND absent
3627
+ * from the findings is invisible to everything except a memory of it.
3628
+ *
3629
+ * So the manager keeps that memory. A dirty id that used to be a background
3630
+ * forces the full pass, whatever the previous findings say or fail to say —
3631
+ * which makes `incremental === full` true by construction rather than by the
3632
+ * luck of no shipped profile using `'off'` yet.
3633
+ */
3634
+ this._backgrounds = new Set();
3635
+ this._rules = null;
3636
+ this._verdictProps = null;
3637
+ this._nudges = null;
3638
+ this._frameworks = null;
3639
+ this._profiles = null;
3640
+ this._subscriptions = [];
3641
+ this._disposeSurfaceEffect = null;
3642
+ }
3643
+ static { this.key = 'validation-manager'; }
3644
+ /**
3645
+ * Publish an audit's results. The ONLY writer of {@link auditFindings$}, and
3646
+ * deliberately dumb: it stores, it does not evaluate, re-judge or merge.
3647
+ *
3648
+ * Replaces rather than appends — a run's findings are a whole answer about
3649
+ * the board as it was, so accumulating two runs would show a user a
3650
+ * contradiction and call it a list.
3651
+ */
3652
+ setAuditFindings(findings) {
3653
+ this.auditFindings$.value = findings;
3654
+ }
3655
+ /** Registered rules, resolved once. Empty when every framework is flagged off. */
3656
+ get _activeRules() {
3657
+ this._rules ??= Array.from(this.std.provider.getAll(ValidationRuleIdentifier).values());
3658
+ return this._rules;
3659
+ }
3660
+ /**
3661
+ * The props worth re-evaluating for, given what is registered. Resolved once,
3662
+ * beside the rules it is derived from — see {@link verdictPropsOf}.
3663
+ */
3664
+ get _watchedProps() {
3665
+ this._verdictProps ??= verdictPropsOf(this._activeRules);
3666
+ return this._verdictProps;
3667
+ }
3668
+ /**
3669
+ * Registered nudges, resolved once. Empty when no framework ships one, or
3670
+ * when every framework is flagged off.
3671
+ */
3672
+ get _activeNudges() {
3673
+ this._nudges ??= Array.from(this.std.provider.getAll(QualityNudgeIdentifier).values());
3674
+ return this._nudges;
3675
+ }
3676
+ /**
3677
+ * Registered root-instance declarations, resolved once. Empty when every
3678
+ * framework derives its frames from its rules, which is all of them but the
3679
+ * ones that ship no rule at all — see {@link ValidationFrameworkDef}.
3680
+ */
3681
+ get _activeFrameworks() {
3682
+ this._frameworks ??= Array.from(this.std.provider.getAll(ValidationFrameworkIdentifier).values());
3683
+ return this._frameworks;
3684
+ }
3685
+ /**
3686
+ * Registered profiles, resolved once. Empty when no framework ships one, in
3687
+ * which case every rule is judged by its own declared severity.
3688
+ */
3689
+ get _activeProfiles() {
3690
+ this._profiles ??= Array.from(this.std.provider.getAll(ValidationProfileIdentifier).values());
3691
+ return this._profiles;
3692
+ }
3693
+ get _overlay() {
3694
+ return this.std.getOptional(OverlayIdentifier(ValidationOverlay.overlayName));
3695
+ }
3696
+ mounted() {
3697
+ // No rule registered => never subscribe, never evaluate. The cost of
3698
+ // validation on a board where nothing can be evaluated is this single
3699
+ // length check, once.
3700
+ //
3701
+ // Deliberately about the RULES alone, and not about the root-instance
3702
+ // declarations that also feed `frameworksOf`: what the subscriptions drive
3703
+ // is the evaluation, and a framework that registers no rule has nothing to
3704
+ // evaluate however many frames it declares. Its tooling reaches the user
3705
+ // through getters nobody subscribes to — `nudgesFor` for the Map quality
3706
+ // popup, `profilesFor` for the toolbar dropdown — which are called on the
3707
+ // element in hand at the moment of the gesture, and answer the same thing
3708
+ // whether or not this method ever ran.
3709
+ if (this._activeRules.length === 0)
3710
+ return;
3711
+ // The surface is a SIGNAL, not a fact: it can legitimately be null at
3712
+ // mount and arrive later, and it is replaced if the surface block is. A
3713
+ // one-shot peek here would leave validation silently dead for the whole
3714
+ // session — hence tracking it rather than reading it once.
3715
+ this._disposeSurfaceEffect = effect(() => {
3716
+ this._resubscribe(this.gfx.surface$.value);
3717
+ });
3718
+ }
3719
+ unmounted() {
3720
+ if (this._pending)
3721
+ clearTimeout(this._pending);
3722
+ this._pending = null;
3723
+ this._disposeSurfaceEffect?.();
3724
+ this._disposeSurfaceEffect = null;
3725
+ this._unsubscribe();
3726
+ this._dirty.clear();
3727
+ this._evaluated = false;
3728
+ this._backgrounds = new Set();
3729
+ this.timeline.clear();
3730
+ // Supersede any check-up still yielding between two rules, and forget the
3731
+ // last one: both are session state, and a stale answer outliving the
3732
+ // surface it was measured on is a panel that lies.
3733
+ this._checkupGeneration += 1;
3734
+ this.checkup$.value = null;
3735
+ this.mapQualityFor$.value = null;
3736
+ // Session state, and the session is over. An audit describes a board at a
3737
+ // moment; carrying one across a remount would show it against a different
3738
+ // surface (PF14.1).
3739
+ this.auditFindings$.value = [];
3740
+ super.unmounted();
3741
+ }
3742
+ _unsubscribe() {
3743
+ for (const subscription of this._subscriptions)
3744
+ subscription.unsubscribe();
3745
+ this._subscriptions = [];
3746
+ }
3747
+ _resubscribe(surface) {
3748
+ this._unsubscribe();
3749
+ if (!surface)
3750
+ return;
3751
+ this._subscriptions.push(surface.elementAdded.subscribe(({ id }) => this._schedule(id)));
3752
+ this._subscriptions.push(surface.elementRemoved.subscribe(({ id }) => {
3753
+ // A check-up measured on a map that no longer exists is an answer about
3754
+ // nothing. The panel is closed by then (the widget watches the same
3755
+ // signal), but the result would otherwise sit in memory waiting to be
3756
+ // shown against whatever the user opens next.
3757
+ this._forgetCheckupOf(id);
3758
+ this._schedule(id);
3759
+ }));
3760
+ this._subscriptions.push(surface.elementUpdated.subscribe(payload => {
3761
+ // A prop that cannot change a verdict must not even rearm the timer.
3762
+ if (!touchesVerdict(payload, this._watchedProps))
3763
+ return;
3764
+ this._schedule(payload.id);
3765
+ }));
3766
+ // A new surface knows nothing about the last one: judge it in full.
3767
+ this.evaluate();
3768
+ }
3769
+ _schedule(id) {
3770
+ this._dirty.add(id);
3771
+ if (this._pending)
3772
+ clearTimeout(this._pending);
3773
+ this._pending = setTimeout(() => {
3774
+ this._pending = null;
3775
+ this.evaluate(true);
3776
+ }, VALIDATION_DELAY_MS);
3777
+ }
3778
+ /**
3779
+ * Evaluate now. Exposed so a host (and the bench) can drive it directly.
3780
+ *
3781
+ * `incremental` is the DEBOUNCED path's privilege and nobody else's: it is
3782
+ * the only caller that knows exactly which elements moved since the last
3783
+ * verdict. Everything else — a gesture that must land on the spot, a surface
3784
+ * arriving, a host asking — pays for a full pass, because a full pass is
3785
+ * always right and a wrong dirty set is a board that lies.
3786
+ */
3787
+ evaluate(incremental = false) {
3788
+ const rules = this._activeRules;
3789
+ const surface = this.gfx.surface;
3790
+ // Whatever happens below, what accumulated is now accounted for: a dirty
3791
+ // set left behind would be replayed against a later, unrelated snapshot.
3792
+ const dirty = this._dirty;
3793
+ this._dirty = new Set();
3794
+ if (rules.length === 0 || !surface)
3795
+ return;
3796
+ const previous = this.violations$.peek();
3797
+ // A frame that WAS there and is now dirty invalidates attributions no
3798
+ // pair-wise re-test would reach — including, when its profile silenced the
3799
+ // rule, findings that were never recorded for the family to find.
3800
+ const frameTouched = [...dirty].some(id => this._backgrounds.has(id));
3801
+ const violations = evaluateRules(rules, surface.elementModels, this._activeProfiles, incremental && this._evaluated && dirty.size > 0 && !frameTouched
3802
+ ? { dirty, previous }
3803
+ : undefined);
3804
+ this._evaluated = true;
3805
+ this._backgrounds = backgroundElementIds(rules, surface.elementModels);
3806
+ // Stay silent when nothing changed: `violations$` is the seam a host panel
3807
+ // subscribes to, and a clean board must not wake it on every debounce tick.
3808
+ if (violations.length === 0 && this.violations$.peek().length === 0)
3809
+ return;
3810
+ // Age the marks BEFORE waking anybody, so the canvas half and the DOM half
3811
+ // read the same clock within one evaluation and never disagree about which
3812
+ // of the two markers is due.
3813
+ //
3814
+ // Exempted findings are dropped HERE and only here: `violations$` keeps
3815
+ // them, so the bubble and a host panel see the full picture, while the
3816
+ // flash and the bracket see only what is still asking for something.
3817
+ const shown = liveViolations(userFacingViolations(violations));
3818
+ this.timeline.sync(shown, performance.now());
3819
+ this.violations$.value = violations;
3820
+ this._overlay?.setViolations(shown, this.timeline);
3821
+ }
3822
+ /** The registered rule with this id, if its framework is enabled. */
3823
+ ruleOf(ruleId) {
3824
+ return this._activeRules.find(rule => rule.id === ruleId);
3825
+ }
3826
+ /**
3827
+ * The profiles selectable on `element`, i.e. those of every framework it is a
3828
+ * ROOT INSTANCE of ({@link frameworksOf}).
3829
+ *
3830
+ * Gated for free: flag off, nothing registered, no framework, no profile
3831
+ * offered. A framework that ships profiles and is a root instance of nothing —
3832
+ * neither a rule framing against the role nor a declaration naming it —
3833
+ * offers nothing to choose between, correctly.
3834
+ *
3835
+ * Empty for a neutral element, and for a background authored before its role
3836
+ * existed: no role, no framework, no profile.
3837
+ */
3838
+ profilesFor(element) {
3839
+ const frameworks = this.frameworksOf(element);
3840
+ if (frameworks.size === 0)
3841
+ return [];
3842
+ return this._activeProfiles.filter(profile => frameworks.has(profile.framework));
3843
+ }
3844
+ /**
3845
+ * The frameworks `element` is a ROOT INSTANCE of — those with a registered
3846
+ * rule measuring against a background carrying this element's role, and those
3847
+ * that declared the role outright ({@link ValidationFrameworkDef}).
3848
+ *
3849
+ * The one test that decides whether an element is a framework's frame, and the
3850
+ * only one: every per-instance surface (the profile picker, the nudge
3851
+ * checklist, the check-up) agrees about what a root instance is without any of
3852
+ * them saying so twice. Both sources are registered from the flag-gated view
3853
+ * extension, so it stays gated for free — flag off, nothing registered, no
3854
+ * framework.
3855
+ *
3856
+ * Empty for a neutral element, and for a background authored before its role
3857
+ * existed.
3858
+ */
3859
+ frameworksOf(element) {
3860
+ return frameworksOfRole(element.role, this._activeRules, this._activeFrameworks);
3861
+ }
3862
+ /**
3863
+ * The nudges offered on `element` (PF7.10), in declared order.
3864
+ *
3865
+ * Same derivation as {@link profilesFor} and gated the same way: a framework
3866
+ * that registers no nudge — or whose flag is off — offers none, and the ids
3867
+ * already ticked on the element stay written, unread, until it comes back.
3868
+ */
3869
+ nudgesFor(element) {
3870
+ const frameworks = this.frameworksOf(element);
3871
+ if (frameworks.size === 0)
3872
+ return [];
3873
+ return this._activeNudges
3874
+ .filter(nudge => frameworks.has(nudge.framework))
3875
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
3876
+ }
3877
+ /** The on-demand rules a check-up on `element` would walk (PF5.14). */
3878
+ checkupRulesFor(element) {
3879
+ const frameworks = this.frameworksOf(element);
3880
+ if (frameworks.size === 0)
3881
+ return [];
3882
+ return onDemandRules(this._activeRules).filter(rule => frameworks.has(rule.framework));
3883
+ }
3884
+ /**
3885
+ * Whether `element` has a Map quality panel to open at all: a framework of
3886
+ * its own declaring a NUDGE.
3887
+ *
3888
+ * The panel is the checklist and nothing else since the PO recette of
3889
+ * 02/08/2026, so this asks for the checklist and nothing else — a framework
3890
+ * that declared only on-demand rules would otherwise be offered a panel with
3891
+ * nothing in it. {@link checkupRulesFor} and {@link runCheckup} are unchanged
3892
+ * and unrelated: a host can still ask for a check-up, it simply is not what
3893
+ * this panel is.
3894
+ *
3895
+ * Generic by construction (PF7.11) — nothing here names a framework. A second
3896
+ * framework shipping nudges gets the entry and the panel with no code written
3897
+ * anywhere.
3898
+ */
3899
+ hasMapQuality(element) {
3900
+ return this.nudgesFor(element).length > 0;
3901
+ }
3902
+ /**
3903
+ * Open the Map quality panel on `element`; the widget draws it.
3904
+ *
3905
+ * Opening on a DIFFERENT instance forgets the last check-up. The panel already
3906
+ * refuses to render a run belonging to another map ({@link CheckupRun.backgroundId}),
3907
+ * so this is not what makes the display correct — it is what stops a result
3908
+ * measured on a map the user has moved on from (or deleted) sitting in memory
3909
+ * waiting to be shown again. Reopening on the SAME map keeps its stamp, which
3910
+ * is the one case where "last check-up: 01:51" is worth reading.
3911
+ */
3912
+ openMapQuality(element) {
3913
+ this._forgetCheckupOf(element.id, true);
3914
+ this.mapQualityFor$.value = element.id;
3915
+ }
3916
+ closeMapQuality() {
3917
+ this.mapQualityFor$.value = null;
3918
+ }
3919
+ /**
3920
+ * Drop the current check-up when it is about `backgroundId` — or, with
3921
+ * `unless`, when it is about anything else.
3922
+ *
3923
+ * The two callers are the two ways a run stops being about something the user
3924
+ * can look at: the instance it measured was deleted, and the panel moved to a
3925
+ * different instance.
3926
+ */
3927
+ _forgetCheckupOf(backgroundId, unless = false) {
3928
+ const run = this.checkup$.peek();
3929
+ if (run === null)
3930
+ return;
3931
+ if ((run.backgroundId === backgroundId) !== unless) {
3932
+ this.checkup$.value = null;
3933
+ }
3934
+ }
3935
+ /**
3936
+ * Run the check-up on `element` (PF5.14).
3937
+ *
3938
+ * ## It is about ONE instance
3939
+ *
3940
+ * The walk covers the whole surface — that is where the elements are — but the
3941
+ * ANSWER is narrowed to the map that was asked about, on
3942
+ * {@link Violation.backgroundId}, which every family measuring against a frame
3943
+ * already records. A board carrying two Wardley maps holds two independent
3944
+ * answers, and handing the panel a tally over both would be exactly the
3945
+ * whole-surface count {@link evaluateMajorityFact} goes out of its way not to
3946
+ * compute. Narrowed HERE and not at the rendering: a run that reaches a host,
3947
+ * a report or the agent has to already be about one map.
3948
+ *
3949
+ * ## Why this is async, for two rules that take a microsecond
3950
+ *
3951
+ * Because the moment exists precisely so that a framework may ship a control
3952
+ * that does NOT fit a frame. A check-up walks the surface with no dirty set
3953
+ * and no budget over its head, and the honest way to offer that is to be
3954
+ * interruptible: the driver evaluates ONE RULE at a time — the smallest slice
3955
+ * that has a meaning — and yields the thread whenever the slice has held it
3956
+ * for {@link checkupSliceMs}. The panel sees `done` climb towards `total`, so
3957
+ * progress is the same value the results arrive in and there is no second
3958
+ * signal to keep in step.
3959
+ *
3960
+ * A run started while another is still yielding SUPERSEDES it: the older one
3961
+ * notices its generation is stale on its next slice and drops what it had.
3962
+ * Clicking twice therefore gives the second answer, never a race between two.
3963
+ *
3964
+ * ## A rule that throws
3965
+ *
3966
+ * ...ends the run, visibly. The failure must not leave the panel believing a
3967
+ * check-up is in flight — that reads as "Checking…" for ever and disables the
3968
+ * one button that could try again, on every map, until the editor is
3969
+ * remounted. So the run is finished with {@link CheckupRun.error} and whatever
3970
+ * it had managed to collect.
3971
+ *
3972
+ * @returns the finished run, or `null` if it was superseded or there was
3973
+ * nothing to run.
3974
+ */
3975
+ async runCheckup(element) {
3976
+ const rules = this.checkupRulesFor(element);
3977
+ const generation = ++this._checkupGeneration;
3978
+ const backgroundId = element.id;
3979
+ if (rules.length === 0) {
3980
+ this.checkup$.value = null;
3981
+ return null;
3982
+ }
3983
+ const at = Date.now();
3984
+ const results = [];
3985
+ const total = rules.length;
3986
+ let sliceStart = performance.now();
3987
+ this.checkup$.value = { backgroundId, at, results: [], done: 0, total };
3988
+ for (let i = 0; i < total; i++) {
3989
+ // The surface is re-read on every slice: the user can keep drawing while
3990
+ // this runs, and a rule must be judged against the board as it is now,
3991
+ // not against a snapshot taken before the last yield.
3992
+ const surface = this.gfx.surface;
3993
+ if (!surface || generation !== this._checkupGeneration)
3994
+ return null;
3995
+ try {
3996
+ for (const remark of evaluateCheckup([rules[i]], surface.elementModels, this._activeProfiles)) {
3997
+ // This map's remarks, and only this map's. A family that measures
3998
+ // against no frame records no `backgroundId` and is dropped: it has
3999
+ // said nothing about the instance the user is looking at.
4000
+ if (remark.backgroundId === backgroundId)
4001
+ results.push(remark);
4002
+ }
4003
+ }
4004
+ catch (error) {
4005
+ console.error(`[validation] check-up rule "${rules[i].id}" threw`, error);
4006
+ const failed = {
4007
+ backgroundId,
4008
+ at,
4009
+ results: [...results],
4010
+ // Finished, deliberately: see the note above. A locked button is a
4011
+ // worse failure than a reported one.
4012
+ done: total,
4013
+ total,
4014
+ error: true,
4015
+ };
4016
+ this.checkup$.value = failed;
4017
+ return failed;
4018
+ }
4019
+ this.checkup$.value = {
4020
+ backgroundId,
4021
+ at,
4022
+ results: [...results],
4023
+ done: i + 1,
4024
+ total,
4025
+ };
4026
+ if (i + 1 < total &&
4027
+ performance.now() - sliceStart > this.checkupSliceMs) {
4028
+ // Back of the queue, so paint and input go first. `setTimeout(0)` and
4029
+ // not a microtask: a microtask would yield the call stack and nothing
4030
+ // else, which is exactly the freeze this exists to avoid.
4031
+ await new Promise(resolve => setTimeout(resolve, 0));
4032
+ sliceStart = performance.now();
4033
+ }
4034
+ }
4035
+ return generation === this._checkupGeneration ? this.checkup$.peek() : null;
4036
+ }
4037
+ /**
4038
+ * The profile `element` is actually checked against — the one it names, or
4039
+ * its framework's default. `undefined` when it is not a root instance of any
4040
+ * enabled framework, or when its framework ships no profile.
4041
+ */
4042
+ profileOf(element) {
4043
+ const available = this.profilesFor(element);
4044
+ const named = available.find(profile => profile.id === element.validationProfile);
4045
+ if (named)
4046
+ return named;
4047
+ const framework = available[0]?.framework;
4048
+ return framework === undefined
4049
+ ? undefined
4050
+ : defaultProfileOf(available, framework);
4051
+ }
4052
+ /**
4053
+ * Put `element` on `profileId`, and re-judge the board on the spot.
4054
+ *
4055
+ * Choosing the DEFAULT clears the key rather than writing it, so a background
4056
+ * that never left the default stays byte-identical to one created before
4057
+ * profiles existed — and a user who tries strict and comes back leaves no
4058
+ * trace. Everything else is one flat string in the same `@field()` the role
4059
+ * and the exceptions live in: it syncs, it undoes, it survives a copy and an
4060
+ * export, with no block schema change.
4061
+ *
4062
+ * Exceptions are deliberately left alone. A level of requirement is not an
4063
+ * amnesty: raising it must not resurrect decisions the user made, and
4064
+ * lowering it must not quietly delete them (PF9.3).
4065
+ *
4066
+ * @returns whether the document actually changed.
4067
+ */
4068
+ setProfile(element, profileId) {
4069
+ // The write below goes through a `@field()` accessor, i.e. `store.transact`,
4070
+ // which has no readonly guard of its own. A readonly board arbitrates
4071
+ // nothing — and `false` keeps the caller's telemetry silent too.
4072
+ if (this.std.store.readonly)
4073
+ return false;
4074
+ const available = this.profilesFor(element);
4075
+ const target = available.find(profile => profile.id === profileId);
4076
+ // Never write a profile nobody registered, nor one belonging to a framework
4077
+ // this element is not a background of.
4078
+ if (!target)
4079
+ return false;
4080
+ const isDefault = defaultProfileOf(available, target.framework)?.id === target.id;
4081
+ const next = isDefault ? undefined : target.id;
4082
+ if ((element.validationProfile ?? undefined) === next)
4083
+ return false;
4084
+ if (next === undefined)
4085
+ element.clearField('validationProfile');
4086
+ else
4087
+ element.validationProfile = next;
4088
+ // The 120 ms debounce would get there on its own; the gesture has to land
4089
+ // immediately, exactly like an exception does.
4090
+ this.evaluate();
4091
+ return true;
4092
+ }
4093
+ /**
4094
+ * Which elements an exception of the given scope is written on.
4095
+ *
4096
+ * `element` — the elements the rule actually indicts, which is what makes the
4097
+ * exception local (PF8.2): excusing one component says nothing about the next.
4098
+ *
4099
+ * `map` — the background element these findings were MEASURED AGAINST, and
4100
+ * only that one. It comes from {@link Violation.backgroundId}, recorded by the
4101
+ * rule family when it picked the frame, so a board carrying three maps holds
4102
+ * three independent arbitrations and a gesture made on one writes on one.
4103
+ * That choice is what lets a map-wide arbitration exist with NO block schema
4104
+ * change and no hidden per-document store: it is one more value in the same
4105
+ * `@field()`, on an element that is selected, copied, exported and deleted
4106
+ * like any other. A family that measures against no background records no
4107
+ * `backgroundId`, has no map to hang an exception on, and offers no map scope.
4108
+ */
4109
+ _targetsOf(violations, scope, surface) {
4110
+ const ids = scope === 'map'
4111
+ ? new Set(violations
4112
+ .map(violation => violation.backgroundId)
4113
+ .filter((id) => id !== undefined))
4114
+ : new Set(violations.flatMap(violation => violation.elementIds));
4115
+ return Array.from(ids)
4116
+ .map(id => surface.getElementById(id))
4117
+ .filter((el) => el !== null);
4118
+ }
4119
+ /**
4120
+ * Grant or revoke `ruleId`'s exception at `scope`, over the elements the given
4121
+ * violations indict.
4122
+ *
4123
+ * Takes the whole set rather than one violation on purpose: a Wardley
4124
+ * component is a GROUP, so one bubble line can stand for several indicted
4125
+ * members, and one click has to settle all of them.
4126
+ *
4127
+ * Re-evaluates on the spot instead of waiting for the 120 ms debounce the
4128
+ * field write would trigger anyway — the gesture must apply immediately
4129
+ * (PF8.1).
4130
+ *
4131
+ * @returns the elements actually written to, so the caller can tell a real
4132
+ * arbitration from a no-op and only report the former.
4133
+ */
4134
+ setException(violations, scope, granted, author) {
4135
+ // Same transact hole as `setProfile`; an empty return is what keeps the
4136
+ // bubble from emitting Granted/Revoked telemetry for a write that never
4137
+ // happened.
4138
+ if (this.std.store.readonly)
4139
+ return [];
4140
+ const ruleId = violations[0]?.ruleId;
4141
+ const surface = this.gfx.surface;
4142
+ if (ruleId === undefined || !surface)
4143
+ return [];
4144
+ // Never write an exception for a rule nobody registered: with the framework
4145
+ // flagged off there is no rule to arbitrate on.
4146
+ if (!this.ruleOf(ruleId))
4147
+ return [];
4148
+ const targets = this._targetsOf(violations, scope, surface).filter(element => hasException(element, ruleId) !== granted);
4149
+ if (targets.length === 0)
4150
+ return [];
4151
+ // One timestamp for the whole gesture: a single click is a single decision,
4152
+ // whatever number of elements it happens to touch.
4153
+ const at = Date.now();
4154
+ for (const element of targets) {
4155
+ if (granted)
4156
+ grantException(element, ruleId, author, at);
4157
+ else
4158
+ revokeException(element, ruleId);
4159
+ }
4160
+ this.evaluate();
4161
+ return targets;
4162
+ }
4163
+ /**
4164
+ * The exceptions on `element` that can still be taken back — those it answers
4165
+ * for ({@link exceptionsAnchoredOn}) whose rule is actually REGISTERED.
4166
+ *
4167
+ * The rule filter is what makes the menu entry disappear with the framework:
4168
+ * flag off, no rule reaches the container, so there is nothing to arbitrate
4169
+ * on and nothing to offer. The exceptions themselves are untouched — they are
4170
+ * document data and outlive the tooling (PF8.6).
4171
+ */
4172
+ revocableExceptionsOn(element) {
4173
+ const surface = this.gfx.surface;
4174
+ if (!surface)
4175
+ return [];
4176
+ return exceptionsAnchoredOn(element, surface).filter(({ ruleId }) => this.ruleOf(ruleId) !== undefined);
4177
+ }
4178
+ /**
4179
+ * Take back every exception `element` answers for, in one gesture.
4180
+ *
4181
+ * @returns one entry per (rule, scope) actually written, so the caller can
4182
+ * report the arbitration and tell a real one from a no-op.
4183
+ */
4184
+ revokeExceptionsOn(element) {
4185
+ // Same transact hole as `setProfile`, same telemetry contract: no entry
4186
+ // returned, nothing reported.
4187
+ if (this.std.store.readonly)
4188
+ return [];
4189
+ const anchored = this.revocableExceptionsOn(element);
4190
+ if (anchored.length === 0)
4191
+ return [];
4192
+ const reported = new Map();
4193
+ for (const { element: target, ruleId } of anchored) {
4194
+ const rule = this.ruleOf(ruleId);
4195
+ if (!rule)
4196
+ continue;
4197
+ revokeException(target, ruleId);
4198
+ // An exception written on the framework's own background IS the map-wide
4199
+ // one — the same test `applyExceptions` uses to read it back.
4200
+ const scope = rule.backgroundRole !== undefined &&
4201
+ target.role !== undefined &&
4202
+ roleIsA(target.role, rule.backgroundRole, rule.roles)
4203
+ ? 'map'
4204
+ : 'element';
4205
+ const key = `${ruleId}|${scope}`;
4206
+ const entry = reported.get(key);
4207
+ if (entry)
4208
+ entry.elementCount += 1;
4209
+ else {
4210
+ reported.set(key, {
4211
+ ruleId,
4212
+ framework: rule.framework,
4213
+ scope,
4214
+ elementCount: 1,
4215
+ });
4216
+ }
4217
+ }
4218
+ this.evaluate();
4219
+ return Array.from(reported.values());
4220
+ }
4221
+ }
4222
+ /**
4223
+ * Where a mark is DRAWN for one violating element: the outermost canvas group
4224
+ * containing it, or the element itself when it is not grouped.
4225
+ *
4226
+ * A RENDERING decision, not a semantic one. The violation object keeps naming
4227
+ * the elements actually at fault (`elementIds`); the group never appears in it,
4228
+ * is never evaluated, and carries no role. This exists because a mark drawn on
4229
+ * a lone member — a Wardley component is a group of {node, label} — collides
4230
+ * with the group's own selection rect and becomes unreadable.
4231
+ *
4232
+ * Frames are deliberately skipped: they are group-compatible BLOCKS and appear
4233
+ * in the group chain, but framing a whole frame would be a wild over-reach.
4234
+ */
4235
+ function anchorOf(elementId, surface) {
4236
+ const element = surface.getElementById(elementId);
4237
+ if (!element)
4238
+ return null;
4239
+ // `getGroups` walks outward, so the last CANVAS group is the outermost one.
4240
+ const canvasGroups = surface
4241
+ .getGroups(elementId)
4242
+ .filter(group => group instanceof GfxGroupLikeElementModel);
4243
+ return canvasGroups[canvasGroups.length - 1] ?? element;
4244
+ }
4245
+ /**
4246
+ * The MIDDLE of a drawn path, by arc length — not the middle point of the
4247
+ * array, which on an elbowed connector is wherever the router happened to put a
4248
+ * bend.
4249
+ */
4250
+ function pathMidpoint(path) {
4251
+ if (path.length === 0)
4252
+ return null;
4253
+ if (path.length === 1)
4254
+ return [path[0][0], path[0][1]];
4255
+ let total = 0;
4256
+ for (let i = 1; i < path.length; i++) {
4257
+ total += Math.hypot(path[i][0] - path[i - 1][0], path[i][1] - path[i - 1][1]);
4258
+ }
4259
+ // A degenerate path (every point on top of the last) has no middle to find;
4260
+ // any of its points is the answer.
4261
+ if (total === 0)
4262
+ return [path[0][0], path[0][1]];
4263
+ let walked = 0;
4264
+ for (let i = 1; i < path.length; i++) {
4265
+ const from = path[i - 1];
4266
+ const to = path[i];
4267
+ const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
4268
+ if (walked + length >= total / 2) {
4269
+ const t = length === 0 ? 0 : (total / 2 - walked) / length;
4270
+ return [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];
4271
+ }
4272
+ walked += length;
4273
+ }
4274
+ return [path[path.length - 1][0], path[path.length - 1][1]];
4275
+ }
4276
+ /**
4277
+ * Whether the mark on this element belongs on the MIDDLE of a link rather than
4278
+ * on a corner of a box.
4279
+ *
4280
+ * Two independent answers, either of which is enough, because they are the two
4281
+ * ways an edge can be one:
4282
+ *
4283
+ * 1. its ROLE says so — a declared role of `kind: 'edge'`, which is the
4284
+ * semantic answer and the one a framework owns;
4285
+ * 2. its GEOMETRY says so — the element exposes a path ({@link elementPath}),
4286
+ * which covers a generalist connector carrying no role at all, and an
4287
+ * element whose framework is not loaded.
4288
+ *
4289
+ * Vocabularies are optional: a caller that has none (the canvas overlay, which
4290
+ * only ever reads `bound`) still gets the geometric answer.
4291
+ */
4292
+ function anchorKindOf(element, vocabularies) {
4293
+ // A GROUP is never an edge, whatever it contains: the mark frames the group's
4294
+ // box, and the middle of that box is not on any of its members.
4295
+ if (element instanceof GfxGroupLikeElementModel)
4296
+ return 'node';
4297
+ const role = element.role;
4298
+ for (const defs of vocabularies) {
4299
+ const def = role === undefined ? undefined : defs[role];
4300
+ if (def?.kind === 'edge')
4301
+ return 'edge';
4302
+ }
4303
+ return elementPath(element) !== null ? 'edge' : 'node';
4304
+ }
4305
+ /**
4306
+ * Where the badge for `element` is pinned, in model space.
4307
+ *
4308
+ * Exported so a test can assert the coordinate rather than the rendering, and
4309
+ * so the widget converts ONE point instead of reproducing the offsets.
4310
+ */
4311
+ function markPointOf(element, kind, bound) {
4312
+ if (kind === 'edge') {
4313
+ const middle = pathMidpoint(elementPath(element) ?? []);
4314
+ // The intersection of the rectangle's diagonals — its centre — for an edge
4315
+ // the layout has not routed yet.
4316
+ return middle ?? [bound.x + bound.w / 2, bound.y + bound.h / 2];
4317
+ }
4318
+ // Outside the corner by the mark's gap plus half a badge, so it does not land
4319
+ // under the selected-rect north-east resize handle.
4320
+ const offset = VIOLATION_MARK_PADDING + VIOLATION_BADGE_SIZE / 2;
4321
+ return [bound.maxX + offset, bound.y - offset];
4322
+ }
4323
+ /**
4324
+ * Group violations by the anchor their mark is drawn on — one entry per
4325
+ * anchor, so several violating members of the same group share a single
4326
+ * bracket, a single badge and a single bubble.
4327
+ *
4328
+ * Pure and stateless, and called at paint time by both halves of the
4329
+ * affordance, so the mark follows a group that moves and falls back onto the
4330
+ * element the moment the group is dissolved, with nothing to invalidate.
4331
+ *
4332
+ * @param vocabularies the registered role vocabularies, for the SEMANTIC half
4333
+ * of "is this an edge" ({@link anchorKindOf}). Optional: a caller that only
4334
+ * reads `bound` — the canvas overlay draws the same bracket either way — needs
4335
+ * none, and still gets the geometric answer.
4336
+ */
4337
+ export function resolveViolationAnchors(violations, surface, vocabularies = []) {
4338
+ const anchors = new Map();
4339
+ for (const violation of violations) {
4340
+ for (const id of violation.elementIds) {
4341
+ const anchor = anchorOf(id, surface);
4342
+ if (!anchor)
4343
+ continue;
4344
+ let entry = anchors.get(anchor.id);
4345
+ if (!entry) {
4346
+ const bound = anchor.elementBound;
4347
+ const kind = anchorKindOf(anchor, vocabularies);
4348
+ entry = {
4349
+ id: anchor.id,
4350
+ bound,
4351
+ kind,
4352
+ markAt: markPointOf(anchor, kind, bound),
4353
+ violations: [],
4354
+ };
4355
+ anchors.set(anchor.id, entry);
4356
+ }
4357
+ // A single violation naming two members of one group resolves to that
4358
+ // group twice and must be recorded once. Two SEPARATE violations of the
4359
+ // same rule stay separate here — collapsing what is DISPLAYED is the
4360
+ // bubble's call, not this function's.
4361
+ if (!entry.violations.includes(violation)) {
4362
+ entry.violations.push(violation);
4363
+ }
4364
+ }
4365
+ }
4366
+ return Array.from(anchors.values());
4367
+ }
4368
+ /**
4369
+ * Violations the drawing user is meant to SEE. `audit` is collected for
4370
+ * reporting and stays invisible on the canvas — that is what the severity
4371
+ * means (see {@link ViolationSeverity}), and the UI is the only place that can
4372
+ * honour it: the engine reports everything, unfiltered, to the host panel.
4373
+ */
4374
+ export function userFacingViolations(violations) {
4375
+ return violations.filter(violation => violation.severity !== 'audit');
4376
+ }
4377
+ /**
4378
+ * Violations still asking for something, i.e. not covered by an exception.
4379
+ *
4380
+ * This is the whole of PF8's canvas behaviour: an exempted finding is dropped
4381
+ * from the timeline, so it never flashes, and from the overlay, so it carries no
4382
+ * bracket. It keeps its badge and its line in the bubble, where it reads as
4383
+ * "exception" and can be revoked — the state changed, the finding did not
4384
+ * disappear.
4385
+ */
4386
+ export function liveViolations(violations) {
4387
+ return violations.filter(violation => violation.exemption === undefined);
4388
+ }
4389
+ /**
4390
+ * One violation per RULE, first occurrence wins.
4391
+ *
4392
+ * What a detail bubble renders is rule-level — a label, a severity, a hint —
4393
+ * so two components of the same group both drawn off the map, which are two
4394
+ * violations, would produce the same sentence twice. The engine is right to
4395
+ * report both (it names the elements); a list that repeats itself is just
4396
+ * noise. Which member moved is on the canvas, not in a list.
4397
+ */
4398
+ export function distinctByRule(violations) {
4399
+ const byRule = new Map();
4400
+ for (const violation of violations) {
4401
+ const kept = byRule.get(violation.ruleId);
4402
+ // A LIVE finding always wins the slot: two members of one group, one
4403
+ // excused and one not, must read as "this rule still applies here" rather
4404
+ // than as an exception that quietly covers both.
4405
+ if (kept === undefined || (kept.exemption && !violation.exemption)) {
4406
+ byRule.set(violation.ruleId, violation);
4407
+ }
4408
+ }
4409
+ return Array.from(byRule.values());
4410
+ }
4411
+ /**
4412
+ * How loudly an ANCHOR is drawn: the loudest of the violations sharing it, so
4413
+ * a fresh finding is not muted by an older one on the same group.
4414
+ *
4415
+ * The single arbiter between the two markers. `> 0` means the bracket still has
4416
+ * the anchor; exactly `0` means it is done and the badge takes over. Both
4417
+ * halves call this, on the same timeline, so they can never both claim it.
4418
+ */
4419
+ export function anchorEmphasis(anchor, timeline, now) {
4420
+ let emphasis = 0;
4421
+ for (const violation of anchor.violations) {
4422
+ emphasis = Math.max(emphasis, timeline.emphasis(violation, now));
4423
+ }
4424
+ return emphasis;
4425
+ }
4426
+ /**
4427
+ * Mark geometry in MODEL units, deliberately — the affordance zooms with the
4428
+ * board like any element on it.
4429
+ *
4430
+ * The house convention for annotation overlays is the opposite (divide by the
4431
+ * zoom, keep a constant size on screen), and that is right for a snap guide,
4432
+ * which is transient and never numerous. It is wrong here. The dimensioning
4433
+ * case is a hundred-component map, zoomed out: screen-constant marks grow
4434
+ * relative to the content they annotate until the brackets are all you can see
4435
+ * and the map underneath is unreadable. Zoomed out far enough these become
4436
+ * small along with everything else, and that is the point — a board you cannot
4437
+ * read is a worse answer than a mark you have to zoom in to inspect.
4438
+ *
4439
+ * The one exception is the CLICK target, which lives in DOM and keeps a screen
4440
+ * minimum so it stays reachable by thumb (see the widget). Invisible padding
4441
+ * around a model-sized visual: the pattern `edgeless-auto-complete` already
4442
+ * uses on this canvas.
4443
+ */
4444
+ const MARK_CORNER = 10;
4445
+ const MARK_LINE_WIDTH = 2;
4446
+ /**
4447
+ * Gap between the anchor's bounds and the mark, in MODEL units. Exported
4448
+ * because the badge sits ON the bracket's top-right corner and has to use the
4449
+ * same gap to land there.
4450
+ */
4451
+ export const VIOLATION_MARK_PADDING = 6;
4452
+ /** Diameter of the persistent badge, in MODEL units. */
4453
+ export const VIOLATION_BADGE_SIZE = 16;
4454
+ /** The one amber the affordance is drawn in, canvas and DOM alike. */
4455
+ export const VIOLATION_MARK_COLOR = '#f5a623';
4456
+ /**
4457
+ * The EPHEMERAL half of the PF7 affordance: amber corner brackets that flash on
4458
+ * the anchor of a violation the moment it appears, hold for a few seconds, then
4459
+ * fade out (see {@link ViolationTimeline}). What stays behind afterwards is the
4460
+ * badge — `affine-violation-detail-widget`, a DOM sibling of this overlay.
4461
+ *
4462
+ * The handover is strict: an anchor whose emphasis has reached zero is skipped
4463
+ * here and picked up by the badge, so the two markers are never on screen at
4464
+ * the same time. {@link anchorEmphasis} is the single arbiter, read from the
4465
+ * one timeline the manager owns.
4466
+ *
4467
+ * An overlay rather than a renderer change: it touches no element model, writes
4468
+ * nothing to the document, creates no undo entry, and disappears with the rule
4469
+ * that produced it. The fade is session state too — nothing about "when did I
4470
+ * first see this" ever reaches the document.
4471
+ *
4472
+ * The mark is anchored on the outermost enclosing canvas group rather than on
4473
+ * the bare element — see {@link resolveMarkAnchors}. Evaluation is untouched by
4474
+ * this: it stays on the element carrying the role, because its position is what
4475
+ * the rule is about.
4476
+ */
4477
+ export class ValidationOverlay extends Overlay {
4478
+ constructor() {
4479
+ super(...arguments);
4480
+ /**
4481
+ * The violations themselves — NOT their bounds, and not their groups either.
4482
+ * Both are resolved at paint time so the mark tracks what it accuses:
4483
+ * evaluation is debounced, the renderer is not, so a frozen snapshot would
4484
+ * leave the bracket behind at the drag's starting point until 120 ms after
4485
+ * the user let go — and a dissolved group would keep framing nothing.
4486
+ */
4487
+ this._violations = [];
4488
+ /**
4489
+ * Handed in with the violations by {@link ValidationManager}, which owns it —
4490
+ * the DOM badge reads the same instance, and the two markers must never
4491
+ * disagree about whether the bracket is done.
4492
+ */
4493
+ this._timeline = null;
4494
+ /** Armed only while something is still fading. */
4495
+ this._frame = null;
4496
+ /**
4497
+ * Whether the renderer this overlay paints into is gone.
4498
+ *
4499
+ * It matters because the two ends of the affordance are on different
4500
+ * lifecycles: the overlay dies with the surface COMPONENT (the renderer
4501
+ * disposes it), while {@link ValidationManager} lives on the gfx scope and
4502
+ * keeps its subscriptions on the surface MODEL, which outlives the component.
4503
+ * So an evaluation can perfectly well land here after teardown — switch to
4504
+ * page mode, edit the doc — and without this flag it would arm an animation
4505
+ * loop repainting a dead renderer sixty times a second for three seconds.
4506
+ *
4507
+ * Not permanent: the overlay is a DI singleton and is handed a new renderer
4508
+ * if the surface comes back.
4509
+ */
4510
+ this._detached = false;
4511
+ this._onFrame = () => {
4512
+ this._frame = null;
4513
+ this._schedule();
4514
+ };
4515
+ }
4516
+ static { this.overlayName = 'validation'; }
4517
+ /**
4518
+ * Repaint, and keep repainting for as long as a mark is inside its window.
4519
+ *
4520
+ * This is the only clock the affordance runs, and it stops on its own: a
4521
+ * board with no violation, or whose violations have all settled, requests no
4522
+ * animation frame at all.
4523
+ */
4524
+ _schedule() {
4525
+ if (this._detached)
4526
+ return;
4527
+ this.refresh();
4528
+ if (this._frame !== null)
4529
+ return;
4530
+ if (!this._timeline?.isAnimating(performance.now()))
4531
+ return;
4532
+ this._frame = requestAnimationFrame(this._onFrame);
4533
+ }
4534
+ _cancelFrame() {
4535
+ if (this._frame === null)
4536
+ return;
4537
+ cancelAnimationFrame(this._frame);
4538
+ this._frame = null;
4539
+ }
4540
+ _forget() {
4541
+ this._cancelFrame();
4542
+ this._violations = [];
4543
+ // The timeline belongs to the manager and is shared with the badge: drop
4544
+ // the reference, never the contents.
4545
+ this._timeline = null;
4546
+ }
4547
+ /**
4548
+ * @param violations already filtered to what the user is meant to see.
4549
+ * @param timeline the manager's, already synced against these violations.
4550
+ */
4551
+ setViolations(violations, timeline) {
4552
+ if (this._detached)
4553
+ return;
4554
+ this._violations = violations;
4555
+ this._timeline = timeline;
4556
+ this._schedule();
4557
+ }
4558
+ setRenderer(renderer) {
4559
+ this._detached = renderer === null;
4560
+ super.setRenderer(renderer);
4561
+ }
4562
+ clear() {
4563
+ this._forget();
4564
+ super.clear();
4565
+ }
4566
+ dispose() {
4567
+ this._detached = true;
4568
+ this._forget();
4569
+ super.dispose();
4570
+ }
4571
+ render(ctx, _rc) {
4572
+ const timeline = this._timeline;
4573
+ if (!timeline || this._violations.length === 0)
4574
+ return;
4575
+ const now = performance.now();
4576
+ // Everything has settled and the badges have it: the cheapest possible
4577
+ // exit, before a single group chain is walked. The canvas repaints on
4578
+ // every pan, zoom and edit — this overlay must cost nothing between the
4579
+ // rare moments it has something to say.
4580
+ if (!timeline.isAnimating(now))
4581
+ return;
4582
+ const surface = this.gfx.surface;
4583
+ if (!surface)
4584
+ return;
4585
+ // Model units, NOT divided by the zoom: the mark scales with the board.
4586
+ const corner = MARK_CORNER;
4587
+ const padding = VIOLATION_MARK_PADDING;
4588
+ ctx.save();
4589
+ ctx.strokeStyle = VIOLATION_MARK_COLOR;
4590
+ ctx.lineWidth = MARK_LINE_WIDTH;
4591
+ for (const anchor of resolveViolationAnchors(this._violations, surface)) {
4592
+ const emphasis = anchorEmphasis(anchor, timeline, now);
4593
+ // Done fading: the badge has this anchor now, and the two never overlap.
4594
+ if (emphasis <= 0)
4595
+ continue;
4596
+ const { bound } = anchor;
4597
+ const x = bound.x - padding;
4598
+ const y = bound.y - padding;
4599
+ const maxX = bound.maxX + padding;
4600
+ const maxY = bound.maxY + padding;
4601
+ ctx.globalAlpha = emphasis;
4602
+ ctx.beginPath();
4603
+ // Four corner brackets: reads as an annotation, not as a selection box.
4604
+ ctx.moveTo(x + corner, y);
4605
+ ctx.lineTo(x, y);
4606
+ ctx.lineTo(x, y + corner);
4607
+ ctx.moveTo(maxX - corner, y);
4608
+ ctx.lineTo(maxX, y);
4609
+ ctx.lineTo(maxX, y + corner);
4610
+ ctx.moveTo(x, maxY - corner);
4611
+ ctx.lineTo(x, maxY);
4612
+ ctx.lineTo(x + corner, maxY);
4613
+ ctx.moveTo(maxX, maxY - corner);
4614
+ ctx.lineTo(maxX, maxY);
4615
+ ctx.lineTo(maxX - corner, maxY);
4616
+ ctx.stroke();
4617
+ }
4618
+ ctx.restore();
4619
+ }
4620
+ }