---
title: "HTML/CSS mock to UI Toolkit"
slug: /workflows/html-css-to-ui-toolkit/
markdown: /workflows/html-css-to-ui-toolkit.md
section: ui
tier: 2
summary: "Port a designed web mock to UXML/USS faithfully and ship it."
sources: ['ui-toolkit.md']
canonical: https://unityworkflowlibrary.dev/workflows/html-css-to-ui-toolkit/
read_first: /read-first.md
---

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

# HTML/CSS mock to UI Toolkit

> Port a designed web mock to UXML/USS faithfully and ship it.

All shell work also follows TEXT-CHANGES-DO-NOT-RESIZE-THE-SHELL (the [UI design](/workflows/ui-design/) page).

Consolidated from the a tic-tac-toe game (a tic-tac-toe game) UI reconstruction, where a developer-supplied
HTML/CSS design was rebuilt 1:1 in UI Toolkit on Unity 6000.5. Editor-UITK footguns
(authoring tools, list focus, scroll offset) live in the unity page — this file is the
RUNTIME screen-building doctrine. Related glossary entries kept in unity.md:
UITK-VISUAL-TRAPS, UITK-DYNAMIC-ATLAS-BYPASS, UITK-IMAGE-NATIVE-OVERFLOW.

## USS-CAPABILITY-PROBE — measure, never assume, what USS supports (6000.5 measured set)

Unsupported, dropped as silent *warnings*: `box-shadow`, `linear-gradient`,
`radial-gradient`, `outline`, `filter`, `clip-path`, `gap`, `aspect-ratio`,
`backdrop-filter`. Supported: `text-shadow`, `transition`, `border-radius`, per-side borders,
flex, absolute positioning, `rotate`/`scale`/`translate`, `opacity`, `background-size`/
`background-position`/`background-repeat`. Consequence: anything shadowed, glowing or
gradient becomes a BAKED SPRITE (9-slice); text glow is native `text-shadow`. Probe again on
a new Unity version — write one test USS, read the import warnings.

## PLATE-CHILD — author controls at FACE size; chrome on an absolute, overflowing child

Sizing an element to its baked sprite (face + glow padding + lip) forces padding arithmetic
to re-centre every label, and each number is a latent bug (off-centre text, oversized touch
rows, phantom gaps). Instead: the element is exactly the design's face size (`250x54` because
the ref says 250x54); the sprite mounts on an absolutely-positioned child with negative
insets covering glow + lip (`.plate { position: absolute; left: -28px; ... bottom: -34px }`).
Children may overflow freely in UITK, so the glow renders while layout stays honest.

- An element's own text paints BEFORE children → a plate child covers `Button.text`. Buttons
  carry `<plate/><Label/>` children instead of text.
- Print each sprite's insets from the bake script; copy them into USS, never re-derive.
- State swap (off/selected) is a class on the BUTTON changing the plate child's image via a
  `>` selector — geometry identical between states or layout jumps.

## HOVER-RESET-SPECIFICITY — the default theme's hover/focus film needs a 3-selector reset

