acdream/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md
Erik 34e79096f3 docs(D.2b): widget-generalization implementation plan
8-task TDD plan: chat golden fixture + resolved-Type conformance (Task 1,
empirically resolves the input's Type), then one-widget-per-commit migration —
UiScrollbar(11), UiButton(1), UiMenu(6), UiText(12)+the Type-12 flip,
UiField(3) — then thin the controller (Task 7, visual gate) and the gated
vitals UiText rewire (Task 8). Each task: failing test, register in the
factory switch, controller find-by-id binding, build+test green, commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:47:32 +02:00

52 KiB
Raw Blame History

D.2b Widget Generalization 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: Refactor the hand-named chat widgets and the Send/Max-Min click-wiring into generic, Type-registered widgets built by DatWidgetFactory, collapsing the controllers to a thin retail gm*UI::PostInit-style find-by-id binder.

Architecture: DatWidgetFactory.Create grows a faithful switch(Type) registering the real retail UIElement classes (Button=1, Field=3, Menu=6, Meter=7, Scrollbar=11, Text=12) as generic widgets; everything else stays UiDatElement. The importer's base-chain Type resolution already surfaces each element's real Type, so this is a registration task. The chat-specific knowledge (channel list, colors, command routing) moves out of widgets into ChatWindowController. Migrate one widget per commit; chat stays visually identical through Tasks 27; vitals is rewired last (Task 8) behind a visual gate.

Tech Stack: C# / .NET 10, xUnit, DatReaderWriter (Chorizite), Silk.NET (GL/input). Retail oracle: docs/research/named-retail/acclient_2013_pseudo_c.txt.

Spec: docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md.


Conventions

  • Repo root = the worktree dir. All paths below are relative to it.
  • Build: dotnet build (builds AcDream.slnx). Must be green before every commit.
  • Test (all UI): dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj
  • Test (filtered): add --filter "FullyQualifiedName~<ClassName>".
  • Commit style: feat(D.2b): <widget> — <what> / test(D.2b): … / refactor(D.2b): …, ending with the project's Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> trailer.
  • Every generic widget cites its retail class + RegisterElementClass line in a doc comment (per spec §8).
  • Divergence register: docs/architecture/retail-divergence-register.md — amend AP-37 / re-check AP-41 in the same commit that lands the relevant widget (per spec §7).

File Structure

Created:

  • src/AcDream.App/UI/UiButton.cs — generic Type-1 button (Task 3).
  • src/AcDream.App/UI/UiText.cs — generic Type-12 scrollable colored-line text (rename of UiChatView, Task 5).
  • src/AcDream.App/UI/UiField.cs — generic Type-3 editable one-line field (rename of UiChatInput, Task 6).
  • src/AcDream.App/UI/UiScrollbar.cs — generic Type-11 scrollbar (rename of UiChatScrollbar, Task 2).
  • src/AcDream.App/UI/UiMenu.cs — generic Type-6 dropdown menu (genericized UiChannelMenu, Task 4).
  • tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json — golden resolved chat tree (Task 1).
  • tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs — skip-by-default fixture generator (Task 1).
  • tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs — resolved-tree + factory-class conformance (Task 1, grown per widget).
  • tests/AcDream.App.Tests/UI/UiButtonTests.cs (Task 3).

Renamed (git mv + class/namespace-internal rename):

  • UiChatScrollbar.csUiScrollbar.cs; UiChatScrollbarTests.csUiScrollbarTests.cs (Task 2).
  • UiChatView.csUiText.cs; UiChatViewTests.csUiTextTests.cs; UiChatViewDatFontTests.csUiTextDatFontTests.cs (Task 5).
  • UiChatInput.csUiField.cs; UiChatInputTests.csUiFieldTests.cs (Task 6).
  • UiChannelMenu.csUiMenu.cs; UiChannelMenuTests.csUiMenuTests.cs (Task 4).

Modified:

  • src/AcDream.App/UI/Layout/DatWidgetFactory.cs — the switch(Type) + BuildButton/BuildMenu/BuildText/BuildField/BuildScrollbar (Tasks 26).
  • src/AcDream.App/UI/Layout/ChatWindowController.cs — construction → find-by-id binding; channel-item population (Tasks 27).
  • src/AcDream.App/UI/Layout/VitalsController.cs — bind UiText numbers (Task 8).
  • src/AcDream.App/Rendering/GameWindow.cs — only property-type follow-through (.Transcript/.Input types change) if needed (Tasks 56).
  • tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs — new per-Type asserts; flip the two Type-12 tests (Tasks 26).
  • tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs — add LoadChat() (Task 1).

Task 1: Chat golden fixture + conformance test (also resolves the input's Type empirically)

Files:

  • Create: tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs
  • Create: tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json (generated, committed)
  • Modify: tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
  • Create: tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs

The generator runs once against the live dat (it is [Fact(Skip=…)] so CI never runs it). The committed JSON is dat-free, like vitals_2100006C.json. The fixture's resolved Type per element answers spec verification #1 (does input 0x10000016 resolve to 3 or 12?).

  • Step 1: Write the generator (skip-by-default).

ChatLayoutFixtureGenerator.cs:

using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;

namespace AcDream.App.Tests.UI.Layout;

/// <summary>
/// One-off generator for the committed chat golden fixture. Skipped by default —
/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate
/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made.
/// </summary>
public class ChatLayoutFixtureGenerator
{
    [Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")]
    public void GenerateChatFixture()
    {
        var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
            ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
                            "Documents", "Asheron's Call");
        using var dats = new DatCollection(datDir, DatAccessType.Read);
        var info = LayoutImporter.ImportInfos(dats, 0x21000006u);
        Assert.NotNull(info);

        var json = JsonSerializer.Serialize(info, new JsonSerializerOptions
        {
            IncludeFields = true,
            WriteIndented = true,
        });
        File.WriteAllText(FixturePath(), json);
    }

    // Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path.
    private static string FixturePath([CallerFilePath] string thisFile = "")
        => Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json");
}
  • Step 2: Generate the fixture (manual, dats present).

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutFixtureGenerator.GenerateChatFixture" -e ACDREAM_DAT_DIR="%USERPROFILE%\Documents\Asheron's Call" after temporarily removing the Skip (or use an IDE run). Confirm tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json is written and non-empty, then restore the Skip. Expected: a JSON tree rooted at id 0x10000006-family with the chat elements. Record the resolved Type of 0x10000016 (input) and 0x10000011 (transcript) — these drive Task 5/6 decisions.

  • Step 3: Add FixtureLoader.LoadChat() + LoadChatInfos().

In FixtureLoader.cs, add (mirroring LoadVitals/LoadVitalsInfos):

    public static ImportedLayout LoadChat()
        => LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null);

    public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos()
        => LoadInfos("chat_21000006.json");

    // Shared loader (refactor LoadVitalsInfos to call this with "vitals_2100006C.json").
    private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName)
    {
        var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName);
        if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}");
        var bytes = File.ReadAllBytes(path);
        ReadOnlySpan<byte> span = bytes;
        if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..];
        return JsonSerializer.Deserialize<AcDream.App.UI.Layout.ElementInfo>(span, _opts)
            ?? throw new InvalidOperationException($"fixture deserialized to null: {path}");
    }

