@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.
- package/LICENSE +201 -0
- package/NOTICE +23 -0
- package/README.md +392 -2
- package/dist/actions.d.ts +98 -0
- package/dist/actions.js +86 -0
- package/dist/actions.js.map +1 -0
- package/dist/agent-settings.d.ts +22 -0
- package/dist/agent-settings.js +35 -0
- package/dist/agent-settings.js.map +1 -0
- package/dist/api.d.ts +14 -0
- package/dist/api.js +159 -0
- package/dist/api.js.map +1 -0
- package/dist/article-link.d.ts +16 -0
- package/dist/article-link.js +25 -0
- package/dist/article-link.js.map +1 -0
- package/dist/artifacts.d.ts +45 -0
- package/dist/artifacts.js +144 -0
- package/dist/artifacts.js.map +1 -0
- package/dist/brand.d.ts +4 -0
- package/dist/brand.js +9 -0
- package/dist/brand.js.map +1 -0
- package/dist/bridge.d.ts +2964 -0
- package/dist/bridge.js +1036 -0
- package/dist/bridge.js.map +1 -0
- package/dist/capacity.d.ts +5 -0
- package/dist/capacity.js +11 -0
- package/dist/capacity.js.map +1 -0
- package/dist/credentials.d.ts +13 -0
- package/dist/credentials.js +88 -0
- package/dist/credentials.js.map +1 -0
- package/dist/development-tls.d.ts +8 -0
- package/dist/development-tls.js +38 -0
- package/dist/development-tls.js.map +1 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +18 -0
- package/dist/errors.js.map +1 -0
- package/dist/export.d.ts +11 -0
- package/dist/export.js +91 -0
- package/dist/export.js.map +1 -0
- package/dist/hosted-operations.d.ts +108 -0
- package/dist/hosted-operations.js +120 -0
- package/dist/hosted-operations.js.map +1 -0
- package/dist/hosting-benefits.d.ts +66 -0
- package/dist/hosting-benefits.js +67 -0
- package/dist/hosting-benefits.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/local-inventory.d.ts +22 -0
- package/dist/local-inventory.js +59 -0
- package/dist/local-inventory.js.map +1 -0
- package/dist/local-setup.d.ts +208 -0
- package/dist/local-setup.js +140 -0
- package/dist/local-setup.js.map +1 -0
- package/dist/pairing.d.ts +71 -0
- package/dist/pairing.js +235 -0
- package/dist/pairing.js.map +1 -0
- package/dist/preferences.d.ts +18 -0
- package/dist/preferences.js +44 -0
- package/dist/preferences.js.map +1 -0
- package/dist/progress.d.ts +205 -0
- package/dist/progress.js +224 -0
- package/dist/progress.js.map +1 -0
- package/dist/reminders.d.ts +31 -0
- package/dist/reminders.js +73 -0
- package/dist/reminders.js.map +1 -0
- package/dist/replace-connection.d.ts +6 -0
- package/dist/replace-connection.js +77 -0
- package/dist/replace-connection.js.map +1 -0
- package/dist/repository-actions.d.ts +18 -0
- package/dist/repository-actions.js +9 -0
- package/dist/repository-actions.js.map +1 -0
- package/dist/repository-benefits.d.ts +96 -0
- package/dist/repository-benefits.js +64 -0
- package/dist/repository-benefits.js.map +1 -0
- package/dist/run-update.d.ts +17 -0
- package/dist/run-update.js +27 -0
- package/dist/run-update.js.map +1 -0
- package/dist/runs.d.ts +541 -0
- package/dist/runs.js +146 -0
- package/dist/runs.js.map +1 -0
- package/dist/runtime.d.ts +12 -0
- package/dist/runtime.js +58 -0
- package/dist/runtime.js.map +1 -0
- package/dist/schema.d.ts +277 -0
- package/dist/schema.js +66 -0
- package/dist/schema.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +298 -0
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +1714 -0
- package/dist/session.js +619 -0
- package/dist/session.js.map +1 -0
- package/dist/settings.d.ts +9 -0
- package/dist/settings.js +21 -0
- package/dist/settings.js.map +1 -0
- package/dist/sync.d.ts +495 -0
- package/dist/sync.js +191 -0
- package/dist/sync.js.map +1 -0
- package/dist/telemetry-scrub.d.ts +13 -0
- package/dist/telemetry-scrub.js +51 -0
- package/dist/telemetry-scrub.js.map +1 -0
- package/dist/telemetry.d.ts +73 -0
- package/dist/telemetry.js +173 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/walkthroughs.d.ts +224 -0
- package/dist/walkthroughs.js +109 -0
- package/dist/walkthroughs.js.map +1 -0
- package/dist/workspace.d.ts +14 -0
- package/dist/workspace.js +127 -0
- package/dist/workspace.js.map +1 -0
- package/dist/writer-agent.d.ts +21 -0
- package/dist/writer-agent.js +27 -0
- package/dist/writer-agent.js.map +1 -0
- package/dist/writer-entry.d.ts +171 -0
- package/dist/writer-entry.js +233 -0
- package/dist/writer-entry.js.map +1 -0
- package/dist/writing-style.d.ts +7 -0
- package/dist/writing-style.js +52 -0
- package/dist/writing-style.js.map +1 -0
- package/engine/SYNC.json +4 -0
- package/engine/VERSION +1 -0
- package/engine/detect-project/README.md +141 -0
- package/engine/detect-project/SKILL.md +1421 -0
- package/engine/detect-project/assets/desktop/desktop-frame.css +428 -0
- package/engine/detect-project/assets/game/game-frame.css +132 -0
- package/engine/detect-project/assets/macosui/LICENSE-puppertino.txt +21 -0
- package/engine/detect-project/assets/macosui/VERSIONS.txt +1 -0
- package/engine/detect-project/assets/macosui/fonts.css +15 -0
- package/engine/detect-project/assets/macosui/macos-frame.css +481 -0
- package/engine/detect-project/assets/macosui/puppertino.css +2153 -0
- package/engine/detect-project/assets/mobileui/LICENSE-fonts.txt +13 -0
- package/engine/detect-project/assets/mobileui/LICENSE-framework7.txt +52 -0
- package/engine/detect-project/assets/mobileui/VERSIONS.txt +6 -0
- package/engine/detect-project/assets/mobileui/device-frame.css +316 -0
- package/engine/detect-project/assets/mobileui/f7-color-theme.mjs +1345 -0
- package/engine/detect-project/assets/mobileui/f7-icons-names.json +1254 -0
- package/engine/detect-project/assets/mobileui/fonts.css +16 -0
- package/engine/detect-project/assets/mobileui/framework7-components.css +39 -0
- package/engine/detect-project/assets/mobileui/framework7-core.css +5245 -0
- package/engine/detect-project/assets/mobileui/icons.css +31 -0
- package/engine/detect-project/assets/mobileui/md3-defaults.css +89 -0
- package/engine/detect-project/assets/mobileui/platforms.json +46 -0
- package/engine/detect-project/assets/tailwind-fallback.css +1729 -0
- package/engine/detect-project/assets/webtui/LICENSE-webtui.txt +28 -0
- package/engine/detect-project/assets/webtui/VERSIONS.txt +7 -0
- package/engine/detect-project/assets/webtui/terminal-frame.css +195 -0
- package/engine/detect-project/assets/webtui/theme-catppuccin.css +1 -0
- package/engine/detect-project/assets/webtui/theme-everforest.css +1 -0
- package/engine/detect-project/assets/webtui/theme-gruvbox.css +1 -0
- package/engine/detect-project/assets/webtui/theme-nord.css +1 -0
- package/engine/detect-project/assets/webtui/theme-vitesse.css +1 -0
- package/engine/detect-project/assets/webtui/themes.json +37 -0
- package/engine/detect-project/assets/webtui/webtui-core.css +1 -0
- package/engine/detect-project/assets/win32ui/7css.css +2 -0
- package/engine/detect-project/assets/win32ui/LICENSE-7css.txt +21 -0
- package/engine/detect-project/assets/win32ui/VERSIONS.txt +1 -0
- package/engine/detect-project/assets/win32ui/win32-frame.css +278 -0
- package/engine/detect-project/package-lock.json +12 -0
- package/engine/detect-project/package.json +10 -0
- package/engine/detect-project/scripts/apply_runtime_profiles.js +313 -0
- package/engine/detect-project/scripts/check_css_health.js +412 -0
- package/engine/detect-project/scripts/check_project_map.js +150 -0
- package/engine/detect-project/scripts/check_runtime_coverage.js +311 -0
- package/engine/detect-project/scripts/check_runtime_recipe_quality.js +184 -0
- package/engine/detect-project/scripts/classify_app_type.sh +246 -0
- package/engine/detect-project/scripts/classify_surface.sh +95 -0
- package/engine/detect-project/scripts/classify_workspace.js +39 -0
- package/engine/detect-project/scripts/compile_css.sh +447 -0
- package/engine/detect-project/scripts/detect_static.js +645 -0
- package/engine/detect-project/scripts/detect_structure.js +187 -0
- package/engine/detect-project/scripts/include_census.js +451 -0
- package/engine/detect-project/scripts/json_get.js +142 -0
- package/engine/detect-project/scripts/merge_json.js +52 -0
- package/engine/detect-project/scripts/recommend_model_tier.js +252 -0
- package/engine/detect-project/scripts/resolve_route_chains.js +135 -0
- package/engine/detect-project/scripts/run_css_build.sh +40 -0
- package/engine/detect-project/scripts/sanitize_css.js +83 -0
- package/engine/detect-project/scripts/test_classify_surface.js +108 -0
- package/engine/detect-project/scripts/test_node_helpers.js +142 -0
- package/engine/detect-project/scripts/test_recommend_model_tier.js +119 -0
- package/engine/detect-project/scripts/test_resolve_route_chains.js +182 -0
- package/engine/detect-project/scripts/test_runtime_coverage.js +323 -0
- package/engine/detect-project/scripts/test_runtime_recipe_quality.js +187 -0
- package/engine/detect-project/scripts/theme_overrides.js +169 -0
- package/engine/detect-project/scripts/write_branding.js +129 -0
- package/engine/generate-illustrated-article/SKILL.md +461 -0
- package/engine/generate-illustrated-article/contracts/desktop.md +90 -0
- package/engine/generate-illustrated-article/contracts/game.md +38 -0
- package/engine/generate-illustrated-article/contracts/label-evidence.md +38 -0
- package/engine/generate-illustrated-article/contracts/macos.md +79 -0
- package/engine/generate-illustrated-article/contracts/mobile.md +40 -0
- package/engine/generate-illustrated-article/contracts/terminal.md +29 -0
- package/engine/generate-illustrated-article/contracts/win32.md +43 -0
- package/engine/generate-illustrated-article/package-lock.json +366 -0
- package/engine/generate-illustrated-article/package.json +15 -0
- package/engine/generate-illustrated-article/scripts/article_blocks.js +34 -0
- package/engine/generate-illustrated-article/scripts/check_article_json.js +105 -0
- package/engine/generate-illustrated-article/scripts/emit_walkthrough_signals.js +100 -0
- package/engine/generate-illustrated-article/scripts/extract_images.js +187 -0
- package/engine/generate-illustrated-article/scripts/generate_content_images.js +376 -0
- package/engine/generate-illustrated-article/scripts/include_census.js +451 -0
- package/engine/generate-illustrated-article/scripts/inject_assets.js +1691 -0
- package/engine/generate-illustrated-article/scripts/jit_mockup_css.js +191 -0
- package/engine/generate-illustrated-article/scripts/label_evidence.js +87 -0
- package/engine/generate-illustrated-article/scripts/lint_article_copy.js +252 -0
- package/engine/generate-illustrated-article/scripts/lint_mockup_fidelity.js +2403 -0
- package/engine/generate-illustrated-article/scripts/polish_tickets.js +1148 -0
- package/engine/generate-illustrated-article/scripts/related_repos.sh +52 -0
- package/engine/generate-illustrated-article/scripts/render_all.js +177 -0
- package/engine/generate-illustrated-article/scripts/render_mockup.js +665 -0
- package/engine/generate-illustrated-article/scripts/render_ready.js +164 -0
- package/engine/generate-illustrated-article/scripts/resolve_workspace.sh +86 -0
- package/engine/generate-illustrated-article/scripts/runtime_region_geometry.js +55 -0
- package/engine/generate-illustrated-article/scripts/source_paths.js +49 -0
- package/engine/generate-illustrated-article/scripts/test_control_visibility.js +30 -0
- package/engine/generate-illustrated-article/scripts/test_emit_walkthrough_signals.js +157 -0
- package/engine/generate-illustrated-article/scripts/test_generate_content_images.js +168 -0
- package/engine/generate-illustrated-article/scripts/test_include_census.js +126 -0
- package/engine/generate-illustrated-article/scripts/test_label_evidence.js +49 -0
- package/engine/generate-illustrated-article/scripts/test_lint_article_copy.js +138 -0
- package/engine/generate-illustrated-article/scripts/test_lint_mockup_fidelity.js +653 -0
- package/engine/generate-illustrated-article/scripts/test_node_ports.js +152 -0
- package/engine/generate-illustrated-article/scripts/test_polish_tickets.js +481 -0
- package/engine/generate-illustrated-article/scripts/test_related_repos.js +64 -0
- package/engine/generate-illustrated-article/scripts/test_render_ready.js +85 -0
- package/engine/generate-illustrated-article/scripts/test_source_paths.js +68 -0
- package/engine/generate-illustrated-article/scripts/trace_hook.js +79 -0
- package/engine/generate-illustrated-article/scripts/trace_hook.sh +4 -0
- package/engine/generate-illustrated-article/scripts/validate_html.js +114 -0
- package/engine/generate-illustrated-article/scripts/watermark.js +69 -0
- package/install.sh +14 -0
- package/package.json +57 -4
- package/scripts/auth.mjs +33 -0
- package/scripts/auto-update.mjs +7 -0
- package/scripts/build-plugin.mjs +58 -0
- package/scripts/build-release.mjs +94 -0
- package/scripts/check-release.mjs +40 -0
- package/scripts/check-runtime.mjs +10 -0
- package/scripts/cli.mjs +8 -0
- package/scripts/install.mjs +64 -0
- package/scripts/lib/agent-runner.mjs +181 -0
- package/scripts/lib/agent-settings.mjs +215 -0
- package/scripts/lib/article-skills.mjs +82 -0
- package/scripts/lib/auto-update.mjs +50 -0
- package/scripts/lib/brand.mjs +9 -0
- package/scripts/lib/browser.mjs +12 -0
- package/scripts/lib/claude-connection.mjs +33 -0
- package/scripts/lib/claude-permissions.mjs +47 -0
- package/scripts/lib/claude-plugin.mjs +15 -0
- package/scripts/lib/claude-writer.mjs +10 -0
- package/scripts/lib/cli-main.mjs +85 -0
- package/scripts/lib/cli.mjs +888 -0
- package/scripts/lib/codex-config.mjs +51 -0
- package/scripts/lib/codex-integration.mjs +61 -0
- package/scripts/lib/codex-skill.mjs +34 -0
- package/scripts/lib/harness-models.mjs +109 -0
- package/scripts/lib/install.mjs +269 -0
- package/scripts/lib/managed-writer.mjs +38 -0
- package/scripts/lib/planning.mjs +263 -0
- package/scripts/lib/prepare-update.mjs +85 -0
- package/scripts/lib/refresh-writers.mjs +12 -0
- package/scripts/lib/remove.mjs +167 -0
- package/scripts/lib/renderer.mjs +34 -0
- package/scripts/lib/terminal.mjs +252 -0
- package/scripts/lib/uninit.mjs +72 -0
- package/scripts/lib/update.mjs +55 -0
- package/scripts/lib/writer-recovery.mjs +35 -0
- package/scripts/lib/yolo.mjs +362 -0
- package/scripts/plugin-session.mjs +65 -0
- package/scripts/prepare-update.mjs +12 -0
- package/scripts/publish-release.mjs +92 -0
- package/scripts/skills/supportpages/SKILL.md +120 -0
- package/scripts/smoke-release.mjs +101 -0
- 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
|
+
}
|