# Changelog

## [v0.8.3] (2026-08-06) - Harness catch-up, and four false-greens it exposed

The `Tests/wowapi` submodule was pinned at `4161af8` (2026-07-19), **83 commits behind**. Upstream
had re-initialised its history, so the local `main` and `origin/main` had no common ancestor — the
six "local" commits were just the old lineage back to the initial commit, nothing authored here, so
the pointer was reset to `origin/main` (`e38b8b8`). The suite was green before and after the move.

**Green before and after is exactly the problem.** The pass count did not change, because the specs
were shadowing the globals whose behaviour changed. Removing the shadows is what found the
following — every one a path that had never executed offline.

### Bug Fixes

- **`Gratz`'s guild level-up trigger never armed on five of six flavours — `GuildRoster` was
  declared as a dependency in the Vanilla TOC only.** `Modules/Gratz/Gratz.lua:66` documents it as a
  hard dependency (`## Dependencies: GuildRoster`) and resolves it with
  `LibStub("LibGuildRoster-1.0", true)`; the silent flag keeps a missing library from erroring at
  file scope, so `LGR` comes back nil, `HAS_LEVEL_CB` goes false, and the trigger simply never
  registers. No error, no log line — the "an absent dependency does not fail, it disables" shape.

  **Scoped honestly, because the first draft of this entry overstated it.** `.pkgmeta` already
  lists `libguildroster` under `required-dependencies`, so CurseForge was installing the library;
  and "GuildRoster" sorts before "TOGTools", so WoW's alphabetical load order generally put it
  first anyway. The declaration is what makes that a *guarantee* rather than a coincidence: a
  `## Dependencies:` entry forces the addon to be present, enabled, and loaded first. Without it,
  a player who unticked GuildRoster in the addon list, or installed by hand without it, lost guild
  level-up gratzing with nothing to indicate why — and the load-order protection rested on a
  filename.

  `GuildRoster` is now declared in all six. Verified safe before making it hard on five more
  manifests: the library ships TOCs for every flavour this addon supports (Vanilla `11509`, TBC
  `20506`, Wrath `30403`, Cata `40402`, Mists `50504`, Mainline `120007`), so no flavour can be left
  unable to load. Achievement and party level-up triggers were unaffected — neither touches the
  library. Location: all six `.toc` files.

- **Gathering Log stored no item quality or expansion, so the Gathering chart could not group by
  expansion.** `GatheringLog` and `LogGraphs` both reached for the **bare** `GetItemInfo`, each
  behind its own feature-test. On Classic Era that bare name is not the API — it is a deprecation
  fallback defined only in `Blizzard_DeprecatedItemScript/Deprecated_ItemScript.lua:42`, behind a
  `return` at that file's line 4 unless `GetCVarBool("loadDeprecationFallbacks")`. Verified against
  the Classic Era client tree: **zero** bare `GetItemInfo(` call sites in the whole `Interface/`
  tree against **66** for `C_Item.GetItemInfo`.

  With fallbacks off the global is nil, both guards are false, and neither branch runs. Nothing
  errors — an absent global does not *fail* a feature-tested call site, it *disables* it. Every
  gather was stored with `quality = nil`, `expac = nil` and the parsed display name instead of the
  canonical one, and the repair path failed with it: `LogGraphs.entryExpac` exists precisely to
  back-fill `expac` for pre-v0.6.2 entries and reached for the same absent global. Capture and
  fallback failed together, silently.

  Both now localise at load through the namespaced form — `(C_Item and C_Item.GetItemInfo) or
  GetItemInfo` — matching the idiom `Modules/ItemDB/ItemDB.lua:51-55` already used. Location:
  `Modules/GatheringLog/GatheringLog.lua`, `Modules/LogGraphs/LogGraphs.lua`.

  **Both specs were complicit, in different ways**, which is why a green suite never saw it:
  `loggraphs_spec` stubbed the bare global — the branch no client takes, the same false-green as
  `AddonLoad` and `LoginDigest` above — and `gatheringlog_spec` stubbed *neither* form, so the
  enrichment branch had never executed offline at all. Both now drive `C_Item.GetItemInfo` and set
  `_G.GetItemInfo = nil` so the deprecated global is absent exactly as on a real client. Two new
  tests cover the enriched and uncached cases. Found by round 2 of the `docs/AUDIT.md` peer review,
  which led with the flavour-reach steer from round 1.

  A follow-up sweep applied the same tripwire to the two `v0.8.3` specs of this shape.
  `Tests/addonload_spec.lua` was half-guarded — it assigned into `C_AddOns` but left the bare names
  present, so a regression in that wiring could have gone green through the `or GetAddOnInfo`
  fallback; the bare three are now nil. `GetAddOnMemoryUsage` / `UpdateAddOnMemoryUsage` deliberately
  stay bare, since `PerformanceDocumentation.lua` declares them under `PerformanceScript` with no
  namespace. `Tests/logindigest_spec.lua` already had the guard and drives both branches on purpose,
  which is correct there — unlike `GetItemInfo`, the bare container global is a live path on the
  Retail tree, and TOGTools ships `TOGTools_Mainline.toc`.

