---
title: "CSS mock to uGUI"
slug: /workflows/css-to-ugui/
markdown: /workflows/css-to-ugui.md
section: ui
tier: 2
summary: "Rebuild a CSS layout in Canvas/uGUI with correct anchors, layout groups and sorting."
sources: ['css-to-ugui.md', 'ugui.md']
canonical: https://unityworkflowlibrary.dev/workflows/css-to-ugui/
read_first: /read-first.md
---

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

# CSS mock to uGUI

> Rebuild a CSS layout in Canvas/uGUI with correct anchors, layout groups and sorting.

## CSS mock → Unity uGUI (old UI)

Procedure for remaking an HTML/CSS screen in **uGUI** (`RectTransform`,
`Image`, `VerticalLayoutGroup` / `HorizontalLayoutGroup`, engine `Shadow`).
Not UITK. Not a per-panel C# builder.

Proven on a mobile card game daily-login 6a. Load this when the ask is
“remake this CSS/HTML mock in Unity UI.”

---

## 1. Decide the scale — once

| Mock kind | Scale |
|---|---|
| The mock **is** the phone / viewport frame | `canvasW / mockW` |
| The mock is a component **already** in canvas units | `1` |

Never invent a third scale. Never mix 1:1 component scale with viewport
scale on the same overlay.

```
canvas = cssPx * (canvasW / mockW)
```

A tiny static helper that **prints** numbers is fine. A `MenuItem` / baker /
`ApplyThisPanel` that only one overlay can run is not.

## 2. Copy the CSS tree — do not generate a panel

Same parent/child order as the mock. Flex column → `VerticalLayoutGroup`.
Flex row → `HorizontalLayoutGroup`.

- **Column VLG stays in control of width:** `childControlWidth = true`,
  `childForceExpandWidth = true`. Title, content row, button row stretch.
- **Shrink-wrap chips** (`padding` around text, “DAY 3 STREAK”) live in
  their **own full-width row**. That row’s HLG is MiddleCenter and
  expand-off. The chip uses `ContentSizeFitter` (Preferred Size) or a fixed
  `sizeDelta` from the calculator.
- Do **not** turn the column’s `childControlWidth` off to unstretch one
  child. Sibling rows go to width 0; with a centre pivot the layout group
  parks them on a vertical line at the column centre and they overflow to
  the right.
- Do **not** put the chip itself on stretch anchors `(0,1)-(1,1)`.
  `sizeDelta.x` on a stretch rect is an **inset**, so `420` becomes
  parent+420 (full-card bar, oval coins).

Block rows stretch. Chips, coins, and fixed tiles do not.

## 3. Chrome

| CSS | uGUI |
|---|---|
| Solid / gradient + radius | Existing 9-slice, or bake **once** into a generic sprite. `spriteMeshType` FullRect (4 verts). Capsule: L/R = cap, **T/B = 0**. |
| `box-shadow: 0 4px 0` (hard offset) | `UnityEngine.UI.Shadow`. CSS +Y is down; Unity Shadow +Y is up. `effectDistance = (cssX, -cssY) * scale`. |
| `box-shadow: 0 18px 50px` (wide blur) | Bake. Engine Shadow has **no blur**. |
| `inset 0 -3px 0` (pressed lip) | Dark band on the **bottom** of the sprite, or a child Image. Engine Shadow is outset only. |
| `@keyframes` / CSS motion | Later pass: the project’s clip player. Not CSS-in-C#. |

HTML mock folders are often **no PNG kit** — say so. Bake a generic sprite
once if needed, then **delete the baker**. Do not leave a per-panel
MenuItem.

If you flip a PNG on save (Unity vs CSS Y), **flip the insets too** or the
pressed recess lands on top.

## 4. Overlay canvas

Nested overlay under a Screen Space Overlay root:

- `AddComponent<Canvas>` + `GraphicRaycaster`
- Set **`overrideSorting` + `sortingOrder` only**
- **Never write `renderMode`** on the root (or flip a child Overlay ↔
  World Space). That can rewrite the parent to World Space. Game view
  becomes camera-clear + a slab; Scene view still looks fine. Full rule:
  CANVAS-RENDERMODE-PARENT-REWRITE in the [CSS mock to uGUI](/workflows/css-to-ugui/) page.

After any canvas MCP write, re-read the **root** `m_RenderMode`. Overlay = `0`.