Then make LoadVitalsInfos() delegate: public static ElementInfo LoadVitalsInfos() => LoadInfos("vitals_2100006C.json");

  • Step 4: Write the resolved-tree conformance test (fails until the fixture exists).

ChatLayoutConformanceTests.cs:

using System.Collections.Generic;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;

public class ChatLayoutConformanceTests
{
    private static ElementInfo Find(ElementInfo n, uint id)
    {
        if (n.Id == id) return n;
        foreach (var c in n.Children) { var f = Find(c, id); if (f is not null) return f; }
        return null!;
    }

    [Fact]
    public void ChatFixture_ResolvesKnownElements()
    {
        var root = FixtureLoader.LoadChatInfos();
        // These ids come from ChatWindowController; the resolved Type proves the base-chain merge.
        Assert.NotNull(Find(root, 0x10000011u)); // transcript
        Assert.NotNull(Find(root, 0x10000016u)); // input
        Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track
        Assert.NotNull(Find(root, 0x10000014u)); // channel menu
        Assert.NotNull(Find(root, 0x10000019u)); // send button
        Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button
    }

    [Fact]
    public void ChatFixture_ResolvedTypes_MatchRetailRegistry()
    {
        var root = FixtureLoader.LoadChatInfos();
        Assert.Equal(6u,  Find(root, 0x10000014u).Type);  // Menu
        Assert.Equal(11u, Find(root, 0x10000012u).Type);  // Scrollbar
        Assert.Equal(1u,  Find(root, 0x10000019u).Type);  // Button (Send)
        Assert.Equal(1u,  Find(root, 0x1000046Fu).Type);  // Button (Max/Min)
        // transcript + input: assert the ACTUAL resolved Type recorded in Step 2.
        // From the Map trace both resolve to 12 (Text); if Step 2 shows otherwise, update these.
        Assert.Equal(12u, Find(root, 0x10000011u).Type);  // Text (transcript)
        Assert.Equal(12u, Find(root, 0x10000016u).Type);  // Text (input — see Task 6 wrinkle)
    }
}
  • Step 5: Run the conformance tests.

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutConformanceTests" Expected: PASS. If ChatFixture_ResolvedTypes_MatchRetailRegistry shows input 0x10000016 Type ≠ 12, update the assert to the real value and note it in Task 6 Step 1 (decides factory-built vs controller-placed UiField).

  • Step 6: Commit.
git add tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs \
        tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json \
        tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs \
        tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs
git commit -m "test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)"

Task 2: UiScrollbar (Type 11) — promote the already-generic scrollbar

UiChatScrollbar has zero chat-specific code; this is a rename + factory registration.

Files:

  • Rename: src/AcDream.App/UI/UiChatScrollbar.cssrc/AcDream.App/UI/UiScrollbar.cs

  • Rename: tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cstests/AcDream.App.Tests/UI/UiScrollbarTests.cs

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

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

  • Modify: tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs, ChatLayoutConformanceTests.cs

  • Step 1: Rename the widget file + class.

git mv src/AcDream.App/UI/UiChatScrollbar.cs src/AcDream.App/UI/UiScrollbar.cs
git mv tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs tests/AcDream.App.Tests/UI/UiScrollbarTests.cs

In UiScrollbar.cs: rename class UiChatScrollbarclass UiScrollbar; update the doc summary to "Generic scrollbar. Ports retail UIElement_Scrollbar (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137)…"; keep all body/fields/methods unchanged. In UiScrollbarTests.cs: rename the test class to UiScrollbarTests; replace every UiChatScrollbar with UiScrollbar. (Keep the test bodies.)

  • Step 2: Write the failing factory test.

In DatWidgetFactoryTests.cs add:

    [Fact]
    public void Type11_Scrollbar_MakesUiScrollbar()
    {
        var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null);
        Assert.IsType<UiScrollbar>(e);
    }
  • Step 3: Run it — verify it fails.

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests.Type11_Scrollbar_MakesUiScrollbar" Expected: FAIL (Create returns UiDatElement, not UiScrollbar).

  • Step 4: Register Type 11 in the factory.

In DatWidgetFactory.Create, add to the switch (before _):

            11 => new UiScrollbar(),                   // UIElement_Scrollbar (reg :124137)
  • Step 5: Build + run factory + scrollbar tests.

Run: dotnet build then dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~UiScrollbarTests" Expected: PASS.

  • Step 6: Point the controller at the factory-built scrollbar (still functional).

