Unity Workflow LibraryUWLAbout

Topic 7 · UI

USS/UXML: differences from CSS

The language delta between USS/UXML and the CSS a browser taught you.

USS-CAPABILITY-PROBE above measures which properties exist. These are the other four ways USS is not CSS — selectors, motion, layout primitives, and text — each of which imports as a silently dropped rule, so the screen renders subtly wrong with a clean console.

USS-NO-STRUCTURAL-SELECTORS — the selector set is small, and the missing ones are the ones mocks use

USS supports type / .class / #name / descendant / > and the pseudo-classes :hover :active :focus :enabled :checked :disabled :root. It has no *, no :first-child / :last-child / :first-of-type / :nth-child, and no ::before / ::after.

  • Every structural selector becomes an explicit modifier class the controller toggles, or a real child element (an "active underline" is a child, not an ::after).
  • Port the CSS * { margin:0; padding:0 } reset as a bare Label {} type rule — only TextElement picks up theme margins — kept FIRST and selector-only (specificity 0,0,1) so class rules still win.
  • No numeric font-weight: 700/800 → -unity-font-style: bold, 300 → normal. No border-style: a dashed/dotted outline becomes solid in a dimmer token, or baked art if the dash is load-bearing.
  • background-image takes ONE image — a comma-separated CSS background stack (texture + glow, grid
  • vignette) becomes N absolutely-positioned children, painted bottom-up in document order.
  • gap → per-child margin, but a CENTRED row (nav, tab bar) needs SYMMETRIC half-margins on every child or the trailing margin shifts the row off-centre; left-aligned rows put the whole margin on the following child.
  • Custom --tokens can be READ from C# (resolvedStyle.Get*Property) but never WRITTEN — there is no style.SetCustomProperty. Retheme by toggling ONE class on the root that redefines the tokens; only values consumed through var(--x) respond (hardcoded literals and circle radii will not move). Do not fall back to per-element inline colour writes — those permanently shadow every USS class.
  • An ellipsis needs ALL THREE: overflow: hidden + white-space: nowrap + text-overflow: ellipsis (position with -unity-text-overflow-position). Any two of the three do nothing, which reads as "text-overflow is broken in Unity" and gets replaced with manual string truncation.
  • A-ONE-LINE-LABEL-IS-NOWRAP . A Label defaults to white-space: normal and flex-shrink: 1, so a content-sized label in a row that runs short of width is squeezed below its own text and wraps; a word wider than the squeezed box breaks mid-word ("PLACE" renders as "PLAC" over "E") with a clean console. The equipped-item readout split onto two lines. A label meant to be one line declares white-space: nowrap; flex-shrink: 0 in its USS class; where the container can be narrower than the text, add overflow: hidden + text-overflow: ellipsis so it truncates instead. Verify by the rendered height (resolvedStyle.height equals one line, 31 px at font 19 here), not by reading the string, which is always whole. Refined 0.6.14: nowrap is not a slot. An empty label still collapses to 0 height and a \n still adds a line, moving its siblings (shell-stress probe, 3120x1440). Give the slot a fixed height of N lines of its resolved font, measured from a constant sample, plus overflow: hidden.
  • Reference fonts as -unity-font-definition: url("…/<Name> SDF.asset") — a TextCore font asset, never the .ttf. One SDF atlas covers every point size; the bitmap path rasters an atlas per size × face and multiplies UI texture memory.
  • No text-transform . Unity 6 USS has 297 properties and this is not one of them, so text-transform: uppercase imports as a dropped rule and the text renders in its authored case. The docs describe UI Builder's per-element text settings, which read like the CSS property and are not it — enumerate UnityEngine.UIElements.StyleSheets.StylePropertyUtil's property map and check the name against that, never against a docs page. See CAPS-ARE-LOCALE-DATA below for where the ALL-CAPS look goes instead.

A hard-offset box-shadow is a BOTTOM BORDER, not art

Split every box-shadow by blur radius before reaching for the bake:

  • 0 4px 0 #color (blur 0 — the chunky/arcade idiom) reproduces EXACTLY as border-bottom-width
  • border-bottom-color: free, stateful, no texture. Lift on press with translate so the border stays put like the reference.
  • Only a blurred glow (0 0 26px rgba(...)) has no USS equivalent and must be baked.

Baking both multiplies the kit by colour × size, and every baked plate is another unatlassable texture to keep in sync across hover states.

UITK-MOTION-VOCABULARY — USS has NO @keyframes; there are exactly four tools

