acdream/docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md
Erik 7aba6d235c docs(D.2b): inventory window finish (Stage 1) implementation plan
6 tasks: (1) UiItemList clip+scroll via the shared UiScrollable + whole-row
clip; (2) mouse-wheel; (3) InventoryController binds the gutter scrollbar
0x100001C7 like ChatWindowController; (4) side-bag column 36px pitch + empty-
slot padding to capacity; (5) backdrop coverage — screenshot-gated, primary
fix is the clip; (6) verify + bookkeeping. TDD on the pure/controller logic
(internals test-visible); backdrop is the visual gate. Grounded in the dat
dumps + the existing scroll/scrollbar machinery.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:15:48 +02:00

25 KiB
Raw Blame History

D.2b inventory window finish (Stage 1) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Make the inventory window match retail's 2D presentation — the contents grid clips + scrolls, the backdrop covers the whole window, and the side-bag column shows proper slots.

Architecture: Reuse the existing UiScrollable scroll model (the chat transcript + UiScrollbar already use it) by giving UiItemList a clip+scroll capability (cells positioned offset by -ScrollY, cell.Visible toggled by whole-row clip — the same whole-row approach UiText uses, since there is no GL scissor). InventoryController binds the dat gutter scrollbar 0x100001C7 to the grid's model exactly like ChatWindowController does, splits the cell pitch (contents 32px / backpack 36px), and pads the side-bag column with empty slots. The backdrop is mostly fixed for free by clipping the grid; any residual is a screenshot-gated render fix.

Tech Stack: C# / .NET 10, xUnit. src/AcDream.App/UI/. Spec: docs/superpowers/specs/2026-06-21-d2b-inventory-window-finish-design.md. Grounding (dat geometry): contents grid 0x100001C6 = 192×96 (6×3, 32px cells); gutter scrollbar 0x100001C7 = 16×96; side-bag column 0x100001CA = 36×252 (7 slots of 36px); backdrop 0x100001D0 = 300×362 full-window.

Reuse references (read before coding):

  • src/AcDream.App/UI/UiScrollable.cs — the scroll model (ContentHeight/ViewHeight/ScrollY/ScrollByLines/etc.). Already unit-tested; do NOT re-test it.
  • src/AcDream.App/UI/UiText.cs:117-247 — the whole-row clip (y < top || y+lh > bottom → skip, line 198) + the wheel handler (OnEvent Scroll case, line 239) to mirror.
  • src/AcDream.App/UI/Layout/ChatWindowController.cs:44-49 (scrollbar sprite-id constants) + :237-255 (the FindElement → bar.Model = …Scroll + sprite ids binding pattern).
  • src/AcDream.App/UI/UiItemList.cs + src/AcDream.App/UI/Layout/InventoryController.cs — the files being changed.
  • tests/AcDream.App.Tests/UI/UiItemListTests.cs + .../UI/Layout/InventoryControllerTests.cs — test style + the BuildLayout/Bind/SeedContained harness.

Every commit message MUST end with: Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>


Task 1: UiItemList — scroll model + scroll-aware clip layout

Give the grid a UiScrollable and make LayoutCells clip whole rows + offset by the scroll position. Fill mode (the toolbar single-cell, CellWidth <= 0) is unchanged.

Files:

  • Modify: src/AcDream.App/UI/UiItemList.cs

  • Test: tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs (create)

  • Step 1: Write the failing tests

Create tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs:

using AcDream.App.UI;
using Xunit;

namespace AcDream.App.Tests.UI;

public sealed class UiItemListScrollTests
{
    private static UiItemList Grid(int items)
    {
        var list = new UiItemList { Columns = 6, CellWidth = 32, CellHeight = 32, Width = 192, Height = 96 };
        list.Flush();                                   // drop the default single cell
        for (int i = 0; i < items; i++) list.AddItem(new UiItemSlot());
        list.LayoutCells();                             // drive the scroll model + position cells
        return list;
    }

