From e58be3f03052587efa8e134962c4ce03f647f3a8 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 20 Jun 2026 18:25:46 +0200 Subject: [PATCH] docs(D.2b): toolbar collapse-to-one-row design spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User request: the toolbar frame resizes vertically between one row (row 2 hidden, minimum) and two rows (shown), SNAPPING between the two stops; default expanded. Small toolkit feature: UiElement.MaxHeight + a ResizableEdges mask (bottom-edge-only) + a UiCollapsibleFrame (snaps height to the nearer stop and ties row-2 visibility to it in OnTick) + the GameWindow mount (compute the two heights from the layout, top-anchor the content so row 1 never reflows). Retail's real mechanism is keystone.dll (no decomp) + the dat stacks both rows always — so this is a toolkit UX from the user's retail observation; amends IA-17. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-20-d2b-toolbar-collapse-design.md | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md 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.