Pick by lifetime, never by writing style from an Update:

  1. transition + a class toggle — one-shot state change. Cheapest, GPU, declarative.
  2. element.experimental.animation.Start(from, to, ms, cb) — a one-shot the code owns. Starting a new animation REPLACES the running one outright, so a persistent element re-shown with new content fades with no transition-to-zero flash; a class-toggled transition visibly flashes there.
  3. element.schedule.Execute(a).Every(ms) re-toggling classes — loops.
  4. generateVisualContent at an explicit repaint Hz — procedural motion.
  • UITK-NO-SHADER-ON-VISUALELEMENT — USS cannot bind HLSL to a VisualElement. A status rim, a frost edge, a scanline: these are generateVisualContent overlays drawn by the element (or a world-space quad that keeps the shader), never a material on the element. (a card game.)

A transition on a non-animatable property silently does nothing: display is Non-animatable, visibility/position are Discrete. To fade out then remove from layout, transition opacity and flip display: none in a TransitionEndEvent.

  • UITK-FADE-IN-NEEDS-TWO-TICKS, FADE-OUT-NEEDS-SETTLED-FRAMES. (1) A class-toggled fade-in on a freshly created panel (a UIDocument that just loaded with its scene) only runs if the element was RESOLVED at opacity 0 first. display: flex + schedule.Execute(remove class).StartingIn(0) lands the removal in the same panel update as the first style resolve, and the panel pops to 1 with no transition — racily: measured 1.0 on frame one in one scene load and a 16-frame fade in the next. Nest two StartingIn(0) schedules (remove on the second tick) and it is deterministic. (2) A time-based fade cannot show through a load hitch: after a surface scene lands, 1.7 s + 0.8 s frames arrive 2-3 frames later and a 350 ms opacity transition collapses into two rendered frames (a pop). Gate the loading overlay's fade-out on N consecutive frames under 100 ms (six, with a 3 s cap) after the two rendered frames; two settled frames is not enough because the hitch is a burst. TransitionEndEvent bubbles from children, so check evt.target == element before flipping display: none, and keep a scheduled floor (1 s) for detached or zero-duration elements that never raise it. Prove it with a per-frame resolvedStyle.opacity log, never a screenshot alone. (a space sandbox game.)

Layout primitives USS does not have

  • No display: grid. grid-template-columns: repeat(N, 1fr) becomes a flex-wrap: wrap row whose children carry width: <100/N>% and flex-shrink: 0 — never flex-grow, which looks identical at the designed item count and balloons one side at every other count. Subtract the vertical scrollbar (~9–12 px) from the available width first or a column silently drops (6 designed → 5 shown).
  • No aspect-ratio. Size a fixed-aspect surface (a framed window, a card face) in fixed px on BOTH axes matched to the art's aspect, with the panel on Scale With Screen Size — px then scale with the screen and the aspect cannot distort. % on both axes distorts at every non-reference aspect.
  • A ScrollView at flex-grow: 0 does not scroll — it expands to full content height and shoves/overlaps its siblings, so the symptom is overlapping siblings rather than a missing scrollbar. Any variable-length list needs flex-grow: 1; flex-shrink: 1; min-height: 0; pin the fixed sections with flex-shrink: 0 and give the GROW to the scroll region. Corollary: never pin a bottom section with a flex-grow spacer — on overflow the spacer collapses to 0 and the "bottom" section lands on top of the content.
  • Never re-centre a group on its content box for a live-updating line. A translate: -50% -50% stack sizes to its children, so every string-length change reflows it and the whole group jitters ("the spinner bounces"). Make the centring container full-screen with absolutely-positioned slots pinned to screen percentages; fixed height + nowrap + ellipsis on the label; pad numeric fields to constant width.

UITK-EVENT-PHASES — learn the three-phase model before writing a handler

