Compatibility policy
This page is a reference. It answers two questions: how a renderer decides whether it understands a document, and what it does with every part it does not understand. The answers are fixed by the contract; a renderer does not choose its own.
Who needs it:
- An app developer — the first three sections: what your users see when a document is newer than the app, and how to move between major versions.
- Someone writing or auditing a renderer — the policy tables and the checklist at the end.
Related pages: index · quickstart for Flutter · iOS · Android · theme · localization · widgets · changelog · quickstart for React
Why there is a policy#
The builder and the app are released separately. A manager can publish a screen built with a newer contract than the app in a user's pocket understands. That is the normal case for server-driven UI, not an error.
The policy is machine-readable. It lives in the artifacts under x-compatibility, so a renderer can test itself against it instead of trusting a document like this one. The keys and values below are copied from the artifacts.
The contracts and their versions#
| Contract | Version field | Current | Artifact |
|---|---|---|---|
| Widget schema | schemaVersion in every document; x-schemaVersion in the artifact |
9.19.0 | screen-schema.json |
| Theme | themeContractVersion in the theme; x-themeContractVersion in the artifact |
4.0.1 | theme.json |
| Data dictionary | x-dataContractVersion in the artifact |
1.5.0 | data-contract.json |
| Icon set | iconSetVersion in the artifact |
1.0.0 | icons.json |
Where the artifact files are published is said in the overview.
The versions are independent. A rebrand adds no widgets and a new widget changes no palette. One shared number would force a release of everything on every change of one part.
What they share is the rule below, not the number. 9.0.0 says nothing about 4.0.0: the two became major versions on the same day for two different reasons.
Changing values does not change a version. A new value for a color, a new token of your own in the theme, or a new field in a project's data dictionary, is content. A version changes when the structure changes.
How a renderer decides#
A version is three numbers: major.minor.patch. Nothing else is accepted — no prefixes, no pre-release suffixes.
Compare the document's version with the version your renderer was written for:
| Document version against yours | Verdict | What to do |
|---|---|---|
different major (8.x against 9.x, either way) |
major mismatch | Do not draw. The structures are incompatible in both directions. |
| same major, minor lower or equal | ok | Draw. |
same major, minor higher (9.3 against 9.0) |
newer minor | Draw. Treat what you do not know by the policy below. |
| not three numbers, or missing | unparsable | Do not draw. |
The patch number never affects the decision.
The rule rests on one promise: a minor version only adds optional things. A renderer of an older minor therefore knows everything that is required in the document.
The two version keys are identical in the schema and the theme artifacts:
| Key | Value | Meaning |
|---|---|---|
majorMustMatch |
true |
A renderer draws only documents of its own major version. |
minorAdditionsAreOptional |
true |
A minor version adds only optional fields, widgets, tokens or values. |
How each renderer package exposes the rule:
| Package | Call | Verdicts |
|---|---|---|
@hotlayout/react |
checkDocumentVersion(version) |
kind: compatible, newer-minor, incompatible (with relation, e.g. document-newer) |
hotlayout_flutter |
document.versionVerdict |
VersionVerdict.ok, .newerMinor, .newerMajor, .olderMajor, .unparsable; canRender is true for the first two |
| iOS reader (Swift) | checkDocumentVersion(document) |
.compatible, .newerMinor(documentVersion:), .incompatible(reason:relation:); isReadable is true for the first two |
| Android reader (Kotlin) | checkDocumentVersion(…) |
Ok, NewerMinor, NewerMajor, OlderMajor, Unparsable |
To the contract a newer and an older major are the same mismatch. The packages tell them apart only to choose what to say to the user: "update the app" helps with a newer document and never with an older one, which has to be published again from the builder.
final verdict = document.versionVerdict;
if (!verdict.canRender) {
// show your own message; "update the app" is true only for VersionVerdict.newerMajor
}A major mismatch is the only case in which the user must be told something. An empty screen reads as a broken app, not as an outdated one.
A document and a theme fail differently#
| What is incompatible | What the renderer does |
|---|---|
| Document, other major | Does not draw it. A document of another structure is different content. |
| Theme, other major | Draws the document with a fallback theme and reports it. A theme is appearance; a screen in slightly wrong colors is better than no screen. |
Moving from 8 to 9#
Widget schema 9.0.0 and theme 4.0.0 are major versions, so the first row of the table above applies to every app in the field.
What changed. The look of a node — padding, background, radius, border, shadow, color, text style — moved out of props into style, which is now the same on every widget. A style field accepts a token or a value of the document's own. The theme declares its own token names next to a required core. The list of moved fields is in the changelog.
What happens to documents.
| Document | Renderer 8.x | Renderer 9.x |
|---|---|---|
| published as 8.x, not published again | draws it | does not draw it: major mismatch |
| published again from the builder (9.x) | does not draw it: major mismatch | draws it |
- A published 8.x document stays 8.x until it is published again. Nothing rewrites published files. The builder migrates a draft when a manager opens it; the manager then publishes, and the published file becomes 9.x.
- An app with a renderer of 8.x does not draw a 9.x document. There is no partial drawing: a 9.x document read as 8.x would be a screen with no backgrounds and no padding.
- A migrated document looks the same. The migration moves fields and writes down the defaults that 8.x renderers applied silently. It invents nothing.
- The theme has its own major. A renderer of theme 3.x rejects a 4.x theme as a whole and draws with its fallback theme; a 4.x renderer does the same with a 3.x theme. The screen is drawn either way, in fallback colors, and the renderer reports it.
The order that leaves no user without a screen.
- Keep every published version on your backend, or keep the 8.x file at its address. Apps in the field ask for 8.x for as long as they are not updated.
- Publish each document again from the builder. Serve the 9.x version to renderers that ask for 9.x and the last 8.x version to renderers that ask for 8.x — the rule is in the backend guide.
- Publish the theme again; it becomes 4.0.0. Serve 3.x to renderers of 3.x in the same way.
- Replace the document and the theme bundled in the app with their 9.x and 4.x forms, then release the app with the 9.x renderer. A bundled 8.x copy in a 9.x app is a fallback that cannot be drawn.
If your app reads the published files directly from our storage, there is one file per document, and it holds the last publication. Publishing again replaces the 8.x file with a 9.x one, and apps with an 8.x renderer fall back to their bundled copy from that moment. Release the app update first in that setup, or serve documents through your own backend.
Two majors cannot be served side by side from the direct address. Side-by-side delivery needs the webhook and your own backend.
Widget schema policy#
x-compatibility.policy in screen-schema.json.
| Key | Value | Situation | What a renderer must do |
|---|---|---|---|
majorMustMatch |
true |
version check | See above. |
minorAdditionsAreOptional |
true |
version check | See above. |
unknownWidget |
render-children |
a node has a type you do not know |
Skip the node itself and draw its children. A new widget is usually a wrapper; its content is made of widgets you know. |
unknownToken |
fallback-to-field-default |
a token the theme you draw with does not declare, in a field of props — those fields have a default in the schema |
Use the default of the field from the schema. The gap, the divider line, the progress fill stay in place. |
tokenWithoutFieldDefault |
omit-field |
a token the theme does not declare, in a field of style — style fields have no defaults |
Do not apply the property, as if the field were absent. For color and typography that means the inherited value stays. A custom gradient with a stop, or a custom shadow with a layer, on an undeclared token is not drawn at all. |
unknownAction |
pass-to-host |
an action type you do not know |
Hand it to the host app unchanged, with its parameters. The app may know it. |
unknownVisibilityOp |
visible |
a condition uses an operator you do not know | Treat the node as visible. Hiding would silently remove content. |
missingConditionValue |
absent-is-empty |
a condition reads a path that has no value | Treat the value as empty: isEmpty holds, isNotEmpty does not, and every other operator does not hold. |
unknownBindingFormat |
render-as-is |
a binding has a format you do not know |
Show the value as it arrived. |
unparsableBoundValue |
render-as-is |
the value does not fit the format |
Show the value as it arrived. |
missingTranslation |
fall-back-to-default-locale |
no translation for a place, or the language is not in the document | Show the node's literal, which is the source-language text. See localization. |
localeNormalization |
underscore-as-hyphen-case-insensitive |
the host passes a device locale spelled ar_EG or AR-eg |
Treat _ as - and ignore case, for the requested locale and for the document's tags alike. See localization. |
localeMatching |
bcp47-lookup-truncate |
the device locale is longer than the document's tag (ar-EG against ar) |
Look for an exact match; then drop the last subtag and look again, until none is left (RFC 4647 Lookup). |
localeChildFallback |
none |
the device asks for ar, the document has only ar-EG |
Not a match. Never pick a regional translation for a shorter request. |
unmatchedLocale |
use-default-locale |
no language of the document matches, or the locale is empty or malformed | Show the source language of the document. |
localeResolution |
once-per-document |
how often the language is looked up | Once for the document. A string missing in that language is missingTranslation, not a new lookup. |
directionFollows |
shown-translation-locale |
which locale gives the writing direction | The language of the text that is shown, not the requested locale. |
formattingLocale |
requested-when-matched-else-default |
which locale formats dates | The requested locale when a translation was found; the source language when it was not. |
actionRecord |
node-record-scope |
an action is invoked on a node that has a record: a list row, or a button on a screen opened from a list row | Give the host the record scope of the node together with the action: the record the screen was opened with, overlaid by the records of the Repeater instances the node is drawn in. A flat map of paths, { "item.id": "bottle" }. This is how the app learns which reward to redeem. |
navigateFromListItem |
open-with-node-record |
navigate is invoked in a list row |
Open the screen with that record scope. The paths of the record (item.name) are read from it first. |
navigateOutsideListItem |
page-without-record-modal-keeps-record |
navigate is invoked outside a list |
A page opens without a record, even from a screen that has one. A modal screen sees the record of the screen beneath it. |
recordLifetime |
while-screen-in-stack |
the user goes back from a screen that has a record | The record belongs to the screen's place in the stack. Going back drops it; the previous screen sees what it saw before. |
dataLayerOrder |
record-session-host-data |
the same path has a value in several data layers | Read the first layer that has it: the record, then what the user wrote into a bound input (ChoiceGroup), then what the host supplied for this screen, then the data of the document. The list is in x-dataScope.layers. |
absentInDataLayer |
read-next-layer |
a layer has null under the path, or no such key |
The layer has no value: read the next one. false, 0 and the empty string are values. |
navigateParams |
for-host-not-data |
a binding names a path such as params.itemId |
There is no such layer. params of an action are for the host; they are not the data of the screen. |
pickedChoice |
shown-while-bound-value-unchanged |
the user picks an option of a ChoiceGroup whose binding reads another layer |
The group shows the user's pick while the value under its binding is the one the pick was made with. |
unknownIcon |
empty-box-same-size |
an icon name the renderer does not have (a document of a newer minor) | Draw nothing and keep the box of the icon at its size, so the layout does not move. This is stronger than unknownToken: do not fall back to the default of the field (star) — a star in place of a flame looks right and is wrong. Applies to Icon and to both icon slots of a ListTile. |
unreadableIconValue |
as-unknown-icon |
an icon value that is not a string — an object, a number, null |
The same as an unknown name: an empty box, no failure. A later minor may put an object there (an icon from the media library); a 9.3 renderer has to survive it. |
unknownStringKind |
render-empty |
a string value has a kind you do not know — a template for a 9.3 renderer |
Show no text; keep the node. This names what the renderers already do. A template printed literally, braces included, on a user's screen would look broken. |
undeclaredPlaceholder |
render-literally-with-braces |
a template (or its translation) says {name} and values has no such name |
Plain text, braces included. A translator's typo is visible instead of hidden. |
unusedTemplateValue |
allowed-not-shown |
values has a name the string does not mention |
Allowed; nothing is shown for it. A translation may not need a word. |
missingTemplateValue |
value-fallback-keep-sentence |
there is no data for one value of a template | Show the fallback of that value and keep the rest of the sentence: “— / 5 steps”. |
missingCurrencyCode |
render-as-is |
format: 'currency' without a currency code of the right shape |
The format does not apply; show the number as it arrived. |
pluralCategory |
cldr-cardinal-categories-not-exact-numbers |
choosing a plural form | Use the CLDR cardinal category of the number in the language of the forms. zero, one, two are categories, not the numbers 0, 1, 2: in English 0 is other. |
pluralNumber |
absolute-value-rounded-to-three-fraction-digits |
which number picks the form | The number of the value named by of: its absolute value rounded to three fraction digits (as the number format writes it), whatever format the value has. For daysUntil it is the counted days. |
missingPluralForm |
use-other |
the form of the needed category is not written | Use other. |
pluralLanguage |
language-of-shown-forms |
which language's rules pick the form | The language the shown forms are written in: if other is translated in the current language, its forms and rules; otherwise the forms of the document by the rules of the source language. Never mix. |
pluralWithoutNumber |
use-other |
the value named by of gives no number |
Use other. |
pluralWithoutLanguage |
use-other |
a plural in a document without localization |
Use other: the document names no language. The builder does not publish such a document. |
daysUntil |
ceil-of-remaining-24h-not-below-zero |
format: 'daysUntil' |
max(0, ceil((moment − now) / 24 h)). A duration rounded up, not a difference of calendar dates: the time zone does not matter. |
readerClock |
injectable-read-at-render |
where “now” comes from | The renderer's clock, which the host must be able to replace. The value is computed when the screen is drawn; the screen does not redraw itself as time passes. |
unknownTemplateValueKind |
render-empty |
a value of a template is of a kind you do not know, is not an object, or is a known kind of the wrong shape | Its place is empty, without braces; the rest of the sentence stays. The name is declared, so printing {name} would be untrue. |
emptyBoundString |
use-fallback |
the data has an empty string "" for a binding |
It is “no value”: show the fallback. A string of spaces is a value and is shown as is. The order of data layers does not change. |
dateValueForm |
iso-8601-with-time-and-zone-else-as-is |
deciding whether a value is a date | Only a string that matches x-template.dateTimePattern (ISO 8601 with a time and a zone) is a date, for date, dateTime, time and daysUntil alike. Anything else is shown as is. |
pluralNumberWithoutData |
numeric-fallback-else-other |
the value a plural agrees with has no data | If its fallback is shown and looks like a decimal number (0, 12, 1.5), that number picks the form; otherwise other. |
numberBeyondSafeInteger |
render-as-is |
a number whose absolute value is above 2^53 − 1 | number and currency do not apply — show the value as is; a plural takes other. |
missingNumberLocaleData |
render-as-is-days-as-plain-integer |
the platform has no number data for the formatting locale | number and currency: the value as is. daysUntil: the counted days in plain digits, no separators. |
unknownIconBesideText |
empty-box-keeps-gap |
an icon beside a text (button label, badge label, trailing text of a list row) is named, but the renderer cannot draw it — the name is not in its icon set, or the value is not a string | An empty box of the same size and the gap stay; the text does not move. "No icon" is only the absence of the field. Since 9.5.0. |
emptyTextBesideIcon |
keep-icon-and-gap |
the text beside an icon is empty (a binding with no data and an empty fallback) | The icon and the gap stay as they are — one unconditional rule. Since 9.5.0. |
iconButtonWithoutLabel |
draw-and-name-by-icon |
an IconButton whose accessible name is empty or only spaces (the builder does not publish such a document) |
Draw the button, keep it working, and use the icon name in words as the accessible name: chevron-left → "chevron left". Since 9.5.0. |
missingDateLocaleData |
render-as-is |
the platform has no locale data for dates (Intl.DateTimeFormat.supportedLocalesOf is empty) |
date, dateTime and time show the value as it arrived. Without this a date is written in the language of the machine. Since 9.5.0. |
dateCalendar |
gregory |
any date format | The calendar is always Gregorian (calendar: 'gregory'): Thai shows the year 2026, not 2569. Digits stay those of the language. Since 9.5.0. |
dateValueValidity |
calendar-checked-by-reader-code |
a date value | After the shape pattern, check the groups yourself (x-template.dateCalendarCheck): month 1–12, day within the month of that year, hour 0–23, minute and second 0–59 (no leap second), offset 0–23:0–59. 30 February, month 13, 24:00 and :60 are not dates: show the value as it arrived. Do not rely on Date.parse. Since 9.5.0. |
numberRounding |
half-away-from-zero-over-shortest-decimal |
a number written by number or currency |
Round half away from zero over the shortest decimal text that reads back as the same number (String(1.005)): 1.005 is 1.01 with two digits, 1.0005 is 1.001 with three. Since 9.5.0. |
pluralFractionOperands |
cldr-with-fraction-digits |
choosing a plural form for a fraction | Use the full CLDR rule with the fraction digits: fr 1.5 is one, en 1.5 is other. Do not round to an integer before the rule. Since 9.5.0. |
spanWithoutPlace |
ignore-style |
Text.spans names a style for a name the template of content does not declare, or content is not a template at all (the builder does not publish such a document) |
Skip that style and draw the text as without it. Since 9.6.0. |
unreadableSpans |
no-spans |
Text.spans is not an object |
No parts: the text is drawn in the one style of the node. Since 9.6.0. |
unreadableSpanStyle |
base-style |
the style of a part is not an object, is empty, or has no field the renderer can read | The place is drawn in the style of the node. Since 9.6.0. |
unknownSpanField |
ignore-field |
a field of a part style the renderer does not know (a later minor), or a known field of a wrong form | Skip the field and apply the rest of the style. Since 9.6.0. |
unusedSpan |
not-drawn-still-measured |
a place has a style, but the string of the shown language does not mention it | Nothing is drawn for it. The part still counts when the line pitch is computed, so the height does not change between translations. Since 9.6.0. |
emptySpanText |
no-run-neighbours-merge |
a styled place gives an empty text (an empty fallback, an empty plural form) | There is no piece for it; neighbouring pieces with the same style are merged. Since 9.6.0. |
richTextSemantics |
single-text-node |
a text with parts, for a screen reader | One node with the whole string; parts are not announced separately. Since 9.6.0. |
standardActionExecution |
host-only-reader-opens-nothing |
an openUrl, deeplink or close action is pressed |
The renderer opens and closes nothing by itself. The action is handed to the host, which performs it. Since 9.8.0. |
standardActionParams |
checked-by-form-before-handover |
the parameters of such an action | Check them against the forms of x-actions.forms (the patterns and the list of forbidden schemes), not with a platform URL parser, and hand the action over parsed. Since 9.8.0. |
invalidStandardAction |
hand-over-marked-invalid |
a parameter failed the form | Hand the action over anyway, marked invalid (param, problem), without the parsed part. It is never run. Since 9.8.0. |
actionParamBinding |
non-empty-string-else-literal |
action.paramBindings names a data path for a parameter |
Read the path with the data scope of the node when it is pressed. A non-empty string of at most 2048 characters replaces the literal in params; anything else leaves the literal. Since 9.8.0. |
closeAction |
host-closes-flow-stack-untouched |
a close action is pressed |
Hand it to the host and leave the screen stack as it is — on the first screen, deeper, or on a modal sheet. One step back is navigate.back. Since 9.8.0. |
actionTrack |
once-per-press-before-action |
the pressed action carries track |
Give the host the event exactly once, before the action is performed or handed over — also when the action is none or the host cancels the navigation. Since 9.8.0. |
screenShown |
once-when-top-of-stack-changes |
the top of the screen stack became another frame | Fire the screen-shown event once, with the cause of x-screenShown.transitions. A re-render, new data or a handed-over action is not a show. Since 9.8.0. |
screenShownOnBack |
new-show-with-cause-back |
the user goes back, or a modal sheet is closed | The screen beneath is shown again: a new event with cause back. There is no "hidden" event. Since 9.8.0. |
screenShownOnServer |
never-on-server |
the screen is rendered on a server | No screen-shown event. The client that shows the screen fires entry. Since 9.8.0. |
carryRecord |
page-opens-with-node-record |
a navigate action has carryRecord: true |
The opened page gets the record scope of the node even outside a list row. Without the field navigateOutsideListItem applies. Since 9.8.0. |
unbindableParamBinding |
ignore-binding-use-literal |
paramBindings names a parameter that the catalog says does not come from data (navigate.screenId) |
Ignore the binding; the literal in params acts. The target of a transition is a structure of the document, and the validator checks it. For an action of your own (not in the catalog) bindings work as before. Since 9.8.0. |
keyAutoRepeat |
not-a-press |
the user holds an activation key (Enter, Space) and the platform repeats the key event | Only the first key press is a press. A repeat (KeyboardEvent.repeat, KeyRepeatEvent) runs no action and sends no analytics event; on a real <button> prevent the default of the repeated keydown, or the browser sends a click. A double click is two presses. Since 9.8.0. |
stackScreenGone |
restart-from-entry |
the host gives a new version of the same document (same documentId and entry) in which a screen of the stack no longer exists |
Drop the whole stack and show the entry screen of the new version: a screen-shown event with cause entry, depth 1. No ghost frames are left. Since 9.8.0. |
actionParamComposition |
none |
a parameter of an action | A parameter is either wholly a value from data or wholly a literal; nothing is glued together, so a document cannot put host data into an address of another domain. A later minor that adds a template to a parameter must add a list of readable paths given by the host. Since 9.8.0. |
imageNamedHeight |
box-is-named-ratio-not-read |
an image has a named style.height |
The box is the named one; aspectRatio is not read. The proportion gives the height only when nothing names it. Since 9.9.0. |
unreadableImageRatio |
field-default |
aspectRatio is neither a word of the list nor a pair of positive numbers |
Use 16:9. Never the natural proportion of the file. Since 9.9.0. |
imageRatioOutOfRange |
clamp-to-range |
an own proportion is outside x-style.layout.image.ratioRange |
Clamp it to the nearest limit (1 : 8 or 8 : 1). Since 9.9.0. |
appBarScroll |
fixed-only-the-body-scrolls |
a screen with an app bar is scrolled | The app bar — the first child of the screen root — stays at the top; only the body of the screen scrolls, clipped at the bottom edge of the bar. Do not scroll the bar with the content and do not let the content under it. Rules: x-style.layout.screen. Since 9.9.0. |
appBarChildren |
icon-buttons-in-order-others-not-drawn |
an app bar has children | Draw every IconButton child in the trailing zone, in document order. Do not draw a child of another type. Since 9.9.0. |
appBarExtraActions |
draw-first-two |
an app bar has more than two actions (a document the builder would not publish) | Draw at most two actions: the first two visible ones in document order (an action hidden by a condition is not counted). Draw no more — with eight actions the title would have no width. Since 9.9.0 (a clarification before release). |
appBarActionColor |
own-role-not-bar-color |
the app bar names style.color |
It colors the title and the back arrow. An action keeps the color of its own role or its own style.color. Since 9.9.0. |
unknownModalPlacement |
bottom |
modalPlacement is a word the renderer does not know |
Show a bottom sheet, as without the field. Since 9.9.0. |
modalPlacementOnPage |
ignore |
modalPlacement stands on a screen that is not modal |
Do not read it. Since 9.9.0. |
modalLayerLook |
screen-root-style-nothing-built-in |
a modal layer is drawn | No built-in background, radius or shadow: the look is the style of the screen root. A root without a background is a transparent layer. Since 9.9.0. |
modalDismiss |
navigate-back |
the scrim is tapped, the system back is used, Esc is pressed | One step of the stack — the same navigate.back a button performs. Since 9.9.0. |
modalOverflow |
body-of-the-layer-scrolls |
the content is taller than the available height of the layer | The body of the layer scrolls and its app bar stays; the layer does not grow beyond the screen. Since 9.9.0. |
statusBarIcons |
handed-to-host-reader-touches-nothing |
a shown screen names statusBarIcons |
Hand the request to the host in the screen-shown event. Never change the system status bar from the renderer. Since 9.9.0. |
unknownStatusBarIcons |
no-request |
statusBarIcons is a word the renderer does not know |
There is no request; the host decides. Since 9.9.0. |
layerOutsideStack |
ignore |
layer stands on a node that is not a direct child of a Stack |
Do not read it: an ordinary node. Since 9.10.0. |
layerOnFill |
fill-wins |
a layer has both fill: true and layer |
The layer takes the whole stack; layer is not read. Since 9.10.0. |
noSizingLayer |
ignore-layer |
every layer of a stack is an overlay or a stretched one | Read layer on none of them; then allLayersFilled applies. Since 9.10.0. |
unreadableLayer |
ordinary-layer-unreadable-part-dropped |
layer is not an object, or a part of it is not readable |
Not an object — an ordinary layer. An unknown alignment word — the alignment of the stack; an unreadable offset axis — no offset on that axis; an unreadable angle — no rotation. Never clamp. Since 9.10.0. |
layerClip |
existing-clippers-only-stack-itself-does-not-clip |
an overlay sticks out of its stack | It is cut only by a node with a radius, a node with a named width or height, a scroll, the screen root. The stack itself does not cut. Since 9.10.0. |
layerTaps |
where-drawn-inside-the-stack-box |
an overlay with an action is tapped | It is hit where it is drawn, inside the box of the stack. Nothing is promised about the part that sticks out. Since 9.10.0. |
pullOutsideFlow |
ignore |
pull stands on a child of a grid, a stack, a wrapping or a scrolling row |
Do not read it. Since 9.10.0. |
pullWithWeight |
weight-wins |
a node has both a readable weight and pull |
The weight gives its place; pull is not read. Since 9.10.0. |
unreadablePull |
no-pull |
pull is neither a token name nor a whole number from 0 to 128 |
No pull. Never clamp. Since 9.10.0. |
pullPaintOrder |
later-over-earlier |
a pulled child overlaps the one before it | The later child is drawn over the earlier one and takes the taps in the overlap. Since 9.10.0. |
carouselOutsideHost |
ignore |
carousel stands on a row without scrollable, or on a data list that is not laid out in a row (or has columns) |
Do not read it: the node is drawn as without the field. Since 9.11.0. |
unreadableCarousel |
plain-scroll-unreadable-part-default |
carousel is not an object, or a part of it is not readable |
Not an object — no carousel, the row scrolls as before. perView that is not a whole number from 1 to 6 — natural widths; an unreadable peek — the default; any indicator word but none — dots. Since 9.11.0. |
carouselCardWidth |
carousel-wins-over-own-width |
a card of a carousel with perView names its own width, minWidth or maxWidth |
The carousel gives the width; the three fields of the card are not read. Since 9.11.0. |
carouselNested |
inner-takes-the-gesture |
a carousel stands inside a node that scrolls sideways, or a scrolling row stands inside a carousel | The builder rejects such a document. A renderer that got one gives the swipe to the inner node. Since 9.11.0. |
carouselState |
reader-state-starts-at-first-stop |
which card of a carousel is open | The renderer keeps it, the document does not: start at the first stop, keep it while the screen is in the stack, clamp to the last stop when the data gets shorter. Since 9.11.0. |
carouselGestures |
along-row-carousel-across-screen |
a swipe starts on a carousel | Along the row it scrolls the carousel, across it scrolls the screen; the larger component at the start of the move decides. Since 9.11.0. |
carouselIndicator |
decorative-position-told-by-the-list |
the dots under a carousel | Not a control and not announced: the position ("3 of 5") is told by the list of cards. Since 9.11.0. |
carouselReducedMotion |
no-animated-programmatic-scroll-snap-stays |
the system asks for reduced motion | Do not animate a scroll the finger did not start; snapping stays. Since 9.11.0. |
unreadableSegments |
continuous-bar |
segments is not a whole number from 2 to 12, or a step would be narrower than one pixel |
Draw the continuous bar with the same ratio. Since 9.11.0. |
segmentsSemantics |
one-scale |
a screen reader meets a stepped progress bar | One scale with a value and a limit, not a row of elements. Since 9.11.0. |
navBarNotLast |
ordinary-child |
a NavBar is not the last child of the screen root |
Draw it where it stands, as an ordinary child of the scrolling body. The builder never publishes such a document. Since 9.12.0. |
navBarInModalLayer |
not-drawn |
a screen with a NavBar is shown as a modal layer |
Do not draw the bar. The same screen shown as a page (the first of the stack) has it. Since 9.12.0. |
navBarExtraItems |
first-five-visible |
a NavBar has more than five visible items |
Draw the first five visible ones in document order. Since 9.12.0. |
navBarNoActiveItem |
none-active |
no item of the bar switches to the shown screen | No item is active; the bar works as usual. Since 9.12.0. |
pinElsewhere |
ignore |
pin stands on a node that is not the last child of the screen root (not counting the NavBar) |
Do not read the field: the node is an ordinary child. Since 9.12.0. |
unreadablePin |
ignore |
pin is a word the renderer does not know, or not a string |
As if there were no field. Since 9.12.0. |
pinWithPull |
pull-not-read |
the pinned node also has pull |
Do not read pull: the bottom zone is not part of the flow. Since 9.12.0. |
sectionSwitch |
stack-reset-to-the-section |
the action navigate.section is pressed |
The screen stack becomes that one screen: everything above it, modal layers included, is closed. One screen-shown event with cause switch. Since 9.12.0. |
sectionSwitchSameScreen |
nothing |
navigate.section names the screen the stack already consists of |
Nothing happens and no event is sent — the active item of the bar pressed again. Since 9.12.0. |
sectionSwitchRecord |
not-carried |
navigate.section is pressed inside a list row |
The record of the row is not carried: a section is not "about this row". Since 9.12.0. |
sectionSwitchScroll |
from-the-top |
the user comes back to a section | It is shown from the top: the renderer remembers neither the stack nor the scroll of a section. Since 9.12.0. |
sectionTargetModal |
shown-as-page |
navigate.section names a screen declared modal |
Show it as a page: it is the bottom of the stack. The builder never publishes such a document. Since 9.12.0. |
unknownSectionTarget |
host |
navigate.section names a screen the document does not have |
Hand the action to the host and show nothing — as with navigate. Since 9.12.0. |
backOnSection |
host-as-on-the-first-screen |
the user goes back on a section screen | The same as back on the first screen: the host decides. The renderer does not remember the previous section. Since 9.12.0. |
bottomSafeInset |
reader-adds-under-the-bottom-zone |
the screen has a bottom zone and the device has a bottom safe inset | The renderer adds the inset itself, under the content of the bottom zone; the background of the navigation bar reaches the physical bottom. A host that accounted for the inset gives zero; a screen without a bottom zone gets nothing. Since 9.12.0. |
bottomZoneKeyboard |
pinned-above-nav-bar-covered |
the screen keyboard is shown | The pinned block rides above the keyboard, the navigation bar stays under it. Named ahead: 9.12 has no text input. Since 9.12.0. |
inputWithoutValue |
local-only-writes-nothing |
an input (TextField, Switch, Checkbox) has no value |
The input works on the screen — it takes text, it toggles — and writes nowhere: not to the session, not to the host. The builder never publishes such a document. Since 9.13.0. |
unreadableInputValue |
local-only-writes-nothing |
value of an input is not { kind: "binding", path } with a non-empty string path |
The same: the input is local. An extra key next to the path is not a broken form. Since 9.13.0. |
inputValueOfOtherType |
empty-field-or-off |
the value under the path is not a string (text field) or not true (switch, checkbox) |
An empty field; an off toggle. Do not turn a number into text: the field would write a string where a number lives. Since 9.13.0. |
inputEmptyString |
empty-field |
the value under the path of a text field is "" |
An empty field. An input has no fallback text (unlike emptyBoundString for a text on screen). Since 9.13.0. |
inputWrite |
on-every-change-to-session-and-host |
the person changes an input | Write the value under the path of the input to the session of the renderer and hand it to the host at the same moment, on every change. The renderer adds no delay; throttling is the host's. Since 9.13.0. |
inputWriteWhileComposing |
after-the-composition |
text is being composed (IME) | The intermediate text is shown and not written; the result is written. Since 9.13.0. |
inputHeldValue |
while-focused-or-until-bound-value-changes |
the data under the path changes, or the path is read from a stronger layer than the session | A focused field shows what was typed, whatever happens to the data. Without focus an input shows what the person entered until the value of the layers changes, then that value. Since 9.13.0. |
inputBeforeFirstTouch |
not-kept-not-written |
the platform fills a field before the person touched it | The value is neither kept in the field nor written. After the first focus or keystroke a suggestion the person picks is theirs. Since 9.13.0. |
inputDisabled |
not-focusable-writes-nothing |
disabledIf of an input holds |
The input takes no focus, is not pressable and writes nothing; the whole widget is drawn with the opacity of x-style.layout.inputs.rules.disabled. Since 9.13.0. |
unknownDisabledOp |
enabled |
disabledIf has an operator the renderer does not know |
The input is enabled: not knowing the question must not take the field away from the person. Since 9.13.0. |
missingDisabledValue |
as-missingConditionValue |
the value disabledIf asks about is missing |
The same rule as missingConditionValue: isEmpty holds, the rest do not. Since 9.13.0. |
unknownInputKeyboard |
text |
keyboard of a text field is a word the renderer does not know (password, a word of a newer minor) |
The text keyboard. The field does not become a hidden one. Since 9.13.0. |
inputMaxLength |
cut-to-code-points |
more is entered than maxLength |
Cut the input to the limit, counted in Unicode code points, and write the cut text. Do not use the platform limit: it counts differently. No counter is drawn. Since 9.13.0. |
unreadableInputLines |
one-line |
lines is not a whole number from 1 to 8 |
A one-line field — not the nearest bound. Since 9.13.0. |
inputError |
heard-text-is-the-error-state |
the text of error resolves to something a person can hear |
The field is in the error state: the error text stands in place of the helper, the field is marked invalid. Empty text, spaces and invisible characters are no error. Since 9.13.0. |
inputKeyAction |
next-or-done-never-an-action |
the person presses the action key of the keyboard | "Next" moves focus to the next shown, enabled text field of the screen; otherwise "done" removes focus. A multiline field takes a new line. The key never performs an action of the document. Since 9.13.0. |
inputInsideRepeater |
drawn-writes-by-its-path |
an input stands inside a Repeater |
Draw it in every instance; each writes to its path as it is. The builder never publishes such a document. Since 9.13.0. |
inputFocusOnScreenChange |
removed |
the shown screen changes while a field has focus | Focus is removed and the keyboard is hidden. Since 9.13.0. |
inputAutofill |
from-keyboard-intent-host-can-turn-off |
a text field may get an autofill hint | Only the hint its keyboard intent names (email, telephone, web address). Never credentials, a payment card, a postal address or a one-time code. The host can turn every hint off. Since 9.13.0. |
inputValueInActionParam |
whole-value-no-composition |
an action reads the value of an input as a parameter | The parameter is the whole value or a literal: there is no composition, so a document cannot put typed text into an address of another domain. Since 9.13.0. |
unreadableRequired |
not-required |
required of an input is not the literal true (a value of a newer minor) |
The field is not required. Since 9.14.0. |
requiredHiddenOrDisabled |
not-counted |
a required field is hidden by visibleIf (its own or of any ancestor) or disabled by disabledIf |
It is not counted: the form may be filled without it — the person cannot fill it. Since 9.14.0. |
requiredWithoutPath |
never-filled |
a required field has no value path, or a required ChoiceGroup has a literal value |
It is never filled, so a control with requiresFilled of that screen stays disabled. The builder refuses such a group (required_without_path). Since 9.14.0. |
requiredInInactiveTab |
counted |
a required field stands in a tab that is not open | It is counted: the person can open the tab. Since 9.14.0. |
requiredMessage |
none |
a required field is empty | The renderer shows no text of its own and does not put the field into the error state; only the mark beside the label and the state of the button. Since 9.14.0. |
unreadableRequiresFilled |
not-read |
requiresFilled of a button is not the literal true |
The field is not read: the button does not depend on the form. Since 9.14.0. |
requiresFilledWithoutRequired |
enabled |
a button has requiresFilled, the screen has no required field that is counted |
The form is filled; the button is enabled. Since 9.14.0. |
disabledPress |
no-action-no-track-stays-focusable |
a disabled Button or IconButton is pressed (touch, Enter, Space) |
Nothing: no action, no track event, no press feedback. The button stays in the focus order and is announced as disabled (aria-disabled on the web, not the disabled attribute). Since 9.14.0. |
unknownInputValueType |
string |
valueType of a text field is a word the renderer does not know |
A string field, as without the field. Since 9.14.0. |
numberInputText |
number-else-empty-string-typed-text-kept |
the text typed into a number field is not a number by x-style.layout.form.number.parse, or the field is cleared |
Write the empty string "" — "no number" — and keep the typed text in the field. A number is written as a JSON number. Since 9.14.0. |
numberInputValueOfOtherType |
empty-field |
the value under the path of a number field is not a finite number within the safe integer (a string, even "12") |
An empty field. A string is never converted to a number. Since 9.14.0. |
numberInputOptions |
keyboard-and-lines-not-read |
a number field has keyboard or lines |
Neither is read: the numeric keyboard, one line. Since 9.14.0. |
choiceWrite |
to-session-and-host-like-inputs |
the person picks an option of a ChoiceGroup whose value is a binding |
Write the option to the session of the renderer and hand it to the host at the same moment, like every input; conditions and buttons of the same screen see it at once. The option already shown as chosen is not written again. Since 9.14.0. |
nonStringActionParam |
literal-from-params |
the value under a path of paramBindings is not a non-empty string (a boolean, a number of a number field) |
It does not become the parameter; the literal from params acts, as x-actions.paramBinding says. Since 9.14.0. |
overlongText |
render-as-is |
text is longer than the recommended length | Draw it. The recommended length is advice to the manager, not a limit. Overflow is governed by the widget's own properties. |
unknownEntryPoint |
use-initial |
the host asks for an entry-point name the document does not have | Open the initial screen. A name lives longer than a document: it may already be in a push notification. |
unknownStyleField |
ignore-field |
style has a field you do not know, from a newer minor |
Skip that field and draw the rest of the style. One property is lost, not the look of the node. |
gradientStopOrder |
non-descending |
order of gradient stops | Each position is not less than the previous one. Equal positions are in order — a hard color edge. The same word as in the theme policy. |
unorderedGradient |
do-not-draw |
a custom gradient in a document has stops that go backwards | Do not draw the gradient. Do not sort the stops: sorting hides a broken document and changes the colors of the transition. |
diagonalGradientGeometry |
css-to-corner |
a gradient angle of 45, 135, 225 or 315 | Exactly CSS to <corner>; see x-gradientGeometry and the theme page. |
invertedSizeRange |
min-wins |
minWidth is above maxWidth, or minHeight above maxHeight |
Use the minimum. CSS does this by itself; Flutter's BoxConstraints throws on such a pair. |
unknownBoxToken |
omit-field |
ONE padding, margin or radius value (not a shape with sides) names an undeclared token |
Do not apply the field: a button keeps its built-in padding, a container gets none. |
unknownSideToken |
side-is-zero |
one side of padding or margin, or one corner of radius, in a shape with sides names an undeclared token |
That side or corner is zero, the others are kept. A shape with sides is always applied, even when every side is undeclared. |
malformedSideValue |
side-is-zero |
a side or a corner has a broken form of value — neither a token nor a number (an object, true, null, an array) |
The same answer as for an undeclared token: that side or corner is zero, the others are kept. Until 9.0.0 this sentence lived only in a comment. |
unknownInheritedToken |
keep-parent |
style.color or style.typography names an undeclared token |
It applies neither to the node nor to its descendants: the parent's value stays, and the color does not count as named. |
boundSizeOutOfRange |
use-fallback |
a size bound to data is not a finite number, or outside 1–1200 | Draw the fallback of the binding. Do not clamp: the author chose the fallback. |
sizeOutsideRange |
clamp-to-range |
width / height lies outside the node's own min… / max… (width: 300 with maxWidth: 200) |
A legal document, not an error: clamp the size into the range, the smaller bound being the stronger (the ceiling first, then the floor). For an inverted pair the two bounds are already equal to the minimum (invertedSizeRange). |
literalOutOfRange |
clamp |
a numeric literal outside the limits of the schema (padding: -8, opacity: 1.5, blur: -5) |
Clamp it into the limits; do not drop the field. A negative padding crashes Flutter's EdgeInsets. |
nonNumericLiteral |
lower-bound |
a value that is not a number where a number within limits is expected (opacity: "oops", blur: "x", NaN) |
Use the lower bound of the field — opacity 0, a size 1, a shadow offset −64 — not zero and not "do not apply". A renderer takes the limits from minimum / maximum of the schema. A field that is absent from the document is not covered: its default applies. The rule covers every bounded numeric field, in style and in props alike: Repeater.limit: 0 is 1 (not an empty list), Text.maxLines: "x" is 1 (not "no limit"), columns is 2. Three exceptions have their own keys: a side of a spacing (malformedSideValue), weight (invalidWeight) and an explicit null in an optional field (nullNumericField). |
nullNumericField |
absent |
an explicit null in an optional numeric field ("opacity": null, "maxLines": null, "width": null) |
Read it as a missing field, not as "not a number": opacity: null does not make the node invisible. In a required field (a gradient stop position, a border width) null stays "not a number" and takes the lower bound. |
invalidWeight |
no-weight |
weight is not a number, is zero, is negative or is not an integer (1.5, infinity) |
The node has no weight and takes its own width. weight is the one bounded numeric field where nonNumericLiteral does not apply: a share of the row out of nothing would take space from the siblings. A fractional weight is not rounded: flex is an integer in Flutter, and CSS would happily apply a fractional one. A positive integer outside 1…4 is clamped (literalOutOfRange): weight: 7 is 4. |
missingShadowLayerField |
zero |
a layer of a custom shadow has no offsetX / offsetY / blur / spread (the field is missing or null) |
Read the missing field as zero, not as the lower bound (for an offset that is −64). The schema requires all four; the rule is for a renderer that does not validate. |
unknownBackgroundKind |
omit-field |
style.background has a kind you do not know |
No background; draw the rest of the style. |
unknownWidgetStyle |
ignore-style |
an unknown widget carries a style |
Ignore the style entirely — no box, no inherited color — and draw the children in the context of the parent. |
hiddenInitialTab |
first-visible |
Tabs.initialTab names a tab hidden by visibleIf |
initialTab counts ALL tabs in document order, hidden ones too. If the named tab is hidden, open the first visible one. |
initialTabOutOfRange |
first-visible |
Tabs.initialTab is negative, fractional or past the last tab |
The same answer as for a hidden tab: open the first visible one. Both rules are one function. |
screenBoxFields |
ignore |
the root of a screen carries margin or a size |
Ignore those fields (x-style.screenExcluded); apply the rest of the style. |
scrollPadding |
inside-scroll |
padding of a screen or of a scrollable row |
The padding lies inside the scroll: the content reaches the edge, it is not cut by a padding frame. |
sizedBoxOverflow |
clip |
content is larger than the style.width / height of its box |
The box clips it. A given size is exact, not a minimum. |
allLayersFilled |
ignore-fill |
every layer of a stack has fill |
fill does not apply; every layer is drawn at its own size. |
fillOutsideStack |
ignore |
fill: true on a node that is not a direct layer of a Stack |
Ignore fill: draw the node as if the field were absent, its own size kept. It applies only to direct children of a stack (x-style.fillScope); a grandchild of a stack is not a layer. |
scrollableRowChildWidth |
row-viewport |
a child of a Row with scrollable |
The child is at most max(row-viewport, own-width) wide (x-style.layout.scrollableRow.childMaxWidth): the visible width of the row, unless its own width is larger. Stretching content — the list in stretchToViewport: a Button with fullWidth, a horizontal Divider, a horizontal Spacer, a ProgressBar, a Grid — takes the width of the visible area, not infinity. The row scrolls instead of clipping (overflow: scroll-instead-of-clip). |
sizedChildInTightSlot |
start-top |
a node with its own width or height in a tight slot — a cell of a Grid, or a cell of a Repeater that has columns (x-style.layout.tightSlots) |
It stands at the logical start and the top; it is neither centered nor stretched. A Repeater without columns is a plain column or row: it has no tight slots. |
naturalWidthInTightSlot |
natural-start-top |
a widget of natural width in a tight slot — Badge, Button with fullWidth: false, Icon, CircularProgress, a vertical Divider or Spacer (x-style.layout.column.naturalWidth, referenced by x-style.layout.tightSlotNaturalWidth) |
It takes its natural width, no wider than the slot, and stands at the logical start and the top — exactly where a node with its own size stands. Every other child takes the width of the slot. |
columnChildWidth |
stretch-unless-natural |
a child of a column parent — Screen, Column, Container stacked as a column, Tab, Accordion, ChoiceGroup (x-style.layout.column.parents) |
The child is stretched: it takes the content width of the parent minus its own margin. Only two kinds of node keep their natural width: a node with a style.width of its own, and the widgets listed in x-style.layout.column.naturalWidth (Button with fullWidth: false, Badge, Icon, CircularProgress, a vertical Divider, a vertical Spacer). Those stand where the parent's align says, and at the logical start in a parent without that prop. minWidth / maxWidth of a stretched child limit the stretched width; a child narrower than the parent because of maxWidth follows the same alignment. A Repeater stacked as a column is not a column parent: it always stretches its item. |
rowInUnboundedWidth |
natural-width-ignore-weights |
a row whose parent does not give it a tight width — a row that is itself any child of a row without weight, a child of a wrapping or a scrollable row, a layer of a Stack without fill, a row inside a column that has no definite width while that column measures its natural width |
The row is as wide as the natural widths of its children plus the gaps; shares do not apply in it — neither the weights of its children nor the share of stretching content — and every child takes its natural width. A width given only as an upper bound is not tight. In Flutter Expanded under an unbounded width is an assertion failure; in CSS flex simply has nothing to divide. |
unweightedRowChild |
natural-shrink-proportionally |
a child without weight in a row that divides its width (Row without wrap and scrollable, Container stacked as a row) |
It takes its natural width: the content on one line, but no wider than the content of the row. When the children without a weight do not fit together, they shrink in proportion to their natural widths. Two kinds do not shrink: a node with a style.width in effect (the row then overflows and is clipped) and the widgets listed in x-style.layout.column.naturalWidth. Shares divide what is left after the children without a weight; if nothing is left, a share is zero. Data: x-style.layout.row. |
unweightedStretchInRow |
share-of-one |
stretching content — Button with fullWidth: true, a horizontal Divider or Spacer, ProgressBar, Grid (x-style.layout.scrollableRow.stretchToViewport) — without weight and without style.width, in a row that divides its width |
It has no natural width and takes what is left as a share of weight 1 (x-style.layout.row.stretchShare). In a flex row of CSS a progress bar is otherwise zero pixels wide. In a row of natural width (rowInUnboundedWidth) that share does not apply, as a weight does not: the node takes the width of its own content, which for a bar without a label, a horizontal line and a spacer is zero. |
justifyInNaturalRow |
nothing-to-distribute |
justify (center, end, between) of a row that is as wide as its content (rowInUnboundedWidth) |
It distributes nothing: such a row has no free space. The place of a row that is a layer of a Stack is set by alignHorizontal / alignVertical of the stack. To make justify work, give the row a width: fill, weight or style.width. |
protoKeyWithoutValidation |
drop-entry |
an own key __proto__ in an open map of the document (entryPoints, action.params, the translation table), read by a renderer that did not validate the document |
Drop that entry — the key does not enter the map — and draw the rest. The validator refuses such a document as a whole; a renderer that draws without validating does not. |
What "unknown token" means since 9.0.0#
Before 9.0.0 the schema held a closed list of token names, and an unknown token was a name from a newer minor. The list is gone: the theme declares the names. An unknown token is now a name the theme you are drawing with does not declare — it was removed from the project theme, or one theme of the set lacks a name another has.
The builder does not publish such a document. A renderer still has to know what to do with one:
| Where the undeclared token is | What to do |
|---|---|
a field of props (gap, Divider.color, ProgressBar.color, …) |
the default of the field |
a field of style |
do not apply the property |
style.color or style.typography |
keep the inherited value, for the node and everything under it (unknownInheritedToken); the color does not count as named |
one padding / margin / radius value for all sides |
do not apply the field (unknownBoxToken) |
one side of padding or margin, one corner of radius, in a shape with sides |
that side or corner is zero; the others are kept (unknownSideToken); the shape is always applied |
style.background with an unknown kind |
no background (unknownBackgroundKind) |
an unknown widget with a style |
the style is ignored entirely (unknownWidgetStyle) |
| a stop of a custom gradient, a layer of a custom shadow | do not draw the gradient or the shadow at all |
locked, inProgress, completed missing from the theme |
not an unknown token: use the documented fallback, see theme |
Never substitute a similar token. An invented color looks like a correct one.
A renderer therefore needs the theme itself at render time, not only its values: whether a token exists is a question to the theme.
Order of two rules#
unknownVisibilityOp is stronger than missingConditionValue.
A node with an unknown operator stays visible even when the data is missing. The two cases are different: a missing value means "I know the question and have no answer"; an unknown operator means "I do not know the question". Check that the operator is one you know before you look at the value.
Why a missing value counts as empty#
One rule for all operators would apply to an operator and to its negation alike. On a screen without data both halves of a branch — "the list" and "nothing here yet" — would disappear.
"Empty" is the only question that absence answers. An absent flag is neither true nor false.
Check a list by its length, not by its text: an empty collection is empty.
Catalogs of what appeared when#
x-compatibility has two more maps. They let a renderer tell "from a newer version" from "broken document".
widgets — widget type → the schema version it appeared in.
| Since | Widgets |
|---|---|
| 1.0.0 | Screen, Column, Spacer, Divider, Text, Image, Button, ListTile |
| 2.0.0 | Row, Container, Icon, ProgressBar, Badge |
| 3.0.0 | Accordion |
| 6.0.0 | AppBar, ChoiceGroup |
| 8.4.0 | Repeater |
| 8.5.0 | Grid |
| 8.6.0 | Stack |
| 8.7.0 | CircularProgress |
| 8.8.0 | Tabs, Tab |
features — a capability that is not a widget → the schema version it appeared in.
| Feature | Since |
|---|---|
localization |
7.2.0 |
unboundedText |
8.0.0 |
containerDirection |
8.1.0 |
boundImageSource |
8.2.0 |
nodeStyle |
8.3.0 |
nodeStyleCases |
8.9.0 |
entryPoints |
8.10.0 |
rowScroll |
8.11.0 |
containerFrame |
8.11.0 |
imageFit |
8.11.0 |
modalScreens |
8.12.0 |
repeaterGrid |
8.13.0 |
stackFill |
8.14.0 |
rowWeights |
8.17.0 |
missingDataIsEmpty |
8.18.0 |
elementStateColors |
8.19.0 |
bindingFormat |
8.20.0 |
openTokens |
9.0.0 |
tokenOrValue |
9.0.0 |
sharedNodeStyle |
9.0.0 |
localeLookup |
9.1.0 |
recordScope |
9.2.0 |
iconLibrary |
9.3.0 |
textTemplate |
9.4.0 |
numberFormats |
9.4.0 |
iconBesideLabel |
9.5.0 |
iconBesideTextRule |
9.5.0 |
textSpans |
9.6.0 |
templateWord |
9.6.0 |
progressLook |
9.7.0 |
ringDiameterFromBox |
9.7.0 |
progressGeometry |
9.7.0 |
actionCatalog |
9.8.0 |
actionParamBinding |
9.8.0 |
actionTrack |
9.8.0 |
screenShownEvent |
9.8.0 |
carryRecord |
9.8.0 |
imageCustomRatio |
9.9.0 |
imageBoxGeometry |
9.9.0 |
appBarActions |
9.9.0 |
appBarFixed |
9.9.0 |
appBarTitleRoom |
9.9.0 |
modalPlacement |
9.9.0 |
modalLayerLook |
9.9.0 |
statusBarIcons |
9.9.0 |
layerPlacement |
9.10.0 |
flowPull |
9.10.0 |
carousel |
9.11.0 |
progressSegments |
9.11.0 |
bottomNavigation |
9.12.0 |
pinnedBottom |
9.12.0 |
sectionSwitch |
9.12.0 |
bottomSafeInset |
9.12.0 |
textField |
9.13.0 |
toggles |
9.13.0 |
inputDisabledIf |
9.13.0 |
inputRequired |
9.14.0 |
pressDisabled |
9.14.0 |
numberInput |
9.14.0 |
The three 9.0.0 entries are there for the record, not for degradation: 9.0.0 is a major version, and a renderer of 8.x does not open a 9.x document at all.
How to use them with an unknown widget type:
| The type is | Meaning |
|---|---|
in widgets, with a version newer than yours |
From a newer contract. Expected. Draw the children. |
in widgets, with a version you support |
Your renderer is behind its own copy of the contract. A bug on your side. |
not in widgets |
Not in the contract at all: a broken document. Draw the children anyway. |
The Flutter renderer mirrors the maps as kWidgetCatalog and kFeatureCatalog, and classifies with widgetOriginOf(type) and supportsFeature(name). In debug builds it draws a labelled placeholder around an unknown widget; in release builds only the children are shown.
What each version added, and what an older renderer loses, is in the changelog.
Other blocks of the schema artifact#
x-budget — the weight of one document.
| Key | Value | Meaning |
|---|---|---|
maxDocumentBytes |
1048576 |
A document is at most 1 MiB. |
measure |
utf8-bytes-of-serialized-json |
Measured as UTF-8 bytes of the serialized JSON. A document is always UTF-8. |
enforcedBy |
producer |
The builder enforces the limit. A renderer does not need to. |
onExceeded |
reject-before-delivery |
A heavier document is rejected before it is delivered. |
observedBytesPerNode |
560 |
Observed average size of a node. Informational. |
approximateNodeBudget |
1900 |
Approximate number of nodes that fit. Informational. |
x-mirroredIcons — the icon names that mirror in a right-to-left script: 29 names since 9.3.0 (chevron, arrow-left, arrow-right, chevron-left, chevron-right, log-out, send, undo, …). Read the list from the artifact. See localization.
x-icons — { "drawings": "contract/icons.json", "names": "IconToken" } (since 9.3.0): where the drawings of the icons are, and which list of the schema names them.
x-template (since 9.4.0) — the limits and parameters of text templates as data: the pattern of a value name and the maximum number of values, the plural categories, the keys translations are stored under, the parameters of the number and currency formats (grouping, fraction digits, the separator before the currency code and its code point), the length of a day and the rounding of daysUntil, which formats give a number a plural can agree with, and where the shared table of cases is (fixtures/template/template-cases.json). See data dictionary.
The icon set#
icons.json is a separate artifact with a version of its own, iconSetVersion (currently 1.0.0). A redrawn icon is a patch of the set and does not touch the schema; a new name is a minor of the set and of the schema. A renderer embeds the set and draws every icon from it — it keeps no pictures of its own.
| Field | Meaning |
|---|---|
icons |
Name → the body of the drawing: the inner elements of an SVG as one string, self-closing, attributes in single quotes. The keys are exactly IconToken. |
grid |
The root every icon shares: viewBox 0 0 24 24, fill: none, stroke: currentColor, strokeWidth: 2, round caps and joins. |
format.elements |
The seven elements a body may contain (path, circle, ellipse, rect, line, polyline, polygon) and the geometry attributes of each. |
format.paint |
The three things an element may say about its look: fill='currentColor' (filled), stroke='none' (no outline), stroke-width='…' (its own width). Everything else comes from grid. |
sources |
Whose drawings these are and under which licence. The notices are in THIRD-PARTY-NOTICES.md. |
currentColor is the foreground color of the node. A drawing never carries a color of its own. The size of an icon did not change: see iconGlyphBox below.
x-tokens — the shape of a token name, the limit of custom names per dictionary, the core names every theme has, and zeroScale: the token none in spacing, radius and elevation is always zero by the structure of a theme (token: "none", sets: ["spacing", "radius", "elevation"]). x-style — how a node's style is read: states, inheritance, the box model, widgets with a role. Both are written out in words in the widget reference.
x-style keys added after the first 9.0.0 draft, each one a decision a renderer would otherwise make alone:
| Key | Value | Meaning |
|---|---|---|
unknownTokenInCase |
keep-base-field |
A field of a state (styleWhen) that the theme would not apply — an undeclared token, a background of an unknown kind, a border with an undeclared color, one padding / margin / radius value with an undeclared token — is dropped before the state is laid over the base style. Otherwise it would replace a good base field and only then fail to apply. A shape with sides always applies. |
tabTitleColorContext |
tab |
A tab title is colored in the context of its own tab, not of the Tabs node. |
sizeIncludes |
["border", "padding"] |
width, height and the min… / max… bounds describe the whole box with its border and padding — CSS border-box, Flutter Container(width:). margin lies outside. |
boxTokenFields |
["padding", "margin", "radius"] |
The fields that take either one value or a shape with sides; the rules unknownBoxToken and unknownSideToken differ for the two forms. |
fillScope |
direct-children-of-stack |
fill acts only on direct layers of a Stack (fillOutsideStack). |
layout |
see below | Lists the policy words refer to. |
roles.neutral |
background: none, border: none, foreground: root-color |
What missingRole: render-neutral means exactly: no background, no border, the label in the root onSurface (not an inherited color). The same when the theme has no roles at all, or a role of the wrong shape. |
roles.roleBorderWidth, roleBorderAppliesTo, withoutBorderSlot |
1, role-with-border-slot, no-border-no-space |
A role border is 1 dp, and only for a role with a border slot that points to a known color; otherwise there is neither a border nor room for one. The builder writes a variable --product-role-<group>-<role>-border-width (0 or 1px) for it. |
x-style.layout — lists, so that a policy word is not a request to invent a list:
| Key | Value | Meaning |
|---|---|---|
iconGlyphTypography |
sm → bodyS, md → titleM, lg → displayL |
The glyph size of an Icon is the font size of that text token (iconGlyphMeasure: typography-font-size). An unknown size is md (defaultIconSize). |
iconGlyphBox |
inline → typography-font-size, block → typography-line-height |
The box of an icon (since 9.1.0): as wide as the font size of the token from iconGlyphTypography and as tall as its line (fontSize × lineHeight). The drawing is a square of the font size, centered. |
listTileIconSizes |
leadingIcon → md, trailingIcon → sm |
The icons inside a ListTile. |
scrollableRow |
childMaxWidth, stretchToViewport, overflow |
The children of a Row with scrollable; see scrollableRowChildWidth. Each entry of stretchToViewport is a type and an optional when ({ prop, equals }); when the prop is absent, the default of the field in the schema is used. |
tightSlots |
Grid; Repeater with columns defined |
Where sizedChildInTightSlot applies. |
tightSlotNaturalWidth |
column.naturalWidth |
Which widgets keep a natural width in a tight slot (naturalWidthInTightSlot): the same list as in a column. |
sizedBoxKeys |
width, height |
The keys that make a box given for sizedBoxOverflow. min… / max… do not. |
column |
childWidth, childWidthWithoutDefiniteParent, parents, naturalWidth, ownWidth, naturalMaxWidth, naturalWidthAlign (prop, withoutProp), constrainedStretch |
The width of a child of a column parent; see columnChildWidth. parents and naturalWidth are lists of the same shape as stretchToViewport; a condition reads the prop with the default of the field from the schema, so a Container without direction is a column. naturalWidthAlign names the prop of the parent that places a child narrower than the parent (align) and the word used where the parent has no such prop (start). |
badgePadding |
block → xs, inline → sm |
The built-in padding of a Badge, as steps of the spacing scale. A style.padding of the node overrides it. |
buttonPadding |
block → sm, inline → md |
The built-in padding of a Button, as steps of the spacing scale. A style.padding of the node overrides it. |
buttonTypography |
titleS |
The text token of a Button label. A style.typography of the node overrides it. |
buttonSize |
label-plus-padding-plus-border |
The size of a button is its label plus the padding plus the border — nothing else. A renderer adds no minimum size and no invisible touch-target padding (in Flutter: minimumSize: Size.zero, tapTargetSize: shrinkWrap); a role border or a style.border is added to the size. A button with the padding sm is 40 dp high, lower than the 48 dp touch target — an open question of the contract, not something a renderer fixes on its own. |
buttonSizes |
sm → xs / md / bodyM, md → sm / md / titleS, lg → md / lg / titleM (block padding / inline padding / text token) |
The geometry of a Button for each props.size (since 9.5.0). md equals buttonPadding and buttonTypography. With the built-in theme a button is 28, 40 or 48 high. A style.padding or style.typography of the node overrides its part. |
defaultButtonSize |
md |
The size of a Button or an IconButton whose size is absent or unknown. The schema has no default for that field, on purpose: a renderer does not write it into the node. |
iconButton |
sizes → buttonSizes, contentBox → typography-line-height-square, glyph → typography-font-size, padding → buttonSizes.padding.block, radius → pill |
An IconButton is a circle: a square as tall as the text line of its size, the drawing as large as the font size in its centre, the block padding of the button of the same size on all four sides. Its side equals the height of that button — 28, 40, 48. |
listTileTrailingColor |
onSurface |
The built-in color of the trailing text of a ListTile and of the icon beside it. A named color (style.color of the node or an ancestor) wins. |
iconBesideText |
gap → xs, crossAlign → center, color → text, order → logical, textGlyphBox (inline → text-font-size, block → text-line-height), defaultPosition → start, places |
One rule for an icon beside a text, in three places (places: Button, Badge, the trailing slot of a ListTile): a gap between them, the icon box centred on the line of the text, the icon in the color of the text, the order in reading direction (start is on the right in a right-to-left script). The size is named by the place: glyph: text — as large as the font size of the text, in a box as tall as its line; otherwise a path to a step of the icon scale (listTileIconSizes.trailingIcon). |
richText |
spanAlign → baseline, linePitch → uniform, lineHeight → tallest-declared-part, strutTieBreak (larger-font-size, base-style, span-name-ascending), wrap → paragraph, bidi → no-isolation, textWidth → sum-of-runs, styleKeys (color, typography), places |
A text whose parts have their own style (Text.spans, since 9.6.0). Parts of different font sizes stand on one baseline. Every line of the paragraph has the same height: the tallest line (font size × line height) among the style of the text and the parts the node declares; that style is the strut and places the baseline. Among equals the larger font size wins, then the style of the text, then the part name in ascending order. The paragraph wraps as one text, a style never isolates or reorders its part in a right-to-left line, and the natural width is the sum of the pieces. |
progress |
ratio (value / max within 0…1; nonPositiveMax → 0, nonFinite → 0), props, defaultThickness → sm, defaultTrackColor → secondary, zeroThickness → no-track-no-fill, paint → track-under-whole-scale-fill-over, fill → gradient-else-color, gradient (spans → filled-part, angle → ignored, unresolved → use-color), bar (start → inline-start, trackRadius / fillRadius → pill, radiusClamp → half-shorter-side, fillClip → track, the label), ring (sizes in units of sizeUnit, sizeKeys, namedSide, diameter → smaller-named-content-side-else-size, placement → center, strokeAlign → inside, maxThickness → half-diameter, start → top, direction → clockwise, cap → butt, the label), widgets |
The progress bar and the progress ring (since 9.7.0). The fill share is value / max clamped to 0…1; a limit that is not a positive number means there is no scale, so the share is 0. The track lies under the whole scale and the fill is painted over it. The fill is the gradient while it resolves, otherwise the solid color; the gradient runs along the filled part and its angle is not read. The bar grows from the start of the line; its track and fill have the pill radius clamped to half of the shorter side. The diameter of the ring is the smaller named side of the content box of its node (style.width, style.height and their limits), and the size word when none is named; a size given by the parent (a row weight, a stack fill layer) does not name it. The ring stands in the center of its box, the arc lies inside the circle and is never thicker than half of the diameter, starts at the top, grows clockwise in every script and has flat ends. |
row |
unweightedWidth → single-line-content-max-row-content, shrink → proportional-to-natural-width, rigid → column.naturalWidth, ownWidth → named-overflows-row, stretch → scrollableRow.stretchToViewport, stretchShare → 1, shares → remainder-after-unweighted |
A child of a row without a weight; see unweightedRowChild and unweightedStretchInRow. rigid and stretch are references to lists of this same block, not new lists. |
x-actions — the action catalog (since 9.8.0): executors, catalog (the type of each named action, who performs it, its parameters with name, required, form, bindable), forms (httpsUrl and appLink with their pattern, maxLength and, for an app link, schemePattern and forbiddenSchemes), problems and problemOrder, paramBinding, handed (the fields of the action the host gets), track (the form and the limits of an analytics event), carryRecord, cases. The rule in words is the policy keys from standardActionExecution to actionTrack, carryRecord, and the four refinements before release (unbindableParamBinding, keyAutoRepeat, stackScreenGone, actionParamComposition); params gives the form of a literal parameter (keyPattern, keyMaxLength, valueMaxLength, valueType, reservedNames, invalidEntry).
x-screenShown — the screen-shown event (since 9.8.0): causes, transitions (what each change of the stack means), fields, and what entry, depth and record are. The rule in words is screenShown, screenShownOnBack and screenShownOnServer.
x-dataScope — the data layers of a screen (since 9.2.0). The rule in words is the eight policy keys from actionRecord to pickedChoice.
| Key | Value | Meaning |
|---|---|---|
layers |
record, session, host, data |
Where a binding or a condition reads a path, strongest first. record is the record scope of the node; session is what the user wrote into a bound input during this run; host is what the host supplied for this screen; data is the data of the document. A renderer whose host keeps the session and its own scope together with the data has two layers: the record over the data. |
recordSources |
opened-screen, repeater-instance |
What the record layer is made of, weakest first: the record the screen was opened with, then the records of the Repeater instances the node is drawn in (the inner one wins). |
absent |
missing-key, null |
What "no value" means in a layer (absentInDataLayer). |
cases |
fixtures/scope/record-scope-cases.json |
The table of cases shared by every renderer. |
x-schemaVersion — the version of the artifact. x-generatedFrom — a note that the file is generated.
Theme contract policy#
x-compatibility.policy in theme.json.
| Key | Value | Situation | What a renderer must do |
|---|---|---|---|
majorMustMatch |
true |
version check | Do not read values from a theme of another major version. |
minorAdditionsAreOptional |
true |
version check | A newer minor is readable. |
schemaDescribesExactVersion |
true |
validating a theme | The embedded x-schema describes exactly this minor and rejects unknown fields. Do not validate a newer theme with it; read by keys. |
newerMinor |
read-known-fields-only |
theme of a newer minor | Take the fields you know and ignore the rest. |
unreadableTheme |
fallback-and-report |
the theme cannot be read: missing, not JSON, no usable theme, other major | Draw with a fallback theme and make the substitution known to the host. |
unknownThemeId |
use-default |
the host asks for a theme id that is not in the set | Use the theme named by defaultThemeId. |
customTokens |
declared-by-theme |
reading the six token dictionaries | Take the token names from the theme. A renderer keeps no list of names of its own. |
missingCustomToken |
treat-as-unknown-token |
a custom name is missing from this theme of the set | For the document it is an unknown token; the widget schema policy applies (unknownToken, tokenWithoutFieldDefault). |
partialTheme |
resolve-missing-to-defaults |
a core name is missing or its value is broken | Keep the theme and resolve only the missing values with the renderer's built-in ones. Do not replace the whole theme. For the three optional state colors the defaults are named by x-optionalColors. |
partialThemeDefaults |
built-in-theme-of-same-brightness |
which built-in values partialTheme uses |
The built-in theme of the same brightness: a dark theme without surface gets the dark surface, not the light one. Only the core of the six dictionaries and the font families are filled; custom tokens, the optional state colors (they have their own fallback) and roles (missingRole) are not. |
zeroScaleToken |
none-is-zero |
the token none in spacing, radius, elevation |
It is zero (no shadow for elevation), whatever a theme says: the theme schema has const: 0 for the first two and an empty list for the third. Declare --product-space-none: 0 even for a partial theme, and do not let the theme override it. Migration and node templates write "no spacing" as none, so a none of 16 would change a migrated screen without a byte of the document changing. |
missingRole |
render-neutral |
a badge tone or button variant has no role in the theme | Draw neutrally (x-style.roles.neutral): the label in the root onSurface, no background, no border. |
unknownRoleColor |
neutral-slot |
a role names a color the theme does not declare | Only that slot becomes neutral, not the whole role: a background or a border with an undeclared color is not drawn, the label color becomes the root onSurface. The other slots of the role stay. A field of the node's own style with an undeclared token does not override the role at all. |
incompleteThemeAppliesTo |
reader-without-validation |
who missingRole and partialThemeDefaults are written for |
x-schema requires both roles and the full core, so a renderer that passed the schema always has a complete theme and never reaches those two rules. They are for a renderer that did not validate the theme: one that took it from the network by keys, or one reading a newer minor. The line runs through the renderer, not through the theme. |
protoKeyWithoutValidation |
drop-entry |
an own key __proto__ in a dictionary of the theme (the six token dictionaries, origins, themes), read without full validation |
Drop that entry — the key does not enter the map — and use the rest of the theme. Do not refuse the theme as a whole. The validator does refuse it. |
shadowOrder |
first-is-nearest |
reading elevation |
The first shadow layer in the list is the nearest one. |
lengthUnit |
logical-pixels |
reading sizes | Spacing, radius, font size and letter spacing are logical pixels. |
lineHeightUnit |
font-size-multiple |
reading lineHeight |
A multiplier of the font size, not pixels. |
missingLetterSpacing |
zero |
a typography entry without letterSpacing |
The spacing is zero, not the tracking of the platform's default text style. In Flutter build the style with letterSpacing: 0 instead of merging the entry over Material's TextTheme, which carries its own tracking on every style. |
colorAlpha |
rrggbbaa-alpha-last |
reading a color | A color is #rrggbb or #rrggbbaa; the alpha is the last pair, as in CSS. Flutter's usual notation puts it first. |
gradientAngleSpace |
axis-as-css-diagonal-css-to-corner |
reading a gradient | 0, 90, 180 and 270 are CSS angles. 45, 135, 225 and 315 are not CSS degrees: they are the geometry of CSS to <corner>, see diagonalGradientGeometry. |
diagonalGradientGeometry |
css-to-corner |
a diagonal angle | The gradient line passes through the center perpendicular to the diagonal between the two other corners; the target corner gets position 1. In Flutter compute the alignments from the box size with the formula of x-gradientGeometry; constant corner alignments are right only on a square. The direction is physical. |
gradientStopOrder |
non-descending |
reading a gradient | Each stop position is not less than the previous one. Equal positions are in order — a hard color edge. |
unorderedGradient |
do-not-draw |
stops go backwards | Do not draw the gradient at all. Do not sort the stops. |
Theme 4.0.0 removed its own unknownToken key: a name outside the core is now normal, not "from the future". Both artifacts say the same words about gradients: gradientStopOrder: 'non-descending' and unorderedGradient: 'do-not-draw'. A gradient of the theme and a custom gradient of a document are treated alike: not drawn when the stops go backwards.
x-rules — seven rules the embedded schema cannot express: every theme of a set declares the same token names; a role names a color that exists; a token a document names is declared by the project theme. The builder enforces them; the policy above says what a renderer does when a theme from elsewhere breaks one.
x-optionalColors — the fallback map for the optional state colors:
| Missing color | Use |
|---|---|
locked |
muted |
inProgress |
accent |
completed |
success |
The whole theme contract, the fallback levels and the load statuses are on the theme page.
Data dictionary delivery rules#
x-delivery in data-contract.json.
| Key | Value | Meaning |
|---|---|---|
pathsShape |
flat-map |
Single values are delivered as a flat map from path to value. |
collectionShape |
array-of-records |
A collection is an array of records. |
missingValue |
null-or-absent |
A value you do not have is null or absent; the binding then shows its fallback. |
absentCollection |
empty |
A collection that was not sent counts as empty — the same rule as missingConditionValue: 'absent-is-empty'. Sending [] is still recommended: it says "the list exists and is empty". |
maxCollectionItems |
50 |
A list draws at most 50 records. |
The renderers do not read the data dictionary at run time, and they do not check x-dataContractVersion. A document is self-sufficient: it names its own paths, fallbacks and formats. The dictionary is the agreement between the builder and your backend. See data dictionary and the backend guide.
Checklist for a renderer#
- The version is checked first; another major is refused and the user is told.
- A newer minor is drawn.
- An unknown widget draws its children and does not throw.
- A document of 8.x is refused by a 9.x renderer, and the bundled fallback copy is already 9.x.
- Token names come from the theme: a theme with a token of its own draws it.
- A token the theme does not declare takes the field default in
props; instylethe property is not applied, and an inherited color or text style stays. - A node without
stylehas no background and no padding. - A state replaces a style field as a whole;
colorandtypographyare inherited after the state is applied. - A button with a
style.backgroundkeeps the label color of its role. - An unknown action reaches the host app.
- An unknown condition operator leaves the node visible — also when the data is missing.
- A missing value makes
isEmptyhold and nothing else. - A missing translation shows the source text.
- An unknown entry point opens the initial screen.
- An unreadable theme falls back, and the host can see that it did.
- A state with an undeclared token keeps the base field; a size outside its own range is clamped; a non-number literal takes the lower bound of the field.
-
filloutside a direct layer of aStackis ignored;Tabs.initialTabout of range opens the first visible tab. - The lists in
x-style.layoutare read from the artifact, not remembered. -
noneis zero in spacing, radius and elevation; a partial theme is completed from the built-in theme of the same brightness. - A document deeper than 32 levels is refused before parsing; an own
__proto__key is dropped as an entry and the screen is still drawn. - In a column a card, a text and an image are stretched; a button that is not full-width, a badge, an icon and a progress ring keep their natural width and follow the
alignof the column (checkalign: center). - A nested row with weights beside a weighted sibling neither crashes nor divides anything: its children take their natural width.
-
opacity: nulldoes not hide a node;weight: 0does not collapse it; a shadow layer without an offset has a zero offset.