local addonName, WB = ...

WB.UI = WB.UI or {}

local FRAME_WIDTH  = 930
local FRAME_HEIGHT = 690
local GAP          = 12   -- spacing between columns and between stacked panels
local RANK_ICON_SIZE = 54  -- Service Record rank badge size

-- ── Palette ───────────────────────────────────────────────────────────────────
local GOLD         = { 0.95, 0.78, 0.35 }
local PANEL_BG     = { 0.05, 0.04, 0.03, 0.88 }
local PANEL_BORDER = { 0.50, 0.40, 0.22, 0.90 }
local LABEL_COLOR  = { 0.68, 0.66, 0.60 }
local VALUE_COLOR  = { 0.92, 0.86, 0.66 }
local MUTED_COLOR  = { 0.55, 0.50, 0.42 }

-- ── Locale-safe race/class names ────────────────────────────────────────────────
-- Synced profiles/bounties carry race + class as the SENDER's localized strings, so a player on
-- a zhCN client would show up as "人类, 战士" on a US client. The profiles also carry the
-- locale-independent tokens (raceFile / classToken), so we re-localize from THOSE on the local
-- client and only fall back to the synced string if a token is missing.
local _raceByFile
local function LocalizeRace(raceFile, fallback)
    if type(raceFile) == "string" and raceFile ~= "" then
        if not _raceByFile then
            _raceByFile = {}
            if C_CreatureInfo and C_CreatureInfo.GetRaceInfo then
                for id = 1, 120 do
                    local ri = C_CreatureInfo.GetRaceInfo(id)
                    if ri and ri.clientFileString and ri.raceName then
                        _raceByFile[ri.clientFileString:lower():gsub("[^%a]", "")] = ri.raceName
                    end
                end
            end
        end
        local n = _raceByFile[raceFile:lower():gsub("[^%a]", "")]
        if n then return n end
    end
    return fallback or "Unknown"
end
local function LocalizeClass(classToken, fallback)
    if type(classToken) == "string" and classToken ~= "" then
        local n = (LOCALIZED_CLASS_NAMES_MALE and LOCALIZED_CLASS_NAMES_MALE[classToken])
               or (LOCALIZED_CLASS_NAMES_FEMALE and LOCALIZED_CLASS_NAMES_FEMALE[classToken])
        if n then return n end
    end
    return fallback or "Unknown"
end
local HORDE_RED     = { 0.55, 0.10, 0.10 }
local ALLIANCE_BLUE = { 0.12, 0.28, 0.58 }
local ACCENT        = HORDE_RED   -- faction-selected; reassigned in CreateWarBoardFrame

-- Brighter faction colours for player names (leaderboard etc.).
local FACTION_NAME_COLOR = {
    Horde    = { 0.93, 0.27, 0.24 },
    Alliance = { 0.39, 0.58, 0.93 },
}

-- Faction-specific frame artwork (atlas names). Identical NineSlice logic for
-- both factions — only the textures (and accent colour) differ. Chosen from the
-- player's faction on login.
local FACTION_ART = {
    Horde = {
        corner     = "Horde-NineSlice-Corner",
        edgeTop    = "_Horde-NineSlice-EdgeTop",
        edgeBottom = "_Horde-NineSlice-EdgeBottom",
        edgeLeft   = "!Horde-NineSlice-EdgeLeft",
        edgeRight  = "!Horde-NineSlice-EdgeRight",
        header     = "UI-Frame-Horde-Header",
        background = "UI-Frame-Horde-BackgroundTile",
        accent     = HORDE_RED,
        headerY    = 55,   -- crest protrusion above the frame top
    },
    Alliance = {
        corner     = "Alliance-NineSlice-Corner",
        edgeTop    = "_Alliance-NineSlice-EdgeTop",
        edgeBottom = "_Alliance-NineSlice-EdgeBottom",
        edgeLeft   = "!Alliance-NineSlice-EdgeLeft",
        edgeRight  = "!Alliance-NineSlice-EdgeRight",
        header     = "UI-Frame-Alliance-Header",
        background = "UI-Frame-Alliance-BackgroundTile",
        accent     = ALLIANCE_BLUE,
        headerY    = 79,   -- taller crest → raised more so its bottom meets the top border
    },
}

-- Player's faction art + name. Neutral (unchosen pandaren) defaults to Horde.
local function GetFactionArt()
    if UnitFactionGroup("player") == "Alliance" then
        return FACTION_ART.Alliance, "Alliance"
    end
    return FACTION_ART.Horde, "Horde"
end

-- SetAtlas caches atlas state internally and re-applies its own UVs, overriding any
-- SetTexCoord call we make afterwards. Using SetTexture + SetTexCoord directly avoids this.
local function SetAtlasTexture(tex, atlasName, flipH, flipV)
    local info = C_Texture.GetAtlasInfo(atlasName)
    if not info then return end
    tex:SetTexture(info.file)
    local l, r, t, b = info.leftTexCoord, info.rightTexCoord, info.topTexCoord, info.bottomTexCoord
    if flipH then l, r = r, l end
    if flipV then t, b = b, t end
    tex:SetTexCoord(l, r, t, b)
    tex:SetSize(info.width, info.height)
end

local PLUNDER_COIN_ATLAS = "plunderstorm-icon-plunderCoins"
local function SetWarBoardCoinIcon(tex, size)
    local info = C_Texture.GetAtlasInfo(PLUNDER_COIN_ATLAS)
    if not info then return end
    tex:SetTexture(info.file)
    local l, r, t, b = info.leftTexCoord, info.rightTexCoord, info.topTexCoord, info.bottomTexCoord
    local crop = 0
    local dx, dy = (r - l) * crop, (b - t) * crop
    tex:SetTexCoord(l + dx, r - dx, t + dy, b - dy)
    tex:SetSize(size, size)
end

local function ApplyFactionNineSlice(frame, art)
    -- Corners: single atlas, rotated via UV flipping
    local tl = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(tl, art.corner)
    tl:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0)

    local tr = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(tr, art.corner, true, false)
    tr:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0)

    local bl = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(bl, art.corner, false, true)
    bl:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0)

    local br = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(br, art.corner, true, true)
    br:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0)

    local cw = tl:GetWidth()
    local ch = tl:GetHeight()
    if cw == 0 then cw = 32 end
    if ch == 0 then ch = 32 end

    -- Top edge (tiles horizontally)
    local edgeTop = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(edgeTop, art.edgeTop)
    edgeTop:SetHorizTile(true)
    edgeTop:SetPoint("TOPLEFT",  frame, "TOPLEFT",  cw,  0)
    edgeTop:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -cw, 0)

    -- Bottom edge (tiles horizontally)
    local edgeBottom = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(edgeBottom, art.edgeBottom)
    edgeBottom:SetHorizTile(true)
    edgeBottom:SetPoint("BOTTOMLEFT",  frame, "BOTTOMLEFT",  cw,  0)
    edgeBottom:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -cw, 0)

    -- Left edge (tiles vertically)
    local edgeLeft = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(edgeLeft, art.edgeLeft)
    edgeLeft:SetVertTile(true)
    edgeLeft:SetPoint("TOPLEFT",    frame, "TOPLEFT",    0, -ch)
    edgeLeft:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0,  ch)

    -- Right edge (tiles vertically)
    local edgeRight = frame:CreateTexture(nil, "BORDER")
    SetAtlasTexture(edgeRight, art.edgeRight)
    edgeRight:SetVertTile(true)
    edgeRight:SetPoint("TOPRIGHT",    frame, "TOPRIGHT",    0, -ch)
    edgeRight:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0,  ch)
end

-- ── Reusable building blocks ──────────────────────────────────────────────────

-- A bordered dark sub-panel ("block").
local function CreatePanel(parent)
    local p = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    p:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 14,
        insets   = { left = 4, right = 4, top = 4, bottom = 4 },
    })
    p:SetBackdropColor(PANEL_BG[1], PANEL_BG[2], PANEL_BG[3], PANEL_BG[4])
    p:SetBackdropBorderColor(PANEL_BORDER[1], PANEL_BORDER[2], PANEL_BORDER[3], PANEL_BORDER[4])
    return p
end

-- Gold section header text, centred in its container. The caller anchors its
-- TOPLEFT; we add the matching TOPRIGHT so the string spans the panel width and
-- the text centres (while the header's BOTTOMLEFT still sits at the panel's left
-- edge, keeping any left-aligned content anchored beneath it in place).
local function SectionHeader(parent, text)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    fs:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")
    fs:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -12, -12)
    fs:SetJustifyH("CENTER")
    fs:SetText(text)
    fs:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    return fs
end

local function HLine(parent)
    local t = parent:CreateTexture(nil, "ARTWORK")
    t:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.22)
    t:SetHeight(1)
    return t
end

-- Make an untrusted wire string safe to DISPLAY: strips colour / link / texture / kstring
-- escapes so a crafted name/message/guild can't inject control codes into our UI. nil stays
-- nil so existing `or "?"` fallbacks still fire. Falls through if the core helper isn't loaded.
local function SafeName(s, maxLen)
    if s == nil then return nil end
    return (WB.SanitizeDisplay and WB.SanitizeDisplay(s, maxLen or 48)) or s
end

-- "Name-Realm" (realm omitted when blank). Shown in hover tooltips across the feeds.
-- Both halves are sanitized here, so every FullPlayerName(...) tooltip is safe in one place.
local function FullPlayerName(name, realm)
    name = SafeName(name, 32) or "?"
    realm = SafeName(realm, 32)
    if realm and realm ~= "" then return name .. "-" .. realm end
    return name
end

-- A transparent mouse region that shows a player's full Name-Realm on hover. Anchor
-- it over a name fontstring (hit:SetAllPoints(nameFS)) and set hit._fullName each
-- render; the tooltip reads it live.
local function MakeNameHover(parent)
    local hit = CreateFrame("Frame", nil, parent)
    hit:EnableMouse(true)
    hit:SetScript("OnEnter", function(self)
        if not self._fullName then return end
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText(self._fullName, 1, 0.95, 0.8)
        GameTooltip:Show()
    end)
    hit:SetScript("OnLeave", function() GameTooltip:Hide() end)
    return hit
end

-- Texture → zone (shown on hover). Delegates to the shared resolver in
-- WarEffort_Objectives.lua so the tooltip and the kill tracker stay in sync.
local function ZoneFromTexture(texture)
    return WB.WarEffort and WB.WarEffort.ZoneFromTexture and WB.WarEffort.ZoneFromTexture(texture)
end

-- Refresh one objective card's progress text from the saved tally (called by the
-- kill tracker when an objective advances).
function WB.UI.RefreshObjectiveProgress(id)
    for _, card in ipairs(WB.UI.objectiveCards or {}) do
        if card.id == id and card.progress then
            local cur = (WB.WarEffort and WB.WarEffort.GetProgress and WB.WarEffort.GetProgress(id)) or 0
            card.progress:SetText(string.format("%d / %d", cur, card.goal or 0))
            if card.SetComplete then
                local done = WB.WarEffort and WB.WarEffort.IsComplete and WB.WarEffort.IsComplete(id)
                card:SetComplete(done and true or false)
            end
            if card.RefreshReward then
                card:RefreshReward()
            end
            -- Keep the live objective-tracker entry (if this objective is currently shown there)
            -- in sync with the new kill count.
            if WB.Tracker and WB.Tracker.OnProgress then WB.Tracker:OnProgress(id) end
            return
        end
    end
    -- The card may not be built yet (War Board never opened), but the tracker can still be live.
    if WB.Tracker and WB.Tracker.OnProgress then WB.Tracker:OnProgress(id) end
end

-- Update the Service Record's banked gold / war credit from saved totals.
function WB.UI.RefreshCurrency()
    local rec = WB.UI.serviceRecord
    if not rec then return end
    local gold = (WB.WarEffort and WB.WarEffort.GetGold      and WB.WarEffort.GetGold())      or 0
    -- War Credit shown here is the LIFETIME total that drives rank — the same value the rank
    -- progress bar uses (the synced floor that survives a rollback / fresh install), so the
    -- stat and the bar can never disagree. Fall back to the live ledger total if the roster
    -- isn't up yet. (WC is never spent, so lifetime == "total earned".)
    local wc   = (WB.Roster and WB.Roster.MyLifetimeWC and WB.Roster.MyLifetimeWC())
                 or (WB.WarEffort and WB.WarEffort.GetWarCredit and WB.WarEffort.GetWarCredit()) or 0
    if rec.gold      then rec.gold:SetText(tostring(gold)) end
    if rec.warCredit then rec.warCredit:SetText(tostring(wc)) end
    if WB.UI.RefreshRank then WB.UI.RefreshRank() end   -- rank + progression scale with war credit
end

function WB.UI.RefreshAllObjectives()
    for _, card in ipairs(WB.UI.objectiveCards or {}) do
        if card.id then WB.UI.RefreshObjectiveProgress(card.id) end
    end
end

function WB.UI.RefreshObjectiveBackgrounds()
    for _, card in ipairs(WB.UI.objectiveCards or {}) do
        if card.RefreshBackground then card:RefreshBackground() end
    end
end

-- Re-anchor the War Effort cards so the current "priority zone" objectives float to the
-- top (in their authored relative order), the rest following (also authored order). The
-- card list itself stays in authored order — we just recompute anchors from the sticky
-- WB.WarEffort.GetPriorityZone() each call, so it's idempotent and safe to call live.
function WB.UI.RelayoutObjectives()
    local list, cards = WB.UI.objectiveList, WB.UI.objectiveCards
    if not list or not cards or #cards == 0 then return end
    local cardHeight, cardGap = 92, 8
    local priority  = WB.WarEffort and WB.WarEffort.GetPriorityZone and WB.WarEffort.GetPriorityZone()
    local InZoneNow = WB.WarEffort and WB.WarEffort.PlayerInZoneNow
    local ordered, rest = {}, {}
    for _, card in ipairs(cards) do
        local pin
        if card.flipZone then
            -- Weekly-flip objectives (Val/Naigtal) float to the top ONLY while you're physically
            -- standing in their zone — never via the sticky last-zone priority — so they drop back
            -- the moment you leave instead of staying pinned like a home battlefield.
            pin = InZoneNow and InZoneNow(card.mapID, card.zone)
        else
            pin = priority and card.zone == priority
        end
        if pin then ordered[#ordered + 1] = card else rest[#rest + 1] = card end
    end
    for _, c in ipairs(rest) do ordered[#ordered + 1] = c end
    for i, card in ipairs(ordered) do
        card:ClearAllPoints()
        card:SetPoint("TOPLEFT",  list, "TOPLEFT",  4, -((i - 1) * (cardHeight + cardGap)))
        card:SetPoint("TOPRIGHT", list, "TOPRIGHT", -4, -((i - 1) * (cardHeight + cardGap)))
    end
end

-- Faction rank names. Backend still refers to ranks by number (1-3); only the
-- displayed "Rank:" text differs per faction.
local RANK_NAMES = {
    Horde    = { [0] = "No Rank", [1] = "Scout",   [2] = "Grunt",    [3] = "Sergeant" },
    Alliance = { [0] = "No Rank", [1] = "Private", [2] = "Corporal", [3] = "Sergeant" },
}

-- Rank is earned by TOTAL war credit: 1000 = rank 1, 2500 = rank 2, 5000 = rank 3.
local RANK_THRESHOLDS = { [1] = 1000, [2] = 2500, [3] = 5000 }
local function RankFromWC(wc)
    wc = tonumber(wc) or 0
    if wc >= RANK_THRESHOLDS[3] then return 3 end
    if wc >= RANK_THRESHOLDS[2] then return 2 end
    if wc >= RANK_THRESHOLDS[1] then return 1 end
    return 0
end

-- Set the displayed rank: 1-3 swaps the icon to the matching prestige badge and
-- shows the faction rank name; 0/nil = no rank (the faction badge, "Rank: No Rank").
function WB.UI.SetRank(rank)
    local rec = WB.UI.serviceRecord
    if not rec or not rec.icon then return end
    rank = tonumber(rank)
    local names = RANK_NAMES[WB.faction] or RANK_NAMES.Horde
    if rank and rank >= 1 and rank <= 3 then
        rec.icon:SetAtlas("honorsystem-icon-prestige-" .. rank)
        rec.icon:SetSize(RANK_ICON_SIZE, RANK_ICON_SIZE)
        if rec.rank then rec.rank:SetText(names[rank] or tostring(rank)) end
    else
        rec.icon:SetAtlas(rec.badge)
        rec.icon:SetSize(RANK_ICON_SIZE, RANK_ICON_SIZE)
        if rec.rank then rec.rank:SetText(names[0] or "No Rank") end
    end
end

-- An objective "pill" card: zone-art background (darkened for legibility), gold
-- title, description, progress, and rewards. Caller sets LEFT/RIGHT/TOP anchors.
-- data = { obj, title, desc, texture, current, goal, gold, warCredit }
local function CreateObjectiveCard(parent, data)
    local card = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    card:SetHeight(92)
    card:SetClipsChildren(true)
    card:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    card:SetBackdropColor(0, 0, 0, 1)
    if data.elite then
        -- Elite objectives recolor the border to the faction colour (Horde red,
        -- Alliance blue) instead of the usual muted gold.
        local ec = (WB.faction == "Alliance") and ALLIANCE_BLUE or HORDE_RED
        card:SetBackdropBorderColor(ec[1], ec[2], ec[3], 1)
    else
        card:SetBackdropBorderColor(PANEL_BORDER[1], PANEL_BORDER[2], PANEL_BORDER[3], 0.95)
    end

    -- Zone-art background, inset inside the border. Keep it above the frame's
    -- black backdrop; BACKGROUND-layer textures can lose draw order to the
    -- BackdropTemplate bg after reloads/reopens.
    local bg = card:CreateTexture(nil, "BACKGROUND")
    bg:SetDrawLayer("ARTWORK", -8)
    bg:SetPoint("TOPLEFT",     card, "TOPLEFT",      4, -4)
    bg:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -4,  4)
    card.bg = bg
    card.texturePath = data.texture
    card.texCoord    = data.texCoord   -- optional {l,r,t,b} crop to trim baked-in art borders

    function card:RefreshBackground()
        if not self.bg or not self.texturePath then return end
        self.bg:SetTexture(self.texturePath)
        if self.texCoord then
            self.bg:SetTexCoord(self.texCoord[1], self.texCoord[2], self.texCoord[3], self.texCoord[4])
        else
            self.bg:SetTexCoord(0, 1, 0, 1)
        end
        self.bg:SetDrawLayer("ARTWORK", -8)
        self.bg:Show()
    end
    card:RefreshBackground()
    card:SetScript("OnShow", function(self)
        self:RefreshBackground()
        if C_Timer then
            C_Timer.After(0, function()
                if self:IsShown() then self:RefreshBackground() end
            end)
        end
    end)

    local shade = card:CreateTexture(nil, "ARTWORK")
    shade:SetDrawLayer("ARTWORK", -7)
    shade:SetAllPoints(bg)
    shade:SetColorTexture(0, 0, 0, 0.5)

    local title = card:CreateFontString(nil, "OVERLAY")
    local titleSize = (data.title and #data.title > 22) and 12 or 13
    title:SetFont("Fonts\\FRIZQT__.TTF", titleSize, "OUTLINE")
    title:SetPoint("TOPLEFT", card, "TOPLEFT", 10, -9)
    title:SetPoint("RIGHT",   card, "RIGHT",   -10, 0)
    title:SetJustifyH("LEFT")
    title:SetWordWrap(true)
    if title.SetMaxLines then title:SetMaxLines(2) end
    title:SetText(data.title)
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local desc = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 0, -3)
    desc:SetPoint("RIGHT",   card,  "RIGHT",     -10, 0)
    desc:SetJustifyH("LEFT")
    desc:SetText(data.desc)
    desc:SetTextColor(0.90, 0.88, 0.82)

    local progress = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    progress:SetPoint("BOTTOMLEFT", card, "BOTTOMLEFT", 10, 9)
    progress:SetText(string.format("%d / %d", data.current or 0, data.goal or 0))
    progress:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    -- War credit reward (far right): amount + faction symbol.
    local symbol = card:CreateTexture(nil, "OVERLAY")
    symbol:SetAtlas((WB.faction == "Alliance") and "AllianceSymbol" or "HordeSymbol")
    symbol:SetSize(20, 20)
    symbol:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -9, 3)

    local warCredit = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    -- +2 keeps the number on the gold-coin baseline while the symbol box sits a
    -- touch lower, so the crest reads on par with the coin instead of riding high.
    warCredit:SetPoint("RIGHT", symbol, "LEFT", -4, 2)
    warCredit:SetText(tostring(data.warCredit or 0))
    warCredit:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    -- Gold reward (left of war credit): amount + gold coin.
    local coin = card:CreateTexture(nil, "OVERLAY")
    SetWarBoardCoinIcon(coin, 24)
    coin:SetPoint("RIGHT", warCredit, "LEFT", -8, 0)

    local goldText = card:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    goldText:SetPoint("RIGHT", coin, "LEFT", -2, 0)
    goldText:SetText(tostring(data.gold or 0))
    goldText:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    card.title, card.desc, card.progress    = title, desc, progress
    card.gold, card.warCredit               = goldText, warCredit
    card.obj                                = data.obj

    function card:RefreshReward()
        local gold, wc = data.gold or 0, data.warCredit or 0
        if self.obj and WB.WarEffort and WB.WarEffort.GetRewardForObjective then
            gold, wc = WB.WarEffort.GetRewardForObjective(self.obj)
        end
        if self.gold then self.gold:SetText(tostring(gold or 0)) end
        if self.warCredit then self.warCredit:SetText(tostring(wc or 0)) end
    end
    card:RefreshReward()

    -- "Completed" banner + checkmark, top-right corner of the card. The card
    -- clips its children, so these are anchored just inside the corner (a flag
    -- hanging into the card) rather than protruding past the edge.
    --
    -- Parented to a thin overlay frame one frame-level above the card so the
    -- banner is guaranteed to draw ON TOP of the quest-name title text where the
    -- two overlap (frame level wins over draw layer/sublevel between frames).
    local flair = CreateFrame("Frame", nil, card)
    flair:SetAllPoints(card)
    flair:SetFrameLevel(card:GetFrameLevel() + 5)

    -- Sublevels: banner under the checkmark so the tick reads on top of the flag.
    local banner = flair:CreateTexture(nil, "OVERLAY", nil, 6)
    banner:SetAtlas("housing-dashboard-activity-listitem-flag", true)
    banner:SetPoint("TOPRIGHT", card, "TOPRIGHT", -2, -2)

    local check = flair:CreateTexture(nil, "OVERLAY", nil, 7)
    check:SetAtlas("housing-dashboard-small-checkmark", true)
    check:SetPoint("CENTER", banner, "CENTER", 0, 0)

    card.completeBanner, card.completeCheck = banner, check

    -- Toggle the completion flair. Driven by the tracker: shown once the objective
    -- has been finished at least once (WB.WarEffort.IsComplete).
    function card:SetComplete(done)
        if done then
            self.completeBanner:Show()
            self.completeCheck:Show()
        else
            self.completeBanner:Hide()
            self.completeCheck:Hide()
        end
    end
    card:SetComplete(data.complete and true or false)

    -- Hover tooltip: an explicit label if given, else the zone inferred from the texture.
    local zone = ZoneFromTexture(data.texture)
    card.zone = zone
    local zoneLabel = data.zoneLabel or zone
    -- Which map a left-click opens: the objective's marker map if it has one, else its
    -- tracking mapID (e.g. the weekly-flip fronts). nil → the card isn't map-linked.
    local clickMapID = data.obj and ((data.obj.marker and data.obj.marker.mapID) or data.obj.mapID)
    if zoneLabel or clickMapID then
        card:EnableMouse(true)
        card:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(zoneLabel or (data.obj and data.obj.title) or "Objective", 1, 0.82, 0.2)
            if clickMapID then GameTooltip:AddLine("Click to view on map", 0.6, 0.8, 1) end
            GameTooltip:Show()
        end)
        card:SetScript("OnLeave", function() GameTooltip:Hide() end)
    end

    -- Left-click → open the world map to this objective's zone (its marker location if set),
    -- then close the War Board so the map isn't hidden behind our window.
    if clickMapID then
        card:EnableMouse(true)
        card:SetScript("OnMouseUp", function(self, button)
            if button ~= "LeftButton" then return end
            if OpenWorldMap then
                pcall(OpenWorldMap, clickMapID)
            elseif WorldMapFrame then
                ShowUIPanel(WorldMapFrame)
                if WorldMapFrame.SetMapID then pcall(WorldMapFrame.SetMapID, WorldMapFrame, clickMapID) end
            end
            if WB.UI.frame and WB.UI.frame:IsShown() then WB.UI.frame:Hide() end
        end)
    end

    return card
end

-- ── Column builders ───────────────────────────────────────────────────────────

