# Rendering, browsers and captures

Which browser measures, captures and tests use (the app's Electron workers or an external engine), and how one screen is rendered for the app and for looks. Part of the [architecture](../architecture.md), the contract every change follows.

## Browser services supplied by the desktop app

The shared launcher in `@elabora/node/emulator/browser` defaults to the app's bundled Chromium when a runtime is configured by the packaged CLI or registered by the installed app. The registration in the machine-local Elabora folder records only the executable and protocol version; it is not course content. A standalone installation without the app retains Chrome → Edge → Playwright Chromium. Explicit external-engine choices and `ELABORA_BROWSER=external` bypass discovery.

The app entry dispatches `--elabora-browser-worker` before initializing the editor. Each Playwright context owns a worker process and temporary user-data folder. Playwright controls its real pages, frames and CDP sessions; the adapter supplies window creation and context lifetime because Electron does not implement Chromium's context/target creation commands. Workers have no editor preload, Node integration or project-write service. Parent/opener relationships remain real. Closing or timing out a context terminates only its worker, with a forced-stop deadline for stuck renderers. Browser feature variants and software WebGL are launched in separate workers.

LMS results and object gate records identify the engine/version. Object visual baselines include the engine and OS (legacy Chromium names remain readable); an Electron run never updates an external engine's baseline. The app ships the runtime and the object-test harness sources it bundles on demand. Browser selection does not change the player, SCORM emulator, test definitions or deterministic package contents.

Headless workers on Windows create offscreen windows, including popups. Hidden native windows can stop supplying surfaces to repeated CDP screenshots, leaving contrast and fit measurements waiting indefinitely. Headed workers keep native windows.

## Browser for measures, captures and tests

`elabora fit`, captures, free-object gates and the LMS emulator use the shared launcher (`emulator/browser.ts`). By default they use the installed app's isolated Electron workers, as described above. Without an app runtime, or with `ELABORA_BROWSER=external`, the launcher tries the installed Google Chrome (channel `chrome`), Microsoft Edge (channel `msedge`) and Playwright's downloaded Chromium (`CHROMIUM_CHANNELS`), each with the same flags. Without any, the message says which were looked for and to install Google Chrome. Explicit external-engine choices bypass app discovery. Every Chromium starts with `--hide-scrollbars` (`chromiumArgs`), as headless Chrome does by itself: a classic scrollbar (macOS with a mouse plugged in, Windows, Linux) takes 15 px of the window, so a capture at 375 px would be 360 px wide, and Electron's object snapshots would depend on the author's mouse. A capture shows the page at the window's whole width, as a phone draws it (`test/e2e/capture-width.test.ts`).

Edge draws as Chrome of the same version, in software too (the same SwiftShader; of the example objects' 22 snapshots, 21 were identical pixel for pixel and one differed by 2/255 on 16 pixels of text, as two runs of Chrome may; `test/e2e/edge.test.ts`). Results identify the engine and version that ran, and object baselines are kept per engine and OS.
- **Rendering.** Free-object gates and the LMS emulator draw in software (SwiftShader: the same pixels on every machine, what visual baselines need). The gates draw only the frames their snapshots show: the others run dry, the object's code without its draws, and a run that saw anything a skipped draw would have changed is done again with every frame drawn (`@elabora/player` `dry-frames.ts`, docs/free-object-contract.md §6); their snapshots are PNGs Chrome encodes for speed. Captures, fit measures, replays, contact sheets and looks draw with the machine's GPU (`launchBrowser(…, { rendering: 'gpu' })`, SwiftShader where there is none): software also composites every page on the CPU. A capture of the 3D screen of `diagnostic-reseau` took 10 s that way, 4 s on the GPU; one of `medieval-charts`, 37 s and 7 s. Measures that read the pixels of a window (contrast) ask Chrome for a PNG encoded for speed (lossless; about 0.1 s instead of 0.6 s a window).
- **Looking while designing** (`@elabora/node` `look.ts`). A capture builds the package, starts a browser, boots the course and its objects and closes everything. A **looker** keeps a canvas host (with the player's bars: canvas page option `chrome=player`, inert) and a GPU browser alive, one page per window size; a look reads the project from disk (`loadProjectFromDir`, milliseconds), renders the screen (`renderScreen`), pushes it to the page, which updates in place (objects whose code and assets did not change keep running), waits for `ready` and for objects that started to settle, and captures the page (JPEG by default: PNG takes seconds to encode on textured pages). A **look server** keeps a looker per project between commands: `elabora look` and MCP `elabora_look` find it through `<data>/look/server.json` (base URL, token, process, Elabora version) with `look-client.ts`, start it in the background when none answers (`elabora look <dir> --serve`, detached; one start at a time), and it stops after 15 minutes without a look or on `elabora look --stop`. Its HTTP API (`/look/info`, `/look/shot`, `/look/stop`) requires the token of `server.json`. Its page (`Looker.url`) follows the looks and the project's files, for a browser pane. The client imports nothing heavy, and `bin/elabora.ts` runs `elabora look` before loading the rest of the command line: a warm look after an edit takes 0.3 s from the command to the image on `diagnostic-reseau` (0.13 s in the server). A look can also **inspect**: `scroll` puts the window at a position (px, a share of the page, a block's id; `scroll-strip.ts`, shared with `elabora screenshot --scroll`) on a page that prefers motion; several positions, or a sheet of a screen without a timeline, give a contact sheet down the page. `block` crops the capture to one block's box and a margin, through the devtools protocol's clip with a scale (`look-inspect.ts`; a devtools session of its own draws at the screen's pixel ratio, measured once per page and divided out). `act` plays clicks (found by role or whole text, through Playwright's access to the object's frame, which has an opaque origin), keys and drags in a free object's frame, then reads its state and events from the canvas's object host. `motion-report.ts` measures, in any page that shows a screen, which blocks the window shows and how much, their CSS animations and transitions (`document.getAnimations()`: timeline, range, progress) and, by scrolling to the end and back, the progress of the window's scroll-driven animations there: below 1, the animation cannot finish. The build and the checks stay the final word.
- **Frames in browser panes.** Packages load a free object next to the course page in `<iframe sandbox="allow-scripts">`: an opaque origin. The in-app browsers of agent apps (Claude's browser pane) refuse such frames (`ERR_BLOCKED_BY_CLIENT`) and what they ask for. Local previews therefore serve the same files on a second port (`startServer({ frames: true })`, `framesPort`) and the page loads frames from there with `allow-same-origin` (`@elabora/player` `frames.ts`): another origin, so the object still cannot reach the page, but the same site, so the frame stays in the page's process and a pane that approves sites asks once. The preview tells its course page with `<meta name="elabora-object-frames" content="<port>">`, the canvas page with `?frames=<port>`; built packages never carry either. Objects that would use storage or cookies behave differently there than in a package; the gates run them in the package's sandbox.

## Rendering a screen

`renderScreen(project, screenId, { state, step, layer, language, dialogue }, context?)` in core (`render/screen.ts`) returns what the player needs to show one screen: the course data reduced to that screen (course-level fields, its section, the screen with the HTML of its component templates, the free objects it places and the backdrop's), the view to show, the design stylesheets (component and course stylesheets in their cascade layers, font faces, token variables: one sheet each, `url()` rebased on the package root) and the project files the page will ask for, each with the hash of its content (of its inputs for generated object files): the canvas asks again, with `?v=<hash>`, for a file changed at the same path, renders again the blocks that use it, and restarts a free object whose code or assets changed. The compiler builds the package's course data and `elabora/theme.css` from the same functions (`compileScreen`, `courseHead`, `designStylesheets`), so the editor canvas, captures and packages cannot drift apart.
- `createRenderContext(project, { language })` computes once what every screen shares (registry, plan index, state keys as the build assigns them, course fields, backdrop, stylesheets). Reuse it while `course.json`, `plan.json`, `elabora.lock.json`, `components/`, object manifests and the theme are unchanged; a screen edited since renders with it (entities it adds get provisional keys, after those of the lock; a component type new to the course brings its stylesheet). The engine memoises renders per screen and view, and forgets one when the screen file or a file it lists changes (see *Engine*).
- **Canvas.** `elabora/canvas.js` (`@elabora/player` `canvas.ts`) mounts the player for one screen without SCORM, navigation chrome or saving, with the code packages use (`screen-view.ts`). Render data for the screen shown updates it in place: blocks whose data did not change keep their nodes (moved with `moveBefore`, so a free object keeps its frame and WebGL context), the scroll position, the step and the open layer stay. Another screen is shown as the player shows it, continuity included (below). It reports the box of every block (`elabora/layout`, `layout-report.ts`, shared with the review bridge) so the app draws selection and handles over it, and the box of each text the author can point at (`fields`: the engine renders the canvas with `fields`, so templates mark with `data-t-field="options.1.label"` an element whose whole content is one text prop; built-in blocks are templates too), and the texts outside the blocks as screen readers meet them (`texts`: the screen's title, a layout's marked texts, its reading time and pictures, an open layer's title, each with the first block after it and whether it is read), from which the app draws the reading order. Packages and captures are never marked. Messages: `canvas-protocol.ts`.
- **Dialogues** (D61). A dialogue on the canvas shows itself at rest, never playing: its thread up to its first decision, the options in their room. The view's `dialogue: { block, choices?, node? }` (an `elabora/view` message, or `renderScreen(…, { dialogue })`, the engine's `canvasView { dialogue }`) shows one after these choices (option ids, one per decision, from its start: what the learner would have chosen), with the thread so far, the options of the decision reached or its end and debrief; with `node`, then at that node (the author selected it in the graph). `dialogue: null`, or another block, puts the one shown before back at its start. The player does it with the walk the learner runs (`view.dialogues.showPath`), so the canvas cannot show a path the learner could not see; choices that no longer exist stop the path where it is. Test: `test/e2e/dialogues.test.ts`.
- **A focal point dragged** (D63). `{ type: "elabora/focus", block, x, y }` (host → canvas, with the page's token) gives the block's pictures that focal point at once (`--focus-x`, `--focus-y`, so their `object-position`), clamped to 0..1, without a render: the inspector moves the crop while the author drags. The next render gives every element its own value again, the saved one once the change is written. A block not on the screen shown is answered with `elabora/error`. Test: `test/e2e/canvas.test.ts`.
- **Focus.** The canvas never takes the focus from its host. What a package focuses when it opens something (a panel, a layer, the PDF's preview, a revealed step) moves the focus in the canvas only when the canvas has it already, the author working in it; opened by a render, a push or the host's messages, it opens unfocused (a dialog is shown inert, so that the dialog focusing steps move nothing, then made interactive). Test: `test/e2e/canvas-live.test.ts`.
- **Page options**, in the canvas page URL: `chrome=player` shows the player's bars around the screen as the course configures them (D58), inert (looks); `chrome=live` shows them working, the player's preview (below); `frames=<port>` loads object frames from that port (see *Frames in browser panes*).
- **The player's preview** (`chrome=live`: the app's « Lecteur » tab, Lin 44; `@elabora/player` `canvas-live.ts`). The bars are wired as packages wire them, around the screen the host renders: Ressources, Glossaire and « IA et crédits » open their panels between the bars, a PDF or an image of the resources opens in its preview, a term of the screen shows its definition, « Affichage » and « Langue » open their menus (the display choices live as long as the page), the outline opens, and a narrated screen's controls play, with the transcript in its panel. What leaves the screen stays the host's: Previous, Next and the outline say where they would go, a language chosen says which, and the canvas shows another screen or language only when the host renders it. Next on a slide with steps left reveals the next one, as Continue does; nothing is locked. The render data carries what the bars need: the course's outline (`outline`: sections and screens with their titles in the language shown), the resources, « IA et crédits », the files of the logo and of the resources (so the host serves them) and, when « Langue » is enabled, the languages a package of every language would hold (D59). A change of `player`, the resources, the glossary or the credits is a change of `course.json`: the engine renders the screen shown again and pushes it, the canvas mounts the bars again without a reload, and the panel, menu or preview that was open opens again. What the bars show that depends on every screen (`renderChrome`: the outline, the items that show, « IA et crédits », the languages) is kept apart from the render of a screen: the engine makes it again after a change to any screen or asset, every render carries the current one, and when it changed the engine sends the screen shown again (a screen renamed reaches the outline at once).
  - **Protocol `elabora-player-preview/1`**, on top of the canvas messages (`canvas-protocol.ts`, types the app imports without Node). Host → canvas, posted by the parent window with the page's token, each with `protocol: "elabora-player-preview/1"`: `{ type: "elabora/highlight", item, bar? }` outlines an item of the bars (`null`: none; `bar` tells the two spacers apart; a highlighted spacer shows its room); `{ type: "elabora/open", panel, resource?, term? }` opens `resources` (with a resource, its preview), `glossary` (at a term), `about`, `outline`, `display`, `language` or `transcript`, one thing at a time; `{ type: "elabora/close" }` closes what is open over the screen. Canvas → host: `{ type: "elabora/ready", chrome: "live", protocol }`; `{ type: "elabora/item", protocol, id, bar }` when the author clicks an item (its action runs too); `{ type: "elabora/navigate", screen, by: "previous" | "next" | "outline" }` (an event of the screen sends `elabora/navigate` without `by`, its `next` and `previous` resolved to screens); `{ type: "elabora/language", protocol, language }`. A message of another version, or a panel the course has nothing for, is answered with `elabora/error`. Tests: `test/e2e/canvas-live.test.ts`.
- **Host.** `@elabora/node` `startCanvasHost({ root })` (or `createCanvasHandler`, a route for any server) serves the canvas page, the player bundles, generated object frames and code, and the project files the pushed renders list, streamed from the project folder with range requests; `push(render)` sends render data to the pages (server-sent events), without the styles a page already has.
