Menu

Rendering, browsers and captures

On this page

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, 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.