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.
Files changed (120) hide show
  1. package/package.json +1 -1
  2. package/template/.agents/skills/agent-browser/SKILL.md +1 -0
  3. package/template/.agents/skills/apps/skybridge/SKILL.md +2 -0
  4. package/template/.agents/skills/data/prisma-cli/SKILL.md +25 -7
  5. package/template/.agents/skills/data/prisma-cli/references/agent-safety.md +27 -0
  6. package/template/.agents/skills/data/prisma-cli/references/complete.md +22 -0
  7. package/template/.agents/skills/data/prisma-cli/references/db-push.md +2 -0
  8. package/template/.agents/skills/data/prisma-cli/references/init.md +3 -0
  9. package/template/.agents/skills/data/prisma-cli/references/mcp.md +2 -1
  10. package/template/.agents/skills/data/prisma-cli/references/migrate-dev.md +1 -1
  11. package/template/.agents/skills/data/prisma-cli/references/migrate-reset.md +2 -0
  12. package/template/.agents/skills/data/prisma-client-api/SKILL.md +1 -1
  13. package/template/.agents/skills/data/prisma-client-api/references/constructor.md +13 -0
  14. package/template/.agents/skills/data/prisma-client-api/references/raw-queries.md +4 -0
  15. package/template/.agents/skills/design/impeccable/SKILL.md +5 -5
  16. package/template/.agents/skills/design/impeccable/reference/android.md +6 -0
  17. package/template/.agents/skills/design/impeccable/reference/animate.md +3 -0
  18. package/template/.agents/skills/design/impeccable/reference/bolder.md +3 -1
  19. package/template/.agents/skills/design/impeccable/reference/craft-floor.md +2 -0
  20. package/template/.agents/skills/design/impeccable/reference/critique.md +23 -5
  21. package/template/.agents/skills/design/impeccable/reference/degraded/asset-producer.md +15 -68
  22. package/template/.agents/skills/design/impeccable/reference/degraded/finish-reviewer.md +13 -12
  23. package/template/.agents/skills/design/impeccable/reference/distill.md +1 -1
  24. package/template/.agents/skills/design/impeccable/reference/doctor.md +1 -0
  25. package/template/.agents/skills/design/impeccable/reference/document.md +1 -1
  26. package/template/.agents/skills/design/impeccable/reference/extract.md +1 -1
  27. package/template/.agents/skills/design/impeccable/reference/hooks.md +17 -11
  28. package/template/.agents/skills/design/impeccable/reference/init.md +9 -3
  29. package/template/.agents/skills/design/impeccable/reference/ios.md +6 -0
  30. package/template/.agents/skills/design/impeccable/reference/new-work.md +69 -29
  31. package/template/.agents/skills/design/impeccable/reference/overdrive.md +1 -1
  32. package/template/.agents/skills/design/impeccable/reference/polish.md +13 -5
  33. package/template/.agents/skills/design/impeccable/reference/quieter.md +1 -1
  34. package/template/.agents/skills/design/impeccable/reference/routing.md +1 -1
  35. package/template/.agents/skills/design/impeccable/reference/visualize.md +21 -22
  36. package/template/.agents/skills/design/impeccable/scripts/build-phase.mjs +1022 -0
  37. package/template/.agents/skills/design/impeccable/scripts/comp-diff.mjs +391 -0
  38. package/template/.agents/skills/design/impeccable/scripts/comp-spec.mjs +513 -0
  39. package/template/.agents/skills/design/impeccable/scripts/concept-seed.mjs +297 -41
  40. package/template/.agents/skills/design/impeccable/scripts/context-signals.mjs +10 -19
  41. package/template/.agents/skills/design/impeccable/scripts/context.mjs +124 -9
  42. package/template/.agents/skills/design/impeccable/scripts/critique-storage.mjs +279 -19
  43. package/template/.agents/skills/design/impeccable/scripts/data/font-index-failures.json +121 -0
  44. package/template/.agents/skills/design/impeccable/scripts/data/font-index.json +1 -0
  45. package/template/.agents/skills/design/impeccable/scripts/detect.mjs +9 -0
  46. package/template/.agents/skills/design/impeccable/scripts/detector/browser/injected/index.mjs +192 -11
  47. package/template/.agents/skills/design/impeccable/scripts/detector/cli/main.mjs +10 -16
  48. package/template/.agents/skills/design/impeccable/scripts/detector/design-system.mjs +339 -11
  49. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns-browser.js +1482 -722
  50. package/template/.agents/skills/design/impeccable/scripts/detector/detect-antipatterns.mjs +1 -0
  51. package/template/.agents/skills/design/impeccable/scripts/detector/engines/browser/detect-url.mjs +64 -2
  52. package/template/.agents/skills/design/impeccable/scripts/detector/engines/regex/detect-text.mjs +580 -29
  53. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +62 -7
  54. package/template/.agents/skills/design/impeccable/scripts/detector/engines/static-html/detect-html.mjs +31 -7
  55. package/template/.agents/skills/design/impeccable/scripts/detector/node/file-system.mjs +23 -22
  56. package/template/.agents/skills/design/impeccable/scripts/detector/registry/antipatterns.mjs +18 -0
  57. package/template/.agents/skills/design/impeccable/scripts/detector/rules/checks.mjs +471 -370
  58. package/template/.agents/skills/design/impeccable/scripts/detector/shared/color.mjs +474 -2
  59. package/template/.agents/skills/design/impeccable/scripts/detector/shared/constants.mjs +17 -2
  60. package/template/.agents/skills/design/impeccable/scripts/doctor.mjs +14 -21
  61. package/template/.agents/skills/design/impeccable/scripts/embed-prompt.mjs +81 -48
  62. package/template/.agents/skills/design/impeccable/scripts/font-match.mjs +457 -0
  63. package/template/.agents/skills/design/impeccable/scripts/generate-image.mjs +219 -12
  64. package/template/.agents/skills/design/impeccable/scripts/hook-admin.mjs +93 -15
  65. package/template/.agents/skills/design/impeccable/scripts/hook-before-edit.mjs +41 -19
  66. package/template/.agents/skills/design/impeccable/scripts/hook-lib.mjs +507 -117
  67. package/template/.agents/skills/design/impeccable/scripts/hook.mjs +10 -9
  68. package/template/.agents/skills/design/impeccable/scripts/lib/concept-catalog.mjs +40 -1
  69. package/template/.agents/skills/design/impeccable/scripts/lib/design-parser.mjs +120 -82
  70. package/template/.agents/skills/design/impeccable/scripts/lib/font-fingerprint.mjs +564 -0
  71. package/template/.agents/skills/design/impeccable/scripts/lib/font-index.mjs +130 -0
  72. package/template/.agents/skills/design/impeccable/scripts/lib/hero-checks.mjs +246 -0
  73. package/template/.agents/skills/design/impeccable/scripts/lib/image-metrics.mjs +306 -0
  74. package/template/.agents/skills/design/impeccable/scripts/lib/impeccable-config.mjs +41 -59
  75. package/template/.agents/skills/design/impeccable/scripts/lib/is-generated.mjs +5 -2
  76. package/template/.agents/skills/design/impeccable/scripts/lib/live-path-globs.mjs +37 -0
  77. package/template/.agents/skills/design/impeccable/scripts/lib/open-system-browser.mjs +26 -0
  78. package/template/.agents/skills/design/impeccable/scripts/lib/png.mjs +281 -0
  79. package/template/.agents/skills/design/impeccable/scripts/lib/raster.mjs +194 -0
  80. package/template/.agents/skills/design/impeccable/scripts/lib/roll-selection.mjs +26 -19
  81. package/template/.agents/skills/design/impeccable/scripts/lib/staleness-deep.mjs +31 -3
  82. package/template/.agents/skills/design/impeccable/scripts/lib/staleness.mjs +93 -17
  83. package/template/.agents/skills/design/impeccable/scripts/lib/surface-briefs.mjs +9 -11
  84. package/template/.agents/skills/design/impeccable/scripts/live/browser-script-parts.mjs +31 -2
  85. package/template/.agents/skills/design/impeccable/scripts/live/project-ignores.mjs +139 -0
  86. package/template/.agents/skills/design/impeccable/scripts/live/svelte-ast.mjs +10 -2
  87. package/template/.agents/skills/design/impeccable/scripts/live/svelte-component.mjs +26 -2
  88. package/template/.agents/skills/design/impeccable/scripts/live/sveltekit-adapter.mjs +15 -27
  89. package/template/.agents/skills/design/impeccable/scripts/live/tanstack-adapter.mjs +4 -25
  90. package/template/.agents/skills/design/impeccable/scripts/live/ui-surfaces.mjs +75 -0
  91. package/template/.agents/skills/design/impeccable/scripts/live-accept.mjs +21 -37
  92. package/template/.agents/skills/design/impeccable/scripts/live-browser-ignores.js +242 -0
  93. package/template/.agents/skills/design/impeccable/scripts/live-browser.js +62 -23
  94. package/template/.agents/skills/design/impeccable/scripts/live-commit-manual-edits.mjs +68 -112
  95. package/template/.agents/skills/design/impeccable/scripts/live-copy-edit-agent.mjs +132 -15
  96. package/template/.agents/skills/design/impeccable/scripts/live-inject.mjs +2 -42
  97. package/template/.agents/skills/design/impeccable/scripts/live-poll.mjs +5 -4
  98. package/template/.agents/skills/design/impeccable/scripts/live-server.mjs +56 -19
  99. package/template/.agents/skills/design/impeccable/scripts/live.mjs +12 -37
  100. package/template/.agents/skills/design/impeccable/scripts/pin.mjs +8 -5
  101. package/template/.agents/skills/design/impeccable/scripts/serve-question.mjs +1006 -155
  102. package/template/.agents/skills/design/shadcn/rules/chat.md +26 -0
  103. package/template/.agents/skills/hono/SKILL.md +17 -3
  104. package/template/.agents/skills/integrations/langfuse/SKILL.md +8 -4
  105. package/template/.agents/skills/integrations/langfuse/references/cli.md +1 -1
  106. package/template/.agents/skills/integrations/langfuse/references/create-dataset.md +35 -0
  107. package/template/.agents/skills/integrations/langfuse/references/error-analysis.md +9 -10
  108. package/template/.agents/skills/integrations/langfuse/references/judge-calibration.md +8 -6
  109. package/template/.agents/skills/integrations/langfuse/references/sdk-upgrade.md +34 -0
  110. package/template/.agents/skills/integrations/langfuse/references/setting-up-evals.md +65 -0
  111. package/template/.agents/skills/integrations/langfuse/references/skill-feedback.md +20 -40
  112. package/template/.agents/skills/integrations/langfuse/references/user-feedback.md +4 -30
  113. package/template/.agents/skills/integrations/langfuse/references/v4-project-migration.md +3 -1
  114. package/template/.agents/skills/supabase/CHANGELOG.md +7 -0
  115. package/template/.agents/skills/supabase/SKILL.md +5 -1
  116. package/template/.agents/skills/writing/humanizer/LICENSE +21 -0
  117. package/template/.agents/skills/writing/humanizer/README.md +209 -0
  118. package/template/.agents/skills/writing/humanizer/SKILL.md +173 -338
  119. package/template/.agents/upstreams.json +21 -22
  120. package/template/.agents/skills/design/impeccable/scripts/live/ui-core.mjs +0 -180
