@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
package/bridge.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ScreenBook bridge — the ONLY file a screen ever includes.
|
|
3
|
+
*
|
|
4
|
+
* Dual mode:
|
|
5
|
+
* embedded — the ScreenBook shell drives it over postMessage (sb:* protocol)
|
|
6
|
+
* standalone — the screen was opened directly; the bridge self-loads the
|
|
7
|
+
* default theme + seed data so the screen still works alone.
|
|
8
|
+
*
|
|
9
|
+
* The shell itself also loads this file (with window.__SCREENBOOK_SHELL__ set)
|
|
10
|
+
* purely to reuse EngineKit — merge/flatten/date logic lives here exactly once.
|
|
11
|
+
*/
|
|
12
|
+
(function () {
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
var VERSION = '0.1.0';
|
|
16
|
+
|
|
17
|
+
/* ------------------------------------------------------------------ *
|
|
18
|
+
* EngineKit — pure helpers shared by bridge (screens) and shell
|
|
19
|
+
* ------------------------------------------------------------------ */
|
|
20
|
+
|
|
21
|
+
function isPlainObject(v) {
|
|
22
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* Deep merge: objects merge recursively, arrays and scalars replace. */
|
|
26
|
+
function deepMerge(base, over) {
|
|
27
|
+
if (!isPlainObject(base) || !isPlainObject(over)) {
|
|
28
|
+
return over === undefined ? base : over;
|
|
29
|
+
}
|
|
30
|
+
var out = {};
|
|
31
|
+
Object.keys(base).forEach(function (k) { out[k] = base[k]; });
|
|
32
|
+
Object.keys(over).forEach(function (k) {
|
|
33
|
+
out[k] = isPlainObject(base[k]) && isPlainObject(over[k])
|
|
34
|
+
? deepMerge(base[k], over[k])
|
|
35
|
+
: over[k];
|
|
36
|
+
});
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function clone(v) { return v === undefined ? v : JSON.parse(JSON.stringify(v)); }
|
|
41
|
+
|
|
42
|
+
/* Delete a dot-path ("trips.italy") from an object, in place. */
|
|
43
|
+
function removePath(obj, path) {
|
|
44
|
+
var parts = String(path).split('.');
|
|
45
|
+
var cur = obj;
|
|
46
|
+
for (var i = 0; i < parts.length - 1; i++) {
|
|
47
|
+
if (!isPlainObject(cur[parts[i]])) return;
|
|
48
|
+
cur = cur[parts[i]];
|
|
49
|
+
}
|
|
50
|
+
if (cur && typeof cur === 'object') delete cur[parts[parts.length - 1]];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
* Apply a scenario overlay to seed data:
|
|
55
|
+
* { remove: ["path", ...], patch: {...} } — remove first, then deep-merge.
|
|
56
|
+
*/
|
|
57
|
+
function applyScenario(seed, scenario) {
|
|
58
|
+
var data = clone(seed) || {};
|
|
59
|
+
if (!scenario) return data;
|
|
60
|
+
(scenario.remove || []).forEach(function (p) { removePath(data, p); });
|
|
61
|
+
if (scenario.patch) data = deepMerge(data, scenario.patch);
|
|
62
|
+
return data;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* {{today}}, {{today+3}}, {{today-2}} → ISO YYYY-MM-DD (local time). */
|
|
66
|
+
var DATE_TOKEN = /\{\{\s*today\s*(?:([+-])\s*(\d+))?\s*\}\}/g;
|
|
67
|
+
function isoDate(offsetDays) {
|
|
68
|
+
var d = new Date();
|
|
69
|
+
d.setDate(d.getDate() + (offsetDays || 0));
|
|
70
|
+
var m = String(d.getMonth() + 1).padStart(2, '0');
|
|
71
|
+
var day = String(d.getDate()).padStart(2, '0');
|
|
72
|
+
return d.getFullYear() + '-' + m + '-' + day;
|
|
73
|
+
}
|
|
74
|
+
function resolveDateTokens(value) {
|
|
75
|
+
if (typeof value === 'string') {
|
|
76
|
+
return value.replace(DATE_TOKEN, function (_, sign, n) {
|
|
77
|
+
var off = n ? (sign === '-' ? -1 : 1) * parseInt(n, 10) : 0;
|
|
78
|
+
return isoDate(off);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(value)) return value.map(resolveDateTokens);
|
|
82
|
+
if (isPlainObject(value)) {
|
|
83
|
+
var out = {};
|
|
84
|
+
Object.keys(value).forEach(function (k) { out[k] = resolveDateTokens(value[k]); });
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/*
|
|
91
|
+
* Flatten a token-mode object to CSS custom properties:
|
|
92
|
+
* { color: { "ink-2": "#333" }, radius: { card: "20px" } }
|
|
93
|
+
* → { "--color-ink-2": "#333", "--radius-card": "20px" }
|
|
94
|
+
*/
|
|
95
|
+
function flattenTokens(node, prefix, out) {
|
|
96
|
+
out = out || {};
|
|
97
|
+
prefix = prefix || '';
|
|
98
|
+
if (!isPlainObject(node)) return out;
|
|
99
|
+
Object.keys(node).forEach(function (k) {
|
|
100
|
+
var v = node[k];
|
|
101
|
+
var path = prefix ? prefix + '-' + k : k;
|
|
102
|
+
if (isPlainObject(v)) flattenTokens(v, path, out);
|
|
103
|
+
else if (v !== null && v !== undefined) out['--' + path] = String(v);
|
|
104
|
+
});
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Resolve a theme JSON + mode to flat CSS vars (dark deep-merges over light). */
|
|
109
|
+
function themeVars(theme, mode) {
|
|
110
|
+
var modes = (theme && theme.modes) || {};
|
|
111
|
+
var light = modes.light || {};
|
|
112
|
+
var active = mode === 'dark' ? deepMerge(light, modes.dark || {}) : light;
|
|
113
|
+
return flattenTokens(active);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* Fallback tokens so a screen never renders unstyled (no theme reachable). */
|
|
117
|
+
var FALLBACK_THEME = {
|
|
118
|
+
id: 'fallback', title: 'Fallback',
|
|
119
|
+
modes: {
|
|
120
|
+
light: {
|
|
121
|
+
color: { accent: '#3B82D6', bg: '#F5F5F4', card: '#FFFFFF',
|
|
122
|
+
ink: '#1C1917', 'ink-2': '#78716C', line: '#E7E5E4' },
|
|
123
|
+
font: { sans: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" },
|
|
124
|
+
radius: { card: '16px', chip: '999px' },
|
|
125
|
+
space: { page: '16px' }
|
|
126
|
+
},
|
|
127
|
+
dark: {
|
|
128
|
+
color: { accent: '#60A5FA', bg: '#131110', card: '#1F1C1A',
|
|
129
|
+
ink: '#F5F5F4', 'ink-2': '#A8A29E', line: '#33302D' }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/* Safe-area insets the device chrome occupies (screens pad with these). */
|
|
135
|
+
var SAFE_AREAS = {
|
|
136
|
+
ios: { top: '59px', bottom: '34px' },
|
|
137
|
+
android: { top: '42px', bottom: '24px' },
|
|
138
|
+
web: { top: '0px', bottom: '0px' },
|
|
139
|
+
none: { top: '0px', bottom: '0px' }
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/* Compact, stable-ish CSS path for review pins ("#cart > button.buy:nth-of-type(2)"). */
|
|
143
|
+
function cssPath(el) {
|
|
144
|
+
var parts = [];
|
|
145
|
+
while (el && el.nodeType === 1 && el !== document.documentElement && parts.length < 6) {
|
|
146
|
+
if (el.id) { parts.unshift('#' + el.id); break; }
|
|
147
|
+
var part = el.tagName.toLowerCase();
|
|
148
|
+
var cls = (el.className && typeof el.className === 'string')
|
|
149
|
+
? el.className.trim().split(/\s+/).slice(0, 2).join('.') : '';
|
|
150
|
+
if (cls) part += '.' + cls;
|
|
151
|
+
var parent = el.parentElement;
|
|
152
|
+
if (parent) {
|
|
153
|
+
var same = Array.prototype.filter.call(parent.children, function (c) {
|
|
154
|
+
return c.tagName === el.tagName;
|
|
155
|
+
});
|
|
156
|
+
if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(el) + 1) + ')';
|
|
157
|
+
}
|
|
158
|
+
parts.unshift(part);
|
|
159
|
+
el = parent;
|
|
160
|
+
}
|
|
161
|
+
return parts.join(' > ');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseMetaFrom(doc) {
|
|
165
|
+
var el = doc.getElementById('screen-meta');
|
|
166
|
+
if (!el) return null;
|
|
167
|
+
try { return JSON.parse(el.textContent); } catch (e) { return null; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/*
|
|
171
|
+
* i18n (v2 strings convention): key-first per-feature i18n.json —
|
|
172
|
+
* { "slide1.title": { "en": "…", "ar": "…" }, … }
|
|
173
|
+
* Fallback chain: lang → defaultLang → the key itself. `{name}` placeholders
|
|
174
|
+
* interpolate from vars.
|
|
175
|
+
*/
|
|
176
|
+
function translate(i18n, lang, defaultLang, key, vars) {
|
|
177
|
+
var entry = i18n && i18n[key];
|
|
178
|
+
var text = entry && (entry[lang] != null ? entry[lang] : entry[defaultLang]);
|
|
179
|
+
if (text == null) text = key;
|
|
180
|
+
if (vars) {
|
|
181
|
+
text = String(text).replace(/\{(\w+)\}/g, function (m, name) {
|
|
182
|
+
return vars[name] != null ? String(vars[name]) : m;
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return text;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
var EngineKit = {
|
|
189
|
+
version: VERSION,
|
|
190
|
+
deepMerge: deepMerge,
|
|
191
|
+
clone: clone,
|
|
192
|
+
removePath: removePath,
|
|
193
|
+
applyScenario: applyScenario,
|
|
194
|
+
resolveDateTokens: resolveDateTokens,
|
|
195
|
+
flattenTokens: flattenTokens,
|
|
196
|
+
themeVars: themeVars,
|
|
197
|
+
translate: translate,
|
|
198
|
+
cssPath: cssPath,
|
|
199
|
+
parseMetaFrom: parseMetaFrom,
|
|
200
|
+
isoDate: isoDate,
|
|
201
|
+
FALLBACK_THEME: FALLBACK_THEME,
|
|
202
|
+
SAFE_AREAS: SAFE_AREAS
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/* Shell context: expose the kit, skip screen boot. */
|
|
206
|
+
if (window.__SCREENBOOK_SHELL__) {
|
|
207
|
+
window.EngineKit = EngineKit;
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ------------------------------------------------------------------ *
|
|
212
|
+
* Screen context — boot the Engine
|
|
213
|
+
* ------------------------------------------------------------------ */
|
|
214
|
+
|
|
215
|
+
var qs = new URLSearchParams(location.search);
|
|
216
|
+
var meta = parseMetaFrom(document) || {};
|
|
217
|
+
var embedded = window.parent !== window;
|
|
218
|
+
var appliedVars = [];
|
|
219
|
+
var readyQueue = [];
|
|
220
|
+
var isReady = false;
|
|
221
|
+
var stateEmitter = {}; // key → [cb]
|
|
222
|
+
var review = { active: false, pins: [] };
|
|
223
|
+
|
|
224
|
+
var env = {
|
|
225
|
+
mode: qs.get('mode') ||
|
|
226
|
+
(window.matchMedia && matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'),
|
|
227
|
+
dir: qs.get('dir') || document.documentElement.getAttribute('dir') || 'ltr',
|
|
228
|
+
lang: qs.get('lang') || document.documentElement.getAttribute('lang') || 'en',
|
|
229
|
+
device: meta.device || 'none',
|
|
230
|
+
scenario: qs.get('scenario') || 'default',
|
|
231
|
+
theme: qs.get('theme') || null,
|
|
232
|
+
embedded: embedded
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
var i18nTable = null;
|
|
236
|
+
var defaultLang = 'en';
|
|
237
|
+
|
|
238
|
+
var Engine = {
|
|
239
|
+
version: VERSION,
|
|
240
|
+
kit: EngineKit,
|
|
241
|
+
meta: meta,
|
|
242
|
+
env: env,
|
|
243
|
+
data: Object.freeze({}),
|
|
244
|
+
ready: function (cb) {
|
|
245
|
+
if (typeof cb !== 'function') return;
|
|
246
|
+
if (isReady) { queueMicrotask(function () { safeCall(cb); }); }
|
|
247
|
+
else readyQueue.push(cb);
|
|
248
|
+
},
|
|
249
|
+
t: function (key, vars) {
|
|
250
|
+
return translate(i18nTable, env.lang, defaultLang, key, vars);
|
|
251
|
+
},
|
|
252
|
+
go: go,
|
|
253
|
+
toast: toast,
|
|
254
|
+
state: makeState()
|
|
255
|
+
};
|
|
256
|
+
window.Engine = Engine;
|
|
257
|
+
|
|
258
|
+
function safeCall(cb) {
|
|
259
|
+
try { cb(Engine.data); }
|
|
260
|
+
catch (err) {
|
|
261
|
+
reportError('Engine.ready callback: ' + (err && err.message ? err.message : err));
|
|
262
|
+
if (!embedded) throw err;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* ---------------- environment application ---------------- */
|
|
267
|
+
|
|
268
|
+
function applyEnvironment(theme, data) {
|
|
269
|
+
var root = document.documentElement;
|
|
270
|
+
var vars = themeVars(theme || FALLBACK_THEME, env.mode);
|
|
271
|
+
appliedVars.forEach(function (name) {
|
|
272
|
+
if (!(name in vars)) root.style.removeProperty(name);
|
|
273
|
+
});
|
|
274
|
+
Object.keys(vars).forEach(function (name) {
|
|
275
|
+
root.style.setProperty(name, vars[name]);
|
|
276
|
+
});
|
|
277
|
+
appliedVars = Object.keys(vars);
|
|
278
|
+
|
|
279
|
+
var safe = SAFE_AREAS[env.device] || SAFE_AREAS.none;
|
|
280
|
+
root.style.setProperty('--safe-top', safe.top);
|
|
281
|
+
root.style.setProperty('--safe-bottom', safe.bottom);
|
|
282
|
+
|
|
283
|
+
root.setAttribute('data-mode', env.mode);
|
|
284
|
+
root.setAttribute('data-device', env.device);
|
|
285
|
+
root.setAttribute('dir', env.dir);
|
|
286
|
+
root.setAttribute('lang', env.lang);
|
|
287
|
+
|
|
288
|
+
if (data !== undefined) {
|
|
289
|
+
Engine.data = Object.freeze(resolveDateTokens(data) || {});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function fireReady() {
|
|
294
|
+
if (isReady) return;
|
|
295
|
+
isReady = true;
|
|
296
|
+
var queue = readyQueue.slice();
|
|
297
|
+
readyQueue.length = 0;
|
|
298
|
+
queue.forEach(safeCall);
|
|
299
|
+
send('sb:ready', { meta: meta });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ---------------- shared KV state (localStorage + storage events) ---------------- */
|
|
303
|
+
|
|
304
|
+
function makeState() {
|
|
305
|
+
var PREFIX = 'sb:state:';
|
|
306
|
+
var mem = {};
|
|
307
|
+
|
|
308
|
+
function read(key) {
|
|
309
|
+
try {
|
|
310
|
+
var raw = localStorage.getItem(PREFIX + key);
|
|
311
|
+
return raw === null ? undefined : JSON.parse(raw);
|
|
312
|
+
} catch (e) { return mem[key]; }
|
|
313
|
+
}
|
|
314
|
+
function emit(key, value) {
|
|
315
|
+
(stateEmitter[key] || []).forEach(function (cb) {
|
|
316
|
+
try { cb(value); } catch (e) { /* subscriber's problem */ }
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
window.addEventListener('storage', function (e) {
|
|
321
|
+
if (!e.key || e.key.indexOf(PREFIX) !== 0) {
|
|
322
|
+
if (e.key === null) Object.keys(stateEmitter).forEach(function (k) { emit(k, undefined); });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
var key = e.key.slice(PREFIX.length);
|
|
326
|
+
var value;
|
|
327
|
+
try { value = e.newValue === null ? undefined : JSON.parse(e.newValue); } catch (err) { value = undefined; }
|
|
328
|
+
mem[key] = value;
|
|
329
|
+
emit(key, value);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
get: function (key, fallback) {
|
|
334
|
+
var v = read(key);
|
|
335
|
+
if (v === undefined) v = mem[key];
|
|
336
|
+
return v === undefined ? fallback : v;
|
|
337
|
+
},
|
|
338
|
+
set: function (key, value) {
|
|
339
|
+
mem[key] = value;
|
|
340
|
+
try {
|
|
341
|
+
if (value === undefined) localStorage.removeItem(PREFIX + key);
|
|
342
|
+
else localStorage.setItem(PREFIX + key, JSON.stringify(value));
|
|
343
|
+
} catch (e) { /* memory-only fallback */ }
|
|
344
|
+
emit(key, value);
|
|
345
|
+
},
|
|
346
|
+
subscribe: function (key, cb) {
|
|
347
|
+
(stateEmitter[key] = stateEmitter[key] || []).push(cb);
|
|
348
|
+
return function () {
|
|
349
|
+
var list = stateEmitter[key] || [];
|
|
350
|
+
var i = list.indexOf(cb);
|
|
351
|
+
if (i >= 0) list.splice(i, 1);
|
|
352
|
+
};
|
|
353
|
+
},
|
|
354
|
+
keys: function () {
|
|
355
|
+
var keys = [];
|
|
356
|
+
try {
|
|
357
|
+
for (var i = 0; i < localStorage.length; i++) {
|
|
358
|
+
var k = localStorage.key(i);
|
|
359
|
+
if (k && k.indexOf(PREFIX) === 0) keys.push(k.slice(PREFIX.length));
|
|
360
|
+
}
|
|
361
|
+
} catch (e) { keys = Object.keys(mem); }
|
|
362
|
+
return keys;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* ---------------- navigation + toast ---------------- */
|
|
368
|
+
|
|
369
|
+
function go(id) {
|
|
370
|
+
if (!id) return;
|
|
371
|
+
if (embedded) send('sb:go', { id: id });
|
|
372
|
+
else location.href = encodeURIComponent(id) + '.html' + location.search;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
document.addEventListener('click', function (e) {
|
|
376
|
+
var el = e.target && e.target.closest ? e.target.closest('[data-go]') : null;
|
|
377
|
+
if (!el) return;
|
|
378
|
+
e.preventDefault();
|
|
379
|
+
go(el.getAttribute('data-go'));
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
var toastBox = null;
|
|
383
|
+
function toast(msg) {
|
|
384
|
+
if (!toastBox) {
|
|
385
|
+
toastBox = document.createElement('div');
|
|
386
|
+
toastBox.setAttribute('data-engine-toasts', '');
|
|
387
|
+
toastBox.style.cssText =
|
|
388
|
+
'position:fixed;left:0;right:0;bottom:calc(var(--safe-bottom, 0px) + 18px);' +
|
|
389
|
+
'display:flex;flex-direction:column;align-items:center;gap:8px;' +
|
|
390
|
+
'pointer-events:none;z-index:2147483000;';
|
|
391
|
+
document.body.appendChild(toastBox);
|
|
392
|
+
}
|
|
393
|
+
var t = document.createElement('div');
|
|
394
|
+
t.textContent = String(msg);
|
|
395
|
+
t.style.cssText =
|
|
396
|
+
'max-width:80%;padding:10px 18px;border-radius:999px;font-size:13px;font-weight:600;' +
|
|
397
|
+
'font-family:var(--font-sans, sans-serif);background:var(--color-ink, #111);' +
|
|
398
|
+
'color:var(--color-bg, #fff);box-shadow:0 6px 24px rgba(0,0,0,.25);' +
|
|
399
|
+
'opacity:0;transform:translateY(8px);transition:all .22s ease;';
|
|
400
|
+
toastBox.appendChild(t);
|
|
401
|
+
requestAnimationFrame(function () { t.style.opacity = '1'; t.style.transform = 'translateY(0)'; });
|
|
402
|
+
setTimeout(function () {
|
|
403
|
+
t.style.opacity = '0'; t.style.transform = 'translateY(8px)';
|
|
404
|
+
setTimeout(function () { t.remove(); }, 250);
|
|
405
|
+
}, 2400);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/* ---------------- error reporting (selftest + shell console) ---------------- */
|
|
409
|
+
|
|
410
|
+
function send(type, payload) {
|
|
411
|
+
if (!embedded) return;
|
|
412
|
+
var msg = { type: type };
|
|
413
|
+
if (payload) Object.keys(payload).forEach(function (k) { msg[k] = payload[k]; });
|
|
414
|
+
try { window.parent.postMessage(msg, '*'); } catch (e) { /* shell gone */ }
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function reportError(message) {
|
|
418
|
+
send('sb:console-error', { message: String(message) });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (embedded) {
|
|
422
|
+
window.addEventListener('error', function (e) {
|
|
423
|
+
if (e.target && e.target !== window && e.target.tagName) {
|
|
424
|
+
reportError('Resource failed: <' + e.target.tagName.toLowerCase() + '> ' +
|
|
425
|
+
(e.target.src || e.target.href || ''));
|
|
426
|
+
} else {
|
|
427
|
+
reportError((e.message || 'Script error') +
|
|
428
|
+
(e.filename ? ' (' + e.filename.split('/').pop() + ':' + e.lineno + ')' : ''));
|
|
429
|
+
}
|
|
430
|
+
}, true);
|
|
431
|
+
window.addEventListener('unhandledrejection', function (e) {
|
|
432
|
+
reportError('Unhandled rejection: ' + (e.reason && e.reason.message ? e.reason.message : e.reason));
|
|
433
|
+
});
|
|
434
|
+
var origConsoleError = console.error;
|
|
435
|
+
console.error = function () {
|
|
436
|
+
reportError(Array.prototype.map.call(arguments, function (a) {
|
|
437
|
+
if (typeof a === 'string') return a;
|
|
438
|
+
if (a && a.stack) return String(a.stack);
|
|
439
|
+
try { return JSON.stringify(a); } catch (e) { return String(a); }
|
|
440
|
+
}).join(' '));
|
|
441
|
+
return origConsoleError.apply(console, arguments);
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* ---------------- review pins (rendered inside the screen) ---------------- */
|
|
446
|
+
|
|
447
|
+
var pinLayer = null;
|
|
448
|
+
function renderPins() {
|
|
449
|
+
if (pinLayer) { pinLayer.remove(); pinLayer = null; }
|
|
450
|
+
if (!review.active || !review.pins.length) return;
|
|
451
|
+
pinLayer = document.createElement('div');
|
|
452
|
+
pinLayer.setAttribute('data-engine-pins', '');
|
|
453
|
+
pinLayer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483100;';
|
|
454
|
+
review.pins.forEach(function (pin) {
|
|
455
|
+
var dot = document.createElement('button');
|
|
456
|
+
dot.textContent = String(pin.n != null ? pin.n : '•');
|
|
457
|
+
dot.title = pin.note || '';
|
|
458
|
+
dot.style.cssText =
|
|
459
|
+
'position:absolute;left:' + (pin.xPct * 100) + '%;top:' + (pin.yPct * 100) + '%;' +
|
|
460
|
+
'transform:translate(-50%,-50%);width:26px;height:26px;border-radius:50%;' +
|
|
461
|
+
'border:2px solid #fff;background:' + (pin.resolved ? '#22A06B' : '#E5484D') + ';color:#fff;' +
|
|
462
|
+
'font:700 12px/1 -apple-system,sans-serif;box-shadow:0 2px 10px rgba(0,0,0,.35);' +
|
|
463
|
+
'pointer-events:auto;cursor:pointer;padding:0;';
|
|
464
|
+
dot.addEventListener('click', function (e) {
|
|
465
|
+
e.preventDefault(); e.stopPropagation();
|
|
466
|
+
send('sb:pin-open', { id: pin.id });
|
|
467
|
+
});
|
|
468
|
+
pinLayer.appendChild(dot);
|
|
469
|
+
});
|
|
470
|
+
document.body.appendChild(pinLayer);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function onReviewClick(e) {
|
|
474
|
+
if (!review.active) return;
|
|
475
|
+
if (e.target && e.target.closest && e.target.closest('[data-engine-pins]')) return;
|
|
476
|
+
e.preventDefault();
|
|
477
|
+
e.stopPropagation();
|
|
478
|
+
send('sb:pin-request', {
|
|
479
|
+
xPct: e.clientX / window.innerWidth,
|
|
480
|
+
yPct: e.clientY / window.innerHeight,
|
|
481
|
+
cssPath: cssPath(e.target),
|
|
482
|
+
screenW: window.innerWidth,
|
|
483
|
+
screenH: window.innerHeight
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
document.addEventListener('click', onReviewClick, true);
|
|
487
|
+
|
|
488
|
+
/* ---------------- embedded handshake / standalone self-load ---------------- */
|
|
489
|
+
|
|
490
|
+
var initDone = false;
|
|
491
|
+
|
|
492
|
+
function handleInit(msg) {
|
|
493
|
+
if (initDone && !msg.reinit) return;
|
|
494
|
+
initDone = true;
|
|
495
|
+
if (msg.mode) env.mode = msg.mode;
|
|
496
|
+
if (msg.dir) env.dir = msg.dir;
|
|
497
|
+
if (msg.lang) env.lang = msg.lang;
|
|
498
|
+
if (msg.device) env.device = msg.device;
|
|
499
|
+
if (msg.scenario) env.scenario = msg.scenario;
|
|
500
|
+
if (msg.themeId) env.theme = msg.themeId;
|
|
501
|
+
if (msg.i18n) i18nTable = msg.i18n;
|
|
502
|
+
if (msg.defaultLang) defaultLang = msg.defaultLang;
|
|
503
|
+
currentTheme = msg.theme || FALLBACK_THEME;
|
|
504
|
+
applyEnvironment(currentTheme, msg.data || {});
|
|
505
|
+
if (msg.review) { review.active = !!msg.review.active; review.pins = msg.review.pins || []; renderPins(); }
|
|
506
|
+
fireReady();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
var currentTheme = null;
|
|
510
|
+
|
|
511
|
+
window.addEventListener('message', function (e) {
|
|
512
|
+
var msg = e.data;
|
|
513
|
+
if (!msg || typeof msg.type !== 'string') return;
|
|
514
|
+
switch (msg.type) {
|
|
515
|
+
case 'sb:init':
|
|
516
|
+
handleInit(msg);
|
|
517
|
+
break;
|
|
518
|
+
case 'sb:update':
|
|
519
|
+
if (msg.mode) env.mode = msg.mode;
|
|
520
|
+
if (msg.dir) env.dir = msg.dir;
|
|
521
|
+
if (msg.lang) env.lang = msg.lang;
|
|
522
|
+
if (msg.theme) currentTheme = msg.theme;
|
|
523
|
+
if (msg.themeId) env.theme = msg.themeId;
|
|
524
|
+
applyEnvironment(currentTheme || FALLBACK_THEME);
|
|
525
|
+
break;
|
|
526
|
+
case 'sb:review':
|
|
527
|
+
review.active = !!msg.active;
|
|
528
|
+
review.pins = msg.pins || [];
|
|
529
|
+
renderPins();
|
|
530
|
+
break;
|
|
531
|
+
case 'sb:ping':
|
|
532
|
+
send('sb:pong', { meta: meta });
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
function fetchJSON(url) {
|
|
538
|
+
return fetch(url, { cache: 'no-store' }).then(function (r) {
|
|
539
|
+
if (!r.ok) throw new Error(r.status + ' ' + url);
|
|
540
|
+
return r.json();
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function standaloneInit() {
|
|
545
|
+
if (initDone) return;
|
|
546
|
+
var root = '../../..';
|
|
547
|
+
var config = null;
|
|
548
|
+
|
|
549
|
+
fetchJSON(root + '/app.config.json')
|
|
550
|
+
.catch(function () { return null; })
|
|
551
|
+
.then(function (cfg) {
|
|
552
|
+
config = cfg || {};
|
|
553
|
+
var themeId = env.theme || (config.themes && config.themes[0]) || 'default';
|
|
554
|
+
env.theme = themeId;
|
|
555
|
+
if (!qs.get('lang') && config.defaultLanguage) env.lang = config.defaultLanguage;
|
|
556
|
+
return Promise.all([
|
|
557
|
+
fetchJSON(root + '/themes/' + themeId + '.json').catch(function () { return null; }),
|
|
558
|
+
fetchJSON('../data/seed.json').catch(function () { return {}; }),
|
|
559
|
+
fetchJSON('../data/scenarios.json').catch(function () { return null; }),
|
|
560
|
+
fetchJSON('../i18n.json').catch(function () { return null; })
|
|
561
|
+
]);
|
|
562
|
+
})
|
|
563
|
+
.then(function (results) {
|
|
564
|
+
var theme = results[0];
|
|
565
|
+
var seed = results[1];
|
|
566
|
+
var scenarios = (results[2] && results[2].list) || [];
|
|
567
|
+
var scenario = null;
|
|
568
|
+
for (var i = 0; i < scenarios.length; i++) {
|
|
569
|
+
if (scenarios[i].id === env.scenario) { scenario = scenarios[i]; break; }
|
|
570
|
+
}
|
|
571
|
+
handleInit({
|
|
572
|
+
theme: theme,
|
|
573
|
+
data: applyScenario(seed, scenario),
|
|
574
|
+
i18n: results[3],
|
|
575
|
+
defaultLang: (config && config.defaultLanguage) || 'en'
|
|
576
|
+
});
|
|
577
|
+
})
|
|
578
|
+
.catch(function () {
|
|
579
|
+
handleInit({ theme: null, data: {} });
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function boot() {
|
|
584
|
+
if (embedded) {
|
|
585
|
+
send('sb:hello', { meta: meta, url: location.pathname });
|
|
586
|
+
/* If no shell answers (iframed by something else), fall back to standalone. */
|
|
587
|
+
setTimeout(function () { if (!initDone) standaloneInit(); }, 600);
|
|
588
|
+
} else {
|
|
589
|
+
standaloneInit();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (document.readyState === 'loading') {
|
|
594
|
+
document.addEventListener('DOMContentLoaded', boot);
|
|
595
|
+
} else {
|
|
596
|
+
boot();
|
|
597
|
+
}
|
|
598
|
+
})();
|