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 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 = 120

-- 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 = 114  -- was 100: a bit more breathing room around rank/next/behind rows
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 chip rows, so they read as one aligned block
-- instead of drifting right based on however wide the row above happened
-- to be. CARD_A_HEIGHT (120) leaves ~18px of breathing room below the chip
-- row once the title row and its gap are accounted for.
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 = 10
local PLAYER_ROW_GAP = 8
local PLAYER_CHIP_GAP = 10

-- 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
local diagBtn, diagBtnFS
local chipAnchor
local chipBadges = {}
local keysScroll, keysContent, emptyText, emptyHelperText
local keyRows = {}
local rankFS
local nextLabelFS, nextPrefixIcon, nextPrefixLabelFS, nextNameFS, nextDeltaFS, nextFallbackFS
local behindLabelFS, behindPrefixIcon, behindPrefixLabelFS, behindNameFS, behindDeltaFS
local pulseOnlineFS, pulseKeysFS, pulseMembersFS
local pulseOnlineBtn, pulseKeysBtn, pulseMembersBtn
local discordPopover
local myCrewCard
local myCrewRows = {}
local clickCatcher
local copyPopup, copyEditBox
local crewPopup, crewHeaderIcon, crewHeaderLabel, crewOnlineDot, crewNameFS, crewRankFS
local crewPopupRows = {}

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

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 totalHeight = (-CARD_A_TOP) + CARD_A_HEIGHT + CARD_GAP + cardBHeight + CARD_GAP + CARD_C_HEIGHT
        + FOOTER_GAP + FOOTER_HEIGHT + PAD + myCrewDelta
    dash:SetHeight(totalHeight)
end

local function CreateBadge(parent, text, isPositive, isAccent)
    local badge = CreateFrame("Frame", nil, parent)
    badge:SetHeight(15)

    -- Neutral subtle background for both states — badges are status labels,
    -- not primary actions, so no green/red tint.
    local bg = badge:CreateTexture(nil, "BACKGROUND")
    bg:SetAllPoints()
    bg:SetColorTexture(1, 1, 1, 0.035)

    -- Very thin muted border; gold accent for the one highlighted badge (ranking)
    local border = badge:CreateTexture(nil, "BORDER")
    border:SetAllPoints()
    if isAccent then
        border:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.35)
    else
        border:SetColorTexture(1, 1, 1, 0.06)
    end

    local label = badge:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    label:SetPoint("CENTER")
    label:SetText(text)
    if isAccent then
        label:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    elseif isPositive then
        label:SetTextColor(1, 1, 1, 0.75)
    else
        label:SetTextColor(1, 1, 1, 0.35)
    end

    -- Auto-width
    badge:SetWidth(label:GetStringWidth() + 14)

    return badge
end

local function ClearBadgeSet(set)
    for _, b in ipairs(set) do
        b:Hide()
        b:SetParent(nil)
    end
    wipe(set)
end

