Unity Workflow LibraryUWLAbout

Topic 7 · UI

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 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 agent default — unconfirmed (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 (agent default — unconfirmed): 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-Adds 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.

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:

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 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 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.