-- LEFT: two stacked blocks — Service Record (top) then War Effort (below).
local function BuildLeftColumn(col)
    -- Service Record (own block)
    local sr = CreatePanel(col)
    sr:SetPoint("TOPLEFT",  col, "TOPLEFT",  0, 0)
    sr:SetPoint("TOPRIGHT", col, "TOPRIGHT", 0, 0)
    sr:SetHeight(182)

    local srHeader = SectionHeader(sr, "Service Record")
    srHeader:SetPoint("TOPLEFT", sr, "TOPLEFT", 12, -12)

    -- Rank icon (stands alone — no base portrait): the faction "no rank" badge by
    -- default; SetRank swaps in the prestige badge for ranks 1-3.
    local rankBadge = (WB.faction == "Alliance")
        and "pvpqueue-sidebar-honorbar-badge-alliance"
        or  "pvpqueue-sidebar-honorbar-badge-horde"

    local rankIcon = sr:CreateTexture(nil, "ARTWORK")
    rankIcon:SetAtlas(rankBadge)
    rankIcon:SetSize(RANK_ICON_SIZE, RANK_ICON_SIZE)
    rankIcon:SetPoint("TOPLEFT", srHeader, "BOTTOMLEFT", 0, -8)

    -- Name + stats sit to the right of the rank icon.
    local name = sr:CreateFontString(nil, "OVERLAY")
    name:SetFont("Fonts\\FRIZQT__.TTF", 16, "OUTLINE")
    name:SetPoint("TOPLEFT", rankIcon, "TOPRIGHT", 10, -2)
    name:SetText(UnitNameUnmodified("player") or "Soldier")
    name:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
    local nameHit = MakeNameHover(sr)
    nameHit:SetAllPoints(name)
    nameHit._fullName = FullPlayerName(UnitNameUnmodified("player"), GetNormalizedRealmName())

    local function StatRow(anchor, labelText, valueText)
        local label = sr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        label:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -6)
        label:SetText(labelText)
        label:SetTextColor(LABEL_COLOR[1], LABEL_COLOR[2], LABEL_COLOR[3])
        local value = sr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        value:SetPoint("LEFT", label, "RIGHT", 4, 0)
        value:SetText(valueText)
        value:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
        return label, value
    end

    local rankRow, rankVal = StatRow(name,    "Rank:", "No Rank")
    local goldRow, goldVal = StatRow(rankRow, "Gold:", "0")
    local _,       wcVal   = StatRow(goldRow, "War Credit:", "0")

    -- Gold coin + war-credit crest icons after their values (follow the value text).
    local goldCoin = sr:CreateTexture(nil, "ARTWORK")
    SetWarBoardCoinIcon(goldCoin, 32)
    goldCoin:SetPoint("LEFT", goldVal, "RIGHT", 4, 0)

    local wcSym = sr:CreateTexture(nil, "ARTWORK")
    wcSym:SetAtlas((WB.faction == "Alliance") and "AllianceSymbol" or "HordeSymbol")
    wcSym:SetSize(20, 20)
    wcSym:SetPoint("LEFT", wcVal, "RIGHT", 3, -3)

    WB.UI.serviceRecord = {
        name = name, rank = rankVal, gold = goldVal, warCredit = wcVal,
        icon = rankIcon, badge = rankBadge,
    }

    -- ── Rank-up progression bar (below the stats) ──────────────────────────────
    local progTitle = sr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    progTitle:SetPoint("TOPLEFT", sr, "TOPLEFT", 12, -118)
    progTitle:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    progTitle:SetText("Progress to Next Rank")

    local progIcon = sr:CreateTexture(nil, "ARTWORK")   -- small next-rank badge
    progIcon:SetSize(14, 14)
    progIcon:SetPoint("LEFT", progTitle, "RIGHT", 5, 0)

    local progBar = CreateFrame("Frame", nil, sr)
    progBar:SetPoint("TOPLEFT", progTitle, "BOTTOMLEFT", 0, -6)
    progBar:SetPoint("RIGHT",   sr, "RIGHT", -12, 0)
    progBar:SetHeight(15)
    local progBorder = progBar:CreateTexture(nil, "BACKGROUND", nil, -2)
    progBorder:SetColorTexture(0.45, 0.40, 0.28, 0.55)
    progBorder:SetPoint("TOPLEFT",     progBar, "TOPLEFT",     -1,  1)
    progBorder:SetPoint("BOTTOMRIGHT", progBar, "BOTTOMRIGHT",  1, -1)
    local progBg = progBar:CreateTexture(nil, "BACKGROUND", nil, -1)
    progBg:SetAllPoints(progBar)
    progBg:SetColorTexture(0.05, 0.04, 0.03, 0.9)
    local progFill = progBar:CreateTexture(nil, "ARTWORK")
    progFill:SetAtlas("activities-bar-background-glow")
    progFill:SetPoint("TOPLEFT",    progBar, "TOPLEFT",    1, -1)
    progFill:SetPoint("BOTTOMLEFT", progBar, "BOTTOMLEFT", 1,  1)
    progFill:SetWidth(1)
    progBar.fill, progBar.frac = progFill, 0
    -- Re-apply the fill width whenever the bar gets/changes its real width (covers the
    -- case where the seed ran before layout computed a width).
    progBar:SetScript("OnSizeChanged", function(self)
        local bw = (self:GetWidth() or 0) - 2
        self.fill:SetWidth(math.max(1, (self.frac or 0) * math.max(1, bw)))
    end)

    local progText = sr:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    progText:SetPoint("TOPLEFT", progBar, "BOTTOMLEFT", 0, -4)
    progText:SetTextColor(0.72, 0.76, 0.84)

    WB.UI.rankProgress = { title = progTitle, icon = progIcon, bar = progBar, fill = progFill, text = progText }

    WB.UI.RefreshCurrency()  -- seed gold / war credit + rank + progression

    -- War Effort (own block, fills the rest of the column; no quests yet)
    local we = CreatePanel(col)
    we:SetPoint("TOPLEFT",  sr,  "BOTTOMLEFT",  0, -GAP)
    we:SetPoint("TOPRIGHT", sr,  "BOTTOMRIGHT", 0, -GAP)
    we:SetPoint("BOTTOM",   col, "BOTTOM",      0, 0)

    local weHeader = SectionHeader(we, "War Effort")
    weHeader:SetPoint("TOPLEFT", we, "TOPLEFT", 12, -12)

    -- Description is shown as a tooltip on the title rather than inline, so the
    -- card list gets the full panel height.
    local WE_DESC = "Complete objectives to earn gold and war credit. Finished objectives can be repeated for 10% of their reward. Daily objectives reset each day."
    local weHeaderHit = CreateFrame("Frame", nil, we)
    weHeaderHit:SetPoint("CENTER", weHeader, "CENTER", 0, 0)
    weHeaderHit:SetSize((weHeader:GetStringWidth() or 80) + 16, 22)
    weHeaderHit:EnableMouse(true)
    weHeaderHit:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText("War Effort", GOLD[1], GOLD[2], GOLD[3])
        GameTooltip:AddLine(WE_DESC, 0.90, 0.88, 0.82, true)
        GameTooltip:Show()
    end)
    weHeaderHit:SetScript("OnLeave", function() GameTooltip:Hide() end)

    local objectiveScroll = CreateFrame("ScrollFrame", nil, we, "UIPanelScrollFrameTemplate")
    objectiveScroll:SetPoint("TOPLEFT",     weHeader, "BOTTOMLEFT", -4, -8)
    objectiveScroll:SetPoint("BOTTOMRIGHT", we,       "BOTTOMRIGHT", -28, 10)

    local objectiveList = CreateFrame("Frame", nil, objectiveScroll)
    objectiveList:SetSize(1, 1)
    objectiveScroll:SetScrollChild(objectiveList)
    objectiveScroll:SetScript("OnSizeChanged", function(self, width)
        objectiveList:SetWidth(width)
    end)
    objectiveScroll:EnableMouseWheel(true)
    objectiveScroll:SetScript("OnMouseWheel", function(self, delta)
        local nextScroll = self:GetVerticalScroll() - (delta * 32)
        local maxScroll = self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0, math.min(nextScroll, maxScroll)))
    end)

    -- Objectives are authored in WarEffort_Objectives.lua. Enemy faction is the
    -- player's opposite; a "%s" in a desc is replaced with it.
    local enemy = (WB.faction == "Alliance") and "Horde" or "Alliance"
    -- Only show objectives for this faction. faction = "horde"/"alliance"
    -- restricts visibility; objectives without a faction show to both.
    local myFaction = (WB.faction or "Horde"):lower()
    local OBJECTIVES = {}
    for _, obj in ipairs((WB.WarEffort and WB.WarEffort.Objectives) or {}) do
        local f = obj.faction and obj.faction:lower()
        -- `retired` objectives stay in ById (so old records still validate) but are no
        -- longer offered as cards — the forward-compatible way to remove an objective.
        -- IsFlipZoneActive hides a weekly-flip objective (Val/Naigtal) while its zone is down.
        if (not f or f == myFaction) and not obj.retired and WB.WarEffort.IsFlipZoneActive(obj) then
            OBJECTIVES[#OBJECTIVES + 1] = obj
        end
    end

    WB.UI.objectiveCards = {}
    if #OBJECTIVES == 0 then
        objectiveList:SetHeight(24)
        local weEmpty = objectiveList:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        weEmpty:SetPoint("TOPLEFT", objectiveList, "TOPLEFT", 4, 0)
        weEmpty:SetPoint("RIGHT",   objectiveList, "RIGHT",  -4, 0)
        weEmpty:SetJustifyH("LEFT")
        weEmpty:SetText("No active objectives.")
        weEmpty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    else
        local cardHeight, cardGap = 92, 8
        objectiveList:SetHeight((#OBJECTIVES * cardHeight) + ((#OBJECTIVES - 1) * cardGap))
        for i, obj in ipairs(OBJECTIVES) do
            local desc = (obj.desc or ""):gsub("%%s", enemy)
            local gold, warCredit = obj.gold, obj.warCredit
            if WB.WarEffort and WB.WarEffort.GetRewardForObjective then
                gold, warCredit = WB.WarEffort.GetRewardForObjective(obj)
            end
            local card = CreateObjectiveCard(objectiveList, {
                obj       = obj,
                title     = obj.title,
                desc      = desc,
                texture   = obj.texture,
                texCoord  = obj.texCoord,
                zoneLabel = obj.zoneLabel,
                current   = (WB.WarEffort.GetProgress and WB.WarEffort.GetProgress(obj.id)) or 0,
                goal      = obj.goal,
                gold      = gold,
                warCredit = warCredit,
                complete  = (WB.WarEffort.IsComplete and WB.WarEffort.IsComplete(obj.id)) or false,
                elite     = obj.elite,
            })
            card.id       = obj.id
            card.goal     = obj.goal
            card.zone     = WB.WarEffort and WB.WarEffort.ZoneFromTexture and WB.WarEffort.ZoneFromTexture(obj.texture)
            card.flipZone = obj.flipZone   -- weekly-flip objectives pin only while you're in-zone (live), not stickily
            card.mapID    = obj.mapID      -- exact UiMap for that live check (Val 2599 / Naigtal 2600)
            card:SetPoint("TOPLEFT",  objectiveList, "TOPLEFT",  4, -((i - 1) * (cardHeight + cardGap)))
            card:SetPoint("TOPRIGHT", objectiveList, "TOPRIGHT", -4, -((i - 1) * (cardHeight + cardGap)))
            WB.UI.objectiveCards[#WB.UI.objectiveCards + 1] = card
        end
    end

    WB.UI.warEffortBody   = we
    WB.UI.warEffortScroll = objectiveScroll
    WB.UI.objectiveList   = objectiveList
    -- Apply the sticky current-zone ordering to the freshly built cards.
    if WB.UI.RelayoutObjectives then WB.UI.RelayoutObjectives() end
end

local function FmtNumber(n)
    n = n or 0
    if BreakUpLargeNumbers then return BreakUpLargeNumbers(n) end
    return tostring(n)
end

local function EllipsizeText(fs, text, maxWidth)
    text = tostring(text or "")
    fs:SetText(text)
    if not maxWidth or maxWidth <= 0 or fs:GetStringWidth() <= maxWidth then return text end

    local suffix = "..."
    local lo, hi, best = 0, #text, suffix
    while lo <= hi do
        local mid = math.floor((lo + hi) / 2)
        local candidate = text:sub(1, mid) .. suffix
        fs:SetText(candidate)
        if fs:GetStringWidth() <= maxWidth then
            best = candidate
            lo = mid + 1
        else
            hi = mid - 1
        end
    end
    fs:SetText(best)
    return best
end

-- Compute the player's rank from total war credit and drive the Service Record's rank
-- icon/text + the "Progress to Next Rank" bar.
function WB.UI.RefreshRank()
    local rec = WB.UI.serviceRecord
    if not rec then return end
    -- Rank is lifetime WC. Prefer the synced "service record" floor (survives a fresh install /
    -- the windowed sync); fall back to the live ledger total if the roster isn't up yet.
    local wc    = (WB.Roster and WB.Roster.MyLifetimeWC and WB.Roster.MyLifetimeWC())
                  or (WB.WarEffort and WB.WarEffort.GetWarCredit and WB.WarEffort.GetWarCredit()) or 0
    local rank  = RankFromWC(wc)
    local names = RANK_NAMES[WB.faction] or RANK_NAMES.Horde

    if rec.icon then
        if rank >= 1 then rec.icon:SetAtlas("honorsystem-icon-prestige-" .. rank)
        else              rec.icon:SetAtlas(rec.badge) end
        rec.icon:SetSize(RANK_ICON_SIZE, RANK_ICON_SIZE)
    end
    if rec.rank then rec.rank:SetText(names[rank] or "No Rank") end

    local p = WB.UI.rankProgress
    if not p then return end
    local function ApplyFill(frac)
        p.bar.frac = frac
        local bw = (p.bar:GetWidth() or 0) - 2
        if bw < 1 then bw = 166 end   -- fallback before layout; OnSizeChanged corrects it
        p.fill:SetWidth(math.max(1, frac * bw))
    end
    local nextT = RANK_THRESHOLDS[rank + 1]
    if nextT then
        p.title:SetText("Progress to Next Rank: " .. (names[rank + 1] or ("Rank " .. (rank + 1))))
        p.icon:SetAtlas("honorsystem-icon-prestige-" .. (rank + 1))
        p.icon:Show()
        local frac = math.min(1, wc / nextT)
        ApplyFill(frac)
        p.text:SetText(("%s / %s WC   \226\128\148   %d%%"):format(FmtNumber(wc), FmtNumber(nextT), math.floor(frac * 100 + 0.5)))
    else
        p.title:SetText("Max Rank: " .. (names[3] or "Sergeant"))
        p.icon:SetAtlas("honorsystem-icon-prestige-3"); p.icon:Show()
        ApplyFill(1)
        p.text:SetText(("%s WC earned"):format(FmtNumber(wc)))
    end
end

-- War Effort tug of war: Horde war credit vs Alliance war credit. The divider
-- sits at Horde's share of the combined total (50/50 with no data); each side's
-- contribution is shown below the bar.
function WB.UI.RefreshWarEffort()
    local p = WB.UI.warEffortProgress
    if not p then return end
    local fwc = WB.Ledger and WB.Ledger.FactionWarCredit
    local hordeWC = (fwc and fwc("Horde"))    or 0
    local allyWC  = (fwc and fwc("Alliance")) or 0
    if p.hordeVal    then p.hordeVal:SetText(FmtNumber(hordeWC) .. " WC") end
    if p.allianceVal then p.allianceVal:SetText(FmtNumber(allyWC) .. " WC") end

    local total = hordeWC + allyWC
    local frac  = (total > 0) and (hordeWC / total) or 0.5   -- Horde's share of the bar
    local w = (p.barBg and p.barBg:GetWidth()) or 0
    if w > 0 and p.hordeFill and p.allianceFill then
        p.hordeFill:SetWidth(math.max(0.01, w * frac))
        p.allianceFill:SetWidth(math.max(0.01, w * (1 - frac)))
    end
end

-- Absolute date/time + relative "ago" for an activity record's timestamp (local time).
local function FmtActivityTime(t)
    t = tonumber(t)
    if not t or t <= 0 then return nil end
    local rel, ago = time() - t, nil
    if     rel < 60    then ago = "just now"
    elseif rel < 3600  then ago = math.floor(rel / 60)    .. "m ago"
    elseif rel < 86400 then ago = math.floor(rel / 3600)  .. "h ago"
    else                    ago = math.floor(rel / 86400) .. "d ago" end
    return date("%b %d, %I:%M %p", t) .. "  (" .. ago .. ")"
end

-- Compact "Xd Yh Zm" duration (drops leading zero units).
local function FmtDHM(secs)
    secs = math.max(0, math.floor(tonumber(secs) or 0))
    local d = math.floor(secs / 86400)
    local h = math.floor((secs % 86400) / 3600)
    local m = math.floor((secs % 3600) / 60)
    if d > 0 then return string.format("%dd %dh %dm", d, h, m) end
    if h > 0 then return string.format("%dh %dm", h, m) end
    return string.format("%dm", m)
end

-- Campaign Status: the active-warfront title card + the "Campaign ends in" clock,
-- plus the Leader Board's reset countdown. All driven by the WB.Campaign module.
function WB.UI.RefreshCampaign()
    if not WB.Campaign then return end
    local c = WB.UI.campaign
    if c then
        if c.name then c.name:SetText(WB.Campaign.ActiveWarfront() or "Unknown") end
        if c.art then
            local tex = WB.Campaign.ActiveWarfrontTexture()
            if tex then c.art:SetTexture(tex); c.art:Show() else c.art:Hide() end
        end
        -- While the just-ended campaign finalizes, the bar/board show that settling
        -- campaign — the countdown reflects the lock timer, not the next end.
        local finalizing = WB.Campaign.IsFinalizing and WB.Campaign.IsFinalizing()
        if c.endsLabel and c.endsValue then
            if finalizing then
                c.endsLabel:SetText("Reconciliation:")
                c.endsValue:SetText(FmtDHM(WB.Campaign.SecondsUntilFinalized()))
            else
                c.endsLabel:SetText("Campaign ends in:")
                c.endsValue:SetText(FmtDHM(WB.Campaign.SecondsUntilEnd()))
            end
        end
        if c.endsRow and c.endsLabel and c.endsValue then
            local w = (c.endsLabel:GetStringWidth() or 0) + 4 + 18 + 3 + (c.endsValue:GetStringWidth() or 0)
            c.endsRow:SetWidth(w)
        end
    end
    if WB.UI.lbResetFS then
        WB.UI.lbResetFS:SetText("Leader Board resets in: " .. FmtDHM(WB.Campaign.SecondsUntilEnd()))
    end
end

-- Render the Leader Board from the synchronized ledger: per-player war credit /
-- gold / kills aggregated across all activity records, sorted by war credit, plus
-- the local player's own standing for the footer.
function WB.UI.RefreshLeaderboard()
    local pane = WB.UI.leaderboardPane
    if not pane or not pane.SetLeaderboard then return end
    local list = (WB.Ledger and WB.Ledger.Leaderboard and WB.Ledger.Leaderboard()) or {}
    -- Rank by the active tab's metric (War Credit by default, Kills on the Kills tab),
    -- with the other metric then name as tie-breakers.
    local byKills = (pane.mode == "kills")
    table.sort(list, function(a, b)
        local av = byKills and (a.kills or 0) or (a.warCredit or 0)
        local bv = byKills and (b.kills or 0) or (b.warCredit or 0)
        if av ~= bv then return av > bv end
        local a2 = byKills and (a.warCredit or 0) or (a.kills or 0)
        local b2 = byKills and (b.warCredit or 0) or (b.kills or 0)
        if a2 ~= b2 then return a2 > b2 end
        return (a.name or "") < (b.name or "")
    end)
    local standing
    if WB.Ledger and WB.Ledger.MyIdentity then
        local myName, myRealm, myGuid = WB.Ledger.MyIdentity()
        for i, e in ipairs(list) do
            local isMe = (myGuid and e.guid == myGuid)
                      or (e.name == myName and (e.realm or "") == (myRealm or ""))
            if isMe then
                standing = { rank = i, kills = e.kills, warCredit = e.warCredit }
                break
            end
        end
    end
    pane:SetLeaderboard(list, standing)
end

-- Builds the Leader Board pane: TOP CONTRIBUTORS table (# / Name / Kills / War
-- Credit) over a "your standing" footer + reset countdown. Data is injected via
-- pane:SetLeaderboard(entries) / WB.UI.SetLeaderboard(entries) — seeded with
-- placeholder standings for now (real per-faction tally comes later).
local function BuildLeaderboardPane(pane)
    if pane.placeholder then pane.placeholder:Hide() end

    -- The board mixes both factions, so per-row art/colour comes from each entry's
    -- own faction rather than the viewer's.
    local function FactionSymbol(faction)
        return (faction == "Alliance") and "AllianceSymbol" or "HordeSymbol"
    end

    -- Rank icon to the left of a name: prestige badge for ranks 1-3, otherwise the
    -- faction quest-type icon for "no rank" (rank 0).
    local function RankIcon(rank, faction)
        rank = tonumber(rank) or 0
        if rank >= 1 and rank <= 3 then
            return "honorsystem-icon-prestige-" .. rank
        end
        return (faction == "Alliance") and "questlog-questtypeicon-alliance" or "questlog-questtypeicon-horde"
    end

    -- Shared column offsets — header labels and row cells use identical anchors
    -- (both span the same width) so the columns line up.
    local X_RANK, X_CREST, X_NAME    = 6, 28, 54
    local R_KILLS, R_WCNUM, R_WCSYM  = -132, -38, -10

    -- ── Tabs: War Credit | Kills (centered pills) ───────────────────────────────
    local function MakePill(text)
        local b = CreateFrame("Button", nil, pane, "BackdropTemplate")
        b:SetSize(92, 19)
        b:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 10,
            insets   = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        b.label = b:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        b.label:SetPoint("CENTER")
        b.label:SetText(text)
        return b
    end
    local pillWC = MakePill("War Credit")
    local pillK  = MakePill("Kills")
    pillWC:SetPoint("TOPRIGHT", pane, "TOP", -2, -3)
    pillK:SetPoint("TOPLEFT",   pane, "TOP",  2, -3)

    -- Title + subtitle (text flips per tab; set in ApplyMode).
    local title = pane:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\FRIZQT__.TTF", 15, "OUTLINE")
    title:SetPoint("TOP", pane, "TOP", 0, -26)
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    local subtitle = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    subtitle:SetPoint("TOP", title, "BOTTOM", 0, -2)
    subtitle:SetJustifyH("CENTER")
    subtitle:SetTextColor(0.74, 0.70, 0.60)

    -- Column header (# / Name / value). The value column label flips per tab.
    local head = CreateFrame("Frame", nil, pane)
    head:SetPoint("TOPLEFT",  pane, "TOPLEFT",  12, -62)
    head:SetPoint("TOPRIGHT", pane, "TOPRIGHT", -12, -62)
    head:SetHeight(16)
    local function HeaderLabel(text, justify, point, x)
        local fs = head:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        fs:SetText(text)
        fs:SetTextColor(0.82, 0.80, 0.72)
        fs:SetJustifyH(justify)
        fs:SetPoint(point, head, point, x, 0)
        return fs
    end
    HeaderLabel("#",    "LEFT",  "LEFT",  X_RANK)
    HeaderLabel("Name", "LEFT",  "LEFT",  X_NAME)
    local valueHeader = HeaderLabel("War Credit", "RIGHT", "RIGHT", R_WCSYM)

    local hLine = HLine(pane)
    hLine:SetPoint("TOPLEFT",  head, "BOTTOMLEFT",  0, -3)
    hLine:SetPoint("TOPRIGHT", head, "BOTTOMRIGHT", 0, -3)

    -- Tab styling + switching.
    local function UpdatePills()
        local kills = (pane.mode == "kills")
        local function style(b, on)
            if on then
                b:SetBackdropColor(0.42, 0.11, 0.09, 0.95)
                b:SetBackdropBorderColor(0.95, 0.70, 0.35, 1)
                b.label:SetTextColor(1, 0.92, 0.72)
            else
                b:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
                b:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.9)
                b.label:SetTextColor(0.68, 0.64, 0.54)
            end
        end
        style(pillWC, not kills)
        style(pillK,  kills)
    end
    local function ApplyMode()
        UpdatePills()
        if pane.mode == "kills" then
            title:SetText("TOP KILLERS")
            subtitle:SetText("Players who have earned the most kills this campaign")
            valueHeader:SetText("Kills")
        else
            title:SetText("TOP CONTRIBUTORS")
            subtitle:SetText("Players who have earned the most War Credit this campaign")
            valueHeader:SetText("War Credit")
        end
        if WB.UI.RefreshLeaderboard then WB.UI.RefreshLeaderboard() end
    end
    pillWC:SetScript("OnClick", function() pane.mode = "wc";    ApplyMode() end)
    pillK:SetScript("OnClick",  function() pane.mode = "kills"; ApplyMode() end)
    pane.mode = pane.mode or "wc"

    -- Footer (bottom-up): reset countdown, "your standing" row. (The Community button now
    -- lives in the War Board's top-left corner — built in the frame builder below.)
    -- The Leader Board resets at each campaign end (not weekly). RefreshCampaign
    -- keeps this text live via WB.UI.lbResetFS.
    local resetFS = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    resetFS:SetPoint("BOTTOM", pane, "BOTTOM", 0, 8)
    resetFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    WB.UI.lbResetFS = resetFS
    local csecs = WB.Campaign and WB.Campaign.SecondsUntilEnd and WB.Campaign.SecondsUntilEnd()
    resetFS:SetText("Leader Board resets in: " .. FmtDHM(csecs))

    local mine = CreateFrame("Frame", nil, pane)
    mine:SetPoint("BOTTOM", resetFS, "TOP",   0, 8)
    mine:SetPoint("LEFT",   pane,    "LEFT",  12, 0)
    mine:SetPoint("RIGHT",  pane,    "RIGHT", -12, 0)
    mine:SetHeight(20)
    local mineLine = HLine(pane)
    mineLine:SetPoint("BOTTOMLEFT",  mine, "TOPLEFT",  0, 5)
    mineLine:SetPoint("BOTTOMRIGHT", mine, "TOPRIGHT", 0, 5)

    local function LabeledText(label, value)
        return string.format("|cffc8b48c%s|r |cffffffff%s|r", label, value)
    end
    local myRank = mine:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    myRank:SetPoint("LEFT", mine, "LEFT", X_RANK, 0)
    local myKills = mine:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    myKills:SetPoint("CENTER", mine, "CENTER", 0, 0)
    local myWC = mine:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    myWC:SetPoint("RIGHT", mine, "RIGHT", R_WCSYM, 0)

    -- Scroll area between the header rule and the footer rule. The board can list more
    -- players than fit on screen, so the rows live on a content child taller than the
    -- viewport and the mouse wheel scrolls them (bare scroll, mouse-wheel only — same
    -- pattern as the Intelligence Feed pane).
    local ROW_H  = 30
    local scroll = CreateFrame("ScrollFrame", nil, pane)
    scroll:SetPoint("TOPLEFT",  hLine,    "BOTTOMLEFT",  0, -4)
    scroll:SetPoint("TOPRIGHT", hLine,    "BOTTOMRIGHT", 0, -4)
    scroll:SetPoint("BOTTOM",   mineLine, "TOP",         0, 4)
    scroll:EnableMouseWheel(true)
    scroll:SetClipsChildren(true)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, w) content:SetWidth(w) end)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local maxScroll = self:GetVerticalScrollRange() or 0
        local nv = self:GetVerticalScroll() - delta * ROW_H
        self:SetVerticalScroll(math.max(0, math.min(nv, maxScroll)))
    end)

    local rows  = {}
    local function GetRow(i)
        if rows[i] then return rows[i] end
        local r = CreateFrame("Frame", nil, content)
        r:SetHeight(ROW_H)
        r:SetPoint("TOPLEFT",  content, "TOPLEFT",  0, -(i - 1) * ROW_H)
        r:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -(i - 1) * ROW_H)

        local bg = r:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints(r)
        bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.025 or 0.05)

        r.rank = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.rank:SetPoint("LEFT", r, "LEFT", X_RANK, 0)
        r.rank:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

        -- Rank icon (set per-entry in SetLeaderboard); a touch larger than the
        -- old crest. Falls back to the faction symbol for unranked players.
        r.rankIcon = r:CreateTexture(nil, "ARTWORK")
        r.rankIcon:SetSize(20, 20)
        r.rankIcon:SetPoint("LEFT", r, "LEFT", X_CREST, 0)

        r.name = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.name:SetPoint("LEFT", r, "LEFT", X_NAME, 0)  -- colour set per-entry (faction)
        r.nameHit = MakeNameHover(r)
        r.nameHit:SetAllPoints(r.name)

        -- Kills cell lives at the right edge (shown only on the Kills tab, where it's
        -- the value column); the War Credit number/symbol occupy the same area on the WC tab.
        r.kills = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.kills:SetPoint("RIGHT", r, "RIGHT", R_WCSYM, 0)
        r.kills:SetJustifyH("RIGHT")
        r.kills:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

        r.wc = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.wc:SetPoint("RIGHT", r, "RIGHT", R_WCNUM, 0)
        r.wc:SetJustifyH("RIGHT")
        r.wc:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

        r.wcSym = r:CreateTexture(nil, "ARTWORK")  -- atlas set per-entry (faction)
        r.wcSym:SetSize(16, 16)
        r.wcSym:SetPoint("RIGHT", r, "RIGHT", R_WCSYM, 0)

        rows[i] = r
        return r
    end

    function pane:SetLeaderboard(entries, standing)
        entries = entries or {}
        for _, r in ipairs(rows) do r:Hide() end
        local kills = (pane.mode == "kills")
        local shown = math.min(#entries, 100)   -- scrollable, but cap the board at the top 100
        for i = 1, shown do
            local e = entries[i]
            local r  = GetRow(i)
            local nc = FACTION_NAME_COLOR[e.faction] or FACTION_NAME_COLOR.Horde
            r.rank:SetText(tostring(i))
            r.rankIcon:SetAtlas(RankIcon(e.rank, e.faction))
            r.name:SetText(SafeName(e.name, 32) or "?")
            r.name:SetTextColor(nc[1], nc[2], nc[3])
            r.nameHit._fullName = FullPlayerName(e.name, e.realm)
            if kills then
                r.kills:SetText(FmtNumber(e.kills)); r.kills:Show()
                r.wc:Hide(); r.wcSym:Hide()
            else
                r.wc:SetText(FmtNumber(e.warCredit)); r.wc:Show()
                r.wcSym:SetAtlas(FactionSymbol(e.faction)); r.wcSym:Show()
                r.kills:Hide()
            end
            r:Show()
        end
        content:SetHeight(math.max(1, shown * ROW_H))
        standing = standing or {}
        myRank:SetText(LabeledText("Your Rank:",  standing.rank      and tostring(standing.rank) or "\226\128\148"))
        myKills:SetText(LabeledText("Kills:",      FmtNumber(standing.kills)))
        myWC:SetText(LabeledText("War Credit:",    FmtNumber(standing.warCredit)))
    end

    WB.UI.leaderboardPane = pane
    WB.UI.SetLeaderboard  = function(entries, standing) pane:SetLeaderboard(entries, standing) end

    -- Apply the initial tab (TOP CONTRIBUTORS / War Credit) + render from the ledger.
    ApplyMode()
end

-- Builds the Activity pane: RECENT ACTIVITY feed (Time / Player / Objective /
-- Reward) with a green "completed" mark at the left and a Show More pager. Data
-- comes from WB.WarEffort.GetActivity(); re-render via WB.UI.RefreshActivity().
local function BuildActivityPane(pane)
    if pane.placeholder then pane.placeholder:Hide() end
    pane.shownCount = 10

    -- The feed mixes both factions, so each row's war-credit crest reflects that entry's OWN
    -- faction (set per-entry in RenderActivity). viewerSym is just the initial atlas a row is
    -- created with, before it's bound to an entry.
    local function FactionSymbol(faction)
        return (faction == "Alliance") and "AllianceSymbol" or "HordeSymbol"
    end
    local viewerSym = FactionSymbol(WB.faction)

    -- Column offsets (header labels + row cells share these). The entry timestamp
    -- stays in the data for ordering/verification but is not displayed.
    local X_CREST, X_NAME, X_OBJ = 44, 64, 160
    local OBJ_RIGHT = -132  -- objective text right bound, clear of the reward cluster

    -- Title
    local title = pane:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\FRIZQT__.TTF", 15, "OUTLINE")
    title:SetPoint("TOP", pane, "TOP", 0, -2)
    title:SetText("RECENT ACTIVITY")
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    -- Column header
    local head = CreateFrame("Frame", nil, pane)
    head:SetPoint("TOPLEFT",  pane, "TOPLEFT",  12, -30)
    head:SetPoint("TOPRIGHT", pane, "TOPRIGHT", -12, -30)
    head:SetHeight(16)
    local function HeaderLabel(text, justify, point, x)
        local fs = head:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        fs:SetText(text)
        fs:SetTextColor(0.82, 0.80, 0.72)
        fs:SetJustifyH(justify)
        fs:SetPoint(point, head, point, x, 0)
        return fs
    end
    HeaderLabel("Player", "LEFT",  "LEFT",  X_CREST)
    HeaderLabel("Event",  "LEFT",  "LEFT",  X_OBJ)
    HeaderLabel("Reward / Details", "RIGHT", "RIGHT", -8)

    local hLine = HLine(pane)
    hLine:SetPoint("TOPLEFT",  head, "BOTTOMLEFT",  0, -3)
    hLine:SetPoint("TOPRIGHT", head, "BOTTOMRIGHT", 0, -3)

    -- Show More button (bottom)
    local moreBtn = CreateFrame("Button", nil, pane, "BackdropTemplate")
    moreBtn:SetHeight(24)
    moreBtn:SetPoint("BOTTOMLEFT",  pane, "BOTTOMLEFT",  40, 4)
    moreBtn:SetPoint("BOTTOMRIGHT", pane, "BOTTOMRIGHT", -40, 4)
    moreBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    moreBtn:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
    moreBtn:SetBackdropBorderColor(0.40, 0.34, 0.22, 0.85)
    local moreLabel = moreBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    moreLabel:SetPoint("CENTER")
    moreLabel:SetText("Show More")
    moreLabel:SetTextColor(0.85, 0.80, 0.66)
    moreBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.70, 0.55, 0.32, 1) end)
    moreBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.40, 0.34, 0.22, 0.85) end)

    -- Scrollable row area (bare scroll, mouse-wheel only — keeps the full width).
    local ROW_H  = 44
    local scroll = CreateFrame("ScrollFrame", nil, pane)
    scroll:SetPoint("TOPLEFT",  hLine,   "BOTTOMLEFT",  0, -4)
    scroll:SetPoint("TOPRIGHT", hLine,   "BOTTOMRIGHT", 0, -4)
    scroll:SetPoint("BOTTOM",   moreBtn, "TOP",         0, 4)
    scroll:EnableMouseWheel(true)
    scroll:SetClipsChildren(true)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, w) content:SetWidth(w) end)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local maxScroll = self:GetVerticalScrollRange() or 0
        local nv = self:GetVerticalScroll() - delta * ROW_H
        self:SetVerticalScroll(math.max(0, math.min(nv, maxScroll)))
    end)

    local emptyFS = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    emptyFS:SetPoint("TOP", scroll, "TOP", 0, -16)
    emptyFS:SetText("No recent activity yet.")
    emptyFS:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    emptyFS:Hide()

    local rows = {}
    local function GetRow(i)
        if rows[i] then return rows[i] end
        local r = CreateFrame("Frame", nil, content)
        r:SetHeight(ROW_H)
        r:SetPoint("TOPLEFT",  content, "TOPLEFT",  0, -(i - 1) * ROW_H)
        r:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -(i - 1) * ROW_H)

        local bg = r:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints(r)
        bg:SetColorTexture(1, 1, 1, (i % 2 == 0) and 0.025 or 0.05)

        -- Green "completed" mark (same banner + check as the objective cards).
        r.flag = r:CreateTexture(nil, "OVERLAY", nil, 1)
        r.flag:SetAtlas("housing-dashboard-activity-listitem-flag", true)
        r.flag:SetPoint("LEFT", r, "LEFT", 4, 0)
        r.check = r:CreateTexture(nil, "OVERLAY", nil, 2)
        r.check:SetAtlas("housing-dashboard-small-checkmark", true)
        r.check:SetPoint("CENTER", r.flag, "CENTER", 0, 0)

        r.crest = r:CreateTexture(nil, "ARTWORK")  -- player faction; set per-entry
        r.crest:SetSize(16, 16)
        r.crest:SetPoint("LEFT", r, "LEFT", X_CREST, 0)

        r.name = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.name:SetPoint("LEFT", r, "LEFT", X_NAME, 0)
        r.nameHit = MakeNameHover(r)
        r.nameHit:SetAllPoints(r.name)

        r.obj = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.obj:SetPoint("LEFT",  r, "LEFT",  X_OBJ, 0)
        r.obj:SetPoint("RIGHT", r, "RIGHT", OBJ_RIGHT, 0)
        r.obj:SetJustifyH("LEFT")
        r.obj:SetWordWrap(false)
        r.obj:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

        -- Hover region over the objective column: shows the full (untruncated)
        -- name and whether it was a repeat completion.
        r.objHit = CreateFrame("Frame", nil, r)
        r.objHit:SetPoint("TOPLEFT",     r, "TOPLEFT",     X_OBJ, 0)
        r.objHit:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", OBJ_RIGHT, 0)
        r.objHit:EnableMouse(true)
        r.objHit:SetScript("OnEnter", function(self)
            if not r.objFull then return end
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(r.objFull, GOLD[1], GOLD[2], GOLD[3])
            if r.objRepeat then
                GameTooltip:AddLine("Repeat completion", 0.70, 0.70, 0.70)
            end
            local ts = FmtActivityTime(r.objTime)
            if ts then GameTooltip:AddLine(ts, 0.58, 0.58, 0.58) end
            GameTooltip:Show()
        end)
        r.objHit:SetScript("OnLeave", function() GameTooltip:Hide() end)

        -- Reward cluster (right → left): wc crest, wc number, coin, gold number.
        r.wcSym = r:CreateTexture(nil, "ARTWORK")
        r.wcSym:SetAtlas(viewerSym)
        r.wcSym:SetSize(16, 16)
        r.wcSym:SetPoint("RIGHT", r, "RIGHT", -8, 0)
        r.wc = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.wc:SetPoint("RIGHT", r.wcSym, "LEFT", -3, 0)
        r.wc:SetJustifyH("RIGHT")
        r.wc:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
        r.coin = r:CreateTexture(nil, "ARTWORK")
        SetWarBoardCoinIcon(r.coin, 32)
        r.coin:SetPoint("RIGHT", r.wc, "LEFT", -8, 0)
        r.gold = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.gold:SetPoint("RIGHT", r.coin, "LEFT", -3, 0)
        r.gold:SetJustifyH("RIGHT")
        r.gold:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

        rows[i] = r
        return r
    end

    function pane:RenderActivity()
        local data  = (WB.WarEffort and WB.WarEffort.GetActivity and WB.WarEffort.GetActivity()) or {}
        local total = #data
        local show  = math.min(self.shownCount or 10, total)

        for _, r in ipairs(rows) do r:Hide() end
        for i = 1, show do
            local e  = data[i]
            local r  = GetRow(i)
            local nc = FACTION_NAME_COLOR[e.faction] or FACTION_NAME_COLOR.Horde
            local atype = e.atype or "objective"
            local isObjective = (atype == "objective")

            r.crest:SetAtlas((e.faction == "Alliance") and "questlog-questtypeicon-alliance" or "questlog-questtypeicon-horde")
            r.name:SetText(SafeName(e.name, 32) or "?")
            r.name:SetTextColor(nc[1], nc[2], nc[3])
            r.nameHit._fullName = FullPlayerName(e.name, e.realm)

            -- Green completed-flag only for objective completions.
            r.flag:SetShown(isObjective)
            r.check:SetShown(isObjective)

            -- Event column text + colour. The title (e.obj) can originate on another
            -- player's client, so sanitize it before display. Records from a newer War
            -- Board version we couldn't fully verify are flagged "(unverified)".
            local objText    = (WB.SanitizeDisplay and WB.SanitizeDisplay(e.obj, 80)) or e.obj
            local unverified = (e.st == "U")
            local eventText, ecol
            if atype == "bounty_cost" then
                eventText = "Placed a bounty on " .. (objText or "an enemy") .. "."
                ecol = { 0.90, 0.58, 0.50 }
            elseif atype == "bounty_reward" or atype == "bounty_claim" then
                eventText = "Claimed bounty on " .. (objText or "an enemy") .. "."
                ecol = { 0.55, 0.85, 0.52 }
            elseif isObjective then
                eventText = (objText or "a War Effort objective") .. (e.isRepeat and " (Repeat)" or "")
                ecol = GOLD
            else
                -- An activity type this version doesn't recognize (newer War Board) —
                -- show it generically rather than mislabel it as an objective.
                eventText = objText or "Recorded war activity"
                ecol = GOLD
            end
            if unverified then eventText = eventText .. "  |cff8a8a8a(unverified)|r" end
            r.obj:SetText(eventText)
            r.obj:SetTextColor(ecol[1], ecol[2], ecol[3])
            r.objFull   = eventText
            r.objRepeat = isObjective and e.isRepeat or false
            r.objTime   = e.t

            -- Reward / details: signed gold (red when spent) + war credit; zero parts hidden.
            local gold = tonumber(e.gold) or 0
            local wc   = tonumber(e.warCredit) or 0
            if wc ~= 0 then
                r.wc:SetText((wc < 0 and "-" or "+") .. FmtNumber(math.abs(wc)))
                r.wcSym:SetAtlas(FactionSymbol(e.faction))   -- the ACTOR's faction, not the viewer's
                r.wcSym:Show(); r.wc:Show()
                r.coin:ClearAllPoints(); r.coin:SetPoint("RIGHT", r.wc, "LEFT", -8, 0)
            else
                r.wcSym:Hide(); r.wc:Hide()
                r.coin:ClearAllPoints(); r.coin:SetPoint("RIGHT", r, "RIGHT", -8, 0)
            end
            if gold ~= 0 then
                local neg = gold < 0
                r.gold:SetText((neg and "-" or "+") .. FmtNumber(math.abs(gold)))
                r.gold:SetTextColor(neg and 0.92 or VALUE_COLOR[1], neg and 0.40 or VALUE_COLOR[2], neg and 0.38 or VALUE_COLOR[3])
                r.gold:Show(); r.coin:Show()
            else
                r.gold:Hide(); r.coin:Hide()
            end

            r:Show()
        end

        content:SetHeight(math.max(1, show * ROW_H))
        emptyFS:SetShown(total == 0)
        moreBtn:SetShown(total > show)
    end

    moreBtn:SetScript("OnClick", function()
        pane.shownCount = (pane.shownCount or 10) + 10
        pane:RenderActivity()
    end)

    WB.UI.activityPane    = pane
    WB.UI.RefreshActivity = function() pane:RenderActivity() end
    pane:RenderActivity()
