@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,108 @@
1
+ /* Device frames — engine-owned hardware chrome around the screen iframe.
2
+ Screens never draw the hardware (EN §4.6). */
3
+
4
+ const SIZES = {
5
+ ios: { w: 393, h: 852 },
6
+ android: { w: 412, h: 915 },
7
+ web: { w: 1280, h: 800 },
8
+ none: { w: 900, h: 640 }
9
+ };
10
+
11
+ function el(tag, className, html) {
12
+ const node = document.createElement(tag);
13
+ if (className) node.className = className;
14
+ if (html !== undefined) node.innerHTML = html;
15
+ return node;
16
+ }
17
+
18
+ /* Build the frame for a device. Returns { root, viewport, urlPill } —
19
+ the iframe gets appended into `viewport` by the screen host. */
20
+ export function buildFrame(device) {
21
+ const dev = SIZES[device] ? device : 'none';
22
+ const root = el('div', `device dev-${dev}`);
23
+ let viewport;
24
+ let urlPill = null;
25
+
26
+ if (dev === 'ios') {
27
+ viewport = el('div', 'dev-viewport');
28
+ viewport.append(
29
+ el('div', 'dev-island'),
30
+ statusBar('dev-statusbar'),
31
+ el('div', 'dev-homebar')
32
+ );
33
+ root.append(viewport);
34
+ } else if (dev === 'android') {
35
+ viewport = el('div', 'dev-viewport');
36
+ viewport.append(
37
+ el('div', 'dev-punch'),
38
+ statusBar('dev-statusbar-android'),
39
+ el('div', 'dev-gesturebar')
40
+ );
41
+ root.append(viewport);
42
+ } else if (dev === 'web') {
43
+ const bar = el('div', 'dev-browserbar');
44
+ const dots = el('div', 'bb-dots', '<i></i><i></i><i></i>');
45
+ urlPill = el('div', 'bb-url');
46
+ bar.append(dots, urlPill, el('div', 'bb-spacer'));
47
+ viewport = el('div', 'dev-viewport');
48
+ root.append(bar, viewport);
49
+ } else {
50
+ viewport = el('div', 'dev-viewport');
51
+ root.append(viewport);
52
+ }
53
+
54
+ return { root, viewport, urlPill, device: dev, size: SIZES[dev] };
55
+ }
56
+
57
+ function statusBar(cls) {
58
+ const bar = el('div', cls);
59
+ const time = el('span', 'sb-time', '9:41');
60
+ const icons = el('span', 'sb-icons');
61
+ icons.innerHTML = signalSvg() + wifiSvg() + batterySvg();
62
+ bar.append(time, icons);
63
+ return bar;
64
+ }
65
+
66
+ function signalSvg() {
67
+ return `<svg width="17" height="11" viewBox="0 0 17 11" fill="currentColor"><rect x="0" y="7" width="3" height="4" rx="1"/><rect x="4.5" y="5" width="3" height="6" rx="1"/><rect x="9" y="2.5" width="3" height="8.5" rx="1"/><rect x="13.5" y="0" width="3" height="11" rx="1"/></svg>`;
68
+ }
69
+ function wifiSvg() {
70
+ return `<svg width="15" height="11" viewBox="0 0 15 11" fill="currentColor"><path d="M7.5 9.2a1.4 1.4 0 1 0 0 2.8 1.4 1.4 0 0 0 0-2.8z" transform="translate(0,-1.2)"/><path d="M7.5 6.2c1.4 0 2.7.5 3.7 1.4l-1.1 1.2a3.9 3.9 0 0 0-5.2 0L3.8 7.6a5.5 5.5 0 0 1 3.7-1.4z"/><path d="M7.5 2.8c2.3 0 4.4.9 6 2.3l-1.1 1.2a7.1 7.1 0 0 0-9.8 0L1.5 5.1a8.7 8.7 0 0 1 6-2.3z"/></svg>`;
71
+ }
72
+ function batterySvg() {
73
+ return `<svg width="25" height="12" viewBox="0 0 25 12" fill="none"><rect x="0.5" y="0.5" width="21" height="11" rx="3" stroke="currentColor" opacity=".4"/><rect x="2" y="2" width="18" height="8" rx="1.8" fill="currentColor"/><path d="M23 4v4c1-.3 1.6-1 1.6-2S24 4.3 23 4z" fill="currentColor" opacity=".4"/></svg>`;
74
+ }
75
+
76
+ /* Scale-to-fit: shrink the frame when the stage is smaller than the device.
77
+ Never scales above 1. Returns a cleanup fn. */
78
+ export function fitFrame(stage, fitWrap, frame) {
79
+ const outer = fitWrap.querySelector('.frame-outer');
80
+ const { w, h } = frame.size;
81
+
82
+ function apply() {
83
+ const pad = 18;
84
+ const availW = stage.clientWidth - pad * 2;
85
+ const availH = stage.clientHeight - pad * 2;
86
+ if (availW <= 0 || availH <= 0) return;
87
+ const scale = frame.device === 'none'
88
+ ? Math.min(1, availW / w, availH / h)
89
+ : Math.min(1, availW / w, availH / h);
90
+ outer.style.setProperty('--frame-scale', String(scale));
91
+ /* the wrapper reserves the scaled footprint so centering stays true */
92
+ fitWrap.style.width = `${Math.round(w * scale)}px`;
93
+ fitWrap.style.height = `${Math.round((frame.root.offsetHeight || h) * scale)}px`;
94
+ }
95
+
96
+ const ro = new ResizeObserver(apply);
97
+ ro.observe(stage);
98
+ apply();
99
+ requestAnimationFrame(apply);
100
+ return () => ro.disconnect();
101
+ }
102
+
103
+ export const DEVICE_LABELS = {
104
+ ios: { icon: '', title: 'iPhone' },
105
+ android: { icon: '🤖', title: 'Android' },
106
+ web: { icon: '🖥', title: 'Web' },
107
+ none: { icon: '▦', title: 'Bare' }
108
+ };
package/shell/main.js ADDED
@@ -0,0 +1,397 @@
1
+ /* ScreenBook shell — boot + orchestration. */
2
+
3
+ import { store, set, subscribe, feature, applyHash } from './store.js';
4
+ import { loadProject, reloadFeature, fetchJSON, detectServe, connectEvents } from './net.js';
5
+ import { renderSidebar, visibleScreens, scrollActiveIntoView } from './sidebar.js';
6
+ import { showScreen, pushUpdate, reloadScreen, onScreenMessage, clearVariant } from './screenhost.js';
7
+ import { dropdown, toggle, action, divider } from './toolbar.js';
8
+ import { DEVICE_LABELS } from './frames.js';
9
+ import { startFlow, stopFlow, stepFlow, renderFlowbar, resumeFromHash, activeFlow } from './flowplayer.js';
10
+ import { initReview, renderReviewPanel, pushPinsToScreen, handlePinRequest, handlePinOpen } from './review.js';
11
+ import { renderThemeLab, themeLabDirty } from './themelab.js';
12
+ import { runSelftest } from './selftest.js';
13
+ import { toast } from './toast.js';
14
+
15
+ const params = new URLSearchParams(location.search);
16
+ const SELFTEST = params.get('selftest') === '1';
17
+ const SOLO = params.get('solo') === '1';
18
+
19
+ const controlsBox = document.getElementById('controls');
20
+ const widgets = [];
21
+ let flowWidget = null;
22
+ let themeWidget = null;
23
+ let langWidget = null;
24
+ let deviceWidget = null;
25
+
26
+ const RTL_LANGS = new Set(['ar', 'he', 'fa', 'ur']);
27
+
28
+ /* ---------------- toolbar ---------------- */
29
+
30
+ function buildControls() {
31
+ const featureDD = dropdown({
32
+ icon: '⧉', title: 'Feature',
33
+ value: () => feature()?.meta.title || '—',
34
+ head: 'Features',
35
+ empty: 'No features in app.config.json',
36
+ items: () => {
37
+ const items = [];
38
+ let lastGroup;
39
+ for (const f of store.project.features.values()) {
40
+ const group = f.meta.group || null;
41
+ if (group && group !== lastGroup) items.push({ head: true, title: group });
42
+ lastGroup = group;
43
+ items.push({
44
+ id: f.id,
45
+ icon: f.meta.icon || '📄',
46
+ title: f.meta.title || f.id,
47
+ desc: f.meta.description,
48
+ checked: f.id === store.ui.feature
49
+ });
50
+ }
51
+ return items;
52
+ },
53
+ onPick: (id) => set({ feature: id, flow: null })
54
+ });
55
+
56
+ deviceWidget = dropdown({
57
+ icon: '📱', title: 'Device',
58
+ value: () => store.ui.deviceFilter === 'all' ? 'All' : (DEVICE_LABELS[store.ui.deviceFilter]?.title || store.ui.deviceFilter),
59
+ head: 'Filter screens by device',
60
+ items: () => {
61
+ const devices = store.project.config.devices || ['ios', 'android', 'web'];
62
+ return [
63
+ { id: 'all', icon: '✳︎', title: 'All devices', checked: store.ui.deviceFilter === 'all' },
64
+ ...devices.map((d) => ({
65
+ id: d,
66
+ icon: DEVICE_LABELS[d]?.icon || '▦',
67
+ title: DEVICE_LABELS[d]?.title || d,
68
+ checked: store.ui.deviceFilter === d
69
+ }))
70
+ ];
71
+ },
72
+ onPick: (id) => set({ deviceFilter: id })
73
+ });
74
+
75
+ const scenarioDD = dropdown({
76
+ icon: '🎭', title: 'Scenario',
77
+ value: () => {
78
+ const s = (feature()?.scenarios.list || []).find((x) => x.id === store.ui.scenario);
79
+ return s ? s.title : 'Default';
80
+ },
81
+ head: 'Data scenarios',
82
+ empty: 'This feature has no scenarios.json',
83
+ items: () => (feature()?.scenarios.list || []).map((s) => ({
84
+ id: s.id,
85
+ icon: s.icon || '🎭',
86
+ title: s.title || s.id,
87
+ desc: s.description,
88
+ checked: s.id === store.ui.scenario
89
+ })),
90
+ onPick: (id) => set({ scenario: id })
91
+ });
92
+
93
+ themeWidget = toggle({
94
+ icon: '🎨', tooltip: 'Theme Lab — view + edit design tokens live',
95
+ label: () => store.project.themes.size > 1
96
+ ? (store.project.themes.get(store.ui.theme)?.title || store.ui.theme)
97
+ : '',
98
+ is: () => store.ui.themeLab,
99
+ onToggle: () => set({ themeLab: !store.ui.themeLab, ...(store.ui.themeLab ? {} : { review: false }) })
100
+ });
101
+
102
+ const modeToggle = toggle({
103
+ icon: '☾', iconOn: '☀︎', tooltip: 'Dark / light',
104
+ is: () => store.ui.mode === 'dark',
105
+ onToggle: () => set({ mode: store.ui.mode === 'dark' ? 'light' : 'dark' })
106
+ });
107
+
108
+ const dirToggle = toggle({
109
+ icon: '⇄', tooltip: 'LTR / RTL',
110
+ label: () => store.ui.dir.toUpperCase(),
111
+ is: () => store.ui.dir === 'rtl',
112
+ onToggle: () => set({ dir: store.ui.dir === 'rtl' ? 'ltr' : 'rtl' })
113
+ });
114
+
115
+ langWidget = dropdown({
116
+ icon: '🌐', tooltip: 'Language',
117
+ value: () => store.ui.lang,
118
+ head: 'Language (sets Engine.env.lang)',
119
+ items: () => (store.project.config.languages || ['en']).map((l) => ({
120
+ id: l, icon: '🌐', title: l, checked: l === store.ui.lang
121
+ })),
122
+ onPick: (id) => {
123
+ const patch = { lang: id };
124
+ patch.dir = RTL_LANGS.has(id) ? 'rtl' : 'ltr';
125
+ set(patch);
126
+ }
127
+ });
128
+
129
+ flowWidget = dropdown({
130
+ icon: '▶', tooltip: 'Play a flow',
131
+ value: () => activeFlow()?.title || null,
132
+ head: 'Flows',
133
+ empty: 'This feature has no flows.json',
134
+ items: () => (feature()?.flows.list || []).map((fl) => ({
135
+ id: fl.id,
136
+ icon: fl.icon || '▶',
137
+ title: fl.title || fl.id,
138
+ desc: fl.description,
139
+ checked: fl.id === store.ui.flow
140
+ })),
141
+ onPick: (id) => (id === store.ui.flow ? stopFlow() : startFlow(id))
142
+ });
143
+
144
+ const reviewToggle = toggle({
145
+ icon: '📌', tooltip: 'Review Mode — pin comments on screens',
146
+ danger: true,
147
+ is: () => store.ui.review,
148
+ onToggle: () => set({ review: !store.ui.review, ...(store.ui.review ? {} : { themeLab: false }) })
149
+ });
150
+
151
+ const resetBtn = action({
152
+ icon: '↺', tooltip: 'Reset shared Engine.state',
153
+ onClick: () => {
154
+ const keys = [];
155
+ for (let i = 0; i < localStorage.length; i++) {
156
+ const k = localStorage.key(i);
157
+ if (k && k.startsWith('sb:state:')) keys.push(k);
158
+ }
159
+ keys.forEach((k) => localStorage.removeItem(k));
160
+ reloadScreen();
161
+ toast(`State cleared (${keys.length} key${keys.length === 1 ? '' : 's'})`);
162
+ }
163
+ });
164
+
165
+ widgets.push(featureDD, deviceWidget, scenarioDD, divider(), themeWidget, modeToggle, dirToggle, langWidget, divider(), flowWidget, reviewToggle, resetBtn);
166
+ for (const w of widgets) controlsBox.append(w.el);
167
+ }
168
+
169
+ function refreshControls() {
170
+ for (const w of widgets) w.refresh();
171
+ const cfg = store.project.config;
172
+ langWidget.el.style.display = (cfg.languages || []).length > 1 ? '' : 'none';
173
+ deviceWidget.el.style.display = (cfg.devices || []).length > 1 ? '' : 'none';
174
+ flowWidget.el.style.display = (feature()?.flows.list || []).length ? '' : 'none';
175
+ }
176
+
177
+ /* ---------------- rendering ---------------- */
178
+
179
+ function ensureValidSelection() {
180
+ const p = store.project;
181
+ if (!p.features.size) return;
182
+ if (!p.features.has(store.ui.feature)) {
183
+ store.ui.feature = p.config.defaultFeature && p.features.has(p.config.defaultFeature)
184
+ ? p.config.defaultFeature
185
+ : [...p.features.keys()][0];
186
+ }
187
+ const f = feature();
188
+ const screens = f.type === 'app' ? (f.appScreens || []) : (f.manifest.screens || []);
189
+ if (!screens.find((s) => s.id === store.ui.screen)) {
190
+ /* for apps, keep a deep-linked screen until the live list arrives */
191
+ if (f.type !== 'app' || f.appScreens !== null) {
192
+ store.ui.screen = screens[0]?.id || (f.type === 'app' ? store.ui.screen : null);
193
+ }
194
+ }
195
+ if (store.ui.scenario !== 'default' &&
196
+ !(f.scenarios.list || []).find((s) => s.id === store.ui.scenario)) {
197
+ store.ui.scenario = 'default';
198
+ }
199
+ if (!p.themes.has(store.ui.theme)) {
200
+ store.ui.theme = [...p.themes.keys()][0] || null;
201
+ }
202
+ }
203
+
204
+ function renderAll() {
205
+ document.documentElement.setAttribute('data-shell', store.ui.mode);
206
+ document.getElementById('project-name').textContent = store.project.config.name || 'Untitled project';
207
+ refreshControls();
208
+ renderSidebar();
209
+ showScreen();
210
+ renderFlowbar();
211
+ renderReviewPanel();
212
+ renderThemeLab();
213
+ }
214
+
215
+ function onChange(changed) {
216
+ if (changed.has('feature')) {
217
+ ensureValidSelection();
218
+ renderAll();
219
+ pushPinsToScreen();
220
+ return;
221
+ }
222
+ if (changed.has('mode')) document.documentElement.setAttribute('data-shell', store.ui.mode);
223
+ if (changed.has('screen')) {
224
+ clearVariant();
225
+ renderSidebar();
226
+ showScreen();
227
+ scrollActiveIntoView();
228
+ pushPinsToScreen();
229
+ }
230
+ if (changed.has('scenario')) {
231
+ if (feature()?.type === 'app') pushUpdate(); // apps map scenarios live, no remount
232
+ else reloadScreen();
233
+ }
234
+ if (changed.has('mode') || changed.has('dir') || changed.has('theme')) pushUpdate();
235
+ if (changed.has('lang')) {
236
+ /* Engine.t strings bind at ready-time — reload file screens on language
237
+ switch (same semantics as scenario changes); apps take env live */
238
+ if (feature()?.type === 'app') pushUpdate();
239
+ else reloadScreen();
240
+ }
241
+ if (changed.has('search') || changed.has('deviceFilter')) renderSidebar();
242
+ if (changed.has('flow') || changed.has('flowStep')) renderFlowbar();
243
+ if (changed.has('review')) { renderReviewPanel(); pushPinsToScreen(); }
244
+ if (changed.has('themeLab') || ((changed.has('mode') || changed.has('theme')) && store.ui.themeLab)) renderThemeLab();
245
+ refreshControls();
246
+ }
247
+
248
+ /* ---------------- screen events ---------------- */
249
+
250
+ onScreenMessage({
251
+ onGo(id) {
252
+ const f = feature();
253
+ const pool = f?.type === 'app' ? (f.appScreens || []) : (f?.manifest.screens || []);
254
+ const target = pool.find((s) => s.id === id);
255
+ if (target) set({ screen: id, ...(f?.type === 'app' ? {} : { flow: null }) });
256
+ else if (f?.type === 'app') set({ screen: id }); // trust the app's own navigation
257
+ else toast(`No screen “${id}” in this feature`);
258
+ },
259
+ onAppUpdate() {
260
+ ensureValidSelection();
261
+ renderSidebar();
262
+ refreshControls();
263
+ showScreen();
264
+ },
265
+ onReady() { pushPinsToScreen(); },
266
+ onConsoleError(message) {
267
+ if (!SELFTEST) console.warn(`[screen] ${message}`);
268
+ },
269
+ onPinRequest: (msg) => { if (store.ui.review) handlePinRequest(msg); },
270
+ onPinOpen: (id) => handlePinOpen(id)
271
+ });
272
+
273
+ /* ---------------- keyboard ---------------- */
274
+
275
+ function wireKeyboard() {
276
+ document.addEventListener('keydown', (e) => {
277
+ const inField = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName || '');
278
+ if (inField) return;
279
+ if (e.key === '/') {
280
+ e.preventDefault();
281
+ document.getElementById('search').focus();
282
+ return;
283
+ }
284
+ if (store.ui.flow && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
285
+ e.preventDefault();
286
+ const rtl = store.ui.dir === 'rtl';
287
+ stepFlow((e.key === 'ArrowRight') !== rtl ? 1 : -1);
288
+ return;
289
+ }
290
+ if (['ArrowDown', 'ArrowUp', 'j', 'k'].includes(e.key)) {
291
+ const screens = visibleScreens();
292
+ if (!screens.length) return;
293
+ e.preventDefault();
294
+ const i = screens.findIndex((s) => s.id === store.ui.screen);
295
+ const delta = (e.key === 'ArrowDown' || e.key === 'j') ? 1 : -1;
296
+ const next = screens[Math.min(screens.length - 1, Math.max(0, i + delta))];
297
+ if (next && next.id !== store.ui.screen) set({ screen: next.id, flow: null });
298
+ }
299
+ });
300
+ }
301
+
302
+ /* ---------------- live reload (engine serve) ---------------- */
303
+
304
+ function wireLiveReload() {
305
+ let pending = new Set();
306
+ let timer = null;
307
+
308
+ connectEvents((evt) => {
309
+ pending.add(JSON.stringify(evt));
310
+ clearTimeout(timer);
311
+ timer = setTimeout(async () => {
312
+ const events = [...pending].map((s) => JSON.parse(s));
313
+ pending = new Set();
314
+ let touchedThemes = false;
315
+ const touchedFeatures = new Set();
316
+ for (const e of events) {
317
+ if (e.area === 'theme' || e.area === 'config') touchedThemes = true;
318
+ if (e.feature) touchedFeatures.add(e.feature);
319
+ }
320
+ try {
321
+ if (touchedThemes && !(store.ui.themeLab && themeLabDirty())) {
322
+ /* skip while the Theme Lab holds unsaved edits — disk must not clobber them */
323
+ const cfg = await fetchJSON('app.config.json', { optional: true });
324
+ if (cfg) store.project.config = cfg;
325
+ for (const id of cfg?.themes || [...store.project.themes.keys()]) {
326
+ const t = await fetchJSON(`themes/${id}.json`, { optional: true });
327
+ if (t) store.project.themes.set(id, t);
328
+ }
329
+ }
330
+ for (const id of touchedFeatures) await reloadFeature(store.project, id);
331
+ } catch { /* transient mid-write reads are fine; next event refreshes */ }
332
+ ensureValidSelection();
333
+ renderSidebar();
334
+ refreshControls();
335
+ renderFlowbar();
336
+ if (touchedFeatures.has(store.ui.feature) || touchedThemes) {
337
+ reloadScreen({ bust: true });
338
+ pushUpdate();
339
+ }
340
+ toast('↻ Live-reloaded');
341
+ }, 160);
342
+ });
343
+ }
344
+
345
+ /* ---------------- boot ---------------- */
346
+
347
+ async function boot() {
348
+ if (SOLO) {
349
+ document.body.classList.add('solo');
350
+ store.ui.solo = true;
351
+ }
352
+
353
+ store.project = await loadProject();
354
+
355
+ const cfg = store.project.config;
356
+ store.ui.theme = cfg.themes?.[0] || [...store.project.themes.keys()][0] || null;
357
+ store.ui.lang = cfg.defaultLanguage || (cfg.languages || ['en'])[0] || 'en';
358
+ store.ui.feature = cfg.defaultFeature || [...store.project.features.keys()][0] || null;
359
+
360
+ applyHash();
361
+ ensureValidSelection();
362
+
363
+ if (SELFTEST) {
364
+ document.getElementById('sidebar').style.pointerEvents = 'none';
365
+ await runSelftest();
366
+ return;
367
+ }
368
+
369
+ store.ui.serveLive = await detectServe();
370
+ const badge = document.getElementById('serve-badge');
371
+ if (store.ui.serveLive) {
372
+ badge.textContent = '● live';
373
+ badge.classList.add('is-live');
374
+ badge.title = 'engine serve — live reload + review comments sync to disk';
375
+ wireLiveReload();
376
+ }
377
+
378
+ await initReview();
379
+ buildControls();
380
+ subscribe(onChange);
381
+ if (store.ui.flow) resumeFromHash(); // deep link into a flow: sync step → screen/scenario
382
+ renderAll();
383
+ wireKeyboard();
384
+
385
+ window.addEventListener('hashchange', () => {
386
+ applyHash();
387
+ if (store.ui.flow) resumeFromHash();
388
+ });
389
+ }
390
+
391
+ boot().catch((err) => {
392
+ const stage = document.getElementById('stage');
393
+ stage.innerHTML = `<div class="stage-empty"><h2>ScreenBook could not load this project</h2>
394
+ <p>${String(err?.message || err).replace(/</g, '&lt;')}</p>
395
+ <p>Expected layout: <code>app.config.json</code> + <code>features/&lt;id&gt;/manifest.json</code> next to <code>engine/</code>.</p></div>`;
396
+ console.error(err);
397
+ });
package/shell/net.js ADDED
@@ -0,0 +1,126 @@
1
+ /* Project loading + live-serve detection. The shell lives at <project>/engine/,
2
+ so the project root is always one level up from this page. */
3
+
4
+ export const PROJECT_ROOT = new URL('..', location.href).href;
5
+
6
+ export function url(path) {
7
+ return new URL(path.replace(/^\//, ''), PROJECT_ROOT).href;
8
+ }
9
+
10
+ export async function fetchJSON(path, { optional = false } = {}) {
11
+ try {
12
+ const res = await fetch(url(path), { cache: 'no-store' });
13
+ if (!res.ok) throw new Error(`${res.status} ${path}`);
14
+ return await res.json();
15
+ } catch (err) {
16
+ if (optional) return null;
17
+ throw err;
18
+ }
19
+ }
20
+
21
+ async function loadFeature(id) {
22
+ const meta = await fetchJSON(`features/${id}/feature.json`, { optional: true });
23
+
24
+ /* app-type feature (EN-20): screens come from the live app, not from files */
25
+ if (meta?.type === 'app') {
26
+ const flows = await fetchJSON(`features/${id}/flows.json`, { optional: true });
27
+ return {
28
+ id,
29
+ type: 'app',
30
+ meta,
31
+ manifest: { screens: [] },
32
+ appScreens: null, // filled by sba:screens after the app says hello
33
+ appTitle: null,
34
+ seed: {},
35
+ scenarios: { list: [] }, // may be filled from sba:hello (driver-mapped 🎭)
36
+ flows: flows || { list: [] }
37
+ };
38
+ }
39
+
40
+ const [manifest, seed, scenarios, flows, i18n] = await Promise.all([
41
+ fetchJSON(`features/${id}/manifest.json`, { optional: true }),
42
+ fetchJSON(`features/${id}/data/seed.json`, { optional: true }),
43
+ fetchJSON(`features/${id}/data/scenarios.json`, { optional: true }),
44
+ fetchJSON(`features/${id}/flows.json`, { optional: true }),
45
+ fetchJSON(`features/${id}/i18n.json`, { optional: true })
46
+ ]);
47
+ return {
48
+ id,
49
+ meta: meta || { id, title: id, icon: '📄', order: 999 },
50
+ manifest: manifest || { screens: [] },
51
+ seed: seed || {},
52
+ scenarios: scenarios || { list: [] },
53
+ flows: flows || { list: [] },
54
+ i18n: i18n || null
55
+ };
56
+ }
57
+
58
+ export async function loadProject() {
59
+ const config = await fetchJSON('app.config.json', { optional: true }) || {};
60
+ const featureIds = config.features || [];
61
+ const themeIds = config.themes?.length ? config.themes : ['default'];
62
+
63
+ const [featureList, themeList] = await Promise.all([
64
+ Promise.all(featureIds.map(loadFeature)),
65
+ Promise.all(themeIds.map((id) => fetchJSON(`themes/${id}.json`, { optional: true })))
66
+ ]);
67
+
68
+ featureList.sort((a, b) => (a.meta.order ?? 999) - (b.meta.order ?? 999) || a.id.localeCompare(b.id));
69
+
70
+ const features = new Map(featureList.map((f) => [f.id, f]));
71
+ const themes = new Map();
72
+ themeIds.forEach((id, i) => { if (themeList[i]) themes.set(id, themeList[i]); });
73
+ if (!themes.size) themes.set('fallback', window.EngineKit.FALLBACK_THEME);
74
+
75
+ return { config, features, themes };
76
+ }
77
+
78
+ export async function reloadFeature(project, id) {
79
+ const fresh = await loadFeature(id);
80
+ project.features.set(id, fresh);
81
+ return fresh;
82
+ }
83
+
84
+ /* ---------------- live serve (engine serve) ---------------- */
85
+
86
+ export async function detectServe() {
87
+ try {
88
+ const res = await fetch(url('__sb/health'), { cache: 'no-store' });
89
+ if (!res.ok) return false;
90
+ const body = await res.json();
91
+ return body?.screenbook === true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /* Server-sent events → change callbacks. Events: {kind, feature?, screen?, path?} */
98
+ export function connectEvents(onChange) {
99
+ let source;
100
+ try {
101
+ source = new EventSource(url('__sb/events'));
102
+ } catch {
103
+ return () => {};
104
+ }
105
+ source.onmessage = (e) => {
106
+ try { onChange(JSON.parse(e.data)); } catch { /* ignore malformed */ }
107
+ };
108
+ source.onerror = () => { /* EventSource auto-reconnects */ };
109
+ return () => source.close();
110
+ }
111
+
112
+ /* ---------------- review comment persistence ---------------- */
113
+
114
+ export async function fetchComments() {
115
+ return fetchJSON('__sb/review/comments', { optional: true });
116
+ }
117
+
118
+ export async function saveComments(comments) {
119
+ const res = await fetch(url('__sb/review/comments'), {
120
+ method: 'POST',
121
+ headers: { 'Content-Type': 'application/json' },
122
+ body: JSON.stringify({ comments }, null, 2)
123
+ });
124
+ if (!res.ok) throw new Error(`save failed: ${res.status}`);
125
+ return res.json();
126
+ }