// THE BROWSER-SIDE COLLECTOR — a LEAF module: it imports nothing. // // That is the whole point. This string is evaluated inside the page by every snapshot // producer, and two of them ship as published plugins a project imports into its Cypress // support file. Leaving it in snapshot.ts made those bundles pull parseHtml → htmlparser2 → // the entire audit engine: 600 KB of Node code shipped to a browser to obtain one string. // Nothing here may ever import from the engine. // // snapshot.ts re-exports all three names, so every existing importer is unaffected. // The computed declarations the rendered tier consumes. Kept as one list so the collector // and the rules that read it cannot drift. Extend here when a rendered rule needs a new one. export const COLLECTED_CSS: readonly string[] = [ "color", "backgroundColor", "backgroundImage", "fontSize", "fontWeight", "fontStyle", "textDecorationLine", "textTransform", "lineHeight", "letterSpacing", "wordSpacing", "whiteSpace", "display", "visibility", "opacity", "position", "overflowX", "overflowY", "outlineStyle", "outlineWidth", "outlineColor", // All four borders + the box shadow: WCAG 1.4.11 asks whether a control's BOUNDARY is // perceivable, and a boundary can be drawn by any side, by the fill, by an outline or by a // shadow. Collecting only one side would manufacture non-conformities on the other three. "borderTopStyle", "borderRightStyle", "borderBottomStyle", "borderLeftStyle", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor", "boxShadow", "cursor", ]; // Bound on elements measured. A 20k-element page would otherwise write a styles.json far // larger than the DOM itself. Truncation is RECORDED, never silent. export const COLLECT_MAX_ELEMENTS = 5000; // Browser-context source, evaluated as a STRING (the engine's tsconfig ships no DOM lib — // see src/scan-local.ts). Registered in probeSources() so a typo fails CI instead of being // swallowed at run time. Returns { dom, styles, boxes } for the CURRENT page. export const COLLECT_SNAPSHOT = `(() => { const PROPS = ${JSON.stringify(COLLECTED_CSS)}; const MAX = ${COLLECT_MAX_ELEMENTS}; const els = document.querySelectorAll('*'); const styles = []; const boxes = []; const n = Math.min(els.length, MAX); for (let i = 0; i < n; i++) { const el = els[i]; const tag = el.tagName.toLowerCase(); const cs = getComputedStyle(el); const css = {}; for (const p of PROPS) { const v = cs[p]; if (v !== undefined && v !== null && v !== '') css[p] = String(v); } styles.push({ i: i, tag: tag, css: css }); const r = el.getBoundingClientRect(); boxes.push({ i: i, tag: tag, x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }); } // The page's own stylesheets. Some criteria are properties of the STYLESHEET rather than of // any element's computed style — focus styling removed with no replacement (2.4.7), a media // query that locks the orientation (1.3.4) — and this is the only place they exist. // A cross-origin sheet throws on .cssRules: COUNT those instead of ignoring them, so a rule // reading this digest can tell "nothing there" apart from "I could not look". // The cap is on the ORDINARY pool only, and the traversal never stops early. // // It used to return the moment the pool filled, which abandoned every remaining sheet — // and on a design-system app the pool fills long before the end. Measured on a real capture // of 38 pages: all 38 came back truncated, so rendered-focus-not-visible declined on every // one of them and 2.4.7 could never be decided by any number of scans. The rule was right to // decline (a :focus rule might have been among the dropped ones); the collector was wrong to // make that unavoidable. // // So the rules a rendered criterion actually READS — :focus styling, orientation locks, // pinned positioning, animation — are kept whatever the pool is doing, and truncated now // means "some ordinary rule was dropped", which is a much weaker statement. A rule can then // ask for what it needs instead of refusing on a cap that never concerned it. const cssRules = []; let unreadable = 0; let dropped = 0; const MAXR = 4000; const camel = (p) => p.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); const KEEP_SELECTOR = /:focus|:target|:active/i; const KEEP_PROP = /^(?:outline|position|animation|transition|transform)/; const walk = (list, media) => { for (const r of list) { if (typeof r.selectorText === 'string' && r.style) { const decls = {}; for (let i = 0; i < r.style.length; i++) { const prop = r.style[i]; decls[camel(prop)] = r.style.getPropertyValue(prop); } const keep = KEEP_SELECTOR.test(r.selectorText) || Object.keys(decls).some((k) => KEEP_PROP.test(k)); if (cssRules.length >= MAXR && !keep) { dropped++; continue; } cssRules.push(media ? { selector: r.selectorText, media: media, decls: decls } : { selector: r.selectorText, decls: decls }); } else if (r.cssRules) { const cond = r.conditionText || (r.media && r.media.mediaText) || media; walk(r.cssRules, cond); } } }; for (const sheet of document.styleSheets) { try { walk(sheet.cssRules, undefined); } catch (e) { unreadable++; } } const truncated = els.length > MAX; return { dom: document.documentElement.outerHTML, css: { v: 1, rules: cssRules, unreadable: unreadable, truncated: dropped > 0, dropped: dropped }, title: document.title, // The doctype is NOT part of documentElement.outerHTML, so a capture that records only // the DOM drops it — and RGAA 8.1 (is a doctype present, valid, and before ?) then // has nothing to look at. Recorded as its own field for that reason. doctype: document.doctype ? '' : '', lang: document.documentElement.getAttribute('lang') || '', url: location.href, viewport: { width: window.innerWidth, height: window.innerHeight }, styles: { v: 1, entries: styles, truncated: truncated }, boxes: { v: 1, entries: boxes, truncated: truncated } }; })()`;