Detector architecture v2: static engine, benchmarks, lab, and visual contrast (#156)

* Add detector benchmark lab and visual contrast fallback

* Expand visual contrast fixture coverage

* Add browser visual contrast fallback

* Show visual contrast overlays in detector lab

* Fix detector lab short viewport layout

* Fix detector lab visual overlays

* Add visual contrast to browser scan overlays

* Avoid browser scroll jumps during visual contrast scans

* Resolve visual contrast lazily on scroll

* Refresh detector lab visual counts lazily

* Update pnpm lockfile for static parser deps

* Address Bugbot detector API comments

* Report extension visual contrast errors

* Refactor detector into engine modules

* Address Bugbot detector comments

* Fix latest Bugbot detector notes

* Fix visual contrast fixture labels

* Refine detector lab fixtures

* Fix stale detector overlay references

* Fix detector lab fixture URLs

* Fix typography lab fixture highlights

* Fix typography lab page-level signal

* Fix visual overlay lifecycle cleanup

* Remove dead spotlight timer cleanup

* Make browser async APIs reject consistently
This commit is contained in:
Paul Bakaus
2026-05-17 19:49:38 -07:00
committed by GitHub
parent 4af581e23f
commit e1d3ea0b6f
46 changed files with 11480 additions and 4915 deletions
+11 -76
View File
@@ -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=="],
File diff suppressed because it is too large Load Diff
+232
View File
@@ -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, '<stdin>');
}
// ---------------------------------------------------------------------------
// 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 };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+251
View File
@@ -0,0 +1,251 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { finding } from '../../findings.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
if (options?.visualContrast === false) return [];
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
? options.visualContrastMaxCandidates
: 12;
const scrollOffscreen = options?.visualContrastScrollOffscreen !== false;
const existingLowContrastSelectors = new Set(
serializedGroups
.filter(group => group.findings?.some(f => f.type === 'low-contrast'))
.map(group => group.selector)
.filter(Boolean)
);
let browserAnalyses = [];
const findings = [];
if (options?.visualContrastBrowser !== false) {
const browserFindings = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'browser-fallback',
target,
}, async () => {
browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => {
if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return [];
return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen });
}, { maxCandidates, scrollOffscreen });
return browserAnalyses
.filter(result => result.finding && !existingLowContrastSelectors.has(result.selector))
.map(result => result.finding);
});
findings.push(...browserFindings);
}
let candidates = browserAnalyses.length > 0 ? browserAnalyses : [];
if (candidates.length === 0) {
candidates = await profileStepAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'collect-candidates',
target,
}, () => page.evaluate(({ maxCandidates }) => {
if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return [];
return window.impeccableCollectVisualContrastCandidates({ maxCandidates });
}, { maxCandidates }));
}
const viewport = options?.viewport || { width: 1280, height: 800 };
const browserResolvedSelectors = new Set(
browserAnalyses
.filter(result => result.status === 'fail' || result.status === 'pass')
.map(result => result.selector)
.filter(Boolean)
);
const filtered = candidates.filter(candidate =>
!existingLowContrastSelectors.has(candidate.selector) &&
!browserResolvedSelectors.has(candidate.selector)
);
if (options?.visualContrastPixel === false) return findings;
for (const candidate of filtered) {
const result = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'pixel-diff',
target,
}, async () => {
const finding = await captureVisualContrastCandidate(page, candidate, viewport);
return finding ? [finding] : [];
});
findings.push(...result);
}
return findings;
}
// ---------------------------------------------------------------------------
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
const viewport = options?.viewport || { width: 1280, height: 800 };
const externalBrowser = options?.browser || null;
let puppeteer;
if (!externalBrowser) {
try {
puppeteer = await profileStepAsync(profile, {
engine: 'browser',
phase: 'setup',
ruleId: 'import-puppeteer',
target: url,
}, () => import('puppeteer'));
} catch {
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
}
}
// Read the browser detection script — reuse it instead of reimplementing
const browserScriptPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'detect-antipatterns-browser.js'
);
let browserScript;
try {
browserScript = profileStep(profile, {
engine: 'browser',
phase: 'setup',
ruleId: 'read-browser-script',
target: url,
}, () => fs.readFileSync(browserScriptPath, 'utf-8'));
} catch {
throw new Error(`Browser script not found at ${browserScriptPath}`);
}
// CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
// Chrome can't initialize its sandbox there. Disable the sandbox only when
// running in CI; local users keep the default hardened launch.
const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
const browser = externalBrowser || await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'launch-browser',
target: url,
}, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
const page = await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'new-page',
target: url,
}, () => browser.newPage());
let results = [];
try {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: `goto:${waitUntil}`,
target: url,
}, () => page.goto(url, { waitUntil, timeout: 30000 }));
if (settleMs > 0) {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'settle',
target: url,
}, () => new Promise(resolve => setTimeout(resolve, settleMs)));
}
// Inject the browser detection script and collect results
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
};
}));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'inject-browser-script',
target: url,
}, () => page.evaluate(browserScript));
let serializedGroups = [];
results = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'browser-scan',
target: url,
}, async () => {
serializedGroups = await page.evaluate(() => {
if (!window.impeccableDetect) return [];
return window.impeccableDetect({ decorate: false, serialize: true });
});
return serializedGroups.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail }))
);
});
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
results.push(...visualFindings);
} finally {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-page',
target: url,
}, () => page.close().catch(() => {}));
if (!externalBrowser) {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-browser',
target: url,
}, () => browser.close());
}
}
return results.map(f => finding(f.id, url, f.snippet));
}
async function createBrowserDetector(options = {}) {
let puppeteer;
try {
puppeteer = await import('puppeteer');
} catch {
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
}
const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
const browser = options.browser || await puppeteer.default.launch({
headless: options.headless ?? true,
args: launchArgs,
});
const ownsBrowser = !options.browser;
const defaults = {
waitUntil: options.waitUntil || 'load',
settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100,
viewport: options.viewport || { width: 1280, height: 800 },
};
return {
browser,
async detectUrl(url, scanOptions = {}) {
return detectUrl(url, {
...defaults,
...scanOptions,
browser,
});
},
async close() {
if (ownsBrowser) await browser.close().catch(() => {});
},
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector };
+420
View File
@@ -0,0 +1,420 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
// ---------------------------------------------------------------------------
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
// ---------------------------------------------------------------------------
const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
const hasBorderRadius = (line) => /border-radius/i.test(line);
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
test: (m) => +m[1] >= 3,
fmt: (m) => m[0] },
// --- Border accent on rounded ---
{ id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
test: (m, line) => hasRounded(line) && +m[1] >= 1,
fmt: (m) => m[0] },
{ id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
fmt: (m) => m[0] },
// --- Overused font ---
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Pure black background ---
{ id: 'pure-black-white', regex: /background(?:-color)?\s*:\s*(#000000|#000|rgb\(0,\s*0,\s*0\))\b/gi,
test: () => true,
fmt: (m) => m[0] },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
fmt: () => 'background-clip: text + gradient' },
// --- Gradient text (Tailwind) ---
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
test: (m, line) => /\bbg-gradient-to-/i.test(line),
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind pure black background ---
{ id: 'pure-black-white', regex: /\bbg-black\b/g,
test: () => true,
fmt: (m) => m[0] },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
fmt: (m) => `${m[0]} on heading` },
{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
fmt: (m) => `${m[0]} gradient` },
// --- Bounce/elastic easing ---
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
},
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
// --- Layout property transition ---
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
} },
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
} },
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
const REM = 16;
let m;
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
while ((m = sizeRe.exec(content)) !== null) {
const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
}
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
while ((m = clampRe.exec(content)) !== null) {
sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
}
const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
if (sizes.size < 3) return [];
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio >= 2.0) return [];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
},
// Monotonous spacing (regex)
(content, filePath) => {
const vals = [];
let m;
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
const rounded = vals.map(v => Math.round(v / 4) * 4);
if (rounded.length < 10) return [];
const counts = {};
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
const maxCount = Math.max(...Object.values(counts));
const pct = maxCount / rounded.length;
const unique = [...new Set(rounded)].filter(v => v > 0);
if (pct <= 0.6 || unique.length > 3) return [];
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
},
// Everything centered (regex)
(content, filePath) => {
const lines = content.split('\n');
let centered = 0, total = 0;
for (const line of lines) {
if (/<(?:h[1-6]|p|div|li|button)\b[^>]*>/i.test(line) && line.trim().length > 20) {
total++;
if (/text-align\s*:\s*center/i.test(line) || /\btext-center\b/.test(line)) centered++;
}
}
if (total < 5 || centered / total <= 0.7) return [];
return [finding('everything-centered', filePath, `${centered}/${total} text elements centered (${Math.round(centered / total * 100)}%)`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
// ---------------------------------------------------------------------------
// Style block extraction (Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (ext !== '.vue' && ext !== '.svelte') return [];
const blocks = [];
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
const startLine = before.split('\n').length + 1;
blocks.push({ content: m[1], startLine });
}
return blocks;
}
// ---------------------------------------------------------------------------
// CSS-in-JS extraction (styled-components, emotion)
// ---------------------------------------------------------------------------
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
function extractCSSinJS(content, ext) {
ext = ext.toLowerCase();
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
const blocks = [];
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
const startLine = before.split('\n').length;
blocks.push({ content: m[1], startLine });
}
return blocks;
}
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
const { profile, phase = 'regex-matchers' } = options || {};
const findings = [];
if (!profile) {
for (const matcher of REGEX_MATCHERS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
const context = blockContext
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
: line;
if (matcher.test(m, context)) {
findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
}
}
}
}
return findings;
}
for (const matcher of REGEX_MATCHERS) {
const matcherFindings = profileFindings(profile, {
engine: 'regex',
phase,
ruleId: matcher.id,
target: filePath,
}, () => {
const matches = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
const context = blockContext
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
: line;
if (matcher.test(m, context)) {
matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
}
}
}
return matches;
});
findings.push(...matcherFindings);
}
return findings;
}
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
const cssLike = new Set(['.css', '.scss', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
// Extract and scan <style> blocks from Vue/Svelte SFCs
const styleBlocks = profile
? profileStep(profile, {
engine: 'regex',
phase: 'extract',
ruleId: 'style-blocks',
target: filePath,
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
}));
}
// Extract and scan CSS-in-JS template literals
const cssJsBlocks = profile
? profileStep(profile, {
engine: 'regex',
phase: 'extract',
ruleId: 'css-in-js',
target: filePath,
}, () => extractCSSinJS(content, ext))
: extractCSSinJS(content, ext);
for (const block of cssJsBlocks) {
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'css-in-js',
}));
}
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
const deduped = [];
for (const f of findings) {
const isDupe = deduped.some(d =>
d.antipattern === f.antipattern &&
d.snippet === f.snippet &&
Math.abs(d.line - f.line) <= 2
);
if (!isDupe) deduped.push(f);
}
// Page-level analyzers only run on full pages
if (isFullPage(content)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'everything-centered',
'dark-glow',
];
for (let i = 0; i < REGEX_ANALYZERS.length; i++) {
const analyzer = REGEX_ANALYZERS[i];
deduped.push(...profileFindings(profile, {
engine: 'regex',
phase: 'page-analyzer',
ruleId: analyzerIds[i] || `analyzer-${i + 1}`,
target: filePath,
}, () => analyzer(content, filePath)));
}
}
return deduped;
}
export {
REGEX_MATCHERS,
REGEX_ANALYZERS,
extractStyleBlocks,
extractCSSinJS,
runRegexMatchers,
detectText,
};
@@ -0,0 +1,954 @@
import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
// ---------------------------------------------------------------------------
//
// jsdom's CSSOM silently drops any border shorthand that contains a var()
// reference — the computed style for the element then shows empty width,
// empty style, and a default black color. That's enough to hide the most
// common real-world side-tab pattern in AI-generated pages:
//
// :root { --brand: #87a8ff; }
// .card { border-left: 5px solid var(--brand); border-radius: 4px; }
//
// Real browsers (and therefore the browser detector path) resolve var()
// natively, so this only affects the Node jsdom path.
//
// This pre-pass walks the stylesheets, finds any rule whose per-side or
// all-sides border property contains var(), resolves the var() against
// :root-level custom properties (read from the documentElement's computed
// style, which jsdom DOES handle correctly), and attaches the resolved
// width+color to every element that matches the rule's selector. The
// Node-side `checkElementBorders` adapter consumes that map as a fallback
// whenever jsdom's computed style came back empty.
//
// Limitations (intentional, to keep the pass simple):
// * Only :root-level custom properties are resolved. Scoped overrides on
// descendants are not tracked — uncommon in practice and would require
// a per-element cascade walk.
// * @media / @supports wrapped rules are ignored (jsdom often mishandles
// these anyway).
// * The fallback only fills sides that jsdom left empty, so any rule
// whose border parses normally still wins via the computed style.
const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
// CSS variables typically hold hex or named colors, so normalize those to
// rgb() before handing the value off to the shared check. Anything we don't
// recognise is passed through unchanged — isNeutralColor then treats it as
// non-neutral, which is the safer default (matches the oklch-era bugfix).
const NAMED_COLORS = {
white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
};
function normalizeColorForCheck(value) {
if (!value) return value;
const v = value.trim();
const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (hex6) {
const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
return `rgb(${r}, ${g}, ${b})`;
}
const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (hex3) {
const [r, g, b] = [
parseInt(hex3[1] + hex3[1], 16),
parseInt(hex3[2] + hex3[2], 16),
parseInt(hex3[3] + hex3[3], 16),
];
return `rgb(${r}, ${g}, ${b})`;
}
const named = NAMED_COLORS[v.toLowerCase()];
if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
return v;
}
function buildBorderOverrideMap(document, window) {
const map = new Map();
const rootStyle = window.getComputedStyle(document.documentElement);
function resolveVar(value, depth = 0) {
if (!value || depth > 10 || !value.includes('var(')) return value;
return value.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
(_, name, fallback) => {
const v = rootStyle.getPropertyValue(name).trim();
if (v) return resolveVar(v, depth + 1);
if (fallback) return resolveVar(fallback.trim(), depth + 1);
return '';
}
);
}
function parseShorthand(text) {
const m = text.trim().match(BORDER_SHORTHAND_RE);
if (!m) return null;
return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
}
// Read from the per-property accessors on rule.style. jsdom preserves
// each border-* shorthand it parsed, even when the overall cssText has
// been truncated (e.g. a `border: 1px solid var(...)` followed by a
// `border-left: ...` loses the first declaration but keeps the second).
const SIDE_PROPS = [
['borderLeft', 'Left'],
['borderRight', 'Right'],
['borderTop', 'Top'],
['borderBottom', 'Bottom'],
['borderInlineStart', 'Left'],
['borderInlineEnd', 'Right'],
];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules || []; } catch { continue; }
for (const rule of rules) {
// CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
const perSide = {};
for (const [prop, side] of SIDE_PROPS) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const parsed = parseShorthand(resolveVar(val));
if (parsed && parsed.color) perSide[side] = parsed;
}
// Uniform `border: <w> <style> var(...)` applies to every side the
// per-side map didn't already claim.
const borderAll = rule.style.border;
if (borderAll && borderAll.includes('var(')) {
const parsed = parseShorthand(resolveVar(borderAll));
if (parsed && parsed.color) {
for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
if (!perSide[s]) perSide[s] = parsed;
}
}
}
// Longhand `border-*-color: var(...)` with width/style in separate
// declarations. Rare in AI-generated pages, but cheap to cover.
for (const [prop, side] of [
['borderLeftColor', 'Left'],
['borderRightColor', 'Right'],
['borderTopColor', 'Top'],
['borderBottomColor', 'Bottom'],
]) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const resolved = resolveVar(val).trim();
if (!resolved) continue;
// Width may or may not come from this rule — that's fine; the
// adapter only substitutes the color when jsdom left it as a
// literal var() string.
if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
}
if (Object.keys(perSide).length === 0) continue;
let matched;
try { matched = document.querySelectorAll(rule.selectorText); }
catch { continue; }
for (const el of matched) {
const existing = map.get(el);
if (existing) {
// Later rules overwrite earlier ones — approximates source-order
// cascade for equal-specificity rules and is good enough for the
// uncontested var()-dropped sides we're trying to recover.
Object.assign(existing, perSide);
} else {
map.set(el, { ...perSide });
}
}
}
}
return map;
}
// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
if (!source || !source.includes('@layer')) return source;
// Find `@layer <name>? {` openers. The match starts at the @, and
// we then balance braces from the opening { onward.
const re = /@layer\b[^{;]*\{/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(source)) !== null) {
const openStart = m.index;
const openEnd = m.index + m[0].length; // position right after `{`
let depth = 1;
let i = openEnd;
while (i < source.length && depth > 0) {
const c = source.charCodeAt(i);
if (c === 0x7b /* { */) depth++;
else if (c === 0x7d /* } */) depth--;
i++;
}
if (depth !== 0) {
// Unbalanced — bail and return source unchanged.
return source;
}
// Emit everything before the @layer, then the inner contents
// (between the opening { and the matched closing }), then advance.
out += source.slice(lastIdx, openStart);
out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
lastIdx = i;
re.lastIndex = i;
}
out += source.slice(lastIdx);
return out;
}
// ---------------------------------------------------------------------------
// Static HTML/CSS detection (default for local HTML files)
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
const STATIC_DEFAULT_STYLE = {
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
backgroundImage: 'none',
borderTopWidth: '0px',
borderRightWidth: '0px',
borderBottomWidth: '0px',
borderLeftWidth: '0px',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
borderRadius: '0px',
boxShadow: 'none',
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
textTransform: 'none',
textAlign: 'start',
hyphens: 'manual',
webkitHyphens: 'manual',
transitionProperty: '',
transitionTimingFunction: '',
animationName: '',
animationTimingFunction: '',
webkitBackgroundClip: '',
backgroundClip: '',
width: '',
height: '',
paddingTop: '0px',
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
position: 'static',
display: '',
};
const STATIC_PROP_MAP = {
'background-color': 'backgroundColor',
'background-image': 'backgroundImage',
'background-clip': 'backgroundClip',
'-webkit-background-clip': 'webkitBackgroundClip',
'border-radius': 'borderRadius',
'border-top-width': 'borderTopWidth',
'border-right-width': 'borderRightWidth',
'border-bottom-width': 'borderBottomWidth',
'border-left-width': 'borderLeftWidth',
'border-top-color': 'borderTopColor',
'border-right-color': 'borderRightColor',
'border-bottom-color': 'borderBottomColor',
'border-left-color': 'borderLeftColor',
'box-shadow': 'boxShadow',
'font-family': 'fontFamily',
'font-size': 'fontSize',
'font-style': 'fontStyle',
'font-weight': 'fontWeight',
'line-height': 'lineHeight',
'letter-spacing': 'letterSpacing',
'text-transform': 'textTransform',
'text-align': 'textAlign',
'hyphens': 'hyphens',
'-webkit-hyphens': 'webkitHyphens',
'transition-property': 'transitionProperty',
'transition-timing-function': 'transitionTimingFunction',
'animation-name': 'animationName',
'animation-timing-function': 'animationTimingFunction',
'width': 'width',
'height': 'height',
'padding-top': 'paddingTop',
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'position': 'position',
'display': 'display',
};
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '(' || ch === '[') depth++;
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(value.slice(start, i).trim());
start = i + 1;
}
}
const tail = value.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
function splitCssTokens(value) {
const tokens = [];
let depth = 0, quote = '', current = '';
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
current += ch;
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
if (ch === '(') { depth++; current += ch; continue; }
if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
if (/\s/.test(ch) && depth === 0) {
if (current) { tokens.push(current); current = ''; }
continue;
}
current += ch;
}
if (current) tokens.push(current);
return tokens;
}
function cssPropToCamel(prop) {
if (!prop) return prop;
const mapped = STATIC_PROP_MAP[prop];
if (mapped) return mapped;
return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
}
function staticColorToCss(c) {
if (!c) return '';
if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
return `rgb(${c.r}, ${c.g}, ${c.b})`;
}
function parseStaticColor(value) {
const parsed = parseAnyColor(value);
if (parsed) return parsed;
const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
return named ? { ...named } : null;
}
function extractStaticColor(value) {
if (!value) return '';
const raw = String(value).trim();
if (/^var\(/i.test(raw)) return raw;
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
if (!colorLike) return '';
return colorLike[0];
}
function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
let resolved = resolveVarRefs(String(value || '').trim(), customProps);
if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
const parsed = parseStaticColor(resolved);
if (parsed) resolved = staticColorToCss(parsed);
}
if (prop === 'fontSize') {
const base = parseFloat(parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'letterSpacing') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'lineHeight' && resolved !== 'normal') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
return resolved;
}
function expandStaticBoxValues(tokens) {
if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
return [tokens[0], tokens[1], tokens[2], tokens[3]];
}
function parseStaticBorder(value) {
const tokens = splitCssTokens(value);
let width = '', color = '';
for (const token of tokens) {
if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
if (!color) color = extractStaticColor(token);
}
return { width, color };
}
function parseStaticFont(value) {
const out = [];
const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
if (weight) out.push(['fontWeight', weight[1]]);
if (slashParts) {
out.push(['fontSize', slashParts[1]]);
if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
const family = value.slice(familyStart).trim();
if (family) out.push(['fontFamily', family]);
}
return out;
}
function parseStaticTransition(value) {
const props = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
if (prop) props.push(prop);
}
return {
property: props.join(', '),
timing: timings.join(', '),
};
}
function parseStaticAnimation(value) {
const names = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const name = tokens.find(token =>
/^[a-z_-][\w-]*$/i.test(token) &&
!/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
);
if (name) names.push(name);
}
return {
name: names.join(', '),
timing: timings.join(', '),
};
}
function expandStaticDeclaration(prop, value) {
const p = prop.toLowerCase();
const v = String(value || '').trim();
if (!v) return [];
if (p.startsWith('--')) return [[p, v]];
if (p === 'background') {
const out = [];
const hasImage = /gradient|url\(/i.test(v);
if (hasImage) out.push(['backgroundImage', v]);
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
return out;
}
if (p === 'border') {
const parsed = parseStaticBorder(v);
const out = [];
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if (parsed.width) out.push([`border${side}Width`, parsed.width]);
if (parsed.color) out.push([`border${side}Color`, parsed.color]);
}
return out;
}
const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
if (sideMatch) {
const parsed = parseStaticBorder(v);
const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
return [
...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
];
}
if (p === 'border-width') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopWidth', vals[0]],
['borderRightWidth', vals[1]],
['borderBottomWidth', vals[2]],
['borderLeftWidth', vals[3]],
];
}
if (p === 'border-color') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopColor', vals[0]],
['borderRightColor', vals[1]],
['borderBottomColor', vals[2]],
['borderLeftColor', vals[3]],
];
}
if (p === 'padding') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['paddingTop', vals[0]],
['paddingRight', vals[1]],
['paddingBottom', vals[2]],
['paddingLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
return [
...(parsed.property ? [['transitionProperty', parsed.property]] : []),
...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
];
}
if (p === 'animation') {
const parsed = parseStaticAnimation(v);
return [
...(parsed.name ? [['animationName', parsed.name]] : []),
...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
];
}
const mapped = cssPropToCamel(p);
if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
return [[mapped, v]];
}
return [];
}
function compareStaticPriority(a, b) {
if (!a) return true;
if (!!b.important !== !!a.important) return !!b.important;
if (!!b.inline !== !!a.inline) return !!b.inline;
for (let i = 0; i < 3; i++) {
if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
return (b.specificity[i] || 0) > (a.specificity[i] || 0);
}
}
return b.order >= a.order;
}
function staticSpecificity(selector) {
const noWhere = selector.replace(/:where\([^)]*\)/g, '');
const ids = (noWhere.match(/#[\w-]+/g) || []).length;
const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
const stripped = noWhere
.replace(/#[\w-]+/g, ' ')
.replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
.replace(/[*>+~(),]/g, ' ');
const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
return [ids, classes, types];
}
function applyStaticDeclaration(specified, node, prop, value, meta) {
let map = specified.get(node);
if (!map) { map = new Map(); specified.set(node, map); }
for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
const existing = map.get(expandedProp);
const next = { ...meta, prop: expandedProp, value: expandedValue };
if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
}
}
function parseStaticStyleAttribute(styleText, orderBase = 0) {
const decls = [];
for (const part of String(styleText || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim();
let value = part.slice(idx + 1).trim();
const important = /!important\s*$/i.test(value);
value = value.replace(/\s*!important\s*$/i, '').trim();
decls.push({ prop, value, important, order: orderBase + decls.length });
}
return decls;
}
function collectStaticCssRules(cssText, csstree) {
const rules = [];
let ast;
try {
ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
} catch {
return rules;
}
let order = 0;
const walkList = (list, atRuleStack = []) => {
list?.forEach?.(node => {
if (node.type === 'Rule' && node.block) {
if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
const selectorText = csstree.generate(node.prelude).trim();
const declarations = [];
node.block.children?.forEach?.(child => {
if (child.type !== 'Declaration') return;
declarations.push({
prop: child.property,
value: csstree.generate(child.value).trim(),
important: !!child.important,
});
});
for (const selector of splitCssList(selectorText)) {
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
}
return;
}
if (node.type === 'Atrule' && node.block) {
const name = String(node.name || '').toLowerCase();
if (name === 'media' || name === 'supports' || name === 'layer') {
walkList(node.block.children, [...atRuleStack, name]);
}
}
});
};
walkList(ast.children);
return rules;
}
class StaticElement {
constructor(node, doc) {
this.node = node;
this._doc = doc;
this.nodeType = 1;
this.tagName = String(node.name || '').toUpperCase();
this.nodeName = this.tagName;
}
get parentElement() {
let cur = this.node.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
return cur ? this._doc.wrap(cur) : null;
}
get previousElementSibling() {
let cur = this.node.prev;
while (cur && cur.type !== 'tag') cur = cur.prev;
return cur ? this._doc.wrap(cur) : null;
}
get children() {
return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
}
get childNodes() {
return (this.node.children || []).map(child => {
if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
if (child.type === 'tag') return this._doc.wrap(child);
return { nodeType: 8, textContent: child.data || '' };
});
}
get textContent() {
return this._doc.domutils.textContent(this.node);
}
get className() {
return this.getAttribute('class') || '';
}
get id() {
return this.getAttribute('id') || '';
}
getAttribute(name) {
return this.node.attribs?.[name] ?? null;
}
querySelector(selector) {
try {
const found = this._doc.selectOne(selector, this.node.children || []);
return found ? this._doc.wrap(found) : null;
} catch {
return null;
}
}
querySelectorAll(selector) {
try {
return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
} catch {
return [];
}
}
closest(selector) {
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
} catch {
return null;
}
cur = cur.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
}
return null;
}
contains(other) {
let cur = other?.node || null;
while (cur) {
if (cur === this.node) return true;
cur = cur.parent;
}
return false;
}
}
class StaticDocument {
constructor(root, modules) {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
if (!wrapped) {
wrapped = new StaticElement(node, this);
this._wrappers.set(node, wrapped);
}
return wrapped;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
} catch {
return [];
}
}
querySelector(selector) {
try {
const found = this.selectOne(selector, this.root.children || []);
return found ? this.wrap(found) : null;
} catch {
return null;
}
}
get documentElement() {
return this.querySelector('html');
}
get body() {
return this.querySelector('body');
}
setStyle(node, style) {
this._styleMap.set(node, style);
}
getStyle(el) {
return this._styleMap.get(el.node) || makeStaticStyle();
}
}
function makeStaticStyle(values = {}) {
const style = { ...STATIC_DEFAULT_STYLE, ...values };
style.getPropertyValue = (prop) => {
const key = cssPropToCamel(prop);
return style[key] || style[prop] || '';
};
return style;
}
function buildStaticWindow(staticDoc) {
return {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
};
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
const links = modules.selectAll('link', root.children || []);
for (const link of links) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
const cssPath = path.resolve(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
phase: 'preprocess',
ruleId: 'inline-linked-stylesheet',
target: filePath,
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
}
return styleTexts.join('\n');
}
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
const specified = new Map();
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
phase: 'parse-css',
ruleId: 'css-rules',
target: filePath,
}, () => collectStaticCssRules(cssText, modules.csstree));
profileStep(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'css-selectors',
target: filePath,
}, () => {
for (const rule of rules) {
let matched;
try {
matched = modules.selectAll(rule.selector, root.children || []);
} catch {
recordProfileEvent(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'unsupported-selector',
target: filePath,
ms: 0,
findings: 0,
detail: rule.selector,
});
continue;
}
for (const node of matched) {
for (const decl of rule.declarations) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
inline: false,
});
}
}
}
let inlineOrder = rules.length + 1;
for (const node of allNodes) {
const styleText = node.attribs?.style;
if (!styleText) continue;
for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: [1, 0, 0],
order: decl.order,
inline: true,
});
}
inlineOrder += 1000;
}
});
const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
const specifiedMap = specified.get(node) || new Map();
const customProps = new Map(parentCustom);
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
}
const values = {};
for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
else values[prop] = STATIC_DEFAULT_STYLE[prop];
}
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) continue;
values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
}
const style = makeStaticStyle(values);
staticDoc.setStyle(node, style);
for (const child of node.children || []) {
if (child.type === 'tag') computeNode(child, style, customProps);
}
};
profileStep(profile, {
engine: 'static-html',
phase: 'cascade',
ruleId: 'compute-styles',
target: filePath,
}, () => {
for (const child of root.children || []) {
if (child.type === 'tag') computeNode(child);
}
});
}
export {
BORDER_SHORTHAND_RE,
NAMED_COLORS,
normalizeColorForCheck,
buildBorderOverrideMap,
unwrapCssAtLayer,
STATIC_INHERITED_PROPS,
STATIC_DEFAULT_STYLE,
STATIC_PROP_MAP,
STATIC_NAMED_COLORS,
splitCssList,
splitCssTokens,
cssPropToCamel,
staticColorToCss,
parseStaticColor,
extractStaticColor,
normalizeStaticCssValue,
expandStaticBoxValues,
parseStaticBorder,
parseStaticFont,
parseStaticTransition,
parseStaticAnimation,
expandStaticDeclaration,
compareStaticPriority,
staticSpecificity,
applyStaticDeclaration,
parseStaticStyleAttribute,
collectStaticCssRules,
StaticElement,
StaticDocument,
makeStaticStyle,
buildStaticWindow,
collectStaticCssText,
buildStaticStyleMap,
};
@@ -0,0 +1,174 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
checkElementColors,
checkElementGlow,
checkElementHeroEyebrow,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementQuality,
checkHtmlPatterns,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { detectText } from '../regex/detect-text.mjs';
import {
StaticDocument,
buildStaticStyleMap,
buildStaticWindow,
collectStaticCssText,
} from './css-cascade.mjs';
function checkStaticPageTypography(document, window) {
const findings = [];
const fonts = new Set();
const overusedFound = new Set();
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
if (!hasText) continue;
const ff = window.getComputedStyle(el).fontFamily || '';
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
if (!primary) continue;
fonts.add(primary);
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
}
for (const font of overusedFound) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
}
const sizes = new Set();
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio < 2.0) {
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
}
}
return findings;
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) },
{ id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) },
{ id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
{ id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) },
{ id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) },
{ id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
];
async function detectHtml(filePath, options = {}) {
const profile = options?.profile;
const html = profileStep(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'read-html',
target: filePath,
}, () => fs.readFileSync(filePath, 'utf-8'));
let modules;
try {
modules = await profileStepAsync(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
is: cssSelect.is,
csstree,
domutils,
};
});
} catch {
return detectText(html, filePath, options);
}
const resolvedPath = path.resolve(filePath);
const fileDir = path.dirname(resolvedPath);
const root = profileStep(profile, {
engine: 'static-html',
phase: 'parse-html',
ruleId: 'parse-document',
target: filePath,
}, () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true }));
const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules);
const document = new StaticDocument(root, modules);
buildStaticStyleMap(root, document, cssText, modules, profile, filePath);
const window = buildStaticWindow(document);
const customPropMap = null;
const findings = [];
const runElementCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback)
: callback();
const visitedByRule = new Map();
for (const rule of STATIC_ELEMENT_RULES) {
const elements = document.querySelectorAll(rule.selector);
visitedByRule.set(rule.id, elements.length);
for (const el of elements) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
}
if (isFullPage(html)) {
const runPageCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
: callback();
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
return findings;
}
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
@@ -0,0 +1,189 @@
function sanitizeScreenshotClip(clip, viewport) {
if (!clip) return null;
const x = Math.max(0, Math.floor(clip.x || 0));
const y = Math.max(0, Math.floor(clip.y || 0));
const width = Math.min(
Math.max(1, Math.ceil(clip.width || 0)),
Math.max(1, viewport?.width || 1600),
);
const height = Math.min(
Math.max(1, Math.ceil(clip.height || 0)),
320,
);
if (width < 1 || height < 1) return null;
return { x, y, width, height };
}
async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
const loadImage = (base64) => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
img.src = `data:image/png;base64,${base64}`;
});
const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
const width = Math.min(before.width, after.width);
const height = Math.min(before.height, after.height);
if (width < 1 || height < 1) return null;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(before, 0, 0, width, height);
const beforePixels = ctx.getImageData(0, 0, width, height).data;
ctx.clearRect(0, 0, width, height);
ctx.drawImage(after, 0, 0, width, height);
const afterPixels = ctx.getImageData(0, 0, width, height).data;
const luminance = ({ r, g, b }) => {
const convert = c => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
};
const ratio = (a, b) => {
const l1 = luminance(a);
const l2 = luminance(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
};
const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
? {
r: candidate.textColor.r,
g: candidate.textColor.g,
b: candidate.textColor.b,
}
: null;
const ratios = [];
let glyphPixels = 0;
let strongestDelta = 0;
for (let i = 0; i < beforePixels.length; i += 4) {
const delta = Math.abs(beforePixels[i] - afterPixels[i])
+ Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
+ Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
+ Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
strongestDelta = Math.max(strongestDelta, delta);
if (delta < 10) continue;
glyphPixels++;
const fg = cssTextColor || {
r: beforePixels[i],
g: beforePixels[i + 1],
b: beforePixels[i + 2],
};
const bg = {
r: afterPixels[i],
g: afterPixels[i + 1],
b: afterPixels[i + 2],
};
ratios.push(ratio(fg, bg));
}
if (ratios.length < 8) {
return {
glyphPixels,
strongestDelta,
worstRatio: null,
p10Ratio: null,
medianRatio: null,
};
}
ratios.sort((a, b) => a - b);
const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
return {
glyphPixels,
strongestDelta,
worstRatio: ratios[0],
p10Ratio: pick(10),
medianRatio: pick(50),
};
}, { beforeBase64, afterBase64, candidate });
}
async function captureVisualContrastCandidate(page, candidate, viewport) {
const clip = sanitizeScreenshotClip(candidate.clip, viewport);
if (!clip) return null;
const beforeBase64 = await page.screenshot({
encoding: 'base64',
clip,
captureBeyondViewport: true,
});
const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
let el;
try {
el = document.querySelector(selector);
} catch {
return false;
}
if (!el) return false;
let style = document.getElementById('impeccable-visual-contrast-hide-style');
if (!style) {
style = document.createElement('style');
style.id = 'impeccable-visual-contrast-hide-style';
style.textContent = [
'[data-impeccable-visual-contrast-target] {',
' color: transparent !important;',
' -webkit-text-fill-color: transparent !important;',
' text-shadow: none !important;',
'}',
'[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
' background-image: none !important;',
'}',
].join('\n');
document.head.appendChild(style);
}
el.setAttribute('data-impeccable-visual-contrast-target', token);
if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
return true;
}, {
selector: candidate.selector,
token,
backgroundClipText: candidate.backgroundClipText,
});
if (!applied) return null;
let afterBase64;
try {
afterBase64 = await page.screenshot({
encoding: 'base64',
clip,
captureBeyondViewport: true,
});
} finally {
await page.evaluate(({ selector }) => {
try {
const el = document.querySelector(selector);
if (el) {
el.removeAttribute('data-impeccable-visual-contrast-target');
el.removeAttribute('data-impeccable-bgclip-text');
}
} catch {
// Ignore invalid or stale selectors during cleanup.
}
}, { selector: candidate.selector }).catch(() => {});
}
const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
const measuredRatio = metrics.p10Ratio;
if (measuredRatio >= candidate.threshold) return null;
const textLabel = candidate.text ? ` "${candidate.text}"` : '';
const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
return {
id: 'low-contrast',
snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
};
}
export {
sanitizeScreenshotClip,
compareScreenshotContrast,
captureVisualContrastCandidate,
};
+12
View File
@@ -0,0 +1,12 @@
import { getAntipattern } from './registry/antipatterns.mjs';
function getAP(id) {
return getAntipattern(id);
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet };
}
export { getAP, finding };
+198
View File
@@ -0,0 +1,198 @@
import fs from 'node:fs';
import path from 'node:path';
// ---------------------------------------------------------------------------
// File walker
// ---------------------------------------------------------------------------
const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
'.svelte-kit', '__pycache__', '.turbo', '.vercel',
]);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function walkDir(dir) {
const files = [];
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
}
return files;
}
// ---------------------------------------------------------------------------
// Import graph (multi-file awareness)
// ---------------------------------------------------------------------------
function resolveImport(specifier, fromDir, fileSet) {
if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
const base = path.resolve(fromDir, specifier);
if (fileSet.has(base)) return base;
for (const ext of SCANNABLE_EXTENSIONS) {
const withExt = base + ext;
if (fileSet.has(withExt)) return withExt;
}
// index file convention
for (const ext of SCANNABLE_EXTENSIONS) {
const indexFile = path.join(base, 'index' + ext);
if (fileSet.has(indexFile)) return indexFile;
}
return null;
}
function buildImportGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
const content = fs.readFileSync(file, 'utf-8');
const dir = path.dirname(file);
const imports = new Set();
// ES imports: import ... from '...' and import '...'
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
let m;
while ((m = esRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// CSS @import
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
while ((m = cssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// SCSS @use / @forward
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
while ((m = scssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
graph.set(file, imports);
}
return graph;
}
// ---------------------------------------------------------------------------
// Framework dev server detection
// ---------------------------------------------------------------------------
const FRAMEWORK_CONFIGS = [
{ name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { header: 'x-powered-by', value: /next/i } },
{ name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { header: 'x-sveltekit-page', value: null } },
{ name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
{ name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { body: /@vite\/client/ } },
{ name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { body: /astro/i } },
{ name: 'Angular', files: ['angular.json'], defaultPort: 4200,
portRe: /"port"\s*:\s*(\d+)/,
fingerprint: { body: /ng-version/i } },
{ name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { header: 'x-powered-by', value: /remix/i } },
];
function detectFrameworkConfig(dir) {
let entries;
try { entries = fs.readdirSync(dir); } catch { return null; }
const entrySet = new Set(entries);
for (const cfg of FRAMEWORK_CONFIGS) {
const match = cfg.files.find(f => entrySet.has(f));
if (!match) continue;
const configPath = path.join(dir, match);
let port = cfg.defaultPort;
try {
const content = fs.readFileSync(configPath, 'utf-8');
const portMatch = content.match(cfg.portRe);
if (portMatch) port = parseInt(portMatch[1], 10);
} catch { /* use default */ }
return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
}
return null;
}
/**
* Check if a port is listening and optionally verify it matches the expected framework.
* Returns { listening: true, matched: true/false } or { listening: false }.
*/
async function isPortListening(port, fingerprint = null) {
if (!fingerprint) {
// Simple TCP probe fallback
const net = await import('node:net');
return new Promise((resolve) => {
const sock = net.default.createConnection({ port, host: '127.0.0.1' });
sock.setTimeout(500);
sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
sock.on('error', () => resolve({ listening: false }));
sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
});
}
// HTTP probe with fingerprint matching
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
clearTimeout(timeout);
// Check header fingerprint
if (fingerprint.header) {
const val = res.headers.get(fingerprint.header);
if (val && (!fingerprint.value || fingerprint.value.test(val))) {
return { listening: true, matched: true };
}
}
// Check body fingerprint
if (fingerprint.body) {
const body = await res.text();
if (fingerprint.body.test(body)) {
return { listening: true, matched: true };
}
}
// Port is listening but doesn't match the expected framework
return { listening: true, matched: false };
} catch {
return { listening: false };
}
}
export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
walkDir,
resolveImport,
buildImportGraph,
FRAMEWORK_CONFIGS,
detectFrameworkConfig,
isPortListening,
};
+166
View File
@@ -0,0 +1,166 @@
function profileNow() {
return typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now();
}
function createDetectorProfile() {
return { events: [] };
}
function recordProfileEvent(profile, event) {
if (!profile) return;
const normalized = {
engine: event.engine || 'unknown',
phase: event.phase || 'unknown',
ruleId: event.ruleId || 'unknown',
target: event.target || '',
ms: Number.isFinite(event.ms) ? event.ms : 0,
findings: Number.isFinite(event.findings) ? event.findings : 0,
};
if (event.detail) normalized.detail = event.detail;
if (Array.isArray(event.findingIds) && event.findingIds.length) {
normalized.findingIds = event.findingIds;
}
if (typeof profile === 'function') {
profile(normalized);
} else if (typeof profile.record === 'function') {
profile.record(normalized);
} else if (Array.isArray(profile.events)) {
profile.events.push(normalized);
} else if (Array.isArray(profile)) {
profile.push(normalized);
}
}
function extractFindingIds(findings) {
if (!Array.isArray(findings) || findings.length === 0) return [];
return [...new Set(findings.map(f => f?.id || f?.type || f?.antipattern).filter(Boolean))];
}
function profileFindings(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
const findings = callback();
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: Array.isArray(findings) ? findings.length : 0,
findingIds: extractFindingIds(findings),
});
return findings;
}
function profileStep(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
try {
return callback();
} finally {
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: 0,
});
}
}
async function profileFindingsAsync(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
const findings = await callback();
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: Array.isArray(findings) ? findings.length : 0,
findingIds: extractFindingIds(findings),
});
return findings;
}
async function profileStepAsync(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
try {
return await callback();
} finally {
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: 0,
});
}
}
function percentile(sortedValues, pct) {
if (!sortedValues.length) return 0;
const idx = Math.min(
sortedValues.length - 1,
Math.max(0, Math.ceil((pct / 100) * sortedValues.length) - 1),
);
return sortedValues[idx];
}
function summarizeDetectorProfile(profile) {
const events = Array.isArray(profile)
? profile
: (Array.isArray(profile?.events) ? profile.events : []);
const groups = new Map();
for (const event of events) {
const key = [
event.engine || 'unknown',
event.phase || 'unknown',
event.ruleId || 'unknown',
event.target || '',
].join('\u0000');
let group = groups.get(key);
if (!group) {
group = {
engine: event.engine || 'unknown',
phase: event.phase || 'unknown',
ruleId: event.ruleId || 'unknown',
target: event.target || '',
calls: 0,
totalMs: 0,
findings: 0,
samples: [],
};
groups.set(key, group);
}
const ms = Number.isFinite(event.ms) ? event.ms : 0;
group.calls += 1;
group.totalMs += ms;
group.findings += Number.isFinite(event.findings) ? event.findings : 0;
group.samples.push(ms);
}
return [...groups.values()]
.map(group => {
const samples = group.samples.sort((a, b) => a - b);
return {
engine: group.engine,
phase: group.phase,
ruleId: group.ruleId,
target: group.target,
calls: group.calls,
totalMs: Number(group.totalMs.toFixed(3)),
avgMs: Number((group.totalMs / group.calls).toFixed(3)),
p50: Number(percentile(samples, 50).toFixed(3)),
p95: Number(percentile(samples, 95).toFixed(3)),
findings: group.findings,
};
})
.sort((a, b) => b.totalMs - a.totalMs);
}
export {
profileNow,
createDetectorProfile,
recordProfileEvent,
extractFindingIds,
profileFindings,
profileStep,
profileFindingsAsync,
profileStepAsync,
percentile,
summarizeDetectorProfile,
};
+278
View File
@@ -0,0 +1,278 @@
const ANTIPATTERNS = [
// ── AI slop: tells that something was AI-generated ──
{
id: 'side-tab',
category: 'slop',
name: 'Side-tab accent border',
description:
'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.',
skillSection: 'Visual Details',
skillGuideline: 'colored accent stripe',
},
{
id: 'border-accent-on-rounded',
category: 'slop',
name: 'Border accent on rounded element',
description:
'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
skillSection: 'Visual Details',
skillGuideline: 'colored accent stripe',
},
{
id: 'overused-font',
category: 'slop',
name: 'Overused font',
description:
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
skillSection: 'Typography',
skillGuideline: 'overused fonts like Inter',
},
{
id: 'single-font',
category: 'slop',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
skillSection: 'Typography',
skillGuideline: 'only one font family for the entire page',
},
{
id: 'flat-type-hierarchy',
category: 'slop',
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
skillSection: 'Typography',
skillGuideline: 'flat type hierarchy',
},
{
id: 'gradient-text',
category: 'slop',
name: 'Gradient text',
description:
'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
skillSection: 'Color & Contrast',
skillGuideline: 'gradient text for',
},
{
id: 'ai-color-palette',
category: 'slop',
name: 'AI color palette',
description:
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
skillSection: 'Color & Contrast',
skillGuideline: 'AI color palette',
},
{
id: 'nested-cards',
category: 'slop',
name: 'Nested cards',
description:
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
skillSection: 'Layout & Space',
skillGuideline: 'Nest cards inside cards',
},
{
id: 'monotonous-spacing',
category: 'slop',
name: 'Monotonous spacing',
description:
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
skillSection: 'Layout & Space',
skillGuideline: 'same spacing everywhere',
},
{
id: 'everything-centered',
category: 'slop',
name: 'Everything centered',
description:
'Every text element is center-aligned. Left-aligned text with asymmetric layouts feels more designed. Center only hero sections and CTAs.',
skillSection: 'Layout & Space',
skillGuideline: 'Center everything',
},
{
id: 'bounce-easing',
category: 'slop',
name: 'Bounce or elastic easing',
description:
'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
skillSection: 'Motion',
skillGuideline: 'bounce or elastic easing',
},
{
id: 'dark-glow',
category: 'slop',
name: 'Dark mode with glowing accents',
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'icon-tile-stack',
category: 'slop',
name: 'Icon tile stacked above heading',
description:
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
skillSection: 'Typography',
skillGuideline: 'large icons with rounded corners above every heading',
},
{
id: 'italic-serif-display',
category: 'slop',
name: 'Italic serif display headline',
description:
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
skillSection: 'Typography',
skillGuideline: 'oversized italic serif as the hero headline',
},
{
id: 'hero-eyebrow-chip',
category: 'slop',
name: 'Hero eyebrow / pill chip',
description:
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
skillSection: 'Typography',
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
id: 'repeated-section-kickers',
category: 'slop',
severity: 'advisory',
name: 'Repeated section kicker labels',
description:
'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
skillSection: 'Typography',
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
},
// ── Quality: general design and accessibility issues ──
{
id: 'pure-black-white',
category: 'quality',
name: 'Pure black background',
description:
'Pure #000000 as a background color looks harsh and unnatural. Tint it slightly toward your brand hue (e.g., oklch(12% 0.01 250)) for a more refined feel.',
skillSection: 'Color & Contrast',
skillGuideline: 'pure black (#000)',
},
{
id: 'gray-on-color',
category: 'quality',
name: 'Gray text on colored background',
description:
'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
skillSection: 'Color & Contrast',
skillGuideline: 'gray text on colored backgrounds',
},
{
id: 'low-contrast',
category: 'quality',
name: 'Low contrast text',
description:
'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
},
{
id: 'layout-transition',
category: 'quality',
name: 'Layout property animation',
description:
'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
skillSection: 'Motion',
skillGuideline: 'Animate layout properties',
},
{
id: 'line-length',
category: 'quality',
name: 'Line length too long',
description:
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
skillSection: 'Layout & Space',
skillGuideline: 'wrap beyond ~80 characters',
},
{
id: 'cramped-padding',
category: 'quality',
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Add at least 8px (ideally 12-16px) of padding inside bordered or colored containers.',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
name: 'Body text touching viewport edge',
description:
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
},
{
id: 'tight-leading',
category: 'quality',
name: 'Tight line height',
description:
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
},
{
id: 'skipped-heading',
category: 'quality',
name: 'Skipped heading level',
description:
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
},
{
id: 'justified-text',
category: 'quality',
name: 'Justified text',
description:
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
},
{
id: 'tiny-text',
category: 'quality',
name: 'Tiny body text',
description:
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
},
{
id: 'all-caps-body',
category: 'quality',
name: 'All-caps body text',
description:
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
skillSection: 'Typography',
skillGuideline: 'long body passages in uppercase',
},
{
id: 'wide-tracking',
category: 'quality',
name: 'Wide letter spacing on body text',
description:
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
},
];
const RULE_ENGINE_SUPPORT = {
regex: new Set(['source', 'page-analyzer']),
'static-html': new Set(['element', 'page']),
browser: new Set(['element', 'page', 'layout']),
visual: new Set(['visual-contrast']),
};
function getAntipattern(id) {
return ANTIPATTERNS.find(rule => rule.id === id);
}
function getRulesForCategory(category) {
return ANTIPATTERNS.filter(rule => rule.category === category);
}
function getRuleEngineSupport(engine) {
return RULE_ENGINE_SUPPORT[engine] || new Set();
}
export {
ANTIPATTERNS,
RULE_ENGINE_SUPPORT,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
};
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
// ─── Section 2: Color Utilities ─────────────────────────────────────────────
function isNeutralColor(color) {
if (!color || color === 'transparent') return true;
// rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0255 range.
const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgb) {
return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
}
// oklch()/lch() — chroma is the second numeric component.
// oklch chroma is ~00.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
// lch chroma is ~0150; >= 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);
if (oklch) return parseFloat(oklch[1]) < 0.02;
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);
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);
if (lab) {
const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
return Math.hypot(a, b) < 3;
}
// hsl/hsla — saturation is the second numeric component (percent).
// Modern jsdom usually converts hsl() to rgb, but handle it directly for
// safety across versions and for any engine that preserves the format.
const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
if (hsl) return parseFloat(hsl[1]) < 10;
// hwb(hue whiteness% blackness%) — a pixel is fully gray when
// whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
if (hwb) {
const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
return (1 - Math.min(100, w + b) / 100) < 0.1;
}
// Unknown / unrecognized format — err on the side of DETECTING rather
// than silently skipping. This is the opposite of the previous default,
// which was the root cause of the oklch bug.
return false;
}
function parseRgb(color) {
if (!color || color === 'transparent') return null;
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return null;
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
}
function relativeLuminance({ r, g, b }) {
const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
);
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function contrastRatio(c1, c2) {
const l1 = relativeLuminance(c1);
const l2 = relativeLuminance(c2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
} else {
colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });
}
}
return colors;
}
function hasChroma(c, threshold = 30) {
if (!c) return false;
return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
}
function getHue(c) {
if (!c) return 0;
const r = c.r / 255, g = c.g / 255, b = c.b / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
if (max === min) return 0;
const d = max - min;
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return Math.round(h * 360);
}
function colorToHex(c) {
if (!c) return '?';
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
hasChroma,
getHue,
colorToHex,
};
+101
View File
@@ -0,0 +1,101 @@
// ─── 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 <label> here because card-shaped clickable
// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
// canonical side-tab anti-pattern shapes and must be detected. The rule's
// other preconditions (non-neutral color, width >= 2px on a single side,
// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
// already filter out plain inline form labels so this does not introduce
// false positives. See modern-color-borders.html for the test matrix.
const BORDER_SAFE_TAGS = new Set(
[...SAFE_TAGS].filter(t => t !== 'label')
);
const OVERUSED_FONTS = new Set([
// Older monoculture (still ubiquitous):
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
// Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave):
'fraunces', 'instrument sans',
'geist', 'geist sans', 'geist mono',
'mona sans',
'plus jakarta sans', 'space grotesk', 'recoleta',
]);
// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
const GOOGLE_DOMAINS = [
'google.com', 'youtube.com', 'android.com', 'chromium.org',
'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
];
const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app'];
const GITHUB_DOMAINS = ['github.com', 'githubnext.com'];
const BRAND_FONT_DOMAINS = {
'roboto': GOOGLE_DOMAINS,
'google sans': GOOGLE_DOMAINS,
'product sans': GOOGLE_DOMAINS,
'geist': VERCEL_DOMAINS,
'geist sans': VERCEL_DOMAINS,
'geist mono': VERCEL_DOMAINS,
'mona sans': GITHUB_DOMAINS,
};
function isBrandFontOnOwnDomain(font) {
if (typeof location === 'undefined') return false;
const allowed = BRAND_FONT_DOMAINS[font];
if (!allowed) return false;
const host = location.hostname.toLowerCase();
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
}
const GENERIC_FONTS = new Set([
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
'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.
const KNOWN_SERIF_FONTS = new Set([
'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair',
'cormorant', 'cormorant garamond', 'garamond', 'eb garamond',
'tiempos', 'tiempos headline', 'tiempos text',
'lora', 'vollkorn', 'spectral',
'source serif pro', 'source serif 4', 'source serif',
'ibm plex serif', 'merriweather',
'libre caslon', 'libre baskerville', 'baskerville',
'georgia', 'times new roman', 'times',
'dm serif display', 'dm serif text',
'instrument serif', 'gt sectra', 'ogg', 'canela',
'freight display', 'freight text',
]);
export {
SAFE_TAGS,
BORDER_SAFE_TAGS,
OVERUSED_FONTS,
GOOGLE_DOMAINS,
VERCEL_DOMAINS,
GITHUB_DOMAINS,
BRAND_FONT_DOMAINS,
isBrandFontOnOwnDomain,
GENERIC_FONTS,
WCAG_LARGE_TEXT_PX,
WCAG_LARGE_BOLD_TEXT_PX,
KNOWN_SERIF_FONTS,
};
+7
View File
@@ -0,0 +1,7 @@
/** Check if content looks like a full page (not a component/partial) */
function isFullPage(content) {
const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
}
export { isFullPage };
+6 -1
View File
@@ -52,6 +52,8 @@
"deploy": "bun run build && wrangler pages deploy build/",
"test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/impeccable-paths.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-reference.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-poll.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/live-session-store.test.mjs && node --test tests/live-browser-session.test.mjs && node --test tests/live-browser-source.test.mjs && node --test tests/live-completion.test.mjs && node --test tests/live-recovery-commands.test.mjs && node --test tests/framework-fixtures.test.mjs",
"test:live-e2e": "node --test --test-timeout=600000 tests/live-e2e.test.mjs",
"bench:detector": "node scripts/benchmark-detector.mjs",
"bench:detector:browser": "node scripts/benchmark-detector.mjs --browser",
"audit": "bun audit --audit-level=moderate",
"prepack": "cp README.md README.repo.md && cp README.npm.md README.md",
"postpack": "cp README.repo.md README.md && rm README.repo.md",
@@ -62,7 +64,10 @@
"og-image": "bun run scripts/generate-og-image.js"
},
"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"
},
"optionalDependencies": {
+59 -304
View File
@@ -8,9 +8,18 @@ importers:
.:
dependencies:
jsdom:
specifier: 29.1.1
version: 29.1.1
css-select:
specifier: ^5.2.2
version: 5.2.2
css-tree:
specifier: ^3.2.1
version: 3.2.1
domutils:
specifier: ^3.2.2
version: 3.2.2
htmlparser2:
specifier: ^10.0.0
version: 10.1.0
marked:
specifier: ^16.4.2
version: 16.4.2
@@ -106,21 +115,25 @@ packages:
resolution: {integrity: sha512-7EhskzBkeH5LOzuXNPoyTjNb7TwR9+mZcZ0s/e3AVEbTJTN9fddx3dcqZ6h+PivyF+JAGYjf7/OqUWTrawIgJw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.122':
resolution: {integrity: sha512-tSOy0BrxQfPNG4mqrz7KC7T+7ysrcuzof1m2Cw+DRVqp1O4CZl1VvYu2gQJxnVYoVKO2B6KzC05oOqpV9EWn7A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.122':
resolution: {integrity: sha512-SLPgQcz8tEsYA4YqHb8SrVfssTMPIXJ4C6NTrullOd3+IlutuJnyRw9wX0tLi0sozDwv6TIwAf78RV84RMUd/w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@anthropic-ai/claude-agent-sdk-linux-x64@0.2.122':
resolution: {integrity: sha512-ClpyiD79YzjJ5o1w/yHJ/FugaZTCeS0IypQJg/SrAKKfn2oTyKFDg7P+Fu4+WSezj81qgdO/vT1TW2IPALue2w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.122':
resolution: {integrity: sha512-h7aUAlm9PTuWyf2RNncpdFwweYD5yMsMqB1Y3DioCP8jZSPNRuiBPxJdtRVPimrAkPre7gY1hLiicKOaEDwFpw==}
@@ -156,21 +169,6 @@ packages:
zod:
optional: true
'@asamuzakjp/css-color@5.1.11':
resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/dom-selector@7.1.1':
resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/generational-cache@1.0.1':
resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@astrojs/compiler@4.0.0':
resolution: {integrity: sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA==}
@@ -213,10 +211,6 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@capsizecss/unpack@4.0.0':
resolution: {integrity: sha512-VERIM64vtTP1C4mxQ5thVT9fK0apjPFobqybMtA1UdUujWka24ERHbRHFGmpbbhp73MhV+KSsHQH9C6uOTdEQA==}
engines: {node: '>=18'}
@@ -276,42 +270,6 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.2.1':
resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.1.1':
resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.4':
resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@emnapi/runtime@1.9.2':
resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==}
@@ -471,15 +429,6 @@ packages:
cpu: [x64]
os: [win32]
'@exodus/bytes@1.15.0':
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@google/genai@1.50.1':
resolution: {integrity: sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==}
engines: {node: '>=20.0.0'}
@@ -525,89 +474,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -757,66 +722,79 @@ packages:
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.2':
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.2':
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.2':
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.2':
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.2':
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.2':
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.2':
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.2':
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.2':
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.2':
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.2':
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.2':
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.2':
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
@@ -1075,9 +1053,6 @@ packages:
resolution: {integrity: sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw==}
engines: {node: '>=10.0.0'}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
@@ -1260,10 +1235,6 @@ packages:
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
engines: {node: '>= 14'}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1273,9 +1244,6 @@ packages:
supports-color:
optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -1368,9 +1336,9 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
@@ -1666,16 +1634,15 @@ packages:
resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==}
engines: {node: '>=16.9.0'}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
http-cache-semantics@4.2.0:
resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==}
@@ -1742,9 +1709,6 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -1775,15 +1739,6 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsdom@29.1.1:
resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
canvas:
optional: true
json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
@@ -1836,10 +1791,6 @@ packages:
resolution: {integrity: sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==}
engines: {node: 20 || >=22}
lru-cache@11.3.6:
resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==}
engines: {node: 20 || >=22}
lru-cache@7.18.3:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
@@ -2173,9 +2124,6 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -2268,10 +2216,6 @@ packages:
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
puppeteer-core@24.42.0:
resolution: {integrity: sha512-T4zXokk/izH01fYPhyyev1A4piWiOKrYq7CUFpdoYQxmOnXoV6YjUabmfIjCYkNspSoAXIxRid3Tw+Vg0fthYg==}
engines: {node: '>=18'}
@@ -2397,10 +2341,6 @@ packages:
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
engines: {node: '>=11.0.0'}
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
@@ -2524,9 +2464,6 @@ packages:
engines: {node: '>=16'}
hasBin: true
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tar-fs@3.1.2:
resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==}
@@ -2554,25 +2491,10 @@ packages:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tldts-core@7.0.28:
resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==}
tldts@7.0.28:
resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==}
hasBin: true
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
tough-cookie@6.0.1:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -2618,10 +2540,6 @@ packages:
resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==}
engines: {node: '>=20.18.1'}
undici@7.25.0:
resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==}
engines: {node: '>=20.18.1'}
unenv@2.0.0-rc.24:
resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==}
@@ -2788,10 +2706,6 @@ packages:
vite:
optional: true
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
@@ -2802,18 +2716,6 @@ packages:
webdriver-bidi-protocol@0.4.1:
resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
which-pm-runs@1.1.0:
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
engines: {node: '>=4'}
@@ -2873,13 +2775,6 @@ packages:
utf-8-validate:
optional: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
xxhash-wasm@1.1.0:
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
@@ -3016,26 +2911,6 @@ snapshots:
optionalDependencies:
zod: 4.3.6
'@asamuzakjp/css-color@5.1.11':
dependencies:
'@asamuzakjp/generational-cache': 1.0.1
'@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@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
'@asamuzakjp/generational-cache@1.0.1': {}
'@asamuzakjp/nwsapi@2.3.9': {}
'@astrojs/compiler@4.0.0': {}
'@astrojs/internal-helpers@0.9.0':
@@ -3103,10 +2978,6 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@capsizecss/unpack@4.0.0':
dependencies:
fontkitten: 1.0.3
@@ -3150,30 +3021,6 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@csstools/color-helpers@6.0.2': {}
'@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.0.2
'@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
'@csstools/css-tokenizer@4.0.0': {}
'@emnapi/runtime@1.9.2':
dependencies:
tslib: 2.8.1
@@ -3257,8 +3104,6 @@ snapshots:
'@esbuild/win32-x64@0.27.3':
optional: true
'@exodus/bytes@1.15.0': {}
'@google/genai@1.50.1(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))':
dependencies:
google-auth-library: 10.6.2
@@ -3861,10 +3706,6 @@ snapshots:
basic-ftp@5.2.2:
optional: true
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
bignumber.js@9.3.1: {}
blake3-wasm@2.1.5: {}
@@ -4039,19 +3880,10 @@ snapshots:
data-uri-to-buffer@6.0.2:
optional: true
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
debug@4.4.3:
dependencies:
ms: 2.1.3
decimal.js@10.6.0: {}
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -4135,7 +3967,7 @@ snapshots:
entities@6.0.1: {}
entities@8.0.0: {}
entities@7.0.1: {}
env-paths@2.2.1:
optional: true
@@ -4548,16 +4380,17 @@ snapshots:
hono@4.12.15: {}
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.0
transitivePeerDependencies:
- '@noble/hashes'
html-escaper@3.0.3: {}
html-void-elements@3.0.0: {}
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
http-cache-semantics@4.2.0: {}
http-errors@2.0.1:
@@ -4618,8 +4451,6 @@ snapshots:
is-plain-obj@4.1.0: {}
is-potential-custom-element-name@1.0.1: {}
is-promise@4.0.0: {}
is-stream@2.0.1: {}
@@ -4647,32 +4478,6 @@ snapshots:
dependencies:
argparse: 2.0.1
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.4(css-tree@3.2.1)
'@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.6
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
transitivePeerDependencies:
- '@noble/hashes'
json-bigint@1.0.0:
dependencies:
bignumber.js: 9.3.1
@@ -4721,8 +4526,6 @@ snapshots:
lru-cache@11.3.3: {}
lru-cache@11.3.6: {}
lru-cache@7.18.3:
optional: true
@@ -5236,10 +5039,6 @@ snapshots:
dependencies:
entities: 6.0.1
parse5@8.0.1:
dependencies:
entities: 8.0.0
parseurl@1.3.3: {}
path-key@3.1.1: {}
@@ -5336,8 +5135,6 @@ snapshots:
once: 1.4.0
optional: true
punycode@2.3.1: {}
puppeteer-core@24.42.0:
dependencies:
'@puppeteer/browsers': 2.13.0
@@ -5573,10 +5370,6 @@ snapshots:
sax@1.6.0: {}
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
semver@7.7.4: {}
send@1.2.1:
@@ -5769,8 +5562,6 @@ snapshots:
picocolors: 1.1.1
sax: 1.6.0
symbol-tree@3.2.4: {}
tar-fs@3.1.2:
dependencies:
pump: 3.0.4
@@ -5819,22 +5610,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tldts-core@7.0.28: {}
tldts@7.0.28:
dependencies:
tldts-core: 7.0.28
toidentifier@1.0.1: {}
tough-cookie@6.0.1:
dependencies:
tldts: 7.0.28
tr46@6.0.0:
dependencies:
punycode: 2.3.1
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -5864,8 +5641,6 @@ snapshots:
undici@7.24.8: {}
undici@7.25.0: {}
unenv@2.0.0-rc.24:
dependencies:
pathe: 2.0.3
@@ -5976,10 +5751,6 @@ snapshots:
optionalDependencies:
vite: 7.3.2(@types/node@25.6.0)
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
web-namespaces@2.0.1: {}
web-streams-polyfill@3.3.3: {}
@@ -5987,18 +5758,6 @@ snapshots:
webdriver-bidi-protocol@0.4.1:
optional: true
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
dependencies:
'@exodus/bytes': 1.15.0
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
which-pm-runs@1.1.0: {}
which@2.0.2:
@@ -6047,10 +5806,6 @@ snapshots:
ws@8.20.0: {}
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
xxhash-wasm@1.1.0: {}
y18n@5.0.8:
+583
View File
@@ -0,0 +1,583 @@
#!/usr/bin/env node
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createBrowserDetector,
createDetectorProfile,
detectHtml,
detectText,
detectUrl,
summarizeDetectorProfile,
walkDir,
} from '../cli/engine/detect-antipatterns.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const FIXTURES = path.join(ROOT, 'tests', 'fixtures', 'antipatterns');
const BROWSER_FIXTURES = [
'cramped-padding.html',
'quality.html',
'body-text-viewport-edge.html',
];
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
};
function parseArgs(argv) {
const args = {
browser: false,
json: false,
out: null,
quick: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--browser') args.browser = true;
else if (arg === '--json') args.json = true;
else if (arg === '--quick') args.quick = true;
else if (arg === '--out') args.out = argv[++i] || null;
else if (arg.startsWith('--out=')) args.out = arg.slice('--out='.length);
else if (arg === '--help') {
printUsage();
process.exit(0);
}
}
return args;
}
function printUsage() {
console.log(`Usage: node scripts/benchmark-detector.mjs [options]
Options:
--quick Run a small smoke benchmark subset
--browser Include browser-backed URL benchmarks
--json Print the benchmark report as JSON
--out FILE Write the benchmark report JSON to FILE
--help Show this help message`);
}
function nowMs() {
return typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now();
}
function roundMs(value) {
return Number(value.toFixed(3));
}
function isHtml(filePath) {
const ext = path.extname(filePath).toLowerCase();
return ext === '.html' || ext === '.htm';
}
function rel(filePath) {
return path.relative(ROOT, filePath);
}
function addEvent(profile, event) {
profile.events.push({
engine: event.engine || 'unknown',
phase: event.phase || 'unknown',
ruleId: event.ruleId || 'unknown',
target: event.target || '',
ms: Number.isFinite(event.ms) ? event.ms : 0,
findings: Number.isFinite(event.findings) ? event.findings : 0,
});
}
async function measureCase({ name, engine, mode, target, run }) {
const profile = createDetectorProfile();
const started = nowMs();
try {
const result = await run(profile);
const findings = Array.isArray(result)
? result.length
: (Number.isFinite(result?.findings) ? result.findings : 0);
return {
name,
engine,
mode,
target,
status: 'ok',
totalMs: roundMs(nowMs() - started),
findings,
profile: summarizeDetectorProfile(profile),
events: profile.events,
};
} catch (err) {
return {
name,
engine,
mode,
target,
status: 'failed',
totalMs: roundMs(nowMs() - started),
findings: 0,
error: err?.message || String(err),
profile: summarizeDetectorProfile(profile),
events: profile.events,
};
}
}
function skippedCase({ name, engine, mode, target, reason }) {
return {
name,
engine,
mode,
target,
status: 'skipped',
totalMs: 0,
findings: 0,
skipReason: reason,
profile: [],
events: [],
};
}
async function scanDirectory(files, fastMode, profile) {
const findings = [];
for (const file of files) {
if (!fastMode && isHtml(file)) {
findings.push(...await detectHtml(file, { profile }));
} else {
const content = fs.readFileSync(file, 'utf-8');
findings.push(...detectText(content, file, { profile }));
}
}
return findings;
}
function selectQuickFiles(files, predicate, preferredNames) {
const preferred = preferredNames
.map(name => files.find(file => path.basename(file) === name))
.filter(Boolean);
const fallback = files.filter(predicate).slice(0, preferredNames.length || 2);
return preferred.length ? preferred : fallback;
}
async function runFileBenchmarks(args) {
const files = walkDir(FIXTURES).sort();
const htmlFiles = files.filter(isHtml);
const textFiles = files.filter(file => !isHtml(file));
const selectedText = args.quick
? textFiles.slice(0, 2)
: textFiles;
const selectedHtml = args.quick
? selectQuickFiles(htmlFiles, isHtml, ['color.html', 'quality.html'])
: htmlFiles;
const directoryFiles = args.quick
? [...selectedHtml, ...selectedText].sort()
: files;
const cases = [];
for (const file of selectedText) {
cases.push(await measureCase({
name: `detectText:${rel(file)}`,
engine: 'regex',
mode: 'file',
target: rel(file),
run: (profile) => detectText(fs.readFileSync(file, 'utf-8'), file, { profile }),
}));
}
for (const file of selectedHtml) {
cases.push(await measureCase({
name: `detectHtml:${rel(file)}`,
engine: 'static-html',
mode: 'file',
target: rel(file),
run: (profile) => detectHtml(file, { profile }),
}));
}
cases.push(await measureCase({
name: args.quick ? 'directory-default:quick-fixtures' : 'directory-default:all-fixtures',
engine: 'mixed',
mode: 'directory-default',
target: rel(FIXTURES),
run: (profile) => scanDirectory(directoryFiles, false, profile),
}));
cases.push(await measureCase({
name: args.quick ? 'directory-fast:quick-fixtures' : 'directory-fast:all-fixtures',
engine: 'regex',
mode: 'directory-fast',
target: rel(FIXTURES),
run: (profile) => scanDirectory(directoryFiles, true, profile),
}));
return cases;
}
function startFixtureServer() {
const server = http.createServer((req, res) => {
let filePath;
const urlPath = req.url?.split('?')[0] || '/';
if (urlPath.startsWith('/fixtures/')) {
filePath = path.join(ROOT, 'tests', urlPath);
} else if (urlPath === '/js/detect-antipatterns-browser.js') {
filePath = path.join(ROOT, 'cli', 'engine', 'detect-antipatterns-browser.js');
} else {
res.writeHead(404).end();
return;
}
try {
const body = fs.readFileSync(filePath);
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404).end();
}
});
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
const address = server.address();
resolve({
server,
baseUrl: `http://127.0.0.1:${address.port}`,
});
});
});
}
async function closeServer(server) {
await new Promise(resolve => server.close(resolve));
}
async function runBrowserBenchmarks(args) {
let serverInfo;
try {
serverInfo = await startFixtureServer();
} catch (err) {
return [
skippedCase({
name: 'browser:fixtures',
engine: 'browser',
mode: 'browser',
target: 'localhost',
reason: `localhost fixture server unavailable: ${err?.message || err}`,
}),
];
}
const cases = [];
const browserFiles = args.quick ? ['quality.html'] : BROWSER_FIXTURES;
try {
for (const fileName of browserFiles) {
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
const fresh = await measureCase({
name: `detectUrl:fresh-load:${fileName}`,
engine: 'browser',
mode: 'fresh-load',
target: url,
run: (profile) => detectUrl(url, { profile, waitUntil: 'load', settleMs: 100 }),
});
if (fresh.status === 'failed' && /Could not find Chrome|Failed to launch|executable|spawn|puppeteer/i.test(fresh.error || '')) {
cases.push(skippedCase({
name: `detectUrl:fresh-load:${fileName}`,
engine: 'browser',
mode: 'fresh-load',
target: url,
reason: `Chromium unavailable: ${fresh.error}`,
}));
} else {
cases.push(fresh);
}
}
const visualContrastUrl = `${serverInfo.baseUrl}/fixtures/antipatterns/visual-contrast.html`;
cases.push(await measureCase({
name: 'detectUrl:visual-contrast',
engine: 'browser',
mode: 'visual-contrast',
target: visualContrastUrl,
run: (profile) => detectUrl(visualContrastUrl, {
profile,
waitUntil: 'load',
settleMs: 0,
visualContrast: true,
}),
}));
cases.push(await measureCase({
name: 'detectUrl:warm-load',
engine: 'browser',
mode: 'warm-load',
target: serverInfo.baseUrl,
run: async (profile) => {
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 100 });
const findings = [];
try {
for (const fileName of browserFiles) {
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
findings.push(...await detector.detectUrl(url, { profile }));
}
} finally {
await detector.close();
}
return findings;
},
}));
cases.push(await measureCase({
name: 'detectUrl:warm-networkidle0',
engine: 'browser',
mode: 'warm-networkidle0',
target: serverInfo.baseUrl,
run: async (profile) => {
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 100 });
const findings = [];
try {
for (const fileName of browserFiles) {
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${fileName}`;
findings.push(...await detector.detectUrl(url, {
profile,
waitUntil: 'networkidle0',
settleMs: 0,
}));
}
} finally {
await detector.close();
}
return findings;
},
}));
let puppeteer;
try {
puppeteer = await import('puppeteer');
} catch (err) {
cases.push(skippedCase({
name: 'browser:pure-vs-overlay',
engine: 'browser',
mode: 'pure-vs-overlay',
target: serverInfo.baseUrl,
reason: `puppeteer unavailable: ${err?.message || err}`,
}));
return cases;
}
cases.push(await measureCase({
name: 'browser:pure-vs-overlay',
engine: 'browser',
mode: 'pure-vs-overlay',
target: serverInfo.baseUrl,
run: async (profile) => {
let browser;
const launchStarted = nowMs();
try {
browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
addEvent(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'launch-browser-overlay-bench',
target: serverInfo.baseUrl,
ms: nowMs() - launchStarted,
});
} catch (err) {
throw new Error(`Chromium unavailable: ${err?.message || err}`);
}
let findings = [];
try {
const page = await browser.newPage();
const url = `${serverInfo.baseUrl}/fixtures/antipatterns/${browserFiles[0]}`;
const browserScript = fs.readFileSync(path.join(ROOT, 'cli', 'engine', 'detect-antipatterns-browser.js'), 'utf-8');
await page.setViewport({ width: 1280, height: 800 });
await page.goto(url, { waitUntil: 'load', timeout: 30000 });
await new Promise(resolve => setTimeout(resolve, 100));
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const pureStarted = nowMs();
findings = await page.evaluate(() => {
const serialized = window.impeccableDetect({ decorate: false, serialize: true });
return serialized.flatMap(({ findings }) => findings.map(f => ({ id: f.type, snippet: f.detail })));
});
addEvent(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'pure-detect',
target: url,
ms: nowMs() - pureStarted,
findings: findings.length,
});
const overlayStarted = nowMs();
const overlayGroupCount = await page.evaluate(() => window.impeccableScan().length);
addEvent(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'overlay-scan',
target: url,
ms: nowMs() - overlayStarted,
findings: overlayGroupCount,
});
await page.close().catch(() => {});
} finally {
const closeStarted = nowMs();
await browser.close().catch(() => {});
addEvent(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-browser-overlay-bench',
target: serverInfo.baseUrl,
ms: nowMs() - closeStarted,
});
}
return findings;
},
}));
} finally {
await closeServer(serverInfo.server);
}
return cases.map(testCase => {
if (testCase.engine === 'browser' && testCase.status === 'failed' && /Chromium unavailable|Failed to launch|Could not find Chrome|executable|spawn|puppeteer/i.test(testCase.error || '')) {
return skippedCase({
name: testCase.name,
engine: testCase.engine,
mode: testCase.mode,
target: testCase.target,
reason: testCase.error,
});
}
return testCase;
});
}
function aggregateEvents(cases) {
const profile = createDetectorProfile();
for (const testCase of cases) {
if (Array.isArray(testCase.events)) profile.events.push(...testCase.events);
}
return summarizeDetectorProfile(profile);
}
function makeReport(args, cases) {
const summary = aggregateEvents(cases);
return {
version: 1,
createdAt: new Date().toISOString(),
cwd: ROOT,
quick: args.quick,
browser: args.browser,
cases: cases.map(({ events, ...testCase }) => testCase),
summary,
};
}
function pad(value, width) {
const str = String(value);
if (str.length >= width) return str.slice(0, width);
return str + ' '.repeat(width - str.length);
}
function printRows(rows, columns) {
const header = columns.map(col => pad(col.label, col.width)).join(' ');
console.log(header);
console.log(columns.map(col => '-'.repeat(col.width)).join(' '));
for (const row of rows) {
console.log(columns.map(col => pad(row[col.key] ?? '', col.width)).join(' '));
}
}
function printConsoleReport(report) {
console.log(`Detector benchmark ${report.quick ? '(quick)' : '(full)'}`);
console.log(`Cases: ${report.cases.length}`);
const caseRows = report.cases.map(testCase => ({
status: testCase.status,
engine: testCase.engine,
mode: testCase.mode,
totalMs: testCase.totalMs,
findings: testCase.findings,
target: testCase.target,
}));
printRows(caseRows, [
{ key: 'status', label: 'Status', width: 8 },
{ key: 'engine', label: 'Engine', width: 12 },
{ key: 'mode', label: 'Mode', width: 20 },
{ key: 'totalMs', label: 'Total ms', width: 10 },
{ key: 'findings', label: 'Findings', width: 8 },
{ key: 'target', label: 'Target', width: 60 },
]);
const skipped = report.cases.filter(testCase => testCase.status === 'skipped');
for (const testCase of skipped) {
console.log(`Skipped ${testCase.name}: ${testCase.skipReason}`);
}
console.log('\nSlowest profile groups');
const slowRows = report.summary.slice(0, 20).map(item => ({
engine: item.engine,
phase: item.phase,
ruleId: item.ruleId,
calls: item.calls,
totalMs: item.totalMs,
avgMs: item.avgMs,
p95: item.p95,
findings: item.findings,
target: item.target,
}));
printRows(slowRows, [
{ key: 'engine', label: 'Engine', width: 12 },
{ key: 'phase', label: 'Phase', width: 14 },
{ key: 'ruleId', label: 'Rule', width: 28 },
{ key: 'calls', label: 'Calls', width: 8 },
{ key: 'totalMs', label: 'Total ms', width: 10 },
{ key: 'avgMs', label: 'Avg ms', width: 8 },
{ key: 'p95', label: 'P95', width: 8 },
{ key: 'findings', label: 'Finds', width: 7 },
{ key: 'target', label: 'Target', width: 45 },
]);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const cases = [
...await runFileBenchmarks(args),
];
if (args.browser) {
cases.push(...await runBrowserBenchmarks(args));
}
const report = makeReport(args, cases);
const json = JSON.stringify(report, null, 2);
if (args.out) {
fs.writeFileSync(path.resolve(args.out), json + '\n');
}
if (args.json) {
process.stdout.write(json + '\n');
} else {
printConsoleReport(report);
if (args.out) console.log(`\nWrote JSON report to ${path.resolve(args.out)}`);
}
if (report.cases.some(testCase => testCase.status === 'failed')) {
process.exitCode = 1;
}
}
main().catch(err => {
console.error(err?.stack || err?.message || err);
process.exit(1);
});
+25 -10
View File
@@ -2,7 +2,7 @@
/**
* Generates cli/engine/detect-antipatterns-browser.js
* by stripping Node-specific sections from the universal source and wrapping in an IIFE.
* by concatenating the browser-safe detector modules and wrapping them in an IIFE.
*
* Run: node scripts/build-browser-detector.js
*/
@@ -14,24 +14,36 @@ import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const SOURCE = path.join(ROOT, 'cli/engine/detect-antipatterns.mjs');
const MODULES = [
'cli/engine/shared/constants.mjs',
'cli/engine/registry/antipatterns.mjs',
'cli/engine/shared/color.mjs',
'cli/engine/rules/checks.mjs',
'cli/engine/browser/injected/index.mjs',
];
const OUTPUT = path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js');
const SITE_OUTPUT = path.join(ROOT, 'site/public/js/detect-antipatterns-browser.js');
let code = fs.readFileSync(SOURCE, 'utf-8');
function browserSafeModule(relPath) {
let code = fs.readFileSync(path.join(ROOT, relPath), 'utf-8');
if (relPath === 'cli/engine/registry/antipatterns.mjs') {
const match = code.match(/const ANTIPATTERNS = \[[\s\S]*?\n\];/);
if (!match) throw new Error('Could not extract browser antipattern registry');
code = match[0];
}
code = code.replace(/^import[\s\S]*?;\n/gm, '');
code = code.replace(/^export\s+\{[\s\S]*?^};\n?/gm, '');
return `// --- ${relPath} ---\n${code.trim()}\n`;
}
// Strip shebang
code = code.replace(/^#!.*\n/, '');
// Strip sections between @browser-strip-start / @browser-strip-end markers
code = code.replace(/^\/\/ @browser-strip-start\n[\s\S]*?^\/\/ @browser-strip-end\n?/gm, '');
// Set IS_BROWSER = true (dead-code eliminates Node paths)
code = code.replace(/^const IS_BROWSER = .*$/m, 'const IS_BROWSER = true;');
const code = MODULES.map(browserSafeModule).join('\n');
const output = `/**
* Anti-Pattern Browser Detector for Impeccable
* 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: <script src="detect-antipatterns-browser.js"></script>
@@ -44,4 +56,7 @@ ${code}
`;
fs.writeFileSync(OUTPUT, output);
fs.mkdirSync(path.dirname(SITE_OUTPUT), { recursive: true });
fs.writeFileSync(SITE_OUTPUT, output);
console.log(`Generated ${path.relative(ROOT, OUTPUT)} (${(output.length / 1024).toFixed(1)} KB)`);
console.log(`Generated ${path.relative(ROOT, SITE_OUTPUT)} (${(output.length / 1024).toFixed(1)} KB)`);
+32 -26
View File
@@ -13,32 +13,44 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const EXT_DIR = path.join(ROOT, 'extension');
const SOURCE = path.join(ROOT, 'cli/engine/detect-antipatterns.mjs');
const BROWSER_MODULES = [
'cli/engine/shared/constants.mjs',
'cli/engine/registry/antipatterns.mjs',
'cli/engine/shared/color.mjs',
'cli/engine/rules/checks.mjs',
'cli/engine/browser/injected/index.mjs',
];
const DETECTOR_OUTPUT = path.join(EXT_DIR, 'detector/detect.js');
const AP_OUTPUT = path.join(EXT_DIR, 'detector/antipatterns.json');
let code = fs.readFileSync(SOURCE, 'utf-8');
function browserSafeModule(relPath) {
let code = fs.readFileSync(path.join(ROOT, relPath), 'utf-8');
if (relPath === 'cli/engine/registry/antipatterns.mjs') {
const match = code.match(/const ANTIPATTERNS = \[[\s\S]*?\n\];/);
if (!match) throw new Error('Could not extract browser antipattern registry');
code = match[0];
}
code = code.replace(/^import[\s\S]*?;\n/gm, '');
code = code.replace(/^export\s+\{[\s\S]*?^};\n?/gm, '');
return `// --- ${relPath} ---\n${code.trim()}\n`;
}
const code = BROWSER_MODULES.map(browserSafeModule).join('\n');
// --- 1. Build detector ---
// Strip shebang
code = code.replace(/^#!.*\n/, '');
// Strip sections between @browser-strip-start / @browser-strip-end markers
code = code.replace(/^\/\/ @browser-strip-start\n[\s\S]*?^\/\/ @browser-strip-end\n?/gm, '');
// Set IS_BROWSER = true (dead-code eliminates Node paths)
code = code.replace(/^const IS_BROWSER = .*$/m, 'const IS_BROWSER = true;');
const output = `/**
* Anti-Pattern Browser Detector for Impeccable (Extension Variant)
* 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-extension.js
*/
(function () {
@@ -53,22 +65,16 @@ console.log(`Generated ${path.relative(ROOT, DETECTOR_OUTPUT)} (${(output.length
// --- 2. Extract antipatterns.json ---
const rawSource = fs.readFileSync(SOURCE, 'utf-8');
const apMatch = rawSource.match(/const ANTIPATTERNS = \[([\s\S]*?)\n\];/);
if (apMatch) {
// Convert JS object literals to JSON. Include description so the
// devtools panel can show the full rule explanation in tooltips —
// previously this dropped description and the panel had nothing to display.
const antipatterns = new Function(`return [${apMatch[1]}]`)();
const apJson = antipatterns.map(({ id, name, category, description }) => ({
id,
name,
category: category || 'quality',
description: description || '',
}));
fs.writeFileSync(AP_OUTPUT, JSON.stringify(apJson, null, 2) + '\n');
console.log(`Generated ${path.relative(ROOT, AP_OUTPUT)} (${antipatterns.length} rules)`);
}
// Include description so the devtools panel can show the full rule explanation
// in tooltips.
const apJson = ANTIPATTERNS.map(({ id, name, category, description }) => ({
id,
name,
category: category || 'quality',
description: description || '',
}));
fs.writeFileSync(AP_OUTPUT, JSON.stringify(apJson, null, 2) + '\n');
console.log(`Generated ${path.relative(ROOT, AP_OUTPUT)} (${ANTIPATTERNS.length} rules)`);
// --- 3. Zip packaging ---
+3 -8
View File
@@ -21,6 +21,7 @@ import { fileURLToPath } from 'url';
import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js';
import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
import { createAllZips } from './lib/zip.js';
import { ANTIPATTERNS } from '../cli/engine/registry/antipatterns.mjs';
// Sub-page generation is now handled by Astro content collections.
/**
@@ -48,14 +49,8 @@ function generateCounts(rootDir, skills, buildDir) {
commandCount = activeCommands.length;
}
// Count detection rules from impeccable package
const detectPkgPath = path.join(rootDir, 'cli/engine/detect-antipatterns.mjs');
const detectorSrc = fs.readFileSync(detectPkgPath, 'utf-8');
const ruleIds = new Set();
for (const match of detectorSrc.matchAll(/^\s+id: '([^']+)'/gm)) {
ruleIds.add(match[1]);
}
const detectionCount = ruleIds.size;
// Count detection rules from the detector registry.
const detectionCount = new Set(ANTIPATTERNS.map(rule => rule.id)).size;
// Write generated counts module
const genDir = path.join(rootDir, 'site/public/js/generated');
+5 -12
View File
@@ -5,7 +5,7 @@
* Single source of truth:
* - skill/SKILL.md → skill frontmatter + body
* - skill/reference/*.md → skill reference files
* - cli/engine/detect-antipatterns.mjs → ANTIPATTERNS array (parsed)
* - cli/engine/registry/antipatterns.mjs → ANTIPATTERNS registry
* - site/content/skills/{id}.md → optional editorial wrapper
* - site/content/tutorials/{slug}.md → full tutorial content
*/
@@ -14,6 +14,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { readSourceFiles, parseFrontmatter, replacePlaceholders } from './utils.js';
import { ANTIPATTERNS } from '../../cli/engine/registry/antipatterns.mjs';
import {
DETECTION_LAYERS,
VISUAL_EXAMPLES,
@@ -137,19 +138,11 @@ export const COMMAND_RELATIONSHIPS = {
};
/**
* Parse the ANTIPATTERNS array out of cli/engine/detect-antipatterns.mjs.
* Mirrors the trick in scripts/build.js validateAntipatternRules() so we
* don't have to run the browser-only module.
* Read the detector rule registry.
*/
export function readAntipatternRules(rootDir) {
const detectPath = path.join(rootDir, 'cli/engine/detect-antipatterns.mjs');
const src = fs.readFileSync(detectPath, 'utf-8');
const match = src.match(/const ANTIPATTERNS = \[([\s\S]*?)\n\];/);
if (!match) {
throw new Error(`Could not extract ANTIPATTERNS from ${detectPath}`);
}
// eslint-disable-next-line no-new-func
return new Function(`return [${match[1]}]`)();
void rootDir;
return ANTIPATTERNS.slice();
}
/**
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Manual metadata for the /anti-patterns page.
*
* The detection rules themselves live in cli/engine/detect-antipatterns.mjs and
* are parsed at build time. This file adds three pieces of content that
* The detection rules themselves live in cli/engine/registry/antipatterns.mjs.
* This file adds three pieces of content that
* can't be automated:
*
* 1. DETECTION_LAYERS: which layer (cli, browser, or llm) catches the
+6 -2
View File
@@ -12,6 +12,8 @@ interface Props {
noIndex?: boolean;
mainId?: string;
mainClass?: string;
hideHeader?: boolean;
hideFooter?: boolean;
ogTitle?: string;
ogDescription?: string;
ogImage?: string;
@@ -28,6 +30,8 @@ const {
noIndex = false,
mainId = 'main',
mainClass,
hideHeader = false,
hideFooter = false,
ogTitle,
ogDescription,
ogImage,
@@ -81,12 +85,12 @@ const canonical = canonicalPath
<body class={bodyClass}>
<a href={`#${mainId}`} class="skip-link">Skip to content</a>
<slot name="before-header" />
<Header activeNav={activeNav} />
{!hideHeader && <Header activeNav={activeNav} />}
<slot name="after-header" />
<main id={mainId} class={mainClass}>
<slot />
</main>
<Footer />
{!hideFooter && <Footer />}
<slot name="scripts" />
</body>
</html>
@@ -0,0 +1,35 @@
import fs from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
const FIXTURES_DIR = path.join(ROOT, 'tests', 'fixtures');
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(fullPath));
else if (entry.name.endsWith('.css')) out.push(fullPath);
}
return out;
}
export function getStaticPaths() {
return walk(FIXTURES_DIR).map((filePath) => ({
params: {
path: path.relative(FIXTURES_DIR, filePath).replace(/\.css$/, '').split(path.sep).join('/'),
},
props: {
filePath,
},
}));
}
export function GET({ props }: { props: { filePath: string } }) {
return new Response(fs.readFileSync(props.filePath, 'utf-8'), {
headers: {
'Content-Type': 'text/css; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
@@ -0,0 +1,47 @@
import fs from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
const FIXTURES_DIR = path.join(ROOT, 'tests', 'fixtures');
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(fullPath));
else if (entry.name.endsWith('.html')) out.push(fullPath);
}
return out;
}
export function getStaticPaths() {
return walk(FIXTURES_DIR).map((filePath) => ({
params: {
path: path.relative(FIXTURES_DIR, filePath).replace(/\.html$/, '').split(path.sep).join('/'),
},
props: {
filePath,
},
}));
}
function withDetectorScript(html: string) {
if (html.includes('detect-antipatterns-browser.js')) return html;
const script = [
'<script>',
'window.__IMPECCABLE_CONFIG__ = { ...(window.__IMPECCABLE_CONFIG__ || {}), autoScan: true };',
'</script>',
'<script src="/js/detect-antipatterns-browser.js"></script>',
].join('');
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${script}</body>`);
return `${html}${script}`;
}
export function GET({ props }: { props: { filePath: string } }) {
return new Response(withDetectorScript(fs.readFileSync(props.filePath, 'utf-8')), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
+47
View File
@@ -0,0 +1,47 @@
import fs from 'node:fs';
import path from 'node:path';
const ROOT = process.cwd();
const FIXTURES_DIR = path.join(ROOT, 'tests', 'fixtures');
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...walk(fullPath));
else if (entry.name.endsWith('.html')) out.push(fullPath);
}
return out;
}
export function getStaticPaths() {
return walk(FIXTURES_DIR).map((filePath) => ({
params: {
path: path.relative(FIXTURES_DIR, filePath).replace(/\.html$/, '').split(path.sep).join('/'),
},
props: {
filePath,
},
}));
}
function withDetectorScript(html: string) {
if (html.includes('detect-antipatterns-browser.js')) return html;
const script = [
'<script>',
'window.__IMPECCABLE_CONFIG__ = { ...(window.__IMPECCABLE_CONFIG__ || {}), autoScan: true };',
'</script>',
'<script src="/js/detect-antipatterns-browser.js"></script>',
].join('');
if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${script}</body>`);
return `${html}${script}`;
}
export function GET({ props }: { props: { filePath: string } }) {
return new Response(withDetectorScript(fs.readFileSync(props.filePath, 'utf-8')), {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
},
});
}
+832
View File
@@ -0,0 +1,832 @@
---
import fs from 'node:fs';
import path from 'node:path';
import Base from '../../layouts/Base.astro';
import '../../styles/sub-pages.css';
import '../../styles/detector-lab.css';
import {
createDetectorProfile,
detectHtml,
summarizeDetectorProfile,
} from '../../../cli/engine/detect-antipatterns.mjs';
const ROOT = process.cwd();
const FIXTURES_DIR = path.join(ROOT, 'tests', 'fixtures', 'antipatterns');
const FIXTURES = [
{
file: 'border-baseline.html',
group: 'Borders',
title: 'Border side tabs',
summary: 'Side-by-side positive and negative cases for colored side borders and rounded accent borders.',
},
{
file: 'partial-component.html',
group: 'Baseline',
title: 'Partial component',
summary: 'Component-like HTML where page-level rules must stay quiet.',
},
{
file: 'linked-stylesheet.html',
group: 'Static CSS',
title: 'Linked stylesheet',
summary: 'Local CSS inlining, selector matching, and cascade coverage.',
},
{
file: 'modern-color-borders.html',
group: 'Borders',
title: 'Modern color borders',
summary: 'OKLCH, LAB, LCH, HSL, HWB, and custom-property border cases.',
},
{
file: 'color.html',
group: 'Color',
title: 'Color and contrast',
summary: 'Solid contrast, gray-on-color, gradient stops, links, buttons, and emoji skips.',
},
{
file: 'visual-contrast.html',
group: 'Color',
title: 'Visual contrast fallback',
summary: 'Image-background text cases for the Puppeteer pixel-diff contrast pass.',
},
{
file: 'legitimate-borders.html',
group: 'Borders',
title: 'Legitimate borders',
summary: 'Border patterns that should pass despite being visually close to side tabs.',
},
{
file: 'typography.html',
group: 'Typography',
title: 'Typography side by side',
summary: 'Element-level typography checks with visible flag/pass highlights.',
},
{
file: 'quality.html',
group: 'Typography',
title: 'Quality and readability',
summary: 'Text quality checks, plus browser-only line length in the live pane.',
},
{
file: 'italic-serif-display.html',
group: 'Typography',
title: 'Italic serif display',
summary: 'Large decorative serif italics versus legitimate emphasis.',
},
{
file: 'hero-eyebrow-chip.html',
group: 'Typography',
title: 'Hero eyebrow chip',
summary: 'Uppercase micro-labels and pills above hero headings.',
},
{
file: 'layout.html',
group: 'Layout',
title: 'Layout',
summary: 'Nested cards and page-level layout patterns.',
},
{
file: 'repeated-section-kickers.html',
group: 'Layout',
title: 'Repeated section kickers',
summary: 'Repeated scaffolding labels that create AI-page rhythm.',
},
{
file: 'icon-tile-stack.html',
group: 'Layout',
title: 'Icon tile stack',
summary: 'Large icon tiles stacked over headings.',
},
{
file: 'cramped-padding.html',
group: 'Browser layout',
title: 'Cramped padding',
summary: 'Browser-only padding checks that need real element rectangles.',
},
{
file: 'body-text-viewport-edge.html',
group: 'Browser layout',
title: 'Viewport-edge body text',
summary: 'Browser-only body text that bleeds to the viewport edge.',
},
{
file: 'motion.html',
group: 'Motion',
title: 'Motion',
summary: 'Bounce easing and layout-property transitions.',
},
{
file: 'glow.html',
group: 'Motion',
title: 'Dark glow',
summary: 'Chromatic shadows on dark surfaces versus safe shadows.',
},
{
file: 'overlay-positioning.html',
group: 'Overlay',
title: 'Overlay positioning',
summary: 'Edge cases for the browser overlay itself.',
},
];
function nowMs() {
return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
}
function roundMs(value: number) {
return Number(value.toFixed(3));
}
function displayMs(value: number) {
if (!Number.isFinite(value)) return '0 ms';
if (value >= 1000) return `${(value / 1000).toFixed(2)} s`;
if (value < 10) return `${value.toFixed(2)} ms`;
return `${value.toFixed(1)} ms`;
}
function slug(file: string) {
return file.replace(/\.html$/, '');
}
function routeFor(file: string) {
return `/detector/fixtures/antipatterns/${file}`;
}
function countByRule(findings: Array<{ antipattern: string }>) {
const counts = new Map<string, number>();
for (const finding of findings) {
counts.set(finding.antipattern, (counts.get(finding.antipattern) || 0) + 1);
}
return [...counts.entries()]
.map(([ruleId, count]) => ({ ruleId, count }))
.sort((a, b) => b.count - a.count || a.ruleId.localeCompare(b.ruleId));
}
async function measureFixture(meta: typeof FIXTURES[number]) {
const filePath = path.join(FIXTURES_DIR, meta.file);
const profile = createDetectorProfile();
const started = nowMs();
const findings = await detectHtml(filePath, { profile });
const totalMs = roundMs(nowMs() - started);
return {
...meta,
id: slug(meta.file),
url: routeFor(meta.file),
bytes: fs.statSync(filePath).size,
staticTotalMs: totalMs,
staticFindings: findings.length,
findingsByRule: countByRule(findings),
profile: summarizeDetectorProfile(profile)
.map(row => ({
engine: row.engine,
phase: row.phase,
ruleId: row.ruleId,
calls: row.calls,
totalMs: row.totalMs,
avgMs: row.avgMs,
p50: row.p50,
p95: row.p95,
findings: row.findings,
})),
};
}
function percentile(values: number[], pct: number) {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((pct / 100) * sorted.length) - 1));
return sorted[idx];
}
function aggregateProfile(fixtures: Awaited<ReturnType<typeof measureFixture>>[]) {
const groups = new Map<string, {
engine: string;
phase: string;
ruleId: string;
calls: number;
totalMs: number;
findings: number;
samples: number[];
}>();
for (const fixture of fixtures) {
for (const row of fixture.profile) {
const key = `${row.engine}\u0000${row.phase}\u0000${row.ruleId}`;
let group = groups.get(key);
if (!group) {
group = {
engine: row.engine,
phase: row.phase,
ruleId: row.ruleId,
calls: 0,
totalMs: 0,
findings: 0,
samples: [],
};
groups.set(key, group);
}
group.calls += row.calls;
group.totalMs += row.totalMs;
group.findings += row.findings;
group.samples.push(row.p50);
}
}
return [...groups.values()]
.map(group => ({
engine: group.engine,
phase: group.phase,
ruleId: group.ruleId,
calls: group.calls,
totalMs: roundMs(group.totalMs),
avgMs: roundMs(group.totalMs / Math.max(1, group.calls)),
p50: roundMs(percentile(group.samples, 50)),
p95: roundMs(percentile(group.samples, 95)),
findings: group.findings,
}))
.sort((a, b) => b.totalMs - a.totalMs);
}
await detectHtml(path.join(FIXTURES_DIR, 'should-pass.html'), { profile: createDetectorProfile() });
const fixtureData = [];
for (const fixture of FIXTURES) {
fixtureData.push(await measureFixture(fixture));
}
const globalProfile = aggregateProfile(fixtureData);
const totalStaticMs = roundMs(fixtureData.reduce((sum, item) => sum + item.staticTotalMs, 0));
const totalFindings = fixtureData.reduce((sum, item) => sum + item.staticFindings, 0);
const slowestRow = globalProfile[0];
const groups = [...new Set(fixtureData.map(item => item.group))];
const dashboardData = {
generatedAt: new Date().toISOString(),
fixtures: fixtureData,
globalProfile,
};
const dashboardJson = JSON.stringify(dashboardData).replace(/</g, '\\u003c');
---
<Base
title="Detector Lab | Impeccable"
description="A fixture browser and timing dashboard for Impeccable's anti-pattern detector."
bodyClass="sub-page detector-lab-page"
mainClass="detector-main-shell"
noIndex
hideHeader
hideFooter
>
<section class="detector-workbench" aria-label="Detector fixture explorer">
<aside class="detector-fixture-nav" aria-label="Fixture navigation">
<div class="detector-nav-head">
<a class="detector-home-link" href="/" aria-label="Back to Impeccable">
<span>/</span>
<strong>Impeccable</strong>
</a>
<h1>Detector Lab</h1>
<p>Internal fixture browser and speed surface.</p>
<dl class="detector-summary-grid" aria-label="Detector benchmark summary">
<div>
<dt>Fixtures</dt>
<dd>{fixtureData.length}</dd>
</div>
<div>
<dt>Static</dt>
<dd>{displayMs(totalStaticMs)}</dd>
</div>
<div>
<dt>Findings</dt>
<dd>{totalFindings}</dd>
</div>
<div>
<dt>Slowest</dt>
<dd>{slowestRow?.ruleId || 'none'}</dd>
</div>
</dl>
</div>
<nav class="detector-nav-scroll">
{groups.map(group => (
<div class="detector-nav-group">
<h2>{group}</h2>
<div class="detector-nav-list">
{fixtureData.filter(item => item.group === group).map(item => (
<button
type="button"
class="detector-fixture-button"
data-fixture-id={item.id}
aria-pressed={item.id === fixtureData[0].id ? 'true' : 'false'}
>
<span>{item.title}</span>
<span>{item.staticFindings}</span>
</button>
))}
</div>
</div>
))}
</nav>
</aside>
<div class="detector-workspace">
<header class="detector-preview-head">
<div>
<p class="detector-panel-label" data-fixture-group>{fixtureData[0].group}</p>
<h2 data-fixture-title>{fixtureData[0].title}</h2>
<p data-fixture-summary>{fixtureData[0].summary}</p>
</div>
<div class="detector-preview-actions">
<a href={fixtureData[0].url} target="_blank" rel="noopener" data-open-fixture>Open fixture</a>
<button type="button" data-rerun-browser>Run browser scan</button>
</div>
</header>
<div class="detector-workspace-scroll">
<div class="detector-content-grid">
<div class="detector-preview-column">
<div class="detector-frame-wrap">
<iframe
src={fixtureData[0].url}
title={`${fixtureData[0].title} detector fixture`}
data-fixture-frame
></iframe>
</div>
</div>
<aside class="detector-metrics" aria-label="Detector timings">
<section class="detector-metric-panel" aria-labelledby="browser-timing-title">
<div class="detector-panel-head">
<p class="detector-panel-label">Live browser</p>
<h2 id="browser-timing-title">Current iframe scan</h2>
</div>
<dl class="detector-live-stats">
<div>
<dt>Pure detect</dt>
<dd data-browser-ms>waiting</dd>
</div>
<div>
<dt>Findings</dt>
<dd data-browser-findings>waiting</dd>
</div>
<div>
<dt>Visual candidates</dt>
<dd data-browser-visual-candidates>waiting</dd>
</div>
</dl>
<div class="detector-rule-pills" data-browser-rules aria-label="Browser findings by rule"></div>
</section>
<section class="detector-metric-panel" aria-labelledby="static-fixture-title">
<div class="detector-panel-head">
<p class="detector-panel-label">Static HTML/CSS</p>
<h2 id="static-fixture-title">Selected fixture</h2>
</div>
<dl class="detector-live-stats">
<div>
<dt>Total</dt>
<dd data-static-ms>{displayMs(fixtureData[0].staticTotalMs)}</dd>
</div>
<div>
<dt>Findings</dt>
<dd data-static-findings>{fixtureData[0].staticFindings}</dd>
</div>
</dl>
<div class="detector-rule-pills" data-static-rules aria-label="Static findings by rule"></div>
</section>
</aside>
</div>
<section class="detector-tables" aria-label="Detector timing tables">
<div class="detector-table-panel">
<header>
<div>
<p class="detector-panel-label">Per fixture</p>
<h2 data-selected-table-title>{fixtureData[0].title} timings</h2>
</div>
<p>Rows come directly from the profiling API.</p>
</header>
<div class="detector-table-scroll">
<table>
<thead>
<tr>
<th>Phase</th>
<th>Check</th>
<th>Calls</th>
<th>Total</th>
<th>P95</th>
<th>Findings</th>
</tr>
</thead>
<tbody data-selected-timings></tbody>
</table>
</div>
</div>
<div class="detector-table-panel">
<header>
<div>
<p class="detector-panel-label">All fixtures</p>
<h2>Slowest checks</h2>
</div>
<p>Aggregated across every fixture in this lab.</p>
</header>
<div class="detector-table-scroll">
<table>
<thead>
<tr>
<th>Engine</th>
<th>Phase</th>
<th>Check</th>
<th>Calls</th>
<th>Total</th>
<th>P95</th>
<th>Findings</th>
</tr>
</thead>
<tbody>
{globalProfile.slice(0, 24).map(row => (
<tr>
<td>{row.engine}</td>
<td>{row.phase}</td>
<td><code>{row.ruleId}</code></td>
<td>{row.calls}</td>
<td>{row.totalMs} ms</td>
<td>{row.p95} ms</td>
<td>{row.findings}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
</div>
</div>
</section>
<script id="detector-dashboard-data" type="application/json" set:html={dashboardJson}></script>
<script>
const data = JSON.parse(document.getElementById('detector-dashboard-data').textContent);
const fixtures = data.fixtures;
const byId = new Map(fixtures.map(item => [item.id, item]));
const workspaceScrollEl = document.querySelector('.detector-workspace-scroll');
const frame = document.querySelector('[data-fixture-frame]');
const titleEl = document.querySelector('[data-fixture-title]');
const groupEl = document.querySelector('[data-fixture-group]');
const summaryEl = document.querySelector('[data-fixture-summary]');
const openLink = document.querySelector('[data-open-fixture]');
const staticMsEl = document.querySelector('[data-static-ms]');
const staticFindingsEl = document.querySelector('[data-static-findings]');
const staticRulesEl = document.querySelector('[data-static-rules]');
const browserMsEl = document.querySelector('[data-browser-ms]');
const browserFindingsEl = document.querySelector('[data-browser-findings]');
const browserVisualCandidatesEl = document.querySelector('[data-browser-visual-candidates]');
const browserRulesEl = document.querySelector('[data-browser-rules]');
const selectedTimingBody = document.querySelector('[data-selected-timings]');
const selectedTableTitle = document.querySelector('[data-selected-table-title]');
let currentFixtureId = fixtures[0]?.id || '';
let latestBrowserSummary = null;
let detachVisualResolutionListener = null;
function formatMs(value) {
if (!Number.isFinite(value)) return '0 ms';
if (value < 1) return `${value.toFixed(3)} ms`;
if (value < 10) return `${value.toFixed(2)} ms`;
return `${value.toFixed(1)} ms`;
}
function renderPills(container, rows, emptyText) {
container.replaceChildren();
if (!rows || rows.length === 0) {
const empty = document.createElement('span');
empty.className = 'detector-empty-pill';
empty.textContent = emptyText;
container.append(empty);
return;
}
for (const row of rows.slice(0, 12)) {
const pill = document.createElement('span');
pill.className = 'detector-rule-pill';
pill.innerHTML = `<code></code><span></span>`;
pill.querySelector('code').textContent = row.ruleId;
pill.querySelector('span').textContent = row.count;
container.append(pill);
}
}
function countBrowserRules(groups) {
const counts = new Map();
for (const group of groups || []) {
for (const finding of group.findings || []) {
counts.set(finding.type, (counts.get(finding.type) || 0) + 1);
}
}
return [...counts.entries()]
.map(([ruleId, count]) => ({ ruleId, count }))
.sort((a, b) => b.count - a.count || a.ruleId.localeCompare(b.ruleId));
}
function countBrowserFindings(groups) {
return (groups || []).reduce((sum, group) => sum + (group.findings?.length || 0), 0);
}
function renderBrowserSummary(summary) {
const visualAnalyses = summary.visualAnalyses || [];
const visualCandidates = visualAnalyses.length > 0 ? visualAnalyses : (summary.visualCandidates || []);
const visualFailures = visualAnalyses.filter(result => result.status === 'fail').length;
const visualPasses = visualAnalyses.filter(result => result.status === 'pass').length;
const visualUnresolved = visualAnalyses.filter(result => result.status === 'unresolved').length;
const groupFindingCount = countBrowserFindings(summary.groups);
const count = summary.useVisualPipeline
? (summary.nonVisualFindingCount + visualFailures)
: (groupFindingCount + visualFailures);
const ruleRows = countBrowserRules(summary.groups)
.filter(row => !(summary.useVisualPipeline && row.ruleId === 'low-contrast'));
if (visualUnresolved > 0) {
ruleRows.unshift({ ruleId: 'visual-contrast-unresolved', count: visualUnresolved });
}
if (visualPasses > 0) {
ruleRows.unshift({ ruleId: 'visual-contrast-pass', count: visualPasses });
}
if (visualFailures > 0) {
ruleRows.unshift({ ruleId: 'low-contrast:visual', count: visualFailures });
}
if (visualCandidates.length > 0) {
ruleRows.unshift({ ruleId: 'visual-contrast-candidate', count: visualCandidates.length });
}
browserMsEl.textContent = formatMs(summary.elapsed);
browserFindingsEl.textContent = String(count);
browserVisualCandidatesEl.textContent = visualAnalyses.length > 0
? `${visualCandidates.length} (${visualFailures} fail, ${visualPasses} pass, ${visualUnresolved} unresolved, ${formatMs(summary.visualElapsed)})`
: String(visualCandidates.length);
renderPills(browserRulesEl, ruleRows, 'No browser findings');
}
function bindVisualResolutionUpdates(win) {
if (detachVisualResolutionListener) {
detachVisualResolutionListener();
detachVisualResolutionListener = null;
}
const updateSummary = () => {
if (!latestBrowserSummary || latestBrowserSummary.win !== win) return;
const visualAnalyses = typeof win.impeccableGetLastVisualContrastAnalyses === 'function'
? win.impeccableGetLastVisualContrastAnalyses()
: latestBrowserSummary.visualAnalyses;
latestBrowserSummary = {
...latestBrowserSummary,
visualAnalyses,
visualCandidates: visualAnalyses.length > 0 ? visualAnalyses : latestBrowserSummary.visualCandidates,
};
renderBrowserSummary(latestBrowserSummary);
};
win.addEventListener('impeccable-visual-contrast-resolved', updateSummary);
detachVisualResolutionListener = () => {
win.removeEventListener('impeccable-visual-contrast-resolved', updateSummary);
};
}
function renderTimingRows(fixture) {
selectedTimingBody.replaceChildren();
for (const row of fixture.profile.slice(0, 36)) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td></td>
<td><code></code></td>
<td></td>
<td></td>
<td></td>
<td></td>
`;
tr.children[0].textContent = row.phase;
tr.querySelector('code').textContent = row.ruleId;
tr.children[2].textContent = row.calls;
tr.children[3].textContent = `${row.totalMs} ms`;
tr.children[4].textContent = `${row.p95} ms`;
tr.children[5].textContent = row.findings;
selectedTimingBody.append(tr);
}
}
function waitForDetector(win, attempts = 30) {
return new Promise((resolve, reject) => {
const tick = () => {
if (win && typeof win.impeccableDetect === 'function') {
resolve(win);
return;
}
attempts -= 1;
if (attempts <= 0) {
reject(new Error('Detector script did not initialize'));
return;
}
setTimeout(tick, 100);
};
tick();
});
}
function clearLabOverlays(win) {
const doc = win?.document;
if (!doc) return;
for (const node of doc.querySelectorAll('[data-impeccable-lab-overlay]')) {
node.remove();
}
}
function ensureLabOverlayStyle(win) {
const doc = win?.document;
if (!doc || doc.getElementById('impeccable-detector-lab-overlay-style')) return;
const style = doc.createElement('style');
style.id = 'impeccable-detector-lab-overlay-style';
style.setAttribute('data-impeccable-lab-overlay', 'style');
style.textContent = `
[data-impeccable-lab-overlay="box"] {
position: absolute;
z-index: 2147483646;
box-sizing: border-box;
border: 2px solid oklch(65% 0.28 350);
border-radius: 6px;
background: color-mix(in oklch, oklch(65% 0.28 350), transparent 88%);
box-shadow: 0 0 0 1px rgb(255 255 255 / 0.92), 0 0 0 5px rgb(218 0 132 / 0.14);
pointer-events: none;
}
[data-impeccable-lab-overlay="label"] {
position: absolute;
z-index: 2147483647;
max-width: 260px;
padding: 4px 7px;
border-radius: 6px;
background: oklch(42% 0.24 350);
color: white;
font: 700 11px/1.25 system-ui, sans-serif;
letter-spacing: 0;
box-shadow: 0 8px 20px rgb(0 0 0 / 0.22);
pointer-events: none;
}
`;
doc.head.append(style);
}
function overlayLabelFor(row) {
if (row.kind === 'visual') return 'low-contrast visual';
const types = (row.findings || []).map(finding => finding.type).filter(Boolean);
return [...new Set(types)].slice(0, 2).join(', ') || 'finding';
}
function renderLabOverlays(win, groups, visualAnalyses, options = {}) {
const doc = win?.document;
if (!doc?.body) return;
clearLabOverlays(win);
if (!options.showVisualOverlays) return;
ensureLabOverlayStyle(win);
const existingFindingSelectors = new Set(
(groups || [])
.filter(group => group.selector && group.findings?.length)
.map(group => group.selector)
);
const rows = [];
for (const result of visualAnalyses || []) {
if (result.status !== 'fail' || !result.selector) continue;
if (existingFindingSelectors.has(result.selector)) continue;
rows.push({ selector: result.selector, kind: 'visual' });
}
for (const row of rows) {
let el = null;
try {
el = doc.querySelector(row.selector);
} catch {
el = null;
}
if (el?._impeccableOverlay) continue;
if (!el || !el.getBoundingClientRect) continue;
const rect = el.getBoundingClientRect();
if (rect.width < 2 || rect.height < 2) continue;
const left = rect.left + win.scrollX;
const top = rect.top + win.scrollY;
const box = doc.createElement('div');
box.setAttribute('data-impeccable-lab-overlay', 'box');
box.style.left = `${Math.max(0, left - 3)}px`;
box.style.top = `${Math.max(0, top - 3)}px`;
box.style.width = `${Math.max(4, rect.width + 6)}px`;
box.style.height = `${Math.max(4, rect.height + 6)}px`;
doc.body.append(box);
const label = doc.createElement('div');
label.setAttribute('data-impeccable-lab-overlay', 'label');
label.textContent = overlayLabelFor(row);
label.style.left = `${Math.max(0, left - 3)}px`;
label.style.top = `${Math.max(0, top - 28)}px`;
doc.body.append(label);
}
}
async function runBrowserScan() {
browserMsEl.textContent = 'scanning';
browserFindingsEl.textContent = 'scanning';
browserVisualCandidatesEl.textContent = 'scanning';
browserRulesEl.replaceChildren();
try {
const win = await waitForDetector(frame.contentWindow);
const restoreWorkspaceScroll = workspaceScrollEl?.scrollTop || 0;
const restoreFrameScroll = { x: win.scrollX, y: win.scrollY };
clearLabOverlays(win);
const useVisualPipeline = currentFixtureId === 'visual-contrast';
const started = performance.now();
const groups = useVisualPipeline && typeof win.impeccableScanAsync === 'function'
? await win.impeccableScanAsync({ visualContrast: true, visualContrastMaxCandidates: 99 })
: await win.impeccableDetect({ decorate: false, serialize: true });
const elapsed = performance.now() - started;
const visualStarted = performance.now();
const visualAnalyses = useVisualPipeline && typeof win.impeccableGetLastVisualContrastAnalyses === 'function'
? win.impeccableGetLastVisualContrastAnalyses()
: (typeof win.impeccableAnalyzeVisualContrast === 'function'
? await win.impeccableAnalyzeVisualContrast({ maxCandidates: 99, scrollOffscreen: false })
: []);
const visualElapsed = useVisualPipeline ? elapsed : performance.now() - visualStarted;
const visualCandidates = visualAnalyses.length > 0
? visualAnalyses
: (typeof win.impeccableCollectVisualContrastCandidates === 'function'
? win.impeccableCollectVisualContrastCandidates({ maxCandidates: 99 })
: []);
const visualFailures = visualAnalyses.filter(result => result.status === 'fail').length;
const nonVisualFindingCount = useVisualPipeline
? Math.max(0, countBrowserFindings(groups) - visualFailures)
: countBrowserFindings(groups);
if (workspaceScrollEl) workspaceScrollEl.scrollTop = restoreWorkspaceScroll;
win.scrollTo(restoreFrameScroll.x, restoreFrameScroll.y);
latestBrowserSummary = {
win,
groups,
visualAnalyses,
visualCandidates,
elapsed,
visualElapsed,
useVisualPipeline,
nonVisualFindingCount,
};
bindVisualResolutionUpdates(win);
renderBrowserSummary(latestBrowserSummary);
renderLabOverlays(win, groups, visualAnalyses, {
showVisualOverlays: currentFixtureId === 'visual-contrast',
});
} catch (err) {
browserMsEl.textContent = 'unavailable';
browserFindingsEl.textContent = '0';
browserVisualCandidatesEl.textContent = '0';
renderPills(browserRulesEl, [], err.message || 'No browser data');
}
}
function selectFixture(id, updateHash = true) {
const fixture = byId.get(id) || fixtures[0];
currentFixtureId = fixture.id;
for (const button of document.querySelectorAll('[data-fixture-id]')) {
button.setAttribute('aria-pressed', button.dataset.fixtureId === fixture.id ? 'true' : 'false');
}
titleEl.textContent = fixture.title;
groupEl.textContent = fixture.group;
summaryEl.textContent = fixture.summary;
openLink.href = fixture.url;
staticMsEl.textContent = formatMs(fixture.staticTotalMs);
staticFindingsEl.textContent = String(fixture.staticFindings);
selectedTableTitle.textContent = `${fixture.title} timings`;
renderPills(staticRulesEl, fixture.findingsByRule, 'No static findings');
renderTimingRows(fixture);
if (workspaceScrollEl) workspaceScrollEl.scrollTop = 0;
browserMsEl.textContent = 'loading';
browserFindingsEl.textContent = 'loading';
browserVisualCandidatesEl.textContent = 'loading';
browserRulesEl.replaceChildren();
latestBrowserSummary = null;
if (detachVisualResolutionListener) {
detachVisualResolutionListener();
detachVisualResolutionListener = null;
}
frame.title = `${fixture.title} detector fixture`;
frame.src = fixture.url;
if (updateHash) history.replaceState(null, '', `#${fixture.id}`);
}
frame.addEventListener('load', () => {
try {
frame.contentWindow?.scrollTo(0, 0);
} catch {
// Ignore iframe scroll reset failures.
}
setTimeout(runBrowserScan, 160);
});
document.querySelector('[data-rerun-browser]').addEventListener('click', runBrowserScan);
for (const button of document.querySelectorAll('[data-fixture-id]')) {
button.addEventListener('click', () => selectFixture(button.dataset.fixtureId));
}
window.addEventListener('hashchange', () => {
const nextId = location.hash ? location.hash.slice(1) : fixtures[0].id;
selectFixture(byId.has(nextId) ? nextId : fixtures[0].id, false);
});
const initialId = location.hash ? location.hash.slice(1) : fixtures[0].id;
selectFixture(byId.has(initialId) ? initialId : fixtures[0].id, false);
</script>
</Base>
+551
View File
@@ -0,0 +1,551 @@
.detector-lab-page {
background: var(--color-cream);
overflow: hidden;
}
.detector-main-shell {
height: 100vh;
min-height: 0;
}
.detector-workbench {
--detector-sidebar-width: 320px;
height: 100vh;
min-height: 0;
background: var(--color-cream);
}
.detector-fixture-nav {
position: fixed;
inset: 0 auto 0 0;
z-index: 10;
display: flex;
flex-direction: column;
width: var(--detector-sidebar-width);
border-right: 1px solid var(--color-mist);
background: var(--color-paper);
}
.detector-nav-head {
flex-shrink: 0;
padding: var(--spacing-md);
border-bottom: 1px solid var(--color-mist);
}
.detector-home-link {
display: inline-flex;
align-items: center;
gap: 9px;
color: var(--color-ink);
text-decoration: none;
}
.detector-home-link span {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--color-ink);
color: var(--color-paper);
font-family: var(--font-body);
font-weight: 700;
line-height: 1;
}
.detector-home-link strong {
color: var(--color-ink);
font-family: var(--font-display);
font-size: 1.125rem;
font-weight: 600;
}
.detector-nav-head h1 {
margin-top: var(--spacing-md);
font-family: var(--font-body);
font-size: 1.25rem;
font-weight: 800;
line-height: 1.15;
letter-spacing: 0;
}
.detector-nav-head > p {
margin-top: 6px;
color: var(--color-charcoal);
font-size: 0.875rem;
line-height: 1.45;
}
.detector-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: var(--spacing-md);
border: 1px solid var(--color-mist);
}
.detector-summary-grid div {
min-width: 0;
padding: 10px;
border-right: 1px solid var(--color-mist);
border-bottom: 1px solid var(--color-mist);
}
.detector-summary-grid div:nth-child(2n) {
border-right: 0;
}
.detector-summary-grid div:nth-last-child(-n + 2) {
border-bottom: 0;
}
.detector-summary-grid dt,
.detector-panel-label,
.detector-nav-group h2,
.detector-live-stats dt {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-ash);
}
.detector-summary-grid dd {
margin-top: 4px;
overflow-wrap: anywhere;
color: var(--color-ink);
font-size: 1rem;
font-weight: 700;
line-height: 1.15;
}
.detector-nav-scroll {
min-height: 0;
overflow: auto;
padding-bottom: var(--spacing-md);
}
.detector-nav-group {
padding: var(--spacing-sm);
border-bottom: 1px solid var(--color-mist);
}
.detector-nav-group:last-child {
border-bottom: 0;
}
.detector-nav-list {
display: grid;
gap: 4px;
margin-top: 10px;
}
.detector-fixture-button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--spacing-sm);
align-items: center;
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--color-charcoal);
text-align: left;
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.detector-fixture-button:hover {
background: var(--color-cream);
color: var(--color-ink);
}
.detector-fixture-button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.detector-fixture-button[aria-pressed="true"] {
background: var(--color-ink);
border-color: var(--color-ink);
color: var(--color-paper);
}
.detector-fixture-button span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 700;
}
.detector-fixture-button span:last-child {
min-width: 2ch;
font-family: var(--font-mono);
font-size: 0.75rem;
color: currentColor;
text-align: right;
opacity: 0.72;
}
.detector-workspace {
display: flex;
flex-direction: column;
min-width: 0;
height: 100vh;
min-height: 0;
margin-left: var(--detector-sidebar-width);
overflow: hidden;
}
.detector-preview-head {
position: relative;
z-index: 8;
display: flex;
flex-shrink: 0;
justify-content: space-between;
gap: var(--spacing-md);
align-items: center;
min-height: 72px;
padding: 12px var(--spacing-md);
border-bottom: 1px solid var(--color-mist);
background: var(--color-paper);
}
.detector-preview-head h2,
.detector-panel-head h2,
.detector-table-panel h2 {
font-family: var(--font-body);
font-size: 1rem;
font-weight: 800;
line-height: 1.2;
letter-spacing: 0;
color: var(--color-ink);
}
.detector-preview-head p:not(.detector-panel-label) {
max-width: 78ch;
margin-top: 4px;
color: var(--color-charcoal);
font-size: 0.875rem;
line-height: 1.45;
}
.detector-preview-actions {
display: flex;
align-items: center;
gap: var(--spacing-xs);
flex-shrink: 0;
}
.detector-preview-actions a,
.detector-preview-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--color-mist);
border-radius: 6px;
background: var(--color-paper);
color: var(--color-ink);
font-size: 0.8125rem;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition:
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.detector-preview-actions a:hover,
.detector-preview-actions button:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.detector-content-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
align-items: start;
}
.detector-preview-column,
.detector-metric-panel,
.detector-table-panel {
min-width: 0;
border: 1px solid var(--color-mist);
background: var(--color-paper);
}
.detector-frame-wrap {
background: oklch(95% 0.004 350);
}
.detector-frame-wrap iframe {
display: block;
width: 100%;
height: max(360px, calc(100vh - 156px));
min-height: 0;
border: 0;
background: var(--color-paper);
}
.detector-workspace-scroll {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.detector-metrics {
position: sticky;
top: var(--spacing-sm);
display: grid;
gap: var(--spacing-sm);
max-height: calc(100vh - 104px);
overflow: auto;
}
.detector-metric-panel {
overflow: hidden;
}
.detector-panel-head {
padding: var(--spacing-sm);
border-bottom: 1px solid var(--color-mist);
}
.detector-live-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.detector-live-stats div {
padding: var(--spacing-sm);
border-right: 1px solid var(--color-mist);
border-bottom: 1px solid var(--color-mist);
}
.detector-live-stats div:nth-child(2n) {
border-right: 0;
}
.detector-live-stats dd {
margin-top: 4px;
font-size: 1.125rem;
font-weight: 800;
color: var(--color-ink);
}
.detector-rule-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: var(--spacing-sm);
}
.detector-rule-pill,
.detector-empty-pill {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 5px 7px;
border: 1px solid var(--color-mist);
border-radius: 6px;
background: var(--color-cream);
color: var(--color-charcoal);
font-size: 0.75rem;
line-height: 1.2;
}
.detector-rule-pill code {
min-width: 0;
padding: 0;
overflow: hidden;
background: transparent;
color: var(--color-ink);
text-overflow: ellipsis;
white-space: nowrap;
}
.detector-rule-pill span {
font-family: var(--font-mono);
color: var(--color-accent);
}
.detector-empty-pill {
color: var(--color-ash);
}
.detector-tables {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--spacing-sm);
padding: 0 var(--spacing-sm) var(--spacing-sm);
}
.detector-table-panel header {
display: flex;
justify-content: space-between;
gap: var(--spacing-md);
padding: var(--spacing-sm);
border-bottom: 1px solid var(--color-mist);
}
.detector-table-panel header p:not(.detector-panel-label) {
max-width: 28ch;
color: var(--color-ash);
font-size: 0.8125rem;
line-height: 1.45;
text-align: right;
}
.detector-table-scroll {
max-height: 420px;
overflow: auto;
}
.detector-table-panel table {
width: 100%;
min-width: 620px;
border-collapse: collapse;
font-size: 0.8125rem;
}
.detector-table-panel th,
.detector-table-panel td {
padding: 8px 10px;
border-bottom: 1px solid var(--color-mist);
color: var(--color-charcoal);
text-align: left;
vertical-align: top;
}
.detector-table-panel th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-paper);
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-ash);
}
.detector-table-panel td code {
padding: 0;
background: transparent;
color: var(--color-ink);
font-size: 0.8125rem;
}
@media (max-width: 1180px) {
.detector-workbench {
--detector-sidebar-width: 280px;
}
.detector-content-grid {
grid-template-columns: minmax(0, 1fr);
}
.detector-metrics {
position: static;
grid-template-columns: repeat(2, minmax(0, 1fr));
max-height: none;
overflow: visible;
}
}
@media (max-width: 860px) {
.detector-lab-page {
overflow: auto;
}
.detector-main-shell,
.detector-workbench,
.detector-workspace {
height: auto;
min-height: 100vh;
overflow: visible;
}
.detector-fixture-nav {
position: static;
width: auto;
border-right: 0;
border-bottom: 1px solid var(--color-mist);
}
.detector-nav-scroll {
max-height: 340px;
}
.detector-workspace {
margin-left: 0;
}
.detector-workspace-scroll {
overflow: visible;
}
.detector-preview-head {
position: static;
flex-direction: column;
align-items: stretch;
}
.detector-preview-actions {
flex-wrap: wrap;
}
.detector-metrics,
.detector-tables {
grid-template-columns: 1fr;
}
.detector-frame-wrap iframe {
height: 560px;
}
.detector-table-panel header {
flex-direction: column;
}
.detector-table-panel header p:not(.detector-panel-label) {
max-width: none;
text-align: left;
}
}
@media (max-width: 520px) {
.detector-nav-head,
.detector-content-grid,
.detector-tables {
padding-left: var(--spacing-sm);
padding-right: var(--spacing-sm);
}
.detector-summary-grid,
.detector-live-stats {
grid-template-columns: 1fr;
}
.detector-summary-grid div,
.detector-live-stats div {
border-right: 0;
}
.detector-summary-grid div:nth-last-child(2) {
border-bottom: 1px solid var(--color-mist);
}
}
+552 -14
View File
@@ -1,11 +1,10 @@
/**
* Puppeteer-backed fixture tests for browser-only detection rules.
*
* Some detection rules (cramped-padding, line-length, tight-leading,
* skipped-heading, justified-text, tiny-text, all-caps-body, wide-tracking,
* small-target) need real browser layout — they read getBoundingClientRect
* and getComputedStyle results that jsdom can't compute. Those rules can't
* be tested with the jsdom suite in detect-antipatterns-fixtures.test.mjs.
* Some detection rules (cramped-padding, line-length, body-text-viewport-edge)
* need real browser layout — they read getBoundingClientRect and real
* getComputedStyle results that the static HTML/CSS engine intentionally
* does not invent.
*
* This file uses detectUrl() (Puppeteer) to load fixtures in headless Chrome
* via a temporary static HTTP server, so the fixtures can use absolute
@@ -20,12 +19,10 @@ import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { detectUrl } from '../cli/engine/detect-antipatterns.mjs';
import { createBrowserDetector, detectUrl } from '../cli/engine/detect-antipatterns.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const PORT = 8765;
const BASE = `http://localhost:${PORT}`;
const MIME = {
'.html': 'text/html; charset=utf-8',
@@ -37,6 +34,7 @@ const MIME = {
};
let server;
let baseUrl;
before(async () => {
// Static server: maps /fixtures/* to tests/fixtures/* and
@@ -61,22 +59,29 @@ before(async () => {
res.writeHead(404).end();
}
});
await new Promise((resolve) => server.listen(PORT, resolve));
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
server.off('error', reject);
baseUrl = `http://127.0.0.1:${server.address().port}`;
resolve();
});
});
});
after(async () => {
await new Promise((resolve) => server.close(resolve));
if (server?.listening) await new Promise((resolve) => server.close(resolve));
});
describe('detectUrl — browser-only fixtures', () => {
// Only two rules genuinely need real browser layout (getBoundingClientRect):
// line-length → reads rect.width to compute chars-per-line
// cramped-padding → reads rect.width/height to filter small badges
// Everything else in the quality.html fixture runs in jsdom and is asserted
// Everything else in the quality.html fixture runs in static HTML/CSS and is asserted
// by tests/detect-antipatterns-fixtures.test.mjs.
it('cramped-padding: flag column triggers all 8 cramped cases, pass column adds none', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/cramped-padding.html`);
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/cramped-padding.html`);
const cramped = f.filter(r => r.antipattern === 'cramped-padding');
// Flag column has 8 cases that should fire under the asymmetric
// proportional rule (vertical: max(4, fs×0.3), horizontal: max(8, fs×0.5)):
@@ -94,12 +99,49 @@ describe('detectUrl — browser-only fixtures', () => {
});
it('line-length: flag column triggers, pass column adds none', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/quality.html`);
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
assert.equal(f.filter(r => r.antipattern === 'line-length').length, 1);
});
it('typography side-by-side: element-level flag cases get regular overlays', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/typography.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(() => {
const groups = window.impeccableScan();
const types = groups.flatMap(group => group.findings.map(finding => finding.type || finding.id));
return {
types,
pageTypes: groups
.filter(group => group.el === document.body || group.el === document.documentElement)
.flatMap(group => group.findings.map(finding => finding.type || finding.id)),
hasBanner: Boolean(document.querySelector('.impeccable-banner')),
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
};
});
for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
assert.ok(result.types.includes(id), `expected browser typography scan to include ${id}: ${JSON.stringify(result)}`);
}
assert.ok(result.pageTypes.includes('overused-font'), `expected browser typography scan to include page-level overused-font: ${JSON.stringify(result)}`);
assert.equal(result.hasBanner, true, `expected page-level typography banner: ${JSON.stringify(result)}`);
assert.ok(result.overlays >= 5, `expected visible typography overlays, got: ${JSON.stringify(result)}`);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('body-text-viewport-edge: 3 flag paragraphs/list-items, 0 pass cases', async () => {
const f = await detectUrl(`${BASE}/fixtures/antipatterns/body-text-viewport-edge.html`);
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`);
const edges = f.filter(r => r.antipattern === 'body-text-viewport-edge');
// Fixture has 3 escape-styled <p>/<li> paragraphs that bleed to
// the viewport edges. The pass column has 5 paragraphs that
@@ -107,4 +149,500 @@ describe('detectUrl — browser-only fixtures', () => {
// inside section with own background, short label < 40 chars).
assert.equal(edges.length, 3, `expected 3 body-text-viewport-edge findings, got ${edges.length}: ${JSON.stringify(edges.map(e => e.snippet))}`);
});
it('visual contrast: browser fallback catches low contrast on image backgrounds', async () => {
const analyticOnly = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
waitUntil: 'load',
visualContrast: false,
});
assert.equal(
analyticOnly.some(r => r.antipattern === 'low-contrast' && /White text on light image/i.test(r.snippet || '')),
false,
'analytic contrast should not guess image-background contrast',
);
const f = await detectUrl(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, {
waitUntil: 'load',
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const visualFindings = f.filter(r =>
r.antipattern === 'low-contrast' &&
/(?:browser|pixel) contrast/i.test(r.snippet || '')
);
assert.equal(
visualFindings.length,
4,
`expected 4 visual contrast findings, got ${visualFindings.length}: ${JSON.stringify(visualFindings.map(r => r.snippet))}`,
);
assert.ok(
f.some(r =>
r.antipattern === 'low-contrast' &&
/(?:browser|pixel) contrast/i.test(r.snippet || '') &&
/White text on light image/i.test(r.snippet || '')
),
`expected visual contrast finding for light image background, got: ${JSON.stringify(f.map(r => r.snippet))}`,
);
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /Dark text on dark image/i.test(r.snippet || '')),
'expected pixel contrast finding for dark text on dark image',
);
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /Translucent white text on a pale pattern/i.test(r.snippet || '')),
'expected pixel contrast finding for translucent text on pale pattern',
);
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /Muted gray text on a misty image/i.test(r.snippet || '')),
'expected pixel contrast finding for muted gray text on misty image',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /White text on dark image/i.test(r.snippet || '')),
false,
'dark image background should keep enough contrast',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /Dark text on light image/i.test(r.snippet || '')),
false,
'light image with dark text should keep enough contrast',
);
assert.equal(
f.some(r => r.antipattern === 'low-contrast' && /Should (?:flag|pass) after pixel sampling/i.test(r.snippet || '')),
false,
'fixture column headings should not be low-contrast findings',
);
});
it('browser API: visual contrast fallback resolves readable image backgrounds without overlays', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
const analyses = await window.impeccableAnalyzeVisualContrast({ maxCandidates: 20, scrollOffscreen: true });
const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
return {
before,
after,
failed: analyses.filter(item => item.status === 'fail').map(item => item.finding?.snippet || ''),
passed: analyses.filter(item => item.status === 'pass').map(item => item.text || ''),
unresolved: analyses.filter(item => item.status === 'unresolved').map(item => item.reason || ''),
};
});
assert.equal(result.before, 0);
assert.equal(result.after, 0);
assert.equal(result.failed.length, 4, `expected 4 browser visual failures, got: ${JSON.stringify(result)}`);
assert.ok(result.failed.some(snippet => /White text on light image/i.test(snippet)));
assert.ok(result.failed.some(snippet => /Dark text on dark image/i.test(snippet)));
assert.ok(result.failed.every(snippet => /browser contrast/i.test(snippet)));
assert.ok(result.passed.some(text => /White text on dark image/i.test(text)));
assert.ok(result.passed.some(text => /Dark text on light image/i.test(text)));
} finally {
await browser.close().catch(() => {});
}
});
it('browser API: visual contrast scan decorates visible findings without scrolling by default', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
let scrollEvents = 0;
let maxScrollY = window.scrollY;
window.addEventListener('scroll', () => {
scrollEvents += 1;
maxScrollY = Math.max(maxScrollY, window.scrollY);
}, { passive: true });
const syncScanResult = window.impeccableScan({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const syncDetectResult = window.impeccableDetect({
visualContrast: true,
serialize: true,
});
const groups = await window.impeccableScanAsync({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
await new Promise(resolve => setTimeout(resolve, 50));
return {
groups: groups.map(group => ({
text: group.el.textContent || '',
types: group.findings.map(finding => finding.type || finding.id),
})),
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
labels: document.querySelectorAll('.impeccable-label').length,
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
scrollEvents,
maxScrollY,
finalScrollY: window.scrollY,
syncScanIsArray: Array.isArray(syncScanResult),
syncDetectIsArray: Array.isArray(syncDetectResult),
hasAsyncApi: typeof window.impeccableScanAsync === 'function' && typeof window.impeccableDetectAsync === 'function',
};
});
const visualGroups = result.groups.filter(group =>
group.types.includes('low-contrast') &&
/(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
);
assert.equal(result.analyses, 3, `expected 3 viewport visual failures, got: ${JSON.stringify(result)}`);
assert.equal(visualGroups.length, 3, `expected 3 viewport visual groups, got: ${JSON.stringify(result)}`);
assert.ok(result.overlays >= 3, `expected regular overlays for visible visual findings, got: ${JSON.stringify(result)}`);
assert.ok(result.labels >= 3, `expected regular labels for visible visual findings, got: ${JSON.stringify(result)}`);
assert.equal(result.maxScrollY, 0, `visual scan should not scroll the page by default: ${JSON.stringify(result)}`);
assert.equal(result.finalScrollY, 0, `visual scan should preserve scroll by default: ${JSON.stringify(result)}`);
assert.equal(result.syncScanIsArray, true, `impeccableScan should keep a synchronous Array return: ${JSON.stringify(result)}`);
assert.equal(result.syncDetectIsArray, true, `impeccableDetect should keep a synchronous Array return: ${JSON.stringify(result)}`);
assert.equal(result.hasAsyncApi, true, `visual contrast should expose explicit async APIs: ${JSON.stringify(result)}`);
const refreshedOverlayResult = await page.evaluate(async () => {
window.scrollTo(0, 0);
const target = [...document.querySelectorAll('p')]
.find(node => /White text on light image should be sampled/i.test(node.textContent || ''));
target.style.fontSize = '10px';
const initialGroups = window.impeccableScan({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const initialTargetGroup = initialGroups.find(group => group.el === target);
const deadline = Date.now() + 1000;
while (
Date.now() < deadline &&
!/low contrast/i.test(target?._impeccableOverlay?.textContent || '')
) {
const nextButton = target?._impeccableOverlay?.querySelector('button:last-of-type');
if (nextButton) nextButton.click();
await new Promise(resolve => setTimeout(resolve, 25));
}
const labelVariants = [];
const overlay = target?._impeccableOverlay;
for (let i = 0; i < 3; i++) {
labelVariants.push(overlay?.textContent || '');
overlay?.querySelector('button:last-of-type')?.click();
await new Promise(resolve => setTimeout(resolve, 0));
}
return {
initialTypes: initialTargetGroup?.findings.map(finding => finding.type || finding.id) || [],
labelText: target?._impeccableOverlay?.textContent || '',
labelVariants,
overlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
};
});
assert.ok(refreshedOverlayResult.initialTypes.includes('tiny-text'), `test setup should create an initial sync overlay on the target: ${JSON.stringify(refreshedOverlayResult)}`);
assert.ok(refreshedOverlayResult.labelVariants.some(text => /tiny body text/i.test(text)), `expected refreshed overlay to keep the sync finding label: ${JSON.stringify(refreshedOverlayResult)}`);
assert.ok(refreshedOverlayResult.labelVariants.some(text => /low contrast/i.test(text)), `expected visual contrast to refresh the existing overlay label: ${JSON.stringify(refreshedOverlayResult)}`);
assert.equal(refreshedOverlayResult.overlayConnected, true, `expected refreshed overlay to stay connected: ${JSON.stringify(refreshedOverlayResult)}`);
const lazyResult = await page.evaluate(async () => {
const target = [...document.querySelectorAll('p')]
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 250));
return {
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
labels: document.querySelectorAll('.impeccable-label').length,
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
targetHasOverlay: Boolean(target?._impeccableOverlay),
scrollY: window.scrollY,
};
});
assert.equal(lazyResult.analyses, 4, `expected lazy visual resolution after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
assert.ok(lazyResult.overlays >= 4, `expected lazy visual overlay after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
assert.ok(lazyResult.labels >= 4, `expected lazy visual label after scrolling into view, got: ${JSON.stringify(lazyResult)}`);
assert.equal(lazyResult.targetHasOverlay, true, `expected lazy visual target to get a regular overlay, got: ${JSON.stringify(lazyResult)}`);
assert.ok(lazyResult.scrollY > 0, `test should have naturally scrolled to the offscreen case: ${JSON.stringify(lazyResult)}`);
const staleOverlayResult = await page.evaluate(async () => {
const target = [...document.querySelectorAll('p')]
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
window.scrollTo(0, 0);
await new Promise(resolve => setTimeout(resolve, 50));
await window.impeccableScanAsync({
visualContrast: true,
visualContrastMaxCandidates: 20,
});
const staleCleared = !target?._impeccableOverlay;
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 250));
return {
staleCleared,
targetHasOverlay: Boolean(target?._impeccableOverlay),
targetOverlayConnected: Boolean(target?._impeccableOverlay?.isConnected),
overlays: document.querySelectorAll('.impeccable-overlay:not(.impeccable-banner)').length,
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
};
});
assert.equal(staleOverlayResult.staleCleared, true, `expected clearOverlays to remove stale target overlay refs, got: ${JSON.stringify(staleOverlayResult)}`);
assert.equal(staleOverlayResult.targetHasOverlay, true, `expected lazy visual target to be highlightable after a rescan, got: ${JSON.stringify(staleOverlayResult)}`);
assert.equal(staleOverlayResult.targetOverlayConnected, true, `expected lazy visual overlay after rescan to be connected, got: ${JSON.stringify(staleOverlayResult)}`);
const offscreenResult = await page.evaluate(async () => {
window.scrollTo(0, 0);
let maxScrollY = window.scrollY;
window.addEventListener('scroll', () => {
maxScrollY = Math.max(maxScrollY, window.scrollY);
}, { passive: true });
const groups = await window.impeccableScanAsync({
visualContrast: true,
visualContrastMaxCandidates: 20,
visualContrastScrollOffscreen: true,
});
await new Promise(resolve => setTimeout(resolve, 50));
return {
groups: groups.map(group => ({
text: group.el.textContent || '',
types: group.findings.map(finding => finding.type || finding.id),
})),
analyses: window.impeccableGetLastVisualContrastAnalyses().filter(item => item.status === 'fail').length,
maxScrollY,
finalScrollY: window.scrollY,
};
});
const offscreenVisualGroups = offscreenResult.groups.filter(group =>
group.types.includes('low-contrast') &&
/(?:White text on light image|Dark text on dark image|Translucent white text|Muted gray text)/i.test(group.text)
);
assert.equal(offscreenResult.analyses, 4, `expected 4 opt-in visual failures, got: ${JSON.stringify(offscreenResult)}`);
assert.equal(offscreenVisualGroups.length, 4, `expected 4 opt-in visual groups, got: ${JSON.stringify(offscreenResult)}`);
assert.ok(offscreenResult.maxScrollY > 0, `offscreen opt-in should be allowed to scroll: ${JSON.stringify(offscreenResult)}`);
assert.equal(offscreenResult.finalScrollY, 0, `offscreen opt-in should restore scroll: ${JSON.stringify(offscreenResult)}`);
} finally {
await browser.close().catch(() => {});
}
});
it('extension mode remove cancels pending lazy visual contrast work', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => {
document.documentElement.dataset.impeccableExtension = 'true';
window.__impeccableMessages = [];
window.addEventListener('message', event => {
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
window.__impeccableMessages.push(event.data);
});
});
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: {
visualContrast: true,
visualContrastMaxCandidates: 20,
},
}, '*');
const scanDeadline = Date.now() + 1000;
while (
Date.now() < scanDeadline &&
!window.impeccableGetLastVisualContrastAnalyses()
.some(item => item.status === 'unresolved' && item.reason === 'text outside viewport')
) {
await new Promise(resolve => setTimeout(resolve, 25));
}
const unresolvedBeforeRemove = window.impeccableGetLastVisualContrastAnalyses()
.filter(item => item.status === 'unresolved' && item.reason === 'text outside viewport').length;
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
await new Promise(resolve => setTimeout(resolve, 50));
const target = [...document.querySelectorAll('p')]
.find(node => /Muted gray text on a misty image/i.test(node.textContent || ''));
target?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
await new Promise(resolve => setTimeout(resolve, 300));
const resultsAfterRemove = window.__impeccableMessages
.filter(message => message.source === 'impeccable-results').length;
return {
unresolvedBeforeRemove,
overlayCount: document.querySelectorAll('.impeccable-overlay').length,
targetHasOverlay: Boolean(target?._impeccableOverlay),
resultsAfterRemove,
};
});
assert.ok(result.unresolvedBeforeRemove > 0, `test setup should leave lazy visual candidates pending: ${JSON.stringify(result)}`);
assert.equal(result.overlayCount, 0, `remove should not allow lazy visual overlays to reappear: ${JSON.stringify(result)}`);
assert.equal(result.targetHasOverlay, false, `remove should clear stale target overlay refs: ${JSON.stringify(result)}`);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('extension mode reports async visual contrast errors to the panel', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/visual-contrast.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => {
document.documentElement.dataset.impeccableExtension = 'true';
window.__impeccableMessages = [];
window.addEventListener('message', event => {
if (event.source !== window || !event.data?.source?.startsWith('impeccable-')) return;
window.__impeccableMessages.push(event.data);
});
});
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
const originalGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function getContext() {
throw new Error('forced visual contrast canvas failure');
};
try {
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: {
visualContrast: true,
visualContrastMaxCandidates: 20,
},
}, '*');
const deadline = Date.now() + 1000;
while (
Date.now() < deadline &&
!window.__impeccableMessages.some(message => message.source === 'impeccable-error')
) {
await new Promise(resolve => setTimeout(resolve, 25));
}
return {
ready: window.__impeccableMessages.some(message => message.source === 'impeccable-ready'),
results: window.__impeccableMessages.some(message => message.source === 'impeccable-results'),
errors: window.__impeccableMessages
.filter(message => message.source === 'impeccable-error')
.map(message => message.message || ''),
};
} finally {
HTMLCanvasElement.prototype.getContext = originalGetContext;
}
});
assert.equal(result.ready, true, `expected extension ready message, got: ${JSON.stringify(result)}`);
assert.equal(result.results, true, `expected initial sync results before async visual error, got: ${JSON.stringify(result)}`);
assert.ok(
result.errors.some(message => /forced visual contrast canvas failure/.test(message)),
`expected extension visual contrast error message, got: ${JSON.stringify(result)}`,
);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('browser API: impeccableDetect is pure, impeccableScan decorates', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const pure = await page.evaluate(() => {
const before = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
const findings = window.impeccableDetect({ decorate: false, serialize: true });
const after = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
return { before, after, count: findings.length };
});
assert.equal(pure.before, 0);
assert.equal(pure.after, 0);
assert.ok(pure.count > 0);
const decorated = await page.evaluate(() => {
const groups = window.impeccableScan();
const overlays = document.querySelectorAll('.impeccable-overlay, .impeccable-label, .impeccable-banner').length;
return { groups: groups.length, overlays };
});
assert.ok(decorated.groups > 0);
assert.ok(decorated.overlays > 0);
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('browser API: async scan and detect reject instead of throwing synchronously', async () => {
const puppeteer = await import('puppeteer');
const browser = await puppeteer.default.launch({
headless: true,
args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [],
});
try {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
await page.goto(`${baseUrl}/fixtures/antipatterns/quality.html`, { waitUntil: 'load' });
const browserScript = fs.readFileSync(path.join(ROOT, 'cli/engine/detect-antipatterns-browser.js'), 'utf-8');
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(browserScript);
const result = await page.evaluate(async () => {
const originalQuerySelectorAll = Document.prototype.querySelectorAll;
Document.prototype.querySelectorAll = function querySelectorAll() {
throw new Error('forced query failure');
};
try {
const scan = await window.impeccableScanAsync().then(
() => ({ state: 'resolved' }),
error => ({ state: 'rejected', message: error?.message || String(error) }),
);
const detect = await window.impeccableDetectAsync().then(
() => ({ state: 'resolved' }),
error => ({ state: 'rejected', message: error?.message || String(error) }),
);
return { scan, detect };
} finally {
Document.prototype.querySelectorAll = originalQuerySelectorAll;
}
});
assert.deepEqual(result.scan, { state: 'rejected', message: 'forced query failure' });
assert.deepEqual(result.detect, { state: 'rejected', message: 'forced query failure' });
await page.close();
} finally {
await browser.close().catch(() => {});
}
});
it('createBrowserDetector reuses a browser and honors waitUntil overrides', async () => {
const detector = await createBrowserDetector({ waitUntil: 'load', settleMs: 0 });
try {
const first = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/quality.html`);
const second = await detector.detectUrl(`${baseUrl}/fixtures/antipatterns/body-text-viewport-edge.html`, {
waitUntil: 'domcontentloaded',
});
assert.ok(first.some(r => r.antipattern === 'line-length'));
assert.equal(second.filter(r => r.antipattern === 'body-text-viewport-edge').length, 3);
} finally {
await detector.close();
}
});
});
+55 -18
View File
@@ -1,6 +1,6 @@
/**
* jsdom fixture tests for anti-pattern detection.
* Run via Node's built-in test runner (not bun) to avoid jsdom resource limits.
* Static HTML/CSS fixture tests for anti-pattern detection.
* Run via Node's built-in test runner (not bun).
*
* Usage: node --test tests/detect-antipatterns-fixtures.test.mjs
*/
@@ -15,7 +15,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const FIXTURES = path.join(__dirname, 'fixtures', 'antipatterns');
describe('detectHtml — jsdom fixtures', () => {
describe('detectHtml — static HTML/CSS fixtures', () => {
it('should-flag: catches border anti-patterns', async () => {
const f = await detectHtml(path.join(FIXTURES, 'should-flag.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
@@ -27,11 +27,32 @@ describe('detectHtml — jsdom fixtures', () => {
assert.equal(f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded').length, 0);
});
it('border-baseline: paired side-tab fixture flags only the positive column', async () => {
const f = await detectHtml(path.join(FIXTURES, 'border-baseline.html'));
const sideTabs = f.filter(r => r.antipattern === 'side-tab');
const accents = f.filter(r => r.antipattern === 'border-accent-on-rounded');
assert.equal(
sideTabs.length,
4,
`expected 4 side-tab findings, got ${sideTabs.length}: ${sideTabs.map(r => r.snippet).join('; ')}`
);
assert.equal(
accents.length,
2,
`expected 2 rounded accent findings, got ${accents.length}: ${accents.map(r => r.snippet).join('; ')}`
);
});
it('linked-stylesheet: catches borders, no false positives', async () => {
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
assert.ok(f.some(r => r.antipattern === 'side-tab'));
assert.ok(f.some(r => r.antipattern === 'border-accent-on-rounded'));
assert.equal(f.filter(r => r.snippet?.includes('clean')).length, 0);
assert.equal(
f.filter(r => r.antipattern !== 'side-tab' && r.antipattern !== 'border-accent-on-rounded').length,
0,
`expected only border findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
);
});
it('partial-component: flags borders, skips page-level', async () => {
@@ -48,6 +69,11 @@ describe('detectHtml — jsdom fixtures', () => {
assert.ok(f.some(r => r.antipattern === 'low-contrast'), 'expected low-contrast');
assert.ok(f.some(r => r.antipattern === 'gradient-text'), 'expected gradient-text');
assert.ok(f.some(r => r.antipattern === 'ai-color-palette'), 'expected ai-color-palette');
assert.equal(
f.some(r => r.antipattern === 'pure-black-white' && /#ffffff|#fff/i.test(r.snippet || '')),
false,
'pure white surfaces with dark text should remain allowed',
);
// Gradient-bg + gray text case (added with the gradient-fix patch)
assert.ok(
f.some(r => r.antipattern === 'low-contrast' && /#808080|#3b82f6|#8b5cf6/i.test(r.snippet || '')),
@@ -163,10 +189,9 @@ describe('detectHtml — jsdom fixtures', () => {
);
});
it('legitimate-borders: minimal false positives', async () => {
it('legitimate-borders: zero findings', async () => {
const f = await detectHtml(path.join(FIXTURES, 'legitimate-borders.html'));
const borderFindings = f.filter(r => r.antipattern === 'side-tab' || r.antipattern === 'border-accent-on-rounded');
assert.ok(borderFindings.length <= 1);
assert.equal(f.length, 0, `expected no findings, got: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`);
});
it('modern-color-borders: oklch/oklab/lch/lab side-tabs are flagged, neutrals pass', async () => {
@@ -217,6 +242,20 @@ describe('detectHtml — jsdom fixtures', () => {
assert.ok(f.some(r => r.antipattern === 'overused-font'));
assert.ok(f.some(r => r.antipattern === 'single-font'));
assert.ok(f.some(r => r.antipattern === 'flat-type-hierarchy'));
assert.equal(
f.some(r => r.antipattern === 'low-contrast'),
false,
`typography fixture should not contain incidental contrast findings: ${f.map(r => `${r.antipattern}:${r.snippet}`).join('; ')}`
);
});
it('typography: side-by-side page has visible element-level flag cases', async () => {
const f = await detectHtml(path.join(FIXTURES, 'typography.html'));
const ids = new Set(f.map(r => r.antipattern));
for (const id of ['tight-leading', 'tiny-text', 'all-caps-body', 'wide-tracking', 'justified-text']) {
assert.ok(ids.has(id), `expected typography side-by-side fixture to include ${id}`);
}
assert.ok(ids.has('overused-font'), 'expected typography side-by-side fixture to include a page-level overused-font finding');
});
it('typography-should-pass: zero findings', async () => {
@@ -265,13 +304,13 @@ describe('detectHtml — icon-tile-stack', () => {
});
});
describe('detectHtml — quality (jsdom-compatible rules)', () => {
// Six of the eight quality rules can run in jsdom because they only need
describe('detectHtml — quality (static-compatible rules)', () => {
// Six of the eight quality rules can run in static HTML/CSS because they only need
// computed CSS values (tight-leading, tiny-text, justified-text,
// all-caps-body, wide-tracking) or pure DOM walks (skipped-heading).
// The other two (line-length, cramped-padding) need real layout rects and
// live in tests/detect-antipatterns-browser.test.mjs (Puppeteer-backed).
it('quality: flag column triggers all 6 jsdom-compatible quality rules', async () => {
it('quality: flag column triggers all 6 static-compatible quality rules', async () => {
const f = await detectHtml(path.join(FIXTURES, 'quality.html'));
assert.equal(f.filter(r => r.antipattern === 'tight-leading').length, 1);
assert.equal(f.filter(r => r.antipattern === 'tiny-text').length, 1);
@@ -288,8 +327,8 @@ describe('detectHtml — layout', () => {
const nested = f.filter(r => r.antipattern === 'nested-cards');
assert.ok(nested.length >= 4, `expected ≥4 nested-cards findings, got ${nested.length}`);
// The page-level layout rules (monotonous-spacing, everything-centered)
// need Tailwind-via-CDN to render, which jsdom doesn't execute. They're
// effectively dormant in this test environment regardless of the fixture
// need Tailwind-via-CDN to render, which the static engine does not fetch.
// They're effectively dormant in this test environment regardless of the fixture
// contents — so all we can verify is that the pass column doesn't push
// them awake unexpectedly.
assert.equal(f.filter(r => r.antipattern === 'monotonous-spacing').length, 0);
@@ -342,7 +381,7 @@ describe('detectHtml — hero-eyebrow-chip', () => {
'Pill Chip Above Hero',
'Already Uppercase Text',
// The rule no longer gates on heading font size (modern hero h1s
// use clamp() / vw / var() that jsdom can't resolve), and the
// use clamp() / vw / var() that static HTML/CSS cannot resolve), and the
// eyebrow text ceiling moved 30 → 60 chars. Both shapes now flag.
'Body-Sized Heading Below Eyebrow',
'Long Uppercase Sentence Above Hero',
@@ -410,19 +449,17 @@ describe('detectHtml — repeated-section-kickers', () => {
});
describe('detectHtml — motion', () => {
// jsdom doesn't fully apply class-based styles, so the absolute finding counts
// are lower than what a real browser would see. The hardcoded counts below are
// the calibrated jsdom baseline — if a future change pushes them up, that's a
// pass-column false positive; if down, the rule or fixture has regressed.
// The static CSS engine applies class-based fixture styles, so it catches all
// flag-column layout-transition cases without relying on browser layout.
it('motion: flag column triggers both motion rules, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'motion.html'));
assert.equal(f.filter(r => r.antipattern === 'bounce-easing').length, 2);
assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 2);
assert.equal(f.filter(r => r.antipattern === 'layout-transition').length, 8);
});
});
describe('detectHtml — dark glow', () => {
// Calibrated jsdom baseline — see motion test note above.
// Calibrated static baseline — see motion test note above.
it('glow: flag column triggers dark-glow, pass column adds none', async () => {
const f = await detectHtml(path.join(FIXTURES, 'glow.html'));
assert.equal(f.filter(r => r.antipattern === 'dark-glow').length, 1);
+177 -3
View File
@@ -1,10 +1,11 @@
import { describe, test, expect } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import {
ANTIPATTERNS, checkElementBorders, checkElementMotion, checkElementGlow, isNeutralColor, isFullPage,
detectText, extractStyleBlocks, extractCSSinJS,
detectText, detectHtml, extractStyleBlocks, extractCSSinJS,
walkDir, SCANNABLE_EXTENSIONS,
buildImportGraph, resolveImport,
detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS,
@@ -12,6 +13,30 @@ import {
const FIXTURES = path.join(import.meta.dir, 'fixtures', 'antipatterns');
const SCRIPT = path.join(import.meta.dir, '..', 'cli', 'engine', 'detect-antipatterns.mjs');
const BENCH_SCRIPT = path.join(import.meta.dir, '..', 'scripts', 'benchmark-detector.mjs');
function writeStaticFixture(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-static-'));
for (const [name, contents] of Object.entries(files)) {
const fullPath = path.join(dir, name);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, contents);
}
return { dir, file: path.join(dir, 'index.html') };
}
async function withStaticFixture(files, callback) {
const fixture = writeStaticFixture(files);
try {
return await callback(fixture);
} finally {
fs.rmSync(fixture.dir, { recursive: true, force: true });
}
}
function findingIds(findings) {
return findings.map(f => f.antipattern);
}
// ---------------------------------------------------------------------------
@@ -170,7 +195,7 @@ describe('detectText — flat type hierarchy', () => {
});
});
// jsdom fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test)
// Static HTML/CSS fixture tests moved to detect-antipatterns-fixtures.test.mjs (run via node --test)
// ---------------------------------------------------------------------------
// Full page vs partial detection
@@ -508,6 +533,116 @@ describe('detectText — dark glow', () => {
});
});
// ---------------------------------------------------------------------------
// Static HTML/CSS engine
// ---------------------------------------------------------------------------
describe('detectHtml — static HTML/CSS engine', () => {
test('inlines local linked stylesheets', async () => {
const f = await detectHtml(path.join(FIXTURES, 'linked-stylesheet.html'));
expect(findingIds(f)).toContain('side-tab');
});
test('flattens @layer, resolves CSS variables and fallbacks, and skips unsupported selectors', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html>
<html>
<head>
<style>
@layer components {
:root { --accent: #3b82f6; --fallback-accent: var(--missing-accent, #a855f7); }
.layer-side { border-left: 5px solid var(--accent); border-radius: 8px; }
.layer-top { border-top: 4px solid var(--fallback-accent); border-radius: 8px; }
.ignored:future-only(foo) { border-left: 20px solid #ef4444; }
}
</style>
</head>
<body>
<div class="layer-side">Layer variable side tab</div>
<div class="layer-top">Fallback variable top accent</div>
</body>
</html>`,
}, async ({ file }) => {
const profile = [];
const f = await detectHtml(file, { profile });
const ids = findingIds(f);
expect(ids).toContain('side-tab');
expect(ids).toContain('border-accent-on-rounded');
expect(profile.some(e => e.engine === 'static-html' && e.ruleId === 'unsupported-selector')).toBe(true);
});
});
test('honors specificity, source order, !important, and inline style precedence', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html>
<html>
<head>
<style>
.specificity-pass { border-left: 5px solid #3b82f6; border-radius: 8px; }
div.specificity-pass { border-left-color: #d1d5db; }
.source-order-flag { border-left: 5px solid #d1d5db; border-radius: 8px; }
.source-order-flag { border-left-color: #ef4444; }
.important-pass { border-left: 5px solid #d1d5db !important; border-radius: 8px; }
.important-pass { border-left-color: #3b82f6; }
</style>
</head>
<body>
<div class="specificity-pass">Specificity neutral pass</div>
<div class="source-order-flag">Source order chromatic flag</div>
<div class="important-pass">Important neutral pass</div>
<div style="border-left: 5px solid #06b6d4; border-radius: 8px;">Inline chromatic flag</div>
</body>
</html>`,
}, async ({ file }) => {
const f = await detectHtml(file);
expect(findingIds(f).filter(id => id === 'side-tab')).toHaveLength(2);
});
});
test('expands background, border, font, transition, and animation shorthands', async () => {
await withStaticFixture({
'index.html': `<!DOCTYPE html>
<html>
<head>
<style>
.font-short {
font: italic 700 11px/1.05 Arial, sans-serif;
}
.background-short {
background: #000;
color: #111;
font-size: 16px;
}
.border-short {
border: 1px solid #d1d5db;
border-left: 5px solid #3b82f6;
border-radius: 8px;
}
.motion-short {
transition: width 250ms cubic-bezier(.68,-.55,.27,1.55);
animation: bounce 1s cubic-bezier(.68,-.55,.27,1.55) infinite;
}
</style>
</head>
<body>
<p class="font-short">This tiny paragraph is long enough to trigger both the static font shorthand size and line-height checks.</p>
<button class="background-short">Low contrast button text</button>
<div class="border-short">Border shorthand side tab</div>
<div class="motion-short">Motion shorthand easing</div>
</body>
</html>`,
}, async ({ file }) => {
const ids = findingIds(await detectHtml(file));
expect(ids).toContain('tiny-text');
expect(ids).toContain('tight-leading');
expect(ids).toContain('low-contrast');
expect(ids).toContain('side-tab');
expect(ids).toContain('bounce-easing');
expect(ids).toContain('layout-transition');
});
});
});
// ---------------------------------------------------------------------------
// ANTIPATTERNS registry
@@ -559,6 +694,12 @@ describe('CLI', () => {
expect(stdout).toContain('Usage:');
});
test('detect subcommand is not treated as a scan target', () => {
const { stderr, code } = run('detect', '--json', path.join(FIXTURES, 'should-pass.html'));
expect(code).toBe(0);
expect(stderr).not.toContain('cannot access detect');
});
test('should-pass exits 0', () => {
const { code } = run(path.join(FIXTURES, 'should-pass.html'));
expect(code).toBe(0);
@@ -598,7 +739,7 @@ describe('CLI', () => {
expect(code).toBe(2);
});
test('linked stylesheet detected (jsdom default)', () => {
test('linked stylesheet detected (static HTML/CSS default)', () => {
const { code, stderr } = run(path.join(FIXTURES, 'linked-stylesheet.html'));
expect(code).toBe(2);
expect(stderr).toContain('side-tab');
@@ -610,6 +751,39 @@ describe('CLI', () => {
});
});
// ---------------------------------------------------------------------------
// Detector benchmark smoke test
// ---------------------------------------------------------------------------
describe('benchmark-detector', () => {
test('--quick --json emits timing schema', () => {
const result = spawnSync('node', [BENCH_SCRIPT, '--quick', '--json'], {
encoding: 'utf-8',
timeout: 30000,
});
expect(result.status).toBe(0);
const parsed = JSON.parse(result.stdout.trim());
expect(parsed.version).toBe(1);
expect(parsed.quick).toBe(true);
expect(parsed.browser).toBe(false);
expect(parsed.cases).toBeArray();
expect(parsed.cases.length).toBeGreaterThan(0);
expect(parsed.summary).toBeArray();
expect(parsed.summary.length).toBeGreaterThan(0);
const okCase = parsed.cases.find(c => c.status === 'ok');
expect(okCase).toBeTruthy();
expect(okCase).toHaveProperty('totalMs');
expect(okCase).toHaveProperty('findings');
expect(okCase.profile).toBeArray();
const row = parsed.summary[0];
for (const key of ['engine', 'phase', 'ruleId', 'target', 'calls', 'totalMs', 'avgMs', 'p50', 'p95', 'findings']) {
expect(row).toHaveProperty(key);
}
});
});
// ---------------------------------------------------------------------------
// Tier 1: Vue/Svelte <style> block extraction
// ---------------------------------------------------------------------------
+5 -3
View File
@@ -17,14 +17,16 @@
.container-good { max-width: 720px; margin: 0 auto; padding: 16px 32px; }
.container-tight { padding: 16px 32px; }
.full-bleed-section { background: #fef3c7; padding: 24px 16px; }
nav.site-nav { padding: 12px 0; background: #f5f5f5; }
header.banner { padding: 12px 0; background: #e5e7eb; }
nav.site-nav { padding: 12px 24px; background: #f5f5f5; }
header.banner { padding: 12px 24px; background: #e5e7eb; }
/* Override the grid container's own padding so we can demonstrate
a paragraph that genuinely bleeds to the viewport edge inside
the flag column — the col itself shouldn't reset its padding. */
.flag-fullwidth-p { /* no container; placed directly in body via .escape */ }
.escape { position: relative; left: 50%; right: 50%; margin-left: -50vw; margin-right: -50vw; width: 100vw; padding: 0; }
.edge-list { margin: 0; padding: 0; list-style-position: inside; }
.edge-list li { margin: 0; }
</style>
</head>
<body>
@@ -41,7 +43,7 @@
<p>This second flush-to-edge paragraph confirms the rule fires on every body paragraph that touches the viewport, not just one. The rendered width should be the full viewport, with text starting essentially at x=0.</p>
</div>
<div class="escape">
<ul>
<ul class="edge-list">
<li>This is a list item whose text content is substantially long enough to qualify as body content. It also runs flush against the viewport edge with no left padding.</li>
</ul>
</div>
+213
View File
@@ -0,0 +1,213 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Border Anti-Patterns — Should Flag vs Should Pass</title>
<style>
body {
margin: 0;
padding: 24px;
background: #f8fafc;
color: #111827;
font: 14px/1.5 system-ui, sans-serif;
}
h1 {
max-width: 1180px;
margin: 0 auto 8px;
font-size: 34px;
line-height: 1.05;
font-weight: 720;
}
.intro {
max-width: 1180px;
margin: 0 auto 24px;
color: #4b5563;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
max-width: 1180px;
margin: 0 auto;
}
.col h2 {
margin: 0 0 14px;
color: #475569;
font-size: 13px;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.case {
margin: 0 0 14px;
padding: 16px;
background: #fdfdfd;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.case h3 {
margin: 0 0 4px;
font-size: 15px;
line-height: 1.25;
}
.case p {
margin: 0;
color: #475569;
}
.flag-left {
border-left: 4px solid #2563eb;
border-radius: 10px;
}
.flag-right {
border-right: 5px solid #dc2626;
border-radius: 10px;
}
.flag-left-plain {
border-left: 4px solid #0f766e;
}
.flag-top {
border-top: 4px solid #7c3aed;
border-radius: 10px;
}
.flag-bottom {
border-bottom: 3px solid #ea580c;
border-radius: 10px;
}
.flag-dark {
background: #141820;
color: #f4f7fb;
border-left: 4px solid #38bdf8;
border-radius: 10px;
}
.flag-dark p {
color: #cbd5e1;
}
.pass-full-border {
border: 1px solid #cbd5e1;
border-radius: 10px;
}
.pass-neutral-side {
border-left: 4px solid #d1d5db;
border-radius: 10px;
}
.pass-thin-side {
border-left: 1px solid #2563eb;
border-radius: 10px;
}
.pass-square-top {
border-top: 4px solid #7c3aed;
border-radius: 0;
}
.pass-square-bottom {
border-bottom: 3px solid #ea580c;
border-radius: 0;
}
.pass-dark-uniform {
background: #161a22;
color: #f4f7fb;
border: 1px solid #334155;
border-radius: 10px;
}
.pass-dark-uniform p {
color: #cbd5e1;
}
</style>
</head>
<body>
<h1>Border side-tab detector cases</h1>
<p class="intro">A paired fixture for colored side borders and rounded accent borders. The left column should produce border findings; the right column should stay clean.</p>
<div class="grid">
<section class="col" data-col="flag">
<h2>Should flag</h2>
<div class="case flag-left">
<h3>Rounded card with colored left border</h3>
<p>Classic side tab: border-left is thick, colored, and paired with rounded corners.</p>
</div>
<div class="case flag-right">
<h3>Rounded card with colored right border</h3>
<p>The right-side version is the same visual trope mirrored.</p>
</div>
<div class="case flag-left-plain">
<h3>Thick colored side border without radius</h3>
<p>A thick chromatic side stripe still reads as a tab even without rounded corners.</p>
</div>
<div class="case flag-top">
<h3>Rounded card with top accent border</h3>
<p>A heavy top accent on a rounded card is the horizontal variant of the same pattern.</p>
</div>
<div class="case flag-bottom">
<h3>Rounded card with bottom accent border</h3>
<p>The bottom accent is also a decorative stripe on a card surface.</p>
</div>
<div class="case flag-dark">
<h3>Dark card with bright side stripe</h3>
<p>Dark surfaces can hide the cliche, but the thick colored side stripe remains.</p>
</div>
</section>
<section class="col" data-col="pass">
<h2>Should pass</h2>
<div class="case pass-full-border">
<h3>Uniform full border</h3>
<p>A quiet all-around border frames the card instead of creating a side tab.</p>
</div>
<div class="case pass-neutral-side">
<h3>Neutral structural side rule</h3>
<p>A gray structural rule can be a legitimate divider when it is not chromatic emphasis.</p>
</div>
<div class="case pass-thin-side">
<h3>Thin colored side rule</h3>
<p>A 1px colored rule is below the decorative side-tab threshold.</p>
</div>
<div class="case pass-square-top">
<h3>Square top border</h3>
<p>A top rule without rounded card corners is treated as a structural divider.</p>
</div>
<div class="case pass-square-bottom">
<h3>Square bottom border</h3>
<p>A bottom rule without rounded card corners is also allowed.</p>
</div>
<div class="case pass-dark-uniform">
<h3>Dark card with uniform border</h3>
<p>The dark surface has a full neutral outline, not a chromatic side stripe.</p>
</div>
</section>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+6 -4
View File
@@ -28,13 +28,10 @@
<div class="col" data-col="flag">
<h2>Should flag</h2>
<h3>Pure black &amp; white</h3>
<h3>Pure black surfaces</h3>
<div class="card" style="background: #000000; color: white;">
<p>Pure #000 background</p>
</div>
<div class="card" style="background: #ffffff;">
<p style="color: #000000;">Pure #000 text on pure #fff background</p>
</div>
<h3>Gray on color</h3>
<div class="card" style="background: rgb(59, 130, 246);">
@@ -103,6 +100,11 @@
<p style="color: rgb(20, 20, 25);">Near-white bg, near-black text — good contrast, tinted</p>
</div>
<h3>Pure white surface is acceptable</h3>
<div class="card" style="background: #ffffff;">
<p style="color: #111827;">Pure #fff background with dark text — acceptable contrast</p>
</div>
<h3>Good contrast</h3>
<div class="card" style="background: rgb(30, 64, 175);">
<p style="color: rgb(240, 240, 245);">Near-white text on dark blue — high contrast, not pure white</p>
+18
View File
@@ -31,3 +31,21 @@
border: 1px solid #e5e7eb;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Neutral side rule — should NOT flag */
.external-neutral-side {
background: white;
padding: 1rem;
border-radius: 12px;
border-left: 4px solid #d1d5db;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* Top rule without rounded card corners — should NOT flag */
.external-square-top {
background: white;
padding: 1rem;
border-radius: 0;
border-top: 4px solid #8b5cf6;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
+4 -4
View File
@@ -38,8 +38,8 @@
<div class="demo">
<div style="margin-bottom: 1rem;">
<label style="display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem;">Email</label>
<input type="email" value="not-an-email" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #fca5a5; border-left: 3px solid #ef4444; border-radius: 6px; outline: none; font-size: 0.875rem;">
<p style="color: #ef4444; font-size: 0.75rem; margin-top: 0.25rem;">Please enter a valid email address.</p>
<input type="email" value="not-an-email" style="width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #fca5a5; border-left: 3px solid #b91c1c; border-radius: 6px; outline: none; font-size: 0.875rem;">
<p style="color: #b91c1c; font-size: 0.75rem; margin-top: 0.25rem;">Please enter a valid email address.</p>
</div>
</div>
@@ -59,7 +59,7 @@
</div>
<div style="position: relative;">
<div style="position: absolute; left: -2.05rem; top: 0.125rem; width: 10px; height: 10px; border-radius: 50%; background: #d1d5db; border: 2px solid white;"></div>
<p style="font-weight: 500; margin: 0; color: #9ca3af;">Delivered</p>
<p style="font-weight: 500; margin: 0; color: #4b5563;">Delivered</p>
<p style="font-size: 0.75rem; color: #6b7280; margin: 0.125rem 0 0;">Expected March 18</p>
</div>
</div>
@@ -104,7 +104,7 @@
<!-- 8. ALERT BANNER — full-width, not a card -->
<h2>Alert Banner</h2>
<div class="demo">
<div style="border-left: 4px solid #f59e0b; background: #fffbeb; padding: 0.75rem 1rem; font-size: 0.875rem; color: #92400e;">
<div style="border: 1px solid #f59e0b; background: #fffbeb; padding: 0.75rem 1rem; font-size: 0.875rem; color: #92400e;">
<strong>Warning:</strong> Your trial expires in 3 days. <a href="#" style="color: #d97706;">Upgrade now</a>
</div>
</div>
+85 -25
View File
@@ -6,11 +6,58 @@
<title>Anti-Patterns From Linked Stylesheet</title>
<link rel="stylesheet" href="external-styles.css">
<style>
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2rem 0 0.75rem; color: #6b7280; }
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; }
.cards { display: grid; gap: 1rem; max-width: 28rem; }
body {
margin: 0;
padding: 24px;
background: #f9fafb;
color: #111827;
font: 14px/1.5 system-ui, sans-serif;
}
h1 {
max-width: 1080px;
margin: 0 auto 8px;
font-size: 34px;
line-height: 1.05;
}
p.intro {
max-width: 1080px;
margin: 0 auto 24px;
color: #4b5563;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
max-width: 1080px;
margin: 0 auto;
}
.col h2 {
margin: 0 0 14px;
color: #475569;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cards {
display: grid;
gap: 14px;
}
h3 {
margin: 0 0 4px;
font-size: 15px;
line-height: 1.25;
}
.cards p {
margin: 0;
color: #4b5563;
}
</style>
</head>
<body>
@@ -20,28 +67,41 @@
Regex-only scanning misses these — only computed style analysis catches them.
</p>
<h2>Side-Tab (from external CSS)</h2>
<div class="cards">
<div class="external-side-tab">
<h3 style="font-weight: 600;">External side-tab class</h3>
<p style="font-size: 0.875rem; color: #6b7280;">border-left + border-radius from linked stylesheet.</p>
</div>
</div>
<div class="grid">
<section class="col" data-col="flag">
<h2>Should flag</h2>
<div class="cards">
<div class="external-side-tab">
<h3>External side-tab class</h3>
<p>border-left plus border-radius from a linked stylesheet.</p>
</div>
<h2>Top Accent + Rounded (from external CSS)</h2>
<div class="cards">
<div class="external-top-accent">
<h3 style="font-weight: 600;">External top accent class</h3>
<p style="font-size: 0.875rem; color: #6b7280;">border-top + border-radius from linked stylesheet.</p>
</div>
</div>
<div class="external-top-accent">
<h3>External top accent class</h3>
<p>border-top plus border-radius from a linked stylesheet.</p>
</div>
</div>
</section>
<h2>Clean Card (from external CSS)</h2>
<div class="cards">
<div class="external-clean">
<h3 style="font-weight: 600;">External clean card</h3>
<p style="font-size: 0.875rem; color: #6b7280;">Uniform 1px border — should NOT flag.</p>
</div>
<section class="col" data-col="pass">
<h2>Should pass</h2>
<div class="cards">
<div class="external-clean">
<h3>External clean card</h3>
<p>Uniform 1px border from the linked stylesheet.</p>
</div>
<div class="external-neutral-side">
<h3>External neutral side rule</h3>
<p>Thick side border, but the color is structural gray.</p>
</div>
<div class="external-square-top">
<h3>External square top rule</h3>
<p>Heavy top border with no rounded card corners.</p>
</div>
</div>
</section>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
+2 -25
View File
@@ -7,8 +7,8 @@
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: system-ui, sans-serif; background: #f9fafb; padding: 2rem; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
h2 { font-size: 1.125rem; margin: 2.5rem 0 0.75rem; color: #6b7280; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; }
h1 { font-size: 2rem; margin-bottom: 0.5rem; }
h2 { font-size: 0.875rem; margin: 2.5rem 0 0.75rem; color: #4b5563; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.5rem; text-transform: uppercase; letter-spacing: 0.08em; }
p.intro { color: #6b7280; margin-bottom: 2rem; max-width: 36rem; }
.cards { display: grid; gap: 1rem; max-width: 28rem; }
.card { background: white; padding: 1rem; border-radius: 0.375rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
@@ -20,9 +20,6 @@
.card-shorthand-right { border-right: 5px solid #8b5cf6; }
.card-longhand-left { border-left-width: 3px; border-left-style: solid; border-left-color: #10b981; }
.card-longhand-right { border-right-width: 6px; border-right-style: solid; border-right-color: #ef4444; }
.card-logical-start { border-inline-start: 4px solid #f59e0b; }
.card-logical-end { border-inline-end: 3px solid #ec4899; }
.card-logical-start-width { border-inline-start-width: 5px; border-inline-start-style: solid; border-inline-start-color: #06b6d4; }
/* CSS top/bottom + border-radius */
.card-css-top { border-radius: 12px; border-top: 4px solid #3b82f6; }
@@ -40,18 +37,10 @@
<h3>border-l-4 + rounded-r</h3>
<p>The classic AI tell.</p>
</div>
<div class="border-l-2 border-emerald-500 bg-white p-4 rounded-r shadow-sm">
<h3>border-l-2 + rounded-r</h3>
<p>Thin but still recognizable with rounded corners.</p>
</div>
<div class="border-r-4 border-purple-500 bg-white p-4 rounded-l shadow-sm">
<h3>border-r-4 + rounded-l</h3>
<p>Right side variant.</p>
</div>
<div class="border-s-3 border-amber-500 bg-white p-4 rounded-r shadow-sm">
<h3>border-s-3 + rounded-r</h3>
<p>Logical inline-start.</p>
</div>
<div class="border-e-8 border-red-500 bg-white p-4 rounded-l shadow-sm">
<h3>border-e-8 + rounded-l</h3>
<p>Logical inline-end, extra thick.</p>
@@ -77,18 +66,6 @@
<h3>border-right-width: 6px</h3>
<p>CSS longhand right.</p>
</div>
<div class="card card-logical-start">
<h3>border-inline-start: 4px solid</h3>
<p>CSS logical start.</p>
</div>
<div class="card card-logical-end">
<h3>border-inline-end: 3px solid</h3>
<p>CSS logical end.</p>
</div>
<div class="card card-logical-start-width">
<h3>border-inline-start-width: 5px</h3>
<p>CSS logical longhand.</p>
</div>
</div>
<!-- TOP/BOTTOM + ROUNDED -->
+1 -1
View File
@@ -15,7 +15,7 @@
h2 { font-size: 16px; font-weight: 600; margin: 2rem 0 0.75rem; color: #6b7280; }
h3 { font-size: 15px; font-weight: 600; }
p { font-size: 14px; color: #6b7280; margin-top: 0.25rem; }
.caption { font-size: 13px; color: #9ca3af; }
.caption { font-size: 13px; color: #6b7280; }
</style>
</head>
<body>
+192
View File
@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Typography Anti-Patterns — Side by Side</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Newsreader:opsz,wght@6..72,400;6..72,700&family=Karla:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body {
margin: 0;
padding: 24px;
background: #f9fafb;
color: #111827;
font: 16px/1.5 system-ui, sans-serif;
}
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 32px;
max-width: 1180px;
margin: 0 auto;
}
.col {
min-width: 0;
}
.col-label {
margin: 0 0 14px;
color: #475569;
font-size: 13px;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.flag-type {
font-family: 'Inter', sans-serif;
background: #fdfdfd;
padding: 22px;
border: 1px solid #e5e7eb;
}
.flag-type h1 {
margin: 0 0 8px;
font-size: 18px;
line-height: 1.2;
}
.flag-type h2 {
margin: 24px 0 8px;
color: #4b5563;
font-size: 16px;
line-height: 1.25;
}
.flag-type h3 {
margin: 18px 0 4px;
font-size: 15px;
line-height: 1.25;
}
.flag-type p {
margin: 0 0 10px;
color: #4b5563;
font-size: 14px;
}
.flag-type .caption {
color: #6b7280;
font-size: 13px;
}
.flag-type .tight-leading-demo {
line-height: 1.05;
}
.flag-type .tiny-body-demo {
color: #374151;
font-size: 10px;
line-height: 1.6;
}
.flag-type .caps-body-demo {
text-transform: uppercase;
}
.flag-type .tracked-body-demo {
letter-spacing: 0.08em;
}
.flag-type .justified-body-demo {
text-align: justify;
}
.pass-type {
background: #fdfdfd;
padding: 22px;
border: 1px solid #e5e7eb;
}
.pass-type h1,
.pass-type h2 {
font-family: 'Newsreader', Georgia, serif;
}
.pass-type h1 {
margin: 0 0 10px;
font-size: 48px;
line-height: 0.98;
font-weight: 700;
}
.pass-type h2 {
margin: 32px 0 10px;
font-size: 30px;
line-height: 1.08;
font-weight: 700;
}
.pass-type h3,
.pass-type p,
.pass-type .caption {
font-family: 'Karla', system-ui, sans-serif;
}
.pass-type h3 {
margin: 22px 0 6px;
font-size: 22px;
line-height: 1.15;
}
.pass-type p {
margin: 0 0 12px;
color: #374151;
font-size: 16px;
}
.pass-type .caption {
color: #4b5563;
font-size: 12px;
}
</style>
</head>
<body>
<div class="grid">
<section class="col" data-col="flag">
<h2 class="col-label">Should flag</h2>
<article class="flag-type">
<h1>Typography Anti-Patterns</h1>
<p>This side uses element-level typography failures that can be highlighted directly in the detector lab.</p>
<h2>Tight Leading</h2>
<p class="tight-leading-demo">This paragraph has a line-height that is too tight for body copy. The lines press into each other and make scanning harder, especially once the text wraps across multiple lines in a production layout.</p>
<h2>Tiny Body Text</h2>
<p class="tiny-body-demo">This body text is set at 10px, which is too small for real reading content and should be reserved for neither primary copy nor explanatory product text.</p>
<h2>All-Caps Body</h2>
<p class="caps-body-demo">This entire paragraph is transformed to uppercase, which removes useful word shapes and makes longer body copy feel like a shout instead of a readable passage.</p>
<h2>Wide Tracking</h2>
<p class="tracked-body-demo">This paragraph spreads every letter apart, a treatment that belongs on short labels at most, not on body copy that people need to read comfortably.</p>
<h2>Justified Text</h2>
<p class="justified-body-demo">This paragraph uses justified alignment without automatic hyphenation. In narrow columns it creates uneven word spacing and distracting rivers through the text block.</p>
<h2>Overused Font</h2>
<p>Inter is also the dominant face on this side of the page, so the browser page-level detector has enough real text elements to show the global overused-font banner.</p>
</article>
</section>
<section class="col" data-col="pass">
<h2 class="col-label">Should pass</h2>
<article class="pass-type">
<h1>Good Typography</h1>
<p>This side keeps readable body defaults, a real font pairing, clear scale, and visible hierarchy.</p>
<h2>Two Font Families</h2>
<p>Newsreader carries display text, while Karla handles reading surfaces and supporting labels.</p>
<h3>Strong Size Hierarchy</h3>
<p>Sizes range from 12px to 48px with distinct roles for heading, subheading, body, and caption.</p>
<p class="caption">Caption text is intentionally smaller but still readable.</p>
</article>
</section>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visual Contrast Fixture</title>
<style>
:root {
--paper: #f7f3ee;
--ink: #171717;
--muted: #566174;
}
body {
margin: 0;
padding: 32px;
background: var(--paper);
color: var(--ink);
font-family: system-ui, sans-serif;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
max-width: 980px;
margin: 0 auto;
}
.column {
display: grid;
gap: 14px;
}
.column > h2 {
margin: 0 0 2px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.4;
text-transform: uppercase;
}
.image-card {
position: relative;
display: grid;
align-content: end;
min-height: 180px;
padding: 28px;
border-radius: 8px;
overflow: hidden;
background-size: cover;
background-position: center;
}
.image-card p {
position: relative;
z-index: 1;
max-width: 28ch;
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 1.45;
}
.light-image {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23ece8df'/%3E%3Ccircle cx='308' cy='58' r='132' fill='%23faf7f0'/%3E%3Cpath d='M0 186c52-22 92-26 142-8s98 18 148-8 92-22 130 0v70H0z' fill='%23f5efe4'/%3E%3C/svg%3E");
}
.pale-pattern {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23edf2f7'/%3E%3Cg fill='%23f8fafc'%3E%3Ccircle cx='60' cy='64' r='42'/%3E%3Ccircle cx='182' cy='142' r='58'/%3E%3Ccircle cx='336' cy='84' r='74'/%3E%3C/g%3E%3Cg stroke='%23dbe4ef' stroke-width='14' opacity='.55'%3E%3Cpath d='M-20 220C74 112 150 94 232 164s138 46 214-62' fill='none'/%3E%3C/g%3E%3C/svg%3E");
}
.mist-image {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23b9c4cf'/%3E%3Cpath d='M0 52c74-28 138-22 194 18s124 38 226-28v198H0z' fill='%23cbd3dc'/%3E%3Ccircle cx='82' cy='176' r='68' fill='%23aeb8c4'/%3E%3C/svg%3E");
}
.dark-image {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23191b20'/%3E%3Ccircle cx='316' cy='50' r='132' fill='%232d3440'/%3E%3Cpath d='M0 182c70-48 138-42 204-6s128 24 216-58v122H0z' fill='%23101318'/%3E%3C/svg%3E");
}
.underlay {
isolation: isolate;
background: #1a1d24;
}
.underlay.light-shell {
background: #f5f2ea;
}
.underlay img,
.underlay svg {
position: absolute;
inset: 0;
z-index: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.underlay svg {
display: block;
}
</style>
</head>
<body>
<main class="grid">
<section class="column" data-col="flag">
<h2>Should flag after pixel sampling</h2>
<article class="image-card light-image">
<p style="color: rgb(255, 255, 255);">White text on light image should be sampled by pixel contrast.</p>
</article>
<article class="image-card dark-image">
<p style="color: rgb(28, 30, 35);">Dark text on dark image should be sampled by pixel contrast.</p>
</article>
<article class="image-card pale-pattern">
<p style="color: rgba(255, 255, 255, 0.72);">Translucent white text on a pale pattern should use rendered pixels.</p>
</article>
<article class="image-card mist-image">
<p style="color: rgb(138, 146, 156);">Muted gray text on a misty image should be sampled by pixel contrast.</p>
</article>
</section>
<section class="column" data-col="pass">
<h2>Should pass after pixel sampling</h2>
<article class="image-card dark-image">
<p style="color: rgb(250, 247, 239);">White text on dark image should pass the pixel contrast fallback.</p>
</article>
<article class="image-card light-image">
<p style="color: rgb(31, 35, 42);">Dark text on light image should pass the pixel contrast fallback.</p>
</article>
<article class="image-card underlay">
<img alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 420 240'%3E%3Crect width='420' height='240' fill='%23141920'/%3E%3Ccircle cx='318' cy='70' r='126' fill='%23293442'/%3E%3C/svg%3E">
<p style="color: rgb(246, 242, 232);">White text over a dark image underlay should pass.</p>
</article>
<article class="image-card underlay light-shell">
<svg viewBox="0 0 420 240" aria-hidden="true">
<rect width="420" height="240" fill="#f3eee4"></rect>
<circle cx="86" cy="70" r="68" fill="#fffaf2"></circle>
<path d="M0 176c72-24 132-22 190 6s128 30 230-34v92H0z" fill="#e5dccd"></path>
</svg>
<p style="color: rgb(35, 31, 27);">Dark text over a light SVG underlay should pass.</p>
</article>
</section>
</main>
</body>
</html>
+2 -2
View File
@@ -51,10 +51,10 @@ describe('Windows path doubling fix (#95)', () => {
expect(thisFile).toContain('windows-path-fix.test');
});
test('source file no longer uses raw .pathname for path construction', () => {
test('URL detector source no longer uses raw .pathname for path construction', () => {
const fs = require('fs');
const src = fs.readFileSync(
path.join(__dirname, '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
path.join(__dirname, '..', 'cli', 'engine', 'engines', 'browser', 'detect-url.mjs'),
'utf-8'
);