Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,13 +462,16 @@ npx impeccable detect --no-config src/ # raw scan, ignoring project config
npx impeccable ignores list # show detector ignores
npx impeccable ignores add-file "src/legacy/**"
npx impeccable ignores add-value overused-font Inter --reason "Brand font"
npx impeccable ignores add-selector undersized-ui-text ".ks-tag" --reason "10px mono label, by design"
```

The detector catches 61 deterministic issues across AI slop (side-tab borders, purple gradients, bounce easing, dark glows) and general design quality (line length, cramped padding, small touch targets, skipped headings, and more).

Human-readable findings are diagnostics written to stderr, so redirect them with `2> findings.txt`. Use `--json` for machine-readable results on stdout. Exit `0` means the scan completed without primary findings, exit `2` means it completed with primary findings, and exit `1` means at least one requested target could not be scanned; operational failure takes precedence for a partial multi-target scan. URL scans inspect the rendered DOM, computed layout, and accessible linked stylesheets; browser security still prevents reading cross-origin CSS without CORS. A clean detector run is evidence, not proof of visual or accessibility quality: it does not replace inspecting the rendered experience across relevant viewports.

By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.
By default, `detect` respects the same `.impeccable/config.json` and `.impeccable/config.local.json` detector config as the design hook: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, `detector.ignoreSelectors`, and `detector.designSystem.enabled`. Hook lifecycle settings such as `hook.enabled` only affect automatic hook execution.

`detector.ignoreSelectors` is the component-level opt-out. One entry, written by `ignores add-selector <rule> "<selector>"`, waives that rule for every element the CSS selector matches and for that element's subtree, so a component with eleven instances takes one line of config instead of eleven `data-impeccable-ignore` attributes in the markup. What it suppressed is never silent: each scan prints a line per entry on stderr, `3 undersized-ui-text hits ignored by detector.ignoreSelectors on .ks-tag.`, in JSON mode too, so a reviewer sees the count next to the findings.

For a waiver that should travel with one file instead of the repo config, add an inline comment in the file: `<!-- impeccable-disable overused-font: exported brand doc -->`. The marker works in any comment syntax, scopes to the whole file (or one line with `impeccable-disable-line` / `impeccable-disable-next-line`), and is bypassed by `--no-inline-ignores` or `--no-config`.

Expand Down
10 changes: 10 additions & 0 deletions browser-bundle/30-scan-common.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function __visualContrastOptions(options = {}, config = {}) {
: false;
return {
...options,
...(Array.isArray(config.ignoreSelectors) ? { ignoreSelectors: config.ignoreSelectors } : {}),
maxCandidates: Number.isFinite(options.visualContrastMaxCandidates)
? options.visualContrastMaxCandidates
: Number.isFinite(options.maxCandidates)
Expand All @@ -47,6 +48,15 @@ function __visualContrastOptions(options = {}, config = {}) {
};
}

// Engines keep waiver stamps for callers that report suppression counts.
// UI consumers render only reportable findings, including after visual passes.
function __reportableGroups(groups) {
return groups.map(group => ({
...group,
findings: group.findings.filter(finding => !finding.ignoredBy),
})).filter(group => group.findings.length > 0);
}

// The analyses the lazy pass watches: unresolved only because the text was
// outside the viewport, and addressable.
function __lazyVisualContrastCandidates(analyses) {
Expand Down
2 changes: 2 additions & 0 deletions browser-bundle/35-visual.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,15 @@ function createVisualContrast(IO) {

function collectVisualContrastCandidates(options = {}) {
return __p(IO.coreSync('collect_visual_contrast_candidates', __j({
ignoreSelectors: options.ignoreSelectors,
maxCandidates: options.maxCandidates,
imageOnly: options.imageOnly,
})));
}

async function collectVisualContrastCandidatesAsync(options = {}) {
return core('collect_visual_contrast_candidates', __j({
ignoreSelectors: options.ignoreSelectors,
maxCandidates: options.maxCandidates,
imageOnly: options.imageOnly,
}));
Expand Down
14 changes: 10 additions & 4 deletions browser-bundle/50-scan.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ if (IS_BROWSER && !__impeccable) {
// applies them where the findings are assembled, because the overlay
// draws its markers from the collected findings.
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
// detector.ignoreSelectors: the project's component-level opt-outs,
// [{ rule, selector }]. The core waives a finding on any element the
// selector matches, and on its subtree, the way the
// data-impeccable-ignore attribute waives the element carrying it.
ignoreSelectors: Array.isArray(config.ignoreSelectors) ? config.ignoreSelectors : [],
Comment thread
cursor[bot] marked this conversation as resolved.
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
Expand Down Expand Up @@ -113,7 +118,7 @@ if (IS_BROWSER && !__impeccable) {
};

function browserFindingsFromMap(groupMap) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
return __reportableGroups([...groupMap.entries()].map(([el, findings]) => ({ el, findings })));
}

function collectBrowserFindings() {
Expand All @@ -130,7 +135,7 @@ if (IS_BROWSER && !__impeccable) {
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
pageLevelFindings: collected.pageLevel,
pageLevelFindings: collected.pageLevel.filter(f => !f.ignoredBy),
};
}

Expand Down Expand Up @@ -167,6 +172,7 @@ if (IS_BROWSER && !__impeccable) {
}

function addVisualContrastResult(groupMap, result, options = {}) {
if (result?.ignoredBy) return false;
const elId = __impeccable.visual_contrast_result_el(JSON.stringify(result));
const el = __el(elId);
if (!el) return false;
Expand Down Expand Up @@ -462,12 +468,12 @@ if (IS_BROWSER && !__impeccable) {
if (__impeccable.snapshot_has_needs()) out = { needs: JSON.parse(__impeccable.snapshot_take_needs()) };
rounds++;
}
const serialized = JSON.parse(__impeccable.serialize_findings(JSON.stringify(out.groups)));
const serialized = JSON.parse(__impeccable.serialize_findings(JSON.stringify(__reportableGroups(out.groups))));
const unknownStyleProps = JSON.parse(__impeccable.snapshot_unknown_style_props());
__impeccable.snapshot_clear();
return {
findings: serialized,
pageLevel: out.pageLevel,
pageLevel: out.pageLevel.filter(f => !f.ignoredBy),
stats: { ...cap.stats, rounds, unknownStyleProps, captureMs: t1 - t0, coreMs: performance.now() - t1 },
};
};
Expand Down
12 changes: 9 additions & 3 deletions browser-bundle/60-offscreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,25 @@
extensionMode: true,
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
disabledValues: Array.isArray(config.disabledValues) ? config.disabledValues : [],
// detector.ignoreSelectors: the project's component-level opt-outs,
// [{ rule, selector }]. The core waives a finding on any element the
// selector matches, and on its subtree, the way the
// data-impeccable-ignore attribute waives the element carrying it.
ignoreSelectors: Array.isArray(config.ignoreSelectors) ? config.ignoreSelectors : [],
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
});
}

function serialize(wasm, groups) {
return JSON.parse(wasm.serialize_findings(JSON.stringify(groups)));
return JSON.parse(wasm.serialize_findings(JSON.stringify(__reportableGroups(groups))));
}

// addVisualContrastResult over id-keyed groups: the two decisions are the
// core's; this only keeps the map.
function addVisualContrastResult(wasm, groups, result) {
if (result?.ignoredBy) return 0;
const elId = wasm.visual_contrast_result_el(JSON.stringify(result));
if (!elId) return 0;
let group = groups.find(g => g.el === elId);
Expand All @@ -144,12 +150,12 @@
const vc = createVisualContrast(IO);
const t0 = performance.now();
const collected = JSON.parse(await IO.core('collect_browser_findings', configJson(config)));
const groups = collected.groups;
const groups = __reportableGroups(collected.groups);
const stats = { elements: n, coreMs: performance.now() - t0, unknownStyleProps: JSON.parse(wasm.snapshot_unknown_style_props()) };
await ask(session, {
stage: 'findings',
groups,
pageLevel: collected.pageLevel,
pageLevel: collected.pageLevel.filter(f => !f.ignoredBy),
serialized: serialize(wasm, groups),
stats,
});
Expand Down
35 changes: 28 additions & 7 deletions crates/browser/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ struct RawResult {
snippet: String,
ignore_value: String,
severity: String,
/// The `detector.ignoreSelectors` selector that waived this finding, when
/// one did. Empty for everything else.
ignored_by: String,
}

fn cdp_err(e: CdpError) -> EngineError {
Expand Down Expand Up @@ -384,6 +387,12 @@ fn detect_url_impl(
item.extras
.insert("ignoreValue".into(), Value::String(r.ignore_value));
}
if !r.ignored_by.is_empty() {
item.extras.insert(
impeccable_core::findings::IGNORED_BY_KEY.into(),
Value::String(r.ignored_by),
);
}
if !r.severity.is_empty() && r.severity != item.severity {
item.severity = r.severity;
}
Expand Down Expand Up @@ -459,6 +468,7 @@ fn scan_page_inner(
let config = snapshot_engine::browser_config(
serialize_design_system_for_browser(options.design_system.as_deref()),
options.rule_pack,
options.ignore_selectors.clone(),
);

// Deterministic pass: capture the page and run the rule core natively over
Expand Down Expand Up @@ -486,6 +496,7 @@ fn scan_page_inner(
id: js_str(f.get("type")),
snippet: js_str(f.get("detail")),
ignore_value: js_str_or_empty(f.get("ignoreValue")),
ignored_by: js_str_or_empty(f.get("ignoredBy")),
severity: js_str_or_empty(f.get("severity")),
});
}
Expand Down Expand Up @@ -515,6 +526,7 @@ fn scan_page_inner(
id: f.id,
snippet: f.snippet,
ignore_value: String::new(),
ignored_by: String::new(),
severity: String::new(),
})
.collect(),
Expand All @@ -527,12 +539,13 @@ fn scan_page_inner(
id: "script-error".to_string(),
snippet: message,
ignore_value: String::new(),
ignored_by: String::new(),
severity: String::new(),
});
}

let analyses = step(profile, "visual-contrast", "browser-analyze", url, || {
snapshot_engine::analyze_visual_contrast(page, &base, 12.0, true)
snapshot_engine::analyze_visual_contrast(page, &base, 12.0, true, &config.ignore_selectors)
})
.map_err(cdp_err)?;
let visual = run_visual_contrast_fallback(page, &analyses, &serialized_groups, viewport, profile, url)?;
Expand Down Expand Up @@ -566,6 +579,7 @@ fn reveal_sweep(page: &mut Page<'_>) -> Result<(), CdpError> {
/// target)`: the JS post-processing of the analytic/canvas analyses
/// (`analyzeVisualContrast`, computed natively in [`snapshot_engine`]) plus the
/// screenshot pixel fallback for candidates the analyses left unresolved.
#[allow(clippy::too_many_arguments)]
fn run_visual_contrast_fallback(
page: &mut Page<'_>,
browser_analyses: &[Value],
Expand Down Expand Up @@ -598,12 +612,17 @@ fn run_visual_contrast_fallback(
.iter()
.any(|s| Some(s.as_str()) == r.get("selector").and_then(Value::as_str))
})
.filter_map(|r| r.get("finding"))
.map(|f| RawResult {
id: js_str(f.get("id")),
snippet: js_str(f.get("snippet")),
ignore_value: String::new(),
severity: String::new(),
.map(|r| {
let f = r.get("finding").expect("filtered on a truthy finding");
let id = js_str(f.get("id"));
let ignored_by = js_str_or_empty(r.get("ignoredBy"));
RawResult {
id,
snippet: js_str(f.get("snippet")),
ignore_value: String::new(),
ignored_by,
severity: String::new(),
}
})
.collect();

Expand Down Expand Up @@ -644,10 +663,12 @@ fn run_visual_contrast_fallback(
.map_err(cdp_err)?;
Ok::<_, EngineError>(
f.map(|f| {
let ignored_by = js_str_or_empty(candidate.get("ignoredBy"));
vec![RawResult {
id: f.id.to_string(),
snippet: f.snippet,
ignore_value: String::new(),
ignored_by,
severity: String::new(),
}]
})
Expand Down
5 changes: 4 additions & 1 deletion crates/browser/src/snapshot_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,13 @@ pub fn resolve_needs<T>(
pub fn browser_config(
design_system: Value,
rule_pack: Option<&'static dyn impeccable_core::rule_pack::RulePack>,
ignore_selectors: Vec<impeccable_core::selector_ignores::SelectorIgnore>,
) -> BrowserConfig {
BrowserConfig {
extension_mode: false,
disabled_rules: Vec::new(),
disabled_values: Vec::new(),
ignore_selectors,
skip_scan: false,
design_system: if design_system.is_null() {
None
Expand Down Expand Up @@ -530,10 +532,11 @@ pub fn analyze_visual_contrast(
base: &SnapshotDom,
max_candidates: f64,
scroll_offscreen: bool,
ignores: &[impeccable_core::selector_ignores::SelectorIgnore],
) -> CdpResult<Vec<Value>> {
let options = json!({ "maxCandidates": max_candidates });
let candidates = resolve_needs(base, page, |d| {
visual::collect_visual_contrast_candidates(d, &options)
visual::collect_visual_contrast_candidates_with_ignores(d, &options, ignores)
})?;
let mut results: Vec<Value> = Vec::with_capacity(candidates.len());
let restore = live_scroll(page)?;
Expand Down
9 changes: 8 additions & 1 deletion crates/context/src/staleness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,14 @@ const KNOWN_CONFIG_KEYS: [&str; 9] =
["hook", "detector", "updateCheck", "stalenessCheck", "projectRoots", "buildPath", "browser", "$schema", "version"];
const BUILD_PATH_VALUES: [&str; 2] = ["comp", "code"];
const DIRECTION_WORK_PATHS: [&str; 2] = [".impeccable/surfaces", ".impeccable/mocks/decision"];
const KNOWN_DETECTOR_KEYS: [&str; 5] = ["ignoreRules", "ignoreFiles", "ignoreValues", "designSystem", "extensions"];
const KNOWN_DETECTOR_KEYS: [&str; 6] = [
"ignoreRules",
"ignoreFiles",
"ignoreValues",
"ignoreSelectors",
"designSystem",
"extensions",
];

struct NativeEvidence {
platform: &'static str,
Expand Down
31 changes: 25 additions & 6 deletions crates/context/src/staleness_deep.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,31 @@ pub fn check_detector_ignores(project_root: &str, known_rule_ids: Option<&[Strin
continue;
}
let rel = to_relative(Some(&fp), project_root).unwrap();
if let (Some(known), Some(rules)) = (known_rule_ids, detector.get("ignoreRules").and_then(|v| v.as_array())) {
let unknown: Vec<String> = rules
.iter()
.map(|r| js_trim(&js_string_or_empty(r)).to_lowercase())
.filter(|r| !r.is_empty() && r != "*" && !known.contains(r))
.collect();
if let Some(known) = known_rule_ids {
let rules = detector
.get("ignoreRules")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// A component ignore names a rule too, and a typo there is the
// same dead entry: it waives nothing and nobody hears about it.
let selector_rules: Vec<Value> = detector
.get("ignoreSelectors")
.and_then(|v| v.as_array())
.map(|list| {
list.iter()
.filter_map(|e| e.get("rule").cloned())
.collect()
})
.unwrap_or_default();
let mut unknown: Vec<String> = Vec::new();
for r in rules.iter().chain(selector_rules.iter()) {
let id = js_trim(&js_string_or_empty(r)).to_lowercase();
if id.is_empty() || id == "*" || known.contains(&id) || unknown.contains(&id) {
continue;
}
unknown.push(id);
}
if !unknown.is_empty() {
out.push(finding(
"detector-ignore-rules-unknown",
Expand Down
Loading