local addonName, TOAST = ...

local GOLD = { 0.88, 0.75, 0.36 }
local TEAL = { 0, 0.92, 0.79 }  -- #00EAC9
local BORDER = { 1, 1, 1, 0.12 }
local SOFT_GREEN = { 0.55, 0.85, 0.55 }  -- delta to player above (catching up)
local SOFT_RED   = { 0.90, 0.45, 0.45 }  -- delta to player below (falling behind)
local DISCORD_INVITE = "https://discord.gg/tide-crew"
local COMMUNITY_INVITE = "https://www.worldofwarcraft.com/invite/pPmMXpyCPoX?region=EU&faction=Horde"
local CURSEFORGE_URL = "https://www.curseforge.com/wow/addons/toast"
local FRESH_THRESHOLD = 3600
local ROW_H = 24

local CreateActionButton = TOAST.CreateActionButton
local LockEditBox = TOAST.LockEditBox
local CLASS_TOKEN = TOAST.CLASS_TOKEN
local ROLE_TEX = TOAST.ROLE_TEX
local ParseDiscordPrefix = TOAST.ParseDiscordPrefix

local W = 480
local PAD = 16
local CARD_GAP = 12
local CREW_ROW_H = 26
local MYCREW_HEADER_H = 30
local CREWPOPUP_HEADER_H = 30  -- was 44: shrunk now crewRankFS shares the name row instead of its own line
local CREWPOPUP_BOTTOM_PAD = 8

-- Header layout: matches the leaderboard's header height and divider
-- placement style (same "+1" convention) so both windows read as the same
-- design language, instead of the dashboard's header feeling shorter/cramped.
local DASHBOARD_HEADER_HEIGHT = 100  -- == leaderboard's BANNER_H
local HEADER_DIVIDER_Y = -(DASHBOARD_HEADER_HEIGHT + 1)
local HEADER_DIVIDER_MEDALLION = 32  -- == leaderboard's medallionSize
local HEADER_DIVIDER_GAP = 6

-- Card A starts far enough below the divider to fully clear the medallion
-- (half its size) plus the standard CARD_GAP breathing room — no overlap.
local CARD_A_TOP = HEADER_DIVIDER_Y - (HEADER_DIVIDER_MEDALLION / 2) - CARD_GAP
local CARD_A_HEIGHT = 136

-- Card B (Current SEAMEN Keys) height is tiered by fresh-key count instead of
-- fixed, so a quiet roster doesn't leave a wall of empty scroll area under
-- the header — see KeysCardHeight()/ReflowCardB() below. CARD_B_HEIGHT_FULL
-- is also the placeholder used for the initial (pre-Refresh) build.
local CARD_B_HEIGHT_EMPTY = 140  -- 0 keys
local CARD_B_HEIGHT_LOW   = 175  -- 1-2 keys
local CARD_B_HEIGHT_FULL  = 260  -- 3+ keys, scrollable

local CARD_C_HEIGHT_ONE  = 88
local CARD_C_HEIGHT_TWO  = 112
local CARD_C_HEIGHT_FULL = 136
local CARD_C_HEIGHT = CARD_C_HEIGHT_FULL  -- build-time maximum; activity refresh tiers it down
local FOOTER_HEIGHT = 24
local FOOTER_GAP = 28  -- breathing room between Card C and the footer (unchanged from before)

-- Player Card (Card A) layout: a single shared left column for the title,
-- greeting, character, and stat rows, so they read as one aligned block
-- instead of drifting right based on however wide the row above happened
-- to be. The slightly taller card gives both three-row sections deliberate
-- breathing room without materially lengthening the dashboard.
local PLAYER_PAD_X = 12  -- matches the left padding every other card title uses
local PLAYER_TOP_Y = -10  -- matches the title Y every other card uses (My Crew / Keys / Progress)
local PLAYER_TITLE_GAP = 20
local PLAYER_ROW_GAP = 10
local PLAYER_CHIP_GAP = 20

-- Card A is split into two sibling information groups: current-character
-- status on the left and resolved leaderboard progress on the right. Keep
-- these anchors centralized so long identities and the fixed delta column
-- cannot drift into one another as either side evolves.
local PROGRESS_DIVIDER_X = (W - PAD * 2) / 2
local PROGRESS_X = PROGRESS_DIVIDER_X + 12
local PROGRESS_RIGHT_PAD = 10
local PROGRESS_RANK_W = 24
local PROGRESS_DELTA_W = 54
local PROGRESS_IDENTITY_GAP = 4

-- Total dashboard height, derived from the header/card/footer chain above
-- instead of an independently eyeballed number. Uses the full-size Card B
-- as the worst-case/build-time size; ReflowCardB() corrects it to the
-- actual tier before the frame is ever shown (see Refresh()/RefreshKeysList()).
local H = (-CARD_A_TOP) + CARD_A_HEIGHT + CARD_GAP + CARD_B_HEIGHT_FULL + CARD_GAP + CARD_C_HEIGHT
    + FOOTER_GAP + FOOTER_HEIGHT + PAD

local dash
local cardA
local cardB, cardC
local ahoyFS, prefixIcon, prefixLabel, discordNameFS, charIcon, charLine, crewlessFS
local diagBtn, diagBtnFS
local chipAnchor
local statusResiStat, statusKeyStat
local progressChipAnchor
local progressRankStat, progressRioStat
local progressTrendsBtn
local rosterEmptyTitleFS, rosterEmptyAlertLeft, rosterEmptyAlertRight, rosterEmptyDivider
local rosterEmptyActionFS, rosterEmptyRefreshFS
local weeklyPeriodFS, weeklyFallbackFS
local weeklyMostHeaderFS, weeklyClimbersHeaderFS
local weeklyMostEmptyFS, weeklyClimbersEmptyFS
local weeklyMostRows, weeklyClimbersRows = {}, {}
local activityActiveTab = "week"
local activityTabs = {}
local keysScroll, keysContent, emptyText, emptyHelperText
local keyRows = {}
local nextRankFS, nextPrefixIcon, nextPrefixLabelFS, nextNameFS, nextDeltaFS, nextFallbackFS
local behindRankFS, behindPrefixIcon, behindPrefixLabelFS, behindNameFS, behindDeltaFS
local pulseOnlineFS, pulseKeysFS, pulseMembersFS
local pulseOnlineBtn, pulseKeysBtn, pulseMembersBtn
local myCrewCard
local myCrewRows = {}
local clickCatcher
local copyPopup, copyEditBox
local crewPopup, crewHeaderIcon, crewHeaderLabel, crewOnlineDot, crewNameFS, crewRankFS
local crewPopupRows = {}

-- Live-refresh state. A crew-state merge can arrive at any moment (own key
-- change, peer broadcast, whole snapshot), so the dashboard re-renders itself
-- while it is open instead of waiting for the next open. Such a pass is
-- flagged "live" all the way down so it re-renders content only and never
-- resizes the window under the player -- see RefreshKeysList()/ReflowCardB().
local LIVE_REFRESH_DELAY = 0.35
local liveRefreshPending = false

local function AddBorder(f, color)
    local top = f:CreateTexture(nil, "BORDER")
    top:SetColorTexture(color[1], color[2], color[3], color[4])
    top:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
    top:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)
    top:SetHeight(1)

    local bottom = f:CreateTexture(nil, "BORDER")
    bottom:SetColorTexture(color[1], color[2], color[3], color[4])
    bottom:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, 0)
    bottom:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)
    bottom:SetHeight(1)

    local left = f:CreateTexture(nil, "BORDER")
    left:SetColorTexture(color[1], color[2], color[3], color[4])
    left:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
    left:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, 0)
    left:SetWidth(1)

    local right = f:CreateTexture(nil, "BORDER")
    right:SetColorTexture(color[1], color[2], color[3], color[4])
    right:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0)
    right:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0)
    right:SetWidth(1)

    return top, bottom, left, right
end

-- A passive summary label: visually groups secondary metadata without any
-- border, hover state, mouse target, or pressed treatment that could imply a
-- button. Width follows the rendered text so adjacent facts remain distinct.
local function CreateSummaryStat(parent)
    local stat = CreateFrame("Frame", nil, parent)
    stat:SetHeight(20)

    local bg = stat:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(1, 1, 1, 0.035)

    local label = stat:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    label:SetPoint("LEFT", stat, "LEFT", 6, 0)
    label:SetWordWrap(false)
    stat.label = label

    return stat
end

local function SetSummaryStat(stat, text)
    stat.label:SetText(text)
    stat:SetWidth(stat.label:GetStringWidth() + 12)
    stat:Show()
end

local WEEKLY_MONTHS = { "Jan", "Feb", "Mar", "Apr", "May", "Jun",
    "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }

-- Presentation-only formatting for the bot's strict ISO period fields. No
-- date arithmetic happens addon-side; malformed values simply render no
-- period rather than throwing or fabricating one.
local function FormatWeeklyPeriod(startDate, endDate)
    if type(startDate) ~= "string" or type(endDate) ~= "string" then return nil end
    local sy, sm, sd = startDate:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
    local ey, em, ed = endDate:match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
    sm, sd, em, ed = tonumber(sm), tonumber(sd), tonumber(em), tonumber(ed)
    if not sy or not ey or not sm or not sd or not em or not ed
        or not WEEKLY_MONTHS[sm] or not WEEKLY_MONTHS[em]
        or sd < 1 or sd > 31 or ed < 1 or ed > 31 then
        return nil
    end
    if sy == ey and sm == em then
        return string.format("%d-%d %s", sd, ed, WEEKLY_MONTHS[sm])
    end
    return string.format("%d %s - %d %s", sd, WEEKLY_MONTHS[sm], ed, WEEKLY_MONTHS[em])
end

local function CreateCard(parent, y, height, skipAccent)
    local card = CreateFrame("Frame", nil, parent)
    card:SetSize(W - PAD * 2, height)
    card:SetPoint("TOPLEFT", parent, "TOPLEFT", PAD, y)

    local bg = card:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.06, 0.08, 0.10, 0.50)

    AddBorder(card, BORDER)

    -- Card A skips this: the header's own anchor divider is the single
    -- strong gold separator above it, so a second gold line here would be
    -- a redundant duplicate right underneath it.
    -- Thinner/softer than the anchor divider — these are section accents,
    -- not primary dividers, so they shouldn't compete with it.
    if not skipAccent then
        local accent = card:CreateTexture(nil, "ARTWORK")
        accent:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.75)
        accent:SetPoint("TOPLEFT", card, "TOPLEFT", 0, 0)
        accent:SetPoint("TOPRIGHT", card, "TOPRIGHT", 0, 0)
        accent:SetHeight(1)
    end

    return card
end

-- Tiered Card B height by fresh-key count, so a quiet roster gets a compact
-- card instead of the full scrollable size sitting mostly empty.
local function KeysCardHeight(freshCount)
    if freshCount <= 0 then
        return CARD_B_HEIGHT_EMPTY
    elseif freshCount <= 2 then
        return CARD_B_HEIGHT_LOW
    else
        return CARD_B_HEIGHT_FULL
    end
end

-- Resizes Card B to cardBHeight and reflows everything anchored below it
-- (Card C, the dash frame itself). Called once per ShowDashboard (from
-- RefreshKeysList), never while the window is already visible, so the
-- dashboard never resizes out from under the player mid-look.
local function ReflowCardB(cardBHeight)
    cardB:SetHeight(cardBHeight)

    cardC:ClearAllPoints()
    cardC:SetPoint("TOPLEFT", cardB, "BOTTOMLEFT", 0, -CARD_GAP)

    local myCrewDelta = myCrewCard and (myCrewCard:GetHeight() + CARD_GAP) or 0
    local cardCHeight = cardC and cardC:GetHeight() or CARD_C_HEIGHT
    local totalHeight = (-CARD_A_TOP) + CARD_A_HEIGHT + CARD_GAP + cardBHeight + CARD_GAP + cardCHeight
        + FOOTER_GAP + FOOTER_HEIGHT + PAD + myCrewDelta
    dash:SetHeight(totalHeight)
end

-- Diagnostics tooltip line: green/positive vs muted-red/negative, with
-- overridable true/false wording (e.g. "On"/"Off" for key sharing).
local function AddDiagLine(label, positive, trueText, falseText)
    if positive then
        GameTooltip:AddDoubleLine(label, trueText or "Yes", 0.7, 0.7, 0.7, 0.55, 0.85, 0.55)
    else
        GameTooltip:AddDoubleLine(label, falseText or "No", 0.7, 0.7, 0.7, 1, 0.45, 0.45)
    end
end

