@nodegx/mcp 0.2.0 → 0.2.1

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 (74) hide show
  1. package/dist/backend/cli.js +182 -182
  2. package/dist/cloud-bundle.cjs +747 -745
  3. package/dist/kit-extract.cjs +228 -228
  4. package/dist/kit-extract.inputs.json +113 -113
  5. package/dist/noodl-mcp.cjs +1705 -918
  6. package/dist/package.json +1 -1
  7. package/dist/render-harness/data/node-catalog-enriched.json +242 -9
  8. package/dist/render-harness/drive-steps.js +156 -16
  9. package/dist/render-harness/measure-from-disk.js +112 -2
  10. package/dist/render-harness/render-from-disk.js +136 -136
  11. package/dist/src/external/viewer/noodl.viewer.js +1 -1
  12. package/dist/starter-assets/noodl_modules/inter/Inter-Bold.woff2 +0 -0
  13. package/dist/starter-assets/noodl_modules/inter/Inter-Medium.woff2 +0 -0
  14. package/dist/starter-assets/noodl_modules/inter/Inter-Regular.woff2 +0 -0
  15. package/dist/starter-assets/noodl_modules/inter/Inter-SemiBold.woff2 +0 -0
  16. package/dist/starter-assets/noodl_modules/inter/LICENSE.txt +93 -0
  17. package/dist/starter-assets/noodl_modules/inter/manifest.json +7 -0
  18. package/dist/starter-assets/noodl_modules/inter/styles.css +49 -0
  19. package/dist/starter-assets/noodl_modules/lucide-icons/lucide.woff2 +0 -0
  20. package/dist/starter-assets/noodl_modules/lucide-icons/manifest.json +229 -0
  21. package/dist/starter-assets/noodl_modules/lucide-icons/styles.css +2007 -0
  22. package/dist/starter-assets/noodl_modules/starter-imagery/LICENCES.json +445 -0
  23. package/dist/starter-assets/noodl_modules/starter-imagery/animal-cat.webp +0 -0
  24. package/dist/starter-assets/noodl_modules/starter-imagery/animal-dog.webp +0 -0
  25. package/dist/starter-assets/noodl_modules/starter-imagery/animal-horse.webp +0 -0
  26. package/dist/starter-assets/noodl_modules/starter-imagery/animal-sheep.webp +0 -0
  27. package/dist/starter-assets/noodl_modules/starter-imagery/avatar-1.webp +0 -0
  28. package/dist/starter-assets/noodl_modules/starter-imagery/avatar-2.webp +0 -0
  29. package/dist/starter-assets/noodl_modules/starter-imagery/avatar-3.webp +0 -0
  30. package/dist/starter-assets/noodl_modules/starter-imagery/avatar-4.webp +0 -0
  31. package/dist/starter-assets/noodl_modules/starter-imagery/avatar-5.webp +0 -0
  32. package/dist/starter-assets/noodl_modules/starter-imagery/avatar-6.webp +0 -0
  33. package/dist/starter-assets/noodl_modules/starter-imagery/food-bakery.webp +0 -0
  34. package/dist/starter-assets/noodl_modules/starter-imagery/food-board.webp +0 -0
  35. package/dist/starter-assets/noodl_modules/starter-imagery/food-bread.webp +0 -0
  36. package/dist/starter-assets/noodl_modules/starter-imagery/food-carrots.webp +0 -0
  37. package/dist/starter-assets/noodl_modules/starter-imagery/food-grocer.webp +0 -0
  38. package/dist/starter-assets/noodl_modules/starter-imagery/food-market.webp +0 -0
  39. package/dist/starter-assets/noodl_modules/starter-imagery/food-plate.webp +0 -0
  40. package/dist/starter-assets/noodl_modules/starter-imagery/ground-aurora.svg +28 -0
  41. package/dist/starter-assets/noodl_modules/starter-imagery/ground-canyon.webp +0 -0
  42. package/dist/starter-assets/noodl_modules/starter-imagery/ground-city-dusk.webp +0 -0
  43. package/dist/starter-assets/noodl_modules/starter-imagery/ground-coast.webp +0 -0
  44. package/dist/starter-assets/noodl_modules/starter-imagery/ground-dune.webp +0 -0
  45. package/dist/starter-assets/noodl_modules/starter-imagery/ground-forest-fog.webp +0 -0
  46. package/dist/starter-assets/noodl_modules/starter-imagery/ground-red-rock.webp +0 -0
  47. package/dist/starter-assets/noodl_modules/starter-imagery/ground-ridge.svg +28 -0
  48. package/dist/starter-assets/noodl_modules/starter-imagery/ground-shore.webp +0 -0
  49. package/dist/starter-assets/noodl_modules/starter-imagery/ground-skyline.webp +0 -0
  50. package/dist/starter-assets/noodl_modules/starter-imagery/manifest.json +4 -0
  51. package/dist/starter-assets/noodl_modules/starter-imagery/people-cafe.webp +0 -0
  52. package/dist/starter-assets/noodl_modules/starter-imagery/people-coffee-shop.webp +0 -0
  53. package/dist/starter-assets/noodl_modules/starter-imagery/people-desk.webp +0 -0
  54. package/dist/starter-assets/noodl_modules/starter-imagery/people-market.webp +0 -0
  55. package/dist/starter-assets/noodl_modules/starter-imagery/people-meeting.webp +0 -0
  56. package/dist/starter-assets/noodl_modules/starter-imagery/portrait.svg +11 -0
  57. package/dist/starter-assets/noodl_modules/starter-imagery/texture-brick.webp +0 -0
  58. package/dist/starter-assets/noodl_modules/starter-imagery/texture-coffee.webp +0 -0
  59. package/dist/starter-assets/noodl_modules/starter-imagery/texture-earth.webp +0 -0
  60. package/dist/starter-assets/noodl_modules/starter-imagery/texture-pebbles.webp +0 -0
  61. package/dist/starter-assets/noodl_modules/starter-imagery/texture-snow.webp +0 -0
  62. package/dist/starter-assets/noodl_modules/starter-imagery/texture-soil.webp +0 -0
  63. package/dist/starter-assets/noodl_modules/starter-imagery/tile-1.svg +17 -0
  64. package/dist/starter-assets/noodl_modules/starter-imagery/tile-2.svg +17 -0
  65. package/dist/starter-assets/noodl_modules/starter-imagery/tile-3.svg +17 -0
  66. package/dist/starter-assets/noodl_modules/starter-imagery/work-baker.webp +0 -0
  67. package/dist/starter-assets/noodl_modules/starter-imagery/work-carpenter.webp +0 -0
  68. package/dist/starter-assets/noodl_modules/starter-imagery/work-chef.webp +0 -0
  69. package/dist/starter-assets/noodl_modules/starter-imagery/work-hands-clay.webp +0 -0
  70. package/dist/starter-assets/noodl_modules/starter-imagery/work-leather-bench.webp +0 -0
  71. package/dist/starter-assets/noodl_modules/starter-imagery/work-machine-shop.webp +0 -0
  72. package/dist/starter-assets/noodl_modules/starter-imagery/work-potter.webp +0 -0
  73. package/dist/starter-assets/noodl_modules/starter-imagery/work-welder.webp +0 -0
  74. package/package.json +4 -3
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodegx-mcp-bundles",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": true,
5
5
  "description": "Build metadata for the bundled CLIs beside it. See build-cloud-bundle.mjs: @noodl/platform reads it."