@@ -18,4 +18,13 @@ if (!detectorPath) {
18
18
 
19
19
  const { detectCli } = await import(pathToFileURL(detectorPath));
20
20
 
21
+ // A comp-led build with its comp round or hero gate still open is not a page
22
+ // the detector can pass: say so after the scan (stderr, so --json stays
23
+ // parseable), on the same condition context.mjs reports at boot.
24
+ try {
25
+ const { compRoundOpen } = await import(pathToFileURL(path.join(__dirname, 'build-phase.mjs')));
26
+ const open = compRoundOpen(process.cwd());
27
+ if (open) process.stderr.write(`COMP_ROUND_OPEN: ${open.reason}. A detector pass is not a finish: run node ${__dirname}/build-phase.mjs status and follow its NEXT line before treating this page as built.\n`);
28
+ } catch { /* build-phase absent */ }
29
+
21
30
  await detectCli();
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
626
626
  if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
627
627
  if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
628
628
 
629
- const solidBg = parseRgb(currentStyle.backgroundColor);
629
+ const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
630
630
  if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
631
631
  current = current.parentElement;
632
632
  }
@@ -683,8 +683,12 @@ if (IS_BROWSER) {
683
683
 
684
684
  const reasons = collectVisualContrastReasons(el, style);
685
685
  if (reasons.length === 0) continue;
686
+ // Image-only mode filters here, inside the cap: gradient/opacity/filter
687
+ // candidates earlier in DOM order must not consume the budget and
688
+ // starve the url()-backed texts this mode exists to sample.
689
+ if (options.imageOnly && !reasons.includes('image background')) continue;
686
690
 
687
- const textColor = parseRgb(style.color);
691
+ const textColor = parseRgb(style.color) || parseAnyColor(style.color);
688
692
  const fontSize = parseFloat(style.fontSize) || 16;
689
693
  const fontWeight = parseInt(style.fontWeight) || 400;
690
694
  const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -981,7 +985,7 @@ if (IS_BROWSER) {
981
985
  return sample;
982
986
  }
983
987
  }
