@cs4alhaider/screenbook 0.1.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 (91) hide show
  1. package/CLAUDE.md +185 -0
  2. package/LICENSE +21 -0
  3. package/README.md +208 -0
  4. package/TESTING.md +54 -0
  5. package/app-bridge.js +131 -0
  6. package/bridge.js +598 -0
  7. package/cli/screenbook.js +290 -0
  8. package/cli/templates.js +139 -0
  9. package/core/browser.js +108 -0
  10. package/core/fsx.js +71 -0
  11. package/core/meta.js +94 -0
  12. package/core/project.js +482 -0
  13. package/core/serve.js +224 -0
  14. package/docs/README.md +21 -0
  15. package/docs/app-mode-guide.md +79 -0
  16. package/docs/authoring-guide.md +129 -0
  17. package/docs/cli.md +78 -0
  18. package/docs/getting-started.md +88 -0
  19. package/docs/mcp-agents.md +60 -0
  20. package/docs/shots/android-dark.png +0 -0
  21. package/docs/shots/app-mode.png +0 -0
  22. package/docs/shots/hero-studio.png +0 -0
  23. package/docs/shots/rtl-arabic.png +0 -0
  24. package/docs/shots/theme-lab.png +0 -0
  25. package/docs/shots/theming-noir.png +0 -0
  26. package/docs/shots/web-dashboard.png +0 -0
  27. package/docs/studio-guide.md +64 -0
  28. package/docs/troubleshooting.md +55 -0
  29. package/examples/qahwa/app.config.json +9 -0
  30. package/examples/qahwa/features/order/data/scenarios.json +25 -0
  31. package/examples/qahwa/features/order/data/seed.json +21 -0
  32. package/examples/qahwa/features/order/feature.json +7 -0
  33. package/examples/qahwa/features/order/flows.json +16 -0
  34. package/examples/qahwa/features/order/manifest.json +78 -0
  35. package/examples/qahwa/features/order/screens/010-menu.html +121 -0
  36. package/examples/qahwa/features/order/screens/020-drink.html +120 -0
  37. package/examples/qahwa/features/order/screens/030-cart.html +99 -0
  38. package/examples/qahwa/features/order/screens/040-status.html +125 -0
  39. package/examples/qahwa/features/order/screens/050-status-android.html +95 -0
  40. package/examples/qahwa/features/order/screens/shared.css +146 -0
  41. package/examples/qahwa/features/roastery/data/scenarios.json +35 -0
  42. package/examples/qahwa/features/roastery/data/seed.json +17 -0
  43. package/examples/qahwa/features/roastery/feature.json +7 -0
  44. package/examples/qahwa/features/roastery/flows.json +14 -0
  45. package/examples/qahwa/features/roastery/manifest.json +32 -0
  46. package/examples/qahwa/features/roastery/screens/010-orders.html +103 -0
  47. package/examples/qahwa/features/roastery/screens/020-menu-editor.html +88 -0
  48. package/examples/qahwa/features/roastery/screens/shared.css +115 -0
  49. package/examples/qahwa/review/comments.json +3 -0
  50. package/examples/qahwa/themes/qahwa.json +43 -0
  51. package/examples/spa-demo/app/index.html +100 -0
  52. package/examples/spa-demo/app.config.json +9 -0
  53. package/examples/spa-demo/features/notes-app/feature.json +11 -0
  54. package/examples/spa-demo/review/comments.json +3 -0
  55. package/examples/spa-demo/themes/default.json +15 -0
  56. package/examples/tokens-lab/app.config.json +9 -0
  57. package/examples/tokens-lab/features/lab/data/scenarios.json +5 -0
  58. package/examples/tokens-lab/features/lab/data/seed.json +3 -0
  59. package/examples/tokens-lab/features/lab/feature.json +7 -0
  60. package/examples/tokens-lab/features/lab/flows.json +14 -0
  61. package/examples/tokens-lab/features/lab/manifest.json +33 -0
  62. package/examples/tokens-lab/features/lab/screens/010-swatches.html +112 -0
  63. package/examples/tokens-lab/features/lab/screens/020-typography.html +115 -0
  64. package/examples/tokens-lab/review/comments.json +3 -0
  65. package/examples/tokens-lab/themes/noir.json +32 -0
  66. package/examples/tokens-lab/themes/paper.json +32 -0
  67. package/index.html +50 -0
  68. package/mcp/server.js +366 -0
  69. package/package.json +57 -0
  70. package/shell/flowplayer.js +85 -0
  71. package/shell/frames.js +108 -0
  72. package/shell/main.js +397 -0
  73. package/shell/net.js +126 -0
  74. package/shell/review.js +213 -0
  75. package/shell/screenhost.js +380 -0
  76. package/shell/selftest.js +275 -0
  77. package/shell/shell.css +952 -0
  78. package/shell/sidebar.js +112 -0
  79. package/shell/store.js +119 -0
  80. package/shell/themelab.js +186 -0
  81. package/shell/toast.js +15 -0
  82. package/shell/toolbar.js +128 -0
  83. package/skill/SKILL.md +52 -0
  84. package/skill/references/app-mode.md +77 -0
  85. package/skill/references/bridge-api.md +55 -0
  86. package/skill/references/cli-mcp.md +53 -0
  87. package/skill/references/flows.md +44 -0
  88. package/skill/references/i18n.md +52 -0
  89. package/skill/references/scenarios.md +45 -0
  90. package/skill/references/screen-contract.md +83 -0
  91. package/skill/references/tokens.md +56 -0
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ /* ScreenBook CLI — init · serve · manifest · validate · screenshot · mcp.
3
+ Shares engine/core/ with the MCP server (EN-18). */
4
+
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { exists, writeJSON, writeAtomic, mkdirp, today } from '../core/fsx.js';
8
+ import { resolveRoot, regenAllManifests, staticValidate, getScreen, addApp } from '../core/project.js';
9
+ import { startServer } from '../core/serve.js';
10
+ import * as T from './templates.js';
11
+
12
+ const ENGINE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
+ const VERSION = '0.1.0';
14
+
15
+ /* ---------------- arg parsing ---------------- */
16
+
17
+ const argv = process.argv.slice(2);
18
+ const command = argv[0];
19
+ const positional = [];
20
+ const flags = {};
21
+ for (let i = 1; i < argv.length; i++) {
22
+ const a = argv[i];
23
+ if (a.startsWith('--')) {
24
+ const eq = a.indexOf('=');
25
+ if (eq > 0) flags[a.slice(2, eq)] = a.slice(eq + 1);
26
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) flags[a.slice(2)] = argv[++i];
27
+ else flags[a.slice(2)] = true;
28
+ } else {
29
+ positional.push(a);
30
+ }
31
+ }
32
+
33
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
34
+ const bad = (s) => `\x1b[31m${s}\x1b[0m`;
35
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
36
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
37
+
38
+ async function requireRoot() {
39
+ const root = await resolveRoot(positional[0]);
40
+ if (!root) {
41
+ console.error(bad(`No app.config.json found${positional[0] ? ` in ${positional[0]}` : ' here'}.`));
42
+ console.error(`Run ${bold('screenbook init')} to scaffold a project, or pass the project directory.`);
43
+ process.exit(2);
44
+ }
45
+ return root;
46
+ }
47
+
48
+ /* ---------------- commands ---------------- */
49
+
50
+ async function cmdInit() {
51
+ const dir = path.resolve(positional[0] || '.');
52
+ const configPath = path.join(dir, 'app.config.json');
53
+ if (await exists(configPath) && !flags.force) {
54
+ console.error(bad(`app.config.json already exists in ${dir} — refusing to overwrite (use --force).`));
55
+ process.exit(2);
56
+ }
57
+ const name = flags.name || path.basename(dir);
58
+ const day = today();
59
+
60
+ await mkdirp(path.join(dir, 'features/welcome/screens'));
61
+ await mkdirp(path.join(dir, 'features/welcome/data'));
62
+ await mkdirp(path.join(dir, 'themes'));
63
+ await mkdirp(path.join(dir, 'review'));
64
+
65
+ await writeJSON(configPath, T.APP_CONFIG(name));
66
+ await writeJSON(path.join(dir, 'themes/default.json'), T.DEFAULT_THEME);
67
+ await writeJSON(path.join(dir, 'review/comments.json'), { comments: [] });
68
+ await writeJSON(path.join(dir, 'features/welcome/feature.json'), T.WELCOME_FEATURE);
69
+ await writeJSON(path.join(dir, 'features/welcome/data/seed.json'), T.WELCOME_SEED);
70
+ await writeJSON(path.join(dir, 'features/welcome/data/scenarios.json'), T.WELCOME_SCENARIOS);
71
+ await writeJSON(path.join(dir, 'features/welcome/flows.json'), T.WELCOME_FLOWS);
72
+ await writeAtomic(path.join(dir, 'features/welcome/screens/010-hello.html'), T.WELCOME_SCREEN(day));
73
+
74
+ /* agent onboarding ships with the product (EN-32) */
75
+ const claudeMd = path.join(dir, 'CLAUDE.md');
76
+ if (!(await exists(claudeMd))) {
77
+ const contract = await readTextSafe(path.join(ENGINE_DIR, 'CLAUDE.md'));
78
+ if (contract) await writeAtomic(claudeMd, contract);
79
+ }
80
+ const skillSrc = path.join(ENGINE_DIR, 'skill');
81
+ if (await exists(skillSrc)) {
82
+ await copyDir(skillSrc, path.join(dir, '.claude/skills/screenbook'));
83
+ }
84
+
85
+ await regenAllManifests(dir);
86
+
87
+ /* `init --app <path>`: register an existing SPA in this folder as an
88
+ embedded app in the same breath (Abdullah's flow: point ScreenBook at
89
+ any folder that already contains an app). */
90
+ if (flags.app) {
91
+ const rel = path.relative(dir, path.resolve(flags.app)) || flags.app;
92
+ const r = await addApp(dir, {
93
+ src: rel.split(path.sep).join('/'),
94
+ driver: !flags.cooperative
95
+ });
96
+ console.log(ok(`✓ app registered: ${r.feature.id}`) + dim(` → ${r.feature.src}`));
97
+ if (r.driver) console.log(` fill in ${bold(r.driver)} to teach ScreenBook the app's internals`);
98
+ }
99
+
100
+ console.log(ok(`✓ ScreenBook project scaffolded in ${dir}`));
101
+ console.log(` app.config.json · themes/default.json · features/welcome/ · review/`);
102
+ console.log(` CLAUDE.md + .claude/skills/screenbook/ ${dim('(agent authoring contract)')}`);
103
+ if (!(await exists(path.join(dir, 'engine')))) {
104
+ console.log(dim(` note: no engine/ folder here — ${bold('screenbook serve')} mounts it automatically;`));
105
+ console.log(dim(` copy the engine folder in when you want plain static servers to work too.`));
106
+ }
107
+ console.log(`\nNext: ${bold(`screenbook serve ${positional[0] || '.'}`)} → http://127.0.0.1:4600`);
108
+ }
109
+
110
+ async function readTextSafe(p) {
111
+ try { return await (await import('node:fs/promises')).readFile(p, 'utf8'); }
112
+ catch { return null; }
113
+ }
114
+
115
+ async function copyDir(src, dest) {
116
+ const fs = await import('node:fs/promises');
117
+ await fs.mkdir(dest, { recursive: true });
118
+ await fs.cp(src, dest, { recursive: true, force: true });
119
+ }
120
+
121
+ async function cmdServe() {
122
+ const root = await requireRoot();
123
+ const port = parseInt(flags.port || '4600', 10);
124
+ const srv = await startServer({ root, engineDir: ENGINE_DIR, port });
125
+ console.log(bold(`⧉ ScreenBook`) + ` serving ${dim(root)}`);
126
+ console.log(` studio ${ok(srv.url + '/engine/index.html')}`);
127
+ console.log(` selftest ${dim(srv.url + '/engine/index.html?selftest=1')}`);
128
+ console.log(` live reload + manifest refresh + review sync ${ok('on')} ${dim('(no-cache always)')}`);
129
+ }
130
+
131
+ async function cmdManifest() {
132
+ const root = await requireRoot();
133
+ const results = await regenAllManifests(root);
134
+ for (const r of results) {
135
+ const flag = r.written ? ok('regenerated') : dim('unchanged');
136
+ console.log(` ${r.feature.padEnd(24)} ${String(r.screens).padStart(3)} screens ${flag}`);
137
+ for (const p of r.problems) console.log(bad(` ! ${p.screen}: ${p.error}`));
138
+ }
139
+ if (!results.length) console.log(dim(' no features found'));
140
+ }
141
+
142
+ async function cmdValidate() {
143
+ const root = await requireRoot();
144
+ const report = { root, static: null, browser: null };
145
+
146
+ const s = await staticValidate(root);
147
+ report.static = s;
148
+ if (!flags.json) {
149
+ console.log(bold('Static checks'));
150
+ for (const e of s.errors) console.log(bad(` ✕ ${e}`));
151
+ for (const w of s.warnings) console.log(` ⚠︎ ${w}`);
152
+ console.log(s.ok ? ok(` ✓ static checks clean (${s.warnings.length} warning${s.warnings.length === 1 ? '' : 's'})`) : bad(` ${s.errors.length} error(s)`));
153
+ }
154
+
155
+ let browserOk = true;
156
+ if (!flags.static) {
157
+ const { runBrowserSelftest } = await import('../core/browser.js');
158
+ if (!flags.json) console.log(bold('Browser self-test') + dim(' (every screen × theme × light/dark × ltr/rtl)'));
159
+ try {
160
+ const r = await runBrowserSelftest({ root, engineDir: ENGINE_DIR });
161
+ report.browser = r;
162
+ browserOk = r.ok;
163
+ if (!flags.json) {
164
+ for (const row of r.rows.filter((x) => !x.ok)) {
165
+ console.log(bad(` ✕ ${row.feature}/${row.screen} ${dim(`${row.theme}·${row.mode}·${row.dir}`)} ${row.fail}`));
166
+ }
167
+ for (const e of r.shellErrors) console.log(bad(` ✕ shell: ${e}`));
168
+ console.log(r.ok
169
+ ? ok(` ✓ ${r.passed}/${r.total} render checks green across ${r.screens} screens`)
170
+ : bad(` ${r.failed} failing / ${r.total}`));
171
+ }
172
+ } catch (err) {
173
+ browserOk = false;
174
+ report.browser = { ok: false, error: String(err.message) };
175
+ if (!flags.json) console.log(bad(` ✕ ${err.message}`));
176
+ }
177
+ }
178
+
179
+ const allOk = s.ok && browserOk;
180
+ if (flags.json) console.log(JSON.stringify({ ok: allOk, ...report }, null, 2));
181
+ else console.log(allOk ? ok(bold('VALIDATE: GREEN')) : bad(bold('VALIDATE: FAILING')));
182
+ process.exit(allOk ? 0 : 1);
183
+ }
184
+
185
+ async function cmdMcp() {
186
+ /* Start the stdio MCP server in-process. Everything after `mcp` stays on
187
+ process.argv, so server.js picks up --root itself. NOTHING may print to
188
+ stdout here — that's the JSON-RPC channel; the server logs to stderr. */
189
+ if (!flags.root && positional[0]) {
190
+ process.argv.push('--root', positional[0]); // allow `screenbook mcp <dir>` too
191
+ }
192
+ await import('../mcp/server.js');
193
+ }
194
+
195
+ async function cmdApp() {
196
+ const sub = positional[0];
197
+ if (sub !== 'add') {
198
+ console.error(bad('usage: screenbook app add <src> [dir] [--id x] [--title "X"] [--icon 🧩] [--device ios|android|web|none] [--chrome self|engine] [--cooperative]'));
199
+ process.exit(2);
200
+ }
201
+ const srcArg = positional[1];
202
+ if (!srcArg) {
203
+ console.error(bad('missing <src> — project-relative path to the app\'s index.html'));
204
+ process.exit(2);
205
+ }
206
+ positional.splice(0, 2, positional[2]); // remaining positional = project dir
207
+ const root = await requireRoot();
208
+ const rel = path.isAbsolute(srcArg)
209
+ ? path.relative(root, srcArg).split(path.sep).join('/')
210
+ : srcArg;
211
+ const r = await addApp(root, {
212
+ src: rel,
213
+ id: flags.id,
214
+ title: flags.title,
215
+ icon: flags.icon,
216
+ device: flags.device,
217
+ chrome: flags.chrome,
218
+ driver: !flags.cooperative
219
+ });
220
+ console.log(ok(`✓ app feature "${r.feature.id}" registered`) + dim(` → ${r.feature.src}`));
221
+ if (r.driver) {
222
+ console.log(` next: fill in ${bold(r.driver)} (screens/go/set) — the app's own files stay untouched`);
223
+ } else {
224
+ console.log(` cooperative mode: the app must include ${bold('engine/app-bridge.js')} and call ${bold('AppBridge.register(…)')}`);
225
+ }
226
+ }
227
+
228
+ async function cmdScreenshot() {
229
+ const root = await requireRoot();
230
+ const spec = flags.screen;
231
+ if (!spec || !spec.includes('/')) {
232
+ console.error(bad('usage: screenbook screenshot [dir] --screen <feature>/<screen> [--mode light,dark] [--dir ltr,rtl] [--scenario id] [--theme id] [--lang xx] [--out dir]'));
233
+ process.exit(2);
234
+ }
235
+ const [feature, screen] = spec.split('/');
236
+ await getScreen(root, feature, screen); // fail fast if missing
237
+
238
+ const modes = String(flags.mode || 'light').split(',');
239
+ const dirs = String(flags.dir || 'ltr').split(',');
240
+ const combos = [];
241
+ for (const mode of modes) {
242
+ for (const d of dirs) {
243
+ combos.push({
244
+ feature, screen, mode, dir: d,
245
+ scenario: flags.scenario, theme: flags.theme, lang: flags.lang
246
+ });
247
+ }
248
+ }
249
+ const outDir = path.resolve(flags.out || path.join(root, 'review', 'screenshots'));
250
+ const { screenshot } = await import('../core/browser.js');
251
+ const files = await screenshot({ root, engineDir: ENGINE_DIR, combos, outDir });
252
+ for (const f of files) console.log(ok(` ✓ ${f}`));
253
+ }
254
+
255
+ function help() {
256
+ console.log(`${bold('screenbook')} ${VERSION} — static prototype studio
257
+
258
+ ${bold('init')} [dir] [--name X] [--force] scaffold a consumer project (+ CLAUDE.md + skill)
259
+ [--app <src>] also register an existing SPA in that folder as an embedded app
260
+ ${bold('app add')} <src> [dir] [--id --title --icon --device --chrome self|engine --cooperative]
261
+ embed an existing single-page app (driver scaffolded unless --cooperative)
262
+ ${bold('serve')} [dir] [--port 4600] static server · live reload · manifest refresh · review sync
263
+ ${bold('manifest')} [dir] rescan screens → regenerate features/*/manifest.json
264
+ ${bold('validate')} [dir] [--static] [--json] static checks + headless browser self-test
265
+ ${bold('mcp')} [--root dir] start the stdio MCP server (for agents):
266
+ claude mcp add screenbook -- npx -y @cs4alhaider/screenbook mcp --root .
267
+ ${bold('screenshot')} [dir] --screen f/s PNG of a screen in its device frame
268
+ [--mode light,dark] [--dir ltr,rtl] [--scenario id] [--theme id] [--lang xx] [--out dir]
269
+
270
+ Projects are folders with app.config.json + features/ + themes/ (see engine/CLAUDE.md).`);
271
+ }
272
+
273
+ const commands = {
274
+ init: cmdInit,
275
+ serve: cmdServe,
276
+ manifest: cmdManifest,
277
+ validate: cmdValidate,
278
+ screenshot: cmdScreenshot,
279
+ app: cmdApp,
280
+ mcp: cmdMcp,
281
+ help,
282
+ '--help': help,
283
+ version: () => console.log(VERSION),
284
+ '--version': () => console.log(VERSION)
285
+ };
286
+
287
+ Promise.resolve((commands[command] || help)()).catch((err) => {
288
+ console.error(bad(`✕ ${err.message}`));
289
+ process.exit(1);
290
+ });
@@ -0,0 +1,139 @@
1
+ /* Templates written by `screenbook init` into a fresh consumer project. */
2
+
3
+ export const APP_CONFIG = (name) => ({
4
+ name,
5
+ defaultFeature: 'welcome',
6
+ features: ['welcome'],
7
+ devices: ['ios', 'android', 'web'],
8
+ themes: ['default'],
9
+ languages: ['en'],
10
+ defaultLanguage: 'en'
11
+ });
12
+
13
+ export const DEFAULT_THEME = {
14
+ id: 'default',
15
+ title: 'Starter',
16
+ modes: {
17
+ light: {
18
+ color: {
19
+ accent: '#5B5BD6', bg: '#F4F3F0', card: '#FFFFFF',
20
+ ink: '#211E1A', 'ink-2': '#6F6A61', line: '#E3DFD7'
21
+ },
22
+ font: {
23
+ sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
24
+ serif: "ui-serif, 'New York', Georgia, serif"
25
+ },
26
+ radius: { card: '18px', chip: '999px' },
27
+ space: { page: '16px' }
28
+ },
29
+ dark: {
30
+ color: {
31
+ accent: '#8484F0', bg: '#161519', card: '#211F26',
32
+ ink: '#EDEBF3', 'ink-2': '#A7A3B0', line: '#2E2D34'
33
+ }
34
+ }
35
+ }
36
+ };
37
+
38
+ export const WELCOME_FEATURE = {
39
+ id: 'welcome',
40
+ title: 'Welcome',
41
+ icon: '👋',
42
+ description: 'A first screen to copy from — delete once real features exist.',
43
+ order: 1
44
+ };
45
+
46
+ export const WELCOME_SEED = {
47
+ app: { name: 'My product' },
48
+ user: { firstName: 'Sara', plan: 'trial', trialEndsOn: '{{today+14}}' }
49
+ };
50
+
51
+ export const WELCOME_SCENARIOS = {
52
+ list: [
53
+ { id: 'default', icon: '🧪', title: 'Trial user', description: 'Baseline seed — trial ends in two weeks.' },
54
+ {
55
+ id: 'expired', icon: '⏰', title: 'Trial expired', description: 'The nudge state.',
56
+ patch: { user: { plan: 'expired', trialEndsOn: '{{today-1}}' } }
57
+ }
58
+ ]
59
+ };
60
+
61
+ export const WELCOME_FLOWS = {
62
+ list: [
63
+ {
64
+ id: 'tour', title: 'Starter tour', icon: '✨',
65
+ description: 'Shows how flows step through screens with notes.',
66
+ steps: [
67
+ { screen: '010-hello', note: 'Every flow step can pin a scenario, show a note, and move the story forward.' }
68
+ ]
69
+ }
70
+ ]
71
+ };
72
+
73
+ export const WELCOME_SCREEN = (today) => `<!DOCTYPE html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="UTF-8">
77
+ <meta name="viewport" content="width=device-width, initial-scale=1">
78
+ <title>Hello ScreenBook</title>
79
+ <script type="application/json" id="screen-meta">
80
+ {
81
+ "title": "Hello",
82
+ "description": "Starter screen — shows tokens, data binding and navigation.",
83
+ "order": 10,
84
+ "device": "ios",
85
+ "status": "draft",
86
+ "tags": ["starter"],
87
+ "updated": "${today}"
88
+ }
89
+ </script>
90
+ <script src="../../../engine/bridge.js"></script>
91
+ <style>
92
+ * { box-sizing: border-box; }
93
+ body {
94
+ margin: 0; min-height: 100vh;
95
+ padding: calc(var(--safe-top, 0px) + 24px) var(--space-page, 16px) calc(var(--safe-bottom, 0px) + 24px);
96
+ background: var(--color-bg); color: var(--color-ink);
97
+ font-family: var(--font-sans);
98
+ }
99
+ .card {
100
+ background: var(--color-card); border: 1px solid var(--color-line);
101
+ border-radius: var(--radius-card); padding: 20px; margin-top: 16px;
102
+ }
103
+ h1 { font-family: var(--font-serif); font-size: 30px; margin: 8px 0 4px; }
104
+ .muted { color: var(--color-ink-2); font-size: 14px; line-height: 1.5; }
105
+ .cta {
106
+ display: inline-block; margin-top: 14px; padding: 12px 18px; border: 0;
107
+ border-radius: var(--radius-chip); background: var(--color-accent); color: #fff;
108
+ font-size: 15px; font-weight: 700; cursor: pointer;
109
+ }
110
+ </style>
111
+ </head>
112
+ <body>
113
+ <h1>Hello 👋</h1>
114
+ <p class="muted">This screen is a full standalone HTML document. Open it directly, or through the ScreenBook shell.</p>
115
+ <div class="card">
116
+ <div id="who" style="font-weight:700"></div>
117
+ <div class="muted" id="plan"></div>
118
+ <button class="cta" id="cta">Try Engine.toast</button>
119
+ </div>
120
+ <script>
121
+ Engine.ready(function (d) {
122
+ document.getElementById('who').textContent =
123
+ (d.user ? d.user.firstName : 'Friend') + ' · ' + (d.app ? d.app.name : 'your product');
124
+ document.getElementById('plan').textContent =
125
+ d.user && d.user.plan === 'expired'
126
+ ? 'Trial expired on ' + d.user.trialEndsOn + ' — try the ⏰ scenario dropdown!'
127
+ : 'Trial until ' + (d.user ? d.user.trialEndsOn : '…') + ' — dates come from {{today+14}} tokens.';
128
+ document.getElementById('cta').addEventListener('click', function () {
129
+ Engine.toast('Tokens, data, state — all live ✦');
130
+ });
131
+ });
132
+ </script>
133
+ </body>
134
+ </html>
135
+ `;
136
+
137
+ export const GITIGNORE = `node_modules/
138
+ .DS_Store
139
+ `;
@@ -0,0 +1,108 @@
1
+ /* Headless-browser half of validation + screenshots.
2
+ Playwright is a dev dependency of the CLI, never of the viewer (EN-28).
3
+ Resolution order: playwright → playwright-core driving the system Chrome. */
4
+
5
+ import path from 'node:path';
6
+ import { startServer } from './serve.js';
7
+ import { mkdirp } from './fsx.js';
8
+
9
+ async function launch() {
10
+ let lastErr;
11
+ for (const [pkg, opts] of [
12
+ ['playwright', { headless: true }],
13
+ ['playwright-core', { headless: true, channel: 'chrome' }],
14
+ ['playwright-core', { headless: true, channel: 'chromium' }]
15
+ ]) {
16
+ try {
17
+ const mod = await import(pkg);
18
+ return await mod.chromium.launch(opts);
19
+ } catch (err) {
20
+ lastErr = err;
21
+ }
22
+ }
23
+ throw new Error(
24
+ `No headless browser available. Install one inside engine/:\n` +
25
+ ` npm i -D playwright-core (uses your installed Chrome)\n` +
26
+ ` npm i -D playwright && npx playwright install chromium (self-contained)\n` +
27
+ `Underlying error: ${lastErr?.message}`
28
+ );
29
+ }
30
+
31
+ async function withPage({ root, engineDir }, fn) {
32
+ const srv = await startServer({ root, engineDir, port: 0, quiet: true });
33
+ const browser = await launch();
34
+ try {
35
+ const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: 2 });
36
+ return await fn(page, srv.url);
37
+ } finally {
38
+ await browser.close();
39
+ await srv.close();
40
+ }
41
+ }
42
+
43
+ /* Run the shell's ?selftest=1 headlessly; also fails on shell-level errors. */
44
+ export async function runBrowserSelftest({ root, engineDir, timeout = 180000 }) {
45
+ return withPage({ root, engineDir }, async (page, base) => {
46
+ const shellErrors = [];
47
+ /* contract-optional project files 404 legitimately (flows.json, seed.json, …) —
48
+ Chrome still logs those as console errors; they are not failures */
49
+ const OPTIONAL_404 = /\/(flows|scenarios|seed|manifest|feature|app\.config|comments)\.json$|\/themes\/[^/]+\.json$/;
50
+ page.on('pageerror', (err) => shellErrors.push(String(err.message || err)));
51
+ page.on('console', (msg) => {
52
+ if (msg.type() !== 'error') return;
53
+ const text = msg.text();
54
+ const src = msg.location()?.url || '';
55
+ if (/Failed to load resource/.test(text) && OPTIONAL_404.test(src.split('?')[0])) return;
56
+ /* screen/app-side errors are captured (and filtered) in-page by the
57
+ bridges and become per-row failures; this listener only polices the
58
+ SHELL itself — engine-owned sources. The two bridge files are the
59
+ console-wrap passthrough for screen/app errors, so they don't count. */
60
+ if (src && !/\/engine\//.test(src)) return;
61
+ if (/\/engine\/(bridge|app-bridge)\.js/.test(src)) return;
62
+ shellErrors.push(text);
63
+ });
64
+
65
+ await page.goto(`${base}/engine/index.html?selftest=1`, { waitUntil: 'domcontentloaded' });
66
+ await page.waitForFunction('window.__selftestDone === true', null, { timeout });
67
+ const result = await page.evaluate('window.__selftestResult');
68
+ result.shellErrors = shellErrors;
69
+ result.ok = result.failed === 0 && shellErrors.length === 0;
70
+ return result;
71
+ });
72
+ }
73
+
74
+ /*
75
+ * Screenshot a screen inside its real device frame via the shell's solo mode.
76
+ * combo: { feature, screen, theme, mode, dir, lang, scenario }
77
+ */
78
+ export async function screenshot({ root, engineDir, combos, outDir }) {
79
+ await mkdirp(outDir);
80
+ const files = [];
81
+ return withPage({ root, engineDir }, async (page, base) => {
82
+ for (const c of combos) {
83
+ const params = new URLSearchParams();
84
+ if (c.scenario && c.scenario !== 'default') params.set('scenario', c.scenario);
85
+ if (c.mode && c.mode !== 'light') params.set('mode', c.mode);
86
+ if (c.theme) params.set('theme', c.theme);
87
+ if (c.dir && c.dir !== 'ltr') params.set('dir', c.dir);
88
+ if (c.lang) params.set('lang', c.lang);
89
+ const q = params.toString();
90
+ const url = `${base}/engine/index.html?solo=1#${encodeURIComponent(c.feature)}/${encodeURIComponent(c.screen)}${q ? `?${q}` : ''}`;
91
+
92
+ await page.goto('about:blank');
93
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
94
+ await page.waitForSelector('.stage .device', { timeout: 20000 });
95
+ await page.waitForSelector('.load-veil.is-hidden', { state: 'attached', timeout: 20000 });
96
+ await page.waitForTimeout(250);
97
+
98
+ const device = page.locator('.stage .device');
99
+ const name = [c.feature, c.screen, c.theme, c.mode, c.dir, c.scenario]
100
+ .filter((x) => x && x !== 'default')
101
+ .join('_') + '.png';
102
+ const file = path.join(outDir, name.replace(/[^a-z0-9._-]+/gi, '-'));
103
+ await device.screenshot({ path: file });
104
+ files.push(file);
105
+ }
106
+ return files;
107
+ });
108
+ }
package/core/fsx.js ADDED
@@ -0,0 +1,71 @@
1
+ /* Atomic, read-then-write file helpers. Every write in the CLI/MCP goes
2
+ through here — the predecessor lost index.html twice to naive writes. */
3
+
4
+ import fs from 'node:fs/promises';
5
+ import path from 'node:path';
6
+
7
+ export async function exists(p) {
8
+ try { await fs.access(p); return true; } catch { return false; }
9
+ }
10
+
11
+ export async function readText(p) {
12
+ return fs.readFile(p, 'utf8');
13
+ }
14
+
15
+ export async function readJSON(p, fallback = undefined) {
16
+ try {
17
+ return JSON.parse(await fs.readFile(p, 'utf8'));
18
+ } catch (err) {
19
+ if (fallback !== undefined && (err.code === 'ENOENT')) return fallback;
20
+ if (err instanceof SyntaxError) {
21
+ throw new Error(`${p} is not valid JSON: ${err.message}`);
22
+ }
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ /* Write via temp file + rename (atomic on POSIX). Creates parent dirs. */
28
+ export async function writeAtomic(p, content) {
29
+ await fs.mkdir(path.dirname(p), { recursive: true });
30
+ const tmp = `${p}.tmp-${process.pid}-${Date.now().toString(36)}`;
31
+ try {
32
+ await fs.writeFile(tmp, content, 'utf8');
33
+ await fs.rename(tmp, p);
34
+ } catch (err) {
35
+ try { await fs.unlink(tmp); } catch { /* already gone */ }
36
+ throw err;
37
+ }
38
+ }
39
+
40
+ export async function writeJSON(p, value) {
41
+ await writeAtomic(p, JSON.stringify(value, null, 2) + '\n');
42
+ }
43
+
44
+ /* Write only when content actually changed. Returns true if written. */
45
+ export async function writeJSONIfChanged(p, value) {
46
+ const next = JSON.stringify(value, null, 2) + '\n';
47
+ try {
48
+ const cur = await fs.readFile(p, 'utf8');
49
+ if (cur === next) return false;
50
+ } catch { /* new file */ }
51
+ await writeAtomic(p, next);
52
+ return true;
53
+ }
54
+
55
+ export async function listDir(p) {
56
+ try { return await fs.readdir(p, { withFileTypes: true }); }
57
+ catch { return []; }
58
+ }
59
+
60
+ export async function remove(p) {
61
+ await fs.rm(p, { force: true });
62
+ }
63
+
64
+ export async function mkdirp(p) {
65
+ await fs.mkdir(p, { recursive: true });
66
+ }
67
+
68
+ export function today() {
69
+ const d = new Date();
70
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
71
+ }