quiver-cli 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +237 -10
- package/package.json +1 -1
- package/template/.agents/skills/agent-browser/SKILL.md +1 -0
- package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
- package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
- package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
- package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
- package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
- package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
- package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
- package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
- package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
- package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
- package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
- package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
- package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
- package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
- package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
- package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
- package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
- package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
- package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
- package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
- package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
- package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
- package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
- package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
- package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
- package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
- package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
- package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
- package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
- package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
- package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
- package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
- package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
- package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
- package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
- package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
- package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
- package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
- package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
- package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
- package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
- package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
- package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
- package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
- package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
- package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
- package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
- package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
- package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
- package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
- package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
- package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
- package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
- package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
- package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
- package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
- package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
- package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
- package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
- package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
- package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
- package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
- package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
- package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
- package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
- package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
- package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
- package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
- package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
- package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
- package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
- package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
- package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
- package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
- package/template/.agents/skills/hono/SKILL.md +17 -3
- package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
- package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
- package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
- package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
- package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
- package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
- package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
- package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
- package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
- package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
- package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
- package/template/.agents/skills/supabase/SKILL.md +5 -1
- package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
- package/template/.agents/skills/writing/humanizer/README.md +209 -0
- package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
- package/template/.agents/upstreams.json +21 -22
- package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js
CHANGED
|
@@ -70,13 +70,27 @@ function isBrandFontOnOwnDomain(font) {
|
|
|
70
70
|
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
// Overused-font primary selection skips only CSS generics so a system stack
|
|
74
|
+
// keeps the system face as primary; GENERIC_FONTS still includes platform
|
|
75
|
+
// faces for design-system/serif resolution.
|
|
76
|
+
const CSS_GENERIC_FONTS = new Set([
|
|
74
77
|
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
|
|
78
|
+
'inherit', 'initial', 'unset', 'revert',
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
const GENERIC_FONTS = new Set([
|
|
82
|
+
...CSS_GENERIC_FONTS,
|
|
75
83
|
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
|
|
76
84
|
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
|
|
77
|
-
'inherit', 'initial', 'unset', 'revert',
|
|
78
85
|
]);
|
|
79
86
|
|
|
87
|
+
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
|
|
88
|
+
return String(fontFamily || '')
|
|
89
|
+
.split(',')
|
|
90
|
+
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
|
|
91
|
+
.find(f => f && !skip.has(f)) || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
// WCAG large text thresholds are defined in points: 18pt normal text and
|
|
81
95
|
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
|
|
82
96
|
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
|
|
@@ -232,6 +246,24 @@ const ANTIPATTERNS = [
|
|
|
232
246
|
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
|
|
233
247
|
skillSection: 'Imagery',
|
|
234
248
|
},
|
|
249
|
+
{
|
|
250
|
+
id: 'organic-clip-path',
|
|
251
|
+
category: 'quality',
|
|
252
|
+
name: 'Organic contour drawn as clip-path',
|
|
253
|
+
description:
|
|
254
|
+
'A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).',
|
|
255
|
+
skillSection: 'Imagery',
|
|
256
|
+
skillGuideline: 'geometric masks standing in for organic contours',
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: 'buried-raster',
|
|
260
|
+
category: 'quality',
|
|
261
|
+
name: 'Raster buried under a wash or opacity',
|
|
262
|
+
description:
|
|
263
|
+
'A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.',
|
|
264
|
+
skillSection: 'Imagery',
|
|
265
|
+
skillGuideline: 'a produced material must survive to the screen',
|
|
266
|
+
},
|
|
235
267
|
{
|
|
236
268
|
id: 'dark-glow',
|
|
237
269
|
category: 'slop',
|
|
@@ -741,14 +773,54 @@ function contrastRatio(c1, c2) {
|
|
|
741
773
|
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
|
742
774
|
}
|
|
743
775
|
|
|
776
|
+
// The CSS color functions worth pulling out of a longer declaration. The set
|
|
777
|
+
// is deliberately closed: `linear-gradient(` and `url(` also look like
|
|
778
|
+
// `name(` and must not be read as colors.
|
|
779
|
+
const COLOR_FUNCTION_NAMES = new Set([
|
|
780
|
+
'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'oklch', 'oklab', 'lch', 'lab', 'color', 'color-mix',
|
|
781
|
+
]);
|
|
782
|
+
|
|
783
|
+
// Pull every color-function token out of a value, with balanced-paren capture
|
|
784
|
+
// so nested forms (`color-mix(in oklab, oklch(...) 20%, transparent)`) survive
|
|
785
|
+
// whole. Returns the raw substrings in source order.
|
|
786
|
+
function extractColorFunctionTokens(value) {
|
|
787
|
+
const str = String(value || '');
|
|
788
|
+
const tokens = [];
|
|
789
|
+
const re = /([a-z][a-z-]*)\(/gi;
|
|
790
|
+
let m;
|
|
791
|
+
while ((m = re.exec(str)) !== null) {
|
|
792
|
+
if (!COLOR_FUNCTION_NAMES.has(m[1].toLowerCase())) continue;
|
|
793
|
+
let depth = 0, end = -1;
|
|
794
|
+
for (let i = m.index + m[0].length - 1; i < str.length; i++) {
|
|
795
|
+
if (str[i] === '(') depth++;
|
|
796
|
+
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
|
797
|
+
}
|
|
798
|
+
if (end < 0) break;
|
|
799
|
+
tokens.push(str.slice(m.index, end + 1));
|
|
800
|
+
re.lastIndex = end + 1;
|
|
801
|
+
}
|
|
802
|
+
return tokens;
|
|
803
|
+
}
|
|
804
|
+
|
|
744
805
|
function parseGradientColors(bgImage) {
|
|
745
806
|
if (!bgImage || !bgImage.includes('gradient')) return [];
|
|
746
807
|
const colors = [];
|
|
747
|
-
|
|
748
|
-
|
|
808
|
+
const tokenSpans = [];
|
|
809
|
+
let from = 0;
|
|
810
|
+
// Stops arrive in whatever syntax the author wrote and the browser kept.
|
|
811
|
+
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
|
|
812
|
+
// to read as a gradient with no stops at all.
|
|
813
|
+
for (const token of extractColorFunctionTokens(bgImage)) {
|
|
814
|
+
const start = bgImage.indexOf(token, from);
|
|
815
|
+
if (start < 0) break;
|
|
816
|
+
tokenSpans.push({ start, end: start + token.length });
|
|
817
|
+
from = start + token.length;
|
|
818
|
+
const c = parseAnyColor(token);
|
|
749
819
|
if (c) colors.push(c);
|
|
750
820
|
}
|
|
751
821
|
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
|
|
822
|
+
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
|
|
823
|
+
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
|
|
752
824
|
const h = m[1];
|
|
753
825
|
if (h.length === 6) {
|
|
754
826
|
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 });
|
|
@@ -782,262 +854,708 @@ function colorToHex(c) {
|
|
|
782
854
|
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
|
|
783
855
|
}
|
|
784
856
|
|
|
785
|
-
//
|
|
786
|
-
|
|
857
|
+
// ─── Color-space conversions ────────────────────────────────────────────────
|
|
858
|
+
//
|
|
859
|
+
// Every function here lands on 8-bit sRGB, clamped to gamut. Chrome, Safari,
|
|
860
|
+
// and Firefox all keep the authored color space in getComputedStyle output
|
|
861
|
+
// (`oklch(0.84 0.19 80.46)`, `lch(20 5 60)`, `color(srgb 1.04 0.72 -0.21)`),
|
|
862
|
+
// so a detector that only reads rgb() is blind on any modern palette. The
|
|
863
|
+
// expected outputs are pinned in tests/detect-antipatterns.test.js against
|
|
864
|
+
// what Chrome itself paints for the same strings.
|
|
787
865
|
|
|
788
|
-
function
|
|
789
|
-
return
|
|
790
|
-
.split('|')
|
|
791
|
-
.map(part => part.split(':')[0].trim().toLowerCase())
|
|
792
|
-
.filter(Boolean);
|
|
866
|
+
function clamp01(x) {
|
|
867
|
+
return Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : 0;
|
|
793
868
|
}
|
|
794
869
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
870
|
+
// Linear-light sRGB channel to the encoded 0-255 value.
|
|
871
|
+
function encodeSrgbChannel(x) {
|
|
872
|
+
const c = clamp01(x);
|
|
873
|
+
return Math.round((c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055) * 255);
|
|
874
|
+
}
|
|
798
875
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
876
|
+
function decodeSrgbChannel(x) {
|
|
877
|
+
const c = Number.isFinite(x) ? x : 0;
|
|
878
|
+
const sign = c < 0 ? -1 : 1;
|
|
879
|
+
const abs = Math.abs(c);
|
|
880
|
+
return sign * (abs <= 0.04045 ? abs / 12.92 : Math.pow((abs + 0.055) / 1.055, 2.4));
|
|
881
|
+
}
|
|
805
882
|
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
}
|
|
810
|
-
}
|
|
883
|
+
function linearSrgbToColor(r, g, b, a = 1) {
|
|
884
|
+
return { r: encodeSrgbChannel(r), g: encodeSrgbChannel(g), b: encodeSrgbChannel(b), a };
|
|
885
|
+
}
|
|
811
886
|
|
|
812
|
-
|
|
887
|
+
// OKLab to sRGB (Björn Ottosson's matrices). L in 0..1, a/b are signed axes.
|
|
888
|
+
function oklabToRgb(L, a, b) {
|
|
889
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
890
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
891
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
892
|
+
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
|
|
893
|
+
return linearSrgbToColor(
|
|
894
|
+
4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc,
|
|
895
|
+
-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc,
|
|
896
|
+
-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc,
|
|
897
|
+
);
|
|
813
898
|
}
|
|
814
899
|
|
|
815
|
-
//
|
|
816
|
-
|
|
900
|
+
// OKLCH to sRGB. L in 0..1, C in 0..~0.4 typical, H in degrees. Chroma past
|
|
901
|
+
// the sRGB gamut clamps per channel rather than producing NaN.
|
|
902
|
+
function oklchToRgb(L, C, H) {
|
|
903
|
+
const hRad = (H * Math.PI) / 180;
|
|
904
|
+
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
|
905
|
+
}
|
|
817
906
|
|
|
818
|
-
//
|
|
907
|
+
// CIE Lab to sRGB. CSS lab()/lch() use the D50 white point; the matrix below
|
|
908
|
+
// is the Bradford-adapted XYZ-D50 to linear-sRGB transform from CSS Color 4.
|
|
909
|
+
function labToRgb(L, a, b) {
|
|
910
|
+
const kappa = 24389 / 27, epsilon = 216 / 24389;
|
|
911
|
+
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
|
|
912
|
+
const invert = (t) => (t * t * t > epsilon ? t * t * t : (116 * t - 16) / kappa);
|
|
913
|
+
const yr = L > kappa * epsilon ? Math.pow((L + 16) / 116, 3) : L / kappa;
|
|
914
|
+
const Xn = 0.3457 / 0.3585, Zn = (1 - 0.3457 - 0.3585) / 0.3585;
|
|
915
|
+
const x = invert(fx) * Xn, y = yr, z = invert(fz) * Zn;
|
|
916
|
+
return linearSrgbToColor(
|
|
917
|
+
3.1341359569958707 * x - 1.6173863321612538 * y - 0.4906619460083532 * z,
|
|
918
|
+
-0.9787955029120890 * x + 1.9162545672595240 * y + 0.0334427311613195 * z,
|
|
919
|
+
0.0719553798841168 * x - 0.2289768264158322 * y + 1.4053860583241250 * z,
|
|
920
|
+
);
|
|
921
|
+
}
|
|
819
922
|
|
|
820
|
-
function
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
923
|
+
function lchToRgb(L, C, H) {
|
|
924
|
+
const hRad = (H * Math.PI) / 180;
|
|
925
|
+
return labToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// color(<space> c1 c2 c3) for the spaces that turn up in real stylesheets.
|
|
929
|
+
// `srgb` is what Chrome serializes most color-mix() results into, routinely
|
|
930
|
+
// with channels outside 0..1. Spaces we do not model return null so callers
|
|
931
|
+
// abstain instead of measuring against a color we invented.
|
|
932
|
+
function colorFunctionToRgb(space, c1, c2, c3) {
|
|
933
|
+
switch (space) {
|
|
934
|
+
case 'srgb':
|
|
935
|
+
return { r: Math.round(clamp01(c1) * 255), g: Math.round(clamp01(c2) * 255), b: Math.round(clamp01(c3) * 255), a: 1 };
|
|
936
|
+
case 'srgb-linear':
|
|
937
|
+
return linearSrgbToColor(c1, c2, c3);
|
|
938
|
+
case 'display-p3': {
|
|
939
|
+
const [R, G, B] = [decodeSrgbChannel(c1), decodeSrgbChannel(c2), decodeSrgbChannel(c3)];
|
|
940
|
+
return linearSrgbToColor(
|
|
941
|
+
1.2249401762805587 * R - 0.2249404646817506 * G + 0.0000002884022551 * B,
|
|
942
|
+
-0.0420569547096138 * R + 1.0420571661298634 * G - 0.0000002113202247 * B,
|
|
943
|
+
-0.0196375587040044 * R - 0.0786360772174755 * G + 1.0982736359214800 * B,
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
default:
|
|
947
|
+
return null;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
831
950
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
951
|
+
function hslToRgb(h, s, l) {
|
|
952
|
+
h = ((h % 360) + 360) % 360;
|
|
953
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
954
|
+
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
955
|
+
const m0 = l - c / 2;
|
|
956
|
+
const [r, g, b] =
|
|
957
|
+
h < 60 ? [c, x, 0] :
|
|
958
|
+
h < 120 ? [x, c, 0] :
|
|
959
|
+
h < 180 ? [0, c, x] :
|
|
960
|
+
h < 240 ? [0, x, c] :
|
|
961
|
+
h < 300 ? [x, 0, c] : [c, 0, x];
|
|
962
|
+
return {
|
|
963
|
+
r: Math.round((r + m0) * 255),
|
|
964
|
+
g: Math.round((g + m0) * 255),
|
|
965
|
+
b: Math.round((b + m0) * 255),
|
|
966
|
+
a: 1,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
835
969
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
970
|
+
function hwbToRgb(h, w, bl) {
|
|
971
|
+
if (w + bl >= 1) {
|
|
972
|
+
const g = Math.round((w / (w + bl)) * 255);
|
|
973
|
+
return { r: g, g, b: g, a: 1 };
|
|
974
|
+
}
|
|
975
|
+
const base = hslToRgb(h, 1, 0.5);
|
|
976
|
+
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
|
|
977
|
+
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
|
|
978
|
+
}
|
|
839
979
|
|
|
840
|
-
|
|
841
|
-
|
|
980
|
+
// Common CSS named colors — the handful that actually show up in generated
|
|
981
|
+
// UIs, not the full 148-name spec list. Includes the achromatic names so a
|
|
982
|
+
// named gray parses (and correctly reads as no-chroma) instead of being
|
|
983
|
+
// treated as an unknown color.
|
|
984
|
+
const CSS_NAMED_COLORS = {
|
|
985
|
+
black: { r: 0, g: 0, b: 0 },
|
|
986
|
+
white: { r: 255, g: 255, b: 255 },
|
|
987
|
+
gray: { r: 128, g: 128, b: 128 },
|
|
988
|
+
grey: { r: 128, g: 128, b: 128 },
|
|
989
|
+
silver: { r: 192, g: 192, b: 192 },
|
|
990
|
+
dimgray: { r: 105, g: 105, b: 105 },
|
|
991
|
+
darkgray: { r: 169, g: 169, b: 169 },
|
|
992
|
+
lightgray: { r: 211, g: 211, b: 211 },
|
|
993
|
+
gainsboro: { r: 220, g: 220, b: 220 },
|
|
994
|
+
whitesmoke: { r: 245, g: 245, b: 245 },
|
|
995
|
+
red: { r: 255, g: 0, b: 0 },
|
|
996
|
+
crimson: { r: 220, g: 20, b: 60 },
|
|
997
|
+
tomato: { r: 255, g: 99, b: 71 },
|
|
998
|
+
coral: { r: 255, g: 127, b: 80 },
|
|
999
|
+
salmon: { r: 250, g: 128, b: 114 },
|
|
1000
|
+
orange: { r: 255, g: 165, b: 0 },
|
|
1001
|
+
gold: { r: 255, g: 215, b: 0 },
|
|
1002
|
+
yellow: { r: 255, g: 255, b: 0 },
|
|
1003
|
+
olive: { r: 128, g: 128, b: 0 },
|
|
1004
|
+
lime: { r: 0, g: 255, b: 0 },
|
|
1005
|
+
green: { r: 0, g: 128, b: 0 },
|
|
1006
|
+
teal: { r: 0, g: 128, b: 128 },
|
|
1007
|
+
turquoise: { r: 64, g: 224, b: 208 },
|
|
1008
|
+
cyan: { r: 0, g: 255, b: 255 },
|
|
1009
|
+
aqua: { r: 0, g: 255, b: 255 },
|
|
1010
|
+
skyblue: { r: 135, g: 206, b: 235 },
|
|
1011
|
+
dodgerblue: { r: 30, g: 144, b: 255 },
|
|
1012
|
+
blue: { r: 0, g: 0, b: 255 },
|
|
1013
|
+
navy: { r: 0, g: 0, b: 128 },
|
|
1014
|
+
indigo: { r: 75, g: 0, b: 130 },
|
|
1015
|
+
rebeccapurple: { r: 102, g: 51, b: 153 },
|
|
1016
|
+
purple: { r: 128, g: 0, b: 128 },
|
|
1017
|
+
violet: { r: 238, g: 130, b: 238 },
|
|
1018
|
+
orchid: { r: 218, g: 112, b: 214 },
|
|
1019
|
+
magenta: { r: 255, g: 0, b: 255 },
|
|
1020
|
+
fuchsia: { r: 255, g: 0, b: 255 },
|
|
1021
|
+
hotpink: { r: 255, g: 105, b: 180 },
|
|
1022
|
+
pink: { r: 255, g: 192, b: 203 },
|
|
1023
|
+
maroon: { r: 128, g: 0, b: 0 },
|
|
1024
|
+
};
|
|
842
1025
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
// table cells, and <hr> never reach here (BORDER_SAFE_TAGS).
|
|
855
|
-
else if (!opts.tabContext && w >= 3 && w <= 12) {
|
|
856
|
-
findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
|
|
857
|
-
}
|
|
1026
|
+
// Split a string on top-level commas (ignoring commas nested in parens).
|
|
1027
|
+
function splitTopLevelCommas(str) {
|
|
1028
|
+
const parts = [];
|
|
1029
|
+
let depth = 0, start = 0;
|
|
1030
|
+
for (let i = 0; i < str.length; i++) {
|
|
1031
|
+
const ch = str[i];
|
|
1032
|
+
if (ch === '(') depth++;
|
|
1033
|
+
else if (ch === ')') depth = Math.max(0, depth - 1);
|
|
1034
|
+
else if (ch === ',' && depth === 0) {
|
|
1035
|
+
parts.push(str.slice(start, i).trim());
|
|
1036
|
+
start = i + 1;
|
|
858
1037
|
}
|
|
859
1038
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
// Returns true if the given text is composed entirely of emoji characters
|
|
865
|
-
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
|
866
|
-
// regardless of CSS `color`, so contrast checks against the element's text
|
|
867
|
-
// color are meaningless for these nodes.
|
|
868
|
-
const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u;
|
|
869
|
-
const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu;
|
|
870
|
-
function isEmojiOnlyText(text) {
|
|
871
|
-
if (!text) return false;
|
|
872
|
-
if (!EMOJI_CHAR_RE.test(text)) return false;
|
|
873
|
-
return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
|
|
1039
|
+
const tail = str.slice(start).trim();
|
|
1040
|
+
if (tail) parts.push(tail);
|
|
1041
|
+
return parts;
|
|
874
1042
|
}
|
|
875
1043
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
// color that fails against every stop sails through on the
|
|
895
|
-
// SAFE_TAGS suppression (the shipped escape).
|
|
896
|
-
|| (bgImage && /gradient/i.test(bgImage)))
|
|
897
|
-
&& fontSize >= 9;
|
|
898
|
-
if (!isStyledControl) return [];
|
|
1044
|
+
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
|
|
1045
|
+
// the expression can't be resolved (unresolved var(), unknown colors).
|
|
1046
|
+
//
|
|
1047
|
+
// Mixing is done with premultiplied alpha in sRGB regardless of the
|
|
1048
|
+
// declared interpolation space. That is exact for the dominant generated-UI
|
|
1049
|
+
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
|
|
1050
|
+
// result is simply <color> at alpha N% in ANY rectangular space, and a
|
|
1051
|
+
// close-enough approximation for opaque-opaque mixes (the detector only
|
|
1052
|
+
// consumes these values for contrast/chroma thresholds, not for display).
|
|
1053
|
+
function parseColorMix(str) {
|
|
1054
|
+
const m = String(str).trim().match(/^color-mix\(/i);
|
|
1055
|
+
if (!m) return null;
|
|
1056
|
+
// Balanced-paren capture of the arguments.
|
|
1057
|
+
let depth = 0, end = -1;
|
|
1058
|
+
const open = str.indexOf('(');
|
|
1059
|
+
for (let i = open; i < str.length; i++) {
|
|
1060
|
+
if (str[i] === '(') depth++;
|
|
1061
|
+
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
|
899
1062
|
}
|
|
900
|
-
|
|
1063
|
+
if (end < 0) return null;
|
|
1064
|
+
const args = splitTopLevelCommas(str.slice(open + 1, end));
|
|
1065
|
+
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
|
|
901
1066
|
|
|
902
|
-
|
|
903
|
-
//
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
? null
|
|
917
|
-
: (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
|
|
918
|
-
if (bgs) {
|
|
919
|
-
// Gray on colored background — flag if every stop is chromatic
|
|
920
|
-
const textLum = relativeLuminance(textColor);
|
|
921
|
-
const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
|
|
922
|
-
if (isGray && bgs.every(b => hasChroma(b, 40))) {
|
|
923
|
-
const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
|
|
924
|
-
findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
|
|
925
|
-
}
|
|
1067
|
+
const parseComponent = (component) => {
|
|
1068
|
+
// Percentage may lead or trail the color per spec.
|
|
1069
|
+
let pct = null;
|
|
1070
|
+
let colorStr = component;
|
|
1071
|
+
const trail = component.match(/\s+([\d.]+)%$/);
|
|
1072
|
+
const lead = component.match(/^([\d.]+)%\s+/);
|
|
1073
|
+
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
|
|
1074
|
+
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
|
|
1075
|
+
let color;
|
|
1076
|
+
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
|
|
1077
|
+
else color = parseAnyColor(colorStr);
|
|
1078
|
+
if (!color) return null;
|
|
1079
|
+
return { color, pct };
|
|
1080
|
+
};
|
|
926
1081
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
if (!isAlphaFallbackFP) {
|
|
947
|
-
// Near-threshold ratios (e.g. 4.497) would round to the threshold
|
|
948
|
-
// itself at one decimal and read as "4.5 needs 4.5" — show two
|
|
949
|
-
// decimals there so the finding stays legible.
|
|
950
|
-
const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1);
|
|
951
|
-
findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
|
|
952
|
-
}
|
|
953
|
-
}
|
|
954
|
-
}
|
|
1082
|
+
const c1 = parseComponent(args[1]);
|
|
1083
|
+
const c2 = parseComponent(args[2]);
|
|
1084
|
+
if (!c1 || !c2) return null;
|
|
1085
|
+
let p1 = c1.pct, p2 = c2.pct;
|
|
1086
|
+
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
|
|
1087
|
+
else if (p1 == null) p1 = 100 - p2;
|
|
1088
|
+
else if (p2 == null) p2 = 100 - p1;
|
|
1089
|
+
const sum = p1 + p2;
|
|
1090
|
+
if (sum <= 0) return null;
|
|
1091
|
+
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
|
|
1092
|
+
// additionally scaled by sum/100.
|
|
1093
|
+
const w1 = p1 / sum, w2 = p2 / sum;
|
|
1094
|
+
const alphaScale = sum < 100 ? sum / 100 : 1;
|
|
1095
|
+
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
|
|
1096
|
+
const a = (a1 * w1 + a2 * w2) * alphaScale;
|
|
1097
|
+
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
|
1098
|
+
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
|
|
1099
|
+
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
|
|
1100
|
+
}
|
|
955
1101
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1102
|
+
// Composite a translucent color over an opaque(ish) base (simple
|
|
1103
|
+
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
|
|
1104
|
+
function compositeColorOver(top, base) {
|
|
1105
|
+
const a = top.a ?? 1;
|
|
1106
|
+
return {
|
|
1107
|
+
r: Math.round(top.r * a + base.r * (1 - a)),
|
|
1108
|
+
g: Math.round(top.g * a + base.g * (1 - a)),
|
|
1109
|
+
b: Math.round(top.b * a + base.b * (1 - a)),
|
|
1110
|
+
a: 1,
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
964
1113
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1114
|
+
// A color() / lab() / lch() component: a bare number, a percentage against
|
|
1115
|
+
// `scale`, or the `none` keyword (which resolves to zero for our purposes).
|
|
1116
|
+
function parseColorComponent(token, scale = 1) {
|
|
1117
|
+
if (token == null) return null;
|
|
1118
|
+
const t = String(token).trim();
|
|
1119
|
+
if (/^none$/i.test(t)) return 0;
|
|
1120
|
+
const num = parseFloat(t);
|
|
1121
|
+
if (!Number.isFinite(num)) return null;
|
|
1122
|
+
return t.endsWith('%') ? (num / 100) * scale : num;
|
|
1123
|
+
}
|
|
969
1124
|
|
|
970
|
-
|
|
971
|
-
if (
|
|
972
|
-
|
|
1125
|
+
function parseAlphaToken(token) {
|
|
1126
|
+
if (token == null) return 1;
|
|
1127
|
+
const t = String(token).trim();
|
|
1128
|
+
if (/^none$/i.test(t)) return 1;
|
|
1129
|
+
const num = parseFloat(t);
|
|
1130
|
+
if (!Number.isFinite(num)) return 1;
|
|
1131
|
+
return t.endsWith('%') ? num / 100 : num;
|
|
1132
|
+
}
|
|
973
1133
|
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1134
|
+
// Extended color parser: rgb/rgba/hex/oklch/oklab/lch/lab/hsl/hwb/color()/
|
|
1135
|
+
// color-mix/common named colors. Returns null on no match. Use this when the
|
|
1136
|
+
// input might be any CSS color form; use plain parseRgb when you only expect
|
|
1137
|
+
// computed rgb() values from real browsers.
|
|
1138
|
+
function parseAnyColor(s) {
|
|
1139
|
+
if (!s || typeof s !== 'string') return null;
|
|
1140
|
+
const str = s.trim();
|
|
1141
|
+
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
|
|
1142
|
+
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
|
|
1143
|
+
let m;
|
|
1144
|
+
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/);
|
|
1145
|
+
if (m) {
|
|
1146
|
+
const c = { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: 1 };
|
|
1147
|
+
if (m[4] !== undefined) c.a = m[5] === '%' ? parseFloat(m[4]) / 100 : +m[4];
|
|
1148
|
+
return c;
|
|
1149
|
+
}
|
|
1150
|
+
m = str.match(/^#([0-9a-f]{3,8})$/i);
|
|
1151
|
+
if (m) {
|
|
1152
|
+
const h = m[1];
|
|
1153
|
+
if (h.length === 3 || h.length === 4) {
|
|
1154
|
+
return {
|
|
1155
|
+
r: parseInt(h[0] + h[0], 16),
|
|
1156
|
+
g: parseInt(h[1] + h[1], 16),
|
|
1157
|
+
b: parseInt(h[2] + h[2], 16),
|
|
1158
|
+
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
|
|
1159
|
+
};
|
|
978
1160
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1161
|
+
if (h.length === 6 || h.length === 8) {
|
|
1162
|
+
return {
|
|
1163
|
+
r: parseInt(h.slice(0, 2), 16),
|
|
1164
|
+
g: parseInt(h.slice(2, 4), 16),
|
|
1165
|
+
b: parseInt(h.slice(4, 6), 16),
|
|
1166
|
+
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
|
1167
|
+
};
|
|
982
1168
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1169
|
+
}
|
|
1170
|
+
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
|
1171
|
+
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
|
1172
|
+
// Match L (with optional %), then C and H separated permissively.
|
|
1173
|
+
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
1174
|
+
if (m) {
|
|
1175
|
+
const Lnum = parseFloat(m[1]);
|
|
1176
|
+
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
|
1177
|
+
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
|
1178
|
+
if (m[5] !== undefined) {
|
|
1179
|
+
const alpha = parseFloat(m[5]);
|
|
1180
|
+
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
|
987
1181
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1182
|
+
return rgb;
|
|
1183
|
+
}
|
|
1184
|
+
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
|
|
1185
|
+
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
1186
|
+
if (m) {
|
|
1187
|
+
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
|
1188
|
+
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
|
|
1189
|
+
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
|
|
1190
|
+
const rgb = oklabToRgb(L, a, b);
|
|
1191
|
+
if (m[7] !== undefined) {
|
|
1192
|
+
const alpha = parseFloat(m[7]);
|
|
1193
|
+
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
|
|
991
1194
|
}
|
|
1195
|
+
return rgb;
|
|
992
1196
|
}
|
|
993
|
-
|
|
994
|
-
|
|
1197
|
+
// LCH / LAB — CIE, D50 white point. Chrome serializes lch(20% 5 60) as
|
|
1198
|
+
// `lch(20 5 60)`, so L arrives with or without its percent sign. In both
|
|
1199
|
+
// spaces L runs 0..100 and 100% means 100.
|
|
1200
|
+
m = str.match(/^lch\(\s*([\d.]+%?|none)\s+([\d.]+%?|none)\s+(-?[\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
|
|
1201
|
+
if (m) {
|
|
1202
|
+
const L = parseColorComponent(m[1], 100);
|
|
1203
|
+
const C = parseColorComponent(m[2], 150);
|
|
1204
|
+
const H = parseFloat(m[3]);
|
|
1205
|
+
if (L == null || C == null || !Number.isFinite(H)) return null;
|
|
1206
|
+
const rgb = lchToRgb(L, C, H);
|
|
1207
|
+
rgb.a = parseAlphaToken(m[4]);
|
|
1208
|
+
return rgb;
|
|
1209
|
+
}
|
|
1210
|
+
m = str.match(/^lab\(\s*([\d.]+%?|none)\s+(-?[\d.]+%?|none)\s+(-?[\d.]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
|
|
1211
|
+
if (m) {
|
|
1212
|
+
const L = parseColorComponent(m[1], 100);
|
|
1213
|
+
const a = parseColorComponent(m[2], 125);
|
|
1214
|
+
const b = parseColorComponent(m[3], 125);
|
|
1215
|
+
if (L == null || a == null || b == null) return null;
|
|
1216
|
+
const rgb = labToRgb(L, a, b);
|
|
1217
|
+
rgb.a = parseAlphaToken(m[4]);
|
|
1218
|
+
return rgb;
|
|
1219
|
+
}
|
|
1220
|
+
// color(<space> c1 c2 c3 [/ alpha]) — what Chrome hands back for most
|
|
1221
|
+
// color-mix() results and for any wide-gamut color an author wrote.
|
|
1222
|
+
m = str.match(/^color\(\s*([a-z0-9-]+)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)\s+(-?[\d.eE+-]+%?|none)(?:\s*\/\s*([\d.]+%?|none))?\s*\)$/i);
|
|
1223
|
+
if (m) {
|
|
1224
|
+
const c1 = parseColorComponent(m[2]);
|
|
1225
|
+
const c2 = parseColorComponent(m[3]);
|
|
1226
|
+
const c3 = parseColorComponent(m[4]);
|
|
1227
|
+
if (c1 == null || c2 == null || c3 == null) return null;
|
|
1228
|
+
const rgb = colorFunctionToRgb(m[1].toLowerCase(), c1, c2, c3);
|
|
1229
|
+
if (!rgb) return null;
|
|
1230
|
+
rgb.a = parseAlphaToken(m[5]);
|
|
1231
|
+
return rgb;
|
|
1232
|
+
}
|
|
1233
|
+
// HSL/HSLA — comma or space syntax, optional deg on hue.
|
|
1234
|
+
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
|
|
1235
|
+
if (m) {
|
|
1236
|
+
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
|
1237
|
+
if (m[4] !== undefined) {
|
|
1238
|
+
const alpha = parseFloat(m[4]);
|
|
1239
|
+
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
|
1240
|
+
}
|
|
1241
|
+
return rgb;
|
|
1242
|
+
}
|
|
1243
|
+
// HWB — hue whiteness% blackness%.
|
|
1244
|
+
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
1245
|
+
if (m) {
|
|
1246
|
+
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
|
1247
|
+
if (m[4] !== undefined) {
|
|
1248
|
+
const alpha = parseFloat(m[4]);
|
|
1249
|
+
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
|
1250
|
+
}
|
|
1251
|
+
return rgb;
|
|
1252
|
+
}
|
|
1253
|
+
const named = CSS_NAMED_COLORS[str.toLowerCase()];
|
|
1254
|
+
if (named) return { ...named, a: 1 };
|
|
1255
|
+
return null;
|
|
995
1256
|
}
|
|
996
1257
|
|
|
997
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
return [{
|
|
1013
|
-
id: 'low-contrast',
|
|
1014
|
-
snippet: `:hover state ${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bg)}`,
|
|
1015
|
-
}];
|
|
1258
|
+
// True when a computed background-color string names no paint at all. Used to
|
|
1259
|
+
// tell "this layer is see-through" (walk on to the ancestor) apart from "this
|
|
1260
|
+
// layer has a color we could not read" (stop and abstain).
|
|
1261
|
+
//
|
|
1262
|
+
// `inherit` belongs here even though it is not literally see-through: it means
|
|
1263
|
+
// "paint with the parent's background-color", and walking on to the parent IS
|
|
1264
|
+
// that resolution. Real browsers resolve the keyword before getComputedStyle
|
|
1265
|
+
// output; only jsdom's partial cascade hands it through verbatim, and treating
|
|
1266
|
+
// it as unreadable would make the walk abstain on a surface it can know.
|
|
1267
|
+
// (`currentcolor` is NOT here — it is real paint in the element's own text
|
|
1268
|
+
// color; resolveBackgroundInfo substitutes the computed color for it.)
|
|
1269
|
+
function isNoPaintColorValue(value) {
|
|
1270
|
+
const v = String(value || '').trim().toLowerCase();
|
|
1271
|
+
if (!v) return true;
|
|
1272
|
+
return v === 'transparent' || v === 'none' || v === 'initial' || v === 'inherit' || v === 'unset' || v === 'revert' || v === 'revert-layer';
|
|
1016
1273
|
}
|
|
1017
1274
|
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1275
|
+
// --- cli/engine/shared/fonts.mjs ---
|
|
1276
|
+
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
|
|
1277
|
+
|
|
1278
|
+
function normalizeGoogleFontFamilyParam(value) {
|
|
1279
|
+
return String(value || '')
|
|
1280
|
+
.split('|')
|
|
1281
|
+
.map(part => part.split(':')[0].trim().toLowerCase())
|
|
1282
|
+
.filter(Boolean);
|
|
1021
1283
|
}
|
|
1022
1284
|
|
|
1023
|
-
|
|
1285
|
+
function extractGoogleFontFamilies(text) {
|
|
1286
|
+
const families = [];
|
|
1287
|
+
if (!text) return families;
|
|
1024
1288
|
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1289
|
+
GOOGLE_FONTS_URL_RE.lastIndex = 0;
|
|
1290
|
+
let urlMatch;
|
|
1291
|
+
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
|
|
1292
|
+
const url = urlMatch[0];
|
|
1293
|
+
const queryStart = url.indexOf('?');
|
|
1294
|
+
if (queryStart === -1) continue;
|
|
1295
|
+
|
|
1296
|
+
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&/g, '&'));
|
|
1297
|
+
for (const value of params.getAll('family')) {
|
|
1298
|
+
families.push(...normalizeGoogleFontFamilyParam(value));
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return families;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// --- cli/engine/rules/checks.mjs ---
|
|
1306
|
+
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
|
|
1307
|
+
|
|
1308
|
+
// ─── Section 3: Pure Detection ──────────────────────────────────────────────
|
|
1309
|
+
|
|
1310
|
+
function checkBorders(tag, widths, colors, radius, opts = {}) {
|
|
1311
|
+
// Badge-shaped <span>s (own visible background) are a real stripe target
|
|
1312
|
+
// for the top/bottom variant — the inline-tag exemption exists to quiet
|
|
1313
|
+
// text-level borders, not chips. They skip the left/right arms below.
|
|
1314
|
+
const spanBadge = tag === 'span' && !!opts.badgeLike;
|
|
1315
|
+
if (BORDER_SAFE_TAGS.has(tag) && !spanBadge) return [];
|
|
1316
|
+
// A live status/alert region wears a colored single-edge border as a
|
|
1317
|
+
// severity accent (toast, snackbar, callout), not as the side-tab tell.
|
|
1318
|
+
if (opts.statusContext) return [];
|
|
1319
|
+
const findings = [];
|
|
1320
|
+
const sides = ['Top', 'Right', 'Bottom', 'Left'];
|
|
1321
|
+
|
|
1322
|
+
for (const side of sides) {
|
|
1323
|
+
const w = widths[side];
|
|
1324
|
+
if (w < 1 || isNeutralColor(colors[side])) continue;
|
|
1325
|
+
|
|
1326
|
+
const otherSides = sides.filter(s => s !== side);
|
|
1327
|
+
const maxOther = Math.max(...otherSides.map(s => widths[s]));
|
|
1328
|
+
if (!(w >= 2 && (maxOther <= 1 || w >= maxOther * 2))) continue;
|
|
1329
|
+
|
|
1330
|
+
const sn = side.toLowerCase();
|
|
1331
|
+
const isSide = side === 'Left' || side === 'Right';
|
|
1332
|
+
|
|
1333
|
+
if (isSide) {
|
|
1334
|
+
if (spanBadge) continue;
|
|
1335
|
+
if (radius > 0) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
|
|
1336
|
+
else if (w >= 3) findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
|
|
1337
|
+
} else {
|
|
1338
|
+
if (radius > 0 && w >= 2) findings.push({ id: 'border-accent-on-rounded', snippet: `border-${sn}: ${w}px + border-radius: ${radius}px` });
|
|
1339
|
+
// Horizontal variant of the side-tab stripe: a thick chromatic accent
|
|
1340
|
+
// riding the top or bottom edge of a card/badge/container. Same
|
|
1341
|
+
// dominant-edge + chroma gates as left/right, 3-12px band. Selected-
|
|
1342
|
+
// tab underlines are exempt via opts.tabContext (adapters look for
|
|
1343
|
+
// tablist/nav/tab ancestors and aria-selected); links, buttons,
|
|
1344
|
+
// table cells, and <hr> never reach here (BORDER_SAFE_TAGS).
|
|
1345
|
+
else if (!opts.tabContext && w >= 3 && w <= 12) {
|
|
1346
|
+
findings.push({ id: 'side-tab', snippet: `border-${sn}: ${w}px` });
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
return findings;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
|
|
1355
|
+
//
|
|
1356
|
+
// An element-scoped waiver that travels with the markup: any element carrying
|
|
1357
|
+
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
|
|
1358
|
+
// every rule) suppresses matching findings from itself and its entire subtree,
|
|
1359
|
+
// in every engine that walks elements — the browser overlay, the extension,
|
|
1360
|
+
// and the static scan. This is the DOM twin of the line-based
|
|
1361
|
+
// `impeccable-disable` comment directives, which the browser cannot apply (a
|
|
1362
|
+
// live DOM has no line numbers), and the generalization of the one-off
|
|
1363
|
+
// `data-impeccable-allow-kickers` opt-out.
|
|
1364
|
+
//
|
|
1365
|
+
// The intended use is curated exhibits: a page that documents anti-patterns by
|
|
1366
|
+
// example, or renders a deliberate "before" specimen, marks the container once
|
|
1367
|
+
// and every engine skips it while still scanning the page around it.
|
|
1368
|
+
function scopedIgnoreActive(el, ruleId) {
|
|
1369
|
+
const rule = String(ruleId || '').toLowerCase();
|
|
1370
|
+
let cur = el;
|
|
1371
|
+
while (cur && cur.nodeType === 1) {
|
|
1372
|
+
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
|
|
1373
|
+
if (attr != null) {
|
|
1374
|
+
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
|
|
1375
|
+
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
|
|
1376
|
+
}
|
|
1377
|
+
cur = cur.parentElement;
|
|
1378
|
+
}
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Returns true if the given text is composed entirely of emoji characters
|
|
1383
|
+
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
|
1384
|
+
// regardless of CSS `color`, so contrast checks against the element's text
|
|
1385
|
+
// color are meaningless for these nodes.
|
|
1386
|
+
const EMOJI_CHAR_RE = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/u;
|
|
1387
|
+
const EMOJI_CHARS_GLOBAL = /[\u{1F1E6}-\u{1F1FF}\u{1F300}-\u{1F9FF}\u{1FA00}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\u{FE0F}\u{200D}\u{1F3FB}-\u{1F3FF}]/gu;
|
|
1388
|
+
function isEmojiOnlyText(text) {
|
|
1389
|
+
if (!text) return false;
|
|
1390
|
+
if (!EMOJI_CHAR_RE.test(text)) return false;
|
|
1391
|
+
return text.replace(EMOJI_CHARS_GLOBAL, '').trim() === '';
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function checkColors(opts) {
|
|
1395
|
+
const { tag, textColor, bgColor, effectiveBg, effectiveBgStops, fontSize, fontWeight, hasDirectText, isEmojiOnly, bgClip, bgImage, classList } = opts;
|
|
1396
|
+
if (SAFE_TAGS.has(tag)) {
|
|
1397
|
+
// Exception for elements styled as controls or chips. SAFE_TAGS exists to
|
|
1398
|
+
// suppress contrast noise on inline links and unstyled spans, where the
|
|
1399
|
+
// element has no own background and the contrast against the ancestor
|
|
1400
|
+
// surface is already the intended visual. When the element paints its own
|
|
1401
|
+
// opaque background under direct text, it is a styled button, chip, or
|
|
1402
|
+
// badge regardless of tag, and contrast on its own surface is a real,
|
|
1403
|
+
// frequent bug worth flagging. (The shipped miss: a <span> severity chip
|
|
1404
|
+
// whose white text lost a specificity fight and rendered muted-on-red at
|
|
1405
|
+
// 1.2:1; the old a/button-only exception never looked at it.) The 9px
|
|
1406
|
+
// font floor keeps sub-text decorations out.
|
|
1407
|
+
const isStyledControl = hasDirectText
|
|
1408
|
+
&& ((bgColor && bgColor.a > 0.5)
|
|
1409
|
+
// A gradient painted on the element itself is an own surface the
|
|
1410
|
+
// same way a solid background is. Without this branch a nav CTA
|
|
1411
|
+
// built as `<a>` with `background: linear-gradient(…)` and a text
|
|
1412
|
+
// color that fails against every stop sails through on the
|
|
1413
|
+
// SAFE_TAGS suppression (the shipped escape).
|
|
1414
|
+
|| (bgImage && /gradient/i.test(bgImage)))
|
|
1415
|
+
&& fontSize >= 9;
|
|
1416
|
+
if (!isStyledControl) return [];
|
|
1417
|
+
}
|
|
1418
|
+
const findings = [];
|
|
1419
|
+
|
|
1420
|
+
if (hasDirectText && textColor && !isEmojiOnly) {
|
|
1421
|
+
// Gradient-clipped text (`background-clip: text`, typically with a
|
|
1422
|
+
// transparent text-fill) paints its glyphs *with* the element's own
|
|
1423
|
+
// gradient. The `color` value the cascade still reports is never painted,
|
|
1424
|
+
// and the gradient is the fill, not a backdrop — so measuring `color`
|
|
1425
|
+
// against that gradient (which resolveGradientStops picks up as the
|
|
1426
|
+
// element's own background-image) is a guaranteed false positive
|
|
1427
|
+
// (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text
|
|
1428
|
+
// rule below still flags the pattern itself. Skipping a rule beats a false
|
|
1429
|
+
// positive here — the true painted contrast can't be measured from `color`.
|
|
1430
|
+
const isGradientClippedText = bgClip === 'text';
|
|
1431
|
+
// Run background-dependent checks against either a solid bg or, if the
|
|
1432
|
+
// ancestor is a gradient, against every gradient stop (use the worst case).
|
|
1433
|
+
const bgs = isGradientClippedText
|
|
1434
|
+
? null
|
|
1435
|
+
: (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null));
|
|
1436
|
+
if (bgs) {
|
|
1437
|
+
// Gray on colored background — flag if every stop is chromatic
|
|
1438
|
+
const textLum = relativeLuminance(textColor);
|
|
1439
|
+
const isGray = !hasChroma(textColor, 20) && textLum > 0.05 && textLum < 0.85;
|
|
1440
|
+
if (isGray && bgs.every(b => hasChroma(b, 40))) {
|
|
1441
|
+
const bgLabel = effectiveBg ? colorToHex(effectiveBg) : `gradient(${bgs.map(colorToHex).join(', ')})`;
|
|
1442
|
+
findings.push({ id: 'gray-on-color', snippet: `text ${colorToHex(textColor)} on bg ${bgLabel}` });
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Low contrast (WCAG AA) — worst case across all bg stops
|
|
1446
|
+
const ratios = bgs.map(b => contrastRatio(textColor, b));
|
|
1447
|
+
let worstIdx = 0;
|
|
1448
|
+
for (let i = 1; i < ratios.length; i++) if (ratios[i] < ratios[worstIdx]) worstIdx = i;
|
|
1449
|
+
const ratio = ratios[worstIdx];
|
|
1450
|
+
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
|
|
1451
|
+
const threshold = isLargeText ? 3.0 : 4.5;
|
|
1452
|
+
if (ratio < threshold) {
|
|
1453
|
+
// Skip the false-positive class where text has alpha < 1 AND we
|
|
1454
|
+
// couldn't find an opaque ancestor (effectiveBg is null, we're
|
|
1455
|
+
// comparing against gradient-stop fallback). In jsdom mode the
|
|
1456
|
+
// detector can't resolve `var(--X)` color tokens, so a dark
|
|
1457
|
+
// section sitting between the text and the body's decorative
|
|
1458
|
+
// gradient is invisible to us — we end up measuring contrast
|
|
1459
|
+
// against the body's paper-grain noise instead of the real
|
|
1460
|
+
// local bg. Real low-contrast bugs use alpha=1 and have a
|
|
1461
|
+
// resolvable opaque ancestor; semi-transparent Tailwind tokens
|
|
1462
|
+
// like `text-paper/60` on `bg-ink` sections are the FP pattern.
|
|
1463
|
+
const isAlphaFallbackFP = !DETECTOR_IS_BROWSER && !effectiveBg && (textColor.a != null && textColor.a < 1);
|
|
1464
|
+
if (!isAlphaFallbackFP) {
|
|
1465
|
+
// Near-threshold ratios (e.g. 4.497) would round to the threshold
|
|
1466
|
+
// itself at one decimal and read as "4.5 needs 4.5" — show two
|
|
1467
|
+
// decimals there so the finding stays legible.
|
|
1468
|
+
const ratioLabel = ratio.toFixed(1) === threshold.toFixed(1) ? ratio.toFixed(2) : ratio.toFixed(1);
|
|
1469
|
+
findings.push({ id: 'low-contrast', snippet: `${ratioLabel}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bgs[worstIdx])}` });
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// AI palette: purple/violet on headings
|
|
1475
|
+
if (hasChroma(textColor, 50)) {
|
|
1476
|
+
const hue = getHue(textColor);
|
|
1477
|
+
if (hue >= 260 && hue <= 310 && (['h1', 'h2', 'h3'].includes(tag) || fontSize >= 20)) {
|
|
1478
|
+
findings.push({ id: 'ai-color-palette', snippet: `Purple/violet text (${colorToHex(textColor)}) on heading` });
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// Gradient text
|
|
1484
|
+
if (bgClip === 'text' && bgImage && bgImage.includes('gradient')) {
|
|
1485
|
+
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// Tailwind class checks
|
|
1489
|
+
if (classList) {
|
|
1490
|
+
const classStr = typeof classList === 'string' ? classList : Array.from(classList).join(' ');
|
|
1491
|
+
|
|
1492
|
+
const grayMatch = classStr.match(/\btext-(?:gray|slate|zinc|neutral|stone)-\d+\b/);
|
|
1493
|
+
const colorBgMatch = classStr.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/);
|
|
1494
|
+
if (grayMatch && colorBgMatch) {
|
|
1495
|
+
findings.push({ id: 'gray-on-color', snippet: `${grayMatch[0]} on ${colorBgMatch[0]}` });
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
if (/\bbg-clip-text\b/.test(classStr) && /\bbg-gradient-to-/.test(classStr)) {
|
|
1499
|
+
findings.push({ id: 'gradient-text', snippet: 'bg-clip-text + bg-gradient (Tailwind)' });
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
const purpleText = classStr.match(/\btext-(?:purple|violet|indigo)-\d+\b/);
|
|
1503
|
+
if (purpleText && (['h1', 'h2', 'h3'].includes(tag) || /\btext-(?:[2-9]xl)\b/.test(classStr))) {
|
|
1504
|
+
findings.push({ id: 'ai-color-palette', snippet: `${purpleText[0]} on heading` });
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
if (/\bfrom-(?:purple|violet|indigo)-\d+\b/.test(classStr) && /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(classStr)) {
|
|
1508
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet gradient (Tailwind)' });
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
return findings;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
// WCAG contrast for the :hover state of an element whose hover rules change
|
|
1516
|
+
// its text color and/or background. The classic miss: a nav CTA whose
|
|
1517
|
+
// author-intended hover pair passes AA, but a broader selector (e.g.
|
|
1518
|
+
// `.nav-links a:hover`) wins the specificity fight and swaps in a color
|
|
1519
|
+
// that fails. Only fires on elements that present as styled controls —
|
|
1520
|
+
// direct text plus an opaque-ish own background in either state — so plain
|
|
1521
|
+
// inline links keep the same suppression they get in checkColors.
|
|
1522
|
+
function checkHoverContrast(opts) {
|
|
1523
|
+
const { tag, textColor, bg, ownBgAlpha, fontSize, fontWeight, hasDirectText, isEmojiOnly } = opts;
|
|
1524
|
+
if (!hasDirectText || isEmojiOnly || !textColor || !bg) return [];
|
|
1525
|
+
if (SAFE_TAGS.has(tag) && !(ownBgAlpha != null && ownBgAlpha > 0.5)) return [];
|
|
1526
|
+
const ratio = contrastRatio(textColor, bg);
|
|
1527
|
+
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
|
|
1528
|
+
const threshold = isLargeText ? 3.0 : 4.5;
|
|
1529
|
+
if (ratio >= threshold) return [];
|
|
1530
|
+
return [{
|
|
1531
|
+
id: 'low-contrast',
|
|
1532
|
+
snippet: `:hover state ${ratio.toFixed(1)}:1 (need ${threshold}:1) — text ${colorToHex(textColor)} on ${colorToHex(bg)}`,
|
|
1533
|
+
}];
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function isCardLikeFromProps(hasShadow, hasBorder, hasRadius, hasBg) {
|
|
1537
|
+
if (!hasShadow && !hasBorder) return false;
|
|
1538
|
+
return hasRadius || hasBg;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
|
|
1542
|
+
|
|
1543
|
+
// Pure check: given a heading and metrics about its previousElementSibling,
|
|
1544
|
+
// decide if the sibling is the canonical "icon-tile-stacked-above-heading" shape.
|
|
1545
|
+
//
|
|
1546
|
+
// Triggers when ALL of the following hold for the sibling:
|
|
1547
|
+
// • size 32–128px on both axes (not too small, not a hero image)
|
|
1548
|
+
// • aspect ratio 0.7–1.4 (squarish — excludes wide thumbnails / pill badges)
|
|
1549
|
+
// • has a non-transparent background-color, background-image, OR a visible border
|
|
1550
|
+
// (covers solid colors, white-with-border, gradients — anything that visually
|
|
1551
|
+
// defines a tile)
|
|
1552
|
+
// • border-radius < width/2 (excludes round avatars; rounded squares pass)
|
|
1553
|
+
// • contains an <svg> or icon-class <i> element that's smaller than the tile
|
|
1554
|
+
// • the tile sits above the heading (its bottom is above the heading's top)
|
|
1555
|
+
function checkIconTile(opts) {
|
|
1556
|
+
const { headingTag, headingText, headingTop,
|
|
1557
|
+
siblingTag, siblingWidth, siblingHeight, siblingBottom,
|
|
1558
|
+
siblingBgColor, siblingBgImage, siblingBorderWidth, siblingBorderRadius,
|
|
1041
1559
|
hasIconChild, iconChildWidth } = opts;
|
|
1042
1560
|
if (!HEADING_TAGS.has(headingTag)) return [];
|
|
1043
1561
|
if (!siblingTag) return [];
|
|
@@ -1087,7 +1605,7 @@ function checkIconTile(opts) {
|
|
|
1087
1605
|
function resolveSerif(fontFamily) {
|
|
1088
1606
|
if (!fontFamily) return { primary: null, isSerif: false };
|
|
1089
1607
|
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
|
1090
|
-
const primary =
|
|
1608
|
+
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
|
1091
1609
|
if (!primary) return { primary: null, isSerif: false };
|
|
1092
1610
|
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
|
1093
1611
|
if (tokens.includes('serif')) return { primary, isSerif: true };
|
|
@@ -1428,6 +1946,31 @@ function cssTextHasDarkRootBg(content, customProps) {
|
|
|
1428
1946
|
return false;
|
|
1429
1947
|
}
|
|
1430
1948
|
|
|
1949
|
+
// Best-effort extraction of the CSS selector whose declaration block contains
|
|
1950
|
+
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
|
|
1951
|
+
// anchor, so the browser pass can resolve scoped ignores against the actual
|
|
1952
|
+
// element and drop patterns that render nowhere on the page. Returns null for
|
|
1953
|
+
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
|
|
1954
|
+
// read as a selector; those findings stay page-level.
|
|
1955
|
+
function enclosingCssSelector(cssText, index) {
|
|
1956
|
+
if (!cssText || !Number.isFinite(index)) return null;
|
|
1957
|
+
const open = cssText.lastIndexOf('{', index);
|
|
1958
|
+
if (open === -1) return null;
|
|
1959
|
+
// A match inside an inline style fragment (`style="…"` appended to the
|
|
1960
|
+
// corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
|
|
1961
|
+
// `{` belongs to some other selector.
|
|
1962
|
+
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
|
1963
|
+
if (closeBeforeIndex > open) return null;
|
|
1964
|
+
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
|
1965
|
+
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
|
|
1966
|
+
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
|
1967
|
+
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
|
1968
|
+
// and `to` would read as (never-matching) type selectors and get a valid
|
|
1969
|
+
// finding wrongly dropped by the zero-match rule downstream.
|
|
1970
|
+
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
|
|
1971
|
+
return raw;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1431
1974
|
function scanCssTextForGlow(content) {
|
|
1432
1975
|
const customProps = collectCssCustomProps(content);
|
|
1433
1976
|
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
|
@@ -1457,20 +2000,19 @@ function scanCssTextForGlow(content) {
|
|
|
1457
2000
|
return results;
|
|
1458
2001
|
}
|
|
1459
2002
|
|
|
1460
|
-
// Decorative grid
|
|
2003
|
+
// Decorative two-axis grid backgrounds drawn with hairline
|
|
1461
2004
|
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
|
|
1462
2005
|
// pattern pass and the regex source engine so standalone CSS, component
|
|
1463
2006
|
// styles, and inline styles receive the same coverage. Both signals must
|
|
1464
2007
|
// co-occur in one declaration block; unrelated rules must not add up across
|
|
1465
|
-
// the file.
|
|
1466
|
-
//
|
|
2008
|
+
// the file. A single hairline is a line, divider, or rail, not a grid, even
|
|
2009
|
+
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
|
|
2010
|
+
// finding per source to match the page-level HTML check's existing behavior.
|
|
1467
2011
|
function scanCssTextForGridBackground(content) {
|
|
1468
2012
|
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
|
|
1469
2013
|
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
|
|
1470
2014
|
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
|
|
1471
|
-
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
|
|
1472
2015
|
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
|
|
1473
|
-
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
|
|
1474
2016
|
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
|
|
1475
2017
|
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
|
|
1476
2018
|
let blk;
|
|
@@ -1487,13 +2029,10 @@ function scanCssTextForGridBackground(content) {
|
|
|
1487
2029
|
}
|
|
1488
2030
|
if (hairlineCount === 0) continue;
|
|
1489
2031
|
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
|
1490
|
-
|
|
1491
|
-
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
|
2032
|
+
if (hairlineCount >= 2 && hasPxCell) {
|
|
1492
2033
|
return [{
|
|
1493
2034
|
index: blk.index,
|
|
1494
|
-
snippet:
|
|
1495
|
-
? 'two-axis grid-line gradient background'
|
|
1496
|
-
: 'px-tiled hairline line-field background',
|
|
2035
|
+
snippet: 'two-axis grid-line gradient background',
|
|
1497
2036
|
}];
|
|
1498
2037
|
}
|
|
1499
2038
|
}
|
|
@@ -1739,6 +2278,7 @@ function scanCssTextForPseudoStripe(rawContent) {
|
|
|
1739
2278
|
id: 'side-tab',
|
|
1740
2279
|
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
|
1741
2280
|
index: selectorStart,
|
|
2281
|
+
selector,
|
|
1742
2282
|
});
|
|
1743
2283
|
}
|
|
1744
2284
|
return findings;
|
|
@@ -1801,6 +2341,7 @@ function scanCssTextForInsetStripe(content) {
|
|
|
1801
2341
|
findings.push({
|
|
1802
2342
|
id: 'side-tab',
|
|
1803
2343
|
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
|
2344
|
+
selector,
|
|
1804
2345
|
});
|
|
1805
2346
|
break;
|
|
1806
2347
|
}
|
|
@@ -1858,7 +2399,7 @@ function collectMarqueeKeyframes(content) {
|
|
|
1858
2399
|
function scanCssTextForMarquee(content, markup = content) {
|
|
1859
2400
|
const findings = [];
|
|
1860
2401
|
if (/<marquee\b/i.test(markup)) {
|
|
1861
|
-
findings.push({ id: 'marquee', snippet: '<marquee> element' });
|
|
2402
|
+
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
|
|
1862
2403
|
}
|
|
1863
2404
|
const marqueeKeyframes = collectMarqueeKeyframes(content);
|
|
1864
2405
|
if (marqueeKeyframes.size === 0) return findings;
|
|
@@ -1873,7 +2414,7 @@ function scanCssTextForMarquee(content, markup = content) {
|
|
|
1873
2414
|
const key = `${selector} ${name}`;
|
|
1874
2415
|
if (seen.has(key)) continue;
|
|
1875
2416
|
seen.add(key);
|
|
1876
|
-
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"
|
|
2417
|
+
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
|
|
1877
2418
|
}
|
|
1878
2419
|
}
|
|
1879
2420
|
return findings;
|
|
@@ -2187,7 +2728,96 @@ function scanHtmlForShapeAssembledIllustration(html) {
|
|
|
2187
2728
|
return findings;
|
|
2188
2729
|
}
|
|
2189
2730
|
|
|
2190
|
-
|
|
2731
|
+
|
|
2732
|
+
// --- Organic clip-path polygons ----------------------------------------------
|
|
2733
|
+
// A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
|
|
2734
|
+
// with curves, is CSS approximating an organic contour: a torn edge, a blob,
|
|
2735
|
+
// a silhouette. The approximation reads as the cheap version of the effect
|
|
2736
|
+
// (the craft floor's geometric-occlusion-mask ban), and it is the signature
|
|
2737
|
+
// of a comp's produced material being replaced with code. Geometric clips
|
|
2738
|
+
// (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
|
|
2739
|
+
// the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
|
|
2740
|
+
// from an alpha matte passes.
|
|
2741
|
+
const ORGANIC_POLYGON_MIN_VERTICES = 10;
|
|
2742
|
+
function scanCssTextForOrganicClipPath(styleText) {
|
|
2743
|
+
const findings = [];
|
|
2744
|
+
const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
|
|
2745
|
+
let m;
|
|
2746
|
+
while ((m = re.exec(styleText)) !== null) {
|
|
2747
|
+
const kind = m[1].toLowerCase();
|
|
2748
|
+
const body = m[2];
|
|
2749
|
+
if (kind === 'path') {
|
|
2750
|
+
// curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a
|
|
2751
|
+
// rectilinear M/L/Z outline; letters in path data are only commands
|
|
2752
|
+
const curves = (body.match(/[CSQTA]/gi) || []).length;
|
|
2753
|
+
if (curves < 3) continue;
|
|
2754
|
+
findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
|
2755
|
+
continue;
|
|
2756
|
+
}
|
|
2757
|
+
const points = body.split(',').map((p) => p.trim()).filter(Boolean);
|
|
2758
|
+
if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
|
|
2759
|
+
// Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
|
|
2760
|
+
// contour has arbitrary values.
|
|
2761
|
+
let offGrid = 0;
|
|
2762
|
+
for (const p of points) {
|
|
2763
|
+
const nums = p.match(/-?[\d.]+/g) || [];
|
|
2764
|
+
for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
|
|
2765
|
+
}
|
|
2766
|
+
if (offGrid < points.length) continue;
|
|
2767
|
+
findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
|
2768
|
+
}
|
|
2769
|
+
return findings;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// --- Buried raster ------------------------------------------------------------
|
|
2773
|
+
// A raster (background-image url or <img>) that never reaches the screen:
|
|
2774
|
+
// under a near-opaque gradient wash in the same background stack, or on an
|
|
2775
|
+
// element at near-zero opacity. It is how a produced texture "ships" while
|
|
2776
|
+
// the page shows flat color, and the finish reviewer cannot see it either.
|
|
2777
|
+
// A tint under 0.9 alpha passes (hero darkening); a blend mode passes
|
|
2778
|
+
// (multiply/overlay keep the material visible); opacity >= 0.15 passes.
|
|
2779
|
+
function scanCssTextForBuriedRaster(styleText) {
|
|
2780
|
+
const findings = [];
|
|
2781
|
+
// background stacks: split declarations, look for url() + a gradient whose
|
|
2782
|
+
// stops all carry alpha >= 0.9 (or opaque hex/named colors)
|
|
2783
|
+
const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
|
|
2784
|
+
let m;
|
|
2785
|
+
while ((m = declRe.exec(styleText)) !== null) {
|
|
2786
|
+
const value = m[1];
|
|
2787
|
+
if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
|
|
2788
|
+
// a blend mode declared in the same rule keeps the raster visible
|
|
2789
|
+
const ruleStart = styleText.lastIndexOf('{', m.index);
|
|
2790
|
+
const ruleEnd = styleText.indexOf('}', m.index);
|
|
2791
|
+
const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
|
|
2792
|
+
if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
|
|
2793
|
+
// Layers are painted first-on-top: only a wash listed BEFORE the url()
|
|
2794
|
+
// covers it. An image on top of a gradient is not buried.
|
|
2795
|
+
const firstUrl = value.search(/url\(/i);
|
|
2796
|
+
const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]);
|
|
2797
|
+
let opaqueWash = false;
|
|
2798
|
+
// an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8
|
|
2799
|
+
const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; };
|
|
2800
|
+
for (const g of gradients) {
|
|
2801
|
+
const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)|hsla?\([^)]*?(?:[,/]\s*([\d.]+%?))?\s*\)/gi)].map((a) => alphaOf(a[1] ?? a[2]));
|
|
2802
|
+
const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '');
|
|
2803
|
+
// hex stops: 4- and 8-digit forms carry their own alpha
|
|
2804
|
+
for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) {
|
|
2805
|
+
const hex = h[1];
|
|
2806
|
+
if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255);
|
|
2807
|
+
else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255);
|
|
2808
|
+
else alphas.push(1);
|
|
2809
|
+
}
|
|
2810
|
+
const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped);
|
|
2811
|
+
if (named) alphas.push(1);
|
|
2812
|
+
if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; }
|
|
2813
|
+
}
|
|
2814
|
+
if (!opaqueWash) continue;
|
|
2815
|
+
findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
|
2816
|
+
}
|
|
2817
|
+
return findings;
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
// Scoped scan corpora for the page-level pattern checks. CSS-property
|
|
2191
2821
|
// regexes run over the whole source string fire on documentation ABOUT
|
|
2192
2822
|
// css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
|
|
2193
2823
|
// comments — so the checks scan only the strings that actually style the
|
|
@@ -2244,8 +2874,10 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
2244
2874
|
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
|
2245
2875
|
if (purpleHexRe.test(styleText)) {
|
|
2246
2876
|
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
|
2247
|
-
|
|
2248
|
-
|
|
2877
|
+
purpleTextRe.lastIndex = 0;
|
|
2878
|
+
const purpleMatch = purpleTextRe.exec(styleText);
|
|
2879
|
+
if (purpleMatch) {
|
|
2880
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
|
|
2249
2881
|
}
|
|
2250
2882
|
}
|
|
2251
2883
|
|
|
@@ -2256,7 +2888,7 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
2256
2888
|
const start = Math.max(0, gm.index - 200);
|
|
2257
2889
|
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
|
2258
2890
|
if (/gradient/i.test(context)) {
|
|
2259
|
-
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
|
2891
|
+
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
|
|
2260
2892
|
break;
|
|
2261
2893
|
}
|
|
2262
2894
|
}
|
|
@@ -2322,7 +2954,7 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
2322
2954
|
const animationToken = bounceMatch[1]
|
|
2323
2955
|
.split(/[,\s]+/)
|
|
2324
2956
|
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
|
2325
|
-
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}
|
|
2957
|
+
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
|
|
2326
2958
|
}
|
|
2327
2959
|
|
|
2328
2960
|
// Overshoot cubic-bezier
|
|
@@ -2331,7 +2963,7 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
2331
2963
|
while ((bm = bezierRe.exec(styleText)) !== null) {
|
|
2332
2964
|
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
|
2333
2965
|
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
|
2334
|
-
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})
|
|
2966
|
+
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
|
|
2335
2967
|
break;
|
|
2336
2968
|
}
|
|
2337
2969
|
}
|
|
@@ -2357,6 +2989,10 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
2357
2989
|
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
|
|
2358
2990
|
findings.push(...scanHtmlForShapeAssembledIllustration(html));
|
|
2359
2991
|
|
|
2992
|
+
// Organic clip-path contours and rasters buried under washes or opacity
|
|
2993
|
+
findings.push(...scanCssTextForOrganicClipPath(styleText));
|
|
2994
|
+
findings.push(...scanCssTextForBuriedRaster(styleText));
|
|
2995
|
+
|
|
2360
2996
|
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
|
|
2361
2997
|
findings.push(...scanCssTextForMarquee(styleText, html));
|
|
2362
2998
|
|
|
@@ -2364,18 +3000,21 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
2364
3000
|
|
|
2365
3001
|
const glowHits = scanCssTextForGlow(styleText);
|
|
2366
3002
|
if (glowHits.length > 0) {
|
|
2367
|
-
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
|
|
3003
|
+
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
|
|
2368
3004
|
}
|
|
2369
3005
|
|
|
2370
3006
|
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
|
|
2371
3007
|
const haloHits = scanCssTextForRadialHalo(styleText);
|
|
2372
3008
|
if (haloHits.length > 0) {
|
|
2373
|
-
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
|
|
3009
|
+
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
|
|
2374
3010
|
}
|
|
2375
3011
|
|
|
2376
3012
|
// --- Generated-UI tells: repeating-gradient stripes ---
|
|
2377
|
-
|
|
2378
|
-
|
|
3013
|
+
{
|
|
3014
|
+
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
|
|
3015
|
+
if (stripesMatch) {
|
|
3016
|
+
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
|
|
3017
|
+
}
|
|
2379
3018
|
}
|
|
2380
3019
|
|
|
2381
3020
|
// --- Generated-UI tells: two-axis grid-line background ---
|
|
@@ -2393,7 +3032,7 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
2393
3032
|
// whole gradient layers.
|
|
2394
3033
|
const gridHits = scanCssTextForGridBackground(styleText);
|
|
2395
3034
|
if (gridHits.length > 0) {
|
|
2396
|
-
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
|
|
3035
|
+
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
|
|
2397
3036
|
}
|
|
2398
3037
|
|
|
2399
3038
|
// --- Generated-copy tells: "X theater" framing copy ---
|
|
@@ -2413,8 +3052,11 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
2413
3052
|
// hover:rotate / hover:translate utility on an <img>. Each distinct
|
|
2414
3053
|
// mechanism is its own finding.
|
|
2415
3054
|
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
|
|
2416
|
-
|
|
2417
|
-
|
|
3055
|
+
{
|
|
3056
|
+
const imgHoverMatch = imgHoverCss.exec(styleText);
|
|
3057
|
+
if (imgHoverMatch) {
|
|
3058
|
+
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
|
|
3059
|
+
}
|
|
2418
3060
|
}
|
|
2419
3061
|
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
|
|
2420
3062
|
let im;
|
|
@@ -2461,7 +3103,46 @@ function readOwnBackgroundColor(el, computedStyle) {
|
|
|
2461
3103
|
return bg;
|
|
2462
3104
|
}
|
|
2463
3105
|
|
|
2464
|
-
|
|
3106
|
+
// One element's background-color as the cascade walk sees it: computed style
|
|
3107
|
+
// first (with the modern-color fallback), then, in static mode only,
|
|
3108
|
+
// custom-prop resolution and the inline-shorthand peek. Shared by
|
|
3109
|
+
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
|
|
3110
|
+
// surfaces.
|
|
3111
|
+
function readCascadeBackgroundColor(current, style, customPropMap) {
|
|
3112
|
+
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
|
3113
|
+
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
|
|
3114
|
+
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
|
|
3115
|
+
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
|
|
3116
|
+
if (customPropMap) {
|
|
3117
|
+
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
|
3118
|
+
}
|
|
3119
|
+
if (!bg || bg.a < 0.1) {
|
|
3120
|
+
// Inline-style fallback for colors the static cascade did not surface
|
|
3121
|
+
// on backgroundColor.
|
|
3122
|
+
const rawStyle = current.getAttribute?.('style') || '';
|
|
3123
|
+
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
|
3124
|
+
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
|
3125
|
+
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
|
3126
|
+
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
return bg;
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
// Walk up for the surface the element's text is painted on.
|
|
3134
|
+
//
|
|
3135
|
+
// Returns { color, unresolved }:
|
|
3136
|
+
// • color set — the effective surface, overlays composited in.
|
|
3137
|
+
// • unresolved: true — a layer on the way up paints a color this parser
|
|
3138
|
+
// cannot read, so the surface is unknown. Callers
|
|
3139
|
+
// must SKIP their contrast checks. Guessing white
|
|
3140
|
+
// here is what flooded dark themes with false
|
|
3141
|
+
// "on #ffffff" findings: one abstention costs a
|
|
3142
|
+
// single finding, one wrong guess costs a hundred.
|
|
3143
|
+
// • both null/false — no solid color, but a gradient or image is in
|
|
3144
|
+
// play; callers fall back to its color stops.
|
|
3145
|
+
function resolveBackgroundInfo(el, win, customPropMap) {
|
|
2465
3146
|
let current = el;
|
|
2466
3147
|
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
|
|
2467
3148
|
// base. A browser composites these over the base; the old behavior
|
|
@@ -2489,67 +3170,114 @@ function resolveBackground(el, win, customPropMap) {
|
|
|
2489
3170
|
// body backgrounds.
|
|
2490
3171
|
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
|
|
2491
3172
|
// (e.g. any color-mix() result), which plain parseRgb misses.
|
|
2492
|
-
let bg =
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
|
2507
|
-
}
|
|
2508
|
-
}
|
|
3173
|
+
let bg = readCascadeBackgroundColor(current, style, customPropMap);
|
|
3174
|
+
|
|
3175
|
+
// `background-color: currentcolor` paints with the element's own text
|
|
3176
|
+
// color — real paint whose value we know. Real browsers resolve the
|
|
3177
|
+
// keyword before getComputedStyle output; jsdom hands it through
|
|
3178
|
+
// verbatim, and without this substitution the layer would read as
|
|
3179
|
+
// unparseable and force a needless abstention.
|
|
3180
|
+
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
|
|
3181
|
+
// The static cascade resolves var() text tokens before checks run, so
|
|
3182
|
+
// style.color is normally already an rgb string here; parseColorResolved
|
|
3183
|
+
// is defense in depth for any future caller that passes a live
|
|
3184
|
+
// customPropMap (it matches the text-color path in checkElementColors
|
|
3185
|
+
// and reduces to parseAnyColor when the map is null or absent).
|
|
3186
|
+
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
|
|
2509
3187
|
}
|
|
2510
3188
|
|
|
2511
3189
|
if (bg && bg.a > 0.1) {
|
|
2512
|
-
if (bg.a >= 0.99) return flatten(bg);
|
|
3190
|
+
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
|
|
2513
3191
|
overlays.push(bg);
|
|
2514
|
-
}
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
//
|
|
2523
|
-
//
|
|
2524
|
-
//
|
|
2525
|
-
//
|
|
2526
|
-
//
|
|
3192
|
+
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
|
|
3193
|
+
// This layer names a color we could not parse (a color space we do not
|
|
3194
|
+
// model, an unresolved var(), a syntax newer than the parser). It may
|
|
3195
|
+
// well be opaque, which would make every ancestor below it invisible —
|
|
3196
|
+
// so the surface is unknown and the walk stops here rather than
|
|
3197
|
+
// reporting an ancestor the visitor never sees.
|
|
3198
|
+
return { color: null, unresolved: true };
|
|
3199
|
+
}
|
|
3200
|
+
// No solid bg-color at this level, but this level paints an image. CSS
|
|
3201
|
+
// stacks background-image layers first-on-top, so which layer leads
|
|
3202
|
+
// decides what the visitor sees:
|
|
3203
|
+
// • gradient on top — the gradient is the surface. Hand the caller a
|
|
3204
|
+
// null color so it falls back to the gradient's own stops (body
|
|
3205
|
+
// grounds, gradient buttons, hero sections).
|
|
3206
|
+
// • url() on top — the surface is an image whose pixels this engine
|
|
3207
|
+
// cannot read, and it may fully cover every layer and ancestor
|
|
3208
|
+
// beneath it. Same contract as an unparseable color: abstain, so
|
|
3209
|
+
// the gradient-stop fallback never measures a gradient the image
|
|
3210
|
+
// hides (the shipped miss: `url(photo), linear-gradient(...)`
|
|
3211
|
+
// reported low-contrast against the invisible gradient's stops).
|
|
2527
3212
|
if (hasGradientOrUrl) {
|
|
2528
|
-
|
|
2529
|
-
|
|
3213
|
+
const layers = splitTopLevelCommas(bgImage);
|
|
3214
|
+
const topPaintLayer = layers.find(
|
|
3215
|
+
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
|
|
3216
|
+
);
|
|
3217
|
+
const gradientOnTop = !!topPaintLayer
|
|
3218
|
+
&& /gradient\s*\(/i.test(topPaintLayer)
|
|
3219
|
+
&& !/^\s*url\s*\(/i.test(topPaintLayer);
|
|
3220
|
+
if (!gradientOnTop) return { color: null, unresolved: true };
|
|
3221
|
+
// Gradient on top of a url() layer: the image shows through wherever
|
|
3222
|
+
// the gradient is not fully opaque, so a translucent wash like
|
|
3223
|
+
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
|
|
3224
|
+
// a blend with pixels this engine cannot read. Only a gradient whose
|
|
3225
|
+
// every readable stop is opaque provably covers the image; otherwise
|
|
3226
|
+
// the surface is unknown — abstain rather than hand callers gradient
|
|
3227
|
+
// stops (or a stop average) the visitor never sees unmixed.
|
|
3228
|
+
const urlBeneath = layers.some(
|
|
3229
|
+
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
|
|
3230
|
+
);
|
|
3231
|
+
if (urlBeneath) {
|
|
3232
|
+
const topStops = parseGradientColors(topPaintLayer);
|
|
3233
|
+
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
|
|
3234
|
+
if (!provablyOpaque) return { color: null, unresolved: true };
|
|
2530
3235
|
}
|
|
2531
|
-
return null;
|
|
3236
|
+
return { color: null, unresolved: false };
|
|
2532
3237
|
}
|
|
2533
3238
|
current = current.parentElement;
|
|
2534
3239
|
}
|
|
2535
|
-
|
|
3240
|
+
// Every layer up to the document root was genuinely see-through, so the
|
|
3241
|
+
// browser paints its default canvas. This is the ONLY case that earns the
|
|
3242
|
+
// white assumption.
|
|
3243
|
+
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
function resolveBackground(el, win, customPropMap) {
|
|
3247
|
+
return resolveBackgroundInfo(el, win, customPropMap).color;
|
|
2536
3248
|
}
|
|
2537
3249
|
|
|
2538
3250
|
// Walk parents looking for a gradient background and return its color stops.
|
|
2539
3251
|
// Used as a fallback when resolveBackground() returns null because the
|
|
2540
3252
|
// effective background is a gradient (no single solid color to compare against).
|
|
3253
|
+
// Translucent solid layers found between the element and the gradient (frosted
|
|
3254
|
+
// panels, glass washes) are composited over every stop, the same way
|
|
3255
|
+
// resolveBackground flattens them over a solid base — raw stops alone would
|
|
3256
|
+
// false-flag dark text on a light frosted wash over a dark gradient, and miss
|
|
3257
|
+
// the inverse.
|
|
2541
3258
|
function resolveGradientStops(el, win, customPropMap) {
|
|
2542
3259
|
let current = el;
|
|
3260
|
+
const overlays = [];
|
|
2543
3261
|
while (current && current.nodeType === 1) {
|
|
2544
3262
|
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
|
2545
3263
|
const bgImage = style.backgroundImage || '';
|
|
3264
|
+
// A url() layer anywhere in the stack — alone, or alongside a gradient in
|
|
3265
|
+
// the same declaration (a translucent wash over a texture photo) — paints
|
|
3266
|
+
// pixels the analytic walk cannot know. Measuring the gradient stops over
|
|
3267
|
+
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
|
|
3268
|
+
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
|
|
3269
|
+
// image-backed text.
|
|
3270
|
+
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
|
|
2546
3271
|
let stops = null;
|
|
2547
3272
|
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
|
3273
|
+
// parseGradientColors (shared) reads modern-space stops too — oklch,
|
|
3274
|
+
// color-mix and friends via balanced-paren token capture — so browser
|
|
3275
|
+
// computed values that keep the authored syntax stay measurable.
|
|
2548
3276
|
const parsed = parseGradientColors(bgImage);
|
|
2549
3277
|
if (parsed.length > 0) stops = parsed;
|
|
2550
3278
|
}
|
|
2551
3279
|
if (!stops && !DETECTOR_IS_BROWSER) {
|
|
2552
|
-
//
|
|
3280
|
+
// Static mode: peek at the raw inline style for gradients the cascade did not surface
|
|
2553
3281
|
const rawStyle = current.getAttribute?.('style') || '';
|
|
2554
3282
|
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
|
2555
3283
|
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
|
@@ -2557,7 +3285,23 @@ function resolveGradientStops(el, win, customPropMap) {
|
|
|
2557
3285
|
if (parsed.length > 0) stops = parsed;
|
|
2558
3286
|
}
|
|
2559
3287
|
}
|
|
2560
|
-
if (stops)
|
|
3288
|
+
if (stops) {
|
|
3289
|
+
const composited = compositeGradientStops(stops, current, win, customPropMap);
|
|
3290
|
+
if (!composited || overlays.length === 0) return composited;
|
|
3291
|
+
return composited.map(stop => {
|
|
3292
|
+
let acc = stop;
|
|
3293
|
+
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
|
|
3294
|
+
return acc;
|
|
3295
|
+
});
|
|
3296
|
+
}
|
|
3297
|
+
const bg = readCascadeBackgroundColor(current, style, customPropMap);
|
|
3298
|
+
if (bg && bg.a > 0.1) {
|
|
3299
|
+
// An opaque surface above the gradient means the gradient never shows
|
|
3300
|
+
// through here; resolveBackground would have returned it, so reaching
|
|
3301
|
+
// this is defensive — bail rather than measure the wrong layer.
|
|
3302
|
+
if (bg.a >= 0.99) return null;
|
|
3303
|
+
overlays.push(bg);
|
|
3304
|
+
}
|
|
2561
3305
|
current = current.parentElement;
|
|
2562
3306
|
}
|
|
2563
3307
|
return null;
|
|
@@ -2777,15 +3521,25 @@ function checkElementColorsDOM(el) {
|
|
|
2777
3521
|
const rect = el.getBoundingClientRect();
|
|
2778
3522
|
if (rect.width < 10 || rect.height < 10) return [];
|
|
2779
3523
|
const style = getComputedStyle(el);
|
|
3524
|
+
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
|
|
3525
|
+
// decks) are not user-visible, and measuring their inherited colors against
|
|
3526
|
+
// whatever surface happens to sit behind the stack is noise, not audit.
|
|
3527
|
+
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
|
|
2780
3528
|
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
2781
3529
|
const hasDirectText = directText.trim().length > 0;
|
|
2782
|
-
|
|
3530
|
+
const bgInfo = resolveBackgroundInfo(el);
|
|
3531
|
+
let effectiveBg = bgInfo.color;
|
|
3532
|
+
// An unreadable surface anywhere up the chain: skip the gradient-stop
|
|
3533
|
+
// fallback too, so nothing downstream measures against a ground we never
|
|
3534
|
+
// resolved.
|
|
3535
|
+
let surfaceUnresolved = bgInfo.unresolved;
|
|
2783
3536
|
let ownBg = readOwnBackgroundColor(el, style);
|
|
2784
3537
|
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
|
|
2785
3538
|
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
|
|
2786
3539
|
if (pseudoSurface) {
|
|
2787
3540
|
ownBg = pseudoSurface;
|
|
2788
3541
|
effectiveBg = pseudoSurface;
|
|
3542
|
+
surfaceUnresolved = false;
|
|
2789
3543
|
}
|
|
2790
3544
|
}
|
|
2791
3545
|
return checkColors({
|
|
@@ -2797,8 +3551,8 @@ function checkElementColorsDOM(el) {
|
|
|
2797
3551
|
// an oklch token near its own oklch background).
|
|
2798
3552
|
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
|
2799
3553
|
bgColor: ownBg,
|
|
2800
|
-
effectiveBg,
|
|
2801
|
-
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
|
3554
|
+
effectiveBg: surfaceUnresolved ? null : effectiveBg,
|
|
3555
|
+
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
|
|
2802
3556
|
fontSize: parseFloat(style.fontSize) || 16,
|
|
2803
3557
|
fontWeight: parseInt(style.fontWeight) || 400,
|
|
2804
3558
|
hasDirectText,
|
|
@@ -2823,409 +3577,132 @@ function checkElementIconTileDOM(el) {
|
|
|
2823
3577
|
// may contain an emoji/symbol character directly as its only text content
|
|
2824
3578
|
// (the "card-icon" pattern from many AI-generated demos).
|
|
2825
3579
|
const iconChild = sibling.querySelector('svg, i[data-lucide], i[class*="fa-"], i[class*="icon"]');
|
|
2826
|
-
const iconRect = iconChild?.getBoundingClientRect();
|
|
2827
|
-
const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
2828
|
-
const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
|
|
2829
|
-
|
|
2830
|
-
return checkIconTile({
|
|
2831
|
-
headingTag: tag,
|
|
2832
|
-
headingText: el.textContent || '',
|
|
2833
|
-
headingTop: headRect.top,
|
|
2834
|
-
siblingTag: sibling.tagName.toLowerCase(),
|
|
2835
|
-
siblingWidth: sibRect.width,
|
|
2836
|
-
siblingHeight: sibRect.height,
|
|
2837
|
-
siblingBottom: sibRect.bottom,
|
|
2838
|
-
siblingBgColor: parseRgb(sibStyle.backgroundColor),
|
|
2839
|
-
siblingBgImage: sibStyle.backgroundImage || '',
|
|
2840
|
-
siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
|
|
2841
|
-
siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
|
|
2842
|
-
hasIconChild: !!iconChild || hasInlineEmojiIcon,
|
|
2843
|
-
iconChildWidth: iconRect?.width || 0,
|
|
2844
|
-
});
|
|
2845
|
-
}
|
|
2846
|
-
|
|
2847
|
-
function checkElementItalicSerifDOM(el) {
|
|
2848
|
-
const tag = el.tagName.toLowerCase();
|
|
2849
|
-
if (tag !== 'h1' && tag !== 'h2') return [];
|
|
2850
|
-
const style = getComputedStyle(el);
|
|
2851
|
-
return checkItalicSerif({
|
|
2852
|
-
tag,
|
|
2853
|
-
fontStyle: style.fontStyle || '',
|
|
2854
|
-
fontFamily: style.fontFamily || '',
|
|
2855
|
-
fontSize: parseFloat(style.fontSize) || 0,
|
|
2856
|
-
headingText: el.textContent || '',
|
|
2857
|
-
});
|
|
2858
|
-
}
|
|
2859
|
-
|
|
2860
|
-
function domAccentDashPseudo(el) {
|
|
2861
|
-
for (const which of ['::before', '::after']) {
|
|
2862
|
-
let ps;
|
|
2863
|
-
try { ps = getComputedStyle(el, which); } catch { continue; }
|
|
2864
|
-
if (!ps || ps.content === 'none' || ps.content === '') continue;
|
|
2865
|
-
const w = parseFloat(ps.width) || 0;
|
|
2866
|
-
const h = parseFloat(ps.height) || 0;
|
|
2867
|
-
if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
|
|
2868
|
-
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
|
|
2869
|
-
if (!bg || (bg.a ?? 1) < 0.1) continue;
|
|
2870
|
-
if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
|
|
2871
|
-
}
|
|
2872
|
-
return false;
|
|
2873
|
-
}
|
|
2874
|
-
|
|
2875
|
-
function checkElementHeroEyebrowDOM(el) {
|
|
2876
|
-
const tag = el.tagName.toLowerCase();
|
|
2877
|
-
if (tag !== 'h1') return [];
|
|
2878
|
-
const sibling = el.previousElementSibling;
|
|
2879
|
-
if (!sibling) return [];
|
|
2880
|
-
const headStyle = getComputedStyle(el);
|
|
2881
|
-
const sibStyle = getComputedStyle(sibling);
|
|
2882
|
-
return checkHeroEyebrow({
|
|
2883
|
-
headingTag: tag,
|
|
2884
|
-
headingText: el.textContent || '',
|
|
2885
|
-
headingFontSize: parseFloat(headStyle.fontSize) || 0,
|
|
2886
|
-
headingInApplicationContext: !!el.closest('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
|
|
2887
|
-
siblingTag: sibling.tagName.toLowerCase(),
|
|
2888
|
-
siblingText: sibling.textContent || '',
|
|
2889
|
-
siblingTextTransform: sibStyle.textTransform || '',
|
|
2890
|
-
siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
|
|
2891
|
-
siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
|
|
2892
|
-
siblingFontWeight: sibStyle.fontWeight || '',
|
|
2893
|
-
siblingColor: sibStyle.color || '',
|
|
2894
|
-
siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
|
|
2895
|
-
});
|
|
2896
|
-
}
|
|
2897
|
-
|
|
2898
|
-
// Build a map of CSS custom properties declared on :root / :host / html.
|
|
2899
|
-
// Used to resolve var(--X) refs that jsdom returns verbatim in
|
|
2900
|
-
// getComputedStyle. Tailwind v4 routes every utility class through
|
|
2901
|
-
// CSS vars (font-weight: var(--font-weight-bold), font-size:
|
|
2902
|
-
// var(--text-xs), letter-spacing: var(--tracking-widest)), so without
|
|
2903
|
-
// resolution every style-based check silently fails on Tailwind v4
|
|
2904
|
-
// builds — the values come back as literal "var(--font-weight-bold)"
|
|
2905
|
-
// strings and parseFloat returns NaN.
|
|
2906
|
-
function buildCustomPropMap(document) {
|
|
2907
|
-
const map = new Map();
|
|
2908
|
-
let sheets;
|
|
2909
|
-
try { sheets = Array.from(document.styleSheets || []); }
|
|
2910
|
-
catch { return map; }
|
|
2911
|
-
for (const sheet of sheets) {
|
|
2912
|
-
let rules;
|
|
2913
|
-
try { rules = Array.from(sheet.cssRules || []); }
|
|
2914
|
-
catch { continue; }
|
|
2915
|
-
for (const rule of rules) {
|
|
2916
|
-
// Style rules only (type 1). Walk @media / @supports if present.
|
|
2917
|
-
if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
|
|
2918
|
-
try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
|
|
2919
|
-
continue;
|
|
2920
|
-
}
|
|
2921
|
-
if (rule.type !== 1 /* STYLE_RULE */) continue;
|
|
2922
|
-
const sel = rule.selectorText || '';
|
|
2923
|
-
if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
|
|
2924
|
-
const style = rule.style;
|
|
2925
|
-
if (!style) continue;
|
|
2926
|
-
for (let i = 0; i < style.length; i++) {
|
|
2927
|
-
const prop = style[i];
|
|
2928
|
-
if (!prop || !prop.startsWith('--')) continue;
|
|
2929
|
-
const val = style.getPropertyValue(prop).trim();
|
|
2930
|
-
if (val) map.set(prop, val);
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
}
|
|
2934
|
-
return map;
|
|
2935
|
-
}
|
|
2936
|
-
|
|
2937
|
-
// Resolve var(--X[, fallback]) refs in a computed-style value string.
|
|
2938
|
-
// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
|
|
2939
|
-
// the original string when no refs are present or the chain doesn't
|
|
2940
|
-
// resolve. Safe to call on already-resolved values.
|
|
2941
|
-
function resolveVarRefs(raw, customPropMap, depth = 0) {
|
|
2942
|
-
if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
|
|
2943
|
-
if (depth > 8) return raw;
|
|
2944
|
-
return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
|
|
2945
|
-
const v = customPropMap.get(name);
|
|
2946
|
-
if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
|
|
2947
|
-
return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
|
|
2948
|
-
});
|
|
2949
|
-
}
|
|
2950
|
-
|
|
2951
|
-
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
|
|
2952
|
-
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
|
|
2953
|
-
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
|
|
2954
|
-
// returns the literal "oklch(...)" string. Without this, the entire
|
|
2955
|
-
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
|
|
2956
|
-
// detector's contrast / color checks.
|
|
2957
|
-
function oklchToRgb(L, C, H) {
|
|
2958
|
-
const hRad = (H * Math.PI) / 180;
|
|
2959
|
-
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
|
2960
|
-
}
|
|
2961
|
-
|
|
2962
|
-
function oklabToRgb(L, a, b) {
|
|
2963
|
-
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
2964
|
-
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
2965
|
-
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
2966
|
-
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
|
|
2967
|
-
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
|
|
2968
|
-
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
|
|
2969
|
-
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
|
|
2970
|
-
const enc = (x) => {
|
|
2971
|
-
const c = Math.max(0, Math.min(1, x));
|
|
2972
|
-
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
2973
|
-
};
|
|
2974
|
-
return {
|
|
2975
|
-
r: Math.round(enc(rLin) * 255),
|
|
2976
|
-
g: Math.round(enc(gLin) * 255),
|
|
2977
|
-
b: Math.round(enc(bLin) * 255),
|
|
2978
|
-
a: 1,
|
|
2979
|
-
};
|
|
2980
|
-
}
|
|
2981
|
-
|
|
2982
|
-
function hslToRgb(h, s, l) {
|
|
2983
|
-
h = ((h % 360) + 360) % 360;
|
|
2984
|
-
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
2985
|
-
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
2986
|
-
const m0 = l - c / 2;
|
|
2987
|
-
const [r, g, b] =
|
|
2988
|
-
h < 60 ? [c, x, 0] :
|
|
2989
|
-
h < 120 ? [x, c, 0] :
|
|
2990
|
-
h < 180 ? [0, c, x] :
|
|
2991
|
-
h < 240 ? [0, x, c] :
|
|
2992
|
-
h < 300 ? [x, 0, c] : [c, 0, x];
|
|
2993
|
-
return {
|
|
2994
|
-
r: Math.round((r + m0) * 255),
|
|
2995
|
-
g: Math.round((g + m0) * 255),
|
|
2996
|
-
b: Math.round((b + m0) * 255),
|
|
2997
|
-
a: 1,
|
|
2998
|
-
};
|
|
2999
|
-
}
|
|
3000
|
-
|
|
3001
|
-
function hwbToRgb(h, w, bl) {
|
|
3002
|
-
if (w + bl >= 1) {
|
|
3003
|
-
const g = Math.round((w / (w + bl)) * 255);
|
|
3004
|
-
return { r: g, g, b: g, a: 1 };
|
|
3005
|
-
}
|
|
3006
|
-
const base = hslToRgb(h, 1, 0.5);
|
|
3007
|
-
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
|
|
3008
|
-
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
|
|
3009
|
-
}
|
|
3010
|
-
|
|
3011
|
-
// Common CSS named colors — the handful that actually show up in generated
|
|
3012
|
-
// UIs, not the full 148-name spec list. Includes the achromatic names so a
|
|
3013
|
-
// named gray parses (and correctly reads as no-chroma) instead of being
|
|
3014
|
-
// treated as an unknown color.
|
|
3015
|
-
const CSS_NAMED_COLORS = {
|
|
3016
|
-
black: { r: 0, g: 0, b: 0 },
|
|
3017
|
-
white: { r: 255, g: 255, b: 255 },
|
|
3018
|
-
gray: { r: 128, g: 128, b: 128 },
|
|
3019
|
-
grey: { r: 128, g: 128, b: 128 },
|
|
3020
|
-
silver: { r: 192, g: 192, b: 192 },
|
|
3021
|
-
dimgray: { r: 105, g: 105, b: 105 },
|
|
3022
|
-
darkgray: { r: 169, g: 169, b: 169 },
|
|
3023
|
-
lightgray: { r: 211, g: 211, b: 211 },
|
|
3024
|
-
gainsboro: { r: 220, g: 220, b: 220 },
|
|
3025
|
-
whitesmoke: { r: 245, g: 245, b: 245 },
|
|
3026
|
-
red: { r: 255, g: 0, b: 0 },
|
|
3027
|
-
crimson: { r: 220, g: 20, b: 60 },
|
|
3028
|
-
tomato: { r: 255, g: 99, b: 71 },
|
|
3029
|
-
coral: { r: 255, g: 127, b: 80 },
|
|
3030
|
-
salmon: { r: 250, g: 128, b: 114 },
|
|
3031
|
-
orange: { r: 255, g: 165, b: 0 },
|
|
3032
|
-
gold: { r: 255, g: 215, b: 0 },
|
|
3033
|
-
yellow: { r: 255, g: 255, b: 0 },
|
|
3034
|
-
olive: { r: 128, g: 128, b: 0 },
|
|
3035
|
-
lime: { r: 0, g: 255, b: 0 },
|
|
3036
|
-
green: { r: 0, g: 128, b: 0 },
|
|
3037
|
-
teal: { r: 0, g: 128, b: 128 },
|
|
3038
|
-
turquoise: { r: 64, g: 224, b: 208 },
|
|
3039
|
-
cyan: { r: 0, g: 255, b: 255 },
|
|
3040
|
-
aqua: { r: 0, g: 255, b: 255 },
|
|
3041
|
-
skyblue: { r: 135, g: 206, b: 235 },
|
|
3042
|
-
dodgerblue: { r: 30, g: 144, b: 255 },
|
|
3043
|
-
blue: { r: 0, g: 0, b: 255 },
|
|
3044
|
-
navy: { r: 0, g: 0, b: 128 },
|
|
3045
|
-
indigo: { r: 75, g: 0, b: 130 },
|
|
3046
|
-
rebeccapurple: { r: 102, g: 51, b: 153 },
|
|
3047
|
-
purple: { r: 128, g: 0, b: 128 },
|
|
3048
|
-
violet: { r: 238, g: 130, b: 238 },
|
|
3049
|
-
orchid: { r: 218, g: 112, b: 214 },
|
|
3050
|
-
magenta: { r: 255, g: 0, b: 255 },
|
|
3051
|
-
fuchsia: { r: 255, g: 0, b: 255 },
|
|
3052
|
-
hotpink: { r: 255, g: 105, b: 180 },
|
|
3053
|
-
pink: { r: 255, g: 192, b: 203 },
|
|
3054
|
-
maroon: { r: 128, g: 0, b: 0 },
|
|
3055
|
-
};
|
|
3056
|
-
|
|
3057
|
-
// Split a string on top-level commas (ignoring commas nested in parens).
|
|
3058
|
-
function splitTopLevelCommas(str) {
|
|
3059
|
-
const parts = [];
|
|
3060
|
-
let depth = 0, start = 0;
|
|
3061
|
-
for (let i = 0; i < str.length; i++) {
|
|
3062
|
-
const ch = str[i];
|
|
3063
|
-
if (ch === '(') depth++;
|
|
3064
|
-
else if (ch === ')') depth = Math.max(0, depth - 1);
|
|
3065
|
-
else if (ch === ',' && depth === 0) {
|
|
3066
|
-
parts.push(str.slice(start, i).trim());
|
|
3067
|
-
start = i + 1;
|
|
3068
|
-
}
|
|
3069
|
-
}
|
|
3070
|
-
const tail = str.slice(start).trim();
|
|
3071
|
-
if (tail) parts.push(tail);
|
|
3072
|
-
return parts;
|
|
3073
|
-
}
|
|
3074
|
-
|
|
3075
|
-
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
|
|
3076
|
-
// the expression can't be resolved (unresolved var(), unknown colors).
|
|
3077
|
-
//
|
|
3078
|
-
// Mixing is done with premultiplied alpha in sRGB regardless of the
|
|
3079
|
-
// declared interpolation space. That is exact for the dominant generated-UI
|
|
3080
|
-
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
|
|
3081
|
-
// result is simply <color> at alpha N% in ANY rectangular space, and a
|
|
3082
|
-
// close-enough approximation for opaque-opaque mixes (the detector only
|
|
3083
|
-
// consumes these values for contrast/chroma thresholds, not for display).
|
|
3084
|
-
function parseColorMix(str) {
|
|
3085
|
-
const m = String(str).trim().match(/^color-mix\(/i);
|
|
3086
|
-
if (!m) return null;
|
|
3087
|
-
// Balanced-paren capture of the arguments.
|
|
3088
|
-
let depth = 0, end = -1;
|
|
3089
|
-
const open = str.indexOf('(');
|
|
3090
|
-
for (let i = open; i < str.length; i++) {
|
|
3091
|
-
if (str[i] === '(') depth++;
|
|
3092
|
-
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
|
3093
|
-
}
|
|
3094
|
-
if (end < 0) return null;
|
|
3095
|
-
const args = splitTopLevelCommas(str.slice(open + 1, end));
|
|
3096
|
-
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
|
|
3097
|
-
|
|
3098
|
-
const parseComponent = (component) => {
|
|
3099
|
-
// Percentage may lead or trail the color per spec.
|
|
3100
|
-
let pct = null;
|
|
3101
|
-
let colorStr = component;
|
|
3102
|
-
const trail = component.match(/\s+([\d.]+)%$/);
|
|
3103
|
-
const lead = component.match(/^([\d.]+)%\s+/);
|
|
3104
|
-
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
|
|
3105
|
-
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
|
|
3106
|
-
let color;
|
|
3107
|
-
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
|
|
3108
|
-
else color = parseAnyColor(colorStr);
|
|
3109
|
-
if (!color) return null;
|
|
3110
|
-
return { color, pct };
|
|
3111
|
-
};
|
|
3580
|
+
const iconRect = iconChild?.getBoundingClientRect();
|
|
3581
|
+
const sibDirectText = [...sibling.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
3582
|
+
const hasInlineEmojiIcon = sibling.children.length === 0 && isEmojiOnlyText(sibDirectText);
|
|
3112
3583
|
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
|
3129
|
-
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
|
|
3130
|
-
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
|
|
3584
|
+
return checkIconTile({
|
|
3585
|
+
headingTag: tag,
|
|
3586
|
+
headingText: el.textContent || '',
|
|
3587
|
+
headingTop: headRect.top,
|
|
3588
|
+
siblingTag: sibling.tagName.toLowerCase(),
|
|
3589
|
+
siblingWidth: sibRect.width,
|
|
3590
|
+
siblingHeight: sibRect.height,
|
|
3591
|
+
siblingBottom: sibRect.bottom,
|
|
3592
|
+
siblingBgColor: parseRgb(sibStyle.backgroundColor),
|
|
3593
|
+
siblingBgImage: sibStyle.backgroundImage || '',
|
|
3594
|
+
siblingBorderWidth: parseFloat(sibStyle.borderTopWidth) || 0,
|
|
3595
|
+
siblingBorderRadius: parseFloat(sibStyle.borderRadius) || 0,
|
|
3596
|
+
hasIconChild: !!iconChild || hasInlineEmojiIcon,
|
|
3597
|
+
iconChildWidth: iconRect?.width || 0,
|
|
3598
|
+
});
|
|
3131
3599
|
}
|
|
3132
3600
|
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
const
|
|
3137
|
-
return {
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3601
|
+
function checkElementItalicSerifDOM(el) {
|
|
3602
|
+
const tag = el.tagName.toLowerCase();
|
|
3603
|
+
if (tag !== 'h1' && tag !== 'h2') return [];
|
|
3604
|
+
const style = getComputedStyle(el);
|
|
3605
|
+
return checkItalicSerif({
|
|
3606
|
+
tag,
|
|
3607
|
+
fontStyle: style.fontStyle || '',
|
|
3608
|
+
fontFamily: style.fontFamily || '',
|
|
3609
|
+
fontSize: parseFloat(style.fontSize) || 0,
|
|
3610
|
+
headingText: el.textContent || '',
|
|
3611
|
+
});
|
|
3143
3612
|
}
|
|
3144
3613
|
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
if (m) return { r: Math.round(+m[1]), g: Math.round(+m[2]), b: Math.round(+m[3]), a: m[4] !== undefined ? +m[4] : 1 };
|
|
3157
|
-
m = str.match(/^#([0-9a-f]{3,8})$/i);
|
|
3158
|
-
if (m) {
|
|
3159
|
-
const h = m[1];
|
|
3160
|
-
if (h.length === 3 || h.length === 4) {
|
|
3161
|
-
return {
|
|
3162
|
-
r: parseInt(h[0] + h[0], 16),
|
|
3163
|
-
g: parseInt(h[1] + h[1], 16),
|
|
3164
|
-
b: parseInt(h[2] + h[2], 16),
|
|
3165
|
-
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
|
|
3166
|
-
};
|
|
3167
|
-
}
|
|
3168
|
-
if (h.length === 6 || h.length === 8) {
|
|
3169
|
-
return {
|
|
3170
|
-
r: parseInt(h.slice(0, 2), 16),
|
|
3171
|
-
g: parseInt(h.slice(2, 4), 16),
|
|
3172
|
-
b: parseInt(h.slice(4, 6), 16),
|
|
3173
|
-
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
|
3174
|
-
};
|
|
3175
|
-
}
|
|
3176
|
-
}
|
|
3177
|
-
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
|
3178
|
-
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
|
3179
|
-
// Match L (with optional %), then C and H separated permissively.
|
|
3180
|
-
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
3181
|
-
if (m) {
|
|
3182
|
-
const Lnum = parseFloat(m[1]);
|
|
3183
|
-
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
|
3184
|
-
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
|
3185
|
-
if (m[5] !== undefined) {
|
|
3186
|
-
const alpha = parseFloat(m[5]);
|
|
3187
|
-
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
|
3188
|
-
}
|
|
3189
|
-
return rgb;
|
|
3190
|
-
}
|
|
3191
|
-
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
|
|
3192
|
-
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
3193
|
-
if (m) {
|
|
3194
|
-
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
|
3195
|
-
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
|
|
3196
|
-
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
|
|
3197
|
-
const rgb = oklabToRgb(L, a, b);
|
|
3198
|
-
if (m[7] !== undefined) {
|
|
3199
|
-
const alpha = parseFloat(m[7]);
|
|
3200
|
-
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
|
|
3201
|
-
}
|
|
3202
|
-
return rgb;
|
|
3203
|
-
}
|
|
3204
|
-
// HSL/HSLA — comma or space syntax, optional deg on hue.
|
|
3205
|
-
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
|
|
3206
|
-
if (m) {
|
|
3207
|
-
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
|
3208
|
-
if (m[4] !== undefined) {
|
|
3209
|
-
const alpha = parseFloat(m[4]);
|
|
3210
|
-
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
|
3211
|
-
}
|
|
3212
|
-
return rgb;
|
|
3614
|
+
function domAccentDashPseudo(el) {
|
|
3615
|
+
for (const which of ['::before', '::after']) {
|
|
3616
|
+
let ps;
|
|
3617
|
+
try { ps = getComputedStyle(el, which); } catch { continue; }
|
|
3618
|
+
if (!ps || ps.content === 'none' || ps.content === '') continue;
|
|
3619
|
+
const w = parseFloat(ps.width) || 0;
|
|
3620
|
+
const h = parseFloat(ps.height) || 0;
|
|
3621
|
+
if (!(w >= 8 && w <= 80 && h >= 1 && h <= 6)) continue;
|
|
3622
|
+
const bg = parseRgb(ps.backgroundColor) || parseAnyColor(ps.backgroundColor);
|
|
3623
|
+
if (!bg || (bg.a ?? 1) < 0.1) continue;
|
|
3624
|
+
if (Math.max(bg.r, bg.g, bg.b) - Math.min(bg.r, bg.g, bg.b) >= 30) return true;
|
|
3213
3625
|
}
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3626
|
+
return false;
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
function checkElementHeroEyebrowDOM(el) {
|
|
3630
|
+
const tag = el.tagName.toLowerCase();
|
|
3631
|
+
if (tag !== 'h1') return [];
|
|
3632
|
+
const sibling = el.previousElementSibling;
|
|
3633
|
+
if (!sibling) return [];
|
|
3634
|
+
const headStyle = getComputedStyle(el);
|
|
3635
|
+
const sibStyle = getComputedStyle(sibling);
|
|
3636
|
+
return checkHeroEyebrow({
|
|
3637
|
+
headingTag: tag,
|
|
3638
|
+
headingText: el.textContent || '',
|
|
3639
|
+
headingFontSize: parseFloat(headStyle.fontSize) || 0,
|
|
3640
|
+
headingInApplicationContext: !!el.closest('[role="tabpanel"], [role="dialog"], [role="application"], dialog'),
|
|
3641
|
+
siblingTag: sibling.tagName.toLowerCase(),
|
|
3642
|
+
siblingText: sibling.textContent || '',
|
|
3643
|
+
siblingTextTransform: sibStyle.textTransform || '',
|
|
3644
|
+
siblingFontSize: parseFloat(sibStyle.fontSize) || 0,
|
|
3645
|
+
siblingLetterSpacing: parseFloat(sibStyle.letterSpacing) || 0,
|
|
3646
|
+
siblingFontWeight: sibStyle.fontWeight || '',
|
|
3647
|
+
siblingColor: sibStyle.color || '',
|
|
3648
|
+
siblingHasAccentDashPseudo: domAccentDashPseudo(sibling),
|
|
3649
|
+
});
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
// Build a map of CSS custom properties declared on :root / :host / html.
|
|
3653
|
+
// Used to resolve var(--X) refs that jsdom returns verbatim in
|
|
3654
|
+
// getComputedStyle. Tailwind v4 routes every utility class through
|
|
3655
|
+
// CSS vars (font-weight: var(--font-weight-bold), font-size:
|
|
3656
|
+
// var(--text-xs), letter-spacing: var(--tracking-widest)), so without
|
|
3657
|
+
// resolution every style-based check silently fails on Tailwind v4
|
|
3658
|
+
// builds — the values come back as literal "var(--font-weight-bold)"
|
|
3659
|
+
// strings and parseFloat returns NaN.
|
|
3660
|
+
function buildCustomPropMap(document) {
|
|
3661
|
+
const map = new Map();
|
|
3662
|
+
let sheets;
|
|
3663
|
+
try { sheets = Array.from(document.styleSheets || []); }
|
|
3664
|
+
catch { return map; }
|
|
3665
|
+
for (const sheet of sheets) {
|
|
3666
|
+
let rules;
|
|
3667
|
+
try { rules = Array.from(sheet.cssRules || []); }
|
|
3668
|
+
catch { continue; }
|
|
3669
|
+
for (const rule of rules) {
|
|
3670
|
+
// Style rules only (type 1). Walk @media / @supports if present.
|
|
3671
|
+
if (rule.type === 4 /* MEDIA_RULE */ || rule.type === 12 /* SUPPORTS_RULE */) {
|
|
3672
|
+
try { rules.push(...Array.from(rule.cssRules || [])); } catch { /* ignore */ }
|
|
3673
|
+
continue;
|
|
3674
|
+
}
|
|
3675
|
+
if (rule.type !== 1 /* STYLE_RULE */) continue;
|
|
3676
|
+
const sel = rule.selectorText || '';
|
|
3677
|
+
if (!/(^|,\s*)(:root|html|:host)\b/i.test(sel)) continue;
|
|
3678
|
+
const style = rule.style;
|
|
3679
|
+
if (!style) continue;
|
|
3680
|
+
for (let i = 0; i < style.length; i++) {
|
|
3681
|
+
const prop = style[i];
|
|
3682
|
+
if (!prop || !prop.startsWith('--')) continue;
|
|
3683
|
+
const val = style.getPropertyValue(prop).trim();
|
|
3684
|
+
if (val) map.set(prop, val);
|
|
3685
|
+
}
|
|
3221
3686
|
}
|
|
3222
|
-
return rgb;
|
|
3223
3687
|
}
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3688
|
+
return map;
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
// Resolve var(--X[, fallback]) refs in a computed-style value string.
|
|
3692
|
+
// Recurses up to 8 levels for chained refs (--a: var(--b)). Returns
|
|
3693
|
+
// the original string when no refs are present or the chain doesn't
|
|
3694
|
+
// resolve. Safe to call on already-resolved values.
|
|
3695
|
+
function resolveVarRefs(raw, customPropMap, depth = 0) {
|
|
3696
|
+
if (typeof raw !== 'string' || !raw.includes('var(')) return raw;
|
|
3697
|
+
if (depth > 8) return raw;
|
|
3698
|
+
return raw.replace(/var\(\s*(--[a-zA-Z0-9_-]+)\s*(?:,\s*([^)]+))?\)/g, (_m, name, fallback) => {
|
|
3699
|
+
const v = customPropMap.get(name);
|
|
3700
|
+
if (v != null) return resolveVarRefs(v, customPropMap, depth + 1);
|
|
3701
|
+
return fallback ? resolveVarRefs(fallback.trim(), customPropMap, depth + 1) : _m;
|
|
3702
|
+
});
|
|
3227
3703
|
}
|
|
3228
3704
|
|
|
3705
|
+
|
|
3229
3706
|
// Resolve var() refs in a color string (via customPropMap), then parse.
|
|
3230
3707
|
// Returns null on any failure. Used in jsdom-mode paths where
|
|
3231
3708
|
// getComputedStyle returns literal "var(--X)" or "oklch(...)" strings.
|
|
@@ -3587,9 +4064,20 @@ function checkElementGlowDOM(el) {
|
|
|
3587
4064
|
if (!boxShadow && !textShadow) return [];
|
|
3588
4065
|
// Use parent's background — glow radiates outward, so the surrounding context matters
|
|
3589
4066
|
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
4067
|
+
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
|
|
4068
|
+
// Unknown surface (an unreadable layer on the way up): skip only the
|
|
4069
|
+
// gradient hunt below, which would walk PAST that layer and score the
|
|
4070
|
+
// glow against a background the visitor never sees. checkGlow still runs
|
|
4071
|
+
// with a null surface: the zero-offset chromatic halo tell holds on ANY
|
|
4072
|
+
// background, and the static loop already passes the unresolved walk's
|
|
4073
|
+
// null color straight through (detect-html.mjs uses resolveBackground).
|
|
4074
|
+
let parentBg = parentBgInfo.color;
|
|
4075
|
+
if (!parentBg && !parentBgInfo.unresolved) {
|
|
4076
|
+
// Gradient background — sample its colors to determine if it's dark.
|
|
4077
|
+
// Modern-syntax parsing matters here: body-level gradients now reach this
|
|
4078
|
+
// fallback in browser mode, and their stops usually serialize as oklch —
|
|
4079
|
+
// which the shared parseGradientColors reads via its color-function
|
|
4080
|
+
// token capture.
|
|
3593
4081
|
let cur = el.parentElement;
|
|
3594
4082
|
while (cur && cur.nodeType === 1) {
|
|
3595
4083
|
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
|
@@ -3632,15 +4120,18 @@ function checkElementAIPaletteDOM(el) {
|
|
|
3632
4120
|
}
|
|
3633
4121
|
|
|
3634
4122
|
// Check for neon text (vivid cyan/purple color on dark background)
|
|
3635
|
-
const textColor = parseRgb(style.color);
|
|
4123
|
+
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
|
3636
4124
|
if (textColor && hasChroma(textColor, 80)) {
|
|
3637
4125
|
const hue = getHue(textColor);
|
|
3638
4126
|
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
|
3639
4127
|
if (isAIPalette) {
|
|
3640
|
-
const
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
4128
|
+
const parentBgInfo = el.parentElement
|
|
4129
|
+
? resolveBackgroundInfo(el.parentElement)
|
|
4130
|
+
: { color: null, unresolved: false };
|
|
4131
|
+
// Unknown surface: leave effectiveBg null (no finding) rather than
|
|
4132
|
+
// hunting gradient ancestors past a layer we could not read.
|
|
4133
|
+
let effectiveBg = parentBgInfo.color;
|
|
4134
|
+
if (!effectiveBg && !parentBgInfo.unresolved) {
|
|
3644
4135
|
let cur = el.parentElement;
|
|
3645
4136
|
while (cur && cur.nodeType === 1) {
|
|
3646
4137
|
const gi = getComputedStyle(cur).backgroundImage || '';
|
|
@@ -3972,14 +4463,30 @@ function isNonRenderedText(el, tag, style) {
|
|
|
3972
4463
|
function checkQuality(opts) {
|
|
3973
4464
|
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
|
|
3974
4465
|
const findings = [];
|
|
3975
|
-
//
|
|
3976
|
-
//
|
|
3977
|
-
//
|
|
3978
|
-
//
|
|
3979
|
-
//
|
|
3980
|
-
//
|
|
4466
|
+
// A raster (<img>, or an element with a background url) at near-zero
|
|
4467
|
+
// opacity never reaches the screen: the produced material ships as a
|
|
4468
|
+
// compliance token. The CSS-text scan catches the stylesheet form; this
|
|
4469
|
+
// catches computed opacity on the element itself (both engines).
|
|
4470
|
+
// Skip browser extension injected elements BEFORE any finding is pushed
|
|
4471
|
+
// (a low-opacity raster those hosts inject used to be recorded and then
|
|
4472
|
+
// returned by this very skip). Read the id via getAttribute whenever
|
|
4473
|
+
// `el.id` is not a string: on a <form> (and other [LegacyOverrideBuiltIns]
|
|
4474
|
+
// hosts) a named control like <input name="id"> shadows the builtin `id`
|
|
4475
|
+
// getter and returns the control element, whose `.startsWith` is undefined
|
|
4476
|
+
// and throws (issue #407 — every Shopify product form ships an
|
|
4477
|
+
// <input name="id">).
|
|
3981
4478
|
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
|
|
3982
4479
|
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
|
|
4480
|
+
{
|
|
4481
|
+
const op = parseFloat(style.opacity);
|
|
4482
|
+
if (Number.isFinite(op) && op < 0.15 && op >= 0) {
|
|
4483
|
+
const bg = String(style.backgroundImage || '');
|
|
4484
|
+
if (tag === 'img' || /url\(/i.test(bg)) {
|
|
4485
|
+
const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
|
|
4486
|
+
findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
3983
4490
|
|
|
3984
4491
|
// --- Line length too long --- (browser-only: needs rect.width)
|
|
3985
4492
|
if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
|
@@ -4435,10 +4942,19 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
|
|
|
4435
4942
|
}
|
|
4436
4943
|
|
|
4437
4944
|
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
|
|
4945
|
+
// Invisible at rest, static twin of the browser walk's skip: opacity does
|
|
4946
|
+
// not inherit, so walk ancestors multiplying declared opacity down.
|
|
4947
|
+
if (style.visibility === 'hidden') return [];
|
|
4948
|
+
let effOpacity = 1;
|
|
4949
|
+
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
|
|
4950
|
+
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
|
|
4951
|
+
}
|
|
4952
|
+
if (effOpacity <= 0.02) return [];
|
|
4438
4953
|
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
4439
4954
|
const hasDirectText = directText.trim().length > 0;
|
|
4440
4955
|
|
|
4441
|
-
const
|
|
4956
|
+
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
|
|
4957
|
+
const effectiveBg = bgInfo.color;
|
|
4442
4958
|
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
|
|
4443
4959
|
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
|
|
4444
4960
|
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
|
|
@@ -4484,11 +5000,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
|
|
4484
5000
|
// element itself has no usable own background, that pseudo is the real
|
|
4485
5001
|
// surface for contrast purposes.
|
|
4486
5002
|
let finalEffectiveBg = effectiveBg;
|
|
5003
|
+
let surfaceUnresolved = bgInfo.unresolved;
|
|
4487
5004
|
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
|
|
4488
5005
|
const pseudoSurface = window.getPseudoSurface(el);
|
|
4489
5006
|
if (pseudoSurface) {
|
|
4490
5007
|
ownBg = pseudoSurface;
|
|
4491
5008
|
finalEffectiveBg = pseudoSurface;
|
|
5009
|
+
surfaceUnresolved = false;
|
|
4492
5010
|
}
|
|
4493
5011
|
}
|
|
4494
5012
|
|
|
@@ -4496,8 +5014,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
|
|
4496
5014
|
tag,
|
|
4497
5015
|
textColor,
|
|
4498
5016
|
bgColor: ownBg,
|
|
4499
|
-
|
|
4500
|
-
|
|
5017
|
+
// Unknown surface: hand the checks nothing rather than a guess.
|
|
5018
|
+
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
|
|
5019
|
+
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
|
4501
5020
|
fontSize: parseFloat(style.fontSize) || 16,
|
|
4502
5021
|
fontWeight: parseInt(style.fontWeight) || 400,
|
|
4503
5022
|
hasDirectText,
|
|
@@ -4685,8 +5204,7 @@ function checkTypography() {
|
|
|
4685
5204
|
const style = getComputedStyle(el);
|
|
4686
5205
|
const ff = style.fontFamily;
|
|
4687
5206
|
if (!ff) continue;
|
|
4688
|
-
const
|
|
4689
|
-
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
|
5207
|
+
const primary = primaryFontFace(ff);
|
|
4690
5208
|
if (!primary) continue;
|
|
4691
5209
|
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
|
4692
5210
|
totalTextElements++;
|
|
@@ -4931,8 +5449,7 @@ function checkPageTypography(doc, win) {
|
|
|
4931
5449
|
if (rule.type !== 1) continue;
|
|
4932
5450
|
const ff = rule.style?.fontFamily;
|
|
4933
5451
|
if (!ff) continue;
|
|
4934
|
-
const
|
|
4935
|
-
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
|
5452
|
+
const primary = primaryFontFace(ff);
|
|
4936
5453
|
if (primary) {
|
|
4937
5454
|
fonts.add(primary);
|
|
4938
5455
|
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
|
@@ -4951,11 +5468,10 @@ function checkPageTypography(doc, win) {
|
|
|
4951
5468
|
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
|
4952
5469
|
let fm;
|
|
4953
5470
|
while ((fm = ffRe.exec(html)) !== null) {
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
}
|
|
5471
|
+
const primary = primaryFontFace(fm[1]);
|
|
5472
|
+
if (primary) {
|
|
5473
|
+
fonts.add(primary);
|
|
5474
|
+
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
|
4959
5475
|
}
|
|
4960
5476
|
}
|
|
4961
5477
|
|
|
@@ -5593,6 +6109,11 @@ function isRenderedForBrowserRule(el) {
|
|
|
5593
6109
|
function checkElementTextOverflowDOM(el) {
|
|
5594
6110
|
const tag = el.tagName.toLowerCase();
|
|
5595
6111
|
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
|
6112
|
+
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
|
|
6113
|
+
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
|
|
6114
|
+
// its rendered length sat comfortably inside its box), so the delta is
|
|
6115
|
+
// noise, not overflow. SVG clips to its own viewport anyway.
|
|
6116
|
+
if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
|
|
5596
6117
|
if (!isRenderedForBrowserRule(el)) return [];
|
|
5597
6118
|
// Only the element that actually owns overflowing text — not its ancestors,
|
|
5598
6119
|
// which inherit a wider scrollWidth from the spilling descendant.
|
|
@@ -5977,6 +6498,22 @@ function isPaintedForOcclusion(el) {
|
|
|
5977
6498
|
// path is pure geometry and runs anywhere on the page.
|
|
5978
6499
|
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
|
|
5979
6500
|
|
|
6501
|
+
// An element whose effective opacity multiplies out to ~0 paints nothing at
|
|
6502
|
+
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
|
|
6503
|
+
// measure a state nobody sees. Browser-only — the walk needs live computed
|
|
6504
|
+
// styles. Cycling scenes that fade such elements in later are the screenshot
|
|
6505
|
+
// subsystem's territory, not the analytic walk's.
|
|
6506
|
+
function effectiveOpacityDOM(el) {
|
|
6507
|
+
let o = 1;
|
|
6508
|
+
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
|
|
6509
|
+
// wrappers hide every descendant just as thoroughly as a local wrapper.
|
|
6510
|
+
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
|
6511
|
+
o *= parseFloat(getComputedStyle(cur).opacity || '1');
|
|
6512
|
+
if (o <= 0.02) return 0;
|
|
6513
|
+
}
|
|
6514
|
+
return o;
|
|
6515
|
+
}
|
|
6516
|
+
|
|
5980
6517
|
function checkTextOcclusionDOM() {
|
|
5981
6518
|
const findings = [];
|
|
5982
6519
|
const seenVictims = new Set();
|
|
@@ -6004,6 +6541,41 @@ function checkTextOcclusionDOM() {
|
|
|
6004
6541
|
}
|
|
6005
6542
|
return false;
|
|
6006
6543
|
};
|
|
6544
|
+
// The classic occluder shape this rules out is an opacity-0 interaction
|
|
6545
|
+
// layer — a range scrubber stretched over a before/after comparison — which
|
|
6546
|
+
// elementFromPoint still returns and whose UA background-color otherwise
|
|
6547
|
+
// reads as an opaque box.
|
|
6548
|
+
const effectiveOpacity = effectiveOpacityDOM;
|
|
6549
|
+
|
|
6550
|
+
// The part of an element that is actually painted, after every scrolling or
|
|
6551
|
+
// clipping ancestor has had its say.
|
|
6552
|
+
//
|
|
6553
|
+
// getBoundingClientRect reports where a box would be if nothing cut it off,
|
|
6554
|
+
// so a paragraph half scrolled out of a panel still reports its full height,
|
|
6555
|
+
// and the half that is clipped away lands wherever the page continues below
|
|
6556
|
+
// the panel. The elementFromPoint probe then samples coordinates the text is
|
|
6557
|
+
// not painted at, finds whatever genuinely is painted there, and reports the
|
|
6558
|
+
// text as buried under it. Any sticky footer or toolbar beneath a scroll
|
|
6559
|
+
// region produces this, and it is the shape most likely to be waved off as
|
|
6560
|
+
// noise, which costs the rule its credibility on the findings that are real.
|
|
6561
|
+
//
|
|
6562
|
+
// Border box rather than padding box on purpose: it errs toward probing, and
|
|
6563
|
+
// giving up a scrollbar gutter's width would drop true findings at the right
|
|
6564
|
+
// edge of a scroller.
|
|
6565
|
+
const paintedRect = (el, rect) => {
|
|
6566
|
+
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
|
|
6567
|
+
for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
|
|
6568
|
+
let cs; try { cs = getComputedStyle(cur); } catch { continue; }
|
|
6569
|
+
const clipsX = String(cs.overflowX || 'visible') !== 'visible';
|
|
6570
|
+
const clipsY = String(cs.overflowY || 'visible') !== 'visible';
|
|
6571
|
+
if (!clipsX && !clipsY) continue;
|
|
6572
|
+
let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
|
|
6573
|
+
if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
|
|
6574
|
+
if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
|
|
6575
|
+
if (right - left < 1 || bottom - top < 1) return null;
|
|
6576
|
+
}
|
|
6577
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
6578
|
+
};
|
|
6007
6579
|
|
|
6008
6580
|
// Collect renderable text owners in / near the first viewport for the
|
|
6009
6581
|
// elementFromPoint probe. SVG <text> counts too.
|
|
@@ -6016,8 +6588,14 @@ function checkTextOcclusionDOM() {
|
|
|
6016
6588
|
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
|
6017
6589
|
if (text.length < 2) continue;
|
|
6018
6590
|
if (!isPaintedForOcclusion(el)) continue;
|
|
6019
|
-
|
|
6020
|
-
|
|
6591
|
+
if (effectiveOpacity(el) <= 0.02) continue;
|
|
6592
|
+
let full; try { full = el.getBoundingClientRect(); } catch { continue; }
|
|
6593
|
+
if (full.width < 6 || full.height < 6) continue;
|
|
6594
|
+
// Probe only where the text is on screen. A run clipped down to a sliver is
|
|
6595
|
+
// dropped rather than sampled: a few pixels of visible text cannot support
|
|
6596
|
+
// a coverage fraction worth reporting either way.
|
|
6597
|
+
const rect = paintedRect(el, full);
|
|
6598
|
+
if (!rect || rect.width < 6 || rect.height < 6) continue;
|
|
6021
6599
|
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
|
6022
6600
|
if (rect.bottom <= 0 || rect.top >= vh) continue;
|
|
6023
6601
|
textEls.push({ el, rect, text, inSvg });
|
|
@@ -6049,6 +6627,7 @@ function checkTextOcclusionDOM() {
|
|
|
6049
6627
|
if (top === el || el.contains(top) || top.contains(el)) continue;
|
|
6050
6628
|
const topCs = getComputedStyle(top);
|
|
6051
6629
|
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
|
6630
|
+
if (effectiveOpacity(top) <= 0.02) continue;
|
|
6052
6631
|
const topTag = top.tagName.toLowerCase();
|
|
6053
6632
|
// Text sitting under a raw image/video is contrast territory (deduped
|
|
6054
6633
|
// against the pixel low-contrast rule); leave those alone here.
|
|
@@ -6884,7 +7463,7 @@ if (IS_BROWSER) {
|
|
|
6884
7463
|
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
|
|
6885
7464
|
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
|
|
6886
7465
|
|
|
6887
|
-
const solidBg = parseRgb(currentStyle.backgroundColor);
|
|
7466
|
+
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
|
|
6888
7467
|
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
|
|
6889
7468
|
current = current.parentElement;
|
|
6890
7469
|
}
|
|
@@ -6941,8 +7520,12 @@ if (IS_BROWSER) {
|
|
|
6941
7520
|
|
|
6942
7521
|
const reasons = collectVisualContrastReasons(el, style);
|
|
6943
7522
|
if (reasons.length === 0) continue;
|
|
7523
|
+
// Image-only mode filters here, inside the cap: gradient/opacity/filter
|
|
7524
|
+
// candidates earlier in DOM order must not consume the budget and
|
|
7525
|
+
// starve the url()-backed texts this mode exists to sample.
|
|
7526
|
+
if (options.imageOnly && !reasons.includes('image background')) continue;
|
|
6944
7527
|
|
|
6945
|
-
const textColor = parseRgb(style.color);
|
|
7528
|
+
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
|
6946
7529
|
const fontSize = parseFloat(style.fontSize) || 16;
|
|
6947
7530
|
const fontWeight = parseInt(style.fontWeight) || 400;
|
|
6948
7531
|
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
|
|
@@ -7239,7 +7822,7 @@ if (IS_BROWSER) {
|
|
|
7239
7822
|
return sample;
|
|
7240
7823
|
}
|
|
7241
7824
|
}
|
|
7242
|
-
const bg = parseRgb(style.backgroundColor);
|
|
7825
|
+
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
|
7243
7826
|
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
|
|
7244
7827
|
return { status: 'unresolved', reason: 'no readable background' };
|
|
7245
7828
|
}
|
|
@@ -7369,7 +7952,7 @@ if (IS_BROWSER) {
|
|
|
7369
7952
|
}
|
|
7370
7953
|
|
|
7371
7954
|
const style = getComputedStyle(el);
|
|
7372
|
-
const textColor = parseRgb(style.color) || candidate.textColor;
|
|
7955
|
+
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
|
|
7373
7956
|
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
|
|
7374
7957
|
|
|
7375
7958
|
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
|
|
@@ -7433,6 +8016,7 @@ if (IS_BROWSER) {
|
|
|
7433
8016
|
}
|
|
7434
8017
|
|
|
7435
8018
|
async function analyzeVisualContrast(options = {}) {
|
|
8019
|
+
// imageOnly is enforced inside the collector, before the candidate cap.
|
|
7436
8020
|
const candidates = collectVisualContrastCandidates(options);
|
|
7437
8021
|
const results = [];
|
|
7438
8022
|
const shouldScrollOffscreen = options.scrollOffscreen === true;
|
|
@@ -7518,9 +8102,16 @@ if (IS_BROWSER) {
|
|
|
7518
8102
|
|
|
7519
8103
|
function addBrowserFindings(groupMap, el, findings) {
|
|
7520
8104
|
if (!findings || findings.length === 0) return;
|
|
8105
|
+
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
|
|
8106
|
+
// matching findings for its whole subtree. Applied at this choke point so
|
|
8107
|
+
// every per-element attribution (checks, layout, occlusion, rhythm)
|
|
8108
|
+
// honors it; page-level findings attributed to <body> pass through
|
|
8109
|
+
// untouched, since body has no ignoring ancestor.
|
|
8110
|
+
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
|
|
8111
|
+
if (kept.length === 0) return;
|
|
7521
8112
|
const existing = groupMap.get(el);
|
|
7522
|
-
if (existing) existing.push(...
|
|
7523
|
-
else groupMap.set(el, [...
|
|
8113
|
+
if (existing) existing.push(...kept);
|
|
8114
|
+
else groupMap.set(el, [...kept]);
|
|
7524
8115
|
}
|
|
7525
8116
|
|
|
7526
8117
|
function browserFindingsFromMap(groupMap) {
|
|
@@ -7718,7 +8309,19 @@ if (IS_BROWSER) {
|
|
|
7718
8309
|
return findings;
|
|
7719
8310
|
}
|
|
7720
8311
|
|
|
8312
|
+
// A page matched by detector.ignoreFiles is waived wholesale: every scan
|
|
8313
|
+
// stage answers empty so the badge and toast read zero. Mirrors
|
|
8314
|
+
// shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
|
|
8315
|
+
// overlay resolves the globs per page (live-browser-ignores.js) and
|
|
8316
|
+
// forwards the verdict as config.skipScan.
|
|
8317
|
+
function skipScanActive() {
|
|
8318
|
+
return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
|
|
8319
|
+
}
|
|
8320
|
+
|
|
7721
8321
|
function collectBrowserFindings() {
|
|
8322
|
+
if (skipScanActive()) {
|
|
8323
|
+
return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
|
|
8324
|
+
}
|
|
7722
8325
|
const groupMap = new Map();
|
|
7723
8326
|
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
|
|
7724
8327
|
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
|
|
@@ -7878,9 +8481,27 @@ if (IS_BROWSER) {
|
|
|
7878
8481
|
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
|
|
7879
8482
|
node.remove();
|
|
7880
8483
|
}
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
|
|
8484
|
+
// Regex findings that name a live selector resolve against the real DOM:
|
|
8485
|
+
// pseudo-element/class segments are stripped (the host element is the
|
|
8486
|
+
// anchor), a selector that matches nothing on this page drops the finding
|
|
8487
|
+
// (the CSS ships here, but the pattern never renders — the live DOM is
|
|
8488
|
+
// ground truth in the browser), and a match under a data-impeccable-ignore
|
|
8489
|
+
// ancestor is waived. Selector-less findings stay page-level.
|
|
8490
|
+
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
|
|
8491
|
+
if (!f.selector) return true;
|
|
8492
|
+
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
|
|
8493
|
+
if (!query || /^[,\s]*$/.test(query)) return true;
|
|
8494
|
+
let matches;
|
|
8495
|
+
try {
|
|
8496
|
+
matches = document.querySelectorAll(query);
|
|
8497
|
+
} catch {
|
|
8498
|
+
return true;
|
|
8499
|
+
}
|
|
8500
|
+
if (matches.length === 0) return false;
|
|
8501
|
+
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
|
|
8502
|
+
});
|
|
8503
|
+
if (scopedHtmlFindings.length > 0) {
|
|
8504
|
+
const mapped = scopedHtmlFindings.map(f => {
|
|
7884
8505
|
const item = { type: f.id, detail: f.snippet };
|
|
7885
8506
|
if (f.severity) {
|
|
7886
8507
|
item.severity = f.severity;
|
|
@@ -7903,6 +8524,119 @@ if (IS_BROWSER) {
|
|
|
7903
8524
|
addBrowserFindings(groupMap, document.body, mapped);
|
|
7904
8525
|
}
|
|
7905
8526
|
|
|
8527
|
+
// Value-level suppression (issue #639). `disabledRules` above handles
|
|
8528
|
+
// whole rules; this applies the config's remaining ignoreValues entries,
|
|
8529
|
+
// which the CLI filters through isIgnoredFindingValue in
|
|
8530
|
+
// cli/lib/impeccable-config.mjs, so a project waiver like
|
|
8531
|
+
// overused-font = "geist mono" reaches the overlay and extension too.
|
|
8532
|
+
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
|
|
8533
|
+
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
|
|
8534
|
+
const _disabledValues = EXTENSION_MODE
|
|
8535
|
+
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
|
|
8536
|
+
.filter(e => e && typeof e === 'object' && e.rule && e.value)
|
|
8537
|
+
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
|
|
8538
|
+
: [];
|
|
8539
|
+
if (_disabledValues.length > 0) {
|
|
8540
|
+
// The six rules whose findings carry a matchable value; keep in step
|
|
8541
|
+
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
|
|
8542
|
+
// Everything else is suppressed by rule or by file scope, both already
|
|
8543
|
+
// resolved into disabledRules before the scan message was sent.
|
|
8544
|
+
const _directValueRules = new Set([
|
|
8545
|
+
'overused-font',
|
|
8546
|
+
'bounce-easing',
|
|
8547
|
+
'design-system-font',
|
|
8548
|
+
'design-system-color',
|
|
8549
|
+
'design-system-radius',
|
|
8550
|
+
'design-system-font-size',
|
|
8551
|
+
]);
|
|
8552
|
+
// The design-system checks set `ignoreValue` on their findings; the
|
|
8553
|
+
// detail fallbacks catch overused-font, whose value lives in its
|
|
8554
|
+
// sentence. One CLI matcher is not mirrored here: the motion extractor
|
|
8555
|
+
// (a value-scoped bounce-easing waiver only matches when the finding
|
|
8556
|
+
// carries ignoreValue directly). The CLI's [?&]family= URL fallback is
|
|
8557
|
+
// also omitted on purpose: browser findings for these rules always
|
|
8558
|
+
// carry ignoreValue or a "Primary font:" / "Google Fonts:" /
|
|
8559
|
+
// font-family sentence, so it is unreachable here.
|
|
8560
|
+
const _findingValue = (f) => {
|
|
8561
|
+
if (!f || !_directValueRules.has(f.type || f.id)) return '';
|
|
8562
|
+
const direct = f.ignoreValue || f.value;
|
|
8563
|
+
if (direct) return _normValue(direct);
|
|
8564
|
+
// The CLI routes bounce-easing through extractMotionIgnoreValue and
|
|
8565
|
+
// never the font regexes; without a direct ignoreValue there is no
|
|
8566
|
+
// value to match, so do not invent one from unrelated CSS text.
|
|
8567
|
+
if ((f.type || f.id) === 'bounce-easing') return '';
|
|
8568
|
+
for (const text of [f.detail, f.snippet]) {
|
|
8569
|
+
if (typeof text !== 'string' || !text) continue;
|
|
8570
|
+
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
|
|
8571
|
+
if (primary) return _normValue(primary[1]);
|
|
8572
|
+
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
|
|
8573
|
+
if (google) return _normValue(google[1]);
|
|
8574
|
+
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
|
|
8575
|
+
if (family) return _normValue(family[1]);
|
|
8576
|
+
}
|
|
8577
|
+
return '';
|
|
8578
|
+
};
|
|
8579
|
+
// design-system-color compares by color value, not by spelling: the
|
|
8580
|
+
// browser reports computed rgb(...) strings while waivers are usually
|
|
8581
|
+
// written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in
|
|
8582
|
+
// cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms;
|
|
8583
|
+
// hsl stays CLI-only.
|
|
8584
|
+
const _colorKey = (value) => {
|
|
8585
|
+
const text = String(value || '').trim().toLowerCase();
|
|
8586
|
+
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
|
|
8587
|
+
if (hex) {
|
|
8588
|
+
const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1];
|
|
8589
|
+
const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16));
|
|
8590
|
+
return `${r},${g},${b},${a}`;
|
|
8591
|
+
}
|
|
8592
|
+
const rgb = text.match(/^rgba?\((.*)\)$/);
|
|
8593
|
+
if (!rgb) return '';
|
|
8594
|
+
const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / ');
|
|
8595
|
+
let parts;
|
|
8596
|
+
if (body.includes(',')) {
|
|
8597
|
+
parts = body.split(',').map(p => p.trim()).filter(Boolean);
|
|
8598
|
+
const last = parts[parts.length - 1];
|
|
8599
|
+
if (last && last.includes('/')) {
|
|
8600
|
+
parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)];
|
|
8601
|
+
}
|
|
8602
|
+
} else {
|
|
8603
|
+
parts = body.split(/\s+/).filter(p => p && p !== '/');
|
|
8604
|
+
}
|
|
8605
|
+
if (parts.length < 3 || parts.length > 4) return '';
|
|
8606
|
+
const channel = (raw, isAlpha) => {
|
|
8607
|
+
const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/);
|
|
8608
|
+
if (!m) return null;
|
|
8609
|
+
let v = parseFloat(m[1]);
|
|
8610
|
+
if (m[2]) v = isAlpha ? v / 100 : v * 2.55;
|
|
8611
|
+
const max = isAlpha ? 1 : 255;
|
|
8612
|
+
if (!Number.isFinite(v) || v < 0 || v > max) return null;
|
|
8613
|
+
return isAlpha ? v : Math.round(v);
|
|
8614
|
+
};
|
|
8615
|
+
const r = channel(parts[0], false);
|
|
8616
|
+
const g = channel(parts[1], false);
|
|
8617
|
+
const b = channel(parts[2], false);
|
|
8618
|
+
const a = parts[3] === undefined ? 1 : channel(parts[3], true);
|
|
8619
|
+
if ([r, g, b, a].some(v => v === null)) return '';
|
|
8620
|
+
return `${r},${g},${b},${Math.round(a * 255)}`;
|
|
8621
|
+
};
|
|
8622
|
+
const _valueIgnored = (f) => {
|
|
8623
|
+
const value = _findingValue(f);
|
|
8624
|
+
if (!value) return false;
|
|
8625
|
+
const rule = f.type || f.id;
|
|
8626
|
+
return _disabledValues.some(e => e.rule === rule && (e.value === value
|
|
8627
|
+
|| (rule === 'design-system-color'
|
|
8628
|
+
&& _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value))));
|
|
8629
|
+
};
|
|
8630
|
+
for (const [el, list] of [...groupMap.entries()]) {
|
|
8631
|
+
const kept = list.filter(f => !_valueIgnored(f));
|
|
8632
|
+
if (kept.length > 0) groupMap.set(el, kept);
|
|
8633
|
+
else groupMap.delete(el);
|
|
8634
|
+
}
|
|
8635
|
+
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
|
|
8636
|
+
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
|
|
8637
|
+
}
|
|
8638
|
+
}
|
|
8639
|
+
|
|
7906
8640
|
return {
|
|
7907
8641
|
groupMap,
|
|
7908
8642
|
allFindings: browserFindingsFromMap(groupMap),
|
|
@@ -7910,8 +8644,27 @@ if (IS_BROWSER) {
|
|
|
7910
8644
|
};
|
|
7911
8645
|
}
|
|
7912
8646
|
|
|
8647
|
+
// Visual contrast has three modes. Explicit true runs the full sampled
|
|
8648
|
+
// pass; explicit false disables it entirely (the deterministic-only mode
|
|
8649
|
+
// the test suites use). Unset — the default overlay run — samples ONLY
|
|
8650
|
+
// image-backed text: the one class the analytic walk deliberately skips,
|
|
8651
|
+
// because a url() layer's pixels are unknowable without looking. In-page
|
|
8652
|
+
// sampling draws the source image alone to a canvas (glyph ink never
|
|
8653
|
+
// pollutes it), and a cross-origin image without CORS reports unresolved
|
|
8654
|
+
// instead of guessing.
|
|
8655
|
+
function visualContrastMode(options = {}) {
|
|
8656
|
+
const explicit = typeof options.visualContrast === 'boolean'
|
|
8657
|
+
? options.visualContrast
|
|
8658
|
+
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
|
|
8659
|
+
? window.__IMPECCABLE_CONFIG__.visualContrast
|
|
8660
|
+
: null;
|
|
8661
|
+
if (explicit === true) return 'full';
|
|
8662
|
+
if (explicit === false) return false;
|
|
8663
|
+
return 'image-only';
|
|
8664
|
+
}
|
|
8665
|
+
|
|
7913
8666
|
function shouldRunVisualContrast(options = {}) {
|
|
7914
|
-
return options
|
|
8667
|
+
return visualContrastMode(options) !== false;
|
|
7915
8668
|
}
|
|
7916
8669
|
|
|
7917
8670
|
function visualContrastOptions(options = {}) {
|
|
@@ -8088,6 +8841,7 @@ if (IS_BROWSER) {
|
|
|
8088
8841
|
return [];
|
|
8089
8842
|
}
|
|
8090
8843
|
const resolvedOptions = visualContrastOptions(options);
|
|
8844
|
+
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
|
|
8091
8845
|
const analyses = await analyzeVisualContrast(resolvedOptions);
|
|
8092
8846
|
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
|
|
8093
8847
|
lastVisualContrastAnalyses = analyses;
|
|
@@ -8100,6 +8854,12 @@ if (IS_BROWSER) {
|
|
|
8100
8854
|
|
|
8101
8855
|
async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
|
|
8102
8856
|
const collected = collectBrowserFindings();
|
|
8857
|
+
// The visual pass walks the DOM on its own; on a skipScan page it would
|
|
8858
|
+
// repopulate the emptied scan, so it is skipped with everything else.
|
|
8859
|
+
if (skipScanActive()) {
|
|
8860
|
+
lastVisualContrastAnalyses = [];
|
|
8861
|
+
return { ...collected, allFindings: [], visualContrastAnalyses: [] };
|
|
8862
|
+
}
|
|
8103
8863
|
await addVisualContrastFindings(collected.groupMap, options, runtime);
|
|
8104
8864
|
return {
|
|
8105
8865
|
...collected,
|
|
@@ -8153,7 +8913,7 @@ if (IS_BROWSER) {
|
|
|
8153
8913
|
const generation = scanGeneration;
|
|
8154
8914
|
const collected = collectBrowserFindings();
|
|
8155
8915
|
const allFindings = renderBrowserFindings(collected, options);
|
|
8156
|
-
if (shouldRunVisualContrast(options)) {
|
|
8916
|
+
if (!skipScanActive() && shouldRunVisualContrast(options)) {
|
|
8157
8917
|
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
|
|
8158
8918
|
.then(() => {
|
|
8159
8919
|
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
|