local function GetOnlineTargetForMember(memberEntry, discordId)
    if not memberEntry then return nil end

    -- The online record carries its own realm, so an alt on another realm is
    -- addressed correctly instead of being rebuilt from a same-name character.
    -- It comes from live comms rather than the bot export, so it has no
    -- realm_wow and takes the display-realm path.
    local onlineChar = TOAST.GetOnlineCrewCharacter(discordId)
    if onlineChar and onlineChar.realm and onlineChar.name then
        return TOAST.FormatCharacterTarget(onlineChar.name, onlineChar.realm)
    end

    -- Exported row: authoritative realm_wow, nil when the realm is unresolved.
    return TOAST.FormatMemberTarget(memberEntry)
end

local function PositionAtCursor(frame)
    local x, y = GetCursorPosition()
    local scale = UIParent:GetEffectiveScale()
    frame:ClearAllPoints()
    frame:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT", x / scale, y / scale)
end

local function EnsureClickCatcher()
    if clickCatcher then return end
    clickCatcher = CreateFrame("Button", nil, UIParent)
    clickCatcher:SetAllPoints(UIParent)
    clickCatcher:SetFrameStrata("FULLSCREEN")
    clickCatcher:Hide()
end

local function ShowWithCatcher(frame)
    EnsureClickCatcher()
    clickCatcher:SetScript("OnClick", function()
        frame:Hide()
        clickCatcher:Hide()
    end)
    clickCatcher:Show()
    frame:Show()
end

local function HideWithCatcher(frame)
    frame:Hide()
    if clickCatcher then clickCatcher:Hide() end
end

-- ── Shared copy popup (names and invite links) ──────────────────────
local function EnsureCopyPopup()
    if copyPopup then return end
    copyPopup = CreateFrame("Frame", "TOASTCopyPopup", UIParent, "BackdropTemplate")
    Mixin(copyPopup, BackdropTemplateMixin)
    copyPopup:SetSize(240, 68)
    copyPopup:SetFrameStrata("TOOLTIP")
    copyPopup:SetClampedToScreen(true)
    copyPopup:EnableMouse(true)
    copyPopup:Hide()
    tinsert(UISpecialFrames, "TOASTCopyPopup")

    local bg = copyPopup:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.06, 0.08, 0.10, 1.0)
    AddBorder(copyPopup, BORDER)

    local accent = copyPopup:CreateTexture(nil, "ARTWORK")
    accent:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
    accent:SetPoint("TOPLEFT", copyPopup, "TOPLEFT", 0, 0)
    accent:SetPoint("TOPRIGHT", copyPopup, "TOPRIGHT", 0, 0)
    accent:SetHeight(2)

    local hint = copyPopup:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    hint:SetPoint("TOP", copyPopup, "TOP", 0, -10)
    hint:SetTextColor(1, 1, 1, 0.45)
    hint:SetText("Ctrl+C to copy")

    -- Same square custom-input treatment as the leaderboard search field.
    local copyBox = CreateFrame("Frame", nil, copyPopup)
    copyBox:SetSize(216, 26)
    copyBox:SetPoint("BOTTOM", copyPopup, "BOTTOM", 0, 12)

    local copyBg = copyBox:CreateTexture(nil, "BACKGROUND")
    copyBg:SetAllPoints()
    copyBg:SetColorTexture(0.04, 0.05, 0.07, 0.8)

    AddBorder(copyBox, { 1, 1, 1, 0.08 })

    copyEditBox = CreateFrame("EditBox", nil, copyBox)
    copyEditBox:SetAllPoints()
    copyEditBox:SetFont(TOAST.FONT_PATH, 12, "")
    copyEditBox:SetTextColor(1, 1, 1, 1)
    copyEditBox:SetTextInsets(10, 10, 0, 0)
    copyEditBox:SetAutoFocus(false)

    copyPopup:SetScript("OnHide", function()
        if clickCatcher then clickCatcher:Hide() end
    end)
end

local copyPopupGen = 0

TOAST.ShowCopyPopup = function(text)
    if crewPopup and crewPopup:IsShown() then crewPopup:Hide() end
    EnsureCopyPopup()
    LockEditBox(copyEditBox, text)
    PositionAtCursor(copyPopup)
    ShowWithCatcher(copyPopup)
    copyEditBox:SetFocus()
    copyEditBox:HighlightText()

    copyPopupGen = copyPopupGen + 1
    local myGen = copyPopupGen
    C_Timer.After(3, function()
        if myGen == copyPopupGen and copyPopup:IsShown() then
            HideWithCatcher(copyPopup)
        end
    end)
end

-- ── View Crew popup (TOAST.ShowCrewPopup) ──────────────────────────
local function EnsureCrewPopup()
    if crewPopup then return end
    crewPopup = CreateFrame("Frame", "TOASTCrewPopup", UIParent, "BackdropTemplate")
    Mixin(crewPopup, BackdropTemplateMixin)
    crewPopup:SetWidth(420)
    crewPopup:SetFrameStrata("TOOLTIP")
    crewPopup:SetClampedToScreen(true)
    crewPopup:EnableMouse(true)
    crewPopup:Hide()
    tinsert(UISpecialFrames, "TOASTCrewPopup")

    local bg = crewPopup:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.06, 0.08, 0.10, 1.0)
    AddBorder(crewPopup, BORDER)

    local accent = crewPopup:CreateTexture(nil, "ARTWORK")
    accent:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
    accent:SetPoint("TOPLEFT", crewPopup, "TOPLEFT", 0, 0)
    accent:SetPoint("TOPRIGHT", crewPopup, "TOPRIGHT", 0, 0)
    accent:SetHeight(2)

    crewHeaderIcon = crewPopup:CreateTexture(nil, "OVERLAY")
    crewHeaderIcon:SetSize(20, 20)
    crewHeaderIcon:SetPoint("TOPLEFT", crewPopup, "TOPLEFT", 18, -8)

    -- Status dot, same position relative to the prefix icon as the
    -- leaderboard's per-row dot: teal when any character in this crew is
    -- online, grey when not.
    crewOnlineDot = crewPopup:CreateTexture(nil, "OVERLAY")
    crewOnlineDot:SetSize(6, 6)
    crewOnlineDot:SetPoint("RIGHT", crewHeaderIcon, "LEFT", -2, 0)

    -- Icon->label->name gaps match the Status Report card's "Ahoy," line
    -- (0px, then 4px) so discord-identity spacing reads the same everywhere.
    crewHeaderLabel = crewPopup:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    crewHeaderLabel:SetPoint("LEFT", crewHeaderIcon, "RIGHT", 0, 0)
    crewHeaderLabel:SetTextColor(0.8, 0.8, 0.8)

    crewNameFS = crewPopup:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
    crewNameFS:SetPoint("LEFT", crewHeaderLabel, "RIGHT", 4, 0)
    crewNameFS:SetTextColor(1, 1, 1)

    -- Same row as the discord name, right-aligned to the popup's edge
    -- (vertically centered on crewHeaderIcon, matching that row's height).
    crewRankFS = crewPopup:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    crewRankFS:SetPoint("RIGHT", crewPopup, "TOPRIGHT", -12, -16)

    crewPopup:SetScript("OnHide", function()
        if clickCatcher then clickCatcher:Hide() end
    end)
end

local function BuildCrewPopupRow(parent, entry, y)
    local isOnline = TOAST.IsCharacterCurrentlyOnline
        and TOAST.IsCharacterCurrentlyOnline(entry.name, entry.realm)

    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(CREW_ROW_H)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, y)
    row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, y)

    local bg = row:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    if isOnline then
        bg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.08)
        local accent = row:CreateTexture(nil, "ARTWORK")
        accent:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
        accent:SetWidth(2)
        accent:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
        accent:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
    else
        bg:SetColorTexture(0, 0, 0, 0)
    end

    local roleTex = row:CreateTexture(nil, "OVERLAY")
    roleTex:SetSize(20, 20)
    roleTex:SetPoint("LEFT", row, "LEFT", 14, 0)
    roleTex:SetTexture(ROLE_TEX[entry.role] or "")

    -- Character-realm: same "class-colored name" + "|cffCCCCCC-realm|r"
    -- format as the leaderboard, as one string (no separate realm column).
    local token = CLASS_TOKEN[entry.class_]
    local c = token and RAID_CLASS_COLORS[token]
    local nameFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    nameFS:SetPoint("LEFT", roleTex, "RIGHT", 8, 0)
    if c then
        nameFS:SetText(string.format("|cff%02x%02x%02x%s|r|cffCCCCCC-%s|r",
            math.floor(c.r * 255), math.floor(c.g * 255), math.floor(c.b * 255),
            entry.name, entry.realm))
    else
        nameFS:SetText(entry.name .. "|cffCCCCCC-" .. entry.realm .. "|r")
    end

    local keyFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    keyFS:SetPoint("RIGHT", row, "RIGHT", -12, 0)
    keyFS:SetWidth(70)
    keyFS:SetJustifyH("RIGHT")
    keyFS:SetTextColor(1, 1, 1, 1)
    local keyData = TOAST.GetCrewKeyDisplay and TOAST.GetCrewKeyDisplay(entry.name, entry.realm)
    keyFS:SetText(keyData and ("+" .. keyData.level .. " " .. keyData.short) or "\226\128\148")

    local resiFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    resiFS:SetPoint("RIGHT", keyFS, "LEFT", -8, 0)
    resiFS:SetWidth(50)
    resiFS:SetJustifyH("RIGHT")
    if entry.resi and entry.resi > 0 then
        resiFS:SetText(string.format("|cff999999Resi |r|cffffffff%d|r", entry.resi))
        resiFS:SetAlpha(1)
    else
        resiFS:SetText("|cff999999Resi |r|cffffffff0|r")
        resiFS:SetAlpha(0.35)
    end

    local rioFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    rioFS:SetPoint("RIGHT", resiFS, "LEFT", -8, 0)
    rioFS:SetWidth(70)
    rioFS:SetJustifyH("RIGHT")
    local resolved = TOAST.ResolveCharacterScore and TOAST.ResolveCharacterScore(entry.name, entry.realm) or nil
    rioFS:SetText(string.format("|cff999999RIO |r|cffffffff%s|r",
        resolved and tostring(resolved.score) or "\226\128\148"))

    return row
end

