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 bareLabel {}type rule — onlyTextElementpicks 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. Noborder-style: a dashed/dotted outline becomes solid in a dimmer token, or baked art if the dash is load-bearing. background-imagetakes 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
--tokenscan be READ from C# (resolvedStyle.Get*Property) but never WRITTEN — there is nostyle.SetCustomProperty. Retheme by toggling ONE class on the root that redefines the tokens; only values consumed throughvar(--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
Labeldefaults towhite-space: normalandflex-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 declareswhite-space: nowrap; flex-shrink: 0in its USS class; where the container can be narrower than the text, addoverflow: hidden+text-overflow: ellipsisso it truncates instead. Verify by the rendered height (resolvedStyle.heightequals one line, 31 px at font 19 here), not by reading the string, which is always whole. Refined 0.6.14:nowrapis not a slot. An empty label still collapses to 0 height and a\nstill 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, plusoverflow: 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, sotext-transform: uppercaseimports 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 — enumerateUnityEngine.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 asborder-bottom-widthborder-bottom-color: free, stateful, no texture. Lift on press withtranslateso 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:
transition+ a class toggle — one-shot state change. Cheapest, GPU, declarative.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.element.schedule.Execute(a).Every(ms)re-toggling classes — loops.generateVisualContentat 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
generateVisualContentoverlays 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 twoStartingIn(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.TransitionEndEventbubbles from children, so checkevt.target == elementbefore flippingdisplay: none, and keep a scheduled floor (1 s) for detached or zero-duration elements that never raise it. Prove it with a per-frameresolvedStyle.opacitylog, never a screenshot alone. (a space sandbox game.)
Layout primitives USS does not have
- No
display: grid.grid-template-columns: repeat(N, 1fr)becomes aflex-wrap: wraprow whose children carrywidth: <100/N>%andflex-shrink: 0— neverflex-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
ScrollViewatflex-grow: 0does 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 needsflex-grow: 1; flex-shrink: 1; min-height: 0; pin the fixed sections withflex-shrink: 0and give the GROW to the scroll region. Corollary: never pin a bottom section with aflex-growspacer — 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.TrickleDownto intercept on the way down (a modal backdrop swallowing clicks).StopPropagationstill runs the remaining handlers on the CURRENT target;StopImmediatePropagationdoes not.- Capture the pointer for any drag:
element.CapturePointer(evt.pointerId)onPointerDownEvent,ReleasePointeron up, and handlePointerCaptureOutEventto 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
:hoverorPointerEnterEvent, so the slot hover rule never lights where the item will land Hit-test the candidates'worldBoundagainstevt.positionin 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
VisualElementis not focusable. Setelement.focusable = truebefore expecting any keyboard/navigation event;tabIndexonly ORDERS the ring (0 default, negative = removed).Focusenqueues a Focus/Blur pair that resolves AFTER the current callback, and nothing is focused on scene load — seed keyboard-first flows fromStartand read state frompanel.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.scaleknob 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
ChangeEventbut apply onPointerUpEvent+PointerCaptureOutEvent, gated on adraggingbool set atPointerDownEvent; 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.rootVisualElementis NULL duringAwake— the document builds its tree in its ownOnEnable, after everyAwake. The usualif (root != null)guard makes it fail silently and the panel just defaults visible over whatever should be showing at play-start. Set initial display inStartor later.- Gate one-time wiring on TREE IDENTITY, not a
bool _wired. "One-time inOnEnable" is not one-time: any visibility system usingGameObject.SetActive, every reconnect and every domain reload re-runsOnEnable, andOnDisabledoes not stripclicked +=/RegisterCallback— so handlers stack until one click fires disconnect or quit twice. Store_wiredRootand re-wire only when_wiredRoot != root; keep_wiredRootacrossOnDisable. 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 theOnEnablehalf of UITK-PREBUILD-REUSE.) - Cloning a UXML produces a
TemplateContainerwrapper 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 toPickingMode.Ignore, and for mutually exclusive screens hide the named element AND theUIDocument.rootVisualElementwithdisplay: none. Verify with the ATTACHED panel'sPickat 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 fromGeometryChangedEventplus 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: 0wrapper 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 nodeposition: absoluteand write the inset asleft/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.targetTextureat a texture switches the panel toConstantPixelSizeandscaledPixelsPerPointdrops 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 forCreateInspectorGUI. 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 fromINotifyBindablePropertyChangedraised in setters — neverBindingUpdateTrigger.EveryFrame, which turns a declarative binding back into a per-frame dirty. - 2022 LTS has NO runtime binding at all: push
label.textfrom a change event or ascheduletick.
UITK-PERF — virtualize, hint, and do nothing while hidden
- Unbounded lists use
ListView. Build the row once inmakeItem(runs ~visible-count times); inbindItem(element, index)only SET data on the RECYCLED element — nevernewor allocate there.fixedItemHeight+virtualizationMethod = FixedHeightfor uniform rows (cheapest),DynamicHeightonly when heights genuinely vary. (UITK-PREBUILD-REUSE says "pool rows";ListViewis the built-in that does it.) - Animate
translate/scale/rotate/opacityonly — 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 againstleft/toprelayouts the whole screen every frame and profiles as "UI is slow" with no single culprit. Setelement.usageHints = UsageHints.DynamicTransformon anything that moves or scales frequently, orGroupTransformon an animated parent. element.schedule.Execute(a).Every(ms)beats a MonoBehaviourUpdate— it ticks only while the element is ATTACHED, so a hidden or detached panel auto-stops with no visibility gate. Hold the returnedIVisualElementScheduledItemandPause/Resumeit; 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 everyMarkDirtyRepaint/displaywrite / class toggle /label.textassignment 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-fillon 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-colorfor 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 Labelto 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
SetValueWithoutNotifyBEFORERegisterValueChangedCallback, 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
transitionDurationinline when capturing. AssetDatabase.ImportAsset(uss, ForceUpdate)is documented as the Play-surviving reload but measured to drop Play, likeRefresh. 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
.ussdoes not reimport the.tssthat@imports it. The theme resolves its import list at IMPORT time, soPanelSettings. themeStyleSheetkeeps 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/resolvedStylevalid) and never draws — noUpdatePanels/MarkDirtyRepaint/PanelRenderervariant 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 projectdocs/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-rolledWorldToScreenPoint+panelHeight - yflip drifts under panel scaling. (a projectdocs/pitfalls/ui-login-flow.md:80.) - MAX-HEIGHT-SQUISHES-CHILDREN —
max-heighton a multi-child panel makes flex SQUISH the children to fit (labels collapse onto inputs). Bound height with aScrollView(flex-grow: 1; min-height: 0) so the viewport clips; a panel whose content fits gets no cap. (a projectdocs/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
displayreflows every sibling. Labels default toflex-shrink: 1and 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 whosedisplay: noneat completion moved everything. Size the band from a natural-height pass (height auto, every conditional row shown); give the fixed-geometry blockflex-shrink: 0and the copy regionflex-grow: 1; min-height: 0; overflow: hidden; wrap each conditional control in a fixed-height slot. Verify the text bottom against the next sibling'sworldBoundin 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), neverdisplay: noneon a flow sibling, never auto-width on a label that changes. Done means: capture every state of the screen and diff every control'sworldBoundacross 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
rgbaalpha renders about three times stronger. Every translucent accent is hit, not only scrims: measured over black, a board's.14wash rendered(45,80,104)where the board shows(17,28,36), and.25rendered(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 resulta x C, and searcha'minimising the error oflinearToSrgb(a' x srgbToLinear(C))against it. The conversion is per-overlay, not a constant —.50 -> .22,.25 -> .055,.20 -> .036,.14 -> .020,.08 -> .0085for 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 isUGUI-DIM-ALPHA-LINEARin the css to ugui page, which covers scrims only. - ROTATED-LABEL-CENTRES-ITS-UNROTATED-BOX —
rotatein USS is visual-only, so a flex-centred vertical label is centred BEFORE it turns and lands off its tab. USS has nowriting-mode, so a sidebar tab's text is a child element the stylesheet rotates; if that child is laid out by the parent'salign-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 aW x Htab, an absolutely positionedH x Wlabel atleft: (W-H)/2; top: (H-W)/2shares the tab's centre, so turning it about that centre lands it exactly on the tab. Readlayout(the local, unrotated rect) to debug this —worldBoundreports 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 itsemnumber, because a different text engine sets the same tracking to a different length.
UITK-NATIVE-POPUP-BELONGS-TO-THE-PANEL
- A runtime
GenericDropdownMenuattaches toRuntimePanel.visualTree, outside theUIDocumentsubtree. Import popup USS through the panel's existing.tssafter the default theme; a sheet referenced only by a screen's UXML cannot reach this sibling popup. - Scope custom-property definitions to
.unity-base-dropdownas 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
.tssas 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(andbackground-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.getbboxon 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
FontAssetwithm_AtlasPopulationMode: 1(Dynamic) rasterises glyphs fromm_SourceFontFileat 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.ttfgets a fresh GUID, and every FontAsset pointing at the old one is silently nulled to{fileID: 0}. Copy a font's.metawith 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
MeasureTextSizereturns 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 ownCustomStyleResolvedEventhandler to apply the authored color toselection.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
BakedCapscolumn (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 nocalc; the child combinator>works. Space siblings with a symmetricmargin: var(--gap-half)on each item and avar(--gap-half-negative)margin on the container, both as tokens. Custom properties declared on:rootresolve 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 withalign-items: stretchwhile the cards centre by content. Set.unity-scroll-view__content-viewport { align-items: center; }and the content container toalign-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 withvisibility, notdisplay, so the fit does not feed back into its own geometry. - Letterboxed previews:
Image.scaleMode = ScaleToFitin a frame of another aspect. UseScaleAndCropfor scene previews; keepScaleToFitfor 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
.ussand its.tsswere reimported, the running panel still showed the old style untilpanelSettings.themeStyleSheetwas 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.