Menu

Development history

On this page

Unreleased — task-scoped agent guidance and skill discovery (2026-10-17)

  • Course rules and the authoring/design skills are short task routers, with detailed recipes loaded on demand. Narration, translation and Storyline/Rise importing have focused skills. Unedited course rules update through D34’s existing revision mechanism; author notes and edited rules are preserved.
  • elabora tools skills [<name> [<reference>]] [--json] lists bundled skills or reads one skill/reference without a course. MCP exposes the same references at elabora://skills/<name>/references/<reference>; the agent guide describes both interfaces.
  • Validation tests account for Windows crash exits, file permissions and folder enumeration.

Unreleased — Windows browser worker captures (2026-10-17)

  • Headless Electron workers on Windows use offscreen windows so repeated screenshots, contrast and fit measurements keep receiving frames. The app applies the same setting to worker popups.

Unreleased — automatic word alignment progress and failures (2026-10-17)

  • Voice generation emits a distinct voiceProgress phase align while a line’s words are timed, including when an earlier take is selected. A failed automatic alignment keeps the audio and reports alignmentError on the line’s result so editors can explain that its words remain estimated.

Unreleased — compatible local model releases and Intel platform policy (2026-10-17)

  • Optional local models select exact compatible releases, stage upgrades beside active versions and retain directory leases until inference finishes (D55). Workers switch directories between calls, and provenance records the release actually used. Update-only intent never reinstalls an absent element.
  • Intel Macs retain editing, recordings, existing narration and media conversion; the core refuses installation and inference for local voice generation, word alignment and transcription on darwin/x64 (D54). Downloaded models remain removable. The app supplies update network policy and the shared status panel.

