diff --git a/bun.lock b/bun.lock index 8c7539d1..6f89438f 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,10 @@ "": { "name": "vibe-design-plugins", "dependencies": { - "jsdom": "29.1.1", + "css-select": "^5.2.2", + "css-tree": "^3.2.1", + "domutils": "^3.2.2", + "htmlparser2": "^10.0.0", "marked": "^16.4.2", }, "devDependencies": { @@ -60,14 +63,6 @@ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], - "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], - - "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], - - "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], - - "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], - "@astrojs/compiler": ["@astrojs/compiler@4.0.0", "", {}, "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA=="], "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.9.0", "", { "dependencies": { "picomatch": "^4.0.4" } }, "sha512-GdYkzR26re8izmyYlBqf4z2s7zNngmWLFuxw0UKiPNqHraZGS6GKWIwSHgS22RDlu2ePFJ8bzmpBcUszut/SDg=="], @@ -90,8 +85,6 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], - "@capsizecss/unpack": ["@capsizecss/unpack@4.0.0", "", { "dependencies": { "fontkitten": "^1.0.0" } }, "sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA=="], "@clack/core": ["@clack/core@1.3.0", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA=="], @@ -114,18 +107,6 @@ "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], - "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], - - "@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="], - - "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="], - - "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], - - "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="], - - "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], - "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], @@ -180,8 +161,6 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], - "@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], @@ -440,8 +419,6 @@ "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], - "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], @@ -530,12 +507,8 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], - "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], @@ -584,7 +557,7 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -734,12 +707,12 @@ "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], - "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], - "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -772,8 +745,6 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -792,8 +763,6 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], - "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], @@ -1008,7 +977,7 @@ "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], - "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -1056,8 +1025,6 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "puppeteer": ["puppeteer@24.42.0", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1595872", "puppeteer-core": "24.42.0", "typed-query-selector": "^2.12.1" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-94MoPfFp2eY3eYIMdINkez4IOP5TMHntlZbVx06fHlQTtiQiYgaY0L2Zzfod8PVUkPqP7m3Qlre2v8YS8cudPA=="], "puppeteer-core": ["puppeteer-core@24.42.0", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1595872", "typed-query-selector": "^2.12.1", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg=="], @@ -1126,8 +1093,6 @@ "sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="], - "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], - "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -1190,8 +1155,6 @@ "svgo": ["svgo@4.0.1", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w=="], - "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], - "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], "tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="], @@ -1208,16 +1171,8 @@ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], - "tldts": ["tldts@7.0.26", "", { "dependencies": { "tldts-core": "^7.0.26" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ=="], - - "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="], - "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], - - "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], - "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -1238,7 +1193,7 @@ "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], - "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + "undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], @@ -1284,20 +1239,12 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], - "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], - "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], - "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], - - "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], - - "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], @@ -1314,10 +1261,6 @@ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], - - "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], - "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -1364,18 +1307,14 @@ "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - - "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], - "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], - "miniflare/undici": ["undici@7.24.8", "", {}, "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ=="], - "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], @@ -1398,10 +1337,6 @@ "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], - "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - - "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], diff --git a/cli/engine/browser/injected/index.mjs b/cli/engine/browser/injected/index.mjs new file mode 100644 index 00000000..c03a69ca --- /dev/null +++ b/cli/engine/browser/injected/index.mjs @@ -0,0 +1,1688 @@ +const IS_BROWSER = typeof window !== 'undefined'; + +// ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── + +if (IS_BROWSER) { + // Detect extension mode via the script tag's data attribute or the document element fallback. + // currentScript is reliable for synchronously-executing scripts (which our IIFE is). + const _myScript = document.currentScript; + const EXTENSION_MODE = (_myScript && _myScript.dataset.impeccableExtension === 'true') + || document.documentElement.dataset.impeccableExtension === 'true'; + + const BRAND_COLOR = 'oklch(55% 0.25 350)'; + const BRAND_COLOR_HOVER = 'oklch(45% 0.25 350)'; + const LABEL_BG = BRAND_COLOR; + const OUTLINE_COLOR = BRAND_COLOR; + + // Inject hover styles via CSS (more reliable than JS event listeners) + const styleEl = document.createElement('style'); + styleEl.textContent = ` + @keyframes impeccable-reveal { + from { opacity: 0; } + to { opacity: 1; } + } + .impeccable-overlay:not(.impeccable-banner) { + pointer-events: none; + outline: 2px solid ${OUTLINE_COLOR}; + border-radius: 4px; + transition: outline-color 0.15s ease; + animation: impeccable-reveal 0.4s cubic-bezier(0.16, 1, 0.3, 1) both; + animation-play-state: paused; + border-top-left-radius: 0; + } + .impeccable-overlay.impeccable-visible { + animation-play-state: running; + } + .impeccable-overlay.impeccable-hover { + outline-color: ${BRAND_COLOR_HOVER}; + z-index: 100001 !important; + } + .impeccable-overlay.impeccable-hover .impeccable-label { + background: ${BRAND_COLOR_HOVER}; + } + .impeccable-overlay.impeccable-spotlight { + z-index: 100002 !important; + } + .impeccable-overlay.impeccable-spotlight-dimmed { + opacity: 0.15 !important; + animation: none !important; + filter: blur(3px); + } + .impeccable-spotlight-backdrop { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + backdrop-filter: blur(3px) brightness(0.6); + -webkit-backdrop-filter: blur(3px) brightness(0.6); + pointer-events: none; + z-index: 99998; + opacity: 0; + outline: none !important; + animation: none !important; + } + .impeccable-spotlight-backdrop.impeccable-visible { + opacity: 1; + } + .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { + display: none !important; + } + `; + (document.head || document.documentElement).appendChild(styleEl); + + // Spotlight backdrop element (created lazily on first use) + let spotlightBackdrop = null; + let spotlightTarget = null; + + function getSpotlightBackdrop() { + if (!spotlightBackdrop) { + spotlightBackdrop = document.createElement('div'); + spotlightBackdrop.className = 'impeccable-spotlight-backdrop'; + document.body.appendChild(spotlightBackdrop); + } + return spotlightBackdrop; + } + + function updateSpotlightClipPath() { + if (!spotlightBackdrop || !spotlightTarget) return; + const r = spotlightTarget.getBoundingClientRect(); + // Match the overlay's outer edge: element rect + 4px (2px overlay offset + 2px outline width) + const inset = 4; + const radius = 6; // outline border-radius (4) + outline width (2) + const x1 = r.left - inset; + const y1 = r.top - inset; + const x2 = r.right + inset; + const y2 = r.bottom + inset; + const vw = window.innerWidth; + const vh = window.innerHeight; + // Outer rect + rounded inner rect (evenodd creates a hole) + const path = `M0 0H${vw}V${vh}H0Z M${x1 + radius} ${y1}H${x2 - radius}A${radius} ${radius} 0 0 1 ${x2} ${y1 + radius}V${y2 - radius}A${radius} ${radius} 0 0 1 ${x2 - radius} ${y2}H${x1 + radius}A${radius} ${radius} 0 0 1 ${x1} ${y2 - radius}V${y1 + radius}A${radius} ${radius} 0 0 1 ${x1 + radius} ${y1}Z`; + spotlightBackdrop.style.clipPath = `path(evenodd, "${path}")`; + } + + function showSpotlight(target) { + if (!target || !target.getBoundingClientRect) return; + // Respect the spotlightBlur setting: if disabled, don't show the backdrop + if (window.__IMPECCABLE_CONFIG__?.spotlightBlur === false) { + spotlightTarget = target; + return; + } + spotlightTarget = target; + const bd = getSpotlightBackdrop(); + updateSpotlightClipPath(); + bd.classList.add('impeccable-visible'); + } + + function hideSpotlight() { + spotlightTarget = null; + if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); + } + + function isInViewport(el) { + const r = el.getBoundingClientRect(); + return r.top >= 0 && r.left >= 0 && r.bottom <= window.innerHeight && r.right <= window.innerWidth; + } + + // Reposition spotlight on scroll/resize + window.addEventListener('scroll', () => { + if (spotlightTarget) updateSpotlightClipPath(); + }, { passive: true }); + window.addEventListener('resize', () => { + if (spotlightTarget) updateSpotlightClipPath(); + }); + + const overlays = []; + const TYPE_LABELS = {}; + const RULE_CATEGORY = {}; + for (const ap of ANTIPATTERNS) { + TYPE_LABELS[ap.id] = ap.name.toLowerCase(); + RULE_CATEGORY[ap.id] = ap.category || 'quality'; + } + + function isInFixedContext(el) { + let p = el; + while (p && p !== document.body) { + if (getComputedStyle(p).position === 'fixed') return true; + p = p.parentElement; + } + return false; + } + + function positionOverlay(overlay) { + const el = overlay._targetEl; + if (!el) return; + const rect = el.getBoundingClientRect(); + if (overlay._isFixed) { + // Viewport-relative coords for fixed targets + overlay.style.top = `${rect.top - 2}px`; + overlay.style.left = `${rect.left - 2}px`; + } else { + // Document-relative coords for normal targets + overlay.style.top = `${rect.top + scrollY - 2}px`; + overlay.style.left = `${rect.left + scrollX - 2}px`; + } + overlay.style.width = `${rect.width + 4}px`; + overlay.style.height = `${rect.height + 4}px`; + } + + function repositionOverlays() { + for (const o of overlays) { + if (!o._targetEl || o.classList.contains('impeccable-banner')) continue; + // Skip overlays whose target is currently hidden (display: none on the overlay) + if (o.style.display === 'none') continue; + positionOverlay(o); + } + } + + let resizeRAF; + const onResize = () => { + cancelAnimationFrame(resizeRAF); + resizeRAF = requestAnimationFrame(repositionOverlays); + }; + window.addEventListener('resize', onResize); + // Reposition on scroll too -- catches sticky/parallax shifts + window.addEventListener('scroll', onResize, { passive: true }); + // Reposition when body resizes (lazy-loaded images, dynamic content, fonts loading) + if (typeof ResizeObserver !== 'undefined') { + const bodyResizeObserver = new ResizeObserver(onResize); + bodyResizeObserver.observe(document.body); + } + + // Track target element visibility via IntersectionObserver. + // Uses a huge rootMargin so all *rendered* elements count as intersecting, + // while display:none / closed
/ hidden modals etc. do not. + // This is event-driven -- no polling needed. + let overlayIndex = 0; + const visibilityObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + const overlay = entry.target._impeccableOverlay; + if (!overlay) continue; + if (entry.isIntersecting) { + overlay.style.display = ''; + positionOverlay(overlay); + if (!overlay._revealed) { + overlay._revealed = true; + if (firstScanDone) { + // Subsequent reveals (re-scans, scroll-into-view): instant, no animation + overlay.style.animation = 'none'; + } else { + // Initial scan: staggered cascade reveal + overlay.style.animationDelay = `${Math.min((overlay._staggerIndex || 0) * 60, 600)}ms`; + } + requestAnimationFrame(() => { + overlay.classList.add('impeccable-visible'); + if (overlay._checkLabel) overlay._checkLabel(); + }); + } + } else { + overlay.style.display = 'none'; + } + } + }, { rootMargin: '99999px' }); + + function detachOverlay(overlay) { + if (!overlay) return; + if (typeof overlay._cleanup === 'function') { + try { overlay._cleanup(); } catch { /* best effort overlay teardown */ } + } + if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) { + visibilityObserver.unobserve(overlay._targetEl); + delete overlay._targetEl._impeccableOverlay; + } + const idx = overlays.indexOf(overlay); + if (idx >= 0) overlays.splice(idx, 1); + overlay.remove(); + } + + // Reposition overlays after CSS transitions end (e.g. reveal animations). + // Listens at document level so it catches transitions on ancestor elements + // (the transform may be on a parent, not the flagged element itself). + document.addEventListener('transitionend', (e) => { + if (e.propertyName !== 'transform') return; + for (const o of overlays) { + if (!o._targetEl || o.classList.contains('impeccable-banner') || o.style.display === 'none') continue; + if (e.target === o._targetEl || e.target.contains(o._targetEl)) { + positionOverlay(o); + } + } + }); + + const highlight = function(el, findings) { + if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay); + const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); + + const fixed = isInFixedContext(el); + const rect = el.getBoundingClientRect(); + const outline = document.createElement('div'); + outline.className = 'impeccable-overlay'; + outline._targetEl = el; + outline._isFixed = fixed; + Object.assign(outline.style, { + position: fixed ? 'fixed' : 'absolute', + top: fixed ? `${rect.top - 2}px` : `${rect.top + scrollY - 2}px`, + left: fixed ? `${rect.left - 2}px` : `${rect.left + scrollX - 2}px`, + width: `${rect.width + 4}px`, height: `${rect.height + 4}px`, + zIndex: '99999', boxSizing: 'border-box', + }); + + // Build per-finding label entries: ✦ prefix for slop + const entries = findings.map(f => { + const name = TYPE_LABELS[f.type || f.id] || f.type || f.id; + const prefix = RULE_CATEGORY[f.type || f.id] === 'slop' ? '\u2726 ' : ''; + return { name: prefix + name, detail: f.detail || f.snippet }; + }); + const allText = entries.map(e => e.name).join(', '); + + const label = document.createElement('div'); + label.className = 'impeccable-label'; + Object.assign(label.style, { + position: 'absolute', bottom: '100%', left: '-2px', + display: 'flex', alignItems: 'center', + whiteSpace: 'nowrap', + fontSize: '11px', fontWeight: '600', letterSpacing: '0.02em', + color: 'white', lineHeight: '14px', + background: LABEL_BG, + fontFamily: 'system-ui, sans-serif', + borderRadius: '4px 4px 0 0', + }); + + const textSpan = document.createElement('span'); + textSpan.style.padding = '3px 8px'; + textSpan.textContent = allText; + label.appendChild(textSpan); + + // State for cycling mode + let cycleMode = false; + let cycleIndex = 0; + let isHovered = false; + let prevBtn, nextBtn; + + function updateCycleText() { + const e = entries[cycleIndex]; + textSpan.textContent = isHovered ? e.detail : e.name; + } + + function enableCycleMode() { + if (cycleMode || entries.length < 2) return; + cycleMode = true; + + const btnStyle = { + background: 'none', border: 'none', color: 'rgba(255,255,255,0.7)', + fontSize: '11px', cursor: 'pointer', padding: '3px 4px', + fontFamily: 'system-ui, sans-serif', lineHeight: '14px', + pointerEvents: 'auto', + }; + + const navGroup = document.createElement('span'); + Object.assign(navGroup.style, { + display: 'inline-flex', alignItems: 'center', flexShrink: '0', + }); + + prevBtn = document.createElement('button'); + prevBtn.textContent = '\u2039'; + Object.assign(prevBtn.style, btnStyle); + prevBtn.style.paddingLeft = '6px'; + prevBtn.addEventListener('click', (e) => { + e.stopPropagation(); + cycleIndex = (cycleIndex - 1 + entries.length) % entries.length; + updateCycleText(); + }); + + nextBtn = document.createElement('button'); + nextBtn.textContent = '\u203A'; + Object.assign(nextBtn.style, btnStyle); + nextBtn.style.paddingRight = '2px'; + nextBtn.addEventListener('click', (e) => { + e.stopPropagation(); + cycleIndex = (cycleIndex + 1) % entries.length; + updateCycleText(); + }); + + navGroup.appendChild(prevBtn); + navGroup.appendChild(nextBtn); + label.insertBefore(navGroup, textSpan); + textSpan.style.padding = '3px 8px 3px 4px'; + updateCycleText(); + } + + outline.appendChild(label); + + // Start hidden; the IntersectionObserver will show it once the target is rendered + outline.style.display = 'none'; + outline._staggerIndex = overlayIndex++; + el._impeccableOverlay = outline; + visibilityObserver.observe(el); + + // After first paint, check label width vs outline + outline._checkLabel = () => { + if (entries.length > 1 && label.offsetWidth > outline.offsetWidth) { + enableCycleMode(); + } + }; + + // Hover: show detail text, darken + const onMouseEnter = () => { + isHovered = true; + outline.classList.add('impeccable-hover'); + outline.style.outlineColor = BRAND_COLOR_HOVER; + label.style.background = BRAND_COLOR_HOVER; + if (cycleMode) { + updateCycleText(); + } else { + textSpan.textContent = entries.map(e => e.detail).join(' | '); + } + }; + const onMouseLeave = () => { + isHovered = false; + outline.classList.remove('impeccable-hover'); + outline.style.outlineColor = ''; + label.style.background = LABEL_BG; + if (cycleMode) { + updateCycleText(); + } else { + textSpan.textContent = allText; + } + }; + el.addEventListener('mouseenter', onMouseEnter); + el.addEventListener('mouseleave', onMouseLeave); + outline._cleanup = () => { + el.removeEventListener('mouseenter', onMouseEnter); + el.removeEventListener('mouseleave', onMouseLeave); + }; + + document.body.appendChild(outline); + overlays.push(outline); + }; + + const showPageBanner = function(findings) { + if (!findings.length) return; + const banner = document.createElement('div'); + banner.className = 'impeccable-overlay impeccable-banner'; + Object.assign(banner.style, { + position: 'fixed', top: '0', left: '0', right: '0', zIndex: '100000', + background: LABEL_BG, color: 'white', + fontFamily: 'system-ui, sans-serif', fontSize: '13px', + display: 'flex', alignItems: 'center', pointerEvents: 'auto', + height: '36px', overflow: 'hidden', maxWidth: '100vw', + transform: 'translateY(-100%)', + transition: 'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1)', + }); + requestAnimationFrame(() => requestAnimationFrame(() => { + banner.style.transform = 'translateY(0)'; + })); + + // Scrollable findings area + const scrollArea = document.createElement('div'); + Object.assign(scrollArea.style, { + flex: '1', minWidth: '0', overflowX: 'auto', overflowY: 'hidden', + display: 'flex', gap: '8px', alignItems: 'center', + padding: '0 12px', scrollSnapType: 'x mandatory', + scrollbarWidth: 'none', + }); + for (const f of findings) { + const prefix = RULE_CATEGORY[f.type] === 'slop' ? '\u2726 ' : ''; + const tag = document.createElement('span'); + tag.textContent = `${prefix}${TYPE_LABELS[f.type] || f.type}: ${f.detail}`; + Object.assign(tag.style, { + background: 'rgba(255,255,255,0.15)', padding: '2px 8px', + borderRadius: '3px', fontSize: '12px', fontFamily: 'ui-monospace, monospace', + whiteSpace: 'nowrap', flexShrink: '0', scrollSnapAlign: 'start', + }); + scrollArea.appendChild(tag); + } + banner.appendChild(scrollArea); + + // Controls area (only in standalone mode, not extension) + if (!EXTENSION_MODE) { + const controls = document.createElement('div'); + Object.assign(controls.style, { + display: 'flex', alignItems: 'center', gap: '2px', + padding: '0 8px', flexShrink: '0', + }); + + // Toggle visibility button + const toggle = document.createElement('button'); + toggle.textContent = '\u25C9'; // circle with dot (visible state) + toggle.title = 'Toggle overlay visibility'; + Object.assign(toggle.style, { + background: 'none', border: 'none', + color: 'white', fontSize: '16px', cursor: 'pointer', padding: '0 4px', + opacity: '0.85', transition: 'opacity 0.15s', + }); + let overlaysVisible = true; + toggle.addEventListener('click', () => { + overlaysVisible = !overlaysVisible; + document.body.classList.toggle('impeccable-hidden', !overlaysVisible); + toggle.textContent = overlaysVisible ? '\u25C9' : '\u25CB'; // filled vs empty circle + toggle.style.opacity = overlaysVisible ? '0.85' : '0.5'; + }); + controls.appendChild(toggle); + + // Close button + const close = document.createElement('button'); + close.textContent = '\u00d7'; + close.title = 'Dismiss banner'; + Object.assign(close.style, { + background: 'none', border: 'none', + color: 'white', fontSize: '18px', cursor: 'pointer', padding: '0 4px', + }); + close.addEventListener('click', () => banner.remove()); + controls.appendChild(close); + + banner.appendChild(controls); + } + document.body.appendChild(banner); + overlays.push(banner); + }; + + // Heuristic for skipping CSS-in-JS hashed class names like "css-1a2b3c" or "_2x4hG_". + // These change between builds and produce brittle, ugly selectors. + function isLikelyHashedClass(c) { + if (!c) return true; + if (/^(css|sc|emotion|jsx|module)-[\w-]{4,}$/i.test(c)) return true; + if (/^_[\w-]{5,}$/.test(c)) return true; + if (/^[a-z0-9]{6,}$/i.test(c) && /\d/.test(c)) return true; + return false; + } + + function buildSelectorSegment(el) { + const tag = el.tagName.toLowerCase(); + let sel = tag; + + if (el.classList && el.classList.length > 0) { + const classes = [...el.classList] + .filter(c => !c.startsWith('impeccable-') && !isLikelyHashedClass(c)) + .slice(0, 2); + if (classes.length > 0) { + sel += '.' + classes.map(c => CSS.escape(c)).join('.'); + } + } + + // Disambiguate among siblings only if the parent has multiple matches + const parent = el.parentElement; + if (parent) { + try { + const matching = parent.querySelectorAll(':scope > ' + sel); + if (matching.length > 1) { + const sameType = [...parent.children].filter(c => c.tagName === el.tagName); + const idx = sameType.indexOf(el) + 1; + sel += `:nth-of-type(${idx})`; + } + } catch { + const idx = [...parent.children].indexOf(el) + 1; + sel = `${tag}:nth-child(${idx})`; + } + } + return sel; + } + + function generateSelector(el) { + if (el === document.body) return 'body'; + if (el === document.documentElement) return 'html'; + if (el.id) return '#' + CSS.escape(el.id); + + const parts = []; + let current = el; + let depth = 0; + const MAX_DEPTH = 10; + + while (current && current !== document.body && current !== document.documentElement && depth < MAX_DEPTH) { + parts.unshift(buildSelectorSegment(current)); + + // Anchor on an ancestor's ID and stop walking up + if (current.id) { + parts[0] = '#' + CSS.escape(current.id); + break; + } + + // Stop as soon as the partial selector uniquely identifies the target + const trySelector = parts.join(' > '); + try { + const matches = document.querySelectorAll(trySelector); + if (matches.length === 1 && matches[0] === el) { + return trySelector; + } + } catch { /* invalid selector — keep walking */ } + + current = current.parentElement; + depth++; + } + + return parts.join(' > '); + } + + function getDirectText(el) { + return [...el.childNodes] + .filter(n => n.nodeType === 3) + .map(n => n.textContent || '') + .join(''); + } + + function getDirectTextRect(el) { + const rects = []; + for (const node of el.childNodes) { + if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue; + const range = document.createRange(); + range.selectNodeContents(node); + for (const rect of range.getClientRects()) { + if (rect.width >= 1 && rect.height >= 1) rects.push(rect); + } + range.detach?.(); + } + if (rects.length === 0) return null; + const left = Math.min(...rects.map(r => r.left)); + const top = Math.min(...rects.map(r => r.top)); + const right = Math.max(...rects.map(r => r.right)); + const bottom = Math.max(...rects.map(r => r.bottom)); + return { + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + x: left, + y: top, + }; + } + + function collectVisualContrastReasons(el, style) { + const reasons = new Set(); + const bgClip = style.webkitBackgroundClip || style.backgroundClip || ''; + const ownBgImage = style.backgroundImage || ''; + if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') { + reasons.add('background-clip text'); + } + if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow'); + + let current = el; + while (current && current.nodeType === 1) { + const tag = current.tagName?.toLowerCase(); + const currentStyle = getComputedStyle(current); + const bgImage = currentStyle.backgroundImage || ''; + const isDocumentSurface = tag === 'body' || tag === 'html'; + + if (!isDocumentSurface && bgImage && bgImage !== 'none') { + if (/url\s*\(/i.test(bgImage)) reasons.add('image background'); + if (/gradient/i.test(bgImage)) reasons.add('gradient background'); + } + if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack'); + if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode'); + if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); + if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); + + const solidBg = parseRgb(currentStyle.backgroundColor); + if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; + current = current.parentElement; + } + + const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect(); + if (sampleRect && document.elementsFromPoint) { + const points = [ + [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2], + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2], + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2], + ]; + for (const [x, y] of points) { + if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue; + const stack = document.elementsFromPoint(x, y); + const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el)); + if (selfIndex < 0) continue; + for (const node of stack.slice(selfIndex + 1)) { + const nodeTag = node.tagName?.toLowerCase(); + if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') { + reasons.add(`${nodeTag} underlay`); + break; + } + } + } + } + + return [...reasons]; + } + + function collectVisualContrastCandidates(options = {}) { + const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12; + const candidates = []; + for (const el of document.querySelectorAll('*')) { + if (candidates.length >= maxCandidates) break; + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; + if (el.closest('[id^="impeccable-live-"]')) continue; + if (el === document.body || el === document.documentElement) continue; + + const tag = el.tagName.toLowerCase(); + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') continue; + const directText = getDirectText(el); + const hasDirectText = directText.trim().length > 0; + if (!hasDirectText || isEmojiOnlyText(directText)) continue; + + const bgColor = readOwnBackgroundColor(el, style); + const isStyledButton = (tag === 'a' || tag === 'button') + && bgColor && bgColor.a > 0.5; + if (SAFE_TAGS.has(tag) && !isStyledButton) continue; + + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); + if (!rect || rect.width < 4 || rect.height < 4) continue; + + const reasons = collectVisualContrastReasons(el, style); + if (reasons.length === 0) continue; + + const textColor = parseRgb(style.color); + const fontSize = parseFloat(style.fontSize) || 16; + const fontWeight = parseInt(style.fontWeight) || 400; + const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); + const threshold = isLargeText ? 3.0 : 4.5; + const clip = { + x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)), + y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)), + width: Math.max(1, Math.ceil(rect.width + 4)), + height: Math.max(1, Math.ceil(rect.height + 4)), + }; + + candidates.push({ + selector: generateSelector(el), + tagName: tag, + text: directText.trim().replace(/\s+/g, ' ').slice(0, 80), + threshold, + reasons, + clip, + textColor, + preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason => + reason === 'opacity stack' || + reason === 'blend mode' || + reason === 'filter' || + reason === 'backdrop filter' || + reason === 'background-clip text' + ), + backgroundClipText: reasons.includes('background-clip text'), + }); + } + return candidates; + } + + const visualContrastImageCache = new Map(); + const visualContrastRasterCache = new WeakMap(); + + function clampByte(value) { + return Math.max(0, Math.min(255, Math.round(value))); + } + + function blendRgba(fg, bg) { + if (!fg) return bg || null; + if (!bg || fg.a == null || fg.a >= 0.999) { + return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a }; + } + const alpha = Math.max(0, Math.min(1, fg.a)); + return { + r: clampByte(fg.r * alpha + bg.r * (1 - alpha)), + g: clampByte(fg.g * alpha + bg.g * (1 - alpha)), + b: clampByte(fg.b * alpha + bg.b * (1 - alpha)), + a: 1, + }; + } + + function pickWorstContrastColor(textColor, colors) { + const usable = (colors || []).filter(Boolean); + if (!usable.length) return null; + let worst = usable[0]; + let worstRatio = contrastRatio(textColor, worst); + for (const color of usable.slice(1)) { + const ratio = contrastRatio(textColor, color); + if (ratio < worstRatio) { + worst = color; + worstRatio = ratio; + } + } + return worst; + } + + function firstCssUrl(value) { + const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i); + if (!match) return ''; + return (match[1] || match[2] || match[3] || '').trim(); + } + + function getLayerValue(value, index = 0) { + return String(value || '').split(',')[index]?.trim() || ''; + } + + function parsePositionToken(token, container, painted) { + if (!token || token === 'center') return (container - painted) / 2; + if (token === 'left' || token === 'top') return 0; + if (token === 'right' || token === 'bottom') return container - painted; + if (/%$/.test(token)) { + const pct = parseFloat(token) / 100; + return (container - painted) * pct; + } + if (/px$/.test(token)) return parseFloat(token) || 0; + return (container - painted) / 2; + } + + function parsePositionPair(positionValue) { + const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean); + const first = tokens[0] || '50%'; + if (tokens.length < 2) { + if (first === 'top' || first === 'bottom') return ['50%', first]; + return [first, '50%']; + } + return [first, tokens[1] || '50%']; + } + + function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) { + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; + let paintedWidth = intrinsicWidth; + let paintedHeight = intrinsicHeight; + const size = String(sizeValue || 'auto').trim(); + + if (size === 'cover' || size === 'contain') { + const scale = size === 'cover' + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); + paintedWidth = intrinsicWidth * scale; + paintedHeight = intrinsicHeight * scale; + } else if (size && size !== 'auto') { + const parts = size.split(/\s+/); + const widthToken = parts[0]; + const heightToken = parts[1] || 'auto'; + if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100); + else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth; + if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth); + else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100); + else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight; + } + + const [xToken, yToken] = parsePositionPair(positionValue); + const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth); + const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight); + return { + left: containerRect.left + positionX, + top: containerRect.top + positionY, + width: paintedWidth, + height: paintedHeight, + intrinsicWidth, + intrinsicHeight, + }; + } + + function parseObjectPosition(positionValue) { + return parsePositionPair(positionValue); + } + + function resolveObjectImageRect(containerRect, image, style) { + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; + const fit = style.objectFit || 'fill'; + let paintedWidth = containerRect.width; + let paintedHeight = containerRect.height; + if (fit === 'contain' || fit === 'cover') { + const scale = fit === 'cover' + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); + paintedWidth = intrinsicWidth * scale; + paintedHeight = intrinsicHeight * scale; + } else if (fit === 'none') { + paintedWidth = intrinsicWidth; + paintedHeight = intrinsicHeight; + } else if (fit === 'scale-down') { + const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1); + paintedWidth = intrinsicWidth * containScale; + paintedHeight = intrinsicHeight * containScale; + } + const [xToken, yToken] = parseObjectPosition(style.objectPosition); + return { + left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth), + top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight), + width: paintedWidth, + height: paintedHeight, + intrinsicWidth, + intrinsicHeight, + }; + } + + function pointToImageSource(point, paintedRect) { + if ( + point.x < paintedRect.left || + point.y < paintedRect.top || + point.x > paintedRect.left + paintedRect.width || + point.y > paintedRect.top + paintedRect.height + ) { + return null; + } + return { + x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)), + y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)), + }; + } + + async function loadVisualContrastImage(src) { + if (!src) return null; + if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src); + const promise = new Promise(resolve => { + const img = new Image(); + let settled = false; + const finish = value => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => finish(null), 800); + try { + const absolute = new URL(src, location.href); + if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') { + img.crossOrigin = 'anonymous'; + } + } catch { + // Let the browser resolve unusual URLs itself. + } + img.onload = () => finish(img); + img.onerror = () => finish(null); + img.src = src; + }); + visualContrastImageCache.set(src, promise); + return promise; + } + + function sampleDrawablePixel(drawable, sourcePoint) { + if (visualContrastRasterCache.has(drawable)) { + const cached = visualContrastRasterCache.get(drawable); + if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' }; + try { + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); + const data = cached.ctx.getImageData(x, y, 1, 1).data; + return { + status: 'sampled', + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, + }; + } catch (err) { + return { + status: 'unresolved', + reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed', + }; + } + } + + const canvas = document.createElement('canvas'); + const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1; + const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1; + const maxRasterSide = 640; + const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight)); + canvas.width = Math.max(1, Math.round(intrinsicWidth * scale)); + canvas.height = Math.max(1, Math.round(intrinsicHeight * scale)); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' }; + try { + ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height); + const cached = { + ctx, + width: canvas.width, + height: canvas.height, + scaleX: canvas.width / intrinsicWidth, + scaleY: canvas.height / intrinsicHeight, + }; + visualContrastRasterCache.set(drawable, cached); + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); + const data = ctx.getImageData(x, y, 1, 1).data; + return { + status: 'sampled', + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, + }; + } catch (err) { + const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed'; + visualContrastRasterCache.set(drawable, { ctx: null, reason }); + return { + status: 'unresolved', + reason, + }; + } + } + + async function sampleCssBackground(el, style, point, textColor) { + const rect = el.getBoundingClientRect(); + const bgImage = style.backgroundImage || ''; + if (bgImage && bgImage !== 'none') { + if (/gradient/i.test(bgImage)) { + const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage)); + if (color) return { status: 'sampled', color, method: 'analytic-gradient' }; + } + if (/url\s*\(/i.test(bgImage)) { + const img = await loadVisualContrastImage(firstCssUrl(bgImage)); + if (!img) return { status: 'unresolved', reason: 'image unavailable' }; + const paintedRect = resolvePaintedImageRect( + rect, + img, + getLayerValue(style.backgroundSize) || 'auto', + getLayerValue(style.backgroundPosition) || '50% 50%', + ); + const sourcePoint = pointToImageSource(point, paintedRect); + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' }; + const sample = sampleDrawablePixel(img, sourcePoint); + if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' }; + return sample; + } + } + const bg = parseRgb(style.backgroundColor); + if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; + return { status: 'unresolved', reason: 'no readable background' }; + } + + async function sampleImageElement(img, point) { + const rect = img.getBoundingClientRect(); + const style = getComputedStyle(img); + const paintedRect = resolveObjectImageRect(rect, img, style); + const sourcePoint = pointToImageSource(point, paintedRect); + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' }; + const sample = sampleDrawablePixel(img, sourcePoint); + if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' }; + + if (img.currentSrc || img.src) { + const loaded = await loadVisualContrastImage(img.currentSrc || img.src); + if (loaded) { + const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight }; + const loadedPoint = pointToImageSource(point, loadedRect); + if (loadedPoint) { + const loadedSample = sampleDrawablePixel(loaded, loadedPoint); + if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' }; + } + } + } + return sample; + } + + function textSamplePoints(rect) { + const insetX = Math.min(12, Math.max(1, rect.width * 0.12)); + const insetY = Math.min(8, Math.max(1, rect.height * 0.22)); + const xs = rect.width < 28 + ? [rect.left + rect.width / 2] + : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX]; + const ys = rect.height < 22 + ? [rect.top + rect.height / 2] + : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY]; + const points = []; + for (const y of ys) { + for (const x of xs) { + if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y }); + } + } + return points; + } + + async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) { + if (depth > 8) { + return { status: 'unresolved', reason: 'background stack too deep' }; + } + const stack = typeof document.elementsFromPoint === 'function' + ? document.elementsFromPoint(point.x, point.y) + : []; + const selfIndex = stack.findIndex(node => node === el || el.contains(node)); + const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack]; + const unresolved = []; + + for (const node of nodes) { + if (!node || node.nodeType !== 1) continue; + if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; + const tag = node.tagName?.toLowerCase(); + if (tag === 'img') { + const sample = await sampleImageElement(node, point); + if (sample.status === 'sampled') return sample; + unresolved.push(sample.reason); + continue; + } + if (tag === 'canvas' || tag === 'video') { + const rect = node.getBoundingClientRect(); + const sourcePoint = pointToImageSource(point, { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + intrinsicWidth: node.width || node.videoWidth || rect.width, + intrinsicHeight: node.height || node.videoHeight || rect.height, + }); + if (sourcePoint) { + const sample = sampleDrawablePixel(node, sourcePoint); + if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` }; + unresolved.push(sample.reason); + } + continue; + } + const style = getComputedStyle(node); + const sample = await sampleCssBackground(node, style, point, textColor); + if (sample.status === 'sampled') { + if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample; + const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1); + if (under.status === 'sampled') { + return { + status: 'sampled', + color: blendRgba(sample.color, under.color), + method: `${sample.method}+alpha`, + }; + } + return sample; + } + unresolved.push(sample.reason); + } + + return { + status: 'unresolved', + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background', + }; + } + + async function analyzeVisualContrastCandidate(candidate) { + let el; + try { + el = document.querySelector(candidate.selector); + } catch { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; + } + if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + + const blockingReason = (candidate.reasons || []).find(reason => + reason === 'background-clip text' || + reason === 'blend mode' || + reason === 'filter' || + reason === 'backdrop filter' || + reason === 'opacity stack' || + reason === 'text shadow' + ); + if (blockingReason) { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` }; + } + + const style = getComputedStyle(el); + const textColor = parseRgb(style.color) || candidate.textColor; + if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; + + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); + if (!rect || rect.width < 4 || rect.height < 4) { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' }; + } + + const points = textSamplePoints(rect); + if (points.length === 0) { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' }; + } + + const ratios = []; + const methods = new Set(); + const unresolved = []; + for (const point of points) { + const sample = await sampleVisualBackgroundAtPoint(el, point, textColor); + if (sample.status !== 'sampled' || !sample.color) { + unresolved.push(sample.reason); + continue; + } + const fg = blendRgba(textColor, sample.color); + ratios.push(contrastRatio(fg, sample.color)); + if (sample.method) methods.add(sample.method); + } + + if (ratios.length < Math.min(3, points.length)) { + return { + ...candidate, + status: 'unresolved', + confidence: 'none', + samples: ratios.length, + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples', + }; + } + + ratios.sort((a, b) => a - b); + const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))]; + const measuredRatio = pick(10); + const medianRatio = pick(50); + const status = measuredRatio < candidate.threshold ? 'fail' : 'pass'; + const method = [...methods].sort().join(', ') || 'browser-visual'; + const textLabel = candidate.text ? ` "${candidate.text}"` : ''; + const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`; + return { + ...candidate, + status, + confidence: method.includes('canvas-') ? 'high' : 'medium', + method, + ratio: measuredRatio, + medianRatio, + samples: ratios.length, + finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null, + }; + } + + function waitForVisualPaint() { + return new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + } + + async function analyzeVisualContrast(options = {}) { + const candidates = collectVisualContrastCandidates(options); + const results = []; + const shouldScrollOffscreen = options.scrollOffscreen === true; + const restoreScroll = { x: window.scrollX, y: window.scrollY }; + for (const candidate of candidates) { + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { + window.scrollTo(restoreScroll.x, restoreScroll.y); + await waitForVisualPaint(); + } + let result = await analyzeVisualContrastCandidate(candidate); + if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') { + let el = null; + try { + el = document.querySelector(candidate.selector); + } catch { + el = null; + } + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); + await waitForVisualPaint(); + result = await analyzeVisualContrastCandidate(candidate); + } + } + results.push(result); + } + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { + window.scrollTo(restoreScroll.x, restoreScroll.y); + } + return results; + } + + function isElementHidden(el) { + if (!el || el === document.body || el === document.documentElement) return false; + if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); + // Fallback: zero size or no offsetParent (covers display:none and detached subtrees) + return el.offsetWidth === 0 && el.offsetHeight === 0; + } + + function serializeFindings(allFindings) { + return allFindings.map(({ el, findings }) => ({ + selector: generateSelector(el), + tagName: el.tagName?.toLowerCase() || 'unknown', + rect: (el !== document.body && el !== document.documentElement && el.getBoundingClientRect) + ? el.getBoundingClientRect().toJSON() : null, + isPageLevel: el === document.body || el === document.documentElement, + isHidden: isElementHidden(el), + findings: findings.map(f => { + const ap = ANTIPATTERNS.find(a => a.id === (f.type || f.id)); + return { + type: f.type || f.id, + category: ap ? ap.category : 'quality', + severity: ap?.severity || 'warning', + detail: f.detail || f.snippet, + name: ap ? ap.name : (f.type || f.id), + description: ap ? ap.description : '', + }; + }), + })); + } + + const printSummary = function(allFindings) { + if (allFindings.length === 0) { + console.log('%c[impeccable] No anti-patterns found.', 'color: #22c55e; font-weight: bold'); + return; + } + console.group( + `%c[impeccable] ${allFindings.length} anti-pattern${allFindings.length === 1 ? '' : 's'} found`, + 'color: oklch(60% 0.25 350); font-weight: bold' + ); + for (const { el, findings } of allFindings) { + for (const f of findings) { + console.log(`%c${f.type || f.id}%c ${f.detail || f.snippet}`, + 'color: oklch(55% 0.25 350); font-weight: bold', 'color: inherit', el); + } + } + console.groupEnd(); + }; + + function addBrowserFindings(groupMap, el, findings) { + if (!findings || findings.length === 0) return; + const existing = groupMap.get(el); + if (existing) existing.push(...findings); + else groupMap.set(el, [...findings]); + } + + function browserFindingsFromMap(groupMap) { + return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); + } + + function collectBrowserFindings() { + const groupMap = new Map(); + const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; + const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); + + for (const el of document.querySelectorAll('*')) { + // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; + // Skip browser extension elements (Claude, etc.) + const elId = el.id || ''; + if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; + // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast). + // These are inspector chrome, not part of the user's design. + if (el.closest('[id^="impeccable-live-"]')) continue; + // Skip html/body -- page-level findings go in the banner, not a full-page overlay + if (el === document.body || el === document.documentElement) continue; + + const findings = [ + ...checkElementBordersDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementColorsDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementMotionDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementGlowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementAIPaletteDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementIconTileDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementItalicSerifDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementHeroEyebrowDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })), + ].filter(f => _ruleOk(f.type)); + + addBrowserFindings(groupMap, el, findings); + } + + const pageLevelFindings = []; + + const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); + if (typoFindings.length > 0) { + pageLevelFindings.push(...typoFindings); + addBrowserFindings(groupMap, document.body, typoFindings); + } + + const sectionKickerFindings = checkRepeatedSectionKickersDOM() + .map(f => ({ type: f.id, detail: f.snippet })) + .filter(f => _ruleOk(f.type)); + if (sectionKickerFindings.length > 0) { + pageLevelFindings.push(...sectionKickerFindings); + addBrowserFindings(groupMap, document.body, sectionKickerFindings); + } + + const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); + for (const f of layoutFindings) { + const el = f.el || document.body; + addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); + } + + // Page-level quality checks (headings, etc.) + const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); + if (qualityFindings.length > 0) { + pageLevelFindings.push(...qualityFindings); + addBrowserFindings(groupMap, document.body, qualityFindings); + } + + // Regex-on-HTML checks (shared with Node) + // Clone the document and strip impeccable-live overlay nodes before the + // regex scan, so the inspector's own inline styles (transitions on top/ + // left/width/height, etc.) don't register as page anti-patterns. + const docClone = document.documentElement.cloneNode(true); + for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) { + node.remove(); + } + const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML); + if (htmlPatternFindings.length > 0) { + const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type)); + pageLevelFindings.push(...mapped); + addBrowserFindings(groupMap, document.body, mapped); + } + + return { + groupMap, + allFindings: browserFindingsFromMap(groupMap), + pageLevelFindings, + }; + } + + function shouldRunVisualContrast(options = {}) { + return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true; + } + + function visualContrastOptions(options = {}) { + const config = window.__IMPECCABLE_CONFIG__ || {}; + const scrollOffscreen = typeof options.scrollOffscreen === 'boolean' + ? options.scrollOffscreen + : typeof options.visualContrastScrollOffscreen === 'boolean' + ? options.visualContrastScrollOffscreen + : typeof config.visualContrastScrollOffscreen === 'boolean' + ? config.visualContrastScrollOffscreen + : false; + return { + ...options, + maxCandidates: Number.isFinite(options.visualContrastMaxCandidates) + ? options.visualContrastMaxCandidates + : Number.isFinite(options.maxCandidates) + ? options.maxCandidates + : Number.isFinite(config.visualContrastMaxCandidates) + ? config.visualContrastMaxCandidates + : undefined, + scrollOffscreen, + }; + } + + let lastVisualContrastAnalyses = []; + let lazyVisualContrastObserver = null; + let lazyVisualContrastPending = new WeakMap(); + const lazyVisualContrastResolving = new WeakSet(); + let scanGeneration = 0; + + function rememberVisualContrastAnalysis(result) { + if (!result?.selector) { + lastVisualContrastAnalyses.push(result); + return; + } + const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector); + if (idx >= 0) lastVisualContrastAnalyses[idx] = result; + else lastVisualContrastAnalyses.push(result); + } + + function disconnectLazyVisualContrastObserver() { + if (lazyVisualContrastObserver) { + lazyVisualContrastObserver.disconnect(); + lazyVisualContrastObserver = null; + } + lazyVisualContrastPending = new WeakMap(); + } + + function addVisualContrastResult(groupMap, result, options = {}) { + if (result.status !== 'fail' || !result.finding || !result.selector) return false; + let el = null; + try { + el = document.querySelector(result.selector); + } catch { + el = null; + } + if (!el) return false; + const findingType = result.finding.type || result.finding.id || 'low-contrast'; + const existing = groupMap.get(el) || []; + if (existing.some(f => (f.type || f.id) === findingType)) return false; + addBrowserFindings(groupMap, el, [{ + type: findingType, + detail: result.finding.detail || result.finding.snippet, + }]); + if (options.decorate && el !== document.body && el !== document.documentElement) { + highlight(el, groupMap.get(el) || []); + } + return true; + } + + function postSerializedFindings(groupMap) { + if (!EXTENSION_MODE) return; + const allFindings = browserFindingsFromMap(groupMap); + window.postMessage({ + source: 'impeccable-results', + findings: serializeFindings(allFindings), + count: allFindings.length, + }, '*'); + } + + function postExtensionError(err) { + if (!EXTENSION_MODE) return; + window.postMessage({ + source: 'impeccable-error', + message: err?.message || String(err), + }, '*'); + } + + function reportVisualContrastError(err, detail = {}) { + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', { + detail: { + ...detail, + message: err?.message || String(err), + }, + })); + if (EXTENSION_MODE) { + postExtensionError(err); + } else { + console.warn('[impeccable] visual contrast scan failed', err); + } + } + + function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) { + disconnectLazyVisualContrastObserver(); + if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return; + if (typeof IntersectionObserver === 'undefined') return; + const unresolved = (analyses || []).filter(result => + result?.status === 'unresolved' && + result.reason === 'text outside viewport' && + result.selector + ); + if (unresolved.length === 0) return; + const generation = runtime.generation || scanGeneration; + + lazyVisualContrastObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const el = entry.target; + const candidate = lazyVisualContrastPending.get(el); + if (!candidate || lazyVisualContrastResolving.has(el)) continue; + lazyVisualContrastObserver?.unobserve(el); + lazyVisualContrastPending.delete(el); + lazyVisualContrastResolving.add(el); + waitForVisualPaint() + .then(() => analyzeVisualContrastCandidate(candidate)) + .then(result => { + if (generation !== scanGeneration) return; + rememberVisualContrastAnalysis(result); + const added = addVisualContrastResult(groupMap, result, { decorate: true }); + if (added) { + postSerializedFindings(groupMap); + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', { + detail: { + selector: result.selector, + status: result.status, + finding: result.finding || null, + }, + })); + } + }) + .catch(err => { + reportVisualContrastError(err, { selector: candidate.selector }); + }) + .finally(() => { + lazyVisualContrastResolving.delete(el); + }); + } + }, { threshold: 0.5 }); + + for (const candidate of unresolved) { + let el = null; + try { + el = document.querySelector(candidate.selector); + } catch { + el = null; + } + if (!el) continue; + lazyVisualContrastPending.set(el, candidate); + lazyVisualContrastObserver.observe(el); + } + } + + async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) { + if (!shouldRunVisualContrast(options)) { + lastVisualContrastAnalyses = []; + disconnectLazyVisualContrastObserver(); + return []; + } + const resolvedOptions = visualContrastOptions(options); + const analyses = await analyzeVisualContrast(resolvedOptions); + if (runtime.generation && runtime.generation !== scanGeneration) return analyses; + lastVisualContrastAnalyses = analyses; + for (const result of analyses) { + addVisualContrastResult(groupMap, result, { decorate: runtime.decorate }); + } + if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime); + return analyses; + } + + async function collectBrowserFindingsAsync(options = {}, runtime = {}) { + const collected = collectBrowserFindings(); + await addVisualContrastFindings(collected.groupMap, options, runtime); + return { + ...collected, + allFindings: browserFindingsFromMap(collected.groupMap), + visualContrastAnalyses: lastVisualContrastAnalyses, + }; + } + + function clearOverlays() { + scanGeneration += 1; + disconnectLazyVisualContrastObserver(); + for (const o of [...overlays]) detachOverlay(o); + overlays.length = 0; + visibilityObserver.disconnect(); + overlayIndex = 0; + } + + function renderBrowserFindings(collected) { + const { allFindings, pageLevelFindings } = collected; + + for (const { el, findings } of allFindings) { + if (el === document.body || el === document.documentElement) continue; + highlight(el, findings); + } + + if (pageLevelFindings.length > 0) { + showPageBanner(pageLevelFindings); + } + + if (!EXTENSION_MODE) printSummary(allFindings); + + // In extension mode, post serialized results for the DevTools panel + if (EXTENSION_MODE) { + window.postMessage({ + source: 'impeccable-results', + findings: serializeFindings(allFindings), + count: allFindings.length, + }, '*'); + } + + // After this scan completes, all subsequent reveals are instant (no stagger, no animation) + setTimeout(() => { firstScanDone = true; }, 1000); + + return allFindings; + } + + let firstScanDone = false; + const scan = function(options = {}) { + clearOverlays(); + const generation = scanGeneration; + const collected = collectBrowserFindings(); + const allFindings = renderBrowserFindings(collected); + if (shouldRunVisualContrast(options)) { + addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) + .then(() => { + if (generation === scanGeneration) postSerializedFindings(collected.groupMap); + }) + .catch(err => { + reportVisualContrastError(err); + }); + } + return allFindings; + }; + + const scanAsync = async function(options = {}) { + clearOverlays(); + const generation = scanGeneration; + if (shouldRunVisualContrast(options)) { + const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true }); + if (generation !== scanGeneration) return []; + return renderBrowserFindings(collected); + } + lastVisualContrastAnalyses = []; + return renderBrowserFindings(collectBrowserFindings()); + }; + + const detect = function(options = {}) { + lastVisualContrastAnalyses = []; + const { allFindings } = collectBrowserFindings(); + return options.serialize === false ? allFindings : serializeFindings(allFindings); + }; + + const detectAsync = async function(options = {}) { + if (shouldRunVisualContrast(options)) { + const { allFindings } = await collectBrowserFindingsAsync(options); + return options.serialize === false ? allFindings : serializeFindings(allFindings); + } + lastVisualContrastAnalyses = []; + const { allFindings } = collectBrowserFindings(); + return options.serialize === false ? allFindings : serializeFindings(allFindings); + }; + + if (EXTENSION_MODE) { + // Extension mode: listen for commands, don't auto-scan + window.addEventListener('message', (e) => { + if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; + if (e.data.action === 'scan') { + if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; + try { + scan(e.data.config || {}); + } catch (err) { + postExtensionError(err); + } + } + if (e.data.action === 'toggle-overlays') { + const visible = !document.body.classList.contains('impeccable-hidden'); + document.body.classList.toggle('impeccable-hidden', visible); + window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); + } + if (e.data.action === 'remove') { + clearOverlays(); + styleEl.remove(); + if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } + document.body.classList.remove('impeccable-hidden'); + } + if (e.data.action === 'highlight') { + try { + const target = e.data.selector ? document.querySelector(e.data.selector) : null; + if (target) { + // Scroll first so positionOverlay reads the post-scroll rect + if (!isInViewport(target) && target.scrollIntoView) { + target.scrollIntoView({ behavior: 'instant', block: 'center' }); + } + for (const o of overlays) { + if (o.classList.contains('impeccable-banner')) continue; + const isMatch = o._targetEl === target; + o.classList.toggle('impeccable-spotlight', isMatch); + o.classList.toggle('impeccable-spotlight-dimmed', !isMatch); + if (isMatch) { + // Force the matching overlay visible immediately, don't wait for IntersectionObserver + o.style.display = ''; + o.style.animation = 'none'; + o.classList.add('impeccable-visible'); + o._revealed = true; + positionOverlay(o); + } + } + showSpotlight(target); + } + } catch { /* invalid selector */ } + } + if (e.data.action === 'unhighlight') { + hideSpotlight(); + for (const o of overlays) { + o.classList.remove('impeccable-spotlight'); + o.classList.remove('impeccable-spotlight-dimmed'); + } + } + }); + window.postMessage({ source: 'impeccable-ready' }, '*'); + } else { + if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) { + const runAutoScan = () => { + try { + scan(); + } catch (err) { + console.warn('[impeccable] scan failed', err); + } + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100)); + } else { + setTimeout(runAutoScan, 100); + } + } + } + + window.impeccableDetect = detect; + window.impeccableDetectAsync = detectAsync; + window.impeccableScan = scan; + window.impeccableScanAsync = scanAsync; + window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates; + window.impeccableAnalyzeVisualContrast = analyzeVisualContrast; + window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice(); +} diff --git a/cli/engine/cli/main.mjs b/cli/engine/cli/main.mjs new file mode 100644 index 00000000..76da09f0 --- /dev/null +++ b/cli/engine/cli/main.mjs @@ -0,0 +1,232 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs'; +import { detectHtml } from '../engines/static-html/detect-html.mjs'; +import { detectText } from '../engines/regex/detect-text.mjs'; +import { + HTML_EXTENSIONS, + buildImportGraph, + detectFrameworkConfig, + isPortListening, + walkDir, +} from '../node/file-system.mjs'; + +// --------------------------------------------------------------------------- +// Output formatting +// --------------------------------------------------------------------------- + +function formatFindings(findings, jsonMode) { + if (jsonMode) return JSON.stringify(findings, null, 2); + + const grouped = {}; + for (const f of findings) { + if (!grouped[f.file]) grouped[f.file] = []; + grouped[f.file].push(f); + } + const out = []; + for (const [file, items] of Object.entries(grouped)) { + const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : ''; + out.push(`\n${file}${importNote}`); + for (const item of items) { + out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`); + out.push(` → ${item.description}`); + } + } + out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`); + return out.join('\n'); +} + +// --------------------------------------------------------------------------- +// Stdin handling +// --------------------------------------------------------------------------- + +async function handleStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const input = Buffer.concat(chunks).toString('utf-8'); + try { + const parsed = JSON.parse(input); + const fp = parsed?.tool_input?.file_path; + if (fp && fs.existsSync(fp)) { + return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase()) + ? detectHtml(fp) : detectText(fs.readFileSync(fp, 'utf-8'), fp); + } + } catch { /* not JSON */ } + return detectText(input, ''); +} + + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function confirm(question) { + const rl = (await import('node:readline')).default.createInterface({ + input: process.stdin, output: process.stderr, + }); + return new Promise((resolve) => { + rl.question(`${question} [Y/n] `, (answer) => { + rl.close(); + resolve(!answer || /^y(es)?$/i.test(answer.trim())); + }); + }); +} + +function printUsage() { + console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...] + +Scan files or URLs for UI anti-patterns and design quality issues. + +Options: + --fast Regex-only mode (skip static HTML/CSS analysis, faster but misses linked stylesheets) + --json Output results as JSON + --help Show this help message + +Detection modes: + HTML files Static HTML/CSS analysis (default, catches linked CSS) + Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) + URLs Puppeteer full browser rendering (auto-detected) + --fast Forces regex for all files + +Examples: + impeccable detect src/ + impeccable detect index.html + impeccable detect https://example.com + impeccable detect --fast --json .`); +} + +async function detectCli() { + let args = process.argv.slice(2).map(arg => { + if (arg === '-json') return '--json'; + if (arg === '-fast') return '--fast'; + return arg; + }); + if (args[0] === 'detect') args = args.slice(1); + const jsonMode = args.includes('--json'); + const helpMode = args.includes('--help'); + const fastMode = args.includes('--fast'); + const targets = args.filter(a => !a.startsWith('--')); + + if (helpMode) { printUsage(); process.exit(0); } + + let allFindings = []; + + if (!process.stdin.isTTY && targets.length === 0) { + allFindings = await handleStdin(); + } else { + const paths = targets.length > 0 ? targets : [process.cwd()]; + const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length; + const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null; + + try { + for (const target of paths) { + if (/^https?:\/\//i.test(target)) { + try { + const scanner = browserDetector + ? (url) => browserDetector.detectUrl(url) + : (url) => detectUrl(url); + allFindings.push(...await scanner(target)); + } catch (e) { process.stderr.write(`Error: ${e.message}\n`); } + continue; + } + + const resolved = path.resolve(target); + let stat; + try { stat = fs.statSync(resolved); } + catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; } + + if (stat.isDirectory()) { + // Check for framework dev server config (skip in JSON mode to avoid polluting output) + if (!jsonMode) { + const fwConfig = detectFrameworkConfig(resolved); + if (fwConfig) { + const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint); + if (probe.listening && probe.matched) { + process.stderr.write( + `\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` + + `For more accurate results, scan the running site:\n` + + ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` + ); + } else if (probe.listening && !probe.matched) { + process.stderr.write( + `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + + `Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n` + ); + } else { + process.stderr.write( + `\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` + + `Start the dev server and scan via URL for best results:\n` + + ` npx impeccable detect http://localhost:${fwConfig.port}\n\n` + ); + } + } + } + + const files = walkDir(resolved); + const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length; + + // Warn and confirm if scanning many files (static HTML/CSS processes each HTML file) + if (files.length > 50 && process.stdin.isTTY && !jsonMode) { + process.stderr.write( + `\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` + + `Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` + + `Use --fast to skip static HTML/CSS analysis, or target a specific subdirectory.\n` + ); + const ok = await confirm('Continue?'); + if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); } + } + + // Build import graph for multi-file awareness + const graph = buildImportGraph(files); + // Build reverse map: file -> set of files that import it + const importedByMap = new Map(); + for (const [importer, imports] of graph) { + for (const imported of imports) { + if (!importedByMap.has(imported)) importedByMap.set(imported, new Set()); + importedByMap.get(imported).add(importer); + } + } + + for (const file of files) { + const ext = path.extname(file).toLowerCase(); + let fileFindings; + if (!fastMode && HTML_EXTENSIONS.has(ext)) { + fileFindings = await detectHtml(file); + } else { + fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file); + } + // Annotate findings with import context + const importers = importedByMap.get(file); + if (importers && importers.size > 0) { + const importerNames = [...importers].map(f => path.basename(f)); + for (const f of fileFindings) { + f.importedBy = importerNames; + } + } + allFindings.push(...fileFindings); + } + } else if (stat.isFile()) { + const ext = path.extname(resolved).toLowerCase(); + if (!fastMode && HTML_EXTENSIONS.has(ext)) { + allFindings.push(...await detectHtml(resolved)); + } else { + allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved)); + } + } + } + } finally { + if (browserDetector) await browserDetector.close(); + } + } + + if (allFindings.length > 0) { + if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n'); + else process.stderr.write(formatFindings(allFindings, false) + '\n'); + process.exit(2); + } + if (jsonMode) process.stdout.write('[]\n'); + process.exit(0); +} + +export { formatFindings, handleStdin, confirm, printUsage, detectCli }; diff --git a/cli/engine/detect-antipatterns-browser.js b/cli/engine/detect-antipatterns-browser.js index 9168f545..1afbe3f1 100644 --- a/cli/engine/detect-antipatterns-browser.js +++ b/cli/engine/detect-antipatterns-browser.js @@ -3,7 +3,7 @@ * Copyright (c) 2026 Paul Bakaus * SPDX-License-Identifier: Apache-2.0 * - * GENERATED -- do not edit. Source: detect-antipatterns.mjs + * GENERATED -- do not edit. Source: cli/engine/browser/injected/index.mjs * Rebuild: node scripts/build-browser-detector.js * * Usage: @@ -11,33 +11,7 @@ */ (function () { if (typeof window === 'undefined') return; - -/** - * Anti-Pattern Detector for Impeccable - * Copyright (c) 2026 Paul Bakaus - * SPDX-License-Identifier: Apache-2.0 - * - * Universal file — auto-detects environment (browser vs Node) and adapts. - * - * Node usage: - * node detect-antipatterns.mjs [file-or-dir...] # jsdom for HTML, regex for rest - * node detect-antipatterns.mjs https://... # Puppeteer (auto) - * node detect-antipatterns.mjs --fast [files...] # regex-only (skip jsdom) - * node detect-antipatterns.mjs --json # JSON output - * - * Browser usage: - * - * Re-scan: window.impeccableScan() - * - * Exit codes: 0 = clean, 2 = findings - */ - -// ─── Environment ──────────────────────────────────────────────────────────── - -const IS_BROWSER = true; -const IS_NODE = !IS_BROWSER; - - +// --- cli/engine/shared/constants.mjs --- // ─── Section 1: Constants ─────────────────────────────────────────────────── const SAFE_TAGS = new Set([ @@ -103,6 +77,11 @@ const GENERIC_FONTS = new Set([ 'inherit', 'initial', 'unset', 'revert', ]); +// WCAG large text thresholds are defined in points: 18pt normal text and +// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch. +const WCAG_LARGE_TEXT_PX = 18 * (96 / 72); +const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72); + // Serif faces that show up in italic-display heroes. The rule also fires when // the primary face is unknown but the stack ends in the generic `serif` token, // which catches custom/private faces with a serif fallback. @@ -120,6 +99,7 @@ const KNOWN_SERIF_FONTS = new Set([ 'freight display', 'freight text', ]); +// --- cli/engine/registry/antipatterns.mjs --- const ANTIPATTERNS = [ // ── AI slop: tells that something was AI-generated ── { @@ -372,6 +352,7 @@ const ANTIPATTERNS = [ }, ]; +// --- cli/engine/shared/color.mjs --- // ─── Section 2: Color Utilities ───────────────────────────────────────────── function isNeutralColor(color) { @@ -387,19 +368,19 @@ function isNeutralColor(color) { // oklch chroma is ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray. // lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats // literally (it does NOT convert them to rgb). - const oklch = color.match(/oklch\(\s*[\d.%-]+\s+([\d.-]+)/i); + const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i); if (oklch) return parseFloat(oklch[1]) < 0.02; - const lch = color.match(/lch\(\s*[\d.%-]+\s+([\d.-]+)/i); + const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i); if (lch) return parseFloat(lch[1]) < 3; // oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²). // oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3. - const oklab = color.match(/oklab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i); + const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); if (oklab) { const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]); return Math.hypot(a, b) < 0.02; } - const lab = color.match(/lab\(\s*[\d.%-]+\s+([\d.-]+)\s+([\d.-]+)/i); + const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i); if (lab) { const a = parseFloat(lab[1]), b = parseFloat(lab[2]); return Math.hypot(a, b) < 3; @@ -486,6 +467,9 @@ function colorToHex(c) { return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); } +// --- cli/engine/rules/checks.mjs --- +const DETECTOR_IS_BROWSER = typeof window !== 'undefined'; + // ─── Section 3: Pure Detection ────────────────────────────────────────────── function checkBorders(tag, widths, colors, radius) { @@ -566,8 +550,7 @@ function checkColors(opts) { let worstIdx = 0; for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i; const ratio = ratios[worstIdx]; - const isHeading = ['h1', 'h2', 'h3'].includes(tag); - const isLargeText = fontSize >= 18 || (fontSize >= 14 && fontWeight >= 700) || isHeading; + const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); const threshold = isLargeText ? 3.0 : 4.5; if (ratio < threshold) { // Skip the false-positive class where text has alpha < 1 AND we @@ -580,7 +563,7 @@ function checkColors(opts) { // local bg. Real low-contrast bugs use alpha=1 and have a // resolvable opaque ancestor; semi-transparent Tailwind tokens // like `text-paper/60` on `bg-ink` sections are the FP pattern. - const isAlphaFallbackFP = !IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1); + const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1); if (!isAlphaFallbackFP) { findings.push({ id: 'low-contrast', snippet: `${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` }); } @@ -1058,7 +1041,7 @@ function checkHtmlPatterns(html) { // a no-op there. function readOwnBackgroundColor(el, computedStyle) { const bg = parseRgb(computedStyle.backgroundColor); - if (IS_BROWSER || (bg && bg.a >= 0.1)) return bg; + if (DETECTOR_IS_BROWSER || (bg && bg.a >= 0.1)) return bg; const rawStyle = el.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i); const inlineBg = bgMatch ? bgMatch[1].trim() : ''; @@ -1080,7 +1063,7 @@ function readOwnBackgroundColor(el, computedStyle) { function resolveBackground(el, win, customPropMap) { let current = el; while (current && current.nodeType === 1) { - const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); + const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); const bgImage = style.backgroundImage || ''; const hasGradientOrUrl = bgImage && bgImage !== 'none' && (/gradient/i.test(bgImage) || /url\s*\(/i.test(bgImage)); @@ -1092,7 +1075,7 @@ function resolveBackground(el, win, customPropMap) { // caused massive false-positive contrast findings on grain-textured // body backgrounds. let bg = parseRgb(style.backgroundColor); - if (!IS_BROWSER && (!bg || bg.a < 0.1)) { + if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) { // jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve // through customPropMap so Tailwind v4 color tokens become RGB. if (customPropMap) { @@ -1111,7 +1094,7 @@ function resolveBackground(el, win, customPropMap) { } if (bg && bg.a > 0.1) { - if (IS_BROWSER || bg.a >= 0.5) return bg; + if (DETECTOR_IS_BROWSER || bg.a >= 0.5) return bg; } // No solid bg-color at this level. If THIS level has a gradient/url // with no underlying solid color we can read: @@ -1142,13 +1125,13 @@ function resolveBackground(el, win, customPropMap) { function resolveGradientStops(el, win) { let current = el; while (current && current.nodeType === 1) { - const style = IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); + const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); const bgImage = style.backgroundImage || ''; if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { const stops = parseGradientColors(bgImage); if (stops.length > 0) return stops; } - if (!IS_BROWSER) { + if (!DETECTOR_IS_BROWSER) { // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style const rawStyle = current.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); @@ -2354,6 +2337,9 @@ function checkPageLayout(doc, win) { return findings; } +// --- cli/engine/browser/injected/index.mjs --- +const IS_BROWSER = typeof window !== 'undefined'; + // ─── Section 7: Browser UI (IS_BROWSER only) ──────────────────────────────── if (IS_BROWSER) { @@ -2419,16 +2405,12 @@ if (IS_BROWSER) { .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { display: none !important; } - .impeccable-hidden .impeccable-overlay${EXTENSION_MODE ? '' : ':not(.impeccable-banner)'} { - display: none !important; - } `; (document.head || document.documentElement).appendChild(styleEl); // Spotlight backdrop element (created lazily on first use) let spotlightBackdrop = null; let spotlightTarget = null; - let spotlightTimer = null; function getSpotlightBackdrop() { if (!spotlightBackdrop) { @@ -2471,7 +2453,6 @@ if (IS_BROWSER) { function hideSpotlight() { spotlightTarget = null; - if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; } if (spotlightBackdrop) spotlightBackdrop.classList.remove('impeccable-visible'); } @@ -2577,6 +2558,20 @@ if (IS_BROWSER) { } }, { rootMargin: '99999px' }); + function detachOverlay(overlay) { + if (!overlay) return; + if (typeof overlay._cleanup === 'function') { + try { overlay._cleanup(); } catch { /* best effort overlay teardown */ } + } + if (overlay._targetEl && overlay._targetEl._impeccableOverlay === overlay) { + visibilityObserver.unobserve(overlay._targetEl); + delete overlay._targetEl._impeccableOverlay; + } + const idx = overlays.indexOf(overlay); + if (idx >= 0) overlays.splice(idx, 1); + overlay.remove(); + } + // Reposition overlays after CSS transitions end (e.g. reveal animations). // Listens at document level so it catches transitions on ancestor elements // (the transform may be on a parent, not the flagged element itself). @@ -2591,6 +2586,7 @@ if (IS_BROWSER) { }); const highlight = function(el, findings) { + if (el._impeccableOverlay) detachOverlay(el._impeccableOverlay); const hasSlop = findings.some(f => RULE_CATEGORY[f.type || f.id] === 'slop'); const fixed = isInFixedContext(el); @@ -2703,7 +2699,7 @@ if (IS_BROWSER) { }; // Hover: show detail text, darken - el.addEventListener('mouseenter', () => { + const onMouseEnter = () => { isHovered = true; outline.classList.add('impeccable-hover'); outline.style.outlineColor = BRAND_COLOR_HOVER; @@ -2713,8 +2709,8 @@ if (IS_BROWSER) { } else { textSpan.textContent = entries.map(e => e.detail).join(' | '); } - }); - el.addEventListener('mouseleave', () => { + }; + const onMouseLeave = () => { isHovered = false; outline.classList.remove('impeccable-hover'); outline.style.outlineColor = ''; @@ -2724,7 +2720,13 @@ if (IS_BROWSER) { } else { textSpan.textContent = allText; } - }); + }; + el.addEventListener('mouseenter', onMouseEnter); + el.addEventListener('mouseleave', onMouseLeave); + outline._cleanup = () => { + el.removeEventListener('mouseenter', onMouseEnter); + el.removeEventListener('mouseleave', onMouseLeave); + }; document.body.appendChild(outline); overlays.push(outline); @@ -2887,6 +2889,644 @@ if (IS_BROWSER) { return parts.join(' > '); } + function getDirectText(el) { + return [...el.childNodes] + .filter(n => n.nodeType === 3) + .map(n => n.textContent || '') + .join(''); + } + + function getDirectTextRect(el) { + const rects = []; + for (const node of el.childNodes) { + if (node.nodeType !== 3 || !(node.textContent || '').trim()) continue; + const range = document.createRange(); + range.selectNodeContents(node); + for (const rect of range.getClientRects()) { + if (rect.width >= 1 && rect.height >= 1) rects.push(rect); + } + range.detach?.(); + } + if (rects.length === 0) return null; + const left = Math.min(...rects.map(r => r.left)); + const top = Math.min(...rects.map(r => r.top)); + const right = Math.max(...rects.map(r => r.right)); + const bottom = Math.max(...rects.map(r => r.bottom)); + return { + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + x: left, + y: top, + }; + } + + function collectVisualContrastReasons(el, style) { + const reasons = new Set(); + const bgClip = style.webkitBackgroundClip || style.backgroundClip || ''; + const ownBgImage = style.backgroundImage || ''; + if (bgClip === 'text' && ownBgImage && ownBgImage !== 'none') { + reasons.add('background-clip text'); + } + if (style.textShadow && style.textShadow !== 'none') reasons.add('text shadow'); + + let current = el; + while (current && current.nodeType === 1) { + const tag = current.tagName?.toLowerCase(); + const currentStyle = getComputedStyle(current); + const bgImage = currentStyle.backgroundImage || ''; + const isDocumentSurface = tag === 'body' || tag === 'html'; + + if (!isDocumentSurface && bgImage && bgImage !== 'none') { + if (/url\s*\(/i.test(bgImage)) reasons.add('image background'); + if (/gradient/i.test(bgImage)) reasons.add('gradient background'); + } + if (parseFloat(currentStyle.opacity) < 0.99) reasons.add('opacity stack'); + if (currentStyle.mixBlendMode && currentStyle.mixBlendMode !== 'normal') reasons.add('blend mode'); + if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter'); + if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter'); + + const solidBg = parseRgb(currentStyle.backgroundColor); + if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break; + current = current.parentElement; + } + + const sampleRect = getDirectTextRect(el) || el.getBoundingClientRect(); + if (sampleRect && document.elementsFromPoint) { + const points = [ + [sampleRect.left + sampleRect.width / 2, sampleRect.top + sampleRect.height / 2], + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.25)), sampleRect.top + sampleRect.height / 2], + [sampleRect.left + Math.min(sampleRect.width - 1, Math.max(1, sampleRect.width * 0.75)), sampleRect.top + sampleRect.height / 2], + ]; + for (const [x, y] of points) { + if (x < 0 || y < 0 || x > window.innerWidth || y > window.innerHeight) continue; + const stack = document.elementsFromPoint(x, y); + const selfIndex = stack.findIndex(node => node === el || el.contains(node) || node.contains?.(el)); + if (selfIndex < 0) continue; + for (const node of stack.slice(selfIndex + 1)) { + const nodeTag = node.tagName?.toLowerCase(); + if (nodeTag === 'img' || nodeTag === 'picture' || nodeTag === 'video' || nodeTag === 'canvas' || nodeTag === 'svg') { + reasons.add(`${nodeTag} underlay`); + break; + } + } + } + } + + return [...reasons]; + } + + function collectVisualContrastCandidates(options = {}) { + const maxCandidates = Number.isFinite(options.maxCandidates) ? options.maxCandidates : 12; + const candidates = []; + for (const el of document.querySelectorAll('*')) { + if (candidates.length >= maxCandidates) break; + if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; + if (el.closest('[id^="impeccable-live-"]')) continue; + if (el === document.body || el === document.documentElement) continue; + + const tag = el.tagName.toLowerCase(); + const style = getComputedStyle(el); + if (style.display === 'none' || style.visibility === 'hidden') continue; + const directText = getDirectText(el); + const hasDirectText = directText.trim().length > 0; + if (!hasDirectText || isEmojiOnlyText(directText)) continue; + + const bgColor = readOwnBackgroundColor(el, style); + const isStyledButton = (tag === 'a' || tag === 'button') + && bgColor && bgColor.a > 0.5; + if (SAFE_TAGS.has(tag) && !isStyledButton) continue; + + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); + if (!rect || rect.width < 4 || rect.height < 4) continue; + + const reasons = collectVisualContrastReasons(el, style); + if (reasons.length === 0) continue; + + const textColor = parseRgb(style.color); + const fontSize = parseFloat(style.fontSize) || 16; + const fontWeight = parseInt(style.fontWeight) || 400; + const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700); + const threshold = isLargeText ? 3.0 : 4.5; + const clip = { + x: Math.max(0, Math.floor(rect.left + window.scrollX - 2)), + y: Math.max(0, Math.floor(rect.top + window.scrollY - 2)), + width: Math.max(1, Math.ceil(rect.width + 4)), + height: Math.max(1, Math.ceil(rect.height + 4)), + }; + + candidates.push({ + selector: generateSelector(el), + tagName: tag, + text: directText.trim().replace(/\s+/g, ' ').slice(0, 80), + threshold, + reasons, + clip, + textColor, + preferRenderedForeground: !textColor || textColor.a < 0.99 || reasons.some(reason => + reason === 'opacity stack' || + reason === 'blend mode' || + reason === 'filter' || + reason === 'backdrop filter' || + reason === 'background-clip text' + ), + backgroundClipText: reasons.includes('background-clip text'), + }); + } + return candidates; + } + + const visualContrastImageCache = new Map(); + const visualContrastRasterCache = new WeakMap(); + + function clampByte(value) { + return Math.max(0, Math.min(255, Math.round(value))); + } + + function blendRgba(fg, bg) { + if (!fg) return bg || null; + if (!bg || fg.a == null || fg.a >= 0.999) { + return { r: clampByte(fg.r), g: clampByte(fg.g), b: clampByte(fg.b), a: fg.a == null ? 1 : fg.a }; + } + const alpha = Math.max(0, Math.min(1, fg.a)); + return { + r: clampByte(fg.r * alpha + bg.r * (1 - alpha)), + g: clampByte(fg.g * alpha + bg.g * (1 - alpha)), + b: clampByte(fg.b * alpha + bg.b * (1 - alpha)), + a: 1, + }; + } + + function pickWorstContrastColor(textColor, colors) { + const usable = (colors || []).filter(Boolean); + if (!usable.length) return null; + let worst = usable[0]; + let worstRatio = contrastRatio(textColor, worst); + for (const color of usable.slice(1)) { + const ratio = contrastRatio(textColor, color); + if (ratio < worstRatio) { + worst = color; + worstRatio = ratio; + } + } + return worst; + } + + function firstCssUrl(value) { + const match = String(value || '').match(/url\((?:"([^"]+)"|'([^']+)'|([^)]*))\)/i); + if (!match) return ''; + return (match[1] || match[2] || match[3] || '').trim(); + } + + function getLayerValue(value, index = 0) { + return String(value || '').split(',')[index]?.trim() || ''; + } + + function parsePositionToken(token, container, painted) { + if (!token || token === 'center') return (container - painted) / 2; + if (token === 'left' || token === 'top') return 0; + if (token === 'right' || token === 'bottom') return container - painted; + if (/%$/.test(token)) { + const pct = parseFloat(token) / 100; + return (container - painted) * pct; + } + if (/px$/.test(token)) return parseFloat(token) || 0; + return (container - painted) / 2; + } + + function parsePositionPair(positionValue) { + const tokens = String(positionValue || '50% 50%').trim().split(/\s+/).filter(Boolean); + const first = tokens[0] || '50%'; + if (tokens.length < 2) { + if (first === 'top' || first === 'bottom') return ['50%', first]; + return [first, '50%']; + } + return [first, tokens[1] || '50%']; + } + + function resolvePaintedImageRect(containerRect, image, sizeValue, positionValue) { + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; + let paintedWidth = intrinsicWidth; + let paintedHeight = intrinsicHeight; + const size = String(sizeValue || 'auto').trim(); + + if (size === 'cover' || size === 'contain') { + const scale = size === 'cover' + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); + paintedWidth = intrinsicWidth * scale; + paintedHeight = intrinsicHeight * scale; + } else if (size && size !== 'auto') { + const parts = size.split(/\s+/); + const widthToken = parts[0]; + const heightToken = parts[1] || 'auto'; + if (/%$/.test(widthToken)) paintedWidth = containerRect.width * (parseFloat(widthToken) / 100); + else if (/px$/.test(widthToken)) paintedWidth = parseFloat(widthToken) || paintedWidth; + if (heightToken === 'auto') paintedHeight = paintedWidth * (intrinsicHeight / intrinsicWidth); + else if (/%$/.test(heightToken)) paintedHeight = containerRect.height * (parseFloat(heightToken) / 100); + else if (/px$/.test(heightToken)) paintedHeight = parseFloat(heightToken) || paintedHeight; + } + + const [xToken, yToken] = parsePositionPair(positionValue); + const positionX = parsePositionToken(xToken, containerRect.width, paintedWidth); + const positionY = parsePositionToken(yToken, containerRect.height, paintedHeight); + return { + left: containerRect.left + positionX, + top: containerRect.top + positionY, + width: paintedWidth, + height: paintedHeight, + intrinsicWidth, + intrinsicHeight, + }; + } + + function parseObjectPosition(positionValue) { + return parsePositionPair(positionValue); + } + + function resolveObjectImageRect(containerRect, image, style) { + const intrinsicWidth = image.naturalWidth || image.videoWidth || image.width || 1; + const intrinsicHeight = image.naturalHeight || image.videoHeight || image.height || 1; + const fit = style.objectFit || 'fill'; + let paintedWidth = containerRect.width; + let paintedHeight = containerRect.height; + if (fit === 'contain' || fit === 'cover') { + const scale = fit === 'cover' + ? Math.max(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight) + : Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight); + paintedWidth = intrinsicWidth * scale; + paintedHeight = intrinsicHeight * scale; + } else if (fit === 'none') { + paintedWidth = intrinsicWidth; + paintedHeight = intrinsicHeight; + } else if (fit === 'scale-down') { + const containScale = Math.min(containerRect.width / intrinsicWidth, containerRect.height / intrinsicHeight, 1); + paintedWidth = intrinsicWidth * containScale; + paintedHeight = intrinsicHeight * containScale; + } + const [xToken, yToken] = parseObjectPosition(style.objectPosition); + return { + left: containerRect.left + parsePositionToken(xToken, containerRect.width, paintedWidth), + top: containerRect.top + parsePositionToken(yToken, containerRect.height, paintedHeight), + width: paintedWidth, + height: paintedHeight, + intrinsicWidth, + intrinsicHeight, + }; + } + + function pointToImageSource(point, paintedRect) { + if ( + point.x < paintedRect.left || + point.y < paintedRect.top || + point.x > paintedRect.left + paintedRect.width || + point.y > paintedRect.top + paintedRect.height + ) { + return null; + } + return { + x: Math.max(0, Math.min(paintedRect.intrinsicWidth - 1, ((point.x - paintedRect.left) / paintedRect.width) * paintedRect.intrinsicWidth)), + y: Math.max(0, Math.min(paintedRect.intrinsicHeight - 1, ((point.y - paintedRect.top) / paintedRect.height) * paintedRect.intrinsicHeight)), + }; + } + + async function loadVisualContrastImage(src) { + if (!src) return null; + if (visualContrastImageCache.has(src)) return visualContrastImageCache.get(src); + const promise = new Promise(resolve => { + const img = new Image(); + let settled = false; + const finish = value => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + const timer = setTimeout(() => finish(null), 800); + try { + const absolute = new URL(src, location.href); + if (absolute.origin !== location.origin && absolute.protocol !== 'data:' && absolute.protocol !== 'blob:') { + img.crossOrigin = 'anonymous'; + } + } catch { + // Let the browser resolve unusual URLs itself. + } + img.onload = () => finish(img); + img.onerror = () => finish(null); + img.src = src; + }); + visualContrastImageCache.set(src, promise); + return promise; + } + + function sampleDrawablePixel(drawable, sourcePoint) { + if (visualContrastRasterCache.has(drawable)) { + const cached = visualContrastRasterCache.get(drawable); + if (!cached || !cached.ctx) return { status: 'unresolved', reason: cached?.reason || 'image sample failed' }; + try { + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); + const data = cached.ctx.getImageData(x, y, 1, 1).data; + return { + status: 'sampled', + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, + }; + } catch (err) { + return { + status: 'unresolved', + reason: /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed', + }; + } + } + + const canvas = document.createElement('canvas'); + const intrinsicWidth = drawable.naturalWidth || drawable.videoWidth || drawable.width || 1; + const intrinsicHeight = drawable.naturalHeight || drawable.videoHeight || drawable.height || 1; + const maxRasterSide = 640; + const scale = Math.min(1, maxRasterSide / Math.max(intrinsicWidth, intrinsicHeight)); + canvas.width = Math.max(1, Math.round(intrinsicWidth * scale)); + canvas.height = Math.max(1, Math.round(intrinsicHeight * scale)); + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) return { status: 'unresolved', reason: 'canvas unavailable' }; + try { + ctx.drawImage(drawable, 0, 0, canvas.width, canvas.height); + const cached = { + ctx, + width: canvas.width, + height: canvas.height, + scaleX: canvas.width / intrinsicWidth, + scaleY: canvas.height / intrinsicHeight, + }; + visualContrastRasterCache.set(drawable, cached); + const x = Math.max(0, Math.min(cached.width - 1, Math.floor(sourcePoint.x * cached.scaleX))); + const y = Math.max(0, Math.min(cached.height - 1, Math.floor(sourcePoint.y * cached.scaleY))); + const data = ctx.getImageData(x, y, 1, 1).data; + return { + status: 'sampled', + color: { r: data[0], g: data[1], b: data[2], a: data[3] / 255 }, + }; + } catch (err) { + const reason = /taint|cross-origin|Security/i.test(err?.message || '') ? 'tainted image' : 'image sample failed'; + visualContrastRasterCache.set(drawable, { ctx: null, reason }); + return { + status: 'unresolved', + reason, + }; + } + } + + async function sampleCssBackground(el, style, point, textColor) { + const rect = el.getBoundingClientRect(); + const bgImage = style.backgroundImage || ''; + if (bgImage && bgImage !== 'none') { + if (/gradient/i.test(bgImage)) { + const color = pickWorstContrastColor(textColor, parseGradientColors(bgImage)); + if (color) return { status: 'sampled', color, method: 'analytic-gradient' }; + } + if (/url\s*\(/i.test(bgImage)) { + const img = await loadVisualContrastImage(firstCssUrl(bgImage)); + if (!img) return { status: 'unresolved', reason: 'image unavailable' }; + const paintedRect = resolvePaintedImageRect( + rect, + img, + getLayerValue(style.backgroundSize) || 'auto', + getLayerValue(style.backgroundPosition) || '50% 50%', + ); + const sourcePoint = pointToImageSource(point, paintedRect); + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside background image' }; + const sample = sampleDrawablePixel(img, sourcePoint); + if (sample.status === 'sampled') return { ...sample, method: 'canvas-background-image' }; + return sample; + } + } + const bg = parseRgb(style.backgroundColor); + if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' }; + return { status: 'unresolved', reason: 'no readable background' }; + } + + async function sampleImageElement(img, point) { + const rect = img.getBoundingClientRect(); + const style = getComputedStyle(img); + const paintedRect = resolveObjectImageRect(rect, img, style); + const sourcePoint = pointToImageSource(point, paintedRect); + if (!sourcePoint) return { status: 'unresolved', reason: 'point outside image' }; + const sample = sampleDrawablePixel(img, sourcePoint); + if (sample.status === 'sampled') return { ...sample, method: 'canvas-img-underlay' }; + + if (img.currentSrc || img.src) { + const loaded = await loadVisualContrastImage(img.currentSrc || img.src); + if (loaded) { + const loadedRect = { ...paintedRect, intrinsicWidth: loaded.naturalWidth || loaded.width || paintedRect.intrinsicWidth, intrinsicHeight: loaded.naturalHeight || loaded.height || paintedRect.intrinsicHeight }; + const loadedPoint = pointToImageSource(point, loadedRect); + if (loadedPoint) { + const loadedSample = sampleDrawablePixel(loaded, loadedPoint); + if (loadedSample.status === 'sampled') return { ...loadedSample, method: 'canvas-img-underlay' }; + } + } + } + return sample; + } + + function textSamplePoints(rect) { + const insetX = Math.min(12, Math.max(1, rect.width * 0.12)); + const insetY = Math.min(8, Math.max(1, rect.height * 0.22)); + const xs = rect.width < 28 + ? [rect.left + rect.width / 2] + : [rect.left + insetX, rect.left + rect.width / 2, rect.right - insetX]; + const ys = rect.height < 22 + ? [rect.top + rect.height / 2] + : [rect.top + insetY, rect.top + rect.height / 2, rect.bottom - insetY]; + const points = []; + for (const y of ys) { + for (const x of xs) { + if (x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight) points.push({ x, y }); + } + } + return points; + } + + async function sampleVisualBackgroundAtPoint(el, point, textColor, depth = 0) { + if (depth > 8) { + return { status: 'unresolved', reason: 'background stack too deep' }; + } + const stack = typeof document.elementsFromPoint === 'function' + ? document.elementsFromPoint(point.x, point.y) + : []; + const selfIndex = stack.findIndex(node => node === el || el.contains(node)); + const nodes = selfIndex >= 0 ? stack.slice(selfIndex) : [el, ...stack]; + const unresolved = []; + + for (const node of nodes) { + if (!node || node.nodeType !== 1) continue; + if (node.closest?.('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; + const tag = node.tagName?.toLowerCase(); + if (tag === 'img') { + const sample = await sampleImageElement(node, point); + if (sample.status === 'sampled') return sample; + unresolved.push(sample.reason); + continue; + } + if (tag === 'canvas' || tag === 'video') { + const rect = node.getBoundingClientRect(); + const sourcePoint = pointToImageSource(point, { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + intrinsicWidth: node.width || node.videoWidth || rect.width, + intrinsicHeight: node.height || node.videoHeight || rect.height, + }); + if (sourcePoint) { + const sample = sampleDrawablePixel(node, sourcePoint); + if (sample.status === 'sampled') return { ...sample, method: `canvas-${tag}-underlay` }; + unresolved.push(sample.reason); + } + continue; + } + const style = getComputedStyle(node); + const sample = await sampleCssBackground(node, style, point, textColor); + if (sample.status === 'sampled') { + if (!sample.color || sample.color.a == null || sample.color.a >= 0.95) return sample; + const under = await sampleVisualBackgroundAtPoint(node.parentElement || document.body, point, textColor, depth + 1); + if (under.status === 'sampled') { + return { + status: 'sampled', + color: blendRgba(sample.color, under.color), + method: `${sample.method}+alpha`, + }; + } + return sample; + } + unresolved.push(sample.reason); + } + + return { + status: 'unresolved', + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'no readable visual background', + }; + } + + async function analyzeVisualContrastCandidate(candidate) { + let el; + try { + el = document.querySelector(candidate.selector); + } catch { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' }; + } + if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' }; + + const blockingReason = (candidate.reasons || []).find(reason => + reason === 'background-clip text' || + reason === 'blend mode' || + reason === 'filter' || + reason === 'backdrop filter' || + reason === 'opacity stack' || + reason === 'text shadow' + ); + if (blockingReason) { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: `${blockingReason} needs screenshot pixels` }; + } + + const style = getComputedStyle(el); + const textColor = parseRgb(style.color) || candidate.textColor; + if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' }; + + const rect = getDirectTextRect(el) || el.getBoundingClientRect(); + if (!rect || rect.width < 4 || rect.height < 4) { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing text rect' }; + } + + const points = textSamplePoints(rect); + if (points.length === 0) { + return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'text outside viewport' }; + } + + const ratios = []; + const methods = new Set(); + const unresolved = []; + for (const point of points) { + const sample = await sampleVisualBackgroundAtPoint(el, point, textColor); + if (sample.status !== 'sampled' || !sample.color) { + unresolved.push(sample.reason); + continue; + } + const fg = blendRgba(textColor, sample.color); + ratios.push(contrastRatio(fg, sample.color)); + if (sample.method) methods.add(sample.method); + } + + if (ratios.length < Math.min(3, points.length)) { + return { + ...candidate, + status: 'unresolved', + confidence: 'none', + samples: ratios.length, + reason: [...new Set(unresolved.filter(Boolean))].slice(0, 3).join(', ') || 'not enough readable samples', + }; + } + + ratios.sort((a, b) => a - b); + const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))]; + const measuredRatio = pick(10); + const medianRatio = pick(50); + const status = measuredRatio < candidate.threshold ? 'fail' : 'pass'; + const method = [...methods].sort().join(', ') || 'browser-visual'; + const textLabel = candidate.text ? ` "${candidate.text}"` : ''; + const detail = `browser contrast ${measuredRatio.toFixed(1)}:1 median ${medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) via ${method}${textLabel}`; + return { + ...candidate, + status, + confidence: method.includes('canvas-') ? 'high' : 'medium', + method, + ratio: measuredRatio, + medianRatio, + samples: ratios.length, + finding: status === 'fail' ? { id: 'low-contrast', snippet: detail } : null, + }; + } + + function waitForVisualPaint() { + return new Promise(resolve => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + } + + async function analyzeVisualContrast(options = {}) { + const candidates = collectVisualContrastCandidates(options); + const results = []; + const shouldScrollOffscreen = options.scrollOffscreen === true; + const restoreScroll = { x: window.scrollX, y: window.scrollY }; + for (const candidate of candidates) { + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { + window.scrollTo(restoreScroll.x, restoreScroll.y); + await waitForVisualPaint(); + } + let result = await analyzeVisualContrastCandidate(candidate); + if (shouldScrollOffscreen && result.status === 'unresolved' && result.reason === 'text outside viewport') { + let el = null; + try { + el = document.querySelector(candidate.selector); + } catch { + el = null; + } + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); + await waitForVisualPaint(); + result = await analyzeVisualContrastCandidate(candidate); + } + } + results.push(result); + } + if (shouldScrollOffscreen && (window.scrollX !== restoreScroll.x || window.scrollY !== restoreScroll.y)) { + window.scrollTo(restoreScroll.x, restoreScroll.y); + } + return results; + } + function isElementHidden(el) { if (!el || el === document.body || el === document.documentElement) return false; if (typeof el.checkVisibility === 'function') return !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }); @@ -2934,13 +3574,19 @@ if (IS_BROWSER) { console.groupEnd(); }; - let firstScanDone = false; - const scan = function() { - for (const o of overlays) o.remove(); - overlays.length = 0; - visibilityObserver.disconnect(); - overlayIndex = 0; - const allFindings = []; + function addBrowserFindings(groupMap, el, findings) { + if (!findings || findings.length === 0) return; + const existing = groupMap.get(el); + if (existing) existing.push(...findings); + else groupMap.set(el, [...findings]); + } + + function browserFindingsFromMap(groupMap) { + return [...groupMap.entries()].map(([el, findings]) => ({ el, findings })); + } + + function collectBrowserFindings() { + const groupMap = new Map(); const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : []; const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id); @@ -2968,10 +3614,7 @@ if (IS_BROWSER) { ...checkElementQualityDOM(el).map(f => ({ type: f.id, detail: f.snippet })), ].filter(f => _ruleOk(f.type)); - if (findings.length > 0) { - highlight(el, findings); - allFindings.push({ el, findings }); - } + addBrowserFindings(groupMap, el, findings); } const pageLevelFindings = []; @@ -2979,7 +3622,7 @@ if (IS_BROWSER) { const typoFindings = checkTypography().filter(f => _ruleOk(f.type)); if (typoFindings.length > 0) { pageLevelFindings.push(...typoFindings); - allFindings.push({ el: document.body, findings: typoFindings }); + addBrowserFindings(groupMap, document.body, typoFindings); } const sectionKickerFindings = checkRepeatedSectionKickersDOM() @@ -2987,32 +3630,20 @@ if (IS_BROWSER) { .filter(f => _ruleOk(f.type)); if (sectionKickerFindings.length > 0) { pageLevelFindings.push(...sectionKickerFindings); - allFindings.push({ el: document.body, findings: sectionKickerFindings }); + addBrowserFindings(groupMap, document.body, sectionKickerFindings); } const layoutFindings = checkLayout().filter(f => _ruleOk(f.type)); for (const f of layoutFindings) { const el = f.el || document.body; - delete f.el; - // Merge into existing overlay if this element already has one - const existing = el._impeccableOverlay; - if (existing) { - const nameRow = existing.querySelector('.impeccable-label-name'); - const detailRow = existing.querySelector('.impeccable-label-detail'); - const newType = TYPE_LABELS[f.type] || f.type; - if (nameRow) nameRow.textContent += ', ' + newType; - if (detailRow) detailRow.textContent += ' | ' + (f.detail || ''); - } else { - highlight(el, [f]); - } - allFindings.push({ el, findings: [f] }); + addBrowserFindings(groupMap, el, [{ type: f.type, detail: f.detail || f.snippet }]); } // Page-level quality checks (headings, etc.) const qualityFindings = checkPageQualityDOM().filter(f => _ruleOk(f.type)); if (qualityFindings.length > 0) { pageLevelFindings.push(...qualityFindings); - allFindings.push({ el: document.body, findings: qualityFindings }); + addBrowserFindings(groupMap, document.body, qualityFindings); } // Regex-on-HTML checks (shared with Node) @@ -3027,7 +3658,222 @@ if (IS_BROWSER) { if (htmlPatternFindings.length > 0) { const mapped = htmlPatternFindings.map(f => ({ type: f.id, detail: f.snippet })).filter(f => _ruleOk(f.type)); pageLevelFindings.push(...mapped); - allFindings.push({ el: document.body, findings: mapped }); + addBrowserFindings(groupMap, document.body, mapped); + } + + return { + groupMap, + allFindings: browserFindingsFromMap(groupMap), + pageLevelFindings, + }; + } + + function shouldRunVisualContrast(options = {}) { + return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true; + } + + function visualContrastOptions(options = {}) { + const config = window.__IMPECCABLE_CONFIG__ || {}; + const scrollOffscreen = typeof options.scrollOffscreen === 'boolean' + ? options.scrollOffscreen + : typeof options.visualContrastScrollOffscreen === 'boolean' + ? options.visualContrastScrollOffscreen + : typeof config.visualContrastScrollOffscreen === 'boolean' + ? config.visualContrastScrollOffscreen + : false; + return { + ...options, + maxCandidates: Number.isFinite(options.visualContrastMaxCandidates) + ? options.visualContrastMaxCandidates + : Number.isFinite(options.maxCandidates) + ? options.maxCandidates + : Number.isFinite(config.visualContrastMaxCandidates) + ? config.visualContrastMaxCandidates + : undefined, + scrollOffscreen, + }; + } + + let lastVisualContrastAnalyses = []; + let lazyVisualContrastObserver = null; + let lazyVisualContrastPending = new WeakMap(); + const lazyVisualContrastResolving = new WeakSet(); + let scanGeneration = 0; + + function rememberVisualContrastAnalysis(result) { + if (!result?.selector) { + lastVisualContrastAnalyses.push(result); + return; + } + const idx = lastVisualContrastAnalyses.findIndex(item => item.selector === result.selector); + if (idx >= 0) lastVisualContrastAnalyses[idx] = result; + else lastVisualContrastAnalyses.push(result); + } + + function disconnectLazyVisualContrastObserver() { + if (lazyVisualContrastObserver) { + lazyVisualContrastObserver.disconnect(); + lazyVisualContrastObserver = null; + } + lazyVisualContrastPending = new WeakMap(); + } + + function addVisualContrastResult(groupMap, result, options = {}) { + if (result.status !== 'fail' || !result.finding || !result.selector) return false; + let el = null; + try { + el = document.querySelector(result.selector); + } catch { + el = null; + } + if (!el) return false; + const findingType = result.finding.type || result.finding.id || 'low-contrast'; + const existing = groupMap.get(el) || []; + if (existing.some(f => (f.type || f.id) === findingType)) return false; + addBrowserFindings(groupMap, el, [{ + type: findingType, + detail: result.finding.detail || result.finding.snippet, + }]); + if (options.decorate && el !== document.body && el !== document.documentElement) { + highlight(el, groupMap.get(el) || []); + } + return true; + } + + function postSerializedFindings(groupMap) { + if (!EXTENSION_MODE) return; + const allFindings = browserFindingsFromMap(groupMap); + window.postMessage({ + source: 'impeccable-results', + findings: serializeFindings(allFindings), + count: allFindings.length, + }, '*'); + } + + function postExtensionError(err) { + if (!EXTENSION_MODE) return; + window.postMessage({ + source: 'impeccable-error', + message: err?.message || String(err), + }, '*'); + } + + function reportVisualContrastError(err, detail = {}) { + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-error', { + detail: { + ...detail, + message: err?.message || String(err), + }, + })); + if (EXTENSION_MODE) { + postExtensionError(err); + } else { + console.warn('[impeccable] visual contrast scan failed', err); + } + } + + function scheduleLazyVisualContrast(groupMap, analyses, options = {}, runtime = {}) { + disconnectLazyVisualContrastObserver(); + if (options.visualContrastLazy === false || options.scrollOffscreen !== false) return; + if (typeof IntersectionObserver === 'undefined') return; + const unresolved = (analyses || []).filter(result => + result?.status === 'unresolved' && + result.reason === 'text outside viewport' && + result.selector + ); + if (unresolved.length === 0) return; + const generation = runtime.generation || scanGeneration; + + lazyVisualContrastObserver = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const el = entry.target; + const candidate = lazyVisualContrastPending.get(el); + if (!candidate || lazyVisualContrastResolving.has(el)) continue; + lazyVisualContrastObserver?.unobserve(el); + lazyVisualContrastPending.delete(el); + lazyVisualContrastResolving.add(el); + waitForVisualPaint() + .then(() => analyzeVisualContrastCandidate(candidate)) + .then(result => { + if (generation !== scanGeneration) return; + rememberVisualContrastAnalysis(result); + const added = addVisualContrastResult(groupMap, result, { decorate: true }); + if (added) { + postSerializedFindings(groupMap); + window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', { + detail: { + selector: result.selector, + status: result.status, + finding: result.finding || null, + }, + })); + } + }) + .catch(err => { + reportVisualContrastError(err, { selector: candidate.selector }); + }) + .finally(() => { + lazyVisualContrastResolving.delete(el); + }); + } + }, { threshold: 0.5 }); + + for (const candidate of unresolved) { + let el = null; + try { + el = document.querySelector(candidate.selector); + } catch { + el = null; + } + if (!el) continue; + lazyVisualContrastPending.set(el, candidate); + lazyVisualContrastObserver.observe(el); + } + } + + async function addVisualContrastFindings(groupMap, options = {}, runtime = {}) { + if (!shouldRunVisualContrast(options)) { + lastVisualContrastAnalyses = []; + disconnectLazyVisualContrastObserver(); + return []; + } + const resolvedOptions = visualContrastOptions(options); + const analyses = await analyzeVisualContrast(resolvedOptions); + if (runtime.generation && runtime.generation !== scanGeneration) return analyses; + lastVisualContrastAnalyses = analyses; + for (const result of analyses) { + addVisualContrastResult(groupMap, result, { decorate: runtime.decorate }); + } + if (runtime.decorate || runtime.scheduleLazy) scheduleLazyVisualContrast(groupMap, analyses, resolvedOptions, runtime); + return analyses; + } + + async function collectBrowserFindingsAsync(options = {}, runtime = {}) { + const collected = collectBrowserFindings(); + await addVisualContrastFindings(collected.groupMap, options, runtime); + return { + ...collected, + allFindings: browserFindingsFromMap(collected.groupMap), + visualContrastAnalyses: lastVisualContrastAnalyses, + }; + } + + function clearOverlays() { + scanGeneration += 1; + disconnectLazyVisualContrastObserver(); + for (const o of [...overlays]) detachOverlay(o); + overlays.length = 0; + visibilityObserver.disconnect(); + overlayIndex = 0; + } + + function renderBrowserFindings(collected) { + const { allFindings, pageLevelFindings } = collected; + + for (const { el, findings } of allFindings) { + if (el === document.body || el === document.documentElement) continue; + highlight(el, findings); } if (pageLevelFindings.length > 0) { @@ -3049,6 +3895,52 @@ if (IS_BROWSER) { setTimeout(() => { firstScanDone = true; }, 1000); return allFindings; + } + + let firstScanDone = false; + const scan = function(options = {}) { + clearOverlays(); + const generation = scanGeneration; + const collected = collectBrowserFindings(); + const allFindings = renderBrowserFindings(collected); + if (shouldRunVisualContrast(options)) { + addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation }) + .then(() => { + if (generation === scanGeneration) postSerializedFindings(collected.groupMap); + }) + .catch(err => { + reportVisualContrastError(err); + }); + } + return allFindings; + }; + + const scanAsync = async function(options = {}) { + clearOverlays(); + const generation = scanGeneration; + if (shouldRunVisualContrast(options)) { + const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true }); + if (generation !== scanGeneration) return []; + return renderBrowserFindings(collected); + } + lastVisualContrastAnalyses = []; + return renderBrowserFindings(collectBrowserFindings()); + }; + + const detect = function(options = {}) { + lastVisualContrastAnalyses = []; + const { allFindings } = collectBrowserFindings(); + return options.serialize === false ? allFindings : serializeFindings(allFindings); + }; + + const detectAsync = async function(options = {}) { + if (shouldRunVisualContrast(options)) { + const { allFindings } = await collectBrowserFindingsAsync(options); + return options.serialize === false ? allFindings : serializeFindings(allFindings); + } + lastVisualContrastAnalyses = []; + const { allFindings } = collectBrowserFindings(); + return options.serialize === false ? allFindings : serializeFindings(allFindings); }; if (EXTENSION_MODE) { @@ -3057,7 +3949,11 @@ if (IS_BROWSER) { if (e.source !== window || !e.data || e.data.source !== 'impeccable-command') return; if (e.data.action === 'scan') { if (e.data.config) window.__IMPECCABLE_CONFIG__ = e.data.config; - scan(); + try { + scan(e.data.config || {}); + } catch (err) { + postExtensionError(err); + } } if (e.data.action === 'toggle-overlays') { const visible = !document.body.classList.contains('impeccable-hidden'); @@ -3065,15 +3961,12 @@ if (IS_BROWSER) { window.postMessage({ source: 'impeccable-overlays-toggled', visible: !visible }, '*'); } if (e.data.action === 'remove') { - for (const o of overlays) o.remove(); - overlays.length = 0; - visibilityObserver.disconnect(); + clearOverlays(); styleEl.remove(); if (spotlightBackdrop) { spotlightBackdrop.remove(); spotlightBackdrop = null; } document.body.classList.remove('impeccable-hidden'); } if (e.data.action === 'highlight') { - if (spotlightTimer) { clearTimeout(spotlightTimer); spotlightTimer = null; } try { const target = e.data.selector ? document.querySelector(e.data.selector) : null; if (target) { @@ -3109,18 +4002,29 @@ if (IS_BROWSER) { }); window.postMessage({ source: 'impeccable-ready' }, '*'); } else { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => setTimeout(scan, 100)); - } else { - setTimeout(scan, 100); + if (window.__IMPECCABLE_CONFIG__?.autoScan !== false) { + const runAutoScan = () => { + try { + scan(); + } catch (err) { + console.warn('[impeccable] scan failed', err); + } + }; + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => setTimeout(runAutoScan, 100)); + } else { + setTimeout(runAutoScan, 100); + } } } + window.impeccableDetect = detect; + window.impeccableDetectAsync = detectAsync; window.impeccableScan = scan; + window.impeccableScanAsync = scanAsync; + window.impeccableCollectVisualContrastCandidates = collectVisualContrastCandidates; + window.impeccableAnalyzeVisualContrast = analyzeVisualContrast; + window.impeccableGetLastVisualContrastAnalyses = () => lastVisualContrastAnalyses.slice(); } -// ─── Section 8: Node Engine ───────────────────────────────────────────────── - -// ─── Section 9: Exports ───────────────────────────────────────────────────── - })(); diff --git a/cli/engine/detect-antipatterns.mjs b/cli/engine/detect-antipatterns.mjs index 55c4ab63..b984e05d 100644 --- a/cli/engine/detect-antipatterns.mjs +++ b/cli/engine/detect-antipatterns.mjs @@ -5,4292 +5,39 @@ * Copyright (c) 2026 Paul Bakaus * SPDX-License-Identifier: Apache-2.0 * - * Universal file — auto-detects environment (browser vs Node) and adapts. - * - * Node usage: - * node detect-antipatterns.mjs [file-or-dir...] # jsdom for HTML, regex for rest - * node detect-antipatterns.mjs https://... # Puppeteer (auto) - * node detect-antipatterns.mjs --fast [files...] # regex-only (skip jsdom) - * node detect-antipatterns.mjs --json # JSON output - * - * Browser usage: - * - * Re-scan: window.impeccableScan() - * - * Exit codes: 0 = clean, 2 = findings - */ - -// ─── Environment ──────────────────────────────────────────────────────────── - -const IS_BROWSER = typeof window !== 'undefined'; -const IS_NODE = !IS_BROWSER; - -// @browser-strip-start -let fs, path, fileURLToPath; -if (!IS_BROWSER) { - fs = (await import('node:fs')).default; - path = (await import('node:path')).default; - fileURLToPath = (await import('node:url')).fileURLToPath; -} -// @browser-strip-end - -// ─── Section 1: Constants ─────────────────────────────────────────────────── - -const SAFE_TAGS = new Set([ - 'blockquote', 'nav', 'a', 'input', 'textarea', 'select', - 'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label', - 'button', 'hr', 'html', 'head', 'body', 'script', 'style', - 'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle', - 'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', -]); - -// Per-check safe-tags override for the border (side-tab / border-accent) -// rule. We intentionally re-allow