TOAST.ShowCrewPopup = function(discord_id)
    if not discord_id then return end
    if copyPopup and copyPopup:IsShown() then copyPopup:Hide() end

    local crewEntries = {}
    for _, m in ipairs(TOAST.members) do
        if m.discord_id == discord_id then
            tinsert(crewEntries, m)
        end
    end
    if #crewEntries == 0 then return end

    EnsureCrewPopup()

    for _, row in ipairs(crewPopupRows) do
        row:Hide()
        row:SetParent(nil)
    end
    wipe(crewPopupRows)

    local first = crewEntries[1]
    local p, discordName = ParseDiscordPrefix(first.discord_nickname)
    if p then
        crewHeaderIcon:SetTexture(p.tex)
        crewHeaderIcon:Show()
        crewHeaderLabel:SetText(p.label)
        crewHeaderLabel:Show()
    else
        crewHeaderIcon:Hide()
        crewHeaderLabel:Hide()
    end
    crewNameFS:SetText(discordName)

    local presenceStatus = TOAST.GetPresenceStatus
        and TOAST.GetPresenceStatus(first.name, first.realm, discord_id)
    if presenceStatus == "online" then
        crewOnlineDot:SetColorTexture(TEAL[1], TEAL[2], TEAL[3], 1)
        crewOnlineDot:Show()
    elseif presenceStatus == "known" then
        crewOnlineDot:SetColorTexture(0.5, 0.5, 0.5, 0.6)
        crewOnlineDot:Show()
    else
        crewOnlineDot:Hide()
    end

    local rankColor = (first.rank and TOAST.RANK_COLORS[first.rank]) or GOLD
    crewRankFS:SetText(first.rank or "")
    crewRankFS:SetTextColor(rankColor[1], rankColor[2], rankColor[3])

    local count = math.min(#crewEntries, 3)
    for i = 1, count do
        local row = BuildCrewPopupRow(crewPopup, crewEntries[i], -(CREWPOPUP_HEADER_H + (i - 1) * CREW_ROW_H))
        tinsert(crewPopupRows, row)
    end

    crewPopup:SetHeight(CREWPOPUP_HEADER_H + count * CREW_ROW_H + CREWPOPUP_BOTTOM_PAD)

    PositionAtCursor(crewPopup)
    ShowWithCatcher(crewPopup)
end

-- Diagnostics state, refreshed alongside the visible card but only ever
-- surfaced through the "?" hover tooltip (see diagBtn in BuildDashboard).
local diagRecognized, diagCommunity, diagLeaderboard, diagRoster = false, false, false, false
local diagKeySharing, diagLastBroadcast, diagCurrentKey = false, "\226\128\148", "No key"

local function RefreshStatus()
    -- Two independent questions, deliberately kept apart:
    --   me     -- the Discord identity this installation has confirmed. Owns
    --             Row 1, and stays valid across a character swap.
    --   active -- the character logged in right now. Owns Row 2 and the chips.
    -- Only active.isRosterMember may answer "is this character crew".
    local me = TOAST_DB.me
    local active = TOAST.GetActiveCharacter()

    ahoyFS:SetText("Ahoy!")

    if not me then
        prefixIcon:Hide()
        prefixLabel:Hide()
        discordNameFS:ClearAllPoints()
        discordNameFS:SetPoint("LEFT", ahoyFS, "RIGHT", 6, 0)
        discordNameFS:SetText("seaman")
        charIcon:Hide()
        charLine:SetText("")
        crewlessFS:Hide()

        diagRecognized, diagCommunity, diagLeaderboard, diagRoster = false, false, false, false
        diagKeySharing, diagLastBroadcast, diagCurrentKey = false, "\226\128\148", "No key"

        SetSummaryStat(statusResiStat, "|cff999999Resi|r |cffffffff\226\128\148|r")
        SetSummaryStat(statusKeyStat, "|cff999999No key|r")
        return
    end

    -- Discord identity: prefix icon + prefix label + discord name, zelfde als leaderboard
    local p, cleanName
    if me.discord then
        p, cleanName = ParseDiscordPrefix(me.discord)
    else
        cleanName = me.name
    end

    discordNameFS:ClearAllPoints()
    if p then
        prefixIcon:SetTexture(p.tex)
        prefixIcon:Show()
        prefixLabel:SetText(p.label)
        prefixLabel:Show()
        discordNameFS:SetPoint("LEFT", prefixLabel, "RIGHT", 4, 0)
    else
        prefixIcon:Hide()
        prefixLabel:Hide()
        discordNameFS:SetPoint("LEFT", ahoyFS, "RIGHT", 6, 0)
    end
    discordNameFS:SetText(cleanName)

    -- The active character is a normal left-aligned status line. Both the name
    -- and the class colour come from the live unit, never from the remembered
    -- identity above -- otherwise a crewless character reads as its own main.
    charIcon:Hide()
    charLine:ClearAllPoints()
    charLine:SetPoint("TOPLEFT", ahoyFS, "BOTTOMLEFT", 0, -PLAYER_ROW_GAP)
    local token = active.classToken or (active.member and CLASS_TOKEN[active.member.class_])
    local c = token and RAID_CLASS_COLORS[token]
    if c then
        charLine:SetText(string.format("|cff%02x%02x%02x%s|r|cffCCCCCC-%s|r",
            math.floor(c.r * 255), math.floor(c.g * 255), math.floor(c.b * 255),
            active.name, active.realm))
    else
        charLine:SetText(active.name .. "|cffCCCCCC-" .. active.realm .. "|r")
    end
    crewlessFS:SetShown(not active.isRosterMember)

    -- Status owns practical current-character state only. Resolved RIO and
    -- rank live exclusively in the sibling Your Progress section, avoiding
    -- two competing copies of the same information inside one card.
    -- Both chips describe the character being played, so they follow active,
    -- not the remembered identity: a crewless character owns neither its
    -- main's key nor its main's resi.
    local keyData = TOAST.GetCrewKeyDisplay and TOAST.GetCrewKeyDisplay(active.name, active.realm)
    local keyStr = keyData and ("+" .. keyData.level .. " " .. keyData.short) or "No key"
    local resi = active.isRosterMember and me.resi or nil
    local resiValue = resi ~= nil and tostring(resi) or "\226\128\148"
    local keyValue
    if keyData then
        keyValue = "|cff999999Key|r |cffffffff" .. keyStr .. "|r"
    else
        keyValue = "|cff999999No key|r"
    end
    SetSummaryStat(statusResiStat, "|cff999999Resi|r |cffffffff" .. resiValue .. "|r")
    SetSummaryStat(statusKeyStat, keyValue)

    -- Diagnostics, computed here but only rendered in the "?" tooltip. The
    -- first two lines answer different questions and can legitimately
    -- disagree: this installation knows a Discord identity, but the character
    -- currently logged in is roster external.
    diagRecognized = TOAST_DB.me ~= nil
    diagCommunity = TOAST.inCommunity and true or false
    diagLeaderboard = me.rank ~= nil
    diagRoster = active.isRosterMember
    diagCurrentKey = keyStr

    -- keyData is already the live character's key, which is exactly what "am I
    -- sharing" asks about -- no second lookup needed.
    diagKeySharing = keyData ~= nil
    if keyData and keyData.lastSeen then
        local ageMin = math.floor((GetServerTime() - keyData.lastSeen) / 60)
        diagLastBroadcast = ageMin < 1 and "now" or (ageMin .. "m ago")
    else
        diagLastBroadcast = "\226\128\148"
    end
end

local function RefreshKeysList(live)
    for _, row in ipairs(keyRows) do
        row:Hide()
        row:SetParent(nil)
    end
    wipe(keyRows)

    local now = GetServerTime()
    local fresh = {}
    local addedNames = {}
    for _, entry in ipairs(TOAST.leaderboard) do
        addedNames[entry.name] = true
        local data = TOAST.GetCrewKeyDisplay and TOAST.GetCrewKeyDisplay(entry.name, entry.realm)
        if data then
            local recentlySeen = data.lastSeen and (now - data.lastSeen) < FRESH_THRESHOLD
            local onlineNow = TOAST.IsCharacterCurrentlyOnline
                and TOAST.IsCharacterCurrentlyOnline(entry.name, entry.realm)
            if recentlySeen or onlineNow then
                tinsert(fresh, { entry = entry, data = data })
            end
        end
    end

    -- Alts van leaderboard-spelers: zelfde discord_id, andere character.
    local discordIds = {}
    for _, entry in ipairs(TOAST.leaderboard) do
        local _, discordId = TOAST.CrewState.ResolveEntry(entry)
        if discordId then discordIds[discordId] = true end
    end
    for _, m in ipairs(TOAST.members) do
        if discordIds[m.discord_id] and not addedNames[m.name] then
            addedNames[m.name] = true
            local data = TOAST.GetCrewKeyDisplay and TOAST.GetCrewKeyDisplay(m.name, m.realm)
            if data then
                local recentlySeen = data.lastSeen and (now - data.lastSeen) < FRESH_THRESHOLD
                local onlineNow = TOAST.IsCharacterCurrentlyOnline
                    and TOAST.IsCharacterCurrentlyOnline(m.name, m.realm)
                if recentlySeen or onlineNow then
                    table.insert(fresh, { entry = m, data = data })
                end
            end
        end
    end

    -- Per discord_id maar één rij tonen: de character die het meest recent
    -- gezien is (alts van dezelfde speler verdringen elkaar).
    local bestByDiscordId = {}
    local noDiscordId = {}
    for _, item in ipairs(fresh) do
        local _, discordId = TOAST.CrewState.ResolveEntry(item.entry)
        if discordId then
            local current = bestByDiscordId[discordId]
            if not current or item.data.lastSeen > current.data.lastSeen then
                bestByDiscordId[discordId] = item
            end
        else
            table.insert(noDiscordId, item)
        end
    end
    local deduped = {}
    for _, item in pairs(bestByDiscordId) do
        table.insert(deduped, item)
    end
    for _, item in ipairs(noDiscordId) do
        table.insert(deduped, item)
    end
    fresh = deduped

    table.sort(fresh, function(a, b)
        local aOnline = TOAST.IsCharacterCurrentlyOnline
            and TOAST.IsCharacterCurrentlyOnline(a.entry.name, a.entry.realm) or false
        local bOnline = TOAST.IsCharacterCurrentlyOnline
            and TOAST.IsCharacterCurrentlyOnline(b.entry.name, b.entry.realm) or false
        if aOnline ~= bOnline then
            return aOnline
        end
        return a.data.level > b.data.level
    end)

    keysContent:SetWidth(keysScroll:GetWidth())

    if #fresh == 0 then
        emptyText:Show()
        emptyHelperText:Show()
    else
        emptyText:Hide()
        emptyHelperText:Hide()
        for i, item in ipairs(fresh) do
            local row = CreateFrame("Frame", nil, keysContent)
            row:SetSize(keysContent:GetWidth(), ROW_H)
            row:SetPoint("TOPLEFT", keysContent, "TOPLEFT", 0, -(i - 1) * ROW_H)

            local rowBg = row:CreateTexture(nil, "BACKGROUND")
            rowBg:SetAllPoints()
            rowBg:SetColorTexture(0, 0, 0, 0)
            row:EnableMouse(true)
            row:SetScript("OnEnter", function() rowBg:SetColorTexture(0.11, 0.13, 0.15, 0.50) end)
            row:SetScript("OnLeave", function() rowBg:SetColorTexture(0, 0, 0, 0) end)

            -- Discord identity (prefix icon + label + name), same language as
            -- the leaderboard's Discord column — this is "which SEAMEN person
            -- has a key", not a raw character list.
            local prefixIconTex = row:CreateTexture(nil, "OVERLAY")
            prefixIconTex:SetSize(20, 20)
            prefixIconTex:SetPoint("LEFT", row, "LEFT", 4, 0)

            local labelFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
            labelFS:SetPoint("LEFT", prefixIconTex, "RIGHT", 0, 0)
            labelFS:SetTextColor(0.8, 0.8, 0.8)

            local nameFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
            nameFS:SetJustifyH("LEFT")
            nameFS:SetWordWrap(false)
            nameFS:SetPoint("LEFT", labelFS, "RIGHT", 4, 0)
            nameFS:SetPoint("RIGHT", row, "LEFT", 194, 0)

            local discordNickname = item.entry.discord_nickname
            local p, discordName
            if discordNickname and discordNickname ~= "" then
                p, discordName = ParseDiscordPrefix(discordNickname)
            else
                discordName = item.entry.name
            end

            if p then
                prefixIconTex:SetTexture(p.tex)
                prefixIconTex:Show()
                labelFS:SetText(p.label)
                labelFS:Show()
            else
                prefixIconTex:Hide()
                labelFS:Hide()
            end

            -- Discord name is the main identity; character name rides along
            -- as a secondary tag, colored by class so it's visible at a glance.
            local charToken = CLASS_TOKEN[item.entry.class_]
            local charColor = charToken and RAID_CLASS_COLORS[charToken]
            local charColorHex = charColor
                and string.format("%02x%02x%02x",
                    math.floor(charColor.r * 255), math.floor(charColor.g * 255), math.floor(charColor.b * 255))
                or "999999"

            if discordName == item.entry.name then
                nameFS:SetText(string.format("|cffffffff%s|r", discordName))
            else
                nameFS:SetText(string.format("|cffffffff%s|r |cff%s(%s)|r", discordName, charColorHex, item.entry.name))
            end

            local roleTex = row:CreateTexture(nil, "OVERLAY")
            roleTex:SetTexture(ROLE_TEX[item.entry.role] or "")
            roleTex:SetSize(20, 20)
            roleTex:SetPoint("LEFT", row, "LEFT", 204, 0)

            local keyFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
            keyFS:SetPoint("LEFT", row, "LEFT", 230, 0)
            keyFS:SetText("+" .. item.data.level .. " " .. item.data.short)

            local dotFrame = CreateFrame("Frame", nil, row)
            dotFrame:SetSize(10, 10)
            dotFrame:SetPoint("RIGHT", row, "RIGHT", -4, 0)
            dotFrame:EnableMouse(true)

            local dot = dotFrame:CreateTexture(nil, "OVERLAY")
            dot:SetSize(6, 6)
            dot:SetPoint("CENTER")
            dot:SetColorTexture(TEAL[1], TEAL[2], TEAL[3], 1)

            dotFrame:SetScript("OnEnter", function(self)
                GameTooltip:SetOwner(self, "ANCHOR_TOP")
                GameTooltip:AddLine("Seen recently through TOAST", 1, 1, 1, true)
                GameTooltip:Show()
            end)
            dotFrame:SetScript("OnLeave", function() GameTooltip:Hide() end)

            local isOnline = TOAST.IsCharacterCurrentlyOnline
                and TOAST.IsCharacterCurrentlyOnline(item.entry.name, item.entry.realm)
            local lastSeenFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
            lastSeenFS:SetWidth(60)
            lastSeenFS:SetJustifyH("RIGHT")
            lastSeenFS:SetPoint("RIGHT", dotFrame, "LEFT", -6, 0)
            if isOnline then
                lastSeenFS:SetText("now")
                lastSeenFS:SetTextColor(TEAL[1], TEAL[2], TEAL[3])
            else
                local age = now - item.data.lastSeen
                lastSeenFS:SetText(math.floor(age / 60) .. "m ago")
                if age < 300 then
                    lastSeenFS:SetTextColor(1, 1, 1, 0.6)
                else
                    lastSeenFS:SetTextColor(1, 1, 1, 0.3)
                end
            end

            -- entry.resi (bot-computed in members.lua) is the level below
            -- which this character's keys won't deplete - the resilience
            -- floor. A current key AT OR BELOW that floor is protected (it
            -- can't be depleted any further), so the badge applies to that
            -- whole range, not just an exact match against the floor level.
            -- Same header wordmark as the leaderboard's Resi column, same
            -- size, reused rather than a new asset.
            local keyLevel = tonumber(item.data.level)
            local resiLevel = tonumber(item.entry.resi)
            if resiLevel and resiLevel > 0 and keyLevel and keyLevel <= resiLevel then
                -- Fixed row-relative X (not anchored off keyFS or lastSeenFS)
                -- so every row's badge lands at the exact same position - a
                -- dedicated status column between the key text and the seen
                -- indicator - regardless of how long that row's dungeon
                -- label is. 335 sits far enough right of keyFS's own start
                -- (230) that even the longest current dungeon labels (e.g.
                -- "+19 XENAS") keep visible clearance before the badge.
                local resiFrame = CreateFrame("Frame", nil, row)
                resiFrame:SetSize(72, 36)
                resiFrame:SetPoint("CENTER", row, "LEFT", 335, 0)

                local resiIcon = resiFrame:CreateTexture(nil, "OVERLAY")
                resiIcon:SetAllPoints()
                resiIcon:SetTexture("Interface\\AddOns\\TOAST\\media\\discord\\resi_256x128.tga")
            end

            row:SetScript("OnMouseUp", function(self, button)
                if button ~= "RightButton" then return end
                local memberEntry, discordId = TOAST.CrewState.ResolveEntry(item.entry)
                local target = GetOnlineTargetForMember(memberEntry, discordId)
                    or TOAST.FormatMemberTarget(item.entry)
                MenuUtil.CreateContextMenu(self, function(ownerRegion, rootDescription)
                    local crewCount = discordId and TOAST.CrewState.GetCrewMemberCount(discordId) or 0
                    if crewCount > 1 then
                        rootDescription:CreateButton("View Crew", function()
                            TOAST.ShowCrewPopup(discordId)
                        end)
                    end
                    if target then
                        rootDescription:CreateButton("Copy Name", function()
                            TOAST.ShowCopyPopup(target)
                        end)
                        rootDescription:CreateButton("Whisper", function()
                            ChatFrame_OpenChat("/w " .. target .. " ", DEFAULT_CHAT_FRAME)
                        end)
                        rootDescription:CreateButton("Invite", function()
                            C_PartyInfo.InviteUnit(target)
                        end)
                    else
                        rootDescription:CreateTitle("Realm unknown - cannot target")
                    end
                end)
            end)

            tinsert(keyRows, row)
        end
    end
    keysContent:SetHeight(math.max(#fresh * ROW_H, 1))
    -- A live merge re-renders the rows but must not resize the open window
    -- around the player's cursor. Card B is a scroll frame, so extra rows stay
    -- reachable at the current tier until the next open re-tiers it.
    if not live then
        ReflowCardB(KeysCardHeight(#fresh))
    end
end

local function BuildMyCrewRow(parent, entry, y)
    -- Realm-safe, and roster-gated: a crewless character must never take the
    -- active-row highlight off a same-named crew character on another realm.
    local active = TOAST.GetActiveCharacter()
    local entryKey = TOAST.CrewState.IdentityKey(entry.name, entry.realm)
    local isCurrent = active.isRosterMember and active.identityKey ~= nil
        and entryKey == active.identityKey
    local isOnlineElsewhere = not isCurrent
        and TOAST.IsCharacterCurrentlyOnline
        and TOAST.IsCharacterCurrentlyOnline(entry.name, entry.realm)

    local row = CreateFrame("Frame", nil, parent)
    row:SetHeight(CREW_ROW_H)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, y)
    row:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, y)
    row:EnableMouse(true)

    local bg = row:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()

    local function ResetBg()
        if isCurrent then
            bg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.08)
        elseif isOnlineElsewhere then
            bg:SetColorTexture(TEAL[1], TEAL[2], TEAL[3], 0.08)
        else
            bg:SetColorTexture(0, 0, 0, 0)
        end
    end
    ResetBg()

    if isCurrent then
        local accent = row:CreateTexture(nil, "ARTWORK")
        accent:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
        accent:SetWidth(2)
        accent:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
        accent:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
    elseif isOnlineElsewhere then
        local accent = row:CreateTexture(nil, "ARTWORK")
        accent:SetColorTexture(TEAL[1], TEAL[2], TEAL[3], 1)
        accent:SetWidth(2)
        accent:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0)
        accent:SetPoint("BOTTOMLEFT", row, "BOTTOMLEFT", 0, 0)
    end

    row:SetScript("OnEnter", function() bg:SetColorTexture(0.11, 0.13, 0.15, 0.50) end)
    row:SetScript("OnLeave", ResetBg)

    local roleTex = row:CreateTexture(nil, "OVERLAY")
    roleTex:SetSize(20, 20)
    roleTex:SetPoint("LEFT", row, "LEFT", 12, 0)
    roleTex:SetTexture(ROLE_TEX[entry.role] or "")

    -- Character-realm: same "class-colored name" + "|cffCCCCCC-realm|r"
    -- format as the leaderboard, as one string (no separate realm column).
    local token = CLASS_TOKEN[entry.class_]
    local c = token and RAID_CLASS_COLORS[token]
    local nameFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    nameFS:SetPoint("LEFT", roleTex, "RIGHT", 6, 0)
    if c then
        nameFS:SetText(string.format("|cff%02x%02x%02x%s|r|cffCCCCCC-%s|r",
            math.floor(c.r * 255), math.floor(c.g * 255), math.floor(c.b * 255),
            entry.name, entry.realm))
    else
        nameFS:SetText(entry.name .. "|cffCCCCCC-" .. entry.realm .. "|r")
    end

    local keyFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    keyFS:SetPoint("RIGHT", row, "RIGHT", -12, 0)
    keyFS:SetWidth(70)
    keyFS:SetJustifyH("RIGHT")
    keyFS:SetTextColor(1, 1, 1, 1)

    local resiFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    resiFS:SetPoint("RIGHT", keyFS, "LEFT", -8, 0)
    resiFS:SetWidth(50)
    resiFS:SetJustifyH("RIGHT")

    local rioFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    rioFS:SetPoint("RIGHT", resiFS, "LEFT", -8, 0)
    rioFS:SetWidth(70)
    rioFS:SetJustifyH("RIGHT")

    local function UpdateContent()
        local resolved = TOAST.ResolveCharacterScore and TOAST.ResolveCharacterScore(entry.name, entry.realm) or nil
        rioFS:SetText(string.format("|cff999999RIO |r|cffffffff%s|r",
            resolved and tostring(resolved.score) or "\226\128\148"))

        if entry.resi and entry.resi > 0 then
            resiFS:SetText(string.format("|cff999999Resi |r|cffffffff%d|r", entry.resi))
            resiFS:SetAlpha(1)
        else
            resiFS:SetText("|cff999999Resi |r|cffffffff0|r")
            resiFS:SetAlpha(0.35)
        end

        local keyData = TOAST.GetCrewKeyDisplay and TOAST.GetCrewKeyDisplay(entry.name, entry.realm)
        keyFS:SetText(keyData and ("+" .. keyData.level .. " " .. keyData.short) or "\226\128\148")
    end
    UpdateContent()

    row:SetScript("OnMouseUp", function(self, button)
        if button ~= "RightButton" then return end
        local target = GetOnlineTargetForMember(entry)
            or TOAST.FormatMemberTarget(entry)
        MenuUtil.CreateContextMenu(self, function(ownerRegion, rootDescription)
            if target then
                rootDescription:CreateButton("Copy Name", function()
                    TOAST.ShowCopyPopup(target)
                end)
            else
                rootDescription:CreateTitle("Realm unknown - cannot target")
            end
        end)
    end)

    return UpdateContent
end

local function RefreshMyCrew()
    if not myCrewCard then return end
    local me = TOAST_DB.me
    if not me or not me.discord_id then
        myCrewCard:Hide()
        return
    end
    myCrewCard:Show()
    for _, update in ipairs(myCrewRows) do
        update()
    end
end

local function FormatDelta(diff)
    if diff > 0 then return "+" .. diff .. " RIO" end
    return diff .. " RIO"  -- Lua's tostring on a negative number already has the "-"
end

local function SetDeltaColor(fs, diff)
    if diff > 0 then
        fs:SetTextColor(SOFT_GREEN[1], SOFT_GREEN[2], SOFT_GREEN[3])
    elseif diff < 0 then
        fs:SetTextColor(SOFT_RED[1], SOFT_RED[2], SOFT_RED[3])
    else
        fs:SetTextColor(1, 1, 1, 0.6)
    end
end

local function SetProgressPrefix(iconTex, labelFS, discordNickname)
    local p = discordNickname and discordNickname ~= "" and (ParseDiscordPrefix(discordNickname)) or nil
    if p then
        iconTex:SetTexture(p.tex)
        iconTex:Show()
        labelFS:SetText(p.label)
        labelFS:Show()
    else
        iconTex:Hide()
        labelFS:Hide()
    end
end

-- Everything Your Progress shows about a resolved leaderboard position.
-- Both empty states clear the whole set, so neither can leave a stale rank,
-- RIO or neighbour row behind next to their own message.
local function HideProgressDetail()
    nextRankFS:Hide(); nextPrefixIcon:Hide(); nextPrefixLabelFS:Hide()
    nextNameFS:Hide(); nextDeltaFS:Hide()
    behindRankFS:Hide(); behindPrefixIcon:Hide(); behindPrefixLabelFS:Hide()
    behindNameFS:Hide(); behindDeltaFS:Hide()
    progressRankStat:Hide()
    progressRioStat:Hide()
    nextFallbackFS:Hide()
end

-- The one warning-state layout this panel owns (alert icon / title / alert
-- icon, divider, action, refresh). Both callers below are "there is no
-- progress to show, here is what to do about it", and the remedy is
-- identical, so only the title varies. The two action lines are fixed-width
-- with SetWordWrap(false) and are already close to the column width, so
-- per-state phrasings would clip rather than wrap -- keep them shared.
local PROGRESS_EMPTY_ACTION = "Add character to |cffE0BE5D/crew|r on Discord."
local PROGRESS_EMPTY_REFRESH = "Update TOAST after the 09:00 refresh."

local function ShowProgressEmptyState(title)
    if not rosterEmptyTitleFS then return end
    if progressTrendsBtn then progressTrendsBtn:Hide() end
    rosterEmptyTitleFS:SetText(title)
    rosterEmptyActionFS:SetText(PROGRESS_EMPTY_ACTION)
    rosterEmptyRefreshFS:SetText(PROGRESS_EMPTY_REFRESH)
    rosterEmptyTitleFS:Show()
    rosterEmptyAlertLeft:Show()
    rosterEmptyAlertRight:Show()
    rosterEmptyDivider:Show()
    rosterEmptyActionFS:Show()
    rosterEmptyRefreshFS:Show()
end

local function RefreshLeaderboardPreview()
    local me = TOAST_DB.me
    local active = TOAST.GetActiveCharacter()
    if rosterEmptyTitleFS then
        rosterEmptyTitleFS:Hide()
        rosterEmptyAlertLeft:Hide()
        rosterEmptyAlertRight:Hide()
        rosterEmptyDivider:Hide()
        rosterEmptyActionFS:Hide()
        rosterEmptyRefreshFS:Hide()
    end
    if progressTrendsBtn then progressTrendsBtn:Show() end

    -- Progress belongs to the character being played, so a roster external
    -- character gets an action state instead of the remembered main's rank,
    -- RIO and neighbours. Checked before the leaderboard lookup below, which
    -- keys off TOAST_DB.me and would otherwise resolve the main's row.
    -- Deliberately scoped to this panel: the remembered identity still owns
    -- the Status Report, the gold leaderboard self-highlight and My Crew.
    if not active.isRosterMember then
        HideProgressDetail()
        ShowProgressEmptyState("CREWLESS CHARACTER")
        return
    end

    -- me.rank only ever identifies WHICH generated entry is "me" (a lookup
    -- key into the bot-generated array); it never drives display or delta
    -- math below - the recalculated snapshot rank does that instead, so a
    -- resolved score that has overtaken a neighbour is always reflected.
    local ownEntry = me and me.rank and TOAST.leaderboard[me.rank]
    local snapshot = ownEntry and TOAST.GetResolvedLeaderboardSnapshot and TOAST.GetResolvedLeaderboardSnapshot() or nil

    local ownRow
    if snapshot then
        for _, row in ipairs(snapshot) do
            if row.entry == ownEntry then
                ownRow = row
                break
            end
        end
    end

    if not ownRow or not ownRow.resolved then
        HideProgressDetail()
        if not me then
            ShowProgressEmptyState("NOT IN SEAMEN ROSTER")
        else
            nextFallbackFS:SetText("Leaderboard data unavailable")
            nextFallbackFS:Show()
        end
        return
    end

    SetSummaryStat(progressRankStat, string.format("|cffE0BF5C#%d of %d|r", ownRow.rank, #snapshot))
    SetSummaryStat(progressRioStat, "|cff999999RIO|r |cffffffff" .. tostring(ownRow.resolved.score) .. "|r")

    local above = ownRow.rank > 1 and snapshot[ownRow.rank - 1] or nil
    local below = snapshot[ownRow.rank + 1]

    if above and above.resolved then
        nextFallbackFS:Hide()
        nextRankFS:Show(); nextNameFS:Show(); nextDeltaFS:Show()
        nextRankFS:SetText("#" .. tostring(above.rank or (ownRow.rank - 1)))
        SetProgressPrefix(nextPrefixIcon, nextPrefixLabelFS, above.entry.discord_nickname)
        nextNameFS:SetText(above.entry.name)
        local diff = ownRow.resolved.score - above.resolved.score
        nextDeltaFS:SetText(FormatDelta(diff))
        SetDeltaColor(nextDeltaFS, diff)
    else
        nextRankFS:Hide(); nextPrefixIcon:Hide(); nextPrefixLabelFS:Hide()
        nextNameFS:Hide(); nextDeltaFS:Hide()
        if ownRow.rank == 1 then
            nextFallbackFS:SetText("Top of the crew")
            nextFallbackFS:Show()
        else
            -- Adjacent row exists but has no resolved score of its own -
            -- hide the comparison rather than inventing/falling back to a
            -- generated score.
            nextFallbackFS:Hide()
        end
    end

    if below and below.resolved then
        behindRankFS:Show(); behindNameFS:Show(); behindDeltaFS:Show()
        behindRankFS:SetText("#" .. tostring(below.rank or (ownRow.rank + 1)))
        SetProgressPrefix(behindPrefixIcon, behindPrefixLabelFS, below.entry.discord_nickname)
        behindNameFS:SetText(below.entry.name)
        local diff = ownRow.resolved.score - below.resolved.score
        behindDeltaFS:SetText(FormatDelta(diff))
        SetDeltaColor(behindDeltaFS, diff)
    else
        behindRankFS:Hide(); behindPrefixIcon:Hide(); behindPrefixLabelFS:Hide()
        behindNameFS:Hide(); behindDeltaFS:Hide()
    end
end

local function HideWeeklyColumn(rows, emptyFS)
    for _, row in ipairs(rows) do row:Hide() end
    emptyFS:Hide()
end

local function ActivityStatusText(status, scopeLabel)
    if status == "empty" then return "No results " .. scopeLabel end
    if status == "insufficient_data" then return "Not enough history yet" end
    if status == "disabled" then return "Data disabled for " .. scopeLabel end
    return "Data unavailable for " .. scopeLabel
end

local function RefreshWeeklyRow(row, entry, valueKey, isClimber)
    if type(entry) ~= "table" or type(entry.discord_nickname) ~= "string"
        or entry.discord_nickname == "" then
        return false
    end

    local rank = tonumber(entry.rank)
    local value = tonumber(entry[valueKey])
    if not rank or rank ~= rank or rank <= 0 or rank == math.huge
        or not value or value ~= value or value == math.huge or value == -math.huge then
        return false
    end

    row.rankFS:SetText(tostring(rank))
    if rank == 1 then
        row.rankFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    else
        row.rankFS:SetTextColor(1, 1, 1, 0.42)
    end

    local p, cleanName = ParseDiscordPrefix(entry.discord_nickname)
    row.nameFS:ClearAllPoints()
    if p then
        row.prefixIcon:SetTexture(p.tex)
        row.prefixIcon:Show()
        row.prefixFS:SetText(p.label)
        row.prefixFS:Show()
        row.nameFS:SetPoint("LEFT", row.prefixFS, "RIGHT", 3, 0)
    else
        row.prefixIcon:Hide()
        row.prefixFS:Hide()
        row.nameFS:SetPoint("LEFT", row, "LEFT", 12, 0)
    end
    row.nameFS:SetPoint("RIGHT", row.valueFS, "LEFT", -4, 0)
    row.nameFS:SetText((p and cleanName) or entry.discord_nickname)

    if isClimber then
        local prefix = value > 0 and "+" or ""
        row.valueFS:SetText(prefix .. tostring(value) .. " RIO")
        SetDeltaColor(row.valueFS, value)
    else
        row.valueFS:SetText(tostring(value) .. " pts")
        row.valueFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    end

    row:Show()
    return true
end

local function RefreshActivityColumn(rows, emptyFS, status, entries, valueKey, isClimber, scopeLabel)
    HideWeeklyColumn(rows, emptyFS)
    if status ~= "ready" or type(entries) ~= "table" then
        emptyFS:SetText(ActivityStatusText(status, scopeLabel))
        emptyFS:Show()
        return 0
    end

    local shown = 0
    for _, entry in ipairs(entries) do
        if shown >= #rows then break end
        if RefreshWeeklyRow(rows[shown + 1], entry, valueKey, isClimber) then
            shown = shown + 1
        end
    end
    if shown == 0 then
        emptyFS:SetText("No results this " .. scopeLabel)
        emptyFS:Show()
    end
    return shown
end

local function ResizeWeeklyCard(rowCount)
    local height = CARD_C_HEIGHT_ONE
    if rowCount >= 3 then
        height = CARD_C_HEIGHT_FULL
    elseif rowCount == 2 then
        height = CARD_C_HEIGHT_TWO
    end
    if cardC:GetHeight() == height then return end

    cardC:SetHeight(height)
    local myCrewDelta = myCrewCard and (myCrewCard:GetHeight() + CARD_GAP) or 0
    local totalHeight = (-CARD_A_TOP) + CARD_A_HEIGHT + CARD_GAP + cardB:GetHeight() + CARD_GAP + height
        + FOOTER_GAP + FOOTER_HEIGHT + PAD + myCrewDelta
    dash:SetHeight(totalHeight)
end

local function RefreshActivityTabs()
    for key, tab in pairs(activityTabs) do
        if key == activityActiveTab then
            tab.label:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
            tab.underline:Show()
        else
            tab.label:SetTextColor(1, 1, 1, 0.45)
            tab.underline:Hide()
        end
    end
end

local function RefreshWeeklyActivity()
    local periodType = activityActiveTab == "month" and "monthly"
        or activityActiveTab == "season" and "season"
        or "weekly"
    local scopeLabel = periodType == "weekly" and "this week"
        or periodType == "monthly" and "the last 30 days"
        or "this season"
    local activity = TOAST.GetTrendPeriod and TOAST.GetTrendPeriod(periodType) or nil

    RefreshActivityTabs()
    if type(activity) ~= "table" or activity.status ~= "ready" then
        HideWeeklyColumn(weeklyMostRows, weeklyMostEmptyFS)
        HideWeeklyColumn(weeklyClimbersRows, weeklyClimbersEmptyFS)
        weeklyMostHeaderFS:Hide()
        weeklyClimbersHeaderFS:Hide()
        weeklyPeriodFS:SetText("")
        weeklyFallbackFS:SetText(ActivityStatusText(type(activity) == "table" and activity.status or nil, scopeLabel))
        weeklyFallbackFS:Show()
        ResizeWeeklyCard(0)
        return
    end

    weeklyFallbackFS:Hide()
    weeklyMostHeaderFS:Show()
    weeklyClimbersHeaderFS:Show()
    weeklyPeriodFS:SetText(FormatWeeklyPeriod(activity.period_start, activity.period_end) or "")
    local mostCount = RefreshActivityColumn(weeklyMostRows, weeklyMostEmptyFS, activity.most_active_status,
        activity.most_active, "activity_points", false, scopeLabel)
    local climberCount = RefreshActivityColumn(weeklyClimbersRows, weeklyClimbersEmptyFS,
        activity.top_climbers_status, activity.top_climbers, "rio_delta", true, scopeLabel)
    ResizeWeeklyCard(math.max(mostCount, climberCount))
end

local function RefreshFooter()
    local online = 0
    local counted = {}
    for _, entry in ipairs(TOAST.leaderboard or {}) do
        local _, discordId = TOAST.CrewState.ResolveEntry(entry)
        local identity = discordId or (entry.name .. "-" .. (entry.realm or ""))
        if not counted[identity] then
            counted[identity] = true
            local status = TOAST.GetPresenceStatus
                and TOAST.GetPresenceStatus(entry.name, entry.realm, discordId)
            if status == "online" then
                online = online + 1
            end
        end
    end

    pulseOnlineFS:SetText(online .. " crew online")
    pulseOnlineBtn:SetWidth(pulseOnlineFS:GetStringWidth())

    local known = TOAST.GetKnownCrewKeyCount and TOAST.GetKnownCrewKeyCount() or 0
    pulseKeysFS:SetText(known .. " keys spotted")
    pulseKeysBtn:SetWidth(pulseKeysFS:GetStringWidth())

    pulseMembersFS:SetText(#TOAST.leaderboard .. " members")
    pulseMembersBtn:SetWidth(pulseMembersFS:GetStringWidth())
end

local function Refresh(live)
    -- The one explicit active/API refresh boundary for this dashboard pass —
    -- TOAST.ResolveCharacterScore() itself stays pure (no API reads, see
    -- scores.lua), so a fresh self-observation only ever enters here.
    if TOAST.RefreshOwnScore then TOAST.RefreshOwnScore() end
    if TOAST.RefreshOnlineStatus then TOAST.RefreshOnlineStatus() end
    RefreshStatus()
    RefreshMyCrew()
    RefreshKeysList(live)
    RefreshLeaderboardPreview()
    RefreshWeeklyActivity()
    RefreshFooter()
end

-- crew_state.lua calls TOAST.OnCrewStateChanged() for every accepted key or
-- score merge. Those arrive in bursts (a snapshot merges dozens of records in
-- one frame), so coalesce them into a single re-render instead of repainting
-- per record. Nothing happens while the dashboard is closed -- ShowDashboard()
-- already does a full Refresh() with the window resize.
local function ScheduleLiveRefresh()
    if not dash or not dash:IsShown() or liveRefreshPending then return end
    liveRefreshPending = true
    C_Timer.After(LIVE_REFRESH_DELAY, function()
        liveRefreshPending = false
        if dash and dash:IsShown() then Refresh(true) end
    end)
end

function TOAST.OnCrewStateChanged()
    ScheduleLiveRefresh()
end

-- Each Build* function below owns only the module-level locals it actually
-- touches, so its upvalue count stays well clear of Lua 5.1's 60-upvalue
-- limit per function. BuildDashboard() used to build every card inline and
-- closed over ~all of them at once, which pushed it over that limit and
-- broke the addon on load.

local function BuildProgressSection(parent, statusIdentityRow, statusCharacterRow, statusChipAnchor)
    local progressTitle = parent:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
    progressTitle:SetPoint("TOPLEFT", parent, "TOPLEFT", PROGRESS_X, PLAYER_TOP_Y)
    progressTitle:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    progressTitle:SetText("Your Progress")

    -- Compact card-local navigation belongs to this content group: it opens
    -- the detailed view represented by Your Progress rather than acting as
    -- a peer page switch in the dashboard header.
    local trendsBtn = CreateFrame("Button", nil, parent)
    progressTrendsBtn = trendsBtn
    trendsBtn:SetSize(20, 20)
    trendsBtn:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -PROGRESS_RIGHT_PAD, -8)

    local trendsBtnBg = trendsBtn:CreateTexture(nil, "BACKGROUND")
    trendsBtnBg:SetAllPoints()
    trendsBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.06)

    local trendsTop, trendsBottom, trendsLeft, trendsRight = AddBorder(trendsBtn, BORDER)
    trendsTop:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.35)
    trendsBottom:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.35)
    trendsLeft:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.35)
    trendsRight:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.35)

    local trendsIcon = trendsBtn:CreateTexture(nil, "ARTWORK")
    trendsIcon:SetSize(14, 14)
    trendsIcon:SetPoint("CENTER")
    trendsIcon:SetTexture("Interface\\AddOns\\TOAST\\media\\buttons\\line-chart.png")
    trendsIcon:SetDesaturated(true)
    trendsIcon:SetVertexColor(GOLD[1], GOLD[2], GOLD[3])
    trendsIcon:SetAlpha(0.85)

    trendsBtn:SetScript("OnEnter", function(self)
        trendsTop:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.9)
        trendsBottom:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.9)
        trendsLeft:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.9)
        trendsRight:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.9)
        trendsBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.14)
        trendsIcon:SetAlpha(1.0)
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:AddLine("Open Trends", GOLD[1], GOLD[2], GOLD[3])
        GameTooltip:AddLine("View RIO score history and progress over time.", 1, 1, 1, true)
        GameTooltip:AddLine(" ")
        GameTooltip:AddDoubleLine("Left-click", "Open Trends", 0.7, 0.7, 0.7, 1, 1, 1)
        GameTooltip:AddDoubleLine("Right-click", "Open alongside Dashboard", 0.7, 0.7, 0.7, 1, 1, 1)
        GameTooltip:Show()
    end)
    trendsBtn:SetScript("OnLeave", function()
        trendsTop:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.35)
        trendsBottom:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.35)
        trendsLeft:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.35)
        trendsRight:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.35)
        trendsBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.06)
        trendsIcon:ClearAllPoints()
        trendsIcon:SetPoint("CENTER", 0, 0)
        trendsIcon:SetAlpha(0.85)
        GameTooltip:Hide()
    end)
    trendsBtn:SetScript("OnMouseDown", function()
        trendsIcon:ClearAllPoints()
        trendsIcon:SetPoint("CENTER", 1, -1)
    end)
    trendsBtn:SetScript("OnMouseUp", function()
        trendsIcon:ClearAllPoints()
        trendsIcon:SetPoint("CENTER", 0, 0)
    end)
    trendsBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    trendsBtn:SetScript("OnClick", function(self, button)
        GameTooltip:Hide()
        if not TOAST.ShowTrends then
            print("|cffE0BE5DTOAST:|r TOAST Trends coming soon.")
            return
        end
        if button == "RightButton" then
            TOAST.ShowTrends()
        else
            dash:Hide()
            TOAST.ShowTrends()
        end
    end)

    local nextRow = CreateFrame("Frame", nil, parent)
    nextRow:SetSize((W - PAD * 2) - PROGRESS_X - PROGRESS_RIGHT_PAD, 20)
    nextRow:SetPoint("LEFT", statusIdentityRow, "LEFT", PROGRESS_X - PLAYER_PAD_X, 0)

    nextRankFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    nextRankFS:SetPoint("LEFT", nextRow, "LEFT", 0, 0)
    nextRankFS:SetWidth(PROGRESS_RANK_W)
    nextRankFS:SetJustifyH("LEFT")
    nextRankFS:SetTextColor(1, 1, 1, 0.42)

    nextPrefixIcon = parent:CreateTexture(nil, "OVERLAY")
    nextPrefixIcon:SetSize(20, 20)
    nextPrefixIcon:SetPoint("LEFT", nextRow, "LEFT", PROGRESS_RANK_W, 0)

    nextPrefixLabelFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    nextPrefixLabelFS:SetPoint("LEFT", nextPrefixIcon, "RIGHT", 0, 0)
    nextPrefixLabelFS:SetTextColor(0.8, 0.8, 0.8)

    nextNameFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    nextNameFS:SetPoint("LEFT", nextPrefixLabelFS, "RIGHT", 4, 0)
    nextNameFS:SetPoint("RIGHT", nextRow, "RIGHT", -(PROGRESS_DELTA_W + PROGRESS_IDENTITY_GAP), 0)
    nextNameFS:SetJustifyH("LEFT")
    nextNameFS:SetWordWrap(false)
    nextNameFS:SetTextColor(1, 1, 1, 1)

    nextDeltaFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    nextDeltaFS:SetPoint("RIGHT", nextRow, "RIGHT", 0, 0)
    nextDeltaFS:SetWidth(PROGRESS_DELTA_W)
    nextDeltaFS:SetJustifyH("RIGHT")

    nextFallbackFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    nextFallbackFS:SetPoint("LEFT", nextRow, "LEFT", 0, 0)
    nextFallbackFS:SetWidth((W - PAD * 2) - PROGRESS_X - PROGRESS_RIGHT_PAD)
    nextFallbackFS:SetJustifyH("LEFT")
    nextFallbackFS:SetWordWrap(false)
    nextFallbackFS:SetTextColor(1, 1, 1, 0.5)
    nextFallbackFS:Hide()

    -- A recognized player on a crew character sees compact rank rows here.
    -- An unknown character, or a crewless one, gets this shared empty state
    -- instead, with deliberate status -> action -> refresh spacing rather
    -- than a wrapped fallback sentence. The three strings are set per state
    -- by ShowProgressEmptyState(), so no build-time copy competes with them.
    rosterEmptyAlertLeft = parent:CreateTexture(nil, "OVERLAY")
    rosterEmptyAlertLeft:SetTexture("Interface\\AddOns\\TOAST\\media\\dividers\\UPDATE.png")
    rosterEmptyAlertLeft:SetSize(20, 20)
    rosterEmptyAlertLeft:SetPoint("TOPLEFT", nextRow, "TOPLEFT", 0, 0)
    rosterEmptyAlertLeft:SetAlpha(0.72)
    rosterEmptyAlertLeft:Hide()

    rosterEmptyTitleFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    -- No SetWidth: the title sizes to its text so the right-hand alert icon,
    -- which anchors to it, reflows for either state's wording.
    rosterEmptyTitleFS:SetPoint("LEFT", rosterEmptyAlertLeft, "RIGHT", 4, 0)
    rosterEmptyTitleFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 0.72)
    rosterEmptyTitleFS:Hide()

    rosterEmptyAlertRight = parent:CreateTexture(nil, "OVERLAY")
    rosterEmptyAlertRight:SetTexture("Interface\\AddOns\\TOAST\\media\\dividers\\UPDATE.png")
    rosterEmptyAlertRight:SetSize(20, 20)
    rosterEmptyAlertRight:SetPoint("LEFT", rosterEmptyTitleFS, "RIGHT", 4, 0)
    rosterEmptyAlertRight:SetAlpha(0.72)
    rosterEmptyAlertRight:Hide()

    rosterEmptyDivider = parent:CreateTexture(nil, "ARTWORK")
    rosterEmptyDivider:SetPoint("TOPLEFT", rosterEmptyAlertLeft, "BOTTOMLEFT", 0, -6)
    rosterEmptyDivider:SetPoint("TOPRIGHT", rosterEmptyAlertRight, "BOTTOMRIGHT", 0, -6)
    rosterEmptyDivider:SetHeight(1)
    rosterEmptyDivider:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.28)
    rosterEmptyDivider:Hide()

    rosterEmptyActionFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    rosterEmptyActionFS:SetPoint("TOPLEFT", rosterEmptyDivider, "BOTTOMLEFT", 0, -8)
    rosterEmptyActionFS:SetWidth((W - PAD * 2) - PROGRESS_X - PROGRESS_RIGHT_PAD)
    rosterEmptyActionFS:SetJustifyH("LEFT")
    rosterEmptyActionFS:SetWordWrap(false)
    rosterEmptyActionFS:SetTextColor(1, 1, 1, 0.52)
    rosterEmptyActionFS:Hide()

    rosterEmptyRefreshFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    rosterEmptyRefreshFS:SetPoint("TOPLEFT", rosterEmptyActionFS, "BOTTOMLEFT", 0, -4)
    rosterEmptyRefreshFS:SetWidth((W - PAD * 2) - PROGRESS_X - PROGRESS_RIGHT_PAD)
    rosterEmptyRefreshFS:SetJustifyH("LEFT")
    rosterEmptyRefreshFS:SetWordWrap(false)
    rosterEmptyRefreshFS:SetTextColor(1, 1, 1, 0.52)
    rosterEmptyRefreshFS:Hide()

    local behindRow = CreateFrame("Frame", nil, parent)
    behindRow:SetSize((W - PAD * 2) - PROGRESS_X - PROGRESS_RIGHT_PAD, 20)
    behindRow:SetPoint("LEFT", statusCharacterRow, "LEFT", PROGRESS_X - PLAYER_PAD_X, 0)

    behindRankFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    behindRankFS:SetPoint("LEFT", behindRow, "LEFT", 0, 0)
    behindRankFS:SetWidth(PROGRESS_RANK_W)
    behindRankFS:SetJustifyH("LEFT")
    behindRankFS:SetTextColor(1, 1, 1, 0.42)

    behindPrefixIcon = parent:CreateTexture(nil, "OVERLAY")
    behindPrefixIcon:SetSize(20, 20)
    behindPrefixIcon:SetPoint("LEFT", behindRow, "LEFT", PROGRESS_RANK_W, 0)

    behindPrefixLabelFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    behindPrefixLabelFS:SetPoint("LEFT", behindPrefixIcon, "RIGHT", 0, 0)
    behindPrefixLabelFS:SetTextColor(0.8, 0.8, 0.8)

    behindNameFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    behindNameFS:SetPoint("LEFT", behindPrefixLabelFS, "RIGHT", 4, 0)
    behindNameFS:SetPoint("RIGHT", behindRow, "RIGHT", -(PROGRESS_DELTA_W + PROGRESS_IDENTITY_GAP), 0)
    behindNameFS:SetJustifyH("LEFT")
    behindNameFS:SetWordWrap(false)
    behindNameFS:SetTextColor(1, 1, 1, 1)

    behindDeltaFS = parent:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    behindDeltaFS:SetPoint("RIGHT", behindRow, "RIGHT", 0, 0)
    behindDeltaFS:SetWidth(PROGRESS_DELTA_W)
    behindDeltaFS:SetJustifyH("RIGHT")

    progressChipAnchor = CreateFrame("Frame", nil, parent)
    progressChipAnchor:SetSize(1, 1)
    progressChipAnchor:SetPoint("LEFT", statusChipAnchor, "LEFT", PROGRESS_X - PLAYER_PAD_X, 0)

    progressRankStat = CreateSummaryStat(parent)
    progressRankStat:SetPoint("TOPLEFT", progressChipAnchor, "BOTTOMLEFT", 0, 4)

    progressRioStat = CreateSummaryStat(parent)
    progressRioStat:SetPoint("LEFT", progressRankStat, "RIGHT", 6, 0)