end

-- Builds the Bounties pane: top posting form + bottom available-bounties list.
-- Data hooks for the future bounty/comm module:
--   WB.UI.SetBountyCandidates({ { name, level, race, class, guild, faction, rank, portrait } })
--   WB.UI.SetAvailableBounties({ { targetName, targetLevel, targetRace, targetClass, targetGuild,
--                                  targetFaction, rank, posterName, posterFaction,
--                                  rewardGold, rewardWarCredit, postedAt, message } })
local function BuildBountiesPane(pane)
    if pane.placeholder then pane.placeholder:Hide() end

    local function FactionSymbol(faction)
        return (faction == "Alliance") and "AllianceSymbol" or "HordeSymbol"
    end

    local function NameColor(faction)
        return FACTION_NAME_COLOR[faction] or VALUE_COLOR
    end

    local function RankIcon(rank, faction)
        rank = tonumber(rank) or 0
        if rank >= 1 and rank <= 3 then
            return "honorsystem-icon-prestige-" .. rank
        end
        -- No rank → faction quest-type icon.
        return (faction == "Alliance") and "questlog-questtypeicon-alliance" or "questlog-questtypeicon-horde"
    end

    -- Bounty cost/reward scale by the target's rank (0-3). The rank TITLE is
    -- faction-specific (RANK_NAMES); rank 0 = "No Rank".
    -- Cost/reward tiers live in WB.Bounties.TIERS so the Verifier's deep audit charges
    -- from the SAME table the Post panel does (a mismatch would quarantine real bounties).
    local function RankInfo(rank, faction)
        local r = tonumber(rank) or 0
        local t = (WB.Bounties and WB.Bounties.RankTier and WB.Bounties.RankTier(r))
                  or { cost = 500, gold = 400, wc = 200 }
        local names = RANK_NAMES[faction] or RANK_NAMES.Horde
        return (names[r] or "No Rank"), t.cost, t.gold, t.wc
    end

    -- Faction lookup by race file token (for filtering community members).
    local RACE_FACTION = {
        human = "Alliance", dwarf = "Alliance", nightelf = "Alliance", gnome = "Alliance",
        draenei = "Alliance", worgen = "Alliance", voidelf = "Alliance", lightforgeddraenei = "Alliance",
        darkironwarf = "Alliance", darkirondwarf = "Alliance", kultiran = "Alliance", kultiranhuman = "Alliance", mechagnome = "Alliance",
        orc = "Horde", scourge = "Horde", undead = "Horde", tauren = "Horde", troll = "Horde",
        bloodelf = "Horde", goblin = "Horde", nightborne = "Horde", highmountaintauren = "Horde",
        magharorc = "Horde", zandalaritroll = "Horde", vulpera = "Horde",
    }
    -- API race-file token → char-create atlas token, for allied/special races whose
    -- atlas name differs from the race file (e.g. "HighmountainTauren" → "highmountain").
    local RACE_ATLAS_ALT = {
        scourge            = "undead",
        highmountaintauren = "highmountain",
        lightforgeddraenei = "lightforged",
        darkirondwarf      = "darkiron",
        magharorc          = "maghar",
        zandalaritroll     = "zandalari",
        earthendwarf       = "earthen",
        kultiranhuman      = "kultiran",
        harronir           = "haranir",   -- race file is "Harronir", atlas is "haranir"
    }

    -- Race icon from the character-create atlas: raceicon128-<race>-<sex>. Builds a
    -- list of candidate tokens (explicit mapping, the raw token, and allied-race
    -- suffix stripping), prefers one C_Texture validates, but FALLS BACK to the best
    -- guess so brand-new races still show even when GetAtlasInfo doesn't know them yet.
    local function RaceIcon(raceFile, sex)
        if not raceFile or raceFile == "" then return nil end
        local s  = (sex == 3) and "female" or "male"
        local rl = raceFile:lower():gsub("[^%a]", "")

        local candidates = {}
        local function add(tok) if tok and tok ~= "" then candidates[#candidates + 1] = tok end end
        add(RACE_ATLAS_ALT[rl])   -- explicit mapping wins
        add(rl)                   -- direct match (covers most core races)
        for _, suffix in ipairs({ "tauren", "draenei", "dwarf", "orc", "troll", "gnome", "human" }) do
            if #rl > #suffix and rl:sub(-#suffix) == suffix then add(rl:sub(1, #rl - #suffix)) end
        end

        local getInfo = C_Texture and C_Texture.GetAtlasInfo
        local best
        for _, token in ipairs(candidates) do
            local name = "raceicon128-" .. token .. "-" .. s
            best = best or name
            if not getInfo or getInfo(name) then return name end
        end
        return best
    end

    -- Paint the circular race-icon portrait; transparent (black backing) if unknown.
    local function SetPortrait(tex, entry)
        tex:SetTexCoord(0, 1, 0, 1)
        local atlas = entry and RaceIcon(entry.raceFile, entry.sex)
        tex:SetAtlas(atlas or "charactercreate-gendericon-male")   -- generic silhouette when empty
    end

    local function CreateRedButton(parent, text)
        local b = CreateFrame("Button", nil, parent, "BackdropTemplate")
        b:SetHeight(26)
        b:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 12,
            insets   = { left = 3, right = 3, top = 3, bottom = 3 },
        })
        b:SetBackdropColor(0.48, 0.03, 0.02, 0.94)
        b:SetBackdropBorderColor(0.95, 0.55, 0.25, 0.95)
        b.label = b:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        b.label:SetPoint("CENTER")
        b.label:SetText(text)
        b.label:SetTextColor(1, 0.88, 0.66)
        b:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(1.0, 0.80, 0.38, 1) end)
        b:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.95, 0.55, 0.25, 0.95) end)
        return b
    end

    -- Shared with the Active Bounties cards (BuildRightColumn).
    WB.UI.bountyHelpers = {
        SetPortrait = SetPortrait, RaceIcon = RaceIcon, RankIcon = RankIcon, RankInfo = RankInfo,
        FactionSymbol = FactionSymbol, NameColor = NameColor, CreateRedButton = CreateRedButton,
    }

    local post = CreatePanel(pane)
    post:SetPoint("TOPLEFT", pane, "TOPLEFT", 0, 0)
    post:SetPoint("TOPRIGHT", pane, "TOPRIGHT", 0, 0)
    post:SetHeight(288)

    local title = SectionHeader(post, "POST A BOUNTY")
    title:SetPoint("TOPLEFT", post, "TOPLEFT", 12, -10)

    local desc = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    desc:SetPoint("TOPLEFT", title, "BOTTOMLEFT", 6, -6)
    desc:SetPoint("RIGHT", post, "RIGHT", -14, 0)
    desc:SetJustifyH("CENTER")
    desc:SetText("Place a bounty on the head of your enemies. The cost to post and the reward for claiming are based on the target's rank.")
    desc:SetTextColor(0.82, 0.78, 0.68)
    desc:SetWordWrap(true)

    -- Circular target portrait, framed by the HUD ring; black when empty. Click it
    -- to use your current in-game target.
    local PORT = 54
    local portFrame = CreateFrame("Button", nil, post)
    portFrame:SetSize(PORT, PORT)
    portFrame:SetPoint("TOPLEFT", desc, "BOTTOMLEFT", -2, -14)

    local portBG = portFrame:CreateTexture(nil, "BACKGROUND")
    portBG:SetAllPoints(portFrame)
    portBG:SetColorTexture(0, 0, 0, 1)

    local portrait = portFrame:CreateTexture(nil, "ARTWORK")
    portrait:SetAllPoints(portFrame)

    local portMask = portFrame:CreateMaskTexture()
    portMask:SetAllPoints(portFrame)
    portMask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
    portrait:AddMaskTexture(portMask)
    portBG:AddMaskTexture(portMask)

    local portRing = portFrame:CreateTexture(nil, "OVERLAY")
    portRing:SetAtlas("hud-PlayerFrame-portraitring-large", false)
    portRing:SetSize(PORT + 22, PORT + 22)
    portRing:SetPoint("CENTER", portFrame, "CENTER", 0, 0)

    local targetName = post:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    targetName:SetPoint("TOPLEFT", portFrame, "TOPRIGHT", 18, -2)
    targetName:SetText("No target selected")
    targetName:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    local targetInfo = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    targetInfo:SetPoint("TOPLEFT", targetName, "BOTTOMLEFT", 0, -4)
    targetInfo:SetText("Level --  Race  Class")
    targetInfo:SetTextColor(0.82, 0.78, 0.68)

    local targetGuild = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    targetGuild:SetPoint("TOPLEFT", targetInfo, "BOTTOMLEFT", 0, -4)
    targetGuild:SetText("<No guild>")
    targetGuild:SetTextColor(0.25, 0.85, 0.25)

    -- Rank line, inline beneath the guild (icon + tier name).
    local rankIcon = post:CreateTexture(nil, "ARTWORK")
    rankIcon:SetSize(16, 16)
    rankIcon:SetPoint("TOPLEFT", targetGuild, "BOTTOMLEFT", 0, -5)
    rankIcon:SetAtlas(FactionSymbol(WB.faction))
    local rankText = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rankText:SetPoint("LEFT", rankIcon, "RIGHT", 5, 0)
    rankText:SetText("Rank: |cffffffff--|r")
    rankText:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    -- "Pick a Player" button (where the rank block used to be): opposite-faction
    -- War Board community members.
    local pickBtn = CreateFrame("Button", nil, post, "BackdropTemplate")
    pickBtn:SetPoint("TOPLEFT", portFrame, "BOTTOMLEFT", -2, -16)
    pickBtn:SetSize(150, 24)
    pickBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    pickBtn:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
    pickBtn:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.95)
    pickBtn.label = pickBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    pickBtn.label:SetPoint("CENTER")
    pickBtn.label:SetText("Pick a Player")
    pickBtn.label:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    pickBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.90, 0.70, 0.35, 1) end)
    pickBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.95) end)

    -- Cost / reward box: Posting Cost over a divider over Reward; values right-aligned.
    local costBox = CreateFrame("Frame", nil, post, "BackdropTemplate")
    costBox:SetPoint("TOPLEFT", pickBtn, "BOTTOMLEFT", 0, -8)
    costBox:SetSize(216, 54)
    costBox:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    costBox:SetBackdropColor(0, 0, 0, 0.20)
    costBox:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.65)

    local costText = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    costText:SetPoint("TOPLEFT", costBox, "TOPLEFT", 8, -8)
    costText:SetText("Posting Cost:")
    costText:SetTextColor(0.82, 0.78, 0.68)
    local costCoin = costBox:CreateTexture(nil, "ARTWORK")
    SetWarBoardCoinIcon(costCoin, 32)
    costCoin:SetPoint("TOPRIGHT", costBox, "TOPRIGHT", -8, 3)
    local costValue = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    costValue:SetPoint("RIGHT", costCoin, "LEFT", -4, 0)
    costValue:SetText("--")
    costValue:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    local costDivider = costBox:CreateTexture(nil, "ARTWORK")
    costDivider:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.18)
    costDivider:SetHeight(1)
    costDivider:SetPoint("LEFT",  costBox, "LEFT",  8, 0)
    costDivider:SetPoint("RIGHT", costBox, "RIGHT", -8, 0)
    costDivider:SetPoint("TOP",   costBox, "TOP",   0, -28)

    local rewardText = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rewardText:SetPoint("BOTTOMLEFT", costBox, "BOTTOMLEFT", 8, 8)
    rewardText:SetText("Reward:")
    rewardText:SetTextColor(0.82, 0.78, 0.68)
    local rewardSym = costBox:CreateTexture(nil, "ARTWORK")
    rewardSym:SetAtlas(FactionSymbol(WB.faction))
    rewardSym:SetSize(16, 16)
    rewardSym:SetPoint("BOTTOMRIGHT", costBox, "BOTTOMRIGHT", -8, 7)
    local rewardWC = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rewardWC:SetPoint("RIGHT", rewardSym, "LEFT", -4, 0)
    rewardWC:SetText("--")
    rewardWC:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
    local rewardCoin = costBox:CreateTexture(nil, "ARTWORK")
    SetWarBoardCoinIcon(rewardCoin, 32)
    rewardCoin:SetPoint("RIGHT", rewardWC, "LEFT", -10, 0)
    local rewardGold = costBox:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rewardGold:SetPoint("RIGHT", rewardCoin, "LEFT", -4, 0)
    rewardGold:SetText("--")
    rewardGold:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

    local messageLabel = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    messageLabel:SetPoint("TOPLEFT", post, "TOPLEFT", 244, -62)
    messageLabel:SetText("Message (Optional)")
    messageLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    -- POST BOUNTY pinned to the bottom; the count sits just above it; the message
    -- box fills everything between the label and the count (top→bottom anchored, so
    -- it's as tall as the space allows regardless of SetHeight).
    local postBtn = CreateRedButton(post, "POST BOUNTY")
    postBtn:SetHeight(26)
    postBtn:SetPoint("BOTTOMLEFT", post, "BOTTOMLEFT", 244, 14)
    postBtn:SetPoint("RIGHT",      post, "RIGHT",      -14, 0)

    local count = post:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    count:SetPoint("BOTTOMLEFT", postBtn, "TOPLEFT", 0, 6)
    count:SetText("0 / 120")
    count:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    local messageBox = CreateFrame("EditBox", nil, post, "BackdropTemplate")
    messageBox:SetPoint("TOPLEFT", messageLabel, "BOTTOMLEFT", 0, -6)
    messageBox:SetPoint("RIGHT",   post,  "RIGHT", -14, 0)
    messageBox:SetPoint("BOTTOM",  count, "TOP",   0, 4)
    messageBox:SetAutoFocus(false)
    messageBox:SetMultiLine(true)
    messageBox:SetMaxLetters(120)
    messageBox:SetFontObject("GameFontNormalSmall")
    messageBox:SetTextInsets(6, 6, 6, 6)
    messageBox:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    messageBox:SetBackdropColor(0, 0, 0, 0.45)
    messageBox:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.75)
    messageBox:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    messageBox:SetScript("OnTabPressed",    function(self) self:ClearFocus() end)
    messageBox:SetScript("OnTextChanged", function(self)
        count:SetText((#(self:GetText() or "")) .. " / 120")
    end)

    local selector = CreatePanel(pane)
    selector:SetFrameLevel(post:GetFrameLevel() + 20)
    selector:SetPoint("TOPLEFT", pickBtn, "BOTTOMLEFT", 0, -2)
    selector:SetSize(260, 300)
    selector:Hide()

    local selectedTarget
    local candidateRows = {}
    local RenderSelector            -- forward declaration (searchBox references it)
    local SEL_ROW_H = 42

    -- Accent/diacritic → plain ASCII, so typing "Woopzy" matches "Wôopzy" (the same
    -- normalization JARVIS uses for its history search).
    local accentMap = {
        ["À"]="a",["Á"]="a",["Â"]="a",["Ã"]="a",["Ä"]="a",["Å"]="a",["Æ"]="ae",["ß"]="b",
        ["Ç"]="c",["È"]="e",["É"]="e",["Ê"]="e",["Ë"]="e",
        ["Ì"]="i",["Í"]="i",["Î"]="i",["Ï"]="i",["Ð"]="d",["Ñ"]="n",
        ["Ò"]="o",["Ó"]="o",["Ô"]="o",["Õ"]="o",["Ö"]="o",["Ø"]="o",
        ["Ù"]="u",["Ú"]="u",["Û"]="u",["Ü"]="u",["Ý"]="y",["Þ"]="th",
        ["à"]="a",["á"]="a",["â"]="a",["ã"]="a",["ä"]="a",["å"]="a",["æ"]="ae",
        ["ç"]="c",["è"]="e",["é"]="e",["ê"]="e",["ë"]="e",
        ["ì"]="i",["í"]="i",["î"]="i",["ï"]="i",["ð"]="d",["ñ"]="n",
        ["ò"]="o",["ó"]="o",["ô"]="o",["õ"]="o",["ö"]="o",["ø"]="o",
        ["ù"]="u",["ú"]="u",["û"]="u",["ü"]="u",["ý"]="y",["þ"]="th",["ÿ"]="y",
    }
    local function NormalizeForSearch(str)
        if not str or str == "" then return "" end
        for accent, plain in pairs(accentMap) do str = str:gsub(accent, plain) end
        return str:lower()
    end

    -- Search box at the top of the picker.
    local searchText = ""
    local searchBox = CreateFrame("EditBox", nil, selector, "BackdropTemplate")
    searchBox:SetPoint("TOPLEFT",  selector, "TOPLEFT",  8, -8)
    searchBox:SetPoint("TOPRIGHT", selector, "TOPRIGHT", -8, -8)
    searchBox:SetHeight(22)
    searchBox:SetAutoFocus(false)
    searchBox:SetFontObject("GameFontHighlightSmall")
    searchBox:SetMaxLetters(50)
    searchBox:SetTextInsets(22, 6, 0, 0)
    searchBox:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Buttons\\WHITE8x8",
        edgeSize = 1,
    })
    searchBox:SetBackdropColor(0, 0, 0, 0.5)
    searchBox:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.85)
    local searchIcon = searchBox:CreateTexture(nil, "OVERLAY")
    searchIcon:SetSize(14, 14)
    searchIcon:SetPoint("LEFT", searchBox, "LEFT", 5, 0)
    searchIcon:SetTexture("Interface\\Common\\UI-Searchbox-Icon")
    searchIcon:SetVertexColor(0.70, 0.65, 0.52)
    local searchHint = searchBox:CreateFontString(nil, "ARTWORK", "GameFontDisableSmall")
    searchHint:SetPoint("LEFT", searchBox, "LEFT", 22, 0)
    searchHint:SetText("Search players...")
    searchBox:SetScript("OnEscapePressed", function(self) self:SetText(""); self:ClearFocus() end)
    searchBox:SetScript("OnEnterPressed",  function(self) self:ClearFocus() end)
    searchBox:SetScript("OnTextChanged", function(self)
        local t = self:GetText() or ""
        searchHint:SetShown(t == "")
        searchText = t
        if RenderSelector then RenderSelector() end
    end)

    -- Scrollable list below the search box (UIPanelScrollFrameTemplate; scrollbar lives
    -- in the right gutter — do NOT SetClipsChildren, that hides the scrollbar).
    local selScroll = CreateFrame("ScrollFrame", nil, selector, "UIPanelScrollFrameTemplate")
    selScroll:SetPoint("TOPLEFT",     searchBox, "BOTTOMLEFT",  0, -6)
    selScroll:SetPoint("BOTTOMRIGHT", selector,  "BOTTOMRIGHT", -26, 8)
    local selContent = CreateFrame("Frame", nil, selScroll)
    selContent:SetSize(1, 1)
    selScroll:SetScrollChild(selContent)
    selScroll:SetScript("OnSizeChanged", function(self, w) selContent:SetWidth(w) end)
    selScroll:EnableMouseWheel(true)
    selScroll:SetScript("OnMouseWheel", function(self, delta)
        local nv   = self:GetVerticalScroll() - (delta * SEL_ROW_H)
        local maxS = self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0, math.min(nv, maxS)))
    end)

    local selEmpty = selector:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    selEmpty:SetPoint("TOP", selScroll, "TOP", -8, -16)
    selEmpty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    selEmpty:Hide()

    -- Set the guild line, shrinking the font if it would clip into the message box.
    local function SetGuild(text)
        targetGuild:SetFont("Fonts\\FRIZQT__.TTF", 10, "")
        targetGuild:SetText(text)
        local size = 10
        while size > 7 and targetGuild:GetStringWidth() > 158 do
            size = size - 1
            targetGuild:SetFont("Fonts\\FRIZQT__.TTF", size, "")
        end
    end

    local function SetTarget(entry)
        selectedTarget = entry
        if not entry then
            SetPortrait(portrait, nil)
            targetName:SetText("No target selected")
            targetName:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
            targetInfo:SetText("Level --  Race  Class")
            SetGuild("<No guild>")
            rankIcon:Hide()   -- no rank icon until a target is picked
            rankText:SetText("Rank: |cffffffff--|r")
            costValue:SetText("--")
            rewardGold:SetText("--")
            rewardWC:SetText("--")
            return
        end
        local nc = NameColor(entry.faction)
        local rankName, cost, rgold, rwc = RankInfo(entry.rank, entry.faction)
        SetPortrait(portrait, entry)
        targetName:SetText(SafeName(entry.name, 32) or "?")
        targetName:SetTextColor(nc[1], nc[2], nc[3])
        local raceName  = LocalizeRace(entry.raceFile, entry.race)
        local className = LocalizeClass(entry.classToken, entry.class)
        targetInfo:SetText(("Level %s  %s  %s"):format(tostring(entry.level or "--"), raceName, className))
        SetGuild(entry.guild and ("<" .. entry.guild .. ">") or "<No guild>")
        rankIcon:Show()
        rankIcon:SetAtlas(RankIcon(entry.rank, entry.faction))
        rankText:SetText("Rank: |cffffffff" .. rankName .. "|r")
        costValue:SetText(FmtNumber(cost))
        rewardGold:SetText(FmtNumber(rgold))
        rewardWC:SetText(FmtNumber(rwc))
    end

    -- Opposite-faction War Board players to bounty. Primary source is the synced
    -- roster directory (WB.Roster — reliable, includes offline users who've shared
    -- their profile); supplemented by the live club roster for anyone not yet in it.
    -- ►► FUTURE: bounties on players NOT in the War Board community ◄◄
    -- Flip this to true (and add a UI affordance) to also offer outsiders. They would be flagged
    -- candidate.outsideCommunity = true and would NOT get rank / location tracking — the bounty
    -- becomes a name-only "wanted" notice. OFF for now: outsiders are simply hidden, so this whole
    -- branch is currently a no-op framework hook.
    local ALLOW_NON_COMMUNITY = false

    local function GetCommunityTargets()
        local myFaction = WB.faction or UnitFactionGroup("player") or "Horde"
        local enemy = (myFaction == "Alliance") and "Horde" or "Alliance"
        local out, seen = {}, {}

        -- Community (club) membership gate. The roster directory persists profiles received over
        -- sync AND free channels (and never prunes), so it can hold ex-members / outsiders. Only
        -- players in the current club roster are eligible to be bountied (rank + location tracking
        -- require it). If the club's member list isn't ready yet, we don't filter (avoid showing an
        -- empty picker) — it tightens up once the roster populates.
        local memberGuids, memberNames, membersReady
        if WB.Community and WB.Community.GetMemberSet then
            memberGuids, memberNames, membersReady = WB.Community.GetMemberSet()
        end
        local function InCommunity(p)
            if not membersReady then return true end
            if p.guid and memberGuids[p.guid] then return true end
            local key = WB.Community and WB.Community.NameKey and WB.Community.NameKey(p.name, p.realm)
            if key and memberNames[key] then return true end
            return false
        end

        if WB.Roster and WB.Roster.GetByFaction then
            for _, p in ipairs(WB.Roster.GetByFaction(enemy)) do
                if p.guid and not seen[p.guid] then
                    local member = InCommunity(p)
                    if member or ALLOW_NON_COMMUNITY then
                        seen[p.guid] = true
                        out[#out + 1] = {
                            name = p.name, realm = p.realm, level = p.level, race = p.race or p.raceFile, raceFile = p.raceFile,
                            sex = p.sex, class = p.class, classToken = p.classToken, guild = p.guild,
                            faction = p.faction, guid = p.guid, rank = 0,
                            outsideCommunity = (not member) or nil,   -- framework flag (unused while filtered out)
                        }
                    end
                end
            end
        end

        local clubId = WB.Community and WB.Community.CLUB_ID
        if clubId and C_Club and C_Club.GetClubMembers then
            local ok, members = pcall(C_Club.GetClubMembers, clubId)
            if ok and type(members) == "table" then
                local ids = {}
                for i = 1, #members do ids[i] = members[i] end
                for _, mid in ipairs(ids) do
                    local ok2, info = pcall(C_Club.GetMemberInfo, clubId, mid)
                    if ok2 and info and info.name and not info.isSelf and info.guid and not seen[info.guid] then
                        local lclass, eclass, lrace, erace, sex, _pname, prealm = GetPlayerInfoByGUID(info.guid)
                        local raceFile, raceName, faction = erace, lrace, nil
                        if raceFile then faction = RACE_FACTION[raceFile:lower():gsub("[^%a]", "")] end
                        if (not faction) and info.race and C_CreatureInfo then
                            local ri = C_CreatureInfo.GetRaceInfo(info.race)
                            if ri then raceFile, raceName = ri.clientFileString, ri.raceName end
                            local fi = C_CreatureInfo.GetFactionInfo(info.race)
                            if fi then faction = fi.groupTag end
                        end
                        if faction == enemy then
                            seen[info.guid] = true
                            out[#out + 1] = {
                                name = Ambiguate and Ambiguate(info.name, "short") or info.name,
                                realm = (prealm and prealm ~= "" and prealm) or GetNormalizedRealmName(),
                                level = info.level, race = raceName, raceFile = raceFile, sex = sex,
                                class = lclass, classToken = eclass, faction = faction, guid = info.guid, rank = 0,
                            }
                        end
                    end
                end
            end
        end
        table.sort(out, function(a, b) return (a.name or "") < (b.name or "") end)
        return out
    end

    local function CandidateList()
        if WB.Bounties and WB.Bounties.GetCandidates then
            return WB.Bounties.GetCandidates() or {}
        end
        return GetCommunityTargets()
    end

    -- (forward-declared above) Filter the candidate list by the search box, then render
    -- each match as a two-line row (circular race icon + name over Level/Race/Class) in
    -- the scrollable child.
    function RenderSelector()
        for _, row in ipairs(candidateRows) do row:Hide() end

        local all = CandidateList()
        local list, q = all, NormalizeForSearch(searchText)
        if q ~= "" then
            list = {}
            for _, e in ipairs(all) do
                if string.find(NormalizeForSearch(e.name or ""),  q, 1, true)
                or string.find(NormalizeForSearch(e.realm or ""), q, 1, true) then
                    list[#list + 1] = e
                end
            end
        end

        if #list == 0 then
            selEmpty:SetText((searchText ~= "") and "No players match your search."
                or "No opposite-faction targets found.")
            selEmpty:Show()
            selContent:SetHeight(1)
            return
        end
        selEmpty:Hide()

        for i = 1, #list do
            local row = candidateRows[i]
            if not row then
                row = CreateFrame("Button", nil, selContent, "BackdropTemplate")
                row:SetHeight(SEL_ROW_H - 4)
                row:SetPoint("TOPLEFT", selContent, "TOPLEFT", 2, -((i - 1) * SEL_ROW_H) - 2)
                row:SetPoint("RIGHT",   selContent, "RIGHT",   -2, 0)
                row:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8" })
                row:SetBackdropColor(1, 1, 1, 0.04)
                row:SetScript("OnEnter", function(self)
                    self:SetBackdropColor(1, 1, 1, 0.12)
                    if self._fullName then
                        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                        GameTooltip:SetText(self._fullName, 1, 0.95, 0.8)
                        GameTooltip:Show()
                    end
                end)
                row:SetScript("OnLeave", function(self)
                    self:SetBackdropColor(1, 1, 1, 0.04)
                    GameTooltip:Hide()
                end)
                -- circular race icon
                row.icon = row:CreateTexture(nil, "ARTWORK")
                row.icon:SetSize(30, 30)
                row.icon:SetPoint("LEFT", row, "LEFT", 4, 0)
                -- name (top line)
                row.text = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
                row.text:SetPoint("BOTTOMLEFT", row.icon, "RIGHT", 8, 2)
                row.text:SetPoint("RIGHT", row, "RIGHT", -6, 0)
                row.text:SetJustifyH("LEFT")
                -- Level / Race / Class (second line)
                row.sub = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
                row.sub:SetPoint("TOPLEFT", row.icon, "RIGHT", 8, -2)
                row.sub:SetPoint("RIGHT", row, "RIGHT", -6, 0)
                row.sub:SetJustifyH("LEFT")
                row.sub:SetTextColor(0.62, 0.59, 0.52)
                candidateRows[i] = row
            end
            local entry = list[i]
            local nc = NameColor(entry.faction)
            SetPortrait(row.icon, entry)
            row.text:SetText(SafeName(entry.name, 32) or "?")
            row.text:SetTextColor(nc[1], nc[2], nc[3])
            local raceName  = LocalizeRace(entry.raceFile, entry.race)
            local className = LocalizeClass(entry.classToken, entry.class)
            row.sub:SetText(SafeName(("Level %s, %s, %s"):format(tostring(entry.level or "--"), raceName, className), 60) or "")
            row._fullName = FullPlayerName(entry.name, entry.realm)
            row:SetScript("OnClick", function()
                SetTarget(entry)
                searchBox:SetText("")
                searchBox:ClearFocus()
                selector:Hide()
            end)
            row:Show()
        end
        selContent:SetHeight(math.max(1, (#list * SEL_ROW_H) + 4))
    end

    -- "Pick a Player" opens the community-member list.
    pickBtn:SetScript("OnClick", function()
        if selector:IsShown() then
            selector:Hide()
        else
            RenderSelector()
            selector:Show()
        end
    end)

    postBtn:SetScript("OnClick", function()
        if not selectedTarget then
            print(WB.TAG.." pick a player first.")
            return
        end
        if WB.Bounties and WB.Bounties.Post then
            local _, cost, rgold, rwc = RankInfo(selectedTarget.rank, selectedTarget.faction)
            local posted, reason = WB.Bounties.Post(selectedTarget, messageBox:GetText() or "", cost, rgold, rwc)
            if posted then
                print((WB.TAG.." bounty posted on %s (-%d gold)."):format(selectedTarget.name or "?", cost or 0))
                messageBox:SetText("")
                SetTarget(nil)
            elseif reason == "duplicate" then
                print((WB.TAG.." you already have a bounty on %s."):format(selectedTarget.name or "?"))
            elseif reason == "broke" then
                print((WB.TAG.." not enough gold \226\128\148 a bounty on %s costs %d gold."):format(selectedTarget.name or "?", cost or 0))
            elseif reason == "cooldown" then
                local left = (WB.Bounties.CooldownRemaining and WB.Bounties.CooldownRemaining(selectedTarget.guid)) or 0
                local hrs  = math.max(1, math.ceil(left / 3600))
                print((WB.TAG.." %s was recently claimed \226\128\148 can't be bountied again for ~%dh."):format(selectedTarget.name or "?", hrs))
            elseif reason == "unknownrank" then
                print((WB.TAG.." %s is a rank your War Board doesn't recognize \226\128\148 update to post a bounty on them."):format(selectedTarget.name or "?"))
            else
                print(WB.TAG.." couldn't post that bounty.")
            end
        end
    end)

    local available = CreatePanel(pane)
    available:SetPoint("TOPLEFT", post, "BOTTOMLEFT", 0, -8)
    available:SetPoint("BOTTOMRIGHT", pane, "BOTTOMRIGHT", 0, 0)

    local availTitle = SectionHeader(available, "AVAILABLE BOUNTIES")
    availTitle:SetPoint("TOPLEFT", available, "TOPLEFT", 12, -10)

    local ROW_H = 82
    -- Same scroll setup as the War Effort list (UIPanelScrollFrameTemplate, whose
    -- scrollbar lives in the right gutter). Note: do NOT SetClipsChildren — that
    -- clips the template's scrollbar out of view.
    local scroll = CreateFrame("ScrollFrame", nil, available, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", available, "TOPLEFT", 10, -40)
    scroll:SetPoint("BOTTOMRIGHT", available, "BOTTOMRIGHT", -28, 10)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, w) content:SetWidth(w) end)
    scroll:EnableMouseWheel(true)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local nextScroll = self:GetVerticalScroll() - (delta * ROW_H)
        local maxScroll = self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0, math.min(nextScroll, maxScroll)))
    end)

    local empty = available:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    empty:SetPoint("CENTER", scroll, "CENTER", 0, 0)
    empty:SetText("No available bounties yet.")
    empty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    local bountyRows = {}
    local function AvailableList()
        if WB.Bounties and WB.Bounties.GetAvailable then
            return WB.Bounties.GetAvailable() or {}
        end
        return pane.availableBounties or {}
    end

    local function AgoText(t)
        local d = math.max(0, time() - (t or 0))
        if d < 3600  then return math.floor(d / 60) .. "m ago" end
        if d < 86400 then return math.floor(d / 3600) .. "h ago" end
        return math.floor(d / 86400) .. "d ago"
    end

    local function GetBountyRow(i)
        if bountyRows[i] then return bountyRows[i] end
        local r = CreateFrame("Frame", nil, content, "BackdropTemplate")
        r:SetHeight(ROW_H - 6)
        r:SetPoint("TOPLEFT", content, "TOPLEFT", 0, -((i - 1) * ROW_H))
        r:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -((i - 1) * ROW_H))
        r:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Buttons\\WHITE8x8",
            edgeSize = 1,
        })
        r:SetBackdropColor(0, 0, 0, 0.18)
        r:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.55)

        local CPORT = 36
        local pframe = CreateFrame("Frame", nil, r)
        pframe:SetSize(CPORT, CPORT)
        pframe:SetPoint("LEFT", r, "LEFT", 8, 0)
        local pbg = pframe:CreateTexture(nil, "BACKGROUND")
        pbg:SetAllPoints(pframe)
        pbg:SetColorTexture(0, 0, 0, 1)
        r.portrait = pframe:CreateTexture(nil, "ARTWORK")
        r.portrait:SetAllPoints(pframe)
        local pmask = pframe:CreateMaskTexture()
        pmask:SetAllPoints(pframe)
        pmask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        r.portrait:AddMaskTexture(pmask)
        pbg:AddMaskTexture(pmask)
        local pring = pframe:CreateTexture(nil, "OVERLAY")
        pring:SetAtlas("hud-PlayerFrame-portraitring-large", false)
        pring:SetSize(CPORT + 16, CPORT + 16)
        pring:SetPoint("CENTER", pframe, "CENTER", 0, 0)

        -- Left: name / level-race-class / guild / rank (rank under the guild),
        -- anchored to the row top so the block sits high and clears the bottom edge.
        r.name = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.name:SetPoint("TOPLEFT", r, "TOPLEFT", 56, -8)
        r.name:SetWidth(136)
        r.name:SetJustifyH("LEFT")
        r.name:SetWordWrap(false)
        r.nameHit = MakeNameHover(r)
        r.nameHit:SetAllPoints(r.name)
        r.info = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.info:SetPoint("TOPLEFT", r.name, "BOTTOMLEFT", 0, -2)
        r.info:SetWidth(136)
        r.info:SetJustifyH("LEFT")
        r.info:SetWordWrap(false)
        r.info:SetTextColor(0.82, 0.78, 0.68)
        r.guild = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.guild:SetPoint("TOPLEFT", r.info, "BOTTOMLEFT", 0, -2)
        r.guild:SetWidth(136)
        r.guild:SetJustifyH("LEFT")
        r.guild:SetWordWrap(false)
        r.guild:SetTextColor(0.25, 0.85, 0.25)
        r.rankIcon = r:CreateTexture(nil, "ARTWORK")
        r.rankIcon:SetSize(14, 14)
        r.rankIcon:SetPoint("TOPLEFT", r.guild, "BOTTOMLEFT", 0, -3)
        r.rank = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.rank:SetPoint("LEFT", r.rankIcon, "RIGHT", 4, 0)
        r.rank:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

        -- Middle: "Posted by:" / name / "Posted:" / time, each on its own line so
        -- it stays narrow and clear of the reward column.
        r.postedByLabel = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.postedByLabel:SetPoint("TOPLEFT", r, "TOPLEFT", 204, -7)
        r.postedByLabel:SetText("Posted by:")
        r.postedByLabel:SetTextColor(0.72, 0.69, 0.60)
        r.poster = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.poster:SetPoint("TOPLEFT", r.postedByLabel, "BOTTOMLEFT", 0, -1)
        r.postedLabel = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.postedLabel:SetPoint("TOPLEFT", r.poster, "BOTTOMLEFT", 0, -3)
        r.postedLabel:SetText("Posted:")
        r.postedLabel:SetTextColor(0.72, 0.69, 0.60)
        r.postedAgo = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.postedAgo:SetPoint("TOPLEFT", r.postedLabel, "BOTTOMLEFT", 0, -1)
        r.postedAgo:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

        -- Invisible hover region over the "Posted by" block: when several posters
        -- share a target the box just shows a count, and this tooltip lists them all.
        r.posterHover = CreateFrame("Frame", nil, r)
        r.posterHover:SetPoint("TOPLEFT", r.postedByLabel, "TOPLEFT", -2, 3)
        r.posterHover:SetPoint("BOTTOM", r.postedAgo, "BOTTOM", 0, -3)
        r.posterHover:SetWidth(100)
        r.posterHover:EnableMouse(true)
        r.posterHover:SetScript("OnEnter", function(self)
            local posters = r._posters
            if not posters or #posters == 0 then return end
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:AddLine(#posters > 1 and ("Posted by " .. #posters .. " players") or "Posted by")
            for _, p in ipairs(posters) do
                local pc = NameColor(p.faction or WB.faction)
                GameTooltip:AddDoubleLine(FullPlayerName(p.name, p.realm), p.postedAt and AgoText(p.postedAt) or "",
                    pc[1], pc[2], pc[3], 0.65, 0.65, 0.65)
                if p.message then
                    GameTooltip:AddLine("  \"" .. (SafeName(p.message, 160) or "") .. "\"", 0.7, 0.7, 0.55, true)
                end
            end
            GameTooltip:Show()
        end)
        r.posterHover:SetScript("OnLeave", function() GameTooltip:Hide() end)

        -- Right: reward (top) + accept (bottom).
        r.rewardLabel = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.rewardLabel:SetPoint("TOPRIGHT", r, "TOPRIGHT", -10, -8)
        r.rewardLabel:SetText("Reward:")
        r.rewardLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
        r.sym = r:CreateTexture(nil, "ARTWORK")
        r.sym:SetSize(14, 14)
        r.sym:SetPoint("TOPRIGHT", r.rewardLabel, "BOTTOMRIGHT", 0, -4)
        r.wc = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.wc:SetPoint("RIGHT", r.sym, "LEFT", -3, 0)
        r.wc:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
        r.coin = r:CreateTexture(nil, "ARTWORK")
        SetWarBoardCoinIcon(r.coin, 30)
        r.coin:SetPoint("RIGHT", r.wc, "LEFT", -6, 0)
        r.gold = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.gold:SetPoint("RIGHT", r.coin, "LEFT", -3, 0)
        r.gold:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])

        r.accept = CreateRedButton(r, "ACCEPT")
        r.accept:SetSize(80, 22)
        r.accept:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", -10, 7)
        -- Shown in place of the ACCEPT button once you've taken the bounty up.
        r.acceptedLabel = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.acceptedLabel:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", -14, 12)
        r.acceptedLabel:SetText("Accepted")
        r.acceptedLabel:SetTextColor(0.45, 0.85, 0.45)
        r.acceptedLabel:Hide()

        bountyRows[i] = r
        return r
    end

    function pane:RenderBounties()
        local list = AvailableList()
        for _, r in ipairs(bountyRows) do r:Hide() end
        for i, b in ipairs(list) do
            local r = GetBountyRow(i)
            local nc = NameColor(b.targetFaction)
            local rankName = select(1, RankInfo(b.rank, b.targetFaction))
            SetPortrait(r.portrait, { raceFile = b.raceFile, sex = b.sex })
            r.name:SetText(SafeName(b.targetName, 32) or "?")
            r.name:SetTextColor(nc[1], nc[2], nc[3])
            r.nameHit._fullName = FullPlayerName(b.targetName, b.targetRealm)
            r.info:SetText(SafeName(("Level %s  %s  %s"):format(tostring(b.targetLevel or "--"), LocalizeRace(b.raceFile, b.targetRace), LocalizeClass(b.classToken, b.targetClass)), 60) or "")
            EllipsizeText(r.guild, SafeName(b.targetGuild and ("<" .. b.targetGuild .. ">"), 28) or "<No guild>", 136)
            r.rankIcon:SetAtlas(RankIcon(b.rank, b.targetFaction))
            r.rank:SetText("Rank: " .. rankName)
            r.rank:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

            -- One poster shows their name; several show a count with the hover list.
            local posters = b.posters or {}
            r._posters = posters
            local np = #posters
            if np > 1 then
                r.poster:SetText("|cffffd200" .. np .. " players|r")
            elseif np == 1 then
                local p = posters[1]
                local pc = NameColor(p.faction or WB.faction)
                r.poster:SetText("|cff" .. ("%02x%02x%02x"):format(pc[1]*255, pc[2]*255, pc[3]*255) .. (p.name or "?") .. "|r")
            else
                r.poster:SetText("--")
            end
            r.postedAgo:SetText(b.postedAt and AgoText(b.postedAt) or "--")

            r.gold:SetText(FmtNumber(b.rewardGold))
            r.wc:SetText(FmtNumber(b.rewardWarCredit))
            r.sym:SetAtlas(FactionSymbol(b.targetFaction))

            local mine     = WB.Bounties and WB.Bounties.IPostedOn and WB.Bounties.IPostedOn(b.targetGuid)
            local accepted = WB.Bounties and WB.Bounties.IsAccepted and WB.Bounties.IsAccepted(b)
            if mine then
                -- You can't take your own contract — no accept button.
                r.accept:Hide()
                r.acceptedLabel:SetText("Your bounty")
                r.acceptedLabel:SetTextColor(0.85, 0.72, 0.40)
                r.acceptedLabel:Show()
            elseif accepted then
                r.accept:Hide()
                r.acceptedLabel:SetText("Accepted")
                r.acceptedLabel:SetTextColor(0.45, 0.85, 0.45)
                r.acceptedLabel:Show()
            else
                r.accept:Show()
                r.acceptedLabel:Hide()
            end
            r.accept:SetScript("OnClick", function()
                if WB.Bounties and WB.Bounties.Accept then
                    local ok, reason = WB.Bounties.Accept(b)
                    if not ok and reason == "limit" then
                        print((WB.TAG.." you can only have %d active bounties \226\128\148 abandon one first.")
                            :format((WB.Bounties.MaxActive and WB.Bounties.MaxActive()) or 3))
                    elseif not ok and reason == "ownbounty" then
                        print(WB.TAG.." you can't accept a bounty you placed.")
                    end
                end
            end)
            r:Show()
        end
        content:SetHeight(math.max(1, #list * ROW_H))
        empty:SetShown(#list == 0)
    end

    function WB.UI.SetBountyCandidates(list)
        pane.bountyCandidates = list or {}
        if selector:IsShown() then RenderSelector() end
    end
    function WB.UI.SetAvailableBounties(list)
        pane.availableBounties = list or {}
        pane:RenderBounties()
    end
    WB.UI.bountiesPane = pane
    WB.UI.RefreshBounties = function() pane:RenderBounties() end
    -- Called by the roster when a new profile arrives, so an open picker refreshes.
    WB.UI.RefreshBountyPicker = function() if selector:IsShown() then RenderSelector() end end

    SetTarget(nil)
    pane:RenderBounties()
end

-- Builds the Alerts pane: a military-style intelligence feed. Each row shows a
-- coloured category/status chip, a headline, a detail line, and a "Last Seen"
-- stamp. Data comes from WB.Alerts; re-render via WB.UI.RefreshAlerts().
local function BuildAlertsPane(pane)
    if pane.placeholder then pane.placeholder:Hide() end

    -- Title
    local title = pane:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\FRIZQT__.TTF", 15, "OUTLINE")
    title:SetPoint("TOP", pane, "TOP", 0, -2)
    title:SetText("INTELLIGENCE FEED")
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local hLine = HLine(pane)
    hLine:SetPoint("TOPLEFT",  pane, "TOPLEFT",  12, -26)
    hLine:SetPoint("TOPRIGHT", pane, "TOPRIGHT", -12, -26)

    -- Scroll area (bare scroll, mouse-wheel only).
    local ROW_H  = 76
    local scroll = CreateFrame("ScrollFrame", nil, pane)
    scroll:SetPoint("TOPLEFT",     hLine, "BOTTOMLEFT",  0, -4)
    scroll:SetPoint("TOPRIGHT",    hLine, "BOTTOMRIGHT", 0, -4)
    scroll:SetPoint("BOTTOM",      pane,  "BOTTOM",      0, 6)
    scroll:EnableMouseWheel(true)
    scroll:SetClipsChildren(true)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, w) content:SetWidth(w) end)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local maxScroll = self:GetVerticalScrollRange() or 0
        local nv = self:GetVerticalScroll() - delta * ROW_H
        self:SetVerticalScroll(math.max(0, math.min(nv, maxScroll)))
    end)

    local emptyFS = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    emptyFS:SetPoint("TOP", scroll, "TOP", 0, -16)
    emptyFS:SetText("No active intel. The front is quiet.")
    emptyFS:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    emptyFS:Hide()

    local rows = {}
    local function GetRow(i)
        if rows[i] then return rows[i] end
        local r = CreateFrame("Frame", nil, content)
        r:SetHeight(ROW_H)
        r:SetPoint("TOPLEFT",  content, "TOPLEFT",  0, -(i - 1) * ROW_H)
        r:SetPoint("TOPRIGHT", content, "TOPRIGHT", 0, -(i - 1) * ROW_H)

        -- Parchment background — each alert reads like a torn intel note.
        local bg = r:CreateTexture(nil, "BACKGROUND")
        bg:SetPoint("TOPLEFT", r, "TOPLEFT", 0, -2)
        bg:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", 0, 4)
        bg:SetAtlas("TalkingHeads-Neutral-TextBackground")

        -- Left accent bar (category colour), inset so it sits on the paper.
        r.accent = r:CreateTexture(nil, "ARTWORK")
        r.accent:SetPoint("TOPLEFT",    r, "TOPLEFT", 8, -16)
        r.accent:SetPoint("BOTTOMLEFT", r, "BOTTOMLEFT", 8, 18)
        r.accent:SetWidth(3)

        -- Status / category chip — a small bevelled plate (dark fill, tan border).
        r.chip = CreateFrame("Frame", nil, r, "BackdropTemplate")
        r.chip:SetPoint("TOPLEFT", r, "TOPLEFT", 12, -14)
        r.chip:SetHeight(19)
        r.chip:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 10,
            insets   = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        r.chipText = r.chip:CreateFontString(nil, "OVERLAY")
        r.chipText:SetPoint("CENTER", r.chip, "CENTER", 0, 0)
        r.chipText:SetFont(STANDARD_TEXT_FONT, 11, "OUTLINE")
        r.chipText:SetTextColor(1, 1, 1)

        -- Headline (decorative serif, auto-width so a divider can follow it).
        r.title = r:CreateFontString(nil, "OVERLAY")
        r.title:SetFont("Fonts\\MORPHEUS.TTF", 12, "")
        r.title:SetPoint("LEFT", r.chip, "RIGHT", 9, 1)
        r.title:SetWordWrap(false)
        r.title:SetJustifyH("LEFT")

        -- "Last Seen" stamp (top-right).
        r.time = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.time:SetPoint("TOPRIGHT", r, "TOPRIGHT", -18, -16)
        r.time:SetJustifyH("RIGHT")
        r.time:SetTextColor(0.40, 0.28, 0.14)  -- dark, for the parchment

        -- Decorative divider filling the gap between the headline and the stamp.
        r.divider = r:CreateTexture(nil, "ARTWORK")
        r.divider:SetColorTexture(0.50, 0.38, 0.18, 0.55)
        r.divider:SetHeight(2)
        r.divider:SetPoint("LEFT",  r.title, "RIGHT", 12, 0)
        r.divider:SetPoint("RIGHT", r.time,  "LEFT", -14, 0)

        -- Detail line (wraps to 2 lines, with a little breathing room between them).
        r.body = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.body:SetPoint("TOPLEFT",  r, "TOPLEFT", 14, -36)
        r.body:SetPoint("RIGHT",    r, "RIGHT",  -10, 0)
        r.body:SetJustifyH("LEFT")
        r.body:SetWordWrap(true)
        r.body:SetSpacing(4)
        if r.body.SetMaxLines then r.body:SetMaxLines(2) end
        r.body:SetTextColor(0.26, 0.17, 0.07)  -- dark, for the parchment

        -- Hover recap: full headline, then who reported it + which shard.
        r:EnableMouse(true)
        r:SetScript("OnEnter", function(self)
            local al, d = self._alert, self._desc
            if not al or not d then return end
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(d.title or "Alert", 1, 0.93, 0.78)
            if al.kind == "assault" then
                GameTooltip:AddLine("Reported by: " .. (SafeName(al.attacker, 32) or "Unknown"), 0.85, 0.85, 0.85)
                GameTooltip:AddLine("Shard: " .. (al.shardId and tostring(al.shardId) or "?"), 0.85, 0.85, 0.85)
                if al.place then
                    GameTooltip:AddLine(al.place .. (al.zone and (", " .. al.zone) or ""), 0.65, 0.65, 0.65)
                end
            elseif d.body and d.body ~= "" then
                GameTooltip:AddLine(d.body, 0.80, 0.80, 0.80, true)
            end
            GameTooltip:AddLine(d.timeText or "", 0.55, 0.55, 0.55)
            GameTooltip:Show()
        end)
        r:SetScript("OnLeave", function() GameTooltip:Hide() end)

        rows[i] = r
        return r
    end

    function pane:RenderAlerts()
        local list = (WB.Alerts and WB.Alerts.All and WB.Alerts.All()) or {}
        for _, r in ipairs(rows) do r:Hide() end
        for i, al in ipairs(list) do
            local r = GetRow(i)
            local d = WB.Alerts.Describe(al)
            r._alert, r._desc = al, d
            r.accent:SetColorTexture(d.accent[1], d.accent[2], d.accent[3], 0.95)
            r.chipText:SetText(d.tag)
            r.chip:SetWidth((r.chipText:GetStringWidth() or 20) + 18)
            r.chip:SetBackdropColor(d.chipFill[1], d.chipFill[2], d.chipFill[3], 0.96)
            r.chip:SetBackdropBorderColor(d.chipBorder[1], d.chipBorder[2], d.chipBorder[3], 1)
            r.title:SetText(d.title or "")
            r.title:SetTextColor(d.titleColor[1], d.titleColor[2], d.titleColor[3])
            r.body:SetText(d.body or "")
            r.time:SetText(d.timeText or "")
            r:Show()
        end
        content:SetHeight(math.max(1, #list * ROW_H))
        emptyFS:SetShown(#list == 0)
    end

    WB.UI.alertsPane    = pane
    WB.UI.RefreshAlerts = function() if pane.RenderAlerts then pane:RenderAlerts() end end
    pane:RenderAlerts()
end

-- MIDDLE: one panel with pill tabs + a switchable body.
local function BuildMiddleColumn(col)
    local panel = CreatePanel(col)
    panel:SetAllPoints(col)

    local TABS  = { "Leader Board", "Activity", "Bounties", "Alerts" }
    local pills = {}
    local panes = {}

    local body = CreateFrame("Frame", nil, panel)
    body:SetPoint("TOPLEFT",     panel, "TOPLEFT",     12, -52)
    body:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -12, 12)

    local function SelectTab(index)
        for i, pill in ipairs(pills) do
            pill:SetSelected(i == index)
            panes[i]:SetShown(i == index)
        end
        WB.UI.activeTab = index
    end

    local function CreatePill(text)
        local b = CreateFrame("Button", nil, panel, "BackdropTemplate")
        b:SetSize(96, 28)
        b:SetBackdrop({
            bgFile   = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            edgeSize = 12,
            insets   = { left = 3, right = 3, top = 3, bottom = 3 },
        })
        local label = b:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        label:SetPoint("CENTER")
        label:SetText(text)
        b.label = label

        function b:SetSelected(sel)
            if sel then
                self:SetBackdropColor(ACCENT[1], ACCENT[2], ACCENT[3], 0.92)
                self:SetBackdropBorderColor(0.90, 0.55, 0.32, 1)
                self.label:SetTextColor(1, 0.92, 0.72)
            else
                self:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
                self:SetBackdropBorderColor(0.40, 0.34, 0.22, 0.85)
                self.label:SetTextColor(0.75, 0.72, 0.62)
            end
        end
        b:SetScript("OnEnter", function(self)
            if WB.UI.activeTab and pills[WB.UI.activeTab] == self then return end
            self:SetBackdropBorderColor(0.70, 0.55, 0.32, 1)
        end)
        b:SetScript("OnLeave", function(self)
            self:SetSelected(WB.UI.activeTab and pills[WB.UI.activeTab] == self)
        end)
        return b
    end

    local pillW, pillGap = 96, 8
    for i, tabName in ipairs(TABS) do
        local pill = CreatePill(tabName)
        pill:SetPoint("TOPLEFT", panel, "TOPLEFT", 14 + (i - 1) * (pillW + pillGap), -12)
        pill:SetScript("OnClick", function() SelectTab(i) end)
        pills[i] = pill

        local pane = CreateFrame("Frame", nil, body)
        pane:SetAllPoints(body)
        pane:Hide()
        local placeholder = pane:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
        placeholder:SetPoint("CENTER")
        placeholder:SetText(tabName)
        placeholder:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
        pane.placeholder = placeholder
        panes[i] = pane

        if tabName == "Leader Board" then
            BuildLeaderboardPane(pane)
        elseif tabName == "Activity" then
            BuildActivityPane(pane)
        elseif tabName == "Bounties" then
            BuildBountiesPane(pane)
        elseif tabName == "Alerts" then
            BuildAlertsPane(pane)
        end
    end

    WB.UI.tabs  = pills
    WB.UI.panes = panes
    SelectTab(1)  -- default to Leader Board
end

-- RIGHT: War Effort Progress (top) + Active Bounties (below).
local function BuildRightColumn(col)
    -- Campaign Status (top) + War Contribution (below) share this one panel.
    local progress = CreatePanel(col)
    progress:SetPoint("TOPLEFT",  col, "TOPLEFT",  0, 0)
    progress:SetPoint("TOPRIGHT", col, "TOPRIGHT", 0, 0)
    progress:SetHeight(340)
    progress:SetClipsChildren(true)
    WB.UI.warEffortPanel = progress   -- the shard pill aligns itself to this block

    -- ── Campaign Status ───────────────────────────────────────────────────────
    local cHeader = SectionHeader(progress, "Campaign Status")
    cHeader:SetPoint("TOPLEFT", progress, "TOPLEFT", 12, -12)

    local cDiv = HLine(progress)
    cDiv:SetPoint("TOP",   cHeader,  "BOTTOM", 0, -6)
    cDiv:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    cDiv:SetPoint("RIGHT", progress, "RIGHT", -12, 0)

    local wfLabel = progress:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    wfLabel:SetPoint("TOP",   cDiv,     "BOTTOM", 0, -8)
    wfLabel:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    wfLabel:SetPoint("RIGHT", progress, "RIGHT", -12, 0)
    wfLabel:SetJustifyH("CENTER")
    wfLabel:SetText("Active Warfront:")
    wfLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    -- Active-warfront title card: zone art + name (word-wrapped, centered). The
    -- hover explains the +50% reward bonus and the daily random rotation.
    local card = CreateFrame("Frame", nil, progress, "BackdropTemplate")
    card:SetPoint("TOP",   wfLabel,  "BOTTOM", 0, -6)
    card:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    card:SetPoint("RIGHT", progress, "RIGHT", -12, 0)
    card:SetHeight(58)
    card:EnableMouse(true)
    card:SetBackdrop({
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    card:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95)

    -- Zone art on the ARTWORK layer (BACKGROUND textures can lose draw order to the
    -- BackdropTemplate after reloads — the objective cards hit the same gotcha).
    local cardArt = card:CreateTexture(nil, "ARTWORK")
    cardArt:SetDrawLayer("ARTWORK", -8)
    cardArt:SetPoint("TOPLEFT",     card, "TOPLEFT",      3, -3)
    cardArt:SetPoint("BOTTOMRIGHT", card, "BOTTOMRIGHT", -3,  3)
    cardArt:SetTexCoord(0.04, 0.96, 0.12, 0.78)

    local cardShade = card:CreateTexture(nil, "ARTWORK")
    cardShade:SetDrawLayer("ARTWORK", -7)
    cardShade:SetPoint("TOPLEFT",     cardArt, "TOPLEFT",     0, 0)
    cardShade:SetPoint("BOTTOMRIGHT", cardArt, "BOTTOMRIGHT", 0, 0)
    cardShade:SetColorTexture(0, 0, 0, 0.42)

    local cardName = card:CreateFontString(nil, "OVERLAY")
    cardName:SetFont("Fonts\\FRIZQT__.TTF", 17, "OUTLINE")
    cardName:SetPoint("CENTER", card, "CENTER", 0, 0)
    cardName:SetWidth(138)
    cardName:SetJustifyH("CENTER")
    cardName:SetWordWrap(true)
    cardName:SetText("--")
    cardName:SetTextColor(0.50, 0.93, 0.50)

    -- Live countdown to when this warfront rotates out (the daily change boundary).
    local function WarfrontEndsStr()
        if not (WB.Campaign and WB.Campaign.SecondsUntilWarfrontChange) then return nil end
        local secs = WB.Campaign.SecondsUntilWarfrontChange()
        local h = math.floor(secs / 3600)
        local m = math.floor((secs % 3600) / 60)
        local s = math.floor(secs % 60)
        if h > 0 then
            return ("Ends in %dh %02dm %02ds"):format(h, m, s)
        elseif m > 0 then
            return ("Ends in %dm %02ds"):format(m, s)
        else
            return ("Ends in %ds"):format(s)
        end
    end

    card:SetScript("OnEnter", function(self)
        GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
        GameTooltip:SetText("Active Warfront", GOLD[1], GOLD[2], GOLD[3])
        GameTooltip:AddLine((WB.Campaign and WB.Campaign.ActiveWarfront()) or "Unknown", 0.50, 0.93, 0.50)
        GameTooltip:AddLine(" ")
        GameTooltip:AddLine("War Effort rewards in this zone are increased by |cff55ff5550%|r today.", 0.85, 0.85, 0.85, true)
        GameTooltip:AddLine("The active warfront changes every day at random.", 0.75, 0.75, 0.75, true)
        local ends = WarfrontEndsStr()
        if ends then
            GameTooltip:AddLine(" ")
            GameTooltip:AddLine(ends, GOLD[1], GOLD[2], GOLD[3])
            self._wfLine = GameTooltip:NumLines()   -- the line OnUpdate ticks live
        else
            self._wfLine = nil
        end
        self._wfAccum = 0
        GameTooltip:Show()
    end)
    card:SetScript("OnLeave", function(self)
        self._wfLine = nil
        GameTooltip:Hide()
    end)
    -- Tick the countdown line while hovered; cheap early-out the rest of the time.
    card:SetScript("OnUpdate", function(self, elapsed)
        if not self._wfLine then return end
        self._wfAccum = (self._wfAccum or 0) + elapsed
        if self._wfAccum < 0.25 then return end
        self._wfAccum = 0
        if GameTooltip:GetOwner() ~= self or not GameTooltip:IsShown() then
            self._wfLine = nil
            return
        end
        local ends = WarfrontEndsStr()
        local fs = ends and _G["GameTooltipTextLeft" .. self._wfLine]
        if fs then fs:SetText(ends) end
    end)
    -- Re-assert the art/zone whenever the panel reopens (draw-order safety).
    card:SetScript("OnShow", function() if WB.UI.RefreshCampaign then WB.UI.RefreshCampaign() end end)

    -- ── War Contribution ──────────────────────────────────────────────────────
    -- Manual (non-SectionHeader) so it can sit below the campaign card rather than
    -- pinned to the panel's top-right corner.
    local pHeader = progress:CreateFontString(nil, "OVERLAY")
    pHeader:SetFont("Fonts\\FRIZQT__.TTF", 14, "OUTLINE")
    pHeader:SetText("War Contribution")
    pHeader:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    pHeader:SetJustifyH("CENTER")
    pHeader:SetPoint("TOP",   card,     "BOTTOM", 0, -14)
    pHeader:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    pHeader:SetPoint("RIGHT", progress, "RIGHT", -12, 0)

    -- Tug-of-war bar: Horde (red) pushes from the left, Alliance (blue) from the
    -- right. The faction emblems sit centered on the bar endpoints, so the bar is
    -- inset enough that those emblems stay inside the War Contribution panel.
    local EMBLEM_SIZE = 58
    local BAR_END_INSET = 20
    local barBg = progress:CreateTexture(nil, "BACKGROUND")
    barBg:SetColorTexture(0.07, 0.06, 0.05, 1)
    barBg:SetHeight(20)
    barBg:SetPoint("TOPLEFT",  pHeader, "BOTTOMLEFT",   BAR_END_INSET, -24)
    barBg:SetPoint("TOPRIGHT", pHeader, "BOTTOMRIGHT", -BAR_END_INSET, -24)

    -- Faction emblems overlap the bar's ends (Horde left, Alliance right).
    local hEmblem = progress:CreateTexture(nil, "OVERLAY", nil, 2)
    hEmblem:SetAtlas("HordeEmblem")
    hEmblem:SetSize(EMBLEM_SIZE, EMBLEM_SIZE)
    hEmblem:SetPoint("CENTER", barBg, "LEFT", 0, 0)
    local aEmblem = progress:CreateTexture(nil, "OVERLAY", nil, 2)
    aEmblem:SetAtlas("AllianceEmblem")
    aEmblem:SetSize(EMBLEM_SIZE, EMBLEM_SIZE)
    aEmblem:SetPoint("CENTER", barBg, "RIGHT", 0, 0)

    local hordeFill = progress:CreateTexture(nil, "ARTWORK")
    hordeFill:SetAtlas("_pvpqueue-conquestbar-fill-red")
    hordeFill:SetPoint("TOPLEFT",    barBg, "TOPLEFT",    0, 0)
    hordeFill:SetPoint("BOTTOMLEFT", barBg, "BOTTOMLEFT", 0, 0)
    hordeFill:SetWidth(1)

    local allianceFill = progress:CreateTexture(nil, "ARTWORK")
    allianceFill:SetAtlas("_pvpqueue-conquestbar-fill-blue")
    allianceFill:SetPoint("TOPRIGHT",    barBg, "TOPRIGHT",    0, 0)
    allianceFill:SetPoint("BOTTOMRIGHT", barBg, "BOTTOMRIGHT", 0, 0)
    allianceFill:SetWidth(1)

    local divider = progress:CreateTexture(nil, "OVERLAY")
    divider:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
    divider:SetWidth(2)
    divider:SetPoint("TOP",    hordeFill, "TOPRIGHT",    0,  1)
    divider:SetPoint("BOTTOM", hordeFill, "BOTTOMRIGHT", 0, -1)

    -- War credit contributions below the bar: Horde (left), Alliance (right).
    local hVal = progress:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hVal:SetPoint("TOPLEFT", barBg, "BOTTOMLEFT", 0, -22)
    hVal:SetText("0 WC")
    hVal:SetTextColor(FACTION_NAME_COLOR.Horde[1], FACTION_NAME_COLOR.Horde[2], FACTION_NAME_COLOR.Horde[3])
    local aVal = progress:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    aVal:SetPoint("TOPRIGHT", barBg, "BOTTOMRIGHT", 0, -22)
    aVal:SetJustifyH("RIGHT")
    aVal:SetText("0 WC")
    aVal:SetTextColor(FACTION_NAME_COLOR.Alliance[1], FACTION_NAME_COLOR.Alliance[2], FACTION_NAME_COLOR.Alliance[3])

    -- Description below the bar + contributions (full panel width).
    local pDesc = progress:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    pDesc:SetPoint("TOP",   hVal,     "BOTTOM", 0, -8)
    pDesc:SetPoint("LEFT",  progress, "LEFT",  12, 0)
    pDesc:SetPoint("RIGHT", progress, "RIGHT", -12, 0)
    pDesc:SetJustifyH("CENTER")
    pDesc:SetText("Earn War Credit to push the front in your faction's favor. The faction with the most War Credit at campaign's end wins.")
    pDesc:SetTextColor(LABEL_COLOR[1], LABEL_COLOR[2], LABEL_COLOR[3])

    WB.UI.warEffortProgress = {
        barBg = barBg, hordeFill = hordeFill, allianceFill = allianceFill,
        divider = divider, hordeVal = hVal, allianceVal = aVal, desc = pDesc,
    }

    -- Campaign countdown beneath the description. The label/icon/value flow inside a
    -- container that's sized to fit (in RefreshCampaign) and centered as one unit, so
    -- the long label never overhangs the panel edge.
    local endsRow = CreateFrame("Frame", nil, progress)
    endsRow:SetPoint("TOP", pDesc, "BOTTOM", 0, -10)
    endsRow:SetSize(190, 18)
    local endsLabel = endsRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    endsLabel:SetPoint("LEFT", endsRow, "LEFT", 0, 0)
    endsLabel:SetText("Campaign ends in:")
    endsLabel:SetTextColor(LABEL_COLOR[1], LABEL_COLOR[2], LABEL_COLOR[3])
    local endsIcon = endsRow:CreateTexture(nil, "OVERLAY")
    endsIcon:SetAtlas("unitframeicon-chromietime")
    endsIcon:SetSize(18, 18)
    endsIcon:SetPoint("LEFT", endsLabel, "RIGHT", 4, 0)
    local endsValue = endsRow:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    endsValue:SetPoint("LEFT", endsIcon, "RIGHT", 3, 0)
    endsValue:SetText("--")
    endsValue:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    WB.UI.campaign = { card = card, art = cardArt, name = cardName,
                       endsValue = endsValue, endsRow = endsRow, endsLabel = endsLabel }

    -- "View Campaign Results" → opens the results-history popup.
    local resultsBtn = CreateFrame("Button", nil, progress, "BackdropTemplate")
    resultsBtn:SetHeight(24)
    resultsBtn:SetPoint("TOP",   endsRow,  "BOTTOM", 0, -10)
    resultsBtn:SetPoint("LEFT",  progress, "LEFT",   34, 0)
    resultsBtn:SetPoint("RIGHT", progress, "RIGHT", -34, 0)
    resultsBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    resultsBtn:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
    resultsBtn:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95)
    local rbLabel = resultsBtn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    rbLabel:SetPoint("CENTER", resultsBtn, "CENTER", 0, 0)
    rbLabel:SetText("View Campaign Results")
    rbLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    resultsBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.90, 0.70, 0.35, 1) end)
    resultsBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95) end)
    resultsBtn:SetScript("OnClick", function()
        if WB.UI.ShowCampaignResults then WB.UI.ShowCampaignResults() end
    end)

    -- Active Bounties (bounties you've personally taken up)
    local bounties = CreatePanel(col)
    bounties:SetPoint("TOPLEFT",  progress, "BOTTOMLEFT",  0, -GAP)
    bounties:SetPoint("TOPRIGHT", progress, "BOTTOMRIGHT", 0, -GAP)
    bounties:SetPoint("BOTTOM",   col,      "BOTTOM",      0, 0)

    local bHeader = SectionHeader(bounties, "Active Bounties")
    bHeader:SetPoint("TOPLEFT", bounties, "TOPLEFT", 12, -12)

    -- Active count "X / 3" beside the header.
    local bCount = bounties:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bCount:SetPoint("TOPRIGHT", bounties, "TOPRIGHT", -14, -16)
    bCount:SetJustifyH("RIGHT")
    bCount:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    local bEmpty = bounties:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    bEmpty:SetPoint("TOPLEFT", bHeader, "BOTTOMLEFT", 0, -10)
    bEmpty:SetPoint("RIGHT",   bounties, "RIGHT",    -12, 0)
    bEmpty:SetJustifyH("CENTER")
    bEmpty:SetText("No bounties taken up yet.")
    bEmpty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])

    -- Scrollable stack of detailed bounty cards (mouse-wheel only).
    local CARD_H = 176
    local scroll = CreateFrame("ScrollFrame", nil, bounties)
    scroll:SetPoint("TOPLEFT",  bounties, "TOPLEFT",   8, -40)
    scroll:SetPoint("TOPRIGHT", bounties, "TOPRIGHT", -8, -40)
    scroll:SetPoint("BOTTOM",   bounties, "BOTTOM",    0,  8)
    scroll:EnableMouseWheel(true)
    scroll:SetClipsChildren(true)
    local abContent = CreateFrame("Frame", nil, scroll)
    abContent:SetSize(1, 1)
    scroll:SetScrollChild(abContent)
    scroll:SetScript("OnSizeChanged", function(self, w) abContent:SetWidth(w) end)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local maxS = self:GetVerticalScrollRange() or 0
        local nv   = self:GetVerticalScroll() - delta * 40
        self:SetVerticalScroll(math.max(0, math.min(nv, maxS)))
    end)

    local abCards = {}
    local function GetCard(i)
        if abCards[i] then return abCards[i] end
        local H = WB.UI.bountyHelpers or {}
        local r = CreateFrame("Frame", nil, abContent, "BackdropTemplate")
        r:SetHeight(CARD_H - 8)
        r:SetPoint("TOPLEFT",  abContent, "TOPLEFT",  0, -((i - 1) * CARD_H))
        r:SetPoint("TOPRIGHT", abContent, "TOPRIGHT", 0, -((i - 1) * CARD_H))
        r:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8x8", edgeFile = "Interface\\Buttons\\WHITE8x8", edgeSize = 1 })
        r:SetBackdropColor(0, 0, 0, 0.22)
        r:SetBackdropBorderColor(0.45, 0.36, 0.22, 0.55)

        -- "Warm bucket" eye (top-right): opens the nearby-allies popup for this target.
        r.eyeBtn = CreateFrame("Button", nil, r)
        r.eyeBtn:SetSize(20, 20)
        r.eyeBtn:SetPoint("TOPRIGHT", r, "TOPRIGHT", -7, -7)
        r.eyeTex = r.eyeBtn:CreateTexture(nil, "ARTWORK")
        r.eyeTex:SetAllPoints()
        r.eyeTex:SetAtlas("groupfinder-eye-frame")
        r.eyeTex:SetVertexColor(0.82, 0.82, 0.85)
        r.eyeBtn:SetScript("OnEnter", function(self)
            r.eyeTex:SetVertexColor(1, 1, 1)
            GameTooltip:SetOwner(self, "ANCHOR_LEFT")
            GameTooltip:SetText("Nearby Allies", 1, 0.95, 0.8)
            GameTooltip:AddLine("View allies recently seen near this bounty target on their shard.", 0.8, 0.8, 0.8, true)
            GameTooltip:AddLine("Right-click an entry to whisper or request an invite.", 0.6, 0.6, 0.6, true)
            GameTooltip:Show()
        end)
        r.eyeBtn:SetScript("OnLeave", function()
            r.eyeTex:SetVertexColor(0.82, 0.82, 0.85)
            GameTooltip:Hide()
        end)

        -- Circular race portrait.
        local CPORT = 46
        local pframe = CreateFrame("Frame", nil, r)
        pframe:SetSize(CPORT, CPORT)
        pframe:SetPoint("TOPLEFT", r, "TOPLEFT", 10, -10)
        local pbg = pframe:CreateTexture(nil, "BACKGROUND")
        pbg:SetAllPoints(pframe); pbg:SetColorTexture(0, 0, 0, 1)
        r.portrait = pframe:CreateTexture(nil, "ARTWORK")
        r.portrait:SetAllPoints(pframe)
        local pmask = pframe:CreateMaskTexture()
        pmask:SetAllPoints(pframe)
        pmask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask", "CLAMPTOBLACKADDITIVE", "CLAMPTOBLACKADDITIVE")
        r.portrait:AddMaskTexture(pmask); pbg:AddMaskTexture(pmask)
        local pring = pframe:CreateTexture(nil, "OVERLAY")
        pring:SetAtlas("hud-PlayerFrame-portraitring-large", false)
        pring:SetSize(CPORT + 14, CPORT + 14)
        pring:SetPoint("CENTER", pframe, "CENTER", 0, 0)

        -- Name / level-race-class / guild / rank (right of the portrait).
        r.name = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.name:SetPoint("TOPLEFT", pframe, "TOPRIGHT", 8, -1)
        r.name:SetPoint("RIGHT",   r.eyeBtn, "LEFT", -4, 0)
        r.name:SetJustifyH("LEFT"); r.name:SetWordWrap(false)
        r.nameHit = MakeNameHover(r); r.nameHit:SetAllPoints(r.name)
        r.info = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.info:SetPoint("TOPLEFT", r.name, "BOTTOMLEFT", 0, -2)
        r.info:SetPoint("RIGHT",   r, "RIGHT", -8, 0)
        r.info:SetJustifyH("LEFT"); r.info:SetWordWrap(false)
        r.info:SetTextColor(0.82, 0.78, 0.68)
        r.guild = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.guild:SetPoint("TOPLEFT", r.info, "BOTTOMLEFT", 0, -2)
        r.guild:SetPoint("RIGHT",   r, "RIGHT", -8, 0)
        r.guild:SetJustifyH("LEFT"); r.guild:SetWordWrap(false)
        r.guild:SetTextColor(0.25, 0.85, 0.25)
        r.rankIcon = r:CreateTexture(nil, "ARTWORK")
        r.rankIcon:SetSize(14, 14)
        r.rankIcon:SetPoint("TOPLEFT", r.guild, "BOTTOMLEFT", 0, -3)
        r.rank = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.rank:SetPoint("LEFT", r.rankIcon, "RIGHT", 4, 0)
        r.rank:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

        -- Divider, then label/value rows.
        local div = HLine(r)
        div:SetPoint("TOPLEFT",  r, "TOPLEFT", 10, -74)
        div:SetPoint("TOPRIGHT", r, "TOPRIGHT", -10, -74)

        local function LabelRow(y, text)
            local lab = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            lab:SetPoint("TOPLEFT", r, "TOPLEFT", 10, y)
            lab:SetText(text)
            lab:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
            local val = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            val:SetPoint("TOPRIGHT", r, "TOPRIGHT", -10, y)
            val:SetJustifyH("RIGHT"); val:SetWordWrap(false)
            val:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3])
            return lab, val
        end
        r.locLabel, r.locVal = LabelRow(-82, "LOCATION:")
        r.locSub = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.locSub:SetPoint("TOPRIGHT", r, "TOPRIGHT", -10, -95)
        r.locSub:SetJustifyH("RIGHT"); r.locSub:SetWordWrap(false)
        r.locSub:SetTextColor(0.66, 0.62, 0.54)
        r.shardLabel, r.shardVal   = LabelRow(-107, "SHARD:")
        r.statusLabel, r.statusVal = LabelRow(-122, "STATUS:")
        r.statusDot = r:CreateTexture(nil, "ARTWORK")
        r.statusDot:SetSize(11, 11)
        r.statusDot:SetPoint("RIGHT", r.statusVal, "LEFT", -5, 0)

        -- TRACK TARGET / ABANDON buttons.
        r.trackBtn = H.CreateRedButton and H.CreateRedButton(r, "TRACK")
        if r.trackBtn then
            r.trackBtn:SetHeight(22)
            r.trackBtn.label:SetFont(STANDARD_TEXT_FONT, 11, "")
            r.trackBtn:SetPoint("BOTTOMLEFT",  r, "BOTTOMLEFT", 8, 8)
            r.trackBtn:SetPoint("BOTTOMRIGHT", r, "BOTTOM",    -3, 8)
        end
        r.abandonBtn = H.CreateRedButton and H.CreateRedButton(r, "ABANDON")
        if r.abandonBtn then
            r.abandonBtn:SetHeight(22)
            r.abandonBtn.label:SetFont(STANDARD_TEXT_FONT, 11, "")
            r.abandonBtn:SetBackdropColor(0.14, 0.13, 0.12, 0.94)
            r.abandonBtn:SetBackdropBorderColor(0.45, 0.40, 0.30, 0.9)
            r.abandonBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.72, 0.62, 0.40, 1) end)
            r.abandonBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.45, 0.40, 0.30, 0.9) end)
            r.abandonBtn:SetPoint("BOTTOMLEFT",  r, "BOTTOM",       3, 8)
            r.abandonBtn:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", -8, 8)
        end

        abCards[i] = r
        return r
    end

    function WB.UI.RefreshActiveBounties()
        local list = (WB.Bounties and WB.Bounties.GetActive and WB.Bounties.GetActive()) or {}
        local H = WB.UI.bountyHelpers or {}
        for _, r in ipairs(abCards) do r:Hide() end
        for i, b in ipairs(list) do
            local r  = GetCard(i)
            local nc = (H.NameColor and H.NameColor(b.targetFaction)) or FACTION_NAME_COLOR[b.targetFaction] or VALUE_COLOR
            if H.SetPortrait then H.SetPortrait(r.portrait, { raceFile = b.raceFile, sex = b.sex }) end
            r.name:SetText(SafeName(b.targetName, 32) or "?")
            r.name:SetTextColor(nc[1], nc[2], nc[3])
            r.nameHit._fullName = FullPlayerName(b.targetName, b.targetRealm)
            r.info:SetText(SafeName(("Lvl %s  %s  %s"):format(tostring(b.targetLevel or "--"), LocalizeRace(b.raceFile, b.targetRace), LocalizeClass(b.classToken, b.targetClass)), 60) or "")
            r.guild:SetText(SafeName(b.targetGuild and ("<" .. b.targetGuild .. ">"), 28) or "<No guild>")
            local rankName = (H.RankInfo and select(1, H.RankInfo(b.rank, b.targetFaction))) or "No Rank"
            if H.RankIcon then r.rankIcon:SetAtlas(H.RankIcon(b.rank, b.targetFaction)) end
            r.rank:SetText("Rank: " .. rankName)

            -- Live tracking state.
            local info  = WB.BountyTracker and WB.BountyTracker.GetTargetInfo and WB.BountyTracker.GetTargetInfo(b.targetGuid)
            local state = (WB.BountyTracker and WB.BountyTracker.GetTrackState and WB.BountyTracker.GetTrackState(b.targetGuid)) or "idle"
            if state == "failed" then
                r.locVal:SetText("Failed to obtain."); r.locSub:SetText("")
            elseif info and info.zone then
                r.locVal:SetText(info.zone)
                r.locSub:SetText((info.subZone and info.subZone ~= "") and info.subZone or "")
            elseif state == "trying" then
                r.locVal:SetText("Locating…"); r.locSub:SetText("")
            else
                r.locVal:SetText("Unknown");   r.locSub:SetText("")
            end
            -- War Mode flag to the left of the shard number (only when the target has
            -- reported a position, i.e. info.warMode is known).
            local shardTxt = (info and info.shard) and tostring(info.shard) or "--"
            if info and info.warMode ~= nil then
                local wm = info.warMode and "|cff58d65cWM On|r  " or "|cffd6585cWM Off|r  "
                r.shardVal:SetText(wm .. shardTxt)
            else
                r.shardVal:SetText(shardTxt)
            end
            if info and info.online then
                r.statusDot:SetTexture("Interface\\COMMON\\Indicator-Green")
                r.statusVal:SetText("Online");  r.statusVal:SetTextColor(0.40, 0.90, 0.45)
            else
                r.statusDot:SetTexture("Interface\\COMMON\\Indicator-Red")
                r.statusVal:SetText("Offline"); r.statusVal:SetTextColor(0.85, 0.45, 0.45)
            end

            if r.trackBtn then
                r.trackBtn.label:SetText((state == "tracking" or state == "trying") and "TRACKING" or "TRACK")
                r.trackBtn:SetScript("OnClick", function()
                    -- Tracking is persisted once the handshake actually succeeds (OnLocation).
                    if WB.BountyTracker and WB.BountyTracker.StartHunting then WB.BountyTracker.StartHunting(b) end
                    if WB.UI.RefreshActiveBounties then WB.UI.RefreshActiveBounties() end
                end)
            end
            if r.abandonBtn then
                r.abandonBtn:SetScript("OnClick", function()
                    if WB.Bounties and WB.Bounties.Abandon then WB.Bounties.Abandon(b) end
                end)
            end
            if r.eyeBtn then
                r.eyeBtn:SetScript("OnClick", function()
                    if WB.UI.ShowSightings then WB.UI.ShowSightings(b) end
                end)
            end
            r:Show()
        end
        abContent:SetHeight(math.max(1, #list * CARD_H))
        bEmpty:SetShown(#list == 0)
        bCount:SetText(("%d / %d"):format(#list, (WB.Bounties and WB.Bounties.MaxActive and WB.Bounties.MaxActive()) or 3))
    end

    WB.UI.activeBountiesBody = bounties
    WB.UI.RefreshActiveBounties()
end

-- ── Nearby-enemies ("warm bucket") popup ─────────────────────────────────────
-- Opened by the eye on an Active Bounty card. Lists the enemies the target has seen on
-- their shard in the last 15 min — those enemies are YOUR faction, so you can whisper
-- them or request a group invite to converge on the target's location.
local sightingsPopup

local function SightingsFullName(e)
    local nm = SafeName(e and e.name, 32) or "?"
    local realm = SafeName(e and e.realm, 32)
    if realm and realm ~= "" then return nm .. "-" .. realm end
    return nm
end

-- Request a group invite from a player (JARVIS's logic: leave a normal group first).
local function RequestInviteFrom(full)
    if not full or full == "" or not (C_PartyInfo and C_PartyInfo.RequestInviteFromUnit) then return end
    if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
        C_PartyInfo.RequestInviteFromUnit(full)                 -- can't auto-leave LFG; ask anyway
    elseif IsInGroup() then
        C_PartyInfo.LeaveParty()
        C_Timer.After(0.5, function() C_PartyInfo.RequestInviteFromUnit(full) end)
    else
        C_PartyInfo.RequestInviteFromUnit(full)
    end
end

local function SightingAgo(t)
    local d = math.max(0, time() - (t or 0))
    if d < 60   then return "just now" end
    if d < 3600 then return math.floor(d / 60) .. "m ago" end
    return math.floor(d / 3600) .. "h ago"
end

local function CreateSightingsPopup()
    local f = CreateFrame("Frame", "WarBoardSightingsPopup", UIParent, "BackdropTemplate")
    f:SetSize(304, 400)
    f:SetPoint("CENTER")
    f:SetFrameStrata("DIALOG")
    f:SetToplevel(true)
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)
    f:SetBackdrop({
        bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        tile = true, tileSize = 32, edgeSize = 24,
        insets = { left = 6, right = 6, top = 6, bottom = 6 },
    })
    f:SetBackdropColor(0.06, 0.06, 0.07, 0.97)

    f.title = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    f.title:SetPoint("TOP", f, "TOP", 0, -14)
    f.title:SetText("Nearby Allies")
    f.title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    f.sub = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.sub:SetPoint("TOP", f.title, "BOTTOM", 0, -3)
    f.sub:SetTextColor(0.70, 0.70, 0.72)

    local close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -3, -3)

    local ROW_H = 50
    local scroll = CreateFrame("ScrollFrame", nil, f, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT",     f, "TOPLEFT",     12, -50)
    scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -30, 14)
    local content = CreateFrame("Frame", nil, scroll)
    content:SetSize(1, 1)
    scroll:SetScrollChild(content)
    scroll:SetScript("OnSizeChanged", function(self, w) content:SetWidth(w) end)
    scroll:EnableMouseWheel(true)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local nv   = self:GetVerticalScroll() - (delta * ROW_H)
        local maxS = self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0, math.min(nv, maxS)))
    end)

    f.empty = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    f.empty:SetPoint("TOP", scroll, "TOP", -8, -20)
    f.empty:SetTextColor(MUTED_COLOR[1], MUTED_COLOR[2], MUTED_COLOR[3])
    f.empty:SetText("No nearby allies reported yet.")
    f.empty:Hide()

    -- Right-click context menu (Whisper / Request an Invite) + a click-catcher to dismiss it.
    local catcher = CreateFrame("Button", nil, UIParent)
    catcher:SetFrameStrata("FULLSCREEN_DIALOG")
    catcher:SetAllPoints(UIParent)
    catcher:Hide()
    local menu = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    menu:SetFrameStrata("FULLSCREEN_DIALOG")
    menu:SetFrameLevel(catcher:GetFrameLevel() + 10)
    menu:SetSize(154, 46)
    menu:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    menu:SetBackdropColor(0.09, 0.08, 0.07, 0.98)
    menu:SetBackdropBorderColor(0.50, 0.40, 0.25, 1)
    menu:Hide()
    menu:SetScript("OnHide", function() catcher:Hide() end)
    catcher:SetScript("OnClick", function() menu:Hide() end)
    local function MenuItem(text, y, onClick)
        local b = CreateFrame("Button", nil, menu)
        b:SetPoint("TOPLEFT",  menu, "TOPLEFT",  5, y)
        b:SetPoint("TOPRIGHT", menu, "TOPRIGHT", -5, y)
        b:SetHeight(18)
        local hl = b:CreateTexture(nil, "HIGHLIGHT")
        hl:SetAllPoints(); hl:SetColorTexture(1, 1, 1, 0.12)
        local fs = b:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        fs:SetPoint("LEFT", b, "LEFT", 6, 0)
        fs:SetText(text); fs:SetTextColor(0.95, 0.90, 0.78)
        b:SetScript("OnClick", function() menu:Hide(); if menu.entry then onClick(menu.entry) end end)
        return b
    end
    MenuItem("Whisper", -4, function(e)
        if ChatFrame_SendTell then ChatFrame_SendTell(SightingsFullName(e)) end
    end)
    MenuItem("Request an Invite", -23, function(e) RequestInviteFrom(SightingsFullName(e)) end)

    f.scroll, f.content, f.ROW_H, f.rows = scroll, content, ROW_H, {}
    f.menu, f.menuCatcher = menu, catcher
    return f
end

local function RenderSightings()
    local f = sightingsPopup
    if not f or not f.targetGuid then return end
    local list = (WB.Sightings and WB.Sightings.GetForTarget and WB.Sightings.GetForTarget(f.targetGuid)) or {}
    for _, row in ipairs(f.rows) do row:Hide() end
    f.sub:SetText(("%d seen in the last 15 min"):format(#list))
    if #list == 0 then
        local st = WB.BountyTracker and WB.BountyTracker.GetTrackState and WB.BountyTracker.GetTrackState(f.targetGuid)
        f.empty:SetText((st == "tracking" or st == "trying")
            and "No nearby allies reported yet."
            or  "Track this target to receive their sightings.")
        f.empty:Show(); f.content:SetHeight(1); return
    end
    f.empty:Hide()
    for i, e in ipairs(list) do
        local row = f.rows[i]
        if not row then
            row = CreateFrame("Button", nil, f.content)
            row:SetHeight(f.ROW_H - 4)
            row:SetPoint("TOPLEFT", f.content, "TOPLEFT", 2, -((i - 1) * f.ROW_H) - 2)
            row:SetPoint("RIGHT",   f.content, "RIGHT",   -2, 0)
            row:RegisterForClicks("LeftButtonUp", "RightButtonUp")
            row.bg = row:CreateTexture(nil, "BACKGROUND")
            row.bg:SetAllPoints(); row.bg:SetColorTexture(1, 1, 1, 0.03)
            row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormal")
            row.name:SetPoint("TOPLEFT", row, "TOPLEFT", 8, -3)
            row.name:SetPoint("RIGHT", row, "RIGHT", -6, 0)
            row.name:SetJustifyH("LEFT"); row.name:SetWordWrap(false)
            row.sub = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            row.sub:SetPoint("TOPLEFT", row.name, "BOTTOMLEFT", 0, -2)
            row.sub:SetTextColor(0.78, 0.74, 0.66)
            row.guild = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
            row.guild:SetPoint("TOPLEFT", row.sub, "BOTTOMLEFT", 0, -2)
            row.guild:SetPoint("RIGHT", row, "RIGHT", -6, 0)
            row.guild:SetJustifyH("LEFT"); row.guild:SetWordWrap(false)
            row.guild:SetTextColor(0.30, 0.78, 0.30)
            row:SetScript("OnEnter", function(self)
                self.bg:SetColorTexture(1, 1, 1, 0.10)
                local en = self.entry
                if not en then return end
                GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
                GameTooltip:SetText(SightingsFullName(en), 1, 0.95, 0.8)
                GameTooltip:AddLine("Shard: " .. (en.shard and tostring(en.shard) or "?"), 0.60, 0.78, 0.95)
                GameTooltip:AddLine("Seen: " .. SightingAgo(en.t), 0.80, 0.80, 0.80)
                GameTooltip:Show()
            end)
            row:SetScript("OnLeave", function(self) self.bg:SetColorTexture(1, 1, 1, 0.03); GameTooltip:Hide() end)
            row:SetScript("OnClick", function(self, button)
                if button ~= "RightButton" then return end
                local m = sightingsPopup.menu
                m.entry = self.entry
                local cx, cy = GetCursorPosition()
                local s = m:GetEffectiveScale()
                m:ClearAllPoints()
                m:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", cx / s, cy / s)
                sightingsPopup.menuCatcher:Show()
                m:Show()
            end)
            f.rows[i] = row
        end
        row.entry = e
        local cc = e.classToken and RAID_CLASS_COLORS and RAID_CLASS_COLORS[e.classToken]
        row.name:SetText(SafeName(e.name, 32) or "?")
        if cc then row.name:SetTextColor(cc.r, cc.g, cc.b)
        else       row.name:SetTextColor(VALUE_COLOR[1], VALUE_COLOR[2], VALUE_COLOR[3]) end
        row.sub:SetText(SafeName(("Level %s, %s"):format(tostring(e.level or "??"), LocalizeRace(e.raceFile, e.race)), 48) or "")
        row.guild:SetText(SafeName((e.guild and e.guild ~= "") and ("<" .. e.guild .. ">"), 28) or "")
        row:Show()
    end
    f.content:SetHeight(math.max(1, #list * f.ROW_H + 4))
end

function WB.UI.ShowSightings(bounty)
    if not bounty or not bounty.targetGuid then return end
    if not sightingsPopup then sightingsPopup = CreateSightingsPopup() end
    sightingsPopup.targetGuid = bounty.targetGuid
    sightingsPopup.title:SetText("Nearby Allies \226\128\148 " .. (SafeName(bounty.targetName, 32) or "?"))
    RenderSightings()
    sightingsPopup:Show()
    sightingsPopup:Raise()
end

function WB.UI.RefreshSightings(targetGuid)
    if sightingsPopup and sightingsPopup:IsShown() and sightingsPopup.targetGuid == targetGuid then
        RenderSightings()
    end
end

-- ── Community invite popup ───────────────────────────────────────────────────
-- Small dialog with the War Board community invite link (highlight + Ctrl+C to
-- copy). On close it opens Blizzard's Join/Create Community flow.
local communityPopup
local function CreateCommunityPopup()
    local INVITE = (WB.Community and WB.Community.INVITE_CODE) or "LqmOb27i5aL"

    local f = CreateFrame("Frame", "WarBoardCommunityPopup", UIParent, "BackdropTemplate")
    f:SetSize(440, 210)
    f:SetPoint("CENTER")
    f:SetFrameStrata("DIALOG")
    f:SetToplevel(true)
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)
    f:SetBackdrop({
        bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        tile = true, tileSize = 32, edgeSize = 28,
        insets = { left = 8, right = 8, top = 8, bottom = 8 },
    })
    f:SetBackdropColor(0.05, 0.05, 0.06, 0.97)
    f:SetBackdropBorderColor(0.85, 0.68, 0.20, 0.90)

    local title = f:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\MORPHEUS.TTF", 20)
    title:SetPoint("TOP", f, "TOP", 0, -16)
    title:SetText("Community Invite")
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local desc = f:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    desc:SetPoint("TOPLEFT",  f, "TOPLEFT",  22, -52)
    desc:SetPoint("TOPRIGHT", f, "TOPRIGHT", -22, -52)
    desc:SetJustifyH("CENTER")
    desc:SetText("Copy and paste this link into the \"Join Community\" field to join the War Board network.")
    desc:SetTextColor(0.85, 0.82, 0.74)

    local box = CreateFrame("EditBox", "WarBoardCommunityLinkBox", f, "InputBoxTemplate")
    box:SetSize(200, 22)
    box:SetPoint("TOP", desc, "BOTTOM", 0, -18)
    box:SetAutoFocus(false)
    box:SetJustifyH("CENTER")
    box:SetText(INVITE)
    box:SetTextColor(0.40, 0.90, 1.00)
    box:SetCursorPosition(0)
    box:SetScript("OnMouseDown", function(self) self:SetFocus(); self:HighlightText() end)
    box:SetScript("OnEditFocusGained", function(self) self:HighlightText() end)
    box:SetScript("OnTextChanged", function(self)
        -- If anything edits/clears it, repopulate the invite code.
        if self:GetText() ~= INVITE then
            self:SetText(INVITE)
            self:HighlightText()
        end
    end)
    box:SetScript("OnEnterPressed",  function(self) self:ClearFocus() end)
    box:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)

    local hint = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    hint:SetPoint("TOP", box, "BOTTOM", 0, -8)
    hint:SetText("Click the link to select it, then press Ctrl+C to copy.")
    hint:SetTextColor(0.50, 0.50, 0.50)

    local xBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    xBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -4, -4)

    local closeBtn = CreateFrame("Button", nil, f, "UIPanelButtonTemplate")
    closeBtn:SetSize(120, 24)
    closeBtn:SetPoint("BOTTOM", f, "BOTTOM", 0, 16)
    closeBtn:SetText("Close")
    closeBtn:SetScript("OnClick", function() f:Hide() end)

    -- Closing the popup (X, Close, or Escape) opens the Join Community flow.
    f:SetScript("OnHide", function()
        if WB.Community and WB.Community.OpenJoinCommunityDialog then
            WB.Community.OpenJoinCommunityDialog()
        end
    end)

    tinsert(UISpecialFrames, "WarBoardCommunityPopup")  -- Escape closes it
    return f
end

function WB.UI.ShowCommunityPopup()
    if not communityPopup then communityPopup = CreateCommunityPopup() end
    communityPopup:Show()
    communityPopup:Raise()
end

-- ── Campaign Results popup ───────────────────────────────────────────────────
-- A paged history of concluded campaigns: Dates (range + ended-at) | Result (faction
-- wax seal + VICTORY) | Score (stacked Horde/Alliance crest + ending WC). Themed with
-- the Horde-vs-Alliance store art top/bottom; the sides have no art, so we draw them.
local resultsPopup

local FACTION_CREST = {
    Horde    = "questlog-questtypeicon-horde",
    Alliance = "questlog-questtypeicon-alliance",
}
local FACTION_SEAL = {
    Horde    = "Quest-Horde-WaxSeal",
    Alliance = "Quest-Alliance-WaxSeal",
    Tie      = "Quest-Legionfall-WaxSeal",   -- a draw → neutral seal + "STALEMATE"
}

local function FmtRangeDates(startT, endT)
    if not startT or not endT then return "\226\128\148" end
    return date("%b %d", startT) .. " \226\128\147 " .. date("%b %d", endT)
end

local function CreateCampaignResultsPopup()
    local W             = 560
    local FRAME_H       = 744
    local ROWS_PER_PAGE = 6     -- max rows shown before paginating
    local ROW_H         = 70

    local f = CreateFrame("Frame", "WarBoardCampaignResults", UIParent, "BackdropTemplate")
    f:SetSize(W, FRAME_H)
    f:SetPoint("CENTER")
    f:SetFrameStrata("DIALOG")
    f:SetToplevel(true)
    f:SetClipsChildren(false)
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)
    f:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        tile = true, tileSize = 16,
        insets = { left = 6, right = 6, top = 6, bottom = 6 },
    })
    f:SetBackdropColor(0.04, 0.045, 0.06, 0.98)

    -- Themed bars: create + size only. The top anchors to the frame top; the bottom is
    -- placed at the END of the content (below the About section) so the window can be
    -- sized to EXACTLY its content — the bars become the true top/bottom edges.
    local function ThemeBar(atlas)
        local t = f:CreateTexture(nil, "ARTWORK")
        t:SetAtlas(atlas)
        local info = C_Texture and C_Texture.GetAtlasInfo and C_Texture.GetAtlasInfo(atlas)
        local h = (info and info.height) or 60
        t:SetHeight(h)
        return t, h
    end
    -- Visible height of each decorative trim — the part that actually shows, NOT the
    -- atlas's transparent padding. The FRAME is sized from these (plus the content), so
    -- the padding never inflates the panel; the atlases just overlay the top/bottom
    -- edges and may spill outside. Tune these two if the title/footer sit too close to
    -- (or too far from) the trim.
    local TOP_TRIM, BOT_TRIM = 42, 68

    local topArt = ThemeBar("perks-theme-hordevsalliance-tp-topbig")
    topArt:SetPoint("TOPLEFT",  f, "TOPLEFT",  0, 42)
    topArt:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 42)
    local botArt = ThemeBar("perks-theme-hordevsalliance-tp-bottombig")
    botArt:SetPoint("BOTTOMLEFT",  f, "BOTTOMLEFT",  0, -50)
    botArt:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, -50)

    local xBtn = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    xBtn:SetPoint("TOPRIGHT", f, "TOPRIGHT", -24, -20)
    xBtn:SetFrameLevel(f:GetFrameLevel() + 10)

    -- ── Top-down content chain (everything has a fixed height; nothing stretches) ──
    local title = f:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\MORPHEUS.TTF", 28)
    title:SetHeight(34)
    title:SetPoint("TOP", f, "TOP", 0, -(TOP_TRIM + 2))
    title:SetText("CAMPAIGN RESULTS")
    title:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local desc = f:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    desc:SetHeight(18)
    desc:SetPoint("TOP", title, "BOTTOM", 0, -4)
    desc:SetText("Review the outcome of previous campaigns and how your faction performed.")
    desc:SetTextColor(0.85, 0.83, 0.76)

    -- Results table — FIXED height (header + N rows). Never stretched, so the empty
    -- state stays compact instead of ballooning to fill the window.
    local BLOCK_H = 42 + ROWS_PER_PAGE * ROW_H
    local block = CreateFrame("Frame", nil, f, "BackdropTemplate")
    block:SetHeight(BLOCK_H)
    block:SetPoint("TOP",   desc, "BOTTOM", 0, -10)
    block:SetPoint("LEFT",  f, "LEFT",   20, 0)
    block:SetPoint("RIGHT", f, "RIGHT", -20, 0)
    block:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    block:SetBackdropColor(0.02, 0.02, 0.03, 0.6)
    block:SetBackdropBorderColor(0.45, 0.36, 0.20, 0.9)

    -- Pagination row below the table.
    local pager = CreateFrame("Frame", nil, f)
    pager:SetHeight(20)
    pager:SetPoint("TOP",   block, "BOTTOM", 0, -4)
    pager:SetPoint("LEFT",  f, "LEFT",   24, 0)
    pager:SetPoint("RIGHT", f, "RIGHT", -24, 0)

    local pageText = pager:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    pageText:SetPoint("CENTER", pager, "CENTER", 0, 0)
    pageText:SetTextColor(0.85, 0.82, 0.74)

    local prevBtn = CreateFrame("Button", nil, pager)
    prevBtn:SetSize(28, 28)
    prevBtn:SetPoint("RIGHT", pageText, "LEFT", -16, 0)
    prevBtn:SetNormalTexture("Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Up")
    prevBtn:SetPushedTexture("Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Down")
    prevBtn:SetDisabledTexture("Interface\\Buttons\\UI-SpellbookIcon-PrevPage-Disabled")

    local nextBtn = CreateFrame("Button", nil, pager)
    nextBtn:SetSize(28, 28)
    nextBtn:SetPoint("LEFT", pageText, "RIGHT", 16, 0)
    nextBtn:SetNormalTexture("Interface\\Buttons\\UI-SpellbookIcon-NextPage-Up")
    nextBtn:SetPushedTexture("Interface\\Buttons\\UI-SpellbookIcon-NextPage-Down")
    nextBtn:SetDisabledTexture("Interface\\Buttons\\UI-SpellbookIcon-NextPage-Disabled")

    -- About section below the pager. War-mode emblem (ring + glow + swords) at the left.
    local aboutDiv = f:CreateTexture(nil, "ARTWORK")
    aboutDiv:SetColorTexture(0, 0, 0, 0)
    aboutDiv:SetHeight(1)
    aboutDiv:SetPoint("TOP",   block, "BOTTOM", 0, -8)
    aboutDiv:SetPoint("LEFT",  f, "LEFT",   24, 0)
    aboutDiv:SetPoint("RIGHT", f, "RIGHT", -24, 0)

    local aboutIcon = f:CreateTexture(nil, "ARTWORK")
    aboutIcon:SetAtlas("Talent-RingWithDot")
    aboutIcon:SetSize(46, 46)
    aboutIcon:SetPoint("TOPLEFT", aboutDiv, "BOTTOMLEFT", 4, -6)
    local aboutGlow = f:CreateTexture(nil, "ARTWORK", nil, 1)
    aboutGlow:SetAtlas("pvptalents-warmode-orb")
    aboutGlow:SetSize(34, 34)
    aboutGlow:SetPoint("CENTER", aboutIcon, "CENTER", 0, 0)
    local aboutSwords = f:CreateTexture(nil, "OVERLAY")
    aboutSwords:SetAtlas("pvptalents-warmode-swords")
    aboutSwords:SetSize(24, 24)
    aboutSwords:SetPoint("CENTER", aboutIcon, "CENTER", 0, 0)

    local aboutTitle = f:CreateFontString(nil, "OVERLAY")
    aboutTitle:SetFont("Fonts\\FRIZQT__.TTF", 14, "")
    aboutTitle:SetHeight(16)
    aboutTitle:SetPoint("TOPLEFT", aboutDiv, "BOTTOMLEFT", 58, -8)
    aboutTitle:SetText("ABOUT CAMPAIGN RESULTS")
    aboutTitle:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    local aboutDesc = f:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    aboutDesc:SetHeight(70)
    aboutDesc:SetPoint("TOPLEFT", aboutTitle, "BOTTOMLEFT", 0, -2)
    aboutDesc:SetPoint("RIGHT",   f, "RIGHT", -26, 0)
    aboutDesc:SetJustifyH("LEFT")
    aboutDesc:SetSpacing(3)
    aboutDesc:SetText("\226\128\162 Campaigns end Mon & Fri at 6:00 PM PST / 9:00 PM EST (one hour later during daylight saving) \226\128\148 Mon to Fri runs 4 days, Fri to Mon 3 days.\n"
        .. "\226\128\162 Results are finalized after a 30-minute reconciliation period.\n"
        .. "\226\128\162 Only activities completed before the campaign end time are counted.")
    aboutDesc:SetTextColor(0.74, 0.72, 0.66)

    -- Fixed compact parent height; the table keeps its full designed size and the
    -- decorative bars attach to the frame edges.
    f:SetHeight(FRAME_H)

    -- Thin gold sides down the content area (between the two trims; the theme has none).
    local function Side(corner)
        local s = f:CreateTexture(nil, "BORDER")
        s:SetColorTexture(0.78, 0.62, 0.28, 0.7)
        s:SetWidth(2)
        local xoff = (corner == "LEFT") and 5 or -5
        s:SetPoint("TOP"..corner,    f, "TOP"..corner,    xoff, -TOP_TRIM + 6)
        s:SetPoint("BOTTOM"..corner, f, "BOTTOM"..corner, xoff,  BOT_TRIM - 6)
    end
    Side("LEFT"); Side("RIGHT")

    -- Column anchors shared by the header + rows.
    local DATE_X, SEAL_X, RESULT_X, SCORE_R = 16, 212, 260, -16

    local head = CreateFrame("Frame", nil, block)
    head:SetPoint("TOPLEFT",  block, "TOPLEFT",   8, -10)
    head:SetPoint("TOPRIGHT", block, "TOPRIGHT", -8, -10)
    head:SetHeight(18)
    local function HeadLabel(text, point, x)
        local fs = head:CreateFontString(nil, "OVERLAY")
        fs:SetFont("Fonts\\FRIZQT__.TTF", 12, "")
        fs:SetText(text)
        fs:SetTextColor(0.82, 0.72, 0.45)
        fs:SetPoint(point, head, point, x, 0)
        return fs
    end
    HeadLabel("DATES",  "LEFT",  DATE_X)
    HeadLabel("RESULT", "LEFT",  248)
    HeadLabel("SCORE",  "RIGHT", SCORE_R)

    local hLine = block:CreateTexture(nil, "ARTWORK")
    hLine:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.25)
    hLine:SetHeight(1)
    hLine:SetPoint("TOPLEFT",  head, "BOTTOMLEFT",  0, -6)
    hLine:SetPoint("TOPRIGHT", head, "BOTTOMRIGHT", 0, -6)

    local rowHost = CreateFrame("Frame", nil, block)
    rowHost:SetPoint("TOPLEFT",  hLine, "BOTTOMLEFT",  0, -2)
    rowHost:SetPoint("TOPRIGHT", hLine, "BOTTOMRIGHT", 0, -2)
    rowHost:SetPoint("BOTTOM",   block, "BOTTOM",      0,  6)

    local emptyFS = block:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    emptyFS:SetPoint("CENTER", rowHost, "CENTER", 0, 0)
    emptyFS:SetText("No campaigns have concluded yet.")
    emptyFS:SetTextColor(0.55, 0.55, 0.55)

    local rows = {}
    local function GetRow(i)
        if rows[i] then return rows[i] end
        local r = CreateFrame("Frame", nil, rowHost)
        r:SetHeight(ROW_H)
        r:SetPoint("TOPLEFT",  rowHost, "TOPLEFT",  0, -(i - 1) * ROW_H)
        r:SetPoint("TOPRIGHT", rowHost, "TOPRIGHT", 0, -(i - 1) * ROW_H)

        -- Dates: range + "72 Hour Campaign" + "Ended: …".
        r.dateRange = r:CreateFontString(nil, "OVERLAY")
        r.dateRange:SetFont("Fonts\\FRIZQT__.TTF", 15, "")
        r.dateRange:SetPoint("TOPLEFT", r, "TOPLEFT", DATE_X, -14)
        r.dateRange:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
        r.dateSub1 = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.dateSub1:SetPoint("TOPLEFT", r.dateRange, "BOTTOMLEFT", 0, -5)
        r.dateSub1:SetText("72 Hour Campaign")
        r.dateSub1:SetTextColor(0.62, 0.60, 0.55)
        r.dateSub2 = r:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        r.dateSub2:SetPoint("TOPLEFT", r.dateSub1, "BOTTOMLEFT", 0, -3)
        r.dateSub2:SetTextColor(0.62, 0.60, 0.55)

        -- Result: faction wax seal + "FACTION VICTORY" (vertically centered).
        r.seal = r:CreateTexture(nil, "ARTWORK")
        r.seal:SetSize(42, 42)
        r.seal:SetPoint("LEFT", r, "LEFT", SEAL_X, 0)
        r.result = r:CreateFontString(nil, "OVERLAY")
        r.result:SetFont("Fonts\\MORPHEUS.TTF", 16, "")
        r.result:SetPoint("LEFT", r, "LEFT", RESULT_X, 1)

        -- Score: two stacked crest + amount lines (winner on top), right-aligned.
        r.s1amount = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.s1amount:SetPoint("RIGHT", r, "RIGHT", SCORE_R, 12)
        r.s1amount:SetJustifyH("RIGHT")
        r.s1crest = r:CreateTexture(nil, "ARTWORK")
        r.s1crest:SetSize(18, 18)
        r.s1crest:SetPoint("RIGHT", r.s1amount, "LEFT", -6, 0)
        r.s2amount = r:CreateFontString(nil, "OVERLAY", "GameFontNormal")
        r.s2amount:SetPoint("RIGHT", r, "RIGHT", SCORE_R, -12)
        r.s2amount:SetJustifyH("RIGHT")
        r.s2crest = r:CreateTexture(nil, "ARTWORK")
        r.s2crest:SetSize(18, 18)
        r.s2crest:SetPoint("RIGHT", r.s2amount, "LEFT", -6, 0)

        -- Row separator.
        r.div = r:CreateTexture(nil, "ARTWORK")
        r.div:SetColorTexture(1, 1, 1, 0.06)
        r.div:SetHeight(1)
        r.div:SetPoint("BOTTOMLEFT",  r, "BOTTOMLEFT",   6, 0)
        r.div:SetPoint("BOTTOMRIGHT", r, "BOTTOMRIGHT", -6, 0)

        rows[i] = r
        return r
    end

    local function SetScoreLine(crest, amount, fac, val)
        crest:SetAtlas(FACTION_CREST[fac]); crest:Show()
        amount:SetText(FmtNumber(val) .. " WC")
        local col = FACTION_NAME_COLOR[fac] or GOLD
        amount:SetTextColor(col[1], col[2], col[3])
    end

    f.page = 1
    f.Refresh = function()
        local hist  = (WB.Campaign and WB.Campaign.History and WB.Campaign.History()) or {}
        local total = #hist
        local pages = math.max(1, math.ceil(total / ROWS_PER_PAGE))
        f.page = math.max(1, math.min(f.page, pages))

        for _, r in ipairs(rows) do r:Hide() end
        local base = (f.page - 1) * ROWS_PER_PAGE
        for slot = 1, ROWS_PER_PAGE do
            local e = hist[base + slot]
            if e then
                local r = GetRow(slot)
                r.dateRange:SetText(FmtRangeDates(e.startT, e.endT))
                local days = (e.startT and e.endT) and math.max(1, math.floor((e.endT - e.startT) / 86400 + 0.5)) or 3
                r.dateSub1:SetText(days .. "-Day Campaign")
                r.dateSub2:SetText("Ended: " .. date("%b %d", e.endT or 0) .. ", " .. (date("%I:%M %p", e.endT or 0):gsub("^0", "")))

                local w = e.winner
                if w == "Horde" or w == "Alliance" then
                    r.seal:SetAtlas(FACTION_SEAL[w]); r.seal:Show()
                    r.result:SetText(w:upper() .. " VICTORY")
                    local col = FACTION_NAME_COLOR[w] or GOLD
                    r.result:SetTextColor(col[1], col[2], col[3])
                elseif w == "Tie" then
                    r.seal:SetAtlas(FACTION_SEAL.Tie); r.seal:Show()
                    r.result:SetText("STALEMATE")
                    r.result:SetTextColor(0.80, 0.76, 0.64)
                else
                    r.seal:Hide()
                    r.result:SetText("NO CONTEST")
                    r.result:SetTextColor(0.70, 0.70, 0.70)
                end

                local h, a = e.horde or 0, e.alliance or 0
                if a > h then
                    SetScoreLine(r.s1crest, r.s1amount, "Alliance", a)
                    SetScoreLine(r.s2crest, r.s2amount, "Horde",    h)
                else
                    SetScoreLine(r.s1crest, r.s1amount, "Horde",    h)
                    SetScoreLine(r.s2crest, r.s2amount, "Alliance", a)
                end

                r.div:SetShown(slot < ROWS_PER_PAGE and hist[base + slot + 1] ~= nil)
                r:Show()
            end
        end

        -- Size the table to the rows actually present (collapse to a small height when
        -- empty), and the frame to that — so the panel hugs its content with no big
        -- reserved blank area. 201 = the fixed chrome around the table (title, desc,
        -- pager, footer); the table's own header/padding is the +42.
        pageText:SetText(("Page %d of %d"):format(f.page, pages))
        prevBtn:SetEnabled(f.page > 1)
        nextBtn:SetEnabled(f.page < pages)
        pager:SetShown(total > 0)
        aboutDiv:ClearAllPoints()
        if total > 0 then
            aboutDiv:SetPoint("TOP", pager, "BOTTOM", 0, -4)
        else
            aboutDiv:SetPoint("TOP", block, "BOTTOM", 0, -8)
        end
        aboutDiv:SetPoint("LEFT",  f, "LEFT",   24, 0)
        aboutDiv:SetPoint("RIGHT", f, "RIGHT", -24, 0)
        emptyFS:SetShown(total == 0)
    end

    prevBtn:SetScript("OnClick", function() f.page = f.page - 1; f.Refresh() end)
    nextBtn:SetScript("OnClick", function() f.page = f.page + 1; f.Refresh() end)

    tinsert(UISpecialFrames, "WarBoardCampaignResults")  -- Escape closes it
    return f
