@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,1691 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /*
5
+ * inject_assets.js — Post-process generated mockup HTML files.
6
+ *
7
+ * Shared by generate-illustrated-article (no --walkthrough) and generate-walkthrough
8
+ * (--walkthrough). generate-walkthrough/scripts/inject_assets.js is a symlink to this
9
+ * file; the two skills differ only by the --walkthrough flag (see below). Keep both
10
+ * modes working when editing.
11
+ *
12
+ * For each step_*.html / block_*.html in <html_dir>:
13
+ * - Replace <!-- INJECT_CSS --> with framework CDN / compiled CSS / Tailwind
14
+ * fallback (see "CSS source resolution" below). Injection is idempotent — a
15
+ * RTFM_CSS_INJECTED sentinel lets STEP-4 lint-retry re-runs skip re-injecting.
16
+ * - Render-time JIT: when project_map.json has a `css_build` recipe, compile the
17
+ * mockups' own classes into <html_dir>/mockup.css (jit_mockup_css.js) and link
18
+ * it in place of the flaky Play CDN. Skips cleanly with no recipe. Runs in BOTH
19
+ * modes, so walkthroughs get the same JIT fidelity as articles.
20
+ * - Replace {{img:filename}} tokens with project image data URIs from <images_json>.
21
+ * - Replace {{generated:id}} tokens with generated-image files declared by the
22
+ * optional --generated-images manifest.
23
+ *
24
+ * With --walkthrough <actions_json>, also inject:
25
+ * - A zoomable stage wrapper (<div data-walkthrough-stage>) around the mockup.
26
+ * - Polish CSS: target glow, click ripple, page-transition fade overlay.
27
+ * - Auto-generates step_intro.html / step_outro.html title cards from
28
+ * article.json (skipping the stage wrapper on those pages).
29
+ *
30
+ * The step text is carried by the narration + subtitles/transcript, so no
31
+ * on-screen caption chrome is injected.
32
+ *
33
+ * With --branding <branding_json>, build a richer head block layered as:
34
+ * 1. App's compiled CSS (if branding.compiled_css_path is set) — highest fidelity
35
+ * 2. Framework CDN (Bootstrap / Bulma / Foundation / Tailwind) if no compiled CSS
36
+ * 3. Tailwind runtime config override (if framework is tailwind + extend present)
37
+ * 4. Bare Tailwind CDN fallback for tailwind/none/custom frameworks
38
+ * 5. Google Fonts <link>s from branding.google_fonts
39
+ * 6. External stylesheets from branding.external_stylesheets
40
+ * 7. <style data-walkthrough-branding> with :root vars + default colours + CSS-var overrides
41
+ *
42
+ * CSS source resolution (without --branding):
43
+ * - If <css_file_or_empty> is a non-empty file → wrap in <style>
44
+ * - Otherwise → Tailwind CDN
45
+ *
46
+ * Usage:
47
+ * node inject_assets.js <html_dir> <css_file_or_empty> <images_json> \
48
+ * [--walkthrough <actions_json>] [--branding <branding_json>]
49
+ * [--generated-images <generated_images_json>] [--refresh-jit]
50
+ */
51
+
52
+ const fs = require('fs');
53
+ const path = require('path');
54
+ const crypto = require('crypto');
55
+ const { spawnSync } = require('child_process');
56
+
57
+ const TAILWIND_CDN = '<script src="https://cdn.tailwindcss.com"></script>';
58
+ // Sentinel marking a mockup whose <!-- INJECT_CSS --> block has already been
59
+ // replaced. STEP 4's lint-retry re-runs processDir on already-injected HTML;
60
+ // the marker lets us skip re-injecting so the block never stacks 2nd/3rd time.
61
+ const INJECTED_SENTINEL = '<!-- RTFM_CSS_INJECTED -->';
62
+
63
+ // All walkthrough-specific styling lives in one block so it can be injected
64
+ // once per page. Includes: page-transition overlay (bidirectional —
65
+ // class-driven), pre-click target glow, click ripple effect, title-card
66
+ // layout. Branding CSS vars (--brand-primary etc.) are produced earlier by
67
+ // buildCssHeadBlock and referenced here with safe fallbacks.
68
+ const WALKTHROUGH_STYLE = [
69
+ '<style data-walkthrough-polish="1">',
70
+ // ── Page-transition overlay ───────────────────────────────────────
71
+ // body::before sits below caption (9999) and cursor (10000) but above
72
+ // mockup content, giving us a fade-in on page load and a fade-out
73
+ // before navigation. Class-driven so the recorder can run it in
74
+ // either direction. The fallback colour is white when no brand
75
+ // colour is set; with a brand colour it becomes a soft brand wash.
76
+ 'body::before{',
77
+ "content:'';",
78
+ 'position:fixed;top:0;left:0;right:0;bottom:0;',
79
+ 'background:color-mix(in srgb,var(--brand-primary,#ffffff) 22%,#ffffff 78%);',
80
+ 'z-index:9997;pointer-events:none;',
81
+ 'opacity:1;',
82
+ 'transition:opacity 520ms ease-out;',
83
+ '}',
84
+ 'html.walkthrough-loaded body::before{opacity:0;}',
85
+ 'html[data-walkthrough-transition="fading-out"] body::before{',
86
+ 'opacity:1;transition:opacity 220ms ease-in;',
87
+ '}',
88
+ // The target highlight ring, click ripple and cursor are drawn by the
89
+ // recorder as overlays (generate-walkthrough/scripts/interaction_fx.js),
90
+ // never as styles on the mockup's own elements.
91
+ // ── Title-card layout ─────────────────────────────────────────────
92
+ // Cards come in a light and a dark theme, picked per project by the
93
+ // logo's ink luminance (dark logo -> light card and vice versa, see
94
+ // writeTitleCards). When a card_bg image is present it covers the
95
+ // flat theme background, so the theme instead follows the image's
96
+ // measured luminance, and a logo whose ink would blend into the image
97
+ // gets a contrast plate (.walkthrough-card-logo--plate-*). Both themes
98
+ // layer soft brand-tinted radial glows and a faint dot grid over a
99
+ // flat base so the card has depth without AI background art.
100
+ // --walkthrough-card-font carries the project's first Google Font when
101
+ // branding provides one.
102
+ // position:relative + overflow:hidden lets the (optional) bg div fill
103
+ // the viewport while still allowing transform:scale to grow past the
104
+ // natural edges without leaking outside the frame.
105
+ 'body[data-walkthrough-card]{',
106
+ 'margin:0;width:100vw;height:100vh;min-height:100vh;',
107
+ 'display:flex;align-items:center;justify-content:center;',
108
+ 'background-image:',
109
+ 'radial-gradient(color-mix(in srgb,var(--brand-primary,#64748b) 14%,transparent) 1px,transparent 1.5px),',
110
+ 'radial-gradient(56rem 38rem at 14% 18%,color-mix(in srgb,var(--brand-primary,#6366f1) 16%,transparent),transparent 70%),',
111
+ 'radial-gradient(64rem 44rem at 86% 84%,color-mix(in srgb,var(--brand-accent,var(--brand-primary,#6366f1)) 12%,transparent),transparent 70%);',
112
+ 'background-size:26px 26px,auto,auto;',
113
+ 'background-color:#fff;',
114
+ "font-family:var(--walkthrough-card-font,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif);",
115
+ 'color:#0f172a;',
116
+ 'position:relative;overflow:hidden;',
117
+ '}',
118
+ 'body[data-walkthrough-card][data-card-theme="dark"]{',
119
+ 'background-image:',
120
+ 'radial-gradient(rgba(255,255,255,0.07) 1px,transparent 1.5px),',
121
+ 'radial-gradient(56rem 38rem at 14% 18%,color-mix(in srgb,var(--brand-primary,#6366f1) 34%,transparent),transparent 70%),',
122
+ 'radial-gradient(64rem 44rem at 86% 84%,color-mix(in srgb,var(--brand-accent,var(--brand-primary,#6366f1)) 22%,transparent),transparent 70%);',
123
+ 'background-size:26px 26px,auto,auto;',
124
+ 'background-color:#0b1120;',
125
+ 'color:#fff;',
126
+ '}',
127
+ 'body[data-walkthrough-card] .walkthrough-card-bg{',
128
+ 'position:absolute;inset:0;',
129
+ 'background-size:cover;background-position:center;background-repeat:no-repeat;',
130
+ 'z-index:0;transform-origin:center center;',
131
+ // Animation: slow zoom from 1.0 to 1.06 over 6s. Ease-out so most of
132
+ // the motion happens in the first 2s while the eye is still settling
133
+ // on the card. forwards keeps the final scale stuck so the card
134
+ // doesn't snap back on long holds.
135
+ 'animation:walkthrough-card-kenburns 6000ms ease-out 0ms forwards;',
136
+ 'will-change:transform;',
137
+ '}',
138
+ 'body[data-walkthrough-card] .walkthrough-card{',
139
+ 'text-align:center;max-width:80%;padding:0 24px;',
140
+ 'position:relative;z-index:1;',
141
+ '}',
142
+ 'body[data-walkthrough-card] .walkthrough-card-logo{',
143
+ 'max-height:84px;max-width:260px;margin:0 auto 28px;',
144
+ 'object-fit:contain;display:block;',
145
+ // Entrance: scale 0.92 -> 1.0 + opacity 0 -> 1 over 800ms starting
146
+ // at 200ms after the card mounts. Cubic-bezier matches the brand
147
+ // polish used elsewhere (data-walkthrough-stage zoom curve).
148
+ 'opacity:0;transform:scale(0.92);',
149
+ 'animation:walkthrough-card-logo-in 800ms cubic-bezier(0.2,0.8,0.2,1) 200ms forwards;',
150
+ 'will-change:opacity,transform;',
151
+ '}',
152
+ // Contrast plate behind the logo, applied by writeTitleCards when the
153
+ // logo's ink luminance sits too close to the card_bg image behind it
154
+ // (green logo on a green gradient). The image shows through the logo's
155
+ // transparent pixels, so the plate is just padding + a translucent fill
156
+ // on the <img> itself. content-box keeps the 84px cap on the artwork
157
+ // even when project CSS resets everything to border-box.
158
+ 'body[data-walkthrough-card] .walkthrough-card-logo--plate-light,',
159
+ 'body[data-walkthrough-card] .walkthrough-card-logo--plate-dark{',
160
+ 'box-sizing:content-box;padding:16px 26px;border-radius:18px;',
161
+ '}',
162
+ 'body[data-walkthrough-card] .walkthrough-card-logo--plate-light{',
163
+ 'background:rgba(255,255,255,0.92);',
164
+ 'box-shadow:0 10px 34px rgba(2,6,23,0.22);',
165
+ '}',
166
+ 'body[data-walkthrough-card] .walkthrough-card-logo--plate-dark{',
167
+ 'background:rgba(11,17,32,0.80);',
168
+ 'box-shadow:0 10px 34px rgba(2,6,23,0.35);',
169
+ '}',
170
+ // Explicit colour on title + tagline so project-injected CSS
171
+ // (e.g. Bootstrap `h1 { color: ... }`) can't override via direct
172
+ // type-selector specificity. Body's inherited colour loses to a direct
173
+ // h1 colour declaration; setting it here at (0,2,1) specificity wins.
174
+ 'body[data-walkthrough-card] .walkthrough-card-title{',
175
+ 'color:#0f172a;',
176
+ 'font-size:48px;font-weight:700;margin:0 0 16px;',
177
+ 'letter-spacing:-0.02em;line-height:1.1;',
178
+ '}',
179
+ 'body[data-walkthrough-card][data-card-theme="dark"] .walkthrough-card-title{',
180
+ 'color:#fff;',
181
+ '}',
182
+ // Title text is wrapped in an inline-block span so clip-path can mask
183
+ // it left-to-right without disrupting the surrounding block layout.
184
+ // The wrapper span animates inset clipping; the h1 itself stays clean.
185
+ 'body[data-walkthrough-card] .walkthrough-card-title-text{',
186
+ 'display:inline-block;',
187
+ 'clip-path:inset(0 100% 0 0);',
188
+ 'animation:walkthrough-card-title-mask 800ms cubic-bezier(0.2,0.8,0.2,1) 500ms forwards;',
189
+ 'will-change:clip-path;',
190
+ '}',
191
+ 'body[data-walkthrough-card] .walkthrough-card-tagline{',
192
+ 'color:#334155;',
193
+ 'font-size:20px;opacity:0.9;margin:0;font-weight:400;line-height:1.4;',
194
+ // Override: start at 0 opacity + 12px down, animate up. The 0.9
195
+ // baseline opacity becomes the animation target via to{opacity:0.9}
196
+ // in the keyframes, keeping the subtle text fade after entrance.
197
+ 'opacity:0;transform:translateY(12px);',
198
+ 'animation:walkthrough-card-tagline-in 500ms cubic-bezier(0.2,0.8,0.2,1) 1000ms forwards;',
199
+ 'will-change:opacity,transform;',
200
+ '}',
201
+ 'body[data-walkthrough-card][data-card-theme="dark"] .walkthrough-card-tagline{',
202
+ 'color:#cbd5e1;',
203
+ '}',
204
+ // When a bg image is present (the sibling .walkthrough-card-bg div only
205
+ // exists then), give the title/tagline a soft counter-shadow so
206
+ // mid-luminance gradients can't wash the text out; the theme flip in
207
+ // writeTitleCards already handles the clear-cut light/dark cases.
208
+ 'body[data-walkthrough-card] .walkthrough-card-bg~.walkthrough-card .walkthrough-card-title,',
209
+ 'body[data-walkthrough-card] .walkthrough-card-bg~.walkthrough-card .walkthrough-card-tagline{',
210
+ 'text-shadow:0 1px 2px rgba(255,255,255,0.55),0 0 26px rgba(255,255,255,0.45);',
211
+ '}',
212
+ 'body[data-walkthrough-card][data-card-theme="dark"] .walkthrough-card-bg~.walkthrough-card .walkthrough-card-title,',
213
+ 'body[data-walkthrough-card][data-card-theme="dark"] .walkthrough-card-bg~.walkthrough-card .walkthrough-card-tagline{',
214
+ 'text-shadow:0 1px 2px rgba(2,6,23,0.60),0 0 26px rgba(2,6,23,0.50);',
215
+ '}',
216
+ // Outro fade-to-black: the recorder sets data-walkthrough-fade-out=1
217
+ // ~400ms before stopping recording. A black overlay fades IN over the
218
+ // card (rather than fading the body out, which would flash the white
219
+ // html background on dark cards), producing a clean tail on both themes.
220
+ 'body[data-walkthrough-card="outro"]::after{',
221
+ "content:'';position:fixed;top:0;left:0;right:0;bottom:0;",
222
+ 'background:#000;opacity:0;pointer-events:none;z-index:9998;',
223
+ '}',
224
+ 'body[data-walkthrough-card="outro"][data-walkthrough-fade-out="1"]::after{',
225
+ 'animation:walkthrough-card-fade-out 400ms ease-in 0ms forwards;',
226
+ '}',
227
+ '@keyframes walkthrough-card-kenburns{',
228
+ '0%{transform:scale(1);}',
229
+ '100%{transform:scale(1.06);}',
230
+ '}',
231
+ '@keyframes walkthrough-card-logo-in{',
232
+ '0%{opacity:0;transform:scale(0.92);}',
233
+ '100%{opacity:1;transform:scale(1);}',
234
+ '}',
235
+ '@keyframes walkthrough-card-title-mask{',
236
+ '0%{clip-path:inset(0 100% 0 0);}',
237
+ '100%{clip-path:inset(0 0% 0 0);}',
238
+ '}',
239
+ '@keyframes walkthrough-card-tagline-in{',
240
+ '0%{opacity:0;transform:translateY(12px);}',
241
+ '100%{opacity:0.9;transform:translateY(0);}',
242
+ '}',
243
+ '@keyframes walkthrough-card-fade-out{',
244
+ '0%{opacity:0;}',
245
+ '100%{opacity:1;}',
246
+ '}',
247
+ // ── Radial-wipe transition into step_0 ────────────────────────────
248
+ // When the recorder navigates from the intro card to step_0, the new
249
+ // page carries data-walkthrough-wipe-in=1 on its body. The stage
250
+ // wrapper (data-walkthrough-stage) gets a clip-path that expands from
251
+ // a centred dot to a full-viewport circle over 700ms — the brand-wash
252
+ // overlay underneath fades on the existing schedule, so the user sees
253
+ // the brand wash dissolve as the mockup emerges through an expanding
254
+ // circle. Only step_0 carries this attribute; step-to-step transitions
255
+ // keep the existing cross-fade.
256
+ 'body[data-walkthrough-wipe-in="1"] [data-walkthrough-stage]{',
257
+ 'clip-path:circle(0% at 50% 50%);',
258
+ 'animation:walkthrough-stage-wipe-in 700ms cubic-bezier(0.2,0.8,0.2,1) 0ms forwards;',
259
+ 'will-change:clip-path;',
260
+ '}',
261
+ '@keyframes walkthrough-stage-wipe-in{',
262
+ '0%{clip-path:circle(0% at 50% 50%);}',
263
+ '100%{clip-path:circle(150% at 50% 50%);}',
264
+ '}',
265
+ '</style>',
266
+ ].join('');
267
+
268
+ // Tiny inline loader that triggers the cross-fade fade-in once the document is
269
+ // parsed. Without this the body::before overlay stays opaque (default state)
270
+ // and the page would never become visible. Placed in <head> so it runs as
271
+ // early as possible — the readyState check covers the case where the script
272
+ // parses after DOMContentLoaded already fired (rare, but possible when
273
+ // scripts above it block).
274
+ const WALKTHROUGH_LOADER = [
275
+ '<script data-walkthrough-loader="1">',
276
+ "(function(){var g=function(){document.documentElement.classList.add('walkthrough-loaded');};",
277
+ "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',g);}else{g();}})();",
278
+ '</script>',
279
+ ].join('');
280
+
281
+ // Stage wrapper around mockup content. The recorder controls its transform to
282
+ // zoom in on interactions (clicks, typing fields). The ghost-cursor element is
283
+ // NOT inside the stage — it's a direct child of body — so it stays stable while
284
+ // the stage scales. Keeping transform-origin at 0 0 means the recorder fully
285
+ // controls the focal point via inline style updates.
286
+ const STAGE_OPEN = [
287
+ '<div data-walkthrough-stage="1" style="',
288
+ 'display:block;',
289
+ // min-height ensures the stage fills the visible viewport even when the
290
+ // mockup content is short — without it, a 200px-tall card on an otherwise
291
+ // empty page would leave ~500px of blank body background at the bottom of
292
+ // the 720p video. The mockup owns the full 720px frame (no caption
293
+ // chrome is reserved), so the content box is exactly the viewport height.
294
+ 'min-height:100vh;',
295
+ 'transform:scale(1);',
296
+ 'transform-origin:0 0;',
297
+ 'transition:transform 350ms cubic-bezier(0.4,0,0.2,1);',
298
+ // No will-change here: it pins the stage's raster at scale 1, so text
299
+ // blurs when the camera zooms in. transform:scale(1) alone keeps the
300
+ // stage the containing block the root-offset bridge relies on.
301
+ '">',
302
+ ].join('');
303
+ const STAGE_CLOSE = '</div>';
304
+
305
+ // ─── Small helpers ───────────────────────────────────────────────────────────
306
+
307
+ const log = message => process.stderr.write(message + '\n');
308
+
309
+ /** Truthiness as the branding/manifest checks expect: empty objects and arrays are false. */
310
+ function truthy(value) {
311
+ if (Array.isArray(value)) return value.length > 0;
312
+ if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
313
+ return Boolean(value);
314
+ }
315
+
316
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
317
+
318
+ /** Replace every occurrence of a literal string (no `$` pattern handling). */
319
+ function replaceAllLiteral(text, search, replacement) {
320
+ return text.split(search).join(replacement);
321
+ }
322
+
323
+ /** Replace the first occurrence of a literal string (no `$` pattern handling). */
324
+ function replaceFirstLiteral(text, search, replacement) {
325
+ const at = text.indexOf(search);
326
+ return at < 0 ? text : text.slice(0, at) + replacement + text.slice(at + search.length);
327
+ }
328
+
329
+ function escapeRegExp(text) {
330
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
331
+ }
332
+
333
+ function isFile(file) {
334
+ try { return fs.statSync(file).isFile(); } catch { return false; }
335
+ }
336
+
337
+ function isDir(dir) {
338
+ try { return fs.statSync(dir).isDirectory(); } catch { return false; }
339
+ }
340
+
341
+ function readFileOrEmpty(file) {
342
+ try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
343
+ }
344
+
345
+ function readJson(file) {
346
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
347
+ }
348
+
349
+ /** Split a directory into subdirectories and files; symlinked directories are listed but never followed. */
350
+ function listDir(dir) {
351
+ let entries;
352
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
353
+ catch { return { dirs: [], files: [] }; }
354
+ const dirs = [];
355
+ const files = [];
356
+ for (const entry of entries) {
357
+ let directory = entry.isDirectory();
358
+ if (entry.isSymbolicLink()) {
359
+ try { directory = fs.statSync(path.join(dir, entry.name)).isDirectory(); } catch { directory = false; }
360
+ if (directory) continue;
361
+ }
362
+ (directory ? dirs : files).push(entry.name);
363
+ }
364
+ return { dirs, files };
365
+ }
366
+
367
+ // ─── Inputs ──────────────────────────────────────────────────────────────────
368
+
369
+ function loadCss(cssFile) {
370
+ if (!cssFile || !isFile(cssFile)) return null;
371
+ try {
372
+ const content = fs.readFileSync(cssFile, 'utf8').trim();
373
+ return content || null;
374
+ } catch { return null; }
375
+ }
376
+
377
+ function loadImages(imagesJson) {
378
+ if (!isFile(imagesJson)) return {};
379
+ try { return readJson(imagesJson).images ?? {}; } catch { return {}; }
380
+ }
381
+
382
+ /**
383
+ * Load generated_images.json and return id -> data URI.
384
+ *
385
+ * Generated files deliberately stay separate from the project image manifest:
386
+ * .rtfm/images_base64.json is signature-cached from repository assets, while
387
+ * generated content is output-scoped and cacheable independently.
388
+ */
389
+ function loadGeneratedImages(generatedImagesJson) {
390
+ if (!generatedImagesJson) return {};
391
+ if (!isFile(generatedImagesJson)) {
392
+ log(`WARNING: generated image manifest not found: ${generatedImagesJson}`);
393
+ return {};
394
+ }
395
+ let manifest;
396
+ try { manifest = readJson(generatedImagesJson); }
397
+ catch (error) {
398
+ log(`WARNING: cannot read generated image manifest: ${error.message}`);
399
+ return {};
400
+ }
401
+
402
+ const resolved = {};
403
+ const baseDir = path.dirname(path.resolve(generatedImagesJson));
404
+ const assets = truthy(manifest?.assets) && isObject(manifest.assets) ? manifest.assets : {};
405
+ for (const [assetId, entry] of Object.entries(assets)) {
406
+ if (!isObject(entry)) continue;
407
+ const relPath = entry.path;
408
+ if (typeof relPath !== 'string') continue;
409
+ const assetPath = path.resolve(baseDir, relPath);
410
+ const inside = assetPath === baseDir || assetPath.startsWith(baseDir + path.sep);
411
+ if (!inside || !isFile(assetPath)) {
412
+ log(`WARNING: generated asset '${assetId}' is missing or outside its output directory`);
413
+ continue;
414
+ }
415
+ let encoded;
416
+ try { encoded = fs.readFileSync(assetPath).toString('base64'); }
417
+ catch (error) {
418
+ log(`WARNING: cannot read generated asset '${assetId}': ${error.message}`);
419
+ continue;
420
+ }
421
+ const mime = entry.mime || (assetPath.endsWith('.svg') ? 'image/svg+xml' : 'image/png');
422
+ resolved[assetId] = `data:${mime};base64,${encoded}`;
423
+ }
424
+ return resolved;
425
+ }
426
+
427
+ /**
428
+ * Load actions.json steps and merge in matching content from article.json
429
+ * (located next to actions.json) so the title cards can read the article
430
+ * title / introduction / summary at inject time, and the step count is known.
431
+ * Returns { steps, article } or null.
432
+ */
433
+ function loadWalkthroughSteps(actionsJson) {
434
+ if (!actionsJson) return null;
435
+ if (!isFile(actionsJson)) {
436
+ log(`WARNING: --walkthrough file not found: ${actionsJson}`);
437
+ return null;
438
+ }
439
+ let data;
440
+ try { data = readJson(actionsJson); }
441
+ catch (error) {
442
+ log(`WARNING: failed to read ${actionsJson}: ${error.message}`);
443
+ return null;
444
+ }
445
+
446
+ const steps = truthy(data.steps) ? data.steps : [];
447
+
448
+ // Try to read article.json from the same directory so we can attach
449
+ // per-step content + article-level title/intro/summary for title cards.
450
+ let article = {};
451
+ const articlePath = path.join(path.dirname(actionsJson) || '.', 'article.json');
452
+ if (isFile(articlePath)) {
453
+ try { article = readJson(articlePath) || {}; }
454
+ catch (error) { log(`WARNING: failed to read ${articlePath}: ${error.message}`); }
455
+ }
456
+
457
+ const articleSteps = truthy(article.steps) ? article.steps : [];
458
+ const merged = steps.map((step, i) => {
459
+ const mergedStep = { ...step };
460
+ if (i < articleSteps.length && isObject(articleSteps[i])) {
461
+ for (const key of ['title', 'content']) {
462
+ if (!truthy(mergedStep[key]) && truthy(articleSteps[i][key])) mergedStep[key] = articleSteps[i][key];
463
+ }
464
+ }
465
+ return mergedStep;
466
+ });
467
+
468
+ return {
469
+ steps: merged,
470
+ article: { title: article.title, introduction: article.introduction, summary: article.summary },
471
+ };
472
+ }
473
+
474
+ // ─── CSS asset inlining ──────────────────────────────────────────────────────
475
+ // Compiled app CSS often references repo assets by relative url() — e.g.
476
+ // .block-pattern-green { background-image: url('img/block-pattern-green.png') }.
477
+ // Those paths can never resolve from a file:// mockup, so the styled region
478
+ // renders blank and the authoring model "fills in" the missing visual with an
479
+ // invented gradient. Inline them as data URIs so the real asset renders.
480
+
481
+ const CSS_ASSET_MAX_BYTES = 1_500_000;
482
+ const CSS_ASSET_MIME = {
483
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
484
+ '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
485
+ '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf',
486
+ };
487
+ const ASSET_WALK_SKIP_DIRS = new Set(['node_modules', 'tmp', 'log', 'output', 'coverage', 'dist', 'build']);
488
+
489
+ const CSS_URL_RE = /url\(\s*(['"]?)([^)'"]+)\1\s*\)/g;
490
+
491
+ /** One walk of the project tree resolving wanted basenames → absolute paths. */
492
+ function findProjectAssets(projectDir, basenames) {
493
+ const wanted = new Set([...basenames].map(name => name.toLowerCase()));
494
+ const found = {};
495
+ const visit = dir => {
496
+ const { dirs, files } = listDir(dir);
497
+ for (const file of files) {
498
+ const low = file.toLowerCase();
499
+ if (wanted.has(low) && !(low in found)) found[low] = path.join(dir, file);
500
+ }
501
+ if (Object.keys(found).length === wanted.size) return true;
502
+ for (const name of dirs) {
503
+ if (ASSET_WALK_SKIP_DIRS.has(name) || name.startsWith('.')) continue;
504
+ if (visit(path.join(dir, name))) return true;
505
+ }
506
+ return false;
507
+ };
508
+ visit(projectDir);
509
+ return found;
510
+ }
511
+
512
+ /**
513
+ * Rewrite relative url() refs in cssPath to data URIs (matched by basename
514
+ * anywhere in the project tree). Idempotent — data:/http(s) refs are skipped.
515
+ * Returns the number of refs inlined.
516
+ */
517
+ function inlineCssAssetUrls(cssPath, projectDir) {
518
+ let css;
519
+ try { css = fs.readFileSync(cssPath, 'utf8'); } catch { return 0; }
520
+
521
+ const refs = new Map();
522
+ for (const match of css.matchAll(CSS_URL_RE)) {
523
+ const target = match[2].trim();
524
+ if (['data:', 'http://', 'https://', '//', '#'].some(prefix => target.startsWith(prefix))) continue;
525
+ const base = path.posix.basename(target.split('?')[0].split('#')[0]);
526
+ const ext = path.extname(base).toLowerCase();
527
+ if (Object.hasOwn(CSS_ASSET_MIME, ext)) refs.set(match[0], { base, ext });
528
+ }
529
+ if (!refs.size) return 0;
530
+
531
+ const lookup = findProjectAssets(projectDir, new Set([...refs.values()].map(ref => ref.base)));
532
+ const replacements = new Map();
533
+ for (const [raw, { base, ext }] of refs) {
534
+ const file = lookup[base.toLowerCase()];
535
+ if (!file) continue;
536
+ let data;
537
+ try {
538
+ const size = fs.statSync(file).size;
539
+ if (!(size > 0 && size <= CSS_ASSET_MAX_BYTES)) continue;
540
+ data = fs.readFileSync(file).toString('base64');
541
+ } catch { continue; }
542
+ replacements.set(raw, `url(data:${CSS_ASSET_MIME[ext]};base64,${data})`);
543
+ }
544
+
545
+ if (!replacements.size) return 0;
546
+ for (const [raw, replacement] of replacements) css = replaceAllLiteral(css, raw, replacement);
547
+ fs.writeFileSync(cssPath, css);
548
+ return replacements.size;
549
+ }
550
+
551
+ // ─── Icon font embedding ─────────────────────────────────────────────────────
552
+ // Icon fonts usually arrive via an app bundle or CDN, neither of which exists
553
+ // in a self-contained mockup (renders block external requests). When step HTML
554
+ // uses fa-*/bi-* classes, fetch the icon CSS once, inline its woff2 fonts as
555
+ // data URIs, and cache the result in the project cache dir so later runs skip
556
+ // the fetch.
557
+
558
+ const ICON_FONT_SOURCES = {
559
+ fontawesome: {
560
+ detect: /class=["'][^"']*\bfa[srlbd]?\s+fa-|class=["'][^"']*\bfa-[a-z]/,
561
+ cssUrl: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css',
562
+ },
563
+ 'bootstrap-icons': {
564
+ detect: /class=["'][^"']*\bbi[\s-]/,
565
+ cssUrl: 'https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css',
566
+ },
567
+ 'material-icons': {
568
+ // Ligature-based: <span class="material-icons">home</span>
569
+ detect: /class=["'][^"']*\bmaterial-icons/,
570
+ cssUrl: 'https://fonts.googleapis.com/icon?family=Material+Icons',
571
+ },
572
+ 'material-symbols': {
573
+ // <span class="material-symbols-outlined">home</span> (+rounded/sharp)
574
+ detect: /class=["'][^"']*\bmaterial-symbols-/,
575
+ cssUrl: 'https://fonts.googleapis.com/css2'
576
+ + '?family=Material+Symbols+Outlined'
577
+ + '&family=Material+Symbols+Rounded'
578
+ + '&family=Material+Symbols+Sharp',
579
+ },
580
+ boxicons: {
581
+ // <i class="bx bx-home">, bxs- (solid), bxl- (logos)
582
+ detect: /class=["'][^"']*\bbx\s+bx[sl]?-/,
583
+ cssUrl: 'https://cdn.jsdelivr.net/npm/boxicons@2.1.4/css/boxicons.min.css',
584
+ },
585
+ remixicon: {
586
+ // <i class="ri-home-line">
587
+ detect: /class=["'][^"']*\bri-[a-z0-9-]+/,
588
+ cssUrl: 'https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css',
589
+ },
590
+ glyphicons: {
591
+ // Bootstrap 3 legacy: <span class="glyphicon glyphicon-cog">. Only
592
+ // ships inside the full BS3 stylesheet, so filter to glyphicon rules —
593
+ // injecting all of Bootstrap 3 would clobber the project's real CSS.
594
+ detect: /class=["'][^"']*\bglyphicon\b/,
595
+ cssUrl: 'https://cdn.jsdelivr.net/npm/bootstrap@3.4.1/dist/css/bootstrap.min.css',
596
+ selectorFilter: 'glyphicon',
597
+ },
598
+ };
599
+
600
+ const ICON_FONTS_FILENAME = 'icon_fonts.css';
601
+
602
+ async function fetchBytes(url, timeoutMs = 20000) {
603
+ // Browser UA: Google Fonts sniffs the UA and only serves woff2 (and the
604
+ // ligature-capable CSS) to modern browsers — an unknown agent gets ttf.
605
+ const ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
606
+ + '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
607
+ const response = await fetch(url, { headers: { 'User-Agent': ua }, signal: AbortSignal.timeout(timeoutMs) });
608
+ if (!response.ok) throw new Error(`HTTP Error ${response.status}: ${response.statusText}`);
609
+ return Buffer.from(await response.arrayBuffer());
610
+ }
611
+
612
+ /**
613
+ * Keep only @font-face blocks and rules whose selector mentions the
614
+ * substring. Good enough for minified framework CSS — used to extract the
615
+ * glyphicon subset from Bootstrap 3 without importing its layout rules.
616
+ */
617
+ function filterCssRules(css, selectorSubstring) {
618
+ const kept = [];
619
+ for (const match of css.matchAll(/(@font-face\s*\{[^{}]*\})|([^{}@]+)\{([^{}]*)\}/g)) {
620
+ if (match[1]) kept.push(match[1]);
621
+ else if ((match[2] ?? '').includes(selectorSubstring)) kept.push(`${match[2].trim()}{${match[3]}}`);
622
+ }
623
+ return kept.join('\n');
624
+ }
625
+
626
+ /**
627
+ * Fetch an icon CSS file and inline its woff2 fonts as data URIs. Other
628
+ * font formats are rewritten to absolute URLs (blocked at render time, but
629
+ * woff2 comes first in every src list so Chromium never needs them).
630
+ */
631
+ async function fetchIconCssWithFonts(cssUrl, selectorFilter) {
632
+ let css = (await fetchBytes(cssUrl)).toString('utf8');
633
+ if (selectorFilter) css = filterCssRules(css, selectorFilter);
634
+
635
+ const replacements = [];
636
+ for (const match of css.matchAll(CSS_URL_RE)) {
637
+ const target = match[2].trim();
638
+ if (target.startsWith('data:')) {
639
+ replacements.push(match[0]);
640
+ continue;
641
+ }
642
+ const absolute = new URL(target, cssUrl).href;
643
+ if (target.split('?')[0].split('#')[0].endsWith('.woff2')) {
644
+ try {
645
+ replacements.push(`url(data:font/woff2;base64,${(await fetchBytes(absolute)).toString('base64')})`);
646
+ } catch {
647
+ replacements.push(`url(${absolute})`);
648
+ }
649
+ } else {
650
+ replacements.push(`url(${absolute})`);
651
+ }
652
+ }
653
+ let i = 0;
654
+ return css.replace(CSS_URL_RE, () => replacements[i++]);
655
+ }
656
+
657
+ /**
658
+ * Detect icon-font usage across article/walkthrough mockups; build/reuse a cached
659
+ * icon_fonts.css with embedded fonts. Returns the local filename to link
660
+ * from the head block, or null when no icon classes are present.
661
+ */
662
+ async function ensureIconFonts(htmlDir, cacheDir) {
663
+ let combined = '';
664
+ for (const fname of fs.readdirSync(htmlDir)) {
665
+ if ((fname.startsWith('step_') || fname.startsWith('block_')) && fname.endsWith('.html')) {
666
+ try { combined += fs.readFileSync(path.join(htmlDir, fname), 'utf8'); } catch { /* skip */ }
667
+ }
668
+ }
669
+ const needed = Object.entries(ICON_FONT_SOURCES).filter(([, spec]) => spec.detect.test(combined)).map(([name]) => name);
670
+ if (!needed.length) return null;
671
+
672
+ const cachePath = cacheDir ? path.join(cacheDir, ICON_FONTS_FILENAME) : null;
673
+ let existing = cachePath && isFile(cachePath) ? readFileOrEmpty(cachePath) : '';
674
+ const have = new Set([...existing.matchAll(/\/\* rtfm-icon-set: (\S+) \*\//g)].map(match => match[1]));
675
+
676
+ for (const name of needed.filter(name => !have.has(name)).sort()) {
677
+ try {
678
+ const spec = ICON_FONT_SOURCES[name];
679
+ const css = await fetchIconCssWithFonts(spec.cssUrl, spec.selectorFilter);
680
+ existing += `\n/* rtfm-icon-set: ${name} */\n${css}`;
681
+ log(` embedded icon font set: ${name}`);
682
+ } catch (error) {
683
+ log(`WARNING: could not fetch icon font set ${name}: ${error.message} (renderer glyph fallback will be used)`);
684
+ }
685
+ }
686
+
687
+ if (!existing.trim()) return null;
688
+ if (cachePath) {
689
+ try { fs.writeFileSync(cachePath, existing); } catch { /* cache is optional */ }
690
+ }
691
+ fs.writeFileSync(path.join(htmlDir, ICON_FONTS_FILENAME), existing);
692
+ return ICON_FONTS_FILENAME;
693
+ }
694
+
695
+ /** Load branding.json. Returns null if missing, unreadable, or empty {}. */
696
+ function loadBranding(brandingJson) {
697
+ if (!brandingJson || !isFile(brandingJson)) return null;
698
+ try {
699
+ const data = readJson(brandingJson);
700
+ return truthy(data) ? data : null;
701
+ } catch (error) {
702
+ log(`WARNING: failed to read ${brandingJson}: ${error.message}`);
703
+ return null;
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Extract the first font family name from branding.google_fonts entries,
709
+ * e.g. https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap
710
+ * -> "Inter". Descriptor objects contribute their `family`. Used to put the
711
+ * brand font on the intro/outro title cards. Returns null when none parses.
712
+ */
713
+ function firstGoogleFontFamily(branding) {
714
+ for (const item of (truthy(branding?.google_fonts) ? branding.google_fonts : [])) {
715
+ let family = null;
716
+ if (typeof item === 'string') {
717
+ const match = item.match(/[?&]family=([^:&]+)/);
718
+ if (match) family = match[1].replaceAll('+', ' ').replaceAll('%20', ' ').trim();
719
+ } else if (isObject(item) && typeof item.family === 'string') {
720
+ family = item.family.trim();
721
+ }
722
+ if (family) return family.replaceAll("'", '');
723
+ }
724
+ return null;
725
+ }
726
+
727
+ /**
728
+ * Normalise a google_fonts entry to a fonts.googleapis.com CSS URL.
729
+ *
730
+ * Accepts either a plain URL string (used verbatim) or a descriptor object like
731
+ * {"family": "Plus Jakarta Sans", "weights": ["500","600","700"], "source": ...}
732
+ * (what detect-project records for a next/font/google import), which is turned
733
+ * into https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700&display=swap.
734
+ * Returns null for anything unusable.
735
+ */
736
+ function fontDescriptorToUrl(item) {
737
+ if (typeof item === 'string') {
738
+ const s = item.trim();
739
+ if (!s) return null;
740
+ // A bare family name ("Inter") is not a URL — emitting it verbatim as an
741
+ // href produces a broken <link href="Inter"> tag. Compose the css2 URL.
742
+ if (!/^(https?:)?\/\//.test(s) && !s.includes('/')) {
743
+ return `https://fonts.googleapis.com/css2?family=${s.replaceAll(' ', '+')}&display=swap`;
744
+ }
745
+ return s;
746
+ }
747
+ if (isObject(item)) {
748
+ const family = String(item.family || '').trim();
749
+ if (!family) return null;
750
+ const famQ = family.replaceAll(' ', '+');
751
+ const weights = (truthy(item.weights) ? item.weights : []).map(w => String(w).trim()).filter(Boolean);
752
+ if (weights.length) return `https://fonts.googleapis.com/css2?family=${famQ}:wght@${weights.join(';')}&display=swap`;
753
+ return `https://fonts.googleapis.com/css2?family=${famQ}&display=swap`;
754
+ }
755
+ return null;
756
+ }
757
+
758
+ const BODY_SELECTOR_RE = /^(?:html\s+)?body((?:[.:#[][^\s>+~,{]*)*)$/;
759
+ const BG_DECL_RE = /(?:^|;)\s*(background(?:-color|-image)?\s*:[^;]+)/gi;
760
+
761
+ /**
762
+ * Bridge body-scoped backgrounds into the desktop window interior.
763
+ *
764
+ * Real apps paint their canvas on `body` (often gated by a theme class:
765
+ * `body.theme-dark { background: … }`). Inside a framed desktop mockup the
766
+ * app lives in `.desktop-window-content`, and `body` is the surrounding
767
+ * stage — so those backgrounds never reach the window interior and a themed
768
+ * app renders on the frame's default white (dark-theme text becomes
769
+ * white-on-white "voids"). Re-emit every body-targeting background rule
770
+ * against `.desktop-window-content`, preserving the original body selector
771
+ * prefix so theme-class gating still applies.
772
+ */
773
+ function desktopWindowBgBridge(cssText) {
774
+ if (!cssText) return '';
775
+ const bridged = [];
776
+ for (const match of cssText.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
777
+ const [, rawSel, body] = match;
778
+ if (!body.includes('background')) continue;
779
+ for (let sel of rawSel.split(',')) {
780
+ sel = sel.trim().split('}').at(-1).trim(); // drop leading at-rule tails
781
+ const sm = sel.match(BODY_SELECTOR_RE);
782
+ if (!sm) continue;
783
+ const decls = [...body.matchAll(BG_DECL_RE)].map(m => m[1].trim().replace(/;+$/, ''));
784
+ if (!decls.length) continue;
785
+ const suffix = sm[1] || '';
786
+ bridged.push(`body${suffix} .desktop-window-content { ${decls.join('; ')}; }`);
787
+ // Modal surfaces get the same app canvas colour — an app's own modal
788
+ // CSS is often JS-injected/unbuilt, and a transparent dialog with the
789
+ // page bleeding through is worse than a flat canvas-coloured card.
790
+ bridged.push(`body${suffix} .desktop-modal { ${decls.join('; ')}; }`);
791
+ }
792
+ }
793
+ if (!bridged.length) return '';
794
+ return '/* window-bg bridge: body-scoped app backgrounds re-emitted onto the\n'
795
+ + ' framed window interior (generated by inject_assets.js) */\n' + bridged.join('\n');
796
+ }
797
+
798
+ const TRIVIAL_OFFSET_RE = /^(?:0(?:\.0+)?(?:px|rem|em|%|vh|vw)?|[12](?:\.\d+)?px|initial|unset|inherit|revert|auto)$/i;
799
+
800
+ /**
801
+ * Bridge root-level fixed-chrome offsets into the walkthrough stage.
802
+ *
803
+ * The zoom stage carries `transform: scale(1)`, which makes it
804
+ * the CONTAINING BLOCK for position:fixed descendants (CSS spec) — so an app's
805
+ * fixed top bar anchors to the stage box instead of the viewport. Meanwhile
806
+ * the offset that keeps that bar off the content lives on the ROOT elements
807
+ * (`html.wp-toolbar { padding-top: 32px }`, Bootstrap's body padding), which
808
+ * sit OUTSIDE the stage: the stage is pushed down by the offset, the captured
809
+ * bar renders at the stage's top edge, and the bar overlaps the first slice
810
+ * of content with an empty band above it. Articles have no stage and are
811
+ * immune — this is walkthrough-only geometry.
812
+ *
813
+ * Fix: replicate the viewport geometry inside the stage. Zero each matching
814
+ * root-level offset and give [data-walkthrough-stage] the same padding-top,
815
+ * so the captured bar paints in the stage's padding band exactly where the
816
+ * real page paints it, and zooming scales bar + content coherently. Only
817
+ * offsets whose selector class is actually present on this step's <html>/
818
+ * <body> are bridged; var() expressions are kept verbatim (they resolve in
819
+ * the document). Returns '' when the step has no such offsets — the common
820
+ * case, leaving output byte-identical.
821
+ */
822
+ function fixedOffsetBridgeCss(html, cssText) {
823
+ if (!html || !cssText) return '';
824
+ const tokens = {};
825
+ for (const root of ['html', 'body']) {
826
+ const match = html.match(new RegExp(`<${root}[^>]*\\bclass\\s*=\\s*"([^"]*)"`, 'i'));
827
+ tokens[root] = new Set((match ? match[1] : '').split(/\s+/).filter(Boolean));
828
+ }
829
+ if (!tokens.html.size && !tokens.body.size) return '';
830
+ const offsets = new Map(); // "root.cls" -> first non-trivial padding-top expression
831
+ for (const match of cssText.matchAll(/([^{};]+)\{([^{}]*)\}/g)) {
832
+ const [, sel, decls] = match;
833
+ const pt = decls.match(/(?:^|;)\s*padding-top\s*:\s*([^;]+)/i);
834
+ if (!pt) continue;
835
+ const value = pt[1].replaceAll('!important', '').trim();
836
+ if (TRIVIAL_OFFSET_RE.test(value)) continue;
837
+ for (const sm of sel.matchAll(/(?:^|[,\s])(html|body)\.([A-Za-z0-9_-]+)/g)) {
838
+ const [, root, cls] = sm;
839
+ if (tokens[root].has(cls) && !offsets.has(`${root}.${cls}`)) offsets.set(`${root}.${cls}`, value);
840
+ }
841
+ }
842
+ if (!offsets.size) return '';
843
+ const zeroRules = [...offsets.keys()].map(key => `${key}{padding-top:0 !important;}`).join('');
844
+ const exprs = [...new Set(offsets.values())];
845
+ const total = exprs.length === 1 ? exprs[0] : 'calc(' + exprs.join(' + ') + ')';
846
+ return '<style data-rtfm-offset-bridge="1">'
847
+ + '/* root-offset bridge: the zoom stage captures position:fixed, so the '
848
+ + 'root-level chrome offset is re-applied inside the stage */'
849
+ + `${zeroRules}[data-walkthrough-stage]{padding-top:${total};}`
850
+ + '</style>';
851
+ }
852
+
853
+ /**
854
+ * Build the HTML snippet that replaces <!-- INJECT_CSS --> in each mockup.
855
+ *
856
+ * Without branding: <style> if cssContent is given, else bare Tailwind CDN.
857
+ * With branding, layers in compiled CSS / framework CDN / Google Fonts /
858
+ * external stylesheets / :root vars in the documented order.
859
+ *
860
+ * Tailwind projects always get the CDN even when compiled_css_path is set.
861
+ * The compiled build is purged — it only contains classes present in the real
862
+ * project templates. Mockup markup often uses additional utility classes that
863
+ * were never in the source, so the CDN is needed as a complete-coverage
864
+ * companion. The compiled CSS loads first so its component/override rules still
865
+ * take precedence over CDN-generated utilities. A local JIT mockup.css, when
866
+ * present, replaces the Play CDN (reliable + offline).
867
+ */
868
+ function buildCssHeadBlock(cssContent, branding, { jitCss = null, twHint = false, extraCss = null } = {}) {
869
+ if (!branding) return cssContent ? `<style>\n${cssContent}\n</style>` : TAILWIND_CDN;
870
+
871
+ const parts = [];
872
+ const framework = String(branding.framework || '').toLowerCase();
873
+ const compiledCssPath = branding.compiled_css_path;
874
+ const frameworkCdn = branding.framework_cdn;
875
+ const isTailwindFamily = framework.includes('tailwind') || ['', 'none', 'custom'].includes(framework) || twHint;
876
+
877
+ // 1. Compiled CSS first (highest fidelity — same bytes the app actually serves)
878
+ if (truthy(compiledCssPath)) parts.push(`<link rel="stylesheet" href="${compiledCssPath}">`);
879
+
880
+ // 1b. Render-time JIT CSS (mockup.css) — locally compiled from THIS mockup's
881
+ // markup via the project's cached Tailwind recipe, so it contains exactly
882
+ // the classes the mockup uses (arbitrary values, responsive variants, plugin
883
+ // classes). When present it *replaces* the Play CDN below — reliable + local,
884
+ // no network dependency in the headless render.
885
+ if (jitCss) parts.push(`<link rel="stylesheet" href="${jitCss}">`);
886
+
887
+ // 2. Tailwind runtime config override (must precede any Tailwind CDN <script>).
888
+ // Only configures the CDN, so skip it entirely when JIT CSS covers the classes.
889
+ if (framework.includes('tailwind') && truthy(branding.tailwind_extend_raw) && !jitCss) {
890
+ const extend = replaceAllLiteral(String(branding.tailwind_extend_raw), '</script>', '<\\/script>');
891
+ parts.push(`<script>window.tailwind=window.tailwind||{};window.tailwind.config={theme:{extend:${extend}}};</script>`);
892
+ }
893
+
894
+ // 3. Framework CDN.
895
+ // Non-Tailwind: skip when compiled CSS is present (CDN would duplicate coverage).
896
+ // Tailwind: inject alongside the (purged) compiled CSS — UNLESS JIT CSS is present.
897
+ if (truthy(frameworkCdn) && (!truthy(compiledCssPath) || framework.includes('tailwind')) && !jitCss) {
898
+ if (String(frameworkCdn).endsWith('.css')) parts.push(`<link rel="stylesheet" href="${frameworkCdn}">`);
899
+ else parts.push(`<script src="${frameworkCdn}"></script>`); // Tailwind's CDN is a JS bundle
900
+ }
901
+
902
+ // 4. Tailwind CDN fallback — fires when no framework_cdn is in branding.json.
903
+ // Skipped when JIT CSS is present (it already carries the mockup's classes).
904
+ if (isTailwindFamily && !truthy(frameworkCdn) && !jitCss) {
905
+ if (cssContent && !truthy(compiledCssPath)) parts.push(`<style>\n${cssContent}\n</style>`);
906
+ else parts.push(TAILWIND_CDN);
907
+ }
908
+
909
+ // 5. Google Fonts. detect-project may record these either as a plain CSS URL
910
+ // string OR as a structured descriptor object ({"family","weights","source"} —
911
+ // e.g. a next/font/google import). Build a real fonts.googleapis.com URL from
912
+ // the object; stringifying it straight into href yields a broken link that
913
+ // 404s (leaving the mockup on a system-font fallback).
914
+ for (const item of (truthy(branding.google_fonts) ? branding.google_fonts : [])) {
915
+ const url = fontDescriptorToUrl(item);
916
+ if (url) parts.push(`<link rel="stylesheet" href="${url}">`);
917
+ }
918
+
919
+ // 6. External stylesheets (tippy, highlight.js, etc.)
920
+ for (const url of (truthy(branding.external_stylesheets) ? branding.external_stylesheets : [])) {
921
+ parts.push(`<link rel="stylesheet" href="${url}">`);
922
+ }
923
+
924
+ // 7. :root vars + design tokens + var overrides
925
+ const styleBlocks = [];
926
+ const rootLines = [];
927
+ if (truthy(branding.root_css)) rootLines.push(branding.root_css);
928
+ const cardFont = firstGoogleFontFamily(branding);
929
+ if (cardFont) rootLines.push(` --walkthrough-card-font: '${cardFont}', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;`);
930
+ if (truthy(branding.default_colors)) {
931
+ for (const [k, v] of Object.entries(branding.default_colors)) rootLines.push(` --brand-${k.replaceAll('_', '-')}: ${v};`);
932
+ }
933
+ if (truthy(branding.css_var_overrides)) {
934
+ for (const [k, v] of Object.entries(branding.css_var_overrides)) rootLines.push(` ${k}: ${v};`);
935
+ }
936
+ if (rootLines.length) styleBlocks.push(':root {\n' + rootLines.join('\n') + '\n}');
937
+ if (truthy(branding.dark_css)) styleBlocks.push('.dark {\n' + branding.dark_css + '\n}');
938
+
939
+ if (styleBlocks.length) {
940
+ // Defensive escape — we're concatenating arbitrary CSS into a <style> tag
941
+ const cssPayload = replaceAllLiteral(styleBlocks.join('\n'), '</style>', '<\\/style>');
942
+ parts.push(`<style data-walkthrough-branding="1">\n${cssPayload}\n</style>`);
943
+ }
944
+
945
+ // 8. Generated bridge CSS (e.g. the desktop window-bg bridge) — last, so it
946
+ // wins source-order ties against the compiled stylesheet it derives from.
947
+ if (extraCss) {
948
+ parts.push(`<style data-rtfm-bridge="1">\n${replaceAllLiteral(extraCss, '</style>', '<\\/style>')}\n</style>`);
949
+ }
950
+
951
+ return parts.length ? parts.join('\n ') : TAILWIND_CDN;
952
+ }
953
+
954
+ // ─── SVG sprite inlining ─────────────────────────────────────────────────────
955
+ // <use href="icons.svg#gear"> references an external sprite file, which can't
956
+ // load from a self-contained file:// mockup (and would be blocked anyway).
957
+ // Inline the referenced <symbol>s into a hidden in-document sprite and rewrite
958
+ // the hrefs to fragment-only (#gear), which works everywhere.
959
+
960
+ const SPRITE_USE_RE = /(?:xlink:)?href\s*=\s*["']([^"'#]+\.svg)#([\w:.-]+)["']/gi;
961
+
962
+ /**
963
+ * Rewrite external sprite refs in <use> to in-document symbols.
964
+ * spriteCache maps basename(lower) → file text (shared across steps).
965
+ */
966
+ function inlineSvgSprites(html, projectDir, spriteCache) {
967
+ const refs = [];
968
+ for (const match of html.matchAll(SPRITE_USE_RE)) {
969
+ // Only rewrite refs that appear inside <use …> tags
970
+ const tagStart = match.index > 0 ? html.lastIndexOf('<', match.index - 1) : -1;
971
+ if (tagStart === -1 || !/^<use\b/i.test(html.slice(tagStart, match.index))) continue;
972
+ refs.push([match[0], match[1], match[2]]);
973
+ }
974
+ if (!refs.length) return html;
975
+
976
+ const basenames = new Set(refs.map(([, file]) => path.posix.basename(file).toLowerCase()));
977
+ const missing = [...basenames].filter(base => !(base in spriteCache));
978
+ if (missing.length) {
979
+ const lookup = findProjectAssets(projectDir, missing);
980
+ for (const base of missing) spriteCache[base] = base in lookup ? readFileOrEmpty(lookup[base]) : '';
981
+ }
982
+
983
+ const symbols = new Map();
984
+ for (const [raw, file, frag] of refs) {
985
+ const sprite = spriteCache[path.posix.basename(file).toLowerCase()] ?? '';
986
+ if (!sprite || symbols.has(frag)) {
987
+ if (sprite) html = replaceAllLiteral(html, raw, `href="#${frag}"`);
988
+ continue;
989
+ }
990
+ const sym = sprite.match(new RegExp(`<symbol\\b[^>]*\\bid\\s*=\\s*["']${escapeRegExp(frag)}["'][\\s\\S]*?</symbol>`, 'i'));
991
+ if (!sym) continue;
992
+ symbols.set(frag, sym[0]);
993
+ html = replaceAllLiteral(html, raw, `href="#${frag}"`);
994
+ }
995
+
996
+ if (symbols.size) {
997
+ const spriteSvg = '<svg xmlns="http://www.w3.org/2000/svg" style="display:none" '
998
+ + 'aria-hidden="true" data-rtfm-sprite="1">' + [...symbols.values()].join('') + '</svg>';
999
+ html = html.replace(/(<body\b[^>]*>)/, match => match + spriteSvg);
1000
+ }
1001
+ return html;
1002
+ }
1003
+
1004
+ /**
1005
+ * Mark <html> so the renderers' glyph-substitute fallback CSS (which uses
1006
+ * !important) knows real icon fonts are embedded and stands down.
1007
+ */
1008
+ function injectIconFontsMarker(html) {
1009
+ if (html.includes('data-rtfm-icon-fonts')) return html;
1010
+ return html.replace(/<html(\b[^>]*)?>/, (_, attrs) => `<html${attrs || ''} data-rtfm-icon-fonts="1">`);
1011
+ }
1012
+
1013
+ function injectCss(html, block) {
1014
+ if (html.includes('<!-- INJECT_CSS -->')) return replaceAllLiteral(html, '<!-- INJECT_CSS -->', block);
1015
+ if (html.includes('</head>')) return replaceAllLiteral(html, '</head>', `${block}\n</head>`);
1016
+ if (html.includes('<body')) return replaceFirstLiteral(html, '<body', `${block}\n<body`);
1017
+ return `<head>${block}</head>\n${html}`;
1018
+ }
1019
+
1020
+ function escapeText(text) {
1021
+ return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
1022
+ }
1023
+
1024
+ function insertHead(html, headInserts) {
1025
+ if (html.includes('</head>')) return replaceFirstLiteral(html, '</head>', `${headInserts}\n</head>`);
1026
+ if (html.includes('<head')) return html.replace(/(<head[^>]*>)/, match => `${match}\n${headInserts}`);
1027
+ return `<head>${headInserts}</head>\n` + html;
1028
+ }
1029
+
1030
+ /**
1031
+ * Inject the polish CSS, cross-fade loader, and the zoomable stage wrapper
1032
+ * around the mockup content.
1033
+ *
1034
+ * No caption chrome is added — the step text lives in the narration and the
1035
+ * subtitles/transcript, so the mockup owns the full viewport frame.
1036
+ * `offsetBridge` is the per-step root-offset bridge <style> (see
1037
+ * fixedOffsetBridgeCss) — empty for apps with no root-level chrome offset.
1038
+ */
1039
+ function injectStage(html, offsetBridge = '') {
1040
+ let headInserts = ` ${WALKTHROUGH_STYLE}\n ${WALKTHROUGH_LOADER}`;
1041
+ if (offsetBridge && !html.includes('data-rtfm-offset-bridge')) headInserts += `\n ${offsetBridge}`;
1042
+ html = insertHead(html, headInserts);
1043
+
1044
+ const bodyInserts = `\n${STAGE_OPEN}`;
1045
+ const bodyMatch = html.match(/<body[^>]*>/);
1046
+ if (bodyMatch) {
1047
+ const insertAt = bodyMatch.index + bodyMatch[0].length;
1048
+ html = html.slice(0, insertAt) + bodyInserts + html.slice(insertAt);
1049
+ html = html.includes('</body>') ? replaceFirstLiteral(html, '</body>', STAGE_CLOSE + '\n</body>') : html + STAGE_CLOSE;
1050
+ } else {
1051
+ html = bodyInserts + html + STAGE_CLOSE;
1052
+ }
1053
+ return html;
1054
+ }
1055
+
1056
+ /**
1057
+ * Add data-walkthrough-wipe-in="1" to the body tag of step_0.html so the
1058
+ * radial-wipe CSS in WALKTHROUGH_STYLE fires when the recorder navigates
1059
+ * from the intro card to step_0. No-op if the attribute is already present
1060
+ * or the body tag is missing.
1061
+ */
1062
+ function injectStepZeroWipe(html) {
1063
+ const bodyMatch = html.match(/<body([^>]*)>/);
1064
+ if (!bodyMatch) return html;
1065
+ const bodyAttrs = bodyMatch[1];
1066
+ if (bodyAttrs.includes('data-walkthrough-wipe-in')) return html;
1067
+ const newOpen = `<body${bodyAttrs} data-walkthrough-wipe-in="1">`;
1068
+ return html.slice(0, bodyMatch.index) + newOpen + html.slice(bodyMatch.index + bodyMatch[0].length);
1069
+ }
1070
+
1071
+ /**
1072
+ * Inject just the polish CSS + cross-fade loader for title-card pages.
1073
+ * No stage wrapper — the card body controls its own layout.
1074
+ */
1075
+ function injectCardLoader(html) {
1076
+ return insertHead(html, ` ${WALKTHROUGH_STYLE}\n ${WALKTHROUGH_LOADER}`);
1077
+ }
1078
+
1079
+ function buildFilenameLookup(images) {
1080
+ const lookup = {};
1081
+ for (const [imagePath, uri] of Object.entries(images)) {
1082
+ const fname = path.posix.basename(imagePath);
1083
+ if (!(fname in lookup)) lookup[fname] = uri;
1084
+ }
1085
+ return lookup;
1086
+ }
1087
+
1088
+ function makeReplaceImg(images, filenameLookup) {
1089
+ return (match, imgName) => {
1090
+ for (const key of [imgName, '/' + imgName, '/assets/' + imgName]) {
1091
+ if (Object.hasOwn(images, key)) return images[key];
1092
+ }
1093
+ return Object.hasOwn(filenameLookup, imgName) ? filenameLookup[imgName] : match;
1094
+ };
1095
+ }
1096
+
1097
+ function stepIndexFromFilename(fname) {
1098
+ const match = fname.match(/^step_(\d+)\.html$/);
1099
+ return match ? Number(match[1]) : null;
1100
+ }
1101
+
1102
+ // Title-card filenames. The recorder treats these specially (no advance
1103
+ // element, fixed display duration). Auto-generated from article.json when
1104
+ // the walkthrough flag is set.
1105
+ const INTRO_FILENAME = 'step_intro.html';
1106
+ const OUTRO_FILENAME = 'step_outro.html';
1107
+
1108
+ // Filename fragments that suggest "this image is the project's wordmark/logo".
1109
+ // Used by findLogo() to pick a brand image for the title cards. Favicons are
1110
+ // excluded explicitly because they're typically too small (16-32px) to render
1111
+ // nicely at the 84px card-logo size.
1112
+ const LOGO_NAME_HINTS = ['logo', 'wordmark', 'brand-mark', 'brandmark', 'brand_logo'];
1113
+
1114
+ /** Pick a logo-like filename from the image manifest, if any. */
1115
+ function findLogo(filenameLookup) {
1116
+ const candidates = [];
1117
+ for (const name of Object.keys(filenameLookup)) {
1118
+ const lower = name.toLowerCase();
1119
+ if (lower.includes('favicon')) continue;
1120
+ if (LOGO_NAME_HINTS.some(hint => lower.includes(hint))) {
1121
+ // Prefer exact "logo.<ext>" matches over hyphenated variants.
1122
+ const dot = lower.lastIndexOf('.');
1123
+ const base = dot < 0 ? lower : lower.slice(0, dot);
1124
+ const rank = base === 'logo' ? 0 : (base.startsWith('logo') ? 1 : 2);
1125
+ candidates.push([rank, name]);
1126
+ }
1127
+ }
1128
+ if (!candidates.length) return null;
1129
+ candidates.sort((a, b) => (a[0] - b[0]) || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0));
1130
+ return candidates[0][1];
1131
+ }
1132
+
1133
+ /** Perceived luminance (0..1) of a #rgb/#rrggbb(aa) colour, or null. */
1134
+ function hexLuminance(hex) {
1135
+ let h = hex.replace(/^#+/, '');
1136
+ if (h.length === 3) h = [...h].map(c => c + c).join('');
1137
+ if (h.length !== 6 && h.length !== 8) return null;
1138
+ if (!/^[0-9a-fA-F]{6}/.test(h)) return null;
1139
+ const [r, g, b] = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16));
1140
+ return (0.299 * r + 0.587 * g + 0.114 * b) / 255.0;
1141
+ }
1142
+
1143
+ // ─── Raster measurement (title cards) ────────────────────────────────────────
1144
+ // Title-card theming needs pixel data from logos and card backgrounds. The
1145
+ // renderer's own Chromium decodes every format a mockup can use, so one
1146
+ // shared headless page does the decoding; the browser starts only when a
1147
+ // walkthrough actually has a raster to measure.
1148
+
1149
+ let rasterPage = null;
1150
+ let rasterBrowser = null;
1151
+
1152
+ async function rasterPixels(dataUri, crop = null) {
1153
+ if (!rasterPage) {
1154
+ let renderer;
1155
+ try { renderer = require('./render_mockup.js'); }
1156
+ catch (error) {
1157
+ log(` raster measurement unavailable: ${error.message}`);
1158
+ return null;
1159
+ }
1160
+ rasterBrowser = await renderer.puppeteer.launch(renderer.launchOptions());
1161
+ rasterPage = await rasterBrowser.newPage();
1162
+ }
1163
+ try {
1164
+ return await rasterPage.evaluate(async (src, box) => {
1165
+ const img = new Image();
1166
+ img.src = src;
1167
+ await img.decode();
1168
+ let [sx, sy, sw, sh] = [0, 0, img.naturalWidth, img.naturalHeight];
1169
+ if (!sw || !sh) return null;
1170
+ if (box) {
1171
+ sx = Math.trunc(sw * box[0]); sy = Math.trunc(sh * box[1]);
1172
+ const ex = Math.trunc(img.naturalWidth * box[2]); const ey = Math.trunc(img.naturalHeight * box[3]);
1173
+ sw = ex - sx; sh = ey - sy;
1174
+ }
1175
+ // Fit within 64x64 preserving aspect, never enlarging.
1176
+ const scale = Math.min(1, 64 / sw, 64 / sh);
1177
+ const w = Math.max(1, Math.round(sw * scale));
1178
+ const h = Math.max(1, Math.round(sh * scale));
1179
+ const canvas = document.createElement('canvas');
1180
+ canvas.width = w; canvas.height = h;
1181
+ const ctx = canvas.getContext('2d');
1182
+ ctx.drawImage(img, sx, sy, sw, sh, 0, 0, w, h);
1183
+ return Array.from(ctx.getImageData(0, 0, w, h).data);
1184
+ }, dataUri, crop);
1185
+ } catch {
1186
+ return null;
1187
+ }
1188
+ }
1189
+
1190
+ async function closeRaster() {
1191
+ if (rasterBrowser) await rasterBrowser.close().catch(() => {});
1192
+ rasterBrowser = null;
1193
+ rasterPage = null;
1194
+ }
1195
+
1196
+ /**
1197
+ * Best-effort ink measurement for a logo data URI. Returns
1198
+ * [mean ink luminance 0..1, hasTransparency] or null when undecidable
1199
+ * (bad data, unsupported format, or no renderer for rasters).
1200
+ *
1201
+ * SVG: average the luminance of explicit fill/stroke colours; treated as
1202
+ * transparent (SVG marks almost never paint a full background).
1203
+ * Raster: alpha-weighted mean luminance of visible pixels; for fully opaque
1204
+ * images (e.g. JPEG) near-white pixels are treated as background and
1205
+ * excluded so a dark mark on white reads as dark.
1206
+ */
1207
+ async function logoInkStats(dataUri) {
1208
+ if (typeof dataUri !== 'string' || !dataUri.includes(',')) return null;
1209
+ const comma = dataUri.indexOf(',');
1210
+ const header = dataUri.slice(0, comma);
1211
+ const raw = Buffer.from(dataUri.slice(comma + 1), 'base64');
1212
+
1213
+ if (header.includes('svg')) {
1214
+ const svg = raw.toString('utf8');
1215
+ const lums = [...svg.matchAll(/(?:fill|stroke|stop-color)\s*[:=]\s*["']?(#[0-9a-fA-F]{3,8})/g)]
1216
+ .map(match => hexLuminance(match[1])).filter(lum => lum !== null);
1217
+ if (!lums.length) return null;
1218
+ return [lums.reduce((a, b) => a + b, 0) / lums.length, true];
1219
+ }
1220
+
1221
+ const data = await rasterPixels(dataUri);
1222
+ if (!data) return null;
1223
+ let hasAlpha = false;
1224
+ for (let i = 3; i < data.length; i += 4) if (data[i] < 250) { hasAlpha = true; break; }
1225
+ const samples = [];
1226
+ for (let i = 0; i < data.length; i += 4) {
1227
+ const lum = (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255.0;
1228
+ if (hasAlpha) { if (data[i + 3] >= 64) samples.push(lum); }
1229
+ else if (lum <= 0.95) samples.push(lum);
1230
+ }
1231
+ if (samples.length < 10) return null;
1232
+ return [samples.reduce((a, b) => a + b, 0) / samples.length, hasAlpha];
1233
+ }
1234
+
1235
+ /**
1236
+ * Best-effort: is the logo's ink predominantly dark? true -> the logo
1237
+ * needs a light card; false -> a dark card. null when undecidable.
1238
+ */
1239
+ async function logoIsDark(dataUri) {
1240
+ const stats = await logoInkStats(dataUri);
1241
+ return stats === null ? null : stats[0] < 0.55;
1242
+ }
1243
+
1244
+ /**
1245
+ * Mean perceived luminance (0..1) of the central region of a card
1246
+ * background image — the area the logo and title sit over (the gradient's
1247
+ * corner glows are deliberately cropped out). null when unreadable.
1248
+ */
1249
+ async function cardBgLuminance(file) {
1250
+ let raw;
1251
+ try { raw = fs.readFileSync(file); } catch { return null; }
1252
+ const mime = CARD_LOGO_MIMES[path.extname(file).toLowerCase()] ?? 'image/png';
1253
+ const data = await rasterPixels(`data:${mime};base64,${raw.toString('base64')}`, [0.20, 0.22, 0.80, 0.78]);
1254
+ if (!data || !data.length) return null;
1255
+ let sum = 0;
1256
+ for (let i = 0; i < data.length; i += 4) sum += (0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]) / 255.0;
1257
+ return sum / (data.length / 4);
1258
+ }
1259
+
1260
+ /**
1261
+ * Light card for dark logos, dark card for light logos. Defaults to
1262
+ * light when there is no logo or its ink colour can't be determined.
1263
+ */
1264
+ async function pickCardTheme(logoFilename, filenameLookup) {
1265
+ if (!logoFilename) return 'light';
1266
+ const darkLogo = await logoIsDark(filenameLookup[logoFilename]);
1267
+ return darkLogo === false ? 'dark' : 'light';
1268
+ }
1269
+
1270
+ /**
1271
+ * Produce raw HTML for an intro or outro title card. Contains the same
1272
+ * {{img:...}} and <!-- INJECT_CSS --> placeholders as a normal step file,
1273
+ * so the existing CSS + image substitution still applies.
1274
+ *
1275
+ * backgroundImage (card_bg.png) sits on a dedicated sibling div so it can be
1276
+ * scale-transformed for the Ken Burns effect without affecting card content.
1277
+ * logoDataUri (the account's uploaded branding logo) is embedded directly and
1278
+ * takes precedence over logoFilename (the repo-detected logo resolved from
1279
+ * the image manifest). logoPlate ("light"/"dark") adds a translucent contrast
1280
+ * plate behind the logo.
1281
+ */
1282
+ function buildCardHtml({ kind, title, tagline, logoFilename, backgroundImage = null, theme = 'light', logoDataUri = null, logoPlate = null }) {
1283
+ const safeTitle = escapeText(title || '');
1284
+ const safeTagline = escapeText(tagline || '');
1285
+ const plateClass = logoPlate ? ` walkthrough-card-logo--plate-${logoPlate}` : '';
1286
+ let logoHtml = '';
1287
+ if (logoDataUri) logoHtml = `<img class="walkthrough-card-logo${plateClass}" src="${logoDataUri}" alt="">`;
1288
+ else if (logoFilename) logoHtml = `<img class="walkthrough-card-logo${plateClass}" src="{{img:${logoFilename}}}" alt="">`;
1289
+ const taglineHtml = safeTagline ? `<p class="walkthrough-card-tagline">${safeTagline}</p>` : '';
1290
+ // Body keeps its flat brand-colour fallback (set in WALKTHROUGH_STYLE) so a
1291
+ // missing image degrades to a clean coloured frame rather than a white flash.
1292
+ const bgHtml = backgroundImage
1293
+ ? `<div class="walkthrough-card-bg" style="background-image:url('${backgroundImage.replaceAll("'", '%27')}');"></div>`
1294
+ : '';
1295
+ return `<!doctype html>
1296
+ <html lang="en">
1297
+ <head>
1298
+ <meta charset="utf-8">
1299
+ <title>${safeTitle}</title>
1300
+ <!-- INJECT_CSS -->
1301
+ </head>
1302
+ <body data-walkthrough-card="${kind}" data-card-theme="${theme}" data-viewport="wide">
1303
+ ${bgHtml}
1304
+ <div class="walkthrough-card">
1305
+ ${logoHtml}
1306
+ <h1 class="walkthrough-card-title"><span class="walkthrough-card-title-text">${safeTitle}</span></h1>
1307
+ ${taglineHtml}
1308
+ </div>
1309
+ </body>
1310
+ </html>
1311
+ `;
1312
+ }
1313
+
1314
+ // Filename of the card background (when present). It lives in the
1315
+ // project-level .rtfm/ cache (legacy: .rtfm-branding/); this module copies it
1316
+ // into each walkthrough's output dir at process time so the title-card HTML
1317
+ // can reference it with a relative path (the same pattern as branding.css).
1318
+ const CARD_BG_FILENAME = 'card_bg.png';
1319
+
1320
+ /** Resolve a cached card background PNG next to branding.json, or null. */
1321
+ function findCardBg(brandingJsonPath) {
1322
+ if (!brandingJsonPath) return null;
1323
+ const candidate = path.join(path.dirname(brandingJsonPath) || '.', CARD_BG_FILENAME);
1324
+ return isFile(candidate) ? candidate : null;
1325
+ }
1326
+
1327
+ // The account's branding logo, seeded into .rtfm/ as card_logo.<ext>. When
1328
+ // present it overrides the repo-detected logo on the title cards.
1329
+ const CARD_LOGO_MIMES = {
1330
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
1331
+ '.svg': 'image/svg+xml', '.webp': 'image/webp', '.gif': 'image/gif',
1332
+ };
1333
+
1334
+ /** Resolve the branding logo (card_logo.<ext>) next to branding.json, or null. */
1335
+ function findCardLogo(brandingJsonPath) {
1336
+ if (!brandingJsonPath) return null;
1337
+ const dir = path.dirname(brandingJsonPath) || '.';
1338
+ for (const ext of Object.keys(CARD_LOGO_MIMES)) {
1339
+ const candidate = path.join(dir, `card_logo${ext}`);
1340
+ if (isFile(candidate)) return candidate;
1341
+ }
1342
+ return null;
1343
+ }
1344
+
1345
+ /** Read a logo file into a base64 data URI for direct embedding, or null. */
1346
+ function cardLogoDataUri(file) {
1347
+ if (!file || !isFile(file)) return null;
1348
+ const mime = CARD_LOGO_MIMES[path.extname(file).toLowerCase()] ?? 'image/png';
1349
+ try { return `data:${mime};base64,${fs.readFileSync(file).toString('base64')}`; }
1350
+ catch { return null; }
1351
+ }
1352
+
1353
+ function sameFile(a, b) {
1354
+ try {
1355
+ const [sa, sb] = [fs.statSync(a), fs.statSync(b)];
1356
+ return sa.dev === sb.dev && sa.ino === sb.ino;
1357
+ } catch { return false; }
1358
+ }
1359
+
1360
+ /**
1361
+ * Write step_intro.html + step_outro.html in htmlDir using article-level
1362
+ * metadata from the merged walkthrough data. Skips emission when article
1363
+ * info is empty (e.g. article.json was missing).
1364
+ *
1365
+ * When cardBgSource is a path to an existing PNG, the file is copied into
1366
+ * htmlDir as card_bg.png and referenced as the card body's background-image.
1367
+ */
1368
+ async function writeTitleCards(htmlDir, walkthroughData, filenameLookup, { cardBgSource = null, cardLogoSource = null } = {}) {
1369
+ if (!walkthroughData) return [];
1370
+ const article = walkthroughData.article || {};
1371
+ const title = String(article.title || '').trim();
1372
+ if (!title) return [];
1373
+ const introTagline = String(article.introduction || '').trim();
1374
+ const outroTagline = String(article.summary || '').trim();
1375
+ // The account's uploaded branding logo (if seeded) wins over the
1376
+ // repo-detected one. Theme follows whichever logo is used.
1377
+ const logoUri = cardLogoDataUri(cardLogoSource);
1378
+ const logo = logoUri ? null : findLogo(filenameLookup);
1379
+ let theme;
1380
+ let logoLabel;
1381
+ if (logoUri) {
1382
+ theme = (await logoIsDark(logoUri)) === false ? 'dark' : 'light';
1383
+ logoLabel = 'branding';
1384
+ } else {
1385
+ theme = await pickCardTheme(logo, filenameLookup);
1386
+ logoLabel = logo || 'none';
1387
+ }
1388
+
1389
+ let backgroundImage = null;
1390
+ let logoPlate = null;
1391
+ if (cardBgSource && isFile(cardBgSource)) {
1392
+ const dest = path.join(htmlDir, CARD_BG_FILENAME);
1393
+ // Skip the copy if dest is already the same file (e.g. running in place).
1394
+ if (!sameFile(cardBgSource, dest)) fs.copyFileSync(cardBgSource, dest);
1395
+ backgroundImage = CARD_BG_FILENAME;
1396
+ // The image covers the theme's flat background, so the logo-derived
1397
+ // theme no longer guarantees contrast. Re-pick the text theme from
1398
+ // the image's own luminance, and plate the logo when its ink sits
1399
+ // near the background's (green logo on a green gradient). Opaque
1400
+ // rasters skip the plate — they carry their own background box.
1401
+ const bgLum = await cardBgLuminance(dest);
1402
+ if (bgLum !== null) {
1403
+ theme = bgLum < 0.55 ? 'dark' : 'light';
1404
+ const ink = await logoInkStats(logoUri || (logo ? filenameLookup[logo] : null));
1405
+ if (ink !== null) {
1406
+ const [inkLum, transparent] = ink;
1407
+ if (transparent && Math.abs(inkLum - bgLum) < 0.35) logoPlate = inkLum < 0.55 ? 'light' : 'dark';
1408
+ }
1409
+ }
1410
+ }
1411
+ const plateNote = logoPlate ? `, ${logoPlate} logo plate` : '';
1412
+ const bgNote = backgroundImage ? `, bg image${plateNote}` : '';
1413
+ log(` title cards: ${theme} theme (logo: ${logoLabel}${bgNote})`);
1414
+
1415
+ const shared = { title, logoFilename: logo, backgroundImage, theme, logoDataUri: logoUri, logoPlate };
1416
+ fs.writeFileSync(path.join(htmlDir, INTRO_FILENAME), buildCardHtml({ kind: 'intro', tagline: introTagline, ...shared }));
1417
+ fs.writeFileSync(path.join(htmlDir, OUTRO_FILENAME), buildCardHtml({ kind: 'outro', tagline: outroTagline, ...shared }));
1418
+ return [INTRO_FILENAME, OUTRO_FILENAME];
1419
+ }
1420
+
1421
+ async function processDir(htmlDir, cssFile, imagesJson, actionsJson, brandingJson = null, generatedImagesJson = null, refreshJit = false) {
1422
+ const cssContent = loadCss(cssFile);
1423
+ const images = loadImages(imagesJson);
1424
+ const generatedImages = loadGeneratedImages(generatedImagesJson);
1425
+ const walkthroughData = loadWalkthroughSteps(actionsJson);
1426
+ const branding = loadBranding(brandingJson);
1427
+
1428
+ if (!isDir(htmlDir)) {
1429
+ log(`Error: ${htmlDir} is not a directory`);
1430
+ return 1;
1431
+ }
1432
+
1433
+ const projectDir = process.cwd();
1434
+ const cacheDir = brandingJson ? path.dirname(path.resolve(brandingJson)) : htmlDir;
1435
+ const compiledCssPath = branding && truthy(branding.compiled_css_path) ? branding.compiled_css_path : null;
1436
+
1437
+ // Inline relative url() assets in the compiled CSS so background-image
1438
+ // classes render their real artwork instead of a blank region.
1439
+ if (compiledCssPath) {
1440
+ const outCss = path.join(htmlDir, compiledCssPath);
1441
+ if (isFile(outCss)) {
1442
+ const inlined = inlineCssAssetUrls(outCss, projectDir);
1443
+ if (inlined) {
1444
+ log(` inlined ${inlined} CSS url() asset(s) into ${compiledCssPath}`);
1445
+ // Persist back to the project cache so future runs reuse the inlined version.
1446
+ const cacheCss = path.join(cacheDir, compiledCssPath);
1447
+ if (isFile(cacheCss) && path.resolve(cacheCss) !== path.resolve(outCss)) {
1448
+ try { fs.writeFileSync(cacheCss, fs.readFileSync(outCss, 'utf8')); }
1449
+ catch (error) { log(`WARNING: could not persist inlined CSS to cache: ${error.message}`); }
1450
+ }
1451
+ }
1452
+ }
1453
+ }
1454
+
1455
+ // Render-time JIT (deterministic — not left to the model's bash block): when the
1456
+ // project has a cached Tailwind `css_build` recipe, locally compile the mockups'
1457
+ // OWN classes into <htmlDir>/mockup.css via jit_mockup_css.js, so arbitrary /
1458
+ // responsive / plugin classes render without the flaky Play CDN. Runs from here
1459
+ // so it always fires. Skips cleanly (no mockup.css) when there's no recipe.
1460
+ const mockupCssFile = path.join(htmlDir, 'mockup.css');
1461
+ let recipe = null;
1462
+ const projectMap = path.join(cacheDir, 'project_map.json');
1463
+ try { recipe = readJson(projectMap).css_build ?? null; } catch { /* no recipe */ }
1464
+ const jitPath = path.join(__dirname, 'jit_mockup_css.js');
1465
+ // Progressive article rendering calls the injector after each newly authored
1466
+ // mockup. Rebuild the JIT bundle so classes introduced by that step are
1467
+ // available immediately; the normal final/batch path keeps its cached bundle.
1468
+ if (refreshJit && isFile(mockupCssFile)) {
1469
+ try { fs.rmSync(mockupCssFile); }
1470
+ catch (error) { log(`WARNING: could not refresh ${mockupCssFile}: ${error.message}`); }
1471
+ }
1472
+ if (truthy(recipe) && !isFile(mockupCssFile) && isFile(jitPath)) {
1473
+ const jitLog = path.join(htmlDir, 'jit.log');
1474
+ const r = spawnSync(process.execPath, [jitPath, htmlDir, projectDir, projectMap], { timeout: 180000, encoding: 'utf8' });
1475
+ if (r.error) {
1476
+ // Never let the JIT break injection.
1477
+ fs.writeFileSync(jitLog, `jit invocation EXCEPTION: ${r.error.message}\ncwd=${process.cwd()}\nproject_dir=${projectDir}\n`);
1478
+ log(` jit invocation error (keeping CDN/compiled CSS): ${r.error.message}`);
1479
+ } else {
1480
+ fs.writeFileSync(jitLog, `cwd=${process.cwd()}\nproject_dir=${projectDir}\njit_path=${jitPath}\n`
1481
+ + `rc=${r.status}\n--- stdout ---\n${r.stdout}\n--- stderr ---\n${r.stderr}\n`);
1482
+ if (r.stderr) log(r.stderr);
1483
+ }
1484
+ }
1485
+ const jitCss = isFile(mockupCssFile) ? 'mockup.css' : null;
1486
+ if (jitCss) log(' using local JIT mockup.css (dropping Tailwind CDN)');
1487
+ // Treat a project with a Tailwind recipe as Tailwind-family for the CDN fallback,
1488
+ // so a framework like "phoenix" still gets coverage if the JIT couldn't run.
1489
+ const twHint = Boolean(isObject(recipe) && truthy(recipe.tw_version));
1490
+
1491
+ const htmlFiles = fs.readdirSync(htmlDir).sort();
1492
+
1493
+ // Desktop mockups: bridge body-scoped app backgrounds into the framed window
1494
+ // interior. Gated on the mockups actually using the desktop frame, so web /
1495
+ // terminal / mobile output is untouched.
1496
+ let bridgeCss = '';
1497
+ const usesDesktopFrame = htmlFiles.some(f => f.endsWith('.html') && readFileOrEmpty(path.join(htmlDir, f)).includes('desktop-window-content'));
1498
+ if (usesDesktopFrame) {
1499
+ const bridgeSources = [cssContent || ''];
1500
+ if (compiledCssPath) bridgeSources.push(readFileOrEmpty(path.join(htmlDir, compiledCssPath)));
1501
+ bridgeCss = desktopWindowBgBridge(bridgeSources.join('\n'));
1502
+ if (bridgeCss) {
1503
+ log(` desktop window-bg bridge: ${bridgeCss.split('{').length - 1} body-background rule(s) re-emitted onto .desktop-window-content`);
1504
+ }
1505
+ }
1506
+
1507
+ // CSS text the walkthrough root-offset bridge scans: css_content plus the
1508
+ // compiled CSS file sitting next to the mockups (the walkthrough SKILL passes
1509
+ // css_file="" and the compiled CSS rides --branding's compiled_css_path).
1510
+ let offsetBridgeCss = cssContent || '';
1511
+ if (compiledCssPath) offsetBridgeCss += '\n' + readFileOrEmpty(path.join(htmlDir, compiledCssPath));
1512
+
1513
+ // Sentinel so a re-run (STEP 4 lint-retry) can detect already-injected HTML
1514
+ // and skip re-injecting — otherwise the whole block stacks a 2nd/3rd time.
1515
+ // Prepended to the block itself so it's ALWAYS present regardless of which
1516
+ // optional sub-blocks buildCssHeadBlock emits.
1517
+ let block = INJECTED_SENTINEL + '\n' + buildCssHeadBlock(cssContent, branding, { jitCss, twHint, extraCss: bridgeCss });
1518
+
1519
+ // Embed real icon fonts when the mockups use icon classes (fa-*/bi-*).
1520
+ const iconCss = await ensureIconFonts(htmlDir, cacheDir);
1521
+ if (iconCss) block += `\n <link rel="stylesheet" href="${iconCss}">`;
1522
+ const filenameLookup = buildFilenameLookup(images);
1523
+ const replaceImg = makeReplaceImg(images, filenameLookup);
1524
+
1525
+ const walkthroughSteps = walkthroughData ? walkthroughData.steps : null;
1526
+ let cardsWritten = [];
1527
+ // Account branding drives the cards: card_bg.png (a brand-colour gradient
1528
+ // rendered from the account's colours, or a user upload) as the background,
1529
+ // and card_logo.<ext> (the account's uploaded logo) overlaid. Both are
1530
+ // resolved next to branding.json; when absent the cards fall back to the
1531
+ // CSS gradient theme + repo logo.
1532
+ const cardBgSource = walkthroughData ? findCardBg(brandingJson) : null;
1533
+ const cardLogoSource = walkthroughData ? findCardLogo(brandingJson) : null;
1534
+ try {
1535
+ if (walkthroughData) cardsWritten = await writeTitleCards(htmlDir, walkthroughData, filenameLookup, { cardBgSource, cardLogoSource });
1536
+ } finally {
1537
+ await closeRaster();
1538
+ }
1539
+
1540
+ let count = 0;
1541
+ const spriteCache = {};
1542
+ for (const fname of fs.readdirSync(htmlDir).sort()) {
1543
+ if (!(fname.startsWith('step_') || fname.startsWith('block_')) || !fname.endsWith('.html')) continue;
1544
+ const fpath = path.join(htmlDir, fname);
1545
+ let html = fs.readFileSync(fpath, 'utf8');
1546
+
1547
+ // Preserve the pre-injection source for the screen library. Numbered
1548
+ // step mockups only (not the auto-generated intro/outro cards), and
1549
+ // only when the CSS placeholder is still present -- STEP 4 re-runs
1550
+ // (lint retry) on already-injected HTML must not clobber the backup.
1551
+ // ".html.pre" deliberately does not end in ".html" so this loop's
1552
+ // filter never re-processes it.
1553
+ if (/^(?:step_\d+|block_[a-z0-9]+(?:-[a-z0-9]+)*)\.html$/.test(fname) && html.includes('<!-- INJECT_CSS -->')) {
1554
+ fs.writeFileSync(fpath + '.pre', html);
1555
+ }
1556
+
1557
+ // Idempotent CSS injection. STEP 4's lint-retry re-runs processDir on
1558
+ // already-injected HTML (the model edits the injected step file to fix a
1559
+ // lint failure, then re-injects). The <!-- INJECT_CSS --> marker is gone
1560
+ // by then, so a naive injectCss() falls through to the </head> branch and
1561
+ // stamps a SECOND (then THIRD) copy of the whole block mid-document. If
1562
+ // our sentinel is already present, skip re-injecting.
1563
+ if (!html.includes(INJECTED_SENTINEL)) html = injectCss(html, block);
1564
+ if (iconCss) html = injectIconFontsMarker(html);
1565
+ html = inlineSvgSprites(html, projectDir, spriteCache);
1566
+ html = html.replace(/\{\{img:([^}]+)\}\}/g, replaceImg);
1567
+ html = html.replace(/\{\{generated:([a-z0-9]+(?:-[a-z0-9]+)*)\}\}/g,
1568
+ (match, id) => (Object.hasOwn(generatedImages, id) ? generatedImages[id] : match));
1569
+
1570
+ const isCard = fname === INTRO_FILENAME || fname === OUTRO_FILENAME;
1571
+ if (walkthroughSteps !== null) {
1572
+ if (isCard) {
1573
+ html = injectCardLoader(html);
1574
+ } else {
1575
+ const idx = stepIndexFromFilename(fname);
1576
+ if (idx !== null && idx < walkthroughSteps.length) {
1577
+ html = injectStage(html, fixedOffsetBridgeCss(html, offsetBridgeCss));
1578
+ // Only step_0 gets the radial wipe — used as the cinematic
1579
+ // reveal out of the intro card. Step-to-step transitions
1580
+ // keep the existing brand-wash cross-fade.
1581
+ if (idx === 0) html = injectStepZeroWipe(html);
1582
+ }
1583
+ }
1584
+ }
1585
+
1586
+ fs.writeFileSync(fpath, html);
1587
+ count += 1;
1588
+ log(` injected: ${fname}`);
1589
+ }
1590
+
1591
+ let styleLabel;
1592
+ if (compiledCssPath) styleLabel = `compiled CSS (${branding.framework || 'detected'})`;
1593
+ else if (branding && truthy(branding.framework_cdn)) styleLabel = `${branding.framework ?? 'None'} CDN`;
1594
+ else if (cssContent) styleLabel = 'compiled CSS (legacy --css)';
1595
+ else styleLabel = 'Tailwind CDN';
1596
+ const extras = [];
1597
+ if (walkthroughSteps !== null) extras.push(`${walkthroughSteps.length} step stage(s)`);
1598
+ if (cardsWritten.length) extras.push(`${cardsWritten.length} title card(s)${cardBgSource ? ' with AI background' : ''}`);
1599
+ if (branding) {
1600
+ if (truthy(branding.root_css)) extras.push('root vars');
1601
+ if (truthy(branding.default_colors)) extras.push(`${Object.keys(branding.default_colors).length} brand colours`);
1602
+ if (truthy(branding.google_fonts)) extras.push(`${branding.google_fonts.length} font(s)`);
1603
+ }
1604
+ if (Object.keys(generatedImages).length) extras.push(`${Object.keys(generatedImages).length} generated image(s)`);
1605
+ const extrasStr = extras.length ? `, ${extras.join(', ')}` : '';
1606
+ log(`${count} file(s) processed (${styleLabel}, ${Object.keys(images).length} image entries${extrasStr})`);
1607
+ return 0;
1608
+ }
1609
+
1610
+ /**
1611
+ * Stamp the running skills version + this file's content hash to stderr, so a
1612
+ * debug bundle reveals exactly which code ran. The hash is authoritative (it
1613
+ * works even when the VERSION file isn't in a vendored tree).
1614
+ */
1615
+ function logSkillsVersion() {
1616
+ const here = path.dirname(fs.realpathSync(__filename));
1617
+ let version = 'unknown';
1618
+ for (const candidate of [path.join(here, '..', '..', 'VERSION'), path.join(here, '..', 'VERSION')]) {
1619
+ try {
1620
+ const v = fs.readFileSync(candidate, 'utf8').trim();
1621
+ if (v) { version = v; break; }
1622
+ } catch { /* try the next */ }
1623
+ }
1624
+ let sha = '?';
1625
+ try { sha = crypto.createHash('sha256').update(fs.readFileSync(fs.realpathSync(__filename))).digest('hex').slice(0, 8); }
1626
+ catch { /* unreadable */ }
1627
+ log(`rtfm-skills inject_assets.js — v${version} (sha ${sha})`);
1628
+ }
1629
+
1630
+ const USAGE = 'usage: inject_assets.js <html_dir> <css_file_or_empty> <images_json> [--walkthrough <actions_json>] '
1631
+ + '[--branding <branding_json>] [--generated-images <generated_images_json>] [--refresh-jit]';
1632
+
1633
+ function parseArgs(argv) {
1634
+ const valued = { '--walkthrough': 'walkthrough', '--branding': 'branding', '--generated-images': 'generatedImages' };
1635
+ const options = { walkthrough: null, branding: null, generatedImages: null, refreshJit: false };
1636
+ const positional = [];
1637
+ for (let i = 0; i < argv.length; i++) {
1638
+ const arg = argv[i];
1639
+ const eq = arg.indexOf('=');
1640
+ const name = arg.startsWith('--') && eq > 0 ? arg.slice(0, eq) : arg;
1641
+ if (name === '--refresh-jit' && eq < 0) options.refreshJit = true;
1642
+ else if (Object.hasOwn(valued, name)) {
1643
+ const value = eq > 0 ? arg.slice(eq + 1) : argv[++i];
1644
+ if (value === undefined) throw new Error(`argument ${name}: expected one argument`);
1645
+ options[valued[name]] = value;
1646
+ } else if (arg === '--help' || arg === '-h') {
1647
+ process.stdout.write(USAGE + '\n');
1648
+ process.exit(0);
1649
+ } else if (arg.startsWith('--') && arg.length > 2) {
1650
+ throw new Error(`unrecognized arguments: ${arg}`);
1651
+ } else {
1652
+ positional.push(arg);
1653
+ }
1654
+ }
1655
+ if (positional.length !== 3) throw new Error(positional.length < 3 ? 'the following arguments are required: html_dir, css_file, images_json' : `unrecognized arguments: ${positional.slice(3).join(' ')}`);
1656
+ return { positional, options };
1657
+ }
1658
+
1659
+ async function main(argv) {
1660
+ logSkillsVersion();
1661
+ let parsed;
1662
+ try { parsed = parseArgs(argv); }
1663
+ catch (error) {
1664
+ log(`${USAGE}\ninject_assets.js: error: ${error.message}`);
1665
+ return 2;
1666
+ }
1667
+ const [htmlDir, cssFile, imagesJson] = parsed.positional;
1668
+ const { walkthrough, branding, generatedImages, refreshJit } = parsed.options;
1669
+ return processDir(htmlDir, cssFile, imagesJson, walkthrough, branding, generatedImages, refreshJit);
1670
+ }
1671
+
1672
+ if (require.main === module) {
1673
+ main(process.argv.slice(2)).then(code => { process.exitCode = code; }, error => {
1674
+ log(error.stack || String(error));
1675
+ process.exitCode = 1;
1676
+ });
1677
+ }
1678
+
1679
+ module.exports = {
1680
+ processDir,
1681
+ buildCssHeadBlock,
1682
+ desktopWindowBgBridge,
1683
+ fixedOffsetBridgeCss,
1684
+ fontDescriptorToUrl,
1685
+ firstGoogleFontFamily,
1686
+ inlineSvgSprites,
1687
+ injectCss,
1688
+ injectStage,
1689
+ findLogo,
1690
+ buildCardHtml,
1691
+ };