984
- const bg = parseRgb(style.backgroundColor);
988
+ const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
985
989
  if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
986
990
  return { status: 'unresolved', reason: 'no readable background' };
987
991
  }
@@ -1111,7 +1115,7 @@ if (IS_BROWSER) {
1111
1115
  }
1112
1116
 
1113
1117
  const style = getComputedStyle(el);
1114
- const textColor = parseRgb(style.color) || candidate.textColor;
1118
+ const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
1115
1119
  if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
1116
1120
 
1117
1121
  const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
1175
1179
  }
1176
1180
 
1177
1181
  async function analyzeVisualContrast(options = {}) {
1182
+ // imageOnly is enforced inside the collector, before the candidate cap.
1178
1183
  const candidates = collectVisualContrastCandidates(options);
1179
1184
  const results = [];
1180
1185
  const shouldScrollOffscreen = options.scrollOffscreen === true;
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
1260
1265
 
1261
1266
  function addBrowserFindings(groupMap, el, findings) {
1262
1267
  if (!findings || findings.length === 0) return;
1268
+ // Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
1269
+ // matching findings for its whole subtree. Applied at this choke point so
1270
+ // every per-element attribution (checks, layout, occlusion, rhythm)
1271
+ // honors it; page-level findings attributed to <body> pass through
1272
+ // untouched, since body has no ignoring ancestor.
1273
+ const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
1274
+ if (kept.length === 0) return;
1263
1275
  const existing = groupMap.get(el);
1264
- if (existing) existing.push(...findings);
1265
- else groupMap.set(el, [...findings]);
1276
+ if (existing) existing.push(...kept);
1277
+ else groupMap.set(el, [...kept]);
1266
1278
  }
1267
1279
 
1268
1280
  function browserFindingsFromMap(groupMap) {
@@ -1460,7 +1472,19 @@ if (IS_BROWSER) {
1460
1472
  return findings;
1461
1473
  }
1462
1474
 
1475
+ // A page matched by detector.ignoreFiles is waived wholesale: every scan
1476
+ // stage answers empty so the badge and toast read zero. Mirrors
1477
+ // shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
1478
+ // overlay resolves the globs per page (live-browser-ignores.js) and
1479
+ // forwards the verdict as config.skipScan.
1480
+ function skipScanActive() {
1481
+ return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
1482
+ }
1483
+
1463
1484
  function collectBrowserFindings() {
1485
+ if (skipScanActive()) {
1486
+ return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
1487
+ }
1464
1488
  const groupMap = new Map();
1465
1489
  const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
1466
1490
  const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
@@ -1620,9 +1644,27 @@ if (IS_BROWSER) {
1620
1644
  for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
1621
1645
  node.remove();
1622
1646
  }
1623
- const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
1624
- if (htmlPatternFindings.length > 0) {
1625
- const mapped = htmlPatternFindings.map(f => {
1647
+ // Regex findings that name a live selector resolve against the real DOM:
1648
+ // pseudo-element/class segments are stripped (the host element is the
1649
+ // anchor), a selector that matches nothing on this page drops the finding
1650
+ // (the CSS ships here, but the pattern never renders — the live DOM is
1651
+ // ground truth in the browser), and a match under a data-impeccable-ignore
1652
+ // ancestor is waived. Selector-less findings stay page-level.
1653
+ const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
1654
+ if (!f.selector) return true;
1655
+ const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
1656
+ if (!query || /^[,\s]*$/.test(query)) return true;
1657
+ let matches;
1658
+ try {
1659
+ matches = document.querySelectorAll(query);
1660
+ } catch {
1661
+ return true;
1662
+ }
1663
+ if (matches.length === 0) return false;
1664
+ return [...matches].some(el => !scopedIgnoreActive(el, f.id));
1665
+ });
1666
+ if (scopedHtmlFindings.length > 0) {
1667
+ const mapped = scopedHtmlFindings.map(f => {
1626
1668
  const item = { type: f.id, detail: f.snippet };
1627
1669
  if (f.severity) {
1628
1670
  item.severity = f.severity;
@@ -1645,6 +1687,119 @@ if (IS_BROWSER) {
1645
1687
  addBrowserFindings(groupMap, document.body, mapped);
1646
1688
  }
1647
1689
 
1690
+ // Value-level suppression (issue #639). `disabledRules` above handles
1691
+ // whole rules; this applies the config's remaining ignoreValues entries,
1692
+ // which the CLI filters through isIgnoredFindingValue in
1693
+ // cli/lib/impeccable-config.mjs, so a project waiver like
1694
+ // overused-font = "geist mono" reaches the overlay and extension too.
1695
+ const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
1696
+ .replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
1697
+ const _disabledValues = EXTENSION_MODE
1698
+ ? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
1699
+ .filter(e => e && typeof e === 'object' && e.rule && e.value)
1700
+ .map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
1701
+ : [];
1702
+ if (_disabledValues.length > 0) {
1703
+ // The six rules whose findings carry a matchable value; keep in step
1704
+ // with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
1705
+ // Everything else is suppressed by rule or by file scope, both already
1706
+ // resolved into disabledRules before the scan message was sent.
1707
+ const _directValueRules = new Set([
1708
+ 'overused-font',
1709
+ 'bounce-easing',
1710
+ 'design-system-font',
1711
+ 'design-system-color',
1712
+ 'design-system-radius',
1713
+ 'design-system-font-size',
1714
+ ]);
1715
+ // The design-system checks set `ignoreValue` on their findings; the
1716
+ // detail fallbacks catch overused-font, whose value lives in its
1717
+ // sentence. One CLI matcher is not mirrored here: the motion extractor
1718
+ // (a value-scoped bounce-easing waiver only matches when the finding
1719
+ // carries ignoreValue directly). The CLI's [?&]family= URL fallback is
1720
+ // also omitted on purpose: browser findings for these rules always
1721
+ // carry ignoreValue or a "Primary font:" / "Google Fonts:" /
1722
+ // font-family sentence, so it is unreachable here.
1723
+ const _findingValue = (f) => {
1724
+ if (!f || !_directValueRules.has(f.type || f.id)) return '';
1725
+ const direct = f.ignoreValue || f.value;
1726
+ if (direct) return _normValue(direct);
1727
+ // The CLI routes bounce-easing through extractMotionIgnoreValue and
1728
+ // never the font regexes; without a direct ignoreValue there is no
1729
+ // value to match, so do not invent one from unrelated CSS text.
1730
+ if ((f.type || f.id) === 'bounce-easing') return '';
1731
+ for (const text of [f.detail, f.snippet]) {
1732
+ if (typeof text !== 'string' || !text) continue;
1733
+ const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
1734
+ if (primary) return _normValue(primary[1]);
1735
+ const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
1736
+ if (google) return _normValue(google[1]);
1737
+ const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
1738
+ if (family) return _normValue(family[1]);
1739
+ }
1740
+ return '';
1741
+ };
1742
+ // design-system-color compares by color value, not by spelling: the
1743
+ // browser reports computed rgb(...) strings while waivers are usually
1744
+ // written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in
1745
+ // cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms;
1746
+ // hsl stays CLI-only.
1747
+ const _colorKey = (value) => {
1748
+ const text = String(value || '').trim().toLowerCase();
1749
+ const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
1750
+ if (hex) {
1751
+ const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1];
1752
+ const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16));
1753
+ return `${r},${g},${b},${a}`;
1754
+ }
1755
+ const rgb = text.match(/^rgba?\((.*)\)$/);
1756
+ if (!rgb) return '';
1757
+ const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / ');
1758
+ let parts;
1759
+ if (body.includes(',')) {
1760
+ parts = body.split(',').map(p => p.trim()).filter(Boolean);
1761
+ const last = parts[parts.length - 1];
1762
+ if (last && last.includes('/')) {
1763
+ parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)];
1764
+ }
1765
+ } else {
1766
+ parts = body.split(/\s+/).filter(p => p && p !== '/');
1767
+ }
1768
+ if (parts.length < 3 || parts.length > 4) return '';
1769
+ const channel = (raw, isAlpha) => {
1770
+ const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/);
1771
+ if (!m) return null;
1772
+ let v = parseFloat(m[1]);
1773
+ if (m[2]) v = isAlpha ? v / 100 : v * 2.55;
1774
+ const max = isAlpha ? 1 : 255;
1775
+ if (!Number.isFinite(v) || v < 0 || v > max) return null;
1776
+ return isAlpha ? v : Math.round(v);
1777
+ };
1778
+ const r = channel(parts[0], false);
1779
+ const g = channel(parts[1], false);
1780
+ const b = channel(parts[2], false);
1781
+ const a = parts[3] === undefined ? 1 : channel(parts[3], true);
1782
+ if ([r, g, b, a].some(v => v === null)) return '';
1783
+ return `${r},${g},${b},${Math.round(a * 255)}`;
1784
+ };
1785
+ const _valueIgnored = (f) => {
1786
+ const value = _findingValue(f);
1787
+ if (!value) return false;
1788
+ const rule = f.type || f.id;
1789
+ return _disabledValues.some(e => e.rule === rule && (e.value === value
1790
+ || (rule === 'design-system-color'
1791
+ && _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value))));
1792
+ };
1793
+ for (const [el, list] of [...groupMap.entries()]) {
1794
+ const kept = list.filter(f => !_valueIgnored(f));
1795
+ if (kept.length > 0) groupMap.set(el, kept);
1796
+ else groupMap.delete(el);
1797
+ }
1798
+ for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
1799
+ if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
1800
+ }
1801
+ }
1802
+
1648
1803
  return {
1649
1804
  groupMap,
1650
1805
  allFindings: browserFindingsFromMap(groupMap),
@@ -1652,8 +1807,27 @@ if (IS_BROWSER) {
1652
1807
  };
1653
1808
  }
