@nodegx/mcp 0.2.2 → 0.2.4

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.2",
3
+ "version": "0.2.4",
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
  },
@@ -174,6 +174,52 @@ var require_src = __commonJS({
174
174
  .filter((x) => x.box > 0 && x.width > x.box + 1)
175
175
  .map((x) => ({ text: x.el.textContent.trim().slice(0, 80), width: round(x.width), parentWidth: round(x.box), cls: cls(x.el) }));
176
176
 
177
+ // P112-OWN020-OFFSYSTEM \u2014 the controls, measured against the project's type scale. A control nobody styled draws
178
+ // the runtime's own look (a Radio Button is a 32px circle with a 2px black ring; a native select or button keeps
179
+ // the browser's 13.33px text), and that is the "chunky" control beside a page on tokens. A radio or checkbox input
180
+ // is opacity 0, so its box is read on the first ancestor a person can see. The scale is the project's --text-*
181
+ // tokens, read off :root; the fake DOMs in the tests have no getPropertyValue, so it is guarded.
182
+ const pxOf = (value) => {
183
+ const s = String(value || '').trim();
184
+ if (!s) return NaN;
185
+ if (s.slice(-3) === 'rem') return parseFloat(s) * (parseFloat(styleOf(document.documentElement).fontSize) || 16);
186
+ return parseFloat(s);
187
+ };
188
+ const textScale = [];
189
+ const rootStyle = styleOf(document.documentElement);
190
+ if (rootStyle && typeof rootStyle.getPropertyValue === 'function') {
191
+ for (const step of ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl']) {
192
+ const px = pxOf(rootStyle.getPropertyValue('--text-' + step));
193
+ if (px > 0) textScale.push(Math.round(px * 100) / 100);
194
+ }
195
+ }
196
+ const controlKind = (el) => {
197
+ const type = String(el.type || '').toLowerCase();
198
+ if (el.tagName === 'INPUT' && type === 'radio') return 'Radio Button';
199
+ if (el.tagName === 'INPUT' && type === 'checkbox') return 'Checkbox';
200
+ if (el.tagName === 'SELECT') return 'Dropdown';
201
+ if (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && ['', 'text', 'email', 'password', 'search', 'number', 'tel', 'url'].indexOf(type) !== -1)) return 'Text Input';
202
+ if (el.tagName === 'BUTTON') return 'Button';
203
+ return null;
204
+ };
205
+ const controls = [];
206
+ for (const el of all) {
207
+ const kind = controlKind(el);
208
+ if (!kind) continue;
209
+ if (kind === 'Radio Button' || kind === 'Checkbox') {
210
+ let shown = el;
211
+ for (let hops = 0; shown && hops < 3 && (parseFloat(styleOf(shown).opacity) < 0.05 || shown.getBoundingClientRect().width < 2); hops++) shown = shown.parentElement;
212
+ if (!shown || shown.offsetParent === null) continue;
213
+ const r = shown.getBoundingClientRect();
214
+ let label = null;
215
+ if (el.id && typeof document.querySelector === 'function') label = document.querySelector('label[for="' + el.id + '"]');
216
+ controls.push({ kind, box: round(Math.max(r.width, r.height)), textSize: label ? pxOf(styleOf(label).fontSize) : NaN });
217
+ } else {
218
+ if (visible.indexOf(el) === -1) continue;
219
+ controls.push({ kind, box: 0, textSize: pxOf(styleOf(el).fontSize) });
220
+ }
221
+ }
222
+
177
223
  // AWP-004 \u2014 what is on screen, as opposed to what is in the DOM. The page is
178
224
  // never scrolled when this runs, so a rect is already a document position.
179
225
  // Kimi K3's storefront was 83 texts in the DOM and about three on screen, and
@@ -632,6 +678,7 @@ var require_src = __commonJS({
632
678
  overflowingCount: overflowing.length,
633
679
  textsWiderThanBox: textsWiderThanBox.slice(0, 10),
634
680
  textsWiderThanBoxCount: textsWiderThanBox.length,
681
+ controls: { textScale, items: controls.slice(0, 60), count: controls.length },
635
682
  text: {
636
683
  elements: textEls.length,
637
684
  onScreen: textEls.filter(onScreen).length,
@@ -721,6 +768,13 @@ var require_src = __commonJS({
721
768
  * page-level finding stayed quiet because the page itself kept its width.
722
769
  */
723
770
  TextWiderThanItsBox: "text-wider-than-its-box",
771
+ /**
772
+ * P112-OWN020-OFFSYSTEM — a control drawn at the runtime's own size beside a page on its token scale: a radio or
773
+ * checkbox box much bigger than its label, or control text off the project's --text-* scale (a native select or
774
+ * button keeps the browser's 13.33px). Mistral (SP1) and DeepSeek (OpenTune) both shipped one — the "chunky"
775
+ * Sort-by circles — because a control placed in passing is the one an agent never styles.
776
+ */
777
+ ControlOffScale: "control-off-scale",
724
778
  SingleColumnGrid: "single-column-grid",
725
779
  MinimumLayoutWidth: "minimum-layout-width",
726
780
  HorizontalOverflow: "horizontal-overflow",
@@ -789,9 +843,30 @@ var require_src = __commonJS({
789
843
  const named = widest.cls ? `${widest.tag.toLowerCase()}.${widest.cls.split(/\s+/)[0]}` : widest.tag.toLowerCase();
790
844
  return `${named} at ${widest.width}px`;
791
845
  }
846
+ function controlsOffScale(controls) {
847
+ const scale = controls.textScale || [];
848
+ const onScale = (px) => scale.length === 0 || !(px > 0) || scale.some((s) => Math.abs(s - px) < 0.6);
849
+ const byKind = {};
850
+ for (const c of controls.items || []) {
851
+ const oversized = (c.kind === "Radio Button" || c.kind === "Checkbox") && c.box > Math.max(20, 1.5 * (c.textSize > 0 ? c.textSize : 0));
852
+ const offText = !onScale(c.textSize);
853
+ if (!oversized && !offText) continue;
854
+ const k = byKind[c.kind] || (byKind[c.kind] = { kind: c.kind, count: 0, box: c.box, textSize: c.textSize, oversized, offText });
855
+ k.count += 1;
856
+ }
857
+ const fmt = (n) => String(Math.round(n * 100) / 100);
858
+ return Object.keys(byKind).map((kind) => {
859
+ const k = byKind[kind];
860
+ const parts = [];
861
+ if (k.oversized) parts.push(fmt(k.box) + "px box" + (k.textSize > 0 ? " beside a " + fmt(k.textSize) + "px label" : ""));
862
+ if (k.offText) parts.push(fmt(k.textSize) + "px text, off the type scale (" + scale.map(fmt).join("/") + "px)");
863
+ return { kind, count: k.count, box: k.box, textSize: k.textSize, sentence: kind + " x" + k.count + ": " + parts.join(", ") };
864
+ });
865
+ }
792
866
  function summarise(viewports, diagnosis, overridden = {}) {
793
867
  const findings = [];
794
868
  const add = (f) => findings.push(f);
869
+ let controlsJudged = false;
795
870
  for (const [name, v] of Object.entries(viewports)) {
796
871
  if (!v || v.error) continue;
797
872
  const isDesktop = v.requested.width >= DESKTOP_WIDTH;
@@ -940,6 +1015,19 @@ var require_src = __commonJS({
940
1015
  evidence: v.textsWiderThanBox
941
1016
  });
942
1017
  }
1018
+ if (!controlsJudged && v.controls && v.controls.count > 0) {
1019
+ controlsJudged = true;
1020
+ const off = controlsOffScale(v.controls);
1021
+ if (off.length > 0) {
1022
+ add({
1023
+ code: RenderFinding.ControlOffScale,
1024
+ severity: "warning",
1025
+ viewport: name,
1026
+ message: "Controls drawn at a size the rest of the page is not: " + off.map((o) => o.sentence).join("; ") + `. A control nobody styled keeps the runtime's own look. Give the type one look for the whole app with set_element_style (find_tools group "theme"), or set_style_preset to add on-system styles for every control; a node that sets the parameter itself keeps its own value.`,
1027
+ evidence: off.map((o) => ({ kind: o.kind, count: o.count, box: o.box, textSize: o.textSize, textScale: v.controls.textScale }))
1028
+ });
1029
+ }
1030
+ }
943
1031
  if (v.text.elements >= 10) {
944
1032
  const weights = Object.keys(v.text.fontWeights);
945
1033
  if (weights.length <= 1 || v.text.distinctFontSizes <= 2) {
@@ -1110,6 +1198,8 @@ var require_src = __commonJS({
1110
1198
  plural,
1111
1199
  widestOffender,
1112
1200
  describeSites,
1201
+ // P112-OWN020-OFFSYSTEM — exported so its spec grades the judgement on readings, not only through a fake DOM.
1202
+ controlsOffScale,
1113
1203
  TEXT_BEARING_PORTS,
1114
1204
  placeholderStringsFromCatalog
1115
1205
  };
@@ -2030,6 +2120,11 @@ var require_render_report = __commonJS({
2030
2120
  "--no-default-browser-check",
2031
2121
  "--hide-scrollbars",
2032
2122
  "--disable-gpu",
2123
+ // PARKED-THREE-S3-NOWEBGL: `--disable-gpu` alone leaves no WebGL, so a three.js scene (three-kit, garden-3d-kit)
2124
+ // drew its "No WebGL here" fallback to the agent. Software GL brings WebGL back with no GPU (measured 2026-10-19 in
2125
+ // this harness: gl false → true, the three-kit fixture's 12 meshes drawn); a page with no WebGL never touches it.
2126
+ "--use-angle=swiftshader",
2127
+ "--enable-unsafe-swiftshader",
2033
2128
  // P112-S14-KEYCHAIN: on macOS a page that touches storage makes Chrome open the login keychain. With no
2034
2129
  // keychain where `HOME` points (an empty HOME, a CI user) the OS shows "Keychain Not Found" on the person's
2035
2130
  // screen and Chrome waits for the answer, so the render hangs until its 240 s kill. A throwaway profile
@@ -174,6 +174,52 @@ var require_src = __commonJS({
174
174
  .filter((x) => x.box > 0 && x.width > x.box + 1)
175
175
  .map((x) => ({ text: x.el.textContent.trim().slice(0, 80), width: round(x.width), parentWidth: round(x.box), cls: cls(x.el) }));
176
176
 
177
+ // P112-OWN020-OFFSYSTEM \u2014 the controls, measured against the project's type scale. A control nobody styled draws
178
+ // the runtime's own look (a Radio Button is a 32px circle with a 2px black ring; a native select or button keeps
179
+ // the browser's 13.33px text), and that is the "chunky" control beside a page on tokens. A radio or checkbox input
180
+ // is opacity 0, so its box is read on the first ancestor a person can see. The scale is the project's --text-*
181
+ // tokens, read off :root; the fake DOMs in the tests have no getPropertyValue, so it is guarded.
182
+ const pxOf = (value) => {
183
+ const s = String(value || '').trim();
184
+ if (!s) return NaN;
185
+ if (s.slice(-3) === 'rem') return parseFloat(s) * (parseFloat(styleOf(document.documentElement).fontSize) || 16);
186
+ return parseFloat(s);
187
+ };
188
+ const textScale = [];
189
+ const rootStyle = styleOf(document.documentElement);
190
+ if (rootStyle && typeof rootStyle.getPropertyValue === 'function') {
191
+ for (const step of ['xs', 'sm', 'base', 'lg', 'xl', '2xl', '3xl', '4xl', '5xl', '6xl']) {
192
+ const px = pxOf(rootStyle.getPropertyValue('--text-' + step));
193
+ if (px > 0) textScale.push(Math.round(px * 100) / 100);
194
+ }
195
+ }
196
+ const controlKind = (el) => {
197
+ const type = String(el.type || '').toLowerCase();
198
+ if (el.tagName === 'INPUT' && type === 'radio') return 'Radio Button';
199
+ if (el.tagName === 'INPUT' && type === 'checkbox') return 'Checkbox';
200
+ if (el.tagName === 'SELECT') return 'Dropdown';
201
+ if (el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && ['', 'text', 'email', 'password', 'search', 'number', 'tel', 'url'].indexOf(type) !== -1)) return 'Text Input';
202
+ if (el.tagName === 'BUTTON') return 'Button';
203
+ return null;
204
+ };
205
+ const controls = [];
206
+ for (const el of all) {
207
+ const kind = controlKind(el);
208
+ if (!kind) continue;
209
+ if (kind === 'Radio Button' || kind === 'Checkbox') {
210
+ let shown = el;
211
+ for (let hops = 0; shown && hops < 3 && (parseFloat(styleOf(shown).opacity) < 0.05 || shown.getBoundingClientRect().width < 2); hops++) shown = shown.parentElement;
212
+ if (!shown || shown.offsetParent === null) continue;
213
+ const r = shown.getBoundingClientRect();
214
+ let label = null;
215
+ if (el.id && typeof document.querySelector === 'function') label = document.querySelector('label[for="' + el.id + '"]');
216
+ controls.push({ kind, box: round(Math.max(r.width, r.height)), textSize: label ? pxOf(styleOf(label).fontSize) : NaN });
217
+ } else {
218
+ if (visible.indexOf(el) === -1) continue;
219
+ controls.push({ kind, box: 0, textSize: pxOf(styleOf(el).fontSize) });
220
+ }
221
+ }
222
+
177
223
  // AWP-004 \u2014 what is on screen, as opposed to what is in the DOM. The page is
178
224
  // never scrolled when this runs, so a rect is already a document position.
179
225
  // Kimi K3's storefront was 83 texts in the DOM and about three on screen, and
@@ -632,6 +678,7 @@ var require_src = __commonJS({
632
678
  overflowingCount: overflowing.length,
633
679
  textsWiderThanBox: textsWiderThanBox.slice(0, 10),
634
680
  textsWiderThanBoxCount: textsWiderThanBox.length,
681
+ controls: { textScale, items: controls.slice(0, 60), count: controls.length },
635
682
  text: {
636
683
  elements: textEls.length,
637
684
  onScreen: textEls.filter(onScreen).length,
@@ -721,6 +768,13 @@ var require_src = __commonJS({
721
768
  * page-level finding stayed quiet because the page itself kept its width.
722
769
  */
723
770
  TextWiderThanItsBox: "text-wider-than-its-box",
771
+ /**
772
+ * P112-OWN020-OFFSYSTEM — a control drawn at the runtime's own size beside a page on its token scale: a radio or
773
+ * checkbox box much bigger than its label, or control text off the project's --text-* scale (a native select or
774
+ * button keeps the browser's 13.33px). Mistral (SP1) and DeepSeek (OpenTune) both shipped one — the "chunky"
775
+ * Sort-by circles — because a control placed in passing is the one an agent never styles.
776
+ */
777
+ ControlOffScale: "control-off-scale",
724
778
  SingleColumnGrid: "single-column-grid",
725
779
  MinimumLayoutWidth: "minimum-layout-width",
726
780
  HorizontalOverflow: "horizontal-overflow",
@@ -789,9 +843,30 @@ var require_src = __commonJS({
789
843
  const named = widest.cls ? `${widest.tag.toLowerCase()}.${widest.cls.split(/\s+/)[0]}` : widest.tag.toLowerCase();
790
844
  return `${named} at ${widest.width}px`;
791
845
  }
846
+ function controlsOffScale(controls) {
847
+ const scale = controls.textScale || [];
848
+ const onScale = (px) => scale.length === 0 || !(px > 0) || scale.some((s) => Math.abs(s - px) < 0.6);
849
+ const byKind = {};
850
+ for (const c of controls.items || []) {
851
+ const oversized = (c.kind === "Radio Button" || c.kind === "Checkbox") && c.box > Math.max(20, 1.5 * (c.textSize > 0 ? c.textSize : 0));
852
+ const offText = !onScale(c.textSize);
853
+ if (!oversized && !offText) continue;
854
+ const k = byKind[c.kind] || (byKind[c.kind] = { kind: c.kind, count: 0, box: c.box, textSize: c.textSize, oversized, offText });
855
+ k.count += 1;
856
+ }
857
+ const fmt = (n) => String(Math.round(n * 100) / 100);
858
+ return Object.keys(byKind).map((kind) => {
859
+ const k = byKind[kind];
860
+ const parts = [];
861
+ if (k.oversized) parts.push(fmt(k.box) + "px box" + (k.textSize > 0 ? " beside a " + fmt(k.textSize) + "px label" : ""));
862
+ if (k.offText) parts.push(fmt(k.textSize) + "px text, off the type scale (" + scale.map(fmt).join("/") + "px)");
863
+ return { kind, count: k.count, box: k.box, textSize: k.textSize, sentence: kind + " x" + k.count + ": " + parts.join(", ") };
864
+ });
865
+ }
792
866
  function summarise(viewports, diagnosis, overridden = {}) {
793
867
  const findings = [];
794
868
  const add = (f) => findings.push(f);
869
+ let controlsJudged = false;
795
870
  for (const [name, v] of Object.entries(viewports)) {
796
871
  if (!v || v.error) continue;
797
872
  const isDesktop = v.requested.width >= DESKTOP_WIDTH;
@@ -940,6 +1015,19 @@ var require_src = __commonJS({
940
1015
  evidence: v.textsWiderThanBox
941
1016
  });
942
1017
  }
1018
+ if (!controlsJudged && v.controls && v.controls.count > 0) {
1019
+ controlsJudged = true;
1020
+ const off = controlsOffScale(v.controls);
1021
+ if (off.length > 0) {
1022
+ add({
1023
+ code: RenderFinding.ControlOffScale,
1024
+ severity: "warning",
1025
+ viewport: name,
1026
+ message: "Controls drawn at a size the rest of the page is not: " + off.map((o) => o.sentence).join("; ") + `. A control nobody styled keeps the runtime's own look. Give the type one look for the whole app with set_element_style (find_tools group "theme"), or set_style_preset to add on-system styles for every control; a node that sets the parameter itself keeps its own value.`,
1027
+ evidence: off.map((o) => ({ kind: o.kind, count: o.count, box: o.box, textSize: o.textSize, textScale: v.controls.textScale }))
1028
+ });
1029
+ }
1030
+ }
943
1031
  if (v.text.elements >= 10) {
944
1032
  const weights = Object.keys(v.text.fontWeights);
945
1033
  if (weights.length <= 1 || v.text.distinctFontSizes <= 2) {
@@ -1110,6 +1198,8 @@ var require_src = __commonJS({
1110
1198
  plural,
1111
1199
  widestOffender,
1112
1200
  describeSites,
1201
+ // P112-OWN020-OFFSYSTEM — exported so its spec grades the judgement on readings, not only through a fake DOM.
1202
+ controlsOffScale,
1113
1203
  TEXT_BEARING_PORTS,
1114
1204
  placeholderStringsFromCatalog
1115
1205
  };
@@ -2030,6 +2120,11 @@ var require_render_report = __commonJS({
2030
2120
  "--no-default-browser-check",
2031
2121
  "--hide-scrollbars",
2032
2122
  "--disable-gpu",
2123
+ // PARKED-THREE-S3-NOWEBGL: `--disable-gpu` alone leaves no WebGL, so a three.js scene (three-kit, garden-3d-kit)
2124
+ // drew its "No WebGL here" fallback to the agent. Software GL brings WebGL back with no GPU (measured 2026-10-19 in
2125
+ // this harness: gl false → true, the three-kit fixture's 12 meshes drawn); a page with no WebGL never touches it.
2126
+ "--use-angle=swiftshader",
2127
+ "--enable-unsafe-swiftshader",
2033
2128
  // P112-S14-KEYCHAIN: on macOS a page that touches storage makes Chrome open the login keychain. With no
2034
2129
  // keychain where `HOME` points (an empty HOME, a CI user) the OS shows "Keychain Not Found" on the person's
2035
2130
  // screen and Chrome waits for the answer, so the render hangs until its 240 s kill. A throwaway profile
@@ -7018,6 +7018,24 @@ function readScriptFiles() {
7018
7018
  walk(root);
7019
7019
  return scripts;
7020
7020
  }
7021
+ function readVariants() {
7022
+ const file = path.join(PROJECT, "nodegx.styles.json");
7023
+ if (!fs.existsSync(file)) return [];
7024
+ let styles;
7025
+ try {
7026
+ styles = readJSON(file);
7027
+ } catch {
7028
+ return [];
7029
+ }
7030
+ return (Array.isArray(styles.variants) ? styles.variants : []).filter((v) => v && typeof v.typename === "string").map((v) => ({
7031
+ ...typeof v.name === "string" && v.name !== "" ? { name: v.name } : {},
7032
+ typename: v.typename,
7033
+ parameters: v.parameters || {},
7034
+ stateParameters: v.stateParameters || v.stateParamaters || {},
7035
+ stateTransitions: v.stateTransitions || {},
7036
+ defaultStateTransitions: v.defaultStateTransitions || {}
7037
+ }));
7038
+ }
7021
7039
  function buildProjectData() {
7022
7040
  const project = readJSON(path.join(PROJECT, "nodegx.project.json"));
7023
7041
  const componentsDir = path.join(PROJECT, project.structure?.componentsDir || "components");
@@ -7132,7 +7150,8 @@ function buildProjectData() {
7132
7150
  componentIndex: {},
7133
7151
  rootComponent,
7134
7152
  routerIndex: { routers, pages },
7135
- scripts: readScriptFiles()
7153
+ scripts: readScriptFiles(),
7154
+ variants: readVariants()
7136
7155
  };
7137
7156
  }
7138
7157
  function projectOverrideDecls() {
@@ -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
  /**