@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/shell/sidebar.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/* Sidebar: current feature header + searchable, grouped screen list. */
|
|
2
|
+
|
|
3
|
+
import { store, feature, set } from './store.js';
|
|
4
|
+
import { DEVICE_LABELS } from './frames.js';
|
|
5
|
+
|
|
6
|
+
const featureBox = document.getElementById('sidebar-feature');
|
|
7
|
+
const listBox = document.getElementById('screen-list');
|
|
8
|
+
const searchInput = document.getElementById('search');
|
|
9
|
+
|
|
10
|
+
searchInput.addEventListener('input', () => set({ search: searchInput.value.trim() }));
|
|
11
|
+
|
|
12
|
+
function esc(s) {
|
|
13
|
+
return String(s ?? '').replace(/[&<>"]/g, (c) => (
|
|
14
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
|
15
|
+
));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const KNOWN_STATUS = ['draft', 'wip', 'ready', 'review'];
|
|
19
|
+
|
|
20
|
+
export function visibleScreens() {
|
|
21
|
+
const f = feature();
|
|
22
|
+
if (!f) return [];
|
|
23
|
+
const q = store.ui.search.toLowerCase();
|
|
24
|
+
|
|
25
|
+
if (f.type === 'app') {
|
|
26
|
+
return (f.appScreens || []).filter((s) =>
|
|
27
|
+
!q || `${s.id} ${s.title} ${s.description || ''}`.toLowerCase().includes(q));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const devFilter = store.ui.deviceFilter;
|
|
31
|
+
return (f.manifest.screens || []).filter((s) => {
|
|
32
|
+
const devs = s.devices?.length ? s.devices : [s.device || 'none'];
|
|
33
|
+
if (devFilter !== 'all' && !devs.includes(devFilter)) return false;
|
|
34
|
+
if (!q) return true;
|
|
35
|
+
const hay = `${s.id} ${s.title} ${s.description || ''} ${(s.tags || []).join(' ')}`.toLowerCase();
|
|
36
|
+
return hay.includes(q);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderSidebar() {
|
|
41
|
+
const f = feature();
|
|
42
|
+
|
|
43
|
+
if (!f) {
|
|
44
|
+
featureBox.innerHTML = '';
|
|
45
|
+
listBox.innerHTML = `<div class="list-empty">No features yet.<br>Add one with the CLI or MCP.</div>`;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
featureBox.innerHTML =
|
|
50
|
+
`<div class="sf-title"><span class="sf-icon">${esc(f.meta.icon || '📄')}</span>${esc(f.meta.title || f.id)}</div>` +
|
|
51
|
+
(f.meta.description ? `<div class="sf-desc">${esc(f.meta.description)}</div>` : '');
|
|
52
|
+
|
|
53
|
+
const screens = visibleScreens();
|
|
54
|
+
if (!screens.length) {
|
|
55
|
+
listBox.innerHTML = `<div class="list-empty">${
|
|
56
|
+
f.type === 'app' && f.appScreens === null
|
|
57
|
+
? 'Waiting for the app to register and report its screens…'
|
|
58
|
+
: store.ui.search || store.ui.deviceFilter !== 'all'
|
|
59
|
+
? 'No screens match the current search / device filter.'
|
|
60
|
+
: 'No screens in this feature yet.'
|
|
61
|
+
}</div>`;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const showDevice = store.ui.deviceFilter === 'all' &&
|
|
66
|
+
new Set(screens.map((s) => s.device || 'none')).size > 1;
|
|
67
|
+
|
|
68
|
+
listBox.innerHTML = '';
|
|
69
|
+
let lastGroup = Symbol('none');
|
|
70
|
+
for (const s of screens) {
|
|
71
|
+
const group = s.group || null;
|
|
72
|
+
if (group !== lastGroup) {
|
|
73
|
+
if (group) {
|
|
74
|
+
const head = document.createElement('div');
|
|
75
|
+
head.className = 'group-head';
|
|
76
|
+
head.textContent = group;
|
|
77
|
+
listBox.append(head);
|
|
78
|
+
}
|
|
79
|
+
lastGroup = group;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const row = document.createElement('button');
|
|
83
|
+
row.type = 'button';
|
|
84
|
+
row.className = `screen-row${s.id === store.ui.screen ? ' is-active' : ''}`;
|
|
85
|
+
row.dataset.screen = s.id;
|
|
86
|
+
|
|
87
|
+
const pills = [];
|
|
88
|
+
if (s.status) {
|
|
89
|
+
const cls = KNOWN_STATUS.includes(s.status) ? ` st-${s.status}` : '';
|
|
90
|
+
pills.push(`<span class="pill${cls}">${esc(s.status)}</span>`);
|
|
91
|
+
}
|
|
92
|
+
if (showDevice) {
|
|
93
|
+
const d = DEVICE_LABELS[s.device || 'none'];
|
|
94
|
+
pills.push(`<span class="pill pill-device">${esc(d ? d.title : s.device)}</span>`);
|
|
95
|
+
}
|
|
96
|
+
for (const t of (s.tags || []).slice(0, 3)) {
|
|
97
|
+
pills.push(`<span class="pill">${esc(t)}</span>`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
row.innerHTML =
|
|
101
|
+
`<div class="sr-title">${esc(s.title || s.id)}</div>` +
|
|
102
|
+
(s.description ? `<div class="sr-desc">${esc(s.description)}</div>` : '') +
|
|
103
|
+
`<div class="sr-meta">${s.updated ? `<span class="sr-date">${esc(s.updated)}</span>` : ''}${pills.join('')}</div>`;
|
|
104
|
+
|
|
105
|
+
row.addEventListener('click', () => set({ screen: s.id, flow: null }));
|
|
106
|
+
listBox.append(row);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function scrollActiveIntoView() {
|
|
111
|
+
listBox.querySelector('.screen-row.is-active')?.scrollIntoView({ block: 'nearest' });
|
|
112
|
+
}
|
package/shell/store.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/* Shell state store: one plain object + subscriptions + URL (hash) sync.
|
|
2
|
+
Deep link shape: #feature/screen?scenario=…&mode=dark&theme=…&dir=rtl&lang=ar&device=ios&flow=…&step=2 */
|
|
3
|
+
|
|
4
|
+
const listeners = new Set();
|
|
5
|
+
|
|
6
|
+
export const store = {
|
|
7
|
+
project: null, // { config, features: Map(id → feature), themes: Map(id → json) }
|
|
8
|
+
ui: {
|
|
9
|
+
feature: null,
|
|
10
|
+
screen: null,
|
|
11
|
+
deviceFilter: 'all',
|
|
12
|
+
scenario: 'default',
|
|
13
|
+
theme: null,
|
|
14
|
+
mode: 'light',
|
|
15
|
+
dir: 'ltr',
|
|
16
|
+
lang: 'en',
|
|
17
|
+
flow: null,
|
|
18
|
+
flowStep: 0,
|
|
19
|
+
review: false,
|
|
20
|
+
themeLab: false,
|
|
21
|
+
search: '',
|
|
22
|
+
serveLive: false,
|
|
23
|
+
solo: false
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function subscribe(fn) {
|
|
28
|
+
listeners.add(fn);
|
|
29
|
+
return () => listeners.delete(fn);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* set(patch) → notifies subscribers with the set of changed keys */
|
|
33
|
+
export function set(patch) {
|
|
34
|
+
const changed = new Set();
|
|
35
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
36
|
+
if (store.ui[k] !== v) {
|
|
37
|
+
store.ui[k] = v;
|
|
38
|
+
changed.add(k);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (changed.size) {
|
|
42
|
+
syncHash();
|
|
43
|
+
for (const fn of listeners) fn(changed);
|
|
44
|
+
}
|
|
45
|
+
return changed;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function feature(id = store.ui.feature) {
|
|
49
|
+
return store.project?.features.get(id) || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function screenEntry(featureId = store.ui.feature, screenId = store.ui.screen) {
|
|
53
|
+
const f = feature(featureId);
|
|
54
|
+
if (f?.type === 'app') return (f.appScreens || []).find((s) => s.id === screenId) || null;
|
|
55
|
+
return f?.manifest?.screens?.find((s) => s.id === screenId) || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function activeTheme() {
|
|
59
|
+
return store.project?.themes.get(store.ui.theme) || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function activeScenario(featureId = store.ui.feature) {
|
|
63
|
+
const f = feature(featureId);
|
|
64
|
+
const list = f?.scenarios?.list || [];
|
|
65
|
+
return list.find((s) => s.id === store.ui.scenario) || null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ---------------- hash routing ---------------- */
|
|
69
|
+
|
|
70
|
+
const HASH_KEYS = ['scenario', 'mode', 'theme', 'dir', 'lang', 'flow'];
|
|
71
|
+
let applyingHash = false;
|
|
72
|
+
|
|
73
|
+
export function parseHash() {
|
|
74
|
+
const raw = location.hash.replace(/^#/, '');
|
|
75
|
+
if (!raw) return null;
|
|
76
|
+
const [path, query = ''] = raw.split('?');
|
|
77
|
+
const [feature, screen] = path.split('/').map((s) => decodeURIComponent(s || ''));
|
|
78
|
+
const params = new URLSearchParams(query);
|
|
79
|
+
const out = { feature: feature || null, screen: screen || null };
|
|
80
|
+
for (const k of HASH_KEYS) if (params.has(k)) out[k] = params.get(k);
|
|
81
|
+
if (params.has('device')) out.deviceFilter = params.get('device');
|
|
82
|
+
if (params.has('step')) out.flowStep = parseInt(params.get('step'), 10) || 0;
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function syncHash() {
|
|
87
|
+
if (applyingHash || store.ui.solo) return;
|
|
88
|
+
const u = store.ui;
|
|
89
|
+
if (!u.feature) return;
|
|
90
|
+
let hash = `#${encodeURIComponent(u.feature)}`;
|
|
91
|
+
if (u.screen) hash += `/${encodeURIComponent(u.screen)}`;
|
|
92
|
+
const params = new URLSearchParams();
|
|
93
|
+
if (u.scenario && u.scenario !== 'default') params.set('scenario', u.scenario);
|
|
94
|
+
if (u.mode !== 'light') params.set('mode', u.mode);
|
|
95
|
+
if (u.theme && store.project?.config?.themes?.[0] !== u.theme) params.set('theme', u.theme);
|
|
96
|
+
if (u.dir !== 'ltr') params.set('dir', u.dir);
|
|
97
|
+
if (u.lang && u.lang !== (store.project?.config?.defaultLanguage || 'en')) params.set('lang', u.lang);
|
|
98
|
+
if (u.deviceFilter !== 'all') params.set('device', u.deviceFilter);
|
|
99
|
+
if (u.flow) { params.set('flow', u.flow); params.set('step', String(u.flowStep)); }
|
|
100
|
+
const q = params.toString();
|
|
101
|
+
if (q) hash += `?${q}`;
|
|
102
|
+
if (location.hash !== hash) history.replaceState(null, '', hash);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function applyHash() {
|
|
106
|
+
const parsed = parseHash();
|
|
107
|
+
if (!parsed) return false;
|
|
108
|
+
applyingHash = true;
|
|
109
|
+
try {
|
|
110
|
+
const patch = {};
|
|
111
|
+
for (const k of ['feature', 'screen', 'scenario', 'mode', 'theme', 'dir', 'lang', 'deviceFilter', 'flow', 'flowStep']) {
|
|
112
|
+
if (parsed[k] !== undefined && parsed[k] !== null) patch[k] = parsed[k];
|
|
113
|
+
}
|
|
114
|
+
set(patch);
|
|
115
|
+
} finally {
|
|
116
|
+
applyingHash = false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/* 🎨 Theme Lab (EN-15): see every token the active theme defines, edit them
|
|
2
|
+
LIVE against the hosted screens, switch themes, export JSON — and save back
|
|
3
|
+
to themes/<id>.json when running under `engine serve`. */
|
|
4
|
+
|
|
5
|
+
import { store, set } from './store.js';
|
|
6
|
+
import { pushUpdate } from './screenhost.js';
|
|
7
|
+
import { fetchJSON, url } from './net.js';
|
|
8
|
+
import { toast } from './toast.js';
|
|
9
|
+
|
|
10
|
+
const Kit = window.EngineKit;
|
|
11
|
+
const panel = document.getElementById('theme-panel');
|
|
12
|
+
|
|
13
|
+
let working = null; // deep clone of the theme being edited
|
|
14
|
+
let workingId = null;
|
|
15
|
+
let dirty = false;
|
|
16
|
+
let applyTimer = null;
|
|
17
|
+
|
|
18
|
+
export function themeLabDirty() {
|
|
19
|
+
return dirty;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ensureWorking() {
|
|
23
|
+
const id = store.ui.theme;
|
|
24
|
+
if (!working || workingId !== id) {
|
|
25
|
+
working = Kit.clone(store.project.themes.get(id)) || Kit.clone(Kit.FALLBACK_THEME);
|
|
26
|
+
workingId = id;
|
|
27
|
+
dirty = false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Effective (light ⊕ dark for dark mode) rows; edits always write the delta
|
|
32
|
+
into modes[activeMode] so dark stays a delta over light. */
|
|
33
|
+
function tokenRows() {
|
|
34
|
+
const mode = store.ui.mode;
|
|
35
|
+
const light = working.modes?.light || {};
|
|
36
|
+
const effective = mode === 'dark' ? Kit.deepMerge(light, working.modes?.dark || {}) : light;
|
|
37
|
+
const rows = [];
|
|
38
|
+
(function walk(node, path) {
|
|
39
|
+
for (const [key, value] of Object.entries(node)) {
|
|
40
|
+
const p = [...path, key];
|
|
41
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) walk(value, p);
|
|
42
|
+
else rows.push({ path: p, value: String(value) });
|
|
43
|
+
}
|
|
44
|
+
})(effective, []);
|
|
45
|
+
return rows;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function setToken(pathArr, value) {
|
|
49
|
+
const mode = store.ui.mode;
|
|
50
|
+
if (!working.modes) working.modes = {};
|
|
51
|
+
if (!working.modes[mode]) working.modes[mode] = {};
|
|
52
|
+
let cur = working.modes[mode];
|
|
53
|
+
for (let i = 0; i < pathArr.length - 1; i++) {
|
|
54
|
+
if (typeof cur[pathArr[i]] !== 'object' || cur[pathArr[i]] === null) cur[pathArr[i]] = {};
|
|
55
|
+
cur = cur[pathArr[i]];
|
|
56
|
+
}
|
|
57
|
+
cur[pathArr[pathArr.length - 1]] = value;
|
|
58
|
+
dirty = true;
|
|
59
|
+
|
|
60
|
+
clearTimeout(applyTimer);
|
|
61
|
+
applyTimer = setTimeout(() => {
|
|
62
|
+
store.project.themes.set(workingId, working);
|
|
63
|
+
pushUpdate();
|
|
64
|
+
renderHeaderBits();
|
|
65
|
+
}, 60);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const HEX_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
69
|
+
const CORE_PREVIEW = ['color.accent', 'color.bg', 'color.card', 'color.ink', 'color.ink-2', 'color.line'];
|
|
70
|
+
|
|
71
|
+
function esc(s) {
|
|
72
|
+
return String(s ?? '').replace(/[&<>"]/g, (c) => (
|
|
73
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
|
74
|
+
));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function download() {
|
|
78
|
+
const blob = new Blob([JSON.stringify(working, null, 2) + '\n'], { type: 'application/json' });
|
|
79
|
+
const a = document.createElement('a');
|
|
80
|
+
a.href = URL.createObjectURL(blob);
|
|
81
|
+
a.download = `${workingId}.json`;
|
|
82
|
+
a.click();
|
|
83
|
+
URL.revokeObjectURL(a.href);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function saveToDisk() {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(url(`__sb/theme/${encodeURIComponent(workingId)}`), {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ theme: working })
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
94
|
+
dirty = false;
|
|
95
|
+
toast(`Saved themes/${workingId}.json ✓`);
|
|
96
|
+
renderThemeLab();
|
|
97
|
+
} catch {
|
|
98
|
+
toast('⚠︎ Save failed — is engine serve running?');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function revert() {
|
|
103
|
+
const disk = await fetchJSON(`themes/${workingId}.json`, { optional: true });
|
|
104
|
+
working = disk || Kit.clone(Kit.FALLBACK_THEME);
|
|
105
|
+
dirty = false;
|
|
106
|
+
store.project.themes.set(workingId, working);
|
|
107
|
+
pushUpdate();
|
|
108
|
+
renderThemeLab();
|
|
109
|
+
toast('Reverted to disk version');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function renderHeaderBits() {
|
|
113
|
+
const badge = panel.querySelector('#tl-dirty');
|
|
114
|
+
if (badge) badge.hidden = !dirty;
|
|
115
|
+
const strip = panel.querySelector('#tl-strip');
|
|
116
|
+
if (strip) {
|
|
117
|
+
const vars = Kit.themeVars(working, store.ui.mode);
|
|
118
|
+
strip.querySelectorAll('[data-swatch]').forEach((el) => {
|
|
119
|
+
el.style.background = vars[`--${el.dataset.swatch.replace(/\./g, '-')}`] || 'transparent';
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function renderThemeLab() {
|
|
125
|
+
if (!store.ui.themeLab) {
|
|
126
|
+
panel.hidden = true;
|
|
127
|
+
panel.innerHTML = '';
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
ensureWorking();
|
|
131
|
+
panel.hidden = false;
|
|
132
|
+
|
|
133
|
+
const themes = [...store.project.themes.keys()];
|
|
134
|
+
const mode = store.ui.mode;
|
|
135
|
+
const vars = Kit.themeVars(working, mode);
|
|
136
|
+
|
|
137
|
+
panel.innerHTML =
|
|
138
|
+
`<div class="rp-head"><h3>🎨 ${esc(working.title || workingId)}
|
|
139
|
+
<span class="rp-count" id="tl-dirty" ${dirty ? '' : 'hidden'}>· unsaved</span></h3>
|
|
140
|
+
<div class="rp-tools">
|
|
141
|
+
<button class="rp-tool" data-act="export">Export</button>
|
|
142
|
+
<button class="rp-tool" data-act="save" title="${store.ui.serveLive ? `Writes themes/${esc(workingId)}.json` : 'Needs engine serve'}">Save</button>
|
|
143
|
+
<button class="rp-tool" data-act="revert">Revert</button>
|
|
144
|
+
</div></div>` +
|
|
145
|
+
(themes.length > 1
|
|
146
|
+
? `<div class="tl-themes">${themes.map((t) =>
|
|
147
|
+
`<button class="tl-theme-chip${t === workingId ? ' is-on' : ''}" data-theme="${esc(t)}">${esc(store.project.themes.get(t)?.title || t)}</button>`).join('')}</div>`
|
|
148
|
+
: '') +
|
|
149
|
+
`<div class="tl-strip" id="tl-strip">${CORE_PREVIEW.map((p) =>
|
|
150
|
+
`<span data-swatch="${esc(p)}" title="${esc(p)}" style="background:${esc(vars[`--${p.replace(/\./g, '-')}`] || 'transparent')}"></span>`).join('')}</div>` +
|
|
151
|
+
`<div class="rp-hint">Editing <b>${esc(mode)}</b> tokens of <b>themes/${esc(workingId)}.json</b> — flip ☾/☀︎ to edit the other mode. Changes hit the screen live.</div>` +
|
|
152
|
+
`<div class="tl-rows">${tokenRows().map((r, i) => {
|
|
153
|
+
const pathStr = r.path.join('.');
|
|
154
|
+
const isHex = HEX_RE.test(r.value.trim());
|
|
155
|
+
return `<div class="tl-row">
|
|
156
|
+
<label for="tl-i${i}" title="--${esc(r.path.join('-'))}">${esc(pathStr)}</label>
|
|
157
|
+
${isHex ? `<input type="color" data-path="${esc(pathStr)}" value="${esc(r.value.trim())}" aria-label="${esc(pathStr)} color">` : `<span class="tl-mini" style="background:${esc(r.value)}"></span>`}
|
|
158
|
+
<input type="text" id="tl-i${i}" data-path="${esc(pathStr)}" value="${esc(r.value)}" spellcheck="false">
|
|
159
|
+
</div>`;
|
|
160
|
+
}).join('')}</div>`;
|
|
161
|
+
|
|
162
|
+
panel.querySelector('[data-act="export"]').addEventListener('click', download);
|
|
163
|
+
panel.querySelector('[data-act="save"]').addEventListener('click', saveToDisk);
|
|
164
|
+
panel.querySelector('[data-act="revert"]').addEventListener('click', revert);
|
|
165
|
+
|
|
166
|
+
panel.querySelectorAll('.tl-theme-chip').forEach((chip) =>
|
|
167
|
+
chip.addEventListener('click', () => {
|
|
168
|
+
working = null;
|
|
169
|
+
set({ theme: chip.dataset.theme });
|
|
170
|
+
renderThemeLab();
|
|
171
|
+
}));
|
|
172
|
+
|
|
173
|
+
panel.querySelectorAll('.tl-rows input').forEach((input) => {
|
|
174
|
+
input.addEventListener('input', () => {
|
|
175
|
+
const pathArr = input.dataset.path.split('.');
|
|
176
|
+
setToken(pathArr, input.value);
|
|
177
|
+
/* keep the twin control in sync */
|
|
178
|
+
const twin = panel.querySelectorAll(`.tl-rows input[data-path="${CSS.escape(input.dataset.path)}"]`);
|
|
179
|
+
twin.forEach((other) => {
|
|
180
|
+
if (other !== input && other.value !== input.value && HEX_RE.test(input.value.trim())) {
|
|
181
|
+
other.value = input.value.trim();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
package/shell/toast.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/* Shell-level toasts (screen-level toasts are Engine.toast inside the iframe). */
|
|
2
|
+
|
|
3
|
+
const box = document.getElementById('shell-toasts');
|
|
4
|
+
|
|
5
|
+
export function toast(msg) {
|
|
6
|
+
const t = document.createElement('div');
|
|
7
|
+
t.className = 'shell-toast';
|
|
8
|
+
t.textContent = msg;
|
|
9
|
+
box.append(t);
|
|
10
|
+
requestAnimationFrame(() => t.classList.add('is-in'));
|
|
11
|
+
setTimeout(() => {
|
|
12
|
+
t.classList.remove('is-in');
|
|
13
|
+
setTimeout(() => t.remove(), 220);
|
|
14
|
+
}, 2200);
|
|
15
|
+
}
|
package/shell/toolbar.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* Toolbar widgets: dropdown chips + toggle chips. One open menu at a time. */
|
|
2
|
+
|
|
3
|
+
let openMenu = null;
|
|
4
|
+
|
|
5
|
+
document.addEventListener('click', (e) => {
|
|
6
|
+
if (openMenu && !openMenu.contains(e.target)) closeMenus();
|
|
7
|
+
});
|
|
8
|
+
document.addEventListener('keydown', (e) => {
|
|
9
|
+
if (e.key === 'Escape') closeMenus();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function closeMenus() {
|
|
13
|
+
if (openMenu) {
|
|
14
|
+
openMenu.querySelector('.menu')?.remove();
|
|
15
|
+
openMenu = null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function h(tag, className, html) {
|
|
20
|
+
const node = document.createElement(tag);
|
|
21
|
+
if (className) node.className = className;
|
|
22
|
+
if (html !== undefined) node.innerHTML = html;
|
|
23
|
+
return node;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function esc(s) {
|
|
27
|
+
return String(s ?? '').replace(/[&<>"]/g, (c) => (
|
|
28
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
|
29
|
+
));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/*
|
|
33
|
+
* dropdown({ icon, title, value(), items(), onPick(id), head? })
|
|
34
|
+
* items() → [{ id, icon, title, desc, checked }]
|
|
35
|
+
* Returns { el, refresh } — refresh re-renders the chip's value label.
|
|
36
|
+
*/
|
|
37
|
+
export function dropdown(spec) {
|
|
38
|
+
const wrap = h('div', 'dropdown');
|
|
39
|
+
const chip = h('button', 'chip');
|
|
40
|
+
chip.type = 'button';
|
|
41
|
+
if (spec.tooltip) chip.title = spec.tooltip;
|
|
42
|
+
wrap.append(chip);
|
|
43
|
+
|
|
44
|
+
function renderChip() {
|
|
45
|
+
const value = spec.value ? spec.value() : null;
|
|
46
|
+
chip.innerHTML =
|
|
47
|
+
`<span class="chip-icon">${esc(spec.icon)}</span>` +
|
|
48
|
+
(spec.title ? `<span>${esc(spec.title)}</span>` : '') +
|
|
49
|
+
(value ? `<span class="chip-value">${esc(value)}</span>` : '') +
|
|
50
|
+
`<span class="chip-caret">▼</span>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function open() {
|
|
54
|
+
closeMenus();
|
|
55
|
+
const menu = h('div', 'menu');
|
|
56
|
+
if (spec.head) menu.append(h('div', 'menu-head', esc(spec.head)));
|
|
57
|
+
const items = spec.items();
|
|
58
|
+
if (!items.length) {
|
|
59
|
+
menu.append(h('div', 'menu-empty', esc(spec.empty || 'Nothing here yet')));
|
|
60
|
+
}
|
|
61
|
+
for (const item of items) {
|
|
62
|
+
if (item.head) {
|
|
63
|
+
menu.append(h('div', 'menu-head', esc(item.title)));
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const btn = h('button', `menu-item${item.checked ? ' is-checked' : ''}`);
|
|
67
|
+
btn.type = 'button';
|
|
68
|
+
btn.innerHTML =
|
|
69
|
+
`<span class="mi-icon">${esc(item.icon ?? '')}</span>` +
|
|
70
|
+
`<span class="mi-body"><span class="mi-title">${esc(item.title)}</span>` +
|
|
71
|
+
(item.desc ? `<span class="mi-desc">${esc(item.desc)}</span>` : '') +
|
|
72
|
+
`</span><span class="mi-check">✓</span>`;
|
|
73
|
+
btn.addEventListener('click', () => {
|
|
74
|
+
closeMenus();
|
|
75
|
+
spec.onPick(item.id);
|
|
76
|
+
});
|
|
77
|
+
menu.append(btn);
|
|
78
|
+
}
|
|
79
|
+
wrap.append(menu);
|
|
80
|
+
openMenu = wrap;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
chip.addEventListener('click', (e) => {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
if (openMenu === wrap) closeMenus();
|
|
86
|
+
else open();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
renderChip();
|
|
90
|
+
return { el: wrap, refresh: renderChip };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/*
|
|
94
|
+
* toggle({ icon, iconOn?, title?, tooltip, is(), onToggle, danger? })
|
|
95
|
+
* Returns { el, refresh }.
|
|
96
|
+
*/
|
|
97
|
+
export function toggle(spec) {
|
|
98
|
+
const chip = h('button', `chip icon-only${spec.danger ? ' is-danger' : ''}`);
|
|
99
|
+
chip.type = 'button';
|
|
100
|
+
chip.title = spec.tooltip || '';
|
|
101
|
+
|
|
102
|
+
function refresh() {
|
|
103
|
+
const on = spec.is();
|
|
104
|
+
chip.classList.toggle('is-on', !!on);
|
|
105
|
+
chip.innerHTML =
|
|
106
|
+
`<span class="chip-icon">${esc(on && spec.iconOn ? spec.iconOn : spec.icon)}</span>` +
|
|
107
|
+
(spec.label ? `<span>${esc(typeof spec.label === 'function' ? spec.label() : spec.label)}</span>` : '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
chip.addEventListener('click', () => { spec.onToggle(); refresh(); });
|
|
111
|
+
refresh();
|
|
112
|
+
return { el: chip, refresh };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* Plain action chip (no state). */
|
|
116
|
+
export function action(spec) {
|
|
117
|
+
const chip = h('button', 'chip icon-only');
|
|
118
|
+
chip.type = 'button';
|
|
119
|
+
chip.title = spec.tooltip || '';
|
|
120
|
+
chip.innerHTML = `<span class="chip-icon">${esc(spec.icon)}</span>` +
|
|
121
|
+
(spec.label ? `<span>${esc(spec.label)}</span>` : '');
|
|
122
|
+
chip.addEventListener('click', spec.onClick);
|
|
123
|
+
return { el: chip, refresh() {} };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function divider() {
|
|
127
|
+
return { el: h('div', 'tb-divider'), refresh() {} };
|
|
128
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: screenbook
|
|
3
|
+
description: Author and review ScreenBook prototype screens — standalone HTML screens with a meta block, JSON mock data + scenarios, JSON design tokens, guided flows, review comments. Use whenever working in a project containing app.config.json + features/ + engine/ (a ScreenBook project), for creating/editing screens, changing mock data or scenarios, theming, building demo flows, taking screenshots, or addressing review pins.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ScreenBook authoring
|
|
7
|
+
|
|
8
|
+
ScreenBook is a static, no-build prototype studio: folders of standalone HTML screens + JSON
|
|
9
|
+
files, rendered by a shell into a device-framed gallery. You author the **project**; you never
|
|
10
|
+
read or modify `engine/` internals.
|
|
11
|
+
|
|
12
|
+
## The 60-second mental model
|
|
13
|
+
|
|
14
|
+
- One screen = one full HTML document in `features/<f>/screens/<id>.html`, with a required
|
|
15
|
+
`<script type="application/json" id="screen-meta">` block and one bridge include
|
|
16
|
+
(`../../../engine/bridge.js`). It works opened alone AND inside the shell.
|
|
17
|
+
- Mock data = `features/<f>/data/seed.json`; named **scenarios** overlay it
|
|
18
|
+
(`patch` deep-merge, `remove` dot-paths, `{{today±N}}` date tokens).
|
|
19
|
+
- Look = design tokens in `themes/<id>.json`, flattened to CSS vars (`--color-accent`,
|
|
20
|
+
`--font-sans`, `--radius-card`, `--space-page`); dark mode merges over light.
|
|
21
|
+
- `manifest.json` is derived — regenerate, never hand-edit.
|
|
22
|
+
- Done = `validate` green (static contract checks + every screen renders in every
|
|
23
|
+
theme × mode × direction with zero console errors).
|
|
24
|
+
|
|
25
|
+
## Typical loops
|
|
26
|
+
|
|
27
|
+
**Add a screen**: read a sibling screen (`get_screen`) to copy its idioms → write the new
|
|
28
|
+
document → `add_screen` (validates + stamps + regenerates manifest) → `validate` →
|
|
29
|
+
`screenshot` if you want to look at it.
|
|
30
|
+
|
|
31
|
+
**Change data/scenarios**: `get_data` → edit → `set_data` → `validate`.
|
|
32
|
+
|
|
33
|
+
**Address review**: `get_review_comments {unresolvedOnly: true}` → each comment carries
|
|
34
|
+
screen, scenario, theme, mode, dir + the CSS path that was clicked → fix the screen →
|
|
35
|
+
`resolve_comment`.
|
|
36
|
+
|
|
37
|
+
**Demo story**: `set_flows` with steps `{screen, note, scenario?}` — the shell plays them
|
|
38
|
+
as a guided walkthrough.
|
|
39
|
+
|
|
40
|
+
Without MCP, the CLI does the same: `node engine/cli/screenbook.js manifest | validate |
|
|
41
|
+
serve | screenshot --screen <feature>/<screen>`.
|
|
42
|
+
|
|
43
|
+
## References (read the one you need)
|
|
44
|
+
|
|
45
|
+
- `references/screen-contract.md` — full screen anatomy, meta fields, safe areas, standalone mode
|
|
46
|
+
- `references/bridge-api.md` — Engine.* API: ready/data/state/go/toast/env + dark/RTL idioms
|
|
47
|
+
- `references/tokens.md` — theme JSON schema, flattening rules, theming discipline
|
|
48
|
+
- `references/scenarios.md` — seed + overlay semantics, date tokens, scenario design tips
|
|
49
|
+
- `references/flows.md` — flows.json schema + how the player behaves
|
|
50
|
+
- `references/cli-mcp.md` — every CLI command + MCP tool, serve endpoints, validation gates
|
|
51
|
+
- `references/app-mode.md` — embedding whole single-page apps (cooperative include or injected driver)
|
|
52
|
+
- `references/i18n.md` — per-feature strings: key-first i18n.json + `Engine.t(key, {vars})`
|