Dispatch is trickle-down (root→target) → target → bubble-up (target→root), and RegisterCallback<T>(h) defaults to target + bubble-up, so ancestors receive everything.

  • Guard a parent handler with if (evt.target != evt.currentTarget) return; — "my row handler fires when I click the button inside it" is this, by design.
  • TrickleDown.TrickleDown to intercept on the way down (a modal backdrop swallowing clicks).
  • StopPropagation still runs the remaining handlers on the CURRENT target; StopImmediatePropagation does not.
  • Capture the pointer for any drag: element.CapturePointer(evt.pointerId) on PointerDownEvent, ReleasePointer on up, and handle PointerCaptureOutEvent to finalise/cancel when capture is stolen (only one element holds it). Without capture the drag silently ends the first time the pointer crosses the element bound — on a flick, nearly always — and it reads as input latency. This is the shape for sliders, drag-resize, drag-to-place and card drag.
  • CAPTURE-HIDES-HOVER-FROM-THE-DROP-TARGET. While the dragged element holds the capture, no other element gets :hover or PointerEnterEvent, so the slot hover rule never lights where the item will land Hit-test the candidates' worldBound against evt.position in the dragged element's move handler, toggle a drop-target class that shares the hover rule, and clear it in the one method every drag end passes through.
  • A plain VisualElement is not focusable. Set element.focusable = true before expecting any keyboard/navigation event; tabIndex only ORDERS the ring (0 default, negative = removed). Focus enqueues a Focus/Blur pair that resolves AFTER the current callback, and nothing is focused on scene load — seed keyboard-first flows from Start and read state from panel.focusController.focusedElement, never inline after the call.

LATER-SHEET-OUTRANKS-THE-THEME-SHEET — put a screen's state override in the screen's own sheet

A template loaded <Style src="Theme.uss"/> then <Style src="Screen.uss"/>. The theme sheet's .theme.instant { transition-duration: 0s; } did NOT apply to a root carrying both classes: the screen sheet's single-class .screen-root { transition-duration: 240ms; } won, resolvedStyle read 240ms with the class on, and the swapped-in view faded from 0 anyway. Do not reason from web-CSS specificity across sheets. A state override that must beat a screen's own rule is written in THAT sheet beside the rule (.screen-root.instant, .screen-root.instant * { ... }) — the same reason each screen already carried its own reduced-motion rule — and a test asserts the resolved value after one panel update, never the class list. (Found.)

UITK-PANEL-IS-SHARED — one PanelSettings is one IPanel, and that breaks panel.Pick

Every UIDocument sharing one PanelSettings asset shares ONE IPanel, so panel.Pick /PickAll returns the topmost element across ALL documents. Any other screen's full-screen root left at PickingMode.Position (a modal backdrop at a high sortOrder) occludes every pick and the world-input gate reads "over UI" forever — Pick is returning a truthful answer about a document you were not asking about. This qualifies TOUCH-UI-GATE-SYNC-PICK (the unity page), which prescribes Pick unconditionally: gate world input by hit-testing the OWNING document's subtree (worldBound.Contains on the specific HUD regions, with the bottom-left→top-left Y flip); keep panel.Pick for single-document panels and for "is the cursor over any UI at all".

Other consequences of the shared panel and of PanelSettings defaults:

  • The shared PanelSettings must be a PROJECT asset. A convenience fallback like FindAssets("t:PanelSettings")[0] picks a PACKAGE asset (Cinemachine ships one), which breaks global UI scaling for those panels and makes the UI depend on a regenerable package-cache file — invisible in the inspector, because a PanelSettings is assigned.
  • A fresh PanelSettings is Constant Pixel Size, which does NOT scale with the screen. Set Scale With Screen Size + Reference Resolution + Match Width Or Height BEFORE authoring anything (then PANEL-MATCH-AXIS picks the match). Constant Pixel Size is deliberate only when a runtime PanelSettings.scale knob owns the scaling instead. The dual-orientation values and the fixed-axis cases are PANEL-SCALE-MODE-DECIDES-BOTH-ORIENTATIONS / PORTRAIT-ONLY-MATCHES-WIDTH in the uitk orientation page.
  • A control that drives a global presentation knob from INSIDE the scaled surface must commit on drag-END. Update the readout live on ChangeEvent but apply on PointerUpEvent + PointerCaptureOutEvent, gated on a dragging bool set at PointerDownEvent; keyboard and click-to-set apply immediately. Committing per tick rescales the track under the captured pointer, so the pointer lands at a different fraction of it and the value jumps — the user physically cannot land a number.
  • UIDocument.rootVisualElement is NULL during Awake — the document builds its tree in its own OnEnable, after every Awake. The usual if (root != null) guard makes it fail silently and the panel just defaults visible over whatever should be showing at play-start. Set initial display in Start or later.
  • Gate one-time wiring on TREE IDENTITY, not a bool _wired. "One-time in OnEnable" is not one-time: any visibility system using GameObject.SetActive, every reconnect and every domain reload re-runs OnEnable, and OnDisable does not strip clicked += / RegisterCallback — so handlers stack until one click fires disconnect or quit twice. Store _wiredRoot and re-wire only when _wiredRoot != root; keep _wiredRoot across OnDisable. A bare bool is wrong in both directions — it re-accumulates on a bool reset AND skips wiring a rebuilt tree, leaving dead buttons. (This is the OnEnable half of UITK-PREBUILD-REUSE.)
  • Cloning a UXML produces a TemplateContainer wrapper you did not author. Hiding only the named child leaves a full-screen pickable wrapper that makes lower-sorting controls look dead — a correct, visible, enabled button that does nothing, with no console output. Force the wrapper to PickingMode.Ignore, and for mutually exclusive screens hide the named element AND the UIDocument.rootVisualElement with display: none. Verify with the ATTACHED panel's Pick at control centres; detached-tree picking is not evidence.

