diff --git a/src/AcDream.App/UI/PluginSidePanel.cs b/src/AcDream.App/UI/PluginSidePanel.cs
index b43f8b48e..2e91bc2c8 100644
--- a/src/AcDream.App/UI/PluginSidePanel.cs
+++ b/src/AcDream.App/UI/PluginSidePanel.cs
@@ -224,11 +224,29 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
BorderThickness = 1f;
Visible = false;
+ // Anchors = None on both: the shelf is the SOLE layout owner of these
+ // children (LayoutChrome, called from Reflow, repositions them every
+ // time the shelf's own Width/Height changes — collapse/expand,
+ // entries added/removed, row-wrap). UiElement.ApplyAnchor is called
+ // for every child on every draw; for any child whose Anchors is not
+ // AnchorEdges.None it captures the Left/Top/Width/Height margins
+ // ONCE on the FIRST draw and re-applies that frozen snapshot on
+ // every subsequent draw, silently overwriting whatever LayoutChrome
+ // had just written. Left at the default Left|Top, the grip/toggle
+ // therefore stayed pinned at their EXPANDED geometry after a
+ // collapse (owner report 2026-09-06: the toggle ended up outside
+ // the collapsed shelf and the ancestor clip removed it — "I dont
+ // see the < after I minimize the window"). Anchoring is simply the
+ // wrong mechanism for a child whose layout owner already re-derives
+ // its full geometry every reflow; ResetAnchorCapture() would only
+ // patch this one call site; the actual fix is to never engage the
+ // capture-and-freeze machinery for these children at all.
_grip = new ShelfGripPanel
{
WindowMoveHandle = true,
BackgroundColor = Vector4.Zero,
BorderColor = Vector4.Zero,
+ Anchors = AnchorEdges.None,
};
_toggle = new UiSimpleButton
{
@@ -238,6 +256,7 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
DatFont = _font,
Outline = true,
TextSource = () => _collapsed ? "<" : ">",
+ Anchors = AnchorEdges.None,
};
_toggle.Click += ToggleCollapsed;
AddChild(_grip);
@@ -285,6 +304,13 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
{
Width = ButtonExtent,
Height = ButtonExtent,
+ // Anchors = None for the same reason as _grip/_toggle above:
+ // Reflow() is the sole owner of each entry button's Left/Top
+ // (row-wrap column/row placement), rewritten on every add/
+ // remove/collapse. Left at the default anchor, ApplyAnchor would
+ // freeze a button at whatever column/row it first drew in and
+ // never let a later removal's row-wrap actually move it.
+ Anchors = AnchorEdges.None,
};
button.Click += () =>
{
diff --git a/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs b/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs
index fee36108a..34b74ed0b 100644
--- a/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs
+++ b/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs
@@ -266,4 +266,236 @@ public sealed class PluginSidePanelToggleGlyphClipTests
Assert.True(shelf.Height > 20f, $"collapsed shelf height ({shelf.Height}) is not button-sized");
Assert.NotEqual(expandedHeight, shelf.Height);
}
+
+ ///
+ /// Owner-reported defect (2026-09-06 live probe): after collapsing the
+ /// shelf, the grip and toggle stayed pinned at the EXPANDED geometry —
+ /// the toggle ended up outside the 24px collapsed shelf and the ancestor
+ /// clip removed it, so the owner saw the tab with no <.
+ ///
+ ///
+ /// Root cause: is called for every
+ /// child on every draw. For any child whose
+ /// is not , it captures the Left/Top/Width/
+ /// Height margins ONCE on the FIRST draw and re-applies that snapshot on
+ /// every subsequent draw — overwriting whatever
+ /// /Reflow set in between.
+ /// never draws, so a test that only ticks (as
+ /// every prior collapse test in this tree does) never exercises this path
+ /// at all — this test drives two REAL passes,
+ /// with a real toggle click in between, to reproduce it.
+ ///
+ ///
+ ///
+ /// Uses a bitmap-fallback font (font: null) deliberately, per the
+ /// investigation's direction, so this reproduction runs in every CI lane
+ /// without an installed retail DAT — unlike the Lane=InstalledDat
+ /// tests above, which require one.
+ ///
+ ///
+ [Fact]
+ public void Collapse_AfterADraw_RepositionsGripAndToggle_NotFrozenAtExpandedGeometry()
+ {
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font: null);
+ root.AddChild(shelf);
+
+ for (int i = 0; i < 2; i++)
+ {
+ var frame = new UiPanel { Width = 200f, Height = 100f };
+ root.AddChild(frame);
+ RetailWindowHandle handle = root.WindowManager.Register(
+ $"plugin:acdream.test:{i}", frame);
+ shelf.Add(
+ new PluginUiOwner($"acdream.test.{i}", $"Test Plugin {i}"),
+ new PluginPanelDescriptor("main", $"Test Plugin {i}"),
+ handle);
+ }
+
+ root.Tick(0.016d, 16L);
+
+ var device = new RecordingGpuDevice();
+ var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
+ var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
+
+ // ── First draw: this is what CAPTURES the anchor snapshot (at the
+ // expanded geometry) — nothing is wrong yet, since the snapshot
+ // matches the current (correct) layout. ──────────────────────────
+ renderer.Begin(new Vector2(800f, 600f));
+ root.Draw(ctx);
+
+ UiSimpleButton toggle = Assert.Single(
+ shelf.Children,
+ c => c is UiSimpleButton && c is not PluginSidePanel.PluginShelfButton) as UiSimpleButton
+ ?? throw new InvalidOperationException("toggle button not found");
+ UiElement grip = Assert.Single(
+ shelf.Children,
+ c => c is UiPanel && c is not UiSimpleButton);
+
+ float expandedShelfHeight = shelf.Height;
+ float expandedBandHeight = shelf.ExpandedGripBandHeight;
+ float toggleWidth = toggle.Width;
+
+ Assert.Equal(0f, grip.Left);
+ Assert.Equal(0f, grip.Top);
+ Assert.Equal(shelf.Width - toggleWidth, grip.Width, 3);
+ Assert.Equal(expandedBandHeight, grip.Height, 3);
+ Assert.Equal(shelf.Width - toggleWidth, toggle.Left, 3);
+ Assert.Equal(0f, toggle.Top);
+ Assert.Equal(expandedBandHeight, toggle.Height, 3);
+
+ // ── Collapse via a REAL press/release through UiRoot (the production
+ // input path), not the state controller directly. ────────────────
+ int toggleX = (int)shelf.Left + (int)shelf.Width - 8;
+ int toggleY = (int)shelf.Top + 4;
+ root.OnMouseDown(UiMouseButton.Left, toggleX, toggleY);
+ root.OnMouseUp(UiMouseButton.Left, toggleX, toggleY);
+ Assert.True(shelf.CaptureWindowState().Collapsed);
+
+ // ── Second draw: must reflect the COLLAPSED geometry. Pre-fix, the
+ // anchor snapshot captured above wins instead: grip/toggle stay at
+ // their EXPANDED Left/Width/Height regardless of what LayoutChrome
+ // just wrote via Reflow. ───────────────────────────────────────────
+ renderer.Begin(new Vector2(800f, 600f));
+ root.Draw(ctx);
+
+ Assert.Equal(0f, grip.Left);
+ Assert.Equal(0f, grip.Top);
+ Assert.Equal(shelf.Width - toggleWidth, grip.Width, 3);
+ Assert.Equal(shelf.Height, grip.Height, 3);
+ Assert.Equal(shelf.Width - toggleWidth, toggle.Left, 3);
+ Assert.Equal(0f, toggle.Top);
+ Assert.Equal(shelf.Height, toggle.Height, 3);
+ Assert.True(
+ toggle.Left + toggle.Width <= shelf.Width + 0.01f,
+ $"toggle (Left={toggle.Left}, Width={toggle.Width}) lies outside " +
+ $"the collapsed shelf (Width={shelf.Width}) — the ancestor clip " +
+ "would remove it, matching the owner's \"no <\" report.");
+
+ // ── Expand again: the geometry must be restored too, via another
+ // real click. ──────────────────────────────────────────────────────
+ int toggleX2 = (int)shelf.Left + (int)shelf.Width - 8;
+ int toggleY2 = (int)shelf.Top + 4;
+ root.OnMouseDown(UiMouseButton.Left, toggleX2, toggleY2);
+ root.OnMouseUp(UiMouseButton.Left, toggleX2, toggleY2);
+ Assert.False(shelf.CaptureWindowState().Collapsed);
+
+ renderer.Begin(new Vector2(800f, 600f));
+ root.Draw(ctx);
+
+ Assert.Equal(expandedShelfHeight, shelf.Height, 3);
+ Assert.Equal(0f, grip.Left);
+ Assert.Equal(0f, grip.Top);
+ Assert.Equal(shelf.Width - toggleWidth, grip.Width, 3);
+ Assert.Equal(expandedBandHeight, grip.Height, 3);
+ Assert.Equal(shelf.Width - toggleWidth, toggle.Left, 3);
+ Assert.Equal(0f, toggle.Top);
+ Assert.Equal(expandedBandHeight, toggle.Height, 3);
+ }
+
+ ///
+ /// Same double-draw pin as
+ /// ,
+ /// but for the ENTRIES
+ /// rather than the grip/toggle: a multi-column row-wrapped layout, draw,
+ /// remove one entry (a real window unregister — the same path a plugin
+ /// unload takes), draw again. Every remaining button's Left/Top must
+ /// match what a FRESH single-pass layout of the same final entry set
+ /// would produce — not a stale position captured from the 12-entry
+ /// layout on the first draw.
+ ///
+ ///
+ /// Compares against an independent "reference" shelf built directly with
+ /// the final (post-removal) entry set and drawn exactly once, so the
+ /// expected geometry is derived from the real layout math rather than
+ /// re-hard-coded private constants (OuterPadding/ButtonExtent/
+ /// ButtonGap are private to ).
+ ///
+ ///
+ [Fact]
+ public void MultiColumnReflow_AfterADraw_EveryRemainingButtonMatchesAFreshSinglePassLayout()
+ {
+ const int entryCount = 12;
+
+ var root = new UiRoot { Width = 800f, Height = 260f };
+ using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font: null);
+ root.AddChild(shelf);
+
+ var handles = new RetailWindowHandle[entryCount];
+ for (int i = 0; i < entryCount; i++)
+ {
+ var frame = new UiPanel { Width = 200f, Height = 100f };
+ root.AddChild(frame);
+ handles[i] = root.WindowManager.Register($"plugin:acdream.test:{i}", frame);
+ shelf.Add(
+ new PluginUiOwner($"acdream.test.{i}", $"Test Plugin {i}"),
+ new PluginPanelDescriptor("main", $"Test Plugin {i}"),
+ handles[i]);
+ }
+
+ root.Tick(0.016d, 16L);
+
+ var device = new RecordingGpuDevice();
+ var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
+ var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
+
+ // ── First draw: captures anchors at the full 12-entry layout. ─────
+ renderer.Begin(new Vector2(800f, 600f));
+ root.Draw(ctx);
+
+ // ── Remove the FIRST entry — every remaining button shifts down one
+ // slot, so this exercises the widest possible reflow. ─────────────
+ root.WindowManager.Unregister(handles[0].Name);
+ Assert.Equal(entryCount - 1, shelf.EntryCount);
+
+ // ── Second draw: must reflect the fresh 11-entry reflow. Pre-fix,
+ // each surviving button stays pinned at its ORIGINAL 12-entry
+ // position/size instead. ───────────────────────────────────────────
+ renderer.Begin(new Vector2(800f, 600f));
+ root.Draw(ctx);
+
+ List afterRemoval =
+ shelf.Children.OfType().ToList();
+ Assert.Equal(entryCount - 1, afterRemoval.Count);
+
+ // ── Reference: an independent shelf built directly with the SAME
+ // final 11-entry set (never having been through a 12-entry layout),
+ // ticked and drawn exactly once. ───────────────────────────────────
+ var referenceRoot = new UiRoot { Width = 800f, Height = 260f };
+ using var referenceShelf = new PluginSidePanel(
+ referenceRoot.WindowManager, _ => (0u, 0, 0), font: null);
+ referenceRoot.AddChild(referenceShelf);
+ for (int i = 1; i < entryCount; i++)
+ {
+ var frame = new UiPanel { Width = 200f, Height = 100f };
+ referenceRoot.AddChild(frame);
+ RetailWindowHandle handle = referenceRoot.WindowManager.Register(
+ $"plugin:acdream.reference:{i}", frame);
+ referenceShelf.Add(
+ new PluginUiOwner($"acdream.reference.{i}", $"Test Plugin {i}"),
+ new PluginPanelDescriptor("main", $"Test Plugin {i}"),
+ handle);
+ }
+ referenceRoot.Tick(0.016d, 16L);
+ var referenceDevice = new RecordingGpuDevice();
+ var referenceRenderer = new TextRenderer(referenceDevice, new NullGpuFrameSource(), "unused");
+ var referenceCtx = new UiRenderContext(referenceRenderer, new Vector2(800f, 600f));
+ referenceRenderer.Begin(new Vector2(800f, 600f));
+ referenceRoot.Draw(referenceCtx);
+
+ List reference =
+ referenceShelf.Children.OfType().ToList();
+ Assert.Equal(reference.Count, afterRemoval.Count);
+
+ for (int i = 0; i < afterRemoval.Count; i++)
+ {
+ Assert.Equal(reference[i].Left, afterRemoval[i].Left, 3);
+ Assert.Equal(reference[i].Top, afterRemoval[i].Top, 3);
+ Assert.Equal(reference[i].Width, afterRemoval[i].Width, 3);
+ Assert.Equal(reference[i].Height, afterRemoval[i].Height, 3);
+ }
+
+ Assert.Equal(shelf.Width, referenceShelf.Width, 3);
+ Assert.Equal(shelf.Height, referenceShelf.Height, 3);
+ }
}