@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,213 @@
1
+ /* Review Mode (EN-16): pinned comments with full context capture.
2
+ Persistence: __sb/review API when running under `engine serve`,
3
+ localStorage fallback under any other static server. */
4
+
5
+ import { store, set } from './store.js';
6
+ import { setReview } from './screenhost.js';
7
+ import { fetchComments, saveComments } from './net.js';
8
+ import { toast } from './toast.js';
9
+
10
+ const panel = document.getElementById('review-panel');
11
+ const LS_KEY = 'sb:review:comments';
12
+ const LS_NAME = 'sb:review:reviewer';
13
+
14
+ let comments = [];
15
+ let pending = null; // pin awaiting a note { xPct, yPct, cssPath }
16
+ let focusId = null;
17
+ let saveTimer = null;
18
+
19
+ export async function initReview() {
20
+ if (store.ui.serveLive) {
21
+ const disk = await fetchComments();
22
+ comments = disk?.comments || [];
23
+ } else {
24
+ try { comments = JSON.parse(localStorage.getItem(LS_KEY) || '[]'); }
25
+ catch { comments = []; }
26
+ }
27
+ }
28
+
29
+ function persist() {
30
+ clearTimeout(saveTimer);
31
+ saveTimer = setTimeout(async () => {
32
+ if (store.ui.serveLive) {
33
+ try { await saveComments(comments); }
34
+ catch { toast('⚠︎ Could not sync comments to disk'); }
35
+ } else {
36
+ try { localStorage.setItem(LS_KEY, JSON.stringify(comments)); } catch { /* full */ }
37
+ }
38
+ }, 250);
39
+ }
40
+
41
+ function screenPins() {
42
+ return comments
43
+ .filter((c) => c.feature === store.ui.feature && c.screen === store.ui.screen)
44
+ .map((c, idx) => ({ id: c.id, n: idx + 1, xPct: c.xPct, yPct: c.yPct, resolved: !!c.resolved, note: c.note }));
45
+ }
46
+
47
+ export function pushPinsToScreen() {
48
+ setReview(store.ui.review, store.ui.review ? screenPins() : []);
49
+ }
50
+
51
+ export function handlePinRequest(msg) {
52
+ pending = { xPct: msg.xPct, yPct: msg.yPct, cssPath: msg.cssPath };
53
+ renderReviewPanel();
54
+ panel.querySelector('textarea')?.focus();
55
+ }
56
+
57
+ export function handlePinOpen(id) {
58
+ focusId = id;
59
+ renderReviewPanel();
60
+ panel.querySelector('.rp-item.is-focus')?.scrollIntoView({ block: 'nearest' });
61
+ }
62
+
63
+ function addComment(note) {
64
+ if (!pending || !note.trim()) return;
65
+ const reviewer = localStorage.getItem(LS_NAME) || '';
66
+ comments.push({
67
+ id: `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
68
+ note: note.trim(),
69
+ reviewer: reviewer || undefined,
70
+ created: window.EngineKit.isoDate(0),
71
+ feature: store.ui.feature,
72
+ screen: store.ui.screen,
73
+ device: store.ui.deviceFilter === 'all' ? undefined : store.ui.deviceFilter,
74
+ scenario: store.ui.scenario,
75
+ theme: store.ui.theme,
76
+ mode: store.ui.mode,
77
+ dir: store.ui.dir,
78
+ xPct: pending.xPct,
79
+ yPct: pending.yPct,
80
+ cssPath: pending.cssPath,
81
+ resolved: false
82
+ });
83
+ pending = null;
84
+ persist();
85
+ pushPinsToScreen();
86
+ renderReviewPanel();
87
+ toast('Pinned ✓');
88
+ }
89
+
90
+ export function resolveComment(id, resolved = true) {
91
+ const c = comments.find((x) => x.id === id);
92
+ if (!c) return false;
93
+ c.resolved = resolved;
94
+ persist();
95
+ pushPinsToScreen();
96
+ renderReviewPanel();
97
+ return true;
98
+ }
99
+
100
+ function deleteComment(id) {
101
+ const i = comments.findIndex((x) => x.id === id);
102
+ if (i >= 0) {
103
+ comments.splice(i, 1);
104
+ persist();
105
+ pushPinsToScreen();
106
+ renderReviewPanel();
107
+ }
108
+ }
109
+
110
+ function exportComments() {
111
+ const blob = new Blob(
112
+ [JSON.stringify({ exported: new Date().toISOString(), comments }, null, 2)],
113
+ { type: 'application/json' }
114
+ );
115
+ const a = document.createElement('a');
116
+ a.href = URL.createObjectURL(blob);
117
+ a.download = 'screenbook-review.json';
118
+ a.click();
119
+ URL.revokeObjectURL(a.href);
120
+ }
121
+
122
+ function esc(s) {
123
+ return String(s ?? '').replace(/[&<>"]/g, (c) => (
124
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]
125
+ ));
126
+ }
127
+
128
+ export function renderReviewPanel() {
129
+ if (!store.ui.review) {
130
+ panel.hidden = true;
131
+ panel.innerHTML = '';
132
+ return;
133
+ }
134
+ panel.hidden = false;
135
+
136
+ const open = comments.filter((c) => !c.resolved).length;
137
+ const reviewer = localStorage.getItem(LS_NAME) || '';
138
+ const items = comments
139
+ .map((c, i) => ({ c, n: i + 1 }))
140
+ .filter(({ c }) => c.feature === store.ui.feature)
141
+ .sort((a, b) => (a.c.screen || '').localeCompare(b.c.screen || '') || a.n - b.n);
142
+
143
+ panel.innerHTML =
144
+ `<div class="rp-head"><h3>Review<span class="rp-count">${open} open</span></h3>
145
+ <div class="rp-tools">
146
+ <button class="rp-tool" data-act="export">Export JSON</button>
147
+ </div></div>
148
+ <div class="rp-reviewer"><input id="rp-name" placeholder="Reviewer name (saved once)" value="${esc(reviewer)}"></div>
149
+ ${pending
150
+ ? `<div class="rp-composer">
151
+ <textarea id="rp-note" placeholder="What should change here?"></textarea>
152
+ <div class="rc-row">
153
+ <span class="rc-ctx">${esc(pending.cssPath || '')}</span>
154
+ <span style="display:flex;gap:6px">
155
+ <button class="btn-ghost" data-act="cancel">Cancel</button>
156
+ <button class="btn-primary" data-act="save">Pin it</button>
157
+ </span>
158
+ </div>
159
+ </div>`
160
+ : `<div class="rp-hint">Click anywhere on the screen to pin a comment. Pins capture screen, scenario, theme, mode and the element under the cursor.</div>`}
161
+ <div class="rp-list">${items.map(({ c }) => {
162
+ const pinsHere = c.screen === store.ui.screen;
163
+ const num = pinsHere ? screenPins().findIndex((p) => p.id === c.id) + 1 : null;
164
+ return `<div class="rp-item${c.id === focusId ? ' is-focus' : ''}${c.resolved ? ' is-resolved' : ''}" data-id="${esc(c.id)}">
165
+ <div class="ri-top"><span class="ri-n">${num || '•'}</span><span class="ri-note">${esc(c.note)}</span></div>
166
+ <div class="ri-ctx">${esc(c.screen)} · ${esc(c.scenario)} · ${esc(c.mode)}${c.dir === 'rtl' ? ' · rtl' : ''}${c.cssPath ? `<br>${esc(c.cssPath)}` : ''}</div>
167
+ <div class="ri-meta">${c.reviewer ? `<span class="pill">${esc(c.reviewer)}</span>` : ''}<span class="sr-date">${esc(c.created || '')}</span></div>
168
+ <div class="ri-actions">
169
+ <button class="btn-ghost" data-act="${c.resolved ? 'reopen' : 'resolve'}">${c.resolved ? 'Reopen' : 'Resolve'}</button>
170
+ <button class="btn-ghost" data-act="delete">Delete</button>
171
+ ${!pinsHere ? `<button class="btn-ghost" data-act="goto">Go to screen</button>` : ''}
172
+ </div>
173
+ </div>`;
174
+ }).join('') || '<div class="list-empty">No comments in this feature yet.</div>'}</div>`;
175
+
176
+ panel.querySelector('#rp-name').addEventListener('change', (e) => {
177
+ localStorage.setItem(LS_NAME, e.target.value.trim());
178
+ });
179
+ panel.querySelector('[data-act="export"]').addEventListener('click', exportComments);
180
+ panel.querySelector('[data-act="save"]')?.addEventListener('click', () => {
181
+ addComment(panel.querySelector('#rp-note').value);
182
+ });
183
+ panel.querySelector('#rp-note')?.addEventListener('keydown', (e) => {
184
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) addComment(e.target.value);
185
+ });
186
+ panel.querySelector('[data-act="cancel"]')?.addEventListener('click', () => {
187
+ pending = null;
188
+ renderReviewPanel();
189
+ });
190
+
191
+ panel.querySelectorAll('.rp-item').forEach((item) => {
192
+ const id = item.dataset.id;
193
+ item.addEventListener('click', () => { focusId = id; });
194
+ item.querySelector('[data-act="resolve"], [data-act="reopen"]')?.addEventListener('click', (e) => {
195
+ e.stopPropagation();
196
+ const c = comments.find((x) => x.id === id);
197
+ resolveComment(id, !c.resolved);
198
+ });
199
+ item.querySelector('[data-act="delete"]')?.addEventListener('click', (e) => {
200
+ e.stopPropagation();
201
+ deleteComment(id);
202
+ });
203
+ item.querySelector('[data-act="goto"]')?.addEventListener('click', (e) => {
204
+ e.stopPropagation();
205
+ const c = comments.find((x) => x.id === id);
206
+ if (c) set({ screen: c.screen, scenario: c.scenario || store.ui.scenario });
207
+ });
208
+ });
209
+ }
210
+
211
+ export function reviewComments() {
212
+ return comments;
213
+ }
@@ -0,0 +1,380 @@
1
+ /* Screen host — owns the device frame + iframe and speaks sb:* postMessage
2
+ with the bridge inside the screen. One screen mounted at a time. */
3
+
4
+ import { store, feature, screenEntry, activeTheme, activeScenario } from './store.js';
5
+ import { buildFrame, fitFrame } from './frames.js';
6
+ import { url } from './net.js';
7
+
8
+ const Kit = window.EngineKit;
9
+ const stage = document.getElementById('stage');
10
+
11
+ let current = null; // { featureId | appId, screenId, frame, iframe, unfit, veil, veilTimer, ready }
12
+ let handlers = {
13
+ onGo: () => {},
14
+ onReady: () => {},
15
+ onConsoleError: () => {},
16
+ onPinRequest: () => {},
17
+ onPinOpen: () => {},
18
+ onAppUpdate: () => {}
19
+ };
20
+ let reviewState = { active: false, pins: [] };
21
+
22
+ /* Inject the app-bridge runtime + a project-owned driver into a same-origin
23
+ app iframe (driven mode, EN-19: the app's own files stay untouched). */
24
+ export function injectAppRuntime(iframe, featureId, driverFile, onFail) {
25
+ let doc = null;
26
+ try { doc = iframe.contentDocument; } catch { /* cross-origin */ }
27
+ if (!doc) {
28
+ onFail?.('Cross-origin app — it must include engine/app-bridge.js itself and call AppBridge.register().');
29
+ return;
30
+ }
31
+ const host = doc.head || doc.documentElement;
32
+ const runtime = doc.createElement('script');
33
+ runtime.src = url('engine/app-bridge.js');
34
+ runtime.onload = () => {
35
+ const driver = doc.createElement('script');
36
+ driver.src = url(`features/${featureId}/${driverFile}`);
37
+ driver.onerror = () => onFail?.(`Driver failed to load: features/${featureId}/${driverFile}`);
38
+ host.appendChild(driver);
39
+ };
40
+ runtime.onerror = () => onFail?.('engine/app-bridge.js failed to load');
41
+ host.appendChild(runtime);
42
+ }
43
+
44
+ export function onScreenMessage(patch) {
45
+ Object.assign(handlers, patch);
46
+ }
47
+
48
+ /* Merged data for the current feature + scenario (dates resolved by bridge). */
49
+ export function mergedData(featureId = store.ui.feature) {
50
+ const f = feature(featureId);
51
+ if (!f) return {};
52
+ return Kit.applyScenario(f.seed, activeScenario(featureId));
53
+ }
54
+
55
+ function initPayload() {
56
+ const entry = screenEntry();
57
+ return {
58
+ type: 'sb:init',
59
+ theme: activeTheme() || Kit.FALLBACK_THEME,
60
+ themeId: store.ui.theme,
61
+ mode: store.ui.mode,
62
+ dir: store.ui.dir,
63
+ lang: store.ui.lang,
64
+ device: entry ? effectiveDevice(entry) : 'none',
65
+ scenario: store.ui.scenario,
66
+ data: mergedData(),
67
+ i18n: feature()?.i18n || null,
68
+ defaultLang: store.project?.config?.defaultLanguage || 'en',
69
+ review: reviewState
70
+ };
71
+ }
72
+
73
+ window.addEventListener('message', (e) => {
74
+ if (!current || e.source !== current.iframe.contentWindow) return;
75
+ const msg = e.data;
76
+ if (!msg || typeof msg.type !== 'string') return;
77
+
78
+ switch (msg.type) {
79
+ case 'sb:hello':
80
+ current.iframe.contentWindow.postMessage(initPayload(), '*');
81
+ break;
82
+ case 'sb:ready':
83
+ current.ready = true;
84
+ current.veil.classList.add('is-hidden');
85
+ handlers.onReady(current.featureId, current.screenId);
86
+ break;
87
+ case 'sb:go':
88
+ handlers.onGo(msg.id);
89
+ break;
90
+ case 'sb:console-error':
91
+ case 'sba:console-error':
92
+ handlers.onConsoleError(msg.message, current.featureId || current.appId, current.screenId);
93
+ break;
94
+ case 'sb:pin-request':
95
+ handlers.onPinRequest(msg);
96
+ break;
97
+ case 'sb:pin-open':
98
+ handlers.onPinOpen(msg.id);
99
+ break;
100
+
101
+ /* ---- app mode (sba:*) ---- */
102
+ case 'sba:hello': {
103
+ const f = feature(current.appId);
104
+ current.ready = true;
105
+ clearTimeout(current.veilTimer);
106
+ current.veil.classList.add('is-hidden');
107
+ if (f) {
108
+ f.appTitle = msg.title || f.meta.title;
109
+ f.capabilities = msg.capabilities || [];
110
+ if (msg.scenarios?.length) f.scenarios = { list: msg.scenarios };
111
+ }
112
+ pushUpdate();
113
+ handlers.onAppUpdate();
114
+ break;
115
+ }
116
+ case 'sba:screens': {
117
+ const f = feature(current.appId);
118
+ if (f) f.appScreens = msg.screens || [];
119
+ handlers.onAppUpdate();
120
+ break;
121
+ }
122
+ case 'sba:current':
123
+ current.screenId = msg.id;
124
+ handlers.onGo(msg.id);
125
+ break;
126
+ }
127
+ });
128
+
129
+ function screenUrl(featureId, screenId, bust) {
130
+ let u = url(`features/${featureId}/screens/${encodeURIComponent(screenId)}.html`);
131
+ if (bust) u += `?t=${Date.now()}`;
132
+ return u;
133
+ }
134
+
135
+ /* App-type feature: mount the whole SPA once, then drive it over sba:*. */
136
+ function mountApp(f, { bust = false } = {}) {
137
+ const device = f.meta.device || 'none';
138
+ const same = current?.appId === f.id && !bust;
139
+
140
+ if (!same) {
141
+ unmount();
142
+ const frame = buildFrame(device);
143
+ if (f.meta.chrome === 'self') frame.root.setAttribute('data-chrome', 'self');
144
+ const fitWrap = document.createElement('div');
145
+ fitWrap.className = 'frame-fit';
146
+ const outer = document.createElement('div');
147
+ outer.className = 'frame-outer';
148
+ outer.append(frame.root);
149
+ fitWrap.append(outer);
150
+ stage.append(fitWrap);
151
+
152
+ const veil = document.createElement('div');
153
+ veil.className = 'load-veil';
154
+ veil.textContent = 'Loading app…';
155
+ frame.viewport.append(veil);
156
+
157
+ const iframe = document.createElement('iframe');
158
+ iframe.setAttribute('title', 'app');
159
+ frame.viewport.insertBefore(iframe, veil);
160
+
161
+ const unfit = fitFrame(stage, fitWrap, frame);
162
+ current = { appId: f.id, screenId: null, frame, iframe, unfit, veil, ready: false };
163
+
164
+ const fail = (text) => { veil.classList.remove('is-hidden'); veil.textContent = text; };
165
+ iframe.addEventListener('load', () => {
166
+ if (f.meta.driver) injectAppRuntime(iframe, f.id, f.meta.driver, fail);
167
+ });
168
+ current.veilTimer = setTimeout(() => {
169
+ if (!current?.ready) {
170
+ fail(f.meta.driver
171
+ ? `App hasn't registered — check features/${f.id}/${f.meta.driver}`
172
+ : 'App hasn\'t registered — it must include engine/app-bridge.js and call AppBridge.register()');
173
+ }
174
+ }, 12000);
175
+
176
+ let src = url(f.meta.src);
177
+ if (bust) src += (src.includes('?') ? '&' : '?') + `t=${Date.now()}`;
178
+ iframe.src = src;
179
+ }
180
+
181
+ current.frame.root.setAttribute('data-screen-mode', store.ui.mode);
182
+ if (current.frame.urlPill) {
183
+ current.frame.urlPill.textContent = `screenbook://${f.id}${store.ui.screen ? `/${store.ui.screen}` : ''}`;
184
+ }
185
+ if (current.ready && store.ui.screen && store.ui.screen !== current.screenId &&
186
+ current.iframe.contentWindow) {
187
+ current.iframe.contentWindow.postMessage({ type: 'sba:go', id: store.ui.screen }, '*');
188
+ }
189
+ }
190
+
191
+ /* Mount (or remount) the current screen. Rebuilds the frame when needed. */
192
+ export function showScreen({ bust = false } = {}) {
193
+ stage.querySelector('.stage-empty')?.remove();
194
+
195
+ const f = feature();
196
+ if (f?.type === 'app') {
197
+ mountApp(f, { bust });
198
+ return;
199
+ }
200
+
201
+ const entry = screenEntry();
202
+
203
+ if (entry) renderVariantChips(entry);
204
+ else stage.querySelector('.variant-chips')?.remove();
205
+
206
+ if (!entry) {
207
+ unmount();
208
+ const empty = document.createElement('div');
209
+ empty.className = 'stage-empty';
210
+ const f = feature();
211
+ empty.innerHTML = f
212
+ ? `<h2>Pick a screen</h2><p>Select a screen from the sidebar${(f.manifest.screens || []).length ? '' : ' — this feature has none yet'}.</p>`
213
+ : `<h2>Welcome to ScreenBook</h2>
214
+ <p>This project has no features yet. Scaffold one:</p>
215
+ <p><code>node engine/cli/screenbook.js init</code></p>
216
+ <p>…or let an agent do it over MCP (<code>add_feature</code>, <code>add_screen</code>).</p>`;
217
+ stage.append(empty);
218
+ return;
219
+ }
220
+
221
+ const device = effectiveDevice(entry);
222
+ const sameFrame = current &&
223
+ current.featureId === store.ui.feature &&
224
+ current.frame.device === device;
225
+
226
+ if (!sameFrame) {
227
+ unmount();
228
+ const frame = buildFrame(device);
229
+ const fitWrap = document.createElement('div');
230
+ fitWrap.className = 'frame-fit';
231
+ const outer = document.createElement('div');
232
+ outer.className = 'frame-outer';
233
+ outer.append(frame.root);
234
+ fitWrap.append(outer);
235
+ stage.append(fitWrap);
236
+
237
+ const veil = document.createElement('div');
238
+ veil.className = 'load-veil';
239
+ veil.textContent = 'Loading…';
240
+ frame.viewport.append(veil);
241
+
242
+ const iframe = document.createElement('iframe');
243
+ iframe.setAttribute('title', 'screen');
244
+ iframe.setAttribute('allow', 'geolocation; clipboard-write');
245
+ frame.viewport.insertBefore(iframe, veil);
246
+
247
+ const unfit = fitFrame(stage, fitWrap, frame);
248
+ current = { featureId: store.ui.feature, screenId: null, frame, iframe, unfit, veil, ready: false };
249
+ }
250
+
251
+ current.frame.root.setAttribute('data-screen-mode', store.ui.mode);
252
+ if (entry.statusBar) current.frame.root.setAttribute('data-statusbar', entry.statusBar);
253
+ else current.frame.root.removeAttribute('data-statusbar');
254
+ if (current.frame.urlPill) {
255
+ current.frame.urlPill.textContent = `screenbook://${store.ui.feature}/${entry.id}`;
256
+ }
257
+
258
+ if (current.screenId !== entry.id || bust) {
259
+ current.screenId = entry.id;
260
+ current.ready = false;
261
+ current.veil.classList.remove('is-hidden');
262
+ current.veil.textContent = 'Loading…';
263
+ current.iframe.src = screenUrl(store.ui.feature, entry.id, bust);
264
+ if (!bust) playTransition();
265
+ }
266
+ }
267
+
268
+ export function unmount() {
269
+ if (!current) return;
270
+ clearTimeout(current.veilTimer);
271
+ current.unfit?.();
272
+ current.iframe.remove();
273
+ stage.querySelector('.frame-fit')?.remove();
274
+ current = null;
275
+ }
276
+
277
+ /* Push a live env update into the mounted screen/app (no reload). */
278
+ export function pushUpdate() {
279
+ if (!current?.iframe.contentWindow) return;
280
+ current.frame.root.setAttribute('data-screen-mode', store.ui.mode);
281
+ const theme = activeTheme() || Kit.FALLBACK_THEME;
282
+
283
+ if (current.appId) {
284
+ current.iframe.contentWindow.postMessage({
285
+ type: 'sba:env',
286
+ mode: store.ui.mode,
287
+ dir: store.ui.dir,
288
+ lang: store.ui.lang,
289
+ scenario: store.ui.scenario,
290
+ theme,
291
+ vars: Kit.themeVars(theme, store.ui.mode)
292
+ }, '*');
293
+ return;
294
+ }
295
+
296
+ current.iframe.contentWindow.postMessage({
297
+ type: 'sb:update',
298
+ mode: store.ui.mode,
299
+ dir: store.ui.dir,
300
+ lang: store.ui.lang,
301
+ theme,
302
+ themeId: store.ui.theme
303
+ }, '*');
304
+ }
305
+
306
+ /* Scenario / data change requires a clean re-run of Engine.ready → reload.
307
+ For apps this remounts the whole SPA (fresh boot + re-injection). */
308
+ export function reloadScreen({ bust = false } = {}) {
309
+ if (current?.appId) {
310
+ unmount();
311
+ showScreen({ bust: true });
312
+ return;
313
+ }
314
+ if (!current?.screenId) return;
315
+ current.ready = false;
316
+ current.veil.classList.remove('is-hidden');
317
+ current.iframe.src = screenUrl(current.featureId, current.screenId, bust);
318
+ }
319
+
320
+ export function setReview(active, pins) {
321
+ reviewState = { active, pins };
322
+ if (current?.iframe.contentWindow) {
323
+ current.iframe.contentWindow.postMessage({ type: 'sb:review', active, pins }, '*');
324
+ }
325
+ }
326
+
327
+ export function currentScreen() {
328
+ return current;
329
+ }
330
+
331
+ /* ---------------- device variants (v2: one screen, several frames) ---------------- */
332
+
333
+ let variantChoice = null; // per-screen, transient; cleared on screen change
334
+
335
+ export function clearVariant() {
336
+ variantChoice = null;
337
+ }
338
+
339
+ function effectiveDevice(entry) {
340
+ const variants = entry.devices?.length ? entry.devices : null;
341
+ if (!variants) return entry.device || 'none';
342
+ if (variantChoice && variants.includes(variantChoice)) return variantChoice;
343
+ /* deep link / Device-filter landing: honor ?device= when this screen supports it */
344
+ if (store.ui.deviceFilter !== 'all' && variants.includes(store.ui.deviceFilter)) return store.ui.deviceFilter;
345
+ return entry.device || variants[0];
346
+ }
347
+
348
+ function renderVariantChips(entry) {
349
+ stage.querySelector('.variant-chips')?.remove();
350
+ const variants = entry.devices?.length > 1 ? entry.devices : null;
351
+ if (!variants) return;
352
+ const active = effectiveDevice(entry);
353
+ const box = document.createElement('div');
354
+ box.className = 'variant-chips';
355
+ for (const d of variants) {
356
+ const label = { ios: 'iPhone', android: 'Android', web: 'Web', none: 'Bare' }[d] || d;
357
+ const chip = document.createElement('button');
358
+ chip.type = 'button';
359
+ chip.className = `variant-chip${d === active ? ' is-on' : ''}`;
360
+ chip.textContent = label;
361
+ chip.addEventListener('click', () => {
362
+ if (variantChoice === d) return;
363
+ variantChoice = d;
364
+ showScreen();
365
+ });
366
+ box.append(chip);
367
+ }
368
+ stage.prepend(box);
369
+ }
370
+
371
+ /* subtle switch transition (respects prefers-reduced-motion; config kill-switch) */
372
+ function playTransition() {
373
+ if (store.project?.config?.transitions === false) return;
374
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
375
+ const outer = stage.querySelector('.frame-outer');
376
+ if (!outer) return;
377
+ outer.classList.remove('is-entering');
378
+ void outer.offsetWidth; // restart the animation
379
+ outer.classList.add('is-entering');
380
+ }