@nodegx/mcp 0.2.1 → 0.2.3

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.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodegx-mcp-bundles",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "private": true,
5
5
  "description": "Build metadata for the bundled CLIs beside it. See build-cloud-bundle.mjs: @noodl/platform reads it."
6
6
  }
@@ -37470,6 +37470,18 @@
37470
37470
  "isSignal": false,
37471
37471
  "description": "Object path inside the bucket, such as avatars/me.png. Supabase only. Leave blank to use the file's own name"
37472
37472
  },
37473
+ {
37474
+ "name": "photoQuality",
37475
+ "displayName": "Photo Quality",
37476
+ "group": "Shrink Photos",
37477
+ "plug": "input",
37478
+ "type": {
37479
+ "name": "number"
37480
+ },
37481
+ "isSignal": false,
37482
+ "default": 80,
37483
+ "description": "How much detail a shrunk JPEG or WebP keeps, from 1 to 100; PNG keeps every pixel and its transparency, GIF and SVG are never shrunk"
37484
+ },
37473
37485
  {
37474
37486
  "name": "private",
37475
37487
  "displayName": "Private",
@@ -37482,6 +37494,17 @@
37482
37494
  "default": false,
37483
37495
  "description": "Restricts the stored file to whoever uploaded it, so reading it later needs a Sign File URL node"
37484
37496
  },
37497
+ {
37498
+ "name": "readers",
37499
+ "displayName": "Readers",
37500
+ "group": "General",
37501
+ "plug": "input",
37502
+ "type": {
37503
+ "name": "string"
37504
+ },
37505
+ "isSignal": false,
37506
+ "description": "Roles that may read the file as well as whoever uploaded it, such as role:member, separated by commas; setting it makes the file private. NodeGX backends only"
37507
+ },
37485
37508
  {
37486
37509
  "name": "recordId",
37487
37510
  "displayName": "Record ID (PocketBase)",
@@ -37493,6 +37516,18 @@
37493
37516
  "isSignal": false,
37494
37517
  "description": "Existing record to attach the file to. PocketBase only. Leave blank to create a new record as part of the upload"
37495
37518
  },
37519
+ {
37520
+ "name": "shrinkTo",
37521
+ "displayName": "Shrink To",
37522
+ "group": "Shrink Photos",
37523
+ "plug": "input",
37524
+ "type": {
37525
+ "name": "number"
37526
+ },
37527
+ "isSignal": false,
37528
+ "default": 0,
37529
+ "description": "Makes a photo smaller in the browser before sending it, so its longer side is at most this many pixels; 0 sends every file as picked"
37530
+ },
37496
37531
  {
37497
37532
  "name": "upload",
37498
37533
  "displayName": "Upload",
@@ -37635,6 +37670,15 @@
37635
37670
  "private": [
37636
37671
  "always: held as handed; read when the next Upload goes"
37637
37672
  ],
37673
+ "readers": [
37674
+ "always: held as handed; read when the next Upload goes — set, those roles may read the file as well as whoever uploaded it, and the file is private"
37675
+ ],
37676
+ "shrinkTo": [
37677
+ "always: held as handed; read when the next Upload goes — above 0, a photo (not a GIF or an SVG) is drawn smaller in the browser first, its longer side at most this many pixels, and the smaller file is what goes; it goes as picked when it is already that small, when the smaller one would weigh more, or when the browser cannot read it"
37678
+ ],
37679
+ "photoQuality": [
37680
+ "always: held as handed; read when the next Upload goes, and only when a photo is shrunk — 1 to 100 for a JPEG or WebP, anything else is 80; a PNG keeps every pixel"
37681
+ ],
37638
37682
  "bucket": [
37639
37683
  "always: held as handed; read when the next Upload goes — set, the file goes to this bucket at Path (or under the file's own name)"
37640
37684
  ],
@@ -37651,7 +37695,7 @@
37651
37695
  "always: held as handed; read when the next Upload goes, and only with Collection — a half-filled group is no location at all"
37652
37696
  ],