## 5. Show / Hide

A Hide that `SizeDelta → (0,0)` the frame does **not** hide children with
their own `sizeDelta`. Put a `CanvasGroup` on **that same** transform and
FadeAlpha it (Show → 1, Hide → 0). Then deactivate the overlay root on
clip complete if that is the existing pattern.

`GetComponent<T> ?? AddComponent<T>` never adds (Unity fake-null).
`var c = GetComponent<T>; if (c == null) c = AddComponent<T>;`

Do not guess a serialized tween enum from a YAML int. Read the enum
declaration.

## 6. Motion is a later pass

Freeze the tree and the calculator sizes first. Animation adds clips on
that tree. Do not rebuild the overlay, retarget Show/Hide end-values, or
change VLG/HLG flags to “make room” for motion.

## 7. Don't

- A per-panel sprite baker / `Apply This Overlay` MenuItem.
- A third scale.
- T/B 9-slice on a short capsule (dirt in the middle).
- Bake an inset, flip the PNG, forget to flip Y.
- Disable a column VLG’s width control to shrink one chip.
- Stretch-anchor a chip or a circular coin.
- Write `renderMode` on the overlay root.
- Rebuild a frozen layout to start the animation pass.
- Skip the end-of-task overlap pass. Dump world corners of every new
  Graphic; leftover stretch texts and 8px LayoutElements will overlap.
- Draw a check/star/icon with TMP if the font atlas does not have that
  glyph. A `*` is not a star. Use an Image sprite.

## 8. Things a mock will lie to you about

### A 9-slice bakes ring:radius as a FIXED RATIO

`pixelsPerUnitMultiplier` scales the whole border region — the corner radius AND
any ring, lip or bevel baked beside it. So an existing card sprite can be
retargeted to a different SIZE but never to a different ring-to-radius ratio.
Land the radius and the ring shrinks to nothing; land the ring and the corners
go round. Check the ratio before reaching for a multiplier; if the mock's
differs, bake a new sprite rather than tuning ppuM until it "looks close".

Bake the new one so **ppuM 1 == one sprite pixel per canvas unit** (author every
band at its canvas-unit size, and set the 9-slice border to cover radius + ring).
Then the multiplier stays out of the maths entirely.

### Dim alphas do not survive sRGB -> Linear

CSS composites in sRGB. A Linear-space project (URP, and most modern setups)
blends on linearised values, so **copying the mock's alpha does not copy the
mock's appearance** — it lands far lighter. Measured on a mobile card game: a `rgba(12,24,44,.62)`
scrim authored at 0.62 left 66-72% of the original screen brightness, where the
CSS look is ~47%; 0.85 was needed to land 48%.

Set a scrim by capturing before/after **in the same session** and comparing pixel
luminance. Never by copying the number, and never by eye — the difference between
66% and 48% is not reliably visible in a screenshot, but it is obvious on device.

### The font may not have the mock's punctuation

Designers reach for `·` `×` `–` `—` freely. A subsetted game font usually has
none of them, and TMP renders a missing glyph as tofu or silently falls back to a
different typeface mid-line. This is the same failure as `*` for a star, but it
hides better because the surrounding text is fine.

Probe the atlas before transcribing, not after:

```csharp
fontAsset.characterLookupTable.ContainsKey(0x00B7)  // middot
```

Substitute ASCII (`-`, `x`) or use an Image. Check every font the panel uses —
the body and title materials are often different assets.

### A sprite's RECT is not its visible extent

Reusing an existing sprite is right (`REUSE-BEFORE-BUILDING`), but a sprite
authored with transparent padding — decorative discs, anything with a baked drop
shadow — draws far smaller than the rect you size it to. a mobile card game's
`Profile_CircleGold` is a gold disc occupying **62.5%** of its sprite width with
an asymmetric opaque bbox (the shadow). Sized as a ring behind a 56px avatar it
landed *inside* the avatar and vanished: active, correct sprite, correct rect,
alpha 1, invisible.

Measure before sizing:

```python
Image.open(path).convert("RGBA").getchannel("A").getbbox
```

If the padding is asymmetric the sprite has a baked shadow and is not a symmetric
primitive at all — do not centre anything on it.

### A 9-slice whose corner radius equals HALF its rect IS a circle

