---
title: "UI Toolkit runtime behaviour"
slug: /workflows/ui-toolkit-runtime/
markdown: /workflows/ui-toolkit-runtime.md
section: ui
tier: 2
summary: "Layout, picking, events, style precedence, atlas cost and the pre-ship sweep."
sources: ['uitk-runtime.md']
canonical: https://unityworkflowlibrary.dev/workflows/ui-toolkit-runtime/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# UI Toolkit runtime behaviour

> Layout, picking, events, style precedence, atlas cost and the pre-ship sweep.

How a UITK screen behaves once it is running: layout, picking, events, style precedence, texture/atlas cost, and the pre-ship checklist. What a screen should look and read like is the ui design page. Porting a design into UXML/USS is the [HTML/CSS mock to UI Toolkit](/workflows/html-css-to-ui-toolkit/) page.

- **Never nest an independently-scrolling region inside an outer page ScrollView — and preserve
  scrollOffset across per-edit rebuilds.** Two UITK scroll footguns that both read as "the panel
  expands/jumps at the wrong time": (1) a ScrollView gives its children UNBOUNDED height, so a
  list+detail split (or anything with its own inner ScrollViews) placed inside a page-level scroll
  is expanded to full content height — the inner scrolls never engage and the whole page scrolls
  instead. Structure such tabs as a sticky header (title/banner/tab bar, `flex-shrink: 0`) + a
  `flex-grow: 1; min-height: 0` host, with scrolling only INSIDE the panes. (2) A pane rebuilt on
  every edit (`Clear` + rebuild — the standard form-refresh shape) resets its ScrollView to the
  top, so a toggle deep in a long form throws the user back up every click. Capture
  `scroll.scrollOffset` before `Clear`, restore it in a one-shot `GeometryChangedEvent` callback
  after the rebuild (offset clamps automatically if content shrank); keep the restore keyed to the
  same item — a genuinely new selection should start at the top. (Seen in a survival RTS: Races & Waves tab had both — split pinned at min-height inside the page scroll, and kit-part
  toggles jumping the form to the top.)

- **UITK-FILTER-KEEP-FOCUS — never `Clear` the parent of a live TextField on each keystroke.**
  A Find/filter that rebuilds the whole list pane (search field included) destroys the focused
  element after every character — reads as "I can't type, it unfocuses." Keep Find + CRUD chrome
  alive; only `Clear` the row host. Filter callback rebuilds rows, not the TextField. Same
  family as UITK-SCROLL (preserve offset) — this one is about focus, not scroll.

- **UITK-AUTHORING-DELAY-SAVE — typed SO fields commit on focus-out; never `SaveAssetIfDirty` per keystroke.**
  `IntegerField` / `FloatField` / `TextField` `ValueChanged` fires every character. Pairing that
  with `AssetDatabase.SaveAssetIfDirty` hitchs the whole Designer Console (disk + import) while
  typing "28" into Interval. `GameUi` text/int/float rows are `isDelayed` (Enter / focus-out).
  `AuthoringDirty.Mark` SetDirtys and flushes after ~0.45s quiet. Live Find/filter stays
  `delayed: false` and must not save. Sibling of UITK-FILTER-KEEP-FOCUS.

- **UITK-SO-REBUILD-CHROME — if `Build` allocates new list/detail panes, reset the chrome latch.**
  `SoAuthoringView` keeps Find + CRUD alive across row rebuilds (`_listChromeBuilt`). Callers that
  `Clear` a sub-tab host and call `Build` again (Races & Waves tab switch) get new columns
  while `_search` still points at the discarded tree — Find and the asset list vanish. Reset
  `_listChromeBuilt` / `_search` / `_listScroll` at the start of every `Build`. Keep `_filter`
  and `Selected` so the query and pick survive. Sibling of UITK-FILTER-KEEP-FOCUS.

- **A UI Toolkit PanelSettings with "Clear Color" ON erases the camera image — the world goes
  invisible while the HUD still draws.** PanelSettings.clearColor clears the whole render target
  before the panel draws, so ANY overlay panel reusing a menu-style settings asset wipes the 3D
  view to a flat color (symptom: HUD visible, world = solid backdrop; a manual `cam.Render` to a
  RenderTexture still shows the world, which misleads debugging toward the camera). Keep Clear
  Color OFF on every overlay/HUD PanelSettings; give UI-only shell scenes a dedicated clear-only
  camera (cullingMask 0, solid color) instead of a clearing panel. One PanelSettings asset per
  role (menu vs in-game HUD) — never share a clearing asset into a scene that renders the world.
  (Seen in a survival RTS: Game scene shell reused MenuPanelSettings with clearColor on —
  read as "no units / no buildings spawn".)

- **Every interactive UI Toolkit control needs a real hit target, and the tap must survive re-renders.**
  Two failure modes both read as "the button is unreliable / hard to click": (1) the control is too
  small — give the *interactive element itself* a comfortable minimum (~36×36 px via `min-height`/
  `min-width` or `height`/`width`), never rely on a child label's size to make the target big; (2)
  `Button.clicked` is wired on an element that gets rebuilt on a timer (a panel re-rendered each refresh
  via `Clear` + rebuild). `clicked` needs PointerDown AND PointerUp on the SAME element instance, so a
  press that straddles a rebuild is silently eaten — the click just "doesn't take" intermittently. For
  anything rebuilt per-render, fire taps with `RegisterCallback<PointerDownEvent>` — and register it
  **`TrickleDown.TrickleDown`**: on a real `Button` the built-in Clickable manipulator (bubble phase,
  registered first) consumes the LMB PointerDown before later same-phase callbacks run, so a
  default-phase tap wired on a Button is silently DEAD (burned — a settings card's whole
  footer). One trickle `OnTap` helper works on Buttons and plain elements alike; reserve `clicked` for
  build-once persistent elements.

- **When you ADD an element to existing UI, re-fit the container around it the SAME pass — never drop
  the new widget in and leave the siblings clipped or overflowing.** A container sized for N children
  silently clips child N+1; the user just sees a control "out of bounds" and has to report it every
  time. After adding, check the FIRST and LAST elements are both reachable, not just the one you added.
  Reflow tools: make a bounded region scroll with a `ScrollView` (`min-height: 0` + `flex-grow: 1` so
  the scroller engages instead of the window growing off-screen — note UI Toolkit does NOT auto-scroll
  on `overflow`, that only clips); `flex-wrap` a row that ran out of width; or let the container
  auto-grow (min floor only, never a fixed width/height that truncates). Don't overlap other panels —
  the screen corners are usually already taken, so stack next to the related controls, not a blind
  corner. Pairs with the hit-target rule above.