37653
37697
  "upload": [
37654
- "always: its own upload at the frame's end — two presses are two uploads — with File, Private and the File Location held then: no File, or a Backend the project does not have, is Error, Error Status Code 0 and Failure at once; otherwise the backend answers with Cloud File then Done, or Error, Error Status Code and Failure"
37698
+ "always: its own upload at the frame's end — two presses are two uploads — with File, Private, Readers and the File Location held then: no File, or a Backend the project does not have, is Error, Error Status Code 0 and Failure at once; otherwise the backend answers with Cloud File then Done, or Error, Error Status Code and Failure"
37655
37699
  ]
37656
37700
  },
37657
37701
  "enrichment": {
@@ -34883,6 +34883,18 @@
34883
34883
  "isSignal": false,
34884
34884
  "description": "Object path inside the bucket, such as avatars/me.png. Supabase only. Leave blank to use the file's own name"
34885
34885
  },
34886
+ {
34887
+ "name": "photoQuality",
34888
+ "displayName": "Photo Quality",
34889
+ "group": "Shrink Photos",
34890
+ "plug": "input",
34891
+ "type": {
34892
+ "name": "number"
34893
+ },
34894
+ "isSignal": false,
34895
+ "default": 80,
34896
+ "description": "How much detail a shrunk JPEG or WebP keeps, from 1 to 100; PNG keeps every pixel and its transparency, GIF and SVG are never shrunk"
34897
+ },
34886
34898
  {
34887
34899
  "name": "private",
34888
34900
  "displayName": "Private",
@@ -34895,6 +34907,17 @@
34895
34907
  "default": false,
34896
34908
  "description": "Restricts the stored file to whoever uploaded it, so reading it later needs a Sign File URL node"
34897
34909
  },
34910
+ {
34911
+ "name": "readers",
34912
+ "displayName": "Readers",
34913
+ "group": "General",
34914
+ "plug": "input",
34915
+ "type": {
34916
+ "name": "string"
34917
+ },
34918
+ "isSignal": false,
34919
+ "description": "Roles that may read the file as well as whoever uploaded it, such as role:member, separated by commas; setting it makes the file private. NodeGX backends only"
34920
+ },
34898
34921
  {
34899
34922
  "name": "recordId",
34900
34923
  "displayName": "Record ID (PocketBase)",
@@ -34906,6 +34929,18 @@
34906
34929
  "isSignal": false,
34907
34930
  "description": "Existing record to attach the file to. PocketBase only. Leave blank to create a new record as part of the upload"
34908
34931
  },
34932
+ {
34933
+ "name": "shrinkTo",
34934
+ "displayName": "Shrink To",
34935
+ "group": "Shrink Photos",
34936
+ "plug": "input",
34937
+ "type": {
34938
+ "name": "number"
34939
+ },
34940
+ "isSignal": false,
34941
+ "default": 0,
34942
+ "description": "Makes a photo smaller in the browser before sending it, so its longer side is at most this many pixels; 0 sends every file as picked"
34943
+ },
34909
34944
  {
34910
34945
  "name": "upload",
34911
34946
  "displayName": "Upload",
@@ -35048,6 +35083,15 @@
35048
35083
  "private": [
35049
35084
  "always: held as handed; read when the next Upload goes"
35050
35085
  ],
35086
+ "readers": [
35087
+ "always: held as handed; read when the next Upload goes — set, those roles may read the file as well as whoever uploaded it, and the file is private"
35088
+ ],
35089
+ "shrinkTo": [
35090
+ "always: held as handed; read when the next Upload goes — above 0, a photo (not a GIF or an SVG) is drawn smaller in the browser first, its longer side at most this many pixels, and the smaller file is what goes; it goes as picked when it is already that small, when the smaller one would weigh more, or when the browser cannot read it"
35091
+ ],
35092
+ "photoQuality": [
35093
+ "always: held as handed; read when the next Upload goes, and only when a photo is shrunk — 1 to 100 for a JPEG or WebP, anything else is 80; a PNG keeps every pixel"
35094
+ ],
35051
35095
  "bucket": [
35052
35096
  "always: held as handed; read when the next Upload goes — set, the file goes to this bucket at Path (or under the file's own name)"
35053
35097
  ],
@@ -35064,7 +35108,7 @@
35064
35108
  "always: held as handed; read when the next Upload goes, and only with Collection — a half-filled group is no location at all"
35065
35109
  ],
