The theme validator
The portal only distributes a theme if it passes the theme validator: it must implement the entire Core v1 language, be safe and load nothing from outside. This page explains what it checks, and why.
Why a validator
A browser never reports a CSS error: a badly written rule is silently ignored, a forgotten class simply does nothing. Someone who writes <!-- _class: content gallery-bento --> with a theme that forgot gallery-bento gets a broken slide, with no message at all.
The validator therefore guarantees that a distributed theme implements the whole language, and that it is safe and self-contained: it will be sent to every machine and included in every render.
Where it runs
A single module, packages/core/src/theme-rules, applied identically everywhere:
| Location | When | Stages | Role |
|---|---|---|---|
| Portal editor (browser) | While typing, after a short pause | 1 to 6 | Immediate feedback, preview, geometry measurement |
| Portal server | Saving a draft, publishing | 1 to 5, plus the stage 6 report | Authority: rejects a non-compliant theme |
| VS Code extension | Installing and using any theme, from the portal or local | 1 to 4 | Reports a non-compliant theme in Problems and the status bar |
Each result carries the rules version (theme-rules@2), stored with the published version. A rule added later does not invalidate versions already published: they keep their original report.
Error or warning
Each finding has a stable identifier (contract.class-without-effect…), a severity, a position in the CSS when it makes sense, and a translated message with a suggested fix.
- Error: the version cannot be published, so it is never distributed.
- Warning: displayed, does not prevent publishing.
A draft can be saved with contract or rendering errors (work in progress), but not with reading, identity or security errors (stages 1 to 3). Publishing requires zero errors.
The six stages
The CSS goes through six stages, in order. Stages 1 to 5 are static: they run everywhere, including on the server, without a browser. Stage 6 requires a real rendering engine.
Stage 1 — Reading
| Rule | Check | Why |
|---|---|---|
format.encoding | No null character | Corrupted or binary file |
format.syntax | The CSS is parsed by a real CSS parser (css-tree); each syntax error is reported with its line | The browser would ignore the faulty rule without warning |
From then on, the validator works on a syntax tree, not on the text: a comment, a string or an escape cannot fool the following checks.
Stage 2 — Identity
| Rule | Check | Why |
|---|---|---|
identity.theme-missing, identity.theme-multiple | Exactly one /* @theme Name */ | This is the name authors write in theme: |
identity.theme-name | A letter, then letters, digits, - or _; 64 characters max | A name usable everywhere without ambiguity |
identity.name-stable | A new version keeps the theme name | Otherwise existing presentations would lose their theme |
identity.language-missing, identity.language-unknown | /* @dwa-language core-v1 */ present; only core-v1 exists | Declares the implemented contract |
identity.slide-size-unit, identity.slide-size | If the theme sets the size of section, it is in pixels and in 16:9 format | PowerPoint export and overflow measurement assume 16:9 |
With no declared size, Marp uses 1280 × 720: nothing is reported.
Stage 3 — Security and self-containment
A remote resource would make it possible to track when presentations are opened, would break offline work and could change after publication: the theme fingerprint would no longer guarantee anything.
| Rule | Check |
|---|---|
security.url | Every url(…) (and the strings in src(), image-set(), cross-fade()) is a data: URI. https:, relative paths and other schemes are rejected |
security.data-encoding | The embedded resource is base64-encoded |
security.mime | Accepted types: PNG, JPEG, GIF, WebP, WOFF, WOFF2, TTF, OTF |
security.payload | The first decoded bytes match the declared type (PNG, JPEG, WOFF signature…) |
security.svg | SVG rejected, even embedded: it can contain scripts and external references |
security.import | @import rejected, except for Marp's built-in themes (default, gaia, uncover) |
security.at-rule | Accepted at-rules: @font-face, @media, @supports, @keyframes, @layer, @container, @charset, @import (case above) |
security.style-break | No </style sequence, even in a comment: Marp inserts the theme in a <style> tag |
security.legacy | expression(), behavior, -moz-binding, javascript: rejected |
CSS escapes (\2014) remain allowed: the parser decodes them and the checks apply to the decoded values. A u\72l( is seen as url( and checked.
Stage 4 — Core v1 contract: every class must have an effect
This is the heart of the validator. For each class in the reference, the question is: "if an author writes it, does anything change?" The list of classes is generated from the shared catalog, the same one used by the extension's autocompletion.
The differential test:
- For each class, the validator builds a test slide: the HTML that Marp produces for the class's example Markdown (the one the extension inserts).
- It computes, without a browser, the declared values of each element of the slide and its pseudo-elements: which rules apply, and which one wins according to the cascade (
!important, specificity, order). Selectors are evaluated on a virtual DOM, including:is(),:not(),:has()and attribute selectors such as[class*='image-text']. - It redoes the computation on the same slide without the tested class.
- If no value changes, the class is not implemented.
Example with theme/DWA.css, which contains section.cols-3 { --columns: 3; … }:
Tested class: cols-3 Slide: <section class="content cols-3"> + 3 groups
Without cols-3 → section: --columns not defined
With cols-3 → section: --columns = 3
⇒ effect found ✓For a theme that forgot gallery-bento, the values are identical with and without the class: error "Class “gallery-bento” has no effect: it is not implemented."
Why values rather than "a rule mentions the class": a rule can mention the class but always be overridden by another; conversely, DWA.css uses fragment selectors ([class*='image-text']) that never mention image-text-70 in full. The test judges the effect, however it is written.
| Rule | Check | Severity |
|---|---|---|
contract.class-without-effect | The class changes at least one declared value. Default classes (color-1, fit) may change nothing | Error |
contract.distinct-values | Two members of the same family do not give the same result: cols-3 ≠ cols-4, image-text-30 ≠ image-text-70, color-2 ≠ color-5… Otherwise an author would choose an option that does not exist | Error |
contract.callout-size | callout-size-N gives the callout title a size of N px: the extension and PowerPoint export rely on this number | Error |
contract.text-effect | Text stays text in the PowerPoint export: text painted by a background (background-clip: text), -webkit-text-fill-color, glyph outlines (text-stroke) and vertical writing (writing-mode) are rejected. Gradients, pseudo-elements and decoration transforms remain free | Error |
contract.selector-skipped | Selector the validator could not evaluate: its effect is not taken into account | Warning |
Additional classes specific to the theme are allowed: the contract defines what must exist, not what is forbidden.
Stage 5 — Marp rendering
The CSS is registered in Marp Core and all test slides are rendered. A failure is a render.marp error: the theme must be accepted by the tool that will use it.
Stage 6 — Geometry checks
The test slides are rendered in a real browser, at the theme's slide size, and the geometry is measured (getBoundingClientRect, 2 px tolerance).
| Rule | Check | Severity |
|---|---|---|
visual.inside | No element extends outside the slide (except the intentionally cropped image of image-full) | Error |
visual.columns | cols-N: N groups on the same row, left to right, of equal width | Error |
visual.split | image-text-N: image on the left, image share ≈ N % (± 3 points); text-image-N: on the right | Error |
visual.gallery | gallery cols-N: N images per row | Error |
visual.bento | gallery-bento: image 1 spans both rows, image 2 spans two columns | Error |
visual.timeline | timeline: steps aligned horizontally; timeline-vertical: stacked | Error |
visual.title | with-title: title above the content, full width | Error |
visual.legend | with-legend: legend below everything else | Error |
visual.chart-wide | chart-wide: two comments side by side above the visual | Error |
visual.contrast | For each palette, text contrast on the background color ≥ 4.5:1 | Warning |
In the portal editor, measurement happens in an isolated iframe, with no scripts or network. The server has no browser: on publication, the editor sends its stage 6 report, and the server requires it to be error-free, computed on the same SHA-256 fingerprint of the CSS and the same rules version. It redoes stages 1 to 5 itself.
Theme size
There is no size rule: images and fonts embedded in base64 naturally make the CSS grow. Only a technical transport limit remains: the maximum size of a request sent to the portal.
Known limitations
- Stage 4 proves that each class does something, not that it does it well: that is the role of stage 6, which only covers the measurable compositions listed above.
- The virtual cascade is not a rendering engine:
@mediaand@supportsconditions are not evaluated, and an unevaluated selector is only reported with a warning. - Contrast does not analyze background images; check readability in the preview.
- Test slides have short texts: they do not guarantee that a real presentation will not overflow. The extension's overflow measurement takes care of that.
- Stage 6 is measured by the editor's browser: someone who forged their report would bypass the geometry checks, but would only publish within their own organization. Security (stages 1 to 3) is always checked by the server.
- The extension applies stages 1 to 4; it does not redo Marp rendering or geometry checks.