# Changelog Archive

Older TOGTools releases, moved out of the live [CHANGELOG.md](CHANGELOG.md) to keep it under the BigWigs packager's GitHub-release body size limit. Newest archived release first.

## [v0.8.1] (2026-07-31) - Gratz offline false-positive, I'd Hit That text escaping

### Bug Fixes

- **Gratz fired a guild level-up for a player who was offline** -- The guild trigger diffed the roster's level column without ever reading the member's online state. The roster reports a **last-known** level for offline members, so a guildmate who logged off at 39 and returned days later presented as a level increase and got gratzed while still showing offline. A level increase now only qualifies when the member was online at **both** ends of the change (`guildLevelUpQualifies`) -- requiring only "online now" would still let a stale cached level that corrects itself on a later rebuild read as a live ding for someone who was offline when it happened. Location: `Modules/Gratz/Gratz.lua`.

- **Guild Log retention never pruned anything** -- `Logs:PruneEntries` walked from the head of the list while entries were expired, which assumes head == oldest. That holds for the logs that append on capture, but the Guild Log sorts its bucket **newest-first** and *then* prunes, so the walk hit the newest entry, found it unexpired, and stopped -- meaning a 200-day-old guild entry survived a 90-day retention setting indefinitely and the guild bucket grew without bound. The prune is now order-agnostic: it compacts in place, preserving relative order, so no caller has to honour an ordering contract to get retention. Found by the new spec. Location: `Modules/Logs/Logs.lua`.

- **Battle.net whispers lost a usable sender name during Retail chat lockdown** -- `resolveBNName` returned `nil` as soon as `bnSenderID` was a secret value, abandoning the whole resolution and falling through to the "Battle.net friend" placeholder even when the raw sender was an ordinary, non-secret plain name. The secret check now skips only the ID *lookup* (`idUsable`), leaving the raw-name fallback reachable; `idUsable` is first in the and-chain so Lua short-circuits before comparing a secret `bnSenderID` against `0`, which would throw. Found by the new spec. Location: `Modules/WhisperLog/WhisperLog.lua`.

- **HTML entities rendered literally in the I'd Hit That tab** -- The Placement and ranged-weapon notes were authored alongside `docs/Curseforge_Description.html` and carried its escaping into Lua string literals, so the panel displayed `&mdash;` and `&apos;` as text instead of `--` and `'`. Replaced with the literal characters. Location: `GUI/IdHitThatTab.lua`.

### Improvements

- **Guild roster state moved to LibGuildRoster-1.0** -- The module no longer touches the guild roster API at all. It previously kept its own snapshot, polled `C_GuildInfo.GuildRoster()` on a 60-second ticker, iterated `GetGuildRosterInfo` itself, and hand-rolled a baseline guard -- all of which the library already does, and does better:

  - **Presence is event-driven, not polled.** The lib maintains `isOnline` from `CHAT_MSG_SYSTEM` ("has come online" / "has gone offline"), mutating the member in place with no roster pull, so the online flag the fix above depends on is accurate the moment it changes rather than up to 60 seconds stale.
  - **The baseline is the lib's.** `OnRosterReady` fires only after a stabilized full build, with login retries for the window where `GetNumGuildMembers()` returns 0. The previous guard was `next(_guildTracker) == nil` -- "is the tracker empty", which is not the same question, and a partial first `GUILD_ROSTER_UPDATE` armed the diff against incomplete data for the rest of the session.
  - **The 60s ticker is gone.** It existed only to keep our own snapshot warm; polling behind the lib would add a redundant server round-trip per consumer.
  - **Names are normalized by the lib.** `GetAllMembers` / `GetMember` key on canonical `Name-Realm`, replacing the local `shortName` keying in the guild path.
  - **The level diff is the library's.** LibGuildRoster's `OnMemberLevelChanged(name, oldLevel, newLevel, wasOnline, isOnline)` fires from its own rebuild diff behind the same post-stabilization gate as `OnMemberRankChanged`, so the module caches no roster state whatsoever.

    The floor is **MINOR 10**, not 9. MINOR 9 introduced the callback but still defaulted a partial roster row's nil level to `1`, so the next rebuild reported `(1 -> 60)` with **both presence flags true** -- which satisfies `guildLevelUpQualifies` and is indistinguishable from a real ding, meaning Gratz would announce a level-up that never happened. That is precisely the false positive this release exists to fix, so 9 is **refused** rather than tolerated; MINOR 10 carries the fix. There is no fallback path -- if an older copy wins the LibStub registration the guild trigger logs a debug line naming the version rather than no-opping in silence, which is realistic rather than theoretical since FastGuildInvite still vendors its own copy. `LibStub` is destructured on its own line because `local a, b = LibStub and LibStub(...)` truncates to the first return and would pin the minor to `nil`, permanently disabling the trigger.

  The achievement trigger's `scope == "guild"` membership check also switched from a hand-rolled roster scan to `LGR:IsInGuild`, which normalizes both sides -- the scan compared a `Name-Realm` sender against possibly-bare roster names and missed members still streaming in. It is now skipped entirely until `LGR:IsReady()`, rather than filtering against a roster known to be partial. Location: `Modules/Gratz/Gratz.lua`.

- **Game-version derivation split into `Version.lua`** -- The `GetBuildInfo` -> flavour-flag block sat three lines above `LibStub("AceAddon-3.0"):NewAddon(...)` in `TOGTools.lua`, so the file could not be loaded offline and every spec hand-built its own flag table: the six-TOC *branches* were covered, but the code that **chooses** the branch never was. Moved verbatim into a new dependency-free `Version.lua` (loaded first in the TOC) exposing a pure `addon.DeriveGameVersion(tocVersion)`; `TOGTools.lua` now aliases `addon.gameVersion`. Same bands, same evaluation order, same `select(4, GetBuildInfo())` read -- no behaviour change. `Tests/version_spec.lua` pins all six shipped interface numbers, **both sides of every band edge**, the 12.0 gate at exactly 119999/120000, Classic/Retail mutual exclusivity at every band, and that the load-time wiring reads the interface number rather than the version string.

- **Offline unit-test suite extended to every module** -- 18 new specs joining `idhitthat_spec.lua`, **503 tests**, green under the zero-dependency runner (`lua Tests\wowapi\run.lua`) -- the only way this suite is run. Tests are local and by hand; there is no CI test job, and `busted` is not used. Each spec drives its module through the **real** wiring -- event frames captured from `CreateFrame` and fired with `:Fire("OnEvent", ...)`, `hooksecurefunc` hooks captured by name, LibGuildRoster callbacks captured from a stub library -- so registration and dispatch are exercised, not just leaf functions. Every spec also passes in isolation, so no cross-file global leakage is masking a failure. New: `version`, `gratz`, `nameprefix`, `guildlog`, `maillog`, `tradelog`, `whisperlog`, `guildbanklog`, `gatheringlog`, `logs`, `diagnostics`, `mailbox`, `logindigest`, `addonload`, `smack`, `loggraph`, `loggraphs`, `itemdb`.

  Smack's combat-log **threshold engine** is covered rather than just its hotkey path: crossing fires once and not on every subsequent health tick, re-arms only when the value returns across the boundary, and combat-start arming means neither pulling at full health (an "above" filter) nor entering a fight already wounded (a "below" filter) counts as a crossing. Group thresholds are armed per-GUID, so one member dropping low cannot disarm the filter for the rest of the raid.

- **Tests run locally, not on CI** -- `.github/workflows/tests.yml` was removed. It spun up a GitHub-hosted runner on every push and PR to install Lua and busted and run the suite there, which is not how this project is tested: the suite runs on the developer's machine, by hand, with zero dependencies, via `lua Tests\wowapi\run.lua`. With one developer and no second contributor a green check on GitHub adds nothing and isn't visible where the work happens -- a failing test needs to stop the change locally, before it lands. GitHub's only remaining workflow is `release.yml`, i.e. packaging. `CLAUDE.md` gained a `## Test system: runnerless, local only` section recording that, so a later session doesn't reinstate the workflow or go looking for `busted` and conclude the suite can't run when it isn't the runner.

- **Logic cores exposed for testing** -- `NamePrefix.ApplyPrefix` / `CollapseMain` / `GRMSenderMain` and `LogGraph.Bucketize` / `AxisText` / `PERIODS` are now reachable on their module tables. All were file-locals with no entry point, behind editbox / chat-filter / AceGUI plumbing that needs a live client -- the CLAUDE.md rule about factoring a UI-thin logic core so it can be tested. The file-locals remain, so the hot paths still resolve without a table lookup. Locations: `Modules/NamePrefix/NamePrefix.lua`, `GUI/LogGraph.lua`.

- **GuildRoster added as a required dependency** -- `## Dependencies: Ace3, VersionCheck-1.0, GuildRoster, !!TOGT` in the TOC and `libguildroster` in `.pkgmeta` `required-dependencies` so CurseForge auto-installs it, matching FastGuildInvite's setup. The `LibStub(..., true)` silent lookup means a broken install disarms the guild level-up trigger instead of erroring at file scope.

- **`.pkgmeta` ignore list corrected** -- `docs/`, `.git/`, `.github/` and `.vscode/` had trailing slashes or leading dots and matched nothing (`parse_ignore` strips a trailing `/*`, not a bare `/`; `copy_directory_tree` prunes dotfiles unconditionally), and `"**/*.ps1"` / `"**/*.bat"` never matched because `match_pattern` uses shell `case` globbing with no recursive `**`. Rewritten to bare folder names and single-star globs, with the rules documented inline.

---

## [v0.8.0] (2026-07-30) - I'd Hit That: always-visible hit chance vs your target

### New Features

- **I'd Hit That module** -- A new tab plus an always-visible readout showing your chance to hit the current target, broken out per attack class: **yellow** (special attacks), **white main-hand**, **white off-hand**, **ranged**, and **spell**. Each is an icon + percentage; the icon is the player's *actual equipped weapon* via `GetInventoryItemTexture`, so a row is self-labelling (your sword carries your white chance, your bow carries your ranged chance). Yellow and spell use the class icon from `Interface\TargetingFrame\UI-Classes-Circles` + `CLASS_ICON_TCOORDS`, with spell tinted blue so the two are distinguishable. Coloured on the shortfall to cap (green at cap / amber within 2% / red beyond), with a footer showing `need +X% hit` or the glancing rate. Rows stack vertically by default (configurable to a row). Location: `Modules/IdHitThat/IdHitThat.lua` (engine), `GUI/IdHitThatHUD.lua` (HUD), `GUI/IdHitThatTab.lua` (settings). New SV section `db.global.idHitThat = { enabled, side, layout, showWhite, showRanged, showSpell }`.