The factory now builds a UiScrollbar for the Type-11 track element. In ChatWindowController.cs, in the "Scrollbar — replace the imported track placeholder" block, change the construction to bind the factory widget instead of building a fresh one. Replace the block (currently c.Scrollbar = new UiChatScrollbar { … }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar);) with:

        // The factory built the Type-11 track element as a UiScrollbar. Find it, bind it.
        if (layout.FindElement(TrackId) is UiScrollbar bar)
        {
            bar.Top    = 0f;                       // pull up to the panel top (resize-bar reclaim)
            bar.Height = bar.Height + bar.Top;     // NOTE: capture old Top before zeroing — see Step 6a
            bar.Model         = c.Transcript.Scroll;
            bar.SpriteResolve = resolve;
            bar.TrackSprite    = TrackSprite;
            bar.ThumbSprite    = ThumbSprite;
            bar.ThumbTopSprite = ThumbTopSprite;
            bar.ThumbBotSprite = ThumbBotSprite;
            bar.UpSprite       = UpSprite;
            bar.DownSprite     = DownSprite;
            c.Scrollbar = bar;
        }
  • Step 6a: Fix the Top/Height order bug introduced above. The old code added track.Top to height before zeroing Top. Write it correctly:
        if (layout.FindElement(TrackId) is UiScrollbar bar)
        {
            float oldTop = bar.Top;
            bar.Top    = 0f;
            bar.Height = bar.Height + oldTop;
            bar.Model         = c.Transcript.Scroll;
            bar.SpriteResolve = resolve;
            bar.TrackSprite = TrackSprite; bar.ThumbSprite = ThumbSprite;
            bar.ThumbTopSprite = ThumbTopSprite; bar.ThumbBotSprite = ThumbBotSprite;
            bar.UpSprite = UpSprite; bar.DownSprite = DownSprite;
            c.Scrollbar = bar;
        }

Change the Scrollbar property type: public UiScrollbar Scrollbar { get; private set; } = null!;

  • Step 7: Update the conformance Type assert (already Type 11) + run full UI suite.

ChatLayoutConformanceTests already asserts Type 11 for the track. Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj Expected: PASS (whole UI suite).

  • Step 8: Re-check AP-41 in the divergence register.

The controller passes ThumbTopSprite/ThumbBotSprite (3-slice caps), so AP-41 ("thumb single stretched sprite") is stale. In docs/architecture/retail-divergence-register.md, update the AP-41 file:line from UiChatScrollbar.cs:37 to UiScrollbar.cs and narrow/retire it (the 3-slice path now draws caps; retire only if the fallback single-tile path is no longer reachable — it is reachable when caps are 0, so narrow the wording to "fallback only").

  • Step 9: Commit.
git add -A
git commit -m "feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)"

Task 3: UiButton (Type 1) — Send + Max/Min

The factory currently builds Send/Max-Min as UiDatElement and the controller sets OnClick/Label. Introduce a dedicated UiButton mirroring that behavior exactly (so clicks don't regress) and register Type 1.

Files:

  • Create: src/AcDream.App/UI/UiButton.cs

  • Create: tests/AcDream.App.Tests/UI/UiButtonTests.cs

  • Modify: DatWidgetFactory.cs, ChatWindowController.cs, DatWidgetFactoryTests.cs

  • Step 1: Write the failing button-behavior test.

UiButtonTests.cs:

using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI;

public class UiButtonTests
{
    private static (uint, int, int) NoTex(uint _) => (0, 0, 0);

    [Fact]
    public void Click_InvokesOnClick()
    {
        var info = new ElementInfo { Type = 1, Width = 46, Height = 18 };
        var b = new UiButton(info, NoTex) { OnClick = () => Clicked = true };
        b.OnEvent(new UiEvent(UiEventType.Click, 0, 0, 0));
        Assert.True(Clicked);
    }
    private bool Clicked;

    [Fact]
    public void NotClickThrough_SoItReceivesClicks()
    {
        var b = new UiButton(new ElementInfo { Type = 1 }, NoTex);
        Assert.False(b.ClickThrough);
    }
}

Confirm the UiEvent constructor signature in src/AcDream.App/UI/UiEvent.cs before finalizing the new UiEvent(...) call; adjust arg order if needed.

  • Step 2: Run it — verify it fails (UiButton does not exist).

Run: dotnet test … --filter "FullyQualifiedName~UiButtonTests" Expected: FAIL (compile error: UiButton not found).

  • Step 3: Write UiButton.

UiButton.cs:

using System;
using System.Numerics;
using AcDream.App.UI.Layout;

namespace AcDream.App.UI;

/// <summary>
/// Generic clickable button. Ports retail UIElement_Button
/// (RegisterElementClass(1, UIElement_Button::Create) @ acclient_2013_pseudo_c.txt:125828):
/// a per-state sprite face + an optional centered caption + a click action. Built by
/// DatWidgetFactory for Type-1 elements (chat Send 0x10000019, Max/Min 0x1000046F).
/// The controller binds OnClick and the caption. State selection mirrors UiDatElement
/// so existing Send/Max-Min behavior is preserved exactly.
/// </summary>
public sealed class UiButton : UiElement
{
    private readonly ElementInfo _info;
    private readonly Func<uint, (uint tex, int w, int h)> _resolve;

    public Action? OnClick { get; set; }
    public string? Label { get; set; }
    public UiDatFont? LabelFont { get; set; }
    public Vector4 LabelColor { get; set; } = Vector4.One;

    /// <summary>Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized).</summary>
    public string ActiveState { get; set; } = "";

    public UiButton(ElementInfo info, Func<uint, (uint tex, int w, int h)> resolve)
    {
        _info = info;
        _resolve = resolve;
        ClickThrough = false;   // buttons are interactive
        if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName;
        else if (info.StateMedia.ContainsKey("Normal")) ActiveState = "Normal";
    }

    private uint ActiveFile()
        => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File
         : _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u;

    protected override void OnDraw(UiRenderContext ctx)
    {
        uint file = ActiveFile();
        if (file != 0)
        {
            var (tex, tw, th) = _resolve(file);
            if (tex != 0 && tw != 0 && th != 0)
                ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
        }
        if (Label is { Length: > 0 } label && LabelFont is { } lf)
        {
            float tx = (Width - lf.MeasureWidth(label)) * 0.5f;
            float ty = (Height - lf.LineHeight) * 0.5f;
            ctx.DrawStringDat(lf, label, tx, ty, LabelColor);
        }
    }

    public override bool OnEvent(in UiEvent e)
    {
        if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
        return false;
    }
}
  • Step 4: Run the button tests — verify they pass.

Run: dotnet test … --filter "FullyQualifiedName~UiButtonTests" Expected: PASS.

  • Step 5: Write the failing factory test + register Type 1.

In DatWidgetFactoryTests.cs:

    [Fact]
    public void Type1_Button_MakesUiButton()
    {
        var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null);
        Assert.IsType<UiButton>(e);
    }

