---
title: "USS/UXML: differences from CSS"
slug: /workflows/uss-uxml/
markdown: /workflows/uss-uxml.md
section: ui
tier: 2
summary: "The language delta between USS/UXML and the CSS a browser taught you."
sources: ['uss-uxml.md']
canonical: https://unityworkflowlibrary.dev/workflows/uss-uxml/
read_first: /read-first.md
---

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

# 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 `@import`s 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.