Before baking a circle, check whether a rounded-rect 9-slice already in the kit
can be one: set `ppuM = (2 * spriteBorder) / diameter` so the rendered radius is
`diameter / 2`. The stretchable middle collapses to zero width and only the four
corner quarter-circles draw. Stack two at different diameters and you have a
ring, in the exact colours the mock asks for, with no new asset and no tint
multiplying a baked hue.

## 9. Scroll viewports, column headers, and tint (uGUI)

### A ScrollRect viewport clips with `RectMask2D`, never a stencil `Mask`

A stencil `Mask` on the viewport eats the rows: the symptom is a correct, populated, INVISIBLE
content tree. Two a mobile card game panels shipped empty scroll lists from this.

(UITK is unaffected — it clips with `overflow: hidden`; its analogous trap is
UITK-IMAGE-NATIVE-OVERFLOW.)

### `ppuM = spriteBorder / wantedRadiusInCanvasUnits`

`type = Sliced` alone draws the border at 1:1 texture pixels on every rect, so one sprite gives the
same radius everywhere — a 36-unit radius on a 65-unit chip. Set
`pixelsPerUnitMultiplier = spriteBorder / wantedRadiusInCanvasUnits` per rect.

- **A rect shorter than 2× the border loses the shape.** The two borders do not fit, Unity compresses
  them, and the authored corner is gone. Clamp the wanted radius to `height / 2` and recompute.
- A button quoting another button at a smaller size must scale the border by the height ratio, or its
  baked lip and end caps come out heavier than the button it quotes.
- The ratio law transfers to UITK; the property does not (USS uses `-unity-slice-*` +
  `-unity-slice-scale`).

### One white luminance-ramp sprite + tint replaces N authored gradients

When a mock authors the same widget in N gradients, bake ONE white sprite as a luminance ramp (e.g.
1.0 → 0.80, with any lip at its own stop) and tint it — **the tint value is the gradient's TOP
colour**. Nine authored gradients become one asset and one authored colour per row.

