Skip to content
HotLayout Docs

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.

  1. 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.
  2. 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.
  3. Publish the theme again; it becomes 4.0.0. Serve 3.x to renderers of 3.x in the same way.
  4. 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; in style the property is not applied, and an inherited color or text style stays.
  • A node without style has no background and no padding.
  • A state replaces a style field as a whole; color and typography are inherited after the state is applied.
  • A button with a style.background keeps 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 isEmpty hold 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.
  • fill outside a direct layer of a Stack is ignored; Tabs.initialTab out of range opens the first visible tab.
  • The lists in x-style.layout are read from the artifact, not remembered.
  • none is 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 align of the column (check align: center).
  • A nested row with weights beside a weighted sibling neither crashes nor divides anything: its children take their natural width.
  • opacity: null does not hide a node; weight: 0 does not collapse it; a shadow layer without an offset has a zero offset.