In DatWidgetFactory.Create switch:

            1  => new UiButton(info, resolve),         // UIElement_Button (reg :125828)
  • Step 6: Update the controller to bind the factory-built buttons.

In ChatWindowController.cs, the Send block currently does if (layout.FindElement(SendId) is UiDatElement sendEl) { sendEl.ClickThrough = false; sendEl.OnClick = …; sendEl.Label = "Send"; sendEl.LabelFont = datFont; sendEl.LabelColor = …; }. Change the cast to UiButton:

        if (layout.FindElement(SendId) is UiButton sendEl)
        {
            sendEl.OnClick    = () => c.Input.Submit();
            sendEl.Label      = "Send";
            sendEl.LabelFont  = datFont;
            sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f);
        }

And the Max/Min block: change if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)is UiButton maxMinEl, drop the now-unneeded maxMinEl.ClickThrough = false; (UiButton is interactive by construction), keep the maxMinEl.Left = track.Left - maxMinEl.Width; and maxMinEl.OnClick = c.ToggleMaximize;.

  • Step 7: Build + run the full UI suite.

Run: dotnet build then dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj Expected: PASS.

  • Step 8: Commit.
git add -A
git commit -m "feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)"

Task 4: UiMenu (Type 6) — genericize the channel menu

UiChannelMenu is the one heavy genericization: move ChatChannelKind, the 14-item array, the button-text map, and the availability defaults into ChatWindowController; keep all drawing/geometry/event mechanics in a generic UiMenu keyed on object? Payload.

Files:

  • Rename: src/AcDream.App/UI/UiChannelMenu.cssrc/AcDream.App/UI/UiMenu.cs

  • Rename: tests/AcDream.App.Tests/UI/UiChannelMenuTests.cstests/AcDream.App.Tests/UI/UiMenuTests.cs

  • Modify: DatWidgetFactory.cs, ChatWindowController.cs, DatWidgetFactoryTests.cs

  • Step 1: Rename file + class.

git mv src/AcDream.App/UI/UiChannelMenu.cs src/AcDream.App/UI/UiMenu.cs
git mv tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs tests/AcDream.App.Tests/UI/UiMenuTests.cs
  • Step 2: Replace the chat-specific members with the generic surface.

In UiMenu.cs, rename class UiChannelMenuclass UiMenu; remove using AcDream.UI.Abstractions;. Replace the chat-specific members — the Item record, the static Items array, Selected (ChatChannelKind), OnChannelChanged, AvailabilityProvider, IsAvailable, and ButtonText — with these generic members:

    /// <summary>One menu row: its label + an opaque payload the controller maps back.</summary>
    public readonly record struct MenuItem(string Label, object? Payload);

    /// <summary>The rows, populated by the controller. Laid out column-major:
    /// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc.</summary>
    public IReadOnlyList<MenuItem> Items { get; set; } = System.Array.Empty<MenuItem>();

    /// <summary>The currently-selected payload (drives the highlighted row).</summary>
    public object? Selected { get; set; }

    /// <summary>Fired with the picked item's payload when a row is chosen.</summary>
    public Action<object?>? OnSelect { get; set; }

    /// <summary>Per-payload enabled gate (disabled rows render greyed + are inert).
    /// Null ⇒ all rows enabled.</summary>
    public Func<object?, bool>? EnabledProvider { get; set; }

    /// <summary>Button-face caption (the active target). Null ⇒ blank face.</summary>
    public Func<string>? ButtonLabelProvider { get; set; }

Make the geometry constants settable so a controller/factory can match the dat:

    public int   RowsPerColumn { get; set; } = 7;     // items per column (dat item template)
    public float RowHeight     { get; set; } = 17f;   // dat item template 0x1000001E H=17
    public float ColumnWidth   { get; set; } = 191f;  // dat item template W=191

Replace the private const int Rows/ItemH/ColW usages with RowsPerColumn/RowHeight/ColumnWidth, and make the derived sizes instance members:

    private int   ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn);
    private float InteriorW => ColumnCount * ColumnWidth;
    private float InteriorH => RowsPerColumn * RowHeight;
    private float OuterW => InteriorW + 2 * Border;
    private float OuterH => InteriorH + 2 * Border;
  • Step 3: Genericize the draw/event logic (mechanical swaps).