35066
35110
  "upload": [
35067
- "always: its own upload at the frame's end — two presses are two uploads — with File, Private and the File Location held then: no File, or a Backend the project does not have, is Error, Error Status Code 0 and Failure at once; otherwise the backend answers with Cloud File then Done, or Error, Error Status Code and Failure"
35111
+ "always: its own upload at the frame's end — two presses are two uploads — with File, Private, Readers and the File Location held then: no File, or a Backend the project does not have, is Error, Error Status Code 0 and Failure at once; otherwise the backend answers with Cloud File then Done, or Error, Error Status Code and Failure"
35068
35112
  ]
35069
35113
  }
35070
35114
  },
@@ -551,7 +551,10 @@ var require_src = __commonJS({
551
551
  // two parts, a centred heading block and a toggle-and-price group ~260 px left of it.
552
552
  const byTop = [...inks].sort((a, b) => a.top - b.top);
553
553
  if (!byTop.every((k, i) => i === 0 || k.top >= byTop[i - 1].bottom - 2)) continue;
554
- if (byTop.some((k) => k.height > 240)) continue;
554
+ // Two or more tall parts, not one: RL1's waitlist is eyebrow, heading, line and a 290 px form card in ONE group,
555
+ // and "any part > 240" skipped it \u2014 Richard saw the eyebrow off the centred heading (s6). A page of sections
556
+ // has many tall children; a section with one card has one.
557
+ if (byTop.filter((k) => k.height > 240).length > 1) continue;
555
558
  const centre = pr.left + pr.width / 2;
556
559
  const offOf = (k) => k.left + k.width / 2 - centre;
557
560
  const centred = byTop.filter((k) => Math.abs(offOf(k)) <= 12 && k.width < pr.width * 0.9);
@@ -564,6 +567,47 @@ var require_src = __commonJS({
564
567
  }
565
568
  }
566
569
 
570
+ // P112-OWN019-LETTEREDGE \u2014 words hard against the edge of the box that frames them. Richard on TL2's hero: seven
571
+ // outlined day chips, each letter pressed to its chip's left edge. The chip Group centred its child the way CSS
572
+ // would, but a Text stretches to its Group's width and sets its letters left, so the centring moved nothing. The
573
+ // frame is the nearest of the text's first four ancestors with a border or a fill (a page-wide band is not a frame,
574
+ // nor an inline highlight); the reading is the gap between the words' own ink (a Range) and the frame's inner left
575
+ // and right edges. Every width: a chip is a chip on a phone too. Raw numbers here; the judgement is summarise's.
576
+ layout.edgeTexts = [];
577
+ const EDGE_TEXT_MAX_GAP = 3;
578
+ if (typeof document.createRange === 'function' && typeof document.createTreeWalker === 'function') {
579
+ const shown = new Set(visible);
580
+ const range = document.createRange();
581
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
582
+ const framed = (el) => {
583
+ const cs = styleOf(el);
584
+ if (cs.display === 'inline') return false;
585
+ const bg = cs.backgroundColor;
586
+ return (bg && bg !== 'transparent' && !/rgba\\([^)]*,\\s*0\\)$/.test(bg)) || cs.backgroundImage !== 'none' ||
587
+ parseFloat(cs.borderLeftWidth) > 0 || parseFloat(cs.borderRightWidth) > 0;
588
+ };
589
+ const frames = new Set();
590
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
591
+ const text = n.textContent.trim();
592
+ if (!text || !shown.has(n.parentElement)) continue;
593
+ let frame = null;
594
+ for (let e = n.parentElement, i = 0; e && e !== document.body && i < 4; e = e.parentElement, i++) {
595
+ if (framed(e)) { frame = e; break; }
596
+ }
597
+ if (!frame || frames.has(frame)) continue;
598
+ const fr = frame.getBoundingClientRect();
599
+ if (fr.width < 16 || fr.width >= vw * 0.98) continue;
600
+ range.selectNodeContents(n);
601
+ const r = range.getBoundingClientRect();
602
+ if (!(r.width > 0 && r.height > 0)) continue;
603
+ const fcs = styleOf(frame);
604
+ const left = r.left - (fr.left + (parseFloat(fcs.borderLeftWidth) || 0));
605
+ const right = fr.right - (parseFloat(fcs.borderRightWidth) || 0) - r.right;
606
+ frames.add(frame);
607
+ if (Math.min(left, right) < EDGE_TEXT_MAX_GAP) layout.edgeTexts.push({ cls: cls(frame), text: text.slice(0, 50), left: round(left), right: round(right), frameWidth: round(fr.width), frameHeight: round(fr.height) });
608
+ }
609
+ }
610
+
567
611
  const contentBottom = visible.reduce((lowest, el) => {
568
612
  const r = el.getBoundingClientRect();
569
613
  return r.width > 0 && r.height > 0 && r.bottom > lowest ? r.bottom : lowest;
@@ -718,7 +762,12 @@ var require_src = __commonJS({
718
762
  */
719
763
  RowGap: "row-gap",
720
764
  /** P112-OWN019-LAPTOPGAPS — a section that centres its heading and leaves another part off that centre (L1's price). */
721
- AlignmentDrift: "alignment-drift"
765
+ AlignmentDrift: "alignment-drift",
766
+ /**
767
+ * P112-OWN019-LETTEREDGE (Richard 2026-10-15, TL2's hero). Words within 3 px of the inner left or right edge of the
768
+ * box that frames them (a border or a fill) — a letter in an outlined chip set left because its Text stretched.
769
+ */
770
+ TextAgainstEdge: "text-against-edge"
722
771
  };
723
772
  var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
724
773
  var ROW_GAP_MIN_TALLEST = 48;
@@ -939,7 +988,7 @@ var require_src = __commonJS({
939
988
  });
940
989
  }
941
990
  if (isDesktop && v.layout) {
942
- const gaps = (v.layout.rowGaps || []).filter((g) => g.tallest >= ROW_GAP_MIN_TALLEST && g.gap >= g.rowWidth * ROW_GAP_MIN_SHARE);
991
+ const gaps = (v.layout.rowGaps || []).filter((g) => (g.left || g.right) && g.tallest >= ROW_GAP_MIN_TALLEST && g.gap >= g.rowWidth * ROW_GAP_MIN_SHARE);
943
992
  const seen2 = /* @__PURE__ */ new Set();
944
993
  for (const g of gaps) {
945
994
  const key = `${g.left}|${g.right}`;
@@ -964,6 +1013,17 @@ var require_src = __commonJS({
964
1013
  });
965
1014
  }
966
1015
  }
1016
+ const edges = v.layout && v.layout.edgeTexts || [];
1017
+ if (edges.length) {
1018
+ const named = edges.slice(0, 3).map((e) => `"${e.text}" ${Math.max(0, Math.min(e.left, e.right))}px from the ${e.left <= e.right ? "left" : "right"} edge of its ${e.frameWidth}\xD7${e.frameHeight}px box`);
1019
+ add({
1020
+ code: RenderFinding.TextAgainstEdge,
1021
+ severity: "warning",
1022
+ viewport: name,
1023
+ message: `${plural(edges.length, "text sits", "texts sit")} hard against the edge of the outlined or filled box that frames ${edges.length === 1 ? "it" : "them"}: ${named.join("; ")}. Centring a Group does not centre a Text's letters \u2014 the Text stretches to the Group's width and sets them left \u2014 so give the Text textAlignX: center, or give the box padding. Look at this width's screenshot.`,
1024
+ evidence: edges.slice(0, 6)
1025
+ });
1026
+ }
967
1027
  if (v.consoleErrors && v.consoleErrors.length) {
968
1028
  add({
969
1029
  code: RenderFinding.ConsoleError,
@@ -1970,6 +2030,11 @@ var require_render_report = __commonJS({
1970
2030
  "--no-default-browser-check",
1971
2031
  "--hide-scrollbars",
1972
2032
  "--disable-gpu",
2033
+ // PARKED-THREE-S3-NOWEBGL: `--disable-gpu` alone leaves no WebGL, so a three.js scene (three-kit, garden-3d-kit)
2034
+ // drew its "No WebGL here" fallback to the agent. Software GL brings WebGL back with no GPU (measured 2026-10-19 in
2035
+ // this harness: gl false → true, the three-kit fixture's 12 meshes drawn); a page with no WebGL never touches it.
2036
+ "--use-angle=swiftshader",
2037
+ "--enable-unsafe-swiftshader",
1973
2038
  // P112-S14-KEYCHAIN: on macOS a page that touches storage makes Chrome open the login keychain. With no
1974
2039
  // keychain where `HOME` points (an empty HOME, a CI user) the OS shows "Keychain Not Found" on the person's
1975
2040
  // screen and Chrome waits for the answer, so the render hangs until its 240 s kill. A throwaway profile
@@ -2761,7 +2826,11 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
2761
2826
  } else if (verb === "watch") {
2762
2827
  const w = step.watch && typeof step.watch === "object" ? step.watch : {};
2763
2828
  if (w.look === void 0) throw new UsageError(`Step ${i + 1}: watch takes { look: T } or { look: [T, T, \u2026] }.`);
2764
- const forMs = Math.min(1e4, Math.max(100, Number(step.for_ms ?? 1500)));
2829
+ const unknown = Object.keys(w).filter((k) => !["look", "for_ms", "expect"].includes(k));
2830
+ if (unknown.length) throw new UsageError(`Step ${i + 1}: watch does not take ${unknown.map((k) => JSON.stringify(k)).join(", ")} \u2014 it takes look, for_ms and expect.`);
2831
+ if (step.expect !== void 0 && w.expect !== void 0) throw new UsageError(`Step ${i + 1}: give watch one expect, beside it or inside it, not both.`);
2832
+ const forMs = Math.min(1e4, Math.max(100, Number(step.for_ms ?? w.for_ms ?? 1500)));
2833
+ const expect = step.expect !== void 0 ? step.expect : w.expect;
2765
2834
  const many = Array.isArray(w.look);
2766
2835
  const targets = many ? w.look : [w.look];
2767
2836
  if (targets.length === 0 || targets.length > 8) throw new UsageError(`Step ${i + 1}: watch looks at 1 to 8 targets.`);
@@ -2782,7 +2851,7 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
2782
2851
  }
2783
2852
  const shown = (list) => list.map(({ from, until, look }) => ({ from, until, look }));
2784
2853
  row.value = many ? targets.map((t, k) => ({ target: t, states: shown(states[k]) })) : shown(states[0]);
2785
- const never = step.expect && typeof step.expect === "object" ? step.expect.never : void 0;
2854
+ const never = expect && typeof expect === "object" ? expect.never : void 0;
2786
2855
  if (never && typeof never === "object") {
2787
2856
  for (let k = 0; k < targets.length && row.ok; k++) {
2788
2857
  const hit = states[k].find((st) => st.look && Object.keys(never).every((key) => matches(st.look[key], never[key])));
@@ -551,7 +551,10 @@ var require_src = __commonJS({
551
551
  // two parts, a centred heading block and a toggle-and-price group ~260 px left of it.
552
552
  const byTop = [...inks].sort((a, b) => a.top - b.top);
553
553
  if (!byTop.every((k, i) => i === 0 || k.top >= byTop[i - 1].bottom - 2)) continue;
554
- if (byTop.some((k) => k.height > 240)) continue;
554
+ // Two or more tall parts, not one: RL1's waitlist is eyebrow, heading, line and a 290 px form card in ONE group,
555
+ // and "any part > 240" skipped it \u2014 Richard saw the eyebrow off the centred heading (s6). A page of sections
556
+ // has many tall children; a section with one card has one.
557
+ if (byTop.filter((k) => k.height > 240).length > 1) continue;
555
558
  const centre = pr.left + pr.width / 2;
556
559
  const offOf = (k) => k.left + k.width / 2 - centre;
557
560
  const centred = byTop.filter((k) => Math.abs(offOf(k)) <= 12 && k.width < pr.width * 0.9);
@@ -564,6 +567,47 @@ var require_src = __commonJS({
564
567
  }
565
568
  }
566
569
 
570
+ // P112-OWN019-LETTEREDGE \u2014 words hard against the edge of the box that frames them. Richard on TL2's hero: seven
571
+ // outlined day chips, each letter pressed to its chip's left edge. The chip Group centred its child the way CSS
572
+ // would, but a Text stretches to its Group's width and sets its letters left, so the centring moved nothing. The
573
+ // frame is the nearest of the text's first four ancestors with a border or a fill (a page-wide band is not a frame,
574
+ // nor an inline highlight); the reading is the gap between the words' own ink (a Range) and the frame's inner left
575
+ // and right edges. Every width: a chip is a chip on a phone too. Raw numbers here; the judgement is summarise's.
576
+ layout.edgeTexts = [];
577
+ const EDGE_TEXT_MAX_GAP = 3;
578
+ if (typeof document.createRange === 'function' && typeof document.createTreeWalker === 'function') {
579
+ const shown = new Set(visible);
580
+ const range = document.createRange();
581
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
582
+ const framed = (el) => {
583
+ const cs = styleOf(el);
584
+ if (cs.display === 'inline') return false;
585
+ const bg = cs.backgroundColor;
586
+ return (bg && bg !== 'transparent' && !/rgba\\([^)]*,\\s*0\\)$/.test(bg)) || cs.backgroundImage !== 'none' ||
587
+ parseFloat(cs.borderLeftWidth) > 0 || parseFloat(cs.borderRightWidth) > 0;
588
+ };
589
+ const frames = new Set();
590
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
591
+ const text = n.textContent.trim();
592
+ if (!text || !shown.has(n.parentElement)) continue;
593
+ let frame = null;
594
+ for (let e = n.parentElement, i = 0; e && e !== document.body && i < 4; e = e.parentElement, i++) {
595
+ if (framed(e)) { frame = e; break; }
596
+ }
597
+ if (!frame || frames.has(frame)) continue;
598
+ const fr = frame.getBoundingClientRect();
599
+ if (fr.width < 16 || fr.width >= vw * 0.98) continue;
600
+ range.selectNodeContents(n);
601
+ const r = range.getBoundingClientRect();
602
+ if (!(r.width > 0 && r.height > 0)) continue;
603
+ const fcs = styleOf(frame);
604
+ const left = r.left - (fr.left + (parseFloat(fcs.borderLeftWidth) || 0));
605
+ const right = fr.right - (parseFloat(fcs.borderRightWidth) || 0) - r.right;
606
+ frames.add(frame);
607
+ if (Math.min(left, right) < EDGE_TEXT_MAX_GAP) layout.edgeTexts.push({ cls: cls(frame), text: text.slice(0, 50), left: round(left), right: round(right), frameWidth: round(fr.width), frameHeight: round(fr.height) });
608
+ }
609
+ }
610
+
567
611
  const contentBottom = visible.reduce((lowest, el) => {
568
612
  const r = el.getBoundingClientRect();
569
613
  return r.width > 0 && r.height > 0 && r.bottom > lowest ? r.bottom : lowest;
@@ -718,7 +762,12 @@ var require_src = __commonJS({
718
762
  */
719
763
  RowGap: "row-gap",
720
764
  /** P112-OWN019-LAPTOPGAPS — a section that centres its heading and leaves another part off that centre (L1's price). */
721
- AlignmentDrift: "alignment-drift"
765
+ AlignmentDrift: "alignment-drift",
766
+ /**
767
+ * P112-OWN019-LETTEREDGE (Richard 2026-10-15, TL2's hero). Words within 3 px of the inner left or right edge of the
768
+ * box that frames them (a border or a fill) — a letter in an outlined chip set left because its Text stretched.
769
+ */
770
+ TextAgainstEdge: "text-against-edge"
722
771
  };
723
772
  var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
724
773
  var ROW_GAP_MIN_TALLEST = 48;
@@ -939,7 +988,7 @@ var require_src = __commonJS({
939
988
  });
940
989
  }
941
990
  if (isDesktop && v.layout) {
942
- const gaps = (v.layout.rowGaps || []).filter((g) => g.tallest >= ROW_GAP_MIN_TALLEST && g.gap >= g.rowWidth * ROW_GAP_MIN_SHARE);
991
+ const gaps = (v.layout.rowGaps || []).filter((g) => (g.left || g.right) && g.tallest >= ROW_GAP_MIN_TALLEST && g.gap >= g.rowWidth * ROW_GAP_MIN_SHARE);
943
992
  const seen2 = /* @__PURE__ */ new Set();
944
993
  for (const g of gaps) {
945
994
  const key = `${g.left}|${g.right}`;
@@ -964,6 +1013,17 @@ var require_src = __commonJS({
964
1013
  });
965
1014
  }
966
1015
  }
1016
+ const edges = v.layout && v.layout.edgeTexts || [];
1017
+ if (edges.length) {
1018
+ const named = edges.slice(0, 3).map((e) => `"${e.text}" ${Math.max(0, Math.min(e.left, e.right))}px from the ${e.left <= e.right ? "left" : "right"} edge of its ${e.frameWidth}\xD7${e.frameHeight}px box`);
1019
+ add({
1020
+ code: RenderFinding.TextAgainstEdge,
1021
+ severity: "warning",
1022
+ viewport: name,
1023
+ message: `${plural(edges.length, "text sits", "texts sit")} hard against the edge of the outlined or filled box that frames ${edges.length === 1 ? "it" : "them"}: ${named.join("; ")}. Centring a Group does not centre a Text's letters \u2014 the Text stretches to the Group's width and sets them left \u2014 so give the Text textAlignX: center, or give the box padding. Look at this width's screenshot.`,
1024
+ evidence: edges.slice(0, 6)
1025
+ });
1026
+ }
967
1027
  if (v.consoleErrors && v.consoleErrors.length) {
968
1028
  add({
969
1029
  code: RenderFinding.ConsoleError,
@@ -1970,6 +2030,11 @@ var require_render_report = __commonJS({
1970
2030
  "--no-default-browser-check",
1971
2031
  "--hide-scrollbars",
1972
2032
  "--disable-gpu",
2033
+ // PARKED-THREE-S3-NOWEBGL: `--disable-gpu` alone leaves no WebGL, so a three.js scene (three-kit, garden-3d-kit)
2034
+ // drew its "No WebGL here" fallback to the agent. Software GL brings WebGL back with no GPU (measured 2026-10-19 in
2035
+ // this harness: gl false → true, the three-kit fixture's 12 meshes drawn); a page with no WebGL never touches it.
2036
+ "--use-angle=swiftshader",
2037
+ "--enable-unsafe-swiftshader",
1973
2038
  // P112-S14-KEYCHAIN: on macOS a page that touches storage makes Chrome open the login keychain. With no
1974
2039
  // keychain where `HOME` points (an empty HOME, a CI user) the OS shows "Keychain Not Found" on the person's
1975
2040
  // screen and Chrome waits for the answer, so the render hangs until its 240 s kill. A throwaway profile
@@ -592,6 +592,19 @@ declare namespace Noodl {
592
592
  onProgress?: (progress: number) => void;
593
593
  }
594
594
  ): Promise<CloudFile>;
595
+
596
+ /**
597
+ * A link an image can use for a private file, the way the Sign File URL node mints one.
598
+ * Takes a CloudFile, a record's file (or an item of a list of them), or the stored name.
599
+ * Rejects for someone who could not read the file itself.
600
+ *
601
+ * Example:
602
+ * ```ts
603
+ * const link = await Noodl.Files.sign(post.photos[0])
604
+ * console.log(link.url, link.expiresAt)
605
+ * ```
606
+ */
607
+ sign(file: CloudFile | { name: string } | string): Promise<{ url: string; kind: string; expiresAt?: string; ttlSeconds?: number }>;
595
608
  }
596
609
 
597
610
  /**