diff --git a/backend/app/utils/scene_guide_gen.py b/backend/app/utils/scene_guide_gen.py index 2cfc3ca8c..b17d8c0fd 100644 --- a/backend/app/utils/scene_guide_gen.py +++ b/backend/app/utils/scene_guide_gen.py @@ -7,17 +7,17 @@ SCENE_GUIDE_SECTIONS: tuple[dict[str, str], ...] = ( {"id": "a-scene", "title": "A scene", "text": "## A scene\n\nA scenes file is a JSON array of scenes. Each scene is\n`{ id, name, nodes, edges, fields, settings, apps?, customEvents? }`:\n\n- `id`: a unique string, kept stable across edits. `name`: shown to people.\n- `settings.execution` is always `\"interpreted\"`.\n- `settings.refreshInterval`: seconds between renders, the scene's default\n cadence. Never below 3600 when the scene calls a paid API (an image or text\n model) unless the person asked for it.\n- `settings.backgroundColor`: the hex color every render starts from. Set it.\n- `fields`: the scene's state fields (see [Fields and state](#fields-and-state)).\n- `apps`: JavaScript apps the scene carries with it (see [JS apps](#js-apps)).\n- `customEvents`: events of the scene's own (see [Events](#events)).\n\nWhen you change an existing scene, keep its id, its node ids and everything\nyou were not asked to touch exactly as it was, and keep its fields so the\nvalues people saved still apply. Change the minimum that does what was asked."}, {"id": "nodes", "title": "Nodes", "text": "## Nodes\n\nEvery node is `{ id, type, data }`. Positions are optional.\n\n| type | `data` | what it does |\n|---|---|---|\n| `event` | `{ keyword }` | Starts a flow when the event happens. Every scene has one `render` event node; the render flow starts there. |\n| `app` | `{ keyword, config, cache? }` | Runs an app. `keyword` is a catalog app (`render/text`) or a key of the scene's `apps` map. |\n| `code` | `{ codeJS, codeArgs?, codeOutputs?, cache? }` | One JavaScript expression (see [Code nodes](#code-nodes)). |\n| `state` | `{ keyword, value }` | Outputs the scene field named `keyword`; `value` is its default, as a string. |\n| `dispatch` | `{ keyword, config }` | Sends an event when the flow reaches it: `render` for another render, `setSceneState`, `setCurrentScene` or a custom event. `config` is the event's payload. A node of its own, never an app keyword like `dispatch/render`. |\n| `scene` | `{ keyword, config }` | Draws another scene, by its id, inside this one (usually in a `render/split` cell). |\n\n`cache` on an app or code node is\n`{ enabled, inputEnabled, durationEnabled, duration }`: keep the output until\nthe inputs change (`inputEnabled`), until `duration` seconds have passed\n(`durationEnabled`), or both. Cache every network fetch on a sensible\nschedule."}, - {"id": "edges", "title": "Edges", "text": "## Edges\n\nEvery edge is `{ id, source, target, sourceHandle, targetHandle, type }`, and\nboth ends must be nodes of the scene. There are two kinds:\n\n- **The render flow** (`type: \"appNodeEdge\"`, `sourceHandle: \"next\"` →\n `targetHandle: \"prev\"`): the `render` event node, then each render or logic\n app in order. It is one straight chain: never point two `next` edges at one\n `prev`. The apps in it draw on one shared canvas, in order, so never pass the\n canvas between them through fields.\n- **Values** (`type: \"codeNodeEdge\"`, `sourceHandle: \"fieldOutput\"`): a node's\n output into an app field (`targetHandle: \"fieldInput/\"`) or into a\n code node argument (`targetHandle: \"codeField/\"`). Code, state and data\n nodes connect only this way, and their source handle is exactly\n `fieldOutput`, never `fieldOutput/`.\n\nData apps (category `data`) are never in the render flow; they feed values.\nImages are values too: to show one, put a `render/image` app in the render\nflow and wire the image into its `image` field. Never put a data app that\nmakes an image straight into the render flow, and never keep an image in\nstate.\n\nA field of type `node` (`logic/ifElse`'s `thenNode` and `elseNode`,\n`render/split`'s `render_functions`) is an edge, never config:\n`sourceHandle: \"field/\"` on the app → `targetHandle: \"prev\"` on the node\nit runs. A split's cells are `field/render_functions[row][col]`, counted from\n1 and within its `rows` × `columns`; a cell runs a render app or a `scene`\nnode. A `scene` node takes the embedded scene's state on\n`fieldInput/`."}, + {"id": "edges", "title": "Edges", "text": "## Edges\n\nEvery edge is `{ id, source, target, sourceHandle, targetHandle, type }`, and\nboth ends must be nodes of the scene. There are two kinds:\n\n- **Flows** (`type: \"appNodeEdge\"`, `sourceHandle: \"next\"` →\n `targetHandle: \"prev\"`): every `event` node starts one, and the frame runs\n its nodes in order. The render flow starts at the `render` event node and\n holds the render and logic apps that draw; a flow from another event\n (`button`, `tap`, a custom event) holds logic apps and `dispatch` nodes. A\n flow is one straight chain: never point two `next` edges at one `prev`. The\n apps in the render flow draw on one shared canvas, in order, so never pass\n the canvas between them through fields.\n- **Values** (`type: \"codeNodeEdge\"`, `sourceHandle: \"fieldOutput\"`): a node's\n output into an app field (`targetHandle: \"fieldInput/\"`) or into a\n code node argument (`targetHandle: \"codeField/\"`). Code, state and data\n nodes connect only this way, and their source handle is exactly\n `fieldOutput`, never `fieldOutput/`.\n\nData apps (category `data`) are never in a flow; they feed values.\nImages are values too: to show one, put a `render/image` app in the render\nflow and wire the image into its `image` field. Never put a data app that\nmakes an image straight into the render flow, and never keep an image in\nstate.\n\nA field of type `node` (`logic/ifElse`'s `thenNode` and `elseNode`,\n`render/split`'s `render_functions`) is an edge, never config:\n`sourceHandle: \"field/\"` on the app → `targetHandle: \"prev\"` on the node\nit runs. A split's cells are `field/render_functions[row][col]`, counted from\n1 and within its `rows` × `columns`; a cell runs a render app or a `scene`\nnode. A `scene` node takes the embedded scene's state on\n`fieldInput/`."}, {"id": "apps", "title": "Apps", "text": "## Apps\n\nAn app node's `keyword` must be an app that exists: look each one up in the\napp catalog before you use it, with its exact field names, types and options.\n\n- `config` keys are field names of that app, and every value is a string:\n `\"12\"`, `\"true\"`, `\"#ff0000\"`. A `select` field takes one of its options'\n values.\n- A required field without a default is set in `config` or fed by an edge. An\n image field (`render/image`'s `image`) is always fed by an edge.\n- Outputs are typed. An image goes only into an image field; text and JSON go\n only into other fields; a float goes into a float field and an integer into\n an integer field.\n- Catalog apps with a `repo/apps//` keyword are JavaScript app\n templates, not keywords a frame knows. A scene uses one by copying it into\n its own `apps` map under ``, with the template's `name`, `category`,\n `description`, `fields`, `output` and `sources`, and\n `\"origin\": \"repo/apps//\"`; its nodes then use `keyword: \"\"`.\n The editor and the scene AI do this for you; anything else that writes scene\n JSON does it itself.\n- Prefer an app that exists to one you write. Weather, photos, calendars,\n the time, AI text and images, home sensors and charts all have catalog apps,\n tested on every kind of frame: search the catalog for the subject before\n writing a JS app or a code node, and to change a scene, add the app that does\n the new part rather than rewriting an app that is already there.\n- When no app fits, build the logic from code nodes and generic apps\n (`render/text`, `render/image`, `render/svg`) or write a JS app.\n\nApps used often: `render/image` (an image into the canvas, with `placement`\ncover, contain, stretch, center…), `render/text` (wrapping text, with\n`richText: \"basic-caret\"` for caret styling), `render/svg`, `render/gradient`\n(`startColor`, `endColor`, `angle`), `render/color`, `render/split` (`rows`,\n`columns`, cells as above), `data/clock` (the time as a string; its `format`\noptions are its own, not the code-node tokens), `data/downloadUrl` (a URL's\nbody as text), `data/parseJson`, `logic/setAsState` (a value into state,\n`stateKey` plus `valueString` or `valueJson`) and `logic/ifElse`. For a chart,\ncopy the `repo/apps/image/chart` JS app into the scene: line, bar or area, from\na `data` field, wired into `render/image`. `render/color` and `render/gradient`\nfill the whole canvas and hide whatever was drawn before them, so they go first\nin the render flow."}, {"id": "fields-and-state", "title": "Fields and state", "text": "## Fields and state\n\nThe scene's state is a JSON object every node can read. `fields` declares the\npart people can see and change:\n`{ name, type, label?, description?, value, options?, access?, persist?, required?, role? }`.\n\n- Types: `string`, `text`, `float`, `integer`, `boolean`, `color`, `date`,\n `json`, `node`, `scene`, `image`, `font`, `select`, `path`.\n- `value` is the default, always a string (`\"600\"`, `\"true\"`, `\"Brussels\"`).\n- `options` (select only) is a list of strings, `[\"dark\", \"light\"]`, or of\n `{ \"value\": \"dark\", \"label\": \"Dark mode\" }` pairs. State and config always\n hold the value, never the label.\n- `access: \"public\"` and `persist: \"disk\"` make a field something people\n customize, and it keeps their value across restarts. Prefer them for\n anything a person would want to change.\n- A `state` node outputs one field: `data.keyword` is the field's name and\n `data.value` its default as a string. Use one state node per consumer, so\n the diagram stays readable.\n- Every scene has a public `refreshInterval` field (\"Refresh interval\n (seconds)\", read as `state.refreshInterval`), whose default is\n `settings.refreshInterval`. Never add your own interval field, and never use\n `logic/nextSleepDuration` to make the cadence configurable: that app sets the\n sleep after one render only. When the interval deserves its own label\n (\"Seconds per image\"), declare one `float` field with\n `\"role\": \"refreshInterval\"`; its value is then the interval."}, {"id": "code-nodes", "title": "Code nodes", "text": "## Code nodes\n\nA code node holds one JavaScript **expression** in `data.codeJS`. Wrap\nseveral statements in an immediately called function that returns the value.\n\n- It has exactly one output: one `codeOutputs` entry, `{ name, type }`, with\n type `string`, `json`, `integer`, `float` or `boolean`. It cannot output an\n image: to draw generated SVG, output a string into a\n `render/svg` node's `svg` field.\n- An argument exists only when it is declared in `data.codeArgs`\n (`{ name, type }`) and fed by an edge into `codeField/`. Arguments are\n constant bindings: `const lat = Number(lat)` is an error, write\n `const latNum = Number(lat)`. Never name one `state`, `args`, `context`,\n `console`, `parseTs`, `format` or `now`: the code would see the global.\n- The only globals are `state.`, the arguments, `context` (`event`,\n `payload`, `loopIndex`, `loopKey`, `hasImage`, and `imageWidth` /\n `imageHeight`, the canvas being drawn on, undefined outside a render),\n `console.log/warn/error`, and three time helpers: `now()` (seconds since the\n epoch), `format(ts, pattern)` (text in the frame's time zone) and\n `parseTs(pattern, text)` (seconds).\n- There is no `frameos` object, no `fetch` and no HTTP in a code node. Fetch\n with a data app (`data/downloadUrl`, `data/parseJson`…) and wire its output\n into an argument. `Math`, `JSON` and `Date` work, but take local time from\n `format(now(), …)`.\n\n`format()` and `parseTs()` patterns use curly tokens, and letters outside the\nbraces are copied as they are (`\"HH:mm\"` prints `HH:mm`):\n\n| token | output |\n|---|---|\n| `{year/4}` `{year/2}` | `2026` / `26` |\n| `{month/2}` `{month}` | `08` / `8` |\n| `{month/n}` `{month/n/3}` | `August` / `Aug` |\n| `{day/2}` `{day}` | `05` / `5` |\n| `{hour/2}` `{hour}` | `09` / `9` (24 h) |\n| `{hour/2/ap}` `{hour/ap}` `{am/pm}` | `09` / `9` / `am` (12 h) |\n| `{minute/2}` `{second/2}` | `05` / `07` |\n| `{weekday}` `{weekday/3}` `{weekday/2}` | `Monday` / `Mon` / `Mo` |\n\n`format(now(), \"{hour/2}:{minute/2}\")` gives `09:05`;\n`Number(format(now(), \"{hour}\"))` gives `9`."}, - {"id": "js-apps", "title": "JS apps", "text": "## JS apps\n\nA JS app is a small TypeScript or JavaScript module that runs on the frame in\nQuickJS. A scene carries its own in the top-level `apps` map:\n\n```json\n\"apps\": {\n \"myPanel\": {\n \"name\": \"My panel\", \"category\": \"data\", \"description\": \"…\",\n \"fields\": [{ \"name\": \"city\", \"type\": \"string\", \"value\": \"Brussels\" }],\n \"output\": [{ \"name\": \"image\", \"type\": \"image\" }],\n \"sources\": { \"config.json\": \"…the same name, category, fields and output…\", \"app.ts\": \"…\" }\n }\n}\n```\n\nand its nodes use `keyword: \"myPanel\"`.\n\n**Files.** `config.json` and `app.ts` (or `.tsx`, `.js`, `.jsx`), plus any\nhelper modules and `.json` data files, imported with relative paths\n(`import { row } from \"./lib/row\"`, `import icons from \"./icons.json\"`). Only\nthe app's own files resolve: no npm packages, no `require()`, no dynamic\n`import()`. JSX works only in `.tsx` / `.jsx` files. Split an app when\n`app.ts` passes a few hundred lines, or when data would swamp the code. Keep\napps small: a scene carries a copy of every app it uses, and an ESP32 keeps\nall its scenes in 512 KiB.\n\n**Exports.**\n\n- A `data` app exports `get(app, context)`, which returns the value its\n `output` names: a string, JSON, or for an image `frameos.svg(...)`,\n `frameos.image(...)` or an image from `frameos.fetchImage`. It is not in the\n render flow: wire its output into `render/image`'s `image` field or another\n node's input.\n- A `logic` app in the render flow exports `run(app, context)`, for what it\n does rather than returns (state, the next sleep).\n- Never make a JS app with category `render`: it draws nothing.\n- `init(app)` is optional and runs each time the app's interpreter starts:\n once per scene on a Pi, before every render on an ESP32 and in a preview\n running as one.\n\n**What the app sees.**\n\n- `app.config`: its fields, with what the node's config and edges set.\n- `app.state`: the scene's state, read by key. Write it with\n `frameos.setState(key, value)`.\n- `app.frame`: the frame, read only: `width`, `height` (always set, also in\n the preview), `rotate`, `timeZone`, `assetsPath`, `debug`, and `embedded`\n (true on an ESP32, and in a preview running as one).\n- `app.nodeId`, `app.nodeName`.\n- `context`: this run: `event`, `payload`, and when an image is flowing\n through, `imageWidth` / `imageHeight`. That is the area being drawn into (a\n split cell, say), which can be smaller than `app.frame`.\n- `frameos`: everything the app can do: fetch (`fetchJson`, `fetchText`,\n `httpRequest`, `fetchImage`), make images (`svg`, `image`), log (`log`,\n `error`), keep values (`setState`, `remember`, `recall`), read the account's\n keys (`getSetting`, only for namespaces the app's `config.json` lists under\n `settings`), set the next sleep (`setNextSleep`, this render only), and\n read and write frame assets and streams. The exact signatures are in the\n [JS API reference](#js-api-reference).\n- `Date` reads local time in the frame's time zone. There are no `format`,\n `now` or `parseTs` here: those belong to code nodes.\n\n**Where an app keeps its values.** Three places, the same on every device:\n\n| the value | goes in | lasts |\n|---|---|---|\n| what the rest of the scene should see: a position a button changes, a photo's metadata | `frameos.setState(key, value)`, read back as `app.state[key]` | the scene's state: every node reads it, state nodes see changes, a declared field can persist and show in the frame's controls |\n| the app's own, needed on the next render: a fetched list, a position | `frameos.remember(key, value)`, read back with `frameos.recall(key)` | until the scene unloads, private to the node; JSON, at most 512 KB per node on an ESP32; `remember` answers `false` when it does not fit, and `null` forgets |\n| scratch for this render: lookup tables built when the module loads, intermediate results | module-level variables | one render |\n\nAn ESP32 starts every render with a fresh module, so a counter in a module\nvariable shows the first photo forever. A Pi keeps its modules longer, but an\napp must not count on it. The editor's preview runs as an ESP32 when its memory\nlimit is set to an ESP32 board or it previews an ESP32 frame, and as a Pi\notherwise, so an app can be tried both ways. An animation works out each frame\nfrom the clock (`Date.now()`), not from the frame before.\n\n**Errors.** Throw. An error thrown from `get()` fails the node: it is logged\nwith its source line, an image field gets the message painted, and the next\nrender tries again. `frameos.fetchImage` never throws; it answers\n`{ image: null, error }`, and the app throws with a message of its own.\n\n**Photos.** `frameos.fetchImage(url, { headers, saveAsset })` downloads and\ndecodes natively, and the bytes never enter the JS heap. Return its `image`.\nUse it for every photo, and `httpRequest` only for the JSON around it.\n\n**Assets and permissions.** Asset paths are relative to the frame's assets\nfolder (`app.frame.assetsPath`, `/srv/assets` by default): uploaded photos,\nfonts, what other scenes saved. What an app may do there is its own\nconfig.json's `\"permissions\"` list:\n\n- `\"assets\"`: read the assets folder (`listAssets`, `readAsset`,\n `loadAssetImage`, a stream opened with `\"r\"`, ...).\n- `\"assets-write\"`: also write and delete in it (`writeAsset`,\n `appendAsset`, `deleteAsset`, a stream opened with `\"w\"` or `\"a\"`).\n- `\"files\"` and `\"shell\"` belong to built-in apps: Local Image reads any\n folder, the screenshot and camera apps run processes.\n\nSaving the scene fills a JavaScript app's list from its code, so you rarely\nwrite it. The frame gives the app exactly that list: with neither `\"assets\"`\nnor `\"assets-write\"`, its asset calls live in a folder of its own,\n`scenes//`; with `\"assets\"` alone, its writes are refused. An app\nwith no list asks for nothing, and a list written by hand is replaced on the\nnext save. A scene asks for what its apps ask for, nothing more, and every\nsurface shows it as tags (\"reads assets folder\", \"writes assets folder\",\n\"reads all files\", \"runs shell commands\", and \"uses OpenAI key\" for each\nsettings namespace an app reads). Installing a scene is the approval of its\ntags, and an update says which ones it adds, so ask for what the app needs\nand no more.\n`fetchImage({ saveAsset })` is the frame caching a download, not the app\nusing the folder: it needs no permission. (Frames before FrameOS 2026.10.16\nignore the lists.)\n\n**Local network.** On a cloud-managed frame, scenes reach addresses on the\nframe's own network (a Home Assistant at `homeassistant.local`, a NAS,\nanything at `192.168.x.x`) only while its local network access is on. That is\na frame setting the owner turns on, not something an app or a scene asks for.\nWith it off, such a request fails with \"Local network access is off for this\nframe\". A scene that needs a local service says so in its description, so the\nowner knows to turn it on. Standalone and self-hosted frames are not limited.\n\n**Big responses.** A response body becomes a JS string. When the app needs\nonly what follows some marker in a large page, pass\n`httpRequest(url, { find: \"marker\", findLength: 400 })`: the body stays native\nand the answer is `{ status, found }`, the text at each occurrence.\n\n**Newer than some frames.** `frameos.remember` / `recall`, `httpRequest`'s\n`find`, `fetchImage`'s `exif`, `app.frame.embedded` / `debug`, and a throw\npainting its message all need FrameOS 2026.10.13; a frame on an older release\nlacks them. A scene meant for such frames keeps to the rest.\n\n**Code style.** Normal formatting, one statement per line, descriptive names,\nshort comments where the logic is not obvious. People open and edit this code\nin the editor, so never minify it."}, + {"id": "js-apps", "title": "JS apps", "text": "## JS apps\n\nA JS app is a small TypeScript or JavaScript module that runs on the frame in\nQuickJS. A scene carries its own in the top-level `apps` map:\n\n```json\n\"apps\": {\n \"myPanel\": {\n \"name\": \"My panel\", \"category\": \"data\", \"description\": \"…\",\n \"fields\": [{ \"name\": \"city\", \"type\": \"string\", \"value\": \"Brussels\" }],\n \"output\": [{ \"name\": \"image\", \"type\": \"image\" }],\n \"sources\": { \"config.json\": \"…the same name, category, fields and output…\", \"app.ts\": \"…\" }\n }\n}\n```\n\nand its nodes use `keyword: \"myPanel\"`.\n\n**Files.** `config.json` and `app.ts` (or `.tsx`, `.js`, `.jsx`), plus any\nhelper modules and `.json` data files, imported with relative paths\n(`import { row } from \"./lib/row\"`, `import icons from \"./icons.json\"`). Only\nthe app's own files resolve: no npm packages, no `require()`, no dynamic\n`import()`. JSX works only in `.tsx` / `.jsx` files. Split an app when\n`app.ts` passes a few hundred lines, or when data would swamp the code. Keep\napps small: a scene carries a copy of every app it uses, and an ESP32 keeps\nall its scenes in 512 KiB.\n\n**Exports.** Where the app's node sits decides which function the frame\ncalls, and it calls no other:\n\n- A node that feeds a field (`fieldOutput` → `fieldInput/`) calls\n `get(app, context)`. That is a `data` app: `get` returns the value its\n `output` names, a string, JSON, or for an image `frameos.svg(...)`,\n `frameos.image(...)` or an image from `frameos.fetchImage`. To show an\n image, wire it into `render/image`'s `image` field.\n- A node in a flow (after an event node, `next` → `prev`) calls\n `run(app, context)`. That is a `logic` app: `run` returns nothing and acts,\n with `frameos.setState`, `frameos.setNextSleep` and the like.\n- So a `get()` app in a flow does nothing, and a `run()` app feeding a field\n gives an empty value. Write the one its node needs.\n- A button that changes the picture is a flow of its own: the `button` event\n node, a logic app, then a `dispatch` node with `keyword: \"render\"`. The\n app's `app.ts` is\n `export function run(app) { frameos.setState(\"revealed\", !app.state.revealed) }`,\n and the render flow reads `revealed` from state. `logic/setAsState` does the\n same for one value without a JS app.\n- Never make a JS app with category `render`: it draws nothing.\n- `init(app)` is optional and runs each time the app's interpreter starts:\n once per scene on a Pi, before every render on an ESP32 and in a preview\n running as one.\n\n**What the app sees.**\n\n- `app.config`: its fields, with what the node's config and edges set.\n- `app.state`: the scene's state, read by key. Write it with\n `frameos.setState(key, value)`.\n- `app.frame`: the frame, read only: `width`, `height` (always set, also in\n the preview), `rotate`, `timeZone`, `assetsPath`, `debug`, and `embedded`\n (true on an ESP32, and in a preview running as one).\n- `app.nodeId`, `app.nodeName`.\n- `context`: this run: `event`, `payload`, and when an image is flowing\n through, `imageWidth` / `imageHeight`. That is the area being drawn into (a\n split cell, say), which can be smaller than `app.frame`.\n- `frameos`: everything the app can do: fetch (`fetchJson`, `fetchText`,\n `httpRequest`, `fetchImage`), make images (`svg`, `image`), log (`log`,\n `error`), keep values (`setState`, `remember`, `recall`), read the account's\n keys (`getSetting`, only for namespaces the app's `config.json` lists under\n `settings`), set the next sleep (`setNextSleep`, this render only), and\n read and write frame assets and streams. The exact signatures are in the\n [JS API reference](#js-api-reference).\n- `Date` reads local time in the frame's time zone. There are no `format`,\n `now` or `parseTs` here: those belong to code nodes.\n\n**Where an app keeps its values.** Three places, the same on every device:\n\n| the value | goes in | lasts |\n|---|---|---|\n| what the rest of the scene should see: a position a button changes, a photo's metadata | `frameos.setState(key, value)`, read back as `app.state[key]` | the scene's state: every node reads it, state nodes see changes, a declared field can persist and show in the frame's controls |\n| the app's own, needed on the next render: a fetched list, a position | `frameos.remember(key, value)`, read back with `frameos.recall(key)` | until the scene unloads, private to the node; JSON, at most 512 KB per node on an ESP32; `remember` answers `false` when it does not fit, and `null` forgets |\n| scratch for this render: lookup tables built when the module loads, intermediate results | module-level variables | one render |\n\nAn ESP32 starts every render with a fresh module, so a counter in a module\nvariable shows the first photo forever. A Pi keeps its modules longer, but an\napp must not count on it. The editor's preview runs as an ESP32 when its memory\nlimit is set to an ESP32 board or it previews an ESP32 frame, and as a Pi\notherwise, so an app can be tried both ways. An animation works out each frame\nfrom the clock (`Date.now()`), not from the frame before.\n\n**Errors.** Throw. An error thrown from `get()` fails the node: it is logged\nwith its source line, an image field gets the message painted, and the next\nrender tries again. `frameos.fetchImage` never throws; it answers\n`{ image: null, error }`, and the app throws with a message of its own.\n\n**Photos.** `frameos.fetchImage(url, { headers, saveAsset })` downloads and\ndecodes natively, and the bytes never enter the JS heap. Return its `image`.\nUse it for every photo, and `httpRequest` only for the JSON around it.\n\n**Assets and permissions.** Asset paths are relative to the frame's assets\nfolder (`app.frame.assetsPath`, `/srv/assets` by default): uploaded photos,\nfonts, what other scenes saved. What an app may do there is its own\nconfig.json's `\"permissions\"` list:\n\n- `\"assets\"`: read the assets folder (`listAssets`, `readAsset`,\n `loadAssetImage`, a stream opened with `\"r\"`, ...).\n- `\"assets-write\"`: also write and delete in it (`writeAsset`,\n `appendAsset`, `deleteAsset`, a stream opened with `\"w\"` or `\"a\"`).\n- `\"files\"` and `\"shell\"` belong to built-in apps: Local Image reads any\n folder, the screenshot and camera apps run processes.\n\nSaving the scene fills a JavaScript app's list from its code, so you rarely\nwrite it. The frame gives the app exactly that list: with neither `\"assets\"`\nnor `\"assets-write\"`, its asset calls live in a folder of its own,\n`scenes//`; with `\"assets\"` alone, its writes are refused. An app\nwith no list asks for nothing, and a list written by hand is replaced on the\nnext save. A scene asks for what its apps ask for, nothing more, and every\nsurface shows it as tags (\"reads assets folder\", \"writes assets folder\",\n\"reads all files\", \"runs shell commands\", and \"uses OpenAI key\" for each\nsettings namespace an app reads). Installing a scene is the approval of its\ntags, and an update says which ones it adds, so ask for what the app needs\nand no more.\n`fetchImage({ saveAsset })` is the frame caching a download, not the app\nusing the folder: it needs no permission. (Frames before FrameOS 2026.10.16\nignore the lists.)\n\n**Local network.** On a cloud-managed frame, scenes reach addresses on the\nframe's own network (a Home Assistant at `homeassistant.local`, a NAS,\nanything at `192.168.x.x`) only while its local network access is on. That is\na frame setting the owner turns on, not something an app or a scene asks for.\nWith it off, such a request fails with \"Local network access is off for this\nframe\". A scene that needs a local service says so in its description, so the\nowner knows to turn it on. Standalone and self-hosted frames are not limited.\n\n**Big responses.** A response body becomes a JS string. When the app needs\nonly what follows some marker in a large page, pass\n`httpRequest(url, { find: \"marker\", findLength: 400 })`: the body stays native\nand the answer is `{ status, found }`, the text at each occurrence.\n\n**Newer than some frames.** `frameos.remember` / `recall`, `httpRequest`'s\n`find`, `fetchImage`'s `exif`, `app.frame.embedded` / `debug`, and a throw\npainting its message all need FrameOS 2026.10.13; a frame on an older release\nlacks them. A scene meant for such frames keeps to the rest.\n\n**Code style.** Normal formatting, one statement per line, descriptive names,\nshort comments where the logic is not obvious. People open and edit this code\nin the editor, so never minify it."}, {"id": "images-svg-and-text", "title": "Images, SVG and text", "text": "## Images, SVG and text\n\n`render/svg` and `frameos.svg()` use a strict renderer. Any tag outside this\nlist fails the whole document (\"Failed to render SVG\"; in a JS app, \"No image\nprovided\"): `svg` (with a `viewBox`), `g`, `path`, `rect`, `circle`,\n`ellipse`, `line`, `polyline`, `polygon`, `text` / `tspan`, `linearGradient`,\n`radialGradient`, `defs` (for gradients only), `title`, `desc`. Not supported:\n`use`, `image`, `filter`, `mask`, `clipPath`, `pattern`, `style`,\n`foreignObject`, `symbol`, `marker`.\n\n- Gradients always have `gradientUnits=\"userSpaceOnUse\"` (the SVG default\n fails the document), coordinates in viewBox units, and `` children\n with an `offset` from 0 to 1, a hex `stop-color` and an optional\n `stop-opacity`. Fills only: a stroke cannot use a gradient. For a\n full-screen gradient, `render/gradient` is simpler.\n\n ```svg\n \n \n \n \n \n \n \n ```\n- SVG is XML. Escape every dynamic string you put in markup, text and\n attributes alike (`&` as `&`, `<` as `<`, `>` as `>`, `\"` as\n `"`), through one helper: a single raw `&` fails the whole document.\n- `` is drawn as glyph outlines, so fill, stroke, gradients, opacity and\n transforms work on it. Supported: `x`, `y`, `dx` / `dy` (the first value),\n `font-family`, `font-size`, `font-weight`, `font-style`, `text-anchor`,\n `dominant-baseline`, and `` children. Not supported: wrapping,\n `textPath`, `textLength`, `letter-spacing`, per-glyph positions, emoji. One\n line per `text` or `tspan`: break lines yourself.\n- `render/text` wraps text and takes caret styling with\n `richText: \"basic-caret\"`: `^(16)` size, `^(#FF00FF)` color,\n `^(PTSans-Bold.ttf)` font, `^(underline)`, combined as `^(16,#FF0000)`,\n `^(reset)` to reset. Prefer it for paragraphs.\n- Fonts: `Ubuntu-*`, `PTSans-*`, `FiraGO-*`, `CormorantGaramond-*`,\n `LiberationSans-*`, `LiberationSerif-*`, `LiberationMono-*`,\n `CascadiaMono`, `ComicRelief`, `Galindo-Regular`, `Peralta-Regular` (all\n `.ttf`), plus the fonts people upload. An SVG `font-family` matches them\n loosely (\"PT Sans\" finds `PTSans-Regular.ttf`); an unknown one falls back to\n the default face. On an ESP32, SVG text always uses the built-in Ubuntu\n face, whatever `font-family` says."}, {"id": "layout-and-color", "title": "Layout and color", "text": "## Layout and color\n\n- The same scene is installed on 800×480, 480×800, 1200×825 and 1600×1200\n panels. Work out every coordinate, size and font size from the frame (in a\n JS app `app.frame.width` / `height`, or `context.imageWidth` / `imageHeight`\n when drawing into a cell; in a code node `context.imageWidth` /\n `imageHeight`), for example `unit = Math.min(W, H) / 100`, and emit\n `` with those numbers. Never\n hard-code 800×600, and never add width or height fields \"just in case\".\n- Branch on orientation: when `H > W`, stack columns vertically.\n- Keep a margin of about 4% of the shorter side, and never draw past the\n edge. Estimate a line's characters as `width / (fontSize * 0.55)`, wrap and\n truncate with an ellipsis yourself in SVG, and shrink a headline that does\n not fit. An 800×480 panel reads well at 24–48 px body text, 1600×1200 at\n 40–80 px.\n- Frames are color e-ink (a Spectra 6 panel dithers to black, white, red,\n yellow, green and blue), black-and-white e-ink, or HDMI and LCD panels.\n Unless asked for monochrome, design with color, but calmly: soft gradients\n of two related tones, deep or muted colors (navy, teal, forest, plum,\n terracotta, ochre, charcoal, warm off-white), and one accent color for the\n key figure. Never put pure primaries side by side or use them as large\n fills. Keep text-to-background contrast high so it survives dithering."}, {"id": "small-frames", "title": "Small frames", "text": "## Small frames\n\nThe scene that runs on a Raspberry Pi with hundreds of MB also gets installed\non an ESP32, where a few MB hold the canvas, the JS heap (at most 8 MB per\nscene), the fonts and the network. Write every code node and JS app for that\ndevice. What counts is what stays alive and how much a render allocates, not\nterse code:\n\n- Parsed objects cost 10–20× their JSON text; strings cost about 1×. Never\n embed big object or array literals (GeoJSON, coordinate lists, lookup\n tables): ship such data as compact strings, SVG path `d` strings or\n delimited text split when needed.\n- Keep only what you draw. Take the fields you need out of a fetched payload\n at once, cut lists to what fits on screen before mapping over them, ask an\n API for fewer rows and fields when it allows, and never keep whole responses\n in module variables, `remember` or state.\n- Build output once: collect parts in one array and `join(\"\")` at the end.\n Keep generated SVG small: round coordinates to one decimal, thin polylines\n to what the pixels can show, scale a shape with one\n ``, and stay in the hundreds of\n elements, not thousands.\n- A scene that renders every second or faster sets up its buffers in `init()`\n and updates them in place: flat arrays of numbers, fixed-size pools, no new\n objects, closures or big strings per frame, no fetch or `JSON.parse` while\n rendering, and a hard cap on particle counts. Those buffers are rebuilt on\n every render on an ESP32, so positions come from the clock.\n- The runtime stops a script after 20 seconds of JavaScript on an ESP32 (30\n on a Pi), and a render or an event after 90 seconds in all (120 on a Pi, 60\n in the preview), HTTP included."}, {"id": "events", "title": "Events", "text": "## Events\n\nAn `event` node starts a flow when its event happens; a `dispatch` node sends\none. A scene listens for the events in the table below with an `event` node\nwhose `keyword` is the event's name, and its flow reads the event's payload as\n`context.payload` (in a JS app, `context.payload` too). A scene can also send\nand listen for events of its own: any other name is a custom event, declared\nin the scene's `customEvents` as `{ name }` (at most 63 characters). A\nschedule or the cloud may send one only when its declaration lists them in\n`origins`: `{ \"name\": \"nextPage\", \"origins\": [\"schedule\"] }`.\n\n| event | an `event` node hears it | a `dispatch` node sends it | `context.payload` | where | what |\n|---|---|---|---|---|---|\n| `init` | yes | | | all | Once per scene, when it is first loaded and its apps are ready — before its first open and render. |\n| `render` | yes | yes | | all | Render the scene. |\n| `open` | yes | | `sceneId` scene | all | Every time the scene becomes the frame's current scene: at boot, and whenever the frame switches to it, including switching back. |\n| `close` | yes | | | all | When the frame switches away from the scene to another one. |\n| `keyDown` | yes | | `code` string, `key` string, `repeat` boolean, `shift` boolean, `ctrl` boolean, `alt` boolean, `meta` boolean, `linuxCode`? integer | Pi, preview | When a key is pressed on a keyboard (or held: `repeat`). |\n| `keyUp` | yes | | `code` string, `key` string, `shift` boolean, `ctrl` boolean, `alt` boolean, `meta` boolean, `linuxCode`? integer | Pi, preview | When a key is released on a keyboard |\n| `textInput` | yes | | `text` string | Pi, preview | Text a key press produced under the frame's keyboard layout, one event per printable key (Ctrl/Alt/Meta chords produce none). |\n| `pointerMove` | yes | | `x` integer, `y` integer, `pointerId` integer, `pointerType` string, `buttons` integer | Pi, preview | When a pointer (a finger, a mouse, a pen) moves |\n| `pointerDown` | yes | | `x` integer, `y` integer, `button` integer, `buttons` integer, `pointerId` integer, `pointerType` string | Pi, preview | When a pointer button is pressed or a finger touches the panel. |\n| `pointerUp` | yes | | `x` integer, `y` integer, `button` integer, `buttons` integer, `pointerId` integer, `pointerType` string | Pi, preview | When a pointer button is released or a finger lifts |\n| `pointerCancel` | yes | | `x` integer, `y` integer, `pointerId` integer, `pointerType` string | Pi, preview | When a pointer that was down is lost without a release: the queue overflowed, the device went away. |\n| `mouseMove` | yes | | `x` integer, `y` integer | Pi, preview | When a mouse moves. |\n| `mouseDown` | yes | | `button` integer | Pi, preview | When a mouse button is pressed. |\n| `mouseUp` | yes | | `button` integer | Pi, preview | When a mouse button is released. |\n| `tap` | yes | | `x` integer, `y` integer, `pointerId` integer, `pointerType` string | Pi, preview | A press and release in one place, within `gestures.tapMs`. |\n| `doubleTap` | yes | | `x` integer, `y` integer, `pointerId` integer, `pointerType` string | Pi, preview | A second `tap` in the same place within `gestures.doubleTapMs` of the first. |\n| `longPress` | yes | | `x` integer, `y` integer, `durationMs` integer, `pointerId` integer, `pointerType` string | Pi, preview | A pointer held in one place for `gestures.longPressMs`. |\n| `swipe` | yes | | `direction` string, `x` integer, `y` integer, `startX` integer, `startY` integer, `pointerId` integer, `pointerType` string | Pi, preview | A press and a release at least `gestures.swipeMinPx` apart, in one of four directions. |\n| `wheel` | yes | | `deltaX` integer, `deltaY` integer, `x` integer, `y` integer | Pi, preview | When a mouse wheel is scrolled. |\n| `turnOn` | yes | yes | | all | Turn the screen on |\n| `turnOff` | yes | yes | | all | Turn the screen off |\n| `gpio` | yes | | `pin` integer, `label` string, `role` string, `action` string, `durationMs`? integer, `wake`? boolean, `atMs`? integer, `level`? integer | all | A GPIO button going `down` or `up`, the moment it happens. |\n| `button` | yes | | `pin` integer, `label` string, `role` string, `action` string, `durationMs`? integer, `wake`? boolean, `atMs`? integer | all | A GPIO button pressed: a `press`, a `doublePress` or a `longPress`, one event per press. |\n| `setSceneState` | | yes | `state`? json, `render`? boolean | all | Set the scene's public state. |\n| `setCurrentScene` | | yes | `sceneId` scene, `state`? json | all | Switch the frame to another scene. |"}, {"id": "js-api-reference", "title": "JS API reference", "text": "## JS API reference\n\nWhat a JS app sees, exactly as the editor's type checker declares it. Code\nnodes see none of this (see [Code nodes](#code-nodes)).\n\n```ts\ntype FrameOSJson = null | boolean | number | string | FrameOSJson[] | { [key: string]: FrameOSJson };\n\ninterface FrameOSAppConfig {\n [key: string]: any;\n}\n\ninterface FrameOSImageSpec {\n width?: number;\n height?: number;\n color?: string;\n opacity?: number;\n svg?: string;\n dataUrl?: string;\n base64?: string;\n [key: string]: any;\n}\n\ninterface FrameOSImageRef {\n __frameosType: \"imageRef\";\n id: number;\n width: number;\n height: number;\n}\n\ninterface FrameOSNodeRef {\n __frameosType: \"node\";\n nodeId: number;\n}\n\ninterface FrameOSSceneRef {\n __frameosType: \"scene\";\n sceneId: string;\n}\n\ninterface FrameOSColorRef {\n __frameosType: \"color\";\n color: string;\n}\ninterface FrameOSStreamRef { __frameosType: \"streamRef\"; id: number; }\n\ninterface FrameOSApp {\n nodeId: number;\n nodeName: string;\n category: string;\n config: FrameOSAppConfig;\n state: Record;\n frame: {\n width: number;\n height: number;\n rotate: number;\n assetsPath: string;\n timeZone: string;\n /** The frame's debug setting (FrameOS 2026.10.13+; undefined before). */\n debug?: boolean;\n /** An ESP32-class runtime, or the preview running as one (FrameOS 2026.10.13+; undefined before). */\n embedded?: boolean;\n };\n initialized?: boolean;\n log(...args: any[]): void;\n logError(...args: any[]): void;\n [key: string]: any;\n}\n\n/** What each built-in scene event carries in `context.payload` (docs/events.md). */\ninterface FrameOSEventPayloads {\n /** Once per scene, when it is first loaded and its apps are ready — before its first open and render. */\n init: Record;\n /** Render the scene. Triggered according to the frame's refresh interval, or when explicitly requested. */\n render: Record;\n /** Every time the scene becomes the frame's current scene: at boot, and whenever the frame switches to it, including switching back. */\n open: { sceneId: string };\n /** When the frame switches away from the scene to another one. Carries no payload. */\n close: Record;\n /** When a key is pressed on a keyboard (or held: `repeat`). `code` is the physical key (`KeyA`, `ArrowLeft`), `key` what it means under the frame's keyboard layout (`a`, `A`, `Enter`). */\n keyDown: { code: string; key: string; repeat: boolean; shift: boolean; ctrl: boolean; alt: boolean; meta: boolean; linuxCode?: number };\n /** When a key is released on a keyboard */\n keyUp: { code: string; key: string; shift: boolean; ctrl: boolean; alt: boolean; meta: boolean; linuxCode?: number };\n /** Text a key press produced under the frame's keyboard layout, one event per printable key (Ctrl/Alt/Meta chords produce none). Made by the dispatcher from `keyDown`; a preview forwards the browser's `beforeinput`. */\n textInput: { text: string };\n /** When a pointer (a finger, a mouse, a pen) moves (not logged) */\n pointerMove: { x: number; y: number; pointerId: number; pointerType: string; buttons: number };\n /** When a pointer button is pressed or a finger touches the panel (not logged). The position is on every pointer event. */\n pointerDown: { x: number; y: number; button: number; buttons: number; pointerId: number; pointerType: string };\n /** When a pointer button is released or a finger lifts (not logged) */\n pointerUp: { x: number; y: number; button: number; buttons: number; pointerId: number; pointerType: string };\n /** When a pointer that was down is lost without a release: the queue overflowed, the device went away. A scene holding a drag lets go. */\n pointerCancel: { x: number; y: number; pointerId: number; pointerType: string };\n /** When a mouse moves (not logged). An alias of `pointerMove` with its old payload. */\n mouseMove: { x: number; y: number };\n /** When a mouse button is pressed (not logged). An alias of `pointerDown` with its old payload. */\n mouseDown: { button: number };\n /** When a mouse button is released (not logged). An alias of `pointerUp` with its old payload. */\n mouseUp: { button: number };\n /** A press and release in one place, within `gestures.tapMs`. Made by the dispatcher from `pointerDown` / `pointerUp` on every host. */\n tap: { x: number; y: number; pointerId: number; pointerType: string };\n /** A second `tap` in the same place within `gestures.doubleTapMs` of the first. The first tap is delivered too. */\n doubleTap: { x: number; y: number; pointerId: number; pointerType: string };\n /** A pointer held in one place for `gestures.longPressMs`. Delivered while it is still held where the host ticks the dispatcher, on release otherwise; no `tap` follows it. */\n longPress: { x: number; y: number; durationMs: number; pointerId: number; pointerType: string };\n /** A press and a release at least `gestures.swipeMinPx` apart, in one of four directions. */\n swipe: { direction: string; x: number; y: number; startX: number; startY: number; pointerId: number; pointerType: string };\n /** When a mouse wheel is scrolled (not logged). Deltas are wheel notches; deltaY is positive when scrolling down (towards the user), deltaX when scrolling right. */\n wheel: { deltaX: number; deltaY: number; x: number; y: number };\n /** Turn the screen on */\n turnOn: Record;\n /** Turn the screen off */\n turnOff: Record;\n /** A GPIO button going `down` or `up`, the moment it happens. `up` says how long it was held. */\n gpio: { pin: number; label: string; role: string; action: string; durationMs?: number; wake?: boolean; atMs?: number; level?: number };\n /** A GPIO button pressed: a `press`, a `doublePress` or a `longPress`, one event per press. */\n button: { pin: number; label: string; role: string; action: string; durationMs?: number; wake?: boolean; atMs?: number };\n /** Set the scene's public state. This is not used for private state changes. */\n setSceneState: { state?: any; render?: boolean };\n /** Switch the frame to another scene. An optional `state` object is applied to that scene's public state. */\n setCurrentScene: { sceneId: string; state?: any };\n}\n\ninterface FrameOSContext {\n /** The name of the event this run is handling. */\n event: keyof FrameOSEventPayloads | (string & {});\n /** That event's payload. */\n payload: any;\n /** Index of the current iteration inside a loop. */\n loopIndex: number;\n /** Key of the current iteration inside a loop. */\n loopKey: string;\n /** Whether the run carries a canvas. */\n hasImage: boolean;\n /** Seconds until the next render. */\n nextSleep: number;\n /** The canvas, when there is one. */\n image?: FrameOSImageRef;\n /** Width of the canvas. In scene pixels, of the canvas this run draws on (a cell's inside render/split); absent when the run has none. */\n imageWidth?: number;\n /** Height of the canvas. In scene pixels, of the canvas this run draws on (a cell's inside render/split); absent when the run has none. */\n imageHeight?: number;\n [key: string]: any;\n}\n\ndeclare const frameos: {\n image(spec?: FrameOSImageSpec): FrameOSImageSpec & { __frameosType: \"image\" };\n svg(svg: string, spec?: FrameOSImageSpec): FrameOSImageSpec & { __frameosType: \"image\"; svg: string };\n node(nodeId: number): FrameOSNodeRef;\n scene(sceneId: string): FrameOSSceneRef;\n color(color: string): FrameOSColorRef;\n log(...args: any[]): void;\n error(...args: any[]): void;\n setNextSleep(seconds: number): void;\n fetchText(url: string): string;\n fetchJson(url: string): any;\n httpRequest(\n url: string,\n options?: {\n method?: string;\n headers?: Record;\n body?: string;\n bodyBase64?: string;\n base64?: boolean;\n timeoutMs?: number;\n find?: string;\n findLength?: number;\n findLimit?: number;\n }\n ): { status: number; body?: string; bodyBase64?: string; found?: string[]; error?: string };\n fetchImage(\n url: string,\n options?: {\n headers?: Record;\n saveAsset?: { name: string; extension?: string; mode?: \"auto\" | \"always\" };\n exif?: boolean;\n // The image's color profile (a .frameos.json sidecar's colors object); null = none, clears the last palette.\n colors?: { [key: string]: any } | null;\n }\n ): {\n image: FrameOSImageRef | null;\n width?: number;\n height?: number;\n error?: string;\n savedAs?: string;\n exif?: Record;\n exifSummary?: string;\n };\n listAssets(dir?: string): string[];\n assetExists(path: string): boolean;\n assetSize(path: string): number;\n readAsset(path: string, options?: { offset?: number; length?: number }): string | null;\n writeAsset(path: string, base64: string): boolean;\n appendAsset(path: string, base64: string): boolean;\n deleteAsset(path: string): boolean;\n loadAssetImage(path: string): FrameOSImageRef | null;\n openAssetStream(path: string, mode?: \"r\" | \"w\" | \"a\"): FrameOSStreamRef | null;\n createStream(base64: string): FrameOSStreamRef | null;\n streamRead(stream: FrameOSStreamRef | number, length: number): string | null;\n streamWrite(stream: FrameOSStreamRef | number, base64: string): boolean;\n streamAtEnd(stream: FrameOSStreamRef | number): boolean;\n streamRewind(stream: FrameOSStreamRef | number): boolean;\n streamClose(stream: FrameOSStreamRef | number): boolean;\n getSetting(...path: string[]): any;\n setState(key: string, value: FrameOSJson): void;\n /** Keep a value for this node between renders (FrameOS 2026.10.13+). null forgets it. */\n remember(key: string, value: FrameOSJson): boolean;\n recall(key: string): FrameOSJson | undefined;\n};\n\n/** console.log and friends log like frameos.log; warn says so; error goes to the error log (FrameOS 2026.10.16+). */\ndeclare const console: {\n log(...args: any[]): void;\n info(...args: any[]): void;\n debug(...args: any[]): void;\n warn(...args: any[]): void;\n error(...args: any[]): void;\n};\n```"}, - {"id": "checklist", "title": "Checklist", "text": "## Checklist\n\n- `settings.execution` is `\"interpreted\"`, and one `render` event node starts\n one straight render flow.\n- Every app keyword exists, every `config` key is one of its fields, and\n every select value is one of its options.\n- `node` fields are edges from `field/`, never config.\n- Every code node argument is declared in `codeArgs` and fed by an edge.\n- Images go only into image fields; nothing keeps an image in state.\n- Time formats in code nodes use `{hour/2}`-style tokens.\n- JS apps are data apps exporting `get` (wired into `render/image`) or logic\n apps exporting `run`.\n- A JS app keeps what must outlast a render with `frameos.remember` (private)\n or `frameos.setState` (shared), never in a module variable.\n- SVG uses only the supported tags, gradients use `userSpaceOnUse`, and every\n dynamic string is escaped.\n- Sizes come from the frame, not from constants.\n- Nothing big is embedded as an object literal or kept alive between renders."}, + {"id": "checklist", "title": "Checklist", "text": "## Checklist\n\n- `settings.execution` is `\"interpreted\"`, and one `render` event node starts\n one straight render flow.\n- Every app keyword exists, every `config` key is one of its fields, and\n every select value is one of its options.\n- `node` fields are edges from `field/`, never config.\n- Every code node argument is declared in `codeArgs` and fed by an edge.\n- Images go only into image fields; nothing keeps an image in state.\n- Time formats in code nodes use `{hour/2}`-style tokens.\n- A JS app whose node feeds a field is a `data` app exporting `get`; one whose\n node sits in a flow, after any event node, is a `logic` app exporting `run`.\n- A JS app keeps what must outlast a render with `frameos.remember` (private)\n or `frameos.setState` (shared), never in a module variable.\n- SVG uses only the supported tags, gradients use `userSpaceOnUse`, and every\n dynamic string is escaped.\n- Sizes come from the frame, not from constants.\n- Nothing big is embedded as an object literal or kept alive between renders."}, ) # The whole guide, as markdown. diff --git a/cloud/apps/auth-web/evals/cases/scenes.ts b/cloud/apps/auth-web/evals/cases/scenes.ts index 9ab3673f5..7a66bd1fb 100644 --- a/cloud/apps/auth-web/evals/cases/scenes.ts +++ b/cloud/apps/auth-web/evals/cases/scenes.ts @@ -261,6 +261,22 @@ export const cases: EvalCase[] = [ seedSlug: "weather", tags: ["modify", "fields"], }, + { + // A button flow is run(), not get(): the chess puzzle's toggle app + // exported get() and the press did nothing (2026-10-17). + checks: [ + { type: "delivered", tool: "modify_scene" }, + { type: "lint_clean" }, + { type: "preserves_node_ids", minFraction: 0.8 }, + { type: "preserves_fields" }, + { type: "flow_sets_state", event: "button" }, + { type: "render_ok" }, + ], + id: "modify-weather-button-unit", + prompt: "When someone presses a button on the frame, switch between Celsius and Fahrenheit and redraw.", + seedSlug: "weather", + tags: ["modify", "events"], + }, { checks: [ { type: "delivered", tool: "modify_scene" }, diff --git a/cloud/apps/auth-web/evals/lib/checks.test.ts b/cloud/apps/auth-web/evals/lib/checks.test.ts index 9fac19c69..0845826a5 100644 --- a/cloud/apps/auth-web/evals/lib/checks.test.ts +++ b/cloud/apps/auth-web/evals/lib/checks.test.ts @@ -80,3 +80,51 @@ describe("code_nodes_min", () => { expect(result.passed).toBe(false); }); }); + +describe("flow_sets_state", () => { + const buttonScene = (nodes: unknown[], apps: Record = {}) => ({ + apps, + edges: [ + { id: "b1", source: "button", sourceHandle: "next", target: "act", targetHandle: "prev", type: "appNodeEdge" }, + { id: "b2", source: "act", sourceHandle: "next", target: "redraw", targetHandle: "prev", type: "appNodeEdge" }, + ], + nodes: [ + event, + { data: { keyword: "button" }, id: "button", type: "event" }, + ...nodes, + { data: { config: {}, keyword: "render" }, id: "redraw", type: "dispatch" }, + ], + }); + const toggleApp = (source: string) => ({ toggle: { sources: { "app.ts": source, "config.json": "{}" } } }); + const toggleNode = { data: { config: {}, keyword: "toggle" }, id: "act", type: "app" }; + + it("passes for a run() app that sets state and for logic/setAsState", () => { + const viaRun = run({ event: "button", type: "flow_sets_state" }, [ + buttonScene([toggleNode], toggleApp("export function run(app) { frameos.setState('unit', 'f') }")), + ]); + expect(viaRun.passed).toBe(true); + expect(viaRun.detail).toContain("scene app toggle (act)"); + const viaApp = run({ event: "button", type: "flow_sets_state" }, [ + buttonScene([{ data: { config: { stateKey: "unit" }, keyword: "logic/setAsState" }, id: "act", type: "app" }]), + ]); + expect(viaApp.passed).toBe(true); + }); + + it("fails for the get()-only app the frame never calls in a flow", () => { + const result = run({ event: "button", type: "flow_sets_state" }, [ + buttonScene([toggleNode], toggleApp("export function get(app) { frameos.setState('unit', 'f'); return null }")), + ]); + expect(result.passed).toBe(false); + expect(result.detail).toContain("toggle (act, no run())"); + }); + + it("fails without a button event node or without a state change", () => { + expect(run({ event: "button", type: "flow_sets_state" }, [{ nodes: [event, renderImage] }]).detail).toBe( + 'no "button" event node', + ); + const redrawOnly = run({ event: "button", type: "flow_sets_state" }, [ + buttonScene([{ data: { config: {}, keyword: "render" }, id: "act", type: "dispatch" }]), + ]); + expect(redrawOnly.passed).toBe(false); + }); +}); diff --git a/cloud/apps/auth-web/evals/lib/checks.ts b/cloud/apps/auth-web/evals/lib/checks.ts index d312bb2f7..8511c0b5e 100644 --- a/cloud/apps/auth-web/evals/lib/checks.ts +++ b/cloud/apps/auth-web/evals/lib/checks.ts @@ -1,7 +1,7 @@ // Deterministic checks over what the agent delivered. Each returns a // CheckResult with a one-line detail so a failing eval explains itself in // the report without opening the JSON. -import type { LintResult } from "../../src/lib/ai/scene-lint"; +import { moduleExports, type LintResult } from "../../src/lib/ai/scene-lint"; import type { Check, CheckResult, JudgeVerdict, RenderSummary } from "./types"; type JsonObject = Record; @@ -109,6 +109,62 @@ function svgRoute(scenes: unknown[]): string | null { return codeNode ? `code node ${String(codeNode.id)}` : null; } +// What the flows an event starts do to state, walking next -> prev (and +// field/ -> prev) edges from its event nodes. A scene-local JS app in a +// flow counts only when its run() is there to call: the frame calls nothing +// else in a flow, so a get()-only app there is listed as inert. +function flowStateWriters(scenes: unknown[], event: string): { found: boolean; writers: string[]; inert: string[] } { + const writers: string[] = []; + const inert: string[] = []; + let found = false; + for (const scene of scenes) { + const entry = obj(scene); + const apps = obj(entry?.apps) ?? {}; + const nodes = new Map(nodesOf([scene]).map((node) => [String(node.id), node])); + const edges = Array.isArray(entry?.edges) ? entry.edges.map(obj).filter((edge): edge is JsonObject => Boolean(edge)) : []; + const next = new Map(); + for (const edge of edges) { + if (edge.targetHandle === "prev") { + next.set(String(edge.source), [...(next.get(String(edge.source)) ?? []), String(edge.target)]); + } + } + const stack = [...nodes.values()] + .filter((node) => node.type === "event" && obj(node.data)?.keyword === event) + .map((node) => String(node.id)); + found ||= stack.length > 0; + const seen = new Set(); + while (stack.length > 0) { + const id = stack.pop()!; + if (seen.has(id)) { + continue; + } + seen.add(id); + stack.push(...(next.get(id) ?? [])); + const node = nodes.get(id); + const keyword = obj(node?.data)?.keyword; + if (!node || node.type === "event" || typeof keyword !== "string") { + continue; + } + if ((node.type === "app" && keyword === "logic/setAsState") || (node.type === "dispatch" && keyword === "setSceneState")) { + writers.push(`${keyword} (${id})`); + continue; + } + const sources = obj(obj(apps[keyword])?.sources); + const main = Object.entries(sources ?? {}).find(([file]) => /^app\.(ts|tsx|js|jsx)$/.test(file))?.[1]; + if (node.type !== "app" || typeof main !== "string") { + continue; + } + const exported = moduleExports(main); + if (exported && !exported.has("run")) { + inert.push(`${keyword} (${id}, no run())`); + } else if (/frameos\.setState\s*\(/.test(main)) { + writers.push(`scene app ${keyword} (${id})`); + } + } + } + return { found, inert, writers }; +} + function regex(pattern: string, flags?: string): RegExp { return new RegExp(pattern, flags ?? "i"); } @@ -213,6 +269,18 @@ export function runCheck(check: Check, input: CheckInput): CheckResult { ? pass(`uses ${alternative}`) : fail(`no SVG drawn: no render/svg or jsSvg app, no frameos.svg(...) or 0) { + return fail(`the ${check.event} flow runs apps that do nothing there: ${inert.join(", ")}`); + } + return writers.length > 0 + ? pass(`the ${check.event} flow sets state via ${writers.join(", ")}`) + : fail(`the ${check.event} flow changes no state (no logic/setAsState, setSceneState dispatch or run() calling frameos.setState)`); + } case "scene_count": { const count = scenes.length; if (check.min !== undefined && count < check.min) { diff --git a/cloud/apps/auth-web/evals/lib/types.ts b/cloud/apps/auth-web/evals/lib/types.ts index c6b172e0f..857ff9511 100644 --- a/cloud/apps/auth-web/evals/lib/types.ts +++ b/cloud/apps/auth-web/evals/lib/types.ts @@ -23,6 +23,12 @@ export type Check = | { type: "code_nodes_min"; min: number } /** The scene draws with SVG, by any route; `orApps` lists apps that satisfy the request just as well. */ | { type: "draws_svg"; orApps?: string[] } + /** + * An `event` node with this keyword starts a flow that changes state (logic/setAsState, a + * setSceneState dispatch, or a scene-local JS app whose run() calls frameos.setState), and no + * JS app in that flow lacks the run() the frame calls there. + */ + | { type: "flow_sets_state"; event: string } | { type: "scene_count"; min?: number; max?: number } | { type: "json_matches"; pattern: string; flags?: string } | { type: "json_not_matches"; pattern: string; flags?: string } diff --git a/cloud/apps/auth-web/src/lib/ai/scene-guide.gen.ts b/cloud/apps/auth-web/src/lib/ai/scene-guide.gen.ts index f0c8b747c..343c013dc 100644 --- a/cloud/apps/auth-web/src/lib/ai/scene-guide.gen.ts +++ b/cloud/apps/auth-web/src/lib/ai/scene-guide.gen.ts @@ -9,17 +9,17 @@ export type SceneGuideSection = { id: string; title: string; text: string }; export const SCENE_GUIDE_SECTIONS: readonly SceneGuideSection[] = [ {"id": "a-scene", "title": "A scene", "text": "## A scene\n\nA scenes file is a JSON array of scenes. Each scene is\n`{ id, name, nodes, edges, fields, settings, apps?, customEvents? }`:\n\n- `id`: a unique string, kept stable across edits. `name`: shown to people.\n- `settings.execution` is always `\"interpreted\"`.\n- `settings.refreshInterval`: seconds between renders, the scene's default\n cadence. Never below 3600 when the scene calls a paid API (an image or text\n model) unless the person asked for it.\n- `settings.backgroundColor`: the hex color every render starts from. Set it.\n- `fields`: the scene's state fields (see [Fields and state](#fields-and-state)).\n- `apps`: JavaScript apps the scene carries with it (see [JS apps](#js-apps)).\n- `customEvents`: events of the scene's own (see [Events](#events)).\n\nWhen you change an existing scene, keep its id, its node ids and everything\nyou were not asked to touch exactly as it was, and keep its fields so the\nvalues people saved still apply. Change the minimum that does what was asked."}, {"id": "nodes", "title": "Nodes", "text": "## Nodes\n\nEvery node is `{ id, type, data }`. Positions are optional.\n\n| type | `data` | what it does |\n|---|---|---|\n| `event` | `{ keyword }` | Starts a flow when the event happens. Every scene has one `render` event node; the render flow starts there. |\n| `app` | `{ keyword, config, cache? }` | Runs an app. `keyword` is a catalog app (`render/text`) or a key of the scene's `apps` map. |\n| `code` | `{ codeJS, codeArgs?, codeOutputs?, cache? }` | One JavaScript expression (see [Code nodes](#code-nodes)). |\n| `state` | `{ keyword, value }` | Outputs the scene field named `keyword`; `value` is its default, as a string. |\n| `dispatch` | `{ keyword, config }` | Sends an event when the flow reaches it: `render` for another render, `setSceneState`, `setCurrentScene` or a custom event. `config` is the event's payload. A node of its own, never an app keyword like `dispatch/render`. |\n| `scene` | `{ keyword, config }` | Draws another scene, by its id, inside this one (usually in a `render/split` cell). |\n\n`cache` on an app or code node is\n`{ enabled, inputEnabled, durationEnabled, duration }`: keep the output until\nthe inputs change (`inputEnabled`), until `duration` seconds have passed\n(`durationEnabled`), or both. Cache every network fetch on a sensible\nschedule."}, - {"id": "edges", "title": "Edges", "text": "## Edges\n\nEvery edge is `{ id, source, target, sourceHandle, targetHandle, type }`, and\nboth ends must be nodes of the scene. There are two kinds:\n\n- **The render flow** (`type: \"appNodeEdge\"`, `sourceHandle: \"next\"` →\n `targetHandle: \"prev\"`): the `render` event node, then each render or logic\n app in order. It is one straight chain: never point two `next` edges at one\n `prev`. The apps in it draw on one shared canvas, in order, so never pass the\n canvas between them through fields.\n- **Values** (`type: \"codeNodeEdge\"`, `sourceHandle: \"fieldOutput\"`): a node's\n output into an app field (`targetHandle: \"fieldInput/\"`) or into a\n code node argument (`targetHandle: \"codeField/\"`). Code, state and data\n nodes connect only this way, and their source handle is exactly\n `fieldOutput`, never `fieldOutput/`.\n\nData apps (category `data`) are never in the render flow; they feed values.\nImages are values too: to show one, put a `render/image` app in the render\nflow and wire the image into its `image` field. Never put a data app that\nmakes an image straight into the render flow, and never keep an image in\nstate.\n\nA field of type `node` (`logic/ifElse`'s `thenNode` and `elseNode`,\n`render/split`'s `render_functions`) is an edge, never config:\n`sourceHandle: \"field/\"` on the app → `targetHandle: \"prev\"` on the node\nit runs. A split's cells are `field/render_functions[row][col]`, counted from\n1 and within its `rows` × `columns`; a cell runs a render app or a `scene`\nnode. A `scene` node takes the embedded scene's state on\n`fieldInput/`."}, + {"id": "edges", "title": "Edges", "text": "## Edges\n\nEvery edge is `{ id, source, target, sourceHandle, targetHandle, type }`, and\nboth ends must be nodes of the scene. There are two kinds:\n\n- **Flows** (`type: \"appNodeEdge\"`, `sourceHandle: \"next\"` →\n `targetHandle: \"prev\"`): every `event` node starts one, and the frame runs\n its nodes in order. The render flow starts at the `render` event node and\n holds the render and logic apps that draw; a flow from another event\n (`button`, `tap`, a custom event) holds logic apps and `dispatch` nodes. A\n flow is one straight chain: never point two `next` edges at one `prev`. The\n apps in the render flow draw on one shared canvas, in order, so never pass\n the canvas between them through fields.\n- **Values** (`type: \"codeNodeEdge\"`, `sourceHandle: \"fieldOutput\"`): a node's\n output into an app field (`targetHandle: \"fieldInput/\"`) or into a\n code node argument (`targetHandle: \"codeField/\"`). Code, state and data\n nodes connect only this way, and their source handle is exactly\n `fieldOutput`, never `fieldOutput/`.\n\nData apps (category `data`) are never in a flow; they feed values.\nImages are values too: to show one, put a `render/image` app in the render\nflow and wire the image into its `image` field. Never put a data app that\nmakes an image straight into the render flow, and never keep an image in\nstate.\n\nA field of type `node` (`logic/ifElse`'s `thenNode` and `elseNode`,\n`render/split`'s `render_functions`) is an edge, never config:\n`sourceHandle: \"field/\"` on the app → `targetHandle: \"prev\"` on the node\nit runs. A split's cells are `field/render_functions[row][col]`, counted from\n1 and within its `rows` × `columns`; a cell runs a render app or a `scene`\nnode. A `scene` node takes the embedded scene's state on\n`fieldInput/`."}, {"id": "apps", "title": "Apps", "text": "## Apps\n\nAn app node's `keyword` must be an app that exists: look each one up in the\napp catalog before you use it, with its exact field names, types and options.\n\n- `config` keys are field names of that app, and every value is a string:\n `\"12\"`, `\"true\"`, `\"#ff0000\"`. A `select` field takes one of its options'\n values.\n- A required field without a default is set in `config` or fed by an edge. An\n image field (`render/image`'s `image`) is always fed by an edge.\n- Outputs are typed. An image goes only into an image field; text and JSON go\n only into other fields; a float goes into a float field and an integer into\n an integer field.\n- Catalog apps with a `repo/apps//` keyword are JavaScript app\n templates, not keywords a frame knows. A scene uses one by copying it into\n its own `apps` map under ``, with the template's `name`, `category`,\n `description`, `fields`, `output` and `sources`, and\n `\"origin\": \"repo/apps//\"`; its nodes then use `keyword: \"\"`.\n The editor and the scene AI do this for you; anything else that writes scene\n JSON does it itself.\n- Prefer an app that exists to one you write. Weather, photos, calendars,\n the time, AI text and images, home sensors and charts all have catalog apps,\n tested on every kind of frame: search the catalog for the subject before\n writing a JS app or a code node, and to change a scene, add the app that does\n the new part rather than rewriting an app that is already there.\n- When no app fits, build the logic from code nodes and generic apps\n (`render/text`, `render/image`, `render/svg`) or write a JS app.\n\nApps used often: `render/image` (an image into the canvas, with `placement`\ncover, contain, stretch, center…), `render/text` (wrapping text, with\n`richText: \"basic-caret\"` for caret styling), `render/svg`, `render/gradient`\n(`startColor`, `endColor`, `angle`), `render/color`, `render/split` (`rows`,\n`columns`, cells as above), `data/clock` (the time as a string; its `format`\noptions are its own, not the code-node tokens), `data/downloadUrl` (a URL's\nbody as text), `data/parseJson`, `logic/setAsState` (a value into state,\n`stateKey` plus `valueString` or `valueJson`) and `logic/ifElse`. For a chart,\ncopy the `repo/apps/image/chart` JS app into the scene: line, bar or area, from\na `data` field, wired into `render/image`. `render/color` and `render/gradient`\nfill the whole canvas and hide whatever was drawn before them, so they go first\nin the render flow."}, {"id": "fields-and-state", "title": "Fields and state", "text": "## Fields and state\n\nThe scene's state is a JSON object every node can read. `fields` declares the\npart people can see and change:\n`{ name, type, label?, description?, value, options?, access?, persist?, required?, role? }`.\n\n- Types: `string`, `text`, `float`, `integer`, `boolean`, `color`, `date`,\n `json`, `node`, `scene`, `image`, `font`, `select`, `path`.\n- `value` is the default, always a string (`\"600\"`, `\"true\"`, `\"Brussels\"`).\n- `options` (select only) is a list of strings, `[\"dark\", \"light\"]`, or of\n `{ \"value\": \"dark\", \"label\": \"Dark mode\" }` pairs. State and config always\n hold the value, never the label.\n- `access: \"public\"` and `persist: \"disk\"` make a field something people\n customize, and it keeps their value across restarts. Prefer them for\n anything a person would want to change.\n- A `state` node outputs one field: `data.keyword` is the field's name and\n `data.value` its default as a string. Use one state node per consumer, so\n the diagram stays readable.\n- Every scene has a public `refreshInterval` field (\"Refresh interval\n (seconds)\", read as `state.refreshInterval`), whose default is\n `settings.refreshInterval`. Never add your own interval field, and never use\n `logic/nextSleepDuration` to make the cadence configurable: that app sets the\n sleep after one render only. When the interval deserves its own label\n (\"Seconds per image\"), declare one `float` field with\n `\"role\": \"refreshInterval\"`; its value is then the interval."}, {"id": "code-nodes", "title": "Code nodes", "text": "## Code nodes\n\nA code node holds one JavaScript **expression** in `data.codeJS`. Wrap\nseveral statements in an immediately called function that returns the value.\n\n- It has exactly one output: one `codeOutputs` entry, `{ name, type }`, with\n type `string`, `json`, `integer`, `float` or `boolean`. It cannot output an\n image: to draw generated SVG, output a string into a\n `render/svg` node's `svg` field.\n- An argument exists only when it is declared in `data.codeArgs`\n (`{ name, type }`) and fed by an edge into `codeField/`. Arguments are\n constant bindings: `const lat = Number(lat)` is an error, write\n `const latNum = Number(lat)`. Never name one `state`, `args`, `context`,\n `console`, `parseTs`, `format` or `now`: the code would see the global.\n- The only globals are `state.`, the arguments, `context` (`event`,\n `payload`, `loopIndex`, `loopKey`, `hasImage`, and `imageWidth` /\n `imageHeight`, the canvas being drawn on, undefined outside a render),\n `console.log/warn/error`, and three time helpers: `now()` (seconds since the\n epoch), `format(ts, pattern)` (text in the frame's time zone) and\n `parseTs(pattern, text)` (seconds).\n- There is no `frameos` object, no `fetch` and no HTTP in a code node. Fetch\n with a data app (`data/downloadUrl`, `data/parseJson`…) and wire its output\n into an argument. `Math`, `JSON` and `Date` work, but take local time from\n `format(now(), …)`.\n\n`format()` and `parseTs()` patterns use curly tokens, and letters outside the\nbraces are copied as they are (`\"HH:mm\"` prints `HH:mm`):\n\n| token | output |\n|---|---|\n| `{year/4}` `{year/2}` | `2026` / `26` |\n| `{month/2}` `{month}` | `08` / `8` |\n| `{month/n}` `{month/n/3}` | `August` / `Aug` |\n| `{day/2}` `{day}` | `05` / `5` |\n| `{hour/2}` `{hour}` | `09` / `9` (24 h) |\n| `{hour/2/ap}` `{hour/ap}` `{am/pm}` | `09` / `9` / `am` (12 h) |\n| `{minute/2}` `{second/2}` | `05` / `07` |\n| `{weekday}` `{weekday/3}` `{weekday/2}` | `Monday` / `Mon` / `Mo` |\n\n`format(now(), \"{hour/2}:{minute/2}\")` gives `09:05`;\n`Number(format(now(), \"{hour}\"))` gives `9`."}, - {"id": "js-apps", "title": "JS apps", "text": "## JS apps\n\nA JS app is a small TypeScript or JavaScript module that runs on the frame in\nQuickJS. A scene carries its own in the top-level `apps` map:\n\n```json\n\"apps\": {\n \"myPanel\": {\n \"name\": \"My panel\", \"category\": \"data\", \"description\": \"…\",\n \"fields\": [{ \"name\": \"city\", \"type\": \"string\", \"value\": \"Brussels\" }],\n \"output\": [{ \"name\": \"image\", \"type\": \"image\" }],\n \"sources\": { \"config.json\": \"…the same name, category, fields and output…\", \"app.ts\": \"…\" }\n }\n}\n```\n\nand its nodes use `keyword: \"myPanel\"`.\n\n**Files.** `config.json` and `app.ts` (or `.tsx`, `.js`, `.jsx`), plus any\nhelper modules and `.json` data files, imported with relative paths\n(`import { row } from \"./lib/row\"`, `import icons from \"./icons.json\"`). Only\nthe app's own files resolve: no npm packages, no `require()`, no dynamic\n`import()`. JSX works only in `.tsx` / `.jsx` files. Split an app when\n`app.ts` passes a few hundred lines, or when data would swamp the code. Keep\napps small: a scene carries a copy of every app it uses, and an ESP32 keeps\nall its scenes in 512 KiB.\n\n**Exports.**\n\n- A `data` app exports `get(app, context)`, which returns the value its\n `output` names: a string, JSON, or for an image `frameos.svg(...)`,\n `frameos.image(...)` or an image from `frameos.fetchImage`. It is not in the\n render flow: wire its output into `render/image`'s `image` field or another\n node's input.\n- A `logic` app in the render flow exports `run(app, context)`, for what it\n does rather than returns (state, the next sleep).\n- Never make a JS app with category `render`: it draws nothing.\n- `init(app)` is optional and runs each time the app's interpreter starts:\n once per scene on a Pi, before every render on an ESP32 and in a preview\n running as one.\n\n**What the app sees.**\n\n- `app.config`: its fields, with what the node's config and edges set.\n- `app.state`: the scene's state, read by key. Write it with\n `frameos.setState(key, value)`.\n- `app.frame`: the frame, read only: `width`, `height` (always set, also in\n the preview), `rotate`, `timeZone`, `assetsPath`, `debug`, and `embedded`\n (true on an ESP32, and in a preview running as one).\n- `app.nodeId`, `app.nodeName`.\n- `context`: this run: `event`, `payload`, and when an image is flowing\n through, `imageWidth` / `imageHeight`. That is the area being drawn into (a\n split cell, say), which can be smaller than `app.frame`.\n- `frameos`: everything the app can do: fetch (`fetchJson`, `fetchText`,\n `httpRequest`, `fetchImage`), make images (`svg`, `image`), log (`log`,\n `error`), keep values (`setState`, `remember`, `recall`), read the account's\n keys (`getSetting`, only for namespaces the app's `config.json` lists under\n `settings`), set the next sleep (`setNextSleep`, this render only), and\n read and write frame assets and streams. The exact signatures are in the\n [JS API reference](#js-api-reference).\n- `Date` reads local time in the frame's time zone. There are no `format`,\n `now` or `parseTs` here: those belong to code nodes.\n\n**Where an app keeps its values.** Three places, the same on every device:\n\n| the value | goes in | lasts |\n|---|---|---|\n| what the rest of the scene should see: a position a button changes, a photo's metadata | `frameos.setState(key, value)`, read back as `app.state[key]` | the scene's state: every node reads it, state nodes see changes, a declared field can persist and show in the frame's controls |\n| the app's own, needed on the next render: a fetched list, a position | `frameos.remember(key, value)`, read back with `frameos.recall(key)` | until the scene unloads, private to the node; JSON, at most 512 KB per node on an ESP32; `remember` answers `false` when it does not fit, and `null` forgets |\n| scratch for this render: lookup tables built when the module loads, intermediate results | module-level variables | one render |\n\nAn ESP32 starts every render with a fresh module, so a counter in a module\nvariable shows the first photo forever. A Pi keeps its modules longer, but an\napp must not count on it. The editor's preview runs as an ESP32 when its memory\nlimit is set to an ESP32 board or it previews an ESP32 frame, and as a Pi\notherwise, so an app can be tried both ways. An animation works out each frame\nfrom the clock (`Date.now()`), not from the frame before.\n\n**Errors.** Throw. An error thrown from `get()` fails the node: it is logged\nwith its source line, an image field gets the message painted, and the next\nrender tries again. `frameos.fetchImage` never throws; it answers\n`{ image: null, error }`, and the app throws with a message of its own.\n\n**Photos.** `frameos.fetchImage(url, { headers, saveAsset })` downloads and\ndecodes natively, and the bytes never enter the JS heap. Return its `image`.\nUse it for every photo, and `httpRequest` only for the JSON around it.\n\n**Assets and permissions.** Asset paths are relative to the frame's assets\nfolder (`app.frame.assetsPath`, `/srv/assets` by default): uploaded photos,\nfonts, what other scenes saved. What an app may do there is its own\nconfig.json's `\"permissions\"` list:\n\n- `\"assets\"`: read the assets folder (`listAssets`, `readAsset`,\n `loadAssetImage`, a stream opened with `\"r\"`, ...).\n- `\"assets-write\"`: also write and delete in it (`writeAsset`,\n `appendAsset`, `deleteAsset`, a stream opened with `\"w\"` or `\"a\"`).\n- `\"files\"` and `\"shell\"` belong to built-in apps: Local Image reads any\n folder, the screenshot and camera apps run processes.\n\nSaving the scene fills a JavaScript app's list from its code, so you rarely\nwrite it. The frame gives the app exactly that list: with neither `\"assets\"`\nnor `\"assets-write\"`, its asset calls live in a folder of its own,\n`scenes//`; with `\"assets\"` alone, its writes are refused. An app\nwith no list asks for nothing, and a list written by hand is replaced on the\nnext save. A scene asks for what its apps ask for, nothing more, and every\nsurface shows it as tags (\"reads assets folder\", \"writes assets folder\",\n\"reads all files\", \"runs shell commands\", and \"uses OpenAI key\" for each\nsettings namespace an app reads). Installing a scene is the approval of its\ntags, and an update says which ones it adds, so ask for what the app needs\nand no more.\n`fetchImage({ saveAsset })` is the frame caching a download, not the app\nusing the folder: it needs no permission. (Frames before FrameOS 2026.10.16\nignore the lists.)\n\n**Local network.** On a cloud-managed frame, scenes reach addresses on the\nframe's own network (a Home Assistant at `homeassistant.local`, a NAS,\nanything at `192.168.x.x`) only while its local network access is on. That is\na frame setting the owner turns on, not something an app or a scene asks for.\nWith it off, such a request fails with \"Local network access is off for this\nframe\". A scene that needs a local service says so in its description, so the\nowner knows to turn it on. Standalone and self-hosted frames are not limited.\n\n**Big responses.** A response body becomes a JS string. When the app needs\nonly what follows some marker in a large page, pass\n`httpRequest(url, { find: \"marker\", findLength: 400 })`: the body stays native\nand the answer is `{ status, found }`, the text at each occurrence.\n\n**Newer than some frames.** `frameos.remember` / `recall`, `httpRequest`'s\n`find`, `fetchImage`'s `exif`, `app.frame.embedded` / `debug`, and a throw\npainting its message all need FrameOS 2026.10.13; a frame on an older release\nlacks them. A scene meant for such frames keeps to the rest.\n\n**Code style.** Normal formatting, one statement per line, descriptive names,\nshort comments where the logic is not obvious. People open and edit this code\nin the editor, so never minify it."}, + {"id": "js-apps", "title": "JS apps", "text": "## JS apps\n\nA JS app is a small TypeScript or JavaScript module that runs on the frame in\nQuickJS. A scene carries its own in the top-level `apps` map:\n\n```json\n\"apps\": {\n \"myPanel\": {\n \"name\": \"My panel\", \"category\": \"data\", \"description\": \"…\",\n \"fields\": [{ \"name\": \"city\", \"type\": \"string\", \"value\": \"Brussels\" }],\n \"output\": [{ \"name\": \"image\", \"type\": \"image\" }],\n \"sources\": { \"config.json\": \"…the same name, category, fields and output…\", \"app.ts\": \"…\" }\n }\n}\n```\n\nand its nodes use `keyword: \"myPanel\"`.\n\n**Files.** `config.json` and `app.ts` (or `.tsx`, `.js`, `.jsx`), plus any\nhelper modules and `.json` data files, imported with relative paths\n(`import { row } from \"./lib/row\"`, `import icons from \"./icons.json\"`). Only\nthe app's own files resolve: no npm packages, no `require()`, no dynamic\n`import()`. JSX works only in `.tsx` / `.jsx` files. Split an app when\n`app.ts` passes a few hundred lines, or when data would swamp the code. Keep\napps small: a scene carries a copy of every app it uses, and an ESP32 keeps\nall its scenes in 512 KiB.\n\n**Exports.** Where the app's node sits decides which function the frame\ncalls, and it calls no other:\n\n- A node that feeds a field (`fieldOutput` → `fieldInput/`) calls\n `get(app, context)`. That is a `data` app: `get` returns the value its\n `output` names, a string, JSON, or for an image `frameos.svg(...)`,\n `frameos.image(...)` or an image from `frameos.fetchImage`. To show an\n image, wire it into `render/image`'s `image` field.\n- A node in a flow (after an event node, `next` → `prev`) calls\n `run(app, context)`. That is a `logic` app: `run` returns nothing and acts,\n with `frameos.setState`, `frameos.setNextSleep` and the like.\n- So a `get()` app in a flow does nothing, and a `run()` app feeding a field\n gives an empty value. Write the one its node needs.\n- A button that changes the picture is a flow of its own: the `button` event\n node, a logic app, then a `dispatch` node with `keyword: \"render\"`. The\n app's `app.ts` is\n `export function run(app) { frameos.setState(\"revealed\", !app.state.revealed) }`,\n and the render flow reads `revealed` from state. `logic/setAsState` does the\n same for one value without a JS app.\n- Never make a JS app with category `render`: it draws nothing.\n- `init(app)` is optional and runs each time the app's interpreter starts:\n once per scene on a Pi, before every render on an ESP32 and in a preview\n running as one.\n\n**What the app sees.**\n\n- `app.config`: its fields, with what the node's config and edges set.\n- `app.state`: the scene's state, read by key. Write it with\n `frameos.setState(key, value)`.\n- `app.frame`: the frame, read only: `width`, `height` (always set, also in\n the preview), `rotate`, `timeZone`, `assetsPath`, `debug`, and `embedded`\n (true on an ESP32, and in a preview running as one).\n- `app.nodeId`, `app.nodeName`.\n- `context`: this run: `event`, `payload`, and when an image is flowing\n through, `imageWidth` / `imageHeight`. That is the area being drawn into (a\n split cell, say), which can be smaller than `app.frame`.\n- `frameos`: everything the app can do: fetch (`fetchJson`, `fetchText`,\n `httpRequest`, `fetchImage`), make images (`svg`, `image`), log (`log`,\n `error`), keep values (`setState`, `remember`, `recall`), read the account's\n keys (`getSetting`, only for namespaces the app's `config.json` lists under\n `settings`), set the next sleep (`setNextSleep`, this render only), and\n read and write frame assets and streams. The exact signatures are in the\n [JS API reference](#js-api-reference).\n- `Date` reads local time in the frame's time zone. There are no `format`,\n `now` or `parseTs` here: those belong to code nodes.\n\n**Where an app keeps its values.** Three places, the same on every device:\n\n| the value | goes in | lasts |\n|---|---|---|\n| what the rest of the scene should see: a position a button changes, a photo's metadata | `frameos.setState(key, value)`, read back as `app.state[key]` | the scene's state: every node reads it, state nodes see changes, a declared field can persist and show in the frame's controls |\n| the app's own, needed on the next render: a fetched list, a position | `frameos.remember(key, value)`, read back with `frameos.recall(key)` | until the scene unloads, private to the node; JSON, at most 512 KB per node on an ESP32; `remember` answers `false` when it does not fit, and `null` forgets |\n| scratch for this render: lookup tables built when the module loads, intermediate results | module-level variables | one render |\n\nAn ESP32 starts every render with a fresh module, so a counter in a module\nvariable shows the first photo forever. A Pi keeps its modules longer, but an\napp must not count on it. The editor's preview runs as an ESP32 when its memory\nlimit is set to an ESP32 board or it previews an ESP32 frame, and as a Pi\notherwise, so an app can be tried both ways. An animation works out each frame\nfrom the clock (`Date.now()`), not from the frame before.\n\n**Errors.** Throw. An error thrown from `get()` fails the node: it is logged\nwith its source line, an image field gets the message painted, and the next\nrender tries again. `frameos.fetchImage` never throws; it answers\n`{ image: null, error }`, and the app throws with a message of its own.\n\n**Photos.** `frameos.fetchImage(url, { headers, saveAsset })` downloads and\ndecodes natively, and the bytes never enter the JS heap. Return its `image`.\nUse it for every photo, and `httpRequest` only for the JSON around it.\n\n**Assets and permissions.** Asset paths are relative to the frame's assets\nfolder (`app.frame.assetsPath`, `/srv/assets` by default): uploaded photos,\nfonts, what other scenes saved. What an app may do there is its own\nconfig.json's `\"permissions\"` list:\n\n- `\"assets\"`: read the assets folder (`listAssets`, `readAsset`,\n `loadAssetImage`, a stream opened with `\"r\"`, ...).\n- `\"assets-write\"`: also write and delete in it (`writeAsset`,\n `appendAsset`, `deleteAsset`, a stream opened with `\"w\"` or `\"a\"`).\n- `\"files\"` and `\"shell\"` belong to built-in apps: Local Image reads any\n folder, the screenshot and camera apps run processes.\n\nSaving the scene fills a JavaScript app's list from its code, so you rarely\nwrite it. The frame gives the app exactly that list: with neither `\"assets\"`\nnor `\"assets-write\"`, its asset calls live in a folder of its own,\n`scenes//`; with `\"assets\"` alone, its writes are refused. An app\nwith no list asks for nothing, and a list written by hand is replaced on the\nnext save. A scene asks for what its apps ask for, nothing more, and every\nsurface shows it as tags (\"reads assets folder\", \"writes assets folder\",\n\"reads all files\", \"runs shell commands\", and \"uses OpenAI key\" for each\nsettings namespace an app reads). Installing a scene is the approval of its\ntags, and an update says which ones it adds, so ask for what the app needs\nand no more.\n`fetchImage({ saveAsset })` is the frame caching a download, not the app\nusing the folder: it needs no permission. (Frames before FrameOS 2026.10.16\nignore the lists.)\n\n**Local network.** On a cloud-managed frame, scenes reach addresses on the\nframe's own network (a Home Assistant at `homeassistant.local`, a NAS,\nanything at `192.168.x.x`) only while its local network access is on. That is\na frame setting the owner turns on, not something an app or a scene asks for.\nWith it off, such a request fails with \"Local network access is off for this\nframe\". A scene that needs a local service says so in its description, so the\nowner knows to turn it on. Standalone and self-hosted frames are not limited.\n\n**Big responses.** A response body becomes a JS string. When the app needs\nonly what follows some marker in a large page, pass\n`httpRequest(url, { find: \"marker\", findLength: 400 })`: the body stays native\nand the answer is `{ status, found }`, the text at each occurrence.\n\n**Newer than some frames.** `frameos.remember` / `recall`, `httpRequest`'s\n`find`, `fetchImage`'s `exif`, `app.frame.embedded` / `debug`, and a throw\npainting its message all need FrameOS 2026.10.13; a frame on an older release\nlacks them. A scene meant for such frames keeps to the rest.\n\n**Code style.** Normal formatting, one statement per line, descriptive names,\nshort comments where the logic is not obvious. People open and edit this code\nin the editor, so never minify it."}, {"id": "images-svg-and-text", "title": "Images, SVG and text", "text": "## Images, SVG and text\n\n`render/svg` and `frameos.svg()` use a strict renderer. Any tag outside this\nlist fails the whole document (\"Failed to render SVG\"; in a JS app, \"No image\nprovided\"): `svg` (with a `viewBox`), `g`, `path`, `rect`, `circle`,\n`ellipse`, `line`, `polyline`, `polygon`, `text` / `tspan`, `linearGradient`,\n`radialGradient`, `defs` (for gradients only), `title`, `desc`. Not supported:\n`use`, `image`, `filter`, `mask`, `clipPath`, `pattern`, `style`,\n`foreignObject`, `symbol`, `marker`.\n\n- Gradients always have `gradientUnits=\"userSpaceOnUse\"` (the SVG default\n fails the document), coordinates in viewBox units, and `` children\n with an `offset` from 0 to 1, a hex `stop-color` and an optional\n `stop-opacity`. Fills only: a stroke cannot use a gradient. For a\n full-screen gradient, `render/gradient` is simpler.\n\n ```svg\n \n \n \n \n \n \n \n ```\n- SVG is XML. Escape every dynamic string you put in markup, text and\n attributes alike (`&` as `&`, `<` as `<`, `>` as `>`, `\"` as\n `"`), through one helper: a single raw `&` fails the whole document.\n- `` is drawn as glyph outlines, so fill, stroke, gradients, opacity and\n transforms work on it. Supported: `x`, `y`, `dx` / `dy` (the first value),\n `font-family`, `font-size`, `font-weight`, `font-style`, `text-anchor`,\n `dominant-baseline`, and `` children. Not supported: wrapping,\n `textPath`, `textLength`, `letter-spacing`, per-glyph positions, emoji. One\n line per `text` or `tspan`: break lines yourself.\n- `render/text` wraps text and takes caret styling with\n `richText: \"basic-caret\"`: `^(16)` size, `^(#FF00FF)` color,\n `^(PTSans-Bold.ttf)` font, `^(underline)`, combined as `^(16,#FF0000)`,\n `^(reset)` to reset. Prefer it for paragraphs.\n- Fonts: `Ubuntu-*`, `PTSans-*`, `FiraGO-*`, `CormorantGaramond-*`,\n `LiberationSans-*`, `LiberationSerif-*`, `LiberationMono-*`,\n `CascadiaMono`, `ComicRelief`, `Galindo-Regular`, `Peralta-Regular` (all\n `.ttf`), plus the fonts people upload. An SVG `font-family` matches them\n loosely (\"PT Sans\" finds `PTSans-Regular.ttf`); an unknown one falls back to\n the default face. On an ESP32, SVG text always uses the built-in Ubuntu\n face, whatever `font-family` says."}, {"id": "layout-and-color", "title": "Layout and color", "text": "## Layout and color\n\n- The same scene is installed on 800×480, 480×800, 1200×825 and 1600×1200\n panels. Work out every coordinate, size and font size from the frame (in a\n JS app `app.frame.width` / `height`, or `context.imageWidth` / `imageHeight`\n when drawing into a cell; in a code node `context.imageWidth` /\n `imageHeight`), for example `unit = Math.min(W, H) / 100`, and emit\n `` with those numbers. Never\n hard-code 800×600, and never add width or height fields \"just in case\".\n- Branch on orientation: when `H > W`, stack columns vertically.\n- Keep a margin of about 4% of the shorter side, and never draw past the\n edge. Estimate a line's characters as `width / (fontSize * 0.55)`, wrap and\n truncate with an ellipsis yourself in SVG, and shrink a headline that does\n not fit. An 800×480 panel reads well at 24–48 px body text, 1600×1200 at\n 40–80 px.\n- Frames are color e-ink (a Spectra 6 panel dithers to black, white, red,\n yellow, green and blue), black-and-white e-ink, or HDMI and LCD panels.\n Unless asked for monochrome, design with color, but calmly: soft gradients\n of two related tones, deep or muted colors (navy, teal, forest, plum,\n terracotta, ochre, charcoal, warm off-white), and one accent color for the\n key figure. Never put pure primaries side by side or use them as large\n fills. Keep text-to-background contrast high so it survives dithering."}, {"id": "small-frames", "title": "Small frames", "text": "## Small frames\n\nThe scene that runs on a Raspberry Pi with hundreds of MB also gets installed\non an ESP32, where a few MB hold the canvas, the JS heap (at most 8 MB per\nscene), the fonts and the network. Write every code node and JS app for that\ndevice. What counts is what stays alive and how much a render allocates, not\nterse code:\n\n- Parsed objects cost 10–20× their JSON text; strings cost about 1×. Never\n embed big object or array literals (GeoJSON, coordinate lists, lookup\n tables): ship such data as compact strings, SVG path `d` strings or\n delimited text split when needed.\n- Keep only what you draw. Take the fields you need out of a fetched payload\n at once, cut lists to what fits on screen before mapping over them, ask an\n API for fewer rows and fields when it allows, and never keep whole responses\n in module variables, `remember` or state.\n- Build output once: collect parts in one array and `join(\"\")` at the end.\n Keep generated SVG small: round coordinates to one decimal, thin polylines\n to what the pixels can show, scale a shape with one\n ``, and stay in the hundreds of\n elements, not thousands.\n- A scene that renders every second or faster sets up its buffers in `init()`\n and updates them in place: flat arrays of numbers, fixed-size pools, no new\n objects, closures or big strings per frame, no fetch or `JSON.parse` while\n rendering, and a hard cap on particle counts. Those buffers are rebuilt on\n every render on an ESP32, so positions come from the clock.\n- The runtime stops a script after 20 seconds of JavaScript on an ESP32 (30\n on a Pi), and a render or an event after 90 seconds in all (120 on a Pi, 60\n in the preview), HTTP included."}, {"id": "events", "title": "Events", "text": "## Events\n\nAn `event` node starts a flow when its event happens; a `dispatch` node sends\none. A scene listens for the events in the table below with an `event` node\nwhose `keyword` is the event's name, and its flow reads the event's payload as\n`context.payload` (in a JS app, `context.payload` too). A scene can also send\nand listen for events of its own: any other name is a custom event, declared\nin the scene's `customEvents` as `{ name }` (at most 63 characters). A\nschedule or the cloud may send one only when its declaration lists them in\n`origins`: `{ \"name\": \"nextPage\", \"origins\": [\"schedule\"] }`.\n\n| event | an `event` node hears it | a `dispatch` node sends it | `context.payload` | where | what |\n|---|---|---|---|---|---|\n| `init` | yes | | | all | Once per scene, when it is first loaded and its apps are ready — before its first open and render. |\n| `render` | yes | yes | | all | Render the scene. |\n| `open` | yes | | `sceneId` scene | all | Every time the scene becomes the frame's current scene: at boot, and whenever the frame switches to it, including switching back. |\n| `close` | yes | | | all | When the frame switches away from the scene to another one. |\n| `keyDown` | yes | | `code` string, `key` string, `repeat` boolean, `shift` boolean, `ctrl` boolean, `alt` boolean, `meta` boolean, `linuxCode`? integer | Pi, preview | When a key is pressed on a keyboard (or held: `repeat`). |\n| `keyUp` | yes | | `code` string, `key` string, `shift` boolean, `ctrl` boolean, `alt` boolean, `meta` boolean, `linuxCode`? integer | Pi, preview | When a key is released on a keyboard |\n| `textInput` | yes | | `text` string | Pi, preview | Text a key press produced under the frame's keyboard layout, one event per printable key (Ctrl/Alt/Meta chords produce none). |\n| `pointerMove` | yes | | `x` integer, `y` integer, `pointerId` integer, `pointerType` string, `buttons` integer | Pi, preview | When a pointer (a finger, a mouse, a pen) moves |\n| `pointerDown` | yes | | `x` integer, `y` integer, `button` integer, `buttons` integer, `pointerId` integer, `pointerType` string | Pi, preview | When a pointer button is pressed or a finger touches the panel. |\n| `pointerUp` | yes | | `x` integer, `y` integer, `button` integer, `buttons` integer, `pointerId` integer, `pointerType` string | Pi, preview | When a pointer button is released or a finger lifts |\n| `pointerCancel` | yes | | `x` integer, `y` integer, `pointerId` integer, `pointerType` string | Pi, preview | When a pointer that was down is lost without a release: the queue overflowed, the device went away. |\n| `mouseMove` | yes | | `x` integer, `y` integer | Pi, preview | When a mouse moves. |\n| `mouseDown` | yes | | `button` integer | Pi, preview | When a mouse button is pressed. |\n| `mouseUp` | yes | | `button` integer | Pi, preview | When a mouse button is released. |\n| `tap` | yes | | `x` integer, `y` integer, `pointerId` integer, `pointerType` string | Pi, preview | A press and release in one place, within `gestures.tapMs`. |\n| `doubleTap` | yes | | `x` integer, `y` integer, `pointerId` integer, `pointerType` string | Pi, preview | A second `tap` in the same place within `gestures.doubleTapMs` of the first. |\n| `longPress` | yes | | `x` integer, `y` integer, `durationMs` integer, `pointerId` integer, `pointerType` string | Pi, preview | A pointer held in one place for `gestures.longPressMs`. |\n| `swipe` | yes | | `direction` string, `x` integer, `y` integer, `startX` integer, `startY` integer, `pointerId` integer, `pointerType` string | Pi, preview | A press and a release at least `gestures.swipeMinPx` apart, in one of four directions. |\n| `wheel` | yes | | `deltaX` integer, `deltaY` integer, `x` integer, `y` integer | Pi, preview | When a mouse wheel is scrolled. |\n| `turnOn` | yes | yes | | all | Turn the screen on |\n| `turnOff` | yes | yes | | all | Turn the screen off |\n| `gpio` | yes | | `pin` integer, `label` string, `role` string, `action` string, `durationMs`? integer, `wake`? boolean, `atMs`? integer, `level`? integer | all | A GPIO button going `down` or `up`, the moment it happens. |\n| `button` | yes | | `pin` integer, `label` string, `role` string, `action` string, `durationMs`? integer, `wake`? boolean, `atMs`? integer | all | A GPIO button pressed: a `press`, a `doublePress` or a `longPress`, one event per press. |\n| `setSceneState` | | yes | `state`? json, `render`? boolean | all | Set the scene's public state. |\n| `setCurrentScene` | | yes | `sceneId` scene, `state`? json | all | Switch the frame to another scene. |"}, {"id": "js-api-reference", "title": "JS API reference", "text": "## JS API reference\n\nWhat a JS app sees, exactly as the editor's type checker declares it. Code\nnodes see none of this (see [Code nodes](#code-nodes)).\n\n```ts\ntype FrameOSJson = null | boolean | number | string | FrameOSJson[] | { [key: string]: FrameOSJson };\n\ninterface FrameOSAppConfig {\n [key: string]: any;\n}\n\ninterface FrameOSImageSpec {\n width?: number;\n height?: number;\n color?: string;\n opacity?: number;\n svg?: string;\n dataUrl?: string;\n base64?: string;\n [key: string]: any;\n}\n\ninterface FrameOSImageRef {\n __frameosType: \"imageRef\";\n id: number;\n width: number;\n height: number;\n}\n\ninterface FrameOSNodeRef {\n __frameosType: \"node\";\n nodeId: number;\n}\n\ninterface FrameOSSceneRef {\n __frameosType: \"scene\";\n sceneId: string;\n}\n\ninterface FrameOSColorRef {\n __frameosType: \"color\";\n color: string;\n}\ninterface FrameOSStreamRef { __frameosType: \"streamRef\"; id: number; }\n\ninterface FrameOSApp {\n nodeId: number;\n nodeName: string;\n category: string;\n config: FrameOSAppConfig;\n state: Record;\n frame: {\n width: number;\n height: number;\n rotate: number;\n assetsPath: string;\n timeZone: string;\n /** The frame's debug setting (FrameOS 2026.10.13+; undefined before). */\n debug?: boolean;\n /** An ESP32-class runtime, or the preview running as one (FrameOS 2026.10.13+; undefined before). */\n embedded?: boolean;\n };\n initialized?: boolean;\n log(...args: any[]): void;\n logError(...args: any[]): void;\n [key: string]: any;\n}\n\n/** What each built-in scene event carries in `context.payload` (docs/events.md). */\ninterface FrameOSEventPayloads {\n /** Once per scene, when it is first loaded and its apps are ready — before its first open and render. */\n init: Record;\n /** Render the scene. Triggered according to the frame's refresh interval, or when explicitly requested. */\n render: Record;\n /** Every time the scene becomes the frame's current scene: at boot, and whenever the frame switches to it, including switching back. */\n open: { sceneId: string };\n /** When the frame switches away from the scene to another one. Carries no payload. */\n close: Record;\n /** When a key is pressed on a keyboard (or held: `repeat`). `code` is the physical key (`KeyA`, `ArrowLeft`), `key` what it means under the frame's keyboard layout (`a`, `A`, `Enter`). */\n keyDown: { code: string; key: string; repeat: boolean; shift: boolean; ctrl: boolean; alt: boolean; meta: boolean; linuxCode?: number };\n /** When a key is released on a keyboard */\n keyUp: { code: string; key: string; shift: boolean; ctrl: boolean; alt: boolean; meta: boolean; linuxCode?: number };\n /** Text a key press produced under the frame's keyboard layout, one event per printable key (Ctrl/Alt/Meta chords produce none). Made by the dispatcher from `keyDown`; a preview forwards the browser's `beforeinput`. */\n textInput: { text: string };\n /** When a pointer (a finger, a mouse, a pen) moves (not logged) */\n pointerMove: { x: number; y: number; pointerId: number; pointerType: string; buttons: number };\n /** When a pointer button is pressed or a finger touches the panel (not logged). The position is on every pointer event. */\n pointerDown: { x: number; y: number; button: number; buttons: number; pointerId: number; pointerType: string };\n /** When a pointer button is released or a finger lifts (not logged) */\n pointerUp: { x: number; y: number; button: number; buttons: number; pointerId: number; pointerType: string };\n /** When a pointer that was down is lost without a release: the queue overflowed, the device went away. A scene holding a drag lets go. */\n pointerCancel: { x: number; y: number; pointerId: number; pointerType: string };\n /** When a mouse moves (not logged). An alias of `pointerMove` with its old payload. */\n mouseMove: { x: number; y: number };\n /** When a mouse button is pressed (not logged). An alias of `pointerDown` with its old payload. */\n mouseDown: { button: number };\n /** When a mouse button is released (not logged). An alias of `pointerUp` with its old payload. */\n mouseUp: { button: number };\n /** A press and release in one place, within `gestures.tapMs`. Made by the dispatcher from `pointerDown` / `pointerUp` on every host. */\n tap: { x: number; y: number; pointerId: number; pointerType: string };\n /** A second `tap` in the same place within `gestures.doubleTapMs` of the first. The first tap is delivered too. */\n doubleTap: { x: number; y: number; pointerId: number; pointerType: string };\n /** A pointer held in one place for `gestures.longPressMs`. Delivered while it is still held where the host ticks the dispatcher, on release otherwise; no `tap` follows it. */\n longPress: { x: number; y: number; durationMs: number; pointerId: number; pointerType: string };\n /** A press and a release at least `gestures.swipeMinPx` apart, in one of four directions. */\n swipe: { direction: string; x: number; y: number; startX: number; startY: number; pointerId: number; pointerType: string };\n /** When a mouse wheel is scrolled (not logged). Deltas are wheel notches; deltaY is positive when scrolling down (towards the user), deltaX when scrolling right. */\n wheel: { deltaX: number; deltaY: number; x: number; y: number };\n /** Turn the screen on */\n turnOn: Record;\n /** Turn the screen off */\n turnOff: Record;\n /** A GPIO button going `down` or `up`, the moment it happens. `up` says how long it was held. */\n gpio: { pin: number; label: string; role: string; action: string; durationMs?: number; wake?: boolean; atMs?: number; level?: number };\n /** A GPIO button pressed: a `press`, a `doublePress` or a `longPress`, one event per press. */\n button: { pin: number; label: string; role: string; action: string; durationMs?: number; wake?: boolean; atMs?: number };\n /** Set the scene's public state. This is not used for private state changes. */\n setSceneState: { state?: any; render?: boolean };\n /** Switch the frame to another scene. An optional `state` object is applied to that scene's public state. */\n setCurrentScene: { sceneId: string; state?: any };\n}\n\ninterface FrameOSContext {\n /** The name of the event this run is handling. */\n event: keyof FrameOSEventPayloads | (string & {});\n /** That event's payload. */\n payload: any;\n /** Index of the current iteration inside a loop. */\n loopIndex: number;\n /** Key of the current iteration inside a loop. */\n loopKey: string;\n /** Whether the run carries a canvas. */\n hasImage: boolean;\n /** Seconds until the next render. */\n nextSleep: number;\n /** The canvas, when there is one. */\n image?: FrameOSImageRef;\n /** Width of the canvas. In scene pixels, of the canvas this run draws on (a cell's inside render/split); absent when the run has none. */\n imageWidth?: number;\n /** Height of the canvas. In scene pixels, of the canvas this run draws on (a cell's inside render/split); absent when the run has none. */\n imageHeight?: number;\n [key: string]: any;\n}\n\ndeclare const frameos: {\n image(spec?: FrameOSImageSpec): FrameOSImageSpec & { __frameosType: \"image\" };\n svg(svg: string, spec?: FrameOSImageSpec): FrameOSImageSpec & { __frameosType: \"image\"; svg: string };\n node(nodeId: number): FrameOSNodeRef;\n scene(sceneId: string): FrameOSSceneRef;\n color(color: string): FrameOSColorRef;\n log(...args: any[]): void;\n error(...args: any[]): void;\n setNextSleep(seconds: number): void;\n fetchText(url: string): string;\n fetchJson(url: string): any;\n httpRequest(\n url: string,\n options?: {\n method?: string;\n headers?: Record;\n body?: string;\n bodyBase64?: string;\n base64?: boolean;\n timeoutMs?: number;\n find?: string;\n findLength?: number;\n findLimit?: number;\n }\n ): { status: number; body?: string; bodyBase64?: string; found?: string[]; error?: string };\n fetchImage(\n url: string,\n options?: {\n headers?: Record;\n saveAsset?: { name: string; extension?: string; mode?: \"auto\" | \"always\" };\n exif?: boolean;\n // The image's color profile (a .frameos.json sidecar's colors object); null = none, clears the last palette.\n colors?: { [key: string]: any } | null;\n }\n ): {\n image: FrameOSImageRef | null;\n width?: number;\n height?: number;\n error?: string;\n savedAs?: string;\n exif?: Record;\n exifSummary?: string;\n };\n listAssets(dir?: string): string[];\n assetExists(path: string): boolean;\n assetSize(path: string): number;\n readAsset(path: string, options?: { offset?: number; length?: number }): string | null;\n writeAsset(path: string, base64: string): boolean;\n appendAsset(path: string, base64: string): boolean;\n deleteAsset(path: string): boolean;\n loadAssetImage(path: string): FrameOSImageRef | null;\n openAssetStream(path: string, mode?: \"r\" | \"w\" | \"a\"): FrameOSStreamRef | null;\n createStream(base64: string): FrameOSStreamRef | null;\n streamRead(stream: FrameOSStreamRef | number, length: number): string | null;\n streamWrite(stream: FrameOSStreamRef | number, base64: string): boolean;\n streamAtEnd(stream: FrameOSStreamRef | number): boolean;\n streamRewind(stream: FrameOSStreamRef | number): boolean;\n streamClose(stream: FrameOSStreamRef | number): boolean;\n getSetting(...path: string[]): any;\n setState(key: string, value: FrameOSJson): void;\n /** Keep a value for this node between renders (FrameOS 2026.10.13+). null forgets it. */\n remember(key: string, value: FrameOSJson): boolean;\n recall(key: string): FrameOSJson | undefined;\n};\n\n/** console.log and friends log like frameos.log; warn says so; error goes to the error log (FrameOS 2026.10.16+). */\ndeclare const console: {\n log(...args: any[]): void;\n info(...args: any[]): void;\n debug(...args: any[]): void;\n warn(...args: any[]): void;\n error(...args: any[]): void;\n};\n```"}, - {"id": "checklist", "title": "Checklist", "text": "## Checklist\n\n- `settings.execution` is `\"interpreted\"`, and one `render` event node starts\n one straight render flow.\n- Every app keyword exists, every `config` key is one of its fields, and\n every select value is one of its options.\n- `node` fields are edges from `field/`, never config.\n- Every code node argument is declared in `codeArgs` and fed by an edge.\n- Images go only into image fields; nothing keeps an image in state.\n- Time formats in code nodes use `{hour/2}`-style tokens.\n- JS apps are data apps exporting `get` (wired into `render/image`) or logic\n apps exporting `run`.\n- A JS app keeps what must outlast a render with `frameos.remember` (private)\n or `frameos.setState` (shared), never in a module variable.\n- SVG uses only the supported tags, gradients use `userSpaceOnUse`, and every\n dynamic string is escaped.\n- Sizes come from the frame, not from constants.\n- Nothing big is embedded as an object literal or kept alive between renders."}, + {"id": "checklist", "title": "Checklist", "text": "## Checklist\n\n- `settings.execution` is `\"interpreted\"`, and one `render` event node starts\n one straight render flow.\n- Every app keyword exists, every `config` key is one of its fields, and\n every select value is one of its options.\n- `node` fields are edges from `field/`, never config.\n- Every code node argument is declared in `codeArgs` and fed by an edge.\n- Images go only into image fields; nothing keeps an image in state.\n- Time formats in code nodes use `{hour/2}`-style tokens.\n- A JS app whose node feeds a field is a `data` app exporting `get`; one whose\n node sits in a flow, after any event node, is a `logic` app exporting `run`.\n- A JS app keeps what must outlast a render with `frameos.remember` (private)\n or `frameos.setState` (shared), never in a module variable.\n- SVG uses only the supported tags, gradients use `userSpaceOnUse`, and every\n dynamic string is escaped.\n- Sizes come from the frame, not from constants.\n- Nothing big is embedded as an object literal or kept alive between renders."}, ]; /** The whole guide, as markdown. */ diff --git a/cloud/apps/auth-web/src/lib/ai/scene-lint.test.ts b/cloud/apps/auth-web/src/lib/ai/scene-lint.test.ts index bf1f18d47..4a6505f3c 100644 --- a/cloud/apps/auth-web/src/lib/ai/scene-lint.test.ts +++ b/cloud/apps/auth-web/src/lib/ai/scene-lint.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { exampleScenes } from "./context"; +import { appCatalog, exampleScenes } from "./context"; import { formatLintIssues, lintScenes } from "./scene-lint"; import type { JsonObject } from "./scene-utils"; @@ -495,6 +495,85 @@ describe("lintScenes", () => { ]); }); + // The chess-puzzle turns that wired button → toggleSolution → dispatch + // render with an app.ts exporting only get(): the frame calls run() in a + // flow, so a press changed nothing. The lint used to ask for that get(). + it("asks a scene app in a flow for run() and one feeding a field for get()", () => { + const toggleScene = (appTs: string, configJson: string) => + scene({ + apps: { toggleSolution: { origin: "toggleSolution", sources: { "app.ts": appTs, "config.json": configJson } } }, + edges: [ + { id: "e1", source: "ev", sourceHandle: "next", target: "text", targetHandle: "prev", type: "appNodeEdge" }, + { id: "e2", source: "button", sourceHandle: "next", target: "toggle", targetHandle: "prev", type: "appNodeEdge" }, + { id: "e3", source: "toggle", sourceHandle: "next", target: "redraw", targetHandle: "prev", type: "appNodeEdge" }, + ], + nodes: [ + { data: { keyword: "render" }, id: "ev", type: "event" }, + { data: { config: { text: "hi" }, keyword: "render/text" }, id: "text", type: "app" }, + { data: { keyword: "button" }, id: "button", type: "event" }, + { data: { config: {}, keyword: "toggleSolution" }, id: "toggle", type: "app" }, + { data: { config: {}, keyword: "render" }, id: "redraw", type: "dispatch" }, + ], + }); + const logicConfig = JSON.stringify({ category: "logic", fields: [], name: "Toggle", output: [] }); + const toggle = "frameos.setState('revealed', !app.state.revealed)"; + + expect(messages([toggleScene(`export function get(app, context) { ${toggle}; return null }`, logicConfig)]).errors).toEqual([ + expect.stringContaining('logic-category JS app must `export function run(app, context)`'), + expect.stringContaining('Node toggle runs scene app "toggleSolution" in a flow, but its app.ts exports no run()'), + ]); + // The category comes from config.json, as on the frame, so an apps entry + // without one is still a logic app. + expect(messages([toggleScene(`export function run(app, context) { ${toggle} }`, logicConfig)]).errors).toEqual([]); + expect(messages([toggleScene(`function toggle(app) { ${toggle} }\nexport { toggle as run }`, logicConfig)]).errors).toEqual([]); + expect(messages([toggleScene(`export default { run(app) { ${toggle} } }`, logicConfig)]).errors).toEqual([]); + // No category anywhere: the node's place decides, and either export will do. + expect(messages([toggleScene(`export function run(app, context) { ${toggle} }`, "{}")]).errors).toEqual([]); + expect(messages([toggleScene(`export function toggle(app) { ${toggle} }`, "{}")]).errors).toEqual([ + expect.stringContaining("a JS app must export `get(app, context)` (a data app"), + expect.stringContaining('Node toggle runs scene app "toggleSolution" in a flow, but its app.ts exports no run()'), + ]); + + const dataConfig = JSON.stringify({ category: "data", fields: [], name: "Label", output: [{ name: "text", type: "string" }] }); + const valueScene = scene({ + apps: { label: { sources: { "app.ts": "export function run(app) { return 'hi' }", "config.json": dataConfig } } }, + edges: [ + { id: "e1", source: "ev", sourceHandle: "next", target: "text", targetHandle: "prev", type: "appNodeEdge" }, + { id: "e2", source: "label", sourceHandle: "fieldOutput", target: "text", targetHandle: "fieldInput/text", type: "codeNodeEdge" }, + ], + nodes: [ + { data: { keyword: "render" }, id: "ev", type: "event" }, + { data: { config: {}, keyword: "render/text" }, id: "text", type: "app" }, + { data: { config: {}, keyword: "label" }, id: "label", type: "app" }, + ], + }); + expect(messages([valueScene]).errors).toEqual([ + expect.stringContaining('is category "logic" and exports run()'), + expect.stringContaining('Node label\'s output feeds a field, but scene app "label" (app.ts) exports no get()'), + ]); + }); + + it("accepts the repo's logic app template in a button flow", () => { + const template = appCatalog()["repo/apps/logic/jsLogic"]; + expect(template?.sources?.["app.ts"]).toContain("export function run("); + const { errors } = messages([ + scene({ + apps: { jsLogic: { ...template, origin: "repo/apps/logic/jsLogic" } as unknown as JsonObject }, + edges: [ + { id: "e1", source: "ev", sourceHandle: "next", target: "text", targetHandle: "prev", type: "appNodeEdge" }, + { id: "e2", source: "button", sourceHandle: "next", target: "logic", targetHandle: "prev", type: "appNodeEdge" }, + ], + nodes: [ + { data: { keyword: "render" }, id: "ev", type: "event" }, + { data: { config: { text: "hi" }, keyword: "render/text" }, id: "text", type: "app" }, + { data: { keyword: "button" }, id: "button", type: "event" }, + { data: { config: {}, keyword: "jsLogic" }, id: "logic", type: "app" }, + ], + }), + ]); + expect(errors).toEqual([]); + }); + it("rejects SVG the frame renderer cannot draw and accepts the one gradient form it can", () => { const withSvg = (svg: string) => scene({ diff --git a/cloud/apps/auth-web/src/lib/ai/scene-lint.ts b/cloud/apps/auth-web/src/lib/ai/scene-lint.ts index fb3fa3527..ee060f87f 100644 --- a/cloud/apps/auth-web/src/lib/ai/scene-lint.ts +++ b/cloud/apps/auth-web/src/lib/ai/scene-lint.ts @@ -51,6 +51,8 @@ type ResolvedApp = { fieldNames: Set; nodeFieldNames: Set; source: "catalog" | "scene" | "inline"; + /** A JS app's main module, for the export its node needs (moduleExports). */ + main?: { file: string; source: string } | undefined; }; /** Mirrors frontend/src/utils/refreshInterval.ts. */ @@ -113,14 +115,20 @@ function resolveApp( ): ResolvedApp | undefined { let config: JsonObject | AiAppConfig | undefined; let source: ResolvedApp["source"] = "catalog"; + let category: string | undefined; + let main: ResolvedApp["main"]; const inlineSources = obj(data.sources); const inlineConfig = inlineSources ? parseJsonString(inlineSources["config.json"]) : undefined; if (inlineConfig) { config = inlineConfig; source = "inline"; + category = str(inlineConfig.category); + main = mainAppSource(inlineSources); } else if (sceneApps && obj(sceneApps[keyword])) { config = obj(sceneApps[keyword]); source = "scene"; + category = sceneAppCategory(config); + main = mainAppSource(obj(config?.sources)); } else if (catalog[keyword]) { config = catalog[keyword]; } else { @@ -139,10 +147,11 @@ function resolveApp( } } return { - category: str((config as { category?: unknown }).category), + category: source === "catalog" ? str((config as { category?: unknown }).category) : category, fieldNames, fields, keyword, + main, nodeFieldNames, outputType: outputTypeOf(config), source, @@ -376,10 +385,39 @@ export function lintAppImports(file: string, source: string, files: string[]): s return problems; } +/** + * The names a JS app's main module exports. `null` when it has a default + * export (`export default { run }`): the frame calls that object's functions + * too, and a regex cannot read its keys, so nothing is claimed about it. + */ +export function moduleExports(source: string): Set | null { + if (/\bexport\s+default\b/.test(source)) { + return null; + } + const names = new Set(); + const declarations = + /\bexport\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)|\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)|\bexports\.([A-Za-z_$][\w$]*)\s*=/g; + for (const match of source.matchAll(declarations)) { + const name = match[1] ?? match[2] ?? match[3]; + if (name) { + names.add(name); + } + } + // `export { run }`, `export { toggle as run }`. + for (const match of source.matchAll(/\bexport\s*\{([^}]*)\}/g)) { + for (const part of (match[1] ?? "").split(",")) { + const name = part.split(/\s+as\s+/).pop()?.trim(); + if (name) { + names.add(name); + } + } + } + return names; +} + export function lintJsAppSource(source: string, category?: string): string[] { const problems: string[] = []; - const exportsFn = (name: string) => - new RegExp(`export\\s+(?:async\\s+)?function\\s+${name}\\b|export\\s+(?:const|let)\\s+${name}\\b|exports\\.${name}\\s*=`).test(source); + const exported = moduleExports(source); // Verified 2026-08-24 against the wasm runtime: a scene-local app with // category "render" (run() returning frameos.svg) draws nothing and logs // nothing. The path that works is a DATA app whose get() returns the image, @@ -388,19 +426,43 @@ export function lintJsAppSource(source: string, category?: string): string[] { problems.push( 'scene-local apps with category "render" draw nothing in the runtime. Make it category "data" with `export function get(app, context)` returning frameos.svg(...) (output [{name:"image", type:"image"}]) and wire its fieldOutput into a render/image node\'s "image" field.', ); - } else if (!exportsFn("get")) { + } else if (exported && category === "logic" && !exported.has("run")) { + problems.push( + `a logic-category JS app must \`export function run(app, context)\`: its node sits in a flow (after an event node) and the frame calls run() there${exported.has("get") ? ", never get()" : ""}.`, + ); + } else if (exported && category !== undefined && category !== "logic" && !exported.has("get")) { problems.push( - `a ${category ?? "data"}-category JS app must \`export function get(app, context)\` returning its output value; nothing else is called.`, + `a ${category}-category JS app must \`export function get(app, context)\` returning its output value: the frame calls get() when the node's output feeds a field.${exported.has("run") ? ' An app that acts in a flow instead (after an event node: sets state, the next sleep) is category "logic" and exports run().' : ""}`, + ); + } else if (exported && category === undefined && !exported.has("get") && !exported.has("run")) { + // No category anywhere: where its node sits says which one it needs. + problems.push( + "a JS app must export `get(app, context)` (a data app: its node's output feeds a field) or `run(app, context)` (a logic app: its node sits in a flow, after an event node); the frame calls nothing else.", ); } if (/(^|[^.\w])(?:format|now|parseTs)\s*\(/.test(source)) { problems.push( - "JS apps have no format()/now()/parseTs() (those exist only in code nodes). Use Date (UTC only) or take the formatted value in through a field fed by a code node or data/clock.", + "JS apps have no format()/now()/parseTs() (those exist only in code nodes). Use Date, which reads the frame's local time, or take the formatted value in through a field fed by a code node or data/clock.", ); } return problems; } +/** The category the frame runs a scene app as: its config.json's (all the runtime reads), else the apps map entry's. */ +function sceneAppCategory(entry: JsonObject | undefined): string | undefined { + return str(parseJsonString(obj(entry?.sources)?.["config.json"])?.category) ?? str(entry?.category); +} + +/** The app's main module (app.ts, app.tsx, …), with its file name. */ +function mainAppSource(sources: JsonObject | undefined): { file: string; source: string } | undefined { + for (const [file, content] of Object.entries(sources ?? {})) { + if (mainAppSourceFile.test(file) && typeof content === "string") { + return { file, source: content }; + } + } + return undefined; +} + // pixie's SVG subset (pixie pin of 2026-08-25): any unsupported tag fails // the whole document; is read for its gradients only, so the tags // below fail inside it too. Gradients must be userSpaceOnUse. @@ -597,9 +659,9 @@ export function lintScene( if (typeof content !== "string" || !/\.(ts|js|tsx|jsx)$/.test(file)) { continue; } - // Only the main module must export get(); helpers export whatever - // app.ts imports from them. - const contract = mainAppSourceFile.test(file) ? lintJsAppSource(content, str(obj(raw)?.category)) : []; + // Only the main module is held to the get()/run() contract; helpers + // export whatever app.ts imports from them. + const contract = mainAppSourceFile.test(file) ? lintJsAppSource(content, sceneAppCategory(obj(raw))) : []; for (const problem of [...contract, ...lintAppImports(file, content, fileNames), ...lintSvgMarkup(content)]) { push("error", `Scene app "${keyword}" (${file}): ${problem}`); } @@ -867,11 +929,13 @@ export function lintScene( const connectedInputs = new Map>(); const codeArgsFed = new Map>(); const chainAdjacency = new Map(); + const chainTargets = new Set(); const valueSourcesUsed = new Set(); const addChain = (from: string, to: string) => { const list = chainAdjacency.get(from) ?? []; list.push(to); chainAdjacency.set(from, list); + chainTargets.add(to); }; edges.forEach((edge, index) => { @@ -1096,6 +1160,32 @@ export function lintScene( } } + // Where a node sits decides which export the frame calls: run() for a node + // in a flow (after an event node, or in a node field such as a split + // cell), get() for one whose output feeds a field. Without that export the + // node does nothing, and the frame used to say nothing either: a chess + // puzzle's button flow ran a get()-only toggle app for three turns. + for (const [nodeId, app] of appByNode) { + const exported = app.main ? moduleExports(app.main.source) : null; + if (!app.main || !exported) { + continue; + } + if (chainTargets.has(nodeId) && !exported.has("run")) { + push( + "error", + `Node ${nodeId} runs scene app "${app.keyword}" in a flow, but its ${app.main.file} exports no run(), so the node does nothing: in a flow (after an event node) the frame calls run(app, context)${exported.has("get") ? ", and get() only when the node's output feeds a field" : ""}. Export run() and do the work there (frameos.setState(key, value) to change state), with category "logic"; or take the node out of the flow and wire its fieldOutput into the field that uses its value.`, + nodeId, + ); + } + if (valueSourcesUsed.has(nodeId) && !exported.has("get")) { + push( + "error", + `Node ${nodeId}'s output feeds a field, but scene app "${app.keyword}" (${app.main.file}) exports no get(), so the value is always empty: the frame calls get(app, context) for a node whose output is wired into a field${exported.has("run") ? ", and run() only in a flow" : ""}. Export get() returning the value, with category "data".`, + nodeId, + ); + } + } + for (const node of nodes) { if (node.type !== "code") { continue; diff --git a/cloud/apps/auth-web/src/lib/ai/tools.test.ts b/cloud/apps/auth-web/src/lib/ai/tools.test.ts index 6c4d430a8..967b38fcb 100644 --- a/cloud/apps/auth-web/src/lib/ai/tools.test.ts +++ b/cloud/apps/auth-web/src/lib/ai/tools.test.ts @@ -213,8 +213,9 @@ describe("create_scenes delivery", () => { // running it, the editor showed no change, and the reply described a toggle // node in the button flow. An app no node runs now bounces back to the model. describe("patch_scene and scene apps no node runs", () => { + // run(), not get(): the node sits in a button flow (scene-lint's flow rule). const toggleApp = { - sources: { "app.ts": "export function get(app) {\n frameos.setState('revealed', true)\n return null\n}\n" }, + sources: { "app.ts": "export function run(app) {\n frameos.setState('revealed', !app.state.revealed)\n}\n" }, }; function current(apps?: Record): Record { diff --git a/docs/js-apps-and-code-nodes.md b/docs/js-apps-and-code-nodes.md index 3f421882d..cb656c0da 100644 --- a/docs/js-apps-and-code-nodes.md +++ b/docs/js-apps-and-code-nodes.md @@ -330,9 +330,15 @@ describe('weather', () => { ### The export that runs +Where the node sits decides the call, not the category: a node in a flow gets +`run()`, a node that feeds a field gets `get()`, and the other export is never +called. A `get()`-only app in a button flow does nothing at all; from FrameOS +2026.10.16 the frame's log says that the app has no `run()` (or no `get()`). + | category | export the runtime calls | how the node is wired | |---|---|---| -| `data` / `logic` | `export function get(app, context)` → returns the `output` value (string, json, or `frameos.svg`/`frameos.image` for an image) | `codeNodeEdge` from `fieldOutput` into another node's `fieldInput/` (e.g. `render/image`'s `image`) | +| `data` | `export function get(app, context)` → returns the `output` value (string, json, or `frameos.svg`/`frameos.image` for an image) | `codeNodeEdge` from `fieldOutput` into another node's `fieldInput/` (e.g. `render/image`'s `image`) | +| `logic` | `export function run(app, context)` → returns nothing; acts with `frameos.setState`, `frameos.setNextSleep`, … | in a flow: `appNodeEdge` from an event node's `next` (or the node before it) into its `prev`, e.g. `button` → the app → a `render` dispatch | `export function init(app)` is optional and runs each time the app's interpreter starts: once per scene on a Pi, before every render on an ESP32 diff --git a/docs/scene-guide.md b/docs/scene-guide.md index fdd5319b8..f4d1066d8 100644 --- a/docs/scene-guide.md +++ b/docs/scene-guide.md @@ -57,18 +57,21 @@ schedule. Every edge is `{ id, source, target, sourceHandle, targetHandle, type }`, and both ends must be nodes of the scene. There are two kinds: -- **The render flow** (`type: "appNodeEdge"`, `sourceHandle: "next"` → - `targetHandle: "prev"`): the `render` event node, then each render or logic - app in order. It is one straight chain: never point two `next` edges at one - `prev`. The apps in it draw on one shared canvas, in order, so never pass the - canvas between them through fields. +- **Flows** (`type: "appNodeEdge"`, `sourceHandle: "next"` → + `targetHandle: "prev"`): every `event` node starts one, and the frame runs + its nodes in order. The render flow starts at the `render` event node and + holds the render and logic apps that draw; a flow from another event + (`button`, `tap`, a custom event) holds logic apps and `dispatch` nodes. A + flow is one straight chain: never point two `next` edges at one `prev`. The + apps in the render flow draw on one shared canvas, in order, so never pass + the canvas between them through fields. - **Values** (`type: "codeNodeEdge"`, `sourceHandle: "fieldOutput"`): a node's output into an app field (`targetHandle: "fieldInput/"`) or into a code node argument (`targetHandle: "codeField/"`). Code, state and data nodes connect only this way, and their source handle is exactly `fieldOutput`, never `fieldOutput/`. -Data apps (category `data`) are never in the render flow; they feed values. +Data apps (category `data`) are never in a flow; they feed values. Images are values too: to show one, put a `render/image` app in the render flow and wire the image into its `image` field. Never put a data app that makes an image straight into the render flow, and never keep an image in @@ -218,15 +221,25 @@ the app's own files resolve: no npm packages, no `require()`, no dynamic apps small: a scene carries a copy of every app it uses, and an ESP32 keeps all its scenes in 512 KiB. -**Exports.** - -- A `data` app exports `get(app, context)`, which returns the value its - `output` names: a string, JSON, or for an image `frameos.svg(...)`, - `frameos.image(...)` or an image from `frameos.fetchImage`. It is not in the - render flow: wire its output into `render/image`'s `image` field or another - node's input. -- A `logic` app in the render flow exports `run(app, context)`, for what it - does rather than returns (state, the next sleep). +**Exports.** Where the app's node sits decides which function the frame +calls, and it calls no other: + +- A node that feeds a field (`fieldOutput` → `fieldInput/`) calls + `get(app, context)`. That is a `data` app: `get` returns the value its + `output` names, a string, JSON, or for an image `frameos.svg(...)`, + `frameos.image(...)` or an image from `frameos.fetchImage`. To show an + image, wire it into `render/image`'s `image` field. +- A node in a flow (after an event node, `next` → `prev`) calls + `run(app, context)`. That is a `logic` app: `run` returns nothing and acts, + with `frameos.setState`, `frameos.setNextSleep` and the like. +- So a `get()` app in a flow does nothing, and a `run()` app feeding a field + gives an empty value. Write the one its node needs. +- A button that changes the picture is a flow of its own: the `button` event + node, a logic app, then a `dispatch` node with `keyword: "render"`. The + app's `app.ts` is + `export function run(app) { frameos.setState("revealed", !app.state.revealed) }`, + and the render flow reads `revealed` from state. `logic/setAsState` does the + same for one value without a JS app. - Never make a JS app with category `render`: it draws nothing. - `init(app)` is optional and runs each time the app's interpreter starts: once per scene on a Pi, before every render on an ESP32 and in a preview @@ -699,8 +712,8 @@ declare const console: { - Every code node argument is declared in `codeArgs` and fed by an edge. - Images go only into image fields; nothing keeps an image in state. - Time formats in code nodes use `{hour/2}`-style tokens. -- JS apps are data apps exporting `get` (wired into `render/image`) or logic - apps exporting `run`. +- A JS app whose node feeds a field is a `data` app exporting `get`; one whose + node sits in a flow, after any event node, is a `logic` app exporting `run`. - A JS app keeps what must outlast a render with `frameos.remember` (private) or `frameos.setState` (shared), never in a module variable. - SVG uses only the supported tags, gradients use `userSpaceOnUse`, and every diff --git a/frameos/src/frameos/js_runtime/app_runtime.nim b/frameos/src/frameos/js_runtime/app_runtime.nim index cdb7b49f1..6b5b4d20b 100644 --- a/frameos/src/frameos/js_runtime/app_runtime.nim +++ b/frameos/src/frameos/js_runtime/app_runtime.nim @@ -1633,12 +1633,22 @@ proc ensureReady(runtime: JsAppRuntime, frameConfig: FrameConfig, sceneId: strin } return globalThis.__frameosModule || {}; } + // A node in a flow calls run(), a node feeding a field calls get(). An app + // without the one its node needs does nothing, so say so, once. + const __frameosMissing = {}; function __frameosInvoke(name) { const mod = __frameosExports(); const fn = mod && mod[name]; - return typeof fn === "function" - ? fn(globalThis.__frameosAppInstance, globalThis.__frameosContext) - : undefined; + if (typeof fn === "function") { + return fn(globalThis.__frameosAppInstance, globalThis.__frameosContext); + } + if (name !== "init" && !__frameosMissing[name]) { + __frameosMissing[name] = true; + jsAppLog("error", JSON.stringify([name === "run" + ? "This app has no run(), so it does nothing in a flow: a node after an event calls run(app, context), and get() only when its output feeds a field." + : "This app has no get(), so its output is empty: a node feeding a field calls get(app, context), and run() only in a flow."])); + } + return undefined; } """ & sceneJsPrelude) # The test harness, when this runtime runs an app's tests (app_tests.nim): diff --git a/frameos/src/frameos/js_runtime/tests/test_js_app_runtime.nim b/frameos/src/frameos/js_runtime/tests/test_js_app_runtime.nim index b84e6255a..c191c6da0 100644 --- a/frameos/src/frameos/js_runtime/tests/test_js_app_runtime.nim +++ b/frameos/src/frameos/js_runtime/tests/test_js_app_runtime.nim @@ -342,6 +342,42 @@ suite "js app runtime": check logged[^1]["message"].getStr() == """JS logic app ran {"event":"render","stateKey":"customState"}""" + test "an app without the export its node calls says so, once": + let config = testConfig() + var logged: seq[JsonNode] = @[] + var logger = testLogger(config) + logger.log = proc(payload: JsonNode) = + logged.add(payload) + let scene = FrameScene(id: "tests/js-app-missing-export".SceneId, frameConfig: config, state: %*{}, logger: logger) + let owner = AppRoot(nodeId: 15.NodeId, nodeName: "toggleSolution", scene: scene, frameConfig: config) + let context = ExecutionContext(scene: scene, event: "button", payload: %*{}, hasImage: false, loopIndex: 0, loopKey: ".", nextSleep: -1) + + # A chess puzzle's toggle: get() only, in a button flow, which calls run(). + let getOnly = newJsAppRuntime( + category = "logic", + outputType = "", + source = """export function get(app) { + frameos.setState("revealed", !app.state.revealed) + return null + }""" + ) + getOnly.run(owner, %*{}, context) + getOnly.run(owner, %*{}, context) + check scene.state{"revealed"}.isNil + check logged.len == 1 + check logged[0]["event"].getStr() == "error:15:toggleSolution" + check logged[0]["error"].getStr().startsWith("This app has no run(), so it does nothing in a flow") + + logged.setLen(0) + let runOnly = newJsAppRuntime( + category = "data", + outputType = "text", + source = """export function run(app) { return "hi" }""" + ) + discard runOnly.get(owner, %*{}, context) + check logged.len == 1 + check logged[0]["error"].getStr().startsWith("This app has no get(), so its output is empty") + test "log lines read like console.log, not like the JSON array of the arguments": let config = testConfig() var logged: seq[JsonNode] = @[]