@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
package/core/meta.js ADDED
@@ -0,0 +1,94 @@
1
+ /* Screen meta block: parse / rewrite the
2
+ <script type="application/json" id="screen-meta">…</script> inside a
3
+ screen's HTML (EN-8 — metadata lives in the file; manifest is derived). */
4
+
5
+ const META_RE = /(<script[^>]*\bid=["']screen-meta["'][^>]*>)([\s\S]*?)(<\/script>)/i;
6
+ const BRIDGE_RE = /<script[^>]*\bsrc=["'][^"']*\bbridge\.js["'][^>]*>/i;
7
+
8
+ export const DEVICES = ['ios', 'android', 'web', 'none'];
9
+
10
+ export function hasBridgeInclude(html) {
11
+ return BRIDGE_RE.test(html);
12
+ }
13
+
14
+ /* → { meta, error } — meta null when missing/broken */
15
+ export function parseMeta(html) {
16
+ const m = META_RE.exec(html);
17
+ if (!m) return { meta: null, error: 'no <script id="screen-meta"> block' };
18
+ try {
19
+ const meta = JSON.parse(m[2]);
20
+ if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
21
+ return { meta: null, error: 'meta block is not a JSON object' };
22
+ }
23
+ return { meta, error: null };
24
+ } catch (err) {
25
+ return { meta: null, error: `meta block is not valid JSON: ${err.message}` };
26
+ }
27
+ }
28
+
29
+ /* Replace the meta block's JSON, preserving the surrounding tags. */
30
+ export function withMeta(html, meta) {
31
+ const json = JSON.stringify(meta, null, 2)
32
+ .split('\n')
33
+ .map((line) => ' ' + line)
34
+ .join('\n');
35
+ return html.replace(META_RE, (_, open, _body, close) => `${open}\n${json}\n ${close}`);
36
+ }
37
+
38
+ /*
39
+ * Stamp `updated` (EN-10): set to today on save, but respect a manual
40
+ * override — if the incoming HTML deliberately carries a different date
41
+ * than the previous version of the file, keep the author's value.
42
+ */
43
+ export function stampUpdated(html, { previousUpdated, today }) {
44
+ const { meta } = parseMeta(html);
45
+ if (!meta) return html;
46
+ const incoming = meta.updated;
47
+ const manualOverride = incoming && previousUpdated && incoming !== previousUpdated;
48
+ if (manualOverride) return html;
49
+ meta.updated = today;
50
+ return withMeta(html, meta);
51
+ }
52
+
53
+ /* Validation used by add/update/validate. Returns array of problems. */
54
+ export function lintScreen(html, { id } = {}) {
55
+ const problems = [];
56
+ const { meta, error } = parseMeta(html);
57
+ if (error) problems.push(error);
58
+ if (meta) {
59
+ if (!meta.title || typeof meta.title !== 'string') problems.push('meta.title is required');
60
+ if (meta.device && !DEVICES.includes(meta.device)) {
61
+ problems.push(`meta.device "${meta.device}" — must be one of ${DEVICES.join(' / ')}`);
62
+ }
63
+ if (meta.devices !== undefined) {
64
+ if (!Array.isArray(meta.devices) || !meta.devices.length) {
65
+ problems.push('meta.devices must be a non-empty array (device variants)');
66
+ } else {
67
+ for (const d of meta.devices) {
68
+ if (!DEVICES.includes(d)) problems.push(`meta.devices contains "${d}" — must be one of ${DEVICES.join(' / ')}`);
69
+ }
70
+ }
71
+ }
72
+ if (meta.order !== undefined && typeof meta.order !== 'number') problems.push('meta.order must be a number');
73
+ if (meta.tags !== undefined && !Array.isArray(meta.tags)) problems.push('meta.tags must be an array');
74
+ }
75
+ if (!hasBridgeInclude(html)) problems.push('missing <script src="…/engine/bridge.js"> include');
76
+ if (!/<!DOCTYPE html>/i.test(html)) problems.push('missing <!DOCTYPE html> (screens are full standalone documents)');
77
+ if (id && !/^[a-z0-9][a-z0-9-]*$/i.test(id)) problems.push(`screen id "${id}" — use letters, digits and dashes`);
78
+ return problems;
79
+ }
80
+
81
+ /* data-go="target" references, for cross-checking navigation. */
82
+ export function dataGoTargets(html) {
83
+ const out = new Set();
84
+ const re = /\bdata-go=["']([^"']+)["']/g;
85
+ let m;
86
+ while ((m = re.exec(html))) out.add(m[1]);
87
+ return [...out];
88
+ }
89
+
90
+ /* Numeric filename prefix (EN-9) as order fallback: "010-home" → 10. */
91
+ export function prefixOrder(id) {
92
+ const m = /^(\d+)/.exec(id);
93
+ return m ? parseInt(m[1], 10) : undefined;
94
+ }
@@ -0,0 +1,482 @@
1
+ /* Project API — the one shared core the CLI and MCP server both import
2
+ (EN-18). Scans consumer projects, derives manifests, does atomic screen
3
+ CRUD, and runs the static half of validation. */
4
+
5
+ import path from 'node:path';
6
+ import { exists, readText, readJSON, writeAtomic, writeJSON, listDir, remove, mkdirp, today } from './fsx.js';
7
+ import { parseMeta, stampUpdated, lintScreen, dataGoTargets, prefixOrder, DEVICES } from './meta.js';
8
+
9
+ const MANIFEST_FIELDS = ['title', 'description', 'order', 'device', 'devices', 'status', 'tags', 'group', 'updated', 'statusBar'];
10
+
11
+ /* ---------------- roots + config ---------------- */
12
+
13
+ export async function resolveRoot(dir) {
14
+ const root = path.resolve(dir || process.cwd());
15
+ if (await exists(path.join(root, 'app.config.json'))) return root;
16
+ /* convenience: running from inside engine/ of a project */
17
+ const parent = path.dirname(root);
18
+ if (await exists(path.join(parent, 'app.config.json'))) return parent;
19
+ return null;
20
+ }
21
+
22
+ export async function readConfig(root) {
23
+ return readJSON(path.join(root, 'app.config.json'), {});
24
+ }
25
+
26
+ export async function writeConfig(root, config) {
27
+ await writeJSON(path.join(root, 'app.config.json'), config);
28
+ }
29
+
30
+ export function featureDir(root, id) {
31
+ return path.join(root, 'features', id);
32
+ }
33
+
34
+ /* Feature ids: config order first, then any extra folders on disk. */
35
+ export async function listFeatureIds(root) {
36
+ const config = await readConfig(root);
37
+ const configured = Array.isArray(config.features) ? [...config.features] : [];
38
+ const onDisk = (await listDir(path.join(root, 'features')))
39
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
40
+ .map((d) => d.name);
41
+ for (const id of onDisk.sort()) if (!configured.includes(id)) configured.push(id);
42
+ return configured.filter((id) => onDisk.includes(id));
43
+ }
44
+
45
+ /* ---------------- screens ---------------- */
46
+
47
+ export function screenPath(root, feature, screen) {
48
+ return path.join(featureDir(root, feature), 'screens', `${screen}.html`);
49
+ }
50
+
51
+ export async function listScreenFiles(root, feature) {
52
+ const dir = path.join(featureDir(root, feature), 'screens');
53
+ return (await listDir(dir))
54
+ .filter((d) => d.isFile() && d.name.endsWith('.html'))
55
+ .map((d) => d.name.slice(0, -5))
56
+ .sort();
57
+ }
58
+
59
+ export async function scanFeature(root, id) {
60
+ const dir = featureDir(root, id);
61
+ const meta = await readJSON(path.join(dir, 'feature.json'), { id, title: id });
62
+ const screenIds = await listScreenFiles(root, id);
63
+ const screens = [];
64
+ const problems = [];
65
+
66
+ for (const sid of screenIds) {
67
+ const html = await readText(screenPath(root, id, sid));
68
+ const { meta: sMeta, error } = parseMeta(html);
69
+ if (error) problems.push({ screen: sid, error });
70
+ const entry = { id: sid, file: `screens/${sid}.html` };
71
+ if (sMeta) {
72
+ for (const f of MANIFEST_FIELDS) if (sMeta[f] !== undefined) entry[f] = sMeta[f];
73
+ }
74
+ if (entry.order === undefined) entry.order = prefixOrder(sid) ?? 999;
75
+ screens.push(entry);
76
+ }
77
+
78
+ screens.sort((a, b) => (a.order - b.order) || a.id.localeCompare(b.id));
79
+ return { id, meta, screens, problems };
80
+ }
81
+
82
+ export async function regenManifest(root, id) {
83
+ const featureMeta = await readJSON(path.join(featureDir(root, id), 'feature.json'), null);
84
+ if (featureMeta?.type === 'app') {
85
+ /* app features have no file screens — the live app reports them (EN-20) */
86
+ return { feature: id, title: featureMeta.title, written: false, screens: 0, appType: true, problems: [] };
87
+ }
88
+ const { meta, screens, problems } = await scanFeature(root, id);
89
+ const manifest = { feature: id, generated: today(), screens };
90
+ const manifestPath = path.join(featureDir(root, id), 'manifest.json');
91
+ /* stable compare ignoring the `generated` stamp so untouched features don't churn */
92
+ const existing = await readJSON(manifestPath, null);
93
+ const changed = !existing ||
94
+ JSON.stringify({ ...existing, generated: null }) !== JSON.stringify({ ...manifest, generated: null });
95
+ if (changed) await writeJSON(manifestPath, manifest);
96
+ return { feature: id, title: meta.title, written: changed, screens: screens.length, problems };
97
+ }
98
+
99
+ export async function regenAllManifests(root) {
100
+ const out = [];
101
+ for (const id of await listFeatureIds(root)) out.push(await regenManifest(root, id));
102
+ return out;
103
+ }
104
+
105
+ export async function getScreen(root, feature, screen) {
106
+ const p = screenPath(root, feature, screen);
107
+ if (!(await exists(p))) throw new Error(`screen not found: ${feature}/${screen}`);
108
+ const html = await readText(p);
109
+ const { meta } = parseMeta(html);
110
+ return { feature, screen, meta, html, path: p };
111
+ }
112
+
113
+ async function saveScreen(root, feature, screen, html, { mustExist, mustNotExist }) {
114
+ const p = screenPath(root, feature, screen);
115
+ const already = await exists(p);
116
+ if (mustExist && !already) throw new Error(`screen not found: ${feature}/${screen} — use add_screen to create it`);
117
+ if (mustNotExist && already) throw new Error(`screen already exists: ${feature}/${screen} — use update_screen to change it`);
118
+ if (!(await exists(featureDir(root, feature)))) {
119
+ throw new Error(`feature not found: ${feature} — use add_feature first`);
120
+ }
121
+
122
+ const problems = lintScreen(html, { id: screen });
123
+ if (problems.length) {
124
+ throw new Error(`screen ${feature}/${screen} rejected:\n - ${problems.join('\n - ')}`);
125
+ }
126
+
127
+ let previousUpdated;
128
+ if (already) {
129
+ const prev = parseMeta(await readText(p)).meta;
130
+ previousUpdated = prev?.updated;
131
+ }
132
+ const stamped = stampUpdated(html, { previousUpdated, today: today() });
133
+
134
+ await writeAtomic(p, stamped);
135
+ const manifest = await regenManifest(root, feature);
136
+ return { feature, screen, path: p, updated: parseMeta(stamped).meta?.updated, manifest };
137
+ }
138
+
139
+ export async function addScreen(root, feature, screen, html) {
140
+ return saveScreen(root, feature, screen, html, { mustNotExist: true });
141
+ }
142
+
143
+ export async function updateScreen(root, feature, screen, html) {
144
+ return saveScreen(root, feature, screen, html, { mustExist: true });
145
+ }
146
+
147
+ export async function removeScreen(root, feature, screen) {
148
+ const p = screenPath(root, feature, screen);
149
+ if (!(await exists(p))) throw new Error(`screen not found: ${feature}/${screen}`);
150
+ await remove(p);
151
+ const manifest = await regenManifest(root, feature);
152
+ return { removed: `${feature}/${screen}`, manifest };
153
+ }
154
+
155
+ /* ---------------- features ---------------- */
156
+
157
+ export async function addFeature(root, { id, title, icon, description, order }) {
158
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(id || '')) {
159
+ throw new Error(`feature id "${id}" — use lowercase letters, digits and dashes`);
160
+ }
161
+ const dir = featureDir(root, id);
162
+ if (await exists(dir)) throw new Error(`feature already exists: ${id}`);
163
+
164
+ const config = await readConfig(root);
165
+ const meta = {
166
+ id,
167
+ title: title || id,
168
+ icon: icon || '📄',
169
+ description: description || '',
170
+ order: order ?? ((config.features?.length || 0) + 1)
171
+ };
172
+ await mkdirp(path.join(dir, 'screens'));
173
+ await mkdirp(path.join(dir, 'data'));
174
+ await writeJSON(path.join(dir, 'feature.json'), meta);
175
+ await writeJSON(path.join(dir, 'data', 'seed.json'), {});
176
+ await writeJSON(path.join(dir, 'data', 'scenarios.json'), {
177
+ list: [{ id: 'default', icon: '🧪', title: 'Default', description: 'Baseline seed data, untouched.' }]
178
+ });
179
+ await writeJSON(path.join(dir, 'flows.json'), { list: [] });
180
+
181
+ config.features = [...(config.features || []), id];
182
+ if (!config.defaultFeature) config.defaultFeature = id;
183
+ await writeConfig(root, config);
184
+ await regenManifest(root, id);
185
+ return { feature: meta };
186
+ }
187
+
188
+ /* ---------------- app-type features (EN-20) ---------------- */
189
+
190
+ /*
191
+ * Register an existing single-page app as an app-type feature. `src` is
192
+ * project-root-relative (e.g. "docs/mockups/ios/index.html"). When `driver`
193
+ * is true a starter driver.js is scaffolded — the injected adapter that lets
194
+ * the shell drive the app WITHOUT touching the app's own files.
195
+ */
196
+ export async function addApp(root, { src, id, title, icon, description, device, chrome, driver, order }) {
197
+ if (!src) throw new Error('src required — project-relative path to the app\'s index.html');
198
+ const srcPath = path.join(root, src);
199
+ if (!(await exists(srcPath))) throw new Error(`app entry not found: ${src}`);
200
+
201
+ const appId = id || path.basename(path.dirname(srcPath)).toLowerCase().replace(/[^a-z0-9-]+/g, '-') || 'app';
202
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(appId)) throw new Error(`app id "${appId}" — use lowercase letters, digits and dashes`);
203
+ const dir = featureDir(root, appId);
204
+ if (await exists(dir)) throw new Error(`feature already exists: ${appId}`);
205
+
206
+ const config = await readConfig(root);
207
+ const meta = {
208
+ id: appId,
209
+ title: title || appId,
210
+ icon: icon || '🧩',
211
+ description: description || `Embedded app — ${src}`,
212
+ order: order ?? ((config.features?.length || 0) + 1),
213
+ type: 'app',
214
+ src,
215
+ device: device || 'none',
216
+ chrome: chrome || 'self'
217
+ };
218
+ if (driver) meta.driver = 'driver.js';
219
+
220
+ await mkdirp(dir);
221
+ await writeJSON(path.join(dir, 'feature.json'), meta);
222
+ if (driver) {
223
+ await writeAtomic(path.join(dir, 'driver.js'), DRIVER_TEMPLATE(appId, src));
224
+ }
225
+ config.features = [...(config.features || []), appId];
226
+ await writeConfig(root, config);
227
+ return { feature: meta, driver: driver ? `features/${appId}/driver.js` : null };
228
+ }
229
+
230
+ const DRIVER_TEMPLATE = (id, src) => `/* ScreenBook driver for "${id}" (${src}).
231
+ * Injected into the app AFTER it loads — the app's own files stay untouched.
232
+ * Teach ScreenBook the app's internals, then register: */
233
+ AppBridge.register({
234
+ title: document.title || '${id}',
235
+
236
+ /* REQUIRED — list the app's screens: [{ id, title, description?, group? }] */
237
+ screens: function () {
238
+ // TODO e.g.: return Object.keys(window.Screens).map(function (k) { return { id: k, title: k }; });
239
+ return [];
240
+ },
241
+
242
+ /* REQUIRED — navigate the app to a screen id */
243
+ go: function (id) {
244
+ // TODO e.g.: window.App.go(id);
245
+ },
246
+
247
+ /* optional — current screen id (call AppBridge.navigated(id) on in-app nav too) */
248
+ current: function () { return null; },
249
+
250
+ /* optional — apply environment changes live: { mode, dir, lang, scenario } */
251
+ set: function (env) {
252
+ // TODO e.g.: if (env.mode) document.body.classList.toggle('dark', env.mode === 'dark');
253
+ }
254
+
255
+ /* optional — map the app's own data scenarios into the 🎭 dropdown:
256
+ , scenarios: function () { return [{ id: 'default', title: 'Default' }]; } */
257
+ });
258
+ `;
259
+
260
+ /* ---------------- data / themes / flows ---------------- */
261
+
262
+ export async function getData(root, feature) {
263
+ const dir = path.join(featureDir(root, feature), 'data');
264
+ return {
265
+ seed: await readJSON(path.join(dir, 'seed.json'), {}),
266
+ scenarios: await readJSON(path.join(dir, 'scenarios.json'), { list: [] }),
267
+ i18n: await readJSON(path.join(featureDir(root, feature), 'i18n.json'), null)
268
+ };
269
+ }
270
+
271
+ export async function setData(root, feature, { seed, scenarios, i18n }) {
272
+ if (!(await exists(featureDir(root, feature)))) throw new Error(`feature not found: ${feature}`);
273
+ const dir = path.join(featureDir(root, feature), 'data');
274
+ const written = [];
275
+ if (seed !== undefined) { await writeJSON(path.join(dir, 'seed.json'), seed); written.push('seed.json'); }
276
+ if (scenarios !== undefined) {
277
+ if (!Array.isArray(scenarios.list)) throw new Error('scenarios must be { "list": [...] }');
278
+ await writeJSON(path.join(dir, 'scenarios.json'), scenarios);
279
+ written.push('scenarios.json');
280
+ }
281
+ if (i18n !== undefined) {
282
+ for (const [key, entry] of Object.entries(i18n || {})) {
283
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
284
+ throw new Error(`i18n key "${key}" must map languages to strings (key-first shape)`);
285
+ }
286
+ }
287
+ await writeJSON(path.join(featureDir(root, feature), 'i18n.json'), i18n);
288
+ written.push('i18n.json');
289
+ }
290
+ return { feature, written };
291
+ }
292
+
293
+ export async function listThemes(root) {
294
+ const config = await readConfig(root);
295
+ const ids = config.themes?.length ? config.themes : ['default'];
296
+ const themes = [];
297
+ for (const id of ids) {
298
+ themes.push({ id, theme: await readJSON(path.join(root, 'themes', `${id}.json`), null) });
299
+ }
300
+ return themes;
301
+ }
302
+
303
+ export async function getTheme(root, id) {
304
+ const config = await readConfig(root);
305
+ const themeId = id || config.themes?.[0] || 'default';
306
+ const theme = await readJSON(path.join(root, 'themes', `${themeId}.json`), null);
307
+ if (!theme) throw new Error(`theme not found: themes/${themeId}.json`);
308
+ return { id: themeId, theme };
309
+ }
310
+
311
+ export async function setTheme(root, id, theme) {
312
+ if (!id) throw new Error('theme id required');
313
+ if (!theme?.modes?.light) throw new Error('theme must have modes.light (and usually modes.dark)');
314
+ await writeJSON(path.join(root, 'themes', `${id}.json`), theme);
315
+ const config = await readConfig(root);
316
+ if (!config.themes?.includes(id)) {
317
+ config.themes = [...(config.themes || []), id];
318
+ await writeConfig(root, config);
319
+ }
320
+ return { id, written: `themes/${id}.json` };
321
+ }
322
+
323
+ export async function getFlows(root, feature) {
324
+ return readJSON(path.join(featureDir(root, feature), 'flows.json'), { list: [] });
325
+ }
326
+
327
+ export async function setFlows(root, feature, flows) {
328
+ if (!(await exists(featureDir(root, feature)))) throw new Error(`feature not found: ${feature}`);
329
+ if (!Array.isArray(flows?.list)) throw new Error('flows must be { "list": [...] }');
330
+ await writeJSON(path.join(featureDir(root, feature), 'flows.json'), flows);
331
+ return { feature, written: 'flows.json' };
332
+ }
333
+
334
+ /* ---------------- review comments ---------------- */
335
+
336
+ const commentsPath = (root) => path.join(root, 'review', 'comments.json');
337
+
338
+ export async function getComments(root, { feature, unresolvedOnly } = {}) {
339
+ const box = await readJSON(commentsPath(root), { comments: [] });
340
+ let comments = box.comments || [];
341
+ if (feature) comments = comments.filter((c) => c.feature === feature);
342
+ if (unresolvedOnly) comments = comments.filter((c) => !c.resolved);
343
+ return { comments };
344
+ }
345
+
346
+ export async function setComments(root, comments) {
347
+ await writeJSON(commentsPath(root), { comments });
348
+ return { count: comments.length };
349
+ }
350
+
351
+ export async function resolveComment(root, id, resolved = true) {
352
+ const box = await readJSON(commentsPath(root), { comments: [] });
353
+ const c = (box.comments || []).find((x) => x.id === id);
354
+ if (!c) throw new Error(`comment not found: ${id}`);
355
+ c.resolved = resolved;
356
+ await writeJSON(commentsPath(root), box);
357
+ return { id, resolved };
358
+ }
359
+
360
+ /* ---------------- static validation ---------------- */
361
+
362
+ export async function staticValidate(root) {
363
+ const errors = [];
364
+ const warnings = [];
365
+ const config = await readConfig(root);
366
+
367
+ if (!config.name) warnings.push('app.config.json has no "name"');
368
+ const featureIds = await listFeatureIds(root);
369
+ if (!featureIds.length) errors.push('no features found under features/');
370
+ for (const id of config.features || []) {
371
+ if (!featureIds.includes(id)) errors.push(`app.config.json lists feature "${id}" but features/${id}/ does not exist`);
372
+ }
373
+ if (config.defaultFeature && !featureIds.includes(config.defaultFeature)) {
374
+ errors.push(`defaultFeature "${config.defaultFeature}" does not exist`);
375
+ }
376
+
377
+ const themes = await listThemes(root);
378
+ for (const { id, theme } of themes) {
379
+ if (!theme) errors.push(`theme "${id}" listed in app.config.json but themes/${id}.json is missing or invalid`);
380
+ else if (!theme.modes?.light) errors.push(`theme "${id}" has no modes.light`);
381
+ }
382
+
383
+ for (const id of featureIds) {
384
+ /* app-type features: static half checks wiring only; the browser half
385
+ boots the app and visits every screen (EN-20 gate) */
386
+ const fMeta = await readJSON(path.join(featureDir(root, id), 'feature.json'), null);
387
+ if (fMeta?.type === 'app') {
388
+ if (!fMeta.src) errors.push(`${id}: app feature has no "src"`);
389
+ else if (!(await exists(path.join(root, fMeta.src)))) errors.push(`${id}: app src not found: ${fMeta.src}`);
390
+ if (fMeta.driver && !(await exists(path.join(featureDir(root, id), fMeta.driver)))) {
391
+ errors.push(`${id}: driver not found: features/${id}/${fMeta.driver}`);
392
+ }
393
+ continue;
394
+ }
395
+
396
+ const { screens, problems } = await scanFeature(root, id);
397
+ for (const p of problems) errors.push(`${id}/${p.screen}: ${p.error}`);
398
+
399
+ const ids = new Set(screens.map((s) => s.id));
400
+ for (const s of screens) {
401
+ const html = await readText(screenPath(root, id, s.id));
402
+ for (const lint of lintScreen(html, { id: s.id })) errors.push(`${id}/${s.id}: ${lint}`);
403
+ for (const target of dataGoTargets(html)) {
404
+ if (!ids.has(target)) warnings.push(`${id}/${s.id}: data-go="${target}" points at a screen that doesn't exist in this feature`);
405
+ }
406
+ if (s.device && !DEVICES.includes(s.device)) errors.push(`${id}/${s.id}: bad device "${s.device}"`);
407
+ }
408
+
409
+ /* manifest freshness */
410
+ const manifest = await readJSON(path.join(featureDir(root, id), 'manifest.json'), null);
411
+ if (!manifest) {
412
+ errors.push(`${id}: manifest.json missing — run: screenbook manifest`);
413
+ } else {
414
+ const fresh = { feature: id, generated: null, screens };
415
+ if (JSON.stringify({ ...manifest, generated: null }) !== JSON.stringify(fresh)) {
416
+ warnings.push(`${id}: manifest.json is stale — run: screenbook manifest`);
417
+ }
418
+ }
419
+
420
+ /* i18n checks (key-first shape; fallback+warning policy) */
421
+ const i18nPath = path.join(featureDir(root, id), 'i18n.json');
422
+ if (await exists(i18nPath)) {
423
+ let i18n = null;
424
+ try { i18n = await readJSON(i18nPath); }
425
+ catch (err) { errors.push(`${id}: ${err.message}`); }
426
+ const langs = config.languages || ['en'];
427
+ if (i18n) {
428
+ const missing = {};
429
+ const usedKeys = new Set();
430
+ for (const [key, entry] of Object.entries(i18n)) {
431
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
432
+ errors.push(`${id}: i18n key "${key}" is not a {lang: string} object`);
433
+ continue;
434
+ }
435
+ for (const lang of Object.keys(entry)) {
436
+ if (!langs.includes(lang)) warnings.push(`${id}: i18n key "${key}" has unknown language "${lang}" (not in app.config.json languages)`);
437
+ }
438
+ for (const lang of langs) {
439
+ if (entry[lang] == null) (missing[lang] = missing[lang] || []).push(key);
440
+ }
441
+ }
442
+ for (const [lang, keys] of Object.entries(missing)) {
443
+ warnings.push(`${id}: ${keys.length} i18n key(s) missing "${lang}": ${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', …' : ''}`);
444
+ }
445
+ /* keys referenced by screens but never defined */
446
+ for (const s of screens) {
447
+ const html = await readText(screenPath(root, id, s.id));
448
+ for (const m of html.matchAll(/Engine\.t\(\s*['"]([^'"]+)['"]/g)) usedKeys.add(m[1]);
449
+ }
450
+ try {
451
+ const sharedJs = await readText(path.join(featureDir(root, id), 'screens', 'shared.js'));
452
+ for (const m of sharedJs.matchAll(/Engine\.t\(\s*['"]([^'"]+)['"]/g)) usedKeys.add(m[1]);
453
+ } catch { /* no shared.js */ }
454
+ for (const key of usedKeys) {
455
+ if (!(key in i18n)) warnings.push(`${id}: Engine.t("${key}") has no entry in i18n.json`);
456
+ }
457
+ }
458
+ }
459
+
460
+ /* scenarios + flows cross-checks */
461
+ const { scenarios } = await getData(root, id);
462
+ const scenarioIds = new Set((scenarios.list || []).map((s) => s.id));
463
+ const seen = new Set();
464
+ for (const s of scenarios.list || []) {
465
+ if (seen.has(s.id)) errors.push(`${id}: duplicate scenario id "${s.id}"`);
466
+ seen.add(s.id);
467
+ }
468
+ const flows = await getFlows(root, id);
469
+ for (const flow of flows.list || []) {
470
+ for (const [i, step] of (flow.steps || []).entries()) {
471
+ if (step.screen && !ids.has(step.screen)) {
472
+ errors.push(`${id}: flow "${flow.id}" step ${i + 1} references missing screen "${step.screen}"`);
473
+ }
474
+ if (step.scenario && !scenarioIds.has(step.scenario)) {
475
+ errors.push(`${id}: flow "${flow.id}" step ${i + 1} references missing scenario "${step.scenario}"`);
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ return { ok: errors.length === 0, errors, warnings };
482
+ }