end

function WB.UI.ShowCampaignResults()
    if not resultsPopup then resultsPopup = CreateCampaignResultsPopup() end
    resultsPopup.page = 1
    resultsPopup.Refresh()
    resultsPopup:Show()
    resultsPopup:Raise()
end

-- Live-refresh the results window if it's open (e.g. a new campaign just locked).
function WB.UI.RefreshCampaignResults()
    if resultsPopup and resultsPopup:IsShown() and resultsPopup.Refresh then
        resultsPopup.Refresh()
    end
end

-- ── Live campaign-winner popup ──────────────────────────────────────────────────
-- A brief banner that drops in near the top of the screen the moment a campaign result
-- locks (fired from Campaign AnnounceResult). Built from the Horde-vs-Alliance store theme
-- panels (top + bottom, stacked into one window). Reuses the same wax seals + faction
-- symbols as the Campaign Results window. Fades in, lingers, fades out; "View Campaign
-- Results" opens the full history. Test it with /wb victor (no effect on live results).
local victorPopup

-- winner key → headline + colour + the wax seal already used in the results window.
local VICTOR = {
    Horde    = { label = "HORDE VICTORY",    color = { 1.00, 0.22, 0.18 }, seal = FACTION_SEAL.Horde    },
    Alliance = { label = "ALLIANCE VICTORY", color = { 0.26, 0.60, 1.00 }, seal = FACTION_SEAL.Alliance },
    Tie      = { label = "STALEMATE",        color = { 0.86, 0.80, 0.55 }, seal = FACTION_SEAL.Tie      },
    None     = { label = "NO CONTEST",       color = { 0.70, 0.70, 0.70 }, seal = FACTION_SEAL.Tie      },
}
local HORDE_WC_COLOR    = { 1.00, 0.38, 0.34 }
local ALLIANCE_WC_COLOR = { 0.42, 0.68, 1.00 }
local VICTOR_SOUND = {
    Horde    = 2146606, -- sound/music/battleforazeroth/mus_80_hordevictory_3_h.mp3
    Alliance = 2146576, -- sound/music/battleforazeroth/mus_80_alliancevictory_3_h.mp3
}
-- Played when the VIEWER's faction LOSES (the enemy won) — keyed by the viewer's faction.
-- SoundKitIDs (→ PlaySound), with the Leatrix FileDataIDs noted for reference.
local DEFEAT_SOUND = {
    Horde    = 34744, -- UI_BrawlersGuild_Defeat_Horde    (Leatrix FileDataID 775178)
    Alliance = 34745, -- UI_BrawlersGuild_Defeat_Alliance (Leatrix FileDataID 775176)
}

