# D.2b toolbar collapse-to-one-row — design **Date:** 2026-06-20 **Phase:** D.2b retail-UI, toolbar polish (follows D.5.3 B.1/B.2, both visually confirmed this session). **Branch:** `claude/hopeful-maxwell-214a12`. **Driver:** user request — the toolbar frame should resize vertically between **one row** (row 2 hidden, the minimum) and **two rows** (row 2 shown), **snapping** between the two stops. --- ## 1. Goal & non-goals **Goal.** The toolbar window can be collapsed to show only the top quickslot row (slots 1–9) or expanded to show both rows (slots 1–18), by dragging its **bottom edge**. The drag **snaps** to the nearer of two height stops — collapsed (row 2 hidden) or expanded (row 2 shown). Default = expanded (today's look). Horizontal size stays fixed; the window still moves by grabbing empty cells / chrome (IA-12). This is a toolkit UX defined from the user's retail observation — the real mechanism lives in `keystone.dll` (no decomp); our research notes the dat just stacks two always-present rows, so the dat encodes no collapse. Recorded as an amendment to **IA-17** (toolbar frame is toolkit-supplied). **Non-goals:** a collapse/expand BUTTON (it's a bottom-edge resize); horizontal resize; persisting the collapsed state across sessions (it resets to expanded each launch — persistence is the deferred window-manager Plan-2); animating the snap. --- ## 2. Geometry (from the layout, not hardcoded) The toolbar `LayoutDesc 0x21000016` root is **300×122**; the two rows are top `0x100001A7..AF` and bottom `0x100006B7..BF`, with the bottom row's slots at content-y ≈ 90 (deep-dive §2a table, slot 9 at `6,90`). Heights are computed at mount time from the actual layout, so there is no magic constant: - `border` = `RetailChromeSprites.Border` (5 px). - `ExpandedHeight` = `contentHeight + 2·border` (today's frame height; `contentHeight` = the imported root's `Height`, 122). - `CollapsedHeight` = `minRow2Top + 2·border`, where `minRow2Top` = the smallest `Top` among the nine resolved row-2 slot elements (`0x100006B7..BF`). That cuts the frame just above row 2. - `snapMidpoint` = `(CollapsedHeight + ExpandedHeight) / 2`. --- ## 3. Components ### 3.1 `UiElement` — `MaxHeight` + a `ResizableEdges` mask — `src/AcDream.App/UI/UiElement.cs` Today resize clamps to a minimum only and (with `ResizeY`) treats BOTH vertical edges as grips. Add two small generic members: ```csharp /// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight. public float MaxHeight { get; set; } = float.MaxValue; /// Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all. /// Set to e.g. ResizeEdges.Bottom to allow only a bottom-edge drag (the collapse toolbar). public ResizeEdges ResizableEdges { get; set; } = ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom; ``` (`MaxWidth` is YAGNI — only height needs it here.) - **`UiRoot.HitEdges`** applies the mask at the end: `e &= w.ResizableEdges;` (after the existing `ResizeX`/`ResizeY` masking). So a toolbar with `ResizableEdges = Bottom` only grips its bottom edge; a press near the top edge falls through to window-move, not resize. - **`UiRoot.ResizeRect`** gains a `maxH` parameter and clamps the Bottom/Top height branches: `h = Math.Clamp(startH + dy, minH, maxH)` (Bottom) and the Top branch likewise. `OnMouseMove`'s resize call passes `_resizeTarget.MaxHeight` (and `float.MaxValue` for the width's maxW). **This changes `ResizeRect`'s signature — update its existing callers + the `UiRootInputTests.ResizeRect_*` tests to pass the new `maxW`/`maxH` args** (`float.MaxValue` where unbounded, preserving their current assertions). ### 3.2 `UiCollapsibleFrame : UiNineSlicePanel` (new) — `src/AcDream.App/UI/UiCollapsibleFrame.cs` A toolbar-frame variant that snaps between two heights and toggles a set of "second-row" elements. One clear responsibility: reconcile its height to a stop and the rows to that stop, every tick. ```csharp public sealed class UiCollapsibleFrame : UiNineSlicePanel { public UiCollapsibleFrame(Func resolveChrome) : base(resolveChrome) { } public float CollapsedHeight { get; set; } public float ExpandedHeight { get; set; } /// Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed. public IReadOnlyList SecondRow { get; set; } = System.Array.Empty(); /// True when the frame is currently at (or nearer) the expanded stop. public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f; protected override void OnTick(double dt) { base.OnTick(dt); if (ExpandedHeight <= CollapsedHeight) return; // not configured yet // Snap to the nearer stop (the resize drag sets Height live; we resolve it to a stop so the // frame always rests collapsed or expanded — never a half-row). bool expanded = IsExpanded; Height = expanded ? ExpandedHeight : CollapsedHeight; // Row 2 is shown only when expanded. (No clipping needed — the dat content is top-anchored, // so row-2 slots simply stop drawing when hidden; row 1 never moves.) for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded; } } ``` Notes: - The snap runs in `OnTick` (after the frame's `MinHeight`/`MaxHeight`-clamped resize drag set `Height` that frame), so the rendered height is always a stop. With only two stops one row apart, this reads as: drag the bottom edge past the midpoint → it jumps to the other stop + row 2 appears/hides. - `IsExpanded`/the snap use the midpoint; `MinHeight`/`MaxHeight` (set by the mount) keep the drag within `[Collapsed, Expanded]` so the midpoint test is well-defined. ### 3.3 GameWindow toolbar mount — `src/AcDream.App/Rendering/GameWindow.cs` (~line 2045) - Build the frame as `UiCollapsibleFrame` instead of `UiNineSlicePanel` (same `ResolveChrome` ctor arg). - After the content (`toolbarRoot`) is sized: compute `expandedH = toolbarContentH + 2·border`, `collapsedH = minRow2Top + 2·border` where `minRow2Top` = `min` of the nine row-2 lists' `Top` (resolve each via `toolbarLayout.FindElement(0x100006B7..BF)`; reuse `ToolbarController`'s row-2 id list or inline the nine ids). - Set on the frame: `Resizable = true; ResizableEdges = ResizeEdges.Bottom` (bottom-edge only — top edge stays a move grip); `MinHeight = collapsedH; MaxHeight = expandedH; Height = expandedH` (default expanded); `CollapsedHeight = collapsedH; ExpandedHeight = expandedH; SecondRow = `. (`ResizeX`/`ResizeY` keep defaults; the `ResizableEdges = Bottom` mask is the operative restriction.) - Change `toolbarRoot.Anchors` from all-four-edges to **`Left | Top | Right`** (drop `Bottom`) so the dat content keeps its full height and row 1 never reflows when the frame collapses; row 2 hides via `Visible`. (Width is fixed — `ResizeX=false` — so the horizontal anchors are inert but harmless.) --- ## 4. Behavior walk-through - **Launch:** frame at `ExpandedHeight`, both rows visible (unchanged from today). - **Collapse:** grab the bottom edge, drag up past the midpoint → `OnTick` snaps `Height` to `CollapsedHeight` and hides the nine row-2 slots. The frame is now a single-row bar; row 1 unchanged. - **Expand:** drag the bottom edge down past the midpoint → snaps to `ExpandedHeight`, row 2 reappears. - **Move:** unchanged — drag an empty cell / chrome to reposition (IA-12); occupied cells drag items (B.1/B.2). - **Edge cases:** `MinHeight`/`MaxHeight` clamp the drag to `[Collapsed, Expanded]`; the snap is idempotent when not dragging (Height already at a stop). The collapsed state is per-session (resets to expanded on relaunch). --- ## 5. Divergence register **Amend IA-17** (toolbar window FRAME is toolkit-supplied): add that the frame also supports a toolkit-defined **collapse-to-one-row** (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop). Retail's real collapse mechanism is keystone.dll (no decomp) and the dat encodes no collapse (both rows always present) — so this is our toolkit UX from the user's retail observation, same justification class as the rest of IA-17. No new row; extend IA-17's text + cite this spec. --- ## 6. Testing `tests/AcDream.App.Tests/UI/`: 1. `UiCollapsibleFrame.OnTick` snap: set `CollapsedHeight=96`, `ExpandedHeight=128`; set `Height` just below the midpoint (e.g. 100) + tick → `Height == 96` and every `SecondRow` element `Visible==false`; set `Height` just above (e.g. 120) + tick → `Height == 128` and `SecondRow` `Visible==true`. 2. `UiCollapsibleFrame` not-configured guard: `ExpandedHeight==CollapsedHeight==0` → `OnTick` is a no-op (no divide/no forced height). 3. `UiRoot.ResizeRect` MaxHeight clamp: a Bottom-edge resize with `dy` huge clamps `h` to `maxH`; a Top-edge resize likewise; min still honored. (Drive via the existing `ResizeRect` static test pattern in `UiRootInputTests`; also update the two pre-existing `ResizeRect_*` tests to the new `maxW`/`maxH` signature — pass `float.MaxValue`, assertions unchanged.) 4. `UiRoot.HitEdges` honors `ResizableEdges`: a panel with `ResizableEdges = ResizeEdges.Bottom` returns only `Bottom` when pressed near its bottom edge, and `None` near its top edge (which would otherwise be a grip with `ResizeY` true). A `UiCollapsibleFrame` needs a chrome resolver in tests — pass `_ => (1u,1,1)` (the existing `UiNineSlicePanel` test pattern); `OnTick` doesn't draw, so no GL. --- ## 7. Acceptance - [ ] `dotnet build` + `dotnet test` green. - [ ] IA-17 amended. - [ ] **Visual (user):** default shows both rows; dragging the toolbar's **bottom edge up** snaps it to a single row (row 2 gone); dragging **down** snaps back to two rows; row 1 never moves/squishes; the window still moves by dragging empty cells/chrome; item drag (B.1/B.2) still works. --- ## 8. Plan size One small task (TDD): `UiElement.MaxHeight` + `ResizeRect` clamp → `UiCollapsibleFrame` + its tests → the GameWindow mount swap + IA-17 amend. ~3 files + 1 test file. Suitable for a single implementer pass.