@invarn/cibuild 2.9.5 → 2.9.7

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 (553) hide show
  1. package/dist/chunk-35SI42CD.mjs +1 -0
  2. package/dist/chunk-IC45KVLJ.mjs +1 -0
  3. package/dist/chunk-XDY4V23V.mjs +1 -0
  4. package/dist/cli.cjs +14 -14
  5. package/dist/commands.mjs +1 -0
  6. package/dist/lib.mjs +3934 -0
  7. package/package.json +6 -10
  8. package/dist/src/cli.d.ts +0 -3
  9. package/dist/src/cli.d.ts.map +0 -1
  10. package/dist/src/cli.js +0 -399
  11. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts +0 -38
  12. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts.map +0 -1
  13. package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.js +0 -222
  14. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts +0 -41
  15. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts.map +0 -1
  16. package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js +0 -324
  17. package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts +0 -2
  18. package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts.map +0 -1
  19. package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.js +0 -382
  20. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts +0 -29
  21. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts.map +0 -1
  22. package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.js +0 -160
  23. package/dist/src/commands/android-gms-guard.test.d.ts +0 -9
  24. package/dist/src/commands/android-gms-guard.test.d.ts.map +0 -1
  25. package/dist/src/commands/android-gms-guard.test.js +0 -78
  26. package/dist/src/commands/android-java-version.test.d.ts +0 -12
  27. package/dist/src/commands/android-java-version.test.d.ts.map +0 -1
  28. package/dist/src/commands/android-java-version.test.js +0 -209
  29. package/dist/src/commands/android-scanner-java.test.d.ts +0 -2
  30. package/dist/src/commands/android-scanner-java.test.d.ts.map +0 -1
  31. package/dist/src/commands/android-scanner-java.test.js +0 -130
  32. package/dist/src/commands/android-scanner.d.ts +0 -250
  33. package/dist/src/commands/android-scanner.d.ts.map +0 -1
  34. package/dist/src/commands/android-scanner.js +0 -1639
  35. package/dist/src/commands/build.d.ts +0 -215
  36. package/dist/src/commands/build.d.ts.map +0 -1
  37. package/dist/src/commands/build.js +0 -1433
  38. package/dist/src/commands/build.test.d.ts +0 -13
  39. package/dist/src/commands/build.test.d.ts.map +0 -1
  40. package/dist/src/commands/build.test.js +0 -121
  41. package/dist/src/commands/detect-platform.d.ts +0 -13
  42. package/dist/src/commands/detect-platform.d.ts.map +0 -1
  43. package/dist/src/commands/detect-platform.js +0 -51
  44. package/dist/src/commands/detect-project.d.ts +0 -7
  45. package/dist/src/commands/detect-project.d.ts.map +0 -1
  46. package/dist/src/commands/detect-project.js +0 -12
  47. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts +0 -26
  48. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts.map +0 -1
  49. package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.js +0 -196
  50. package/dist/src/commands/edit.d.ts +0 -12
  51. package/dist/src/commands/edit.d.ts.map +0 -1
  52. package/dist/src/commands/edit.js +0 -652
  53. package/dist/src/commands/edit.test.d.ts +0 -6
  54. package/dist/src/commands/edit.test.d.ts.map +0 -1
  55. package/dist/src/commands/edit.test.js +0 -45
  56. package/dist/src/commands/fidelity-trim.d.ts +0 -13
  57. package/dist/src/commands/fidelity-trim.d.ts.map +0 -1
  58. package/dist/src/commands/fidelity-trim.js +0 -21
  59. package/dist/src/commands/fidelity-trim.test.d.ts +0 -8
  60. package/dist/src/commands/fidelity-trim.test.d.ts.map +0 -1
  61. package/dist/src/commands/fidelity-trim.test.js +0 -79
  62. package/dist/src/commands/file-secret-collector.d.ts +0 -50
  63. package/dist/src/commands/file-secret-collector.d.ts.map +0 -1
  64. package/dist/src/commands/file-secret-collector.js +0 -217
  65. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts +0 -38
  66. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts.map +0 -1
  67. package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.js +0 -300
  68. package/dist/src/commands/github-workflow.d.ts +0 -5
  69. package/dist/src/commands/github-workflow.d.ts.map +0 -1
  70. package/dist/src/commands/github-workflow.js +0 -45
  71. package/dist/src/commands/index.d.ts +0 -33
  72. package/dist/src/commands/index.d.ts.map +0 -1
  73. package/dist/src/commands/index.js +0 -33
  74. package/dist/src/commands/init.d.ts +0 -20
  75. package/dist/src/commands/init.d.ts.map +0 -1
  76. package/dist/src/commands/init.js +0 -264
  77. package/dist/src/commands/ios-scanner.d.ts +0 -104
  78. package/dist/src/commands/ios-scanner.d.ts.map +0 -1
  79. package/dist/src/commands/ios-scanner.js +0 -492
  80. package/dist/src/commands/ios-scheme-ranking.test.d.ts +0 -2
  81. package/dist/src/commands/ios-scheme-ranking.test.d.ts.map +0 -1
  82. package/dist/src/commands/ios-scheme-ranking.test.js +0 -264
  83. package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts +0 -30
  84. package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts.map +0 -1
  85. package/dist/src/commands/local-properties-reaches-every-workflow.test.js +0 -146
  86. package/dist/src/commands/reset.d.ts +0 -7
  87. package/dist/src/commands/reset.d.ts.map +0 -1
  88. package/dist/src/commands/reset.js +0 -81
  89. package/dist/src/commands/run.d.ts +0 -22
  90. package/dist/src/commands/run.d.ts.map +0 -1
  91. package/dist/src/commands/run.js +0 -141
  92. package/dist/src/commands/secrets-sync-workflow.d.ts +0 -15
  93. package/dist/src/commands/secrets-sync-workflow.d.ts.map +0 -1
  94. package/dist/src/commands/secrets-sync-workflow.js +0 -255
  95. package/dist/src/commands/secrets-upload.d.ts +0 -21
  96. package/dist/src/commands/secrets-upload.d.ts.map +0 -1
  97. package/dist/src/commands/secrets-upload.js +0 -177
  98. package/dist/src/commands/secrets-upload.test.d.ts +0 -5
  99. package/dist/src/commands/secrets-upload.test.d.ts.map +0 -1
  100. package/dist/src/commands/secrets-upload.test.js +0 -60
  101. package/dist/src/commands/validate.d.ts +0 -10
  102. package/dist/src/commands/validate.d.ts.map +0 -1
  103. package/dist/src/commands/validate.js +0 -46
  104. package/dist/src/config.d.ts +0 -3
  105. package/dist/src/config.d.ts.map +0 -1
  106. package/dist/src/config.js +0 -47
  107. package/dist/src/envman/cli.d.ts +0 -21
  108. package/dist/src/envman/cli.d.ts.map +0 -1
  109. package/dist/src/envman/cli.js +0 -240
  110. package/dist/src/envman/envman.d.ts +0 -83
  111. package/dist/src/envman/envman.d.ts.map +0 -1
  112. package/dist/src/envman/envman.js +0 -361
  113. package/dist/src/envman/envman.test.d.ts +0 -5
  114. package/dist/src/envman/envman.test.d.ts.map +0 -1
  115. package/dist/src/envman/envman.test.js +0 -236
  116. package/dist/src/envman/envstore-round-trip.test.d.ts +0 -27
  117. package/dist/src/envman/envstore-round-trip.test.d.ts.map +0 -1
  118. package/dist/src/envman/envstore-round-trip.test.js +0 -130
  119. package/dist/src/envman/index.d.ts +0 -23
  120. package/dist/src/envman/index.d.ts.map +0 -1
  121. package/dist/src/envman/index.js +0 -23
  122. package/dist/src/envman/shim.d.ts +0 -16
  123. package/dist/src/envman/shim.d.ts.map +0 -1
  124. package/dist/src/envman/shim.js +0 -66
  125. package/dist/src/envman/types.d.ts +0 -55
  126. package/dist/src/envman/types.d.ts.map +0 -1
  127. package/dist/src/envman/types.js +0 -12
  128. package/dist/src/lib.d.ts +0 -33
  129. package/dist/src/lib.d.ts.map +0 -1
  130. package/dist/src/lib.js +0 -37
  131. package/dist/src/pipeline.d.ts +0 -3
  132. package/dist/src/pipeline.d.ts.map +0 -1
  133. package/dist/src/pipeline.js +0 -57
  134. package/dist/src/runner-node-step.test.d.ts +0 -12
  135. package/dist/src/runner-node-step.test.d.ts.map +0 -1
  136. package/dist/src/runner-node-step.test.js +0 -61
  137. package/dist/src/runner.d.ts +0 -31
  138. package/dist/src/runner.d.ts.map +0 -1
  139. package/dist/src/runner.js +0 -240
  140. package/dist/src/shared/detect-project.d.ts +0 -57
  141. package/dist/src/shared/detect-project.d.ts.map +0 -1
  142. package/dist/src/shared/detect-project.js +0 -219
  143. package/dist/src/shared/detect-project.test.d.ts +0 -2
  144. package/dist/src/shared/detect-project.test.d.ts.map +0 -1
  145. package/dist/src/shared/detect-project.test.js +0 -161
  146. package/dist/src/shared/git.d.ts +0 -25
  147. package/dist/src/shared/git.d.ts.map +0 -1
  148. package/dist/src/shared/git.js +0 -53
  149. package/dist/src/shared/git.test.d.ts +0 -9
  150. package/dist/src/shared/git.test.d.ts.map +0 -1
  151. package/dist/src/shared/git.test.js +0 -66
  152. package/dist/src/shared/gitignore.d.ts +0 -6
  153. package/dist/src/shared/gitignore.d.ts.map +0 -1
  154. package/dist/src/shared/gitignore.js +0 -26
  155. package/dist/src/shared/gradle-settings.d.ts +0 -49
  156. package/dist/src/shared/gradle-settings.d.ts.map +0 -1
  157. package/dist/src/shared/gradle-settings.js +0 -138
  158. package/dist/src/shared/gradle-settings.test.d.ts +0 -2
  159. package/dist/src/shared/gradle-settings.test.d.ts.map +0 -1
  160. package/dist/src/shared/gradle-settings.test.js +0 -65
  161. package/dist/src/shared/prompts.d.ts +0 -30
  162. package/dist/src/shared/prompts.d.ts.map +0 -1
  163. package/dist/src/shared/prompts.js +0 -121
  164. package/dist/src/shared/xcode-container.test.d.ts +0 -2
  165. package/dist/src/shared/xcode-container.test.d.ts.map +0 -1
  166. package/dist/src/shared/xcode-container.test.js +0 -123
  167. package/dist/src/step-marker.d.ts +0 -12
  168. package/dist/src/step-marker.d.ts.map +0 -1
  169. package/dist/src/step-marker.js +0 -29
  170. package/dist/src/step-marker.test.d.ts +0 -11
  171. package/dist/src/step-marker.test.d.ts.map +0 -1
  172. package/dist/src/step-marker.test.js +0 -45
  173. package/dist/src/types.d.ts +0 -58
  174. package/dist/src/types.d.ts.map +0 -1
  175. package/dist/src/types.js +0 -2
  176. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts +0 -28
  177. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts.map +0 -1
  178. package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.js +0 -223
  179. package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts +0 -20
  180. package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts.map +0 -1
  181. package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.js +0 -201
  182. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts +0 -37
  183. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts.map +0 -1
  184. package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.js +0 -255
  185. package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts +0 -32
  186. package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts.map +0 -1
  187. package/dist/src/yaml/a-missing-credential-is-loud.test.js +0 -301
  188. package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.d.ts +0 -18
  189. package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.d.ts.map +0 -1
  190. package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.js +0 -66
  191. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts +0 -34
  192. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts.map +0 -1
  193. package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.js +0 -218
  194. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts +0 -27
  195. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts.map +0 -1
  196. package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.js +0 -126
  197. package/dist/src/yaml/bitrise-compat.d.ts +0 -65
  198. package/dist/src/yaml/bitrise-compat.d.ts.map +0 -1
  199. package/dist/src/yaml/bitrise-compat.js +0 -206
  200. package/dist/src/yaml/bitrise-compat.test.d.ts +0 -5
  201. package/dist/src/yaml/bitrise-compat.test.d.ts.map +0 -1
  202. package/dist/src/yaml/bitrise-compat.test.js +0 -347
  203. package/dist/src/yaml/cache-key-missing-file.test.d.ts +0 -12
  204. package/dist/src/yaml/cache-key-missing-file.test.d.ts.map +0 -1
  205. package/dist/src/yaml/cache-key-missing-file.test.js +0 -116
  206. package/dist/src/yaml/converter.d.ts +0 -103
  207. package/dist/src/yaml/converter.d.ts.map +0 -1
  208. package/dist/src/yaml/converter.js +0 -420
  209. package/dist/src/yaml/converter.test.d.ts +0 -5
  210. package/dist/src/yaml/converter.test.d.ts.map +0 -1
  211. package/dist/src/yaml/converter.test.js +0 -548
  212. package/dist/src/yaml/e2e.test.d.ts +0 -6
  213. package/dist/src/yaml/e2e.test.d.ts.map +0 -1
  214. package/dist/src/yaml/e2e.test.js +0 -446
  215. package/dist/src/yaml/env-resolver.d.ts +0 -218
  216. package/dist/src/yaml/env-resolver.d.ts.map +0 -1
  217. package/dist/src/yaml/env-resolver.js +0 -591
  218. package/dist/src/yaml/env-resolver.test.d.ts +0 -5
  219. package/dist/src/yaml/env-resolver.test.d.ts.map +0 -1
  220. package/dist/src/yaml/env-resolver.test.js +0 -502
  221. package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.d.ts +0 -16
  222. package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.d.ts.map +0 -1
  223. package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.js +0 -126
  224. package/dist/src/yaml/fidelity-input-digest.d.ts +0 -37
  225. package/dist/src/yaml/fidelity-input-digest.d.ts.map +0 -1
  226. package/dist/src/yaml/fidelity-input-digest.js +0 -208
  227. package/dist/src/yaml/fidelity-input-digest.test.d.ts +0 -2
  228. package/dist/src/yaml/fidelity-input-digest.test.d.ts.map +0 -1
  229. package/dist/src/yaml/fidelity-input-digest.test.js +0 -167
  230. package/dist/src/yaml/fidelity-manifest.d.ts +0 -126
  231. package/dist/src/yaml/fidelity-manifest.d.ts.map +0 -1
  232. package/dist/src/yaml/fidelity-manifest.js +0 -134
  233. package/dist/src/yaml/fidelity-manifest.test.d.ts +0 -12
  234. package/dist/src/yaml/fidelity-manifest.test.d.ts.map +0 -1
  235. package/dist/src/yaml/fidelity-manifest.test.js +0 -251
  236. package/dist/src/yaml/interactive-prompts.d.ts +0 -71
  237. package/dist/src/yaml/interactive-prompts.d.ts.map +0 -1
  238. package/dist/src/yaml/interactive-prompts.js +0 -260
  239. package/dist/src/yaml/meta-helpers.d.ts +0 -28
  240. package/dist/src/yaml/meta-helpers.d.ts.map +0 -1
  241. package/dist/src/yaml/meta-helpers.js +0 -46
  242. package/dist/src/yaml/missing-env-handler.d.ts +0 -45
  243. package/dist/src/yaml/missing-env-handler.d.ts.map +0 -1
  244. package/dist/src/yaml/missing-env-handler.js +0 -64
  245. package/dist/src/yaml/parser.d.ts +0 -33
  246. package/dist/src/yaml/parser.d.ts.map +0 -1
  247. package/dist/src/yaml/parser.js +0 -148
  248. package/dist/src/yaml/parser.test.d.ts +0 -5
  249. package/dist/src/yaml/parser.test.d.ts.map +0 -1
  250. package/dist/src/yaml/parser.test.js +0 -83
  251. package/dist/src/yaml/pipeline-with-secrets.d.ts +0 -25
  252. package/dist/src/yaml/pipeline-with-secrets.d.ts.map +0 -1
  253. package/dist/src/yaml/pipeline-with-secrets.js +0 -76
  254. package/dist/src/yaml/platform-detector.d.ts +0 -83
  255. package/dist/src/yaml/platform-detector.d.ts.map +0 -1
  256. package/dist/src/yaml/platform-detector.js +0 -189
  257. package/dist/src/yaml/platform-detector.test.d.ts +0 -5
  258. package/dist/src/yaml/platform-detector.test.d.ts.map +0 -1
  259. package/dist/src/yaml/platform-detector.test.js +0 -525
  260. package/dist/src/yaml/preflight-validation.d.ts +0 -29
  261. package/dist/src/yaml/preflight-validation.d.ts.map +0 -1
  262. package/dist/src/yaml/preflight-validation.js +0 -21
  263. package/dist/src/yaml/secrets-manager.d.ts +0 -87
  264. package/dist/src/yaml/secrets-manager.d.ts.map +0 -1
  265. package/dist/src/yaml/secrets-manager.js +0 -230
  266. package/dist/src/yaml/shell-assignments.d.ts +0 -40
  267. package/dist/src/yaml/shell-assignments.d.ts.map +0 -1
  268. package/dist/src/yaml/shell-assignments.js +0 -77
  269. package/dist/src/yaml/step-validator-check-failure.test.d.ts +0 -24
  270. package/dist/src/yaml/step-validator-check-failure.test.d.ts.map +0 -1
  271. package/dist/src/yaml/step-validator-check-failure.test.js +0 -130
  272. package/dist/src/yaml/step-validator-self-assigned.test.d.ts +0 -23
  273. package/dist/src/yaml/step-validator-self-assigned.test.d.ts.map +0 -1
  274. package/dist/src/yaml/step-validator-self-assigned.test.js +0 -191
  275. package/dist/src/yaml/step-validator.d.ts +0 -67
  276. package/dist/src/yaml/step-validator.d.ts.map +0 -1
  277. package/dist/src/yaml/step-validator.js +0 -466
  278. package/dist/src/yaml/step-validator.test.d.ts +0 -18
  279. package/dist/src/yaml/step-validator.test.d.ts.map +0 -1
  280. package/dist/src/yaml/step-validator.test.js +0 -140
  281. package/dist/src/yaml/steps/android-sign.d.ts +0 -35
  282. package/dist/src/yaml/steps/android-sign.d.ts.map +0 -1
  283. package/dist/src/yaml/steps/android-sign.js +0 -147
  284. package/dist/src/yaml/steps/android-variant-resolution.test.d.ts +0 -2
  285. package/dist/src/yaml/steps/android-variant-resolution.test.d.ts.map +0 -1
  286. package/dist/src/yaml/steps/android-variant-resolution.test.js +0 -260
  287. package/dist/src/yaml/steps/android-version.d.ts +0 -26
  288. package/dist/src/yaml/steps/android-version.d.ts.map +0 -1
  289. package/dist/src/yaml/steps/android-version.js +0 -128
  290. package/dist/src/yaml/steps/android-version.test.d.ts +0 -5
  291. package/dist/src/yaml/steps/android-version.test.d.ts.map +0 -1
  292. package/dist/src/yaml/steps/android-version.test.js +0 -196
  293. package/dist/src/yaml/steps/android.d.ts +0 -156
  294. package/dist/src/yaml/steps/android.d.ts.map +0 -1
  295. package/dist/src/yaml/steps/android.js +0 -1092
  296. package/dist/src/yaml/steps/app-store-deploy.d.ts +0 -48
  297. package/dist/src/yaml/steps/app-store-deploy.d.ts.map +0 -1
  298. package/dist/src/yaml/steps/app-store-deploy.js +0 -167
  299. package/dist/src/yaml/steps/base.d.ts +0 -263
  300. package/dist/src/yaml/steps/base.d.ts.map +0 -1
  301. package/dist/src/yaml/steps/base.js +0 -380
  302. package/dist/src/yaml/steps/bitrise-android-tools.d.ts +0 -26
  303. package/dist/src/yaml/steps/bitrise-android-tools.d.ts.map +0 -1
  304. package/dist/src/yaml/steps/bitrise-android-tools.js +0 -200
  305. package/dist/src/yaml/steps/bitrise-android-tools.test.d.ts +0 -5
  306. package/dist/src/yaml/steps/bitrise-android-tools.test.d.ts.map +0 -1
  307. package/dist/src/yaml/steps/bitrise-android-tools.test.js +0 -280
  308. package/dist/src/yaml/steps/bitrise-apk-info.d.ts +0 -22
  309. package/dist/src/yaml/steps/bitrise-apk-info.d.ts.map +0 -1
  310. package/dist/src/yaml/steps/bitrise-apk-info.js +0 -144
  311. package/dist/src/yaml/steps/bitrise-apk-info.test.d.ts +0 -5
  312. package/dist/src/yaml/steps/bitrise-apk-info.test.d.ts.map +0 -1
  313. package/dist/src/yaml/steps/bitrise-apk-info.test.js +0 -331
  314. package/dist/src/yaml/steps/bitrise-slack.d.ts +0 -49
  315. package/dist/src/yaml/steps/bitrise-slack.d.ts.map +0 -1
  316. package/dist/src/yaml/steps/bitrise-slack.js +0 -280
  317. package/dist/src/yaml/steps/bitrise-slack.test.d.ts +0 -5
  318. package/dist/src/yaml/steps/bitrise-slack.test.d.ts.map +0 -1
  319. package/dist/src/yaml/steps/bitrise-slack.test.js +0 -484
  320. package/dist/src/yaml/steps/bitrise-ssh.d.ts +0 -27
  321. package/dist/src/yaml/steps/bitrise-ssh.d.ts.map +0 -1
  322. package/dist/src/yaml/steps/bitrise-ssh.js +0 -134
  323. package/dist/src/yaml/steps/bitrise-ssh.test.d.ts +0 -5
  324. package/dist/src/yaml/steps/bitrise-ssh.test.d.ts.map +0 -1
  325. package/dist/src/yaml/steps/bitrise-ssh.test.js +0 -205
  326. package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts +0 -27
  327. package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts.map +0 -1
  328. package/dist/src/yaml/steps/cache-pull-daemon.test.js +0 -212
  329. package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts +0 -42
  330. package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts.map +0 -1
  331. package/dist/src/yaml/steps/cache-pull-restore-roots.test.js +0 -209
  332. package/dist/src/yaml/steps/cache-push-daemon.test.d.ts +0 -27
  333. package/dist/src/yaml/steps/cache-push-daemon.test.d.ts.map +0 -1
  334. package/dist/src/yaml/steps/cache-push-daemon.test.js +0 -171
  335. package/dist/src/yaml/steps/cache.d.ts +0 -59
  336. package/dist/src/yaml/steps/cache.d.ts.map +0 -1
  337. package/dist/src/yaml/steps/cache.js +0 -1027
  338. package/dist/src/yaml/steps/capture-injection.d.ts +0 -30
  339. package/dist/src/yaml/steps/capture-injection.d.ts.map +0 -1
  340. package/dist/src/yaml/steps/capture-injection.js +0 -88
  341. package/dist/src/yaml/steps/capture-injection.test.d.ts +0 -2
  342. package/dist/src/yaml/steps/capture-injection.test.d.ts.map +0 -1
  343. package/dist/src/yaml/steps/capture-injection.test.js +0 -163
  344. package/dist/src/yaml/steps/cocoapods-deployment-floor.d.ts +0 -42
  345. package/dist/src/yaml/steps/cocoapods-deployment-floor.d.ts.map +0 -1
  346. package/dist/src/yaml/steps/cocoapods-deployment-floor.js +0 -140
  347. package/dist/src/yaml/steps/cocoapods-deployment-floor.test.d.ts +0 -11
  348. package/dist/src/yaml/steps/cocoapods-deployment-floor.test.d.ts.map +0 -1
  349. package/dist/src/yaml/steps/cocoapods-deployment-floor.test.js +0 -176
  350. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts +0 -22
  351. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts.map +0 -1
  352. package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.js +0 -97
  353. package/dist/src/yaml/steps/cocoapods-locked-bundler.test.d.ts +0 -17
  354. package/dist/src/yaml/steps/cocoapods-locked-bundler.test.d.ts.map +0 -1
  355. package/dist/src/yaml/steps/cocoapods-locked-bundler.test.js +0 -282
  356. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts +0 -17
  357. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts.map +0 -1
  358. package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.js +0 -132
  359. package/dist/src/yaml/steps/envstore-loader.test.d.ts +0 -15
  360. package/dist/src/yaml/steps/envstore-loader.test.d.ts.map +0 -1
  361. package/dist/src/yaml/steps/envstore-loader.test.js +0 -143
  362. package/dist/src/yaml/steps/fastlane.d.ts +0 -27
  363. package/dist/src/yaml/steps/fastlane.d.ts.map +0 -1
  364. package/dist/src/yaml/steps/fastlane.js +0 -79
  365. package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts +0 -53
  366. package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts.map +0 -1
  367. package/dist/src/yaml/steps/fidelity-aspect-guard.js +0 -72
  368. package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts +0 -11
  369. package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts.map +0 -1
  370. package/dist/src/yaml/steps/fidelity-aspect-guard.test.js +0 -50
  371. package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts +0 -87
  372. package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts.map +0 -1
  373. package/dist/src/yaml/steps/fidelity-reference-provenance.js +0 -111
  374. package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts +0 -22
  375. package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts.map +0 -1
  376. package/dist/src/yaml/steps/fidelity-reference-provenance.test.js +0 -118
  377. package/dist/src/yaml/steps/file.d.ts +0 -52
  378. package/dist/src/yaml/steps/file.d.ts.map +0 -1
  379. package/dist/src/yaml/steps/file.js +0 -108
  380. package/dist/src/yaml/steps/flutter.d.ts +0 -63
  381. package/dist/src/yaml/steps/flutter.d.ts.map +0 -1
  382. package/dist/src/yaml/steps/flutter.js +0 -215
  383. package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.d.ts +0 -16
  384. package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.d.ts.map +0 -1
  385. package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.js +0 -127
  386. package/dist/src/yaml/steps/git-clone.d.ts +0 -32
  387. package/dist/src/yaml/steps/git-clone.d.ts.map +0 -1
  388. package/dist/src/yaml/steps/git-clone.js +0 -171
  389. package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts +0 -10
  390. package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts.map +0 -1
  391. package/dist/src/yaml/steps/git-fetch-retry-exec.test.js +0 -162
  392. package/dist/src/yaml/steps/git-fetch-retry.d.ts +0 -62
  393. package/dist/src/yaml/steps/git-fetch-retry.d.ts.map +0 -1
  394. package/dist/src/yaml/steps/git-fetch-retry.js +0 -155
  395. package/dist/src/yaml/steps/git-fetch-retry.test.d.ts +0 -5
  396. package/dist/src/yaml/steps/git-fetch-retry.test.d.ts.map +0 -1
  397. package/dist/src/yaml/steps/git-fetch-retry.test.js +0 -86
  398. package/dist/src/yaml/steps/google-play-deploy.d.ts +0 -37
  399. package/dist/src/yaml/steps/google-play-deploy.d.ts.map +0 -1
  400. package/dist/src/yaml/steps/google-play-deploy.js +0 -193
  401. package/dist/src/yaml/steps/google-play-deploy.test.d.ts +0 -5
  402. package/dist/src/yaml/steps/google-play-deploy.test.d.ts.map +0 -1
  403. package/dist/src/yaml/steps/google-play-deploy.test.js +0 -310
  404. package/dist/src/yaml/steps/gradle-memory.d.ts +0 -61
  405. package/dist/src/yaml/steps/gradle-memory.d.ts.map +0 -1
  406. package/dist/src/yaml/steps/gradle-memory.js +0 -386
  407. package/dist/src/yaml/steps/gradle-memory.test.d.ts +0 -2
  408. package/dist/src/yaml/steps/gradle-memory.test.d.ts.map +0 -1
  409. package/dist/src/yaml/steps/gradle-memory.test.js +0 -344
  410. package/dist/src/yaml/steps/gradle-sdk-cmake.d.ts +0 -28
  411. package/dist/src/yaml/steps/gradle-sdk-cmake.d.ts.map +0 -1
  412. package/dist/src/yaml/steps/gradle-sdk-cmake.js +0 -107
  413. package/dist/src/yaml/steps/gradle-sdk-cmake.test.d.ts +0 -2
  414. package/dist/src/yaml/steps/gradle-sdk-cmake.test.d.ts.map +0 -1
  415. package/dist/src/yaml/steps/gradle-sdk-cmake.test.js +0 -183
  416. package/dist/src/yaml/steps/index.d.ts +0 -10
  417. package/dist/src/yaml/steps/index.d.ts.map +0 -1
  418. package/dist/src/yaml/steps/index.js +0 -1461
  419. package/dist/src/yaml/steps/ios-deps.d.ts +0 -78
  420. package/dist/src/yaml/steps/ios-deps.d.ts.map +0 -1
  421. package/dist/src/yaml/steps/ios-deps.js +0 -279
  422. package/dist/src/yaml/steps/ios-deps.test.d.ts +0 -5
  423. package/dist/src/yaml/steps/ios-deps.test.d.ts.map +0 -1
  424. package/dist/src/yaml/steps/ios-deps.test.js +0 -90
  425. package/dist/src/yaml/steps/ios-signing.d.ts +0 -31
  426. package/dist/src/yaml/steps/ios-signing.d.ts.map +0 -1
  427. package/dist/src/yaml/steps/ios-signing.js +0 -144
  428. package/dist/src/yaml/steps/ios-version.d.ts +0 -47
  429. package/dist/src/yaml/steps/ios-version.d.ts.map +0 -1
  430. package/dist/src/yaml/steps/ios-version.js +0 -151
  431. package/dist/src/yaml/steps/java-home-version-spec.test.d.ts +0 -10
  432. package/dist/src/yaml/steps/java-home-version-spec.test.d.ts.map +0 -1
  433. package/dist/src/yaml/steps/java-home-version-spec.test.js +0 -58
  434. package/dist/src/yaml/steps/linting.d.ts +0 -47
  435. package/dist/src/yaml/steps/linting.d.ts.map +0 -1
  436. package/dist/src/yaml/steps/linting.js +0 -148
  437. package/dist/src/yaml/steps/phase2.test.d.ts +0 -6
  438. package/dist/src/yaml/steps/phase2.test.d.ts.map +0 -1
  439. package/dist/src/yaml/steps/phase2.test.js +0 -197
  440. package/dist/src/yaml/steps/phase3.test.d.ts +0 -5
  441. package/dist/src/yaml/steps/phase3.test.d.ts.map +0 -1
  442. package/dist/src/yaml/steps/phase3.test.js +0 -144
  443. package/dist/src/yaml/steps/phase4.test.d.ts +0 -5
  444. package/dist/src/yaml/steps/phase4.test.d.ts.map +0 -1
  445. package/dist/src/yaml/steps/phase4.test.js +0 -166
  446. package/dist/src/yaml/steps/phase5.test.d.ts +0 -6
  447. package/dist/src/yaml/steps/phase5.test.d.ts.map +0 -1
  448. package/dist/src/yaml/steps/phase5.test.js +0 -263
  449. package/dist/src/yaml/steps/registry.d.ts +0 -88
  450. package/dist/src/yaml/steps/registry.d.ts.map +0 -1
  451. package/dist/src/yaml/steps/registry.js +0 -125
  452. package/dist/src/yaml/steps/registry.test.d.ts +0 -5
  453. package/dist/src/yaml/steps/registry.test.d.ts.map +0 -1
  454. package/dist/src/yaml/steps/registry.test.js +0 -235
  455. package/dist/src/yaml/steps/release.d.ts +0 -50
  456. package/dist/src/yaml/steps/release.d.ts.map +0 -1
  457. package/dist/src/yaml/steps/release.js +0 -154
  458. package/dist/src/yaml/steps/render-post-processor.d.ts +0 -100
  459. package/dist/src/yaml/steps/render-post-processor.d.ts.map +0 -1
  460. package/dist/src/yaml/steps/render-post-processor.js +0 -270
  461. package/dist/src/yaml/steps/render-post-processor.test.d.ts +0 -13
  462. package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +0 -1
  463. package/dist/src/yaml/steps/render-post-processor.test.js +0 -220
  464. package/dist/src/yaml/steps/script.d.ts +0 -23
  465. package/dist/src/yaml/steps/script.d.ts.map +0 -1
  466. package/dist/src/yaml/steps/script.js +0 -63
  467. package/dist/src/yaml/steps/spec-validation.test.d.ts +0 -6
  468. package/dist/src/yaml/steps/spec-validation.test.d.ts.map +0 -1
  469. package/dist/src/yaml/steps/spec-validation.test.js +0 -130
  470. package/dist/src/yaml/steps/steps.test.d.ts +0 -6
  471. package/dist/src/yaml/steps/steps.test.d.ts.map +0 -1
  472. package/dist/src/yaml/steps/steps.test.js +0 -881
  473. package/dist/src/yaml/steps/structural-facts.d.ts +0 -992
  474. package/dist/src/yaml/steps/structural-facts.d.ts.map +0 -1
  475. package/dist/src/yaml/steps/structural-facts.js +0 -3276
  476. package/dist/src/yaml/steps/structural-facts.test.d.ts +0 -12
  477. package/dist/src/yaml/steps/structural-facts.test.d.ts.map +0 -1
  478. package/dist/src/yaml/steps/structural-facts.test.js +0 -3039
  479. package/dist/src/yaml/steps/test-config.d.ts +0 -5
  480. package/dist/src/yaml/steps/test-config.d.ts.map +0 -1
  481. package/dist/src/yaml/steps/test-config.js +0 -25
  482. package/dist/src/yaml/steps/trim-align-corpus.test.d.ts +0 -28
  483. package/dist/src/yaml/steps/trim-align-corpus.test.d.ts.map +0 -1
  484. package/dist/src/yaml/steps/trim-align-corpus.test.js +0 -383
  485. package/dist/src/yaml/steps/trim-align.d.ts +0 -87
  486. package/dist/src/yaml/steps/trim-align.d.ts.map +0 -1
  487. package/dist/src/yaml/steps/trim-align.js +0 -367
  488. package/dist/src/yaml/steps/trim-align.test.d.ts +0 -11
  489. package/dist/src/yaml/steps/trim-align.test.d.ts.map +0 -1
  490. package/dist/src/yaml/steps/trim-align.test.js +0 -261
  491. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +0 -66
  492. package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +0 -1
  493. package/dist/src/yaml/steps/ui-fidelity-preview-android.js +0 -251
  494. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts +0 -13
  495. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts.map +0 -1
  496. package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +0 -302
  497. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts +0 -134
  498. package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +0 -1
  499. package/dist/src/yaml/steps/ui-fidelity-preview.js +0 -173
  500. package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts +0 -11
  501. package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts.map +0 -1
  502. package/dist/src/yaml/steps/ui-fidelity-preview.test.js +0 -210
  503. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +0 -155
  504. package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +0 -1
  505. package/dist/src/yaml/steps/ui-fidelity-render-android.js +0 -1389
  506. package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts +0 -2
  507. package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts.map +0 -1
  508. package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +0 -1106
  509. package/dist/src/yaml/steps/ui-fidelity-render.d.ts +0 -230
  510. package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +0 -1
  511. package/dist/src/yaml/steps/ui-fidelity-render.js +0 -1597
  512. package/dist/src/yaml/steps/ui-fidelity-render.test.d.ts +0 -18
  513. package/dist/src/yaml/steps/ui-fidelity-render.test.d.ts.map +0 -1
  514. package/dist/src/yaml/steps/ui-fidelity-render.test.js +0 -1871
  515. package/dist/src/yaml/steps/xcode-app-product.d.ts +0 -90
  516. package/dist/src/yaml/steps/xcode-app-product.d.ts.map +0 -1
  517. package/dist/src/yaml/steps/xcode-app-product.js +0 -247
  518. package/dist/src/yaml/steps/xcode-app-product.test.d.ts +0 -2
  519. package/dist/src/yaml/steps/xcode-app-product.test.d.ts.map +0 -1
  520. package/dist/src/yaml/steps/xcode-app-product.test.js +0 -202
  521. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts +0 -30
  522. package/dist/src/yaml/steps/xcode-derived-data.test.d.ts.map +0 -1
  523. package/dist/src/yaml/steps/xcode-derived-data.test.js +0 -279
  524. package/dist/src/yaml/steps/xcode-destination.d.ts +0 -47
  525. package/dist/src/yaml/steps/xcode-destination.d.ts.map +0 -1
  526. package/dist/src/yaml/steps/xcode-destination.js +0 -202
  527. package/dist/src/yaml/steps/xcode-destination.test.d.ts +0 -9
  528. package/dist/src/yaml/steps/xcode-destination.test.d.ts.map +0 -1
  529. package/dist/src/yaml/steps/xcode-destination.test.js +0 -227
  530. package/dist/src/yaml/steps/xcode-new.test.d.ts +0 -5
  531. package/dist/src/yaml/steps/xcode-new.test.d.ts.map +0 -1
  532. package/dist/src/yaml/steps/xcode-new.test.js +0 -211
  533. package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts +0 -9
  534. package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts.map +0 -1
  535. package/dist/src/yaml/steps/xcode-resolve-retry.test.js +0 -146
  536. package/dist/src/yaml/steps/xcode-simulator-archs.d.ts +0 -53
  537. package/dist/src/yaml/steps/xcode-simulator-archs.d.ts.map +0 -1
  538. package/dist/src/yaml/steps/xcode-simulator-archs.js +0 -98
  539. package/dist/src/yaml/steps/xcode-simulator-archs.test.d.ts +0 -21
  540. package/dist/src/yaml/steps/xcode-simulator-archs.test.d.ts.map +0 -1
  541. package/dist/src/yaml/steps/xcode-simulator-archs.test.js +0 -155
  542. package/dist/src/yaml/steps/xcode.d.ts +0 -230
  543. package/dist/src/yaml/steps/xcode.d.ts.map +0 -1
  544. package/dist/src/yaml/steps/xcode.js +0 -1350
  545. package/dist/src/yaml/types.d.ts +0 -76
  546. package/dist/src/yaml/types.d.ts.map +0 -1
  547. package/dist/src/yaml/types.js +0 -5
  548. package/dist/src/yaml/validation-types.d.ts +0 -102
  549. package/dist/src/yaml/validation-types.d.ts.map +0 -1
  550. package/dist/src/yaml/validation-types.js +0 -8
  551. package/dist/src/yaml/yaml-updater.d.ts +0 -24
  552. package/dist/src/yaml/yaml-updater.d.ts.map +0 -1
  553. package/dist/src/yaml/yaml-updater.js +0 -128
