# Changelog

## [v0.5.1] (2026-05-25) - Bug fix roll-up

### Improvements

- **RowList: interactive hyperlinks on every row** — Cell text containing `|Hitem:...|h[Name]|h` (or any other `|H...` markup — spell / achievement / quest) now hover-shows the `GameTooltip` with the linked subject, plain-click defers to Blizzard's `HandleModifiedItemClick` (which handles shift-into-chat, ctrl-dressup, etc.), and non-item links fall back to `ChatEdit_InsertLink` for the shift-into-chat case. Implementation: `row:SetHyperlinksEnabled(true)` + `OnHyperlinkEnter` / `OnHyperlinkLeave` / `OnHyperlinkClick` scripts in `_buildRow`. Guarded on `SetHyperlinksEnabled` presence so Vanilla (where the API doesn't exist) silently degrades to inert rendered markup as before. Applies to every RowList consumer at once — Guild Bank Log item column was the trigger for the feature (item links rendered as colored bracketed text but weren't hoverable), but Mail Log / Trade Log / Gratz also benefit. `.luarc.json` gained `HandleModifiedItemClick`, `IsModifiedClick`, `ChatEdit_InsertLink` globals. Location: `GUI/RowList.lua`, `.luarc.json`.

- **RowList: per-column `autoFit` measures the widest visible cell and grows the column to fit** — New optional column descriptor flags `autoFit = true` + `minWidth = N`. On every `SetData()`, RowList walks the data (and the column header label) measuring each cell's rendered width via a cached hidden `UIParent:CreateFontString` per font face. Each autoFit column gets `col.width = max(col.minWidth, measured + 8 px padding)`. After measurement, `_repositionColumns` rewrites every header button and every row cell's `SetWidth` + `SetPoint` in place — same two-pass logic as the existing _buildHeader (right-anchored cols right of the auto col, left-anchored cols left of it, stretchy auto col between). `:New()` seeds `col.width = col.minWidth` on autoFit cols before the first `_buildHeader` so the initial render has something to anchor against, then SetData's auto-fit pass grows widths to real content. Result: columns are exactly as wide as their longest visible entry, with no dead horizontal space inside fixed-width columns. The stretchy column (the one without a width) absorbs whatever space remains — making it the only column that can be visually truncated when the window is too narrow, since every other column was sized to fit content exactly. Backward compatible: columns without `autoFit` keep their static `width` (Mail / Trade / Addon Load / Gratz / Guild Bank tabs unchanged). Location: `GUI/RowList.lua`.

- **AutoFit applied to every RowList consumer (Mail Log / Trade Log / Guild Bank Log / Addon Load / Gratz)** — User confirmed Guild Log's autoFit behaviour was working well and asked to apply globally. Each consumer's fixed-width columns were converted from `width = N` to `autoFit = true, minWidth = M` with column-appropriate floors (60-80 for name/character cols, 50-60 for type/time/money cols, 35-45 for narrow numeric cols, ~45-55 for memory/load-time cols). Stretchy columns (the one without a width — Subject on Mail, Given on Trade, Item/Amount on Guild Bank, Addon Name on Addon Load) are unchanged and remain the sole truncation target per the established convention. Gratz's checkbox column kept its fixed `width = 30` (no autoFit — boxSize dictates the slot). The outdated "auto-width column always goes leftmost" comment was dropped from Gratz now that RowList supports mid-array auto columns. `_repositionColumns` updated to special-case checkbox cells when sibling autoFit columns trigger a column-layout pass — the box is re-centred within its slot instead of being SetWidth'd to the column width (which would stretch the CheckButton). Location: `GUI/MailLogSubTab.lua`, `GUI/TradeLogSubTab.lua`, `GUI/GuildBankLogSubTab.lua`, `GUI/AddonLoadTab.lua`, `GUI/GratzTab.lua`, `GUI/RowList.lua`.

- **Guild Log sub-tab — every column except Detail opted into autoFit; Detail is the rightmost stretchy truncatable reservoir** — Wired the new RowList `autoFit` flag on Time / Guild / Type / Player / Rank / Actor with reasonable `minWidth` floors (60–80 px). Detail dropped its fixed width and became the stretchy column, deliberately positioned rightmost so it's the only column subject to truncation. User constraint: non-rightmost columns must never truncate — autoFit honours this by always growing to fit content. Trial-gated to Guild Log only; once validated will be lifted into a shared library alongside RowList / DateRangePicker / addon.UI helpers (target: `TOGUI-1.0` LibStub-versioned standalone addon used across the TOG suite). Location: `GUI/GuildLogSubTab.lua`.

- **RowList: auto-width column can now live anywhere in the array** — Previous behaviour positioned the auto-width column (the one with `width = nil`) at the visual leftmost regardless of its array position, because `_buildHeader` / `_buildRow` used a single right-to-left pass that anchored the auto column at `LEFT_PAD` + total fixed-width offset. Caller convention was "put the stretchy column at array index 1"; Guild Log's Detail column (last in array, no width) was therefore rendering leftmost — the opposite of where users expect a "details" column to live. Fix: two-pass placement in both `_buildHeader` and `_buildRow`. Pass 1 walks right-to-left from `#columns` down to `autoColIndex + 1` and right-anchors those cols. Pass 2 walks left-to-right from `1` up to `autoColIndex - 1` and left-anchors those cols. Pass 3 stretches the auto column between the captured `autoColRightOffset` and `autoColLeftOffset`. `_makeHeaderColumn` signature renamed `autoLeftPad` → `anchorLeft` and grew a third case: LEFT-only anchored with explicit width (for the new pass 2). `_buildRow` factored the fixed-cell construction into a `placeFixedCell` closure so the checkbox + fontstring paths both branch on `anchorLeft` vs `anchorRight`. Net effect: array order now equals visual order; sub-tabs can put the stretchy column wherever they want. Backward compatible — Mail / Trade / Addon Load / Gratz / Guild Bank tabs all keep their auto column at array index 1, which still produces the same visual layout. Location: `GUI/RowList.lua`.

- **Guild Log Rank column populates default rank for join events** — Previously blank for join events because `e.rank` is empty from the API. User feedback: new members ARE assigned the guild's default rank on join, so the column should reflect that. New `GuildLog:GetDefaultRankFor(guildKey)` method walks `GetGuildRosterInfo` for the current guild and returns the rank name at the highest `rankIndex` (= lowest privilege = the default join rank). Result cached per session, keyed by guild key, so the roster scan only happens once per guild switch. Sub-tab Rank column format gained a join branch that calls into this. **Invite events stay deliberately blank** — the invitee may decline the invite and never actually get a rank, so attributing the default rank to an invite would be misleading; the matching join event (if/when it lands) carries the actual outcome. Limitation: only resolves for the character's currently-active guild — Blizzard's roster API only exposes one guild at a time, so entries from an alt's guild still fall back to an empty cell. Header tooltip rewritten to document the per-type rule. Location: `Modules/GuildLog/GuildLog.lua`, `GUI/GuildLogSubTab.lua`.

- **Guild Log sub-tab — Detail moved to rightmost, Player becomes the stretchy column** — Now that RowList supports an auto-width column in any array position, Guild Log's columns render in the order they appear in the array: Time | Guild | Type | Player | Rank | Actor | Detail. Detail gained an explicit `width = 90` (fixed-width rightmost), and Player (target) dropped its width to become the stretchy column — natural fit since player names vary most. Location: `GUI/GuildLogSubTab.lua`.

- **Guild Log sub-tab — new dedicated Rank column between Player and Actor** — User request: surface the player's guild rank as its own column rather than burying it inside Detail. Added `{ key = "rank", header = "Rank", width = 110 }` immediately after Player and before Actor in `GUI/GuildLogSubTab.lua`. Format function applies a per-event-type rule because Blizzard's `rank` field carries different semantics by event: for promote / demote it's the target's NEW rank; for quit / leave / remove it's the rank the target held at the time of the action; for join it's blank (the API doesn't expose the default-rank assignment in the log entry); for invite it's the INVITER's rank (NOT the invitee's), so we suppress it in this column — showing it next to the invitee's name would mislead readers into thinking the invitee already has Guild Master rank. Cell renders the rank in brand-gold (`|cffffd700`) when present, blank when not (no em-dash placeholder per the existing convention). Column tooltip documents the per-type rule. `fmtDetail` simplified to return `""` since Detail's only previous job (showing the rank for promote / demote) is now the Rank column's responsibility; Detail kept as the rightmost auto-width column, reserved for future event-specific annotations. Confirmed via the `/togt gldump` user provided that the rank data is already in SV for every applicable entry — no engine / capture changes needed. Location: `GUI/GuildLogSubTab.lua`.

- **Guild Log sub-tab — Actor column falls back to player name for self-events instead of rendering empty** — Confirmed via `/togt gldump` that out of 96 persisted entries on the user's TBC guild, 56 (~58%) were self-events (`type=join/quit`, occasionally `leave`) where Blizzard's `GetGuildEventInfo` returns nil for player2 because no officer is involved. The Actor column rendered those as `|cff888888—|r`, producing the "wall of em-dashes" the user wanted gone. Fix: format function on the Actor column now falls back to `e.target` when `e.actor` is empty — so a `quit` row for Hikorakara renders Player=Hikorakara, Actor=Hikorakara (visually saying "they did this to themselves") instead of Player=Hikorakara, Actor=—. Actor-driven events (invite/remove/promote/demote) are unchanged because `e.actor` is already populated for those. Column structure / width / sort key unchanged. Header tooltip rewritten to explain the fallback rule. Location: `GUI/GuildLogSubTab.lua`.

- **Guild Log: empty-string validation + ghost-row cleanup + debug trace** — TBC reproduction showed blank-everywhere rows landing in the SV. Root cause: `readEvent`'s `if not etype then return nil end` guard was falsy-only, so empty-string `etype` (or any blank field permutation) slipped past. The (type, target, actor, rank) dedupe key collapsed to `"|||"` for blank rows, so multiple distinct blank events folded into one persistent ghost row that survived every re-query. Fix: tighten `readEvent` to reject when `etype == ""` OR when `player1` is empty (a self-event with no player name is meaningless data), with `addon:Debug` traces that say why each rejection happened. Existing SV ghost rows are handled by a new one-shot `cleanupBlankEntries(bucket)` pass that runs once per session inside `ingest`, walks every bucket entry, and drops rows where the type is blank OR both actor and target are blank — marked on the bucket via `_blanksCleaned = true` so it doesn't repeat in the same session. The full ingest path is now traced through `addon:Debug` (gated by `db.global.debug`): skip-reasons, `n` from `GetNumGuildEvents`, bucket-before count, per-iteration new / deduped / readNil tallies, bucket-after count. `/togt glcheck` grew a "Blank/ghost entries in SV" audit that counts the existing blank rows and prints up to 3 samples so the user can confirm before letting cleanupBlankEntries drop them on the next ingest. Location: `Modules/GuildLog/GuildLog.lua`.

- **`/togt gbcheck` diagnostic command + verbose debug trace through the Guild Bank Log capture path** — Mirrors the existing `/togt glcheck` shape for Guild Log. `/togt gbcheck` prints API availability (`QueryGuildBankLog`, `GetNumGuildBankTransactions`, `GetGuildBankTransaction`, `GetNumGuildBankMoneyTransactions`, `GetGuildBankMoneyTransaction`, `GuildBankFrame`), in-guild status, `GuildBankFrame:IsShown()`, persisted entry count for the current guild, then — if the bank frame is shown — forces a per-tab `QueryGuildBankLog(1..7) + QueryGuildBankLog(8)` and 2 s later dumps the first row of each tab's buffer plus the money log, then runs `ingestTab` for every tab and reports the final persisted count. Empty-bank-frame and empty-API cases each get a specific failure-mode hint instead of a silent return. Engine paths gained `addon:Debug` instrumentation gated on `db.global.debug` (toggle via `/togt settings` > Verbose debug output): `GUILDBANKFRAME_OPENED` logs the scheduled `requestAllTabs`; `requestAllTabs` logs its enabled / API-present / in-guild gates and the tab range it queries; `GUILDBANKLOG_UPDATE` logs the arg1 it received; `ingestTab` logs per-tab `n`, bucket-before, new / deduped / readNil counts, bucket-after; `readItemTxn` / `readMoneyTxn` log when Blizzard returns a nil `etype`. Reason: empty Guild Bank Log on TBC reproduced by user — adding both the on-demand chat-dump diagnostic and the always-available debug trace so the failure mode (event-not-firing vs. buffer-empty vs. ingest-rejecting) can be pinned down on the next test pass without a code change. Location: `Modules/GuildBankLog/GuildBankLog.lua`, `SlashCommands.lua`.

### Bug Fixes

- **Guild Bank Log — move-row Tab cell rendered an unsupported glyph instead of an arrow on TBC / Anniversary** — `fmtTab` formatted move-type transactions as `"%d → %d"` using U+2192 RIGHTWARDS ARROW; WoW's default chat font on TBC and Anniversary clients doesn't ship that code point, so the cell showed a missing-glyph box between the two tab numbers. Swapped to ASCII `>` so the cell always renders. Same swap propagated to the Tab column header tooltip text. Location: `GUI/GuildBankLogSubTab.lua`.

- **Guild Bank Log — bank-open ingest never ran on TBC / Anniversary because `GUILDBANKLOG_UPDATE` doesn't reliably fire there** — Follow-up after the previous bank-open fix still didn't repopulate. User confirmed `/togt gbcheck` populated INSTANTLY when run manually post-open — gbcheck fires `QueryGuildBankLog` per tab and then ingests 2 s later. That timing difference was the diagnostic: the on-open flow was firing queries and the server WAS responding and populating the per-tab buffers, but `GUILDBANKLOG_UPDATE` (the event our ingest waits for) wasn't firing on TBC / Anniversary — identical failure mode to `GUILD_EVENT_LOG_UPDATE` getting silenced on Retail Midnight (legacy notification event dropped while the legacy read API was kept). Fix: `requestAllTabs` now schedules an explicit `C_Timer.After(2, ingest all tabs)` directly after firing the queries, exactly like `requestQuery` does in `Modules/GuildLog/GuildLog.lua`. The `GUILDBANKLOG_UPDATE` handler stays registered (it does fire on some flavours) and `ingestTab` is idempotent via the dedupe map, so the double-fire is harmless. Net effect: open bank → 1 s deferral for Blizzard's UI queries to land → `requestAllTabs` fires → 2 s deferred ingest reads the now-populated buffers → table populates. No more 5-minute-ticker wait. Location: `Modules/GuildBankLog/GuildBankLog.lua`.

- **Guild Bank Log — Clear button silently re-populated when a deferred ingest was in flight** — User reported "Clear data button isn't clearing" after the previous bank-open ingest fix. Root cause: the new `requestAllTabs` deferred-ingest fallback was a fire-and-forget `C_Timer.After(2, ...)` with no cancellation handle. Timeline that triggered the bug: user opens bank → `requestAllTabs` fires queries + schedules ingest in 2 s → user clicks Clear (1st + 2nd confirm) within those 2 s → SV wiped → deferred ingest fires moments later → reads Blizzard's per-tab buffers (still populated client-side) → re-fills the SV → UI shows the data back. Fix: switch the deferred ingest to `C_Timer.NewTimer` and track the handle in a module-scope `_deferredIngestTimer`. New `cancelDeferredIngest()` helper kills any pending pass. `requestAllTabs` cancels any prior pending pass before scheduling a new one (so rapid consecutive calls don't queue overlapping ingests). `ClearAll` calls `cancelDeferredIngest()` before wiping the SV, so the queued ingest can't revert the clear. Same protection added implicitly to `/togt gbcheck` since it goes through `requestAllTabs`. Location: `Modules/GuildBankLog/GuildBankLog.lua`.

