@nodegx/mcp 0.2.0 → 0.2.2

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 +6073 -1090
  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 +222 -18
  9. package/dist/render-harness/measure-from-disk.js +172 -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.2",
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,122 @@ 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
+ // Two or more tall parts, not one: RL1's waitlist is eyebrow, heading, line and a 290 px form card in ONE group,
555
+ // and "any part > 240" skipped it \u2014 Richard saw the eyebrow off the centred heading (s6). A page of sections
556
+ // has many tall children; a section with one card has one.
557
+ if (byTop.filter((k) => k.height > 240).length > 1) continue;
558
+ const centre = pr.left + pr.width / 2;
559
+ const offOf = (k) => k.left + k.width / 2 - centre;
560
+ const centred = byTop.filter((k) => Math.abs(offOf(k)) <= 12 && k.width < pr.width * 0.9);
561
+ if (!centred.some((k) => byTop.indexOf(k) < 2)) continue;
562
+ for (const k of byTop) {
563
+ if (Math.abs(offOf(k)) > 40 && k.width < pr.width * 0.8) {
564
+ layout.drifts.push({ cls: cls(parent), offBy: round(offOf(k)), sectionWidth: round(pr.width), text: k.text, centredText: centred[0].text });
565
+ }
566
+ }
567
+ }
568
+ }
569
+
570
+ // P112-OWN019-LETTEREDGE \u2014 words hard against the edge of the box that frames them. Richard on TL2's hero: seven
571
+ // outlined day chips, each letter pressed to its chip's left edge. The chip Group centred its child the way CSS
572
+ // would, but a Text stretches to its Group's width and sets its letters left, so the centring moved nothing. The
573
+ // frame is the nearest of the text's first four ancestors with a border or a fill (a page-wide band is not a frame,
574
+ // nor an inline highlight); the reading is the gap between the words' own ink (a Range) and the frame's inner left
575
+ // and right edges. Every width: a chip is a chip on a phone too. Raw numbers here; the judgement is summarise's.
576
+ layout.edgeTexts = [];
577
+ const EDGE_TEXT_MAX_GAP = 3;
578
+ if (typeof document.createRange === 'function' && typeof document.createTreeWalker === 'function') {
579
+ const shown = new Set(visible);
580
+ const range = document.createRange();
581
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
582
+ const framed = (el) => {
583
+ const cs = styleOf(el);
584
+ if (cs.display === 'inline') return false;
585
+ const bg = cs.backgroundColor;
586
+ return (bg && bg !== 'transparent' && !/rgba\\([^)]*,\\s*0\\)$/.test(bg)) || cs.backgroundImage !== 'none' ||
587
+ parseFloat(cs.borderLeftWidth) > 0 || parseFloat(cs.borderRightWidth) > 0;
588
+ };
589
+ const frames = new Set();
590
+ for (let n = walker.nextNode(); n; n = walker.nextNode()) {
591
+ const text = n.textContent.trim();
592
+ if (!text || !shown.has(n.parentElement)) continue;
593
+ let frame = null;
594
+ for (let e = n.parentElement, i = 0; e && e !== document.body && i < 4; e = e.parentElement, i++) {
595
+ if (framed(e)) { frame = e; break; }
596
+ }
597
+ if (!frame || frames.has(frame)) continue;
598
+ const fr = frame.getBoundingClientRect();
599
+ if (fr.width < 16 || fr.width >= vw * 0.98) continue;
600
+ range.selectNodeContents(n);
601
+ const r = range.getBoundingClientRect();
602
+ if (!(r.width > 0 && r.height > 0)) continue;
603
+ const fcs = styleOf(frame);
604
+ const left = r.left - (fr.left + (parseFloat(fcs.borderLeftWidth) || 0));
605
+ const right = fr.right - (parseFloat(fcs.borderRightWidth) || 0) - r.right;
606
+ frames.add(frame);
607
+ if (Math.min(left, right) < EDGE_TEXT_MAX_GAP) layout.edgeTexts.push({ cls: cls(frame), text: text.slice(0, 50), left: round(left), right: round(right), frameWidth: round(fr.width), frameHeight: round(fr.height) });
608
+ }
609
+ }
610
+
495
611
  const contentBottom = visible.reduce((lowest, el) => {
496
612
  const r = el.getBoundingClientRect();
497
613
  return r.width > 0 && r.height > 0 && r.bottom > lowest ? r.bottom : lowest;
@@ -499,6 +615,7 @@ var require_src = __commonJS({
499
615
 
500
616
  return {
501
617
  lists,
618
+ layout,
502
619
  // FLD-012 AC3 \u2014 the SIZE of the population every rule below is computed over, and the number of
503
620
  // elements the visibility correction took out of it. Reported directly because a rule's finding
504
621
  // count can move for several reasons at once, and 'the fix changed what is measured' is a
@@ -637,9 +754,24 @@ var require_src = __commonJS({
637
754
  */
638
755
  SingleGround: "single-ground",
639
756
  NoImagery: "no-imagery",
640
- NoDisplayType: "no-display-type"
757
+ NoDisplayType: "no-display-type",
758
+ /**
759
+ * P112-OWN019-LAPTOPGAPS (R39, Richard 2026-10-14). On a laptop, two things side by side in one row with a hole
760
+ * between their ink — RL1's feature number given a fifth of the page by a Columns "1 4" row, L1's text and card
761
+ * pushed to the row's two edges. A warning: a hole can be meant, and the screenshots are the arbiter.
762
+ */
763
+ RowGap: "row-gap",
764
+ /** P112-OWN019-LAPTOPGAPS — a section that centres its heading and leaves another part off that centre (L1's price). */
765
+ AlignmentDrift: "alignment-drift",
766
+ /**
767
+ * P112-OWN019-LETTEREDGE (Richard 2026-10-15, TL2's hero). Words within 3 px of the inner left or right edge of the
768
+ * box that frames them (a border or a fill) — a letter in an outlined chip set left because its Text stretched.
769
+ */
770
+ TextAgainstEdge: "text-against-edge"
641
771
  };
642
772
  var SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
773
+ var ROW_GAP_MIN_TALLEST = 48;
774
+ var ROW_GAP_MIN_SHARE = 0.12;
643
775
  var POVERTY_FINDINGS = [RenderFinding.SingleGround, RenderFinding.NoImagery, RenderFinding.NoDisplayType];
644
776
  function isPovertyFinding(finding) {
645
777
  return Boolean(finding) && POVERTY_FINDINGS.indexOf(finding.code) !== -1;
@@ -855,6 +987,43 @@ var require_src = __commonJS({
855
987
  evidence: v.grounds.values
856
988
  });
857
989
  }
990
+ if (isDesktop && v.layout) {
991
+ const gaps = (v.layout.rowGaps || []).filter((g) => (g.left || g.right) && g.tallest >= ROW_GAP_MIN_TALLEST && g.gap >= g.rowWidth * ROW_GAP_MIN_SHARE);
992
+ const seen2 = /* @__PURE__ */ new Set();
993
+ for (const g of gaps) {
994
+ const key = `${g.left}|${g.right}`;
995
+ if (seen2.has(key) || seen2.size >= 3) continue;
996
+ seen2.add(key);
997
+ add({
998
+ code: RenderFinding.RowGap,
999
+ severity: "warning",
1000
+ viewport: name,
1001
+ 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.`,
1002
+ evidence: g
1003
+ });
1004
+ }
1005
+ const drifts = (v.layout.drifts || []).slice(0, 3);
1006
+ for (const d of drifts) {
1007
+ add({
1008
+ code: RenderFinding.AlignmentDrift,
1009
+ severity: "warning",
1010
+ viewport: name,
1011
+ 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.`,
1012
+ evidence: d
1013
+ });
1014
+ }
1015
+ }
1016
+ const edges = v.layout && v.layout.edgeTexts || [];
1017
+ if (edges.length) {
1018
+ const named = edges.slice(0, 3).map((e) => `"${e.text}" ${Math.max(0, Math.min(e.left, e.right))}px from the ${e.left <= e.right ? "left" : "right"} edge of its ${e.frameWidth}\xD7${e.frameHeight}px box`);
1019
+ add({
1020
+ code: RenderFinding.TextAgainstEdge,
1021
+ severity: "warning",
1022
+ viewport: name,
1023
+ message: `${plural(edges.length, "text sits", "texts sit")} hard against the edge of the outlined or filled box that frames ${edges.length === 1 ? "it" : "them"}: ${named.join("; ")}. Centring a Group does not centre a Text's letters \u2014 the Text stretches to the Group's width and sets them left \u2014 so give the Text textAlignX: center, or give the box padding. Look at this width's screenshot.`,
1024
+ evidence: edges.slice(0, 6)
1025
+ });
1026
+ }
858
1027
  if (v.consoleErrors && v.consoleErrors.length) {
859
1028
  add({
860
1029
  code: RenderFinding.ConsoleError,
@@ -964,6 +1133,7 @@ var require_render_report = __commonJS({
964
1133
  var WS_MODULE = HARNESS_PATHS.WS_MODULE.path;
965
1134
  var {
966
1135
  DEFAULT_VIEWPORTS,
1136
+ NAMED_VIEWPORTS,
967
1137
  DESKTOP_WIDTH,
968
1138
  RenderFinding,
969
1139
  SEVERITY_ORDER,
@@ -1499,7 +1669,7 @@ var require_render_report = __commonJS({
1499
1669
  }
1500
1670
  function parseViewports2(spec) {
1501
1671
  if (!spec) return DEFAULT_VIEWPORTS;
1502
- const known = new Map(DEFAULT_VIEWPORTS.map((v) => [v.name, v]));
1672
+ const known = new Map(NAMED_VIEWPORTS.map((v) => [v.name, v]));
1503
1673
  return spec.split(",").map((token) => {
1504
1674
  const trimmed = token.trim();
1505
1675
  if (known.has(trimmed)) return known.get(trimmed);
@@ -2312,10 +2482,12 @@ function locateExpression(target) {
2312
2482
  if (all.length === 0) return { found: false, matched: 0 };
2313
2483
  let last;
2314
2484
  for (const el of all) {
2315
- el.scrollIntoView({ block: 'center', inline: 'center' });
2485
+ // 'instant': under \`html { scroll-behavior: smooth }\` a plain scrollIntoView glides, the rect below is read
2486
+ // before it arrives, and every target off screen was refused as off screen (P112-OWN019-TAPHOLD, L1's page).
2487
+ el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
2316
2488
  const r = el.getBoundingClientRect();
2317
2489
  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; }
2490
+ if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) { last = { x, y, offscreen: true, wouldHit: 'nothing (off screen)', target: describe(el) }; continue; }
2319
2491
  // Lent pointer-events for the one measurement: elementFromPoint skips an element that has none.
2320
2492
  const before = el.style.pointerEvents;
2321
2493
  el.style.pointerEvents = 'auto';
@@ -2540,6 +2712,20 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
2540
2712
  const value = JSON.parse(last);
2541
2713
  return value && typeof value === "object" ? { ...value, settledMs: Date.now() - t0 } : value;
2542
2714
  };
2715
+ 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(','); })()`;
2716
+ const scrollSettled = async () => {
2717
+ const t0 = Date.now();
2718
+ let last = await evaluate(SCROLLS);
2719
+ let moved = false;
2720
+ for (let same = 0; same < 2 && Date.now() - t0 < 2e3; ) {
2721
+ await frames();
2722
+ const next = await evaluate(SCROLLS);
2723
+ if (next === last) same++;
2724
+ else same = 0, moved = true;
2725
+ last = next;
2726
+ }
2727
+ return moved ? { scrolledMs: Date.now() - t0, still: Date.now() - t0 >= 2e3 } : null;
2728
+ };
2543
2729
  const press = async (target) => {
2544
2730
  let p = await evaluate(locateExpression(target));
2545
2731
  for (let i = 0; i < 20 && !p.found; i++) {
@@ -2576,6 +2762,11 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
2576
2762
  row.pressed = p.target;
2577
2763
  if (p.matched > 1) row.matched = p.matched;
2578
2764
  await frames();
2765
+ const glide = await scrollSettled();
2766
+ if (glide) {
2767
+ row.scrolledMs = glide.scrolledMs;
2768
+ if (glide.still) row.stillScrolling = true;
2769
+ }
2579
2770
  } else if (verb === "type") {
2580
2771
  if (typeof step.type !== "string") throw new UsageError(`Step ${i + 1}: type takes a string.`);
2581
2772
  if (step.into !== void 0) await press(step.into);
@@ -2629,27 +2820,40 @@ async function runSteps(page, steps, { outDir, stubs = routeStubs(page), network
2629
2820
  row.url = await evaluate("location.pathname + location.hash");
2630
2821
  } else if (verb === "watch") {
2631
2822
  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 }.`);
2633
- const forMs = Math.min(1e4, Math.max(100, Number(step.for_ms ?? 1500)));
2634
- const expr = lookExpression(w.look);
2823
+ if (w.look === void 0) throw new UsageError(`Step ${i + 1}: watch takes { look: T } or { look: [T, T, \u2026] }.`);
2824
+ const unknown = Object.keys(w).filter((k) => !["look", "for_ms", "expect"].includes(k));
2825
+ if (unknown.length) throw new UsageError(`Step ${i + 1}: watch does not take ${unknown.map((k) => JSON.stringify(k)).join(", ")} \u2014 it takes look, for_ms and expect.`);
2826
+ if (step.expect !== void 0 && w.expect !== void 0) throw new UsageError(`Step ${i + 1}: give watch one expect, beside it or inside it, not both.`);
2827
+ const forMs = Math.min(1e4, Math.max(100, Number(step.for_ms ?? w.for_ms ?? 1500)));
2828
+ const expect = step.expect !== void 0 ? step.expect : w.expect;
2829
+ const many = Array.isArray(w.look);
2830
+ const targets = many ? w.look : [w.look];
2831
+ if (targets.length === 0 || targets.length > 8) throw new UsageError(`Step ${i + 1}: watch looks at 1 to 8 targets.`);
2832
+ const expr = `Promise.all([${targets.map((t) => lookExpression(t)).join(", ")}])`;
2635
2833
  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 = [];
2834
+ const states = targets.map(() => []);
2637
2835
  const t0 = Date.now();
2638
2836
  while (Date.now() - t0 <= forMs) {
2639
- const v = await evaluate(expr);
2837
+ const vs = await evaluate(expr);
2640
2838
  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 });
2839
+ vs.forEach((v, k) => {
2840
+ const list = states[k];
2841
+ const last = list[list.length - 1];
2842
+ if (last && last.key === keyOf(v)) last.until = at;
2843
+ 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 });
2844
+ });
2644
2845
  await wait(25);
2645
2846
  }
2646
- row.value = states.map(({ from, until, look }) => ({ from, until, look }));
2647
- const never = step.expect && typeof step.expect === "object" ? step.expect.never : void 0;
2847
+ const shown = (list) => list.map(({ from, until, look }) => ({ from, until, look }));
2848
+ row.value = many ? targets.map((t, k) => ({ target: t, states: shown(states[k]) })) : shown(states[0]);
2849
+ const never = expect && typeof expect === "object" ? expect.never : void 0;
2648
2850
  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)}`;
2851
+ for (let k = 0; k < targets.length && row.ok; k++) {
2852
+ const hit = states[k].find((st) => st.look && Object.keys(never).every((key) => matches(st.look[key], never[key])));
2853
+ if (hit) {
2854
+ row.ok = false;
2855
+ row.error = `${many ? `${JSON.stringify(targets[k])}: ` : ""}${JSON.stringify(never)} was seen from ${hit.from} to ${hit.until} ms: ${JSON.stringify(hit.look)}`;
2856
+ }
2653
2857
  }
2654
2858
  }
2655
2859
  } else if (verb === "viewport") {