diff --git a/.github/workflows/docker-publish-multi.yml b/.github/workflows/docker-publish-multi.yml
index 82d0cd1cb..834a72df4 100644
--- a/.github/workflows/docker-publish-multi.yml
+++ b/.github/workflows/docker-publish-multi.yml
@@ -54,7 +54,9 @@ jobs:
VERSION=$(python3 -c "import json; print(json.load(open('versions.json'))['docker'].split('+', 1)[0])")
git config user.name "frameos-bot"
git config user.email "git@frameos.net"
- git add versions.json
+ # update_versions.py syncs the wasm package to the FrameOS runtime
+ # version and the editor package to its independent frontend hash.
+ git add versions.json frameos/wasm/package.json frameos/editor/package.json
git commit -m "chore: version ${VERSION}"
git push origin HEAD:main
@@ -66,6 +68,79 @@ jobs:
echo "docker_version=$VERSION" >> "$GITHUB_OUTPUT"
echo "release_tag=v$VERSION" >> "$GITHUB_OUTPUT"
+ publish-npm:
+ name: Publish FrameOS npm packages
+ needs: update-versions
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout release commit
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ needs.update-versions.outputs.release_sha }}
+
+ - uses: pnpm/action-setup@v4
+ with:
+ version: 10.27.0
+ run_install: false
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'pnpm'
+ cache-dependency-path: pnpm-lock.yaml
+ registry-url: 'https://registry.npmjs.org'
+
+ - uses: jiro4989/setup-nim-action@v1
+ with:
+ nim-version: 2.2.4
+ repo-token: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: mymindstorm/setup-emsdk@v14
+ with:
+ version: 6.0.2
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ # The runtime assets (frameos.js/frameos.wasm) are build outputs, not
+ # committed — compile them like the Dockerfile does.
+ - name: Build the wasm runtime
+ run: |
+ cd frameos
+ nimble install -d -y
+ ./tools/build_wasm.sh
+
+ - name: Build frameos-wasm
+ run: pnpm --dir frameos/wasm run build
+
+ # The embedded editor bundle needs the full frontend build (its build
+ # config lives in frontend/build.mjs).
+ - name: Build the frontend and frameos-editor
+ run: |
+ pnpm --dir frontend run build
+ pnpm --dir frameos/editor run build
+
+ - name: Publish frameos-wasm and frameos-editor
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+ run: |
+ if [ -z "$NODE_AUTH_TOKEN" ]; then
+ echo "NPM_TOKEN secret is not set — skipping npm publish."
+ exit 0
+ fi
+ for package in frameos/wasm frameos/editor; do
+ name=$(node -p "require('./${package}/package.json').name")
+ version=$(node -p "require('./${package}/package.json').version")
+ # Each package version follows its own content-owning component;
+ # skip only when that exact component version already exists.
+ if npm view "${name}@${version}" version >/dev/null 2>&1; then
+ echo "${name}@${version} already on npm — skipping."
+ continue
+ fi
+ (cd "$package" && npm publish --access public)
+ done
+
release-notes:
name: Generate Release Notes
needs: update-versions
diff --git a/.gitignore b/.gitignore
index 95ed1e2d1..5f7e4634d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -100,3 +100,5 @@ release-assets/
# wasm live-preview bundle (built by frameos/tools/build_wasm.sh)
frontend/public/frameos-wasm/
frameos/build/wasm/
+dist-editor
+frontend/editor-smoke.png
diff --git a/AGENTS.md b/AGENTS.md
index 268a6aee0..5525d37b2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -32,7 +32,9 @@
- Build pipeline orchestrated by `build.mjs` using esbuild, with Tailwind/PostCSS for styling and optional bundle analysis via `vite-bundle-visualizer`.
- Development: `pnpm install` followed by `pnpm --dir frontend run dev` (spawns kea typegen watch and esbuild dev build concurrently).
- Repo-level local development runner: `pnpm dev` starts `mprocs` with panes for backend API, ARQ worker, the main frontend dev server, and the frame-local frontend watcher. `redis`, `frameos`, and `backend-docker` panes are available but do not autostart. The `backend-docker` pane runs `scripts/backend-docker.sh`, which persists a generated Docker `SECRET_KEY` in the gitignored `.env.docker.local`. `mprocs.yaml` defines the process list.
-- Production build: `pnpm --dir frontend run build` which chains kea codegen, schema generation (`ts-json-schema-generator`), TypeScript type-checking, and final bundling to `dist/`. 【F:frontend/package.json†L6-L66】- Output folder is consumed by the backend’s static file mounts; ensure `frontend/dist` exists (e.g., via `pnpm --dir frontend run build`) before running the Python app outside of test mode. 【F:backend/app/fastapi.py†L38-L86】
+- Production build: `pnpm --dir frontend run build` which chains kea codegen, schema generation (`ts-json-schema-generator`), TypeScript type-checking, and final bundling to `dist/`. 【F:frontend/package.json†L6-L66】
+- The published `frameos-editor` package has its own `editor` component hash/version in `versions.json`; `project-folders.json` includes shared frontend sources so frontend-only editor changes receive a new npm version during release.
+- Output folder is consumed by the backend’s static file mounts; ensure `frontend/dist` exists (e.g., via `pnpm --dir frontend run build`) before running the Python app outside of test mode. 【F:backend/app/fastapi.py†L38-L86】
- ALWAYS prefer writing frontend business logic in kea logic files over using effects like `useState` or `useEffect`.
- This includes small functions and callbacks inside components. Prefer to keep as much code as possible in logic files, treating React as a templating layer.
- When adding a frame model key that is tracked for deploy changes in `frontend/src/scenes/frame/frameLogic.ts`, also add a marker to `FRAME_KEY_INTRODUCED_FRAMEOS_VERSION`. For unreleased work, use the next patch after the current `versions.json` FrameOS base version.
diff --git a/Dockerfile b/Dockerfile
index 964bf02f2..52258873c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -142,6 +142,8 @@ WORKDIR /app
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml versions.json ./
COPY frontend/package.json frontend/package.json
COPY frameos/frontend/package.json frameos/frontend/package.json
+COPY frameos/wasm/package.json frameos/wasm/package.json
+COPY frameos/editor/package.json frameos/editor/package.json
RUN pnpm install --frozen-lockfile
COPY frameos/frameos.nimble frameos/nimble.lock frameos/nim.cfg frameos/config.nims frameos/
diff --git a/frameos/editor/README.md b/frameos/editor/README.md
new file mode 100644
index 000000000..280f4fce6
--- /dev/null
+++ b/frameos/editor/README.md
@@ -0,0 +1,70 @@
+# frameos-editor
+
+The [FrameOS](https://frameos.net) visual scene editor — the same node-graph editor the FrameOS
+backend ships — as an embeddable static bundle. No backend needed: the app catalog and app sources
+are embedded at build time, scenes go in and come back out as JSON over `postMessage`. The editor
+carries the full scene workspace: the node diagram plus side panels for scene settings, state
+variables, the app catalog, events and raw scene JSON; app sources open in a Monaco editor modal.
+When the [`frameos-wasm`](https://www.npmjs.com/package/frameos-wasm) package's assets are served
+next to the bundle (at `./frameos-wasm/` relative to `dist/`), the Preview panel runs the edited
+scenes live through the FrameOS WebAssembly runtime — canvas, scene state and runtime logs included.
+
+The package version always equals the FrameOS release it was built from.
+
+**License: AGPL-3.0-only.** The editor is FrameOS code. The intended embedding model is an iframe
+served from your own host, talking to your page over the documented `postMessage` protocol — the
+editor stays a separate program at arm's length, whatever the license of the embedding page. If you
+modify the bundle itself, AGPL terms apply to those modifications.
+
+## Usage
+
+Serve this package's `dist/` directory from your host (e.g. copy it to `/frameos-editor/`), then:
+
+```js
+import { createFrameOSEditor } from 'frameos-editor'
+
+const editor = createFrameOSEditor({
+ container: document.getElementById('editor'),
+ url: '/frameos-editor/index.html',
+ scenes, // parsed scenes.json
+ width: 800,
+ height: 480,
+ onScenesChanged: (scenes) => console.log('edited', scenes),
+})
+
+const edited = await editor.getScenes()
+editor.destroy()
+```
+
+## postMessage protocol
+
+Parent → editor:
+
+- `{type: 'frameos-editor:init', scenes, sceneId?, mode?, width?, height?, interval?, theme?, previewProxyUrl?, description?}` —
+ `theme` is `'light' | 'dark'`; `previewProxyUrl` is an optional same-origin endpoint the in-editor wasm live
+ preview routes CORS-blocked HTTP requests through; `description` is the embedding page's description of the
+ scene, shown in the Scene settings panel
+- `{type: 'frameos-editor:get-scenes'}` — replies with a `:scenes` message
+- `{type: 'frameos-editor:select-scene', sceneId}`
+
+Editor → parent:
+
+- `{type: 'frameos-editor:ready'}` — once listening (the helper auto-sends `init` on this)
+- `{type: 'frameos-editor:scenes', scenes}` — after every edit (debounced) and as the
+ `:get-scenes` reply
+- `{type: 'frameos-editor:save-screenshot', dataUrl, sceneId}` — the Preview panel captured a frame;
+ reply with `{type: 'frameos-editor:screenshot-saved', ok, error?, fallbackDownload?}` to store it
+ yourself, or stay silent and the editor downloads the PNG locally after a short timeout
+
+## Demo
+
+`demo.html` shows the scene list, the editor, and (when the [`frameos-wasm`](https://www.npmjs.com/package/frameos-wasm)
+package's assets are served next to it at `./frameos-wasm/`) a live WebAssembly preview of the
+edited scenes — everything running in the browser.
+
+## Development (FrameOS repo)
+
+The bundle is built by `frontend/build.mjs` ("FrameOS Embedded Editor" config: the regular editor
+code with `frameLogic`/`logsLogic` swapped for in-memory shims, see `frontend/src/embed/`) into
+`frontend/dist-editor/`, and copied into this package's `dist/` by `npm run build`. Smoke test:
+`node frontend/scripts/smokeEditorEmbed.mjs`.
diff --git a/frameos/editor/demo.html b/frameos/editor/demo.html
new file mode 100644
index 000000000..ed423af35
--- /dev/null
+++ b/frameos/editor/demo.html
@@ -0,0 +1,118 @@
+
+
+
+
+
+ FrameOS scene editor — demo
+
+
+
+
+
+
+
Scenes
+
+
Live preview
+
+
+ Serve the frameos-wasm package assets next to this page (at ./frameos-wasm/)
+ to run the edited scenes live in WebAssembly.
+
+
+
+ Everything here runs in your browser: the editor (this package, AGPL) edits the scenes JSON,
+ and the wasm runtime renders it — the same code a FrameOS frame runs.
+
+
+
+
+
+
diff --git a/frameos/editor/embed.d.ts b/frameos/editor/embed.d.ts
new file mode 100644
index 000000000..882325d8e
--- /dev/null
+++ b/frameos/editor/embed.d.ts
@@ -0,0 +1,43 @@
+export interface FrameOSEditorOptions {
+ /** Element the editor iframe is appended to. */
+ container: HTMLElement
+ /** URL of the editor bundle's index.html (serve this package's dist/). */
+ url: string
+ /** The scenes to edit — the parsed contents of a scenes.json. */
+ scenes: Record[]
+ /** Scene to open initially; defaults to the default scene. */
+ sceneId?: string
+ /** Frame mode; decides code-node language (default 'rpios'). */
+ mode?: string
+ width?: number
+ height?: number
+ interval?: number
+ /** Editor color theme; defaults to the browser's preferred color scheme. */
+ theme?: 'light' | 'dark'
+ /**
+ * Same-origin endpoint the in-editor wasm live preview routes CORS-blocked
+ * HTTP requests through (appended as `?url=`-style proxy by the runtime).
+ * Without it the preview still runs, but scenes fetching external data may
+ * render incompletely.
+ */
+ previewProxyUrl?: string
+ /**
+ * The embedding page's description of the scene (scenes.json doesn't carry
+ * one); shown in the editor's Scene settings panel.
+ */
+ description?: string
+ /** Fires (debounced) after every edit with the full scenes array. */
+ onScenesChanged?: (scenes: Record[]) => void
+ onReady?: () => void
+}
+
+export interface FrameOSEditorHandle {
+ iframe: HTMLIFrameElement
+ getScenesSync: () => Record[]
+ getScenes: () => Promise[]>
+ setScenes: (scenes: Record[], sceneId?: string) => void
+ selectScene: (sceneId: string) => void
+ destroy: () => void
+}
+
+export function createFrameOSEditor(options: FrameOSEditorOptions): FrameOSEditorHandle
diff --git a/frameos/editor/embed.js b/frameos/editor/embed.js
new file mode 100644
index 000000000..f70fc1c6a
--- /dev/null
+++ b/frameos/editor/embed.js
@@ -0,0 +1,118 @@
+// Parent-side helper for embedding the FrameOS scene editor. Creates an
+// iframe pointing at the editor bundle (this package's dist/index.html,
+// served from your own host) and speaks its postMessage protocol:
+// in: {type: 'frameos-editor:init', scenes, sceneId?, mode?, width?, height?, interval?, theme?, previewProxyUrl?, description?}
+// {type: 'frameos-editor:get-scenes'} · {type: 'frameos-editor:select-scene', sceneId}
+// out: {type: 'frameos-editor:ready'} · {type: 'frameos-editor:scenes', scenes}
+// {type: 'frameos-editor:save-screenshot', dataUrl, sceneId} (ack with 'frameos-editor:screenshot-saved')
+//
+// The editor bundle is AGPL-licensed FrameOS code running at arm's length in
+// its own frame; this helper is part of the same package.
+
+export function createFrameOSEditor({
+ container,
+ url,
+ scenes,
+ sceneId,
+ mode = 'rpios',
+ width = 800,
+ height = 480,
+ interval = 300,
+ theme,
+ previewProxyUrl,
+ description,
+ onScenesChanged,
+ onReady,
+}) {
+ const iframe = document.createElement('iframe')
+ iframe.src = url
+ iframe.style.width = '100%'
+ iframe.style.height = '100%'
+ iframe.style.border = '0'
+ container.appendChild(iframe)
+
+ const editorOrigin = new URL(url, location.href).origin
+ let latestScenes = scenes
+ let latestSceneId = sceneId
+ let ready = false
+ const sceneWaiters = []
+
+ const onMessage = (event) => {
+ if (event.source !== iframe.contentWindow || event.origin !== editorOrigin) {
+ return
+ }
+ const message = event.data
+ if (!message || typeof message !== 'object') {
+ return
+ }
+ if (message.type === 'frameos-editor:ready') {
+ ready = true
+ post({
+ type: 'frameos-editor:init',
+ scenes: latestScenes,
+ sceneId: latestSceneId,
+ mode,
+ width,
+ height,
+ interval,
+ theme,
+ previewProxyUrl,
+ description,
+ })
+ onReady?.()
+ } else if (message.type === 'frameos-editor:scenes' && Array.isArray(message.scenes)) {
+ latestScenes = message.scenes
+ while (sceneWaiters.length > 0) {
+ sceneWaiters.shift()(message.scenes)
+ }
+ onScenesChanged?.(message.scenes)
+ }
+ }
+
+ function post(message) {
+ iframe.contentWindow?.postMessage(message, editorOrigin)
+ }
+
+ window.addEventListener('message', onMessage)
+
+ return {
+ iframe,
+ /** Latest scenes as reported by the editor (kept current on every edit). */
+ getScenesSync: () => latestScenes,
+ /** Ask the editor for its current scenes. */
+ getScenes: () =>
+ new Promise((resolve) => {
+ if (!ready) {
+ resolve(latestScenes)
+ return
+ }
+ sceneWaiters.push(resolve)
+ post({ type: 'frameos-editor:get-scenes' })
+ }),
+ /** Replace the loaded scenes (re-initializes the editor). */
+ setScenes: (nextScenes, nextSceneId) => {
+ latestScenes = nextScenes
+ latestSceneId = nextSceneId
+ post({
+ type: 'frameos-editor:init',
+ scenes: nextScenes,
+ sceneId: nextSceneId,
+ mode,
+ width,
+ height,
+ interval,
+ theme,
+ previewProxyUrl,
+ description,
+ })
+ },
+ selectScene: (nextSceneId) => {
+ latestSceneId = nextSceneId
+ post({ type: 'frameos-editor:select-scene', sceneId: nextSceneId })
+ },
+ destroy: () => {
+ window.removeEventListener('message', onMessage)
+ iframe.remove()
+ },
+ }
+}
diff --git a/frameos/editor/embed.test.mjs b/frameos/editor/embed.test.mjs
new file mode 100644
index 000000000..b4b6a59a0
--- /dev/null
+++ b/frameos/editor/embed.test.mjs
@@ -0,0 +1,51 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { createFrameOSEditor } from './embed.js'
+
+test('setScenes keeps its selected scene until the iframe is ready', () => {
+ const messages = []
+ const listeners = new Map()
+ const contentWindow = {
+ postMessage: (message, origin) => messages.push({ message, origin }),
+ }
+ const iframe = {
+ contentWindow,
+ remove: () => {},
+ style: {},
+ }
+ globalThis.document = { createElement: () => iframe }
+ globalThis.location = new URL('https://host.example/page')
+ globalThis.window = {
+ addEventListener: (type, listener) => listeners.set(type, listener),
+ removeEventListener: (type) => listeners.delete(type),
+ }
+
+ const editor = createFrameOSEditor({
+ container: { appendChild: () => {} },
+ url: 'https://host.example/editor/',
+ scenes: [{ id: 'old' }],
+ sceneId: 'old',
+ })
+ editor.setScenes([{ id: 'replacement' }], 'replacement')
+
+ listeners.get('message')({
+ source: contentWindow,
+ origin: 'https://host.example',
+ data: { type: 'frameos-editor:ready' },
+ })
+
+ assert.deepEqual(messages.at(-1).message, {
+ type: 'frameos-editor:init',
+ scenes: [{ id: 'replacement' }],
+ sceneId: 'replacement',
+ mode: 'rpios',
+ width: 800,
+ height: 480,
+ interval: 300,
+ theme: undefined,
+ previewProxyUrl: undefined,
+ description: undefined,
+ })
+ editor.destroy()
+})
diff --git a/frameos/editor/package.json b/frameos/editor/package.json
new file mode 100644
index 000000000..2eb6fa5df
--- /dev/null
+++ b/frameos/editor/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "frameos-editor",
+ "version": "2026.7.3",
+ "description": "The FrameOS visual scene editor as an embeddable static bundle: serve it from any host, embed it in an iframe, and exchange scenes JSON over postMessage.",
+ "keywords": ["frameos", "editor", "scenes", "node-editor", "e-ink", "smart-frame"],
+ "homepage": "https://frameos.net",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/FrameOS/frameos.git",
+ "directory": "frameos/editor"
+ },
+ "license": "AGPL-3.0-only",
+ "type": "module",
+ "main": "./embed.js",
+ "types": "./embed.d.ts",
+ "exports": {
+ ".": {
+ "types": "./embed.d.ts",
+ "import": "./embed.js"
+ },
+ "./dist/*": "./dist/*",
+ "./package.json": "./package.json"
+ },
+ "files": ["dist", "!dist/**/*.map", "embed.js", "embed.d.ts", "demo.html", "README.md"],
+ "scripts": {
+ "build": "node scripts/build.mjs",
+ "test": "node --test embed.test.mjs",
+ "sync-version": "node scripts/sync-version.mjs",
+ "prepublishOnly": "node scripts/sync-version.mjs --check && npm run build"
+ }
+}
diff --git a/frameos/editor/scripts/build.mjs b/frameos/editor/scripts/build.mjs
new file mode 100644
index 000000000..1b5b039f0
--- /dev/null
+++ b/frameos/editor/scripts/build.mjs
@@ -0,0 +1,19 @@
+// Package the embedded editor bundle: frontend/dist-editor (built by the
+// frontend's build.mjs "FrameOS Embedded Editor" config) is copied into
+// dist/. Run the frontend build first; the release workflow does.
+import { cpSync, existsSync, rmSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const packageDir = dirname(dirname(fileURLToPath(import.meta.url)))
+const bundleSource = join(packageDir, '..', '..', 'frontend', 'dist-editor')
+const bundleTarget = join(packageDir, 'dist')
+
+if (!existsSync(join(bundleSource, 'index.html'))) {
+ console.error(`Missing ${bundleSource}/index.html — build the frontend first: pnpm --dir frontend run build`)
+ process.exit(1)
+}
+
+rmSync(bundleTarget, { force: true, recursive: true })
+cpSync(bundleSource, bundleTarget, { recursive: true })
+console.log(`Copied ${bundleSource} to dist/`)
diff --git a/frameos/editor/scripts/sync-version.mjs b/frameos/editor/scripts/sync-version.mjs
new file mode 100644
index 000000000..a42f74553
--- /dev/null
+++ b/frameos/editor/scripts/sync-version.mjs
@@ -0,0 +1,33 @@
+// Keep this package's version identical to the independent editor component
+// version (without its content hash).
+import { readFileSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const packageDir = dirname(dirname(fileURLToPath(import.meta.url)))
+const versionsPath = join(packageDir, '..', '..', 'versions.json')
+const packageJsonPath = join(packageDir, 'package.json')
+
+const versions = JSON.parse(readFileSync(versionsPath, 'utf8'))
+const editorVersion = String(versions.editor ?? '').split('+')[0]
+if (!/^\d{4}\.\d{1,2}\.\d+$/.test(editorVersion)) {
+ console.error(`Unexpected editor version in versions.json: ${versions.editor}`)
+ process.exit(1)
+}
+
+const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
+if (packageJson.version === editorVersion) {
+ console.log(`frameos-editor version already ${editorVersion}`)
+ process.exit(0)
+}
+
+if (process.argv.includes('--check')) {
+ console.error(
+ `frameos-editor version ${packageJson.version} != editor version ${editorVersion}. Run: npm run sync-version`
+ )
+ process.exit(1)
+}
+
+packageJson.version = editorVersion
+writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n')
+console.log(`frameos-editor version set to ${editorVersion}`)
diff --git a/frameos/tools/wasm/frameos_library.js b/frameos/tools/wasm/frameos_library.js
index 8f7ba77af..4ad52163f 100644
--- a/frameos/tools/wasm/frameos_library.js
+++ b/frameos/tools/wasm/frameos_library.js
@@ -43,10 +43,10 @@ addToLibrary({
{{{ makeSetValue('outLenPtr', 0, 0, 'i32') }}}
var method = UTF8ToString(methodPtr)
var url = UTF8ToString(urlPtr)
- // When a same-origin backend proxy is configured, route the request
- // through it: the browser can't fetch most external URLs directly (CORS),
- // but the backend can — mirroring how the device fetches server-side. The
- // proxy fetch is same-origin, so auth cookies flow and CORS doesn't apply.
+ // When a same-origin backend proxy is configured, it is the FALLBACK:
+ // requests go straight from the browser first (client-side, no server
+ // load), and only CORS/network failures are retried through the proxy,
+ // which fetches server-side like the device would.
var proxyUrl = Module['frameosProxyUrl']
try {
var body = null
@@ -66,10 +66,30 @@ addToLibrary({
}
}
- var xhr = new XMLHttpRequest()
- // Synchronous: the Nim/pixie pipeline is fully synchronous and this
+ // Synchronous XHR: the Nim/pixie pipeline is fully synchronous and this
// module runs in a Web Worker, where sync XHR is permitted.
- if (proxyUrl) {
+ var directRequest = function () {
+ var xhr = new XMLHttpRequest()
+ xhr.open(method, url, false)
+ try {
+ xhr.responseType = 'arraybuffer'
+ if (timeoutMs > 0) xhr.timeout = timeoutMs
+ } catch (e) {
+ // Main-thread fallback: sync XHR only supports text responses there.
+ }
+ for (var name in headers) {
+ try {
+ xhr.setRequestHeader(name, headers[name])
+ } catch (e) {
+ // Forbidden header names (User-Agent & co) throw; skip them.
+ }
+ }
+ xhr.send(body)
+ return xhr
+ }
+
+ var proxyRequest = function () {
+ var xhr = new XMLHttpRequest()
xhr.open('POST', proxyUrl, false)
xhr.withCredentials = true
try {
@@ -86,22 +106,21 @@ addToLibrary({
xhr.send(
JSON.stringify({ method: method, url: url, headers: headers, bodyBase64: bodyBase64, timeoutMs: timeoutMs })
)
- } else {
- xhr.open(method, url, false)
- try {
- xhr.responseType = 'arraybuffer'
- if (timeoutMs > 0) xhr.timeout = timeoutMs
- } catch (e) {
- // Main-thread fallback: sync XHR only supports text responses there.
- }
- for (var name in headers) {
- try {
- xhr.setRequestHeader(name, headers[name])
- } catch (e) {
- // Forbidden header names (User-Agent & co) throw; skip them.
- }
- }
- xhr.send(body)
+ return xhr
+ }
+
+ var xhr = null
+ var directError = null
+ try {
+ xhr = directRequest()
+ } catch (e) {
+ // A cross-origin host without CORS headers throws on sync send.
+ directError = e
+ }
+ if ((xhr === null || xhr.status === 0) && proxyUrl) {
+ xhr = proxyRequest()
+ } else if (xhr === null) {
+ throw directError
}
var bytes
diff --git a/frameos/wasm/README.md b/frameos/wasm/README.md
new file mode 100644
index 000000000..0dfa95a4a
--- /dev/null
+++ b/frameos/wasm/README.md
@@ -0,0 +1,72 @@
+# frameos-wasm
+
+Run [FrameOS](https://frameos.net) scenes in the browser through WebAssembly. The package wraps the
+emscripten-built FrameOS scene runtime with a typed API and ships a minimal management interface:
+a live canvas, scene switching, showIf-aware state fields, event buttons, and logs — the same
+control surface a frame exposes on-device.
+
+The package version always equals the FrameOS release the runtime was built from.
+
+## Install
+
+```sh
+npm install frameos-wasm
+```
+
+The runtime assets (`frameos.js`, `frameos.wasm`, `preview-worker.js`) live in
+`frameos-wasm/dist/assets/`. They must be served **same-origin** (the runtime runs in a module Web
+Worker and uses synchronous XHR): copy that directory into your static assets, e.g. to
+`/frameos-wasm/`.
+
+## Quick start — management interface
+
+```ts
+import { mountFrameOSManager } from 'frameos-wasm'
+
+const handle = mountFrameOSManager(document.getElementById('preview')!, {
+ workerUrl: '/frameos-wasm/preview-worker.js',
+ width: 800,
+ height: 480,
+ scenes, // parsed scenes.json (from a template zip, backup, or export)
+})
+// later: handle.preview.sendEvent('myButton'), handle.destroy()
+```
+
+## Lower level — just the runtime
+
+```ts
+import { createFrameOSPreview } from 'frameos-wasm'
+
+const preview = createFrameOSPreview({
+ workerUrl: '/frameos-wasm/preview-worker.js',
+ width: 800,
+ height: 480,
+ scenes,
+ canvas: document.querySelector('canvas'),
+ onLog: (line) => console.log(line),
+ onState: (state) => console.log('scene state', state),
+})
+preview.setSceneState({ message: 'Hello' })
+preview.sendEvent('button', { label: 'a' })
+preview.selectScene('sceneId')
+preview.destroy()
+```
+
+Helpers for building your own UI are exported too: `evaluateShowIf`, `visiblePublicStateFields`,
+`coerceStateFieldValue`, `sceneEventButtons`, and the `StateField`/`FrameOSScene` types.
+
+## Notes
+
+- Scenes that fetch external URLs are subject to browser CORS unless you pass `proxyUrl` (a
+ same-origin endpoint that forwards `{method, url, headers, bodyBase64, timeoutMs}` — see
+ FrameOS's `/api/frames/{id}/scene_preview_proxy`).
+- Apps that need host processes are not in the wasm build: `data/chromiumScreenshot`,
+ `data/rstpSnapshot`, `data/localImage`.
+- `index.html` in the package root is a standalone demo page:
+ `npx serve node_modules/frameos-wasm` and paste a scenes.json.
+
+## Development (FrameOS repo)
+
+The runtime assets are built by `frameos/tools/build_wasm.sh` into `frontend/public/frameos-wasm`
+and copied into `dist/assets` by `npm run build`. `npm run sync-version` copies the FrameOS
+version out of the repo's `versions.json`; publishing refuses to run when the two disagree.
diff --git a/frameos/wasm/index.html b/frameos/wasm/index.html
new file mode 100644
index 000000000..ed4daa727
--- /dev/null
+++ b/frameos/wasm/index.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+ frameos-wasm — scene preview
+
+
+
+
frameos-wasm scene preview
+
+ Runs a FrameOS scene in your browser through the WebAssembly runtime, with the same state fields, event
+ buttons, and logs the frame itself exposes. Paste the contents of a scenes.json (from a template
+ zip, a backup, or an export) and press start. Serve this file over HTTP with the runtime assets next to it —
+ for example: npx serve node_modules/frameos-wasm and open /index.html.
+
+
+
+
+
+
+ ×
+
+
+
+
+
+
+
+
diff --git a/frameos/wasm/package.json b/frameos/wasm/package.json
new file mode 100644
index 000000000..965eef859
--- /dev/null
+++ b/frameos/wasm/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "frameos-wasm",
+ "version": "2026.7.3",
+ "description": "Run FrameOS scenes in the browser via WebAssembly: a typed live-preview runtime plus a minimal showIf-aware management interface.",
+ "keywords": ["frameos", "wasm", "webassembly", "e-ink", "smart-frame"],
+ "homepage": "https://frameos.net",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/FrameOS/frameos.git",
+ "directory": "frameos/wasm"
+ },
+ "license": "AGPL-3.0-only",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ },
+ "./assets/*": "./dist/assets/*",
+ "./package.json": "./package.json"
+ },
+ "files": ["dist", "index.html", "README.md"],
+ "scripts": {
+ "build": "tsc -p tsconfig.json && node scripts/build.mjs",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "sync-version": "node scripts/sync-version.mjs",
+ "prepublishOnly": "node scripts/sync-version.mjs --check && npm run build"
+ },
+ "devDependencies": {
+ "typescript": "~5.9.3"
+ }
+}
diff --git a/frameos/wasm/scripts/build.mjs b/frameos/wasm/scripts/build.mjs
new file mode 100644
index 000000000..ff4030c60
--- /dev/null
+++ b/frameos/wasm/scripts/build.mjs
@@ -0,0 +1,26 @@
+// Copy the wasm runtime assets into dist/assets. The assets are built by
+// frameos/tools/build_wasm.sh into frontend/public/frameos-wasm (a gitignored
+// build output — run that script first; the release workflow does).
+import { copyFileSync, existsSync, mkdirSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const packageDir = dirname(dirname(fileURLToPath(import.meta.url)))
+const assetsSource = join(packageDir, '..', '..', 'frontend', 'public', 'frameos-wasm')
+const assetsTarget = join(packageDir, 'dist', 'assets')
+
+const files = ['frameos.js', 'frameos.wasm', 'preview-worker.js']
+for (const file of files) {
+ if (!existsSync(join(assetsSource, file))) {
+ console.error(
+ `Missing ${join(assetsSource, file)} — build the wasm runtime first: frameos/tools/build_wasm.sh`
+ )
+ process.exit(1)
+ }
+}
+
+mkdirSync(assetsTarget, { recursive: true })
+for (const file of files) {
+ copyFileSync(join(assetsSource, file), join(assetsTarget, file))
+}
+console.log(`Copied ${files.join(', ')} to dist/assets`)
diff --git a/frameos/wasm/scripts/sync-version.mjs b/frameos/wasm/scripts/sync-version.mjs
new file mode 100644
index 000000000..c24412d25
--- /dev/null
+++ b/frameos/wasm/scripts/sync-version.mjs
@@ -0,0 +1,34 @@
+// Keep this package's version identical to the FrameOS release version (the
+// `frameos` entry of versions.json at the repo root, without the content
+// hash). Run with --check to fail instead of write (used by prepublishOnly).
+import { readFileSync, writeFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const packageDir = dirname(dirname(fileURLToPath(import.meta.url)))
+const versionsPath = join(packageDir, '..', '..', 'versions.json')
+const packageJsonPath = join(packageDir, 'package.json')
+
+const versions = JSON.parse(readFileSync(versionsPath, 'utf8'))
+const frameosVersion = String(versions.frameos ?? '').split('+')[0]
+if (!/^\d{4}\.\d{1,2}\.\d+$/.test(frameosVersion)) {
+ console.error(`Unexpected frameos version in versions.json: ${versions.frameos}`)
+ process.exit(1)
+}
+
+const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
+if (packageJson.version === frameosVersion) {
+ console.log(`frameos-wasm version already ${frameosVersion}`)
+ process.exit(0)
+}
+
+if (process.argv.includes('--check')) {
+ console.error(
+ `frameos-wasm version ${packageJson.version} != FrameOS version ${frameosVersion}. Run: npm run sync-version`
+ )
+ process.exit(1)
+}
+
+packageJson.version = frameosVersion
+writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n')
+console.log(`frameos-wasm version set to ${frameosVersion}`)
diff --git a/frameos/wasm/src/index.ts b/frameos/wasm/src/index.ts
new file mode 100644
index 000000000..4a73e6ac7
--- /dev/null
+++ b/frameos/wasm/src/index.ts
@@ -0,0 +1,27 @@
+// frameos-wasm: run FrameOS scenes in the browser via WebAssembly.
+//
+// The runtime assets (frameos.js, frameos.wasm, preview-worker.js) ship in
+// this package under ./assets — serve that directory same-origin and pass
+// `workerUrl: '/preview-worker.js'`. Version tracks the FrameOS
+// release the runtime was built from (versions.json in the FrameOS repo).
+export { FrameOSPreview, createFrameOSPreview, type FrameOSPreviewOptions } from './preview'
+export { mountFrameOSManager, type FrameOSManagerHandle, type FrameOSManagerOptions } from './manager'
+export {
+ coerceStateFieldValue,
+ evaluateShowIf,
+ stateFieldShowIfValues,
+ visiblePublicStateFields,
+} from './showIf'
+export {
+ LIFECYCLE_EVENTS,
+ sceneEventButtons,
+ type ConfigFieldCondition,
+ type ConfigFieldConditionAnd,
+ type FrameOSScene,
+ type PreviewFrame,
+ type SceneEventButton,
+ type SceneInfo,
+ type SceneNode,
+ type ShowIfCondition,
+ type StateField,
+} from './types'
diff --git a/frameos/wasm/src/manager.ts b/frameos/wasm/src/manager.ts
new file mode 100644
index 000000000..1f019f236
--- /dev/null
+++ b/frameos/wasm/src/manager.ts
@@ -0,0 +1,308 @@
+// A minimal, dependency-free management interface for a FrameOS preview:
+// canvas, scene switcher, showIf-aware state fields, event buttons, and a log
+// pane. Mirrors the device's own /control page (frameos/assets/web/control.html)
+// but runs entirely in the browser against the wasm runtime.
+import { FrameOSPreview, type FrameOSPreviewOptions } from './preview'
+import { coerceStateFieldValue, evaluateShowIf, stateFieldShowIfValues } from './showIf'
+import { sceneEventButtons, type FrameOSScene, type StateField } from './types'
+
+export interface FrameOSManagerOptions extends Omit {
+ /** Show the log pane (default true). */
+ showLogs?: boolean
+ /** Max log lines kept (default 200). */
+ maxLogLines?: number
+}
+
+export interface FrameOSManagerHandle {
+ preview: FrameOSPreview
+ destroy: () => void
+}
+
+/**
+ * Mount a self-contained preview + control surface into `container`.
+ * Returns the underlying FrameOSPreview for programmatic control.
+ */
+export function mountFrameOSManager(container: HTMLElement, options: FrameOSManagerOptions): FrameOSManagerHandle {
+ const showLogs = options.showLogs !== false
+ const maxLogLines = options.maxLogLines ?? 200
+
+ container.classList.add('frameos-manager')
+ container.innerHTML = ''
+ injectStyles()
+
+ const stage = el('div', 'frameos-manager__stage')
+ const canvas = document.createElement('canvas')
+ canvas.className = 'frameos-manager__canvas'
+ canvas.width = options.width
+ canvas.height = options.height
+ stage.appendChild(canvas)
+
+ const status = el('div', 'frameos-manager__status', 'Loading FrameOS runtime…')
+ const controls = el('div', 'frameos-manager__controls')
+ const logPane = el('pre', 'frameos-manager__logs')
+ logPane.style.display = showLogs ? '' : 'none'
+
+ container.appendChild(stage)
+ container.appendChild(status)
+ container.appendChild(controls)
+ container.appendChild(logPane)
+
+ // Values the user typed, overriding the runtime's reported state until the
+ // runtime confirms them via a state message.
+ let editedValues: Record = {}
+ let destroyed = false
+ const logLines: string[] = []
+
+ const appendLog = (line: string): void => {
+ logLines.push(line)
+ if (logLines.length > maxLogLines) {
+ logLines.splice(0, logLines.length - maxLogLines)
+ }
+ logPane.textContent = logLines.join('\n')
+ logPane.scrollTop = logPane.scrollHeight
+ }
+
+ const sceneById = new Map()
+ for (const scene of options.scenes) {
+ sceneById.set(scene.id, scene)
+ }
+
+ const preview = new FrameOSPreview({
+ ...options,
+ canvas,
+ onReady: (sceneInfo) => {
+ status.textContent = ''
+ renderControls()
+ options.onReady?.(sceneInfo)
+ },
+ onFrame: (frame) => {
+ status.textContent = `Rendered ${frame.width}×${frame.height} in ${frame.renderMs} ms`
+ options.onFrame?.(frame)
+ },
+ onState: (state) => {
+ // Drop only the edits the runtime has confirmed; anything the user
+ // typed but has not applied yet must survive a render (or any other
+ // state report) instead of being silently reverted.
+ const unconfirmed: Record = {}
+ for (const [key, value] of Object.entries(editedValues)) {
+ if (String(state?.[key] ?? '') !== String(value ?? '')) {
+ unconfirmed[key] = value
+ }
+ }
+ editedValues = unconfirmed
+ // Rebuilding the form mid-typing would steal focus and discard the
+ // in-progress value; the next interaction re-renders anyway.
+ if (!controls.contains(document.activeElement)) {
+ renderControls()
+ }
+ options.onState?.(state)
+ },
+ onLog: (message) => {
+ appendLog(message)
+ options.onLog?.(message)
+ },
+ onSceneEvent: (name, payload) => {
+ appendLog(`event: ${name} ${JSON.stringify(payload)}`)
+ options.onSceneEvent?.(name, payload)
+ },
+ onError: (message) => {
+ status.textContent = message
+ status.classList.add('frameos-manager__status--error')
+ appendLog(`error: ${message}`)
+ options.onError?.(message)
+ },
+ })
+
+ function currentScene(): FrameOSScene | undefined {
+ return (preview.currentSceneId && sceneById.get(preview.currentSceneId)) || options.scenes[0]
+ }
+
+ function publicFields(scene: FrameOSScene | undefined): StateField[] {
+ return (scene?.fields ?? []).filter((field) => field.access === 'public' && field.name)
+ }
+
+ function fieldValues(scene: FrameOSScene | undefined): Record {
+ return stateFieldShowIfValues(publicFields(scene), preview.state, editedValues)
+ }
+
+ function renderControls(): void {
+ if (destroyed) {
+ return
+ }
+ controls.innerHTML = ''
+ const scene = currentScene()
+
+ // Scene switcher, when more than one scene is loaded.
+ const scenes = preview.sceneInfo?.scenes ?? []
+ if (scenes.length > 1) {
+ const row = el('div', 'frameos-manager__row')
+ row.appendChild(el('label', 'frameos-manager__label', 'Scene'))
+ const select = document.createElement('select')
+ select.className = 'frameos-manager__input'
+ for (const item of scenes) {
+ const option = document.createElement('option')
+ option.value = item.id
+ option.textContent = item.name || item.id
+ option.selected = item.id === preview.currentSceneId
+ select.appendChild(option)
+ }
+ select.onchange = () => {
+ editedValues = {}
+ preview.selectScene(select.value)
+ renderControls()
+ }
+ row.appendChild(select)
+ controls.appendChild(row)
+ }
+
+ // State fields, filtered by showIf against current + edited values.
+ const fields = publicFields(scene)
+ const values = fieldValues(scene)
+ const visible = fields.filter((field) => evaluateShowIf(field.showIf, values, field.name))
+ for (const field of visible) {
+ controls.appendChild(fieldRow(field, values[field.name]))
+ }
+ // One row of actions: Apply & render (when there are fields), the
+ // scene's custom event buttons, and a plain Render.
+ const buttonRow = el('div', 'frameos-manager__row frameos-manager__row--buttons')
+ if (visible.length > 0) {
+ const apply = document.createElement('button')
+ apply.type = 'button'
+ apply.className = 'frameos-manager__button frameos-manager__button--primary'
+ apply.textContent = 'Apply & render'
+ apply.onclick = () => {
+ const state: Record = {}
+ for (const field of visible) {
+ const value = field.name in editedValues ? editedValues[field.name] : values[field.name]
+ if (value !== undefined) {
+ state[field.name] = coerceStateFieldValue(field, value)
+ }
+ }
+ preview.setSceneState(state)
+ }
+ buttonRow.appendChild(apply)
+ }
+
+ // Custom event nodes as buttons. Listener nodes filter on payload values
+ // (a "button" listener with label "A" only fires for {label: "A"} — see
+ // eventNodeMatchesPayload in frameos' interpreter.nim), so the label must
+ // ride along in the payload, not just caption the button.
+ for (const event of sceneEventButtons(scene)) {
+ const button = document.createElement('button')
+ button.type = 'button'
+ button.className = 'frameos-manager__button'
+ button.textContent = event.label || event.keyword
+ button.onclick = () => preview.sendEvent(event.keyword, event.label ? { label: event.label } : {})
+ buttonRow.appendChild(button)
+ }
+ const render = document.createElement('button')
+ render.type = 'button'
+ render.className = 'frameos-manager__button'
+ render.textContent = 'Render'
+ render.onclick = () => preview.render()
+ buttonRow.appendChild(render)
+ controls.appendChild(buttonRow)
+ }
+
+ function fieldRow(field: StateField, value: unknown): HTMLElement {
+ const row = el('div', 'frameos-manager__row')
+ row.appendChild(el('label', 'frameos-manager__label', field.label || field.name))
+
+ const update = (next: unknown): void => {
+ editedValues = { ...editedValues, [field.name]: next }
+ // showIf conditions may depend on this field: re-render the form (a
+ // fresh element keeps focus handling simple — apply happens on click).
+ renderControls()
+ }
+
+ if (field.type === 'select' || field.type === 'boolean' || field.type === 'font') {
+ const select = document.createElement('select')
+ select.className = 'frameos-manager__input'
+ const opts = field.type === 'boolean' ? ['true', 'false'] : field.options ?? []
+ for (const opt of opts) {
+ const option = document.createElement('option')
+ option.value = opt
+ option.textContent = opt
+ option.selected = String(value ?? '') === opt
+ select.appendChild(option)
+ }
+ select.onchange = () => update(select.value)
+ row.appendChild(select)
+ } else if (field.type === 'text') {
+ const textarea = document.createElement('textarea')
+ textarea.className = 'frameos-manager__input'
+ textarea.rows = 3
+ textarea.value = value === undefined || value === null ? '' : String(value)
+ textarea.onchange = () => update(textarea.value)
+ row.appendChild(textarea)
+ } else {
+ const input = document.createElement('input')
+ input.className = 'frameos-manager__input'
+ input.type =
+ field.type === 'integer' || field.type === 'float'
+ ? 'number'
+ : field.type === 'date'
+ ? 'date'
+ : field.type === 'color'
+ ? 'color'
+ : 'text'
+ if (field.type === 'float') {
+ input.step = 'any'
+ }
+ if (field.placeholder) {
+ input.placeholder = field.placeholder
+ }
+ input.value = value === undefined || value === null ? '' : String(value)
+ input.onchange = () => update(input.value)
+ row.appendChild(input)
+ }
+ return row
+ }
+
+ return {
+ preview,
+ destroy: () => {
+ destroyed = true
+ preview.destroy()
+ container.innerHTML = ''
+ container.classList.remove('frameos-manager')
+ },
+ }
+}
+
+function el(tag: string, className: string, text?: string): HTMLElement {
+ const node = document.createElement(tag)
+ node.className = className
+ if (text) {
+ node.textContent = text
+ }
+ return node
+}
+
+let stylesInjected = false
+function injectStyles(): void {
+ if (stylesInjected || typeof document === 'undefined') {
+ return
+ }
+ stylesInjected = true
+ const style = document.createElement('style')
+ style.setAttribute('data-frameos-manager', '')
+ style.textContent = `
+.frameos-manager { display: flex; flex-direction: column; gap: 12px; font: 14px/1.4 system-ui, sans-serif; }
+.frameos-manager__stage { display: flex; justify-content: center; background: #0f172a; border-radius: 12px; padding: 12px; }
+.frameos-manager__canvas { max-width: 100%; max-height: min(60vh, 480px); width: auto; height: auto; border-radius: 6px; background: #fff; }
+.frameos-manager__status { min-height: 1.2em; font-size: 12px; opacity: 0.7; }
+.frameos-manager__status--error { color: #dc2626; opacity: 1; }
+.frameos-manager__controls { display: flex; flex-direction: column; gap: 8px; }
+.frameos-manager__row { display: flex; align-items: center; gap: 8px; }
+.frameos-manager__row--buttons { flex-wrap: wrap; }
+.frameos-manager__label { min-width: 120px; font-weight: 600; }
+.frameos-manager__input { flex: 1; padding: 6px 8px; border: 1px solid #cbd5e1; border-radius: 8px; background: inherit; color: inherit; }
+.frameos-manager__button { padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 8px; background: transparent; color: inherit; cursor: pointer; }
+.frameos-manager__button:hover { background: rgba(100, 116, 139, 0.1); }
+.frameos-manager__button--primary { background: #2563eb; border-color: #2563eb; color: #fff; align-self: flex-start; }
+.frameos-manager__button--primary:hover { background: #1d4ed8; }
+.frameos-manager__logs { max-height: 200px; overflow: auto; margin: 0; padding: 8px 10px; background: rgba(15, 23, 42, 0.05); border-radius: 8px; font-size: 11px; }
+`
+ document.head.appendChild(style)
+}
diff --git a/frameos/wasm/src/preview.ts b/frameos/wasm/src/preview.ts
new file mode 100644
index 000000000..ecc754ec6
--- /dev/null
+++ b/frameos/wasm/src/preview.ts
@@ -0,0 +1,172 @@
+// Typed wrapper around the FrameOS preview worker (assets/preview-worker.js).
+// The worker loads the emscripten-built scene runtime (frameos.js/frameos.wasm)
+// and drives renders; this class owns the worker lifecycle, paints frames onto
+// a canvas, and exposes events/state as callbacks.
+import type { FrameOSScene, PreviewFrame, SceneInfo } from './types'
+
+export interface FrameOSPreviewOptions {
+ /** URL of the module worker script: `/preview-worker.js`. The
+ * frameos.js/frameos.wasm files must live next to it (same directory) —
+ * copy the package's `dist/assets/` folder somewhere same-origin. */
+ workerUrl: string | URL
+ /** Render width/height in pixels (the frame's dimensions). */
+ width: number
+ height: number
+ /** The scenes to load — the parsed contents of a scenes.json. */
+ scenes: FrameOSScene[]
+ /** Scene to select initially; defaults to the runtime's default scene. */
+ sceneId?: string
+ /** Frame name shown in logs. */
+ name?: string
+ /** IANA time zone for the simulated frame; defaults to the browser's. */
+ timeZone?: string
+ /** Frame settings (app API keys etc.); most previews run fine without. */
+ settings?: Record
+ /** Same-origin proxy endpoint for the runtime's HTTP requests. Without it,
+ * scenes fetching external data hit browser CORS limits. */
+ proxyUrl?: string
+ /** Canvas to paint frames onto; can also be attached later. */
+ canvas?: HTMLCanvasElement | null
+ onReady?: (sceneInfo: SceneInfo) => void
+ onFrame?: (frame: PreviewFrame) => void
+ onState?: (state: Record) => void
+ onLog?: (message: string) => void
+ onSceneEvent?: (name: string, payload: Record) => void
+ onError?: (message: string) => void
+}
+
+interface PendingFrame {
+ width: number
+ height: number
+ buffer: ArrayBuffer
+}
+
+export class FrameOSPreview {
+ readonly options: FrameOSPreviewOptions
+ private worker: Worker | null = null
+ private canvas: HTMLCanvasElement | null = null
+ private pendingFrame: PendingFrame | null = null
+ private destroyed = false
+
+ /** Latest scene info from the runtime (set once `ready` fires). */
+ sceneInfo: SceneInfo | null = null
+ /** Latest public state of the current scene. */
+ state: Record = {}
+ /** The scene currently selected in the runtime. */
+ currentSceneId: string | null = null
+
+ constructor(options: FrameOSPreviewOptions) {
+ this.options = options
+ this.canvas = options.canvas ?? null
+ this.currentSceneId = options.sceneId ?? null
+
+ this.worker = new Worker(options.workerUrl, { type: 'module' })
+ this.worker.onerror = (event: ErrorEvent) => {
+ options.onError?.(event.message || 'FrameOS preview worker failed to load')
+ }
+ this.worker.onmessage = (event: MessageEvent) => this.handleMessage(event.data ?? {})
+ this.worker.postMessage({
+ type: 'init',
+ width: options.width,
+ height: options.height,
+ name: options.name || 'frameos-wasm preview',
+ timeZone: options.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
+ scenesJson: JSON.stringify(options.scenes),
+ settingsJson: JSON.stringify(options.settings ?? {}),
+ proxyUrl: options.proxyUrl || '',
+ sceneId: options.sceneId || '',
+ })
+ }
+
+ private handleMessage(msg: Record): void {
+ if (this.destroyed) {
+ return
+ }
+ switch (msg.type) {
+ case 'ready':
+ this.sceneInfo = msg.sceneInfo ?? null
+ if (this.sceneInfo?.currentSceneId) {
+ this.currentSceneId = this.sceneInfo.currentSceneId
+ }
+ this.options.onReady?.(msg.sceneInfo)
+ break
+ case 'frame':
+ this.pendingFrame = { width: msg.width, height: msg.height, buffer: msg.buffer }
+ this.paint()
+ this.options.onFrame?.({ width: msg.width, height: msg.height, renderMs: msg.renderMs })
+ break
+ case 'state':
+ this.state = msg.state ?? {}
+ this.options.onState?.(this.state)
+ break
+ case 'log':
+ this.options.onLog?.(String(msg.message ?? ''))
+ break
+ case 'sceneEvent':
+ this.options.onSceneEvent?.(String(msg.name ?? ''), msg.payload ?? {})
+ break
+ case 'error':
+ this.options.onError?.(String(msg.message ?? 'Unknown FrameOS preview error'))
+ break
+ }
+ }
+
+ /** Attach (or replace) the canvas frames are painted onto. */
+ attachCanvas(canvas: HTMLCanvasElement | null): void {
+ this.canvas = canvas
+ this.paint()
+ }
+
+ private paint(): void {
+ const canvas = this.canvas
+ const frame = this.pendingFrame
+ if (!canvas || !frame || !frame.buffer.byteLength) {
+ return
+ }
+ if (canvas.width !== frame.width) {
+ canvas.width = frame.width
+ }
+ if (canvas.height !== frame.height) {
+ canvas.height = frame.height
+ }
+ const context = canvas.getContext('2d')
+ if (!context) {
+ return
+ }
+ context.putImageData(new ImageData(new Uint8ClampedArray(frame.buffer), frame.width, frame.height), 0, 0)
+ }
+
+ /** Force a render now. */
+ render(): void {
+ this.worker?.postMessage({ type: 'render' })
+ }
+
+ /** Dispatch a scene event (a custom event node's keyword, "button", ...). */
+ sendEvent(name: string, payload: Record = {}): void {
+ this.worker?.postMessage({ type: 'event', name, payload })
+ }
+
+ /** Update the current scene's state fields; renders by default. */
+ setSceneState(state: Record, render = true): void {
+ this.sendEvent('setSceneState', { state, render })
+ }
+
+ /** Switch the runtime to another loaded scene. */
+ selectScene(sceneId: string): void {
+ this.currentSceneId = sceneId
+ this.worker?.postMessage({ type: 'selectScene', sceneId })
+ }
+
+ /** Terminate the worker; the instance cannot be reused afterwards. */
+ destroy(): void {
+ this.destroyed = true
+ this.worker?.terminate()
+ this.worker = null
+ this.pendingFrame = null
+ this.canvas = null
+ }
+}
+
+export function createFrameOSPreview(options: FrameOSPreviewOptions): FrameOSPreview {
+ return new FrameOSPreview(options)
+}
diff --git a/frameos/wasm/src/showIf.ts b/frameos/wasm/src/showIf.ts
new file mode 100644
index 000000000..407da76c7
--- /dev/null
+++ b/frameos/wasm/src/showIf.ts
@@ -0,0 +1,115 @@
+// showIf evaluation for scene state fields. A framework-free mirror of
+// frontend/src/utils/showIf.ts (which itself mirrors the Nim implementation
+// in frameos/src/frameos/utils/show_if.nim) — keep the three in sync.
+import type { ShowIfCondition, StateField } from './types'
+
+function matchCondition(
+ condition: ShowIfCondition,
+ values: Record,
+ currentFieldName: string | null
+): boolean {
+ if ('and' in condition) {
+ return condition.and.every((cond) => matchCondition(cond, values, currentFieldName))
+ }
+
+ const { value, operator, field: fieldName } = condition
+ const field = fieldName || currentFieldName || ''
+ const actualValue = values[field] as any
+
+ if (operator === 'eq') {
+ return actualValue === value
+ } else if (operator === 'ne') {
+ return actualValue !== value
+ } else if (operator === 'gt') {
+ return actualValue > (value as any)
+ } else if (operator === 'lt') {
+ return actualValue < (value as any)
+ } else if (operator === 'gte') {
+ return actualValue >= (value as any)
+ } else if (operator === 'lte') {
+ return actualValue <= (value as any)
+ } else if (operator === 'in') {
+ return !!(value as any)?.includes?.(actualValue)
+ } else if (operator === 'notIn') {
+ return !(value as any)?.includes?.(actualValue)
+ } else if (operator === 'empty') {
+ return !actualValue
+ } else if (operator === 'notEmpty') {
+ return !!actualValue
+ }
+ return value !== undefined ? value === actualValue : !!actualValue
+}
+
+/**
+ * True when the field should be visible: no conditions, or at least one
+ * top-level condition matches (top level is OR-ed, `and` blocks are AND-ed).
+ */
+export function evaluateShowIf(
+ conditions: ShowIfCondition[] | undefined,
+ values: Record,
+ currentFieldName?: string | null
+): boolean {
+ if (!conditions || conditions.length === 0) {
+ return true
+ }
+ return conditions.some((condition) => matchCondition(condition, values, currentFieldName ?? null))
+}
+
+/**
+ * Coerce a raw form value (usually a string) to the field's type so showIf
+ * comparisons behave the same as on the frame itself.
+ */
+export function coerceStateFieldValue(field: Pick, value: unknown): unknown {
+ if (value === undefined || value === null) {
+ return value
+ }
+ if (field.type === 'boolean') {
+ return value === true || value === 'true'
+ }
+ if (field.type === 'integer') {
+ const parsed = typeof value === 'number' ? Math.trunc(value) : parseInt(String(value))
+ return isNaN(parsed) ? undefined : parsed
+ }
+ if (field.type === 'float') {
+ const parsed = typeof value === 'number' ? value : parseFloat(String(value))
+ return isNaN(parsed) ? undefined : parsed
+ }
+ return value
+}
+
+/** The values map for showIf checks: field defaults overlaid with the sources. */
+export function stateFieldShowIfValues(
+ fields: StateField[],
+ ...valueSources: (Record | null | undefined)[]
+): Record {
+ const values: Record = {}
+ for (const field of fields) {
+ if (!field.name) {
+ continue
+ }
+ let value = field.value
+ for (const source of valueSources) {
+ if (source && field.name in source && source[field.name] !== undefined) {
+ value = source[field.name]
+ }
+ }
+ const coerced = coerceStateFieldValue(field, value)
+ if (coerced !== undefined) {
+ values[field.name] = coerced
+ }
+ }
+ return values
+}
+
+/**
+ * The public state fields visible in a control form given the current values
+ * (later sources win, defaults fill the gaps).
+ */
+export function visiblePublicStateFields(
+ fields: StateField[],
+ ...valueSources: (Record | null | undefined)[]
+): StateField[] {
+ const publicFields = fields.filter((field) => field.access === 'public')
+ const values = stateFieldShowIfValues(publicFields, ...valueSources)
+ return publicFields.filter((field) => evaluateShowIf(field.showIf, values, field.name ?? null))
+}
diff --git a/frameos/wasm/src/types.ts b/frameos/wasm/src/types.ts
new file mode 100644
index 000000000..a8bbc04f9
--- /dev/null
+++ b/frameos/wasm/src/types.ts
@@ -0,0 +1,101 @@
+// Types for the FrameOS scenes JSON and the preview worker protocol. These
+// mirror the FrameOS backend/frontend definitions (frontend/src/types.tsx and
+// frameos/src/frameos/types.nim) for the parts a browser preview needs.
+
+/** One showIf condition: compare a (state) field's value against `value`. */
+export interface ConfigFieldCondition {
+ field?: string
+ operator?: 'eq' | 'ne' | 'gt' | 'lt' | 'gte' | 'lte' | 'empty' | 'notEmpty' | 'in' | 'notIn' | null
+ value?: unknown
+}
+
+/** All conditions inside `and` must match; top-level conditions are OR-ed. */
+export interface ConfigFieldConditionAnd {
+ and: ConfigFieldCondition[]
+}
+
+export type ShowIfCondition = ConfigFieldCondition | ConfigFieldConditionAnd
+
+/** A scene state field, as found in a scene JSON's `fields` array. */
+export interface StateField {
+ name: string
+ label?: string
+ type?: string
+ value?: unknown
+ options?: string[]
+ placeholder?: string
+ required?: boolean
+ secret?: boolean
+ persist?: 'memory' | 'disk'
+ access?: 'private' | 'public'
+ showIf?: ShowIfCondition[]
+}
+
+export interface SceneNode {
+ id?: string
+ type?: string
+ data?: Record & { keyword?: string; config?: Record }
+}
+
+/** A FrameOS scene as exported in scenes.json / template zips. */
+export interface FrameOSScene {
+ id: string
+ name?: string
+ nodes?: SceneNode[]
+ edges?: unknown[]
+ fields?: StateField[]
+ settings?: Record
+ default?: boolean
+ [key: string]: unknown
+}
+
+/** frameos_wasm_scene_info() payload, sent with the worker's `ready` message. */
+export interface SceneInfo {
+ loaded: number
+ currentSceneId: string
+ currentSceneName: string
+ defaultSceneId: string
+ renderRequested: boolean
+ scenes: { id: string; name: string; refreshInterval: number }[]
+}
+
+export interface PreviewFrame {
+ width: number
+ height: number
+ renderMs: number
+}
+
+/** An interactive scene event (custom `event` node) usable as a button. */
+export interface SceneEventButton {
+ keyword: string
+ label: string | null
+}
+
+/** Events every scene handles on its own; not useful as interactive buttons. */
+export const LIFECYCLE_EVENTS = new Set(['render', 'init', 'open', 'close', 'setSceneState', 'setCurrentScene'])
+
+/** The custom event nodes of a scene, deduplicated — render these as buttons. */
+export function sceneEventButtons(scene: FrameOSScene | undefined | null): SceneEventButton[] {
+ if (!scene) {
+ return []
+ }
+ const events: SceneEventButton[] = []
+ const seen = new Set()
+ for (const node of scene.nodes ?? []) {
+ if (node.type !== 'event') {
+ continue
+ }
+ const data = (node.data ?? {}) as Record & { config?: Record }
+ const keyword = String(data.keyword ?? '')
+ if (!keyword || LIFECYCLE_EVENTS.has(keyword)) {
+ continue
+ }
+ const label = (data.config?.label ?? data.label ?? null) as string | null
+ const dedupeKey = `${keyword}:${label ?? ''}`
+ if (!seen.has(dedupeKey)) {
+ seen.add(dedupeKey)
+ events.push({ keyword, label: label ? String(label) : null })
+ }
+ }
+ return events
+}
diff --git a/frameos/wasm/tsconfig.json b/frameos/wasm/tsconfig.json
new file mode 100644
index 000000000..6ff0d1d10
--- /dev/null
+++ b/frameos/wasm/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ES2020",
+ "moduleResolution": "bundler",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src"]
+}
diff --git a/frontend/build.mjs b/frontend/build.mjs
index 167b65174..4c3037d30 100644
--- a/frontend/build.mjs
+++ b/frontend/build.mjs
@@ -15,6 +15,7 @@ import {
export const __dirname = path.dirname(fileURLToPath(import.meta.url))
await import('./scripts/generateRepoApps.mjs')
+await import('./scripts/generateBuiltinApps.mjs')
startDevServer(__dirname)
copyPublicFolder(path.resolve(__dirname, 'public'), path.resolve(__dirname, 'dist'))
@@ -25,17 +26,17 @@ const common = {
bundle: true,
}
-function isMonacoWorkerOutput(outputPath) {
- return outputPath.startsWith('dist/static/monaco/') && outputPath.endsWith('.js')
+function isMonacoWorkerOutput(outputPath, staticPrefix) {
+ return outputPath.startsWith(`${staticPrefix}monaco/`) && outputPath.endsWith('.js')
}
-function isSharedWorkerChunk(outputPath) {
- return outputPath.startsWith('dist/static/chunk-') && outputPath.endsWith('.js')
+function isSharedWorkerChunk(outputPath, staticPrefix) {
+ return outputPath.startsWith(`${staticPrefix}chunk-`) && outputPath.endsWith('.js')
}
-function copyMonacoWorkerChunks(buildOutputs) {
+function copyMonacoWorkerChunks(buildOutputs, staticPrefix = 'dist/static/') {
const outputs = buildOutputs.outputs ?? {}
- const workerOutputKeys = Object.keys(outputs).filter(isMonacoWorkerOutput)
+ const workerOutputKeys = Object.keys(outputs).filter((key) => isMonacoWorkerOutput(key, staticPrefix))
const chunkKeys = new Set()
const visit = (key) => {
const output = outputs[key]
@@ -43,7 +44,7 @@ function copyMonacoWorkerChunks(buildOutputs) {
return
}
for (const imported of output.imports ?? []) {
- if (isSharedWorkerChunk(imported.path) && !chunkKeys.has(imported.path)) {
+ if (isSharedWorkerChunk(imported.path, staticPrefix) && !chunkKeys.has(imported.path)) {
chunkKeys.add(imported.path)
visit(imported.path)
}
@@ -56,7 +57,7 @@ function copyMonacoWorkerChunks(buildOutputs) {
for (const key of chunkKeys) {
const sourcePath = path.resolve(__dirname, key)
- const targetPath = path.resolve(__dirname, 'dist/static/monaco', path.basename(key))
+ const targetPath = path.resolve(__dirname, `${staticPrefix}monaco`, path.basename(key))
fse.copyFileSync(sourcePath, targetPath)
const sourceMapPath = `${sourcePath}.map`
@@ -66,6 +67,82 @@ function copyMonacoWorkerChunks(buildOutputs) {
}
}
+// The embedded scene editor (dist-editor/): the same Diagram/EditApp code,
+// but with frameLogic and logsLogic swapped for in-memory shims so it runs
+// without a backend — scenes go in and out over postMessage. Published to
+// npm as `frameos-editor`; see src/embed/.
+const embedAliasPlugin = {
+ name: 'frameos-embed-aliases',
+ setup(build) {
+ const shimSkip = /frameLogicShim|logsLogicShim/
+ const realFrameLogic = path.resolve(__dirname, 'src/scenes/frame/frameLogic')
+ const realLogsLogic = path.resolve(__dirname, 'src/scenes/frame/panels/Logs/logsLogic')
+ build.onResolve({ filter: /(frameLogic|logsLogic)$/ }, (args) => {
+ if (!args.path.startsWith('.') || !args.importer || shimSkip.test(args.importer)) {
+ return null
+ }
+ const resolved = path.resolve(path.dirname(args.importer), args.path)
+ if (resolved === realFrameLogic) {
+ return { path: path.resolve(__dirname, 'src/embed/frameLogicShim.ts') }
+ }
+ if (resolved === realLogsLogic) {
+ return { path: path.resolve(__dirname, 'src/embed/logsLogicShim.ts') }
+ }
+ return null
+ })
+ },
+}
+
+function writeEditorHtml(outputs = {}) {
+ const distEditor = path.resolve(__dirname, 'dist-editor')
+ fse.mkdirpSync(distEditor)
+ // Hashless copies of the entry + monaco workers, so configureMonaco's
+ // worker URLs keep stable names; the html references the HASHED entry
+ // files so a redeployed bundle busts browser caches.
+ let entryJs = './static/editor.js'
+ let entryCss = './static/editor.css'
+ for (const key of Object.keys(outputs)) {
+ if (/^dist-editor\/static\/(editor-[A-Z0-9]+\.(js|css)|monaco\/(editor|json|css|html|ts)\.worker-[A-Z0-9]+\.js)$/.test(key)) {
+ createHashlessEntrypoints(__dirname, [key])
+ if (/static\/editor-[A-Z0-9]+\.js$/.test(key)) {
+ entryJs = `./static/${path.basename(key)}`
+ } else if (/static\/editor-[A-Z0-9]+\.css$/.test(key)) {
+ entryCss = `./static/${path.basename(key)}`
+ }
+ }
+ }
+ fse.writeFileSync(
+ path.resolve(distEditor, 'index.html'),
+ `
+
+
+
+
+ FrameOS Scene Editor
+
+
+
+
+
+
+
+
+
+
+`
+ )
+}
+
await buildInParallel(
[
{
@@ -84,6 +161,26 @@ await buildInParallel(
outdir: path.resolve(__dirname, 'dist', 'static'),
...common,
},
+ {
+ name: 'FrameOS Embedded Editor',
+ globalName: 'frameOSEditor',
+ // publicPath './' makes chunk imports same-directory-relative, so the
+ // entry must land at the outdir root, next to the chunks (the workers
+ // get their shared chunks copied in by copyMonacoWorkerChunks).
+ entryPoints: [
+ { in: 'src/embed/editor.tsx', out: 'editor' },
+ { in: 'src/monaco/editor.worker.ts', out: 'monaco/editor.worker' },
+ { in: 'src/monaco/json.worker.ts', out: 'monaco/json.worker' },
+ { in: 'src/monaco/css.worker.ts', out: 'monaco/css.worker' },
+ { in: 'src/monaco/html.worker.ts', out: 'monaco/html.worker' },
+ { in: 'src/monaco/ts.worker.ts', out: 'monaco/ts.worker' },
+ ],
+ splitting: true,
+ format: 'esm',
+ outdir: path.resolve(__dirname, 'dist-editor', 'static'),
+ extraPlugins: [embedAliasPlugin],
+ ...common,
+ },
],
{
async onBuildComplete(config, buildResponse) {
@@ -95,6 +192,16 @@ await buildInParallel(
if (config.name === 'FrameOS Frontend') {
writeIndexHtml(chunks, entrypoints)
}
+ if (config.name === 'FrameOS Embedded Editor') {
+ try {
+ writeEditorHtml(buildResponse.outputs)
+ copyMonacoWorkerChunks(buildResponse, 'dist-editor/static/')
+ } catch (error) {
+ console.error('Embedded editor packaging failed:', error)
+ throw error
+ }
+ return
+ }
const files = Object.keys(buildResponse.outputs).filter(
(key) => key.startsWith('dist/static/main-') || key.startsWith('dist/static/monaco/')
diff --git a/frontend/package.json b/frontend/package.json
index fa67dee7a..85a7cd859 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,7 +10,7 @@
"dev:vite": "vite --host 0.0.0.0 --port 8616",
"clean": "rm -rf ./dist && mkdir ./dist",
"build": "npm run build:repo-apps && npm run build:kea && npm run schema && tsc && npm run build:esbuild",
- "build:repo-apps": "node scripts/generateRepoApps.mjs",
+ "build:repo-apps": "node scripts/generateRepoApps.mjs && node scripts/generateBuiltinApps.mjs",
"build:kea": "kea-typegen write --write-paths",
"build:esbuild": "node build.mjs",
"prettier": "prettier --write src",
diff --git a/frontend/scripts/generateBuiltinApps.mjs b/frontend/scripts/generateBuiltinApps.mjs
new file mode 100644
index 000000000..007c8f199
--- /dev/null
+++ b/frontend/scripts/generateBuiltinApps.mjs
@@ -0,0 +1,85 @@
+#!/usr/bin/env node
+// Embed the built-in (Nim) app catalog into the bundle, mirroring
+// generateRepoApps.mjs. The backend serves the same configs via GET
+// /api/apps; the embedded copy makes the app catalog available without a
+// backend (standalone/embedded editor, offline fallback) and ships the app
+// sources so "view app source" works there too.
+import fs from 'node:fs/promises'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url))
+const frontendRoot = path.resolve(__dirname, '..')
+const repoRoot = path.resolve(frontendRoot, '..')
+const sourceRoot = path.join(repoRoot, 'frameos', 'src', 'apps')
+const outputPath = path.join(frontendRoot, 'src', 'generated', 'builtinApps.ts')
+
+async function pathExists(filePath) {
+ try {
+ await fs.access(filePath)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function subdirs(dir) {
+ if (!(await pathExists(dir))) {
+ return []
+ }
+ const entries = await fs.readdir(dir, { withFileTypes: true })
+ return entries
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+ .sort()
+}
+
+async function buildEmbeddedBuiltinApps() {
+ const configs = {}
+ const sources = {}
+
+ for (const category of await subdirs(sourceRoot)) {
+ for (const appName of await subdirs(path.join(sourceRoot, category))) {
+ const appDir = path.join(sourceRoot, category, appName)
+ const configPath = path.join(appDir, 'config.json')
+ if (!(await pathExists(configPath))) {
+ continue
+ }
+ const config = JSON.parse(await fs.readFile(configPath, 'utf8'))
+ if (!config || typeof config !== 'object' || !config.name) {
+ continue
+ }
+
+ const keyword = `${category}/${appName}`
+ configs[keyword] = { ...config, category }
+
+ const files = (await fs.readdir(appDir, { withFileTypes: true }))
+ .filter((entry) => entry.isFile())
+ .map((entry) => entry.name)
+ .sort()
+ sources[keyword] = {}
+ for (const file of files) {
+ sources[keyword][file] = await fs.readFile(path.join(appDir, file), 'utf8')
+ }
+ }
+ }
+
+ return { configs, sources }
+}
+
+function renderModule({ configs, sources }) {
+ return `${[
+ '// Auto-generated by scripts/generateBuiltinApps.mjs.',
+ "import type { AppConfig } from '../types'",
+ '',
+ // Cast: real config.json files use a few field properties the strict
+ // frontend types don't model (min/max, bare showIf operators).
+ `export const embeddedBuiltinAppConfigs = ${JSON.stringify(configs, null, 2)} as unknown as Record`,
+ '',
+ `export const embeddedBuiltinAppSources: Record> = ${JSON.stringify(sources, null, 2)}`,
+ '',
+ ].join('\n')}`
+}
+
+await fs.mkdir(path.dirname(outputPath), { recursive: true })
+await fs.writeFile(outputPath, renderModule(await buildEmbeddedBuiltinApps()))
diff --git a/frontend/scripts/smokeEditorEmbed.mjs b/frontend/scripts/smokeEditorEmbed.mjs
new file mode 100644
index 000000000..223ac7252
--- /dev/null
+++ b/frontend/scripts/smokeEditorEmbed.mjs
@@ -0,0 +1,108 @@
+// Smoke test for the embedded editor bundle: serve dist-editor statically,
+// post an init message with a small scene, and check nodes render + edits
+// round-trip over postMessage.
+import http from 'node:http'
+import { readFile } from 'node:fs/promises'
+import path from 'node:path'
+import { chromium } from '@playwright/test'
+
+const distEditor = process.argv[2] ?? new URL("../dist-editor", import.meta.url).pathname
+const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.map': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', '.ttf': 'font/ttf' }
+
+const server = http.createServer(async (req, res) => {
+ const urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname)
+ const filePath = path.join(distEditor, urlPath === '/' ? 'index.html' : urlPath)
+ try {
+ const body = await readFile(filePath)
+ res.writeHead(200, { 'content-type': mime[path.extname(filePath)] ?? 'application/octet-stream' })
+ res.end(body)
+ } catch {
+ res.writeHead(404)
+ res.end('not found')
+ }
+})
+await new Promise((resolve) => server.listen(0, resolve))
+const port = server.address().port
+
+const scenes = [
+ {
+ id: 'scene-1',
+ name: 'Smoke scene',
+ default: true,
+ nodes: [
+ { id: 'n-render', type: 'event', position: { x: 0, y: 0 }, data: { keyword: 'render' } },
+ {
+ id: 'n-color',
+ type: 'app',
+ position: { x: 300, y: 0 },
+ data: { keyword: 'render/color', config: { color: '#ff0000' } },
+ },
+ ],
+ edges: [{ id: 'e1', source: 'n-render', target: 'n-color', sourceHandle: 'next', targetHandle: 'prev' }],
+ fields: [],
+ },
+]
+
+const browser = await chromium.launch()
+const page = await browser.newPage({ viewport: { width: 1400, height: 900 } })
+const errors = []
+page.on('pageerror', (err) => errors.push(`pageerror: ${err.message}`))
+page.on('console', (msg) => {
+ if (msg.type() === 'error') {
+ errors.push(`console: ${msg.text()}`)
+ }
+})
+page.on('requestfailed', (req) => errors.push(`reqfail: ${req.url()}`))
+page.on('response', (res) => {
+ if (res.status() === 404) {
+ errors.push(`404: ${res.url()}`)
+ }
+})
+
+const received = []
+await page.exposeFunction('smokeReport', (msg) => received.push(msg))
+
+await page.addInitScript(() => {
+ window.addEventListener('message', (event) => {
+ if (event.data?.type?.startsWith('frameos-editor:')) {
+ // The page IS the "parent" here (window.parent === window when not framed).
+ window.smokeReport({ type: event.data.type, sceneCount: event.data.scenes?.length })
+ }
+ })
+})
+
+await page.goto(`http://127.0.0.1:${port}/index.html`)
+try {
+ await page.waitForFunction(() => document.body.innerText.includes('Waiting for scenes'), null, { timeout: 15000 })
+} catch {
+ console.log('INIT SCREEN MISSING')
+ console.log(JSON.stringify({ errors, bodyText: await page.evaluate(() => document.body.innerText.slice(0, 300)), html: await page.evaluate(() => document.getElementById('root')?.innerHTML?.slice(0, 300)) }, null, 2))
+ await browser.close(); server.close(); process.exit(1)
+}
+
+await page.evaluate((scenes) => {
+ window.postMessage({ type: 'frameos-editor:init', scenes, width: 800, height: 480, mode: 'rpios' }, '*')
+}, scenes)
+
+// The diagram should render both nodes.
+try {
+ await page.waitForSelector('.react-flow__node', { timeout: 15000 })
+} catch {
+ console.log('NODES MISSING')
+ console.log(JSON.stringify({ errors, received, bodyText: await page.evaluate(() => document.body.innerText.slice(0, 300)) }, null, 2))
+ await browser.close(); server.close(); process.exit(1)
+}
+const nodeCount = await page.locator('.react-flow__node').count()
+
+// Ask for the scenes back.
+await page.evaluate(() => window.postMessage({ type: 'frameos-editor:get-scenes' }, '*'))
+await page.waitForFunction(() => true, null, { timeout: 1000 }).catch(() => {})
+await page.waitForTimeout(800)
+
+const bodyText = await page.evaluate(() => document.body.innerText.slice(0, 400))
+await page.screenshot({ path: path.join(path.dirname(distEditor), 'editor-smoke.png') }).catch(() => {})
+
+console.log(JSON.stringify({ nodeCount, received, errors: errors.slice(0, 8), bodyText: bodyText.slice(0, 200) }, null, 2))
+await browser.close()
+server.close()
+process.exit(nodeCount >= 2 && received.some((m) => m.type === 'frameos-editor:scenes' && m.sceneCount === 1) ? 0 : 1)
diff --git a/frontend/src/embed/EmbedScenePreview.tsx b/frontend/src/embed/EmbedScenePreview.tsx
new file mode 100644
index 000000000..96cd04e26
--- /dev/null
+++ b/frontend/src/embed/EmbedScenePreview.tsx
@@ -0,0 +1,460 @@
+import { useActions, useValues } from 'kea'
+import clsx from 'clsx'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { CameraIcon, CursorArrowRaysIcon, KeyIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
+
+import { Button } from '../components/Button'
+import { Checkbox } from '../components/Checkbox'
+import { Label } from '../components/Label'
+import { Modal } from '../components/Modal'
+import { Spinner } from '../components/Spinner'
+import { TextInput } from '../components/TextInput'
+import { Tooltip } from '../components/Tooltip'
+import { visiblePublicStateFields } from '../utils/showIf'
+import { appsModel } from '../models/appsModel'
+import { collectSecretSettingsFromScenes, settingsDetails } from '../scenes/frame/panels/secretSettings'
+import { collectScenePreviewPayloadScenes } from '../scenes/frame/panels/Scenes/scenesLogic'
+import { livePreviewLogic } from '../scenes/frame/panels/Scenes/livePreviewLogic'
+import {
+ formatTimestamp,
+ logLineColor,
+ openCanvasImageInNewTab,
+ renderLogLine,
+} from '../scenes/frame/panels/Scenes/LivePreviewModal'
+import { StateFieldEdit } from '../scenes/frame/panels/Scenes/StateFieldEdit'
+
+// The Preview drawer panel of the standalone embedded editor: runs the edited
+// scenes through the frameos-wasm runtime, in the browser — canvas, event
+// buttons, live scene state and the runtime log. Same livePreviewLogic as the
+// main app's "Preview in browser" modal, rendered as panel content and
+// without the frame-dependent actions (there is no frame to preview on).
+export function EmbedScenePreview({ frameId, sceneId }: { frameId: number; sceneId: string }): JSX.Element {
+ const {
+ livePreviewScene,
+ previewStatus,
+ previewError,
+ previewLogs,
+ previewState,
+ previewSceneEvents,
+ previewDimensions,
+ gpioButtons,
+ wasmUnsupportedApps,
+ lastRenderMs,
+ renderCount,
+ previewSettings,
+ scenes,
+ } = useValues(livePreviewLogic({ frameId }))
+ const {
+ openLivePreview,
+ closeLivePreview,
+ registerCanvas,
+ dispatchPreviewEvent,
+ forcePreviewRender,
+ setPreviewSettings,
+ } = useActions(livePreviewLogic({ frameId }))
+ const { apps } = useValues(appsModel)
+
+ const canvasRef = useRef(null)
+ const [screenshotStatus, setScreenshotStatus] = useState(null)
+ const [showPublicState, setShowPublicState] = useState(true)
+ const [showPrivateState, setShowPrivateState] = useState(false)
+ // Non-null while the "edit state" modal is open; holds the edited values.
+ const [editStateValues, setEditStateValues] = useState | null>(null)
+
+ // API keys (etc.) the previewed scenes' apps need. Typed values live here
+ // per flat "group.field" key; "Apply" nests them into the settings object
+ // the runtime expects and restarts the preview. Seeded from the previously
+ // applied settings so reopening the panel shows them.
+ const [settingsForm, setSettingsForm] = useState>(() => {
+ const flat: Record = {}
+ for (const [group, groupValues] of Object.entries(previewSettings ?? {})) {
+ for (const [key, value] of Object.entries(groupValues ?? {})) {
+ if (typeof value === 'string') {
+ flat[`${group}.${key}`] = value
+ }
+ }
+ }
+ return flat
+ })
+ const requiredSettingKeys = useMemo(() => {
+ const scene = scenes.find((item) => item.id === sceneId)
+ const payloadScenes = scene ? collectScenePreviewPayloadScenes(scene, scenes, null) : []
+ return collectSecretSettingsFromScenes(payloadScenes, apps)
+ }, [scenes, sceneId, apps])
+
+ const applySettings = (): void => {
+ const nested: Record> = {}
+ for (const settingKey of requiredSettingKeys) {
+ for (const field of settingsDetails[settingKey]?.fields ?? []) {
+ const value = settingsForm[field.path.join('.')]?.trim()
+ if (value) {
+ const [group, key] = field.path as [string, string]
+ nested[group] = { ...(nested[group] ?? {}), [key]: value }
+ }
+ }
+ }
+ setPreviewSettings(nested)
+ }
+
+ // "Save screenshot": offer the current frame to the embedding page (which
+ // can store it — FrameOS Cloud adds it to the scene's image gallery). If no
+ // parent acknowledges within a moment, fall back to a plain PNG download.
+ const saveScreenshot = (): void => {
+ const canvas = canvasRef.current
+ if (!canvas) {
+ setScreenshotStatus('Nothing rendered yet')
+ return
+ }
+ const dataUrl = canvas.toDataURL('image/png')
+ setScreenshotStatus('Saving…')
+ let settled = false
+ const onAck = (event: MessageEvent): void => {
+ const message = event.data
+ if (!message || message.type !== 'frameos-editor:screenshot-saved') {
+ return
+ }
+ settled = true
+ window.removeEventListener('message', onAck)
+ setScreenshotStatus(message.ok ? 'Saved to scene images' : message.error || 'Saving failed')
+ if (!message.ok && message.fallbackDownload !== false) {
+ downloadDataUrl(dataUrl)
+ setScreenshotStatus('Screenshot downloaded')
+ }
+ }
+ window.addEventListener('message', onAck)
+ window.parent?.postMessage({ type: 'frameos-editor:save-screenshot', dataUrl, sceneId }, '*')
+ window.setTimeout(() => {
+ if (!settled) {
+ window.removeEventListener('message', onAck)
+ downloadDataUrl(dataUrl)
+ setScreenshotStatus('Screenshot downloaded')
+ }
+ }, 3000)
+ }
+
+ const downloadDataUrl = (dataUrl: string): void => {
+ const link = document.createElement('a')
+ link.href = dataUrl
+ link.download = `${sceneId}-preview.png`
+ link.click()
+ }
+
+ // The wasm runtime starts when the panel opens and stops when it closes;
+ // switching scenes restarts it on the newly selected scene.
+ useEffect(() => {
+ openLivePreview(sceneId)
+ return () => closeLivePreview()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sceneId])
+
+ // Stick the runtime log to the bottom as new lines arrive — but only while
+ // the user hasn't scrolled up to read older lines.
+ const logRef = useRef(null)
+ const stickToBottomRef = useRef(true)
+ useEffect(() => {
+ const el = logRef.current
+ if (el && stickToBottomRef.current) {
+ el.scrollTop = el.scrollHeight
+ }
+ }, [previewLogs])
+
+ // GPIO buttons get dedicated buttons; hide indistinguishable "button"
+ // scene-event entries (same rule as LivePreviewModal).
+ const sceneEventButtons = previewSceneEvents.filter((event) => {
+ if (event.keyword !== 'button') {
+ return true
+ }
+ if (gpioButtons.length > 0) {
+ return false
+ }
+ return Boolean(event.label)
+ })
+
+ const publicFields = (livePreviewScene?.fields ?? []).filter((field) => field.access === 'public')
+ const publicFieldNames = new Set(publicFields.map((field) => field.name))
+ const stateEntries = Object.entries(previewState)
+ const publicEntries = stateEntries.filter(([key]) => publicFieldNames.has(key))
+ const privateEntries = stateEntries.filter(([key]) => !publicFieldNames.has(key))
+ const visibleStateEntries = [...(showPublicState ? publicEntries : []), ...(showPrivateState ? privateEntries : [])]
+
+ const openEditState = (): void => {
+ const values: Record = {}
+ for (const field of publicFields) {
+ values[field.name] = previewState[field.name] ?? field.value
+ }
+ setEditStateValues(values)
+ }
+
+ const submitEditState = (): void => {
+ if (!editStateValues) {
+ return
+ }
+ const state: Record = {}
+ for (const field of visiblePublicStateFields(publicFields, previewState, editStateValues)) {
+ const value = editStateValues[field.name] ?? field.value
+ if (value !== undefined && value !== null) {
+ state[field.name] = String(value)
+ }
+ }
+ dispatchPreviewEvent('setSceneState', { state, render: true })
+ setEditStateValues(null)
+ }
+
+ const editableFields = editStateValues ? visiblePublicStateFields(publicFields, previewState, editStateValues) : []
+
+ return (
+
+ )
+}
diff --git a/frontend/src/embed/editor.tsx b/frontend/src/embed/editor.tsx
new file mode 100644
index 000000000..77efc5459
--- /dev/null
+++ b/frontend/src/embed/editor.tsx
@@ -0,0 +1,14 @@
+import ReactDOM from 'react-dom/client'
+import '../utils/configureMonaco'
+import '../index.css'
+import { initKea } from '../initKea'
+import { EmbeddedEditor } from './EmbeddedEditor'
+
+// Standalone embedded scene editor (AGPL, like all of FrameOS). Built as its
+// own bundle with frameLogic/logsLogic swapped for in-memory shims — no
+// backend, no websocket; scenes arrive and leave over postMessage. See
+// EmbeddedEditor.tsx for the protocol and build.mjs for the alias setup.
+
+initKea()
+
+ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render()
diff --git a/frontend/src/embed/embedFrameLogic.ts b/frontend/src/embed/embedFrameLogic.ts
new file mode 100644
index 000000000..f78dfc640
--- /dev/null
+++ b/frontend/src/embed/embedFrameLogic.ts
@@ -0,0 +1,157 @@
+import { actions, kea, key, listeners, path, props, reducers, selectors } from 'kea'
+import { forms } from 'kea-forms'
+import { FrameScene, FrameType } from '../types'
+import { frameFormSceneErrors } from '../scenes/frame/frameFormSceneErrors'
+import type { embedFrameLogicType } from './embedFrameLogicType'
+
+// In-memory replacement for frameLogic's editor-facing slice, used by the
+// standalone embedded editor build (see frameLogicShim.ts and build.mjs).
+// Scenes come in over postMessage and edits flow back out through
+// embedBridge — there is no backend and nothing to deploy.
+//
+// frameForm is a real kea-forms form (like the one on the real frameLogic):
+// the settings/state/events panels render