Anything that must keep its own colour (a white rim, an already-alpha'd glow) is a SEPARATE untinted
Image: a rim baked into the ramp gets multiplied by the tint, and that is the failure that makes the
ninth one look wrong. (Applies to UITK too — `-unity-background-image-tint-color`.)

### A column-header row is a SECOND layout group — mirror it, one flexible sink

Any width change to a body row must be mirrored in the header, and each screen needs **exactly one
flexible column, in the same slot on both**. Without a sink the row is exactly full, the slack becomes
dead space on the right, and every column slides off its header a few units at a time. Verify with
world corners (`dx = 0` per fixed column), never by eye. (Law transfers to UITK flexbox; `flex-grow`
replaces `LayoutElement.flexibleWidth`.)


## 10. "Make X match Y" is a DIFF task, not a styling task

`UI-MATCH-BY-DIFF`. When asked to make one element identical to another, **enumerate and diff
both, component by component and property by property, BEFORE changing anything.** Cherry-picking
the fields you think matter fails every time, because the differences that read as "lumpy" are
never the ones you would guess.

Do it mechanically, in this order:

1. **Composition first — the component LISTS differ.** Walk both subtrees (root + every child) and
   list components with their `enabled` state. One will carry an effect the other lacks. Real
   example: the copy had a `Shadow` the target did not, and the target carried a **disabled**
   `Outline` the copy lacked. Neither is visible in a casual inspector glance, and a disabled
   component still counts — copy it *with* its enabled flag and its values.
2. **Then every serialized property**, via `SerializedObject` + `GetIterator/NextVisible(true)`,
   not by reading the inspector. Diff as strings and print only the mismatches.
3. **Fix, then re-run the same diff** until the only remaining differences are the ones that MUST
   differ — position/slot, label text, `onClick` target, `targetGraphic`. Anything else surviving
   is a bug you have not found yet.
4. **Confirm with a screenshot.** Matching numbers is not matching pixels.

**The silent one is `Image.pixelsPerUnitMultiplier`.** It scales a sliced sprite's border, so two
Images sharing a sprite, a rect and a colour still render **different corner radii**. Diff `type`,
`fillCenter` and `ppuM` together — same sprite is not same look. (See also the 9-slice ratio rules
in §8.)

**Sizing rules do not travel with a moved element.** An element authored for a small box carries
padding and font settings sized for it: a fixed `fontSize` with autosizing OFF stays tiny in a
bigger rect, and the label's rect insets stay at the old box's padding. Copy the target's
`enableAutoSizing`, `fontSizeMin/Max`, and the text child's `sizeDelta` insets — not just the
button's own rect. And when a `LayoutGroup` is `enabled == false`, `LayoutElement` values are
inert and the RECT is what positions things; copy both anyway so the pair stays identical if the
group is ever switched on.

**Do not ask which structural or scaling components to bring across — bring them.** "Should I also
copy the Outline?" is the wrong question when the instruction was *make it identical*.

## 11. Aspect ratios - the canvas changes SIZE, not just scale

`UGUI-ASPECT-ANCHOR-GAP`. With `ScaleWithScreenSize` + `MatchWidthOrHeight`, an aspect other than
the reference gives you a canvas that is a different **number of units** wide and tall - not the
same layout at a different zoom. A layout authored at 2622x1206 (2.17) resolves on a 4:3 tablet,
at match 0.263, to about 2306x1729: **316 units narrower and 523 taller.**

Two consequences, and both look like unrelated bugs:

- **Elements on different anchors lose exactly that delta from the gap between them.** A
  right-anchored panel and a centre-anchored one give up half the width change each - so a 43-unit
  clearance on the reference aspect becomes a 48-unit *overlap* on 4:3 without a single value
  changing. The fix is to move the centre-anchored side, not to shrink the panel.
- **Extra height lands as dead space between rows**, because top-anchored, centre-anchored and
  bottom-anchored content all move apart. The board reads as "too small" when nothing shrank.

Correct it with **one component holding an offset + scale multiplier per element**, blended by
aspect (`InverseLerp(referenceAspect, targetAspect, current)`) rather than switched at a threshold
- a 16:9 device loses about half the width and should get about half the correction, and nothing
snaps on rotation. Capture the authored values **once**, in `Awake`, and always write
`authored + delta * t`: reading the live value back compounds the correction on every resize.
Keep the component off `[ExecuteAlways]` - it writes to *other* objects' RectTransforms, so a
scene saved while it had run bakes the correction in as the authored layout. Give it an explicit
edit-mode Preview/Restore pair instead, and serialize the captured state so a scene saved
mid-preview is still recoverable.

**Solve it against VISIBLE ART bounds, never RectTransforms.** Glow rings, dotted borders and drop
shadows routinely run tens of units past their rect (47 in the case that produced this rule), so a
scale-up sized against rects clips every one of them at the screen edge. Union the world bounds of
the active `Graphic` components with alpha > 0, and search for the largest scale that keeps every
clearance positive - the ceiling is a measurement, not a taste call.

`UGUI-AABB-IS-NOT-AN-AXIS`. `GetWorldCorners` returns four corners; the axis-aligned box you build
from them says nothing about the rect's own axes. **A rotated panel's `sizeDelta.x` is on-screen
HEIGHT.** Read `localEulerAngles` before concluding which way anything grows, and compute a
hypothetical size through `localToWorldMatrix` rather than by adding to the AABB. The tell is
cheap: if the measured box is `sizeDelta.y * scale` wide and `sizeDelta.x * scale` tall, it is
turned 90 degrees. Getting this wrong inverts the whole diagnosis - a menu that grows straight up
the right edge reads as one sweeping left across half the screen.

## Project pin

A repo may keep **one** local file for canvas size, mock size, and a frozen
panel (seen on a mobile card game). That file does not replace
this procedure.

## uGUI — canvas, layout & sorting

All shell work also follows TEXT-CHANGES-DO-NOT-RESIZE-THE-SHELL (UI orientation page).

Old-UI Canvas architecture: sorting bands, layout-group knobs that are not the ones you think, and measurements that are lies on an inactive hierarchy.

- **CSS mock → uGUI is a calculator, not a per-panel builder (HARD).**
  Full pipeline: the css to ugui page. Viewport mock scales by
  `canvasW/mockW`; a component already in canvas units is 1:1. Copy the CSS
  tree into VLG/HLG. Shrink-wrap chips get their own non-expanding row —
  never disable the column's `childControlWidth`. `sizeDelta.x` on a stretch
  rect is an inset. Hard `box-shadow` → engine `Shadow` (Y flipped, no blur).
  Do not ship a MenuItem that only one overlay can run.

- **A shrink-wrap chip in a stretching VLG gets its own non-expanding row (HARD).**
  Full write-up: the css to ugui page. Do not set the column's
  `childControlWidth` / `childForceExpandWidth` false to unstretch one child.
  Sibling rows go to width 0 and overflow from the column centre. Wrap the
  chip in a full-width row whose HLG is MiddleCenter and expand-off.

- **UI Show/Hide that SizeDelta→0 a frame must FadeAlpha a CanvasGroup on that frame.**
  Children with their own `sizeDelta` stay visible when the parent rect is 0×0.
  `FadeAlpha` prefers CanvasGroup over Image/TMP. Then deactivate the overlay
  root on clip complete if that is the existing pattern.

- **Before "moving" a UI element, find out what its animator actually drives (HARD).**
  An authored `anchoredPosition` is only the element's resting place if nothing animates it. Show/Hide
  clips routinely tween **size** (`sizeDelta → 0` is a common authored-collapsed state, not an
  off-screen position) or alpha, and they usually write **absolute** end values, so a clip re-asserts
  its target a beat after the scene loads and silently undoes an inspector edit. Two consequences:
  the editor's authored values can differ from what players ever see (read the clip's end value, not
  the RectTransform, to learn the runtime geometry), and an element whose **width** animates must be
  anchored/pivoted from the edge you want fixed — a centre pivot slides its contents sideways every
  time the clip runs. (a mobile card game: the main-menu name row is authored `881.94` wide but settles
  at `500`; a layout judged from the editor was wrong about where its left edge lands in game, and
  about the name wrapping to two lines at the real width.)