1654
1809
 
1810
+ // Visual contrast has three modes. Explicit true runs the full sampled
1811
+ // pass; explicit false disables it entirely (the deterministic-only mode
1812
+ // the test suites use). Unset — the default overlay run — samples ONLY
1813
+ // image-backed text: the one class the analytic walk deliberately skips,
1814
+ // because a url() layer's pixels are unknowable without looking. In-page
1815
+ // sampling draws the source image alone to a canvas (glyph ink never
1816
+ // pollutes it), and a cross-origin image without CORS reports unresolved
1817
+ // instead of guessing.
1818
+ function visualContrastMode(options = {}) {
1819
+ const explicit = typeof options.visualContrast === 'boolean'
1820
+ ? options.visualContrast
1821
+ : typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
1822
+ ? window.__IMPECCABLE_CONFIG__.visualContrast
1823
+ : null;
1824
+ if (explicit === true) return 'full';
1825
+ if (explicit === false) return false;
1826
+ return 'image-only';
1827
+ }
1828
+
1655
1829
  function shouldRunVisualContrast(options = {}) {
1656
- return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
1830
+ return visualContrastMode(options) !== false;
1657
1831
  }
1658
1832
 
1659
1833
  function visualContrastOptions(options = {}) {
@@ -1830,6 +2004,7 @@ if (IS_BROWSER) {
1830
2004
  return [];
1831
2005
  }
1832
2006
  const resolvedOptions = visualContrastOptions(options);
2007
+ if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
1833
2008
  const analyses = await analyzeVisualContrast(resolvedOptions);
1834
2009
  if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
1835
2010
  lastVisualContrastAnalyses = analyses;
@@ -1842,6 +2017,12 @@ if (IS_BROWSER) {
1842
2017
 
1843
2018
  async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
1844
2019
  const collected = collectBrowserFindings();
2020
+ // The visual pass walks the DOM on its own; on a skipScan page it would
2021
+ // repopulate the emptied scan, so it is skipped with everything else.
2022
+ if (skipScanActive()) {
2023
+ lastVisualContrastAnalyses = [];
2024
+ return { ...collected, allFindings: [], visualContrastAnalyses: [] };
2025
+ }
1845
2026
  await addVisualContrastFindings(collected.groupMap, options, runtime);
1846
2027
  return {
1847
2028
  ...collected,
@@ -1895,7 +2076,7 @@ if (IS_BROWSER) {
1895
2076
  const generation = scanGeneration;
1896
2077
  const collected = collectBrowserFindings();
1897
2078
  const allFindings = renderBrowserFindings(collected, options);
1898
- if (shouldRunVisualContrast(options)) {
2079
+ if (!skipScanActive() && shouldRunVisualContrast(options)) {
1899
2080
  addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
1900
2081
  .then(() => {
1901
2082
  if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
@@ -105,6 +105,13 @@ function formatFindings(findings, jsonMode) {
105
105
  // `optionsFor` maps a local path to scan options carrying that path's own
106
106
  // project design system (or base options when null). Falls back to a plain
107
107
  // object so direct/legacy callers still work.
108
+ async function detectLocalFile(filePath, options) {
109
+ if (HTML_EXTENSIONS.has(path.extname(filePath).toLowerCase())) {
110
+ return detectHtml(filePath, options);
111
+ }
112
+ return detectText(fs.readFileSync(filePath, 'utf-8'), filePath, options);
113
+ }
114
+
108
115
  async function handleStdin(optionsFor = () => ({})) {
109
116
  const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor;
110
117
  const chunks = [];
@@ -114,9 +121,7 @@ async function handleStdin(optionsFor = () => ({})) {
114
121
  const parsed = JSON.parse(input);
115
122
  const fp = parsed?.tool_input?.file_path;
116
123
  if (fp && fs.existsSync(fp)) {
117
- const options = resolve(fp);
118
- return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
119
- ? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
124
+ return detectLocalFile(fp, resolve(fp));
120
125
  }
121
126
  } catch { /* not JSON */ }
122
127
  return detectText(input, '<stdin>', resolve(null));
@@ -374,16 +379,10 @@ async function detectCli() {
374
379
  }
375
380
 
376
381
  for (const file of files) {
377
- const ext = path.extname(file).toLowerCase();
378
382
  // Each file resolves its own project design system (cached by root),
379
383
  // so a scan spanning sibling projects applies the right rules per file.
380
384
  const fileOptions = scanOptionsFor(file);
381
- let fileFindings;
382
- if (HTML_EXTENSIONS.has(ext)) {
383
- fileFindings = await detectHtml(file, fileOptions);
384
- } else {
385
- fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions);
386
- }
385
+ const fileFindings = await detectLocalFile(file, fileOptions);
387
386
  // Annotate findings with import context
388
387
  const importers = importedByMap.get(file);
389
388
  if (importers && importers.size > 0) {
@@ -396,13 +395,8 @@ async function detectCli() {
396
395
  }
397
396
  } else if (stat.isFile()) {
398
397
  if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
399
- const ext = path.extname(resolved).toLowerCase();
400
398
  const fileOptions = scanOptionsFor(resolved);
401
- if (HTML_EXTENSIONS.has(ext)) {
402
- allFindings.push(...await detectHtml(resolved, fileOptions));
403
- } else {
404
- allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions));
405
- }
399
+ allFindings.push(...await detectLocalFile(resolved, fileOptions));
406
400
  }
407
401
  }
408
402
  } finally {