    [Fact]
    public void RowCount_ceils_to_whole_rows()
    {
        Assert.Equal(0, UiItemList.RowCount(0, 6));
        Assert.Equal(1, UiItemList.RowCount(1, 6));
        Assert.Equal(7, UiItemList.RowCount(41, 6));
    }

    [Fact]
    public void Grid_drives_scroll_model_from_content()
    {
        var list = Grid(30);                            // 5 rows × 32 = 160 content, 96 view
        Assert.Equal(160, list.Scroll.ContentHeight);
        Assert.Equal(96, list.Scroll.ViewHeight);
        Assert.Equal(64, list.Scroll.MaxScroll);
        Assert.True(list.Scroll.HasOverflow);
    }

    [Fact]
    public void At_top_first_three_rows_visible_rest_clipped()
    {
        var list = Grid(30);
        Assert.True(list.GetItem(0)!.Visible);          // row 0, top 0
        Assert.True(list.GetItem(12)!.Visible);         // row 2, top 64 (+32 == 96)
        Assert.False(list.GetItem(18)!.Visible);        // row 3, top 96 (off the bottom)
    }

    [Fact]
    public void Scrolled_one_row_shifts_window_and_clips_top_row()
    {
        var list = Grid(30);
        list.Scroll.SetScrollY(32);
        list.LayoutCells();
        Assert.False(list.GetItem(0)!.Visible);         // row 0 scrolled above
        Assert.Equal(0f, list.GetItem(6)!.Top);         // row 1 now at the view top
        Assert.True(list.GetItem(18)!.Visible);         // row 3 now visible
    }
}
  • Step 2: Run to verify it fails

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests" Expected: FAIL — UiItemList has no Scroll, no RowCount, LayoutCells is private.

  • Step 3: Implement

In src/AcDream.App/UI/UiItemList.cs:

(a) add using System; if missing, and the scroll model field after _cells:

    /// <summary>Vertical scroll model for grid mode (clip+scroll). Bound to the gutter
    /// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell).</summary>
    public UiScrollable Scroll { get; } = new();

(b) add the row-count helper (next to CellOffset):

    /// <summary>Whole rows needed for <paramref name="cellCount"/> cells in a grid of
    /// <paramref name="columns"/> columns.</summary>
    public static int RowCount(int cellCount, int columns)
    {
        int cols = columns < 1 ? 1 : columns;
        return (cellCount + cols - 1) / cols;
    }

(c) replace the whole private void LayoutCells() body with this (and change privateinternal so tests can drive it):

    internal void LayoutCells()
    {
        if (CellWidth <= 0f)
        {
            // Fill mode (the toolbar single cell): size the one cell to the list.
            if (_cells.Count > 0)
            {
                var c = _cells[0];
                c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; c.Visible = true;
            }
            return;
        }

        int cols = Columns < 1 ? 1 : Columns;
        int cellH = (int)MathF.Round(CellHeight);

        // Drive the shared scroll model from the current geometry, then re-clamp the
        // offset to the (possibly changed) max. ContentHeight/ViewHeight must be set
        // BEFORE reading ScrollY so the clamp uses the right max.
        Scroll.LineHeight = cellH > 0 ? cellH : 1;
        Scroll.ContentHeight = RowCount(_cells.Count, cols) * cellH;
        Scroll.ViewHeight = (int)MathF.Floor(Height);
        Scroll.SetScrollY(Scroll.ScrollY);          // re-clamp to new max
        float scrollY = Scroll.ScrollY;

        for (int i = 0; i < _cells.Count; i++)
        {
            int col = i % cols, row = i / cols;
            float top = row * CellHeight - scrollY;
            var cell = _cells[i];
            cell.Left = col * CellWidth;
            cell.Top = top;
            cell.Width = CellWidth;
            cell.Height = CellHeight;
            // Whole-row vertical clip (no scissor — mirrors UiText.cs:198). A row fully
            // inside [0, Height] draws; a partially-scrolled row is hidden.
            cell.Visible = top >= -0.5f && top + CellHeight <= Height + 0.5f;
        }
    }
  • Step 4: Run to verify it passes

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests" Expected: PASS (4 tests).

  • Step 5: Run the existing UiItemList tests (no regression)

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemList" Expected: PASS (the scroll tests + the pre-existing UiItemListTests/UiItemListGridTests; fill mode + grid offsets unchanged for non-scrolled grids).

  • Step 6: Commit