In the same file, in OnDrawOverlay, OnEvent, OnHitTest, and DrawButtonFace/label:

  • Replace Items[i].Channel is { } c && c == Selected (selected-row test) with Equals(Items[i].Payload, Selected).

  • Replace Items[i].Channel is not { } c || IsAvailable(c) (availability) with EnabledProvider?.Invoke(Items[i].Payload) ?? true.

  • Replace the button caption ButtonText with ButtonLabelProvider?.Invoke() ?? "" in both OnDraw (the DrawLabel(ctx, ButtonText, …) call) and NaturalButtonWidth() (the MeasureWidth(ButtonText)).

  • In OnEvent's pick branch, replace the channel-specific selection

    if ( && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); }
    

    with

    if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count
        && (EnabledProvider?.Invoke(Items[idx].Payload) ?? true))
    {
        Selected = Items[idx].Payload;
        OnSelect?.Invoke(Selected);
    }
    
  • Replace the column/row math int col = i / Rows, row = i % Rows; with RowsPerColumn and Items.LengthItems.Count. Keep DrawBevel, DrawButtonFace, DrawSprite, DrawLabel, the sprite-id properties, the colors, and NaturalButtonWidth() otherwise unchanged. Update the doc comment to cite UIElement_Menu (RegisterElementClass(6) @ :120163) + MakePopup @0x46d310.

  • Step 4: Update the menu tests for the generic surface.

In UiMenuTests.cs, rename the class to UiMenuTests, replace UiChannelMenuUiMenu. Where tests referenced ChatChannelKind/Selected/OnChannelChanged, rewrite them against the generic surface, e.g.:

    [Fact]
    public void ClickingRow_FiresOnSelect_WithPayload()
    {
        object? picked = null;
        var m = new UiMenu
        {
            Width = 46, Height = 18,
            Items = new UiMenu.MenuItem[] { new("Chat to All", "say"), new("Trade", "trade") },
            OnSelect = p => picked = p,
        };
        // open, then click row 0 (geometry per RowsPerColumn/RowHeight — mirror the
        // existing test's click coords, which used the same 17px rows).
        m.OnEvent(new UiEvent(UiEventType.MouseDown, 0, 0, 0));      // toggle open
        // … click into row 0 of the open popup (reuse the prior test's local coords) …
        Assert.Equal("say", picked);
    }

Reuse the exact open/click coordinates from the original UiChannelMenuTests (they map into the same popup geometry); only the payload/selection assertions change.

  • Step 5: Run the menu tests — green.

Run: dotnet test … --filter "FullyQualifiedName~UiMenuTests" Expected: PASS.

  • Step 6: Failing factory test + register Type 6.

In DatWidgetFactoryTests.cs:

    [Fact]
    public void Type6_Menu_MakesUiMenu()
    {
        var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null);
        Assert.IsType<UiMenu>(e);
    }

In DatWidgetFactory.Create switch:

            6  => new UiMenu(),                        // UIElement_Menu (reg :120163)
  • Step 7: Move the channel knowledge into ChatWindowController.

In ChatWindowController.cs, add the channel item table + maps (ported verbatim from the old UiChannelMenu):

    // Talk-focus channels (ported from the old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50).
    private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems =
    {
        ("Squelch (ignore)",      null),
        ("Tell to Selected",      null),
        ("Chat to All",           ChatChannelKind.Say),
        ("Tell to Fellows",       ChatChannelKind.Fellowship),
        ("Tell to General Chat",  ChatChannelKind.General),
        ("Tell to LFG Chat",      ChatChannelKind.Lfg),
        ("Tell to Society Chat",  ChatChannelKind.Society),
        ("Tell to Monarch",       ChatChannelKind.Monarch),
        ("Tell to Patron",        ChatChannelKind.Patron),
        ("Tell to Vassals",       ChatChannelKind.Vassals),
        ("Tell to Allegiance",    ChatChannelKind.Allegiance),
        ("Tell to Trade Chat",    ChatChannelKind.Trade),
        ("Tell to Roleplay Chat", ChatChannelKind.Roleplay),
        ("Tell to Olthoi Chat",   ChatChannelKind.Olthoi),
    };

    private static string ChannelButtonLabel(ChatChannelKind k) => k switch
    {
        ChatChannelKind.Say => "Chat", ChatChannelKind.General => "General",
        ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG",
        ChatChannelKind.Fellowship => "Fellow", ChatChannelKind.Allegiance => "Alleg",
        ChatChannelKind.Patron => "Patron", ChatChannelKind.Vassals => "Vassals",
        ChatChannelKind.Monarch => "Monarch", ChatChannelKind.Roleplay => "Roleplay",
        ChatChannelKind.Society => "Society", ChatChannelKind.Olthoi => "Olthoi",
        _ => "Chat",
    };

    private static bool ChannelAvailable(ChatChannelKind k)
        => k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg;

Replace the "Channel menu — replace the imported menu placeholder" block. The factory now builds the Type-6 element as a UiMenu; find it and populate it:

        if (layout.FindElement(MenuId) is UiMenu menu)
        {
            menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve;
            menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed;
            menu.PopupBgSprite = MenuPopupBg;
            menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected;
            menu.Items = System.Array.ConvertAll(ChannelItems,
                t => new UiMenu.MenuItem(t.Label, (object?)t.Channel));
            menu.Selected = (object?)c._activeChannel;
            menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch);
            menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel);
            menu.OnSelect = p =>
            {
                if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; }
            };
            c.Menu = menu;
        }

Update the Menu property type: public UiMenu Menu { get; private set; } = null!; Update the reflow block (ReflowInputRow) — it calls c.Menu.NaturalButtonWidth(), c.Menu.ResetAnchorCapture() (both still exist on UiMenu), and wraps c.Menu.OnChannelChanged. Replace the OnChannelChanged wrap with the generic OnSelect:

            var onSelect = c.Menu.OnSelect;
            c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); };

_activeChannel already exists on the controller; the old per-menu OnChannelChanged = k => c._activeChannel = k; is now folded into OnSelect.

  • Step 8: Build + run the full UI suite.

Run: dotnet build then dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj Expected: PASS.

  • Step 9: Add a divergence row if the generic menu lost fidelity.