local function PlayWarBoardSound(sound)
    if not sound then return end
    if PlaySoundFile then
        local ok = PlaySoundFile(sound, "Master")
        if ok then return end
    end
    if PlaySound then
        local ok = PlaySound(sound, "Master")
        if ok then return end
    end
    if PlayMusic then
        PlayMusic(sound)
    end
end

-- ── Victory TalkingHead quips ──────────────────────────────────────────────────
-- A faction-leader voice line that pops up on top of the victor banner. Same
-- override-GetCurrentLineInfo trick that SemiDevTools' VO tester uses: we feed
-- TalkingHeadFrame:PlayCurrent a one-shot lie about what line is "next", then
-- drive the talk animation directly so intro/loop animations actually fire.
-- Each line plays through ONE of two fields, depending on what kind of id it is:
--   soundKitID → a SoundKit, played via PlaySound  (Rokhan)
--   soundFile  → a raw FileDataID, played via PlaySoundFile  (Genn's vo_*.ogg)
-- A FileDataID handed to PlaySound (or a SoundKit handed to PlaySoundFile) silently
-- no-ops, so the two must not be crossed.
local VICTOR_TH = {
    Horde = {
        soundKitID = 121647,
        modelID    = 116250,
        cameraID   = 102,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Rokhan",
        text       = "Heh, you keep this up mon, and we be winning this war in no time!",
        duration   = 8,
    },
    Alliance = {
        soundKitID = 122898,    -- vo_81_genn_greymane_30_m.ogg (Leatrix lists FileDataID 2443056)
        modelID    = 62093,
        cameraID   = 88,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Genn Greymane",
        text       = "An assertive victory, and a decisive blow against the Horde.",
        duration   = 8,
    },
}

-- Named, general-purpose TalkingHead lines that anything can fire by key via
-- WB.UI.PlayTalkingHead("<key>"). Objective proximity triggers (and future systems)
-- reference these. Same field shape as VICTOR_TH.
WB.UI = WB.UI or {}
WB.UI.TalkingHeads = {
    -- Horde "Mine Your Manners" (Azshara) objective proximity line.
    cromush_engage = {
        soundKitID = 121078,    -- VO_81_High_Warlord_Cromush_24_M (Leatrix lists FileDataID 2410910)
        modelID    = 63276,
        cameraID   = 102,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "High Warlord Cromush",
        text       = "Our troops have engaged the enemy. Join in the fight and bring glory to the Horde!",
        duration   = 8,
    },
    -- Horde "Barking Up the Wrong Tree" (Ashenvale / Warsong Lumber Camp) proximity line.
    usha_cornered = {
        soundKitID = 121130,    -- VO_81_Usha_Eyegouge_13_F (Leatrix lists FileDataID 2414904)
        modelID    = 89223,
        cameraID   = 120,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Usha Eyegouge",
        text       = "The Alliance thinks it has us cornered, but if there's anyone who can fight their way out of a valley, its me... Let's kill them all!",
        duration   = 8,
    },
    -- Horde "Leaf Them No Quarter" (Ashenvale / Astranaar) proximity line.
    usha_simple_plan = {
        soundKitID = 121121,    -- VO_81_Usha_Eyegouge_04_F (Leatrix lists FileDataID 2414895)
        modelID    = 89223,
        cameraID   = 120,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Usha Eyegouge",
        text       = "The plan here is simple... You see the Alliance, you kill the Alliance! I don't care how you do it!",
        duration   = 8,
    },
    -- Horde "Secure the Merchant Coast" (Northern Barrens) proximity line.
    cromush_supply_lines = {
        soundKitID = 120808,    -- VO_81_High_Warlord_Cromush_09_M (Leatrix lists FileDataID 2400926)
        modelID    = 63276,
        cameraID   = 102,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "High Warlord Cromush",
        text       = "We can strike a blow at the enemies' supply lines here. Keep your focus on the target, soldier. Lok-Narash!",
        duration   = 8,
    },
    -- Horde "Battle for the Scar" (Southern Barrens) proximity line.
    cromush_battlefield = {
        soundKitID = 121079,    -- VO_81_High_Warlord_Cromush_25_M (Leatrix lists FileDataID 2410911)
        modelID    = 63276,
        cameraID   = 102,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "High Warlord Cromush",
        text       = "We will meet the enemy on the battlefield! Crush them, and secure our victory!",
        duration   = 8,
    },
    -- Horde "Quell the Danil" (Hinterlands) proximity line.
    rokhan_crush_insects = {
        soundKitID = 104595,    -- VO_801_Rokhan_07_M (Leatrix lists FileDataID 1992023)
        modelID    = 116250,
        cameraID   = 102,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Rokhan",
        text       = "Send in our forces! We got to crush these Alliance insects before they can spread!",
        duration   = 8,
    },
    -- Horde "Tomb It May Concern" (Badlands) proximity line. Custom packaged sound file
    -- (a path, not a SoundKit), so it plays through the PlaySoundFile branch.
    rexxar_prize_hunt = {
        soundFile  = "Interface\\AddOns\\WarBoard\\Media\\Sounds\\VO_Rexxar_PrizeHuntBegin.ogg",
        modelID    = 60766,
        cameraID   = 102,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Rexxar",
        text       = "The Alliance must not claim this prize... Let the hunt begin!",
        duration   = 6.2,   -- measured length of VO_Rexxar_PrizeHuntBegin.ogg (~6.21s)
    },
    -- Horde "Stormpike Offensive" (Hillsbrad Foothills) proximity line.
    rokhan_make_us_proud = {
        soundKitID = 121635,    -- VO_81_Rokhan_03_M (Leatrix lists FileDataID 2430368)
        modelID    = 116250,
        cameraID   = 102,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Rokhan",
        text       = "The Horde need ya here mon. Get in there, and make us proud.",
        duration   = 8,
    },

    -- ── Alliance objective lines ────────────────────────────────────────────────
    -- Shandris Feathermoon (modelID 116646, camera 110).
    shandris_environmental = {
        soundKitID = 122220,    -- vo_81_shandris_feathermoon_02_f (Leatrix lists FileDataID 2434825)
        modelID    = 116646,
        cameraID   = 110,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Shandris Feathermoon",
        text       = "They believe they can stand against us. Demonstrate just how wrong they are.",
        duration   = 8,
    },
    shandris_dock = {
        soundKitID = 122226,    -- vo_81_shandris_feathermoon_08_f (Leatrix lists FileDataID 2434831)
        modelID    = 116646,
        cameraID   = 110,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Shandris Feathermoon",
        text       = "The Horde will find no victory here. Eliminate its officers and scatter its armies.",
        duration   = 8,
    },
    shandris_peril = {
        soundKitID = 122221,    -- vo_81_shandris_feathermoon_03_f (Leatrix lists FileDataID 2434826)
        modelID    = 116646,
        cameraID   = 110,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Shandris Feathermoon",
        text       = "These savages only understand the language of death... It is time we speak to them.",
        duration   = 8,
    },
    shandris_morshan = {
        soundKitID = 122223,    -- vo_81_shandris_feathermoon_05_f (Leatrix lists FileDataID 2434828)
        modelID    = 116646,
        cameraID   = 110,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Shandris Feathermoon",
        text       = "Our enemies move against us. Stand firm and defeat them here to strike a vital blow to their efforts.",
        duration   = 8,
    },
    -- High Commander Halford Wyrmbane (modelID 85758, camera 324).
    wyrmbane_scar = {
        soundKitID = 109787,    -- vo_801_high_commander_halford_wyrmbane_03 (Leatrix lists FileDataID 2019088)
        modelID    = 85758,
        cameraID   = 324,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "High Commander Halford Wyrmbane",
        text       = "Several of my soldiers have fallen trying to take the nearby area. End the threat there now.",
        duration   = 8,
    },
    -- Falstad Wildhammer (modelID 32681, camera 90).
    falstad_frostwolf = {
        soundKitID = 121099,    -- vo_81_falstad_wildhammer_09_m (Leatrix lists FileDataID 2413833)
        modelID    = 32681,
        cameraID   = 90,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Falstad Wildhammer",
        text       = "We've got trouble brewin' over here! Let's put a stop to it!",
        duration   = 8,
    },
    falstad_research = {
        soundKitID = 121100,    -- vo_81_falstad_wildhammer_10_m (Leatrix lists FileDataID 2413834)
        modelID    = 32681,
        cameraID   = 90,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Falstad Wildhammer",
        text       = "My gryphon riders have spotted the enemy! Knock em' out hero!",
        duration   = 8,
    },
    -- Genn Greymane (modelID 62093, camera 120).
    genn_tomb = {
        soundKitID = 122891,    -- vo_81_genn_greymane_23_m (Leatrix lists FileDataID 2443049)
        modelID    = 62093,
        cameraID   = 120,
        animKit    = 0,
        animIntro  = 60,
        animLoop   = 60,
        speaker    = "Genn Greymane",
        text       = "These cowards are attempting to seize control of this location. Shut them down.",
        duration   = 8,
    },
}

-- Sound-driven close. We play the VO ourselves with runFinishCallback enabled, so
-- WoW fires SOUNDKIT_FINISHED (with our sound handle) the instant the clip ends. On
-- that we fade the TalkingHead out slowly. The override hands the frame a long
-- internal duration so Blizzard's own close timer never pre-empts the line.
local _thSoundHandle           -- handle of the VO we're currently waiting on
local _thFallback              -- safety timer in case SOUNDKIT_FINISHED never arrives
local _thEventFrame = CreateFrame("Frame")
local _thCombatHider           -- reused frame to defer a combat-blocked Hide() to PLAYER_REGEN_ENABLED

local function CloseTalkingHead()
    if _thFallback then _thFallback:Cancel(); _thFallback = nil end
    _thSoundHandle = nil
    _thEventFrame:UnregisterEvent("SOUNDKIT_FINISHED")
    if not TalkingHeadFrame or not TalkingHeadFrame:IsShown() then return end
    local model = TalkingHeadFrame.MainFrame and TalkingHeadFrame.MainFrame.Model
    UIFrameFadeOut(TalkingHeadFrame, 1.5, TalkingHeadFrame:GetAlpha() or 1, 0)
    -- The 3D model ignores the frame's alpha, so fade ITS own alpha in parallel — otherwise
    -- the character just snaps out of existence when the frame finishes fading.
    if model then UIFrameFadeOut(model, 1.5, model:GetAlpha() or 1, 0) end
    C_Timer.After(1.5, function()
        if not TalkingHeadFrame then return end
        if UIFrameFadeRemoveFrame then
            UIFrameFadeRemoveFrame(TalkingHeadFrame)
            if model then UIFrameFadeRemoveFrame(model) end
        end

        -- Restore alpha on the frame, its MainFrame, and the model, then hide. The chrome
        -- (background, name, text) honors frame alpha and the model honors its own — leaving
        -- either stuck at 0 makes the next talking head (ours OR another addon's) render wrong.
        local function finishHide()
            if not TalkingHeadFrame then return end
            TalkingHeadFrame:Hide()
            TalkingHeadFrame:SetAlpha(1)
            if TalkingHeadFrame.MainFrame then TalkingHeadFrame.MainFrame:SetAlpha(1) end
            if model then model:SetAlpha(1) end
        end

        -- TalkingHeadFrame is an EditMode-managed frame, so Hide() routes through the PROTECTED
        -- HideBase() — which is BLOCKED if we're in combat (the head can open out of combat but
        -- finish closing once combat started). SetAlpha isn't protected, so keep it invisible now
        -- and defer the real Hide() until we drop combat.
        if InCombatLockdown() then
            TalkingHeadFrame:SetAlpha(0)
            if model then model:SetAlpha(0) end
            if not _thCombatHider then _thCombatHider = CreateFrame("Frame") end
            _thCombatHider:SetScript("OnEvent", function(self)
                self:UnregisterEvent("PLAYER_REGEN_ENABLED")
                self:SetScript("OnEvent", nil)
                finishHide()
            end)
            _thCombatHider:RegisterEvent("PLAYER_REGEN_ENABLED")
        else
            finishHide()
        end
    end)
end

_thEventFrame:SetScript("OnEvent", function(_, event, handle)
    if event == "SOUNDKIT_FINISHED" and _thSoundHandle and handle == _thSoundHandle then
        -- Stop listening now, then let the frame linger ~2s before fading out.
        _thSoundHandle = nil
        _thEventFrame:UnregisterEvent("SOUNDKIT_FINISHED")
        if _thFallback then _thFallback:Cancel(); _thFallback = nil end
        C_Timer.After(2, CloseTalkingHead)
    end
end)

-- Reusable TalkingHead player. `spec` is either a line table (fields below) or a string
-- key into WB.UI.TalkingHeads. Drives a Blizzard TalkingHead via the GetCurrentLineInfo
-- override, plays the VO loud on the Master channel, syncs the talk animation to model
-- load, and closes ~2s after the clip ends. Used by the victor popup AND objective
-- proximity lines. Fields: soundKitID | soundFile, modelID, cameraID, animKit, animIntro,
-- animLoop, speaker, text, duration.
function WB.UI.PlayTalkingHead(spec)
    if type(spec) == "string" then spec = WB.UI.TalkingHeads and WB.UI.TalkingHeads[spec] end
    local th = spec
    if not th then return end

    -- Presentation options (Options → AddOns → War Board):
    --   talkingHeadFrames → show the model frame; when off, print the line in chat as NPC speech.
    --   talkingHeadSound  → play the voice at all.
    local soundOn  = not (WB.Options and not WB.Options.Get("talkingHeadSound"))
    local framesOn = not (WB.Options and not WB.Options.Get("talkingHeadFrames"))

    -- Sound-only helper (used by the chat path; the frame path plays it on model-load instead).
    local function PlayVoice()
        if not soundOn then return end
        if th.soundFile and PlaySoundFile then
            PlaySoundFile(th.soundFile, "Master")
        elseif th.soundKitID and PlaySound then
            PlaySound(th.soundKitID, "Master", false, true)
        end
    end
    local function ChatLine()
        if th.speaker and th.text and th.text ~= "" then
            print(("|cffffd200%s says:|r %s"):format(th.speaker, th.text))
        end
    end

    -- The TalkingHead frame is EditMode-managed and can't be shown in combat (protected), so in
    -- combat we always fall back to the chat presentation.
    if InCombatLockdown() then framesOn = false end

    -- CHAT presentation (frames off, or in combat): print the line as NPC speech + play the voice.
    if not framesOn then
        ChatLine()
        PlayVoice()
        return
    end

    -- ── FRAME presentation ──
    -- TalkingHeadFrame lives in Blizzard_TalkingHeadUI, a load-on-demand addon that doesn't exist
    -- until the game first triggers a real talking head. Force-load it so the frame is available.
    if not TalkingHeadFrame then
        local load = (C_AddOns and C_AddOns.LoadAddOn) or LoadAddOn
        if load then pcall(load, "Blizzard_TalkingHeadUI") end
    end
    if not TalkingHeadFrame or not C_TalkingHead or not C_TalkingHead.GetCurrentLineInfo then
        ChatLine(); PlayVoice(); return   -- frame unavailable → chat fallback so the line isn't lost
    end

    -- We tell the frame the line is silent (vo = 0) and play the voice ourselves (when enabled)
    -- through the Master channel. A SoundKit goes through PlaySound, a FileDataID through
    -- PlaySoundFile. When a clip will play we hold the frame open (long duration) and close it on
    -- SOUNDKIT_FINISHED; with sound off the frame just self-closes after its duration.
    local hasSound = soundOn and ((th.soundFile and PlaySoundFile) or (th.soundKitID and PlaySound))
    local holdDuration = hasSound and 600 or (th.duration or 8)

    local model = TalkingHeadFrame.MainFrame and TalkingHeadFrame.MainFrame.Model

    -- Defensive reset: a prior custom close (ours or another addon's) can leave the frame
    -- mid-fade — its MainFrame alpha at 0 (chrome hidden) or the model's alpha at 0 (invisible
    -- character). Detach everything from the fade system and force a fully-opaque start so this
    -- line always shows correctly, no matter what state the frame was left in.
    if UIFrameFadeRemoveFrame then UIFrameFadeRemoveFrame(TalkingHeadFrame) end
    TalkingHeadFrame:SetAlpha(1)
    if TalkingHeadFrame.MainFrame then TalkingHeadFrame.MainFrame:SetAlpha(1) end
    if model then
        if UIFrameFadeRemoveFrame then UIFrameFadeRemoveFrame(model) end
        model:SetAlpha(1)
    end

    local origFn = C_TalkingHead.GetCurrentLineInfo
    C_TalkingHead.GetCurrentLineInfo = function()
        C_TalkingHead.GetCurrentLineInfo = origFn  -- restore on first call
        return th.modelID, th.cameraID, 0, holdDuration,
               1, 1, th.speaker or "", th.text or "", true, false
    end

    local ok = pcall(TalkingHeadFrame.PlayCurrent, TalkingHeadFrame)
    if not ok then
        if C_TalkingHead.GetCurrentLineInfo ~= origFn then
            C_TalkingHead.GetCurrentLineInfo = origFn
        end
        return
    end

    -- The model streams in asynchronously (Genn's takes ~2s). If we start the voice at
    -- t=0 but the animation can't apply until the mesh exists, the line runs ahead of his
    -- gestures. So defer BOTH the sound and the talk animation to OnModelLoaded, so audio
    -- and movement begin together the instant the model is ready.
    local prevOnLoaded = model and model:GetScript("OnModelLoaded")
    local started = false
    local readyPoll

    local function startLine()
        if started then return end
        started = true
        if readyPoll then readyPoll:Cancel(); readyPoll = nil end
        -- One-shot: restore the model's own load handler so this never leaks onto a
        -- later, unrelated talking head.
        if model then model:SetScript("OnModelLoaded", prevOnLoaded) end

        -- Voice line (only when the sound option is on). A SoundKit goes through PlaySound; a
        -- custom packaged file goes through PlaySoundFile (same call the JARVIS dialogue uses).
        local willPlay, handle, usedFile
        if soundOn then
            if th.soundFile and PlaySoundFile then
                willPlay, handle = PlaySoundFile(th.soundFile, "Master")
                usedFile = true
            elseif th.soundKitID and PlaySound then
                willPlay, handle = PlaySound(th.soundKitID, "Master", false, true)
            end
        end

        -- Close timing differs by source:
        --   • SoundKit → SOUNDKIT_FINISHED fires the instant the clip ends → precise close,
        --     with a long safety fallback.
        --   • Custom file → SOUNDKIT_FINISHED does NOT reliably fire for PlaySoundFile, so we
        --     close on a duration timer instead (exactly how JARVIS times its dialogue). Tune
        --     the line's `duration` to the real clip length.
        if usedFile then
            if _thFallback then _thFallback:Cancel() end
            _thFallback = C_Timer.NewTimer((th.duration or 8) + 2, function()
                _thFallback = nil
                CloseTalkingHead()
            end)
        else
            if willPlay and handle then
                _thSoundHandle = handle
                _thEventFrame:RegisterEvent("SOUNDKIT_FINISHED")
            end
            if hasSound then
                if _thFallback then _thFallback:Cancel() end
                -- Safety net: if SOUNDKIT_FINISHED never arrives, close after a generous window.
                _thFallback = C_Timer.NewTimer(30, function()
                    _thFallback = nil
                    CloseTalkingHead()
                end)
            end
        end

        -- Talk animation, mirroring Blizzard's SetupAnimations: animKit > 0 → PlayAnimKit;
        -- else animIntro > 0 → SetAnimation(intro) then loop animLoop (or re-loop intro);
        -- else animLoop → SetAnimation(loop).
        if model then
            local animKit, animIntro, animLoop = th.animKit, th.animIntro, th.animLoop
            model.shouldLoop = true
            if animKit and animKit > 0 then
                if model.PlayAnimKit then pcall(model.PlayAnimKit, model, animKit, true) end
            elseif animIntro and animIntro > 0 then
                pcall(model.SetAnimation, model, animIntro, 0)
                local nextAnim = (animLoop and animLoop > 0) and animLoop or animIntro
                model:SetScript("OnAnimFinished", function(self)
                    if self.shouldLoop then pcall(self.SetAnimation, self, nextAnim, 0) end
                end)
            elseif animLoop and animLoop > 0 then
                pcall(model.SetAnimation, model, animLoop, 0)
            end
        end
    end

    if model then
        -- Two triggers race to start the line; the first wins (startLine is one-shot):
        --   • OnModelLoaded — fires when a model has to stream in fresh.
        --   • a readiness poll — covers a CACHED model (same speaker replayed back to back),
        --     where SetDisplayInfo doesn't reload so OnModelLoaded may never fire. GetModelFileID
        --     reports a loaded model right away, so the voice starts promptly instead of stalling
        --     on a timer. For a fresh stream it stays 0 until the mesh is ready, so audio still
        --     syncs to the model appearing. The 5s cap is the last-resort safety net.
        model:SetScript("OnModelLoaded", function(self, ...)
            if prevOnLoaded then prevOnLoaded(self, ...) end
            startLine()
        end)
        local waited = 0
        readyPoll = C_Timer.NewTicker(0.05, function()
            waited = waited + 0.05
            local fid = model.GetModelFileID and model:GetModelFileID()
            if (fid and fid ~= 0) or waited >= 5 then
                startLine()   -- model ready (or hard cap reached); startLine cancels this ticker
            end
        end)
    else
        startLine()
    end
end

local function CreateVictorPopup()
    local W     = 420
    local TOP_H = math.floor(W * 133 / 360 + 0.5)   -- top panel keeps the 360×133 aspect
    local BOT_H = math.floor(W * 77  / 360 + 0.5)    -- bottom panel keeps the 360×77 aspect
    local H     = 190

    local f = CreateFrame("Frame", "WarBoardVictorPopup", UIParent, "BackdropTemplate")
    f:SetSize(W, H)
    f:SetPoint("TOP", UIParent, "TOP", 0, -140)
    f:SetFrameStrata("HIGH")
    f:SetToplevel(true)
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)
    -- Opaque dark body — same approach as the Campaign Results window. The store theme panels
    -- are decorative FRAMES with transparent centers; without this fill the game world shows
    -- straight through them.
    f:SetBackdrop({
        bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
        tile = true, tileSize = 16,
        insets = { left = 0, right = 0, top = 0, bottom = 0 },
    })
    f:SetBackdropColor(0.04, 0.045, 0.06, 0.98)
    f:Hide()

    -- Theme art (ARTWORK, above the backdrop). The store theme atlases have transparent
    -- padding around the decorative ribbon — the visible art is in the lower portion of the
    -- top atlas and the upper portion of the bottom atlas. The trick (same idea as the
    -- Campaign Results window) is to anchor the textures so they SPILL outside the frame
    -- edges, which slides the visible ribbon into place at the very top and bottom. Tune
    -- TOP_SPILL / BOT_SPILL if the ribbons drift away from the edges.
    local TOP_SPILL = 118
    local BOT_SPILL = 50
    local top = f:CreateTexture(nil, "ARTWORK")
    top:SetAtlas("perks-theme-hordevsalliance-tp-topsmall", false)
    top:SetPoint("TOPLEFT",  f, "TOPLEFT",  -8, TOP_SPILL)
    top:SetPoint("TOPRIGHT", f, "TOPRIGHT",  8, TOP_SPILL)
    top:SetHeight(TOP_H)
    local bot = f:CreateTexture(nil, "ARTWORK")
    bot:SetAtlas("perks-theme-hordevsalliance-tp-bottomsmall", false)
    bot:SetPoint("BOTTOMLEFT",  f, "BOTTOMLEFT",  -8, -BOT_SPILL)
    bot:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT",  8, -BOT_SPILL)
    bot:SetHeight(BOT_H)

    local function Side(corner)
        local s = f:CreateTexture(nil, "BORDER")
        s:SetColorTexture(0.78, 0.62, 0.28, 0.7)
        s:SetWidth(2)
        s:SetPoint("TOP"..corner,    f, "TOP"..corner,    0, -10)
        s:SetPoint("BOTTOM"..corner, f, "BOTTOM"..corner, 0,  10)
    end
    Side("LEFT"); Side("RIGHT")

    local close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    close:SetSize(26, 26)
    close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -4, -4)
    close:SetScript("OnClick", function() WB.UI.HideVictorPopup() end)

    -- Wax seal, far left, level with the headline.
    local seal = f:CreateTexture(nil, "ARTWORK")
    seal:SetSize(52, 52)
    seal:SetPoint("TOPLEFT", f, "TOPLEFT", 24, -30)
    f.seal = seal

    -- Headline (HORDE/ALLIANCE VICTORY), centred in the top panel.
    local title = f:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\MORPHEUS.TTF", 28, "")
    title:SetPoint("TOP", f, "TOP", 0, -34)
    title:SetShadowColor(0, 0, 0, 1)
    title:SetShadowOffset(1, -1)
    f.title = title

    -- Date range under the headline.
    local dates = f:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    dates:SetPoint("TOP", title, "BOTTOM", 0, -6)
    dates:SetTextColor(0.86, 0.83, 0.74)
    f.dates = dates

    -- WC totals: Horde (left) | Alliance (right), split by a gold divider.
    local div = f:CreateTexture(nil, "ARTWORK")
    div:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.45)
    div:SetSize(1, 28)
    div:SetPoint("TOP", dates, "BOTTOM", 0, -12)

    local hWC = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    hWC:SetPoint("RIGHT", div, "LEFT", -26, 0)
    hWC:SetTextColor(unpack(HORDE_WC_COLOR))
    local hIcon = f:CreateTexture(nil, "ARTWORK")
    hIcon:SetAtlas("HordeSymbol", false)
    hIcon:SetSize(22, 22)
    hIcon:SetPoint("RIGHT", hWC, "LEFT", -6, 0)
    f.hWC = hWC

    local aIcon = f:CreateTexture(nil, "ARTWORK")
    aIcon:SetAtlas("AllianceSymbol", false)
    aIcon:SetSize(22, 22)
    aIcon:SetPoint("LEFT", div, "RIGHT", 26, 0)
    local aWC = f:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    aWC:SetPoint("LEFT", aIcon, "RIGHT", 6, 0)
    aWC:SetTextColor(unpack(ALLIANCE_WC_COLOR))
    f.aWC = aWC

    -- "View Campaign Results" button.
    local btn = CreateFrame("Button", nil, f, "BackdropTemplate")
    btn:SetSize(184, 24)
    btn:SetPoint("BOTTOM", f, "BOTTOM", 0, 30)
    btn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    btn:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
    btn:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95)
    local btnText = btn:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    btnText:SetPoint("CENTER", btn, "CENTER", 0, 0)
    btnText:SetText("View Campaign Results")
    btnText:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    btn:SetScript("OnEnter", function(self)
        self:SetBackdropBorderColor(0.90, 0.70, 0.35, 1)
        btnText:SetTextColor(1, 0.95, 0.72)
    end)
    btn:SetScript("OnLeave", function(self)
        self:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95)
        btnText:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    end)
    btn:SetScript("OnClick", function()
        if WB.UI.ShowCampaignResults then WB.UI.ShowCampaignResults() end
        WB.UI.HideVictorPopup()
    end)

    function f:Populate(entry)
        local v = VICTOR[entry and entry.winner] or VICTOR.None
        self.seal:SetAtlas(v.seal, false)
        self.title:SetText(v.label)
        self.title:SetTextColor(v.color[1], v.color[2], v.color[3])
        self.dates:SetText(FmtRangeDates(entry and entry.startT, entry and entry.endT))
        self.hWC:SetText(FmtNumber(math.floor(tonumber(entry and entry.horde)    or 0)) .. " WC")
        self.aWC:SetText(FmtNumber(math.floor(tonumber(entry and entry.alliance) or 0)) .. " WC")
    end

    return f
