@kaydash9999/banker-plugins 0.15.2 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +65 -0
  4. package/README.md +31 -9
  5. package/codex/manifest.json +6 -3
  6. package/codex/transform-matrix.md +2 -0
  7. package/commands/progress.md +10 -0
  8. package/commands/setup.md +1 -1
  9. package/hooks/graceful-pause.mjs +31 -71
  10. package/hooks/hooks.json +11 -1
  11. package/hooks/progress-fallback.mjs +10 -0
  12. package/hooks/progress.mjs +427 -0
  13. package/hooks/register.mjs +82 -5
  14. package/package.json +1 -1
  15. package/skills/3d-intro-build/SKILL.md +238 -122
  16. package/skills/3d-intro-build/references/NOTICE.md +2 -1
  17. package/skills/3d-intro-build/references/assemble.mjs +164 -108
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +204 -44
  19. package/skills/3d-intro-build/references/curate.mjs +573 -267
  20. package/skills/3d-intro-build/references/intro-fixes.css +61 -0
  21. package/skills/3d-intro-build/references/panel-glass.css +55 -0
  22. package/skills/3d-intro-build/references/preview-lib.mjs +256 -0
  23. package/skills/3d-intro-build/references/prompts.md +32 -9
  24. package/skills/3d-intro-build/references/serve.mjs +111 -103
  25. package/skills/3d-intro-build/references/step-nav.css +59 -0
  26. package/skills/3d-intro-build/references/step-nav.js +503 -0
  27. package/skills/3d-intro-build/references/verify-intro.mjs +474 -0
  28. package/skills/3d-intro-build/references/verify.md +45 -0
  29. package/skills/3d-intro-build/references/video-pool.mjs +817 -0
  30. package/skills/3d-intro-setup/SKILL.md +120 -47
  31. package/skills/3d-intro-setup/references/azure-adapter.mjs +204 -44
  32. package/skills/3d-intro-setup/references/video-pool.mjs +817 -0
  33. package/skills/motion-graphic-make/SKILL.md +73 -28
  34. package/skills/motion-graphic-make/references/curate.mjs +607 -0
  35. package/skills/motion-graphic-make/references/preview-lib.mjs +256 -0
  36. package/skills/motion-graphic-make/references/serve.mjs +131 -0
  37. package/skills/omc-patch/SKILL.md +143 -0
  38. package/skills/omc-patch/scripts/omc-patch.mjs +577 -0
  39. package/skills/remains/SKILL.md +125 -0
  40. package/skills/remains/scripts/boxes.mjs +448 -0
  41. package/skills/trouble-shooting/SKILL.md +50 -0