@@ -1,3276 +0,0 @@
1
- /**
2
- * Structural-facts deriver — Signal A of the UI-fidelity structural pass.
3
- *
4
- * Pure and platform-agnostic: it takes a per-node layout dump (clipped +
5
- * unclipped bounds in px, parent) emitted alongside the render and derives
6
- * intent-agnostic geometric facts the fidelity judge should check instead of
7
- * discovering them by eye:
8
- *
9
- * - `overflow` — a node extends past its parent's box (parent does
10
- * NOT clip, so the excess is visibly sticking out).
11
- * - `clipped_by_parent` — a node extends past a CLIPPING parent's box, so the
12
- * excess is cut away (the motivating euro-coin case).
13
- * - `off_screen` — a node extends past the root canvas.
14
- * - `overlap` — two siblings overlap by a significant area.
15
- *
16
- * The deriver does NOT judge or score; it reports facts. The verdict stays the
17
- * agent's. This is the brain of Signal A; the render-side emission + facts
18
- * artifact wiring live in the render step (a separate slice).
19
- */
20
- /** Placement drift at or below this many dp is sub-pixel/rounding noise, not
21
- * a misplacement — same reasoning as the size-delta floor. */
22
- export const DEFAULT_PLACEMENT_TOLERANCE_DP = 2;
23
- /** Captured-root vs declared-frame tolerance (dp): sub-pixel rounding must not
24
- * read as a mis-sized wrapper root. */
25
- export const DEFAULT_FRAME_TOLERANCE_DP = 1;
26
- const EDGE_ORDER = ['left', 'top', 'right', 'bottom'];
27
- const DEFAULT_TOLERANCE_PX = 0.5;
28
- const DEFAULT_OVERLAP_MIN_RATIO = 0.1;
29
- /**
30
- * By how much `inner` extends past `outer` on each edge (positive = exceeds).
31
- * left/top excess is `outer - inner`; right/bottom excess is `inner - outer`.
32
- */
33
- function edgeExcess(inner, outer, tolerancePx) {
34
- const raw = {
35
- left: outer.left - inner.left,
36
- top: outer.top - inner.top,
37
- right: inner.right - outer.right,
38
- bottom: inner.bottom - outer.bottom,
39
- };
40
- const edges = [];
41
- const px = {};
42
- for (const edge of EDGE_ORDER) {
43
- if (raw[edge] > tolerancePx) {
44
- edges.push(edge);
45
- px[edge] = raw[edge];
46
- }
47
- }
48
- return { edges, px };
49
- }
50
- function area(b) {
51
- return Math.max(0, b.right - b.left) * Math.max(0, b.bottom - b.top);
52
- }
53
- /**
54
- * The box a node actually PAINTS, in layout px — its painted ink when the node
55
- * declares where its ink sits, else its layout bounds (icon-ink-ratio 02 Half B).
56
- *
57
- * An `Icon` reports the container it sits in, not the glyph: `ic_filter` paints
58
- * 20x14 of a 24x24 viewport, offset 2 in and 5 down. Intersecting containers
59
- * therefore answered a question nobody asked — a badge tucked in an icon's corner
60
- * "overlapped" a glyph it visually clears — while the design side of the
61
- * comparison has always been the Figma PATH box, which is pure ink.
62
- *
63
- * Falls back to the bounds, never to a guess, in all three blind cases: no
64
- * `paintedInk` (the node paints no painter, so its box IS its ink),
65
- * `unmeasurable` (the paths could not be read), and an extent with NO ORIGIN
66
- * (a pre-origin dump — the size cannot place the ink, and assuming it centred
67
- * would invent the number this capture exists to stop inventing).
68
- */
69
- function paintedBox(node) {
70
- const p = node.paintedInk;
71
- if (!p || p.kind !== 'vector')
72
- return node.bounds;
73
- const v = p.viewport;
74
- const ink = p.ink;
75
- const finite = (n) => typeof n === 'number' && Number.isFinite(n);
76
- if (!v || !ink || !finite(v.w) || !finite(v.h) || v.w <= 0 || v.h <= 0)
77
- return node.bounds;
78
- if (![ink.w, ink.h, ink.x, ink.y].every(finite))
79
- return node.bounds;
80
- const scaleX = (node.bounds.right - node.bounds.left) / v.w;
81
- const scaleY = (node.bounds.bottom - node.bounds.top) / v.h;
82
- const left = node.bounds.left + ink.x * scaleX;
83
- const top = node.bounds.top + ink.y * scaleY;
84
- return { left, top, right: left + ink.w * scaleX, bottom: top + ink.h * scaleY };
85
- }
86
- /**
87
- * Node ids that appear on MORE THAN ONE node of the dump (testTag collisions),
88
- * mapped to their instance count. Every id-keyed lookup (parent pairing,
89
- * geometry, placement) is last-wins for such an id, so facts derived through it
90
- * would compare arbitrary instances — consumers withhold them and report the
91
- * collision as a `duplicate_tag` fact instead.
92
- */
93
- export function duplicatedNodeIds(nodes) {
94
- const counts = new Map();
95
- for (const node of nodes)
96
- counts.set(node.id, (counts.get(node.id) ?? 0) + 1);
97
- for (const [id, count] of counts) {
98
- if (count < 2)
99
- counts.delete(id);
100
- }
101
- return counts;
102
- }
103
- export function deriveStructuralFacts(dump, options = {}) {
104
- const tolerancePx = options.tolerancePx ?? DEFAULT_TOLERANCE_PX;
105
- const overlapMinRatio = options.overlapMinRatio ?? DEFAULT_OVERLAP_MIN_RATIO;
106
- const byId = new Map(dump.nodes.map((n) => [n.id, n]));
107
- const facts = [];
108
- // testTag collisions first: the fact that explains every withheld comparison
109
- // below. Parent-relative overflow/clip is skipped when the node OR its parent
110
- // carries a duplicated tag — the byId pairing is last-wins, so the measured
111
- // box could belong to any instance. Off-screen (node vs canvas, no pairing)
112
- // and overlaps (measured between two concrete node objects) still derive.
113
- const duplicates = duplicatedNodeIds(dump.nodes);
114
- for (const tag of [...duplicates.keys()].sort()) {
115
- facts.push({ kind: 'duplicate_tag', tag, instanceCount: duplicates.get(tag) });
116
- }
117
- // Overflow / clip vs the direct parent, plus off-screen vs the canvas.
118
- for (const node of dump.nodes) {
119
- const parent = node.parentId && !duplicates.has(node.id) && !duplicates.has(node.parentId)
120
- ? byId.get(node.parentId)
121
- : undefined;
122
- if (parent) {
123
- if (node.unclippedBounds) {
124
- // New dump: measure the child's TRUE extent against the parent's box, so
125
- // a clamping parent no longer hides the excess. An exceeding edge is
126
- // `clipped_by_parent` when that edge was actually clamped (the unclipped
127
- // extent runs past the visible `bounds`), else it visibly `overflow`s.
128
- const parentBox = parent.unclippedBounds ?? parent.bounds;
129
- const over = edgeExcess(node.unclippedBounds, parentBox, tolerancePx);
130
- if (over.edges.length > 0) {
131
- const cut = edgeExcess(node.unclippedBounds, node.bounds, tolerancePx);
132
- const clippedEdges = [];
133
- const overflowEdges = [];
134
- for (const edge of over.edges) {
135
- if (cut.px[edge] != null)
136
- clippedEdges.push(edge);
137
- else
138
- overflowEdges.push(edge);
139
- }
140
- if (clippedEdges.length > 0) {
141
- const clippedPx = {};
142
- for (const edge of clippedEdges)
143
- clippedPx[edge] = cut.px[edge];
144
- facts.push({
145
- kind: 'clipped_by_parent',
146
- nodeId: node.id,
147
- clipperId: parent.id,
148
- edges: clippedEdges,
149
- clippedPx,
150
- });
151
- }
152
- if (overflowEdges.length > 0) {
153
- const overflowPx = {};
154
- for (const edge of overflowEdges)
155
- overflowPx[edge] = over.px[edge];
156
- facts.push({
157
- kind: 'overflow',
158
- nodeId: node.id,
159
- parentId: parent.id,
160
- edges: overflowEdges,
161
- overflowPx,
162
- });
163
- }
164
- }
165
- }
166
- else {
167
- // Legacy dump (no unclippedBounds): clipped bounds + the clipsChildren hint.
168
- const { edges, px } = edgeExcess(node.bounds, parent.bounds, tolerancePx);
169
- if (edges.length > 0) {
170
- if (parent.clipsChildren) {
171
- facts.push({
172
- kind: 'clipped_by_parent',
173
- nodeId: node.id,
174
- clipperId: parent.id,
175
- edges,
176
- clippedPx: px,
177
- });
178
- }
179
- else {
180
- facts.push({
181
- kind: 'overflow',
182
- nodeId: node.id,
183
- parentId: parent.id,
184
- edges,
185
- overflowPx: px,
186
- });
187
- }
188
- }
189
- }
190
- }
191
- // Off-screen uses the true extent when available, so a node positioned past
192
- // the canvas surfaces even if it is clamped to the screen.
193
- const offBox = node.unclippedBounds ?? node.bounds;
194
- const offscreen = edgeExcess(offBox, dump.canvas, tolerancePx);
195
- if (offscreen.edges.length > 0) {
196
- facts.push({
197
- kind: 'off_screen',
198
- nodeId: node.id,
199
- edges: offscreen.edges,
200
- offscreenPx: offscreen.px,
201
- });
202
- }
203
- }
204
- // Significant overlaps between siblings (same parent).
205
- const groups = new Map();
206
- for (const node of dump.nodes) {
207
- const key = node.parentId ?? 'root';
208
- const arr = groups.get(key) ?? [];
209
- arr.push(node);
210
- groups.set(key, arr);
211
- }
212
- for (const siblings of groups.values()) {
213
- for (let i = 0; i < siblings.length; i++) {
214
- for (let j = i + 1; j < siblings.length; j++) {
215
- const a = siblings[i];
216
- const b = siblings[j];
217
- // Measured on what each sibling PAINTS, not on the box it occupies
218
- // (icon-ink-ratio 02 Half B). The ratio uses the painted areas for the
219
- // same reason: a generous container would dilute every ratio it joins.
220
- const ab = paintedBox(a);
221
- const bb = paintedBox(b);
222
- const ix = Math.min(ab.right, bb.right) - Math.max(ab.left, bb.left);
223
- const iy = Math.min(ab.bottom, bb.bottom) - Math.max(ab.top, bb.top);
224
- if (ix <= tolerancePx || iy <= tolerancePx)
225
- continue;
226
- const smaller = Math.min(area(ab), area(bb));
227
- const ratio = smaller > 0 ? (ix * iy) / smaller : 0;
228
- if (ratio < overlapMinRatio)
229
- continue;
230
- const [first, second] = a.id <= b.id ? [a.id, b.id] : [b.id, a.id];
231
- facts.push({
232
- kind: 'overlap',
233
- nodeIds: [first, second],
234
- overlapPx: { width: ix, height: iy },
235
- ratio,
236
- });
237
- }
238
- }
239
- }
240
- return facts;
241
- }
242
- const GEOMETRY_DIMENSIONS = ['width', 'height', 'x', 'y'];
243
- const DEFAULT_GEOMETRY_TOLERANCE_PX = 1;
244
- /**
245
- * Per-element dimensional deltas between a GENERATED geometry (render layout) and
246
- * a REFERENCE geometry (design numbers), both in the same unit. Only elements
247
- * present on both sides, and only dimensions supplied on both, are compared.
248
- * Port of `packages/cli/lib/fidelity-geometry-diff.mjs`.
249
- */
250
- export function diffKeyGeometry(generated, reference, options = {}) {
251
- if (!reference)
252
- return [];
253
- const tolerancePx = options.tolerancePx ?? DEFAULT_GEOMETRY_TOLERANCE_PX;
254
- const out = [];
255
- for (const elementId of Object.keys(generated)) {
256
- const g = generated[elementId];
257
- const r = reference[elementId];
258
- if (!g || !r)
259
- continue;
260
- const deltas = [];
261
- for (const dimension of GEOMETRY_DIMENSIONS) {
262
- const gv = g[dimension];
263
- const rv = r[dimension];
264
- if (typeof gv !== 'number' || typeof rv !== 'number')
265
- continue;
266
- const delta = gv - rv;
267
- if (Math.abs(delta) > tolerancePx) {
268
- deltas.push({ dimension, generated: gv, reference: rv, delta });
269
- }
270
- }
271
- if (deltas.length > 0)
272
- out.push({ elementId, deltas });
273
- }
274
- return out;
275
- }
276
- /** The metrics a `type_metric` fact can report, in the order a finding reads
277
- * them: the size first (it explains the others), then tracking, then leading. */
278
- export const TYPE_METRIC_KEYS = ['fontSize', 'letterSpacing', 'lineHeight'];
279
- function isNonEmptyRecord(value) {
280
- return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0;
281
- }
282
- /**
283
- * Assemble the `AppearanceReference` from a screen's reference params. Issue 05
284
- * decouples the appearance container from `reference_geometry`: the reference is
285
- * built when ANY of geometry / text / color is present, with `elementIds` = the
286
- * UNION of their keys (geometry first, then text, then color — deduped, order
287
- * preserved). So a run carrying `reference_color` but no `reference_geometry` now
288
- * yields a reference (→ color facts), while a geometry-only run is byte-identical
289
- * to before (color/text keys are a subset). `text`/`color` are attached only when
290
- * present. No reference of any kind → null (no appearance container).
291
- */
292
- export function buildAppearanceReferenceFromParams(params) {
293
- const hasGeom = isNonEmptyRecord(params.refGeom);
294
- const hasText = isNonEmptyRecord(params.refText);
295
- const hasColor = isNonEmptyRecord(params.refColor);
296
- const hasSpans = isNonEmptyRecord(params.refSpans);
297
- const hasLines = isNonEmptyRecord(params.refLines);
298
- const hasTypeface = isNonEmptyRecord(params.refTypeface);
299
- if (!hasGeom && !hasText && !hasColor && !hasSpans && !hasLines && !hasTypeface)
300
- return null;
301
- const elementIds = [];
302
- const seen = new Set();
303
- const addKeys = (record) => {
304
- for (const key of Object.keys(record ?? {})) {
305
- if (!seen.has(key)) {
306
- seen.add(key);
307
- elementIds.push(key);
308
- }
309
- }
310
- };
311
- if (hasGeom)
312
- addKeys(params.refGeom);
313
- if (hasText)
314
- addKeys(params.refText);
315
- if (hasColor)
316
- addKeys(params.refColor);
317
- if (hasSpans)
318
- addKeys(params.refSpans);
319
- if (hasLines)
320
- addKeys(params.refLines);
321
- if (hasTypeface)
322
- addKeys(params.refTypeface);
323
- const reference = { baseline: params.baseline, elementIds };
324
- if (hasText)
325
- reference.text = params.refText;
326
- if (hasColor)
327
- reference.color = params.refColor;
328
- if (hasSpans)
329
- reference.spans = params.refSpans;
330
- if (hasLines)
331
- reference.lines = params.refLines;
332
- if (hasTypeface)
333
- reference.typeface = params.refTypeface;
334
- return reference;
335
- }
336
- /**
337
- * Map a previous catalog render's persisted appearance snapshot
338
- * (`StructuralAppearance.rendered` — element ids + per-element colors) into the
339
- * `previous_render` AppearanceReference the next render diffs against. The
340
- * no-Figma drift path: the colors/ids the next render compares itself to are the
341
- * LAST render's, so a diverging fill surfaces as a regression (drift), marked
342
- * `baseline: 'previous_render'`, distinct from a design divergence.
343
- *
344
- * Pure (no I/O) — the testable core the thin catalog-fetch adapter wraps,
345
- * mirroring how the geometry baseline reuses the previous render's persisted
346
- * `geometry`. `color` is carried only when the previous render exposed colors;
347
- * a previous render with no extractable color yields a presence-only reference
348
- * (element ids, no color). A genuinely absent previous render (no snapshot) →
349
- * null, so the next render carries no appearance container, exactly as before.
350
- * `color` (issue 01/02) and `text` (issue 03) are each carried only when the
351
- * previous render exposed them; an empty map is no signal on that axis.
352
- */
353
- export function appearanceReferenceFromPreviousRender(previous) {
354
- if (!previous || !Array.isArray(previous.elementIds))
355
- return null;
356
- const reference = {
357
- baseline: 'previous_render',
358
- elementIds: [...previous.elementIds],
359
- };
360
- if (previous.color && Object.keys(previous.color).length > 0) {
361
- reference.color = previous.color;
362
- }
363
- if (previous.text && Object.keys(previous.text).length > 0) {
364
- reference.text = previous.text;
365
- }
366
- // The previous render's RESOLVED typeface (typeface issue 05): the drift
367
- // reference that catches a previously-correct element silently falling back
368
- // on a later build. Design (Figma-supplied) typeface stays authoritative when
369
- // present — this is the secondary signal.
370
- if (previous.typeface && Object.keys(previous.typeface).length > 0) {
371
- reference.typeface = previous.typeface;
372
- }
373
- return reference;
374
- }
375
- /**
376
- * Element-presence facts from the set difference of the rendered element-id set
377
- * and the reference element-id set, keyed on the stable element id shared with
378
- * Signal B. An id present in the reference but not rendered is `element_missing`;
379
- * an id rendered but not in the reference is `element_extra`.
380
- *
381
- * Pure and platform-agnostic: it takes two id collections and is order-
382
- * independent — the result depends only on the two SETS, not on input order or
383
- * duplicates. Output is sorted (missing before extra, each by id) so callers get
384
- * a deterministic list. The deriver reports facts only; significance is a later,
385
- * separately-wired concern.
386
- */
387
- export function derivePresenceFacts(renderedIds, referenceIds) {
388
- const rendered = new Set(renderedIds);
389
- const reference = new Set(referenceIds);
390
- const facts = [];
391
- for (const elementId of [...reference].filter((id) => !rendered.has(id)).sort()) {
392
- facts.push({ kind: 'element_missing', elementId });
393
- }
394
- for (const elementId of [...rendered].filter((id) => !reference.has(id)).sort()) {
395
- facts.push({ kind: 'element_extra', elementId });
396
- }
397
- return facts;
398
- }
399
- /** True when a text value carries an actual signal (a non-empty string). */
400
- function hasText(value) {
401
- return typeof value === 'string' && value.length > 0;
402
- }
403
- /**
404
- * Copy/text facts from the per-element comparison of rendered text vs reference
405
- * text, keyed on the stable element id shared with Signal B. For each element the
406
- * REFERENCE declares text for: identical text → no fact; a different rendered
407
- * string → `text_mismatch` (both strings attached); empty/absent rendered text →
408
- * `text_missing`. An element with no reference text signal produces no fact —
409
- * absence of a signal is never reported as a divergence, so a rendered string the
410
- * reference never declared is silent.
411
- *
412
- * Pure and platform-agnostic; order-independent and deterministic (output sorted
413
- * by element id). The deriver reports facts only; significance is a later,
414
- * separately-wired concern.
415
- */
416
- export function deriveCopyFacts(rendered, reference) {
417
- const facts = [];
418
- for (const elementId of Object.keys(reference).sort()) {
419
- const referenceText = reference[elementId];
420
- if (!hasText(referenceText))
421
- continue; // no reference signal → no fact
422
- const renderedText = rendered[elementId];
423
- if (!hasText(renderedText)) {
424
- facts.push({ kind: 'text_missing', elementId, reference: referenceText });
425
- }
426
- else if (renderedText !== referenceText) {
427
- facts.push({ kind: 'text_mismatch', elementId, rendered: renderedText, reference: referenceText });
428
- }
429
- }
430
- return facts;
431
- }
432
- /**
433
- * Span facts (issue 16 + color spans) from the per-element comparison of
434
- * rendered styled extents vs the declared reference span, keyed on the stable
435
- * element id. Channel-scoped: a `background` declaration (highlight chip)
436
- * compares against background-carrying rendered spans; a `color` declaration
437
- * (partial foreground emphasis) against color-carrying ones — a color-only
438
- * rendered span never satisfies a chip declaration. A channel-less
439
- * declaration ({text} only) keeps its legacy semantics (any extent matches).
440
- *
441
- * Capability gate: the COLOR channel compares only when `colorCapable` is
442
- * true (the dump's `spanChannels` marker includes "color") — an old
443
- * emitter's silence is "cannot see", never "no color span rendered". Within
444
- * a capable dump, an ABSENT `textSpans` is a real absence for a color
445
- * declaration (the segment rendered unstyled); background/legacy
446
- * declarations keep the old-package tolerance (no spans array → no fact).
447
- * A wrong extent on either channel is one `span_mismatch`; a matching extent
448
- * whose color diverges is a `span_color_mismatch` carrying both hex values.
449
- *
450
- * Pure and platform-agnostic; order-independent and deterministic (output
451
- * sorted by element id). The deriver reports facts only; significance is a
452
- * later, separately-wired concern.
453
- */
454
- export function deriveSpanFacts(rendered, reference, colorCapable) {
455
- const facts = [];
456
- for (const elementId of Object.keys(reference).sort()) {
457
- const declared = reference[elementId];
458
- const declaredText = declared && typeof declared === 'object' ? declared.text : undefined;
459
- if (!declared || !hasText(declaredText))
460
- continue; // no reference signal → no fact
461
- const spans = rendered[elementId];
462
- const all = Array.isArray(spans) ? spans : null;
463
- const wantsBackground = hasText(declared.background);
464
- const wantsColor = hasText(declared.color) && colorCapable === true;
465
- let extentOk = true;
466
- const scopedExtents = [];
467
- // Background / channel-less half.
468
- if (wantsBackground || !hasText(declared.color)) {
469
- if (all === null) {
470
- // Node emitted no spans: old package / spanless text — blind on this
471
- // half. Only a capable color declaration can still speak below.
472
- if (!wantsColor)
473
- continue;
474
- }
475
- else {
476
- const pool = wantsBackground ? all.filter((s) => hasText(s?.background)) : all;
477
- const extents = pool.map((s) => s?.text).filter(hasText);
478
- scopedExtents.push(...extents);
479
- if (!extents.some((t) => t === declaredText))
480
- extentOk = false;
481
- }
482
- }
483
- // Color half (capability-gated above).
484
- let renderedColorForExtent = null;
485
- if (wantsColor) {
486
- const pool = (all ?? []).filter((s) => hasText(s?.color));
487
- scopedExtents.push(...pool.map((s) => s?.text).filter(hasText));
488
- const matches = pool.filter((s) => s?.text === declaredText);
489
- if (matches.length === 0) {
490
- extentOk = false;
491
- }
492
- else {
493
- const declaredColor = String(declared.color).toLowerCase();
494
- const hit = matches.some((s) => String(s.color).toLowerCase() === declaredColor);
495
- if (!hit)
496
- renderedColorForExtent = String(matches[0].color).toLowerCase();
497
- }
498
- }
499
- if (!extentOk) {
500
- facts.push({
501
- kind: 'span_mismatch',
502
- elementId,
503
- rendered: [...new Set(scopedExtents)].join(', '),
504
- reference: declaredText,
505
- });
506
- continue;
507
- }
508
- if (renderedColorForExtent !== null) {
509
- facts.push({
510
- kind: 'span_color_mismatch',
511
- elementId,
512
- extent: declaredText,
513
- rendered: renderedColorForExtent,
514
- reference: String(declared.color).toLowerCase(),
515
- });
516
- }
517
- }
518
- return facts;
519
- }
520
- /**
521
- * Line facts (issue 18) from the per-element comparison of the rendered
522
- * laid-out lines vs the declared reference split, keyed on the stable element
523
- * id. For each element the REFERENCE declares a split for: rendered line
524
- * texts equal to the declared line texts (same count, same order) → no fact;
525
- * anything else — the break on a different word, a dropped break (fewer
526
- * lines), an extra break — is ONE `line_break_mismatch` carrying both splits
527
- * newline-joined. An element whose node emitted NO lines produces no fact —
528
- * old committed packages don't emit `textLines`, and absence of a signal is
529
- * never reported as a divergence (regenerating the package picks it up).
530
- *
531
- * Pure and platform-agnostic; order-independent and deterministic (output
532
- * sorted by element id). The deriver reports facts only; significance is a
533
- * later, separately-wired concern.
534
- */
535
- export function deriveLineFacts(rendered, reference) {
536
- const facts = [];
537
- for (const elementId of Object.keys(reference).sort()) {
538
- const declared = reference[elementId];
539
- if (!Array.isArray(declared))
540
- continue;
541
- const declaredLines = declared.filter(hasText);
542
- if (declaredLines.length === 0)
543
- continue; // no reference signal → no fact
544
- const lines = rendered[elementId];
545
- if (!Array.isArray(lines))
546
- continue; // node emitted no lines → no signal
547
- const renderedLines = lines.map((l) => l.text).filter(hasText);
548
- if (renderedLines.length === declaredLines.length &&
549
- renderedLines.every((t, i) => t === declaredLines[i])) {
550
- continue;
551
- }
552
- facts.push({
553
- kind: 'line_break_mismatch',
554
- elementId,
555
- rendered: renderedLines.join('\n'),
556
- reference: declaredLines.join('\n'),
557
- });
558
- }
559
- return facts;
560
- }
561
- const COLOR_CHANNELS = ['fill', 'text'];
562
- /** Parse a #rrggbb / rrggbb hex string into 0–255 channels (case-insensitive). */
563
- function parseHex(hex) {
564
- const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
565
- if (!m)
566
- return null;
567
- const n = parseInt(m[1], 16);
568
- return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
569
- }
570
- /** One sRGB channel (0–1) → linear-light. */
571
- function srgbToLinear(c) {
572
- return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
573
- }
574
- /** The CIELAB f() companding function. */
575
- function labF(t) {
576
- return t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116;
577
- }
578
- // D65 reference white (CIE 1931 2°), scaled to Y=100.
579
- const D65 = [95.047, 100.0, 108.883];
580
- /**
581
- * Convert an sRGB hex string to CIELAB (D65). White → ~[100,0,0], black → L 0.
582
- * Returns [0,0,0] for an unparseable string so a malformed color never throws
583
- * (the deriver guards on signal presence separately).
584
- */
585
- export function hexToLab(hex) {
586
- const rgb = parseHex(hex);
587
- if (!rgb)
588
- return [0, 0, 0];
589
- const [r, g, b] = rgb.map((v) => srgbToLinear(v / 255));
590
- // linear sRGB → XYZ (D65), scaled to 0–100
591
- const X = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100;
592
- const Y = (r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100;
593
- const Z = (r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100;
594
- const fx = labF(X / D65[0]);
595
- const fy = labF(Y / D65[1]);
596
- const fz = labF(Z / D65[2]);
597
- return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
598
- }
599
- const deg2rad = (d) => (d * Math.PI) / 180;
600
- const rad2deg = (r) => (r * 180) / Math.PI;
601
- /**
602
- * CIEDE2000 colour-difference ΔE00 between two CIELAB colours (kL=kC=kH=1).
603
- * Implements Sharma, Wu & Dalal (2005); validated against their supplementary
604
- * test data. Pure and symmetric; identical colours give 0.
605
- */
606
- export function ciede2000(lab1, lab2) {
607
- const [L1, a1, b1] = lab1;
608
- const [L2, a2, b2] = lab2;
609
- const C1 = Math.sqrt(a1 * a1 + b1 * b1);
610
- const C2 = Math.sqrt(a2 * a2 + b2 * b2);
611
- const Cbar = (C1 + C2) / 2;
612
- const Cbar7 = Math.pow(Cbar, 7);
613
- const G = 0.5 * (1 - Math.sqrt(Cbar7 / (Cbar7 + 6103515625))); // 25^7 = 6103515625
614
- const a1p = (1 + G) * a1;
615
- const a2p = (1 + G) * a2;
616
- const C1p = Math.sqrt(a1p * a1p + b1 * b1);
617
- const C2p = Math.sqrt(a2p * a2p + b2 * b2);
618
- const h1p = hueDeg(b1, a1p);
619
- const h2p = hueDeg(b2, a2p);
620
- const dLp = L2 - L1;
621
- const dCp = C2p - C1p;
622
- let dhp = 0;
623
- if (C1p * C2p !== 0) {
624
- dhp = h2p - h1p;
625
- if (dhp > 180)
626
- dhp -= 360;
627
- else if (dhp < -180)
628
- dhp += 360;
629
- }
630
- const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(deg2rad(dhp / 2));
631
- const Lbarp = (L1 + L2) / 2;
632
- const Cbarp = (C1p + C2p) / 2;
633
- let hbarp;
634
- if (C1p * C2p === 0) {
635
- hbarp = h1p + h2p;
636
- }
637
- else if (Math.abs(h1p - h2p) > 180) {
638
- hbarp = h1p + h2p < 360 ? (h1p + h2p + 360) / 2 : (h1p + h2p - 360) / 2;
639
- }
640
- else {
641
- hbarp = (h1p + h2p) / 2;
642
- }
643
- const T = 1 -
644
- 0.17 * Math.cos(deg2rad(hbarp - 30)) +
645
- 0.24 * Math.cos(deg2rad(2 * hbarp)) +
646
- 0.32 * Math.cos(deg2rad(3 * hbarp + 6)) -
647
- 0.2 * Math.cos(deg2rad(4 * hbarp - 63));
648
- const dTheta = 30 * Math.exp(-(((hbarp - 275) / 25) ** 2));
649
- const Cbarp7 = Math.pow(Cbarp, 7);
650
- const Rc = 2 * Math.sqrt(Cbarp7 / (Cbarp7 + 6103515625));
651
- const Sl = 1 + (0.015 * (Lbarp - 50) ** 2) / Math.sqrt(20 + (Lbarp - 50) ** 2);
652
- const Sc = 1 + 0.045 * Cbarp;
653
- const Sh = 1 + 0.015 * Cbarp * T;
654
- const Rt = -Math.sin(deg2rad(2 * dTheta)) * Rc;
655
- const termL = dLp / Sl;
656
- const termC = dCp / Sc;
657
- const termH = dHp / Sh;
658
- return Math.sqrt(termL * termL + termC * termC + termH * termH + Rt * termC * termH);
659
- }
660
- /** atan2(b, a) in degrees, normalised to [0, 360). */
661
- function hueDeg(b, a) {
662
- if (a === 0 && b === 0)
663
- return 0;
664
- const deg = rad2deg(Math.atan2(b, a));
665
- return deg >= 0 ? deg : deg + 360;
666
- }
667
- /**
668
- * Border facts (property-recognizer 01) — the design's `stroke` against the
669
- * render's captured border. THE case this exists for: an 18dp badge with a 4dp
670
- * white ring inside it rendered as a plain green blob, about which the gate said
671
- * nothing at all, because the design's stroke lived in an authoring hint and the
672
- * dump captured no border. Nothing compared, so an unmeasured property read as a
673
- * pass.
674
- *
675
- * The DESIGN is the only side that raises the question, exactly as with alpha: an
676
- * entry with no declared `stroke` is skipped, so a wrapper adding a ring the
677
- * design never asked for is not something this calls wrong.
678
- *
679
- * Three kinds, because they are three different fixes — absent / wrong colour /
680
- * wrong thickness. Folding width into `border` would report "the ring is the
681
- * wrong colour" about a ring whose colour is exactly right.
682
- *
683
- * `capable` gates the ABSENCE case only, and both directions of that gate are
684
- * wrong in a way worth naming: without it, a border-blind dump would fire
685
- * `border_missing` on every stroked design in the catalogue; with it applied too
686
- * broadly, a real divergence on a capable dump would go unreported. A colour or
687
- * width difference is a genuine measurement either way — only "I saw no border"
688
- * depends on being able to see borders at all.
689
- *
690
- * Pure and platform-agnostic; deterministic (sorted by element id). Mirrors the
691
- * CLI preview twin in `packages/cli/lib/fidelity-structural.mjs`.
692
- */
693
- export function deriveBorderFacts(rendered, reference, capable) {
694
- const facts = [];
695
- for (const elementId of Object.keys(reference).sort()) {
696
- const ref = reference[elementId];
697
- if (!ref || !hasText(ref.stroke))
698
- continue;
699
- const ren = rendered[elementId];
700
- const renderedStroke = ren ? ren.stroke : undefined;
701
- if (!hasText(renderedStroke)) {
702
- if (capable) {
703
- const width = ref.strokeWeight;
704
- facts.push({
705
- kind: 'border_missing',
706
- elementId,
707
- reference: ref.stroke,
708
- ...(typeof width === 'number' ? { referenceWidth: width } : {}),
709
- });
710
- }
711
- continue;
712
- }
713
- if (renderedStroke.toLowerCase() !== ref.stroke.toLowerCase()) {
714
- facts.push({
715
- kind: 'border',
716
- elementId,
717
- rendered: renderedStroke,
718
- reference: ref.stroke,
719
- });
720
- }
721
- // Width only when the DESIGN states one — an undeclared weight says nothing
722
- // about thickness, the same asymmetry alpha uses. 0.5dp is the floor below
723
- // which two rings are the same ring.
724
- const refW = ref.strokeWeight;
725
- const renW = ren ? ren.strokeWeight : undefined;
726
- if (typeof refW === 'number' && typeof renW === 'number' && Math.abs(refW - renW) > 0.5) {
727
- facts.push({ kind: 'border_width', elementId, rendered: renW, reference: refW });
728
- }
729
- }
730
- return facts;
731
- }
732
- /**
733
- * Color facts from the per-element, per-channel comparison of rendered vs
734
- * reference color, keyed on the stable element id shared with Signal B. For each
735
- * element/channel the REFERENCE declares a color for and the render also exposes
736
- * one: identical color → no fact; any divergence → a `color` fact carrying the
737
- * channel (fill vs text), both hex values, and the CIEDE2000 ΔE as the metric.
738
- * Fill and text are distinct channels, so a correct background with a wrong text
739
- * color and the reverse are separate facts, never collapsed. A missing rendered
740
- * or reference color for a channel is no signal → no fact.
741
- *
742
- * Pure and platform-agnostic; order-independent and deterministic (output sorted
743
- * by element id then channel, fill before text). Measurement only — significance
744
- * is a later, separately-wired concern.
745
- */
746
- export function deriveColorFacts(rendered, reference) {
747
- const facts = [];
748
- for (const elementId of Object.keys(reference).sort()) {
749
- const refColors = reference[elementId];
750
- const renColors = rendered[elementId];
751
- if (!refColors)
752
- continue;
753
- for (const channel of COLOR_CHANNELS) {
754
- const referenceColor = refColors[channel];
755
- const renderedColor = renColors ? renColors[channel] : undefined;
756
- if (!hasText(referenceColor) || !hasText(renderedColor))
757
- continue; // no signal
758
- // Opacity (issue 14b): graded only when the DESIGN states a translucency —
759
- // an opaque design says nothing about alpha, so a render reporting one is not
760
- // a divergence — and only past a rounding step. Independent of the hex
761
- // compare: the colour can be right while the state (disabled vs enabled) is
762
- // wrong, and a matching hex gives ΔE 0, which would never flag.
763
- const referenceAlpha = refColors[`${channel}Alpha`];
764
- const renderedAlpha = renColors ? renColors[`${channel}Alpha`] : undefined;
765
- if (typeof referenceAlpha === 'number') {
766
- const renA = typeof renderedAlpha === 'number' ? renderedAlpha : 1;
767
- if (Math.abs(referenceAlpha - renA) > 0.01) {
768
- facts.push({ kind: 'color_alpha', elementId, channel, rendered: renA, reference: referenceAlpha });
769
- }
770
- }
771
- if (renderedColor.toLowerCase() === referenceColor.toLowerCase())
772
- continue; // matching
773
- facts.push({
774
- kind: 'color',
775
- elementId,
776
- channel,
777
- rendered: renderedColor,
778
- reference: referenceColor,
779
- deltaE: ciede2000(hexToLab(renderedColor), hexToLab(referenceColor)),
780
- });
781
- }
782
- }
783
- return facts;
784
- }
785
- /** Map a weight-name token (regular, semibold, …) to its OpenType numeric weight. */
786
- const WEIGHT_NAMES = {
787
- thin: 100,
788
- hairline: 100,
789
- extralight: 200,
790
- ultralight: 200,
791
- light: 300,
792
- normal: 400,
793
- regular: 400,
794
- book: 400,
795
- medium: 500,
796
- semibold: 600,
797
- demibold: 600,
798
- bold: 700,
799
- extrabold: 800,
800
- ultrabold: 800,
801
- black: 900,
802
- heavy: 900,
803
- };
804
- /** The canonical comparison form of a typeface: a normalized family key and an
805
- * effective numeric weight. A weight-name suffix on the family ("Poppins
806
- * SemiBold", "fira_sans_medium") is stripped from the family and, when no explicit
807
- * numeric weight is supplied, becomes the weight. An explicit numeric `weight`
808
- * always wins (it is the measured resolved value). Returns null when there is no
809
- * family signal (no font to compare). */
810
- function normalizeTypeface(spec) {
811
- const raw = typeof spec?.family === 'string' ? spec.family.trim() : '';
812
- if (raw.length === 0)
813
- return null;
814
- // Split into tokens on separators (hyphen, underscore, space) — the boundaries
815
- // every real encoding uses between family and a weight suffix: PostScript names
816
- // ("Poppins-SemiBold"), resource names ("fira_sans_medium"), and design specs
817
- // ("Fira Sans" + numeric weight). The family key collapses to alphanumerics
818
- // below, so a multi-token family ("Fira Sans") still matches a glued one.
819
- const tokens = raw.split(/[\s\-_]+/).filter((t) => t.length > 0);
820
- let derivedWeight;
821
- // Strip recognized weight-name tokens off the END of the family (a leading
822
- // weight token is part of the family identity, e.g. "Black Ops One").
823
- while (tokens.length > 1) {
824
- const candidate = tokens[tokens.length - 1].toLowerCase();
825
- if (candidate in WEIGHT_NAMES) {
826
- derivedWeight = WEIGHT_NAMES[candidate];
827
- tokens.pop();
828
- }
829
- else {
830
- break;
831
- }
832
- }
833
- const family = tokens.join('').toLowerCase().replace(/[^a-z0-9]/g, '');
834
- const explicit = typeof spec?.weight === 'number' && Number.isFinite(spec.weight) ? spec.weight : undefined;
835
- const weight = explicit ?? derivedWeight ?? 400;
836
- return { family, weight };
837
- }
838
- /**
839
- * Typeface facts from the per-element comparison of the RESOLVED render font vs the
840
- * DESIGN reference font, keyed on the stable element id shared with Signal B. For
841
- * each element the reference declares a typeface for and the render also resolved
842
- * one: normalize both (family-name + weight-encoding) and emit a `typeface` fact
843
- * when the normalized family OR weight differs. A normalization-equivalent pair
844
- * ("Poppins-SemiBold" vs "Poppins" + 600) yields no fact. A missing rendered or
845
- * reference font for an element is no signal → no fact (graceful, no false
846
- * positive). The fact carries the raw `rendered`/`reference` specs as given, so the
847
- * finding names the actual fonts.
848
- *
849
- * Pure and platform-agnostic; order-independent and deterministic (output sorted by
850
- * element id). Measurement only — significance is a later, separately-wired concern.
851
- */
852
- export function deriveTypefaceFacts(rendered, reference) {
853
- const facts = [];
854
- for (const elementId of Object.keys(reference).sort()) {
855
- const refNorm = normalizeTypeface(reference[elementId]);
856
- const renNorm = normalizeTypeface(rendered[elementId]);
857
- if (!refNorm || !renNorm)
858
- continue; // no signal on one side → no fact
859
- if (refNorm.family === renNorm.family && refNorm.weight === renNorm.weight)
860
- continue; // equivalent
861
- facts.push({
862
- kind: 'typeface',
863
- elementId,
864
- channel: 'text',
865
- rendered: rendered[elementId],
866
- reference: reference[elementId],
867
- });
868
- }
869
- return facts;
870
- }
871
- /**
872
- * Sub-unit tolerance for a type metric, in sp.
873
- *
874
- * Design tools and layout engines disagree in the last fraction of a point: a
875
- * design exported at 15.999 against a style declaring 16 is the same type, and a
876
- * fact for it would flap on every render forever. The band is deliberately well
877
- * under the smallest difference anyone authors — no design system steps type by
878
- * a tenth of a point — so it absorbs representation noise without absorbing a
879
- * real divergence. The MEASUREMENT it guards is exact; this only decides whether
880
- * there is anything to report (ADR-0004: strict, not loosen).
881
- */
882
- const TYPE_METRIC_TOLERANCE_SP = 0.25;
883
- /** A finite number, or undefined — the only values a metric may compare on. A
884
- * NaN/Infinity that reached the dump is no measurement, not a zero. */
885
- function finiteMetric(value) {
886
- return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
887
- }
888
- /**
889
- * Type-metric facts from the per-element comparison of the LAID-OUT metrics
890
- * against the design's, keyed on the stable element id shared with Signal B.
891
- *
892
- * For each element the reference declares and the render measured, each of the
893
- * three metrics is compared independently and emits its own fact when the two
894
- * differ by more than the sub-unit tolerance. A metric missing on EITHER side is
895
- * no signal and yields no fact — a design that declares no tracking is not
896
- * asserting zero, and a style that leaves leading Unspecified has not chosen a
897
- * value to be wrong about.
898
- *
899
- * Pure and platform-agnostic; deterministic (sorted by element id, then by the
900
- * metric order a finding reads them in). Measurement only — significance and
901
- * wording live downstream.
902
- */
903
- export function deriveTypeMetricFacts(rendered, reference) {
904
- const facts = [];
905
- for (const elementId of Object.keys(reference).sort()) {
906
- const ref = reference[elementId];
907
- const ren = rendered[elementId];
908
- if (!ref || !ren)
909
- continue;
910
- for (const metric of TYPE_METRIC_KEYS) {
911
- const referenceValue = finiteMetric(ref[metric]);
912
- const renderedValue = finiteMetric(ren[metric]);
913
- if (referenceValue === undefined || renderedValue === undefined)
914
- continue;
915
- if (Math.abs(renderedValue - referenceValue) <= TYPE_METRIC_TOLERANCE_SP)
916
- continue;
917
- facts.push({
918
- kind: 'type_metric',
919
- elementId,
920
- channel: 'text',
921
- metric,
922
- rendered: renderedValue,
923
- reference: referenceValue,
924
- });
925
- }
926
- }
927
- return facts;
928
- }
929
- const DEFAULT_SIZE_TOLERANCE_DP = 1;
930
- /** Per-edge overhang (clip) delta tolerance, dp. Logged-in Δ9 flags, guest Δ2 passes. */
931
- const DEFAULT_OVERHANG_TOLERANCE_DP = 2;
932
- // ---- Text-aware size band (line-box residue) ----
933
- //
934
- // JVM (Robolectric) text metrics differ from Figma's nominal line boxes by
935
- // roughly ±2dp per laid-out line, so a text-driven element legitimately renders
936
- // a few dp off its design size. The SIGNIFICANCE flag (`sizeFlag`) gets a band
937
- // scaled by the element's subtree line count — the MEASUREMENT (deltas) stays
938
- // exact, and non-text elements keep the strict band (ADR-0004: strict, not
939
- // loosen). Calibrated on the logged noise cases: a 1-line pill gets ±4dp
940
- // (covers +4/−3), a 3-line card ±8dp (covers −7); the cap keeps the ≥15dp
941
- // fixed-size bug class (e.g. 328 vs 343) failing no matter how tall the stack.
942
- /** Per laid-out text line of JVM font-metric residue (dp). */
943
- const TEXT_LINE_RESIDUE_DP = 2;
944
- /** Box rounding on a text-driven element (dp), on top of the per-line residue. */
945
- const TEXT_BOX_RESIDUE_DP = 2;
946
- /** Ceiling for the text-driven size band (dp). */
947
- const TEXT_SIZE_SLACK_MAX_DP = 8;
948
- /** Laid-out lines one node contributes: the `textLines` count when the emitter
949
- * tracked them, else 1 for a text-carrying node (old packages), else 0. */
950
- function nodeTextLines(node) {
951
- if (Array.isArray(node.textLines))
952
- return node.textLines.length;
953
- return typeof node.text === 'string' && node.text.length > 0 ? 1 : 0;
954
- }
955
- /**
956
- * Total laid-out text lines in `elementId`'s subtree (the element node plus
957
- * every descendant). 0 = the element renders no text — not text-driven.
958
- * Visits node OBJECTS (never re-follows one), so a malformed parent cycle or
959
- * a duplicated id cannot loop.
960
- */
961
- function subtreeTextLineCount(elementId, byId, childrenByParent) {
962
- const root = byId.get(elementId);
963
- if (!root)
964
- return 0;
965
- let lines = 0;
966
- const visited = new Set();
967
- const stack = [root];
968
- while (stack.length > 0) {
969
- const node = stack.pop();
970
- if (visited.has(node))
971
- continue;
972
- visited.add(node);
973
- lines += nodeTextLines(node);
974
- for (const child of childrenByParent.get(node.id) ?? [])
975
- stack.push(child);
976
- }
977
- return lines;
978
- }
979
- /**
980
- * One element's render ancestry: every ancestor id from its IMMEDIATE parent up
981
- * to the root (icon-ink-ratio 05). Empty for a root node.
982
- *
983
- * Ids, not nodes, because the judge compares them against DESIGN keys — a render
984
- * parent is usually an untagged layout node whose id is a Compose number, and it
985
- * gets identified by the declared elements inside it rather than by its name.
986
- *
987
- * Guards against a parent cycle the same way `subtreeTextLineCount` does: a
988
- * malformed dump must not hang a build.
989
- */
990
- function ancestorChain(elementId, byId) {
991
- const out = [];
992
- const seen = new Set([elementId]);
993
- let cursor = byId.get(elementId)?.parentId ?? null;
994
- while (cursor != null && !seen.has(cursor)) {
995
- seen.add(cursor);
996
- out.push(cursor);
997
- cursor = byId.get(cursor)?.parentId ?? null;
998
- }
999
- return out;
1000
- }
1001
- /** The size band for one element: the line-scaled text band when its subtree
1002
- * lays out text (never stricter than the caller's tolerance), else strict. */
1003
- function sizeBandDp(subtreeLines, sizeTolerancePx) {
1004
- if (subtreeLines <= 0)
1005
- return sizeTolerancePx;
1006
- return Math.max(sizeTolerancePx, Math.min(TEXT_SIZE_SLACK_MAX_DP, TEXT_BOX_RESIDUE_DP + TEXT_LINE_RESIDUE_DP * subtreeLines));
1007
- }
1008
- /** An element only "overflows" a frame when its rendered overhang exceeds this, dp. */
1009
- const OVERHANG_MIN_DP = 0.5;
1010
- const OVERHANG_EDGES = ['left', 'top', 'right', 'bottom'];
1011
- const DIMENSION_SHORT = {
1012
- width: 'w',
1013
- height: 'h',
1014
- x: 'x',
1015
- y: 'y',
1016
- };
1017
- const numOrNaN = (v) => (typeof v === 'number' ? v : NaN);
1018
- /**
1019
- * Per-edge overhang of one element vs its frame (the element's parent in the
1020
- * layout dump). Returns null when the element is fully contained (no edge
1021
- * overflows), the frame is absent from the render, or the frame isn't declared in
1022
- * `referenceGeometry` (so the design overhang can't be computed — an advisory
1023
- * covers that case). Frame-relative edge overhang is the real clip and IS flagged;
1024
- * absolute canvas x/y stays suppressed elsewhere (canvas-width artifact).
1025
- */
1026
- /** The size axis a clip edge bears on: top/bottom → height, left/right → width. */
1027
- function edgeAxis(edge) {
1028
- return edge === 'top' || edge === 'bottom' ? 'height' : 'width';
1029
- }
1030
- function computeOverhang(elementId, elementDp, byId, generated, referenceGeometry, overhangTolerancePx, sizeTolerancePx, duplicates) {
1031
- const node = byId.get(elementId);
1032
- const frameId = node?.parentId;
1033
- if (!frameId)
1034
- return undefined;
1035
- // A duplicated frame tag resolves last-wins in `generated`/`byId`, so the
1036
- // overhang would measure against an arbitrary instance — withheld (the
1037
- // duplicate_tag fact carries the collision).
1038
- if (duplicates?.has(frameId))
1039
- return undefined;
1040
- const frameDp = generated[frameId];
1041
- if (!frameDp)
1042
- return undefined;
1043
- const frameRef = referenceGeometry[frameId];
1044
- const elRef = referenceGeometry[elementId];
1045
- if (!frameRef || !elRef)
1046
- return undefined; // frame undeclared → advisory, not error
1047
- const elX = elementDp.x ?? NaN;
1048
- const elY = elementDp.y ?? NaN;
1049
- const fX = frameDp.x ?? NaN;
1050
- const fY = frameDp.y ?? NaN;
1051
- // Rendered AND design overhang per edge (dp); positive = element extends past
1052
- // the frame. Both sides are compared so a clip the design requires but the
1053
- // render omits (a MISSING clip — render contained/tangent) surfaces too.
1054
- const renderedRaw = {
1055
- left: fX - elX,
1056
- top: fY - elY,
1057
- right: elX + elementDp.width - (fX + frameDp.width),
1058
- bottom: elY + elementDp.height - (fY + frameDp.height),
1059
- };
1060
- const designRaw = {
1061
- left: numOrNaN(frameRef.x) - numOrNaN(elRef.x),
1062
- top: numOrNaN(frameRef.y) - numOrNaN(elRef.y),
1063
- right: numOrNaN(elRef.x) + numOrNaN(elRef.w) - (numOrNaN(frameRef.x) + numOrNaN(frameRef.w)),
1064
- bottom: numOrNaN(elRef.y) + numOrNaN(elRef.h) - (numOrNaN(frameRef.y) + numOrNaN(frameRef.h)),
1065
- };
1066
- // An edge is a candidate when EITHER side overflows it — so over-, under-,
1067
- // missing-, and spurious-clip all qualify (not just render-side overflow).
1068
- const candidates = OVERHANG_EDGES.filter((e) => renderedRaw[e] > OVERHANG_MIN_DP || designRaw[e] > OVERHANG_MIN_DP);
1069
- if (candidates.length === 0)
1070
- return undefined; // neither side overflows — not a clip element
1071
- const rendered = {};
1072
- const design = {};
1073
- const deltas = [];
1074
- for (const edge of candidates) {
1075
- const dRaw = designRaw[edge];
1076
- if (!Number.isFinite(dRaw))
1077
- continue; // design coords missing for this edge
1078
- rendered[edge] = Math.round(renderedRaw[edge]);
1079
- design[edge] = dRaw;
1080
- const delta = renderedRaw[edge] - dRaw;
1081
- if (Math.abs(delta) > overhangTolerancePx)
1082
- deltas.push({ edge, delta: Math.round(delta) });
1083
- }
1084
- if (Object.keys(rendered).length === 0)
1085
- return undefined;
1086
- // Why is the clip wrong? Trace each flagged edge to the element's OWN size: a
1087
- // clip is `sizeDriven` when the element's rendered size on that axis differs
1088
- // from its design size, and `frameRelative` when that wrong size also matches
1089
- // the frame's rendered size (while the design sizes differ) — the
1090
- // fillMaxHeight/matchParentSize signature whose fix is a fixed size, not a
1091
- // re-tuned clip. This is the diagnosis the judge must not wave off as "intended".
1092
- const elSize = { width: elementDp.width, height: elementDp.height };
1093
- const frameSize = { width: frameDp.width, height: frameDp.height };
1094
- const elDesign = { width: numOrNaN(elRef.w), height: numOrNaN(elRef.h) };
1095
- const frameDesign = { width: numOrNaN(frameRef.w), height: numOrNaN(frameRef.h) };
1096
- // Frame-relative position per axis (NOT absolute x/y — that's the suppressed
1097
- // canvas artifact; this offset is what actually sets the clip).
1098
- const elRelPos = { width: elX - fX, height: elY - fY };
1099
- const elRelPosDesign = {
1100
- width: numOrNaN(elRef.x) - numOrNaN(frameRef.x),
1101
- height: numOrNaN(elRef.y) - numOrNaN(frameRef.y),
1102
- };
1103
- let sizeDriven = false;
1104
- let frameRelative = false;
1105
- let positionDriven = false;
1106
- for (const { edge } of deltas) {
1107
- const axis = edgeAxis(edge);
1108
- const elD = elDesign[axis];
1109
- if (Number.isFinite(elD) && Math.abs(elSize[axis] - elD) > sizeTolerancePx) {
1110
- sizeDriven = true;
1111
- const frameD = frameDesign[axis];
1112
- if (Math.abs(elSize[axis] - frameSize[axis]) <= sizeTolerancePx &&
1113
- Number.isFinite(frameD) &&
1114
- Math.abs(elD - frameD) > sizeTolerancePx) {
1115
- frameRelative = true;
1116
- }
1117
- }
1118
- const relD = elRelPosDesign[axis];
1119
- if (Number.isFinite(relD) && Math.abs(elRelPos[axis] - relD) > sizeTolerancePx) {
1120
- positionDriven = true;
1121
- }
1122
- }
1123
- return { unit: 'dp', rendered, design, deltas, clipFlag: deltas.length > 0, sizeDriven, frameRelative, positionDriven };
1124
- }
1125
- /** Auto-generated (untagged) node id, e.g. "node-11" — a `testTag` gives a real id. */
1126
- const UNTAGGED_ID = /^node-\d+$/;
1127
- /**
1128
- * Node ids the derived facts name (`nodeId`/`nodeIds`/`clipperId`). A synthetic
1129
- * node can participate in a judged finding (the deriver runs over ALL dump
1130
- * nodes), and without its box the finding's scene is undrawable — the fact
1131
- * already puts the synthetic name in the customer-facing text, so persisting
1132
- * its box adds nothing new. Bounded by the fact count.
1133
- */
1134
- function factNamedNodeIds(facts) {
1135
- const ids = new Set();
1136
- for (const fact of facts) {
1137
- const f = fact;
1138
- if (typeof f.nodeId === 'string')
1139
- ids.add(f.nodeId);
1140
- if (typeof f.clipperId === 'string')
1141
- ids.add(f.clipperId);
1142
- if (Array.isArray(f.nodeIds)) {
1143
- for (const id of f.nodeIds)
1144
- if (typeof id === 'string')
1145
- ids.add(id);
1146
- }
1147
- }
1148
- return ids;
1149
- }
1150
- /** Rendered geometry per node, normalised px → dp via density (unclipped extent). */
1151
- function generatedGeometryDp(dump, density) {
1152
- const d = density > 0 ? density : 1;
1153
- const out = {};
1154
- for (const node of dump.nodes) {
1155
- const b = node.unclippedBounds ?? node.bounds;
1156
- if (!b)
1157
- continue;
1158
- out[node.id] = {
1159
- width: (b.right - b.left) / d,
1160
- height: (b.bottom - b.top) / d,
1161
- x: b.left / d,
1162
- y: b.top / d,
1163
- };
1164
- }
1165
- return out;
1166
- }
1167
- /**
1168
- * The persisted precision of a rendered box: 2 decimals. The paint is sub-dp,
1169
- * and quantizing the box to whole dp moved it up to half a dp off the pixels
1170
- * it describes — visibly mis-wrapped once evidence is magnified. Comparison
1171
- * semantics do not move: deltas are computed from raw values and persisted
1172
- * integer-rounded, the flag bands read raw deltas, and the judge rounds
1173
- * rendered boxes back to whole dp on read.
1174
- */
1175
- function roundMeasure(value) {
1176
- return Number(value.toFixed(2));
1177
- }
1178
- function referenceGeometryToDiffShape(referenceGeometry) {
1179
- const out = {};
1180
- for (const id of Object.keys(referenceGeometry)) {
1181
- const g = referenceGeometry[id];
1182
- if (!g || typeof g !== 'object')
1183
- continue;
1184
- const r = { width: NaN, height: NaN };
1185
- if (typeof g.w === 'number')
1186
- r.width = g.w;
1187
- if (typeof g.h === 'number')
1188
- r.height = g.h;
1189
- if (typeof g.x === 'number')
1190
- r.x = g.x;
1191
- if (typeof g.y === 'number')
1192
- r.y = g.y;
1193
- out[id] = r;
1194
- }
1195
- return out;
1196
- }
1197
- /**
1198
- * Assemble the inline `structural` block (ADR-0004): Signal-A facts always, plus
1199
- * the Signal-B geometry block when `referenceGeometry` is supplied. Size (w/h)
1200
- * deltas drive `sizeFlag`/`summary.clean`; x/y position deltas are reported but
1201
- * never flagged (canvas-width artifact). Mirrors Invarn's `buildStructuralBlock`.
1202
- */
1203
- export function buildStructuralBlock(dump, referenceGeometry, density, options = {}, appearanceReference, referenceFrame, anchorFrame) {
1204
- const facts = deriveStructuralFacts(dump);
1205
- const d = typeof density === 'number' && density > 0 ? density : 1;
1206
- const sizeTolerancePx = options.sizeTolerancePx ?? DEFAULT_SIZE_TOLERANCE_DP;
1207
- const overhangTolerancePx = options.overhangTolerancePx ?? DEFAULT_OVERHANG_TOLERANCE_DP;
1208
- // Design-frame anchor (composition runs): assert the captured root against
1209
- // the declared frame. A mismatch is an authoring fact about the WRAPPER
1210
- // (mis-sized root) and withholds placement facts for the State — measured
1211
- // against a wrong canvas they would be garbage, not drift.
1212
- let placement;
1213
- if (referenceFrame &&
1214
- typeof referenceFrame.w === 'number' &&
1215
- typeof referenceFrame.h === 'number') {
1216
- const capturedW = (dump.canvas.right - dump.canvas.left) / d;
1217
- const capturedH = (dump.canvas.bottom - dump.canvas.top) / d;
1218
- const frameDeltas = [];
1219
- if (Math.abs(capturedW - referenceFrame.w) > DEFAULT_FRAME_TOLERANCE_DP) {
1220
- frameDeltas.push({ dimension: 'w', delta: Math.round(capturedW - referenceFrame.w) });
1221
- }
1222
- if (Math.abs(capturedH - referenceFrame.h) > DEFAULT_FRAME_TOLERANCE_DP) {
1223
- frameDeltas.push({ dimension: 'h', delta: Math.round(capturedH - referenceFrame.h) });
1224
- }
1225
- if (frameDeltas.length > 0) {
1226
- facts.push({
1227
- kind: 'frame_mismatch',
1228
- declared: { w: referenceFrame.w, h: referenceFrame.h },
1229
- captured: { w: Math.round(capturedW), h: Math.round(capturedH) },
1230
- deltas: frameDeltas,
1231
- });
1232
- placement = { withheld: true, reason: 'frame_mismatch' };
1233
- }
1234
- else {
1235
- placement = { withheld: false };
1236
- }
1237
- }
1238
- // Frame-anchored component runs (coverage-gaps issue 01): no composition
1239
- // frame was asserted, but the design frame arrives as an ANCHOR PROBE. When
1240
- // the captured root MATCHES it within the frame tolerance, ADR-0004's
1241
- // canvas-width-artifact rationale for advisory x/y provably does not apply —
1242
- // the render is at design scale — so the run earns the same placement check
1243
- // a composition run gets (x/y flags via placement facts, zero new shapes).
1244
- // A root that does not match stays exactly as before: no fact, no placement
1245
- // container, x/y advisory — the probe must never start an invalidation
1246
- // storm for legacy content-sized wrappers.
1247
- if (!placement &&
1248
- anchorFrame &&
1249
- typeof anchorFrame.w === 'number' &&
1250
- typeof anchorFrame.h === 'number') {
1251
- const capturedW = (dump.canvas.right - dump.canvas.left) / d;
1252
- const capturedH = (dump.canvas.bottom - dump.canvas.top) / d;
1253
- if (Math.abs(capturedW - anchorFrame.w) <= DEFAULT_FRAME_TOLERANCE_DP &&
1254
- Math.abs(capturedH - anchorFrame.h) <= DEFAULT_FRAME_TOLERANCE_DP) {
1255
- placement = { withheld: false };
1256
- }
1257
- }
1258
- let geometry;
1259
- const duplicates = duplicatedNodeIds(dump.nodes);
1260
- const generated = generatedGeometryDp(dump, d);
1261
- if (referenceGeometry && Object.keys(referenceGeometry).length > 0) {
1262
- const ref = referenceGeometryToDiffShape(referenceGeometry);
1263
- const byId = new Map(dump.nodes.map((n) => [n.id, n]));
1264
- const childrenByParent = new Map();
1265
- for (const node of dump.nodes) {
1266
- if (!node.parentId)
1267
- continue;
1268
- const siblings = childrenByParent.get(node.parentId) ?? [];
1269
- siblings.push(node);
1270
- childrenByParent.set(node.parentId, siblings);
1271
- }
1272
- const deltasByElement = new Map(diffKeyGeometry(generated, ref, { tolerancePx: 0 }).map((g) => [g.elementId, g.deltas]));
1273
- const elements = [];
1274
- for (const id of Object.keys(ref)) {
1275
- // A duplicated tag's generated geometry is last-wins (an arbitrary
1276
- // instance) — its size/clip comparison is withheld; the deriver's
1277
- // duplicate_tag fact carries the collision.
1278
- if (duplicates.has(id))
1279
- continue;
1280
- const g = generated[id];
1281
- const r = ref[id];
1282
- if (!r)
1283
- continue;
1284
- if (!g) {
1285
- // Declared but absent from the render. Persist the design box alone —
1286
- // the same field, built the same way as the compared path below — so
1287
- // the absence keeps the design's record of WHERE instead of taking it
1288
- // down with the element.
1289
- const design = {
1290
- w: Number.isNaN(r.width) ? undefined : r.width,
1291
- h: Number.isNaN(r.height) ? undefined : r.height,
1292
- };
1293
- if (typeof r.x === 'number')
1294
- design.x = r.x;
1295
- if (typeof r.y === 'number')
1296
- design.y = r.y;
1297
- elements.push({ id, design });
1298
- continue;
1299
- }
1300
- const rawDeltas = deltasByElement.get(id) ?? [];
1301
- const deltas = [];
1302
- // Text-aware significance band (line-box residue): the deltas below stay
1303
- // exact; only the flag uses the band.
1304
- const band = sizeBandDp(subtreeTextLineCount(id, byId, childrenByParent), sizeTolerancePx);
1305
- let sizeFlag = false;
1306
- for (const { dimension, delta } of rawDeltas) {
1307
- const rounded = Math.round(delta);
1308
- if (rounded === 0)
1309
- continue;
1310
- deltas.push({ dimension: DIMENSION_SHORT[dimension], delta: rounded });
1311
- if ((dimension === 'width' || dimension === 'height') && Math.abs(delta) > band) {
1312
- sizeFlag = true;
1313
- }
1314
- }
1315
- const design = {
1316
- w: Number.isNaN(r.width) ? undefined : r.width,
1317
- h: Number.isNaN(r.height) ? undefined : r.height,
1318
- };
1319
- // x/y carry coordinates (not flags) so the stored render is a complete
1320
- // clip reference; design x/y only when the reference declared them.
1321
- if (typeof r.x === 'number')
1322
- design.x = r.x;
1323
- if (typeof r.y === 'number')
1324
- design.y = r.y;
1325
- const element = {
1326
- id,
1327
- rendered: { w: roundMeasure(g.width), h: roundMeasure(g.height), x: roundMeasure(g.x ?? 0), y: roundMeasure(g.y ?? 0) },
1328
- design,
1329
- deltas,
1330
- sizeFlag,
1331
- };
1332
- // Signal B v2: per-edge overhang (clip) for an element that overflows its
1333
- // declared frame. Separate from sizeFlag — an element can match size but
1334
- // mis-clip (the euro-coin case) or vice-versa.
1335
- const overhang = computeOverhang(id, g, byId, generated, referenceGeometry, overhangTolerancePx, sizeTolerancePx, duplicates);
1336
- if (overhang)
1337
- element.overhang = overhang;
1338
- // What the element PAINTS, verbatim from the dump: an icon's layout box is
1339
- // its container, and the reference's key is the glyph.
1340
- const paintedInk = byId.get(id)?.paintedInk;
1341
- if (paintedInk)
1342
- element.paintedInk = paintedInk;
1343
- // The render's ancestry (icon-ink-ratio 05). Always set when the node is in
1344
- // the dump, empty chain included: an absent field means "this render predates
1345
- // ancestry", which is what keeps the nesting check silent on old renders
1346
- // instead of guessing at a tree it cannot see.
1347
- if (byId.has(id))
1348
- element.ancestors = ancestorChain(id, byId);
1349
- elements.push(element);
1350
- }
1351
- geometry = { unit: 'dp', density: d, elements };
1352
- // Placement facts (composition runs, frame asserted OK): member-root x/y
1353
- // vs the declared design geometry, root-space dp — directly comparable
1354
- // because the root is frame-sized. Load-bearing here, unlike the advisory
1355
- // x/y deltas on geometry elements (the canvas-width artifact does not
1356
- // apply to a frame-sized surface render).
1357
- if (placement && !placement.withheld) {
1358
- const placementFacts = [];
1359
- for (const id of Object.keys(ref)) {
1360
- // Same last-wins ambiguity as geometry: a duplicated member tag's
1361
- // rendered position is an arbitrary instance's — withheld.
1362
- if (duplicates.has(id))
1363
- continue;
1364
- const g = generated[id];
1365
- const r = referenceGeometry[id];
1366
- if (!g || !r || typeof r.x !== 'number' || typeof r.y !== 'number')
1367
- continue;
1368
- const gx = g.x ?? 0;
1369
- const gy = g.y ?? 0;
1370
- const placementDeltas = [];
1371
- if (Math.abs(gx - r.x) > DEFAULT_PLACEMENT_TOLERANCE_DP) {
1372
- placementDeltas.push({ dimension: 'x', delta: Math.round(gx - r.x) });
1373
- }
1374
- if (Math.abs(gy - r.y) > DEFAULT_PLACEMENT_TOLERANCE_DP) {
1375
- placementDeltas.push({ dimension: 'y', delta: Math.round(gy - r.y) });
1376
- }
1377
- if (placementDeltas.length > 0) {
1378
- placementFacts.push({
1379
- elementId: id,
1380
- expected: { x: r.x, y: r.y },
1381
- actual: { x: Math.round(gx), y: Math.round(gy) },
1382
- deltas: placementDeltas,
1383
- });
1384
- }
1385
- }
1386
- placement.facts = placementFacts;
1387
- }
1388
- }
1389
- let maxSizeDeltaDp = null;
1390
- let maxClipDeltaDp = null;
1391
- if (geometry) {
1392
- maxSizeDeltaDp = 0;
1393
- maxClipDeltaDp = 0;
1394
- for (const el of geometry.elements) {
1395
- for (const { dimension, delta } of el.deltas ?? []) {
1396
- if (dimension !== 'w' && dimension !== 'h')
1397
- continue;
1398
- const abs = Math.abs(delta);
1399
- if (abs > maxSizeDeltaDp)
1400
- maxSizeDeltaDp = abs;
1401
- }
1402
- for (const { delta } of el.overhang?.deltas ?? []) {
1403
- const abs = Math.abs(delta);
1404
- if (abs > maxClipDeltaDp)
1405
- maxClipDeltaDp = abs;
1406
- }
1407
- }
1408
- }
1409
- const sizeIssue = !!geometry && geometry.elements.some((e) => e.sizeFlag);
1410
- const clipIssue = !!geometry && geometry.elements.some((e) => e.overhang?.clipFlag);
1411
- const placementIssue = !!placement?.facts && placement.facts.length > 0;
1412
- const summary = {
1413
- clean: facts.length === 0 && !sizeIssue && !clipIssue && !placementIssue,
1414
- factCount: facts.length,
1415
- maxSizeDeltaDp,
1416
- maxClipDeltaDp,
1417
- };
1418
- // Description vs comparison: rendered boxes persist for every authored-tag
1419
- // node and every fact-named node, reference or not — a description of what
1420
- // rasterized. The comparison fields (design/deltas/sizeFlag/overhang) exist
1421
- // only on the reference-declared elements assembled above, and the summary
1422
- // is computed BEFORE this extension, so geometry presence can never affect
1423
- // verdict outcomes (no reference → max deltas stay null). Duplicated tags
1424
- // stay out everywhere: last-wins geometry is ambiguous, and the
1425
- // duplicate_tag fact carries the collision.
1426
- {
1427
- const emitted = new Set((geometry?.elements ?? []).map((e) => e.id));
1428
- const factNamed = factNamedNodeIds(facts);
1429
- const described = [];
1430
- for (const node of dump.nodes) {
1431
- if (emitted.has(node.id) || duplicates.has(node.id))
1432
- continue;
1433
- if (UNTAGGED_ID.test(node.id) && !factNamed.has(node.id))
1434
- continue;
1435
- const g = generated[node.id];
1436
- if (!g)
1437
- continue;
1438
- emitted.add(node.id);
1439
- described.push({
1440
- id: node.id,
1441
- rendered: { w: roundMeasure(g.width), h: roundMeasure(g.height), x: roundMeasure(g.x ?? 0), y: roundMeasure(g.y ?? 0) },
1442
- });
1443
- }
1444
- if (geometry)
1445
- geometry.elements.push(...described);
1446
- else
1447
- geometry = { unit: 'dp', density: d, elements: described };
1448
- }
1449
- const block = { summary, facts };
1450
- if (geometry)
1451
- block.geometry = geometry;
1452
- if (placement)
1453
- block.placement = placement;
1454
- // Signal C (additive): when a reference is supplied, emit the appearance
1455
- // container — presence facts (element id set diff), plus copy facts (rendered
1456
- // vs reference text) when the reference carries text, plus color facts
1457
- // (rendered vs reference color, CIEDE2000 ΔE) when it carries color. The
1458
- // container's mere presence (carrying a baseline) means "Signal C ran," even
1459
- // with zero facts. Absent reference → no container, so screens without Signal C
1460
- // behave exactly as before.
1461
- if (appearanceReference) {
1462
- // The render's own appearance — element ids + per-node text/colors. Used both
1463
- // to derive facts vs the reference AND persisted as the `rendered` snapshot so
1464
- // a later no-Figma render can use this render as its previous-render baseline.
1465
- const renderedIds = dump.nodes.map((n) => n.id);
1466
- const renderedText = {};
1467
- const renderedColor = {};
1468
- const renderedSpans = {};
1469
- const renderedLines = {};
1470
- const renderedTypeface = {};
1471
- const renderedTypeMetrics = {};
1472
- for (const node of dump.nodes) {
1473
- if (typeof node.text === 'string')
1474
- renderedText[node.id] = node.text;
1475
- if (Array.isArray(node.textSpans))
1476
- renderedSpans[node.id] = node.textSpans;
1477
- if (Array.isArray(node.textLines))
1478
- renderedLines[node.id] = node.textLines;
1479
- const colors = {};
1480
- if (typeof node.fillColor === 'string')
1481
- colors.fill = node.fillColor;
1482
- if (typeof node.textColor === 'string')
1483
- colors.text = node.textColor;
1484
- if (typeof node.fillAlpha === 'number')
1485
- colors.fillAlpha = node.fillAlpha;
1486
- if (typeof node.textAlpha === 'number')
1487
- colors.textAlpha = node.textAlpha;
1488
- // The rendered border (property-recognizer 01), keyed as `stroke` so the
1489
- // design and render sides line up. `borderWidth` is already DP.
1490
- if (typeof node.borderColor === 'string')
1491
- colors.stroke = node.borderColor;
1492
- if (typeof node.borderWidth === 'number')
1493
- colors.strokeWeight = node.borderWidth;
1494
- if (colors.fill !== undefined || colors.text !== undefined || colors.stroke !== undefined) {
1495
- renderedColor[node.id] = colors;
1496
- }
1497
- // The resolved-typeface enrichment (issue 03): the font that actually painted.
1498
- const tf = node.resolvedTypeface;
1499
- if (tf && typeof tf.family === 'string' && tf.family.length > 0) {
1500
- renderedTypeface[node.id] = typeof tf.weight === 'number' ? { family: tf.family, weight: tf.weight } : { family: tf.family };
1501
- }
1502
- // The laid-out type metrics (typography facts 01), kept in their own map:
1503
- // they are captured independently of the family above and must stay
1504
- // comparable when it is absent.
1505
- const tm = node.typeMetrics;
1506
- if (tm && typeof tm === 'object') {
1507
- const metrics = {};
1508
- for (const key of TYPE_METRIC_KEYS) {
1509
- const value = tm[key];
1510
- if (typeof value === 'number' && Number.isFinite(value))
1511
- metrics[key] = value;
1512
- }
1513
- if (Object.keys(metrics).length > 0)
1514
- renderedTypeMetrics[node.id] = metrics;
1515
- }
1516
- }
1517
- // Presence facts: element_extra requires the COMPLETE reference element
1518
- // inventory, which only the previous_render baseline carries (the prior
1519
- // render's full persisted id set, via appearanceReferenceFromPreviousRender).
1520
- // The figma baseline's ids come from reference_geometry — a deliberately
1521
- // PARTIAL design annotation — so element_extra there falsely flags every
1522
- // untagged rendered element (noise). element_missing is the opposite shape
1523
- // (coverage-gaps issue 03): every declared key is a deliberate, named,
1524
- // load-bearing element, so a declared id matching NO rendered node is a real
1525
- // absence, noise-free on EITHER baseline. Derive both on previous_render,
1526
- // missing-only on figma; the pure deriver itself is unchanged.
1527
- const presenceFacts = derivePresenceFacts(renderedIds, appearanceReference.elementIds);
1528
- const appearanceFacts = appearanceReference.baseline === 'previous_render'
1529
- ? presenceFacts
1530
- : presenceFacts.filter((f) => f.kind === 'element_missing');
1531
- if (appearanceReference.text) {
1532
- appearanceFacts.push(...deriveCopyFacts(renderedText, appearanceReference.text));
1533
- }
1534
- if (appearanceReference.spans) {
1535
- appearanceFacts.push(...deriveSpanFacts(renderedSpans, appearanceReference.spans,
1536
- // Color-span capability: the emitter declares the channels it can
1537
- // report; a dump predating the marker is color-blind.
1538
- Array.isArray(dump.spanChannels) && dump.spanChannels.includes('color')));
1539
- }
1540
- if (appearanceReference.lines) {
1541
- appearanceFacts.push(...deriveLineFacts(renderedLines, appearanceReference.lines));
1542
- }
1543
- if (appearanceReference.color) {
1544
- appearanceFacts.push(...deriveColorFacts(renderedColor, appearanceReference.color));
1545
- }
1546
- // Typeface facts (issue 03): resolved (painted) font vs the reference font.
1547
- // The capture seam (TextRunShaper) is API 31+ — a proof that rendered below
1548
- // that cannot carry resolved typefaces, so the check is reported as NOT RUN
1549
- // (an advisory), never silently passed. An ABSENT renderSdk means the dump
1550
- // predates the emitter generation that captures typefaces at all (it stamps
1551
- // renderSdk unconditionally) — same not-run advisory, never a silent pass
1552
- // (coverage-gaps issue 04).
1553
- const advisories = [];
1554
- if (appearanceReference.typeface) {
1555
- if (typeof dump.renderSdk !== 'number') {
1556
- advisories.push('typeface check requires render SDK >= 31; this proof declared no render SDK ' +
1557
- '(an older package) — typeface facts were not derived (not checked, not passing)');
1558
- }
1559
- else if (dump.renderSdk < 31) {
1560
- advisories.push('typeface check requires render SDK >= 31; this proof rendered at SDK ' +
1561
- dump.renderSdk +
1562
- ' — typeface facts were not derived (not checked, not passing)');
1563
- }
1564
- else {
1565
- appearanceFacts.push(...deriveTypefaceFacts(renderedTypeface, appearanceReference.typeface));
1566
- }
1567
- // Type metrics ride the same reference channel but NOT the SDK guard: the
1568
- // shaping seam that gates the family capture has nothing to do with
1569
- // reading a style, and a proof rendered below SDK 31 still lays its text
1570
- // out at a size. Gating them together would make a font size uncomparable
1571
- // on exactly the older packages that most need checking.
1572
- appearanceFacts.push(...deriveTypeMetricFacts(renderedTypeMetrics, appearanceReference.typeface));
1573
- }
1574
- // Border facts (property-recognizer 01). `styleChannels` is the emitter's own
1575
- // declaration that it can see borders; a package predating border capture
1576
- // lacks it, and that silence is "cannot see a border", never "no border was
1577
- // drawn". The advisory NAMES the elements — an unnamed "not checked" is a
1578
- // statement nobody can act on, which is the same silence in a new place.
1579
- if (appearanceReference.color) {
1580
- const borderCapable = Array.isArray(dump.styleChannels) && dump.styleChannels.includes('border');
1581
- appearanceFacts.push(...deriveBorderFacts(renderedColor, appearanceReference.color, borderCapable));
1582
- if (!borderCapable) {
1583
- const declared = Object.keys(appearanceReference.color)
1584
- .filter((key) => hasText(appearanceReference.color?.[key]?.stroke))
1585
- .sort();
1586
- if (declared.length > 0) {
1587
- advisories.push('border check requires a render package that captures one; this proof declared no ' +
1588
- 'style channels (an older package) — the design stroke on ' +
1589
- declared.join(', ') +
1590
- ' was not checked, not passing');
1591
- }
1592
- }
1593
- }
1594
- const rendered = { elementIds: [...renderedIds].sort() };
1595
- if (Object.keys(renderedText).length > 0)
1596
- rendered.text = renderedText;
1597
- if (Object.keys(renderedColor).length > 0)
1598
- rendered.color = renderedColor;
1599
- if (Object.keys(renderedTypeface).length > 0)
1600
- rendered.typeface = renderedTypeface;
1601
- block.appearance = { baseline: appearanceReference.baseline, facts: appearanceFacts, rendered };
1602
- if (advisories.length > 0)
1603
- block.appearance.advisories = advisories;
1604
- }
1605
- return block;
1606
- }
1607
- /** Human label for a fact, used in the review obligation. */
1608
- function factLabel(fact) {
1609
- if (fact.kind === 'overlap')
1610
- return `${fact.nodeIds[0]}↔${fact.nodeIds[1]}`;
1611
- if (fact.kind === 'frame_mismatch')
1612
- return 'frame';
1613
- if (fact.kind === 'duplicate_tag')
1614
- return fact.tag;
1615
- return fact.nodeId;
1616
- }
1617
- /** Compact "size +15×+15" delta description for a geometry row. */
1618
- function sizeDeltaText(element) {
1619
- const byDim = {};
1620
- for (const { dimension, delta } of element.deltas ?? [])
1621
- byDim[dimension] = delta;
1622
- const signed = (n) => (n > 0 ? `+${n}` : `${n}`);
1623
- const w = byDim.w ?? 0;
1624
- const h = byDim.h ?? 0;
1625
- return `size ${signed(w)}×${signed(h)}`;
1626
- }
1627
- const signedDelta = (n) => (n > 0 ? `+${n}` : `${n}`);
1628
- /** The review scaffold speaks in the judge's numbers: every rendered box is
1629
- * read at whole dp, exactly as the judge quantizes on read — the persisted
1630
- * box carries 2-decimal precision for display, and a row here must say what
1631
- * the verdict's finding would say. */
1632
- const judgeDp = (n) => Math.round(n);
1633
- /** "bottom 16dp" — flagged overhang edges, rendered side. */
1634
- function clipRenderedText(overhang) {
1635
- return overhang.deltas.map((d) => `${d.edge} ${overhang.rendered[d.edge]}dp`).join(', ');
1636
- }
1637
- /** "bottom 25dp" — flagged overhang edges, design side. */
1638
- function clipDesignText(overhang) {
1639
- return overhang.deltas.map((d) => `${d.edge} ${overhang.design[d.edge]}dp`).join(', ');
1640
- }
1641
- /**
1642
- * "clip bottom 16dp vs design 25dp (-9) — driven by the element's own size
1643
- * (h 71 vs 81dp) tracking the frame (parent-relative); reproduce at the fixed
1644
- * design size" — the overhang mismatch to reconcile PLUS its cause when the clip
1645
- * is driven by the element's own (frame-relative) size, so the judge can't file
1646
- * it as an intended clip.
1647
- */
1648
- function clipDeltaText(overhang, element) {
1649
- const base = 'clip ' +
1650
- overhang.deltas
1651
- .map((d) => `${d.edge} ${overhang.rendered[d.edge]}dp vs design ${overhang.design[d.edge]}dp (${signedDelta(d.delta)})`)
1652
- .join('; ');
1653
- const causes = [];
1654
- if (overhang.sizeDriven) {
1655
- // Report only the dimensions whose size actually differs from design.
1656
- // (An overhang exists only on a reference-compared element, so design AND
1657
- // rendered are present here at runtime; the null checks are for the type.)
1658
- const sizeParts = [];
1659
- const designW = element.design?.w;
1660
- const designH = element.design?.h;
1661
- const renderedW = element.rendered?.w;
1662
- const renderedH = element.rendered?.h;
1663
- if (designW != null && renderedW != null && judgeDp(renderedW) !== designW)
1664
- sizeParts.push(`w ${judgeDp(renderedW)} vs ${designW}dp`);
1665
- if (designH != null && renderedH != null && judgeDp(renderedH) !== designH)
1666
- sizeParts.push(`h ${judgeDp(renderedH)} vs ${designH}dp`);
1667
- const sizeText = sizeParts.join(', ');
1668
- const frameSuffix = overhang.frameRelative ? ' tracking the frame (parent-relative)' : '';
1669
- causes.push(sizeText ? `the element's own size (${sizeText})${frameSuffix}` : `the element's own size${frameSuffix}`);
1670
- }
1671
- if (overhang.positionDriven)
1672
- causes.push("the element's position relative to the frame");
1673
- if (causes.length === 0)
1674
- return base;
1675
- const fix = overhang.sizeDriven && overhang.positionDriven
1676
- ? 'reproduce at the fixed design size and reposition to the design offset'
1677
- : overhang.sizeDriven
1678
- ? 'reproduce at the fixed design size'
1679
- : 'reposition to the design offset';
1680
- return `${base} — driven by ${causes.join(' and ')}; ${fix}`;
1681
- }
1682
- /** One-line description of a fact for a fact-only review row. */
1683
- function factText(fact) {
1684
- switch (fact.kind) {
1685
- case 'frame_mismatch':
1686
- return (`wrapper root ${fact.captured.w}×${fact.captured.h}dp differs from the declared ` +
1687
- `design frame ${fact.declared.w}×${fact.declared.h}dp — size the wrapper root to the ` +
1688
- 'frame (placement facts are withheld until it matches)');
1689
- case 'duplicate_tag':
1690
- return (`testTag "${fact.tag}" is shared by ${fact.instanceCount} nodes — geometry facts for ` +
1691
- `it are withheld; give each instance a unique testTag (e.g. suffix _0/_1/_2) and re-run`);
1692
- case 'overlap':
1693
- return `overlap ${fact.overlapPx.width}×${fact.overlapPx.height}px (ratio ${fact.ratio.toFixed(2)})`;
1694
- case 'overflow':
1695
- return `overflow past ${fact.parentId} (${fact.edges.join(',')})`;
1696
- case 'clipped_by_parent':
1697
- return `clipped by ${fact.clipperId} (${fact.edges.join(',')})`;
1698
- case 'off_screen':
1699
- return `off-screen (${fact.edges.join(',')})`;
1700
- default:
1701
- return fact.kind;
1702
- }
1703
- }
1704
- /**
1705
- * Build the run-only `review` scaffold from an assembled structural block:
1706
- * size-flagged geometry elements first (load-bearing), then every structural
1707
- * fact, each as a row with a null `verdict` the agent must replace.
1708
- */
1709
- export function buildReviewScaffold(structural) {
1710
- const facts = Array.isArray(structural.facts) ? structural.facts : [];
1711
- const geometry = structural.geometry ?? null;
1712
- const mustExplain = [];
1713
- const rows = [];
1714
- // A size delta and a clip (overhang) delta are separate signals — emit a row
1715
- // for each so neither is silently rolled into the other. One mustExplain entry
1716
- // per element even when both fire.
1717
- for (const el of geometry ? geometry.elements : []) {
1718
- let flagged = false;
1719
- if (el.sizeFlag) {
1720
- // A size flag exists only on a reference-compared element, so design and
1721
- // rendered are present here at runtime; the optional chaining is for the
1722
- // type.
1723
- rows.push({
1724
- element: el.id,
1725
- rendered: `${el.rendered ? judgeDp(el.rendered.w) : undefined}×${el.rendered ? judgeDp(el.rendered.h) : undefined}dp`,
1726
- design: `${el.design?.w}×${el.design?.h}dp`,
1727
- fact: sizeDeltaText(el),
1728
- verdict: null,
1729
- });
1730
- flagged = true;
1731
- }
1732
- if (el.overhang?.clipFlag) {
1733
- rows.push({
1734
- element: el.id,
1735
- rendered: clipRenderedText(el.overhang),
1736
- design: clipDesignText(el.overhang),
1737
- fact: clipDeltaText(el.overhang, el),
1738
- verdict: null,
1739
- });
1740
- flagged = true;
1741
- }
1742
- if (flagged && !mustExplain.includes(el.id))
1743
- mustExplain.push(el.id);
1744
- }
1745
- for (const fact of facts) {
1746
- const label = factLabel(fact);
1747
- if (mustExplain.includes(label))
1748
- continue;
1749
- mustExplain.push(label);
1750
- rows.push({ element: label, rendered: null, design: null, fact: factText(fact), verdict: null });
1751
- }
1752
- return { verdict: null, mustExplain, rows };
1753
- }
1754
- /**
1755
- * Render-side embedded form of the deriver.
1756
- *
1757
- * The Android render runs as a single self-contained generated script (see
1758
- * `generateAndroidRenderScript`), so render-side pure logic is embedded as a
1759
- * JS-source string and concatenated in — exactly like `RENDER_SCRIPT_TRIM_STAGE`.
1760
- * The typed `deriveStructuralFacts` above stays the canonical, tested spec; the
1761
- * `parity` test asserts this embedded port produces identical facts on shared
1762
- * fixtures so the two cannot drift.
1763
- *
1764
- * `deriveStructuralFacts` here is pure; `deriveAndWriteFacts` references runtime
1765
- * globals (`fs`, `path`, `artifactsDir`, `log`) defined in the main script and
1766
- * is only invoked from there — harmless to define in the eval sandbox.
1767
- */
1768
- export const RENDER_SCRIPT_FACTS_STAGE = String.raw `
1769
- // ---- structural-facts stage (UI-fidelity Signal A) ----
1770
-
1771
- function deriveStructuralFacts(dump, options) {
1772
- var opts = options || {};
1773
- var tolerancePx = opts.tolerancePx == null ? 0.5 : opts.tolerancePx;
1774
- var overlapMinRatio = opts.overlapMinRatio == null ? 0.1 : opts.overlapMinRatio;
1775
- var EDGE_ORDER = ['left', 'top', 'right', 'bottom'];
1776
-
1777
- function edgeExcess(inner, outer) {
1778
- var raw = {
1779
- left: outer.left - inner.left,
1780
- top: outer.top - inner.top,
1781
- right: inner.right - outer.right,
1782
- bottom: inner.bottom - outer.bottom,
1783
- };
1784
- var edges = [];
1785
- var px = {};
1786
- for (var i = 0; i < EDGE_ORDER.length; i++) {
1787
- var edge = EDGE_ORDER[i];
1788
- if (raw[edge] > tolerancePx) {
1789
- edges.push(edge);
1790
- px[edge] = raw[edge];
1791
- }
1792
- }
1793
- return { edges: edges, px: px };
1794
- }
1795
-
1796
- function area(b) {
1797
- return Math.max(0, b.right - b.left) * Math.max(0, b.bottom - b.top);
1798
- }
1799
-
1800
- // What the node PAINTS, not the box it occupies (icon-ink-ratio 02 Half B).
1801
- // Falls back to the bounds for no ink, unmeasurable ink, and an extent with no
1802
- // origin (a pre-origin dump) — never to a guessed inset.
1803
- function paintedBox(node) {
1804
- var p = node.paintedInk;
1805
- if (!p || p.kind !== 'vector') return node.bounds;
1806
- var v = p.viewport;
1807
- var ink = p.ink;
1808
- function fin(n) { return typeof n === 'number' && isFinite(n); }
1809
- if (!v || !ink || !fin(v.w) || !fin(v.h) || v.w <= 0 || v.h <= 0) return node.bounds;
1810
- if (!fin(ink.w) || !fin(ink.h) || !fin(ink.x) || !fin(ink.y)) return node.bounds;
1811
- var scaleX = (node.bounds.right - node.bounds.left) / v.w;
1812
- var scaleY = (node.bounds.bottom - node.bounds.top) / v.h;
1813
- var left = node.bounds.left + ink.x * scaleX;
1814
- var top = node.bounds.top + ink.y * scaleY;
1815
- return { left: left, top: top, right: left + ink.w * scaleX, bottom: top + ink.h * scaleY };
1816
- }
1817
-
1818
- var byId = {};
1819
- var idCounts = {};
1820
- for (var n = 0; n < dump.nodes.length; n++) {
1821
- byId[dump.nodes[n].id] = dump.nodes[n];
1822
- idCounts[dump.nodes[n].id] = (idCounts[dump.nodes[n].id] || 0) + 1;
1823
- }
1824
- var facts = [];
1825
-
1826
- // testTag collisions first: id lookups are last-wins for a duplicated tag, so
1827
- // parent-relative overflow/clip through it is withheld rather than guessed.
1828
- // Off-screen and overlaps (no id pairing) still derive.
1829
- var duplicateTags = [];
1830
- for (var dk in idCounts) {
1831
- if (Object.prototype.hasOwnProperty.call(idCounts, dk) && idCounts[dk] > 1) duplicateTags.push(dk);
1832
- }
1833
- duplicateTags.sort();
1834
- for (var dt = 0; dt < duplicateTags.length; dt++) {
1835
- facts.push({ kind: 'duplicate_tag', tag: duplicateTags[dt], instanceCount: idCounts[duplicateTags[dt]] });
1836
- }
1837
-
1838
- for (var k = 0; k < dump.nodes.length; k++) {
1839
- var node = dump.nodes[k];
1840
- var parent = node.parentId && idCounts[node.id] === 1 && idCounts[node.parentId] === 1 ? byId[node.parentId] : undefined;
1841
- if (parent) {
1842
- if (node.unclippedBounds) {
1843
- var parentBox = parent.unclippedBounds || parent.bounds;
1844
- var over = edgeExcess(node.unclippedBounds, parentBox);
1845
- if (over.edges.length > 0) {
1846
- var cut = edgeExcess(node.unclippedBounds, node.bounds);
1847
- var clippedEdges = [];
1848
- var overflowEdges = [];
1849
- for (var oe = 0; oe < over.edges.length; oe++) {
1850
- var edge = over.edges[oe];
1851
- if (cut.px[edge] != null) clippedEdges.push(edge); else overflowEdges.push(edge);
1852
- }
1853
- if (clippedEdges.length > 0) {
1854
- var clippedPx = {};
1855
- for (var ce = 0; ce < clippedEdges.length; ce++) clippedPx[clippedEdges[ce]] = cut.px[clippedEdges[ce]];
1856
- facts.push({ kind: 'clipped_by_parent', nodeId: node.id, clipperId: parent.id, edges: clippedEdges, clippedPx: clippedPx });
1857
- }
1858
- if (overflowEdges.length > 0) {
1859
- var overflowPx = {};
1860
- for (var ov = 0; ov < overflowEdges.length; ov++) overflowPx[overflowEdges[ov]] = over.px[overflowEdges[ov]];
1861
- facts.push({ kind: 'overflow', nodeId: node.id, parentId: parent.id, edges: overflowEdges, overflowPx: overflowPx });
1862
- }
1863
- }
1864
- } else {
1865
- var ex = edgeExcess(node.bounds, parent.bounds);
1866
- if (ex.edges.length > 0) {
1867
- if (parent.clipsChildren) {
1868
- facts.push({ kind: 'clipped_by_parent', nodeId: node.id, clipperId: parent.id, edges: ex.edges, clippedPx: ex.px });
1869
- } else {
1870
- facts.push({ kind: 'overflow', nodeId: node.id, parentId: parent.id, edges: ex.edges, overflowPx: ex.px });
1871
- }
1872
- }
1873
- }
1874
- }
1875
- var offBox = node.unclippedBounds || node.bounds;
1876
- var off = edgeExcess(offBox, dump.canvas);
1877
- if (off.edges.length > 0) {
1878
- facts.push({ kind: 'off_screen', nodeId: node.id, edges: off.edges, offscreenPx: off.px });
1879
- }
1880
- }
1881
-
1882
- var groups = {};
1883
- for (var g = 0; g < dump.nodes.length; g++) {
1884
- var node2 = dump.nodes[g];
1885
- var key = node2.parentId == null ? ' root' : node2.parentId;
1886
- if (!groups[key]) groups[key] = [];
1887
- groups[key].push(node2);
1888
- }
1889
- var keys = Object.keys(groups);
1890
- for (var gk = 0; gk < keys.length; gk++) {
1891
- var siblings = groups[keys[gk]];
1892
- for (var i2 = 0; i2 < siblings.length; i2++) {
1893
- for (var j2 = i2 + 1; j2 < siblings.length; j2++) {
1894
- var a = siblings[i2];
1895
- var b = siblings[j2];
1896
- var ab = paintedBox(a);
1897
- var bb = paintedBox(b);
1898
- var ix = Math.min(ab.right, bb.right) - Math.max(ab.left, bb.left);
1899
- var iy = Math.min(ab.bottom, bb.bottom) - Math.max(ab.top, bb.top);
1900
- if (ix <= tolerancePx || iy <= tolerancePx) continue;
1901
- var smaller = Math.min(area(ab), area(bb));
1902
- var ratio = smaller > 0 ? (ix * iy) / smaller : 0;
1903
- if (ratio < overlapMinRatio) continue;
1904
- var ids = a.id <= b.id ? [a.id, b.id] : [b.id, a.id];
1905
- facts.push({ kind: 'overlap', nodeIds: ids, overlapPx: { width: ix, height: iy }, ratio: ratio });
1906
- }
1907
- }
1908
- }
1909
-
1910
- return facts;
1911
- }
1912
-
1913
- // Read the layout-dump sidecar emitted next to the render, copy it to the
1914
- // artifacts dir, derive structural facts, and write them as a build artifact.
1915
- // Best-effort: a missing/malformed sidecar logs and leaves the paths null so
1916
- // the render never fails on a structural-pass problem (graceful degradation).
1917
- function deriveAndWriteFacts(entry, layoutSourcePath) {
1918
- var dump;
1919
- try {
1920
- dump = JSON.parse(fs.readFileSync(layoutSourcePath, 'utf-8'));
1921
- } catch (readError) {
1922
- log('no usable layout dump for ' + entry.screen + ' (skipping structural facts): ' +
1923
- (readError && readError.message ? readError.message : String(readError)));
1924
- return;
1925
- }
1926
- if (!dump || typeof dump !== 'object' || !dump.canvas || !Array.isArray(dump.nodes)) {
1927
- log('layout dump for ' + entry.screen + ' is not a {canvas,nodes} object (skipping structural facts)');
1928
- return;
1929
- }
1930
-
1931
- var layoutRelative = 'ui-fidelity/layout/' + entry.screen + '.json';
1932
- var factsRelative = 'ui-fidelity/facts/' + entry.screen + '.json';
1933
- try {
1934
- fs.copyFileSync(layoutSourcePath, path.resolve(artifactsDir, layoutRelative));
1935
- entry.layout_path = layoutRelative;
1936
- var facts = deriveStructuralFacts(dump);
1937
- fs.writeFileSync(
1938
- path.resolve(artifactsDir, factsRelative),
1939
- JSON.stringify({ screen: entry.screen, facts: facts }, null, 2) + '\n'
1940
- );
1941
- entry.facts_path = factsRelative;
1942
- log('structural facts for ' + entry.screen + ': ' + facts.length + ' fact(s) -> ' + factsRelative);
1943
- } catch (writeError) {
1944
- log('could not write structural facts for ' + entry.screen + ': ' +
1945
- (writeError && writeError.message ? writeError.message : String(writeError)));
1946
- }
1947
- }
1948
- `;
1949
- /**
1950
- * Evaluates the embedded facts stage and returns its pure `deriveStructuralFacts`,
1951
- * so the parity test exercises exactly the code that ships in the runtime script
1952
- * (mirrors `getPostProcessorInternals`). `deriveAndWriteFacts` references runtime
1953
- * globals only when called, so defining it in the sandbox is harmless.
1954
- */
1955
- export function getStructuralFactsStageInternals() {
1956
- const factory = new Function(RENDER_SCRIPT_FACTS_STAGE +
1957
- '\nreturn { deriveStructuralFacts: deriveStructuralFacts };');
1958
- return factory();
1959
- }
1960
- /**
1961
- * Render-side embedded form of the Signal-B differ + structural-block assembler.
1962
- *
1963
- * Concatenated into the generated render script alongside `RENDER_SCRIPT_FACTS_STAGE`
1964
- * (function declarations hoist, so order does not matter — `buildStructuralBlock`
1965
- * calls the facts stage's `deriveStructuralFacts`). `assembleStructural` references
1966
- * runtime globals (`fs`, `path`, `log`) defined in the main script and is only
1967
- * invoked from there. Kept byte-equivalent to the typed `buildStructuralBlock` /
1968
- * `diffKeyGeometry` above by the parity test, so run-side and preview-side (Invarn)
1969
- * structural blocks cannot drift.
1970
- */
1971
- export const RENDER_SCRIPT_GEOMETRY_STAGE = String.raw `
1972
- // ---- key-geometry stage (UI-fidelity Signal B) ----
1973
-
1974
- function diffKeyGeometry(generated, reference, options) {
1975
- if (!reference) return [];
1976
- var opts = options || {};
1977
- var tolerancePx = opts.tolerancePx == null ? 1 : opts.tolerancePx;
1978
- var DIMS = ['width', 'height', 'x', 'y'];
1979
- var out = [];
1980
- var ids = Object.keys(generated);
1981
- for (var i = 0; i < ids.length; i++) {
1982
- var elementId = ids[i];
1983
- var g = generated[elementId];
1984
- var r = reference[elementId];
1985
- if (!g || !r) continue;
1986
- var deltas = [];
1987
- for (var d = 0; d < DIMS.length; d++) {
1988
- var dim = DIMS[d];
1989
- var gv = g[dim];
1990
- var rv = r[dim];
1991
- if (typeof gv !== 'number' || typeof rv !== 'number') continue;
1992
- var delta = gv - rv;
1993
- if (Math.abs(delta) > tolerancePx) {
1994
- deltas.push({ dimension: dim, generated: gv, reference: rv, delta: delta });
1995
- }
1996
- }
1997
- if (deltas.length > 0) out.push({ elementId: elementId, deltas: deltas });
1998
- }
1999
- return out;
2000
- }
2001
-
2002
- function generatedGeometryDp(dump, density) {
2003
- var d = density > 0 ? density : 1;
2004
- var out = {};
2005
- for (var i = 0; i < dump.nodes.length; i++) {
2006
- var node = dump.nodes[i];
2007
- var b = node.unclippedBounds || node.bounds;
2008
- if (!b) continue;
2009
- out[node.id] = {
2010
- width: (b.right - b.left) / d,
2011
- height: (b.bottom - b.top) / d,
2012
- x: b.left / d,
2013
- y: b.top / d,
2014
- };
2015
- }
2016
- return out;
2017
- }
2018
-
2019
- function referenceGeometryToDiffShape(referenceGeometry) {
2020
- var out = {};
2021
- var ids = Object.keys(referenceGeometry);
2022
- for (var i = 0; i < ids.length; i++) {
2023
- var id = ids[i];
2024
- var g = referenceGeometry[id];
2025
- if (!g || typeof g !== 'object') continue;
2026
- var r = { width: NaN, height: NaN };
2027
- if (typeof g.w === 'number') r.width = g.w;
2028
- if (typeof g.h === 'number') r.height = g.h;
2029
- if (typeof g.x === 'number') r.x = g.x;
2030
- if (typeof g.y === 'number') r.y = g.y;
2031
- out[id] = r;
2032
- }
2033
- return out;
2034
- }
2035
-
2036
- // The persisted precision of a rendered box: 2 decimals. The paint is sub-dp,
2037
- // and quantizing the box to whole dp moved it up to half a dp off the pixels
2038
- // it describes — visibly mis-wrapped once evidence is magnified. Comparison
2039
- // semantics do not move: deltas are computed from raw values and persisted
2040
- // integer-rounded, the flag bands read raw deltas, and the judge rounds
2041
- // rendered boxes back to whole dp on read. Mirrors the typed roundMeasure.
2042
- function roundMeasure(value) {
2043
- return Number(value.toFixed(2));
2044
- }
2045
-
2046
- var OVERHANG_EDGES = ['left', 'top', 'right', 'bottom'];
2047
- var OVERHANG_MIN_DP = 0.5;
2048
-
2049
- function overhangNum(v) { return typeof v === 'number' ? v : NaN; }
2050
- function edgeAxis(edge) { return edge === 'top' || edge === 'bottom' ? 'height' : 'width'; }
2051
-
2052
- // Per-edge overhang of one element vs its frame (the element's parent in the
2053
- // dump). Frame-relative edge overhang is the real clip and IS flagged; absolute
2054
- // canvas x/y stays suppressed (canvas-width artifact). sizeDriven/frameRelative
2055
- // trace a flagged clip to the element's own (frame-relative) size. Returns
2056
- // undefined when the element is fully contained, the frame is absent from the
2057
- // render, or the frame isn't declared in referenceGeometry (an advisory covers that).
2058
- function computeOverhang(elementId, elementDp, byId, generated, referenceGeometry, overhangTolerancePx, sizeTolerancePx, duplicates) {
2059
- var node = byId[elementId];
2060
- var frameId = node ? node.parentId : null;
2061
- if (!frameId) return undefined;
2062
- // A duplicated frame tag resolves last-wins in generated/byId, so the overhang
2063
- // would measure against an arbitrary instance — withheld (duplicate_tag fact).
2064
- if (duplicates && duplicates[frameId] > 1) return undefined;
2065
- var frameDp = generated[frameId];
2066
- if (!frameDp) return undefined;
2067
- var frameRef = referenceGeometry[frameId];
2068
- var elRef = referenceGeometry[elementId];
2069
- if (!frameRef || !elRef) return undefined;
2070
- var elX = elementDp.x == null ? NaN : elementDp.x;
2071
- var elY = elementDp.y == null ? NaN : elementDp.y;
2072
- var fX = frameDp.x == null ? NaN : frameDp.x;
2073
- var fY = frameDp.y == null ? NaN : frameDp.y;
2074
- var renderedRaw = {
2075
- left: fX - elX,
2076
- top: fY - elY,
2077
- right: elX + elementDp.width - (fX + frameDp.width),
2078
- bottom: elY + elementDp.height - (fY + frameDp.height),
2079
- };
2080
- var designRaw = {
2081
- left: overhangNum(frameRef.x) - overhangNum(elRef.x),
2082
- top: overhangNum(frameRef.y) - overhangNum(elRef.y),
2083
- right: overhangNum(elRef.x) + overhangNum(elRef.w) - (overhangNum(frameRef.x) + overhangNum(frameRef.w)),
2084
- bottom: overhangNum(elRef.y) + overhangNum(elRef.h) - (overhangNum(frameRef.y) + overhangNum(frameRef.h)),
2085
- };
2086
- // Candidate when EITHER side overflows — over/under/missing/spurious clip all qualify.
2087
- var candidates = [];
2088
- for (var ce = 0; ce < OVERHANG_EDGES.length; ce++) {
2089
- var ed = OVERHANG_EDGES[ce];
2090
- if (renderedRaw[ed] > OVERHANG_MIN_DP || designRaw[ed] > OVERHANG_MIN_DP) candidates.push(ed);
2091
- }
2092
- if (candidates.length === 0) return undefined;
2093
- var rendered = {};
2094
- var design = {};
2095
- var deltas = [];
2096
- var has = false;
2097
- for (var c = 0; c < candidates.length; c++) {
2098
- var edge = candidates[c];
2099
- var dRaw = designRaw[edge];
2100
- if (!isFinite(dRaw)) continue;
2101
- rendered[edge] = Math.round(renderedRaw[edge]);
2102
- design[edge] = dRaw;
2103
- has = true;
2104
- var delta = renderedRaw[edge] - dRaw;
2105
- if (Math.abs(delta) > overhangTolerancePx) deltas.push({ edge: edge, delta: Math.round(delta) });
2106
- }
2107
- if (!has) return undefined;
2108
-
2109
- var elSize = { width: elementDp.width, height: elementDp.height };
2110
- var frameSize = { width: frameDp.width, height: frameDp.height };
2111
- var elDesign = { width: overhangNum(elRef.w), height: overhangNum(elRef.h) };
2112
- var frameDesign = { width: overhangNum(frameRef.w), height: overhangNum(frameRef.h) };
2113
- var elRelPos = { width: elX - fX, height: elY - fY };
2114
- var elRelPosDesign = {
2115
- width: overhangNum(elRef.x) - overhangNum(frameRef.x),
2116
- height: overhangNum(elRef.y) - overhangNum(frameRef.y),
2117
- };
2118
- var sizeDriven = false;
2119
- var frameRelative = false;
2120
- var positionDriven = false;
2121
- for (var sd = 0; sd < deltas.length; sd++) {
2122
- var axis = edgeAxis(deltas[sd].edge);
2123
- var elD = elDesign[axis];
2124
- if (isFinite(elD) && Math.abs(elSize[axis] - elD) > sizeTolerancePx) {
2125
- sizeDriven = true;
2126
- var frameD = frameDesign[axis];
2127
- if (Math.abs(elSize[axis] - frameSize[axis]) <= sizeTolerancePx && isFinite(frameD) && Math.abs(elD - frameD) > sizeTolerancePx) {
2128
- frameRelative = true;
2129
- }
2130
- }
2131
- var relD = elRelPosDesign[axis];
2132
- if (isFinite(relD) && Math.abs(elRelPos[axis] - relD) > sizeTolerancePx) {
2133
- positionDriven = true;
2134
- }
2135
- }
2136
-
2137
- return { unit: 'dp', rendered: rendered, design: design, deltas: deltas, clipFlag: deltas.length > 0, sizeDriven: sizeDriven, frameRelative: frameRelative, positionDriven: positionDriven };
2138
- }
2139
-
2140
- // Signal C: element-presence facts from the set difference of rendered vs
2141
- // reference element ids. Order-independent; output sorted (missing before extra,
2142
- // each by id). Mirrors the typed derivePresenceFacts.
2143
- function derivePresenceFacts(renderedIds, referenceIds) {
2144
- var rendered = {};
2145
- for (var i = 0; i < renderedIds.length; i++) rendered[renderedIds[i]] = true;
2146
- var reference = {};
2147
- for (var j = 0; j < referenceIds.length; j++) reference[referenceIds[j]] = true;
2148
- var facts = [];
2149
- var missing = [];
2150
- for (var rk in reference) { if (Object.prototype.hasOwnProperty.call(reference, rk) && !rendered[rk]) missing.push(rk); }
2151
- missing.sort();
2152
- for (var m = 0; m < missing.length; m++) facts.push({ kind: 'element_missing', elementId: missing[m] });
2153
- var extra = [];
2154
- for (var ek in rendered) { if (Object.prototype.hasOwnProperty.call(rendered, ek) && !reference[ek]) extra.push(ek); }
2155
- extra.sort();
2156
- for (var x = 0; x < extra.length; x++) facts.push({ kind: 'element_extra', elementId: extra[x] });
2157
- return facts;
2158
- }
2159
-
2160
- // Signal C: copy/text facts from rendered vs reference text per element. Only
2161
- // elements the reference declares text for are compared; identical → no fact,
2162
- // different → text_mismatch, empty/absent render → text_missing. Order-
2163
- // independent; output sorted by id. Mirrors the typed deriveCopyFacts.
2164
- function copyHasText(value) { return typeof value === 'string' && value.length > 0; }
2165
- function deriveCopyFacts(rendered, reference) {
2166
- var facts = [];
2167
- var ids = [];
2168
- for (var rk in reference) { if (Object.prototype.hasOwnProperty.call(reference, rk)) ids.push(rk); }
2169
- ids.sort();
2170
- for (var i = 0; i < ids.length; i++) {
2171
- var elementId = ids[i];
2172
- var referenceText = reference[elementId];
2173
- if (!copyHasText(referenceText)) continue;
2174
- var renderedText = rendered[elementId];
2175
- if (!copyHasText(renderedText)) {
2176
- facts.push({ kind: 'text_missing', elementId: elementId, reference: referenceText });
2177
- } else if (renderedText !== referenceText) {
2178
- facts.push({ kind: 'text_mismatch', elementId: elementId, rendered: renderedText, reference: referenceText });
2179
- }
2180
- }
2181
- return facts;
2182
- }
2183
-
2184
- // Signal C: span facts (issue 16 + color spans) from declared styled-segment
2185
- // extents vs the rendered spans per element, channel-scoped: a background
2186
- // declaration compares against background-carrying spans, a color declaration
2187
- // against color-carrying ones. The COLOR channel is capability-gated on the
2188
- // dump's spanChannels marker — an old emitter's silence is "cannot see",
2189
- // never "no color span rendered". A wrong extent is one span_mismatch; a
2190
- // matching extent whose color diverges is a span_color_mismatch.
2191
- // Order-independent; output sorted by id. Mirrors the typed deriveSpanFacts.
2192
- function deriveSpanFacts(rendered, reference, colorCapable) {
2193
- var facts = [];
2194
- var ids = [];
2195
- for (var rk in reference) { if (Object.prototype.hasOwnProperty.call(reference, rk)) ids.push(rk); }
2196
- ids.sort();
2197
- for (var i = 0; i < ids.length; i++) {
2198
- var elementId = ids[i];
2199
- var declared = reference[elementId];
2200
- var declaredText = declared && typeof declared === 'object' ? declared.text : undefined;
2201
- if (!declared || !copyHasText(declaredText)) continue;
2202
- var spans = rendered[elementId];
2203
- var all = Array.isArray(spans) ? spans : null;
2204
- var wantsBackground = copyHasText(declared.background);
2205
- var wantsColor = copyHasText(declared.color) && colorCapable === true;
2206
-
2207
- var extentOk = true;
2208
- var scopedExtents = [];
2209
- var s;
2210
- // Background / channel-less half. A channel-less declaration keeps its
2211
- // legacy semantics: any rendered span extent satisfies it.
2212
- if (wantsBackground || !copyHasText(declared.color)) {
2213
- if (all === null) {
2214
- // Node emitted no spans: old package / spanless text — blind on this
2215
- // half; only a capable color declaration can still speak below.
2216
- if (!wantsColor) continue;
2217
- } else {
2218
- var poolBg = [];
2219
- for (s = 0; s < all.length; s++) {
2220
- if (!wantsBackground || copyHasText(all[s] && all[s].background)) poolBg.push(all[s]);
2221
- }
2222
- var bgMatched = false;
2223
- for (s = 0; s < poolBg.length; s++) {
2224
- var t = poolBg[s] && poolBg[s].text;
2225
- if (!copyHasText(t)) continue;
2226
- scopedExtents.push(t);
2227
- if (t === declaredText) bgMatched = true;
2228
- }
2229
- if (!bgMatched) extentOk = false;
2230
- }
2231
- }
2232
- // Color half (capability-gated above).
2233
- var renderedColorForExtent = null;
2234
- if (wantsColor) {
2235
- var poolColor = [];
2236
- var source = all === null ? [] : all;
2237
- for (s = 0; s < source.length; s++) {
2238
- if (copyHasText(source[s] && source[s].color)) poolColor.push(source[s]);
2239
- }
2240
- var colorMatches = [];
2241
- for (s = 0; s < poolColor.length; s++) {
2242
- var ct = poolColor[s] && poolColor[s].text;
2243
- if (!copyHasText(ct)) continue;
2244
- scopedExtents.push(ct);
2245
- if (ct === declaredText) colorMatches.push(poolColor[s]);
2246
- }
2247
- if (colorMatches.length === 0) {
2248
- extentOk = false;
2249
- } else {
2250
- var declaredColor = String(declared.color).toLowerCase();
2251
- var colorHit = false;
2252
- for (s = 0; s < colorMatches.length; s++) {
2253
- if (String(colorMatches[s].color).toLowerCase() === declaredColor) { colorHit = true; break; }
2254
- }
2255
- if (!colorHit) renderedColorForExtent = String(colorMatches[0].color).toLowerCase();
2256
- }
2257
- }
2258
-
2259
- if (!extentOk) {
2260
- var uniqueExtents = [];
2261
- for (s = 0; s < scopedExtents.length; s++) {
2262
- if (uniqueExtents.indexOf(scopedExtents[s]) === -1) uniqueExtents.push(scopedExtents[s]);
2263
- }
2264
- facts.push({ kind: 'span_mismatch', elementId: elementId, rendered: uniqueExtents.join(', '), reference: declaredText });
2265
- continue;
2266
- }
2267
- if (renderedColorForExtent !== null) {
2268
- facts.push({
2269
- kind: 'span_color_mismatch',
2270
- elementId: elementId,
2271
- extent: declaredText,
2272
- rendered: renderedColorForExtent,
2273
- reference: String(declared.color).toLowerCase(),
2274
- });
2275
- }
2276
- }
2277
- return facts;
2278
- }
2279
-
2280
- // Signal C: line facts (issue 18) from the declared line split vs the rendered
2281
- // laid-out lines per element. Only elements the reference declares a split for
2282
- // are compared; a node that emitted no textLines (old package / no laid-out
2283
- // text) produces no fact — absence of a signal is never a divergence.
2284
- // Order-independent; output sorted by id. Mirrors the typed deriveLineFacts.
2285
- function deriveLineFacts(rendered, reference) {
2286
- var facts = [];
2287
- var ids = [];
2288
- for (var rk in reference) { if (Object.prototype.hasOwnProperty.call(reference, rk)) ids.push(rk); }
2289
- ids.sort();
2290
- for (var i = 0; i < ids.length; i++) {
2291
- var elementId = ids[i];
2292
- var declared = reference[elementId];
2293
- if (!Array.isArray(declared)) continue;
2294
- var declaredLines = [];
2295
- for (var d = 0; d < declared.length; d++) {
2296
- if (copyHasText(declared[d])) declaredLines.push(declared[d]);
2297
- }
2298
- if (declaredLines.length === 0) continue;
2299
- var lines = rendered[elementId];
2300
- if (!Array.isArray(lines)) continue;
2301
- var renderedLines = [];
2302
- for (var l = 0; l < lines.length; l++) {
2303
- if (copyHasText(lines[l] && lines[l].text)) renderedLines.push(lines[l].text);
2304
- }
2305
- var equal = renderedLines.length === declaredLines.length;
2306
- if (equal) {
2307
- for (var e = 0; e < renderedLines.length; e++) {
2308
- if (renderedLines[e] !== declaredLines[e]) { equal = false; break; }
2309
- }
2310
- }
2311
- if (equal) continue;
2312
- facts.push({ kind: 'line_break_mismatch', elementId: elementId, rendered: renderedLines.join('\n'), reference: declaredLines.join('\n') });
2313
- }
2314
- return facts;
2315
- }
2316
-
2317
- // Signal C: color facts (CIEDE2000 ΔE). hexToLab + ciede2000 + deriveColorFacts
2318
- // mirror the typed module; deltaE is the measurement only, no significance.
2319
- var COLOR_CHANNELS = ['fill', 'text'];
2320
- function colorParseHex(hex) {
2321
- var m = /^#?([0-9a-fA-F]{6})$/.exec(String(hex).trim());
2322
- if (!m) return null;
2323
- var n = parseInt(m[1], 16);
2324
- return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
2325
- }
2326
- function colorSrgbToLinear(c) {
2327
- return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
2328
- }
2329
- function colorLabF(t) {
2330
- return t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116;
2331
- }
2332
- function hexToLab(hex) {
2333
- var rgb = colorParseHex(hex);
2334
- if (!rgb) return [0, 0, 0];
2335
- var r = colorSrgbToLinear(rgb[0] / 255);
2336
- var g = colorSrgbToLinear(rgb[1] / 255);
2337
- var b = colorSrgbToLinear(rgb[2] / 255);
2338
- var X = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100;
2339
- var Y = (r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100;
2340
- var Z = (r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100;
2341
- var fx = colorLabF(X / 95.047);
2342
- var fy = colorLabF(Y / 100.0);
2343
- var fz = colorLabF(Z / 108.883);
2344
- return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
2345
- }
2346
- function colorDeg2rad(d) { return (d * Math.PI) / 180; }
2347
- function colorRad2deg(r) { return (r * 180) / Math.PI; }
2348
- function colorHueDeg(b, a) {
2349
- if (a === 0 && b === 0) return 0;
2350
- var deg = colorRad2deg(Math.atan2(b, a));
2351
- return deg >= 0 ? deg : deg + 360;
2352
- }
2353
- function ciede2000(lab1, lab2) {
2354
- var L1 = lab1[0], a1 = lab1[1], b1 = lab1[2];
2355
- var L2 = lab2[0], a2 = lab2[1], b2 = lab2[2];
2356
- var C1 = Math.sqrt(a1 * a1 + b1 * b1);
2357
- var C2 = Math.sqrt(a2 * a2 + b2 * b2);
2358
- var Cbar = (C1 + C2) / 2;
2359
- var Cbar7 = Math.pow(Cbar, 7);
2360
- var G = 0.5 * (1 - Math.sqrt(Cbar7 / (Cbar7 + 6103515625)));
2361
- var a1p = (1 + G) * a1;
2362
- var a2p = (1 + G) * a2;
2363
- var C1p = Math.sqrt(a1p * a1p + b1 * b1);
2364
- var C2p = Math.sqrt(a2p * a2p + b2 * b2);
2365
- var h1p = colorHueDeg(b1, a1p);
2366
- var h2p = colorHueDeg(b2, a2p);
2367
- var dLp = L2 - L1;
2368
- var dCp = C2p - C1p;
2369
- var dhp = 0;
2370
- if (C1p * C2p !== 0) {
2371
- dhp = h2p - h1p;
2372
- if (dhp > 180) dhp -= 360;
2373
- else if (dhp < -180) dhp += 360;
2374
- }
2375
- var dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(colorDeg2rad(dhp / 2));
2376
- var Lbarp = (L1 + L2) / 2;
2377
- var Cbarp = (C1p + C2p) / 2;
2378
- var hbarp;
2379
- if (C1p * C2p === 0) {
2380
- hbarp = h1p + h2p;
2381
- } else if (Math.abs(h1p - h2p) > 180) {
2382
- hbarp = (h1p + h2p) < 360 ? (h1p + h2p + 360) / 2 : (h1p + h2p - 360) / 2;
2383
- } else {
2384
- hbarp = (h1p + h2p) / 2;
2385
- }
2386
- var T = 1 - 0.17 * Math.cos(colorDeg2rad(hbarp - 30)) + 0.24 * Math.cos(colorDeg2rad(2 * hbarp)) +
2387
- 0.32 * Math.cos(colorDeg2rad(3 * hbarp + 6)) - 0.2 * Math.cos(colorDeg2rad(4 * hbarp - 63));
2388
- var dTheta = 30 * Math.exp(-Math.pow((hbarp - 275) / 25, 2));
2389
- var Cbarp7 = Math.pow(Cbarp, 7);
2390
- var Rc = 2 * Math.sqrt(Cbarp7 / (Cbarp7 + 6103515625));
2391
- var Sl = 1 + (0.015 * Math.pow(Lbarp - 50, 2)) / Math.sqrt(20 + Math.pow(Lbarp - 50, 2));
2392
- var Sc = 1 + 0.045 * Cbarp;
2393
- var Sh = 1 + 0.015 * Cbarp * T;
2394
- var Rt = -Math.sin(colorDeg2rad(2 * dTheta)) * Rc;
2395
- var termL = dLp / Sl, termC = dCp / Sc, termH = dHp / Sh;
2396
- return Math.sqrt(termL * termL + termC * termC + termH * termH + Rt * termC * termH);
2397
- }
2398
- function deriveColorFacts(rendered, reference) {
2399
- var facts = [];
2400
- var ids = [];
2401
- for (var rk in reference) { if (Object.prototype.hasOwnProperty.call(reference, rk)) ids.push(rk); }
2402
- ids.sort();
2403
- for (var i = 0; i < ids.length; i++) {
2404
- var elementId = ids[i];
2405
- var refColors = reference[elementId];
2406
- var renColors = rendered[elementId];
2407
- if (!refColors) continue;
2408
- for (var c = 0; c < COLOR_CHANNELS.length; c++) {
2409
- var channel = COLOR_CHANNELS[c];
2410
- var referenceColor = refColors[channel];
2411
- var renderedColor = renColors ? renColors[channel] : undefined;
2412
- if (!copyHasText(referenceColor) || !copyHasText(renderedColor)) continue;
2413
- // Opacity (issue 14b): graded only when the DESIGN states a translucency —
2414
- // an opaque design says nothing about alpha, so a render reporting one is
2415
- // not a divergence — and only past a rounding step. Kept ahead of the hex
2416
- // compare because the two are independent: the colour can be right while
2417
- // the state (disabled vs enabled) is wrong.
2418
- var referenceAlpha = refColors[channel + 'Alpha'];
2419
- var renderedAlpha = renColors ? renColors[channel + 'Alpha'] : undefined;
2420
- if (typeof referenceAlpha === 'number') {
2421
- var renA = typeof renderedAlpha === 'number' ? renderedAlpha : 1;
2422
- if (Math.abs(referenceAlpha - renA) > 0.01) {
2423
- facts.push({
2424
- kind: 'color_alpha', elementId: elementId, channel: channel,
2425
- rendered: renA, reference: referenceAlpha,
2426
- });
2427
- }
2428
- }
2429
- if (renderedColor.toLowerCase() === referenceColor.toLowerCase()) continue;
2430
- facts.push({
2431
- kind: 'color', elementId: elementId, channel: channel,
2432
- rendered: renderedColor, reference: referenceColor,
2433
- deltaE: ciede2000(hexToLab(renderedColor), hexToLab(referenceColor)),
2434
- });
2435
- }
2436
- }
2437
- return facts;
2438
- }
2439
-
2440
- // Border facts (property-recognizer 01) — the runner twin of deriveBorderFacts.
2441
- // The design's stroke against the render's captured border; the DESIGN is the
2442
- // only side that raises the question, and "capable" gates the ABSENCE case only,
2443
- // because a border-blind dump cannot tell "no ring drawn" from "I cannot see
2444
- // rings". Patching only the typed copy would make the local preview and the real
2445
- // gate disagree, silently.
2446
- function deriveBorderFacts(rendered, reference, capable) {
2447
- var facts = [];
2448
- var ids = Object.keys(reference).sort();
2449
- for (var i = 0; i < ids.length; i++) {
2450
- var elementId = ids[i];
2451
- var ref = reference[elementId];
2452
- if (!ref || !copyHasText(ref.stroke)) continue;
2453
- var ren = rendered[elementId];
2454
- var renderedStroke = ren ? ren.stroke : undefined;
2455
- if (!copyHasText(renderedStroke)) {
2456
- if (capable) {
2457
- var missing = { kind: 'border_missing', elementId: elementId, reference: ref.stroke };
2458
- if (typeof ref.strokeWeight === 'number') missing.referenceWidth = ref.strokeWeight;
2459
- facts.push(missing);
2460
- }
2461
- continue;
2462
- }
2463
- if (renderedStroke.toLowerCase() !== ref.stroke.toLowerCase()) {
2464
- facts.push({
2465
- kind: 'border', elementId: elementId,
2466
- rendered: renderedStroke, reference: ref.stroke,
2467
- });
2468
- }
2469
- var refW = ref.strokeWeight;
2470
- var renW = ren ? ren.strokeWeight : undefined;
2471
- if (typeof refW === 'number' && typeof renW === 'number' && Math.abs(refW - renW) > 0.5) {
2472
- facts.push({ kind: 'border_width', elementId: elementId, rendered: renW, reference: refW });
2473
- }
2474
- }
2475
- return facts;
2476
- }
2477
-
2478
- // Signal C: typeface facts (issue 03) — the RESOLVED (painted, post-fallback)
2479
- // font vs the reference font. Mirrors the typed normalizeTypeface /
2480
- // deriveTypefaceFacts: a weight-name suffix on the family is stripped and, when
2481
- // no explicit numeric weight exists, becomes the weight; an explicit numeric
2482
- // weight always wins; no family signal on either side → no fact.
2483
- var TYPEFACE_WEIGHT_NAMES = {
2484
- thin: 100, hairline: 100, extralight: 200, ultralight: 200, light: 300,
2485
- normal: 400, regular: 400, book: 400, medium: 500, semibold: 600,
2486
- demibold: 600, bold: 700, extrabold: 800, ultrabold: 800, black: 900, heavy: 900,
2487
- };
2488
- function normalizeTypeface(spec) {
2489
- var raw = spec && typeof spec.family === 'string' ? spec.family.replace(/^\s+|\s+$/g, '') : '';
2490
- if (raw.length === 0) return null;
2491
- var tokens = [];
2492
- var rawTokens = raw.split(/[\s\-_]+/);
2493
- for (var t = 0; t < rawTokens.length; t++) { if (rawTokens[t].length > 0) tokens.push(rawTokens[t]); }
2494
- var derivedWeight;
2495
- while (tokens.length > 1) {
2496
- var candidate = tokens[tokens.length - 1].toLowerCase();
2497
- if (Object.prototype.hasOwnProperty.call(TYPEFACE_WEIGHT_NAMES, candidate)) {
2498
- derivedWeight = TYPEFACE_WEIGHT_NAMES[candidate];
2499
- tokens.pop();
2500
- } else {
2501
- break;
2502
- }
2503
- }
2504
- var family = tokens.join('').toLowerCase().replace(/[^a-z0-9]/g, '');
2505
- var explicit = spec && typeof spec.weight === 'number' && isFinite(spec.weight) ? spec.weight : undefined;
2506
- var weight = explicit != null ? explicit : (derivedWeight != null ? derivedWeight : 400);
2507
- return { family: family, weight: weight };
2508
- }
2509
- function deriveTypefaceFacts(rendered, reference) {
2510
- var facts = [];
2511
- var ids = [];
2512
- for (var rk in reference) { if (Object.prototype.hasOwnProperty.call(reference, rk)) ids.push(rk); }
2513
- ids.sort();
2514
- for (var i = 0; i < ids.length; i++) {
2515
- var elementId = ids[i];
2516
- var refNorm = normalizeTypeface(reference[elementId]);
2517
- var renNorm = normalizeTypeface(rendered[elementId]);
2518
- if (!refNorm || !renNorm) continue;
2519
- if (refNorm.family === renNorm.family && refNorm.weight === renNorm.weight) continue;
2520
- facts.push({
2521
- kind: 'typeface', elementId: elementId, channel: 'text',
2522
- rendered: rendered[elementId], reference: reference[elementId],
2523
- });
2524
- }
2525
- return facts;
2526
- }
2527
-
2528
- // Signal C: type-metric facts (typography facts 01) — the laid-out size,
2529
- // tracking and leading against the design's, in sp. Mirrors the typed
2530
- // deriveTypeMetricFacts: one fact PER METRIC (finding identity is
2531
- // screen+element+category, so folding metrics into one fact would collapse them
2532
- // into one finding and drop the rest), both sides must declare the metric, and a
2533
- // sub-unit difference is representation noise rather than a divergence.
2534
- var TYPE_METRIC_KEYS = ['fontSize', 'letterSpacing', 'lineHeight'];
2535
- var TYPE_METRIC_TOLERANCE_SP = 0.25;
2536
- function finiteMetric(value) {
2537
- return typeof value === 'number' && isFinite(value) ? value : undefined;
2538
- }
2539
- function deriveTypeMetricFacts(rendered, reference) {
2540
- var facts = [];
2541
- var ids = [];
2542
- for (var rk in reference) { if (Object.prototype.hasOwnProperty.call(reference, rk)) ids.push(rk); }
2543
- ids.sort();
2544
- for (var i = 0; i < ids.length; i++) {
2545
- var elementId = ids[i];
2546
- var ref = reference[elementId];
2547
- var ren = rendered[elementId];
2548
- if (!ref || !ren) continue;
2549
- for (var m = 0; m < TYPE_METRIC_KEYS.length; m++) {
2550
- var metric = TYPE_METRIC_KEYS[m];
2551
- var referenceValue = finiteMetric(ref[metric]);
2552
- var renderedValue = finiteMetric(ren[metric]);
2553
- if (referenceValue === undefined || renderedValue === undefined) continue;
2554
- if (Math.abs(renderedValue - referenceValue) <= TYPE_METRIC_TOLERANCE_SP) continue;
2555
- facts.push({
2556
- kind: 'type_metric', elementId: elementId, channel: 'text', metric: metric,
2557
- rendered: renderedValue, reference: referenceValue,
2558
- });
2559
- }
2560
- }
2561
- return facts;
2562
- }
2563
-
2564
- // Signal C: assemble the AppearanceReference from a screen's reference params
2565
- // (issue 05). Built when ANY of geometry/text/color is present; elementIds is the
2566
- // UNION of their keys (geometry, then text, then color — deduped). Mirrors the
2567
- // typed buildAppearanceReferenceFromParams. Decouples the appearance container
2568
- // from reference_geometry so a reference_color-only run still yields color facts.
2569
- function appearanceParamsNonEmpty(value) {
2570
- return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0;
2571
- }
2572
- function buildAppearanceReferenceFromParams(params) {
2573
- var p = params || {};
2574
- var hasGeom = appearanceParamsNonEmpty(p.refGeom);
2575
- var hasText = appearanceParamsNonEmpty(p.refText);
2576
- var hasColor = appearanceParamsNonEmpty(p.refColor);
2577
- var hasSpans = appearanceParamsNonEmpty(p.refSpans);
2578
- var hasLines = appearanceParamsNonEmpty(p.refLines);
2579
- var hasTypeface = appearanceParamsNonEmpty(p.refTypeface);
2580
- if (!hasGeom && !hasText && !hasColor && !hasSpans && !hasLines && !hasTypeface) return null;
2581
- var elementIds = [];
2582
- var seen = {};
2583
- function addKeys(record) {
2584
- if (!record) return;
2585
- var keys = Object.keys(record);
2586
- for (var i = 0; i < keys.length; i++) {
2587
- if (!Object.prototype.hasOwnProperty.call(seen, keys[i])) {
2588
- seen[keys[i]] = true;
2589
- elementIds.push(keys[i]);
2590
- }
2591
- }
2592
- }
2593
- if (hasGeom) addKeys(p.refGeom);
2594
- if (hasText) addKeys(p.refText);
2595
- if (hasColor) addKeys(p.refColor);
2596
- if (hasSpans) addKeys(p.refSpans);
2597
- if (hasLines) addKeys(p.refLines);
2598
- if (hasTypeface) addKeys(p.refTypeface);
2599
- var reference = { baseline: p.baseline, elementIds: elementIds };
2600
- if (hasText) reference.text = p.refText;
2601
- if (hasColor) reference.color = p.refColor;
2602
- if (hasSpans) reference.spans = p.refSpans;
2603
- if (hasLines) reference.lines = p.refLines;
2604
- if (hasTypeface) reference.typeface = p.refTypeface;
2605
- return reference;
2606
- }
2607
-
2608
- // Text-aware size band (line-box residue): JVM text metrics differ ~2dp per
2609
- // laid-out line from the design's nominal line boxes, so text-driven elements
2610
- // get a line-scaled significance band (capped) while measurements stay exact
2611
- // and non-text elements keep the strict band. Mirrors the typed sizeBandDp.
2612
- var TEXT_LINE_RESIDUE_DP = 2;
2613
- var TEXT_BOX_RESIDUE_DP = 2;
2614
- var TEXT_SIZE_SLACK_MAX_DP = 8;
2615
-
2616
- function nodeTextLines(node) {
2617
- if (Array.isArray(node.textLines)) return node.textLines.length;
2618
- return typeof node.text === 'string' && node.text.length > 0 ? 1 : 0;
2619
- }
2620
-
2621
- // Total laid-out lines in the element's subtree; visits node OBJECTS so a
2622
- // malformed parent cycle or duplicated id cannot loop.
2623
- function subtreeTextLineCount(elementId, byId, childrenByParent) {
2624
- var root = byId[elementId];
2625
- if (!root) return 0;
2626
- var lines = 0;
2627
- var visited = [];
2628
- var stack = [root];
2629
- while (stack.length > 0) {
2630
- var node = stack.pop();
2631
- if (visited.indexOf(node) !== -1) continue;
2632
- visited.push(node);
2633
- lines += nodeTextLines(node);
2634
- var children = childrenByParent[node.id] || [];
2635
- for (var ci = 0; ci < children.length; ci++) stack.push(children[ci]);
2636
- }
2637
- return lines;
2638
- }
2639
-
2640
- function sizeBandDp(subtreeLines, sizeTolerancePx) {
2641
- if (subtreeLines <= 0) return sizeTolerancePx;
2642
- var band = TEXT_BOX_RESIDUE_DP + TEXT_LINE_RESIDUE_DP * subtreeLines;
2643
- if (band > TEXT_SIZE_SLACK_MAX_DP) band = TEXT_SIZE_SLACK_MAX_DP;
2644
- return band > sizeTolerancePx ? band : sizeTolerancePx;
2645
- }
2646
-
2647
- // The element's render ancestry, immediate parent first (icon-ink-ratio 05).
2648
- // Empty for a root. Cycle-guarded like subtreeTextLineCount above.
2649
- function ancestorChain(elementId, byId) {
2650
- var out = [];
2651
- var seen = [elementId];
2652
- var node = byId[elementId];
2653
- var cursor = node && node.parentId != null ? node.parentId : null;
2654
- while (cursor != null && seen.indexOf(cursor) === -1) {
2655
- seen.push(cursor);
2656
- out.push(cursor);
2657
- var next = byId[cursor];
2658
- cursor = next && next.parentId != null ? next.parentId : null;
2659
- }
2660
- return out;
2661
- }
2662
-
2663
- function buildStructuralBlock(dump, referenceGeometry, density, options, appearanceReference, referenceFrame, anchorFrame) {
2664
- var opts = options || {};
2665
- var DIMENSION_SHORT = { width: 'w', height: 'h', x: 'x', y: 'y' };
2666
- var FRAME_TOLERANCE_DP = 1;
2667
- var facts = deriveStructuralFacts(dump);
2668
- var d = typeof density === 'number' && density > 0 ? density : 1;
2669
- var sizeTolerancePx = opts.sizeTolerancePx == null ? 1 : opts.sizeTolerancePx;
2670
- var overhangTolerancePx = opts.overhangTolerancePx == null ? 2 : opts.overhangTolerancePx;
2671
-
2672
- // Design-frame anchor (composition runs): assert the captured root against
2673
- // the declared frame; a mismatch is a wrapper-authoring fact and withholds
2674
- // placement facts for the State (they'd be garbage on a wrong canvas).
2675
- var placement;
2676
- if (referenceFrame && typeof referenceFrame.w === 'number' && typeof referenceFrame.h === 'number') {
2677
- var capturedW = (dump.canvas.right - dump.canvas.left) / d;
2678
- var capturedH = (dump.canvas.bottom - dump.canvas.top) / d;
2679
- var frameDeltas = [];
2680
- if (Math.abs(capturedW - referenceFrame.w) > FRAME_TOLERANCE_DP) {
2681
- frameDeltas.push({ dimension: 'w', delta: Math.round(capturedW - referenceFrame.w) });
2682
- }
2683
- if (Math.abs(capturedH - referenceFrame.h) > FRAME_TOLERANCE_DP) {
2684
- frameDeltas.push({ dimension: 'h', delta: Math.round(capturedH - referenceFrame.h) });
2685
- }
2686
- if (frameDeltas.length > 0) {
2687
- facts.push({
2688
- kind: 'frame_mismatch',
2689
- declared: { w: referenceFrame.w, h: referenceFrame.h },
2690
- captured: { w: Math.round(capturedW), h: Math.round(capturedH) },
2691
- deltas: frameDeltas,
2692
- });
2693
- placement = { withheld: true, reason: 'frame_mismatch' };
2694
- } else {
2695
- placement = { withheld: false };
2696
- }
2697
- }
2698
-
2699
- // Frame-anchored component runs (coverage-gaps issue 01): the design frame
2700
- // as an ANCHOR PROBE — a matching captured root earns the composition-style
2701
- // placement check (x/y flags); a non-matching root changes NOTHING (no fact,
2702
- // no placement, x/y stays advisory).
2703
- if (!placement && anchorFrame && typeof anchorFrame.w === 'number' && typeof anchorFrame.h === 'number') {
2704
- var anchorW = (dump.canvas.right - dump.canvas.left) / d;
2705
- var anchorH = (dump.canvas.bottom - dump.canvas.top) / d;
2706
- if (
2707
- Math.abs(anchorW - anchorFrame.w) <= FRAME_TOLERANCE_DP &&
2708
- Math.abs(anchorH - anchorFrame.h) <= FRAME_TOLERANCE_DP
2709
- ) {
2710
- placement = { withheld: false };
2711
- }
2712
- }
2713
-
2714
- var geometry;
2715
- // testTag collisions: a duplicated tag's generated geometry is last-wins (an
2716
- // arbitrary instance), so its size/clip/placement comparisons are withheld.
2717
- var dupCounts = {};
2718
- for (var dci = 0; dci < dump.nodes.length; dci++) {
2719
- dupCounts[dump.nodes[dci].id] = (dupCounts[dump.nodes[dci].id] || 0) + 1;
2720
- }
2721
- var generated = generatedGeometryDp(dump, d);
2722
- if (referenceGeometry && Object.keys(referenceGeometry).length > 0) {
2723
- var ref = referenceGeometryToDiffShape(referenceGeometry);
2724
- var byId = {};
2725
- for (var bi = 0; bi < dump.nodes.length; bi++) byId[dump.nodes[bi].id] = dump.nodes[bi];
2726
- var childrenByParent = {};
2727
- for (var cp = 0; cp < dump.nodes.length; cp++) {
2728
- var cnode = dump.nodes[cp];
2729
- if (!cnode.parentId) continue;
2730
- if (!childrenByParent[cnode.parentId]) childrenByParent[cnode.parentId] = [];
2731
- childrenByParent[cnode.parentId].push(cnode);
2732
- }
2733
- var rawList = diffKeyGeometry(generated, ref, { tolerancePx: 0 });
2734
- var deltasByElement = {};
2735
- for (var i = 0; i < rawList.length; i++) deltasByElement[rawList[i].elementId] = rawList[i].deltas;
2736
- var elements = [];
2737
- var refIds = Object.keys(ref);
2738
- for (var k = 0; k < refIds.length; k++) {
2739
- var id = refIds[k];
2740
- if (dupCounts[id] > 1) continue;
2741
- var g = generated[id];
2742
- var r = ref[id];
2743
- if (!r) continue;
2744
- if (!g) {
2745
- // Declared but absent from the render: persist the design box alone,
2746
- // so the absence keeps the design's record of WHERE instead of taking
2747
- // it down with the element.
2748
- var absentDesign = { w: isNaN(r.width) ? undefined : r.width, h: isNaN(r.height) ? undefined : r.height };
2749
- if (typeof r.x === 'number') absentDesign.x = r.x;
2750
- if (typeof r.y === 'number') absentDesign.y = r.y;
2751
- elements.push({ id: id, design: absentDesign });
2752
- continue;
2753
- }
2754
- var rawDeltas = deltasByElement[id] || [];
2755
- var deltas = [];
2756
- // Text-aware significance band (line-box residue): deltas stay exact;
2757
- // only the flag uses the band.
2758
- var band = sizeBandDp(subtreeTextLineCount(id, byId, childrenByParent), sizeTolerancePx);
2759
- var sizeFlag = false;
2760
- for (var j = 0; j < rawDeltas.length; j++) {
2761
- var dimension = rawDeltas[j].dimension;
2762
- var rounded = Math.round(rawDeltas[j].delta);
2763
- if (rounded === 0) continue;
2764
- deltas.push({ dimension: DIMENSION_SHORT[dimension], delta: rounded });
2765
- if ((dimension === 'width' || dimension === 'height') && Math.abs(rawDeltas[j].delta) > band) {
2766
- sizeFlag = true;
2767
- }
2768
- }
2769
- var element = {
2770
- id: id,
2771
- rendered: { w: roundMeasure(g.width), h: roundMeasure(g.height), x: roundMeasure(g.x), y: roundMeasure(g.y) },
2772
- design: { w: isNaN(r.width) ? undefined : r.width, h: isNaN(r.height) ? undefined : r.height },
2773
- deltas: deltas,
2774
- sizeFlag: sizeFlag,
2775
- };
2776
- if (typeof r.x === 'number') element.design.x = r.x;
2777
- if (typeof r.y === 'number') element.design.y = r.y;
2778
- var overhang = computeOverhang(id, g, byId, generated, referenceGeometry, overhangTolerancePx, sizeTolerancePx, dupCounts);
2779
- if (overhang) element.overhang = overhang;
2780
- // What the element PAINTS, verbatim from the dump (icon-ink-ratio 01): an
2781
- // icon's layout box is its container and the reference's key is the glyph,
2782
- // so without this the judge compares a container against ink.
2783
- var paintedInk = byId[id] ? byId[id].paintedInk : undefined;
2784
- if (paintedInk) element.paintedInk = paintedInk;
2785
- // Render ancestry (icon-ink-ratio 05) — the CHAIN, because Compose omits
2786
- // untagged layout nodes and an immediate parent cannot see past the gap.
2787
- // Absent (not empty) when the node is not in the dump, so the judge can tell
2788
- // "no ancestry measured" from "measured, at the root".
2789
- if (byId[id]) element.ancestors = ancestorChain(id, byId);
2790
- elements.push(element);
2791
- }
2792
- geometry = { unit: 'dp', density: d, elements: elements };
2793
-
2794
- // Placement facts (composition runs, frame asserted OK): member-root x/y
2795
- // vs the declared design geometry, root-space dp — directly comparable
2796
- // because the root is frame-sized. Load-bearing here, unlike the advisory
2797
- // x/y deltas on geometry elements.
2798
- if (placement && !placement.withheld) {
2799
- var PLACEMENT_TOLERANCE_DP = 2;
2800
- var placementFacts = [];
2801
- var pIds = Object.keys(ref);
2802
- for (var pi = 0; pi < pIds.length; pi++) {
2803
- var pid = pIds[pi];
2804
- if (dupCounts[pid] > 1) continue;
2805
- var pg = generated[pid];
2806
- var pr = referenceGeometry[pid];
2807
- if (!pg || !pr || typeof pr.x !== 'number' || typeof pr.y !== 'number') continue;
2808
- var gx = pg.x == null ? 0 : pg.x;
2809
- var gy = pg.y == null ? 0 : pg.y;
2810
- var placementDeltas = [];
2811
- if (Math.abs(gx - pr.x) > PLACEMENT_TOLERANCE_DP) {
2812
- placementDeltas.push({ dimension: 'x', delta: Math.round(gx - pr.x) });
2813
- }
2814
- if (Math.abs(gy - pr.y) > PLACEMENT_TOLERANCE_DP) {
2815
- placementDeltas.push({ dimension: 'y', delta: Math.round(gy - pr.y) });
2816
- }
2817
- if (placementDeltas.length > 0) {
2818
- placementFacts.push({
2819
- elementId: pid,
2820
- expected: { x: pr.x, y: pr.y },
2821
- actual: { x: Math.round(gx), y: Math.round(gy) },
2822
- deltas: placementDeltas,
2823
- });
2824
- }
2825
- }
2826
- placement.facts = placementFacts;
2827
- }
2828
- }
2829
-
2830
- var maxSizeDeltaDp = null;
2831
- var maxClipDeltaDp = null;
2832
- if (geometry) {
2833
- maxSizeDeltaDp = 0;
2834
- maxClipDeltaDp = 0;
2835
- for (var e = 0; e < geometry.elements.length; e++) {
2836
- var els = geometry.elements[e].deltas || [];
2837
- for (var di = 0; di < els.length; di++) {
2838
- if (els[di].dimension !== 'w' && els[di].dimension !== 'h') continue;
2839
- var abs = Math.abs(els[di].delta);
2840
- if (abs > maxSizeDeltaDp) maxSizeDeltaDp = abs;
2841
- }
2842
- var oh = geometry.elements[e].overhang;
2843
- if (oh) {
2844
- for (var od = 0; od < oh.deltas.length; od++) {
2845
- var oabs = Math.abs(oh.deltas[od].delta);
2846
- if (oabs > maxClipDeltaDp) maxClipDeltaDp = oabs;
2847
- }
2848
- }
2849
- }
2850
- }
2851
-
2852
- var sizeIssue = false;
2853
- var clipIssue = false;
2854
- if (geometry) {
2855
- for (var s = 0; s < geometry.elements.length; s++) {
2856
- if (geometry.elements[s].sizeFlag) sizeIssue = true;
2857
- if (geometry.elements[s].overhang && geometry.elements[s].overhang.clipFlag) clipIssue = true;
2858
- }
2859
- }
2860
- var placementIssue = !!(placement && placement.facts && placement.facts.length > 0);
2861
- var summary = {
2862
- clean: facts.length === 0 && !sizeIssue && !clipIssue && !placementIssue,
2863
- factCount: facts.length,
2864
- maxSizeDeltaDp: maxSizeDeltaDp,
2865
- maxClipDeltaDp: maxClipDeltaDp,
2866
- };
2867
-
2868
- // Description vs comparison: rendered boxes persist for every authored-tag
2869
- // node and every fact-named node, reference or not — a description of what
2870
- // rasterized. The comparison fields (design/deltas/sizeFlag/overhang) exist
2871
- // only on the reference-declared elements assembled above, and the summary
2872
- // is computed BEFORE this extension, so geometry presence can never affect
2873
- // verdict outcomes (no reference → max deltas stay null). Duplicated tags
2874
- // stay out everywhere: last-wins geometry is ambiguous, and the
2875
- // duplicate_tag fact carries the collision.
2876
- var UNTAGGED_ID_RE = /^node-\d+$/;
2877
- var factNamed = {};
2878
- for (var fni = 0; fni < facts.length; fni++) {
2879
- var ffact = facts[fni];
2880
- if (typeof ffact.nodeId === 'string') factNamed[ffact.nodeId] = true;
2881
- if (typeof ffact.clipperId === 'string') factNamed[ffact.clipperId] = true;
2882
- if (Array.isArray(ffact.nodeIds)) {
2883
- for (var fnj = 0; fnj < ffact.nodeIds.length; fnj++) {
2884
- if (typeof ffact.nodeIds[fnj] === 'string') factNamed[ffact.nodeIds[fnj]] = true;
2885
- }
2886
- }
2887
- }
2888
- var emitted = {};
2889
- if (geometry) {
2890
- for (var emi = 0; emi < geometry.elements.length; emi++) emitted[geometry.elements[emi].id] = true;
2891
- }
2892
- var described = [];
2893
- for (var dni = 0; dni < dump.nodes.length; dni++) {
2894
- var dnode = dump.nodes[dni];
2895
- if (emitted[dnode.id] || dupCounts[dnode.id] > 1) continue;
2896
- if (UNTAGGED_ID_RE.test(dnode.id) && !factNamed[dnode.id]) continue;
2897
- var dg = generated[dnode.id];
2898
- if (!dg) continue;
2899
- emitted[dnode.id] = true;
2900
- described.push({
2901
- id: dnode.id,
2902
- rendered: { w: roundMeasure(dg.width), h: roundMeasure(dg.height), x: roundMeasure(dg.x == null ? 0 : dg.x), y: roundMeasure(dg.y == null ? 0 : dg.y) },
2903
- });
2904
- }
2905
- if (geometry) {
2906
- for (var dpi = 0; dpi < described.length; dpi++) geometry.elements.push(described[dpi]);
2907
- } else {
2908
- geometry = { unit: 'dp', density: d, elements: described };
2909
- }
2910
-
2911
- var block = { summary: summary, facts: facts };
2912
- if (geometry) block.geometry = geometry;
2913
- if (placement) block.placement = placement;
2914
- // Signal C (additive): emit the appearance container when a reference is
2915
- // supplied — presence facts plus, when the reference carries text, copy facts.
2916
- // Container presence (with a baseline) means "Signal C ran."
2917
- if (appearanceReference) {
2918
- // The render's own appearance — ids + per-node text/colors. Used to derive
2919
- // facts vs the reference AND persisted as the rendered snapshot (baseline
2920
- // source for the next no-Figma render).
2921
- var renderedIds = [];
2922
- var renderedText = {};
2923
- var renderedColor = {};
2924
- var renderedSpans = {};
2925
- var renderedLinesMap = {};
2926
- var renderedTypeface = {};
2927
- var renderedTypeMetrics = {};
2928
- for (var ri = 0; ri < dump.nodes.length; ri++) {
2929
- var rnode = dump.nodes[ri];
2930
- renderedIds.push(rnode.id);
2931
- if (typeof rnode.text === 'string') renderedText[rnode.id] = rnode.text;
2932
- if (Array.isArray(rnode.textSpans)) renderedSpans[rnode.id] = rnode.textSpans;
2933
- if (Array.isArray(rnode.textLines)) renderedLinesMap[rnode.id] = rnode.textLines;
2934
- var colors = {};
2935
- var hasColor = false;
2936
- if (typeof rnode.fillColor === 'string') { colors.fill = rnode.fillColor; hasColor = true; }
2937
- if (typeof rnode.textColor === 'string') { colors.text = rnode.textColor; hasColor = true; }
2938
- if (typeof rnode.fillAlpha === 'number') { colors.fillAlpha = rnode.fillAlpha; }
2939
- if (typeof rnode.textAlpha === 'number') { colors.textAlpha = rnode.textAlpha; }
2940
- // The rendered border (property-recognizer 01), keyed as "stroke" to match
2941
- // the design side. borderWidth is already DP — never divided by density.
2942
- // hasColor flips here too: a ring is the element's only paint on a
2943
- // stroke-only rule, and dropping the entry would lose the comparison.
2944
- if (typeof rnode.borderColor === 'string') { colors.stroke = rnode.borderColor; hasColor = true; }
2945
- if (typeof rnode.borderWidth === 'number') { colors.strokeWeight = rnode.borderWidth; }
2946
- if (hasColor) renderedColor[rnode.id] = colors;
2947
- // The resolved-typeface enrichment (issue 03): the font that actually painted.
2948
- var rtf = rnode.resolvedTypeface;
2949
- if (rtf && typeof rtf.family === 'string' && rtf.family.length > 0) {
2950
- renderedTypeface[rnode.id] = typeof rtf.weight === 'number' ? { family: rtf.family, weight: rtf.weight } : { family: rtf.family };
2951
- }
2952
- // The laid-out type metrics (typography facts 01) — their own map, so a
2953
- // size stays comparable on a dump whose family capture is absent.
2954
- var rtm = rnode.typeMetrics;
2955
- if (rtm && typeof rtm === 'object') {
2956
- var metrics = {};
2957
- var hasMetric = false;
2958
- for (var tmk = 0; tmk < TYPE_METRIC_KEYS.length; tmk++) {
2959
- var mkey = TYPE_METRIC_KEYS[tmk];
2960
- var mval = rtm[mkey];
2961
- if (typeof mval === 'number' && isFinite(mval)) { metrics[mkey] = mval; hasMetric = true; }
2962
- }
2963
- if (hasMetric) renderedTypeMetrics[rnode.id] = metrics;
2964
- }
2965
- }
2966
- // Presence facts: element_extra needs the COMPLETE reference inventory —
2967
- // only the previous_render baseline carries it (figma's ids are a partial
2968
- // annotation, so extra there is noise — issue 04). element_missing is
2969
- // noise-free on either baseline: a declared id with no rendered node is a
2970
- // real absence (coverage-gaps issue 03). Missing-only on figma.
2971
- var presenceFacts = derivePresenceFacts(renderedIds, appearanceReference.elementIds);
2972
- var appearanceFacts = appearanceReference.baseline === 'previous_render'
2973
- ? presenceFacts
2974
- : presenceFacts.filter(function (f) { return f.kind === 'element_missing'; });
2975
- if (appearanceReference.text) {
2976
- var copyFacts = deriveCopyFacts(renderedText, appearanceReference.text);
2977
- for (var cf = 0; cf < copyFacts.length; cf++) appearanceFacts.push(copyFacts[cf]);
2978
- }
2979
- if (appearanceReference.spans) {
2980
- // Color-span capability: the emitter declares the channels it can
2981
- // report; a dump predating the marker is color-blind.
2982
- var colorSpanCapable = Array.isArray(dump.spanChannels) && dump.spanChannels.indexOf('color') !== -1;
2983
- var spanFacts = deriveSpanFacts(renderedSpans, appearanceReference.spans, colorSpanCapable);
2984
- for (var sf = 0; sf < spanFacts.length; sf++) appearanceFacts.push(spanFacts[sf]);
2985
- }
2986
- if (appearanceReference.lines) {
2987
- var lineFacts = deriveLineFacts(renderedLinesMap, appearanceReference.lines);
2988
- for (var lf = 0; lf < lineFacts.length; lf++) appearanceFacts.push(lineFacts[lf]);
2989
- }
2990
- if (appearanceReference.color) {
2991
- var colorFacts = deriveColorFacts(renderedColor, appearanceReference.color);
2992
- for (var cof = 0; cof < colorFacts.length; cof++) appearanceFacts.push(colorFacts[cof]);
2993
- }
2994
- // Typeface facts (issue 03): the capture seam (TextRunShaper) is API 31+ —
2995
- // a proof rendered below that is reported as NOT RUN (advisory), never a
2996
- // silent pass. An ABSENT renderSdk predates the typeface-capturing emitter
2997
- // entirely — same advisory (coverage-gaps issue 04).
2998
- var appearanceAdvisories = [];
2999
- if (appearanceReference.typeface) {
3000
- if (typeof dump.renderSdk !== 'number') {
3001
- appearanceAdvisories.push(
3002
- 'typeface check requires render SDK >= 31; this proof declared no render SDK ' +
3003
- '(an older package) — typeface facts were not derived (not checked, not passing)'
3004
- );
3005
- } else if (dump.renderSdk < 31) {
3006
- appearanceAdvisories.push(
3007
- 'typeface check requires render SDK >= 31; this proof rendered at SDK ' +
3008
- dump.renderSdk +
3009
- ' — typeface facts were not derived (not checked, not passing)'
3010
- );
3011
- } else {
3012
- var typefaceFacts = deriveTypefaceFacts(renderedTypeface, appearanceReference.typeface);
3013
- for (var tff = 0; tff < typefaceFacts.length; tff++) appearanceFacts.push(typefaceFacts[tff]);
3014
- }
3015
- // Type metrics ride the same reference channel but NOT the SDK guard —
3016
- // reading a style has nothing to do with the per-glyph shaping seam, and a
3017
- // proof rendered below SDK 31 still lays its text out at a size.
3018
- var metricFacts = deriveTypeMetricFacts(renderedTypeMetrics, appearanceReference.typeface);
3019
- for (var tmf = 0; tmf < metricFacts.length; tmf++) appearanceFacts.push(metricFacts[tmf]);
3020
- }
3021
- // Border facts (property-recognizer 01). styleChannels is the emitter's own
3022
- // declaration that it can see borders; a package predating border capture
3023
- // lacks it, and that silence is "cannot see a border", never "no border was
3024
- // drawn". The advisory NAMES the elements — an unnamed "not checked" is a
3025
- // statement nobody can act on.
3026
- if (appearanceReference.color) {
3027
- var borderCapable = Array.isArray(dump.styleChannels) &&
3028
- dump.styleChannels.indexOf('border') !== -1;
3029
- var borderFacts = deriveBorderFacts(renderedColor, appearanceReference.color, borderCapable);
3030
- for (var bf = 0; bf < borderFacts.length; bf++) appearanceFacts.push(borderFacts[bf]);
3031
- if (!borderCapable) {
3032
- var strokeKeys = Object.keys(appearanceReference.color).filter(function (k) {
3033
- return copyHasText(appearanceReference.color[k] && appearanceReference.color[k].stroke);
3034
- }).sort();
3035
- if (strokeKeys.length > 0) {
3036
- appearanceAdvisories.push(
3037
- 'border check requires a render package that captures one; this proof declared no ' +
3038
- 'style channels (an older package) — the design stroke on ' +
3039
- strokeKeys.join(', ') +
3040
- ' was not checked, not passing'
3041
- );
3042
- }
3043
- }
3044
- }
3045
- var sortedIds = renderedIds.slice().sort();
3046
- var rendered = { elementIds: sortedIds };
3047
- if (Object.keys(renderedText).length > 0) rendered.text = renderedText;
3048
- if (Object.keys(renderedColor).length > 0) rendered.color = renderedColor;
3049
- if (Object.keys(renderedTypeface).length > 0) rendered.typeface = renderedTypeface;
3050
- block.appearance = { baseline: appearanceReference.baseline, facts: appearanceFacts, rendered: rendered };
3051
- if (appearanceAdvisories.length > 0) block.appearance.advisories = appearanceAdvisories;
3052
- }
3053
- return block;
3054
- }
3055
-
3056
- function factLabel(fact) {
3057
- if (fact.kind === 'overlap') return fact.nodeIds[0] + '↔' + fact.nodeIds[1];
3058
- if (fact.kind === 'frame_mismatch') return 'frame';
3059
- if (fact.kind === 'duplicate_tag') return fact.tag;
3060
- return fact.nodeId;
3061
- }
3062
-
3063
- function sizeDeltaText(element) {
3064
- var byDim = {};
3065
- for (var i = 0; i < element.deltas.length; i++) byDim[element.deltas[i].dimension] = element.deltas[i].delta;
3066
- function signed(n) { return n > 0 ? '+' + n : '' + n; }
3067
- var w = byDim.w == null ? 0 : byDim.w;
3068
- var h = byDim.h == null ? 0 : byDim.h;
3069
- return 'size ' + signed(w) + '×' + signed(h);
3070
- }
3071
-
3072
- function signedDelta(n) { return n > 0 ? '+' + n : '' + n; }
3073
- function clipRenderedText(overhang) {
3074
- var parts = [];
3075
- for (var i = 0; i < overhang.deltas.length; i++) {
3076
- var edge = overhang.deltas[i].edge;
3077
- parts.push(edge + ' ' + overhang.rendered[edge] + 'dp');
3078
- }
3079
- return parts.join(', ');
3080
- }
3081
- function clipDesignText(overhang) {
3082
- var parts = [];
3083
- for (var i = 0; i < overhang.deltas.length; i++) {
3084
- var edge = overhang.deltas[i].edge;
3085
- parts.push(edge + ' ' + overhang.design[edge] + 'dp');
3086
- }
3087
- return parts.join(', ');
3088
- }
3089
- function clipDeltaText(overhang, element) {
3090
- var parts = [];
3091
- for (var i = 0; i < overhang.deltas.length; i++) {
3092
- var edge = overhang.deltas[i].edge;
3093
- parts.push(edge + ' ' + overhang.rendered[edge] + 'dp vs design ' + overhang.design[edge] + 'dp (' + signedDelta(overhang.deltas[i].delta) + ')');
3094
- }
3095
- var base = 'clip ' + parts.join('; ');
3096
- var causes = [];
3097
- if (overhang.sizeDriven) {
3098
- var sizeParts = [];
3099
- // The scaffold speaks in the judge's numbers: rendered boxes read at whole
3100
- // dp, exactly as the judge quantizes on read (the persisted box carries
3101
- // 2-decimal precision for display). Mirrors the typed judgeDp.
3102
- if (element.design.w != null && Math.round(element.rendered.w) !== element.design.w) sizeParts.push('w ' + Math.round(element.rendered.w) + ' vs ' + element.design.w + 'dp');
3103
- if (element.design.h != null && Math.round(element.rendered.h) !== element.design.h) sizeParts.push('h ' + Math.round(element.rendered.h) + ' vs ' + element.design.h + 'dp');
3104
- var sizeText = sizeParts.join(', ');
3105
- var frameSuffix = overhang.frameRelative ? ' tracking the frame (parent-relative)' : '';
3106
- causes.push(sizeText ? "the element's own size (" + sizeText + ')' + frameSuffix : "the element's own size" + frameSuffix);
3107
- }
3108
- if (overhang.positionDriven) causes.push("the element's position relative to the frame");
3109
- if (causes.length === 0) return base;
3110
- var fix = (overhang.sizeDriven && overhang.positionDriven)
3111
- ? 'reproduce at the fixed design size and reposition to the design offset'
3112
- : overhang.sizeDriven
3113
- ? 'reproduce at the fixed design size'
3114
- : 'reposition to the design offset';
3115
- return base + ' — driven by ' + causes.join(' and ') + '; ' + fix;
3116
- }
3117
-
3118
- function factText(fact) {
3119
- if (fact.kind === 'frame_mismatch') {
3120
- return 'wrapper root ' + fact.captured.w + '×' + fact.captured.h + 'dp differs from the declared ' +
3121
- 'design frame ' + fact.declared.w + '×' + fact.declared.h + 'dp — size the wrapper root to the ' +
3122
- 'frame (placement facts are withheld until it matches)';
3123
- }
3124
- if (fact.kind === 'duplicate_tag') {
3125
- return 'testTag "' + fact.tag + '" is shared by ' + fact.instanceCount + ' nodes — geometry facts for ' +
3126
- 'it are withheld; give each instance a unique testTag (e.g. suffix _0/_1/_2) and re-run';
3127
- }
3128
- if (fact.kind === 'overlap') return 'overlap ' + fact.overlapPx.width + '×' + fact.overlapPx.height + 'px (ratio ' + fact.ratio.toFixed(2) + ')';
3129
- if (fact.kind === 'overflow') return 'overflow past ' + fact.parentId + ' (' + fact.edges.join(',') + ')';
3130
- if (fact.kind === 'clipped_by_parent') return 'clipped by ' + fact.clipperId + ' (' + fact.edges.join(',') + ')';
3131
- if (fact.kind === 'off_screen') return 'off-screen (' + fact.edges.join(',') + ')';
3132
- return fact.kind;
3133
- }
3134
-
3135
- // The run-only verdict obligation: each size-flagged geometry element and each
3136
- // structural fact becomes a row whose verdict the agent MUST fill, plus a
3137
- // mustExplain list — computed on the runner so it rides in the result no matter
3138
- // how the result is read. Mirrors Invarn's buildReviewScaffold.
3139
- function buildReviewScaffold(structural) {
3140
- var facts = structural && Array.isArray(structural.facts) ? structural.facts : [];
3141
- var geometry = structural && structural.geometry ? structural.geometry : null;
3142
- var mustExplain = [];
3143
- var rows = [];
3144
- if (geometry) {
3145
- for (var i = 0; i < geometry.elements.length; i++) {
3146
- var el = geometry.elements[i];
3147
- var flagged = false;
3148
- if (el.sizeFlag) {
3149
- rows.push({
3150
- element: el.id,
3151
- rendered: Math.round(el.rendered.w) + '×' + Math.round(el.rendered.h) + 'dp',
3152
- design: el.design.w + '×' + el.design.h + 'dp',
3153
- fact: sizeDeltaText(el),
3154
- verdict: null,
3155
- });
3156
- flagged = true;
3157
- }
3158
- if (el.overhang && el.overhang.clipFlag) {
3159
- rows.push({
3160
- element: el.id,
3161
- rendered: clipRenderedText(el.overhang),
3162
- design: clipDesignText(el.overhang),
3163
- fact: clipDeltaText(el.overhang, el),
3164
- verdict: null,
3165
- });
3166
- flagged = true;
3167
- }
3168
- if (flagged && mustExplain.indexOf(el.id) === -1) mustExplain.push(el.id);
3169
- }
3170
- }
3171
- for (var f = 0; f < facts.length; f++) {
3172
- var label = factLabel(facts[f]);
3173
- if (mustExplain.indexOf(label) !== -1) continue;
3174
- mustExplain.push(label);
3175
- rows.push({ element: label, rendered: null, design: null, fact: factText(facts[f]), verdict: null });
3176
- }
3177
- return { verdict: null, mustExplain: mustExplain, rows: rows };
3178
- }
3179
-
3180
- // Assemble the inline structural block (Signal A facts + Signal B geometry) onto
3181
- // the screen entry from the same layout dump the facts stage uses. Best-effort:
3182
- // a missing/malformed dump leaves entry.structural unset (graceful degradation).
3183
- function assembleStructural(entry, layoutSourcePath, allReferenceGeometry, density, allReferenceText, allReferenceColor, referenceBaseline, allReferenceFrame, proofKind, allReferenceSpans, allReferenceLines, allReferenceTypeface) {
3184
- var dump;
3185
- try {
3186
- dump = JSON.parse(fs.readFileSync(layoutSourcePath, 'utf-8'));
3187
- } catch (readError) {
3188
- return;
3189
- }
3190
- if (!dump || typeof dump !== 'object' || !dump.canvas || !Array.isArray(dump.nodes)) return;
3191
- var refGeom =
3192
- allReferenceGeometry && typeof allReferenceGeometry === 'object' && !Array.isArray(allReferenceGeometry)
3193
- ? allReferenceGeometry[entry.screen]
3194
- : null;
3195
- // Design-frame anchor: composition runs ASSERT the frame (the door requires
3196
- // it there; a mismatch is a wrapper-authoring fact). A component run must
3197
- // never grow that assertion — its frame instead rides as the ANCHOR PROBE
3198
- // (coverage-gaps issue 01): a root that happens to match the design frame
3199
- // earns the composition-style x/y placement check, a root that does not is
3200
- // untouched.
3201
- var frameEntry =
3202
- allReferenceFrame && typeof allReferenceFrame === 'object' && !Array.isArray(allReferenceFrame)
3203
- ? allReferenceFrame[entry.screen]
3204
- : null;
3205
- var declaredFrame =
3206
- frameEntry && typeof frameEntry === 'object' && !Array.isArray(frameEntry) &&
3207
- typeof frameEntry.w === 'number' && typeof frameEntry.h === 'number'
3208
- ? frameEntry
3209
- : null;
3210
- var refFrame = proofKind === 'composition' ? declaredFrame : null;
3211
- var anchorFrame = proofKind === 'composition' ? null : declaredFrame;
3212
- // Signal C reference (issue 05): the appearance container is assembled when ANY
3213
- // of reference_geometry / reference_text / reference_color is declared for the
3214
- // screen — its element-id set is the UNION of their keys. So a no-Figma run
3215
- // carrying only reference_color (the drift path) still emits color facts,
3216
- // without needing reference_geometry. The run-level reference_baseline marks
3217
- // 'figma' (design divergences, default) or 'previous_render' (regressions/drift).
3218
- var baseline = referenceBaseline === 'previous_render' ? 'previous_render' : 'figma';
3219
- var refText =
3220
- allReferenceText && typeof allReferenceText === 'object' && !Array.isArray(allReferenceText)
3221
- ? allReferenceText[entry.screen]
3222
- : null;
3223
- var refColor =
3224
- allReferenceColor && typeof allReferenceColor === 'object' && !Array.isArray(allReferenceColor)
3225
- ? allReferenceColor[entry.screen]
3226
- : null;
3227
- var refSpans =
3228
- allReferenceSpans && typeof allReferenceSpans === 'object' && !Array.isArray(allReferenceSpans)
3229
- ? allReferenceSpans[entry.screen]
3230
- : null;
3231
- var refLines =
3232
- allReferenceLines && typeof allReferenceLines === 'object' && !Array.isArray(allReferenceLines)
3233
- ? allReferenceLines[entry.screen]
3234
- : null;
3235
- var refTypeface =
3236
- allReferenceTypeface && typeof allReferenceTypeface === 'object' && !Array.isArray(allReferenceTypeface)
3237
- ? allReferenceTypeface[entry.screen]
3238
- : null;
3239
- var appearanceReference = buildAppearanceReferenceFromParams({
3240
- refGeom: refGeom,
3241
- refText: refText,
3242
- refColor: refColor,
3243
- refSpans: refSpans,
3244
- refLines: refLines,
3245
- refTypeface: refTypeface,
3246
- baseline: baseline,
3247
- });
3248
- try {
3249
- entry.structural = buildStructuralBlock(dump, refGeom, density, undefined, appearanceReference, refFrame, anchorFrame);
3250
- // Bake the verdict obligation onto the run result so it survives any read
3251
- // path (waited run or polled build result), not just the Invarn read path.
3252
- entry.structural.review = buildReviewScaffold(entry.structural);
3253
- var sm = entry.structural.summary;
3254
- log(
3255
- 'structural for ' + entry.screen + ': clean=' + sm.clean + ', facts=' + sm.factCount +
3256
- ', mustExplain=' + entry.structural.review.mustExplain.length +
3257
- (sm.maxSizeDeltaDp != null ? ', maxSizeDeltaDp=' + sm.maxSizeDeltaDp : '')
3258
- );
3259
- } catch (assembleError) {
3260
- log('could not assemble structural block for ' + entry.screen + ': ' +
3261
- (assembleError && assembleError.message ? assembleError.message : String(assembleError)));
3262
- }
3263
- }
3264
- `;
3265
- /**
3266
- * Evaluates the embedded facts + geometry stages together (mirroring how the
3267
- * render script concatenates them) and returns the pure `diffKeyGeometry` +
3268
- * `buildStructuralBlock`, so the parity test exercises exactly the shipped code.
3269
- */
3270
- export function getGeometryStageInternals() {
3271
- const factory = new Function(RENDER_SCRIPT_FACTS_STAGE +
3272
- RENDER_SCRIPT_GEOMETRY_STAGE +
3273
- '\nreturn { diffKeyGeometry: diffKeyGeometry, buildStructuralBlock: buildStructuralBlock, buildReviewScaffold: buildReviewScaffold, derivePresenceFacts: derivePresenceFacts, deriveCopyFacts: deriveCopyFacts, deriveSpanFacts: deriveSpanFacts, deriveLineFacts: deriveLineFacts, deriveColorFacts: deriveColorFacts, deriveTypefaceFacts: deriveTypefaceFacts, deriveTypeMetricFacts: deriveTypeMetricFacts, buildAppearanceReferenceFromParams: buildAppearanceReferenceFromParams, ciede2000: ciede2000, hexToLab: hexToLab };');
3274
- return factory();
3275
- }
3276
- //# sourceMappingURL=structural-facts.js.map