The runtime theme tints `.unity-button` backgrounds via `.unity-button:hover:enabled` /
`:focus:enabled` / `:active:enabled` — THREE simple selectors. A `.my-btn:hover` reset (two)
LOSES and the white film returns (it shipped twice before this was caught). Every custom
button class needs `background-color: rgba(0,0,0,0)` re-asserted at `:hover:enabled`,
`:active:enabled`, `:focus:enabled`, `:hover:active:enabled` (project sheet loads after the
theme, so ties go your way — but don't rely on ties, carry `:enabled`).

Third recurrence taught two more halves of the same rule (measured):

- **The theme's state rules set `border-color` too** (hovered buttons resolve to 0.502 gray), so
  any button with a real border-width grows a light ring on hover even with the background reset
  in place. Re-assert EVERY painted property the class authors — background AND border — in its
  state block.
- **Your own shared transparent reset outguns single-class paint rules.** `.ctl:hover:enabled`
  is (0,3,0); a background-carrying `.row-item` is (0,1,0) — on hover the shared reset WINS and
  the row's background vanishes. Any class that authors background/border needs its OWN
  four-state block, and its `--on`/selected modifier needs one as well or the plain state block
  overrides the selected look while hovered.
- **Audit measured, never by hand-hovering:** force the pseudo-state via reflection
  (`VisualElement.pseudoStates` internal property — OR in `PseudoStates.Hover`), wait a frame
  for the style pass, then diff `resolvedStyle` background/border/tint across every Button in
  the panel. The one-call version reads stale values — resolution is async.

## PANEL-MATCH-AXIS — pick the PanelSettings match axis from the design's fixed axis

`ScaleWithScreenSize` + match-width means every screen shorter than the reference aspect
CROPS the layout (a 430x900 ref on 16:9 loses ~136 logical px — real overlap, reported by
the developer). A portrait mobile design is a fixed VERTICAL stack → `match = 1` (height): the
stack always fits and width slack is absorbed by centring. Match width only when the design
is a fixed horizontal band. Verify at BOTH extremes (16:9 and 19.5:9+ for phones), not just
the aspect the design was drawn at.

A portrait design authored at a fixed WIDTH (a 450-wide card table) is the other case of the same
invariant — match the design's fixed axis — and takes `match 0`: PORTRAIT-ONLY-MATCHES-WIDTH in
the uitk orientation page. `0.5` is only for dual orientation.

## Baked 9-slice import contract (SLICE-VERIFY)

Sprite/Single, mesh **FullRect** (Tight tears corners; `spriteMeshType` only exists on
`TextureImporterSettings` — round-trip Read/SetTextureSettings), no mips, Bilinear,
Uncompressed, Clamp (Repeat only for tiles that actually repeat), border = (pad+radius)×bake
scale printed by the bake script, `-unity-slice-scale` = 1/bake-scale. **The bake scale must cover the LARGEST
reference-to-device scale factor** (a 430x900 ref on a 1440x3120 phone runs 3.5x — 2x bakes
upscale ~1.7x there and every edge feathers; the fix that answered "why is everything so non
sharp" was 3x bakes + `-unity-slice-scale: 0.3333`). Keep the scale in ONE number mirrored in
three places (bake scripts, USS slice-scale, importer border multiplier), and raise the dynamic
atlas `maxSubTextureSize` when bigger plates would exceed it or each becomes a draw call. The
editor Game View MINIFIES device res into its window and always looks softer than the device —
judge sharpness from native-res capture crops. Enforce imports with an
AssetPostprocessor scoped to the generated folder ONLY (the postprocessor's path is its
blast radius — see ASSETPOSTPROCESSOR-PATH-IS-BLAST-RADIUS). A stretched-looking button is
an IMPORT bug (border zero / not Single / Tight / missing slice-scale) before it is a USS bug.

## Marquee/pattern tiles must be TRANSPARENT except the motif

A tile with a faint full-tile wash renders as a visible band ("weird thing around the
dots") AND hides its own background-position crawl — uniform pixels moving look static.
Bake the motif on transparency with the falloff ending inside the tile edge; the crawl
becomes legible and tiling seams vanish.

## UITK-REPEAT-TILE-NEEDS-FULLRECT-AND-SIZE — a sparse dot tile needs Full Rect, Point and an explicit size

A 1–2 px motif on a 7–26 px tile imported as `spriteMeshType: Tight` loses its transparent
padding (a 26 px tile becomes a 4 px blob), and the default `-unity-background-scale-mode` then
stretches that blob across the element — which reads as "no grid", a flat navy screen. Import
`Full Rect` + `filterMode: Point`, and in USS pair `background-repeat: repeat` WITH
`background-size: 26px 26px` at the spec pitch. Keep such tiles uncompressed — ASTC erases a
1 px dot outright. (a card game /.)

## Idle motion must be READABLE, not merely present

Translate-only drift of ±10px over 4-6s reads as "nothing moves". The kit vocabulary that
reads: floats pair translateY with a ~7° rotation from one tween driver; marquees crawl one
tile per second-ish; the money button carries a shimmer sweep (baked 110° white streak in an
`overflow:hidden` face-radius clip, translate across face×2.4 per 2.6s — the overshoot is
the ref's dwell). Runtime code cannot resolve `project://database` URLs — code-built rows
take imagery from USS CLASSES, never `style.backgroundImage`.

## UXML-COMMENT-NO-DOUBLE-HYPHEN — a `--` inside a UXML comment kills the whole document

UXML is XML, and XML forbids `--` anywhere inside a comment. Every BEM modifier class name IS a
double hyphen (`tt-tray--dual`), so pasting one into a `<!-- -->` comment makes the document
MALFORMED: it imports EMPTY, every `Q<>` returns null, and the console you are filtering shows
nothing at the moment you look. Refer to modifier classes in prose inside comments ("the dual
layout class"), never verbatim. Symptom signature: a whole screen's queries null at once with a
non-null visualTreeAsset. Corollary (measured, same day): a controller whose tree failed to build
must NOT subscribe its handlers — UI handlers on static game events run synchronously inside
server-side state changes (SyncVar hooks, state machines), and one NRE from a dead screen aborts
the SERVER caller (an empty HUD froze the match-start routine). Null-check core elements after
the query block; log + return before the first `+=`.

## UITK-REIMPORT-REBUILDS-PANEL — never verify after an in-play asset refresh

Reimporting a USS during play mode rebuilds the dependent UIDocument's tree: authored defaults
return (hidden overlays reappear, runtime classes and inline styles vanish) while every cached C#
element reference points at the DETACHED old tree — handlers keep writing to elements nobody
renders. Captures taken after an in-play `AssetDatabase.Refresh` show neither the authored state
nor the live state. Restart play after any USS/UXML edit before judging pixels.

## Verifying in the editor: captures only flush while player frames flow

An unfocused editor stops rendering play mode entirely (`Time.frameCount` freezes) —
`ScreenCapture.CaptureScreenshot` then never writes, or writes a STALE frame; Repaint
pokes render exactly one frame and race the capture queue. Reliable: set
`Application.runInBackground = true` for the session (or focus the editor), then
route-state → capture → wait, and verify the FILE'S pixels (hash captures — identical
hashes = stale buffer). GameView size presets live per BUILD-TARGET GROUP — add/select in
`GameViewSizes.instance.currentGroupType`, not a hardcoded group, or you silently select a
different preset.

## UIDocument vs Panel Renderer (Unity 6000.5)

The UIDocument inspector suggests migrating to **Panel Renderer** ("UI Document will keep
working but no longer receives new features"). The banner is UNCONDITIONAL - every UIDocument
shows it forever; it is not a misconfiguration signal. Treat as a flagged migration, not a
blocker: UIDocument is stable for shipped screens; plan the Panel Renderer move as its own slice
and re-run the capability probe after it (rendering path changes can shift USS behaviour).

Measured API (6000.5.0f1, reflection): `PanelRenderer` derives from `Renderer` (materials,
sorting layers, shadows, world-space size/pivot), not MonoBehaviour, and exposes **no
`rootVisualElement`** - the only tree access is `RegisterUIReloadCallback((PanelRenderer,
VisualElement rootElement) => ...)` (plus a versioned overload), a push model that hands you the
root on every (re)build and expects full rebind. Controllers written against UIDocument's pull
model (cache rootVisualElement refs in OnEnable) must be restructured around rebind-on-reload -
which, once done, structurally fixes the detached-tree-after-reimport hazard.

## UITK-PREBUILD-REUSE - screens are BUILT ONCE and shown/hidden; rebuilding a panel on every open is a defect (HARD)

A UITK screen/panel/dialog that reconstructs its element tree each time it opens
(`Clear` + re-Add on open, a fresh `Build*` per show, a new card grid per refresh)
pays layout + text shaping + (unatlased) texture binds + GC on EVERY open, re-runs
callback wiring (the accumulation family: UITK-FILTER-KEEP-FOCUS, interactive-tint
re-registration), and resets scroll/focus state. On low-end devices the rebuild IS the
perceived hitch when a menu opens.

- **Build once, keep the reference, toggle `display: Flex/None`** (screens-as-siblings -
  the same pattern the UI pre-ship checklist mandates for panel exclusivity). Opening =
  show + REFRESH DATA into existing elements (set `label.text`, swap classes, retint);
  never reconstruct chrome.
- **Dynamic lists**: pool rows (the notice-stack/pooled-flier shape) or rebuild ONLY the
  row host - never the pane chrome around it (UITK-SO-REBUILD-CHROME is this rule's
  editor-side sibling).
- **The tell in review**: a `Show*`/`Open*`/`Toggle*` method that calls `new
  VisualElement`/`Make*`/`Add` chains, or a controller whose open path calls the same
  builder the boot path called. Builders belong in one-time init (bind/attach), open
  paths belong to state refresh.
- **Rebuild is legitimate ONLY on**: panel (re)attach/UI reload (UIDocument reload,
  PanelRenderer rebind - full rebind is the contract there), device-class/layout-mode
  change (compact flip), or a genuinely different document. Cache-drop under memory
  pressure re-bakes TEXTURES, not trees.
- **Three sub-tells from the first full burn-down** (a mobile strategy game, 7 fixes):
  (1) **a close path that NULLS the cached reference makes the reuse branch structurally
  dead** - the cache reads as implemented and never runs (`CloseModal` nulled the daily-login
  dialog; its `if cached -> Refresh` branch was unreachable for months). Grep every close/
  dismiss path for `_cachedX = null`. (2) **an open path calling
  `schedule.Execute(...).Every(N)` stacks a NEW repeating item per open** - keep ONE
  `IVisualElementScheduledItem` handle built with the cached tree and `Resume` it on show.
  (3) **kit controls holding state in closures (custom toggle/slider factories) cannot be
  value-refreshed by a cached panel** - give the factory an `out Action<T> setWithoutNotify`
  seam so reopen can re-sync bindables that drift externally (a music-widget mute, a name
  edited elsewhere); the companion split that keeps caching SAFE is: data-change triggers
  INVALIDATE the pane cache (fresh values), tab/nav switches RE-ATTACH it.
  (4) **a cached pane that holds percent-width wrap children must itself be the wrap host
  with a defined width.** Nesting `new VisualElement` under a `flexDirection:Row; flexWrap`
  parent and moving the 48%-wide rows onto the extra node collapses the percents (parent
  width is now content-sized) and the original wrap only wraps one child — rows pile into
  one overlapping line. Put wrap + `width: 100%` on the cached pane; the slot around it is
  a column.

---

## The default runtime theme's control chrome vs design transcription

- **`unity-theme://default` pads and margins every Label and Button.** An HTML/CSS reference has
  neither, so every mockup-derived vertical metric lands inflated — measured ~40% on stacked
  cap-over-value text (a chip drawn at 55pt in the design resolved 96pt), enough to push a screen's
  action row off a 16:9 panel. Zero it ONCE in the project theme (imported after the default theme so
  the equal-specificity tie breaks your way): `.unity-label` margins+padding to 0, `.unity-button`
  margins to 0 — then spacing is always declared, never inherited chrome. The tell that it was never
  done: sheets full of "restate the margins" boilerplate fighting it one element at a time.
  (a card game.)
- Labels inside a Button INHERIT its `-unity-text-align: middle-center` from the default theme —
  a left-aligned text column inside a card-style button needs the alignment restated on the labels.

## PORT-FROM-THE-RENDERED-REF, never from the markup (HARD)

**Serve the reference, render it, and read `getComputedStyle` off the live DOM.** Porting from the
HTML/CSS source as *text* produces a screen that compiles, renders, and looks nothing like the design
— and the gap is invisible until someone opens both side by side.

What reading the source cannot give you, measured on one port that had to be redone:

- **The fonts.** Two `font-family` stacks in a stylesheet are two strings. Rendered, they are the
  entire character of the design; the port had neither font imported and every glyph fell back to
  Unity's default sans. This alone accounted for most of the mismatch.
- **Computed values.** Authored CSS is inherited, cascaded and unit-converted before it paints. The
  rendered tree gives exact px rects, resolved colours, and the real type scale (that design used
  exactly two body sizes — 20px and 21px — carrying hierarchy through weight, alpha and
  letter-spacing; a porter inventing a 12/14/16/18 ramp cannot match it).
- **Which effects actually exist.** Gradients, vignettes and pinstripes are easy to skim past in
  markup and dominate the rendered look.
- **Runtime-generated content.** A design that is really a prototype computes half its visual system
  in script; the card faces, seats and buttons were `{{binding}}` placeholders whose styles came from
  a `renderVals` function, so the markup alone documented ~40% of the visuals as a placeholder.

Procedure: serve the ref (`py -m http.server` in its folder — `file://` may render as a static
snapshot with scripts inert, which looks like a working page with unbound `{{...}}` text), size the
viewport to the artboard, then walk the DOM emitting `{rect, computedStyle}` per node to a JSON the
porters read. **Play a prototype ref** — states only reachable by interacting (dealt hands, overlays,
win screens) are part of the design too, and random content (bot names drawn from a pool) only
reveals itself at runtime.

## UITK-NEEDS-TEXTCORE-FONTASSET, not TMP_FontAsset (HARD)

`-unity-font-definition` requires **`UnityEngine.TextCore.Text.FontAsset`**.
`TMPro.TMP_FontAsset` is a different type for uGUI/TMP, and pointing USS at one **fails silently**:
the `url` resolves, the asset loads, no warning is emitted, and `resolvedStyle.unityFontDefinition`
comes back NULL on every element. The render is byte-identical to having authored no font at all.

```csharp
// WRONG for UI Toolkit — silently no-ops
TMPro.TMP_FontAsset.CreateFontAsset(font, ...);
// RIGHT
UnityEngine.TextCore.Text.FontAsset.CreateFontAsset(font, 90, 9, GlyphRenderMode.SDFAA, 1024, 1024);
```

Assert it, don't eyeball it: walk the panel and check
`element.resolvedStyle.unityFontDefinition.fontAsset != null` on every element carrying a font class.
Eighteen elements resolving NULL is indistinguishable from a font that looks slightly off.

Two companions:

- **A fresh project has no TMP Settings**, so `CreateFontAsset` throws
  `NullReferenceException` at `TMP_Settings.get_clearDynamicDataOnBuild`. The
  *Window → TextMeshPro → Import TMP Essential Resources* menu item does not complete under
  automation (`ExecuteMenuItem` returns true and imports nothing). Copy `Assets/TextMesh Pro` from a
  project on the **same editor version** instead.
- **Add the generated atlas texture and material into the asset** (`AddObjectToAsset`) or the font
  renders blank once the project is reopened.

## UITK-FONT-NEEDS-FALLBACK-FACE — a mock glyph absent from the brand font draws NOTHING, with no log

`✕ ★ ♥ ✱` render in the HTML mock only because the browser silently substitutes an OS fallback;
a dynamic SDF `FontAsset` has no fallback table, so the glyph is a blank box or nothing at all,
and a designer reads it as "the close button is missing". Parse each shipped face's cmap for
every decorative codepoint the design uses, and set a symbols face (`NotoSansSymbols2`, SIL OFL)
as the FALLBACK on every FontAsset — substituting lookalikes drifts from the design and does not
scale to the next glyph. (a card game: absent from all five shipped faces.)

## USS-UNITS-LIE — three USS properties do not mean what the CSS name implies (6000.5, measured)

Ported CSS numbers cannot be copied into these three properties verbatim. Each fails quietly
in a different way, and none of them looks wrong until you diff against the rendered ref.

- **`-unity-slice-scale` demands a unit.** A bare `0.25` is rejected with a *warning*
  (`Expected (<length>) but found '0.25'`), the declaration is dropped, and the slice scale
  stays 1 — so a 4x bake renders its borders 4x too thick and the plate looks bloated rather
  than broken. Write `0.25px`. A unitless scale factor with a `px` suffix reads wrong and is
  correct.
- **`letter-spacing` is not pixels.** Measured, it adds roughly `value * fontSize / 100` px per
  gap, so CSS `letter-spacing: 4px` at 20px type needs about `22.6px` in USS — copying `4px`
  gives a fifth of the tracking. Do not trust the formula either: sweep
  `MeasureTextSize` at two values, solve the slope, and pin the number that reproduces the
  ref's measured rect.
- **A text element's box is taller than a CSS `line-height: 1` box.** Anton at 88px resolved a
  129px tall element against the ref's 88 — and in a flex column the surplus *accumulates*,
  which is why a port can match every colour and font yet sit 20-40px low by the bottom of the
  screen. Pin `height` to the ref's measured box on every text element in a ported column;
  the drift disappears in one pass.

## UITK-LABEL-MEASURE-DROPS-TRACKING — a Label's auto size under-counts `letter-spacing`

A `Label` sizes itself from a glyph run measured WITHOUT most of its tracking: `MeasureTextSize`
returned 70.5 pt for "YOUR TURN" while the run with 1.2 px tracking over 9 characters needs
~81 pt. The element renders at that "natural" size and then clips its own text — `YOUR TU…` with
visible empty space under `text-overflow: ellipsis`, or a second line sliced through the middle
under wrap. Never let text measurement size tracked chrome: give tracked labels an explicit
`min-width` / `min-height`, use `min-height` (not `height`) on their containers, and let a banner
take its width from layout. USS-UNITS-LIE covers the unit; this is the measurement. (a card game.)

## TOKEN-SHEET-BLANKET-SELECTOR-OUTRANKS-EVERY-SCREEN — two classes beat one

A shared token sheet that ends its reset with `.sh-ui .unity-text-element { color: var(--fg) }` and
`.sh-ui TextElement { color: var(--fg) }` paints EVERY piece of text in EVERY screen the one
foreground colour. Both selectors carry two simple selectors; a screen's own `.MyTagline`,
`.MyButton`, `.MyTelemetry` carries one, so the screen loses. The port then reads as "flat" or
"washed out" with nothing visibly wrong in the screen's own USS — every rule is there, correct, and
overridden. Seven screens had lost their entire tonal hierarchy this way and no console entry said so.

The root class already sets `color`, and colour INHERITS: an inherited value loses to any direct
rule, which is exactly the wanted precedence. So delete the blanket rules and keep only the ones
that must beat a control's own chrome (`.unity-base-text-field__input TextElement` and friends).
Same trap for `Root Button { color: ... }` (one class + one type = two) against `.MyButton`.

Never verify a palette by eye — read it back: `element.resolvedStyle.color` per element, and a
contrast sweep (foreground vs nearest opaque ancestor background) over every non-empty TextElement
in the panel. Companion to VAR-TYPO-IS-SILENT: that one paints nothing, this one paints everything
the same.

## UITK-DEFAULT-THEME-MARGINS-MOVE-A-PORT — Label 4/4/2/2, Button 2px, measured not guessed

The runtime default theme gives every `Label` `margin: 4px 4px 2px 2px` and `padding: 4px 2px 4px 1px`,
and every `Button` `margin: 2px`. A ported column that sets only `margin-bottom` inherits the rest:
the title landed 4 px low, the rule under it 4 px, the tagline 8 px, a bottom-anchored telemetry line
2 px, and a button row measured 64 px against the ref's 60. Every offset compounds down the column,
so the drift looks like a bad conversion rather than an inherited default.

Zero them explicitly on every element you position — `margin: 0 0 20px 0`, not `margin-bottom: 20px` —
and write `padding` as a shorthand too, because `padding-left: 36px` leaves the theme's 4/2/4 on the
other three sides. Read `resolvedStyle.marginTop/Left/...` before theorising about which side is
wrong; the four numbers name the culprit in one call. Pairs with PIN-HEIGHT-TO-REF.

## EDIT-MODE-UIDOCUMENT-MEASURES-AFTER-A-GAME-VIEW-REPAINT — port verification without Play

A `UIDocument` builds its panel in EDIT mode, so a ported screen can be measured against the ref
without ever entering Play. But geometry is NaN until something drives a layout pass, and
`RepaintAllViews` alone did not: `root.resolvedStyle.width` stayed NaN through three calls.
What drives it is the Game view — `EditorWindow.GetWindow(Type.GetType("UnityEditor.GameView,UnityEditor"))`
then `Repaint`. The layout lands on the editor's next tick, so READ IN A SEPARATE CALL; reading in
the same one returns the NaNs again.

The loop that works: open the screen's scene additive, seed the text the presenter would set (the
presenter does not run in edit mode, and an empty Label measures nothing), strip the intro/hidden
classes, repaint — then in a second call read `worldBound` per element and compare to the ref rects.
Panel size comes out at the PanelSettings reference resolution, so the numbers are directly
comparable to a ref measured at that size. Close the scene with `CloseScene(scene, true)` and check
`scene.isDirty` first, so a measurement pass never lands in the scene file.

## VAR-TYPO-IS-SILENT — an undefined USS custom property renders nothing, with no warning

`var(--fource-font-ui-medium)` against a stylesheet that defines `--fource-font-ui-med`
resolves to nothing at all. No console entry, no fallback, no visual placeholder: the six
elements referencing it simply had no font and drew no glyphs. Because it is a *reference*
error rather than a parse error, the sheet imports clean.

Grep every `var(--x)` against the `--x:` definitions as a build step, or assert on the
resolved value (`resolvedStyle.unityFontDefinition.fontAsset != null` and friends) rather than
looking at the screen. Companion to UITK-NEEDS-TEXTCORE-FONTASSET: both produce a NULL font
that is indistinguishable from "slightly off" until asserted.

## CODE-BUILT-VIEWS-GET-NO-AUTHORING-CLASSES — declare the font on the semantic class

A `font-display` utility class stamped in UXML never reaches elements a factory builds at
runtime — the factory adds its own semantic classes (`fc-card__index`) and nothing else. All
sixteen dealt cards rendered with a NULL font while the authored hero cards beside them were
fine, because only the authored ones carried the utility class.

Any class a code path attaches must carry every property that element needs to *paint* --
font definition included. This is the same failure family as "runtime code cannot resolve
`project://database` URLs, so code-built rows take imagery from USS CLASSES": utilities are for
authored trees, semantic classes are the contract with code.

## BAKE-PAD-IS-OFFSET-PLUS-3-SIGMA, and an inset shadow is the OUTSIDE blurred inward

Two arithmetic errors in a bake script that both read as "the design is wrong" rather than
"the sprite is wrong":

- **Pad.** A CSS blur radius `r` is a gaussian sigma of `r/2`, and a gaussian needs ~3 sigma to
  reach zero. Pad the bake by `offset + 3 * (r / 2)`; pad by the blur radius alone and the glow
  is CUT OFF at the image edge, which renders as a hard straight seam across the screen where
  the sprite ends. Symptom signature: a full-width horizontal line above a glowing button.
- **Inset shadows.** `box-shadow: inset` is the region OUTSIDE the shape, blurred inward and
  clipped to the shape — so it decays over the blur radius and hugs the rim. Blurring the shape
  and subtracting it from itself decays over roughly twice that and washes the whole element
  (a 48x68 card came out solid red instead of dark with a red rim). And do NOT normalise the
  result to 255: the blur straddles the boundary, so the rim peaks near half the shadow's alpha
  exactly as CSS paints it, and normalising doubles the ref.

## VARIABLE-FONT-DEFAULTS-TO-ITS-CUT, and -unity-font-style: bold is not a weight

A single `.ttf` for a family is whatever instance the file was cut at — one shipped as
`Space Grotesk Light` (wght axis 300-700, default 300) while the design called for 400/500/700.
`-unity-font-style: bold` dilates strokes in the SDF material; it does not carry the Bold cut's
advances or shapes, so the type stays visibly wrong and the row widths stay wrong with it.

Check the file (`ImageFont.truetype(p, n).getname`, and `get_variation_axes` for the axis
range), then instance the axes you actually need
(`fontTools.varLib.instancer.instantiateVariableFont(f, {"wght": 700}, updateFontNames=True)`)
and build one TextCore FontAsset per weight. Validation that costs nothing: the instanced
face's advance sum plus the ref's tracking should land within ~1px of the ref's measured text
rect — if it does, the weight is right.

## PROTOTYPE-REF-NEEDS-TRUSTED-INPUT — you cannot script a prototype to its late states

PORT-FROM-THE-RENDERED-REF says to PLAY a prototype ref, because dealt hands, overlays and win
screens only exist at runtime. The practical wall: a component-framework prototype responds only
to **trusted** events. Measured on one port, `element.click` did nothing, and neither did a
full synthesized sequence (`pointerdown` / `mousedown` / `pointerup` / `mouseup` / `click`, all
bubbling and composed) — while the same element driven by the browser tool's real click worked
every time.

Consequences for planning the port:

- **Budget real clicks for the states you must see.** Reaching an end-of-hand overlay can be
  dozens of interactions; that is a hand-driven session, not a loop you can leave running.
- **Measure the CHROME once and reuse it.** Overlays in one design almost always share a scrim,
  card, title, body and CTA. Two measured overlays gave the tokens for five, which is the honest
  way to build the unreachable ones — and it is worth saying in the port notes which screens were
  measured and which inherited.
- **Say so in the spec.** A ported screen that was never rendered side by side is a different
  confidence class from one that was; record which is which where the next porter will read it.

## UITK-SCROLLER-VISIBILITY-IS-NOT-USS — a ported panel grows a desktop scrollbar

A `ScrollView` whose content overflows paints the default theme's full scroller — a wide track
with arrow buttons at both ends. A mobile design reference has nothing there (browsers draw
overlay scrollbars, or none), so the ported panel looks instantly wrong and no amount of USS
fixes it: **visibility is a C# property, not a style**.

```csharp
scroll.verticalScrollerVisibility = ScrollerVisibility.Hidden;   // no USS equivalent
scroll.horizontalScrollerVisibility = ScrollerVisibility.Hidden;
scroll.mode = ScrollViewMode.Vertical;                            // drag-scroll still works
```

Set it wherever the tree is built, alongside the queries. Hiding the scroller keeps drag and
wheel scrolling, so nothing is lost on either touch or desktop.

### UITK-THIN-SCROLLER-INCLUDES-INNER-SLIDER

A narrow outer `Scroller` does not constrain its inner `Slider` or clear the theme's tracker.
For a visible thin rail, set the inner slider's width/min/max, remove reserved arrow margins,
clear or deliberately style the tracker, and bound the thumb's width and theme offsets.
Measure the complete overflowed control, not just the outer rail. Compare geometry in the same
coordinate space; derive one screen pixel of rounding tolerance with `RuntimePanelUtils.ScreenToPanel`
instead of assuming half a logical unit. Native measurement found an 8px rail containing a 24px
opaque tracker; an authored 6px thumb rounded to 6.77 panel units. Verify wheel/drag endpoints
and text clearance on an overflowing list.

## UITK-KEYFRAMES-NEED-CSS-SEMANTICS (HARD)

USS has transitions and no `@keyframes`; a ported CSS animation is replayed in C# through
`element.experimental.animation`. Four things silently make the replay look nothing like the ref:

- **`ValueAnimation` eases OutQuad by default.** Start every effect with `.Ease(t => t)` and apply
  the CSS curve yourself. Implement `cubic-bezier(x1,y1,x2,y2)` properly (Newton plus a bisection
  fallback; `y` may exceed 1 for overshoot curves like `(.2,.9,.25,1.2)`).
- **CSS eases each keyframe SEGMENT, not the whole track.** A 0/55/100 % slam or a five-stop shake
  evaluated with one global curve has the wrong shape. Keep stops and values as a track and ease
  the local `u` inside the current segment.
- **Inline styles beat class styles.** An effect that writes `style.translate` leaves the card's
  USS `.--sel { translate }` lift dead until the inline value is cleared; end every effect with
  `StyleKeyword.Null` on translate, scale and opacity.
- **A USS `transition` on an inline-animated property re-tweens every frame's write.** Zero the
  element's `transition-duration` for the effect and restore it after; delete transitions that only
  existed to soften a class toggle you replaced.

Also: fill-both means the from-state is painted the frame the effect is requested, before any
delay, or a staggered deal shows all sixteen cards for one frame. Verify by sampling
`resolvedStyle` at fixed offsets from an `EditorApplication.update` probe, not by a screenshot,
which catches one frame and can come back black mid-effect. Corpus: FOURCE `UiMotion.cs`.

## DYNAMIC-FONT-FIRST-GLYPH-SPIKES-THE-FRAME

- **A dynamic TextCore `FontAsset` re-reads the font's whole GPOS (kerning) table each time it adds a glyph.** The first
  time new text appears mid-game (a level-up toast, a new number), that frame pays for it. Measured: 9.4-9.8 ms of
  `FontAsset.UpdateGlyphAdjustmentRecords` per level-up across 4 runtime-created fonts; the frame went 19-21 ms, then 9-10 ms after warming.
- Fix: at boot (loading screen), create the asset once, call `TryAddCharacters(<authored UI character set>, true)` with
  kerning, and pass that warmed asset to `FontDefinition.FromSDFFont`. Keep it dynamic, so characters outside the set still
  add instead of drawing nothing. Trim characters the face lacks from the set; each one logs a warning at every boot.

## ICON-CROPS-TO-THE-SUBJECT

- A studio-lit icon render with a lit floor puts the subject in 25-65 % of the frame. Downscaled whole to a card slot
  of about 42 px, it read as a dark blob.
- Key the background out, crop to the subject's bounds, and compose the element or category colour behind it at
  runtime. Never shrink the whole render. Judge the result at the real card size, not in the source image.

## ONE-VISUAL-CHANNEL-PER-MEANING

- Rarity and element shared the colour channel on upgrade cards: a white Common read like a white Physical, and a green
  Uncommon like a green poison-type element.
- Give each meaning its own channel. For example, the frame carries rarity, the field carries element, and the badge
  shape carries category. Text ink never carries rarity.

## CARD-SHOWS-THE-COMPARABLE-NUMBER

- An action label on every card ("ATTACK +1") gives the player nothing to compare between offers.
- Put the one comparable number on the card (DPS, targets, +%, +HP, gold per second). Compute it with the same
  resolver the detail view and live stats use, so the card never disagrees with them.

## RESAMPLE-ALPHA-PREMULTIPLIED

- Box-resampling (downscaling) a cut-out image with straight alpha averages the RGB of fully transparent pixels into
  the edge pixels. The result is a dark or coloured fringe around every cut-out icon (see ICON-CROPS-TO-THE-SUBJECT).
- Premultiply RGB by alpha before the resample, then divide the resampled RGB by the resampled alpha (unpremultiply,
  skipping alpha 0). Measured: 199 cut-out icons baked with no edge residue this way.
- Apply the same rule to any filter over a cut-out: blur, mip generation in custom code, and bilinear scaling in a bake
  tool.

## UITK-COMPOSITE-PREP-PRESERVES-CHILDREN

- A composite `Button` preparation helper clears the control's own `text` and preserves caller-owned child labels.
  Do not hide every `TextElement` child: `Label` derives from it, so calling the helper after composition blanks
  live controls. Verify preparation both before and after children, and again on a repeated call, in an attached
  native panel.
- PLATE-CHILD in this file already puts the visible name on a child `Label`, because a plate covers `Button.text`.
  Clearing `text` is not a reason to hide that label.