- **`AddonLoad` was tested exclusively through a branch no supported flavour takes.** The module
  feature-detects `(C_AddOns and C_AddOns.X) or X`, and `Tests/addonload_spec.lua` set
  `_G.C_AddOns = nil` and installed the bare `GetNumAddOns` / `GetAddOnInfo` / `IsAddOnLoaded`
  globals — so the `or` fallback was the only branch ever exercised. Verified in the client source
  rather than assumed: `AddOnsDocumentation.lua` declares those three under
  `Namespace = "C_AddOns"`, and the Classic Era `Interface/` tree has **zero** bare call sites
  against **21** namespaced ones. The spec now drives `C_AddOns` (assigning *into* it, never over
  it). The module's comment claimed "Classic Era keeps the bare globals", which is backwards, and
  has been replaced with the per-function truth — `GetAddOnMemoryUsage` /
  `UpdateAddOnMemoryUsage` genuinely *are* bare globals there, because
  `PerformanceDocumentation.lua` declares them under `PerformanceScript` with no namespace.
  Location: `Modules/AddonLoad/AddonLoad.lua`, `Tests/addonload_spec.lua`.

- **`MailLog`'s hook chain was simulated, never run.** `Tests/maillog_spec.lua` installed
  `_G.hooksecurefunc = function(name, fn) hooks[name] = fn end`, which *recorded* callbacks and
  wrapped nothing. Two consequences: the spec invoked the captured function directly instead of
  going through the chain, and `_ourTakeInboxItemRef = _G.TakeInboxItem` (`MailLog.lua:289`) saved
  the **unwrapped original**. That saved reference is the entire TSM-replacement signature — the
  justification for the snapshot capture path — and it was being compared against a value it could
  never hold in game. The shadow is gone; the spec's `hooks` table now dispatches through the live
  global, so the existing call sites exercise the real chain.
  Location: `Tests/maillog_spec.lua`.

- **Every `C_Timer`-deferred path was dead offline.** Eight specs assigned
  `_G.C_Timer = { After = function() end, … }` — a no-op that silently discarded the callback, and
  a wholesale namespace assignment besides. So `Diagnostics`' two `After(0)` handlers, `Mailbox`'s
  login alert, `LoginDigest`'s print, `Gratz`'s level-settle delay and party-tracker refresh,
  `NamePrefix`'s dedup re-apply and `ItemDB`'s walk ticker had never executed in a test. The
  harness's `C_Timer` is driven by `wow.advanceTime`, so those now run at their real delays.
  `GuildBankLog`'s cancellable `NewTimer` handle was likewise a `Cancel` that did nothing.

- **`LoginDigest`'s bag read was tested on the wrong branch by default.** The spec's flag was named
  `retailBags` and defaulted to the **bare** `GetContainerNumSlots` globals as though those were the
  Classic form. It is the reverse: bare has **zero** call sites in the Classic Era, Anniversary and
  Classic trees (9 `C_Container.` in each) and one only in Retail. `C_Container` is now the default,
  and the fallback case is labelled as the Retail-only path it is — it still earns its place because
  `TOGTools_Mainline.toc` ships. Location: `Tests/logindigest_spec.lua`.

### Improvements

- **Every spec now calls `wow.reset()` first.** Not one of the 20 did. The suite runs in a single
  Lua state and the world-builders assign globals freely, so leftovers leaked from one spec *file*
  into later ones — invisible in a single-file run, which is how it survived. This matters most in
  `Tests/idhitthat_spec.lua`, whose whole purpose is that a later-expansion API must be **absent**
  on a flavour that lacks it: a global left behind by an earlier file would make exactly the
  accidental dependency it exists to catch pass silently. Ordering is load-bearing — `reset()`
  reinstalls every global the env owns, so it must precede the builder's own assignments (putting it
  after `smack_spec`'s `GetTime` override wiped that override and broke a cooldown test, which is
  how the rule got learned).

- **The 13 frozen-clock `_G.time` overrides are now `wow.epoch`.** The harness's `time()` reads it
  live, so it is still steerable per test, but `GetTime()` and `time()` now advance **together**
  under `wow.advanceTime` — as in the client, where nothing an addon can do makes the two disagree
  about elapsed time. `Tests/gratz_spec.lua` had a local `now`/`GetTime` pair that could drift from
  a separately-frozen `time()`.

