Architecture — how the engine works
One mental model: two worlds meeting at plain files.
- The studio runs in the browser — plain ES modules, zero dependencies, nothing for a consumer project to build.
- The tooling runs in Node — the CLI and the MCP server, sharing one core. Exactly one
runtime dependency (
playwright-core), used only for headless validation and screenshots.
ScreenBook itself is authored in TypeScript (src/) and compiled to dist/ — that is
a development-time step for this repo only. What ships, and what executes everywhere, is
plain JS + HTML; consumer projects still have no build step, no bundler, no framework.
Repo map
src/ the source of truth — strict TypeScript
types/ the contracts as types: every JSON file shape (contracts.ts)
and the sb:*/sba:* postMessage protocols as tagged unions (protocol.ts)
kit/ EngineKit — the pure logic both worlds must agree on, one module each:
deep merge, scenario overlay, {{today±N}} dates, token flattening,
theme resolution, i18n lookup, review-pin CSS paths
bridge/ the screen runtime → bundled to dist/bridge.js (ONE classic script)
app-bridge/ the embedded-SPA runtime → dist/app-bridge.js (same deal)
shell/ the studio, one module per concern (see below) + index.html + shell.css
core/ Node core shared by CLI + MCP: fsx, meta, project API, server, browser
cli/ command dispatcher + init templates + the `tool` renderer
mcp/ rpc.ts (stdio transport) · tools.ts (THE operation registry) · server.ts
version.ts the one version constant
dist/ build output (gitignored) — mirrors the runtime layout; the npm
tarball ships this plus skill/, examples/, docs/
test/ the engine's own suite — TypeScript, run natively by node --test
tools/ build scripts (esbuild bundles, static copy) + dev utilities
Build: npm run build = tsc (Node + shell emit) + esbuild (the two bridge bundles) +
a static copy. npm test builds first — the suite exercises exactly what consumers run.
The bridge (src/bridge/ → dist/bridge.js)
A screen is a standalone HTML document whose only obligation is a meta block and one
script include: the bridge. It is authored as TS modules but ships as one synchronous
classic script — window.Engine exists before any later <script> runs. Never make it
a type="module" include; the screens' inline-script timing depends on it. It boots in
one of two modes:
- Embedded — the screen is inside the studio's iframe. The bridge sends
sb:hello, the shell answerssb:initwith everything the screen needs (theme, mode, dir, lang, merged data, i18n, review pins). Live changes arrive assb:update/sb:review. - Standalone — the screen was opened directly. The bridge self-loads
app.config.json, the theme, seed + scenarios, and i18n from their conventional relative paths, honoring?scenario=…&mode=dark&dir=rtl&lang=…— so a screen file is demoable with zero infrastructure. If ansb:hellogoes unanswered for 600ms (iframed by something that isn't the shell), it falls back to standalone.
The pure logic lives once, in src/kit/ — the bridge bundle inlines it, the shell
loads the bundle (with window.__SCREENBOOK_SHELL__ set) purely to reuse it as
window.EngineKit, and the Node tests import the same modules from dist/kit/. The
browser, the tooling, and the tests exercise identical code.
What screens see: Engine.ready(cb) · Engine.data (frozen) · Engine.env ·
Engine.state (localStorage-backed KV that syncs across screens via storage events) ·
Engine.go / data-go · Engine.t · Engine.toast.
The shell (src/shell/)
A tiny unidirectional loop, no framework:
store.ts → set(patch) → subscribers get the set of changed keys → each module
re-renders only what its keys touch → syncHash() mirrors state to the URL
| Module | Owns |
|---|---|
store.ts | UI state + the shell's shared types (UiState, LoadedFeature…), selectors, hash deep links |
net.ts | project loading (config, features, themes), serve detection, SSE, review persistence |
main.ts | boot, toolbar wiring, keyboard, live reload, the change→render map |
screenhost.ts | the mounted iframe: builds frames, speaks sb:/sba:, pushes env updates |
frames.ts | device chrome (iPhone island/status bar, Android punch-hole, browser bar) + scale-to-fit |
sidebar.ts | searchable, grouped screen list |
map.ts | the feature map: live mini frames, edges from manifest links, BFS layout, drag positions → features/<id>/map.json |
toolbar.ts | dropdown/segmented/toggle/action chip widgets |
flowplayer.ts | walkthrough playback; steps can pin scenarios; restores state on exit |
review.ts | pins, the comments panel, disk/localStorage persistence |
themelab.ts | live token editing; dark stays a delta; save writes themes/<id>.json |
images.ts | 🖼 Image Generator panel: library grid + full-option generate form + provider compare + cost line, over the /__sb/images endpoints |
selftest.ts | the in-browser render matrix (below) |
Two decisions carry most of the weight here. Screens live in iframes — full style and
error isolation, which is what lets the gate say "zero console errors" and mean it. And
state changes are described, not performed — set({mode:'dark'}) doesn't touch the
DOM; every interested module reacts, which is why deep links, flows, and the self-test can
drive the studio through the same door the toolbar uses.
App mode (src/app-bridge/ → dist/app-bridge.js)
An embedded SPA mounts once and is driven over sba:* instead of being remounted per
screen. The adapter contract is a type (AppAdapter in src/types/protocol.ts):
screens(), go(), optional current()/set()/scenarios(). It gets registered in one
of two ways: cooperative — the app includes app-bridge.js itself; driven — the
shell injects the runtime plus a project-owned driver.js into the (same-origin) iframe
after load, so the app's own files stay byte-untouched.
The Node core (src/core/)
| Module | Owns |
|---|---|
fsx.ts | atomic writes (temp + rename), change-detecting JSON writes — every CLI/MCP write goes through here |
meta.ts | the screen-meta block: parse, rewrite, updated stamping, the screen lint, data-go extraction |
project.ts | the one Project API everything imports: root resolution, feature scan, derived manifests, screen CRUD with lint-on-write, data/theme/flow/comment accessors, static validation |
serve.ts | the dev server: project root + /screenbook/* runtime mount (+ /engine/* alias) + /project/* parent mount, no-cache everywhere, SSE live reload, watch → per-feature debounced manifest regen, review/theme write-back + /__sb/images (list/generate/remove — API keys stay server-side) |
browser.ts | headless side: playwright→system-Chrome resolution ladder (auto-download as last resort), the headless self-test wrapper, framed screenshots via the shell's ?solo=1 on a parallel page pool |
images.ts | the Image Generator: nano-banana/chatgpt/grok adapters over plain fetch, provider/model resolution (flag → env → global config → default), cost estimates, and generateToLibrary() — THE orchestration every door calls |
image-library.ts | the flat images/ library: timestamped filenames, manifest.json metadata log (prompt/provider/model/aspect/feature/lineage), list filters, remove-with-cleanup |
user-config.ts | ~/.screenbook/config.json — machine-wide preferences via an allowlist that REFUSES secrets; screenbook config is its CLI |
One core, three doors (the operation registry)
src/mcp/tools.ts is the single table of authoring operations — for each: a name, an
agent-facing description, a JSON schema, and a handler over core/project.ts. Three doors
open onto that one table:
- MCP —
mcp/server.tsserves the registry over stdio JSON-RPC (21 tools). - CLI, operation-for-operation —
screenbook tool <name> [dir] --param value…renders the same registry mechanically: baretoollists the catalog,--helpprints the schema, values accept literals,@file, or-(stdin), and the identical handler runs. - CLI, curated commands —
init,serve,manifest,validate,screenshot,app addremain hand-shaped for their richer terminal UX, calling the same core.
Adding a registry entry surfaces it in the MCP and the CLI automatically — there is no second implementation to keep in sync, by construction.
The gate (selftest.ts + validate)
?selftest=1 renders every screen × device variant × theme × light/dark × LTR/RTL in
hidden iframes (a small worker pool), replaying the exact shell handshake per combo, and
requires Engine.ready + a meta title + zero console errors. Embedded apps must register
within 20s, report ≥1 screen, and survive a visit to every screen they report.
screenbook validate = static checks from project.ts first, then this same matrix run
headlessly, with results read off window.__selftestResult. Green is the definition of
done — for humans and agents alike.
The protocols (src/types/protocol.ts — typed, flat on the wire)
| Message | Direction | Meaning |
|---|---|---|
sb:hello | screen → shell | bridge booted; carries the parsed meta |
sb:init | shell → screen | full environment: theme, mode, dir, lang, device, scenario, data, i18n, review |
sb:update | shell → screen | live env change (theme/mode/dir/lang) — no reload |
sb:review | shell → screen | review state + pins to render |
sb:ready | screen → shell | Engine.ready callbacks ran; hide the veil |
sb:go | screen → shell | data-go/Engine.go navigation request |
sb:console-error | screen → shell | forwarded error (also feeds the self-test) |
sb:pin-request / sb:pin-open | screen → shell | review-mode clicks |
sba:hello / sba:screens / sba:current | app → shell | registration, screen list, navigation echo |
sba:go / sba:env | shell → app | drive navigation / apply environment live |
Rules that keep it simple
- No build in consumer projects, ever. Screens are hand-editable HTML; the studio
they open runs from plain files. ScreenBook's own
src/ → dist/compile is a development-time concern of this repo, invisible to users. - The viewer has zero dependencies; the tooling has one. Dev-dependencies
(
typescript,esbuild,@types/node) never ship. Anything heavier belongs in a consumer project, not here. - Screens are standalone documents. Every screen must work opened directly — the bridge's standalone mode is a contract, not a fallback.
- Derived files are never hand-edited (
manifest.json), and every write is atomic (fsx.ts) — agents and humans share the same safety rails. - One core, three doors. New authoring capability goes into
core/project.tsfirst, then ONE entry in the operation registry (mcp/tools.ts) — the MCP tool and the CLItoolcommand come for free. - The bridges ship as single classic scripts. Authored as modules, bundled by
esbuild, synchronous by contract — and no module in those bundles may touch
document/locationat import time (the VM tests and the shell load them on a barewindow).
Adding things
- An authoring operation → implement against
core/project.ts, add one entry to the registry inmcp/tools.ts(schema + description are the agent's documentation — write them like you'd brief a new hire). It appears in the MCP and inscreenbook toolautomatically; document it in the CLI reference. - A CLI command with bespoke UX → implement against
core/project.ts, register it in thecommandsmap incli/screenbook.ts, document it in the CLI reference. - A studio control → a widget in
main.ts#buildControls(), state instore.ts, and a reaction inonChange; if it must survive reload, add its key to the hash sync. - Anything → update the testing map and keep
npm test+validategreen on the examples.