@@ -2,32 +2,45 @@
2
2
  /*
3
3
  * First-party assembler for the 3d-intro-build skill.
4
4
  *
5
- * Takes a project directory that already holds the generated stills + forward-chained clips
6
- * (+ an intro.json manifest describing them) and produces a self-contained preview site:
5
+ * Takes a project directory that already holds the generated stills + clips (+ an intro.json
6
+ * manifest describing them) and produces the intro page:
7
7
  * <outDir>/
8
- * index.html (written from index-template.html, wired to the manifest)
8
+ * index.html (head of index-template.html; no inline script or style)
9
9
  * scrub-engine.js (copied verbatim from references/)
10
+ * scrub-engine.css (the CSS the engine injects, taken from scrub-engine.js unchanged)
11
+ * scrub-engine.LICENSE.txt (scroll-world's MIT notice, which ships with the engine files)
12
+ * theme.css (the template's --sw-* tokens, set from the manifest theme)
13
+ * intro-fixes.css (layout, button text and overflow fixes for the engine, every page)
14
+ * intro.js (the mountScrollWorld config and call)
15
+ * panel-glass.css (only with "panel": "glass")
16
+ * step-nav.js/.css (only with "stepNav": scene-by-scene navigation)
10
17
  * assets/<id>.<ext> (scene posters/stills)
11
- * assets/vid/<id>.mp4 (per-scene dive clips)
12
- * assets/vid/connN.mp4 (optional connectors)
13
- * Serve <outDir> with serve.mjs to preview.
18
+ * assets/vid/<id>.mp4 (per-scene clips: dives, or the short holds of the hold-and-flight mode)
19
+ * assets/vid/connN.mp4 (optional connectors: the flights between scenes)
20
+ * Nothing inline, so the page runs under a strict Content-Security-Policy (no 'unsafe-inline';
21
+ * media-src needs blob:). The engine still tries to inject its <style>: a site with a CSP adds the
22
+ * reported `styleHash` to style-src to keep that refusal out of the console (scrub-engine.css
23
+ * carries the same rules either way). Serve <outDir> with serve.mjs (--csp to check under a policy).
14
24
  *
15
- * Runtime: Node >=18 builtins ONLY (node:fs / node:path / node:url). No external deps,
16
- * no shell — cross-platform (path.join / path.sep; asset URLs are always posix '/').
25
+ * Runtime: Node >=18 builtins ONLY (node:fs / node:path / node:url / node:crypto). No external
26
+ * deps, no shell; cross-platform (path.join / path.sep; asset URLs are always posix '/').
17
27
  *
18
28
  * The manifest (intro.json) mirrors the mountScrollWorld config, but its `still` / `clip` /
19
29
  * `connectors` values are paths to SOURCE files relative to projectDir; this assembler copies
20
30
  * them into assets/ and rewrites the config to the copied relative URLs. Shape:
21
31
  *
22
32
  * {
23
- * "pageTitle": "BRAND — the world of SUBJECT", // optional (else derived)
33
+ * "pageTitle": "BRAND - the world of SUBJECT", // optional (else derived)
24
34
  * "pageDescription": "Scroll to fly through ...", // optional (else derived)
35
+ * "lang": "ko", // optional <html lang> (template: en)
25
36
  * "brand": { "name": "BRAND", "href": "#top" }, // optional
26
37
  * "cta": { "label": "Order now", "href": "#finale" }, // optional top-bar CTA
27
38
  * "hint": "scroll to fly in", // optional
28
39
  * "theme": { "bg":"#F5EDE0","ink":"#241d2b","inkSoft":"#6a6072","accent":"#9B7EBD" }, // optional
29
40
  * "diveScroll": 1.3, "connScroll": 0.9, "crossfade": 0.12, // optional
30
41
  * "nav": true, "atmosphere": true, // optional (only false is emitted)
42
+ * "panel": "glass", // optional frosted-glass copy panel
43
+ * "stepNav": { "end": "#main", ... }, // optional, see step-nav.js for the options
31
44
  * "sections": [ // required, >= 1, in order
32
45
  * { "id":"sceneA", "label":"Scene A",
33
46
  * "still":"still-1.png", "clip":"dive-1.mp4", // required (relative to projectDir)
@@ -47,6 +60,7 @@
47
60
  * outDir output site dir (default: <projectDir>/site)
48
61
  * --manifest manifest path (default: <projectDir>/intro.json)
49
62
  */
63
+ import crypto from 'node:crypto';
50
64
  import fs from 'node:fs';
51
65
  import path from 'node:path';
52
66
  import { fileURLToPath } from 'node:url';
@@ -73,6 +87,53 @@ const extOf = (p) => path.extname(String(p)).toLowerCase();
73
87
 
74
88
  // ---- config build -----------------------------------------------------------
75
89
 