UITK-SAFE-AREA — the notch inset is panel space, not screen pixels

Screen.safeArea is in SCREEN pixels with a bottom-left origin; UITK panel coordinates are top-left and scaled. Divide each inset by panel.scaledPixelsPerPoint before writing root.style.paddingTop/Bottom/Left/Right, or you over- or under-inset by the panel scale factor on every device that is not 1:1.

  • Apply on a dedicated safe-area node; cache on (safeArea, screen size, orientation) so it no-ops when nothing changed; re-apply from GeometryChangedEvent plus an orientation poll.
  • Keep edge-bleeding BACKGROUND art as a SIBLING outside the safe node — insetting the background too leaves visible letterbox bars around the notch. Only interactive/legible content is inset.
  • Padding insets nothing whose children are ABSOLUTE. A screen stack — one absolutely positioned left/top/right/bottom: 0 wrapper per screen — is laid out from the safe node's BORDER box, so a padded safe node measures correctly, logs nothing, and moves no pixel. Measured: host padded 59/34pt, every screen still at y=0 and full panel height. When the safe node's children are absolute, make the safe node position: absolute and write the inset as left/top/right/bottom; its border box is then the safe rect and inset-0 children fill exactly it. width/height: 100% is not the fix — the percentage resolves against the parent's content box while the origin comes from the padding edge, so the element lands inset at the top and overflows by the same amount below.
  • An offscreen capture proves nothing about the inset unless it keeps the panel's scale. Pointing PanelSettings.targetTexture at a texture switches the panel to ConstantPixelSize and scaledPixelsPerPoint drops to 1 — the inset, derived from it, changes in the very image meant to show it. Carry the live scale into the capture (PanelSettings.scale) and size the texture in points × scale. An editor tool that fakes the inset must also express its override in the space the runtime reads it back in (Screen.width/height), never in the panel's own size: during a capture the two differ, and a mixed-space override insets by hundreds of points and renders a blank frame.

UITK-BINDING-VERSION-SPLIT — Bind/BindProperty is EDITOR-ONLY

  • element.Bind / BindProperty (SerializedObject) does nothing in a player build. Reserve it for CreateInspectorGUI. This is the classic "works in the editor, dead in the build" trap.
  • Runtime binding (element.dataSource + dataSourcePath, element.SetBinding("text", new DataBinding{…}), [CreateProperty]) exists only on Unity 6 / 2023.2+. Refresh it from INotifyBindablePropertyChanged raised in setters — never BindingUpdateTrigger.EveryFrame, which turns a declarative binding back into a per-frame dirty.
  • 2022 LTS has NO runtime binding at all: push label.text from a change event or a schedule tick.

UITK-PERF — virtualize, hint, and do nothing while hidden

  • Unbounded lists use ListView. Build the row once in makeItem (runs ~visible-count times); in bindItem(element, index) only SET data on the RECYCLED element — never new or allocate there. fixedItemHeight + virtualizationMethod = FixedHeight for uniform rows (cheapest), DynamicHeight only when heights genuinely vary. (UITK-PREBUILD-REUSE says "pool rows"; ListView is the built-in that does it.)
  • Animate translate / scale / rotate / opacity only — they run at the GPU transform stage with no relayout. width / height / top / left / margins / padding force a layout recalc up the hierarchy on every tick, so a card-deal written against left/top relayouts the whole screen every frame and profiles as "UI is slow" with no single culprit. Set element.usageHints = UsageHints.DynamicTransform on anything that moves or scales frequently, or GroupTransform on an animated parent.
  • element.schedule.Execute(a).Every(ms) beats a MonoBehaviour Update — it ticks only while the element is ATTACHED, so a hidden or detached panel auto-stops with no visibility gate. Hold the returned IVisualElementScheduledItem and Pause/Resume it; never create a second one (UITK-PREBUILD-REUSE sub-tell 2).
  • A hidden panel must do NO work — return before timers, service/ECS queries, label stamping, debug drawing and MarkDirtyRepaint, keeping only hotkey/self-toggle handling above the gate. Guard every MarkDirtyRepaint / display write / class toggle / label.text assignment on an actual value change. The failure mode is subtle: the UI still looks correct while every frame dirties the visual tree and allocates — a steady GC pulse with no visible symptom until it profiles on device.

