Files
pbakaus_impeccable/browser-bundle/60-offscreen.js
T
Paul BakausandClaude Fable 5.1 b50098a477 Address review findings on the component ignore
Four gaps the review bots found in the first commit:

- **The hook dropped the waivers when the design system was off.** Both
  `design_system_options` paths returned `HookScanOptions::default()` when
  `designSystem.enabled` is false, which left `ignore_selectors` empty, so an
  opted-out component kept re-firing on every edit in exactly the projects
  that have no DESIGN.md. Component ignores are not design-system state; they
  travel either way now.
- **The in-page and extension scans never saw the key.** `BrowserConfig`
  reads `ignoreSelectors`, but the two JS adapters that build that config
  (`browser-bundle/50-scan.js` `collectConfigJson`, `60-offscreen.js`
  `configJson`) listed their keys explicitly and dropped it, so the documented
  `window.__IMPECCABLE_CONFIG__.ignoreSelectors` path did nothing. Both
  forward it now, and the bundle is regenerated.
- **Visual-contrast findings skipped the stamp.** The URL engine's visual pass
  produces its findings outside `collect_browser_findings`, so a
  `low-contrast` hit on an opted-out component was reported rather than
  waived. Each candidate carries its own selector, so the pass now resolves
  that element against the same post-reveal snapshot and stamps what the
  config waives. Page-level results (`content-hidden-at-rest`, `script-error`)
  stay unstamped: they name no element.
- **One bad entry could discard the whole page config.** `ignoreSelectors`
  used strict deserialization, so a hand-edited `{}` or `null` in the array
  failed the parse of `BrowserConfig`, which the wasm entry points answer with
  `unwrap_or_default()` — losing the design system and every other setting.
  It now filters bad entries the way `disabledValues` does.

Also: a `files` glob no longer applies to a URL scan. Globs name repo paths,
and `index.html` reaching `https://example.com/index.html` would scope an
ignore to a page the entry never named. URL scans take the unscoped entries
only, which is what the docs already promised.

Assisted-by: Claude Code
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LQBUunp8QttxZqihybNmtL
2026-09-11 12:42:24 -07:00

254 lines
11 KiB
JavaScript

