@vfrey/vfrey 0.0.1 → 1.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +33 -0
- package/README.md +78 -3
- package/accounts.example.json +61 -0
- package/accounts.first-run.json +41 -0
- package/bin/vfrey.mjs +36 -0
- package/integration-tools/README.md +43 -0
- package/integration-tools/image/pillow.md +32 -0
- package/integration-tools/image/playwright.md +33 -0
- package/integration-tools/image/resvg.md +36 -0
- package/integration-tools/image/satori.md +36 -0
- package/integration-tools/image/sharp.md +38 -0
- package/integration-tools/video/blender.md +34 -0
- package/integration-tools/video/ffmpeg.md +41 -0
- package/integration-tools/video/manim.md +37 -0
- package/integration-tools/video/motion-canvas.md +37 -0
- package/integration-tools/video/remotion.md +50 -0
- package/integration-tools/video/revideo.md +32 -0
- package/package.json +74 -6
- package/skills/vfrey-orchestrator/SKILL.md +15 -0
- package/skills/vfrey-orchestrator/channel-video/SKILL.md +41 -0
- package/skills/vfrey-product/SKILL.md +63 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/framey/cook-one.svg +14 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/framey/cook-two.svg +22 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/framey/index.html +70 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/remotion/cook-one.svg +14 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/remotion/cook-two.svg +22 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/remotion/index.tsx +49 -0
- package/skills/vfrey-product/examples/concurrency-vs-parallelism/script.json +15 -0
- package/skills/vfrey-product/examples/octopus-facts/index.html +60 -0
- package/skills/vfrey-product/examples/octopus-facts/octopus.svg +8 -0
- package/skills/vfrey-product/examples/octopus-facts/script.json +14 -0
- package/skills/vfrey-product/push/SKILL.md +15 -0
- package/skills/vfrey-product/scenes/NOTICE +5 -0
- package/skills/vfrey-product/scenes/blocks.md +57 -0
- package/skills/vfrey-product/scenes/craft.md +118 -0
- package/skills/vfrey-product/scenes/motion.md +88 -0
- package/src/analytics/dashboard.ts +109 -0
- package/src/analytics/index.ts +260 -0
- package/src/api/client.ts +282 -0
- package/src/api/commands.ts +330 -0
- package/src/api/config.ts +34 -0
- package/src/api/creators.ts +463 -0
- package/src/api/credentials.ts +152 -0
- package/src/api/mail.ts +333 -0
- package/src/api/topics.ts +207 -0
- package/src/api/types.ts +205 -0
- package/src/cli.ts +467 -0
- package/src/cloud/executor.ts +281 -0
- package/src/cloud/job.ts +106 -0
- package/src/cloud/render-job.ts +187 -0
- package/src/config.ts +309 -0
- package/src/dashboard-link.ts +180 -0
- package/src/dashboard-start.ts +103 -0
- package/src/desk/channel.ts +64 -0
- package/src/desk/cli.ts +216 -0
- package/src/desk/creators-route.ts +129 -0
- package/src/desk/fonts/JetBrainsMono-OFL.txt +93 -0
- package/src/desk/fonts/google-sans-flex-latin.woff2 +0 -0
- package/src/desk/fonts/jetbrains-mono-medium.woff2 +0 -0
- package/src/desk/http.ts +460 -0
- package/src/desk/mail-route.ts +103 -0
- package/src/desk/overlay.ts +112 -0
- package/src/desk/preview.ts +422 -0
- package/src/desk/screens.ts +149 -0
- package/src/desk/secrets.ts +143 -0
- package/src/desk/series.ts +127 -0
- package/src/desk/state.ts +77 -0
- package/src/desk/studio-route.ts +119 -0
- package/src/desk/tick-runner.ts +66 -0
- package/src/desk/tick.ts +56 -0
- package/src/desk/topics-route.ts +41 -0
- package/src/desk/ui/app.css +505 -0
- package/src/desk/ui/app.js +185 -0
- package/src/desk/ui/apple-icon.png +0 -0
- package/src/desk/ui/components.js +79 -0
- package/src/desk/ui/core.js +156 -0
- package/src/desk/ui/favicon.ico +0 -0
- package/src/desk/ui/icon.svg +1 -0
- package/src/desk/ui/icons.js +49 -0
- package/src/desk/ui/index.html +41 -0
- package/src/desk/ui/metrics-math.d.ts +22 -0
- package/src/desk/ui/metrics-math.js +135 -0
- package/src/desk/ui/theme.js +5 -0
- package/src/desk/ui/views-creators.js +209 -0
- package/src/desk/ui/views-keys.js +114 -0
- package/src/desk/ui/views-mail.js +188 -0
- package/src/desk/ui/views-misc.js +123 -0
- package/src/desk/ui/views-review.js +119 -0
- package/src/desk/ui/views-runs.js +350 -0
- package/src/desk/ui/views-screens.js +163 -0
- package/src/desk/ui/views-series.js +318 -0
- package/src/desk/ui/views-topics.js +87 -0
- package/src/desk/verbs.ts +361 -0
- package/src/doctor.ts +212 -0
- package/src/drain.ts +14 -0
- package/src/engage/index.ts +217 -0
- package/src/generate/index.ts +110 -0
- package/src/generate/script.ts +152 -0
- package/src/generate/title.ts +78 -0
- package/src/help.ts +233 -0
- package/src/home.ts +45 -0
- package/src/llm/client.ts +177 -0
- package/src/log.ts +7 -0
- package/src/oauth/tokens.ts +189 -0
- package/src/oauth/vault.ts +72 -0
- package/src/pipeline/review.ts +642 -0
- package/src/pipeline/run.ts +443 -0
- package/src/pipeline/series-guard.ts +26 -0
- package/src/pipeline/source-fetch.ts +69 -0
- package/src/pipeline/stages.ts +728 -0
- package/src/pipeline/topics-source.ts +90 -0
- package/src/product/commands.ts +296 -0
- package/src/product/config-file.ts +38 -0
- package/src/product/engine-choice.ts +89 -0
- package/src/product/engines.ts +103 -0
- package/src/product/insights.ts +50 -0
- package/src/product/keys.ts +67 -0
- package/src/product/paths.ts +18 -0
- package/src/product/probes.ts +88 -0
- package/src/product/push-flow.ts +90 -0
- package/src/product/render-video.ts +21 -0
- package/src/product/short.ts +62 -0
- package/src/product/update.ts +32 -0
- package/src/project/canonical.ts +14 -0
- package/src/project/commands.ts +177 -0
- package/src/project/dashboard-status.ts +28 -0
- package/src/project/discover.ts +19 -0
- package/src/project/index-file.ts +73 -0
- package/src/project/marker.ts +87 -0
- package/src/project/migrate.ts +201 -0
- package/src/project/names.ts +27 -0
- package/src/project/notice.ts +16 -0
- package/src/project/open.ts +88 -0
- package/src/project/scaffold.ts +112 -0
- package/src/project/templates.ts +42 -0
- package/src/project/videos.ts +64 -0
- package/src/publish/facebook.ts +91 -0
- package/src/publish/index.ts +12 -0
- package/src/publish/instagram.ts +97 -0
- package/src/publish/rate.ts +62 -0
- package/src/publish/session.ts +242 -0
- package/src/publish/tiktok.ts +202 -0
- package/src/publish/youtube.ts +177 -0
- package/src/queue/desk-ops.ts +1170 -0
- package/src/queue/seal.ts +3 -0
- package/src/queue/store.ts +1006 -0
- package/src/remotion/AiLabel.tsx +26 -0
- package/src/remotion/Root.tsx +73 -0
- package/src/remotion/defaults.ts +26 -0
- package/src/remotion/films/claude/ClaudeFilm.tsx +1014 -0
- package/src/remotion/films/engine/EngineMark.tsx +17 -0
- package/src/remotion/films/rea/ReaShort.tsx +552 -0
- package/src/remotion/films/rea/drawings.tsx +907 -0
- package/src/remotion/films/rea/timing.ts +144 -0
- package/src/remotion/index.ts +4 -0
- package/src/remotion/templates/EditorialSplit.tsx +39 -0
- package/src/remotion/templates/KineticType.tsx +39 -0
- package/src/remotion/templates/SignalBoard.tsx +50 -0
- package/src/remotion/timing.ts +22 -0
- package/src/render/chrome.ts +78 -0
- package/src/render/engine-mark.ts +29 -0
- package/src/render/framey-template.ts +130 -0
- package/src/render/html-engine.ts +149 -0
- package/src/render/node-crypto-stub.ts +11 -0
- package/src/render/probe.ts +29 -0
- package/src/render/props.ts +17 -0
- package/src/render/vertical-short.ts +35 -0
- package/src/render/webpack.ts +33 -0
- package/src/render/worker.ts +65 -0
- package/src/scene/assets/fonts/TitanOne-OFL.txt +94 -0
- package/src/scene/assets/fonts/titan-one.woff2 +0 -0
- package/src/scene/assets/gsap/LICENSE.txt +8 -0
- package/src/scene/assets/gsap/gsap.min.js +11 -0
- package/src/scene/audio.ts +54 -0
- package/src/scene/blocks/LICENSE +190 -0
- package/src/scene/blocks/NOTICE +5 -0
- package/src/scene/blocks/bar-chart-race/bar-chart-race.html +585 -0
- package/src/scene/blocks/beat-freeze-cut/beat-freeze-cut.html +874 -0
- package/src/scene/blocks/camcorder-hud/camcorder-hud.html +164 -0
- package/src/scene/blocks/camera-dolly-zoom/camera-dolly-zoom.html +355 -0
- package/src/scene/blocks/code-diff/code-diff.html +817 -0
- package/src/scene/blocks/code-snippet-apple-terminal-pro/code-snippet-apple-terminal-pro.html +248 -0
- package/src/scene/blocks/code-typing/code-typing.html +764 -0
- package/src/scene/blocks/data-chart/data-chart.html +452 -0
- package/src/scene/blocks/flowchart-vertical/flowchart-vertical.html +454 -0
- package/src/scene/blocks/hw-scribble-transition/hw-scribble-transition.html +153 -0
- package/src/scene/blocks/index.json +824 -0
- package/src/scene/blocks/lt-accent-underline/lt-accent-underline.html +107 -0
- package/src/scene/blocks/lt-bold-block/lt-bold-block.html +109 -0
- package/src/scene/blocks/lt-clean-bar/lt-clean-bar.html +112 -0
- package/src/scene/blocks/lt-dark-card/lt-dark-card.html +111 -0
- package/src/scene/blocks/lt-kicker-name/lt-kicker-name.html +114 -0
- package/src/scene/blocks/lt-mask-reveal/lt-mask-reveal.html +120 -0
- package/src/scene/blocks/lt-neon-border/lt-neon-border.html +391 -0
- package/src/scene/blocks/lt-stack-bars/lt-stack-bars.html +109 -0
- package/src/scene/blocks/macos-notification/macos-notification.html +194 -0
- package/src/scene/blocks/mk-background/mk-background.html +236 -0
- package/src/scene/blocks/mk-callout-highlight/mk-callout-highlight.html +183 -0
- package/src/scene/blocks/mk-clone-wall-transition/mk-clone-wall-transition.html +226 -0
- package/src/scene/blocks/mk-line-graph/mk-line-graph.html +293 -0
- package/src/scene/blocks/mk-progress-stat/mk-progress-stat.html +186 -0
- package/src/scene/blocks/mk-specs-list/mk-specs-list.html +223 -0
- package/src/scene/blocks/news-ticker/news-ticker.html +409 -0
- package/src/scene/blocks/organic-light-leak-overlay/organic-light-leak-overlay.html +126 -0
- package/src/scene/blocks/oscilloscope-trace/oscilloscope-trace.html +519 -0
- package/src/scene/blocks/snow/snow.html +205 -0
- package/src/scene/blocks/split-flap-board/split-flap-board.html +347 -0
- package/src/scene/blocks/spotify-card/spotify-card.html +294 -0
- package/src/scene/blocks/starfield/starfield.html +188 -0
- package/src/scene/blocks/thread-message-stack/thread-message-stack.html +239 -0
- package/src/scene/blocks/week-in-merges/week-in-merges.html +470 -0
- package/src/scene/blocks/x-post/x-post.html +437 -0
- package/src/scene/blocks/yt-comment-card/yt-comment-card.html +319 -0
- package/src/scene/blocks/yt-lcd-background/yt-lcd-background.html +307 -0
- package/src/scene/blocks/yt-prism-title/yt-prism-title.html +205 -0
- package/src/scene/blocks.ts +229 -0
- package/src/scene/command.ts +272 -0
- package/src/scene/figure.ts +181 -0
- package/src/scene/glyphs.ts +107 -0
- package/src/scene/init.ts +86 -0
- package/src/scene/inspect/css-tree.d.ts +6 -0
- package/src/scene/inspect/page-scripts.ts +272 -0
- package/src/scene/inspect/report.ts +405 -0
- package/src/scene/inspect/run.ts +200 -0
- package/src/scene/inspect/source.ts +236 -0
- package/src/scene/label.ts +131 -0
- package/src/scene/net-guard.ts +74 -0
- package/src/scene/render.ts +292 -0
- package/src/scene/runtime/browser.ts +199 -0
- package/src/scene/runtime/core.ts +181 -0
- package/src/scene/runtime/react.tsx +115 -0
- package/src/scene/runtime/transitions.ts +133 -0
- package/src/scene/sanitize.ts +181 -0
- package/src/scene/scene.ts +152 -0
- package/src/scene/studio-command.ts +48 -0
- package/src/scene/studio-skin.ts +59 -0
- package/src/scene/studio.ts +337 -0
- package/src/scene/supply.ts +111 -0
- package/src/scene/timing.ts +288 -0
- package/src/scene/transitions/LICENSE +190 -0
- package/src/scene/transitions/NOTICE +5 -0
- package/src/scene/transitions/SOURCE.json +24 -0
- package/src/scene/transitions/transitions.global.js +38 -0
- package/src/scene/transitions.ts +26 -0
- package/src/service/all-projects.ts +153 -0
- package/src/service/commands.ts +256 -0
- package/src/service/due.ts +82 -0
- package/src/service/lock.ts +101 -0
- package/src/service/log-file.ts +56 -0
- package/src/service/state.ts +41 -0
- package/src/service/units.ts +90 -0
- package/src/service/worker.ts +51 -0
- package/src/skill-agents.ts +74 -0
- package/src/skill-install.ts +334 -0
- package/src/sync-write.ts +21 -0
- package/src/tick-report.ts +205 -0
- package/src/tts.ts +124 -0
- package/vendor/@vfrey/contracts/dist/account.d.ts +97 -0
- package/vendor/@vfrey/contracts/dist/account.js +27 -0
- package/vendor/@vfrey/contracts/dist/checks.d.ts +8 -0
- package/vendor/@vfrey/contracts/dist/checks.js +1 -0
- package/vendor/@vfrey/contracts/dist/credentials/crypto.d.ts +4 -0
- package/vendor/@vfrey/contracts/dist/credentials/crypto.js +38 -0
- package/vendor/@vfrey/contracts/dist/domain/advice-persona.d.ts +10 -0
- package/vendor/@vfrey/contracts/dist/domain/advice-persona.js +24 -0
- package/vendor/@vfrey/contracts/dist/domain/backoff.d.ts +13 -0
- package/vendor/@vfrey/contracts/dist/domain/backoff.js +27 -0
- package/vendor/@vfrey/contracts/dist/domain/cadence.d.ts +29 -0
- package/vendor/@vfrey/contracts/dist/domain/cadence.js +95 -0
- package/vendor/@vfrey/contracts/dist/domain/creators.d.ts +146 -0
- package/vendor/@vfrey/contracts/dist/domain/creators.js +2 -0
- package/vendor/@vfrey/contracts/dist/domain/disclosure.d.ts +127 -0
- package/vendor/@vfrey/contracts/dist/domain/disclosure.js +250 -0
- package/vendor/@vfrey/contracts/dist/domain/errors.d.ts +34 -0
- package/vendor/@vfrey/contracts/dist/domain/errors.js +68 -0
- package/vendor/@vfrey/contracts/dist/domain/redact.d.ts +1 -0
- package/vendor/@vfrey/contracts/dist/domain/redact.js +21 -0
- package/vendor/@vfrey/contracts/dist/domain/revenue.d.ts +13 -0
- package/vendor/@vfrey/contracts/dist/domain/revenue.js +16 -0
- package/vendor/@vfrey/contracts/dist/domain/safety.d.ts +20 -0
- package/vendor/@vfrey/contracts/dist/domain/safety.js +61 -0
- package/vendor/@vfrey/contracts/dist/domain/similarity.d.ts +13 -0
- package/vendor/@vfrey/contracts/dist/domain/similarity.js +100 -0
- package/vendor/@vfrey/contracts/dist/domain/templates.d.ts +18 -0
- package/vendor/@vfrey/contracts/dist/domain/templates.js +67 -0
- package/vendor/@vfrey/contracts/dist/domain/tiktok-chunks.d.ts +19 -0
- package/vendor/@vfrey/contracts/dist/domain/tiktok-chunks.js +52 -0
- package/vendor/@vfrey/contracts/dist/domain/topics.d.ts +78 -0
- package/vendor/@vfrey/contracts/dist/domain/topics.js +4 -0
- package/vendor/@vfrey/contracts/dist/domain/types.d.ts +216 -0
- package/vendor/@vfrey/contracts/dist/domain/types.js +1 -0
- package/vendor/@vfrey/contracts/dist/domain/variation.d.ts +57 -0
- package/vendor/@vfrey/contracts/dist/domain/variation.js +278 -0
- package/vendor/@vfrey/contracts/dist/events.d.ts +20 -0
- package/vendor/@vfrey/contracts/dist/events.js +1 -0
- package/vendor/@vfrey/contracts/dist/http.d.ts +11 -0
- package/vendor/@vfrey/contracts/dist/http.js +37 -0
- package/vendor/@vfrey/contracts/dist/index.d.ts +22 -0
- package/vendor/@vfrey/contracts/dist/index.js +22 -0
- package/vendor/@vfrey/contracts/dist/log.d.ts +2 -0
- package/vendor/@vfrey/contracts/dist/log.js +10 -0
- package/vendor/@vfrey/contracts/dist/mail.d.ts +55 -0
- package/vendor/@vfrey/contracts/dist/mail.js +1 -0
- package/vendor/@vfrey/contracts/package.json +18 -0
- package/vendor/@vfrey/ingest/dist/index.d.ts +41 -0
- package/vendor/@vfrey/ingest/dist/index.js +221 -0
- package/vendor/@vfrey/ingest/dist/parse.d.ts +5 -0
- package/vendor/@vfrey/ingest/dist/parse.js +165 -0
- package/vendor/@vfrey/ingest/package.json +21 -0
- package/vendor/framey/LICENSE +190 -0
- package/vendor/framey/NOTICE +15 -0
- package/vendor/framey/SOURCE.json +24 -0
- package/vendor/framey/STUDIO-SOURCE.json +9 -0
- package/vendor/framey/bin/framey-localize-fonts.mjs +12 -0
- package/vendor/framey/bin/framey.mjs +16 -0
- package/vendor/framey/dist/add-VMJD5KBS.js +75 -0
- package/vendor/framey/dist/animatedGifPrep-QPKGJ6PM.js +43 -0
- package/vendor/framey/dist/api.d.ts +211 -0
- package/vendor/framey/dist/api.js +76 -0
- package/vendor/framey/dist/appHistory-WW56FZTT.js +31 -0
- package/vendor/framey/dist/asset-WQKGOELE.js +26 -0
- package/vendor/framey/dist/audio-RSJWVWRA.js +1936 -0
- package/vendor/framey/dist/batchRender-3HPICDCT.js +366 -0
- package/vendor/framey/dist/beats-HL2BDUZI.js +273 -0
- package/vendor/framey/dist/benchmark-RWCWHI5Z.js +230 -0
- package/vendor/framey/dist/browser-ECYET3Q2.js +206 -0
- package/vendor/framey/dist/bundleWithLocalizedFonts-ALYBQEQT.js +52 -0
- package/vendor/framey/dist/captureCompositionFrame-4DA6SEH2.js +94 -0
- package/vendor/framey/dist/catalog-5JVDEUWK.js +979 -0
- package/vendor/framey/dist/catch-up-NEBCL5NO.js +106 -0
- package/vendor/framey/dist/check-F5N544ML.js +111 -0
- package/vendor/framey/dist/checkBrowser-LZ66CMJG.js +110 -0
- package/vendor/framey/dist/chunk-2K7Z2HVP.js +372 -0
- package/vendor/framey/dist/chunk-2UZQHDM7.js +323 -0
- package/vendor/framey/dist/chunk-2X4PQEYM.js +34 -0
- package/vendor/framey/dist/chunk-3422KMTD.js +168 -0
- package/vendor/framey/dist/chunk-3FYD4YEK.js +72 -0
- package/vendor/framey/dist/chunk-3GXQPCJC.js +682 -0
- package/vendor/framey/dist/chunk-3LM6YPBR.js +179 -0
- package/vendor/framey/dist/chunk-442JNXRM.js +20 -0
- package/vendor/framey/dist/chunk-4BNN5FGX.js +182 -0
- package/vendor/framey/dist/chunk-4P7NX5BM.js +11532 -0
- package/vendor/framey/dist/chunk-4UMPIPCQ.js +88 -0
- package/vendor/framey/dist/chunk-4YYJOFKS.js +28 -0
- package/vendor/framey/dist/chunk-54HJ63Q2.js +29 -0
- package/vendor/framey/dist/chunk-5ETJX6WM.js +89 -0
- package/vendor/framey/dist/chunk-5SG54JRS.js +68 -0
- package/vendor/framey/dist/chunk-634HSJ3J.js +58 -0
- package/vendor/framey/dist/chunk-66LREXGS.js +48 -0
- package/vendor/framey/dist/chunk-6BEFEWFB.js +25 -0
- package/vendor/framey/dist/chunk-6BNTGNGD.js +286 -0
- package/vendor/framey/dist/chunk-6DDPOHMK.js +140 -0
- package/vendor/framey/dist/chunk-6GV5XSOR.js +214 -0
- package/vendor/framey/dist/chunk-6JR2XUEE.js +127 -0
- package/vendor/framey/dist/chunk-73QVATIM.js +362 -0
- package/vendor/framey/dist/chunk-7FMUTGOH.js +279 -0
- package/vendor/framey/dist/chunk-7JQFJ6JJ.js +291 -0
- package/vendor/framey/dist/chunk-AHPGAESX.js +113 -0
- package/vendor/framey/dist/chunk-AHX34EFP.js +15 -0
- package/vendor/framey/dist/chunk-AJ5CSAEZ.js +487 -0
- package/vendor/framey/dist/chunk-B7RJV74O.js +5047 -0
- package/vendor/framey/dist/chunk-BL4U6U52.js +254 -0
- package/vendor/framey/dist/chunk-BSQ7XANM.js +210 -0
- package/vendor/framey/dist/chunk-BZ56MTDX.js +6 -0
- package/vendor/framey/dist/chunk-C5BO7PMT.js +282 -0
- package/vendor/framey/dist/chunk-CFUCHO4T.js +358 -0
- package/vendor/framey/dist/chunk-CZGHFATT.js +31 -0
- package/vendor/framey/dist/chunk-DLA44CMS.js +5649 -0
- package/vendor/framey/dist/chunk-DPVXBVLL.js +31292 -0
- package/vendor/framey/dist/chunk-DQBY7FRR.js +397 -0
- package/vendor/framey/dist/chunk-EAMLBAHA.js +154 -0
- package/vendor/framey/dist/chunk-EOVYQ3IG.js +143 -0
- package/vendor/framey/dist/chunk-EVMY7IPL.js +612 -0
- package/vendor/framey/dist/chunk-FG4BBFZI.js +122 -0
- package/vendor/framey/dist/chunk-FI4I7CME.js +56 -0
- package/vendor/framey/dist/chunk-FLREUEHW.js +2081 -0
- package/vendor/framey/dist/chunk-FUFP7A7S.js +1126 -0
- package/vendor/framey/dist/chunk-FV3PIKGH.js +56 -0
- package/vendor/framey/dist/chunk-FXMKHHPO.js +123 -0
- package/vendor/framey/dist/chunk-GGZAM5VH.js +1187 -0
- package/vendor/framey/dist/chunk-GKZEVJIB.js +39 -0
- package/vendor/framey/dist/chunk-GMECXFAN.js +57 -0
- package/vendor/framey/dist/chunk-GNEFDLFB.js +621 -0
- package/vendor/framey/dist/chunk-GPMLSOEG.js +19274 -0
- package/vendor/framey/dist/chunk-GPYVT5EE.js +59 -0
- package/vendor/framey/dist/chunk-H6IEMZEV.js +40 -0
- package/vendor/framey/dist/chunk-HEFQCIXR.js +170 -0
- package/vendor/framey/dist/chunk-HFQ4CWHA.js +118 -0
- package/vendor/framey/dist/chunk-HPDTCOKE.js +304 -0
- package/vendor/framey/dist/chunk-HTUHQKDB.js +548 -0
- package/vendor/framey/dist/chunk-HU2SPFNG.js +103 -0
- package/vendor/framey/dist/chunk-HVW7ABXD.js +19 -0
- package/vendor/framey/dist/chunk-IG7NDKXO.js +26 -0
- package/vendor/framey/dist/chunk-II3J3WXK.js +246 -0
- package/vendor/framey/dist/chunk-IR35BCLK.js +13 -0
- package/vendor/framey/dist/chunk-ISN5GRYI.js +20 -0
- package/vendor/framey/dist/chunk-IUONB55J.js +669 -0
- package/vendor/framey/dist/chunk-IVSUQR2B.js +161 -0
- package/vendor/framey/dist/chunk-IXU6PQSW.js +259 -0
- package/vendor/framey/dist/chunk-IZCW2NKT.js +29 -0
- package/vendor/framey/dist/chunk-JGXJU5PF.js +68 -0
- package/vendor/framey/dist/chunk-JHOCK3DG.js +27 -0
- package/vendor/framey/dist/chunk-JVBRJMOP.js +121 -0
- package/vendor/framey/dist/chunk-K4MJX7SR.js +17 -0
- package/vendor/framey/dist/chunk-K4WKGXRC.js +18 -0
- package/vendor/framey/dist/chunk-KGPLIH7B.js +127 -0
- package/vendor/framey/dist/chunk-KLIKCX6Q.js +63 -0
- package/vendor/framey/dist/chunk-KXTKKBOC.js +61 -0
- package/vendor/framey/dist/chunk-KZI44OPA.js +185 -0
- package/vendor/framey/dist/chunk-LEKZNRKI.js +45 -0
- package/vendor/framey/dist/chunk-LFGJWOVA.js +61 -0
- package/vendor/framey/dist/chunk-LLMSX6V6.js +17 -0
- package/vendor/framey/dist/chunk-LTGTN2TH.js +195 -0
- package/vendor/framey/dist/chunk-M2WBOQ2Q.js +54 -0
- package/vendor/framey/dist/chunk-MI5FTLYK.js +399 -0
- package/vendor/framey/dist/chunk-MLQP2QUF.js +1809 -0
- package/vendor/framey/dist/chunk-MMQ52NIZ.js +956 -0
- package/vendor/framey/dist/chunk-ND46XKK3.js +742 -0
- package/vendor/framey/dist/chunk-NYEXNSHM.js +504 -0
- package/vendor/framey/dist/chunk-O4YRB27L.js +57 -0
- package/vendor/framey/dist/chunk-O7BGSQ5F.js +92 -0
- package/vendor/framey/dist/chunk-OFQWTKPW.js +117 -0
- package/vendor/framey/dist/chunk-P5ZBLXJ6.js +91 -0
- package/vendor/framey/dist/chunk-P6LJ4Y32.js +4662 -0
- package/vendor/framey/dist/chunk-PKZ7RK5N.js +281 -0
- package/vendor/framey/dist/chunk-PURHAXFL.js +15 -0
- package/vendor/framey/dist/chunk-Q5X2B5WT.js +29 -0
- package/vendor/framey/dist/chunk-QAMKOG67.js +248 -0
- package/vendor/framey/dist/chunk-R6YBK4XX.js +514 -0
- package/vendor/framey/dist/chunk-RGLQURIX.js +2997 -0
- package/vendor/framey/dist/chunk-RNV3DM4S.js +3573 -0
- package/vendor/framey/dist/chunk-ROEW726S.js +4638 -0
- package/vendor/framey/dist/chunk-RQVDTPGD.js +6 -0
- package/vendor/framey/dist/chunk-RVZHGOGR.js +26 -0
- package/vendor/framey/dist/chunk-RXATE6FZ.js +385 -0
- package/vendor/framey/dist/chunk-S3S4TGEH.js +424 -0
- package/vendor/framey/dist/chunk-SDYDNHEE.js +11202 -0
- package/vendor/framey/dist/chunk-SSWBU67I.js +173 -0
- package/vendor/framey/dist/chunk-ST35C4K6.js +28 -0
- package/vendor/framey/dist/chunk-SZNTYTIV.js +990 -0
- package/vendor/framey/dist/chunk-T2EUWCJE.js +29 -0
- package/vendor/framey/dist/chunk-T6AGUTZJ.js +444 -0
- package/vendor/framey/dist/chunk-THJALHFJ.js +11131 -0
- package/vendor/framey/dist/chunk-TIQB63MX.js +18128 -0
- package/vendor/framey/dist/chunk-U242RTCT.js +134 -0
- package/vendor/framey/dist/chunk-UJZREAV6.js +3901 -0
- package/vendor/framey/dist/chunk-UMD3V7CU.js +264 -0
- package/vendor/framey/dist/chunk-UOFAE5RF.js +42 -0
- package/vendor/framey/dist/chunk-UTEIUGAZ.js +29 -0
- package/vendor/framey/dist/chunk-VAS6HQX3.js +138 -0
- package/vendor/framey/dist/chunk-VC76R6W2.js +27 -0
- package/vendor/framey/dist/chunk-VFZ7W5W7.js +226 -0
- package/vendor/framey/dist/chunk-VHNLZAE2.js +348 -0
- package/vendor/framey/dist/chunk-VMC4WSH5.js +278 -0
- package/vendor/framey/dist/chunk-VORQIH64.js +108 -0
- package/vendor/framey/dist/chunk-VTXFRIHO.js +186 -0
- package/vendor/framey/dist/chunk-W2LGJSEN.js +182 -0
- package/vendor/framey/dist/chunk-WKBM7NDG.js +32 -0
- package/vendor/framey/dist/chunk-WMQNIQDJ.js +2057 -0
- package/vendor/framey/dist/chunk-WN4XZC5F.js +504 -0
- package/vendor/framey/dist/chunk-WQ6ZXHFQ.js +133 -0
- package/vendor/framey/dist/chunk-X7HBSTCZ.js +53 -0
- package/vendor/framey/dist/chunk-X7MTZ4T2.js +6 -0
- package/vendor/framey/dist/chunk-XPTOJZJF.js +308 -0
- package/vendor/framey/dist/chunk-XT6MXC7A.js +6 -0
- package/vendor/framey/dist/chunk-XT7X6QCP.js +6 -0
- package/vendor/framey/dist/chunk-XTMDWNEC.js +2820 -0
- package/vendor/framey/dist/chunk-XUHOIDKG.js +6 -0
- package/vendor/framey/dist/chunk-XX7LTW5Y.js +138 -0
- package/vendor/framey/dist/chunk-Y3YGTZEG.js +382 -0
- package/vendor/framey/dist/chunk-Y5SBSGN7.js +346 -0
- package/vendor/framey/dist/chunk-YCMVI4MZ.js +625 -0
- package/vendor/framey/dist/chunk-YOR4CTCE.js +261 -0
- package/vendor/framey/dist/chunk-YPRBGITP.js +2748 -0
- package/vendor/framey/dist/chunk-ZKDMAOFU.js +32 -0
- package/vendor/framey/dist/chunk-ZW6DURPF.js +1318 -0
- package/vendor/framey/dist/clean-ZJOGDKDM.js +336 -0
- package/vendor/framey/dist/cli.js +439 -0
- package/vendor/framey/dist/cloudrun-HGJ3B6ZV.js +767 -0
- package/vendor/framey/dist/commands/contrast-audit.browser.js +643 -0
- package/vendor/framey/dist/commands/layout-audit.browser.js +2914 -0
- package/vendor/framey/dist/commands/motion-sample.browser.js +74 -0
- package/vendor/framey/dist/commands/motion-signature.browser.js +475 -0
- package/vendor/framey/dist/compare-W7UY3BAZ.js +463 -0
- package/vendor/framey/dist/compiler-TM3W6GYF.js +136 -0
- package/vendor/framey/dist/component-QF5VGYMU.js +176 -0
- package/vendor/framey/dist/compositions-XT3PMNDF.js +238 -0
- package/vendor/framey/dist/contactSheet-7W6GYHBQ.js +24 -0
- package/vendor/framey/dist/deploy-6FJPWKF2.js +299 -0
- package/vendor/framey/dist/destroy-HE2RIPKZ.js +57 -0
- package/vendor/framey/dist/deterministicFonts-RADD3BSX.js +50 -0
- package/vendor/framey/dist/dist-4S2FGGVY.js +422 -0
- package/vendor/framey/dist/dist-BE4B2EZE.js +32 -0
- package/vendor/framey/dist/dist-EWXJGN63.js +129 -0
- package/vendor/framey/dist/docs-VKIRFEEX.js +131 -0
- package/vendor/framey/dist/doctor-2UNJ2G7U.js +74 -0
- package/vendor/framey/dist/dom-37K6YOH4.js +14 -0
- package/vendor/framey/dist/dotEnv-7PZQTVIM.js +54 -0
- package/vendor/framey/dist/events-ACY3P2PA.js +76 -0
- package/vendor/framey/dist/events-IHB3B2MK.js +69 -0
- package/vendor/framey/dist/ffmpeg-DR5LGGSK.js +63 -0
- package/vendor/framey/dist/figma-BODVUPHB.js +63 -0
- package/vendor/framey/dist/fontData.generated-NXAOI4GH.js +13 -0
- package/vendor/framey/dist/fontLocalizeCli.js +142 -0
- package/vendor/framey/dist/gpuPolicy-XLEXORZY.js +49 -0
- package/vendor/framey/dist/grade-compare-GAXFQ74C.js +661 -0
- package/vendor/framey/dist/gsapParser-IEUSLYDJ.js +2885 -0
- package/vendor/framey/dist/gsapParserAcorn-LZJKCFQI.js +30 -0
- package/vendor/framey/dist/help-VCB5X2HS.js +184 -0
- package/vendor/framey/dist/history-ENGZEVFT.js +471 -0
- package/vendor/framey/dist/hyperframe.manifest.json +22 -0
- package/vendor/framey/dist/hyperframe.runtime.iife.js +1132 -0
- package/vendor/framey/dist/hyperframes-player.global.js +478 -0
- package/vendor/framey/dist/hyperframes-slideshow.global.js +148 -0
- package/vendor/framey/dist/info-WPCJHBMU.js +182 -0
- package/vendor/framey/dist/init-G4WGLYGD.js +1176 -0
- package/vendor/framey/dist/inspect-5IQ6CNEQ.js +106 -0
- package/vendor/framey/dist/installer-ML4HWUV7.js +40 -0
- package/vendor/framey/dist/keyframes-Q6XJO7VW.js +2755 -0
- package/vendor/framey/dist/lambda-A7JC74JF.js +468 -0
- package/vendor/framey/dist/layout-YET6XNXA.js +95 -0
- package/vendor/framey/dist/lint-I7FZN2VJ.js +162 -0
- package/vendor/framey/dist/lintProject-PDB3UN72.js +30 -0
- package/vendor/framey/dist/manager-CD6LJ7ZN.js +45 -0
- package/vendor/framey/dist/manager-WA4F2GST.js +67 -0
- package/vendor/framey/dist/media-treatment-BXYS4WVA.js +1030 -0
- package/vendor/framey/dist/media-use-LWH3MANK.js +113 -0
- package/vendor/framey/dist/models-K363FYM4.js +195 -0
- package/vendor/framey/dist/motionShot-GPAVWXLH.js +640 -0
- package/vendor/framey/dist/normalize-FASIPBUM.js +24 -0
- package/vendor/framey/dist/normalize-audio-VDWYRHN6.js +268 -0
- package/vendor/framey/dist/open-E3TB6P6J.js +128 -0
- package/vendor/framey/dist/parakeetRunner-UP34UOI2.js +58 -0
- package/vendor/framey/dist/pipeline-VXIUYEBU.js +64 -0
- package/vendor/framey/dist/play-BUTDQ4DV.js +353 -0
- package/vendor/framey/dist/policies-OLDYGFK5.js +309 -0
- package/vendor/framey/dist/preflight-2KZ2GWAZ.js +55 -0
- package/vendor/framey/dist/present-GXYSM7NX.js +353 -0
- package/vendor/framey/dist/preview-EKZ33JOJ.js +1910 -0
- package/vendor/framey/dist/producer-SKU5EV3J.js +13 -0
- package/vendor/framey/dist/progress-ZWB4Z4ET.js +76 -0
- package/vendor/framey/dist/projectConfig-3MZAYVTS.js +60 -0
- package/vendor/framey/dist/publication-DTKWNF74.js +33 -0
- package/vendor/framey/dist/remove-background-OY5L7XN2.js +265 -0
- package/vendor/framey/dist/render-CDV4GDFX.js +187 -0
- package/vendor/framey/dist/render-YE5YAMOD.js +2620 -0
- package/vendor/framey/dist/render-batch-ZAMFPCLL.js +274 -0
- package/vendor/framey/dist/renderSetupWorker.js +120 -0
- package/vendor/framey/dist/resolver-D5BIPSEW.js +42 -0
- package/vendor/framey/dist/runtimeVersion.js +18 -0
- package/vendor/framey/dist/sam-WFZXI4F7.js +23 -0
- package/vendor/framey/dist/shaderTransitionWorker.js +44 -0
- package/vendor/framey/dist/sherpa-RQWLV7I3.js +79 -0
- package/vendor/framey/dist/sherpaWorker.js +84 -0
- package/vendor/framey/dist/sites-AMOVABTK.js +54 -0
- package/vendor/framey/dist/skill-ZHVLH7KX.js +15 -0
- package/vendor/framey/dist/slideshow-SJGK4RQS.js +24 -0
- package/vendor/framey/dist/snapshot-TUXL6LBQ.js +1515 -0
- package/vendor/framey/dist/src-JHQGB2S6.js +215 -0
- package/vendor/framey/dist/src-Z5PGEMXB.js +532 -0
- package/vendor/framey/dist/state-B6H3WHCF.js +28 -0
- package/vendor/framey/dist/staticProjectServer-DFVZOUGI.js +63 -0
- package/vendor/framey/dist/studio/assets/hyperframes-player-DLaKjw0L.js +478 -0
- package/vendor/framey/dist/studio/assets/index-BX3KHhGX.js +71 -0
- package/vendor/framey/dist/studio/assets/index-CYbvOVp-.css +1 -0
- package/vendor/framey/dist/studio/assets/index-Dp3QEUGL.js +402 -0
- package/vendor/framey/dist/studio/assets/index-qrTAXbqc.js +531 -0
- package/vendor/framey/dist/studio/favicon.svg +14 -0
- package/vendor/framey/dist/studio/icons/timeline/audio.svg +7 -0
- package/vendor/framey/dist/studio/icons/timeline/captions.svg +5 -0
- package/vendor/framey/dist/studio/icons/timeline/composition.svg +12 -0
- package/vendor/framey/dist/studio/icons/timeline/image.svg +18 -0
- package/vendor/framey/dist/studio/icons/timeline/music.svg +10 -0
- package/vendor/framey/dist/studio/icons/timeline/text.svg +3 -0
- package/vendor/framey/dist/studio/index.html +23 -0
- package/vendor/framey/dist/studio-api-2NECGRMB.js +179 -0
- package/vendor/framey/dist/studioSelectionClient-IUUPKUHZ.js +25 -0
- package/vendor/framey/dist/studioServer-N2OPUMOQ.js +1569 -0
- package/vendor/framey/dist/synthesize-PKTB5ATG.js +183 -0
- package/vendor/framey/dist/system-7F3UEHQT.js +27 -0
- package/vendor/framey/dist/telemetry-DQGPEKM6.js +145 -0
- package/vendor/framey/dist/telemetry-UN55N6U6.js +77 -0
- package/vendor/framey/dist/timeline-LHUUMK63.js +1295 -0
- package/vendor/framey/dist/tokens-OLECWDFK.js +92 -0
- package/vendor/framey/dist/transcribe-O7HQKMIV.js +331 -0
- package/vendor/framey/dist/transcribe-UJESPBT5.js +76 -0
- package/vendor/framey/dist/tts-YCCQHZD4.js +234 -0
- package/vendor/framey/dist/urlDownloader-H2LXLG4U.js +48 -0
- package/vendor/framey/dist/usage-HG4WMM7A.js +2167 -0
- package/vendor/framey/dist/validate-F2XMN6EV.js +87 -0
- package/vendor/framey/dist/vendor/npm/gsap@3.15.0/GSAP-LICENSE.txt +8 -0
- package/vendor/framey/dist/vendor/npm/gsap@3.15.0/dist/CustomEase.min.js +11 -0
- package/vendor/framey/dist/vendor/npm/gsap@3.15.0/dist/MotionPathPlugin.min.js +11 -0
- package/vendor/framey/dist/vendor/npm/gsap@3.15.0/dist/gsap.min.js +11 -0
- package/vendor/framey/dist/warmCheck.js +220 -0
- package/vendor/framey/dist/warmCheckClient.js +139 -0
- package/vendor/framey/dist/workspaceBuildCheck-XYD2YTFW.js +133 -0
- package/vendor/framey-player/LICENSE +190 -0
- package/vendor/framey-player/NOTICE +16 -0
- package/vendor/framey-player/SOURCE.json +6 -0
- package/vendor/framey-player/hyperframes-player.global.js +478 -0
- package/vendor/framey.pin +1 -0
- package/bin/index.js +0 -3
|
@@ -0,0 +1,2914 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
const IGNORE_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "META", "LINK"]);
|
|
3
|
+
|
|
4
|
+
function toRect(rect) {
|
|
5
|
+
return {
|
|
6
|
+
left: round(rect.left),
|
|
7
|
+
top: round(rect.top),
|
|
8
|
+
right: round(rect.right),
|
|
9
|
+
bottom: round(rect.bottom),
|
|
10
|
+
width: round(rect.width),
|
|
11
|
+
height: round(rect.height),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function rectFromOrigin(left, top, width, height) {
|
|
16
|
+
return {
|
|
17
|
+
left: round(left),
|
|
18
|
+
top: round(top),
|
|
19
|
+
right: round(left + width),
|
|
20
|
+
bottom: round(top + height),
|
|
21
|
+
width: round(width),
|
|
22
|
+
height: round(height),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function round(value) {
|
|
27
|
+
return Math.round(value * 100) / 100;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function horizontalOverflow(subject, container, tolerance) {
|
|
31
|
+
if (subject.width <= container.width + tolerance) return null;
|
|
32
|
+
const overflow = overflowFor(subject, container, tolerance);
|
|
33
|
+
if (!overflow) return null;
|
|
34
|
+
const horizontal = {};
|
|
35
|
+
if (overflow.left != null) horizontal.left = overflow.left;
|
|
36
|
+
if (overflow.right != null) horizontal.right = overflow.right;
|
|
37
|
+
return Object.keys(horizontal).length > 0 ? horizontal : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function overflowFor(subject, container, tolerance, vTolerance) {
|
|
41
|
+
// Horizontal axis uses `tolerance`; vertical axis uses `vTolerance` (defaults to the same).
|
|
42
|
+
// A separate vertical tolerance lets text overflow checks absorb glyph ink that exceeds a
|
|
43
|
+
// snug line-height — see textOverflowIssues.
|
|
44
|
+
if (vTolerance == null) vTolerance = tolerance;
|
|
45
|
+
const overflow = {};
|
|
46
|
+
if (subject.left < container.left - tolerance)
|
|
47
|
+
overflow.left = round(container.left - subject.left);
|
|
48
|
+
if (subject.right > container.right + tolerance)
|
|
49
|
+
overflow.right = round(subject.right - container.right);
|
|
50
|
+
if (subject.top < container.top - vTolerance) overflow.top = round(container.top - subject.top);
|
|
51
|
+
if (subject.bottom > container.bottom + vTolerance)
|
|
52
|
+
overflow.bottom = round(subject.bottom - container.bottom);
|
|
53
|
+
return Object.keys(overflow).length > 0 ? overflow : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function escapeCss(value) {
|
|
57
|
+
if (window.CSS && typeof window.CSS.escape === "function") return window.CSS.escape(value);
|
|
58
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function escapeAttr(value) {
|
|
62
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function selectorFor(element) {
|
|
66
|
+
if (element.id) return `#${escapeCss(element.id)}`;
|
|
67
|
+
const dataName =
|
|
68
|
+
element.getAttribute("data-layout-name") ||
|
|
69
|
+
element.getAttribute("data-composition-id") ||
|
|
70
|
+
element.getAttribute("data-start");
|
|
71
|
+
if (dataName) {
|
|
72
|
+
const attr = element.hasAttribute("data-layout-name")
|
|
73
|
+
? "data-layout-name"
|
|
74
|
+
: element.hasAttribute("data-composition-id")
|
|
75
|
+
? "data-composition-id"
|
|
76
|
+
: "data-start";
|
|
77
|
+
const attrSelector = `[${attr}="${escapeAttr(dataName)}"]`;
|
|
78
|
+
if (document.querySelectorAll(attrSelector).length === 1) return attrSelector;
|
|
79
|
+
return `${element.tagName.toLowerCase()}${attrSelector}`;
|
|
80
|
+
}
|
|
81
|
+
const classes = Array.from(element.classList).slice(0, 2);
|
|
82
|
+
if (classes.length > 0) {
|
|
83
|
+
return `${element.tagName.toLowerCase()}.${classes.map(escapeCss).join(".")}`;
|
|
84
|
+
}
|
|
85
|
+
const parent = element.parentElement;
|
|
86
|
+
if (!parent) return element.tagName.toLowerCase();
|
|
87
|
+
const siblings = Array.from(parent.children).filter(
|
|
88
|
+
(child) => child.tagName === element.tagName,
|
|
89
|
+
);
|
|
90
|
+
const index = siblings.indexOf(element) + 1;
|
|
91
|
+
return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function uniqueSelectorFor(element) {
|
|
95
|
+
const preferred = selectorFor(element);
|
|
96
|
+
try {
|
|
97
|
+
if (document.querySelectorAll(preferred).length === 1) return preferred;
|
|
98
|
+
} catch {
|
|
99
|
+
// Fall through to a structural selector.
|
|
100
|
+
}
|
|
101
|
+
const parent = element.parentElement;
|
|
102
|
+
if (!parent) return preferred;
|
|
103
|
+
const siblings = Array.from(parent.children).filter(
|
|
104
|
+
(child) => child.tagName === element.tagName,
|
|
105
|
+
);
|
|
106
|
+
const index = siblings.indexOf(element) + 1;
|
|
107
|
+
return `${uniqueSelectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Color grading paints an <img>/<video> into a canvas the runtime inserts right after it; that canvas answers
|
|
111
|
+
// to the element the author wrote. ponytail: render-frame siblings sit between them only in render, not check.
|
|
112
|
+
const GRADED_STAND_IN = "[data-hf-color-grading-canvas]";
|
|
113
|
+
function authoredElement(element) {
|
|
114
|
+
return (element.matches(GRADED_STAND_IN) && element.previousElementSibling) || element;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hasIgnoreFlag(element) {
|
|
118
|
+
return !!authoredElement(element).closest("[data-layout-ignore], [data-layout-check='ignore']");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hasAllowOverflowFlag(element) {
|
|
122
|
+
return !!authoredElement(element).closest("[data-layout-allow-overflow]");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hasAllowCaptionZoneFlag(element) {
|
|
126
|
+
return !!authoredElement(element).closest("[data-layout-allow-caption-zone]");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function hasTextClipOptOut(element) {
|
|
130
|
+
return (
|
|
131
|
+
hasAllowOverflowFlag(element) || authoredElement(element).hasAttribute("data-layout-bleed")
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function opacityChain(element) {
|
|
136
|
+
let opacity = 1;
|
|
137
|
+
for (let current = element; current; current = current.parentElement) {
|
|
138
|
+
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
|
|
139
|
+
if (Number.isFinite(parsed)) opacity *= parsed;
|
|
140
|
+
}
|
|
141
|
+
return opacity;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hasOpacityBelow(element, floor) {
|
|
145
|
+
for (let current = element; current; current = current.parentElement) {
|
|
146
|
+
const parsed = Number.parseFloat(getComputedStyle(current).opacity || "1");
|
|
147
|
+
if (Number.isFinite(parsed) && parsed < floor) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A clip-path can shrink an element's painted region to nothing (e.g. a
|
|
153
|
+
// typewriter span pre-reveal at `inset(0 100% 0 0)`, or `circle(0px)`) while
|
|
154
|
+
// its layout box, opacity, visibility and display all still read as present.
|
|
155
|
+
// Such an element paints zero pixels, so flagging it for overlap/occlusion is
|
|
156
|
+
// a false positive. clip-path also drives hit-testing, so an element clipped
|
|
157
|
+
// to nothing is unreachable by elementFromPoint anywhere in its box; only run
|
|
158
|
+
// the probe when a clip-path is actually in effect (self or ancestor) to avoid
|
|
159
|
+
// mistaking a genuinely-occluded element for a clipped one.
|
|
160
|
+
function hasClipPath(element) {
|
|
161
|
+
for (let current = element; current; current = current.parentElement) {
|
|
162
|
+
const clip = getComputedStyle(current).clipPath;
|
|
163
|
+
if (clip && clip !== "none") return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const CLIP_PROBE_COLS = [0.05, 0.25, 0.5, 0.75, 0.95];
|
|
169
|
+
const CLIP_PROBE_ROWS = [0.25, 0.5, 0.75];
|
|
170
|
+
|
|
171
|
+
function paintsAnyProbePoint(element, rect) {
|
|
172
|
+
// Probe resolution intentionally treats edge strips narrower than the
|
|
173
|
+
// nearest probe point as clipped away. That avoids noisy reports for
|
|
174
|
+
// typewriter pre-reveal states; if a real visible-strip bug appears, add
|
|
175
|
+
// edge probes here before widening the audit surface.
|
|
176
|
+
for (const fx of CLIP_PROBE_COLS) {
|
|
177
|
+
for (const fy of CLIP_PROBE_ROWS) {
|
|
178
|
+
const hit = document.elementFromPoint(
|
|
179
|
+
rect.left + rect.width * fx,
|
|
180
|
+
rect.top + rect.height * fy,
|
|
181
|
+
);
|
|
182
|
+
if (hit === element || element.contains(hit)) return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function isClippedAway(element) {
|
|
189
|
+
if (typeof document.elementFromPoint !== "function") return false;
|
|
190
|
+
if (!hasClipPath(element)) return false;
|
|
191
|
+
const rect = element.getBoundingClientRect();
|
|
192
|
+
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
|
|
193
|
+
return !paintsAnyProbePoint(element, rect);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isVisibleElement(element, opacityFloor, probeClipPath) {
|
|
197
|
+
if (IGNORE_TAGS.has(element.tagName)) return false;
|
|
198
|
+
if (hasIgnoreFlag(element)) return false;
|
|
199
|
+
if (
|
|
200
|
+
opacityFloor != null &&
|
|
201
|
+
typeof element.checkVisibility === "function" &&
|
|
202
|
+
!element.checkVisibility({
|
|
203
|
+
opacityProperty: true,
|
|
204
|
+
visibilityProperty: true,
|
|
205
|
+
contentVisibilityAuto: true,
|
|
206
|
+
})
|
|
207
|
+
) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
const style = getComputedStyle(element);
|
|
211
|
+
if (
|
|
212
|
+
style.display === "none" ||
|
|
213
|
+
style.visibility === "hidden" ||
|
|
214
|
+
style.visibility === "collapse"
|
|
215
|
+
) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
if (
|
|
219
|
+
opacityFloor == null ? opacityChain(element) < 0.2 : hasOpacityBelow(element, opacityFloor)
|
|
220
|
+
) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
const rect = element.getBoundingClientRect();
|
|
224
|
+
if (rect.width <= 0.5 || rect.height <= 0.5) return false;
|
|
225
|
+
return probeClipPath === false || !isClippedAway(element);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function directTextNodes(element) {
|
|
229
|
+
return Array.from(element.childNodes).filter((node) => node.nodeType === 3);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function textContentFor(element, ownTextOnly) {
|
|
233
|
+
const content = ownTextOnly
|
|
234
|
+
? directTextNodes(element)
|
|
235
|
+
.map((node) => node.textContent || "")
|
|
236
|
+
.join("")
|
|
237
|
+
: element.innerText || element.textContent || "";
|
|
238
|
+
return content.replace(/\s+/g, " ").trim();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function hasOwnTextCandidate(element, directOnly) {
|
|
242
|
+
const text = textContentFor(element, directOnly);
|
|
243
|
+
if (!text) return false;
|
|
244
|
+
if (directOnly) return true;
|
|
245
|
+
// Aggregate text may come exclusively from descendants (including hidden
|
|
246
|
+
// captions). The container itself does not paint that text and must not be
|
|
247
|
+
// audited as though it did.
|
|
248
|
+
return textContentFor(element, true).length > 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function textClientRects(element, directOnly) {
|
|
252
|
+
const subjects = directOnly ? directTextNodes(element) : [element];
|
|
253
|
+
const rects = [];
|
|
254
|
+
for (const subject of subjects) {
|
|
255
|
+
const range = document.createRange();
|
|
256
|
+
range.selectNodeContents(subject);
|
|
257
|
+
rects.push(
|
|
258
|
+
...Array.from(range.getClientRects()).filter(
|
|
259
|
+
(rect) => rect.width > 0.5 && rect.height > 0.5,
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
range.detach();
|
|
263
|
+
}
|
|
264
|
+
return rects;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function unionRects(rects) {
|
|
268
|
+
if (rects.length === 0) return null;
|
|
269
|
+
const union = rects.reduce(
|
|
270
|
+
(acc, rect) => ({
|
|
271
|
+
left: Math.min(acc.left, rect.left),
|
|
272
|
+
top: Math.min(acc.top, rect.top),
|
|
273
|
+
right: Math.max(acc.right, rect.right),
|
|
274
|
+
bottom: Math.max(acc.bottom, rect.bottom),
|
|
275
|
+
}),
|
|
276
|
+
{
|
|
277
|
+
left: Number.POSITIVE_INFINITY,
|
|
278
|
+
top: Number.POSITIVE_INFINITY,
|
|
279
|
+
right: Number.NEGATIVE_INFINITY,
|
|
280
|
+
bottom: Number.NEGATIVE_INFINITY,
|
|
281
|
+
},
|
|
282
|
+
);
|
|
283
|
+
return toRect({
|
|
284
|
+
...union,
|
|
285
|
+
width: union.right - union.left,
|
|
286
|
+
height: union.bottom - union.top,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function visibleTextClientRects(element, directOnly) {
|
|
291
|
+
// Range rects stay geometrically present outside an overflow clip. Reduce
|
|
292
|
+
// them in viewport coordinates so overlap measures only paintable text.
|
|
293
|
+
let rects = textClientRects(element, directOnly).map(toRect);
|
|
294
|
+
for (
|
|
295
|
+
let ancestor = element.parentElement;
|
|
296
|
+
ancestor && rects.length > 0;
|
|
297
|
+
ancestor = ancestor.parentElement
|
|
298
|
+
) {
|
|
299
|
+
const style = getComputedStyle(ancestor);
|
|
300
|
+
const clipX = clipsOverflowValue(style.overflowX || style.overflow);
|
|
301
|
+
const clipY = clipsOverflowValue(style.overflowY || style.overflow);
|
|
302
|
+
if (!clipX && !clipY) continue;
|
|
303
|
+
const clip = toRect(ancestor.getBoundingClientRect());
|
|
304
|
+
rects = rects
|
|
305
|
+
.map((rect) => {
|
|
306
|
+
const left = clipX ? Math.max(rect.left, clip.left) : rect.left;
|
|
307
|
+
const right = clipX ? Math.min(rect.right, clip.right) : rect.right;
|
|
308
|
+
const top = clipY ? Math.max(rect.top, clip.top) : rect.top;
|
|
309
|
+
const bottom = clipY ? Math.min(rect.bottom, clip.bottom) : rect.bottom;
|
|
310
|
+
if (right - left <= 0.5 || bottom - top <= 0.5) return null;
|
|
311
|
+
return toRect({ left, right, top, bottom, width: right - left, height: bottom - top });
|
|
312
|
+
})
|
|
313
|
+
.filter(Boolean);
|
|
314
|
+
}
|
|
315
|
+
return rects;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function textRectFor(element, directOnly) {
|
|
319
|
+
return unionRects(textClientRects(element, directOnly));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parsePx(value) {
|
|
323
|
+
const parsed = Number.parseFloat(value);
|
|
324
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function hasMeaningfulBoxStyle(style) {
|
|
328
|
+
return (
|
|
329
|
+
parsePx(style.paddingTop) +
|
|
330
|
+
parsePx(style.paddingRight) +
|
|
331
|
+
parsePx(style.paddingBottom) +
|
|
332
|
+
parsePx(style.paddingLeft) +
|
|
333
|
+
parsePx(style.borderTopWidth) +
|
|
334
|
+
parsePx(style.borderRightWidth) +
|
|
335
|
+
parsePx(style.borderBottomWidth) +
|
|
336
|
+
parsePx(style.borderLeftWidth) +
|
|
337
|
+
parsePx(style.borderTopLeftRadius) +
|
|
338
|
+
parsePx(style.borderTopRightRadius) +
|
|
339
|
+
parsePx(style.borderBottomRightRadius) +
|
|
340
|
+
parsePx(style.borderBottomLeftRadius) >
|
|
341
|
+
0
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function hasPaint(style) {
|
|
346
|
+
const backgroundColor = style.backgroundColor || "";
|
|
347
|
+
const hasBackground =
|
|
348
|
+
backgroundColor !== "" &&
|
|
349
|
+
backgroundColor !== "transparent" &&
|
|
350
|
+
!backgroundColor.endsWith(", 0)") &&
|
|
351
|
+
backgroundColor !== "rgba(0, 0, 0, 0)";
|
|
352
|
+
const hasImage = style.backgroundImage && style.backgroundImage !== "none";
|
|
353
|
+
const hasBorder =
|
|
354
|
+
parsePx(style.borderTopWidth) +
|
|
355
|
+
parsePx(style.borderRightWidth) +
|
|
356
|
+
parsePx(style.borderBottomWidth) +
|
|
357
|
+
parsePx(style.borderLeftWidth) >
|
|
358
|
+
0;
|
|
359
|
+
const hasRadius =
|
|
360
|
+
parsePx(style.borderTopLeftRadius) +
|
|
361
|
+
parsePx(style.borderTopRightRadius) +
|
|
362
|
+
parsePx(style.borderBottomRightRadius) +
|
|
363
|
+
parsePx(style.borderBottomLeftRadius) >
|
|
364
|
+
0;
|
|
365
|
+
return hasBackground || hasImage || hasBorder || hasRadius;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function clipsOverflowValue(value) {
|
|
369
|
+
return value && value !== "visible" && value !== "clip visible";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function clipsOverflow(style) {
|
|
373
|
+
return [style.overflowX, style.overflowY, style.overflow].some((value) =>
|
|
374
|
+
clipsOverflowValue(value),
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function rootRectFor(root) {
|
|
379
|
+
const measured = toRect(root.getBoundingClientRect());
|
|
380
|
+
const authoredWidth = Number.parseFloat(root.getAttribute("data-width") || "");
|
|
381
|
+
const authoredHeight = Number.parseFloat(root.getAttribute("data-height") || "");
|
|
382
|
+
const hasAuthoredSize =
|
|
383
|
+
Number.isFinite(authoredWidth) &&
|
|
384
|
+
authoredWidth > 0 &&
|
|
385
|
+
Number.isFinite(authoredHeight) &&
|
|
386
|
+
authoredHeight > 0;
|
|
387
|
+
|
|
388
|
+
if (!hasAuthoredSize) return measured;
|
|
389
|
+
if (measured.width > 0.5 && measured.height > 0.5) return measured;
|
|
390
|
+
return rectFromOrigin(measured.left, measured.top, authoredWidth, authoredHeight);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function isConstraintCandidate(element, root, rootRect) {
|
|
394
|
+
if (element === root) return true;
|
|
395
|
+
const style = getComputedStyle(element);
|
|
396
|
+
if (clipsOverflow(style)) return true;
|
|
397
|
+
if (element.hasAttribute("data-layout-boundary")) return true;
|
|
398
|
+
if (!hasPaint(style)) return false;
|
|
399
|
+
if (!hasMeaningfulBoxStyle(style)) return false;
|
|
400
|
+
const rect = element.getBoundingClientRect();
|
|
401
|
+
const rootArea = rootRect.width * rootRect.height;
|
|
402
|
+
const area = rect.width * rect.height;
|
|
403
|
+
return area > 0 && area < rootArea * 0.95;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function nearestConstraint(element, root, rootRect) {
|
|
407
|
+
for (
|
|
408
|
+
let current = element;
|
|
409
|
+
current && current !== document.body;
|
|
410
|
+
current = current.parentElement
|
|
411
|
+
) {
|
|
412
|
+
if (!isVisibleElement(current)) continue;
|
|
413
|
+
if (isConstraintCandidate(current, root, rootRect)) return current;
|
|
414
|
+
if (current === root) return current;
|
|
415
|
+
}
|
|
416
|
+
return root;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function formatPx(value) {
|
|
420
|
+
return `${Math.round(value)}px`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function maxOverflow(overflow) {
|
|
424
|
+
return Math.max(...Object.values(overflow).filter((value) => typeof value === "number"));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function textOverflowFixHint(textRect, containerRect, overflow, fontSize, targetName) {
|
|
428
|
+
const horizontalOverflow = (overflow.left || 0) + (overflow.right || 0);
|
|
429
|
+
const verticalOverflow = (overflow.top || 0) + (overflow.bottom || 0);
|
|
430
|
+
const neededWidth = containerRect.width + horizontalOverflow;
|
|
431
|
+
const neededHeight = containerRect.height + verticalOverflow;
|
|
432
|
+
const widthRatio = containerRect.width > 0 ? containerRect.width / textRect.width : 0;
|
|
433
|
+
const heightRatio = containerRect.height > 0 ? containerRect.height / textRect.height : 0;
|
|
434
|
+
const limitingRatio = Math.min(
|
|
435
|
+
widthRatio > 0 ? widthRatio : Number.POSITIVE_INFINITY,
|
|
436
|
+
heightRatio > 0 ? heightRatio : Number.POSITIVE_INFINITY,
|
|
437
|
+
);
|
|
438
|
+
const shrinkPercent =
|
|
439
|
+
Number.isFinite(limitingRatio) && limitingRatio < 1
|
|
440
|
+
? Math.ceil((1 - limitingRatio) * 100)
|
|
441
|
+
: 0;
|
|
442
|
+
const targetFont =
|
|
443
|
+
shrinkPercent > 0 && Number.isFinite(fontSize) && fontSize > 0
|
|
444
|
+
? ` or shrink font-size from ${formatPx(fontSize)} to ~${formatPx(fontSize * limitingRatio)}`
|
|
445
|
+
: "";
|
|
446
|
+
const sizeTarget =
|
|
447
|
+
horizontalOverflow > 0 && verticalOverflow > 0
|
|
448
|
+
? `resize ${targetName} to at least ~${formatPx(neededWidth)} x ${formatPx(neededHeight)}`
|
|
449
|
+
: horizontalOverflow > 0
|
|
450
|
+
? `widen ${targetName} to at least ~${formatPx(neededWidth)}`
|
|
451
|
+
: `increase ${targetName} height to at least ~${formatPx(neededHeight)}`;
|
|
452
|
+
|
|
453
|
+
return `Text is ${formatPx(textRect.width)} x ${formatPx(textRect.height)} inside ${formatPx(containerRect.width)} x ${formatPx(containerRect.height)} and overflows by up to ${formatPx(maxOverflow(overflow))}; ${sizeTarget}${targetFont}, or allow wrapping with max-width/fitTextFontSize.`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function clippedTextIssue(element, time, tolerance) {
|
|
457
|
+
if (hasTextClipOptOut(element)) return null;
|
|
458
|
+
const style = getComputedStyle(element);
|
|
459
|
+
if (!clipsOverflow(style)) return null;
|
|
460
|
+
const overflowX = element.scrollWidth - element.clientWidth;
|
|
461
|
+
const overflowY = element.scrollHeight - element.clientHeight;
|
|
462
|
+
if (overflowX <= tolerance && overflowY <= tolerance) return null;
|
|
463
|
+
const overflow = {};
|
|
464
|
+
if (overflowX > tolerance) overflow.right = round(overflowX);
|
|
465
|
+
if (overflowY > tolerance) overflow.bottom = round(overflowY);
|
|
466
|
+
const selector = uniqueSelectorFor(element);
|
|
467
|
+
const text = textContentFor(element);
|
|
468
|
+
const rect = toRect(element.getBoundingClientRect());
|
|
469
|
+
const fontSize = parsePx(style.fontSize);
|
|
470
|
+
return {
|
|
471
|
+
code: "clipped_text",
|
|
472
|
+
severity: "error",
|
|
473
|
+
time,
|
|
474
|
+
selector,
|
|
475
|
+
text,
|
|
476
|
+
message: "Text content is clipped by its own box.",
|
|
477
|
+
rect,
|
|
478
|
+
overflow,
|
|
479
|
+
fixHint: textOverflowFixHint(rect, rect, overflow, fontSize, "the text box"),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function capitalizeWords(text) {
|
|
484
|
+
const words = new Intl.Segmenter(undefined, { granularity: "word" }).segment(text);
|
|
485
|
+
return Array.from(words, ({ segment, isWordLike }) => {
|
|
486
|
+
if (!isWordLike) return segment;
|
|
487
|
+
const [first, ...rest] = segment;
|
|
488
|
+
return first.toUpperCase() + rest.join("");
|
|
489
|
+
}).join("");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function horizontalTextMetrics(element, style) {
|
|
493
|
+
if (style.writingMode && style.writingMode !== "horizontal-tb") return null;
|
|
494
|
+
const context = document.createElement("canvas").getContext("2d");
|
|
495
|
+
if (!context) return null;
|
|
496
|
+
context.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
|
|
497
|
+
let text = textContentFor(element, true);
|
|
498
|
+
if (style.textTransform === "uppercase") text = text.toUpperCase();
|
|
499
|
+
if (style.textTransform === "lowercase") text = text.toLowerCase();
|
|
500
|
+
if (style.textTransform === "capitalize") text = capitalizeWords(text);
|
|
501
|
+
const metrics = context.measureText(text);
|
|
502
|
+
return metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent > 0 ? metrics : null;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function intersectsTextWindow(rect, clip, tolerance) {
|
|
506
|
+
return (
|
|
507
|
+
Math.min(rect.right, clip.right) - Math.max(rect.left, clip.left) > tolerance &&
|
|
508
|
+
Math.min(rect.bottom, clip.bottom) - Math.max(rect.top, clip.top) > tolerance
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// The glyphs' own extent inside a Range rect (the font's content area); font metrics scale with the rect.
|
|
513
|
+
function inkRect(rect, metrics) {
|
|
514
|
+
const scale = rect.height / (metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent);
|
|
515
|
+
const top =
|
|
516
|
+
rect.top + (metrics.fontBoundingBoxAscent - metrics.actualBoundingBoxAscent) * scale;
|
|
517
|
+
const bottom =
|
|
518
|
+
rect.bottom - (metrics.fontBoundingBoxDescent - metrics.actualBoundingBoxDescent) * scale;
|
|
519
|
+
return { ...rect, top, bottom, height: bottom - top };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function visibleTextLineRects(element, rects, style, clip, tolerance) {
|
|
523
|
+
const metrics = horizontalTextMetrics(element, style);
|
|
524
|
+
const fontHeight = metrics ? metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent : 0;
|
|
525
|
+
const lineHeight = parsePx(style.lineHeight) || fontHeight;
|
|
526
|
+
return rects.flatMap((rect) => {
|
|
527
|
+
if (!metrics) return intersectsTextWindow(rect, clip, tolerance) ? [rect] : [];
|
|
528
|
+
const scale = rect.height / fontHeight;
|
|
529
|
+
if (!intersectsTextWindow(inkRect(rect, metrics), clip, tolerance)) return [];
|
|
530
|
+
// Negative leading belongs outside the used line box. Font metrics scale
|
|
531
|
+
// with the Range rect, so zoomed cards retain the same clipping decision.
|
|
532
|
+
const inset = Math.max(0, (rect.height - lineHeight * scale) / 2);
|
|
533
|
+
return [
|
|
534
|
+
toRect({
|
|
535
|
+
...rect,
|
|
536
|
+
top: rect.top + inset,
|
|
537
|
+
bottom: rect.bottom - inset,
|
|
538
|
+
height: rect.height - 2 * inset,
|
|
539
|
+
}),
|
|
540
|
+
];
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function textOverflowIssues(element, root, rootRect, time, tolerance, clippedIssue) {
|
|
545
|
+
const lineRects = textClientRects(element, true).map(toRect);
|
|
546
|
+
const textRect = unionRects(lineRects);
|
|
547
|
+
if (!textRect) return [];
|
|
548
|
+
const text = textContentFor(element, true);
|
|
549
|
+
const selector = uniqueSelectorFor(element);
|
|
550
|
+
const issues = [];
|
|
551
|
+
|
|
552
|
+
const container = nearestConstraint(element, root, rootRect);
|
|
553
|
+
const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect());
|
|
554
|
+
const elementStyle = getComputedStyle(element);
|
|
555
|
+
const containerClips = clipsOverflow(
|
|
556
|
+
container === root ? getComputedStyle(root) : getComputedStyle(container),
|
|
557
|
+
);
|
|
558
|
+
const visibleTextRect = containerClips
|
|
559
|
+
? unionRects(visibleTextLineRects(element, lineRects, elementStyle, containerRect, tolerance))
|
|
560
|
+
: textRect;
|
|
561
|
+
const verticalTolerance = containerClips
|
|
562
|
+
? tolerance
|
|
563
|
+
: Math.max(tolerance, parsePx(elementStyle.fontSize) * 0.2);
|
|
564
|
+
const containerOverflow = visibleTextRect
|
|
565
|
+
? overflowFor(visibleTextRect, containerRect, tolerance, verticalTolerance)
|
|
566
|
+
: null;
|
|
567
|
+
const billedAsClippedText =
|
|
568
|
+
container === element &&
|
|
569
|
+
clippedIssue != null &&
|
|
570
|
+
containerOverflow != null &&
|
|
571
|
+
containerOverflow.left == null &&
|
|
572
|
+
containerOverflow.top == null;
|
|
573
|
+
if (containerOverflow && !billedAsClippedText && !hasTextClipOptOut(element)) {
|
|
574
|
+
const style = elementStyle;
|
|
575
|
+
issues.push({
|
|
576
|
+
code: "text_box_overflow",
|
|
577
|
+
severity: "error",
|
|
578
|
+
time,
|
|
579
|
+
selector,
|
|
580
|
+
containerSelector: uniqueSelectorFor(container),
|
|
581
|
+
text,
|
|
582
|
+
message: "Text extends outside its nearest visual/container box.",
|
|
583
|
+
rect: visibleTextRect,
|
|
584
|
+
containerRect,
|
|
585
|
+
overflow: containerOverflow,
|
|
586
|
+
fixHint: textOverflowFixHint(
|
|
587
|
+
visibleTextRect,
|
|
588
|
+
containerRect,
|
|
589
|
+
containerOverflow,
|
|
590
|
+
parsePx(style.fontSize),
|
|
591
|
+
"the container",
|
|
592
|
+
),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const canvasOverflow = overflowFor(textRect, rootRect, tolerance);
|
|
597
|
+
if (canvasOverflow && !hasTextClipOptOut(element)) {
|
|
598
|
+
issues.push({
|
|
599
|
+
code: "canvas_overflow",
|
|
600
|
+
severity: "info",
|
|
601
|
+
time,
|
|
602
|
+
selector,
|
|
603
|
+
containerSelector: uniqueSelectorFor(root),
|
|
604
|
+
text,
|
|
605
|
+
message: "Text extends outside the composition canvas.",
|
|
606
|
+
rect: textRect,
|
|
607
|
+
containerRect: rootRect,
|
|
608
|
+
overflow: canvasOverflow,
|
|
609
|
+
fixHint:
|
|
610
|
+
"Move the text inward, reduce its size, or mark intentional off-canvas animation with data-layout-allow-overflow.",
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return issues;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function isNowrapTextChild(child) {
|
|
618
|
+
if (!isVisibleElement(child) || hasAllowOverflowFlag(child)) return false;
|
|
619
|
+
if (getComputedStyle(child).whiteSpace !== "nowrap") return false;
|
|
620
|
+
return (child.textContent || "").trim().length > 0;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function hasNowrapTextChild(element) {
|
|
624
|
+
return Array.from(element.children).some(isNowrapTextChild);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function containerOverflowIssues(root, time, tolerance) {
|
|
628
|
+
const issues = [];
|
|
629
|
+
const containers = Array.from(root.querySelectorAll("*")).filter((element) => {
|
|
630
|
+
if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) return false;
|
|
631
|
+
const style = getComputedStyle(element);
|
|
632
|
+
return (
|
|
633
|
+
clipsOverflow(style) ||
|
|
634
|
+
element.hasAttribute("data-layout-boundary") ||
|
|
635
|
+
hasNowrapTextChild(element)
|
|
636
|
+
);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
for (const container of containers) {
|
|
640
|
+
const style = getComputedStyle(container);
|
|
641
|
+
const checksEveryChild =
|
|
642
|
+
clipsOverflow(style) || container.hasAttribute("data-layout-boundary");
|
|
643
|
+
const containerRect = toRect(container.getBoundingClientRect());
|
|
644
|
+
for (const child of Array.from(container.children)) {
|
|
645
|
+
if (!isVisibleElement(child) || hasAllowOverflowFlag(child)) continue;
|
|
646
|
+
if (!checksEveryChild && !isNowrapTextChild(child)) continue;
|
|
647
|
+
const childRect = toRect(child.getBoundingClientRect());
|
|
648
|
+
const overflow = checksEveryChild
|
|
649
|
+
? overflowFor(childRect, containerRect, tolerance)
|
|
650
|
+
: horizontalOverflow(childRect, containerRect, tolerance);
|
|
651
|
+
if (!overflow) continue;
|
|
652
|
+
issues.push({
|
|
653
|
+
code: "container_overflow",
|
|
654
|
+
severity: "warning",
|
|
655
|
+
time,
|
|
656
|
+
selector: uniqueSelectorFor(child),
|
|
657
|
+
containerSelector: uniqueSelectorFor(container),
|
|
658
|
+
message: checksEveryChild
|
|
659
|
+
? "Element extends outside a clipping layout container."
|
|
660
|
+
: "Nowrap text is wider than its container.",
|
|
661
|
+
rect: childRect,
|
|
662
|
+
containerRect,
|
|
663
|
+
overflow,
|
|
664
|
+
fixHint:
|
|
665
|
+
"Resize/reposition the child or container, or mark intentional overflow with data-layout-allow-overflow.",
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return issues;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function hasAllowOverlapFlag(element) {
|
|
674
|
+
// The marker applies to the element that carries it and to text inside
|
|
675
|
+
// that container. It stops at the composition root: a waiver on the scene
|
|
676
|
+
// itself would exempt every collision in the subtree.
|
|
677
|
+
for (let current = element; current; current = current.parentElement) {
|
|
678
|
+
if (current.hasAttribute("data-composition-id")) return false;
|
|
679
|
+
if (current.hasAttribute("data-layout-allow-overlap")) return true;
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function isTransparentColor(color) {
|
|
685
|
+
return (
|
|
686
|
+
!color || color === "transparent" || color === "rgba(0, 0, 0, 0)" || color.endsWith(", 0)")
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function alphaFromParts(parts, index) {
|
|
691
|
+
if (parts.length <= index) return 1;
|
|
692
|
+
const raw = parts[index].trim();
|
|
693
|
+
return raw.endsWith("%") ? parsePx(raw) / 100 : parsePx(raw);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Alpha of a CSS colour; 1 when no alpha component is present. Handles both
|
|
697
|
+
// legacy `rgba(r, g, b, a)` and modern `rgb(r g b / a)` syntaxes.
|
|
698
|
+
function colorAlpha(color) {
|
|
699
|
+
const match = (color || "").match(/rgba?\(([^)]+)\)/);
|
|
700
|
+
if (!match) return 1;
|
|
701
|
+
const body = match[1];
|
|
702
|
+
return body.includes(",")
|
|
703
|
+
? alphaFromParts(body.split(","), 3)
|
|
704
|
+
: alphaFromParts(body.split("/"), 1);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// A text block competes for space only when it is solid: watermark-style text
|
|
708
|
+
// (low colour alpha) is decorative and exempt, as are elements opted out with
|
|
709
|
+
// data-layout-allow-overlap.
|
|
710
|
+
function isSolidTextBlock(element) {
|
|
711
|
+
if (!isVisibleElement(element) || !hasOwnTextCandidate(element, true)) return false;
|
|
712
|
+
if (hasAllowOverlapFlag(element)) return false;
|
|
713
|
+
return colorAlpha(getComputedStyle(element).color) >= 0.35;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const RUNTIME_HIDDEN_CLIP = "[data-hf-clip-hidden]";
|
|
717
|
+
|
|
718
|
+
// Core's timedClipHide rule hides these clips' whole subtrees: skipping them bounds work by what is on screen.
|
|
719
|
+
function elementsOnScreenScenes(root) {
|
|
720
|
+
const elements = [];
|
|
721
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
|
|
722
|
+
acceptNode: (element) =>
|
|
723
|
+
element.matches(RUNTIME_HIDDEN_CLIP) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT,
|
|
724
|
+
});
|
|
725
|
+
for (let element = walker.nextNode(); element; element = walker.nextNode()) {
|
|
726
|
+
elements.push(element);
|
|
727
|
+
}
|
|
728
|
+
return elements;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function collectSolidTextBlocks(root) {
|
|
732
|
+
const blocks = [];
|
|
733
|
+
for (const element of elementsOnScreenScenes(root)) {
|
|
734
|
+
if (!isSolidTextBlock(element)) continue;
|
|
735
|
+
const rects = visibleTextClientRects(element, true);
|
|
736
|
+
const rect = unionRects(rects);
|
|
737
|
+
if (rect) blocks.push({ element, rect, rects });
|
|
738
|
+
}
|
|
739
|
+
return blocks;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function rectArea(rect) {
|
|
743
|
+
return rect.width * rect.height;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function intersectionArea(a, b) {
|
|
747
|
+
const overlapX = Math.min(a.right, b.right) - Math.max(a.left, b.left);
|
|
748
|
+
const overlapY = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
|
|
749
|
+
return overlapX > 0 && overlapY > 0 ? overlapX * overlapY : 0;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function rectsArea(rects) {
|
|
753
|
+
return rects.reduce((total, rect) => total + rectArea(rect), 0);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function fragmentIntersectionArea(a, b) {
|
|
757
|
+
let total = 0;
|
|
758
|
+
for (const aRect of a) {
|
|
759
|
+
for (const bRect of b) total += intersectionArea(aRect, bRect);
|
|
760
|
+
}
|
|
761
|
+
return total;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function overlapsByAFifth(a, b) {
|
|
765
|
+
return fragmentIntersectionArea(a, b) > Math.min(rectsArea(a), rectsArea(b)) * 0.2;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Collision is judged on the glyphs: at line-height < 1 the content areas of stacked lines overlap
|
|
769
|
+
// while the words do not touch. Measured only for pairs whose content areas already overlap.
|
|
770
|
+
function glyphRects(block) {
|
|
771
|
+
if (!block.glyphRects) {
|
|
772
|
+
const metrics = horizontalTextMetrics(block.element, getComputedStyle(block.element));
|
|
773
|
+
block.glyphRects = metrics ? block.rects.map((rect) => inkRect(rect, metrics)) : block.rects;
|
|
774
|
+
}
|
|
775
|
+
return block.glyphRects;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function isNested(a, b) {
|
|
779
|
+
return a.contains(b) || b.contains(a);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function isInFlow(element) {
|
|
783
|
+
const position = getComputedStyle(element).position;
|
|
784
|
+
return position === "static" || position === "relative" || position === "sticky";
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function nearestFlexGridAncestor(element) {
|
|
788
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
789
|
+
const display = getComputedStyle(parent).display;
|
|
790
|
+
if (display.includes("flex") || display.includes("grid")) return parent;
|
|
791
|
+
}
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Two in-flow text blocks governed by the same flex/grid container are placed
|
|
796
|
+
// by the layout engine, which reserves space for each — they cannot visually
|
|
797
|
+
// collide. Any measured text-rect overlap between them is line-box / leading
|
|
798
|
+
// slop (tight stacks, number lockups, super/subscript units), not a collision.
|
|
799
|
+
// A real overlap bug needs free positioning (absolute/fixed), which keeps a
|
|
800
|
+
// different formatting context and is still flagged.
|
|
801
|
+
function isManagedFlowOverlap(a, b) {
|
|
802
|
+
if (!isInFlow(a) || !isInFlow(b)) return false;
|
|
803
|
+
const container = nearestFlexGridAncestor(a);
|
|
804
|
+
return !!container && container === nearestFlexGridAncestor(b);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Two solid text blocks whose boxes overlap by more than a fifth of the
|
|
808
|
+
// smaller block read as a collision — unreadable, and invisible to the
|
|
809
|
+
// overflow checks, which only compare an element against its container.
|
|
810
|
+
function overlapIssue(a, b, time) {
|
|
811
|
+
if (isNested(a.element, b.element)) return null;
|
|
812
|
+
if (isManagedFlowOverlap(a.element, b.element)) return null;
|
|
813
|
+
if (!overlapsByAFifth(a.rects, b.rects)) return null;
|
|
814
|
+
if (!overlapsByAFifth(glyphRects(a), glyphRects(b))) return null;
|
|
815
|
+
return {
|
|
816
|
+
// Warning at the per-sample level: a single-sample overlap is usually an
|
|
817
|
+
// entrance/exit transient (two blocks crossing mid-animation), not a real
|
|
818
|
+
// collision. `collapseStaticLayoutIssues` (utils/layoutAudit.ts) re-promotes
|
|
819
|
+
// this to error once the SAME overlap is held across >= 2 adjacent samples
|
|
820
|
+
// (or ~500ms of timeline) — a persistence-tiered replacement for the old
|
|
821
|
+
// "re-promote once data-layout-allow-overlap is widely adopted" plan (#U10).
|
|
822
|
+
code: "content_overlap",
|
|
823
|
+
severity: "warning",
|
|
824
|
+
time,
|
|
825
|
+
selector: uniqueSelectorFor(a.element),
|
|
826
|
+
containerSelector: uniqueSelectorFor(b.element),
|
|
827
|
+
text: textContentFor(a.element),
|
|
828
|
+
message: "Two text blocks overlap and may render unreadable.",
|
|
829
|
+
rect: a.rect,
|
|
830
|
+
fixHint:
|
|
831
|
+
"Give each block its own zone, or put data-layout-allow-overlap on the text or on a container inside the scene. A marker on the scene root does not apply.",
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function hiddenVisibilityAncestor(element) {
|
|
836
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
837
|
+
const visibility = getComputedStyle(parent).visibility;
|
|
838
|
+
if (visibility === "hidden" || visibility === "collapse") return parent;
|
|
839
|
+
}
|
|
840
|
+
return null;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function visibilityEscapeIssue(block, hiddenAncestor, time) {
|
|
844
|
+
return {
|
|
845
|
+
code: "visibility_escapes_segment",
|
|
846
|
+
severity: "warning",
|
|
847
|
+
time,
|
|
848
|
+
selector: uniqueSelectorFor(block.element),
|
|
849
|
+
containerSelector: uniqueSelectorFor(hiddenAncestor),
|
|
850
|
+
text: textContentFor(block.element),
|
|
851
|
+
message:
|
|
852
|
+
"This text stays on screen after its segment ends. visibility:visible here overrides an ancestor's visibility:hidden, so the runtime hide does not reach it.",
|
|
853
|
+
rect: block.rect,
|
|
854
|
+
fixHint: "Remove the explicit visibility:visible so this element hides with its ancestor.",
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function contentOverlapIssues(root, time) {
|
|
859
|
+
const blocks = collectSolidTextBlocks(root);
|
|
860
|
+
const issues = [];
|
|
861
|
+
const participating = [];
|
|
862
|
+
for (const block of blocks) {
|
|
863
|
+
const hiddenAncestor = hiddenVisibilityAncestor(block.element);
|
|
864
|
+
if (hiddenAncestor) {
|
|
865
|
+
issues.push(visibilityEscapeIssue(block, hiddenAncestor, time));
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
participating.push(block);
|
|
869
|
+
}
|
|
870
|
+
for (let i = 0; i < participating.length; i++) {
|
|
871
|
+
for (let j = i + 1; j < participating.length; j++) {
|
|
872
|
+
const issue = overlapIssue(participating[i], participating[j], time);
|
|
873
|
+
if (issue) issues.push(issue);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return issues;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function hasOpaqueBackground(style) {
|
|
880
|
+
let imageAlpha = 0;
|
|
881
|
+
if (style.backgroundImage && style.backgroundImage !== "none") {
|
|
882
|
+
if (style.backgroundImage.includes("url(")) return true;
|
|
883
|
+
// A gradient only occludes as much as its colours — a 4%-alpha grid/scrim
|
|
884
|
+
// must not count, and neither must a solid-color dot a couple of pixels
|
|
885
|
+
// wide tiled across a much larger background-size.
|
|
886
|
+
imageAlpha = gradientLayersAlpha(style.backgroundImage, style.backgroundSize);
|
|
887
|
+
}
|
|
888
|
+
const colorValue = isTransparentColor(style.backgroundColor)
|
|
889
|
+
? 0
|
|
890
|
+
: colorAlpha(style.backgroundColor);
|
|
891
|
+
// Layers composite: a 0.5 gradient over a 0.5 background colour paints at ~0.75.
|
|
892
|
+
return 1 - (1 - imageAlpha) * (1 - colorValue) > 0.6;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// background-image layers stack: two 0.5-alpha gradients paint at 1-(1-.5)^2 = .75.
|
|
896
|
+
function gradientLayersAlpha(backgroundImage, backgroundSize) {
|
|
897
|
+
let combined = 0;
|
|
898
|
+
for (const layer of splitTopLevelCommas(backgroundImage)) {
|
|
899
|
+
const coverage = gradientStopCoverage(layer, backgroundSize);
|
|
900
|
+
combined = 1 - (1 - combined) * (1 - gradientMaxAlpha(layer) * coverage);
|
|
901
|
+
}
|
|
902
|
+
return combined;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Share of a radial dot's tile. A linear fill, or an opaque stop after a
|
|
906
|
+
// transparent one, stays fully painted. A 1.6px dot on a 38px tile does not.
|
|
907
|
+
function gradientStopCoverage(layer, backgroundSize) {
|
|
908
|
+
if (!/radial-gradient/i.test(layer) || namesAColor(withoutScoredColors(layer))) return 1;
|
|
909
|
+
const stops = [
|
|
910
|
+
...layer.matchAll(/(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|transparent)\s+([\d.]+)(px|%)/gi),
|
|
911
|
+
];
|
|
912
|
+
const tile = pxTile(backgroundSize);
|
|
913
|
+
const reach = tile ? leadingOpaqueRadius(stops, tile) : null;
|
|
914
|
+
if (reach === null) return 1;
|
|
915
|
+
return Math.min(1, (Math.PI * reach * reach) / (tile * tile));
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function pxTile(backgroundSize) {
|
|
919
|
+
const sizeMatch = String(backgroundSize || "").match(/([\d.]+)px/);
|
|
920
|
+
return sizeMatch ? Number(sizeMatch[1]) : 0;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function leadingOpaqueRadius(stops, tile) {
|
|
924
|
+
const walked = walkOpaquePrefix(stops, tile);
|
|
925
|
+
if (!walked || !walked.sawOpaque || !walked.sawTransparent) return null;
|
|
926
|
+
return walked.reach;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function walkOpaquePrefix(stops, tile) {
|
|
930
|
+
const state = { reach: 0, sawOpaque: false, sawTransparent: false };
|
|
931
|
+
for (const stop of stops) {
|
|
932
|
+
if (!takeRadiusStop(state, stop, tile)) return null;
|
|
933
|
+
}
|
|
934
|
+
return state;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function takeRadiusStop(state, stop, tile) {
|
|
938
|
+
if (stopAlpha(stop[1] || "") <= 0.6) {
|
|
939
|
+
state.sawTransparent = true;
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
942
|
+
if (state.sawTransparent) return false;
|
|
943
|
+
state.sawOpaque = true;
|
|
944
|
+
state.reach = Math.max(state.reach, stopLength(stop, tile));
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function stopAlpha(color) {
|
|
949
|
+
if (color === "transparent") return 0;
|
|
950
|
+
if (color.startsWith("#")) return 1;
|
|
951
|
+
return colorAlpha(color);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function stopLength(stop, tile) {
|
|
955
|
+
return stop[3] === "%" ? (Number(stop[2]) / 100) * tile : Number(stop[2]);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function splitTopLevelCommas(value) {
|
|
959
|
+
const parts = [];
|
|
960
|
+
let depth = 0;
|
|
961
|
+
let start = 0;
|
|
962
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
963
|
+
const ch = value[i];
|
|
964
|
+
if (ch === "(") depth += 1;
|
|
965
|
+
else if (ch === ")") depth -= 1;
|
|
966
|
+
else if (ch === "," && depth === 0) {
|
|
967
|
+
parts.push(value.slice(start, i));
|
|
968
|
+
start = i + 1;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
parts.push(value.slice(start));
|
|
972
|
+
return parts;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function gradientMaxAlpha(backgroundImage) {
|
|
976
|
+
// Any colour we cannot score (oklch/lab/named-colour fns/...) counts as opaque so real panels keep flagging.
|
|
977
|
+
const known = backgroundImage
|
|
978
|
+
.replace(/(?:repeating-)?(?:linear|radial|conic)-gradient\(/gi, "(")
|
|
979
|
+
.replace(/rgba?\([^)]*\)/gi, "");
|
|
980
|
+
if (/[a-z][a-z-]+\(/i.test(known)) return 1;
|
|
981
|
+
if (namesAColor(known)) return 1;
|
|
982
|
+
const colors = backgroundImage.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\btransparent\b/g);
|
|
983
|
+
if (!colors) return 1;
|
|
984
|
+
let max = 0;
|
|
985
|
+
for (const color of colors) {
|
|
986
|
+
if (color === "transparent") continue;
|
|
987
|
+
if (color.startsWith("#")) {
|
|
988
|
+
const hex = color.slice(1);
|
|
989
|
+
max = Math.max(
|
|
990
|
+
max,
|
|
991
|
+
hex.length === 4
|
|
992
|
+
? parseInt(hex[3] + hex[3], 16) / 255
|
|
993
|
+
: hex.length === 8
|
|
994
|
+
? parseInt(hex.slice(6), 16) / 255
|
|
995
|
+
: 1,
|
|
996
|
+
);
|
|
997
|
+
} else {
|
|
998
|
+
max = Math.max(max, colorAlpha(color));
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return max;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function withoutScoredColors(layer) {
|
|
1005
|
+
return layer.replace(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b/g, " ");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function namesAColor(known) {
|
|
1009
|
+
const words = known.replace(
|
|
1010
|
+
/\b(?:to|top|bottom|left|right|center|circle|ellipse|closest-side|farthest-side|closest-corner|farthest-corner|from|deg|rad|turn|grad|transparent|repeating|linear|radial|conic|gradient)\b/gi,
|
|
1011
|
+
" ",
|
|
1012
|
+
);
|
|
1013
|
+
return /\b[a-z]{3,}\b/i.test(words);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
|
|
1017
|
+
const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
|
|
1018
|
+
const imageAlphaCanvases = new WeakMap();
|
|
1019
|
+
|
|
1020
|
+
function objectPositionOffset(value, freeSpace) {
|
|
1021
|
+
const token = String(value || "50%")
|
|
1022
|
+
.trim()
|
|
1023
|
+
.split(/\s+/)[0];
|
|
1024
|
+
if (token === "left" || token === "top") return 0;
|
|
1025
|
+
if (token === "right" || token === "bottom") return freeSpace;
|
|
1026
|
+
if (token === "center") return freeSpace / 2;
|
|
1027
|
+
if (token.endsWith("%")) return (freeSpace * parseFloat(token)) / 100;
|
|
1028
|
+
const pixels = parseFloat(token);
|
|
1029
|
+
return Number.isFinite(pixels) ? pixels : freeSpace / 2;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function objectPositionOffsets(value, freeX, freeY) {
|
|
1033
|
+
const tokens = String(value || "50% 50%")
|
|
1034
|
+
.trim()
|
|
1035
|
+
.split(/\s+/)
|
|
1036
|
+
.slice(0, 2);
|
|
1037
|
+
let x = "50%";
|
|
1038
|
+
let y = "50%";
|
|
1039
|
+
if (tokens.length === 1) {
|
|
1040
|
+
if (tokens[0] === "top" || tokens[0] === "bottom") y = tokens[0];
|
|
1041
|
+
else x = tokens[0];
|
|
1042
|
+
} else {
|
|
1043
|
+
for (const token of tokens) {
|
|
1044
|
+
if (token === "top" || token === "bottom") y = token;
|
|
1045
|
+
else if (token === "left" || token === "right") x = token;
|
|
1046
|
+
else if (x === "50%") x = token;
|
|
1047
|
+
else y = token;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return { x: objectPositionOffset(x, freeX), y: objectPositionOffset(y, freeY) };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const IDENTITY_FLIP = { flipX: false, flipY: false };
|
|
1054
|
+
|
|
1055
|
+
// 2D CSS matrix components [a, b, c, d], or null when the transform is 3D or unparsed.
|
|
1056
|
+
function transformMatrix(value) {
|
|
1057
|
+
if (!value || value === "none") return [1, 0, 0, 1];
|
|
1058
|
+
const match = /^matrix\(([^)]*)\)$/.exec(value.trim());
|
|
1059
|
+
if (!match) return null;
|
|
1060
|
+
const parts = match[1].split(",").map(Number);
|
|
1061
|
+
return parts.length === 6 && parts.every(Number.isFinite) ? parts.slice(0, 4) : null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Which axes the element's paint is mirrored on, or null when a rotation, skew or 3D transform
|
|
1065
|
+
// (on it or an ancestor) means its bounding box no longer maps axis-aligned onto its pixels.
|
|
1066
|
+
function paintFlips(element) {
|
|
1067
|
+
let flipX = false;
|
|
1068
|
+
let flipY = false;
|
|
1069
|
+
for (let current = element; current; current = current.parentElement) {
|
|
1070
|
+
const style = getComputedStyle(current);
|
|
1071
|
+
const matrix = transformMatrix(style.transform);
|
|
1072
|
+
if (!matrix) return null;
|
|
1073
|
+
const [a, b, c, d] = matrix;
|
|
1074
|
+
if (Math.abs(b) > 1e-6 || Math.abs(c) > 1e-6) return null;
|
|
1075
|
+
if (style.rotate && style.rotate !== "none" && parseFloat(style.rotate) !== 0) return null;
|
|
1076
|
+
const scale = String(style.scale || "none")
|
|
1077
|
+
.trim()
|
|
1078
|
+
.split(/\s+/)
|
|
1079
|
+
.map(Number);
|
|
1080
|
+
const scaleX = Number.isFinite(scale[0]) ? scale[0] : 1;
|
|
1081
|
+
const scaleY = Number.isFinite(scale[1]) ? scale[1] : scaleX;
|
|
1082
|
+
if (a * scaleX < 0) flipX = !flipX;
|
|
1083
|
+
if (d * scaleY < 0) flipY = !flipY;
|
|
1084
|
+
}
|
|
1085
|
+
return flipX || flipY ? { flipX, flipY } : IDENTITY_FLIP;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
function paintedSourceMapping(element, sourceWidth, sourceHeight) {
|
|
1089
|
+
const rect = element.getBoundingClientRect();
|
|
1090
|
+
if (!sourceWidth || !sourceHeight || !rect.width || !rect.height) return null;
|
|
1091
|
+
const flips = paintFlips(element);
|
|
1092
|
+
if (!flips) return null;
|
|
1093
|
+
|
|
1094
|
+
const style = getComputedStyle(element);
|
|
1095
|
+
const fit = style.objectFit || "fill";
|
|
1096
|
+
let scaleX = rect.width / sourceWidth;
|
|
1097
|
+
let scaleY = rect.height / sourceHeight;
|
|
1098
|
+
if (fit !== "fill") {
|
|
1099
|
+
const contain = Math.min(scaleX, scaleY);
|
|
1100
|
+
const cover = Math.max(scaleX, scaleY);
|
|
1101
|
+
const scale =
|
|
1102
|
+
fit === "cover"
|
|
1103
|
+
? cover
|
|
1104
|
+
: fit === "none"
|
|
1105
|
+
? 1
|
|
1106
|
+
: fit === "scale-down"
|
|
1107
|
+
? Math.min(1, contain)
|
|
1108
|
+
: contain;
|
|
1109
|
+
scaleX = scale;
|
|
1110
|
+
scaleY = scale;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const paintedWidth = sourceWidth * scaleX;
|
|
1114
|
+
const paintedHeight = sourceHeight * scaleY;
|
|
1115
|
+
const offsets = objectPositionOffsets(
|
|
1116
|
+
style.objectPosition,
|
|
1117
|
+
rect.width - paintedWidth,
|
|
1118
|
+
rect.height - paintedHeight,
|
|
1119
|
+
);
|
|
1120
|
+
return {
|
|
1121
|
+
rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
|
|
1122
|
+
offsets,
|
|
1123
|
+
...flips,
|
|
1124
|
+
paintedWidth,
|
|
1125
|
+
paintedHeight,
|
|
1126
|
+
scaleX,
|
|
1127
|
+
scaleY,
|
|
1128
|
+
sourceWidth,
|
|
1129
|
+
sourceHeight,
|
|
1130
|
+
};
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// The source pixel painted at a viewport point, or null outside the painted content box.
|
|
1134
|
+
function sourcePixelAt(mapping, x, y) {
|
|
1135
|
+
const { rect, offsets } = mapping;
|
|
1136
|
+
const localX = (mapping.flipX ? rect.right - x : x - rect.left) - offsets.x;
|
|
1137
|
+
const localY = (mapping.flipY ? rect.bottom - y : y - rect.top) - offsets.y;
|
|
1138
|
+
if (
|
|
1139
|
+
localX < 0 ||
|
|
1140
|
+
localY < 0 ||
|
|
1141
|
+
localX >= mapping.paintedWidth ||
|
|
1142
|
+
localY >= mapping.paintedHeight
|
|
1143
|
+
) {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
return {
|
|
1147
|
+
x: Math.min(mapping.sourceWidth - 1, Math.max(0, Math.floor(localX / mapping.scaleX))),
|
|
1148
|
+
y: Math.min(mapping.sourceHeight - 1, Math.max(0, Math.floor(localY / mapping.scaleY))),
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Return the alpha painted by an <img> at a viewport point. `null` means the
|
|
1153
|
+
// browser would not let us inspect the image (not loaded or cross-origin), in
|
|
1154
|
+
// which case callers preserve the conservative opaque fallback.
|
|
1155
|
+
function imageAlphaAt(element, x, y) {
|
|
1156
|
+
const sourceWidth = element.naturalWidth;
|
|
1157
|
+
const sourceHeight = element.naturalHeight;
|
|
1158
|
+
const mapping = paintedSourceMapping(element, sourceWidth, sourceHeight);
|
|
1159
|
+
if (!mapping) return null;
|
|
1160
|
+
const pixel = sourcePixelAt(mapping, x, y);
|
|
1161
|
+
if (!pixel) return 0;
|
|
1162
|
+
|
|
1163
|
+
try {
|
|
1164
|
+
let cached = imageAlphaCanvases.get(element);
|
|
1165
|
+
const source = element.currentSrc || element.src;
|
|
1166
|
+
if (
|
|
1167
|
+
!cached ||
|
|
1168
|
+
cached.width !== sourceWidth ||
|
|
1169
|
+
cached.height !== sourceHeight ||
|
|
1170
|
+
cached.source !== source
|
|
1171
|
+
) {
|
|
1172
|
+
const canvas = document.createElement("canvas");
|
|
1173
|
+
canvas.width = sourceWidth;
|
|
1174
|
+
canvas.height = sourceHeight;
|
|
1175
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
1176
|
+
if (!context) return null;
|
|
1177
|
+
context.drawImage(element, 0, 0, sourceWidth, sourceHeight);
|
|
1178
|
+
cached = { context, width: sourceWidth, height: sourceHeight, source };
|
|
1179
|
+
imageAlphaCanvases.set(element, cached);
|
|
1180
|
+
}
|
|
1181
|
+
return cached.context.getImageData(pixel.x, pixel.y, 1, 1).data[3] / 255;
|
|
1182
|
+
} catch {
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
const OCCLUDING_ALPHA = 0.6;
|
|
1188
|
+
const VIDEO_ALPHA_MAX_SIDE = 480;
|
|
1189
|
+
let videoAlphaFrames = new Map();
|
|
1190
|
+
let videoAlphaScratch = null;
|
|
1191
|
+
|
|
1192
|
+
function videoAlphaContext(width, height) {
|
|
1193
|
+
if (!videoAlphaScratch) {
|
|
1194
|
+
const canvas = document.createElement("canvas");
|
|
1195
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
1196
|
+
if (!context) return null;
|
|
1197
|
+
videoAlphaScratch = { canvas, context };
|
|
1198
|
+
}
|
|
1199
|
+
videoAlphaScratch.canvas.width = width;
|
|
1200
|
+
videoAlphaScratch.canvas.height = height;
|
|
1201
|
+
return videoAlphaScratch.context;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Read once per audit call, so per sample time, and only for a video some text probe lands under.
|
|
1205
|
+
function videoAlphaFrame(video) {
|
|
1206
|
+
let frame = videoAlphaFrames.get(video);
|
|
1207
|
+
if (!frame) {
|
|
1208
|
+
frame = readVideoAlphaFrame(video);
|
|
1209
|
+
videoAlphaFrames.set(video, frame);
|
|
1210
|
+
}
|
|
1211
|
+
return frame;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
function unreadVideoFrame(reason) {
|
|
1215
|
+
return { kind: "unread", reason };
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// "opaque" keeps a video's whole box occluding; "matte" judges each point by the decoded
|
|
1219
|
+
// frame's alpha; "unread" falls back to opaque and is reported, never guessed.
|
|
1220
|
+
function readVideoAlphaFrame(video) {
|
|
1221
|
+
if (video.seeking) return unreadVideoFrame("the seek had not landed");
|
|
1222
|
+
if (video.readyState < 2) return unreadVideoFrame("no frame was decoded at this time");
|
|
1223
|
+
if (!video.videoWidth || !video.videoHeight)
|
|
1224
|
+
return unreadVideoFrame("the video has no frame size");
|
|
1225
|
+
const mapping = paintedSourceMapping(video, video.videoWidth, video.videoHeight);
|
|
1226
|
+
if (!mapping) return unreadVideoFrame("it is rotated, skewed or transformed in 3D");
|
|
1227
|
+
const downscale = Math.min(
|
|
1228
|
+
1,
|
|
1229
|
+
VIDEO_ALPHA_MAX_SIDE / Math.max(mapping.sourceWidth, mapping.sourceHeight),
|
|
1230
|
+
);
|
|
1231
|
+
const width = Math.max(1, Math.round(mapping.sourceWidth * downscale));
|
|
1232
|
+
const height = Math.max(1, Math.round(mapping.sourceHeight * downscale));
|
|
1233
|
+
let rgba;
|
|
1234
|
+
try {
|
|
1235
|
+
const context = videoAlphaContext(width, height);
|
|
1236
|
+
if (!context) return unreadVideoFrame("no 2D canvas was available");
|
|
1237
|
+
context.clearRect(0, 0, width, height);
|
|
1238
|
+
context.drawImage(video, 0, 0, width, height);
|
|
1239
|
+
rgba = context.getImageData(0, 0, width, height).data;
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
return unreadVideoFrame(
|
|
1242
|
+
error && error.name === "SecurityError"
|
|
1243
|
+
? "its source is cross-origin"
|
|
1244
|
+
: "its frame could not be drawn",
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
return classifyVideoAlpha(rgba, width, height, mapping, opacityChain(video));
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function classifyVideoAlpha(rgba, width, height, mapping, opacity) {
|
|
1251
|
+
const alpha = new Uint8Array(width * height);
|
|
1252
|
+
let clear = 0;
|
|
1253
|
+
let painted = 0;
|
|
1254
|
+
for (let index = 0; index < alpha.length; index++) {
|
|
1255
|
+
alpha[index] = rgba[index * 4 + 3];
|
|
1256
|
+
if (alpha[index] < OCCLUDING_ALPHA * 255) clear += 1;
|
|
1257
|
+
if (alpha[index] > 0) painted += 1;
|
|
1258
|
+
}
|
|
1259
|
+
if (clear === 0) return { kind: "opaque" };
|
|
1260
|
+
if (painted === 0) return unreadVideoFrame("its frame read back fully transparent");
|
|
1261
|
+
return {
|
|
1262
|
+
kind: "matte",
|
|
1263
|
+
alpha,
|
|
1264
|
+
width,
|
|
1265
|
+
height,
|
|
1266
|
+
opacity,
|
|
1267
|
+
mapping: {
|
|
1268
|
+
...mapping,
|
|
1269
|
+
sourceWidth: width,
|
|
1270
|
+
sourceHeight: height,
|
|
1271
|
+
scaleX: mapping.paintedWidth / width,
|
|
1272
|
+
scaleY: mapping.paintedHeight / height,
|
|
1273
|
+
},
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function matteOccludesAt(frame, x, y) {
|
|
1278
|
+
const pixel = sourcePixelAt(frame.mapping, x, y);
|
|
1279
|
+
if (!pixel) return false;
|
|
1280
|
+
return (frame.alpha[pixel.y * frame.width + pixel.x] / 255) * frame.opacity >= OCCLUDING_ALPHA;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// A video with an opaque CSS background paints that background behind its clear pixels.
|
|
1284
|
+
function matteFrameFor(element) {
|
|
1285
|
+
if (element.tagName !== "VIDEO") return null;
|
|
1286
|
+
if (hasOpaqueBackground(getComputedStyle(element))) return null;
|
|
1287
|
+
const frame = videoAlphaFrame(element);
|
|
1288
|
+
return frame.kind === "matte" ? frame : null;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// An element hides text beneath it when it paints opaque pixels at near-full
|
|
1292
|
+
// opacity: raster content (img/video/canvas), a background image, or a solid
|
|
1293
|
+
// background colour. Low-opacity overlays (grain, scrims) do not occlude.
|
|
1294
|
+
function isOpaqueOccluder(element, x, y) {
|
|
1295
|
+
const opacity = opacityChain(element);
|
|
1296
|
+
if (opacity < OCCLUDING_ALPHA) return false;
|
|
1297
|
+
if (IGNORE_TAGS.has(element.tagName)) return false;
|
|
1298
|
+
if (element.tagName === "IMG") {
|
|
1299
|
+
const alpha = imageAlphaAt(element, x, y);
|
|
1300
|
+
if (alpha !== null) return alpha * opacity >= OCCLUDING_ALPHA;
|
|
1301
|
+
}
|
|
1302
|
+
const matte = matteFrameFor(element);
|
|
1303
|
+
if (matte) return matteOccludesAt(matte, x, y);
|
|
1304
|
+
if (RASTER_TAGS.has(element.tagName)) return true;
|
|
1305
|
+
return hasOpaqueBackground(getComputedStyle(element));
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function hasAllowOcclusionFlag(element) {
|
|
1309
|
+
return element.hasAttribute("data-layout-allow-occlusion");
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// A foreign element is one painted independently of the text — not the text
|
|
1313
|
+
// itself, its own subtree, or an ancestor it shares a background with.
|
|
1314
|
+
function isForeignElement(element, hit) {
|
|
1315
|
+
return !!hit && hit !== element && !element.contains(hit) && !hit.contains(element);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// During a scene-to-scene crossfade the incoming scene paints over the
|
|
1319
|
+
// outgoing scene's still-visible text at >= 0.6 opacity — and `--at-transitions`
|
|
1320
|
+
// samples exactly that midpoint. That overlap is the transition doing its job,
|
|
1321
|
+
// not an occlusion bug. Detect it: the occluder lives in a DIFFERENT composition
|
|
1322
|
+
// mount ([data-composition-id]) than the text, and at least one of the two scenes
|
|
1323
|
+
// is mid-fade (effective opacity < 1). Two fully-settled scenes overlapping
|
|
1324
|
+
// (both opacity 1) is NOT suppressed — that is a real layering bug.
|
|
1325
|
+
function isCrossSceneTransitionOverlap(textEl, occluder) {
|
|
1326
|
+
const textScene = textEl.closest("[data-composition-id]");
|
|
1327
|
+
const occluderScene = occluder.closest("[data-composition-id]");
|
|
1328
|
+
if (!textScene || !occluderScene || textScene === occluderScene) return false;
|
|
1329
|
+
return Math.min(opacityChain(textScene), opacityChain(occluderScene)) < 0.999;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// The nearest ancestor establishing a 3D rendering context, or null. Elements
|
|
1333
|
+
// sharing one are depth-sorted in 3D, so a "covering" hit is legitimate
|
|
1334
|
+
// perspective (e.g. the back face of a preserve-3d cube), not a 2D overlap.
|
|
1335
|
+
function preserve3dContext(element) {
|
|
1336
|
+
for (let current = element; current; current = current.parentElement) {
|
|
1337
|
+
const ts = getComputedStyle(current).transformStyle;
|
|
1338
|
+
if (ts === "preserve-3d") return current;
|
|
1339
|
+
}
|
|
1340
|
+
return null;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function sharedPreserve3d(a, b) {
|
|
1344
|
+
const ctx = preserve3dContext(a);
|
|
1345
|
+
return !!ctx && ctx === preserve3dContext(b);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// The opaque element painted over (x, y), or null when the topmost element
|
|
1349
|
+
// there is related to the text, non-opaque, sharing a 3D context with it, or
|
|
1350
|
+
// part of a transient crossfade overlap.
|
|
1351
|
+
// fallow-ignore-next-line complexity
|
|
1352
|
+
function occluderAt(element, x, y, seenMattes) {
|
|
1353
|
+
// Walk the paint-ordered stack: a transparent layer on top must not mask an opaque one below it.
|
|
1354
|
+
const stack =
|
|
1355
|
+
typeof document.elementsFromPoint === "function"
|
|
1356
|
+
? document.elementsFromPoint(x, y)
|
|
1357
|
+
: typeof document.elementFromPoint === "function"
|
|
1358
|
+
? [document.elementFromPoint(x, y)].filter(Boolean)
|
|
1359
|
+
: [];
|
|
1360
|
+
for (const hit of stack) {
|
|
1361
|
+
if (!isForeignElement(element, hit)) return null;
|
|
1362
|
+
// Pair-specific exemptions excuse this hit only; keep walking for deeper occluders.
|
|
1363
|
+
if (sharedPreserve3d(element, hit)) continue;
|
|
1364
|
+
if (isCrossSceneTransitionOverlap(element, hit)) continue;
|
|
1365
|
+
const matte = seenMattes && matteFrameFor(hit);
|
|
1366
|
+
if (matte && !seenMattes.has(matte)) seenMattes.set(matte, hit);
|
|
1367
|
+
if (isOpaqueOccluder(hit, x, y)) return hit;
|
|
1368
|
+
}
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const OCCLUSION_PROBE_Y_FRACTIONS = [0.25, 0.5, 0.75];
|
|
1373
|
+
const OCCLUSION_PROBE_X_FRACTIONS = [0.03, 0.1, 0.2, 0.35, 0.5, 0.65, 0.8, 0.9, 0.97];
|
|
1374
|
+
const OCCLUSION_GRID_POINTS =
|
|
1375
|
+
OCCLUSION_PROBE_Y_FRACTIONS.length * OCCLUSION_PROBE_X_FRACTIONS.length;
|
|
1376
|
+
|
|
1377
|
+
// Short, atomic text (a label/button/word, no whitespace) reads as a single
|
|
1378
|
+
// unit — ANY covered probe point changes what it says, so flag at any hit
|
|
1379
|
+
// (the pre-#U10 behaviour). Longer prose survives a nibbled edge; only flag
|
|
1380
|
+
// once a real share of it is covered — see `occludedTextIssue`.
|
|
1381
|
+
const ATOMIC_LABEL_MAX_CHARS = 16;
|
|
1382
|
+
// Default prose floor — callers may lower via auditLayout({ proseCoverageFloor }).
|
|
1383
|
+
const DEFAULT_PROSE_COVERAGE_FLOOR = 0.15;
|
|
1384
|
+
|
|
1385
|
+
function isAtomicLabel(text) {
|
|
1386
|
+
return text.length > 0 && text.length <= ATOMIC_LABEL_MAX_CHARS && !/\s/.test(text);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Sweep a grid across each painted text fragment (three rows, not just the
|
|
1390
|
+
// mid-line, so overlays covering only part of a multi-line block are caught).
|
|
1391
|
+
// Sampling fragments instead of their union avoids probing empty line gaps.
|
|
1392
|
+
// Unlike a
|
|
1393
|
+
// first-hit scan, this keeps sampling every point so it can report what
|
|
1394
|
+
// fraction of the box is actually covered — a corner nibble on a paragraph
|
|
1395
|
+
// reads very differently from a label buried under an overlay. Still
|
|
1396
|
+
// returns the first opaque element found, for `containerSelector`.
|
|
1397
|
+
function occlusionCoverage(element, textRects) {
|
|
1398
|
+
let occluder = null;
|
|
1399
|
+
let hits = 0;
|
|
1400
|
+
let opaqueHits = 0;
|
|
1401
|
+
// Every matte above the text, including where its alpha is clear: a feature narrower than the
|
|
1402
|
+
// probe grid (an arm, a mic stand) can hide a word between two probe columns.
|
|
1403
|
+
const seenMattes = new Map();
|
|
1404
|
+
for (const textRect of textRects) {
|
|
1405
|
+
for (const yFraction of OCCLUSION_PROBE_Y_FRACTIONS) {
|
|
1406
|
+
const y = textRect.top + textRect.height * yFraction;
|
|
1407
|
+
for (const xFraction of OCCLUSION_PROBE_X_FRACTIONS) {
|
|
1408
|
+
const x = textRect.left + textRect.width * xFraction;
|
|
1409
|
+
const hit = occluderAt(element, x, y, seenMattes);
|
|
1410
|
+
if (!hit) continue;
|
|
1411
|
+
hits += 1;
|
|
1412
|
+
if (!occluder) occluder = hit;
|
|
1413
|
+
if (!matteFrameFor(hit)) opaqueHits += 1;
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
const judgeByMatte = opaqueHits === 0 && seenMattes.size > 0;
|
|
1418
|
+
return {
|
|
1419
|
+
occluder: occluder || (judgeByMatte ? seenMattes.values().next().value : null),
|
|
1420
|
+
coveredFraction: round(hits / (OCCLUSION_GRID_POINTS * textRects.length)),
|
|
1421
|
+
mattes: judgeByMatte ? [...seenMattes.keys()] : [],
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Thresholds from measuring rendered words against real subject mattes: a word reads as
|
|
1426
|
+
// deliberately tucked behind the subject at 30-55% covered, and is lost at 80%, or when it
|
|
1427
|
+
// and its neighbour are both half covered.
|
|
1428
|
+
const MATTE_WORD_LOST_COVERAGE = 0.8;
|
|
1429
|
+
const MATTE_PAIR_LOST_COVERAGE = 0.5;
|
|
1430
|
+
const MATTE_MAX_SAMPLES_PER_AXIS = 48;
|
|
1431
|
+
|
|
1432
|
+
function wordClientRects(element) {
|
|
1433
|
+
const words = [];
|
|
1434
|
+
for (const node of directTextNodes(element)) {
|
|
1435
|
+
for (const match of (node.textContent || "").matchAll(/\S+/g)) {
|
|
1436
|
+
const range = document.createRange();
|
|
1437
|
+
range.setStart(node, match.index);
|
|
1438
|
+
range.setEnd(node, match.index + match[0].length);
|
|
1439
|
+
const rects = Array.from(range.getClientRects()).filter(
|
|
1440
|
+
(rect) => rect.width > 0.5 && rect.height > 0.5,
|
|
1441
|
+
);
|
|
1442
|
+
range.detach();
|
|
1443
|
+
if (rects.length > 0) words.push({ word: match[0], rects });
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return words;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
function matteSampleCount(length, mattes) {
|
|
1450
|
+
const pitch = Math.max(1, Math.min(...mattes.map((matte) => matte.mapping.scaleX)));
|
|
1451
|
+
return Math.max(1, Math.min(MATTE_MAX_SAMPLES_PER_AXIS, Math.ceil(length / pitch)));
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Area-weighted share of the rects under any matte's occluding alpha, sampled at the matte's resolution.
|
|
1455
|
+
function matteCoverage(rects, mattes) {
|
|
1456
|
+
let coveredArea = 0;
|
|
1457
|
+
let totalArea = 0;
|
|
1458
|
+
for (const rect of rects) {
|
|
1459
|
+
const cols = matteSampleCount(rect.width, mattes);
|
|
1460
|
+
const rows = matteSampleCount(rect.height, mattes);
|
|
1461
|
+
let covered = 0;
|
|
1462
|
+
for (let row = 0; row < rows; row++) {
|
|
1463
|
+
const y = rect.top + ((row + 0.5) * rect.height) / rows;
|
|
1464
|
+
for (let col = 0; col < cols; col++) {
|
|
1465
|
+
const x = rect.left + ((col + 0.5) * rect.width) / cols;
|
|
1466
|
+
if (mattes.some((matte) => matteOccludesAt(matte, x, y))) covered += 1;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
const area = rect.width * rect.height;
|
|
1470
|
+
coveredArea += (area * covered) / (rows * cols);
|
|
1471
|
+
totalArea += area;
|
|
1472
|
+
}
|
|
1473
|
+
return totalArea > 0 ? round(coveredArea / totalArea) : 0;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function lostNeighbourPair(words) {
|
|
1477
|
+
for (let index = 1; index < words.length; index++) {
|
|
1478
|
+
const pair = [words[index - 1], words[index]];
|
|
1479
|
+
if (pair.every((word) => word.coverage >= MATTE_PAIR_LOST_COVERAGE)) return pair;
|
|
1480
|
+
}
|
|
1481
|
+
return null;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function percent(fraction) {
|
|
1485
|
+
return `${Math.round(fraction * 100)}%`;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function lostWordsMessage(lostWord, pair) {
|
|
1489
|
+
if (lostWord) {
|
|
1490
|
+
return `"${lostWord.word}" is ${percent(lostWord.coverage)} behind a transparent video's matte.`;
|
|
1491
|
+
}
|
|
1492
|
+
return `Neighbouring words "${pair[0].word}" and "${pair[1].word}" are ${percent(pair[0].coverage)} and ${percent(pair[1].coverage)} behind a transparent video's matte.`;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function markMatteWordsLost(issue, lostWord, pair) {
|
|
1496
|
+
issue.severity = "error";
|
|
1497
|
+
issue.message = lostWordsMessage(lostWord, pair);
|
|
1498
|
+
issue.fixHint =
|
|
1499
|
+
"Move or resize the text so no word is 80% behind the matte and no two neighbouring words are each half behind it, or mark intentional layering with data-layout-allow-occlusion.";
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Text behind a cutout is judged per rendered word against the decoded alpha: partial cover is
|
|
1503
|
+
// reported as info with its share, a lost word is an error.
|
|
1504
|
+
function matteOcclusionIssue(element, time, textRect, text, occluder, mattes) {
|
|
1505
|
+
const words = wordClientRects(element).map(({ word, rects }) => ({
|
|
1506
|
+
word,
|
|
1507
|
+
coverage: matteCoverage(rects, mattes),
|
|
1508
|
+
}));
|
|
1509
|
+
if (words.length === 0) return null;
|
|
1510
|
+
const peak = words.reduce((top, word) => (word.coverage > top.coverage ? word : top));
|
|
1511
|
+
if (peak.coverage <= 0) return null;
|
|
1512
|
+
const issue = {
|
|
1513
|
+
code: "text_occluded",
|
|
1514
|
+
severity: "info",
|
|
1515
|
+
time,
|
|
1516
|
+
selector: uniqueSelectorFor(element),
|
|
1517
|
+
containerSelector: uniqueSelectorFor(occluder),
|
|
1518
|
+
text,
|
|
1519
|
+
message: `Text sits partly behind a transparent video's matte; its most covered word "${peak.word}" is ${percent(peak.coverage)} hidden.`,
|
|
1520
|
+
rect: textRect,
|
|
1521
|
+
coveredFraction: peak.coverage,
|
|
1522
|
+
};
|
|
1523
|
+
const lostWord = peak.coverage >= MATTE_WORD_LOST_COVERAGE ? peak : null;
|
|
1524
|
+
const pair = lostNeighbourPair(words);
|
|
1525
|
+
if (lostWord || pair) markMatteWordsLost(issue, lostWord, pair);
|
|
1526
|
+
return { issue, first: words[0], last: words[words.length - 1] };
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Captions often render one word per element, so the neighbour rule also spans sibling elements.
|
|
1530
|
+
function markLostSiblingWords(matteResults) {
|
|
1531
|
+
for (const [element, result] of matteResults) {
|
|
1532
|
+
const next = element.nextElementSibling && matteResults.get(element.nextElementSibling);
|
|
1533
|
+
if (!next) continue;
|
|
1534
|
+
const pair = [result.last, next.first];
|
|
1535
|
+
if (!pair.every((word) => word.coverage >= MATTE_PAIR_LOST_COVERAGE)) continue;
|
|
1536
|
+
for (const { issue } of [result, next]) {
|
|
1537
|
+
if (issue.severity !== "error") markMatteWordsLost(issue, null, pair);
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function unreadVideoIssues(time) {
|
|
1543
|
+
const issues = [];
|
|
1544
|
+
for (const [video, frame] of videoAlphaFrames) {
|
|
1545
|
+
if (frame.kind !== "unread") continue;
|
|
1546
|
+
issues.push({
|
|
1547
|
+
code: "video_alpha_unread",
|
|
1548
|
+
severity: "info",
|
|
1549
|
+
time,
|
|
1550
|
+
selector: uniqueSelectorFor(video),
|
|
1551
|
+
message: `Could not read this video's frame alpha (${frame.reason}), so text under it was judged as if the video were opaque.`,
|
|
1552
|
+
rect: toRect(video.getBoundingClientRect()),
|
|
1553
|
+
});
|
|
1554
|
+
}
|
|
1555
|
+
return issues;
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// pointer-events:none hides elements from elementFromPoint — both probed text AND occluders.
|
|
1559
|
+
function restoreHitTesting(root) {
|
|
1560
|
+
const restores = [];
|
|
1561
|
+
for (const node of [root, ...root.querySelectorAll("*")]) {
|
|
1562
|
+
if (getComputedStyle(node).pointerEvents !== "none") continue;
|
|
1563
|
+
const previous = node.style.getPropertyValue("pointer-events");
|
|
1564
|
+
const priority = node.style.getPropertyPriority("pointer-events");
|
|
1565
|
+
node.style.setProperty("pointer-events", "auto", "important");
|
|
1566
|
+
restores.push(() => {
|
|
1567
|
+
if (previous) node.style.setProperty("pointer-events", previous, priority);
|
|
1568
|
+
else node.style.removeProperty("pointer-events");
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
return () => restores.forEach((restore) => restore());
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// No text ink is on screen while every non-whitespace text node sits at ~0 opacity (entrance not started).
|
|
1575
|
+
function hasVisibleTextInk(element) {
|
|
1576
|
+
const nodes = [element, ...element.querySelectorAll("*")];
|
|
1577
|
+
for (const node of nodes) {
|
|
1578
|
+
if (!directTextNodes(node).some((textNode) => textNode.textContent.trim())) continue;
|
|
1579
|
+
if (opacityChain(node) >= 0.05) return true;
|
|
1580
|
+
}
|
|
1581
|
+
return false;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// text_occluded: atomic labels flag at any hit; prose needs coveredFraction >= proseCoverageFloor (default 0.15).
|
|
1585
|
+
function occludedTextIssue(element, time, proseCoverageFloor, matteResults) {
|
|
1586
|
+
if (hasAllowOcclusionFlag(element)) return null;
|
|
1587
|
+
if (!hasVisibleTextInk(element)) return null;
|
|
1588
|
+
const textRect = textRectFor(element, true);
|
|
1589
|
+
if (!textRect) return null;
|
|
1590
|
+
const textRects = textClientRects(element, true);
|
|
1591
|
+
const text = textContentFor(element, true);
|
|
1592
|
+
const { occluder, coveredFraction, mattes } = occlusionCoverage(
|
|
1593
|
+
element,
|
|
1594
|
+
textRects.length > 0 ? textRects : [textRect],
|
|
1595
|
+
);
|
|
1596
|
+
if (!occluder) return null;
|
|
1597
|
+
if (mattes.length > 0) {
|
|
1598
|
+
const result = matteOcclusionIssue(element, time, textRect, text, occluder, mattes);
|
|
1599
|
+
if (result) matteResults.set(element, result);
|
|
1600
|
+
return result ? result.issue : null;
|
|
1601
|
+
}
|
|
1602
|
+
if (!isAtomicLabel(text) && coveredFraction < proseCoverageFloor) return null;
|
|
1603
|
+
return {
|
|
1604
|
+
code: "text_occluded",
|
|
1605
|
+
severity: "error",
|
|
1606
|
+
time,
|
|
1607
|
+
selector: uniqueSelectorFor(element),
|
|
1608
|
+
containerSelector: uniqueSelectorFor(occluder),
|
|
1609
|
+
text,
|
|
1610
|
+
message: "Text is hidden beneath an opaque element.",
|
|
1611
|
+
rect: textRect,
|
|
1612
|
+
coveredFraction,
|
|
1613
|
+
fixHint:
|
|
1614
|
+
"Give the text its own zone, raise its stacking order above the covering element, or mark intentional layering with data-layout-allow-occlusion.",
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function layoutOffset(element) {
|
|
1619
|
+
let left = 0;
|
|
1620
|
+
let top = 0;
|
|
1621
|
+
for (let current = element; current instanceof HTMLElement; current = current.offsetParent) {
|
|
1622
|
+
left += current.offsetLeft;
|
|
1623
|
+
top += current.offsetTop;
|
|
1624
|
+
if (current !== element) {
|
|
1625
|
+
left += current.clientLeft;
|
|
1626
|
+
top += current.clientTop;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
return { left, top };
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function ancestorBackgroundPaintsText(element, textRect) {
|
|
1633
|
+
// Chromium 150+ includes independently painted descendants in text masks;
|
|
1634
|
+
// older builds drop their glyphs: https://github.com/heygen-com/hyperframes/issues/5117.
|
|
1635
|
+
const chromium = window.navigator.userAgent.match(/(?:HeadlessChrome|Chrome)\/(\d+)/);
|
|
1636
|
+
const chromiumVersion = chromium ? Number(chromium[1]) : 0;
|
|
1637
|
+
if (!chromiumVersion || !(element instanceof HTMLElement)) return false;
|
|
1638
|
+
|
|
1639
|
+
for (
|
|
1640
|
+
let ancestor = element.parentElement;
|
|
1641
|
+
ancestor instanceof HTMLElement;
|
|
1642
|
+
ancestor = ancestor.parentElement
|
|
1643
|
+
) {
|
|
1644
|
+
const style = getComputedStyle(ancestor);
|
|
1645
|
+
const clips = splitTopLevelCommas(
|
|
1646
|
+
style.webkitBackgroundClip || style.backgroundClip || "border-box",
|
|
1647
|
+
).map((clip) => clip.trim());
|
|
1648
|
+
if (!clips.includes("text")) continue;
|
|
1649
|
+
if (chromiumVersion < 150 && hasIndependentTextPaint(element, ancestor)) continue;
|
|
1650
|
+
// Chromium builds the mask before descendant transforms, so moving the
|
|
1651
|
+
// child must not move its glyphs out of the mask for this check.
|
|
1652
|
+
const offset = layoutOffset(element);
|
|
1653
|
+
const parentOffset = layoutOffset(ancestor);
|
|
1654
|
+
const elementRect = element.getBoundingClientRect();
|
|
1655
|
+
const scaleX = elementRect.width > 0 ? element.offsetWidth / elementRect.width : 1;
|
|
1656
|
+
const scaleY = elementRect.height > 0 ? element.offsetHeight / elementRect.height : 1;
|
|
1657
|
+
const backgroundRect = rectFromOrigin(0, 0, ancestor.offsetWidth, ancestor.offsetHeight);
|
|
1658
|
+
const overlaps =
|
|
1659
|
+
element.offsetWidth > 0 && element.offsetHeight > 0
|
|
1660
|
+
? textClientRects(element, true).some((rect) => {
|
|
1661
|
+
const layoutRect = rectFromOrigin(
|
|
1662
|
+
offset.left - parentOffset.left + (rect.left - elementRect.left) * scaleX,
|
|
1663
|
+
offset.top - parentOffset.top + (rect.top - elementRect.top) * scaleY,
|
|
1664
|
+
rect.width * scaleX,
|
|
1665
|
+
rect.height * scaleY,
|
|
1666
|
+
);
|
|
1667
|
+
return intersectionArea(layoutRect, backgroundRect) > 0;
|
|
1668
|
+
})
|
|
1669
|
+
: !overflowFor(textRect, ancestor.getBoundingClientRect(), 2);
|
|
1670
|
+
if (!overlaps) continue;
|
|
1671
|
+
|
|
1672
|
+
const images = splitTopLevelCommas(style.backgroundImage || "none");
|
|
1673
|
+
const imagePaints = images.some(
|
|
1674
|
+
(image, index) =>
|
|
1675
|
+
clips[index % clips.length] === "text" &&
|
|
1676
|
+
(/url\(/i.test(image) ||
|
|
1677
|
+
(/(?:linear|radial|conic)-gradient\(/i.test(image) && gradientMaxAlpha(image) > 0.05)),
|
|
1678
|
+
);
|
|
1679
|
+
const colorPaints =
|
|
1680
|
+
clips[(images.length - 1) % clips.length] === "text" &&
|
|
1681
|
+
colorAlpha(style.backgroundColor || "rgba(0, 0, 0, 0)") > 0.05;
|
|
1682
|
+
if (imagePaints || colorPaints) return true;
|
|
1683
|
+
}
|
|
1684
|
+
return false;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Plain descendants share a text mask on older Chromium; independently
|
|
1688
|
+
// painted descendants do not. Check the whole path to the mask, including
|
|
1689
|
+
// wrappers: https://github.com/chromium/chromium/blob/148.0.7778.97/third_party/blink/renderer/core/layout/layout_inline.cc.
|
|
1690
|
+
function hasIndependentTextPaint(element, background) {
|
|
1691
|
+
for (let current = element; current !== background; current = current.parentElement) {
|
|
1692
|
+
const style = getComputedStyle(current);
|
|
1693
|
+
if (style.position && style.position !== "static") return true;
|
|
1694
|
+
if (Number.parseFloat(style.opacity || "1") < 1) return true;
|
|
1695
|
+
const isInline = style.display === "inline";
|
|
1696
|
+
if (
|
|
1697
|
+
[
|
|
1698
|
+
style.filter,
|
|
1699
|
+
style.backdropFilter,
|
|
1700
|
+
style.webkitBackdropFilter,
|
|
1701
|
+
style.maskImage,
|
|
1702
|
+
style.webkitMaskImage,
|
|
1703
|
+
style.clipPath,
|
|
1704
|
+
style.webkitClipPath,
|
|
1705
|
+
style.viewTransitionName,
|
|
1706
|
+
].some((value) => value && value !== "none")
|
|
1707
|
+
)
|
|
1708
|
+
return true;
|
|
1709
|
+
if (
|
|
1710
|
+
!isInline &&
|
|
1711
|
+
[style.transform, style.translate, style.rotate, style.scale, style.perspective].some(
|
|
1712
|
+
(value) => value && value !== "none",
|
|
1713
|
+
)
|
|
1714
|
+
)
|
|
1715
|
+
return true;
|
|
1716
|
+
if (style.isolation === "isolate") return true;
|
|
1717
|
+
if (style.mixBlendMode && style.mixBlendMode !== "normal") return true;
|
|
1718
|
+
if (
|
|
1719
|
+
!isInline &&
|
|
1720
|
+
(style.backfaceVisibility === "hidden" || style.transformStyle === "preserve-3d")
|
|
1721
|
+
)
|
|
1722
|
+
return true;
|
|
1723
|
+
if (!isInline && /\b(?:paint|layout|content|strict)\b/.test(style.contain || "")) return true;
|
|
1724
|
+
if (style.contentVisibility && style.contentVisibility !== "visible") return true;
|
|
1725
|
+
if (
|
|
1726
|
+
(style.willChange || "")
|
|
1727
|
+
.split(",")
|
|
1728
|
+
.some(
|
|
1729
|
+
(property) =>
|
|
1730
|
+
/^(?:opacity|filter|backdrop-filter|position|isolation|mix-blend-mode|mask|mask-image|clip-path)$/.test(
|
|
1731
|
+
property.trim(),
|
|
1732
|
+
) ||
|
|
1733
|
+
(!isInline &&
|
|
1734
|
+
/^(?:transform|translate|rotate|scale|perspective)$/.test(property.trim())),
|
|
1735
|
+
)
|
|
1736
|
+
)
|
|
1737
|
+
return true;
|
|
1738
|
+
if (style.zIndex && style.zIndex !== "auto" && current.parentElement) {
|
|
1739
|
+
const display = getComputedStyle(current.parentElement).display;
|
|
1740
|
+
if (/^(?:inline-)?(?:flex|grid)$/.test(display)) return true;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
return false;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// Text whose glyphs paint with an effectively transparent fill renders
|
|
1747
|
+
// invisibly even though the element, its box, opacity and color all read as
|
|
1748
|
+
// present — so geometry/occlusion/contrast audits miss it (contrast reads
|
|
1749
|
+
// `color`, not the fill that actually paints). `-webkit-text-fill-color`
|
|
1750
|
+
// overrides `color` for the glyph fill AND inherits, so a parent's
|
|
1751
|
+
// `transparent` fill silently blanks descendant text that has its own opaque
|
|
1752
|
+
// `color`. Its computed value already resolves to `color` when unset, so it
|
|
1753
|
+
// is the effective fill directly. Clipped text (`background-clip: text`)
|
|
1754
|
+
// legitimately uses a transparent fill — BUT only when a background actually
|
|
1755
|
+
// paints the glyphs; a `background-clip: text` with no gradient/image and no
|
|
1756
|
+
// opaque background-color paints nothing, so it stays reportable.
|
|
1757
|
+
function invisibleTextIssue(element, time) {
|
|
1758
|
+
const textRect = textRectFor(element, true);
|
|
1759
|
+
if (!textRect) return null;
|
|
1760
|
+
const text = textContentFor(element, true);
|
|
1761
|
+
if (!text) return null;
|
|
1762
|
+
const cs = getComputedStyle(element);
|
|
1763
|
+
// Vendor computed-style props are read by property (camelCase), matching
|
|
1764
|
+
// the rest of this script; `webkitTextFillColor` computes to `color` when
|
|
1765
|
+
// unset, so it is the effective fill directly.
|
|
1766
|
+
const fill = cs.webkitTextFillColor || cs.color;
|
|
1767
|
+
if (colorAlpha(fill) > 0.05) return null;
|
|
1768
|
+
const clip = cs.webkitBackgroundClip || cs.backgroundClip || "";
|
|
1769
|
+
if (/text/i.test(clip)) {
|
|
1770
|
+
const bgImage = cs.backgroundImage || "none";
|
|
1771
|
+
const paintsGlyphs =
|
|
1772
|
+
bgImage !== "none" || colorAlpha(cs.backgroundColor || "rgba(0, 0, 0, 0)") > 0.05;
|
|
1773
|
+
// A usable clipped background fills the glyphs — legitimate gradient/solid
|
|
1774
|
+
// clipped text. If nothing paints, fall through and report it.
|
|
1775
|
+
if (paintsGlyphs) return null;
|
|
1776
|
+
}
|
|
1777
|
+
if (ancestorBackgroundPaintsText(element, textRect)) return null;
|
|
1778
|
+
return {
|
|
1779
|
+
code: "text_not_painted",
|
|
1780
|
+
severity: "error",
|
|
1781
|
+
time,
|
|
1782
|
+
selector: uniqueSelectorFor(element),
|
|
1783
|
+
text,
|
|
1784
|
+
message:
|
|
1785
|
+
"Text paints with an effectively transparent fill (-webkit-text-fill-color / color), so its glyphs are invisible.",
|
|
1786
|
+
rect: textRect,
|
|
1787
|
+
fixHint:
|
|
1788
|
+
"Set an explicit, opaque `color` on the text — and an explicit `-webkit-text-fill-color` if an ancestor makes the fill transparent. If the transparency is intentional gradient text, add `background-clip: text`.",
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// Attachment allowance: callouts/tooltips legitimately hang near (not inside) their anchor.
|
|
1793
|
+
const ESCAPE_INTERSECTION_FRACTION = 0.3;
|
|
1794
|
+
const ESCAPE_MIN_CHILD_AREA = 2500;
|
|
1795
|
+
|
|
1796
|
+
function edgeGap(child, parent) {
|
|
1797
|
+
const dx = Math.max(parent.left - child.right, 0, child.left - parent.right);
|
|
1798
|
+
const dy = Math.max(parent.top - child.bottom, 0, child.top - parent.bottom);
|
|
1799
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
// An absolute element rendering far outside its offset parent was positioned in the wrong frame.
|
|
1803
|
+
function escapedContainerIssues(root, time) {
|
|
1804
|
+
const issues = [];
|
|
1805
|
+
const flagged = new Set();
|
|
1806
|
+
for (const element of Array.from(root.querySelectorAll("*"))) {
|
|
1807
|
+
if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
|
|
1808
|
+
if (getComputedStyle(element).position !== "absolute") continue;
|
|
1809
|
+
const parent = element.offsetParent;
|
|
1810
|
+
if (!parent || parent === document.body || parent === root || !isVisibleElement(parent)) {
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
const childRect = toRect(element.getBoundingClientRect());
|
|
1814
|
+
if (rectArea(childRect) < ESCAPE_MIN_CHILD_AREA) continue;
|
|
1815
|
+
const parentRect = toRect(parent.getBoundingClientRect());
|
|
1816
|
+
const visible = intersectionArea(childRect, parentRect);
|
|
1817
|
+
if (visible >= rectArea(childRect) * ESCAPE_INTERSECTION_FRACTION) continue;
|
|
1818
|
+
// Fully detached but hugging the parent = a callout/tooltip; touching yet mostly outside = drift.
|
|
1819
|
+
const allowance = Math.max(48, Math.min(childRect.width, childRect.height) / 2);
|
|
1820
|
+
if (visible <= 0 && edgeGap(childRect, parentRect) <= allowance) continue;
|
|
1821
|
+
flagged.add(element);
|
|
1822
|
+
issues.push({
|
|
1823
|
+
code: "escaped_container",
|
|
1824
|
+
severity: "warning",
|
|
1825
|
+
time,
|
|
1826
|
+
selector: uniqueSelectorFor(element),
|
|
1827
|
+
containerSelector: uniqueSelectorFor(parent),
|
|
1828
|
+
text: textContentFor(element),
|
|
1829
|
+
message:
|
|
1830
|
+
"Positioned element renders far outside its offset parent — its coordinates were likely computed in a different frame (canvas/viewport pixels).",
|
|
1831
|
+
rect: childRect,
|
|
1832
|
+
containerRect: parentRect,
|
|
1833
|
+
fixHint:
|
|
1834
|
+
"Compute left/top in the offset parent's frame (subtract its rect), or mark intentional placement with data-layout-allow-overflow.",
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
return { issues, flagged };
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// A gradient reads as content when any stop is solid; all-translucent stops are glows/vignettes.
|
|
1841
|
+
function gradientHasOpaqueStop(image) {
|
|
1842
|
+
const colors = image.match(/rgba?\([^)]+\)|#[0-9a-f]{3,8}|\btransparent\b/gi) || [];
|
|
1843
|
+
return colors.some((color) => !/^transparent$/i.test(color) && colorAlpha(color) >= 0.6);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
function isPaintedPanel(element) {
|
|
1847
|
+
if (FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) return false;
|
|
1848
|
+
const style = getComputedStyle(element);
|
|
1849
|
+
const image = style.backgroundImage || "none";
|
|
1850
|
+
if (image.includes("url(")) return true;
|
|
1851
|
+
if (image !== "none" && gradientHasOpaqueStop(image)) return true;
|
|
1852
|
+
if (!isTransparentColor(style.backgroundColor) && colorAlpha(style.backgroundColor) > 0.05) {
|
|
1853
|
+
return true;
|
|
1854
|
+
}
|
|
1855
|
+
return (
|
|
1856
|
+
parsePx(style.borderTopWidth) +
|
|
1857
|
+
parsePx(style.borderRightWidth) +
|
|
1858
|
+
parsePx(style.borderBottomWidth) +
|
|
1859
|
+
parsePx(style.borderLeftWidth) >
|
|
1860
|
+
0
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
// Canvas-breach floor: entrance nudges stay quiet; matches the connector threshold scale.
|
|
1865
|
+
const PANEL_BREACH_FLOOR_PX = 24;
|
|
1866
|
+
const PANEL_BREACH_FLOOR_FRACTION = 0.025;
|
|
1867
|
+
// A hero-sized panel stuck on the edge is drift; a small painted bleed is usually decoration.
|
|
1868
|
+
const PANEL_HERO_AREA_FRACTION = 0.1;
|
|
1869
|
+
|
|
1870
|
+
// Painted panels breaching the canvas: text is canvas_overflow's, media is frame_out_of_frame's, panels were nobody's.
|
|
1871
|
+
function panelOutOfCanvasIssues(root, rootRect, time, tolerance, escapedElements) {
|
|
1872
|
+
const issues = [];
|
|
1873
|
+
const floor = Math.max(
|
|
1874
|
+
PANEL_BREACH_FLOOR_PX,
|
|
1875
|
+
Math.min(rootRect.width, rootRect.height) * PANEL_BREACH_FLOOR_FRACTION,
|
|
1876
|
+
);
|
|
1877
|
+
const rootArea = rectArea(rootRect);
|
|
1878
|
+
const flagged = new Set();
|
|
1879
|
+
for (const element of Array.from(root.querySelectorAll("*"))) {
|
|
1880
|
+
if (!isVisibleElement(element) || hasAllowOverflowFlag(element)) continue;
|
|
1881
|
+
if (escapedElements.has(element)) continue;
|
|
1882
|
+
// Ownership is geometric and strict-mutex: any text breach past canvas_overflow's own
|
|
1883
|
+
// tolerance cedes the element to canvas_overflow; in-bounds text leaves the panel finding.
|
|
1884
|
+
if (hasOwnTextCandidate(element, true)) {
|
|
1885
|
+
const textRect = textRectFor(element, true);
|
|
1886
|
+
if (textRect && overflowFor(textRect, rootRect, tolerance)) continue;
|
|
1887
|
+
}
|
|
1888
|
+
const rect = toRect(element.getBoundingClientRect());
|
|
1889
|
+
if (rectArea(rect) >= rootArea * 0.95) continue;
|
|
1890
|
+
// Fully off-canvas paints nothing — that is a parked entrance, not drift.
|
|
1891
|
+
if (intersectionArea(rect, rootRect) <= 0) continue;
|
|
1892
|
+
const overflow = overflowFor(rect, rootRect, floor);
|
|
1893
|
+
if (!overflow || !isPaintedPanel(element)) continue;
|
|
1894
|
+
if (element.parentElement && flagged.has(element.parentElement)) {
|
|
1895
|
+
flagged.add(element);
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
flagged.add(element);
|
|
1899
|
+
issues.push({
|
|
1900
|
+
code: "panel_out_of_canvas",
|
|
1901
|
+
severity: rectArea(rect) >= rootArea * PANEL_HERO_AREA_FRACTION ? "warning" : "info",
|
|
1902
|
+
time,
|
|
1903
|
+
selector: uniqueSelectorFor(element),
|
|
1904
|
+
containerSelector: uniqueSelectorFor(root),
|
|
1905
|
+
text: textContentFor(element).slice(0, 48),
|
|
1906
|
+
message: "Painted panel extends outside the composition canvas.",
|
|
1907
|
+
rect,
|
|
1908
|
+
containerRect: rootRect,
|
|
1909
|
+
overflow,
|
|
1910
|
+
fixHint:
|
|
1911
|
+
"Move the panel inward, or mark intentional off-canvas animation with data-layout-allow-overflow.",
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
return issues;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// Pixels drawn into a <canvas> have no DOM box, so text the frame edge cuts is invisible to
|
|
1918
|
+
// canvas_overflow. Read a thin band just inside each covered frame edge and count sharp steps.
|
|
1919
|
+
const CANVAS_EDGE_STEP = 96;
|
|
1920
|
+
const CANVAS_EDGE_MIN_STEPS = 2;
|
|
1921
|
+
// Monospace and bitmap glyph cells end in a gap, so the outermost column alone can read empty.
|
|
1922
|
+
const CANVAS_EDGE_BAND_PX = 4;
|
|
1923
|
+
|
|
1924
|
+
function coveredFrameEdges(rect, rootRect, tolerance) {
|
|
1925
|
+
const top = Math.max(rect.top, rootRect.top);
|
|
1926
|
+
const bottom = Math.min(rect.bottom, rootRect.bottom);
|
|
1927
|
+
const left = Math.max(rect.left, rootRect.left);
|
|
1928
|
+
const right = Math.min(rect.right, rootRect.right);
|
|
1929
|
+
if (bottom - top < CANVAS_EDGE_BAND_PX || right - left < CANVAS_EDGE_BAND_PX) return [];
|
|
1930
|
+
const band = CANVAS_EDGE_BAND_PX;
|
|
1931
|
+
const rows = { y: top, height: bottom - top, width: band, vertical: true };
|
|
1932
|
+
const cols = { x: left, width: right - left, height: band, vertical: false };
|
|
1933
|
+
const edges = [];
|
|
1934
|
+
if (rect.left <= rootRect.left + tolerance) edges.push({ side: "left", x: left, ...rows });
|
|
1935
|
+
if (rect.right >= rootRect.right - tolerance)
|
|
1936
|
+
edges.push({ side: "right", x: right - band, ...rows });
|
|
1937
|
+
if (rect.top <= rootRect.top + tolerance) edges.push({ side: "top", y: top, ...cols });
|
|
1938
|
+
if (rect.bottom >= rootRect.bottom - tolerance)
|
|
1939
|
+
edges.push({ side: "bottom", y: bottom - band, ...cols });
|
|
1940
|
+
return edges;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
// Draw the band into a scratch canvas (never getContext on the film's own canvas, which would
|
|
1944
|
+
// claim it as 2D). A WebGL canvas without preserveDrawingBuffer reads blank: no finding.
|
|
1945
|
+
function canvasEdgeBand(canvas, rect, edge) {
|
|
1946
|
+
const scaleX = canvas.width / rect.width;
|
|
1947
|
+
const scaleY = canvas.height / rect.height;
|
|
1948
|
+
const off = document.createElement("canvas");
|
|
1949
|
+
off.width = Math.max(1, Math.round(edge.width * scaleX));
|
|
1950
|
+
off.height = Math.max(1, Math.round(edge.height * scaleY));
|
|
1951
|
+
const ctx = off.getContext("2d");
|
|
1952
|
+
if (!ctx) return null;
|
|
1953
|
+
const sx = Math.max(0, Math.floor((edge.x - rect.left) * scaleX));
|
|
1954
|
+
const sy = Math.max(0, Math.floor((edge.y - rect.top) * scaleY));
|
|
1955
|
+
ctx.drawImage(canvas, sx, sy, off.width, off.height, 0, 0, off.width, off.height);
|
|
1956
|
+
return {
|
|
1957
|
+
data: ctx.getImageData(0, 0, off.width, off.height).data,
|
|
1958
|
+
width: off.width,
|
|
1959
|
+
height: off.height,
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
// Collapse the band across its thickness (brightest wins), then count steps along the edge:
|
|
1964
|
+
// luma over black plus alpha, compared two pixels back so anti-aliased glyph edges still step.
|
|
1965
|
+
function sharpSteps(band, vertical) {
|
|
1966
|
+
const length = vertical ? band.height : band.width;
|
|
1967
|
+
const thickness = vertical ? band.width : band.height;
|
|
1968
|
+
const levels = [];
|
|
1969
|
+
for (let along = 0; along < length; along++) {
|
|
1970
|
+
let luma = 0;
|
|
1971
|
+
let alpha = 0;
|
|
1972
|
+
for (let across = 0; across < thickness; across++) {
|
|
1973
|
+
const i = 4 * (vertical ? along * band.width + across : across * band.width + along);
|
|
1974
|
+
const a = band.data[i + 3];
|
|
1975
|
+
const l =
|
|
1976
|
+
(0.299 * band.data[i] + 0.587 * band.data[i + 1] + 0.114 * band.data[i + 2]) * (a / 255);
|
|
1977
|
+
luma = Math.max(luma, l);
|
|
1978
|
+
alpha = Math.max(alpha, a);
|
|
1979
|
+
}
|
|
1980
|
+
levels.push([luma, alpha]);
|
|
1981
|
+
}
|
|
1982
|
+
let steps = 0;
|
|
1983
|
+
for (let i = 2; i < levels.length; i++) {
|
|
1984
|
+
const [luma, alpha] = levels[i];
|
|
1985
|
+
const [backLuma, backAlpha] = levels[i - 2];
|
|
1986
|
+
if (Math.max(Math.abs(luma - backLuma), Math.abs(alpha - backAlpha)) < CANVAS_EDGE_STEP)
|
|
1987
|
+
continue;
|
|
1988
|
+
steps++;
|
|
1989
|
+
i += 2;
|
|
1990
|
+
}
|
|
1991
|
+
return steps;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
function canvasEdgeHasContent(canvas, rect, edge) {
|
|
1995
|
+
try {
|
|
1996
|
+
const band = canvasEdgeBand(canvas, rect, edge);
|
|
1997
|
+
return !!band && sharpSteps(band, edge.vertical) >= CANVAS_EDGE_MIN_STEPS;
|
|
1998
|
+
} catch {
|
|
1999
|
+
return false;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// ponytail: maps through the bounding box, exact for scale and translate; a rotated or skewed
|
|
2004
|
+
// canvas reads the wrong pixels. Add a matrix inverse if that bites.
|
|
2005
|
+
function canvasEdgeIssues(root, rootRect, time, tolerance) {
|
|
2006
|
+
const issues = [];
|
|
2007
|
+
for (const canvas of Array.from(root.querySelectorAll("canvas"))) {
|
|
2008
|
+
// A graded stand-in shows the author's media, not drawn art; the media itself is never read here.
|
|
2009
|
+
if (canvas.matches(GRADED_STAND_IN)) continue;
|
|
2010
|
+
if (!isVisibleElement(canvas) || hasAllowOverflowFlag(canvas)) continue;
|
|
2011
|
+
if (!canvas.width || !canvas.height) continue;
|
|
2012
|
+
const rect = toRect(canvas.getBoundingClientRect());
|
|
2013
|
+
if (!rect.width || !rect.height) continue;
|
|
2014
|
+
const sides = coveredFrameEdges(rect, rootRect, tolerance)
|
|
2015
|
+
.filter((edge) => canvasEdgeHasContent(canvas, rect, edge))
|
|
2016
|
+
.map((edge) => edge.side);
|
|
2017
|
+
if (sides.length === 0) continue;
|
|
2018
|
+
issues.push({
|
|
2019
|
+
code: "canvas_content_at_edge",
|
|
2020
|
+
severity: "warning",
|
|
2021
|
+
time,
|
|
2022
|
+
selector: uniqueSelectorFor(canvas),
|
|
2023
|
+
containerSelector: uniqueSelectorFor(root),
|
|
2024
|
+
message: `Canvas content touches the frame edge (${sides.join(", ")}).`,
|
|
2025
|
+
rect,
|
|
2026
|
+
containerRect: rootRect,
|
|
2027
|
+
fixHint:
|
|
2028
|
+
"Keep drawn text inside the visible part of the canvas, or mark intentional full-bleed art with data-layout-allow-overflow.",
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
return issues;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// Soft prior only — the counterfactual attach test (below) is what makes detachment a finding.
|
|
2035
|
+
const CONNECTOR_NAME = /\b(conn(ector)?|arrow|edge|link|flow|wire)\b/i;
|
|
2036
|
+
const CONNECTOR_SKIP_CONTAINERS = "defs, marker, clipPath, mask, symbol, pattern";
|
|
2037
|
+
|
|
2038
|
+
function connectorNameFor(element) {
|
|
2039
|
+
const className =
|
|
2040
|
+
typeof element.className === "string" ? element.className : element.className.baseVal || "";
|
|
2041
|
+
return `${element.id || ""} ${className}`;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function isConnectorPath(svg, path) {
|
|
2045
|
+
if (path.hasAttribute("marker-start") || path.hasAttribute("marker-end")) return true;
|
|
2046
|
+
return (
|
|
2047
|
+
CONNECTOR_NAME.test(connectorNameFor(svg)) || CONNECTOR_NAME.test(connectorNameFor(path))
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// Raw `d`-space endpoints (no CTM), as authors paste screen coords into `d`, plus the path length.
|
|
2052
|
+
function pathUserEndpoints(path) {
|
|
2053
|
+
if (typeof path.getTotalLength !== "function" || typeof path.getPointAtLength !== "function") {
|
|
2054
|
+
return null;
|
|
2055
|
+
}
|
|
2056
|
+
let total;
|
|
2057
|
+
try {
|
|
2058
|
+
total = path.getTotalLength();
|
|
2059
|
+
} catch {
|
|
2060
|
+
return null;
|
|
2061
|
+
}
|
|
2062
|
+
if (!Number.isFinite(total) || total <= 0) return null;
|
|
2063
|
+
const start = path.getPointAtLength(0);
|
|
2064
|
+
const end = path.getPointAtLength(total);
|
|
2065
|
+
return { start: { x: start.x, y: start.y }, end: { x: end.x, y: end.y }, total };
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
// Screen endpoints via getScreenCTM (viewBox, preserveAspectRatio, group transforms).
|
|
2069
|
+
function pathScreenEndpoints(svg, path, user) {
|
|
2070
|
+
if (
|
|
2071
|
+
!user ||
|
|
2072
|
+
typeof path.getScreenCTM !== "function" ||
|
|
2073
|
+
typeof svg.createSVGPoint !== "function"
|
|
2074
|
+
) {
|
|
2075
|
+
return null;
|
|
2076
|
+
}
|
|
2077
|
+
const matrix = path.getScreenCTM();
|
|
2078
|
+
if (!matrix) return null;
|
|
2079
|
+
const toScreen = (local) => {
|
|
2080
|
+
const point = svg.createSVGPoint();
|
|
2081
|
+
point.x = local.x;
|
|
2082
|
+
point.y = local.y;
|
|
2083
|
+
const mapped = point.matrixTransform(matrix);
|
|
2084
|
+
return { x: mapped.x, y: mapped.y };
|
|
2085
|
+
};
|
|
2086
|
+
return { start: toScreen(user.start), end: toScreen(user.end) };
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
function distanceToRect(point, rect) {
|
|
2090
|
+
const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
|
|
2091
|
+
const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
|
|
2092
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// Solid, compact elements a connector could plausibly anchor to.
|
|
2096
|
+
// Both tiers keep `element` so attachment identity is stable across containment vs near-miss.
|
|
2097
|
+
function connectorAnchorRects(root, rootRect) {
|
|
2098
|
+
const compact = [];
|
|
2099
|
+
const painted = [];
|
|
2100
|
+
const rootArea = rectArea(rootRect);
|
|
2101
|
+
for (const element of Array.from(root.querySelectorAll("*"))) {
|
|
2102
|
+
// Known blind spot: anchors living inside an SVG (<image>, foreignObject) are not counted.
|
|
2103
|
+
if (element.closest("svg") || !isVisibleElement(element)) continue;
|
|
2104
|
+
const opaque =
|
|
2105
|
+
RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(getComputedStyle(element));
|
|
2106
|
+
if (!opaque && !textContentFor(element)) continue;
|
|
2107
|
+
const rect = toRect(element.getBoundingClientRect());
|
|
2108
|
+
const area = rectArea(rect);
|
|
2109
|
+
if (area < 400) continue;
|
|
2110
|
+
// Containment tier: large opaque targets only — a text-bearing wrapper contains its own diagram's endpoints.
|
|
2111
|
+
if (opaque && area <= rootArea * 0.6) painted.push({ rect, element });
|
|
2112
|
+
if (area <= rootArea * 0.15) compact.push({ rect, element });
|
|
2113
|
+
}
|
|
2114
|
+
return { compact, painted };
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// Attach near-miss tolerance (screen px) for both connector findings; not the glyph chord floor.
|
|
2118
|
+
function connectorAttachThreshold(rootRect) {
|
|
2119
|
+
return Math.max(32, Math.min(rootRect.width, rootRect.height) * 0.02);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// The one connector enumeration for connector_detached and connector_orphan. Skips dash-hidden
|
|
2123
|
+
// shafts; reports `painted` for callers to gate on, and `chord`, the rendered span in screen px.
|
|
2124
|
+
function* connectorShafts(root) {
|
|
2125
|
+
for (const svg of Array.from(root.querySelectorAll("svg"))) {
|
|
2126
|
+
if (!isVisibleElement(svg)) continue;
|
|
2127
|
+
for (const path of Array.from(svg.querySelectorAll("path"))) {
|
|
2128
|
+
if (path.closest(CONNECTOR_SKIP_CONTAINERS)) continue;
|
|
2129
|
+
if (!isConnectorPath(svg, path)) continue;
|
|
2130
|
+
const user = pathUserEndpoints(path);
|
|
2131
|
+
if (!user || shaftDashHidden(path, user.total)) continue;
|
|
2132
|
+
const rendered = pathScreenEndpoints(svg, path, user);
|
|
2133
|
+
if (!rendered) continue;
|
|
2134
|
+
const chord = Math.hypot(
|
|
2135
|
+
rendered.end.x - rendered.start.x,
|
|
2136
|
+
rendered.end.y - rendered.start.y,
|
|
2137
|
+
);
|
|
2138
|
+
yield { svg, path, user, rendered, chord, painted: shaftIsPainted(path) };
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// Flag only the documented bug: rendered endpoints miss, but user-space-as-screen would attach.
|
|
2144
|
+
function connectorDetachmentIssues(root, rootRect, time) {
|
|
2145
|
+
const issues = [];
|
|
2146
|
+
let anchors = null;
|
|
2147
|
+
const threshold = connectorAttachThreshold(rootRect);
|
|
2148
|
+
const MIN_CONNECTOR_CHORD_PX = 8;
|
|
2149
|
+
for (const { svg, path, user, rendered, chord } of connectorShafts(root)) {
|
|
2150
|
+
if (chord < MIN_CONNECTOR_CHORD_PX) continue;
|
|
2151
|
+
if (anchors === null) anchors = connectorAnchorRects(root, rootRect);
|
|
2152
|
+
if (anchors.compact.length < 2) return issues;
|
|
2153
|
+
// Stable DOM identity across painted (inside) and compact (near-miss) tiers.
|
|
2154
|
+
const attachmentKey = (point) => {
|
|
2155
|
+
for (const anchor of anchors.painted) {
|
|
2156
|
+
if (!anchor.element.contains(svg) && distanceToRect(point, anchor.rect) === 0) {
|
|
2157
|
+
return anchor.element;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
for (const anchor of anchors.compact) {
|
|
2161
|
+
if (distanceToRect(point, anchor.rect) <= threshold) return anchor.element;
|
|
2162
|
+
}
|
|
2163
|
+
return null;
|
|
2164
|
+
};
|
|
2165
|
+
const attached = (point) => attachmentKey(point) !== null;
|
|
2166
|
+
// Half-attached as drawn is allowed; only full render-miss proceeds.
|
|
2167
|
+
if (attached(rendered.start) || attached(rendered.end)) continue;
|
|
2168
|
+
// Paste-into-`d` bug: both raw endpoints land on distinct anchors as screen pixels.
|
|
2169
|
+
const userStartKey = attachmentKey(user.start);
|
|
2170
|
+
const userEndKey = attachmentKey(user.end);
|
|
2171
|
+
const pasteBug = Boolean(userStartKey && userEndKey && userStartKey !== userEndKey);
|
|
2172
|
+
// Guessed marked shaft: both frames miss. Same-anchor grazes attach in user-space
|
|
2173
|
+
// and must stay skipped. Name-only decorative flow/arrow paths stay skipped.
|
|
2174
|
+
// 80px keeps short marker glyphs (chevrons, tips) out.
|
|
2175
|
+
const markedMiss =
|
|
2176
|
+
chord >= 80 &&
|
|
2177
|
+
!userStartKey &&
|
|
2178
|
+
!userEndKey &&
|
|
2179
|
+
(path.hasAttribute("marker-start") || path.hasAttribute("marker-end"));
|
|
2180
|
+
if (!pasteBug && !markedMiss) continue;
|
|
2181
|
+
const gap = Math.round(
|
|
2182
|
+
Math.min(
|
|
2183
|
+
Math.min(...anchors.compact.map((a) => distanceToRect(rendered.start, a.rect))),
|
|
2184
|
+
Math.min(...anchors.compact.map((a) => distanceToRect(rendered.end, a.rect))),
|
|
2185
|
+
),
|
|
2186
|
+
);
|
|
2187
|
+
issues.push({
|
|
2188
|
+
code: "connector_detached",
|
|
2189
|
+
severity: "warning",
|
|
2190
|
+
time,
|
|
2191
|
+
selector: uniqueSelectorFor(path),
|
|
2192
|
+
containerSelector: uniqueSelectorFor(svg),
|
|
2193
|
+
message: pasteBug
|
|
2194
|
+
? `Connector path endpoints render ${gap}px from the nearest anchorable element, but the path's user-space coordinates would attach if read as screen pixels — screen/viewport numbers were likely written into SVG \`d\` without inverting the CTM.`
|
|
2195
|
+
: `Connector path endpoints render ${gap}px from the nearest anchorable element — a marked shaft that meets no node.`,
|
|
2196
|
+
rect: toRect({
|
|
2197
|
+
left: Math.min(rendered.start.x, rendered.end.x),
|
|
2198
|
+
top: Math.min(rendered.start.y, rendered.end.y),
|
|
2199
|
+
right: Math.max(rendered.start.x, rendered.end.x),
|
|
2200
|
+
bottom: Math.max(rendered.start.y, rendered.end.y),
|
|
2201
|
+
width: Math.abs(rendered.end.x - rendered.start.x),
|
|
2202
|
+
height: Math.abs(rendered.end.y - rendered.start.y),
|
|
2203
|
+
}),
|
|
2204
|
+
fixHint: pasteBug
|
|
2205
|
+
? "Convert measured screen coordinates into the SVG's user space (subtract the SVG rect / invert getScreenCTM) before writing path `d`, and keep the SVG a direct child of the stage."
|
|
2206
|
+
: "Measure the settled node boxes and write `d` in the SVG's user space (invert getScreenCTM), or grow a layout-owned shaft from the source node.",
|
|
2207
|
+
});
|
|
2208
|
+
}
|
|
2209
|
+
return issues;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
function shaftIsPainted(path) {
|
|
2213
|
+
if (IGNORE_TAGS.has(path.tagName) || hasIgnoreFlag(path)) return false;
|
|
2214
|
+
const style = getComputedStyle(path);
|
|
2215
|
+
if (
|
|
2216
|
+
style.display === "none" ||
|
|
2217
|
+
style.visibility === "hidden" ||
|
|
2218
|
+
style.visibility === "collapse"
|
|
2219
|
+
) {
|
|
2220
|
+
return false;
|
|
2221
|
+
}
|
|
2222
|
+
return opacityChain(path) >= 0.2;
|
|
2223
|
+
}
|
|
2224
|
+
|
|
2225
|
+
// True when the visible dash window [offset, offset + length] sits in one gap (an unstarted
|
|
2226
|
+
// draw-on), allowing 10% overlap. Zero-length dashes paint only with a round or square linecap.
|
|
2227
|
+
// Distances are in `pathLength` units when set; a `pathLength` resolving to 0 paints solid.
|
|
2228
|
+
function shaftDashHidden(path, total) {
|
|
2229
|
+
const style = getComputedStyle(path);
|
|
2230
|
+
const dashes = dashArrayLengths(style.strokeDasharray, path);
|
|
2231
|
+
if (dashes === null) return false;
|
|
2232
|
+
const authored = path.pathLength?.baseVal; // SVGAnimatedNumber; 0 when unset or unparseable
|
|
2233
|
+
if (authored === 0 && path.hasAttribute("pathLength")) return false;
|
|
2234
|
+
const dotsPaint = (style.strokeLinecap || "butt") !== "butt";
|
|
2235
|
+
const dashPaints = (index) => index % 2 === 0 && (dashes[index] > 0 || dotsPaint);
|
|
2236
|
+
if (!dashes.some((_, index) => dashPaints(index))) return true; // only butt-capped dots
|
|
2237
|
+
const period = dashes.reduce((sum, dash) => sum + dash, 0);
|
|
2238
|
+
const length = authored > 0 ? authored : total;
|
|
2239
|
+
const offset = dashLength(style.strokeDashoffset, path); // unparseable reads as 0
|
|
2240
|
+
// Wrap into [0, period); adding the period only to negatives keeps `0.9 % 2` exact.
|
|
2241
|
+
const wrapped = Number.isFinite(offset) ? offset % period : 0;
|
|
2242
|
+
const start = wrapped < 0 ? wrapped + period : wrapped;
|
|
2243
|
+
const end = start + length;
|
|
2244
|
+
let painted = 0;
|
|
2245
|
+
let segmentStart = 0;
|
|
2246
|
+
// Two periods cover any window that starts inside the first.
|
|
2247
|
+
for (let i = 0; i < dashes.length * 2; i++) {
|
|
2248
|
+
const segmentEnd = segmentStart + dashes[i % dashes.length];
|
|
2249
|
+
if (dashPaints(i % dashes.length)) {
|
|
2250
|
+
if (segmentStart >= start && segmentEnd <= end) return false;
|
|
2251
|
+
painted += Math.max(0, Math.min(segmentEnd, end) - Math.max(segmentStart, start));
|
|
2252
|
+
}
|
|
2253
|
+
segmentStart = segmentEnd;
|
|
2254
|
+
}
|
|
2255
|
+
return painted <= length * 0.1;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// Computed `stroke-dasharray` as an even-length list of user-unit lengths, or null when the
|
|
2259
|
+
// stroke is solid: `none`, an all-zero list, or any negative/unparseable entry (which the spec
|
|
2260
|
+
// renders as `none`). Odd lists repeat, per spec.
|
|
2261
|
+
function dashArrayLengths(value, path) {
|
|
2262
|
+
const text = String(value || "none").trim();
|
|
2263
|
+
if (text === "none") return null;
|
|
2264
|
+
const lengths = text.split(/[\s,]+/).map((token) => dashLength(token, path));
|
|
2265
|
+
if (lengths.some((length) => !Number.isFinite(length) || length < 0)) return null;
|
|
2266
|
+
if (lengths.every((length) => length === 0)) return null;
|
|
2267
|
+
return lengths.length % 2 === 0 ? lengths : lengths.concat(lengths);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
// One dash length in user units. Computed lengths are already px; a percentage is relative to
|
|
2271
|
+
// the normalised diagonal of the owning SVG viewport (viewBox when set, else the layout box).
|
|
2272
|
+
function dashLength(token, path) {
|
|
2273
|
+
const text = String(token).trim();
|
|
2274
|
+
const value = Number.parseFloat(text);
|
|
2275
|
+
if (!Number.isFinite(value) || !text.endsWith("%")) return value;
|
|
2276
|
+
const svg = path.ownerSVGElement;
|
|
2277
|
+
if (!svg) return NaN;
|
|
2278
|
+
const box = svg.viewBox && svg.viewBox.baseVal;
|
|
2279
|
+
const { width, height } =
|
|
2280
|
+
box && box.width > 0 && box.height > 0 ? box : svg.getBoundingClientRect();
|
|
2281
|
+
return (value / 100) * (Math.hypot(width, height) / Math.SQRT2);
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
function connectorEndpointCandidates(root, rootRect) {
|
|
2285
|
+
const candidates = [];
|
|
2286
|
+
const rootArea = rectArea(rootRect);
|
|
2287
|
+
for (const element of Array.from(root.querySelectorAll("*"))) {
|
|
2288
|
+
if (element.closest("svg") || IGNORE_TAGS.has(element.tagName) || hasIgnoreFlag(element))
|
|
2289
|
+
continue;
|
|
2290
|
+
const style = getComputedStyle(element);
|
|
2291
|
+
const opaque = RASTER_TAGS.has(element.tagName) || hasOpaqueBackground(style);
|
|
2292
|
+
if (!opaque && !textContentFor(element)) continue;
|
|
2293
|
+
const rect = toRect(element.getBoundingClientRect());
|
|
2294
|
+
const area = rectArea(rect);
|
|
2295
|
+
if (area < 400 || area > rootArea * 0.15) continue;
|
|
2296
|
+
candidates.push({ rect, element });
|
|
2297
|
+
}
|
|
2298
|
+
return candidates;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
function connectorOrphanIssues(root, rootRect, time) {
|
|
2302
|
+
const issues = [];
|
|
2303
|
+
let candidates = null;
|
|
2304
|
+
const threshold = connectorAttachThreshold(rootRect);
|
|
2305
|
+
for (const { svg, path, rendered, chord, painted } of connectorShafts(root)) {
|
|
2306
|
+
if (!painted) continue;
|
|
2307
|
+
if (chord < 80) continue;
|
|
2308
|
+
if (candidates === null) candidates = connectorEndpointCandidates(root, rootRect);
|
|
2309
|
+
const dark = [];
|
|
2310
|
+
for (const point of [rendered.start, rendered.end]) {
|
|
2311
|
+
let best = null;
|
|
2312
|
+
let attached = false;
|
|
2313
|
+
for (const candidate of candidates) {
|
|
2314
|
+
const gap = distanceToRect(point, candidate.rect);
|
|
2315
|
+
if (gap > threshold) continue;
|
|
2316
|
+
if (isVisibleElement(candidate.element)) {
|
|
2317
|
+
attached = true;
|
|
2318
|
+
break;
|
|
2319
|
+
}
|
|
2320
|
+
if (best === null || gap < best.gap) best = { gap, candidate };
|
|
2321
|
+
}
|
|
2322
|
+
if (!attached && best !== null) dark.push(best.candidate);
|
|
2323
|
+
}
|
|
2324
|
+
if (dark.length === 0) continue;
|
|
2325
|
+
issues.push({
|
|
2326
|
+
code: "connector_orphan",
|
|
2327
|
+
severity: "warning",
|
|
2328
|
+
time,
|
|
2329
|
+
selector: uniqueSelectorFor(path),
|
|
2330
|
+
containerSelector: uniqueSelectorFor(svg),
|
|
2331
|
+
message: `Connector shaft is visible while ${dark.length === 2 ? "both endpoints are" : `its endpoint ${uniqueSelectorFor(dark[0].element)} is`} not on stage.`,
|
|
2332
|
+
rect: toRect({
|
|
2333
|
+
left: Math.min(rendered.start.x, rendered.end.x),
|
|
2334
|
+
top: Math.min(rendered.start.y, rendered.end.y),
|
|
2335
|
+
right: Math.max(rendered.start.x, rendered.end.x),
|
|
2336
|
+
bottom: Math.max(rendered.start.y, rendered.end.y),
|
|
2337
|
+
width: Math.abs(rendered.end.x - rendered.start.x),
|
|
2338
|
+
height: Math.abs(rendered.end.y - rendered.start.y),
|
|
2339
|
+
}),
|
|
2340
|
+
fixHint:
|
|
2341
|
+
"Show the shaft only after both ends are on, and hide it with the earlier exit. Do not give the line its own clock.",
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
return issues;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
function candidateAnchor(element) {
|
|
2348
|
+
const dataAttributes = {};
|
|
2349
|
+
for (const attribute of Array.from(element.attributes)) {
|
|
2350
|
+
if (attribute.name.startsWith("data-")) dataAttributes[attribute.name] = attribute.value;
|
|
2351
|
+
}
|
|
2352
|
+
const source = element
|
|
2353
|
+
.closest("[data-composition-file]")
|
|
2354
|
+
?.getAttribute("data-composition-file");
|
|
2355
|
+
return {
|
|
2356
|
+
selector: uniqueSelectorFor(element),
|
|
2357
|
+
dataAttributes,
|
|
2358
|
+
sourceFile: source || "index.html",
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
function geometryCandidate(element, kind, rect, elementRect, rootRect, tolerance) {
|
|
2363
|
+
const tag = element.tagName.toLowerCase();
|
|
2364
|
+
const text = kind === "text" ? textContentFor(element, true) : tag;
|
|
2365
|
+
const overflow = kind === "media" ? overflowFor(elementRect, rootRect, tolerance) : null;
|
|
2366
|
+
return {
|
|
2367
|
+
kind,
|
|
2368
|
+
tag,
|
|
2369
|
+
text,
|
|
2370
|
+
rect,
|
|
2371
|
+
elementRect,
|
|
2372
|
+
...candidateAnchor(element),
|
|
2373
|
+
...(overflow ? { overflow } : {}),
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
window.__hyperframesGeometryCandidates = function collectGeometryCandidates(options) {
|
|
2378
|
+
const includeText = options?.text === true;
|
|
2379
|
+
const includeMedia = options?.media === true;
|
|
2380
|
+
if (!includeText && !includeMedia) return [];
|
|
2381
|
+
const tolerance = typeof options?.tolerance === "number" ? options.tolerance : 2;
|
|
2382
|
+
const root =
|
|
2383
|
+
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
|
2384
|
+
document.querySelector("[data-composition-id]") ||
|
|
2385
|
+
document.body;
|
|
2386
|
+
const rootRect = rootRectFor(root);
|
|
2387
|
+
const candidates = [];
|
|
2388
|
+
for (const element of Array.from(document.querySelectorAll("body *"))) {
|
|
2389
|
+
if (element.closest('[data-composition-id="captions"], .caption-layer, #caption-stage')) {
|
|
2390
|
+
continue;
|
|
2391
|
+
}
|
|
2392
|
+
if (!isVisibleElement(element, 0.05, false)) continue;
|
|
2393
|
+
const elementRect = toRect(element.getBoundingClientRect());
|
|
2394
|
+
if (includeText && hasOwnTextCandidate(element, true) && !hasAllowCaptionZoneFlag(element)) {
|
|
2395
|
+
const rect = textRectFor(element, true);
|
|
2396
|
+
if (rect) {
|
|
2397
|
+
candidates.push(
|
|
2398
|
+
geometryCandidate(element, "text", rect, elementRect, rootRect, tolerance),
|
|
2399
|
+
);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
if (includeMedia && FRAME_MEDIA_TAGS.has(element.tagName.toUpperCase())) {
|
|
2403
|
+
candidates.push(
|
|
2404
|
+
geometryCandidate(element, "media", elementRect, elementRect, rootRect, tolerance),
|
|
2405
|
+
);
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
return candidates;
|
|
2409
|
+
};
|
|
2410
|
+
|
|
2411
|
+
window.__hyperframesLayoutAudit = function auditLayout(options) {
|
|
2412
|
+
const time = options && typeof options.time === "number" ? options.time : 0;
|
|
2413
|
+
const tolerance =
|
|
2414
|
+
options && typeof options.tolerance === "number" ? Math.max(0, options.tolerance) : 2;
|
|
2415
|
+
const proseCoverageFloor =
|
|
2416
|
+
options && typeof options.proseCoverageFloor === "number"
|
|
2417
|
+
? Math.min(1, Math.max(0, options.proseCoverageFloor))
|
|
2418
|
+
: DEFAULT_PROSE_COVERAGE_FLOOR;
|
|
2419
|
+
const root =
|
|
2420
|
+
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
|
2421
|
+
document.querySelector("[data-composition-id]") ||
|
|
2422
|
+
document.body;
|
|
2423
|
+
const rootRect = rootRectFor(root);
|
|
2424
|
+
const elements = Array.from(root.querySelectorAll("*")).filter((element) =>
|
|
2425
|
+
isVisibleElement(element, 0.05),
|
|
2426
|
+
);
|
|
2427
|
+
const issues = [];
|
|
2428
|
+
const matteResults = new Map();
|
|
2429
|
+
videoAlphaFrames = new Map();
|
|
2430
|
+
|
|
2431
|
+
const restoreHits = restoreHitTesting(root);
|
|
2432
|
+
try {
|
|
2433
|
+
for (const element of elements) {
|
|
2434
|
+
if (!hasOwnTextCandidate(element)) continue;
|
|
2435
|
+
const clipped = clippedTextIssue(element, time, tolerance);
|
|
2436
|
+
if (clipped) issues.push(clipped);
|
|
2437
|
+
issues.push(...textOverflowIssues(element, root, rootRect, time, tolerance, clipped));
|
|
2438
|
+
const occluded = occludedTextIssue(element, time, proseCoverageFloor, matteResults);
|
|
2439
|
+
if (occluded) issues.push(occluded);
|
|
2440
|
+
const invisible = invisibleTextIssue(element, time);
|
|
2441
|
+
if (invisible) issues.push(invisible);
|
|
2442
|
+
}
|
|
2443
|
+
} finally {
|
|
2444
|
+
restoreHits();
|
|
2445
|
+
}
|
|
2446
|
+
markLostSiblingWords(matteResults);
|
|
2447
|
+
issues.push(...unreadVideoIssues(time));
|
|
2448
|
+
|
|
2449
|
+
issues.push(...containerOverflowIssues(root, time, tolerance));
|
|
2450
|
+
issues.push(...contentOverlapIssues(root, time));
|
|
2451
|
+
const escaped = escapedContainerIssues(root, time);
|
|
2452
|
+
issues.push(...escaped.issues);
|
|
2453
|
+
issues.push(...panelOutOfCanvasIssues(root, rootRect, time, tolerance, escaped.flagged));
|
|
2454
|
+
issues.push(...canvasEdgeIssues(root, rootRect, time, tolerance));
|
|
2455
|
+
issues.push(...connectorDetachmentIssues(root, rootRect, time));
|
|
2456
|
+
issues.push(...connectorOrphanIssues(root, rootRect, time));
|
|
2457
|
+
return issues;
|
|
2458
|
+
};
|
|
2459
|
+
|
|
2460
|
+
// Reruns only the overlap detector (same threshold, no new surface) on a fine grid for the dense motion re-sampling pass.
|
|
2461
|
+
window.__hyperframesOverlapAudit = function auditOverlap(options) {
|
|
2462
|
+
const time = options && typeof options.time === "number" ? options.time : 0;
|
|
2463
|
+
const root =
|
|
2464
|
+
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
|
2465
|
+
document.querySelector("[data-composition-id]") ||
|
|
2466
|
+
document.body;
|
|
2467
|
+
return contentOverlapIssues(root, time);
|
|
2468
|
+
};
|
|
2469
|
+
|
|
2470
|
+
// Craft sampling for check's craft section (utils/craftAudit.ts judges it in Node). One call per seek:
|
|
2471
|
+
// every painted text element with its reading block, rendered font size and opacity; whether a data-hold
|
|
2472
|
+
// element is on screen; whether anything besides the background paints; and the keepsMoving liveness
|
|
2473
|
+
// signature from motion-signature.browser.js, so a still stretch here is the stillness motion_frozen sees.
|
|
2474
|
+
const craftKeys = new WeakMap();
|
|
2475
|
+
const craftAnchors = new WeakMap();
|
|
2476
|
+
let craftNextKey = 0;
|
|
2477
|
+
const INLINE_LEVEL_DISPLAY = /^(inline|inline-block|inline-flex|inline-grid|contents|ruby)$/;
|
|
2478
|
+
const CRAFT_PAINTED_OPACITY = 0.2;
|
|
2479
|
+
const PAINT_OPTIONS = { opacityProperty: true, visibilityProperty: true };
|
|
2480
|
+
|
|
2481
|
+
function craftKey(element) {
|
|
2482
|
+
if (!craftKeys.has(element)) craftKeys.set(element, ++craftNextKey);
|
|
2483
|
+
return craftKeys.get(element);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// Sampled at every seek of a dense grid, so the selector walk runs once per element.
|
|
2487
|
+
function craftAnchor(element) {
|
|
2488
|
+
if (!craftAnchors.has(element)) {
|
|
2489
|
+
const source = element
|
|
2490
|
+
.closest("[data-composition-file]")
|
|
2491
|
+
?.getAttribute("data-composition-file");
|
|
2492
|
+
craftAnchors.set(element, {
|
|
2493
|
+
selector: uniqueSelectorFor(element),
|
|
2494
|
+
sourceFile: source || "index.html",
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
return craftAnchors.get(element);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
// Mirrors trackKindOf in @framey/parsers (data-track-kind, legacy caption hosts) plus the caption
|
|
2501
|
+
// stages __hyperframesGeometryCandidates already skips.
|
|
2502
|
+
function isCaptionsText(element) {
|
|
2503
|
+
for (let current = element; current; current = current.parentElement) {
|
|
2504
|
+
if ((current.getAttribute("data-track-kind") || "").trim().toLowerCase() === "captions") {
|
|
2505
|
+
return true;
|
|
2506
|
+
}
|
|
2507
|
+
const host =
|
|
2508
|
+
current.hasAttribute("data-composition-id") || current.hasAttribute("data-composition-src");
|
|
2509
|
+
if (!host) continue;
|
|
2510
|
+
if (current.getAttribute("data-composition-id") === "captions") return true;
|
|
2511
|
+
if (/(?:^|\s)caption[-_]/.test(current.getAttribute("class") || "")) return true;
|
|
2512
|
+
}
|
|
2513
|
+
return !!element.closest(".caption-layer, #caption-stage");
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
// The line a reader takes in at once: the nearest block-level ancestor-or-self, so a headline split into
|
|
2517
|
+
// inline word or character spans is one line, while flex items and positioned spans stay their own.
|
|
2518
|
+
function readingBlock(element, root) {
|
|
2519
|
+
let current = element;
|
|
2520
|
+
while (
|
|
2521
|
+
current !== root &&
|
|
2522
|
+
current.parentElement &&
|
|
2523
|
+
!current.hasAttribute("data-composition-id") &&
|
|
2524
|
+
INLINE_LEVEL_DISPLAY.test(getComputedStyle(current).display)
|
|
2525
|
+
) {
|
|
2526
|
+
current = current.parentElement;
|
|
2527
|
+
}
|
|
2528
|
+
return current;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
function scaleProperty(style) {
|
|
2532
|
+
const parts = (style.scale || "none")
|
|
2533
|
+
.split(/\s+/)
|
|
2534
|
+
.map((part) => Number.parseFloat(part))
|
|
2535
|
+
.filter(Number.isFinite);
|
|
2536
|
+
if (parts.length === 0) return 1;
|
|
2537
|
+
return Math.sqrt(Math.abs(parts[0] * (parts.length > 1 ? parts[1] : parts[0])));
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// A transform scales glyphs without changing the computed font-size: multiply in every ancestor's area
|
|
2541
|
+
// scale (rotation-invariant), or the SVG screen matrix for SVG text.
|
|
2542
|
+
function renderedFontPx(element, style) {
|
|
2543
|
+
const font = parsePx(style.fontSize);
|
|
2544
|
+
if (typeof element.getScreenCTM === "function" && element.ownerSVGElement) {
|
|
2545
|
+
const ctm = element.getScreenCTM();
|
|
2546
|
+
return ctm ? font * Math.sqrt(Math.abs(ctm.a * ctm.d - ctm.b * ctm.c)) : font;
|
|
2547
|
+
}
|
|
2548
|
+
let scale = 1;
|
|
2549
|
+
for (let current = element; current; current = current.parentElement) {
|
|
2550
|
+
const currentStyle = current === element ? style : getComputedStyle(current);
|
|
2551
|
+
if (currentStyle.transform && currentStyle.transform !== "none") {
|
|
2552
|
+
try {
|
|
2553
|
+
const matrix = new DOMMatrixReadOnly(currentStyle.transform);
|
|
2554
|
+
scale *= Math.sqrt(Math.abs(matrix.a * matrix.d - matrix.b * matrix.c));
|
|
2555
|
+
} catch {
|
|
2556
|
+
// An unparsable transform leaves the size as computed.
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
scale *= scaleProperty(currentStyle);
|
|
2560
|
+
}
|
|
2561
|
+
return font * scale;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function intersectsRect(rect, frame) {
|
|
2565
|
+
return (
|
|
2566
|
+
rect.right > frame.left &&
|
|
2567
|
+
rect.left < frame.right &&
|
|
2568
|
+
rect.bottom > frame.top &&
|
|
2569
|
+
rect.top < frame.bottom
|
|
2570
|
+
);
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
function craftTextParts(root, rootRect) {
|
|
2574
|
+
const candidates = [];
|
|
2575
|
+
for (const element of Array.from(root.querySelectorAll("*"))) {
|
|
2576
|
+
if (!hasOwnTextCandidate(element, true) || !isVisibleElement(element, 0.05, false)) continue;
|
|
2577
|
+
const rect = unionRects(visibleTextClientRects(element, true));
|
|
2578
|
+
if (!rect || !intersectsRect(rect, rootRect)) continue;
|
|
2579
|
+
candidates.push({ element, rect });
|
|
2580
|
+
}
|
|
2581
|
+
const clipped = candidates.filter((candidate) => hasClipPath(candidate.element));
|
|
2582
|
+
const clippedAway = new Set();
|
|
2583
|
+
if (clipped.length > 0) {
|
|
2584
|
+
const restoreHits = restoreHitTesting(root);
|
|
2585
|
+
try {
|
|
2586
|
+
for (const candidate of clipped) {
|
|
2587
|
+
if (isClippedAway(candidate.element)) clippedAway.add(candidate.element);
|
|
2588
|
+
}
|
|
2589
|
+
} finally {
|
|
2590
|
+
restoreHits();
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
return candidates
|
|
2594
|
+
.filter((candidate) => !clippedAway.has(candidate.element))
|
|
2595
|
+
.map(({ element, rect }) => {
|
|
2596
|
+
const block = readingBlock(element, root);
|
|
2597
|
+
return {
|
|
2598
|
+
key: craftKey(element),
|
|
2599
|
+
block: craftKey(block),
|
|
2600
|
+
...craftAnchor(element),
|
|
2601
|
+
blockSelector: craftAnchor(block).selector,
|
|
2602
|
+
text: textContentFor(element, true),
|
|
2603
|
+
opacity: round(opacityChain(element)),
|
|
2604
|
+
fontPx: round(renderedFontPx(element, getComputedStyle(element))),
|
|
2605
|
+
rect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
|
|
2606
|
+
captions: isCaptionsText(element),
|
|
2607
|
+
};
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
function isPaintedBox(element, rootRect) {
|
|
2612
|
+
if (typeof element.checkVisibility === "function" && !element.checkVisibility(PAINT_OPTIONS)) {
|
|
2613
|
+
return false;
|
|
2614
|
+
}
|
|
2615
|
+
const style = getComputedStyle(element);
|
|
2616
|
+
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
2617
|
+
if (opacityChain(element) < CRAFT_PAINTED_OPACITY) return false;
|
|
2618
|
+
const rect = element.getBoundingClientRect();
|
|
2619
|
+
return rect.width >= 4 && rect.height >= 4 && intersectsRect(rect, rootRect);
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function paintsContent(root, rootRect, parts) {
|
|
2623
|
+
if (parts.some((part) => part.opacity >= CRAFT_PAINTED_OPACITY)) return true;
|
|
2624
|
+
for (const element of Array.from(root.querySelectorAll("img, video, canvas, svg"))) {
|
|
2625
|
+
if (element.parentElement?.closest("svg")) continue;
|
|
2626
|
+
if (isPaintedBox(element, rootRect)) return true;
|
|
2627
|
+
}
|
|
2628
|
+
return false;
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
window.__hyperframesCraftSample = function collectCraftSample() {
|
|
2632
|
+
const root =
|
|
2633
|
+
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
|
2634
|
+
document.querySelector("[data-composition-id]") ||
|
|
2635
|
+
document.body;
|
|
2636
|
+
const rootRect = rootRectFor(root);
|
|
2637
|
+
const parts = craftTextParts(root, rootRect);
|
|
2638
|
+
const holds = [root, ...Array.from(root.querySelectorAll("[data-hold]"))].filter((element) =>
|
|
2639
|
+
element.hasAttribute("data-hold"),
|
|
2640
|
+
);
|
|
2641
|
+
const shared = window.__hyperframesMotionSignature;
|
|
2642
|
+
return {
|
|
2643
|
+
parts,
|
|
2644
|
+
held: holds.some((element) => isPaintedBox(element, rootRect)),
|
|
2645
|
+
painted: paintsContent(root, rootRect, parts),
|
|
2646
|
+
signature: shared
|
|
2647
|
+
? shared.compositionSignature(shared.compositionRoot(), { quantize: true })
|
|
2648
|
+
: "",
|
|
2649
|
+
};
|
|
2650
|
+
};
|
|
2651
|
+
|
|
2652
|
+
// Sound the render mixes: any unmuted <audio>, and a <video> the timing compiler marks audible
|
|
2653
|
+
// (data-has-audio="true", or no data-has-audio on an unmuted video).
|
|
2654
|
+
window.__hyperframesCraftFilm = function collectCraftFilm() {
|
|
2655
|
+
let soundSources = 0;
|
|
2656
|
+
for (const media of Array.from(document.querySelectorAll("audio, video"))) {
|
|
2657
|
+
if (media.hasAttribute("muted")) continue;
|
|
2658
|
+
const declared = media.getAttribute("data-has-audio");
|
|
2659
|
+
const audible =
|
|
2660
|
+
media.tagName.toUpperCase() === "AUDIO" || declared === null || declared === "true";
|
|
2661
|
+
if (audible) soundSources += 1;
|
|
2662
|
+
}
|
|
2663
|
+
return { soundSources };
|
|
2664
|
+
};
|
|
2665
|
+
|
|
2666
|
+
// The frozen-sweep fingerprint (window.__hyperframesLayoutGeometry, #U10 in
|
|
2667
|
+
// checkPipeline.ts) lives in motion-signature.browser.js, the single owner
|
|
2668
|
+
// of "what counts as motion" shared with motion-sample.browser.js's
|
|
2669
|
+
// liveness signature.
|
|
2670
|
+
|
|
2671
|
+
// Rotation-pivot sampling (rotation_pivot_drift). Per sample, report every
|
|
2672
|
+
// rotatable candidate's bbox center, size, and current rotation angle. Node
|
|
2673
|
+
// accumulates these across the seek grid and, after the run, flags any
|
|
2674
|
+
// element that spins (angle varies) while its bbox CENTER drifts — the
|
|
2675
|
+
// signature of a wrong transformOrigin/svgOrigin (spokes swinging off-axis
|
|
2676
|
+
// instead of spinning in place). Single frame can't tell spin from pivot
|
|
2677
|
+
// drift, so this is a cross-sample finder, not a per-sample one.
|
|
2678
|
+
function rotationAngleDeg(transform) {
|
|
2679
|
+
if (!transform || transform === "none") return null;
|
|
2680
|
+
const match = transform.match(/matrix(3d)?\(([^)]+)\)/);
|
|
2681
|
+
if (!match) return null;
|
|
2682
|
+
const values = match[2].split(",").map((part) => Number.parseFloat(part));
|
|
2683
|
+
// matrix(a,b,c,d,e,f) → a=values[0], b=values[1]. matrix3d shares the same
|
|
2684
|
+
// leading two entries for the in-plane 2D rotation component.
|
|
2685
|
+
const a = values[0];
|
|
2686
|
+
const b = values[1];
|
|
2687
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
|
|
2688
|
+
return (Math.atan2(b, a) * 180) / Math.PI;
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
window.__hyperframesRotationSample = function collectRotationSample() {
|
|
2692
|
+
const root =
|
|
2693
|
+
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
|
2694
|
+
document.querySelector("[data-composition-id]") ||
|
|
2695
|
+
document.body;
|
|
2696
|
+
const samples = [];
|
|
2697
|
+
// Cap the candidate set so a pathological composition can't blow up the
|
|
2698
|
+
// per-sample payload; transformed elements above a minimum area only.
|
|
2699
|
+
const CANDIDATE_CAP = 200;
|
|
2700
|
+
for (const element of Array.from(root.querySelectorAll("*"))) {
|
|
2701
|
+
if (samples.length >= CANDIDATE_CAP) break;
|
|
2702
|
+
// Intended orbits/satellites opt out — their bbox center is SUPPOSED to
|
|
2703
|
+
// travel, so a drift finding there is a false positive.
|
|
2704
|
+
if (element.closest("[data-layout-allow-orbit]")) continue;
|
|
2705
|
+
if (!isVisibleElement(element, 0.05)) continue;
|
|
2706
|
+
const angle = rotationAngleDeg(getComputedStyle(element).transform);
|
|
2707
|
+
if (angle === null) continue; // identity / untransformed — not a candidate
|
|
2708
|
+
const box = element.getBoundingClientRect();
|
|
2709
|
+
if (box.width * box.height <= 400) continue;
|
|
2710
|
+
samples.push({
|
|
2711
|
+
selector: uniqueSelectorFor(element),
|
|
2712
|
+
cx: round(box.left + box.width / 2),
|
|
2713
|
+
cy: round(box.top + box.height / 2),
|
|
2714
|
+
w: round(box.width),
|
|
2715
|
+
h: round(box.height),
|
|
2716
|
+
angle: round(angle),
|
|
2717
|
+
});
|
|
2718
|
+
}
|
|
2719
|
+
return samples;
|
|
2720
|
+
};
|
|
2721
|
+
|
|
2722
|
+
// Needle-pivot sampling (off_pivot_rotation). A gauge/clock/radar pointer
|
|
2723
|
+
// whose center-of-rotation sits far from the dial hub. bbox-intrinsic measures
|
|
2724
|
+
// can't tell a correct sweep from a broken one (a base-pivoted needle's bbox
|
|
2725
|
+
// center orbits either way), so this records two MATERIAL points on each
|
|
2726
|
+
// elongated rotating SVG figure — mapped through getScreenCTM so the actual
|
|
2727
|
+
// rendered transform is honored regardless of svgOrigin/transform-origin — and
|
|
2728
|
+
// the dial's static hub (the point shared by the most non-rotating circles).
|
|
2729
|
+
// The pipeline fits a rotation to the material-point trajectories to recover
|
|
2730
|
+
// the real center-of-rotation and flags it when it drifts off that hub.
|
|
2731
|
+
function ctmRotationDeg(ctm) {
|
|
2732
|
+
if (!ctm) return null;
|
|
2733
|
+
return (Math.atan2(ctm.b, ctm.a) * 180) / Math.PI;
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
function ctmScale(ctm) {
|
|
2737
|
+
return Math.hypot(ctm.a, ctm.b);
|
|
2738
|
+
}
|
|
2739
|
+
|
|
2740
|
+
function mapPoint(svg, ctm, x, y) {
|
|
2741
|
+
const point = svg.createSVGPoint();
|
|
2742
|
+
point.x = x;
|
|
2743
|
+
point.y = y;
|
|
2744
|
+
const mapped = point.matrixTransform(ctm);
|
|
2745
|
+
return { x: mapped.x, y: mapped.y };
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// Walks up to (and including) the composition root, NOT just the owner <svg>:
|
|
2749
|
+
// an element spun by a div ancestor above its svg must not be mistaken for a
|
|
2750
|
+
// static hub anchor (else a lone rotating arc becomes its own dial center).
|
|
2751
|
+
function hasRotatedAncestor(element, root) {
|
|
2752
|
+
let node = element;
|
|
2753
|
+
while (node) {
|
|
2754
|
+
const angle = rotationAngleDeg(getComputedStyle(node).transform);
|
|
2755
|
+
if (angle !== null && Math.abs(angle) > 1) return true;
|
|
2756
|
+
if (node === root) break;
|
|
2757
|
+
node = node.parentElement;
|
|
2758
|
+
}
|
|
2759
|
+
return false;
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// KEEP IN SYNC with `fitCircle` in packages/cli/src/utils/checkPipeline.ts —
|
|
2763
|
+
// this browser copy resolves arc-drawn dial hubs and is injected as a raw
|
|
2764
|
+
// string (no import across the puppeteer boundary), so the Kåsa math is
|
|
2765
|
+
// intentionally duplicated per-language. Any change must land in both copies.
|
|
2766
|
+
function fitCirclePoints(points) {
|
|
2767
|
+
const count = points.length;
|
|
2768
|
+
if (count < 3) return null;
|
|
2769
|
+
const meanX = points.reduce((sum, p) => sum + p.x, 0) / count;
|
|
2770
|
+
const meanY = points.reduce((sum, p) => sum + p.y, 0) / count;
|
|
2771
|
+
let suu = 0,
|
|
2772
|
+
svv = 0,
|
|
2773
|
+
suv = 0,
|
|
2774
|
+
suuu = 0,
|
|
2775
|
+
svvv = 0,
|
|
2776
|
+
suvv = 0,
|
|
2777
|
+
svuu = 0;
|
|
2778
|
+
for (const point of points) {
|
|
2779
|
+
const u = point.x - meanX;
|
|
2780
|
+
const v = point.y - meanY;
|
|
2781
|
+
suu += u * u;
|
|
2782
|
+
svv += v * v;
|
|
2783
|
+
suv += u * v;
|
|
2784
|
+
suuu += u * u * u;
|
|
2785
|
+
svvv += v * v * v;
|
|
2786
|
+
suvv += u * v * v;
|
|
2787
|
+
svuu += v * u * u;
|
|
2788
|
+
}
|
|
2789
|
+
const det = suu * svv - suv * suv;
|
|
2790
|
+
if (Math.abs(det) < 1e-6) return null;
|
|
2791
|
+
const uc = (((suuu + suvv) / 2) * svv - ((svvv + svuu) / 2) * suv) / det;
|
|
2792
|
+
const vc = (((svvv + svuu) / 2) * suu - ((suuu + suvv) / 2) * suv) / det;
|
|
2793
|
+
const cx = uc + meanX;
|
|
2794
|
+
const cy = vc + meanY;
|
|
2795
|
+
const radius = Math.sqrt(uc * uc + vc * vc + (suu + svv) / count);
|
|
2796
|
+
let squaredError = 0;
|
|
2797
|
+
for (const point of points) {
|
|
2798
|
+
const delta = Math.hypot(point.x - cx, point.y - cy) - radius;
|
|
2799
|
+
squaredError += delta * delta;
|
|
2800
|
+
}
|
|
2801
|
+
return { cx, cy, radius, residual: Math.sqrt(squaredError / count) };
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
// Fallback for dials drawn as arc <path> rather than <circle> rings: sample
|
|
2805
|
+
// the largest static, near-circular path and recover its arc center.
|
|
2806
|
+
function arcHubForSvg(svg, root) {
|
|
2807
|
+
let best = null;
|
|
2808
|
+
for (const path of Array.from(svg.querySelectorAll("path"))) {
|
|
2809
|
+
if (hasRotatedAncestor(path, root)) continue;
|
|
2810
|
+
if (typeof path.getTotalLength !== "function") continue;
|
|
2811
|
+
const total = path.getTotalLength();
|
|
2812
|
+
if (total < 200) continue;
|
|
2813
|
+
const ctm = path.getScreenCTM();
|
|
2814
|
+
if (!ctm) continue;
|
|
2815
|
+
const points = [];
|
|
2816
|
+
for (let i = 0; i <= 16; i++) {
|
|
2817
|
+
const local = path.getPointAtLength((total * i) / 16);
|
|
2818
|
+
points.push(mapPoint(svg, ctm, local.x, local.y));
|
|
2819
|
+
}
|
|
2820
|
+
const fit = fitCirclePoints(points);
|
|
2821
|
+
if (!fit || fit.radius < 40) continue;
|
|
2822
|
+
if (fit.residual > 0.05 * fit.radius) continue;
|
|
2823
|
+
if (!best || fit.radius > best.radius) best = fit;
|
|
2824
|
+
}
|
|
2825
|
+
return best ? { hx: best.cx, hy: best.cy, hr: best.radius, count: 2 } : null;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
function dialHubForSvg(svg, root) {
|
|
2829
|
+
const centers = [];
|
|
2830
|
+
for (const circle of Array.from(svg.querySelectorAll("circle"))) {
|
|
2831
|
+
if (hasRotatedAncestor(circle, root)) continue;
|
|
2832
|
+
const ctm = circle.getScreenCTM();
|
|
2833
|
+
if (!ctm) continue;
|
|
2834
|
+
const cx = Number.parseFloat(circle.getAttribute("cx") || "0");
|
|
2835
|
+
const cy = Number.parseFloat(circle.getAttribute("cy") || "0");
|
|
2836
|
+
const center = mapPoint(svg, ctm, cx, cy);
|
|
2837
|
+
const radius = Number.parseFloat(circle.getAttribute("r") || "0") * ctmScale(ctm);
|
|
2838
|
+
centers.push({ x: center.x, y: center.y, radius });
|
|
2839
|
+
}
|
|
2840
|
+
let best = null;
|
|
2841
|
+
for (const anchor of centers) {
|
|
2842
|
+
const cluster = centers.filter(
|
|
2843
|
+
(other) => Math.hypot(other.x - anchor.x, other.y - anchor.y) <= 8,
|
|
2844
|
+
);
|
|
2845
|
+
if (!best || cluster.length > best.cluster.length) best = { anchor, cluster };
|
|
2846
|
+
}
|
|
2847
|
+
if (best && best.cluster.length >= 2) {
|
|
2848
|
+
const count = best.cluster.length;
|
|
2849
|
+
const hx = best.cluster.reduce((sum, item) => sum + item.x, 0) / count;
|
|
2850
|
+
const hy = best.cluster.reduce((sum, item) => sum + item.y, 0) / count;
|
|
2851
|
+
const hr = best.cluster.reduce((max, item) => Math.max(max, item.radius), 0);
|
|
2852
|
+
return { hx, hy, hr, count };
|
|
2853
|
+
}
|
|
2854
|
+
return arcHubForSvg(svg, root);
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
window.__hyperframesOffPivotRotationSample = function collectOffPivotRotationSample() {
|
|
2858
|
+
const root =
|
|
2859
|
+
document.querySelector("[data-composition-id][data-width][data-height]") ||
|
|
2860
|
+
document.querySelector("[data-composition-id]") ||
|
|
2861
|
+
document.body;
|
|
2862
|
+
const samples = [];
|
|
2863
|
+
const hubCache = new Map();
|
|
2864
|
+
const CANDIDATE_CAP = 60;
|
|
2865
|
+
for (const element of Array.from(
|
|
2866
|
+
root.querySelectorAll("path, polygon, line, rect, polyline, g"),
|
|
2867
|
+
)) {
|
|
2868
|
+
if (samples.length >= CANDIDATE_CAP) break;
|
|
2869
|
+
const svg = element.ownerSVGElement;
|
|
2870
|
+
if (!svg || typeof element.getBBox !== "function") continue;
|
|
2871
|
+
if (element.closest("[data-layout-allow-orbit]")) continue;
|
|
2872
|
+
if (!isVisibleElement(element, 0.05)) continue;
|
|
2873
|
+
const ctm = element.getScreenCTM();
|
|
2874
|
+
const angle = ctmRotationDeg(ctm);
|
|
2875
|
+
if (ctm === null || angle === null) continue;
|
|
2876
|
+
let bbox;
|
|
2877
|
+
try {
|
|
2878
|
+
bbox = element.getBBox();
|
|
2879
|
+
} catch {
|
|
2880
|
+
continue;
|
|
2881
|
+
}
|
|
2882
|
+
const long = Math.max(bbox.width, bbox.height);
|
|
2883
|
+
const short = Math.min(bbox.width, bbox.height);
|
|
2884
|
+
if (short <= 0 || long / short < 3 || long < 40) continue;
|
|
2885
|
+
const vertical = bbox.height >= bbox.width;
|
|
2886
|
+
const midMajor = vertical ? bbox.x + bbox.width / 2 : bbox.y + bbox.height / 2;
|
|
2887
|
+
const a = vertical
|
|
2888
|
+
? mapPoint(svg, ctm, midMajor, bbox.y)
|
|
2889
|
+
: mapPoint(svg, ctm, bbox.x, midMajor);
|
|
2890
|
+
const b = vertical
|
|
2891
|
+
? mapPoint(svg, ctm, midMajor, bbox.y + bbox.height)
|
|
2892
|
+
: mapPoint(svg, ctm, bbox.x + bbox.width, midMajor);
|
|
2893
|
+
let hub = hubCache.get(svg);
|
|
2894
|
+
if (hub === undefined) {
|
|
2895
|
+
hub = dialHubForSvg(svg, root);
|
|
2896
|
+
hubCache.set(svg, hub);
|
|
2897
|
+
}
|
|
2898
|
+
samples.push({
|
|
2899
|
+
selector: uniqueSelectorFor(element),
|
|
2900
|
+
ax: round(a.x),
|
|
2901
|
+
ay: round(a.y),
|
|
2902
|
+
bx: round(b.x),
|
|
2903
|
+
by: round(b.y),
|
|
2904
|
+
len: round(Math.hypot(b.x - a.x, b.y - a.y)),
|
|
2905
|
+
angle: round(angle),
|
|
2906
|
+
hx: hub ? round(hub.hx) : null,
|
|
2907
|
+
hy: hub ? round(hub.hy) : null,
|
|
2908
|
+
hr: hub ? round(hub.hr) : null,
|
|
2909
|
+
hubCount: hub ? hub.count : 0,
|
|
2910
|
+
});
|
|
2911
|
+
}
|
|
2912
|
+
return samples;
|
|
2913
|
+
};
|
|
2914
|
+
})();
|