- **`Tests/gratz_spec.lua` no longer assigns `_G.C_GuildInfo` wholesale**, which would have
  discarded the harness's `GetInfoText`. The "never polls the guild roster" assertion now reads the
  guild env's own `rosterUpdates` counter instead of a hand-rolled one.

- **Adopted `docs/AUDIT.md`, the harness's peer-review protocol** — the inverse of a harness
  contract: a contract is raised here and answered by the harness, an audit finding is raised by a
  review session and answered by **us**. A review session writes its findings straight into that
  file — the one thing a harness session touches in this repo; it does not edit `CLAUDE.md`, the
  docs index or `.pkgmeta`, and does not commit here. Suite green at every step of the catch-up;
  the final pin is recorded once at the end of this entry.

  **Round 1 has already run** and found no defects in `Modules/Logs/Logs.lua`, with four hazards
  chased and disproved — recorded in the file's `Checked and correct` section so the next reviewer
  does not re-derive them. One suggestion taken: `Logs.lua:56`'s `cfg.retentionDays or 90` reads
  like the classic Lua default-value trap and is not one, because **`0` is truthy in Lua** — so a
  configured `0` survives `or 90` and correctly disables pruning. Commented in place, because the
  plausible "fix" is what would break it. That is one file of eighteen and must not be read as a
  clean bill of health.

  The first cut of this file was built against the older docs and got two rules wrong, corrected on
  the harness's own notice: it had a `## Fixed` section, which means a resolved finding gets **moved**
  — a slower form of deleting it, when the whole value is the failure scenario sitting next to the
  code it describes. A fixed finding now stays in place, gets a response block, and flips its row in
  the **Status table**, which is the only place state is tracked. `CLAUDE.md`'s pointer said "struck
  through in place" and was corrected to match.

- **`wow-version-replication.ps1` was replicating almost everything it was supposed to exclude.**
  The dev watcher is a second implementation of the packager's exclusion rules, so it is only
  correct while it agrees with them. Two independent bugs, both silent, found by running
  `-DryRun` and reading the output rather than trusting the startup banner:

  1. **A bare folder name in `ignore:` matched nothing.** `.pkgmeta` uses `docs` and `Tests` — the
     canonical form, since `release.sh`'s `parse_ignore()` strips a trailing `/*`, not a bare `/`,
     and then rewrites a directory entry to `dir/*`. `Convert-GlobToRegex` only treated an entry as
     a directory when it *ended* with a slash, so `docs` compiled to `^docs$` and matched only a
     file literally named `docs`. Both folders were printed at startup as loaded ignore globs and
     replicated anyway — pushing `docs\` and the **entire `Tests\wowapi` harness submodule**,
     several hundred files, into every synced client. Bare non-wildcard entries are now resolved
     against the repo with `Test-Path -PathType Container` and treated as a prefix match.
  2. **The dotfile rule was an enumerated list, not a rule.** It named `.git`, `.gitignore`,
     `.gitattributes`, `.gitmodules` and `.pkgmeta`, so `.busted`, `.luarc.json`,
     `.markdownlint.json` and `.markdownlintignore` were replicated. The packager prunes dotfiles
     unconditionally (`-name ".*" -prune`), which is also why listing them in `.pkgmeta` is a no-op
     and the script has to own the rule. Replaced with the general `(^|\\)\.` pattern.

  Verified with `-DryRun`: `docs\`, `Tests\`, every dotfile, `CLAUDE.md`, `*.ps1` and the
  `.code-workspace` all report `[skip]`; `README.md`, `CHANGELOG.md`, all six TOCs, `Version.lua`,
  every module, `libs\` and `textures\` still copy. Synced installs now match the released zip.

- **Added `README.md`** — the repo had none. User-facing, in the same shape as FastGuildInvite's:
  what each tool does in plain language, a quickstart, the slash-command table, and the three
  companions CurseForge installs for you. `README.md` is **not** in `.pkgmeta`'s `ignore:`, so it
  ships with the addon, which is the point.

- **TOC pass across all six manifests.** `TOGTools_BCC.toc` is now `TOGTools_TBC.toc` — its
  `## Title` already read "TBC" and only the filename disagreed. Both suffixes are valid client
  flavour names (counted across this install: 52 `_TBC.toc` against 11 `_BCC.toc`), so this is a
  naming-consistency change, not a load fix. Renamed with `git mv` so history follows;
  `Tests/toc_spec.lua` and the replication script's comment were updated with it. Note the sibling
  `GuildRoster` addon still uses `_BCC` — worth a sweep some time, but out of scope here.

  The empty `## SavedVariablesPerCharacter:` directive was removed from all six. It declared
  nothing: AceDB scopes per-character data inside the account-wide `TOGTools_DB`, which is what
  `DB_DEFAULTS.char` is.

