@nodegx/mcp 0.1.0 → 0.2.0

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.
Files changed (64) hide show
  1. package/dist/backend/cli.js +102959 -0
  2. package/dist/cloud-bundle.cjs +35311 -2409
  3. package/dist/kit-extract.cjs +732 -545
  4. package/dist/kit-extract.inputs.json +115 -113
  5. package/dist/node-catalog.json +502 -59
  6. package/dist/noodl-mcp.cjs +44889 -9360
  7. package/dist/package.json +6 -0
  8. package/dist/preset-fonts/dm-sans/OFL.txt +93 -0
  9. package/dist/preset-fonts/dm-sans/dm-sans-latin-ext-wght-normal.woff2 +0 -0
  10. package/dist/preset-fonts/dm-sans/dm-sans-latin-wght-normal.woff2 +0 -0
  11. package/dist/preset-fonts/dm-sans/manifest.json +9 -0
  12. package/dist/preset-fonts/dm-sans/styles.css +25 -0
  13. package/dist/preset-fonts/nunito/OFL.txt +93 -0
  14. package/dist/preset-fonts/nunito/manifest.json +9 -0
  15. package/dist/preset-fonts/nunito/nunito-latin-ext-wght-normal.woff2 +0 -0
  16. package/dist/preset-fonts/nunito/nunito-latin-wght-normal.woff2 +0 -0
  17. package/dist/preset-fonts/nunito/styles.css +25 -0
  18. package/dist/preset-fonts/source-sans-3/OFL.txt +93 -0
  19. package/dist/preset-fonts/source-sans-3/manifest.json +9 -0
  20. package/dist/preset-fonts/source-sans-3/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
  21. package/dist/preset-fonts/source-sans-3/source-sans-3-latin-wght-normal.woff2 +0 -0
  22. package/dist/preset-fonts/source-sans-3/styles.css +25 -0
  23. package/dist/render-harness/data/node-catalog-enriched.json +93650 -0
  24. package/dist/render-harness/data/node-catalog.json +65289 -0
  25. package/dist/render-harness/data/tokens.ts +674 -0
  26. package/dist/render-harness/drive-steps.js +2784 -0
  27. package/dist/render-harness/measure-from-disk.js +2366 -0
  28. package/dist/render-harness/node_modules/ws/LICENSE +20 -0
  29. package/dist/render-harness/node_modules/ws/README.md +548 -0
  30. package/dist/render-harness/node_modules/ws/browser.js +8 -0
  31. package/dist/render-harness/node_modules/ws/index.js +22 -0
  32. package/dist/render-harness/node_modules/ws/lib/buffer-util.js +131 -0
  33. package/dist/render-harness/node_modules/ws/lib/constants.js +19 -0
  34. package/dist/render-harness/node_modules/ws/lib/event-target.js +292 -0
  35. package/dist/render-harness/node_modules/ws/lib/extension.js +203 -0
  36. package/dist/render-harness/node_modules/ws/lib/limiter.js +55 -0
  37. package/dist/render-harness/node_modules/ws/lib/permessage-deflate.js +528 -0
  38. package/dist/render-harness/node_modules/ws/lib/receiver.js +743 -0
  39. package/dist/render-harness/node_modules/ws/lib/sender.js +607 -0
  40. package/dist/render-harness/node_modules/ws/lib/stream.js +161 -0
  41. package/dist/render-harness/node_modules/ws/lib/subprotocol.js +62 -0
  42. package/dist/render-harness/node_modules/ws/lib/validation.js +152 -0
  43. package/dist/render-harness/node_modules/ws/lib/websocket-server.js +562 -0
  44. package/dist/render-harness/node_modules/ws/lib/websocket.js +1407 -0
  45. package/dist/render-harness/node_modules/ws/package.json +74 -0
  46. package/dist/render-harness/node_modules/ws/wrapper.mjs +21 -0
  47. package/dist/render-harness/render-from-disk.js +7306 -0
  48. package/dist/src/external/viewer/global.d.ts.keep +718 -0
  49. package/dist/src/external/viewer/index.html +126 -0
  50. package/dist/src/external/viewer/load_terminator.js +1 -0
  51. package/dist/src/external/viewer/ndl_assets/OpenSans-Bold.ttf +0 -0
  52. package/dist/src/external/viewer/ndl_assets/OpenSans-Regular.ttf +0 -0
  53. package/dist/src/external/viewer/ndl_assets/home-icon.svg +3 -0
  54. package/dist/src/external/viewer/ndl_assets/make-home-instructions@2x.png +0 -0
  55. package/dist/src/external/viewer/ndl_assets/noodl-logo-black.svg +3 -0
  56. package/dist/src/external/viewer/noodl-app.png +0 -0
  57. package/dist/src/external/viewer/noodl.viewer.js +2 -0
  58. package/dist/src/external/viewer/noodl.viewer.js.LICENSE.txt +20 -0
  59. package/dist/src/external/viewer/react-dom.production.min.js +267 -0
  60. package/dist/src/external/viewer/react.production.min.js +31 -0
  61. package/dist/src/external/viewer/react19/VERSION +2 -0
  62. package/dist/src/external/viewer/react19/react-dom.production.min.js +51 -0
  63. package/dist/src/external/viewer/react19/react.production.min.js +21 -0
  64. package/package.json +8 -2
