# Elabora course format — specification 0.2

**Status:** draft for implementation, 6 October 2026. **Format id:** `elabora/0.1`. **Licence of this text:** Apache-2.0. The JSON Schemas in [`packages/format/schemas/0.1.10/`](packages/format/schemas/0.1.10/) are normative; this document explains them. Where they disagree, the schemas win.

The format describes an e-learning module as plain files that people, agents and other tools can read and write. It separates three layers with different rules:

| Layer | Files | Who changes it | Guarantee |
|---|---|---|---|
| **Contract** (the plan and its versions) | `plan.json`, `plan-versions/` | The author and their agent write the plan; a person makes its versions and records the client's validation of one | A version never changes (its content hash); release builds need a validated version and list what changed since |
| **Blocks** | `course.json`, `screens/*.json` | Authors and agents, freely | Every value is checked against a schema (built-in or component) |
| **Components** (templates) | `components/<id>/` | Agents and developers; authors edit instances | Templates without code, behaviour hooks, fallback to native blocks, versioned with migrations |
| **Free objects** (governed code) | `objects/<id>/` | Agents, on explicit request; authors edit declared props only | Manifest + tests + sandbox, replayed at every change |

## 1. Project layout

```
course.json                 course settings, sections and screen order, variables, theme, tracking
plan.json                   the plan, as it is now: objectives, ordered items, validated texts, questions, sources
screens/<id>.json           one file per screen
objects/<id>/               free objects: object.manifest.json, object.js, object.tests.json, object.notes.md, object.gates.json (last gate run)
components/<id>/            course components (block types and screen layouts): component.json, template.html, component.css, migrations.mjs
theme/                      course stylesheet and embedded fonts (course.json theme)
generators/                 asset generators (*.mjs) and provenance.json of the assets they produced
assets/                     media files referenced by blocks (portable ASCII names); the audio of narration lines in assets/narration/<language>/<screen>/ (§4.7)
plan-versions/V<n>.json     the plan's versions (V1, V2…): frozen copies, their sends and the client's validation
plan-versions/notes.json    the author's notes for the client on changes in no version yet
translations/<lang>.xlf     translations of the course (XLIFF 2.0) and <lang>.approval.json, approval of the translated plan
elabora.lock.json             permanent short keys of stateful elements and component versions (generated; keep under version control)
AGENTS.md                   rules for agents working on this course
sources/                    the author's files the course is made from, and their readable versions (<file>.md, <file>.files/): not part of the course
```

