@nodegx/mcp 0.2.5 → 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.
@@ -7262,11 +7262,9 @@
7262
7262
  ],
7263
7263
  "dynamicPorts": {
7264
7264
  "mechanisms": [
7265
- "runtime-discovered",
7266
- "editor-adapter"
7265
+ "runtime-discovered"
7267
7266
  ],
7268
- "description": "Filter/sort input ports are generated by the editor from the visual query (QueryRecordsAdapter) and the selected class schema.",
7269
- "editorAdapter": "QueryRecordsAdapter"
7267
+ "description": "Every input is drawn from the parameters, so the static port list is empty: \"collectionName\" (the class), \"visualFilter\", \"visualSort\", \"storageEnableLimit\" + \"storageLimit\"/\"storageSkip\", \"storageEnableCount\", \"search\", \"realtime\", and the \"runOnChange-<input>\" boxes. A server-side filter is \"visualFilter\": {\"combinator\": \"and\", \"rules\": [{\"property\": \"<field>\", \"operator\": \"equal to\", \"input\": \"<name>\"}]}; each rule's \"input\" mints the input port \"qp-<name>\" a connection feeds, or the rule carries a literal \"value\" instead. Any other parameter name is stored and never read."
7270
7268
  },
7271
7269
  "parameterEncoding": {
7272
7270
  "known": true,
@@ -7805,7 +7803,7 @@
7805
7803
  "name": "signal"
7806
7804
  },
7807
7805
  "isSignal": true,
7808
- "description": "Fires when the Id binds a record, and again when a Fetch finishes reading it — a bind has read nothing, so use Done to act on data that is really there"
7806
+ "description": "Fires when the Id binds a record already in memory, when a record the Id names is first read from the backend, and when a Fetch finishes reading it; use Done to act on the answer to a Fetch"
7809
7807
  },
7810
7808
  {
7811
7809
  "name": "id",
@@ -29189,6 +29187,17 @@
29189
29187
  "description": "Read and write rules stored on the record as it is written, each rule adding its own Target, Read and Write ports; the NodeGX backend enforces them on every query and every realtime event, and backends with no per-record access control ignore them",
29190
29188
  "index": 1000
29191
29189
  },
29190
+ {
29191
+ "name": "addTo",
29192
+ "displayName": "Add To",
29193
+ "group": "General",
29194
+ "plug": "input",
29195
+ "type": {
29196
+ "name": "string"
29197
+ },
29198
+ "isSignal": false,
29199
+ "description": "Property names, comma-separated (e.g. points). For these, the value wired in is an amount added to what the backend holds, in one step on the backend, instead of replacing it: two devices adding at once both count. Wire the amount (1, -1, 5). The record then shows the backend's total. Leave empty to write every value as given."
29200
+ },
29192
29201
  {
29193
29202
  "name": "idSource",
29194
29203
  "displayName": "Id Source",
@@ -29478,6 +29487,9 @@
29478
29487
  "onlyIfUnchanged": [
29479
29488
  "always: read by the next save — the fields the backend must still hold as this record holds them, or the save is refused"
29480
29489
  ],
29490
+ "addTo": [
29491
+ "always: read by the next Do — the fields whose value is an amount the backend adds, not a value it stores"
29492
+ ],
29481
29493
  "accessControl": [
29482
29494
  "always: read by the next call — the access rules sent with the record"
29483
29495
  ],
@@ -46461,6 +46473,18 @@
46461
46473
  "description": "How the element is placed: In Layout follows its siblings, Absolute ignores them, Sticky pins to the parent edge on overflow, Fixed stays put and takes no space",
46462
46474
  "index": 11
46463
46475
  },
46476
+ {
46477
+ "name": "repeatWhileSaving",
46478
+ "displayName": "Repeat While Saving",
46479
+ "group": "General",
46480
+ "plug": "input",
46481
+ "type": {
46482
+ "name": "boolean"
46483
+ },
46484
+ "isSignal": false,
46485
+ "default": false,
46486
+ "description": "Off (the default): when a click starts a save, the next click is ignored until the backend has answered, and for at least half a second, so a double press saves once. Controls wired to the same node share this. On: every click fires, for a control that saves each quick tap"
46487
+ },
46464
46488
  {
46465
46489
  "name": "scrollIntoView",
46466
46490
  "displayName": "Scroll Into View",
@@ -52008,11 +52032,12 @@
52008
52032
  ],
52009
52033
  "items": [
52010
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",
52011
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"
52012
52037
  ],
52013
52038
  "value": [
52014
52039
  "null: ignored",
52015
- "the value it already holds, as text: nothing",
52040
+ "the value it already holds, as text: nothing (it is no longer the seed)",
52016
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)"
52017
52042
  ],
52018
52043
  "mounted": [
@@ -58536,6 +58561,18 @@
58536
58561
  "description": "How the element is placed: In Layout follows its siblings, Absolute ignores them, Sticky pins to the parent edge on overflow, Fixed stays put and takes no space",
58537
58562
  "index": 11
58538
58563
  },
58564
+ {
58565
+ "name": "repeatWhileSaving",
58566
+ "displayName": "Repeat While Saving",
58567
+ "group": "General",
58568
+ "plug": "input",
58569
+ "type": {
58570
+ "name": "boolean"
58571
+ },
58572
+ "isSignal": false,
58573
+ "default": false,
58574
+ "description": "Off (the default): when Enter starts a save, the next Enter is ignored until the backend has answered, and for at least half a second, so a double press saves once. Controls wired to the same node share this. On: every Enter fires, for a control that saves each quick tap"
58575
+ },
58539
58576
  {
58540
58577
  "name": "runOnChange-startValue",
58541
58578
  "displayName": "Value",
@@ -64381,7 +64418,7 @@
64381
64418
  "group": "Request",
64382
64419
  "plug": "output",
64383
64420
  "type": {
64384
- "name": "boolean"
64421
+ "name": "string"
64385
64422
  },
64386
64423
  "isSignal": false,
64387
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) {