- **Ranged attack support** -- Its own path, deliberately not derived from the melee numbers. Skill comes from `UnitRangedAttack("player")` (verified in Blizzard's Vanilla `PaperDollFrame.lua:462`), and ranged has **no dual-wield penalty and no glancing blows** -- any shot that isn't a miss lands in full. Gated on `UnitHasRelicSlot`, which Blizzard's own ranged display uses for the same reason: slot 18 holds either a ranged weapon *or* a Paladin libram / Shaman totem / Druid idol, and the latter must not produce a ranged readout. **Scope hit** is read off slot 18 separately, because `GetHitModifier()` omits it; `tooltipHitScan` therefore covers slots 1-17 only and slot-18 hit is treated as ranged-only, which is what prevents double-counting on the fallback path.

- **Host unit-frame skins** -- The readout is built from the host addon's *own* artwork so it reads as a native element. **ZPerl**: bubbles created with `XPerlBackdropTemplate,XPerl_BorderStyleTemplate` and the `XPerl_Frame_Backdrop_32_16_3333` backdrop, chained off `cpFrame` when combo points are up and `levelFrame` otherwise -- the same idiom ZPerl uses between its own boxes (per `ZPerl_Target.xml`). **ElvUI**: verified against the tukui-org/ElvUI source -- frames spawn as `ElvUF_'..StringTitle(unit)`, and the target frame carries `Health`/`Power`/`Name`/`Portrait2D`/`Portrait3D`/`Buffs`/`Debuffs`/`Castbar` but has **no level element** (level lives in the Name tag), so there is no bubble to chain off; it aligns to the aura grid instead, reading `auras.size` / `auras.height` / `auras.spacing` which `Configure_Auras` drives from the user's ElvUI profile. **Blizzard**: matches the aura grid from Classic `TargetFrame.lua` (`LARGE_AURA_SIZE = 21`, bordered frame is `size + 2`, `AURA_OFFSET_Y = 1`), preferring a live measurement of `TargetFrameDebuff1` when the target actually has a debuff up. Anything else gets a generic skin with the same bubbles and neutral defaults. Every skin is feature-checked and `pcall`-guarded: a renamed frame degrades to the generic look rather than erroring.

- **Alignment measured from the host, not assumed** -- `measureHostStep` finds the nearest sibling stacked directly below the anchor (shown, horizontally overlapping, top edge lower) and uses the real **top-to-top distance** as our row spacing; bubble height comes from `anchor:GetHeight()`. Both therefore track the host's configured size instead of drifting out of step the moment anyone rescales their frames. Bubble width is derived from its parts (`BUBBLE_PAD + BUBBLE_ICON + BUBBLE_TGAP + BUBBLE_TEXT + BUBBLE_PAD`) rather than hand-tuned, so the number can never spill past the border at three digits.

- **Per-row tooltips** -- Each bubble names itself, explains what the number covers, and adds a gold line with either the shortfall or "At the hit cap for this target." Deliberately **not** routed through `addon.UI.AttachTooltip`: that helper anchors via `addon.Tooltip.Owner`, whose flip threshold is tuned for the main window and would cover the strip. Placement anchors off the whole strip rather than the hovered bubble, so no bubble is obscured regardless of which one the mouse is on, and flips to the left past 70% of screen width.

- **Per-flavor hit model** -- Three distinct code paths, because the mechanic differs by expansion. **Vanilla / TBC / Wrath**: the weapon-skill model. Target defense is derived as `level * 5` (no API exposes an NPC's defense skill), player skill comes from `UnitAttackBothHands("player")` -- verified present in Blizzard's Vanilla `PaperDollFrame.lua` -- and base miss uses the 10-point-break formula (`delta > 10 -> 5 + delta*0.2`, else `5 + delta*0.1`). Glancing chance is `10 + (targetDefense - playerBaseSkill) * 2`. Cross-checked against a level-63 boss: 8% base miss, 40% glancing. **Cata / Mists**: weapon skill was removed in 4.0, so Blizzard's own `PaperDollFrameUtil.Constants.BaseMissChancePhysical` tables are used verbatim (Cata `{5.0, 5.5, 6.0, 8.0}`, Mists `{3.0, 4.5, 6.0, 7.5}`). **Retail**: hit was removed as a stat in Warlords, so the module does no math and the tab says so plainly rather than showing dead controls. Spell miss uses `{4, 5, 6, 17}` by level offset, taken from Blizzard's Cata `BaseMissChanceSpell` -- whose values are identical to the Vanilla spell table.

- **Dual-wield and unknown-level handling** -- The dual-wield penalty (`DUAL_WIELD_HIT_PENALTY`, falling back to the literal 19 on clients that don't define it) is applied to **white swings only**; specials are unaffected. The off-hand row is computed from the off-hand's own skill, which is frequently lower than the main-hand's. A `??` target returns `UnitLevel == -1`, so it falls back to player level + 3 -- the raid-boss case and the realistic worst case.

- **Hit source with no external dependency** -- `GetHitModifier()` is **feature-detected**, not assumed: Blizzard's Vanilla `PaperDollFrame` never calls it, so its presence on the 1.15 Era client cannot be taken for granted. When absent, a cached tooltip scan of equipped slots 1-17 reads `+N% hit` off the item text -- necessary on Vanilla, where hit is an equip *aura* rather than an item mod and is therefore invisible to `GetItemStats`. Slot 18 is excluded from that sweep and scanned separately as ranged-only hit (see the ranged entry above). `GetCombatRatingBonus(CR_HIT_MELEE / CR_HIT_SPELL)` is folded in where combat ratings exist. No dependency on ItemDB/LibItemDB.

### Improvements

- **Zero idle cost by construction** -- Hit chance is a pure function of two independent halves that change at very different rates, so they are cached separately. The **expensive half** (gear hit %, weapon skill, talents, buffs) rebuilds only when a dirty flag is set; the **cheap half** (target level -> defense -> offset) is a handful of arithmetic ops and is the only thing that runs on a target swap. There is **no `OnUpdate` anywhere** in the module. `UNIT_AURA` is registered via `RegisterUnitEvent(..., "player")` so other units' aura churn in a 40-man raid never wakes the handler, and both `UNIT_AURA` and `UNIT_INVENTORY_CHANGED` only set `_playerDirty` -- consumed lazily inside `Recalculate` -- so a full gear swap firing ~19 inventory events costs exactly one rebuild. `SetText` is skipped when the formatted string is unchanged, avoiding font-string churn per target change.

- **No free-floating window -- a component or nothing** -- There is deliberately no draggable mode, no saved position and no background: the readout is a **child** of a host unit frame (so it inherits show/hide, scale and strata for free, and needs no visibility mirroring) or it is not drawn. The earlier fallback-to-a-window behaviour was worse than showing nothing, because unit-frame addons show their target frame *slightly after* `PLAYER_TARGET_CHANGED` fires -- our first pass would find nothing shown and produce a window roughly half the time. `UpdateHUD` now hides and reschedules itself (`C_Timer.After`, 50 ms, bounded at ~2 s and reset on every event-driven refresh) until the host frame appears. `SetParent` onto a secure host is deferred out of combat; our frame is never protected and we never call anything on theirs, so nothing can taint a secure unit frame.

- **Silent fallbacks are now diagnosable** -- A skin failing must never be fatal, which also made it invisible from a screenshot. `resolveSkin` now reports its branch once per change through `addon:Debug` -- which skin was chosen, that a host frame was missing or hidden, or the error text when a resolver threw.

- **New globals in `.luarc.json`** -- `GetHitModifier`, `GetSpellHitModifier`, `GetCombatRatingBonus`, `UnitAttackBothHands`, `UnitRangedAttack`, `UnitHasRelicSlot`, `OffhandHasWeapon`, `GetInventoryItemLink`, `GetInventoryItemTexture`, `UnitCanAttack`, `UnitClassification`, `CLASS_ICON_TCOORDS`, `TargetFrame`, `TargetFrameDebuff1`, `ElvUF_Target`, `XPerl_Target`, `CR_HIT_MELEE`, `CR_HIT_RANGED`, `CR_HIT_SPELL`, `DUAL_WIELD_HIT_PENALTY`.

### Bug Fixes (pre-release)

- **Ranged hit inherited the melee combat rating** -- `_player.rangedHit` was built from `_player.hit`, which already had `GetCombatRatingBonus(CR_HIT_MELEE)` folded in, so on Cata/Mists melee hit rating leaked into the ranged number. Split into a shared `_player.baseHit` (gear/talent/buff hit, no rating) with melee and ranged each adding their own `CR_HIT_MELEE` / `CR_HIT_RANGED` on top.

- **Two forward-reference bugs in the HUD** -- `BUBBLE_GAP` and `measureHostStep` were both declared *after* the skins table whose resolvers close over them. A Lua function cannot see a `local` declared below it, so each resolved to a nil global; because resolvers run inside a `pcall`, the failure surfaced only as a silent fallback to the unskinned look. Both are now declared/forward-declared above the table.

- **Bubble text overflowed the border** -- The percentage font string had no explicit width, so it sized to its content and ran past the bubble at `100%`. It now gets `SetWidth` plus `SetWordWrap(false)`, and the bubble width is computed from its parts rather than guessed.

- **Bubbles overlapped when chained** -- ZPerl anchors `cpFrame` to `levelFrame` at `x = -4`, and copying that offset verbatim made our bubbles overlap: the negative value is tuned to *ZPerl's* border padding, not ours. Replaced with a small positive `BUBBLE_GAP`.

---

## [v0.7.2] (2026-07-20) - Guild Bank duplicate-transaction capture + GRM dedup reliability

### Bug Fixes

- **Guild Bank log dropped repeated identical transactions** -- Multiple genuinely-distinct transactions that share every captured field -- e.g. three separate deposits of `[Primal Life] x20` into the same tab within the same hour -- collapsed into a single row, so most of a player's deposits silently went missing from the log (user-reported with a side-by-side against the in-game bank log). Root cause: the dedupe key was `type | itemSig | count | tab1 | tab2 | amount`, with `name` and `ymdh` deliberately excluded (name is returned inconsistently across queries; the hour stamp is a relative "X ago" offset that drifts). That left nothing to tell two identical-looking transactions apart, so they hashed to the same key and all but one were discarded. Fix: introduced an **occurrence ordinal** (`occ`) assigned in transaction-index order during ingest (`GetNumGuildBankTransactions`/`GetGuildBankTransaction` walk index 1 = oldest -> newest, per the Blizzard `Blizzard_GuildBankUI` mirror) and folded into both `dedupeKey` and `rowId`. N identical transactions in a buffer now become N distinct keys (`...|1`, `...|2`, `...|3`); because every viewer reads the same ordered bank buffer, the ordinals -- and therefore the cross-viewer sync ids -- stay consistent. `name` is still out of the key, so the existing name-backfill path is untouched. Split the old `dedupeKey` into `baseKey` (the shared tuple) + occ; added `migrateOccOrdinals`, a one-shot per-bucket backfill that assigns `occ=1` to legacy entries (the pre-`occ` key *was* the base tuple, so each had at most one persisted entry) and re-derives their `id`, so on the next bank visit the collapsed historical siblings are recovered rather than staying lost. `occ` added to the `/togt gbdump` diagnostic line and the `GetEntries` row copy. Location: `Modules/GuildBankLog/GuildBankLog.lua`.

- **Name Prefix: GRM main-name dedup only worked some sessions** -- The opt-in "Hide duplicate main name shown by GRM" filter collapses the redundant `(Main)` only if it runs *after* GRM's `AddMainToChat` filter in the chat-filter chain (filters run in registration order -- confirmed against the Blizzard `ChatFrameFilters` mirror). Registration was on a fixed 3-second post-login timer, but GRM registers `AddMainToChat` on a *variable* schedule (`MessageHookControl` runs during login `LoadAddon` **and** again after the first roster scan completes, gated on `IsInGuild()` and scan timing). When GRM hooked after our 3-second timer, our filter landed *earlier* in the chain -- it saw only one `(Main)`, did nothing, and GRM then appended its copy, so the duplicate survived. Nondeterministic across sessions = "not consistently working." Fix: `ApplyDedupSetting` now polls `GRM_G.MainHookConfigured` (the exact global GRM flips when it registers its filter) and registers our filter only once GRM is hooked -- guaranteeing we run after it every session. The poll is bounded (~20s) and only runs when GRM is actually installed, so a no-GRM or not-in-a-guild client can't leak a timer. Location: `Modules/NamePrefix/NamePrefix.lua`.

---

## [v0.7.1] (2026-07-17) - Battle.net whisper attribution fix

### Bug Fixes

- **Battle.net whispers logged against the wrong person** -- In the Whisper Log, a Battle.net whisper could show under a different friend's name than the one who actually sent it, especially after a relog. Root cause: `CHAT_MSG_BN_WHISPER` / `_INFORM` deliver the partner in arg2 as a **Kstring** (e.g. `|Kq2|k`) -- a session-scoped index into the client's presence table, not a stable name (confirmed against the Blizzard docs mirror `ChatInfoDocumentation.lua` and Warcraft Wiki). The engine persisted that token verbatim, so on a later session the client re-resolved the same `|Kq2|k` to whichever presence now held that slot, relabelling old messages. In-game whispers (real name + GUID) were never affected. Fix: a new `resolveBNName(bnSenderID, rawName)` resolves the sender to a stable `accountName` (fallback `battleTag`) from **bnSenderID (arg13)** via `C_BattleNet.GetAccountInfoByID` at capture time -- the same path Blizzard's own chat UI uses (`ChatFrameOverrides.lua`) -- and stores that plain string, never the Kstring. Secret-guarded (`bnSenderID` isn't `NeverSecret`; can be secret in Retail chat lockdown), `pcall`-wrapped, and feature-detected (`C_BattleNet.GetAccountInfoByID` exists Classic Era 1.15 -> Retail, so all six flavors resolve; anything lacking it falls back to the label "Battle.net friend"). Display-side, `fmtOther` now masks any already-stored Kstring on BN rows with "Battle.net friend", so pre-fix entries stop rendering a mislabelled name -- non-destructive, saved data is left intact. New global `C_BattleNet` in `.luarc.json`. Location: `Modules/WhisperLog/WhisperLog.lua`, `GUI/WhisperLogSubTab.lua`.

---

## [v0.7.0] (2026-06-14) - Logs overhaul (Diagnostics, filter/sort/search, Gathering source), Graphs, Name Prefix/GRM dedup, the Smack tool, and Retail/Midnight secret-value handling

### New Features

- **Name Prefix: hide a duplicate main name shown by GRM (opt-in)** -- When a guildmate's addon self-prefixes their main into a message (`(Bob) ...`) *and* you run Guild Roster Manager with "show main name" on, GRM injects its own `(Bob):` into the same message body -- so you see the main twice. A new **off-by-default** toggle in the Name Prefix tab adds a receive-side chat filter (over GRM's decorated channels -- guild/officer/whisper/party/raid/instance) that collapses the redundant copy, keeping GRM's canonical class-coloured tag. It **consumes GRM's own data** (`GRM.GetPlayer` / `GetFormattedMainName` / `S().showMainName`) per our data-scope rule -- never a home-grown roster. Safety: it only removes a `(main)` token that appears a **second** time near the front and matches the sender's GRM main, so it can't eat legitimately-typed text like `(brb) ...`. **Zero footprint when off** -- no filter registered, no GRM call -- so there's no integration or cost unless ticked; registration is deferred at login so it runs after GRM's filter. New SV field `namePrefix.dedupeGRMMain`; new global `ChatFrame_RemoveMessageEventFilter`. Location: `Modules/NamePrefix/NamePrefix.lua`, `GUI/NamePrefixTab.lua`.

- **Smack: per-filter hotkeys + "Manual (hotkey only)" trigger** -- Any filter can carry a key combo that fires it **on demand**, and a new `manual` trigger makes a filter that *only* fires from its hotkey (never automatically). This is the key answer to the Retail/Midnight restrictions: a **keypress is a hardware event**, so the resulting `SendChatMessage` is user-initiated and sidesteps *both* the secret system *and* the encounter public-chat block -- hotkey smack works everywhere, every flavour, including `/say` mid-boss, where the automatic triggers can't. Bound via **`SetOverrideBindingClick`** on a private owner frame, so bindings are **reversible** (`ClearOverrideBindings` restores the original; they auto-clear on logout) and the user's saved keybinds are **never overwritten** -- no clobbering other addons. The capture UI is an AceGUI `Keybinding` widget; on assignment we look up `GetBindingAction(key)` and print a non-blocking heads-up if the key is already taken (filtering out our own override buttons so reusing a Smack key doesn't false-positive). Binding changes are combat-protected, so an apply requested in combat defers to `PLAYER_REGEN_ENABLED`. Hotkey fires skip the per-filter minute cooldown (deliberate press) but keep a 1-second anti-double-tap floor. New filter field `hotkey`; a **Key** column in the list. New globals `SetOverrideBindingClick` / `ClearOverrideBindings` / `GetBindingAction` / `C_KeyBindings` in `.luarc.json`. Location: `Modules/Smack/Smack.lua`, `GUI/SmackTab.lua`.

- **Smack module (user request)** -- A new tab that fires a randomly-chosen chat line when a combat trigger hits -- DDO-style "trash-talk the boss while you heal". Built on the Gratz / FGI-Filters pattern: a top form (Name + Trigger + threshold + Channels + Lines + Cooldown + Save) and a `RowList` below where each row is a saved **filter** with an **On** checkbox for active/passive. Click a row to load it into the form; Save overwrites by name, a new name appends. Location: `Modules/Smack/Smack.lua` (engine) + `GUI/SmackTab.lua` (UI); SV schema `db.global.smack = { enabled, filters = {} }` (keyed by name, like `gratz`). **Classic-only: shipped in the five Classic TOCs but removed from the Retail (Mainline) TOC** -- on Midnight 12.0 the platform blocks essentially everything Smack needs (enemy health is secret, `COMBAT_LOG_EVENT_UNFILTERED` registration is refused, and automated public-chat sends are `ADDON_ACTION_BLOCKED`), all confirmed in-game, so rather than ship a half-working tab there it simply doesn't load on Retail.

- **Thirteen triggers, one per filter** -- Six percent-threshold triggers (show a `%` field, fire on a crossing): `self-hp-below`, `self-hp-above` (recover), `target-hp-below` (re-arms on a new target), `self-mana-below`, `group-hp-below` (tracked per member), and `self-bighit` (a single hit >= pct% of max health). Seven event triggers: `killing-blow` (CLEU `PARTY_KILL`, source = you), `combat-start` (`PLAYER_REGEN_DISABLED`), `combat-end` (`PLAYER_REGEN_ENABLED`, only if alive), `group-death` (CLEU `UNIT_DIED` with party/raid+player affiliation flags, not you), `self-cc` (`LOSS_OF_CONTROL_ADDED`), `self-death` (`PLAYER_DEAD`), and `bloodlust` (CLEU `SPELL_AURA_APPLIED` of a Bloodlust/Heroism/Time Warp/etc. spell on you).

- **Crossing + re-arm, combat-only** -- Threshold filters arm at `PLAYER_REGEN_DISABLED` from the current value (so an "above" filter doesn't fire just because you pulled at full health), fire once on the crossing, and re-arm only when the value returns to the waiting side; target filters also re-arm on `PLAYER_TARGET_CHANGED` (without firing on an already-low new target). Group filters arm per member GUID. Everything is gated on `_inCombat`; the high-frequency `UNIT_HEALTH` / `UNIT_POWER_UPDATE` / CLEU handlers early-out when out of combat, and CLEU also bails before parsing unless an active filter needs it (`_clActive`, refreshed at combat start).

- **Lines, channels, cooldown** -- Each filter holds a pool of lines (multi-line editor, one per row); one is picked at random per fire, with placeholders `[me]` / `[target]` / `[hp]` / `[targethp]` / `[member]`. Channels reuse the Gratz set plus **Emote** (Guild / Officer / Party / Raid / Say / Yell / Instance / Emote), dispatched **immediately** (no Gratz-style combat queue -- the point is to talk mid-fight). Per-filter cooldown is entered in **minutes** (minimum 1) and caps repeats.

- **Public-chat restriction documented (not a protected call, but restricted)** -- Per Blizzard's `ChatInfoDocumentation` (`SendChatMessage` `HasRestrictions` / `RestrictedForMacroChatMessages`, `FailureMode = "ReturnNothing"`), the chat types observable by external players -- **Say / Yell / Emote** and public channels -- are silently dropped when sent from automated/addon code **during an instance boss encounter**. So a boss-fight smack set to Say/Yell/Emote won't appear inside a dungeon/raid encounter (open-world combat is unaffected); **Party / Raid / Guild / Officer / Instance are not restricted** and work during encounters. `SendChatMessage` is not a protected function (no taint / blocked-action error), so the send just no-ops -- surfaced in the tab help and the Say/Yell/Emote channel tooltips rather than worked around. Mirrors why DBM/BigWigs announce to Raid, not /say, during pulls.

- **Version safety** -- Every trigger maps to an event present Era->Retail; ones that can't apply degrade to silence (Bloodlust never lands on Classic Era; mana filters no-op for a no-mana class/form). Percent helpers return nil for a `secret` value (`issecretvalue`, feature-detected) so HP/damage comparisons never throw during Retail tainted combat. New globals in `.luarc.json`: `UnitHealth` / `UnitHealthMax` / `UnitPower` / `UnitPowerMax` / `UnitPowerType` / `UnitExists` / `UnitIsUnit` / `UnitIsDeadOrGhost` / `UnitClass` / `CombatLogGetCurrentEventInfo` / `bit`.

### Improvements

- **Smack: Retail/Midnight-aware trigger gating** -- Six triggers can't work on Retail/Midnight and are now gated to **Classic only**, for two confirmed reasons. (1) **Target HP below %** and **Group member below %** read another unit's health, which is a *secret* value to addons there (`UnitHealth` is `SecretReturns = true`; only the player's own health reads back plainly -- DBM reads player health ungated but guards every other unit). (2) **Killing blow**, **Big hit**, **Group death**, and **Bloodlust** depend on `COMBAT_LOG_EVENT_UNFILTERED`, whose registration is **blocked for addons on Midnight 12.0** -- verified in-game (a diagnostic showed `issecure()` is false in our `PLAYER_ENTERING_WORLD` handler, so the registration is never attempted and CLEU never fires) and independently by Recount (which pivoted to `C_DamageMeter`) and DBM (stub Midnight mods). A central `isTriggerAvailable()` check in `eachActiveFilter` keeps all six **dormant on Retail** -- no fire, no notice -- so a Classic-made filter synced via the account-wide SV just does nothing there. The trigger dropdown hides them on Retail (a Classic-made filter opened on Retail still shows its trigger tagged `(Classic only)`); tab help + tooltips state the limit. Retail keeps every self/event trigger (**My HP/mana**, **Combat start/end**, **CC'd**, **I die**) plus the **Manual hotkey** (a hardware event -- works everywhere). Detection-flag branching in the shared engine, not a forked Retail file, so the paths can't drift. CLEU GUID compares were also hardened with a secret guard. Location: `Modules/Smack/Smack.lua`, `GUI/SmackTab.lua`.

- **Smack list columns align left like the other tabs** -- The Smack filter list right-packed all its columns because every column carried a width (the `Name` column was `autoFit`, so RowList had no zero-width "stretch" column and right-anchored everything). Made `Name` the auto-width stretch column (no `width`/`autoFit`), matching the Addon Load tab's `Addon Name` column, so the table spans from the left edge. Location: `GUI/SmackTab.lua`.

- **Graphs: filter dropdowns now cascade** -- On the Logs -> Graphs page, each per-log filter's options are now drawn from the entries passing the OTHER active filters, so choosing **Mining** trims the **Item** / **Zone** / **Source** lists to mining gathers only (previously every dropdown listed everything regardless of the other selections). A selection that's no longer offered resets to "All". Implemented via a `getOptions(scopedEntries)` signature and a `rebuildFilterDropdowns()` pass run inside `refresh()`. Location: `GUI/LogGraph.lua`, `Modules/LogGraphs/LogGraphs.lua`.

- **Graphs: distinguish same-name items (e.g. the two Refulgent Copper Ore tiers)** -- The Gathering chart keyed series by `itemID` but labelled them by name, so two items sharing a name rendered as identical legend/tooltip entries. Series labels and the **Item** filter now use the captured item **link** (quality colour + the reagent-tier "pentagon" icon) and the Item filter is keyed by `itemID`, matching the Gathering log's Item column -- so the tiers are visually distinct and individually selectable. Location: `Modules/LogGraphs/LogGraphs.lua`.

### Bug Fixes

- **Smack: automatic public-chat sends blocked on Retail/Midnight (ADDON_ACTION_BLOCKED)** -- Confirmed in-game: an auto-trigger (combat-end) firing to **Say** threw `ADDON_ACTION_BLOCKED` on `C_ChatInfo.SendChatMessage` -- even in the open world, not just encounters. So Blizzard requires a **hardware event** to send to public channels (Say/Yell/Emote) on Midnight; automated addon sends are blocked. Fix: `sendToChannels` now takes an `isHardware` flag -- for **automatic** fires on 12.0+ it **skips** the public channels (no blocked-action error) and warns the user **once per filter** to use Party/Raid/Guild or bind a hotkey; **hotkey** fires (a real keypress) go through to every channel, and **Classic** is unaffected. Non-public channels (Party/Raid/Guild/Officer/Instance) are still attempted for auto-fires. The `isHardware` flag also subsumes the hotkey's cooldown bypass. Location: `Modules/Smack/Smack.lua`, `GUI/SmackTab.lua`.

- **Smack enemy-health triggers explained instead of silently dead on Retail/Midnight** -- `Target HP below %` (and `big hit`, which reads combat-log damage) never fired on Midnight 12.0 and looked broken. Confirmed via the API docs (`UnitHealth` is annotated `SecretReturns = true`; its `unit` arg is a `UnitTokenPvPRestrictedForAddOns`) and an in-game trace (`UNIT_HEALTH` fires for `target` but `hpPct` reads `nil` every tick): on Retail/Midnight a unit's health is a **secret** value to addons, so it can't be compared -- and there's no read-around (a secret may be handed to a display sink but never compared/sorted/summed; verified against Recount's `Tracker_Midnight.lua` pass-through approach). Smack now warns **once per filter** when a trigger's value is unreadable (instead of silently doing nothing or spamming chat every health tick), and the `Target HP` / `big hit` trigger tooltips note the limit. These triggers work normally on Classic (no secret system); own-health, mana, and event triggers are unaffected everywhere. Location: `Modules/Smack/Smack.lua`, `GUI/SmackTab.lua`.

- **Gathering Log threw on a secret tooltip/GUID while gathering in tainted combat** -- In the `UNIT_SPELLCAST_SENT` handler, the object-source path read the node name via `GameTooltipTextLeft1:GetText()` and tested `if t and t ~= "" and not issecretvalue(t)` -- but on Retail the tooltip text is a *secret* value when gathering during tainted combat, and the `t ~= ""` comparison runs **before** the `issecretvalue` test and throws on a secret ("attempt to compare local 't' (a secret string value...)"). Reordered so `issecretvalue(t)` is tested before any comparison (truthiness is safe; comparison isn't). Two adjacent GUID reads in the same mid-combat gathering path had the same latent hole -- `UnitGUID("target")` (`UNIT_SPELLCAST_SENT`) and `GetLootSourceInfo(1)` (`LOOT_OPENED`) were `strsplit`/compared with no secret guard even though the name beside the first was guarded -- so both now carry an `issecretvalue` check before `strsplit`. No-op on Classic Era. Location: `Modules/GatheringLog/GatheringLog.lua`.

- **Secret values in name placeholders could throw on Retail** -- Smack's `[target]` / `[member]` / `[me]` placeholders read `UnitName` (and the combat-log `destName`), which on Retail can be *secret* values during tainted combat. Feeding one into an outgoing line would make the message text secret, and Ace3's `ChatThrottleLib` `SendChatMessage` post-hook (`tostring(text)` on every send) throws "attempt to perform string conversion on a secret string value" on it. The earlier audit guarded the numeric reads (`UnitHealth`/power) but not the name reads. Fix: all placeholder name values now go through a `safeStr`/`safeName` helper that collapses a secret (or nil) to `""`, plus a final `issecretvalue(msg)` guard in `fireFilter` that skips the send entirely rather than ever passing a secret to `SendChatMessage`. No-op on Classic Era (no secret system). Location: `Modules/Smack/Smack.lua`.

- **`ADDON_ACTION_FORBIDDEN` registering `COMBAT_LOG_EVENT_UNFILTERED`** -- The engine registered all events in the file-load main chunk. `COMBAT_LOG_EVENT_UNFILTERED` is a secure-registration event, and TOGTools' load chain is tainted (the `!!TOGT` / Diagnostics `seterrorhandler` wrap runs earlier in the load order), so registering it from that context tripped `ADDON_ACTION_FORBIDDEN` -- the insecure events (`PLAYER_REGEN_*`, `UNIT_HEALTH`, ...) registered fine, only the secure combat-log event was blocked. Fix: the insecure events still register at load (they're unaffected by taint); the secure combat-log event is registered from the `PLAYER_ENTERING_WORLD` handler and **gated on `issecure()`** -- the documented test for whether the running execution path is untainted -- so the secure call is only attempted when it's guaranteed not to be blocked, rather than assuming a `C_Timer` callback happens to be clean. `PEW` is a clean Blizzard dispatch and fires on login / `/reload` / zone change, so a one-off tainted dispatch just retries next time; once wired, `PEW` is unregistered. The handler also picks up `InCombatLockdown()` so a mid-combat `/reload` isn't dormant until the next fight. New global `issecure` in `.luarc.json`. Location: `Modules/Smack/Smack.lua`.

### New Features (Logs / Diagnostics)

- **Diagnostics sub-tab (developer tool)** -- A dev-gated sub-tab under the **Logs** nexus (`Modules/Diagnostics/Diagnostics.lua` + `GUI/DiagnosticsTab.lua`, registered as `addon.logCategories["diagnostics"]`) that logs the Lua errors and Lua warnings (`LUA_WARNING`) the client raises in memory. Blocked/forbidden addon-action events (`ADDON_ACTION_BLOCKED` / `_FORBIDDEN` + the `MACRO_` variants) are folded in as errors, tagged `blocked:`/`forbidden:` in Detail -- these are NOT "taint" (true taint propagation goes only to the unreadable on-disk `taint.log`); they're the addon-action errors the `scriptErrors` CVar surfaces. Errors keep ONLY the parsed message summary (addon, `file:line`, reason via `splitMessage`) -- never the stack/locals -- and identical messages de-duplicate into one row with a `count` + last-seen time (Blizzard's `ScriptErrorsFrame` approach). One RowList (Type / Source / Detail / # / Last-seen), a search box, a Clear button, a Test button (throws real errors on command -- see below), and a right-click context menu (Copy); filtering to just Errors or Warnings is the Type column-header filter. SV schema `db.global.diagnostics` (`entries`, capped at 500 distinct, oldest-by-last pruned; `alert` flag). Added to all six TOCs; new globals `C_CVar` / `GetCVar` / `SetCVar` / `GetCVarBool` / `issecretvalue` / `CopyToClipboard` / `MenuUtil` / `EasyMenu` in `.luarc.json`.

- **Early capture via the !!TOGT companion (requires !!TOGT v0.2.0+)** -- Capture lives in !!TOGT (renamed from !TOGT so it loads before EVERY other addon, including !BugGrabber), not in TOGTools, because most addon load errors fire during the loading screen -- before TOGTools, a lettered addon, has initialized. !!TOGT saves the real `seterrorhandler` at file-load, installs a **wrapping** handler that captures then TAIL-CALLs the previous one (`return prev(msg)`), and re-asserts itself on top whenever something (e.g. BugGrabber, which neuters `seterrorhandler` without chaining) replaces it; it also runs a persistent warning / blocked-action event frame. Records buffer into `TOGToolsEarlyData.diagnostics` (ring-capped at 250). The tail-call wrap (Lua 5.1 drops our frame) means TOG Tools and BugSack both capture every error, with neither broken. TOG Tools' six TOCs now declare `## Dependencies: !!TOGT`. On `OnEnable`, the Diagnostics module's `_attachCapture` sets `TOGToolsEarlyData.diagnostics.cb` (so later captures arrive live) and drains the buffer through `_ingestRaw` -> `_record`; the callback is attached BEFORE the drain iterates, so a capture firing mid-drain is never lost or double-counted. When the companion provides the buffer there is exactly one !!TOGT error handler in our pair (BugGrabber, if present, is wrapped beneath it). **Fallback:** if `TOGToolsEarlyData.diagnostics` is absent (an older companion without capture, or it failed to load), `_attachCapture` calls `_installSelfCapture` to install TOGTools' own chained handler + event frame, so live capture still works (only the pre-load window is missed). `_record` self-gates on the Developer Tools switch, so end users store nothing.

- **New-error alert toast** -- BugSack-style on-screen toast (`TOGToolsDiagAlert`, a backdrop Button anchored TOP of UIParent) shown when a NEW distinct error is recorded. Left-click opens Logs > Diagnostics; right-click or a 5s auto-fade (Alpha `AnimationGroup`) dismisses; hover pauses the fade. Gated by `db.global.diagnostics.alert` (default on) via the "Alert on new errors" checkbox. Only new distinct errors alert -- repeats of an already-seen error just bump its count.

- **Titan Panel / LibDataBroker integration** -- A LibDataBroker-1.1 `"data source"` object (`TOGToolsDiagnostics`, label "TOG Tools Errors") shows live error / warning counts; left-click opens Logs > Diagnostics, right-click clears, tooltip breaks down the counts. `type = "data source"` (not `"launcher"`) so Titan Panel auto-adds it as a plugin -- pattern lifted from Grouper's `GrouperCore.lua` LDB object. Created only when Developer Tools is on (no broker clutter for end users); Titan picks it up live via `LibDataBroker_DataObjectCreated`, and the Settings devTools toggle calls `_ensureBroker` so it appears immediately. Text refreshes on every `_record` / `ClearAll` via `_updateBroker`.

- **Right-click a row for a context menu** -- Right-click pops a dropdown menu (cross-version: `MenuUtil.CreateContextMenu` on Retail 11.0+, `EasyMenu`/`UIDropDownMenu` on Classic) with a **Copy** option -- built to be extensible so more row actions can be added later. Copy opens a focused, pre-selected `StaticPopup` EditBox (`TOGTOOLS_DIAG_COPY`) for a manual Ctrl+C (addons can't write the OS clipboard); the copied line is plain-text and reconstructs the original `source: detail` message with a `[kind xN] ... (last ...)` tag. `RowList` now passes the mouse `button` and the row frame to `onRowClick` (backward-compatible extra args) so the tab can split right-click -> menu from other clicks and anchor the menu to the row. Location: `GUI/DiagnosticsTab.lua`, `GUI/RowList.lua`.

- **Sortable, multi-select-filterable column headers (every table)** -- Clicking any `RowList` column header opens a cross-version dropdown menu (new global `addon.UI.OpenColumnMenu`; `MenuUtil.CreateContextMenu` on Retail 11.0+, `EasyMenu`/`UIDropDownMenu` on Classic). It offers **sort** (auto-labelled by type -- text `A > Z`/`Z > A`, number `Low > High`/`High > Low`, date `Oldest > Newest`/`Newest > Oldest`, via `col.sortType` or inferred from the first value) and, on opted-in columns (`col.filterable = true`), a **multi-select filter** with Select all / Clear all and per-value checkboxes that keep the menu open (`MenuResponse.Refresh` on Retail, `keepShownOnClick` + `checked`-as-function on Classic). RowList owns the filter state (`colExcluded[colKey]`, in-memory), applies it in a rewritten `_getSortedData` (filter -> sort, cached in `_displayData`), marks a filtered header with a gold `*`, and `Refresh`/scrollbar size to the filtered count. **Cascading:** a column's checkbox list is built from the rows passing the OTHER columns' filters (`_rowsForFilterMenu`), so choosing Mining in Type narrows the Item / Source / Zone lists. **Nested groups:** a column with `col.filterGroup(entry)` renders its values as submenus (Gathering's Zone groups by continent, `_distinctGroups`). Value lists cap at 200 with an "N more (not shown)" note. Filtering is **opt-in** -- only short categorical columns enable it (Gathering Type/Source/Zone, Guild & Guild Bank Type, Guild Bank Tab, Diagnostics Type); name/item/free-text/numeric/timestamp columns stay sort-only. Labels are colour/link/atlas-stripped (`stripEscapes`) so quality variants group. Location: `GUI/RowList.lua`, `GUI/UI.lua`.

- **Per-table free-text search** -- `RowList:SetSearch(text)` filters rows by case-insensitive substring across EVERY column's rendered text (escapes stripped), composing with the column filters and the date range. Each log sub-tab gets a magnifying-glass search box via `addon.UI.StyleSearchBox` -- ported verbatim from TOGProfessionMaster (`Interface\Common\UI-Searchbox-Icon` + text inset, OnRelease cleanup). Live (`OnTextChanged`, no button), persisted per sub-tab in `self._search`. Location: `GUI/RowList.lua`, `GUI/UI.lua`, all `GUI/*LogSubTab.lua`, `GUI/DiagnosticsTab.lua`.

- **Gathering Log: source tracking (what each item came from)** -- Each gather entry now records `source` (the node / object / creature it came from), `sourceID` (a stable per-type id), and `continent`. The capture method was established empirically because the API exposes no node-name function and `UNIT_SPELLCAST_SENT.target` is a Secret value on Retail: a `SOURCE_KIND` map splits **object** gathers (mining / herbalism / fishing / gas -- name from the node's tooltip first line at `UNIT_SPELLCAST_SENT`, stable id from `GetLootSourceInfo`'s GameObject template id on the first `LOOT_OPENED` inside the pre-loot window, so a treasure chest opened mid-window can't steal the id) from **unit** gathers (skinning / pickpocketing -- name + creature id from the target unit). A new **Source** column shows it, and the **Zone** filter nests continent -> zone (continent resolved by walking the `C_Map` tree to the Continent-type map at gather time). Location: `Modules/GatheringLog/GatheringLog.lua`, `GUI/GatheringLogSubTab.lua`; new globals `UnitGUID` / `C_Map` / `Enum` / `GetNumLootItems` / `GetLootSourceInfo` in `.luarc.json`.

- **Gathering Log: gas extraction + pickpocketing** -- Two gather types beyond mining/herb/skin/fish: **Gas Extraction** (the `Extract Gas` cast -- a world/object source like a node) and **Pickpocketing** (`Pick Pocket` -- a unit source like skinning). Wired via the same `SOURCE_KIND` object/unit split (which also gives skinning proper creature attribution), detected by ability-name keyword so they self-confirm against the existing unlogged-loot debug line. Location: `Modules/GatheringLog/GatheringLog.lua`.

- **Graphs: per-log filter dropdowns** -- The Graphs sub-tab replaced its per-item series-checkbox wall with filter dropdowns that change with the selected log: **Gathering** = Character / Profession / Zone / Source / Item / Expansion, **Mail** = Character / Direction, **Trade** = Character / Partner. The spec's single `subFilter` became a `filters` list (`{ key, label, getOptions, match }`); `LogGraph` renders one single-select "All ..." dropdown per filter (wrapping by width), scopes the entries by the active selections, and `getSeries(filteredEntries)` charts one line per item (top 12) of that scoped set. The coloured totals line under the chart is now the legend. Location: `Modules/LogGraphs/LogGraphs.lua`, `GUI/LogGraph.lua`, `GUI/LogGraphsSubTab.lua`.

### Bug Fixes (Logs / Diagnostics)

- **Trades still never logged on Retail after the v0.6.4 attempt** -- v0.6.4 tracked "both sides accepted" as a live boolean updated on every `TRADE_ACCEPT_UPDATE` and re-snapshotted the slots each time, committing on `TRADE_CLOSED` if the flag was set. On Retail a *completing* trade fires a final `TRADE_ACCEPT_UPDATE(0,0)` teardown **and** empties the trade slots (`TRADE_PLAYER_ITEM_CHANGED` / `TRADE_TARGET_ITEM_CHANGED`) in the instant before `TRADE_CLOSED` -- so the live flag was cleared (-> no commit) and the re-snapshot wiped the captured items anyway. Reworked to a **latch + frozen copy**: on the `(1,1)` "both accepted" edge we snapshot the slots and freeze an independent copy of the items/money/enchant/partner into `_completed` (allocated via `slotsToList`, immune to later slot teardown). The latch is never cleared by a subsequent reset; an edit-then-re-accept just produces a new `(1,1)` that re-freezes. `TRADE_CLOSED` commits the frozen copy; a `_committed` flag dedupes against the Classic `ERR_TRADE_COMPLETE` path (which fires before teardown, so it freezes the still-valid live staging). Confirmed against the Retail flow in `f:\blizzard api docs` (`TradeFrame.xml` accept -> `C_SecureTransfer.AcceptTrade`; `TradeInfoDocumentation.lua` payload `playerAccepted`/`targetAccepted`). Added `[trade]` `addon:Debug` traces of the accept/close sequence so the live event order can be captured if anything is still off. Location: `Modules/TradeLog/TradeLog.lua`.

- **Whisper / Gathering logging errored on Retail in combat ("secret string value")** -- On Retail, chat-event payloads can arrive as **secret values** while execution is tainted during combat (the restricted-data / "Secret" system). `WhisperLog`'s `append` compared the whisper text (`text == ""`) and `GatheringLog`'s `parseLoot` ran `text:match(...)` / `tostring(text)` on the loot line -- any compare, match, or tostring on a secret value throws (`attempt to compare local 'text' (a secret string value, while execution tainted by 'TOGTools')`). The error was previously invisible because `scriptErrors` was off; the new Diagnostics "Display Lua errors" toggle (which enables `scriptErrors`) surfaced it -- the tool catching a real latent combat crash. Fix: feature-detect `issecretvalue` (`GlobalAPI.lua`; absent on Classic Era) and skip any whisper whose text/sender is secret, and any loot line that is secret -- we can't read, compare, or persist a secret value, and storing one would taint SavedVariables. Classic Era is unaffected (no Secret system). Location: `Modules/WhisperLog/WhisperLog.lua`, `Modules/GatheringLog/GatheringLog.lua`; new global `issecretvalue` in `.luarc.json`.

- **Diagnostics tab controls overlapped** -- The filter dropdown (which carries its own "Show" label, making it taller than a checkbox) shared one AceGUI Flow row with the capture checkboxes, so the differing widget heights overlapped. Split into a dedicated filter row plus a uniform-height toggle row (the ItemDB layout). Location: `GUI/DiagnosticsTab.lua`.

- **Alert toast text overlapped** -- The toast's hint line was anchored to the icon's bottom while the message was anchored below the title, so in the short (54px) frame the message and hint drew on top of each other. Reworked to stack title / message / hint top-down from one left edge in a taller (66px) frame. Location: `Modules/Diagnostics/Diagnostics.lua` (`ensureAlert`).

- **Titan/LDB counter stayed stale after Clear** -- `ClearAll` reset the entries and refreshed the tab but never refreshed the LibDataBroker text, so Titan kept the pre-clear counts; it now calls `_updateBroker`. The broker shows DISTINCT issue counts (matching the tab summary). Location: `Modules/Diagnostics/Diagnostics.lua` (`ClearAll`).

### Improvements (Logs / Diagnostics)

- **CVar capture toggles** -- Errors need no CVar (always captured via the handler). "Capture warnings" toggles `scriptWarnings` (the `LUA_WARNING` gate). "Display Lua errors" toggles `scriptErrors` -- Blizzard's built-in error popup, which is also the gate that lets the blocked/forbidden addon-action errors reach addons (so those rows populate only while it's on). `taintLog` is intentionally NOT touched -- it only writes the unreadable on-disk `taint.log` (an early build set it and so captured nothing in-game despite the file filling up; and an early build mislabeled this category "taint" -- it's just `scriptErrors` output). The tooltip is explicit that the toggle is `scriptErrors`, re-enables the default popup, and can fight BugSack. All `SetCVar` writes are `pcall`-wrapped with the live state read back and a `/console` fallback shown.

- **SavedVariables-too-large capture** -- `SAVED_VARIABLES_TOO_LARGE` (payload = culprit addon) is recorded as an error ("SavedVariables too large -- not saved (data lost)"). It's the usual cause of silent settings/data loss, and -- per a full sweep of the event list -- the only generic addon-troubleshooting signal beyond errors / warnings / blocked-actions (everything else error-ish is feature-specific gameplay noise; CPU/memory is deliberately left to dedicated profilers). Deprecation warnings are already covered -- the deprecation system routes through `LUA_WARNING`. Location: `!!TOGT/!TOGT.lua`, `Modules/Diagnostics/Diagnostics.lua`.

- **Test button throws REAL errors** -- Instead of injecting synthetic rows, the Test button now generates two genuine, harmless errors on command (`FireTestError`), each deferred to the next frame so it fires OUTSIDE the click's AceGUI `pcall` and reaches the global handler/events: (1) a real Lua `error()` -> exercises the `seterrorhandler` path and the !!TOGT tail-call wrap (it also lands in BugSack, proving coexistence); (2) a forbidden protected-function call (`CopyToClipboard`) -> an `ADDON_ACTION_FORBIDDEN` event exercising the event-frame path. An honest end-to-end test of both live capture paths rather than a fabricated display. Location: `Modules/Diagnostics/Diagnostics.lua` (`FireTestError`), `GUI/DiagnosticsTab.lua`.

- **Diagnostics moved under the Logs nexus as a sub-tab** -- It was originally a top-level `devOnly` tab, but structurally it's just another log (a de-duplicated RowList over time), so it now registers as `addon.logCategories["diagnostics"]` and renders inside the Logs tab's sub-tab strip -- reusing the nexus's per-sub help dispatch, last-viewed-sub-tab memory, and deep-linking instead of reimplementing them. **Gating is deliberately decoupled:** rather than the standard Logs-master-AND-per-category check (`IsLogCategoryEnabled`), the category declares a `gate()` that tracks ONLY the Developer Tools switch -- so a dev who disables gameplay logging (or any individual log type) still sees Diagnostics, and it never gets a player-facing toggle. `LogsTab.BuildInnerTabDefs` now honours `cat.gate` in place of `IsLogCategoryEnabled` (a gated category is governed solely by its gate, with no fallback); the Logs module gained `showWhenDev = true` so `MainWindow.BuildTabDefs` keeps the Logs tab present for developers even when the Logs module itself is toggled off -- otherwise disabling Logs would also hide Diagnostics. Since it left `addon.modules`, whose `OnEnable` loop no longer reaches it, `Ace:OnEnable` gained a second loop over `addon.logCategories` to call `OnEnable` on any log engine that exposes it (only Diagnostics does; the rest self-wire). The toast and the Titan/LDB broker now deep-link to Logs > Diagnostics via `pendingSubTab`; new dev-gated `/togt diag` (alias `/togt diagnostics`) does the same. `order = 900` sorts it last in the strip. Location: `Modules/Diagnostics/Diagnostics.lua`, `GUI/DiagnosticsTab.lua`, `GUI/LogsTab.lua`, `GUI/MainWindow.lua`, `GUI/Settings.lua`, `SlashCommands.lua`, `TOGTools.lua`.

- **Log sub-tab filter rows decluttered** -- Now that the column headers filter, the external filter-dropdown rows were removed wherever they duplicated a column: Character on every log, Profession/Zone on Gathering, Type on Guild, Type/Tab on Guild Bank, and the All/Errors/Warnings "Show" dropdown on Diagnostics. Non-column filters stay as dropdowns -- Whisper's Direction / Type / GM, Mail's & Trade's Direction, and the Guild & Guild Bank **Guild selector** (which chooses which guild's stored bucket loads -- a data source, not a display filter). Whisper's two substring boxes (Partner + Message) collapsed into the single all-column search box. Every tab keeps its Date range picker. Stale "use the ... dropdown above" column tooltips were updated to point at the column-header filter / search box. The four gameplay tabs (Mail / Trade / Guild / Guild Bank) were done in parallel against the Whisper/Gathering template. Location: all `GUI/*LogSubTab.lua`, `GUI/DiagnosticsTab.lua`.

- **Gathering stats text summary removed** -- The multi-line "By profession / Top items / By zone / By source" block above the Gathering table was dropped -- sortable/filterable headers cover lookup and the Graphs tab covers trends, so the cramped prose was redundant. The Gathering data tab is now Date range + search + table. Location: `GUI/GatheringLogSubTab.lua`.

- **Gathering Log: quantity shown as its own sortable column** -- Item pickups show the count in a dedicated **Qty** column (between Type and Item) rather than a "Nx" prefix, so it sorts independently. Location: `GUI/GatheringLogSubTab.lua`.

- **Removed the Mail/Trade log load-verification diagnostic globals** -- Dropped `_G.TOGTOOLS_MAILLOG_VERSION` and `_G.TOGTOOLS_TRADELOG_VERSION` (and their comment blocks). These existed only during the v0.6.4 development cycle so a `/run print(TOGTOOLS_MAILLOG_VERSION)` / `print(TOGTOOLS_TRADELOG_VERSION)` could confirm a `/reload` had pulled in the latest source; the MailLog one was marked "remove before release". No runtime or user-facing behaviour change -- they only assigned a string to a global. Location: `Modules/MailLog/MailLog.lua`, `Modules/TradeLog/TradeLog.lua`.

---

## [v0.6.4] (2026-06-04) - Mail + Trade logs: Retail capture fixes

### Bug Fixes

- **Sent mail logged with no money or attachments on Retail** -- The Mail log's send path re-read attachments (`GetSendMailItemLink`) and money (`GetSendMailMoney`) at *commit* time, inside the send-confirmation handler. On Retail the send-completion event is `MAIL_SEND_SUCCESS`, which runs Blizzard's `SendMailFrame_Reset()` (clearing the compose money/attachment slots), and by then the mail has already left the client -- so both reads returned `0`/`nil`. Only the recipient + subject survived (captured earlier in the `SendMail` hook), which is why a row appeared but money/items were empty. Fix: snapshot money + staged items **inside the `SendMail` hook** -- the one moment they're guaranteed present on every flavour, since nothing can be added after `SendMail` is invoked -- and commit those captured values rather than re-reading. Commit now fires from a shared `commitSend()` driven by whichever send-confirmation event arrives first: `MAIL_SEND_SUCCESS` (Retail, registered only when `addon.gameVersion.isRetail`) or `UI_INFO_MESSAGE`/`ERR_MAIL_SENT` (Classic); the second event is a no-op via the existing `_pendingSend = nil` dedupe. Removed the `MAIL_SEND_INFO_UPDATE` rebuild, which fired during the post-send compose reset on empty slots and would clobber the captured items. Classic keeps its working `ERR_MAIL_SENT` path and also gains the more-robust send-time capture (no regression). Location: `Modules/MailLog/MailLog.lua`.

- **Trades never logged on Retail (incl. M+ / dungeon loot redistribution)** -- The Trade log committed only on `UI_INFO_MESSAGE` with `ERR_TRADE_COMPLETE`. Retail's `TradeFrame` no longer registers `UI_INFO_MESSAGE` and signals trade completion purely through `TRADE_CLOSED` (which fires for both completion *and* cancel), so no trade -- items, money, or enchant -- was ever recorded on Retail; the dungeon/M+ context the reports came from is simply where players trade most. Fix: track whether **both sides have accepted** from each `TRADE_ACCEPT_UPDATE(playerAccepted, targetAccepted)`, and on `TRADE_CLOSED` treat "both accepted at close time" as a completed trade and commit. The both-accepted flag is driven purely by the accept-update args (adding/removing an item resets both accept states, so it self-clears on cancel), and is never cleared on item/money changes -- a successful trade may fire no further events between the both-accepted update and `TRADE_CLOSED`. Commit logic extracted into a shared `commitTrade()`; the Classic `ERR_TRADE_COMPLETE` path is retained and deduped against `TRADE_CLOSED` via `_staging = nil` (whichever fires first logs, the other is a no-op). The partner name is now captured via `UnitName("npc")` during the slot snapshot (TRADE_SHOW + each accept update) and read from staging at commit time -- by `TRADE_CLOSED`, the Retail commit point, the `npc` unit can already be gone, which would otherwise log every Retail trade's partner as `?`. Location: `Modules/TradeLog/TradeLog.lua`.

### Developer Tooling

- **Dev-sync watcher failed to auto-start and could spawn duplicate instances** -- Two faults kept `wow-version-replication.ps1` from mirroring edits into the other installed WoW clients. (1) Its single-instance guard created a `Global\` named mutex, which requires `SeCreateGlobalPrivilege` (absent for a standard, non-elevated user) -- `New-Object` threw, the guard's `catch` failed *open*, and duplicate watchers piled up. Changed the mutex scope to `Local\` (per-session; no special privilege), so the guard actually dedupes; the watcher and its relaunchers always share one interactive session. Location: `wow-version-replication.ps1`. (2) Companion fix outside the repo, in the global `~/.claude/settings.json` SessionStart hook that auto-launches the watcher: it located the script with `$PWD` -- not guaranteed to be the repo root, so nothing launched under the VS Code extension -- and used `Start-Process ... -ArgumentList '-File',$s` (array form), which left the space-containing script path unquoted so `powershell` saw `-File c:\Program` and exited. Now resolves the directory via `${CLAUDE_PROJECT_DIR}` -> hook stdin `cwd` -> `$PWD`, and launches with a single quoted argument string. Net effect: the watcher reliably starts on session open and back-fills every client.

---

## [v0.6.3] (2026-05-31) - Gathering Log, Log Graphs, Item DB load fix

### New Features

- **Gathering Log -- automatic mining / herbalism / skinning / fishing log** -- New `gathering` log sub-category (`Modules/GatheringLog/GatheringLog.lua` + `GUI/GatheringLogSubTab.lua`) that records every node you gather: item, quantity, profession, zone, and time. Capture is **spell-cast-gated**: `UNIT_SPELLCAST_SUCCEEDED` (unit `player`) on a gather spell arms a short window, and the `CHAT_MSG_LOOT` that follows is attributed to it -- so mob drops, vendor buys, and mail are never logged because nothing gathered them. The gather cast is identified by resolving the Classic rank-1 anchor spell IDs (Mining 2575, Herb Gathering 2366, Skinning 8613, Fishing 7620) to localized names, **plus a profession-keyword word-boundary match** so Retail's per-expansion casts also resolve (verified: Midnight mining is spell `471013` "Midnight Mining" -- ends in "Mining"; "Examining" and similar lookalikes are rejected). Fishing gets a longer pre-loot window (30s vs 3s) for the bobber bite; multi-drop nodes group under one `action` id (a persisted monotonic counter) so per-node yield stats are exact. Loot is parsed locale-safely -- itemID/name from the embedded `|Hitem:` link, stack count from `LOOT_ITEM_SELF_MULTIPLE` turned into a pattern. The sub-tab has Character / Profession / Zone / Date-range filters, a stats panel (total items, nodes worked + per-node yield, per-profession, top items, by-zone, items/hour), and a sortable table with an absolute `MM/DD/YY HH:MM:SS` time column. Each item's `expansionID` (15th `GetItemInfo` return) is captured for the graph's expansion filter. Settings toggle + "Clear Gathering Log" wired in `GUI/Settings.lua`; schema in `TOGTools.lua`; new globals `C_Spell` / `GetSubZoneText` / `LOOT_ITEM_SELF_MULTIPLE` in `.luarc.json`. Added to all six TOCs.

- **Log Graphs -- LibGraph-2.0 line charts for log data** -- New **Graphs** sub-tab, forced first in the Logs strip via a new `order` field on log categories (`GUI/LogsTab.lua` now sorts by `order` then label; capture logs default to 100 and stay alphabetical). A reusable `addon.LogGraph` component (`GUI/LogGraph.lua`), generalized from FastGuildInvite's Statistics tab, takes a per-log "graph spec" (named series with colour + `match`/`value` functions, an entry provider, and an optional sub-filter), buckets the log's entries by time over a selectable period (24h hourly / 7-14-30d daily / all-time), and draws one LibGraph line per visible series with per-series checkboxes and a totals line. It uses a single persistent raw-frame set reparented onto the AceGUI host per render (avoids orphaning frames on pooled containers, the RowList approach); period / series / sub-filter changes repopulate in place. `Modules/LogGraphs/LogGraphs.lua` registers the category and owns the specs: **Gathering** = one line per gathered item with an **Expansion** sub-filter (resolved from each item's `expansionID` -> `EXPANSION_NAME<id>`, top-12 items charted, defaults to the newest expansion present); **Mail** = Received vs Sent; **Trade** = items received vs given. Per-log prefs (period / series visibility / expansion) persist in `db.global.logGraphs`. **Hovering the chart** pops a GameTooltip for the time bucket under the cursor, listing each visible series' value for that bucket -- a transparent mouse overlay maps the cursor's X to a bucket by inverting LibGraph's linear plot transform (`pixel = Width·(x−XMin)/(XMax−XMin)`), so it needed no LibGraph change (FGI's shared copy is untouched). The X axis renders our own time anchors (oldest -> "now", e.g. "24h ago / 12h ago / now", or dates on the multi-day views) instead of LibGraph's bare 1..N numbers, which read backwards on a past->now axis and collided with the axis label (`XLabelsEnabled` is left off). Bundled **LibGraph-2.0** into `libs/` (self-locates its textures via `debugstack`) and registered it in all six TOCs; new `UIDropDownMenu_SetSelectedValue` global in `.luarc.json`.

### Bug Fixes

- **Item DB SavedVariables failed to load once large (`constant table overflow`)** -- The Item DB stored each item as its own `classes[classID][idStr] = packed` entry. At ~240k items that put ~480k string constants in the saved file's single Lua chunk, past Lua's ~262,144-constant-per-function loader limit, so the **entire `TOGTools_DB` failed to compile on load** -- taking every log and setting with it (and on next logout WoW would overwrite the file with empty defaults). Storage is now one concatenated string **blob per item class** (per-item records joined by `\30`, fields by `\31`) -- a single string constant per class (~13 total), which loads at any size. The engine works against an in-memory index parsed from the blobs on first use and re-serializes on pause / complete / `PLAYER_LOGOUT`; a legacy per-item `classes` table that still loaded is migrated into the blob form automatically. Location: `Modules/ItemDB/ItemDB.lua` (`ensureIndex`, `flushBlobs`, `_ingest`, `Search` / `GetClasses` / `GetSubClasses`, `Purge`, `PLAYER_LOGOUT`), `TOGTools.lua` (itemDB schema). Note: a DB already saved in the old format must be rebuilt (the old file can't be read back).

- **Gratz guild level-ups never fired (all flavours)** -- `refreshGuildTracker` read the roster with `local name, _, _, level = GetGuildRosterInfo and GetGuildRosterInfo(i) or nil, nil, nil, 0`. Lua operator precedence parses the right-hand side as four expressions; wrapping `GetGuildRosterInfo(i)` in `and`/`or` truncated its multi-return to the first value (the name), so `level` was assigned the literal `0` every iteration. The `level > 0` guard then rejected every member, so `_guildTracker` never populated and `fireGuildLevelUp` never ran. Fixed with a plain destructure (`local name, _, _, level = GetGuildRosterInfo(i)` -- level is the documented 4th return) and the existence guard moved to `if not (GetNumGuildMembers and GetGuildRosterInfo)`. The party path (`UNIT_LEVEL`) was unaffected. Location: `Modules/Gratz/Gratz.lua`.

---

## [v0.6.2] (2026-05-30) - Item DB: item level + stats capture

### Improvements

- **Item DB walk now records item level + full stats** -- `_ingest` packs each item as `name\31quality\31subClassID\31equipLoc\31itemLevel\31stats` (was four fields, name/quality/sub/equip). The stat blob is the client's own `GetItemStats` serialized as `KEY=val,...` over its `ITEM_MOD_*` / `RESISTANCE*` keys (primary stats, spell power, mp5, resistances, weapon DPS, ...) -- the only fully-aggregated source, since the raw DB2 export omits effect-granted stats. New `EncodeStats(link)` helper; **Rescan** upgrades older four-field entries to the six-field form in place (the bucket-merge dedups on field count, so a partial DB converges without a wipe). This is the data the standalone **LibItemDB** library ships (stats are locale-independent -- captured once per game version; names come per-locale). Location: `Modules/ItemDB/ItemDB.lua` (`EncodeStats`, `_ingest`), `GUI/ItemDBTab.lua`, `TOGTools.lua` (itemDB schema).

---

## [v0.6.1] (2026-05-30) - Classic whisper-menu crash fix

### Bug Fixes

- **Right-clicking a player name in the Whisper Log and choosing Whisper errored on Classic** -- The shared RowList player-link right-click handler passed the RowList *row* frame as the 5th argument (the chat frame) to `FriendsFrame_ShowDropdown`. On Classic the UnitPopup **Whisper** action routes through `ChatFrame_SendTell(name, chatFrame)` -> `ChatFrame_SendTellWithMessage`, which reads `chatFrame.editBox`; our row isn't a real ChatFrame, so it has no `editBox` and threw `attempt to index local 'editBox' (a nil value)` at `Blizzard_ChatFrameBase/Classic/ChatFrame.lua:1688`. Retail tolerates the row frame, so that path was left exactly as-is; on Classic the call now passes `DEFAULT_CHAT_FRAME`, so the Whisper edit box resolves and the menu's Whisper/Invite/Inspect/Ignore/Report actions all work. Only the Whisper Log emits `|Hplayer:|h` links today, so that's the only affected surface. Location: `GUI/RowList.lua`.

---

## [v0.6.0] (2026-05-29) - Item DB builder (developer tool)

### New Features

- **Item DB module -- runtime-built, searchable item catalog (dev tool)** -- First piece of a planned shared item database for the TOG suite. WoW exposes no item-name search API and ships no searchable item table, so the only way to resolve a name->link offline is to ask the server about every item ID once and persist the answers -- the same technique the "Get Link" / Ludwig addons use. New `ItemDB` module (`Modules/ItemDB/ItemDB.lua`) walks the item-ID space (`db.global.itemDB.cursor` -> `topID`, default 240000) on a throttled `C_Timer.NewTicker`: each ID is gated by **`GetItemInfoInstant`** -- a **local**, synchronous call that returns the itemID plus `classID` / `subClassID` / `equipLoc` for real items and `nil` for non-existent IDs (no server traffic) -- then `GetItemInfo` supplies `name` / `quality`. Uncached items return nil from `GetItemInfo` (which issues the async request); `GET_ITEM_INFO_RECEIVED` re-ingests on `success == true`, and on `success == false` (a phantom item the server has no data for -- removed / test / cross-version stubs the client carries static data for, so `GetItemInfoInstant` reports them real) drops the ID from `pending`. Some phantoms are worse: after the server answers "no data" once, the client **negative-caches** it, so a later `GetItemInfo` returns nil and fires **no event at all** -- they can't be cleared reactively. So the gap-fill phase is patient (`GAPFILL_MAX_STALL = 300` ticks ~ 15s of zero progress; any real-but-slow item resolving resets the window) and, when that window elapses with nothing resolving, **drops the remaining unresolvable IDs** outright -- a nameless item can never live in a name-search DB -- so a build / Fill gaps converges to **0 gaps** instead of parking on a few hundred permanently-stuck IDs. (The existence gate was originally `C_Item.DoesItemExistByID`, but on Classic Era that returns true for essentially every ID in range -- it flooded `pending` with ~216k non-existent IDs and fired a request for each. `GetItemInfoInstant` is the reliable gate; `_ingest` also prunes any non-real ID it encounters out of `pending`, so a legacy over-stuffed gap ledger self-cleans on the next Fill gaps / Rescan.) Throttle constants: `WALK_PER_TICK = 500` IDs/tick but `REQ_PER_TICK = 25` **new server requests**/tick at `TICK_INTERVAL = 0.05` -- the request budget (not the walk count) is the disconnect-safety governor, deliberately conservative (~500 req/s peak). All API access is feature-detected (`C_Item.*` with bare-global fallback) so the same file runs on every flavour; if `DoesItemExistByID` is absent the builder reports unsupported and no-ops. Storage is **bucketed by `classID`** so type/subtype filtering iterates one class instead of the whole catalog: `classes[classID][idStr] = "name\31quality\31subClassID\31equipLoc"` (US-separator pack, `\31` never appears in a name). **Stop/resume across sessions** is a two-part state, both persisted in SV: a forward `cursor` (sequential walk progress) AND a `pending` set (`[idStr]=true`) of IDs requested-but-not-yet-stored. The walk runs in two phases -- phase 1 walks `cursor`->`topID`; phase 2 (gap-fill) drains `pending` by re-poking a `REQ_PER_TICK` batch each tick until it empties or stalls (`GAPFILL_MAX_STALL = 60` ticks of no shrink -> finalise). The pending set is what makes a pause/logout mid-walk lossless: items left in-flight when you stop (the cursor already passed them) are re-requested on resume instead of skipped. `GetProgress` exposes a `phase` (idle/walking/gapfill/complete), `cursor`, and `pending` so the tab shows the exact resume point. `Start()` resumes from `cursor` (no-op if already complete with no pending); `Pause()` just stops the ticker (state survives in SV); `locale`/`build` are stamped so the tab can flag a rebuild after a language or client-version change. Read API: `:GetProgress()`, `:GetClasses()`, `:GetSubClasses(classID)`, `:Search({query, classID, subClassID, quality, max})` (returns reconstructed interactive links), plus `:Start()`/`:Pause()`/`:Purge()` (`:ClearAll` alias). Location: `Modules/ItemDB/ItemDB.lua`.

- **Item DB tab + Developer Tools gate** -- `GUI/ItemDBTab.lua` provides Build / Resume / Fill gaps / Rescan / Pause / Rebuild / Purge controls (Rebuild + Purge two-step confirm via `StaticPopupDialogs`). The DB already tracks both halves of "have vs don't-have": `classes` is the have-set (and `_ingest`'s existing-bucket short-circuit skips them with no server request), while `pending` is the persisted gap ledger -- IDs confirmed to exist via `DoesItemExistByID` whose name reply never arrived. **Fill gaps** (`:FillGaps()`) is the cheap "fetch only the ones we don't have" pass: it jumps the cursor past `topID` straight into the gap-fill phase so it re-requests just the `pending` IDs, skipping the ~240k forward re-walk entirely (no-op when there are no gaps). **Rescan** (`:Rescan()`) is the thorough version -- resets `cursor`/`complete` but preserves `classes`/`count`/`pending` and re-walks from id 1, catching existing items that were never walked at all. The progress line shows the have-count and a `N gaps` figure so the split is always visible. a live progress line (item count, %, id cursor, locale-change warning) driven by an `ItemDB.onProgress` hook the tab sets in `:Draw` and clears on the body `OnRelease`, and a search row: Type dropdown (item classes present in the DB, via `GetItemClassInfo`), Subtype dropdown (repopulated per class via `GetItemSubClassInfo`), and a name search box (2+ chars, or any length when a Type filter is active). Results render in a `RowList` (ID · Item · Type · Subtype) where the Item column is the stretchy auto-width column carrying the reconstructed `|Hitem:ID|h` link -- hover for the tooltip, shift-click into chat, for free via RowList's existing hyperlink handling. The controls (progress line, button row, filter row, result-info) are collected into a single auto-height `List` `SimpleGroup`, with the table body as its one full-height sibling -- AddonLoad's two-child header+fill shape. Earlier attempts (loose relative-width children, then per-row `SimpleGroup`s) dropped into the outer Flow let the full-height body draw on top of the row directly above it; collapsing everything into one solidly-measured List group leaves only that group above the body. Every button and filter widget carries a hover tooltip via `addon.UI.AttachTooltip` (which handles the Dropdown/EditBox label-area hover too). The module is flagged `devOnly`: `MainWindow.BuildTabDefs` now skips `devOnly` modules unless `db.global.devTools` is set, and the Settings auto-generated Modules list skips them (gated solely by the new General > Developer Tools toggle, which calls `MainWindow:Rebuild()` so the tab appears/disappears immediately). Hidden slash command `/togt itemdb` (`/togt db`) opens it when dev tools are on; intentionally absent from the help list. Schema in `TOGTools.lua`, gate in `GUI/MainWindow.lua`, toggle + Modules-skip in `GUI/Settings.lua`, slash in `SlashCommands.lua`, `.luarc.json` gained `GetItemClassInfo` / `GetItemSubClassInfo` / `strsplit`, and the module pair was added to all six `*.toc`. Curseforge description intentionally NOT updated -- this is a hidden developer tool, not a player-facing feature.

---

## [v0.5.4] (2026-05-28) - Whisper Log: right-click partner menu

### New Features

- **Whisper Log: left/right-click partner names** -- User-requested follow-up to v0.5.3. The Other column's partner name (in-game whispers) is now a `|Hplayer:NAME|h[NAME]|h` hyperlink -- left-click opens a whisper edit box, right-click pops the standard chat-name context menu (whisper / invite / inspect / ignore / report) -- exactly the same dropdown you get from clicking a name in the chat frame. Implementation routes through `SetItemRef(link, text, button, frame)` because it has version-specific dispatch baked in: on Mainline the `LinkUtil.RegisterLinkHandler(LinkTypes.Player, HandlePlayerLink)` path runs `FriendsFrame_ShowDropdown`; on Classic Era the same `SetItemRef` entry resolves through the older inline player-link branch -- both terminate at the same UI. Confirmed against the Blizzard API docs mirror (`Blizzard_UIPanels_Game/Mainline/ItemRefHandlers.lua:1-51`). Battle.net partners (`isBN == true`) skip the player-link wrapping in v1 -- the canonical link type is `|HBNplayer:|h` with bnetIDAccount in the link options, and cross-version BN routing is fragile enough to defer; BN rows still render correctly as plain text with the `[BN]` tag. Unknown senders (`other == "?"`) also skip the wrap. Location: `GUI/WhisperLogSubTab.lua`, `Modules/WhisperLog/WhisperLog.lua` (help blurb).

### Improvements

- **`RowList`: player links dispatch left vs right click separately; main window strata dropped so menus float above** -- Touched the global RowList hyperlink handler so any consumer that emits `|Hplayer:|h` links gets the canonical chat-frame behavior automatically. `OnHyperlinkClick` captures `(self, link, text, button)` instead of just `link` and splits on button: **right-click** extracts the name via `link:match("player:([^:]+)")` and calls `FriendsFrame_ShowDropdown(name, 1, nil, nil, row)` DIRECTLY rather than routing through `SetItemRef`. This is the pattern Blizzard's own `Blizzard_Communities/CommunitiesInvitationFrame.lua:103-109` uses, because `SetItemRef`'s right-click dispatch doesn't reliably pop the UnitPopup menu for non-chat-frame contexts on Retail (initial attempt did this and the menu never appeared in testing). `FriendsFrame_ShowDropdown` lands at `UnitPopup_OpenMenu("FRIEND", contextData)` which produces the same dropdown as clicking a name in chat (Whisper / Invite / Inspect / Ignore / Report Player / Copy Character Name / etc.). **Left-click** stays on `SetItemRef(link, text, button, row)` since its left-click branch does the right thing (opens a whisper edit box on Retail and Classic alike). **Strata root cause + fix**: AceGUI's Frame widget hard-codes itself to `FULLSCREEN_DIALOG` strata with frame level 100 (`Ace3/AceGUI-3.0/widgets/AceGUIContainer-Frame.lua:81-82`); Blizzard's UnitPopup context menu *also* defaults to `FULLSCREEN_DIALOG` (`Blizzard_Menu/Menu.lua:2084` -- only escalating to `TOOLTIP` when its ownerRegion is on TOOLTIP). Same strata, AceGUI's frame level 100 wins z-order, so the menu opens BEHIND the window. Two attempts to bump the menu's strata up via `Menu.GetManager():GetOpenMenu():SetFrameStrata("TOOLTIP")` didn't take -- likely either timing or the proxy's `__index` forwarding doesn't propagate `SetFrameStrata` to the rendered frame. A third version added an ugly window-strata-lowering fallback with `C_Timer` poll-and-restore which worked but was tangled. Final solution is a one-liner in `GUI/MainWindow.lua` right after `AceGUI:Create("Frame")`: `f.frame:SetFrameStrata("DIALOG")`. The window drops one strata below the menu, so the menu's `FULLSCREEN_DIALOG` naturally floats above without any per-click gymnastics. No noticeable side effects -- the main window is a content tool, not a modal that needs to sit above other UI; DIALOG is the strata most addon windows of this kind use. All other link types fall through to the existing `HandleModifiedItemClick` / `ChatEdit_InsertLink` chain unchanged. `OnHyperlinkEnter` early-returns for player links -- no `GameTooltip:SetHyperlink` representation, and the chat frame itself doesn't pop a tooltip on hover for them. `.luarc.json` gained `SetItemRef` + `FriendsFrame_ShowDropdown` to the known-globals list. Location: `GUI/RowList.lua`, `GUI/MainWindow.lua`, `.luarc.json`.

---

## [v0.5.3] (2026-05-27) - Whisper Log

### New Features

- **Whisper Log sub-tab** -- New `whispers` sub-category under the Logs nexus. Captures every whisper sent and received -- both in-game whispers (`CHAT_MSG_WHISPER` / `CHAT_MSG_WHISPER_INFORM`) and Battle.net whispers (`CHAT_MSG_BN_WHISPER` / `CHAT_MSG_BN_WHISPER_INFORM`) -- into a flat list under `db.global.whisperLog.entries`. Entry shape: `{ ts, dir = "in"|"out", player, other, text, isGM, isBN, guid, bnSenderID, lineID, zone }`. Event payload positions (arg1 text, arg2 playerName, arg6 specialFlags, arg11 lineID, arg12 guid, arg13 bnSenderID) were confirmed against the Blizzard API docs mirror at `Blizzard_APIDocumentationGenerated/ChatInfoDocumentation.lua` and are stable across every supported version (Classic Era through Retail). `isGM` uses the `specialFlags == "GM"` test that Blizzard's own `Blizzard_GMChatUI.lua` uses for the same purpose. **Dedup**: a 5-second recent-`lineID` set drops repeats when chat events re-fire through filter chains, without losing real back-to-back whispers (distinct lineIDs). **Filters**: Character (per-alt), Direction (Received / Sent / Both), Type (In-game / Battle.net / Both), GM (All / Hide GM / GM only), Date range, plus a Partner substring search and a Text body substring search -- both case-insensitive, both apply on Enter or the green check button. **Table**: Time · Character · Other · Message, with auto-fit on the fixed columns and Message as the rightmost stretchy column. The Other column folds direction (`<-` / `->`) + `[BN]` / `[GM]` tags into one cell to keep the table compact. **Wiring**: per-category sub-toggle in `Settings > Modules > Log categories > Whisper Log`; clear button at `Settings > Clear Data > Clear Whisper Log` (two-step confirm via AceConfig's native popup); slash command aliases `/togt wl` and `/togt logs whispers`; deep-link via `Tab.pendingSubTab`. Location: `Modules/WhisperLog/WhisperLog.lua`, `GUI/WhisperLogSubTab.lua`, plus surface-level wiring in `TOGTools.lua` (DB defaults), `GUI/Settings.lua` (sub-toggle + Clear button), `SlashCommands.lua` (slash aliases), `GUI/LogsTab.lua` (outer-tab help text), and every `*.toc`.

---

## [v0.5.2] (2026-05-25) - AceGUI widget-pool tooltip leak

### New Features

- **Guild Bank Log -- Bank Tab filter** -- User-requested. New "Tab" dropdown in the Guild Bank Log filter row, between Type and Date range. Options: |cffffd700All tabs|r (default), |cffffd700Tab 1|r through |cffffd700Tab N|r (where N = `MAX_GUILDBANK_TABS - 1`, normally 7), and |cffffd700Money log|r. Numeric filters match entries whose `tab1` OR `tab2` equals the selected tab -- Move transactions span two tabs and surface under either tab's filter. The Money log filter restricts to `kind == "money"` entries (gold deposit/withdraw). Engine signature gained a fifth optional parameter: `GuildBankLog:GetEntries(guildKey, filterType, fromTs, toTs, filterTab)`. Filter state is session-scoped (`GuildBankLog._filterTab`) -- picks reset on `/reload`, same as the existing Guild / Type / Date filters. Tab column header tooltip updated to point users at the dropdown; help blurb mentions the new filter. Location: `GUI/GuildBankLogSubTab.lua`, `Modules/GuildBankLog/GuildBankLog.lua`.

### Bug Fixes

- **Mail Log capture under TradeSkillMaster + consecutive-mail merge collapse** -- Two-part fix landing in one architecture. **(1) Coexistence with TSM.** User reported on TBC: taking mail produced a `MailFrame.lua:357 InboxFrameItem_OnEnter` taint trace (`SetTooltipMoney -> MoneyFrame_Update` on a TSM-supplied "secret number value") and the Mail Log captured nothing even though mail was successfully removed from the inbox. TSM disabled -> Mail Log captured every mail; TSM enabled -> nothing. Root cause (from reading TSM's `Core/Service/Accounting/Mail.lua`): TSM does a full global function replacement on `TakeInboxItem` / `AutoLootMailItem` / `TakeInboxMoney` at its `Mail.OnInitialize`, then runs up to five deferred 0.2-second retry passes through its own `ScanCollectedMail` before finally calling back to the original via `private.hooks[oFunc]`. By the time our `hooksecurefunc` callback fires through that chain, the Blizzard client-side inbox cache is already cleared and `GetInboxItemLink` / `GetInboxHeaderInfo` return nil. **(2) Consecutive-mail merge collapse.** Independent bug exposed by the TSM work: each Take shifts the inbox so all consecutive takes arrive at `MailIndex=1`, and the v0.4.0 merge-window guard only checked `cached.other == sender`. Five "Auction expired: X" / "Auction expired: Y" mails all have `sender="Auction House"` so the guard accepted the merge and collapsed five distinct mails into one row. **Fix -- two paths, deterministic dedupe.** Hook path stays the primary capture for the 99% case (no TSM). New snapshot-diff fallback path listens to `MAIL_SHOW` / `MAIL_INBOX_UPDATE` / `MAIL_CLOSED` -- plain events with no global-replacement vulnerability -- snapshots inbox state at MAIL_SHOW, and on each subsequent update compares `sender|subject` bucket counts to the prior snapshot; when a bucket loses an entry the mail vanished and gets logged with its captured pre-take metadata. Snapshot events are gated on TSM detection: probes four signals (`IsAddOnLoaded("TradeSkillMaster")`, `C_AddOns.IsAddOnLoaded`, `_G.TradeSkillMaster`, and our saved-reference vs `_G.<mail-api>` to catch the globals being swapped after our hook ran) lazily on first MAIL_SHOW (PLAYER_LOGIN was too early -- TSM's init hadn't finished on Classic Era). Merge predicate now requires strict equality on `sender` AND `subject` AND `firstAttachmentLink` AND `daysLeft` (fractional-days mail expiration, 6-decimal float -- unique per arrival moment) AND a 5-second window -- covers same-mail multi-take (all four match) while distinguishing all distinct-mail patterns including same-sender / same-subject / same-day batches. `appendReceive` bails early if `sender` is empty or `subject` is nil (TSM-tainted post-take cache reads) so degraded entries never enter SV. Dedupe between paths uses a reference-counted `_takenSenders[sender|subject]` table: `appendReceive` increments only when it creates a brand-new entry (multi-item-mail merges don't double-stamp); `appendReceiveFromSnapshot` decrements one stamp per vanishing -- present = take-hook captured cleanly, skip the snapshot log; absent = take-hook couldn't read, snapshot logs it. Counter (not boolean) so batch-identical mails decrement in lockstep. Keyed by `sender|subject` not `MailIndex` because the snapshot stores the index a mail was at when snapshotted while the take-hook fires at the LIVE post-shift index -- those drift apart and broke earlier mailIndex-based dedupe. `AutoLootMailItem` now also reads `money` from `GetInboxHeaderInfo` because that API takes both items AND money in one server call without dispatching through `TakeInboxItem` / `TakeInboxMoney`. `_takenSenders` wiped on `MAIL_CLOSED`. Take-hook + snapshot traces gated behind `addon.DB.global.debug` for future TSM-style breakage diagnosis. Location: `Modules/MailLog/MailLog.lua`.

- **Tooltip text rendered twice in Settings > Modules** -- User-reported on TBC: hovering a Settings panel toggle (e.g. Login Digest) showed the option's `desc` body text rendered twice at slightly offset positions, giving a doubled / blurry appearance. Root cause: `addon.UI.AttachTooltip` uses `HookScript("OnEnter", ...)` on the passed frame, and **`HookScript` cannot be unhooked** -- once attached, the handler persists for the entire session. Several consumers (Gratz, Guild Log Clear button, Guild Bank Log Clear / dropdowns / Pick-range button) passed `widget.frame` from AceGUI widgets to this function. AceGUI pools widget frames across the entire UI: when `AceConfigDialog` later acquired the same pooled CheckBox/Dropdown/Button frame for the Settings panel's option toggles, our residual HookScript fired alongside AceConfig's own tooltip handler. Both rendered tooltip content for the same hover, producing the doubled body text the user saw. Fix: migrate every call site from `addon.UI.AttachTooltip(widget.frame, ...)` to `addon.GUI.AttachTooltip(widget, ...)` -- the latter (defined in `Compat.lua`) uses `widget:SetCallback("OnEnter", ...)` which AceGUI clears on widget Release, so the handler doesn't survive into the next pool acquirer. `addon.UI.AttachTooltip` kept available for raw `CreateFrame` frames (which aren't pooled) but the docstring now loudly warns against using it on AceGUI widget frames. Files touched: `GUI/GratzTab.lua`, `GUI/GuildBankLogSubTab.lua`, `GUI/UI.lua` (docstring), `Compat.lua` (no change -- already had the safe version).

### Improvements

- **Guild Bank Log 5-minute background ticker removed** -- Was gated on `GuildBankFrame:IsShown()` so it only fired while the user was parked at the bank, and even then was redundant with `GUILDBANKLOG_UPDATE` events that already fire when transactions land while the bank is open. Blizzard's own `Blizzard_GuildBankUI.lua` has no equivalent periodic re-query -- they rely entirely on the event stream. Confirmed factually against the Blizzard UI source mirror rather than guessing. Removing the ticker simplifies the engine and removes ~7 RPCs every 5 minutes for parked users; the bank-open event handler still triggers `requestAllTabs` on every visit with a 2-second deferred ingest for the TBC / Anniversary / Era flavour where `GUILDBANKLOG_UPDATE` doesn't fire reliably. Location: `Modules/GuildBankLog/GuildBankLog.lua`.

- **Every tooltip site routes through the global anchor helpers** -- Consolidated raw `GameTooltip:SetOwner(...)` fallbacks in `GUI/RowList.lua` (column header tooltips, row hyperlink tooltips) and removed Compat.lua's duplicate-and-stale `addon.Tooltip.Owner` definition. Now every tooltip in the addon goes through either `addon.Tooltip.Owner(frame, [budget])` (attachment-time `SetOwner`) or `addon.Tooltip.AnchorTo(tooltipFrame, ownerFrame, [budget])` (post-population `SetPoint` re-anchor), both of which share an internal `_computePlacement` so the flip threshold (250 px below the owner) is identical across the codebase. The earlier RowList fallbacks defaulted to fixed `ANCHOR_TOPLEFT` / `ANCHOR_RIGHT` anchors with no flip; the old Compat.lua definition used a simpler top-vs-bottom-half heuristic that diverged from UI.lua's budget-based version once UI.lua landed. Both anomalies are gone.

- **Clear Data moved from the sub-tabs into a new Settings > Clear Data tab** -- Each log sub-tab (Mail / Trade / Guild / Guild Bank) used to have its own "Clear data" button in the filter row, each with its own copy of the two-step "Really? (5s)" countdown helper (~30 lines of identical Lua duplicated four times). Consolidated into a single `clearData` group in `GUI/Settings.lua`'s OPTIONS, exposed as a third tab in the Settings panel alongside General and Modules. Each clear button is an AceConfig `type = "execute"` with `confirm = true` + a `confirmText` line -- that surfaces Blizzard's native StaticPopup confirm gate, which is consistent with how every other destructive Bliz UI action works and is more discoverable than an inline "Really?" countdown. Removed all four `clearBtn` blocks plus the four duplicate `attachClearConfirm` helpers from `GUI/MailLogSubTab.lua`, `GUI/TradeLogSubTab.lua`, `GUI/GuildLogSubTab.lua`, `GUI/GuildBankLogSubTab.lua`. Engine `:ClearAll()` methods on each module unchanged; the Settings buttons just call them via `addon.logCategories[<key>]:ClearAll()`. Location: `GUI/Settings.lua`, `GUI/MailLogSubTab.lua`, `GUI/TradeLogSubTab.lua`, `GUI/GuildLogSubTab.lua`, `GUI/GuildBankLogSubTab.lua`.

- **Global tooltip-placement helper: fixed inverted room-below check + narrow-owner label offset** -- User-confirmed the flip logic wasn't working. Two bugs in `_computePlacement` and `addon.Tooltip.AnchorTo`: (1) the room-below check used `GetScreenHeight() - frame:GetTop()`, which measures distance from the frame to the TOP of the screen -- not space BELOW the frame. WoW's coordinate origin is at the BOTTOM of the screen, so the correct check is `frame:GetBottom() > budget`. The old check flipped the anchor exactly opposite of intent (frames near the top of the screen got the "rise up" anchor; frames near the bottom got the "drop down" anchor). (2) For narrow owners (checkbox ~22 px, icon ~20 px) the tooltip's left edge was anchored flush with the owner's left edge -- landing UNDER the icon rather than next to the label text the user was hovering. New `_xOffsetForLabel` helper detects owners thinner than 40 px and adds `width + 8 px` of horizontal offset so the tooltip aligns with where the LABEL begins. Wider owners (header buttons, dropdowns) keep their existing left-aligned drop-down behaviour. Both fixes live in the global helper at `GUI/UI.lua`, so every caller benefits -- including the Settings>Modules tooltip re-anchor hook. Location: `GUI/UI.lua`.

- **Settings>Modules toggle row hit-rect extension reverted** -- Earlier in this version I tried to make the entire toggle row (checkbox + label) hover-sensitive by walking the panel's children recursively and calling `SetHitRectInsets(0, -500, -2, -2)` on every frame with an `OnEnter` script. User reported they could no longer select the Modules tab -- the General tab button's `OnEnter`-bearing frame got its hit rect extended 500 px rightward too, overlapping the Modules tab button's click target and stealing every click meant for it. Reverted the extension; relying on the global narrow-owner offset in `addon.Tooltip.AnchorTo` to place the tooltip next to the label even when only the checkbox itself triggers `OnEnter`. If we re-add row-hover later, the filter must exclude tab-strip buttons specifically -- `SetHitRectInsets` on horizontally-laid-out siblings will always steal clicks from the neighbour.

- **Tooltip re-anchor scoped back to the Settings panel only** -- Briefly in v0.5.2 the `hooksecurefunc(GameTooltip, "SetOwner", ...)` was promoted to global scope (lived in `GUI/UI.lua`, no gate) so every tooltip in the addon routed through `addon.Tooltip.AnchorTo`. User reported that broke Blizzard's native tooltips -- hovering a corpse / item / unit produced visibly wonky tooltip placement because the global hook was re-anchoring those too. Reverted: the SetOwner hook lives back in `GUI/Settings.lua` gated by a `_blizPanel:OnShow/OnHide` flag so it only fires while the TOG Tools settings panel is the active page. Settings>Modules tooltips still get the smart re-anchor (which was the original goal); native + other-addon tooltips are untouched again. Lesson logged: global hooksecurefunc on shared Blizzard frames affects every consumer -- always gate on a frame-specific flag.

- **Scoped tooltip re-anchor for the Settings>Modules panel + global `addon.Tooltip.AnchorTo` helper** -- User reported that AceConfigDialog's tooltips for our addon's Settings options were positioned "way out there" -- anchored hard-right of the option widget, often clipping near the screen edge on TBC at typical UI scales. AceConfigDialog uses `GameTooltip:SetOwner(widgetFrame, "ANCHOR_TOPRIGHT")` internally with no per-addon override. Two-part fix: (1) added a new global `addon.Tooltip.AnchorTo(tooltipFrame, ownerFrame, [budget])` helper in `GUI/UI.lua` -- uses `ClearAllPoints` + `SetPoint` so it can re-position a tooltip without clearing AceConfigDialog's already-populated content. Shares an internal `_computePlacement` function with `addon.Tooltip.Owner` so the flip threshold (250 px below the owner frame's top edge) is identical between attachment-time `SetOwner` calls and post-population `SetPoint` re-anchors. (2) `_blizPanel:HookScript("OnShow"/"OnHide")` in `GUI/Settings.lua` flips a `ourPanelShown` flag; `GameTooltip:HookScript("OnShow", ...)` delegates to `addon.Tooltip.AnchorTo` when the flag is set. Cross-addon impact is bounded: the hook is no-op except while the TOG Tools panel is the active Settings page. Location: `GUI/UI.lua`, `GUI/Settings.lua`.

- **Single canonical tooltip-attach helper** -- Previously two helpers existed for the same purpose: `addon.UI.AttachTooltip` (HookScript-based, in `GUI/UI.lua`) and `addon.GUI.AttachTooltip` (SetCallback-based, in `Compat.lua`). Different callers picked one or the other, with different leak characteristics. Consolidated into a single smart helper at `addon.UI.AttachTooltip` that auto-detects the target type: AceGUI widget (table with `:SetCallback`) -> uses `widget:SetCallback("OnEnter"/"OnLeave", ...)` (AceGUI clears callbacks on Release, so the handler is properly released when the widget returns to the pool); raw frame (no `:SetCallback`) -> uses `HookScript` (safe because raw `CreateFrame` frames aren't pool-shared, so the never-unhookable handler doesn't leak). For Dropdown / EditBox the label area is outside the interactive body -- both paths fall through to `addon.AceGUIFrameScripts` which saves the prior script and restores it on Release so the recycled widget's next owner gets a clean frame. `addon.GUI.AttachTooltip` is now an alias for `addon.UI.AttachTooltip` at the bottom of `GUI/UI.lua`, so call sites using either namespace get identical leak-safe behaviour. The standalone Compat.lua definition was removed to keep a single source of truth. Location: `GUI/UI.lua`, `Compat.lua` (removed duplicate).

---

## [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.

  *Correction, 2026-08-16 (audit finding tog-R): "Used by every tab" was never true of three of
  these. `addon.Tooltip.AnchorFrame`, `addon.GUI.ApplyMinResize` and `addon.GUI.DetachPool` have no
  call site anywhere in this repo. They may be reachable by a sibling TOG addon through the
  `TOGTools.addon` global, so they are not being removed on that basis - but the claim above is
  corrected here rather than left standing in the shipped record.*

### 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`.

---
