# 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.