diff --git a/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md b/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md new file mode 100644 index 00000000..e68c745f --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md @@ -0,0 +1,992 @@ +# 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.