- **Never write `renderMode` on a Screen Space Overlay root canvas (HARD).**
  Nested overlays (`overrideSorting` + `sortingOrder` + `GraphicRaycaster`) inherit the
  parent's overlay space and CanvasScaler. Assigning `ScreenSpaceOverlay` on the *child*
  detaches it from that scaler — Game view draws 1:1 pixels (blown up), Scene view still
  shows the authored layout. Assigning `WorldSpace` on the child, or flipping the child
  Overlay ↔ World Space, can rewrite the **parent** root to World Space (`m_RenderMode: 0 → 2`)
  and bake its overlay-zero rect into screen-sized world numbers (`pos ~1311,603`,
  `sizeDelta ~2622x1206`, `scale 1`). Symptom: Game view is the camera clear color plus a
  giant slab of one graphic; Scene view still looks like the real menu. Recipe: `AddComponent<Canvas>`,
  set `overrideSorting` and `sortingOrder` only, never `renderMode`. After any canvas MCP write,
  re-read the **root** canvas `m_RenderMode` and confirm it is still `0`. Game view is the
  authority, not Scene view. (a mobile card game: daily-login overlay; `Canvas - Main` landed
  World Space.)

## childForceExpand beats flexibleWidth — they are not the same knob

`HorizontalLayoutGroup.childForceExpandWidth` (and the vertical twin) hands
leftover space to **every** child in equal shares, regardless of each child's
`LayoutElement.flexibleWidth`. So a child set to `preferredWidth = N,
flexibleWidth = 0` — the normal way to say "this one is a fixed size" — still
stretches, and the fix looks like it did nothing.

`flexibleWidth` only governs once force-expand is **off**. Turn it off on any row
that mixes fixed-width and filling children; the filling ones keep `flexibleWidth
= 1` and still take the slack.

Verify by reading back `rect.width`, not by looking — a card 20% too wide in a
row of four reads as "the gap is a bit big".

### …and it propagates UPWARD too: the group reports flexible to ITS parent

The same switch changes what the group advertises to the layout **above** it. A
`HorizontalLayoutGroup` with `childForceExpandHeight = true` reports
`flexibleHeight >= 1` for **itself**, because `CalcAlongAxis` takes the max child
flexible and force-expand floors every child at 1.

So a row you think is inert — its own `LayoutElement.flexibleHeight = -1`, i.e.
unset — will still fight its parent's other children for surplus space, and win a
share. Symptom: you add a spacer to absorb slack, the spacer measures 0, and the
row silently grew instead.

`-1` means "not set", not "zero". Set an explicit `flexibleHeight = 0` on the
group's own `LayoutElement` to say "never absorb". That works because
`LayoutElement.layoutPriority` (1) outranks `LayoutGroup`'s (0), so the explicit 0
wins the `LayoutUtility` lookup — **without** touching `childForceExpand`, which
you usually still need, since it is what gives the children their size.