-- Anchors the row directly off anchorTo's bottom-left corner — callers that
-- need breathing room above the row (e.g. Card A's chipAnchor) bake that gap
-- into anchorTo's own position instead of this helper guessing at one.
local function BuildBadgeRow(parent, anchorTo, defs)
    local row = {}
    local prev
    for _, def in ipairs(defs) do
        local badge = CreateBadge(parent, def[1], def[2], def[3])
        if prev then
            badge:SetPoint("LEFT", prev, "RIGHT", 6, 0)
        else
            badge:SetPoint("TOPLEFT", anchorTo, "BOTTOMLEFT", 0, 0)
        end
        tinsert(row, badge)
        prev = badge
    end
    return row
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 GetMemberByName(name)
    for _, m in ipairs(TOAST.members) do
        if m.name == name then return m end
    end
    return nil
end

local function CountCrewByDiscordId(discord_id)
    local count = 0
    for _, m in ipairs(TOAST.members) do
        if m.discord_id == discord_id then count = count + 1 end
    end
    return count
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

-- ── Copy Name popup (TOAST.ShowCopyPopup) ──────────────────────────
local function EnsureCopyPopup()
    if copyPopup then return end
    copyPopup = CreateFrame("Frame", "TOASTCopyPopup", UIParent, "BackdropTemplate")
    Mixin(copyPopup, BackdropTemplateMixin)
    copyPopup:SetSize(220, 46)
    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", "GameFontHighlight")
    hint:SetPoint("TOP", copyPopup, "TOP", 0, -8)
    hint:SetTextColor(1, 1, 1, 0.45)
    hint:SetText("Ctrl+C to copy")

    copyEditBox = CreateFrame("EditBox", nil, copyPopup, "InputBoxTemplate")
    copyEditBox:SetSize(196, 22)
    copyEditBox:SetPoint("BOTTOM", copyPopup, "BOTTOM", 0, 8)

    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", "GameFontHighlight")
    crewHeaderLabel:SetPoint("LEFT", crewHeaderIcon, "RIGHT", 0, 0)
    crewHeaderLabel:SetTextColor(0.8, 0.8, 0.8)

    crewNameFS = crewPopup:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    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", "GameFontHighlight")
    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 = entry.discord_id
        and TOAST._onlineCharByDiscordId
        and TOAST._onlineCharByDiscordId[entry.discord_id] == entry.name

    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", "GameFontHighlight")
    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", "GameFontHighlight")
    keyFS:SetPoint("RIGHT", row, "RIGHT", -12, 0)
    keyFS:SetWidth(70)
    keyFS:SetJustifyH("RIGHT")
    keyFS:SetTextColor(1, 1, 1, 1)
    local keyData = TOAST.keys and TOAST.keys[entry.name]
    keyFS:SetText(keyData and ("+" .. keyData.level .. " " .. keyData.short) or "\226\128\148")

    local resiFS = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    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", "GameFontHighlight")
    rioFS:SetPoint("RIGHT", resiFS, "LEFT", -8, 0)
    rioFS:SetWidth(70)
    rioFS:SetJustifyH("RIGHT")
    rioFS:SetText(string.format("|cff999999RIO |r|cffffffff%s|r",
        entry.rio and tostring(entry.rio) 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 isOnline = TOAST._onlineCharByDiscordId and TOAST._onlineCharByDiscordId[discord_id]
    if isOnline then
        crewOnlineDot:SetColorTexture(TEAL[1], TEAL[2], TEAL[3], 1)
    else
        crewOnlineDot:SetColorTexture(0.5, 0.5, 0.5, 0.6)
    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()
    local me = TOAST_DB.me

    ahoyFS:SetText("Ahoy!")

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

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

        ClearBadgeSet(chipBadges)
        chipBadges = BuildBadgeRow(cardA, chipAnchor, {
            { "Unranked", true, true },
            { "RIO \226\128\148", true },
            { "Resi \226\128\148", true },
            { "No key", true },
        })
        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)

    -- Character-Realm met class color + role icoon
    charIcon:Show()
    charIcon:SetTexture(ROLE_TEX[me.role] or "")
    local token = CLASS_TOKEN[me.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), me.name, me.realm))
    else
        charLine:SetText(me.name .. "|cffCCCCCC-" .. me.realm .. "|r")
    end

    -- Chip row: rank / RIO / resi / key at a glance.
    -- me.rank/rio/resi are only present when recognized via TOAST.leaderboard;
    -- members-only matches (alts not on the leaderboard) lack these fields.
    local keyData = TOAST.keys and TOAST.keys[me.name]
    local keyStr = keyData and ("+" .. keyData.level .. " " .. keyData.short) or "No key"
    local rankStr = me.rank and string.format("#%d of %d", me.rank, #TOAST.leaderboard) or "Unranked"
    local rioStr  = me.rio  and ("RIO " .. me.rio)   or "RIO \226\128\148"
    local resiStr = me.resi and ("Resi " .. me.resi) or "Resi \226\128\148"

    ClearBadgeSet(chipBadges)
    chipBadges = BuildBadgeRow(cardA, chipAnchor, {
        { rankStr, true, true },
        { rioStr, true },
        { resiStr, true },
        { keyStr, true },
    })

    -- Diagnostics, computed here but only rendered in the "?" tooltip.
    diagRecognized = TOAST.me ~= nil
    diagCommunity = TOAST.inCommunity and true or false
    diagLeaderboard = me.rank ~= nil
    diagRoster = TOAST_DB.me ~= nil
    diagCurrentKey = keyStr

    local myKeyData = TOAST.keys and TOAST.keys[UnitName("player")]
    diagKeySharing = myKeyData ~= nil
    if myKeyData and myKeyData.lastSeen then
        local ageMin = math.floor((GetServerTime() - myKeyData.lastSeen) / 60)
        diagLastBroadcast = ageMin < 1 and "now" or (ageMin .. "m ago")
    else
        diagLastBroadcast = "\226\128\148"
    end
end

local function RefreshKeysList()
    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.keys and TOAST.keys[entry.name]
        if data then
            local recentlySeen = data.lastSeen and (now - data.lastSeen) < FRESH_THRESHOLD
            local onlineNow = TOAST._onlineMembers and TOAST._onlineMembers[entry.name]
            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
        for _, m in ipairs(TOAST.members) do
            if m.name == entry.name then
                if m.discord_id then discordIds[m.discord_id] = true end
                break
            end
        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.keys and TOAST.keys[m.name]
            if data then
                local recentlySeen = data.lastSeen and (now - data.lastSeen) < FRESH_THRESHOLD
                local onlineNow = TOAST._onlineMembers and TOAST._onlineMembers[m.name]
                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 memberEntry = GetMemberByName(item.entry.name)
        local discordId = memberEntry and memberEntry.discord_id
        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._onlineMembers and TOAST._onlineMembers[a.entry.name] or false
        local bOnline = TOAST._onlineMembers and TOAST._onlineMembers[b.entry.name] 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", "GameFontHighlight")
            labelFS:SetPoint("LEFT", prefixIconTex, "RIGHT", 0, 0)
            labelFS:SetTextColor(0.8, 0.8, 0.8)

            local nameFS = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            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", "GameFontHighlight")
            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._onlineMembers and TOAST._onlineMembers[item.entry.name]
            local lastSeenFS = row:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
            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

            row:SetScript("OnMouseUp", function(self, button)
                if button ~= "RightButton" then return end
                local target = item.entry.name .. "-" .. item.entry.realm:gsub(" ", "")
                local memberEntry = GetMemberByName(item.entry.name)
                MenuUtil.CreateContextMenu(self, function(ownerRegion, rootDescription)
                    if memberEntry and memberEntry.discord_id and CountCrewByDiscordId(memberEntry.discord_id) > 1 then
                        rootDescription:CreateButton("View Crew", function()
                            TOAST.ShowCrewPopup(memberEntry.discord_id)
                        end)
                    end
                    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)
                end)
            end)

            tinsert(keyRows, row)
        end
    end
    keysContent:SetHeight(math.max(#fresh * ROW_H, 1))
    ReflowCardB(KeysCardHeight(#fresh))
end

local function BuildMyCrewRow(parent, entry, y)
    local isCurrent = entry.name == UnitName("player")
    local isOnlineElsewhere = not isCurrent
        and entry.discord_id
        and TOAST._onlineCharByDiscordId
        and TOAST._onlineCharByDiscordId[entry.discord_id] == entry.name

    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", "GameFontHighlight")
    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", "GameFontHighlight")
    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", "GameFontHighlight")
    resiFS:SetPoint("RIGHT", keyFS, "LEFT", -8, 0)
    resiFS:SetWidth(50)
    resiFS:SetJustifyH("RIGHT")

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

    local function UpdateContent()
        rioFS:SetText(string.format("|cff999999RIO |r|cffffffff%s|r",
            entry.rio and tostring(entry.rio) 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.keys and TOAST.keys[entry.name]
        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 = entry.name .. "-" .. entry.realm:gsub(" ", "")
        MenuUtil.CreateContextMenu(self, function(ownerRegion, rootDescription)
            rootDescription:CreateButton("Copy Name", function()
                TOAST.ShowCopyPopup(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

local function RefreshLeaderboardPreview()
    local me = TOAST_DB.me
    if not me or not me.rank or #TOAST.leaderboard == 0 then
        rankFS:SetTextColor(1, 1, 1, 0.5)
        rankFS:SetText("Leaderboard data unavailable")
        nextLabelFS:Hide(); nextPrefixIcon:Hide(); nextPrefixLabelFS:Hide()
        nextNameFS:Hide(); nextDeltaFS:Hide(); nextFallbackFS:Hide()
        behindLabelFS:Hide(); behindPrefixIcon:Hide(); behindPrefixLabelFS:Hide()
        behindNameFS:Hide(); behindDeltaFS:Hide()
        return
    end

    rankFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    rankFS:SetText(string.format("#%d of %d", me.rank, #TOAST.leaderboard))

    local above = me.rank > 1 and TOAST.leaderboard[me.rank - 1] or nil
    local below = TOAST.leaderboard[me.rank + 1]

    if above then
        nextFallbackFS:Hide()
        nextLabelFS:Show(); nextNameFS:Show(); nextDeltaFS:Show()
        nextLabelFS:SetText("Next")
        SetProgressPrefix(nextPrefixIcon, nextPrefixLabelFS, above.discord_nickname)
        nextNameFS:SetText(above.name)
        local diff = me.rio - above.rio
        nextDeltaFS:SetText(FormatDelta(diff))
        SetDeltaColor(nextDeltaFS, diff)
    else
        nextLabelFS:Hide(); nextPrefixIcon:Hide(); nextPrefixLabelFS:Hide()
        nextNameFS:Hide(); nextDeltaFS:Hide()
        nextFallbackFS:SetText("Top of the crew")
        nextFallbackFS:Show()
    end

    if below then
        behindLabelFS:Show(); behindNameFS:Show(); behindDeltaFS:Show()
        behindLabelFS:SetText("Behind")
        SetProgressPrefix(behindPrefixIcon, behindPrefixLabelFS, below.discord_nickname)
        behindNameFS:SetText(below.name)
        local diff = me.rio - below.rio
        behindDeltaFS:SetText(FormatDelta(diff))
        SetDeltaColor(behindDeltaFS, diff)
    else
        behindLabelFS:Hide(); behindPrefixIcon:Hide(); behindPrefixLabelFS:Hide()
        behindNameFS:Hide(); behindDeltaFS:Hide()
    end
end

local function RefreshFooter()
    local now = GetServerTime()
    local online, known = 0, 0
    for _, data in pairs(TOAST.keys or {}) do
        known = known + 1
        if data.lastSeen and (now - data.lastSeen) < FRESH_THRESHOLD then
            online = online + 1
        end
    end

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

    pulseKeysFS:SetText(known .. " keys spotted")
    pulseKeysBtn:SetWidth(pulseKeysFS:GetStringWidth())

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

local function Refresh()
    if TOAST.RefreshOnlineStatus then TOAST.RefreshOnlineStatus() end
    RefreshStatus()
    RefreshMyCrew()
    RefreshKeysList()
    RefreshLeaderboardPreview()
    RefreshFooter()
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 BuildCardA(parent, yOffset, height, skipAccent)
    cardA = CreateCard(parent, yOffset, height, skipAccent)

    -- Title: styled like every other card's section title (gold
    -- GameFontNormal), 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", "GameFontNormal")
    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", "GameFontHighlight")
    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", "GameFontHighlight")
    prefixLabel:SetPoint("LEFT", prefixIcon, "RIGHT", 0, 0)
    prefixLabel:SetTextColor(0.8, 0.8, 0.8)

    discordNameFS = cardA:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    discordNameFS:SetPoint("LEFT", prefixLabel, "RIGHT", 4, 0)

    -- 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", "GameFontHighlight")
    charLine:SetPoint("LEFT", charIcon, "RIGHT", 4, 0)

    -- Row 3: at-a-glance chips (rank / RIO / 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)

    -- Diagnostics affordance: a small, subtle "?" right-aligned on the title
    -- row — muted grey by default, TOAST gold on hover — so it reads as a
    -- help affordance, not a primary action competing with the title.
    diagBtn = CreateFrame("Button", nil, cardA)
    diagBtn:SetSize(16, 16)
    diagBtn:SetPoint("TOPRIGHT", cardA, "TOPRIGHT", -10, PLAYER_TOP_Y)

    diagBtnFS = diagBtn:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    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", "GameFontNormal")
    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", "GameFontHighlight")
    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", "GameFontNormal")
    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", "GameFontHighlight")
    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", "GameFontHighlight")
    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 BuildCardC(parent, yOffset, height)
    cardC = CreateCard(parent, yOffset, height)

    local progressTitle = cardC:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    progressTitle:SetPoint("TOPLEFT", cardC, "TOPLEFT", 12, -10)
    progressTitle:SetTextColor(GOLD[1], GOLD[2], GOLD[3])
    progressTitle:SetText("Your Progress")

    -- Trends: opens a rating-over-time chart in a future version. Top-right,
    -- aligned with the title row like Card A's "?" affordance.
    local trendsBtn = CreateFrame("Button", nil, cardC)
    trendsBtn:SetSize(24, 24)
    trendsBtn:SetPoint("TOPRIGHT", cardC, "TOPRIGHT", -10, -8)

    local trendsBtnBg = trendsBtn:CreateTexture(nil, "BACKGROUND")
    trendsBtnBg:SetAllPoints()
    trendsBtnBg:SetColorTexture(0.06, 0.08, 0.10, 0.95)

    local trendsTop, trendsBottom, trendsLeft, trendsRight = AddBorder(trendsBtn, BORDER)

    local trendsIcon = trendsBtn:CreateTexture(nil, "ARTWORK")
    trendsIcon:SetSize(18, 18)
    trendsIcon:SetPoint("CENTER")
    trendsIcon:SetTexture("Interface\\AddOns\\TOAST\\media\\buttons\\line-chart.png")
    trendsIcon:SetAlpha(0.65)

    trendsBtn:SetScript("OnEnter", function(self)
        trendsTop:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.8)
        trendsBottom:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.8)
        trendsLeft:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.8)
        trendsRight:SetColorTexture(GOLD[1], GOLD[2], GOLD[3], 0.8)
        trendsBtnBg:SetColorTexture(0.10, 0.12, 0.14, 0.95)
        trendsIcon:SetAlpha(1.0)
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:AddLine("TOAST Trends", GOLD[1], GOLD[2], GOLD[3])
        GameTooltip:AddLine("View RIO score history and progress over time.", 1, 1, 1, true)
        GameTooltip:Show()
    end)
    trendsBtn:SetScript("OnLeave", function()
        trendsTop:SetColorTexture(BORDER[1], BORDER[2], BORDER[3], BORDER[4])
        trendsBottom:SetColorTexture(BORDER[1], BORDER[2], BORDER[3], BORDER[4])
        trendsLeft:SetColorTexture(BORDER[1], BORDER[2], BORDER[3], BORDER[4])
        trendsRight:SetColorTexture(BORDER[1], BORDER[2], BORDER[3], BORDER[4])
        trendsBtnBg:SetColorTexture(0.06, 0.08, 0.10, 0.95)
        trendsIcon:SetAlpha(0.65)
        GameTooltip:Hide()
    end)
    -- Right-click opens Trends alongside the dashboard instead of switching
    -- to it, same convention as the dashboard/leaderboard cross-nav buttons.
    trendsBtn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    trendsBtn:SetScript("OnClick", function(self, button)
        -- The button's own tooltip must never linger over the newly opened
        -- Trends window, so hide it immediately instead of waiting for OnLeave.
        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)

    rankFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge")
    rankFS:SetPoint("TOPLEFT", progressTitle, "BOTTOMLEFT", 0, -10)
    rankFS:SetTextColor(GOLD[1], GOLD[2], GOLD[3])

    -- Next row: label / player name / delta, fixed columns via cardC LEFT + x.
    nextLabelFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    nextLabelFS:SetPoint("TOPLEFT", rankFS, "BOTTOMLEFT", 0, -10)
    nextLabelFS:SetWidth(50)
    nextLabelFS:SetJustifyH("LEFT")
    nextLabelFS:SetTextColor(1, 1, 1, 0.5)

    nextPrefixIcon = cardC:CreateTexture(nil, "OVERLAY")
    nextPrefixIcon:SetSize(20, 20)
    nextPrefixIcon:SetPoint("TOP", nextLabelFS, "TOP", 0, 0)
    nextPrefixIcon:SetPoint("LEFT", cardC, "LEFT", 66, 0)

    nextPrefixLabelFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    nextPrefixLabelFS:SetPoint("TOP", nextLabelFS, "TOP", 0, 0)
    nextPrefixLabelFS:SetPoint("LEFT", nextPrefixIcon, "RIGHT", 2, 0)
    nextPrefixLabelFS:SetTextColor(0.8, 0.8, 0.8)

    nextNameFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    nextNameFS:SetPoint("TOP", nextLabelFS, "TOP", 0, 0)
    nextNameFS:SetPoint("LEFT", cardC, "LEFT", 118, 0)
    nextNameFS:SetWidth(90)
    nextNameFS:SetJustifyH("LEFT")
    nextNameFS:SetTextColor(1, 1, 1, 1)

    nextDeltaFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    nextDeltaFS:SetPoint("TOP", nextLabelFS, "TOP", 0, 0)
    nextDeltaFS:SetPoint("LEFT", cardC, "LEFT", 221, 0)
    nextDeltaFS:SetWidth(90)
    nextDeltaFS:SetJustifyH("LEFT")

    -- Fallback for "no player above" (rank #1) — replaces the whole row.
    nextFallbackFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    nextFallbackFS:SetPoint("TOP", nextLabelFS, "TOP", 0, 0)
    nextFallbackFS:SetPoint("LEFT", cardC, "LEFT", 12, 0)
    nextFallbackFS:SetTextColor(1, 1, 1, 0.5)
    nextFallbackFS:Hide()

    -- Behind row: same column layout, one row down.
    behindLabelFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    behindLabelFS:SetPoint("TOPLEFT", nextLabelFS, "BOTTOMLEFT", 0, -6)
    behindLabelFS:SetWidth(50)
    behindLabelFS:SetJustifyH("LEFT")
    behindLabelFS:SetTextColor(1, 1, 1, 0.5)

    behindPrefixIcon = cardC:CreateTexture(nil, "OVERLAY")
    behindPrefixIcon:SetSize(20, 20)
    behindPrefixIcon:SetPoint("TOP", behindLabelFS, "TOP", 0, 0)
    behindPrefixIcon:SetPoint("LEFT", cardC, "LEFT", 66, 0)

    behindPrefixLabelFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    behindPrefixLabelFS:SetPoint("TOP", behindLabelFS, "TOP", 0, 0)
    behindPrefixLabelFS:SetPoint("LEFT", behindPrefixIcon, "RIGHT", 2, 0)
    behindPrefixLabelFS:SetTextColor(0.8, 0.8, 0.8)

    behindNameFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    behindNameFS:SetPoint("TOP", behindLabelFS, "TOP", 0, 0)
    behindNameFS:SetPoint("LEFT", cardC, "LEFT", 118, 0)
    behindNameFS:SetWidth(90)
    behindNameFS:SetJustifyH("LEFT")
    behindNameFS:SetTextColor(1, 1, 1, 1)

    behindDeltaFS = cardC:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    behindDeltaFS:SetPoint("TOP", behindLabelFS, "TOP", 0, 0)
    behindDeltaFS:SetPoint("LEFT", cardC, "LEFT", 221, 0)
    behindDeltaFS:SetWidth(90)
    behindDeltaFS:SetJustifyH("LEFT")

    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", "GameFontHighlight")
    pulseOnlineFS:SetPoint("LEFT", footer, "LEFT", 4, 0)
    pulseOnlineFS:SetTextColor(TEAL[1], TEAL[2], TEAL[3])

    local pulseBullet1 = footer:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    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", "GameFontHighlight")
    pulseKeysFS:SetPoint("LEFT", pulseBullet1, "RIGHT", 6, 0)
    pulseKeysFS:SetTextColor(TEAL[1], TEAL[2], TEAL[3])

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

    pulseMembersFS = footer:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    pulseMembersFS:SetPoint("LEFT", pulseBullet2, "RIGHT", 6, 0)
    pulseMembersFS:SetTextColor(TEAL[1], TEAL[2], TEAL[3])

    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 discordBtn = CreateFrame("Button", nil, footer)
    discordBtn:SetSize(20, 20)
    discordBtn:SetPoint("RIGHT", footer, "RIGHT", -4, 0)

    local discordIcon = discordBtn:CreateTexture(nil, "OVERLAY")
    discordIcon:SetAllPoints()
    discordIcon:SetTexture("Interface\\AddOns\\TOAST\\media\\discord\\discord.png")
    discordIcon:SetAlpha(0.8)

    discordBtn:SetScript("OnEnter", function() discordIcon:SetAlpha(1) end)
    discordBtn:SetScript("OnLeave", function() discordIcon:SetAlpha(0.8) end)
    discordBtn:SetScript("OnClick", function()
        if discordPopover:IsShown() then
            discordPopover:Hide()
        else
            discordPopover:Show()
        end
    end)

    discordPopover = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    Mixin(discordPopover, BackdropTemplateMixin)
    discordPopover:SetSize(240, 40)
    discordPopover:SetPoint("BOTTOMRIGHT", discordBtn, "TOPRIGHT", 0, 6)
    discordPopover:SetFrameStrata("TOOLTIP")
    discordPopover:Hide()

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

    local discordEB = CreateFrame("EditBox", nil, discordPopover, "InputBoxTemplate")
    discordEB:SetSize(220, 22)
    discordEB:SetPoint("CENTER")
    LockEditBox(discordEB, DISCORD_INVITE)
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", "GameFontNormalHuge")
    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", "GameFontNormalSmall")
    versionFS:SetPoint("TOPLEFT", titleFS, "BOTTOMLEFT", 0, -4)
    versionFS:SetTextColor(1, 1, 1, 0.45)
    versionFS:SetText("v" .. 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: YOUR PROGRESS
    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 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