The generic UiMenu item model is flat (label+payload, no submenu/hierarchical popup). If that is a new approximation vs UIElement_Menu::MakePopup's nested popups, add a row to docs/architecture/retail-divergence-register.md (Adaptation) citing src/AcDream.App/UI/UiMenu.cs + MakePopup @0x46d310. (The chat menu is single-level, so this is a latent note, not a behavior change.)

  • Step 10: Commit.
git add -A
git commit -m "feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)"

Task 5: UiText (Type 12) — transcript + the Type-12 flip

Rename UiChatViewUiText, default its background to transparent + add an optional dat state-sprite background (so any Type-12-with-sprite element keeps rendering its sprite), register Type 12, flip the two factory Type-12 tests, and have the controller bind the factory-built transcript. An unbound UiText must draw nothing so vitals stays frozen.

Files:

  • Rename: src/AcDream.App/UI/UiChatView.cssrc/AcDream.App/UI/UiText.cs

  • Rename: tests/AcDream.App.Tests/UI/UiChatViewTests.csUiTextTests.cs; UiChatViewDatFontTests.csUiTextDatFontTests.cs

  • Modify: DatWidgetFactory.cs, LayoutImporter.cs (none needed — Text recurses normally), ChatWindowController.cs, DatWidgetFactoryTests.cs, GameWindow.cs

  • Step 1: Rename file + class + tests.

git mv src/AcDream.App/UI/UiChatView.cs src/AcDream.App/UI/UiText.cs
git mv tests/AcDream.App.Tests/UI/UiChatViewTests.cs tests/AcDream.App.Tests/UI/UiTextTests.cs
git mv tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs

In UiText.cs: rename class UiChatViewclass UiText; the nested Line/Pos records, LinesProvider, selection, and scroll stay. Update the doc to cite UIElement_Text (RegisterElementClass(0xc) @ :115655). In the test files, rename classes + replace UiChatViewUiText.

  • Step 2: Default the background to transparent (so an unbound UiText is invisible).

In UiText.cs, change:

    public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);   // transparent by default

(was (0,0,0,0.35)). OnDraw's ctx.DrawFill(0,0,Width,Height,BackgroundColor) then draws nothing when transparent. The chat controller will set the translucent value explicitly (Step 6).

  • Step 3: Add an optional dat state-sprite background (faithful UIElement_Text media).

So a Type-12 element that carries its own sprite (currently rendered by UiDatElement) does not lose it. Add to UiText:

    /// <summary>Optional dat state-sprite background (the element's own media), drawn
    /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
    public uint BackgroundSprite { get; set; }
    public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }

At the very top of OnDraw, before DrawFill:

        if (BackgroundSprite != 0 && SpriteResolve is { } sr)
        {
            var (tex, tw, th) = sr(BackgroundSprite);
            if (tex != 0 && tw != 0 && th != 0)
                ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One);
        }
  • Step 4: Write the failing factory test (and flip the two existing Type-12 tests).

In DatWidgetFactoryTests.cs:

  • Add:
    [Fact]
    public void Type12_Text_MakesUiText()
    {
        var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null);
        Assert.IsType<UiText>(e);
    }
  • Replace Type12_StylePrototype_ReturnsNull (delete it — Type 12 is no longer skipped).
  • Replace DatWidgetFactory_Type12WithMedia_Renders body to assert UiText for both media and no-media:
    [Fact]
    public void DatWidgetFactory_Type12_AlwaysMakesUiText()
    {
        var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16,
            StateMedia = { ["Normal"] = (0x00001234u, 1) } };
        Assert.IsType<UiText>(DatWidgetFactory.Create(withMedia, NoTex, null));
        Assert.IsType<UiText>(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null));
    }
  • Step 5: Run — verify the new/flipped tests fail.

Run: dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests" Expected: FAIL on the Type-12 asserts (factory still returns null / UiDatElement).

  • Step 6: Register Type 12 + add BuildText; remove the skip.

In DatWidgetFactory.cs:

  • Delete the skip line if (info.Type == 12 && info.StateMedia.Count == 0) return null;.
  • Add to the switch:
            12 => BuildText(info, resolve),            // UIElement_Text (reg :115655)
  • Add the builder:
    /// <summary>Type-12 UIElement_Text: a scrollable colored-line text view. The
    /// element's own Direct/Normal media (if any) becomes the background sprite, drawn
    /// under the text — so a Type-12 element that previously rendered via UiDatElement
    /// keeps its sprite. Lines are bound later by the controller (LinesProvider).</summary>
    private static UiText BuildText(ElementInfo info, Func<uint, (uint, int, int)> resolve)
    {
        uint bg = info.StateMedia.TryGetValue(
                      !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName
                    : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m)
                  ? m.File : 0u;
        return new UiText { BackgroundSprite = bg, SpriteResolve = resolve };
    }

Update the Create summary/<returns> doc that referenced Type-12 returning null.

  • Step 7: Verify factory + vitals fixture still green (vitals frozen).

Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~LayoutConformanceTests|FullyQualifiedName~VitalsBindingTests" Expected: PASS. The vitals number text elements are meter-children (consumed, never built — LayoutImporter.cs:113), and any other vitals Type-12 element now builds as an unbound, transparent UiText (draws only its own sprite, if it had one — same as before). Spec verification #2: if a vitals conformance test fails, a standalone Type-12 element changed class — inspect it; its sprite must still draw via BackgroundSprite.

  • Step 8: Controller binds the factory-built transcript (instead of constructing it).

In ChatWindowController.cs, the factory now builds the Type-12 transcript element 0x10000011 as a UiText. Replace the "Transcript" block (which read tInfo and new UiChatView { … }; transcriptPanel.AddChild(...)) with find-and-bind:

        // The factory built the Type-12 transcript as a UiText; find + bind it.
        c.Transcript = layout.FindElement(TranscriptId) as UiText
            ?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
        c.Transcript.DatFont = datFont;
        c.Transcript.Font    = debugFont;
        c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);   // retail translucent transcript
        c.Transcript.LinesProvider   = () => BuildLines(vm, c.Transcript, datFont, debugFont);

