diff --git a/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md b/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md
new file mode 100644
index 00000000..92cd7f25
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md
@@ -0,0 +1,181 @@
+# 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.