diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index d672e01d..502a0686 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -26,7 +26,44 @@ What does NOT go here:
## #372 — Options panel: Character/Chat/Config tabs render BLANK on screen and most Gameplay buttons do nothing (connected-gate failure)
-**Status:** OPEN — filed 2026-08-11 at Campaign OP's first connected gate
+**Status:** BLANK-TABS ROOT-CAUSED + FIXED (`c3ed32fb` probe, fix this
+commit); the Gameplay-buttons observation still needs a re-gate. Filed
+2026-08-11 at Campaign OP's first connected gate.
+
+**ROOT CAUSE (blank tabs) — found + fixed.** `UiTemplateListBox` creates its
+row viewport lazily (post-Build, during a page controller's `Bind`) at
+**0×0** with `Left|Top|Right|Bottom` fill-anchors. The anchor system captures
+its baseline from that 0-size rect (`mR = parentW − (Left+Width) = parentW`),
+and `UiElement.ComputeAnchoredRect`'s left+right branch then yields
+`w = parentW − mR − mL = 0` (and `h = 0`) **permanently**. A 0-tall viewport
+makes `UiScrollablePanel.LayoutScrollableChildren` cull every row
+(`top + height ≤ Height` is false for any real row at Height 0), so every
+ListBox-backed tab (Character/Chat/Config) draws empty. Gameplay works
+because it has no viewport — its buttons are authored static children sized
+at Build, so they never hit the lazy-0×0 path. **Fix:** seed the viewport to
+the ListBox's current extent at creation (`UiTemplateListBox.cs` Viewport
+getter), so the fill-anchor baseline is `mR = parentW − parentW = 0` and the
+viewport tracks the ListBox. Regressed by
+`tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs` (RED→GREEN)
+and the live-DAT mount probe. Every fixture conformance test stayed green
+throughout — the false-negative class this issue documents — so the
+regression test drives the anchor+cull layout path the suite never did.
+
+**Still owed — the Gameplay-buttons half is NOT root-caused.** Of the seven
+buttons the user found only Exit Game acting. Two (Configure Keyboard,
+In-Game Help) are correctly INERT. The other four (Exit-to-CharSel confirm
+dialog, Use-Mouse-Turning chat lines, Urgent Assistance / Report Abuse
+failure text) have effects that may have gone unnoticed rather than failed —
+no evidence either way yet. Needs a re-gate observation (per-button: does a
+click produce ANY visible response) before investigating; do not guess-fix.
+Separately, the live log showed all 13 `ID_ChatOption_TextFilter_*` Chat-tab
+filter labels failing to resolve from string table `0x23000003` (rows render
+blank-captioned by the honest-fallback path; masks/behaviour unaffected) — a
+minor label-resolution bug to fix (wrong table id or key spelling for that
+family), tracked here until split out.
+
+
+**(filed OPEN)** — 2026-08-11 at Campaign OP's first connected gate
(`ACDREAM_RETAIL_UI=1`, live ACE). The user found: opening the Options panel
(F11/toolbar) shows the Gameplay tab, but switching to Character/Chat/Config
shows a BLANK page, and of the seven Gameplay buttons only **Exit Game**
diff --git a/src/AcDream.App/UI/UiTemplateListBox.cs b/src/AcDream.App/UI/UiTemplateListBox.cs
index 4091d143..5968899d 100644
--- a/src/AcDream.App/UI/UiTemplateListBox.cs
+++ b/src/AcDream.App/UI/UiTemplateListBox.cs
@@ -144,16 +144,37 @@ public sealed class UiTemplateListBox : UiDatElement
/// Retail ListBox rows never come from static dat children — see class doc.
public override bool ConsumesDatChildren => true;
+ /// The lazily-created row viewport, or null before the first row is added.
+ /// Exposed for #372's regression test (the viewport must fill this ListBox, not
+ /// collapse to 0×0).
+ internal UiScrollablePanel? ViewportForTest => _viewport;
+
private UiScrollablePanel Viewport
{
get
{
if (_viewport is null)
{
+ // #372: the viewport MUST start filling this ListBox. It is created
+ // lazily (post-Build, during a page controller's Bind), so it misses
+ // the Build-time sizing that authored static children get. With
+ // Left|Top|Right|Bottom fill-anchors but a 0×0 initial rect, the
+ // anchor system captures a degenerate baseline (mR = parentW - (0+0)
+ // = parentW) and ComputeAnchoredRect then keeps it 0×0 forever
+ // (w = parentW - mR - mL = 0) — a 0-tall viewport makes
+ // UiScrollablePanel.LayoutScrollableChildren cull every row, so every
+ // ListBox-backed Options tab renders BLANK (Gameplay, which has no
+ // viewport, was the only tab that worked). Seeding the viewport to
+ // this ListBox's current size makes the fill-anchor baseline correct
+ // (mR = parentW - (0 + parentW) = 0) and self-maintaining as the
+ // ListBox re-anchors each frame. The ListBox is a static dat child
+ // sized at Build, so its extent is already authored here.
_viewport = new UiScrollablePanel
{
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
LineHeight = _pendingLineHeight,
+ Width = Width,
+ Height = Height,
};
base.AddChild(_viewport);
}
diff --git a/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs
new file mode 100644
index 00000000..98ceeba6
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs
@@ -0,0 +1,79 @@
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+
+namespace AcDream.App.Tests.UI;
+
+///
+/// #372 regression: the Options panel's Character/Chat/Config tabs rendered
+/// BLANK at the first connected gate while Gameplay (which has no ListBox)
+/// worked. Root cause: creates its row
+/// viewport at 0×0 with Left|Top|Right|Bottom fill-anchors; the anchor
+/// baseline captured from that 0-size rect makes
+/// ComputeAnchoredRect keep it 0×0 forever (mR=parentW, so
+/// w=parentW-mR-mL=0), and
+/// then culls every row (top+height ≤ 0 is false). These tests pin that the
+/// viewport fills its ListBox and the rows stay visible after a layout pass.
+///
+public sealed class UiTemplateListBoxViewportTests
+{
+ private static UiTemplateListBox MakeListBox(float width, float height)
+ {
+ // A ListBox element authoring one template entry, sized like the
+ // Options-panel Character ListBox (0x100001FA, 276×560 authored).
+ var info = new ElementInfo { Id = 0x100001FAu, Type = 5u };
+ var box = new UiTemplateListBox(
+ info,
+ _ => (0u, 0, 0),
+ new[] { new UiTemplateListEntry(0x2100002Bu, 0x10000218u) },
+ scrollbarElementId: 0x100001FBu)
+ {
+ Width = width,
+ Height = height,
+ };
+ return box;
+ }
+
+ [Fact]
+ public void Viewport_FillsTheListBox_NotCollapsedToZero()
+ {
+ var box = MakeListBox(276f, 560f);
+ // Add a couple of rows via the prebuilt path (no DAT needed).
+ box.AddPrebuiltRow(new UiText { Width = 260f, Height = 20f });
+ box.AddPrebuiltRow(new UiText { Width = 260f, Height = 20f });
+
+ UiScrollablePanel? viewport = box.ViewportForTest;
+ Assert.NotNull(viewport);
+
+ // Drive the per-frame layout the draw traversal runs: the parent
+ // ListBox re-anchors its children (UiElement.DrawSelfAndChildren
+ // line ~500), sizing the fill-anchored viewport.
+ viewport!.ApplyAnchor(box.Width, box.Height);
+
+ // The whole point of #372: the viewport must FILL the ListBox, not
+ // collapse. Pre-fix this was 0×0.
+ Assert.Equal(276f, viewport.Width, 3);
+ Assert.Equal(560f, viewport.Height, 3);
+ }
+
+ [Fact]
+ public void Rows_StayVisible_AfterLayout_WhenTheyFitTheViewport()
+ {
+ var box = MakeListBox(276f, 560f);
+ var row0 = new UiText { Width = 260f, Height = 20f };
+ var row1 = new UiText { Width = 260f, Height = 20f };
+ var row2 = new UiText { Width = 260f, Height = 20f };
+ box.AddPrebuiltRow(row0);
+ box.AddPrebuiltRow(row1);
+ box.AddPrebuiltRow(row2);
+
+ UiScrollablePanel viewport = box.ViewportForTest!;
+ viewport.ApplyAnchor(box.Width, box.Height); // size the viewport
+ viewport.LayoutScrollableChildren(); // the cull pass (runs each OnDraw)
+
+ // All three rows sit at y 0/20/40 inside a 560px viewport → visible.
+ // Pre-fix the viewport was 0px tall and every row was culled → blank tab.
+ Assert.True(row0.Visible, "row 0 culled — the #372 blank-tab bug");
+ Assert.True(row1.Visible, "row 1 culled — the #372 blank-tab bug");
+ Assert.True(row2.Visible, "row 2 culled — the #372 blank-tab bug");
+ }
+}