Topic 7 · UI
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 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-colortoo (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:enabledis (0,3,0); a background-carrying.row-itemis (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.pseudoStatesinternal property — OR inPseudoStates.Hover), wait a frame for the style pass, then diffresolvedStylebackground/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 (setlabel.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 callsnew VisualElement/Make*/Addchains, 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 (
CloseModalnulled the daily-login dialog; itsif cached -> Refreshbranch was unreachable for months). Grep every close/ dismiss path for_cachedX = null. (2) an open path callingschedule.Execute(...).Every(N)stacks a NEW repeating item per open - keep ONEIVisualElementScheduledItemhandle built with the cached tree andResumeit on show. (3) kit controls holding state in closures (custom toggle/slider factories) cannot be value-refreshed by a cached panel - give the factory anout Action<T> setWithoutNotifyseam 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. Nestingnew VisualElementunder aflexDirection:Row; flexWrapparent 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://defaultpads 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-labelmargins+padding to 0,.unity-buttonmargins 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-centerfrom 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-familystacks 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 arenderValsfunction, 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.
// 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
CreateFontAssetthrowsNullReferenceExceptionatTMP_Settings.get_clearDynamicDataOnBuild. The Window → TextMeshPro → Import TMP Essential Resources menu item does not complete under automation (ExecuteMenuItemreturns true and imports nothing). CopyAssets/TextMesh Profrom 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-scaledemands a unit. A bare0.25is 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. Write0.25px. A unitless scale factor with apxsuffix reads wrong and is correct.letter-spacingis not pixels. Measured, it adds roughlyvalue * fontSize / 100px per gap, so CSSletter-spacing: 4pxat 20px type needs about22.6pxin USS — copying4pxgives a fifth of the tracking. Do not trust the formula either: sweepMeasureTextSizeat 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: 1box. 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. Pinheightto 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
ris a gaussian sigma ofr/2, and a gaussian needs ~3 sigma to reach zero. Pad the bake byoffset + 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: insetis 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.
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:
ValueAnimationeases OutQuad by default. Start every effect with.Ease(t => t)and apply the CSS curve yourself. Implementcubic-bezier(x1,y1,x2,y2)properly (Newton plus a bisection fallback;ymay 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
uinside the current segment. - Inline styles beat class styles. An effect that writes
style.translateleaves the card's USS.--sel { translate }lift dead until the inline value is cleared; end every effect withStyleKeyword.Nullon translate, scale and opacity. - A USS
transitionon an inline-animated property re-tweens every frame's write. Zero the element'stransition-durationfor 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
FontAssetre-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 ofFontAsset.UpdateGlyphAdjustmentRecordsper 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 toFontDefinition.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
Buttonpreparation helper clears the control's owntextand preserves caller-owned child labels. Do not hide everyTextElementchild:Labelderives 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 coversButton.text. Clearingtextis not a reason to hide that label.