Agent-authored prototype specification¶
This is the current, unversioned contract for #9296, child of #9295. Compose
rendering on Android (#9299, #9300), asset transport (#9301), wire messages
(#9298), the prototype MCP tool (#9302) and the
iOS simulator agent implement it; the iOS agent’s
feature support table lists what is Android only
(display, window.layer: "app", window.persistence: "device", inspect, idle TTL).
No code,
expressions, URLs, image bytes, or migration instructions are accepted in a
spec.
Owner decisions¶
The 2026-10-04 decisions supersede the issue’s earlier proposals:
- Envelope:
{ id, window, state?, root }, with nospecVersion. An older or newer shape is rejected rather than migrated. - Every object is strict: unknown properties, unknown node/action types, explicit null for optional fields, and wrong types reject the entire spec with a path.
- Scroll supports both axes. Pager is horizontal, full-page only, with no axis.
- Navigation includes
tabBarand author-positionedbottomNav, bound to a pager or a number state key. - Windows are edge-to-edge. Safe-area padding is an explicit node opt-in.
- A
sheetwindow and a modalbottomSheetnode are separate concepts. - Built-in icons come from a closed list (the bundled Material icon set); other artwork uses image assets.
Earlier decisions still apply: the renderer is Compose; sizes and positions use dp; prototype opacity is a percentage; app-element anchoring is supported; observe includes prototype nodes by default; existing tap/text tools target prototype nodes; screenshots come from observe, with no separate screenshot tool. Focus management belongs to the window host: accessibility set-text does not require focus, while human keyboard input does (the later device findings in #9300 supersede the older blanket focusability proposal).
Envelope and limits¶
| Property | Type / meaning |
|---|---|
id |
Required nonempty prototype identifier string. |
window |
Required window configuration below. |
state |
Optional flat object of string, finite number, or boolean values. |
motion |
Optional none or standard (default). See Motion below. |
root |
Required single node. |
All numeric values are finite. State keys (including action/binding/condition
keys) match [A-Za-z_][A-Za-z0-9_]{0,63}. No nested state, arrays, or nulls.
Prototype IDs, pager IDs, tags, event names, and asset IDs are opaque nonempty
strings; they do not share the state-key restriction.
| Constant | Value | Counting rule |
|---|---|---|
MAX_PROTOTYPE_NODES |
2000 | Includes root and every child/page/sheet child; repeat templates count once per instance. |
MAX_PROTOTYPE_DEPTH |
24 | Root has depth 1; only node nesting counts. |
MAX_PROTOTYPE_IMAGES |
32 | Counts image nodes and nav item image uses, including hidden ones; repeated asset IDs count again. |
MAX_PROTOTYPE_SPEC_BYTES |
1048576 (1 MiB) | UTF-8 bytes of raw JSON, including whitespace. |
MAX_PROTOTYPE_EMIT_PAYLOAD_BYTES |
4096 (4 KiB) | Conservative compact JSON byte budget for each emit payload; numbers reserve 32 bytes. |
Navigation item image uses also count toward MAX_PROTOTYPE_IMAGES. Assets have
separate transport and decoded-memory limits in #9301. These spec limits keep
parsing/layout work bounded without limiting image pixel data inside this file.
Emit payload nesting has a separate MAX_PROTOTYPE_EMIT_PAYLOAD_DEPTH of 24
(root payload depth 0). To avoid different number spellings in JVM/JS decoders,
the byte budget counts each finite number as 32 bytes; strings, keys, booleans,
null, punctuation, and separators use compact JSON UTF-8 bytes. This bounds
actual compact serialized size and can conservatively reject numeric-heavy
payloads. Raw number token length and whitespace are governed by the spec-byte
limit. Nonfinite JSON numbers are rejected, including inside emit payloads.
Every onTap list has 1–32 actions; tab bars have 1–32 items; bottom navigation has 2–5 items; sheet detents have 1–8 entries.
The single structural rule source is schemas/prototype-spec-contract.json,
imported by TypeScript and packaged as a JVM protocol resource. TypeScript’s
concrete Zod schema supplies inferred types and the final typed decode. Kotlin
uses kotlinx.serialization models with sealed discriminated hierarchies. Use
validatePrototypeSpec / PrototypeSpecValidator.validate as the complete entry
points: structural validation, limits, and cross-references precede typed decode.
Do not bypass them with a model decoder. Untagged dimension, detent, page-target,
and scalar unions have explicit Kotlin serializers; only arbitrary emit payloads
retain JsonElement. Validated integer model fields normalize decimal/exponent
spellings before Kotlin decoding (e.g. 100.0 and 1e2 both mean 100). The protocol module has no API-dump
plugin or API-check task; its published artifact includes the contract resource.
Theme¶
Optional top-level theme sets the Material scheme every built-in component draws
from. At least one of its fields is required:
| Field | Meaning |
|---|---|
mode |
light, dark or system (follow the device). Omitted: inferred from the first opaque background on the root’s leading chain, else the device setting. |
colors.seed |
Hex color a full light or dark scheme is generated from. |
colors.source |
device: Android 12+ (API 31) dynamic color. On older devices it falls back to colors.seed when present, else the default scheme. |
colors.<role> |
Hex override for one Material 3 role (primary, onPrimary, surface, surfaceContainer … the 36 roles style colors accept), applied over the seed, device or default scheme. |
colors.light, colors.dark |
Maps of role name to hex, applied after colors.<role> in the matching mode only. See Light and dark values. |
typography.scale |
Number 0.75–1.5 multiplying the size and line height of every Material type role. Default 1. |
typography.fontFamily |
sans, serif or mono: the family of every Material type role. |
shapes.corner |
none, small, medium (the stock Material 3 scale), large or full (pill). Shifts every corner family built-in components use. |
colors, typography and shapes each need at least one field. An explicit theme wins over the scheme
inferred from backgrounds.
Role overrides are literal colors, so they apply unchanged in light and dark: pair them with a
fixed mode, or rely on seed/source for a mode-aware scheme and override only the roles that
must match a brand. With no mode, a colors.background (else colors.surface) override decides
light or dark from its luminance, ahead of the authored-background inference, for both the content
scheme and the host dismiss control. A spec that names only roles in its styles and no hex
literals renders in light and dark from one source.
A text node’s style.textStyle names a Material 3 type role (displayLarge … labelSmall, 15
in all) and so follows the theme’s scale and family. It supplies size, weight and family; an
explicit textSize, fontWeight or fontFamily on the same node still wins. Plain text
without textStyle keeps its authored 14 sp default and is not scaled; it takes the theme’s
typography.fontFamily only when it names no fontFamily of its own.
A text node with no color draws in the theme’s content colour (onSurface). A cornerRadius token maps to the
theme’s Shapes (shapes.corner shifts them), none is square and full a pill. A
role-valued background does not take part in inferring the prototype’s light/dark
theme from authored backgrounds (it would be circular); set theme.mode or
colors.seed for that.
Light and dark values¶
A shown prototype has exactly one resolved mode, light or dark: an explicit theme.mode
(system reads the system setting), else the luminance of a flat colors.background (else
colors.surface) override, else the first opaque hex background on the root’s leading chain, else
the system setting. Every per-mode form below reads that one resolved mode. A device needs
prototype_theme_modes_v1 for them; the host refuses such a spec for an older CtrlProxy.
- Colour pairs. Every colour slot (
style.background,style.color,style.shadowColor,style.border.color, gradient stops,window.placement.scrimand thebottomSheetscrim, including insidestyleWhen) accepts a hex value, a role name, or{light, dark}where each side is a hex value or a role name. Both sides are required. The side for the resolved mode is taken first, and is then a literal colour or a role looked up in the active scheme. - Role maps. The scheme is built in this order: seed, device or default scheme, then the flat
colors.<role>overrides (both modes), thencolors.lightorcolors.darkfor the resolved mode. A role named in both the flat overrides and the mode map takes the map’s value in that mode and the flat value in the other. A role name inside a pair resolves against this final scheme, so there is no precedence to decide between a node’s pair and the theme maps: the pair picks the name or literal, the scheme supplies the role’s colour. - Mode inference ignores per-mode values.
colors.light/colors.darkand a{light, dark}background never decide the mode, because they depend on it. A pair background is skipped by the authored-background inference in the same way a role-named background is, and the search continues down the leading chain. A spec whose only backgrounds are pairs or roles follows the device setting. - Gradient stops accept role names; a role is the scheme’s colour unchanged.
- Scrims (
window.placement.scrimand thebottomSheetscrim) accept role names with one special case. Thescrimrole, alone or as the side of a pair for the resolved mode, draws the scheme’s scrim colour at the default scrim opacity (40%), the same value an omittedbottomSheetscrim uses, so"scrim": "scrim"dims rather than hides the app. Any other role (surface,primary, …) draws its scheme colour unchanged, which is opaque unless the theme overrides it with alpha. A hex scrim keeps exactly its authored alpha. Adialogauthors no scrim; its fixed scrim is thescrimrole at 32%. - Component colours. A role name or pair in
style.coloron abutton,checkbox,switch,radioGrouporlistItem, and instyle.backgroundon acardorlistItem, resolves against the theme like any other colour slot. Beforeprototype_theme_modes_v1only a hex value took effect in those slots. - Images.
image.assetand atabBar/bottomNavitemimageaccept{light: assetId, dark: assetId}. Only the asset for the resolved mode is decoded and drawn; both ids are checked atshowand reported inmissingAssets, and both stay retained with the prototype. - Window metadata.
prototypeOpaquefollows what is drawn. The root background and the window scrim are resolved in the mode the show resolved to and count as solid only when the result is fully opaque. An opaque role counts; thescrimrole in a scrim slot never does. The root counts only when it is drawn over the whole window (#11408):widthandheightbothfill, noanchor(an anchored root is laid out at its anchor’s rectangle, in every placement), nomaxWidth/maxHeightcap and noaspectRatio, no non-zerooffset, no rounded corner (cornerRadiusabsent,0,noneor all corners0) and nopressScalebelow one. A root that fails any of these lets the app show at an edge or corner, so only an opaque fullscreen scrim behind it can still make the window opaque.
Live appearance (#11221, prototype_appearance_v1)¶
The controller resolves the mode once per show and keeps it current. That one value drives the host chrome (dismiss bar, Close control), the window scrim, every colour and image pair in the content and the window metadata, so they cannot draw in different modes. No window reads its own Compose configuration for it, which is why a window created from a display or app-layer window context follows the same value as one on the service context.
- Override.
show_prototypetakes a top-levelappearance:"device"(the default when absent),"light"or"dark". It stands in for the system setting only: it decides a spec withtheme.mode: "system"and a spec whose mode would otherwise fall through to the device. It does not beat an explicitlight/darkmode, a flatbackground/surfaceoverride or an authored opaque background. It belongs to the show: a later show of the same id without the field follows the device again. An unknown value fails the request and leaves what is showing untouched. - Re-theme. The mode is resolved again after each interaction (a pager page or a
styleWhenstate can change the background it is inferred from) and on every configuration change (the device’s night mode; on API 31+ the Material You palette, so acolors.source: "device"scheme is read again after a wallpaper change). The window, the runtime, authored state, pager pages and text-field contents are kept; only colours change. A prototype hidden by the lock screen or because its app left the foreground is resolved too, and returns in the current mode. - Event. When the resolved mode changes, the device sends one
prototype_eventwithkind: "appearance_changed",name: nullandpayload: {mode, source}, on the normal sequence, buffering and replay path. Nothing is sent when the mode stays the same (a repeated configuration change, a device flip under an explicit mode or an override, a palette change), nor for the show itself, whose result carries the mode. - Report. A successful
show_prototyperesult and eachinspect_prototypesentry carryappearance: {mode, source, deviceDark}.modeislightordark.sourcenames the step that decided it:explicit,override,roleLuminance,authoredBackgroundorsystem(theme.mode: "system"reportssystem, oroverridewhen the show pinned it).deviceDarkis the device’s own setting whatever decided the mode. The host’sprototypetool reports it only in device answers (show, inspect); its host-localstatusomits it (#11416). The prototype’s own window entry in a hierarchy capture carries the same object asprototypeAppearance, next toprototypePlacementandprototypeOpaque; an APK withoutprototype_appearance_v1omits it.
Windows¶
window has required placement and optional integer opacity (0–100, default
100). Opacity applies to the entire prototype, including content and scrims.
Placement type |
Properties |
|---|---|
fullscreen |
Optional scrim color. Occupies the display and blocks app touches. |
sheet |
Required edge: top or bottom; required positive height in dp. App stays touchable outside the window. |
floating |
Required gravity from the alignment list below; required offset: {x, y} in dp. Outside-window touches reach the app. |
Root fills the entire window, including behind system bars and cutouts; ordinary style constraints apply to its content. A floating/sheet root fills that smaller window, not the entire display. No display selection is defined here (#9308).
Optional layer (#10496) is system (default) or app. system is
TYPE_ACCESSIBILITY_OVERLAY, above system UI including SystemUI’s screenshot
flash and preview. app is TYPE_APPLICATION_OVERLAY, just above apps: the
shade, keyboard, toasts, system dialogs and the screenshot preview draw over the
prototype, the status and navigation bars draw over a fullscreen one (safe-area
padding matters more), and the keyboard covers its text fields like a real app’s.
Non-default displays create their window context with the matching type. app
needs SYSTEM_ALERT_WINDOW; the host grants it with
adb shell appops set dev.jasonpearson.automobile.ctrlproxy SYSTEM_ALERT_WINDOW allow
before the show, and the device refuses the show with that command when it is
still missing. Application overlays are not trusted for touch pass-through on
Android 12+, so gestures dispatched through an app prototype can be blocked as
untrusted touches.
Optional persistence (#10494) is session (default) or device. See
Lifecycle and safety. Both fields are honoured only by a CtrlProxy that advertises
prototype_window_options_v1; the request decoder ignores unknown spec fields, so
the host refuses them for an older device instead of sending them.
Common node properties¶
Every node has required type. All other common properties are optional:
| Property | Shape / meaning |
|---|---|
id |
Nonempty node ID; required and unique among pagers for pager. Other node IDs need not be unique. |
testTag |
Nonempty accessibility/test selector tag. |
contentDescription |
Nonempty accessible label read by observe and screen readers in place of the derived one. {key} state placeholders resolve as in text. |
onTap |
Nonempty ordered array of actions, run in order. See “Tap targets” below. |
style |
Strict style object below. |
styleWhen |
Conditional style overrides (see Conditional style below). One to 8 entries. |
visibleWhen |
Condition (see Conditions below). A false condition, or a missing key, means hidden. |
transition |
Optional none, fade, expand or slide: the visibleWhen enter/exit. See Motion. |
anchor |
Bounds or app-element anchor below. |
safeAreaPadding |
Explicit inset selection below. |
Hidden and closed sheet content still counts toward all limits and references. No arbitrary extra metadata is allowed.
Accessible labels: an authored contentDescription wins; otherwise a node is
labelled by its text, and an icon-only tappable node (or a tappable container
whose only content is one icon, such as a FAB) by its icon name. Layout
containers (box, row, column, scroll, pager, spacer, card) and
navigation bars are never labelled by their kind while they have content. A
layout container with no label, text, onTap, testTag or state adds no
accessibility node of its own, so its children join the nearest reporting
ancestor, as with Compose’s own layouts.
Tap targets: a node with onTap responds across its whole drawn area, including
its own padding, and its accessibility bounds are that drawn area. It also
reserves a 48 dp minimum touch target, like Material components: a smaller node
keeps the size it draws at and is centered in the reserved space, which takes up
layout room.
Nodes¶
type |
Node-specific properties |
|---|---|
box |
Required children array, possibly empty; children stack. Optional repeat (see List templates below). |
row |
Required children array, possibly empty; horizontal layout. Optional repeat (see List templates below). |
column |
Required children array, possibly empty; vertical layout. Optional repeat (see List templates below). |
text |
Required text string, possibly empty. |
image |
Required opaque asset string; optional contentScale: fit (default), crop, fill. |
icon |
Required built-in name below; optional variant (filled, outlined, rounded, sharp, twoTone). |
spacer |
No node-specific properties; size comes from style. |
textField |
Required stateKey naming an initialized string state value; optional placeholder string, default empty. |
switch |
Required stateKey naming an initialized boolean state value; optional nonempty label. |
checkbox |
Required stateKey naming an initialized boolean state value; optional nonempty label. |
button |
Required nonempty label; optional variant: filled (default), tonal, elevated, outlined, text; optional leading icon (built-in name). Taps run onTap. |
radioGroup |
Required stateKey naming an initialized string state value; required options, 2 to 16 {value, label} entries with nonempty, unique values. |
listItem |
Required nonempty headline; optional nonempty supporting, optional leadingIcon (built-in name), optional trailing: {type: switch\|checkbox, stateKey} bound to an initialized boolean, or {type: icon, name}. Taps run onTap. |
slider |
Required stateKey naming an initialized number within min..max; required finite min < max; optional step, optional nonempty label. |
chip |
Required nonempty label; optional variant: assist, filter, input, suggestion; optional stateKey naming a boolean (a filter chip only). |
card |
Required children array; optional variant: filled (default), elevated, outlined. |
iconButton |
Required built-in icon; optional variant: standard (default), filled, tonal, outlined. Taps run onTap. |
fab |
Required built-in icon; optional nonempty label (an extended FAB); optional size: small, regular (default), large, only without a label. Taps run onTap. |
segmentedButton |
Required stateKey naming an initialized string state value; required options, 2 to 5 {value, label} entries with nonempty, unique values. Single select. |
topAppBar |
Required nonempty title; optional variant: small (default), centerAligned, medium, large; optional navigationIcon and 1 to 3 actions, each {icon, label, onTap?}. |
divider |
Optional orientation: horizontal (default), vertical. |
badge |
Optional nonempty text (a count or short label); without it a small dot. |
progress |
Optional variant: linear (default), circular; optional stateKey naming an initialized number within 0..max (determinate); optional max > 0, default 1, only with stateKey. Unbound is indeterminate. |
dialog |
Required openWhen and confirm; optional nonempty title and text, optional icon, optional dismiss, optional single child. Buttons are {label, onTap?}. |
snackbar |
Required openWhen and nonempty text; optional action button {label, onTap?} and durationMs (1 to 600000, self-closing). |
timePicker |
Required distinct hourKey and minuteKey naming initialized integers 0..23 and 0..59; optional is24Hour boolean, default the device setting. |
datePicker |
Required stateKey naming an initialized YYYY-MM-DD string, a real date in years 1900..2100. |
scroll |
Required single child; optional axis: vertical (default), horizontal. Free scrolling, with no page snapping. |
pager |
Required id and nonempty children array. Each child is one full-size page; horizontal swipe only. |
tabBar |
Required items; exactly one pager or stateKey; optional scrollable boolean, default false. |
bottomNav |
Required 2–5 items; exactly one pager or stateKey. Author positions it, typically last in a column. |
bottomSheet |
Required single child, openWhen, and detents; optional scrim, dragHandle, dismissOnSwipe. |
Each nav item is {label, icon?, image?}. Label is nonempty; icon uses the same
closed list as icon nodes; image is an opaque asset ID. Both icon and image may
be supplied; the image takes visual precedence, with icon as fallback. This
counts as one image use, regardless of whether the icon fallback renders.
Pager binding (pager: "variants") follows and sets that pager’s local selected
page. stateKey: "selected" follows and sets an initialized nonnegative integer
state value. The selected value is clamped to the available item/page range by
the future renderer. Nav item count need not equal pager page count; authors
should match them when each item represents a page. Per-pager selection starts
at zero, is separate from the flat state map, and is changed by setPage.
When a pager settles on another page the device sends one page_changed event whose
name is the pager id and whose payload is the new zero-based page index, the same
shape as the iOS agent (#11409). The event pages map still carries every pager.
bottomSheet.openWhen is {key, equals} with a boolean equals value. Its
state key may be absent (sheet closed); an existing value must be boolean. A
swipe dismissal with dismissOnSwipe: true writes !equals to that key, making
the condition false. Defaults: dragHandle: true, dismissOnSwipe: true. Setting
both false is permitted; the host’s non-removable safety dismiss control remains
outside author content (#9307). Detents are unique values: positive {dp: n},
"half", or "full". They preserve author order. Half/full refer to the prototype
window’s height. Sheet node scrim applies inside that window, not beyond a
floating or sheet window’s bounds. A modal sheet intercepts touches inside the
window while open; a sheet placement is only a window placement.
switch, checkbox and button are Material 3 components (#10439, first
slice). A switch or checkbox draws the bound boolean; a tap anywhere on the
control, including its label, flips that state value, emits a change event
({key, value}) like a text field or stateKey selection, and then runs the
node’s own onTap, if any. A setState that would make the bound value
non-boolean is rejected like any other binding type change. A button runs its
onTap; without one it is drawn but inert. Each component is one accessibility
node carrying its label as text, a native role (Switch, Checkbox,
Button), and for switch and checkbox the checkable/checked state, so observe
reports them as controls and tapOn by testTag or label toggles or presses
them. Each reserves the Material 48 dp minimum touch target.
slider, chip and card are the second slice. A slider is bound to a number
state key that must already lie within min..max; step, when given, must
be positive and divide max - min evenly (the thumb snaps to those positions,
otherwise it is continuous). Dragging, or an accessibility set-progress action,
stores the snapped number, emits change ({key, value}) only when the value
moved, and then runs the node’s onTap. It is one accessibility node carrying
its label and a progress range (current, min..max, steps), so observe
reports the value as range info. A chip with a stateKey is a filter chip: it
toggles that boolean like a switch, emits change, then runs onTap, and
reports the Checkbox role with its checked state. A chip without one is an
assist chip: a Button that runs onTap. variant: filter requires a
stateKey and variant: assist forbids one; input and suggestion chips are
later slices. A card is a Material container whose children are laid out as a
column; its variant selects filled, elevated or outlined, a style.background
overrides the container colour, and an onTap makes the whole card clickable.
A card has no accessibility label of its own.
radioGroup, listItem and the button extras are the third slice of #10439. Button
tonal and elevated are Material’s filled-tonal and elevated buttons; an
icon draws before the label. A radio group draws one Material radio row per
option; a tap on a row (or its label) sets the bound string to that option’s
value, emits change ({key, value}) only if the value changed, and then runs
the group’s own onTap. A bound value matching no option leaves every option
unselected. Each option row is its own accessibility node with the RadioButton
role, its label as text and a selected state; when the group has a testTag, the
option’s tag is <testTag>.<value>. A list item is one accessibility node whose
text is the headline (and supporting line). With a trailing switch or checkbox it
is toggleable with the Switch or Checkbox role and checked state: a tap
anywhere on the row flips the bound boolean, emits change, then runs onTap.
Otherwise a row with onTap is a Button that runs it, and a row without is
inert. A trailing icon is decorative.
iconButton, fab, segmentedButton, topAppBar, divider, badge,
progress, dialog, snackbar, timePicker, datePicker and the input and
suggestion chips are the last slice of #10439. Icon buttons and FABs are
Button nodes that run onTap, labelled by contentDescription, else an
extended FAB’s label, else their icon name. A segmented button behaves like a
radio group: each segment is its own selectable node tagged <testTag>.<value>,
and a tap binds the string key to its value, emits change if it moved, then
runs onTap; multi-select segments are not offered (use filter chips). Input and
suggestion chips, like assist chips, only run onTap and cannot bind a key.
A top app bar’s text is its title (state placeholders are resolved); its
navigation icon and actions are icon Button nodes labelled by their label and
tagged <testTag>.navigation and <testTag>.actions.<index>, each running its
own onTap. It draws no system-bar inset of its own; add safeAreaPadding. A
divider has no accessibility node unless tagged. A badge reads as its text. A
bound progress indicator is determinate and reports value / max as progress
range info over 0..1; an unbound one reports indeterminate progress.
dialog and snackbar open while openWhen holds, like bottomSheet (an
existing key must be boolean), and are drawn above the whole author tree inside
the prototype window, never as a separate window. A dialog is modal: a scrim tap
closes it, and so does either button. While it is open the page behind it is
removed from the accessibility tree (clearAndSetSemantics on the page), so
observe and screen readers see only the dialog, as on iOS and as touches
already behave; a closed dialog leaves the page as it was. Closing writes !equals to the key and
emits change; a button then runs its own onTap. Its title is its text, its
text is a separate text node, child sits between the text and the buttons,
and the buttons are tagged <testTag>.confirm and <testTag>.dismiss. A snackbar
sits at the bottom of the window and is not modal: the page stays in the
accessibility tree. It stays until closed unless it
sets durationMs (an integer, 1 to 600000): it then closes itself that long after it
opens, by writing !equals to its openWhen key like any other close. The timer is
wall-clock time, not scaled by the animator duration. Its action
(tagged <testTag>.action) closes it, then runs onTap. Title, text and
snackbar text resolve state placeholders, so Alarm set for {hour}:{minute}
reads the bound time.
A time picker is Material’s dial picker bound to two integer keys. A change
stores both keys together and emits one change event ({keys, values}, or
{key, value} when only one moved), then runs onTap. A date picker is
Material’s calendar bound to a YYYY-MM-DD string; picking a day binds the key,
emits change, then runs onTap. Both report their value as the accessibility
state (07:30 in 24-hour form, 2026-10-08), and a new bound value from a
re-show or setState moves the picker. A setState that would put an out of
range hour, minute or date into a bound key is rejected.
Text interpolation is a renderer concern: {page}, {pageCount}, and state
keys may appear in text. No expressions or interpolation parsing occurs during
validation. Pager context is the nearest enclosing pager; outside a pager those
two reserved placeholders remain literal. State keys page and pageCount are
permitted but the pager placeholders take precedence within a pager.
A number renders the same in a {key} placeholder and in a repeat binding (#11408),
and as the TypeScript host renders it (renderScalar):
- An integral number prints every digit with no decimal point and no exponent at any
magnitude (
12345678,1000000000000000000000), and negative zero prints0. - Any other number prints as JavaScript’s
String(number): the shortest decimal digits that read back as the same double, in plain notation down to0.000001(0.30000000000000004,0.00001) and as<digits>e-<exponent>below that (1e-7,2.5e-7). Android computes the digits itself, so the result does not depend on the device’sDouble.toString.
Known divergence: the iOS agent formats a non-integral number with Swift’s own
description, which switches to an exponent earlier and pads it (0.00001 shows as
1e-05). Integral numbers agree on all three. Prefer integral state for numbers a
prototype shows, or keep display text in a string key.
Style¶
All properties are optional. Sizes, padding, offsets, radii, and spacing use dp. Text size uses sp, so it follows the system font scale. Negative offsets/positions are allowed; sizes are nonnegative, except text size and sheet height/detent height which must be positive. Positive values use a minimum of 0.000001.
| Property | Accepted value |
|---|---|
width, height |
"fill", "wrap", or strict {dp: n}. |
weight |
Positive number. A row/column child fills the remaining main-axis space in proportion to its weight (ignored elsewhere). |
minWidth, maxWidth, minHeight, maxHeight |
Nonnegative dp bounds applied after width/height, so fill and {dp} are clamped by them. |
padding |
Strict {top?, bottom?, start?, end?}, each nonnegative dp; omitted edges are zero. |
background, color |
Strict hex color, or a Material 3 color role name (see below). |
cornerRadius |
Nonnegative dp, a Shapes token (none, extraSmall, small, medium, large, extraLarge, full), or strict per-corner {topStart?, topEnd?, bottomEnd?, bottomStart?} of nonnegative dp where an omitted corner is square. Start and end follow the layout direction. |
border |
{width, color}; nonnegative dp width; color takes hex or a color role name. |
elevation |
Nonnegative dp. Draws a shadow in the node’s corner shape, behind its clip, background and border. |
shadowColor |
Hex color or color role name tinting the elevation shadow (ambient and spot). Without elevation it draws nothing; absent, the platform shadow color applies. |
gradient |
{type: "linear", angle, stops} or {type: "radial", stops}. angle is a finite number of degrees clockwise from left-to-right (0 = left to right, 90 = top to bottom). stops is 2–4 {color, position?} with a hex color and an optional 0–1 position; positions apply only when every stop has one (a position below the previous stop’s is raised to it), otherwise stops are evenly spaced. Radial gradients are centered and reach the corners. Painted over background in the node’s corner shape. |
aspectRatio |
Positive number (width / height) applied after the size bounds. |
offset |
Strict {x, y} dp, negative allowed. Shifts the drawn node, its shadow, touch target and accessibility bounds without moving siblings: the node keeps its layout slot. |
alpha |
Finite number 0–1; default 1. Multiplies window opacity. |
pressScale |
Finite number 0.5–1. A node with a plain onTap (box, row, column, text, image, icon, spacer) scales to this while pressed and back on release; absent leaves it unscaled. Instant, not animated, when motion is none or the system animator duration scale is 0; iOS also snaps under Reduce Motion. Component nodes (button, chip, switch, …) keep their own press feedback and ignore it. |
alignment |
topStart, topCenter, topEnd, centerStart, center, centerEnd, bottomStart, bottomCenter, bottomEnd. |
arrangement |
start, center, end, spaceBetween, spaceAround, spaceEvenly. |
spacing |
Nonnegative dp between row/column children; arrangement remains authoritative for distributed free space. |
textSize |
Positive sp; scaled by the system font scale. |
fontWeight |
Integer 100–900. |
textAlign |
start, center, end, justify. |
maxLines |
Integer 1–2147483647. |
overflow |
Text past maxLines or its width: clip (default), ellipsis or visible. |
lineHeight |
Positive sp between baselines; scaled by the system font scale. |
letterSpacing |
Finite sp added between letters; negative tightens. |
textDecoration |
none, underline, lineThrough or underlineLineThrough. |
fontStyle |
normal or italic. |
fontFamily |
A system name (default, sansSerif, serif, monospace) or {asset: "<id>"} naming an uploaded TTF/OTF font (#10443). |
The text properties (textSize, fontWeight, textAlign, maxLines,
overflow, lineHeight, letterSpacing, textDecoration, fontStyle,
fontFamily, textStyle) style text nodes. An unset fontStyle,
textDecoration, lineHeight or letterSpacing keeps what the textStyle role
or the theme supplies.
Colors accept #RRGGBB or #AARRGGBB, with case-insensitive hex digits, or one
of the Material 3 ColorScheme role names (primary, onPrimary,
primaryContainer, secondary, tertiary, background, surface, onSurface,
surfaceVariant, surfaceContainer and its Low/High/Highest/Lowest
steps, surfaceBright, surfaceDim, error, outline, scrim, and the rest
of the scheme, 36 in all). A role resolves against the prototype’s active theme
(theme.mode, colors.seed, device colour, colors.<role> overrides), so it follows light/dark and the
seed. No short hex, other named colors, CSS functions, or separate color opacity. Style
properties that do not apply to a node have no rendering effect; their shape is
still validated. Omitted layout/text properties use Compose/system defaults.
Start/end use layout direction, including RTL.
Icons and images¶
Built-in Material names are the 2,075 icons of material-icons-extended, written
in snake_case (home, timer, bedtime, alarm_add, add_a_photo). The closed list is the
iconName definition in schemas/prototype-spec-contract.json, shared by the TypeScript
and JVM validators. The renderer already ships that library, so the full set adds no APK
size beyond the list itself (about 10 KB measured). An icon node may set variant:
filled (default), outlined, rounded, sharp or twoTone; nav items are always
filled. Material Symbols names with no material-icons-extended counterpart are not
available, and neither are symbol weight or fill axes, which would need a bundled
variable font. Custom text fonts do ship, as host-uploaded assets (see “Custom fonts”
below).
Unknown names reject the spec. #9301 pins ID-based assets, but no nested reference
shape, so image nodes use {type: "image", asset: "opaque-id"} and nav items
use image: "opaque-id". Asset existence is not validated here. A missing asset
renders a visible placeholder and reports a result under #9301; image bytes,
MIME types, URLs, cache paths, and screenshot handles are not spec properties.
Asset transport (#9301, first slice)¶
put_prototype_asset {id, mimeType, dataBase64} uploads one asset and
remove_prototype_asset {id} deletes one; each gets one prototype_result carrying the
request ID. Bytes travel as base64 in the single JSON text frame, like screenshots.
Heap, not the 64 MiB frame limit, is the binding constraint, so caps are
conservative and shared with the host through schemas/prototype-asset-contract.json:
4 MiB per asset, 32 assets, 16 MiB total, ids of 1 to 256 characters, and
image/png, image/jpeg or image/webp (exact lowercase) whose bytes must start
with the matching signature, plus font/ttf and font/otf (2 MiB each, see Custom fonts). Putting an existing ID replaces it; a full store rejects
the put with a clear error and never evicts. Removing an unknown ID succeeds. Assets
sit in the CtrlProxy cache directory and are cleared when the prototype session ends:
on any dismissal, on service start, unbind or teardown, on dismiss_prototype with
all, and when the last client disconnects (even with no prototype showing). A show
replacement and a temporary lock-screen hide keep them.
Rendering assets (#9301, second slice)¶
An image node draws its asset with contentScale: fit letterboxes, crop fills the
box and clips, fill stretches. Bytes are decoded off the main thread with
BitmapFactory and inSampleSize, downsampled to the node’s laid-out size (the screen
size when an axis wraps content) and never above 4 Mi pixels (16 MiB as ARGB_8888). The
decoded-bitmap cache keys on asset id and a power-of-two size bucket, evicts least
recently used first, and holds at most 32 MiB of decoded pixels; a single bitmap larger
than that is drawn but not retained. A replaced, removed or cleared asset drops its
decoded copies immediately and the nodes showing it reload; uploading an id that was
missing makes the placeholder load it.
A nav item draws its image when it is ready, then its built-in icon, then a gray
square. While an image decodes the node shows a plain gray box. An unknown asset id, a
file the OS evicted from the cache directory (the store still lists it but read
returns null) or undecodable bytes renders a gray box with a broken-image glyph.
show_prototype lists the referenced ids the device has no copy of in
prototype_result.missingAssets, in first-use order. It is a warning: success stays true
and the prototype is shown with placeholders, so the host can upload the assets and the
nodes fill in without another show. The field is omitted when nothing is missing and
from every other result, so older hosts see the frame they always did. An id the store
lists but whose file was evicted is not reported at show time; it renders the
placeholder. Dismissal clearing is unchanged.
Host surface: the prototype tool’s show takes
assets: [{id, path}], an absolute daemon-readable file path per asset. The host
reads and validates every file first (signature-detected MIME type, the contract
limits, unique ids), then uploads sequentially before the prototype request; any
failure fails the call before the prototype changes and names the assets already
stored. An entry may instead be {id, observation}, an
automobile:observation/{deviceId}/{observationId}/screenshot URI; the host reads it
through the same handler as that resource (current-observation check, pending-capture
wait, retention lease), which is readable by any client, so no access is widened.
When prototype_result.missingAssets lists an id the same call uploaded (the device
cleared its store between the upload and the show), the host re-uploads those assets
once from the bytes it already holds and re-sends the show once. It never
loops: if they are still missing, or the retry fails or is cancelled, the first
successful result is returned with missingAssets and a warning on the tool output.
Ids the call did not supply are only reported. See docs/tools.md for the result and
deadline model.
Custom fonts (#10443)¶
style.fontFamily also accepts {asset: "<id>"}, an opaque id of a font uploaded through
the same asset pipeline as images (put_prototype_asset, or assets: [{id, path}] on the
host). The spec carries only the id; font bytes, MIME types and caps stay out of it.
- Transport:
font/ttfandfont/otfjoin the contract’s MIME types. The bytes must start with an sfnt version tag,0x00010000,trueorOTTO, accepted for either MIME type because an OpenType file may carry TrueType outlines. TrueType collections (ttcf) and WOFF are rejected. Fonts are never decoded as images. The per-font cap is 2 MiB (MAX_PROTOTYPE_FONT_ASSET_BYTES); fonts count toward the 32-asset and 16 MiB totals like images. The host detects a font from the file signature, not the extension. - Missing assets: a referenced font the device has no copy of is listed in
prototype_result.missingAssetswith the image ids (references instyleandstyleWhencount, including hidden nodes) and the text draws in the default family until it is uploaded. - Rendering: the font is loaded from the stored asset file with
FontFamily(Font(file)), once per asset, and cached. A file that cannot be parsed logs one warning and draws with the default family; the prototype is never failed for it. Replacing an asset reloads it; removing it (or ending the session) while a spec still references it falls back to the default family, the same as a removed image becoming a placeholder. - Weights: one file is one face, so
fontWeightselects that face rather than a bolder one. Upload a font file per weight and usestyleWhento choose between them.
Motion¶
Prototype state changes animate by default (#10442). A node with visibleWhen
fades and expands in and fades and shrinks out; a pager page change driven by
setPage animates the scroll instead of jumping. Spec-level motion: "none"
opts out and keeps every change instant. Motion is also off when the system
animator duration scale is 0 (adb shell settings put global
animator_duration_scale 0), so observe screenshots are settled with no
extra waits; other scales are honored by Compose’s animation clock. The scale is
observed while the prototype is shown, so setting it to 0 after the prototype is up
makes the next visibility or page change instant, and turning motion off
mid-scroll snaps the pager to its target page. Only nodes with visibleWhen get
an animation wrapper.
box, row and column containers animate their size when their children
appear, disappear or change (animateContentSize), gated by the same
motion-enabled check, so a sibling slides into the freed space instead of
jumping.
A node’s optional transition picks the visibleWhen enter/exit: none
(instant), fade, expand (grow/shrink) or slide (vertical slide with a
fade). Absent keeps the default fade + expand. It has no effect on nodes
without visibleWhen, or when motion is off.
A style pressScale (0.5–1) shrinks a tappable node while it is pressed. With
motion on it animates with a short spring; with motion: "none" or animator
scale 0 it snaps, so a screenshot taken mid-press is still deterministic.
Actions and state¶
Action type |
Properties |
|---|---|
emit |
Required nonempty name; optional arbitrary JSON payload up to the 4 KiB conservative compact JSON budget (null and nested JSON allowed). |
setPage |
Required existing pager ID; required page: next, prev, or nonnegative integer index up to 2147483647. Renderer clamps to available pages. |
setState |
Required state key and scalar value; creates or replaces a key. |
toggle |
Required key that the spec’s state declares as a boolean; flips it. |
increment |
Required key that state declares as a finite number; adds optional finite by (default 1, negative values decrement). Non-finite results are ignored. |
decrement |
Same as increment but subtracts by (default 1); decrement with a negative by adds. Non-finite results are ignored. |
dismiss |
No additional properties. |
No scripts, callbacks, expressions, or implicit navigation. setState must
preserve any text-field, selection, or sheet binding’s scalar type at runtime;
renderer enforcement is part of #9300. Event sequence and transmission are
9298/#9303. Dismissal does not remove the host’s safety responsibilities.¶
Conditions¶
A condition is exactly one of these forms. visibleWhen takes any of them;
bottomSheet.openWhen keeps the boolean {key, equals} form.
| Form | Holds when |
|---|---|
{key, equals: scalar} |
The state value is exactly equal (same type). A missing key never holds. |
{key, notEquals: scalar} |
The state value differs, including when the key is missing. |
{key, gt: number} |
The state value is a number greater than gt. Any other type is false. |
{key, lt: number} |
The state value is a number less than lt. Any other type is false. |
{all: [condition, ...]} |
Every member holds. One to 16 members. |
{any: [condition, ...]} |
At least one member holds. One to 16 members. |
{not: condition} |
The member does not hold. |
Numbers compare by value, so a state of -0 equals 0 on every platform (#11408).
Conditions nest to a depth of 8 (MAX_PROTOTYPE_CONDITION_DEPTH in the shared
contract). key takes exactly one comparison, and comparisons need key;
errors point at the offending field (for example root.visibleWhen.gt).
toggle, increment and decrement validate their key against the declared state
(root.onTap[0].key), and are no-ops at runtime if the key’s type was changed by
other means. After an onTap action list finishes, if setState, toggle,
increment or decrement changed state, the device emits exactly one change event carrying
the final state (#10622), so host status follows button taps. A single changed
key sends {key, value}; several send {keys, values}. emit actions still fire
in order with the state at that point, a list that nets no change emits nothing,
and a switch or checkbox tap keeps its own {key, value} change before its
onTap runs. Wire patches from the host stay silent.
An action list is atomic on Android (#11408). The runtime re-validates the state every
write would leave (binding types, numeric ranges, the size cap) before the first action
runs; if any write is rejected, nothing in the list is applied and nothing is emitted,
so device state and the host’s event-fed copy never drift apart. Actions after a
dismiss never run and are not checked. A control’s own write (a switch flip, a chosen
option, a slider value, a picked time, a tab selection, a dialog close) is part of the
same unit as its onTap list: a rejected list leaves the control’s value and a bound
pager’s page untouched too. An accepted control still reports its own change first
and then its list, as the iOS agent does. A rejected interaction is logged on the device
only; no event or status field reports it, so the host sees nothing change. The iOS
agent does not re-validate at runtime, so the same list applies there in full with
one change; the TypeScript host and Kotlin validators refuse at show a setState whose
value type does not fit a control bound to the key (#11452), which closes that gap on the host.
Conditional style: styleWhen¶
Any node may declare styleWhen: one to 8 entries of {when: condition, style: style}
(for selected, disabled or error looks without duplicating nodes). when takes
any condition form above; style is the same strict style object as style, so
an unknown key fails at root.styleWhen[0].style.colour. At render, every entry
whose condition holds is merged over the node’s base style in authored order:
a property set by a later matching entry wins, and properties no entry sets keep
the base value. Merging is per top-level property, so padding, border, offset and a
per-corner cornerRadius replace the base object whole rather than merging field
by field. Conditions read the
node’s local state, so pager {page}/{pageCount} keys work inside a pager.
List templates: repeat¶
A box, row or column may declare repeat: {items, as} to render one template
per item without writing every row out. The container’s children are the
template: they are instantiated once per entry of items, in item order, as
siblings inside the same container (the container itself is not repeated, so a
list is a column with repeat whose single child is the row template).
items holds 1–128 entries, and the expanded tree still counts against
MAX_PROTOTYPE_NODES.
{
"type": "column",
"repeat": {
"items": [
{ "name": "Alpha", "tag": "a" },
{ "name": "Beta", "tag": "b" }
],
"as": "item"
},
"children": [
{
"type": "text",
"text": "{index}: {item.name}",
"styleWhen": [
{ "when": { "key": "picked", "equals": "{item.tag}" }, "style": { "color": "#2255CC" } }
],
"onTap": [{ "type": "setState", "key": "picked", "value": "{item.tag}" }]
}
]
}
itemsis a literal array of 1 to 32 objects declared in the spec. Each object maps state-style field names to scalar values (string, finite number, boolean); no nesting.asnames the item binding (a state-key-shaped identifier). State values are scalar, so a state key cannot hold a list; per-state-key lists are not supported.- Placeholders are
{index}(the zero-based position) and{<as>.<field>}. They are bound in a template child’stext; in the component fieldsbutton.label,fab.label,segmentedButton.options[].label,topAppBar.title,topAppBar.navigationIcon/actions[](labeland theironTap),dialog.title,dialog.text,dialog.confirm/dismiss(labelandonTap),snackbar.textandsnackbar.action(labelandonTap); in condition operands (equalsandnotEqualsstrings invisibleWhen,styleWhen[].whenand nestedall/any/not), insetState.valueand inemit.name; and in every state key (#11051):stateKey,timePickerhourKey/minuteKey, alistItemtrailingstateKey,openWhen.key, conditionkeys and thekeyofsetState,toggle,incrementanddecrement, so"key": "liked_{item.id}"gives every row its own state. Anything else, including a{key}state placeholder, other braces, and the container’s own fields, is not a repeat placeholder and is untouched. Inside a template{index}shadows a state key namedindex. A string that is exactly one placeholder keeps the item’s own type, soequals: "{item.id}"matches a numeric state value whenidis a number; a placeholder inside a longer string renders to text (integral numbers without a decimal point).emit.payloadis not interpolated. Bound values are plain text, but atextnode is then interpolated against state as usual, so an item string that itself reads{status}shows the state value; item values cannot be escaped. - Validation, with the same paths in TypeScript and Kotlin: a placeholder naming a
field that is missing from any item fails at the string (for example
root.children[0].text, messageUnknown repeat field "nme"); a template may not contain anotherrepeat(root.children[0].repeat) or apager(root.children[0]);repeaton a leaf node is an unknown property; anemit.namethat binds to an empty string for any item fails at the name (root.children[0].onTap[0].name). A bound state key must be a literal key (^[A-Za-z_][A-Za-z0-9_]{0,63}$) for every item; one that is not fails at that item (root.repeat.items[1], messageBound state key "liked_b-c" is invalid). A state-key placeholder outside every template fails at the key (State key placeholder outside a repeat template). A bound key missing fromstatebehaves exactly like a literal missing key: the type checks run once per item and fail at the key, naming the item (Toggle requires a boolean state key (repeat item 1)). - Limits count the expanded tree.
MAX_PROTOTYPE_NODESandMAX_PROTOTYPE_IMAGESare checked against every instance, and an overflow fails at the container’srepeat(for exampleroot.children[2].repeat,Expanded node limit exceeded).MAX_PROTOTYPE_DEPTHis unaffected by design: instances are siblings, so a template child is exactly as deep as it was written. Because items are literal, all of this is decided statically; the renderer re-checks the expanded count before layout as a backstop. - Rendering expands the template before layout. Each instance node renders under
the path
container.repeat[item].children[template], which is also its Compose key, so a row keeps its identity for as long as it keeps its index.
Reusable components: components and use¶
A spec may declare a top-level components map of named node templates and place
them with use nodes (#11053), so a card, row or header used in several places is
written once.
{
"components": {
"postCard": {
"root": {
"type": "card",
"children": [
{ "type": "text", "text": "{props.name}" },
{
"type": "button",
"label": "Like",
"onTap": [{ "type": "toggle", "key": "{props.likeKey}" }]
}
]
}
}
},
"root": {
"type": "column",
"children": [
{
"type": "use",
"component": "postCard",
"props": { "name": "Alexey", "likeKey": "liked_a" }
},
{ "type": "use", "component": "postCard", "props": { "name": "Bea", "likeKey": "liked_b" } }
]
}
}
- Components are expanded on the host (
src/features/prototype/prototypeComponents.ts) before validation and transport: eachuseis replaced by a copy of the component’srootandcomponentsis dropped. Devices never see either, so the Kotlin and Swift renderers and the device validator are unchanged. - Component names and prop names are state-key-shaped. A
usenode has onlytype,componentand optionalprops; props are scalars (string, finite number, boolean). {props.<field>}binds in exactly the fields arepeatplaceholder binds (see List templates above), state keys included, plus thepropsof a nesteduse. A string that is exactly one placeholder keeps the prop’s type.{index}and other aliases are left for arepeatto bind, so auseinside a repeat template can pass"likeKey": "liked_{item.id}", and a component may containrepeatand furtherusenodes.- Rejected, with the path of the
use: an unknown component (root.children[0].component), a missing prop (.props), an unused or non-scalar prop (.props.<name>), a cycle including self-reference (.component, messageComponent cycle: a → b → a), andusenesting deeper than 8. Unused components are allowed. - Limits apply to the expanded tree: nodes, depth and images as usual (expansion
itself stops at
MAX_PROTOTYPE_NODES), and the byte limit applies to both the authored and the expanded spec (Expanded spec byte limit exceeded), because the device re-validates bytes. - An error inside an expansion names each
useit went through, for exampleroot.children[2] (use postCard) → components.postCard.root.children[1].label. Nodes outside every component keep their authored paths, because auseexpands to exactly one node.
Re-showing a prototype¶
show_prototype always carries a full spec; there is no partial update (#10490).
When spec.id is the prototype already on screen and reset is absent or false,
the device replaces it in place: it keeps the display the prototype is on (the
request’s displayId is ignored), and each pager keeps its settled page,
matched by pager id and clamped to the new page count. The new spec’s state
is authoritative; values the user changed by tapping or typing are not carried
over unless the spec includes them. The window content is rebuilt from the new
runtime, so a text field shows the spec’s value and edits still in flight from
the previous showing are dropped. Event sequences continue per id, as for any
re-show. reset: true, a different id, or nothing on screen is a fresh show:
pages start from the spec and the requested display is used. reset is only
sent when true; a device that predates it ignores the field and always starts
fresh. The host reports a display that a same-id show ignored as a warning
on the tool result.
Presenting alternatives¶
The host has no carousel helper; showVariants and its on-device pick were
removed (#10489, #10488). An agent shows one design with show, explains it and
the others in the conversation, and shows the next on request, or shows one spec
whose pager holds every design with a visible label per page (see the
full-screen pager example below). The user chooses in chat; nothing waits on the
device for a choice. pager, setPage and {page}/{pageCount} interpolation
remain spec primitives for that.
Anchors¶
9316 specifies semantics but not exact field names. These are the chosen strict¶
shapes:
{ "type": "bounds", "bounds": { "x": 12, "y": 48, "width": 300, "height": 56 } }
{
"type": "element",
"selector": {
"elementId": "42",
"text": "Buy",
"testTag": "buy",
"container": { "elementId": "card" }
},
"alignment": "cover",
"offset": { "x": 0, "y": 0 }
}
Bounds are screen-space dp; width/height are nonnegative. Element selector has
optional nonempty elementId, text, and testTag strings, and an optional container object, with
at least one supplied. Combined fields narrow the match. The container object uses the existing tap
container shape: exactly one nonempty elementId or nonblank text, optional
nonnegative integer index (up to 2147483647), optional selectionStrategy, and
an optional nested container. Anchors accept only selectionStrategy: "unique"
(default); first/random selection would contradict #9316’s ambiguous-anchor
rejection. Container chains have a separate MAX_PROTOTYPE_SELECTOR_DEPTH of 8;
the first container has depth 1. The existing TypeScript
ElementContainerSelector type is reused. Strings are preserved as authored. Alignment is required: cover, top, bottom,
start, or end; optional offset defaults to zero. Cover adopts target bounds;
edge alignment aligns the same node edge to the target edge, centered along the
other axis, preserving authored node dimensions, then applies the offset.
The host resolves selectors against the app hierarchy with the prototype excluded,
using tap’s resolution (a text match is promoted to its clickable owner) with
unique selection, and converts the element’s px bounds to dp once with the
display density the capture reports (px * 160 / densityDpi). Resolution happens at
show, not live. A missing, ambiguous (candidates listed), empty or off-screen element
fails the call before anything is shown. The result reports anchors (each anchored
node’s path with the element’s px and dp bounds) and hierarchyUpdatedAt; show again
to re-anchor after the app scrolls or re-lays out.
On the wire the device only receives bounds anchors: the host replaces each element
anchor with { "type": "bounds", "bounds", "alignment", "offset"? }, keeping its
alignment and offset. A bounds anchor may also be authored with alignment (default
cover) and offset. The renderer refuses an element anchor that reaches it
unresolved. A host only sends anchors to a CtrlProxy advertising prototype_anchor_v1;
older APKs decode and ignore them, so the host refuses the show there. Element anchors
resolve against the default display (another display is refused). On an iOS simulator
the injected prototype agent positions anchors too, advertising the same
prototype_anchor_v1 capability in its handshake; there, hierarchy bounds and spec sizes
are both points, so nothing is converted (see the
iOS prototype agent).
The renderer lays an anchored node at its screen rectangle by subtracting the window’s screen origin and the node’s own position inside the window (cutout, system bars, the fullscreen host row, safe-area and authored padding). An anchored node below the root is drawn in a window-level layer above the author tree (modal content: above its modal), so its parent neither reserves a slot for it nor clips it, and its touch target and accessibility bounds are where it is drawn (#10803). It is shown while all its ancestors are; it does not inherit their opacity. Cover sizes the node to the bounds, ignoring authored width/height. Window size is determined by placement/content, not by implicitly resizing to an anchor:
- fullscreen and sheet windows take anchors on any node; content outside the window (above a bottom sheet, or under the fullscreen host row) is clipped;
- a floating window moves onto its anchored root (top-start gravity, x/y in screen px) and stays content-sized, so the rest of the app remains touchable. Only the root of a floating window may be anchored; the host refuses an anchor below it.
Safe area and IME¶
{
"safeAreaPadding": {
"edges": ["bottom"],
"types": ["systemBars", "ime"]
}
}
Both arrays must be nonempty, with no duplicates. Edges: top, bottom, start,
end. Types: systemBars, cutout, ime. It mirrors Compose
windowInsetsPadding: take the selected inset types’ union (maximum per edge),
apply only selected edges, and consume those insets for descendants. Insets are
additional to authored style padding. No padding is applied automatically.
Bottom navigation, bottom sheets, and text-field-bearing content normally opt
into bottom/systemBars, plus ime when the keyboard should move content. Cutout
padding is normally useful at top/start/end. Anchors stay in screen coordinates;
inset padding does not reinterpret their coordinate origin.
Lifecycle and safety¶
Fullscreen windows reserve an opaque host row above clipped authored content, with
“Dismiss AutoMobile prototype”. Its visibility, style and opacity are independent of
the spec, including modal sheets and window.opacity: 0. The authored content
viewport excludes the host row; relative sheet detents use that remaining height.
Spec opacity continues
to apply to authored content and scrims; it cannot fade the safety control.
A persistence: "device" prototype outlives its host session: it is not dismissed
when the last client disconnects (including a show queued after that edge), it
has no idle TTL, and its uploaded assets are kept until it is dismissed. Because
nobody may be connected to remove it, sheet and floating windows also carry an
opaque “Close” control drawn above the authored content, and spec opacity fades
only the content. It ends through that control or the fullscreen dismiss row
(user), dismiss_prototype (agent), a replacing show, or service
unbind/destroy (teardown). Keyguard hiding is unchanged.
Events a persisted prototype emits while no client is connected (checked per event
against the live client count) go to a bounded ring in PrototypeController
(PrototypeOfflineEventBuffer, 200 events, oldest dropped and counted). The ring
is flushed under the controller mutex, so order is preserved: when a client
connects (onClientConnected), before the next live event, and before an
inspect_prototypes reply. Session-scoped prototypes never use it. inspect_prototypes
returns one prototype_result with prototypes (id, persistent, state,
pages, lastSequence) and droppedEvents, the cumulative count dropped from
the ring. The host adopts the report into its status store and event buffers,
because the host clears both on session release. Both behaviours are advertised
as prototype_persistence_replay_v1; a host refuses inspect without it.
Every dismissal emits one prototype_event with kind: "dismissed", null name,
and payload: {"reason": "user|agent|disconnect|ttl|teardown"} (one reason string).
Host and authored dismiss controls use user; dismiss_prototype uses agent;
last-client disconnect uses disconnect; idle expiry uses ttl; service teardown,
unbind/restart and owning-display removal use teardown. Sequence allocation
precedes delivery even if no socket remains. A show replacement closes the old
runtime silently and cancels its timer. Disconnect counts are captured at removal
with the existing observer-session generation so rapid reconnects cannot erase the
zero edge or dismiss a replacement from a new observer session. No state is
persisted across process restart.
Idle means no interaction or accepted show. The device fallback TTL is five minutes (300,000 ms), positive and settable locally on the controller. Shows (including a same-id show) and user interactions restart it (initial/restored unchanged pager reports are rendering and do not count); configuration changes and safety hide/restore do not. Hidden prototypes still expire. The current strict protocol has no TTL or device-session-release message: no wire field is added here. Session release is covered only when it closes the last WebSocket; a release that retains sockets requires a future daemon/device contract.
Rotation, density/size changes and fold posture callbacks refresh layout params without recreating runtime or composition: authored state and settled pager pages survive with no event or sequence allocation. If the owning display disappears, dismiss rather than moving content onto another display; it never revives on return. V1 uses the service’s default display; display selection remains #9308.
Show-time keyguard/screen checks fail closed. Screen and window signals hide the window while locked or noninteractive and restore the same runtime on unlock; no dismissal event is emitted for temporary hiding. Own-package accessibility events are dropped before hierarchy debouncing and navigation tracking.
Foreground scoping (#10261, owner decision 2026-10-09)¶
A session prototype is tied to the app (package) it was shown over, matching the iOS
agent, which lives inside the app. When that app leaves the foreground the window is
hidden (state, pages and the idle TTL are kept; no dismissed event, no sequence
allocation) and when the app returns the same runtime is shown again. A
window.persistence: "device" prototype is a standalone mock and is not tied to an app.
- Anchor. Each
show(including an in-place replace) reads the application window in front (the active application-type accessibility window, else the topmost) and clears any suspension. If no application window qualifies, the prototype is unscoped and stays up everywhere. - Signal.
TYPE_WINDOW_STATE_CHANGEDevents already delivered to CtrlProxy; no new poller. An event moves the foreground only when its window isTYPE_APPLICATIONand its package is not CtrlProxy’s own and not in the ignore set. IME, dialogs of type system, accessibility overlays (this prototype’s own windows) and the shade are therefore never candidates. Ignore set:com.android.systemui,com.google.android.permissioncontroller,com.android.permissioncontroller,com.google.android.packageinstaller,com.android.packageinstaller,android(resolver, ANR and crash dialogs). A dialog of the same app is the same package. An event whose window type cannot be read is ignored (fails open: nothing hides). - Debounce. A flip, either way, applies only after the new state holds for 400 ms
(
PROTOTYPE_FOREGROUND_DEBOUNCE_MILLIS), so a transient window does not flicker it. - Hidden means gone. The window is removed, so it takes no touches and is absent
from
observe.inspectreportssuspended: trueon the prototype entry, and the hierarchy capture carries top-levelprototypeSuspended: true, solayer: "prototype"calls fail with “hidden because the app it was shown over is not in front” instead of “no prototype is showing”. The host storessuspendedfrominspectand surfaces it onprototypestatus entries (host-local, so it is as fresh as the lastinspect; a newshowor aninspectafter the app returns clears it), and anawaitEventthat times out on a suspended prototype carries the same explanation as awarning. Absent on iOS. - Separate from other hides. Suspension is its own state
(
PrototypeForegroundTracker.suspended). The host’sisBlockedis the lock-screen check OR suspension, so any restore path (unlock, relayout, or a future capture-time hide from #9305) that callsshowwhile suspended is refused; a capture restore can never re-show a suspended prototype, and ending suspension does not undo a capture hide held by another component. - Dismissal, TTL expiry and teardown release the anchor. A hidden prototype still expires on its idle TTL.
Deferred: secure-window detection has no trusted existing signal.
Bottom sheet and the keyboard (#10262)¶
Owner decision 2026-10-09: a bottom sheet moves above the keyboard whenever the keyboard is shown and returns to the screen edge when it hides.
- Scope. Only a
sheetwindow withedge: "bottom". Fullscreen, floating, dialog, top and side sheet windows never move. AbottomSheetnode lives inside its window and rides with it; it needs no separate handling. - Source. The input-method window’s bounds from the accessibility window list
(
TYPE_INPUT_METHOD), not the prototype window’sWindowInsets.Type.ime(). Prototype windows areTYPE_ACCESSIBILITY_OVERLAYwindows that are not the IME target, and the platform dispatches the IME inset only to the IME target, so a sheet over another app’s field would read 0. The service sees the keyboard window whichever app owns the field. The lift isscreenBottom - ime.top(0 when no input-method window has bounds). This follows from platform behaviour and is unit-tested with fake bounds; the API 30/34/36 device check in the issue is still open. - Mechanism. The host relayouts on the window events it already receives
(
TYPE_WINDOWS_CHANGED), setting the bottom-gravity window’syto the lift (a positiveyraises a bottom-gravity window). Moving the window moves its touch region with it; touch-through and the foreground-suspension and capture hides are unchanged because they act on the same params andisBlocked. - No system panning. A bottom sheet sets
SOFT_INPUT_ADJUST_NOTHING, so a sheet that owns the focused text field is not also resized or panned by the platform. - Motion. The position jumps. The accessibility source reports no per-frame values,
so it cannot follow the keyboard’s slide (
WindowInsetsAnimationonly reaches the IME target).motion: "none"therefore has nothing to disable here. - Explicit padding.
safeAreaPaddingwithimestill applies inside the window; on a lifted sheet it reads 0 for a non-IME-target window, so it does not double-count.
Device checks must cover keyguard timing, daemon death, pager page 3 across rotation, fold/display removal, and API 30/34/36 keyboard/cutout geometry.
This is Android only. The iOS agent does not lift a sheet above the keyboard, and whether to rely on UIKit keyboard avoidance or match this behaviour is an open decision (see iOS prototype agent).
Rejection paths and deterministic first error¶
Paths omit a leading $ for ordinary root members: root.children[1].style.color.
Array indices use brackets. Non-identifier keys use JSON-quoted brackets, e.g.
root["bad.key"]; top-level equivalents start with $["bad.key"]. A whole
input, malformed JSON, or spec byte error uses $. Missing/unknown discriminator
errors end in .type. Paths identify the rejected input, before any defaults.
Both walkers use the same sequence:
- Raw UTF-8 byte limit, then strict JSON parsing (syntax error at
$). - Depth-first structural walk. At a node entry, check node count, node depth, then image-use count. Nav image uses are checked at item entry. Container depth is checked at container reference entry. At a tagged object, check its discriminator first.
- At each object, visit the union of present and declared keys in ascending
UTF-16 lexicographic order, independent of JSON insertion order. Unknown keys
fail at their own path; missing required keys fail there too. Arrays visit
increasing indices, with length checked first and uniqueness after each item.
Selection binding exclusivity is checked after its fields, at
.pagerfor both/neither. An empty selector fails at.selector. Container exclusivity fails at.elementIdafter its fields are checked. - After structure passes, find duplicate pager IDs in node preorder (second
ID fails at
.id); then resolve node pager references in preorder, then action pager references in encounter order. Finally check initialized text/nav state binding types in node preorder, then existing sheet state types in node preorder. - Decode typed models. Any schema/model disagreement is an internal contract
failure at
$, never successful partial content.
Lexicographic order avoids dependence on property insertion order or library union error ordering. The shared rule table reduces vocabulary duplication, but walkers and model decoders remain independent. Neither implementation parses Zod or kotlinx.serialization exception messages. Human error messages describe the failure; exact path parity is the shared contract.
JSON supplied as an already-decoded TypeScript value is measured as compact JSON, while string input measures original bytes. Only JSON-representable values are accepted. Duplicate JSON object keys must be avoided by authors; standard JSON decoders retain the last value. They are not unknown fields.
Worked examples¶
Full-screen variants with pager tabs¶
{
"id": "variants",
"window": { "placement": { "type": "fullscreen", "scrim": "#80000000" }, "opacity": 80 },
"state": { "query": "", "open": false },
"root": {
"type": "column",
"safeAreaPadding": { "edges": ["top", "bottom"], "types": ["systemBars", "cutout", "ime"] },
"children": [
{
"type": "tabBar",
"pager": "pages",
"items": [
{ "label": "Original", "icon": "home" },
{ "label": "Edited", "image": "variant-b" }
]
},
{
"type": "pager",
"id": "pages",
"children": [
{
"type": "scroll",
"axis": "vertical",
"child": { "type": "text", "text": "Page {page} of {pageCount}" }
},
{ "type": "image", "asset": "variant-b", "contentScale": "fit" }
]
},
{ "type": "textField", "stateKey": "query", "placeholder": "Feedback" },
{
"type": "text",
"text": "Next",
"onTap": [{ "type": "setPage", "pager": "pages", "page": "next" }]
},
{
"type": "bottomSheet",
"openWhen": { "key": "open", "equals": true },
"detents": ["half", "full"],
"child": { "type": "text", "text": "Details" }
}
]
}
}
Bottom window sheet with state navigation and horizontal scrolling¶
{
"id": "choices",
"window": { "placement": { "type": "sheet", "edge": "bottom", "height": 240 } },
"state": { "selected": 0 },
"root": {
"type": "column",
"children": [
{
"type": "scroll",
"axis": "horizontal",
"child": {
"type": "row",
"children": [
{
"type": "text",
"text": "Save",
"onTap": [{ "type": "emit", "name": "save", "payload": { "variant": "A" } }]
},
{ "type": "spacer", "style": { "width": { "dp": 16 } } },
{
"type": "icon",
"name": "favorite",
"onTap": [{ "type": "setState", "key": "selected", "value": 1 }]
}
]
}
},
{
"type": "bottomNav",
"stateKey": "selected",
"safeAreaPadding": { "edges": ["bottom"], "types": ["systemBars"] },
"items": [
{ "label": "Home", "icon": "home" },
{ "label": "Favorites", "icon": "favorite" }
]
}
]
}
}
Floating anchored replacement¶
{
"id": "button-preview",
"window": {
"placement": { "type": "floating", "gravity": "topStart", "offset": { "x": 24, "y": 120 } },
"opacity": 50
},
"root": {
"type": "box",
"anchor": { "type": "element", "selector": { "testTag": "buy" }, "alignment": "cover" },
"style": { "background": "#2255CC", "cornerRadius": 12 },
"children": [{ "type": "text", "text": "Buy now", "onTap": [{ "type": "dismiss" }] }]
}
}
The committed doc-example-3 fixture is this example verbatim. The renderer refuses an unresolved
element anchor, so the screenshot gallery substitutes a fixed resolved bounds anchor (same
alignment) before rendering; only the host resolves selectors.
Shared verification and sibling updates¶
Both test suites enumerate the same test/fixtures/prototype-spec/valid and
invalid JSON files, assert nonempty directories, and require coverage of every
node/action/placement. Invalid files wrap {spec, expectedPath}. The byte limit
has no fixture file: each suite generates its own input and checks the exact
boundary in bytes, including whitespace, as well as the emit payload boundary.
Fixture and contract files are pinned to LF for Windows. No binary assets are
included. Worked examples above are also committed as valid fixtures and decode in both
suites. The page is registered under How it Works in mkdocs.yml and linked from the
design index. The existing system-tray page remains in its internal not_in_nav
registration; this public contract has an explicit navigation entry.
Sibling follow-ups: #9300 must include horizontal scroll, tabs/navigation, and
modal sheet state; #9298 must use the envelope’s id and window rather than
introducing conflicting separate placement/spec identity; #9316 must adopt the
anchor field names and selected placement/origin rules; #9301 must use opaque
asset strings and count nav images as spec image uses; #9296 must remove its
specVersion proposal. #9295’s root-level opacity wording maps to
window.opacity, and its focusability wording follows the later device findings.
No sibling issues are edited as part of this implementation.