231 lines
17 KiB
TypeScript
231 lines
17 KiB
TypeScript
// Theme invariants (theme engine v3, plan 6.2): a pure measurement over a rendered document that
|
|
// says, with numbers, whether a theme is correct. Runs in the page (fixtures over Playwright, the live
|
|
// app over CDP); it never changes the page. Grew out of verification/theme-qa/probe.js.
|
|
//
|
|
// I1 text contrast 4.5:1 (3:1 large; 7:1 with access high) of ink over its painted backdrop
|
|
// I1b non-text contrast icons (svg, masked glyphs) 3:1 over their backdrop
|
|
// I2 X colour leakage computed colours equal to an X palette key that is not whitelisted
|
|
// I3 truncated labels in bar, nav, tabs, buttons and menu rows (X's own long-name truncation allowed)
|
|
// I4 bar geometry a horizontal bar: equal item widths with labels, gaps <= one item, hit-testable
|
|
// I5 icon provenance in pack contexts: X's own line paths still visible (xLine)
|
|
// I6 empty frames a framed box (edge or own fill) with nothing rendered inside, taller than 2px
|
|
// I7 frame nesting a framed box holding another framed box of the same fill, or a whole column
|
|
// I8 popover bounds menus, hover cards, dropdowns, tooltips inside the viewport minus the bar inset
|
|
// I10 targets interactive elements in bar, action bar, composer >= 24px (44 with access high)
|
|
// I9 (forced :hover) needs CDP and I11 is static; both live in the runners.
|
|
|
|
export type InvariantId = 'I1' | 'I1b' | 'I2' | 'I3' | 'I4' | 'I5' | 'I6' | 'I7' | 'I8' | 'I10';
|
|
export const INVARIANT_IDS: InvariantId[] = ['I1', 'I1b', 'I2', 'I3', 'I4', 'I5', 'I6', 'I7', 'I8', 'I10'];
|
|
|
|
export type Rect = [number, number, number, number];
|
|
export interface Finding { id: InvariantId; sel: string; rect: Rect; detail: string; value?: number }
|
|
export interface InvariantOptions {
|
|
/** Colours (`rgb(r, g, b)`) that are X's own palette; default: X's light and dark neutrals and blue. */
|
|
xColourKeys?: string[];
|
|
/** The X default theme keeps X's colours: I2 off. */
|
|
exemptLeak?: boolean;
|
|
/** The theme draws an icon pack: I5 on. */
|
|
pack?: boolean;
|
|
access?: 'standard' | 'high';
|
|
/** Cap of findings kept per invariant (counts are always complete). */
|
|
keep?: number;
|
|
}
|
|
export interface InvariantReport {
|
|
counts: Record<InvariantId, number>;
|
|
findings: Finding[];
|
|
/** Measured but not failures: text over images, X icons with no slot, X's allowed truncation. */
|
|
notes: { unknownBackdrop: number; allowedTruncation: number; bar: 'none' | 'rail' | 'horizontal'; barMode?: 'labels' | 'icons' };
|
|
viewport: [number, number];
|
|
}
|
|
|
|
/** X's own palette (Default and Lights out neutrals, X blue): the leak list until the engine exports X_COLOUR_KEYS. */
|
|
export const DEFAULT_X_KEYS = ['rgb(29, 155, 240)', 'rgb(26, 140, 216)', 'rgb(15, 20, 25)', 'rgb(83, 100, 113)', 'rgb(113, 118, 123)', 'rgb(239, 243, 244)',
|
|
'rgb(247, 249, 249)', 'rgb(231, 233, 234)', 'rgb(22, 24, 28)', 'rgb(32, 35, 39)', 'rgb(47, 51, 54)', 'rgb(20, 20, 20)', 'rgb(207, 217, 222)', 'rgb(51, 54, 57)'];
|
|
|
|
type RGBA = [number, number, number, number];
|
|
const parse = (c: string | null | undefined): RGBA | null => { const m = /rgba?\(([\d.]+),? ([\d.]+),? ([\d.]+)(?:,? ?\/? ?([\d.]+))?/.exec(c || ''); return m ? [+m[1], +m[2], +m[3], m[4] === undefined ? 1 : +m[4]] : null; };
|
|
const lum = ([r, g, b]: RGBA) => { const f = (v: number) => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
|
|
export const contrast = (a: RGBA, b: RGBA) => { const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
|
|
const blend = (top: RGBA, under: RGBA): RGBA => top[3] >= 1 ? top : [0, 1, 2].map(i => top[i] * top[3] + under[i] * (1 - top[3])).concat(1) as RGBA;
|
|
const css = (c: RGBA) => `rgb(${c.slice(0, 3).map(Math.round).join(', ')})`;
|
|
|
|
export function runInvariants(doc: Document = document, opts: InvariantOptions = {}): InvariantReport {
|
|
const win = doc.defaultView as Window & typeof globalThis;
|
|
const vw = win.innerWidth, vh = win.innerHeight;
|
|
const keep = opts.keep ?? 12;
|
|
const high = opts.access === 'high';
|
|
const counts = Object.fromEntries(INVARIANT_IDS.map(id => [id, 0])) as Record<InvariantId, number>;
|
|
const findings: Finding[] = [];
|
|
const notes: InvariantReport['notes'] = { unknownBackdrop: 0, allowedTruncation: 0, bar: 'none' };
|
|
const cs = (e: Element) => win.getComputedStyle(e);
|
|
const up = (e: Element): Element | null => e.parentElement ?? ((e.parentNode as ShadowRoot | null)?.host ?? null);
|
|
const rectOf = (e: Element): Rect => { const r = e.getBoundingClientRect(); return [Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)]; };
|
|
const sel = (el: Element) => {
|
|
const p: string[] = [];
|
|
for (let e: Element | null = el; e && e !== doc.body && p.length < 4; e = up(e)) {
|
|
let s = e.tagName.toLowerCase(); const t = e.getAttribute('data-testid');
|
|
if (t) { p.unshift(`${s}[data-testid="${t}"]`); break; }
|
|
if (e.id) { p.unshift(`#${e.id}`); break; }
|
|
const c = [...e.classList].filter(c => !/^(css|r)-/.test(c)).slice(0, 2); if (c.length) s += '.' + c.join('.');
|
|
const role = e.getAttribute('role'); if (role) s += `[role="${role}"]`;
|
|
p.unshift(s);
|
|
}
|
|
return p.join(' > ');
|
|
};
|
|
const add = (id: InvariantId, el: Element, detail: string, value?: number) => {
|
|
counts[id]++;
|
|
if (findings.filter(f => f.id === id).length < keep) findings.push({ id, sel: sel(el), rect: rectOf(el), detail, ...(value === undefined ? {} : { value: +value.toFixed(2) }) });
|
|
};
|
|
const hidden = (el: Element) => { for (let e: Element | null = el; e; e = up(e)) { const s = cs(e); if (s.display === 'none' || s.visibility === 'hidden' || +s.opacity < 0.05) return true; } return false; };
|
|
const onScreen = (el: Element) => { const r = el.getBoundingClientRect(); return r.width > 1 && r.height > 1 && r.bottom > 0 && r.top < vh && r.right > 0 && r.left < vw; };
|
|
const visible = (el: Element) => onScreen(el) && !hidden(el);
|
|
const pageColor = (): RGBA => { for (const e of [doc.body, doc.documentElement]) { const c = parse(cs(e).backgroundColor); if (c && c[3] > 0) return blend(c, [255, 255, 255, 1]); } return [255, 255, 255, 1]; };
|
|
/** The painted backdrop under an element: the alpha stack up to the first opaque fill; `image` when an image or gradient paints first. */
|
|
const backdrop = (el: Element): { color: RGBA; image: boolean } => {
|
|
const stack: RGBA[] = [];
|
|
for (let e: Element | null = el; e && e !== doc.documentElement; e = up(e)) {
|
|
const s = cs(e);
|
|
if (s.backgroundImage !== 'none' && !/^linear-gradient\(rgba?\([^)]*\), rgba?\([^)]*\)\)$/.test(s.backgroundImage)) return { color: pageColor(), image: true };
|
|
const c = parse(s.backgroundColor);
|
|
if (c && c[3] > 0) { stack.push(c); if (c[3] >= 1) break; }
|
|
}
|
|
let base = pageColor();
|
|
for (const c of stack.reverse()) base = blend(c, base);
|
|
return { color: base, image: false };
|
|
};
|
|
const ownText = (el: Element) => [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('').trim();
|
|
const all = [...doc.querySelectorAll('body *')];
|
|
|
|
// I1 text contrast, I2 leakage, I3 truncation
|
|
const xKeys = new Set(opts.xColourKeys ?? DEFAULT_X_KEYS);
|
|
const whitelisted = (el: Element) => !!el.closest('[data-testid="icon-verified"],[data-testid="unlike"],[data-testid="unretweet"],[data-testid="removeBookmark"],img,video,canvas');
|
|
const LABEL_CONTEXT = 'nav[role="navigation"], header[role="banner"], [role="tablist"], [role="tab"], button, [role="button"], [role="menuitem"], .dv-tab';
|
|
const ALLOWED_TRUNC = '[data-testid="User-Name"], [data-testid="trend"], .bt-exact-time, [data-testid="UserName"], [data-testid="UserCell"]';
|
|
for (const el of all) {
|
|
if (el.closest('[aria-hidden="true"]') || el.closest('svg') && el.tagName.toLowerCase() !== 'svg') continue;
|
|
const text = ownText(el);
|
|
if (!visible(el)) continue;
|
|
const st = cs(el);
|
|
if (!opts.exemptLeak && !whitelisted(el)) {
|
|
for (const prop of ['color', 'backgroundColor', 'borderTopColor', 'borderBottomColor', 'fill', 'outlineColor'] as const) {
|
|
const v = st[prop] as string; if (!xKeys.has(v)) continue;
|
|
if (prop.startsWith('border') && parseFloat(prop === 'borderTopColor' ? st.borderTopWidth : st.borderBottomWidth) === 0) continue;
|
|
if (prop === 'outlineColor' && (st.outlineStyle === 'none' || parseFloat(st.outlineWidth) === 0)) continue;
|
|
if (prop === 'color' && !text) continue;
|
|
if (prop === 'fill' && el.tagName.toLowerCase() !== 'svg') continue;
|
|
add('I2', el, `${v} as ${prop}`); break;
|
|
}
|
|
}
|
|
if (!text) continue;
|
|
const fg = parse(st.color); if (!fg) continue;
|
|
const back = backdrop(el);
|
|
if (back.image) notes.unknownBackdrop++;
|
|
else {
|
|
const size = parseFloat(st.fontSize), bold = +st.fontWeight >= 700;
|
|
const large = size >= 24 || (size >= 18.66 && bold);
|
|
const need = high ? (large ? 4.5 : 7) : (large ? 3 : 4.5);
|
|
const ink = blend([fg[0], fg[1], fg[2], fg[3] * Math.min(1, +st.opacity || 1)], back.color);
|
|
const r = contrast(ink, back.color);
|
|
if (r < need - 0.005) add('I1', el, `${r.toFixed(2)}:1 < ${need} ${st.color} on ${css(back.color)} "${text.slice(0, 24)}"`, r);
|
|
}
|
|
if (el.closest(LABEL_CONTEXT)) {
|
|
const cut = el.scrollWidth > el.clientWidth + 1 && (st.textOverflow === 'ellipsis' || st.overflow === 'hidden' || st.overflowX === 'hidden');
|
|
const barLabel = !!el.closest('nav[role="navigation"]') && st.textOverflow === 'ellipsis' && el.scrollWidth > el.clientWidth + 1;
|
|
if (cut || barLabel) {
|
|
if (el.closest(ALLOWED_TRUNC)) notes.allowedTruncation++;
|
|
else add('I3', el, `"${text.slice(0, 24)}" shown in ${el.clientWidth}px of ${el.scrollWidth}px`, el.clientWidth);
|
|
}
|
|
}
|
|
}
|
|
|
|
// I1b non-text contrast and I5 icon provenance
|
|
const PACK_CONTEXT = 'nav[role="navigation"], header[role="banner"], [role="group"], [data-testid="toolBar"], #layers [role="menu"], [data-testid="GrokDrawer"], [data-testid="DMDrawer"], [data-testid="app-bar-back"], [data-testid="app-bar-close"]';
|
|
for (const svg of doc.querySelectorAll('svg')) {
|
|
if (!visible(svg) || svg.closest('[data-testid="icon-verified"]')) continue;
|
|
const s = cs(svg);
|
|
const painted = [...svg.querySelectorAll('path, circle, rect, polygon, ellipse, line')].filter(p => { const ps = cs(p); return +ps.opacity > 0.05 && (ps.fill !== 'none' || ps.stroke !== 'none'); });
|
|
const masked = (e: Element) => { const es = cs(e); return (es.maskImage && es.maskImage !== 'none') || ((es as unknown as Record<string, string>).webkitMaskImage ?? 'none') !== 'none' || es.backgroundImage !== 'none'; };
|
|
if (opts.pack && svg.closest(PACK_CONTEXT) && painted.length && !masked(svg) && !(svg.parentElement && masked(svg.parentElement))) add('I5', svg, `X's own drawing in ${svg.closest(PACK_CONTEXT)!.tagName.toLowerCase()}`);
|
|
const ink = parse(masked(svg) ? s.backgroundColor : s.color);
|
|
if (!ink || ink[3] === 0 || (!painted.length && !masked(svg))) continue;
|
|
const back = backdrop(svg.parentElement ?? svg);
|
|
if (back.image) continue;
|
|
const r = contrast(blend(ink, back.color), back.color);
|
|
if (r < 3 - 0.005) add('I1b', svg, `${r.toFixed(2)}:1 < 3 icon ${css(ink)} on ${css(back.color)}`, r);
|
|
}
|
|
|
|
// I6 empty frames, I7 nesting
|
|
const framed = (el: Element) => {
|
|
const s = cs(el), r = el.getBoundingClientRect();
|
|
if (r.width < 60 || r.height <= 2 || s.position === 'fixed' && r.width >= vw - 2 && r.height >= vh - 2) return false;
|
|
if (s.backgroundImage !== 'none') return false;
|
|
const edge = ['Top', 'Right', 'Bottom', 'Left'].filter(k => parseFloat(s[`border${k}Width` as 'borderTopWidth']) > 0 && s[`border${k}Style` as 'borderTopStyle'] !== 'none' && (parse(s[`border${k}Color` as 'borderTopColor'])?.[3] ?? 0) > 0).length >= 3 || /inset/.test(s.boxShadow);
|
|
const fill = parse(s.backgroundColor); const parent = up(el);
|
|
const own = !!fill && fill[3] > 0.05 && !!parent && css(blend(fill, backdrop(parent).color)) !== css(backdrop(parent).color);
|
|
return edge || own;
|
|
};
|
|
const MEDIA = 'img, svg, video, canvas, input, textarea, select, iframe, [role="img"], [role="textbox"], [role="progressbar"]';
|
|
const frames: Element[] = [];
|
|
for (const el of all) {
|
|
if (!visible(el) || el.closest('#layers [data-testid="mask"]') || el.matches('[data-testid="mask"]')) continue;
|
|
if (el.matches(MEDIA) || !framed(el)) continue;
|
|
frames.push(el);
|
|
const content = (el.textContent ?? '').trim() || [...el.querySelectorAll(MEDIA)].some(m => visible(m)) || [...el.querySelectorAll('*')].some(c => cs(c).backgroundImage !== 'none' && visible(c));
|
|
if (!content) add('I6', el, `empty frame ${rectOf(el)[2]}x${rectOf(el)[3]} ${cs(el).backgroundColor}`, rectOf(el)[3]);
|
|
}
|
|
for (const el of frames) {
|
|
const fill = cs(el).backgroundColor;
|
|
const column = el.querySelector('[data-testid="sidebarColumn"], [data-testid="primaryColumn"]');
|
|
// Controls (a bevelled button on a bevelled bar, a pill in a card) are not frames: only boxes nest wrongly.
|
|
const inner = frames.find(f => f !== el && el.contains(f) && !f.closest('a, button, [role="button"], [role="link"], [role="tab"], input, label') && cs(f).backgroundColor === fill && parse(fill)![3] > 0 && /solid|inset/.test(cs(f).borderTopStyle + cs(f).boxShadow));
|
|
if (column) add('I7', el, `frame holds the ${column.getAttribute('data-testid')}`);
|
|
else if (inner) add('I7', el, `frame holds a frame of the same fill (${sel(inner)})`);
|
|
}
|
|
|
|
// I4 bar geometry and I10 targets
|
|
const nav = doc.querySelector('header[role="banner"] nav[role="navigation"]');
|
|
if (nav && visible(nav)) {
|
|
const items = [...nav.children].filter(c => visible(c)) as HTMLElement[];
|
|
const rects = items.map(i => i.getBoundingClientRect());
|
|
const horizontal = rects.length > 2 && rects.every(r => Math.abs(r.top - rects[0].top) < 6);
|
|
notes.bar = horizontal ? 'horizontal' : 'rail';
|
|
const labelled = items.some(i => [...i.querySelectorAll('span, div')].some(s => ownText(s) && visible(s)));
|
|
if (horizontal) {
|
|
notes.barMode = labelled ? 'labels' : 'icons';
|
|
const widths = rects.map(r => r.width);
|
|
if (labelled && Math.max(...widths) - Math.min(...widths) > 2) add('I4', nav, `item widths ${Math.round(Math.min(...widths))}..${Math.round(Math.max(...widths))}px`, Math.max(...widths) - Math.min(...widths));
|
|
const sorted = [...rects].sort((a, b) => a.left - b.left);
|
|
const unit = Math.max(...widths);
|
|
for (let i = 1; i < sorted.length; i++) { const gap = sorted[i].left - sorted[i - 1].right; if (gap > unit + 1) { add('I4', nav, `gap ${Math.round(gap)}px > one item ${Math.round(unit)}px`, gap); break; } }
|
|
}
|
|
for (const [i, item] of items.entries()) {
|
|
const r = rects[i]; const x = r.left + r.width / 2, y = r.top + r.height / 2;
|
|
if (x < 0 || y < 0 || x > vw || y > vh) { add('I4', item, 'item off screen'); continue; }
|
|
const hit = doc.elementFromPoint(x, y);
|
|
// A layer over the bar (modal mask, menu, hover card) is I8's business, not the bar's.
|
|
if (hit?.closest('#layers')) continue;
|
|
if (!hit || !(item === hit || item.contains(hit))) add('I4', item, `item not hit-testable (under ${hit ? sel(hit) : 'nothing'})`);
|
|
}
|
|
}
|
|
const minTarget = high ? 44 : 24;
|
|
const TARGET_CONTEXT = 'header[role="banner"] nav[role="navigation"] > *, header[role="banner"] [data-testid="SideNav_NewTweet_Button"], header[role="banner"] [data-testid="SideNav_AccountSwitcher_Button"], article [role="group"] button, article [role="group"] a, [data-testid="toolBar"] button, [data-testid="toolBar"] [role="button"]';
|
|
for (const el of doc.querySelectorAll(TARGET_CONTEXT)) {
|
|
if (!visible(el)) continue;
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width < minTarget - 0.5 || r.height < minTarget - 0.5) add('I10', el, `${Math.round(r.width)}x${Math.round(r.height)} < ${minTarget}`, Math.min(r.width, r.height));
|
|
}
|
|
|
|
// I8 popover bounds
|
|
const root = cs(doc.documentElement);
|
|
const insetTop = parseFloat(root.getPropertyValue('--wb-bar-inset-top')) || 0, insetBottom = parseFloat(root.getPropertyValue('--wb-bar-inset-bottom')) || 0;
|
|
for (const el of doc.querySelectorAll('[data-testid="hoverCardParent"], [role="menu"], [role="listbox"], [data-testid="HoverCard"], [data-testid="Dropdown"], [data-testid="HoverLabel"]')) {
|
|
if (!visible(el) && !(el.getBoundingClientRect().width > 1 && !hidden(el))) continue;
|
|
if (el.parentElement?.closest('[data-testid="hoverCardParent"], [role="menu"], [data-testid="HoverCard"]')) continue;
|
|
const r = el.getBoundingClientRect();
|
|
if (r.width < 2 || r.height < 2) continue;
|
|
const out = Math.max(-r.left, -(r.top - insetTop), r.right - vw, r.bottom - (vh - insetBottom));
|
|
if (out > 1) add('I8', el, `${Math.round(out)}px outside the viewport`, out);
|
|
}
|
|
|
|
return { counts, findings, notes, viewport: [vw, vh] };
|
|
}
|