diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
new file mode 100644
index 00000000..edc4c3f3
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -0,0 +1,304 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.UI;
+using AcDream.Core.Chat;
+using AcDream.UI.Abstractions;
+using AcDream.UI.Abstractions.Panels.Chat;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream
+/// analogue of retail ChatInterface + gmMainChatUI::PostInit @0x4ce130.
+///
+///
+/// The transcript (0x10000011) and input (0x10000016) are Type-0
+/// elements whose base is a Type-12 prototype, so the importer factory skips them
+/// (returns null). This controller reads their rects from the raw
+/// tree (which contains everything) and adds the behavioral
+/// widgets as children of their parent container widgets (transcript panel
+/// 0x10000010 / input bar 0x10000013) which ARE created as
+/// nodes. The scrollbar track (0x10000012) and
+/// channel menu (0x10000014) are created by the factory and are replaced
+/// with their behavioral counterparts here.
+///
+///
+public sealed class ChatWindowController
+{
+ public const uint LayoutId = 0x21000006u;
+
+ // Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1).
+ private const uint RootId = 0x1000000Eu;
+ private const uint TranscriptPanelId = 0x10000010u;
+ private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
+ private const uint TrackId = 0x10000012u;
+ private const uint InputBarId = 0x10000013u;
+ private const uint MenuId = 0x10000014u;
+ private const uint InputId = 0x10000016u; // Type-12 prototype — skipped by factory
+ private const uint SendId = 0x10000019u;
+ private const uint MaxMinId = 0x1000046Fu;
+
+ // Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D).
+ private const uint TrackSprite = 0x06004C5Fu;
+ private const uint ThumbSprite = 0x06004C63u;
+ private const uint UpSprite = 0x06004C69u;
+ private const uint DownSprite = 0x06004C6Cu;
+
+ // Channel menu sprite ids (confirmed in chat element dump).
+ private const uint MenuNormal = 0x06004D65u;
+ private const uint MenuPressed = 0x06004D66u;
+
+ // ── Public surface ─────────────────────────────────────────────────────
+
+ /// Root element of the imported layout (the chat window chrome).
+ public UiElement Root { get; private set; } = null!;
+
+ /// Live chat transcript widget. Null until succeeds.
+ public UiChatView Transcript { get; private set; } = null!;
+
+ /// Editable chat input widget. Null until succeeds.
+ public UiChatInput Input { get; private set; } = null!;
+
+ /// Scrollbar widget, driven by 's scroll model.
+ public UiChatScrollbar Scrollbar { get; private set; } = null!;
+
+ /// Channel-selector menu widget.
+ public UiChannelMenu Menu { get; private set; } = null!;
+
+ // ── Private state ──────────────────────────────────────────────────────
+
+ private ChatChannelKind _activeChannel = ChatChannelKind.Say;
+
+ /// Window height before maximize (stored to restore on un-maximize).
+ private float _normalHeight;
+ /// Window top before maximize.
+ private float _normalTop;
+ private bool _maximized;
+
+ // ── Factory ────────────────────────────────────────────────────────────
+
+ ///
+ /// Bind an imported chat layout to live behavior.
+ ///
+ /// and must come from the
+ /// SAME pass (ImportInfos then Build)
+ /// so rects in the info tree match the widget geometry in the layout tree.
+ ///
+ /// Returns null if the essential transcript/input panels are missing from
+ /// the info tree or the widget tree (e.g. the layout dat is incomplete).
+ ///
+ /// Full tree from
+ /// .
+ /// Widget tree from .
+ /// Chat view-model (transcript data + command routing).
+ /// Command bus for SendChatCmd publishes.
+ /// Retail dat font for transcript + input rendering.
+ /// Fallback debug bitmap font (used when
+ /// is null).
+ /// Dat RenderSurface id → (GL tex handle, px width, px height).
+ /// Forwarded to and .
+ public static ChatWindowController? Bind(
+ ElementInfo rootInfo,
+ ImportedLayout layout,
+ ChatVM vm,
+ ICommandBus bus,
+ UiDatFont? datFont,
+ BitmapFont? debugFont,
+ Func resolve)
+ {
+ // The transcript + input nodes are Type-12 based and were skipped by the factory.
+ // Find them in the raw ElementInfo tree to read their rects.
+ var tInfo = FindInfo(rootInfo, TranscriptId);
+ var iInfo = FindInfo(rootInfo, InputId);
+
+ // Their parent panels must exist as real widgets in the layout tree.
+ var transcriptPanel = layout.FindElement(TranscriptPanelId);
+ var inputBar = layout.FindElement(InputBarId);
+
+ if (tInfo is null || iInfo is null || transcriptPanel is null || inputBar is null)
+ {
+ Console.WriteLine(
+ $"[D.2b] ChatWindowController.Bind: missing required elements " +
+ $"(tInfo={tInfo is not null}, iInfo={iInfo is not null}, " +
+ $"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " +
+ $"chat window will not be interactive.");
+ return null;
+ }
+
+ var c = new ChatWindowController { Root = layout.Root };
+
+ // ── Transcript ───────────────────────────────────────────────────
+ // Place the behavioral transcript widget inside the transcript panel at the
+ // dat-rect of the (skipped) Type-12 transcript element.
+ c.Transcript = new UiChatView
+ {
+ Left = tInfo.X,
+ Top = tInfo.Y,
+ Width = tInfo.Width,
+ Height = tInfo.Height,
+ Anchors = ElementReader.ToAnchors(tInfo.Left, tInfo.Top, tInfo.Right, tInfo.Bottom),
+ DatFont = datFont,
+ Font = debugFont,
+ LinesProvider = () => BuildLines(vm),
+ };
+ transcriptPanel.AddChild(c.Transcript);
+
+ // ── Input ────────────────────────────────────────────────────────
+ // Place the behavioral input widget inside the input bar.
+ c.Input = new UiChatInput
+ {
+ 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,
+ };
+ inputBar.AddChild(c.Input);
+ c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, bus, c._activeChannel);
+
+ // ── Scrollbar — replace the imported track placeholder ────────────
+ // The factory created a UiDatElement for the track. Remove it and place a
+ // behavioral UiChatScrollbar at the same position, driving the transcript's scroll.
+ var track = layout.FindElement(TrackId);
+ if (track?.Parent is { } trackParent)
+ {
+ c.Scrollbar = new UiChatScrollbar
+ {
+ Left = track.Left,
+ Top = track.Top,
+ Width = track.Width,
+ Height = track.Height,
+ Anchors = track.Anchors,
+ Model = c.Transcript.Scroll,
+ SpriteResolve = resolve,
+ TrackSprite = TrackSprite,
+ ThumbSprite = ThumbSprite,
+ UpSprite = UpSprite,
+ DownSprite = DownSprite,
+ };
+ trackParent.RemoveChild(track);
+ trackParent.AddChild(c.Scrollbar);
+ }
+
+ // ── Channel menu — replace the imported menu placeholder ──────────
+ var menuEl = layout.FindElement(MenuId);
+ if (menuEl?.Parent is { } menuParent)
+ {
+ c.Menu = new UiChannelMenu
+ {
+ Left = menuEl.Left,
+ Top = menuEl.Top,
+ Width = menuEl.Width,
+ Height = menuEl.Height,
+ Anchors = menuEl.Anchors,
+ DatFont = datFont,
+ Font = debugFont,
+ SpriteResolve = resolve,
+ NormalSprite = MenuNormal,
+ PressedSprite = MenuPressed,
+ };
+ c.Menu.OnChannelChanged = k => c._activeChannel = k;
+ menuParent.RemoveChild(menuEl);
+ menuParent.AddChild(c.Menu);
+ }
+
+ // ── Send button — Enter-alternate submit trigger ──────────────────
+ // Retail's gmMainChatUI wires the Send button to the same ProcessCommand path.
+ if (layout.FindElement(SendId) is UiDatElement sendEl)
+ {
+ sendEl.ClickThrough = false;
+ sendEl.OnClick = () => c.Input.Submit();
+ }
+
+ // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ──
+ if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)
+ {
+ maxMinEl.ClickThrough = false;
+ maxMinEl.OnClick = c.ToggleMaximize;
+ }
+
+ return c;
+ }
+
+ // ── Max/min implementation ─────────────────────────────────────────────
+
+ ///
+ /// Toggle between the normal chat window height and an expanded 320px height.
+ /// Simplified port of retail gmMainChatUI::HandleMaximizeButton @0x4cddb0:
+ /// retail stores the pre-maximize height and restores it on a second click.
+ /// The 320px expanded size is the approximate retail maximized chat height.
+ ///
+ private void ToggleMaximize()
+ {
+ if (!_maximized)
+ {
+ _normalHeight = Root.Height;
+ _normalTop = Root.Top;
+ // Expand upward: move the top edge up so the bottom stays anchored.
+ Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f);
+ Root.Height = 320f;
+ _maximized = true;
+ }
+ else
+ {
+ Root.Top = _normalTop;
+ Root.Height = _normalHeight;
+ _maximized = false;
+ }
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────────
+
+ ///
+ /// Depth-first search for an node by id in the
+ /// raw info tree (which contains ALL elements, including the Type-12 skipped ones).
+ ///
+ private static ElementInfo? FindInfo(ElementInfo node, uint id)
+ {
+ if (node.Id == id) return node;
+ foreach (var child in node.Children)
+ {
+ var found = FindInfo(child, id);
+ if (found is not null) return found;
+ }
+ return null;
+ }
+
+ ///
+ /// Convert the ChatVM's detailed lines to the transcript's
+ /// record format, applying retail-faithful
+ /// per- colors.
+ ///
+ private static IReadOnlyList BuildLines(ChatVM vm)
+ {
+ var detailed = vm.RecentLinesDetailed();
+ if (detailed.Count == 0) return Array.Empty();
+
+ var result = new UiChatView.Line[detailed.Count];
+ for (int i = 0; i < detailed.Count; i++)
+ result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind));
+ return result;
+ }
+
+ ///
+ /// Per- text color matching retail AC's channel coloring
+ /// (observed from retail client screenshots and holtburger's chat.rs coloring).
+ ///
+ private static Vector4 RetailChatColor(ChatKind kind) => kind switch
+ {
+ ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // white — spoken nearby
+ ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), // warm white — shout
+ ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), // light blue — channel text
+ ChatKind.Tell => new(1f, 0.5f, 1f, 1f), // magenta — whisper
+ ChatKind.System => new(1f, 1f, 0.45f, 1f), // yellow — system messages
+ ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), // amber — popup/broadcast
+ ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — emote
+ ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), // off-white — soul emote
+ ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), // orange — combat feedback
+ _ => new(0.9f, 0.9f, 0.9f, 1f), // light grey — fallback
+ };
+}
diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs
index 61f7c6b3..43cc4032 100644
--- a/src/AcDream.App/UI/Layout/UiDatElement.cs
+++ b/src/AcDream.App/UI/Layout/UiDatElement.cs
@@ -76,6 +76,17 @@ public sealed class UiDatElement : UiElement
: _info.StateMedia.TryGetValue("", out var d) ? d
: (0u, 0);
+ /// Optional click handler. Set by a controller for interactive dat
+ /// elements (e.g. the chat Send / max-min buttons). Requires
+ /// = false to receive click events.
+ public Action? OnClick { get; set; }
+
+ public override bool OnEvent(in UiEvent e)
+ {
+ if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; }
+ return false;
+ }
+
protected override void OnDraw(UiRenderContext ctx)
{
var (file, _) = ActiveMedia();
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
new file mode 100644
index 00000000..c4c6b9b1
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
@@ -0,0 +1,209 @@
+using System.Collections.Generic;
+using AcDream.App.UI;
+using AcDream.App.UI.Layout;
+using AcDream.Core.Chat;
+using AcDream.UI.Abstractions;
+using AcDream.UI.Abstractions.Panels.Chat;
+
+namespace AcDream.App.Tests.UI.Layout;
+
+///
+/// Smoke tests for — no dats, no GL.
+///
+/// Building the Type-12 "skipped" elements via the pure
+/// path is the correct approach: we build a synthetic info tree that reflects the
+/// real chat layout hierarchy (root → transcript panel + input bar as Type-3
+/// containers, with Type-12 children for transcript + input, plus a Type-3 track
+/// and menu), call to get the widget tree
+/// (Type-12 children skipped, Type-3 parents created), then call
+/// which reads rects from the info tree
+/// and places behavioral widgets under the parent containers.
+///
+public class ChatWindowControllerTests
+{
+ // ── Null-resolve helper (no GL needed) ─────────────────────────────────
+ private static (uint, int, int) NoTex(uint _) => (0u, 0, 0);
+
+ // ── Capture bus — records every Publish call ────────────────────────────
+ private sealed class CaptureBus : ICommandBus
+ {
+ public readonly List