end

function WB.UI.HideVictorPopup()
    local p = victorPopup
    if not p then return end
    if p._fadeTimer then p._fadeTimer:Cancel(); p._fadeTimer = nil end
    if p._hideTimer then p._hideTimer:Cancel(); p._hideTimer = nil end
    if p._thTimer   then p._thTimer:Cancel();   p._thTimer   = nil end
    p:Hide()
end

-- Show the banner for `entry` ({ winner, horde, alliance, startT, endT }). Fades in, lingers
-- ~14s, then fades out — or dismiss early via the X / the View button.
function WB.UI.ShowVictorPopup(entry)
    if not victorPopup then victorPopup = CreateVictorPopup() end
    local p = victorPopup
    if p._fadeTimer then p._fadeTimer:Cancel() end
    if p._hideTimer then p._hideTimer:Cancel() end
    if p._thTimer   then p._thTimer:Cancel()   end
    p:Populate(entry or {})
    local winner = entry and entry.winner
    local me     = WB.faction
    -- A real faction won that ISN'T the viewer's → the viewer lost.
    local lost   = (winner == "Horde" or winner == "Alliance") and me and winner ~= me
    p:Show()
    p:Raise()
    UIFrameFadeIn(p, 0.4, 0, 1)
    if lost then
        -- The enemy won: the victory banner still shows, but skip their fanfare and their
        -- leader's TalkingHead — play a defeat sting for the viewer's faction instead.
        local defeat = DEFEAT_SOUND[me]
        if defeat and PlaySound then PlaySound(defeat, "Master") end
    else
        -- The viewer won (or it's a tie / no contest, which have no fanfare or line):
        -- play the victory fanfare, then slide the winner's leader in a beat later.
        PlayWarBoardSound(VICTOR_SOUND[winner])
        local th = VICTOR_TH[winner]
        if th then
            p._thTimer = C_Timer.NewTimer(1.5, function() WB.UI.PlayTalkingHead(th) end)
        end
    end
    p._fadeTimer = C_Timer.NewTimer(14, function()
        UIFrameFadeOut(p, 1.0, p:GetAlpha(), 0)
        p._hideTimer = C_Timer.NewTimer(1.0, function() p:Hide() end)
    end)
end

-- ── Frame ──────────────────────────────────────────────────────────────────────

local function CreateWarBoardFrame()
    -- Choose faction art on login. Same NineSlice logic; only textures + accent
    -- differ. WB.faction is stored for the (future) faction-separate data feeds.
    local art, factionName = GetFactionArt()
    ACCENT     = art.accent
    WB.faction = factionName

    local frame = CreateFrame("Frame", "WarBoardFrame", UIParent)
    frame:SetSize(FRAME_WIDTH, FRAME_HEIGHT)
    frame:SetPoint("CENTER")
    frame:SetFrameStrata("HIGH")
    frame:SetMovable(true)
    frame:EnableMouse(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", frame.StartMoving)
    frame:SetScript("OnDragStop", frame.StopMovingOrSizing)
    frame:Hide()

    -- Background tile, inset 6px on all sides so it stays inside the border edges
    local bg = frame:CreateTexture(nil, "BACKGROUND", nil, -1)
    bg:SetAtlas(art.background)
    bg:SetPoint("TOPLEFT",     frame, "TOPLEFT",     6, -6)
    bg:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -6,  6)
    bg:SetHorizTile(true)
    bg:SetVertTile(true)
    bg:SetAlpha(0.82)

    -- NineSlice border (corners + edges)
    ApplyFactionNineSlice(frame, art)

    -- Header banner: native atlas size, centered and protruding above the top edge.
    local header = frame:CreateTexture(nil, "ARTWORK")
    header:SetAtlas(art.header, true)
    header:SetPoint("TOP", frame, "TOP", 0, art.headerY)

    -- Title sits inside the frame, below the protruding header crest
    local title = frame:CreateFontString(nil, "OVERLAY")
    title:SetFont("Fonts\\MORPHEUS.TTF", 24, "OUTLINE")
    title:SetPoint("TOP", frame, "TOP", 0, -38)
    title:SetText("War Board")

    -- Close button in the top-right corner
    local closeBtn = CreateFrame("Button", "WarBoardCloseButton", frame, "UIPanelCloseButton")
    closeBtn:SetSize(26, 26)
    closeBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -6)
    closeBtn:SetScript("OnClick", function() frame:Hide() end)

    -- Community button in the TOP-LEFT corner — mirror of the shard pill on the right,
    -- frame-level so it's always reachable (it used to sit under the Leader Board). A bit
    -- larger than before for prominence.
    local communityBtn = CreateFrame("Button", "WarBoardCommunityButton", frame, "BackdropTemplate")
    communityBtn:SetSize(140, 30)
    communityBtn:SetPoint("TOPLEFT", frame, "TOPLEFT", 63, -36)
    communityBtn:SetBackdrop({
        bgFile   = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 12,
        insets   = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    communityBtn:SetBackdropColor(0.10, 0.09, 0.08, 0.92)
    communityBtn:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95)
    local cbLabel = communityBtn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    cbLabel:SetPoint("CENTER")
    cbLabel:SetText("Community")
    cbLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    communityBtn:SetScript("OnEnter", function(self) self:SetBackdropBorderColor(0.90, 0.70, 0.35, 1) end)
    communityBtn:SetScript("OnLeave", function(self) self:SetBackdropBorderColor(0.50, 0.40, 0.22, 0.95) end)
    communityBtn:SetScript("OnClick", function()
        if WB.UI.ShowCommunityPopup then WB.UI.ShowCommunityPopup() end
    end)

    -- Scale grip: drag bottom-right corner to scale the whole frame up/down.
    -- Capped at 50% – 200% of native size. Percentage popup tracks the cursor.
    local scaleHint = CreateFrame("Frame", nil, UIParent, "BackdropTemplate")
    scaleHint:SetSize(64, 18)
    scaleHint:SetFrameStrata("TOOLTIP")
    scaleHint:Hide()
    scaleHint:SetBackdrop({
        bgFile   = "Interface\\Tooltips\\UI-Tooltip-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        edgeSize = 8,
        insets   = { left = 2, right = 2, top = 2, bottom = 2 },
    })
    scaleHint:SetBackdropColor(0, 0, 0, 0.75)
    scaleHint:SetBackdropBorderColor(0.4, 0.4, 0.4, 0.8)
    local scaleHintText = scaleHint:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
    scaleHintText:SetPoint("CENTER")

    local scaleGrip = CreateFrame("Button", nil, frame)
    scaleGrip:SetSize(18, 18)
    scaleGrip:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -2, 2)
    scaleGrip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    scaleGrip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    scaleGrip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")

    scaleGrip:SetScript("OnMouseDown", function(self, button)
        if button ~= "LeftButton" then return end
        local baseScale = frame:GetScale()
        local startX    = GetCursorPosition()
        scaleHint:Show()
        self:SetScript("OnUpdate", function()
            local curX, curY = GetCursorPosition()
            local uiScale    = UIParent:GetScale()
            local newScale   = math.max(0.5, math.min(2.0, baseScale + (curX - startX) / 200))
            frame:SetScale(newScale)
            local pct  = math.floor((newScale / baseScale - 1) * 100 + 0.5)
            local sign = pct >= 0 and "+" or ""
            scaleHintText:SetText(sign .. pct .. "%")
            scaleHint:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", curX / uiScale + 12, curY / uiScale + 12)
        end)
    end)

    scaleGrip:SetScript("OnMouseUp", function(self)
        self:SetScript("OnUpdate", nil)
        scaleHint:Hide()
    end)

    -- ── Content: three-column command layout ──────────────────────────────────
    local content = CreateFrame("Frame", nil, frame)
    content:SetPoint("TOPLEFT",     frame, "TOPLEFT",     30, -66)
    content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -30,  42)
    WB.UI.content = content

    local COL_W = 206

    local leftCol = CreateFrame("Frame", nil, content)
    leftCol:SetPoint("TOPLEFT",    content, "TOPLEFT",    0, 0)
    leftCol:SetPoint("BOTTOMLEFT", content, "BOTTOMLEFT", 0, 0)
    leftCol:SetWidth(COL_W)

    local rightCol = CreateFrame("Frame", nil, content)
    rightCol:SetPoint("TOPRIGHT",    content, "TOPRIGHT",    0, 0)
    rightCol:SetPoint("BOTTOMRIGHT", content, "BOTTOMRIGHT", 0, 0)
    rightCol:SetWidth(COL_W)

    local midCol = CreateFrame("Frame", nil, content)
    midCol:SetPoint("TOPLEFT",     leftCol,  "TOPRIGHT",    GAP, 0)
    midCol:SetPoint("BOTTOMRIGHT", rightCol, "BOTTOMLEFT", -GAP, 0)

    BuildLeftColumn(leftCol)
    BuildMiddleColumn(midCol)
    BuildRightColumn(rightCol)

    if WB.UI.RefreshWarEffort then WB.UI.RefreshWarEffort() end
    if WB.UI.RefreshCampaign  then WB.UI.RefreshCampaign()  end

    WB.UI.frame = frame

    -- Non-members get the community join link automatically whenever the board opens (the same
    -- popup the Community button shows), so they immediately see how to connect. Deferred a tick
    -- so C_Club's subscribed-clubs list is populated first — avoids a false "not a member" flash
    -- on a very early open right after login.
    frame:HookScript("OnShow", function()
        if not (WB.Community and WB.Community.IsMember and WB.UI.ShowCommunityPopup) then return end
        C_Timer.After(0, function()
            if frame:IsShown() and not WB.Community.IsMember() then
                WB.UI.ShowCommunityPopup()
            end
        end)
    end)

    -- Shard readout pinned to the frame's top-right corner.
    if WB.ShardPill and WB.ShardPill.Create then WB.ShardPill.Create(frame) end