end

local function BuildCardA(parent, yOffset, height, skipAccent)
    cardA = CreateCard(parent, yOffset, height, skipAccent)

    -- Title: styled like every other card's section title (gold
    -- TOASTFontNormal), sharing Card A's left column so it lines up with
    -- "Ahoy," below it instead of the generic 12px other cards use.
    local cardTitle = cardA:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
    cardTitle:SetPoint("TOPLEFT", cardA, "TOPLEFT", PLAYER_PAD_X, PLAYER_TOP_Y)
    cardTitle:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    cardTitle:SetText("Status Report")

    -- Row 1: greeting + Discord identity, anchored at the shared left column
    -- so every row below lines up with the title instead of the row above it.
    ahoyFS = cardA:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    ahoyFS:SetPoint("TOPLEFT", cardTitle, "BOTTOMLEFT", 0, -PLAYER_TITLE_GAP)
    ahoyFS:SetTextColor(1, 1, 1, 0.55)

    prefixIcon = cardA:CreateTexture(nil, "OVERLAY")
    prefixIcon:SetSize(20, 20)
    prefixIcon:SetPoint("LEFT", ahoyFS, "RIGHT", 0, 0)

    prefixLabel = cardA:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    prefixLabel:SetPoint("LEFT", prefixIcon, "RIGHT", 0, 0)
    prefixLabel:SetTextColor(0.8, 0.8, 0.8)

    discordNameFS = cardA:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    discordNameFS:SetPoint("LEFT", prefixLabel, "RIGHT", 4, 0)
    discordNameFS:SetWidth(126)
    discordNameFS:SetJustifyH("LEFT")
    discordNameFS:SetWordWrap(false)

    -- Row 2: Character-Realm — anchored off ahoyFS (not prefixIcon), so its
    -- left edge sits at PLAYER_PAD_X regardless of how wide the Discord
    -- identity row above it is.
    charIcon = cardA:CreateTexture(nil, "OVERLAY")
    charIcon:SetSize(20, 20)
    charIcon:SetPoint("TOPLEFT", ahoyFS, "BOTTOMLEFT", 0, -PLAYER_ROW_GAP)

    charLine = cardA:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    charLine:SetPoint("LEFT", charIcon, "RIGHT", 4, 0)
    charLine:SetWidth(PROGRESS_DIVIDER_X - PLAYER_PAD_X - 34)
    charLine:SetJustifyH("LEFT")
    charLine:SetWordWrap(false)

    -- Row 2b: only shown when the logged-in character is not in members.lua.
    -- It qualifies the character line above it, never the Discord identity on
    -- Row 1, so it hangs off charLine and fits inside the existing slack ahead
    -- of the chip row -- CARD_A_HEIGHT stays put and Your Progress, which
    -- anchors to the same left column, does not shift.
    crewlessFS = cardA:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
    crewlessFS:SetPoint("TOPLEFT", charLine, "BOTTOMLEFT", 0, -2)
    crewlessFS:SetJustifyH("LEFT")
    crewlessFS:SetWordWrap(false)
    crewlessFS:SetTextColor(1, 1, 1, 0.55)
    crewlessFS:SetText("Crewless character")
    crewlessFS:Hide()

    -- Row 3: quiet at-a-glance status text (resi / key), built in
    -- RefreshStatus. chipAnchor is an invisible anchor pinned to the shared
    -- left column (rather than reusing charLine, whose left edge trails
    -- charIcon) so the chip row doesn't inherit Row 2's indent.
    chipAnchor = CreateFrame("Frame", nil, cardA)
    chipAnchor:SetSize(1, 1)
    chipAnchor:SetPoint("TOPLEFT", charIcon, "BOTTOMLEFT", 0, -PLAYER_CHIP_GAP)

    statusResiStat = CreateSummaryStat(cardA)
    statusResiStat:SetPoint("TOPLEFT", chipAnchor, "BOTTOMLEFT", 0, 4)

    statusKeyStat = CreateSummaryStat(cardA)
    statusKeyStat:SetPoint("LEFT", statusResiStat, "RIGHT", 6, 0)

    -- Build the right half only after the three left-side row anchors exist;
    -- each progress row attaches to its exact counterpart rather than using
    -- independently eyeballed Y offsets.
    BuildProgressSection(cardA, ahoyFS, charIcon, chipAnchor)

    -- Diagnostics is supporting help for Status Report, so keep it attached
    -- to that title. Trends remains edge-aligned because it is a navigation
    -- action rather than explanatory title metadata.
    diagBtn = CreateFrame("Button", nil, cardA)
    diagBtn:SetSize(16, 16)
    diagBtn:SetPoint("LEFT", cardTitle, "RIGHT", 6, 0)

    diagBtnFS = diagBtn:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    diagBtnFS:SetAllPoints()
    diagBtnFS:SetJustifyH("CENTER")
    diagBtnFS:SetJustifyV("MIDDLE")
    diagBtnFS:SetText("?")
    diagBtnFS:SetTextColor(1, 1, 1, 0.35)

    diagBtn:SetScript("OnEnter", function(self)
        diagBtnFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:AddLine("TOAST Diagnostics", GOLD[1], GOLD[2], GOLD[3])
        GameTooltip:AddLine(" ")
        AddDiagLine("Recognized", diagRecognized)
        AddDiagLine("In TIDE and Crew", diagCommunity)
        AddDiagLine("Known in leaderboard", diagLeaderboard)
        AddDiagLine("Known in full roster", diagRoster)
        AddDiagLine("Key sharing", diagKeySharing, "On", "Off")
        GameTooltip:AddDoubleLine("Last broadcast", diagLastBroadcast, 0.7, 0.7, 0.7,
            diagLastBroadcast == "now" and TEAL[1] or 1,
            diagLastBroadcast == "now" and TEAL[2] or 1,
            diagLastBroadcast == "now" and TEAL[3] or 1)
        GameTooltip:AddDoubleLine("Current detected key", diagCurrentKey, 0.7, 0.7, 0.7, 1, 1, 1)
        GameTooltip:AddDoubleLine("Version", "v" .. TOAST.VERSION, 0.7, 0.7, 0.7, 1, 1, 1)
        GameTooltip:Show()
    end)
    diagBtn:SetScript("OnLeave", function()
        diagBtnFS:SetTextColor(1, 1, 1, 0.35)
        GameTooltip:Hide()
    end)

    return cardA