6
6
  }
@@ -5355,14 +5355,16 @@
5355
5355
  "runtimeBehavior": "Ports are mirrored from the targeted cloud function component by the editor's CloudFunctionAdapter. The `function` input (edit-only) lists every component that contains a `noodl.cloud.request` node — cloud function components live under the `/#__cloud__/` sheet, and the parameter stores the component name without that prefix. Each name in the Request node's `params` list becomes an `in-<name>` input; each name in the Response nodes' `params` lists becomes an `out-<name>` output. All are type '*'; an authoring tool must read the target component's Request/Response `params` to know which ports exist.",
5356
5356
  "examples": [
5357
5357
  "fn-cloud-function-roundtrip",
5358
- "fn-aggregate-stats-function"
5358
+ "fn-aggregate-stats-function",
5359
+ "fn-play-audio-a-cloud-function-returns"
5359
5360
  ],
5360
5361
  "patterns": [
5361
5362
  "Button `onClick` → `call`, `out-<name>` → visual inputs: the request/response cycle for an action the client must not perform itself.",
5362
5363
  "`done` → a Query Records fetch action: refresh client data after a server-side write."
5363
5364
  ],
5364
5365
  "antiPatterns": [
5365
- "Wiring `out-` outputs somewhere without also handling `failure` leaves errors invisible — surface `error` in the UI or react to `failure`."
5366
+ "Wiring `out-` outputs somewhere without also handling `failure` leaves errors invisible — surface `error` in the UI or react to `failure`.",
5367
+ "Putting a new WAV header around audio a function returns, or re-encoding it: an API's audio is almost always a finished file (audio/wav, audio/mpeg), and wrapping it again plays its header as a click and anything after the samples as loud static. Return the base64 and its MIME type, and play it as it came; only audio/L16 or audio/pcm is raw samples (example fn-play-audio-a-cloud-function-returns)."
5366
5368
  ],
5367
5369
  "relatedNodes": [
5368
5370
  "noodl.cloud.request",
@@ -69597,7 +69599,8 @@
69597
69599
  "runtimeBehavior": "Each name in the `params` stringlist becomes a runtime-registered input (stored as `pm-<name>`, type '*') collected into the success reply. Declared port groups make the ports conditional on `status`: `params` (and its `pm-` inputs) exist while status is 'success' or unset, `errorMessage` exists while status is 'failure'. CloudFunctionAdapter mirrors the union of all Response nodes' `params` onto the browser-side caller as `out-<name>` outputs.",
69598
69600
  "examples": [
69599
69601
  "fn-cloud-function-roundtrip",
69600
- "fn-aggregate-stats-function"
69602
+ "fn-aggregate-stats-function",
69603
+ "fn-play-audio-a-cloud-function-returns"
69601
69604
  ],
69602
69605
  "patterns": [
69603
69606
  "Two Response nodes — one with status 'success', one with status 'failure' and an `errorMessage` — triggered from the two branches of a Condition: explicit success/failure replies.",
@@ -82118,9 +82121,9 @@
82118
82121
  {
82119
82122
  "id": "call_stats",
82120
82123
  "type": "CloudFunction2",
82121
- "label": "Call TaskStats",
82124
+ "label": "Call Task Stats",
82122
82125
  "parameters": {
82123
- "function": "TaskStats"
82126
+ "function": "Cloud Functions/Task Stats"
82124
82127
  }
82125
82128
  }
82126
82129
  ],
@@ -82140,7 +82143,7 @@
82140
82143
  ]