Unreleased — documentation for people and agents, ready to be public (2026-10-17)

  • One map: docs/index.md lists every page with one line of what it helps with, by section; GitHub shows it, the site renders the same files (elabora.studio/en/docs/developers) and its llms.txt, and elabora docs reads it. New pages: docs/getting-started.md, CONTRIBUTING.md, and two generated references, docs/cli.md (the help) and docs/mcp-tools.md (every agent operation and its arguments). The README is a short introduction with a capture of a sample course; its details moved to these pages.
  • Smaller pages: the decision log is one file per decision, docs/decisions/D01.md…D53.md, and docs/decisions.md their generated index; the architecture is a summary of the contract (docs/architecture.md) and one page per subsystem in docs/architecture/ (operations and identifiers, media, delivery and hosting, the plan, storage, import, rendering, engine). Code comments cite the new pages.
  • elabora docs [page] [--section <heading>] [--full] and the read tool elabora_docs (also tools call) give agents the documentation without a checkout: the map, a page by its id (design, spec, architecture/media) or a decision by its number (D29), a long page as its opening and outline, or one section. The MCP server serves the pages as resources too (elabora://docs/<id>). Tool descriptions, diagnostic fixes, the visual-design skill, the server’s instructions, the agent guide and the course AGENTS.md (revision 6) point to it.
  • Rules defined: @elabora/core/diagnostics/rules states each guardrail and LMS checklist rule a diagnostic cites (A1…I1, LMS-n); docs/diagnostics.md lists them with their codes.
  • Public wording: the docs, the changelog and comments no longer point to private notes, reports, tracker numbers or people; the private provenance of the decisions and the open questions were moved out of the repository.
  • Checks: test/unit/docs.test.ts (every page on the map, links and anchors resolve, the docs/….md paths and Dn the code names exist, no raw HTML outside code, every cited rule is defined, elabora docs), and .github/workflows/docs.yml, which runs it and fails when a generated page is out of date, on every pull request including Markdown-only ones.

Unreleased — translation CLI writes use the shared command executor (2026-10-17)

  • translate export and translate approve now use the same locked, validated, journaled and undoable commands as the engine. Export JSON includes its change/undo reference; the Node helpers retain their existing count/approval fields and accept explicit edit options for embedding.
  • approve_translation refuses an agent author in the core command (E_PLAN_PERSON_ONLY), matching plan-version approval. Human scripts identify themselves explicitly as for other plan approvals. There is still no agent approval tool.
  • Tests cover CLI export undone over MCP, agent approval refusal without writes, human approval and cross-interface undo, and structured translation build errors.

Unreleased — agent bootstrap and host-selected tools (2026-10-17)

  • elabora_agent_guide, elabora tools guide [--json] and exported AGENT_GUIDE expose the same provider-independent workflow index: interface choices, tool names, authoring/translation/design/narration/review/delivery, and human decisions.
  • mcp --tools <names> and ToolOptions.tools select tools for plugins/embedded hosts. The server fixes its selection for the connection; discovery and dispatch agree, read-only intersects it, and unknown names fail early. tools list --tools previews the selection.
  • Updated the course AGENTS.md, examples, authoring skill, architecture and integration guide: connected MCP when available, CLI shared operations otherwise, direct edits for code/design, no automatic agent-configuration changes. D53 records the interface contract.

Unreleased — complete translation workflows on the source server (2026-10-17)

  • elabora_translation_status reports text readiness, schema/invariant problems, stale/missing units, word anchors and plan approval. elabora_validate, elabora_check_fit, elabora_preview_screenshot, elabora_build and elabora_test_package accept language and explicit allowMissing drafts; elabora_look accepts the same BCP 47 tags. Agents can finish a translated package through one source-scoped MCP connection, or through tools call.
  • Checks/builds use isolated, cleaned-up translated copies, clone files where supported and omit Git metadata and author sources. Package/proof outputs stay in the source course’s dist/. Translated-plan approval remains human, and release validation enforces it.
  • Tool results carrying ok: false set the MCP isError flag too, so shell callers get a failing exit code. translate status --json now returns the same readiness exit code as text; translate build and approve accept --json, and translation service errors in JSON mode are structured.
  • Authoring guidance and interface fixtures updated; tests cover transport agreement, translation lifecycle, concurrent derived copies and the complete browser/package workflow over MCP.

Unreleased — shared agent operations over MCP and CLI (2026-10-17)

  • elabora tools list [tool] --json discovers the same schemas and annotations as MCP; tools call <tool> [dir] --from <file>|- invokes the same validated handlers, journaled edits and errors without an MCP connection. Stdout is always a JSON tool result; exit codes distinguish success, refusal and malformed input. Read-only discovery and invocation are supported.
  • @elabora/cli exports listAgentTools and callAgentTool for embedded callers, with an explicit author and course root. MCP retains its existing names, schemas and results.
  • docs/agents.md documents transport choice, workflow coverage, direct file edits and intentional human-only actions. The README no longer hardcodes an obsolete tool count.

Unreleased — the plan’s versions: the plan written at any time, versions made by hand, one validated (2026-10-16)

Clean break before publication, with no migration: a plan written before it (with status and approval) breaks the plan’s schema.

  • plan.json is the working copy (D52), written at any time by the author and their agent: write_plan (CLI elabora plan write, MCP elabora_write_plan, in place of draft_plan, elabora plan draft and elabora_draft_plan), set_plan_text (elabora plan text: a validated text reworded, every block that shows it follows), set_plan_voiceover (always written), revert_plan_change (elabora plan revert, « Revenir à la V2 »). status and approval leave plan.json. An agent’s write that changes a text kept « à l’identique » is refused (E_PLAN_VERBATIM_LOCKED). Commands know who runs them (CommandContext.author).
  • Versions in plan-versions/V<n>.json (schema plan-version: version, date, note, contentHash, plan, notes, sends, validation): create_plan_version (elabora plan version [--note]) and approve_plan (elabora plan approve [--version Vn] --by … [--reference] [--date], the latest version by default, never the working copy) are a person’s: refused to an agent (E_PLAN_PERSON_ONLY), and no MCP tool does them. record_plan_send adds a send; set_plan_note (elabora plan note) writes a note for the client on a change in no version yet (plan-versions/notes.json, schema plan-notes), frozen with the next version.
  • What changed, computed: @elabora/core/project/plan-diff planDiff(base, current, course?) (elements matched by ids, added, removed, moved from their position, an item’s part changed, each field, texts word by word; the part an item belongs to, not the field it is written in: a plan without parts that writes each item’s part down, as the app does at the first drag, changed nobody’s part, which planCourse(project) lets the diff know by the course’s sections that gave them) and revertPlanChange; @elabora/core/project/plan-versions (planState, planVersionsView with the changes since the validated version and those in no version, planDiffBetween, notesFor, describePlanVersions, planContentHash). CLI elabora plan diff [--from] [--to] [--json] and elabora plan status [--json]; MCP elabora_plan_diff (read-only); elabora_get_plan gives versions in place of validatedOnce.
  • Rules: E_PLAN_NOT_APPROVED means a release build without a validated version; W_PLAN_MODIFIED_SINCE_VALIDATION replaces E_PLAN_MODIFIED_AFTER_APPROVAL (a warning: the build report’s plan.validated and plan.changesSince, and the proof dossier, list the changes delivered); W_PLAN_VERBATIM_CHANGED; E_PLAN_VERSION_INVALID (a version edited by hand, not counted). Gone: E_RETOUCH_VERBATIM, E_RETOUCH_WITHOUT_TEXTREF, I_TEXT_RETOUCHED, W_CHANGE_REQUEST_INVALID.
  • Gone with them: change requests (changes/CR-*.json, the change-request schema, propose_plan_change, apply_change_request, reject_change_request, elabora change apply|reject, MCP elabora_propose_plan_change) and retouches (the blocks’ retouch, retouch_text, restore_validated_text, approve_retouches, elabora plan retouch|restore|approve-retouches, MCP elabora_retouch_text and elabora_restore_validated_text). elabora rename rewrites the plan’s waivers that name the id.
  • The plan’s document (format elabora/plan-document/2) shows a version, the latest by default, or the working copy (PlanDocumentOptions.version, planDocumentTarget): its head says the version shown and the history up to it; the changes since the latest validated version read in place (EntryMark, titleChange, words deleted and inserted, elements added and removed), as Word tracked changes signed « Depuis la V2 validée », and the notes for the client as Word comments and margin notes. exportPlanDocument, elabora plan export --version Vn|current, elabora_export_plan_document { version } and the engine’s exportPlanDocument record the send on the version shown (ExportedPlanDocument.version, sent) and never make one.
  • Engine: requests planVersions and planDiff; planDocument, planDocumentPage (with the version shown) and exportPlanDocument take version; summary.plan gives latest, validated, sinceValidated, unversioned; commands write_plan, set_plan_text, revert_plan_change, set_plan_note, create_plan_version, approve_plan, record_plan_send (gone: draft_plan, propose_plan_change, apply_change_request, reject_change_request, retouch_text, restore_validated_text, approve_retouches).
  • A translated course holds its approved translation as its own validated V1, and none of the source’s versions.
  • Agents: rule 1 and the workflow of the course AGENTS.md (revision 4), the authoring, pedagogy and free-objects skills and the MCP server’s instructions: the agent writes the plan and says what it changed, never makes nor validates a version, never changes a verbatim text.
  • The examples hold their validated version; diagnostic-reseau has V1 sent, V2 validated, two changes since and a note for the client.

Unreleased — the plan’s days are the author’s, not UTC’s (2026-10-16)

  • elabora plan export dates the document the author’s day (planDocument’s default date, PlanDocumentOptions.now to fix the moment): between midnight and 2 a.m. in France it said yesterday, the UTC day. The approval of the plan (approve_plan), the dates of a retouch, a decision and a client’s approval of a retouch (plan-changes.ts) and a translation’s approval are the same day (@elabora/core/util/local-day localDay, the computer’s time zone; TZ moves it), so that the Sommaire, the Document and the files say the day the author lived. --date still wins. A unit test fixes the moment and the zone (Paris, New York).

Unreleased — the plan’s PDF printed by Elabora’s Electron as the app prints it (2026-10-16)

  • elabora plan export --format pdf, elabora_export_plan_document and the engine’s exportPlanDocument print with Elabora’s Electron when the app is installed (D26, D48) through its own webContents.printToPDF in a browser worker (@elabora/node/emulator/electron printWithElectron): Electron’s DevTools protocol has no Page.printToPDF, so Playwright’s page.pdf failed there and the command went on to Chrome, or failed without it. The file is the same, byte for byte, as the app’s « Exporter ▾ › PDF… ». @elabora/core/plan-document/pdf PLAN_PDF_WINDOW: the offscreen window both print in. A print through Electron that has not ended after 60 s stops its worker and fails.
  • The document’s page fetches nothing: a Content-Security-Policy (default-src 'none', its inline style, its fonts and images as data) in planDocumentHtml, for every Chromium that prints it and every browser that opens the HTML export.

Unreleased — the app imports Storyline and Rise modules: the brief kept, the report settled, the choices made again (2026-10-15)

  • importCourse({ brief, defaultLanguage, replace }) (D47, D51): the brief of « Nouveau module » kept as createProject keeps it (audience, business goal and duration in the draft plan, LMS in course.json, notes in AGENTS.md, its presentation the presentation choice’s default); defaultLanguage when the source says none (Storyline); replace: <project id> imports again in that project’s place, as an archive’s replacement does (same id and folder, history and sources kept, a version just before, its files in the trash), keeping its title, course id, language, AGENTS.md and brief. ImportCourseResult.replaced. A module imported into the library gets a course id no other module there has (the title’s, numbered).
  • The report holds choices (what the import was made with) and, per decision, accepted; settle_import_report { accept?: number[], close?: boolean } (engine command, @elabora/node/edit settleImportReport) marks decisions accepted or deletes import-report.json, one journaled change each.
  • @elabora/node/library/archive replaceWithStaged(targetDir, stage): a folder staged in the library takes a project’s place (the archive’s replacement and the importer share it); checkBrief is exported from @elabora/node/scaffold.

Unreleased — a plan that reads as an outline: parts, short names, a duration, screens that follow (2026-10-15)

  • items[].part and objectives[].short (D50), optional in plan.json: the module’s parts by title (consecutive items with the same part; an item without one continues the part before it) and an objective’s name in three to five words. Written by draft_plan, translated with the plan (units item.<id>.part, objective.<id>.short); the authoring skill asks for them.
  • A duration estimated from the screens: @elabora/core/project/duration (screenEstimates, roundedMinutes, countWords): the words read at 150 a minute or the voice when it says more, a moment per question, decision or free object, at least 10 s. The engine’s summary gives seconds for every screen of its outline.
  • Screens follow the plan’s items (@elabora/core/project/plan-order, followPlanOrder, itemParts): draft_plan and apply_change_request move, in the same change, the screens of items that moved and the sections of parts that moved (renamed with their part when they were called as it was), and say so (moved: screens, sections, renamed); elabora plan draft and elabora change apply print it. Screens covering several items stay; a plan without parts moves no section.
  • The engine runs draft_plan (run), so that the app’s author writes a plan never validated as their agent does (its moves, its fields in Propriétés).
  • D35 amended: the app no longer says who wrote a draft plan.

Unreleased — the plan’s document, in Word and in PDF (2026-10-15)

  • The plan’s document as data (D48): @elabora/core/plan-document/model planDocument(project, { date }) gives what the client reads, comments and signs, without layout: the module’s title, the status (« Bon pour production », « Modifié depuis sa validation », « Brouillon »…), the version, who approved and when, the module’s duration and delivery, then Cadrage, Objectifs, Séquence, Textes validés, Évaluations and Sources, in the module’s language (French, else English). Its texts are the plan’s Markdown and the same as rich-text data (plan-document/rich: parseRich, diffRich, plainOf). A retouch not reviewed yet reads in place, its words deleted and inserted; an approved one says so. It never says who wrote the plan, a change request or a retouch.
  • Word (plan-document/docx): a .docx written by hand, deterministic, with Word’s Title and Heading styles, named styles and no direct formatting but the plan’s own emphasis, maths as Word equations, retouches as tracked changes, and a bookmark around every objective, item, validated text, question and source named after its plan id (wordBookmarkName, planIdOfBookmark), for a later import of the client’s Word comments.
  • PDF and HTML (plan-document/html, plan-document/pdf): one HTML page with its fonts inline and an A4 print stylesheet, printed by Chromium (PLAN_PDF_PLAYWRIGHT, PLAN_PDF_ELECTRON for the app’s printToPDF), tagged and with an outline; finishPlanPdf dates it to the document’s day.
  • elabora plan export [dir] [--format pdf|docx|html] [--out <file>] [--date YYYY-MM-DD] [--json] (default dist/<course id>-plan.<format>), MCP elabora_export_plan_document { format }, @elabora/node/plan-document (exportPlanDocument, writePlanDocument, readPlanDocument, planDocumentPage, planDocumentWord, printPlanDocument), and the engine’s requests planDocument, planDocumentPage and exportPlanDocument.
  • @elabora/core/compile/markdown exports parseMarkdown (the subset’s tokens).

Unreleased — Storyline and Rise modules imported as Elabora modules, beta (2026-10-15)

  • elabora import <file> [dir] (D47) takes a Storyline 360 project (.story), a course Storyline published (web or LMS, a folder, its zip or its story.html) or a Rise 360 export (web, SCORM 1.2 and 2004, xAPI; zipped or not) and makes a new module of it, in dir or else the app’s library; --title, --id, --lang, and --choose <decision>=<choice>,… to run it again with another choice of its report (presentation=scroll, backgrounds=keep, timing=steps, fonts=theme, media-accessibility=errors). An Elabora archive or folder goes on into the library as before; --replace and --copy are theirs.
  • @elabora/node/import/index: detectImport(file) (kind: storyline-source, storyline-published, rise, elabora-archive, elabora-folder or null, the course’s folder inside the source, its packaging, beta) and importCourse({ file, dir?, id?, title?, language?, choices?, author? }) (the module’s folder, the library project when no dir, the report, the files, the fonts added, the validation counts); ImportError (E_IMPORT_UNKNOWN, E_IMPORT_ELABORA, E_IMPORT_TARGET, E_IMPORT_UNREADABLE); also exported from @elabora/node.
  • @elabora/core/import/: the readers (storyline-source.ts, storyline-published.ts, rise.ts), one model (model.ts), the slide’s reading order (layout.ts: a recursive XY cut, or the author’s focus order when a reader gives one), the conversion (convert.ts) and the report (report.ts), with an HTML to Markdown converter and a small XML reader; pure, so the cloud and a browser can import too.
  • The report (import-report.json in the module, format elabora-import-report/1): one sentence, the decisions grouped (fonts without a web licence replaced by vetted ones, Content Library media not taken, backgrounds, objects along the timeline, hotspots made buttons, images without alt, media without captions, questions without feedback), then every loss with its status (fallback, not-yet, dropped); in French or English after the course’s language. Blocks standing for a lost feature carry a note.
  • ZipReader.hashEntry(entry, 'md5' | 'sha256'): a digest of an entry read in chunks (a .story names its media by their MD5).
  • scripts/make-import-fixtures.ts writes the synthetic Storyline and Rise modules of test/unit/import.test.ts (no Articulate content or code).

Unreleased — the author’s sources, and a readable version of each that any agent reads (2026-10-15)

  • sources/ (D49): the files a module is made from (a client’s brief, a deck, recorded interviews), at the top of the course folder, outside the course: no snapshot holds them, so they are never validated, journaled, watched, backed up, restored, built or published. @elabora/core exports SOURCES_DIR and isSourcePath.
  • E_SOURCE_REFERENCED: an asset, a theme’s stylesheet, font or url(), or a free object’s file that points into sources/, and a web build’s sharing image taken there; the build also checks every entry and media file it writes. SCORM, web and review packages are byte for byte the same with sources or without them (tested).
  • @elabora/node/sources/index: addSources(dir, files, { onProgress, signal }) (copied or cloned, a folder’s files with it; a content already there not copied again, already-there; a name taken numbered, « brief (2).docx »; names made portable), makeSourcesReadable(dir, { names, force, install, language, onProgress, signal }), listSources, readSource(dir, file, { refresh, install }), removeSource (for good), resolveSource, sourceFiles, sourcesSize, copySources; SourceError (E_NOT_A_COURSE, E_SOURCE_NOT_FOUND, E_SOURCE_NOT_A_FILE).
  • Readable versions, sources/<name>.md and sources/<name>.files/, made on this computer, written again when the source changes: Word documents (headings, lists, tables, images, footnotes, links, comments in place and listed with their replies, tracked changes as {++…++}/{--…--}), PowerPoint decks (slides in order, texts in reading order, tables, SmartArt, notes, comments, hidden slides, the pictures each slide places and the file’s thumbnail; slides are not drawn), video and audio (the transcript by D46’s local transcriber in timed paragraphs, a video’s frames; partial until the transcriber is here). No program of the author’s is used (revised on 19 Oct 2026 by D49): older office formats (.doc, .rtf, .odt, .ppt, .pps, .odp, .key) are unsupported, their notes saying to save the file as .docx or .pptx (or as a PDF), and PDFs are read as they are. No new dependency.
  • elabora sources list|add|read|refresh|remove; MCP elabora_sources and elabora_source_read (read-only; the Markdown in parts, its pictures with images: true) and elabora_source_add. The course’s AGENTS.md gains rule 11 (template revision 2, brought to unedited courses, D34), the authoring skill and the MCP server’s instructions the same line: the sources are in sources/, read them through their readable versions.
  • The library carries sources: duplicateProject and importFolder copy them; exportProject(dir, out, { sources: true }) (elabora export --sources) puts them in the archive, which leaves them out by default; ExportResult.sources, ArchiveSummary.sources, and files/bytes now count the course’s files only; a replacement by an archive without sources keeps the module’s; LibraryProject.sourcesSize. A component’s preview and a translation’s folder no longer copy them.
  • npm run fixtures:sources writes the synthetic sources of the tests (test/sources-fixtures.ts) to test/fixtures/sources/.

Unreleased — a new course keeps what the author said of it, and the agent’s draft says what it decided (2026-10-15)

  • brief for initProject and createProject (@elabora/node/scaffold CourseBrief, D51): audience, businessGoal and duration written into the plan, presentation and lms into course.json, notes under AGENTS.md’s « Notes for this course »; refused before anything is written when it would not fit. elabora init --audience --business-goal --duration 10-20|5|30+ --presentation slides|scroll --lms a,b --notes <text>.
  • plan.duration { min, max }, the learner time asked for, in minutes.
  • plan.decided[] (topic, label, choice, why, items) and plan.question (text, topic, items): what the agent decided by itself while the plan is a draft, and the one question it asks; their items are checked (E_PLAN_REFERENCE); approve_plan removes both, and planContentHash leaves them out. A duration whose min is above its max is E_SCHEMA. Types PlanDecision, PlanTopic.
  • initProject escapes a title’s backslashes and control characters as well as its quotes (course.json and plan.json stay JSON).
  • The course AGENTS.md (a new revision: the notes, sources/, decided and question) and the authoring skill teach it.

Unreleased — lines made from recordings, and a line’s text from its recording (2026-10-15)

  • Transcription on this computer (D46): NVIDIA’s Parakeet TDT 0.6B v3 (CC-BY-4.0, int8, 671 MB, 25 European languages, punctuation and capitals), run by onnxruntime-node 1.23.2 in a process of its own (@elabora/node/transcribe/index: transcribeAudio, transcribers, estimateTranscription, disposeTranscriber). It replaces D27’s greedy wav2vec2 draft (28 % of French words wrong on the product’s voices; 10 % now, mostly numbers said in words). An optional download of its own, transcriber (ELABORA_TRANSCRIBER_MODELS_DIR, _URL, _MANIFEST; test/isolate.ts gives tests a folder). align/process.ts now runs on models/process.ts, shared with the transcriber; align/index no longer exports transcribeAudio nor Transcript (@elabora/node/transcribe/types has it).
  • ElevenLabs’ Scribe v2 when the author asks (service: 'elevenlabs'): its share of the quota told first (from 1 %, approvedPercent, E_TRANSCRIBE_SPEND, the CLI’s exit code 3), its transcript deleted from the service at once. The key needs the « Speech to Text » permission. The ledger has its list price (elevenlabs/scribe_v2/starter, 5.5 credits a second), not measured yet.
  • A line made from a recording: command add_narration_line_with_audio (@elabora/core addNarrationLineWithAudio, @elabora/node/narration/from-audio addNarrationLineFromAudio): its text transcribed or given, its audio the recording trimmed and encoded, its words timed, after the last line (or after) in its step and with its speaker, in one undoable change. elabora narration add-from-audio [dir] <screen> <file> [--text] [--after|--first] [--step] [--speaker], MCP elabora_narration_line_from_audio.
  • A line’s text from its recording: command set_narration_text_from_audio (setNarrationTextFromAudio): the recording kept, the text replaced (anchors follow their words, translations become stale), its words timed again, in one undoable change; refused for a generated voice, a translation, or an audio that changed meanwhile. transcribeNarrationLine (engine transcribeLine) shows what the recording says first. elabora narration retranscribe [dir] <screen> <line> [--dry-run] [--text], MCP elabora_narration_text_from_audio (preview).
  • elabora narration transcribe and MCP elabora_transcribe: what a recording says, on this computer or with --service elevenlabs. Engine requests transcribers, transcribeRecording (service, approvedPercent), transcriptionEstimate, transcribeLine; event transcribeProgress.

Unreleased — the trash can be listed with its dates, and emptied one entry at a time (2026-10-15)

  • deleteFromTrash(entry) (@elabora/node/library/index) deletes one trash entry for good, whatever its age: its folder, its history, its record and the data kept outside the project (ELABORA_DATA_DIR) unless a module of the library or another entry has the same project id; it gives the entry back, with its size, and refuses a name that is not an entry (E_TRASH_ENTRY). emptyTrash goes through the same code. elabora projects trash delete <entry> does it from the command line, and trash list says from when each entry may be deleted.
  • TrashEntry.expiresAt (trashed time + TRASH_RETENTION_DAYS, 30) says when an entry will be deleted by emptyTrash({ olderThanDays: TRASH_RETENTION_DAYS }), so that no screen has to know the number.
  • Restoring a module whose id is taken again (replaced by an import, or made again) keeps its history when the data lives outside the project: it is copied under the new id; the module that holds the old id keeps its own.

Unreleased — a read-only key for the author’s AI, the review page’s last findings (2026-10-14)

  • A read-only AI key (D45; the hosting’s protocol 6): @elabora/node/cloud/index gains createAiKey(name, { token?, forgetSessionFile? }), listAiKeys, revokeAiKey, readAiKey, aiKeyFile; the key (ear_…) is kept in <local data>/credentials/elabora-ai-key (0600) and never printed. It lists reviews and reads their comments, nothing else. elabora account ai-key create [--name] [--replace-session] | list | revoke <id>; account status and account logout know it.
  • The MCP read tools, review list and review comments take the AI key first; every command that acts with only that key says it reads reviews only and that publishing, sharing, answering, resolving and the account need the author’s session (E_CLOUD_SESSION_REQUIRED). A course’s reviews are found by their versions’ course (versions[].course), so the key needs no publication list.
  • elabora review create --note <text>: a word to reviewers under « <your name> vous invite à relire ce module » on the review page; setReviewNote changes or clears it. A review gives note and invitedBy.
  • The review bridge gives the narration line’s text and speaker (location.said, location.speaker, and in the anchor; D42) and a key message when the reviewer presses C inside the module, so that « Commenter » works from there. Only review.js changes: a build without --review-origin gains nothing.
  • With --progress local, the player says that the progress stays in this browser and offers « Effacer ma progression » (the end of the outline and the page an exit leaves; D41), in the seven languages. player.js and player.css change for every build.

Unreleased — what the app’s account settings and Publier call (2026-10-14)

  • setSessionStore(store | null) (D43 amended): the author’s session goes through a store, the 0600 file by default, so that the app keeps it in its safeStorage when the author’s AI may not use it; readSession, SessionRecord and SessionStore are exported (the types Node-free, in @elabora/node/cloud/types).
  • @elabora/node/cloud/index gains accountSessions, revokeSession, updateAccount, requestDeletionCode, deleteAccount, unpublish, renameReview; chooseHandle(handle, { displayName, api }) (its second argument was the API). Types AccountSession, AccountErased, PublishCheck.
  • elabora publish --private sends to reviewers even with red checks: a review build is a preview; the checks it let through come back as checks and are listed. Public publications are unchanged.
  • test/cloud-fake.ts speaks the devices, the display name, the account’s erasure, unpublishing and a review’s title, and compiles under the app’s settings.
  • elabora account login no longer sends the computer’s name. The session’s device is « Elabora CLI · macOS » (or Windows, Linux) instead of Elabora CLI (<computer name>), a name that is often a first name; --device <name> still names it as the author likes (D43).
  • elabora publication versions <publication> lists the versions the hosting keeps of a publication, the current one marked; elabora publication withdraw <publication> <version> --yes takes one older version down for good while the publication goes on (its pinned address answers 404, the files only it used are deleted, a review that showed it lists it as withdrawn with its comments). The current version is refused. @elabora/node/cloud/index gains publicationVersions, withdrawVersion and isVersionId; a review’s version has withdrawn and a content that is null once withdrawn. Needs the hosting’s protocol 5.

Unreleased — one licence for the core, CC0 for the samples’ own content (2026-10-14)

  • Apache-2.0 covers the specification too (D44): SPEC.md and the README no longer say CC BY 4.0, and the licence is no longer an open decision.
  • The original content of the four samples is CC0 1.0: each examples/<id>/LICENSE.md says so and lists what stays under its own licence (SIL OFL fonts, three.js, the simplex noise of orbital-rendezvous, the Pocket TTS narration of diagnostic-reseau with its credit); NOTICE points to them.

Unreleased — the hosting’s client: accounts, publishing, review comments for the AI (2026-10-14)

  • @elabora/node/cloud/index (D43): the client of Elabora’s hosting (protocol version 3), without a new dependency: sign-in by e-mail code, publishing by hashes (only the files the space lacks; above the API’s limit in parts; a second publication of the same content sends nothing), private publications for the review, reviews, comments with since, the author’s reply, resolve and reopen, and resolveComments, which places each comment in the course’s sources. The API is https://api.elabora.studio (ELABORA_CLOUD_URL elsewhere; HTTPS only, but on this computer).
  • elabora account login | handle | status | logout: the code is read from the input, never an argument; the session token is kept in <local data>/credentials/elabora-session (0600) and never printed; --invitation <file>|- --space <space> keeps a tester’s key instead (credentials/elabora-invitation); ELABORA_CLOUD_KEY_FILE and ELABORA_CLOUD_SPACE for scripts. A credential is only sent to the API it was made for.
  • elabora publish [dir] [--private] [--name] [--image] [--index] [--progress] [--navigation] [--review-origin]: builds the course for the web and publishes it in the author’s space (public, noindex unless --index; or --private, a review build named <id>-review, sent to its open reviews); a folder elabora build --target web wrote is published as it is.
  • elabora review create | list | link | close | open | delete | comments | reply | resolve | reopen: the link is printed once (or written to --link-out, 0600); review comments [--since <rev>] [--json] gives, for each comment, the screen (key, title, source file), the block (id, type, JSON pointer), the step, layer, line and word, the version commented with newerInReview and screenChanged, the author, the text, the state and the replies.
  • MCP: elabora_review_list, elabora_review_comments (read-only, openWorldHint: true, a new openWorld field of tools): the course’s reviews and their placed comments for the author’s AI. No tool publishes, shares, answers or resolves (D43). The authoring skill and the server’s instructions say how to work from comments.

Unreleased — the review bridge (2026-10-14)

  • elabora build --target web --review-origin <origin> (D42; repeatable; MCP elabora_build web.review { origins }, the engine’s build request web.review, build({ target: 'web', web: { review } })): a module for the Revue. It carries elabora/review.js, loaded after the player, and speaks elabora-review/1 with the review page that frames it, only with the origins named (https or http on this computer; new code E_WEB_REVIEW_ORIGIN): ready, layout, location, blocks, went, measured, error to the page; hello, go, pause, resume, layout, measure, scroll from it. docs/review-bridge.md.
  • A review build’s defaults are progress none, navigation free, noindex; its course data has delivery.review { origins, languages }, its report web.review, its proof dossier the origins. A build without --review-origin is unchanged, byte for byte: the player’s files are the same.
  • @elabora/player/review-protocol: the protocol’s types, the origin rule (reviewOrigin), the text sizes of player.css (SIZE_RULES, textScale, widestColumn), pointing (blockAt) and the comment anchor (anchorFor: { version, language, screen, block?, step?, layer?, time?, line?, word?, occurrence?, point?, frame }), for review pages too.
  • The canvas’s block boxes and wheel passing move to @elabora/player layout-report.ts, shared by the canvas and the bridge (the canvas’s messages are unchanged).
  • A list flag of the command line given again adds to it (--review-origin a --review-origin b, --profile moodle --profile docebo).

Unreleased — the samples’ notices and credits, for the showcase (2026-10-14)

  • A free object’s legal comments ship with it: the build keeps /*! … */, @license and @preserve comments of an object’s code at the end of its object.js (esbuild legalComments: "eof", it dropped them); objects without one build to the same bytes (docs/free-object-contract.md).
  • orbital-rendezvous carries the copyright notice and MIT licence of webgl-noise’s simplex noise (Ashima Arts, Ian McEwan, Stefan Gustavson) in objects/phasing-lab/noise.js, and so in every package; NOTICE names it.
  • The Pocket TTS voices’ credits are complete (packages/node/voices/pocket-tts/, D25): the model’s authors, its address and licence with its link (CC-BY 4.0), KevinAHM’s ONNX conversion; for Claire, the CML-TTS corpus’s authors, its address, licence and link, and the changes (cleaned by Kyutai, made into a synthetic voice); for the CC0 voices, the voices’ repository. diagnostic-reseau’s takes carry the new credits (their sidecars rewritten through set_narration_audio, the audio untouched).
  • The player links the addresses of credits (the outline and the transcript), in a new tab without referrer, long addresses wrapped.

Unreleased — a web target beside SCORM 1.2 (2026-10-14)

  • elabora build --target web (D41; MCP elabora_build target: "web", the engine’s build request target and web, build({ target: 'web', web })): a module for a plain web server, dist/<course>-<version>-web/ and its ZIP, deterministic, without imsmanifest.xml nor LMS checks (validateProject target: 'web'). Its index.html carries the course’s description, Open Graph and a Twitter card; --url (canonical, og:url), --image (a PNG, JPEG or WebP shared as share.<ext>), --noindex. New codes E_WEB_URL, E_WEB_IMAGE, W_WEB_IMAGE_WITHOUT_URL. docs/delivery-web.md.
  • --progress local|none: the player’s web adapter (@elabora/player/web, createWebAdapter) keeps the lesson location, status, score and suspend data in localStorage (elabora:web:<course id>), or nothing at all (the narration’s preferences included); no LMS search, no banner, no Quit. SCORM packages and the preview are unchanged.
  • --navigation authored|free: free opens the course data (navigation mode: free, no lockUntilComplete, the outline shown, narrations’ seek: seen → free). Defaults: local + authored (a publication); a showcase is none + free.
  • Leaving a module for the web (an event’s exit action; the Quit button is hidden there) says where the progress is: « Votre progression est enregistrée dans ce navigateur » with local, « Rien n’est enregistré » with none, instead of the LMS’s « Votre progression est enregistrée » (labels webEndedKept, webEndedNotKept, in the seven languages; a course in another language gives them in course.ui).
  • The build report has target: "web" and web; its budget.suspendDataLimit is null there, profiles empty; the proof dossier says « Diffusion web » and asks for a trial at the published address.

Unreleased — the ElevenLabs key in one place (2026-10-14)

  • The author’s ElevenLabs key lives in one place (D40, amends D29): the 0600 file the command line and agents read when the author lets their AI use it (the default; elabora voice key set writes it), else the app’s keychain only; the app uses the keychain only after the author’s gesture, never at startup or when a module opens. Comments of key.ts, the engine’s setVoiceKey and the architecture say so.

Unreleased — the name Elabora (2026-10-13)

  • The product’s name is Elabora (D39): packages @elabora/*, the command, the MCP server and the tools, the skills, the variables ELABORA_*, the schemas under https://elabora.studio/schemas/.
  • Format elabora/0.1, the first: its chain of migrations is empty, and the mechanism (elabora migrate, the engine’s migrate option, the library import) is tested with a step of the tests’ own (checkFormat(files, chain), migrateProject(dir, { steps })). The SCORM manifest’s identifier is elabora-<course id>.
  • ELABORA_LOCAL_DATA_DIR moves the local data folder; the test suites’ global setup gives every run its own, with the model folders. A key the system’s storage cannot decrypt reads as no key.

Unreleased — every optional download in one list (2026-10-13)

  • @elabora/node downloads/ (D38): one registry of what Elabora downloads once per computer, never into a course: a kind registers its title, what it serves, what happens without it, its downloads by language with their state (on this computer with its size on disk, or the size to download; a download under way in any process; in use) and how to install and remove one. The local voices (voices-fr, voices-en) and the word aligners (aligner-fr, aligner-en) are the first two kinds.
  • elabora downloads list|install|uninstall and MCP elabora_downloads, elabora_download_install, elabora_download_uninstall, which replace elabora_voice_models and elabora_voice_uninstall of the entry below (never released); elabora voice models, voice install and voice uninstall stay as the voices’ part, voice uninstall <lang> being downloads uninstall voices-<lang>.
  • The word aligners are held while they work (an alignment, a transcription, elabora narration align), so that removing one is refused meanwhile (E_ALIGN_IN_USE); their process is stopped before a removal. ELABORA_ALIGNER_MODELS_MANIFEST gives an aligner made-up files, for tests only.
  • Tests never reach the author’s models: npm test, test:e2e and test:matrix run with a global setup (test/isolate.ts) that gives ELABORA_VOICE_MODELS_DIR and ELABORA_ALIGNER_MODELS_DIR folders of the run unless they are set; scripts/pocket-voices.ts keeps its model in ~/.cache/elabora-dev/pocket-tts.

Unreleased — local voices removed and reinstalled (2026-10-13)

  • elabora voice models [--language] [--json] (MCP elabora_voice_models, @elabora/node localVoices, D37): the local voices by language, their names, and their model on this computer (its size on disk) or not (its download’s size), a download under way in any process (bytes read from the disk) and whether lines are being said with it.
  • elabora voice uninstall <language> (MCP elabora_voice_uninstall, uninstallVoices, provider uninstall): removes a language’s model, refused with E_VOICE_IN_USE while a generation says lines with it or it is being downloaded, in any process; the lines already made keep their audio, and the next generation downloads it again. elabora voice install <language> --force (MCP force) removes it and downloads it again.
  • Generations hold the models they speak with (provider hold, models/download.ts holdFiles), and the download lock moved beside the folder (<bundle>.lock, with what holds it), so that a hold and a removal never cross; removeFiles renames the folder away before deleting it. The word aligner’s downloads share the lock.
  • ELABORA_VOICE_MODELS_MANIFEST: made-up files for a bundle, for tests only.

Unreleased — a ledger of what paid AI services charge (2026-10-13)

  • ai-costs/ (D36, README there): measurements.jsonl, one measured request a line (provider, exact model id, kind, settings, plan, UTC date, units sent and billed as maps, so that tokens, images or seconds fit as characters do, the price period, the source), and periods.json, each provider, model and plan’s price periods (list price per unit, by settings and per a quantity; a discount’s rate, announced end and source). It starts with the 49 ElevenLabs requests of 12 and 13 October and 5 bought for it (40 credits: Eleven v3 and v4 Turbo).
  • Averages generated, never written: npm run gen writes @elabora/core ai/cost-ledger.generated.ts by provider, model, plan, price settings and period, and refuses a measurement in no period or two, a unit with no price, a request recorded twice or periods that overlap (ledgerProblems); a unit test fails when the module is stale.
  • The voice estimate reads them (expectedShare, @elabora/node expectedCharge, chargedShare(plan, model, rate)): only the current period’s average, its discount taken off, while its list price is the one GET /v1/models says; the list price where the period has fewer than 3 requests (Multilingual v2), for a model, plan or list price not measured, and once a discount with no known rate runs; never an older period’s figures. Eleven v4 on a Starter plan is told 0.4005 of the list price (the course’s 1,163 characters 466 credits for 465 charged), v4 Turbo and v3 0.4. The author’s own charges (<cache>/charges.json, with date, plan and list price; rates.json, without dates, is dropped) take over under the same keys and periods: a rise from the first request, from three requests and a quarter more where the ledger has nothing, never under the ledger, never past the list price; for a plan with no period, only the requests since the model’s last change of price.
  • npm run costs:record [-- --dry-run], a tool for our experiments: appends the ElevenLabs history’s new requests once (history_item_id) with the plan and GET /v1/models’ multipliers at that moment, says a list price, a discount or a share the current period does not have, and generates the averages again.

Unreleased — a plan never validated is the agent’s draft (2026-10-13)

  • elabora plan draft [dir] (--from <plan.json> | --patch <patch.json>) (MCP elabora_draft_plan, edit draftPlan, D35): while the plan has never been validated (not approved, no approval, no change request ever applied: @elabora/core planEverApproved, planFrozen), the author’s agent writes it, whole or as a JSON Patch, its schema checked before anything is written; it stays a draft. Once the author or the client validated it, the command refuses and names the change request to propose. elabora plan status says « never validated »; elabora_get_plan gives validatedOnce; elabora plan voiceover and elabora_set_plan_voiceover write in a plan never validated and propose once it was. Rule 1 of the course AGENTS.md (revision 28), the authoring skill, W_PLAN_DRAFT, E_PLAN_ITEM_UNCOVERED and E_PLAN_MODIFIED_AFTER_APPROVAL say so.
  • Fixes from the independent review of the four changes below: an inline SVG’s <style> is foreign content in the page, so its < and character references are now written as CSS escapes (a <![CDATA[ holding <img onerror> became an element); declarations that could load anything are dropped however written (CSS escapes, entities, image-set(), src(), inside @keyframes), as are selectors reaching the drawing’s siblings and position: fixed; every id the drawing names is prefixed. The draw effect reads arc flags written together (a5 5 0 105 5). elabora theme upgrade keeps declarations as written (a string holding /*) and, beside overrides the author already wrote, puts what it moves in a sublayer under them. elabora undo, redo and history no longer update AGENTS.md first; a read-only MCP server only says what it finds; a command finds its course among all its arguments. W_NEUROMYTH on a screen is reported per block, keyed by its text; W_NARRATION_LINE_LONG is keyed by the line’s text, not its take’s length.

Unreleased — a course’s rules for agents stay in step with Elabora (2026-10-13)

  • AGENTS.md (D34): the rules sit between elabora:rules markers carrying their revision and fingerprint, then « Notes for this course », never touched. Rules nobody edited are brought up to date as a system change (update_agent_rules, undoable) when the app opens the course, an MCP server starts, a elabora command runs in it (a line on stderr), or elabora migrate runs; edited ones are left and said; rules from a newer Elabora make an older elabora say that commands may be missing. elabora rules [dir] prints the running CLI’s rules; elabora rules update [--force]. templates/agents-revisions.json (every revision of the template; npm run gen appends one and updates the examples’ and test courses’ AGENTS.md).

Unreleased — more warnings the author may ignore (2026-10-13)

  • Keys (D31) on the checks that read the author’s own content and can misjudge a case: W_QUIZ_NEGATION (a negation inside a quoted maxim), W_QUIZ_ALL_NONE, W_QUIZ_OPTION_COUNT, W_QUIZ_LENGTH_CUE, W_IMAGE_ALT_SUSPECT, W_NEUROMYTH on screens, W_TEXT_DENSITY, W_NARRATION_DUPLICATED, W_NARRATION_LINE_LONG; each comes back when what it read changes (findingKey). Their fixes say when ignoring them is right.
  • elabora ignore [dir] <code> <key> [--restore] and MCP elabora_ignore_finding, for when the author asks; elabora validate prints the key of a finding the author may ignore.

Unreleased — compact quiz feedback (2026-10-13)

  • feedbackStyle: "compact" on a quiz (and its forks): the feedback of the chosen option without its text in bold first (the option is marked where it is; its text stays in the feedback for screen readers, .t-feedback-option.t-sr-only), the result and the explanation in one paragraph. On the « Nietzsche » evaluation, two questions side by side reserve 388 px instead of 445 each. full stays the default: existing courses keep their look, and with several options’ feedback at once (multiple choice) each keeps its text. A template may set data-t-feedback-style="compact" on its group.
  • elabora fit names, for a slide that scrolls, the quizzes whose compact feedback would reserve less room and by how much (ScreenFit.feedback, __elabora.feedbackRooms()).

Unreleased — figures drawn in the page (2026-10-13)

  • inline: true on an image block (D33): an SVG asset, generated or local, is written into the page in place of its <img>, sanitized from an allowlist (no script, handler, link or outside reference; ids prefixed with the block’s; its <style> scoped), named by its alt (role="img"), so that the draw effect traces its strokes and the theme’s CSS reaches its shapes (@elabora/core render/svg-inline.ts; svg.t-figure-svg[data-t-svg]). W_SVG_INLINE_SANITIZED, W_IMAGE_INLINE_NOT_SVG, W_IMAGE_INLINE_HEAVY, and W_TIMING_DRAW_IMAGE for draw on an image the page cannot trace.
  • draw draws each subpath: a shape’s dash is its longest subpath, so the hatching of an engraving (one path of many lines) grows line by line instead of showing at once (subpaths in @elabora/player timed-blocks.ts).

Unreleased — the theme’s rules give way to components; overrides are said (2026-10-13)

  • Cascade (D32): elabora.base < elabora.theme < elabora.components < elabora.overrides. The course stylesheet’s rules are the course’s defaults and component stylesheets win over them; the stylesheet’s top-level @layer overrides { … } blocks are what it overrides on purpose (a package keeps them in elabora/overrides.css). course.json theme.cascade (format 0.2, optional): components-over-theme, which elabora init writes; a course without it keeps the order of 6 October (theme-over-components, the theme after the components) and its look, and is told (I_THEME_CASCADE_OLD). elabora/player.css now names elabora.base alone; elabora/theme.css and the canvas start with the course’s order (DesignStylesheets.cascade, .order; LAYER_ORDER gives way to BASE_LAYER and layerOrder).
  • elabora theme upgrade [--dry-run] (MCP elabora_upgrade_theme, edit upgradeThemeCascade): a course moves to the new order in one undoable change, its look kept: the theme’s declarations of properties the components also set go into @layer overrides (upgradeTheme, propertyFamily, splitOverrides in @elabora/core render/cascade.ts; a CSS reader of ranges in project/css-tree.ts).
  • Who wins, measured in the page (@elabora/node style-cascade.ts): elabora look and elabora_look say, and elabora fit reports, W_STYLE_COMPONENT_OVERRIDDEN (a component’s rule a theme rule overrides without naming the component) and W_STYLE_THEME_UNDER_COMPONENT (a theme rule for a component that gives way to it). LookResult.styles, ScreenFit.styles. W_STYLE_OVERRIDES_NESTED: an @layer overrides block inside another rule.
  • The examples set theme.cascade; medieval-charts’ gloss notes now set their emphasis in roman, as the gloss component asked.

Unreleased — seven faults met building the « Nietzsche » course (2026-10-13)

  • A look server of older code is replaced (@elabora/node code-version.ts): the server a elabora look started kept answering newer command lines, because the version it told, and the client compared, was the package’s (0.1.0 for weeks), so captures were rendered by the old code (subtitles showed « [chuchote] »). The version is now <package version>+<hash of the sources> (of the bundles beside the entry in a packaged app); a command whose own differs stops the server (by its pid when it does not stop) and starts its own.
  • elabora narration transcribe printed nothing and exited 0. The aligner’s process is not held while idle; dispose() stopped it and waited for its exit, nothing else held the command’s process, Node ended it before the answer was printed. A process being stopped is now held until it has gone; and a command whose promise nobody settles ends with E_COMMAND_UNSETTLED on stderr and exit code 70, never as a silent success (packages/cli/src/unsettled.ts).
  • elabora voice estimate no longer undershoots (D30): the 8 lines of two narrated screens were told ~200 credits (0.5 % of a Starter quota, so no approval was asked) and charged 465 (1.2 %). Cause found with ElevenLabs’ history and 110 credits of experiments: a Starter plan is charged 0.4 of the list price for every model (30 characters: 12 credits on Eleven v4 and Multilingual v2, 6 on v4 Turbo; a dialogue, context text, timestamps, format and voice change nothing), and the 0.11 of 12 and 13 October was a promotion that ended on the morning of the 13th. The estimate is the plan’s undiscounted rate (0.4, never counting on a discount), the list price for a plan not measured until its own requests say, and follows a rise at once. A line of 70 characters is told about 0.07 % of the quota.
  • elabora screenshot with several shots refuses --out (exit code 2, asking for --out-dir), as elabora look does; it wrote the files into screenshots/ of the folder it ran in, unasked, and nothing at the path given. --out with --out-dir is refused too.
  • Animated SVG figures are captured drawn (look, screenshot, assets --sheet): the page’s reduced-motion setting does not reach an SVG shown through <img>, whose animations started from nothing. The capture browser is launched with --force-prefers-reduced-motion (launchBrowser reducedMotion, launchMotionBrowsers: another browser, started only when asked, for pages with animations on).
  • A change request is checked before the client applies it: elabora validate (and elabora_validate) applies each pending request to the plan in memory and reports the one that cannot be applied as W_CHANGE_REQUEST_INVALID with its id (a spacing over 50 characters, a patch that no longer applies); elabora_propose_plan_change refuses a patch that would make plan.json invalid, with why, and writes nothing.
  • AGENTS.md says where the Elabora app carries the command line when elabora is not in the PATH (macOS and Windows launchers of the packaged app).

Unreleased — stage directions are not read, a swallowed word may be ignored (2026-10-13)

  • A line’s speed counts what learners read. W_TEXT_TOO_FAST on a narration line counted its stage directions (D29) as characters to read: « [agacé, au bord de l’explosion] Ça fait trois fois ! » was said too fast for its captions at 27 characters a second; it is now measured without them. Words placed by estimate (lineWordTimes without word times) no longer give the directions time (a line opening with one had its first word late), and on-screen text that repeats a line (W_NARRATION_DUPLICATED) is found when directions stand between the line’s words.
  • Ignored by the author (D31). W_NARRATION_WORD_SWALLOWED is one finding per word, with a key (the line, the word and its take’s audio: swallowedKey), its score as measured and 0.25 as limit. The new command ignore_findings { findings: [{ code, key }], ignored } (engine, @elabora/node edit) writes course.json ignored (format 0.2, optional); the validator, the engine and the build mark that finding ignored and no longer count it (summary.ignored); another take brings it back. elabora validate does not list it, elabora_validate leaves it out (includeIgnored), as do the MCP’s edit, undo and build results, the alignment’s doubtful does not report it again, the proof dossier says it is ignored. Errors cannot be ignored.
  • The narration view gives each line its speaker’s voice in the language (speakerVoice): the editor knows which lines a paid voice would say without reading course.json again.

Unreleased — a dialogue is cut where the aligner hears its turns (2026-10-13)

  • An interrupted « et — » no longer loses its word. In the « Essais ElevenLabs » test module, ElevenLabs’ times for the turns after the first of an exchange ran 0.38 s ahead of its audio, so the cut after the client’s « et — » fell before it: the line’s alignment flagged « et » as swallowed (it was not said again alone: same take, the word is there at score 1). generateNarration now aligns the whole exchange with the language’s aligner when it is on the computer (DialogueRequest.hear) and splitDialogue cuts in the first silence after a turn’s last word (a breath or a sigh before the next turn’s first word stays with that turn); the service’s word times move onto the audio. Without an aligner the cut is where the service’s times say, as before. The other flag of that module, « de » in « trois de trop » (score 0.01 in the exchange, in a take of the line said alone and on the spectrogram), is a real swallow.

Unreleased — costs as a share of the quota, estimates the service charges (2026-10-13)

  • One language for paid AI services (D30, @elabora/core ai/quota.ts): what they cost is a share of the author’s monthly quota, never credits (quotaPercent: « 0.02 », « 2 »), and one threshold, APPROVAL_SHARE (1 %): under it an action runs and the author’s AI needs no approval; from it, the app confirms and agents ask.
  • ElevenLabs estimates follow what the service charges (D29): the list price (a credit a character times the model’s and the voice’s rates) is now the ceiling; credits is what Eleven v4 is charged through the API, about 0.11 of it (8 credits for a line of 74 characters on 13 October 2026, after its launch discount), learnt from each request’s character-cost header (voice/elevenlabs/costs.ts). An estimate carries the month’s quota, its percent and needsApproval; a generation’s spent the credits charged and their percent.
  • Approval in percent: elabora voice generate --spend <percent> (was credits), MCP approvedPercent (was approvedCredits), engine approvedPercent (was maxCredits). Under 1 % of the quota nothing needs approval; a generation charged half as much again as allowed stops (overBudget). voice account and elabora_voice_account say usedPercent and linePercent, and whether the plan may use the voice library (library: not the free plan); account({ key }) checks a key before it is kept.
  • The cache of ElevenLabs answers keeps another address’s answers apart (an imitation in tests never answers a real request).
  • An approval covers whatever was told with its figure (« 1 % » up to 1.49 %) and a quarter more; a library voice’s higher price is kept on the computer (<cache>/voice-rates.json) and priced after a restart; a paid sample obeys the 1 % threshold; an exchange’s name in its takes is unique to its generation (<screen>-<stamp>-<n>), so that two exchanges of a screen are never taken for one.
  • The cache of ElevenLabs answers moves to <local data>/elevenlabs/cache (it was <local data>/cache/elevenlabs: on macOS, inside the app’s Chromium Cache folder, the file system ignoring case); what it kept is moved there once.

Unreleased — ElevenLabs voices: stage directions, dialogue, credits (2026-10-12)

  • Stage directions (D29): words in square brackets in a narration line ([chuchote], [rit], [un temps]) tell an expressive voice how to say it. They stay in the text (hash, translation) but are never words, captions, transcript nor estimated time, and pronunciations skip them (@elabora/core lineDirections, maskDirections, withoutDirections, playsDirections). XLIFF wraps a narration line’s in <mrk type="elabora:direction">, with a note for translators. I_NARRATION_DIRECTIONS_IGNORED when the speaker’s voice cannot play them; Pocket TTS is given the line without them.
  • ElevenLabs (provider: "elevenlabs", model eleven_v4 by default, settings stability, similarity): the author’s key (elabora voice key set|remove|status, ELEVENLABS_API_KEY, the app’s in memory), never written in a module or shown unmasked; voices searched in the account and the library (elabora voice search, MCP elabora_voice_search, engine voiceSearch); the plan and its credits (elabora voice account [--check], MCP elabora_voice_account, engine voiceAccount); speakers[].voices[<lang>].name (format 0.2, optional).
  • Credits approved before they are spent: elabora voice estimate (MCP elabora_voice_estimate, engine voiceEstimate); elabora voice generate --spend <credits> (exit code 3 and the estimate without it; MCP approvedCredits, needsApproval); an out-of-date line gets back an earlier take of the same text, voice and delivery; answers cached on the computer.
  • Dialogue: elabora voice generate --dialogue (MCP dialogue) says a step’s lines by several speakers as one exchange, cut into lines, their pauses from it.
  • voice install and the engine’s installVoices leave paid providers out; voiceSample refuses a paid voice (E_VOICE_PAID: its preview is free).

Unreleased — long recordings no longer stop the engine (2026-10-12)

  • Aligner (D27): importing a recording of 90 s stopped the engine (ONNX Runtime aborted in an allocation inside the aligner’s worker thread, and took the process with it). The audio is now read in windows of 10 s whose outer 2 s are given up, the memory arena is off, and the aligner runs in a process of its own (align/process.ts): peak memory on that recording 2.44 GB before, 1.12 GB after (the model itself 0.8 GB); word starts at the seams stay within 28 ms of those of each line alone (median 8 ms, as without windows). If ONNX Runtime still aborts, the line fails with E_ALIGN_CRASHED and the engine goes on. A recording tried against several lines is read once.
  • W_NARRATION_LINE_LONG: a warning for a line of more than 45 s of audio, suggesting it be cut into lines of a few sentences; nothing is blocked.

Unreleased — the stack’s contents share one place (2026-10-12)

  • Stack (stack, « Pile »): its contents now always share one grid cell, the size of the largest, in the player, the editor’s canvas, captures and at a phone’s width. The grid rule was outweighed by the slot’s flex column (.t-slot) and only held on screens with a seek bar, so in the editor’s canvas the second content showed under the first and the layout kept holes. An end-to-end test measures the stack in the canvas at three moments and at the end, at 1280 and 375 px.

Unreleased — the fine timeline, part 6: what the editor asked for (2026-10-12)

  • Canvas: elabora/view messages posted by the app take at (the playhead, while the author drags it) and ghost (every step, those after the first faded at 0.42 in their place: the phone width; also canvasView, renderScreen); BlockLayout.timed gives each timed block’s state (before, entering, shown, leaving, after) and its effect’s progress; a timed block not there is hidden.
  • Effects: effectsOf and engine request effects (the presets then the theme’s, default durations, directions, what they do).
  • Findings with details: E_TIMELINE_ANCHOR_MISSING (screen, block or event, kind, anchor, what it lost), W_TEXT_TOO_FAST (shown, needed, from, until; or a line’s characters a second), E_ACTIVITY_LEAVES_UNANSWERED (leaves, next stop), E_TIMING_ORDER, W_TIMING_STEP, W_MOTION_LONG, I_TIMELINE_WORDS_ESTIMATED, W_SEGMENT_TOO_LONG (step, its start, end and lines), W_NARRATION_WORD_SWALLOWED (line, words and scores).
  • Transcription (D27): transcribeRecording (engine), elabora narration transcribe: what a recording says, its words timed, by the word aligner’s model (a draft: 28 % of French words and 20 % of English ones wrong on the product’s voices).
  • keep_narration_audio (elabora narration keep, « La voix dit bien ce texte »): a line whose text was corrected keeps its audio; its words follow.
  • The plan’s voice-over is written by set_plan_voiceover (elabora plan voiceover --from file.txt, MCP elabora_set_plan_voiceover, which proposes): in a draft plan directly, in an approved one as a change request.

Unreleased — the fine timeline, part 4: what the editor and the review need (2026-10-12)

  • Marks: @elabora/core timelineMarks (a timed screen’s start, each block that comes or goes, each cue, each step’s end, with the time to show each and a caption); elabora look --sheet (MCP elabora_look sheet) captures one per mark on a contact sheet; engine request timelineMarks for the Revue’s storyboard. elabora_look at takes any moment (l2:imprimante+0.3s, 1.5s@2, step2:end, 12.5) and a language.
  • Steps: command split_step (elabora narration split, « Couper à une réplique », the answer to W_SEGMENT_TOO_LONG): a line and those after it in its step start the next step; later steps, their blocks, steps.<n>, step anchors and reveal-step actions move one on; blocks that come on a moved line move with it.
  • The plan’s voice-over: plan items’ voiceover (format 0.2, compatible: the voice-over the client validated, line by line, translated with the plan); command narration_from_plan (elabora narration from-plan, « Reprendre la voix off du plan ») and engine request planVoiceover.
  • Recordings: importNarrationFiles (elabora narration import-files, engine importNarrationFiles): each file to the line its name gives (l2.wav, accueil-l2.m4a, 02.wav), the others to the line whose text the word aligner finds in it; one change a line.
  • Waveform: engine request narrationPeaks (the loudest sample of each slice of a line’s audio, 0 to 255, cached per audio file).
  • duplicate_screen copies the lines’ audio with sidecars naming the copy.
  • The example diagnostic-reseau: its narration aligned (words in the sidecars); on « accueil » the ticket comes on « impression » and the module’s aims on « trajet »; on « chemin » the network path is wiped in on « schéma », a stack shows the floor’s network on « ordinateur » then the servers’ on « routeur », and the rule rises on « contrôlez ».
  • Fix: the engine protocol refused timeline and timelineAt (missing from its list of requests); the list is now checked complete by the compiler.

Unreleased — the fine timeline, part 3: word times by forced alignment (2026-10-12)

D27. Word anchors now take the times the voice says their words.

  • Aligner (@elabora/node align/): a wav2vec2 CTC character model per language (French wav2vec2-large-xlsr-53-french int8, 355 MB; English wav2vec2-base-960h quantized, 95 MB; Apache-2.0) run by onnxruntime-node 1.23.2 in a worker thread, and a Viterbi search in TypeScript. Numbers, times and symbols are spelt out (French and English), elisions run on, other words fit the model’s vocabulary or a wildcard. Measured on the product’s voices: word starts within 22 to 24 ms (median, 20 ms correction applied), 9 in 10 within 61 and 49 ms. Models download once per language into the computer’s local data (ELABORA_ALIGNER_MODELS_DIR, ELABORA_ALIGNER_MODELS_URL), pinned and checked; the download code is shared with the voices (models/download.ts).
  • Sidecars: lines a voice makes or a recording imported are timed at once when their language’s aligner is installed (ELABORA_ALIGN=0 turns it off); every new sidecar keeps the text its audio says; words carry a score; aligner says what timed them.
  • Commands and tools: set_narration_words (words into a line’s sidecar, refused when its audio changed meanwhile); elabora narration align [dir] [screen] [--line] [--language] [--force], MCP elabora_align_narration, engine requests aligners and alignNarration (alignProgress events, stopped by cancelVoice); NarrationLineInfo.aligner.
  • Validation: W_NARRATION_WORD_SWALLOWED, a word of two letters or more the alignment scores under 0.25 (the voice may have swallowed it: another take).

Unreleased — the fine timeline, part 2: word anchors through translation (2026-10-12)

  • XLIFF markers: a narration line’s unit carries the words its anchors name as <mrk id="w-<words>" type="elabora:anchor"> in its source (with a note for translators); the translation places each around its own words. readXliff reads markers apart from the text (sourceMarks, targetMarks; other inline tags are dropped, their words kept), toXliff writes them.
  • The translated course anchors to the words the markers are placed on (translatedCourse rewrites block timings, cues and seek-timeline targets) and its voice gives them their times. A marker left to place is refused by elabora translate build, anchored to its line’s start with --allow-missing (TranslationBuild.unplaced).
  • Status and table: translationStatus lists anchors.toPlace; translationTable rows of anchored lines give anchors (each with the words it is placed on, or null), sourceMarked and targetMarked. translate_text (elabora_translate_text) takes markers in target, or anchors: [{ id, word, occurrence? }] to place them by naming the words; markers already placed stay when a target is written again without them; an unknown marker id is refused.
  • Timelines in a language: screenTimeline(project, screen, { language }) anchors to the translation’s words; an unplaced marker is an anchor problem of kind unplaced.

Unreleased — the fine timeline, part 1: timed blocks and anchors (2026-10-11)

Phase 2 of the slide timeline (D28). Compatible additions to format 0.2.

  • Anchors (defs.schema.json anchor): a line, a word or a few words of it (word, occurrence), its end (edge), with an offset; seconds from a step’s start on screens without narration (seconds, step); a step’s start or end. Event cues (timeline.reached) take them all; new action seek-timeline (to): the playhead goes there as a seek, firing nothing it passes.
  • Timed blocks: block.timing.in and .out (at, effect, duration, direction) with the effects none, fade, rise, slide, zoom, wipe, draw and the theme’s (course.json theme.effects, @keyframes t-fx-<name>). The player sets each effect (a Web Animation created paused) to the timeline’s time at every move: playing, seeking, the speed and captures are one computation. A timed block keeps its place hidden and inert before and after (data-t-timed); focus inside one a seek hides goes to the seek bar. Reduced motion: a short fade at the same time.
  • Screens timed in seconds, without narration, have a timeline too: a bar (no captions), the page’s clock, steps[n].duration.
  • stack block (« Pile »): contents that follow one another in one place, the size of the largest (transition crossfade, cut, slide).
  • Steps’ stretches end when their last line or their last effect ends: the build lays the track’s silences from the timeline (TimelineLayout.gaps).
  • Word times: narrationWords (one tokenizer; French elisions are words), a line’s words from its sidecar’s words, followed through a changed text, estimated without. Sidecar additions: text (what the audio says), aligner, words’ score.
  • The bar: dragging the playhead holds playback, the screen follows, a bubble says the time and the words said, playback goes on from where it is let go; the shortcuts list (Shift+? on the bar, « Raccourcis clavier » in the settings); at the end of a step the live region says it once. A screen without voice has no captions, mute or transcript button. New labels in the seven languages: shortcuts, shortcutPlay, shortcutReplay, shortcutCaptions, shortcutSeek, shortcutStep, shortcutEnds, shortcutList, stepEnded, onScreen.
  • Validation: E_TIMELINE_ANCHOR_MISSING, E_TIMING_SECONDS_NARRATED, W_TIMING_IGNORED, E_TIMING_EFFECT_UNKNOWN, W_THEME_EFFECT_UNDEFINED, E_TIMING_ORDER, W_TIMING_STEP, W_TEXT_TOO_FAST (reading time of what appears; captions over 25 characters a second), E_ACTIVITY_LEAVES_UNANSWERED, W_MOTION_LONG (WCAG 2.2.2), W_STACK_UNTIMED, I_TIMELINE_WORDS_ESTIMATED; E_TIMELINE_CUE now accepts seconds without narration.
  • Commands and tools: set_block_timing (patches of in and out), update_narration_line takes word anchors along with their words (anchors.moved, anchors.lost); CLI elabora timing [dir] <block> --in l2:imprimante --effect rise …, MCP elabora_set_timing; elabora timeline lists timed blocks and anchored cues; elabora look --at and elabora screenshot --at take any moment (l2:imprimante, l2:end-0.2s, 1.5s@2, step2:end, 12.5).
  • Engine: requests timeline (lines with their words, offsets and times, timed blocks, cues: @elabora/core timelineView) and timelineAt (what is seen at a time: screenAt); canvasView and renderScreen take at (a time, or an anchor resolved in the language shown); command set_block_timing.

Unreleased — narration for the app’s editor (2026-10-11)

  • The voice speaks in a worker thread (@elabora/node voice/pocket/thread.ts, worker.ts; D25): the engine and the MCP server go on answering while a line is said (their longest wait went from 800 ms to a few), and a generation stops between two steps of the model. A stopped generation keeps the lines said so far and says cancelled.
  • Engine requests: narration (a narrated screen as an editor shows it: advance and seek as they apply and as the screen sets them, each line in a language with its audio’s state, its provider, take and credit, and its place on the timeline, estimated without audio; @elabora/core narration/view.ts), cancelVoice (a screen’s generation, or all of them and the downloads), voiceSample (a few words said by a voice, written nowhere: WAV bytes; E_VOICE_NOT_INSTALLED while its model is not on the computer).
  • Why a line is missing or out of date, as codes (NarrationLineInfo.causes: untranslated, no-audio, no-sidecar, text, audio-replaced, elsewhere, voice, pronunciation, delivery) beside the reasons in English, for an editor to word them.
  • The canvas host serves a narration line’s audio (assets/narration/<language>/<screen>/<line>.m4a), which no render lists, so that the editor plays it.
  • ELABORA_VOICE_TONE_SECONDS: test tones (ELABORA_VOICE_TONES=1) take that long to say, with progress, and stop on their signal.

Unreleased — author narration through MCP (2026-10-08)

  • elabora_update_course exposes speakers, pronunciations and the course narration defaults. elabora_update_screen exposes narration (the script and playback settings; null removes it). Agents can now prepare a narrated screen before calling the voice tools without editing JSON files themselves.
  • The diagnostic network example includes a French dialogue and an explanation of the network path, with real Pocket TTS audio, captions and voice credits.

Unreleased — the voices’ credits in front of learners (2026-10-08)

  • The course data carries the credits of the voices (their sidecars’ credit, the attribution CC-BY voices such as those of Pocket TTS ask for), each once: narration.credits on each narrated screen, credits for the module (keys left out without credits: other courses keep their bytes).
  • The player shows them: a « Crédits » section ends the transcript of a narrated screen (its voices) and the module’s outline (all its voices). Label credits in the seven languages; hooks .t-timeline-credits, .t-menu-credits.

Unreleased — free local voices for narration (2026-10-08)

Decision D25 (docs/decisions.md): Kyutai’s Pocket TTS, free, on the author’s computer, the same voices on Mac and Windows.

  • @elabora/node voice/: voice providers (VoiceProvider: voices, status, install, synthesize, dispose; listVoices, findVoice, voiceProvider); voice/pocket/ runs Pocket TTS with ONNX Runtime (a TypeScript port of Kyutai’s generation: its Unigram tokenizer, text preparation and chunking, flow language model and Mimi decoder; failed chunks said again; seeded takes), its model downloaded once per language into the computer’s local data (fr 354 MB, en 126 MB; pinned, checked by SHA-256, resumed; ELABORA_VOICE_MODELS_DIR, ELABORA_VOICE_MODELS_URL); generateNarration says a screen’s lines with their speakers’ voices and writes each as a take through setNarrationAudio (take, credit, model), the takes kept in the project’s data; setSpeakerVoice, voiceTakes. ELABORA_VOICE_TONES=1 adds test tones.
  • Voices: Estelle, Claire, Henri, Rémy, Jérôme (French), Grace, Nora, Iris, Owen, Felix (English), made from recordings given for speech synthesis (Kyutai’s Unmute donations and its own recordings, CC0; one reader of CML-TTS, CC-BY 4.0), each checked on five takes of six lines. packages/node/voices/pocket-tts/ (sources.json by hand; voices.json and the embeddings by scripts/pocket-voices.ts).
  • Agents: elabora voice list|install|set|generate|takes, MCP elabora_voice_list, elabora_voice_set, elabora_voice_generate, elabora_voice_takes, elabora_voice_install; the course AGENTS.md and the skill elabora-course-authoring tell how. The help and the MCP catalogue (test fixtures) gain them.
  • Engine: requests voices, installVoices, setSpeakerVoice, generateVoice, voiceTakes, and voiceProgress events; what a voice makes goes through the session’s queue.
  • New optional dependency: onnxruntime-node 1.23.2 (the last with a binary for Intel Macs).

Unreleased — the narrated player as the app’s validated mockups have it (2026-10-08)

The app’s mockups of the timeline were validated on 8 October; the player keeps their behaviours and structure, its final look coming with the app’s stylesheet.

  • Three ways to advance (format 0.2, compatible): advance learner (default), auto (the steps chain, then the screen waits for Next) and next-screen (they chain, then the next screen comes by itself). W_AUTO_ADVANCE (WCAG 2.2.1) is for next-screen only. course.json narration gives the course’s defaults of advance and seek.
  • The bar in the footer, before Previous, in place of the counter: the footer keeps its height on every screen (#elabora-app[data-timeline] hides the counter). Icon buttons; the part of the seek bar not heard yet is dotted, the part heard solid, a gap at each step’s end. seek: "none" has no bar: the time and the step in words.
  • Continue in the screen: a narrated slide keeps the place of its own Continue at its end from the start (.t-continue--slot, hidden with data-t-waiting while a step plays), shown at the end of the step heard (seek seen, none) or at any time (free); on phones and in scroll pages it comes in the page where the next step opens. At the end of a step, Play does what Continue does. Next goes to the next screen (with navigation.lockUntilComplete, once the screen has been heard: « Écoutez l’écran jusqu’au bout pour continuer. »).
  • Playback settings under « 1× »: the speeds and « M’arrêter à chaque étape » (offered when the module has screens that chain). Below 1280 px, Mute and Transcript go into « … »; on phones the settings too, Captions staying in the bar.
  • The learner’s Pause stops everything: the voice, the animations (paused, then played again) and the free objects. Space acts on the bar’s buttons only (no longer on the seek bar).
  • Captions name the speaker when it changes. The transcript goes by step (« Étape 1 »…), with a close button; a line already heard takes the playhead there, one not heard yet is disabled.
  • The large Play button says « Écouter l’écran » and why (« Le son démarre au premier clic ; ensuite, chaque écran se lance seul. »). New labels in the seven languages: settings, more, stepOf, stepHeading, playNarrationHint, stopAtStepsHint; seek is « Position ».
  • The LMS emulator presses the Continue a narrated slide shows (never the one hidden in its kept place).

Unreleased — takes, pronunciations and credits of generated voices (2026-10-08)

Asked by the voice session for elabora voice generate (compatible additions to format 0.2 and to the D24 contract).

  • Sidecar (narration-audio): take (which take of a voice that says a line differently each time is in the course), credit (what the module’s credits say of the voice: the build lists the distinct credits of its tracks in the report, narration.credits, and the proof dossier), model (information: another model does not make a line out of date), and for generated voices spokenHash and settingsHash, written by setNarrationAudio itself from the project, as the validator checks them.
  • Pronunciations: course.json pronunciations (the course’s lexicon) and a line’s own pronunciations, by language, { text, say }: whole words, case-sensitive, the longest first, the line’s before the course’s. @elabora/core spokenText(project, screen, line, language) gives what the voice is told and its hash (applyPronunciations, spokenTextOf); captions and the transcript keep the text. A generated line whose spoken text changed is out of date (W_NARRATION_AUDIO_STALE: « the pronunciation of its words changed »).
  • Delivery: a line’s delivery, settings by language over those of the speaker’s voice (deliveryOf); other settings make a generated line out of date. update_narration_line takes delivery and pronunciations, update_course takes pronunciations.

Unreleased — narration and timed steps, phase 1 of the timeline (2026-10-08)

Decision D24 (docs/decisions.md), approved on 8 October: what is seen at a time is a function of that time and of the steps reached; what happens (events) only when playback crosses a cue, never on a seek; the course state never goes back; steps are the timeline’s segments.

  • Format 0.2, compatible additions (no new version nor migration: a course without narration is unchanged and keeps its bytes):
    • screen.narration: advance (learner, the default: playback stops at the end of each step and Continue plays the next; auto: the steps follow each other and the course goes to the next screen), seek (seen, the default: back anywhere, forward up to the furthest point reached; free; none) and lines[] (id, unique in the screen; speaker; text, translated; step; pauseBefore, 0.4 s by default, 0 for the first line).
    • course.speakers[]: id, name (shown in captions and the transcript, translated) and voices by language (provider, voice, free settings), for the voice tools.
    • Triggers timeline.reached (at: { line, edge }), timeline.ended, media.ended (target: a sound or a video that is not a loop); actions pause-timeline, play-timeline, play-media, pause-media (block).
    • completeWhen: "viewed" on a narrated screen means its timeline played to its end; with navigation.lockUntilComplete, Continue also waits for the step to be heard.
    • Schema narration-audio for the sidecar of a line’s audio.
  • The audio of a line, by convention: assets/narration/<language>/<screen>/<line>.m4a (AAC, mono) and its sidecar <line>.json (textHash of the line’s text in that language, duration, provider, voice, generated, audioHash, later words). @elabora/core narrationLines says for each line whether its audio is there and up to date (ok, missing, stale, untranslated), narrationTextHash how the text is hashed; elabora rename moves a screen’s audio with it; removing a line removes its audio (undoable).
  • The build makes one track per narrated screen (@elabora/node narration/tracks.ts): the lines decoded, laid end to end with their pauses (a step’s first line at least 0.3 s after the previous step), encoded once into narration/<screen>.m4a (AAC-LC, mono, 48 kHz, 64 kb/s: about 0.5 MB a minute), cached in the project’s data by the hash of its inputs, deterministic, with its WebVTT captions (<v Speaker>, cut at sentences, two lines at most). Cue times come from the decoded samples. Until every line of a screen has its audio, the screen has no track and its timeline runs silent on estimated times (15 characters a second), with its captions. The build report has narration (bytes, seconds, tracks, silent screens); package limits count the tracks, and the proof dossier lists generated voices (AI Act).
  • The player (@elabora/player timeline.ts): a timeline per narrated screen whose clock is the narration’s <audio> (currentTime read at every frame, so the timeline waits when the audio waits and follows its speed), performance.now() without a track, a virtual clock in tests (window.ELABORA_TEST.virtualTime; hooks __elabora.timeline.state|play|pause|seek|advance|at|toEnd). Steps are segments; seeking back hides the blocks of later steps without changing the step reached, and moves focus out of a hidden block to the seek bar. Cues fire when playback crosses them (replaying a passage crosses them again). A modal layer pauses the narration and its closing resumes it; a panel does not; a sound or video the learner plays pauses it, and it pauses them. A screen left part-way reopens at the start of the step reached (the suspend data’s p: no new byte); one seen to its end waits at its end. Phones go step by step. The next screen’s track preloads.
  • The bar, on narrated screens only, in the footer (kept in view at the bottom with navigation.footer: "end"): Play/Pause, Replay the step, the seek bar (WAI-ARIA APG seek slider: role="slider", aria-valuetext with the time and the step, arrows ±5 s, Page Up/Down a step, Home, End, Space while it has the focus), the time, Captions, Speed (0.75–2×, playbackRate, pitch kept), Mute, Transcript (the script with its speakers, the line said marked), « M’arrêter à chaque étape » on auto screens, and « Voix générée par IA » when a line’s voice is. Shortcuts Ctrl+Alt+P, R and C (aria-keyshortcuts), never one character. Captions in a band over the bottom of the screen that moves nothing. When the browser refuses sound, a large Play button waits for the learner. Minimal styles, isolated under .t-timeline* (docs/design.md), to be restyled after the app’s mockups. Labels in the seven languages (narration, play, pause, replayStep, seek, captions, speed, mute, stopAtSteps, playNarration, timelineTime, timelineValue, listen, generatedVoice).
  • The validator: W_AUTO_ADVANCE (WCAG 2.2.1), W_SEGMENT_TOO_LONG (more than 90 s of voice in a step), W_NARRATION_DUPLICATED (on-screen text that reads a line again, rule E4, now checked), W_NARRATION_AUDIO_MISSING, W_NARRATION_AUDIO_STALE (text, audio or voice changed), W_TIMED_INCREMENT (counting on a cue counts replays), E_NARRATION_LINE_DUPLICATE, E_NARRATION_SPEAKER, E_NARRATION_STEP_ORDER, E_TIMELINE_CUE (a cue on no line, a timeline trigger or action on a screen without narration), E_NARRATION_TRACK (the build could not make a track). media.ended and play-media/pause-media targets are checked (E_EVENT_TARGET, E_ACTION_TARGET); reveal-step reaches the steps of lines. The generated captions and transcript answer F3 for the narration.
  • Engine and app: commands update_narration, add_narration_line, update_narration_line, move_narration_line, remove_narration_line, set_narration_audio (a voice tool’s M4A or a recording, measured and written with its sidecar in one change); update_course takes speakers; screenParts gives the narration’s lines (and their steps) to the event editor; the canvas shows a line’s moment (RenderView.at, RenderOptions.at).
  • Agents: elabora timeline [dir] [screen] [--language] [--json] (MCP elabora_timeline): the steps, lines, times, audio and cues of a narrated screen as text, or every narrated screen and its audio; elabora narration import [dir] <screen> <line> <file> (MCP elabora_import_narration): a recorded human voice, converted, its silences trimmed; elabora look --at l2 (MCP elabora_look at) and elabora screenshot --at l1,l2:end (MCP elabora_preview_screenshot at): a narrated screen as it is when a line starts or ends, its caption shown. Captures, elabora fit and the LMS emulator run narrated screens on the virtual clock: captures and fit see them still, the emulator plays them to their end (closing the dialogs their cues open). The emulator’s scenario errors name the step they stopped at.
  • Translation: lines (screen.<id>.narration.<line>.text) and speaker names (course.speaker.<id>.name) are units; each language’s package carries its own voice.
  • LESSON.md export: a decorative sound or video, or a silent video described by its alt, no longer writes « Transcription undefined »; a narration is reported as a loss.
  • Docs: D24, SPEC (§1, §4.1, §4.2, §4.4, §13), docs/design.md (the bar’s DOM hooks; narrated slides fit with their bar), docs/architecture.md (narration audio and tracks), docs/diagnostics.md, the course AGENTS.md, the skills elabora-course-authoring and elabora-pedagogy (segmenting, contiguity, redundancy).

Unreleased — captions for agents (2026-10-08)

  • elabora media captions [dir] <block> <file.vtt|.srt> [--name <name>] [--json] and MCP elabora_set_captions (block, and file, an absolute path, or text; name): a video block’s captions, as the app gives them (engine command set_captions, @elabora/core setCaptions). The text is written under assets/captions/<block>.vtt (a SubRip file converted to WebVTT; the same text already there reused, another one numbered) and the block’s captions names it, in one undoable change; the same captions again change nothing. Refused: a block that is not a video, a text with no cue. Until now only the app could do it, so an agent left with E_VIDEO_CAPTIONS_MISSING had no command to fix it.
  • E_VIDEO_CAPTIONS_MISSING’s fix names the command and the tool; so do the course AGENTS.md (media rule), the skills elabora-course-authoring and elabora-pedagogy, docs/architecture.md (media) and the README. The help and the MCP catalogue (test fixtures) gain them.

Unreleased — a decorative sound (2026-10-08)

  • decorative on an audio block reaches the player (the compiler dropped it: AudioProps.decorative in the course data). A decorative sound (an ambiance, music) shows no transcript, even one left in its props, as a decorative video shows none; the learner still starts it, so its player stays in reach and named, unlike a decorative image or loop, which assistive technologies skip.
  • A sound’s player is named by its title (title on <audio>, as on a video’s), decorative or not: it had no accessible name, only the figure’s caption.
  • The agent’s screen summary marks a decorative video or sound (decorative), as it does an image.

Unreleased — closing the open layer (2026-10-08)

  • hide-layer may leave out layer (format 0.2, compatible: only show-layer requires it now; every course valid before stays valid, so no new version nor migration). Without it, the action closes the layer open on top: a dialog (modal, over everything) before a panel, the last opened first. It is the action of a Close button inside a layer, which then needs no layer named; the app’s « Fermer un calque › Le calque ouvert » writes it, and the format refused it until now. Its layer.closed fires as for any other layer; with no layer open it does nothing.
  • The validator: E_ACTION_TARGET when the screen has no layer to close. The compiled course keeps such an event (it was left out as malformed), the player closes the layer on top, the agent’s screen summary says hide-layer (the open layer).
  • Docs: SPEC §4.4, the event schema’s descriptions of the actions and of layer.

Unreleased — Microsoft Edge when Chrome is not there (2026-10-08)

  • launchBrowser('chromium') falls back to Microsoft Edge (@elabora/node emulator/browser.ts): the installed Google Chrome, else Edge (Playwright channel msedge: on every Windows 10 and 11, sometimes on macOS), else the Chromium Playwright downloads; each with the same flags (software or GPU rendering, unload variants, disabled features). A tester’s computer without Chrome, running the command line an Elabora test build carries, can now look, capture, fit, test packages and objects, replay, preview components and make contact sheets, and their MCP tools. The order is CHROMIUM_CHANNELS; launchBrowser(…, { channels }) takes another (launchChromium tries them with a given Playwright chromium).
  • Free-object gates run on Edge too. Edge 154 draws as Chrome 154 in software (macOS, measured): the same SwiftShader device (ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0)))); of the 22 snapshots of the four example objects (diagnostic-reseau, planetary-lab, chart-table, phasing-lab), 21 are Chrome’s pixel for pixel and one differs by 16 pixels of a line of text, 2/255 apart (two runs of Chrome differ as much on other snapshots); their gates pass against the baselines Chrome recorded. Unload deprecated and allowed behave as in Chrome. Baselines stay per system, whichever of the two drew them. test/e2e/edge.test.ts checks it where both are installed (GitHub’s runners): the same renderer, the same pixels within the gates’ tolerance.
  • Without a browser, the message is for someone without developer tools: Cannot open a browser: Google Chrome and Microsoft Edge are not installed on this computer. Install Google Chrome (https://www.google.com/chrome/), then run the command again (developers: npx playwright install chromium works too). A browser that is there but does not start is named with its reason (« Update or reinstall Google Chrome » when it is Chrome), without Playwright’s call log.
  • Docs: README (requirements), architecture (browser for measures, captures and tests).

Unreleased — a library project knows the example it was copied from (2026-10-08)

  • importArchive({ origin }) (@elabora/node library/): what the archive is a copy of, an example the app ships ({ kind: 'example', id, version }), written in the project’s data (<data>/origin.json, with the time of the copy) before the project appears in the library, or after a replacement under its lock. LibraryProject.origin reports it (listProjects, getProject, findProjectsByCourseId); null for any other project. Machine-local: an export does not carry it, a duplicate does not inherit it, a replacement keeps the replaced project’s. The app opens the copy it made from an example instead of making another, and « Repartir de l’exemple » makes a new one.

Unreleased — a package’s limits, with the LMS that sets them (2026-10-08)

  • course.json delivery.lms (format 0.2, compatible): the LMS the course is delivered to, ids of LMS profiles. elabora build, validate and test, elabora_build, elabora_test_package and the engine’s build target them when no --profile is given (targetProfileIds); an id no profile has is E_LMS_UNKNOWN at /delivery/lms/<n> (a preview still runs, without it).
  • No package limit is assumed for an unknown LMS. The unknown profile (no LMS chosen) had the smallest documented size, 100 MB (Teach Up’s), and stopped builds on it. Its size, file and compression limits are now null: the build warns instead with the documented LMSs that would refuse the package, so the author chooses the LMS rather than shrinking the course for a limit it may not have (W_PACKAGE_TOO_LARGE_FOR_SOME_LMS, W_PACKAGE_TOO_MANY_FILES_FOR_SOME_LMS, W_COMPRESSION_RATIO_FOR_SOME_LMS). A chosen LMS’s limit still stops the build (E_PACKAGE_TOO_LARGE, E_PACKAGE_TOO_MANY_FILES, E_COMPRESSION_RATIO), and the extracted size is now checked too (maxUnzippedMB: 360Learning, Workday).
  • Package findings carry their facts (Diagnostic.details, PackageLimitDetails in @elabora/core/lms/package-limits): the measure, its value, the lowest limit passed, the LMSs that set it (id, name as people say it: lmsName) and whether they were chosen, the media quality, the package at each quality (measured or estimated), the best quality under the limit (fits; null when none is enough), and the heaviest files (path, asset, size, kind, duration). The English message says all of it, and what to do when no quality is enough: shorten the video, host it on a video platform and link to it, or split the course. The engine’s build result has warnings too, and so has buildTranslation’s (a language’s package, the app’s « Versions »: its build targets the course’s LMS as the source’s does).
  • Profiles checked against their sources (October 2026): SuccessFactors publishes no package size (a tenant setting, CONTENT IMPORT maximumFileSizeKB; KBAs 2294748, 2622763, 2428881; files over 1,800 MB are not served by iContent, KBA 2984975): 2048 MB → null. Workday: 2 GB by default for new tenants, 4 GB once raised, 20 GB extracted, 100,000 files: 4096 → 2048 MB, maxUnzippedMB 20480. Docebo only advises 15,000 files: maxFiles → null. Confirmed: Teach Up 100 MB (lower on some subscriptions), Rise Up 1 GB, Docebo 1024 MB, Cornerstone 2 GB per file, 360Learning 4 GB extracted, 50,000 files, ratio 200. SCORM Cloud: no limit on paid accounts (trial: 100 MB extracted, noted). A unit test checks that every package limit of a profile comes with a source.
  • Docs: architecture (Media, engine build), decisions, the elabora-course-authoring skill and the course AGENTS.md template (name the LMS before delivering), docs/diagnostics.md and docs/lms-profiles.md (extracted size and ratio in the table).

Unreleased — the canvas scrolls under a host’s overlay (2026-10-08)

  • elabora/scroll (host → canvas message, @elabora/player canvas-protocol.ts): a host that lays something over the canvas page to take the pointer (the app’s selection) takes the wheel with it and passes it on, by in CSS px of the canvas viewport and at the pointer there. The canvas scrolls the nearest box under the point that can still go that way, else the page, at once (smooth scrolling would lag a trackpad’s many small distances). From the parent window it needs the page’s token, as the other messages. The app let its overlay through for 400 ms once the wheel turned instead; Chrome sends a whole gesture (a trackpad swipe, a mouse wheel turned on) to what was under the pointer when it began, so most gestures stayed on the overlay and the page did not move.

Unreleased — an event written from its trigger (2026-10-08)

  • An event may have no actions (format 0.2, compatible: do loses minItems: 1; every course valid before stays valid, so no new version nor migration). The app writes an event as soon as its author has chosen what triggers it, in its place among the others, and its first action follows; until then it does nothing. upsert_event and MCP elabora_upsert_event accept do: [], and the player runs nothing for it.
  • W_EVENT_NO_ACTION (warning, « Événement sans action » in the app): an event with nothing in do nor in else. A warning, not an error: it breaks nothing for the learner and does not stop a build, but it is unfinished work an author or an agent should see. An event with conditions may leave do empty and act in else: no warning then.
  • Docs: SPEC §4.4, the event schema’s description of do, elabora-course-authoring (an event the author started: finish it as they meant it, or ask), docs/diagnostics.md.

Unreleased — a translation seen in context, built as its own package (2026-10-08)

  • screenTranslation (engine request, @elabora/core screenTranslation): one screen’s texts in a language, as translationTable gives them, each with the block and the field that show it on the canvas (data-t-field: options.1.text); the plan’s validated texts and questions its blocks show are among them, at their block. The canvas already shows a screen in a language (canvasView { language }, renderScreen { language }): the app’s « En contexte ».
  • A language renders while some of its translations break the text: translatedCourse({ skipBroken: true }) leaves those out (their source text stays, listed in broken); renders and looks use it, so one lost placeholder no longer makes the whole language unrenderable. Builds still refuse them.
  • translate_text takes texts (several units in one change, one undo) and starts a language the course does not have yet (translations/<lang>.xlf written with them); one refused text refuses the batch.
  • approve_translation (engine command, @elabora/core approveTranslation): the translated plan approved by a person, as elabora translate approve writes it, as a change to undo.
  • buildTranslation (engine request): a language as its own package, the translated course generated in the project’s data folder (.elabora/translations/<lang>, never copied) and built into <project>/dist (<course>-<lang>-<version>-scorm12.zip); texts left to translate stop it unless allowMissing (a draft). elabora translate build accepts an output folder in .elabora or dist of the source.
  • MCP elabora_translation_table (a language as a bilingual table, screen for one screen’s texts with where they show, todo for what is left) and elabora_translate_text (many texts per call, one undoable change).

Unreleased — a free object’s choices in words (2026-10-08)

  • x-enum-labels (object manifest, on a prop, on the items of a list of simple values or on a field of a list’s items): how each value of an enum reads in the editor, in the manifest’s language ({ "parefeu": "Pare-feu" }); the object still receives the values. Each key must be a value of the enum (E_OBJECT_ENUM_LABELS, numbers written as text). The network simulation of diagnostic-reseau words its four devices.
  • props.enumLabels (free-object block): the same words for one block ({ "failingDevice": { "parefeu": "Firewall" } }, "<prop>.<field>" for a field of a list), checked against the manifest’s enums (E_OBJECT_ENUM_LABELS). Translations write there: units block.<id>.enumLabels.<prop>.<value>, marked editor (authors read them, learners do not), so findText and replace_text leave them alone.
  • screenParts gives each setting of a free object its enumLabels (the block’s over the manifest’s), for event editors that offer its values (set-object-props).

Unreleased — events, layers and variables edited in the app (2026-10-08)

  • screenParts (engine request, @elabora/core project/parts.ts): what the events of a screen can name, so that an event editor offers only what the validator accepts: its blocks in order (layers’ included), each with its behaviour, the names of its controls (what a click names; none: it cannot be clicked), a decision’s options, a free object’s declared events (with the payload fields a trigger can filter on) and its authors’ simple settings (for set-object-props); its layers and steps; the course’s screens and variables.
  • upsert_layer and upsert_event without an id add a new layer or event with a free id (<screen>-layer, <screen>-event, then -2…), returned as layer or event; the blocks of a layer written without ids get free ones too (<layer>-<type>).
  • Steps by command: update_block takes step (a block of the screen itself: the step that reveals it; null or 1: from the start; refused for a block in a layer or a component instance), update_screen takes steps (how each step opens, replaced whole; null removes them). MCP elabora_update_block and elabora_update_screen too.
  • remove_variable (engine command): a variable no event uses any more; one still read or written is refused, with the events that use it.
  • replace_text (engine command, @elabora/core replaceText) and findText (engine request, findText): the course’s words found and replaced in one change, one undo: every string a learner reads in course.json and the screens, as translation extracts them (titles, blocks’ words, layers, step buttons, free objects’ wording); never the plan’s validated texts (a retouch, block by block), ids, files or values of a choice; in Markdown, never a link’s address, maths or code. A replacement that would change an object string’s placeholders, exceed its length or empty it is skipped and said; units limits it to chosen matches.
  • MCP tools for the app’s new commands: elabora_find_text, elabora_replace_text, elabora_duplicate_screen, elabora_remove_layer, elabora_rename_variable, elabora_remove_variable; elabora_upsert_layer takes a layer without id; elabora_update_course takes presentation; edit results give the ids made (layer, event) and what a replacement did (replaced, units, skipped).
  • Translations as changes (the app’s « Traduction »): engine commands export_translation (a language started or brought up to date, translations/<lang>.xlf as elabora translate export writes it) and translate_text (one unit translated, reviewed or cleared; its source becomes the current one; a translation that loses the source’s maths, links, code or placeholders, or exceeds its length, is refused); engine requests translationStatus and translationTable (@elabora/core translationTable: every unit with its translation and what it asks for: to translate, to review, stale with the source it was translated from, translated; and what breaks the text).
  • create_screen takes layout and layoutProps: a new screen in one of the course’s screen components, in one change (the app’s « Nouvel écran » offers them).
  • duplicate_screen (engine command, @elabora/core duplicateScreen): a copy of a screen after it in its section; its blocks, layers and events get new ids (those named after the screen follow its new id), and the copy’s events point at its own blocks and layers.
  • update_course takes presentation (scroll or slides, reading widths, the sizes slides fit), so that the app’s settings can change it.
  • rename_variable (engine command, @elabora/core renameVariable): variables have ids of their own, which rename does not take; the variable gets its new name in course.json, in the events that read or write it (triggers, conditions, actions), and in the state lock, so the value saved for learners stays theirs.

Unreleased — an open project loses no write when other watchers come and go (2026-10-07)

  • On macOS FsStore watches each folder from a worker thread of its own. The watchers of a thread share one FSEvents stream, built again whenever one of them starts or stops, and what is written from about 16 ms before to 10 ms after is never reported. In the app’s engine process, starting or stopping the preview, or opening or closing another project, hid an agent’s writes from an open project until the listing every 15 s: with a file written every millisecond, 467 writes of 5786 over 10 previews started and stopped, 336 of 4297 over 10 projects opened and closed (packages/engine/bench/watch-rebuild.ts). A worker has its own event loop, hence its own stream: none lost since. The worker is plain JavaScript evaluated from a string (nothing to resolve in an app bundle; it runs in Electron’s utility process). Costs: a thread and about 10 MB per open project; the folder’s watcher reports after 38 ms instead of 11 (median), so a project opens about 25 ms later on macOS. Linux (watchFolders) and Windows are unchanged; so is the Store contract.
  • The journal’s watcher stays in the engine’s thread: what another Elabora tool journals it also writes in the folder, whose watcher reports it, and the journal is read before the files. The preview’s watchers stay there too: a change written while another watcher starts or stops does not rebuild the preview, the next one does.
  • Tests: a store reports every write while a preview’s worth of watchers start and stop in the process, and a session sees every write of an agent while other projects open and close, without the periodic listing (before, the store missed 10 to 20 % of the writes, the session about 40 %).

Unreleased — a project is watched once open (2026-10-07)

  • openProject resolves once the folder’s watcher reports what is written from then on, and the session’s first listing of the folder comes after it: a file an agent writes as soon as the app has opened the project is journaled at once, instead of at the next listing, 15 s later (the engine tests turned that listing off and failed once in about twenty runs of npm test). On macOS the watchers of a process share one FSEvents stream, built again whenever one starts or stops, and writes from about 16 ms before to 10 ms after are never reported; with sessions kept open in one process, a write right after opening was missed in 5 opens of 120, and in none of 240 since (packages/engine/bench/watch-ready.ts).
  • Store contract: watch(onChange, onReady?) (@elabora/core project/store.ts): a store calls onReady once what is written from then on is reported, at once when it cannot tell, or after a bound. FsStore on macOS writes a probe file in the project’s .elabora/ every 25 ms until its watcher reports it, then removes it (about 12 ms; 2 s at most, for a volume without FSEvents); inotify and ReadDirectoryChangesW watch from the call. The session starts the journal’s watcher before the folder’s and waits 3 s at most. Open time on macOS: median 7 → 14 ms in the measure. Stores that wrap another store’s watch pass onReady on.
  • Tests: the engine tests open projects with openProject again (the probe helper openWatched is gone); new tests write as soon as a store is ready and as soon as a project is open, with other folders watched.

Unreleased — automatic versions in any process (2026-10-07)

  • An edit no longer hangs when the worker of its automatic version cannot start. Edits through @elabora/node edit (CLI, MCP server) take the version in a worker thread and wait for it; a process started with node --input-type=module -e "…" passed --input-type to that worker, which failed before running anything, and the edit, holding the project lock, waited 10 minutes without a word. The worker now does without the flags of the main entry (--input-type, -e/--eval, -p/--print; it inherits every flag when the others include one a worker refuses, such as --max-old-space-size), and starts from an evaluated bootstrap that wakes the edit in every case: a task module that cannot load, a thread that stops before finishing (an uncaught error, process.exit) or cannot be created end the wait at once with an error in the result’s backup, and the edit goes on, as when a version cannot be taken. The CLI was not affected.

Unreleased — faster free-object gates (2026-10-07)

elabora object test (MCP elabora_test_object) had become the slowest check agents run: about 230 s for the 3D chart table of medieval-charts and 22 s for diagnostic-reseau on a Mac.

  • Where the time went (Linux, 4 cores, no GPU: chart table 326 to 328 s, diagnostic-reseau 8.3 s). Nearly all of it was Chrome drawing the objects’ frames in software (SwiftShader, which the gates keep: snapshots need the same pixels on every machine, and the boot budget speaks for machines without a GPU): 40 to 75 ms a frame for the chart table, about 5,600 frames a run, of which its snapshots look at 10. A step returned once its frames were queued, so their drawing was paid by whatever came next: a later step (up to 24 s), closing the page (72 s in all, for frames nobody would see) or a snapshot, which hit Playwright’s 30 s timeout: the chart table failed its gates there, every run (« Three voyages complete the table »; with the stall guard’s 60 s that main gave snapshots since, it passes in 309 s). Pages (0.1 s per test), axe (0.2 s), PNG comparisons (0.1 s) were negligible; mounting takes 0.3 to 8 s a test (its first frame uploads textures of 1,536 to 4,096 px), and stays: it is the boot time the gate measures.
  • Dry frames. Frames no snapshot shows run dry: the object’s code runs every frame (its state, events, uploads, shaders and their errors), its draws, clears and blits are skipped (@elabora/player dry-frames.ts, frame method object/test-dry, test mode only). What a skipped operation would have drawn is tracked as stale until drawn again; a dry run decides only when it passes and saw nothing that depended on it (a canvas still stale at a snapshot, a draw on a stale framebuffer or sampling a stale texture, readPixels, copies and blits of a stale drawing, a WebGL canvas drawn into a 2D canvas, uploaded or turned into an image, a query or getError after a skipped draw, transform feedback). Otherwise, and whenever it fails or a snapshot differs, the run is done again with every frame drawn, and that run decides; when only the drawing of a render target was reused, it is first tried again with render targets drawn and only the canvas dry, as are the object’s next tests (orbital-rendezvous bakes its Earth into textures over the first frames: 134 s fully dry and drawn again, 48 s so). Runs that record baselines draw every frame. The 300 frames of the smoke gate, tests without snapshots and the steps after a test’s last snapshot cost nothing to draw; a step before a snapshot draws its last frame only. testObject(…, { allFrames: true }) draws everything.
  • A step waits for its frames (a WebGL 2 fence: gl.finish is a flush in Chrome), so that every call waits for its own drawing and the 60 s stall limit applies to it; a snapshot no longer times out behind queued frames.
  • Snapshots through the DevTools protocol: a PNG Chrome encodes for speed when it is only compared (lossless; the same pixels as Playwright’s element screenshot, without the text caret as there; about 50 ms instead of 0.4 to 1 s), with the default compression when it is kept as a baseline: baselines recorded now are byte for byte those recorded before. Existing baselines stay valid: the chart table’s ten and diagnostic-reseau’s two compare equal.
  • After, on the same machine, against the gate runner of main (which since gives snapshots the stall guard’s 60 s): chart table 309 s → 36 to 45 s, orbital-rendezvous phasing lab 288 s → 37 s, diagnostic-reseau 8.5 s → 4.2 to 4.9 s, every gate as before and every baseline equal (those of main included). Drawing every frame (allFrames), the chart table takes 322 and 331 s: the time goes away with the frames not drawn. What is left is mostly mounting, which the boot gate measures.
  • How each run was drawn is in the result (gates.frames: dry, dry on the canvas, or every frame (why): records baselines, to confirm a failure, readPixels of a drawing a dry frame skipped…), so that a slow test explains itself; it is not part of object.gates.json.
  • Boot times are measured as before (the mount and its first frame, drawn). On this machine they come in steps of about 2.2 s whatever the code (chart table, medians of 9 from 0.7 to 4.5 s over runs of the same object, with or without dry frames; the slow-boot warning comes and goes): the time is in the object’s first frame, which uploads its canvas textures through SwiftShader. Not changed here.
  • Evaluated and left out: running behaviour tests in a GPU browser (the frames it would move are the ones now skipped; frames before snapshots must stay in software; without a GPU, as here and on CI, both browsers are SwiftShader), several pages at once (SwiftShader already uses every core: two runs together took 93 s against 112 s one after the other, and concurrent mounts would skew the boot samples), fewer page loads (0.1 s each), dropping the queued frames when a page closes (Page.crash hangs Playwright’s close).
  • Docs and skills: docs/free-object-contract.md (protocol, §6 Frames drawn), docs/architecture.md (Rendering), elabora-free-objects (draw every frame in tests, pick by raycasting rather than readPixels).

Unreleased — free-object gates on a loaded machine (2026-10-07)

  • Event expectations no longer read after a fixed 20 ms (elabora object test, gate 3). Each expect-event and expect-no-event first takes a round trip to the frame (messages between two windows keep their order), so the events of the earlier steps are all in, those answering a notification (set-props, set-view…) included; an expect-event not yet met reads the events again until its event arrives or 5 s have passed (an object that emits once an asset has arrived). Virtual time does not move meanwhile, and consecutive expectations still share the events of an action. Under a load average of 30 to 70, an event could arrive after the 20 ms and fail its test.
  • Snapshots have the stall guard’s 60 s (gate 5) instead of Playwright’s 30 s, which software WebGL with large textures exceeded under load (medieval-charts, chart table); a capture that never ends fails its test as a stuck call does.

Unreleased — fonts for Central European languages (2026-10-07)

The vetted fonts held the latin subset only: a course in Polish, Czech, Romanian or Turkish had its ą, ř, ș or ğ drawn by the fallback fonts of the stack, in another typeface than the letters around them.

  • unicodeRange on theme.fonts entries (format 0.2, additive): the characters a font file holds, in CSS unicode-range syntax (code points only, checked by the schema). A face split into subset files is one entry per file; the player writes one @font-face rule per file with its unicode-range, so a browser downloads a file only for the pages that use one of its letters. The compiled course data carries it, and free objects get each face with its range. theme.fonts holds 24 entries instead of 12.
  • @elabora/fonts ships the latin-ext subset of 24 families (every one but UnifrakturMaguntia, which has none), each file with its range from Fontsource’s unicode.json: the list goes from 1.87 MB to 3.42 MB (latin 1.76 MB, latin-ext 1.56 MB, licences 0.11 MB). The catalogue is elabora-fonts/2: subsets (latin; latin-ext: az, bs, cs, cy, dsb, eo, et, haw, hr, hsb, hu, lt, lv, mi, mt, pl, ro, se, sk, sl, tk, tr, uz) replaces subset and languages, families list their subsets, files their subset and unicodeRange; languageSubsets(language) says what a language needs, and selectFontFiles takes subsets. Pangrams in Czech, Hungarian, Polish, Romanian and Turkish.
  • elabora font add takes latin-ext when a language of the course needs it: the language of course.json or of a translation (translations/<language>.xlf). Western European courses get the latin files as before, without ranges. --subsets latin,latin-ext (MCP elabora_add_font subsets, engine add_font) chooses; with --from, any subset of the Fontsource package (cyrillic, greek, vietnamese…), with the ranges of its unicode.json (a package without one is taken one subset at a time; scripts Fontsource splits into numbered slices, Japanese, Chinese, Korean, are refused: take a font file). Notes say which languages keep letters no embedded file holds; the output names the subsets. elabora font list and elabora_list_fonts give the subsets.
  • Tests: the catalogue’s ranges hold the letters of every pangram; in Chrome, a Polish word is drawn by the embedded face alone (the DevTools protocol’s platform fonts) while a family added without latin-ext leaves its letters to another font, and only the latin-ext file of the faces a page uses is fetched.
  • Docs and skills: docs/design.md Fonts, D22 in docs/decisions.md (revised: the size, the new mark at about 4 MB), SPEC.md, docs/architecture.md, packages/fonts/README.md, elabora-visual-design.

Unreleased — looking fast while designing (2026-10-07)

Agents building courses reported a slow look-and-fix loop: each visual check was a build plus a headless browser, about 12 s, and the in-app browser of their app did not show 3D objects, so they wrote throwaway Playwright scripts.

  • Where the time went (one screen, elabora screenshot, cold): starting Node and building were under 1.5 s; most of the rest was Chrome drawing in software. Every browser Elabora started forced SwiftShader for WebGL (--use-angle=swiftshader), which also composites every page on the CPU: waiting for fonts, images and objects took 2 to 9 s on designed pages, a full-page PNG 1 to 4 s, and a free object 1.5 s more of fixed wait. Measured on the samples, cold, before and after alternated (other test runs loaded the machine): a capture of the 3D screen of diagnostic-reseau took 10.3 s, of medieval-charts 37 s, of a long medieval-charts page 20 s; elabora fit 23 to 48 s and 160 to 250 s.
  • Captures draw with the GPU. launchBrowser takes rendering: 'software' | 'gpu'; captures (elabora screenshot, component previews), elabora fit, elabora object replay and contact sheets use the machine’s GPU (SwiftShader where there is none). Free-object gates and the LMS emulator stay in software: visual baselines need the same pixels everywhere. The same captures now take 4.0 s, 7 s and 4.5 s. elabora fit reads the pixels behind text from a PNG Chrome encodes for speed (lossless; about 0.1 s instead of 0.6 s a window): 9 s and 33 s. elabora test (Moodle, iframe) 30 → 22 s and 121 → 59 s through its fit pass; elabora object test is unchanged (software, 22 s and 230 s).
  • elabora look [dir] [--screen <id>,…|all] [--size …] [--state answered] [--step <n>] [--layer <id>] [--viewport] [--json] | --url | --stop (MCP elabora_look): a screen in well under a second while designing. A look server kept for the course (@elabora/node look.ts: one canvas host and one GPU browser, started in the background by the first look, stopped after 15 minutes without one) renders the screen from the files as they are, without a build, as the editor canvas does, with the player’s header and footer; the page updates in place, so a free object an edit did not touch keeps running, and one whose code or assets changed starts again. It writes a JPEG (PNG with --out x.png; PNG took seconds to encode on textured pages), says whether a slide fits at that size or how long a page is, the objects and console errors, and with --json where every block is. --url prints a page that follows the looks and every saved file, for a browser pane. A look after an edit takes 0.3 to 0.4 s from the command to the image on the 3D screen of diagnostic-reseau (0.14 s in the server), 0.4 to 0.5 s on a four-window medieval-charts page, 0.7 s on its 3D page (the object below the window is scrolled to and drawn); the first look of a session 2 to 2.6 s (the server and the browser start). elabora look starts without loading the rest of the command line (bin/elabora.ts; @elabora/node look-client.ts): 0.1 s instead of 0.5 s. The full build and its checks stay the final word.
  • Free objects show in the in-app browsers of agent apps. Claude’s browser pane refuses frames sandboxed without allow-same-origin (an opaque origin, what packages use for free objects) with ERR_BLOCKED_BY_CLIENT, and what they would load; the course showed « Chargement de l’activité… » for ever. elabora preview and the look page now serve the same files on a second port of their server (startServer({ frames: true })) and load object frames from there with allow-same-origin: another origin, so the object still cannot reach the page, but the same site (same process, one site approval). The player reads the port from <meta name="elabora-object-frames"> (added by the preview to the page it serves), the canvas from ?frames=<port> (@elabora/player frames.ts); built packages are unchanged.
  • elabora preview pages reload after each rebuild, on the screen they showed (server-sent events from the preview server, in the page it serves).
  • Full-page captures with free objects below the window lay the capture of each object over its frame in the page, then capture the page once, instead of composing the whole page again in a second page.
  • Canvas page options: chrome=player shows the player’s header and footer, inert (the player and the canvas share @elabora/player shell.ts), so a slide has the room it has in a package; frames=<port>.
  • Docs and skills: docs/architecture.md (Browser for measures, captures and tests: rendering, looking while designing, frames in browser panes), docs/design.md §6, elabora-visual-design, elabora-free-objects, elabora-course-authoring, elabora-components, the course AGENTS.md template and the MCP instructions: look after every visual change, check before handing over, no Playwright scripts to see a screen or an object.

Unreleased — lists and material colours in free objects (2026-10-07)

The agent that built examples/medieval-charts had to number a repeated setting (voyage1From, voyage2From…) and to add a theme token for every shade of its 3D scene, down to the brass of the dividers. Both now have a sanctioned form.

  • List props (format 0.2, object manifest, additive): a prop of type: "array" with x-control: "list" and items that are a simple value or an object whose fields are simple values, each with a type and a title (schemas listItem, listObject, listField; minItems, maxItems, required, a default per field for new items). Props take a title too, the label editors show. Items take no field the manifest does not declare. A block’s list, set-object-props, the default and now the props of object tests (test props and set-props) are checked item by item (E_OBJECT_PROP_VALUE names the item: /1/to; E_OBJECT_PROP_DEFAULT). Fields marked x-translatable are translation units (block.<id>.props.<prop>.<item id or index>.<field>); a list a block leaves to the manifest default is written whole into the translated screen. Item fields named like an annotation (description, default) are no longer stripped before validation.
  • Material colours: a colour prop (format: "color", x-control: "color") may keep a literal default with no theme token: the brass of an instrument, a wood, a hull. Colours that carry the course identity still bind to a token with x-token. Fixes of W_OBJECT_LITERAL_COLOR and E_OBJECT_TOKEN say so.
  • Contrast of the text an object draws: x-text-on (format 0.2, additive). A colour prop the object draws text in names the colour that text is read on, another colour prop or a theme token; E_OBJECT_TEXT_CONTRAST (error, F1) reports a pair under 4.5:1 with the manifest defaults, a block’s values or an event’s set-object-props.
  • W_OBJECT_PROP_CONTROL: an x-control that does not fit its prop (list or vec3-gizmo on something else than a list, select without an enum or an integer range, slider without minimum and maximum, color without format: "color", a field of a list with a control of another type).
  • Sample medieval-charts: the chart table (object version 2.0.0) takes one voyages list (one to six voyages, three by default) instead of voyage1From…voyage3To, and colour props for the brass, boxwood, steel and hull of its tools and ship, applied live on update(); its words for the end of the voyages follow their number. A new object test lists two other voyages and recolours the brass. The block’s props are unchanged (it uses the defaults).
  • Sample orbital-rendezvous: the phasing lab (object version 3.0.0) takes one requirements list (one to six requirements, each a distance to the client, the most phasing orbits allowed and a propellant budget; the three of before by default) instead of req1Gap…req3Budget. Its state, events and score follow the number of requirements (state arrays sized to six), as do the dots and the eyebrow of the mission card; the end of the lab says « Every requirement is met » (« Toutes les exigences sont remplies » in fr.xlf) instead of « All three requirements met ». The block of screens/lab.json lists its three requirements, with the same values. A new object test runs two requirements in another order; per-system baselines of its snapshots for Linux.
  • Docs and skills: docs/free-object-contract.md (Lists, Colours), SPEC.md, docs/design.md, elabora-free-objects (lists instead of numbered props, material colours), elabora-visual-design.

Unreleased — blocks added by hand (2026-10-07)

  • add_block without an id (engine command, MCP elabora_add_block): the block gets a free id, <screen>-<type> then -2, -3…, returned as block. An id already used in the course is now refused (Id "…" is already used) instead of leaving a duplicate for the validator.
  • Videos and sounds are added before their alternatives (format 0.2, relaxed and additive): captions and transcript are no longer required by the video and audio schemas, so a video or a sound can be placed (by the app, by hand) and completed afterwards; the validator reports what is missing, as for an image’s alt (errors, rule F3, they block the build): E_VIDEO_CAPTIONS_MISSING (a video played with its sound: the media probe says whether its file has a sound track; a file not probed counts as having one), E_MEDIA_TRANSCRIPT_MISSING (a video played by the learner, or a sound; a silent video may give alt instead), E_VIDEO_ALT_MISSING (a loop).
  • Looping videos (video.loop, with video.alt): a short silent animation plays like an animated image, muted, in a loop, starting by itself unless the learner prefers reduced motion, with a button that pauses it (WCAG 2.2.2; labels pauseAnimation, playAnimation in the player’s seven languages); its alt names it as an image. decorative: true on a video (played as a loop, hidden from assistive technologies) or on a sound (an ambiance, music: no transcript) says it carries no information. A video played by the learner without a transcript but with alt is described by it.
  • The pause button of a loop acts on what it shows. Pressed while the loop was still loading (the video not playing yet), « Mettre en pause l’animation » started it; it now keeps it still, also once loaded. A loop below the window starts when it comes into it (Chrome holds a muted video that plays by itself until then) and stops when it leaves; the button follows.
  • set_captions (engine command): a video’s captions from the text of a WebVTT file, or of a SubRip one (.srt, its timings converted), written under assets/captions/ (named after the block; the same text is reused) and named by the block, as one undoable change.
  • A preview shows a course that is not deliverable yet. elabora preview (and the app’s Aperçu) built like a delivery, so an image without alt or a video without captions, placed and not yet completed, left it on a server error (ENOENT … .elabora/preview/index.html); a package over the LMS size limit did too. build({ preview: true }) (@elabora/node) now stops only on errors of the course itself (format, references, components, objects: codes without a rule); errors under a rule of the guardrails or of the delivery checklist (accessibility, plan coverage, package limits) are reported and the course is shown. When it cannot be built at all, the preview page lists the errors that stop it and shows the course again once a change builds. elabora preview prints the errors left either way. Deliveries and elabora build are unchanged.

Unreleased — vetted fonts (2026-10-07)

  • New package @elabora/fonts: 25 free font families vetted for courses, shipped with Elabora so they are there offline (the desktop app, the CLI, new projects): EB Garamond, Libre Baskerville, Lora, Source Serif 4, Merriweather, Playfair Display, Fraunces; Inter, Roboto, Open Sans, Source Sans 3, Lato, Atkinson Hyperlegible Next, IBM Plex Sans, Space Grotesk, Montserrat, Poppins, Figtree, Nunito; JetBrains Mono; Oswald, Bebas Neue, Caveat, Cinzel, UnifrakturMaguntia. All under the SIL OFL 1.1, as WOFF2 files of the latin subset (variable fonts: the weight axis, one file per style) next to their licence, 1.87 MB in all. catalogue.json records for each family its category and style, use, CSS stack, default tokens, pairings, specimens, weights, styles, files with sizes and hashes, licence and copyright, and where the files came from (npm package, version, tarball and integrity, upstream version); import { FONTS, findFont } from '@elabora/fonts' gives it as data to the app. npm run fonts refreshes the files from sources.json (--check, --update); packages/fonts/README.md lists them.
  • elabora font list [dir] [--category <c>] [--json] (MCP elabora_list_fonts) and elabora font add [dir] <family> [--weights …] [--styles …] [--for body,heading|mono|display|none] (MCP elabora_add_font, engine command add_font): copies a font and its licence into theme/fonts/<id>/, declares its faces in course.json theme.fonts and gives its stack to the tokens (font.body, font.heading, or a custom token font.<name>), as one undoable change; adding a family again replaces its faces. Any other font goes the same way with its licence: --from <Fontsource package folder or npm pack .tgz>, or --file <a.woff2,…> --license <file> [--family <name>] (weights and style read from the files).
  • The build carries font licences: the licence files (OFL.txt, LICENSE…, EULA…) in the folder of each packaged font go into the package next to it; the build report lists them (design.fontLicenses). Packages of courses that kept their licence next to their fonts (the samples) now hold that file too.
  • New warning W_FONT_LICENSE_MISSING: a font file of the course without a licence file in its folder (one finding per folder).
  • The player sets code, kbd, samp and pre in var(--t-font-mono), the custom token font.mono (elabora font add . "JetBrains Mono" sets it), with the system monospace fonts as fallback.
  • Docs and skills: docs/design.md Fonts, docs/architecture.md (packages), D22 in docs/decisions.md, elabora-visual-design, the course AGENTS.md template, NOTICE.

Unreleased — stable tests (2026-10-07)

  • elabora fit gives the same answer every run on scroll pages with transitions. Its second pass (animations on) hides the text to capture what is behind it, then shows it again: that started the colour transitions of the theme back (.t-btn: color .2s), and the next window read buttons still fading in, at 70 to 99 % of their colour depending on timing (medieval-charts: « “Submit” reaches 4.41:1 … as the page scrolls » one run in two). CSS transitions are now settled before each window is read (those of the screen just shown too); scroll-driven and other animations are left as they are.

Unreleased — texts the author points at on the canvas (2026-10-07)

  • Fields on the canvas: rendered with the new fields option (renderScreen, createRenderContext; the engine’s canvas always), templates mark an element whose whole content is one text prop with data-t-field (its path below props: prompt, options.1.label), built-in blocks included, and the canvas layout gives each block its fields with their boxes. The app opens its text editor on the text under the pointer. Packages and captures are never marked.

Unreleased — retouches of validated texts (2026-10-07)

  • Retouches in the module (rule A5): the wording of a locked text changes at once on its block, without a change request: the block keeps textRef and gets retouch: { text, date, by? } (format 0.2, additive, on text, callout and heading), which the module shows until the client’s next review. The plan is not touched and stays approved; I_TEXT_RETOUCHED says it. Commands retouch_text and restore_validated_text (CLI elabora plan retouch|restore, MCP elabora_retouch_text, elabora_restore_validated_text), and the human-only approve_retouches (elabora plan approve-retouches --by): the plan takes the approved wording, stays approved, follows with its hash and lists them in approval.retouches.
  • Verbatim texts: lockedTexts[].verbatim: true marks a text the client wants as signed (a legal notice, a quote): never retouched (E_RETOUCH_VERBATIM); a retouch on a block without textRef is E_RETOUCH_WITHOUT_TEXTREF.
  • The engine’s listFiles request lists the files of the project, or of one folder (changes/, assets/), with their kind and size.

Unreleased — Rise-like steps, replay, contact sheets (2026-10-07)

  • Continue in the page (scroll presentation): the next step of a page opens with a Continue button where the shown content ends, like the continue dividers of Rise; Next, in the footer, goes to the next page. With navigation.lockUntilComplete the button waits for the activities shown above it and says why. Slides keep Next relabelled Continue. Visible change for scroll courses with steps: the footer no longer reveals them.
  • Steps that open once answered: screen steps (format 0.2, additive) sets, by step number, opens (continue, the default, or answered: by itself once every activity of the steps above is answered, right or wrong, without taking the focus) and label (the text of its Continue button, a translation unit screen.<id>.step.<n>.label). A step that opens once answered needs an activity above it (E_STEP_UNREACHABLE, which also counts such steps as reached on screens with continueRevealsSteps: false); settings for a step the screen does not have, or for its first, are the new warning W_STEP_SETTING_UNUSED.
  • navigation.footer: "end" (format 0.2, additive): Previous and Next at the end of the page instead of a bar at the bottom of the window; in scroll presentation they appear once the last step is shown.
  • Labels follow the presentation: in scroll presentation the player says page (Page 2 of 6, the locked and explore messages) in its seven languages; course.ui still rewords them.
  • Headings that follow the page in templates: data-t-heading on an <h2>–<h6> of a component template renders it one level under the heading before the instance in reading order (the others marked so keep their distance), in the package and in E_HEADING_ORDER (@elabora/core components/headings.ts, shared by the validator and the compiler). It goes on headings only (E_COMPONENT_HOOK).
  • elabora object replay [dir] <id> [--test <name>] [--block <id>] [--size 1600x900] [--out sheet.png] (MCP elabora_replay_object): a test of object.tests.json replayed in the built course, on the page that places the object, scrolled into view, in real time; a contact sheet of its snapshots and the end, and the expectations that failed (they wait a moment for an object a little behind).
  • elabora assets --sheet <file.png> (MCP elabora_build_assets sheet): a contact sheet of every asset the generators wrote, on the course’s surface colour, with names and sizes.
  • elabora fit on scroll pages runs a second pass with animations on when a stylesheet animates with the scroll: text that a scroll-driven animation never lets be readable in the middle of the window (20 to 80 % of its height, sampled every quarter of a window) is reported “as the page scrolls”. Measured contrast now counts the opacity of the text’s ancestors.
  • Object tests settle CSS: in test mode the frame ends the CSS transitions and animations of the object’s DOM at once (time there is virtual), so snapshots and the accessibility gate never catch a panel half-faded.
  • elabora build writes again a package folder an earlier build left in the output folder (elabora test writes one), so dist/ never holds a folder older than the ZIP next to it.
  • E_DUPLICATE_ID says that sections share one namespace with screens, blocks, layers and events.
  • The LMS emulator presses Continue buttons in the page.
  • Sample medieval-charts: its scroll-driven animations start later, so the learner sees them happen; Continue buttons and steps that open once a check is answered; Previous and Next at the end of each page; headings of its components follow the page; covered by test/matrix/medieval.test.ts (emulator with axe, fit with and without motion, the gates of its chart table).

Unreleased — what an agent did, and Codex by name (2026-10-07)

  • The engine’s history request takes ops: true: each HistoryItem then carries the change’s ops (as ChangeView.ops in changed events). A window opened after an agent’s work can say what it did (« 1 bloc modifié, 1 ajouté ») and not only that a screen changed. Without the param the response is unchanged.
  • Codex is named in the journal: the CLI journals a elabora command run by Codex as { kind: 'agent', name: 'codex' }. Codex (the CLI and the desktop app, since February 2026) sets CODEX_THREAD_ID in the shells it runs, as Claude Code sets CLAUDECODE; such commands used to be journaled as an unnamed agent. ELABORA_AUTHOR still wins.

Unreleased — scroll pages, Rise-like courses (2026-10-07)

presentation.mode already chose between scroll (the default: a screen is a page, a lesson) and slides. Building a long scrolling sample (examples/medieval-charts) showed where the tools still thought in slides; they now follow the mode.

  • elabora fit in scroll presentation no longer prints a ✘ and “below the fold” for every long page: it gives the length of each page in windows and measures the contrast of its text down the whole page, window by window (it used to read the first window only), at the reference size unless sizes are given. Text that has scrolled under the sticky footer is left out (in slides too), and text shadows now count as what letters are read against (a halo behind a label). FitResult screens have windows. elabora test measures contrast on scroll courses too (it ran the fit for slides only); the build report’s tests.fit stays for slides. The MCP elabora_check_fit description says so.
  • W_TEXT_DENSITY follows the presentation. Slides: 250 words a screen, as before. Scroll pages: the longest stretch of reading with nothing to do (an activity, a component with controls, an accordion, a button, media) over about 450 words, or a page over about 1,500 words (two lessons rather than one). The message names the first and last block of the stretch.
  • Free objects can follow the scroll: ctx.view and host.onView (docs/free-object-contract.md, View), { visible, ratio, progress, entry, exit } measured on the frame like a CSS view() timeline, sent by the player when it changes (scroll, resize, a step revealed) at most once a frame, and at initialize. New protocol notification object/view; hosts that do not measure (slides, object tests) leave the frame wholly in view. Object tests (format 0.2, object-tests schema, additive): view on a test and the step set-view.
  • elabora screenshot --scroll 0,25%,50%,1800 (MCP elabora_preview_screenshot scroll): a filmstrip down a page, the window scrolled to each position (px or % of the page), animations on, on one contact sheet: scroll-driven animations, sticky figures and objects that follow the scroll, which a full-page capture cannot show.
  • Full-page captures show free objects below the window. Their frames run in their own process, which browsers neither run out of view nor paint in a capture beyond the window: each one is now scrolled into view, given time to draw, captured and laid in place.
  • LMS emulator: a first screen whose activity must be done before Next (navigation locked) is answered before leaving it, as a learner would; the run used to time out.
  • W_STYLE_LITERAL_COLOR ignores the colours of masks (mask, mask-image, -webkit-mask-image): only their alpha counts.
  • Docs and skills: docs/design.md Scroll pages (what each tool does in scroll presentation; scroll-driven design as progressive enhancement: @supports (animation-timeline: view()), prefers-reduced-motion, the final state as base style; timeline-scope for a sticky figure that builds up); elabora-visual-design, elabora-free-objects, elabora-course-authoring; the course AGENTS.md template.
  • New sample examples/medieval-charts (English, 4 scroll pages): a folio layout with a turning wind rose, glosses in the margin, a T-O map that assembles as it scrolls into view, a diptych that opens, a scrolling workshop where a portolan chart builds up stage by stage, a wind rose of tabs, and a 3D chart table (free object chart-table) whose vellum unrolls and inks itself as the learner scrolls to it, then three voyages to lay with ruler and dividers; assessment with five validated questions. Geometry shared by the 3D object and the figures (objects/chart-table/chart.js).

Unreleased — found by the desktop app (2026-10-07)

  • The engine builds packages: a build request (session.build({ mediaQuality?, release?, profiles?, name? }); docs/architecture.md, Engine). The app used to call @elabora/node build() beside the session: the state keys it wrote to elabora.lock.json came back from the watcher as an external change, shown as an agent’s work. The build now runs in the session’s write queue (outside the project lock, so CLI writers do not wait for renditions being encoded); the keys it adds are journaled as one system change (command build), and a successful build takes a publish version named after the day in the course language (« Publié le 7 octobre 2026 »). Result: ok, zip, size, report, summary, errors, change, version; renditions encoded meanwhile come as mediaJob events.
  • A new course in French starts with French texts. elabora init, createProject and the library wrote the placeholders of the plan and the intro screen in English whatever the course language (“Replace with the introduction validated in the storyboard.”); they follow it now (French and English; another language gets English): « À remplacer par l’introduction validée dans le storyboard. »
  • A results block failed to show while the course status was « not attempted »: its status took the SCORM value as a class name, and a class name cannot hold a space. The editor canvas and thumbnails of a results screen (nothing answered there) showed the previous screen instead; a player would fail the same way on a results screen reached before the course set its status. The class is now t-status--not-attempted.

Unreleased — Linux and Windows, found by CI (2026-10-07)

  • Project lock on Windows: a lock file another process is deleting refuses to open or be created (EPERM) for a few milliseconds; the lock read that as an error and the writer failed. It now counts as a lock being released and tries again (writers in five processes, 100 increments, none lost).
  • Media on Windows: a conversion’s change lacked the removal of the original. Windows reports the folder of a file that changes, too. A reported folder brings its files to be read again, and the original, gone as soon as the job was done, was journaled as removed by someone else before the job’s change. Files a media job writes or replaces are now left to the job there as everywhere else, so undoing the conversion brings the original back in one step. A store that reports folders like Windows reproduces it on every system.
  • Watching on Linux: Node’s recursive watcher watches each file by its inode and loses a file once it is replaced by a rename (atomic writes, the engine’s own included), so an agent’s next write to it went unseen until the rescan. FsStore watches each folder there (watchFolders).
  • Free-object visual baselines are per system (<test>--<step>-darwin.png, -win32, -linux): fonts and WebGL draw 2 to 3 % of a 3D frame differently on another system, which failed gate 5 for an object made on a Mac. A system’s first run records its baselines; baselines without suffix were made on macOS and stay macOS’s.
  • Planetary sample: the phone-width column of the figure-split layout is minmax(0, 1fr), so content can no longer widen it. On Linux, where maths are drawn with a wider fallback font, the practice screen was 386 px wide at 375 px.
  • CI: .github/workflows/ci.yml runs on Linux for each push to main and each pull request; on Windows it runs the typecheck and the unit suite (the browser suites pass on a Windows 11 machine, but three canvas tests time out on GitHub’s Windows runners); macOS runs weekly. .gitattributes keeps LF line endings on Windows checkouts.

Unreleased — rendition cache pruned (2026-10-07)

  • pruneProjectData (run by automatic versions and once per engine session) also removes the renditions of <data>/cache/media/ that no package can use any more: their master was replaced or deleted, or the preset’s parameters changed. They go once a week old, so a deletion undone meanwhile finds its rendition again. Its result has renditions: { removed, bytesFreed }.

Unreleased — resume on a renamed screen (2026-10-07)

  • cmi.core.lesson_location now holds the screen’s key from elabora.lock.json, not its id. elabora rename carries the key to the new id, so a learner who left on a screen renamed since resumes on it in the new version (they used to start again at the first screen, with their progress). The player still reads a location saved as an id by an older package. elabora test accepts either in its close check; the update report no longer warns about renamed screens.

Unreleased — TypeScript packages (2026-10-06)

The core is now TypeScript in strict mode, in six npm workspace packages (@elabora/format, @elabora/player, @elabora/core, @elabora/node, @elabora/engine, @elabora/cli; docs/architecture.md), run directly by Node 24 with no build step (decision D12, revised). The entries below this one describe what the migration added; this one is what changes for people using the core.

  • Node 24 or later is required (type stripping). node bin/elabora.js and the elabora command work as before.
  • Code that imported src/… modules imports packages instead: @elabora/core (logic without I/O: a project is loaded from a files snapshot, loadProject(files); validateProject(project)), @elabora/node (loadProjectFromDir, validateDir, build, the emulator, media, backups, library…), @elabora/format (schemas and generated types).
  • Builds of the samples are byte-identical to 0.1 except where an entry below says otherwise (the player bundles, "use strict", the format version, the cascade-layer fix). An oracle compared validation, projections and every built file before and after each step.
  • A typical module (87 screens, 288 MB of video): validation 16 ms (one screen re-validated: 0.2 ms); build 1.1–1.4 s and under 190 MB of memory, instead of 4.2 s and 845 MB more (videos were read whole and recompressed at every build, preview included); elabora export 0.4 s, elabora import 1.1 s.

Unreleased — cascade layers in the documented order (2026-10-06)

  • Visible change for existing courses: the course theme now overrides component stylesheets, as documented (elabora.base < elabora.components < elabora.theme, docs/design.md). elabora/player.css still declared the prototype’s order, @layer elabora.base, elabora.layout, elabora.theme;, and loads first, so elabora.components came last: in packages and in the editor canvas a component or layout rule beat a theme rule on the same property whatever their specificity. Every stylesheet that names the layers (elabora/player.css, elabora/theme.css, the canvas) now starts with the same statement, @layer elabora.base, elabora.components, elabora.theme; (LAYER_ORDER in @elabora/core render/styles.ts).
  • What to check in a course: rules of components/*/component.css that set a property the theme also sets on the same element, typically a layout’s own screen title (.t-layout--hero .t-screen-title) against the theme’s .t-screen-title, or a component’s .t-text against the theme’s .t-text. Those now lose to the theme. Capture the course before and after updating (elabora screenshot . --screen all --size 1600x900,1280x720 --state initial,answered --out-dir shots/) and compare; where the component’s value was meant, write it in the theme, scoped to the layout or component (the theme has the last word on what it styles).
  • Samples: the titles of the hero and stage layouts and the object frame of stage (planetary-gearset), the titles of the cosmos-hero, mission and split layouts and the line height of the stat card’s sentence (orbital-rendezvous) moved from their component stylesheets to the theme. Every capture is unchanged.

Unreleased — broken projects, catalogue (2026-10-07)

  • New warning W_OBJECT_UNUSED for a free object placed on no screen (and not the course backdrop), with its own fix; it was reported as W_SCREEN_FILE_UNUSED, whose fix is about screens. The message is unchanged.
  • Broken projects are reported, never a crash: loading, validation, compilation, screen rendering, the projections, translation units, the LESSON.md export and every command read projects whose JSON parses but breaks the schema (agents’ half-written files). A document that is not a JSON object (course.json holding null, a screen holding a list…) gives E_SCHEMA and is left out like a file that does not parse; it used to be silently ignored or to crash. Commands refuse to edit a part of a file that breaks the schema (EditError, for instance screens/intro.json: "layers" is not a list); migrate_components refuses to overwrite a elabora.lock.json that does not read. A block of unknown type or a free object without manifest compiles to an empty block. Seeded mutation tests check every reader and command.
  • W_UI_LABELS_LANGUAGE no longer points to src/runtime/i18n.js.

Unreleased — the editing engine (2026-10-06)

  • @elabora/engine (docs/architecture.md, Engine): the editing session the desktop app sits on. openProject(dir) keeps the project in memory (files snapshot, loaded project, findings per screen and per project check) and runs commands, undo, undoBatch (« Annuler le travail de l’IA ») and mark (« Demander à l’IA ») one at a time as journaled changes under the project lock, given its snapshot: what an edit wrote updates it without reading the folder, and only what the change can affect is validated again (on an 87-screen course: about 5 ms from a command to its diagnostics).
  • Other writers: changes of the CLI and the MCP server are read from the journal and applied, never journaled again; files written by agents’ own tools are journaled as one external change (bursts debounced, the session’s own writes recognised by content); a JSON file being written keeps its last valid content, with E_JSON_PARSE, until it parses.
  • Batches and versions: AI batches close after 5 minutes without AI changes, at a human change or a mark, with an after-ai version; a mark keeps a before-ai version; auto versions every 5 minutes of activity, a close version, the project’s data pruned once per session. Restores are journaled as one system change, so they can be undone.
  • Media in the session: the project’s media queue runs from opening; job results are journaled (a conversion with its references rewritten, undoable back to the original); media dropped into assets/ that are not masters are converted; the media status follows the jobs (I_MEDIA_PROCESSING).
  • Rendering and the canvas: renderScreen through render contexts kept while shared files are unchanged, memoised per screen and view; openCanvas, canvasView, closeCanvas drive the canvas host, and the screen shown is pushed again after each change that affects it, the change’s sequence number as render id (about 10 ms from a command to the canvas DOM in Chrome).
  • Older formats: option migrate ask (the summary says so, the migrate request writes it, an edit migrates first), auto (on open, after the open version) or never (writes refused).
  • Message protocol (@elabora/engine/protocol): requests with ids, typed results and errors, events (changed, diagnostics, mediaJob, backupTaken, batchClosed, error, closed), with session.handle and session.subscribe, and ProtocolClient for the app.
  • Journal: a change may list previous contents that no store kept (unavailable: a binary file overwritten by an agent’s own tools before any version); it is journaled all the same, and undo skips that part. Undo’s reason for contents not kept is now unavailable (it was missing, which stays for targets that are gone; MCP elabora_undo skipped).
  • @elabora/core project/store.ts: the Store interface; @elabora/node store/: FsStore (async reads, atomic writes with retries on Windows sharing violations, recursive watching) and readSnapshot. @elabora/node edits take files, validate: false and autoVersion: false for callers that hold the project in memory, and return written. The component registry is kept per set of components (a project with one screen replaced keeps it); generator imports are parsed once per source text.

Unreleased — media: import, masters, renditions, streaming packages (2026-10-06)

  • elabora media import [dir] <file…> [--to <folder>] [--name <name>] [--no-wait] [--json] (MCP elabora_media_import) brings video, audio and images into assets/ as web masters at the high preset: MP4 (H.264, AAC, index first), M4A, WebP (JPEG or PNG when ffmpeg has no WebP encoder), SVG as is. Files already right are copied or rewrapped without re-encoding; only the streams that need it are re-encoded; metadata (GPS positions of phone videos and photos included) is removed, image orientation and colour profile kept. The original is never copied. Names are reserved at once under the project lock; the conversion runs as a background job (--no-wait: a detached elabora media worker goes on).
  • elabora media optimize [dir] [<path>…|--all] converts media of assets/ that are not masters next to themselves, then rewrites the references and removes the old files in one undoable change (optimize_media). elabora media status [--wait] (MCP elabora_media_status), elabora media cancel <job>, elabora media worker.
  • New codes: W_MEDIA_NOT_OPTIMIZED (a media file of assets/ that is not a master; a GPS position is reported as a privacy matter), I_MEDIA_PROCESSING (a reference to a file a media job is writing, instead of E_ASSET_MISSING), E_MEDIA_UNREADABLE, I_MEDIA_TOOLS_MISSING (no ffprobe: media not checked), E_MEDIA_RENDITION (a rendition could not be made). E_PACKAGE_TOO_LARGE gives the size of the package at each media quality and the one that fits.
  • Media quality of packages: elabora build --media-quality high|medium|low (MCP elabora_build mediaQuality), else course.json delivery.mediaQuality (format 0.2), else high. Below high, packages ship renditions encoded once from the masters and cached; a rendition under another extension (MP3 → M4A) takes that name and the course points at it. elabora build waits for media jobs and shows their progress on stderr; --no-wait refuses to build while they run. The build report has a media section (quality, files, renditions) when the course ships media.
  • Packages are streamed: media are never read whole; the ZIP is planned first (exact size before writing) and written as a stream; folder builds clone media. Packages without media keep their bytes. A 300 MB module builds in under 2 s with under 300 MB of memory.
  • Preview: media are served from the project folder with HTTP range requests (videos seek, Safari plays), never copied into the preview; media still being converted show a placeholder. The LMS emulator serves package files as streams with range requests too.
  • @elabora/core media/ (presets, conformance, plans, size estimates); the files snapshot gives media files of assets/ their probe and verdict (BinaryFile.media), probing only new or changed files, and Project.media (jobs under way, unreadable files, ffprobe missing). @elabora/node media/ (tools, probe, lossless image metadata removal, encoding, jobs, import, optimisation, renditions), packaging/media.ts, http/files.ts.

Unreleased — prototype format 0.2, format migrations, rename (2026-10-06)

  • Prototype format 0.2 (never published: the format restarted at elabora/0.1 on 13 October, D39): course.json delivery.mediaQuality (high by default, medium, low: the media preset of the package; read by the media pipeline once it lands in the build; set with update_course, MCP elabora_update_course, checked against the schema), the a11y wording of free-object blocks is part of the format, quiz shuffle is removed (it was never implemented). Generated types and the registry follow 0.2 (MediaQuality type).
  • Migrations: an older course validates and builds as it is, read migrated in memory, with the new info I_FORMAT_OUTDATED (what elabora migrate would change; elabora validate shows it). elabora migrate [dir] [--dry-run] [--json] writes it as one change after a backup version (elabora undo reverts it). Edits through Elabora (CLI and MCP) migrate an outdated course first, as its own change (the edit result carries formatMigration), and library imports migrate archives and folders. Migrations keep each file’s formatting and change only the migrated values; the gate results of a free object (object.gates.json) carry over when the object changed by its format strings only. A course in a newer format gives the new error E_FORMAT_NEWER and is neither read further nor written. 0.1 → 0.2 changes the format and $schema strings, drops quiz shuffle (blocks, forks, component examples and fallbacks) and updates the two lines of AGENTS.md that name the format and renaming.
  • elabora rename [dir] <old-id> <new-id> and MCP elabora_rename (from, to; 46 tools with the library tools): a section, screen, layer, block or event gets another id in one undoable change. Sections, events, translation unit ids, theme and component stylesheet selectors and elabora.lock.json follow, a screen file takes the new name, so the course validates as before and learners keep their progress; plan waivers that name the id are proposed in a change request. Refused when the new id is in use or not a slug. elabora diff lists renamed elements (learnerState.renamed) instead of removed and added ones.
  • Examples, templates and LMS profiles are in 0.2; test/fixtures/format-0.1/ keeps a course in 0.1 for the migration tests. AGENTS.md (template), the MCP instructions and the elabora-course-authoring skill tell agents to rename with elabora rename and about format versions.
  • Node API: migrateProject, migrateIfOutdated (for the engine on open; both take the executor’s options files, validate, autoVersion, and return written with files), edit.rename; core: checkFormat, FORMAT_STEPS, renameId; project.migration on loaded projects.

Unreleased — the editor canvas (2026-10-06)

  • renderScreen (@elabora/core render/screen.ts): one screen as the player shows it, without building the course: the course data reduced to the screen, the view (state, step, layer), the design stylesheets in their cascade layers and the project files it references; language renders a translation. createRenderContext computes once what screens share; a screen edited since renders with it. The build gets its course data and elabora/theme.css from the same functions (packages are byte-identical).
  • Canvas (elabora/canvas.js, @elabora/player canvas.ts): the player mounted for one screen without SCORM, navigation chrome or saving, updated in place by render data (postMessage or window.__elaboraCanvas.receive): unchanged blocks keep their nodes, so free objects keep running while the author types; scroll, step and open layer stay; every block’s box is reported (elabora/layout). Files carry the hash of their content: an image replaced at the same path or an edited object.js loads again. The screen rendering of the player moves to screen-view.ts, shared by both.
  • Canvas host (@elabora/node startCanvasHost, createCanvasHandler): serves the canvas page, the player bundles and the project files renders list (media streamed from the project folder with range requests), and pushes render data to the pages as server-sent events.

Unreleased — project library and archives (2026-10-06)

  • Project library (@elabora/node library/, docs/architecture.md, Project library and archives): the app keeps projects as plain folders named by their project id in a library of its own (ELABORA_LIBRARY, else the libraryPath setting, else the local application data), with a trash. elabora projects list [--json] [--validate] lists them newest first (title, course id, language, last change, size, validation summary, any error, folder); elabora projects path [<id>] prints the library’s folder or a project’s (by id or its first characters), and --set <dir> [--move|--copy] changes the library’s folder, moving the projects by renaming, or by copying and checking every file; elabora projects create <course-id> --title <t> [--language fr], duplicate <id> [--title <t>], rename <id> --title <t> (an undoable edit of course.json), trash <id>, trash list|restore <entry>|empty [--older-than <days>].
  • Project archives (elabora-archive/1, documented in docs/architecture.md and pointed to from SPEC.md §14): elabora export [dir] [--out <file>] [--history] [--json] writes <course-id>.elabora (by default in the project’s dist/), a ZIP of the project’s files with a manifest of their sizes and SHA-256, and with --history the journal, the backup versions and their contents. elabora import <archive|folder> [--replace [<id>] | --copy] [--json] checks the whole archive (names, sizes, hashes, history) before anything appears in the library; when the library holds the course already it imports nothing, names the projects and exits with 1 until --replace (same id and folder, replaced files to the trash, history kept, a backup version taken first) or --copy is given. A folder is imported as a copy. Long exports and imports report their progress on stderr; --json results go to stdout.
  • MCP: read-only elabora_projects_list (optional courseId) and elabora_projects_path (optional id) let agents find a project’s folder; no tool changes the library.
  • @elabora/node packaging/zip-reader.ts reads ZIP files as streams (one entry at a time through one reused buffer, checked against its CRC and size, inflating stopped at the declared size); writeZip takes store and a known CRC and size for files already read, and reports its progress.

Unreleased — command line and MCP server: undo, history, batches, strict flags (2026-10-06)

  • elabora history [dir] [--limit 20] [--json] shows the changes grouped by batch of AI work: when, who (Claude Code, an MCP client, a person, edits made outside Elabora), the request that started the batch, each change in a few words with its sequence number, and what is undone; --json gives the batches (historyBatches).
  • elabora undo undoes the last change; --change <id|seq> one change (an undo too, which redoes it; --seq still works); --batch [<id>] a batch of AI work, by default the last one. Whatever was edited since is kept and listed (kept: screens/a.json title: changed since), and the command still succeeds.
  • elabora mark [dir] "<request>" labels the next batch of AI work with the request that starts it. In a Claude Code UserPromptSubmit hook, elabora mark --stdin reads the prompt from the hook’s JSON and prints nothing.
  • MCP: writes are journaled as { kind: "agent", name: <MCP client name> } and every write result carries its change (id, seq, batch). elabora_history returns the batches (limit); elabora_undo takes change (id or seq) or batch (id, or true for the last batch of AI work) instead of seq, and returns undone, change, files, skipped (file, path, reason, message) and diagnostics.
  • MCP resources: the server lists and serves the shipped skills again (elabora://skills/<name>); since the move into packages/cli it listed none.
  • Flags are checked: every command declares its flags; an unknown flag, a value given to a flag that takes none, or a missing value stops the command with exit code 2 and its section of the help. They were ignored before, and a flag without value took the next argument (elabora validate --json course validated the current folder). elabora <command> --help prints the section of the command; elabora --version prints the version. elabora change with an unknown subcommand prints its usage. Command words come first.
  • elabora component new --level takes block or screen; elabora component add|publish without --library print their usage.
  • Code: packages/cli/src/commands/ (one module per group of the help) and src/mcp/ (one module per group of tools, typed tool definitions whose arguments ajv checks).

Unreleased — edits as operations, journal and undo (2026-10-06)

  • Every edit is a journaled change: the commands of the CLI and the MCP server (add_block, move_block, create_screen…) are pure functions in @elabora/core commands/; @elabora/node edit/ runs them under a project lock, as operations addressed by id (@elabora/core ops/), with their inverse, their author (ELABORA_AUTHOR, Claude Code, agent, human) and their batch, in journal/<yyyy-mm>.jsonl of the project’s data folder. Edits return change (id, seq, batch); undo is the change’s sequence number in the journal. The edit history of the prototype (.elabora/history/) is no longer read.
  • Undo keeps later edits: undoing a change applies the inverse operations whose targets still hold what the change wrote and reports the others (skipped), instead of refusing when a file changed since. undoBatch undoes a batch of AI work (by default the last), historyBatches lists batches with the request that opened them, appendMark records that request; an undo can be undone. A hand-formatted JSON file comes back exactly as it was.
  • Automatic versions: an edit takes a backup version just before writing when the newest one is older than 5 minutes, then prunes the journal (30 days) and the backups (pruneProjectData).
  • New errors for refused edits: E_LOCKED (another writer holds the project), E_CONFLICT (a file changed on disk while the edit was computed; computed again twice first), E_BLOB_MISSING (a binary content is not kept), and E_OP_TARGET_MISSING, E_OP_DUPLICATE, E_OP_NOT_KEYED, E_OP_NOT_JSON, E_OP_INVALID for operations that cannot apply. Edit errors carry them in code and diagnostics.

Unreleased — the core without I/O (2026-10-06)

  • Projects load from a files snapshot: @elabora/core reads a project only through ProjectFiles (text files with their text, binary files with their size and hash) and loadProject(files); it has no Node import, no process, no Buffer, so the same validation and compilation can run in a browser or on a server. @elabora/node reads course folders (readProjectFiles, loadProjectFromDir, validateDir); media are hashed on demand, through .elabora/cache/hashes.json.
  • New warning W_SYNC_CONFLICT_COPY: conflict copies made by OneDrive, Dropbox, iCloud or Google Drive (intro (conflicted copy).json, intro-DESKTOP-4F2K9.json…) and iCloud placeholders are reported and no longer loaded as content.
  • Asset and font references are matched with the exact case of the file name, as LMS servers do (macOS used to find Logo.svg for logo.svg).

Unreleased — explore in any order (2026-10-06)

  • Named controls: a component button can carry a name (data-t-click="engine", or bound to a prop: one marker per item of a list), and a click event can listen to that name only (on.name). Tabs and disclosures of components fire click too, with their key as name. One block can now hold every marker of a figure. Names are checked on the rendered instance (E_EVENT_TARGET lists the names a block has); W_DEAD_BUTTON names the button left without an event.
  • Visited state: every control (buttons, tabs, disclosures) is visited once the learner has used it, a tab or disclosure once its content is shown. The player sets data-t-visited (style a check, a dimmed marker), saves it with the progress (a lock key per control; suspend data w, a few characters each, counted in the budget) and restores it on return or relaunch.
  • Visited condition: { "visited": "<block id>" } holds once every control of the block is visited, "name" for one; any screen’s block. The visit is recorded before the click’s events run, so “all visited → complete-screen” works on the last one: Storyline’s “click each item, then Next”. New check E_VISITED_TARGET.

Unreleased — click to reveal (2026-10-06)

  • Event action reveal-step ({ "type": "reveal-step", "step": 3 }): a click in the slide (a marker over a figure, a button) reveals the steps of the screen up to step, as Continue would; selected again, it brings that step back into focus. Checked against the steps of the screen (E_ACTION_TARGET). Steps that are skipped fade in too.
  • Blocks of the latest step shown carry data-t-current: the marker to select next can call for a click.
  • Continue per screen: continueRevealsSteps: false leaves the steps of a screen to its own triggers (the button reads Next and, with lockUntilComplete, waits for the last step: “Explore this screen to continue”, a new player label in the 7 languages). E_STEP_UNREACHABLE when a step is then reached by no reveal-step.
  • Resume where the learner left: a screen left part-way opens again at the step reached (Previous, outline, relaunch), saved in the suspend data (p, counted in the budget); a screen seen to the end shows every step. Before, any visited screen showed every step.
  • Answer room measured again once fonts are loaded and shortly after the screen shows (feedback-only glyphs or fonts arriving late left it short under load), and by ready() before fit checks and captures, which also wait for fonts.
  • Faster tests: the emulator runs its launches four at a time (elabora test too; parallel option); e2e files run side by side; the multi-LMS test runs Chrome’s two unload settings for one LMS only. A run that ended without onRun hung the emulator (fixed before release).
  • Example orbital-rendezvous: “Lower is faster” is built like a Storyline slide. Only marker 1 shows at first, pulsing; selecting it flies the Space Station for one hour around the Earth (its trail draws behind it), then its card appears and marker 2 starts pulsing; after 3, the rule. Course components hotspot and orbit-flight, layout split with figure: "stack", the figure generated in layers.

Unreleased — what the space sample taught (2026-10-05)

Features decided after building examples/orbital-rendezvous, and the bugs it surfaced. No format version change while prototyping.

  • Nothing moves when the learner answers (slides): every activity reserves the room of its tallest outcome (behaviours render their outcomes in a dry run, measured on hidden copies; templates opt out with data-t-reserve="off"); blocks concealed by events keep their place (data-t-concealed); layers can open as a panel over part of the screen ("presentation": "panel"), the slide staying usable. elabora fit measures the answered slide and reports what still moves (W_SLIDE_SHIFT, I_SLIDE_SHIFT_MINIMUM).
  • Captures for agents: elabora screenshot takes any number of shots in one build and one browser (--screen all, --size a,b, --state initial,answered, --out-dir), filmstrips of animations on one contact sheet (--at 0,2000,4000), --motion; MCP elabora_preview_screenshot gains state, at, motion. Full-page captures now paint free objects below the first viewport (sandboxed frames kept in process) and keep the footer at the end of the page; the preview banner is left out.
  • Measured contrast: elabora fit compares text with the pixels behind it (backdrops, images, gradients) at the reference size (W_TEXT_CONTRAST_MEASURED).
  • Backdrop (theme.backdrop): CSS layers behind every screen that follow the learner (data-screen, data-section, data-step, data-variant), variants picked per screen (backdrop, or none), and decorative free objects (pedagogicalRole: "decoration") mounted once, given the course context (ctx.context, host.onContext), still under reduced motion and frozen below 24 frames per second. Checks E_BACKDROP_OBJECT, E_BACKDROP_VARIANT, E_OBJECT_DECORATION.
  • Safe area: layouts declare the part of a free object they cover (--t-safe-top|right|bottom|left); objects get it in px (ctx.safeArea, host.onSafeArea, object/safe-area); tests set safeArea or set-safe-area.
  • Translation: one package per language; translations/<lang>.xlf (XLIFF 2.0) in the source course; elabora translate export|status|build|approve; stale units when the source changes; checks of maths, links, placeholders and schema limits; the translated course regenerates its figures; the translated plan has its own approval (translationOf). Object words: manifest strings (ICU messages) read with ctx.t(), reworded per block (props.strings, props.a11y), E_OBJECT_STRINGS, W_OBJECT_STRINGS_LANGUAGE, W_OBJECT_STRING_UNDECLARED. Player labels in fr, en, de, es, it, nl and pt with ICU plurals; W_UI_LABELS_LANGUAGE elsewhere. The proof dossier is fully worded in the course language.
  • Free objects: a loading indicator after 400 ms (machines without a GPU); boot time judged on the median of every mount of the run, over the budget a warning (W_GATE_BOOT_SLOW), over 10 s an error; elabora object test --only <name>; a full run is recorded in object.gates.json and the proof dossier reports it (passed, failed, stale, never run).
  • Fixes: XML namespace names (http://www.w3.org/2000/svg) are not external resources; a “ResizeObserver loop” warning no longer fails an object, errors after load are logged instead of “could not load”, an object answering after the 15 s timeout recovers, each error is reported once; the WebGL budget counts WebGL contexts, not canvases; consecutive expect-event steps share the events of the previous actions; x-token accepts custom tokens; theme url() may point anywhere in the course; reduced motion stops ::before/::after animations; punctuation stays on the line of inline maths; component new|fork print only their own findings; full-bleed slides are edge to edge; the contract says mount() must return fast. Test runs no longer hang on a page that stops answering: an emulator run with no progress for 90 s fails with the step it stopped at, and an object call unanswered for 60 s fails the smoke gate (the other gates are reported not run). elabora test prints each run as it ends.
  • AGENTS.md (template and examples) leads with the command line; MCP tools are named alongside.
  • Example orbital-rendezvous: space course in slides with a backdrop, a frameless 3D phasing lab using the safe area and manifest strings, a panel debrief, answered slides that fit 1600×900 and 1280×720, and a French translation in translations/fr.xlf.

Unreleased — size classes, slides that fit, steps (2026-10-05)

  • Size classes: sizes in rem, and 1rem follows the window. Reference 1600×900 (1rem = the learner’s default, 16 px); medium ≥ 1360×760 (15 px); compact, such as 1280×720 (14 px); phones and portrait tablets (15 px). The player, the free-object UI kit and the sample are in rem; contentWidth and wideWidth (px at the reference size) are emitted in rem. Text fields never go under 16 px. Free-object frames receive the page’s 1rem (rootSize at initialize, object/set-root-size when the window changes class). New warning W_STYLE_FIXED_FONT_SIZE for px font sizes in course and component stylesheets.
  • Slides that fit: presentation.fit (reference 1600x900, minimum 1280x720 by default). elabora fit (MCP elabora_check_fit) measures every screen with all its steps in Chrome: overflow in px and the blocks below the fold. elabora test runs it for slides (W_SLIDE_OVERFLOW at the reference size, I_SLIDE_OVERFLOW_MINIMUM at the minimum) and the proof dossier reports it. Slide padding reduced.
  • Steps: step on the blocks of a screen; Next becomes Continue while steps remain. Pending steps take no room in scroll presentation and keep their place in slides; revealed blocks take focus and fade in. With lockUntilComplete, the activities of the revealed steps come first. A screen counts as viewed at its last step; visited screens show every step. Checks W_STEP_ORDER (scroll presentation) and W_STEP_PLACEMENT. elabora screenshot --step, MCP step.
  • Screenshots default to 1600×900 and run with reduced motion. aspectRatio: "fill" objects now get the flexed height of their slot.
  • Example planetary-gearset reworked as slides that fit 1600×900: a compact hero with the goals and the three ratios revealed as a second step; anatomy and the worked example split along the plan items (9 screens); the 3D bench fills the stage, its debrief table opens in a layer; the hoist decision sits next to its figure; one assessment question per slide, the configuration question as four cards in a row. Fixed: the hero figure collapsed to zero width on phones.

Unreleased — components (2026-10-05)

One model for every block type; no format version change while prototyping.

  • Components: components/<id>/ (component.json, template.html, component.css, migrations.mjs) define block types and screen layouts. Props schema with x-kind (text, markdown, tex, asset, token), named slots holding blocks (blocks on instances), named styles, examples. Templates rendered at build time ({{prop}}, data-if, data-each, data-slot, data-title, {{@id}}), parsed with parse5.
  • Built-in components: the native catalogue is rendered from built-in templates (src/components/builtin/), except video, audio and free objects (code).
  • Headless behaviours: forks of quiz, numeric, decision and results keep scoring, saved state (same key), SCORM records, focus and announcements in the player; templates provide data-t-… hooks. Primitives for any template: click events, disclosure, tabs, player labels.
  • Forks (extends, propsExtend), fallback to native blocks (declared or generated; used by LESSON.md export, the client view and detach), duplicate and detach of instances, moves into and out of slots.
  • Libraries: elabora component add|publish with pinned copies (origin and hash), explicit upgrades, local edits reported.
  • Migrations: elabora component migrate runs migrations.mjs steps on every instance, all or nothing; versions recorded in elabora.lock.json.
  • Layouts are screen-level components (layouts/ is gone; layoutProps); cascade layer elabora.components replaces elabora.layout.
  • Checks: 21 component codes (templates, hooks, slots, props, fallback, migrations, library copies, usage); heading order through templates and slots; plan references inside component props; quiz forks may add presentation fields to plan question options.
  • CLI elabora component list|new|fork|preview|migrate|add|publish, elabora block duplicate|detach; MCP: elabora_list_components (replaces elabora_list_block_types), elabora_get_component, elabora_create_component, elabora_fork_component, elabora_write_component_file, elabora_duplicate_block, elabora_detach_block, elabora_migrate_components, elabora_add_library_component, elabora_publish_component, elabora_preview_component (42 tools; 43 with elabora_check_fit).
  • Player: the course outline floats over the screen under the header instead of pushing it down; it closes on Escape, a click outside or when focus leaves it. No scroll bounce, pull-to-refresh or scroll chaining into the LMS page (overscroll-behavior: none).
  • Example planetary-gearset: layouts as screen components, a ratio-card component replacing a CSS convention, a config-quiz fork of quiz with generated configuration diagrams for plan question Q-3.

Unreleased — design layer prototype (2026-10-05)

Agents design freely; content stays in blocks. No format version change while prototyping.

  • Theme: free course stylesheet (theme/theme.css, cascade layer elabora.theme), embedded fonts (also sent to free objects), custom tokens exposed as --t-* variables and bindable with x-token, named styles per block type or screen (style, data-style, t-s-<name>), presentation (scroll / slides, content widths).
  • Layouts: layouts/<id>/ templates with named slots (layout, slot), full-bleed bands, reading order from the template; checked for forbidden elements, text, undeclared slots, accepted block types.
  • Maths: $…$ / $$…$$ in Markdown and a math block, LaTeX rendered to MathML at build time with temml (E_MATH_PARSE).
  • Numeric answers: numeric block and numeric plan questions (tolerance, unit, targeted feedback per misconception, locale-aware input, numeric SCORM interactions, suspend-data budget).
  • Responsive free objects: aspectRatio auto / fill and more ratios, narrowAspectRatio, minHeight / maxHeight, host.requestHeight(), theme variables, fonts and a UI kit inside frames, test frame size.
  • Asset generators: generators/*.mjs, elabora assets (MCP elabora_build_assets), provenance with stale / hand-edited detection, listed in the build report.
  • Checks: words in SVG figures, CSS and templates; literal colours and external URLs in stylesheets; phone-width overflow in elabora test; axe findings now name the elements (object gate 6 and emulator).
  • Fixes: no focus ring on the screen title, English labels without the French space before colons, styled tables, an object frame that never answers now shows its fallback after 15 s, elabora screenshot waits for images and objects (--width, --height, --viewport, --layer), function parameters named like globals no longer flagged by the object analyzer, props read in imported files count as used.
  • MCP: elabora_write_design_file, elabora_build_assets; layouts, slots and styles in screen and block edits; screenshots of layers and at other sizes.
  • Example examples/planetary-gearset (English): hero, split and stage layouts, theme with Inter and Space Grotesk (OFL), generated figures without words, numeric questions, typeset equations, responsive 3D test bench.

0.1.0 — 2026-10-05

First version of the open core (Stage 1).

  • Format elabora/0.1: JSON Schemas for course, plan (contract layer with approval hash and change requests), screens, 12 native blocks, events, variables, theme tokens, free-object manifest and tests, LMS profiles, diagnostics.
  • Validator with stable diagnostic codes: format, plan conformity, item writing and feedback, accessibility (alt text, headings, contrast), events, free-object static analysis, suspend-data budget per LMS profile, pedagogical waivers.
  • Deterministic SCORM 1.2 compiler with build report and proof dossier.
  • Accessible player: layers, events, scoring, status machine without regression, session end without unload, sandboxed free objects.
  • Free-object contract 0.1 with three.js r186 vendored and automated gates 2–6.
  • 11 executable LMS profiles and an emulator (iframe, popup, popup chain, nested iframe; abrupt close, resume, status, axe-core).
  • MCP server (30 tools, journaled and undoable edits), four skills, AGENTS.md template.
  • LESSON.md 1.9 export/import for simple blocks; elabora inspect for any SCORM ZIP; elabora diff for updates.