@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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "paper",
|
|
3
|
+
"title": "Paper",
|
|
4
|
+
"modes": {
|
|
5
|
+
"light": {
|
|
6
|
+
"color": {
|
|
7
|
+
"accent": "#2F6FED",
|
|
8
|
+
"bg": "#F7F6F3",
|
|
9
|
+
"card": "#FFFFFF",
|
|
10
|
+
"ink": "#1B1A17",
|
|
11
|
+
"ink-2": "#6E6A61",
|
|
12
|
+
"line": "#E6E2D8"
|
|
13
|
+
},
|
|
14
|
+
"font": {
|
|
15
|
+
"sans": "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
|
16
|
+
"serif": "ui-serif, 'New York', Georgia, serif"
|
|
17
|
+
},
|
|
18
|
+
"radius": { "card": "16px", "chip": "999px" },
|
|
19
|
+
"space": { "page": "20px" }
|
|
20
|
+
},
|
|
21
|
+
"dark": {
|
|
22
|
+
"color": {
|
|
23
|
+
"accent": "#6E9BFF",
|
|
24
|
+
"bg": "#15161A",
|
|
25
|
+
"card": "#1E2026",
|
|
26
|
+
"ink": "#EDEDF2",
|
|
27
|
+
"ink-2": "#9DA0AB",
|
|
28
|
+
"line": "#2E3138"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/index.html
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-shell="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>ScreenBook</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect x='8' y='8' width='84' height='84' rx='20' fill='%235B5BD6'/%3E%3Crect x='26' y='24' width='48' height='36' rx='6' fill='white'/%3E%3Crect x='26' y='66' width='48' height='8' rx='4' fill='white' opacity='.7'/%3E%3C/svg%3E">
|
|
8
|
+
<link rel="stylesheet" href="shell/shell.css">
|
|
9
|
+
<script>window.__SCREENBOOK_SHELL__ = true;</script>
|
|
10
|
+
<script src="bridge.js"></script>
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<header class="topbar" id="topbar">
|
|
14
|
+
<div class="brand">
|
|
15
|
+
<span class="brand-mark">⧉</span>
|
|
16
|
+
<span class="brand-name">ScreenBook</span>
|
|
17
|
+
<span class="brand-sep">/</span>
|
|
18
|
+
<span class="brand-project" id="project-name">…</span>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="topbar-controls" id="controls"></div>
|
|
21
|
+
</header>
|
|
22
|
+
|
|
23
|
+
<div class="workspace" id="workspace">
|
|
24
|
+
<aside class="sidebar" id="sidebar">
|
|
25
|
+
<div class="sidebar-feature" id="sidebar-feature"></div>
|
|
26
|
+
<div class="sidebar-search">
|
|
27
|
+
<input type="search" id="search" placeholder="Search screens…" autocomplete="off" spellcheck="false">
|
|
28
|
+
</div>
|
|
29
|
+
<nav class="screen-list" id="screen-list"></nav>
|
|
30
|
+
<footer class="sidebar-foot">
|
|
31
|
+
<span class="serve-badge" id="serve-badge" title="Static server — review comments stay in localStorage">○ static</span>
|
|
32
|
+
<a class="foot-link" id="selftest-link" href="?selftest=1">Self-test</a>
|
|
33
|
+
</footer>
|
|
34
|
+
</aside>
|
|
35
|
+
|
|
36
|
+
<main class="canvas" id="canvas">
|
|
37
|
+
<div class="stage" id="stage"></div>
|
|
38
|
+
<div class="flowbar" id="flowbar" hidden></div>
|
|
39
|
+
</main>
|
|
40
|
+
|
|
41
|
+
<aside class="theme-panel" id="theme-panel" hidden></aside>
|
|
42
|
+
<aside class="review-panel" id="review-panel" hidden></aside>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="shell-toasts" id="shell-toasts"></div>
|
|
46
|
+
<div class="selftest-overlay" id="selftest-overlay" hidden></div>
|
|
47
|
+
|
|
48
|
+
<script type="module" src="shell/main.js"></script>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
package/mcp/server.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* ScreenBook MCP server — stdio JSON-RPC 2.0, newline-delimited (EN-18).
|
|
3
|
+
Zero dependencies; shares engine/core/ with the CLI. Agents use these
|
|
4
|
+
tools to author screens without ever reading engine internals (EN-22).
|
|
5
|
+
|
|
6
|
+
Usage: node engine/mcp/server.js [--root <project-dir>] */
|
|
7
|
+
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import readline from 'node:readline';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import {
|
|
12
|
+
resolveRoot, readConfig, listFeatureIds, scanFeature,
|
|
13
|
+
getScreen, addScreen, updateScreen, removeScreen, addFeature, addApp,
|
|
14
|
+
getData, setData, getTheme, setTheme, getFlows, setFlows,
|
|
15
|
+
getComments, resolveComment, staticValidate
|
|
16
|
+
} from '../core/project.js';
|
|
17
|
+
|
|
18
|
+
const ENGINE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const VERSION = '0.1.0';
|
|
20
|
+
|
|
21
|
+
const rootFlagIx = process.argv.indexOf('--root');
|
|
22
|
+
const rootArg = rootFlagIx > -1 ? process.argv[rootFlagIx + 1] : undefined;
|
|
23
|
+
const ROOT = await resolveRoot(rootArg);
|
|
24
|
+
if (!ROOT) {
|
|
25
|
+
console.error(`screenbook-mcp: no app.config.json found${rootArg ? ` under ${rootArg}` : ''} — pass --root <project-dir>`);
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ---------------- tool definitions ---------------- */
|
|
30
|
+
|
|
31
|
+
const SCREEN_CONTRACT =
|
|
32
|
+
'A screen is one full standalone HTML document: <!DOCTYPE html>, a required ' +
|
|
33
|
+
'<script type="application/json" id="screen-meta"> block ({title, description, order, ' +
|
|
34
|
+
'device: ios|android|web|none, status?, tags?, group?, statusBar?: light|dark, updated?}), and one ' +
|
|
35
|
+
'<script src="../../../engine/bridge.js"></script> include. Use CSS vars from the active theme ' +
|
|
36
|
+
'(--color-*, --font-*, --radius-*, --space-*, --safe-top, --safe-bottom), bind mock data with ' +
|
|
37
|
+
'Engine.ready(cb → cb(data)), navigate with data-go="<screen-id>" attributes, share state via ' +
|
|
38
|
+
'Engine.state.get/set/subscribe, notify with Engine.toast(msg). Screens must render sensibly ' +
|
|
39
|
+
'with empty data. The updated date is stamped automatically on save.';
|
|
40
|
+
|
|
41
|
+
const TOOLS = [
|
|
42
|
+
{
|
|
43
|
+
name: 'list_features',
|
|
44
|
+
description: 'List all features (folders under features/) with their metadata and screen counts.',
|
|
45
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
46
|
+
run: async () => {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const id of await listFeatureIds(ROOT)) {
|
|
49
|
+
const { meta, screens } = await scanFeature(ROOT, id);
|
|
50
|
+
out.push({ id, ...meta, screens: screens.length });
|
|
51
|
+
}
|
|
52
|
+
return { features: out, config: await readConfig(ROOT) };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
name: 'list_screens',
|
|
57
|
+
description: 'List the screens of one feature (id, title, description, order, device, status, tags, updated).',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object', required: ['feature'],
|
|
60
|
+
properties: { feature: { type: 'string', description: 'feature id (folder name)' } },
|
|
61
|
+
additionalProperties: false
|
|
62
|
+
},
|
|
63
|
+
run: async ({ feature }) => {
|
|
64
|
+
const { screens } = await scanFeature(ROOT, feature);
|
|
65
|
+
return { feature, screens };
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'get_screen',
|
|
70
|
+
description: 'Read one screen: parsed meta + the full HTML source.',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object', required: ['feature', 'screen'],
|
|
73
|
+
properties: { feature: { type: 'string' }, screen: { type: 'string' } },
|
|
74
|
+
additionalProperties: false
|
|
75
|
+
},
|
|
76
|
+
run: ({ feature, screen }) => getScreen(ROOT, feature, screen)
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'add_screen',
|
|
80
|
+
description: `Create a new screen file (fails if it exists). ${SCREEN_CONTRACT}`,
|
|
81
|
+
inputSchema: {
|
|
82
|
+
type: 'object', required: ['feature', 'screen', 'html'],
|
|
83
|
+
properties: {
|
|
84
|
+
feature: { type: 'string' },
|
|
85
|
+
screen: { type: 'string', description: 'screen id = filename without .html; numeric prefix orders the sidebar (e.g. 030-checkout)' },
|
|
86
|
+
html: { type: 'string', description: 'the complete standalone HTML document' }
|
|
87
|
+
},
|
|
88
|
+
additionalProperties: false
|
|
89
|
+
},
|
|
90
|
+
run: ({ feature, screen, html }) => addScreen(ROOT, feature, screen, html)
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'update_screen',
|
|
94
|
+
description: `Replace an existing screen's HTML (fails if missing; read-then-write). ${SCREEN_CONTRACT}`,
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: 'object', required: ['feature', 'screen', 'html'],
|
|
97
|
+
properties: {
|
|
98
|
+
feature: { type: 'string' },
|
|
99
|
+
screen: { type: 'string' },
|
|
100
|
+
html: { type: 'string', description: 'the complete new HTML document (send the whole file, not a diff)' }
|
|
101
|
+
},
|
|
102
|
+
additionalProperties: false
|
|
103
|
+
},
|
|
104
|
+
run: ({ feature, screen, html }) => updateScreen(ROOT, feature, screen, html)
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'remove_screen',
|
|
108
|
+
description: 'Delete a screen file and refresh the manifest. Check flows.json references afterwards.',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: 'object', required: ['feature', 'screen'],
|
|
111
|
+
properties: { feature: { type: 'string' }, screen: { type: 'string' } },
|
|
112
|
+
additionalProperties: false
|
|
113
|
+
},
|
|
114
|
+
run: ({ feature, screen }) => removeScreen(ROOT, feature, screen)
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: 'add_feature',
|
|
118
|
+
description: 'Create a feature folder (feature.json, empty data/seed.json + scenarios.json + flows.json, screens/) and register it in app.config.json.',
|
|
119
|
+
inputSchema: {
|
|
120
|
+
type: 'object', required: ['id'],
|
|
121
|
+
properties: {
|
|
122
|
+
id: { type: 'string', description: 'lowercase-dashed folder name, e.g. trip-planning' },
|
|
123
|
+
title: { type: 'string' },
|
|
124
|
+
icon: { type: 'string', description: 'one emoji' },
|
|
125
|
+
description: { type: 'string' },
|
|
126
|
+
order: { type: 'number' }
|
|
127
|
+
},
|
|
128
|
+
additionalProperties: false
|
|
129
|
+
},
|
|
130
|
+
run: (args) => addFeature(ROOT, args)
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'add_app',
|
|
134
|
+
description: 'Register an existing single-page app as an embedded app-type feature (app mode). The shell will drive it over postMessage; a scaffolded driver.js (injected, project-owned) teaches ScreenBook the app\'s internals so the app\'s own files stay untouched. Fill in the driver\'s screens()/go()/set() afterwards, then run validate.',
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: 'object', required: ['src'],
|
|
137
|
+
properties: {
|
|
138
|
+
src: { type: 'string', description: 'project-relative path to the app\'s index.html' },
|
|
139
|
+
id: { type: 'string' },
|
|
140
|
+
title: { type: 'string' },
|
|
141
|
+
icon: { type: 'string' },
|
|
142
|
+
description: { type: 'string' },
|
|
143
|
+
device: { type: 'string', enum: ['ios', 'android', 'web', 'none'] },
|
|
144
|
+
chrome: { type: 'string', enum: ['self', 'engine'], description: 'self = app draws its own status/home bars (engine shows bezel only)' },
|
|
145
|
+
driver: { type: 'boolean', default: true, description: 'false for cooperative apps that include engine/app-bridge.js themselves' }
|
|
146
|
+
},
|
|
147
|
+
additionalProperties: false
|
|
148
|
+
},
|
|
149
|
+
run: (args) => addApp(ROOT, { ...args, driver: args.driver !== false })
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'get_data',
|
|
153
|
+
description: "Read a feature's mock data + strings: seed.json, scenarios.json (scenario = {id, icon, title, description, patch?, remove?}; patch deep-merges over seed, remove deletes dot-paths; {{today±N}} date tokens), and i18n.json (key-first: { \"key\": { \"en\": \"…\", \"ar\": \"…\" } } for Engine.t).",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
type: 'object', required: ['feature'],
|
|
156
|
+
properties: { feature: { type: 'string' } },
|
|
157
|
+
additionalProperties: false
|
|
158
|
+
},
|
|
159
|
+
run: ({ feature }) => getData(ROOT, feature)
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'set_data',
|
|
163
|
+
description: "Write a feature's seed, scenarios and/or i18n strings. Scenarios: { list: [{id, icon, title, description, patch?, remove?}, …] } — keep an id:'default' baseline. i18n (key-first): { \"slide1.title\": { \"en\": \"…\", \"ar\": \"…\" } } — screens read them with Engine.t('slide1.title', {vars}); missing translations fall back defaultLanguage → key and surface as validate warnings.",
|
|
164
|
+
inputSchema: {
|
|
165
|
+
type: 'object', required: ['feature'],
|
|
166
|
+
properties: {
|
|
167
|
+
feature: { type: 'string' },
|
|
168
|
+
seed: { type: 'object', description: 'full replacement for seed.json (omit to keep)' },
|
|
169
|
+
scenarios: { type: 'object', description: 'full replacement for scenarios.json (omit to keep)' },
|
|
170
|
+
i18n: { type: 'object', description: 'full replacement for i18n.json, key-first shape (omit to keep)' }
|
|
171
|
+
},
|
|
172
|
+
additionalProperties: false
|
|
173
|
+
},
|
|
174
|
+
run: ({ feature, seed, scenarios, i18n }) => setData(ROOT, feature, { seed, scenarios, i18n })
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
name: 'get_theme',
|
|
178
|
+
description: 'Read a theme (design tokens JSON). Omit id for the default. Tokens flatten to CSS vars: color.ink-2 → --color-ink-2; modes.dark deep-merges over modes.light.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: { id: { type: 'string' } },
|
|
182
|
+
additionalProperties: false
|
|
183
|
+
},
|
|
184
|
+
run: ({ id } = {}) => getTheme(ROOT, id)
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: 'set_theme',
|
|
188
|
+
description: 'Write a theme JSON ({id, title, modes: {light: {color/font/radius/space…}, dark: {…}}}) and register it in app.config.json.',
|
|
189
|
+
inputSchema: {
|
|
190
|
+
type: 'object', required: ['id', 'theme'],
|
|
191
|
+
properties: { id: { type: 'string' }, theme: { type: 'object' } },
|
|
192
|
+
additionalProperties: false
|
|
193
|
+
},
|
|
194
|
+
run: ({ id, theme }) => setTheme(ROOT, id, theme)
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: 'get_flows',
|
|
198
|
+
description: "Read a feature's flows.json — named walkthroughs the shell can play: { list: [{id, title, icon, description, steps: [{screen, note?, scenario?}]}] }.",
|
|
199
|
+
inputSchema: {
|
|
200
|
+
type: 'object', required: ['feature'],
|
|
201
|
+
properties: { feature: { type: 'string' } },
|
|
202
|
+
additionalProperties: false
|
|
203
|
+
},
|
|
204
|
+
run: ({ feature }) => getFlows(ROOT, feature)
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'set_flows',
|
|
208
|
+
description: "Write a feature's flows.json (full replacement, same shape as get_flows). Steps may pin a scenario for that step.",
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: 'object', required: ['feature', 'flows'],
|
|
211
|
+
properties: { feature: { type: 'string' }, flows: { type: 'object' } },
|
|
212
|
+
additionalProperties: false
|
|
213
|
+
},
|
|
214
|
+
run: ({ feature, flows }) => setFlows(ROOT, feature, flows)
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'get_review_comments',
|
|
218
|
+
description: 'Read Review-Mode comments pinned by humans in the shell. Each carries note, reviewer, screen, scenario, theme, mode, dir, cssPath of the clicked element, created date, resolved flag.',
|
|
219
|
+
inputSchema: {
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: {
|
|
222
|
+
feature: { type: 'string', description: 'filter to one feature' },
|
|
223
|
+
unresolvedOnly: { type: 'boolean', default: false }
|
|
224
|
+
},
|
|
225
|
+
additionalProperties: false
|
|
226
|
+
},
|
|
227
|
+
run: (args = {}) => getComments(ROOT, args)
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'resolve_comment',
|
|
231
|
+
description: 'Mark a review comment resolved (or reopen with resolved:false) after addressing it.',
|
|
232
|
+
inputSchema: {
|
|
233
|
+
type: 'object', required: ['id'],
|
|
234
|
+
properties: { id: { type: 'string' }, resolved: { type: 'boolean', default: true } },
|
|
235
|
+
additionalProperties: false
|
|
236
|
+
},
|
|
237
|
+
run: ({ id, resolved = true }) => resolveComment(ROOT, id, resolved)
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'validate',
|
|
241
|
+
description: 'Validate the project. mode "static" = fast file/contract checks; "full" (default) also boots a headless browser and renders every screen × theme × light/dark × ltr/rtl asserting zero console errors. Run full validation before calling work done.',
|
|
242
|
+
inputSchema: {
|
|
243
|
+
type: 'object',
|
|
244
|
+
properties: { mode: { type: 'string', enum: ['static', 'full'], default: 'full' } },
|
|
245
|
+
additionalProperties: false
|
|
246
|
+
},
|
|
247
|
+
run: async ({ mode = 'full' } = {}) => {
|
|
248
|
+
const s = await staticValidate(ROOT);
|
|
249
|
+
if (mode === 'static' || !s.ok) return { ok: s.ok && mode === 'static', static: s, browser: mode === 'static' ? undefined : 'skipped — fix static errors first' };
|
|
250
|
+
const { runBrowserSelftest } = await import('../core/browser.js');
|
|
251
|
+
const b = await runBrowserSelftest({ root: ROOT, engineDir: ENGINE_DIR });
|
|
252
|
+
return {
|
|
253
|
+
ok: s.ok && b.ok,
|
|
254
|
+
static: s,
|
|
255
|
+
browser: {
|
|
256
|
+
ok: b.ok, total: b.total, passed: b.passed, failed: b.failed,
|
|
257
|
+
failures: b.rows.filter((r) => !r.ok).map((r) => ({ screen: `${r.feature}/${r.screen}`, combo: `${r.theme}·${r.mode}·${r.dir}`, fail: r.fail })),
|
|
258
|
+
shellErrors: b.shellErrors
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: 'screenshot',
|
|
265
|
+
description: 'Render a screen inside its device frame to a PNG (headless Chrome). Returns the file path; set return_image:true to also get the image inline.',
|
|
266
|
+
inputSchema: {
|
|
267
|
+
type: 'object', required: ['feature', 'screen'],
|
|
268
|
+
properties: {
|
|
269
|
+
feature: { type: 'string' },
|
|
270
|
+
screen: { type: 'string' },
|
|
271
|
+
mode: { type: 'string', enum: ['light', 'dark'], default: 'light' },
|
|
272
|
+
dir: { type: 'string', enum: ['ltr', 'rtl'], default: 'ltr' },
|
|
273
|
+
lang: { type: 'string' },
|
|
274
|
+
scenario: { type: 'string' },
|
|
275
|
+
theme: { type: 'string' },
|
|
276
|
+
return_image: { type: 'boolean', default: false }
|
|
277
|
+
},
|
|
278
|
+
additionalProperties: false
|
|
279
|
+
},
|
|
280
|
+
run: async (args, extra) => {
|
|
281
|
+
await getScreen(ROOT, args.feature, args.screen);
|
|
282
|
+
const { screenshot } = await import('../core/browser.js');
|
|
283
|
+
const outDir = path.join(ROOT, 'review', 'screenshots');
|
|
284
|
+
const files = await screenshot({
|
|
285
|
+
root: ROOT, engineDir: ENGINE_DIR, outDir,
|
|
286
|
+
combos: [{ feature: args.feature, screen: args.screen, mode: args.mode, dir: args.dir, lang: args.lang, scenario: args.scenario, theme: args.theme }]
|
|
287
|
+
});
|
|
288
|
+
if (args.return_image) extra.imagePath = files[0];
|
|
289
|
+
return { files };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
];
|
|
293
|
+
|
|
294
|
+
/* ---------------- JSON-RPC over stdio ---------------- */
|
|
295
|
+
|
|
296
|
+
function reply(id, result) {
|
|
297
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, result }) + '\n');
|
|
298
|
+
}
|
|
299
|
+
function replyError(id, code, message) {
|
|
300
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function handle(msg) {
|
|
304
|
+
const { id, method, params } = msg;
|
|
305
|
+
const isRequest = id !== undefined && id !== null;
|
|
306
|
+
|
|
307
|
+
switch (method) {
|
|
308
|
+
case 'initialize':
|
|
309
|
+
reply(id, {
|
|
310
|
+
protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : '2024-11-05',
|
|
311
|
+
capabilities: { tools: { listChanged: false } },
|
|
312
|
+
serverInfo: { name: 'screenbook', version: VERSION },
|
|
313
|
+
instructions:
|
|
314
|
+
`ScreenBook prototype studio for the project at ${ROOT}. ` +
|
|
315
|
+
`Projects are convention-over-configuration: app.config.json, themes/<id>.json (design tokens), ` +
|
|
316
|
+
`features/<id>/{feature.json, flows.json, data/seed.json, data/scenarios.json, screens/*.html}. ` +
|
|
317
|
+
`${SCREEN_CONTRACT} ` +
|
|
318
|
+
`Typical loop: list_features → get_screen (steal an existing screen's shape) → add_screen/update_screen → validate. ` +
|
|
319
|
+
`Never edit manifest.json (derived) or anything under engine/.`
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
case 'notifications/initialized':
|
|
323
|
+
case 'notifications/cancelled':
|
|
324
|
+
return;
|
|
325
|
+
case 'ping':
|
|
326
|
+
if (isRequest) reply(id, {});
|
|
327
|
+
return;
|
|
328
|
+
case 'tools/list':
|
|
329
|
+
reply(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
330
|
+
return;
|
|
331
|
+
case 'tools/call': {
|
|
332
|
+
const tool = TOOLS.find((t) => t.name === params?.name);
|
|
333
|
+
if (!tool) return replyError(id, -32602, `unknown tool: ${params?.name}`);
|
|
334
|
+
try {
|
|
335
|
+
const extra = {};
|
|
336
|
+
const result = await tool.run(params.arguments || {}, extra);
|
|
337
|
+
const content = [{ type: 'text', text: JSON.stringify(result, null, 2) }];
|
|
338
|
+
if (extra.imagePath) {
|
|
339
|
+
const { readFile } = await import('node:fs/promises');
|
|
340
|
+
content.push({ type: 'image', data: (await readFile(extra.imagePath)).toString('base64'), mimeType: 'image/png' });
|
|
341
|
+
}
|
|
342
|
+
reply(id, { content, isError: false });
|
|
343
|
+
} catch (err) {
|
|
344
|
+
reply(id, { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true });
|
|
345
|
+
}
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
default:
|
|
349
|
+
if (isRequest) replyError(id, -32601, `method not found: ${method}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
354
|
+
rl.on('line', (line) => {
|
|
355
|
+
const text = line.trim();
|
|
356
|
+
if (!text) return;
|
|
357
|
+
let msg;
|
|
358
|
+
try { msg = JSON.parse(text); } catch { return replyError(null, -32700, 'parse error'); }
|
|
359
|
+
handle(msg).catch((err) => {
|
|
360
|
+
if (msg.id !== undefined) replyError(msg.id, -32603, `internal error: ${err.message}`);
|
|
361
|
+
else console.error('screenbook-mcp:', err);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
rl.on('close', () => process.exit(0));
|
|
365
|
+
|
|
366
|
+
console.error(`screenbook-mcp ready · root=${ROOT} · ${TOOLS.length} tools`);
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cs4alhaider/screenbook",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "ScreenBook — a static, no-build prototype studio. Drop folders of HTML screens + JSON (data, scenarios, tokens) and get a device-framed gallery with review mode, flows, self-tests, a CLI and an MCP server for AI agents.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"screenbook": "cli/screenbook.js"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Abdullah Alhaider (cs4alhaider)",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"prototyping",
|
|
19
|
+
"design",
|
|
20
|
+
"mockups",
|
|
21
|
+
"mcp",
|
|
22
|
+
"ai-agents",
|
|
23
|
+
"design-tokens",
|
|
24
|
+
"no-build",
|
|
25
|
+
"prototype-studio"
|
|
26
|
+
],
|
|
27
|
+
"files": [
|
|
28
|
+
"bridge.js",
|
|
29
|
+
"app-bridge.js",
|
|
30
|
+
"index.html",
|
|
31
|
+
"shell/",
|
|
32
|
+
"core/",
|
|
33
|
+
"cli/",
|
|
34
|
+
"mcp/",
|
|
35
|
+
"skill/",
|
|
36
|
+
"examples/",
|
|
37
|
+
"docs/",
|
|
38
|
+
"CLAUDE.md",
|
|
39
|
+
"TESTING.md"
|
|
40
|
+
],
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"playwright-core": "^1.45.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node --test",
|
|
46
|
+
"serve": "node cli/screenbook.js serve ..",
|
|
47
|
+
"validate": "node cli/screenbook.js validate .."
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/cs4alhaider/screenbook.git"
|
|
52
|
+
},
|
|
53
|
+
"homepage": "https://github.com/cs4alhaider/screenbook#readme",
|
|
54
|
+
"bugs": {
|
|
55
|
+
"url": "https://github.com/cs4alhaider/screenbook/issues"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* Flow player — plays named walkthroughs from a feature's flows.json (EN-26).
|
|
2
|
+
A step may pin a scenario; leaving the flow restores the previous scenario. */
|
|
3
|
+
|
|
4
|
+
import { store, feature, set } from './store.js';
|
|
5
|
+
|
|
6
|
+
const bar = document.getElementById('flowbar');
|
|
7
|
+
let savedScenario = null;
|
|
8
|
+
|
|
9
|
+
export function activeFlow() {
|
|
10
|
+
const f = feature();
|
|
11
|
+
if (!f || !store.ui.flow) return null;
|
|
12
|
+
return (f.flows.list || []).find((fl) => fl.id === store.ui.flow) || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function startFlow(flowId) {
|
|
16
|
+
const f = feature();
|
|
17
|
+
const flow = (f?.flows.list || []).find((fl) => fl.id === flowId);
|
|
18
|
+
if (!flow || !flow.steps?.length) return;
|
|
19
|
+
savedScenario = store.ui.scenario;
|
|
20
|
+
set({ flow: flowId, flowStep: 0, ...stepPatch(flow, 0) });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stopFlow() {
|
|
24
|
+
if (!store.ui.flow) return;
|
|
25
|
+
const restore = savedScenario;
|
|
26
|
+
savedScenario = null;
|
|
27
|
+
set({ flow: null, flowStep: 0, ...(restore ? { scenario: restore } : {}) });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function stepFlow(delta) {
|
|
31
|
+
const flow = activeFlow();
|
|
32
|
+
if (!flow) return;
|
|
33
|
+
const next = store.ui.flowStep + delta;
|
|
34
|
+
if (next < 0 || next >= flow.steps.length) return;
|
|
35
|
+
set({ flowStep: next, ...stepPatch(flow, next) });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stepPatch(flow, index) {
|
|
39
|
+
const step = flow.steps[index] || {};
|
|
40
|
+
const patch = {};
|
|
41
|
+
if (step.screen) patch.screen = step.screen;
|
|
42
|
+
if (step.scenario) patch.scenario = step.scenario;
|
|
43
|
+
if (step.mode) patch.mode = step.mode;
|
|
44
|
+
return patch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/* Re-sync flow state after a hash-driven boot (deep link into a flow). */
|
|
48
|
+
export function resumeFromHash() {
|
|
49
|
+
const flow = activeFlow();
|
|
50
|
+
if (!flow) return;
|
|
51
|
+
savedScenario = savedScenario ?? store.ui.scenario;
|
|
52
|
+
const i = Math.min(store.ui.flowStep, flow.steps.length - 1);
|
|
53
|
+
set({ flowStep: i, ...stepPatch(flow, i) });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function esc(s) {
|
|
57
|
+
return String(s ?? '').replace(/[&<>"]/g, (c) => (
|
|
58
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"' }[c]
|
|
59
|
+
));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function renderFlowbar() {
|
|
63
|
+
const flow = activeFlow();
|
|
64
|
+
if (!flow) {
|
|
65
|
+
bar.hidden = true;
|
|
66
|
+
bar.innerHTML = '';
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const i = store.ui.flowStep;
|
|
70
|
+
const step = flow.steps[i] || {};
|
|
71
|
+
bar.hidden = false;
|
|
72
|
+
bar.innerHTML =
|
|
73
|
+
`<button class="fb-nav" data-nav="-1" ${i === 0 ? 'disabled' : ''}>←</button>` +
|
|
74
|
+
`<div class="fb-body">` +
|
|
75
|
+
`<div class="fb-title">${esc(flow.icon || '▶')} ${esc(flow.title || flow.id)}` +
|
|
76
|
+
`<span class="fb-count">${i + 1} / ${flow.steps.length}</span></div>` +
|
|
77
|
+
(step.note ? `<div class="fb-note">${esc(step.note)}</div>` : '') +
|
|
78
|
+
`</div>` +
|
|
79
|
+
`<button class="fb-nav" data-nav="1" ${i === flow.steps.length - 1 ? 'disabled' : ''}>→</button>` +
|
|
80
|
+
`<button class="fb-close" title="Exit flow">✕</button>`;
|
|
81
|
+
|
|
82
|
+
bar.querySelectorAll('.fb-nav').forEach((btn) =>
|
|
83
|
+
btn.addEventListener('click', () => stepFlow(parseInt(btn.dataset.nav, 10))));
|
|
84
|
+
bar.querySelector('.fb-close').addEventListener('click', stopFlow);
|
|
85
|
+
}
|