- **UITK-ABSOLUTE-OVERLAY-FITS-THE-PANEL.** An absolute hover/tooltip card is still "adding an
  element". Measure the wrapped text, set width and height from that measure, then clamp left/top
  so every edge stays inside the panel `contentRect`. A fixed `width` parked at `panel.xMax + gutter`
  clips off-screen the moment the drawer is `max-width: 100%`. `display: none` skips opacity/translate
  transitions — keep the card in the tree at opacity 0 and fade it like the rest of the shell.
  `hidden { display: none }` on a card that should animate is the defect.

- **UITK dark shells: paint the INNER input surface, not just the field root — and verify contrast
  before claiming UI done (burned).** Default runtime theme still draws a **light/white**
  `unity-text-field__input` / `unity-base-field__input` while project labels stay near-white →
  unreadable text on a white box (name field, invite code, any `TextField`/`DropdownField`). Setting
  `field.style.backgroundColor` alone does NOT fix it — the theme box is a **child**. Force dark
  background + light `color` on every input class (`unity-base-field__input`, `unity-text-field__input`,
  `unity-base-text-field__input*`) and every `TextElement` inside through the owning stylesheet.
  For native dropdown popup rows, the owner is the panel theme, not the document or a scheduled
  inline painter; see `UITK-NATIVE-POPUP-BELONGS-TO-THE-PANEL` in the uss uxml page. **Never ship a new
  field without reading it against its real background.**

- **UI pre-ship checklist (HARD — every new/changed interactive screen).** Do not mark UI done until
  each of items 1-8 is true or explicitly N/A. Items 9-16 are customizable defaults
  (the ui design page, plus item 16's hit line in the audio page; OWNER-VOICE):
  run them and report the result.
  They are not law until the developer confirms them.
  1. **Contrast** — every label, button caption, and field text is readable on its actual painted
     background (dark shell → light text; no white/light theme inputs left unstyled). Numeric
     thresholds are UI-CONTRAST-NUMBERS in the ui design page (customizable defaults):
     body text ≥ 4.5:1, large text and non-text ≥ 3:1, computed not asserted.
  2. **Inner field skins** — every `TextField`/`DropdownField`/`Slider`/`Toggle` went through the
     project style helper that paints **inner** inputs, not only the root.
  3. **Re-fit** — adding widgets this pass did not clip first/last controls; home/settings use
     `ScrollView` when content can exceed one phone screen.
  4. **No panel overlap** — screens are exclusive (`display` Flex/None siblings), not
     `position: absolute` overlays stacked on live controls (unless a true modal with intentional
     scrim + documented pick mode).
  5. **Touch targets** — interactive elements ≥ ~48×48 logical px on mobile product menus.
  6. **Flow** — primary actions don't auto-skip a required intermediate screen (e.g. lobby room
     before match load).
  7. **Rebuild survival** — if UI rebuilds, in-progress screens (lobby room, modal) restore; don't
     force `ShowHome` on every `PanelRenderer` reload.
  8. **World input** — if the scene has a camera rig, pointer-over-UI gates stay general
     (`IsPointerOverGameObject`), not a per-panel list.
  9. **Direction** — the screen's mode and decision-reason lines exist, and its direction source
     is cited: reference, recorded direction, or a labelled proposal (UI-DIRECTION-SOURCE-ORDER,
     UI-SURFACE-MODE, UI-REASON-PER-DECISION in the ui design page).
  10. **Hierarchy** — the squint capture shows one focal element and at most one primary action
      (UI-ONE-FOCAL-POINT, UI-SQUINT-TEST in the ui design page).
  11. **Contrast numbers** — text 4.5/3, non-text 3:1, colour never the only cue
      (UI-CONTRAST-NUMBERS, UI-COLOR-NEVER-ALONE in the ui design page).
  12. **States** — the control state set is complete, and data views have empty, loading and error
      content (UI-CONTROL-STATE-SET, UI-STATES-NAME-CAUSE-AND-NEXT-ACTION in the ui design page).
  13. **Decoration scan** — zero UI-DECORATION-NEEDS-A-JOB items without a written job or developer
      reference, and every pictorial slot holds real art (UI-REAL-ART-OR-NONE in the ui design page).
  14. **Type floor** — rendered sizes, tracking and casing within UI-TEXT-SIZE-FLOOR and
      UI-TRACKING-AND-CASE-BOUNDS at the target device. Each face has its reason line
      (UI-TYPEFACE-HAS-A-REASON in the ui design page).
  15. **Copy pass** — the player-facing strings touched went through UI-COPY-AUDIT-PASS, with no
      hits from UI-COPY-HYGIENE, UI-COPY-NO-CEREMONY, UI-COPY-NO-AI-CADENCE or
      UI-COPY-FORMATTING-RESTRAINT outside the flavour-text carve-out (the ui design page).
  16. **Motion pass** — the plan names each move's duration, easing or spring damping, start/peak/end
      samples, the tool, the version checked, the parameter the API reads, and the known trap by
      rule id (UI-MOTION-SPEC-NAMES-THE-SAMPLES). Each transition was checked on its own from stills
      or captures at those frames plus a measurement (UI-MOTION-EACH-TRANSITION-IS-A-PASS). Both in
      the ui design page. A hit's picture frame and sound frame are
      HIT-IS-MEASURED-AGAINST-ITS-FRAME in the audio page when the screen has one.

- **An overlay passes clicks through ONLY when its `pickingMode` says so — transparency does not.**
  A `VisualElement`'s default `pickingMode = PickingMode.Position` hit-tests its whole layout rectangle
  regardless of how it's painted, so a fully-transparent OR `visibility: hidden` container STILL eats
  every click under it (only `display: none` is auto-excluded from picking). For any non-interactive
  overlay / spacer / backdrop sitting over live controls, set `pickingMode = PickingMode.Ignore` (USS
  `picking-mode: ignore`) so clicks fall through to what's beneath. The inverse trap: `:hover`/`:active`/
  `:focus` pseudo-states and `panel.Pick`/`PickAll` only work on an element whose `pickingMode = Position`
  — an element you set to `Ignore` never hovers and never returns from a pick.