end

local function BuildMyCrewCard(parent, yOffset, myCrewEntries)
    if #myCrewEntries == 0 then
        myCrewCard = nil
        return nil
    end

    local height = MYCREW_HEADER_H + (#myCrewEntries * CREW_ROW_H)
    myCrewCard = CreateCard(parent, yOffset, height)

    local myCrewTitle = myCrewCard:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
    myCrewTitle:SetPoint("TOPLEFT", myCrewCard, "TOPLEFT", 12, -10)
    myCrewTitle:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    myCrewTitle:SetText("My Crew")

    local myCrewHelper = myCrewCard:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    myCrewHelper:SetPoint("TOPRIGHT", myCrewCard, "TOPRIGHT", -12, -10)
    myCrewHelper:SetTextColor(1, 1, 1, 0.35)
    myCrewHelper:SetText("Managed in Discord /crew")

    wipe(myCrewRows)
    for i = 1, math.min(#myCrewEntries, 3) do
        local update = BuildMyCrewRow(myCrewCard, myCrewEntries[i], -(MYCREW_HEADER_H + (i - 1) * CREW_ROW_H))
        tinsert(myCrewRows, update)
    end

    return myCrewCard
end

local function BuildCardB(parent, yOffset, height)
    cardB = CreateCard(parent, yOffset, height)
    local keysLabel = cardB:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
    keysLabel:SetPoint("TOPLEFT", cardB, "TOPLEFT", 12, -10)
    keysLabel:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    keysLabel:SetText("Current SEAMEN Keys")

    emptyText = cardB:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    emptyText:SetPoint("TOPLEFT", keysLabel, "BOTTOMLEFT", 4, -10)
    emptyText:SetTextColor(1, 1, 1, 0.45)
    emptyText:SetText("No fresh keys right now")

    -- Only shown alongside emptyText in the 0-key state, so the compact
    -- card reads as an intentional empty state rather than missing content.
    emptyHelperText = cardB:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    emptyHelperText:SetPoint("TOPLEFT", emptyText, "BOTTOMLEFT", 0, -6)
    emptyHelperText:SetTextColor(1, 1, 1, 0.3)
    emptyHelperText:SetText("Keys appear here when SEAMEN addon users broadcast them.")

    keysScroll = CreateFrame("ScrollFrame", "TOASTDashboardKeysSF", cardB)
    keysScroll:SetPoint("TOPLEFT", cardB, "TOPLEFT", 8, -34)
    keysScroll:SetPoint("BOTTOMRIGHT", cardB, "BOTTOMRIGHT", -28, 8)

    keysContent = CreateFrame("Frame", nil, keysScroll)
    keysContent:SetSize(1, 1)
    keysScroll:SetScrollChild(keysContent)

    TOAST.CreateCustomScrollbar(keysScroll, keysContent)

    return cardB
end

local function BuildWeeklyRow(parent, xOffset, yOffset, width)
    local row = CreateFrame("Frame", nil, parent)
    row:SetSize(width, 20)
    row:SetPoint("TOPLEFT", parent, "TOPLEFT", xOffset, yOffset)

    row.rankFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    row.rankFS:SetPoint("LEFT", row, "LEFT", 0, 0)
    row.rankFS:SetWidth(8)
    row.rankFS:SetJustifyH("LEFT")

    row.prefixIcon = row:CreateTexture(nil, "OVERLAY")
    row.prefixIcon:SetSize(20, 20)
    row.prefixIcon:SetPoint("LEFT", row, "LEFT", 12, 0)

    row.prefixFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    row.prefixFS:SetPoint("LEFT", row.prefixIcon, "RIGHT", 0, 0)
    row.prefixFS:SetTextColor(0.8, 0.8, 0.8)
    row.prefixFS:SetWordWrap(false)

    row.valueFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    row.valueFS:SetPoint("RIGHT", row, "RIGHT", 0, 0)
    row.valueFS:SetWidth(54)
    row.valueFS:SetJustifyH("RIGHT")
    row.valueFS:SetWordWrap(false)

    row.nameFS = row:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    row.nameFS:SetPoint("LEFT", row.prefixFS, "RIGHT", 3, 0)
    row.nameFS:SetPoint("RIGHT", row.valueFS, "LEFT", -4, 0)
    row.nameFS:SetJustifyH("LEFT")
    row.nameFS:SetWordWrap(false)
    row.nameFS:SetTextColor(1, 1, 1, 0.9)

    row:Hide()
    return row
end

local function BuildCardC(parent, yOffset, height)
    cardC = CreateCard(parent, yOffset, height)

    wipe(activityTabs)
    local function CreateActivityTab(key, text, anchorTo)
        local tab = CreateFrame("Button", nil, cardC)
        tab:SetHeight(20)

        local label = tab:CreateFontString(nil, "OVERLAY", "TOASTFontNormal")
        label:SetPoint("LEFT", tab, "LEFT", 0, 0)
        label:SetText(text)
        label:SetWordWrap(false)
        tab:SetWidth(label:GetStringWidth())

        if anchorTo then
            tab:SetPoint("TOPLEFT", anchorTo, "TOPRIGHT", 14, 0)
        else
            tab:SetPoint("TOPLEFT", cardC, "TOPLEFT", PLAYER_PAD_X, -7)
        end

        local underline = tab:CreateTexture(nil, "OVERLAY")
        underline:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
        underline:SetWidth(label:GetStringWidth() + 8)
        underline:SetHeight(2)
        underline:SetPoint("BOTTOM", tab, "BOTTOM", 0, -3)

        activityTabs[key] = { button = tab, label = label, underline = underline }
        tab:SetScript("OnEnter", function()
            if activityActiveTab ~= key then
                label:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 0.75)
            end
            underline:Show()
        end)
        tab:SetScript("OnLeave", RefreshActivityTabs)
        tab:SetScript("OnClick", function()
            if activityActiveTab == key then return end
            activityActiveTab = key
            RefreshWeeklyActivity()
        end)
        return tab
    end

    local weekTab = CreateActivityTab("week", "This Week")
    local monthTab = CreateActivityTab("month", "30 Days", weekTab)
    CreateActivityTab("season", "This Season", monthTab)

    RefreshActivityTabs()

    weeklyPeriodFS = cardC:CreateFontString(nil, "OVERLAY", "TOASTFontHighlightSmall")
    weeklyPeriodFS:SetPoint("TOPRIGHT", cardC, "TOPRIGHT", -12, -11)
    weeklyPeriodFS:SetTextColor(1, 1, 1, 0.4)
    weeklyPeriodFS:SetWordWrap(false)

    weeklyMostHeaderFS = cardC:CreateFontString(nil, "OVERLAY", "TOASTFontNormalSmall")
    weeklyMostHeaderFS:SetPoint("TOPLEFT", cardC, "TOPLEFT", PLAYER_PAD_X, -34)
    weeklyMostHeaderFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 0.78)
    weeklyMostHeaderFS:SetText("MOST ACTIVE")

    weeklyClimbersHeaderFS = cardC:CreateFontString(nil, "OVERLAY", "TOASTFontNormalSmall")
    weeklyClimbersHeaderFS:SetPoint("TOPLEFT", cardC, "TOPLEFT", PROGRESS_X, -34)
    weeklyClimbersHeaderFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3], 0.78)
    weeklyClimbersHeaderFS:SetText("TOP CLIMBERS")

    local columnWidth = PROGRESS_DIVIDER_X - PLAYER_PAD_X - 10
    for i = 1, 3 do
        local rowY = -51 - ((i - 1) * 24)
        tinsert(weeklyMostRows, BuildWeeklyRow(cardC, PLAYER_PAD_X, rowY, columnWidth))
        tinsert(weeklyClimbersRows, BuildWeeklyRow(cardC, PROGRESS_X, rowY, columnWidth))
    end

    weeklyMostEmptyFS = cardC:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    weeklyMostEmptyFS:SetPoint("TOPLEFT", cardC, "TOPLEFT", PLAYER_PAD_X, -54)
    weeklyMostEmptyFS:SetWidth(columnWidth)
    weeklyMostEmptyFS:SetJustifyH("LEFT")
    weeklyMostEmptyFS:SetTextColor(1, 1, 1, 0.38)
    weeklyMostEmptyFS:Hide()

    weeklyClimbersEmptyFS = cardC:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    weeklyClimbersEmptyFS:SetPoint("TOPLEFT", cardC, "TOPLEFT", PROGRESS_X, -54)
    weeklyClimbersEmptyFS:SetWidth(columnWidth)
    weeklyClimbersEmptyFS:SetJustifyH("LEFT")
    weeklyClimbersEmptyFS:SetTextColor(1, 1, 1, 0.38)
    weeklyClimbersEmptyFS:Hide()

    weeklyFallbackFS = cardC:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    weeklyFallbackFS:SetPoint("TOPLEFT", cardC, "TOPLEFT", PLAYER_PAD_X, -48)
    weeklyFallbackFS:SetTextColor(1, 1, 1, 0.4)
    weeklyFallbackFS:Hide()

    return cardC