Sprite-kit economy (extends the SLICE-VERIFY bake contract above)

  • importer.spriteBorder = new Vector4(left, bottom, right, top) — x=left, y=bottom, z=right, w=top — even though the Sprite Editor UI and most docs say L/T/R/B. A postprocessor writing borders from a per-texture table transposes top and bottom by default, and the result is a silent visual bug (asymmetric chrome, a lip on the wrong edge) that reads as a bad bake, never an error.
  • Do NOT 9-slice a frame with a fixed feature mid-edge (a top-centre nav bay, a notch, a title tab) — 9-slice stretches each edge's middle and stretches the feature away. Ship it as a border-0 Single sprite with -unity-background-scale-mode: stretch-to-fill on an aspect-matched fixed-px element, or split into sliced edges + fixed corner brackets + a separately-anchored centre piece. Only uniform-corner rectangles 9-slice cleanly.
  • Small dark chrome uses border + background-color, not a dark sprite. A dark sprite that reads fine on the mockup's page vanishes against a dark UITK frame in-engine — present, correct and invisible, the hardest class of UI bug to see in a screenshot. Reserve sprites for art with real internal detail.
  • Never author a sprite per colour variant. UITK-VISUAL-TRAPS item 4 already reaches for -unity-background-image-tint-color for flat glyph states; this is the authoring rule behind it. Bake ONE white / alpha-mask sprite (or a white luminance ramp, e.g. 1.0 → 0.80 with any lip at its own stop) and recolour per state or per player with the tint — the tint is the ramp's TOP colour. Inline SVGs from a mockup import as white alpha masks for exactly this. Anything that must keep its own colour (a white rim, an already-alpha'd glow) stays a SEPARATE untinted element — a rim baked into the ramp gets multiplied by the tint. (Same law in uGUI: the css to ugui page §9.)

Authoring ownership and the edit-mode loop

  • Structure = UXML, look = USS, data = C# (HARD). C# may query named elements, set text/fill/enabled/visibility, toggle USS modifier classes, wire callbacks once, and clone an AUTHORED prototype that already exists in the same UXML when the count is data-driven. Forbidden: new VisualElement/new Label to build permanent layout, Clear + rebuild of structure, or a second hand-written hierarchy that diverges from the UXML. Code-built UI cannot be opened in UI Builder, so the developer cannot hand-adjust a margin, and preview/Play drift apart with no diff.
  • Preserve every element NAME the controller queries. Before rewriting a UXML, read its Q<>("Name") calls and keep every name (park unused ones hidden); zero controller changes is the goal. Q<> returns null silently, so a rename ships a screen that builds, imports clean, and NREs at the first data push.
  • Seed a control with SetValueWithoutNotify BEFORE RegisterValueChangedCallback, never the reverse — reversed, every form build fires a spurious change per row, so simply opening a tab writes back over the asset it is displaying.
  • USS transitions do not tick in edit mode, so an edit-mode capture photographs the START state. Set the target state before the first render and zero transitionDuration inline when capturing.
  • AssetDatabase.ImportAsset(uss, ForceUpdate) is documented as the Play-surviving reload but measured to drop Play, like Refresh. Iterate USS in EDIT mode and enter Play fresh; UXML and C# never hot-reload in Play regardless (this is the other half of UITK-REIMPORT-REBUILDS-PANEL).
  • UITK-THEME-IMPORT-IS-NOT-TRANSITIVE — editing an imported .uss does not reimport the .tss that @imports it. The theme resolves its import list at IMPORT time, so PanelSettings. themeStyleSheet keeps serving the previously resolved sheet: the rule is present and correct in the file, specificity is right, the screen renders exactly as before, and nothing logs — it reads as "my selector is wrong". Reimport the THEME (AssetDatabase.ImportAsset(tss, ForceUpdate)), then restart Play. (a card game.)
  • An overlay runtime panel composites in the edit-mode Game view only if it was born during a Play session and persisted on the PanelSettings (measured HDRP + 6000.5). An edit-born panel lays out correctly (worldBound/resolvedStyle valid) and never draws — no UpdatePanels / MarkDirtyRepaint / PanelRenderer variant fixes it. Never destroy the last UIDocument on the shared PanelSettings mid-capture-session; rebind a reusable preview doc. If the panel is edit-born, pixels require Play — report geometry only, and say so.
  • For a CSS/HTML-sourced design, iterate on a headless render of the mockup (msedge --headless=new --screenshot=OUT.png --window-size=W,H "file:///<abs, %20-encoded>") — ~1 s per iteration versus a Play cycle — and READ the PNG you produced; never declare a design step done from the stylesheet. Caveat: a mockup pulling Google Fonts silently falls back on a machine with no network, so such a render is NOT a typography reference.