- **Guild Bank Log — first-open ingest delayed by 8 server round-trips even when buffers were already cached client-side** — `GUILDBANKFRAME_OPENED` deferred all ingest by 1 second + 8 sequential server round-trips for the 7 item tabs and the money log to refresh, so users saw blank tables for several seconds even when data was already cached client-side from a previous bank visit in the same session. Fix: `GUILDBANKFRAME_OPENED` now runs an immediate local-buffer ingest BEFORE scheduling the 1-second-deferred fresh `requestAllTabs`. `ingestTab` is idempotent via the dedupe map, so the post-response re-ingest is harmless. Net effect: opening the bank with cached data → instant table; bank with no cached data → falls back to the server-query-and-wait flow. Note: `ClearAll` deliberately does NOT re-ingest — that would defeat the user's clear intent. To get fresh data after Clear, close + reopen the bank or run `/togt gbcheck`. Location: `Modules/GuildBankLog/GuildBankLog.lua`.

- **Guild Bank Log — Player column blank for many transactions, including newest** — User report on TBC: lots of rows showed `—` in the Player column despite being recent deposits / withdrawals that obviously had a player attached. `/togt gbdump` revealed the actual problem: SV had `total=239` entries when the bank only had ~120 real transactions, and the per-type breakdown showed roughly half of every type's entries missing a name (deposit 128/74-hasName, withdraw 61/36, move 35/19). The dump's per-entry list made the duplication pattern obvious — same transaction appearing TWICE with identical type / itemSig / count / tab1 / tab2 / amount / ymdh, once with name populated and once with name=''. Root cause: Blizzard's `GetGuildBankTransaction` returns the SAME transaction on consecutive `QueryGuildBankLog` calls with inconsistent `name` field — populated on one query, nil on the next (likely a server-side race between the bank-log buffer and the player-name resolver on TBC / classic-flavour servers). Our `dedupeKey` included `name`, so the two payloads hashed to different keys and BOTH landed in the SV. Fix: (1) drop `name` from `dedupeKey` and `rowId` — Blizzard returns it unstably so it can't be part of identity; the remaining tuple (type, itemSig, count, tab1, tab2, amount) uniquely identifies the transaction. (2) Switch `ingestTab`'s `seen` set to a dedupeKey → existing-entry MAP so we can detect duplicates AND backfill `name` and `itemLink` onto an existing entry when a later query gives populated values where the earlier gave nil. (3) New `cleanupDuplicateEntries(bucket, guildKey)` one-shot pass runs on first `ingestTab` per session per bucket, rehashes existing SV entries under the new name-less key, collapses matches keeping the populated-name copy, and regenerates `id` fields from the new (name-less) formula. Marker `bucket._dupesCleaned = true` persists in SV so subsequent sessions don't redo the work. Trace gained a `backfilled` count in the ingest debug print. Same dedupe-collapse caveat as before: two truly-distinct transactions in the same hour with identical (type, item, count, tabs, amount) collapse to one. Location: `Modules/GuildBankLog/GuildBankLog.lua`.

- **Main window's gear/help icons tainted other addons that use AceGUI Frame** — Cleanup of the bottom-row icon strip was wired to the AceGUI Frame's `OnClose` callback, but `OnClose` only fires when the user clicks the AceGUI X-button. Every other close path — ESC via `_escProxy`'s `OnHide`, `/togt` toggle, minimap LMB toggle, `MainWindow:Rebuild` collapsing the strip empty, and even the just-added Settings-flag suppression refactor — calls `MainWindow:Close()` directly, which calls `AceGUI:Release(self.frame)`. Confirmed by reading `Ace3/AceGUI-3.0/AceGUI-3.0.lua:172-198`: `AceGUI:Release` fires `"OnRelease"` (line 177), wipes `widget.events` (line 188-190), reparents the frame back to UIParent, and pushes it into the shared widget pool — but it never fires `"OnClose"`. Net effect: every close path except the X-button left our `_gearIcon` and `_helpIcon` still parented to the released `f.frame`. Next addon to call `AceGUI:Create("Frame")` (FGI's main window, ChatCopyPaste, any AceConfigDialog Bliz-options group, etc.) got our frame back from the pool with our gear icon's `OnClick = function() addon:OpenSettings() end` still attached and visible. User-visible symptom: clicking what looked like FGI's gear opened TOG Tools' settings page, because the click was actually landing on our orphaned gear that AceGUI had reparented into FGI's frame. Fix: move the `addon.UI.DetachChildren(self, { "_helpIcon", "_gearIcon" })` call out of the `f:SetCallback("OnClose", ...)` handler and into `MainWindow:Close()` itself, immediately before `AceGUI:Release(self.frame)`. The X-button path now delegates: `f:SetCallback("OnClose", function() MainWindow:Close() end)` so all five close paths converge on the same cleanup. Re-checking the proxy lifecycle: `_escProxy:Hide()` was already in `MainWindow:Close()` and the `_settingsOpen` flag suppresses the recursive OnHide so the consolidation is safe. Comment added at `MainWindow:Close()` documenting WHY the cleanup must live there and not in `OnClose`, so future contributors don't move it back. Location: `GUI/MainWindow.lua`.

---

## [v0.5.0] (2026-05-24) - Gratz Module

### Bug Fixes (pre-release)

- **Gratz skipped cross-realm guildmate achievements** — `CHAT_MSG_GUILD_ACHIEVEMENT` for a cross-realm guildmate (arg2 = `"Xiomaara-MoonGuard"`) was correctly identified as a non-self event, but the guild-scope filter then rejected it with `skip: sender 'Xiomaara' not found in guild roster`. Root cause: `fireAchievement`'s defensive roster-membership check walks `GetGuildRosterInfo` for every entry and compares `shortName(memberName) == short`. Roster representation for cross-realm members varies (some flavours return "Name", some "Name-Realm", recently invited entries can be briefly absent during async load), so the local lookup is fragile. The check was redundant in the first place: `CHAT_MSG_GUILD_ACHIEVEMENT` is server-gated by Blizzard to deliver only to guild members about guild members, so trust the channel. Fix: thread a `fromGuildChannel` flag into `fireAchievement` (true when the source event is `CHAT_MSG_GUILD_ACHIEVEMENT`, false for `CHAT_MSG_ACHIEVEMENT`), and skip the roster-lookup branch when the flag is true. `CHAT_MSG_ACHIEVEMENT` still runs the lookup because that event fires for nearby non-guildies on Retail and genuinely needs the filter. Same-realm guildies (Spedo-Stormrage) were unaffected because their roster representation happened to match shortName-stripped. Location: `Modules/Gratz/Gratz.lua`.

- **Main window closed when the user dismissed the Settings panel** — Opening Settings (via the gear icon, `/togt settings`, or right-clicking the minimap button) correctly left the main window open, but closing Settings via ESC or its X button caused the main window to close alongside it. Root cause: `_escProxy` is registered in `UISpecialFrames` so ESC closes the main window — and Blizzard's ESC handler iterates `UISpecialFrames` via `CloseSpecialWindows`, hiding every visible entry in the list. While Settings was up, our proxy was Shown (re-armed on the next frame after the gear click). When the user pressed ESC to dismiss Settings, the cascade hid both the Settings panel AND our proxy, and the proxy's `OnHide` handler closed the main window. The X button on the Settings panel hits the same path because Blizzard's Settings close logic also calls `CloseSpecialWindows` internally. Fix: introduce `MainWindow._settingsOpen` inhibit flag. `addon:OpenSettings` sets the flag to true before calling `Settings.OpenToCategory` so the synchronous `CloseSpecialWindows` cascade triggered by the open call sees it set (the gear click previously needed an ad-hoc `_escProxy:SetScript("OnHide", nil)` swap for the open-time cascade; with the flag in place that dance is no longer needed and was removed). New `MainWindow:_HookSettingsClose` installs a one-shot `HookScript("OnHide")` on the Settings panel (Retail `SettingsPanel`, falling back to older Classic `InterfaceOptionsFrame`) the first time `OpenSettings` runs; the hook defers `_settingsOpen = false` and `_escProxy:Show()` by one frame via `C_Timer.After(0, ...)` so any same-frame `UISpecialFrames` cascade is still suppressed by the still-set flag. Result: gear click leaves main window open (as before), ESC or X on Settings closes only Settings, ESC after Settings closes the main window normally. Location: `GUI/MainWindow.lua`, `GUI/Settings.lua`.