Change the Transcript property type to public UiText Transcript { get; private set; } = null!;. Remove the now-unused tInfo lookup + the transcriptPanel.AddChild (the transcript is already in the tree at its dat position). Keep the transcriptPanel.Top/Height resize-bar reclaim.

Also in ChatWindowController.cs, replace every UiChatView.Line with UiText.Line — this hits BuildLines (its UiText view parameter, its IReadOnlyList<UiText.Line> return type, the Array.Empty<UiText.Line>(), and the new UiText.Line(frag, color) inside the wrap loop). WrapText/RetailChatColor are unaffected (they return string/Vector4).

Finally, repoint the Bind early-guard: it currently does var tInfo = FindInfo(rootInfo, TranscriptId); and checks tInfo is null. The transcript is now found via layout.FindElement(TranscriptId); change the guard to null-check the factory-built widgets it needs (layout.FindElement(TranscriptPanelId) for the panel, plus the transcript/input found in their Steps). The iInfo lookup stays only for Task 6 Variant B. (Full guard tidy lands in Task 7.)

  • Step 9: GameWindow follow-through.

GameWindow.cs:1860 (chatController.Transcript.Keyboard = …) still compiles (UiText.Keyboard exists). Build to confirm.

  • Step 10: Build + full UI suite.

Run: dotnet build then dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj Expected: PASS.

  • Step 11: Amend AP-37 (Type-0 text skip retired).

In docs/architecture/retail-divergence-register.md, edit AP-37: remove the "Standalone Type-0 text elements are also skipped (… a dedicated dat-text widget is Plan 2)" clause (now shipped as UiText). Keep the meter-collapse clause and the vitals-numbers-via-UiMeter.Label clause (retired in Task 8).

  • Step 12: Commit.
git add -A
git commit -m "feat(D.2b): UiText (Type 12) — generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)"

Task 6: UiField (Type 3) — editable input

Rename UiChatInputUiField, register Type 3, and wire the input. Input handling depends on Task 1 Step 5's recorded resolved Type for 0x10000016:

  • If it resolved to Type 3: the factory builds UiField directly; the controller finds + binds it.
  • If it resolved to Type 12 (per the Map trace): the factory built it as a UiText; the controller replaces it with a UiField at the same rect (the existing replace pattern).

Files:

  • Rename: src/AcDream.App/UI/UiChatInput.cssrc/AcDream.App/UI/UiField.cs; UiChatInputTests.csUiFieldTests.cs

  • Modify: DatWidgetFactory.cs, ChatWindowController.cs, DatWidgetFactoryTests.cs, GameWindow.cs

  • Step 1: Confirm the input's resolved Type from Task 1, choose the path.

Re-read ChatLayoutConformanceTests.ChatFixture_ResolvedTypes_MatchRetailRegistry (Task 1) for 0x10000016. Note "Type 3 → direct build" or "Type 12 → controller-place". Proceed with the matching variant in Step 6.

  • Step 2: Rename file + class + tests.
git mv src/AcDream.App/UI/UiChatInput.cs src/AcDream.App/UI/UiField.cs
git mv tests/AcDream.App.Tests/UI/UiChatInputTests.cs tests/AcDream.App.Tests/UI/UiFieldTests.cs

In UiField.cs: rename class UiChatInputclass UiField; body unchanged. Update doc to cite UIElement_Field (RegisterElementClass(3) @ :126190) + the drag-drop hooks (CatchDroppedItem/MouseOverTop) it will host for future item windows. In UiFieldTests.cs: rename class, replace UiChatInputUiField.

  • Step 3: Default the background to transparent (consistency with UiText).

Change UiField.BackgroundColor default to new(0f, 0f, 0f, 0f). The controller sets the translucent value (Step 6).

  • Step 4: Failing factory test + register Type 3.

In DatWidgetFactoryTests.cs:

    [Fact]
    public void Type3_Field_MakesUiField()
    {
        var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null);
        Assert.IsType<UiField>(e);
    }

In DatWidgetFactory.Create switch:

            3  => new UiField(),                       // UIElement_Field (reg :126190)
  • Step 5: Run — verify pass.

Run: dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests.Type3_Field_MakesUiField|FullyQualifiedName~UiFieldTests" Expected: PASS.

  • Step 6: Wire the input in the controller (variant per Step 1).

Replace the "Input" block (new UiChatInput { … }; inputBar.AddChild(c.Input); c.Input.OnSubmit = …).

Variant A — input resolved to Type 3 (factory-built):

        c.Input = layout.FindElement(InputId) as UiField
            ?? throw new InvalidOperationException("chat input 0x10000016 not built as UiField");
        c.Input.DatFont = datFont; c.Input.Font = debugFont;
        c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f);
        c.Input.SpriteResolve = resolve; c.Input.FocusFieldSprite = InputFocusField;
        c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);

Variant B — input resolved to Type 12 (controller-placed UiField over the UiText):

        // 0x10000016 resolves to Type-12 Text in this layout; the editable entry is a
        // controller-placed UiField at the dat element's rect (retail authors a separate Field).
        var iInfo = FindInfo(rootInfo, InputId)
            ?? throw new InvalidOperationException("chat input info 0x10000016 missing");
        if (layout.FindElement(InputId) is { Parent: { } iparent } placeholder)
            iparent.RemoveChild(placeholder);             // drop the read-only Text placeholder
        c.Input = new UiField
        {
            Left = iInfo.X, Top = iInfo.Y, Width = iInfo.Width, Height = iInfo.Height,
            Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom),
            DatFont = datFont, Font = debugFont,
            BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f),
            SpriteResolve = resolve, FocusFieldSprite = InputFocusField,
        };
        (inputBar).AddChild(c.Input);
        c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel);

Change the Input property type to public UiField Input { get; private set; } = null!; (Keep FindInfo for Variant B; it may become unused in Variant A — remove it then.)

  • Step 7: GameWindow follow-through.