@@ -0,0 +1,2366 @@
1
+ #!/usr/bin/env node
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __commonJS = (cb, mod) => function __require() {
9
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
20
+ // If the importer is in node compatibility mode or this is not an ESM
21
+ // file that has been converted to a CommonJS file using a Babel-
22
+ // compatible transform (i.e. "__esModule" has not been set), then set
23
+ // "default" to the CommonJS "module.exports" for node compatibility.
24
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
25
+ mod
26
+ ));
27
+
28
+ // ../../scripts/devtools/harness-paths.js
29
+ var require_harness_paths = __commonJS({
30
+ "../../scripts/devtools/harness-paths.js"(exports2, module2) {
31
+ var fs2 = require("fs");
32
+ var path2 = require("path");
33
+ var CHECKOUT_ROOT = path2.resolve(__dirname, "..", "..");
34
+ var PACKAGED_ROOT = path2.resolve(__dirname, "..");
35
+ var PACKAGED_DATA = path2.join(__dirname, "data");
36
+ function firstExisting(candidates) {
37
+ const probed = [];
38
+ for (const candidate of candidates) {
39
+ probed.push(candidate);
40
+ if (fs2.existsSync(candidate)) return { path: candidate, probed, candidates };
41
+ }
42
+ return { path: null, probed, candidates };
43
+ }
44
+ function resolveHarnessPath(checkoutRelative, packagedCandidate) {
45
+ return firstExisting([path2.join(CHECKOUT_ROOT, checkoutRelative), packagedCandidate]);
46
+ }
47
+ var VIEWER_DIR = resolveHarnessPath(
48
+ "packages/noodl-editor/src/external/viewer",
49
+ path2.join(PACKAGED_ROOT, "src", "external", "viewer")
50
+ );
51
+ var CATALOG_JSON = resolveHarnessPath(
52
+ "packages/noodl-types/src/node-catalog.json",
53
+ path2.join(PACKAGED_DATA, "node-catalog.json")
54
+ );
55
+ var ENRICHED_CATALOG_JSON = resolveHarnessPath(
56
+ "packages/noodl-types/src/node-catalog-enriched.json",
57
+ path2.join(PACKAGED_DATA, "node-catalog-enriched.json")
58
+ );
59
+ var TOKENS_SRC = resolveHarnessPath(
60
+ "packages/nodegx-project-contract/tokens.ts",
61
+ path2.join(PACKAGED_DATA, "tokens.ts")
62
+ );
63
+ var WS_MODULE = (() => {
64
+ const checkout = path2.join(CHECKOUT_ROOT, "node_modules", "ws");
65
+ const candidates = [checkout, "require('ws')"];
66
+ if (fs2.existsSync(checkout)) return { path: checkout, probed: [checkout], candidates, bare: false };
67
+ try {
68
+ require.resolve("ws");
69
+ return { path: "ws", probed: candidates, candidates, bare: true };
70
+ } catch {
71
+ return { path: null, probed: candidates, candidates, bare: false };
72
+ }
73
+ })();
74
+ module2.exports = {
75
+ CHECKOUT_ROOT,
76
+ PACKAGED_ROOT,
77
+ PACKAGED_DATA,
78
+ VIEWER_DIR,
79
+ CATALOG_JSON,
80
+ ENRICHED_CATALOG_JSON,
81
+ TOKENS_SRC,
82
+ WS_MODULE,
83
+ firstExisting,
84
+ resolveHarnessPath
85
+ };
86
+ }
87
+ });
88
+
89
+ // ../nodegx-render-measure/src/index.js
90
+ var require_src = __commonJS({
91
+ "../nodegx-render-measure/src/index.js"(exports2, module2) {
92
+ "use strict";
93
+ var DEFAULT_VIEWPORTS = [
94
+ { name: "desktop", width: 1280, height: 900, mobile: false },
95
+ { name: "phone", width: 390, height: 844, mobile: true }
96
+ ];
97
+ var NAMED_VIEWPORTS = [
98
+ { name: "desktop", width: 1280, height: 900, mobile: false },
99
+ { name: "tablet", width: 1024, height: 1366, mobile: false },
100
+ { name: "phone", width: 390, height: 844, mobile: true }
101
+ ];
102
+ var DESKTOP_WIDTH = 1024;
103
+ var GRID_ITEM_MIN_HEIGHT = 120;
104
+ var GRID_ITEM_MIN_WIDTH = 300;
105
+ var GRID_ITEM_PARENT_SHARE = 0.6;
106
+ var GRID_PARENT_VIEWPORT_SHARE = 0.5;
107
+ function measureExpression(placeholders, probes = []) {
108
+ return `(() => {
109
+ const PLACEHOLDERS = ${JSON.stringify(placeholders)};
110
+ const PROBES = ${JSON.stringify(probes)};
111
+ const round = (n) => Math.round(n);
112
+ const cls = (el) => String(el.className || '').trim().slice(0, 60);
113
+ const all = [...document.querySelectorAll('body *')];
114
+
115
+ // FLD-012 \u2014 one computed style per element, reused by every rule below. 'visible' now needs a
116
+ // style for EVERY element rather than only for the ones with no offset parent, and four rules
117
+ // downstream ask for the same element's style again. Without this the change would have paid for
118
+ // a second 'getComputedStyle' per empty box on every page.
119
+ const styles = new Map();
120
+ const styleOf = (el) => {
121
+ let cs = styles.get(el);
122
+ if (cs === undefined) {
123
+ cs = getComputedStyle(el);
124
+ styles.set(el, cs);
125
+ }
126
+ return cs;
127
+ };
128
+
129
+ // FLD-012 (#32) \u2014 'visible' is the population every rule below is computed over, and it used to
130
+ // mean "the browser gave it a layout box". That is not the same as "a person can see it":
131
+ // 'offsetParent' is null for 'display: none' but perfectly non-null for 'opacity: 0' and for
132
+ // 'visibility: hidden'. So an invisible element was being reported as a box a person could see \u2014
133
+ // by 'empty-decorated-box', and inherited unchanged by 'elements-overflowing', the accent-area
134
+ // census, the rhythm bands and 'single-column-grid'. This is a correctness fix, not a
135
+ // suppression: nothing that can be seen is dropped.
136
+ //
137
+ // \u26A0\uFE0F 'visibility' is inherited, so the computed value already accounts for a hidden ancestor.
138
+ // 'opacity' is NOT inherited \u2014 a child of an 'opacity: 0' parent computes to 1 \u2014 so this catches
139
+ // the element that is itself transparent and not one buried under a faded ancestor. Walking every
140
+ // ancestor would cost a style read per level per element; the shipped false positives are all the
141
+ // first kind ('.ndl-controls-radio-2' is 'opacity: 0' on the input itself).
142
+ // FLD-012 AC3 \u2014 declared OUTSIDE the filter below on purpose: a reverted arm replaces only the
143
+ // filter, and a counter declared inside it would make the reverted arm throw a ReferenceError
144
+ // instead of measuring. Left at 0, which is exactly what the old predicate excluded.
145
+ let transparentExcluded = 0;
146
+ const visible = all.filter((el) => {
147
+ const cs = styleOf(el);
148
+ if (cs.visibility === 'hidden' || cs.opacity === '0') {
149
+ transparentExcluded += 1;
150
+ return false;
151
+ }
152
+ return el.offsetParent !== null || cs.position === 'fixed';
153
+ });
154
+
155
+ const vw = document.documentElement.clientWidth;
156
+ const vh = document.documentElement.clientHeight;
157
+ const overflowing = visible
158
+ .map((el) => ({ el, w: el.getBoundingClientRect().width }))
159
+ .filter((x) => x.w > vw + 1)
160
+ .map((x) => ({ tag: x.el.tagName, cls: cls(x.el), width: round(x.w) }));
161
+
162
+ // P88 GAM-020 (D58) \u2014 a Text wider than the box it sits in. RKT-001's corrected clause: the text's
163
+ // border box against its PARENT's content box. A 'white-space: pre' Text always fits itself, so its
164
+ // own scrollWidth against its own clientWidth cannot fail (that read 60/60 green on the clipped
165
+ // build). An inline parent has no content box (clientWidth 0) and is skipped, never judged.
166
+ const textsWiderThanBox = visible
167
+ .filter((el) => el.classList.contains('ndl-visual-text') && el.parentElement)
168
+ .map((el) => {
169
+ const parent = el.parentElement;
170
+ const ps = styleOf(parent);
171
+ const box = parent.clientWidth - (parseFloat(ps.paddingLeft) || 0) - (parseFloat(ps.paddingRight) || 0);
172
+ return { el, width: el.getBoundingClientRect().width, box };
173
+ })
174
+ .filter((x) => x.box > 0 && x.width > x.box + 1)
175
+ .map((x) => ({ text: x.el.textContent.trim().slice(0, 80), width: round(x.width), parentWidth: round(x.box), cls: cls(x.el) }));
176
+
177
+ // AWP-004 \u2014 what is on screen, as opposed to what is in the DOM. The page is
178
+ // never scrolled when this runs, so a rect is already a document position.
179
+ // Kimi K3's storefront was 83 texts in the DOM and about three on screen, and
180
+ // the report called it clean because every check it had was about content
181
+ // present and wrong rather than content present and unreachable.
182
+ const onScreen = (el) => {
183
+ const r = el.getBoundingClientRect();
184
+ return r.width > 0 && r.height > 0 && r.bottom > 0 && r.top < vh && r.right > 0 && r.left < vw;
185
+ };
186
+
187
+ // Below the fold is fine \u2014 that is what scrolling is for. Below the *scrollable
188
+ // extent* is not: no scroll reaches it, so it is on the page and can never be
189
+ // seen. Measured directly rather than inferred from a visible-count ratio,
190
+ // because a correct 82-text page also shows only ~19 at a time and a ratio
191
+ // cannot tell the two apart.
192
+ const pageBottom = document.documentElement.scrollHeight;
193
+ const unreachable = (el) => el.getBoundingClientRect().top >= pageBottom - 1;
194
+
195
+ const textEls = visible.filter((el) => el.children.length === 0 && el.textContent.trim().length > 0);
196
+ const fontWeights = {};
197
+ const fontSizes = {};
198
+ for (const el of textEls) {
199
+ const cs = styleOf(el);
200
+ fontWeights[cs.fontWeight] = (fontWeights[cs.fontWeight] || 0) + 1;
201
+ fontSizes[cs.fontSize] = (fontSizes[cs.fontSize] || 0) + 1;
202
+ }
203
+
204
+ const dead = textEls.filter((el) => PLACEHOLDERS.includes(el.textContent.trim()));
205
+ const byText = {};
206
+ for (const el of dead) {
207
+ const t = el.textContent.trim();
208
+ byText[t] = (byText[t] || 0) + 1;
209
+ }
210
+
211
+ // FLD-012 (#32) \u2014 *"I started ignoring the warning."* Five of ten pages carried this finding
212
+ // purely for containing a control, and noise in a signal this good is a real cost.
213
+ //
214
+ // Two residues survive the 'visible' fix above, because neither is invisible \u2014 they are drawn,
215
+ // they are just not the author's boxes:
216
+ //
217
+ // 1. A FORM CONTROL. It has no children and no text by construction, and the runtime styles it
218
+ // \u2014 the deprecated Radio Button is an '<input type="radio">' at 24x24 with a solid border
219
+ // ('nodes-deprecated/controls/radiobutton.tsx:62', 'assets/style.css:64'). It is not an empty
220
+ // box; it is a control that happens to be childless.
221
+ // 2. A CONTROL'S OWN FURNITURE \u2014 the parts the runtime draws INSIDE a control, beside its
222
+ // input, as plain unclassed '<div>'s: the Slider's track and thumb ('Slider.tsx:146-176')
223
+ // and the Radio Button's checked fill dot, which is the one this fixture actually caught \u2014
224
+ // a 16x16 absolutely-positioned black div whose parent also holds the radio input. Every
225
+ // one of them is drawn, so no visibility test reaches them, and every one is furniture
226
+ // rather than a box the author left empty.
227
+ //
228
+ // \u{1F534} #32's own suggested discriminator does not reach either of them, and the second half of
229
+ // this file's earlier scope note was actively dangerous:
230
+ // - 'closest('[class*="ndl-controls-"]')' returns null for the track and the thumb \u2014 they are
231
+ // unclassed and so is their parent; only their '<input>' SIBLING carries the class.
232
+ // - 'appearance: none' is the COMPUTED DEFAULT of an ordinary '<div>' in Chrome, measured on
233
+ // this fixture. Skipping on it would suppress every finding on every page, including the
234
+ // author's genuinely empty box \u2014 which is exactly the failure mode this rule's presence
235
+ // control exists to catch. It is not used here.
236
+ //
237
+ // \u26A0\uFE0F The furniture predicate is deliberately narrow, and it is TWO conditions, not one:
238
+ // absolutely positioned AND a direct sibling of a form control. An author's own empty Group is
239
+ // neither \u2014 measured on this fixture it is 'position: relative' with four plain div siblings.
240
+ // It would take positioning a box absolutely AND placing it beside a control in the same parent
241
+ // to be suppressed by this: small, real, and stated here rather than discovered later.
242
+ const FORM_CONTROL_TAGS = ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'PROGRESS', 'METER'];
243
+ const CONTROL_SIBLING_SELECTOR = ':scope > input, :scope > select, :scope > textarea, :scope > button';
244
+ const isControlFurniture = (el) => {
245
+ const parent = el.parentElement;
246
+ if (!parent || !parent.querySelector(CONTROL_SIBLING_SELECTOR)) return false;
247
+ return styleOf(el).position === 'absolute';
248
+ };
249
+
250
+ const emptyBoxes = visible.filter((el) => {
251
+ if (el.children.length !== 0 || el.textContent.trim() !== '') return false;
252
+ const r = el.getBoundingClientRect();
253
+ if (r.width <= 8 || r.height <= 8) return false;
254
+ if (FORM_CONTROL_TAGS.indexOf(el.tagName) !== -1) return false;
255
+ if (isControlFurniture(el)) return false;
256
+ const cs = styleOf(el);
257
+ return cs.backgroundColor !== 'rgba(0, 0, 0, 0)' || cs.borderStyle !== 'none';
258
+ });
259
+
260
+ const images = visible.filter((el) => el.tagName === 'IMG');
261
+ const brokenImages = images.filter((el) => el.complete && el.naturalWidth === 0);
262
+
263
+ // VIB-007 M3 \u2014 iconography, which no earlier finding needed and which
264
+ // 'no-imagery' cannot be honest without: README section 2's tell is "no
265
+ // imagery AND no iconography", and a page of five Lucide glyphs and no
266
+ // photographs satisfies only half of it.
267
+ //
268
+ // Four shapes, because IconGlyph has four branches and only two of them carry
269
+ // a class this could key on. Measured against the VIB-006 page rather than
270
+ // read off the component: the font branch renders
271
+ // span.lucide.icon-sprout with NO ndl-icon-glyph class on it, so a selector
272
+ // written from that constant would have counted zero on a page with ten.
273
+ //
274
+ // sprite / inline -> .ndl-icon-glyph
275
+ // the Icon node -> .ndl-visual-icon wrapper
276
+ // an inline svg -> the svg element itself
277
+ // a font glyph -> a ::before whose content is a Private Use Area
278
+ // codepoint, which is what an icon font IS. Restricting
279
+ // to U+E000..U+F8FF is what keeps a bullet, a quote mark
280
+ // or a CSS counter from counting as iconography.
281
+ //
282
+ // A Set of ELEMENTS, so the wrapper and the glyph it holds are not two icons.
283
+ // Over-counting is harmless here and under-counting is not: the predicate is
284
+ // "is there any iconography at all", so a wrapper counted twice still says
285
+ // yes, and a glyph missed entirely says no when the answer is yes.
286
+ const PUA_FIRST = 0xe000;
287
+ const PUA_LAST = 0xf8ff;
288
+ const isGlyphContent = (raw) => {
289
+ if (!raw || raw === 'none' || raw === 'normal') return false;
290
+ const text = raw.replace(/^["']|["']$/g, '');
291
+ if (!text.length) return false;
292
+ for (const ch of text) {
293
+ const pt = ch.codePointAt(0);
294
+ if (pt < PUA_FIRST || pt > PUA_LAST) return false;
295
+ }
296
+ return true;
297
+ };
298
+ const iconEls = new Set();
299
+ for (const el of visible) {
300
+ const list = el.classList;
301
+ if (list && (list.contains('ndl-icon-glyph') || list.contains('ndl-visual-icon'))) {
302
+ iconEls.add(el);
303
+ continue;
304
+ }
305
+ if (String(el.tagName).toLowerCase() === 'svg') {
306
+ iconEls.add(el);
307
+ continue;
308
+ }
309
+ if (isGlyphContent(getComputedStyle(el, '::before').content)) iconEls.add(el);
310
+ }
311
+
312
+ // VIB-007 M3 \u2014 the page's full-bleed surfaces and what each is painted with.
313
+ //
314
+ // README section 2's first WordPress tell is "one background colour end to
315
+ // end", and the reading it needs is the number of DISTINCT grounds across the
316
+ // bands, not across every element: a page whose sections alternate has a
317
+ // designed rhythm, and a page whose cards and inputs happen to carry three
318
+ // greys does not. The width filter is the one the rhythm block already uses,
319
+ // so "band" means the same thing in both readings.
320
+ //
321
+ // background-image is read BEFORE background-color and wins, because a
322
+ // gradient or a photograph sits on top of the colour and is the ground a
323
+ // person sees. Measured, not assumed: every one of the VIB-006 page's
324
+ // gradient bands reports backgroundColor rgba(0, 0, 0, 0) and its whole
325
+ // identity is in background-image, so a colour-only count read 6 grounds on a
326
+ // page that has 8.
327
+ const groundCounts = {};
328
+ for (const el of visible) {
329
+ const r = el.getBoundingClientRect();
330
+ if (r.width < vw * 0.6 || r.height < 8) continue;
331
+ const cs = styleOf(el);
332
+ const painted =
333
+ cs.backgroundImage && cs.backgroundImage !== 'none'
334
+ ? cs.backgroundImage
335
+ : cs.backgroundColor && cs.backgroundColor !== 'rgba(0, 0, 0, 0)' && cs.backgroundColor !== 'transparent'
336
+ ? cs.backgroundColor
337
+ : null;
338
+ if (!painted) continue;
339
+ const key = String(painted).slice(0, 120);
340
+ groundCounts[key] = (groundCounts[key] || 0) + 1;
341
+ }
342
+
343
+ // DSG-006 \xA74 \u2014 "one accent". A designed page carries one chromatic colour as
344
+ // a background and neutrals for everything else; five accents is decorated
345
+ // rather than designed. Neutral is decided by *chroma* (max minus min
346
+ // channel) rather than by matching a palette, so it holds for any token set
347
+ // and for a project that never adopted one. Area-weighted, because a 4px
348
+ // chromatic rule and a full-bleed hero are not the same claim.
349
+ const parseRgb = (s) => {
350
+ const m = s.match(/rgba?\\(([^)]+)\\)/);
351
+ if (!m) return null;
352
+ const p = m[1].split(',').map((x) => parseFloat(x));
353
+ if (p.length >= 4 && p[3] === 0) return null;
354
+ return [p[0], p[1], p[2]];
355
+ };
356
+ const accentArea = {};
357
+ const neutralArea = {};
358
+ for (const el of visible) {
359
+ const r = el.getBoundingClientRect();
360
+ const area = r.width * r.height;
361
+ if (area < 1000) continue;
362
+ const bg = styleOf(el).backgroundColor;
363
+ const c = parseRgb(bg);
364
+ if (!c) continue;
365
+ const chroma = Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]);
366
+ const bucket = chroma > 12 ? accentArea : neutralArea;
367
+ bucket[bg] = (bucket[bg] || 0) + area;
368
+ }
369
+ // DSG-006/F41 \u2014 raw area cannot say whether a chromatic colour is an *accent*
370
+ // or the page's ground, and it is not comparable between viewports: 600k px\xB2
371
+ // is half of a desktop page and four phone screens. "share" is the fraction
372
+ // of the viewport the colour covers, which is the number \xA74 is actually
373
+ // about. The case that named this: haiku and sonnet paint the SAME hue at the
374
+ // SAME accent count, at 0.524 and 0.013 of the page respectively.
375
+ // \u26A0\uFE0F No backticks in here \u2014 this whole block is a template literal, and one
376
+ // in a comment ends the string. It surfaces as a syntax error hundreds of
377
+ // lines away, which is how it cost a debug cycle the first time.
378
+ const viewportArea = window.innerWidth * window.innerHeight;
379
+ const accents = Object.keys(accentArea)
380
+ .map((color) => ({
381
+ color,
382
+ area: round(accentArea[color]),
383
+ share: viewportArea ? Math.round((1000 * accentArea[color]) / viewportArea) / 1000 : null
384
+ }))
385
+ .sort((a, b) => b.area - a.area);
386
+
387
+ // DSG-006 \xA71/\xA78 \u2014 vertical rhythm. Measured between the full-width bands of
388
+ // the page's main stack, not between every pair of siblings: a designed page
389
+ // repeats two or three spacings, and counting every gap in the DOM would
390
+ // drown that signal in card padding. The widest-stack parent is chosen by
391
+ // how many bands it holds, which is what "the page's sections" means when
392
+ // nothing in the graph labels them as such.
393
+ // \u26A0\uFE0F Measured as gaps *and* band padding, because in this renderer the two
394
+ // are interchangeable and the first version of this saw nothing: a stack of
395
+ // bands that abut at gap 0 and carry their spacing as internal padding is the
396
+ // normal shape here, so gaps alone reported "1 distinct gap: 0" for a page
397
+ // with perfectly good rhythm. What the doctrine is about is the number of
398
+ // distinct vertical spacings on the page, however they are expressed.
399
+ const bandParents = new Map();
400
+ for (const el of visible) {
401
+ const r = el.getBoundingClientRect();
402
+ if (r.width < vw * 0.6 || r.height < 8) continue;
403
+ const p = el.parentElement;
404
+ if (!p) continue;
405
+ if (!bandParents.has(p)) bandParents.set(p, []);
406
+ bandParents.get(p).push({ el, r });
407
+ }
408
+ let bands = [];
409
+ for (const set of bandParents.values()) if (set.length > bands.length) bands = set;
410
+ bands = bands.slice().sort((a, b) => a.r.top - b.r.top);
411
+
412
+ const spacingCounts = {};
413
+ const addSpacing = (v) => {
414
+ const n = round(v);
415
+ // Zero is "these abut", not a spacing; a huge value is a page break.
416
+ if (n <= 0 || n > 400) return;
417
+ spacingCounts[n] = (spacingCounts[n] || 0) + 1;
418
+ };
419
+ for (let i = 1; i < bands.length; i++) addSpacing(bands[i].r.top - bands[i - 1].r.bottom);
420
+ for (const b of bands) {
421
+ const cs = styleOf(b.el);
422
+ addSpacing(parseFloat(cs.paddingTop));
423
+ addSpacing(parseFloat(cs.paddingBottom));
424
+ }
425
+ const spacingValues = Object.keys(spacingCounts)
426
+ .map(Number)
427
+ .sort((a, b) => a - b);
428
+
429
+ // Repeated sibling sets \u2014 a card grid, seen from the DOM. Two kinds:
430
+ // 'columns' \u2014 a Columns node's own item wrappers (.column-item), so the
431
+ // author asked for a grid explicitly and we know it.
432
+ // 'siblings' \u2014 identical-looking children of one parent, which is what a
433
+ // hand-laid or repeated card row looks like.
434
+ // Grouping by parent, and by an identity signature within the parent, is what
435
+ // separates a grid from an ordinary stack of page sections.
436
+ const byParent = new Map();
437
+ for (const el of visible) {
438
+ const p = el.parentElement;
439
+ if (!p) continue;
440
+ if (!byParent.has(p)) byParent.set(p, []);
441
+ byParent.get(p).push(el);
442
+ }
443
+ const groups = [];
444
+ const record = (parent, kids, kind) => {
445
+ if (kids.length < 3) return;
446
+ const rects = kids.map((k) => k.getBoundingClientRect());
447
+ if (rects[0].width < 100) return;
448
+ groups.push({
449
+ kind,
450
+ count: kids.length,
451
+ columns: new Set(rects.map((r) => round(r.left))).size,
452
+ rows: new Set(rects.map((r) => round(r.top))).size,
453
+ itemWidth: round(rects[0].width),
454
+ itemHeight: round(rects[0].height),
455
+ parentWidth: round(parent.getBoundingClientRect().width),
456
+ tag: kids[0].tagName,
457
+ cls: cls(kids[0])
458
+ });
459
+ };
460
+ for (const [parent, kids] of byParent) {
461
+ const items = kids.filter((k) => k.classList && k.classList.contains('column-item'));
462
+ if (items.length >= 3) {
463
+ record(parent, items, 'columns');
464
+ continue;
465
+ }
466
+ const bySignature = new Map();
467
+ for (const k of kids) {
468
+ const sig = k.tagName + '|' + cls(k) + '|' + k.querySelectorAll('*').length;
469
+ if (!bySignature.has(sig)) bySignature.set(sig, []);
470
+ bySignature.get(sig).push(k);
471
+ }
472
+ for (const set of bySignature.values()) record(parent, set, 'siblings');
473
+ }
474
+
475
+ // LAS-012 \xA73. Content the graph declares for a list, looked for on the page.
476
+ //
477
+ // Built from the visible leaf text elements, NOT \`document.body.textContent\`
478
+ // \u2014 measured, not assumed: render-from-disk injects the whole project as
479
+ // \`window.projectData\` in a <script> inside <body>, so body text contains
480
+ // every items array verbatim and the first version of this check found all
481
+ // six of haiku's missing strings on a page showing none of them.
482
+ //
483
+ // \`textContent\` is unaffected by text-transform, so an uppercased heading
484
+ // still matches its source string.
485
+ const pageText = textEls.map((el) => el.textContent).join('\\n');
486
+ const lists = PROBES.map((p) => {
487
+ const found = p.strings.filter((s) => pageText.indexOf(s) !== -1);
488
+ return { ...p, found: found.length, missing: p.strings.filter((s) => found.indexOf(s) === -1).slice(0, 3) };
489
+ });
490
+
491
+ // How far down the page anything was actually laid out. A page pinned to the
492
+ // viewport height with content laid out past it is clipped; a page that is
493
+ // simply short has nothing past it. That difference is the whole of
494
+ // \`clipped-page\`, and neither number alone can tell them apart.
495
+ const contentBottom = visible.reduce((lowest, el) => {
496
+ const r = el.getBoundingClientRect();
497
+ return r.width > 0 && r.height > 0 && r.bottom > lowest ? r.bottom : lowest;
498
+ }, 0);
499
+
500
+ return {
501
+ lists,
502
+ // FLD-012 AC3 \u2014 the SIZE of the population every rule below is computed over, and the number of
503
+ // elements the visibility correction took out of it. Reported directly because a rule's finding
504
+ // count can move for several reasons at once, and 'the fix changed what is measured' is a
505
+ // different claim from 'the fix changed what is reported'.
506
+ visibleCount: visible.length,
507
+ transparentExcluded,
508
+ layoutWidth: window.innerWidth,
509
+ clientWidth: vw,
510
+ clientHeight: vh,
511
+ scrollWidth: document.documentElement.scrollWidth,
512
+ pageHeight: document.documentElement.scrollHeight,
513
+ contentBottom: round(contentBottom),
514
+ overflowing: overflowing.slice(0, 10),
515
+ overflowingCount: overflowing.length,
516
+ textsWiderThanBox: textsWiderThanBox.slice(0, 10),
517
+ textsWiderThanBoxCount: textsWiderThanBox.length,
518
+ text: {
519
+ elements: textEls.length,
520
+ onScreen: textEls.filter(onScreen).length,
521
+ unreachable: textEls.filter(unreachable).length,
522
+ fontWeights,
523
+ fontSizes,
524
+ distinctFontSizes: Object.keys(fontSizes).length,
525
+ // VIB-007 M3 \u2014 the display-type reading. Derived here rather than in
526
+ // summarise so a client that only keeps the summary still has it.
527
+ largestFontSize: Object.keys(fontSizes).reduce((max, px) => Math.max(max, parseFloat(px) || 0), 0),
528
+ bodyFontFamily: getComputedStyle(document.body).fontFamily.slice(0, 80)
529
+ },
530
+ placeholders: {
531
+ count: dead.length,
532
+ byText,
533
+ samples: dead.slice(0, 8).map((el) => ({ text: el.textContent.trim(), tag: el.tagName, cls: cls(el) }))
534
+ },
535
+ images: {
536
+ total: images.length,
537
+ // VIB-007 M3. Deliberately inside the images block rather than beside it: every
538
+ // consumer that asks "does this page have any pictures" has to ask both
539
+ // halves, and two sibling fields is how one of them gets forgotten.
540
+ icons: iconEls.size,
541
+ onScreen: images.filter(onScreen).length,
542
+ unreachable: images.filter(unreachable).length,
543
+ broken: brokenImages.length,
544
+ brokenSources: brokenImages.slice(0, 8).map((el) => String(el.currentSrc || el.src).slice(0, 160))
545
+ },
546
+ emptyDecoratedBoxes: {
547
+ count: emptyBoxes.length,
548
+ samples: emptyBoxes.slice(0, 6).map((el) => {
549
+ const r = el.getBoundingClientRect();
550
+ return { tag: el.tagName, cls: cls(el), width: round(r.width), height: round(r.height) };
551
+ })
552
+ },
553
+ colors: {
554
+ distinctAccents: accents.length,
555
+ accents: accents.slice(0, 6),
556
+ distinctNeutrals: Object.keys(neutralArea).length,
557
+ // The denominator behind every "share", emitted so a score can be
558
+ // recomputed from the report without knowing which viewport produced it.
559
+ viewportArea: round(viewportArea)
560
+ },
561
+ grounds: {
562
+ distinct: Object.keys(groundCounts).length,
563
+ values: Object.keys(groundCounts)
564
+ .map((ground) => ({ ground, count: groundCounts[ground] }))
565
+ .sort((a, b) => b.count - a.count)
566
+ .slice(0, 8)
567
+ },
568
+ rhythm: {
569
+ bands: bands.length,
570
+ distinctSpacings: spacingValues.length,
571
+ spacings: spacingValues.slice(0, 12).map((px) => ({ px, count: spacingCounts[px] }))
572
+ },
573
+ repeatedGroups: groups.sort((a, b) => b.itemWidth * b.count - a.itemWidth * a.count).slice(0, 12)
574
+ };
575
+ })()`;
576
+ }
577
+ var RenderFinding = {
578
+ BlankRender: "blank-render",
579
+ /**
580
+ * LAS-012 §3 — a list whose rows the graph spells out, and the page does not
581
+ * contain a single one of them. The one shape this report was structurally
582
+ * unable to see: every other finding is about content present and wrong, and
583
+ * a repeater that instantiates nothing emits no elements to judge.
584
+ */
585
+ EmptyList: "empty-list",
586
+ DeadPlaceholderText: "dead-placeholder-text",
587
+ BrokenImage: "broken-image",
588
+ /**
589
+ * AWP-004 §2 — content on the page that no scroll can reach.
590
+ *
591
+ * The direct check for F45, and the one that generalises the other two: Kimi
592
+ * K3's storefront put 83 texts and 10 images in the DOM and stranded 70 and 9
593
+ * of them below a page that does not scroll, and the report called it
594
+ * *"Rendered clean: 83 texts, 10 images"*.
595
+ */
596
+ ContentNotVisible: "content-not-visible",
597
+ /** The mechanism behind it: a page pinned to the viewport with content past the fold. */
598
+ ClippedPage: "clipped-page",
599
+ /** Elements wider than the viewport inside a page that does not itself scroll sideways. */
600
+ ElementsOverflowing: "elements-overflowing",
601
+ /**
602
+ * P88 GAM-020 (D58) — a Text wider than its parent's content box, so it is cut off or spills. The
603
+ * text is named. Rocket School's French sentences did this with zero console errors, and every
604
+ * page-level finding stayed quiet because the page itself kept its width.
605
+ */
606
+ TextWiderThanItsBox: "text-wider-than-its-box",
607
+ SingleColumnGrid: "single-column-grid",
608
+ MinimumLayoutWidth: "minimum-layout-width",
609
+ HorizontalOverflow: "horizontal-overflow",
610
+ FlatTypeScale: "flat-type-scale",
611
+ EmptyDecoratedBox: "empty-decorated-box",
612
+ ConsoleError: "console-error",
613
+ /**
614
+ * VIB-007 M3 — the three poverty findings.
615
+ *
616
+ * 🔴 **Every other finding in this list detects EXCESS or BREAKAGE**, which is
617
+ * register V10: a page can pass all thirteen of them, render perfectly clean,
618
+ * and be worth nothing to look at. That is what the entire VIB-001 baseline
619
+ * is — nine pages ruled SHITTY, and the report called every one of them
620
+ * clean.
621
+ *
622
+ * These three are not invented. Each is one of the WordPress-starter tells
623
+ * README section 2 lists verbatim, restricted to the ones a render can see:
624
+ *
625
+ * "one background colour end to end" -> SingleGround
626
+ * "no imagery and no iconography anywhere" -> NoImagery
627
+ * "headline under ~48px on desktop" -> NoDisplayType
628
+ *
629
+ * ⚠️ **They are warnings, not errors, and that is a decision rather than an
630
+ * oversight.** `error` is the severity `noodl-mcp`'s render verdict blocks
631
+ * "done" on (VIB-007 M1), and README section 2 exempts app-chrome pages (forms,
632
+ * lists, settings) from the marketing tells — a settings page needs no 72px
633
+ * headline and refusing to certify one would be the instrument being wrong,
634
+ * loudly, on every honest project. The instrument cannot currently tell a
635
+ * landing page from a settings page; until something can, poverty is reported
636
+ * and does not refuse.
637
+ */
638
+ SingleGround: "single-ground",
639
+ NoImagery: "no-imagery",
640
+ NoDisplayType: "no-display-type"
641
+ };
642
+ var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
643
+ var POVERTY_FINDINGS = [RenderFinding.SingleGround, RenderFinding.NoImagery, RenderFinding.NoDisplayType];
644
+ function isPovertyFinding(finding) {
645
+ return Boolean(finding) && POVERTY_FINDINGS.indexOf(finding.code) !== -1;
646
+ }
647
+ var DISPLAY_TYPE_MIN_PX = 48;
648
+ var MIN_DISTINCT_GROUNDS = 2;
649
+ var POVERTY_MIN_TEXTS = 3;
650
+ var CLIPPED_CONTENT_SLACK = 8;
651
+ function plural(n, one, many) {
652
+ return n === 1 ? `1 ${one}` : `${n} ${many}`;
653
+ }
654
+ function widestOffender(v) {
655
+ const widest = (v.overflowing || []).slice().sort((a, b) => b.width - a.width)[0];
656
+ if (!widest) return null;
657
+ const named = widest.cls ? `${widest.tag.toLowerCase()}.${widest.cls.split(/\s+/)[0]}` : widest.tag.toLowerCase();
658
+ return `${named} at ${widest.width}px`;
659
+ }
660
+ function summarise(viewports, diagnosis, overridden = {}) {
661
+ const findings = [];
662
+ const add = (f) => findings.push(f);
663
+ for (const [name, v] of Object.entries(viewports)) {
664
+ if (!v || v.error) continue;
665
+ const isDesktop = v.requested.width >= DESKTOP_WIDTH;
666
+ if (v.text.elements === 0 && v.images.total === 0) {
667
+ add({
668
+ code: RenderFinding.BlankRender,
669
+ severity: "error",
670
+ viewport: name,
671
+ message: "The page rendered nothing at all \u2014 no text and no images. " + (diagnosis ? diagnosis.message : "No project was available to diagnose it against, so the cause was not determined."),
672
+ ...diagnosis && diagnosis.cause ? { cause: diagnosis.cause } : {},
673
+ ...diagnosis && diagnosis.evidence ? { evidence: diagnosis.evidence } : {}
674
+ });
675
+ continue;
676
+ }
677
+ for (const list of v.lists || []) {
678
+ if (list.found > 0) continue;
679
+ add({
680
+ code: RenderFinding.EmptyList,
681
+ severity: "error",
682
+ viewport: name,
683
+ relatedDiagnostic: "repeater-without-template",
684
+ message: `The Repeater "${list.label}" in ${list.component} has ${plural(list.rows, "row", "rows")} of ${list.source}, and none of that content is on the page \u2014 so it built nothing. A For Each instantiates the component named on its "template" port once per item and inserts each copy as its own next sibling; with no template, or with the item markup nested underneath it instead, it renders nothing at all. Looked for: ${list.missing.map((s) => `"${s}"`).join(", ")}.`,
685
+ evidence: { component: list.component, nodeId: list.nodeId, rows: list.rows, missing: list.missing }
686
+ });
687
+ }
688
+ if (v.placeholders.count > 0) {
689
+ const entries = Object.entries(v.placeholders.byText);
690
+ const listing = (pairs) => pairs.map(([text, n]) => `${n}\xD7 "${text}"`).join(", ");
691
+ const total = (pairs) => pairs.reduce((sum, [, n]) => sum + n, 0);
692
+ const fromInput = entries.filter(([text]) => overridden[text]);
693
+ const fromCatalog = entries.filter(([text]) => !overridden[text]);
694
+ if (fromInput.length) {
695
+ const where = fromInput.map(([text]) => `"${text}" is ${describeSites(overridden[text])}`).join("; ");
696
+ add({
697
+ code: RenderFinding.DeadPlaceholderText,
698
+ severity: "error",
699
+ viewport: name,
700
+ relatedDiagnostic: "interfaceless-instance",
701
+ message: `${plural(total(fromInput), "element shows", "elements show")} the fallback hardcoded on a port that a Component Inputs node also feeds: ${listing(fromInput)}. That value is only ever visible when the input does not arrive, so it did not arrive \u2014 ${where}.`,
702
+ evidence: v.placeholders.samples.filter((s) => overridden[s.text])
703
+ });
704
+ }
705
+ if (fromCatalog.length) {
706
+ add({
707
+ code: RenderFinding.DeadPlaceholderText,
708
+ severity: "error",
709
+ viewport: name,
710
+ relatedDiagnostic: "interfaceless-instance",
711
+ message: `${plural(total(fromCatalog), "element renders", "elements render")} a node-type default instead of content: ${listing(fromCatalog)}. Nothing set those ports. The usual cause is a component instantiated with parameters its Component Inputs node does not declare, so every value is discarded \u2014 the graph-side name for it is interfaceless-instance.`,
712
+ evidence: v.placeholders.samples.filter((s) => !overridden[s.text])
713
+ });
714
+ }
715
+ }
716
+ if (v.images.broken > 0) {
717
+ add({
718
+ code: RenderFinding.BrokenImage,
719
+ severity: "error",
720
+ viewport: name,
721
+ message: `${plural(v.images.broken, "image", "images")} of ${v.images.total} failed to load \u2014 the element is in the DOM with naturalWidth 0. A URL that returns 200 can still be the wrong picture; this is only the half a number can see, so look at the screenshot for the rest.`,
722
+ evidence: v.images.brokenSources
723
+ });
724
+ }
725
+ const measuredVisibility = typeof v.contentBottom === "number" && typeof v.text.unreachable === "number";
726
+ if (measuredVisibility) {
727
+ const strandedText = v.text.unreachable;
728
+ const strandedImages = v.images.unreachable || 0;
729
+ if (strandedText > 0 || strandedImages > 0) {
730
+ const parts = [];
731
+ if (strandedText > 0) parts.push(`${strandedText} of ${v.text.elements} text elements`);
732
+ if (strandedImages > 0) parts.push(`${strandedImages} of ${v.images.total} images`);
733
+ add({
734
+ code: RenderFinding.ContentNotVisible,
735
+ severity: "error",
736
+ viewport: name,
737
+ message: `${parts.join(" and ")} are laid out below ${v.pageHeight}px, which is as far as this page scrolls \u2014 no scroll reaches them, so they are on the page and cannot be seen. Content extends to ${v.contentBottom}px. ${v.text.onScreen} of ${v.text.elements} texts are on screen.`,
738
+ evidence: {
739
+ pageHeight: v.pageHeight,
740
+ contentBottom: v.contentBottom,
741
+ textElements: v.text.elements,
742
+ textOnScreen: v.text.onScreen,
743
+ textUnreachable: strandedText,
744
+ imagesUnreachable: strandedImages
745
+ }
746
+ });
747
+ }
748
+ const pinned = Math.abs(v.pageHeight - v.requested.height) <= 1;
749
+ if (pinned && v.contentBottom > v.pageHeight + CLIPPED_CONTENT_SLACK) {
750
+ add({
751
+ code: RenderFinding.ClippedPage,
752
+ severity: "warning",
753
+ viewport: name,
754
+ message: `The page is exactly ${v.pageHeight}px tall \u2014 the viewport height \u2014 and its content runs to ${v.contentBottom}px, so it is clipped at the fold rather than scrolling. A root that clips to the viewport with no scrollbar is the default; a page of this length has to opt into scrolling.`,
755
+ evidence: { pageHeight: v.pageHeight, contentBottom: v.contentBottom, viewport: v.requested.height }
756
+ });
757
+ }
758
+ }
759
+ if (isDesktop) {
760
+ for (const g of v.repeatedGroups) {
761
+ if (g.count < 3 || g.columns !== 1) continue;
762
+ if (g.itemWidth < g.parentWidth * GRID_ITEM_PARENT_SHARE) continue;
763
+ if (g.kind !== "columns") {
764
+ if (g.itemHeight < GRID_ITEM_MIN_HEIGHT || g.itemWidth < GRID_ITEM_MIN_WIDTH) continue;
765
+ if (g.parentWidth < v.requested.width * GRID_PARENT_VIEWPORT_SHARE) continue;
766
+ }
767
+ add({
768
+ code: RenderFinding.SingleColumnGrid,
769
+ severity: g.kind === "columns" ? "warning" : "info",
770
+ viewport: name,
771
+ message: `${g.count} repeated items are stacked in one column at ${v.requested.width}px, each ${g.itemWidth}\xD7${g.itemHeight}px inside a ${g.parentWidth}px parent` + (g.kind === "columns" ? '. They are a Columns node\u2019s items, so the layoutString asked for one column \u2014 "1 1 1" is three equal columns, and the count of numbers is the count of columns.' : ". If they are meant to be a grid, a Columns node is the only node that lays items out in one and collapses it on small screens; if they are meant to be full-width bands, this is right."),
772
+ evidence: g
773
+ });
774
+ }
775
+ }
776
+ if (v.layoutWidth > v.requested.width + 1) {
777
+ add({
778
+ code: RenderFinding.MinimumLayoutWidth,
779
+ severity: "warning",
780
+ viewport: name,
781
+ message: `The page cannot lay out below ${v.layoutWidth}px: asked for ${v.requested.width}px, the browser widened the layout viewport to ${v.layoutWidth}px and scaled the whole page down.` + (widestOffender(v) ? ` The widest element inside it is ${widestOffender(v)}.` : " Nothing wider than the viewport was captured, so the element holding the floor was not identified."),
782
+ evidence: v.overflowing
783
+ });
784
+ } else if (v.scrollWidth > v.clientWidth + 1) {
785
+ add({
786
+ code: RenderFinding.HorizontalOverflow,
787
+ severity: "warning",
788
+ viewport: name,
789
+ message: `The page scrolls sideways at ${v.requested.width}px \u2014 content is ${v.scrollWidth}px wide in a ${v.clientWidth}px viewport, with ${plural(v.overflowingCount, "element", "elements")} wider than it.`,
790
+ evidence: v.overflowing
791
+ });
792
+ } else if (v.overflowingCount > 0) {
793
+ add({
794
+ code: RenderFinding.ElementsOverflowing,
795
+ severity: "warning",
796
+ viewport: name,
797
+ message: `${plural(v.overflowingCount, "element is", "elements are")} wider than the ${v.clientWidth}px viewport, while the page itself does not scroll sideways \u2014 so each is clipped by an ancestor rather than reachable${widestOffender(v) ? `; the widest is ${widestOffender(v)}` : ""}.`,
798
+ evidence: v.overflowing
799
+ });
800
+ }
801
+ if (v.textsWiderThanBoxCount > 0) {
802
+ const first = v.textsWiderThanBox[0];
803
+ add({
804
+ code: RenderFinding.TextWiderThanItsBox,
805
+ severity: "warning",
806
+ viewport: name,
807
+ message: `${plural(v.textsWiderThanBoxCount, "text is", "texts are")} wider than the box ${v.textsWiderThanBoxCount === 1 ? "it sits" : "they sit"} in at ${v.requested.width}px, so the line is cut off or spills: "${first.text}" is ${first.width}px in a ${first.parentWidth}px box. A Text sized to its content (sizeMode "contentSize" or "contentWidth") never wraps; "contentHeight" takes its width from the parent and wraps.`,
808
+ evidence: v.textsWiderThanBox
809
+ });
810
+ }
811
+ if (v.text.elements >= 10) {
812
+ const weights = Object.keys(v.text.fontWeights);
813
+ if (weights.length <= 1 || v.text.distinctFontSizes <= 2) {
814
+ add({
815
+ code: RenderFinding.FlatTypeScale,
816
+ severity: "warning",
817
+ viewport: name,
818
+ message: `${v.text.elements} text elements render at ${plural(weights.length, "font weight", "font weights")} (${weights.join(", ")}) and ${plural(v.text.distinctFontSizes, "font size", "font sizes")}. Nothing is emphasised over anything else, which is what an unstyled page measures like.`
819
+ });
820
+ }
821
+ }
822
+ if (v.emptyDecoratedBoxes.count > 0) {
823
+ add({
824
+ code: RenderFinding.EmptyDecoratedBox,
825
+ severity: "warning",
826
+ viewport: name,
827
+ message: `${plural(v.emptyDecoratedBoxes.count, "box has", "boxes have")} a background or border and no content \u2014 a card whose contents never arrived, or a decorated Group used as a spacer.`,
828
+ evidence: v.emptyDecoratedBoxes.samples
829
+ });
830
+ }
831
+ const poorEnoughToJudge = v.text.elements >= POVERTY_MIN_TEXTS;
832
+ if (poorEnoughToJudge && v.images && typeof v.images.icons === "number" && v.images.total === 0 && v.images.icons === 0) {
833
+ add({
834
+ code: RenderFinding.NoImagery,
835
+ severity: "warning",
836
+ viewport: name,
837
+ message: `${v.text.elements} text elements and not one picture or glyph \u2014 no images, no icons. README \xA72's second WordPress-starter tell is "no imagery and no iconography anywhere on the page", and this page measures as exactly that. The Image and Icon nodes are in the kit and the starter modules ship photographs and 1,998 Lucide glyphs.`
838
+ });
839
+ }
840
+ if (poorEnoughToJudge && isDesktop && typeof v.text.largestFontSize === "number" && v.text.largestFontSize > 0 && v.text.largestFontSize < DISPLAY_TYPE_MIN_PX) {
841
+ add({
842
+ code: RenderFinding.NoDisplayType,
843
+ severity: "warning",
844
+ viewport: name,
845
+ message: `The largest text on this page renders at ${Math.round(v.text.largestFontSize)}px across ${plural(v.text.distinctFontSizes, "distinct size", "distinct sizes")}. README \xA72 calls a headline under ~${DISPLAY_TYPE_MIN_PX}px on desktop a WordPress-starter tell; nothing on this page is operating as display type.`,
846
+ evidence: v.text.fontSizes
847
+ });
848
+ }
849
+ if (poorEnoughToJudge && v.grounds && typeof v.grounds.distinct === "number" && v.grounds.distinct < MIN_DISTINCT_GROUNDS) {
850
+ add({
851
+ code: RenderFinding.SingleGround,
852
+ severity: "warning",
853
+ viewport: name,
854
+ message: `Every full-width band on this page is painted the same \u2014 ${plural(v.grounds.distinct, "distinct ground", "distinct grounds")} across the whole page. README \xA72's first WordPress-starter tell is "one background colour end to end". A ground can be a colour, a gradient or an image, and alternating them is what makes a page read as sections rather than as one column.`,
855
+ evidence: v.grounds.values
856
+ });
857
+ }
858
+ if (v.consoleErrors && v.consoleErrors.length) {
859
+ add({
860
+ code: RenderFinding.ConsoleError,
861
+ severity: "warning",
862
+ viewport: name,
863
+ message: `The runtime logged ${plural(v.consoleErrors.length, "error", "errors")} while rendering.`,
864
+ evidence: v.consoleErrors.slice(0, 5)
865
+ });
866
+ }
867
+ }
868
+ const seen = /* @__PURE__ */ new Set();
869
+ const unique = findings.filter((f) => {
870
+ const key = `${f.code}|${f.viewport}|${f.message}`;
871
+ if (seen.has(key)) return false;
872
+ seen.add(key);
873
+ return true;
874
+ });
875
+ unique.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
876
+ return { findings: unique, summary: summaryLine(viewports, unique) };
877
+ }
878
+ function summaryLine(viewports, findings) {
879
+ const count = (severity) => findings.filter((f) => f.severity === severity).length;
880
+ const errors = count("error");
881
+ const warnings = count("warning");
882
+ const infos = count("info");
883
+ const live = Object.entries(viewports).filter(([, v]) => v && !v.error);
884
+ const shape = live.map(([name, v]) => {
885
+ const counts = typeof v.text.onScreen === "number" ? `${v.text.elements} texts, ${v.text.onScreen} on screen, ${v.images.total} images` : `${v.text.elements} texts, ${v.images.total} images`;
886
+ return `${name} ${v.requested.width}\xD7${v.pageHeight}px, ${counts}`;
887
+ }).join("; ");
888
+ if (!errors && !warnings) {
889
+ const measured = live.filter(([, v]) => typeof v.text.onScreen === "number");
890
+ const observations = infos ? ` (${plural(infos, "observation", "observations")})` : "";
891
+ if (measured.length !== live.length) {
892
+ return `No findings${observations}, and visibility was not measured \u2014 not a claim the page is on screen: ${shape}.`;
893
+ }
894
+ if (measured.some(([, v]) => v.text.onScreen === 0 && v.images.onScreen === 0)) {
895
+ return `No findings${observations}, but nothing is on screen at the top of the page: ${shape}.`;
896
+ }
897
+ return `Rendered clean${observations}: ${shape}.`;
898
+ }
899
+ const worst = [...new Set(findings.filter((f) => f.severity !== "info").map((f) => f.code))].slice(0, 4).join(", ");
900
+ return `${plural(errors, "error", "errors")}, ${plural(warnings, "warning", "warnings")}${infos ? `, ${plural(infos, "observation", "observations")}` : ""} (${worst}). ${shape}.`;
901
+ }
902
+ function describeSites(entry) {
903
+ const sites = entry.sites || [];
904
+ const named = sites.slice(0, 2).map((s) => `${s.port} in ${s.component}`);
905
+ const rest = sites.length - named.length;
906
+ return named.join(" and ") + (rest > 0 ? ` and ${plural(rest, "other place", "other places")}` : "");
907
+ }
908
+ var TEXT_BEARING_PORTS = /^(text|label|placeholder|title|caption|heading)$/i;
909
+ function placeholderStringsFromCatalog(catalog) {
910
+ const found = /* @__PURE__ */ new Set();
911
+ for (const node of catalog && catalog.nodes || []) {
912
+ if (!node.isVisual) continue;
913
+ for (const port of node.inputs || []) {
914
+ if (!TEXT_BEARING_PORTS.test(port.name)) continue;
915
+ if (typeof port.default === "string" && port.default.trim()) found.add(port.default.trim());
916
+ }
917
+ }
918
+ return [...found];
919
+ }
920
+ module2.exports = {
921
+ // Vocabulary — every client must agree on these.
922
+ DEFAULT_VIEWPORTS,
923
+ NAMED_VIEWPORTS,
924
+ DESKTOP_WIDTH,
925
+ RenderFinding,
926
+ SEVERITY_ORDER,
927
+ CLIPPED_CONTENT_SLACK,
928
+ // VIB-007 M3 — exported because a spec that restates 48 is a second copy of
929
+ // README §2's number, and the one that drifts is always the copy.
930
+ DISPLAY_TYPE_MIN_PX,
931
+ MIN_DISTINCT_GROUNDS,
932
+ POVERTY_MIN_TEXTS,
933
+ POVERTY_FINDINGS,
934
+ isPovertyFinding,
935
+ // The two halves of the loop: what to evaluate in the page, and what the
936
+ // numbers that come back mean.
937
+ measureExpression,
938
+ summarise,
939
+ summaryLine,
940
+ // Helpers the callers share.
941
+ plural,
942
+ widestOffender,
943
+ describeSites,
944
+ TEXT_BEARING_PORTS,
945
+ placeholderStringsFromCatalog
946
+ };
947
+ }
948
+ });
949
+
950
+ // ../../scripts/devtools/render-report.js
951
+ var require_render_report = __commonJS({
952
+ "../../scripts/devtools/render-report.js"(exports2, module2) {
953
+ var { spawn } = require("child_process");
954
+ var fs2 = require("fs");
955
+ var http = require("http");
956
+ var net = require("net");
957
+ var os = require("os");
958
+ var path2 = require("path");
959
+ var HARNESS_PATHS = require_harness_paths();
960
+ var RENDER_SCRIPT = path2.join(__dirname, "render-from-disk.js");
961
+ var VIEWER_DIR = HARNESS_PATHS.VIEWER_DIR.path;
962
+ var VIEWER_BUNDLE = VIEWER_DIR && path2.join(VIEWER_DIR, "noodl.viewer.js");
963
+ var CATALOG_JSON = HARNESS_PATHS.CATALOG_JSON.path;
964
+ var WS_MODULE = HARNESS_PATHS.WS_MODULE.path;
965
+ var {
966
+ DEFAULT_VIEWPORTS,
967
+ DESKTOP_WIDTH,
968
+ RenderFinding,
969
+ SEVERITY_ORDER,
970
+ CLIPPED_CONTENT_SLACK,
971
+ DISPLAY_TYPE_MIN_PX,
972
+ MIN_DISTINCT_GROUNDS,
973
+ POVERTY_MIN_TEXTS,
974
+ POVERTY_FINDINGS,
975
+ isPovertyFinding,
976
+ measureExpression,
977
+ summarise,
978
+ summaryLine,
979
+ plural,
980
+ widestOffender,
981
+ describeSites,
982
+ TEXT_BEARING_PORTS,
983
+ placeholderStringsFromCatalog
984
+ } = require_src();
985
+ function placeholderStrings(catalogPath = CATALOG_JSON) {
986
+ return placeholderStringsFromCatalog(JSON.parse(fs2.readFileSync(catalogPath, "utf8")));
987
+ }
988
+ var PROBE_MIN_WORDS = 2;
989
+ var PROBE_MIN_LENGTH = 8;
990
+ var PROBE_MIN_LENGTH_SINGLE_WORD = 12;
991
+ var PROBE_MAX_STRINGS = 6;
992
+ function isDistinctive(s) {
993
+ if (/^[^A-Za-z]*$/.test(s)) return false;
994
+ const words = s.split(/\s+/).filter(Boolean).length;
995
+ if (words >= PROBE_MIN_WORDS) return s.length >= PROBE_MIN_LENGTH;
996
+ return s.length >= PROBE_MIN_LENGTH_SINGLE_WORD;
997
+ }
998
+ function readableStrings(items) {
999
+ const out = [];
1000
+ for (const item of items) {
1001
+ if (!item || typeof item !== "object") {
1002
+ if (typeof item === "string" && isDistinctive(item.trim())) out.push(item.trim());
1003
+ continue;
1004
+ }
1005
+ for (const value of Object.values(item)) {
1006
+ if (typeof value !== "string") continue;
1007
+ const s = value.trim();
1008
+ if (/^(https?:|data:|\/|#|var\()/.test(s)) continue;
1009
+ if (!isDistinctive(s)) continue;
1010
+ out.push(s);
1011
+ }
1012
+ }
1013
+ return [...new Set(out)];
1014
+ }
1015
+ function staticDataItems(node) {
1016
+ const p = node.parameters || {};
1017
+ if (Array.isArray(p.items)) return p.items;
1018
+ if (typeof p.json !== "string" || !p.json.trim()) return null;
1019
+ try {
1020
+ const parsed = JSON.parse(p.json);
1021
+ return Array.isArray(parsed) && parsed.length ? parsed : null;
1022
+ } catch {
1023
+ return null;
1024
+ }
1025
+ }
1026
+ function readComponents(projectDir2) {
1027
+ let project;
1028
+ try {
1029
+ project = JSON.parse(fs2.readFileSync(path2.join(projectDir2, "nodegx.project.json"), "utf8"));
1030
+ } catch {
1031
+ return { ok: false, components: [] };
1032
+ }
1033
+ const componentsDir = path2.join(projectDir2, project.structure && project.structure.componentsDir || "components");
1034
+ let registry;
1035
+ try {
1036
+ registry = JSON.parse(fs2.readFileSync(path2.join(componentsDir, "_registry.json"), "utf8"));
1037
+ } catch {
1038
+ return { ok: false, project, components: [] };
1039
+ }
1040
+ const components = [];
1041
+ for (const key of Object.keys(registry.components || {})) {
1042
+ const dir = path2.join(componentsDir, registry.components[key].path);
1043
+ try {
1044
+ const nodesFile = JSON.parse(fs2.readFileSync(path2.join(dir, "nodes.json"), "utf8"));
1045
+ const meta = JSON.parse(fs2.readFileSync(path2.join(dir, "component.json"), "utf8"));
1046
+ const connPath = path2.join(dir, "connections.json");
1047
+ components.push({
1048
+ name: meta.path || "/" + key,
1049
+ nodes: nodesFile.nodes || [],
1050
+ connections: fs2.existsSync(connPath) ? JSON.parse(fs2.readFileSync(connPath, "utf8")).connections || [] : [],
1051
+ // Absent and empty are different things and AWP-001 §3 turns on the
1052
+ // difference: `undefined` means nobody computed this, `[]` means a writer
1053
+ // did and found nothing. Carried through verbatim, decided in one place.
1054
+ declaredRoots: nodesFile.visualRoots
1055
+ });
1056
+ } catch {
1057
+ continue;
1058
+ }
1059
+ }
1060
+ return { ok: true, project, components };
1061
+ }
1062
+ function listProbes(projectDir2) {
1063
+ const probes = [];
1064
+ for (const component of readComponents(projectDir2).components) {
1065
+ const { name, nodes, connections } = component;
1066
+ const byId = new Map(nodes.map((n) => [n.id, n]));
1067
+ for (const node of nodes) {
1068
+ if (node.type !== "For Each") continue;
1069
+ const parameters = node.parameters || {};
1070
+ let items = Array.isArray(parameters.items) && parameters.items.length ? parameters.items : null;
1071
+ let source = "inline items";
1072
+ if (!items) {
1073
+ const feed = connections.find((c) => (c.toId ?? c.targetId) === node.id && (c.toProperty ?? c.targetPort) === "items");
1074
+ const from = feed ? byId.get(feed.fromId ?? feed.sourceId) : void 0;
1075
+ if (from && from.type === "Static Data") {
1076
+ items = staticDataItems(from);
1077
+ source = `Static Data "${from.label || from.id}"`;
1078
+ }
1079
+ }
1080
+ if (!items) continue;
1081
+ const strings = readableStrings(items).slice(0, PROBE_MAX_STRINGS);
1082
+ if (!strings.length) continue;
1083
+ probes.push({
1084
+ component: name,
1085
+ nodeId: node.id,
1086
+ label: node.label || node.id,
1087
+ rows: items.length,
1088
+ source,
1089
+ strings
1090
+ });
1091
+ }
1092
+ }
1093
+ return probes;
1094
+ }
1095
+ function overriddenDefaults(projectDir2) {
1096
+ const { components } = readComponents(projectDir2);
1097
+ const suppliedByInstances = /* @__PURE__ */ new Map();
1098
+ for (const component of components) {
1099
+ for (const node of component.nodes) {
1100
+ const params = node.parameters || {};
1101
+ for (const port of Object.keys(params)) {
1102
+ const value = params[port];
1103
+ if (typeof value !== "string" || !value.trim()) continue;
1104
+ const key = node.type + "\0" + port;
1105
+ if (!suppliedByInstances.has(key)) suppliedByInstances.set(key, /* @__PURE__ */ new Set());
1106
+ suppliedByInstances.get(key).add(value.trim());
1107
+ }
1108
+ }
1109
+ }
1110
+ const found = /* @__PURE__ */ new Map();
1111
+ for (const component of components) {
1112
+ const byId = new Map(component.nodes.map((n) => [n.id, n]));
1113
+ for (const conn of component.connections) {
1114
+ const from = byId.get(conn.sourceId ?? conn.fromId);
1115
+ const to = byId.get(conn.targetId ?? conn.toId);
1116
+ const port = conn.targetPort ?? conn.toProperty;
1117
+ const inputPort = conn.sourcePort ?? conn.fromProperty;
1118
+ if (!from || !to || from.type !== "Component Inputs") continue;
1119
+ if (!TEXT_BEARING_PORTS.test(port)) continue;
1120
+ const hardcoded = (to.parameters || {})[port];
1121
+ if (typeof hardcoded !== "string" || !hardcoded.trim()) continue;
1122
+ const key = hardcoded.trim();
1123
+ const supplied = inputPort && suppliedByInstances.get(component.name + "\0" + inputPort);
1124
+ if (supplied && supplied.has(key)) continue;
1125
+ if (!found.has(key)) found.set(key, { sites: [] });
1126
+ found.get(key).sites.push({ component: component.name, nodeId: to.id, port });
1127
+ }
1128
+ }
1129
+ return found;
1130
+ }
1131
+ var ENRICHED_CATALOG_JSON = HARNESS_PATHS.ENRICHED_CATALOG_JSON.path;
1132
+ var visualTypesCache = /* @__PURE__ */ new Map();
1133
+ function visualTypeNames(catalogPath = ENRICHED_CATALOG_JSON) {
1134
+ if (visualTypesCache.has(catalogPath)) return visualTypesCache.get(catalogPath);
1135
+ let names;
1136
+ try {
1137
+ const catalog = JSON.parse(fs2.readFileSync(catalogPath, "utf8"));
1138
+ names = new Set((catalog.nodes || []).filter((n) => n.isVisual === true).map((n) => n.typeName));
1139
+ if (names.size === 0) names = null;
1140
+ } catch {
1141
+ names = null;
1142
+ }
1143
+ visualTypesCache.set(catalogPath, names);
1144
+ return names;
1145
+ }
1146
+ var CONTENT_BEARING_PORTS = /^(text|label|placeholder|title|caption|heading|src|source|icon)$/i;
1147
+ var CONTAINER_DESPITE_A_CONTENT_PORT = /* @__PURE__ */ new Set(["Page"]);
1148
+ var contentTypesCache = /* @__PURE__ */ new Map();
1149
+ function contentBearingTypeNames(catalogPath = ENRICHED_CATALOG_JSON) {
1150
+ if (contentTypesCache.has(catalogPath)) return contentTypesCache.get(catalogPath);
1151
+ let names;
1152
+ try {
1153
+ const catalog = JSON.parse(fs2.readFileSync(catalogPath, "utf8"));
1154
+ names = new Set(
1155
+ (catalog.nodes || []).filter(
1156
+ (n) => n.isVisual === true && !CONTAINER_DESPITE_A_CONTENT_PORT.has(n.typeName) && (n.inputs || []).some((p) => CONTENT_BEARING_PORTS.test(p.name))
1157
+ ).map((n) => n.typeName)
1158
+ );
1159
+ if (names.size === 0) names = null;
1160
+ } catch {
1161
+ names = null;
1162
+ }
1163
+ contentTypesCache.set(catalogPath, names);
1164
+ return names;
1165
+ }
1166
+ function rootNodes(nodes) {
1167
+ return (nodes || []).filter((n) => n.parent === void 0);
1168
+ }
1169
+ function subtreeNodes(nodes, ids) {
1170
+ const byId = new Map((nodes || []).map((n) => [n.id, n]));
1171
+ const seen = /* @__PURE__ */ new Set();
1172
+ const out = [];
1173
+ const stack = [...ids];
1174
+ while (stack.length) {
1175
+ const id = stack.pop();
1176
+ if (seen.has(id)) continue;
1177
+ seen.add(id);
1178
+ const node = byId.get(id);
1179
+ if (!node) continue;
1180
+ out.push(node);
1181
+ for (const child of node.children || []) stack.push(child);
1182
+ }
1183
+ return out;
1184
+ }
1185
+ function projectVisualPredicate(components) {
1186
+ const catalogVisual = visualTypeNames();
1187
+ const byName = new Map(components.map((c) => [c.name, c]));
1188
+ const memo = /* @__PURE__ */ new Map();
1189
+ const inFlight = /* @__PURE__ */ new Set();
1190
+ const isVisual = (typeName) => {
1191
+ if (catalogVisual && catalogVisual.has(typeName)) return true;
1192
+ if (!byName.has(typeName)) return false;
1193
+ if (memo.has(typeName)) return memo.get(typeName);
1194
+ if (inFlight.has(typeName)) return false;
1195
+ inFlight.add(typeName);
1196
+ const draws = effectiveRoots(byName.get(typeName), isVisual).length > 0;
1197
+ inFlight.delete(typeName);
1198
+ memo.set(typeName, draws);
1199
+ return draws;
1200
+ };
1201
+ return isVisual;
1202
+ }
1203
+ function effectiveRoots(component, isVisual) {
1204
+ if (component.declaredRoots !== void 0) return component.declaredRoots;
1205
+ return rootNodes(component.nodes).filter((n) => isVisual(n.type)).map((n) => n.id);
1206
+ }
1207
+ var BlankCause = {
1208
+ Unreadable: "project-unreadable",
1209
+ NoCatalog: "no-catalog",
1210
+ NoStartPage: "no-start-page",
1211
+ StartPageMissing: "start-page-missing",
1212
+ PageNotRouted: "page-not-routed",
1213
+ PageWithoutPageNode: "page-without-page-node",
1214
+ PageWithoutVisualRoot: "page-without-visual-root",
1215
+ PageHasNoContent: "page-has-no-content",
1216
+ InstancesWithoutVisualRoot: "instances-without-visual-root",
1217
+ Undetermined: "undetermined"
1218
+ };
1219
+ function blankDiagnosis(projectDir2) {
1220
+ const { ok, project, components } = readComponents(projectDir2);
1221
+ if (!ok || !components.length) {
1222
+ return {
1223
+ ok: false,
1224
+ cause: BlankCause.Unreadable,
1225
+ message: "The project could not be read from disk, so there is nothing to diagnose against.",
1226
+ checked: []
1227
+ };
1228
+ }
1229
+ if (!visualTypeNames() || !contentBearingTypeNames()) {
1230
+ return {
1231
+ ok: false,
1232
+ cause: BlankCause.NoCatalog,
1233
+ message: "No generated node catalog in this checkout, so which node types draw is not knowable here and the cause was not determined.",
1234
+ checked: []
1235
+ };
1236
+ }
1237
+ const isVisual = projectVisualPredicate(components);
1238
+ const contentTypes = contentBearingTypeNames();
1239
+ const byName = new Map(components.map((c) => [c.name, c]));
1240
+ const checked = [];
1241
+ const done = (cause, message, evidence) => ({ ok: true, cause, message, evidence, checked });
1242
+ const routers = [];
1243
+ const pageComponents = /* @__PURE__ */ new Set();
1244
+ for (const c of components) {
1245
+ for (const n of c.nodes) {
1246
+ if (n.type === "Router") routers.push({ component: c.name, ...n.parameters || {} });
1247
+ if (n.type === "Page") pageComponents.add(c.name);
1248
+ }
1249
+ }
1250
+ const routed = /* @__PURE__ */ new Set();
1251
+ let startPage;
1252
+ for (const r of routers) {
1253
+ const pages = r.pages || {};
1254
+ for (const route of pages.routes || []) routed.add(route);
1255
+ if (!startPage && pages.startPage) startPage = pages.startPage;
1256
+ }
1257
+ if (!routers.length || !startPage) {
1258
+ return done(
1259
+ BlankCause.NoStartPage,
1260
+ routers.length ? `${plural(routers.length, "Router", "Routers")} in this project, and none names a startPage \u2014 nothing selects a page to show.` : "No Router anywhere in the project, so no page is ever selected to be shown.",
1261
+ { routers: routers.map((r) => r.component) }
1262
+ );
1263
+ }
1264
+ checked.push(`a Router names "${startPage}" as its startPage`);
1265
+ const page = byName.get(startPage);
1266
+ if (!page) {
1267
+ return done(
1268
+ BlankCause.StartPageMissing,
1269
+ `The Router's startPage is "${startPage}" and no component of that name exists in the project.`,
1270
+ { component: startPage, known: [...byName.keys()] }
1271
+ );
1272
+ }
1273
+ if (!routed.has(startPage)) {
1274
+ return done(
1275
+ BlankCause.PageNotRouted,
1276
+ `"${startPage}" is the startPage but no Router lists it in its routes, so the route is never reached.`,
1277
+ { component: startPage, routes: [...routed] }
1278
+ );
1279
+ }
1280
+ checked.push("a Router lists it in its routes");
1281
+ if (!pageComponents.has(startPage)) {
1282
+ return done(
1283
+ BlankCause.PageWithoutPageNode,
1284
+ `"${startPage}" contains no Page node. A page component renders blank without one at its root.`,
1285
+ { component: startPage }
1286
+ );
1287
+ }
1288
+ checked.push("it has a Page node");
1289
+ const pageRoots = effectiveRoots(page, isVisual);
1290
+ if (!pageRoots.length) {
1291
+ return done(
1292
+ BlankCause.PageWithoutVisualRoot,
1293
+ `"${startPage}" has no visual root \u2014 nothing in it is a node that draws, so the component renders nothing.`,
1294
+ { component: startPage }
1295
+ );
1296
+ }
1297
+ checked.push(`its visual root set is ${JSON.stringify(pageRoots)}`);
1298
+ const drawn = subtreeNodes(page.nodes, pageRoots);
1299
+ const carriesContent = (node) => contentTypes.has(node.type) || byName.has(node.type) && componentDrawsContent(node.type);
1300
+ const seenComponents = /* @__PURE__ */ new Set();
1301
+ function componentDrawsContent(name) {
1302
+ if (seenComponents.has(name)) return false;
1303
+ seenComponents.add(name);
1304
+ const c = byName.get(name);
1305
+ if (!c) return false;
1306
+ const nodes = subtreeNodes(c.nodes, effectiveRoots(c, isVisual));
1307
+ const answer = nodes.some((n) => contentTypes.has(n.type) || byName.has(n.type) && componentDrawsContent(n.type));
1308
+ seenComponents.delete(name);
1309
+ return answer;
1310
+ }
1311
+ if (!drawn.some(carriesContent)) {
1312
+ const only = drawn.map((n) => n.type);
1313
+ return done(
1314
+ BlankCause.PageHasNoContent,
1315
+ `Nothing under the visual root of "${startPage}" shows content of its own: it draws ${plural(drawn.length, "node", "nodes")} (${[...new Set(only)].join(", ")}); nothing there is text, an image, or an instance that resolves to either. The page is an empty container.`,
1316
+ { component: startPage, nodes: drawn.length, types: [...new Set(only)] }
1317
+ );
1318
+ }
1319
+ checked.push("it has content-bearing nodes under that root");
1320
+ const instanceTypes = [...new Set(drawn.filter((n) => byName.has(n.type)).map((n) => n.type))];
1321
+ const empty = instanceTypes.filter((t) => effectiveRoots(byName.get(t), isVisual).length === 0);
1322
+ if (empty.length) {
1323
+ return done(
1324
+ BlankCause.InstancesWithoutVisualRoot,
1325
+ `${plural(empty.length, "component has", "components have")} no visual root, so every instance of ${empty.length === 1 ? "it" : "them"} on "${startPage}" renders nothing: ${empty.join(", ")}.`,
1326
+ { component: startPage, components: empty }
1327
+ );
1328
+ }
1329
+ return done(
1330
+ BlankCause.Undetermined,
1331
+ `Checked, and each held: ${checked.join("; ")}. The graph says this page should draw, so the cause is downstream of it \u2014 a runtime error, or content that reached the DOM and is not visible.`,
1332
+ { component: startPage }
1333
+ );
1334
+ }
1335
+ var CHROME_CANDIDATES = {
1336
+ darwin: [
1337
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1338
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
1339
+ "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
1340
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
1341
+ ],
1342
+ linux: [
1343
+ "/usr/bin/google-chrome",
1344
+ "/usr/bin/google-chrome-stable",
1345
+ "/usr/bin/chromium",
1346
+ "/usr/bin/chromium-browser",
1347
+ "/snap/bin/chromium"
1348
+ ],
1349
+ win32: [
1350
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
1351
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe"
1352
+ ]
1353
+ };
1354
+ function reachableComponents(startName, byName) {
1355
+ const seen = /* @__PURE__ */ new Set();
1356
+ const queue = [startName];
1357
+ while (queue.length) {
1358
+ const name = queue.shift();
1359
+ if (seen.has(name)) continue;
1360
+ seen.add(name);
1361
+ const component = byName.get(name);
1362
+ if (!component) continue;
1363
+ for (const node of component.nodes) {
1364
+ if (byName.has(node.type)) queue.push(node.type);
1365
+ const template = (node.parameters || {}).template;
1366
+ if (typeof template === "string" && byName.has(template)) queue.push(template);
1367
+ }
1368
+ }
1369
+ return seen;
1370
+ }
1371
+ function routedPages(projectDir2) {
1372
+ const { ok, project, components } = readComponents(projectDir2);
1373
+ if (!ok) return { ok: false, pages: [], startPage: void 0, pathType: "hash" };
1374
+ const pathType = project.settings && project.settings.navigationPathType || "hash";
1375
+ const byName = new Map(components.map((c) => [c.name, c]));
1376
+ const routes = [];
1377
+ let startPage;
1378
+ for (const c of components) {
1379
+ for (const n of c.nodes) {
1380
+ if (n.type !== "Router") continue;
1381
+ const pages2 = (n.parameters || {}).pages || {};
1382
+ for (const route of pages2.routes || []) if (!routes.includes(route)) routes.push(route);
1383
+ if (!startPage && pages2.startPage) startPage = pages2.startPage;
1384
+ }
1385
+ }
1386
+ const pages = routes.map((component) => {
1387
+ const c = byName.get(component);
1388
+ const pageNode = c && c.nodes.find((n) => n.type === "Page");
1389
+ const params = pageNode && pageNode.parameters || {};
1390
+ const urlPath = typeof params.urlPath === "string" ? params.urlPath : void 0;
1391
+ const isStart = component === startPage;
1392
+ let unreachable;
1393
+ if (!c) unreachable = "no component of that name exists in the project";
1394
+ else if (!pageNode) unreachable = "the component has no Page node, so it has no URL of its own";
1395
+ else if (!isStart && !urlPath) unreachable = "its Page node sets no urlPath, so there is no URL to navigate to";
1396
+ else if (!isStart && /[{}]/.test(urlPath))
1397
+ unreachable = `its urlPath "${urlPath}" takes a route parameter, which this harness has no value for`;
1398
+ return {
1399
+ component,
1400
+ title: typeof params.title === "string" ? params.title : void 0,
1401
+ urlPath,
1402
+ isStart,
1403
+ reachable: !unreachable,
1404
+ ...unreachable ? { unreachable } : {},
1405
+ url: isStart ? "/" : unreachable ? void 0 : pathType === "hash" ? `/#${urlPath}` : `/${urlPath}`
1406
+ };
1407
+ });
1408
+ return { ok: true, pages, startPage, pathType };
1409
+ }
1410
+ function resolvePageRequest(routes, requested) {
1411
+ const raw = String(requested).trim();
1412
+ const normalised = raw.replace(/^\//, "").replace(/^#/, "").replace(/^\//, "");
1413
+ const registered = routes.pages.map((p) => `${p.isStart ? "/" : p.urlPath || "(no urlPath)"} (${p.component})`).join(", ");
1414
+ if (!routes.pages.length) {
1415
+ return {
1416
+ ok: false,
1417
+ message: `Cannot render the page "${raw}": this project registers no pages at all. A page is reachable only when a Router node lists it in its \`pages\` parameter.`
1418
+ };
1419
+ }
1420
+ if (normalised === "") {
1421
+ const start = routes.pages.find((p) => p.isStart);
1422
+ if (start) return { ok: true, page: start };
1423
+ }
1424
+ let match = routes.pages.find((p) => p.urlPath === normalised) || routes.pages.find((p) => p.component === raw || p.component.replace(/^\//, "") === normalised);
1425
+ if (!match) {
1426
+ const titled = routes.pages.filter((p) => p.title !== void 0 && p.title === raw);
1427
+ if (titled.length > 1) {
1428
+ return {
1429
+ ok: false,
1430
+ message: `Cannot render the page "${raw}": ${titled.length} pages have that title (${titled.map((p) => p.component).join(", ")}). Ask for one by its path.`
1431
+ };
1432
+ }
1433
+ match = titled[0];
1434
+ }
1435
+ if (!match) {
1436
+ return {
1437
+ ok: false,
1438
+ message: `Cannot render the page "${raw}": no page registered at that path. Registered pages: ${registered}.`
1439
+ };
1440
+ }
1441
+ if (!match.reachable) {
1442
+ return {
1443
+ ok: false,
1444
+ message: `The page "${raw}" is registered as ${match.component}, but this harness cannot address it: ${match.unreachable}.`
1445
+ };
1446
+ }
1447
+ return { ok: true, page: match };
1448
+ }
1449
+ function findChrome() {
1450
+ const probed = [];
1451
+ const check = (p) => {
1452
+ probed.push(p);
1453
+ return fs2.existsSync(p) ? p : null;
1454
+ };
1455
+ if (process.env.CHROME_PATH) {
1456
+ const found = check(process.env.CHROME_PATH);
1457
+ if (found) return { chrome: found, probed };
1458
+ }
1459
+ for (const candidate of CHROME_CANDIDATES[process.platform] || []) {
1460
+ const found = check(candidate);
1461
+ if (found) return { chrome: found, probed };
1462
+ }
1463
+ return { chrome: null, probed };
1464
+ }
1465
+ function checkPrerequisites(projectDir2) {
1466
+ const problems = [];
1467
+ if (!projectDir2 || !fs2.existsSync(path2.join(projectDir2, "nodegx.project.json"))) {
1468
+ problems.push(
1469
+ `Not a NodeGX v2 project directory (no nodegx.project.json): ${projectDir2 || "(none given)"}.`
1470
+ );
1471
+ }
1472
+ if (!VIEWER_BUNDLE || !fs2.existsSync(VIEWER_BUNDLE)) {
1473
+ problems.push(
1474
+ `The viewer bundle is missing. Build it first: cd packages/noodl-viewer-react && npx webpack --config webpack-configs/webpack.viewer.prod.js. Probed: ${HARNESS_PATHS.VIEWER_DIR.probed.join(", ")}`
1475
+ );
1476
+ }
1477
+ if (!WS_MODULE) {
1478
+ problems.push(
1479
+ `The "ws" package is missing. Run npm install at the repo root. Probed: ${HARNESS_PATHS.WS_MODULE.probed.join(", ")}`
1480
+ );
1481
+ }
1482
+ if (!CATALOG_JSON) {
1483
+ problems.push(
1484
+ `The node catalog is missing. Probed: ${HARNESS_PATHS.CATALOG_JSON.probed.join(", ")}`
1485
+ );
1486
+ }
1487
+ if (!ENRICHED_CATALOG_JSON) {
1488
+ problems.push(
1489
+ `The enriched node catalog is missing. Probed: ${HARNESS_PATHS.ENRICHED_CATALOG_JSON.probed.join(", ")}`
1490
+ );
1491
+ }
1492
+ const { chrome, probed } = findChrome();
1493
+ if (!chrome) {
1494
+ problems.push(
1495
+ "No Chrome or Chromium binary found. Install Google Chrome, or set CHROME_PATH to one. If you cannot install one, pass allow_unrendered to skip the render check rather than waiting on it. Probed: " + probed.join(", ")
1496
+ );
1497
+ }
1498
+ return { ok: problems.length === 0, problems, chrome };
1499
+ }
1500
+ function parseViewports2(spec) {
1501
+ if (!spec) return DEFAULT_VIEWPORTS;
1502
+ const known = new Map(DEFAULT_VIEWPORTS.map((v) => [v.name, v]));
1503
+ return spec.split(",").map((token) => {
1504
+ const trimmed = token.trim();
1505
+ if (known.has(trimmed)) return known.get(trimmed);
1506
+ const m = /^(\d+)x(\d+)$/.exec(trimmed);
1507
+ if (!m) {
1508
+ const error = new Error(`Unknown viewport "${trimmed}". Use ${[...known.keys()].join(", ")} or WIDTHxHEIGHT.`);
1509
+ error.problems = [error.message];
1510
+ error.actionable = true;
1511
+ error.usage = true;
1512
+ throw error;
1513
+ }
1514
+ const width = Number(m[1]);
1515
+ return { name: trimmed, width, height: Number(m[2]), mobile: width < 500 };
1516
+ });
1517
+ }
1518
+ function freePort() {
1519
+ return new Promise((resolve, reject) => {
1520
+ const server = net.createServer();
1521
+ server.once("error", reject);
1522
+ server.listen(0, "127.0.0.1", () => {
1523
+ const { port } = server.address();
1524
+ server.close(() => resolve(port));
1525
+ });
1526
+ });
1527
+ }
1528
+ var wait = (ms) => new Promise((r) => setTimeout(r, ms));
1529
+ function httpJson(port, urlPath) {
1530
+ return new Promise((resolve, reject) => {
1531
+ const req = http.get({ host: "127.0.0.1", port, path: urlPath, timeout: 3e3 }, (res) => {
1532
+ let body = "";
1533
+ res.on("data", (d) => body += d);
1534
+ res.on("end", () => {
1535
+ try {
1536
+ resolve(JSON.parse(body));
1537
+ } catch (e) {
1538
+ reject(e);
1539
+ }
1540
+ });
1541
+ });
1542
+ req.on("timeout", () => {
1543
+ req.destroy();
1544
+ reject(new Error("timeout"));
1545
+ });
1546
+ req.on("error", reject);
1547
+ });
1548
+ }
1549
+ function settleSummary(settles) {
1550
+ if (!settles || !settles.length) return void 0;
1551
+ const waited = settles.reduce((n, s) => n + s.waitedMs, 0);
1552
+ const ceiling = settles.reduce((n, s) => n + s.ceilingMs, 0);
1553
+ return {
1554
+ count: settles.length,
1555
+ waitedMs: waited,
1556
+ /** What the fixed timers would have slept for the same run. */
1557
+ fixedTimerMs: ceiling,
1558
+ savedMs: ceiling - waited,
1559
+ atCeiling: settles.filter((s) => !s.quiet).length,
1560
+ rows: settles
1561
+ };
1562
+ }
1563
+ function connect(wsUrl, onEvent) {
1564
+ const WebSocket = require(WS_MODULE);
1565
+ return new Promise((resolve, reject) => {
1566
+ const ws = new WebSocket(wsUrl, { maxPayload: 512 * 1024 * 1024 });
1567
+ let nextId = 0;
1568
+ const pending = /* @__PURE__ */ new Map();
1569
+ ws.on(
1570
+ "open",
1571
+ () => resolve({
1572
+ send(method, params) {
1573
+ const id = ++nextId;
1574
+ ws.send(JSON.stringify({ id, method, params }));
1575
+ return new Promise((res, rej) => pending.set(id, { res, rej }));
1576
+ },
1577
+ close() {
1578
+ ws.close();
1579
+ }
1580
+ })
1581
+ );
1582
+ ws.on("error", reject);
1583
+ ws.on("message", (data) => {
1584
+ const msg = JSON.parse(data);
1585
+ if (msg.id && pending.has(msg.id)) {
1586
+ const { res, rej } = pending.get(msg.id);
1587
+ pending.delete(msg.id);
1588
+ msg.error ? rej(new Error(msg.error.message)) : res(msg.result);
1589
+ } else if (msg.method && onEvent) {
1590
+ onEvent(msg);
1591
+ }
1592
+ });
1593
+ });
1594
+ }
1595
+ async function evaluate(client, expression) {
1596
+ const r = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
1597
+ if (r.exceptionDetails) {
1598
+ throw new Error((r.exceptionDetails.exception || {}).description || r.exceptionDetails.text);
1599
+ }
1600
+ return r.result.value;
1601
+ }
1602
+ var BOOT_MS = 3500;
1603
+ var REFLOW_MS = 1200;
1604
+ var PAGE_NAV_MS = 2e3;
1605
+ var PAGE_TABS = 4;
1606
+ var QUIET_MS = 150;
1607
+ var SETTLE_POLL_MS = 25;
1608
+ function settleExpression(ceilingMs, rafs) {
1609
+ return `(async () => {
1610
+ const started = Date.now();
1611
+ const deadline = started + ${ceilingMs};
1612
+ const left = () => Math.max(0, deadline - Date.now());
1613
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1614
+ const reasons = [];
1615
+
1616
+ // 1. The document. In hash routing a route change is not a document load at
1617
+ // all, so this is already 'complete' and costs nothing \u2014 which is most of
1618
+ // where PAGE_NAV_MS was going.
1619
+ if (document.readyState !== 'complete') {
1620
+ reasons.push('load');
1621
+ await Promise.race([
1622
+ new Promise((res) => window.addEventListener('load', res, { once: true })),
1623
+ sleep(left())
1624
+ ]);
1625
+ }
1626
+
1627
+ // 2. Fonts. Measured text is compared against the font sets, and a page read
1628
+ // mid-swap reports the fallback family as if the author had chosen it.
1629
+ try {
1630
+ if (document.fonts && document.fonts.status !== 'loaded') {
1631
+ reasons.push('fonts');
1632
+ await Promise.race([document.fonts.ready, sleep(left())]);
1633
+ }
1634
+ } catch (e) {
1635
+ /* no Font Loading API: fall through to the quiet window */
1636
+ }
1637
+
1638
+ // 3. Images still in flight. \`broken-image\` is counted from naturalWidth, and
1639
+ // an image that has not loaded YET is indistinguishable from one that never
1640
+ // will \u2014 this is the finding a too-eager budget invents.
1641
+ const pending = () => Array.prototype.filter.call(document.images, (im) => !im.complete).length;
1642
+ if (pending()) reasons.push('images');
1643
+ while (left() > 0 && pending() > 0) await sleep(${SETTLE_POLL_MS});
1644
+
1645
+ // 4. Two frames \u2014 which is also what lets step 5 see anything. A transition
1646
+ // does not exist until the style recalc that starts it, so asking for the
1647
+ // running animations before a frame has passed returns an empty list and
1648
+ // the wait below is skipped on exactly the pages that need it.
1649
+ for (let i = 0; i < ${rafs} && left() > 0; i++) {
1650
+ // Raced against a sleep: a headless page that is never composited never
1651
+ // fires rAF, and an unraced await would burn the whole budget waiting for a
1652
+ // frame that is not coming \u2014 turning every settle back into the fixed timer.
1653
+ await Promise.race([new Promise((res) => requestAnimationFrame(() => res())), sleep(50)]);
1654
+ }
1655
+
1656
+ // 5. \u{1F534} CSS transitions and animations, which are the reason this step exists.
1657
+ //
1658
+ // MEASURED, not anticipated: \`project-examples/lessons/snacks\` at 390px has
1659
+ // ONE element still at opacity 0 at 190ms and opaque at 1200ms. FLD-012's
1660
+ // \`visible\` correctly excludes a transparent element, so the eager read
1661
+ // counted 9 text elements where the fixed timer counted 10 \u2014 and
1662
+ // \`flat-type-scale\` gates at **>= 10**. A settle budget with this step
1663
+ // missing does not report a page differently; it DELETES a real finding and
1664
+ // reports the page clean.
1665
+ //
1666
+ // \u26A0\uFE0F Only animations that will actually END are waited for. A spinner
1667
+ // iterates forever, and \`await\`ing its \`finished\` would spend the entire
1668
+ // budget on every page that has one \u2014 which is the fixed timer again, on the
1669
+ // pages least able to afford it.
1670
+ try {
1671
+ const finite = (document.getAnimations ? document.getAnimations() : []).filter((anim) => {
1672
+ try {
1673
+ const timing = anim.effect.getComputedTiming();
1674
+ return Number.isFinite(timing.iterations) && Number.isFinite(timing.endTime);
1675
+ } catch (e) {
1676
+ return false;
1677
+ }
1678
+ });
1679
+ if (finite.length) {
1680
+ reasons.push('animations:' + finite.length);
1681
+ await Promise.race([Promise.allSettled(finite.map((anim) => anim.finished)), sleep(left())]);
1682
+ }
1683
+ } catch (e) {
1684
+ /* no Web Animations API: the quiet window is the only guard left */
1685
+ }
1686
+
1687
+ // 6. And finally the quiet window.
1688
+ let lastChange = Date.now();
1689
+ const observer = new MutationObserver(() => {
1690
+ lastChange = Date.now();
1691
+ });
1692
+ observer.observe(document.documentElement, {
1693
+ subtree: true,
1694
+ childList: true,
1695
+ attributes: true,
1696
+ characterData: true
1697
+ });
1698
+ try {
1699
+ while (left() > 0 && Date.now() - lastChange < ${QUIET_MS}) await sleep(${SETTLE_POLL_MS});
1700
+ } finally {
1701
+ observer.disconnect();
1702
+ }
1703
+
1704
+ return {
1705
+ waitedMs: Date.now() - started,
1706
+ ceilingMs: ${ceilingMs},
1707
+ // \u{1F534} The honest field. \`false\` means the budget ran out and this is the OLD
1708
+ // fixed-timer behaviour \u2014 the same wait, and the same reading. A run where
1709
+ // this is false everywhere has not been sped up; it has been re-measured.
1710
+ quiet: Date.now() - lastChange >= ${QUIET_MS},
1711
+ waitedFor: reasons
1712
+ };
1713
+ })()`;
1714
+ }
1715
+ var RENDER_PROFILE_PREFIX = "nodegx-render-";
1716
+ function reapOrphanedRenderProcesses() {
1717
+ const reaped = [];
1718
+ try {
1719
+ const ps = require("child_process").execFileSync("ps", ["-Ao", "pid=,ppid=,command="], {
1720
+ encoding: "utf8",
1721
+ maxBuffer: 8 * 1024 * 1024
1722
+ });
1723
+ for (const line of ps.split("\n")) {
1724
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
1725
+ if (!m) continue;
1726
+ const [, pid, ppid, command] = m;
1727
+ if (ppid !== "1") continue;
1728
+ const isOurServer = command.includes(RENDER_SCRIPT);
1729
+ const isOurChrome = command.includes(`--user-data-dir=`) && command.includes(RENDER_PROFILE_PREFIX);
1730
+ if (!isOurServer && !isOurChrome) continue;
1731
+ try {
1732
+ process.kill(Number(pid), "SIGKILL");
1733
+ reaped.push(`${pid} ${isOurServer ? "render-from-disk" : "chrome"}`);
1734
+ } catch {
1735
+ }
1736
+ }
1737
+ } catch {
1738
+ return [];
1739
+ }
1740
+ try {
1741
+ for (const name of fs2.readdirSync(os.tmpdir())) {
1742
+ if (!name.startsWith(RENDER_PROFILE_PREFIX)) continue;
1743
+ const dir = path2.join(os.tmpdir(), name);
1744
+ try {
1745
+ if (Date.now() - fs2.statSync(dir).mtimeMs < 60 * 60 * 1e3) continue;
1746
+ fs2.rmSync(dir, { recursive: true, force: true });
1747
+ } catch {
1748
+ }
1749
+ }
1750
+ } catch {
1751
+ }
1752
+ if (reaped.length) console.error(`[render] reaped ${reaped.length} orphaned process(es): ${reaped.join(", ")}`);
1753
+ return reaped;
1754
+ }
1755
+ async function unthrottle(client) {
1756
+ for (const [method, params] of [
1757
+ ["Emulation.setFocusEmulationEnabled", { enabled: true }],
1758
+ ["Page.setWebLifecycleState", { state: "active" }]
1759
+ ]) {
1760
+ try {
1761
+ await client.send(method, params);
1762
+ } catch {
1763
+ }
1764
+ }
1765
+ }
1766
+ async function attachTab({ wsUrl, servePort, settles, label }) {
1767
+ const consoleErrors = [];
1768
+ const listeners = /* @__PURE__ */ new Set();
1769
+ const client = await connect(wsUrl, (msg) => {
1770
+ for (const listener of listeners) listener(msg);
1771
+ if (msg.method === "Runtime.exceptionThrown") {
1772
+ const d = msg.params.exceptionDetails || {};
1773
+ consoleErrors.push(String(d.exception && d.exception.description || d.text || "").slice(0, 300));
1774
+ } else if (msg.method === "Runtime.consoleAPICalled" && msg.params.type === "error") {
1775
+ consoleErrors.push(
1776
+ msg.params.args.map((a) => String(a.value !== void 0 ? a.value : a.description || "")).join(" ").slice(0, 300)
1777
+ );
1778
+ }
1779
+ });
1780
+ await client.send("Page.enable", {});
1781
+ await client.send("Runtime.enable", {});
1782
+ const settle = async (what, ceilingMs, rafs) => {
1783
+ const stat = await evaluate(client, settleExpression(ceilingMs, rafs));
1784
+ settles.push({ what, ...label ? { tab: label } : {}, ...stat });
1785
+ return stat;
1786
+ };
1787
+ const goto = async (urlPath, ceilingMs) => {
1788
+ await client.send("Page.navigate", { url: `http://127.0.0.1:${servePort}${urlPath}` });
1789
+ await settle(`navigate ${urlPath}`, ceilingMs, 2);
1790
+ };
1791
+ return {
1792
+ client,
1793
+ consoleErrors,
1794
+ settle,
1795
+ goto,
1796
+ evaluate: (expression) => evaluate(client, expression),
1797
+ /**
1798
+ * Go to another route and let it settle.
1799
+ *
1800
+ * UNI-010 §8.2. `PAGE_NAV_MS` rather than `BOOT_MS`: the runtime is already
1801
+ * booted, and in the default `hash` mode this is a `hashchange` the router
1802
+ * listens for rather than a document load. The viewport emulation set by
1803
+ * `setViewport` survives it — `Emulation.setDeviceMetricsOverride` is
1804
+ * per-session, not per-document — so a caller that has already sized the
1805
+ * page does not have to size it again.
1806
+ */
1807
+ navigate: (urlPath) => goto(urlPath, PAGE_NAV_MS),
1808
+ /** Hear every CDP event on this tab. Returns the function that stops it. */
1809
+ onEvent(listener) {
1810
+ listeners.add(listener);
1811
+ return () => listeners.delete(listener);
1812
+ },
1813
+ async setViewport(vp) {
1814
+ await client.send("Emulation.setDeviceMetricsOverride", {
1815
+ width: vp.width,
1816
+ height: vp.height,
1817
+ deviceScaleFactor: 1,
1818
+ mobile: Boolean(vp.mobile)
1819
+ });
1820
+ await settle(`viewport ${vp.name}`, REFLOW_MS, 2);
1821
+ },
1822
+ close() {
1823
+ try {
1824
+ client.close();
1825
+ } catch {
1826
+ }
1827
+ }
1828
+ };
1829
+ }
1830
+ async function withRenderedPage(options, fn) {
1831
+ const { projectDir: projectDir2, backendPort, editorTokens = false, chromeArgs = [], beforeBoot } = options;
1832
+ reapOrphanedRenderProcesses();
1833
+ const pre = checkPrerequisites(projectDir2);
1834
+ if (!pre.ok) {
1835
+ const error = new Error(pre.problems.join(" "));
1836
+ error.problems = pre.problems;
1837
+ error.actionable = true;
1838
+ throw error;
1839
+ }
1840
+ const servePort = await freePort();
1841
+ const cdpPort = await freePort();
1842
+ const serverArgs = [RENDER_SCRIPT, projectDir2, "--port", String(servePort)];
1843
+ if (backendPort) serverArgs.push("--backend-port", String(backendPort));
1844
+ if (editorTokens) serverArgs.push("--editor-tokens");
1845
+ const server = spawn(process.execPath, serverArgs, {
1846
+ stdio: ["ignore", "pipe", "pipe"],
1847
+ env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }
1848
+ });
1849
+ let serverLog = "";
1850
+ server.stdout.on("data", (d) => serverLog += d);
1851
+ server.stderr.on("data", (d) => serverLog += d);
1852
+ const profile = fs2.mkdtempSync(path2.join(os.tmpdir(), "nodegx-render-"));
1853
+ const chrome = spawn(
1854
+ pre.chrome,
1855
+ [
1856
+ "--headless=new",
1857
+ `--remote-debugging-port=${cdpPort}`,
1858
+ `--user-data-dir=${profile}`,
1859
+ "--no-first-run",
1860
+ "--no-default-browser-check",
1861
+ "--hide-scrollbars",
1862
+ "--disable-gpu",
1863
+ // P112-S14-KEYCHAIN: on macOS a page that touches storage makes Chrome open the login keychain. With no
1864
+ // keychain where `HOME` points (an empty HOME, a CI user) the OS shows "Keychain Not Found" on the person's
1865
+ // screen and Chrome waits for the answer, so the render hangs until its 240 s kill. A throwaway profile
1866
+ // needs no real keychain.
1867
+ "--use-mock-keychain",
1868
+ // Caller-supplied flags, for drives that need a capability this browser
1869
+ // does not have by default — a synthetic microphone and camera
1870
+ // (`--use-fake-device-for-media-stream`) being the case that added this.
1871
+ // Appended last so a drive can override a default above it.
1872
+ ...chromeArgs,
1873
+ "about:blank"
1874
+ ],
1875
+ { stdio: "ignore" }
1876
+ );
1877
+ const extraTabs = [];
1878
+ let browserClient;
1879
+ let cleanedUp = false;
1880
+ const cleanup = (client2) => {
1881
+ if (cleanedUp) return;
1882
+ cleanedUp = true;
1883
+ for (const tab of extraTabs) tab.close();
1884
+ try {
1885
+ if (browserClient) browserClient.close();
1886
+ } catch {
1887
+ }
1888
+ try {
1889
+ if (client2) client2.close();
1890
+ } catch {
1891
+ }
1892
+ chrome.kill();
1893
+ server.kill();
1894
+ fs2.rm(profile, { recursive: true, force: true }, () => {
1895
+ });
1896
+ };
1897
+ const onSignal = (signal) => () => {
1898
+ cleanup(void 0);
1899
+ process.exit(signal === "SIGINT" ? 130 : 143);
1900
+ };
1901
+ const handlers = [
1902
+ ["SIGINT", onSignal("SIGINT")],
1903
+ ["SIGTERM", onSignal("SIGTERM")],
1904
+ ["SIGHUP", onSignal("SIGHUP")],
1905
+ ["exit", () => cleanup(void 0)]
1906
+ ];
1907
+ for (const [event, handler] of handlers) process.once(event, handler);
1908
+ const releaseHandlers = () => {
1909
+ for (const [event, handler] of handlers) process.removeListener(event, handler);
1910
+ };
1911
+ let client;
1912
+ try {
1913
+ let targets;
1914
+ for (let i = 0; i < 40 && !targets; i++) {
1915
+ await wait(250);
1916
+ try {
1917
+ targets = await httpJson(cdpPort, "/json/list");
1918
+ } catch {
1919
+ }
1920
+ }
1921
+ if (!targets) throw new Error(`Chrome never opened a debugging port (${pre.chrome}).`);
1922
+ const target = targets.find((t) => t.type === "page");
1923
+ const settles = [];
1924
+ const main2 = await attachTab({ wsUrl: target.webSocketDebuggerUrl, servePort, settles });
1925
+ client = main2.client;
1926
+ const openTab = async (label) => {
1927
+ if (!browserClient) {
1928
+ const version = await httpJson(cdpPort, "/json/version");
1929
+ browserClient = await connect(version.webSocketDebuggerUrl);
1930
+ await unthrottle(main2.client);
1931
+ }
1932
+ const { targetId } = await browserClient.send("Target.createTarget", { url: "about:blank" });
1933
+ let described;
1934
+ for (let i = 0; i < 40 && !described; i++) {
1935
+ const list = await httpJson(cdpPort, "/json/list");
1936
+ described = list.find((t) => t.id === targetId && t.webSocketDebuggerUrl);
1937
+ if (!described) await wait(50);
1938
+ }
1939
+ if (!described) throw new Error("a tab was created and never appeared in /json/list with a debugger URL");
1940
+ const tab = await attachTab({ wsUrl: described.webSocketDebuggerUrl, servePort, settles, label });
1941
+ await unthrottle(tab.client);
1942
+ tab.targetId = targetId;
1943
+ extraTabs.push(tab);
1944
+ return tab;
1945
+ };
1946
+ const closeTab = async (tab) => {
1947
+ tab.close();
1948
+ const at = extraTabs.indexOf(tab);
1949
+ if (at !== -1) extraTabs.splice(at, 1);
1950
+ try {
1951
+ if (tab.targetId) await browserClient.send("Target.closeTarget", { targetId: tab.targetId });
1952
+ } catch {
1953
+ }
1954
+ };
1955
+ if (beforeBoot) await beforeBoot(main2);
1956
+ await main2.client.send("Page.navigate", { url: `http://127.0.0.1:${servePort}/` });
1957
+ await main2.settle("boot", BOOT_MS, 2);
1958
+ return await fn({
1959
+ client,
1960
+ consoleErrors: main2.consoleErrors,
1961
+ settles,
1962
+ serverLog: () => serverLog,
1963
+ servePort,
1964
+ evaluate: main2.evaluate,
1965
+ goto: main2.goto,
1966
+ navigate: main2.navigate,
1967
+ settle: main2.settle,
1968
+ setViewport: main2.setViewport,
1969
+ onEvent: main2.onEvent,
1970
+ openTab,
1971
+ closeTab
1972
+ });
1973
+ } finally {
1974
+ cleanup(client);
1975
+ releaseHandlers();
1976
+ }
1977
+ }
1978
+ async function renderReport2(options) {
1979
+ const {
1980
+ projectDir: projectDir2,
1981
+ viewports = DEFAULT_VIEWPORTS,
1982
+ screenshot = "full",
1983
+ screenshotPages = "start",
1984
+ deviceScaleFactor = 0.5,
1985
+ backendPort,
1986
+ editorTokens = false,
1987
+ renderRoutedPages = true,
1988
+ page: requestedPage,
1989
+ concurrency = PAGE_TABS
1990
+ } = options;
1991
+ const started = Date.now();
1992
+ const routes = routedPages(projectDir2);
1993
+ let focus;
1994
+ if (requestedPage !== void 0) {
1995
+ const resolved = resolvePageRequest(routes, requestedPage);
1996
+ if (!resolved.ok) {
1997
+ const error = new Error(resolved.message);
1998
+ error.problems = [resolved.message];
1999
+ error.actionable = true;
2000
+ throw error;
2001
+ }
2002
+ focus = resolved.page;
2003
+ }
2004
+ return withRenderedPage({ projectDir: projectDir2, backendPort, editorTokens }, async (page) => {
2005
+ const overridden = Object.fromEntries(overriddenDefaults(projectDir2));
2006
+ const placeholders = [.../* @__PURE__ */ new Set([...placeholderStrings(), ...Object.keys(overridden)])];
2007
+ const allProbes = listProbes(projectDir2);
2008
+ const { components: projectComponents } = readComponents(projectDir2);
2009
+ const byName = new Map(projectComponents.map((c) => [c.name, c]));
2010
+ const reachBy = new Map(routes.pages.map((p) => [p.component, reachableComponents(p.component, byName)]));
2011
+ const unplaced = allProbes.filter((probe) => ![...reachBy.values()].some((set) => set.has(probe.component)));
2012
+ const probesFor = (pageComponent) => {
2013
+ const reach = reachBy.get(pageComponent);
2014
+ const own = reach ? allProbes.filter((probe) => reach.has(probe.component)) : allProbes;
2015
+ return pageComponent === routes.startPage ? [...own, ...unplaced] : own;
2016
+ };
2017
+ const subject = focus || routes.pages.find((pg) => pg.isStart);
2018
+ const subjectComponent = subject ? subject.component : routes.startPage;
2019
+ const focusedOffStart = Boolean(focus && !focus.isStart);
2020
+ if (focusedOffStart) await page.navigate(focus.url);
2021
+ const measured = {};
2022
+ const screenshots = [];
2023
+ const capture = async (vp, raw, component, subject2, client = page.client) => {
2024
+ const shot = await client.send("Page.captureScreenshot", {
2025
+ format: "png",
2026
+ captureBeyondViewport: screenshot === "full",
2027
+ optimizeForSpeed: true,
2028
+ ...screenshot === "full" ? {
2029
+ clip: {
2030
+ x: 0,
2031
+ y: 0,
2032
+ width: vp.width,
2033
+ // Chrome refuses beyond 16384px; a page taller than that is
2034
+ // already the finding.
2035
+ height: Math.min(raw.pageHeight, 16384),
2036
+ scale: deviceScaleFactor
2037
+ }
2038
+ } : {}
2039
+ });
2040
+ return { name: vp.name, page: component, subject: subject2, mimeType: "image/png", base64: shot.data };
2041
+ };
2042
+ const expression = measureExpression(placeholders, probesFor(subjectComponent));
2043
+ let loggedThrough = 0;
2044
+ for (const vp of viewports) {
2045
+ const loggedBefore = loggedThrough;
2046
+ await page.setViewport(vp);
2047
+ const raw = await page.evaluate(expression);
2048
+ loggedThrough = page.consoleErrors.length;
2049
+ measured[vp.name] = {
2050
+ requested: { width: vp.width, height: vp.height },
2051
+ ...raw,
2052
+ consoleErrors: page.consoleErrors.slice(loggedBefore, loggedThrough)
2053
+ };
2054
+ if (screenshot !== "none") {
2055
+ screenshots.push(await capture(vp, raw, subjectComponent, true));
2056
+ }
2057
+ }
2058
+ const blank = Object.values(measured).some((v) => v && !v.error && v.text.elements === 0 && v.images.total === 0);
2059
+ const diagnosable = blank && !focusedOffStart;
2060
+ const { findings: rawFindings, summary } = summarise(
2061
+ measured,
2062
+ diagnosable ? blankDiagnosis(projectDir2) : void 0,
2063
+ overridden
2064
+ );
2065
+ const findings = !focusedOffStart ? rawFindings : rawFindings.map((f) => ({
2066
+ ...f,
2067
+ page: subjectComponent,
2068
+ message: f.code === RenderFinding.BlankRender ? `The routed page "${subjectComponent}" rendered nothing at all \u2014 no text and no images. Only the start page is diagnosed against the graph, so the cause was not determined here.` : `On the routed page "${subjectComponent}": ${f.message}`
2069
+ }));
2070
+ const pageReports = [];
2071
+ const extraFindings = [];
2072
+ let sweep;
2073
+ if (focus) {
2074
+ pageReports.push({ ...focus, measured: true, viewports: measured });
2075
+ } else if (renderRoutedPages) {
2076
+ const rows = new Array(routes.pages.length);
2077
+ const findingsByPage = new Array(routes.pages.length);
2078
+ const shotsByPage = new Array(routes.pages.length);
2079
+ const todo = [];
2080
+ routes.pages.forEach((p, i) => {
2081
+ if (p.isStart) {
2082
+ rows[i] = { ...p, measured: true, viewports: measured };
2083
+ } else if (!p.reachable) {
2084
+ rows[i] = { ...p, measured: false };
2085
+ } else {
2086
+ todo.push({ p, i });
2087
+ }
2088
+ });
2089
+ const measureRoutedPage = async (p, i, tab, firstInTab) => {
2090
+ let loggedThrough2 = tab.consoleErrors.length;
2091
+ await tab.goto(p.url, firstInTab ? BOOT_MS : PAGE_NAV_MS);
2092
+ const pageExpression = measureExpression(placeholders, probesFor(p.component));
2093
+ const pageMeasured = {};
2094
+ const shots = [];
2095
+ for (const vp of viewports) {
2096
+ const loggedBefore = loggedThrough2;
2097
+ await tab.setViewport(vp);
2098
+ const raw = await tab.evaluate(pageExpression);
2099
+ loggedThrough2 = tab.consoleErrors.length;
2100
+ pageMeasured[vp.name] = {
2101
+ requested: { width: vp.width, height: vp.height },
2102
+ ...raw,
2103
+ consoleErrors: tab.consoleErrors.slice(loggedBefore, loggedThrough2)
2104
+ };
2105
+ if (screenshot !== "none" && screenshotPages === "all") {
2106
+ shots.push(await capture(vp, raw, p.component, false, tab.client));
2107
+ }
2108
+ }
2109
+ const perPage = summarise(pageMeasured, void 0, overridden);
2110
+ findingsByPage[i] = perPage.findings.map((f) => ({
2111
+ ...f,
2112
+ page: p.component,
2113
+ message: f.code === RenderFinding.BlankRender ? `The routed page "${p.component}" rendered nothing at all \u2014 no text and no images. Only the start page is diagnosed against the graph, so the cause was not determined here.` : `On the routed page "${p.component}": ${f.message}`
2114
+ }));
2115
+ shotsByPage[i] = shots;
2116
+ rows[i] = { ...p, measured: true, viewports: pageMeasured, summary: perPage.summary };
2117
+ };
2118
+ if (todo.length) {
2119
+ const lanes = Math.max(1, Math.min(Math.floor(Number(concurrency)) || 1, todo.length));
2120
+ let cursor = 0;
2121
+ const lane = async (tab, ownTab) => {
2122
+ let firstInTab = ownTab;
2123
+ for (; ; ) {
2124
+ const at = cursor++;
2125
+ if (at >= todo.length) return;
2126
+ await measureRoutedPage(todo[at].p, todo[at].i, tab, firstInTab);
2127
+ firstInTab = false;
2128
+ }
2129
+ };
2130
+ const helpers = [];
2131
+ try {
2132
+ for (let k = 1; k < lanes; k++) helpers.push(await page.openTab(`tab${k}`));
2133
+ await Promise.all([lane(page, false), ...helpers.map((tab) => lane(tab, true))]);
2134
+ } finally {
2135
+ for (const tab of helpers) await page.closeTab(tab);
2136
+ }
2137
+ sweep = { pages: todo.length, tabs: lanes };
2138
+ }
2139
+ for (const row of rows) if (row) pageReports.push(row);
2140
+ for (const list of findingsByPage) if (list) extraFindings.push(...list);
2141
+ for (const list of shotsByPage) if (list) screenshots.push(...list);
2142
+ if (pageReports.some((p) => p.measured && !p.isStart)) await page.navigate("/");
2143
+ }
2144
+ const allFindings = [...findings, ...extraFindings];
2145
+ let allSummary = extraFindings.length ? summaryLine(measured, allFindings) : summary;
2146
+ if (focus) {
2147
+ allSummary += ` (page "${focus.isStart ? "/" : focus.urlPath}" only \u2014 the rest of this project was not looked at.)`;
2148
+ }
2149
+ const skipped = pageReports.filter((p) => !p.measured);
2150
+ if (skipped.length) {
2151
+ allSummary += ` \u26A0\uFE0F ${skipped.length} of ${pageReports.length} routed ${skipped.length === 1 ? "page was" : "pages were"} not measured, so this says nothing about ${skipped.length === 1 ? "it" : "them"}: ` + skipped.map((p) => `${p.component} (${p.unreachable})`).join("; ") + ".";
2152
+ }
2153
+ const project = JSON.parse(fs2.readFileSync(path2.join(projectDir2, "nodegx.project.json"), "utf8"));
2154
+ const log = page.serverLog();
2155
+ return {
2156
+ report: {
2157
+ project: projectDir2,
2158
+ projectName: project.name,
2159
+ durationMs: Date.now() - started,
2160
+ /**
2161
+ * FLD-011 — what the settle budget actually cost, beside what the fixed
2162
+ * timers it replaced would have.
2163
+ *
2164
+ * 🔴 `atCeiling` is the field that keeps the speed claim honest: those
2165
+ * settles waited the full old timer and read what the old code read. A
2166
+ * run where `atCeiling` equals `count` was not made faster at all, and
2167
+ * `savedMs` on such a run is zero rather than a number to quote.
2168
+ */
2169
+ settle: settleSummary(page.settles),
2170
+ ...sweep ? { sweep } : {},
2171
+ tokens: (log.match(/\[render\] design tokens: (.*)/) || [])[1] || "unknown",
2172
+ components: (log.match(/\[render\] rootComponent=\S+\s+(\d+) components/) || [])[1],
2173
+ viewports: measured,
2174
+ pages: pageReports,
2175
+ findings: allFindings,
2176
+ summary: allSummary
2177
+ },
2178
+ screenshots
2179
+ };
2180
+ });
2181
+ }
2182
+ module2.exports = {
2183
+ renderReport: renderReport2,
2184
+ // Exported so a session-teardown hook, a CI step or a spec can reap without
2185
+ // starting a drive — the leak outlives the run that caused it.
2186
+ reapOrphanedRenderProcesses,
2187
+ routedPages,
2188
+ resolvePageRequest,
2189
+ reachableComponents,
2190
+ withRenderedPage,
2191
+ summarise,
2192
+ measureExpression,
2193
+ listProbes,
2194
+ readComponents,
2195
+ blankDiagnosis,
2196
+ overriddenDefaults,
2197
+ rootNodes,
2198
+ effectiveRoots,
2199
+ projectVisualPredicate,
2200
+ visualTypeNames,
2201
+ contentBearingTypeNames,
2202
+ BlankCause,
2203
+ placeholderStrings,
2204
+ // VIB-007 M3 — the poverty thresholds, so a spec reads README §2's number
2205
+ // rather than restating it.
2206
+ DISPLAY_TYPE_MIN_PX,
2207
+ MIN_DISTINCT_GROUNDS,
2208
+ POVERTY_MIN_TEXTS,
2209
+ POVERTY_FINDINGS,
2210
+ isPovertyFinding,
2211
+ checkPrerequisites,
2212
+ findChrome,
2213
+ freePort,
2214
+ // HLS-015 — the CDP pair, exported so a drive that boots something OTHER than a project
2215
+ // directory can still talk to a page. `withRenderedPage` boots the viewer against project
2216
+ // FILES; `nodegx deploy`'s output is a finished site with no project in it, so it needs the
2217
+ // plumbing without the booting. The alternative was a second copy of `connect` in a sibling
2218
+ // script, which is the thing the comment above `withRenderedPage` says not to do.
2219
+ connect,
2220
+ evaluate,
2221
+ httpJson,
2222
+ parseViewports: parseViewports2,
2223
+ RenderFinding,
2224
+ DEFAULT_VIEWPORTS,
2225
+ DESKTOP_WIDTH,
2226
+ // FLD-011 — the default tab count, so a spec asserts the shipped number rather than restating it.
2227
+ PAGE_TABS,
2228
+ VIEWER_BUNDLE,
2229
+ RENDER_SCRIPT
2230
+ };
2231
+ }
2232
+ });
2233
+
2234
+ // ../../scripts/devtools/measure-from-disk.js
2235
+ var fs = require("fs");
2236
+ var path = require("path");
2237
+ var { renderReport, parseViewports } = require_render_report();
2238
+ var argv = process.argv.slice(2);
2239
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
2240
+ "--out",
2241
+ "--out-dir",
2242
+ "--viewports",
2243
+ "--screenshot",
2244
+ "--scale",
2245
+ "--backend-port",
2246
+ "--page",
2247
+ "--concurrency"
2248
+ ]);
2249
+ var flag = (name, fallback) => {
2250
+ const i = argv.indexOf(name);
2251
+ return i === -1 ? fallback : argv[i + 1];
2252
+ };
2253
+ var projectDir = argv.find((a, i) => !a.startsWith("--") && !VALUE_FLAGS.has(argv[i - 1]));
2254
+ var asJson = argv.includes("--json");
2255
+ function viewportsOrExit(spec) {
2256
+ try {
2257
+ return parseViewports(spec);
2258
+ } catch (e) {
2259
+ if (!e.usage) throw e;
2260
+ console.error(e.message);
2261
+ process.exit(2);
2262
+ }
2263
+ }
2264
+ function pageSlug(component) {
2265
+ const slug = String(component).replace(/^\/+/, "").replace(/__page__/g, "page").replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
2266
+ return slug || "page";
2267
+ }
2268
+ function printHuman(report, files) {
2269
+ console.log(`${report.projectName} \u2014 ${report.summary}`);
2270
+ console.log(` tokens: ${report.tokens} ${report.durationMs}ms`);
2271
+ for (const [name, v] of Object.entries(report.viewports)) {
2272
+ console.log(
2273
+ ` ${name.padEnd(8)} ${v.requested.width}px \u2192 layout ${v.layoutWidth}px, page ${v.pageHeight}px, ${v.text.elements} texts / ${v.text.distinctFontSizes} sizes / weights ${Object.keys(v.text.fontWeights).join("+") || "none"}, ${v.images.total} images (${v.images.broken} broken), ${v.placeholders.count} placeholders`
2274
+ );
2275
+ }
2276
+ if (report.pages && report.pages.length) {
2277
+ const measured = report.pages.filter((p) => p.measured);
2278
+ console.log(` pages: ${measured.length}/${report.pages.length} measured \u2014 ${measured.map((p) => p.component).join(", ")}`);
2279
+ for (const p of report.pages.filter((page) => !page.measured)) {
2280
+ console.log(` [skipped] ${p.component} \u2014 ${p.unreachable}`);
2281
+ }
2282
+ }
2283
+ for (const f of report.findings) {
2284
+ console.log(` [${f.severity}] ${f.viewport}: ${f.code} \u2014 ${f.message}`);
2285
+ }
2286
+ for (const file of files) console.log(` wrote ${file}`);
2287
+ }
2288
+ async function main() {
2289
+ if (!projectDir) {
2290
+ console.error(
2291
+ "usage: measure-from-disk.js <project-dir> [--json] [--out prefix] [--out-dir dir] [--viewports desktop,phone] [--page quiz]"
2292
+ );
2293
+ process.exit(2);
2294
+ }
2295
+ const outDir = flag("--out-dir");
2296
+ const { report, screenshots } = await renderReport({
2297
+ projectDir,
2298
+ viewports: viewportsOrExit(flag("--viewports")),
2299
+ screenshot: flag("--screenshot", "full"),
2300
+ // 🔴 The sweep is what makes `--out-dir` mean "every page", and it is off by default in
2301
+ // `renderReport` so that `render_report` keeps costing what it costs. Asking for the images
2302
+ // is the act that turns it on; nothing else in this script does.
2303
+ screenshotPages: outDir ? "all" : "start",
2304
+ deviceScaleFactor: Number(flag("--scale", 0.5)),
2305
+ backendPort: flag("--backend-port") ? Number(flag("--backend-port")) : void 0,
2306
+ editorTokens: argv.includes("--editor-tokens"),
2307
+ page: flag("--page"),
2308
+ // FLD-011 — `--concurrency 1` is the serial sweep, and it is here so the arms
2309
+ // that grade the parallel one are two runs of the SAME build differing by a
2310
+ // number, rather than a run of this build against a `git show HEAD:` copy.
2311
+ ...flag("--concurrency") ? { concurrency: Number(flag("--concurrency")) } : {}
2312
+ });
2313
+ const out = flag("--out");
2314
+ const files = [];
2315
+ if (out) {
2316
+ for (const shot of screenshots.filter((shot2) => shot2.subject !== false)) {
2317
+ const file = `${out}-${shot.name}.png`;
2318
+ fs.writeFileSync(file, Buffer.from(shot.base64, "base64"));
2319
+ report.viewports[shot.name].screenshot = file;
2320
+ files.push(file);
2321
+ }
2322
+ }
2323
+ if (outDir) {
2324
+ fs.mkdirSync(outDir, { recursive: true });
2325
+ const pageByComponent = new Map((report.pages || []).map((pg) => [pg.component, pg]));
2326
+ const taken = /* @__PURE__ */ new Set();
2327
+ for (const shot of screenshots) {
2328
+ let stem = `${pageSlug(shot.page)}-${shot.name}`;
2329
+ if (taken.has(stem)) {
2330
+ let n = 2;
2331
+ while (taken.has(`${stem}-${n}`)) n += 1;
2332
+ stem = `${stem}-${n}`;
2333
+ }
2334
+ taken.add(stem);
2335
+ const file = path.join(outDir, `${stem}.png`);
2336
+ fs.writeFileSync(file, Buffer.from(shot.base64, "base64"));
2337
+ const row = pageByComponent.get(shot.page);
2338
+ if (row && row.viewports && row.viewports[shot.name]) row.viewports[shot.name].screenshot = file;
2339
+ if (shot.subject && report.viewports[shot.name]) report.viewports[shot.name].screenshot = file;
2340
+ files.push(file);
2341
+ }
2342
+ }
2343
+ if (asJson) {
2344
+ const payload = argv.includes("--inline-screenshots") ? { ...report, screenshots } : report;
2345
+ console.log(JSON.stringify(payload, null, 2));
2346
+ } else {
2347
+ printHuman(report, files);
2348
+ }
2349
+ }
2350
+ main().catch((e) => {
2351
+ if (asJson) {
2352
+ console.log(
2353
+ JSON.stringify(
2354
+ { error: { actionable: Boolean(e.actionable), message: e.message, problems: e.problems ?? [e.message] } },
2355
+ null,
2356
+ 2
2357
+ )
2358
+ );
2359
+ } else if (e.actionable) {
2360
+ console.error("Cannot render:");
2361
+ for (const problem of e.problems) console.error(` - ${problem}`);
2362
+ } else {
2363
+ console.error("MEASURE FAILED:", e.message);
2364
+ }
2365
+ process.exit(1);
2366
+ });