### A stable panel height: floor it with minHeight, do not pin sizeDelta

For a panel whose content changes (tab switch, list length) and must not resize:

- `LayoutUtility.GetPreferredHeight` returns `max(minHeight, preferredHeight)`,
  so a `LayoutElement.minHeight = N` on the same object as the `ContentSizeFitter`
  makes the fitter resolve to `N` on every state — while still **growing** if
  content ever exceeds `N`. Writing `sizeDelta.y = N` and setting the fitter to
  Unconstrained clips instead.
- The slack still has to land somewhere. With `childAlignment = Upper*` an
  unabsorbed surplus parks below the last child, so anything meant to sit at the
  bottom (a footer) rides up and down per state. A zero-size child with
  `flexibleHeight = 1` placed just before it pins it down.

## Layout numbers read off an INACTIVE hierarchy are all lies (HARD)

`LayoutUtility.GetPreferredHeight/GetFlexibleHeight/GetMinHeight` skip any
`ILayoutElement` whose component is not `isActiveAndEnabled`. On a deactivated
panel that is **every** component, so every query returns `0` — and
`LayoutRebuilder.ForceRebuildLayoutImmediate` does nothing, leaving `rect` at
whatever was last serialised.

This does not throw and does not warn. You get a full table of plausible numbers
that are pure fiction, and a "verified" conclusion.

UI panels are the common case, because the well-behaved ones **ship deactivated**
and are shown by an animator or a controller. So the default state of the thing
you want to measure is exactly the state in which measuring silently fails.

```csharp
bool was = root.activeSelf;
root.SetActive(true);                       // REQUIRED before any measurement
LayoutRebuilder.ForceRebuildLayoutImmediate(rt);
Canvas.ForceUpdateCanvases;
LayoutRebuilder.ForceRebuildLayoutImmediate(rt);   // twice: nested groups settle on pass 2
/* ...measure... */
root.SetActive(was);                        // and RESTORE, or you commit a panel that ships open
```

**The tell:** every element reports `preferred = 0.0` and `flexible = 0.00`,
including ones you know have an authored `LayoutElement`. If a whole column reads
zero, you are measuring a corpse — re-run with the root active before believing
anything, including a fix you just "confirmed".

⚠ **Edit-mode writes made during Play Mode are discarded on exit**, and
`EditorSceneManager.MarkSceneDirty` throws `"This cannot be used during play
mode."` — which, if it is the last line of your script, means the mutations above
it already applied to the runtime scene and looked like they worked. Check
`Application.isPlaying` (or the editor state) BEFORE authoring, not after.

⚠ **Driving a view to measure it MUTATES it.** Reflection-calling a panel's
`Build`/`Populate`/`Refresh` to set up a measurement is not a read. Those
methods `Instantiate` from a template, and the ones that do not pool **append
without clearing** — so N measurement passes leave N copies of every row, and if
you save the scene between passes you have persisted them.

The damage is invisible in numbers: the duplicates stack on top of each other at
the same layout slots, so heights still measure correctly and only a *screenshot*
shows twenty overlapping cards.

- Snapshot `childCount` per container BEFORE the first drive, and assert it back
  afterwards. A pre-edit hierarchy dump is the cheapest insurance.
- Prefer driving the **state** over the **builder**: toggling which child is
  active and calling `ForceRebuildLayoutImmediate` tests the layout without
  instantiating anything.
- Deduplicate by keeping the FIRST occurrence of each child name — appended
  copies come after the originals, so this preserves authored instances.

## uGUI canvas architecture (a mobile card game / Cross-The-Line)

- **No Canvas means order 0, which is a BAND — not "the bottom".** Children without their own
  `Canvas` all share the root's sorting order and are sorted purely by SIBLING INDEX — including
  full-screen modals. Anything appended at the end of the list silently draws over every modal that
  has no Canvas of its own. Place a new object on a shared-order canvas by sibling index relative to
  those modals; a new `sortingOrder` is usually the wrong tool.

