@supportpages.io/wtfm 0.0.0-stage → 0.3.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 (275) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +23 -0
  3. package/README.md +392 -2
  4. package/dist/actions.d.ts +98 -0
  5. package/dist/actions.js +86 -0
  6. package/dist/actions.js.map +1 -0
  7. package/dist/agent-settings.d.ts +22 -0
  8. package/dist/agent-settings.js +35 -0
  9. package/dist/agent-settings.js.map +1 -0
  10. package/dist/api.d.ts +14 -0
  11. package/dist/api.js +159 -0
  12. package/dist/api.js.map +1 -0
  13. package/dist/article-link.d.ts +16 -0
  14. package/dist/article-link.js +25 -0
  15. package/dist/article-link.js.map +1 -0
  16. package/dist/artifacts.d.ts +45 -0
  17. package/dist/artifacts.js +144 -0
  18. package/dist/artifacts.js.map +1 -0
  19. package/dist/brand.d.ts +4 -0
  20. package/dist/brand.js +9 -0
  21. package/dist/brand.js.map +1 -0
  22. package/dist/bridge.d.ts +2964 -0
  23. package/dist/bridge.js +1036 -0
  24. package/dist/bridge.js.map +1 -0
  25. package/dist/capacity.d.ts +5 -0
  26. package/dist/capacity.js +11 -0
  27. package/dist/capacity.js.map +1 -0
  28. package/dist/credentials.d.ts +13 -0
  29. package/dist/credentials.js +88 -0
  30. package/dist/credentials.js.map +1 -0
  31. package/dist/development-tls.d.ts +8 -0
  32. package/dist/development-tls.js +38 -0
  33. package/dist/development-tls.js.map +1 -0
  34. package/dist/errors.d.ts +15 -0
  35. package/dist/errors.js +18 -0
  36. package/dist/errors.js.map +1 -0
  37. package/dist/export.d.ts +11 -0
  38. package/dist/export.js +91 -0
  39. package/dist/export.js.map +1 -0
  40. package/dist/hosted-operations.d.ts +108 -0
  41. package/dist/hosted-operations.js +120 -0
  42. package/dist/hosted-operations.js.map +1 -0
  43. package/dist/hosting-benefits.d.ts +66 -0
  44. package/dist/hosting-benefits.js +67 -0
  45. package/dist/hosting-benefits.js.map +1 -0
  46. package/dist/index.d.ts +2 -0
  47. package/dist/index.js +49 -0
  48. package/dist/index.js.map +1 -0
  49. package/dist/local-inventory.d.ts +22 -0
  50. package/dist/local-inventory.js +59 -0
  51. package/dist/local-inventory.js.map +1 -0
  52. package/dist/local-setup.d.ts +208 -0
  53. package/dist/local-setup.js +140 -0
  54. package/dist/local-setup.js.map +1 -0
  55. package/dist/pairing.d.ts +71 -0
  56. package/dist/pairing.js +235 -0
  57. package/dist/pairing.js.map +1 -0
  58. package/dist/preferences.d.ts +18 -0
  59. package/dist/preferences.js +44 -0
  60. package/dist/preferences.js.map +1 -0
  61. package/dist/progress.d.ts +205 -0
  62. package/dist/progress.js +224 -0
  63. package/dist/progress.js.map +1 -0
  64. package/dist/reminders.d.ts +31 -0
  65. package/dist/reminders.js +73 -0
  66. package/dist/reminders.js.map +1 -0
  67. package/dist/replace-connection.d.ts +6 -0
  68. package/dist/replace-connection.js +77 -0
  69. package/dist/replace-connection.js.map +1 -0
  70. package/dist/repository-actions.d.ts +18 -0
  71. package/dist/repository-actions.js +9 -0
  72. package/dist/repository-actions.js.map +1 -0
  73. package/dist/repository-benefits.d.ts +96 -0
  74. package/dist/repository-benefits.js +64 -0
  75. package/dist/repository-benefits.js.map +1 -0
  76. package/dist/run-update.d.ts +17 -0
  77. package/dist/run-update.js +27 -0
  78. package/dist/run-update.js.map +1 -0
  79. package/dist/runs.d.ts +541 -0
  80. package/dist/runs.js +146 -0
  81. package/dist/runs.js.map +1 -0
  82. package/dist/runtime.d.ts +12 -0
  83. package/dist/runtime.js +58 -0
  84. package/dist/runtime.js.map +1 -0
  85. package/dist/schema.d.ts +277 -0
  86. package/dist/schema.js +66 -0
  87. package/dist/schema.js.map +1 -0
  88. package/dist/server.d.ts +4 -0
  89. package/dist/server.js +298 -0
  90. package/dist/server.js.map +1 -0
  91. package/dist/session.d.ts +1714 -0
  92. package/dist/session.js +619 -0
  93. package/dist/session.js.map +1 -0
  94. package/dist/settings.d.ts +9 -0
  95. package/dist/settings.js +21 -0
  96. package/dist/settings.js.map +1 -0
  97. package/dist/sync.d.ts +495 -0
  98. package/dist/sync.js +191 -0
  99. package/dist/sync.js.map +1 -0
  100. package/dist/telemetry-scrub.d.ts +13 -0
  101. package/dist/telemetry-scrub.js +51 -0
  102. package/dist/telemetry-scrub.js.map +1 -0
  103. package/dist/telemetry.d.ts +73 -0
  104. package/dist/telemetry.js +173 -0
  105. package/dist/telemetry.js.map +1 -0
  106. package/dist/walkthroughs.d.ts +224 -0
  107. package/dist/walkthroughs.js +109 -0
  108. package/dist/walkthroughs.js.map +1 -0
  109. package/dist/workspace.d.ts +14 -0
  110. package/dist/workspace.js +127 -0
  111. package/dist/workspace.js.map +1 -0
  112. package/dist/writer-agent.d.ts +21 -0
  113. package/dist/writer-agent.js +27 -0
  114. package/dist/writer-agent.js.map +1 -0
  115. package/dist/writer-entry.d.ts +171 -0
  116. package/dist/writer-entry.js +233 -0
  117. package/dist/writer-entry.js.map +1 -0
  118. package/dist/writing-style.d.ts +7 -0
  119. package/dist/writing-style.js +52 -0
  120. package/dist/writing-style.js.map +1 -0
  121. package/engine/SYNC.json +4 -0
  122. package/engine/VERSION +1 -0
  123. package/engine/detect-project/README.md +141 -0
  124. package/engine/detect-project/SKILL.md +1421 -0
  125. package/engine/detect-project/assets/desktop/desktop-frame.css +428 -0
  126. package/engine/detect-project/assets/game/game-frame.css +132 -0
  127. package/engine/detect-project/assets/macosui/LICENSE-puppertino.txt +21 -0
  128. package/engine/detect-project/assets/macosui/VERSIONS.txt +1 -0
  129. package/engine/detect-project/assets/macosui/fonts.css +15 -0
  130. package/engine/detect-project/assets/macosui/macos-frame.css +481 -0
  131. package/engine/detect-project/assets/macosui/puppertino.css +2153 -0
  132. package/engine/detect-project/assets/mobileui/LICENSE-fonts.txt +13 -0
  133. package/engine/detect-project/assets/mobileui/LICENSE-framework7.txt +52 -0
  134. package/engine/detect-project/assets/mobileui/VERSIONS.txt +6 -0
  135. package/engine/detect-project/assets/mobileui/device-frame.css +316 -0
  136. package/engine/detect-project/assets/mobileui/f7-color-theme.mjs +1345 -0
  137. package/engine/detect-project/assets/mobileui/f7-icons-names.json +1254 -0
  138. package/engine/detect-project/assets/mobileui/fonts.css +16 -0
  139. package/engine/detect-project/assets/mobileui/framework7-components.css +39 -0
  140. package/engine/detect-project/assets/mobileui/framework7-core.css +5245 -0
  141. package/engine/detect-project/assets/mobileui/icons.css +31 -0
  142. package/engine/detect-project/assets/mobileui/md3-defaults.css +89 -0
  143. package/engine/detect-project/assets/mobileui/platforms.json +46 -0
  144. package/engine/detect-project/assets/tailwind-fallback.css +1729 -0
  145. package/engine/detect-project/assets/webtui/LICENSE-webtui.txt +28 -0
  146. package/engine/detect-project/assets/webtui/VERSIONS.txt +7 -0
  147. package/engine/detect-project/assets/webtui/terminal-frame.css +195 -0
  148. package/engine/detect-project/assets/webtui/theme-catppuccin.css +1 -0
  149. package/engine/detect-project/assets/webtui/theme-everforest.css +1 -0
  150. package/engine/detect-project/assets/webtui/theme-gruvbox.css +1 -0
  151. package/engine/detect-project/assets/webtui/theme-nord.css +1 -0
  152. package/engine/detect-project/assets/webtui/theme-vitesse.css +1 -0
  153. package/engine/detect-project/assets/webtui/themes.json +37 -0
  154. package/engine/detect-project/assets/webtui/webtui-core.css +1 -0
  155. package/engine/detect-project/assets/win32ui/7css.css +2 -0
  156. package/engine/detect-project/assets/win32ui/LICENSE-7css.txt +21 -0
  157. package/engine/detect-project/assets/win32ui/VERSIONS.txt +1 -0
  158. package/engine/detect-project/assets/win32ui/win32-frame.css +278 -0
  159. package/engine/detect-project/package-lock.json +12 -0
  160. package/engine/detect-project/package.json +10 -0
  161. package/engine/detect-project/scripts/apply_runtime_profiles.js +313 -0
  162. package/engine/detect-project/scripts/check_css_health.js +412 -0
  163. package/engine/detect-project/scripts/check_project_map.js +150 -0
  164. package/engine/detect-project/scripts/check_runtime_coverage.js +311 -0
  165. package/engine/detect-project/scripts/check_runtime_recipe_quality.js +184 -0
  166. package/engine/detect-project/scripts/classify_app_type.sh +246 -0
  167. package/engine/detect-project/scripts/classify_surface.sh +95 -0
  168. package/engine/detect-project/scripts/classify_workspace.js +39 -0
  169. package/engine/detect-project/scripts/compile_css.sh +447 -0
  170. package/engine/detect-project/scripts/detect_static.js +645 -0
  171. package/engine/detect-project/scripts/detect_structure.js +187 -0
  172. package/engine/detect-project/scripts/include_census.js +451 -0
  173. package/engine/detect-project/scripts/json_get.js +142 -0
  174. package/engine/detect-project/scripts/merge_json.js +52 -0
  175. package/engine/detect-project/scripts/recommend_model_tier.js +252 -0
  176. package/engine/detect-project/scripts/resolve_route_chains.js +135 -0
  177. package/engine/detect-project/scripts/run_css_build.sh +40 -0
  178. package/engine/detect-project/scripts/sanitize_css.js +83 -0
  179. package/engine/detect-project/scripts/test_classify_surface.js +108 -0
  180. package/engine/detect-project/scripts/test_node_helpers.js +142 -0
  181. package/engine/detect-project/scripts/test_recommend_model_tier.js +119 -0
  182. package/engine/detect-project/scripts/test_resolve_route_chains.js +182 -0
  183. package/engine/detect-project/scripts/test_runtime_coverage.js +323 -0
  184. package/engine/detect-project/scripts/test_runtime_recipe_quality.js +187 -0
  185. package/engine/detect-project/scripts/theme_overrides.js +169 -0
  186. package/engine/detect-project/scripts/write_branding.js +129 -0
  187. package/engine/generate-illustrated-article/SKILL.md +461 -0
  188. package/engine/generate-illustrated-article/contracts/desktop.md +90 -0
  189. package/engine/generate-illustrated-article/contracts/game.md +38 -0
  190. package/engine/generate-illustrated-article/contracts/label-evidence.md +38 -0
  191. package/engine/generate-illustrated-article/contracts/macos.md +79 -0
  192. package/engine/generate-illustrated-article/contracts/mobile.md +40 -0
  193. package/engine/generate-illustrated-article/contracts/terminal.md +29 -0
  194. package/engine/generate-illustrated-article/contracts/win32.md +43 -0
  195. package/engine/generate-illustrated-article/package-lock.json +366 -0
  196. package/engine/generate-illustrated-article/package.json +15 -0
  197. package/engine/generate-illustrated-article/scripts/article_blocks.js +34 -0
  198. package/engine/generate-illustrated-article/scripts/check_article_json.js +105 -0
  199. package/engine/generate-illustrated-article/scripts/emit_walkthrough_signals.js +100 -0
  200. package/engine/generate-illustrated-article/scripts/extract_images.js +187 -0
  201. package/engine/generate-illustrated-article/scripts/generate_content_images.js +376 -0
  202. package/engine/generate-illustrated-article/scripts/include_census.js +451 -0
  203. package/engine/generate-illustrated-article/scripts/inject_assets.js +1691 -0
  204. package/engine/generate-illustrated-article/scripts/jit_mockup_css.js +191 -0
  205. package/engine/generate-illustrated-article/scripts/label_evidence.js +87 -0
  206. package/engine/generate-illustrated-article/scripts/lint_article_copy.js +252 -0
  207. package/engine/generate-illustrated-article/scripts/lint_mockup_fidelity.js +2403 -0
  208. package/engine/generate-illustrated-article/scripts/polish_tickets.js +1148 -0
  209. package/engine/generate-illustrated-article/scripts/related_repos.sh +52 -0
  210. package/engine/generate-illustrated-article/scripts/render_all.js +177 -0
  211. package/engine/generate-illustrated-article/scripts/render_mockup.js +665 -0
  212. package/engine/generate-illustrated-article/scripts/render_ready.js +164 -0
  213. package/engine/generate-illustrated-article/scripts/resolve_workspace.sh +86 -0
  214. package/engine/generate-illustrated-article/scripts/runtime_region_geometry.js +55 -0
  215. package/engine/generate-illustrated-article/scripts/source_paths.js +49 -0
  216. package/engine/generate-illustrated-article/scripts/test_control_visibility.js +30 -0
  217. package/engine/generate-illustrated-article/scripts/test_emit_walkthrough_signals.js +157 -0
  218. package/engine/generate-illustrated-article/scripts/test_generate_content_images.js +168 -0
  219. package/engine/generate-illustrated-article/scripts/test_include_census.js +126 -0
  220. package/engine/generate-illustrated-article/scripts/test_label_evidence.js +49 -0
  221. package/engine/generate-illustrated-article/scripts/test_lint_article_copy.js +138 -0
  222. package/engine/generate-illustrated-article/scripts/test_lint_mockup_fidelity.js +653 -0
  223. package/engine/generate-illustrated-article/scripts/test_node_ports.js +152 -0
  224. package/engine/generate-illustrated-article/scripts/test_polish_tickets.js +481 -0
  225. package/engine/generate-illustrated-article/scripts/test_related_repos.js +64 -0
  226. package/engine/generate-illustrated-article/scripts/test_render_ready.js +85 -0
  227. package/engine/generate-illustrated-article/scripts/test_source_paths.js +68 -0
  228. package/engine/generate-illustrated-article/scripts/trace_hook.js +79 -0
  229. package/engine/generate-illustrated-article/scripts/trace_hook.sh +4 -0
  230. package/engine/generate-illustrated-article/scripts/validate_html.js +114 -0
  231. package/engine/generate-illustrated-article/scripts/watermark.js +69 -0
  232. package/install.sh +14 -0
  233. package/package.json +57 -4
  234. package/scripts/auth.mjs +33 -0
  235. package/scripts/auto-update.mjs +7 -0
  236. package/scripts/build-plugin.mjs +58 -0
  237. package/scripts/build-release.mjs +94 -0
  238. package/scripts/check-release.mjs +40 -0
  239. package/scripts/check-runtime.mjs +10 -0
  240. package/scripts/cli.mjs +8 -0
  241. package/scripts/install.mjs +64 -0
  242. package/scripts/lib/agent-runner.mjs +181 -0
  243. package/scripts/lib/agent-settings.mjs +215 -0
  244. package/scripts/lib/article-skills.mjs +82 -0
  245. package/scripts/lib/auto-update.mjs +50 -0
  246. package/scripts/lib/brand.mjs +9 -0
  247. package/scripts/lib/browser.mjs +12 -0
  248. package/scripts/lib/claude-connection.mjs +33 -0
  249. package/scripts/lib/claude-permissions.mjs +47 -0
  250. package/scripts/lib/claude-plugin.mjs +15 -0
  251. package/scripts/lib/claude-writer.mjs +10 -0
  252. package/scripts/lib/cli-main.mjs +85 -0
  253. package/scripts/lib/cli.mjs +888 -0
  254. package/scripts/lib/codex-config.mjs +51 -0
  255. package/scripts/lib/codex-integration.mjs +61 -0
  256. package/scripts/lib/codex-skill.mjs +34 -0
  257. package/scripts/lib/harness-models.mjs +109 -0
  258. package/scripts/lib/install.mjs +269 -0
  259. package/scripts/lib/managed-writer.mjs +38 -0
  260. package/scripts/lib/planning.mjs +263 -0
  261. package/scripts/lib/prepare-update.mjs +85 -0
  262. package/scripts/lib/refresh-writers.mjs +12 -0
  263. package/scripts/lib/remove.mjs +167 -0
  264. package/scripts/lib/renderer.mjs +34 -0
  265. package/scripts/lib/terminal.mjs +252 -0
  266. package/scripts/lib/uninit.mjs +72 -0
  267. package/scripts/lib/update.mjs +55 -0
  268. package/scripts/lib/writer-recovery.mjs +35 -0
  269. package/scripts/lib/yolo.mjs +362 -0
  270. package/scripts/plugin-session.mjs +65 -0
  271. package/scripts/prepare-update.mjs +12 -0
  272. package/scripts/publish-release.mjs +92 -0
  273. package/scripts/skills/supportpages/SKILL.md +120 -0
  274. package/scripts/smoke-release.mjs +101 -0
  275. package/server.json +27 -0
