fix(ui): Options panel Config tab content escapes the window frame — stale viewport anchor capture, not a missing clip

The Config tab's footer sat mid-panel with further rows drawing below the
window's bottom edge. Live-DAT measured: the mounted tab-host root is
authored 300x362 (retail's real default window size), but the Config page
slot underneath keeps its own larger design geometry (298x575 against a
300x600 canvas) until retail's real four-edge UiLayoutPolicy
(UIElement::UpdateForParentSizeChange @0x00462640) shrinks it on the first
ApplyAnchor pass -- verified stable, this part already worked.

The actual bug: UiTemplateListBox.Viewport (the UiScrollablePanel that
hosts + clips every row) is a programmatic C# element seeded at Bind time,
BEFORE the tree's first draw frame -- before the ListBox has ever shrunk.
Its legacy anchor baseline is captured lazily on its own first ApplyAnchor
call, which lands AFTER the ListBox has already shrunk earlier in that same
frame (parent-before-child draw order). That capture measures a negative
bottom margin the stretch math preserves forever: the viewport stayed
locked at its original 560px design height, clipping rows to a bound
retail never actually gave the window on screen.

Fix: force the viewport's anchor capture to happen immediately after
seeding it, while its Width/Height still exactly equal a zero-margin
baseline against the CURRENT (pre-shrink) parent, instead of lazily on the
first draw frame against an already-shrunk parent. This is #372's sequel --
#372 fixed the 0x0 collapse case; this is the "ListBox itself later
shrinks" case #372's own fixture never exercised.

Three new tests (UiTemplateListBoxViewportTests using the live-DAT-measured
298x575/276x560 numbers, plus two ConfigOptionsPageControllerTests against
the real production Bind path and the committed host fixture) all fail
pre-fix, confirmed by temporarily reverting the change. Scoped to
UiTemplateListBox's own viewport; UiScrollablePanel/ApplyAnchor/
ComputeAnchoredRect are untouched, so chat's transcript scrolling and every
other UiScrollablePanel/UiItemList consumer are unaffected.

fix #412

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-17 00:24:33 +02:00
parent 97a7be12ee
commit c623b57ad3
4 changed files with 310 additions and 0 deletions

View file

@ -24,6 +24,94 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
## #412 — Options panel Config tab content escapes the window frame (footer mid-panel, rows drawing below the window's bottom edge)
**Status:** DONE 2026-08-16/17 (overnight hover/UI round, Batch A bug 2).
**Symptom (user screenshot):** the Config tab's Sound/Camera/Graphics/Rendering
Quality sections rendered with the Apply/Reset/Defaults footer sitting
mid-panel and further rows (Full Screen, Sync With Refresh Rate, Screen
Brightness, Adaptive Degrade, the quality dropdowns...) drawing BELOW the
window's bottom edge, outside the panel frame — not clipped to the window,
not reachable by scrolling. The user noted seeing this class of bug before
("another bug that I saw before as well") — a related but distinct symptom,
the CONTENT-behind-the-footer bleed-through, was already fixed as #381; this
is content escaping the WHOLE window, not just showing through the footer
strip.
**Root cause — a stale anchor-baseline capture, not a missing clip.**
Live-DAT measured (`0x2100006E` slot `0x1000018D`): the merged tab-host root
is authored 300×362 (retail's real default window size — matches the floaty
frame's own 310×372 root), but the Config page slot underneath
(`0x10000213`) keeps its own larger authored design geometry, 298×575
against a 300×600 design canvas — retail's real
`UIElement::UpdateForParentSizeChange @0x00462640` four-edge policy
(`L=T=R=B=1`, "preserve original margin on every edge") correctly shrinks
that slot to ~298×337 on the first `ApplyAnchor` layout pass, and the Config
ListBox (`0x10000200`, 276×560) shrinks right behind it via the SAME
per-element `UiLayoutPolicy` mechanism — both verified stable over repeated
simulated frames. The actual bug: `UiTemplateListBox.Viewport` (the
`UiScrollablePanel` that hosts + clips every row) is a PROGRAMMATIC C#
element seeded at `ConfigOptionsPageController.Bind` time — BEFORE the
tree's first real draw frame, i.e. before the ListBox has ever shrunk. Its
legacy `Left|Top|Right|Bottom` anchor baseline is captured lazily, on ITS
own first `ApplyAnchor` call, which lands AFTER the ListBox has already
shrunk earlier in that SAME frame (parent-before-child draw order) — so the
capture measures a NEGATIVE bottom margin (`parentH(297) - (0+560) = -263`)
that `ComputeAnchoredRect`'s stretch math preserves FOREVER: the viewport
stayed locked at its original 560px design height, clipping rows to a bound
retail never actually gave the window on screen. Rows past the real ~297px
stayed "visible" per the cull test and painted straight through the footer
and past the window's real bottom edge.
**Fix (mechanism, not a workaround):** `UiTemplateListBox.Viewport`'s getter
now calls `_viewport.CaptureCurrentAnchorBaseline()` immediately after
seeding it — forcing the anchor capture to happen NOW, while the viewport's
own Width/Height still exactly equal a zero-margin baseline against its
CURRENT (pre-shrink) parent, instead of lazily on the first real draw frame
against an ALREADY-shrunk parent. `ComputeAnchoredRect` then tracks whatever
height the ListBox actually ends up at after its own `LayoutPolicy` runs, on
every subsequent frame — exactly #372's original intent (#372 fixed the 0×0
collapse case; this is #372's sequel for the "ListBox itself later shrinks"
case, which #372's own fixture never exercised because its harness ListBox
had no parent to shrink it).
`src/AcDream.App/UI/UiTemplateListBox.cs`.
**Tests:** `UiTemplateListBoxViewportTests.Viewport_TracksTheListBox_WhenTheListBoxItselfShrinksOnFirstLayout`
(synthetic two-level `UiLayoutPolicy` parent chain using the live-DAT-measured
298×575/276×560 numbers) and two `ConfigOptionsPageControllerTests` fixture
regressions
(`ConfigSlot_MatchesItsAuthoredOversizedDesign_BeforeAnyLayoutPass`,
`ConfigTab_ContentFitsInsideItsMountedWindow_AfterOneDrawFramesLayoutPass`)
against the REAL production `ConfigOptionsPageController.Bind` path and the
committed `options_panel_2100006E_1000018D.json` fixture. All three fail
pre-fix (red-green confirmed by temporarily reverting the fix) and pass
post-fix. Full App suite (5437/3 skips), Runtime (1735/0), and the complete
solution (14,575 tests) pass with the fix in place.
**Blast-radius note (task-required):** the fix is scoped to
`UiTemplateListBox`'s own lazily-created viewport — it does not touch
`UiScrollablePanel`, `UiElement.ApplyAnchor`, or `ComputeAnchoredRect`
themselves, so chat's transcript scrolling and every other
`UiScrollablePanel`/`UiItemList` consumer (inventory grids, spell/component
catalogs, Chat tab's own filter blocks) are unaffected — confirmed by the
full solution run passing with no new failures anywhere outside the two
files this fix touches. The ONLY other `UiTemplateListBox` consumers are the
Character and Chat Options tabs, which share the identical
Bind-before-first-frame ordering and are now protected by the SAME fix.
**Live check not performed:** the fix is proven via live-DAT-measured
geometry (real installed DAT numbers feeding both the regression tests and
this writeup) plus the fixture path that mirrors the exact production
`RetailUiRuntime.MountOptionsPanel` sequence, but the actual visual
Config-tab-in-window check was not done live (no interactive desktop
consent available this session — see #409's own note on the same
constraint). Owed: open Options -> Config with `ACDREAM_RETAIL_UI=1` and
confirm the footer and every row stay inside the window frame, with the
scrollbar reaching every row.
---
## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state
**Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the