Unity Workflow LibraryUWLAbout

Topic 7 · UI

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 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:

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:

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.

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).