// --- browser-bundle/60-offscreen.js ---
// The extension's offscreen document: hosts the WASM core (its own CSP
// allows 'wasm-unsafe-eval'; a page's never has to) and runs the same scan
// the in-page bundle runs, over a page snapshot the content script captured
// (15-snapshot.js -> crates/core/src/browser/snapshot.rs). No rule logic
// here: marshalling, the session protocol, and the visual-contrast IO
// adapter whose every read is a question back to the content script.
//
// Protocol (content script <-> this document, chrome.runtime messages with
// `target: 'impeccable-offscreen'`; each request is answered exactly once):
//
// { action: 'scan-start', session, snapshot, config }
// -> { ask: { hitTests: [[x, y]] } } answer: { hits: [...] }
// -> { ask: { io: { kind: 'loadImage', src } } }
// answer: { ref, w, h } | null
// -> { ask: { io: { kind: 'readPixel', ref, plan, px, py } } }
// answer: { data } | { error } | { noContext }
// -> { stage: 'findings', groups, pageLevel, serialized } answer: {}
// -> { stage: 'visual', groups, serialized, lazy } answer: {}
// -> { done: true }
// -> { error: message }
// -> { superseded: true } (a newer scan-start took the session over)
// { action: 'scan-continue', session, answer } (the answer to the last ask/stage)
// { action: 'analyze-candidate', session, snapshot, candidate, groups }
// -> asks as above, then { result, el, finding, serialized } (el 0 = no addition)
// { action: 'antipatterns' } -> the registry slice for the overlay labels
// { action: 'ping' } -> { ok: true, ready }
//
// `groups` are `[{ el, findings }]` with snapshot ids; the content script
// maps ids to Elements through the capture it made.
(function () {
const TARGET = 'impeccable-offscreen';
const sessions = new Map();
let corePromise = null;
function coreReady() {
if (!corePromise) corePromise = __impeccableLoadCore();
return corePromise;
}
// Coroutine over messages: `ask` answers the pending request with a
// question and parks until the next 'scan-continue' brings the answer.
function ask(session, payload) {
return new Promise((resolve, reject) => {
const respond = session.respond;
session.respond = null;
session.resume = { resolve, reject };
if (!respond) {
reject(new Error('session has no pending request'));
return;
}
respond(payload);
});
}
function finish(session, payload) {
const respond = session.respond;
session.respond = null;
if (sessions.get(session.id) === session) sessions.delete(session.id);
if (respond) respond(payload);
}
// The core holds one loaded snapshot at a time, so scans (which park at
// asks) run one after another; a second tab's scan waits its turn.
let chain = Promise.resolve();
function serialized(fn) {
const run = chain.then(fn, fn);
chain = run.catch(() => {});
return run;
}
// The visual-contrast IO over the snapshot: the core over the loaded
// snapshot (hit-test needs answered by the content script between calls),
// node = snapshot id, images and pixels read by the content script.
function createOffscreenVisualIO(wasm, session) {
async function core(fn, ...args) {
for (;;) {
const out = wasm[fn](...args);
if (!wasm.snapshot_has_needs()) return out;
const needs = JSON.parse(wasm.snapshot_take_needs());
const facts = await ask(session, { ask: { hitTests: needs.hitTests || [] } });
wasm.snapshot_add_facts(JSON.stringify(facts || { hits: [] }));
}
}
const media = (id) => JSON.parse(wasm.snapshot_media(id)) || {};
return {
core,
coreSync() { throw new Error('the offscreen adapter is asynchronous'); },
node: (handle) => handle,
handle: (id) => id,
parentOrBody: (id) => wasm.snapshot_parent_or_body(id),
intrinsicImg(id) { const m = media(id); return [m.nw || m.vw || m.w || 0, m.nh || m.vh || m.h || 0]; },
intrinsicRaster(id) { const m = media(id); return [m.w || m.vw || 0, m.h || m.vh || 0]; },
imgSrc(id) { const m = media(id); return m.cur || m.src || ''; },
loadImage: (src) => ask(session, { ask: { io: { kind: 'loadImage', src } } }),
readPixel: (ref, plan, px, py) => ask(session, { ask: { io: { kind: 'readPixel', ref, plan, px, py } } }),
// Scrolling the page from a snapshot is not meaningful; the extension
// never sets scrollOffscreen, and the lazy pass re-captures instead.
querySelector: () => null,
scroll() { const v = JSON.parse(wasm.snapshot_viewport()) || {}; return { x: v.scrollX || 0, y: v.scrollY || 0 }; },
scrollTo() {},
scrollIntoView: () => false,
waitForPaint: () => Promise.resolve(),
};
}
function configJson(config) {
config = config || {};
return JSON.stringify({
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)));
}
// addVisualContrastResult over id-keyed groups: the two decisions are the
// core's; this only keeps the map.
function addVisualContrastResult(wasm, groups, result) {
const elId = wasm.visual_contrast_result_el(JSON.stringify(result));
if (!elId) return 0;
let group = groups.find(g => g.el === elId);
const existing = group ? group.findings : [];
const finding = JSON.parse(wasm.visual_contrast_result_finding(elId, JSON.stringify(existing), JSON.stringify(result)));
if (!finding) return 0;
if (group) group.findings.push(finding);
else groups.push({ el: elId, findings: [finding] });
return elId;
}
async function runScan(session, msg) {
const wasm = await coreReady();
const n = wasm.snapshot_load(msg.snapshot);
if (n === 0xFFFFFFFF) throw new Error('snapshot did not parse');
const config = msg.config || {};
const IO = createOffscreenVisualIO(wasm, session);
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 stats = { elements: n, coreMs: performance.now() - t0, unknownStyleProps: JSON.parse(wasm.snapshot_unknown_style_props()) };
await ask(session, {
stage: 'findings',
groups,
pageLevel: collected.pageLevel,
serialized: serialize(wasm, groups),
stats,
});
const options = config;
// An ignoreFiles-waived page (config.skipScan) answers every stage empty:
// the core already emptied the collect pass, and the visual pass would
// repopulate it, so it is skipped with everything else (mirrors
// skipScanActive() in 50-scan.js; offscreen is always extension mode).
if (config.skipScan !== true && __visualContrastMode(options, config) !== false) {
const resolved = __visualContrastOptions(options, config);
if (__visualContrastMode(options, config) === 'image-only') resolved.imageOnly = true;
const analyses = await vc.analyzeVisualContrast(resolved);
const added = [];
for (const result of analyses) {
const el = addVisualContrastResult(wasm, groups, result);
if (el) added.push(el);
}
const lazy = (resolved.visualContrastLazy === false || resolved.scrollOffscreen !== false)
? []
: __lazyVisualContrastCandidates(analyses);
await ask(session, {
stage: 'visual',
groups,
added,
analyses,
serialized: serialize(wasm, groups),
lazy,
stats: { visualMs: performance.now() - t0 - stats.coreMs },
});
}
wasm.snapshot_clear();
finish(session, { done: true });
}
async function runCandidate(session, msg) {
const wasm = await coreReady();
const n = wasm.snapshot_load(msg.snapshot);
if (n === 0xFFFFFFFF) throw new Error('snapshot did not parse');
const IO = createOffscreenVisualIO(wasm, session);
const vc = createVisualContrast(IO);
const groups = Array.isArray(msg.groups) ? msg.groups : [];
const result = await vc.analyzeVisualContrastCandidate(msg.candidate);
const el = addVisualContrastResult(wasm, groups, result);
const out = { result, el, groups, serialized: el ? serialize(wasm, groups) : null };
wasm.snapshot_clear();
finish(session, out);
}
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!msg || msg.target !== TARGET) return false;
if (msg.action === 'ping') {
coreReady().then(() => sendResponse({ ok: true, ready: true }), (err) => sendResponse({ ok: false, error: err?.message || String(err) }));
return true;
}
if (msg.action === 'antipatterns') {
coreReady().then((wasm) => sendResponse({ antipatterns: JSON.parse(wasm.antipatterns_json()) }), (err) => sendResponse({ error: err?.message || String(err) }));
return true;
}
if (msg.action === 'scan-start' || msg.action === 'analyze-candidate') {
const prior = sessions.get(msg.session);
if (prior) {
// A restarted session (the content script re-scanned): drop the old
// coroutine so it never answers a stale request.
prior.superseded = true;
if (prior.resume) prior.resume.reject(new Error('superseded'));
if (prior.respond) { try { prior.respond({ superseded: true }); } catch { /* channel gone */ } }
prior.respond = null;
sessions.delete(msg.session);
}
const session = { id: msg.session, respond: sendResponse, resume: null, superseded: false };
sessions.set(msg.session, session);
const run = msg.action === 'scan-start' ? runScan : runCandidate;
serialized(() => {
if (session.superseded) return;
return run(session, msg);
}).catch((err) => {
if (err && err.message === 'superseded') return;
finish(session, { error: err?.message || String(err) });
});
return true;
}
if (msg.action === 'scan-continue') {
const session = sessions.get(msg.session);
if (!session || !session.resume) {
sendResponse({ error: 'no such session' });
return false;
}
session.respond = sendResponse;
const resume = session.resume;
session.resume = null;
resume.resolve(msg.answer);
return true;
}
return false;
});
})();