### New Features

- **Guild Bank Log (phase 4)** — Fourth Logs sub-tab snapshotting Blizzard's in-game guild bank transaction log every time the bank frame opens, and persisting entries beyond Blizzard's small rolling buffer. Per-guild buckets keyed by `GuildName-PlayerRealm` (same partition scheme as Guild Log) so an account with alts in multiple guilds keeps independent histories. Engine recipe: `GUILDBANKFRAME_OPENED` schedules a `C_Timer.After(1, requestAllTabs)` (1-s delay so Blizzard's own queries land first and our reads aren't racing partial buffers); `requestAllTabs` issues `QueryGuildBankLog(tab)` for each of the 7 item tabs and the money log (tab 8); `GUILDBANKLOG_UPDATE` fires per-tab and triggers `ingestTab(tab)` which walks `GetNumGuildBankTransactions(tab)` + `GetGuildBankTransaction(tab, i)` (or the `GetGuildBankMoneyTransactions` pair for the money log), dedupes against persisted entries, and appends new ones. Safety re-query ticker every 5 min that only fires when `GuildBankFrame:IsShown()` (covers users parked at the bank watching new guildmate transactions). Entry shape: `{ kind, type, name, itemLink, itemSig, count, tab1, tab2, y, m, d, h, ts, amount, id }` — `kind = "item" / "money"`, `type` is Blizzard's transaction type (`deposit` / `withdraw` / `move` / `repair` / `withdrawForTab` / `depositSummary` / `buyTab`), `tab2` populated for `move` (destination tab), `amount` populated for money entries. Same ymdh-is-relative caveat as Guild Log: those fields are "X ago" offsets, not absolute calendar values, so they're stored verbatim and a synthesised `ts` is derived via `now - y*365*86400 - m*30*86400 - d*86400 - h*3600`. **Sync identity** — each entry carries a stable cross-viewer `id` field computed at ingest as `"gblog|<guildKey>|<type>|<name>|<itemSig>|<count>|<tab1>|<tab2>|<amount>"` with NO timestamp component (the ymdh tuple drifts every query). `itemSig` is the bare itemID parsed from `|Hitem:N:` to keep dedupe stable across player-context payloads (crafter names, suffix rolls). Two viewers ingesting the same transaction compute the same `id` so future cross-addon sync with TOGBankClassic / cross-account merge can dedupe deterministically. The `"gblog|"` prefix distinguishes from sibling log types (`"glog|"`, `"mlog|"`, `"tlog|"`). Public `InjectEntry(guildKey, entry)` API exposed for future TOGBankClassic integration to feed Vanilla bank snapshots directly into the same SV bucket (Classic Era predates guild banks; the engine no-ops gracefully when the bank API is absent and the sub-tab stays present as a target for injected entries). Sub-tab UI: filter row (Guild / Type / Date range / Clear) over a 7-column RowList — Time · Guild · Type · Player · Item/Amount · Qty · Tab — with type-coloured Type cells (green = deposit, yellow = withdraw, blue = move, orange = repair, purple = tab purchase), `GetCoinTextureString` rendering for money entries, and a `from → to` Tab-cell format for moves. Empty-state copy adapts: "open your guild bank" message on TBC+, "no guild banks on this version" message on Vanilla with mention of the TOGBankClassic injection path. Permission-gated like Guild Log — players without View Tab on a tab see nothing for that tab, money log requires Withdraw Gold privilege. Sub-tab Clear button uses `addon.UI.AttachTooltip` for hover help and the standard two-step confirm. Settings > Modules > Log categories grows a |cffffd700Guild Bank Log|r toggle at order 40; inline group still greys out when the Logs master toggle is off. Slash deep-links: `/togt logs guildbank` (aliases: `/togt logs bank`, `/togt logs gbank`) and the shorthand `/togt gb`. SV partition: `db.global.guildBankLog.guilds[GuildKey].entries`. `.luarc.json` gains globals for `QueryGuildBankLog`, `GetGuildBankTransaction`, `GetGuildBankMoneyTransaction`, `GetNumGuildBankTransactions`, `GetNumGuildBankMoneyTransactions`, `GuildBankFrame`, `GetCoinTextureString`, `MAX_GUILDBANK_TABS`. Location: `Modules/GuildBankLog/GuildBankLog.lua`, `GUI/GuildBankLogSubTab.lua`, `TOGTools.lua` (DB defaults), `GUI/Settings.lua`, `SlashCommands.lua`, `GUI/LogsTab.lua` (help blurb), `.luarc.json`, all six `TOGTools*.toc`.

- **RowList gains interactive checkbox columns** — ported from FGI's RowList. Column descriptors now accept `checkbox = true` + optional `boxSize` (default 16) + `onToggle(entry, val, index, rl)` to render a real `UICheckButtonTemplate` cell instead of a text fontstring. Click toggles the row's `entry[col.key]` boolean, then fires `onToggle`. Cell render path detects `col.checkbox` and calls `SetChecked` instead of `SetText`. Used by the Gratz tab's "On" column so users can enable/disable profiles directly from the list without round-tripping through the form. Location: `GUI/RowList.lua`.

- **Gratz module — auto-congratulations on triggered events** — New top-level tab + engine for sending congratulatory chat messages when something gratz-worthy happens. Profile-based: each profile names a trigger type, its criteria, the destination channels, a templated message, and a cooldown. Profiles persist under `db.global.gratz.profiles[name]` and are managed FGI-Filters-tab-style — top form with Name / Trigger / criteria / Channels / Message / Cooldown / Save, RowList below showing every profile with On / Name / Trigger / Channels / Count / delete. Click a row to load it into the form; Save with an existing name updates in place. Phase-1 trigger types: |cffffd700achievement|r (Retail — hooks `CHAT_MSG_ACHIEVEMENT` for guild + visible achievements, `ACHIEVEMENT_EARNED` for the player's own IDs to cross-reference points / category / link via `GetAchievementInfo`); |cffffd700level-up (party)|r (UNIT_LEVEL + GROUP_ROSTER_UPDATE bootstrap); |cffffd700level-up (guild)|r (GUILD_ROSTER_UPDATE event + 60-second polling ticker with a level-diff cache). The achievement-trigger profile sits dormant on Classic Era / TBC (no achievement system); level-up triggers work on every flavour. Template placeholders: `[player]`, `[achievement]`, `[link]`, `[points]`, `[category]`, `[level]`. Per-(profile, target, eventKey) cooldown (default 300 s; eventKey = achievement ID for achievement triggers, "lvl:N" for level-ups) prevents spam from duplicate events around loading screens. Skip-self is hardcoded — the engine never congratulates the player on their own events. Combat-safe send: messages queue and flush on `PLAYER_REGEN_ENABLED`. Level-up tracking pattern adapted from AutoGrats (MIT, Copyright 2024 elromanov) with attribution in the file header; achievement triggering is original. Picked up automatically by the per-module toggle framework via `Tab.configKey = "gratz"` — Settings > Modules > Gratz toggles the entire module on/off. New slash commands: `/togt gratz` and `/togt gz`. Location: `Modules/Gratz/Gratz.lua`, `GUI/GratzTab.lua`, `TOGTools.lua` (DB defaults), `SlashCommands.lua`, all six `TOGTools*.toc`.

---

## [v0.4.0] (2026-05-24) - Logs Nexus & Retail Compatibility

### Bug Fixes (pre-release)

- **Mail Log collapsed N consecutive mails from the same sender into one row** — `appendReceive` merged into the most-recent entry when `(player + sender)` matched within a 5-second window. An addon like Postal opening 12 mails from the Auction House in quick succession landed them all in the same entry, with same-`itemLink` items stacking — the user saw one row containing the aggregate, looking like "only the first mail was logged." Fix: switch the merge key from `(last-entry sender + time window)` to a per-`MailIndex` cache. Items, money, and COD updates for the same `MailIndex` still merge into one entry (correct for a single Take-All on a multi-item mail); takes on a different `MailIndex` always create a new entry even if the sender matches. The cache is sender-guarded too — if the mailbox got refreshed and indices shifted, a cached entry at the same slot with a different sender no longer matches, so we create a fresh entry rather than mis-merging. Cache is wiped on `MAIL_CLOSED`. Location: `Modules/MailLog/MailLog.lua`.

- **Calendar SimpleGroup height collapsed to 0, empty-state body label overlapped the day grid** — Custom-day calendar widget had `SetHeight(230)` but the empty-state body rendered on top of the buttons. Root cause: AceGUI's `SimpleGroup` has a `LayoutFinished` hook (per [Ace3 source line 27-30](https://github.com/WoWUIDev/Ace3/blob/master/AceGUI-3.0/widgets/AceGUIContainer-SimpleGroup.lua)) that auto-overwrites the SimpleGroup's height with the measured height of its AceGUI children every time the parent runs `DoLayout`. The calendar has no AceGUI children (only raw `CreateFrame` buttons + dropdowns parented to `.content`), so `LayoutFinished(0)` reset height to 0 right after my explicit `SetHeight(230)`. The parent's Flow layout then placed `body` at Y=0 thinking the calendar was zero-height — body overlapped the buttons. Diagnosed via temporary `addon:Debug` prints hooking `SetHeight` and `LayoutFinished`. Fix: set `group.noAutoHeight = true` before any layout pass (the escape-hatch flag Ace3 itself reads at line 28: `if self.noAutoHeight then return end`). Also `SetLayout("Fill")` so subsequent `DoLayout` calls don't recurse through the (unused) "List" default. Diagnostic prints removed after confirming `frame.h=230` survives end-to-end. Location: `GUI/DateRangePicker.lua`.

- **Calendar greyed out days without entries, making it look like a "days with data" indicator rather than a date picker** — initial design gated each cell's `Enable()` on an availability set computed from current entries (mirrored MailLogger's UX). User feedback: they want to pick ANY date and see entries-or-empty, not be limited to days that already have data. Fix: every in-month day enables unconditionally; the availability set is no longer consulted for cell enablement (still consulted for the year-dropdown list so the year selector shows years with data). Removed the unused `availMonth` local. Location: `GUI/DateRangePicker.lua`.

- **"Mail Log captures every mail..." onboarding hint cluttered the Custom-mode no-date state** — when the user picked Custom day but hadn't selected a date yet, the empty-state body still rendered below the calendar with the generic "no entries match" message. In that state the calendar IS the prompt, and the onboarding hint is noise. Fix: all three sub-tabs (Mail / Trade / Guild) early-return from `Draw` after adding the calendar when `_filterRange == "custom"` and `_filterCustomDate` is nil, skipping the spacer + body. Once the user picks a day, the table renders normally. Location: `GUI/MailLogSubTab.lua`, `GUI/TradeLogSubTab.lua`, `GUI/GuildLogSubTab.lua`.