Tools keep their own data beside these files, in `.elabora/` by default: the journal of changes, the backup versions, the agents' activity and the transcripts of Elabora's chat ([storage](docs/architecture/storage.md#machine-local-data)). It is machine-local, never part of the course: in Elabora's chat, everything the agent writes during one turn is one batch of the journal, undone at once, and the tools refused to it (a file edit, a shell) are kept with their target, never any content.

`sources/` is reserved: what it holds is never validated, built or published, and a course file that points into it is an error (`E_SOURCE_REFERENCED`). Tools may keep a readable version of each source beside it (`<file>.md`, whose first line is an `<!-- elabora:readable {…} -->` marker, and `<file>.files/`).

All JSON files are UTF-8, and may carry `"$schema"` pointing to `https://elabora.studio/schemas/0.1.10/<name>.schema.json`. Tools write them with two-space indentation and a final newline.

## 2. Identifiers

| Kind | Pattern | Example | Scope |
|---|---|---|---|
| Course elements (sections, screens, layers, blocks, events, objects) | `^[a-z][a-z0-9-]{0,47}$` | `evaluation-q1` | Unique in the whole course |
| Plan elements (objectives, items, texts, questions, sources) | `^[A-Z][A-Z0-9_-]{0,31}$` | `OBJ-2`, `ITEM-5`, `Q-12` | Unique in the plan |
| Variables | `^[a-z][a-zA-Z0-9_]{0,31}$` | `bonsReflexes` | Unique in the course |
| Options (quiz, decision) | `^[a-z0-9]{1,8}$` | `a`, `b` | Unique in their block |
| Dialogue nodes, endings and their variables | `^[a-z][a-z0-9-]{0,47}$` (variables: as course variables) | `premiere-question` | Unique in their dialogue; a dialogue's options are unique in their node |
| Narration and dialogue lines | `^[a-z][a-z0-9-]{0,47}$` | `l2`, `m1` | Unique in their screen, across its narration, its layers and its dialogues |

Ids are permanent: learner progress is keyed by id (§9), and tools never give an element another id on their own. Renaming is one explicit command, `elabora rename <old> <new>` (MCP `elabora_rename`), for sections, screens, layers, blocks and events: every reference follows (sections, events, translation unit ids, stylesheet selectors on `data-section`, `data-screen`, `data-layer` and `data-block`), a screen file takes the new name, and `elabora.lock.json` keeps the element's key under its new id. Waivers of the plan that name the id follow it. It is refused for an id already in use or one that does not match the pattern. An id is never reused for something else.

## 3. Contract layer: `plan.json` and its versions

The plan is the storyboard the client validates. `plan.json` is always the plan as it is now (the working copy); its versions are frozen copies of it in `plan-versions/` (D52). It holds:

- `objectives[]`: observable statements (`statement`), a `short` name, Bloom level, success criterion, sources;
- `items[]`: the ordered storyboard. Each item has a `kind` (`intro`, `exposition`, `demonstration`, `practice`, `scenario`, `assessment`, `summary`), a `part`, the objectives it serves, optional `lockedTexts[]` (validated texts, `verbatim: true` for one the client wants « à l'identique »), `questions[]` (validated assessment items with options, correctness and feedback) and `voiceover[]` (the item's voice-over, line by line: `speaker`, `text`; translated with the plan, not kept word for word in the module; written by `elabora plan voiceover`; a screen covering the item takes it as its narration script, `elabora narration from-plan`);
- `sources[]`, `businessGoal`, `audience`, `duration` (the learner time asked for, in minutes: `min`, `max`), `spacing[]` (retrieval planned after the module), `waivers[]` (approved exceptions to pedagogical warnings).
- before its validation (D51): `decided[]`, what the agent decided by itself because the author left it open (`topic`, `choice`, `why`, `items`), and `question`, the one question it asks the author now (`text`, `topic`, `items`). Validating a version removes both, and no version holds them: they are never part of the contract.

Validated questions are choice questions (`mode` `single` or `multiple`, with `options`) or numeric questions (`mode: "numeric"`, with `answer`, `tolerance`, `unit`, `feedback` and `misconceptions`), reused by quiz and numeric blocks respectively.

**Writing the plan.** The author and their agent write `plan.json` at any time, before and after a validation: `elabora plan write --from <plan.json> | --patch <patch.json>` (MCP `elabora_write_plan`) checks it against its schema first, and the screens of items moved follow them (D50). A validated text is reworded in the plan (`elabora plan text`), and every block that shows it by `textRef` follows. An agent's write that changes a text kept `verbatim` (its words, its flag, or the text removed) is refused (`E_PLAN_VERBATIM_LOCKED`): only the author changes it.

**Versions.** `plan-versions/V<n>.json` (schema `plan-version`) holds `version` (its number, the file's name), `date`, an optional `note`, `contentHash` = SHA-256 of the canonical JSON (sorted keys, no whitespace) of `plan` without `$schema`, `decided` and `question`, and `plan`, the plan as it was: these never change (a version that does not hold together is `E_PLAN_VERSION_INVALID`, and does not count). The days it was exported for the client are added to `sends[]` (`date`, `format`), and the client's validation to `validation` (`by`, `date`, `reference`). A person makes a version (`elabora plan version`) and records a validation (`elabora plan approve --version Vn`, the latest by default), never an agent (`E_PLAN_PERSON_ONLY`). Exporting the plan's document shows a version (the latest by default) or the working copy, records the send on the version, and never makes one.

**State.** The plan's state follows from its versions: without a validated version it is a draft (`W_PLAN_DRAFT`; release builds need one: `E_PLAN_NOT_APPROVED`); when the working copy is the latest validated version it is approved; otherwise it changed since (`W_PLAN_MODIFIED_SINCE_VALIDATION`, a warning: a release build lists the changes in its report and proof dossier). A text kept `verbatim` in the validated version that changed since is flagged (`W_PLAN_VERBATIM_CHANGED`). What changed is computed, never written: `planDiff` matches the elements of two plans by their ids (added, removed, moved, an item's part changed, each field, texts word by word), from the latest validated version (the app's marks) or from the latest version (what is in no version yet); `elabora plan diff`.

**Notes for the client.** `plan-versions/notes.json` (schema `plan-notes`) holds the author's notes on changes in no version yet, one per change (`target`: its key in the diff, `item:ITEM-3`; `text`). The next version takes those whose change it holds and freezes them in its `notes[]`. The document shows them in its margin and Word as comments; packages never carry them.

**Translated plans.** A translated course (§13) has its own plan, generated from the source plan; its `translationOf` records the source plan's id, content hash and language. It is approved on its own (`elabora translate approve`): the translated course holds the approval as its validated version V1 while the source plan and its translation keep their content.

**Coverage rules.** Every screen `covers` at least one plan item; every plan item is covered by at least one screen (`E_PLAN_ITEM_UNCOVERED`). A validated text is used through `textRef` on a screen covering its item; a validated question through `questionRef`. Copies are not accepted as uses.

## 4. Native layer: `course.json` and screens

### 4.1 `course.json`
`format`, `id`, `title`, `language` (BCP 47), `version` (semver), `sections[]` (each lists its screen ids in learner order), `variables[]`, `speakers[]` (the voices of the narration, §4.7, and the characters of dialogues, §4.9), `theme` (charter `tokens` checked for WCAG 2.2 AA contrast, with the player's `player.*` and the dialogues' `dialog.*`, `custom` tokens, free `stylesheet`, embedded `fonts` (one entry per file: `family`, `src`, `weight`, `style`, and `unicodeRange` for a face split into subset files, latin and latin-ext), named `styles` (each `{ "label", "description" }`, the name authors pick it by and what it is for, or the description alone), `backdrop` (its `variants` written the same way); see [`docs/design.md`](docs/design.md)), `presentation` (`scroll` or `slides`, content widths in px at the reference window, `fit` sizes for slides: `reference` 1600x900 and `minimum` 1280x720 by default; see [`docs/design.md`](docs/design.md) §2), `navigation` (`linear`/`free`, `lockUntilComplete`), `player` (the learner's player: two bars of items, §4.8), `resources[]`, `glossary[]` and `credits[]` (what the player's Resources, Glossary and « IA et crédits » show, §4.8), `sounds` (interface sounds, music and ambiences, §4.12), `completion` (`reportAs`, `masteryScore`), `tracking`, `delivery` (`mediaQuality`: `high`, the default, ships the media masters; `medium` and `low` ship renditions encoded from them for LMS size limits; the presets are in [`docs/architecture.md`](docs/architecture.md), *Media*), `ui` (label overrides).

### 4.2 Screens
A screen has `id` (equal to its file name), `title`, `covers`, `blocks[]` in reading order, optional `layout` (a built-in screen layout, `opening` (D63), or a screen-level course component: a template with named slots; blocks choose a `slot`) with its `layoutProps`, `style` (named style of the theme), `layers[]` (overlays opened by events, like Storyline layers: `id`, `title`, `presentation` `dialog` or `panel`, `blocks[]`, and an optional `narration`, which gives the layer a voice and a timeline of its own, §4.7) and `events[]`. `completeWhen` is `viewed` (default), `interactions-done` (every quiz, fill-in, sort and decision answered, every reflection saved, every dialogue's first run ended, every free object completed; blocks with `optional: true` aside) or `action` (only by a `complete-screen` action). `continueRevealsSteps: false` leaves the steps of the screen to its events (`reveal-step`): Continue no longer reveals them. `steps` says how each step opens, keyed by step number: `opens` `continue` (default: a Continue button in the page in scroll presentation, like the continue dividers of Rise; Next relabelled Continue in slides) `answered` (by itself once every activity of the steps above is answered, right or wrong, like content after a knowledge check) or `explored` (Continue, or Next in slides, is shown but locked until every activity of the steps above is answered and every control of their blocks is visited, optional blocks aside; locked, it says what is left), and `label`, the text of its Continue button. Screens with `required: false` do not count for completion. `narration` gives the screen a voice and a timeline (§4.7).

### 4.3 Blocks
`{ "id", "type", "props", "blocks"?, "hidden"?, "step"?, "width"?, "slot"?, "style"?, "optional"?, "continuity"?, "note"? }`. `step` (blocks of a screen) holds the block back until the learner presses Continue or an event reveals it (`reveal-step`): in scroll presentation pending steps take no room, in slides they keep their place. `type` is a built-in type (below) or the id of a course component (§4.6). `width` is a layout hint in the default column (`full`, `wide`, `half`, `bleed`); two consecutive `half` blocks share a row on wide screens; `bleed` goes edge to edge of the window in scroll presentation and of the slide in slides, its text keeping the reading width (in a layout's slot it fills the slot), and a template asks for it from its root with `data-t-bleed`. `slot` places the block in the screen layout, or in a slot of the component instance holding it in `blocks`; `style` picks a named style. `optional: true` marks extra the learner may skip (a transcript, more detail): its activities and controls count neither for a step that opens once explored nor for `completeWhen: "interactions-done"`. `continuity: { "key", "duration"? }` makes blocks of different screens one thing to the learner (D65): going from one of those screens to another, a free object keeps running and moves to the arriving block's props over `duration` ms (default 700; [free-object contract §4e](docs/free-object-contract.md#4e-continuity-between-screens)), another block glides from where its counterpart was, nothing else moves, and less motion makes it a cut. Blocks sharing a key have the same type (free objects the same object), once per screen, never in a layer; a chain of free objects saves one state. Built-in catalogue 0.2:

| Type | Purpose | Key props |
|---|---|---|
| `heading` | Section heading (levels 2–4; the screen title is level 1) | `text` or `textRef`, `level` |
| `text` | Markdown subset | `markdown` or `textRef` |
| `callout` | Key rule, tip, warning | `variant`, `title`, `markdown` or `textRef` |
| `image` | Image from `assets/` | `asset`, `alt` or `decorative`, `caption`, `generated` |
| `video` | Video played by the learner (captions when it has sound, transcript), a muted loop like an animated image (`loop`, with `alt`), or decorative | `asset`, `title`, `captions` (WebVTT), `transcript`, `loop`, `alt`, `decorative`, `poster`, `generated` |
| `audio` | Audio with transcript, unless decorative | `asset`, `title`, `transcript`, `decorative`, `generated` |
| `button` | Triggers `click` events | `label`, `variant` |
| `accordion` | Panels (« Panneaux »): optional or parallel detail the learner opens (not practice) | `presentation` (`accordion`, `tabs`, `cards`, `stack`), `items[]` (`id`, `title`, `markdown`, `image`, `alt`, `focus`), `prompt`, `allowMultiple`, `hint` |
| `math` | Displayed equation (LaTeX → MathML at build time) | `tex`, `caption`, `label` |
| `quiz` | Single/multiple choice, elaborated feedback; options as text, as picture cards (`presentation: "images"`) or as zones on one picture (`"image"`, §4.10) | `questionRef`, or `stem` + `mode` + `options[]`; `scored`, `weight`, `attempts`; `presentation`, `image`, `alt`, options' `image`, `alt`, `x`, `y` |
| `numeric` | Numeric answer within a tolerance, targeted feedback per misconception | `questionRef`, or `stem` + `answer` + `tolerance` + `unit` + `feedback` + `misconceptions[]`; `scored`, `weight`, `attempts` |
| `fill-in` | Text with blanks, typed, chosen from menus or placed from a bank of words (§4.10) | `markdown` with blanks `[[answer\|other accepted]]`, `presentation`, `stem`, `distractors`, `ignoreAccents`, `feedback`; `scored`, `weight`, `attempts`, `showCorrectAnswer` |
| `sort` | Items placed in pairs, into categories or in order (§4.10) | `presentation`, `stem`, `items[]` (`id`, `text`, `image`, `alt`, `target`), `targets[]`, `feedback`; `scored`, `weight`, `attempts`, `showCorrectAnswer` |
| `reflection` | The learner's own words, or a point on a scale; never scored (§4.10) | `prompt`, `presentation` (`text`, `scale`), `maxLength`, `rows`, `model`, `download`, `scale` |
| `decision` | Scenario choice with consequences | `prompt`, `options[]`, `scored`, `allowChange` |
| `dialogue` | Branching conversation held in the block (§4.9) | `title`, `learner`, `mode`, `scoring`, `nodes[]` (`lines[]`, `options[]`, `next`, `end`), `endings[]`, `vars[]` |
| `free-object` | Places a free object | `object`, `title`, `props` (declared values), `textAlternative`, `a11y` (`description` and `keyboard`, what screen readers say, worded for this block), `strings`, `aspectRatio`, `narrowAspectRatio`, `minHeight`, `maxHeight`, `scored`, `weight` |
| `results` | Score and status | `allowRetry`, messages |
| `statement` | The one idea of a page to remember (« Énoncé »); with an author, a quotation | `presentation` (`statement`, `ruled`, `quote`, `image`), `markdown` or `textRef`, `author`, `role`, `portrait`, `image`, `alt`, `decorative`, `focus`, `align` |
| `divider` | A pause between the parts of a long page (« Séparateur »), decoration | `presentation` (`line`, `ornament`, `number`, `space`), `size`, `restart` |
| `gallery` | Images read together (« Galerie ») | `presentation` (`grid`, `mosaic`, `carousel`, `compare`), `images[]` (`id`, `asset`, `alt`, `caption`, `focus`, `decorative`), `columns`, `ratio`, `caption`, `zoom` |
| `columns` | Two or three columns of blocks (« Colonnes »), stacked on narrow screens | `count`, `widths`, `align`; slots `first`, `second`, `third` |
| `speech` | A character speaking to the learner in bubbles beside its portrait (« Personnage ») | `speaker` (a speaker of `course.json`), `lines[]` (`id`, `markdown`), `side` (`start`, `end`), `kind` (`speech`, `thought`) |
| `steps` | A process or a story in order (« Étapes »; a block, not the screen's `steps`), §4.11 | `presentation` (`carousel`, `timeline`, `scroll`), `items[]` (`id`, `title`, `markdown`, `image`, `alt`, `decorative`, `date`, `region`), `intro`, `summary`, `numbered`, `figure` (`replace`, `build`, `focus`), `figureImage`, `figureAlt`, `side` |
| `hotspots` | Markers on one picture, each opening a text (« Image interactive »), §4.11 | `presentation` (`popover`, `side`, `below`, `tour`), `image`, `alt`, `decorative`, `focus`, `items[]` (`id`, `x`, `y`, `title`, `markdown`, `image`, `alt`), `markerStyle` (`number`, `icon`), `icon`, `prompt` |

`image` also takes a `presentation` (`centered`, the default, `wide`, `band`, `beside`, `over`) with `focus`, `title` and `markdown` (beside, over), `side` and `share` (beside), `height` (band, over), `zoom` (centered, wide, beside: the image opens in the player's lightbox, as a gallery's images do with `zoom`); `text` takes an `aside`, a note in the margin. **Focal point**: `focus: { "x": 0..1, "y": 0..1 }` on an image becomes its `object-position`, so crops keep the subject. Blocks with several forms name them with `presentation`, its first value the default (D63).

**Panels, characters, the gallery's carousel (D63).** An `accordion`'s items are the same in its four presentations, and each is a control named by its `id` in every one (a panel opened, a tab shown, a card turned over, a card of the stack seen), so its visited state survives a change of presentation: `accordion` (native disclosures, `allowMultiple: false` one open at a time), `tabs` (the room of the tallest panel kept; in containers narrower than 560 px the same items as an accordion, the one rendered being the one in the accessibility tree), `cards` (flip cards in a grid whose cards are as tall as the tallest, the front the title and the image, the back the text; two to a row on phones) and `stack` (the flip cards one at a time with Previous, Next and the position; a stack opens on its first card). An item's image needs `alt`, `""` when decorative. A `speech` block's speaker gives its name, colour and look (a character's SVG framed on its face and at rest, a portrait picture, else its initial); each line reads « Name: text » to screen readers, the written name and the portrait hidden from them. A `gallery` `carousel` shows one image whole in a frame of `ratio`, its caption, Previous, Next, a thumbnail per image (wide containers) and the position, in the room of the tallest. With `zoom`, the images of a gallery (but `compare`) and an image open in the lightbox, with previous and next between a gallery's. Checks: `W_ITEMS_MANY` (tabs over 7, cards over 12, a gallery's carousel over 5), `W_TABS_LABEL_LONG` (over 24 characters), `E_SPEECH_SPEAKER`.

**Converting a block (« Transformer en… », D63).** A block becomes another presentation of its type, or another type that holds the same content, in place: panels, steps, an interactive picture and a gallery (when its items have pictures) into one another, panels into a sort in pairs, steps into a sort in order or the charts `process` and `milestones`, `statement`, `callout` and `text` into one another, an image and a gallery of one image, the bulleted or numbered list of a text into panels or steps. The block's id and its items' ids are kept, and items are never reordered (a sort's placements are saved by index), so visited controls keep their names and translation units whose text moves to another prop follow it; what a conversion keeps, loses (learner progress included: a panel's visited mark has no place in a sort) and leaves to write is said before anything is converted, and what does not fit the target (too many items, a text too long, a picture missing) is refused with its reason (`E_CONVERT_REFUSED`). The command is `convert_block` (CLI `elabora convert`, MCP `elabora_convert_block`, the engine's `conversions` request); for the lock it is a content update like any other.

**Screen layouts.** A screen's `layout` names a built-in layout (`layouts/<id>.schema.json` holds its props) or a screen-level course component. Built-in 0.2: `opening` (« Ouverture »), the head of a lesson then its page, `presentation` `title`, `band`, `split` or `cover`, props `lead`, `image`, `alt`, `decorative`, `focus`, `duration` (the page's reading time, computed by the build), slots `question` (a question before the lesson) and `body` (default). Course components may not take a built-in layout's name; they may fork one.

**Markdown subset.** CommonMark without raw HTML and without images (use image blocks), plus tables and maths: `$…$` inline and `$$…$$` display, in LaTeX, rendered as MathML at build time (no script at runtime; read by screen readers). Write a literal dollar as `\$`. Links to external sites open in a new tab. Text marked `x-translatable` in the schemas is what translation tools extract. Editors lay a block's settings out from its schema: `x-group` names the section a setting goes in (`question`, `options`, `scoring`, `data`, `figure`, `markers`; without one, its content), `x-advanced` marks one authors rarely change.

**Generated media.** Images, video and audio produced by an AI model carry `generated: true`; the player marks them, its « IA et crédits » names them, and the proof dossier lists them (AI Act, art. 50).

**Glossary links.** A Markdown link to `glossary:<id>` (`[passerelle](glossary:gateway)`) names an entry of `course.json` `glossary`: the player shows its definition in a tooltip when the word is hovered or focused, and opens the Glossary there when it is followed. An id the glossary does not have is `E_GLOSSARY_TERM_UNKNOWN`.

### 4.4 Events
`{ id, description?, on, if?, do, else? }` — when the trigger fires and every condition holds, run `do`; otherwise `else`. `do` may be empty: an event being written, its trigger chosen before its actions, does nothing until it has one (`W_EVENT_NO_ACTION` while `else` has none either).

- Triggers: `screen.enter`; `click` (a control of the block: a button block, or a button, tab, disclosure, sequence item, flip card, details or element brought into view of a component; `name` filter for one control); `quiz.submitted` (quiz, numeric, fill-in or sort block, `result` filter: a fill-in or a sort is correct when all its blanks or items are); `decision.selected` (decision, `option` filter); `object.event` (free-object block, `name`, `where` payload filter); `variable.changed`; `layer.closed`; `timeline.reached` (`at`, an anchor (§4.7): playback of the screen's timeline crosses the start or end of a line, a word of it, seconds from a step's start on a screen without narration; never on a seek, again when the learner replays the passage; a line of a layer's narration names that layer's timeline, and `layer` with `at.seconds` counts from the opening of a layer without narration); `timeline.ended` (playback reaches the end of the timeline; with `layer`, the end of that layer's timeline: `hide-layer` there closes a layer once it has spoken); `media.ended` (an audio block, or a video that is not a loop, plays to its end); `dialogue.started`, `dialogue.node` (`node`), `dialogue.line` (`line`), `dialogue.chosen` (`node`, `option`), `dialogue.ended` (`ending`) for a dialogue block (§4.9).
- Conditions, all required: `{ variable, op: eq|neq|gt|gte|lt|lte, value }`, or `{ visited, name? }`: the learner has used every control of a block of the course (or the control `name`); or `{ dialogue, ending | chosen | score | done }`: what the scored run of a dialogue block of the course gave. A control is visited once used; a tab or disclosure once its content is shown. The visit is recorded before the click's events run.
- Actions: `go-to` (screen id, `next`, `previous`), `show-layer`, `hide-layer` (without `layer`: the layer open on top, a dialog before a panel, the last opened first; for a Close button inside a layer; its `layer.closed` fires as for any other), `show-block`, `hide-block`, `reveal-step` (the steps of the screen up to `step`, as Continue would; a step already shown takes the focus again), `set-variable`, `increment-variable`, `complete-screen`, `set-object-props` (declared props only), `exit-course`, `pause-timeline` and `play-timeline` (the timeline of the screen; a modal layer pauses it and resumes it by itself; with `layer`, the timeline of that layer while it is open), `seek-timeline` (`to`, an anchor: the playhead goes there as a seek, firing nothing it passes; a moment in a later step reaches that step; with `layer`, or to a line of a layer's narration, that layer's timeline), `play-media` and `pause-media` (an audio or video block; playing one pauses the narration), `start-dialogue`, `goto-dialogue` (`node`) and `restart-dialogue` (a dialogue block, §4.9).

Targets of triggers and of layer/block actions belong to the same screen.

Controls are named in the component template: `data-t-click="name"` (the name may be bound to a prop, one per item of a list), `data-t-tab="k"`, `data-t-toggle="k"`, and the primitives' items of a sequence (`data-t-item="k"`), flip cards (`data-t-flip="k"`), disclosures (`<details data-t-details="k">`) and elements visited once in view (`data-t-reveal="k"`); an unnamed `data-t-click` and the `button` block have no name. Names are checked on the rendered instance (`E_EVENT_TARGET`, `E_VISITED_TARGET`); a button without a click event that hears it is `W_DEAD_BUTTON`.

### 4.5 Variables
`{ id, type: boolean|number|string|choice, default, maxLength (strings), options (choice), minimum, maximum, persist }`. Persisted variables are saved for resume and count in the suspend data budget, so strings must declare `maxLength`.

### 4.6 Components
Every type is a component: built-in types are components shipped with Elabora (block types, and the screen layout `opening`), and a course defines more in `components/<id>/` — block types (`level: "block"`) and screen layouts (`level: "screen"`). A component has a props schema (strings with `x-kind`: text, markdown, tex, asset, token), named slots, a template rendered at build time (bindings, `data-if`, `data-each`, slots, behaviour hooks, headings that follow the page with `data-t-heading`, and the player's primitives: one item at a time, flip cards, the lightbox, one part open at a time, details and elements visited once in view, D63; no script) and a stylesheet (cascade layer `elabora.components`, over the theme's rules and under its `@layer overrides` blocks). A component may **extend** another (a fork remembers its parent and inherits what it does not redefine); a fork of `quiz`, `numeric`, `decision`, `dialogue` or `results` keeps its behaviour — scoring, saved state, SCORM records, focus and announcements stay in the player — so learner state is keyed as for the built-in. Components are not scored by themselves: activity blocks can sit in their slots. Every component has a **fallback** in native blocks (declared, or generated from its props), used by exports, the client view, translation and detach. Courses copy components from libraries with their origin (pinned) and upgrade them explicitly; when a definition changes the shape of its props, `migrations.mjs` steps migrate every instance, checked before anything is written. Details: [`docs/components.md`](docs/components.md).

### 4.7 Narration and timeline
Every line a voice says is a **voice line** (`defs.schema.json` `voiceLine`): `id`, `speaker`, `text`, `delivery`, `pronunciations`, as below. The lines of a screen's narration add `step` and `pauseBefore`; those of a layer's narration add `pauseBefore`; those of a dialogue add their own fields (§4.9: a line with a speaker, and the learner's answers with `learnerVoice`, named `<node>.<option>` in the sidecar's `line`). Line ids are **unique in the screen, every track counted** (the screen's narration, each layer's, each dialogue's: `E_NARRATION_LINE_DUPLICATE`), so a line's audio, its translation unit and the cues on it are named by the screen and its id alone.

A narrated screen has a script, its `narration.lines[]` in the order they are spoken: `id` (unique in the screen), `speaker` (a speaker of `course.json` `speakers`, the first one by default), `text` (word for word what is said; translated), `step` (default 1) and `pauseBefore` (seconds of silence before the line; 0.4 by default, 0 for the first line), and for generated voices `delivery` (settings by language over those of the speaker's voice: a speed, a style) and `pronunciations` (by language, `{ text, say }`: how the voice says a word of this line). Lines are in step order. `course.json` `speakers[]` gives each voice its `id`, the `name` learners read in captions and the transcript, and `voices` by language (`provider`, `voice`, `settings`) for the tools that generate the lines; `course.json` `pronunciations` is the course's lexicon by language (each language its own list, never translated). A voice is told the line's text with the line's pronunciations, then the course's, applied to whole words, case-sensitive, the longest text first; captions and the transcript keep the text. Words in square brackets in a line's text are **stage directions** for an expressive voice (`[chuchote]`, `[rit]`, `[un temps]`, in the line's language, at most 80 characters, on one line): how to say what follows. They are part of the text (its hash, its translation, where translators keep or adapt them) but never words (anchors, word times), never shown (captions, transcript) and never touched by pronunciations; a voice that cannot play them is given the line without them (D29). A speaker's voice may carry its `name` as a voice service gives it (information only).

- **The timeline** is the lines end to end with their pauses. Each step of the screen is a segment of it, from its first line to its last (a step without lines has an empty segment); a step's first line starts at least 0.3 s after the previous segment. `narration.advance`: `learner` (default) stops playback at the end of each step until Continue plays the next one; `auto` plays the steps one after the other; `next-screen` also goes to the next screen at the end (`W_AUTO_ADVANCE`, WCAG 2.2.1); the learner can turn both back into `learner` (« M'arrêter à chaque étape »), and phones always go step by step. `narration.seek`: `seen` (default: back anywhere, forward up to the furthest point reached), `free`, `none` (no seek bar on this screen; the course's default is `seen` or `free`, and the player's controls hide the bar of every screen with `seekBar: false`, §4.8). `course.json` `narration` gives the course's defaults of both. A narrated screen has its own Continue in the screen, shown at the end of the step heard (`seen`, `none`) or at any time (`free`); at the end of a step Play does the same; Next goes to the next screen.
- **What is seen and what happens.** Seeking recomputes what is seen at that time (the blocks of the steps started, the caption) and fires nothing; timeline triggers fire when playback crosses their cue. The course state never goes back: a step reached stays reached, an answer stays answered. `completeWhen: "viewed"` means the end of the narration reached by playback.
- **Audio, one file per line and language**, next to the script by convention: `assets/narration/<language>/<screen>/<line>.m4a` (AAC in M4A, mono, at most 192 kb/s, the index first, silence at both ends trimmed to 0.1 s) and its sidecar `<line>.json` (schema `narration-audio`: `screen`, `line`, `language`, `textHash` of the line's text in that language after Unicode NFC with whitespace runs made one space and trimmed, `duration`, `provider` (`recording` for a human voice), `voice`, `generated`, optional `audioHash`, `createdAt`, `text` (the line's text the audio says), `words` (when each word of `text` is said, from the start of the file, with a `score`: what word anchors resolve to; written by forced alignment, D27) and `aligner` (what timed them), `take` (which take of a voice that says a line differently each time), `credit` (what the module's credits say of the voice; builds list them in the report and the proof dossier), `model` (information only), `mouth` (the mouth's envelope of a dialogue's characters: one letter per 1/30 s, `c` closed, `s` small, `o` open, `w` wide, measured on the audio and its words when the tools write it, and by the build when it is missing; D61), and for generated voices `spokenHash` (the text the voice was told, after the pronunciations) and `settingsHash` (the canonical JSON of the speaker's voice settings with the line's delivery over them)). A line without both files has no audio (`W_NARRATION_AUDIO_MISSING`); one whose text, audio file or speaker's voice changed since, or for a generated voice whose pronunciations or delivery settings changed, is out of date (`W_NARRATION_AUDIO_STALE`); another model is not. Tools write them (`elabora narration import`, the voice tools), never by hand.
- **Anchors** (`defs.schema.json` `anchor`) name a moment of the timeline by what the voice says, never in seconds on a narrated screen: `{ "line": "l2" }` (the line starts), `{ "line": "l2", "edge": "end" }`, `{ "line": "l2", "word": "imprimante" }` (one or a few words of the line, as written, case, punctuation and apostrophes aside; `occurrence` picks a word said more than once; French elisions such as `l'` and `qu'` are words of their own), with `offset` seconds after (or before). On a screen without narration, `{ "seconds": 1.5 }` from the start of a step (`step`, else the block's). `{ "step": 2 }` and `{ "step": 2, "edge": "end" }` name a step's stretch. A word's time comes from its line's word times (the sidecar's `words`, filled by forced alignment: `elabora narration align`, or at once when a voice makes the line or a recording is imported and the aligner of its language is installed); a word the alignment scores very low is one the voice may have swallowed (`W_NARRATION_WORD_SWALLOWED`); without them it is placed in proportion to the text (`I_TIMELINE_WORDS_ESTIMATED`). A word the line no longer says is an error (`E_TIMELINE_ANCHOR_MISSING`); editing a line's text through Elabora takes its anchors along with their words. Translations carry word anchors as markers in the line's XLIFF unit.
- **Timed blocks** (`block.timing`, D28): `in` and `out`, each `{ at, effect, duration, direction }`, say when a block comes and goes. Without `in` it shows with its step; without `out` it stays until the end. Effects: `none`, `fade` (default), `rise`, `slide`, `zoom`, `wipe`, `draw` (the strokes of its inline drawing: an image block's SVG with `inline: true`, or a component's markup; a wipe otherwise), or an effect of the theme (`course.json` `theme.effects`, its stylesheet's `@keyframes t-fx-<name>` and `t-fx-<name>-out`); `direction` (`left`, `right`, `up`, `down`) for `slide` and `wipe`. Effects move the block relative to its place, never the layout; a timed block keeps its place while it is not there, out of reach of the keyboard and of screen readers, and is recomputed when learners seek. Under reduced motion each effect is a short fade at the same time. A step's stretch lasts until its last line ends, or until an effect anchored in it ends; on a screen without narration until its last moment, or `steps[n].duration`. Timing applies to the blocks of the screen and of its components' slots, and to the blocks of a layer on the layer's own timeline (below). A **stack** (block type `stack`, « Pile ») holds contents that follow one another in the same place, the size of its largest: each content shows from its `in` until the next one's (`transition` `crossfade`, `cut` or `slide`). The validator checks the reading time of what appears (`W_TEXT_TOO_FAST`), that an activity or a control does not leave before playback stops for the learner (`E_ACTIVITY_LEAVES_UNANSWERED`), that a block does not leave before it comes (`E_TIMING_ORDER`), and movement longer than five seconds (`W_MOTION_LONG`, WCAG 2.2.2).
- **Layers' timelines** (D62). A layer with a `narration` (`lines[]` of voice lines with `pauseBefore`, no step; `seek` `seen`, `free` or `none`, the course's default otherwise), or with timed blocks or cues, has a timeline of its own: its lines end to end from its opening, in one stretch, played through. Its blocks anchor to its lines (`{ "line": "r2", "word": "poste" }`), or, in a layer without narration, to `{ "seconds": n }` from its opening; an anchor never names a line of another timeline (the screen's for a block of a layer, a layer's for a block of the screen: `E_TIMELINE_ANCHOR_TRACK`). The player starts it at 0 each time the layer opens and stops it when it closes; its compact bar (play and pause, replay, the seek bar as `seek` allows, captions) and its captions are in the layer. **Two voices never overlap**: while a layer speaks the screen's timeline is held, until the layer closes (a dialog) or until the end of its narration (a panel). The learner's Pause stops what is in the layer; seeking and reduced motion work as on a screen; the screen's transcript adds the lines of each layer under its title. Its audio lives where every line's does (`assets/narration/<language>/<screen>/<line>.m4a`). The checks of a screen's timeline apply in the layer (missing or stale audio, reading time, activities that leave while it plays on, blocks that leave before they come).
- **Packages** carry one track per narrated screen (`narration/<screen>.m4a`, the lines laid end to end, and `narration/<screen>.vtt`, its captions with the speakers' voices) and one per narrated layer (`narration/<screen>.<layer>.m4a` and `.vtt`: a dot is never in an id, so no screen, layer or language folder can take the name), and the cue times in the course data. Until every line has its audio in the package's language, the screen has no track and runs silent on times estimated from the text, with its captions. The player draws the captions and the transcript from the script; they answer F3 for the narration. The voices' credits (their sidecars' `credit`: the attribution a CC-BY voice asks for) travel in the course data and end the transcript of their screen and « IA et crédits » (§4.8).

### 4.8 The player
The learner's player around the screens (decision D58). `course.json` `player` has two bars, `top` and `bottom`, each `{ items[], position }`:
- **Items**, in order from the start of the line, each `{ id, enabled (default true), options? }`, each in one bar at most (`E_PLAYER_ITEM_DUPLICATE`): `logo` (`options.asset`, an image of `assets/`, scaled to the bar's height; `options.alt`, translated; `options.plate`, the logo on a white plate when it does not read on its bar), `title`, `spacer` (« Espace flexible »: one per bar, always on; the items before it start the line, those after it end it, and the narration's controls stretch before it does), `progress` (the share of required screens completed; in a course of two sections or more, one segment per section, as wide as its share of the screens and filled as its screens are completed, the section shown marked, each a button to its first screen, refused like Next while that screen cannot be opened; on a phone a tap opens the outline; `options.parts` false keeps the bar in one piece, not a button, `options.percent` false hides the percentage beside it, both true by default), `outline` (the drop-down table of contents; `options.label`: its button reads « Sommaire », `outline`, the default, or the title of the section shown, `section`, or of the screen shown, `screen`), `controls` (a narrated screen's bar, and the transcript of a screen without narration whose dialogue or layer speaks; `options`: `seekBar`, `captionsByDefault`, `volume`, `transcript`, `speeds` `none`, `0.75-1.5` or `0.75-2`, the default), `resources`, `glossary` (`options.linkTerms`, default true: « Relier les termes aux écrans », below), `credits` (« IA et crédits »), `display` (« Affichage »: text size in three steps, larger captions, the keyboard shortcuts, in a module with a dialogue « Texte : tout de suite », and in a module with interface sounds « Sons de l'interface », §4.12), `language` (« Langue »: the languages of a package of several, D59), `previous`, `next` and `exit` (Quit, in SCORM packages only). An item shows when it is enabled and has something to show (a resource, a glossary entry, an AI mention or a credit, the logo's file, a timed or speaking screen for the controls); a bar with nothing to show is removed and the screen takes its room. A bar left out is its default; an item in neither bar is not shown. In a bar without a spacer, the room left goes to the title or the controls, else before the first of `resources`, `glossary`, `credits`, `exit`, `previous` and `next` that follows another item.
- **Defaults**: top `logo` (disabled), `title`, `progress`, `spacer`, `resources`, `glossary`, `credits`, `exit`; bottom `outline`, `controls`, `spacer`, `previous`, `next`; `display` and `language` in neither bar (enabled, they go after the bottom and the top bar's spacer). `position` `window` (default: the bar stays in the window) or `page` (the top bar scrolls away; the bottom bar ends the page, after its content and its last step, in scroll presentation).
- **Nothing moves while learners use it.** Previous and Next unavailable stay in place, greyed; Next is as wide as its longest label (Next, Continue); a refusal (a locked Next, a Continue that waits) is said in the player's one tooltip and in the live region.
- **Its look** comes from the theme's `player.*` tokens (`player.top.background`, `player.top.text`, `player.bottom.background`, `player.bottom.text`, `player.accent`, `player.onAccent`, `player.progress`, `player.tooltip.background`, `player.tooltip.text`, `player.radius`, `player.density`), with the charter as default; every pair is checked for contrast (`E_THEME_CONTRAST`).
- **Resources** (`resources[]`: `id`, `title`, `description`, and `asset`, a file of `assets/` shipped in the package, or `url`, an `https://` address, one of the two): a PDF or an image opens in a preview over the module (the browser's own viewer for a PDF) with « Télécharger »; another file downloads; a link opens in a new tab. Formats an LMS web server may refuse are `W_RESOURCE_TYPE`; another address than `https://` is `E_RESOURCE_URL`.
- **Glossary** (`glossary[]`: `id`, `term`, `definition`, `variants[]`, `translate`: false keeps the term and its variants in every language, `source`: where the term was found, a file of `sources/` or a screen, for the author only): a panel with a search field and the entries in the alphabetical order of the course's language; texts link to entries (§4.3). « Relier les termes aux écrans » writes those links: the first time a term or a variant is in a screen's own texts, when the glossary option `linkTerms` is turned on, for a term added while it is on, and on demand; never otherwise, so a link the author removed stays removed.
- **« IA et crédits »** says what was made with AI (generated voices and the speakers who have one, generated images, videos and sounds) and the credits: `credits[]`, one line each, then those the voices ask for. Its button shows « IA » (« AI », « KI » in the player's languages) when something was made with AI, « i » otherwise. The first time a generated voice plays, its tooltip says « Voix générée par IA » for a while (AI Act, art. 50(5)).
- **Checks**: `W_PLAYER_NO_WAY_BACK` (neither the outline nor Previous), `W_PLAYER_END_UNREACHABLE` (Next hidden and the last screen out of reach of the screens' own navigation), `W_PLAYER_AI_MENTION_HIDDEN` (AI content and « IA et crédits » hidden: AI Act art. 50(4) and (5)), `W_PLAYER_CONTROLS_HIDDEN` and `W_PLAYER_TRANSCRIPT_HIDDEN` (narration without its controls or its transcript: WCAG 2.2, EN 301 549, RGAA), `W_PLAYER_EXIT_HIDDEN` (Quit hidden in an LMS package, §8), `I_PLAYER_SPEEDS_NONE`, `W_PLAYER_LOGO_CONTRAST` (an SVG logo whose colours do not stand out from its bar, without a plate). Settings that lived in `navigation` before 19 October 2026 (`showMenu`, `showProgress`, `footer`) and the course's `narration.seek: "none"` are `E_PLAYER_SETTING_MOVED`.

### 4.9 Dialogues
A `dialogue` block holds a branching conversation (D61; guide: [`docs/dialogues.md`](docs/dialogues.md)). Its `nodes[]` are moments of the conversation: `lines[]`, then a way out, in this order: `options[]` (a decision of the learner, under `prompt`), `next` (a node, or cases `{ if, go }` where the first that holds wins), `end` (`true`: the first of `endings[]` whose `if` holds; or an ending's id), else the next node of the list, else the end. A line is a narration line (`id`, `speaker`, `text`, `delivery`, `pronunciations`) with `expression`, `cues` (an anchor on its own words with a `gesture`, an `expression` or presentation actions) and `if`; without `speaker` it is a narration. An option (always the learner's: no speaker) has `id`, `text`, `label`, `quality` (`good`, `ok`, `bad`), `reply` (lines), `feedback`, `points`, `set` and `add` (the dialogue's `vars`), `do`, `next`, `end`, `retry`, `once`, `fallback`, `if` with `unavailable`; without `next` or `end` it takes its node's way out. Conditions in a dialogue: `chosen` (`node.option`), `seen` (a node), `var` (a variable of the dialogue), `variable` (of the course, read only), `score` (out of 100). Options and cues run presentation actions only (`show-block`, `hide-block`, `show-layer`, `hide-layer`, `play-media`, `pause-media`, `set-object-props`, `reveal-step`): a dialogue never writes the course's variables nor navigates.

- **The walk** is one pure implementation (`@elabora/player` `dialogue/`): enter a node and play its lines (those whose `if` holds), offer its options (greyed when retried here, hidden or greyed by `unavailable` when their `if` fails or a `once` option was taken; the `fallback` when none can be chosen, else the way out; never one taken by itself), choose (the effects before any later condition), leave (`retry`: the same options, the choice replaced; else the option's way, else the node's), end. A condition that cannot be evaluated holds. A path holds at most 60 decisions.
- **State.** The progress of a dialogue is the list of its choices; everything else is recomputed by replaying it (resume, going back, a new version: replay stops at the first choice that no longer exists). `mode` (`practice`, `assessment`, `exploration`) sets `revisit` (`none`, `retry`, `after-end`, `anytime`), `feedback` (`now`, `debrief`, `both`) and `scoring` (`none`, `quality`, `points`, `ending`); a `final` decision cannot be revisited, nor anything before it.
- **Score and tracking.** The first run to an end is the only one scored, and in each decision its first choice: `quality` is the mean of good 1, ok ½, bad 0 over the decisions met; `points` the sum over the highest sum a path can reach; `ending` the ending's `score`. A dialogue whose `scoring` is not `none` is scored with its `weight` and records one `choice` interaction per decision of its scored run.
- **Suspend data.** One string per dialogue under its key (`dialogue:<block>`): `p|r|e|f`, the current path (each option by its own key in `elabora.lock.json`, `choice:<block>#<node>.<option>`; `~` after a first choice replaced; `>node` for a jump), the scored run when it differs, and bit fields of the options explored and the endings found (`ending:<block>#<ending>`), places never reused.
- **Characters.** `course.json` `speakers[]` take `color` (a colour token), `look` (`svg`: an SVG that follows the characters' convention; or `portrait` and `expressions`, pictures by expression) and `narrator`. **The convention** ([`docs/dialogues.md`](docs/dialogues.md#characters)): the SVG's root declares `data-expressions`, `data-default-expression`, `data-gestures` (`name:seconds`) and `data-portrait` (the face's box for the round avatar); everything that moves is in `<g data-part="character">`, on which the player sets the state (`data-speaking`, `data-talking`, `data-mouth` `closed`/`small`/`open`/`wide`, `data-expression`, `data-gesture`, `data-listening`, `data-paused`, `data-reduced-motion`); a part is shown when its `data-mouth`, `data-expression` and `data-gesture` match the state (a mouth that names the expression before the generic one), `data-part="eyes-closed"` is transparent until the character's blink shows it, and every animation stops under `data-paused` and plays none under `data-reduced-motion`. Its own CSS only moves it. No `id`, no text, under 40 KB (`W_CHARACTER_CONTENT`); the group, the four generic mouths and the declarations are `W_CHARACTER_CONVENTION`. The course data carries it sanitized (D33, class `t-character`, its styles scoped per speaker) with what its root declares.
- **In the player** ([`docs/dialogues.md`](docs/dialogues.md#what-the-learner-sees)): the thread is a log whose bubbles arrive whole (character, learner, narration; trainer notes with their quality in words); the options come in a room under it that keeps its height, and the focus waits in its group « Votre réponse » once the learner answers, never before. `messaging` and `scene` (the backdrop and the cast from 761 px wide, a thread narrower); `pace`, `reveal` (`ink`, `appear`, `instant`; instant under reduced motion or « Texte : tout de suite ») and `begins` set the rhythm; a line's words, mouth and cues follow its audio's time and it ends on the audio's end; « Choisir autrement », « Reprendre d'ici », « Recommencer » and the debrief as the mode allows. In a slide the dialogue's box keeps its size and nothing moves while it plays. The theme's `dialog.*` tokens colour the bubbles, the words said and to be said and the notes, checked for contrast once the course sets one.
- **Voices.** A dialogue's lines with a speaker are voice lines (§4.7: their audio at `assets/narration/<language>/<screen>/<line>.m4a`, the voice tools, translation with their markers); with `learnerVoice`, so are the learner's answers, as the line `<node>.<option>` said by the `learner` speaker. Missing audio is said once per dialogue. A package ships each line's audio at its media quality as `narration/<screen>.<block>/<line>.m4a` (`narration/<language>/…` for another language), and the course data gives each line, and each voiced answer, its `voice` (`src`, null without audio; `duration`; `words`; `mouth`; `estimated`) and each cue its `time` in its line. Without audio, the voice is estimated from the text.

### 4.10 Activities: fill-in, sort, reflection (D63)

Three activities of the player, scored, saved, recorded and announced like a quiz; their templates hold no field: the player builds the inputs, lists and buttons in their hooks ([`docs/components.md`](docs/components.md) §3).

- **`fill-in`.** Blanks are written in its Markdown, `[[answer|other accepted]]`; the build turns each into a `<span data-t-blank="n">` of the rendered text and moves its answers into the course data. `typed`: a field per blank, accepting an answer whatever its case, the spaces around and between its words and the form of its quotes and apostrophes, its accents too with `ignoreAccents`; `menus`: a list per blank of every blank's first answer and the `distractors`; `bank`: those words to place, a word chosen then its blank (a mouse may drag). Lists and banks are shuffled the same way at every build. Scored by the share of its blanks right; after the last try the right answers show in place.
- **`sort`.** `pairs` (each item to its own target, each target holding one), `categories` (two to four targets of several items), `order` (no targets: the items written in the right order, shown in a fixed shuffled order). An item is chosen, then where it goes (WCAG 2.5.7), with the keyboard or by tapping; a mouse may drag; `order` has move up and move down buttons. Scored by the share of its items placed right (Moodle's per-pair rule); after the last try each item says where it belonged.
- **`reflection`.** `text`: a field of `maxLength` characters (500 by default), saved with a confirmation and, with `model`, an expert's answer to compare with; `scale`: a point from `scale.min` to `scale.max`. Never scored: answered once saved, not emptied by a retake. `download` offers « Mes réponses », a text file of every reflection of the course and its answer, made in the browser.
- **`quiz` on pictures.** `presentation: "images"`: a grid of picture cards, each option with its `image` and `alt`; `"image"`: the quiz's `image` with each option a zone at its `x`, `y` (0 to 1 from the top left), a marker of 44 px at least, named « Zone 1 »… for screen readers until the answer is final, then by its words. A plan question has no pictures: write the question inline, or fork the quiz.
- **Checks.** `E_FILLIN_NO_BLANK` (no blank, an empty one, more than 12), `E_SORT_TARGET` (an item to a target that does not exist, pairs not one to one, categories outside two to four, a sort without items), `W_ACTIVITY_NO_FEEDBACK` (a fill-in or a sort without feedback for wrong answers), `E_QUIZ_PICTURE` (a quiz on pictures without its pictures or zones).

### 4.11 Steps and interactive pictures (D63)

Two blocks the learner goes through, their items controls of the player's primitives ([`docs/components.md`](docs/components.md) §3): visited, saved, read by `{ "visited": … }` conditions and by a screen's step that `opens: "explored"`, whose locked Continue says what is left in their words (« Il reste 2 étapes à voir », « … événements à voir », « … repères à ouvrir »).

- **`steps` (« Étapes »).** Not the screen's `steps`, which pace a screen's blocks. `carousel` (default): one step at a time on the sequence primitive, Previous and Next, a button per step and where the learner is, the room of its tallest step kept; `intro` and `summary` are its first and last items (ids `intro`, `summary`, so no step takes them), and the intro has « Commencer ». `timeline` (« Frise »): the steps one under the other on a line, each `date` before it (above it in narrow places), each a reveal, its dot filled once seen. `scroll` (« Défilé »): native scroll, never held; the step crossing the middle of the window is current (`data-t-follow`), and on places 560 px wide or more, with motion, a figure stays in view beside the text (`side`: `end` or `start`) and follows it: `replace` (each step's `image` over the one before, a step without one keeping it), `build` (each step's `image` a transparent layer over `figureImage`, step *n* showing layers 1 to *n*), `focus` (`figureImage` with each step's `region` `{ x, y, w, h }`, 0 to 1 of the picture, outlined and the rest dimmed). Narrower, with less motion, without the player and in slides, each step shows its own picture with it (`build`: the layers up to it; `focus`: the whole picture, its region outlined); screen readers always read those, the following figure being hidden from them. Numbers (`numbered`, default true) are decorative.
- **`hotspots` (« Image interactive »).** Markers of 44 px at `x`, `y` (0 to 1 of the whole picture, before any crop: the editor places them the same way), numbered or showing one `icon` (`plus`, `info`, `question`, `eye`), named « 2. title », never pulsing. `popover` (default): the text floats by its marker inside the picture, below it in the picture's top half and above it otherwise, scrolling inside itself rather than leaving the picture; `side` and `below`: the texts share one place beside or under the picture, the room of the longest kept, a hint while none is open; `tour`: the markers in order on the sequence primitive, the markers its buttons. In places narrower than 560 px the texts are a numbered list under the picture (a tour stays a tour), each entry counting once seen; with a `focus`, the picture is then shown half as large again, cropped at its sides around it, a marker the crop leaves out kept at its edge.
- **Checks.** `W_ITEMS_MANY` (a carousel's steps over 7, its intro and summary aside; markers over 8), `W_HOTSPOTS_CLOSE` (markers closer than 44 px on a phone 375 px wide), `W_STEPS_SCROLL_SLIDES` (a scroll in a course in slides), `E_STEPS_FIGURE` (a scroll without what its figure follows), `E_BLOCK_ITEM_DUPLICATE` (two items with one id, or a step named `intro` or `summary` beside the carousel's), and their pictures' alt (`E_IMAGE_ALT_MISSING`).
- **As text.** LESSON.md, the client view and translation carry their words: the steps under their titles (numbered, or dated), the intro and the summary, their pictures; the picture, then its markers' texts in order.

### 4.12 Sounds (D67)

- **Interface sounds** (`course.json` `sounds.interface`): short sounds the player makes after a learner's gesture, the same in dialogues and questions: `select` (a choice), `good`, `ok`, `bad` (a feedback; a question answered is `good` or `bad`), `back` (going back, starting over, replaying), `open` and `close` (a history or a debrief), `end` (a dialogue that ends well). `when`: `auto` (default: on when the module has sound — a voice line whose audio is there, an audio or video block, a sound of the story, music or an ambience), `on`, `off`; `volume` (0 to 1, 0.5 by default, under the player's volume); `replace`: a role's sound replaced by a sound of the course (its id), or `null` for none. Learners switch them off in « Affichage » (« Sons de l'interface »), kept for the course with the display choices. The player's own sounds ship as `elabora/sounds/<role>.m4a`, only in packages whose course has them on. A sound the course names that is not in `assets/sounds/` is `E_SOUND_MISSING`.
- **Sound files**: `assets/sounds/<id>.<ext>` (FLAC for what was lossless, the original's own bytes for what came lossy: Vorbis, Opus, AAC, MP3) and its sidecar `<id>.json` (schema `sound`: `title`, `source`, `url`, `sourceId`, `author`, `license`, `licenseUrl`, `credit`, `modified`, `trim`, `duration`, `loop`, `caption` by language, `generated`, `checked`). Packages ship each sound used as M4A (AAC), at every media quality. A sound without a readable sidecar, or under an unknown, non-commercial (NC) or no-derivatives (ND) licence, is `E_SOUND_LICENSE`; CC0, a public-domain mark, CC BY, CC BY-SA, `own` and `generated` ship.
- **Sound lines**: in a screen's or a layer's `narration.lines` and in a dialogue, `{ id, sound, caption?, volume? }` (with `step` and `pauseBefore` in a narration): the sound at its place among the lines, as long as it lasts. A narration's sound line is laid into the narration's track (no file of its own in the package); its caption goes with the captions, in italics, and in the transcript. Its id shares the screen's line ids.
- **Actions**: `play-sound` (`sound`; `volume`, 0 to 2, default 1, an ambience 0.3; `loop`: an ambience of the screen; `fade`, seconds of fade in; `fadeOut`, seconds of fade out at its end, or, for an ambience, when it stops without a fade of its own; `start` and `end`, the part of the file played, in seconds; `duck`, dB; `caption`) and `stop-sound` (`sound`, or every sound of the story; `fade`), in events, cues and a dialogue's options (presentation actions). Sounds of the story and ambiences stop at the end of their screen. Since schemas 0.1.7:
  - **A volume above 1** (up to 200 %) raises a sound recorded too quietly, no further than brings its loudest sample, over the part played, to −1 dBFS: louder would clip. A sound already that loud plays at its own level.
  - **A part** (`start`, `end`) plays a piece of a longer file, the file left whole; an ambience loops its part. `end` after `start`, both inside the file, else `E_SOUND_PART`. Fades in and out longer together than the part are shortened together.
  - **`duck`** (above 0, at most 30 dB; since 0.1.8, the music and the ambiences only) lowers the music and the ambiences while a sound played once plays: down in 0.1 s as it starts, back up in 0.5 s after it; the deepest of two at once wins. 6 lowers a little, 12 clearly, 20 much. A sound never lowers a voice (the narration, a dialogue's lines, a layer's, an audio or video block): the voice comes first. An ambience never ducks (`W_SOUND_DUCK_LOOP`).
- **Music and ambiences** (`sounds.background[]`: `sound`, `from`, `to` (default `from`), `volume` 0.25 (up to 2, as `play-sound`'s), `fade` 2 s, `start` and `end`, the part of the file looped): a sound looped with a crossfade over a range of screens in the course's order, playing on from one screen of its range to the next; ranges that do not exist, run backwards or overlap are `E_SOUND_BACKGROUND`. Music and ambiences are lowered by 20 dB while a voice or a media block plays (WCAG 1.4.7); learners switch them off in « Affichage » (« Musique et ambiances »); hidden, it is `W_SOUND_MUSIC_SWITCH_HIDDEN`.
- **Captions** of sounds are advised when a sound carries meaning no text gives, never required: no finding asks for one. They are units of translation.
- **Credits**: « IA et crédits » gives the credit line of each CC BY or BY-SA sound (the sidecar's `credit`, else one made from its title, author, licence, changes and address; hidden, `W_SOUND_CREDITS_HIDDEN`) and says when sounds were generated; `CREDITS.txt` at the root of the package lists every sound shipped with its source and licence.

## 5. Free objects

A free object is code under contract. Its manifest (`object.manifest.json`, schema `object-manifest`) declares:

- `planRefs` (objectives and items it serves; contract layer), `pedagogicalRole`;
- `props`: a JSON Schema subset; each prop has a `default` and a `description` (and a `title`); `x-*` keywords drive the generated editor (`x-group`, `x-advanced`, `x-control`, `x-token`, `x-text-on`, `x-object-id`, `x-editable-by`, `x-translatable`). A list is a prop of type `array` with `x-control: "list"` whose `items` are a simple value or an object of simple fields (each with a `type` and a `title`); values are checked item by item. Colours are props of `format: "color"`: bound to a theme token (`x-token`) when they carry the course identity, with a literal default for a material of the scene; a colour drawn as text names its background (`x-text-on`) and keeps 4.5:1;
- `objects` (parts with stable ids, clickable in the editor), `state` (schema of `getState()`), `persistState` (saved for resume, with a maximum length);
- `events` with optional `tracking` (`interaction` → `cmi.interactions` linked to an objective; `score` field; `completion`);
- `requires.three: "r186"`, `assets`, `budgets`, `a11y`, `sandbox`;
- `strings` (the object's words, ICU messages read with `ctx.t`) and `language`; blocks reword them with `props.strings`.

A `pedagogicalRole` of `decoration` makes a decorative object for the course backdrop (`theme.backdrop.object`): no plan references, no events, no saved state, no focusable element.

The code (`object.js`) is an ES module exporting `mount(container, ctx)` that returns `{ update, getState, setState, dispose }`. It runs in `<iframe sandbox="allow-scripts">` without `allow-same-origin` and under a CSP without network. It may import only `three`, two vendored addons and relative files. Full contract: [`docs/free-object-contract.md`](docs/free-object-contract.md).

### 5.1 Size and look
The `free-object` block sets the frame shape: `aspectRatio` (fixed ratios, `auto` for a height requested by the object with `host.requestHeight(px)`, `fill` for the height of a layout slot), `narrowAspectRatio` under 640 px, `minHeight`, `maxHeight`. The frame receives the course theme: `--t-*` variables, embedded fonts and a small UI kit (`t-ui-*` classes) so objects look like the course. A layout that floats its own content over the object declares the covered part with `--t-safe-top|right|bottom|left`; the object receives it in px (`ctx.safeArea`, `host.onSafeArea`) and keeps its key content out of it. On a page longer than the window, the object hears where its frame is (`ctx.view`, `host.onView`: visible, ratio, progress, entry, exit, as a CSS `view()` timeline), to appear as the learner scrolls to it.

## 5b. Design layer

The course stylesheet (`theme/`), component templates and stylesheets (`components/`) and generated figures (`generators/`) are free code for agents, bound by three rules checked by the validator: words stay in blocks and props (no text in CSS, templates or figures), packages stay offline (no external URL), colours come from tokens. Templates hold structure, decoration and bindings only; interactions come from the player's behaviours. Generated assets record their provenance and are reported when stale or edited by hand. Details and the stable DOM hooks: [`docs/design.md`](docs/design.md).

The cascade (`theme.cascade`, D32): `components-over-theme` orders the player, the course stylesheet (its defaults), the component stylesheets, then the course stylesheet's top-level `@layer overrides` blocks (what it overrides in components on purpose); without it, as written before 13 October 2026, the course stylesheet comes after the components (`theme-over-components`), and `elabora theme upgrade` changes the order without changing the look.

`theme.backdrop` declares a decorative background behind every screen: CSS layers the stylesheet paints, which follow the learner through `data-screen`, `data-section`, `data-step`, `data-variant`, `data-screen-index`, `data-section-index`, `data-section-count` and `data-direction`, and an optional decorative object that receives the same context and the room the player's bars cover; screens pick a variant or `none`, the others take `default`, and `#elabora-app[data-backdrop]` is the variant shown. With a backdrop or without, `#elabora-app` says where the screen stands (`data-screen-index`, `data-section-index`, `data-section-count`, and `--t-screen-index`, `--t-section-index`, `--t-section-count`, from 0), so a theme can vary by part. In slides, nothing moves when the learner answers: activities reserve the room of their tallest outcome, blocks shown by events keep their place, and layers can open as a `panel` over part of the screen instead of a modal `dialog`.

## 6. Delivery: SCORM 1.2

Version 0.2 compiles to **SCORM 1.2, one SCO, no sequencing**, the lowest common denominator of the documented LMSs (see [`docs/delivery-scorm12.md`](docs/delivery-scorm12.md) for the choice). The package contains `imsmanifest.xml` at the root, `index.html`, the player (`elabora/`), the compiled course (`elabora/course.js`, JavaScript rather than JSON because some servers do not serve `.json`), assets and compiled free objects. Packages are deterministic: the same sources give the same bytes.

## 7. Tracking model

- `cmi.core.lesson_status` follows `completion.reportAs` (`completed-incomplete`, `passed-incomplete`, `passed-failed`, `completed-failed`); the status never regresses (completed and passed are final; failed may only improve).
- The score (`cmi.core.score.raw`, 0–100) is sent once every scored item is decided: quizzes (correct = full weight), fill-ins and sorts (the share of their blanks or items right, times their weight), scored decisions (option with `correct`), scored dialogues (their first run to an end, §4.9), scored free objects (their score event, 0–1).
- `cmi.core.lesson_location` = the current screen's key (`elabora.lock.json`; packages built before 2026-10-07 wrote its id, which the player still reads); `cmi.core.exit` = `suspend` (until completion, then according to `tracking.exitAfterCompletion`); `cmi.core.session_time` at every commit.
- `cmi.interactions.n` for quiz submissions (`choice`), numeric answers (`numeric`, with the expected value as correct pattern), fill-ins (one per blank, `<block>.<n>`: `fill-in` with the words typed, `choice` by the word's letter for menus and words to place), sorts (`matching` as `1.a,2.c` for pairs and categories, `sequencing` as `b,a,c` for order), reflections (`fill-in`, the first 255 characters on one line, or `likert`; `neutral`), decisions, each decision of a dialogue's scored run (`choice`: its first choice, `correct`, `neutral` or `wrong` from its quality) and tracked free-object events, with the plan objective id.

## 8. Session end

Packages never rely on `unload` (Chrome and Edge stopped firing it on 22 September 2026). The player commits after every significant change, saves again on `visibilitychange: hidden`, commits and finishes on `pagehide` (when the page is not kept in the back-forward cache), shows a Quit button (the player's `exit` item, §4.8; `W_PLAYER_EXIT_HIDDEN` when it is disabled) that commits and calls `LMSFinish`, and never calls `LMSInitialize` again after `LMSFinish`.

## 9. Learner state and updates

The suspend data is a compact JSON (`v`, visited and completed screen keys, quiz answers, numeric answers, decisions, variables, free-object states, completed objects with their score, visibility overrides, the step reached on screens left part-way, the visited controls, each dialogue's progress (§4.9, under `t`), fill-in answers, sort placements and saved reflections (§4.10, their worst case at their longest: a reflection at its `maxLength`, as a persisted string variable), and in a package of several languages the language the learner chose, D59). Every stateful element has a short permanent key from `elabora.lock.json` (base 36 counter); activities are keyed by behaviour, so a fork of the quiz block keeps a quiz key and changing a block to a fork (or detaching it) keeps its learner state. Because keys never change for an id, and `elabora rename` carries an element's key to its new id, a new version of the course keeps the progress of every element that still exists (the screen a learner left is kept by its key too, so a learner resumes on a renamed screen). The compiler computes the worst-case length with the same encoder and fails (`E_SUSPEND_DATA_BUDGET`) when it exceeds the strictest target LMS.

## 10. Conformance

A **reader** accepts every file valid against the schemas. A **writer** produces files valid against the schemas and preserves unknown free-object files. A **validator** reports the diagnostics of [`docs/diagnostics.md`](docs/diagnostics.md) with their codes. A **compiler** produces a SCORM 1.2 package that behaves as §7–9 describe. The reference implementation is `@elabora/core`.

## 11. Versioning and migrations

The format follows semantic versioning on `elabora/MAJOR.MINOR`, given by `course.json` `format`. Readers reject a newer version than they know (`E_FORMAT_NEWER`: nothing past the format is read, nothing is written) and migrate older ones. Every format change ships with its migration, a pure step from one version to the next on the raw files of a project, and a fixture course in the older version.

- A course in an older format validates and builds: Elabora reads it migrated in memory and reports `I_FORMAT_OUTDATED` with what the migration changes.
- `elabora migrate [--dry-run]` writes the migration as one change after a backup version (`elabora undo` reverts it). Edits through Elabora, the desktop app when it opens a course and library imports migrate first, the same way.
- A migration changes the values it must and keeps the rest of each file as it was written (indentation, one block per line); what a new version no longer has is listed as dropped, with why nothing is lost.

**0.1** (13 October 2026): the first version, everything this document describes. Its migrations chain is empty: the first format change will bring the first step.

Planned: SCORM 2004 (per-LMS editions), cmi5, more behaviours (matching, ordering, hotspots, fill-in).

## 12. Interoperability

Simple blocks map to and from **LESSON.md 1.9** (Slate): `text`, `heading`, `callout` ↔ `note`, `image`, `accordion` (↔ `accordion`, `tabs`), `quiz` ↔ `knowledge-check`. Course components are exported through their fallback. Everything else is converted with a loss report (`elabora lesson-md export|import`). Elabora stays the superset: plan links, per-option feedback, events, layers, free objects and tracking have no LESSON.md equivalent.

Modules made with **Storyline 360** (its `.story` source or a publication) and **Rise 360** (its exports) are imported as Elabora modules (`elabora import <file> [dir]`, beta): a slide's objects are read in order and become blocks, layers, events and variables; what has no block yet falls back to a text, a picture or a note for the author, and `import-report.json` lists every loss and the choices left to the author. See [`docs/decisions.md`](docs/decisions.md) D47.

## 13. Translation

One SCORM package per language, or one package of every language that learners switch in place (`elabora build --languages all`, D59: the course's own language and each translation that is ready and approved; `elabora/course.<lang>.js` and `narration/<lang>/` beside the course's own; the language chosen kept in the learner's state). The source course keeps its translations: `translations/<lang>.xlf` (XLIFF 2.0, one unit per string, with the source text it was translated from) and `translations/<lang>.approval.json` (approval of the translated plan). Units cover every string a learner or a plan approver reads: course and section titles, label overrides (`course.ui`), speaker names, narration lines, the plan (objectives, items, locked texts, questions), screen and layer titles, block props marked `x-translatable` (component props of kind `text` and `markdown`), free-object props marked `x-translatable`, object strings and a11y texts; and the words authors read in the editor about the course's own design, never shown to learners (`editor` units): the names of its styles and backdrop variants and what they are for (`theme.style.<kind>.<name>`, `theme.backdrop.variant.<name>`, each with its `.label`), its components' names, descriptions, slot labels, styles, settings, their sections (`x-group`) and the words of their choices (`x-enum-labels`) (`component.<id>.…`, written in `components/<id>/component.json`), and the settings, groups and events of the free objects its blocks place (`object.<id>.…`, in their manifest), so that a course made in another language (a template's, D68) reads in it in the editor too; a component's `instructions`, for agents, stay as written. Editor units hold no language back: `translate status` counts them apart (`editor`), readiness, a translated build and a package in several languages do not wait for them, and the translated course keeps the source's words where none is given. Unit ids are stable and readable: `plan.text.TXT-1`, `block.<id>.options.<id>.feedback`, `block.<id>.strings.<key>`.

- `elabora translate export --to <lang>` creates or updates the XLIFF; a unit whose source changed keeps its previous translation as a starting point and is marked to translate again.
- `elabora translate status` counts translated, missing and stale units and reports translations that break the text (maths, links, placeholders, schema limits) and word anchors to place.
- Word anchors (§4.4) travel as inline markers in narration units: `<mrk id="w-imprimante" type="elabora:anchor">imprimante</mrk>` in the source; the translation puts each marker around its own words (`<mrk id="w-imprimante">printer</mrk>`), and the translated course anchors there, timed by its own voice. A build refuses a marker left out; `--allow-missing` anchors it to the start of its line.
- `elabora translate build --to <lang> --out <dir>` generates the translated course: a copy of the source (design, objects, assets, regenerated figures) with the translated strings, `language` set, `translationOf` in `course.json` and `plan.json`. It is validated, fitted and built like any course; edit the source, not the copy.
- The player has labels in fr, en, de, es, it, nl and pt (ICU plurals); in another language `course.ui` must give them (`W_UI_LABELS_LANGUAGE`).

## 14. Project archives

A course is exchanged as one file, `<course-id>.elabora`: a ZIP holding the project's files at its root (unzipped, it is the course folder), a manifest `elabora-archive.json` with the size and SHA-256 of every file, and optionally the project's history under `history/` (journal, backup versions and their contents). Its format, `elabora-archive/1`, and what an importer must check are described in [`docs/architecture/storage.md`](docs/architecture/storage.md#archive-format-elabora-archive1). `elabora export [dir] [--history] [--sources]` writes one (with `--sources`, the author's sources under `sources/`); `elabora import <archive>` reads one into the app's project library.
