@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.
- package/dist/chunk-35SI42CD.mjs +1 -0
- package/dist/chunk-IC45KVLJ.mjs +1 -0
- package/dist/chunk-XDY4V23V.mjs +1 -0
- package/dist/cli.cjs +14 -14
- package/dist/commands.mjs +1 -0
- package/dist/lib.mjs +3934 -0
- package/package.json +6 -10
- package/dist/src/cli.d.ts +0 -3
- package/dist/src/cli.d.ts.map +0 -1
- package/dist/src/cli.js +0 -399
- package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts +0 -38
- package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.d.ts.map +0 -1
- package/dist/src/commands/a-credential-target-follows-the-flavor-source-set.test.js +0 -222
- package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts +0 -41
- package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.d.ts.map +0 -1
- package/dist/src/commands/a-credential-two-workflows-cannot-share-is-declared-per-workflow.test.js +0 -324
- package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts +0 -2
- package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.d.ts.map +0 -1
- package/dist/src/commands/a-properties-stand-in-declares-the-keys-the-build-reads.test.js +0 -382
- package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts +0 -29
- package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.d.ts.map +0 -1
- package/dist/src/commands/an-already-stored-value-does-not-answer-the-target-question.test.js +0 -160
- package/dist/src/commands/android-gms-guard.test.d.ts +0 -9
- package/dist/src/commands/android-gms-guard.test.d.ts.map +0 -1
- package/dist/src/commands/android-gms-guard.test.js +0 -78
- package/dist/src/commands/android-java-version.test.d.ts +0 -12
- package/dist/src/commands/android-java-version.test.d.ts.map +0 -1
- package/dist/src/commands/android-java-version.test.js +0 -209
- package/dist/src/commands/android-scanner-java.test.d.ts +0 -2
- package/dist/src/commands/android-scanner-java.test.d.ts.map +0 -1
- package/dist/src/commands/android-scanner-java.test.js +0 -130
- package/dist/src/commands/android-scanner.d.ts +0 -250
- package/dist/src/commands/android-scanner.d.ts.map +0 -1
- package/dist/src/commands/android-scanner.js +0 -1639
- package/dist/src/commands/build.d.ts +0 -215
- package/dist/src/commands/build.d.ts.map +0 -1
- package/dist/src/commands/build.js +0 -1433
- package/dist/src/commands/build.test.d.ts +0 -13
- package/dist/src/commands/build.test.d.ts.map +0 -1
- package/dist/src/commands/build.test.js +0 -121
- package/dist/src/commands/detect-platform.d.ts +0 -13
- package/dist/src/commands/detect-platform.d.ts.map +0 -1
- package/dist/src/commands/detect-platform.js +0 -51
- package/dist/src/commands/detect-project.d.ts +0 -7
- package/dist/src/commands/detect-project.d.ts.map +0 -1
- package/dist/src/commands/detect-project.js +0 -12
- package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts +0 -26
- package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.d.ts.map +0 -1
- package/dist/src/commands/each-flavor-is-asked-for-its-own-properties-file.test.js +0 -196
- package/dist/src/commands/edit.d.ts +0 -12
- package/dist/src/commands/edit.d.ts.map +0 -1
- package/dist/src/commands/edit.js +0 -652
- package/dist/src/commands/edit.test.d.ts +0 -6
- package/dist/src/commands/edit.test.d.ts.map +0 -1
- package/dist/src/commands/edit.test.js +0 -45
- package/dist/src/commands/fidelity-trim.d.ts +0 -13
- package/dist/src/commands/fidelity-trim.d.ts.map +0 -1
- package/dist/src/commands/fidelity-trim.js +0 -21
- package/dist/src/commands/fidelity-trim.test.d.ts +0 -8
- package/dist/src/commands/fidelity-trim.test.d.ts.map +0 -1
- package/dist/src/commands/fidelity-trim.test.js +0 -79
- package/dist/src/commands/file-secret-collector.d.ts +0 -50
- package/dist/src/commands/file-secret-collector.d.ts.map +0 -1
- package/dist/src/commands/file-secret-collector.js +0 -217
- package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts +0 -38
- package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.d.ts.map +0 -1
- package/dist/src/commands/flavors-a-convention-plugin-creates-are-still-found.test.js +0 -300
- package/dist/src/commands/github-workflow.d.ts +0 -5
- package/dist/src/commands/github-workflow.d.ts.map +0 -1
- package/dist/src/commands/github-workflow.js +0 -45
- package/dist/src/commands/index.d.ts +0 -33
- package/dist/src/commands/index.d.ts.map +0 -1
- package/dist/src/commands/index.js +0 -33
- package/dist/src/commands/init.d.ts +0 -20
- package/dist/src/commands/init.d.ts.map +0 -1
- package/dist/src/commands/init.js +0 -264
- package/dist/src/commands/ios-scanner.d.ts +0 -104
- package/dist/src/commands/ios-scanner.d.ts.map +0 -1
- package/dist/src/commands/ios-scanner.js +0 -492
- package/dist/src/commands/ios-scheme-ranking.test.d.ts +0 -2
- package/dist/src/commands/ios-scheme-ranking.test.d.ts.map +0 -1
- package/dist/src/commands/ios-scheme-ranking.test.js +0 -264
- package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts +0 -30
- package/dist/src/commands/local-properties-reaches-every-workflow.test.d.ts.map +0 -1
- package/dist/src/commands/local-properties-reaches-every-workflow.test.js +0 -146
- package/dist/src/commands/reset.d.ts +0 -7
- package/dist/src/commands/reset.d.ts.map +0 -1
- package/dist/src/commands/reset.js +0 -81
- package/dist/src/commands/run.d.ts +0 -22
- package/dist/src/commands/run.d.ts.map +0 -1
- package/dist/src/commands/run.js +0 -141
- package/dist/src/commands/secrets-sync-workflow.d.ts +0 -15
- package/dist/src/commands/secrets-sync-workflow.d.ts.map +0 -1
- package/dist/src/commands/secrets-sync-workflow.js +0 -255
- package/dist/src/commands/secrets-upload.d.ts +0 -21
- package/dist/src/commands/secrets-upload.d.ts.map +0 -1
- package/dist/src/commands/secrets-upload.js +0 -177
- package/dist/src/commands/secrets-upload.test.d.ts +0 -5
- package/dist/src/commands/secrets-upload.test.d.ts.map +0 -1
- package/dist/src/commands/secrets-upload.test.js +0 -60
- package/dist/src/commands/validate.d.ts +0 -10
- package/dist/src/commands/validate.d.ts.map +0 -1
- package/dist/src/commands/validate.js +0 -46
- package/dist/src/config.d.ts +0 -3
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js +0 -47
- package/dist/src/envman/cli.d.ts +0 -21
- package/dist/src/envman/cli.d.ts.map +0 -1
- package/dist/src/envman/cli.js +0 -240
- package/dist/src/envman/envman.d.ts +0 -83
- package/dist/src/envman/envman.d.ts.map +0 -1
- package/dist/src/envman/envman.js +0 -361
- package/dist/src/envman/envman.test.d.ts +0 -5
- package/dist/src/envman/envman.test.d.ts.map +0 -1
- package/dist/src/envman/envman.test.js +0 -236
- package/dist/src/envman/envstore-round-trip.test.d.ts +0 -27
- package/dist/src/envman/envstore-round-trip.test.d.ts.map +0 -1
- package/dist/src/envman/envstore-round-trip.test.js +0 -130
- package/dist/src/envman/index.d.ts +0 -23
- package/dist/src/envman/index.d.ts.map +0 -1
- package/dist/src/envman/index.js +0 -23
- package/dist/src/envman/shim.d.ts +0 -16
- package/dist/src/envman/shim.d.ts.map +0 -1
- package/dist/src/envman/shim.js +0 -66
- package/dist/src/envman/types.d.ts +0 -55
- package/dist/src/envman/types.d.ts.map +0 -1
- package/dist/src/envman/types.js +0 -12
- package/dist/src/lib.d.ts +0 -33
- package/dist/src/lib.d.ts.map +0 -1
- package/dist/src/lib.js +0 -37
- package/dist/src/pipeline.d.ts +0 -3
- package/dist/src/pipeline.d.ts.map +0 -1
- package/dist/src/pipeline.js +0 -57
- package/dist/src/runner-node-step.test.d.ts +0 -12
- package/dist/src/runner-node-step.test.d.ts.map +0 -1
- package/dist/src/runner-node-step.test.js +0 -61
- package/dist/src/runner.d.ts +0 -31
- package/dist/src/runner.d.ts.map +0 -1
- package/dist/src/runner.js +0 -240
- package/dist/src/shared/detect-project.d.ts +0 -57
- package/dist/src/shared/detect-project.d.ts.map +0 -1
- package/dist/src/shared/detect-project.js +0 -219
- package/dist/src/shared/detect-project.test.d.ts +0 -2
- package/dist/src/shared/detect-project.test.d.ts.map +0 -1
- package/dist/src/shared/detect-project.test.js +0 -161
- package/dist/src/shared/git.d.ts +0 -25
- package/dist/src/shared/git.d.ts.map +0 -1
- package/dist/src/shared/git.js +0 -53
- package/dist/src/shared/git.test.d.ts +0 -9
- package/dist/src/shared/git.test.d.ts.map +0 -1
- package/dist/src/shared/git.test.js +0 -66
- package/dist/src/shared/gitignore.d.ts +0 -6
- package/dist/src/shared/gitignore.d.ts.map +0 -1
- package/dist/src/shared/gitignore.js +0 -26
- package/dist/src/shared/gradle-settings.d.ts +0 -49
- package/dist/src/shared/gradle-settings.d.ts.map +0 -1
- package/dist/src/shared/gradle-settings.js +0 -138
- package/dist/src/shared/gradle-settings.test.d.ts +0 -2
- package/dist/src/shared/gradle-settings.test.d.ts.map +0 -1
- package/dist/src/shared/gradle-settings.test.js +0 -65
- package/dist/src/shared/prompts.d.ts +0 -30
- package/dist/src/shared/prompts.d.ts.map +0 -1
- package/dist/src/shared/prompts.js +0 -121
- package/dist/src/shared/xcode-container.test.d.ts +0 -2
- package/dist/src/shared/xcode-container.test.d.ts.map +0 -1
- package/dist/src/shared/xcode-container.test.js +0 -123
- package/dist/src/step-marker.d.ts +0 -12
- package/dist/src/step-marker.d.ts.map +0 -1
- package/dist/src/step-marker.js +0 -29
- package/dist/src/step-marker.test.d.ts +0 -11
- package/dist/src/step-marker.test.d.ts.map +0 -1
- package/dist/src/step-marker.test.js +0 -45
- package/dist/src/types.d.ts +0 -58
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/types.js +0 -2
- package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts +0 -28
- package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.d.ts.map +0 -1
- package/dist/src/yaml/a-cache-path-must-not-swallow-a-credential.test.js +0 -223
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts +0 -20
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.d.ts.map +0 -1
- package/dist/src/yaml/a-declared-secret-reaches-the-file-step.test.js +0 -201
- package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts +0 -37
- package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.d.ts.map +0 -1
- package/dist/src/yaml/a-declared-slot-may-name-the-workflow-it-belongs-to.test.js +0 -255
- package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts +0 -32
- package/dist/src/yaml/a-missing-credential-is-loud.test.d.ts.map +0 -1
- package/dist/src/yaml/a-missing-credential-is-loud.test.js +0 -301
- package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.d.ts +0 -18
- package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.d.ts.map +0 -1
- package/dist/src/yaml/a-runtime-requirement-names-a-real-provider.test.js +0 -66
- package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts +0 -34
- package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.d.ts.map +0 -1
- package/dist/src/yaml/a-scoped-slot-must-name-a-workflow-the-pipeline-has.test.js +0 -218
- package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts +0 -27
- package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.d.ts.map +0 -1
- package/dist/src/yaml/a-value-this-instance-stored-is-a-value-it-has.test.js +0 -126
- package/dist/src/yaml/bitrise-compat.d.ts +0 -65
- package/dist/src/yaml/bitrise-compat.d.ts.map +0 -1
- package/dist/src/yaml/bitrise-compat.js +0 -206
- package/dist/src/yaml/bitrise-compat.test.d.ts +0 -5
- package/dist/src/yaml/bitrise-compat.test.d.ts.map +0 -1
- package/dist/src/yaml/bitrise-compat.test.js +0 -347
- package/dist/src/yaml/cache-key-missing-file.test.d.ts +0 -12
- package/dist/src/yaml/cache-key-missing-file.test.d.ts.map +0 -1
- package/dist/src/yaml/cache-key-missing-file.test.js +0 -116
- package/dist/src/yaml/converter.d.ts +0 -103
- package/dist/src/yaml/converter.d.ts.map +0 -1
- package/dist/src/yaml/converter.js +0 -420
- package/dist/src/yaml/converter.test.d.ts +0 -5
- package/dist/src/yaml/converter.test.d.ts.map +0 -1
- package/dist/src/yaml/converter.test.js +0 -548
- package/dist/src/yaml/e2e.test.d.ts +0 -6
- package/dist/src/yaml/e2e.test.d.ts.map +0 -1
- package/dist/src/yaml/e2e.test.js +0 -446
- package/dist/src/yaml/env-resolver.d.ts +0 -218
- package/dist/src/yaml/env-resolver.d.ts.map +0 -1
- package/dist/src/yaml/env-resolver.js +0 -591
- package/dist/src/yaml/env-resolver.test.d.ts +0 -5
- package/dist/src/yaml/env-resolver.test.d.ts.map +0 -1
- package/dist/src/yaml/env-resolver.test.js +0 -502
- package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.d.ts +0 -16
- package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.d.ts.map +0 -1
- package/dist/src/yaml/every-build-log-witnesses-the-commit-on-disk.test.js +0 -126
- package/dist/src/yaml/fidelity-input-digest.d.ts +0 -37
- package/dist/src/yaml/fidelity-input-digest.d.ts.map +0 -1
- package/dist/src/yaml/fidelity-input-digest.js +0 -208
- package/dist/src/yaml/fidelity-input-digest.test.d.ts +0 -2
- package/dist/src/yaml/fidelity-input-digest.test.d.ts.map +0 -1
- package/dist/src/yaml/fidelity-input-digest.test.js +0 -167
- package/dist/src/yaml/fidelity-manifest.d.ts +0 -126
- package/dist/src/yaml/fidelity-manifest.d.ts.map +0 -1
- package/dist/src/yaml/fidelity-manifest.js +0 -134
- package/dist/src/yaml/fidelity-manifest.test.d.ts +0 -12
- package/dist/src/yaml/fidelity-manifest.test.d.ts.map +0 -1
- package/dist/src/yaml/fidelity-manifest.test.js +0 -251
- package/dist/src/yaml/interactive-prompts.d.ts +0 -71
- package/dist/src/yaml/interactive-prompts.d.ts.map +0 -1
- package/dist/src/yaml/interactive-prompts.js +0 -260
- package/dist/src/yaml/meta-helpers.d.ts +0 -28
- package/dist/src/yaml/meta-helpers.d.ts.map +0 -1
- package/dist/src/yaml/meta-helpers.js +0 -46
- package/dist/src/yaml/missing-env-handler.d.ts +0 -45
- package/dist/src/yaml/missing-env-handler.d.ts.map +0 -1
- package/dist/src/yaml/missing-env-handler.js +0 -64
- package/dist/src/yaml/parser.d.ts +0 -33
- package/dist/src/yaml/parser.d.ts.map +0 -1
- package/dist/src/yaml/parser.js +0 -148
- package/dist/src/yaml/parser.test.d.ts +0 -5
- package/dist/src/yaml/parser.test.d.ts.map +0 -1
- package/dist/src/yaml/parser.test.js +0 -83
- package/dist/src/yaml/pipeline-with-secrets.d.ts +0 -25
- package/dist/src/yaml/pipeline-with-secrets.d.ts.map +0 -1
- package/dist/src/yaml/pipeline-with-secrets.js +0 -76
- package/dist/src/yaml/platform-detector.d.ts +0 -83
- package/dist/src/yaml/platform-detector.d.ts.map +0 -1
- package/dist/src/yaml/platform-detector.js +0 -189
- package/dist/src/yaml/platform-detector.test.d.ts +0 -5
- package/dist/src/yaml/platform-detector.test.d.ts.map +0 -1
- package/dist/src/yaml/platform-detector.test.js +0 -525
- package/dist/src/yaml/preflight-validation.d.ts +0 -29
- package/dist/src/yaml/preflight-validation.d.ts.map +0 -1
- package/dist/src/yaml/preflight-validation.js +0 -21
- package/dist/src/yaml/secrets-manager.d.ts +0 -87
- package/dist/src/yaml/secrets-manager.d.ts.map +0 -1
- package/dist/src/yaml/secrets-manager.js +0 -230
- package/dist/src/yaml/shell-assignments.d.ts +0 -40
- package/dist/src/yaml/shell-assignments.d.ts.map +0 -1
- package/dist/src/yaml/shell-assignments.js +0 -77
- package/dist/src/yaml/step-validator-check-failure.test.d.ts +0 -24
- package/dist/src/yaml/step-validator-check-failure.test.d.ts.map +0 -1
- package/dist/src/yaml/step-validator-check-failure.test.js +0 -130
- package/dist/src/yaml/step-validator-self-assigned.test.d.ts +0 -23
- package/dist/src/yaml/step-validator-self-assigned.test.d.ts.map +0 -1
- package/dist/src/yaml/step-validator-self-assigned.test.js +0 -191
- package/dist/src/yaml/step-validator.d.ts +0 -67
- package/dist/src/yaml/step-validator.d.ts.map +0 -1
- package/dist/src/yaml/step-validator.js +0 -466
- package/dist/src/yaml/step-validator.test.d.ts +0 -18
- package/dist/src/yaml/step-validator.test.d.ts.map +0 -1
- package/dist/src/yaml/step-validator.test.js +0 -140
- package/dist/src/yaml/steps/android-sign.d.ts +0 -35
- package/dist/src/yaml/steps/android-sign.d.ts.map +0 -1
- package/dist/src/yaml/steps/android-sign.js +0 -147
- package/dist/src/yaml/steps/android-variant-resolution.test.d.ts +0 -2
- package/dist/src/yaml/steps/android-variant-resolution.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/android-variant-resolution.test.js +0 -260
- package/dist/src/yaml/steps/android-version.d.ts +0 -26
- package/dist/src/yaml/steps/android-version.d.ts.map +0 -1
- package/dist/src/yaml/steps/android-version.js +0 -128
- package/dist/src/yaml/steps/android-version.test.d.ts +0 -5
- package/dist/src/yaml/steps/android-version.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/android-version.test.js +0 -196
- package/dist/src/yaml/steps/android.d.ts +0 -156
- package/dist/src/yaml/steps/android.d.ts.map +0 -1
- package/dist/src/yaml/steps/android.js +0 -1092
- package/dist/src/yaml/steps/app-store-deploy.d.ts +0 -48
- package/dist/src/yaml/steps/app-store-deploy.d.ts.map +0 -1
- package/dist/src/yaml/steps/app-store-deploy.js +0 -167
- package/dist/src/yaml/steps/base.d.ts +0 -263
- package/dist/src/yaml/steps/base.d.ts.map +0 -1
- package/dist/src/yaml/steps/base.js +0 -380
- package/dist/src/yaml/steps/bitrise-android-tools.d.ts +0 -26
- package/dist/src/yaml/steps/bitrise-android-tools.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-android-tools.js +0 -200
- package/dist/src/yaml/steps/bitrise-android-tools.test.d.ts +0 -5
- package/dist/src/yaml/steps/bitrise-android-tools.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-android-tools.test.js +0 -280
- package/dist/src/yaml/steps/bitrise-apk-info.d.ts +0 -22
- package/dist/src/yaml/steps/bitrise-apk-info.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-apk-info.js +0 -144
- package/dist/src/yaml/steps/bitrise-apk-info.test.d.ts +0 -5
- package/dist/src/yaml/steps/bitrise-apk-info.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-apk-info.test.js +0 -331
- package/dist/src/yaml/steps/bitrise-slack.d.ts +0 -49
- package/dist/src/yaml/steps/bitrise-slack.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-slack.js +0 -280
- package/dist/src/yaml/steps/bitrise-slack.test.d.ts +0 -5
- package/dist/src/yaml/steps/bitrise-slack.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-slack.test.js +0 -484
- package/dist/src/yaml/steps/bitrise-ssh.d.ts +0 -27
- package/dist/src/yaml/steps/bitrise-ssh.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-ssh.js +0 -134
- package/dist/src/yaml/steps/bitrise-ssh.test.d.ts +0 -5
- package/dist/src/yaml/steps/bitrise-ssh.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/bitrise-ssh.test.js +0 -205
- package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts +0 -27
- package/dist/src/yaml/steps/cache-pull-daemon.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cache-pull-daemon.test.js +0 -212
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts +0 -42
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cache-pull-restore-roots.test.js +0 -209
- package/dist/src/yaml/steps/cache-push-daemon.test.d.ts +0 -27
- package/dist/src/yaml/steps/cache-push-daemon.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cache-push-daemon.test.js +0 -171
- package/dist/src/yaml/steps/cache.d.ts +0 -59
- package/dist/src/yaml/steps/cache.d.ts.map +0 -1
- package/dist/src/yaml/steps/cache.js +0 -1027
- package/dist/src/yaml/steps/capture-injection.d.ts +0 -30
- package/dist/src/yaml/steps/capture-injection.d.ts.map +0 -1
- package/dist/src/yaml/steps/capture-injection.js +0 -88
- package/dist/src/yaml/steps/capture-injection.test.d.ts +0 -2
- package/dist/src/yaml/steps/capture-injection.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/capture-injection.test.js +0 -163
- package/dist/src/yaml/steps/cocoapods-deployment-floor.d.ts +0 -42
- package/dist/src/yaml/steps/cocoapods-deployment-floor.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-deployment-floor.js +0 -140
- package/dist/src/yaml/steps/cocoapods-deployment-floor.test.d.ts +0 -11
- package/dist/src/yaml/steps/cocoapods-deployment-floor.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-deployment-floor.test.js +0 -176
- package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts +0 -22
- package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-install-emits-valid-bash.test.js +0 -97
- package/dist/src/yaml/steps/cocoapods-locked-bundler.test.d.ts +0 -17
- package/dist/src/yaml/steps/cocoapods-locked-bundler.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-locked-bundler.test.js +0 -282
- package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts +0 -17
- package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/cocoapods-skip-if-absent.test.js +0 -132
- package/dist/src/yaml/steps/envstore-loader.test.d.ts +0 -15
- package/dist/src/yaml/steps/envstore-loader.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/envstore-loader.test.js +0 -143
- package/dist/src/yaml/steps/fastlane.d.ts +0 -27
- package/dist/src/yaml/steps/fastlane.d.ts.map +0 -1
- package/dist/src/yaml/steps/fastlane.js +0 -79
- package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts +0 -53
- package/dist/src/yaml/steps/fidelity-aspect-guard.d.ts.map +0 -1
- package/dist/src/yaml/steps/fidelity-aspect-guard.js +0 -72
- package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts +0 -11
- package/dist/src/yaml/steps/fidelity-aspect-guard.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/fidelity-aspect-guard.test.js +0 -50
- package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts +0 -87
- package/dist/src/yaml/steps/fidelity-reference-provenance.d.ts.map +0 -1
- package/dist/src/yaml/steps/fidelity-reference-provenance.js +0 -111
- package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts +0 -22
- package/dist/src/yaml/steps/fidelity-reference-provenance.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/fidelity-reference-provenance.test.js +0 -118
- package/dist/src/yaml/steps/file.d.ts +0 -52
- package/dist/src/yaml/steps/file.d.ts.map +0 -1
- package/dist/src/yaml/steps/file.js +0 -108
- package/dist/src/yaml/steps/flutter.d.ts +0 -63
- package/dist/src/yaml/steps/flutter.d.ts.map +0 -1
- package/dist/src/yaml/steps/flutter.js +0 -215
- package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.d.ts +0 -16
- package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-clone-names-the-inputs-it-ignores.test.js +0 -127
- package/dist/src/yaml/steps/git-clone.d.ts +0 -32
- package/dist/src/yaml/steps/git-clone.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-clone.js +0 -171
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts +0 -10
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-fetch-retry-exec.test.js +0 -162
- package/dist/src/yaml/steps/git-fetch-retry.d.ts +0 -62
- package/dist/src/yaml/steps/git-fetch-retry.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-fetch-retry.js +0 -155
- package/dist/src/yaml/steps/git-fetch-retry.test.d.ts +0 -5
- package/dist/src/yaml/steps/git-fetch-retry.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/git-fetch-retry.test.js +0 -86
- package/dist/src/yaml/steps/google-play-deploy.d.ts +0 -37
- package/dist/src/yaml/steps/google-play-deploy.d.ts.map +0 -1
- package/dist/src/yaml/steps/google-play-deploy.js +0 -193
- package/dist/src/yaml/steps/google-play-deploy.test.d.ts +0 -5
- package/dist/src/yaml/steps/google-play-deploy.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/google-play-deploy.test.js +0 -310
- package/dist/src/yaml/steps/gradle-memory.d.ts +0 -61
- package/dist/src/yaml/steps/gradle-memory.d.ts.map +0 -1
- package/dist/src/yaml/steps/gradle-memory.js +0 -386
- package/dist/src/yaml/steps/gradle-memory.test.d.ts +0 -2
- package/dist/src/yaml/steps/gradle-memory.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/gradle-memory.test.js +0 -344
- package/dist/src/yaml/steps/gradle-sdk-cmake.d.ts +0 -28
- package/dist/src/yaml/steps/gradle-sdk-cmake.d.ts.map +0 -1
- package/dist/src/yaml/steps/gradle-sdk-cmake.js +0 -107
- package/dist/src/yaml/steps/gradle-sdk-cmake.test.d.ts +0 -2
- package/dist/src/yaml/steps/gradle-sdk-cmake.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/gradle-sdk-cmake.test.js +0 -183
- package/dist/src/yaml/steps/index.d.ts +0 -10
- package/dist/src/yaml/steps/index.d.ts.map +0 -1
- package/dist/src/yaml/steps/index.js +0 -1461
- package/dist/src/yaml/steps/ios-deps.d.ts +0 -78
- package/dist/src/yaml/steps/ios-deps.d.ts.map +0 -1
- package/dist/src/yaml/steps/ios-deps.js +0 -279
- package/dist/src/yaml/steps/ios-deps.test.d.ts +0 -5
- package/dist/src/yaml/steps/ios-deps.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ios-deps.test.js +0 -90
- package/dist/src/yaml/steps/ios-signing.d.ts +0 -31
- package/dist/src/yaml/steps/ios-signing.d.ts.map +0 -1
- package/dist/src/yaml/steps/ios-signing.js +0 -144
- package/dist/src/yaml/steps/ios-version.d.ts +0 -47
- package/dist/src/yaml/steps/ios-version.d.ts.map +0 -1
- package/dist/src/yaml/steps/ios-version.js +0 -151
- package/dist/src/yaml/steps/java-home-version-spec.test.d.ts +0 -10
- package/dist/src/yaml/steps/java-home-version-spec.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/java-home-version-spec.test.js +0 -58
- package/dist/src/yaml/steps/linting.d.ts +0 -47
- package/dist/src/yaml/steps/linting.d.ts.map +0 -1
- package/dist/src/yaml/steps/linting.js +0 -148
- package/dist/src/yaml/steps/phase2.test.d.ts +0 -6
- package/dist/src/yaml/steps/phase2.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/phase2.test.js +0 -197
- package/dist/src/yaml/steps/phase3.test.d.ts +0 -5
- package/dist/src/yaml/steps/phase3.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/phase3.test.js +0 -144
- package/dist/src/yaml/steps/phase4.test.d.ts +0 -5
- package/dist/src/yaml/steps/phase4.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/phase4.test.js +0 -166
- package/dist/src/yaml/steps/phase5.test.d.ts +0 -6
- package/dist/src/yaml/steps/phase5.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/phase5.test.js +0 -263
- package/dist/src/yaml/steps/registry.d.ts +0 -88
- package/dist/src/yaml/steps/registry.d.ts.map +0 -1
- package/dist/src/yaml/steps/registry.js +0 -125
- package/dist/src/yaml/steps/registry.test.d.ts +0 -5
- package/dist/src/yaml/steps/registry.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/registry.test.js +0 -235
- package/dist/src/yaml/steps/release.d.ts +0 -50
- package/dist/src/yaml/steps/release.d.ts.map +0 -1
- package/dist/src/yaml/steps/release.js +0 -154
- package/dist/src/yaml/steps/render-post-processor.d.ts +0 -100
- package/dist/src/yaml/steps/render-post-processor.d.ts.map +0 -1
- package/dist/src/yaml/steps/render-post-processor.js +0 -270
- package/dist/src/yaml/steps/render-post-processor.test.d.ts +0 -13
- package/dist/src/yaml/steps/render-post-processor.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/render-post-processor.test.js +0 -220
- package/dist/src/yaml/steps/script.d.ts +0 -23
- package/dist/src/yaml/steps/script.d.ts.map +0 -1
- package/dist/src/yaml/steps/script.js +0 -63
- package/dist/src/yaml/steps/spec-validation.test.d.ts +0 -6
- package/dist/src/yaml/steps/spec-validation.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/spec-validation.test.js +0 -130
- package/dist/src/yaml/steps/steps.test.d.ts +0 -6
- package/dist/src/yaml/steps/steps.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/steps.test.js +0 -881
- package/dist/src/yaml/steps/structural-facts.d.ts +0 -992
- package/dist/src/yaml/steps/structural-facts.d.ts.map +0 -1
- package/dist/src/yaml/steps/structural-facts.js +0 -3276
- package/dist/src/yaml/steps/structural-facts.test.d.ts +0 -12
- package/dist/src/yaml/steps/structural-facts.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/structural-facts.test.js +0 -3039
- package/dist/src/yaml/steps/test-config.d.ts +0 -5
- package/dist/src/yaml/steps/test-config.d.ts.map +0 -1
- package/dist/src/yaml/steps/test-config.js +0 -25
- package/dist/src/yaml/steps/trim-align-corpus.test.d.ts +0 -28
- package/dist/src/yaml/steps/trim-align-corpus.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/trim-align-corpus.test.js +0 -383
- package/dist/src/yaml/steps/trim-align.d.ts +0 -87
- package/dist/src/yaml/steps/trim-align.d.ts.map +0 -1
- package/dist/src/yaml/steps/trim-align.js +0 -367
- package/dist/src/yaml/steps/trim-align.test.d.ts +0 -11
- package/dist/src/yaml/steps/trim-align.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/trim-align.test.js +0 -261
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts +0 -66
- package/dist/src/yaml/steps/ui-fidelity-preview-android.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-preview-android.js +0 -251
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts +0 -13
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-preview-android.test.js +0 -302
- package/dist/src/yaml/steps/ui-fidelity-preview.d.ts +0 -134
- package/dist/src/yaml/steps/ui-fidelity-preview.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-preview.js +0 -173
- package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts +0 -11
- package/dist/src/yaml/steps/ui-fidelity-preview.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-preview.test.js +0 -210
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts +0 -155
- package/dist/src/yaml/steps/ui-fidelity-render-android.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.js +0 -1389
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts +0 -2
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-render-android.test.js +0 -1106
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts +0 -230
- package/dist/src/yaml/steps/ui-fidelity-render.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-render.js +0 -1597
- package/dist/src/yaml/steps/ui-fidelity-render.test.d.ts +0 -18
- package/dist/src/yaml/steps/ui-fidelity-render.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/ui-fidelity-render.test.js +0 -1871
- package/dist/src/yaml/steps/xcode-app-product.d.ts +0 -90
- package/dist/src/yaml/steps/xcode-app-product.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-app-product.js +0 -247
- package/dist/src/yaml/steps/xcode-app-product.test.d.ts +0 -2
- package/dist/src/yaml/steps/xcode-app-product.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-app-product.test.js +0 -202
- package/dist/src/yaml/steps/xcode-derived-data.test.d.ts +0 -30
- package/dist/src/yaml/steps/xcode-derived-data.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-derived-data.test.js +0 -279
- package/dist/src/yaml/steps/xcode-destination.d.ts +0 -47
- package/dist/src/yaml/steps/xcode-destination.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-destination.js +0 -202
- package/dist/src/yaml/steps/xcode-destination.test.d.ts +0 -9
- package/dist/src/yaml/steps/xcode-destination.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-destination.test.js +0 -227
- package/dist/src/yaml/steps/xcode-new.test.d.ts +0 -5
- package/dist/src/yaml/steps/xcode-new.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-new.test.js +0 -211
- package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts +0 -9
- package/dist/src/yaml/steps/xcode-resolve-retry.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-resolve-retry.test.js +0 -146
- package/dist/src/yaml/steps/xcode-simulator-archs.d.ts +0 -53
- package/dist/src/yaml/steps/xcode-simulator-archs.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-simulator-archs.js +0 -98
- package/dist/src/yaml/steps/xcode-simulator-archs.test.d.ts +0 -21
- package/dist/src/yaml/steps/xcode-simulator-archs.test.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode-simulator-archs.test.js +0 -155
- package/dist/src/yaml/steps/xcode.d.ts +0 -230
- package/dist/src/yaml/steps/xcode.d.ts.map +0 -1
- package/dist/src/yaml/steps/xcode.js +0 -1350
- package/dist/src/yaml/types.d.ts +0 -76
- package/dist/src/yaml/types.d.ts.map +0 -1
- package/dist/src/yaml/types.js +0 -5
- package/dist/src/yaml/validation-types.d.ts +0 -102
- package/dist/src/yaml/validation-types.d.ts.map +0 -1
- package/dist/src/yaml/validation-types.js +0 -8
- package/dist/src/yaml/yaml-updater.d.ts +0 -24
- package/dist/src/yaml/yaml-updater.d.ts.map +0 -1
- 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
|