end

local function BuildFooter(parent, yOffset)
    local footer = CreateFrame("Frame", nil, parent)
    footer:SetSize(W - PAD * 2, FOOTER_HEIGHT)
    footer:SetPoint("BOTTOM", parent, "BOTTOM", 0, yOffset)

    local function CreatePulseHoverZone(fs, tooltipText)
        local btn = CreateFrame("Button", nil, footer)
        btn:SetPoint("LEFT", fs, "LEFT", 0, 0)
        btn:SetHeight(20)
        btn:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_TOP")
            GameTooltip:AddLine(tooltipText, 1, 1, 1, true)
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
        return btn
    end

    pulseOnlineFS = footer:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    pulseOnlineFS:SetPoint("LEFT", footer, "LEFT", 4, 0)
    pulseOnlineFS:SetTextColor(TEAL[1], TEAL[2], TEAL[3])

    local pulseBullet1 = footer:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    pulseBullet1:SetPoint("LEFT", pulseOnlineFS, "RIGHT", 6, 0)
    pulseBullet1:SetTextColor(0.4, 0.4, 0.4)
    pulseBullet1:SetText("\226\128\162")

    pulseKeysFS = footer:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    pulseKeysFS:SetPoint("LEFT", pulseBullet1, "RIGHT", 6, 0)
    pulseKeysFS:SetTextColor(TEAL[1], TEAL[2], TEAL[3])

    local pulseBullet2 = footer:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    pulseBullet2:SetPoint("LEFT", pulseKeysFS, "RIGHT", 6, 0)
    pulseBullet2:SetTextColor(0.4, 0.4, 0.4)
    pulseBullet2:SetText("\226\128\162")

    -- Static count, not a live status value like online/keys - muted
    -- neutral instead of teal so teal stays reserved for actual live status.
    pulseMembersFS = footer:CreateFontString(nil, "OVERLAY", "TOASTFontHighlight")
    pulseMembersFS:SetPoint("LEFT", pulseBullet2, "RIGHT", 6, 0)
    pulseMembersFS:SetTextColor(1, 1, 1, 0.45)

    pulseOnlineBtn = CreatePulseHoverZone(pulseOnlineFS, "Players seen recently through TOAST")
    pulseKeysBtn = CreatePulseHoverZone(pulseKeysFS, "Keys stored in your local TOAST cache")
    pulseMembersBtn = CreatePulseHoverZone(pulseMembersFS, "Players on the SEAMEN leaderboard")

    local function CreateFooterLinkButton(texturePath, inviteLink, tooltipTitle, iconSize, copyHint)
        local btn = CreateFrame("Button", nil, footer)
        btn:SetSize(20, 20)

        local icon = btn:CreateTexture(nil, "OVERLAY")
        if iconSize then
            icon:SetSize(iconSize, iconSize)
            icon:SetPoint("CENTER")
        else
            icon:SetAllPoints()
        end
        icon:SetTexture(texturePath)
        icon:SetAlpha(0.8)

        btn:SetScript("OnEnter", function(self)
            icon:SetAlpha(1)
            GameTooltip:SetOwner(self, "ANCHOR_TOP")
            GameTooltip:AddLine(tooltipTitle, GOLD[1], GOLD[2], GOLD[3])
            GameTooltip:AddLine(copyHint or "Click to copy invite link", 1, 1, 1)
            GameTooltip:Show()
        end)
        btn:SetScript("OnLeave", function()
            icon:SetAlpha(0.8)
            GameTooltip:Hide()
        end)
        btn:SetScript("OnClick", function()
            GameTooltip:Hide()
            TOAST.ShowCopyPopup(inviteLink)
        end)
        return btn
    end

    local discordBtn = CreateFooterLinkButton(
        "Interface\\AddOns\\TOAST\\media\\discord\\discord.png",
        DISCORD_INVITE,
        "TOAST Discord")
    discordBtn:SetPoint("RIGHT", footer, "RIGHT", -4, 0)

    local communityBtn = CreateFooterLinkButton(
        "Interface\\AddOns\\TOAST\\media\\buttons\\community.png",
        COMMUNITY_INVITE,
        "TIDE and Crew Community",
        14)
    communityBtn:SetPoint("RIGHT", discordBtn, "LEFT", -6, 0)

    -- forge.png is a native 24x24 asset. At the standard 20px footer button
    -- size it matches the visible Discord icon, while Community remains 14px
    -- because that source icon contains less built-in padding.
    local curseForgeBtn = CreateFooterLinkButton(
        "Interface\\AddOns\\TOAST\\media\\buttons\\forge.png",
        CURSEFORGE_URL,
        "TOAST on CurseForge",
        nil,
        "Click to copy CurseForge link")
    curseForgeBtn:SetPoint("RIGHT", communityBtn, "LEFT", -6, 0)