- **`Tests/toc_spec.lua` now pins the manifests' *directives*, not just their file lists.** Four new
  assertions: `GuildRoster` present in every flavour (the regression above), `Ace3` and `!!TOGT` in
  every flavour, `VersionCheck-1.0` in every flavour **except** Retail — where `/togt vc` is
  unsupported, so its absence is deliberate and now asserted as such rather than merely tolerated —
  and a Title present with no empty `SavedVariablesPerCharacter`. The spec previously read only the
  file entries, so a dependency could go missing from five manifests with the suite fully green.
  A duplicate directive parser inside the file was folded into the shared one.

Suite: **520 passed, 0 failed**. `Tests/wowapi` pin `4161af8` → `05d04e1`.

---

## [v0.8.2] (2026-08-01) - Version.lua missing from five TOCs

### Bug Fixes

- **`Version.lua` was listed only in the Vanilla TOC, so `addon.gameVersion` was nil on TBC, Wrath, Cata, Mists and Retail** — v0.8.1 split the `GetBuildInfo` → flavour-flag derivation out of `TOGTools.lua` into a new dependency-free `Version.lua`, but added the new file to `TOGTools.toc` only. The other five manifests never loaded it, so `addon.gameVersion` was never assigned and every `local gv = addon.gameVersion` resolved to nil on five of the six shipped flavours. One omission, four distinct user-visible errors:

  - `TOGTools.lua:302` — `gv.isRetail` in `Ace:OnInitialize`, which aborts initialization inside AceAddon's `InitializeAddon`.
  - `Modules/NamePrefix/NamePrefix.lua:275` — `gv.isRetail120Plus` in `Install`, reached from `OnEnable`, so no chat hook was installed at all.
  - `Modules/IdHitThat/IdHitThat.lua:53` — `gv.isMists` at **file scope**, picking the physical miss table. This one throws during file load, so the file dies before `addon.modules["idhitthat"]` is registered…
  - …which is why `GUI/IdHitThatTab.lua:15` then failed with "attempt to perform indexed assignment on local 'Tab' (a nil value)". The tab file merges its UI fields into the engine's registered table; a dead engine file leaves nothing to merge into. Not a second bug — the same one, one file later.

  Added `Version.lua` ahead of `TOGTools.lua` in `TOGTools_BCC.toc`, `TOGTools_Wrath.toc`, `TOGTools_Cata.toc`, `TOGTools_Mists.toc` and `TOGTools_Mainline.toc`.

### Improvements

- **`Tests/toc_spec.lua` — the manifests are now under test** — Nothing in the suite read a `.toc`. Specs load modules by path directly, so a file missing from a manifest tests perfectly green right up until the client loads it; v0.8.1 shipped 503 passing tests and this defect. The new spec parses all six manifests and asserts:

  - **`Version.lua` is listed in every flavour**, and loads **before every non-lib entry** — the exact regression, plus the ordering constraint that makes it matter (files read `addon.gameVersion` into a local at load time, so anything above it sees nil). Embedded `libs\` entries are exempt; they take no namespace and are deliberately first.
  - **Every listed file exists on disk** — catches a rename or move that updated the file but not the manifest.
  - **Cross-flavour parity against the union of all six manifests**, so a file added to *any* single TOC fails regardless of which one. Deliberate omissions live in an explicit `INTENTIONAL_OMISSIONS` table with the reason (currently only Smack on Retail, where automated public chat is blocked), and a second assertion fails if an allowlisted entry is actually present — a stale exemption would silently mask a future real gap.
  - **Relative load order matches across flavours.** Order is behaviour here: engines register `addon.modules[key]` and the GUI tab files merge into that table, so a swapped pair is a nil index in one flavour only.
  - **Each TOC's `Interface` sits in the band `DeriveGameVersion` maps it to.** A TOC in the wrong band doesn't error, it routes a whole flavour down another expansion's code path. `Interface` is parsed as the comma-separated list it is, not via a bare `tonumber`.

- **Retail TOC interface list pruned to `110207, 120008`** — `TOGTools_Mainline.toc` had accumulated `120000, 120001, 120005, 120007` alongside the 11.x floor. The 11.x entry stays so the addon keeps loading on The War Within; the 12.x side now tracks only the current Midnight build, so bumping it is an edit rather than an append. `toc_spec.lua` pins that shape — exactly one pre-12.0 entry and exactly one 12.x entry — so the list can't grow back.

---

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

---

Older releases (v0.5.2 and earlier) are archived in [CHANGELOG_ARCHIVE.md](CHANGELOG_ARCHIVE.md).
