iOS simulator prototype agent¶
Agent-authored prototypes (milestone 37, #9295) on iOS simulators, with no SDK in the target app. This is the iOS counterpart of the Android prototype specification; the spec, validation limits and node vocabulary are shared. See epic #10563 and the prototype in #10498.
Status: epic #10563 is complete apart from device verification. The agent (ios/prototype-agent/), the
release dylib and its checksum-verified download (#10564, #10565), the per-launch port, token and
handshake (#10566), launchApp { prototype: true } (#10567), prototype routing (#10568) and the
advisory per-runtime simulator smoke test (#10569) are merged, and the tool now covers anchors
(#10874), pressScale (#10885), reset on show (#10998), hiding the prototype from layer: "app"
screenshots (#10943, #10988) and the renderer and accessibility fixes that followed (#10903,
10918, #10928, #10953). Still open: device verification of the iOS window-layer tapOn fix from¶
10498, and the decisions listed under Open decisions.¶
scripts/ios/prototype-agent-demo.ts is a standalone driver that launches with a fresh port and token.
Feature support¶
| Feature | iOS simulator |
|---|---|
show, dismiss, status, awaitEvent; fullscreen, floating, sheet |
Supported |
Replace in place, and reset: true to start fresh |
Supported (prototype_show_in_place_v1) |
| Element and bounds anchors | Supported, in points (prototype_anchor_v1); see Anchors |
pressScale, motion, visibleWhen, state and actions, assets and fonts |
Supported |
| Material component nodes, dialog, snackbar, pickers | Supported; see Material components |
Reusable components / use nodes (#11053) |
Supported: the host expands them before sending, so the agent only sees plain nodes |
Prototype hidden from layer: "app" observe screenshots (target) |
Supported (#10943, #10988); the host restores it after capture |
display selector |
Refused: a simulator has one screen |
window.layer: "app" |
Accepted and ignored, silently: the window level is fixed at alert + 1 (decision 3) |
window.persistence: "device" |
Refused: the agent lives in the app process and dies with it |
inspect |
Supported (prototype_inspect_v1); see Inspect |
| Idle TTL and dismissal on host disconnect | Supported; see Expiry. Reasons user, agent, ttl, disconnect |
| Per-mode colours and images, gradients, role scrims (#11220) | Supported, not yet device-verified (prototype_theme_modes_v1); see Light and dark values |
| Appearance override, inference, live re-theme, reported mode (#11222) | Supported, not yet device-verified (prototype_appearance_v1); see Appearance |
| Bottom sheet lifting above the keyboard | Supported, not yet device-verified; see Bottom sheet and the keyboard |
Style weight, minWidth/maxWidth/minHeight/maxHeight, aspectRatio (#11543) |
Supported, not yet device-verified: sized by PrototypeSizing in Android’s order (bounds, then width/height, then aspect ratio); a weight takes exactly its share of a row/column’s main axis and beats that axis’s size and bounds, and is ignored outside a row or column; min wins when it exceeds max; content on an axis with no authored size (or wrap) is centred in the box a min bound grew, as wrapContentWidth()/wrapContentHeight() do, while an explicit width/height/fill keeps the node’s alignment; weighted stacks mirror under RTL via SwiftUI’s own Layout mirroring; sizes are fractional points, not rounded to pixels |
Non-scalar state values (null, array, object) |
Refused at show, as Android and the host schema do: state is string, number or boolean only |
| Foreground scoping to the shown-over app | Not needed: the agent lives in the app, so backgrounding the app hides its window |
Open decisions¶
All of these are decided (2026-10-09); the table above describes the code, and the open item is simulator verification.
- Decided 2026-10-09:
inspectmaps to the agent’sget_prototype_status(see Inspect). - Decided 2026-10-09: a bottom sheet lifts above the keyboard from keyboard-frame notifications (see Bottom sheet and the keyboard). Still open: a simulator check.
- Decided 2026-10-09:
window.layer: "app"is accepted and ignored silently on iOS, with no warning, so one spec runs on both platforms. The prototype stays at window level alert + 1.
Expiry¶
Owner decision 2026-10-09: iOS prototypes expire exactly like Android session prototypes (Lifecycle and safety).
- Idle TTL. Five minutes (300,000 ms;
PrototypeIdleTimer, settable locally throughPrototypeModel.idleTtlMilliseconds, no wire field). An acceptedshow_prototype(including a same-id replace) and a user interaction (taps, edits, a pager page that actually changed) restart it. Initial pager reports, a snackbar’s owndurationMsclose,hide_for_captureandget_prototype_statusdo not. A hidden prototype still expires. - Disconnect. The prototype belongs to the host session, not one socket. The agent counts
authenticated connections (
PrototypeClientTracker); when the last one closes the prototype is dismissed and the uploaded assets are dropped, whether or not a prototype was showing. Ashow_prototypehandled after that edge is dismissed the same way. - Events. Each expiry emits one
prototype_eventkind: "dismissed"with a nullnameandpayload: {"reason": "ttl"}or{"reason": "disconnect"}, sequenced from the same per-id ledger as every other event (the disconnect event has no socket to reach).userandagentare unchanged. - Not applicable.
teardownis never emitted: the agent has no service to unbind, and the prototype dies with the app process. There is nopersistence: "device", so nothing is exempt from the TTL. - Host. No host change or capability is needed: the host holds one persistent connection per
launch and already treats any
dismissedevent as removing shown status.
How it works¶
ios/prototype-agent/builds a small simulator-only dylib, ad-hoc signed. It needs no Developer ID identity, notarization or provisioning because it only loads into simulator processes.- The app is launched with
simctl launchand the child environment variableSIMCTL_CHILD_DYLD_INSERT_LIBRARIESpointing at the dylib.simctlstrips theSIMCTL_CHILD_prefix, so dyld loads the library into the app beforemain(). - The agent adds a SwiftUI
UIWindowat window level.alert + 1, above the app’s own windows, and renders the shared prototype spec into it. - The window’s
hitTestreturnsniloutside the prototype content, so touches there pass through to the app. Floating cards therefore leave the live app usable. - The agent listens on a loopback (
127.0.0.1) TCP socket and speaks the CtrlProxy prototype message names (show_prototype,dismiss_prototype,put_prototype_asset,prototype_result,prototype_event) as newline-delimited JSON. There is noupdate_prototype(#10490): ashow_prototypewith the id already shown replaces it in place. - Each launch gets its own port and a random token. The host passes them as
AUTOMOBILE_PROTOTYPE_PORTandAUTOMOBILE_PROTOTYPE_TOKEN; the first frame on a connection must be ahellocarrying the token, and the agent replies with its version and capabilities. The agent does not start its server without both values, and the token must be at least 16 characters.
Because the window lives inside the app’s process, observe, tapOn, sendKeys,
screenshots and video recording all see the prototype the same way they see app UI.
Limits¶
- Simulator only. On physical devices code signing blocks the injection. Use the in-app SDK route for devices.
- Only apps launched with the agent. Injection happens at launch, so enabling it for a
running app means relaunching it, which loses in-memory app state. An app started any
other way (home screen, a plain
launchApp, Xcode) has no agent unless you set it up as described below. - SpringBoard and system UI are not covered, so the home screen cannot host a prototype.
- Prototypes disappear when the app exits or is relaunched without the agent.
- Structural limits are re-checked. The agent does not validate a spec, but it refuses one
whose expanded tree passes the node (2000), depth (24) or
repeatitem (128) limit ofschemas/prototype-spec-contract.jsonbefore expanding it (#11049). - Every state write is validated. A tap, a control’s own write, text-field typing and
setStatesall go through one validator (PrototypeStateValidator) with Android’s binding rules, and a rejected interaction changes nothing and emits nothing (#11408, #11551, #11562). A rejected edit also redraws the text field so it shows the accepted text again. Android versions each field’s text (textEpochs) to drop edits typed against replaced text; the iOS field binds the live state on the main actor, so there is no in-flight stale edit to drop. - Spec byte cap (difference from Android). Android re-encodes the whole spec, state
included, after each write and rejects one over
MAX_PROTOTYPE_SPEC_BYTES(1 MiB of UTF-8). The agent keeps no raw spec bytes, so it measures the shown spec’s size outsidestateonce, from theshow_prototyperequest’s ownspec(re-serialized, so whitespace is not counted), and adds the candidate state’s JSON size to it on each write. A rejection (Spec byte limit exceeded) is therefore the same up to a few bytes of encoder difference (number spelling, default fields Kotlin re-emits). A session built without a request (unit tests) counts the state alone.
Entry point: launchApp { prototype: true }¶
launchApp with prototype: true on an iOS simulator is the intended entry point (#10567).
It resolves the dylib, allocates a port and token, launches with the SIMCTL_CHILD_*
environment, and records the agent against the device and bundle id so prototype can
connect. A normal launchApp injects nothing. Any DYLD_INSERT_LIBRARIES you already set
is preserved. On a physical device the option fails with a simulator-only error.
If prototype targets an app that was not launched this way, it fails and tells you to
relaunch with launchApp { prototype: true } rather than relaunching on its own, because a
relaunch loses state.
Where the dylib comes from¶
The client resolves the dylib in this order (#10565):
- An explicit path.
- The
AUTOMOBILE_IOS_PROTOTYPE_AGENTenvironment variable, a path to a local build. Use it for repository checkouts and development builds. - The local build output.
- A download of the release asset, checksum-verified and cached under
~/.auto-mobile/prototype-agent/.
If there is no pinned checksum for the running build, the client reports that the prototype
agent is unavailable instead of using unverified bytes. Set
AUTOMOBILE_SKIP_IOS_PROTOTYPE_AGENT_DOWNLOAD to disable the download.
Apps launched from Xcode¶
When you run the app from Xcode, AutoMobile does not launch it, so add the injection to the scheme yourself:
- Product, Scheme, Edit Scheme, select Run, then the Arguments tab.
- Under Environment Variables, add
DYLD_INSERT_LIBRARIESwith the absolute path of the dylib: the cached file under~/.auto-mobile/prototype-agent/, or the file named byAUTOMOBILE_IOS_PROTOTYPE_AGENTfor a local build. - Add
AUTOMOBILE_PROTOTYPE_PORTandAUTOMOBILE_PROTOTYPE_TOKEN(at least 16 characters) with values you also give the client, since AutoMobile did not choose them. - Run on a simulator destination. In a scheme the variable is
DYLD_INSERT_LIBRARIESitself; theSIMCTL_CHILD_prefix applies only tosimctl launch.
Do not enable this on device destinations or in archive/release configurations; the dylib is simulator-only and device launches will fail to load it.
Differences from Android¶
- Numbers in
{key}text andrepeatbindings render exactly as the host’srenderScalardoes (PrototypeScalarText): integral values as exact plain digits at any magnitude (-0is0), all others as JavaScriptString(number)(1e-7,1.5e-7,0.30000000000000004). prototypeon iOS has noshowVariantsand noupdateaction (removed by owner decision). Showing a spec with an id that is already shown updates it in place, keeping each pager’s page;reset: truestarts it fresh (the agent advertisesprototype_show_in_place_v1, and the host refusesreseton an older agent). For a variant carousel, compose the spec yourself andshowit.- No
displayselector and nowindow.persistence: "device"; the host refuses them.window.layer: "app"is accepted and ignored. See Feature support. inspectreports the one prototype the agent shows, with nosuspendedand nodeviceDroppedEvents(nothing is persisted or buffered offline). Idle TTL and disconnect dismissal match Android; see Expiry.- Rendering is SwiftUI in the app’s process rather than Compose in CtrlProxy, so there is no separate accessibility service involved.
- Android reaches any app through CtrlProxy; iOS reaches only apps launched with the agent.
Inspect¶
inspect (#10494 on Android) asks the device which prototypes it shows and adopts them into the host’s
status and awaitEvent state, for use after a session release or daemon restart. On iOS the host
sends get_prototype_status and adopts its reply. The agent holds at most one prototype and dies with the
app, so the report is that one prototype: id, state, pages and lastSequence (the agent’s event
ledger for the id, so the host resumes past it), with persistent: false. The agent also replies
visible, whether its window is on screen, which the host does not surface. There is no suspended
(the prototype lives in the app, so backgrounding the app hides its window), no offline event replay
and no deviceDroppedEvents. A prototype the host lists that the agent no longer shows is dropped.
The agent advertises prototype_inspect_v1 for the lastSequence and visible fields. The host
refuses inspect on an agent without it, before sending anything, and asks to relaunch with
launchApp { prototype: true } to load the current agent.
Bottom sheet and the keyboard¶
Owner decision 2026-10-09, matching Android:
a sheet with edge: "bottom" (or no edge) moves above the software keyboard while it is shown and
returns to the screen edge when it hides. Fullscreen, floating and top sheets never move.
- Source. The agent runs in the app’s process, so
keyboardWillChangeFrameandkeyboardWillHideare the notifications of the keyboard the app shows. The lift is how far the keyboard’s end frame reaches into the prototype window (PrototypeKeyboardLift.amount, in the UIKit-free core), 0 for a hardware keyboard or a hidden one, and never more than the window. - No double lift (#11042). This lift is the only thing that moves for the keyboard. Both hosting
controllers (page and top layer) set
safeAreaRegionsto none (PrototypeKeyboardLift.hostSafeAreaRegions) and the root view ignores every safe-area region, so neither UIKit nor SwiftUI adds its own keyboard inset; floating prototypes do not move, as on Android. On an iOS 26.5 simulator the root’signoresSafeArea(.keyboard)alone still let a 300 pt sheet move 527 pt for a 334 pt lift. Core tests pin sheet bottom == keyboard top, including for a window with a non-zero origin and a shorter height. - Touch and clip. The sheet’s content is padded up by the lift inside its full-window frame, so
its reported touch rect moves with it, and the anchor layer’s clip region is computed with the same
lift (
PrototypeSheetFrame.rect(lift:)), so anchored nodes and their touch targets stay in sync. - Motion. The move animates over the keyboard’s own duration, with an ease-in-out curve (UIKit’s
private keyboard curve is not public API). It is instant under spec
motion: "none"or Reduce Motion (PrototypeKeyboardLift.animationDuration). - Not yet verified on a simulator. No simulator was driven for this change.
Light and dark values¶
The agent advertises prototype_theme_modes_v1 (#11220, part of #11215), so the host sends it the
per-mode spec forms. The rules below are the Core target’s (PrototypeModeValue,
PrototypePalette, PrototypeGradient, PrototypeThemeModes), covered by swift test; the
drawing itself is not yet device-verified.
Which mode. One mode is resolved per shown prototype, and every per-mode value follows it. The order, and what the agent reports about it, is in Appearance.
Colours. Every colour slot takes a hex value, a role name, or {light, dark} whose sides are
each a hex value or a role: style.background, style.color, style.shadowColor,
style.border.color, a styleWhen entry’s style, gradient stop colours, window.placement.scrim
and a bottomSheet scrim. A pair gives its side for the resolved mode; that value is then
resolved like a single one, so a role comes from the resolved mode’s scheme.
Theme role maps. The scheme is built in this order: the baseline Material scheme for the
mode, the seed scheme, the flat theme.colors.<role> overrides (both modes), then
theme.colors.light or theme.colors.dark for the resolved mode. A mode map never takes part in
choosing the mode. A top-level theme.colors key that is neither a field nor a role is not
applied and is logged once per show.
Gradients. style.gradient is painted over style.background and under the content, inside
the node’s corner shape. The geometry matches Android: a linear gradient’s angle is degrees
clockwise from left to right (0 runs left to right, 90 top to bottom) and its line passes through
the centre, long enough that the corners take the first and last stop colours; a radial gradient
starts at the centre and reaches the corners. Positions apply only when every stop has one, and
are made non-decreasing; otherwise the stops are spread evenly. A card and a topAppBar drop
their default container when the node has a gradient, as they do for a background.
Scrims. window.placement.scrim and a bottomSheet’s scrim resolve through one function,
PrototypePalette.scrim (owner decision 2026-10-10). Only the scrim role gets an alpha: named
directly or as the resolved side of a pair, it draws the scheme’s scrim colour at 40% opacity
(PrototypePalette.scrimRoleAlpha, the same number as Android’s PROTOTYPE_SHEET_SCRIM_ALPHA),
because the role itself is opaque black. Any other role used as a scrim draws its colour
unchanged, and a hex value keeps exactly the authored alpha. Outside a scrim slot the scrim
role is the plain colour. A bottomSheet’s authored scrim takes the touches it covers, and a tap
on it closes the sheet by writing !equals to its openWhen key, as on Android.
Known differences from Android.
- A bottomSheet without a
scrimdraws nothing behind the sheet. Android draws its default scrim (thescrimrole at 40%). - A bottomSheet’s scrim covers the sheet node’s own frame, behind the sheet surface, not the whole prototype window: the iOS renderer draws a sheet in place instead of hoisting it above the tree.
- A
buttonkeeps its system fill, which covers astyle.gradientbehind it. Android makes the button’s container transparent so the gradient shows.
Images. image.asset and a tabBar / bottomNav item image take an asset id or
{light, dark} ids; the id for the resolved mode is drawn. A nav item draws its image at 24 pt
when the asset is uploaded, then its icon, then a placeholder square. Both ids of a pair are
reported in missingAssets until they are uploaded.
Malformed values. The agent has no copy of the host validator. For the forms this capability
added it checks them while decoding, and a malformed one fails show_prototype with
Invalid prototype spec: <path>: ..., leaving the shown prototype untouched: a pair that is not
exactly {light, dark} strings, a pair side or a gradient stop or a scrim that is neither a hex
value nor a role, an empty id in an image pair, a gradient with an unknown type, a linear one
without an angle, fewer than two or more than four stops or a position outside 0 to 1, and a
theme.colors.light / dark that is not a non-empty {role: hex} map. Every shared invalid
fixture for these forms fails on iOS (PrototypeThemeModesTests). Two things stay with the host
alone: the image-count limit (the agent has none, and a spec over it draws correctly), and a
single unknown value in a slot that predates the capability (a style colour, a border or shadow
colour), which draws that slot’s fallback as before.
Appearance¶
The agent advertises prototype_appearance_v1 (#11222, part of #11215). The rules are the Core
target’s (PrototypeAppearance, PrototypeSession), covered by swift test; the UIKit side is not
yet device-verified.
Resolution order. The same on Android (#11215 D4, owner decision 2026-10-10). The first that
applies decides the mode, and names the source the agent reports:
| Step | Decides | source |
|---|---|---|
| 1 | theme.mode light or dark |
explicit |
| 2 | Luminance of the flat theme.colors.background (else surface) |
roleLuminance |
| 3 | The first opaque authored background on the root’s leading chain | authoredBackground |
| 4 | The system setting: the show’s appearance when light or dark |
override |
| else the simulator’s appearance | system |
The override replaces the simulator’s setting only, so it never beats what the spec itself says: a
spec with no mode and a white root shown with appearance: "dark" is light, from
authoredBackground. override is reported only when the override decided the mode.
theme.mode: "system" asks for the system setting outright, so it skips steps 2 and 3. A surface
is dark when its luminance is below 0.179, as on Android.
Authored background. Step 3 is Android’s prototypeAuthoredTheme. The leading chain is the
root, then its first visible child, and so on; a pager continues into its current page, and a
single-child node into its child. A node hidden by visibleWhen paints nothing and is skipped.
The background is the node’s style with every matching styleWhen merged in. Only a single hex
value with alpha of at least 0.99 decides: a role name and a {light, dark} pair depend on the
mode, so neither can infer it, and the search continues below them. theme.colors.light / dark
never take part either. Unlike Android, the inferred background is not painted over the scheme’s
surface roles; it only picks the mode.
Override. show_prototype takes a request-level appearance: device (the default, also
when the field is absent), light or dark. It belongs to that show: a later show without it
follows the simulator again. Any other value fails the show with
appearance: Expected device, light or dark and leaves the shown prototype untouched. The
simulator itself is not changed, so the app behind keeps its own appearance.
One mode for everything. PrototypeSession.appearance is derived from the spec, the current
state and pages, the override and the simulator’s appearance. The palette, the host chrome (the
dismiss bar and close chip), scrims, per-mode colours and images, the SwiftUI colorScheme and
the window’s UIKit trait all read that one value. A state change that changes the inferred
background (a styleWhen, a visibleWhen, a pager page) therefore moves all of them together.
UIKit trait. The prototype window’s overrideUserInterfaceStyle is set to the resolved mode,
so UIKit-backed pieces (a text field’s keyboard, dynamic UIColors) match what SwiftUI draws.
Because the window is overridden, the simulator’s appearance is read from the window scene’s trait
collection instead, and the agent does not adopt an app window’s own override (#11215 Q1).
Live re-theme and the event. The agent registers for UITraitUserInterfaceStyle changes on
the window scene. A change re-resolves the shown prototype, keeping its state and pages. Whenever
the resolved mode of a shown prototype changes, the agent pushes exactly one prototype_event
with kind: "appearance_changed", name: null and payload: {mode, source}, sequenced like any
other event. That covers the simulator flipping, and a state change that moves the inferred
background; for the latter the event follows the change or page_changed that caused it
(PrototypeSession.transition). There is no event when the mode stays as it was (an explicit
mode, a repeated report, a state change that leaves the leading background’s polarity alone), for
the show itself (its result reports the mode), or with nothing shown.
Report. A successful show_prototype result and get_prototype_status’s status carry
appearance: {mode, source, deviceDark}: mode is light or dark, source is one of the five
names above, and deviceDark is the simulator’s own appearance whatever decided the mode. The
agent states it in each reply; the host’s prototype status action is host-local and reports
only mode and source, because a pinned mode sends no event when the appearance flips (#11416). The
status has no appearance while nothing is shown.
Anchors¶
Node anchors (#9316) work as on Android, in points.
The host resolves each element anchor once at show against a fresh hierarchy of the app
alone (the agent’s own window, found by its dismiss control, is excluded) and sends it as
a bounds anchor. iOS hierarchy bounds are already points, the unit iOS spec sizes use, so
no density conversion happens; the result’s anchors[].boundsPx and bounds carry the
same point values. The host sends anchors only to an agent whose handshake advertises
prototype_anchor_v1, and refuses the show otherwise (relaunch with launchApp
{ prototype: true } to load the current agent). The agent refuses an element anchor that
reaches it unresolved.
The agent draws anchored nodes in a window-level layer above the spec tree (above its
modal, for a node inside a dialog), so a parent neither reserves a slot for one nor clips
it, and its touch target and accessibility frame are where it is drawn. The layer places
a node at its anchor’s screen rectangle less the layer’s own screen origin: the prototype
window’s screen origin plus the layer’s position in the window. For a fullscreen prototype
the layer sits in the content area below the dismiss bar, so anchors are shifted up by the
bar and an anchor under the bar is clipped. cover sizes the node to the bounds, ignoring
authored width and height; the edges keep the node’s size, align that edge and centre it
on the other axis; start and end follow the layout direction; offset applies last,
in screen axes. Only the root of a floating prototype may be anchored, as on Android; with
no window to move, the layer places the anchored root itself and the rest of the screen
stays touchable. Anchors are resolved once and do not follow later scrolling.
Material components¶
The agent draws every component node of the shared vocabulary (#10439) with SwiftUI and
follows Android’s behavior for each: what a tap, pick or drag does, the change events it
emits, and the <testTag>.<part> identifiers of composite parts. The tap semantics live in the
UIKit-free core (PrototypeComponents.swift), so the simulate_tap test hook performs the same
transition as a real tap, including on a part identifier.
| Node | iOS drawing |
|---|---|
switch, checkbox |
One toggle element with a SwiftUI-drawn track (no UISwitch); a checkbox is a button that reads as selected |
button |
.borderedProminent filled, .bordered tonal/outlined/elevated, .borderless text; optional leading SF Symbol |
slider |
Slider with step snapping, one adjustable element labelled by label |
chip |
Rounded button; a filter chip toggles its key and reads as selected |
card |
Rounded container, filled, elevated (shadow) or outlined |
radioGroup, segmentedButton |
One button per option, identified <testTag>.<value>, with a selected trait |
listItem |
One row element; a toggle when it has a trailing switch or checkbox |
iconButton, fab |
Icon buttons labelled by contentDescription, a FAB’s label, else the icon |
topAppBar |
Header title; <testTag>.navigation and <testTag>.actions.<index> buttons |
divider, badge, progress |
Hairline, dot or count capsule, determinate ProgressView or ring, indeterminate sweep |
timePicker, datePicker |
Wheel DatePicker (one change per edit), graphical DatePicker in UTC days |
dialog, snackbar |
Drawn above the author tree inside the prototype window; <testTag>.confirm, .dismiss, .action |
A segmented Picker cannot carry per-segment accessibility identifiers, so segmented buttons
are drawn as a row of buttons. A dialog’s scrim covers the whole window (below the dismiss
bar in fullscreen) and takes every touch while it is open, even for floating or sheet
placements; a snackbar takes touches only on itself. A dialog’s title (a header), text, child
controls and buttons are each their own accessibility element. While a dialog is open the page
behind it (the spec tree and its anchor layer) is collapsed out of the accessibility tree, as it
is inert to touches; accessibilityHidden alone did not keep it out of the XCUITest snapshot
(#10899). The dialog itself is not marked modal (#10912). Otherwise each layer is its own
accessibility container, so a page whose only element is one node reports that node at its own
frame, not the whole page’s (#10898). Opening or closing a dialog drops keyboard focus in the prototype unless the
dialog holds a text field; a child taller than the screen scrolls. Material icon names map to
SF Symbols; a name without a mapping draws a placeholder.
A snackbar that sets durationMs closes itself that long after it opens, by writing !equals to
its openWhen key like any other close (a plain timer behind an injectable PrototypeClock).
Motion matches Android (#10442, #10439): a visibleWhen node fades and expands in and out, its
transition (none, fade, expand, slide) picks the animation, and pager page changes
animate. A box, row or column also animates its size (Android’s animateContentSize) when a
direct child appears, disappears or resolves to a new width or height, so siblings slide into
freed space; unrelated state changes such as typing do not animate. The animation is scoped to
PrototypeNode.containerLayoutSignature, so only layout changes trigger it. Spec motion: "none" or
the system’s Reduce Motion (UIAccessibility.isReduceMotionEnabled) makes every change instant.
The host’s dismiss control (automobile-prototype-dismiss, “Dismiss prototype”) cannot be removed
by the spec. In fullscreen it sits in a bar across the top of the window, like Android’s
dismiss bar: the bar clears the status bar and cutout, is only as tall as the 44 pt control, is
translucent and follows the spec’s light or dark theme, and the spec is laid out below it, so
the control never covers spec content. safeAreaPadding inside a fullscreen spec therefore
sees no top inset. Floating and sheet prototypes keep the control at the window’s top trailing
corner.
See the prototype tool reference.