# 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 2–7; 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~"`. - **Commit style:** `feat(D.2b): ` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) ` 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.cs` → `UiScrollbar.cs`; `UiChatScrollbarTests.cs` → `UiScrollbarTests.cs` (Task 2). - `UiChatView.cs` → `UiText.cs`; `UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` (Task 5). - `UiChatInput.cs` → `UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` (Task 6). - `UiChannelMenu.cs` → `UiMenu.cs`; `UiChannelMenuTests.cs` → `UiMenuTests.cs` (Task 4). **Modified:** - `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` — the `switch(Type)` + `BuildButton`/`BuildMenu`/`BuildText`/`BuildField`/`BuildScrollbar` (Tasks 2–6). - `src/AcDream.App/UI/Layout/ChatWindowController.cs` — construction → find-by-id binding; channel-item population (Tasks 2–7). - `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 5–6). - `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` — new per-Type asserts; flip the two Type-12 tests (Tasks 2–6). - `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`: ```csharp 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; /// /// 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. /// 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`): ```csharp 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 span = bytes; if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..]; return JsonSerializer.Deserialize(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`: ```csharp 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.** ```bash 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.cs` → `src/AcDream.App/UI/UiScrollbar.cs` - Rename: `tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs` → `tests/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.** ```bash 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 UiChatScrollbar` → `class 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: ```csharp [Fact] public void Type11_Scrollbar_MakesUiScrollbar() { var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null); Assert.IsType(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 `_`): ```csharp 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: ```csharp // 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: ```csharp 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.** ```bash 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`: ```csharp 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`: ```csharp using System; using System.Numerics; using AcDream.App.UI.Layout; namespace AcDream.App.UI; /// /// 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. /// public sealed class UiButton : UiElement { private readonly ElementInfo _info; private readonly Func _resolve; public Action? OnClick { get; set; } public string? Label { get; set; } public UiDatFont? LabelFont { get; set; } public Vector4 LabelColor { get; set; } = Vector4.One; /// Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized). public string ActiveState { get; set; } = ""; public UiButton(ElementInfo info, Func 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`: ```csharp [Fact] public void Type1_Button_MakesUiButton() { var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null); Assert.IsType(e); } ``` In `DatWidgetFactory.Create` switch: ```csharp 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`: ```csharp 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.** ```bash 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.cs` → `src/AcDream.App/UI/UiMenu.cs` - Rename: `tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs` → `tests/AcDream.App.Tests/UI/UiMenuTests.cs` - Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs` - [ ] **Step 1: Rename file + class.** ```bash 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 UiChannelMenu` → `class 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: ```csharp /// One menu row: its label + an opaque payload the controller maps back. public readonly record struct MenuItem(string Label, object? Payload); /// 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. public IReadOnlyList Items { get; set; } = System.Array.Empty(); /// The currently-selected payload (drives the highlighted row). public object? Selected { get; set; } /// Fired with the picked item's payload when a row is chosen. public Action? OnSelect { get; set; } /// Per-payload enabled gate (disabled rows render greyed + are inert). /// Null ⇒ all rows enabled. public Func? EnabledProvider { get; set; } /// Button-face caption (the active target). Null ⇒ blank face. public Func? ButtonLabelProvider { get; set; } ``` Make the geometry constants settable so a controller/factory can match the dat: ```csharp 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: ```csharp 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 ```csharp if (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); } ``` with ```csharp 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.Length` → `Items.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 `UiChannelMenu` → `UiMenu`. Where tests referenced `ChatChannelKind`/`Selected`/`OnChannelChanged`, rewrite them against the generic surface, e.g.: ```csharp [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`: ```csharp [Fact] public void Type6_Menu_MakesUiMenu() { var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null); Assert.IsType(e); } ``` In `DatWidgetFactory.Create` switch: ```csharp 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`): ```csharp // 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: ```csharp 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`: ```csharp 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.** ```bash 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 `UiChatView` → `UiText`, 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.cs` → `src/AcDream.App/UI/UiText.cs` - Rename: `tests/AcDream.App.Tests/UI/UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` - Modify: `DatWidgetFactory.cs`, `LayoutImporter.cs` (none needed — Text recurses normally), `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs` - [ ] **Step 1: Rename file + class + tests.** ```bash 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 UiChatView` → `class 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 `UiChatView` → `UiText`. - [ ] **Step 2: Default the background to transparent (so an unbound UiText is invisible).** In `UiText.cs`, change: ```csharp 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`: ```csharp /// Optional dat state-sprite background (the element's own media), drawn /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. public uint BackgroundSprite { get; set; } public Func? SpriteResolve { get; set; } ``` At the very top of `OnDraw`, before `DrawFill`: ```csharp 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: ```csharp [Fact] public void Type12_Text_MakesUiText() { var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null); Assert.IsType(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: ```csharp [Fact] public void DatWidgetFactory_Type12_AlwaysMakesUiText() { var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16, StateMedia = { ["Normal"] = (0x00001234u, 1) } }; Assert.IsType(DatWidgetFactory.Create(withMedia, NoTex, null)); Assert.IsType(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: ```csharp 12 => BuildText(info, resolve), // UIElement_Text (reg :115655) ``` - Add the builder: ```csharp /// 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). private static UiText BuildText(ElementInfo info, Func 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/`` 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: ```csharp // 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` return type, the `Array.Empty()`, 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.** ```bash 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 `UiChatInput` → `UiField`, 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.cs` → `src/AcDream.App/UI/UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.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.** ```bash 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 UiChatInput` → `class 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 `UiChatInput` → `UiField`. - [ ] **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`: ```csharp [Fact] public void Type3_Field_MakesUiField() { var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null); Assert.IsType(e); } ``` In `DatWidgetFactory.Create` switch: ```csharp 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):** ```csharp 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):** ```csharp // 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.** ```bash 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 2–6, `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.** ```bash 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.** ```bash 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 2–7 (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.