Design layer (prototype)
On this page
- 1. Theme
- Backdrop
- The player
- Cascade layers
- DOM contract (stable hooks)
- Bands, pictures and ornaments (D63)
- 2. Window sizes, slides and steps
- Size classes
- Presentation: scroll or slides
- Scroll pages
- Nothing moves when the learner answers
- Steps
- 3. Layouts and components
- 4. Figures and generators
- 5. Free objects in the design
- 6. Checks on the rendered course
- Not in this prototype
Agents design freely; content stays structured. An Elabora course separates what is said and scored (blocks: schema-checked, editable, translatable) from how it looks (CSS, layout templates, generated figures: free). Authors rework content and pick among the options the agent declared; changing the design itself is an agent or developer task, like free-object code.
| Layer | Files | What the agent writes freely | What an author edits without code |
|---|---|---|---|
| Theme | course.json theme, theme/theme.css, theme/fonts/<id>/* (fonts and their licence) |
Any CSS, embedded fonts, custom tokens, named styles | Token values, the style of a block or screen (from a list) |
| Components | components/<id>/ (component.json, template.html, component.css) |
Block types and screen layouts: templates with slots, decoration, forks of built-in blocks (headless quizzes) | Props through a form, design props, slot contents; duplicate, detach |
| Figures | generators/*.mjs → assets/ |
Code that draws figures from the geometry, the tokens and the plan | Nothing in the figure; labels live in blocks next to it |
| Free objects | objects/<id>/ |
Interactive code (three.js, games) | Declared props |
The one rule: words never live in CSS, templates or figures. The validator reports them (W_STYLE_TEXT, W_COMPONENT_TEXT, W_ASSET_SVG_TEXT).
1. Theme
"theme": {
"tokens": { "color.primary": "#0F2A47", "font.heading": "\"Space Grotesk\", system-ui, sans-serif", "radius": 12 },
"custom": { "color.blueprint": "#0B2440", "shadow.card": "0 8px 24px rgba(11,36,64,.08)" },
"stylesheet": "theme/theme.css",
"fonts": [{ "family": "Space Grotesk", "src": "theme/fonts/space-grotesk/space-grotesk-latin-wght-normal.woff2", "weight": "300 700" }],
"styles": { "callout": { "ratio": "Card with a large figure" }, "screen": { "dark": "Dark background" } }
},
"presentation": { "mode": "slides", "contentWidth": 720, "wideWidth": 1160, "fit": { "reference": "1600x900", "minimum": "1280x720" } }
- Tokens (
tokens): the client charter, checked for WCAG contrast. Custom tokens (custom): any extra value. Every tokena.bis the CSS variable--t-a-b(color.blueprint→--t-color-blueprint); charter tokens also keep short names (--t-primary,--t-text,--t-font-body…). Free objects get the same variables and can bind props to any token withx-token; generators receive them too. A material colour that only a free object uses (the brass of an instrument, a wood) is a colour prop of that object with a literal default, not a custom token (docs/free-object-contract.md, Colours). - Stylesheet: free CSS in
theme/.url()points to files inside the course (copied into the package); no@import, no external URL (E_STYLE_RULE). Hard-coded hex colours are reported (W_STYLE_LITERAL_COLOR): use tokens so a charter change propagates. - Fonts: WOFF2 or WOFF files in
theme/, declared intheme.fonts(the player writes their@font-facerules and sends them to free objects) and named infont.body,font.headingor any token. Add them withelabora font add(below) rather than by hand. A face split into subset files is one entry per file, each with itsunicodeRange(CSSunicode-rangesyntax,U+0100-02BA,U+1E00-1E9F): one@font-facerule per file, and the browser downloads a file only for a page that uses one of its characters.- The vetted fonts shipped with Elabora (
@elabora/fonts, there offline, in the desktop app too): 25 families under the SIL OFL, from EB Garamond, Lora and Merriweather to Inter, Source Sans 3, Atkinson Hyperlegible Next (low vision), Poppins, JetBrains Mono and display faces (Oswald, Bebas Neue, Caveat, Cinzel, UnifrakturMaguntia).elabora font list [--category serif](MCPelabora_list_fonts) gives what each is good for, its weights, italics, subsets, size and pairings. elabora font add . "<family>" [--weights 400,700] [--styles normal,italic] [--for body,heading] [--subsets latin,latin-ext](MCPelabora_add_font, engine commandadd_font) copies its files and its licence intotheme/fonts/<id>/, declares its faces and gives its stack (fallbacks included) to the tokens, in one undoable change:bodyandheadingarefont.bodyandfont.heading, any other name a custom token (mono→font.mono, which the player uses forcode,kbd,sampandpre;display→font.display, used asvar(--t-font-display)),nonesets none; by default the role the list gives the font. A variable file covers every weight of its range;--weightspicks the files of a static family. Adding a family again replaces its faces.theme.fontsholds 24 faces at most (one per file: a variable family in two styles and two subsets takes 4).- Any other font, when nothing in the list fits (look for one: a brand font, a typeface for a period or a script): its Fontsource package (
npm pack @fontsource-variable/<id>or@fontsource/<id>, thenelabora font add . --from <the .tgz, or its folder in node_modules>: its files of the subsets, as for a vetted font, with the ranges of itsunicode.json, the weight axis of a variable font, its LICENSE;--subsetsnames any subset the package has,cyrillic,greek,vietnamese…), or WOFF2 files with their licence (elabora font add . --file a.woff2,b.woff2 --license OFL.txt [--family <name>]; weights and style are read from the files; each file is embedded whole, without range). SIL OFL and Apache allow embedding; check any other licence (a client’s web licence, an EULA). Convert TTF and OTF files to WOFF2 first. Japanese, Chinese and Korean come from Fontsource in a hundred numbered slices thattheme.fontscannot declare: take a font file. - The licence travels with the font: a licence file (
OFL.txt,LICENSE.txt,EULA.txt…) in the folder of every font file, which the build copies into the package next to it (the build report lists them,design.fontLicenses). A font file without one isW_FONT_LICENSE_MISSING. - Languages. The vetted files come in two subsets: latin (the player’s seven languages and the other Western European ones) and latin-ext (Polish, Czech, Slovak, Slovenian, Croatian, Bosnian, Hungarian, Romanian, Turkish, Azerbaijani, the Baltic languages, Maltese, Welsh, Esperanto, Māori…), which every family but UnifrakturMaguntia has.
elabora font addtakes latin, and latin-ext too when the language of the course or of one of its translations (translations/<language>.xlf) needs it;--subsetschooses. After adding a language to a course, add its fonts again. Letters that no embedded file holds (Greek, Cyrillic, Vietnamese, or latin-ext letters of a family without them) are drawn by the next fonts of the stack, andelabora font addsays so: take a font of that script from its Fontsource package (--from … --subsets cyrillic) or its files (--file).
- The vetted fonts shipped with Elabora (
- Styles (
styles): named styles per type (built-in or component) orscreen, each with the name authors pick it by and what it is for:"lead": { "label": "Lead", "description": "Large opening paragraph" }, or the description alone, the name then being the identifier, capitalised (schemas 0.1.6). The backdrop’s variants are written the same way. Both are translated with the course (unitstheme.style.<kind>.<name>and its.label,theme.backdrop.variant.<name>and its.label): give every style and variant a label, so that a course in another language does not name them in English. A block or screen picks one with"style": "rule"; the player addsdata-style="rule"and the classt-s-rule. Undeclared styles are errors. Styles of a type apply to its forks; components can declare their own. (Not the callout propvariant, which is info/tip/warning/important.) - Style or component? A style changes the look of a block whose structure fits. When the structure itself differs (a figure that is not “the first bold text”, fields of their own, a different markup for a quiz), make a component: authors then fill named fields instead of following a convention.
- Presentation:
scroll(a document per screen) orslides(each screen is one view that must fit the window; see §2).contentWidthandwideWidth(px at the reference window, emitted in rem) set--t-content-widthand--t-wide-width.
Backdrop
A decorative background behind every screen, declared in the theme:
"backdrop": {
"layers": ["nebula", "far", "near"],
"object": { "id": "starfield", "props": { "density": 0.4 } },
"variants": {
"dim": { "label": "Dim", "description": "Darker sky behind dense screens" },
"day": { "label": "Day", "description": "Light paper" }
},
"default": "day"
}
- Decoration only. It carries no information, takes no focus and is hidden from screen readers. A scene the learner uses or learns from is a free-object block, with a role, objectives and a text version.
- Layers: empty elements behind the course (
.t-backdrop-layer[data-layer="far"], at most six), painted and animated by the stylesheet (animatetransformandopacity). The backdrop element carries where the learner is,.t-backdrop[data-screen][data-section][data-step][data-variant], and where that stands in the course:data-screen-index(from 0),data-section-index(from 0),data-section-count,data-direction(forwardorback, the way the learner came). A stylesheet can thus follow the course without naming the author’s ids:.t-backdrop[data-section-index="1"]is the second part, whatever it is called. - Object: a free object with
pedagogicalRole: "decoration", mounted once for the whole session behind the course;elabora object new . <id> --decorationwrites one to start from (a 2D canvas that follows the course). It receives the same context (ctx.context,host.onContext: screen, section, step, variant,screenIndex,screenCount,sectionIndex,sectionCount,direction), and the room the player’s bars cover as its safe area (ctx.safeArea,host.onSafeArea: the top bar’s height at the top, the bottom bar’s at the bottom): draw what must be seen out of it. No events, no state, no controls; smaller budgets (30 draw calls, 60,000 triangles). Under reduced motion it renders still frames; on a machine below 24 frames per second it freezes (host.still,host.onStill; free-object contract, §4c). - Order: back to front, the layers (
z-index: 0), then the object (z-index: 1), then the course. A layer that goes over the object (a paper grain) takesz-index: 2. - Screens:
"backdrop": "dim"picks a declared variant,"backdrop": "none"hides it (the object stops).defaultis the variant of the screens that pick none: a module light throughout says it once. - Text and bars over a variant:
#elabora-app[data-backdrop]has the variant shown, so the theme gives the text and the bars their colours for it:#elabora-app[data-backdrop="night"] { --t-text: #f3efe6; --t-muted: #c9c2b4; }.E_THEME_CONTRASTchecks the tokens only:elabora fitmeasures what the variant shows. - Seeing it:
elabora lookshows each screen with its own backdrop, its object given the course’s ranks and the bars’ room, and says it (backdrop variant …, object … ready;--json:backdrop).elabora object replay . <id>replays a test of the decoration behind the course, with its bars (--screento start elsewhere; aset-contextstep goes to the screen it names). Under reduced motion (captures,fit,test) the backdrop and the bars change at once, never halfway. - Readability: text over a backdrop must keep its contrast.
elabora fitmeasures it against the pixels behind each line (W_TEXT_CONTRAST_MEASURED); fix it with a panel behind the text, a dimmer variant on that screen, or a lighter text colour. Letters painted by a gradient clipped to them (background-clip: text) are not measured: check them by eye against the lightest part of what is behind them. A grain laid over the course (z-index: 2) darkens the text’s ground further than the tokens say: keep small and muted text a little above 4.5:1, or the grain light. - Keep the root background in the stylesheet (
html { background: … }) andbodytransparent; a darkcolor-schemeon the root makes browsers paint an opaque box behind frames, so scope it to the panels that need it.
The player
The bars around the screens are course.json player (D58, SPEC.md §4.8): two bars, top and bottom, each an ordered list of items (logo, title, progress, spacer, outline, controls, resources, glossary, credits, display, language, previous, next, exit), in the window or at the end of the page. Each bar has one spacer (« Espace flexible », always on): the items before it start the line, those after it end it; move it to change where the line splits. Read and change them with elabora player . (MCP elabora_player): it lists what shows and why not, the player tokens with their values, and the findings. A file from outside the course goes into the resources with elabora player . --add-resource <file> (MCP elabora_player addResources, engine command add_resource): copied into assets/resources/, titled by the PDF’s own title or the file’s name unless a title is given.
Where the learner is belongs to the bars, never to a screen’s texts: in a course of two sections or more the progress is one segment per part, filled as its screens are completed, each a button to the part’s first screen (D58, 29 Oct 2026), and the outline’s button can name the part shown (elabora player . --set outline.label=section). The author may keep the bar in one piece (--set progress.parts=false) or hide its percentage (--set progress.percent=false). Do not write an eyebrow such as « Partie 2 » or « Leçon 3 sur 6 » over a title to say it.
Fit the player to the theme with its tokens rather than CSS; each defaults to the charter and every pair is checked for contrast (E_THEME_CONTRAST):
| Token | Default | What it colours |
|---|---|---|
player.top.background, player.top.text |
color.primary, color.onPrimary |
the top bar |
player.bottom.background, player.bottom.text |
color.surface, color.primary when it reads there (4.5:1), else color.text |
the bottom bar and the outline |
player.accent, player.onAccent |
color.primary, color.onPrimary |
Next, Play, the seek bar’s playhead, the active speed |
player.progress |
the text colour of its bar | the progress bar |
player.tooltip.background, player.tooltip.text |
color.text, color.surface |
the one tooltip |
player.radius |
999 (pills) | the bars’ buttons, chips and the progress bar (in px) |
player.density |
comfortable |
compact: bars of 48 and 60 px instead of 56 and 72 at the reference size |
Dialogues (D61) have six tokens of their own, dialog.bubble, dialog.learner, dialog.text, dialog.wordSpoken, dialog.wordUnspoken and dialog.note, each with its default from the charter, checked for contrast once the course sets one (Dialogues).
A light top bar with the text colour suits airy themes; the primary colour, a strong identity. Set player.radius to the theme’s corners when they are square (4, 8), give a logo (an image of assets/ in the item’s options.asset, with alt; options.plate: true puts it on a white plate when it does not read on the bar, which W_PLAYER_LOGO_CONTRAST says of an SVG logo), and keep the other defaults. The bars are in elabora.base; a theme may still restyle their hooks (DOM contract below) when a token does not say it, but W_STYLE_* checks apply as anywhere.
Cascade layers
The design layer is ordered in cascade layers (docs/decisions.md D32). A later layer wins whatever the specificity:
elabora.base (elabora/player.css) < elabora.theme (the course stylesheet) < elabora.components (component stylesheets, parents before forks) < elabora.overrides (the course stylesheet’s @layer overrides blocks)
- The theme’s rules are the course’s defaults.
.t-figure img { width: 100% }sizes every figure of the course, until a component says otherwise for its own figures: a component’s stylesheet is complete and travels to a library as it is. @layer overrides { … }, at the top level of the theme stylesheet, holds what the theme overrides in components on purpose: restyling a library component for this course, a layout’s title the theme wants bigger. Name the component in those rules (.t-block--<id> …,.t-layout--<id> …). Several blocks are fine; one inside@mediaor another rule is a sub-layer of the theme and does not override anything (W_STYLE_OVERRIDES_NESTED): put the@mediainside the block. A package keeps these rules inelabora/overrides.css.- Prefer what a component offers to overrides. A component that expects the theme to tune it reads a custom property with its default,
gap: var(--atelier-gap, 1.5rem), and never sets it; the theme sets--atelier-gap(anywhere: custom properties cross layers). Tokens (--t-*) are read the same way. - Who wins is measured, not guessed.
elabora lookandelabora fittake each rule of a component stylesheet and each theme rule that meet on an element and ask the page which one shows: a component’s rule overridden by a theme rule that does not name it isW_STYLE_COMPONENT_OVERRIDDEN; a theme rule that names a component but gives way to it (outside@layer overrides) isW_STYLE_THEME_UNDER_COMPONENT. - Courses written before 13 October 2026 have no
theme.cascadeand keep the order of that time,elabora.base<elabora.components<elabora.theme<elabora.overrides: their theme wins over their components, they look as they did, and the validator says so (I_THEME_CASCADE_OLD).elabora theme upgrade(MCPelabora_upgrade_theme) setstheme.cascadetocomponents-over-themewithout changing their look: the theme’s declarations of properties that some component stylesheet sets too go into an@layer overridesblock, in order, in their rules and at-rules (properties that act on one another, a shorthand and its longhands, logical and physical sides, are moved together, so the theme’s own rules keep their outcome among themselves); the others stay, under components that never set them. Then move out of that block the rules that are course-wide defaults.elabora initwritescomponents-over-theme. - The order is the statement
@layer elabora.base, elabora.theme, elabora.components, elabora.overrides;(or the older one), first inelabora/theme.cssand in the editor canvas;elabora/player.css, shared by every course, nameselabora.basealone first. A browser orders layers where their names first appear. - Until 6 October 2026 packages declared the prototype’s
elabora.base, elabora.layout, elabora.theme, soelabora.componentscame last and component stylesheets beat the theme; from 6 to 13 October the theme came last. See the development history.
DOM contract (stable hooks)
| Element | Hooks |
|---|---|
| App | #elabora-app[data-presentation][data-density] (compact with the token player.density), #elabora-app[data-screen-index][data-section-index][data-section-count] and the custom properties --t-screen-index, --t-section-index, --t-section-count (where the screen stands in the course, from 0, backdrop or not: #elabora-app[data-section-index="1"] .t-screen-title { color: var(--t-color-plate-2); }, or calc() on the number), .t-backdrop[data-screen][data-section][data-step][data-variant] with .t-backdrop-layer[data-layer] and .t-backdrop-object, #t-main.t-main[data-full-bleed] (its own stacking context, so screen z-index values stay under the bars), .t-banner |
| Player (D58) | the bars header.t-bar.t-bar--top and footer.t-bar.t-bar--bottom (`[data-bar][data-position=window |
| Dialogue (D61) | the group `.t-dialogue-root[data-t-look=messaging |
| Screen | article.t-screen[data-screen][data-section][data-layout][data-style], .t-s-<style>, .t-layout--<id> (and --<parent> for forks), title h1.t-screen-title |
| Default column | .t-blocks (grid), blocks `.t-w-full |
| Slots | [data-slot].t-slot (screen layouts and component instances), [data-empty] when no block |
| Block | .t-block.t-block--<type>[data-block][data-type][data-style][data-behavior], .t-block--<parent> and [data-component] for forks, .t-s-<style>; steps: [data-t-pending] (not revealed yet), [data-t-revealed] (revealed by Continue or reveal-step), [data-t-current] (the latest step shown); [data-t-concealed] (hidden until an event shows it); controls (buttons, tabs, disclosures): [data-t-visited] once used |
| Narrated screens (D24) | `#elabora-app[data-timeline=seen |
| Timed blocks (D28) | `.t-block[data-t-timed=before |
| Inside built-in blocks | .t-text, .t-callout.t-callout--<variant>, .t-callout-title, .t-figure, .t-math, .t-math-display, .t-quiz, .t-option, .t-numeric, .t-numeric-input, .t-decision, .t-choice, .t-object, .t-object-frame[data-ratio], .t-object-loading, .t-results, .t-accordion, .t-dialog (modal layer), .t-panel (panel layer), .t-feedback (.t-feedback-head, .t-feedback-item > .t-feedback-option, the chosen option’s text; [data-t-feedback-style="compact"] on a quiz’s group with compact feedback, where it is .t-sr-only and the head and the explanation run as one paragraph); activity states .is-selected, .is-correct, .is-wrong, [data-t-result], [data-t-final] |
.t-screen is a CSS container (container-type: inline-size): layouts adapt with @container (max-width: 820px) { … }, which also works in narrow LMS frames.
Bands, pictures and ornaments (D63)
The starter blocks (statement, divider, gallery, columns, the image’s presentations, a text’s margin note, the layout opening) are styled by the player from the theme’s tokens: they follow any charter. What a theme tunes without overriding them:
- Full bleed.
width: "bleed"on a block, ordata-t-bleedon a template’s root, takes the block edge to edge: the window’s width in scroll presentation (without its scrollbar), the slide’s in slides, the slot’s in a layout’s slot. The player keeps the width of the screen’s area in--t-page-widthon#t-main(the bleed margins arecalc((100% - var(--t-page-width)) / 2)) and#t-mainclips sideways overflow, so the page never scrolls sideways. Text inside keeps the reading width: a native block’s content is held at--t-content-width; a template pads its text withvar(--t-bleed-gutter). A band breathes a little more than a paragraph (.t-block.t-w-bleed). - Focal point.
focus: { "x": 0..1, "y": 0..1 }on an image is written as--focus-xand--focus-yon its element and read asobject-positionwherever the picture is cropped (a band, a grid, an opening, a beside image on a phone). Without it, crops keep the middle. - Scrim. Text over a picture (statement
image, imageover, openingbandandcover) sits on--t-scrim(any background: a colour with opacity, a gradient; by default a dark veil, 46 % at the top to 78 % at the foot) in--t-on-scrim(white by default). Set them in the theme stylesheet (.t-screen { --t-scrim: … }) or as custom tokensscrimandonScrim.elabora fitmeasures that text against the pixels behind it: lighten the scrim only as far asW_TEXT_CONTRAST_MEASUREDstays away, on the lightest picture of the course. Without a picture, these blocks sit on the course’s colour (color.primary, textcolor.onPrimary). - Ornament. The divider’s
ornamentis a mask painted in--t-ornament-color(defaultcolor.accent): a theme draws its own with--t-ornament: url(…)(an SVG of the course, its shape only: the colour comes from the mask), or restyles.t-divider-ornament(size, mask, background). - Bands’ heights.
--t-band-short,--t-band-medium,--t-band-tall(in rem, smaller in slides); the margin note’s width,--t-aside-width(13.5rem). - Counters. A number divider shows the screen’s counter
t-part(decimal-leading-zero), counted on the blocks;restartstarts it again. - Hooks:
.t-statement(--statement,--ruled,--quote,--image,--center,--start;.t-statement-text,.t-statement-mark(an SVG: its colour isfill,var(--t-accent)by default),.t-statement-by,.t-statement-portrait,.t-statement-author,.t-statement-role,.t-statement-scrim),.t-divider(--line,--ornament,--number,--space,--small,--medium,--large;.t-divider-ornament,.t-divider-number),.t-image(--wide,--band,--beside,--over,--start,--end,--half,--third,--two-thirds,--short,--medium,--tall;.t-image-frame,.t-image-text,.t-image-title,.t-image-overlay,.t-image-caption),.t-gallery(--grid,--mosaic,--carousel,--compare,[data-columns],[data-ratio];.t-gallery-list,.t-gallery-item,.t-gallery-caption,.t-compare,.t-compare-before,.t-compare-after,.t-compare-label,.t-compare-range,--t-compare),.t-columns(--2,--3,--equal,--wide-start,--wide-end,--start,--center;.t-column),.t-aside,.t-opening(--title,--band,--split,--cover;.t-opening-head,.t-opening-copy,.t-opening-lead,.t-opening-meta,.t-opening-media,.t-opening-question,.t-opening-body). - They move nothing by themselves: no entrance, no loop. A theme’s scroll-driven entrance applies to them as to any block (§2, Scroll-driven design).
2. Window sizes, slides and steps
Size classes
Sizes are written in rem, and 1rem follows the window. The reference window is 1600×900: there 1rem is the learner’s default font size (16 px). A design written at that size keeps its proportions in smaller windows, like a slide scaled down, except that text reflows instead of shrinking with a picture.
| Class | Window (CSS px, inside the LMS frame) | 1rem | Body text (1.0625rem) |
|---|---|---|---|
| Reference | ≥ 1500 × 840 (designed at 1600×900) | 16 px | 17 px |
| Medium | ≥ 1360 × 760 | 15 px | 16 px |
| Compact | smaller desktop windows (1280×720, 1366×650…) | 14 px | 14.9 px |
| Phone, portrait tablet | ≤ 760 px wide, or portrait up to 1100 px | 15 px | 16 px |
- Write font sizes, padding, margins and gaps in rem (px ÷ 16 at the reference size); keep borders, radii, shadows and touch targets (44 px) in px.
clamp(1.875rem, 3.6vw, 2.75rem)is fine.W_STYLE_FIXED_FONT_SIZEreports font sizes in px in course and component stylesheets. - The classes are percentages of the learner’s default size: a learner who set a larger default gets larger text everywhere. Text fields never go under 16 px (iOS would zoom on focus).
- Free objects: the host sets 1rem in the frame to 1rem of the page and updates it when the window changes class. Write the object’s own UI in rem (the UI kit already is).
- Layouts still adapt with container queries in px (
@container (max-width: 820px)): queries cannot read the page’s rem.
Presentation: scroll or slides
- scroll: a screen is a page (a lesson, as in Rise); long content scrolls. Nothing has to fit (see Scroll pages below).
- slides: a screen is one view.
presentation.fitnames the windows it must fit:reference(default1600x900; an overflow is a warning,W_SLIDE_OVERFLOW) andminimum(default1280x720; slides may scroll a little there, reported as info,I_SLIDE_OVERFLOW_MINIMUM). Below the minimum and on phones, slides read as a scrolling page. A slide is never scaled down as a picture. elabora fit(MCPelabora_check_fit) measures every screen with all its steps in a real browser at both sizes: the overflow in px and the blocks that end below the fold.elabora testruns it for slides and adds the results to the proof dossier.- Each screen’s measure is kept in the course’s
.elabora/cache/fit/with what it was made from: the screen as the player shows it (its blocks, the theme and the component stylesheets, the templates it uses, the code of the objects it places, the images and fonts it loads, the outline of the bars), the Elabora code, the browser, the system, the window size and the presentation. A screen unchanged since is not measured again: its measure is given withreused(its date).--fresh(MCPfresh) measures every screen again;--screen <id>,…(MCPscreens) measures only those. After an edit,elabora fit --screen <the screens it touched>takes seconds. - When a slide does not fit: split the screen (plan items are often already separate), put content side by side (layout slots), bound figures with
max-heightin rem, move secondary content to a layer or an accordion, give a free object"aspectRatio": "fill"so it takes the height that is left.
Scroll pages
"presentation": { "mode": "scroll" } (the default) makes each screen a page of any length, read from top to bottom like a lesson of Rise. The tools follow the mode:
elabora fitandelabora testreport no overflow and no layout shift in scroll presentation: they give the length of each page in windows and measure the contrast of its text against what is behind it down the whole page, window by window (W_TEXT_CONTRAST_MEASURED). On a page where a scroll- or view-timeline animation runs, a second pass runs with animations on, down the page by a quarter of a window, and reads the text in the middle of the window (20 to 80 % of its height) wherever such an animation applies to it: text must be readable there at least once (an entrance may still be fading in as it enters the middle), and text a fade keeps too faint is reported “as the page scrolls”. The text’s colour counts the opacity of its ancestors. Only the reference size is measured unless--reference/--minimumare given.- Activities do not reserve the room of their feedback (an answer below the question may push the page; the learner is reading there), concealed blocks and pending steps take no room, and a revealed step scrolls into view. Steps open with a Continue button in the page, or by themselves once the activities above are answered (Steps below).
- The player’s labels say page, not screen (“Page 2 of 6”);
course.uirewords them (“Lesson {n} of {total}”). - Text density is read by stretch, not by screen: more than about 450 words to read in a row with nothing to do (an activity, a control, an accordion, media) is
W_TEXT_DENSITY, and so is a page of more than about 1,500 words (two lessons rather than one). In slides it stays 250 words a screen. - A screen layout with
fullBleed: trueowns the page: a tall opening band, then a reading column (max-width: var(--t-content-width),t-w-wideblocks tovar(--t-wide-width)). Example:examples/medieval-charts, layoutfolio.
The player’s motion. The player’s own pieces follow one set of rules (packages/player/src/player.css, « Motion »): menus open at once and shrink back toward their button; popovers, layers and the PDF viewer grow and go back; side sheets slide from their edge. Themes and components reuse its tokens rather than their own values:
--t-ease-outfor what comes or goes,--t-ease-in-outfor what moves on screen,--t-ease-drawerfor sheets;easefor a fade that must be seen; neverease-inon what answers the learner.--t-dur-press160 ms,--t-dur-tip200 ms,--t-dur-pop200 ms,--t-dur-modal250 ms,--t-dur-drawer350 ms,--t-dur-reveal400 ms.--t-movemultiplies distances and scales (translate: 0 calc(.5rem * var(--t-move))): it is 0 when the learner asks for less motion, so that only fades stay.--t-scroll-inbrings something in with the scroll as a revealed step comes in, over a stretch of scroll instead of a time:animation: var(--t-scroll-in); animation-timeline: view(); animation-range: entry 5svh entry 35svh;fades it witheaseand lifts it by--t-scroll-in-distance(1rem) on--t-ease-out, which slows down to rest;--t-scroll-in-opacity(0) is where the fade starts. Set them on the element (--t-scroll-in-distance: 1.5rem).- Only
opacity,translateandscale.
Scroll-driven design. Themes and components may animate with the scroll in CSS: animation-timeline: view() for a block that rises into place as it enters the window, scroll(root) for a backdrop or an ornament that turns as the page goes down, named view timelines shared through timeline-scope for a figure that stays in view (position: sticky) and builds up as the stages of the text scroll past (component workshop of the sample). An entrance ends at rest: use --t-scroll-in, or end a curve of your own as --t-ease-out does (never linear, nor a curve that reaches its end at speed: the eye sees the block stop). Write them as progressive enhancement: inside @supports (animation-timeline: view()) and @media (prefers-reduced-motion: no-preference), with the final state as the base style, so that browsers without them (Firefox today) and learners who reduce motion see everything in place. The player turns animations off under reduced motion; captures, elabora fit and elabora test run with reduced motion, so they see the finished page.
- Free objects follow the scroll with
host.onView(docs/free-object-contract.md, View): an entrance as the learner scrolls to them. - Check motion down the page with a filmstrip of scroll positions:
elabora screenshot . --screen <id> --scroll 0,25%,50%,75%(px or percentages of the page; animations on; one contact sheet). A full-page capture shows the page as it is at the top; free objects below the window are captured once scrolled into view and laid in place, and sticky figures appear where they start.
Nothing moves when the learner answers
In slides, an interaction must not push content around: a slide that jumps when an answer appears feels broken.
- Reserved room. Each activity (quiz, numeric, decision, results) reserves from the start the height of its tallest outcome (feedback, flags, messages), measured at its real width: it grows inside that room. Fit checks therefore measure the answered slide too. A component whose feedback floats over its content opts out with
data-t-reserve="off"in its template. - Concealed blocks keep their place, like pending steps: a block shown by an event appears where its room already was.
- Longer content goes into a layer.
"presentation": "panel"opens over part of the screen, which stays usable (like a Storyline layer): feedback that needs a table, a hint, a debrief over a 3D lab."dialog"(default) is modal. - Room is a design budget: tight feedback (a head line and two or three lines) keeps slides small; long explanations belong in the next step or a panel.
elabora fitshows every slide as it is once answered and reports what still moves (W_SLIDE_SHIFT: a block that grows, the blocks it pushes);elabora screenshot --state answeredshows it.
Steps
"step": 2 on a block of a screen holds it back until the learner presses Continue. Steps are numbers; gaps do not matter.
- scroll: a pending step takes no room. A Continue button in the page, where the shown content ends, opens the next step (the continue dividers of Rise); revealed blocks appear below and scroll into view. Next, in the bottom bar, only goes to the next page.
- slides: Continue is the Next button, relabelled while steps remain.
- slides: a pending step keeps its place (the view does not move, like the builds of a presentation), so a slide must fit with all its steps.
- How each step opens (screen
steps, keyed by step number):"opens": "continue"(default), or"opens": "answered": the step opens by itself once every activity of the steps above is answered, right or wrong (a quiz, a numeric answer, a decision, a free object completed), like the rest of a Rise lesson shown after a knowledge check. It opens without taking the focus, so the learner reads the feedback first."label"words its Continue button ("steps": { "2": { "label": "Test yourself" }, "3": { "opens": "answered" } }). A step that opens once answered needs an activity above it (E_STEP_UNREACHABLE); settings for a step the screen does not have, or for its first step, areW_STEP_SETTING_UNUSED. - With
navigation.lockUntilComplete, Continue waits for the activities shown above it (the button says why). - The bottom bar’s
position: "page"(course.jsonplayer, D58) puts it at the end of the page, after its content, instead of at the bottom of the window; in scroll presentation it appears once the last step is shown, and a narrated screen’s controls stay in the window in a strip of their own. - Focus moves to the first revealed block (screen readers read it). Revealed blocks fade in and rise a little; with reduced motion, they only fade.
- With
navigation.lockUntilComplete, the activities of the revealed steps must be done before Continue. - A screen counts as viewed once its last step is shown. Back on a screen left part-way (Previous, the outline, a relaunch), the learner finds the step reached, saved in the suspend data; a screen seen to the end shows all its steps.
- Steps apply to the blocks of the screen. In scroll presentation the reading order (layout slots, then block order) must not go back to an earlier step (
W_STEP_ORDER); steps on blocks nested in a component or in a layer are ignored (W_STEP_PLACEMENT). - Previews:
elabora screenshot --step 1, MCPelabora_preview_screenshotwithstep; both show every step by default.
Click to reveal (the builds of a Storyline slide): a click in the slide can reveal the next step instead of Continue. Give the button (a button block, or a component with a data-t-click button such as a marker over a figure) a click event { "type": "reveal-step", "step": 3 }: it reveals every step up to 3, and selected again later it only brings step 3 back into focus. Next appears after the last step; back on the screen later, the learner finds the step reached.
- Continue still reveals the same steps by default: a second path that always works with a keyboard. When the screen should be explored through its own triggers (every marker selected before going on), set
"continueRevealsSteps": falseon the screen: the button reads Next and, withnavigation.lockUntilComplete, waits for the last step (“Explore this screen to continue”). Every step must then be reached by areveal-step(E_STEP_UNREACHABLE); markers are buttons, so the keyboard path stays. - Put each marker in the step before the one it reveals; the blocks of the latest step shown carry
data-t-current, so the marker to select next can call for a click (a pulse, still under reduced motion) and the selected ones settle. - A figure that builds up is a stack of blocks in one frame (a layout slot whose blocks are drawn over each other): the base image with its caption, then layers revealed by step. Layers animate in CSS from
[data-t-pending]to revealed (a registered custom property in a transition draws a path or turns a layer); on a visited screen they are drawn at once. - Order the blocks so that the first block of each step is what it shows (focus goes there and screen readers read its text alternative), then the marker to select next: Tab leads from one to the other. Text of the step can fade in after the figure (
transition-delayon[data-t-revealed]). - Example:
examples/orbital-rendezvous, screenorbits(componentshotspotandorbit-flight, layoutsplitwithfigure: "stack").
Narrated steps (D24): a screen’s narration.lines give it a voice, and each step a stretch of it. The bar of a narrated screen is the player’s controls item (in the bottom bar by default; a bar shows on every screen as soon as one screen is narrated, so a slide’s room never changes), and the screen keeps the place of its own Continue at its end, so a narrated slide must fit with that place (elabora fit and looks measure it there); its captions show in a band laid over the bottom of the screen, which moves nothing, and its transcript in a panel over the screen. While the voice plays a step, its blocks are shown (pending before, as any step); seeking back hides the blocks of later steps again without changing the step reached. Put on screen what the voice does not say (a figure, keywords): W_NARRATION_DUPLICATED reports text that reads a line again. Look at a moment with elabora look . --screen <id> --at l2 (l2:end: as the line ends; l2:imprimante as the word is said; 12.5 a time) or a filmstrip elabora screenshot . --screen <id> --at l1,l2:imprimante,step1:end; elabora timeline . <id> gives the times as text.
Timed blocks (D28): block.timing brings a block in on a word of the voice and takes it away later, with an effect relative to its place (fade, rise, slide, zoom, wipe, draw, or the theme’s own @keyframes t-fx-<name>). A timed block keeps its place while it is not there, as a step does: lay the slide out with every block shown (looks without --at show the end of the screen; with a moment, the screen as it is then). Several contents in one place go into a stack: it takes the size of the largest. Keep effects short (0.3 to 0.8 s) and rare enough that learners can follow them (W_MOTION_LONG past five seconds of movement), and keep text shown long enough to be read (W_TEXT_TOO_FAST).
Explore in any order (click each tab or marker, then Next): every control of a block (its buttons, tabs and disclosures) is visited once the learner has used it, saved with the progress and styled through [data-t-visited] (a check, a dimmed marker). A tab or disclosure counts once its content is shown, so the tab open at first counts as soon as its block is in view.
- One block can hold every marker: name the buttons in the template (
<button data-each="markers" data-t-click="{{id}}">) and give each name its own event:{ "on": { "type": "click", "target": "figure-markers", "name": "engine" }, "do": [{ "type": "show-layer", "layer": "engine" }] }(a panel layer keeps the figure in view). - Unlock Next when all are visited:
"completeWhen": "action"on the screen, withnavigation.lockUntilComplete, and an event on the block’s clicks{ "on": { "type": "click", "target": "figure-markers" }, "if": [{ "visited": "figure-markers" }], "do": [{ "type": "complete-screen" }] }. Several blocks: one such event per block, each with everyvisitedcondition.{ "visited": "figure-markers", "name": "engine" }reads one control.
3. Layouts and components
Screen layouts are screen-level components (components/<id>/ with "level": "screen"); block types of the course are block-level components. Both are templates with named slots, written in reading order, with decoration only and words in props. See components.md.
{ "id": "figure-split", "title": "Figure and explanation", "version": "1.0.0", "level": "screen",
"slots": [ { "id": "figure", "label": "Figure and legend", "accepts": ["image", "text"], "maxBlocks": 4 },
{ "id": "body", "label": "Explanation", "default": true } ] }
<div class="pg-split">
<div class="pg-split-head" data-title></div>
<div class="pg-split-figure"><div class="pg-split-card" data-slot="figure"></div></div>
<div class="pg-split-body" data-slot="body"></div>
</div>
- A screen uses it with
"layout": "figure-split"(and"layoutProps"when the layout declares props); each block chooses"slot": "figure"; blocks without slot go to the default slot. The title goes into thedata-titleelement (or before the template). fullBleed: trueremoves the page padding and width so bands can span the window.- Built-in layout
opening(« Ouverture », D63): the head of a lesson (title, lead, reading time, nothing above the title; presentationstitle,band,split,cover), then aquestionslot and the page (body, laid out as the default column,wide,halfandbleedincluded). It owns the page (full bleed)."layout": "opening", "layoutProps": { "presentation": "band", "image": "images/harbour.jpg", "alt": "…", "lead": "…" }. - Scope layout CSS with
.t-layout--<id>, component CSS with.t-block--<id>.
4. Figures and generators
Figures with words cannot be translated, edited or reflowed on phones. Draw numbers, symbols and markers; put labels in a block next to the figure (a numbered list styled as a legend, a caption, a slot).
Check them at a glance with elabora assets . --sheet assets.png (MCP elabora_build_assets with sheet: true): every asset each generator wrote, on the course’s surface colour, with its name and size.
Figures drawn by code live in generators/<name>.mjs (helpers: generators/_<name>.mjs or sub-folders):
import { gearset } from '../objects/planetary-lab/gear.js'; // share geometry with a free object
export const meta = { description: 'Gearset illustrations' };
export default async function ({ tokens, course, plan, language, write, read }) {
write('images/gearset.svg', svgFrom(gearset('30-78'), tokens['color.copper']));
}
elabora assets (MCP elabora_build_assets) runs them and writes generators/provenance.json (generator, hash of its inputs: its code, the files it imports, the tokens and the language; hash of each output). elabora validate reports stale assets (W_GENERATED_ASSET_STALE: a generator, an imported file or a token changed) and hand-edited ones (W_GENERATED_ASSET_EDITED). The proof dossier lists generated assets. Generators run on the author’s machine with Node, like npm scripts: review them like any code.
Drawings in the page (D33). An image block shows an <img>, which neither the page’s CSS nor the timeline’s draw effect can reach inside. inline: true on an image block whose asset is an SVG (generated or local) puts the drawing in the page instead, as figure.t-figure > svg.t-figure-svg[data-t-svg="t-svg-<block>"]: draw then traces its strokes (each subpath of a path from its start, so the hatching of an engraving grows line by line), and the theme’s CSS and currentColor reach its shapes. The build writes it from the file, sanitized: only SVG elements that draw (no script, foreignObject, link or iframe), no event handler, no reference out of the drawing (#fragments and data: images only), its ids prefixed with the block’s, its own <style> scoped to it (@keyframes renamed, no @import nor @font-face). It is named by the block’s alt (role="img", aria-label) or hidden from screen readers when decorative, as the <img> was. What was removed is W_SVG_INLINE_SANITIZED, an inline bitmap W_IMAGE_INLINE_NOT_SVG, a drawing over 256 KB W_IMAGE_INLINE_HEAVY (it is part of the course data), and draw on an image the page cannot trace W_TIMING_DRAW_IMAGE. Rules of the theme for .t-figure img do not reach it: style .t-figure :is(img, .t-figure-svg). Captures without a moment show the drawing finished; under reduced motion draw is a short fade. Keep animations inside the drawing for the theme or the timeline (an SVG’s own @keyframes run once, at load, whatever the learner is doing).
5. Free objects in the design
- Size:
aspectRatio(16:9,21:9,3:2,4:3,1:1,3:4,2:3,9:16,auto,fill),narrowAspectRatiounder 640 px,minHeight,maxHeight.auto: the object callsctx.host.requestHeight(px);fill: the frame takes the height its layout slot gets (in slides, the height left by the rest of the screen when the layout lets the slot grow). - Text: 1rem in the frame is 1rem of the page (size classes, §2).
- Look: the frame receives the
--t-*variables, the course fonts and the UI kit (t-ui,t-ui-panel,t-ui-card,t-ui-eyebrow,t-ui-title,t-ui-btn,t-ui-btn--primary,t-ui-seg+t-ui-seg-options,t-ui-meters+t-ui-meter,t-ui-status(is-ok,is-ko),t-ui-badge). See free-object-contract.md.
6. Checks on the rendered course
elabora test runs axe-core on every screen (contrast of the real CSS, not only of the tokens), loads every screen at 375 px to report horizontal overflow (W_RENDER_OVERFLOW, with the widest element) and, for slides, measures the fit at the reference and minimum windows (§2). The run that audits accessibility requests reduced motion, so scroll-driven entrances do not leave offscreen text partly transparent during the audit; other LMS runs keep normal motion. Fit and free-object gates check animated layouts separately. elabora fit also measures the contrast of text against the pixels behind it, where tokens cannot tell (backdrops, images, gradients).
Looking while designing. elabora look . --screen <id> (MCP elabora_look) shows a screen in well under a second once warm, for the look-and-fix loop: a look server kept for the course renders it from the files as they are, without a build, in one browser drawing with the GPU, as the editor canvas does, with the player’s bars (so a slide has the room it has in a package) and its free objects; an object the edit did not touch keeps running. It writes a JPEG (PNG on request), says whether a slide fits at that size or how long a page is, and with --json where every block is. On a scroll page, --scroll 100% (px, a share of the page or a block’s id) shows the window there with its animations, --sheet the page down its length, --block <id> --scale 2 a close-up of one block; they report what each block in the window does (visible, clipped, its animations, their timeline and progress) and the scroll-driven animations that cannot finish because the page ends before their range (elabora fit reports them too, W_SCROLL_ANIMATION_UNFINISHED). --block <object> --act click:<text> drives a free object from inside its frame and reports its state and events. elabora look . --url prints a page that follows the looks and every saved file, for a browser pane; elabora preview . serves the whole course and reloads after each rebuild. Both load free objects from a second port of their server (another origin of the same site, sandboxed with allow-same-origin), because the in-app browsers of agent apps refuse frames of an opaque origin, which packages use. Looks are for iterating; the captures below, built like the package, are the check.
elabora screenshot and MCP elabora_preview_screenshot build the package and wait for images and free objects; agents should check each designed screen at 1600×900, 1280×720 and 375 px. One command captures many shots with one build and one browser:
elabora look . --screen intro --size 1600x900,375x812 # while designing: well under a second once warm
elabora screenshot . --screen all --size 1600x900,1280x720,375x812 --state initial,answered --out-dir shots/
elabora screenshot . --screen intro --at 0,2000,4000 --viewport # filmstrip of the animations, one contact sheet
elabora screenshot . --screen workshop --scroll 0,20%,40%,60%,80% # filmstrip down a scroll page
elabora object replay . chart-table --test "Voyage 1" # an object test, replayed in the course page
elabora assets . --sheet assets.png # what the generators wrote, on one sheet
--state answered shows every activity with its tallest feedback; --at sets CSS animations to each time and lets free objects run, then lays the frames out on one sheet; --motion keeps animations on (by default captures use reduced motion, so steps and transitions are settled).
Not in this prototype
A visual editor for components, slots and styles; theme packs shared between courses (component libraries exist); right-to-left layouts.