@@ -0,0 +1,665 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Mockup Renderer
4
+ *
5
+ * Renders an HTML mockup to PNG using Puppeteer with quality detection,
6
+ * adaptive viewport, and diagnostics JSON.
7
+ *
8
+ * Frame size: the preset named by <body data-viewport="…"> (default `web`,
9
+ * 1480×900 — the PC-class size shared with the desktop window frame).
10
+ * `web` and `exact` presets capture exactly the viewport; every other preset
11
+ * crops to the measured content extent + 48px (framed modes — ios/android/
12
+ * macos/windows/game — and terminal's `wide` rely on that crop for symmetric
13
+ * margins around a variable-height frame).
14
+ *
15
+ * Notes:
16
+ * - Resolves puppeteer relative to this script's location (so the skill works
17
+ * regardless of the cwd it's invoked from).
18
+ * - Drops the /usr/bin/chromium executablePath default — Puppeteer uses its
19
+ * bundled Chromium that npm installed.
20
+ *
21
+ * Usage: render_mockup.js <output_png> <html_file>
22
+ */
23
+
24
+ const Module = require('module');
25
+ const path = require('path');
26
+ const fs = require('fs');
27
+
28
+ // Resolve puppeteer from the script's own node_modules, not the cwd's.
29
+ const scriptNodeModules = path.join(__dirname, '..', 'node_modules');
30
+ Module.globalPaths.unshift(scriptNodeModules);
31
+
32
+ // Packaged installs hoist dependencies to a parent node_modules instead, which
33
+ // ordinary resolution from this directory finds.
34
+ function loadPuppeteer() {
35
+ try { return require(path.join(scriptNodeModules, 'puppeteer')); }
36
+ catch (error) {
37
+ if (error.code !== 'MODULE_NOT_FOUND') throw error;
38
+ return require('puppeteer');
39
+ }
40
+ }
41
+ const puppeteer = loadPuppeteer();
42
+ const { WATERMARK_TEXT_SCREENSHOT, WATERMARK_FONT, watermarkMetrics, isWatermarkEnabled } = require('./watermark.js');
43
+ const { measureRuntimeRegionGeometry } = require('./runtime_region_geometry.js');
44
+
45
+ const VIEWPORTS = {
46
+ mobile: { width: 375, height: 667 }, // legacy responsive-web preset — NOT for device-frame mockups (use ios/android)
47
+ // Device-frame presets (mobile-app mode): width = device outer width + 96 so the
48
+ // centered .device-stage (48px padding) yields symmetric 48px margins under the
49
+ // content crop. iOS outer 417px, Android outer 432px (see device-frame.css).
50
+ // Heights must exceed the framed device's bottom edge (48px stage padding +
51
+ // outer frame height) — content below the viewport height does not paint.
52
+ ios: { width: 513, height: 1008 },
53
+ android: { width: 528, height: 1072 },
54
+ // Window-frame presets (desktop-app mode): same margin math — width 1576 =
55
+ // .desktop-window 1480 + 96 (48px .desktop-stage padding each side; the
56
+ // crop is width-capped at the viewport). Height carries ~100px headroom
57
+ // over the window bottom (48 + 900 + 48 = 996): content below the viewport
58
+ // height does not paint, so zero headroom would turn any 1px overflow into
59
+ // a blank strip in the crop. The window matches the `web` preset so every
60
+ // PC-class capture (browser, Electron/Tauri, Win32, macOS) is the same size.
61
+ macos: { width: 1576, height: 1100 },
62
+ windows: { width: 1576, height: 1100 },
63
+ // Playfield preset (game mode): width 1296 = max .game-screen 1200 + 96.
64
+ // The .game-stage is LEFT-aligned (flex-start) at 48px padding, so the
65
+ // content crop (maxRight/maxBottom + 48) yields symmetric 48px margins for
66
+ // ANY per-game display_size — unlike the centered device/window stages
67
+ // there is no fixed frame width to center against. Height 1100 clears the
68
+ // fit-height maximum (48 + 900 = 948 content bottom) with ~150px headroom;
69
+ // content below the viewport height does not paint. Deliberately NOT
70
+ // exact:true — display sizes vary per game and the content crop handles
71
+ // them; exact would freeze dead space around smaller screens.
72
+ game: { width: 1296, height: 1100 },
73
+ tablet: { width: 768, height: 1024 },
74
+ desktop: { width: 800, height: 600 }, // legacy responsive-web preset — NOT for window-frame mockups (use macos/windows)
75
+ // Web-app preset (and the default when a mockup carries no data-viewport):
76
+ // the PNG IS the viewport — 1480×900 logical, like a browser screenshot on
77
+ // a typical laptop (16:10; Bootstrap xxl, Tailwind xl, MUI lg — 56px clear
78
+ // of every framework breakpoint, so no layout sits on a knife edge).
79
+ // viewportCrop (vs exact) keeps the watermark band APPENDED below the
80
+ // frame rather than overlaid. Motivation: the content-extent crop grew
81
+ // past the viewport on tall pages while position:fixed overlays (a modal
82
+ // + its backdrop, a sticky header) stay viewport-sized, so a modal step
83
+ // rendered dimmed for the top 800px and undimmed below. Content past the
84
+ // fold is simply not in the screenshot — the contract tells the model to
85
+ // depict the scrolled state, and the evidence-visibility gate enforces it.
86
+ web: { width: 1480, height: 900, viewportCrop: true },
87
+ // Content-cropped 960×800 — terminal mockups (the window height follows the
88
+ // row count) and any legacy web mockup that set it explicitly. 960 gives a
89
+ // ~100-column window: wide enough for real CLI output, narrow enough that a
90
+ // ~750px help-centre column shows the monospace text at a readable size
91
+ // (at 1200 it scaled to ~9px and short prompts filled half the window).
92
+ wide: { width: 960, height: 800 },
93
+ terminal: { width: 600, height: 400 },
94
+ tui: { width: 720, height: 480 },
95
+ social: { width: 1200, height: 630, exact: true },
96
+ };
97
+
98
+ const BASE_FALLBACK_CSS = `
99
+
100
+ @font-face {
101
+ font-family: 'Inter';
102
+ src: local('Inter'), local('system-ui'), local('-apple-system'), local('BlinkMacSystemFont');
103
+ font-weight: 100 900;
104
+ }
105
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap');
106
+ .tui-app, .tui-container, [data-viewport="tui"] body {
107
+ font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, 'Cascadia Code', monospace !important;
108
+ }
109
+ `;
110
+
111
+ // Glyph substitutes for icon-font classes. Uses !important, so it must NOT
112
+ // be injected when inject_assets embedded the real icon fonts (marked via
113
+ // data-rtfm-icon-fonts on <html>).
114
+ const ICON_FALLBACK_CSS = `
115
+ .fa, .fas, .far, .fab, [class^="fa-"], [class*=" fa-"] {
116
+ font-family: system-ui, -apple-system, sans-serif !important;
117
+ }
118
+ .fa-check::before, .fa-check-circle::before { content: "\\2713" !important; }
119
+ .fa-times::before, .fa-times-circle::before, .fa-close::before { content: "\\2717" !important; }
120
+ .fa-arrow-right::before { content: "\\2192" !important; }
121
+ .fa-arrow-left::before { content: "\\2190" !important; }
122
+ .fa-arrow-up::before { content: "\\2191" !important; }
123
+ .fa-arrow-down::before { content: "\\2193" !important; }
124
+ .fa-plus::before { content: "+" !important; }
125
+ .fa-minus::before { content: "\\2212" !important; }
126
+ .fa-search::before { content: "\\1F50D" !important; }
127
+ .fa-user::before { content: "\\1F464" !important; }
128
+ .fa-cog::before, .fa-gear::before { content: "\\2699" !important; }
129
+ .fa-home::before { content: "\\1F3E0" !important; }
130
+ .fa-envelope::before, .fa-mail::before { content: "\\2709" !important; }
131
+ .fa-edit::before, .fa-pencil::before { content: "\\270E" !important; }
132
+ .fa-trash::before { content: "\\1F5D1" !important; }
133
+ .fa-star::before { content: "\\2605" !important; }
134
+ .fa-warning::before, .fa-exclamation-triangle::before { content: "\\26A0" !important; }
135
+ .fa-info::before, .fa-info-circle::before { content: "\\2139" !important; }
136
+ .fa-lock::before { content: "\\1F512" !important; }
137
+ .fa-eye::before { content: "\\1F441" !important; }
138
+ .fa-copy::before { content: "\\1F4CB" !important; }
139
+ .fa-spinner::before { content: "\\21BB" !important; }
140
+ .fa-refresh::before, .fa-sync::before { content: "\\21BB" !important; }
141
+ .fa-link::before { content: "\\1F517" !important; }
142
+ .fa-calendar::before { content: "\\1F4C5" !important; }
143
+ .fa-clock::before { content: "\\1F550" !important; }
144
+ .fa-file::before { content: "\\1F4C4" !important; }
145
+ .fa-folder::before { content: "\\1F4C1" !important; }
146
+ .fa-github::before { content: "\\1F4BB" !important; }
147
+ .fa-sort::before { content: "\\21C5" !important; }
148
+ .fa-bars::before { content: "\\2630" !important; }
149
+ .fa-chevron-down::before, .fa-caret-down::before, .fa-angle-down::before { content: "\\25BE" !important; }
150
+ .fa-chevron-up::before, .fa-caret-up::before, .fa-angle-up::before { content: "\\25B4" !important; }
151
+ .fa-chevron-right::before, .fa-angle-right::before { content: "\\203A" !important; }
152
+ .fa-chevron-left::before, .fa-angle-left::before { content: "\\2039" !important; }
153
+ .fa-ellipsis::before, .fa-ellipsis-h::before { content: "\\2026" !important; }
154
+ .fa-phone::before { content: "\\260E" !important; }
155
+ .fa-comment::before, .fa-comments::before { content: "\\1F4AC" !important; }
156
+ .fa-bell::before { content: "\\1F514" !important; }
157
+ .fa-question-circle::before, .fa-circle-question::before { content: "?" !important; }
158
+ .fa-filter::before { content: "\\25E2" !important; }
159
+ .fa-download::before { content: "\\2913" !important; }
160
+ .fa-upload::before { content: "\\2912" !important; }
161
+ .fa-sign-out::before, .fa-sign-out-alt::before, .fa-right-from-bracket::before { content: "\\21AA" !important; }
162
+ .bi, [class^="bi-"], [class*=" bi-"] {
163
+ font-family: system-ui, -apple-system, sans-serif !important;
164
+ }
165
+ [class*="bi-house"]::before { content: "\\1F3E0" !important; }
166
+ [class*="bi-person"]::before { content: "\\1F464" !important; }
167
+ [class*="bi-gear"]::before { content: "\\2699" !important; }
168
+ [class*="bi-envelope"]::before { content: "\\2709" !important; }
169
+ [class*="bi-pencil"]::before { content: "\\270E" !important; }
170
+ [class*="bi-trash"]::before { content: "\\1F5D1" !important; }
171
+ [class*="bi-search"]::before { content: "\\1F50D" !important; }
172
+ [class*="bi-plus"]::before { content: "+" !important; }
173
+ [class*="bi-check"]::before { content: "\\2713" !important; }
174
+ [class*="bi-x-"]::before, .bi-x::before { content: "\\2717" !important; }
175
+ [class*="bi-calendar"]::before { content: "\\1F4C5" !important; }
176
+ [class*="bi-clock"]::before { content: "\\1F550" !important; }
177
+ [class*="bi-telephone"]::before { content: "\\260E" !important; }
178
+ [class*="bi-chat"]::before { content: "\\1F4AC" !important; }
179
+ [class*="bi-bell"]::before { content: "\\1F514" !important; }
180
+ [class*="bi-chevron-down"]::before { content: "\\25BE" !important; }
181
+ [class*="bi-chevron-right"]::before { content: "\\203A" !important; }
182
+ [class*="bi-list"]::before { content: "\\2630" !important; }
183
+ [class*="bi-star"]::before { content: "\\2605" !important; }
184
+ [class*="bi-info"]::before { content: "\\2139" !important; }
185
+ [class*="bi-exclamation"]::before { content: "\\26A0" !important; }
186
+ `;
187
+
188
+ function getViewportFromHtml(html) {
189
+ const m = html.match(/data-viewport=["'](\w+)["']/i);
190
+ const type = m ? m[1].toLowerCase() : 'web';
191
+ return VIEWPORTS[type] || VIEWPORTS.web;
192
+ }
193
+
194
+ function calculateQualityScore(metrics, pageErrors, failedResources) {
195
+ let score = 100;
196
+ const deductions = [];
197
+ if (pageErrors.length > 0) {
198
+ const d = Math.min(pageErrors.length * 10, 30);
199
+ score -= d;
200
+ deductions.push(`-${d}: ${pageErrors.length} page error(s)`);
201
+ }
202
+ if (failedResources.length > 0) {
203
+ const d = Math.min(failedResources.length * 3, 15);
204
+ score -= d;
205
+ deductions.push(`-${d}: ${failedResources.length} failed resource(s)`);
206
+ }
207
+ if (metrics.isLikelyBlank) {
208
+ score -= 50;
209
+ deductions.push('-50: Appears blank');
210
+ }
211
+ if (metrics.visibleElementCount < 3) {
212
+ score -= 10;
213
+ deductions.push('-10: Very few visible elements');
214
+ }
215
+ if (metrics.isClipped) {
216
+ score -= 15;
217
+ deductions.push('-15: Content taller than screenshot cap — bottom cut off');
218
+ }
219
+ const geometryErrors = (metrics.regionGeometry && metrics.regionGeometry.errors) || [];
220
+ if (geometryErrors.length) {
221
+ const d = Math.min(geometryErrors.length * 20, 60);
222
+ score -= d;
223
+ deductions.push(`-${d}: ${geometryErrors.length} runtime-region geometry error(s)`);
224
+ }
225
+ const rating = geometryErrors.length ? 'poor' : (score >= 80 ? 'good' : score >= 50 ? 'acceptable' : 'poor');
226
+ return { score: Math.max(0, Math.min(100, score)), rating, deductions };
227
+ }
228
+
229
+ function launchOptions() {
230
+ const launchOpts = {
231
+ headless: true,
232
+ args: [
233
+ '--no-sandbox',
234
+ '--disable-setuid-sandbox',
235
+ '--disable-dev-shm-usage',
236
+ '--disable-gpu',
237
+ '--disable-web-security',
238
+ '--disable-crash-reporter',
239
+ ],
240
+ };
241
+ if (process.env.PUPPETEER_EXECUTABLE_PATH) {
242
+ launchOpts.executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
243
+ }
244
+ return launchOpts;
245
+ }
246
+
247
+ // Render one HTML file to PNG on an existing browser (one tab per call, so
248
+ // several renders can share a single warm browser — see render_all.js).
249
+ async function renderOnBrowser(browser, outputPath, htmlFile, opts = {}) {
250
+ const html = fs.readFileSync(htmlFile, 'utf8').trim();
251
+ if (!html) {
252
+ throw new Error('No HTML content in file');
253
+ }
254
+
255
+ const viewport = getViewportFromHtml(html);
256
+ const pageErrors = [];
257
+ const consoleMessages = [];
258
+ const failedResources = [];
259
+ const loadedResources = [];
260
+
261
+ let renderMetrics = {};
262
+
263
+ const blockedExternal = [];
264
+ const stageTimings = {};
265
+ let stageStart = Date.now();
266
+ const stage = (name) => { stageTimings[name] = Date.now() - stageStart; stageStart = Date.now(); };
267
+
268
+ const page = await browser.newPage();
269
+ stage('newPage');
270
+ try {
271
+ // Mockups are self-contained (CSS inlined, images as data URIs, icon
272
+ // fallbacks injected below) — block all external requests so renders
273
+ // are deterministic and never stall on unreachable CDNs/fonts.
274
+ await page.setRequestInterception(true);
275
+ page.on('request', req => {
276
+ const url = req.url();
277
+ if (/^(file|data|about|blob):/.test(url)) return req.continue();
278
+ blockedExternal.push(url);
279
+ req.abort('blockedbyclient');
280
+ });
281
+
282
+ page.on('pageerror', err => pageErrors.push(err.message));
283
+ page.on('console', msg => {
284
+ if (msg.text().includes('ERR_BLOCKED_BY_CLIENT')) return; // deliberate block, not a page defect
285
+ consoleMessages.push({ type: msg.type(), text: msg.text() });
286
+ if (msg.type() === 'error') pageErrors.push(msg.text());
287
+ });
288
+ page.on('requestfinished', req => loadedResources.push({ url: req.url(), resourceType: req.resourceType() }));
289
+ page.on('requestfailed', req => {
290
+ const reason = req.failure()?.errorText || 'Unknown';
291
+ if (reason === 'net::ERR_BLOCKED_BY_CLIENT') return; // deliberate block
292
+ failedResources.push({
293
+ url: req.url(),
294
+ resourceType: req.resourceType(),
295
+ reason,
296
+ });
297
+ });
298
+
299
+ await page.setViewport({ width: viewport.width, height: viewport.height, deviceScaleFactor: 2 });
300
+ stage('setViewport');
301
+
302
+ const fileUrl = `file://${path.resolve(htmlFile)}`;
303
+ await page.goto(fileUrl, { waitUntil: 'networkidle0', timeout: 15000 }).catch(() => {
304
+ console.warn('Network idle timeout, falling back to domcontentloaded');
305
+ return page.goto(fileUrl, { waitUntil: 'domcontentloaded', timeout: 5000 });
306
+ });
307
+ stage('goto');
308
+
309
+ // Glyph substitutes only when real icon fonts weren't embedded by
310
+ // inject_assets (marked via data-rtfm-icon-fonts on <html>).
311
+ const hasRealIconFonts = await page.evaluate(
312
+ () => document.documentElement.hasAttribute('data-rtfm-icon-fonts')
313
+ );
314
+ await page.addStyleTag({ content: BASE_FALLBACK_CSS });
315
+ if (!hasRealIconFonts) {
316
+ await page.addStyleTag({ content: ICON_FALLBACK_CSS });
317
+ }
318
+ stage('addStyleTag');
319
+
320
+ // Measure the true content extent: the max bottom/right edge across all
321
+ // visible elements. body.boundingBox() is wrong here — fixed/absolutely
322
+ // positioned elements (modal overlays, dropdowns) don't contribute to the
323
+ // body's in-flow height, so a mockup showing a modal taller than the page
324
+ // behind it would get cropped mid-modal.
325
+ const contentExtent = await page.evaluate(() => {
326
+ // Clip each element's rect against its overflow-hidden/clip ancestors:
327
+ // content inside a fixed-size frame (.device-screen, .desktop-window)
328
+ // is painted clipped, so it must not inflate the crop (an Electron
329
+ // renderer's #root{height:100vh} inside a 800px window would otherwise
330
+ // add a blank strip below the frame). position:fixed elements escape
331
+ // ordinary ancestor clipping per CSS and keep the modal-taller-than-
332
+ // page behaviour this measurement exists for — EXCEPT inside an
333
+ // ancestor with paint containment (.game-screen): containment makes
334
+ // that ancestor the containing block and clips its paint, so nothing
335
+ // escapes visually and the crop must not grow past it (a game's real
336
+ // fixed-position UI container is bigger than the display screen).
337
+ let maxBottom = 0;
338
+ let maxRight = 0;
339
+ for (const el of document.querySelectorAll('body, body *')) {
340
+ const style = window.getComputedStyle(el);
341
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
342
+ let rect = el.getBoundingClientRect();
343
+ if (rect.width === 0 && rect.height === 0) continue;
344
+ let { bottom, right } = rect;
345
+ const isFixed = style.position === 'fixed';
346
+ for (let a = el.parentElement; a && a !== document.documentElement; a = a.parentElement) {
347
+ const as = window.getComputedStyle(a);
348
+ if (as.display === 'none' || as.visibility === 'hidden' || parseFloat(as.opacity || '1') === 0) { bottom = top; break; }
349
+ const clips = /(hidden|clip)/.test(as.overflow + as.overflowX + as.overflowY);
350
+ const containsPaint = /(paint|strict|content)/.test(as.contain || '');
351
+ if ((clips && !isFixed) || containsPaint) {
352
+ const ar = a.getBoundingClientRect();
353
+ bottom = Math.min(bottom, ar.bottom);
354
+ right = Math.min(right, ar.right);
355
+ }
356
+ }
357
+ maxBottom = Math.max(maxBottom, bottom + window.scrollY);
358
+ maxRight = Math.max(maxRight, right + window.scrollX);
359
+ }
360
+ return { maxBottom, maxRight };
361
+ });
362
+ stage('contentExtent');
363
+
364
+ renderMetrics = await page.evaluate(() => {
365
+ const body = document.body;
366
+ const rect = body.getBoundingClientRect();
367
+ const computedStyle = window.getComputedStyle(body);
368
+ const visibleElements = document.querySelectorAll('div, p, h1, h2, h3, h4, h5, h6, span, button, input, form, table, ul, ol, li, img, a, nav, header, footer, section, article');
369
+ const textContent = body.innerText.trim();
370
+ const images = document.querySelectorAll('img');
371
+ const loadedImages = Array.from(images).filter(img => img.complete && img.naturalWidth > 0);
372
+ return {
373
+ width: rect.width,
374
+ height: rect.height,
375
+ hasChildren: body.children.length > 0,
376
+ childCount: body.children.length,
377
+ visibleElementCount: visibleElements.length,
378
+ hasTextContent: textContent.length > 0,
379
+ textLength: textContent.length,
380
+ backgroundColor: computedStyle.backgroundColor,
381
+ imageCount: images.length,
382
+ loadedImageCount: loadedImages.length,
383
+ };
384
+ });
385
+
386
+ renderMetrics.regionGeometry = await page.evaluate(measureRuntimeRegionGeometry);
387
+
388
+ renderMetrics.isLikelyBlank =
389
+ renderMetrics.width < 10 ||
390
+ renderMetrics.height < 10 ||
391
+ (!renderMetrics.hasChildren && !renderMetrics.hasTextContent) ||
392
+ (renderMetrics.visibleElementCount === 0 && renderMetrics.textLength === 0);
393
+
394
+ let screenshotWidth, screenshotHeight;
395
+ if (viewport.exact || viewport.viewportCrop) {
396
+ // The frame is the viewport. Content below the fold is cut, exactly
397
+ // as a browser screenshot would — record it so a step whose subject
398
+ // sits below the fold is diagnosable (the evidence-visibility gate
399
+ // below is what actually fails it).
400
+ screenshotWidth = viewport.width;
401
+ screenshotHeight = viewport.height;
402
+ const contentHeight = Math.ceil(contentExtent.maxBottom);
403
+ renderMetrics.contentHeight = contentHeight;
404
+ renderMetrics.isClipped = contentHeight > viewport.height;
405
+ if (renderMetrics.isClipped) {
406
+ console.warn(`Warning: content extends ${contentHeight - viewport.height}px below the ${viewport.height}px viewport — the screenshot shows the viewport only; depict the scrolled state if the step's subject sits below the fold`);
407
+ }
408
+ } else {
409
+ const MIN_HEIGHT = 300;
410
+ const MAX_HEIGHT = 2400;
411
+ const contentHeight = Math.ceil(contentExtent.maxBottom) + 48;
412
+ screenshotHeight = Math.min(Math.max(contentHeight, MIN_HEIGHT), MAX_HEIGHT);
413
+ screenshotWidth = Math.min(Math.ceil(contentExtent.maxRight) + 48, viewport.width);
414
+ renderMetrics.isClipped = contentHeight > MAX_HEIGHT;
415
+ if (renderMetrics.isClipped) {
416
+ console.warn(`Warning: content height ${contentHeight}px exceeds the ${MAX_HEIGHT}px cap — bottom of mockup will be cut off`);
417
+ }
418
+ }
419
+
420
+ stage('metrics');
421
+
422
+ // Evidence-visibility gate: the lint proves each step's verbatim
423
+ // evidence exists in the HTML TEXT; this proves it paints inside the
424
+ // CROPPED FRAME. The two can diverge silently — computed layout is only
425
+ // observable in the render (observed: a faithful Settings copy whose
426
+ // target section sat below the desktop window's overflow clip, and a
427
+ // bones-misassembled shell whose main pane rendered zero-height; both
428
+ // lint-green, both blank where it mattered). Same clip-ancestor logic
429
+ // as the content-extent measurement above; a string is visible if ANY
430
+ // occurrence paints ≥1px² inside the crop.
431
+ if (Array.isArray(opts.evidence) && opts.evidence.length) {
432
+ renderMetrics.evidenceVisibility = await measureStringVisibility(
433
+ page, opts.evidence, screenshotHeight, screenshotWidth);
434
+ stage('evidenceVisibility');
435
+ }
436
+ // Probe strings (shell nav/footer labels + this step's action targets):
437
+ // measured, NEVER gated. The lint's shell_nav_labels metric is text
438
+ // presence only — a sidebar copied faithfully from the source with a
439
+ // `hidden lg:flex` class pair can pass 4/4 and still paint 0 px
440
+ // (seen on a Next.js scheduling app). The polish phase reads this to ask "the
441
+ // shell is in your HTML but not in your screenshot".
442
+ if (Array.isArray(opts.probe) && opts.probe.length) {
443
+ renderMetrics.probeVisibility = await measureStringVisibility(
444
+ page, opts.probe, screenshotHeight, screenshotWidth);
445
+ stage('probeVisibility');
446
+ }
447
+
448
+ let outputHeight = screenshotHeight;
449
+ renderMetrics.watermark = isWatermarkEnabled();
450
+ if (renderMetrics.watermark) {
451
+ // Watermark band — composited in a SECOND screenshot pass, never
452
+ // injected into the mockup DOM. Injecting an absolutely-positioned
453
+ // element is not paint-safe: e.g. Framework7 sets body
454
+ // { position: relative; overflow-x: hidden }, which makes body both
455
+ // the containing block and a clip container, so a band placed below
456
+ // the content lands in body's scrollable overflow and never paints.
457
+ // Instead: screenshot the untouched content, then setContent a
458
+ // minimal composite page (block <img> + flow band below it) and
459
+ // screenshot that. The composite has no mockup CSS to fight, so it
460
+ // works identically across every app-type frame. The page viewport
461
+ // (dSF 2) still applies, and the img at logical size maps 1:1 to
462
+ // the source capture's physical pixels. For exact-frame presets
463
+ // (social) the frame must not grow, so the band overlays the
464
+ // bottom of the image instead of appending below it.
465
+ // viewportCrop frames (web) append the band like content-cropped
466
+ // renders do — the frame may grow; only truly exact frames overlay.
467
+ const overlay = !!viewport.exact;
468
+ // Same physical-pixel sizing rule as the walkthrough video strip,
469
+ // so a mobile PNG and a mobile video carry an identically-sized
470
+ // strip. PNGs always render at dSF 2 (setViewport above).
471
+ const { stripHeight, fontSize } = watermarkMetrics(viewport.height, 2);
472
+ const shot = await page.screenshot({
473
+ type: 'png',
474
+ encoding: 'base64',
475
+ clip: { x: 0, y: 0, width: screenshotWidth, height: screenshotHeight },
476
+ });
477
+ const bandCss =
478
+ (overlay ? 'position:absolute;left:0;bottom:0;' : '') +
479
+ 'display:flex;align-items:center;justify-content:center;box-sizing:border-box;' +
480
+ `width:${screenshotWidth}px;height:${stripHeight}px;margin:0;padding:0;` +
481
+ `background:${overlay ? 'rgba(15,23,42,0.82)' : '#0f172a'};` +
482
+ `color:rgba(255,255,255,0.92);font:500 ${fontSize}px/1 ${WATERMARK_FONT};letter-spacing:0.02em;`;
483
+ await page.setContent(
484
+ '<!doctype html><html><body style="margin:0;padding:0;position:relative">' +
485
+ `<img style="display:block;width:${screenshotWidth}px;height:${screenshotHeight}px" ` +
486
+ `src="data:image/png;base64,${shot}">` +
487
+ `<div style="${bandCss}">${WATERMARK_TEXT_SCREENSHOT}</div></body></html>`
488
+ );
489
+ if (!overlay) outputHeight += stripHeight;
490
+ }
491
+ await page.screenshot({
492
+ path: outputPath,
493
+ type: 'png',
494
+ clip: { x: 0, y: 0, width: screenshotWidth, height: outputHeight },
495
+ });
496
+ stage('screenshot');
497
+ console.log(`Stage timings for ${path.basename(outputPath)}: ${JSON.stringify(stageTimings)}`);
498
+
499
+ console.log(`Rendered ${path.basename(outputPath)}: ${screenshotWidth}x${outputHeight}px (viewport: ${viewport.width}x${viewport.height})`);
500
+
501
+ if (renderMetrics.isLikelyBlank) console.warn('Warning: Rendered image appears blank');
502
+ if (failedResources.length > 0) console.warn(`Warning: ${failedResources.length} resource(s) failed to load`);
503
+ } finally {
504
+ await page.close().catch(() => {});
505
+ }
506
+
507
+ const diagnostics = {
508
+ timestamp: new Date().toISOString(),
509
+ viewport,
510
+ metrics: renderMetrics,
511
+ pageErrors,
512
+ failedResources,
513
+ blockedExternalResources: blockedExternal,
514
+ stageTimings,
515
+ loadedResourceCount: loadedResources.length,
516
+ qualityScore: calculateQualityScore(renderMetrics, pageErrors, failedResources),
517
+ };
518
+
519
+ const diagnosticsPath = outputPath.replace(/\.png$/, '_diagnostics.json');
520
+ fs.writeFileSync(diagnosticsPath, JSON.stringify(diagnostics, null, 2));
521
+ return diagnostics;
522
+ }
523
+
524
+ // For each string: does its text, button value, or accessibly named control paint ≥1px² inside the
525
+ // cropped frame? Same clip-ancestor logic as the content-extent measurement.
526
+ // Shared by the evidence gate and the (ungated) probe.
527
+ async function measureStringVisibility(page, strings, cropBottom, cropRight) {
528
+ return page.evaluate((strings, cropBottom, cropRight) => {
529
+ const norm = s => String(s).replace(/\s+/g, ' ').trim();
530
+ const results = [];
531
+ const all = Array.from(document.querySelectorAll('body, body *'));
532
+ for (const str of strings) {
533
+ const needle = norm(str);
534
+ if (!needle) continue;
535
+ let visible = false;
536
+ let bestArea = 0;
537
+ let matchKind = null;
538
+ for (const el of all) {
539
+ const textMatch = norm(el.textContent || '').includes(needle);
540
+ const valueMatch = el.matches('input[type=submit], input[type=button], input[type=reset]')
541
+ && norm(el.value) === needle;
542
+ const control = el.matches('button, a[href], a[data-rtfm-action-target], a[data-bs-toggle], a[data-toggle], input:not([type=hidden]), select, textarea, [role=button], [role=link], [role=checkbox], [role=switch], [role=menuitem], [role=combobox], [role=tab]');
543
+ const labelledBy = (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean)
544
+ .map(id => document.getElementById(id)?.textContent || '').join(' ');
545
+ const accessibleName = norm(labelledBy) || norm(el.getAttribute('aria-label') || '');
546
+ const namedControl = control && accessibleName === needle;
547
+ if (!textMatch && !valueMatch && !namedControl) continue;
548
+ // deepest match only — a wrapper containing the string via a
549
+ // child doesn't paint the text itself
550
+ let deepest = true;
551
+ if (!valueMatch && !namedControl) for (const c of el.children) {
552
+ if (norm(c.textContent || '').includes(needle)) { deepest = false; break; }
553
+ }
554
+ if (!deepest) continue;
555
+ const style = window.getComputedStyle(el);
556
+ if (style.display === 'none' || style.visibility === 'hidden'
557
+ || parseFloat(style.opacity || '1') === 0) continue;
558
+ const rect = el.getBoundingClientRect();
559
+ if (rect.width === 0 || rect.height === 0) continue;
560
+ let { top, left, bottom, right } = rect;
561
+ const isFixed = style.position === 'fixed';
562
+ for (let a = el.parentElement; a && a !== document.documentElement; a = a.parentElement) {
563
+ const as = window.getComputedStyle(a);
564
+ if (as.display === 'none' || as.visibility === 'hidden' || parseFloat(as.opacity || '1') === 0) { bottom = top; break; }
565
+ const clips = /(hidden|clip)/.test(as.overflow + as.overflowX + as.overflowY);
566
+ const containsPaint = /(paint|strict|content)/.test(as.contain || '');
567
+ if ((clips && !isFixed) || containsPaint) {
568
+ const ar = a.getBoundingClientRect();
569
+ top = Math.max(top, ar.top);
570
+ left = Math.max(left, ar.left);
571
+ bottom = Math.min(bottom, ar.bottom);
572
+ right = Math.min(right, ar.right);
573
+ }
574
+ }
575
+ top = Math.max(top + window.scrollY, 0);
576
+ left = Math.max(left + window.scrollX, 0);
577
+ bottom = Math.min(bottom + window.scrollY, cropBottom);
578
+ right = Math.min(right + window.scrollX, cropRight);
579
+ const area = Math.max(0, bottom - top) * Math.max(0, right - left);
580
+ bestArea = Math.max(bestArea, area);
581
+ if (area >= 1) { visible = true; matchKind = valueMatch ? 'input_value' : namedControl ? 'accessible_control' : 'text'; break; }
582
+ }
583
+ results.push({ string: needle, visible, visible_px: Math.round(bestArea), match_kind: matchKind });
584
+ }
585
+ return results;
586
+ }, strings, cropBottom, cropRight);
587
+ }
588
+
589
+ // The probe string set for one step: the app shell's nav/footer labels (with
590
+ // children) plus this step's action_coverage targets, minus strings already in
591
+ // the evidence set (measured separately) and anything under 3 chars. Reads the
592
+ // project map from <projectRoot>/.rtfm (or the legacy .rtfm-branding). Returns
593
+ // [] when there is no map or no shell — the probe is purely additive.
594
+ function probeStringsFor(projectRoot, viewSources, blockKey, evidence) {
595
+ const labels = new Set();
596
+ const add = v => { if (typeof v === 'string' && v.trim().length >= 3) labels.add(v.trim()); };
597
+ for (const dir of ['.rtfm', '.rtfm-branding']) {
598
+ try {
599
+ const pm = JSON.parse(fs.readFileSync(path.join(projectRoot, dir, 'project_map.json'), 'utf8'));
600
+ const shell = pm.app_shell || {};
601
+ const walk = items => {
602
+ for (const it of Array.isArray(items) ? items : []) {
603
+ if (!it) continue;
604
+ add(typeof it === 'string' ? it : it.label);
605
+ if (it && Array.isArray(it.children)) walk(it.children);
606
+ }
607
+ };
608
+ walk(shell.nav_items);
609
+ walk(shell.footer_items);
610
+ break;
611
+ } catch { /* try the next dir */ }
612
+ }
613
+ // action_coverage keys screenshots by block id (v1.43+) or by step index
614
+ // (v1.41–42); the block entry itself carries both, so resolve through it.
615
+ const vs = viewSources || {};
616
+ const entries = vs.blocks || vs.steps || [];
617
+ const block = entries.find(b => b && (String(b.block_id) === String(blockKey)
618
+ || (b.block_id === undefined && String(b.index) === String(blockKey)))) || null;
619
+ const keys = new Set([String(blockKey)]);
620
+ if (block) {
621
+ if (block.block_id !== undefined) keys.add(String(block.block_id));
622
+ if (block.index !== undefined) keys.add(String(block.index));
623
+ }
624
+ for (const entry of Array.isArray(vs.action_coverage) ? vs.action_coverage : []) {
625
+ if (!entry) continue;
626
+ const key = entry.screenshot_block_id ?? entry.screenshot_step_index;
627
+ if (key !== undefined && keys.has(String(key))) add(entry.target);
628
+ }
629
+ const seen = new Set((evidence || []).map(s => String(s).replace(/\s+/g, ' ').trim()));
630
+ return [...labels].filter(s => !seen.has(s.replace(/\s+/g, ' ').trim()));
631
+ }
632
+
633
+ // Single-file CLI mode: launch a browser, render, close. render_all.js
634
+ // requires this module instead and shares one browser across all steps.
635
+ async function renderMockup(outputPath, htmlFile) {
636
+ const browser = await puppeteer.launch(launchOptions());
637
+ try {
638
+ return await renderOnBrowser(browser, outputPath, htmlFile);
639
+ } finally {
640
+ await browser.close();
641
+ }
642
+ }
643
+
644
+ module.exports = { puppeteer, launchOptions, renderOnBrowser, measureStringVisibility, probeStringsFor };
645
+
646
+ if (require.main === module) {
647
+ const outputPath = process.argv[2];
648
+ const htmlFile = process.argv[3];
649
+
650
+ if (!outputPath || !htmlFile) {
651
+ console.error('Usage: render_mockup.js <output_png> <html_file>');
652
+ process.exit(1);
653
+ }
654
+
655
+ renderMockup(outputPath, htmlFile)
656
+ .then(diag => {
657
+ if (diag.qualityScore.rating === 'poor') {
658
+ console.error(`Warning: Render quality is poor (score: ${diag.qualityScore.score})`);
659
+ }
660
+ })
661
+ .catch(err => {
662
+ console.error('Error rendering mockup:', err.message);
663
+ process.exit(1);
664
+ });
665
+ }