- **Keep controller components OFF the UI element chain.** Never put custom logic/controller
  MonoBehaviours on a RectTransform under a Canvas — a controller there entangles its lifecycle and
  every state/layout mutation with that canvas's dirty→rebuild cycle, so enabling its object (or
  calling in to mutate children) re-dirties and rebuilds the whole subtree. Host them on a dedicated
  non-UI `Scripts - X` object and drive the UI through serialized refs; leave only passive
  presentation (Image/TMP/CanvasRenderer) on UI nodes. **Corollary — the naive relocation trades one
  bug for another:** a `Scripts - X` object is always-active, so the relocated controller's
  `OnEnable` fires ONCE at boot, not when the canvas shows. It must (un)subscribe and render on
  explicit show/hide events from the canvas owner. The fix is event-driven init, not moving the
  controller back.

- **Solve panel geometry in WORLD units when an ancestor is scaled.** Authored insets are in LOCAL
  units, and scaling about the centre walks the edges inward — solve with `worldWidth = local × scale`
  and treat `anchoredPosition.x` as the rect centre's offset from the canvas centre. The symptom is a
  panel that "floats" a fixed gap off its neighbour and no inset value fixes it, because you are
  editing the wrong space.

- **Loop a rotation as a signed DELTA, never an absolute 360.** Tween `-360` (clockwise) with
  incremental looping; a tween to an absolute `+360` endpoint reports as playing and nothing turns,
  because 0 and 360 are the same quaternion — and the flag people reach for (a FastBeyond360-style
  option) is fighting the wrong layer. Applies to any quaternion-backed tween: uGUI, UITK transform,
  DOTween, or a hand-rolled animator.

- **TWEEN-KILL-KEY-IS-THE-TWEEN-TARGET (HARD) — kill by the key the tween was created with.**
  DOTween's shortcuts target the COMPONENT they animate (`image.DOFade` → the `Image`,
  `canvasGroup.DOFade` → the `CanvasGroup`) and `DOTween.To` targets nothing, so an animator that
  kills by the object its config names (`DOTween.Kill(rectTransform)`) misses them. The survivor is an
  infinite loop that outlives `Stop`/`OnDisable` and logs "has been destroyed" on the next scene
  switch. Stamp `SetTarget(key)` once in the shared tween-creation path, never per call site, and never
  silence it with null checks or safe-mode logging. Measured: `Kill(rectTransform)` killed 0 of a fade
  loop before `SetTarget` and 1 after. `Kill(target)` also never reaches a tween nested in a `Sequence`
  (0 killed), so a loop that must be stoppable plays standalone.

- **KILLED-LOOP-RESTARTS-FROM-THE-FROZEN-VALUE — rewind a loop before you kill it.** `Kill` leaves
  the property wherever the cycle was, and a relative tween (`DOFade`, `DOScale`, `DOLocalRotate`)
  captures its start value when it starts, so the next play of the same loop yoyos from that frozen
  value and the authored range shrinks on every stop/replay. Measured: a 0.35..0.75 fade ran
  0.452..0.75 after one close/reopen. Call `Rewind` then `Kill` in the ONE shared kill path (stop,
  disable, replay), not per panel: the tween restores its own captured start, so a second snapshot
  (an Awake capture, which also rarely holds scale or rotation) is not needed. Verified: four cycles
  each 0.35..0.75, closed at exactly 0.35, a spin rewound to 0°, and no destroyed-target warnings when
  the rewind runs in `OnDisable` during a scene unload.

## Vendor overlays and dead listeners (a tower-defense game)

- **A debug overlay's trigger is a raycast target like any other.** SRDebugger's on-screen
  trigger (settings default BottomRight, ~110 px) sat on the PLAY button: every real click died
  while automation that called `onClick.Invoke` kept passing. Keep debug triggers off player
  buttons (TopCenter here) and prove a button with `EventSystem.current.RaycastAll` at its CENTRE
  — the top hit must be the button or one of its own children.
- **Listeners left over from a removed UI framework fire nothing, silently.** A persistent
  `onClick` whose target component was deleted keeps a NULL target — the button looks wired in the
  Inspector and does nothing at runtime. After removing a framework, grep the scenes for its method
  names (`m_MethodName: Show|Hide`) and rewire every hit.
- **A flow claim needs the player's input path, not the handler.** RaycastAll for the hit, plus a
  click through the input module — real, or `InputSystem.QueueStateEvent`. In an unfocused editor
  the Mouse device is DISABLED and `editorInputBehaviorInPlayMode` drops pointer input: enable the
  device and set `AllDeviceInputAlwaysGoesToGameView` for the test, restore afterwards, and treat
  a lost synthetic click as inconclusive (the user clicking other windows resets device state).