git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs
git commit -F - <<'EOF'
feat(ui): D.2b inventory finish — UiItemList clip+scroll via UiScrollable

Grid mode now drives a shared UiScrollable from its content + clips whole rows
to the panel (cells offset by -ScrollY, Visible toggled by whole-row clip,
mirroring UiText). Fill mode (toolbar single cell) unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF

Task 2: UiItemList — mouse-wheel scroll

Wheel over the grid scrolls it, mirroring UiText's Scroll handler.

Files:

  • Modify: src/AcDream.App/UI/UiItemList.cs

  • Test: tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs (append)

  • Step 1: Write the failing test

First open src/AcDream.App/UI/UiText.cs around line 239 to confirm the UiEvent Scroll shape (e.Data0 = wheel delta). Then append to UiItemListScrollTests.cs:

    [Fact]
    public void Wheel_down_scrolls_toward_the_bottom()
    {
        var list = Grid(30);                            // max scroll 64, LineHeight 32
        // Silk wheel +Y = up/older; UiText negates Data0. Wheel DOWN (Data0 < 0) → +ScrollY.
        var e = new UiEvent { Type = UiEventType.Scroll, Data0 = -1f };
        bool handled = list.OnEvent(e);
        Assert.True(handled);
        Assert.Equal(32, list.Scroll.ScrollY);          // one line (32px) down
    }