GameWindow.cs:1861 (chatController.Input.Keyboard = …) still compiles (UiField.Keyboard exists). Build to confirm.

  • Step 8: Build + full UI suite.

Run: dotnet build then dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj Expected: PASS.

  • Step 9: Commit.
git add -A
git commit -m "feat(D.2b): UiField (Type 3) — editable input as a generic field (widget-generalization Task 6)"

Task 7: Thin + verify the controller; remove dead construction

After Tasks 26, ChatWindowController.Bind should construct no widgets (except the Variant-B input). Audit and tidy.

Files:

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

  • Step 1: Remove dead helpers + confirm find-by-id shape.

In ChatWindowController.cs: confirm every widget is obtained via layout.FindElement(id) as UiX and only data/callbacks are bound. Remove any now-unused locals (transcriptPanel/inputBar are still used for the resize-bar reclaim / Variant-B parent — keep those; remove tInfo/FindInfo if Variant A). Confirm the class doc reads as the gmMainChatUI::PostInit @0x4ce130 analogue (find child by id → bind).

  • Step 2: Update ChatWindowControllerTests for the new types.

In tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs, update any references to UiChatView/UiChatInput/UiChatScrollbar/UiChannelMenu to UiText/UiField/UiScrollbar/UiMenu, and any assertions on .Selected/OnChannelChanged to the generic OnSelect/payload surface. Run them to confirm the binding still wires the right elements.

  • Step 3: Build + full UI suite.

Run: dotnet build then dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj Expected: PASS.

  • Step 4: Visual gate (user) — chat unchanged.

Launch the client (ACDREAM_RETAIL_UI=1, per CLAUDE.md launch recipe) and confirm the chat window looks + behaves identically to before this pass: transcript scroll/select/copy, input write-mode/history/clipboard, channel dropdown, send, max/min, scrollbar drag. Stop for user confirmation.

  • Step 5: Commit.
git add -A
git commit -m "refactor(D.2b): ChatWindowController is now a thin find-by-id binder (widget-generalization Task 7)"

Task 8 (GATED): vitals numbers as UiText

Rewire the vitals number text from UiMeter.Label to factory-built UiText (retail-faithful: vitals numbers are UIElement_Text). This is a stop-and-confirm gate — vitals shipped pixel-identical and is fixture-locked. If it risks the pixel-identical result, stop and keep UiMeter.Label (narrow AP-37 instead).

Files:

  • Modify: src/AcDream.App/UI/Layout/VitalsController.cs, LayoutImporter.cs (meter child handling), GameWindow.cs (Bind call), tests/.../VitalsBindingTests.cs, fixtures/vitals_2100006C.json

  • Step 1: Decide the number element's path.

The vitals number text is a meter child (consumed; LayoutImporter.cs:113 does not recurse meter children). To render it as a real UiText, either (a) have VitalsController construct a UiText at the number element's rect (read from the meter's children — mirrors the chat Variant-B pattern), or (b) stop consuming the meter's text child so the factory builds it. Prefer (a) — it is local to VitalsController and does not disturb the meter slice extraction. Read the number element's rect from DatWidgetFactory.BuildMeter's skipped text child (expose it, or re-read via the layout's ElementInfo).

  • Step 2: Write a failing binding test.

In VitalsBindingTests.cs, add a test that, after VitalsController.Bind, a UiText exists for each vital and its LinesProvider returns the cur/max string. (Use the vitals fixture; assert the text node is present + bound.)

  • Step 3: Implement the UiText number binding in VitalsController.

Add a UiText per meter (constructed at the number rect, single centered line). Keep UiMeter.Label unset for vitals. Bind LinesProvider = () => new[] { new UiText.Line(text(), color) } (centered — add a UiText.CenterSingleLine option or a thin overload if needed for horizontal centering).

If centering a single line requires new UiText layout support, add a minimal public bool CenterHorizontally flag to UiText with a unit test, rather than overloading the chat path.

  • Step 4: Build + run vitals tests.

Run: dotnet test … --filter "FullyQualifiedName~VitalsBindingTests|FullyQualifiedName~LayoutConformanceTests" Expected: PASS. Update vitals_2100006C.json only if the resolved tree legitimately changed (it should not — the change is in binding, not the tree).

  • Step 5: Visual gate (user) — vitals pixel-identical.

Launch (ACDREAM_RETAIL_UI=1); confirm the vitals numbers render identically (font, position, centering, color) to the shipped UiMeter.Label version. Stop for user confirmation. If not identical → revert this task and narrow AP-37 instead.

  • Step 6: Retire/narrow AP-37 + update memory.

If the rewire lands: in docs/architecture/retail-divergence-register.md, retire the AP-37 vitals-numbers clause (now real UiText). Update claude-memory/project_d2b_retail_ui.md (the generalization pass shipped) + the roadmap.

  • Step 7: Commit.
git add -A
git commit -m "feat(D.2b): vitals numbers as UiText (widget-generalization Task 8, gated)"

Done criteria (from spec §8)

  • DatWidgetFactory registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; _ still → UiDatElement.
  • The Type==12 → null skip is removed; no Type-12 element is double-built (fixtures green).
  • No ChatChannelKind/chat-color/command-routing knowledge inside any widget; ChatWindowController only finds-by-id and binds.
  • Chat window visually + behaviorally identical through Tasks 27 (user-confirmed, Task 7 Step 4).
  • chat_21000006.json golden fixture + renamed generic-widget tests all green.
  • Vitals window unchanged after Task 8 (user-confirmed), or Task 8 deferred with AP-37 narrowed.
  • Every generic widget cites its retail UIElement_X class + reg. line.
  • Divergence register updated (AP-37 amended; AP-41 re-checked) in the same commits.
  • Roadmap / claude-memory/project_d2b_retail_ui.md updated when the pass lands.