end

local function BuildDashboard()
    local myCrewEntries = {}
    if TOAST_DB.me and TOAST_DB.me.discord_id then
        for _, m in ipairs(TOAST.members) do
            if m.discord_id == TOAST_DB.me.discord_id then
                tinsert(myCrewEntries, m)
            end
        end
    end
    local hasMyCrew = #myCrewEntries > 0
    local myCrewHeight = hasMyCrew and (MYCREW_HEADER_H + (#myCrewEntries * CREW_ROW_H)) or 0
    local myCrewDelta = hasMyCrew and (myCrewHeight + CARD_GAP) or 0

    dash = CreateFrame("Frame", "TOASTDashboard", UIParent, "BackdropTemplate")
    Mixin(dash, BackdropTemplateMixin)
    dash:SetSize(W, H + myCrewDelta)
    TOAST.RestoreWindowPosition(dash, "dashboard")
    dash:SetMovable(true)
    dash:SetFrameStrata("DIALOG")
    dash:Hide()
    tinsert(UISpecialFrames, "TOASTDashboard")

    local bg = dash:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(0.06, 0.08, 0.10, 1.0)

    AddBorder(dash, BORDER)

    -- ── Header banner ──────────────────────────────────────────────
    -- Same structure as the leaderboard header: banner/logo/title live on a
    -- dedicated child frame, which is also the draggable region (not the
    -- whole window), instead of drawing everything directly on dash.
    local header = CreateFrame("Frame", nil, dash)
    header:SetSize(W, DASHBOARD_HEADER_HEIGHT)
    header:SetPoint("TOPLEFT", dash, "TOPLEFT", 0, 0)
    header:EnableMouse(true)
    header:RegisterForDrag("LeftButton")
    header:SetScript("OnDragStart", function() dash:StartMoving() end)
    header:SetScript("OnDragStop",  function()
        dash:StopMovingOrSizing()
        TOAST.SaveWindowPosition(dash, "dashboard")
    end)

    -- Gold top accent, on its own frame leveled above the header so the
    -- banner texture (a header child frame, which outranks dash's own
    -- layers) can't draw over it and muddy the gold — same fix as leaderboard.
    local topAccentFrame = CreateFrame("Frame", nil, dash)
    topAccentFrame:SetAllPoints(dash)
    topAccentFrame:SetFrameLevel(header:GetFrameLevel() + 1)

    local accent = topAccentFrame:CreateTexture(nil, "ARTWORK")
    accent:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 1)
    accent:SetPoint("TOPLEFT", dash, "TOPLEFT", 0, 0)
    accent:SetPoint("TOPRIGHT", dash, "TOPRIGHT", 0, 0)
    accent:SetHeight(2)  -- == leaderboard's topAccent

    -- Stormy harbor banner, dark/empty on the left (behind the title),
    -- harbor detail fades in on the right — same asset design language as
    -- the leaderboard's tentacle banner. Kept as its own file rather than
    -- reusing tentakel.png: the dashboard is 480px wide vs the leaderboard's
    -- 745px, so stretching the wider composition here would distort it.
    local headerBanner = header:CreateTexture(nil, "BACKGROUND")
    headerBanner:SetTexCoord(0, 1, 0, 1)
    headerBanner:SetAllPoints(header)
    headerBanner:SetVertexColor(1, 1, 1, 0.48)

    -- Setting the texture at creation time can silently no-op on first
    -- login (frame not yet shown when the lazy-load PNG/TGA resolves).
    -- Re-applying on OnShow guarantees it's set once the frame is actually visible.
    dash:HookScript("OnShow", function()
        headerBanner:SetTexture("Interface\\AddOns\\TOAST\\media\\banners\\tentakel_2.png")
    end)

    TOAST.CreateCloseButton(dash, function() dash:Hide() end)

    -- Same spot as the leaderboard page's "DASHBOARD" button (top-right of
    -- the header, same offset/frame-level fix), so cross-navigation between
    -- the two windows is symmetric instead of this one living inside a card.
    local lbBtn = CreateActionButton(dash, "LEADERBOARD", "gold", 90, 22)  -- == leaderboard's DASHBOARD button width
    lbBtn:SetPoint("TOPRIGHT", dash, "TOPRIGHT", -36, -10)
    lbBtn:SetFrameLevel(dash:GetFrameLevel() + 5)
    -- Right-click opens the leaderboard alongside the dashboard instead of
    -- switching to it, so both windows can stay open side by side.
    lbBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    lbBtn:SetScript("OnClick", function(self, button)
        if button == "RightButton" then
            TOAST.ShowLeaderboard()
        else
            dash:Hide()
            TOAST.commands["lb"]()
        end
    end)

    -- Slightly more solid background so the banner behind it reads less
    -- busy (overrides just this button's own bg alpha, not the shared style).
    local lbBtnBg = select(1, lbBtn:GetRegions())
    if lbBtnBg then
        lbBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.30)
        lbBtn:SetScript("OnEnter", function() lbBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.42) end)
        lbBtn:SetScript("OnLeave", function() lbBtnBg:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.30) end)
    end

    local logo = header:CreateTexture(nil, "OVERLAY")
    logo:SetTexture("Interface\\AddOns\\TOAST\\media\\discord\\toast_128.tga")
    logo:SetSize(64, 64)
    logo:SetPoint("TOPLEFT", header, "TOPLEFT", 12, -8)

    local titleFS = header:CreateFontString(nil, "OVERLAY", "TOASTFontNormalHuge")
    titleFS:SetPoint("TOPLEFT", logo, "TOPRIGHT", 10, -8)
    titleFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    titleFS:SetText("TOAST DASHBOARD")

    local versionFS = header:CreateFontString(nil, "OVERLAY", "TOASTFontNormalSmall")
    versionFS:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -4)
    versionFS:SetJustifyH("LEFT")
    versionFS:SetTextColor(1, 1, 1, 0.45)
    versionFS:SetText("Totally Overengineered Aquatic Support Tech\nv" .. TOAST.VERSION)

    -- CARD A: PLAYER CARD
    -- skipAccent=false: restores the same subtle gold top accent every other
    -- card has, now that the title row gives Card A its own section header
    -- to underline instead of just relying on the divider above it.
    BuildCardA(dash, CARD_A_TOP, CARD_A_HEIGHT, false)

    -- Gold divider + anchor medallion between the header banner and Card A,
    -- aligned to card width (not the outer frame) and restrained in size
    -- since the dashboard is card-based rather than a full-width table like
    -- the leaderboard. CARD_A_TOP already clears the medallion fully, so
    -- aboveFrame is just a defensive guard against future size changes.
    TOAST.CreateAnchorDivider(dash, PAD, W - PAD, HEADER_DIVIDER_Y, {
        medallionSize = HEADER_DIVIDER_MEDALLION,
        gap = HEADER_DIVIDER_GAP,
        lineAlpha = 0.85,  -- dashboard-only softening; leaderboard keeps its default 1.0
        aboveFrame = cardA,
    })

    -- CARD MYCREW: MY CREW (only if the player is discord-linked)
    local belowCardA = CARD_A_TOP - CARD_A_HEIGHT - CARD_GAP
    BuildMyCrewCard(dash, belowCardA, myCrewEntries)

    -- CARD B: CURRENT SEAMEN KEYS. Built at the full-size placeholder;
    -- ReflowCardB() (called from the Refresh() right after BuildDashboard())
    -- resizes it to the actual tier and repositions Card C before the frame
    -- is ever shown.
    BuildCardB(dash, belowCardA - myCrewDelta, CARD_B_HEIGHT_FULL)

    -- CARD C: bot-generated weekly / 30-day / season activity and RIO recap.
    BuildCardC(dash, belowCardA - CARD_B_HEIGHT_FULL - CARD_GAP - myCrewDelta, CARD_C_HEIGHT)

    -- FOOTER: Community Pulse
    BuildFooter(dash, PAD)
end

-- Unconditional show (not a toggle) so the LEADERBOARD button's right-click
-- can open this window without caring whether it's already open.
local function ShowDashboard()
    if TOAST.CanOpenMajorWindow and not TOAST.CanOpenMajorWindow() then return end
    if not dash then BuildDashboard() end
    Refresh()
    dash:Show()
end
TOAST.ShowDashboard = ShowDashboard

TOAST.commands["dashboard"] = function()
    if not dash then BuildDashboard() end
    if dash:IsShown() then
        dash:Hide()
    else
        ShowDashboard()
    end
end
