@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.
- package/CLAUDE.md +185 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/TESTING.md +54 -0
- package/app-bridge.js +131 -0
- package/bridge.js +598 -0
- package/cli/screenbook.js +290 -0
- package/cli/templates.js +139 -0
- package/core/browser.js +108 -0
- package/core/fsx.js +71 -0
- package/core/meta.js +94 -0
- package/core/project.js +482 -0
- package/core/serve.js +224 -0
- package/docs/README.md +21 -0
- package/docs/app-mode-guide.md +79 -0
- package/docs/authoring-guide.md +129 -0
- package/docs/cli.md +78 -0
- package/docs/getting-started.md +88 -0
- package/docs/mcp-agents.md +60 -0
- package/docs/shots/android-dark.png +0 -0
- package/docs/shots/app-mode.png +0 -0
- package/docs/shots/hero-studio.png +0 -0
- package/docs/shots/rtl-arabic.png +0 -0
- package/docs/shots/theme-lab.png +0 -0
- package/docs/shots/theming-noir.png +0 -0
- package/docs/shots/web-dashboard.png +0 -0
- package/docs/studio-guide.md +64 -0
- package/docs/troubleshooting.md +55 -0
- package/examples/qahwa/app.config.json +9 -0
- package/examples/qahwa/features/order/data/scenarios.json +25 -0
- package/examples/qahwa/features/order/data/seed.json +21 -0
- package/examples/qahwa/features/order/feature.json +7 -0
- package/examples/qahwa/features/order/flows.json +16 -0
- package/examples/qahwa/features/order/manifest.json +78 -0
- package/examples/qahwa/features/order/screens/010-menu.html +121 -0
- package/examples/qahwa/features/order/screens/020-drink.html +120 -0
- package/examples/qahwa/features/order/screens/030-cart.html +99 -0
- package/examples/qahwa/features/order/screens/040-status.html +125 -0
- package/examples/qahwa/features/order/screens/050-status-android.html +95 -0
- package/examples/qahwa/features/order/screens/shared.css +146 -0
- package/examples/qahwa/features/roastery/data/scenarios.json +35 -0
- package/examples/qahwa/features/roastery/data/seed.json +17 -0
- package/examples/qahwa/features/roastery/feature.json +7 -0
- package/examples/qahwa/features/roastery/flows.json +14 -0
- package/examples/qahwa/features/roastery/manifest.json +32 -0
- package/examples/qahwa/features/roastery/screens/010-orders.html +103 -0
- package/examples/qahwa/features/roastery/screens/020-menu-editor.html +88 -0
- package/examples/qahwa/features/roastery/screens/shared.css +115 -0
- package/examples/qahwa/review/comments.json +3 -0
- package/examples/qahwa/themes/qahwa.json +43 -0
- package/examples/spa-demo/app/index.html +100 -0
- package/examples/spa-demo/app.config.json +9 -0
- package/examples/spa-demo/features/notes-app/feature.json +11 -0
- package/examples/spa-demo/review/comments.json +3 -0
- package/examples/spa-demo/themes/default.json +15 -0
- package/examples/tokens-lab/app.config.json +9 -0
- package/examples/tokens-lab/features/lab/data/scenarios.json +5 -0
- package/examples/tokens-lab/features/lab/data/seed.json +3 -0
- package/examples/tokens-lab/features/lab/feature.json +7 -0
- package/examples/tokens-lab/features/lab/flows.json +14 -0
- package/examples/tokens-lab/features/lab/manifest.json +33 -0
- package/examples/tokens-lab/features/lab/screens/010-swatches.html +112 -0
- package/examples/tokens-lab/features/lab/screens/020-typography.html +115 -0
- package/examples/tokens-lab/review/comments.json +3 -0
- package/examples/tokens-lab/themes/noir.json +32 -0
- package/examples/tokens-lab/themes/paper.json +32 -0
- package/index.html +50 -0
- package/mcp/server.js +366 -0
- package/package.json +57 -0
- package/shell/flowplayer.js +85 -0
- package/shell/frames.js +108 -0
- package/shell/main.js +397 -0
- package/shell/net.js +126 -0
- package/shell/review.js +213 -0
- package/shell/screenhost.js +380 -0
- package/shell/selftest.js +275 -0
- package/shell/shell.css +952 -0
- package/shell/sidebar.js +112 -0
- package/shell/store.js +119 -0
- package/shell/themelab.js +186 -0
- package/shell/toast.js +15 -0
- package/shell/toolbar.js +128 -0
- package/skill/SKILL.md +52 -0
- package/skill/references/app-mode.md +77 -0
- package/skill/references/bridge-api.md +55 -0
- package/skill/references/cli-mcp.md +53 -0
- package/skill/references/flows.md +44 -0
- package/skill/references/i18n.md +52 -0
- package/skill/references/scenarios.md +45 -0
- package/skill/references/screen-contract.md +83 -0
- 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
|
+
});
|
package/cli/templates.js
ADDED
|
@@ -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
|
+
`;
|
package/core/browser.js
ADDED
|
@@ -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
|
+
}
|