diff --git a/docs/plans/2026-08-21-chat-text-tag-campaign.md b/docs/plans/2026-08-21-chat-text-tag-campaign.md
index cd7ab07c..b8cde931 100644
--- a/docs/plans/2026-08-21-chat-text-tag-campaign.md
+++ b/docs/plans/2026-08-21-chat-text-tag-campaign.md
@@ -1,6 +1,14 @@
# Campaign CT — complete chat parity (system + GUI)
-**Status:** PROPOSED (2026-08-21). Not started.
+**Status:** Groups A, B, C and D COMPLETE 2026-08-21, each user-gated. Two
+items deliberately not shipped: CT-B3 (word filtering — dropped by user
+direction, register row CT-2) and CT-B4 (session chat log — research-blocked).
+
+**Carried forward:** multi-frame state media. Retail's unseen-text indicator
+blinks because its `Normal` state authors SIX image frames; our importer keeps
+one file per state, so nothing authored can animate. The indicator is
+state-driven and correct today and gains the blink for free once that
+capability lands. It is the first known customer, not the only one.
**Goal, set by the user 2026-08-21: complete retail parity for the chat
system AND the chat GUI.** Not "fix the green name" — that was the symptom
@@ -135,16 +143,21 @@ Nothing is user-visible until A4.
grey), gated on `PlayerModule::DisplayTimeStamps()`.
- **CT-C4** Input-bar editing parity: clipboard and selection paths
(Ctrl+C/X/V, shift-selection) work but are untested; `ToggleMaximize` and the
- floating-window Close button have zero coverage.
+ floating-window Close button have zero coverage. **DEFERRED** — pure test
+ coverage over behaviour the audit confirmed already works, so it changes
+ nothing a user can see. Worth doing; not worth blocking the campaign on.
### Group D — hygiene
-- **CT-D1** Delete the dead ImGui-era `ChatPanel` (never constructed since
- Campaign V deleted `AcDream.UI.ImGui`), and its three test files, which
- currently make the real input surface look better covered than it is.
-- **CT-D2** Reconcile the chat digest and `docs/ISSUES.md`: #358, #362, #363,
- #367, #372, #379, #380, #382 are DONE in code but still listed open. #359,
- #360, #361, #366 remain genuinely open.
+- **CT-D1** ~~Delete the dead ImGui-era `ChatPanel`~~ **DONE.** Verified never
+ constructed in `src/`, then removed with its three panel-only test files.
+ `ChatVMCombatTests` was KEPT — three of its four tests are real `ChatVM`
+ coverage; only the one `ChatPanel` render test went.
+- **CT-D2** ~~Reconcile the chat digest and `docs/ISSUES.md`~~ **DONE.**
+ `docs/ISSUES.md` turned out to be ACCURATE already — #358 and #363 are
+ recorded CLOSED there. Only the chat digest's "Open" section was stale, and
+ it is corrected: genuinely open are #359, #360, #361, #366. The digest also
+ gained a Campaign CT section and three new DO-NOT-RETRY rows.
## Research still owed before the affected slices
diff --git a/src/AcDream.Core/Chat/ChatLog.cs b/src/AcDream.Core/Chat/ChatLog.cs
index 397377d9..56d86d8f 100644
--- a/src/AcDream.Core/Chat/ChatLog.cs
+++ b/src/AcDream.Core/Chat/ChatLog.cs
@@ -507,8 +507,9 @@ public readonly record struct ChatEntry(
///
/// Phase I.7: severity bucket for
- /// entries. Null for every other kind. Drives the
- /// 's TextColored color choice.
+ /// entries. Null for every other kind. Carried through to the transcript
+ /// on FormattedLine; the per-message colour itself now comes from
+ /// the retail colour table keyed by LogTextType.
///
public Combat.CombatLineKind? CombatKind { get; init; }
diff --git a/src/AcDream.Platform/packages.win-x64.lock.json b/src/AcDream.Platform/packages.win-x64.lock.json
index c0ad5490..0f43b377 100644
--- a/src/AcDream.Platform/packages.win-x64.lock.json
+++ b/src/AcDream.Platform/packages.win-x64.lock.json
@@ -1,14 +1,7 @@
{
"version": 2,
"dependencies": {
- "net10.0": {
- "Microsoft.NET.ILLink.Tasks": {
- "type": "Direct",
- "requested": "[10.0.8, )",
- "resolved": "10.0.8",
- "contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw=="
- }
- },
+ "net10.0": {},
"net10.0/win-x64": {}
}
}
\ No newline at end of file
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
deleted file mode 100644
index 9e6bf097..00000000
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs
+++ /dev/null
@@ -1,225 +0,0 @@
-using System.Linq;
-using System.Numerics;
-using AcDream.Core.Chat;
-using AcDream.Core.Combat;
-
-namespace AcDream.UI.Abstractions.Panels.Chat;
-
-///
-/// The chat panel. Shows the tail of + an input
-/// field at the bottom that submits on Enter.
-///
-///
-/// Phase I.4 added the input field and slash-command parsing. Supported
-/// prefixes (alias-matched against the verb token, not by string-prefix
-/// — so /general is NOT /g):
-///
-/// /say <msg> or no prefix → Say (default)
-/// /t / /tell <name> <msg> → whisper
-/// /r / /reply <msg> → reply to most recent
-/// INCOMING Tell (uses ;
-/// drops the message if no Tell has arrived yet)
-/// /g, /f, /a, /m, /p, /v, /cv, /lfg, /trade, /role, /society,
-/// /olthoi <msg> → corresponding channel
-/// unknown /xyz hello → Say with the literal text intact
-/// (matches holtburger fall-through)
-///
-///
-///
-///
-/// Empty / whitespace-only / target-but-no-message inputs are silently
-/// dropped — the input field clears and no command goes out.
-///
-///
-public sealed class ChatPanel : IPanel
-{
- private const int InputBufferMaxLen = 512;
-
- private readonly ChatVM _vm;
- private string _input = string.Empty;
-
- // Phase J Tier 3: tracks the chat-tail size between frames so we
- // can auto-scroll the scrollable child to the bottom on new
- // entries without yanking the user's manual scroll.
- private int _lastRenderedCount;
-
- // Phase K.2: one-shot focus request for the chat input. Set by
- // FocusInput() (driven by Tab → ToggleChatEntry); the next Render
- // call emits SetKeyboardFocusHere immediately before the input
- // field and clears the flag. Without the one-shot semantics, the
- // panel would steal focus on every frame and the user could never
- // click into another widget.
- private bool _focusRequested;
-
- // L.0 follow-up: "Copy mode" — when true, render the chat tail as
- // a read-only multi-line text widget the user can click+drag to
- // select + Ctrl+C to copy. Trades per-line color for selectability;
- // user toggles when they want to grab specific text out of the
- // log (item names, coordinates, NPC dialogue, etc).
- private bool _copyMode;
-
- public ChatPanel(ChatVM vm)
- {
- _vm = vm ?? throw new ArgumentNullException(nameof(vm));
- }
-
- ///
- public string Id => "acdream.chat";
-
- ///
- public string Title => "Chat";
-
- ///
- public bool IsVisible { get; set; } = true;
-
- ///
- /// Phase K.2: request keyboard focus for the chat input on the
- /// NEXT . One-shot — fires once and resets,
- /// so callers (e.g. GameWindow's Tab handler subscribing to
- /// ToggleChatEntry) can drive it on a single key press
- /// without trapping the user permanently in the input field.
- ///
- public void FocusInput() => _focusRequested = true;
-
- ///
- public void Render(PanelContext ctx, IPanelRenderer renderer)
- {
- if (!renderer.Begin(Title))
- {
- renderer.End();
- return;
- }
-
- // L.0 follow-up: wrap the entire chat panel body in a single
- // outer BeginChild so empty-space clicks anywhere in the body
- // (Checkbox row, between Separator and input, etc.) are
- // absorbed by BeginChild's drag-trap (an InvisibleButton the
- // ImGui renderer adds inside every BeginChild). Without this
- // wrapper the chat panel was draggable from any empty body
- // pixel — only the inner ##chattail area was protected.
- if (!renderer.BeginChild("##chatbody", new System.Numerics.Vector2(0f, 0f)))
- {
- renderer.EndChild();
- renderer.End();
- return;
- }
-
- // L.0 follow-up: top-of-panel "Copy mode" toggle. When on, the
- // chat tail rendering swaps to TextMultilineReadOnly so the
- // user can mark + Ctrl+C any text. Off (default) preserves the
- // colored per-line render with combat highlights. The checkbox
- // sits ABOVE the chat tail (not in the footer) so it's always
- // visible regardless of scroll position.
- bool copyMode = _copyMode;
- if (renderer.Checkbox("Copy mode (select text to Ctrl+C)", ref copyMode))
- _copyMode = copyMode;
- renderer.Separator();
-
- // Phase J Tier 3: keep the input field at the bottom of the
- // window across resizes by reserving footer space and putting
- // the chat tail in a scrollable child that fills the rest.
- // The reserved footer holds: one Separator + one InputText.
- // FrameHeightWithSpacing covers the input; we add a small fudge
- // (~6px) for the separator above it.
- float footerHeight = renderer.FrameHeightWithSpacing() + 6f;
-
- // Phase I.7: pull the typed-line view so combat entries can
- // route through TextColored. Non-combat entries still take
- // the plain Text path (visually identical to the I.4 panel).
- var lines = _vm.RecentLinesDetailed();
-
- if (_copyMode)
- {
- // Copy mode: one big read-only multiline text widget
- // holding every visible line, joined with newlines. Loses
- // per-line color but lets the user click+drag to select
- // arbitrary spans of text + Ctrl+C to copy. Sized to fill
- // the available space minus the footer.
- string joined = lines.Count == 0
- ? "(no messages yet)"
- : string.Join("\n", lines.Select(l => l.Text));
- renderer.TextMultilineReadOnly(
- "##chattailcopy", joined,
- new System.Numerics.Vector2(0f, -footerHeight));
- }
- else if (renderer.BeginChild("##chattail", new System.Numerics.Vector2(0, -footerHeight)))
- {
- if (lines.Count == 0)
- {
- renderer.Text("(no messages yet)");
- }
- else
- {
- for (int i = 0; i < lines.Count; i++)
- {
- var line = lines[i];
- if (line.Kind == ChatKind.Combat)
- {
- // Campaign CH slice CH1: color combat lines from the
- // retail LogTextType table (Combat_Self/Combat_Enemy/
- // Default per CombatChatTranslator's ACE-cited
- // mapping) instead of the CombatLineKind info/
- // warning/error severity bucket. Every LogTextType
- // CombatChatTranslator emits is in-range (<0x22), so
- // the fallback below is defensive only.
- Vector4 color = RetailChatColorTable.TryGetColor(line.LogTextType, out var resolved)
- ? resolved
- : ColorForCombat(line.CombatKind ?? CombatLineKind.Info);
- renderer.TextColored(color, line.Text);
- }
- else
- {
- renderer.Text(line.Text);
- }
- }
- }
-
- // Auto-scroll to bottom only when a new line was appended
- // since the last render. Manual user scroll-up isn't fought
- // against; new messages will jump the view back down once
- // they arrive.
- if (lines.Count > _lastRenderedCount)
- {
- renderer.SetScrollHereY(1.0f);
- }
- _lastRenderedCount = lines.Count;
- }
- if (!_copyMode) renderer.EndChild();
-
- // Phase I.4: input field. Backend implementation clears _input
- // on submit per the IPanelRenderer contract.
- renderer.Separator();
- // Phase K.2: honor a pending FocusInput() request — emit
- // SetKeyboardFocusHere immediately before the input widget so
- // ImGui (or the future custom backend) applies it to that
- // field. One-shot: clear the flag after firing.
- if (_focusRequested)
- {
- renderer.SetKeyboardFocusHere();
- _focusRequested = false;
- }
- if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted)
- && submitted is not null)
- {
- ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say);
- _input = string.Empty;
- }
-
- renderer.EndChild(); // outer ##chatbody
- renderer.End();
- }
-
- ///
- /// Phase I.7: per-severity color for combat-feedback chat lines.
- /// Maps onto holtburger's color_for_tags at chat.rs:330-333
- /// (info → yellowish, warning → red incoming, error → deep red).
- ///
- public static Vector4 ColorForCombat(CombatLineKind kind) => kind switch
- {
- CombatLineKind.Info => new Vector4(1.0f, 1.0f, 0.6f, 1.0f),
- CombatLineKind.Warning => new Vector4(1.0f, 0.5f, 0.5f, 1.0f),
- CombatLineKind.Error => new Vector4(1.0f, 0.3f, 0.3f, 1.0f),
- _ => new Vector4(1f, 1f, 1f, 1f),
- };
-
-}
diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
index 30081845..ee476db5 100644
--- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs
@@ -393,10 +393,9 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
: entry.ChannelName;
///
- /// Phase I.7: snapshot of the chat tail with kind metadata so
- /// can pick the right rendering primitive
- /// per entry (plain Text for most kinds; TextColored
- /// for combat lines, with the rgba chosen from
+ /// Phase I.7: snapshot of the chat tail with kind metadata so the
+ /// transcript can pick the right rendering per entry (most kinds take one
+ /// colour; combat lines take the rgba chosen from
/// ). Campaign CH slice CH1 also
/// carries through — the retail
/// color key, keyed independently of .
@@ -458,10 +457,8 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
}
///
-/// Phase I.7: formatted chat line with kind metadata. The
-/// switches on +
-/// to pick a rendering primitive
-/// (Text vs TextColored(rgba)).
+/// Phase I.7: formatted chat line with kind metadata. The transcript switches
+/// on + to pick a colour.
///
///
/// Campaign CH slice CH1: the retail wire LogTextType that keys
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs
deleted file mode 100644
index 95f7df1a..00000000
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelFocusTests.cs
+++ /dev/null
@@ -1,71 +0,0 @@
-using AcDream.Core.Chat;
-using AcDream.UI.Abstractions.Panels.Chat;
-
-namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
-
-///
-/// Phase K.2 — Tab fires ,
-/// which calls . The chat panel honors
-/// the request on the very next by emitting
-/// a SetKeyboardFocusHere immediately before the input field. After
-/// it fires once, subsequent renders without another FocusInput
-/// call must not re-fire (one-shot semantics) — otherwise the chat field
-/// would steal focus on every frame and the user could never click out.
-///
-public sealed class ChatPanelFocusTests
-{
- private sealed class NullBus : AcDream.Runtime.Chat.ICommandBus
- {
- public void Publish(T command) where T : notnull { }
- }
-
- [Fact]
- public void FocusInput_NextRender_EmitsSetKeyboardFocusHereBeforeInput()
- {
- var panel = new ChatPanel(new ChatVM(new ChatLog()));
- var renderer = new FakePanelRenderer();
-
- panel.FocusInput();
- panel.Render(new PanelContext(0.016f, new NullBus()), renderer);
-
- // Find the SetKeyboardFocusHere call; it must come before the
- // InputTextSubmit call so ImGui applies the focus to that widget.
- int focusIdx = -1, inputIdx = -1;
- for (int i = 0; i < renderer.Calls.Count; i++)
- {
- if (renderer.Calls[i].Method == "SetKeyboardFocusHere") focusIdx = i;
- else if (renderer.Calls[i].Method == "InputTextSubmit") inputIdx = i;
- }
- Assert.True(focusIdx >= 0, "ChatPanel must call SetKeyboardFocusHere when FocusInput requested.");
- Assert.True(inputIdx >= 0, "ChatPanel must still render the InputTextSubmit field.");
- Assert.True(focusIdx < inputIdx, "SetKeyboardFocusHere must precede the InputTextSubmit it targets.");
- }
-
- [Fact]
- public void Render_WithoutFocusInputCall_DoesNotEmitSetKeyboardFocusHere()
- {
- var panel = new ChatPanel(new ChatVM(new ChatLog()));
- var renderer = new FakePanelRenderer();
-
- panel.Render(new PanelContext(0.016f, new NullBus()), renderer);
-
- Assert.DoesNotContain(renderer.Calls, c => c.Method == "SetKeyboardFocusHere");
- }
-
- [Fact]
- public void FocusInput_OnlyAffectsTheNextRender_OneShot()
- {
- var panel = new ChatPanel(new ChatVM(new ChatLog()));
-
- // Frame 1 — FocusInput requested → expect a SetKeyboardFocusHere.
- var r1 = new FakePanelRenderer();
- panel.FocusInput();
- panel.Render(new PanelContext(0.016f, new NullBus()), r1);
- Assert.Contains(r1.Calls, c => c.Method == "SetKeyboardFocusHere");
-
- // Frame 2 — no further FocusInput call → must NOT re-fire.
- var r2 = new FakePanelRenderer();
- panel.Render(new PanelContext(0.016f, new NullBus()), r2);
- Assert.DoesNotContain(r2.Calls, c => c.Method == "SetKeyboardFocusHere");
- }
-}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
deleted file mode 100644
index 77aac3f7..00000000
--- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatPanelInputTests.cs
+++ /dev/null
@@ -1,358 +0,0 @@
-using AcDream.Core.Chat;
-using AcDream.UI.Abstractions.Panels.Chat;
-
-namespace AcDream.UI.Abstractions.Tests.Panels.Chat;
-
-///
-/// Phase I.4: when the user submits text via the chat input field, the
-/// panel must publish the appropriate typed intent to the command bus.
-/// We exercise the full Render path with the
-/// pre-loading a "submitted" string and a recording bus capturing the
-/// resulting command.
-///
-public sealed class ChatPanelInputTests
-{
- private sealed class RecordingBus : ICommandBus
- {
- public List