end

-- ── War Board Guide (/wb guide; also auto-opens on first run) ─────────────────
-- A parchment "manual" explaining the basics. Faction-themed (red Horde / blue
-- Alliance), scrollable, content-driven height.
local guideFrame

local GUIDE_DESC =
    "War Board is a faction-wide World PvP campaign system that rewards players for "
 .. "fighting for their faction across Azeroth.\n\n"
 .. "Complete objectives, defeat enemy players, hunt bounties, and contribute to your "
 .. "faction's War Effort to earn rewards, rise through the ranks, and help secure victory.\n\n"
 .. "To participate, use War Board, enable War Mode, and join the War Board community."

local GUIDE_SECTIONS = {
    { h = "Objectives", items = {
        { p = "Open War Board to view your active objectives." },
        { p = "Objectives may ask you to:" },
        { bullets = { "Defeat enemy players", "Eliminate faction NPCs", "Complete special campaign tasks" } },
        { p = "Completing objectives rewards **Gold** and **War Credit**." },
    }},
    { h = "Gold", items = {
        { p = "Gold is War Board's **addon currency**. It is earned through objectives, bounties, and other War Board activities." },
        { p = "Gold is primarily used to place bounties on enemy players." },
        { p = "Gold is separate from normal Warcraft gold and has no value outside of War Board." },
    }},
    { h = "War Credit", items = {
        { p = "War Credit, or WC, represents your contribution to your faction's War Effort." },
        { p = "War Credit is earned by:" },
        { bullets = { "Completing objectives", "Claiming bounties", "Defeating enemy players", "Participating in campaign activities" } },
        { p = "War Credit serves two important purposes:" },
        { bullets = { "It advances your personal rank", "It contributes to your faction's campaign progress" } },
        { p = "The faction with the most War Credit when a campaign ends is declared the victor." },
    }},
    { h = "Campaigns", items = {
        { p = "War Board campaigns end every **Monday and Friday evening** at **6:00 PM PST**, or **7:00 PM PDT** during daylight saving time." },
        { p = "The clock is fixed to that exact moment, so every player's campaign ends at the same instant worldwide." },
        { p = "Because campaign endings are pinned to those two days, campaign length alternates:" },
        { bullets = { "Weekend Campaign: Friday to Monday, 3 days", "Weekday Campaign: Monday to Friday, 4 days" } },
        { p = "Each campaign features an active warfront zone and a faction-wide contribution race." },
        { p = "Earn War Credit during the campaign to push the front line, strengthen your faction's War Effort, and help secure victory before the campaign ends." },
        { p = "When a campaign ends, War Board enters a **30-minute reconciliation period**. During this time, final campaign updates are collected and verified before the winner is announced and the next campaign begins." },
    }},
    { h = "Bounties", items = {
        { p = "Players can place bounties on enemy players through the **Bounties** tab." },
        { p = "Accept a bounty, track your target, and eliminate them to claim the reward." },
        { p = "Successful bounty claims reward both **Gold** and **War Credit**." },
        { p = "Higher-ranked players are more valuable targets and may carry greater bounty rewards." },
    }},
    { h = "Ranks", items = {
        { p = "Ranks are earned by gaining War Credit." },
        { p = "Your rank represents your dedication to the war effort and your standing within your faction's campaign forces." },
        { p = "Higher-ranked players are also more valuable bounty targets, making them greater threats and greater prizes on the battlefield." },
    }},
    { h = "Alerts", items = {
        { p = "War Board reports enemy activity occurring at active objective locations." },
        { p = "Use these alerts to locate battles, defend strategic areas, respond to attacks, and hunt enemy players across the warfront." },
    }},
    { h = "Campaign Results", items = {
        { p = "The Campaign Results window stores the history of completed campaigns." },
        { p = "Review past victories, defeats, and stalemates, compare faction War Credit totals, and track the outcome of previous war efforts." },
    }},
}