(If UiEvent's field types differ — e.g. Data0 is int — match them; the construction style is the only thing to align, the assertion stands.)

  • Step 2: Run to verify it fails

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests.Wheel" Expected: FAIL — UiItemList doesn't override OnEvent (wheel ignored, ScrollY stays 0).

  • Step 3: Implement

In src/AcDream.App/UI/UiItemList.cs add an OnEvent override (mirror UiText.cs:239):

    public override bool OnEvent(in UiEvent e)
    {
        if (e.Type == UiEventType.Scroll && CellWidth > 0f)
        {
            // Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0.
            Scroll.ScrollByLines(-(int)e.Data0);
            return true;
        }
        return base.OnEvent(e);
    }
  • Step 4: Run to verify it passes

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests" Expected: PASS (5 tests).

  • Step 5: Commit
git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs
git commit -F - <<'EOF'
feat(ui): D.2b inventory finish — UiItemList mouse-wheel scroll

OnEvent handles the Scroll wheel in grid mode (mirrors UiText's sign), driving
the shared UiScrollable. The bound gutter scrollbar (Task 3) is the primary
scroll affordance; the wheel is the convenience path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF

NOTE for the implementer: the wheel only fires if a Scroll event reaches the list (directly hovered, or bubbled from a cell that doesn't consume Scroll). UiItemSlot does not handle Scroll, so it should bubble — but confirm at the visual gate (Task 5). If the wheel doesn't reach the list, the scrollbar (Task 3) still works; file a follow-up rather than reworking event dispatch here.


Task 3: InventoryController — bind the contents-grid gutter scrollbar

Bind 0x100001C7 (a factory-built Type-11 UiScrollbar) to the contents grid's scroll model, with the same sprite ids the chat scrollbar uses (both inherit base layout 0x2100003E).

Files:

  • Modify: src/AcDream.App/UI/Layout/InventoryController.cs

  • Test: tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs (extend BuildLayout + add a test)

  • Step 1: Write the failing test

In InventoryControllerTests.cs: (a) add the scrollbar id constant near the others (private const uint ContentsScrollbar = 0x100001C7u;); (b) extend BuildLayout to create + register + return a UiScrollbar. Change its signature/return tuple to append UiScrollbar scrollbar:

        var scrollbar   = new UiScrollbar { Width = 16, Height = 96 };
        // ... after the existing root.AddChild(...) calls:
        root.AddChild(scrollbar);
        // ... in the byId dictionary initializer add:
        //   [ContentsScrollbar] = scrollbar,
        // ... and append scrollbar to the returned tuple.

Then add the test:

    [Fact]
    public void Contents_grid_scrollbar_binds_to_the_grid_scroll_model()
    {
        var (layout, grid, _, _, _, _, _, _, scrollbar) = BuildLayout();
        Bind(layout, new ClientObjectTable());
        Assert.Same(grid.Scroll, scrollbar.Model);      // the bar drives the grid's scroll
    }

(Update the other BuildLayout() call sites' tuple deconstruction to add a trailing _ for the new element.)

  • Step 2: Run to verify it fails

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests.Contents_grid_scrollbar" Expected: FAIL — scrollbar.Model is null (controller doesn't bind it).

  • Step 3: Implement

In src/AcDream.App/UI/Layout/InventoryController.cs:

(a) add the element-id + sprite-id constants (sprite ids copied from ChatWindowController.cs:44-49 — both scrollbars inherit 0x2100003E):

    public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar
    // Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar).
    private const uint ScrollTrackSprite  = 0x06004C5Fu;
    private const uint ScrollThumbSprite  = 0x06004C63u;
    private const uint ScrollThumbTop     = 0x06004C60u;
    private const uint ScrollThumbBot     = 0x06004C66u;
    private const uint ScrollUpSprite     = 0x06004C6Cu;
    private const uint ScrollDownSprite   = 0x06004C69u;

(b) in the constructor, after the _contentsGrid setup block (the if (_contentsGrid is not null) { … } around line 63-68), bind the scrollbar:

        // Bind the gutter scrollbar to the contents grid's scroll model (the factory built
        // 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does).
        if (_contentsGrid is not null
            && layout.FindElement(ContentsScrollbarId) is UiScrollbar bar)
        {
            bar.Model          = _contentsGrid.Scroll;
            bar.SpriteResolve  = _contentsGrid.SpriteResolve;   // chrome resolve from the factory ctor
            bar.TrackSprite    = ScrollTrackSprite;
            bar.ThumbSprite    = ScrollThumbSprite;
            bar.ThumbTopSprite = ScrollThumbTop;
            bar.ThumbBotSprite = ScrollThumbBot;
            bar.UpSprite       = ScrollUpSprite;
            bar.DownSprite     = ScrollDownSprite;
        }
  • Step 4: Run to verify it passes

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests" Expected: PASS (the new test + all pre-existing InventoryController tests, incl. the B-Wire burden tests).

  • Step 5: Commit
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
git commit -F - <<'EOF'
feat(ui): D.2b inventory finish — bind contents-grid gutter scrollbar

InventoryController binds 0x100001C7 (factory Type-11 UiScrollbar) to the
contents grid's UiScrollable + the shared 0x2100003E scrollbar sprites,
mirroring ChatWindowController. The grid now scrolls instead of overflowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF

Task 4: InventoryController — side-bag column (36px pitch + empty-slot padding)

The side-bag column should show the player's bags + empty slot frames up to capacity, at the correct 36px pitch.

Files:

  • Modify: src/AcDream.App/UI/Layout/InventoryController.cs

  • Test: tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs (append)

  • Step 1: Write the failing test

Append to InventoryControllerTests.cs:

    [Fact]
    public void Side_bag_column_pads_empty_slots_up_to_capacity()
    {
        var (layout, _, containers, _, _, _, _, _, _) = BuildLayout();
        var objects = new ClientObjectTable();
        objects.AddOrUpdate(new ClientObject { ObjectId = Player, ContainersCapacity = 3 });
        SeedContained(objects, 0xC, Player, slot: 0, type: ItemType.Container);  // one side bag

        Bind(layout, objects);

        Assert.Equal(3, containers.GetNumUIItems());        // 1 bag + 2 empty = capacity 3
        Assert.Equal(0xCu, containers.GetItem(0)!.ItemId);  // the bag
        Assert.Equal(0u,  containers.GetItem(1)!.ItemId);   // empty frame
        Assert.Equal(0u,  containers.GetItem(2)!.ItemId);   // empty frame
    }
  • Step 2: Run to verify it fails

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests.Side_bag_column" Expected: FAIL — only 1 cell (the bag); no empty padding.

  • Step 3: Implement

In src/AcDream.App/UI/Layout/InventoryController.cs:

(a) split the cell pitch — replace the single private const float CellPx = 32f; with:

    private const float ContentsCellPx = 32f;   // gm3DItemsUI grid (192x96 = 6x3 of 32px)
    private const float BackpackCellPx = 36f;   // gmBackpackUI column cells (0x100001C9/CA = 36px)
    private const int   SideBagSlots   = 7;     // 0x100001CA is 36x252 = 7 slots

Update the constructor's three cell-pitch assignments: _contentsGrid uses ContentsCellPx (was CellPx); _containerList and _topContainer use BackpackCellPx.

(b) in Populate(), after the foreach (var guid in contents) loop that adds side bags to _containerList, pad it with empty slots. Find where the loop ends (before the _topContainer block) and add:

        // Side-bag column: pad with empty slot frames up to the player's container
        // capacity (clamped to the 7-slot column), so the column reads like retail
        // (bags on top, empty frames below) rather than one lone cell. Divergence AP-XX
        // if capacity is absent → default to the full 7-slot column.
        if (_containerList is not null)
        {
            int capacity = _objects.Get(p)?.ContainersCapacity ?? 0;
            int slots = capacity > 0 ? capacity : SideBagSlots;
            slots = Math.Clamp(slots, _containerList.GetNumUIItems(), SideBagSlots);
            while (_containerList.GetNumUIItems() < slots)
                _containerList.AddItem(new UiItemSlot { SpriteResolve = _containerList.SpriteResolve });
        }

(Place this AFTER the foreach that adds bags and BEFORE the if (_topContainer is not null) block. The _contentsGrid/_containerList were already Flush()ed at the top of Populate, so the count here is exactly the bags added this pass.)

  • Step 4: Run to verify it passes

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests" Expected: PASS (all InventoryController tests).

  • Step 5: Commit
git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs
git commit -F - <<'EOF'
feat(ui): D.2b inventory finish — side-bag column slots (36px pitch + empties)

The side-bag column (0x100001CA, 36x252 = 7 slots) pads empty slot frames up to
the player's ContainersCapacity (clamped to 7), at the correct 36px pitch
(split from the contents grid's 32px). Reads like retail's bag column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF

Task 5: Backdrop coverage — visual gate + conditional residual fix

The backdrop 0x100001D0 is already full-window (300×362); the tear is the unclipped grid overflowing below it, which Tasks 1+3 fix. This task verifies that at the running client and fixes any residual.

Files:

  • Modify (only if residual gap found): src/AcDream.App/UI/UiDatElement.cs (Alphablend draw) — exact change determined by the root cause.

  • Step 1: Build + launch

Run: dotnet build Expected: Build succeeded. Then launch the client against ACE with ACDREAM_RETAIL_UI=1 (per CLAUDE.md "Running the client"), get in-world with a full pack, press F12.

  • Step 2: Observe the backdrop

With the grid now clipped to 3 rows + scrollable, check the dark backdrop covers the whole inventory window (no grass/world showing through anywhere) — compare to retail screenshot 2.

  • Step 3: If fully covered — done. Record the observation; no code change. Skip to Task 6.

  • Step 4: If a residual gap remains — root-cause then fix. Most likely candidate: UiDatElement draws the Alphablend backdrop sprite (0x06004D0A) at native size instead of stretched/tiled to the element's 300×362 bounds. Open src/AcDream.App/UI/UiDatElement.cs, find the Alphablend DrawMode draw path, and confirm whether it fills the element rect. If it draws at native sprite size, change it to fill the element bounds (stretch or tile, matching how the vitals chrome center fill tiles — see UiNineSlicePanel). Add a divergence row if the fill differs from retail's exact draw. Re-launch + re-verify. (Do not change the draw speculatively — only if Step 2 shows a gap.)

  • Step 5: Commit (only if a fix was made)

git add src/AcDream.App/UI/UiDatElement.cs
git commit -F - <<'EOF'
fix(ui): D.2b inventory finish — backdrop fills the full window

<describe the root cause found at the visual gate>

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF

Task 6: Full verification + bookkeeping

Files:

  • Modify: docs/ISSUES.md (close the contents-grid-overflow issue), docs/architecture/retail-divergence-register.md (add the side-bag-capacity-fallback row + any backdrop row), claude-memory/project_d2b_retail_ui.md + MEMORY.md (Stage 1 shipped entry).

  • Step 1: Full build + test

Run: dotnet build then dotnet test Expected: all green (App count = the B-Wire baseline 534 + the new Stage-1 tests; Core/Core.Net/UI unchanged).

  • Step 2: Close the overflow issue + add divergence rows

In docs/ISSUES.md: move the "Inventory 'Contents of Backpack' grid overflows (no scroll)" issue to DONE with the commit SHAs (Tasks 1-3 fixed it). In docs/architecture/retail-divergence-register.md: add a row for the side-bag slot count using the 7-slot fallback when ContainersCapacity is absent (replace the AP-XX placeholder in the Task-4 comment with the real id). Add a backdrop row only if Task 5 made a fill change that differs from retail.

  • Step 3: Update memory

In claude-memory/project_d2b_retail_ui.md: add a Stage-1 SHIPPED entry (UiItemList clip+scroll via UiScrollable; gutter scrollbar 0x100001C7 bound like chat; side-bag column 36px + empty padding; backdrop coverage outcome). Update the MEMORY.md index line. Note Stage 2 (paperdoll) is next.

  • Step 4: Commit
git add docs/ISSUES.md docs/architecture/retail-divergence-register.md
git commit -F - <<'EOF'
docs(D.2b): inventory window finish (Stage 1) shipped — close overflow issue

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
EOF

Post-implementation: visual gate

After Task 6, Stage 1 is code-complete. The acceptance is visual (project model): open F12 with a full pack → scrollable 3-row grid with a working gutter scrollbar, solid backdrop edge-to-edge, side-bag column with bag(s) + empty frames — matching retail screenshot 2 minus the doll. Report the observation. Then Stage 2 (paperdoll) gets its own brainstorm → spec → plan.


Self-review notes (coverage vs. spec)

  • A (contents-grid clip+scroll) → Tasks 1 (clip layout) + 2 (wheel) + 3 (scrollbar binding). Reuses UiScrollable per decision §4.
  • B (backdrop coverage) → Task 5 (screenshot-gated; primary fix is A's clip per spec §B / decision §4).
  • C (side-bag column) → Task 4 (36px pitch + empty padding up to capacity per decision §4).
  • Testing → per-task unit tests (UiItemList scroll/wheel pure-ish via internal LayoutCells; controller scrollbar-bound + side-bag count via the BuildLayout harness); backdrop is visual-gate only per spec §5.
  • Divergence → side-bag capacity fallback row in Task 6; backdrop row only if Task 5 changes the draw.
  • Out of scope (paperdoll, B-Drag, side-bag scrollbar 0x100001CB, stack overlays) — untouched.
  • Type consistency: UiItemList.Scroll (UiScrollable) / RowCount / internal LayoutCells defined in Task 1 and consumed in Tasks 2-3; ContentsCellPx/BackpackCellPx/SideBagSlots defined + used in Task 4; scrollbar Model/sprite setters match UiScrollbar's public API.