82141
82144
  },
82142
82145
  {
82143
- "name": "/Cloud Functions/Task Stats",
82146
+ "name": "/#__cloud__/Cloud Functions/Task Stats",
82144
82147
  "nodes": [
82145
82148
  {
82146
82149
  "id": "fn_request",
@@ -82271,9 +82274,9 @@
82271
82274
  {
82272
82275
  "id": "call_add",
82273
82276
  "type": "CloudFunction2",
82274
- "label": "Call AddNumbers",
82277
+ "label": "Call Add Numbers",
82275
82278
  "parameters": {
82276
- "function": "AddNumbers"
82279
+ "function": "Cloud Functions/Add Numbers"
82277
82280
  }
82278
82281
  }
82279
82282
  ],
@@ -82305,7 +82308,7 @@
82305
82308
  ]
82306
82309
  },
82307
82310
  {
82308
- "name": "/Cloud Functions/Add Numbers",
82311
+ "name": "/#__cloud__/Cloud Functions/Add Numbers",
82309
82312
  "nodes": [
82310
82313
  {
82311
82314
  "id": "fn_request",
@@ -82361,6 +82364,236 @@
82361
82364
  }
82362
82365
  ]
82363
82366
  },
82367
+ {
82368
+ "id": "fn-play-audio-a-cloud-function-returns",
82369
+ "title": "Speak a phrase: text-to-speech in a cloud function, played in the page as it came",
82370
+ "description": "A text-to-speech (TTS) API is called from a cloud function, so its key stays on the server, and the page plays what comes back. Three decisions matter. First, the key is a NAME: Secret reads GEMINI_API_KEY from the backend's secrets, the Function sends it in the x-goog-api-key header (never in the URL, which ends up in logs), and the key is never a port value the page can see. Second, and the one that is easy to get wrong: the audio an API returns today is almost always a FINISHED FILE (WAV, MP3 or OGG, with its own header, and sometimes extra chunks after the samples), so the cloud function passes the base64 and its MIME type through untouched and the page plays it as it is. Only a MIME type that says raw samples (audio/L16 or audio/pcm, which older Gemini text-to-speech returned) needs a WAV header put around it. Wrapping a finished file in a second header plays its first header as a click and anything after the samples as half a second of loud static. Gemini's current text-to-speech model returns audio/wav, so the wrap branch does not run for it. Third, failures stay readable: a script that threw answers through a failure Response with its own message, and a missing secret answers with a fixed sentence, because the Secret's own error names a server path the page should not show. Playback is a Function calling `new Audio(...)`, run from the call's Done; the call starts from a press, so the browser allows sound. In this function the value inputs have Run On Value Change unticked, because a signal runs each Function.",
82371
+ "demonstrates": [
82372
+ "CloudFunction2",
82373
+ "noodl.cloud.request",
82374
+ "noodl.cloud.secret",
82375
+ "noodl.cloud.response",
82376
+ "JavaScriptFunction"
82377
+ ],
82378
+ "components": [
82379
+ {
82380
+ "name": "/Phrase Screen",
82381
+ "nodes": [
82382
+ {
82383
+ "id": "root",
82384
+ "type": "Group",
82385
+ "label": "Screen",
82386
+ "parameters": {
82387
+ "flexDirection": "column"
82388
+ },
82389
+ "children": [
82390
+ "phrase_input",
82391
+ "say_btn",
82392
+ "status_text"
82393
+ ]
82394
+ },
82395
+ {
82396
+ "id": "phrase_input",
82397
+ "type": "net.noodl.controls.textinput",
82398
+ "label": "Phrase",
82399
+ "parent": "root",
82400
+ "parameters": {
82401
+ "placeholder": "Bonjour"
82402
+ }
82403
+ },
82404
+ {
82405
+ "id": "say_btn",
82406
+ "type": "net.noodl.controls.button",
82407
+ "label": "Say it",
82408
+ "parent": "root",
82409
+ "parameters": {
82410
+ "label": "Say it"
82411
+ }
82412
+ },
82413
+ {
82414
+ "id": "status_text",
82415
+ "type": "Text",
82416
+ "label": "What went wrong",
82417
+ "parent": "root"
82418
+ },
82419
+ {
82420
+ "id": "call_speak",
82421
+ "type": "CloudFunction2",
82422
+ "label": "Call Speak",
82423
+ "parameters": {
82424
+ "function": "Speak"
82425
+ }
82426
+ },
82427
+ {
82428
+ "id": "play",
82429
+ "type": "JavaScriptFunction",
82430
+ "label": "Play it as it came",
82431
+ "parameters": {
82432
+ "functionScript": "const audio = Inputs.audio;\nconst mimeType = String(Inputs.mimeType || 'audio/mpeg');\nif (!audio) return;\n\n// A finished file (audio/wav, audio/mpeg, audio/ogg ...) plays exactly as it came.\nlet url = 'data:' + mimeType + ';base64,' + audio;\n\n// Only raw 16-bit samples need a WAV header around them.\nif (/^audio\\/(l16|pcm)/i.test(mimeType)) {\n const rate = Number((/rate=(\\d+)/i.exec(mimeType) || [])[1]) || 24000;\n const samples = Uint8Array.from(atob(audio), (c) => c.charCodeAt(0));\n const wav = new DataView(new ArrayBuffer(44 + samples.length));\n const text = (at, s) => [...s].forEach((ch, i) => wav.setUint8(at + i, ch.charCodeAt(0)));\n text(0, 'RIFF'); wav.setUint32(4, 36 + samples.length, true); text(8, 'WAVE');\n text(12, 'fmt '); wav.setUint32(16, 16, true); wav.setUint16(20, 1, true); wav.setUint16(22, 1, true);\n wav.setUint32(24, rate, true); wav.setUint32(28, rate * 2, true); wav.setUint16(32, 2, true); wav.setUint16(34, 16, true);\n text(36, 'data'); wav.setUint32(40, samples.length, true);\n new Uint8Array(wav.buffer, 44).set(samples);\n let bytes = '';\n new Uint8Array(wav.buffer).forEach((b) => (bytes += String.fromCharCode(b)));\n url = 'data:audio/wav;base64,' + btoa(bytes);\n}\n\nawait new Audio(url).play();",
82433
+ "runOnChange-in-audio": false,
82434
+ "runOnChange-in-mimeType": false
82435
+ }
82436
+ }
82437
+ ],
82438
+ "connections": [
82439
+ {
82440
+ "fromId": "phrase_input",
82441
+ "fromProperty": "onTextChanged",
82442
+ "toId": "call_speak",
82443
+ "toProperty": "in-text"
82444
+ },
82445
+ {
82446
+ "fromId": "say_btn",
82447
+ "fromProperty": "onClick",
82448
+ "toId": "call_speak",
82449
+ "toProperty": "call"
82450
+ },
82451
+ {
82452
+ "fromId": "call_speak",
82453
+ "fromProperty": "out-audio",
82454
+ "toId": "play",
82455
+ "toProperty": "in-audio"
82456
+ },
82457
+ {
82458
+ "fromId": "call_speak",
82459
+ "fromProperty": "out-mimeType",
82460
+ "toId": "play",
82461
+ "toProperty": "in-mimeType"
82462
+ },
82463
+ {
82464
+ "fromId": "call_speak",
82465
+ "fromProperty": "done",
82466
+ "toId": "play",
82467
+ "toProperty": "run"
82468
+ },
82469
+ {
82470
+ "fromId": "call_speak",
82471
+ "fromProperty": "error",
82472
+ "toId": "status_text",
82473
+ "toProperty": "text"
82474
+ }
82475
+ ]
82476
+ },
82477
+ {
82478
+ "name": "/#__cloud__/Speak",
82479
+ "nodes": [
82480
+ {
82481
+ "id": "speak_request",
82482
+ "type": "noodl.cloud.request",
82483
+ "label": "Say this",
82484
+ "parameters": {
82485
+ "params": "text"
82486
+ }
82487
+ },
82488
+ {
82489
+ "id": "speak_key",
82490
+ "type": "noodl.cloud.secret",
82491
+ "label": "GEMINI_API_KEY",
82492
+ "parameters": {
82493
+ "name": "GEMINI_API_KEY"
82494
+ }
82495
+ },
82496
+ {
82497
+ "id": "speak_tts",
82498
+ "type": "JavaScriptFunction",
82499
+ "label": "Ask for the audio, pass it through",
82500
+ "parameters": {
82501
+ "functionScript": "const key = Inputs.key;\nconst text = String(Inputs.text || '');\nif (!key || !text) return;\n\nconst model = 'gemini-3.8-flash-tts';\nconst response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/' + model + ':generateContent', {\n method: 'POST',\n headers: { 'content-type': 'application/json', 'x-goog-api-key': key },\n body: JSON.stringify({\n contents: [{ parts: [{ text }] }],\n generationConfig: {\n responseModalities: ['AUDIO'],\n speechConfig: { voiceConfig: { prebuiltVoiceConfig: { voiceName: 'Kore' } } }\n }\n })\n});\nconst body = await response.json();\nif (!response.ok) throw new Error((body.error && body.error.message) || 'Text-to-speech refused the request');\n\nconst part = body.candidates && body.candidates[0] && body.candidates[0].content && body.candidates[0].content.parts[0];\nconst audio = part && part.inlineData;\nif (!audio || !audio.data) throw new Error('Text-to-speech sent no audio');\n\n// Pass the audio through exactly as it came: do not decode it, re-encode it or add a header here.\nOutputs.audio = audio.data;\nOutputs.mimeType = audio.mimeType;",
82502
+ "runOnChange-in-key": false,
82503
+ "runOnChange-in-text": false
82504
+ }
82505
+ },
82506
+ {
82507
+ "id": "speak_ok",
82508
+ "type": "noodl.cloud.response",
82509
+ "label": "The audio, and what kind it is",
82510
+ "parameters": {
82511
+ "params": "audio,mimeType"
82512
+ }
82513
+ },
82514
+ {
82515
+ "id": "speak_failed",
82516
+ "type": "noodl.cloud.response",
82517
+ "label": "Why not",
82518
+ "parameters": {
82519
+ "status": "failure"
82520
+ }
82521
+ },
82522
+ {
82523
+ "id": "speak_not_set_up",
82524
+ "type": "noodl.cloud.response",
82525
+ "label": "Not set up yet",
82526
+ "parameters": {
82527
+ "status": "failure",
82528
+ "errorMessage": "Speech is not set up yet: add GEMINI_API_KEY to the backend's secrets."
82529
+ }
82530
+ }
82531
+ ],
82532
+ "connections": [
82533
+ {
82534
+ "fromId": "speak_request",
82535
+ "fromProperty": "receive",
82536
+ "toId": "speak_key",
82537
+ "toProperty": "fetch"
82538
+ },
82539
+ {
82540
+ "fromId": "speak_request",
82541
+ "fromProperty": "pm-text",
82542
+ "toId": "speak_tts",
82543
+ "toProperty": "in-text"
82544
+ },
82545
+ {
82546
+ "fromId": "speak_key",
82547
+ "fromProperty": "value",
82548
+ "toId": "speak_tts",
82549
+ "toProperty": "in-key"
82550
+ },
82551
+ {
82552
+ "fromId": "speak_key",
82553
+ "fromProperty": "done",
82554
+ "toId": "speak_tts",
82555
+ "toProperty": "run"
82556
+ },
82557
+ {
82558
+ "fromId": "speak_key",
82559
+ "fromProperty": "failure",
82560
+ "toId": "speak_not_set_up",
82561
+ "toProperty": "send"
82562
+ },
82563
+ {
82564
+ "fromId": "speak_tts",
82565
+ "fromProperty": "out-audio",
82566
+ "toId": "speak_ok",
82567
+ "toProperty": "pm-audio"
82568
+ },
82569
+ {
82570
+ "fromId": "speak_tts",
82571
+ "fromProperty": "out-mimeType",
82572
+ "toId": "speak_ok",
82573
+ "toProperty": "pm-mimeType"
82574
+ },
82575
+ {
82576
+ "fromId": "speak_tts",
82577
+ "fromProperty": "success",
82578
+ "toId": "speak_ok",
82579
+ "toProperty": "send"
82580
+ },
82581
+ {
82582
+ "fromId": "speak_tts",
82583
+ "fromProperty": "error",
82584
+ "toId": "speak_failed",
82585
+ "toProperty": "errorMessage"
82586
+ },
82587
+ {
82588
+ "fromId": "speak_tts",
82589
+ "fromProperty": "failure",
82590
+ "toId": "speak_failed",
82591
+ "toProperty": "send"
82592
+ }
82593
+ ]
82594
+ }
82595
+ ]
82596
+ },
82364
82597
  {
82365
82598
  "id": "fn-send-notification-email",
82366
82599
  "title": "Cloud function that sends an email notification",
@@ -492,6 +492,78 @@ var require_src = __commonJS({
492
492
  // viewport height with content laid out past it is clipped; a page that is
493
493
  // simply short has nothing past it. That difference is the whole of
494
494
  // \`clipped-page\`, and neither number alone can tell them apart.
495
+ // P112-OWN019-LAPTOPGAPS (R39) \u2014 where the INK is, not where the boxes are. Richard on RL1's laptop page: a
496
+ // feature number in a Columns "1 4" row had a fifth of the page to itself, ~215 px of nothing before its title;
497
+ // L1's pricing toggle and price sat ~260 px left of their centred heading. Every box there is well-formed \u2014 the
498
+ // number's column box abuts the title's \u2014 so the measure is each child's ink: the union of its shown text runs
499
+ // (a Range, so a full-width text block counts only as far as its words reach) and its painted boxes. Desktop only:
500
+ // that is where a row has room to open a hole. Raw numbers here; the judgement is summarise's.
501
+ const layout = { rowGaps: [], drifts: [] };
502
+ // (The API guard is for the fake DOMs the unit suites hand this expression; every browser has both.)
503
+ if (vw >= 1024 && typeof document.createRange === 'function' && typeof document.createTreeWalker === 'function') {
504
+ const shown = new Set(visible);
505
+ const textInk = [];
506
+ const range = document.createRange();
507
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
508
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
509
+ if (!n.textContent.trim() || !shown.has(n.parentElement)) continue;
510
+ range.selectNodeContents(n);
511
+ const r = range.getBoundingClientRect();
512
+ if (r.width > 0 && r.height > 0) textInk.push({ node: n, r });
513
+ }
514
+ const PAINTED_TAGS = ['IMG', 'SVG', 'CANVAS', 'VIDEO', 'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'svg'];
515
+ const paintedInk = visible
516
+ .filter((el) => {
517
+ if (PAINTED_TAGS.includes(el.tagName)) return true;
518
+ const cs = styleOf(el);
519
+ const bg = cs.backgroundColor;
520
+ return (bg && bg !== 'transparent' && !/rgba\\([^)]*,\\s*0\\)$/.test(bg)) || cs.backgroundImage !== 'none' || parseFloat(cs.borderTopWidth) > 0;
521
+ })
522
+ .map((el) => ({ el, r: el.getBoundingClientRect() }))
523
+ .filter((p) => p.r.width > 0 && p.r.height > 0 && p.r.width < vw * 0.98);
524
+ const inkOf = (el) => {
525
+ let l = Infinity, t = Infinity, rr = -Infinity, b = -Infinity, text = '';
526
+ const take = (r) => { l = Math.min(l, r.left); t = Math.min(t, r.top); rr = Math.max(rr, r.right); b = Math.max(b, r.bottom); };
527
+ for (const x of textInk) if (el.contains(x.node)) { take(x.r); if (!text) text = x.node.textContent.trim().slice(0, 50); }
528
+ for (const p of paintedInk) if (el === p.el || el.contains(p.el)) take(p.r);
529
+ return l === Infinity ? null : { left: l, top: t, right: rr, bottom: b, width: rr - l, height: b - t, text };
530
+ };
531
+ const ROW_GAP_MIN_PX = 160;
532
+ for (const parent of visible) {
533
+ const kids = [...parent.children].filter((k) => shown.has(k));
534
+ if (kids.length < 2) continue;
535
+ const pr = parent.getBoundingClientRect();
536
+ if (pr.width < 600) continue;
537
+ const inks = kids.map(inkOf).filter(Boolean);
538
+ if (inks.length < 2) continue;
539
+ // Side by side: consecutive by left edge, overlapping vertically, the second starting after the first ends.
540
+ const byLeft = [...inks].sort((a, b) => a.left - b.left);
541
+ for (let i = 1; i < byLeft.length; i++) {
542
+ const a = byLeft[i - 1], b = byLeft[i];
543
+ const overlap = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
544
+ const gap = b.left - a.right;
545
+ if (overlap <= 0 || gap < ROW_GAP_MIN_PX) continue;
546
+ layout.rowGaps.push({ cls: cls(parent), gap: round(gap), rowWidth: round(pr.width), tallest: round(Math.max(a.height, b.height)), left: a.text, right: b.text });
547
+ }
548
+ // Stacked, each starting below the one before, and the parts of ONE section: every part under 240 px tall (a
549
+ // page whose children are whole sections compared section against section \u2014 measured on L1 and GL1, s5). The
550
+ // section's axis is set by a centred part among its first two (its heading, usually); L1's pricing is just
551
+ // two parts, a centred heading block and a toggle-and-price group ~260 px left of it.
552
+ const byTop = [...inks].sort((a, b) => a.top - b.top);
553
+ if (!byTop.every((k, i) => i === 0 || k.top >= byTop[i - 1].bottom - 2)) continue;
554
+ if (byTop.some((k) => k.height > 240)) continue;
555
+ const centre = pr.left + pr.width / 2;
556
+ const offOf = (k) => k.left + k.width / 2 - centre;
557
+ const centred = byTop.filter((k) => Math.abs(offOf(k)) <= 12 && k.width < pr.width * 0.9);
558
+ if (!centred.some((k) => byTop.indexOf(k) < 2)) continue;
559
+ for (const k of byTop) {
560
+ if (Math.abs(offOf(k)) > 40 && k.width < pr.width * 0.8) {
561
+ layout.drifts.push({ cls: cls(parent), offBy: round(offOf(k)), sectionWidth: round(pr.width), text: k.text, centredText: centred[0].text });
562
+ }
563
+ }
564
+ }
565
+ }
566
+
495
567
  const contentBottom = visible.reduce((lowest, el) => {
496
568
  const r = el.getBoundingClientRect();
497
569
  return r.width > 0 && r.height > 0 && r.bottom > lowest ? r.bottom : lowest;
@@ -499,6 +571,7 @@ var require_src = __commonJS({
499
571
 
500
572
  return {
501
573
  lists,
574
+ layout,
502
575
  // FLD-012 AC3 \u2014 the SIZE of the population every rule below is computed over, and the number of
503
576
  // elements the visibility correction took out of it. Reported directly because a rule's finding
504
577
  // count can move for several reasons at once, and 'the fix changed what is measured' is a
@@ -637,9 +710,19 @@ var require_src = __commonJS({
637
710
  */
638
711
  SingleGround: "single-ground",
639
712
  NoImagery: "no-imagery",
640
- NoDisplayType: "no-display-type"
713
+ NoDisplayType: "no-display-type",
714
+ /**
715
+ * P112-OWN019-LAPTOPGAPS (R39, Richard 2026-10-14). On a laptop, two things side by side in one row with a hole
716
+ * between their ink — RL1's feature number given a fifth of the page by a Columns "1 4" row, L1's text and card
717
+ * pushed to the row's two edges. A warning: a hole can be meant, and the screenshots are the arbiter.
718
+ */
719
+ RowGap: "row-gap",
720
+ /** P112-OWN019-LAPTOPGAPS — a section that centres its heading and leaves another part off that centre (L1's price). */
721
+ AlignmentDrift: "alignment-drift"
641
722
  };
642
723
  var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
724
+ var ROW_GAP_MIN_TALLEST = 48;
725
+ var ROW_GAP_MIN_SHARE = 0.12;
643
726
  var POVERTY_FINDINGS = [RenderFinding.SingleGround, RenderFinding.NoImagery, RenderFinding.NoDisplayType];
644
727
  function isPovertyFinding(finding) {
645
728
  return Boolean(finding) && POVERTY_FINDINGS.indexOf(finding.code) !== -1;
@@ -855,6 +938,32 @@ var require_src = __commonJS({
855
938
  evidence: v.grounds.values
856
939
  });
857
940
  }
941
+ if (isDesktop && v.layout) {
942
+ const gaps = (v.layout.rowGaps || []).filter((g) => g.tallest >= ROW_GAP_MIN_TALLEST && g.gap >= g.rowWidth * ROW_GAP_MIN_SHARE);
943
+ const seen2 = /* @__PURE__ */ new Set();
944
+ for (const g of gaps) {
945
+ const key = `${g.left}|${g.right}`;
946
+ if (seen2.has(key) || seen2.size >= 3) continue;
947
+ seen2.add(key);
948
+ add({
949
+ code: RenderFinding.RowGap,
950
+ severity: "warning",
951
+ viewport: name,
952
+ message: `"${g.left}" and "${g.right}" sit side by side with ${g.gap}px of empty space between them (${Math.round(g.gap / g.rowWidth * 100)}% of the ${g.rowWidth}px row). A label or number beside text reads as one thing at its own width with a small gap \u2014 a row Group, the label at its natural width and the text growing \u2014 not a column given a share of the page (Columns widths are proportions); two parts pushed to a wide row's edges leave the same hole: give the row a max width and a gap. Look at this width's screenshot.`,
953
+ evidence: g
954
+ });
955
+ }
956
+ const drifts = (v.layout.drifts || []).slice(0, 3);
957
+ for (const d of drifts) {
958
+ add({
959
+ code: RenderFinding.AlignmentDrift,
960
+ severity: "warning",
961
+ viewport: name,
962
+ message: `This section centres "${d.centredText}", but "${d.text}" sits ${Math.abs(d.offBy)}px ${d.offBy < 0 ? "left" : "right"} of that centre. One section, one alignment: centre the rest too, or align everything to the same left edge. Look at this width's screenshot.`,
963
+ evidence: d
964
+ });
965
+ }
966
+ }
858
967
  if (v.consoleErrors && v.consoleErrors.length) {
859
968
  add({
860
969
  code: RenderFinding.ConsoleError,
@@ -964,6 +1073,7 @@ var require_render_report = __commonJS({
964
1073
  var WS_MODULE = HARNESS_PATHS.WS_MODULE.path;
965
1074
  var {
966
1075
  DEFAULT_VIEWPORTS,
1076
+ NAMED_VIEWPORTS,
967
1077
  DESKTOP_WIDTH,
968
1078
  RenderFinding,
969
1079
  SEVERITY_ORDER,
@@ -1499,7 +1609,7 @@ var require_render_report = __commonJS({
1499
1609
  }
1500
1610
  function parseViewports2(spec) {
1501
1611
  if (!spec) return DEFAULT_VIEWPORTS;
1502
- const known = new Map(DEFAULT_VIEWPORTS.map((v) => [v.name, v]));
1612
+ const known = new Map(NAMED_VIEWPORTS.map((v) => [v.name, v]));
1503
1613
  return spec.split(",").map((token) => {
1504
1614
  const trimmed = token.trim();
1505
1615
  if (known.has(trimmed)) return known.get(trimmed);
@@ -2312,10 +2422,12 @@ function locateExpression(target) {
2312
2422
  if (all.length === 0) return { found: false, matched: 0 };
2313
2423
  let last;
2314
2424
  for (const el of all) {
2315
- el.scrollIntoView({ block: 'center', inline: 'center' });
2425
+ // 'instant': under \`html { scroll-behavior: smooth }\` a plain scrollIntoView glides, the rect below is read
2426
+ // before it arrives, and every target off screen was refused as off screen (P112-OWN019-TAPHOLD, L1's page).
2427
+ el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
2316
2428
  const r = el.getBoundingClientRect();
2317
2429
  const x = r.left + r.width / 2, y = r.top + r.height / 2;
2318
- if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) { last = { x, y, offscreen: true, wouldHit: 'nothing (off screen)' }; continue; }
2430
+ if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) { last = { x, y, offscreen: true, wouldHit: 'nothing (off screen)', target: describe(el) }; continue; }
2319
2431
  // Lent pointer-events for the one measurement: elementFromPoint skips an element that has none.
2320
2432
  const before = el.style.pointerEvents;
2321
2433
  el.style.pointerEvents = 'auto';
@@ -2540,6 +2652,20 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
2540
2652
  const value = JSON.parse(last);
2541
2653
  return value && typeof value === "object" ? { ...value, settledMs: Date.now() - t0 } : value;
2542
2654
  };
2655
+ const SCROLLS = `(() => { const a = [scrollX, scrollY]; for (const e of document.querySelectorAll('*')) if (e.scrollTop || e.scrollLeft) a.push(e.scrollTop, e.scrollLeft); return a.join(','); })()`;
2656
+ const scrollSettled = async () => {
2657
+ const t0 = Date.now();
2658
+ let last = await evaluate(SCROLLS);
2659
+ let moved = false;
2660
+ for (let same = 0; same < 2 && Date.now() - t0 < 2e3; ) {
2661
+ await frames();
2662
+ const next = await evaluate(SCROLLS);
2663
+ if (next === last) same++;
2664
+ else same = 0, moved = true;
2665
+ last = next;
2666
+ }
2667
+ return moved ? { scrolledMs: Date.now() - t0, still: Date.now() - t0 >= 2e3 } : null;
2668
+ };
2543
2669
  const press = async (target) => {
2544
2670
  let p = await evaluate(locateExpression(target));
2545
2671
  for (let i = 0; i < 20 && !p.found; i++) {
@@ -2576,6 +2702,11 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
2576
2702
  row.pressed = p.target;
2577
2703
  if (p.matched > 1) row.matched = p.matched;
2578
2704
  await frames();
2705
+ const glide = await scrollSettled();
2706
+ if (glide) {
2707
+ row.scrolledMs = glide.scrolledMs;
2708
+ if (glide.still) row.stillScrolling = true;
2709
+ }
2579
2710
  } else if (verb === "type") {
2580
2711
  if (typeof step.type !== "string") throw new UsageError(`Step ${i + 1}: type takes a string.`);
2581
2712
  if (step.into !== void 0) await press(step.into);
@@ -2629,27 +2760,36 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
2629
2760
  row.url = await evaluate("location.pathname + location.hash");
2630
2761
  } else if (verb === "watch") {
2631
2762
  const w = step.watch && typeof step.watch === "object" ? step.watch : {};
2632
- if (w.look === void 0) throw new UsageError(`Step ${i + 1}: watch takes { look: T }.`);
2763
+ if (w.look === void 0) throw new UsageError(`Step ${i + 1}: watch takes { look: T } or { look: [T, T, \u2026] }.`);
2633
2764
  const forMs = Math.min(1e4, Math.max(100, Number(step.for_ms ?? 1500)));
2634
- const expr = lookExpression(w.look);
2765
+ const many = Array.isArray(w.look);
2766
+ const targets = many ? w.look : [w.look];
2767
+ if (targets.length === 0 || targets.length > 8) throw new UsageError(`Step ${i + 1}: watch looks at 1 to 8 targets.`);
2768
+ const expr = `Promise.all([${targets.map((t) => lookExpression(t)).join(", ")}])`;
2635
2769
  const keyOf = (v) => v && typeof v === "object" ? JSON.stringify({ text: v.text, visible: v.visible, backgroundName: v.backgroundName, colorName: v.colorName, opacity: v.opacity === void 0 ? void 0 : Math.round(v.opacity * 10) / 10 }) : "null";
2636
- const states = [];
2770
+ const states = targets.map(() => []);
2637
2771
  const t0 = Date.now();
2638
2772
  while (Date.now() - t0 <= forMs) {
2639
- const v = await evaluate(expr);
2773
+ const vs = await evaluate(expr);
2640
2774
  const at = Date.now() - t0;
2641
- const last = states[states.length - 1];
2642
- if (last && last.key === keyOf(v)) last.until = at;
2643
- else states.push({ key: keyOf(v), from: at, until: at, look: v && typeof v === "object" ? { text: v.text, visible: v.visible, backgroundName: v.backgroundName, colorName: v.colorName, opacity: v.opacity } : null });
2775
+ vs.forEach((v, k) => {
2776
+ const list = states[k];
2777
+ const last = list[list.length - 1];
2778
+ if (last && last.key === keyOf(v)) last.until = at;
2779
+ else list.push({ key: keyOf(v), from: at, until: at, look: v && typeof v === "object" ? { text: v.text, visible: v.visible, backgroundName: v.backgroundName, colorName: v.colorName, opacity: v.opacity } : null });
2780
+ });
2644
2781
  await wait(25);
2645
2782
  }
2646
- row.value = states.map(({ from, until, look }) => ({ from, until, look }));
2783
+ const shown = (list) => list.map(({ from, until, look }) => ({ from, until, look }));
2784
+ row.value = many ? targets.map((t, k) => ({ target: t, states: shown(states[k]) })) : shown(states[0]);
2647
2785
  const never = step.expect && typeof step.expect === "object" ? step.expect.never : void 0;
2648
2786
  if (never && typeof never === "object") {
2649
- const hit = states.find((st) => st.look && Object.keys(never).every((k) => matches(st.look[k], never[k])));
2650
- if (hit) {
2651
- row.ok = false;
2652
- row.error = `${JSON.stringify(never)} was seen from ${hit.from} to ${hit.until} ms: ${JSON.stringify(hit.look)}`;
2787
+ for (let k = 0; k < targets.length && row.ok; k++) {
2788
+ const hit = states[k].find((st) => st.look && Object.keys(never).every((key) => matches(st.look[key], never[key])));
2789
+ if (hit) {
2790
+ row.ok = false;
2791
+ row.error = `${many ? `${JSON.stringify(targets[k])}: ` : ""}${JSON.stringify(never)} was seen from ${hit.from} to ${hit.until} ms: ${JSON.stringify(hit.look)}`;
2792
+ }
2653
2793
  }
2654
2794
  }
2655
2795
  } else if (verb === "viewport") {