90
+ // The copy and pacing fields a section carries over to the engine as they are.
91
+ const SECTION_FIELDS = ['accent', 'eyebrow', 'title', 'body', 'scroll', 'linger', 'cta'];
92
+ // Where each media field of a section is copied (the source extension is kept).
93
+ const MEDIA = [
94
+ ['still', (id, src) => `assets/${id}${extOf(src)}`],
95
+ ['stillMobile', (id, src) => `assets/${id}-m${extOf(src)}`],
96
+ ['clip', (id, src) => `assets/vid/${id}${extOf(src) || '.mp4'}`],
97
+ ['clipMobile', (id, src) => `assets/vid/${id}-m${extOf(src) || '.mp4'}`],
98
+ ];
99
+
100
+ function requireFields(s, i) {
101
+ if (!s || !s.id) throw new Error(`assemble: section[${i}] missing "id"`);
102
+ for (const k of ['still', 'clip']) if (!s[k]) throw new Error(`assemble: section[${i}] (${s.id}) missing "${k}"`);
103
+ }
104
+
105
+ // One section of the engine config, its still and clips copied into assets/.
106
+ function sectionOut(s, i, copy) {
107
+ requireFields(s, i);
108
+ const out = { id: s.id, label: s.label || s.id };
109
+ for (const [k, dest] of MEDIA) if (s[k]) out[k] = copy(s[k], dest(s.id, s[k]));
110
+ for (const k of SECTION_FIELDS) if (s[k] != null && s[k] !== '') out[k] = s[k];
111
+ if (Array.isArray(s.tags) && s.tags.length) out.tags = s.tags;
112
+ return out;
113
+ }
114
+
115
+ // Connectors: one per gap between sections, in order; a null entry means "crossfade directly".
116
+ function connectorsOut(list, n, suffix, copy) {
117
+ const src = Array.isArray(list) ? list : [];
118
+ return Array.from({ length: Math.max(0, n - 1) }, (_, i) => (src[i]
119
+ ? copy(src[i], `assets/vid/conn${i + 1}${suffix}${extOf(src[i]) || '.mp4'}`) : null));
120
+ }
121
+
122
+ // The config in the shape mountScrollWorld reads (see scrub-engine.js).
123
+ function engineConfig(m, sections, connectors, connectorsMobile) {
124
+ const config = {};
125
+ for (const k of ['brand', 'cta']) if (m[k]) config[k] = m[k];
126
+ config.hint = m.hint || 'scroll to fly in';
127
+ config.diveScroll = m.diveScroll ?? 1.3;
128
+ config.connScroll = m.connScroll ?? 0.9;
129
+ if (m.crossfade != null) config.crossfade = m.crossfade;
130
+ for (const k of ['nav', 'atmosphere']) if (m[k] === false) config[k] = false;
131
+ config.sections = sections;
132
+ config.connectors = connectors;
133
+ if (connectorsMobile.some(Boolean)) config.connectorsMobile = connectorsMobile;
134
+ return config;
135
+ }
136
+
76
137
  /**
77
138
  * Build the exact mountScrollWorld config object from a manifest, copying every referenced
78
139
  * asset into outDir/assets. Returns { config, copied:[destPosix,...] }.
@@ -80,120 +141,118 @@ const extOf = (p) => path.extname(String(p)).toLowerCase();
80
141
  function buildConfig(manifest, projectDir, outDir) {
81
142
  const sections = Array.isArray(manifest.sections) ? manifest.sections : [];
82
143
  if (sections.length === 0) throw new Error('assemble: manifest.sections must have at least one section');
83
-
84
144
  const copied = [];
85
- const track = (destPosix) => { copied.push(destPosix); return destPosix; };
86
-
87
- const sectionsOut = sections.map((s, i) => {
88
- if (!s || !s.id) throw new Error(`assemble: section[${i}] missing "id"`);
89
- if (!s.still) throw new Error(`assemble: section[${i}] (${s.id}) missing "still"`);
90
- if (!s.clip) throw new Error(`assemble: section[${i}] (${s.id}) missing "clip"`);
91
-
92
- const out = { id: s.id, label: s.label || s.id };
93
-
94
- // poster still (keep source extension so png/webp/jpg all work)
95
- out.still = track(copyAsset(projectDir, outDir, s.still, `assets/${s.id}${extOf(s.still)}`));
96
- if (s.stillMobile) {
97
- out.stillMobile = track(copyAsset(projectDir, outDir, s.stillMobile, `assets/${s.id}-m${extOf(s.stillMobile)}`));
98
- }
99
-
100
- // dive clip
101
- out.clip = track(copyAsset(projectDir, outDir, s.clip, `assets/vid/${s.id}${extOf(s.clip) || '.mp4'}`));
102
- if (s.clipMobile) {
103
- out.clipMobile = track(copyAsset(projectDir, outDir, s.clipMobile, `assets/vid/${s.id}-m${extOf(s.clipMobile) || '.mp4'}`));
104
- }
105
-
106
- if (s.accent) out.accent = s.accent;
107
- if (s.eyebrow) out.eyebrow = s.eyebrow;
108
- if (s.title) out.title = s.title;
109
- if (s.body) out.body = s.body;
110
- if (Array.isArray(s.tags) && s.tags.length) out.tags = s.tags;
111
- if (s.scroll != null) out.scroll = s.scroll;
112
- if (s.linger != null) out.linger = s.linger;
113
- if (s.cta) out.cta = s.cta; // last section only (engine renders it wherever present)
114
- return out;
115
- });
116
-
117
- // connectors: length === sections.length - 1; null entries mean "crossfade directly".
118
- const srcConn = Array.isArray(manifest.connectors) ? manifest.connectors : [];
119
- const srcConnM = Array.isArray(manifest.connectorsMobile) ? manifest.connectorsMobile : [];
120
- const connectorsOut = [];
121
- const connectorsMobileOut = [];
122
- for (let i = 0; i < sectionsOut.length - 1; i++) {
123
- const c = srcConn[i];
124
- connectorsOut.push(c ? track(copyAsset(projectDir, outDir, c, `assets/vid/conn${i + 1}${extOf(c) || '.mp4'}`)) : null);
125
- const cm = srcConnM[i];
126
- connectorsMobileOut.push(cm ? track(copyAsset(projectDir, outDir, cm, `assets/vid/conn${i + 1}-m${extOf(cm) || '.mp4'}`)) : null);
127
- }
128
-
129
- // Assemble in the shape mountScrollWorld reads (see scrub-engine.js).
130
- const config = {};
131
- if (manifest.brand) config.brand = manifest.brand;
132
- if (manifest.cta) config.cta = manifest.cta;
133
- config.hint = manifest.hint || 'scroll to fly in';
134
- config.diveScroll = manifest.diveScroll != null ? manifest.diveScroll : 1.3;
135
- config.connScroll = manifest.connScroll != null ? manifest.connScroll : 0.9;
136
- if (manifest.crossfade != null) config.crossfade = manifest.crossfade;
137
- if (manifest.nav === false) config.nav = false;
138
- if (manifest.atmosphere === false) config.atmosphere = false;
139
- config.sections = sectionsOut;
140
- config.connectors = connectorsOut;
141
- if (connectorsMobileOut.some(Boolean)) config.connectorsMobile = connectorsMobileOut;
142
-
143
- return { config, copied };
145
+ const copy = (src, dest) => { copied.push(copyAsset(projectDir, outDir, src, dest)); return dest; };
146
+ const sectionsOut = sections.map((s, i) => sectionOut(s, i, copy));
147
+ const connectors = connectorsOut(manifest.connectors, sectionsOut.length, '', copy);
148
+ const connectorsMobile = connectorsOut(manifest.connectorsMobile, sectionsOut.length, '-m', copy);
149
+ return { config: engineConfig(manifest, sectionsOut, connectors, connectorsMobile), copied };
144
150
  }
145
151
 
146
- // ---- index.html generation --------------------------------------------------
152
+ // ---- page generation -------------------------------------------------------
153
+
154
+ const PANELS = { glass: 'panel-glass.css' };
155
+
156
+ /**
157
+ * The CSS the engine injects into <head>, exactly: the `css` template literal wrapped in @layer sw.
158
+ * A template literal turns CRLF and CR into LF, so a checkout with CRLF line ends (git on Windows)
159
+ * is read the same way and gives the same hash.
160
+ */
161
+ export function engineCss(rawSource) {
162
+ const engineSource = String(rawSource).replace(/\r\n?/g, '\n');
163
+ const open = engineSource.indexOf('const css = `');
164
+ const close = open === -1 ? -1 : engineSource.indexOf('`;', open + 13);
165
+ if (close === -1) throw new Error('assemble: scrub-engine.js no longer holds `const css = `...``');
166
+ const css = engineSource.slice(open + 13, close);
167
+ if (/\\|\$\{/.test(css)) throw new Error('assemble: the engine CSS has escapes or substitutions; copy it by hand');
168
+ return '@layer sw {\n' + css + '\n}';
169
+ }
147
170
 
148
- // Replace the value of a --sw-* custom property in the template's :root block, keeping comments.
149
- function setToken(html, name, value) {
150
- if (!value) return html;
171
+ // Replace the value of a --sw-* custom property, keeping comments.
172
+ function setToken(css, name, value) {
173
+ if (!value) return css;
151
174
  const re = new RegExp(`(--${name}\\s*:\\s*)[^;]+;`);
152
- return html.replace(re, `$1${value};`);
175
+ return css.replace(re, `$1${value};`);
176
+ }
177
+
178
+ // The template's inline theme block, with the manifest's colours, as a stylesheet.
179
+ function themeCss(styleInner, theme = {}) {
180
+ let css = setToken(styleInner, 'sw-bg', theme.bg);
181
+ css = setToken(css, 'sw-ink', theme.ink);
182
+ css = setToken(css, 'sw-ink-soft', theme.inkSoft);
183
+ css = setToken(css, 'sw-accent', theme.accent);
184
+ return css.replace(/^\n+/, '').replace(/\s*$/, '\n');
185
+ }
186
+
187
+ function stylesheets(manifest) {
188
+ return ['scrub-engine.css', 'theme.css', 'intro-fixes.css', manifest.panel ? PANELS[manifest.panel] : null,
189
+ manifest.stepNav ? 'step-nav.css' : null].filter(Boolean);
153
190
  }
154
191
 
155
- /** Render index.html from index-template.html for the given config + manifest metadata. */
156
- function renderIndexHtml(template, config, manifest) {
192
+ /** index.html from the template's head: title, description and lang set, its inline style replaced by links. */
193
+ function renderIndexHtml(template, manifest) {
157
194
  const brandName = manifest.brand?.name || 'BRAND';
158
195
  const subject = manifest.subject || 'SUBJECT';
159
- const title = manifest.pageTitle || `${brandName} — the world of ${subject}`;
196
+ const title = manifest.pageTitle || `${brandName} - the world of ${subject}`;
160
197
  const desc = manifest.pageDescription || `Scroll to fly through the world of ${brandName.replace(/\.+$/, '')}.`;
161
-
162
- // Everything up to the engine <script> tag is the head+body-open; regenerate the tail.
163
198
  const marker = '<script src="scrub-engine.js">';
164
199
  const idx = template.indexOf(marker);
165
200
  if (idx === -1) throw new Error('assemble: index-template.html missing the scrub-engine.js script tag');
166
201
  let head = template.slice(0, idx).replace(/\s*$/, '\n');
167
-
168
202
  head = head.replace(/<title>[^<]*<\/title>/, `<title>${escHtml(title)}</title>`);
169
203
  head = head.replace(/(<meta name="description" content=")[^"]*(")/, `$1${escAttr(desc)}$2`);
170
- const t = manifest.theme || {};
171
- head = setToken(head, 'sw-bg', t.bg);
172
- head = setToken(head, 'sw-ink', t.ink);
173
- head = setToken(head, 'sw-ink-soft', t.inkSoft);
174
- head = setToken(head, 'sw-accent', t.accent);
175
-
176
- const configJson = JSON.stringify(config, null, 2).replace(/\n/g, '\n ');
177
- const tail =
178
- ` <script src="scrub-engine.js"></script>
179
- <script>
180
- mountScrollWorld(document.getElementById('world'), ${configJson});
181
- </script>
182
- </body>
183
- </html>
204
+ if (manifest.lang) head = head.replace(/<html lang="[^"]*">/, `<html lang="${escAttr(manifest.lang)}">`);
205
+ const links = stylesheets(manifest).map((f) => ` <link rel="stylesheet" href="${f}" />`).join('\n');
206
+ head = head.replace(/[ \t]*<style>[\s\S]*?<\/style>\n?/, `${links}\n`);
207
+ const scripts = ['scrub-engine.js', manifest.stepNav ? 'step-nav.js' : null, 'intro.js'].filter(Boolean)
208
+ .map((f) => ` <script src="${f}"></script>`).join('\n');
209
+ return `${head}${scripts}\n</body>\n</html>\n`;
210
+ }
211
+
212
+ // The config and the mount, out of the page so no inline script is needed.
213
+ function introJs(config, nav) {
214
+ const json = (v) => JSON.stringify(v, null, 2).replace(/\n/g, '\n ');
215
+ const lines = nav
216
+ ? [`var nav = ${json(nav === true ? {} : nav)};`, 'if (window.IntroStepNav) window.IntroStepNav.prefetch(config, nav);',
217
+ 'mountScrollWorld(world, config);', 'if (window.IntroStepNav) window.IntroStepNav.attach(world, config, nav);']
218
+ : ['mountScrollWorld(world, config);'];
219
+ return `// Generated by assemble.mjs from intro.json. Kept out of index.html so the page runs under a
220
+ // Content-Security-Policy without 'unsafe-inline'.
221
+ (function () {
222
+ var world = document.getElementById('world');
223
+ var config = ${json(config)};
224
+ ${lines.map((l) => ` ${l}`).join('\n')}
225
+ })();
184
226
  `;
185
- return head + tail;
186
227
  }
187
228
 
188
229
  const escHtml = (s) => String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
189
230
  const escAttr = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
190
231
 
232
+ // Heads the copied engine rules: they are scroll-world's (MIT), whose notice ships beside them.
233
+ const ENGINE_NOTICE = '/* scroll-world (https://github.com/oso95/scroll-world), MIT License, (c) 2026 cyw.\n'
234
+ + ' The rules scrub-engine.js injects, copied unchanged. Notice: scrub-engine.LICENSE.txt */\n';
235
+
236
+ function writeStatic(out, manifest, template) {
237
+ const engineSource = fs.readFileSync(path.join(HERE, 'scrub-engine.js'), 'utf8');
238
+ fs.copyFileSync(path.join(HERE, 'scrub-engine.js'), path.join(out, 'scrub-engine.js'));
239
+ fs.copyFileSync(path.join(HERE, 'LICENSE'), path.join(out, 'scrub-engine.LICENSE.txt'));
240
+ const css = engineCss(engineSource);
241
+ fs.writeFileSync(path.join(out, 'scrub-engine.css'), ENGINE_NOTICE + css);
242
+ const style = /<style>([\s\S]*?)<\/style>/.exec(template);
243
+ fs.writeFileSync(path.join(out, 'theme.css'), themeCss(style ? style[1] : '', manifest.theme));
244
+ const copies = ['intro-fixes.css', manifest.panel ? PANELS[manifest.panel] : null,
245
+ manifest.stepNav ? 'step-nav.css' : null, manifest.stepNav ? 'step-nav.js' : null].filter(Boolean);
246
+ for (const f of copies) fs.copyFileSync(path.join(HERE, f), path.join(out, f));
247
+ return `'sha256-${crypto.createHash('sha256').update(css).digest('base64')}'`;
248
+ }
249
+
191
250
  // ---- public API -------------------------------------------------------------
192
251
 
193
252
  /**
194
- * Assemble a preview site. Copies scrub-engine.js + all referenced assets into outDir and
195
- * writes index.html wired to the exact mountScrollWorld config.
196
- * @returns {{ outDir:string, index:string, engine:string, assets:string[] }}
253
+ * Assemble the intro page. Copies the engine, its fixes and all referenced assets into outDir and
254
+ * writes index.html, intro.js and the stylesheets (see the header).
255
+ * @returns {{ outDir:string, index:string, engine:string, assets:string[], styleHash:string }}
197
256
  */
198
257
  export function assemble({ projectDir, outDir, manifestPath } = {}) {
199
258
  if (!projectDir) throw new Error('assemble: projectDir is required');
@@ -205,21 +264,17 @@ export function assemble({ projectDir, outDir, manifestPath } = {}) {
205
264
  'Write an intro.json in the project dir (see the header of assemble.mjs for its shape).');
206
265
  }
207
266
  const manifest = JSON.parse(fs.readFileSync(mPath, 'utf8'));
267
+ if (manifest.panel && !PANELS[manifest.panel]) throw new Error(`assemble: unknown panel "${manifest.panel}" (known: ${Object.keys(PANELS).join(', ')})`);
208
268
 
209
269
  fs.mkdirSync(out, { recursive: true });
210
270
  const { config, copied } = buildConfig(manifest, proj, out);
211
-
212
- // Vendored engine sits next to this script; copy it beside index.html.
213
- const engineSrc = path.join(HERE, 'scrub-engine.js');
214
- if (!fs.existsSync(engineSrc)) throw new Error(`assemble: scrub-engine.js not found at ${engineSrc}`);
215
- fs.copyFileSync(engineSrc, path.join(out, 'scrub-engine.js'));
216
-
217
271
  const template = fs.readFileSync(path.join(HERE, 'index-template.html'), 'utf8');
218
- const html = renderIndexHtml(template, config, manifest);
272
+ const styleHash = writeStatic(out, manifest, template);
273
+ fs.writeFileSync(path.join(out, 'intro.js'), introJs(config, manifest.stepNav));
219
274
  const indexPath = path.join(out, 'index.html');
220
- fs.writeFileSync(indexPath, html);
275
+ fs.writeFileSync(indexPath, renderIndexHtml(template, manifest));
221
276
 
222
- return { outDir: out, index: indexPath, engine: path.join(out, 'scrub-engine.js'), assets: copied };
277
+ return { outDir: out, index: indexPath, engine: path.join(out, 'scrub-engine.js'), assets: copied, styleHash };
223
278
  }
224
279
 
225
280
  // ---- CLI --------------------------------------------------------------------
@@ -244,7 +299,8 @@ if (invokedDirectly) {
244
299
  console.log(`assembled ${r.assets.length} asset(s) -> ${r.outDir}`);
245
300
  console.log(`index: ${r.index}`);
246
301
  console.log(`engine: ${r.engine}`);
247
- console.log(`\npreview: node ${path.join(HERE, 'serve.mjs')} ${r.outDir}`);
302
+ console.log(`CSP: the engine injects one <style>; to allow it add ${r.styleHash} to style-src (scrub-engine.css carries the same rules)`);
303
+ console.log(`\npreview: node ${path.join(HERE, 'serve.mjs')} ${r.outDir} [--csp strict]`);
248
304
  } catch (e) {
249
305
  console.error(String(e.message || e));
250
306
  process.exit(1);