- **Guild Log auto-capture never populated on Retail** — even after fixing the function name (`QueryGuildEventLog` instead of the legacy `GuildEventLog_QueryGuild` UI-helper) the log stayed empty on login. Diagnostic `/togt glcheck` showed Blizzard's buffer correctly held 100 events post-query and our `ingest()` happily appended all 100 when invoked manually — but the wired `GUILD_EVENT_LOG_UPDATE` event handler never fired in the auto-capture path. **Root cause: on Retail Midnight the legacy notification event has been dropped even though the legacy read API was kept.** The Communities UI uses its own signaling mechanism and `GUILD_EVENT_LOG_UPDATE` no longer fires after `QueryGuildEventLog()`. Fix: `requestQuery` now schedules an explicit `C_Timer.After(1.5, ingest)` immediately after calling `QueryGuildEventLog()`, so the buffer gets ingested whether or not the event fires. The event handler is still registered for Classic flavours where the event does fire; `ingest` is idempotent via dedupe so double-firing on Classic is harmless. Location: `Modules/GuildLog/GuildLog.lua`.

- **Guild Log timestamps were treated as absolute dates instead of "X ago" offsets** — `GetGuildEventInfo` returns `(year, month, day, hour)` but on Retail these are RELATIVE offsets (years-ago, months-ago, days-ago, hours-ago), not absolute calendar values. Confirmed empirically by re-running the diagnostic an hour apart and watching the same three events shift from `ymdh=0/0/0/3` to `0/0/0/4`. `tupleToTs` was treating them as absolute and calling `time({year=0, month=0, day=0, hour=4, ...})` which gave nonsense timestamps (Lua's `time()` normalises month=0 to December-of-previous-year etc.). Fix: subtract from `GetServerTime()` — `now - y*365*86400 - m*30*86400 - d*86400 - h*3600`. Calendar-accurate month arithmetic isn't worth it given the underlying precision is hour-coarse. Location: `Modules/GuildLog/GuildLog.lua`.

- **Guild Log dedupe key included the drifting "X ago" fields, would have re-added every event on every query** — the dedupe tuple was `(y, m, d, h, type, target, actor)` but ymdh values drift with every re-query (3-hours-ago becomes 4-hours-ago an hour later). Even if the auto-capture had worked, every 2-minute ticker tick would have seen "new" entries that were really the same events with updated relative-age fields, exploding the SV. Fix: drop ymdh from the dedupe key entirely — `(type, target, actor, rank)` is stable across queries. Edge case: same-person-repeatedly-joining-the-same-rank collapses to one entry; acceptable because Blizzard's 100-entry buffer cap means by the time a re-join happens, the original event has rolled off and the new join appears fresh. Location: `Modules/GuildLog/GuildLog.lua`.

- **Guild Log captured nothing — wrong function name** — The engine called `GuildEventLog_QueryGuild()`, which was a UI-helper from Blizzard's old `Blizzard_GuildUI` standalone addon. That UI was retired when the Communities frame replaced it on Retail (8.0 / Battle for Azeroth, Aug 2018), so the helper is no longer present in the live Retail UI — our `if not GuildEventLog_QueryGuild then return end` guard silently no-op'd and never queried Blizzard's buffer. The actual underlying API global is **`QueryGuildEventLog()`** (one word, capital Q) — that's the function the legacy UI-helper wrapped, and it's still present on every supported flavour including Retail. Verified by searching Guild Roster Manager's source ([GRM_ScanRoster.lua](https://www.curseforge.com/wow/addons/guild-roster-manager) calls `QueryGuildEventLog()` from its retail-shipping codebase). Fix: rename the call site, drop the (now mistaken) `isRetailUnsupported` short-circuit added during the misdiagnosis, swap `.luarc.json` globals entry to `QueryGuildEventLog`. `GetNumGuildEvents` and `GetGuildEventInfo` were correct already. Location: `Modules/GuildLog/GuildLog.lua`, `.luarc.json`.

- **Mail Log table cells rendered past the left edge of the window** — RowList anchors columns right-to-left, computing the auto-width column's width as `parent_width - sum_of_fixed_widths`. Mail Log had 7 columns (Time 80, Dir 60, Character 140, Other 140, Subject auto, Items 160, Money 90) for ~670 px of fixed widths + ~54 px of gaps/padding/scrollbar gutter ≈ 724 px total — but the default 760 px Logs window has only ~710 px of effective inner content area after AceGUI Frame chrome and the inner TabGroup padding. The auto-width Subject column came out roughly -14 px wide, which inverted its anchors and caused every right-of-Subject cell (Items, Money) to render at negative positions, spilling past the parent's left edge. Fix has two parts: (1) drop the dedicated Dir column entirely and fold direction into the Other column via a coloured arrow prefix (`|cff66ff66<-|r Sender` for received, `|cffffaa55->|r Recipient` for sent) — saves ~64 px without losing information; (2) shrink Character (140→110), Other (140→145, slight widen to fit the arrow prefix), Items (160→130), Money (90→80). New fixed total ≈ 545 px, leaves ~165 px for Subject at default width. Same shrink applied prophylactically to Trade Log (Character 130→110, Partner 130→110, Received 175→150, Enchant 110→90 — fixed total 540 px) and Guild Log (Guild 130→110, Player 140→120, Actor 140→120 — fixed total 525 px), both of which had the same latent overflow at the old default. Also bumped `Logs.WINDOW_SIZE` width 760→820 and minWidth 600→740 so the columns stay positive even at the new minimum. Location: `GUI/MailLogSubTab.lua`, `GUI/TradeLogSubTab.lua`, `GUI/GuildLogSubTab.lua`, `GUI/LogsTab.lua`.

### Improvements (pre-release)

- **Custom range picker — two-click range selection** — "Custom day..." renamed to "Custom..." and reworked to support multi-day ranges via a two-click pattern (universal: Google Calendar / Outlook / GitHub all use it). First click stages the range start with a visual highlight; second click later than start stages the end and every day between gets highlighted; second click earlier than start treats as a new start (per user preference — simpler than auto-swapping endpoints); third click anywhere resets to a fresh single-day range. Apply commits the staged range. Single-click + Apply still works as before (committed as a single-day window). State tracked as `startY/M/D` and `endY/M/D` on the calendar widget so navigation between months preserves the picked range — endpoints in months you're not currently viewing stay staged and re-highlight when you scroll back. Apply button moved from bottom-center to **top-right** (same row as the year / month dropdowns) per user feedback that the bottom placement overlapped sibling UI; calendar SimpleGroup height trimmed 260→210 since the bottom slot is no longer needed. New API: `CreateCalendar` opts `defaultFrom` / `defaultTo` (replace `default`), `onApply(fromTs, toTs)` (toTs is nil for single-day picks). `Resolve(key, customFrom, customTo)` produces a midnight-to-end-of-day-inclusive window from the (from, to) pair; nil `customTo` is treated as a single-day range. Sub-tab state split: `_filterCustomDate` → `_filterCustomFrom` + `_filterCustomTo`. "Pick date..." button renamed "Pick range..." to match. Location: `GUI/DateRangePicker.lua`, `GUI/MailLogSubTab.lua`, `GUI/TradeLogSubTab.lua`, `GUI/GuildLogSubTab.lua`.

- **Custom-day calendar: explicit Apply button + collapse-on-commit** — Previously, clicking a day in the calendar immediately committed the selection and rebuilt the sub-tab, but the calendar stayed visible alongside the now-filtered results — cluttering the view. New flow: clicking a day STAGES the selection (visual highlight only, no rebuild), an Apply button below the grid (disabled until a day is picked) commits the date and collapses the calendar. State tracked as `pickedY / pickedM / pickedD` on the calendar widget so navigation between months preserves the picked day's highlight when you return to its month, and Apply commits the actually-chosen date even after the user browses other months. When the calendar is collapsed and a date is set, a "Pick date..." button appears in the filter row to re-open the calendar (date dropdown won't fire OnValueChanged when re-clicking the same Custom value, so an explicit button is required for the re-open path). Re-entering Custom mode from a different preset auto-opens the calendar via `_filterCalendarOpen` state set in the date-dropdown's onChange. SimpleGroup height bumped 230→260 to make room for the Apply button. Location: `GUI/DateRangePicker.lua`, `GUI/MailLogSubTab.lua`, `GUI/TradeLogSubTab.lua`, `GUI/GuildLogSubTab.lua`.

- **Guild Log entries carry a stable cross-addon row id** — Sync between guild-history addons needs a deterministic identifier so two clients can recognise the same event across their independent captures. Added an `id` field to each guild-log entry, computed at ingest as `"glog|<guildKey>|<type>|<target>|<actor>|<rank>"` — composed entirely from the stable dedupe-key fields plus the guild context, with NO timestamp component (the ymdh tuple drifts every query, the synthesised `ts` drifts further for old events due to 30-day month / 365-day year approximations). Two addons reading the same Blizzard buffer for the same guild compute the same `id` for the same logical event; sync code can compare on it directly. Backwards-compatible: existing SV entries without `id` get one computed and stored on first read via lazy migration in `GetEntries`. The `"glog|"` prefix leaves room for future `"mlog|"` / `"tlog|"` ids on Mail / Trade if sync expands; for now only Guild Log carries an id because that's where the user identified the use case. Not surfaced in the default UI — internal data for sync framework. Location: `Modules/GuildLog/GuildLog.lua`.

- **Per-sub-tab help blocks for the Logs nexus + i-icon dispatch** — Each log sub-category (Mail / Trade / Guild) now carries its own `help = { title, lines }` block describing its specific UI and behaviour. The main window's bottom-row help (i) icon detects when the active outer tab is `logs` and looks up the active SUB-TAB's help via `addon.logCategories[<subKey>].help` instead of falling back to the generic outer-Logs blurb. Sub-tab help replaces the parent help whenever it exists; the bottom-row icon descriptions still append on the tail. New tab → add `help` next to `subKey` / `label` and it lights up automatically. Location: `Modules/MailLog/MailLog.lua`, `Modules/TradeLog/TradeLog.lua`, `Modules/GuildLog/GuildLog.lua`, `GUI/MainWindow.lua`.

- **Column header tooltips on every log sub-tab** — `headerTip` + `headerTipDesc` populated on every column of Mail / Trade / Guild RowLists. Hovering a column header surfaces a tooltip explaining what the column shows, sort behaviour, and any precision caveats (e.g. Guild Log's hour-coarse time precision is called out on the Time column). Mirrors the existing pattern from the Addon Load tab. Location: `GUI/MailLogSubTab.lua`, `GUI/TradeLogSubTab.lua`, `GUI/GuildLogSubTab.lua`.

- **Guild Log time column shows date AND time** — was showing just `MM/DD/YY` for entries older than 7 days, which made it impossible to distinguish multiple events on the same day. Now formats as `MM/DD HH:00` for entries inside ~11 months and `MM/DD/YY HH:00` for older. Minute is hardcoded `:00` because Blizzard's `GetGuildEventInfo` API only exposes hour precision. Recent entries still use the relative "Just now" / "Nh ago" forms. Location: `GUI/GuildLogSubTab.lua`.

- **`/togt glcheck` diagnostic command** — Slash subcommand that prints API availability, in-guild status, current Blizzard buffer contents, and runs an instrumented inline ingest reporting how many events the dedupe/readEvent gates accept vs. reject. Kept around past the v0.4.0 triage in case Guild Log silently stops capturing in a future patch — much faster to triage than adding `print` statements live. Documented in the Guild sub-tab's help block. Location: `Modules/GuildLog/GuildLog.lua` (`Diagnose` method), `SlashCommands.lua`.

- **Guild Log dropped the manual Refresh button + tightened the periodic ticker from 30 min to 2 min** — `GUILD_ROSTER_UPDATE` is the responsive discovery path for every guild event type EXCEPT "invite sent but not yet accepted" — every join, leave, kick, promote, and demote fires a roster update, which we already opportunistically re-query on. The periodic ticker only exists for the pending-invite case. `GuildEventLog_QueryGuild` is a local-buffer read (not a server round-trip), so the tighter 2 min cadence is cheap, and combined with the roster-update path it makes the manual Refresh button redundant. Also dropped the unused `_ticker` local — `C_Timer.NewTicker` keeps its own internal reference so we don't need to hold the handle. Empty-state copy updated to mention the auto-capture cadence instead of pointing at the now-removed button. Location: `Modules/GuildLog/GuildLog.lua`, `GUI/GuildLogSubTab.lua`.

### New Features

- **Guild Log (phase 3)** — Third Logs sub-tab snapshotting Blizzard's in-game guild event log on demand and persisting entries beyond the ~100-entry cap Blizzard keeps in its buffer. Per-guild buckets keyed by `GuildName-PlayerRealm` so an account with alts in multiple guilds keeps independent histories (alts in the same guild on different realms also disambiguate cleanly because the realm suffix is included). Engine recipe: on `PLAYER_LOGIN` (3 s deferred to land after Ace's loaded line + other addons' login spam), on each `GUILD_ROSTER_UPDATE` (cheap opportunistic re-query — roster changes often coincide with log changes), and on a `C_Timer.NewTicker` every 30 minutes (catch-up for long sessions), call `GuildEventLog_QueryGuild()`; the matching `GUILD_EVENT_LOG_UPDATE` event then triggers `ingest()` which walks `GetNumGuildEvents()` + `GetGuildEventInfo(i)`, dedupes against the persisted entries via a `(y,m,d,h,type,target,actor)` tuple key, and appends new ones. Sorted newest-first after each ingest. Manual `Refresh` button on the sub-tab exposes the re-query without waiting for the periodic tick. `GetGuildEventInfo`'s player1 / player2 / rank semantics are normalised to `actor` / `target` / `rank` at read time so the UI doesn't need to know the per-event-type swap: actor-driven events (`promote` / `demote` / `remove` / `invite`) put the officer in player1 and the target in player2; self-events (`join` / `leave` / `quit`) put the player in player1 and leave player2 nil. Timestamps are coarse — Blizzard only exposes (year, month, day, hour), no minute / second — so the tuple is stored verbatim alongside a synthesised `ts` for sort order; the year-offset interpretation handles both the "years-ago" form (Classic 1.15) and a literal-year form (older clients) via a magnitude check. Sub-tab UI: filter row (Guild / Type / Date range / Refresh / Clear) over a 6-column RowList — Time · Guild · Type · Player · Actor · Detail. Type column is colour-coded per event type (join = green, leave = yellow, kicked = red, promote = blue, demote = orange). Guild column strips the realm suffix when it matches the player's own realm to keep the column compact for the single-realm common case. Default guild filter is the character's current guild (or All when no current guild). Date range default is "All" (guild events are sparse — last 7d would frequently show empty even with months of history). Permission failure silent: if the player lacks event-log read privilege Blizzard returns zero entries and the module sits idle until they get the rank or log into an alt that has access. SV partition: `db.global.guildLog.guilds[GuildKey].entries`. Settings > Modules > Log categories grows a |cffffd700Guild Log|r toggle at order 30; inline group still greys out when the Logs master toggle is off. Location: `Modules/GuildLog/GuildLog.lua`, `GUI/GuildLogSubTab.lua`.

- **Trade Log (phase 2)** — Second Logs sub-tab capturing every completed player-to-player trade. Two-sided entry shape — separate `given` / `received` item lists, separate `moneyGiven` / `moneyReceived` totals, and a dedicated `enchantGiven` / `enchantReceived` pair for trade slot 7 (the enchant slot — applying an enchant to/from a partner's item goes there, not into the regular 1-6 item slots). Hook recipe: `TRADE_SHOW` opens a per-trade staging cache; `TRADE_PLAYER_ITEM_CHANGED(slot)` / `TRADE_TARGET_ITEM_CHANGED(slot)` mutate the slot-keyed item maps; `TRADE_MONEY_CHANGED` refreshes both money totals; `TRADE_ACCEPT_UPDATE` re-snapshots every slot (covers any change event missed during drag-and-drop frame storms); `UI_INFO_MESSAGE` with arg2 == `ERR_TRADE_COMPLETE` commits to entries; `TRADE_CLOSED` discards the staging when a trade is cancelled. Arena/BG trades are skipped (`IsInInstance() == "pvp" / "arena"`) per the MailLogger convention — they're noisy and rarely useful. Trades with no items either side AND no money AND no enchant are discarded as inspection-only opens. Trade partner name comes from `UnitName("npc")` with the second return preserved for connected-realm clusters. Slot-keyed staging maps reduce to ordered lists on commit with same-link counts merged (mirrors Mail Log's stack-merge for compact rendering). Sub-tab UI: filter row (Character / Date range / Clear) over a 6-column RowList — Time · Character · Partner · Given · Received · Enchant — with the Enchant column visualising slot 7 contents on either side using `->` / `<-` arrows. Settings > Modules > Log categories grows a |cffffd700Trade Log|r toggle at order 20; the inline group still greys out when the Logs master toggle is off. SV partition: `db.global.tradeLog.entries`, account-wide with per-entry `player` field for the alt filter. Location: `Modules/TradeLog/TradeLog.lua`, `GUI/TradeLogSubTab.lua`.

- **Logs nexus tab — Mail Log (phase 1)** — New top-level "Logs" tab that hosts a nested AceGUI `TabGroup` whose sub-tabs come from a per-category registry (`addon.logCategories`). Phase 1 ships the |cffffd700Mail|r sub-tab; Trade and Guild follow as phases 2 / 3 in the same v0.4.0 release. Outer tab registers via the standard module pattern (`addon.modules["logs"]`) so it picks up the bottom-row Help/Settings icons, the per-tab help block, and the Settings > Modules master toggle for free. Sub-engines self-register at file-load time: `addon.logCategories["mail"] = MailLog`. The Logs Draw builds its inner tab strip from the registry, filtered on the per-category enabled flag — `addon:IsLogCategoryEnabled(subKey)` ANDs the Logs master toggle with the sub-category toggle so disabling either silently kills the sub-engine. Per-character last-viewed sub-tab is cached at `db.char.frames.logsActiveSubTab` so opening Logs returns to the last sub-tab the user was on. Deep-linking via `Tab.pendingSubTab` lets slash commands target a specific sub-tab. Location: `Modules/Logs/Logs.lua`, `GUI/LogsTab.lua`.

- **Mail Log engine + sub-tab** — Captures every mail received (inbox takes) and sent (composed via `SendMail`) into `db.global.mailLog.entries`. Entry shape: `{ ts, dir, player, other, subject, items = {{link, count}}, money, cod }`. Hook recipe: `hooksecurefunc("TakeInboxItem")` / `"AutoLootMailItem"` / `"TakeInboxMoney"` for receive, `hooksecurefunc("SendMail")` + `MAIL_SEND_INFO_UPDATE` event + `UI_INFO_MESSAGE` (ERR_MAIL_SENT) for send. Receive merging is timestamp-based — consecutive takes from the same `player`+`sender` within `MERGE_WINDOW_SEC` (5 s) collapse into the most-recent entry, so "Take All" on a 12-item mail produces one row, not 12. Within an entry, same-`itemLink` items are merged into stacks. Items get merged on link match so a multi-stack mail shows as one row "[Link] ×N more" rather than N rows. Per-account-wide retention via `db.global.logs.retentionDays` (default 90); each capture calls `Logs:PruneEntries` which walks the head of the list dropping expired entries. UI: filter row of three AceGUI dropdowns (Character / Direction / Date range) + two-step Clear-data confirm button, over a `RowList` table with columns Time · Dir · Character · Other · Subject · Items · Money. Items column shows the first item's link with "+N more" for multi-item mails. Time column is relative ("3m ago", "2h ago", "Yesterday", then `MM/DD/YY`). Money column is gsc colour-formatted. Engine refreshes the sub-tab via `MainWindow:Refresh()` when a new entry lands (only when Logs is the active outer tab AND Mail is the active sub-tab, so other tabs aren't disturbed). Location: `Modules/MailLog/MailLog.lua`, `GUI/MailLogSubTab.lua`.

- **Shared date range picker — `addon.DateRangePicker`** — Reusable widget for Logs sub-tabs. Exposes `:Create(opts)` (returns AceGUI Dropdown), `:Resolve(key, customTs)` (returns `fromTs, toTs`), and `:CreateCalendar(opts)` (returns AceGUI SimpleGroup hosting an inline year/month dropdowns + 7×6 day grid). Range presets: Last 24 hours / 7 days / 30 days / All / Custom day. Picking |cffffd700Custom day...|r expands the filter row to show the calendar below; clicking a day filters the log to that day's midnight-to-midnight window. The calendar's availability set is built from the current entries (filtered by character / direction / type — the non-date filters), so days without any matching event render greyed out and can't be picked — mirrors MailLogger's calendar UX without copying its code (custom-built using AceGUI SimpleGroup + native UIDropDownMenu + raw CreateFrame buttons for the grid cells). Year dropdown shows years with data descending; month dropdown is Jan-Dec; the 7×6 grid plus weekday header renders inside a 200 px-tall host frame parented to the SimpleGroup. Day-1-of-month weekday computed via `date("%w", time({year, month, day=1, hour=12}))`. Per-sub-tab `_filterCustomDate` state persists the picked day across rebuilds (per-session, not SV). Location: `GUI/DateRangePicker.lua`, `GUI/MailLogSubTab.lua`, `GUI/TradeLogSubTab.lua`, `GUI/GuildLogSubTab.lua`.

- **Slash command deep-links** — `/togt logs` opens the Logs tab to the last-viewed sub-tab; `/togt logs mail` / `/togt logs trade` / `/togt logs guild` deep-link to a specific sub-tab via `addon.modules["logs"].pendingSubTab`. The Logs tab Draw reads and clears the pending key on its first paint. `/togt ml` retained as a shorthand for `/togt logs mail`. Sub-keys are matched case-insensitively. Location: `SlashCommands.lua`.

- **Settings > Modules: inline "Log categories" sub-group** — Renders right below the Logs master toggle as an `inline = true` AceConfig group. Phase 1 contains a single |cffffd700Mail Log|r toggle (Trade and Guild added in phases 2 / 3). The whole sub-group is `disabled = function() return not addon:IsModuleEnabled("logs") end` so it greys out when the Logs master toggle is off — visually expressing the AND relationship. Sub-toggles write `db.global.<configKey>.enabled` and call `MainWindow:Refresh()` so the inner tab strip rebuilds on the spot. Location: `GUI/Settings.lua`.

- **Per-module on/off toggles (Settings > Modules)** — The Settings panel is now split into two AceConfig sub-groups rendered as tabs at the top of the Blizzard Interface Options page: |cffffd700General|r (the existing minimap + debug settings) and |cffffd700Modules|r (one toggle per registered tab/module). Disabling a module both hides its tab from the main window's tab strip AND short-circuits its engine on the same `db.global.<configKey>.enabled` flag — capture handlers, login alerts, and cross-module readers (e.g. Login Digest's mail field calling Mailbox:GetExpiringSoon) all silently no-op. Data already captured (mailbox snapshots, NamePrefix nickname, Login Digest field selections) is preserved and reappears verbatim on re-enable. The Modules group's args are populated dynamically from `addon.modules` at registration time so new modules added in future patches automatically grow a toggle without per-module wiring in Settings.lua — they just need to declare `Tab.configKey = "<sectionName>"` alongside `tabKey` / `label`. New `addon:IsModuleEnabled(tabKey)` helper reads the flag via the module's `configKey` field (defaults to true when no configKey is declared, so legacy / freshly-registered modules stay on by default). New `MainWindow:Rebuild()` rebuilds the tab strip and re-anchors the active selection when the toggle fires — closes the window entirely if every module ends up disabled (re-enter via minimap RMB or `/togt settings`). The existing in-tab "Enable" checkboxes on the Name Prefix and Login Digest tabs continue to work and now also call `MainWindow:Rebuild()` so the tab disappears immediately when toggled off from within itself. Surfaced by a user request specifically for a Mailbox kill-switch but built generally so every current and future module benefits. Location: `GUI/Settings.lua`, `GUI/MainWindow.lua`, `TOGTools.lua`, `Modules/Mailbox/Mailbox.lua`, `GUI/NamePrefixTab.lua`, `GUI/AddonLoadTab.lua`, `GUI/MailboxTab.lua`, `GUI/LoginDigestTab.lua`.

- **Bottom-row Help (i) and Settings (gear) icons on every tab** — Two new icons sit between the version-string status bar and the AceGUI Close button on the main window, adapted from the FastGuildInvite pattern. The 24×24 |TInterface\Common\help-i:14:14|t icon shows a per-tab help tooltip on hover (no click action in v0.4.0); the 20×20 |TInterface\Icons\Trade_Engineering:14:14|t gear opens the TOG Tools settings panel via `addon:OpenSettings()` and survives the Blizzard `CloseSpecialWindows()` ESC-frame side-effect via a temporary `_escProxy:SetScript("OnHide", nil)` + restore-on-next-frame workaround that mirrors FGI's `GUI/MainWindow.lua` gear handler. Layout math (status bar right edge -180, help -153, gear -130, Close -127) gives 3 px gaps across the row and centre-y=27 alignment with the Close button. `Interface\Icons\Trade_Engineering` gets a `TexCoord(0.08, 0.92, 0.08, 0.92)` crop to remove the ~8% transparent border padding that otherwise makes the visible icon smaller than its hit box; `help-i` doesn't need it. Hit-rect insets of -2 give 2 px of click slop on every side inside the row's 3 px gap. Both icons are detached from the AceGUI Frame's underlying WoW frame on close via `addon.UI.DetachChildren` — without this the AceGUI widget pool returns the same `f.frame` on next Open with the leftover icons still parented, stacking fresh icons on top of stale ones. Location: `GUI/MainWindow.lua`, `GUI/UI.lua`.

- **Per-tab help registry** — Each tab module now carries a `help = { title, lines }` block alongside `tabKey` / `label` / `WINDOW_SIZE` / `Draw`. The help icon's `OnEnter` reads `addon.modules[self.activeTab].help` at hover time and dispatches automatically — new tabs add a `help` table next to `Draw` and immediately get a working help tooltip with no per-icon plumbing. Tabs without a help block fall back to a "No help available for this tab yet." placeholder. Help copy added for all four existing tabs (Name Prefix, Addon Load, Mailbox, Login Digest). The shared bottom-row icon description is appended at the end of every tab's help tooltip so users always see what the two icons mean. Chosen over FGI's flat `TAB_HELP` table-in-MainWindow pattern because the per-module structure scales as we add tabs — MainWindow stays free of god-table accretion. Location: `GUI/NamePrefixTab.lua`, `GUI/AddonLoadTab.lua`, `GUI/MailboxTab.lua`, `GUI/LoginDigestTab.lua`, `GUI/MainWindow.lua`.

- **Shared UI helpers — new `GUI/UI.lua`** — Addon-global helpers that future tabs (and the upcoming Logs nexus) can use without duplicating boilerplate. `addon.Tooltip.Owner(frame, [budget])` is an auto-flipping `GameTooltip:SetOwner` that picks ANCHOR_TOPRIGHT vs ANCHOR_BOTTOMLEFT based on `GetScreenHeight() - frame:GetTop()` vs the budget (default 250 px), so tooltips don't hang off the bottom of the screen for frames near the bottom edge. `addon.UI.Brand(text)` returns text wrapped in `|c<addon.BrandColor>...|r`. `addon.UI.AttachTooltip(frame, title, body)` HookScripts an OnEnter/OnLeave pair onto any frame (chains rather than replacing existing handlers). `addon.UI.MakeIcon(parent, opts)` is the factory used by the new bottom-row icons — accepts size, texture, texCoordCrop, hitRectInsets, optional onClick, and either an explicit onEnter or `tooltipTitle` / `tooltipBody` for the auto-tooltip path. `addon.UI.DetachChildren(host, keys)` is the OnClose release helper that hides, reparents to UIParent, clears points, and nils each named child reference on the host table — extracted as a global because we're about to add many more tabs each with their own icons. All six TOC files load `GUI\UI.lua` between `GUI\RowList.lua` and `GUI\MainWindow.lua`. Location: `GUI/UI.lua`, all six `TOGTools*.toc`.

### Bug Fixes

- **`addon:OpenSettings` crashed on Retail Midnight** — `GUI/Settings.lua` captured only the first return value from `AceConfigDialog-3.0:AddToBlizOptions("TOGTools", "TOG Tools")` (the panel frame) and tried to open with `Settings.OpenToCategory(_blizPanel.categoryID or "TOG Tools")`. On Midnight builds the panel frame doesn't carry a `categoryID` field, so the string fallback `"TOG Tools"` was passed in — `Settings.OpenToCategory` calls `C_SettingsUtil.OpenSettingsPanel(openToCategoryID, ...)` which now strictly validates `openToCategoryID` as an integer in `[-2^31, 2^31-1]`, throwing `bad argument #1 to 'OpenSettingsPanel' (outside of expected range)` for the string. This wasn't visible before v0.4.0 because nothing called `OpenSettings` on Midnight in practice — the new bottom-row gear icon is the first caller that exposed it. Fix: capture both return values from `AddToBlizOptions` (the SECOND is the opaque numeric category ID — the first/panel-frame field that Ace3's older builds populated isn't reliable on Midnight). Pass `_categoryID` directly to `Settings.OpenToCategory`, called twice so the panel navigates past the Settings landing page into the TOG Tools sub-category (calling once sometimes lands on the root). Pattern lifted from FastGuildInvite's `GUI/SettingsPanel.lua` after Grouper hit the same issue. Also dropped the third-tier `InterfaceOptionsFrame:Show()` fallback — it opens to whatever category was last visited (not TOG Tools), and modern Retail doesn't have `InterfaceOptionsFrame` anyway. Location: `GUI/Settings.lua`.

- **TOGTools failed to load on current Retail (Midnight 12.0.x patches)** — `TOGTools_Mainline.toc` declared `## Interface: 110207, 120001, 120000`, but live Retail is on Midnight patches 12.0.5 and 12.0.7. Verified by cross-checking currently maintained Retail addons: RaiderIO ships `120000, 120001, 120005`, !BugGrabber ships up to `120007`, Ace3 covers `120000, 120001` and below. Clients on 12.0.5+ marked TOGTools as out-of-date, and because `## Dependencies: Ace3, !TOGT` requires !TOGT to load (which had the same gap — single `## Interface: 110207`), users without "Load out of date AddOns" ticked saw the dep check fail and TOGTools never initialised. Fix: expanded the Mainline Interface list to `110207, 120000, 120001, 120005, 120007`, and added `## X-Min-Interface: 110207` for consistency with the other flavor TOCs. The Vanilla / TBC / Wrath / Cata / Mists TOCs are unchanged. Companion fix in !TOGT v0.1.1 covers the same Interface range. Location: `TOGTools_Mainline.toc`.

### Improvements

- **`addon:Debug` uses `addon.UI.Brand`** instead of the hardcoded `"|cffFF8000TOGTools|r"` literal. Pipes the debug-print tag through the global brand color so any future change to `addon.BrandColor` propagates automatically. Lookup is deferred to call time, which is always after `GUI/UI.lua` has loaded. Location: `TOGTools.lua`.

- **Main window title uses `addon.UI.Brand`** — `f:SetTitle(addon.UI.Brand("TOG Tools"))` replaces the literal `"|cffFF8000TOG Tools|r"` in `MainWindow:Open`. Same propagation benefit as the Debug refactor. Location: `GUI/MainWindow.lua`.

- **Silenced pre-existing `unused-local` Lua hint** — `tg:SetCallback("OnGroupSelected", function(_widget, _event, group)` flagged `_event` as unused under the project's `.luarc.json` rules. Renamed to `_` to satisfy the lint check (single underscore is the canonical "ignore" convention; the surrounding `_widget` keeps its name because it's actually used). Location: `GUI/MainWindow.lua`.

---

## [v0.3.5] (2026-05-23) - NamePrefix Chat-History Recall Duplicate Fix

### Bug Fixes

- **NamePrefix doubled the prefix when ElvUI's Up-arrow chat history recalled a previously-sent message** — ElvUI's chat editbox enhancement lets the user press Up in the chat editbox to re-populate it with a previously-sent message; pressing Enter then re-sends it. The recalled text already contains the prefix that `ApplyPrefix` added on the original send (e.g. `(Vishiswaz): 123`), so when our hook fired again on the recall it prepended the prefix a second time, producing `(Vishiswaz): (Vishiswaz): 123`. Name2Chat does not double-prefix in the same scenario because its hook point inspects the outgoing message differently; ours just unconditionally prepended. Fix: in `ApplyPrefix`, after building the `prefix` string from `cfg.format` and `cfg.nickname`, early-exit when `string.sub(msg, 1, #prefix) == prefix`. Plain `sub` comparison (not a Lua pattern) so format strings containing magic characters are safe. Edge case where the user changes their nickname between sends is acceptable: the old prefix in the recalled message won't match the new one, so the message ships as the user originally typed it rather than gaining a second prefix. Hook entry points (Retail `EventRegistry` and Classic `OnKeyDown`) are unchanged. Location: `Modules/NamePrefix/NamePrefix.lua`.

---

## [v0.3.4] (2026-05-22) - TBC `/camp` `/exit` `/logout` Taint Fix

### Bug Fixes

- **`/camp`, `/exit`, `/logout` typed in chat tripped `ADDON_ACTION_FORBIDDEN` on TBC / Anniversary 1.15.x with NamePrefix active** — v0.3.3 replaced the per-editbox `OnEnterPressed` script slot with an addon-Lua closure that invoked the captured original script via `securecall(origScript, editBox, ...)`. The `securecall` boundary clears taint at its call site, but on TBC / Anniversary the slash-dispatch chain (`ChatFrameEditBoxMixin:OnEnterPressed` → `SendText` → `ParseText` → slash-command dispatcher → protected `Logout()`) still failed the secure-execution check for `Logout()` — confirmed in the field after v0.3.3 shipped, with the failing trace `[TOGTools/Modules/NamePrefix/NamePrefix.lua]:161 → [C]: securecall → ChatFrameEditBox.lua:370 → SendText:252 → ParseText:207 → SlashCommands.lua:748 → Logout()`. The OnEnterPressed slot itself becomes addon-owned once `SetScript` runs on it, so the C-level taint check sees addon ownership on the dispatch path regardless of the `securecall` clear at the boundary. Switched the Classic / older-Retail hook from `SetScript("OnEnterPressed", ...) + securecall(origScript)` to `HookScript("OnKeyDown", ...)` keyed on `key == "ENTER"` or `"NUMPADENTER"`. `OnKeyDown` fires in its own C dispatch frame, ahead of `OnEnterPressed`, which is dispatched as a separate C-level call — our hook applies the prefix via `SetText` and returns to C *before* `OnEnterPressed` begins, so addon Lua is never on the call stack when `SendText` / `ParseText` / `Logout()` run. The `OnEnterPressed` script slot is no longer touched at all and stays bound to the original Blizzard handler, so the entire slash-dispatch chain executes in a fully secure context. `HookScript` chains alongside any existing `OnKeyDown` handler instead of claiming the slot; chat editboxes have no default `OnKeyDown` binding, so our hook is the only one on the editbox. The retail 12.0+ `EventRegistry "ChatFrame.OnEditBoxPreSendText"` path is unchanged. The `ApplyPrefix` leading-`/` early-exit is unchanged and continues to ensure no `SetText` is issued for slash commands even though the OnKeyDown hook still runs. Updated the file-header comment block to document the rejected `SetScript + securecall` approach alongside the previously-rejected `SendChatMessage` / `ChatEdit_SendText` / mixin-method-replace wraps, and updated the `ModifyMessage` doc comment to reference the new OnKeyDown entry point. Removed the v0.3.3 temporary `OnKeyDown` diagnostic since its purpose (verify `OnKeyDown` fires on TBC ahead of `OnEnterPressed`) is now load-bearing in production code. Location: `Modules/NamePrefix/NamePrefix.lua`.

---

## [v0.3.3] (2026-05-18) - NamePrefix /say Channel + TBC /logout Taint Fix

### Bug Fixes

- **NamePrefix did not fire on TBC / Anniversary 1.15.x in the v0.3.2 working tree** — During pre-release work toward this version the Classic hook was experimentally rewritten twice — first to wrap the global `SendChatMessage`, then to wrap the global `ChatEdit_SendText` (Name2Chat's pattern). Both wraps installed cleanly (verified with a temporary install-time `print`) but neither fire-time path was ever entered for user-typed chat on TBC / Anniversary 1.15.x — confirmed empirically with a temporary fire-time `print` inside `ModifyMessage`. On those clients `ChatFrameEditBoxMixin:OnEnterPressed` dispatches via `self:SendText()` using a captured local reference, so addon-level global reassignment never intercepts user chat. On Classic Era 1.15.x the global `ChatEdit_SendText` path is still active and did fire during testing, which initially masked the TBC/Anniversary regression. Restored v0.3.2's per-editbox `SetScript("OnEnterPressed", ...)` wrap that walks `ChatFrame1EditBox..ChatFrameN.EditBox` via `NUM_CHAT_WINDOWS` — this is the only entry point that reliably fires on every Classic flavor. Verified empirically on TBC (Anniversary, `Wowhead Looter` interface 20505) and Classic Era (interface 11508). Location: `Modules/NamePrefix/NamePrefix.lua`.
- **Per-editbox `OnEnterPressed` wrap tripped `ADDON_ACTION_FORBIDDEN` on `/logout`** — `SetScript("OnEnterPressed", addonFn)` leaves an addon-Lua handler on the C call stack whenever Enter is pressed in a chat editbox. When the user types `/logout`, the original `OnEnterPressed` we invoke from inside our handler runs through `ChatEdit_SendText` → `ChatEdit_ParseText` → slash-command dispatcher → protected `Logout()`. Because addon Lua is still on the stack, taint propagates the whole way and the secure-execution layer blocks `Logout()` on TBC and Anniversary. Fix: invoke the captured original script via `securecall(origScript, editBox, ...)` instead of a direct call. `securecall` is the canonical taint-clearing dispatcher (see WoWWiki "Secure Execution and Tainting") — it saves and clears the current taint flag around the call so `ParseText` → slash-handler → `Logout()` runs untainted regardless of what sits on the C stack above. Belt-and-suspenders: `ApplyPrefix` already early-exits on a leading `/`, so the editbox text is never modified for slash commands and its `GetText` result stays untainted on the secure dispatch path. Location: `Modules/NamePrefix/NamePrefix.lua`.

### New Features

- **NamePrefix `Say (/s)` channel toggle** — New checkbox at the top of the NamePrefix tab's Active Channels list. When enabled, the configured nickname is prepended to outgoing `/say` messages alongside the existing guild / officer / party / raid / instance toggles. `SAY` case added to the `ApplyPrefix` chatType dispatch table; `say = false` added to `DB_DEFAULTS.global.namePrefix`. Location: `Modules/NamePrefix/NamePrefix.lua`, `GUI/NamePrefixTab.lua`, `TOGTools.lua`.
- **Account-wide debug flag + `addon:Debug()` helper** — New `Debug` section in the Settings panel (ESC → Options → Addons → TOG Tools, or right-click the minimap button) with a `Verbose debug output` toggle. Stored at `db.global.debug`, default `false`. `addon:Debug(msg)` prints `|cffFF8000TOGTools|r <msg>` only when the flag is true; modules call it for hook-install confirmations and fire-time diagnostics that should be silent in normal play. NamePrefix uses it for two diagnostics: the install confirmation `NamePrefix: hook installed (OnEnterPressed xN)` (one-shot at addon load — requires `/reload` to re-fire after toggling the flag) and the per-send fire line `NamePrefix fire: chatType=X prefixed=true/false` (no message echo, takes effect on the next chat send without `/reload`). Location: `TOGTools.lua`, `GUI/Settings.lua`, `Modules/NamePrefix/NamePrefix.lua`.

### Improvements

- **NamePrefix channel defaults are now all `false`** — `say`, `guild`, `officer`, `party`, `raid`, `instance` all default to `false` in `DB_DEFAULTS.global.namePrefix`. Previously `guild` and `officer` defaulted to `true`. Fresh installs now opt-in per channel; existing saved settings are unaffected because AceDB merges defaults without overwriting saved keys. Location: `TOGTools.lua`.
- **`.luarc.json` / `TOGTools.code-workspace`** — Added `securecall`, `rawget`, `rawset` to `diagnostics.globals` for Lua LSP coverage of the new hook code and the v0.3.2 account-wide migration block.

---

## [v0.3.2] (2026-05-17) - NamePrefix BCC / Anniversary Fix

### Bug Fixes

- **NamePrefix did nothing on BCC and Anniversary (Classic Era 1.15.x)** — v0.3.0 relocated the Classic hook from `ChatEdit_SendText` to the global `ChatEdit_OnEnterPressed` to avoid tainting OPie's `securecall(ChatEdit_SendText, ...)` macrotext dispatch. On modern Classic builds (BCC, Anniversary 1.15.x) the chat editbox's `OnEnterPressed` script is the `ChatFrameEditBoxMixin:OnEnterPressed` method, NOT the global — so the wrapped global never fired and outgoing messages went un-prefixed. Replaced the global-wrap with a per-editbox `SetScript("OnEnterPressed", ...)` wrap that walks `ChatFrame1EditBox..ChatFrameN EditBox` (`NUM_CHAT_WINDOWS`) and prepends `ModifyMessage` to each editbox's existing script. This catches both the legacy `ChatEdit_OnEnterPressed` script binding and the modern mixin-method binding without touching any global function reference. Verified safe for OPie: the wrap only touches user-visible `ChatFrameN` editboxes, not OPie's synthetic Rewire editboxes, and never replaces `ChatEdit_SendText` or `ChatFrameEditBoxMixin.SendText`. Confirmed against Name2Chat's own analysis that `EventRegistry "ChatFrame.OnEditBoxPreSendText"` is **not** fired on Classic 1.15.x despite `EventRegistry` being backported — the existing `gv.isRetail120Plus` gate stays correct. Location: `Modules/NamePrefix/NamePrefix.lua`.
- **`.luarc.json`** — Added `NUM_CHAT_WINDOWS` to `diagnostics.globals`.

### Improvements

- **MainWindow remembers last-selected tab** — Opening the main window (minimap button, `/togt`, or `Toggle()` with no arg) now restores the tab that was active the last time the user closed the window. Stored as `db.char.lastTab`; per-character so different alts can have different defaults. Resolution order in `MainWindow:Open(tabKey)` is: explicit caller arg → `db.char.lastTab` (if the module still exists) → alphabetically first tab. Slash commands that pass an explicit tab (`/togt np`, `/togt mb`, etc.) still win, and clicking a different tab updates the saved value via `OnGroupSelected`. Location: `GUI/MainWindow.lua`, `TOGTools.lua`.
- **NamePrefix is now account-wide** — Moved `namePrefix` from `DB_DEFAULTS.char` to `DB_DEFAULTS.global` so the nickname, format string, per-channel toggles, and `hideIfCharName` are configured once and shared across every alt. Toons that shouldn't self-prefix rely on `hideIfCharName` (now defaulting to `true`) to suppress the prefix when the nickname matches the character's own name. One-shot migration in `Ace:OnInitialize` copies any pre-upgrade `db.char.namePrefix` with a non-empty nickname into `db.global.namePrefix` the first time an upgraded character logs in (only if the global table is still at defaults); subsequent alts inherit the now-global config. Leftover per-char entries are left in place since AceDB ignores keys not in the defaults. Location: `TOGTools.lua`, `Modules/NamePrefix/NamePrefix.lua`, `GUI/NamePrefixTab.lua`.
- **NamePrefix default** — `hideIfCharName` now defaults to `true` in `DB_DEFAULTS.global.namePrefix`. New accounts no longer self-prefix on the character whose name matches the configured nickname (the common case where the nickname IS the main's name). Existing saved-variables are unaffected. Location: `TOGTools.lua`.

---

## [v0.3.1] (2026-05-06) - TBC Compatibility Fix

### Bug Fixes

- **Addon Load tab crashed on TBC (and Wrath/Cata/Mists/Retail)** — `GetNumAddOns`, `GetAddOnInfo`, `IsAddOnLoaded`, `GetAddOnMemoryUsage`, and `UpdateAddOnMemoryUsage` were called as bare globals, but TBC+ moved them all into `C_AddOns.*`. Added five compat locals at the top of the module that prefer `C_AddOns.*` when available and fall back to the bare globals for Classic Era, matching the pattern already used elsewhere in the addon. Location: `Modules/AddonLoad/AddonLoad.lua`.

---

## [v0.3.0] (2026-05-04) - Mailbox Stale Watcher

### New Features

- **Mailbox Stale Watcher** — New tab (`/togt mail` / `mb` / `mailbox`) tracking mail expiration across every alt on the account so mail never gets auto-deleted at the 30-day mark for an unvisited character. Captures a per-alt inbox snapshot on each `MAIL_INBOX_UPDATE` event, stamping absolute `expiresAt = GetServerTime() + (daysLeft * 86400)` per item so the time-remaining math stays accurate even when the alt does not re-visit a mailbox for several days (same content-derived-timestamp pattern TOGPM uses). Login chat alert prints once when any alt has mail expiring within the configured threshold (default 2 days, slider-configurable 1–7 in the tab). Tab lists each alt with a row-per-mail breakdown of expiring items (sender, subject, time remaining), snapshot age display, and a `- stale` flag for snapshots older than 7 days. Live-refresh on `MAIL_INBOX_UPDATE` while the tab is active, so opening a mailbox in-game while the Mailbox tab is showing updates the rows in real time without forcing a manual tab-switch (`addon.MainWindow:Refresh()` is called from the engine's event handler when `addon.MainWindow.activeTab == "mailbox"`). Account-wide storage in `db.global.mailbox.snapshots[Name-Realm]` so every alt's data is visible from any character, full `Name-Realm` keys throughout to prevent collisions across connected-realm clusters. Location: `Modules/Mailbox/Mailbox.lua`, `GUI/MailboxTab.lua`.

### Improvements

- **AceDB schema** — Added `global` namespace to `DB_DEFAULTS` with `mailbox = { thresholdDays, snapshots }`. Account-wide scope so cross-alt mail data persists across every physical realm in a connected-realm cluster (per-`db.realm` would silo each physical realm of the cluster into a separate notes table — wrong for our use case). Location: `TOGTools.lua`.
- **Slash commands** — `/togt mb` / `/togt mail` / `/togt mailbox` open the Mailbox tab directly; help output updated. Location: `SlashCommands.lua`.
- **`.luarc.json`** — Added `GetInboxNumItems`, `GetInboxHeaderInfo`, and `ChatEdit_OnEnterPressed` to `diagnostics.globals` so the new mail API calls and the relocated NamePrefix hook target resolve cleanly under the Lua LSP.
- **TOC files** — All six TOC variants (Vanilla, BCC, Wrath, Cata, Mists, Mainline) updated to load `Modules\Mailbox\Mailbox.lua` and `GUI\MailboxTab.lua` after the AddonLoad pair.

### Bug Fixes

- **NamePrefix broke OPie macrotext ring entries (custom mounts, `/cast` macros)** — On Classic clients, NamePrefix wrapped the global `ChatEdit_SendText` to enable pre-send text modification. OPie's `Libs/ActionBook/Rewire.lua` dispatches each line of macro ring entries via `securecall(ChatEdit_SendText, box, false)`. Replacing the global with a wrapper created in our (insecure) addon loading context tainted that function reference, so OPie's secure dispatch path picked up taint when calling it — failing for any ring slot delivered as macrotext (custom mount macros, `/cast` slots), while direct spell-cast slots that bypass macrotext (most tradeskills) kept working. Fix: relocated the Classic hook target from `ChatEdit_SendText` to `ChatEdit_OnEnterPressed`, which is upstream of `ChatEdit_SendText` in the user-typed Enter-key flow but is *not* on OPie's Rewire dispatch path. The user-facing prefixing behavior is unchanged. The replaced-global pattern itself stays (`hooksecurefunc` fires post-call, so it can't be used to modify text before send); we just no longer taint the function OPie depends on. Location: `Modules/NamePrefix/NamePrefix.lua`.

---

## [v0.2.0] (2026-05-04) - Addon Load Monitor

### New Features

- **Addon Load Monitor** — New tab (`/togt al`) showing every installed addon's memory usage, load status, and load timing. Sortable by name, memory, load time, and status (failures-first when descending). Summary bar shows totals (loaded / failed / disabled / on-demand) and total memory. Requires the `!TOGT` companion addon for full timing coverage across all addons. Location: `Modules/AddonLoad/AddonLoad.lua`, `GUI/AddonLoadTab.lua`.
- **!TOGT companion integration** — `!TOGT` (companion addon) added as a required dependency. Loads before all other addons at the very start of the loading screen (the `!` prefix sorts before all letters) and records load order and per-addon offset into the `TOGToolsEarlyData` global. The Addon Load Monitor reads it at display time and shows a status-bar indicator (`!TOGT active` / `!TOGT missing`).
- **Shared `addon.RowList` component** — New file `GUI/RowList.lua`, ported and trimmed from FastGuildInvite's `GUI/RowList.lua`. Reusable sortable-row list for any tab that needs a tabular data view: brand-colored clickable header bar, click-toggle ASC/DESC sort with per-column `sortKey`/`sortDescDefault`/`sortable`/`gapBefore` opts and tie-break on `entry.name`, alternating row banding, virtual-scroll pool that grows with the parent's height, custom Blizzard-textured scrollbar, mouse-wheel scroll. Cells use `SetWordWrap(false)` + `SetMaxLines(1)` and anchor `LEFT/RIGHT` to the parent so column layouts compact elastically when the user shrinks the window — no row-wrap. Public API: `:New(parent, opts)`, `:SetData(arr)`, `:SetSort(key, desc)`, `:Refresh()`, `:Detach()`. Registered to all six TOC files after `Compat.lua`, before `MainWindow.lua`.
- **`Compat.lua` shared GUI helpers** — `addon.Tooltip.Owner` / `AnchorFrame` (smart anchor flipping based on screen half), `addon.AceGUIFrameScripts` (leak-safe raw frame scripts that restore prior handlers on pooled-widget release), `addon.GUI.AttachTooltip`, `addon.GUI.MakeColumnHeader`, `addon.GUI.ApplyMinResize`, `addon.GUI.DetachPool`. Used by every tab.

### Bug Fixes

- **Load times reporting `0.000s`** — `GetTime()` is frame-locked, so every `ADDON_LOADED` event fired in the same loading-screen frame returned the same timestamp. Per-addon deltas collapsed to zero. Switched the timing capture in `!TOGT.lua` and the fallback capture in `Modules/AddonLoad/AddonLoad.lua` to `debugprofilestop()`, which has sub-millisecond precision. Offsets are stored in seconds (ms / 1000) so downstream consumers are unchanged.
- **Load Time column sort produced random-looking order** — The sort comparator in `GUI/AddonLoadTab.lua` was reading `row.offset` (cumulative seconds since the first event) instead of `row.loadTime` (per-addon delta). Because `offset` is monotonic with load order, clicking the header effectively sorted by load order regardless of direction. Comparator now reads `row.loadTime`.
- **Addon Load tab wrapped to multiple lines on horizontal shrink** — The tab used AceGUI Flow with `widget:SetWidth(...)` per cell, so when the user shrunk the window narrower than the column-width sum AceGUI moved cells to a new line and the layout fell apart. Replaced the row-rendering path with `addon.RowList`, whose anchor-based cells truncate instead of wrapping. The window's `minWidth` was tightened from 640 to 520 now that compaction is graceful.
- **Tab status bar leaked between tabs** — `MainWindow:DrawTab` now resets the status text to the version string before delegating to the tab module, so a tab that overrides the status (e.g. Addon Load showing `!TOGT active/missing`) doesn't bleed that text into the next tab the user opens.
- **Tab content didn't reflow on tab switch** — `MainWindow:ApplyTabSize` now calls `f:DoLayout()` after sizing, so when switching between tabs whose `WINDOW_SIZE` differs, the new tab's content reflows immediately instead of inheriting stale layout.

### Improvements

- **Addon Load tab column widths tightened** — Status reduced from 175 → 85 px (fits "Wrong Version"); Memory reduced from 110 → 65 px (fits "999.9 MB"); Load Time reduced from 110 → 60 px. The auto-width Addon Name column reclaims the freed space. `RowList`'s `SCROLLBAR_GUTTER` reduced from `SCROLLBAR_WIDTH + 6` to `SCROLLBAR_WIDTH + 2` so the rightmost column sits ~5 px from the scrollbar. Status column gets `gapBefore = 5` to break the visual collision between right-justified Load Time text and left-justified Status text.
- **Sort indicator removed from active column header** — `RowList:_updateHeaderText` no longer appends a glyph (`v` / `^`) to the active sort column; WoW's default font doesn't render unicode triangles cleanly and ASCII letters look like typos. Click affordance is communicated via the column tooltip.
- **AceGUI pool-bleed protection** — `RowList:Detach()` orphans the row pool, header, and scrollbar to `UIParent` on host-widget release; a `_detached` guard on `:Refresh()` makes sure a recycled host doesn't trigger a pool grow that would re-attach rows into the next addon's widget. Wired via `body:SetCallback("OnRelease", ...)` in `GUI/AddonLoadTab.lua`.
- **`.luarc.json` updated** — Added `debugprofilestop` to `diagnostics.globals` in both `TOGTools/.luarc.json` and `!TOGT/.luarc.json` so the new timer call resolves cleanly under the Lua LSP.

---

## [v0.1.0] (2026-05-04) - Initial Release

### New Features

- **Project scaffolding** — TOC files for all WoW versions (Vanilla 11508, TBC 20505, Wrath 30405, Cata 40402, Mists 50503, Retail 110207/120001/120000), `.pkgmeta` with BigWigs packager config, `.luarc.json` (Lua 5.1 LSP), `.markdownlint.json`, and `.github/workflows/release.yml` for tag-triggered CurseForge releases.
- **Core addon** — `TOGTools.lua`: AceAddon-3.0 instance with AceConsole-3.0, AceEvent-3.0, AceHook-3.0 mixins; AceDB-3.0 schema (`char` scope); version detection flags (`gv.isVanilla`, `gv.isTBC`, `gv.isWrath`, `gv.isCata`, `gv.isMists`, `gv.isClassic`, `gv.isRetail`, `gv.isRetail120Plus`).
- **Slash commands** — `SlashCommands.lua`: all `/togt` command registration and handling in a dedicated file, separate from core addon logic. Subcommands: `/togt` (toggle window), `/togt np` (Name Prefix tab), `/togt settings` (open settings), `/togt vc` (version check).
- **Tabbed main window** — `GUI/MainWindow.lua`: dynamic AceGUI Frame + TabGroup; tabs auto-populate from `addon.modules` sorted alphabetically by `label`; per-tab locked/unlocked window sizing via `WINDOW_SIZE`; ESC-to-close proxy; position persistence via AceDB.
- **Name Prefix module** — `Modules/NamePrefix/NamePrefix.lua` + `GUI/NamePrefixTab.lua`: wraps `ChatEdit_SendText` (Classic) or hooks `EventRegistry` (Retail 12.0+) to prepend a configurable nickname to outgoing chat messages. Settings: enable/disable, nickname, format string with live preview, per-channel toggles (Guild, Officer, Party, Raid, Instance, custom channel), skip-exclamation option, suppress-if-char-name option. Per-character DB scope. Skips messages beginning with `/` (slash commands/macros) unconditionally.
- **Minimap button** — `GUI/MinimapButton.lua`: LibDataBroker-1.1 launcher + LibDBIcon-1.0 registration. Left-click toggles the main window; right-click opens the native addon settings panel. Icon: `textures/ToGTools_PH_MMB.tga`. Button show/hide state and position persisted in `DB.char`.
- **Addon settings panel** — `GUI/Settings.lua`: AceConfig-3.0 options table registered under ESC → Interface → Addons → TOG Tools (native Blizzard panel). `addon:OpenSettings()` uses `Settings.OpenToCategory` (Classic Era 1.15+ / Retail 10+), `InterfaceOptionsFrame_OpenToCategory` (older builds), or `InterfaceOptionsFrame:Show()` as a final fallback — detected by API presence, not version flag.
- **VersionCheck-1.0 integration** — `VC:Enable(Ace)` called on all non-Retail versions in `OnInitialize`; `/togt vc` broadcasts a guild-wide version check and prints responses after 21 seconds.
- **Embedded libs** — `libs/LibDataBroker-1.1.lua`, `libs/LibDBIcon-1.0.lua` (single-file embeds, copied from TOGProfessionMaster).
- **MIT License** — `LICENSE` file added; referenced in `.pkgmeta` via `license-output`.
- **CurseForge project** — Project ID `1533830` set in `.pkgmeta` and all TOC files.
- **Governance files** — `.github/copilot-instructions.md` and `CLAUDE.md` with full project rules (module pattern, commit/tag process, changelog format, HTML doc rules, no-tag rule).

### Bug Fixes

- **Name Prefix not reaching other players** — `hooksecurefunc` fires after `SendChatMessage` has already been called. Fixed by wrapping `ChatEdit_SendText` directly (`local orig = ...; ChatEdit_SendText = function(...) ... return orig(...) end`) so the prefix is applied before the message is sent. Location: `Modules/NamePrefix/NamePrefix.lua`.
- **Macros/slash commands being prefixed** — Added an unconditional early-return in `ModifyMessage` when the message starts with `/`. Location: `Modules/NamePrefix/NamePrefix.lua`.

---
