@nodegx/mcp 0.2.6 → 0.2.7

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.
@@ -52032,11 +52032,12 @@
52032
52032
  ],
52033
52033
  "items": [
52034
52034
  "nothing handed (undefined): the list it has stays",
52035
+ "the value is still the placed node's seed and the list does not offer it: the list is kept, and the value is cleared to '' and sent",
52035
52036
  "otherwise: the list is kept as handed and drawn at the next paint; the value stays, so a list handed again keeps the pick"
52036
52037
  ],
52037
52038
  "value": [
52038
52039
  "null: ignored",
52039
- "the value it already holds, as text: nothing",
52040
+ "the value it already holds, as text: nothing (it is no longer the seed)",
52040
52041
  "a change: kept as text, Value follows, and the list selects it at the next paint if it names an option — Changed does not fire (only a person's pick fires it)"
52041
52042
  ],
52042
52043
  "mounted": [
@@ -64417,7 +64418,7 @@
64417
64418
  "group": "Request",
64418
64419
  "plug": "output",
64419
64420
  "type": {
64420
- "name": "boolean"
64421
+ "name": "string"
64421
64422
  },
64422
64423
  "isSignal": false,
64423
64424
  "description": "Id of the user the session token resolved to, and blank for an unauthenticated request"
@@ -654,6 +654,80 @@ var require_src = __commonJS({
654
654
  }
655
655
  }
656
656
 
657
+ // P112-OWN019-LOOKBLIND / OWN-026 \u2014 text a person cannot read against what is painted behind it. Large 4's number pad
658
+ // set white digits on a #FAF5FF button (1.07:1) and it survived five changes: the model tapped the keys, read
659
+ // "Correct!" and never saw that nobody could read them, because nothing here measured contrast. The ground is the
660
+ // text's ancestors' background colours composited from the first opaque one (white under them all, as a browser
661
+ // paints), the ink is the text's colour faded by every ancestor's opacity, and the ratio is WCAG's. It abstains,
662
+ // counted, wherever ancestors are not what is painted underneath: a background image or gradient, a text shadow, an
663
+ // SVG text. A disabled or aria-hidden control is exempt (WCAG 1.4.3's inactive components). Known limit: a sibling
664
+ // laid over the text by position is not an ancestor, so it is not seen. Raw pairs here; the judgement is summarise's.
665
+ layout.lowContrast = [];
666
+ layout.contrastChecked = 0;
667
+ layout.contrastAbstained = 0;
668
+ const CONTRAST_FLOOR = 3;
669
+ {
670
+ const parseColor = (c) => {
671
+ const m = String(c || '').match(/rgba?\\(([^)]+)\\)/);
672
+ if (!m) return null;
673
+ const p = m[1].split(/[ ,\\/]+/).filter(Boolean).map(Number);
674
+ if (p.length < 3 || p.some((x) => isNaN(x))) return null;
675
+ return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
676
+ };
677
+ const over = (top, under) => ({ r: top.r * top.a + under.r * (1 - top.a), g: top.g * top.a + under.g * (1 - top.a), b: top.b * top.a + under.b * (1 - top.a), a: 1 });
678
+ const lum = (c) => {
679
+ const f = (x) => { const v = x / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
680
+ return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
681
+ };
682
+ const contrastOf = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
683
+ const hexOf = (c) => '#' + [c.r, c.g, c.b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
684
+ const inactive = (e) => e.disabled === true || (typeof e.getAttribute === 'function' && (e.getAttribute('aria-disabled') === 'true' || e.getAttribute('aria-hidden') === 'true'));
685
+ // What is behind the text, or null to abstain; 'exempt' for an inactive control.
686
+ const groundOf = (el) => {
687
+ const stack = [];
688
+ let opacity = 1;
689
+ for (let e = el; e; e = e.parentElement) {
690
+ if (inactive(e)) return 'exempt';
691
+ const cs = styleOf(e);
692
+ if (!cs) break;
693
+ const image = cs.backgroundImage;
694
+ if (image && image !== 'none' && !/^url\\("data:image\\/svg/.test(image)) return null;
695
+ const bg = parseColor(cs.backgroundColor);
696
+ const o = Number(cs.opacity);
697
+ if (o >= 0 && o <= 1) opacity *= o;
698
+ if (bg && bg.a > 0) {
699
+ stack.push(bg);
700
+ if (bg.a >= 0.999) break;
701
+ }
702
+ }
703
+ let g = { r: 255, g: 255, b: 255, a: 1 };
704
+ while (stack.length) g = over(stack.pop(), g);
705
+ return { ground: g, opacity };
706
+ };
707
+ const pairs = new Map();
708
+ for (const el of textEls) {
709
+ if (el.namespaceURI && el.namespaceURI !== 'http://www.w3.org/1999/xhtml') { layout.contrastAbstained += 1; continue; }
710
+ const cs = styleOf(el);
711
+ const fill = parseColor(cs.webkitTextFillColor);
712
+ const fg = fill || parseColor(cs.color);
713
+ if (!fg || fg.a === 0) continue;
714
+ if (cs.textShadow && cs.textShadow !== 'none') { layout.contrastAbstained += 1; continue; }
715
+ const at = groundOf(el);
716
+ if (at === 'exempt') continue;
717
+ if (at === null) { layout.contrastAbstained += 1; continue; }
718
+ if (at.opacity < 0.05) continue;
719
+ const ink = over({ r: fg.r, g: fg.g, b: fg.b, a: fg.a * at.opacity }, at.ground);
720
+ const ratio = Math.round(contrastOf(ink, at.ground) * 100) / 100;
721
+ layout.contrastChecked += 1;
722
+ if (ratio >= CONTRAST_FLOOR) continue;
723
+ const key = hexOf(ink) + '|' + hexOf(at.ground);
724
+ const seen = pairs.get(key);
725
+ if (seen) seen.count += 1;
726
+ else pairs.set(key, { text: el.textContent.trim().slice(0, 40), fg: hexOf(ink), bg: hexOf(at.ground), ratio, size: round(parseFloat(cs.fontSize) || 0), weight: String(cs.fontWeight || ''), cls: cls(el), count: 1 });
727
+ }
728
+ layout.lowContrast = [...pairs.values()].sort((a, b) => a.ratio - b.ratio).slice(0, 10);
729
+ }
730
+
657
731
  const contentBottom = visible.reduce((lowest, el) => {
658
732
  const r = el.getBoundingClientRect();
659
733
  return r.width > 0 && r.height > 0 && r.bottom > lowest ? r.bottom : lowest;
@@ -821,7 +895,12 @@ var require_src = __commonJS({
821
895
  * P112-OWN019-LETTEREDGE (Richard 2026-10-15, TL2's hero). Words within 3 px of the inner left or right edge of the
822
896
  * box that frames them (a border or a fill) — a letter in an outlined chip set left because its Text stretched.
823
897
  */
824
- TextAgainstEdge: "text-against-edge"
898
+ TextAgainstEdge: "text-against-edge",
899
+ /**
900
+ * P112-OWN019-LOOKBLIND (OWN-026's number pad: white digits on #FAF5FF, 1.07:1, through five changes). Text under 3:1
901
+ * against the background composited behind it — unreadable to anyone, and invisible to an agent that only reads text.
902
+ */
903
+ LowContrastText: "low-contrast-text"
825
904
  };
826
905
  var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
827
906
  var ROW_GAP_MIN_TALLEST = 48;
@@ -867,6 +946,7 @@ var require_src = __commonJS({
867
946
  const findings = [];
868
947
  const add = (f) => findings.push(f);
869
948
  let controlsJudged = false;
949
+ const contrastPairs = /* @__PURE__ */ new Map();
870
950
  for (const [name, v] of Object.entries(viewports)) {
871
951
  if (!v || v.error) continue;
872
952
  const isDesktop = v.requested.width >= DESKTOP_WIDTH;
@@ -1112,6 +1192,12 @@ var require_src = __commonJS({
1112
1192
  evidence: edges.slice(0, 6)
1113
1193
  });
1114
1194
  }
1195
+ for (const c of v.layout && v.layout.lowContrast || []) {
1196
+ const key = c.fg + "|" + c.bg;
1197
+ const prev = contrastPairs.get(key);
1198
+ if (!prev) contrastPairs.set(key, { ...c, viewport: name });
1199
+ else if (c.count > prev.count) prev.count = c.count;
1200
+ }
1115
1201
  if (v.consoleErrors && v.consoleErrors.length) {
1116
1202
  add({
1117
1203
  code: RenderFinding.ConsoleError,
@@ -1122,6 +1208,17 @@ var require_src = __commonJS({
1122
1208
  });
1123
1209
  }
1124
1210
  }
1211
+ if (contrastPairs.size) {
1212
+ const worst = [...contrastPairs.values()].sort((a, b) => a.ratio - b.ratio);
1213
+ const named = worst.slice(0, 3).map((c) => `"${c.text}" ${c.fg} on ${c.bg}, ${c.ratio}:1${c.count > 1 ? ` (and ${c.count - 1} more like it)` : ""}`);
1214
+ add({
1215
+ code: RenderFinding.LowContrastText,
1216
+ severity: "warning",
1217
+ viewport: worst[0].viewport,
1218
+ message: `${plural(worst.length, "colour pair", "colour pairs")} of text ${worst.length === 1 ? "reads" : "read"} under 3:1 against what is behind ${worst.length === 1 ? "it" : "them"}, so a person cannot read ${worst.length === 1 ? "it" : "them"}: ${named.join("; ")}. Pressing it still works, so a drive will not notice: set the text's colour or its background from the style vocabulary so they differ in lightness, not only in hue. Look at the ${worst[0].viewport} screenshot.`,
1219
+ evidence: worst.slice(0, 6)
1220
+ });
1221
+ }
1125
1222
  const seen = /* @__PURE__ */ new Set();
1126
1223
  const unique = findings.filter((f) => {
1127
1224
  const key = `${f.code}|${f.viewport}|${f.message}`;
@@ -2087,6 +2184,32 @@ var require_render_report = __commonJS({
2087
2184
  }
2088
2185
  };
2089
2186
  }
2187
+ var PAGE_ANSWER_MS = Number(process.env.NODEGX_RENDER_PAGE_ANSWER_MS) || 3e4;
2188
+ async function pageAnswers(attaching, chromePath) {
2189
+ const neverAnswered = (refused) => {
2190
+ const message = "Chrome started (its debugging port opened) but its page " + (refused ? `refused the connection (${refused.message || refused}). ` : `never answered within ${Math.round(PAGE_ANSWER_MS / 1e3)} s. `) + `That is what Chrome does inside another sandbox (an agent harness that runs commands under sandbox-exec, a container): its own sandbox cannot start and its pages crash. Run the render outside that sandbox, or set CHROME_PATH to a script that starts ${chromePath} with --no-sandbox.`;
2191
+ const error = new Error(message);
2192
+ error.problems = [message];
2193
+ error.actionable = true;
2194
+ return error;
2195
+ };
2196
+ if (!attaching) throw neverAnswered();
2197
+ let timer;
2198
+ const deadline = new Promise((_, reject) => {
2199
+ timer = setTimeout(() => reject(neverAnswered()), PAGE_ANSWER_MS);
2200
+ });
2201
+ try {
2202
+ const answered = attaching.then(
2203
+ async (tab) => (await evaluate(tab.client, "1"), tab),
2204
+ (refused) => {
2205
+ throw neverAnswered(refused);
2206
+ }
2207
+ );
2208
+ return await Promise.race([answered, deadline]);
2209
+ } finally {
2210
+ clearTimeout(timer);
2211
+ }
2212
+ }
2090
2213
  async function withRenderedPage2(options, fn) {
2091
2214
  const { projectDir, backendPort, editorTokens = false, chromeArgs = [], beforeBoot } = options;
2092
2215
  reapOrphanedRenderProcesses();
@@ -2186,7 +2309,10 @@ var require_render_report = __commonJS({
2186
2309
  if (!targets) throw new Error(`Chrome never opened a debugging port (${pre.chrome}).`);
2187
2310
  const target = targets.find((t) => t.type === "page");
2188
2311
  const settles = [];
2189
- const main2 = await attachTab({ wsUrl: target.webSocketDebuggerUrl, servePort, settles });
2312
+ const main2 = await pageAnswers(
2313
+ target ? attachTab({ wsUrl: target.webSocketDebuggerUrl, servePort, settles }) : void 0,
2314
+ pre.chrome
2315
+ );
2190
2316
  client = main2.client;
2191
2317
  const openTab = async (label) => {
2192
2318
  if (!browserClient) {
@@ -2251,9 +2377,11 @@ var require_render_report = __commonJS({
2251
2377
  editorTokens = false,
2252
2378
  renderRoutedPages = true,
2253
2379
  page: requestedPage,
2254
- concurrency = PAGE_TABS
2380
+ concurrency = PAGE_TABS,
2381
+ imageBudget
2255
2382
  } = options;
2256
2383
  const started = Date.now();
2384
+ const imageBytesEach = imageBudget ? Math.floor(imageBudget / Math.max(1, viewports.length)) : 0;
2257
2385
  const routes = routedPages2(projectDir);
2258
2386
  let focus;
2259
2387
  if (requestedPage !== void 0) {
@@ -2285,24 +2413,45 @@ var require_render_report = __commonJS({
2285
2413
  if (focusedOffStart) await page.navigate(focus.url);
2286
2414
  const measured = {};
2287
2415
  const screenshots = [];
2416
+ const take = (vp, client, format, height, quality) => client.send("Page.captureScreenshot", {
2417
+ format,
2418
+ ...format === "jpeg" ? { quality } : {},
2419
+ captureBeyondViewport: screenshot === "full",
2420
+ optimizeForSpeed: true,
2421
+ ...screenshot === "full" ? { clip: { x: 0, y: 0, width: vp.width, height, scale: deviceScaleFactor } } : {}
2422
+ });
2288
2423
  const capture = async (vp, raw, component, subject2, client = page.client) => {
2289
- const shot = await client.send("Page.captureScreenshot", {
2290
- format: "png",
2291
- captureBeyondViewport: screenshot === "full",
2292
- optimizeForSpeed: true,
2293
- ...screenshot === "full" ? {
2294
- clip: {
2295
- x: 0,
2296
- y: 0,
2297
- width: vp.width,
2298
- // Chrome refuses beyond 16384px; a page taller than that is
2299
- // already the finding.
2300
- height: Math.min(raw.pageHeight, 16384),
2301
- scale: deviceScaleFactor
2302
- }
2303
- } : {}
2304
- });
2305
- return { name: vp.name, page: component, subject: subject2, mimeType: "image/png", base64: shot.data };
2424
+ const fullHeight = Math.min(raw.pageHeight, 16384);
2425
+ const shot = await take(vp, client, "png", fullHeight);
2426
+ const base = { name: vp.name, page: component, subject: subject2 };
2427
+ if (!imageBytesEach || shot.data.length <= imageBytesEach) return { ...base, mimeType: "image/png", base64: shot.data };
2428
+ return { ...base, ...await fitted(vp, client, fullHeight) };
2429
+ };
2430
+ const fitted = async (vp, client, fullHeight) => {
2431
+ const screenHeight = Math.max(1, vp.height);
2432
+ const screens = Math.max(1, Math.ceil(fullHeight / screenHeight));
2433
+ let quality = 70;
2434
+ let height = fullHeight;
2435
+ let shot = await take(vp, client, "jpeg", height, quality);
2436
+ if (screenshot === "full" && shot.data.length > imageBytesEach && screens > 1) {
2437
+ let keep = Math.min(screens - 1, Math.max(1, Math.floor(screens * imageBytesEach / shot.data.length)));
2438
+ for (; ; ) {
2439
+ height = Math.min(fullHeight, keep * screenHeight);
2440
+ shot = await take(vp, client, "jpeg", height, quality);
2441
+ if (shot.data.length <= imageBytesEach || keep === 1) break;
2442
+ keep -= 1;
2443
+ }
2444
+ }
2445
+ if (shot.data.length > imageBytesEach) {
2446
+ quality = 40;
2447
+ shot = await take(vp, client, "jpeg", height, quality);
2448
+ }
2449
+ const shown = screenshot === "full" ? Math.min(screens, Math.ceil(height / screenHeight)) : 1;
2450
+ return {
2451
+ mimeType: "image/jpeg",
2452
+ base64: shot.data,
2453
+ fit: { format: "jpeg", quality, screens: shown, of: screenshot === "full" ? screens : 1, overBudget: shot.data.length > imageBytesEach }
2454
+ };
2306
2455
  };
2307
2456
  const expression = measureExpression(placeholders, probesFor(subjectComponent));
2308
2457
  let loggedThrough = 0;
@@ -2449,6 +2598,7 @@ var require_render_report = __commonJS({
2449
2598
  // Exported so a session-teardown hook, a CI step or a spec can reap without
2450
2599
  // starting a drive — the leak outlives the run that caused it.
2451
2600
  reapOrphanedRenderProcesses,
2601
+ pageAnswers,
2452
2602
  routedPages: routedPages2,
2453
2603
  resolvePageRequest: resolvePageRequest2,
2454
2604
  reachableComponents,
@@ -2751,7 +2901,32 @@ function lookExpression(target) {
2751
2901
  const r = box.getBoundingClientRect();
2752
2902
  const visible = r.width > 0 && r.height > 0 && st.visibility !== 'hidden' && st.display !== 'none' && opacity > 0.05;
2753
2903
  const color = getComputedStyle(leaf).color;
2754
- return { text: label, visible, opacity: Math.round(opacity * 100) / 100, background: painted, backgroundName: name(painted), color, colorName: name(color) };
2904
+ // P112-OWN019-LOOKBLIND \u2014 can a person read it: WCAG contrast of the ink (faded by every ancestor's opacity) against
2905
+ // the ground composited from the text outwards (white under all of them, as a browser paints). null, never a guess,
2906
+ // where a picture or gradient is behind the text or it casts a shadow. The same reading as render_report's
2907
+ // low-contrast-text, so a page behind a sign-in can be checked from a drive.
2908
+ let contrast = null;
2909
+ const ink = rgba(getComputedStyle(leaf).webkitTextFillColor) || rgba(color);
2910
+ if (ink && ink.a > 0 && (getComputedStyle(leaf).textShadow || 'none') === 'none') {
2911
+ const over = (t, u) => ({ r: t.r * t.a + u.r * (1 - t.a), g: t.g * t.a + u.g * (1 - t.a), b: t.b * t.a + u.b * (1 - t.a), a: 1 });
2912
+ const lum = (c) => { const f = (x) => { const v = x / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b); };
2913
+ const stack = [];
2914
+ let picture = false;
2915
+ for (let e = leaf; e; e = e.parentElement) {
2916
+ const cs = getComputedStyle(e);
2917
+ if (cs.backgroundImage && cs.backgroundImage !== 'none' && !/^url\\("data:image\\/svg/.test(cs.backgroundImage)) { picture = true; break; }
2918
+ const bg = rgba(cs.backgroundColor);
2919
+ if (bg && bg.a > 0) { stack.push(bg); if (bg.a >= 0.999) break; }
2920
+ }
2921
+ if (!picture) {
2922
+ let ground = { r: 255, g: 255, b: 255, a: 1 };
2923
+ while (stack.length) ground = over(stack.pop(), ground);
2924
+ const fg = over({ r: ink.r, g: ink.g, b: ink.b, a: ink.a * opacity }, ground);
2925
+ const x = lum(fg), y = lum(ground);
2926
+ contrast = Math.round(((Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05)) * 100) / 100;
2927
+ }
2928
+ }
2929
+ return { text: label, visible, opacity: Math.round(opacity * 100) / 100, background: painted, backgroundName: name(painted), color, colorName: name(color), contrast, readable: contrast === null ? null : contrast >= 3 };
2755
2930
  })()`;
2756
2931
  }
2757
2932
  function readExpression(r) {
@@ -654,6 +654,80 @@ var require_src = __commonJS({
654
654
  }
655
655
  }
656
656
 
657
+ // P112-OWN019-LOOKBLIND / OWN-026 \u2014 text a person cannot read against what is painted behind it. Large 4's number pad
658
+ // set white digits on a #FAF5FF button (1.07:1) and it survived five changes: the model tapped the keys, read
659
+ // "Correct!" and never saw that nobody could read them, because nothing here measured contrast. The ground is the
660
+ // text's ancestors' background colours composited from the first opaque one (white under them all, as a browser
661
+ // paints), the ink is the text's colour faded by every ancestor's opacity, and the ratio is WCAG's. It abstains,
662
+ // counted, wherever ancestors are not what is painted underneath: a background image or gradient, a text shadow, an
663
+ // SVG text. A disabled or aria-hidden control is exempt (WCAG 1.4.3's inactive components). Known limit: a sibling
664
+ // laid over the text by position is not an ancestor, so it is not seen. Raw pairs here; the judgement is summarise's.
665
+ layout.lowContrast = [];
666
+ layout.contrastChecked = 0;
667
+ layout.contrastAbstained = 0;
668
+ const CONTRAST_FLOOR = 3;
669
+ {
670
+ const parseColor = (c) => {
671
+ const m = String(c || '').match(/rgba?\\(([^)]+)\\)/);
672
+ if (!m) return null;
673
+ const p = m[1].split(/[ ,\\/]+/).filter(Boolean).map(Number);
674
+ if (p.length < 3 || p.some((x) => isNaN(x))) return null;
675
+ return { r: p[0], g: p[1], b: p[2], a: p.length > 3 ? p[3] : 1 };
676
+ };
677
+ const over = (top, under) => ({ r: top.r * top.a + under.r * (1 - top.a), g: top.g * top.a + under.g * (1 - top.a), b: top.b * top.a + under.b * (1 - top.a), a: 1 });
678
+ const lum = (c) => {
679
+ const f = (x) => { const v = x / 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
680
+ return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
681
+ };
682
+ const contrastOf = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
683
+ const hexOf = (c) => '#' + [c.r, c.g, c.b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
684
+ const inactive = (e) => e.disabled === true || (typeof e.getAttribute === 'function' && (e.getAttribute('aria-disabled') === 'true' || e.getAttribute('aria-hidden') === 'true'));
685
+ // What is behind the text, or null to abstain; 'exempt' for an inactive control.
686
+ const groundOf = (el) => {
687
+ const stack = [];
688
+ let opacity = 1;
689
+ for (let e = el; e; e = e.parentElement) {
690
+ if (inactive(e)) return 'exempt';
691
+ const cs = styleOf(e);
692
+ if (!cs) break;
693
+ const image = cs.backgroundImage;
694
+ if (image && image !== 'none' && !/^url\\("data:image\\/svg/.test(image)) return null;
695
+ const bg = parseColor(cs.backgroundColor);
696
+ const o = Number(cs.opacity);
697
+ if (o >= 0 && o <= 1) opacity *= o;
698
+ if (bg && bg.a > 0) {
699
+ stack.push(bg);
700
+ if (bg.a >= 0.999) break;
701
+ }
702
+ }
703
+ let g = { r: 255, g: 255, b: 255, a: 1 };
704
+ while (stack.length) g = over(stack.pop(), g);
705
+ return { ground: g, opacity };
706
+ };
707
+ const pairs = new Map();
708
+ for (const el of textEls) {
709
+ if (el.namespaceURI && el.namespaceURI !== 'http://www.w3.org/1999/xhtml') { layout.contrastAbstained += 1; continue; }
710
+ const cs = styleOf(el);
711
+ const fill = parseColor(cs.webkitTextFillColor);
712
+ const fg = fill || parseColor(cs.color);
713
+ if (!fg || fg.a === 0) continue;
714
+ if (cs.textShadow && cs.textShadow !== 'none') { layout.contrastAbstained += 1; continue; }
715
+ const at = groundOf(el);
716
+ if (at === 'exempt') continue;
717
+ if (at === null) { layout.contrastAbstained += 1; continue; }
718
+ if (at.opacity < 0.05) continue;
719
+ const ink = over({ r: fg.r, g: fg.g, b: fg.b, a: fg.a * at.opacity }, at.ground);
720
+ const ratio = Math.round(contrastOf(ink, at.ground) * 100) / 100;
721
+ layout.contrastChecked += 1;
722
+ if (ratio >= CONTRAST_FLOOR) continue;
723
+ const key = hexOf(ink) + '|' + hexOf(at.ground);
724
+ const seen = pairs.get(key);
725
+ if (seen) seen.count += 1;
726
+ else pairs.set(key, { text: el.textContent.trim().slice(0, 40), fg: hexOf(ink), bg: hexOf(at.ground), ratio, size: round(parseFloat(cs.fontSize) || 0), weight: String(cs.fontWeight || ''), cls: cls(el), count: 1 });
727
+ }
728
+ layout.lowContrast = [...pairs.values()].sort((a, b) => a.ratio - b.ratio).slice(0, 10);
729
+ }
730
+
657
731
  const contentBottom = visible.reduce((lowest, el) => {
658
732
  const r = el.getBoundingClientRect();
659
733
  return r.width > 0 && r.height > 0 && r.bottom > lowest ? r.bottom : lowest;
@@ -821,7 +895,12 @@ var require_src = __commonJS({
821
895
  * P112-OWN019-LETTEREDGE (Richard 2026-10-15, TL2's hero). Words within 3 px of the inner left or right edge of the
822
896
  * box that frames them (a border or a fill) — a letter in an outlined chip set left because its Text stretched.
823
897
  */
824
- TextAgainstEdge: "text-against-edge"
898
+ TextAgainstEdge: "text-against-edge",
899
+ /**
900
+ * P112-OWN019-LOOKBLIND (OWN-026's number pad: white digits on #FAF5FF, 1.07:1, through five changes). Text under 3:1
901
+ * against the background composited behind it — unreadable to anyone, and invisible to an agent that only reads text.
902
+ */
903
+ LowContrastText: "low-contrast-text"
825
904
  };
826
905
  var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
827
906
  var ROW_GAP_MIN_TALLEST = 48;
@@ -867,6 +946,7 @@ var require_src = __commonJS({
867
946
  const findings = [];
868
947
  const add = (f) => findings.push(f);
869
948
  let controlsJudged = false;
949
+ const contrastPairs = /* @__PURE__ */ new Map();
870
950
  for (const [name, v] of Object.entries(viewports)) {
871
951
  if (!v || v.error) continue;
872
952
  const isDesktop = v.requested.width >= DESKTOP_WIDTH;
@@ -1112,6 +1192,12 @@ var require_src = __commonJS({
1112
1192
  evidence: edges.slice(0, 6)
1113
1193
  });
1114
1194
  }
1195
+ for (const c of v.layout && v.layout.lowContrast || []) {
1196
+ const key = c.fg + "|" + c.bg;
1197
+ const prev = contrastPairs.get(key);
1198
+ if (!prev) contrastPairs.set(key, { ...c, viewport: name });
1199
+ else if (c.count > prev.count) prev.count = c.count;
1200
+ }
1115
1201
  if (v.consoleErrors && v.consoleErrors.length) {
1116
1202
  add({
1117
1203
  code: RenderFinding.ConsoleError,
@@ -1122,6 +1208,17 @@ var require_src = __commonJS({
1122
1208
  });
1123
1209
  }
1124
1210
  }
1211
+ if (contrastPairs.size) {
1212
+ const worst = [...contrastPairs.values()].sort((a, b) => a.ratio - b.ratio);
1213
+ const named = worst.slice(0, 3).map((c) => `"${c.text}" ${c.fg} on ${c.bg}, ${c.ratio}:1${c.count > 1 ? ` (and ${c.count - 1} more like it)` : ""}`);
1214
+ add({
1215
+ code: RenderFinding.LowContrastText,
1216
+ severity: "warning",
1217
+ viewport: worst[0].viewport,
1218
+ message: `${plural(worst.length, "colour pair", "colour pairs")} of text ${worst.length === 1 ? "reads" : "read"} under 3:1 against what is behind ${worst.length === 1 ? "it" : "them"}, so a person cannot read ${worst.length === 1 ? "it" : "them"}: ${named.join("; ")}. Pressing it still works, so a drive will not notice: set the text's colour or its background from the style vocabulary so they differ in lightness, not only in hue. Look at the ${worst[0].viewport} screenshot.`,
1219
+ evidence: worst.slice(0, 6)
1220
+ });
1221
+ }
1125
1222
  const seen = /* @__PURE__ */ new Set();
1126
1223
  const unique = findings.filter((f) => {
1127
1224
  const key = `${f.code}|${f.viewport}|${f.message}`;
@@ -2087,6 +2184,32 @@ var require_render_report = __commonJS({
2087
2184
  }
2088
2185
  };
2089
2186
  }
2187
+ var PAGE_ANSWER_MS = Number(process.env.NODEGX_RENDER_PAGE_ANSWER_MS) || 3e4;
2188
+ async function pageAnswers(attaching, chromePath) {
2189
+ const neverAnswered = (refused) => {
2190
+ const message = "Chrome started (its debugging port opened) but its page " + (refused ? `refused the connection (${refused.message || refused}). ` : `never answered within ${Math.round(PAGE_ANSWER_MS / 1e3)} s. `) + `That is what Chrome does inside another sandbox (an agent harness that runs commands under sandbox-exec, a container): its own sandbox cannot start and its pages crash. Run the render outside that sandbox, or set CHROME_PATH to a script that starts ${chromePath} with --no-sandbox.`;
2191
+ const error = new Error(message);
2192
+ error.problems = [message];
2193
+ error.actionable = true;
2194
+ return error;
2195
+ };
2196
+ if (!attaching) throw neverAnswered();
2197
+ let timer;
2198
+ const deadline = new Promise((_, reject) => {
2199
+ timer = setTimeout(() => reject(neverAnswered()), PAGE_ANSWER_MS);
2200
+ });
2201
+ try {
2202
+ const answered = attaching.then(
2203
+ async (tab) => (await evaluate(tab.client, "1"), tab),
2204
+ (refused) => {
2205
+ throw neverAnswered(refused);
2206
+ }
2207
+ );
2208
+ return await Promise.race([answered, deadline]);
2209
+ } finally {
2210
+ clearTimeout(timer);
2211
+ }
2212
+ }
2090
2213
  async function withRenderedPage(options, fn) {
2091
2214
  const { projectDir: projectDir2, backendPort, editorTokens = false, chromeArgs = [], beforeBoot } = options;
2092
2215
  reapOrphanedRenderProcesses();
@@ -2186,7 +2309,10 @@ var require_render_report = __commonJS({
2186
2309
  if (!targets) throw new Error(`Chrome never opened a debugging port (${pre.chrome}).`);
2187
2310
  const target = targets.find((t) => t.type === "page");
2188
2311
  const settles = [];
2189
- const main2 = await attachTab({ wsUrl: target.webSocketDebuggerUrl, servePort, settles });
2312
+ const main2 = await pageAnswers(
2313
+ target ? attachTab({ wsUrl: target.webSocketDebuggerUrl, servePort, settles }) : void 0,
2314
+ pre.chrome
2315
+ );
2190
2316
  client = main2.client;
2191
2317
  const openTab = async (label) => {
2192
2318
  if (!browserClient) {
@@ -2251,9 +2377,11 @@ var require_render_report = __commonJS({
2251
2377
  editorTokens = false,
2252
2378
  renderRoutedPages = true,
2253
2379
  page: requestedPage,
2254
- concurrency = PAGE_TABS
2380
+ concurrency = PAGE_TABS,
2381
+ imageBudget
2255
2382
  } = options;
2256
2383
  const started = Date.now();
2384
+ const imageBytesEach = imageBudget ? Math.floor(imageBudget / Math.max(1, viewports.length)) : 0;
2257
2385
  const routes = routedPages(projectDir2);
2258
2386
  let focus;
2259
2387
  if (requestedPage !== void 0) {
@@ -2285,24 +2413,45 @@ var require_render_report = __commonJS({
2285
2413
  if (focusedOffStart) await page.navigate(focus.url);
2286
2414
  const measured = {};
2287
2415
  const screenshots = [];
2416
+ const take = (vp, client, format, height, quality) => client.send("Page.captureScreenshot", {
2417
+ format,
2418
+ ...format === "jpeg" ? { quality } : {},
2419
+ captureBeyondViewport: screenshot === "full",
2420
+ optimizeForSpeed: true,
2421
+ ...screenshot === "full" ? { clip: { x: 0, y: 0, width: vp.width, height, scale: deviceScaleFactor } } : {}
2422
+ });
2288
2423
  const capture = async (vp, raw, component, subject2, client = page.client) => {
2289
- const shot = await client.send("Page.captureScreenshot", {
2290
- format: "png",
2291
- captureBeyondViewport: screenshot === "full",
2292
- optimizeForSpeed: true,
2293
- ...screenshot === "full" ? {
2294
- clip: {
2295
- x: 0,
2296
- y: 0,
2297
- width: vp.width,
2298
- // Chrome refuses beyond 16384px; a page taller than that is
2299
- // already the finding.
2300
- height: Math.min(raw.pageHeight, 16384),
2301
- scale: deviceScaleFactor
2302
- }
2303
- } : {}
2304
- });
2305
- return { name: vp.name, page: component, subject: subject2, mimeType: "image/png", base64: shot.data };
2424
+ const fullHeight = Math.min(raw.pageHeight, 16384);
2425
+ const shot = await take(vp, client, "png", fullHeight);
2426
+ const base = { name: vp.name, page: component, subject: subject2 };
2427
+ if (!imageBytesEach || shot.data.length <= imageBytesEach) return { ...base, mimeType: "image/png", base64: shot.data };
2428
+ return { ...base, ...await fitted(vp, client, fullHeight) };
2429
+ };
2430
+ const fitted = async (vp, client, fullHeight) => {
2431
+ const screenHeight = Math.max(1, vp.height);
2432
+ const screens = Math.max(1, Math.ceil(fullHeight / screenHeight));
2433
+ let quality = 70;
2434
+ let height = fullHeight;
2435
+ let shot = await take(vp, client, "jpeg", height, quality);
2436
+ if (screenshot === "full" && shot.data.length > imageBytesEach && screens > 1) {
2437
+ let keep = Math.min(screens - 1, Math.max(1, Math.floor(screens * imageBytesEach / shot.data.length)));
2438
+ for (; ; ) {
2439
+ height = Math.min(fullHeight, keep * screenHeight);
2440
+ shot = await take(vp, client, "jpeg", height, quality);
2441
+ if (shot.data.length <= imageBytesEach || keep === 1) break;
2442
+ keep -= 1;
2443
+ }
2444
+ }
2445
+ if (shot.data.length > imageBytesEach) {
2446
+ quality = 40;
2447
+ shot = await take(vp, client, "jpeg", height, quality);
2448
+ }
2449
+ const shown = screenshot === "full" ? Math.min(screens, Math.ceil(height / screenHeight)) : 1;
2450
+ return {
2451
+ mimeType: "image/jpeg",
2452
+ base64: shot.data,
2453
+ fit: { format: "jpeg", quality, screens: shown, of: screenshot === "full" ? screens : 1, overBudget: shot.data.length > imageBytesEach }
2454
+ };
2306
2455
  };
2307
2456
  const expression = measureExpression(placeholders, probesFor(subjectComponent));
2308
2457
  let loggedThrough = 0;
@@ -2449,6 +2598,7 @@ var require_render_report = __commonJS({
2449
2598
  // Exported so a session-teardown hook, a CI step or a spec can reap without
2450
2599
  // starting a drive — the leak outlives the run that caused it.
2451
2600
  reapOrphanedRenderProcesses,
2601
+ pageAnswers,
2452
2602
  routedPages,
2453
2603
  resolvePageRequest,
2454
2604
  reachableComponents,
@@ -2509,7 +2659,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
2509
2659
  "--scale",
2510
2660
  "--backend-port",
2511
2661
  "--page",
2512
- "--concurrency"
2662
+ "--concurrency",
2663
+ "--image-budget"
2513
2664
  ]);
2514
2665
  var flag = (name, fallback) => {
2515
2666
  const i = argv.indexOf(name);
@@ -2573,7 +2724,8 @@ async function main() {
2573
2724
  // FLD-011 — `--concurrency 1` is the serial sweep, and it is here so the arms
2574
2725
  // that grade the parallel one are two runs of the SAME build differing by a
2575
2726
  // number, rather than a run of this build against a `git show HEAD:` copy.
2576
- ...flag("--concurrency") ? { concurrency: Number(flag("--concurrency")) } : {}
2727
+ ...flag("--concurrency") ? { concurrency: Number(flag("--concurrency")) } : {},
2728
+ ...flag("--image-budget") ? { imageBudget: Number(flag("--image-budget")) } : {}
2577
2729
  });
2578
2730
  const out = flag("--out");
2579
2731
  const files = [];