(Sources: UI notes and stylesheets from several shipped projects.)

  • USS-VARS-NOT-WRITABLE-FROM-CS — a USS custom property (--x) cannot be written from C#. A runtime "style knob" is a class toggle or an inline style on the element, never a var. (a project docs/pitfalls/settings.md:29.)
  • WORLD-ANCHORED-OVERLAY-PROJECTS-VIA-PANELUTILS — a world-tracking overlay (health bars, markers) projects with RuntimePanelUtils.CameraTransformWorldToPanel(panel, worldPos, camera). A hand-rolled WorldToScreenPoint + panelHeight - y flip drifts under panel scaling. (a project docs/pitfalls/ui-login-flow.md:80.)
  • MAX-HEIGHT-SQUISHES-CHILDREN — max-height on a multi-child panel makes flex SQUISH the children to fit (labels collapse onto inputs). Bound height with a ScrollView (flex-grow: 1; min-height: 0) so the viewport clips; a panel whose content fits gets no cap. (a project docs/pitfalls/ui-login-flow.md:50.)
  • LOCKED-BAND-FITS-ITS-MEASURED-CONTENT — a fixed-height flex band shorter than its content squeezes its rows, and a conditional control toggled with display reflows every sibling. Labels default to flex-shrink: 1 and shrink below their text height while the text still draws from the box top, so a 34px-tall number overflowed its header (squeezed to 30px) onto the bar below. Measured: a 280px band needed 367px, 417px with a 49px button row whose display: none at completion moved everything. Size the band from a natural-height pass (height auto, every conditional row shown); give the fixed-geometry block flex-shrink: 0 and the copy region flex-grow: 1; min-height: 0; overflow: hidden; wrap each conditional control in a fixed-height slot. Verify the text bottom against the next sibling's worldBound in BOTH states, not by eye.
  • STATE-CHANGE-NEVER-MOVES-LAYOUT — a toggle, tab, filter, selection, hover, count or text change never changes any element's size or position. Every stateful region gets a fixed or reserved box sized to its largest state (longest label, full list, every optional row shown); a state swaps content INSIDE that box (visibility/opacity/class, or a fixed-height slot), never display: none on a flow sibling, never auto-width on a label that changes. Done means: capture every state of the screen and diff every control's worldBound across them - any delta is a defect - plus zero overlaps and the minimum gap between siblings met in every state.

  • PORTED-RGBA-ALPHA-IS-3X-STRONGER-IN-LINEAR — a CSS board composites in sRGB, a UI Toolkit panel in a Linear-colour-space project composites in LINEAR, so a copied rgba alpha renders about three times stronger. Every translucent accent is hit, not only scrims: measured over black, a board's .14 wash rendered (45,80,104) where the board shows (17,28,36), and .25 rendered (61,106,136) where the board shows (30,50,63). This is not "close enough" — it is the whole difference between a soft edge and the hard, over-lit look a reviewer reports as "sharp compared to the ref". Do not eyeball a correction. Solve for the alpha that RENDERS the board's colour: take the board's sRGB result a x C, and search a' minimising the error of linearToSrgb(a' x srgbToLinear(C)) against it. The conversion is per-overlay, not a constant — .50 -> .22, .25 -> .055, .20 -> .036, .14 -> .020, .08 -> .0085 for a light accent over black. Land the solved numbers in the token sheet with the board's originals in the comment, so the next reader does not "fix" them back. Verify by sampling the PNG, never by looking at it. The uGUI sibling is UGUI-DIM-ALPHA-LINEAR in the css to ugui page, which covers scrims only.

  • ROTATED-LABEL-CENTRES-ITS-UNROTATED-BOX — rotate in USS is visual-only, so a flex-centred vertical label is centred BEFORE it turns and lands off its tab. USS has no writing-mode, so a sidebar tab's text is a child element the stylesheet rotates; if that child is laid out by the parent's align-items/justify-content, flex centres the wide-and-short box and the rotation then swings it off the tab. Pin the child as the ROTATED footprint instead: for a W x H tab, an absolutely positioned H x W label at left: (W-H)/2; top: (H-W)/2 shares the tab's centre, so turning it about that centre lands it exactly on the tab. Read layout (the local, unrotated rect) to debug this — worldBound reports the rotated bounding box and looks correct while the text is visibly wrong. Tracking is the second half: a tracked vertical label whose run reaches the tab's own length touches its neighbours and reads as one word, so match the board's measured RUN and air (e.g. 77px of run in an 84px tab), not its em number, because a different text engine sets the same tracking to a different length.

UITK-NATIVE-POPUP-BELONGS-TO-THE-PANEL

  • A runtime GenericDropdownMenu attaches to RuntimePanel.visualTree, outside the UIDocument subtree. Import popup USS through the panel's existing .tss after the default theme; a sheet referenced only by a screen's UXML cannot reach this sibling popup.
  • Scope custom-property definitions to .unity-base-dropdown as well as the authored screen root. The popup cannot inherit tokens or fonts from a document it is not inside.
  • Remove scheduled panel-wide inline painting when the theme owns these styles. Keeping both paths gives styles two owners and inline values shadow later USS changes. Reimport the .tss as well as its changed .uss, then verify an actually opened native popup's hierarchy, resolved styles and pixels. A closed field does not prove its detached popup is styled.

SCALE-TO-FIT-FITS-THE-CANVAS-NOT-THE-INK

  • -unity-background-scale-mode: scale-to-fit (and background-size: contain) fits the whole texture, transparent padding included. A bake whose art occupies the middle third of its canvas renders at a third of the box you sized for it, inside an element whose measured width, contrast and layout are all correct. Nothing warns, and the box is the right size, so the defect reads as "the artwork is too small" rather than "the file is mostly empty".
  • Measured: a 1200x460 wordmark whose ink bounded 384..819 x 39..434 drew at 165px inside a box scaled for 416px. Crop the bake to its alpha bounds and re-derive the element's aspect from the CROPPED size; a comment quoting the uncropped dimensions is then also wrong.
  • Check every generated image once, not the one that looks wrong. One pass over the folder comparing Image.getbbox on the alpha channel against the file size finds them all. Uniform padding on a nine-sliced background is intentional and must be left alone; lopsided padding on a fit-to-box image is the bug.

DYNAMIC-FONTASSET-NEEDS-ITS-SOURCE

  • A TextCore FontAsset with m_AtlasPopulationMode: 1 (Dynamic) rasterises glyphs from m_SourceFontFile at runtime; with that reference null, every label using it draws NOTHING. The asset still loads, the element still lays out at its full size, colour and contrast checks still pass, and the hierarchy looks correct. Only the pixels are empty.
  • The usual cause is copying font files between projects without their .meta: the .ttf gets a fresh GUID, and every FontAsset pointing at the old one is silently nulled to {fileID: 0}. Copy a font's .meta with it, always.
  • Two guards catch it and nothing else does. Static: fail when any FontAsset a stylesheet names has a non-Static population mode and a null source. Dynamic: assert MeasureTextSize returns non-zero width for every visible label — measured text width is the only property of a label that notices missing glyphs. Prove both by deliberately re-breaking one font and watching them fail.

UITK-SELECTED-GLYPHS-NEED-TRANSLUCENT-HIGHLIGHTS

  • Theme the focused selectable Label explicitly: the default focus rule can replace a white foreground with dark teal (measured #00526A), making the entire reply unreadable on a dark surface.
  • Keep selection paint translucent. In Unity 6000.5.0f1 it paints over the selected glyphs: an opaque highlight hid every selected letter even while the resolved foreground remained white. Verify actual selected text in both themes; foreground/background numbers alone miss this failure.
  • TextField consumes --unity-selection-color; a selectable Label needs its own CustomStyleResolvedEvent handler to apply the authored color to selection.selectionColor. Measured 0.30-alpha dark and 0.22-alpha light highlights preserved readable selected glyphs.

CAPS-ARE-LOCALE-DATA — an ALL-CAPS look is table data, not a style, once a second language exists

A single-language UI bakes caps into the string ("CONTINUE") or reaches for text-transform, which USS does not have. Both break the moment a second language arrives: three of the CJK scripts have no case at all, so upper-casing them is a no-op that still costs a code path, and Turkish i upper-cases to İ, which is not the letter the reader expects. A runtime ToUpper carries the same bug plus a per-frame allocation.

  • The string table holds the cased string, per locale, produced by the tool that builds the table — not by the UI and not by a style. Cased scripts get the upper-cased value; caseless scripts get the translation untouched.
  • Carry a BakedCaps column (or equivalent) through the authoring CSV so which cells were cased is auditable from the source, not inferred from the rendered screen.
  • A size-budgeted slot takes a shorter word, not a smaller font. A vertical tab or chip sized in the source language clips translations mid-word, and the clipped text reads as a different word with a clean console. Author a per-locale override keyed by string id, with the pixel budget written down beside it, and apply it AFTER the casing pass so the override cannot be re-cased.
  • Verify by rendering each locale, not by reading the table: a clipped label and a missing glyph both draw silently.

UITK-SELECTION-KEEPS-CONTROL-METRICS

When selection is meant to keep a content-sized button fixed, do not change its font metrics. -unity-font-style: bold changes the caption's measured width even when scale stays (1,1,1): two choice buttons measured 72 -> 68 and 71 -> 75 pixels when selection moved. This feels like a broken press animation but is a layout change. Keep one font weight and use background/border/color feedback, or explicitly reserve the largest state if typography must change. Measure both buttons and adjacent controls before press, while held, after release and after selection; a scale-only assertion misses it.

USS-HAS-NO-SIBLING-OR-CALC

  • USS has no sibling combinators (+, ~) and no calc; the child combinator > works. Space siblings with a symmetric margin: var(--gap-half) on each item and a var(--gap-half-negative) margin on the container, both as tokens. Custom properties declared on :root resolve in a runtime panel; override one on a subtree class to resize one screen (a phone class that lowers a title token).

CARD-ROW-FITS-WHOLE-CARDS

  • Unequal card heights in a horizontal ScrollView: the content viewport is a row with align-items: stretch while the cards centre by content. Set .unity-scroll-view__content-viewport { align-items: center; } and the content container to align-items: stretch: cards take the tallest height and a short row centres.
  • The last visible card is cut mid-text: fixed-width cards in a scrolled row. Narrow the scroll to floor(room / stride) whole cards from resolved widths. Show page arrows only while cards wait off-row, and hide them with visibility, not display, so the fit does not feed back into its own geometry.
  • Letterboxed previews: Image.scaleMode = ScaleToFit in a frame of another aspect. Use ScaleAndCrop for scene previews; keep ScaleToFit for portraits and icons that must show whole.
  • Absolute children of a padded container sit on its edge: offsets resolve against the border box, so a dock adds the pad token itself (right: var(--pad)).

LIVE-USS-RELOAD-REASSIGNS-THE-THEME

  • Amends UITK-THEME-IMPORT-IS-NOT-TRANSITIVE above. Measured in a runtime panel in Play (6000.5): after an edited .uss and its .tss were reimported, the running panel still showed the old style until panelSettings.themeStyleSheet was reassigned (set to null, then back to the same asset). That restyled it live, with no domain reload and without leaving Play. UXML and C# changes still need a fresh Play. If a reimport drops Play on your version (the bullet above), iterate in Edit mode instead.

CUSTOM-PAINT-SITS-OUTSIDE-CONTENT-CLIP

overflow: hidden can clip custom generateVisualContent border and outer-shadow paint along with descendants. A 2 px border and 3 px shadow read back correctly but were absent in capture; switching clipping off on that same pane restored both. Use an authored outer paint element and an authored inner content clip when custom paint must extend outside the content box. Preserve child coordinates, border insets, corner clipping, hit targets and binding names; prove the output.

INLINE-TEXT-BOUNDS-ARE-NATURAL-LINE-BOXES

An inline span's browser Range bounds describe natural glyph boxes, which may start above its parent's CSS line box. Porting those bounds and adding the line box's half-leading again shifts the baseline twice. Capture actual text lines; preserve their pitch while applying leading once. Keep one-line authored slots nowrap, and retain measured multiline breaks. Changing data requires its own wrapping/overflow proof; a static text capture does not prove every dynamic value.