quiver-cli 1.3.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/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
|
@@ -9,16 +9,24 @@ import {
|
|
|
9
9
|
WCAG_LARGE_BOLD_TEXT_PX,
|
|
10
10
|
WCAG_LARGE_TEXT_PX,
|
|
11
11
|
isBrandFontOnOwnDomain,
|
|
12
|
+
primaryFontFace,
|
|
12
13
|
} from '../shared/constants.mjs';
|
|
13
14
|
import {
|
|
15
|
+
CSS_NAMED_COLORS,
|
|
14
16
|
colorToHex,
|
|
17
|
+
compositeColorOver,
|
|
15
18
|
contrastRatio,
|
|
16
19
|
getHue,
|
|
17
20
|
hasChroma,
|
|
18
21
|
isNeutralColor,
|
|
22
|
+
isNoPaintColorValue,
|
|
23
|
+
oklchToRgb,
|
|
24
|
+
parseAnyColor,
|
|
25
|
+
parseColorMix,
|
|
19
26
|
parseGradientColors,
|
|
20
27
|
parseRgb,
|
|
21
28
|
relativeLuminance,
|
|
29
|
+
splitTopLevelCommas,
|
|
22
30
|
} from '../shared/color.mjs';
|
|
23
31
|
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
|
|
24
32
|
|
|
@@ -70,6 +78,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
|
|
|
70
78
|
return findings;
|
|
71
79
|
}
|
|
72
80
|
|
|
81
|
+
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
|
|
82
|
+
//
|
|
83
|
+
// An element-scoped waiver that travels with the markup: any element carrying
|
|
84
|
+
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
|
|
85
|
+
// every rule) suppresses matching findings from itself and its entire subtree,
|
|
86
|
+
// in every engine that walks elements — the browser overlay, the extension,
|
|
87
|
+
// and the static scan. This is the DOM twin of the line-based
|
|
88
|
+
// `impeccable-disable` comment directives, which the browser cannot apply (a
|
|
89
|
+
// live DOM has no line numbers), and the generalization of the one-off
|
|
90
|
+
// `data-impeccable-allow-kickers` opt-out.
|
|
91
|
+
//
|
|
92
|
+
// The intended use is curated exhibits: a page that documents anti-patterns by
|
|
93
|
+
// example, or renders a deliberate "before" specimen, marks the container once
|
|
94
|
+
// and every engine skips it while still scanning the page around it.
|
|
95
|
+
function scopedIgnoreActive(el, ruleId) {
|
|
96
|
+
const rule = String(ruleId || '').toLowerCase();
|
|
97
|
+
let cur = el;
|
|
98
|
+
while (cur && cur.nodeType === 1) {
|
|
99
|
+
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
|
|
100
|
+
if (attr != null) {
|
|
101
|
+
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
|
|
102
|
+
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
|
|
103
|
+
}
|
|
104
|
+
cur = cur.parentElement;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
73
109
|
// Returns true if the given text is composed entirely of emoji characters
|
|
74
110
|
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
|
|
75
111
|
// regardless of CSS `color`, so contrast checks against the element's text
|
|
@@ -296,7 +332,7 @@ function checkIconTile(opts) {
|
|
|
296
332
|
function resolveSerif(fontFamily) {
|
|
297
333
|
if (!fontFamily) return { primary: null, isSerif: false };
|
|
298
334
|
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
|
|
299
|
-
const primary =
|
|
335
|
+
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
|
|
300
336
|
if (!primary) return { primary: null, isSerif: false };
|
|
301
337
|
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
|
|
302
338
|
if (tokens.includes('serif')) return { primary, isSerif: true };
|
|
@@ -637,6 +673,31 @@ function cssTextHasDarkRootBg(content, customProps) {
|
|
|
637
673
|
return false;
|
|
638
674
|
}
|
|
639
675
|
|
|
676
|
+
// Best-effort extraction of the CSS selector whose declaration block contains
|
|
677
|
+
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
|
|
678
|
+
// anchor, so the browser pass can resolve scoped ignores against the actual
|
|
679
|
+
// element and drop patterns that render nowhere on the page. Returns null for
|
|
680
|
+
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
|
|
681
|
+
// read as a selector; those findings stay page-level.
|
|
682
|
+
function enclosingCssSelector(cssText, index) {
|
|
683
|
+
if (!cssText || !Number.isFinite(index)) return null;
|
|
684
|
+
const open = cssText.lastIndexOf('{', index);
|
|
685
|
+
if (open === -1) return null;
|
|
686
|
+
// A match inside an inline style fragment (`style="…"` appended to the
|
|
687
|
+
// corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
|
|
688
|
+
// `{` belongs to some other selector.
|
|
689
|
+
const closeBeforeIndex = cssText.lastIndexOf('}', index);
|
|
690
|
+
if (closeBeforeIndex > open) return null;
|
|
691
|
+
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
|
|
692
|
+
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
|
|
693
|
+
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
|
|
694
|
+
// Keyframe steps: percentage steps fail the digit test above, but `from`
|
|
695
|
+
// and `to` would read as (never-matching) type selectors and get a valid
|
|
696
|
+
// finding wrongly dropped by the zero-match rule downstream.
|
|
697
|
+
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
|
|
698
|
+
return raw;
|
|
699
|
+
}
|
|
700
|
+
|
|
640
701
|
function scanCssTextForGlow(content) {
|
|
641
702
|
const customProps = collectCssCustomProps(content);
|
|
642
703
|
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
|
|
@@ -666,20 +727,19 @@ function scanCssTextForGlow(content) {
|
|
|
666
727
|
return results;
|
|
667
728
|
}
|
|
668
729
|
|
|
669
|
-
// Decorative grid
|
|
730
|
+
// Decorative two-axis grid backgrounds drawn with hairline
|
|
670
731
|
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
|
|
671
732
|
// pattern pass and the regex source engine so standalone CSS, component
|
|
672
733
|
// styles, and inline styles receive the same coverage. Both signals must
|
|
673
734
|
// co-occur in one declaration block; unrelated rules must not add up across
|
|
674
|
-
// the file.
|
|
675
|
-
//
|
|
735
|
+
// the file. A single hairline is a line, divider, or rail, not a grid, even
|
|
736
|
+
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
|
|
737
|
+
// finding per source to match the page-level HTML check's existing behavior.
|
|
676
738
|
function scanCssTextForGridBackground(content) {
|
|
677
739
|
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
|
|
678
740
|
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
|
|
679
741
|
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
|
|
680
|
-
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
|
|
681
742
|
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
|
|
682
|
-
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
|
|
683
743
|
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
|
|
684
744
|
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
|
|
685
745
|
let blk;
|
|
@@ -696,13 +756,10 @@ function scanCssTextForGridBackground(content) {
|
|
|
696
756
|
}
|
|
697
757
|
if (hairlineCount === 0) continue;
|
|
698
758
|
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
|
|
699
|
-
|
|
700
|
-
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
|
|
759
|
+
if (hairlineCount >= 2 && hasPxCell) {
|
|
701
760
|
return [{
|
|
702
761
|
index: blk.index,
|
|
703
|
-
snippet:
|
|
704
|
-
? 'two-axis grid-line gradient background'
|
|
705
|
-
: 'px-tiled hairline line-field background',
|
|
762
|
+
snippet: 'two-axis grid-line gradient background',
|
|
706
763
|
}];
|
|
707
764
|
}
|
|
708
765
|
}
|
|
@@ -948,6 +1005,7 @@ function scanCssTextForPseudoStripe(rawContent) {
|
|
|
948
1005
|
id: 'side-tab',
|
|
949
1006
|
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
|
|
950
1007
|
index: selectorStart,
|
|
1008
|
+
selector,
|
|
951
1009
|
});
|
|
952
1010
|
}
|
|
953
1011
|
return findings;
|
|
@@ -1010,6 +1068,7 @@ function scanCssTextForInsetStripe(content) {
|
|
|
1010
1068
|
findings.push({
|
|
1011
1069
|
id: 'side-tab',
|
|
1012
1070
|
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
|
|
1071
|
+
selector,
|
|
1013
1072
|
});
|
|
1014
1073
|
break;
|
|
1015
1074
|
}
|
|
@@ -1067,7 +1126,7 @@ function collectMarqueeKeyframes(content) {
|
|
|
1067
1126
|
function scanCssTextForMarquee(content, markup = content) {
|
|
1068
1127
|
const findings = [];
|
|
1069
1128
|
if (/<marquee\b/i.test(markup)) {
|
|
1070
|
-
findings.push({ id: 'marquee', snippet: '<marquee> element' });
|
|
1129
|
+
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
|
|
1071
1130
|
}
|
|
1072
1131
|
const marqueeKeyframes = collectMarqueeKeyframes(content);
|
|
1073
1132
|
if (marqueeKeyframes.size === 0) return findings;
|
|
@@ -1082,7 +1141,7 @@ function scanCssTextForMarquee(content, markup = content) {
|
|
|
1082
1141
|
const key = `${selector} ${name}`;
|
|
1083
1142
|
if (seen.has(key)) continue;
|
|
1084
1143
|
seen.add(key);
|
|
1085
|
-
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"
|
|
1144
|
+
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
|
|
1086
1145
|
}
|
|
1087
1146
|
}
|
|
1088
1147
|
return findings;
|
|
@@ -1396,6 +1455,95 @@ function scanHtmlForShapeAssembledIllustration(html) {
|
|
|
1396
1455
|
return findings;
|
|
1397
1456
|
}
|
|
1398
1457
|
|
|
1458
|
+
|
|
1459
|
+
// --- Organic clip-path polygons ----------------------------------------------
|
|
1460
|
+
// A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
|
|
1461
|
+
// with curves, is CSS approximating an organic contour: a torn edge, a blob,
|
|
1462
|
+
// a silhouette. The approximation reads as the cheap version of the effect
|
|
1463
|
+
// (the craft floor's geometric-occlusion-mask ban), and it is the signature
|
|
1464
|
+
// of a comp's produced material being replaced with code. Geometric clips
|
|
1465
|
+
// (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
|
|
1466
|
+
// the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
|
|
1467
|
+
// from an alpha matte passes.
|
|
1468
|
+
const ORGANIC_POLYGON_MIN_VERTICES = 10;
|
|
1469
|
+
function scanCssTextForOrganicClipPath(styleText) {
|
|
1470
|
+
const findings = [];
|
|
1471
|
+
const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
|
|
1472
|
+
let m;
|
|
1473
|
+
while ((m = re.exec(styleText)) !== null) {
|
|
1474
|
+
const kind = m[1].toLowerCase();
|
|
1475
|
+
const body = m[2];
|
|
1476
|
+
if (kind === 'path') {
|
|
1477
|
+
// curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a
|
|
1478
|
+
// rectilinear M/L/Z outline; letters in path data are only commands
|
|
1479
|
+
const curves = (body.match(/[CSQTA]/gi) || []).length;
|
|
1480
|
+
if (curves < 3) continue;
|
|
1481
|
+
findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
const points = body.split(',').map((p) => p.trim()).filter(Boolean);
|
|
1485
|
+
if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
|
|
1486
|
+
// Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
|
|
1487
|
+
// contour has arbitrary values.
|
|
1488
|
+
let offGrid = 0;
|
|
1489
|
+
for (const p of points) {
|
|
1490
|
+
const nums = p.match(/-?[\d.]+/g) || [];
|
|
1491
|
+
for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
|
|
1492
|
+
}
|
|
1493
|
+
if (offGrid < points.length) continue;
|
|
1494
|
+
findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
|
1495
|
+
}
|
|
1496
|
+
return findings;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// --- Buried raster ------------------------------------------------------------
|
|
1500
|
+
// A raster (background-image url or <img>) that never reaches the screen:
|
|
1501
|
+
// under a near-opaque gradient wash in the same background stack, or on an
|
|
1502
|
+
// element at near-zero opacity. It is how a produced texture "ships" while
|
|
1503
|
+
// the page shows flat color, and the finish reviewer cannot see it either.
|
|
1504
|
+
// A tint under 0.9 alpha passes (hero darkening); a blend mode passes
|
|
1505
|
+
// (multiply/overlay keep the material visible); opacity >= 0.15 passes.
|
|
1506
|
+
function scanCssTextForBuriedRaster(styleText) {
|
|
1507
|
+
const findings = [];
|
|
1508
|
+
// background stacks: split declarations, look for url() + a gradient whose
|
|
1509
|
+
// stops all carry alpha >= 0.9 (or opaque hex/named colors)
|
|
1510
|
+
const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
|
|
1511
|
+
let m;
|
|
1512
|
+
while ((m = declRe.exec(styleText)) !== null) {
|
|
1513
|
+
const value = m[1];
|
|
1514
|
+
if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
|
|
1515
|
+
// a blend mode declared in the same rule keeps the raster visible
|
|
1516
|
+
const ruleStart = styleText.lastIndexOf('{', m.index);
|
|
1517
|
+
const ruleEnd = styleText.indexOf('}', m.index);
|
|
1518
|
+
const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
|
|
1519
|
+
if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
|
|
1520
|
+
// Layers are painted first-on-top: only a wash listed BEFORE the url()
|
|
1521
|
+
// covers it. An image on top of a gradient is not buried.
|
|
1522
|
+
const firstUrl = value.search(/url\(/i);
|
|
1523
|
+
const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]);
|
|
1524
|
+
let opaqueWash = false;
|
|
1525
|
+
// an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8
|
|
1526
|
+
const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; };
|
|
1527
|
+
for (const g of gradients) {
|
|
1528
|
+
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]));
|
|
1529
|
+
const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '');
|
|
1530
|
+
// hex stops: 4- and 8-digit forms carry their own alpha
|
|
1531
|
+
for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) {
|
|
1532
|
+
const hex = h[1];
|
|
1533
|
+
if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255);
|
|
1534
|
+
else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255);
|
|
1535
|
+
else alphas.push(1);
|
|
1536
|
+
}
|
|
1537
|
+
const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped);
|
|
1538
|
+
if (named) alphas.push(1);
|
|
1539
|
+
if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; }
|
|
1540
|
+
}
|
|
1541
|
+
if (!opaqueWash) continue;
|
|
1542
|
+
findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined });
|
|
1543
|
+
}
|
|
1544
|
+
return findings;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1399
1547
|
// Scoped scan corpora for the page-level pattern checks. CSS-property
|
|
1400
1548
|
// regexes run over the whole source string fire on documentation ABOUT
|
|
1401
1549
|
// css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
|
|
@@ -1453,8 +1601,10 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
1453
1601
|
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
|
|
1454
1602
|
if (purpleHexRe.test(styleText)) {
|
|
1455
1603
|
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
|
|
1456
|
-
|
|
1457
|
-
|
|
1604
|
+
purpleTextRe.lastIndex = 0;
|
|
1605
|
+
const purpleMatch = purpleTextRe.exec(styleText);
|
|
1606
|
+
if (purpleMatch) {
|
|
1607
|
+
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
|
|
1458
1608
|
}
|
|
1459
1609
|
}
|
|
1460
1610
|
|
|
@@ -1465,7 +1615,7 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
1465
1615
|
const start = Math.max(0, gm.index - 200);
|
|
1466
1616
|
const context = styleText.substring(start, gm.index + gm[0].length + 200);
|
|
1467
1617
|
if (/gradient/i.test(context)) {
|
|
1468
|
-
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
|
|
1618
|
+
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
|
|
1469
1619
|
break;
|
|
1470
1620
|
}
|
|
1471
1621
|
}
|
|
@@ -1531,7 +1681,7 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
1531
1681
|
const animationToken = bounceMatch[1]
|
|
1532
1682
|
.split(/[,\s]+/)
|
|
1533
1683
|
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
|
|
1534
|
-
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}
|
|
1684
|
+
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
|
|
1535
1685
|
}
|
|
1536
1686
|
|
|
1537
1687
|
// Overshoot cubic-bezier
|
|
@@ -1540,7 +1690,7 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
1540
1690
|
while ((bm = bezierRe.exec(styleText)) !== null) {
|
|
1541
1691
|
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
|
|
1542
1692
|
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
|
|
1543
|
-
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})
|
|
1693
|
+
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
|
|
1544
1694
|
break;
|
|
1545
1695
|
}
|
|
1546
1696
|
}
|
|
@@ -1566,6 +1716,10 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
1566
1716
|
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
|
|
1567
1717
|
findings.push(...scanHtmlForShapeAssembledIllustration(html));
|
|
1568
1718
|
|
|
1719
|
+
// Organic clip-path contours and rasters buried under washes or opacity
|
|
1720
|
+
findings.push(...scanCssTextForOrganicClipPath(styleText));
|
|
1721
|
+
findings.push(...scanCssTextForBuriedRaster(styleText));
|
|
1722
|
+
|
|
1569
1723
|
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
|
|
1570
1724
|
findings.push(...scanCssTextForMarquee(styleText, html));
|
|
1571
1725
|
|
|
@@ -1573,18 +1727,21 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
1573
1727
|
|
|
1574
1728
|
const glowHits = scanCssTextForGlow(styleText);
|
|
1575
1729
|
if (glowHits.length > 0) {
|
|
1576
|
-
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
|
|
1730
|
+
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
|
|
1577
1731
|
}
|
|
1578
1732
|
|
|
1579
1733
|
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
|
|
1580
1734
|
const haloHits = scanCssTextForRadialHalo(styleText);
|
|
1581
1735
|
if (haloHits.length > 0) {
|
|
1582
|
-
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
|
|
1736
|
+
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
|
|
1583
1737
|
}
|
|
1584
1738
|
|
|
1585
1739
|
// --- Generated-UI tells: repeating-gradient stripes ---
|
|
1586
|
-
|
|
1587
|
-
|
|
1740
|
+
{
|
|
1741
|
+
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
|
|
1742
|
+
if (stripesMatch) {
|
|
1743
|
+
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
|
|
1744
|
+
}
|
|
1588
1745
|
}
|
|
1589
1746
|
|
|
1590
1747
|
// --- Generated-UI tells: two-axis grid-line background ---
|
|
@@ -1602,7 +1759,7 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
1602
1759
|
// whole gradient layers.
|
|
1603
1760
|
const gridHits = scanCssTextForGridBackground(styleText);
|
|
1604
1761
|
if (gridHits.length > 0) {
|
|
1605
|
-
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
|
|
1762
|
+
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
|
|
1606
1763
|
}
|
|
1607
1764
|
|
|
1608
1765
|
// --- Generated-copy tells: "X theater" framing copy ---
|
|
@@ -1622,8 +1779,11 @@ function checkHtmlPatterns(html, corpora) {
|
|
|
1622
1779
|
// hover:rotate / hover:translate utility on an <img>. Each distinct
|
|
1623
1780
|
// mechanism is its own finding.
|
|
1624
1781
|
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
|
|
1625
|
-
|
|
1626
|
-
|
|
1782
|
+
{
|
|
1783
|
+
const imgHoverMatch = imgHoverCss.exec(styleText);
|
|
1784
|
+
if (imgHoverMatch) {
|
|
1785
|
+
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
|
|
1786
|
+
}
|
|
1627
1787
|
}
|
|
1628
1788
|
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
|
|
1629
1789
|
let im;
|
|
@@ -1670,7 +1830,46 @@ function readOwnBackgroundColor(el, computedStyle) {
|
|
|
1670
1830
|
return bg;
|
|
1671
1831
|
}
|
|
1672
1832
|
|
|
1673
|
-
|
|
1833
|
+
// One element's background-color as the cascade walk sees it: computed style
|
|
1834
|
+
// first (with the modern-color fallback), then, in static mode only,
|
|
1835
|
+
// custom-prop resolution and the inline-shorthand peek. Shared by
|
|
1836
|
+
// resolveBackgroundInfo and resolveGradientStops so both walks read the same
|
|
1837
|
+
// surfaces.
|
|
1838
|
+
function readCascadeBackgroundColor(current, style, customPropMap) {
|
|
1839
|
+
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
|
|
1840
|
+
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
|
|
1841
|
+
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
|
|
1842
|
+
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
|
|
1843
|
+
if (customPropMap) {
|
|
1844
|
+
bg = parseColorResolved(style.backgroundColor, customPropMap);
|
|
1845
|
+
}
|
|
1846
|
+
if (!bg || bg.a < 0.1) {
|
|
1847
|
+
// Inline-style fallback for colors the static cascade did not surface
|
|
1848
|
+
// on backgroundColor.
|
|
1849
|
+
const rawStyle = current.getAttribute?.('style') || '';
|
|
1850
|
+
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
|
|
1851
|
+
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
|
|
1852
|
+
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
|
|
1853
|
+
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
return bg;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// Walk up for the surface the element's text is painted on.
|
|
1861
|
+
//
|
|
1862
|
+
// Returns { color, unresolved }:
|
|
1863
|
+
// • color set — the effective surface, overlays composited in.
|
|
1864
|
+
// • unresolved: true — a layer on the way up paints a color this parser
|
|
1865
|
+
// cannot read, so the surface is unknown. Callers
|
|
1866
|
+
// must SKIP their contrast checks. Guessing white
|
|
1867
|
+
// here is what flooded dark themes with false
|
|
1868
|
+
// "on #ffffff" findings: one abstention costs a
|
|
1869
|
+
// single finding, one wrong guess costs a hundred.
|
|
1870
|
+
// • both null/false — no solid color, but a gradient or image is in
|
|
1871
|
+
// play; callers fall back to its color stops.
|
|
1872
|
+
function resolveBackgroundInfo(el, win, customPropMap) {
|
|
1674
1873
|
let current = el;
|
|
1675
1874
|
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
|
|
1676
1875
|
// base. A browser composites these over the base; the old behavior
|
|
@@ -1698,67 +1897,114 @@ function resolveBackground(el, win, customPropMap) {
|
|
|
1698
1897
|
// body backgrounds.
|
|
1699
1898
|
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
|
|
1700
1899
|
// (e.g. any color-mix() result), which plain parseRgb misses.
|
|
1701
|
-
let bg =
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1900
|
+
let bg = readCascadeBackgroundColor(current, style, customPropMap);
|
|
1901
|
+
|
|
1902
|
+
// `background-color: currentcolor` paints with the element's own text
|
|
1903
|
+
// color — real paint whose value we know. Real browsers resolve the
|
|
1904
|
+
// keyword before getComputedStyle output; jsdom hands it through
|
|
1905
|
+
// verbatim, and without this substitution the layer would read as
|
|
1906
|
+
// unparseable and force a needless abstention.
|
|
1907
|
+
if ((!bg || bg.a < 0.1) && /^currentcolor$/i.test(String(style.backgroundColor || '').trim())) {
|
|
1908
|
+
// The static cascade resolves var() text tokens before checks run, so
|
|
1909
|
+
// style.color is normally already an rgb string here; parseColorResolved
|
|
1910
|
+
// is defense in depth for any future caller that passes a live
|
|
1911
|
+
// customPropMap (it matches the text-color path in checkElementColors
|
|
1912
|
+
// and reduces to parseAnyColor when the map is null or absent).
|
|
1913
|
+
bg = parseRgb(style.color) || parseColorResolved(style.color, customPropMap);
|
|
1718
1914
|
}
|
|
1719
1915
|
|
|
1720
1916
|
if (bg && bg.a > 0.1) {
|
|
1721
|
-
if (bg.a >= 0.99) return flatten(bg);
|
|
1917
|
+
if (bg.a >= 0.99) return { color: flatten(bg), unresolved: false };
|
|
1722
1918
|
overlays.push(bg);
|
|
1919
|
+
} else if (!bg && !isNoPaintColorValue(style.backgroundColor)) {
|
|
1920
|
+
// This layer names a color we could not parse (a color space we do not
|
|
1921
|
+
// model, an unresolved var(), a syntax newer than the parser). It may
|
|
1922
|
+
// well be opaque, which would make every ancestor below it invisible —
|
|
1923
|
+
// so the surface is unknown and the walk stops here rather than
|
|
1924
|
+
// reporting an ancestor the visitor never sees.
|
|
1925
|
+
return { color: null, unresolved: true };
|
|
1723
1926
|
}
|
|
1724
|
-
// No solid bg-color at this level
|
|
1725
|
-
//
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1729
|
-
//
|
|
1730
|
-
//
|
|
1731
|
-
//
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
//
|
|
1735
|
-
//
|
|
1927
|
+
// No solid bg-color at this level, but this level paints an image. CSS
|
|
1928
|
+
// stacks background-image layers first-on-top, so which layer leads
|
|
1929
|
+
// decides what the visitor sees:
|
|
1930
|
+
// • gradient on top — the gradient is the surface. Hand the caller a
|
|
1931
|
+
// null color so it falls back to the gradient's own stops (body
|
|
1932
|
+
// grounds, gradient buttons, hero sections).
|
|
1933
|
+
// • url() on top — the surface is an image whose pixels this engine
|
|
1934
|
+
// cannot read, and it may fully cover every layer and ancestor
|
|
1935
|
+
// beneath it. Same contract as an unparseable color: abstain, so
|
|
1936
|
+
// the gradient-stop fallback never measures a gradient the image
|
|
1937
|
+
// hides (the shipped miss: `url(photo), linear-gradient(...)`
|
|
1938
|
+
// reported low-contrast against the invisible gradient's stops).
|
|
1736
1939
|
if (hasGradientOrUrl) {
|
|
1737
|
-
|
|
1738
|
-
|
|
1940
|
+
const layers = splitTopLevelCommas(bgImage);
|
|
1941
|
+
const topPaintLayer = layers.find(
|
|
1942
|
+
(layer) => /gradient\s*\(/i.test(layer) || /url\s*\(/i.test(layer),
|
|
1943
|
+
);
|
|
1944
|
+
const gradientOnTop = !!topPaintLayer
|
|
1945
|
+
&& /gradient\s*\(/i.test(topPaintLayer)
|
|
1946
|
+
&& !/^\s*url\s*\(/i.test(topPaintLayer);
|
|
1947
|
+
if (!gradientOnTop) return { color: null, unresolved: true };
|
|
1948
|
+
// Gradient on top of a url() layer: the image shows through wherever
|
|
1949
|
+
// the gradient is not fully opaque, so a translucent wash like
|
|
1950
|
+
// `linear-gradient(rgba(0,0,0,.2), rgba(0,0,0,.2)), url(photo)` paints
|
|
1951
|
+
// a blend with pixels this engine cannot read. Only a gradient whose
|
|
1952
|
+
// every readable stop is opaque provably covers the image; otherwise
|
|
1953
|
+
// the surface is unknown — abstain rather than hand callers gradient
|
|
1954
|
+
// stops (or a stop average) the visitor never sees unmixed.
|
|
1955
|
+
const urlBeneath = layers.some(
|
|
1956
|
+
(layer) => layer !== topPaintLayer && /url\s*\(/i.test(layer),
|
|
1957
|
+
);
|
|
1958
|
+
if (urlBeneath) {
|
|
1959
|
+
const topStops = parseGradientColors(topPaintLayer);
|
|
1960
|
+
const provablyOpaque = topStops.length > 0 && topStops.every((s) => (s.a ?? 1) >= 0.99);
|
|
1961
|
+
if (!provablyOpaque) return { color: null, unresolved: true };
|
|
1739
1962
|
}
|
|
1740
|
-
return null;
|
|
1963
|
+
return { color: null, unresolved: false };
|
|
1741
1964
|
}
|
|
1742
1965
|
current = current.parentElement;
|
|
1743
1966
|
}
|
|
1744
|
-
|
|
1967
|
+
// Every layer up to the document root was genuinely see-through, so the
|
|
1968
|
+
// browser paints its default canvas. This is the ONLY case that earns the
|
|
1969
|
+
// white assumption.
|
|
1970
|
+
return { color: flatten({ r: 255, g: 255, b: 255, a: 1 }), unresolved: false };
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
function resolveBackground(el, win, customPropMap) {
|
|
1974
|
+
return resolveBackgroundInfo(el, win, customPropMap).color;
|
|
1745
1975
|
}
|
|
1746
1976
|
|
|
1747
1977
|
// Walk parents looking for a gradient background and return its color stops.
|
|
1748
1978
|
// Used as a fallback when resolveBackground() returns null because the
|
|
1749
1979
|
// effective background is a gradient (no single solid color to compare against).
|
|
1980
|
+
// Translucent solid layers found between the element and the gradient (frosted
|
|
1981
|
+
// panels, glass washes) are composited over every stop, the same way
|
|
1982
|
+
// resolveBackground flattens them over a solid base — raw stops alone would
|
|
1983
|
+
// false-flag dark text on a light frosted wash over a dark gradient, and miss
|
|
1984
|
+
// the inverse.
|
|
1750
1985
|
function resolveGradientStops(el, win, customPropMap) {
|
|
1751
1986
|
let current = el;
|
|
1987
|
+
const overlays = [];
|
|
1752
1988
|
while (current && current.nodeType === 1) {
|
|
1753
1989
|
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
|
|
1754
1990
|
const bgImage = style.backgroundImage || '';
|
|
1991
|
+
// A url() layer anywhere in the stack — alone, or alongside a gradient in
|
|
1992
|
+
// the same declaration (a translucent wash over a texture photo) — paints
|
|
1993
|
+
// pixels the analytic walk cannot know. Measuring the gradient stops over
|
|
1994
|
+
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
|
|
1995
|
+
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
|
|
1996
|
+
// image-backed text.
|
|
1997
|
+
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
|
|
1755
1998
|
let stops = null;
|
|
1756
1999
|
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
|
|
2000
|
+
// parseGradientColors (shared) reads modern-space stops too — oklch,
|
|
2001
|
+
// color-mix and friends via balanced-paren token capture — so browser
|
|
2002
|
+
// computed values that keep the authored syntax stay measurable.
|
|
1757
2003
|
const parsed = parseGradientColors(bgImage);
|
|
1758
2004
|
if (parsed.length > 0) stops = parsed;
|
|
1759
2005
|
}
|
|
1760
2006
|
if (!stops && !DETECTOR_IS_BROWSER) {
|
|
1761
|
-
//
|
|
2007
|
+
// Static mode: peek at the raw inline style for gradients the cascade did not surface
|
|
1762
2008
|
const rawStyle = current.getAttribute?.('style') || '';
|
|
1763
2009
|
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
|
|
1764
2010
|
if (bgMatch && /gradient/i.test(bgMatch[1])) {
|
|
@@ -1766,7 +2012,23 @@ function resolveGradientStops(el, win, customPropMap) {
|
|
|
1766
2012
|
if (parsed.length > 0) stops = parsed;
|
|
1767
2013
|
}
|
|
1768
2014
|
}
|
|
1769
|
-
if (stops)
|
|
2015
|
+
if (stops) {
|
|
2016
|
+
const composited = compositeGradientStops(stops, current, win, customPropMap);
|
|
2017
|
+
if (!composited || overlays.length === 0) return composited;
|
|
2018
|
+
return composited.map(stop => {
|
|
2019
|
+
let acc = stop;
|
|
2020
|
+
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
|
|
2021
|
+
return acc;
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
const bg = readCascadeBackgroundColor(current, style, customPropMap);
|
|
2025
|
+
if (bg && bg.a > 0.1) {
|
|
2026
|
+
// An opaque surface above the gradient means the gradient never shows
|
|
2027
|
+
// through here; resolveBackground would have returned it, so reaching
|
|
2028
|
+
// this is defensive — bail rather than measure the wrong layer.
|
|
2029
|
+
if (bg.a >= 0.99) return null;
|
|
2030
|
+
overlays.push(bg);
|
|
2031
|
+
}
|
|
1770
2032
|
current = current.parentElement;
|
|
1771
2033
|
}
|
|
1772
2034
|
return null;
|
|
@@ -1986,15 +2248,25 @@ function checkElementColorsDOM(el) {
|
|
|
1986
2248
|
const rect = el.getBoundingClientRect();
|
|
1987
2249
|
if (rect.width < 10 || rect.height < 10) return [];
|
|
1988
2250
|
const style = getComputedStyle(el);
|
|
2251
|
+
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
|
|
2252
|
+
// decks) are not user-visible, and measuring their inherited colors against
|
|
2253
|
+
// whatever surface happens to sit behind the stack is noise, not audit.
|
|
2254
|
+
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
|
|
1989
2255
|
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
1990
2256
|
const hasDirectText = directText.trim().length > 0;
|
|
1991
|
-
|
|
2257
|
+
const bgInfo = resolveBackgroundInfo(el);
|
|
2258
|
+
let effectiveBg = bgInfo.color;
|
|
2259
|
+
// An unreadable surface anywhere up the chain: skip the gradient-stop
|
|
2260
|
+
// fallback too, so nothing downstream measures against a ground we never
|
|
2261
|
+
// resolved.
|
|
2262
|
+
let surfaceUnresolved = bgInfo.unresolved;
|
|
1992
2263
|
let ownBg = readOwnBackgroundColor(el, style);
|
|
1993
2264
|
if (!ownBg || (ownBg.a ?? 1) <= 0.5) {
|
|
1994
2265
|
const pseudoSurface = readPseudoSurfaceDOM(el, rect);
|
|
1995
2266
|
if (pseudoSurface) {
|
|
1996
2267
|
ownBg = pseudoSurface;
|
|
1997
2268
|
effectiveBg = pseudoSurface;
|
|
2269
|
+
surfaceUnresolved = false;
|
|
1998
2270
|
}
|
|
1999
2271
|
}
|
|
2000
2272
|
return checkColors({
|
|
@@ -2006,8 +2278,8 @@ function checkElementColorsDOM(el) {
|
|
|
2006
2278
|
// an oklch token near its own oklch background).
|
|
2007
2279
|
textColor: parseRgb(style.color) || parseAnyColor(style.color),
|
|
2008
2280
|
bgColor: ownBg,
|
|
2009
|
-
effectiveBg,
|
|
2010
|
-
effectiveBgStops: effectiveBg ? null : resolveGradientStops(el),
|
|
2281
|
+
effectiveBg: surfaceUnresolved ? null : effectiveBg,
|
|
2282
|
+
effectiveBgStops: surfaceUnresolved || effectiveBg ? null : resolveGradientStops(el),
|
|
2011
2283
|
fontSize: parseFloat(style.fontSize) || 16,
|
|
2012
2284
|
fontWeight: parseInt(style.fontWeight) || 400,
|
|
2013
2285
|
hasDirectText,
|
|
@@ -2157,283 +2429,6 @@ function resolveVarRefs(raw, customPropMap, depth = 0) {
|
|
|
2157
2429
|
});
|
|
2158
2430
|
}
|
|
2159
2431
|
|
|
2160
|
-
// OKLCH → sRGB conversion (Björn Ottosson's matrices). L in 0..1 (or %),
|
|
2161
|
-
// C in 0..~0.4 typical, H in degrees. Returns clamped {r,g,b,a:1} in 0..255.
|
|
2162
|
-
// Needed because jsdom doesn't compute oklch() values — getComputedStyle
|
|
2163
|
-
// returns the literal "oklch(...)" string. Without this, the entire
|
|
2164
|
-
// Tailwind v4 color palette (which is OKLCH-based) is invisible to the
|
|
2165
|
-
// detector's contrast / color checks.
|
|
2166
|
-
function oklchToRgb(L, C, H) {
|
|
2167
|
-
const hRad = (H * Math.PI) / 180;
|
|
2168
|
-
return oklabToRgb(L, C * Math.cos(hRad), C * Math.sin(hRad));
|
|
2169
|
-
}
|
|
2170
|
-
|
|
2171
|
-
function oklabToRgb(L, a, b) {
|
|
2172
|
-
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
2173
|
-
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
2174
|
-
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
2175
|
-
const lc = l_ * l_ * l_, mc = m_ * m_ * m_, sc = s_ * s_ * s_;
|
|
2176
|
-
const rLin = 4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc;
|
|
2177
|
-
const gLin = -1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc;
|
|
2178
|
-
const bLin = -0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc;
|
|
2179
|
-
const enc = (x) => {
|
|
2180
|
-
const c = Math.max(0, Math.min(1, x));
|
|
2181
|
-
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
2182
|
-
};
|
|
2183
|
-
return {
|
|
2184
|
-
r: Math.round(enc(rLin) * 255),
|
|
2185
|
-
g: Math.round(enc(gLin) * 255),
|
|
2186
|
-
b: Math.round(enc(bLin) * 255),
|
|
2187
|
-
a: 1,
|
|
2188
|
-
};
|
|
2189
|
-
}
|
|
2190
|
-
|
|
2191
|
-
function hslToRgb(h, s, l) {
|
|
2192
|
-
h = ((h % 360) + 360) % 360;
|
|
2193
|
-
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
2194
|
-
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
2195
|
-
const m0 = l - c / 2;
|
|
2196
|
-
const [r, g, b] =
|
|
2197
|
-
h < 60 ? [c, x, 0] :
|
|
2198
|
-
h < 120 ? [x, c, 0] :
|
|
2199
|
-
h < 180 ? [0, c, x] :
|
|
2200
|
-
h < 240 ? [0, x, c] :
|
|
2201
|
-
h < 300 ? [x, 0, c] : [c, 0, x];
|
|
2202
|
-
return {
|
|
2203
|
-
r: Math.round((r + m0) * 255),
|
|
2204
|
-
g: Math.round((g + m0) * 255),
|
|
2205
|
-
b: Math.round((b + m0) * 255),
|
|
2206
|
-
a: 1,
|
|
2207
|
-
};
|
|
2208
|
-
}
|
|
2209
|
-
|
|
2210
|
-
function hwbToRgb(h, w, bl) {
|
|
2211
|
-
if (w + bl >= 1) {
|
|
2212
|
-
const g = Math.round((w / (w + bl)) * 255);
|
|
2213
|
-
return { r: g, g, b: g, a: 1 };
|
|
2214
|
-
}
|
|
2215
|
-
const base = hslToRgb(h, 1, 0.5);
|
|
2216
|
-
const mix = (c) => Math.round(((c / 255) * (1 - w - bl) + w) * 255);
|
|
2217
|
-
return { r: mix(base.r), g: mix(base.g), b: mix(base.b), a: 1 };
|
|
2218
|
-
}
|
|
2219
|
-
|
|
2220
|
-
// Common CSS named colors — the handful that actually show up in generated
|
|
2221
|
-
// UIs, not the full 148-name spec list. Includes the achromatic names so a
|
|
2222
|
-
// named gray parses (and correctly reads as no-chroma) instead of being
|
|
2223
|
-
// treated as an unknown color.
|
|
2224
|
-
const CSS_NAMED_COLORS = {
|
|
2225
|
-
black: { r: 0, g: 0, b: 0 },
|
|
2226
|
-
white: { r: 255, g: 255, b: 255 },
|
|
2227
|
-
gray: { r: 128, g: 128, b: 128 },
|
|
2228
|
-
grey: { r: 128, g: 128, b: 128 },
|
|
2229
|
-
silver: { r: 192, g: 192, b: 192 },
|
|
2230
|
-
dimgray: { r: 105, g: 105, b: 105 },
|
|
2231
|
-
darkgray: { r: 169, g: 169, b: 169 },
|
|
2232
|
-
lightgray: { r: 211, g: 211, b: 211 },
|
|
2233
|
-
gainsboro: { r: 220, g: 220, b: 220 },
|
|
2234
|
-
whitesmoke: { r: 245, g: 245, b: 245 },
|
|
2235
|
-
red: { r: 255, g: 0, b: 0 },
|
|
2236
|
-
crimson: { r: 220, g: 20, b: 60 },
|
|
2237
|
-
tomato: { r: 255, g: 99, b: 71 },
|
|
2238
|
-
coral: { r: 255, g: 127, b: 80 },
|
|
2239
|
-
salmon: { r: 250, g: 128, b: 114 },
|
|
2240
|
-
orange: { r: 255, g: 165, b: 0 },
|
|
2241
|
-
gold: { r: 255, g: 215, b: 0 },
|
|
2242
|
-
yellow: { r: 255, g: 255, b: 0 },
|
|
2243
|
-
olive: { r: 128, g: 128, b: 0 },
|
|
2244
|
-
lime: { r: 0, g: 255, b: 0 },
|
|
2245
|
-
green: { r: 0, g: 128, b: 0 },
|
|
2246
|
-
teal: { r: 0, g: 128, b: 128 },
|
|
2247
|
-
turquoise: { r: 64, g: 224, b: 208 },
|
|
2248
|
-
cyan: { r: 0, g: 255, b: 255 },
|
|
2249
|
-
aqua: { r: 0, g: 255, b: 255 },
|
|
2250
|
-
skyblue: { r: 135, g: 206, b: 235 },
|
|
2251
|
-
dodgerblue: { r: 30, g: 144, b: 255 },
|
|
2252
|
-
blue: { r: 0, g: 0, b: 255 },
|
|
2253
|
-
navy: { r: 0, g: 0, b: 128 },
|
|
2254
|
-
indigo: { r: 75, g: 0, b: 130 },
|
|
2255
|
-
rebeccapurple: { r: 102, g: 51, b: 153 },
|
|
2256
|
-
purple: { r: 128, g: 0, b: 128 },
|
|
2257
|
-
violet: { r: 238, g: 130, b: 238 },
|
|
2258
|
-
orchid: { r: 218, g: 112, b: 214 },
|
|
2259
|
-
magenta: { r: 255, g: 0, b: 255 },
|
|
2260
|
-
fuchsia: { r: 255, g: 0, b: 255 },
|
|
2261
|
-
hotpink: { r: 255, g: 105, b: 180 },
|
|
2262
|
-
pink: { r: 255, g: 192, b: 203 },
|
|
2263
|
-
maroon: { r: 128, g: 0, b: 0 },
|
|
2264
|
-
};
|
|
2265
|
-
|
|
2266
|
-
// Split a string on top-level commas (ignoring commas nested in parens).
|
|
2267
|
-
function splitTopLevelCommas(str) {
|
|
2268
|
-
const parts = [];
|
|
2269
|
-
let depth = 0, start = 0;
|
|
2270
|
-
for (let i = 0; i < str.length; i++) {
|
|
2271
|
-
const ch = str[i];
|
|
2272
|
-
if (ch === '(') depth++;
|
|
2273
|
-
else if (ch === ')') depth = Math.max(0, depth - 1);
|
|
2274
|
-
else if (ch === ',' && depth === 0) {
|
|
2275
|
-
parts.push(str.slice(start, i).trim());
|
|
2276
|
-
start = i + 1;
|
|
2277
|
-
}
|
|
2278
|
-
}
|
|
2279
|
-
const tail = str.slice(start).trim();
|
|
2280
|
-
if (tail) parts.push(tail);
|
|
2281
|
-
return parts;
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
// Evaluate a CSS color-mix() expression to {r,g,b,a}. Returns null when
|
|
2285
|
-
// the expression can't be resolved (unresolved var(), unknown colors).
|
|
2286
|
-
//
|
|
2287
|
-
// Mixing is done with premultiplied alpha in sRGB regardless of the
|
|
2288
|
-
// declared interpolation space. That is exact for the dominant generated-UI
|
|
2289
|
-
// pattern — `color-mix(in oklab, <color> N%, transparent)` — where the
|
|
2290
|
-
// result is simply <color> at alpha N% in ANY rectangular space, and a
|
|
2291
|
-
// close-enough approximation for opaque-opaque mixes (the detector only
|
|
2292
|
-
// consumes these values for contrast/chroma thresholds, not for display).
|
|
2293
|
-
function parseColorMix(str) {
|
|
2294
|
-
const m = String(str).trim().match(/^color-mix\(/i);
|
|
2295
|
-
if (!m) return null;
|
|
2296
|
-
// Balanced-paren capture of the arguments.
|
|
2297
|
-
let depth = 0, end = -1;
|
|
2298
|
-
const open = str.indexOf('(');
|
|
2299
|
-
for (let i = open; i < str.length; i++) {
|
|
2300
|
-
if (str[i] === '(') depth++;
|
|
2301
|
-
else if (str[i] === ')') { depth--; if (depth === 0) { end = i; break; } }
|
|
2302
|
-
}
|
|
2303
|
-
if (end < 0) return null;
|
|
2304
|
-
const args = splitTopLevelCommas(str.slice(open + 1, end));
|
|
2305
|
-
if (args.length !== 3 || !/^in\s/i.test(args[0])) return null;
|
|
2306
|
-
|
|
2307
|
-
const parseComponent = (component) => {
|
|
2308
|
-
// Percentage may lead or trail the color per spec.
|
|
2309
|
-
let pct = null;
|
|
2310
|
-
let colorStr = component;
|
|
2311
|
-
const trail = component.match(/\s+([\d.]+)%$/);
|
|
2312
|
-
const lead = component.match(/^([\d.]+)%\s+/);
|
|
2313
|
-
if (trail) { pct = parseFloat(trail[1]); colorStr = component.slice(0, trail.index).trim(); }
|
|
2314
|
-
else if (lead) { pct = parseFloat(lead[1]); colorStr = component.slice(lead[0].length).trim(); }
|
|
2315
|
-
let color;
|
|
2316
|
-
if (/^transparent$/i.test(colorStr)) color = { r: 0, g: 0, b: 0, a: 0 };
|
|
2317
|
-
else color = parseAnyColor(colorStr);
|
|
2318
|
-
if (!color) return null;
|
|
2319
|
-
return { color, pct };
|
|
2320
|
-
};
|
|
2321
|
-
|
|
2322
|
-
const c1 = parseComponent(args[1]);
|
|
2323
|
-
const c2 = parseComponent(args[2]);
|
|
2324
|
-
if (!c1 || !c2) return null;
|
|
2325
|
-
let p1 = c1.pct, p2 = c2.pct;
|
|
2326
|
-
if (p1 == null && p2 == null) { p1 = 50; p2 = 50; }
|
|
2327
|
-
else if (p1 == null) p1 = 100 - p2;
|
|
2328
|
-
else if (p2 == null) p2 = 100 - p1;
|
|
2329
|
-
const sum = p1 + p2;
|
|
2330
|
-
if (sum <= 0) return null;
|
|
2331
|
-
// Per spec: weights normalize to sum; when sum < 100 the result alpha is
|
|
2332
|
-
// additionally scaled by sum/100.
|
|
2333
|
-
const w1 = p1 / sum, w2 = p2 / sum;
|
|
2334
|
-
const alphaScale = sum < 100 ? sum / 100 : 1;
|
|
2335
|
-
const a1 = c1.color.a ?? 1, a2 = c2.color.a ?? 1;
|
|
2336
|
-
const a = (a1 * w1 + a2 * w2) * alphaScale;
|
|
2337
|
-
if (a <= 0) return { r: 0, g: 0, b: 0, a: 0 };
|
|
2338
|
-
const mix = (ch) => Math.round((c1.color[ch] * a1 * w1 + c2.color[ch] * a2 * w2) / (a1 * w1 + a2 * w2));
|
|
2339
|
-
return { r: mix('r'), g: mix('g'), b: mix('b'), a: Math.min(1, a) };
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
// Composite a translucent color over an opaque(ish) base (simple
|
|
2343
|
-
// source-over in sRGB). Returns an opaque {r,g,b,a:1}.
|
|
2344
|
-
function compositeColorOver(top, base) {
|
|
2345
|
-
const a = top.a ?? 1;
|
|
2346
|
-
return {
|
|
2347
|
-
r: Math.round(top.r * a + base.r * (1 - a)),
|
|
2348
|
-
g: Math.round(top.g * a + base.g * (1 - a)),
|
|
2349
|
-
b: Math.round(top.b * a + base.b * (1 - a)),
|
|
2350
|
-
a: 1,
|
|
2351
|
-
};
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
// Extended color parser: rgb/rgba/hex/oklch/oklab/hsl/hwb/color-mix/common
|
|
2355
|
-
// named colors. Returns null on no match. Use this when the input might be
|
|
2356
|
-
// any CSS color form; use plain parseRgb when you only expect computed rgb()
|
|
2357
|
-
// values from real browsers.
|
|
2358
|
-
function parseAnyColor(s) {
|
|
2359
|
-
if (!s || typeof s !== 'string') return null;
|
|
2360
|
-
const str = s.trim();
|
|
2361
|
-
if (str === 'transparent' || str === 'currentcolor' || str === 'inherit') return null;
|
|
2362
|
-
if (/^color-mix\(/i.test(str)) return parseColorMix(str);
|
|
2363
|
-
let m;
|
|
2364
|
-
m = str.match(/rgba?\(\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)\s*,?\s*(\d+(?:\.\d+)?)(?:\s*[,/]\s*([\d.]+))?\s*\)/);
|
|
2365
|
-
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 };
|
|
2366
|
-
m = str.match(/^#([0-9a-f]{3,8})$/i);
|
|
2367
|
-
if (m) {
|
|
2368
|
-
const h = m[1];
|
|
2369
|
-
if (h.length === 3 || h.length === 4) {
|
|
2370
|
-
return {
|
|
2371
|
-
r: parseInt(h[0] + h[0], 16),
|
|
2372
|
-
g: parseInt(h[1] + h[1], 16),
|
|
2373
|
-
b: parseInt(h[2] + h[2], 16),
|
|
2374
|
-
a: h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1,
|
|
2375
|
-
};
|
|
2376
|
-
}
|
|
2377
|
-
if (h.length === 6 || h.length === 8) {
|
|
2378
|
-
return {
|
|
2379
|
-
r: parseInt(h.slice(0, 2), 16),
|
|
2380
|
-
g: parseInt(h.slice(2, 4), 16),
|
|
2381
|
-
b: parseInt(h.slice(4, 6), 16),
|
|
2382
|
-
a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
|
|
2383
|
-
};
|
|
2384
|
-
}
|
|
2385
|
-
}
|
|
2386
|
-
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
|
|
2387
|
-
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
|
|
2388
|
-
// Match L (with optional %), then C and H separated permissively.
|
|
2389
|
-
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
2390
|
-
if (m) {
|
|
2391
|
-
const Lnum = parseFloat(m[1]);
|
|
2392
|
-
const L = m[2] === '%' ? Lnum / 100 : Lnum;
|
|
2393
|
-
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
|
|
2394
|
-
if (m[5] !== undefined) {
|
|
2395
|
-
const alpha = parseFloat(m[5]);
|
|
2396
|
-
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
|
|
2397
|
-
}
|
|
2398
|
-
return rgb;
|
|
2399
|
-
}
|
|
2400
|
-
// OKLAB — a/b are signed axes; percentages map 100% → 0.4.
|
|
2401
|
-
m = str.match(/oklab\(\s*([\d.]+)(%?)\s+(-?[\d.]+)(%?)\s+(-?[\d.]+)(%?)(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
2402
|
-
if (m) {
|
|
2403
|
-
const L = m[2] === '%' ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
|
2404
|
-
const a = m[4] === '%' ? parseFloat(m[3]) * 0.004 : parseFloat(m[3]);
|
|
2405
|
-
const b = m[6] === '%' ? parseFloat(m[5]) * 0.004 : parseFloat(m[5]);
|
|
2406
|
-
const rgb = oklabToRgb(L, a, b);
|
|
2407
|
-
if (m[7] !== undefined) {
|
|
2408
|
-
const alpha = parseFloat(m[7]);
|
|
2409
|
-
rgb.a = m[8] === '%' ? alpha / 100 : alpha;
|
|
2410
|
-
}
|
|
2411
|
-
return rgb;
|
|
2412
|
-
}
|
|
2413
|
-
// HSL/HSLA — comma or space syntax, optional deg on hue.
|
|
2414
|
-
m = str.match(/hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[,\s]\s*([\d.]+)%\s*[,\s]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+)(%)?)?\s*\)/i);
|
|
2415
|
-
if (m) {
|
|
2416
|
-
const rgb = hslToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
|
2417
|
-
if (m[4] !== undefined) {
|
|
2418
|
-
const alpha = parseFloat(m[4]);
|
|
2419
|
-
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
|
2420
|
-
}
|
|
2421
|
-
return rgb;
|
|
2422
|
-
}
|
|
2423
|
-
// HWB — hue whiteness% blackness%.
|
|
2424
|
-
m = str.match(/hwb\(\s*(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
|
|
2425
|
-
if (m) {
|
|
2426
|
-
const rgb = hwbToRgb(parseFloat(m[1]), parseFloat(m[2]) / 100, parseFloat(m[3]) / 100);
|
|
2427
|
-
if (m[4] !== undefined) {
|
|
2428
|
-
const alpha = parseFloat(m[4]);
|
|
2429
|
-
rgb.a = m[5] === '%' ? alpha / 100 : alpha;
|
|
2430
|
-
}
|
|
2431
|
-
return rgb;
|
|
2432
|
-
}
|
|
2433
|
-
const named = CSS_NAMED_COLORS[str.toLowerCase()];
|
|
2434
|
-
if (named) return { ...named, a: 1 };
|
|
2435
|
-
return null;
|
|
2436
|
-
}
|
|
2437
2432
|
|
|
2438
2433
|
// Resolve var() refs in a color string (via customPropMap), then parse.
|
|
2439
2434
|
// Returns null on any failure. Used in jsdom-mode paths where
|
|
@@ -2796,9 +2791,20 @@ function checkElementGlowDOM(el) {
|
|
|
2796
2791
|
if (!boxShadow && !textShadow) return [];
|
|
2797
2792
|
// Use parent's background — glow radiates outward, so the surrounding context matters
|
|
2798
2793
|
// If resolveBackground returns null (gradient), try to infer from the gradient colors
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2794
|
+
const parentBgInfo = resolveBackgroundInfo(el.parentElement || el);
|
|
2795
|
+
// Unknown surface (an unreadable layer on the way up): skip only the
|
|
2796
|
+
// gradient hunt below, which would walk PAST that layer and score the
|
|
2797
|
+
// glow against a background the visitor never sees. checkGlow still runs
|
|
2798
|
+
// with a null surface: the zero-offset chromatic halo tell holds on ANY
|
|
2799
|
+
// background, and the static loop already passes the unresolved walk's
|
|
2800
|
+
// null color straight through (detect-html.mjs uses resolveBackground).
|
|
2801
|
+
let parentBg = parentBgInfo.color;
|
|
2802
|
+
if (!parentBg && !parentBgInfo.unresolved) {
|
|
2803
|
+
// Gradient background — sample its colors to determine if it's dark.
|
|
2804
|
+
// Modern-syntax parsing matters here: body-level gradients now reach this
|
|
2805
|
+
// fallback in browser mode, and their stops usually serialize as oklch —
|
|
2806
|
+
// which the shared parseGradientColors reads via its color-function
|
|
2807
|
+
// token capture.
|
|
2802
2808
|
let cur = el.parentElement;
|
|
2803
2809
|
while (cur && cur.nodeType === 1) {
|
|
2804
2810
|
const bgImage = getComputedStyle(cur).backgroundImage || '';
|
|
@@ -2841,15 +2847,18 @@ function checkElementAIPaletteDOM(el) {
|
|
|
2841
2847
|
}
|
|
2842
2848
|
|
|
2843
2849
|
// Check for neon text (vivid cyan/purple color on dark background)
|
|
2844
|
-
const textColor = parseRgb(style.color);
|
|
2850
|
+
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
|
|
2845
2851
|
if (textColor && hasChroma(textColor, 80)) {
|
|
2846
2852
|
const hue = getHue(textColor);
|
|
2847
2853
|
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
|
|
2848
2854
|
if (isAIPalette) {
|
|
2849
|
-
const
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2855
|
+
const parentBgInfo = el.parentElement
|
|
2856
|
+
? resolveBackgroundInfo(el.parentElement)
|
|
2857
|
+
: { color: null, unresolved: false };
|
|
2858
|
+
// Unknown surface: leave effectiveBg null (no finding) rather than
|
|
2859
|
+
// hunting gradient ancestors past a layer we could not read.
|
|
2860
|
+
let effectiveBg = parentBgInfo.color;
|
|
2861
|
+
if (!effectiveBg && !parentBgInfo.unresolved) {
|
|
2853
2862
|
let cur = el.parentElement;
|
|
2854
2863
|
while (cur && cur.nodeType === 1) {
|
|
2855
2864
|
const gi = getComputedStyle(cur).backgroundImage || '';
|
|
@@ -3181,14 +3190,30 @@ function isNonRenderedText(el, tag, style) {
|
|
|
3181
3190
|
function checkQuality(opts) {
|
|
3182
3191
|
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
|
|
3183
3192
|
const findings = [];
|
|
3184
|
-
//
|
|
3185
|
-
//
|
|
3186
|
-
//
|
|
3187
|
-
//
|
|
3188
|
-
//
|
|
3189
|
-
//
|
|
3193
|
+
// A raster (<img>, or an element with a background url) at near-zero
|
|
3194
|
+
// opacity never reaches the screen: the produced material ships as a
|
|
3195
|
+
// compliance token. The CSS-text scan catches the stylesheet form; this
|
|
3196
|
+
// catches computed opacity on the element itself (both engines).
|
|
3197
|
+
// Skip browser extension injected elements BEFORE any finding is pushed
|
|
3198
|
+
// (a low-opacity raster those hosts inject used to be recorded and then
|
|
3199
|
+
// returned by this very skip). Read the id via getAttribute whenever
|
|
3200
|
+
// `el.id` is not a string: on a <form> (and other [LegacyOverrideBuiltIns]
|
|
3201
|
+
// hosts) a named control like <input name="id"> shadows the builtin `id`
|
|
3202
|
+
// getter and returns the control element, whose `.startsWith` is undefined
|
|
3203
|
+
// and throws (issue #407 — every Shopify product form ships an
|
|
3204
|
+
// <input name="id">).
|
|
3190
3205
|
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
|
|
3191
3206
|
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
|
|
3207
|
+
{
|
|
3208
|
+
const op = parseFloat(style.opacity);
|
|
3209
|
+
if (Number.isFinite(op) && op < 0.15 && op >= 0) {
|
|
3210
|
+
const bg = String(style.backgroundImage || '');
|
|
3211
|
+
if (tag === 'img' || /url\(/i.test(bg)) {
|
|
3212
|
+
const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
|
|
3213
|
+
findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3192
3217
|
|
|
3193
3218
|
// --- Line length too long --- (browser-only: needs rect.width)
|
|
3194
3219
|
if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
|
|
@@ -3644,10 +3669,19 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
|
|
|
3644
3669
|
}
|
|
3645
3670
|
|
|
3646
3671
|
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
|
|
3672
|
+
// Invisible at rest, static twin of the browser walk's skip: opacity does
|
|
3673
|
+
// not inherit, so walk ancestors multiplying declared opacity down.
|
|
3674
|
+
if (style.visibility === 'hidden') return [];
|
|
3675
|
+
let effOpacity = 1;
|
|
3676
|
+
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
|
|
3677
|
+
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
|
|
3678
|
+
}
|
|
3679
|
+
if (effOpacity <= 0.02) return [];
|
|
3647
3680
|
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
|
|
3648
3681
|
const hasDirectText = directText.trim().length > 0;
|
|
3649
3682
|
|
|
3650
|
-
const
|
|
3683
|
+
const bgInfo = resolveBackgroundInfo(el, window, customPropMap);
|
|
3684
|
+
const effectiveBg = bgInfo.color;
|
|
3651
3685
|
// jsdom returns literal "var(--X)" / "oklch(...)" for color, so plain
|
|
3652
3686
|
// parseRgb misses Tailwind-tokenized text colors. Resolve through the
|
|
3653
3687
|
// customPropMap first; fall back to parseRgb for vanilla rgb() pages.
|
|
@@ -3693,11 +3727,13 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
|
|
3693
3727
|
// element itself has no usable own background, that pseudo is the real
|
|
3694
3728
|
// surface for contrast purposes.
|
|
3695
3729
|
let finalEffectiveBg = effectiveBg;
|
|
3730
|
+
let surfaceUnresolved = bgInfo.unresolved;
|
|
3696
3731
|
if ((!ownBg || (ownBg.a ?? 1) <= 0.5) && typeof window.getPseudoSurface === 'function') {
|
|
3697
3732
|
const pseudoSurface = window.getPseudoSurface(el);
|
|
3698
3733
|
if (pseudoSurface) {
|
|
3699
3734
|
ownBg = pseudoSurface;
|
|
3700
3735
|
finalEffectiveBg = pseudoSurface;
|
|
3736
|
+
surfaceUnresolved = false;
|
|
3701
3737
|
}
|
|
3702
3738
|
}
|
|
3703
3739
|
|
|
@@ -3705,8 +3741,9 @@ function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInhe
|
|
|
3705
3741
|
tag,
|
|
3706
3742
|
textColor,
|
|
3707
3743
|
bgColor: ownBg,
|
|
3708
|
-
|
|
3709
|
-
|
|
3744
|
+
// Unknown surface: hand the checks nothing rather than a guess.
|
|
3745
|
+
effectiveBg: surfaceUnresolved ? null : finalEffectiveBg,
|
|
3746
|
+
effectiveBgStops: surfaceUnresolved || finalEffectiveBg ? null : resolveGradientStops(el, window, customPropMap),
|
|
3710
3747
|
fontSize: parseFloat(style.fontSize) || 16,
|
|
3711
3748
|
fontWeight: parseInt(style.fontWeight) || 400,
|
|
3712
3749
|
hasDirectText,
|
|
@@ -3894,8 +3931,7 @@ function checkTypography() {
|
|
|
3894
3931
|
const style = getComputedStyle(el);
|
|
3895
3932
|
const ff = style.fontFamily;
|
|
3896
3933
|
if (!ff) continue;
|
|
3897
|
-
const
|
|
3898
|
-
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
|
3934
|
+
const primary = primaryFontFace(ff);
|
|
3899
3935
|
if (!primary) continue;
|
|
3900
3936
|
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
|
|
3901
3937
|
totalTextElements++;
|
|
@@ -4140,8 +4176,7 @@ function checkPageTypography(doc, win) {
|
|
|
4140
4176
|
if (rule.type !== 1) continue;
|
|
4141
4177
|
const ff = rule.style?.fontFamily;
|
|
4142
4178
|
if (!ff) continue;
|
|
4143
|
-
const
|
|
4144
|
-
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
|
|
4179
|
+
const primary = primaryFontFace(ff);
|
|
4145
4180
|
if (primary) {
|
|
4146
4181
|
fonts.add(primary);
|
|
4147
4182
|
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
|
@@ -4160,11 +4195,10 @@ function checkPageTypography(doc, win) {
|
|
|
4160
4195
|
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
|
|
4161
4196
|
let fm;
|
|
4162
4197
|
while ((fm = ffRe.exec(html)) !== null) {
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
}
|
|
4198
|
+
const primary = primaryFontFace(fm[1]);
|
|
4199
|
+
if (primary) {
|
|
4200
|
+
fonts.add(primary);
|
|
4201
|
+
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
|
|
4168
4202
|
}
|
|
4169
4203
|
}
|
|
4170
4204
|
|
|
@@ -4802,6 +4836,11 @@ function isRenderedForBrowserRule(el) {
|
|
|
4802
4836
|
function checkElementTextOverflowDOM(el) {
|
|
4803
4837
|
const tag = el.tagName.toLowerCase();
|
|
4804
4838
|
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
|
|
4839
|
+
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
|
|
4840
|
+
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
|
|
4841
|
+
// its rendered length sat comfortably inside its box), so the delta is
|
|
4842
|
+
// noise, not overflow. SVG clips to its own viewport anyway.
|
|
4843
|
+
if (el.namespaceURI === 'http://www.w3.org/2000/svg') return [];
|
|
4805
4844
|
if (!isRenderedForBrowserRule(el)) return [];
|
|
4806
4845
|
// Only the element that actually owns overflowing text — not its ancestors,
|
|
4807
4846
|
// which inherit a wider scrollWidth from the spilling descendant.
|
|
@@ -5186,6 +5225,22 @@ function isPaintedForOcclusion(el) {
|
|
|
5186
5225
|
// path is pure geometry and runs anywhere on the page.
|
|
5187
5226
|
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
|
|
5188
5227
|
|
|
5228
|
+
// An element whose effective opacity multiplies out to ~0 paints nothing at
|
|
5229
|
+
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
|
|
5230
|
+
// measure a state nobody sees. Browser-only — the walk needs live computed
|
|
5231
|
+
// styles. Cycling scenes that fade such elements in later are the screenshot
|
|
5232
|
+
// subsystem's territory, not the analytic walk's.
|
|
5233
|
+
function effectiveOpacityDOM(el) {
|
|
5234
|
+
let o = 1;
|
|
5235
|
+
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
|
|
5236
|
+
// wrappers hide every descendant just as thoroughly as a local wrapper.
|
|
5237
|
+
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
|
|
5238
|
+
o *= parseFloat(getComputedStyle(cur).opacity || '1');
|
|
5239
|
+
if (o <= 0.02) return 0;
|
|
5240
|
+
}
|
|
5241
|
+
return o;
|
|
5242
|
+
}
|
|
5243
|
+
|
|
5189
5244
|
function checkTextOcclusionDOM() {
|
|
5190
5245
|
const findings = [];
|
|
5191
5246
|
const seenVictims = new Set();
|
|
@@ -5213,6 +5268,41 @@ function checkTextOcclusionDOM() {
|
|
|
5213
5268
|
}
|
|
5214
5269
|
return false;
|
|
5215
5270
|
};
|
|
5271
|
+
// The classic occluder shape this rules out is an opacity-0 interaction
|
|
5272
|
+
// layer — a range scrubber stretched over a before/after comparison — which
|
|
5273
|
+
// elementFromPoint still returns and whose UA background-color otherwise
|
|
5274
|
+
// reads as an opaque box.
|
|
5275
|
+
const effectiveOpacity = effectiveOpacityDOM;
|
|
5276
|
+
|
|
5277
|
+
// The part of an element that is actually painted, after every scrolling or
|
|
5278
|
+
// clipping ancestor has had its say.
|
|
5279
|
+
//
|
|
5280
|
+
// getBoundingClientRect reports where a box would be if nothing cut it off,
|
|
5281
|
+
// so a paragraph half scrolled out of a panel still reports its full height,
|
|
5282
|
+
// and the half that is clipped away lands wherever the page continues below
|
|
5283
|
+
// the panel. The elementFromPoint probe then samples coordinates the text is
|
|
5284
|
+
// not painted at, finds whatever genuinely is painted there, and reports the
|
|
5285
|
+
// text as buried under it. Any sticky footer or toolbar beneath a scroll
|
|
5286
|
+
// region produces this, and it is the shape most likely to be waved off as
|
|
5287
|
+
// noise, which costs the rule its credibility on the findings that are real.
|
|
5288
|
+
//
|
|
5289
|
+
// Border box rather than padding box on purpose: it errs toward probing, and
|
|
5290
|
+
// giving up a scrollbar gutter's width would drop true findings at the right
|
|
5291
|
+
// edge of a scroller.
|
|
5292
|
+
const paintedRect = (el, rect) => {
|
|
5293
|
+
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
|
|
5294
|
+
for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
|
|
5295
|
+
let cs; try { cs = getComputedStyle(cur); } catch { continue; }
|
|
5296
|
+
const clipsX = String(cs.overflowX || 'visible') !== 'visible';
|
|
5297
|
+
const clipsY = String(cs.overflowY || 'visible') !== 'visible';
|
|
5298
|
+
if (!clipsX && !clipsY) continue;
|
|
5299
|
+
let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
|
|
5300
|
+
if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
|
|
5301
|
+
if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
|
|
5302
|
+
if (right - left < 1 || bottom - top < 1) return null;
|
|
5303
|
+
}
|
|
5304
|
+
return { left, top, right, bottom, width: right - left, height: bottom - top };
|
|
5305
|
+
};
|
|
5216
5306
|
|
|
5217
5307
|
// Collect renderable text owners in / near the first viewport for the
|
|
5218
5308
|
// elementFromPoint probe. SVG <text> counts too.
|
|
@@ -5225,8 +5315,14 @@ function checkTextOcclusionDOM() {
|
|
|
5225
5315
|
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
|
|
5226
5316
|
if (text.length < 2) continue;
|
|
5227
5317
|
if (!isPaintedForOcclusion(el)) continue;
|
|
5228
|
-
|
|
5229
|
-
|
|
5318
|
+
if (effectiveOpacity(el) <= 0.02) continue;
|
|
5319
|
+
let full; try { full = el.getBoundingClientRect(); } catch { continue; }
|
|
5320
|
+
if (full.width < 6 || full.height < 6) continue;
|
|
5321
|
+
// Probe only where the text is on screen. A run clipped down to a sliver is
|
|
5322
|
+
// dropped rather than sampled: a few pixels of visible text cannot support
|
|
5323
|
+
// a coverage fraction worth reporting either way.
|
|
5324
|
+
const rect = paintedRect(el, full);
|
|
5325
|
+
if (!rect || rect.width < 6 || rect.height < 6) continue;
|
|
5230
5326
|
// Viewport-bound probe: keep text whose box overlaps the live viewport.
|
|
5231
5327
|
if (rect.bottom <= 0 || rect.top >= vh) continue;
|
|
5232
5328
|
textEls.push({ el, rect, text, inSvg });
|
|
@@ -5258,6 +5354,7 @@ function checkTextOcclusionDOM() {
|
|
|
5258
5354
|
if (top === el || el.contains(top) || top.contains(el)) continue;
|
|
5259
5355
|
const topCs = getComputedStyle(top);
|
|
5260
5356
|
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
|
|
5357
|
+
if (effectiveOpacity(top) <= 0.02) continue;
|
|
5261
5358
|
const topTag = top.tagName.toLowerCase();
|
|
5262
5359
|
// Text sitting under a raw image/video is contrast territory (deduped
|
|
5263
5360
|
// against the pixel low-contrast rule); leave those alone here.
|
|
@@ -5468,6 +5565,7 @@ export {
|
|
|
5468
5565
|
CSS_NAMED_COLORS,
|
|
5469
5566
|
checkBorders,
|
|
5470
5567
|
isEmojiOnlyText,
|
|
5568
|
+
scopedIgnoreActive,
|
|
5471
5569
|
checkColors,
|
|
5472
5570
|
checkHoverContrast,
|
|
5473
5571
|
checkElementHoverContrast,
|
|
@@ -5493,10 +5591,13 @@ export {
|
|
|
5493
5591
|
cssLengthToPx,
|
|
5494
5592
|
scanCssTextForPulsingDot,
|
|
5495
5593
|
scanHtmlForShapeAssembledIllustration,
|
|
5594
|
+
scanCssTextForOrganicClipPath,
|
|
5595
|
+
scanCssTextForBuriedRaster,
|
|
5496
5596
|
buildHtmlPatternCorpora,
|
|
5497
5597
|
checkHtmlPatterns,
|
|
5498
5598
|
readOwnBackgroundColor,
|
|
5499
5599
|
resolveBackground,
|
|
5600
|
+
resolveBackgroundInfo,
|
|
5500
5601
|
resolveGradientStops,
|
|
5501
5602
|
parseRadiusToPx,
|
|
5502
5603
|
resolveBorderRadiusPx,
|