diff --git a/src/AcDream.App/UI/PluginSidePanel.cs b/src/AcDream.App/UI/PluginSidePanel.cs
index 90060320b..300c0b1f4 100644
--- a/src/AcDream.App/UI/PluginSidePanel.cs
+++ b/src/AcDream.App/UI/PluginSidePanel.cs
@@ -75,10 +75,37 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
private const float ButtonGap = 4f;
private const float DefaultTop = 116f;
- /// Height of the top drag-grip band. Also the collapsed shelf's
- /// total height (only the grip remains when collapsed).
+ ///
+ /// Minimum/bitmap-fallback height of the top drag-grip band while
+ /// EXPANDED. Owner-reported defect (2026-09-06): with this literal
+ /// hard-coded regardless of font, the retail 16px-tall dat-font glyph
+ /// line box () overhangs a 12px band —
+ /// measured against the real DAT (font 0x40000000): the outline
+ /// (background/shadow) plane's inflated quad is clipped by 1px at the
+ /// top and 2px at the bottom of a 12px band, even though the FILL glyph
+ /// itself happens to fit (measured '<'/'>': OffsetY=4 Height=7
+ /// VerticalOffsetBefore=5 — the fill quad lands at local y=[3,10],
+ /// inside [0,12]; the border-inflated outline quad would span
+ /// y=[-1,14], clipped to [0,12]).
+ /// now derives the real band from the font's own metrics so neither
+ /// plane clips for any font, and this constant remains only as the
+ /// bitmap-font (no ) fallback.
private const float GripHeight = 12f;
+ ///
+ /// The actual EXPANDED grip/toggle band height for the current font: the
+ /// bitmap fallback () when there is no
+ /// , else max(GripHeight, font.LineHeight + 2)
+ /// so the whole-line-height glyph box (fill AND the border-inflated
+ /// outline plane) always fits with margin to spare. internal so
+ /// tests can derive expected coordinates from the real value instead of
+ /// re-hard-coding the constant this replaces (the shelf's own EXPANDED
+ /// band height is always this value, never
+ /// directly, once a dat font is supplied).
+ ///
+ internal float ExpandedGripBandHeight =>
+ _font is { } f ? MathF.Max(GripHeight, f.LineHeight + 2f) : GripHeight;
+
/// Width of the collapse-toggle button, anchored to the grip's
/// right end.
private const float ToggleWidth = 16f;
@@ -179,7 +206,7 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
_font = font;
Width = ButtonExtent + OuterPadding * 2f;
- Height = GripHeight + OuterPadding * 2f;
+ Height = ExpandedGripBandHeight + OuterPadding * 2f;
Top = DefaultTop;
Anchors = AnchorEdges.None;
// See the class doc for why this stays false: FindDragHandleWindow does
@@ -325,7 +352,7 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
// same as before Slice A.
float availableHeight = MathF.Max(
ButtonExtent + OuterPadding * 2f,
- parent.Height - Top - GripHeight - OuterPadding);
+ parent.Height - Top - ExpandedGripBandHeight - OuterPadding);
if (MathF.Abs(availableHeight - _lastLayoutHeight) > 0.5f)
{
_lastLayoutHeight = availableHeight;
@@ -580,18 +607,39 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
/// Repositions the grip/toggle children to span the current
/// Width — called whenever changes it (entries added/
/// removed, collapse toggled, row-wrap) so the toggle always sits flush
- /// against the shelf's right edge and the grip fills the rest of the band.
+ /// against the shelf's right edge and the grip fills the rest of the band.
+ ///
+ ///
+ /// Owner-reported defect fix (2026-09-06, "I dont see the < after I
+ /// minimize the window"): while COLLAPSED the band is
+ /// tall (matching an ordinary entry button) instead of the 12px grip band —
+ /// direct evidence (a real-DAT draw probe, see
+ /// PluginSidePanelToggleGlyphClipTests) showed the toggle glyph's FILL
+ /// plane was never actually erased by the 12px band's self-clip (it measured
+ /// well inside it), so a bare clip fix would not have addressed the report;
+ /// the real problem is that the collapsed shelf shrinks to a barely-visible
+ /// 24x12 near-black sliver at the screen edge — several times smaller than
+ /// every other clickable affordance in the UI. Growing the collapsed tab to
+ /// button size makes it findable again, and the toggle glyph fills that
+ /// taller band (vertically centered by the unchanged (Height -
+ /// dat.LineHeight) * 0.5f formula in ).
+ /// While EXPANDED the band uses (derived
+ /// from the font, not the bitmap-fallback constant) so neither the fill nor
+ /// the border-inflated outline glyph plane clips for any font metrics.
+ ///
+ ///
private void LayoutChrome()
{
+ float bandHeight = _collapsed ? ButtonExtent : ExpandedGripBandHeight;
_grip.Left = 0f;
_grip.Top = 0f;
_grip.Width = MathF.Max(0f, Width - ToggleWidth);
- _grip.Height = GripHeight;
+ _grip.Height = bandHeight;
_toggle.Left = Width - ToggleWidth;
_toggle.Top = 0f;
_toggle.Width = ToggleWidth;
- _toggle.Height = GripHeight;
+ _toggle.Height = bandHeight;
}
///
@@ -636,7 +684,7 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
int row = index % maximumRows;
entry.Button.Left = OuterPadding
+ column * (ButtonExtent + ButtonGap);
- entry.Button.Top = GripHeight + OuterPadding
+ entry.Button.Top = ExpandedGripBandHeight + OuterPadding
+ row * (ButtonExtent + ButtonGap);
entry.Button.Visible = !_collapsed;
index++;
@@ -647,15 +695,18 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
if (_collapsed)
{
+ // Findability fix: the collapsed tab is exactly one entry
+ // button's height, not the thin grip band — see LayoutChrome's
+ // doc comment for the owner-reported symptom this addresses.
Width = CollapsedWidth;
- Height = GripHeight;
+ Height = ButtonExtent;
}
else
{
Width = OuterPadding * 2f
+ columns * ButtonExtent
+ Math.Max(0, columns - 1) * ButtonGap;
- Height = GripHeight
+ Height = ExpandedGripBandHeight
+ OuterPadding * 2f
+ rows * ButtonExtent
+ Math.Max(0, rows - 1) * ButtonGap;
diff --git a/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs b/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
index 9105c07b5..579c8fe31 100644
--- a/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
+++ b/tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs
@@ -249,7 +249,7 @@ public sealed class PluginSidePanelTests
// NEW-1 (residual round, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
// Slice A): pins the actual Draggable:true -> false change 4fada238e made.
// A press on the shelf's own PADDING — left of the first entry button's
- // Left=4 (OuterPadding), below the 12px grip band — must never promote
+ // Left=4 (OuterPadding), below the grip band — must never promote
// to a whole-window drag now that Draggable is false; only the grip's
// WindowMoveHandle subtree can start a move (see the class doc). Proven
// to fail against the PRE-4fada238e shelf: temporarily flipping the
@@ -274,7 +274,12 @@ public sealed class PluginSidePanelTests
root.Tick(0.016d, 16L); // establishes the initial right-edge dock
int pressX = (int)shelf.Left + 2; // left of button.Left (4)
- int pressY = (int)shelf.Top + 20; // below the grip band (12)
+ // Below the grip band: derived from the shelf's own EXPANDED band
+ // height (owner-reported-defect fix, docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md)
+ // rather than a re-hard-coded literal — with font: null this is
+ // still the 12px bitmap-fallback band, so the numeric press point is
+ // unchanged, but the intent now survives a font change.
+ int pressY = (int)shelf.Top + (int)shelf.ExpandedGripBandHeight + 8;
float leftBefore = shelf.Left;
float topBefore = shelf.Top;
@@ -370,6 +375,46 @@ public sealed class PluginSidePanelTests
Assert.False(shelf.CaptureWindowState().Collapsed);
}
+ [Fact]
+ public void CollapseThenExpand_WhileStillDocked_ReturnsToTheIdenticalLeftAndTop()
+ {
+ // Owner-reported defect fix (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md):
+ // the collapsed tab's HEIGHT changed (button-sized, not the thin grip
+ // band) — confirms that change alone does not disturb the docked
+ // anchor math: collapsing shifts Left to preserve the right edge (a
+ // width change), and expanding back must return to the EXACT same
+ // Left/Top, not merely an equal Width.
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ using var shelf = new PluginSidePanel(
+ root.WindowManager, _ => (0u, 0, 0), font: null);
+ root.AddChild(shelf);
+ root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
+
+ var frame = new UiPanel { Width = 200f, Height = 100f };
+ root.AddChild(frame);
+ RetailWindowHandle pluginHandle = root.WindowManager.Register(
+ "plugin:acdream.test:main", frame);
+ shelf.Add(
+ new PluginUiOwner("acdream.test", "Test Plugin"),
+ new PluginPanelDescriptor("main", "Test Plugin"),
+ pluginHandle);
+ root.Tick(0.016d, 16L); // establishes the initial right-edge dock
+
+ float leftBefore = shelf.Left;
+ float topBefore = shelf.Top;
+
+ shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
+ // Width shrank while still docked, so Left shifted right to keep the
+ // right edge fixed; Top never moves on a collapse/expand.
+ Assert.NotEqual(leftBefore, shelf.Left);
+ Assert.Equal(topBefore, shelf.Top);
+
+ shelf.RestoreWindowState(new RetainedWindowState(Collapsed: false));
+
+ Assert.Equal(leftBefore, shelf.Left, precision: 3);
+ Assert.Equal(topBefore, shelf.Top, precision: 3);
+ }
+
[Fact]
public void ToggleVisibility_ShowHideShow_AndStaysHiddenWhenANewWindowRegisters()
{
diff --git a/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs b/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs
new file mode 100644
index 000000000..fee36108a
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/PluginSidePanelToggleGlyphClipTests.cs
@@ -0,0 +1,269 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.Rendering.Gpu;
+using AcDream.App.Tests.Rendering.Gpu;
+using AcDream.App.UI;
+using AcDream.Content;
+using AcDream.Plugin.Abstractions;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Options;
+using DatReaderWriter.Types;
+using SysEnv = System.Environment;
+
+namespace AcDream.App.Tests.UI;
+
+///
+/// Owner-reported defect (2026-09-06): "I dont see the < after I minimize
+/// the window" — after collapsing the plugin shelf with the >
+/// toggle, the < (expand) glyph was not visible.
+///
+///
+/// Reproduction findings (a real-DAT draw probe against font 0x40000000, the
+/// shelf's default font): '<' and '>' share IDENTICAL glyph metrics
+/// (OffsetY=4, Width=5, Height=7, VerticalOffsetBefore=5 — symmetric
+/// characters, as expected), so there is no per-glyph asymmetry to explain
+/// "I see one but not the other." Against the PRE-FIX 12px grip band
+/// (GripHeight), the FILL glyph plane measured fully INSIDE the band
+/// (local y=[3,10] of [0,12]) in both collapsed and expanded states — the
+/// text's "16px line box overhangs a 12px band" theory does not, on its own,
+/// erase the glyph. What IS measurably true: the border-inflated OUTLINE
+/// (background/shadow) plane, which retail draws first
+/// (UIElement_Text::DrawSelf), spans y=[-1,14] before clipping and was
+/// cropped by the 12px band's self-clip (UiElement.ClipsChildren) to
+/// exactly [0,12] — a real, if minor (1px top / 2px bottom), defect. The
+/// PRIMARY explanation for the report is discoverability, not erasure: the
+/// collapsed shelf shrank to a bare 24x12 near-black sliver at the screen
+/// edge, several times smaller than any other clickable affordance in the
+/// UI, easy to overlook even though its pixels were, in fact, being drawn.
+///
+///
+///
+/// Fix: derives the
+/// EXPANDED band from the real font metrics
+/// (max(12, font.LineHeight + 2)) so neither the fill nor the
+/// border-inflated outline plane can ever clip, for any font. The COLLAPSED
+/// tab is now ButtonExtent (28px) tall — the same size as an ordinary
+/// entry button — instead of the 12px grip band, making it findable. This
+/// file pins both: full containment of the measured ink (fill AND outline)
+/// inside the toggle's own clip rect in BOTH states against the REAL
+/// installed DAT font, and the collapsed tab's button-sized geometry.
+///
+///
+public sealed class PluginSidePanelToggleGlyphClipTests
+{
+ private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
+ {
+ public IGpuFrame? CurrentFrame => null;
+ }
+
+ private static string? ResolveDatDir()
+ {
+ string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
+ if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
+ return fromEnv;
+
+ string defaultDir = Path.Combine(
+ SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
+ "Documents", "Asheron's Call");
+ return Directory.Exists(defaultDir) ? defaultDir : null;
+ }
+
+ /// Decodes (x0,y0)/(x1,y1) of every quad (6 verts / 8 floats each)
+ /// in a recorded sprite segment — same 8-floats-per-vertex layout every
+ /// other draw-level test in this tree decodes
+ /// (UiRenderContextDrawStringDatOutlineTests.DecodeQuads).
+ private static IEnumerable<(float y0, float y1)> DecodeVerticalSpans(IReadOnlyList verts)
+ {
+ const int floatsPerVertex = 8;
+ const int floatsPerQuad = floatsPerVertex * 6;
+ for (int i = 0; i + floatsPerQuad <= verts.Count; i += floatsPerQuad)
+ {
+ float y0 = verts[i + 1];
+ float y1 = verts[i + 8 + 1];
+ yield return (y0, y1);
+ }
+ }
+
+ ///
+ /// Independently computes the FULL, unclipped vertical ink span (fill
+ /// plane union the border-inflated outline plane) that
+ /// UiRenderContext.DrawStringDatPass/DrawOutlineGlyph would
+ /// produce for a single centered glyph drawn at
+ /// (Height - font.LineHeight) * 0.5f (exactly
+ /// 's formula) in a box of the given
+ /// — mirroring the production math
+ /// (UiRenderContext.cs's baseY/gy/outline-inflation
+ /// lines) independently, so this test can tell a genuinely unclipped draw
+ /// apart from one whose recorded (already-clipped) geometry merely
+ /// happens to fill the band.
+ ///
+ private static (float top, float bottom) ComputeUnclippedInkSpan(
+ UiDatFont font, char glyph, float bandHeight)
+ {
+ Assert.True(font.TryGetGlyph(glyph, out FontCharDesc g), $"font is missing glyph '{glyph}'");
+
+ float y = (bandHeight - font.LineHeight) * 0.5f;
+ float baseY = MathF.Floor(y + 0.5f);
+ float gy = baseY + g.VerticalOffsetBefore;
+ float gh = g.Height;
+
+ float top = gy;
+ float bottom = gy + gh;
+ if (font.HasBackground)
+ {
+ float iy = gy - font.BorderY;
+ float ih = gh + 2f * font.BorderY;
+ top = MathF.Min(top, iy);
+ bottom = MathF.Max(bottom, iy + ih);
+ }
+ return (top, bottom);
+ }
+
+ [Fact]
+ [Trait("Lane", "InstalledDat")]
+ public void ToggleGlyph_InkFullyFitsInsideTheGripBand_ExpandedAndCollapsed()
+ {
+ string? datDir = ResolveDatDir();
+ if (datDir is null)
+ Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ using var adapter = new DatCollectionAdapter(dats);
+ var device = new RecordingGpuDevice();
+ var cache = new TextureCache(device, adapter);
+ UiDatFont? font = UiDatFont.Load(adapter, cache);
+ Assert.NotNull(font);
+
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font);
+ root.AddChild(shelf);
+ root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
+
+ var frame = new UiPanel { Width = 200f, Height = 100f };
+ root.AddChild(frame);
+ RetailWindowHandle pluginHandle = root.WindowManager.Register(
+ "plugin:acdream.test:main", frame);
+ shelf.Add(
+ new PluginUiOwner("acdream.test", "Test Plugin"),
+ new PluginPanelDescriptor("main", "Test Plugin"),
+ pluginHandle);
+
+ root.Tick(0.016d, 16L);
+
+ UiSimpleButton toggle = Assert.Single(
+ shelf.Children,
+ c => c is UiSimpleButton && c is not PluginSidePanel.PluginShelfButton) as UiSimpleButton
+ ?? throw new InvalidOperationException("toggle button not found");
+
+ var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
+ var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
+
+ // ── Expanded state ('>') ────────────────────────────────────────
+ Assert.False(shelf.CaptureWindowState().Collapsed);
+ AssertGlyphInkFullyContained(font!, '>', toggle, renderer, ctx);
+
+ // ── Collapse via the REAL toggle click (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);
+ root.Tick(0.016d, 16L);
+
+ AssertGlyphInkFullyContained(font!, '<', toggle, renderer, ctx);
+ }
+
+ private static void AssertGlyphInkFullyContained(
+ UiDatFont font, char glyph, UiSimpleButton toggle, TextRenderer renderer, UiRenderContext ctx)
+ {
+ (float expectedTop, float expectedBottom) = ComputeUnclippedInkSpan(font, glyph, toggle.Height);
+
+ Assert.True(
+ expectedTop >= -0.01f && expectedBottom <= toggle.Height + 0.01f,
+ $"'{glyph}': computed unclipped ink span [{expectedTop},{expectedBottom}] " +
+ $"does not fit inside the {toggle.Height}px band — the band is too short for this font's metrics " +
+ $"(LineHeight={font.LineHeight}, BorderY={font.BorderY}).");
+
+ // Draw the toggle IN ISOLATION (its own DrawSelfAndChildren call, no
+ // ancestor transform) so the recorded quads are in the toggle's own
+ // local [0,Width]x[0,Height] space and reflect only its own self-clip
+ // (UiElement.ClipsChildren's [0,Width]x[0,Height] push).
+ renderer.Begin(new Vector2(800f, 600f));
+ toggle.DrawSelfAndChildren(ctx);
+
+ float observedMinY = float.MaxValue, observedMaxY = float.MinValue;
+ int quadCount = 0;
+ foreach (var seg in renderer.DebugSpriteSegmentVerts)
+ {
+ foreach ((float y0, float y1) in DecodeVerticalSpans(seg.Verts))
+ {
+ observedMinY = MathF.Min(observedMinY, MathF.Min(y0, y1));
+ observedMaxY = MathF.Max(observedMaxY, MathF.Max(y0, y1));
+ quadCount++;
+ }
+ }
+
+ Assert.True(quadCount > 0, $"toggle drew no glyph quads at all for '{glyph}'");
+
+ // The recorded (already self-clipped) geometry must match the
+ // INDEPENDENTLY computed unclipped span within a pixel — if the band
+ // were too short, the recorded span would be narrower than the
+ // computed one (clipped away), not equal to it.
+ Assert.Equal(expectedTop, observedMinY, 1);
+ Assert.Equal(expectedBottom, observedMaxY, 1);
+
+ // And, restated directly in terms of the toggle's own clip rect (the
+ // form the investigation asked for): every recorded quad lies fully
+ // inside [0, toggle.Height].
+ Assert.True(observedMinY >= -0.01f, $"'{glyph}': ink top ({observedMinY}) is above the band (0)");
+ Assert.True(
+ observedMaxY <= toggle.Height + 0.01f,
+ $"'{glyph}': ink bottom ({observedMaxY}) overflows the {toggle.Height}px band");
+ }
+
+ [Fact]
+ [Trait("Lane", "InstalledDat")]
+ public void CollapsedTab_IsButtonSized_ForFindability()
+ {
+ string? datDir = ResolveDatDir();
+ if (datDir is null)
+ Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
+
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ using var adapter = new DatCollectionAdapter(dats);
+ var device = new RecordingGpuDevice();
+ var cache = new TextureCache(device, adapter);
+ UiDatFont? font = UiDatFont.Load(adapter, cache);
+ Assert.NotNull(font);
+
+ var root = new UiRoot { Width = 800f, Height = 600f };
+ using var shelf = new PluginSidePanel(root.WindowManager, _ => (0u, 0, 0), font);
+ root.AddChild(shelf);
+ root.WindowManager.Register(WindowNames.PluginShelf, shelf, shelf, controller: shelf);
+
+ var frame = new UiPanel { Width = 200f, Height = 100f };
+ root.AddChild(frame);
+ RetailWindowHandle pluginHandle = root.WindowManager.Register(
+ "plugin:acdream.test:main", frame);
+ shelf.Add(
+ new PluginUiOwner("acdream.test", "Test Plugin"),
+ new PluginPanelDescriptor("main", "Test Plugin"),
+ pluginHandle);
+ root.Tick(0.016d, 16L);
+
+ float expandedHeight = shelf.Height;
+
+ shelf.RestoreWindowState(new RetainedWindowState(Collapsed: true));
+
+ // The collapsed tab is button-sized, not the thin 12px grip band —
+ // this is the actual owner-reported fix (the tab is findable), not
+ // merely "the glyph is unclipped".
+ Assert.True(shelf.Height > 20f, $"collapsed shelf height ({shelf.Height}) is not button-sized");
+ Assert.NotEqual(expandedHeight, shelf.Height);
+ }
+}