local function CreateGuideFrame()
    local faction = WB.faction or UnitFactionGroup("player") or "Horde"
    local isHorde = (faction ~= "Alliance")
    local parchment = isHorde and "UI-Frame-Horde-CardParchmentWider" or "UI-Frame-Alliance-CardParchmentWider"
    -- Horde's deep red blended into the dark reddish parchment, so it's brightened here;
    -- Alliance's dark blue read fine on its page, so it keeps the original shade.
    local accent    = isHorde and { 0.88, 0.28, 0.22 } or { 0.13, 0.25, 0.56 }
    local accentHex = isHorde and "ffe04738" or "ff21408f"   -- bold "War Credit" etc.
    local amberHex  = "ffb87a26"                              -- bold "Gold" (darker; reads on light parchment)
    local bodyCol   = { 0.20, 0.13, 0.08 }                    -- dark brown ink
    local SERIF     = "Fonts\\FRIZQT__.TTF"
    local footerTxt = ("FOR THE %s!"):format(isHorde and "HORDE" or "ALLIANCE")

    local function SetAccentFont(fs, size, outline)
        fs:SetFont(SERIF, size, outline and "OUTLINE" or "")
        fs:SetShadowColor(0, 0, 0, 0.85)
        fs:SetShadowOffset(1, -1)
    end

    local W = 1060
    local f = CreateFrame("Frame", "WarBoardGuideFrame", UIParent)
    f:SetSize(W, 740)               -- height re-set after the columns are laid out
    f:SetPoint("CENTER")
    -- Default 20% smaller; the corner grip can rescale, persisted per character.
    f:SetScale((WarBoardCharDB and WarBoardCharDB.guideScale) or 0.8)
    f:SetFrameStrata("DIALOG")
    f:SetToplevel(true)
    f:EnableMouse(true)
    f:SetMovable(true)
    f:RegisterForDrag("LeftButton")
    f:SetScript("OnDragStart", f.StartMoving)
    f:SetScript("OnDragStop",  f.StopMovingOrSizing)

    -- The parchment atlas carries its own torn/burnt edges, so no backdrop behind it —
    -- the transparent margins then read as nothing (the paper floats) rather than a black
    -- box jutting past the paper edges.
    local bg = f:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints(f)
    bg:SetAtlas(parchment, false)

    local close = CreateFrame("Button", nil, f, "UIPanelCloseButton")
    close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -12, -10)

    -- Two-column geometry inside the torn margins.
    local LM, TM = 56, 44
    local centerX = W / 2
    local colW    = (centerX - 30) - LM
    local topY    = -TM
    local left  = { x = LM,          y = topY }
    local right = { x = centerX + 30, y = topY }

    local function Diamond(parent, size)
        local d = parent:CreateTexture(nil, "ARTWORK")
        d:SetColorTexture(accent[1], accent[2], accent[3], 0.95)
        d:SetSize(size or 8, size or 8)
        d:SetRotation(0.785398)   -- 45° → a small diamond
        return d
    end
    local function Markup(s)
        return (s:gsub("%*%*(.-)%*%*", function(w)
            return "|c" .. ((w == "Gold") and amberHex or accentHex) .. w .. "|r"
        end))
    end
    local function AddPara(col, text, size, color, indent, gap, justify)
        local fs = f:CreateFontString(nil, "OVERLAY")
        fs:SetFont(SERIF, size, "")
        fs:SetWidth(colW - (indent or 0))
        fs:SetWordWrap(true)
        fs:SetJustifyH(justify or "LEFT")
        fs:SetTextColor(color[1], color[2], color[3])
        fs:SetText(text)
        fs:SetPoint("TOPLEFT", f, "TOPLEFT", col.x + (indent or 0), col.y)
        col.y = col.y - fs:GetStringHeight() - (gap or 5)
    end
    local function SplitGuideMarkup(text)
        local out, pos = {}, 1
        while true do
            local s, e, bold = text:find("%*%*(.-)%*%*", pos)
            if not s then break end
            if s > pos then out[#out + 1] = { text = text:sub(pos, s - 1), bold = false } end
            out[#out + 1] = { text = bold, bold = true }
            pos = e + 1
        end
        if pos <= #text then out[#out + 1] = { text = text:sub(pos), bold = false } end
        return out
    end
    local function AddRichPara(col, text, size, color, indent, gap)
        if not text:find("%*%*") then
            AddPara(col, text, size, color, indent, gap)
            return
        end

        indent = indent or 0
        local maxW = colW - indent
        local x, y, lineH = 0, col.y, 0
        local spaceW = size * 0.33
        for _, seg in ipairs(SplitGuideMarkup(text)) do
            for word in tostring(seg.text or ""):gmatch("%S+") do
                local fs = f:CreateFontString(nil, "OVERLAY")
                if seg.bold then
                    fs:SetFont(SERIF, size, "")
                    fs:SetShadowColor(0, 0, 0, 0.75)
                    fs:SetShadowOffset(1, -1)
                    local c = (word == "Gold") and { tonumber(amberHex:sub(3, 4), 16) / 255, tonumber(amberHex:sub(5, 6), 16) / 255, tonumber(amberHex:sub(7, 8), 16) / 255 } or accent
                    fs:SetTextColor(c[1], c[2], c[3])
                else
                    fs:SetFont(SERIF, size, "")
                    fs:SetTextColor(color[1], color[2], color[3])
                end
                fs:SetText(word)
                local w = fs:GetStringWidth()
                local addSpace = (x > 0) and spaceW or 0
                if x > 0 and (x + addSpace + w) > maxW then
                    y = y - lineH
                    x, lineH, addSpace = 0, 0, 0
                end
                fs:SetPoint("TOPLEFT", f, "TOPLEFT", col.x + indent + x + addSpace, y)
                x = x + addSpace + w
                lineH = math.max(lineH, fs:GetStringHeight())
            end
        end
        col.y = y - math.max(lineH, size) - (gap or 5)
    end
    -- Section header: small-caps serif + a faction rule running to the column's edge.
    local function AddHeader(col, text)
        local fs = f:CreateFontString(nil, "OVERLAY")
        SetAccentFont(fs, 18)
        fs:SetText(text:upper())
        fs:SetTextColor(accent[1], accent[2], accent[3])
        fs:SetPoint("TOPLEFT", f, "TOPLEFT", col.x, col.y)
        local rule = f:CreateTexture(nil, "ARTWORK")
        rule:SetColorTexture(accent[1], accent[2], accent[3], 0.5)
        rule:SetHeight(1.5)
        rule:SetPoint("LEFT", fs, "RIGHT", 10, 1)
        local rw = colW - fs:GetStringWidth() - 10
        if rw > 6 then rule:SetWidth(rw) else rule:Hide() end
        col.y = col.y - fs:GetStringHeight() - 7
    end
    local function AddSection(col, sec)
        AddHeader(col, sec.h)
        for _, item in ipairs(sec.items) do
            if item.p then
                AddRichPara(col, item.p, 14, bodyCol, 0, 6)
            elseif item.bullets then
                for _, b in ipairs(item.bullets) do
                    AddRichPara(col, "\226\128\162  " .. b, 14, bodyCol, 16, 3)
                end
                col.y = col.y - 3
            end
        end
        col.y = col.y - 9   -- breathing room before the next section
    end

    -- LEFT column: big title, ornamented divider, description, then sections 1-3.
    local title = f:CreateFontString(nil, "OVERLAY")
    SetAccentFont(title, 30, true)
    title:SetText("WAR BOARD GUIDE")
    title:SetTextColor(accent[1], accent[2], accent[3])
    title:SetPoint("TOPLEFT", f, "TOPLEFT", left.x, left.y)
    left.y = left.y - title:GetStringHeight() - 7
    local td = f:CreateTexture(nil, "ARTWORK")
    td:SetColorTexture(accent[1], accent[2], accent[3], 0.5)
    td:SetHeight(1.5)
    td:SetPoint("TOPLEFT", f, "TOPLEFT", left.x, left.y)
    td:SetWidth(colW)
    Diamond(f, 8):SetPoint("CENTER", td, "CENTER", 0, 0)
    left.y = left.y - 14
    AddPara(left, GUIDE_DESC, 14, bodyCol, 0, 12)
    for i = 1, 3 do AddSection(left, GUIDE_SECTIONS[i]) end

    -- RIGHT column: sections 4-8.
    for i = 4, #GUIDE_SECTIONS do AddSection(right, GUIDE_SECTIONS[i]) end

    -- Size the frame to the taller column, then place the footer + center divider.
    local FOOTER_GAP, FOOTER_H, BM = 16, 22, 26
    f:SetHeight(-math.min(left.y, right.y) + FOOTER_GAP + FOOTER_H + BM)

    local footer = f:CreateFontString(nil, "OVERLAY")
    SetAccentFont(footer, 22, true)   -- bigger + black OUTLINE so the battle cry stands out
    footer:SetText(footerTxt)
    footer:SetTextColor(accent[1], accent[2], accent[3])
    footer:SetPoint("BOTTOM", f, "BOTTOM", 0, BM + 16)   -- lifted up off the bottom edge
    Diamond(f, 8):SetPoint("RIGHT", footer, "LEFT",  -14, 0)
    Diamond(f, 8):SetPoint("LEFT",  footer, "RIGHT",  14, 0)

    local vdiv = f:CreateTexture(nil, "ARTWORK")
    vdiv:SetColorTexture(accent[1], accent[2], accent[3], 0.28)
    vdiv:SetWidth(1)
    vdiv:SetPoint("TOP",    f, "TOP", 0, -TM)
    vdiv:SetPoint("BOTTOM", footer, "TOP", 0, 10)

    -- Corner grip: drag to scale (zoom) the whole guide; the scale persists per character.
    local grip = CreateFrame("Button", nil, f)
    grip:SetSize(16, 16)
    grip:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -5, 5)
    grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
    grip:SetScript("OnMouseDown", function(self)
        self.scaling = true
        self.sx, self.sy = GetCursorPosition()
        self.startScale  = f:GetScale()
    end)
    grip:SetScript("OnMouseUp", function(self)
        self.scaling = false
        WarBoardCharDB = WarBoardCharDB or {}
        WarBoardCharDB.guideScale = f:GetScale()
    end)
    grip:SetScript("OnUpdate", function(self)
        if not self.scaling then return end
        local cx, cy = GetCursorPosition()
        local delta = (cx - self.sx) + (self.sy - cy)   -- drag right/down → larger
        f:SetScale(math.max(0.5, math.min(1.5, self.startScale + delta / 800)))
    end)

    return f
end

function WB.UI.ShowGuide()
    if not guideFrame then guideFrame = CreateGuideFrame() end
    guideFrame:Show()
    guideFrame:Raise()
end

-- Show/hide the War Board (shared by /wb and the minimap button), refreshing the panes
-- when it opens.
function WB.UI.Toggle()
    local f = WB.UI.frame
    if not f then return end
    if f:IsShown() then
        f:Hide()
        return
    end
    f:Show()
    if WB.UI.RefreshObjectiveBackgrounds then WB.UI.RefreshObjectiveBackgrounds() end
    if WB.UI.RefreshActivity          then WB.UI.RefreshActivity()          end
    if WB.UI.RefreshBounties          then WB.UI.RefreshBounties()          end
    if WB.UI.RefreshActiveBounties    then WB.UI.RefreshActiveBounties()    end
    if WB.UI.RefreshWarEffort         then WB.UI.RefreshWarEffort()         end
    if WB.UI.RefreshAlerts            then WB.UI.RefreshAlerts()            end
    if WB.UI.RefreshCurrency          then WB.UI.RefreshCurrency()          end  -- needs real bar width once shown
end

-- Initialize after the addon is loaded
local initFrame = CreateFrame("Frame")
initFrame:RegisterEvent("PLAYER_LOGIN")
initFrame:SetScript("OnEvent", function(self)
    -- PLAYER_LOGIN (not ADDON_LOADED): player faction is reliably available here.
    CreateWarBoardFrame()
    -- First run on this character: open the guide once.
    WarBoardCharDB = WarBoardCharDB or {}
    if not WarBoardCharDB.guideSeen then
        WarBoardCharDB.guideSeen = true
        C_Timer.After(2, function() if WB.UI.ShowGuide then WB.UI.ShowGuide() end end)
    end
    self:UnregisterEvent("PLAYER_LOGIN")
end)

-- Slash commands to toggle the frame
SLASH_WARBOARD1 = "/wb"
SLASH_WARBOARD2 = "/warboard"
SlashCmdList["WARBOARD"] = function(msg)
    local raw = (msg or ""):gsub("^%s+", ""):gsub("%s+$", "")   -- preserves case (for player names)
    msg = raw:lower()

    -- /wb guide — open the War Board guide (the parchment manual).
    if msg == "guide" then
        if WB.UI.ShowGuide then WB.UI.ShowGuide() end
        return
    end

    -- /wb options — open the WarBoard settings panel (Options → AddOns → War Board).
    if msg == "options" or msg == "config" or msg == "settings" then
        if WB.Options and WB.Options.Open then WB.Options.Open() end
        return
    end

    -- /wb help — list the available commands.
    if msg == "help" or msg == "?" then
        print(WB.TAG.." commands:")
        print("  |cffffd100/wb|r \226\128\148 open or close the War Board")
        print("  |cffffd100/wb guide|r \226\128\148 open the War Board guide")
        print("  |cffffd100/wb options|r \226\128\148 open WarBoard settings (Options \226\134\146 AddOns \226\134\146 War Board)")
        print("  |cffffd100/wb sync|r \226\128\148 force a sync now (clears the cooldown); |cffffd100/wb sync Name-Realm|r pulls from one player")
        print("  |cffffd100/wb peers|r \226\128\148 list War Board users currently online in your community (who you can sync with)")
        print("  |cffffd100/wb diag|r \226\128\148 sync diagnostics (ledger size, quarantined records, local clock)")
        print("  |cffffd100/wb resync|r \226\128\148 clear the local quarantine and re-pull from peers")
        print("  |cffffd100/wb victor|r \226\128\148 preview the campaign-winner popup (test only: horde / alliance / tie)")
        print("  |cffffd100/wb th|r \226\128\148 preview a named TalkingHead line (test only: /wb th cromush_engage)")
        print("  |cffffd100/wb help|r \226\128\148 show this command list")
        return
    end

    -- /wb sync [name-realm] — force a sync. Bare: re-announce to everyone, clearing the
    -- per-peer cooldown so it always re-pulls immediately. With a target: ask that one player
    -- to dump their ledger to us right now (direct, immediate catch-up).
    if msg == "sync" or msg:find("^sync%s") then
        local target = raw:match("^%S+%s+(.+)$")   -- everything after "sync " (original case)
        if target and target ~= "" then
            if WB.Comm and WB.Comm.RequestSyncFrom then
                WB.Comm.RequestSyncFrom(target)
                print((WB.TAG.." requested an immediate sync from |cffffd100%s|r."):format(target))
            end
        elseif WB.Comm and WB.Comm.Announce then
            if WB.Comm.ResetSyncCooldown then WB.Comm.ResetSyncCooldown() end
            local n = WB.Comm.Announce()
            print((WB.TAG.." announced to %d peer(s) + channels (cooldown cleared)."):format(n or 0))
        end
        return
    end

    -- /wb peers — list the online War Board community members we can currently reach.
    if msg == "peers" then
        if WB.Comm and WB.Comm.GetOnlinePeers then
            local peers = WB.Comm.GetOnlinePeers()
            print((WB.TAG.." %d online peer(s):"):format(#peers))
            for _, p in ipairs(peers) do print("  - " .. p) end
        end
        return
    end

    -- /wb diag — sync diagnostics. The most common cause of "I sync but get no current data"
    -- is a wrong PC clock: peers' fresh records look like they're from the future and get
    -- quarantined (hidden from the feed + score nothing). This shows the ledger size, what's
    -- been quarantined and why, and your local clock to compare against real time.
    if msg == "diag" then
        local Lg = WB.Ledger
        print(WB.TAG.." diagnostics:")
        print(("  ledger: %d record(s) total, %d in the sync window (only these transfer)"):format(
            (Lg and Lg.Count and Lg.Count()) or 0,
            (Lg and Lg.SyncCount and Lg.SyncCount()) or 0))
        if WB.Verifier and WB.Verifier.GetQuarantine then
            local q = WB.Verifier.GetQuarantine()
            print(("  quarantined: %d record(s)"):format(#q))
            local shown = 0
            for i = #q, 1, -1 do   -- newest first
                local e = q[i]
                print(("    - [%s] %s"):format(tostring(e.atype), tostring(e.reason)))
                shown = shown + 1
                if shown >= 8 then break end
            end
        end
        local now = time()
        print(("  local clock: %s  (epoch %d)"):format(date("%Y-%m-%d %H:%M:%S", now), now))
        print("  If that time is off vs real time, fix your PC clock, then run |cffffd100/wb resync|r.")
        return
    end

    -- /wb resync — clear the local quarantine and re-pull from peers. Use after fixing a clock
    -- (already-quarantined records are remembered and won't re-admit on their own); genuinely
    -- bad records simply re-quarantine on the next dump.
    if msg == "resync" then
        if WB.Verifier and WB.Verifier.WipeQuarantine then WB.Verifier.WipeQuarantine() end
        if WB.Comm and WB.Comm.ResetSyncCooldown then WB.Comm.ResetSyncCooldown() end
        local n = (WB.Comm and WB.Comm.Announce and WB.Comm.Announce()) or 0
        print((WB.TAG.." cleared quarantine and re-announced to %d peer(s) — records re-pull on the next dump."):format(n or 0))
        return
    end

    -- /wb victor [horde|alliance|tie] — preview the live campaign-winner popup with fake data.
    -- Purely visual: it never touches real results. Default winner is your own faction.
    if msg == "victor" or msg:find("^victor%s") then
        local who    = (raw:match("^%S+%s+(%S+)") or ""):lower()
        local winner = (who == "alliance" and "Alliance")
                    or (who == "tie"      and "Tie")
                    or (who == "horde"    and "Horde")
                    or (WB.faction or "Horde")
        local h, a
        if winner == "Tie" then
            h, a = 1000, 1000
        elseif winner == "Horde" then
            h, a = math.random(1500, 4000), math.random(300, 1400)
        else
            h, a = math.random(300, 1400), math.random(1500, 4000)
        end
        if WB.UI.ShowVictorPopup then
            WB.UI.ShowVictorPopup({ winner = winner, horde = h, alliance = a,
                startT = time() - 3 * 86400, endT = time(), index = -1 })
        end
        print((WB.TAG.." test winner popup: %s (Horde %d / Alliance %d)"):format(winner, h, a))
        return
    end

    -- /wb th <key> — preview a named TalkingHead line (test only). Bare lists the keys.
    if msg == "th" or msg:find("^th%s") then
        local key = raw:match("^%S+%s+(%S+)")
        if key and WB.UI.TalkingHeads and WB.UI.TalkingHeads[key] then
            WB.UI.PlayTalkingHead(key)
            print((WB.TAG.." test TalkingHead: %s"):format(key))
        else
            local keys = {}
            for k in pairs(WB.UI.TalkingHeads or {}) do keys[#keys+1] = k end
            table.sort(keys)
            print(WB.TAG.." usage: /wb th <key> — known: " .. (table.concat(keys, ", ") ~= "" and table.concat(keys, ", ") or "(none)"))
        end
        return
    end

    -- ── DEV-ONLY: objective popup / tracker test commands ────────────────────
    -- /wb warpopup — preview the center-screen War Objective banner.
    if msg == "warpopup" then
        if WB.Popup and WB.Popup.Show then WB.Popup:Show("Battle for the Scar", "War Objective") end
        return
    end

    -- /wb testtracker — add a fake objective to the War Board tracker section.
    if msg == "testtracker" then
        if WB.Tracker and WB.Tracker.AddObjective then
            WB.Tracker:AddObjective("__wb_test", "Battle for the Scar", "Alliance NPCs killed", 0, 25)
            print(WB.TAG.." test tracker objective added (0/25). /wb cleartracker to remove.")
        end
        return
    end

    -- /wb cleartracker — remove all War Board tracker entries.
    if msg == "cleartracker" then
        if WB.Tracker and WB.Tracker.Clear then WB.Tracker:Clear() end
        print(WB.TAG.." tracker cleared.")
        return
    end

    if WB.UI.Toggle then WB.UI.Toggle() end
end
