mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
@@ -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
@@ -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
+31
-4284
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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 0–255 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 ~0–0.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
|
||||
// lch chroma is ~0–150; >= 3 reads as tinted. jsdom emits both formats
|
||||
// literally (it does NOT convert them to rgb).
|
||||
const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);
|
||||
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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+59
-304
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
@@ -28,13 +28,10 @@
|
||||
<div class="col" data-col="flag">
|
||||
<h2>Should flag</h2>
|
||||
|
||||
<h3>Pure black & 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user