- **A `VisualElement` callback's teardown site is `DetachFromPanelEvent`, and ONLY a named method can be
  unregistered.** The csharp.md "pair every subscription with teardown" rule has a UITK-specific shape:
  `element.RegisterCallback<T>(Handler)` must be matched by `element.UnregisterCallback<T>(Handler)` with
  the SAME delegate — an inline lambda has no stable identity, so it can NEVER be unregistered (a
  guaranteed leak that re-fires N times). When a callback's lifetime isn't an owning MB's `OnEnable`/
  `OnDisable`, register in `AttachToPanelEvent` and tear down in `DetachFromPanelEvent` (the canonical
  element lifecycle hooks). (`element.Clear` removes CHILDREN, not callbacks on `element` itself.)

- **UI Toolkit flexbox is NOT web CSS — the defaults differ and bite.** `flex-direction` defaults to
  **`column`** (children stack top-to-bottom), not `row` — the #1 "why is my horizontal layout vertical."
  `flex-grow` defaults to `0` (an element won't fill its parent until you set `flex-grow: 1`); `flex-shrink`
  defaults to `1` (set `0` to stop a child being squeezed out). `position: absolute` removes the element
  from flex layout entirely — it ignores grow/shrink/siblings and anchors via `left/top/right/bottom`
  against the nearest sized ancestor. A `%` width/height resolves against the parent's RESOLVED size, so a
  content-sized/`auto` parent makes the percentage collapse to nothing. (And overflow does not scroll —
  wrap it in a `ScrollView`; see the re-fit rule above.)

- **Layout geometry is NaN/zero until the first layout pass — never read a size in the build method.**
  `resolvedStyle`, `layout`, `contentRect`, `worldBound`, and `localBound` are ALL invalid in the
  constructor / `Awake` / `OnEnable` / first frame (`resolvedStyle.height` reads `NaN`), so any
  "measure then position" done inline at build silently uses garbage (cause of a panel restoring to the
  wrong size on open). Read final sizes/positions inside `element.RegisterCallback<GeometryChangedEvent>(…)`
  (fires AFTER layout; it neither bubbles nor trickles, so register it on the element you're measuring), or
  off a `schedule.Execute` tick once geometry is valid. Read `resolvedStyle` for computed values — `style`
  holds only inline overrides.

- **UITK-GEOMETRY-FEEDBACK-NEEDS-HYSTERESIS .** Do not feed pixel-snapped resolved size back into its own geometry on every GeometryChangedEvent. Fractional panel scale can oscillate even after whole-point rounding. Prefer authored slot sizes or a stable measured size with a justified hysteresis band. Verify event counts and Console at fractional and target phone scales. Measured: scale 0.703 alternated widths 23.9/24.6 and rounded 24/25; a greater-than-2-point band stopped that instance. The measurement is not a universal threshold.

- **A C# / UXML inline style silently beats EVERY USS selector — that's why "my class change does
  nothing."** Precedence is inline (`element.style.X = …`) > UXML inline > USS selectors, and USS has no
  `!important` and no numeric weights (ranking is `#id` > `.class` > Type; on a tie the LAST rule in the
  sheet wins). So an earlier inline write permanently shadows any class you add later. Drive look through
  the class list — `element.EnableInClassList("state", flag)` / `AddToClassList` / `RemoveFromClassList` —
  and let USS own the styling; to RELEASE an inline override set `element.style.X = StyleKeyword.Null`.
  Custom `var(--token)` properties are scoped to the defining element + its descendants, so define shared
  tokens on `:root` (or a common ancestor) or `var` resolves to nothing.

- **Procedural UITK textures (9-slice bevels, backdrops, icons) must be SUPERSAMPLED — a
  1:1-point texture pixelates the moment the panel scale factor exceeds 1.** With PanelSettings
  ScaleWithScreenSize, any game view/screen taller than the reference resolution magnifies every
  background texture (1620p view on a 1080p reference = 1.5×), and procedurally-drawn rounded
  rects/gradients/stripes go soft and jaggy — reads as "the whole UI got pixelated" with nothing
  wrong visible in code. Recipe: draw at SS× resolution (4× works), render 9-slices with
  `style.unitySliceScale = 1f/SS` (slice values then in texels), generate mip chains + set
  `FilterMode.Trilinear` so minification back at scale ≤1 stays clean, and feather procedural
  pattern edges (stripes/bands) with a 1–1.5-texel ramp instead of hard cutoffs. Full-screen
  backdrops: ≥1280×720 with AA'd patterns, never 640×360 hard-edged. Rotated solid-color elements
  alias too — give them a border-radius to force the AA render path. (Seen in a mobile strategy game: whole one faction menu kit pixelated on a 1620p game view; SS=4 + sliceScale + trilinear mips
  restored crispness at every scale.)
  **Atlas tradeoff (same day, same kit):** this recipe kills UITK dynamic-atlas batching THREE
  ways at once (the three rejection triggers are UITK-DYNAMIC-ATLAS-BYPASS, further down this
  file), so every kit texture becomes a per-element bind
  and RGBA32+mips stays resident (~20 MB for one menu+HUD kit at SS=4). Pair the recipe with:
  raise PanelSettings `maxSubTextureSize` to ≥512, generate small/mid chrome (bevels, pills,
  strips ≤512px) WITHOUT mips at `FilterMode.Bilinear` so it atlases, and reserve mips+Trilinear
  for the few large hand-bound textures (backdrops, map cards). A tileable gradient strip needs
  only its unique columns (repeat-wrap), not the full period at SS×.

- **A `border-radius` clip of a `backgroundImage` is a HARD aliased cut, and fixing it with real
  border widths shifts the layout — finish an exposed radius corner with an absolute overlay cap.**
  Two paired traps when rounding a corner of a textured element (a HUD bar that folds down to a
  stub, a card with one rounded edge): (1) UITK's radius clip does NOT anti-alias the background
  texture along the arc — the exposed corner reads as "cut off by alpha"; drawn borders DO render
  anti-aliased along the radius, which suggests adding `borderTop/RightWidth` as cover — but
  (2) border widths shrink the content box, so toggling them (fold/unfold, show/hide) visibly
  shifts every child (a minimap jumping 4px on every deselect). Correct shape: keep the radius on
  the element (radius never affects layout), and draw the AA ink edge with an absolutely-positioned
  child overlay (`position: Absolute; top/right/bottom: 0; width: ~radius+12`) carrying the border
  widths + the same radius, `pickingMode: Ignore` — the cap traces the arc with AA over the aliased
  cut and costs zero layout. Related principle from the same session: when a panel section
  collapses, FOLD the same chrome (hide children, let the container shrink-wrap via
  `alignSelf: FlexStart`) — never swap to a second look-alike element with its own background; any
  texture/background swap pops visibly. (Seen in a mobile strategy game: folding the one faction bottom
  bar to a minimap stub.)

- **A stacked notification/alert list holds its order by INSERTING new cards into
  priority-split hosts — never by reordering live children; and a radius baked into a 9-slice
  background does NOT clip children.** Two paired traps when building a toast/alert stack.
  (1) "Newest on top, pinned above transient" tempts you to sort the list and re-`Add` its
  children every push — but reparenting a `VisualElement` runs detach/attach, interrupting any
  running `experimental.animation` and re-triggering entry transitions on cards that had already
  settled. **`parent.Add(child)` on a child the parent ALREADY holds is that same detach/attach**
  (UITK-READD-RESTARTS-TRANSITIONS): a render that re-`Add`s its rows "to keep them in order"
  restarts every USS transition on every row every frame, so a 0.25 s tint eases in the sampled
  series only after the re-add was replaced by `if (child.parent != parent ||
  parent.IndexOf(child) != index) { child.RemoveFromHierarchy; parent.Insert(index, child); }`
  — move a child only when its slot is wrong (found: pods tinted instantly, the pill
  outside the re-added host eased). Instead give the layer TWO child hosts (pinned, then transient) and `Insert(0, card)`
  into the right one: inserting a FRESH element never touches a live sibling, so priority
  ordering costs nothing and disturbs no animation. (2) When the card's rounded corner lives in
  a baked bevel TEXTURE (the element's own `border-radius` is 0), UITK clips children to the
  element's RECTANGLE — an absolutely-positioned full-bleed child (a progress/timer bar at
  `left:0; right:0`) visibly pokes out through the painted ink outline at the corners. Inset
  that child past the radius instead of adding a real `border-radius` + `overflow: Hidden`,
  which re-introduces the aliased radius cut (see UITK-RADIUS-CLIP-CAP above). Also: pool the
  cards, and make a PINNED live-progress card non-dismissable — a readout a stray tap hides and
  the next poll restores reads as a bug. (Proven a mobile strategy game: HUD upgrade-countdown +
  under-attack alert stack.)

- **A pose that must REACH its end value is held longer than its transition, or released from
  `TransitionEndEvent` — never by a timer equal to the duration** (UITK-POSE-OUTLIVES-ITS-TRANSITION).
  The panel scheduler and the USS transition clock do not tick together: a class flip to
  `scale: 1.35` with a 0.18 s ease, released by a 180 ms scheduled item, re-targets the scale from
  wherever it was — a 480-frame `resolvedStyle` sample peaked at 1.22 and never touched 1.35.
  Holding the class ~40% past the duration (260 ms) reached 1.38, the ease-out-back overshoot.
  Rule of thumb for any pulse / stamp / swap pose: release margin ≥ 2 frames past the transition,
  or subscribe `TransitionEndEvent` on the element and release there; prove it with the per-frame
  sample, not the capture (found on a "saved" beat that looked fine in a screenshot).

- **A transition on a bounded property needs the bound widened past BOTH ends** (UITK-CLAMP-CUTS-THE-TWEEN). `min-height`/`max-height` (and the width pair) are applied AFTER the transition interpolates, so a box whose USS pins `height`, `min-height` and `max-height` to the same number, moving to a pose that pins all three to another, tweens `width` over 0.34 s while `height` lands on its first frame — the clamp makes the target the only legal value. A per-frame `resolvedStyle` sample shows it (`w 168→412` easing, `h 75→58.3` at frame 1); a capture cannot. Fix: the element that moves carries `min = the smaller pose, max = the larger pose` for the whole move (inline or on the moving-state class), and the pose classes set only the property that transitions. Found on a card table whose player boxes slide between a 58pt row and a 75pt tile.

- **UITK-IMAGE-NATIVE-OVERFLOW — `Image` measures at the sprite's native pixel size
  and parent `overflow: Hidden` does not clip it.** A 1500×512 family lineup inside a 72×46
  well still paints at native size and leaks out of the button. `StyleBackground` on a Sprite
  also ignores `ScaleToFit`. Fix: give the `Image` explicit `width`/`height` equal to the well
  (not Absolute left/right/top/bottom stretch) and `scaleMode = ScaleToFit`. Do not later set
  the well's width to `Auto` / `Stretch` or the constraint is gone. (Proven a mobile strategy game
  army / shop / race cards.)

- **UITK-GLASS-TINT-NOT-OPACITY — make a HUD panel see-through by alphaing
  `unityBackgroundImageTintColor`, never `style.opacity`; alpha COMPOSITES, so a card and the bar
  under it cannot both carry the same glass value; and every interactive tint handler must carry
  the alpha or the element pops opaque on hover.** Three paired traps behind one ask ("make the
  selection panel a bit see-through"). (1) `style.opacity` fades the whole SUBTREE — labels,
  icons, portraits — so the panel turns translucent *and* the text washes out, exactly the
  contrast failure the UI pre-ship checklist bans. Tint the background instead
  (`style.unityBackgroundImageTintColor = new Color(1,1,1,alpha)` for a plain texture, or the
  kit's resting tint with alpha for a texture drawn bright against one): only the painted
  background alphas, every child keeps full opacity. (2) Nested panels COMPOSITE — two stacked
  layers at 0.78 land at `1-(1-0.78)² ≈ 0.95`, i.e. solid — so an inner card needs a visibly
  LOWER alpha than its parent (0.55 over 0.78 reads ~0.90). "I set both to the same glass
  constant" is the tell that the densest region of the panel never actually became see-through;
  compute the composite, don't eyeball the constant. (3) A hover/press handler that resets the
  tint to `Color.white` / a press tint DISCARDS the alpha on the first pointer enter, so a
  translucent button snaps opaque mid-hover — thread the alpha into the interactive-tint wiring
  and rebuild every state tint from it. Corollary for a bar that folds/unfolds around a
  persistent element (minimap dock ↔ selection console): apply the glass in BOTH states, since
  changing a background between states visibly pops — the same reason such bars keep one texture
  instead of swapping panels. (a mobile strategy game, in-match selection console over the granite
  HUD bar.)

## UI Toolkit visual traps: radius, shrink, text fit, composite icons (HARD)

Four ways a UITK screen silently diverges from its design, all measured on a tic-tac-toe game
while porting an HTML kit. Each one renders WITHOUT error and looks like a different bug than it is.

1. **`border-radius` is not clamped.** CSS clamps an oversized radius to half the box; UITK renders
   ELLIPTICAL corners instead, so the `border-radius: 999px` capsule idiom produces squashed ovals
   with the border curving through the content. Give every fully-rounded element an explicit radius
   of exactly half its height.

2. **`flex-shrink` defaults to 1 and squashes fixed chrome silently.** When a column exceeds its
   space, Unity 6 shrinks children rather than overflowing - a 44px pill measured at 28px tall,
   which presented as "the radius is broken" (see 1: the radius then exceeded half-height and went
   elliptical, compounding). Put `flex-shrink: 0` on all fixed-size chrome so a layout that does
   not fit is VISIBLE and gets fixed as spacing, not silently deformed. Verify fit at a real device
   aspect (e.g. 1080x2340), never the default squat Game View - the squat aspect triggers the
   squash and lies about both bugs.

3. **Text must never touch its plate.** Guarantee air with padding on the control - a longer label
   stretches the 9-slice, but padding never shrinks. A label "maxed out" against its button edge is
   a defect. (Pairs with the existing >=36px/44px hit-target floors.)

4. **Composite-bake plated icon buttons.** A plate + separately-positioned icon element drifts off
   centre and costs an extra draw call per button. Bake plate AND glyph as one sprite, centred by
   the bake script, and reuse it. Keep separate tinted icon elements only for flat glyphs with no
   plate (tab bars), where `-unity-background-image-tint-color` handles states.

Related repair note for 9-slice: when a control stretches wrong, check the IMPORTER first - border
still zero (file imported before the postprocessor knew it), sprite mode not Single, mesh not
Full Rect, or a supersampled bake missing `-unity-slice-scale` - before touching the USS.

## UI Toolkit's dynamic atlas silently refuses your textures (HARD)

UITK batches UI textures through a **dynamic atlas**, and a texture that fails to atlas becomes its
own draw call. Nothing warns you. Measured on a mobile strategy game (developer-verified in the
PanelSettings YAML): **every** UI texture in the project was refused, leaving ~19 distinct textures
in HUD steady state against 8 slots per batch.

A texture is refused if **any** of these is true:

| Disqualifier | Fix |
| --- | --- |
| has mipmaps | mips **off** for UI textures |
| filter mode Trilinear | **Bilinear** |
| bigger than `m_MaxSubTextureSize` - **default 64px** | raise it on the PanelSettings asset |

The 64px default is the trap: any realistic panel, button or 9-slice sprite blows past it, so the
atlas is bypassed by default the moment a design uses art. Set the cap and force the import settings
(an AssetPostprocessor, not per-file) when the FIRST sprite lands - not after a profiler session on
device, by which point the UI is built.

Three churn patterns from the same sweep, each worth its own grep on any UITK project:

- **`display: None -> Flex` flipped per label per frame.** A change gate is the fix; `display` is
  also non-animatable, so a transition on it silently does nothing.

- **Interactive-tint wiring re-registering callbacks every repaint** - unbounded handler
  accumulation on persistent elements. Wire once; re-render updates text/classes/`userData`.

- **A second live panel on a DIFFERENT PanelSettings asset** for a whole match. Share one
  PanelSettings asset and layer with `UIDocument.sortingOrder`; a separate asset is only for a
  different scale/DPI/target-texture.

Related: legacy bitmap fonts raster per point size (27+ sizes x 4 faces in that project). Use
TextCore SDF font assets so one atlas covers every size.

## USS is not CSS: no box-shadow, no gradients, no outline (HARD)

Before porting ANY web/Figma design to UI Toolkit, know what UI Toolkit will refuse. Measured on
**Unity 6000.5.0f1** by importing a USS file that declares each property and reading the importer's
own warnings - the definitive check, and it takes one minute:

| Property | Supported | Unity's exact warning |
| --- | --- | --- |
| `text-shadow` | YES | (none) |
| `transition` | YES | (none) |
| `box-shadow` | **NO** | `Unknown property 'box-shadow' (did you mean 'text-shadow'?)` |
| `linear-gradient` | **NO** | `Expected (<resource> \| <url> \| none) but found 'linear-gradient(...)'` |
| `radial-gradient` | **NO** | `Unknown function 'radial-gradient'` |
| `outline` | **NO** | `Unknown property 'outline' (did you mean '-unity-text-outline'?)` |

USS warnings are **warnings, not errors** - the stylesheet imports "successfully" with the
declaration silently dropped. So the failure mode is not a red console, it is a screen that renders
flat and looks like a layout bug. Import a probe and read the console; do not infer support from a
clean import, and do not infer it from web docs, which are written against whichever Unity version
the author had.

**Consequence for any modern app design:** `box-shadow` is usually doing two jobs at once -
`0 4px 0 #hard` for the raised/arcade offset and `0 0 26px rgba(...)` for the neon glow - and both
vanish. Gradients usually carry the primary button and the background. Between them that is most of
a design's identity.

**Resolve it with 9-slice sprites, not nested elements.** Faking an offset shadow with a second
element behind each control doubles the element count on every button in the app, still cannot
produce a glow, and adds a second thing to keep in sync on every hover state. Bake the shadowed /
glowing / gradient box as a sprite and use `background-image: url(...)`. Generate the set from the
token list rather than hand-authoring, because colour variants multiply fast, and keep the
generator in the repo - a sprite bakes its corner radius, so a radius change is a re-bake.

`text-shadow` working is the one piece of luck: every neon TEXT glow stays pure USS. Only boxes
need art.

## Timed release gates: success must dismiss them

- A full-screen "connecting…" gate needs a release-on-timeout so a lost event can never strand the
  player — but the release cannot tell "still connecting" from "connected, nobody dismissed me",
  and if its timeout handler tears state down (leave session, cancel search) it will destroy a
  HEALTHY connection minutes after success. Every success path must close the gate it opened;
  only failure leaves it up showing the reason. Also: with a single-modal router, pushing a gate
  over another modal REPLACES it — re-push the destination modal after closing the gate.
  (a card game: an auto-join left the gate up; its release left the live session 90s later.)

## UITK-CHAT-PANEL-TRAPS — four things a text chat panel hits

A child `VisualElement` added to a `Button` collapses it to `width: NaN` (a Button is a
`TextElement`) — badges and dots go on a plain `VisualElement` tab; Enter-to-send needs a
`TrickleDown` `KeyDownEvent` or the `TextField` eats the first press ("press twice"); user text is
wrapped in `<noparse>` AFTER stripping any `</noparse>` from it, or `enableRichText` is an
injection; and a `ScrollView` top-anchors short content, so pin the log to the bottom
(`contentContainer.style.justifyContent = FlexEnd`) or the first lines float and jump.
(a project `docs/pitfalls/vivox-chat.md:35-45`.)

## UITK-SCRIM-DISMISS-BUBBLES-FROM-DIALOG-BODY — a tap on the dialog closes it (HARD)

A modal scrim that dismisses on `PointerDownEvent` also dismisses from every tap INSIDE the dialog:
pointer events bubble up from the deepest target, and only a real `Button`'s `Clickable` stops the
LMB PointerDown. Labels, card borders, padding and slider tracks bubble straight through to the
scrim, so the dialog "randomly" closes when the player taps a caption. Fix at the kit level, once:
the dialog root registers `PointerDownEvent` → `evt.StopPropagation` (build-once dialogs make
this one registration for the lifetime of the screen). Probe over the bridge: `SendEvent` a
`PointerDownEvent` targeted at a label inside the dialog — the scrim must stay `display: Flex`;
targeted at the scrim itself it must close. (a tower-defense game.)

## ONE-HEADER-DOCUMENT-OVER-SWAPPED-WORKSPACES — the shared frame lives in its own document, UNDER the workspaces (HARD)

Three full-screen workspaces (inventory, crafting, build) each instanced the same header template, so
every tab change rebuilt the row: the title re-measured, the tabs slid, the close button jumped, and the
developer read it as "the top section shouldn't be affected". Moving only the header into its own document
was not enough: each workspace still painted its own board and faded in from opacity 0 with child
slides, so every switch blanked the board and replayed the entry.
The FRAME — board and header — is ONE UIDocument sorted UNDER the workspaces. Workspace roots paint
nothing and are `picking-mode: Ignore`, so a point with no workspace control picks nothing there and
the runtime falls through to the frame's header. The frame reads the shared overlay OWNER (not each
screen's IsOpen — the owner changes before the outgoing screen closes) to mark the active tab and routes
nav/close to the owning presenter. A switch (a workspace was already open when the new one claims the
owner) shows the incoming root with transitions zeroed for one style pass — add a class that sets
`transition-duration: 0s`, remove it two scheduled ticks later, fire focus after that — and only an open
from nothing fades. Each workspace leaves a fixed-height placeholder (`height: 76px; flex-shrink: 0`)
where the header draws, and the header is `flex-wrap: nowrap` with a fixed-width title, because a second
row or a re-measured title would draw over the workspace's content. Tests: `layout` rects across every
tab, not `worldBound` (the open fade translates the header); the incoming root reads opacity 1 after ONE
panel update; the frame never gains the hidden class; the workspace panel's `Pick` at each header button
is null while the frame panel's is the button. (Header found, identical 24,24,1872,76 across
three tabs at 1080p; frame moved under, frame and incoming root at opacity 1 in the click frame.)
The zero-duration class must sit in each workspace's own sheet: see LATER-SHEET-OUTRANKS-THE-THEME-SHEET
in the uss uxml page.

## NAVIGATION-IS-FOCUS-NEVER-THE-CARET — cancel it all, typing does not use it (HARD)

A document that owns the screen — a modal, a full-screen workspace — has to stop the panel turning
Tab, Space, Return and the arrows into focus jumps, while a search or name field inside it still
types. The instinct is to exempt anything whose target sits inside a `TextField` from the cancel.
That exemption IS the defect: from a focused field both the Tab `KeyDownEvent` and the
`NavigationMoveEvent` survive it, and focus steps to the next focusable control. With a toolbar of
two, the developer sees the window "just select between 2 elements" instead of closing.

The exemption is also unnecessary, because **the caret never rides on a navigation event.** Read off
the `UnityEngine.UIElementsModule` method table: `TextSelectingManipulator` handles exactly
`OnKeyDown` and `OnPointerMoveEvent`, and `TextEditingManipulator` declares no event handler of
either kind — there is no navigation handler anywhere in the text stack. The only `NavigationMove`
handlers in the whole module are `BaseSlider<T>`, `MinMaxSlider` and `KeyboardNavigationManipulator`
(what collection views use for up/down selection), plus the focus controller's own default action,
`SwitchFocus`. None of the first three can ever be the target inside a text field, so a
`NavigationMoveEvent` raised from one has exactly one possible effect: moving focus off it.

So cancel every direction, from inside a text field too. Split the two event families by what each
one can do, not by where it came from:

- `NavigationMoveEvent` / `NavigationSubmitEvent` / `NavigationCancelEvent` — cancel unconditionally.
- `KeyDownEvent` for Tab — cancel unconditionally; the panel maps it to `Direction.Next`/`Previous`.
- `KeyDownEvent` for Space and Return — cancel only OUTSIDE a text field, or the field stops typing.
- `KeyDownEvent` for the arrows — never cancel; this is the one that moves the caret.

Register on the guarded root with `TrickleDown.TrickleDown` and `StopPropagation` + `focusController
.IgnoreEvent`, so the event dies before the target sees it. Say the trade out loud: nothing under
that root then nudges a slider or walks a list with the arrow keys either. For a document whose
shortcut has to close it, that is the point; for a page that wants keyboard list navigation, guard
the focus jump directions only and let the rest through.

Do not reason from "arrows probably do not raise navigation while a field is focused" — whether they
do is a runtime decision in `KeyboardEventExtensions.ShouldSendNavigationMoveEventRuntime` and it is
version-specific. Cancel on what the event CAN do; then the answer does not matter.

You can check any of this without opening an Editor, which is worth doing when the Editor is busy or
leased. The module's method table is plain assembly metadata:

```powershell
Add-Type -AssemblyName System.Reflection.Metadata
$fs = [IO.File]::OpenRead("<editor>/Data/Managed/UnityEngine/UnityEngine.UIElementsModule.dll")
$pe = [Reflection.PortableExecutable.PEReader]::new($fs)
$md = [Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($pe)
foreach ($h in $md.TypeDefinitions) { $td = $md.GetTypeDefinition($h)
  foreach ($mh in $td.GetMethods) { $n = $md.GetString($md.GetMethodDefinition($mh).Name)
    if ($n -match "NavigationMove") { "$($md.GetString($td.Name)) : $n" } } }
```

`MetadataLoadContext` is a separate package and is usually NOT present; `System.Reflection.Metadata`
ships with the runtime, so the `PEReader` route above is the one that works out of the box. It
answers "which type handles this event" and "does this member exist in this version" — not what a
method body does, which still needs a decompiler or a live probe. (Measured, Unity
6000.5.0f1: 5 `NavigationMove` methods in the module, 0 of them in the text stack.)

## CARET-IGNORES-MIDDLE-ALIGN — the text is centred, the caret is not (HARD)

A text input taller than its line draws its caret HIGH, and only the caret: the developer reports "the
waiting to type thing is slightly up" while the glyphs look right. `-unity-text-align: middle-*`
centres the GLYPHS inside the text element's box, and UITK never applies that same offset to the
caret — `ITextSelection.cursorPosition.y` comes back as the line height measured from the box TOP.
So a 44px field holding a 14px line puts the glyphs on the centreline and the caret at the top:
measured 12.53px above centre (caret 159..175.94 in a field of 158..202, gap 1px above and 26px
below), with the error equal to `(contentHeight - lineHeight) / 2` — the centring offset the caret
never got.

Do not chase it with padding: `padding-top` moves the glyphs down with the caret, so you trade a
high caret for low text. Centre the LINE BOX instead of the text inside a tall box — `align-items:
center` on the input. The text element then measures its natural line height and both the glyphs and
the caret sit on it (measured +0.47px after, from -12.53px). The input is a ROW container, so height
is the CROSS axis: `align-items` is the lever and `flex-grow` on the child does nothing.

Fix it in the SHARED input rule, not the one field that was reported. Every input styled with a
`min-height` or vertical padding larger than its line has this, so one declaration on the theme's
`.unity-base-text-field__input` selector list fixes every field at once; leaving `-unity-text-align:
middle-left` in place is harmless once the box is line-tall. Measure it without chasing the blink:
read `cursorPosition` and `lineHeightAtCursorPosition` off the inner TextElement (both explicit
`ITextSelection` implementations — fetch them by a name that ENDS WITH the property, since
`GetProperty("lineHeightAtCursorPosition")` misses an explicit implementation) and compare
`worldBound.y + cursorPosition.y - lineHeight .. worldBound.y + cursorPosition.y` against the
field's `worldBound.center.y`. Screenshots catch the caret only on its blink-on phase.
(Measured on Inter at 14px: ascent 13.563, descent 3.381, line 16.944, in a 42px box.)

- **WORLD-SPACE-UI-UNDER-HDRP-EXPOSURE .** A native world-space UI Toolkit panel draws through the lit
  scene, so HDRP daylight exposure crushes it to near-black. Render the UXML to a RenderTexture and show it on a
  mesh as Unlit emission with exposure weight 0; verify in a daylight capture.
- **PICK-WITH-THE-RAY-THE-RETICLE-DRAWS .** A world-space panel's hover pointer sat 68-143 panel px off
  the HUD dot: the pick ray started at a simulated eye while the camera socket rode the body 150 mm ahead and
  26-41 mm above. Pick from the camera position along the same unshaken aim direction the dot is drawn on;
  cosmetic camera rotation (shake, recoil kick) put the axis up to 3 px off the pick, so dot and pick share one
  source. A 3 px dot also sat 0.25-0.5 px off centre because layout pixel-snaps the element: add the rounding
  remainder to the container's `translate`, which layout does not round.

## UITK-RETAINED-ANIMATION-KEEPS-ITS-LEASE

- Experimental ValueAnimation handles retained beyond completion need KeepAlive; otherwise the completed object returns to its pool and a later Stop can address recycled state. Explicitly Stop and Recycle the kept handle on replacement/teardown, and cancel nested scheduled starts too. Verify loading/retry/reopen and disable/rebuild. The installed Unity 6 API documents KeepAlive as preventing return to the instance pool on completion; merely retaining a reference does not keep that lease.

## POOLED-ELEMENT-RESETS-EVERY-KIND-STYLE

- A pooled label shared by several popup kinds keeps the last kind's style: a crit's outline and glow showed on a
  later gold popup. Set or clear every kind-specific style (classes, outline, colour, scale, text shadow) in the one
  spawn method, on every spawn, never only when the element is created.

## UITK-SWEEP-DRIVES-THE-REAL-PATHS

Recipes for an automated screen sweep in the Editor, each one measured in a landscape-mobile UI pass:

- **Press a button like a player.** `using (var e = NavigationSubmitEvent.GetPooled) { e.target = b; b.SendEvent(e); }`
  runs the button's `Clickable` handler. A row that listens to `PointerDownEvent` needs
  `PointerDownEvent.GetPooled(new Event { type = EventType.MouseDown, mousePosition = centre })`. Press only buttons
  whose ancestors are all displayed and visible and whose `worldBound` is non-zero.
- **A button under a full-screen layer is not a target.** Treat any painted (background colour or background image),
  picking, full-screen element as modal and skip what it covers. A probe keyed on background colour alone reported
  three buttons under an image scrim as "gap 0" failures.
- **Aspects:** Game view presets through `GameViewSizes` (see the [HTML/CSS mock to UI Toolkit](/workflows/html-css-to-ui-toolkit/) page and the uitk orientation page),
  removed and the view index restored afterwards; never the Device Simulator (DEVICE-SIMULATOR-SPLITS-SCREEN-PARAMS).
- **Phone class and cutouts in the Editor:** set the panel's reference resolution to the phone target, toggle the
  phone class on the roots, and inset the safe container by `inset x referenceHeight / screenHeight` on each side.
  Restore all of it in cleanup.
- **Android back** arrives as `<Keyboard>/escape`. Queue a keyboard state holding Escape, `InputSystem.Update`, then
  an empty state to release, so the real `InputAction` binding runs (INJECTED-INPUT-RESPECTS-GAME-VIEW-FOCUS in
  the [Live play testing via Unity MCP](/workflows/live-play-testing-unity-mcp/) page applies).
- Directly dispatched touch events are not physical gesture proof. Verify factory coordinates and pointer ids before interpreting a synthetic touch result; SendEvent did not drive ScrollView scrolling in a measured pass. Wheel endpoints establish wheel behavior only. Slider-versus-scroll intent still needs a real-device gesture. (Refined.)
- **Tests restore player data.** A flow test that acknowledges a reveal or picks a map writes the player's save files.
  Copy the `Application.persistentDataPath` files first, restore them byte-identical after, delete files the test
  created, and read fixture strings from the real snapshot, never hardcode them.
- Measure `resolvedStyle` / `worldBound` the frame after a display change (MCP-READ-IN-THE-NEXT-CALL) or in a
  `GeometryChangedEvent`, and guard 0 and NaN.

## TEXT-OVER-THE-WORLD-GETS-OUTLINED-INK

- A muted caption colour chosen for dark panels is unreadable over the open 3D scene. Measured: a timer caption at
  #77828f became unreadable on grass once a cover pass removed the panel behind it.
- Text drawn directly over the world uses the light primary ink plus a soft text shadow. Do not put a panel back behind
  it: the panel costs screen cover, which is what the pass removed.
- No text outlines (developer, refined 0.6.14): the developer removed `-unity-text-outline` from every in-game label,
  world text included. Gate: `grep -rl "text-outline\|unityTextOutline"` over the project's cs/uss/uxml/tss = 0.

## SWEEP-CHECKS-TEXT-FIT-AT-DEVICE-RESOLUTION

- Amends UITK-SWEEP-DRIVES-THE-REAL-PATHS above. An Editor sweep that measured boxes and touch targets passed, and the
  phone still cut "VIEW INFO" to "IEW INF": the emulated 2400×1080 panel had room that the real 3120×1440 panel
  (1560×720 after the cutout) did not.
- The probe compares each label's preferred text size (`MeasureTextSize`) with its content rect and reports
  `truncated=`; the real device resolution, with its cutout inset, is in the sweep set. Shorten the label where it
  fails rather than shrinking the type below the scale.
- End every UI slice with a device capture of each touched screen: the dev build installed with `adb install -r`,
  a logcat gate on first boot, and `adb exec-out screencap -p` for every screen.

## SHARED-FACTORY-LEAVES-SPACING-TO-USS

- Amends the inline-style bullet above ("A C# / UXML inline style silently beats EVERY USS selector"). A shared
  control factory, such as a label helper in a UI kit, that writes margin and padding inline takes spacing away from
  every caller's USS. A class that spaces the label then does nothing, and nothing warns about it.
- Kit factories write no layout or spacing inline; the kit's USS owns defaults. Where a factory already does, the
  caller whose class owns spacing resets all 8 longhands (`marginLeft/Right/Top/Bottom`,
  `paddingLeft/Right/Top/Bottom`) to `StyleKeyword.Null`. There is no inline shorthand to reset in one call.
- Refined, corrected: the same applies to text ink. The factory label also pinned `color`, text
  outline and `textShadow` inline, so a shared world-text class changed nothing. Delete the factory's outline writes
  (text outlines are forbidden, see TEXT-OVER-THE-WORLD-GETS-OUTLINED-INK and its outline grep gate); release `color`
  and `textShadow` in one helper that sets each to `StyleKeyword.Null` and adds the class. Readable text over the
  world is one class plus tokens: light ink and a soft shadow, no outline.

## SHEET-SWEEP-FORCES-THE-TALLEST-STATE

- Amends SWEEP-CHECKS-TEXT-FIT-AT-DEVICE-RESOLUTION above. A results-sheet sweep passed with 0 truncated and 0
  off-panel, but the phone capture showed squeezed stat rows and a clipped title line. The tallest state (every
  optional card and row on) overflowed the dialog, so flex shrank the rows. Each label still fit its own content box
  but spilled out of its parent, so a text-in-own-box check cannot see it.
- The first sweep also missed the tallest state: an earlier step had already acknowledged the optional reveal, so that
  card was absent.
- Every sheet sweep has a step that forces the tallest state explicitly: set the restore-safe flags memory-only, then
  republish the snapshot. It reports two counts scoped to the sheet, and both must be 0: labels whose bounds leave
  their parent (clipped), and rows whose resolved height is below their wanted height (squeezed).

## UI-OVERLAP-SWEEP-INTERSECTS-PAINTED-EXTENTS

- An overlap sweep intersects, pairwise across the whole panel, the extents that actually paint. That means inked text
  (the measured text size placed by its alignment, with about 15 % of the line box trimmed off top and bottom),
  painted buttons, icons, and small painted chips or pills.
- Skip pairs that cannot be a visible overlap: an element and its ancestor, a pair where an opaque plate drawn later
  hides one of them (decide by draw order), a chip contained in its own host, and unpainted tap-area buttons.
- Run it per screen in each real phase, at the target device profile (CAPTURES-SHOWN-USE-THE-TARGET-DEVICE-RESOLUTION).
  Write a table per screen; the bar is overlaps = 0. Measured: this found a card's level pill covering 9 px of its
  category disc on all 8 cards, which layout-bounds sweeps had passed.

- Include separate edge/containment/clip tables, including meters, because pairwise overlap deliberately skips ancestors. Log panel versus device units and thresholds from the authored policy; demonstrate the known defect before accepting a repair. Keep exclusions visible with separate gates for their content (for example card interiors). An unsampled boss/toast or a BY-DESIGN label cannot establish zero defects. See SHEET-SWEEP-FORCES-THE-TALLEST-STATE. (Refined.)

## CAPPED-PILL-SCALES-NOT-ELIDES

- A content-sized pill or tag inside a width-capped slot elides ("NE...") instead of shrinking when its text grows.
- Measure the pill's natural width, set that width explicitly, then scale the pill down to fit the slot when it is
  wider. Test with the worst-case strings, not only the shipped ones.

## PROBE-DRIVES-THE-REAL-SETTER

- A probe that writes a label's text directly bypasses the component's own refit (fit-to-slot, scale, relayout), so it
  reports defects the game never shows, or misses ones it does. Drive the component's real setter, or call the same
  refit the component runs after its own writes.

## SWEEP-DRIVES-THE-REAL-PHASE

- Forcing a phase-dependent dialog open while the game is in another phase lays it out in a state a player never
  sees. Measured: a version row reported over the game-over sheet moved out of the way in the real results phase.
- Drive the real phase transition (the game's own enter-results call) before sweeping that screen. Keep the run alive
  during a multi-screen sweep (a bounded keep-alive, AGENT-PLAY-PROBES-ARE-BOUNDED), or the later screens are swept in
  the wrong phase.
