-- ============================================================
-- Asylum Compagnon — UI.lua
-- Fenêtre principale : sidebar (Accueil / Optimisation / Inventaire),
-- listes des personnages synchronisés, code SimC, inventaire partagé.
-- ============================================================

local ADDON_NAME, NS = ...

local unpack = unpack or table.unpack

local WHITE = "Interface\\Buttons\\WHITE8x8"
local MASK  = "Interface\\CharacterFrame\\TempPortraitAlphaMask"
local FONT  = "Fonts\\FRIZQT__.TTF"
local MONO  = "Fonts\\ARIALN.TTF"
local GOLD_ICON  = "Interface\\MoneyFrame\\UI-GoldIcon"
local HOME_IMAGE = "Interface\\AddOns\\AsylumCompagnon\\media\\accueil.tga"

local RAIDBOTS_URL = "https://www.raidbots.com/simbot/topgear"
local GEARCOMPASS_URL = "https://gearcompass.turbopigeon.fr"

local WIN_W, WIN_H = 780, 520
local SIDEBAR_W    = 170
local TOPBAR_H     = 44

-- Palette (modifiable à volonté)
local COLORS = {
    bg     = { 0.049, 0.055, 0.071, 0.975 }, -- fond principal  ~#0d0e12
    panel  = { 0.082, 0.090, 0.114, 1.0   }, -- panneaux        ~#151721
    field  = { 0.062, 0.068, 0.088, 1.0   }, -- zones de saisie
    line   = { 1, 1, 1, 0.07 },              -- séparateurs / bordures
    accent = { 0.655, 0.545, 0.980, 1.0   }, -- violet          ~#a78bfa
    text   = { 0.930, 0.930, 0.950, 1.0   },
    muted  = { 0.550, 0.560, 0.620, 1.0   },
    green  = { 0.13, 0.77, 0.37, 1.0 },   -- Guilde
    gray   = { 0.42, 0.44, 0.50, 1.0 },
    pink   = { 0.96, 0.55, 0.78, 1.0 },   -- Rerolls        ~#f48ec7
}
-- Couleurs de catégorie, cohérentes dans TOUS les menus (Clés, Inventaire,
-- Optimisation) : Groupe = violet (accent), Guilde = vert, Rerolls = rose.

-- ---------- Helpers ----------
local function Solid(parent, layer, sublevel)
    local t = parent:CreateTexture(nil, layer or "BACKGROUND", nil, sublevel)
    t:SetTexture(WHITE)
    return t
end

local function Label(parent, size, color, font)
    local fs = parent:CreateFontString(nil, "OVERLAY")
    fs:SetFont(font or FONT, size, "")
    fs:SetTextColor(unpack(color or COLORS.text))
    fs:SetShadowColor(0, 0, 0, 0.7)
    fs:SetShadowOffset(0, -1)
    return fs
end

local function Border(frame, color, thickness)
    thickness = thickness or 1
    local r, g, b, a = unpack(color or COLORS.line)
    for _, side in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
        local t = Solid(frame, "OVERLAY", 7)
        t:SetVertexColor(r, g, b, a)
        if side == "TOP" then
            t:SetPoint("TOPLEFT"); t:SetPoint("TOPRIGHT"); t:SetHeight(thickness)
        elseif side == "BOTTOM" then
            t:SetPoint("BOTTOMLEFT"); t:SetPoint("BOTTOMRIGHT"); t:SetHeight(thickness)
        elseif side == "LEFT" then
            t:SetPoint("TOPLEFT"); t:SetPoint("BOTTOMLEFT"); t:SetWidth(thickness)
        else
            t:SetPoint("TOPRIGHT"); t:SetPoint("BOTTOMRIGHT"); t:SetWidth(thickness)
        end
    end
end

local function Circle(parent, size, layer, sublevel)
    local t = Solid(parent, layer or "ARTWORK", sublevel)
    t:SetSize(size, size)
    local m = parent:CreateMaskTexture()
    m:SetTexture(MASK)
    m:SetAllPoints(t)
    t:AddMaskTexture(m)
    return t
end

local function RoundIcon(parent, fileID, size)
    local tex = parent:CreateTexture(nil, "ARTWORK")
    tex:SetTexture(fileID)
    tex:SetTexCoord(0.07, 0.93, 0.07, 0.93)
    tex:SetSize(size, size)
    local mask = parent:CreateMaskTexture()
    mask:SetTexture(MASK)
    mask:SetAllPoints(tex)
    tex:AddMaskTexture(mask)
    return tex
end

local function MakeButton(parent, text, w, h)
    local b = CreateFrame("Button", nil, parent)
    b:SetSize(w, h)
    local bg = Solid(b)
    bg:SetAllPoints()
    bg:SetVertexColor(0.13, 0.14, 0.18, 1)
    Border(b, COLORS.line)
    local hl = b:CreateTexture(nil, "HIGHLIGHT")
    hl:SetTexture(WHITE)
    hl:SetAllPoints()
    hl:SetVertexColor(1, 1, 1, 0.05)
    b.label = Label(b, 11)
    b.label:SetPoint("CENTER")
    b.label:SetText(text)
    b.bg = bg
    b.highlight = hl
    -- Bouton « maison » : un CreateFrame("Button") brut n'a PAS de fontstring
    -- natif, donc b:SetText(...) ne changeait rien à l'écran (le filtre perso et
    -- le sélecteur de run restaient bloqués sur « — »). On câble SetText/GetText
    -- sur notre propre label pour que les mises à jour dynamiques s'affichent.
    function b:SetText(t) self.label:SetText(t) end
    function b:GetText() return self.label:GetText() end
    return b
end

local function MakeCompactButton(parent, text, w, h)
    local button = MakeButton(parent, text, w or 76, h or 20)
    button.bg:SetVertexColor(0.062, 0.068, 0.088, 1)
    button.label:SetFont(FONT, 10, "")
    return button
end

local function SetOptimizationTabActive(button, active)
    button.bg:SetVertexColor(active and 0.105 or 0.052,
        active and 0.095 or 0.057, active and 0.135 or 0.074, 1)
    button.label:SetTextColor(unpack(active and COLORS.text or COLORS.muted))
end

-- Zone de texte copiable : lecture seule mais sélectionnable (Ctrl+C)
local function MakeCopyBox(parent, multiline)
    local eb = CreateFrame("EditBox", nil, parent)
    eb:SetAutoFocus(false)
    eb:SetMultiLine(multiline and true or false)
    eb:SetMaxLetters(0)
    eb:SetFont(MONO, 11, "")
    eb:SetTextColor(unpack(COLORS.text))
    eb:SetScript("OnEscapePressed", function(self) self:ClearFocus() end)
    eb:SetScript("OnEditFocusGained", function(self) self:HighlightText() end)
    eb:SetScript("OnMouseUp", function(self) self:HighlightText() end)
    eb:SetScript("OnTextChanged", function(self, user)
        if user then
            self:SetText(self.lockedText or "")
            self:HighlightText()
        end
    end)
    eb.SetLockedText = function(self, txt)
        self.lockedText = txt or ""
        self:SetText(self.lockedText)
    end
    return eb
end

local function ClassColorStr(token)
    local c = token and RAID_CLASS_COLORS and RAID_CLASS_COLORS[token]
    return (c and c.colorStr) or "ffffffff"
end

local function ClassName(token)
    return (token and LOCALIZED_CLASS_NAMES_MALE and LOCALIZED_CLASS_NAMES_MALE[token]) or token or "?"
end

local function SpecName(specID)
    if specID and specID > 0 and GetSpecializationInfoByID then
        local _, name = GetSpecializationInfoByID(specID)
        return name
    end
end

-- Séparateur doré des menus (design v0.32) : une fine ligne dorée + un libellé.
-- center=false → petit carré doré + libellé + ligne à droite ; center=true →
-- ligne — libellé — ligne (centré). x/y/w relatifs au coin haut-gauche du parent.
-- Purement décoratif (aucune logique).
local GOLD      = { 0.82, 0.66, 0.30 }
local GOLD_LINE = { 0.44, 0.35, 0.18 }
local function GoldSep(parent, x, y, w, label, center)
    local lab = Label(parent, 12, GOLD)
    lab:SetText(label or "")
    local lw = lab:GetStringWidth() or 0
    if lw <= 0 then lw = (label and #label * 6) or 40 end
    local sq, line, lft, rgt
    if center then
        lft = Solid(parent, "ARTWORK"); lft:SetVertexColor(unpack(GOLD_LINE)); lft:SetHeight(1)
        rgt = Solid(parent, "ARTWORK"); rgt:SetVertexColor(unpack(GOLD_LINE)); rgt:SetHeight(1)
        lft:SetWidth(math.max(4, w / 2 - lw / 2 - 12))
        rgt:SetWidth(math.max(4, w / 2 - lw / 2 - 12))
    else
        sq = Solid(parent, "ARTWORK"); sq:SetVertexColor(unpack(GOLD)); sq:SetSize(5, 5)
        line = Solid(parent, "ARTWORK"); line:SetVertexColor(unpack(GOLD_LINE)); line:SetHeight(1)
    end
    -- place(ny) : (re)positionne le séparateur à la coordonnée y (négative = plus
    -- bas). Renvoyé pour les séparateurs à position variable (ex. Enchantements).
    local function place(ny)
        local cy = ny - 6
        lab:ClearAllPoints()
        if center then
            lab:SetPoint("TOP", parent, "TOPLEFT", x + w / 2, ny)
            lft:ClearAllPoints(); lft:SetPoint("TOPLEFT", parent, "TOPLEFT", x, cy)
            rgt:ClearAllPoints(); rgt:SetPoint("TOPRIGHT", parent, "TOPLEFT", x + w, cy)
        else
            sq:ClearAllPoints(); sq:SetPoint("TOPLEFT", parent, "TOPLEFT", x, ny - 3)
            lab:SetPoint("TOPLEFT", parent, "TOPLEFT", x + 12, ny)
            line:ClearAllPoints()
            line:SetPoint("TOPLEFT", parent, "TOPLEFT", x + 12 + lw + 10, cy)
            line:SetPoint("TOPRIGHT", parent, "TOPLEFT", x + w, cy)
        end
    end
    place(y)
    return place
end

local function RelTime(ts)
    if not ts or ts == 0 then return nil end
    local now = (GetServerTime and GetServerTime()) or time()
    local d = math.max(0, now - ts)
    if d < 60 then return "à l'instant" end
    if d < 3600 then return ("il y a %d min"):format(math.floor(d / 60)) end
    if d < 86400 then return ("il y a %d h"):format(math.floor(d / 3600)) end
    return ("il y a %d j"):format(math.floor(d / 86400))
end

local function FormatNumber(n)
    if BreakUpLargeNumbers then return BreakUpLargeNumbers(n) end
    return tostring(n)
end

-- ---------- Fenêtre principale ----------
local UI

-- Arborescence de réglages (esprit menu cooldowns) : sections repliables avec
-- + / −. Repliée = titre seul ; dépliée = cases/steppers/boutons dessous.
-- tree:section(titre[, ouvert]) → objet avec :check / :stepper / :button ;
-- puis tree:relayout() et tree:refreshAll(). growParent=true : le parent est
-- un enfant de ScrollFrame → sa hauteur suit le contenu (jamais de
-- débordement de la fenêtre, l'ascenseur prend le relais).
local function MakeSettingsTree(parent, x, topY, width, growParent)
    local sections = {}
    local relayout
    local tree = {}

    local function newCheck(sec, text, dbKey, onChange, indent)
        local box = CreateFrame("Button", nil, parent)
        box:SetSize(16, 16)
        local bg = Solid(box); bg:SetAllPoints(); bg:SetVertexColor(unpack(COLORS.field))
        Border(box, COLORS.line)
        local inner = Solid(box, "ARTWORK")
        inner:SetSize(10, 10); inner:SetPoint("CENTER"); inner:SetVertexColor(unpack(COLORS.accent))
        local lbl = Label(box, 10); lbl:SetPoint("LEFT", box, "RIGHT", 10, 0); lbl:SetText(NS.L(text))
        local function upd() inner:SetShown(not (NS.db and NS.db[dbKey] == false)) end
        box:SetScript("OnClick", function()
            if not NS.db then return end
            NS.db[dbKey] = (NS.db[dbKey] == false) and true or false
            upd(); if onChange then onChange() end
        end)
        box:Hide()
        sec.items[#sec.items + 1] = {
            h = 20, upd = upd,
            layout = function(ix, iy)
                box:ClearAllPoints()
                box:SetPoint("TOPLEFT", parent, "TOPLEFT", ix + (indent or 0), iy)
            end,
            show = function(v) box:SetShown(v); if v then upd() end end,
        }
    end

    -- suffix : texte après la valeur ("%" par défaut, "" pour une taille ou un
    -- préréglage) ; step : incrément des boutons -/+ (5 par défaut, 1 pour un
    -- préréglage 1-7 — avant, +5 sur la couleur sautait de 1 à 6 !)
    local function newStepper(sec, labelText, dbKey, default, minV, maxV, color, onChange, suffix, step)
        if suffix == nil then suffix = "%" end
        step = step or 5
        local lbl = Label(parent, 11); lbl:SetText(NS.L(labelText))
        if color then lbl:SetTextColor(unpack(color)) end
        local minus = MakeButton(parent, "-", 24, 20)
        local val = Label(parent, 13); val:SetWidth(46); val:SetJustifyH("CENTER")
        local plus = MakeButton(parent, "+", 24, 20)
        local function get() return (NS.db and NS.db[dbKey]) or default end
        local function upd() val:SetText(("%d"):format(get()) .. suffix) end
        minus:SetScript("OnClick", function()
            if not NS.db then return end
            NS.db[dbKey] = math.max(minV, get() - step); upd(); if onChange then onChange() end
        end)
        plus:SetScript("OnClick", function()
            if not NS.db then return end
            NS.db[dbKey] = math.min(maxV, get() + step); upd(); if onChange then onChange() end
        end)
        lbl:Hide(); minus:Hide(); val:Hide(); plus:Hide()
        sec.items[#sec.items + 1] = {
            h = 46, upd = upd,
            layout = function(ix, iy)
                lbl:ClearAllPoints(); lbl:SetPoint("TOPLEFT", parent, "TOPLEFT", ix, iy)
                minus:ClearAllPoints(); minus:SetPoint("TOPLEFT", parent, "TOPLEFT", ix, iy - 20)
                val:ClearAllPoints(); val:SetPoint("LEFT", minus, "RIGHT", 8, 0)
                plus:ClearAllPoints(); plus:SetPoint("LEFT", val, "RIGHT", 8, 0)
            end,
            show = function(v) lbl:SetShown(v); minus:SetShown(v); val:SetShown(v); plus:SetShown(v); if v then upd() end end,
        }
    end

    -- bouton d'action simple ; onClick reçoit le bouton (pour changer son
    -- libellé) ; updFn optionnel = resynchronise le libellé à l'affichage
    local function newButton(sec, text, onClick, width, updFn)
        local btn = MakeButton(parent, NS.L(text), width or 180, 22)
        if onClick then btn:SetScript("OnClick", function() onClick(btn) end) end
        btn:Hide()
        sec.items[#sec.items + 1] = {
            h = 26, upd = function() if updFn then updFn(btn) end end,
            layout = function(ix, iy)
                btn:ClearAllPoints()
                btn:SetPoint("TOPLEFT", parent, "TOPLEFT", ix, iy)
            end,
            show = function(v) btn:SetShown(v); if v and updFn then updFn(btn) end end,
        }
        return btn
    end

    function tree:section(titleText, expanded)
        local sec = { items = {}, expanded = expanded and true or false }
        local hdr = CreateFrame("Button", nil, parent)
        hdr:SetHeight(20)
        local hl = hdr:CreateTexture(nil, "HIGHLIGHT"); hl:SetTexture(WHITE); hl:SetAllPoints(); hl:SetVertexColor(1, 1, 1, 0.04)
        local plusFs = Label(hdr, 14, COLORS.accent); plusFs:SetPoint("LEFT", 2, 0)
        local lbl = Label(hdr, 12); lbl:SetPoint("LEFT", plusFs, "RIGHT", 8, 0)
        lbl:SetTextColor(0.67, 0.57, 0.98); lbl:SetText(NS.L(titleText))
        sec.hdr, sec.plusFs = hdr, plusFs
        hdr:SetScript("OnClick", function() sec.expanded = not sec.expanded; relayout() end)
        sec.check = function(_, ...) newCheck(sec, ...) end
        sec.stepper = function(_, ...) newStepper(sec, ...) end
        sec.button = function(_, ...) return newButton(sec, ...) end
        sections[#sections + 1] = sec
        return sec
    end

    relayout = function()
        local y = topY
        for _, sec in ipairs(sections) do
            sec.hdr:ClearAllPoints()
            sec.hdr:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
            sec.hdr:SetWidth(width or 300)
            sec.plusFs:SetText(sec.expanded and "-" or "+")
            y = y - 22
            for _, it in ipairs(sec.items) do
                if sec.expanded then
                    it.layout(x + 22, y)
                    it.show(true)
                    y = y - it.h
                else
                    it.show(false)
                end
            end
            y = y - 4
        end
        -- enfant de scroll : la hauteur suit le contenu → l'ascenseur s'active
        if growParent then parent:SetHeight(math.max(40, -y + 12)) end
    end
    tree.relayout = relayout
    function tree:refreshAll()
        for _, sec in ipairs(sections) do
            for _, it in ipairs(sec.items) do it.upd() end
        end
    end
    return tree
end

-- Page Toolbox : hors de BuildUI (budget de variables locales séparé).
-- ============================================================
-- PAGE : DIAG — tests de communication et sonde de combat + zone copiable.
-- Fonction dédiée (comme Toolbox/Stats) pour rester sous la limite Lua de
-- 200 variables locales par fonction. But : diagnostiquer pourquoi rien ne
-- se synchronise en clé (envoi vs réception vs whisper).
-- ============================================================
local function BuildDiagPage(content, pages)
    local page = CreateFrame("Frame", nil, content)
    page:SetAllPoints()
    page:Hide()
    pages.diag = page

    local title = Label(page, 15)
    title:SetPoint("TOPLEFT", 24, -14)
    title:SetText(NS.L("Diagnostic communication et combat"))

    local box  -- déclaré avant les boutons (fermeture Récap)
    local btns = {
        { "Tout tester",  function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.RunAll() end end },
        { "Commtest",     function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.CommTest() end end },
        { "Env",          function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.Env() end end },
        { "Codes envoi",  function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.SendCodes() end end },
        { "Réception",    function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.Rx() end end },
        { "Groupe",       function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.Group() end end },
        { "Ping whisper", function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.PingWhisper() end end },
        { "Test whisper", function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.WhisperTest() end end },
        { "Sonde combat", function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.CombatProbe() end end },
        { "Récap",        function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.Recap() end end },
        { "Vider",        function() if NS.Sync and NS.Sync.Diag then NS.Sync.Diag.Clear() end end },
    }
    for i, e in ipairs(btns) do
        local col = (i - 1) % 4
        local row = math.floor((i - 1) / 4)
        local b = MakeButton(page, NS.L(e[1]), 122, 24)
        b:SetPoint("TOPLEFT", 24 + col * 128, -48 - row * 30)
        b:SetScript("OnClick", e[2])
    end

    -- zone copiable (EditBox mono dans un ScrollFrame), remplie par « Récap »
    local frame = Solid(page, "BACKGROUND")
    frame:SetVertexColor(0.05, 0.05, 0.07, 0.85)
    frame:SetPoint("TOPLEFT", 22, -144)   -- 3 rangees de boutons (11 boutons)
    frame:SetPoint("BOTTOMRIGHT", -30, 14)
    local scroll = CreateFrame("ScrollFrame", "AsylumCompagnonDiagScroll", page, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", 26, -148)
    scroll:SetPoint("BOTTOMRIGHT", -34, 18)
    box = MakeCopyBox(scroll, true)
    box:SetPoint("TOPLEFT")
    box:SetWidth(520)
    box:SetHeight(400)
    scroll:SetScrollChild(box)
    scroll:SetScript("OnSizeChanged", function(_, w) if w and w > 40 then box:SetWidth(w - 4) end end)
    box:SetLockedText(NS.L("(le récap s'affichera ici après avoir cliqué « Récap »)"))

    -- rempli par Sync.Diag.Recap / Clear
    NS.Diag_SetRecap = function(txt)
        txt = txt or ""
        box:SetLockedText(txt ~= "" and txt or NS.L("(vide)"))
        local _, nl = txt:gsub("\n", "\n")
        box:SetHeight(math.max(400, (nl + 2) * 14 + 20))
    end
end

local function BuildToolboxPage(content, pages)
    local toolbox = CreateFrame("Frame", nil, content)
    toolbox:SetAllPoints()
    toolbox:Hide()
    pages.toolbox = toolbox

    local title = Label(toolbox, 14)
    title:SetPoint("TOPLEFT", 24, -16)
    title:SetText(NS.L("Boîte à outils"))
    local hint = Label(toolbox, 10, COLORS.muted)
    hint:SetPoint("TOPLEFT", 24, -38)
    hint:SetText(NS.L("Automatisations locales (n'affectent que toi)."))

    local function refreshLog()
        if NS.Toolbox and NS.Toolbox.UpdateAutoLog then NS.Toolbox.UpdateAutoLog() end
    end
    local function refreshFastLoot()
        if NS.Toolbox and NS.Toolbox.ApplyFastLootSetting then NS.Toolbox.ApplyFastLootSetting() end
    end

    local T = MakeSettingsTree(toolbox, 24, -64, 360)
    local s
    s = T:section("Vente automatique")
    s:check("Vendre les objets gris chez un marchand", "tbAutoSell")
    s = T:section("Réparation automatique")
    s:check("Réparer automatiquement", "tbAutoRepair")
    s:check("Fonds de guilde en priorité", "tbRepairGuild", nil, 22)
    s = T:section("Butin automatique")
    s:check("Butin automatique ultra-rapide (toujours actif)", "tbFastLoot", refreshFastLoot)
    s = T:section("Files d'attente")
    s:check("Accepter automatiquement les files", "tbAutoQueue")
    s = T:section("Journaux de combat")
    s:check("Log auto en donjon", "tbAutoLogDungeon", refreshLog)
    s:check("Log auto en raid", "tbAutoLogRaid", refreshLog)
    s = T:section("SpellID / IconID")
    s:check("Afficher SpellID / IconID (infobulles)", "spellIDTooltip")
    T:relayout()

    -- Tout activer / Tout désactiver (haut-droite, au niveau du titre)
    local ALL = { "tbAutoSell", "tbAutoRepair", "tbFastLoot", "tbAutoQueue",
        "spellIDTooltip", "tbAutoLogDungeon", "tbAutoLogRaid" }
    local function setAll(v)
        if not NS.db then return end
        for _, k in ipairs(ALL) do NS.db[k] = v end
        T:refreshAll(); refreshLog(); refreshFastLoot()
    end
    local enAll = MakeButton(toolbox, NS.L("Tout activer"), 96, 22)
    enAll:SetPoint("TOPRIGHT", toolbox, "TOPRIGHT", -132, -14)
    enAll:SetScript("OnClick", function() setAll(true) end)
    local disAll = MakeButton(toolbox, NS.L("Tout désactiver"), 112, 22)
    disAll:SetPoint("TOPRIGHT", toolbox, "TOPRIGHT", -16, -14)
    disAll:SetScript("OnClick", function() setAll(false) end)

    return function() T:refreshAll() end
end

-- Page ALERTES — construite hors de BuildUI (comme la Boîte à outils) pour
-- rester sous la limite Lua de 200 variables locales, et pour un agencement
-- STRICTEMENT identique : titre + astuce, arborescence repliée, Tout activer /
-- Tout désactiver en haut-droite.
local function BuildAlertsPage(content, pages)
    local alerts = CreateFrame("Frame", nil, content)
    alerts:SetAllPoints()
    alerts:Hide()
    pages.alerts = alerts

    local title = Label(alerts, 14)
    title:SetPoint("TOPLEFT", 24, -16)
    title:SetText(NS.L("Alertes"))
    local hint = Label(alerts, 10, COLORS.muted)
    hint:SetPoint("TOPLEFT", 24, -38)
    hint:SetText(NS.L("Notifications locales (chat, écran, son)."))

    local durChg = function() if NS.CheckDurability then NS.CheckDurability() end end
    local blPreview = function() if NS.Stats and NS.Stats.PreviewLustAlert then NS.Stats.PreviewLustAlert() end end
    local blColorChg = function()
        if NS.Stats and NS.Stats.SetColorPreset then
            NS.Stats.SetColorPreset((NS.db and NS.db.blAlertColorIdx) or 1)
            NS.Stats.PreviewLustAlert()
        end
    end

    local T = MakeSettingsTree(alerts, 24, -64, 360)
    local s
    s = T:section("Alertes de stock épuisé")
    s:check("Message dans le chat", "alertChat")
    s:check("Alerte au centre de l'écran", "alertRaid")
    s:check("Alerte sonore", "alertSound")
    s = T:section("Fonctionnalités (affichage local)")
    s:check("Rapport chat à l'appel (ready check)", "appelReport")
    s = T:section("Durabilité de l'équipement")
    s:check("Alerte de durabilité faible", "durAlert")
    s:stepper("Seuil jaune (moyen)",    "durYellow", 25, 10, 60, { 0.98, 0.80, 0.25 }, durChg)
    s:stepper("Seuil rouge (critique)", "durRed",    10,  5, 40, { 0.94, 0.36, 0.36 }, durChg)
    s = T:section("Alerte Bloodlust (Potion + Burst)")
    s:check("Message + son quand un Lust est lancé", "blAlert")
    s:check("Jouer un son", "blAlertSound")
    s:stepper("Taille du message", "blAlertSize", 30, 16, 60, { 0.98, 0.86, 0.35 }, blPreview, "")
    s:stepper("Couleur (préréglage 1-7)", "blAlertColorIdx", 1, 1, 7, { 0.98, 0.86, 0.35 }, blColorChg, "", 1)
    -- position du message : mode « déplacer » (aperçu glissable, sauvegardé)
    s:button("Déplacer le message", function(btn)
        if NS.Stats and NS.Stats.ToggleBLMove then
            local moving = NS.Stats.ToggleBLMove()
            btn.label:SetText(NS.L(moving and "Valider la position" or "Déplacer le message"))
        end
    end, 180, function(btn)
        local moving = NS.Stats and NS.Stats.IsBLMoving and NS.Stats.IsBLMoving()
        btn.label:SetText(NS.L(moving and "Valider la position" or "Déplacer le message"))
    end)
    s:button("Réinitialiser la position", function()
        if NS.Stats and NS.Stats.ResetBLPosition then NS.Stats.ResetBLPosition() end
        if NS.Stats and NS.Stats.PreviewLustAlert then NS.Stats.PreviewLustAlert() end
    end, 180)
    T:relayout()

    -- Tout activer / Tout désactiver (haut-droite, au niveau du titre)
    local ALL = { "alertChat", "alertRaid", "alertSound", "appelReport", "durAlert", "blAlert" }
    local function setAll(v)
        if not NS.db then return end
        for _, k in ipairs(ALL) do NS.db[k] = v end
        T:refreshAll(); durChg()
    end
    local enAll = MakeButton(alerts, NS.L("Tout activer"), 96, 22)
    enAll:SetPoint("TOPRIGHT", alerts, "TOPRIGHT", -132, -14)
    enAll:SetScript("OnClick", function() setAll(true) end)
    local disAll = MakeButton(alerts, NS.L("Tout désactiver"), 112, 22)
    disAll:SetPoint("TOPRIGHT", alerts, "TOPRIGHT", -16, -14)
    disAll:SetScript("OnClick", function() setAll(false) end)

    return function() T:refreshAll() end
end

-- Page STATS M+ — historique des donjons (déroulant) + tableau par joueur.
-- Construite hors de BuildUI (limite Lua 200 locals). Se rafraîchit via
-- NS.RefreshStatsPage (appelé à l'ouverture de la page et à la fin d'un donjon).
local function BuildStatsPage(content, pages)
    local page = CreateFrame("Frame", nil, content)
    page:SetAllPoints()
    page:Hide()
    pages.stats = page

    local title = Label(page, 13)
    title:SetPoint("TOPLEFT", 24, -16)
    title:SetText(NS.L("Statistiques Mythique+"))
    local hint = Label(page, 10, COLORS.muted)
    hint:SetPoint("TOPLEFT", 24, -38)
    hint:SetText(NS.L("Actions par catégorie et par joueur (ceux qui ont l'addon)."))

    -- sélecteur d'historique (déroulant maison) — même largeur que le
    -- tableau en dessous (s'arrête au bout de la colonne « Pot »)
    local selBtn = MakeButton(page, "—", 470, 26)
    selBtn:SetPoint("TOPLEFT", 24, -62)
    selBtn:SetPoint("TOPRIGHT", page, "TOPRIGHT", -24, -62)  -- pleine largeur (bord droit = filtre perso)
    local caret = Label(selBtn, 11, COLORS.muted)
    caret:SetPoint("RIGHT", -8, 0); caret:SetText("v")

    local listFrame = CreateFrame("Frame", nil, page)
    listFrame:SetPoint("TOPLEFT", selBtn, "BOTTOMLEFT", 0, -2)
    listFrame:SetPoint("TOPRIGHT", selBtn, "BOTTOMRIGHT", 0, -2)
    listFrame:SetHeight(40)
    listFrame:SetFrameStrata("DIALOG")
    listFrame:Hide()
    local lbg = Solid(listFrame); lbg:SetAllPoints(); lbg:SetVertexColor(0.05, 0.05, 0.07, 0.98)
    Border(listFrame, COLORS.line)
    local listBtns = {}

    local emptyFs = Label(page, 12, COLORS.muted)
    emptyFs:SetPoint("TOPLEFT", 40, -152)
    emptyFs:Hide()

    local selectedIndex = 1
    local selectedChar        -- nil = perso courant ; sinon clé « Nom-Royaume » filtrée
    local refreshCharBtn      -- (défini plus bas ; met à jour le bouton filtre perso)
    local function charKey()
        return selectedChar or (NS.Stats and NS.Stats.CurrentChar()) or nil
    end

    -- rendu du tableau (colonnes + tooltips) via le module Stats, partagé avec le popup
    local function renderTable(entry)
        local e = entry or { rows = {} }
        if NS.Stats and NS.Stats.RenderTable then
            NS.Stats.RenderTable(page, e, 24, -112, math.max(470, (page:GetWidth() or 0) - 48))
        end
        local empty = not (entry and entry.rows and #entry.rows > 0)
        emptyFs:SetText(NS.L("Aucune donnée pour ce donjon."))
        emptyFs:SetShown(empty)
    end

    -- liste combinée : clé EN COURS (aperçu live) en tête, puis l'historique
    -- DU PERSO FILTRÉ (défaut = perso connecté).
    local function buildList()
        local list = {}
        local ck = charKey()
        -- l'aperçu LIVE appartient au perso courant → affiché seulement si on
        -- filtre sur lui (ou sans filtre explicite).
        local live = NS.Stats and NS.Stats.GetLiveEntry and NS.Stats.GetLiveEntry()
        if live and (not selectedChar or selectedChar == live.char) then list[#list + 1] = live end
        local h = (NS.Stats and NS.Stats.GetHistoryFor and NS.Stats.GetHistoryFor(ck)) or {}
        for _, e in ipairs(h) do list[#list + 1] = e end
        return list
    end
    -- ligne principale (la compo va sur une 2e ligne dans le déroulant)
    local function labelOf(e)
        if e.live then
            return ("|cff46d75a●|r %s |cfff2d159+%d|r  —  %s"):format(
                e.name or "?", e.level or 0, NS.L("clé en cours"))
        end
        return ("%s |cfff2d159+%d|r  —  %s"):format(e.name or "?", e.level or 0, e.date or "")
    end
    local function refresh()
        local list = buildList()
        if #list == 0 then
            selBtn:SetText(NS.L("Aucun donjon enregistré"))
            renderTable(nil); return
        end
        if selectedIndex < 1 or selectedIndex > #list then selectedIndex = 1 end
        local e = list[selectedIndex]
        selBtn:SetText(labelOf(e))
        renderTable(e)
    end
    NS.RefreshStatsPage = refresh

    -- à l'ouverture de la page : si une clé est en cours, on montre l'aperçu
    -- live d'office (index 1) ; sinon on garde le dernier donjon sélectionné.
    page:SetScript("OnShow", function()
        selectedChar = nil               -- défaut à l'ouverture : perso connecté
        if refreshCharBtn then refreshCharBtn() end
        if NS.Stats and NS.Stats.GetLiveEntry and NS.Stats.GetLiveEntry() then
            selectedIndex = 1
        end
        refresh()
    end)
    -- rafraîchit l'aperçu live tant que la page est ouverte pendant une clé
    if C_Timer and C_Timer.NewTicker then
        C_Timer.NewTicker(2, function()
            if page:IsShown() and selectedIndex == 1
                and NS.Stats and NS.Stats.GetLiveEntry and NS.Stats.GetLiveEntry() then
                refresh()
            end
        end)
    end

    selBtn:SetScript("OnClick", function()
        if listFrame:IsShown() then listFrame:Hide(); return end
        local list = buildList()
        for _, b in ipairs(listBtns) do b:Hide() end
        if #list == 0 then return end
        local yy = -6
        for i, e in ipairs(list) do
            local b = listBtns[i]
            if not b then
                b = CreateFrame("Button", nil, listFrame)
                local bh = b:CreateTexture(nil, "HIGHLIGHT"); bh:SetTexture(WHITE)
                bh:SetAllPoints(); bh:SetVertexColor(1, 1, 1, 0.06)
                b.fs = Label(b, 12); b.fs:SetPoint("TOPLEFT", 8, -4)
                b.fs2 = Label(b, 11); b.fs2:SetPoint("TOPLEFT", 10, -21)
                b.fs2:SetWidth(700); b.fs2:SetJustifyH("LEFT"); b.fs2:SetWordWrap(false)
                listBtns[i] = b
            end
            b:SetHeight(38)
            b:ClearAllPoints(); b:SetPoint("TOPLEFT", 6, yy); b:SetPoint("TOPRIGHT", -6, yy)
            b.fs:SetText(labelOf(e))                                   -- ligne 1 : donjon +niv — date
            b.fs2:SetText((NS.Stats and NS.Stats.RunComp and NS.Stats.RunComp(e)) or "") -- ligne 2 : compo (noms colorés)
            b:SetScript("OnClick", function() selectedIndex = i; listFrame:Hide(); refresh() end)
            b:Show()
            yy = yy - 40
        end
        listFrame:SetHeight(math.max(30, -yy + 10))
        listFrame:Show()
    end)

    -- ---------- Filtre PERSONNAGE (haut-droite) ----------
    -- Choisit de quel perso on voit les stats. Défaut = perso connecté.
    local charBtn = MakeButton(page, "—", 160, 24)
    charBtn:SetPoint("TOPRIGHT", -24, -12)
    local charCaret = Label(charBtn, 11, COLORS.muted)
    charCaret:SetPoint("RIGHT", -8, 0); charCaret:SetText("v")
    local charFrame = CreateFrame("Frame", nil, page)
    charFrame:SetPoint("TOPRIGHT", charBtn, "BOTTOMRIGHT", 0, -2)
    charFrame:SetSize(160, 40); charFrame:SetFrameStrata("DIALOG"); charFrame:Hide()
    local cfBg = Solid(charFrame); cfBg:SetAllPoints(); cfBg:SetVertexColor(0.05, 0.05, 0.07, 0.98)
    Border(charFrame, COLORS.line)
    local charBtns = {}

    refreshCharBtn = function()
        local key = charKey()
        local name, class = (key and (key:match("^([^%-]+)"))) or "?", nil
        for _, c in ipairs((NS.Stats and NS.Stats.HistoryChars and NS.Stats.HistoryChars()) or {}) do
            if c.key == key then name, class = c.name, c.class; break end
        end
        charBtn:SetText(("|c%s%s|r"):format(ClassColorStr(class), name))
    end

    charBtn:SetScript("OnClick", function()
        if charFrame:IsShown() then charFrame:Hide(); return end
        local chars = (NS.Stats and NS.Stats.HistoryChars and NS.Stats.HistoryChars()) or {}
        for _, b in ipairs(charBtns) do b:Hide() end
        local yy = -6
        for i, c in ipairs(chars) do
            local b = charBtns[i]
            if not b then
                b = CreateFrame("Button", nil, charFrame); b:SetSize(148, 22)
                local bh = b:CreateTexture(nil, "HIGHLIGHT"); bh:SetTexture(WHITE)
                bh:SetAllPoints(); bh:SetVertexColor(1, 1, 1, 0.06)
                b.fs = Label(b, 12); b.fs:SetPoint("LEFT", 8, 0)
                charBtns[i] = b
            end
            b:ClearAllPoints(); b:SetPoint("TOPLEFT", 6, yy)
            b.fs:SetText(("|c%s%s|r"):format(ClassColorStr(c.class), c.name))
            local ck = c.key
            b:SetScript("OnClick", function()
                selectedChar = ck; selectedIndex = 1
                charFrame:Hide(); refreshCharBtn(); refresh()
            end)
            b:Show(); yy = yy - 24
        end
        charFrame:SetHeight(math.max(30, -yy + 10))
        charFrame:Show()
    end)

    refreshCharBtn()
    refresh()
end

-- Page ACCUEIL — tableau de bord (maquette « Option D magazine » validée) :
-- IMAGE ENTIÈRE 4:3 à gauche, Préparation + Amis à droite, bandeau bas
-- Stocks · Clé de la semaine · Derniers runs M+ (cliquable → Stats M+).
-- Construite hors de BuildUI (limite Lua 200 locals).
-- Ne coûte rien en réseau : tout vient de données déjà présentes en local.
-- Renvoie la fonction de rafraîchissement (ouverture + retour sur l'onglet).
local function BuildHomePage(content, pages, gotoPage)
    local home = CreateFrame("Frame", nil, content)
    home:SetAllPoints()
    pages.home = home

    -- liseré doré en haut d'un cadre (contour « moderne », design v0.33)
    local function GoldTop(f)
        local t = Solid(f, "ARTWORK"); t:SetVertexColor(GOLD[1], GOLD[2], GOLD[3], 0.6)
        t:SetHeight(2); t:SetPoint("TOPLEFT"); t:SetPoint("TOPRIGHT")
    end

    local hello = Label(home, 16)
    hello:SetPoint("TOPLEFT", 24, -20)
    -- haut-droite : or du perso (le statut de synchro est déjà en bas à gauche)
    local status = Label(home, 12)
    status:SetPoint("TOPRIGHT", -24, -24)

    -- Mise en page « Option D — magazine » (maquette validée) : IMAGE ENTIÈRE
    -- 4:3 à GAUCHE (372×279, aucune coupe), Préparation + Amis à droite sur
    -- toute la hauteur de l'image, puis un bandeau bas plein largeur :
    -- Stocks bas · Clé de la semaine · Derniers runs M+ — zéro espace mort.
    -- (La fenêtre se redimensionne par échelle uniforme → les proportions
    -- tiennent à toutes les tailles.)
    local function MakeCard(x, y, w, h, titleText)
        local card = CreateFrame("Frame", nil, home)
        card:SetPoint("TOPLEFT", x, y)
        card:SetSize(w, h)
        local bg = Solid(card); bg:SetAllPoints(); bg:SetVertexColor(unpack(COLORS.panel))
        Border(card, COLORS.line)
        GoldTop(card)
        local t = Label(card, 9, COLORS.muted)
        t:SetPoint("TOPLEFT", 10, -9)
        t:SetText(NS.L(titleText))
        local body = Label(card, 11)
        body:SetPoint("TOPLEFT", 10, -26)
        body:SetPoint("BOTTOMRIGHT", -10, 8)
        body:SetJustifyH("LEFT")
        body:SetJustifyV("TOP")
        card.body = body
        return card
    end

    -- rangée 1 : image ENTIÈRE à gauche (haut du canevas 1024×1024, soit
    -- 1024×768 → SetTexCoord(0,1,0,0.75), cadre 4:3 exact)
    local banner = CreateFrame("Frame", nil, home)
    banner:SetPoint("TOPLEFT", 24, -52)
    banner:SetSize(370, 279)
    local bbg = Solid(banner); bbg:SetAllPoints(); bbg:SetVertexColor(unpack(COLORS.field))
    Border(banner, COLORS.line)
    local bMissing = banner:CreateFontString(nil, "BORDER")
    bMissing:SetFont(FONT, 10, "")
    bMissing:SetTextColor(unpack(COLORS.muted))
    bMissing:SetPoint("CENTER")
    bMissing:SetText("media\\accueil.tga")
    local bimg = banner:CreateTexture(nil, "ARTWORK")
    bimg:SetPoint("TOPLEFT", 1, -1)
    bimg:SetPoint("BOTTOMRIGHT", -1, 1)
    bimg:SetTexture(HOME_IMAGE)
    bimg:SetTexCoord(0, 1, 0, 0.75)

    -- rangée 1, colonne droite : préparation + amis (hauteur de l'image)
    local cardPrep    = MakeCard(402, -52,  181, 135, "Ta préparation")
    local cardFriends = MakeCard(402, -195, 181, 136, "Amis en ligne")

    -- rangée 2 (bandeau bas) : stocks · clé de la semaine · derniers runs
    local cardStock = MakeCard(24, -339, 181, 119, "Stocks bas")

    -- clé de la semaine : panneau CLIQUABLE (→ Clés & Coffre), avec un lien
    -- violet en bas à gauche — même logique que « Derniers runs M+ » (pas de bouton).
    local keyRow = CreateFrame("Button", nil, home)
    keyRow:SetPoint("TOPLEFT", 213, -339)
    keyRow:SetSize(181, 119)
    local kbg = Solid(keyRow); kbg:SetAllPoints(); kbg:SetVertexColor(unpack(COLORS.panel))
    Border(keyRow, COLORS.line)
    GoldTop(keyRow)
    local kHl = keyRow:CreateTexture(nil, "HIGHLIGHT")
    kHl:SetTexture(WHITE); kHl:SetAllPoints(); kHl:SetVertexColor(1, 1, 1, 0.04)
    local kTitle = Label(keyRow, 9, COLORS.muted)
    kTitle:SetPoint("TOPLEFT", 10, -9)
    kTitle:SetText(NS.L("Clé de la semaine"))
    local kText = Label(keyRow, 11)
    kText:SetPoint("TOPLEFT", 10, -26)
    kText:SetPoint("BOTTOMRIGHT", -10, 24)
    kText:SetJustifyH("LEFT")
    kText:SetJustifyV("TOP")
    local kMore = Label(keyRow, 10, COLORS.accent)
    kMore:SetPoint("BOTTOMLEFT", 10, 8)
    kMore:SetText(NS.L("Clés & Coffre") .. " »")
    keyRow:SetScript("OnClick", function() gotoPage("vault") end)

    -- derniers runs M+ (panneau cliquable → page Stats M+)
    local runsCard = CreateFrame("Button", nil, home)
    runsCard:SetPoint("TOPLEFT", 402, -339)
    runsCard:SetSize(181, 119)
    local rcBg = Solid(runsCard); rcBg:SetAllPoints(); rcBg:SetVertexColor(unpack(COLORS.panel))
    Border(runsCard, COLORS.line)
    GoldTop(runsCard)
    local rcHl = runsCard:CreateTexture(nil, "HIGHLIGHT")
    rcHl:SetTexture(WHITE); rcHl:SetAllPoints(); rcHl:SetVertexColor(1, 1, 1, 0.04)
    local rTitle = Label(runsCard, 9, COLORS.muted)
    rTitle:SetPoint("TOPLEFT", 10, -9)
    rTitle:SetText(NS.L("Derniers runs M+"))
    local rBody = Label(runsCard, 11)
    rBody:SetPoint("TOPLEFT", 10, -26)
    rBody:SetPoint("BOTTOMRIGHT", -10, 24)
    rBody:SetJustifyH("LEFT")
    rBody:SetJustifyV("TOP")
    rBody:SetWordWrap(false)
    local rMore = Label(runsCard, 10, COLORS.accent)
    rMore:SetPoint("BOTTOMLEFT", 10, 8)
    rMore:SetText(NS.L("Stats M+") .. " »")
    runsCard:SetScript("OnClick", function() gotoPage("stats") end)

    local function ItemNameOf(id, fallback)
        local fn = (C_Item and C_Item.GetItemInfo) or GetItemInfo
        if fn and id then
            local ok, n = pcall(fn, id)
            if ok and n then return n end
        end
        return fallback or ("objet " .. tostring(id))
    end
    -- coche verte / croix rouge : TEXTURES du jeu (mêmes icônes que le tracker
    -- de kicks) — les glyphes ✓/✗ ne sont pas dans la police du jeu (carrés)
    local OK_T  = "|TInterface\\RaidFrame\\ReadyCheck-Ready:12|t"
    local KO_T  = "|TInterface\\RaidFrame\\ReadyCheck-NotReady:12|t"
    local MID_T = "|TInterface\\COMMON\\Indicator-Yellow:12|t"  -- rond orange : présent mais pas OR
    -- accepte booléen (enchant/buffs) OU niveau de qualité (2=OR, 1=inférieur, 0=rien)
    local function mark(v)
        if v == true or v == 2 then return OK_T end
        if v == 1 then return MID_T end
        if v == false or v == 0 then return KO_T end
        return "|cff8b8fa3—|r"
    end

    local function refresh()
        -- salutation + état de la synchro
        local _, myClass = UnitClass("player")
        local h = tonumber(date("%H")) or 12
        local greet = (h >= 5 and h < 18) and NS.L("Bonjour") or NS.L("Bonsoir")
        hello:SetText(("%s, |c%s%s|r"):format(greet, ClassColorStr(myClass), UnitName("player") or "?"))
        -- or du perso (montant + icône pièce d'or) ; le statut de synchro est déjà
        -- affiché en bas à gauche, inutile de le répéter ici.
        local g = math.floor(((GetMoney and GetMoney()) or 0) / 10000)
        local gStr = (BreakUpLargeNumbers and BreakUpLargeNumbers(g)) or tostring(g)
        status:SetText(("|cfff2d159%s|r |TInterface\\MoneyFrame\\UI-GoldIcon:15:15:2:-1|t"):format(gStr))

        -- amis EN LIGNE uniquement (pas de compteur hors ligne) + version de leur
        -- addon entre parenthèses, colorée : vert = à jour, orange = 1 version de
        -- retard, rouge = plus ancien (référence = MA version).
        local function verNum(v)
            local a, b, c = tostring(v or ""):match("(%d+)%.(%d+)%.(%d+)")
            if not a then return nil end
            return tonumber(a) * 10000 + tonumber(b) * 100 + tonumber(c)
        end
        local mine = verNum(NS.VERSION) or 0
        local function verTag(v)
            local pv, hex = verNum(v), "8b8fa3"
            local shown = tostring(v or "?")
            if not pv then shown = "?"
            elseif pv >= mine then hex = "3fd07a"        -- à jour
            elseif pv >= mine - 1 then hex = "f0a020"    -- 1 version de retard
            else hex = "e24b4a" end                      -- plus ancien
            return ("|cff%s(%s)|r"):format(hex, shown)
        end
        local on = {}
        for k, p in pairs((NS.db and NS.db.peers) or {}) do
            if p.online then
                on[#on + 1] = { name = k:match("^(.-)%-") or k, class = p.class, ver = p.ver }
            end
        end
        table.sort(on, function(a, b) return a.name < b.name end)
        local lines = {}
        for i = 1, math.min(#on, 4) do
            lines[#lines + 1] = ("|TInterface\\COMMON\\Indicator-Green:10|t |c%s%s|r %s")
                :format(ClassColorStr(on[i].class), on[i].name, verTag(on[i].ver))
        end
        if #on > 4 then lines[#lines + 1] = ("|cff8b8fa3+%d|r"):format(#on - 4) end
        if #lines == 0 then lines[1] = "|cff8b8fa3" .. NS.L("Personne d'autre en ligne.") .. "|r" end
        cardFriends.body:SetText(table.concat(lines, "\n"))

        -- ta préparation (mêmes lectures que la page Buffs & auras)
        local s = (NS.Buffs and NS.Buffs.GetSelfStatus and NS.Buffs.GetSelfStatus()) or {}
        local prep = {
            ("%s  %s"):format(NS.L("Flacon"), mark(s.flask)),
            ("%s  %s"):format(NS.L("Bouffe"), mark(s.food)),
            ("%s  %s"):format(NS.L("Huile"), mark(s.oil)),
        }
        -- enchantements : coche verte si les 8 emplacements suivis sont
        -- enchantés, sinon croix rouge + liste des emplacements manquants
        if NS.Buffs and NS.Buffs.SelfEnchantBits then
            local okE, bits = pcall(NS.Buffs.SelfEnchantBits)
            if okE and type(bits) == "string" and #bits > 0 then
                local miss = {}
                for i, sl in ipairs(NS.ENCHANT_SLOTS or {}) do
                    if bits:sub(i, i) ~= "1" then miss[#miss + 1] = NS.L(sl.label) end
                end
                if #miss == 0 then
                    prep[#prep + 1] = ("%s  %s"):format(NS.L("Enchantements"), mark(true))
                else
                    prep[#prep + 1] = ("%s  %s |cff8b8fa3%s|r"):format(
                        NS.L("Enchantements"), mark(false), table.concat(miss, ", "))
                end
            end
        end
        cardPrep.body:SetText(table.concat(prep, "\n"))

        -- stocks bas (consommables suivis ≤ seuil, les 3 pires)
        local low = NS.LOW_STOCK or 5
        local items = (NS.db and NS.db.myProfile and NS.db.myProfile.items) or {}
        local worst = {}
        for _, t in ipairs(NS.TRACKED_ITEMS or {}) do
            local pid = t.ids and t.ids[1]
            local c = pid and items[pid]
            if c ~= nil and c <= low then
                worst[#worst + 1] = { id = pid, c = c, fb = t.fallback }
            end
        end
        table.sort(worst, function(a, b) return a.c < b.c end)
        local sl = {}
        for i = 1, math.min(#worst, 3) do
            local w = worst[i]
            local col = (w.c <= 0) and "ea4850" or "facd40"
            sl[#sl + 1] = ("%s |cff%s%d|r"):format(ItemNameOf(w.id, w.fb), col, w.c)
        end
        if #sl == 0 then sl[1] = "|cff8b8fa3" .. NS.L("Rien à signaler.") .. "|r" end
        cardStock.body:SetText(table.concat(sl, "\n"))

        -- clé de la semaine
        local keyText = NS.Vault and NS.Vault.MyKeyText and NS.Vault.MyKeyText()
        kText:SetText(keyText or ("|cff8b8fa3" .. NS.L("aucune clé") .. "|r"))

        -- derniers runs M+ (3 max) — UNIQUEMENT ceux du perso ACTUEL (pas les rerolls)
        local hist = (NS.Stats and NS.Stats.GetHistoryFor and NS.Stats.GetHistoryFor()) or {}
        local rl = {}
        for i = 1, math.min(#hist, 3) do
            local e = hist[i]
            rl[#rl + 1] = ("%s |cfff2d159+%d|r"):format(e.name or "?", e.level or 0)
        end
        if #rl == 0 then rl[1] = "|cff8b8fa3" .. NS.L("Aucun donjon enregistré") .. "|r" end
        rBody:SetText(table.concat(rl, "\n"))
    end

    return refresh
end

local function BuildUI()
    local pages, navButtons = {}, {}
    local currentDetail, currentInv
    local ShowPage, FillDetail, ShowDetail, FillInvDetail, ShowInvDetail
    local refreshOptimList, refreshInvList
    local UpdateKicksPage, UpdateAlertsPage, RefreshBuffsPage, RefreshLootPage, UpdateCDPage, RefreshVaultPage
    local UpdateToolboxPage, UpdateHomePage

    UI = CreateFrame("Frame", "AsylumCompagnonFrame", UIParent)
    UI:SetSize(WIN_W, WIN_H)
    local savedPos = NS.db and NS.db.uiPos
    if savedPos and savedPos.p then
        UI:SetPoint(savedPos.p, UIParent, savedPos.rp or savedPos.p, savedPos.x or 0, savedPos.y or 0)
    else
        UI:SetPoint("CENTER", 0, 30)
    end
    UI:SetFrameStrata("HIGH")
    UI:SetMovable(true)
    UI:SetClampedToScreen(true)
    UI:EnableMouse(true)
    UI:SetScale((NS.db and NS.db.uiScale) or 1)
    UI:Hide()

    table.insert(UISpecialFrames, "AsylumCompagnonFrame") -- Échap ferme

    local bg = Solid(UI)
    bg:SetAllPoints()
    bg:SetVertexColor(unpack(COLORS.bg))

    -- bordure + liseré par-dessus tout le contenu
    local overlay = CreateFrame("Frame", nil, UI)
    overlay:SetAllPoints()
    overlay:SetFrameLevel(UI:GetFrameLevel() + 20)
    Border(overlay, COLORS.line)
    local topline = Solid(overlay, "OVERLAY")
    topline:SetPoint("TOPLEFT", 1, -1)
    topline:SetPoint("TOPRIGHT", -1, -1)
    topline:SetHeight(2)
    topline:SetGradient("HORIZONTAL",
        CreateColor(0.655, 0.545, 0.98, 0.95),
        CreateColor(0.655, 0.545, 0.98, 0.05))

    -- poignée de redimensionnement (bas-droite) : ajuste l'échelle de
    -- toute la fenêtre — tout le contenu (image comprise) se met à
    -- l'échelle proportionnellement ; réglage sauvegardé
    local grip = CreateFrame("Button", nil, overlay)
    grip:SetSize(16, 16)
    grip:SetPoint("BOTTOMRIGHT", -3, 3)
    grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
    grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
    grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
    grip:SetScript("OnMouseDown", function(self)
        local pScale = UIParent:GetEffectiveScale()
        -- coin haut-gauche figé, en pixels écran
        local sx = UI:GetLeft() * UI:GetEffectiveScale()
        local sy = UI:GetTop() * UI:GetEffectiveScale()
        self:SetScript("OnUpdate", function()
            local cx = GetCursorPosition()
            local newScale = (cx - sx) / (WIN_W * pScale)
            if newScale < 0.7 then newScale = 0.7 end
            if newScale > 1.6 then newScale = 1.6 end
            UI:SetScale(newScale)
            UI:ClearAllPoints()
            UI:SetPoint("TOPLEFT", UIParent, "BOTTOMLEFT",
                sx / (pScale * newScale), sy / (pScale * newScale))
        end)
    end)
    grip:SetScript("OnMouseUp", function(self)
        self:SetScript("OnUpdate", nil)
        if NS.db then
            NS.db.uiScale = UI:GetScale()
            local p, _, rp, x, y = UI:GetPoint(1)
            NS.db.uiPos = { p = p, rp = rp, x = x, y = y }
        end
    end)

    local function SaveUIPosition()
        if not NS.db then return end
        local p, _, rp, x, y = UI:GetPoint(1)
        NS.db.uiPos = { p = p, rp = rp, x = x, y = y }
    end

    local function MakeDraggable(region)
        region:EnableMouse(true)
        region:SetScript("OnMouseDown", function() UI:StartMoving() end)
        region:SetScript("OnMouseUp", function()
            UI:StopMovingOrSizing()
            SaveUIPosition()
        end)
    end

    -- ----- Sidebar -----
    local sidebar = CreateFrame("Frame", nil, UI)
    sidebar:SetPoint("TOPLEFT", 1, -1)
    sidebar:SetPoint("BOTTOMLEFT", 1, 1)
    sidebar:SetWidth(SIDEBAR_W)
    MakeDraggable(sidebar)

    local sbg = Solid(sidebar)
    sbg:SetAllPoints()
    sbg:SetVertexColor(unpack(COLORS.panel))
    local sdiv = Solid(sidebar, "OVERLAY")
    sdiv:SetPoint("TOPRIGHT")
    sdiv:SetPoint("BOTTOMRIGHT")
    sdiv:SetWidth(1)
    sdiv:SetVertexColor(unpack(COLORS.line))

    local logo = RoundIcon(sidebar, NS.ICON_ID, 24)
    logo:SetPoint("TOPLEFT", 14, -13)
    local sTitle = Label(sidebar, 12)
    sTitle:SetPoint("LEFT", logo, "RIGHT", 9, 0) -- centré verticalement avec le logo
    sTitle:SetWidth(SIDEBAR_W - 46)
    sTitle:SetJustifyH("LEFT")
    sTitle:SetWordWrap(false)
    sTitle:SetText(NS.L("Asylum Compagnon"))

    -- navigation
    local NAV = {
        { key = "home",   label = "Accueil" },
        { key = "optim",  label = "Optimisation" },
        { key = "inv",    label = "Inventaire" },
        { key = "kicks",  label = "Kicks" },
        { key = "cd",     label = "Cooldowns" },
        { key = "stats",  label = "Stats M+" },
        { key = "buffs",  label = "Buffs & auras" },
        -- Menu « Butin » masqué pour l'instant (groupe full stuff → peu utile).
        -- Le code (Loot.lua + page) reste intact : décommenter la ligne ci-dessous
        -- pour le réactiver quand le loot redeviendra intéressant.
        -- { key = "loot",   label = "Butin" },
        { key = "vault",  label = "Clés & Coffre" },
        { key = "toolbox", label = "Toolbox" },
        { key = "alerts", label = "Alertes" },
        { key = "diag",   label = "Diag" },
    }
    local function SetNavActive(activeKey)
        for key, btn in pairs(navButtons) do
            local on = (key == activeKey)
            btn.bar:SetShown(on)
            btn.text:SetTextColor(unpack(on and COLORS.text or COLORS.muted))
        end
    end
    for i, item in ipairs(NAV) do
        local btn = CreateFrame("Button", nil, sidebar)
        btn:SetHeight(32)
        btn:SetPoint("TOPLEFT", 0, -(58 + (i - 1) * 34))
        btn:SetPoint("TOPRIGHT", -1, -(58 + (i - 1) * 34))
        local hl = btn:CreateTexture(nil, "HIGHLIGHT")
        hl:SetTexture(WHITE)
        hl:SetAllPoints()
        hl:SetVertexColor(1, 1, 1, 0.04)
        btn.bar = Solid(btn, "ARTWORK")
        btn.bar:SetPoint("TOPLEFT")
        btn.bar:SetPoint("BOTTOMLEFT")
        btn.bar:SetWidth(3)
        btn.bar:SetVertexColor(unpack(COLORS.accent))
        btn.bar:Hide()
        btn.text = Label(btn, 12, COLORS.muted)
        btn.text:SetPoint("LEFT", 16, 0)
        btn.text:SetText(NS.L(item.label))
        btn:SetScript("OnClick", function() ShowPage(item.key) end)
        navButtons[item.key] = btn
    end

    -- pied de sidebar : état de la synchro + version
    local syncDot = Circle(sidebar, 7, "ARTWORK")
    syncDot:SetPoint("BOTTOMLEFT", 15, 30)
    local syncLabel = Label(sidebar, 10, COLORS.muted)
    syncLabel:SetPoint("LEFT", syncDot, "RIGHT", 6, 0)
    local sVersion = Label(sidebar, 10, COLORS.muted)
    sVersion:SetPoint("BOTTOMLEFT", 14, 12)

    -- ----- Barre du haut -----
    local topbar = CreateFrame("Frame", nil, UI)
    topbar:SetPoint("TOPLEFT", SIDEBAR_W + 1, -1)
    topbar:SetPoint("TOPRIGHT", -1, -1)
    topbar:SetHeight(TOPBAR_H)
    MakeDraggable(topbar)

    local tdiv = Solid(topbar, "OVERLAY")
    tdiv:SetPoint("BOTTOMLEFT")
    tdiv:SetPoint("BOTTOMRIGHT")
    tdiv:SetHeight(1)
    tdiv:SetVertexColor(unpack(COLORS.line))

    local pageTitle = Label(topbar, 15)
    pageTitle:SetPoint("LEFT", 16, 0)

    local close = CreateFrame("Button", nil, topbar)
    close:SetSize(30, 30)
    close:SetPoint("RIGHT", -8, 0)
    local cbg = Solid(close)
    cbg:SetAllPoints()
    cbg:SetVertexColor(1, 0.3, 0.35, 0)
    -- croix « × » DESSINÉE (2 traits diagonaux) : même gabarit 12x12 et même
    -- épaisseur de trait que le carré « agrandir », pour un rendu cohérent.
    local xf = CreateFrame("Frame", nil, close)
    xf:SetSize(12, 12)
    xf:SetPoint("CENTER")
    local xlines = {}
    for _, ang in ipairs({ 45, -45 }) do
        local ln = xf:CreateTexture(nil, "ARTWORK")
        ln:SetTexture(WHITE)
        ln:SetSize(16, 1.7)
        ln:SetPoint("CENTER")
        ln:SetRotation(math.rad(ang))
        xlines[#xlines + 1] = ln
    end
    local function XColor(...) for _, ln in ipairs(xlines) do ln:SetVertexColor(...) end end
    XColor(unpack(COLORS.muted))
    close:SetScript("OnEnter", function()
        cbg:SetVertexColor(1, 0.3, 0.35, 0.16)
        XColor(1, 1, 1)
        GameTooltip:SetOwner(close, "ANCHOR_BOTTOM")
        GameTooltip:SetText(NS.L("Fermer"))
        GameTooltip:Show()
    end)
    close:SetScript("OnLeave", function()
        cbg:SetVertexColor(1, 0.3, 0.35, 0)
        XColor(unpack(COLORS.muted))
        GameTooltip:Hide()
    end)
    close:SetScript("OnClick", function() UI:Hide() end)

    -- bouton « taille max » (juste à gauche de la croix) : bascule entre la
    -- taille normale et la taille max fixée (échelle 1.6), en un clic.
    local MAX_SCALE = 1.6
    local maxBtn = CreateFrame("Button", nil, topbar)
    maxBtn:SetSize(30, 30)
    maxBtn:SetPoint("RIGHT", close, "LEFT", -2, 0)
    local mbg = Solid(maxBtn)
    mbg:SetAllPoints()
    mbg:SetVertexColor(1, 1, 1, 0)
    -- carré vide (style « agrandir »), MÊME gabarit 12x12 et MÊME épaisseur de
    -- trait (1.5) que la croix « × », pour que les deux icônes soient cohérentes.
    local sq = CreateFrame("Frame", nil, maxBtn)
    sq:SetSize(12, 12)
    sq:SetPoint("CENTER")
    local edges = {}
    for _, e in ipairs({ "TOP", "BOTTOM", "LEFT", "RIGHT" }) do
        local t = sq:CreateTexture(nil, "ARTWORK")
        t:SetTexture(WHITE)
        if e == "TOP" then t:SetPoint("TOPLEFT"); t:SetPoint("TOPRIGHT"); t:SetHeight(1.5)
        elseif e == "BOTTOM" then t:SetPoint("BOTTOMLEFT"); t:SetPoint("BOTTOMRIGHT"); t:SetHeight(1.5)
        elseif e == "LEFT" then t:SetPoint("TOPLEFT"); t:SetPoint("BOTTOMLEFT"); t:SetWidth(1.5)
        else t:SetPoint("TOPRIGHT"); t:SetPoint("BOTTOMRIGHT"); t:SetWidth(1.5) end
        edges[#edges + 1] = t
    end
    local function SqColor(...) for _, t in ipairs(edges) do t:SetVertexColor(...) end end
    SqColor(unpack(COLORS.muted))
    maxBtn:SetScript("OnEnter", function()
        mbg:SetVertexColor(1, 1, 1, 0.10)
        SqColor(1, 1, 1)
        GameTooltip:SetOwner(maxBtn, "ANCHOR_BOTTOM")
        GameTooltip:SetText(NS.L("Taille max / normale"))
        GameTooltip:Show()
    end)
    maxBtn:SetScript("OnLeave", function()
        mbg:SetVertexColor(1, 1, 1, 0)
        SqColor(unpack(COLORS.muted))
        GameTooltip:Hide()
    end)
    maxBtn:SetScript("OnClick", function()
        local atMax = UI:GetScale() >= (MAX_SCALE - 0.01)
        UI:SetScale(atMax and 1.0 or MAX_SCALE)
        if NS.db then
            NS.db.uiScale = UI:GetScale()
            local p, _, rp, x, y = UI:GetPoint(1)
            NS.db.uiPos = { p = p, rp = rp, x = x, y = y }
        end
    end)

    -- ----- Conteneur de pages -----
    local content = CreateFrame("Frame", nil, UI)
    content:SetPoint("TOPLEFT", SIDEBAR_W + 1, -(TOPBAR_H + 1))
    content:SetPoint("BOTTOMRIGHT", -1, 1)

    local PAGE_TITLES = {
        home = "Accueil",
        optim = "Optimisation", detail = "Optimisation",
        inv = "Inventaire", invdetail = "Inventaire",
        kicks = "Kicks", cd = "Cooldowns de groupe", buffs = "Buffs & auras",
        loot = "Butin", vault = "Clés & Coffre", alerts = "Alertes",
        toolbox = "Toolbox", stats = "Statistiques Mythique+",
        diag = "Diag",
    }
    local NAV_FOR_PAGE = { detail = "optim", invdetail = "inv" }

    ShowPage = function(key)
        -- changer d'onglet coupe TOUS les modes aperçu (Kick, CD, message Bloodlust)
        if NS.Kicks and NS.Kicks.SetPreview then NS.Kicks.SetPreview(false) end
        if NS.Cooldowns and NS.Cooldowns.SetPreview then NS.Cooldowns.SetPreview(false) end
        if NS.Stats and NS.Stats.IsBLMoving and NS.Stats.IsBLMoving()
            and NS.Stats.ToggleBLMove then NS.Stats.ToggleBLMove() end
        if NS.HideCDClassWindow then NS.HideCDClassWindow() end  -- ferme la fenêtre de classe CD
        for k, p in pairs(pages) do p:SetShown(k == key) end
        pageTitle:SetText(NS.L(PAGE_TITLES[key] or ""))
        SetNavActive(NAV_FOR_PAGE[key] or key)
        if key == "home" and UpdateHomePage then UpdateHomePage() end
        if key == "optim" then
            if refreshOptimList then refreshOptimList() end
            if NS.RefreshGearCompassUI then NS.RefreshGearCompassUI() end
        end
        if key == "inv" and refreshInvList then
            if NS.RequestGuildRoster then NS.RequestGuildRoster() end -- classer Guilde
            if NS.RefreshTrackedItems then NS.RefreshTrackedItems() end
            refreshInvList()
        end
        if key == "kicks" and UpdateKicksPage then UpdateKicksPage() end
        if key == "cd" and UpdateCDPage then UpdateCDPage() end
        if key == "buffs" and RefreshBuffsPage then RefreshBuffsPage() end
        if key == "loot" and RefreshLootPage then RefreshLootPage() end
        if key == "vault" and RefreshVaultPage then
            if NS.RequestGuildRoster then NS.RequestGuildRoster() end -- roster guilde à jour
            RefreshVaultPage()
        end
        if key == "alerts" and UpdateAlertsPage then UpdateAlertsPage() end
        if key == "toolbox" and UpdateToolboxPage then UpdateToolboxPage() end
        if key == "stats" and NS.RefreshStatsPage then NS.RefreshStatsPage() end
    end

    -- ============================================================
    -- Liste de personnages réutilisable (Optimisation + Inventaire)
    -- Ordre : toi d'abord, puis en ligne, puis par dernière synchro.
    -- ============================================================
    local function BuildEntries()
        local myMeta = (NS.GetMyMeta and NS.GetMyMeta()) or {}
        local mp = NS.db and NS.db.myProfile
        local me = {
            key = "ME", display = UnitName("player") or "?",
            class = (mp and mp.class) or myMeta.class,
            level = (mp and mp.level) or myMeta.level,
            specID = (mp and mp.specID) or myMeta.specID,
            ilvl = (mp and mp.ilvl) or myMeta.ilvl,
            ts = mp and mp.ts, online = true, isMe = true,
        }
        local peers = {}
        for k, p in pairs((NS.db and NS.db.peers) or {}) do
            peers[#peers + 1] = {
                key = k, display = k:match("^(.-)%-") or k,
                class = p.class, level = p.level, specID = p.specID,
                ilvl = p.ilvl, ts = p.ts, online = p.online,
                lastSeen = p.lastSeen,
            }
        end
        table.sort(peers, function(a, b)
            if (a.online and 1 or 0) ~= (b.online and 1 or 0) then
                return a.online and true or false
            end
            local ra = math.max(a.lastSeen or 0, a.ts or 0)
            local rb = math.max(b.lastSeen or 0, b.ts or 0)
            if ra ~= rb then return ra > rb end
            return a.display < b.display
        end)
        local list = { me }
        for _, p in ipairs(peers) do list[#list + 1] = p end
        return list
    end

    -- Liste de personnages de l'onglet OPTIMISATION, EN SECTIONS et avec
    -- HISTORIQUE complet (contrairement à l'inventaire) : on garde le SimC des
    -- persos hors ligne pour pouvoir les simuler. Ordre :
    -- Mon personnage · Groupe (en ligne) · Guilde (en ligne) · Rerolls ·
    -- Historique (guilde/groupe hors ligne, par dernière synchro).
    -- Défile (ScrollFrame) pour encaisser un historique qui grandit.
    local function MakeCharList(page, onClick)
        local scroll = CreateFrame("ScrollFrame", nil, page, "UIPanelScrollFrameTemplate")
        scroll:SetPoint("TOPLEFT", 20, -20)
        scroll:SetPoint("BOTTOMRIGHT", -34, 40)
        local child = CreateFrame("Frame", nil, scroll)
        child:SetSize(10, 10)
        scroll:SetScrollChild(child)

        local emptyState = Label(child, 11, COLORS.muted)
        emptyState:SetText(NS.L("Aucun ami détecté.\nRejoins un groupe ou une guilde où l'addon est installé."))
        emptyState:SetJustifyH("LEFT")
        emptyState:Hide()

        local rows, heads = {}, {}
        local function getHead(i)
            if heads[i] then return heads[i] end
            local h = Label(child, 11); h:SetJustifyH("LEFT"); heads[i] = h; return h
        end
        local function getRow(i)
            if rows[i] then return rows[i] end
            local row = CreateFrame("Button", nil, child)
            row:SetHeight(44)
            local rbg = Solid(row); rbg:SetAllPoints(); rbg:SetVertexColor(unpack(COLORS.panel))
            Border(row, COLORS.line)
            local hl = row:CreateTexture(nil, "HIGHLIGHT"); hl:SetTexture(WHITE); hl:SetAllPoints(); hl:SetVertexColor(1, 1, 1, 0.04)
            row.dot = Circle(row, 8, "ARTWORK"); row.dot:SetPoint("LEFT", 14, 0)
            row.name = Label(row, 13); row.name:SetPoint("TOPLEFT", 32, -8)
            row.meta = Label(row, 10, COLORS.muted); row.meta:SetPoint("BOTTOMLEFT", 32, 8)
            row.chev = Label(row, 14, COLORS.muted); row.chev:SetPoint("RIGHT", -14, 0); row.chev:SetText("»")
            row:SetScript("OnClick", function(self) onClick(self.key) end)
            rows[i] = row
            return row
        end
        -- membres de mon groupe courant (party/raid), clés « Nom-Royaume »
        local function groupSet()
            local set = {}
            if IsInRaid() then
                for i = 1, 40 do local k = NS.UnitKey("raid" .. i); if k then set[k] = true end end
            elseif IsInGroup() then
                for i = 1, 4 do local k = NS.UnitKey("party" .. i); if k then set[k] = true end end
            end
            return set
        end

        return function()
            local gs = groupSet()
            local meKey = NS.UnitKey("player")
            local mp = NS.db and NS.db.myProfile
            local myMeta = (NS.GetMyMeta and NS.GetMyMeta()) or {}
            local me = { key = "ME", display = UnitName("player") or "?",
                class = (mp and mp.class) or myMeta.class, level = (mp and mp.level) or myMeta.level,
                ilvl = (mp and mp.ilvl) or myMeta.ilvl, specID = (mp and mp.specID) or myMeta.specID,
                isMe = true, online = true }

            -- pairs : en ligne → Groupe (si dans mon groupe) sinon Guilde ; hors ligne → Historique
            local group, guild, hist = {}, {}, {}
            for k, p in pairs((NS.db and NS.db.peers) or {}) do
                local e = { key = k, display = k:match("^(.-)%-") or k, class = p.class, level = p.level,
                    ilvl = p.ilvl, specID = p.specID, online = p.online, ts = p.ts, lastSeen = p.lastSeen }
                if p.online then
                    if gs[k] then group[#group + 1] = e else guild[#guild + 1] = e end
                else
                    hist[#hist + 1] = e
                end
            end
            -- mes rerolls (autres persos de l'historique de compte)
            local reroll = {}
            for k, c in pairs((NS.db and NS.db.myChars) or {}) do
                if k ~= meKey then
                    reroll[#reroll + 1] = { key = k, display = c.name or (k:match("^([^%-]+)")) or k,
                        class = c.class, level = c.level, ilvl = c.ilvl, specID = c.specID,
                        isReroll = true, ts = c.ts, lastSeen = c.ts }
                end
            end
            local byName = function(a, b) return (a.display or "") < (b.display or "") end
            local byRecent = function(a, b)
                return math.max(a.lastSeen or 0, a.ts or 0) > math.max(b.lastSeen or 0, b.ts or 0)
            end
            table.sort(group, byName); table.sort(guild, byName)
            table.sort(reroll, byRecent); table.sort(hist, byRecent)

            local sections = {
                { title = NS.L("Mon personnage"), accent = COLORS.accent, list = { me } },
                { title = NS.L("Groupe"),  accent = COLORS.accent, list = group },
                { title = NS.L("Guilde"),  accent = COLORS.green,  list = guild },
                { title = NS.L("Rerolls"), accent = COLORS.pink,   list = reroll },
                { title = NS.L("Historique"), accent = COLORS.gray, list = hist },
            }

            for _, r in ipairs(rows) do r:Hide() end
            for _, h in ipairs(heads) do h:Hide() end
            local width = math.max((scroll:GetWidth() or 0), 200)
            child:SetWidth(width)
            local y, ri, hi = 0, 0, 0
            for _, sec in ipairs(sections) do
                if #sec.list > 0 then
                    hi = hi + 1
                    local h = getHead(hi)
                    h:ClearAllPoints(); h:SetPoint("TOPLEFT", 2, -y)
                    h:SetText(("%s  |cff6a6f7a%d|r"):format(sec.title, #sec.list))
                    h:SetTextColor(unpack(sec.accent))
                    h:Show()
                    y = y + 22
                    for _, e in ipairs(sec.list) do
                        ri = ri + 1
                        local row = getRow(ri)
                        row:ClearAllPoints()
                        row:SetPoint("TOPLEFT", 0, -y); row:SetPoint("TOPRIGHT", 0, -y)
                        row.key = e.key
                        row.name:SetText(("|c%s%s|r"):format(ClassColorStr(e.class), e.display))
                        local bits = { ClassName(e.class) }
                        if e.level and e.level > 0 then bits[#bits + 1] = NS.L("Niveau ") .. e.level end
                        if e.ilvl and e.ilvl > 0 then bits[#bits + 1] = "ilvl " .. e.ilvl end
                        local spec = SpecName(e.specID); if spec then bits[#bits + 1] = spec end
                        if not e.online then
                            local seen = RelTime(math.max(e.lastSeen or 0, e.ts or 0))
                            if seen then bits[#bits + 1] = NS.L("vu ") .. seen end
                        end
                        row.meta:SetText(table.concat(bits, " · "))
                        row.dot:SetVertexColor(unpack(e.isMe and COLORS.accent or (e.online and COLORS.green or COLORS.gray)))
                        row:Show()
                        y = y + 48
                    end
                    y = y + 8
                end
            end
            if (#group + #guild + #reroll + #hist) == 0 then
                emptyState:ClearAllPoints(); emptyState:SetPoint("TOPLEFT", 2, -y - 2); emptyState:Show()
                y = y + 60
            else
                emptyState:Hide()
            end
            child:SetHeight(math.max(y, 10))
        end
    end

    -- ============================================================
    -- PAGE : ACCUEIL — tableau de bord (fonction dédiée, hors BuildUI)
    -- ============================================================
    UpdateHomePage = BuildHomePage(content, pages, function(k) ShowPage(k) end)

    -- ============================================================
    -- PAGE : OPTIMISATION — liste
    -- ============================================================
    local optim = CreateFrame("Frame", nil, content)
    optim:SetAllPoints()
    optim:Hide()
    pages.optim = optim

    -- Deux sous-onglets dans la page existante. Raidbots conserve intégralement
    -- la liste et le détail SimC historiques ; GearCompass ajoute son export local.
    local opt = {}
    opt.tabs = CreateFrame("Frame", nil, optim)
    opt.tabs:SetPoint("TOPLEFT", 20, -8)
    opt.tabs:SetPoint("TOPRIGHT", -20, -8)
    opt.tabs:SetHeight(26)
    opt.raidbotsTab = MakeCompactButton(opt.tabs, "Raidbots", 82, 22)
    opt.raidbotsTab:SetPoint("TOPLEFT")
    opt.gearCompassTab = MakeCompactButton(opt.tabs, "GearCompass", 106, 22)
    opt.gearCompassTab:SetPoint("LEFT", opt.raidbotsTab, "RIGHT", 6, 0)
    opt.raidbotsLine = Solid(opt.raidbotsTab, "ARTWORK")
    opt.raidbotsLine:SetPoint("BOTTOMLEFT", 4, 0); opt.raidbotsLine:SetPoint("BOTTOMRIGHT", -4, 0); opt.raidbotsLine:SetHeight(2)
    opt.raidbotsLine:SetVertexColor(unpack(COLORS.accent))
    opt.gearCompassLine = Solid(opt.gearCompassTab, "ARTWORK")
    opt.gearCompassLine:SetPoint("BOTTOMLEFT", 4, 0); opt.gearCompassLine:SetPoint("BOTTOMRIGHT", -4, 0); opt.gearCompassLine:SetHeight(2)
    opt.gearCompassLine:SetVertexColor(unpack(COLORS.accent))

    opt.raidbotsPanel = CreateFrame("Frame", nil, optim)
    opt.raidbotsPanel:SetPoint("TOPLEFT", 0, -34)
    opt.raidbotsPanel:SetPoint("BOTTOMRIGHT")
    opt.gearCompassPanel = CreateFrame("Frame", nil, optim)
    opt.gearCompassPanel:SetPoint("TOPLEFT", 0, -34)
    opt.gearCompassPanel:SetPoint("BOTTOMRIGHT")

    refreshOptimList = MakeCharList(opt.raidbotsPanel, function(key) ShowDetail(key) end)
    if NS.GearCompassUI and NS.GearCompassUI.Build then
        NS.GearCompassUI.Build(opt.gearCompassPanel, {
            Label = Label, MakeButton = MakeCompactButton, MakeCopyBox = MakeCopyBox,
            Solid = Solid, Border = Border, COLORS = COLORS, URL = GEARCOMPASS_URL,
        })
    end

    opt.Select = function(tab)
        if tab == ("tank" .. "gear") then tab = "gearcompass" end
        tab = tab == "gearcompass" and "gearcompass" or "raidbots"
        if NS.db then NS.db.optimizationTab = tab end
        opt.raidbotsPanel:SetShown(tab == "raidbots")
        opt.gearCompassPanel:SetShown(tab == "gearcompass")
        opt.raidbotsLine:SetShown(tab == "raidbots")
        opt.gearCompassLine:SetShown(tab == "gearcompass")
        SetOptimizationTabActive(opt.raidbotsTab, tab == "raidbots")
        SetOptimizationTabActive(opt.gearCompassTab, tab == "gearcompass")
        if tab == "raidbots" then refreshOptimList()
        elseif NS.RefreshGearCompassUI then NS.RefreshGearCompassUI() end
    end
    opt.raidbotsTab:SetScript("OnClick", function() opt.Select("raidbots") end)
    opt.gearCompassTab:SetScript("OnClick", function() opt.Select("gearcompass") end)
    NS.SelectOptimizationTab = opt.Select
    opt.Select(NS.db and NS.db.optimizationTab or "raidbots")

    -- ============================================================
    -- PAGE : OPTIMISATION — détail (code SimC)
    -- ============================================================
    local detail = CreateFrame("Frame", nil, content)
    detail:SetAllPoints()
    detail:Hide()
    pages.detail = detail

    local back = MakeCompactButton(detail, NS.L("« Retour"), 72, 20)
    back:SetPoint("TOPLEFT", 20, -12)
    back:SetScript("OnClick", function() ShowPage("optim") end)

    local dName = Label(detail, 16)
    dName:SetPoint("TOPLEFT", 20, -46)

    local dMeta = Label(detail, 11, COLORS.muted)
    dMeta:SetPoint("TOPLEFT", 20, -68)
    dMeta:SetWidth(400)
    dMeta:SetJustifyH("LEFT")

    local refresh = MakeCompactButton(detail, NS.L("Actualiser"), 82, 20)
    refresh:SetPoint("TOPRIGHT", -20, -44)

    local dStatus = Label(detail, 10, COLORS.muted)
    dStatus:SetPoint("TOPRIGHT", -20, -76)

    local step1 = Label(detail, 12)
    step1:SetPoint("TOPLEFT", 20, -92)
    step1:SetText((NS.L("|cff%s1|r · Ouvre Raidbots :")):format(NS.ACCENT))

    local urlContainer = CreateFrame("Frame", nil, detail)
    urlContainer:SetHeight(26)
    urlContainer:SetPoint("TOPLEFT", 20, -110)
    urlContainer:SetPoint("TOPRIGHT", -20, -110)
    local ubg = Solid(urlContainer)
    ubg:SetAllPoints()
    ubg:SetVertexColor(unpack(COLORS.field))
    Border(urlContainer, COLORS.line)
    local urlBox = MakeCopyBox(urlContainer, false)
    urlBox:SetPoint("TOPLEFT", 8, 0)
    urlBox:SetPoint("BOTTOMRIGHT", -8, 0)
    urlBox:SetLockedText(RAIDBOTS_URL)
    urlContainer:EnableMouse(true)
    urlContainer:SetScript("OnMouseDown", function() urlBox:SetFocus() end)

    local step2 = Label(detail, 12)
    step2:SetPoint("TOPLEFT", 20, -150)
    step2:SetText((NS.L("|cff%s2|r · Copie le code (clic = tout sélectionner, puis Ctrl+C) :")):format(NS.ACCENT))

    local codeContainer = CreateFrame("Frame", nil, detail)
    codeContainer:SetPoint("TOPLEFT", 20, -168)
    codeContainer:SetPoint("BOTTOMRIGHT", -20, 38)
    local cobg = Solid(codeContainer)
    cobg:SetAllPoints()
    cobg:SetVertexColor(unpack(COLORS.field))
    Border(codeContainer, COLORS.line)

    local scroll = CreateFrame("ScrollFrame", nil, codeContainer, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", 8, -8)
    scroll:SetPoint("BOTTOMRIGHT", -28, 8)
    local codeBox = MakeCopyBox(scroll, true)
    codeBox:SetWidth(530)
    codeBox:SetHeight(60)
    scroll:SetScrollChild(codeBox)
    scroll:SetScript("OnSizeChanged", function(self)
        codeBox:SetWidth(self:GetWidth())
    end)
    codeContainer:EnableMouse(true)
    codeContainer:SetScript("OnMouseDown", function() codeBox:SetFocus() end)

    local step3 = Label(detail, 10, COLORS.muted)
    step3:SetPoint("BOTTOMLEFT", 20, 16)
    step3:SetText((NS.L("|cff%s3|r · Colle sur Raidbots et lance la simulation.")):format(NS.ACCENT))

    local function SetCode(text)
        codeBox:SetLockedText(text or "")
        scroll:SetVerticalScroll(0)
    end

    FillDetail = function(key)
        if key == "ME" then
            if not (NS.db and NS.db.myProfile) and NS.UpdateMyProfile then
                NS.UpdateMyProfile(false)
            end
            local mp = NS.db and NS.db.myProfile
            local myMeta = (NS.GetMyMeta and NS.GetMyMeta()) or {}
            local class = (mp and mp.class) or myMeta.class
            dName:SetText(("|c%s%s|r"):format(ClassColorStr(class), UnitName("player") or "?"))
            local bits = { ClassName(class) }
            local lvl = (mp and mp.level) or myMeta.level
            local ilvl = (mp and mp.ilvl) or myMeta.ilvl
            if lvl and lvl > 0 then bits[#bits + 1] = NS.L("Niveau ") ..lvl end
            if ilvl and ilvl > 0 then bits[#bits + 1] = "ilvl " .. ilvl end
            local spec = SpecName((mp and mp.specID) or myMeta.specID)
            if spec then bits[#bits + 1] = spec end
            dMeta:SetText(table.concat(bits, " · "))
            SetCode(mp and mp.simc or NS.L("# Impossible de générer le code pour l'instant."))
            dStatus:SetText(mp and mp.ts and (NS.L("Code généré ") .. (RelTime(mp.ts) or "")) or "")
        else
            -- pair (autre joueur, reçu par synchro) OU reroll (mon perso, myChars)
            local p = NS.db and NS.db.peers and NS.db.peers[key]
            local rc = (not p) and NS.db and NS.db.myChars and NS.db.myChars[key]
            local src = p or rc
            if not src then ShowPage("optim") return end
            local display = key:match("^(.-)%-") or key
            dName:SetText(("|c%s%s|r"):format(ClassColorStr(src.class), display))
            local bits = { ClassName(src.class) }
            if src.level and src.level > 0 then bits[#bits + 1] = NS.L("Niveau ") ..src.level end
            if src.ilvl and src.ilvl > 0 then bits[#bits + 1] = "ilvl " .. src.ilvl end
            local spec = SpecName(src.specID)
            if spec then bits[#bits + 1] = spec end
            if p then
                bits[#bits + 1] = p.online and ("|cff22c55e" .. NS.L("en ligne") .. "|r") or NS.L("hors ligne")
            else
                bits[#bits + 1] = NS.L("reroll")
            end
            dMeta:SetText(table.concat(bits, " · "))
            if src.simc then
                SetCode(src.simc)
                if p then
                    local stale = p.profileTs and p.ts and p.profileTs > p.ts
                    dStatus:SetText(((NS.L("Code reçu %s")):format(RelTime(p.ts) or ""))
                        .. (stale and NS.L("  |cffeab308(plus récent dispo)|r") or ""))
                else
                    dStatus:SetText(NS.L("Code généré ") .. (RelTime(rc.ts) or ""))
                end
            else
                SetCode(NS.L("# Pas encore de code reçu pour ce personnage.\n")
                    .. NS.L("# Il doit être en ligne avec l'addon installé.\n")
                    .. NS.L("# Clique « Actualiser » pour lui demander son code."))
                dStatus:SetText("")
            end
        end
    end

    refresh:SetScript("OnClick", function()
        if not currentDetail then return end
        if currentDetail == "ME" then
            if NS.UpdateMyProfile then NS.UpdateMyProfile(true) end
            FillDetail("ME")
        else
            local isReroll = NS.db and NS.db.myChars and NS.db.myChars[currentDetail]
                and not (NS.db.peers and NS.db.peers[currentDetail])
            if isReroll then
                dStatus:SetText(NS.L("Reconnecte-toi sur ce perso pour régénérer son code."))
            elseif NS.Sync and NS.Sync.available then
                NS.Sync.RequestProfile(currentDetail)
                dStatus:SetText(NS.L("demande envoyée…"))
            else
                dStatus:SetText(NS.L("synchro indisponible"))
            end
        end
    end)

    ShowDetail = function(key)
        currentDetail = key
        FillDetail(key)
        ShowPage("detail")
        -- demande auto si le pair est en ligne et que son code est absent/dépassé
        if key ~= "ME" and NS.Sync and NS.Sync.available then
            local p = NS.db and NS.db.peers and NS.db.peers[key]
            if p and p.online and (not p.simc or ((p.profileTs or 0) > (p.ts or 0))) then
                NS.Sync.RequestProfile(key)
                dStatus:SetText(NS.L("demande envoyée…"))
            end
        end
    end

    -- ============================================================
    -- PAGE : INVENTAIRE — liste
    -- ============================================================
    local inv = CreateFrame("Frame", nil, content)
    inv:SetAllPoints()
    inv:Hide()
    pages.inv = inv

    -- clé "Nom-Royaume" d'une unité / de soi : utilitaire partagé (Core.lua)
    local UnitKeyOf = NS.UnitKey
    -- set des clés du groupe (party/raid), self inclus
    local function GroupKeySet()
        local set = {}
        local me = UnitKeyOf("player"); if me then set[me] = true end
        if IsInRaid() then
            for i = 1, 40 do local k = UnitKeyOf("raid" .. i); if k then set[k] = true end end
        elseif IsInGroup() then
            for i = 1, 4 do local k = UnitKeyOf("party" .. i); if k then set[k] = true end end
        end
        return set
    end
    -- set des membres de ma guilde (nom complet « Nom-Royaume » + nom court, repli
    -- robuste aux formats de royaume) — pour classer un pair en ligne hors groupe.
    local function GuildKeySet()
        local set = {}
        if not (IsInGuild and IsInGuild()) then return set end
        local n = (GetNumGuildMembers and GetNumGuildMembers()) or 0
        for i = 1, n do
            local full = GetGuildRosterInfo and GetGuildRosterInfo(i)
            if full and full ~= "" then
                set[full] = true
                local bare = full:match("^([^%-]+)"); if bare then set[bare] = true end
            end
        end
        return set
    end
    -- mes rerolls : les AUTRES persos dont l'addon a un historique d'inventaire
    -- (base compte NS.db.myChars), triés du plus récemment vu au plus ancien.
    local function RerollChars()
        local out, myKey = {}, UnitKeyOf("player")
        for k, c in pairs((NS.db and NS.db.myChars) or {}) do
            if k ~= myKey then
                out[#out + 1] = { key = k, name = c.name or (k:match("^([^%-]+)")) or k,
                    class = c.class, ts = c.ts, items = c.items or {} }
            end
        end
        table.sort(out, function(a, b) return (a.ts or 0) > (b.ts or 0) end)
        return out
    end
    local function ItemNameOf(id)
        if not id then return "" end
        local n = (C_Item and C_Item.GetItemNameByID and C_Item.GetItemNameByID(id))
            or (GetItemInfo and GetItemInfo(id))
        return n or ("#" .. tostring(id))
    end

    local invFilter = nil -- itemID sélectionné (filtre du panneau droit), ou nil
    local invCat = "flask" -- famille affichée à gauche : flask|potion|food|oil (une seule à la fois → jamais de débordement)

    -- ----- Panneau gauche : TOUS les consommables suivis, par catégorie (clic → filtre) -----
    local invLeftTitle = Label(inv, 12)
    invLeftTitle:SetPoint("TOPLEFT", 20, -14)
    invLeftTitle:SetText(NS.L("Consommables"))

    -- Filtres par famille (maquette B) : on n'affiche QU'UNE catégorie à la fois,
    -- donc la liste tient toujours dans le cadre sans déborder ni ascenseur. La
    -- puce active fait office de titre de section (plus besoin d'en-tête doré).
    local INV_CHIP = { { "flask", "Flacons" }, { "potion", "Potions" },
        { "food", "Nourriture" }, { "oil", "Huiles" }, { "other", "Divers" } }
    local invChips = {}
    do
        local cx = 18
        for _, e in ipairs(INV_CHIP) do
            local chip = MakeButton(inv, NS.L(e[2]), 44, 18)
            local lw = chip.label and chip.label:GetStringWidth() or 0
            if not lw or lw < 8 then lw = 40 end
            lw = math.floor(lw) + 12
            chip:SetWidth(lw)
            chip:ClearAllPoints(); chip:SetPoint("TOPLEFT", cx, -32)
            chip.ul = Solid(chip, "OVERLAY")
            chip.ul:SetPoint("BOTTOMLEFT", 1, 1); chip.ul:SetPoint("BOTTOMRIGHT", -1, 1)
            chip.ul:SetHeight(2); chip.ul:SetVertexColor(unpack(COLORS.accent)); chip.ul:Hide()
            chip.catKey = e[1]
            chip:SetScript("OnClick", function()
                invCat = e[1]
                if refreshInvList then refreshInvList() end
            end)
            invChips[#invChips + 1] = chip
            cx = cx + lw + 4
        end
    end

    -- en-têtes de catégorie (poolés) + libellés
    local invLeftHeads = {}
    local function getInvLeftHead(i)
        if invLeftHeads[i] then return invLeftHeads[i] end
        local h = Label(inv, 11, GOLD); h:SetJustifyH("LEFT"); invLeftHeads[i] = h; return h
    end
    local INV_CAT_LABEL = { flask = "Flacons", potion = "Potions", food = "Nourriture", oil = "Huiles" }

    local myItemRows = {}
    for i, t in ipairs(NS.TRACKED_ITEMS or {}) do
        local id = t.ids and t.ids[1]
        local row = CreateFrame("Button", nil, inv)
        row:SetSize(272, 26)
        row:Hide()
        local rbg = Solid(row); rbg:SetAllPoints(); rbg:SetVertexColor(unpack(COLORS.panel))
        Border(row, COLORS.line)
        row.sel = Solid(row, "ARTWORK")
        row.sel:SetPoint("TOPLEFT"); row.sel:SetPoint("BOTTOMLEFT"); row.sel:SetWidth(3)
        row.sel:SetVertexColor(unpack(COLORS.accent)); row.sel:Hide()
        local hl = row:CreateTexture(nil, "HIGHLIGHT"); hl:SetTexture(WHITE); hl:SetAllPoints(); hl:SetVertexColor(1, 1, 1, 0.05)
        row.icon = row:CreateTexture(nil, "ARTWORK")
        row.icon:SetSize(18, 18); row.icon:SetPoint("LEFT", 8, 0); row.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)
        row.name = Label(row, 11)
        row.name:SetPoint("LEFT", row.icon, "RIGHT", 8, 0); row.name:SetWidth(180); row.name:SetJustifyH("LEFT"); row.name:SetWordWrap(false)
        row.name:SetText(t.fallback)
        row.count = Label(row, 12); row.count:SetPoint("RIGHT", -8, 0)
        row.itemID = id
        if id and Item and Item.CreateFromItemID then
            local it = Item:CreateFromItemID(id)
            it:ContinueOnItemLoad(function()
                row.name:SetText(it:GetItemName() or t.fallback)
                row.icon:SetTexture(it:GetItemIcon())
            end)
        end
        row:SetScript("OnClick", function()
            invFilter = (invFilter == id) and nil or id
            if refreshInvList then refreshInvList() end
        end)
        myItemRows[i] = row
    end

    -- ----- Panneau droit : détenteurs (groupe) / liste des persos -----
    local invRightTitle = Label(inv, 12)
    invRightTitle:SetPoint("TOPLEFT", 312, -14)
    local invClear = MakeButton(inv, NS.L("Tout afficher"), 100, 20)
    invClear:SetPoint("TOPRIGHT", -20, -12)
    invClear:SetScript("OnClick", function()
        invFilter = nil
        if refreshInvList then refreshInvList() end
    end)
    local invEmpty = Label(inv, 11, COLORS.muted)
    invEmpty:SetPoint("TOPLEFT", 312, -46)
    invEmpty:SetWidth(276); invEmpty:SetJustifyH("LEFT")

    local holderRows, holderHeads, holderBars = {}, {}, {}
    -- en-têtes de section (Groupe / Guilde / Rerolls) + barres d'accent à gauche
    -- de chaque bloc (façon maquette 2), poolés et repositionnés à chaque refresh.
    local function getHead(i)
        if holderHeads[i] then return holderHeads[i] end
        local h = Label(inv, 11); h:SetJustifyH("LEFT"); holderHeads[i] = h; return h
    end
    local function getBar(i)
        if holderBars[i] then return holderBars[i] end
        local b = Solid(inv, "ARTWORK"); holderBars[i] = b; return b
    end
    local function GetHolderRow(i)
        if holderRows[i] then return holderRows[i] end
        local row = CreateFrame("Button", nil, inv)
        row:SetHeight(26)
        row:SetPoint("TOPLEFT", 312, -(40 + (i - 1) * 30))
        row:SetPoint("TOPRIGHT", -20, -(40 + (i - 1) * 30))
        local rbg = Solid(row); rbg:SetAllPoints(); rbg:SetVertexColor(unpack(COLORS.panel))
        Border(row, COLORS.line)
        local hl = row:CreateTexture(nil, "HIGHLIGHT"); hl:SetTexture(WHITE); hl:SetAllPoints(); hl:SetVertexColor(1, 1, 1, 0.05)
        row.dot = Circle(row, 7, "ARTWORK"); row.dot:SetPoint("LEFT", 8, 0)
        row.name = Label(row, 12); row.name:SetPoint("LEFT", 22, 0); row.name:SetJustifyH("LEFT"); row.name:SetWordWrap(false)
        row.count = Label(row, 12); row.count:SetPoint("RIGHT", -10, 0)
        holderRows[i] = row
        return row
    end

    -- Rendu SEUL (lecture du cache) → instantané. Le recalcul des quantités
    -- (léger) se fait à l'ouverture de l'onglet, pas à chaque clic.
    refreshInvList = function()
        local low = NS.LOW_STOCK or 5
        local mp = NS.db and NS.db.myProfile
        local myItems = (mp and mp.items) or {}
        -- gauche : TOUS les consommables suivis, groupés par catégorie (toujours
        -- visibles, sans ascenseur). À droite de chaque ligne : MA quantité
        -- (— = jamais possédé · 0 rouge · ≤5 jaune).
        for _, h in ipairs(invLeftHeads) do h:Hide() end
        for _, r in ipairs(myItemRows) do r:Hide() end
        -- puces de famille : surligne l'active, grise les autres (elle sert de titre)
        for _, chip in ipairs(invChips) do
            local on = (chip.catKey == invCat)
            if chip.ul then chip.ul:SetShown(on) end
            if chip.label then chip.label:SetTextColor(unpack(on and COLORS.text or COLORS.muted)) end
        end
        do
            -- une seule famille affichée → liste courte, pas d'en-tête (la puce le dit)
            local y = 0
            for i, t in ipairs(NS.TRACKED_ITEMS or {}) do
                local row = myItemRows[i]
                if row and t.cat == invCat then
                    row:ClearAllPoints(); row:SetPoint("TOPLEFT", 20, -(64 + y)); row:SetWidth(272)
                    local c = row.itemID and myItems[row.itemID]
                    if c == nil then row.count:SetText("—"); row.count:SetTextColor(unpack(COLORS.muted))
                    elseif c <= 0 then row.count:SetText("0"); row.count:SetTextColor(0.92, 0.28, 0.32, 1)
                    elseif c <= low then row.count:SetText(FormatNumber(c)); row.count:SetTextColor(0.98, 0.80, 0.25, 1)
                    else row.count:SetText(FormatNumber(c)); row.count:SetTextColor(unpack(COLORS.text)) end
                    row.sel:SetShown(invFilter == row.itemID)
                    row:Show()
                    y = y + 28
                end
            end
        end

        -- (b) panneau droit — 3 sections : Groupe / Guilde / Rerolls (maquette 2).
        -- Groupe + Guilde = joueurs EN LIGNE uniquement (groupe prioritaire) ;
        -- Rerolls = mes AUTRES persos via l'historique d'inventaire (hors ligne).
        invClear:SetShown(invFilter ~= nil)
        for _, r in ipairs(holderRows) do r:Hide() end
        for _, h in ipairs(holderHeads) do h:Hide() end
        for _, bar in ipairs(holderBars) do bar:Hide() end

        local groupSet, guildSet = GroupKeySet(), GuildKeySet()
        local meKey = UnitKeyOf("player")
        local function sectionOf(k)
            if groupSet[k] then return 1 end            -- groupe prioritaire
            local bare = k:match("^([^%-]+)")
            if guildSet[k] or (bare and guildSet[bare]) then return 2 end
            return nil
        end

        local group, guild, reroll = {}, {}, {}
        if invFilter then
            invRightTitle:SetText("")   -- titre « Qui a : … » retiré (demande utilisateur)
            -- (soi non listé ici : ton stock est déjà à gauche dans « Mes consommables »)
            for k, p in pairs((NS.db and NS.db.peers) or {}) do
                if p.online and k ~= meKey and p.items and (p.items[invFilter] or 0) > 0 then
                    local s = sectionOf(k)
                    if s then
                        local e = { name = k:match("^(.-)%-") or k, class = p.class, dot = "on",
                            right = "×" .. FormatNumber(p.items[invFilter]), key = k }
                        if s == 1 then group[#group + 1] = e else guild[#guild + 1] = e end
                    end
                end
            end
            for _, c in ipairs(RerollChars()) do
                local n = c.items and c.items[invFilter]
                if n and n > 0 then
                    reroll[#reroll + 1] = { name = c.name, class = c.class, dot = "off", dim = true,
                        right = "×" .. FormatNumber(n), key = c.key }
                end
            end
            invEmpty:SetText(NS.L("Personne (groupe / guilde / rerolls) ne l'a."))
        else
            invRightTitle:SetText("")   -- titre retiré (demande utilisateur)
            -- (soi non listé : ton inventaire est déjà à gauche dans « Mes consommables »)
            for k, p in pairs((NS.db and NS.db.peers) or {}) do
                if p.online and k ~= meKey then
                    local s = sectionOf(k)
                    if s then
                        local e = { name = k:match("^(.-)%-") or k, class = p.class, dot = "on",
                            right = NS.L("en ligne"), key = k }
                        if s == 1 then group[#group + 1] = e else guild[#guild + 1] = e end
                    end
                end
            end
            for _, c in ipairs(RerollChars()) do
                reroll[#reroll + 1] = { name = c.name, class = c.class, dot = "off", dim = true,
                    right = RelTime(c.ts) or NS.L("historique"), key = c.key }
            end
            invEmpty:SetText(NS.L("Aucun perso connecté ni reroll en historique."))
        end

        local byName = function(a, b) return (a.name or "") < (b.name or "") end
        table.sort(group, function(a, b)
            if (a.isMe and 1 or 0) ~= (b.isMe and 1 or 0) then return a.isMe and true or false end
            return byName(a, b)
        end)
        table.sort(guild, byName)   -- rerolls déjà triés par date (RerollChars)

        -- Mon perso, toujours en tête : sa quantité de l'item filtré (ou « en ligne »).
        local me = {}
        do
            local _, myClass = UnitClass("player")
            local right = invFilter and ("×" .. FormatNumber((myItems and myItems[invFilter]) or 0))
                or NS.L("en ligne")
            me[1] = { name = UnitName("player") or "?", class = myClass, dot = "me", right = right }
        end
        local blocks = {
            { title = NS.L("Mon personnage"), accent = COLORS.accent, tag = "",             list = me },
            { title = NS.L("Groupe"),  accent = COLORS.accent, tag = NS.L("en ligne"),   list = group },
            { title = NS.L("Guilde"),  accent = COLORS.green,  tag = NS.L("en ligne"),   list = guild },
            { title = NS.L("Rerolls"), accent = COLORS.pink,   tag = NS.L("historique"), list = reroll },
        }
        local ri, hi, bi, y = 0, 0, 0, 44
        for _, blk in ipairs(blocks) do
            if #blk.list > 0 then
                local topY = y
                hi = hi + 1
                local head = getHead(hi)
                head:ClearAllPoints(); head:SetPoint("TOPLEFT", 320, -y)
                local sub = (blk.tag ~= "" and (blk.tag .. " · ") or "") .. #blk.list
                head:SetText(("%s  |cff6f6880%s|r"):format(blk.title, sub))
                head:SetTextColor(unpack(blk.accent))
                head:Show()
                y = y + 22
                for _, e in ipairs(blk.list) do
                    ri = ri + 1
                    local row = GetHolderRow(ri)
                    row:ClearAllPoints()
                    row:SetPoint("TOPLEFT", 320, -y)
                    row:SetPoint("TOPRIGHT", -20, -y)
                    row.name:SetText(("|c%s%s|r%s"):format(ClassColorStr(e.class), e.name or "?",
                        ""))
                    if e.dot == "me" then row.dot:SetVertexColor(unpack(COLORS.accent))
                    elseif e.dot == "on" then row.dot:SetVertexColor(unpack(COLORS.green))
                    else row.dot:SetVertexColor(unpack(COLORS.gray)) end
                    row.count:SetText(e.right or "")
                    if invFilter then row.count:SetTextColor(0.98, 0.80, 0.25, 1)  -- doré : quantité
                    else row.count:SetTextColor(unpack(COLORS.muted)) end
                    row.key = e.key
                    row:SetScript("OnClick", function(self) if self.key then ShowInvDetail(self.key) end end)
                    row:Show()
                    y = y + 28
                end
                bi = bi + 1
                local bar = getBar(bi)
                bar:ClearAllPoints(); bar:SetPoint("TOPLEFT", 312, -topY)
                bar:SetSize(2, (y - topY) - 8)
                bar:SetVertexColor(unpack(blk.accent))
                bar:Show()
                y = y + 10
            end
        end
        invEmpty:SetShown(ri == 0)
    end

    -- ============================================================
    -- PAGE : INVENTAIRE — détail
    -- ============================================================
    local invdetail = CreateFrame("Frame", nil, content)
    invdetail:SetAllPoints()
    invdetail:Hide()
    pages.invdetail = invdetail

    local iback = MakeButton(invdetail, NS.L("« Retour"), 84, 22)
    iback:SetPoint("TOPLEFT", 20, -12)
    iback:SetScript("OnClick", function() ShowPage("inv") end)

    local iName = Label(invdetail, 16)
    iName:SetPoint("TOPLEFT", 20, -46)

    local iMeta = Label(invdetail, 11, COLORS.muted)
    iMeta:SetPoint("TOPLEFT", 20, -68)
    iMeta:SetWidth(400)
    iMeta:SetJustifyH("LEFT")

    local iStatus = Label(invdetail, 10, COLORS.muted)
    iStatus:SetPoint("TOPRIGHT", -20, -50)

    -- seuil « stock bas » : quantité ≤ LOW_STOCK → affichée en jaune
    local LOW_STOCK = (NS.LOW_STOCK) or 5

    -- lignes : consommables suivis (grille 2 colonnes, positionnées
    -- dynamiquement — les objets jamais possédés sont masqués)
    local itemRows = {}
    for i, t in ipairs(NS.TRACKED_ITEMS or {}) do
        local row = CreateFrame("Frame", nil, invdetail)
        row:SetSize(276, 30)
        row:Hide()
        local rbg = Solid(row)
        rbg:SetAllPoints()
        rbg:SetVertexColor(unpack(COLORS.panel))
        Border(row, COLORS.line)

        row.icon = row:CreateTexture(nil, "ARTWORK")
        row.icon:SetSize(20, 20)
        row.icon:SetPoint("LEFT", 6, 0)
        row.icon:SetTexCoord(0.07, 0.93, 0.07, 0.93)

        row.name = Label(row, 11)
        row.name:SetPoint("LEFT", row.icon, "RIGHT", 8, 0)
        row.name:SetWidth(180)
        row.name:SetJustifyH("LEFT")
        row.name:SetWordWrap(false)
        row.name:SetText(t.fallback)

        row.count = Label(row, 12)
        row.count:SetPoint("RIGHT", -10, 0)

        row.itemID = t.ids and t.ids[1] -- clé = premier ID
        itemRows[i] = row

        -- nom + icône localisés (FR sur client FR), chargés en asynchrone
        if row.itemID and Item and Item.CreateFromItemID then
            local it = Item:CreateFromItemID(row.itemID)
            it:ContinueOnItemLoad(function()
                row.name:SetText(it:GetItemName() or t.fallback)
                row.icon:SetTexture(it:GetItemIcon())
            end)
        end
    end

    local iNote = Label(invdetail, 10, COLORS.muted)
    iNote:SetPoint("BOTTOMLEFT", 20, 16)
    iNote:SetWidth(560)
    iNote:SetJustifyH("LEFT")

    FillInvDetail = function(key)
        -- affiche les lignes : objet jamais possédé → masqué ;
        -- possédé mais épuisé → « 0 » en rouge ; sinon → quantité.
        local function SetValues(items)
            local visible = 0
            for _, row in ipairs(itemRows) do
                local c = items and row.itemID and items[row.itemID]
                if c == nil then
                    row:Hide()
                else
                    local col = visible % 2
                    local r = math.floor(visible / 2)
                    row:ClearAllPoints()
                    row:SetPoint("TOPLEFT", 20 + col * 292, -(110 + r * 36))
                    if c <= 0 then
                        row.count:SetText("0")
                        row.count:SetTextColor(0.92, 0.28, 0.32, 1)      -- rouge : épuisé
                    elseif c <= LOW_STOCK then
                        row.count:SetText(FormatNumber(c))
                        row.count:SetTextColor(0.98, 0.80, 0.25, 1)      -- jaune : stock bas
                    else
                        row.count:SetText(FormatNumber(c))
                        row.count:SetTextColor(unpack(COLORS.text))
                    end
                    row:Show()
                    visible = visible + 1
                end
            end
            return visible
        end

        if key == "ME" then
            -- régénère le profil : compteurs à jour + mémorisation
            -- des objets déjà possédés (itemsSeen)
            if NS.UpdateMyProfile then NS.UpdateMyProfile(false) end
            local myMeta = (NS.GetMyMeta and NS.GetMyMeta()) or {}
            local mp = NS.db and NS.db.myProfile
            local class = (mp and mp.class) or myMeta.class
            iName:SetText(("|c%s%s|r"):format(ClassColorStr(class), UnitName("player") or "?"))
            local bits = { ClassName(class) }
            local lvl = (mp and mp.level) or myMeta.level
            if lvl and lvl > 0 then bits[#bits + 1] = NS.L("Niveau ") ..lvl end
            iMeta:SetText(table.concat(bits, " · "))
            local shown = SetValues(mp and mp.items)
            iStatus:SetText(NS.L("en direct"))
            if shown == 0 then
                iNote:SetText(NS.L("Aucun consommable suivi possédé pour l'instant."))
            else
                iNote:SetText((NS.L("Sacs + banque · |cffea48500|r = épuisé · |cfffacd40≤%d|r = stock bas")):format(LOW_STOCK))
            end
        else
            -- pair (autre joueur, en direct) OU reroll (mon perso, historique myChars)
            local p = NS.db and NS.db.peers and NS.db.peers[key]
            local rc = (not p) and NS.db and NS.db.myChars and NS.db.myChars[key]
            if not p and not rc then ShowPage("inv") return end
            local display = key:match("^(.-)%-") or key
            local class = (p and p.class) or (rc and rc.class)
            iName:SetText(("|c%s%s|r%s"):format(ClassColorStr(class), display,
                rc and ("  |cff8b8fa3" .. NS.L("reroll") .. "|r") or ""))
            local bits = { ClassName(class) }
            if p and p.level and p.level > 0 then bits[#bits + 1] = NS.L("Niveau ") ..p.level end
            if p then bits[#bits + 1] = p.online and ("|cff22c55e" .. NS.L("en ligne") .. "|r") or NS.L("hors ligne") end
            iMeta:SetText(table.concat(bits, " · "))
            local items = (p and p.items) or (rc and rc.items)
            local shown = SetValues(items)
            local seen = RelTime(p and math.max(p.lastSeen or 0, p.ts or 0) or (rc and rc.ts))
            iStatus:SetText(seen and ((rc and NS.L("vu ") or NS.L("synchro ")) .. seen) or "")
            if items == nil then
                iNote:SetText(NS.L("Inventaire non reçu (version d'addon trop ancienne)."))
            elseif shown == 0 then
                iNote:SetText(NS.L("Aucun consommable suivi possédé."))
            else
                iNote:SetText((NS.L("Dernière synchro · |cffea48500|r = épuisé · |cfffacd40≤%d|r = stock bas")):format(LOW_STOCK))
            end
        end
    end

    ShowInvDetail = function(key)
        currentInv = key
        FillInvDetail(key)
        ShowPage("invdetail")
    end

    -- ============================================================
    -- PAGE : KICKS
    -- ============================================================
    local kicks = CreateFrame("Frame", nil, content)
    kicks:SetAllPoints()
    kicks:Hide()
    pages.kicks = kicks

    local kTitle = Label(kicks, 13)
    kTitle:SetPoint("TOPLEFT", 20, -14)
    kTitle:SetText(NS.L("Suivi des interruptions (kicks)"))

    -- Panneaux « Affichage » / « Activation » (design maquette B, v0.32). Purement
    -- décoratifs : les cases se positionnent dessus (créées après = au-dessus).
    do
        local function Panel(px, pw, title)
            -- contour seul (pas de fond plein : les cases/labels sont des régions
            -- de `kicks` et passeraient DERRIÈRE un fond de panneau → on garde le
            -- cadre + le liseré doré, l'intérieur reste transparent).
            local p = CreateFrame("Frame", nil, kicks)
            p:SetPoint("TOPLEFT", px, -38); p:SetSize(pw, 192)
            Border(p, COLORS.line)
            local top = Solid(p, "ARTWORK"); top:SetVertexColor(unpack(GOLD)); top:SetHeight(2)
            top:SetPoint("TOPLEFT"); top:SetPoint("TOPRIGHT")
            local h = Label(p, 12, GOLD); h:SetPoint("TOPLEFT", 12, -9); h:SetText(title)
        end
        Panel(16, 352, NS.L("Affichage"))
        Panel(380, 214, NS.L("Activation"))
    end

    -- case à cocher : afficher / masquer l'encadré
    local cb = CreateFrame("Button", nil, kicks)
    cb:SetSize(16, 16)
    cb:SetPoint("TOPLEFT", 28, -68)
    local cbBg = Solid(cb)
    cbBg:SetAllPoints()
    cbBg:SetVertexColor(unpack(COLORS.field))
    Border(cb, COLORS.line)
    local cbInner = Solid(cb, "ARTWORK")
    cbInner:SetSize(10, 10)
    cbInner:SetPoint("CENTER")
    cbInner:SetVertexColor(unpack(COLORS.accent))

    local cbLabel = Label(kicks, 10)
    cbLabel:SetPoint("LEFT", cb, "RIGHT", 10, 0)
    cbLabel:SetText(NS.L("Afficher l'encadré des kicks dans l'interface"))

    local function UpdateCheckbox()
        cbInner:SetShown(NS.Kicks and NS.Kicks.IsEnabled() or false)
    end
    cb:SetScript("OnClick", function()
        if not NS.Kicks then return end
        NS.Kicks.SetEnabled(not NS.Kicks.IsEnabled())
        UpdateCheckbox()
    end)

    -- case : verrouiller la taille (masque la poignée). Bloc local (limite Lua).
    do
        local lockCb = CreateFrame("Button", nil, kicks)
        lockCb:SetSize(16, 16)
        lockCb:SetPoint("TOPLEFT", 28, -94)
        local lockBg = Solid(lockCb); lockBg:SetAllPoints(); lockBg:SetVertexColor(unpack(COLORS.field))
        Border(lockCb, COLORS.line)
        local lockInner = Solid(lockCb, "ARTWORK")
        lockInner:SetSize(10, 10); lockInner:SetPoint("CENTER"); lockInner:SetVertexColor(unpack(COLORS.accent))
        local lockLbl = Label(kicks, 10)
        lockLbl:SetPoint("LEFT", lockCb, "RIGHT", 10, 0)
        lockLbl:SetText(NS.L("Verrouiller le cadre"))
        -- resync depuis l'état module (cadenas overlay ↔ cette case, bidirectionnel)
        NS._syncKickMenuLock = function() lockInner:SetShown(NS.Kicks and NS.Kicks.IsLocked() or false) end
        NS._syncKickMenuLock()
        lockCb:SetScript("OnClick", function()
            if NS.Kicks and NS.Kicks.SetLocked then
                NS.Kicks.SetLocked(not NS.Kicks.IsLocked())   -- déclenche NS._syncKickMenuLock
            end
        end)
    end

    -- case : rotation de kick (cadres vert/orange). Bloc local (limite Lua).
    do
        local rc = CreateFrame("Button", nil, kicks)
        rc:SetSize(16, 16)
        rc:SetPoint("TOPLEFT", 28, -120)
        local rbg = Solid(rc); rbg:SetAllPoints(); rbg:SetVertexColor(unpack(COLORS.field))
        Border(rc, COLORS.line)
        local rin = Solid(rc, "ARTWORK")
        rin:SetSize(10, 10); rin:SetPoint("CENTER"); rin:SetVertexColor(unpack(COLORS.accent))
        local rl = Label(kicks, 10)
        rl:SetPoint("LEFT", rc, "RIGHT", 10, 0)
        rl:SetText(NS.L("Rotation de kick (badges 1/2)"))
        local function rget() return not (NS.db and NS.db.kickRotation == false) end
        rin:SetShown(rget())
        rc:SetScript("OnClick", function()
            if NS.db then NS.db.kickRotation = not rget() end
            rin:SetShown(rget())
        end)
    end

    -- Activation du module PAR CONTEXTE (Open World / Donjons / Raids). Les 3
    -- cochés par défaut ; l'encadré n'apparaît qu'en groupe avec un vrai joueur.
    do
        -- empilées dans le panneau « Activation » (le titre du panneau sert d'entête)
        local defs = {
            { key = "kickWorld",   text = NS.L("Monde") },
            { key = "kickDungeon", text = NS.L("Donjons") },
            { key = "kickRaid",    text = NS.L("Raids") },
        }
        for i, d in ipairs(defs) do
            local c = CreateFrame("Button", nil, kicks)
            c:SetSize(16, 16); c:SetPoint("TOPLEFT", 392, -68 - (i - 1) * 26)
            local cbg = Solid(c); cbg:SetAllPoints(); cbg:SetVertexColor(unpack(COLORS.field))
            Border(c, COLORS.line)
            local cin = Solid(c, "ARTWORK"); cin:SetSize(10, 10); cin:SetPoint("CENTER")
            cin:SetVertexColor(unpack(COLORS.accent))
            local ct = Label(kicks, 10); ct:SetPoint("LEFT", c, "RIGHT", 8, 0); ct:SetText(d.text)
            local function get() return not (NS.db and NS.db[d.key] == false) end
            cin:SetShown(get())
            c:SetScript("OnClick", function()
                if NS.db then NS.db[d.key] = not get() end
                cin:SetShown(get())
                if NS.Kicks and NS.Kicks.UpdateVisibility then NS.Kicks.UpdateVisibility() end
            end)
        end
    end

    -- Aperçu : affiche l'encadré hors groupe pour le placer, puis le verrouiller.
    -- (Le mode s'arrête tout seul quand on change d'onglet — voir ShowPage.)
    local kPreview = MakeButton(kicks, NS.L("Aperçu du cadre"), 196, 24)
    kPreview:SetPoint("TOPLEFT", 392, -152)
    local function SyncKickPreview()
        kPreview:SetText(NS.L((NS.Kicks and NS.Kicks.IsPreview and NS.Kicks.IsPreview())
            and "Arrêter l'aperçu" or "Aperçu du cadre"))
    end
    kPreview:SetScript("OnClick", function()
        if NS.Kicks and NS.Kicks.SetPreview then
            NS.Kicks.SetPreview(not (NS.Kicks.IsPreview and NS.Kicks.IsPreview()))
        end
        SyncKickPreview()
    end)
    local kPrevHint = Label(kicks, 9, COLORS.muted)
    kPrevHint:SetPoint("TOPLEFT", 392, -180)
    kPrevHint:SetWidth(196); kPrevHint:SetJustifyH("LEFT")
    kPrevHint:SetText(NS.L("Placez le cadre puis verrouillez-le."))

    local kReset = MakeButton(kicks, NS.L("Réinitialiser la position"), 170, 24)
    kReset:SetPoint("TOPLEFT", 28, -190)
    kReset:SetScript("OnClick", function()
        if NS.Kicks then NS.Kicks.ResetPosition() end
    end)

    -- opacité du fond de l'encadré (curseur)
    local opLabel = Label(kicks, 10)
    opLabel:SetPoint("TOPLEFT", 28, -146)

    local track = CreateFrame("Frame", nil, kicks)
    track:SetSize(180, 12)
    track:SetPoint("TOPLEFT", 28, -164)
    local trackBg = Solid(track)
    trackBg:SetAllPoints()
    trackBg:SetVertexColor(unpack(COLORS.field))
    Border(track, COLORS.line)
    local trackFill = Solid(track, "ARTWORK")
    trackFill:SetPoint("TOPLEFT", 1, -1)
    trackFill:SetPoint("BOTTOMLEFT", 1, 1)
    trackFill:SetVertexColor(unpack(COLORS.accent))

    local function SetOpacityDisplay(a)
        trackFill:SetWidth(math.max(1, 178 * a))
        opLabel:SetText((NS.L("Opacité du fond de l'encadré : |cff%s%d%%|r")):format(NS.ACCENT, math.floor(a * 100 + 0.5)))
    end
    local function OpacityFromCursor()
        local scale = track:GetEffectiveScale()
        local x = GetCursorPosition() / scale - track:GetLeft()
        local a = x / track:GetWidth()
        if a < 0 then a = 0 elseif a > 1 then a = 1 end
        a = math.floor(a * 20 + 0.5) / 20
        SetOpacityDisplay(a)
        if NS.Kicks and NS.Kicks.SetBgAlpha then NS.Kicks.SetBgAlpha(a) end
    end
    track:EnableMouse(true)
    track:SetScript("OnMouseDown", function(self)
        OpacityFromCursor()
        self:SetScript("OnUpdate", OpacityFromCursor)
    end)
    track:SetScript("OnMouseUp", function(self)
        self:SetScript("OnUpdate", nil)
    end)

    local kHow = Label(kicks, 9, COLORS.muted)
    kHow:SetPoint("TOPLEFT", 24, -264)
    kHow:SetWidth(900)
    kHow:SetJustifyH("LEFT")
    kHow:SetText(NS.L("|cff22c55ePRÊT|r = kick disponible · |cfff2a33c12 s|r = en recharge · |TInterface\\RaidFrame\\ReadyCheck-Ready:14|t = sort bien interrompu · |TInterface\\RaidFrame\\ReadyCheck-NotReady:14|t = kick dans le vent"))

    local kTracked = Label(kicks, 10)
    kTracked:SetPoint("TOPLEFT", 24, -318)
    kTracked:SetWidth(560)
    kTracked:SetJustifyH("LEFT")

    -- Liste des joueurs dans la rotation de kick (coche = inclus). Auto selon
    -- si le joueur a une interruption ; décoche/recoche pour forcer. Bloc local.
    do
        local note = Label(kicks, 9, COLORS.muted)
        note:SetPoint("TOPLEFT", 24, -338)
        note:SetText(NS.L("Auto : coché si le joueur a une interruption. Décoche/recoche pour forcer."))
        local pool = {}
        local function refresh()
            local list = (NS.Kicks and NS.Kicks.GetRotationList()) or {}
            for _, r in ipairs(pool) do r.cb:Hide(); r.lbl:Hide() end
            local y = -360
            for i, p in ipairs(list) do
                local row = pool[i]
                if not row then
                    local cb = CreateFrame("Button", nil, kicks)
                    cb:SetSize(16, 16)
                    local bg = Solid(cb); bg:SetAllPoints(); bg:SetVertexColor(unpack(COLORS.field))
                    Border(cb, COLORS.line)
                    local inner = Solid(cb, "ARTWORK")
                    inner:SetSize(10, 10); inner:SetPoint("CENTER"); inner:SetVertexColor(unpack(COLORS.accent))
                    local lbl = Label(kicks, 10)
                    row = { cb = cb, inner = inner, lbl = lbl }
                    pool[i] = row
                end
                row.cb:ClearAllPoints(); row.cb:SetPoint("TOPLEFT", 24, y)
                row.inner:SetShown(p.inRotation)
                local col = p.class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[p.class]
                local hex = (col and col.colorStr) or "ffffffff"
                local tag = (p.hasKick == false) and ("  |cff8a8f99" .. NS.L("(pas de kick)") .. "|r") or ""
                row.lbl:ClearAllPoints(); row.lbl:SetPoint("LEFT", row.cb, "RIGHT", 8, 0)
                row.lbl:SetText(("|c%s%s|r%s%s"):format(hex, p.name or "?",
                    "", tag))
                row.cb:SetScript("OnClick", function()
                    if NS.Kicks and NS.Kicks.SetInRotation then
                        NS.Kicks.SetInRotation(p.key, not p.inRotation)
                        refresh()
                    end
                end)
                row.cb:Show(); row.lbl:Show()
                y = y - 24
            end
        end
        NS.RefreshKickRotationList = refresh
        refresh()
    end

    -- séparateurs dorés du menu (design v0.32) : Légende puis Rotation de kick
    do
        GoldSep(kicks, 20, -242, 570, NS.L("Légende"), true)
        GoldSep(kicks, 20, -298, 570, NS.L("Rotation de kick"), true)
    end

    UpdateKicksPage = function()
        UpdateCheckbox()
        SyncKickPreview()
        if NS._syncKickMenuLock then NS._syncKickMenuLock() end   -- reflète l'état du cadenas overlay

        SetOpacityDisplay((NS.db and NS.db.kickBgAlpha) or 0.6)
        local txt = NS.L("Suivis actuellement : ") .. ((NS.Kicks and NS.Kicks.GetRosterNames()) or "—")
        if NS.Kicks and NS.Kicks.available == false then
            txt = txt .. "\n" .. NS.L("|cffea4850Détection indisponible (restrictions Blizzard) — un /reload peut aider.|r")
        end
        kTracked:SetText(txt)
        if NS.RefreshKickRotationList then NS.RefreshKickRotationList() end
    end

    -- ============================================================
    -- PAGE : COOLDOWNS DE GROUPE (overlay + sélection des CD)
    -- ============================================================
    local cdPage = CreateFrame("Frame", nil, content)
    cdPage:SetAllPoints()
    cdPage:Hide()
    pages.cd = cdPage

    local cdTitle = Label(cdPage, 13)
    cdTitle:SetPoint("TOPLEFT", 20, -16)
    cdTitle:SetText(NS.L("Tracker de cooldowns de groupe"))

    -- Panneaux « Affichage » / « Options » / « Activation » (template B, v0.33.3).
    -- Contour seul (les cases sont des régions de cdPage → passeraient derrière un fond).
    do
        local function CDPanel(px, pw, title)
            local p = CreateFrame("Frame", nil, cdPage)
            p:SetPoint("TOPLEFT", px, -40); p:SetSize(pw, 176)
            Border(p, COLORS.line)
            local top = Solid(p, "ARTWORK"); top:SetVertexColor(unpack(GOLD)); top:SetHeight(2)
            top:SetPoint("TOPLEFT"); top:SetPoint("TOPRIGHT")
            local h = Label(p, 12, GOLD); h:SetPoint("TOPLEFT", 12, -9); h:SetText(title)
        end
        CDPanel(16, 284, NS.L("Affichage"))
        CDPanel(308, 172, NS.L("Options"))
        CDPanel(488, 102, NS.L("Activation"))
    end

    -- case : afficher / masquer l'encadré
    local cdCb = CreateFrame("Button", nil, cdPage)
    cdCb:SetSize(16, 16)
    cdCb:SetPoint("TOPLEFT", 28, -66)
    local cdCbBg = Solid(cdCb)
    cdCbBg:SetAllPoints()
    cdCbBg:SetVertexColor(unpack(COLORS.field))
    Border(cdCb, COLORS.line)
    local cdCbInner = Solid(cdCb, "ARTWORK")
    cdCbInner:SetSize(10, 10)
    cdCbInner:SetPoint("CENTER")
    cdCbInner:SetVertexColor(unpack(COLORS.accent))
    local cdCbLabel = Label(cdPage, 10)
    cdCbLabel:SetPoint("LEFT", cdCb, "RIGHT", 10, 0)
    cdCbLabel:SetText(NS.L("Afficher l'encadré des cooldowns"))
    local function UpdateCDCheckbox()
        cdCbInner:SetShown(NS.Cooldowns and NS.Cooldowns.IsEnabled() or false)
    end
    cdCb:SetScript("OnClick", function()
        if not NS.Cooldowns then return end
        NS.Cooldowns.SetEnabled(not NS.Cooldowns.IsEnabled())
        UpdateCDCheckbox()
    end)

    local cdReset = MakeButton(cdPage, NS.L("Réinitialiser la position"), 150, 22)
    cdReset:SetPoint("TOPLEFT", 320, -118)
    cdReset:SetScript("OnClick", function()
        if NS.Cooldowns then NS.Cooldowns.ResetPosition() end
    end)

    -- opacité
    local cdOpLabel = Label(cdPage, 10)
    cdOpLabel:SetPoint("TOPLEFT", 28, -114)
    local cdTrack = CreateFrame("Frame", nil, cdPage)
    cdTrack:SetSize(180, 10)
    cdTrack:SetPoint("TOPLEFT", 28, -130)
    local cdTrackBg = Solid(cdTrack)
    cdTrackBg:SetAllPoints()
    cdTrackBg:SetVertexColor(unpack(COLORS.field))
    Border(cdTrack, COLORS.line)
    local cdTrackFill = Solid(cdTrack, "ARTWORK")
    cdTrackFill:SetPoint("TOPLEFT", 1, -1)
    cdTrackFill:SetPoint("BOTTOMLEFT", 1, 1)
    cdTrackFill:SetVertexColor(unpack(COLORS.accent))
    local function SetCDOpacityDisplay(a)
        cdTrackFill:SetWidth(math.max(1, 178 * a))
        cdOpLabel:SetText((NS.L("Opacité du fond : |cff%s%d%%|r")):format(NS.ACCENT, math.floor(a * 100 + 0.5)))
    end
    local function CDOpacityFromCursor()
        local scale = cdTrack:GetEffectiveScale()
        local x = GetCursorPosition() / scale - cdTrack:GetLeft()
        local a = x / cdTrack:GetWidth()
        if a < 0 then a = 0 elseif a > 1 then a = 1 end
        a = math.floor(a * 20 + 0.5) / 20
        SetCDOpacityDisplay(a)
        if NS.Cooldowns and NS.Cooldowns.SetBgAlpha then NS.Cooldowns.SetBgAlpha(a) end
    end
    cdTrack:EnableMouse(true)
    cdTrack:SetScript("OnMouseDown", function(self)
        CDOpacityFromCursor()
        self:SetScript("OnUpdate", CDOpacityFromCursor)
    end)
    cdTrack:SetScript("OnMouseUp", function(self) self:SetScript("OnUpdate", nil) end)

    -- cases (colonne droite) : catégorie dans l'infobulle + suivi des potions
    local function MakeCDToggle(px, yOff, text, getFn, setFn)
        local cb = CreateFrame("Button", nil, cdPage)
        cb:SetSize(16, 16)
        cb:SetPoint("TOPLEFT", px, yOff)
        local bg = Solid(cb); bg:SetAllPoints(); bg:SetVertexColor(unpack(COLORS.field))
        Border(cb, COLORS.line)
        local inner = Solid(cb, "ARTWORK")
        inner:SetSize(10, 10); inner:SetPoint("CENTER"); inner:SetVertexColor(unpack(COLORS.accent))
        local lbl = Label(cdPage, 10)
        lbl:SetPoint("LEFT", cb, "RIGHT", 10, 0)
        lbl:SetText(text)
        inner:SetShown(getFn())
        cb:SetScript("OnClick", function() setFn(not getFn()); inner:SetShown(getFn()) end)
        return function() inner:SetShown(getFn()) end   -- resync externe (état module)
    end
    MakeCDToggle(320, -66, NS.L("Catégorie dans l'infobulle"),   -- panneau Options
        function() return not (NS.db and NS.db.cdShowCat == false) end,
        function(v) if NS.db then NS.db.cdShowCat = v end end)
    MakeCDToggle(320, -90, NS.L("Suivre les potions de combat"),   -- panneau Options
        function() return not (NS.db and NS.db.cdTrackPot == false) end,
        function(v) if NS.db then NS.db.cdTrackPot = v end end)
    -- case « Verrouiller » liée au cadenas de l'overlay (bidirectionnel via SetLocked)
    NS._syncCDMenuLock = MakeCDToggle(28, -90, NS.L("Verrouiller le cadre"),   -- panneau Affichage
        function() return NS.Cooldowns and NS.Cooldowns.IsLocked() or false end,
        function(v) if NS.Cooldowns and NS.Cooldowns.SetLocked then NS.Cooldowns.SetLocked(v) end end)

    -- Espacement vertical entre les lignes de membres (0 = collé). Bloc local
    -- (do/end) pour ne pas alourdir le compteur de variables de BuildUI.
    do
        local gapLbl = Label(cdPage, 10)
        gapLbl:SetPoint("TOPLEFT", 28, -154)
        gapLbl:SetText(NS.L("Espacement des lignes"))
        local gapMinus = MakeButton(cdPage, "−", 22, 20)
        gapMinus:SetPoint("LEFT", gapLbl, "RIGHT", 12, 0)
        local gapNum = Label(cdPage, 12)
        gapNum:SetPoint("LEFT", gapMinus, "RIGHT", 8, 0)
        gapNum:SetWidth(48); gapNum:SetJustifyH("CENTER")
        local gapPlus = MakeButton(cdPage, "+", 22, 20)
        gapPlus:SetPoint("LEFT", gapNum, "RIGHT", 8, 0)
        local function gapRefresh()
            gapNum:SetText(("%d px"):format((NS.db and NS.db.cdRowGap) or 0))
            if NS.Cooldowns and NS.Cooldowns.Refresh then NS.Cooldowns.Refresh() end
        end
        local function gapStep(d)
            if not NS.db then return end
            local g = (NS.db.cdRowGap or 0) + d
            if g < 0 then g = 0 elseif g > 30 then g = 30 end
            NS.db.cdRowGap = g
            gapRefresh()
        end
        gapMinus:SetScript("OnClick", function() gapStep(-2) end)
        gapPlus:SetScript("OnClick", function() gapStep(2) end)
        NS.RefreshCDPage = gapRefresh  -- resync l'affichage après un drag de la poignée
        gapRefresh()
    end

    -- Espacement horizontal pseudo ↔ 1re icône (= largeur de la colonne des
    -- noms) : plus petit → icônes collées au pseudo → fenêtre plus étroite.
    do
        local nwLbl = Label(cdPage, 10)
        nwLbl:SetPoint("TOPLEFT", 28, -178)
        nwLbl:SetText(NS.L("Espacement pseudo/icône"))
        local nwMinus = MakeButton(cdPage, "−", 22, 20)
        nwMinus:SetPoint("LEFT", nwLbl, "RIGHT", 12, 0)
        local nwNum = Label(cdPage, 12)
        nwNum:SetPoint("LEFT", nwMinus, "RIGHT", 8, 0)
        nwNum:SetWidth(48); nwNum:SetJustifyH("CENTER")
        local nwPlus = MakeButton(cdPage, "+", 22, 20)
        nwPlus:SetPoint("LEFT", nwNum, "RIGHT", 8, 0)
        local function nwRefresh()
            nwNum:SetText(("%d px"):format((NS.db and NS.db.cdNameW) or 92))
            if NS.Cooldowns and NS.Cooldowns.Refresh then NS.Cooldowns.Refresh() end
        end
        local function nwStep(d)
            if not NS.db then return end
            local w = (NS.db.cdNameW or 92) + d
            if w < 24 then w = 24 elseif w > 130 then w = 130 end
            NS.db.cdNameW = w
            nwRefresh()
        end
        nwMinus:SetScript("OnClick", function() nwStep(-4) end)
        nwPlus:SetScript("OnClick", function() nwStep(4) end)
        nwRefresh()
    end

    -- Activation du module PAR CONTEXTE (Open World / Donjons / Raids), colonne
    -- de droite. Les 3 cochés par défaut ; le board n'apparaît qu'en groupe.
    do
        -- empilées dans le panneau « Activation »
        local defs = {
            { key = "cdWorld",   text = NS.L("Monde") },
            { key = "cdDungeon", text = NS.L("Donjons") },
            { key = "cdRaid",    text = NS.L("Raids") },
        }
        for i, d in ipairs(defs) do
            local c = CreateFrame("Button", nil, cdPage)
            c:SetSize(16, 16); c:SetPoint("TOPLEFT", 500, -66 - (i - 1) * 24)
            local cbg = Solid(c); cbg:SetAllPoints(); cbg:SetVertexColor(unpack(COLORS.field))
            Border(c, COLORS.line)
            local cin = Solid(c, "ARTWORK"); cin:SetSize(10, 10); cin:SetPoint("CENTER")
            cin:SetVertexColor(unpack(COLORS.accent))
            local ct = Label(cdPage, 10); ct:SetPoint("LEFT", c, "RIGHT", 8, 0); ct:SetText(d.text)
            local function get() return not (NS.db and NS.db[d.key] == false) end
            cin:SetShown(get())
            c:SetScript("OnClick", function()
                if NS.db then NS.db[d.key] = not get() end
                cin:SetShown(get())
                if NS.Cooldowns and NS.Cooldowns.UpdateVisibility then NS.Cooldowns.UpdateVisibility() end
            end)
        end
    end

    -- Aperçu : affiche le board hors groupe (ta ligne) pour placer le cadre.
    -- Bas du panneau « Activation ». Coupé au changement d'onglet (ShowPage).
    local cdPreview = MakeButton(cdPage, NS.L("Aperçu"), 84, 22)
    cdPreview:SetPoint("TOPLEFT", 498, -142)
    local function SyncCDPreview()
        cdPreview:SetText(NS.L((NS.Cooldowns and NS.Cooldowns.IsPreview and NS.Cooldowns.IsPreview())
            and "Arrêter" or "Aperçu"))
    end
    cdPreview:SetScript("OnClick", function()
        if NS.Cooldowns and NS.Cooldowns.SetPreview then
            NS.Cooldowns.SetPreview(not (NS.Cooldowns.IsPreview and NS.Cooldowns.IsPreview()))
        end
        SyncCDPreview()
    end)

    -- séparateur doré « Blocs » — placé SOUS les réglages (colonne droite qui
    -- descend jusqu'aux cases Monde/Donjons/Raids), sinon la ligne les traversait.
    GoldSep(cdPage, 20, -230, 566, NS.L("Blocs — afficher / masquer"), false)

    -- boutons par catégorie (bascule tout le bloc offensif/défensif/…)
    local cdBoxes = {}
    local function SetTypeShown(typ, shown)
        for sid, info in pairs(NS.COOLDOWNS or {}) do
            if info.type == typ and NS.db then
                NS.db.cdShow[sid] = shown
                if cdBoxes[sid] then cdBoxes[sid]:SetShown(shown) end
            end
        end
        if NS.Cooldowns and NS.Cooldowns.PruneHidden then NS.Cooldowns.PruneHidden() end
        if NS._refreshCDClassWindow then NS._refreshCDClassWindow() end  -- reflète le bloc dans la fenêtre ouverte
    end
    local function TypeAllShown(typ)
        for sid, info in pairs(NS.COOLDOWNS or {}) do
            if info.type == typ and not (NS.IsCDShown and NS.IsCDShown(sid)) then return false end
        end
        return true
    end
    local CATSHORT = { off = "Offensifs", def = "Défensifs", ext = "Externes", lust = "Bloodlust", res = "Résurr.", disp = "Dissip." }
    local catX = 20
    for _, ct in ipairs(NS.CD_TYPES or {}) do
        -- « Potions » retiré des blocs : déjà couvert par l'option « Suivre les
        -- potions de combat » (panneau Options). Boutons resserrés (84/90) pour
        -- que les 6 catégories (dont Dissip.) tiennent sur une seule ligne.
        if ct.key ~= "pot" then
            local btn = MakeButton(cdPage, NS.L(CATSHORT[ct.key] or ct.label), 84, 20)
            btn:SetPoint("TOPLEFT", catX, -250)
            btn:SetScript("OnClick", function()
                SetTypeShown(ct.key, not TypeAllShown(ct.key))
            end)
            catX = catX + 90
        end
    end

    -- séparateur doré « Classes » puis liste scrollable des CD (coche = suivi individuel)
    GoldSep(cdPage, 20, -276, 566, NS.L("Classes"), false)
    local cdScroll = CreateFrame("ScrollFrame", "AsylumCompagnonCDScroll", cdPage, "UIPanelScrollFrameTemplate")
    cdScroll:SetPoint("TOPLEFT", 16, -292)
    cdScroll:SetPoint("BOTTOMRIGHT", -30, 14)
    local cdChild = CreateFrame("Frame", nil, cdScroll)
    cdChild:SetSize(520, 10)
    cdScroll:SetScrollChild(cdChild)

    -- Bloc local (do/end) : évite d'alourdir le compteur de variables de BuildUI.
    do
        local CATNAME = { off = "Offensifs", def = "Défensifs", ext = "Externes / raid",
            lust = "Bloodlust", res = "Résurrection", disp = "Dissipations" }
        local CATCOL  = { off = { 0.93, 0.42, 0.30 }, def = { 0.35, 0.60, 0.95 },
            ext = { 0.55, 0.85, 0.45 }, lust = { 0.85, 0.55, 0.95 }, res = { 0.95, 0.82, 0.35 },
            disp = { 0.30, 0.80, 0.80 } }
        local CAT_SEQ = { "off", "def", "ext", "lust", "res", "disp" }

        -- sorts d'une classe, groupés par catégorie et triés par nom
        -- Sorts d'une classe, groupés par catégorie PUIS par NOM. Un même sort a
        -- souvent plusieurs spellID (variantes de spé / talents de héros) : utile
        -- pour la détection du board, mais on ne l'affiche qu'UNE fois ici. Chaque
        -- entrée = { name, sids = {tous les IDs de ce sort}, info = représentatif }.
        local function ClassSpells(class)
            local raw = {}
            for sid, info in pairs(NS.COOLDOWNS or {}) do
                if info.class == class then
                    local l = raw[info.type]; if not l then l = {}; raw[info.type] = l end
                    l[#l + 1] = { sid = sid, info = info }
                end
            end
            local by = {}
            for typ, l in pairs(raw) do
                local seen, groups = {}, {}
                for _, it in ipairs(l) do
                    local nm = NS.SpellName(it.sid, it.info.label) or it.info.label or tostring(it.sid)
                    local g = seen[nm]
                    if not g then
                        g = { name = nm, sids = {}, info = it.info }
                        seen[nm] = g; groups[#groups + 1] = g
                    end
                    g.sids[#g.sids + 1] = it.sid
                end
                table.sort(groups, function(a, b) return (a.name or "") < (b.name or "") end)
                by[typ] = groups
            end
            return by
        end
        -- au moins un des IDs du sort est-il suivi ?
        local function AnyShown(sids)
            for _, s in ipairs(sids) do
                if NS.IsCDShown and NS.IsCDShown(s) then return true end
            end
            return false
        end

        -- ---------- Fenêtre flottante par classe (déplaçable + fermable) ----------
        local winClass, winCat, classWin
        local function RenderClassWindow()
            local w = classWin; if not w or not winClass then return end
            if w.sc and w.listChild then w.listChild:SetWidth(math.max((w.sc:GetWidth() or 300) - 2, 120)) end
            local by = ClassSpells(winClass)
            local avail = {}
            for _, t in ipairs(CAT_SEQ) do if by[t] and #by[t] > 0 then avail[#avail + 1] = t end end
            if not winCat or not by[winCat] then winCat = avail[1] end
            local cc = RAID_CLASS_COLORS and RAID_CLASS_COLORS[winClass]
            w.dot:SetVertexColor(cc and cc.r or 0.6, cc and cc.g or 0.6, cc and cc.b or 0.6)
            w.title:SetText(("|c%s%s|r"):format(ClassColorStr(winClass), NS.ClassLabel(winClass)))
            -- onglets de catégorie (pool)
            w.tabs = w.tabs or {}
            local tx = 12
            for i, t in ipairs(avail) do
                local tb = w.tabs[i]
                if not tb then
                    tb = CreateFrame("Button", nil, w)
                    tb:SetHeight(22)
                    tb.bg = Solid(tb); tb.bg:SetAllPoints()
                    Border(tb, COLORS.line)
                    tb.lbl = Label(tb, 11); tb.lbl:SetPoint("CENTER")
                    w.tabs[i] = tb
                end
                tb.lbl:SetText(NS.L(CATNAME[t]))
                local bw = (tb.lbl:GetStringWidth() or 40) + 18
                tb:SetWidth(bw)
                tb:ClearAllPoints(); tb:SetPoint("TOPLEFT", tx, -42)
                local col = CATCOL[t]
                if t == winCat then
                    tb.bg:SetVertexColor(col[1] * 0.45, col[2] * 0.45, col[3] * 0.45, 0.7)
                    tb.lbl:SetTextColor(1, 1, 1)
                else
                    tb.bg:SetVertexColor(unpack(COLORS.field))
                    tb.lbl:SetTextColor(unpack(COLORS.muted))
                end
                tb:SetScript("OnClick", function() winCat = t; RenderClassWindow() end)
                tb:Show()
                tx = tx + bw + 6
            end
            for i = #avail + 1, #w.tabs do w.tabs[i]:Hide() end
            -- lignes de sorts de la catégorie courante (pool)
            w.rows = w.rows or {}
            local list = by[winCat] or {}
            local y = -2
            for i, item in ipairs(list) do
                local r = w.rows[i]
                if not r then
                    r = CreateFrame("Button", nil, w.listChild)
                    r:SetHeight(24)
                    r:RegisterForClicks("LeftButtonUp")
                    local hl = r:CreateTexture(nil, "HIGHLIGHT"); hl:SetTexture(WHITE); hl:SetAllPoints(); hl:SetVertexColor(1, 1, 1, 0.06)
                    r.box = CreateFrame("Frame", nil, r); r.box:SetSize(16, 16); r.box:SetPoint("LEFT", 2, 0)
                    local bxg = Solid(r.box); bxg:SetAllPoints(); bxg:SetVertexColor(unpack(COLORS.field))
                    Border(r.box, COLORS.line)
                    r.inner = Solid(r.box, "ARTWORK"); r.inner:SetSize(10, 10); r.inner:SetPoint("CENTER"); r.inner:SetVertexColor(unpack(COLORS.accent))
                    -- icône du sort (beaucoup reconnaissent le sort à son icône)
                    r.icon = r:CreateTexture(nil, "ARTWORK"); r.icon:SetSize(20, 20); r.icon:SetPoint("LEFT", 26, 0)
                    r.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
                    r.lbl = Label(r, 13); r.lbl:SetPoint("LEFT", r.icon, "RIGHT", 8, 0)
                    -- clic n'importe où sur la ligne = suivre / ne plus suivre
                    r:SetScript("OnClick", function()
                        if not (r.sids and NS.db) then return end
                        local v = not AnyShown(r.sids)   -- toutes les variantes d'un coup
                        for _, s in ipairs(r.sids) do NS.db.cdShow[s] = v end
                        r.inner:SetShown(v)
                        if NS.Cooldowns and NS.Cooldowns.PruneHidden then NS.Cooldowns.PruneHidden() end
                    end)
                    -- survol (icône ou nom) = infobulle du sort telle qu'en jeu
                    r:SetScript("OnEnter", function()
                        if not r.rep then return end
                        GameTooltip:SetOwner(r, "ANCHOR_RIGHT")
                        if not pcall(GameTooltip.SetSpellByID, GameTooltip, r.rep) then
                            GameTooltip:SetText(NS.SpellName(r.rep, ""))
                        end
                        GameTooltip:Show()
                    end)
                    r:SetScript("OnLeave", function() GameTooltip:Hide() end)
                    w.rows[i] = r
                end
                local g = item
                r.sids, r.rep = g.sids, g.sids[1]
                r.inner:SetShown(AnyShown(g.sids))
                r.icon:SetTexture((NS.SpellIcon and NS.SpellIcon(r.rep)) or 134400)
                r.lbl:SetText(("%s  |cff8b8fa3%ds|r"):format(g.name, g.info.cd or 0))
                r:ClearAllPoints(); r:SetPoint("TOPLEFT", 2, y); r:SetPoint("TOPRIGHT", -2, y)
                r:Show()
                y = y - 26
            end
            for i = #list + 1, #w.rows do w.rows[i]:Hide() end
            w.listChild:SetHeight(math.max(-y + 6, 10))
        end
        NS._refreshCDClassWindow = function()
            if classWin and classWin:IsShown() then RenderClassWindow() end
        end

        local function ApplyToCat(class, cat, shown)
            if not (NS.db and class and cat) then return end
            for _, g in ipairs((ClassSpells(class))[cat] or {}) do
                for _, s in ipairs(g.sids) do NS.db.cdShow[s] = shown end
            end
            if NS.Cooldowns and NS.Cooldowns.PruneHidden then NS.Cooldowns.PruneHidden() end
            RenderClassWindow()
        end
        local function BuildClassWindow()
            if classWin then return classWin end
            local w = CreateFrame("Frame", "AsylumCompagnonCDClassWindow", UIParent)
            -- taille par défaut ~×1.6, redimensionnable par le coin bas-droit (mémorisée)
            w:SetSize((NS.db and NS.db.cdClassWinW) or 576, (NS.db and NS.db.cdClassWinH) or 700)
            w:SetPoint("CENTER")
            w:SetFrameStrata("FULLSCREEN_DIALOG")
            w:EnableMouse(true); w:SetMovable(true); w:SetClampedToScreen(true)
            w:SetResizable(true)
            if w.SetResizeBounds then w:SetResizeBounds(380, 320)
            elseif w.SetMinResize then w:SetMinResize(380, 320) end
            w:Hide()
            local bg = Solid(w); bg:SetAllPoints(); bg:SetVertexColor(0.05, 0.05, 0.07, 0.97)
            Border(w, GOLD_LINE)
            -- entête : drag + pastille classe + titre + fermer
            local hdr = CreateFrame("Frame", nil, w)
            hdr:SetPoint("TOPLEFT"); hdr:SetPoint("TOPRIGHT"); hdr:SetHeight(30)
            local hbg = Solid(hdr); hbg:SetAllPoints(); hbg:SetVertexColor(unpack(COLORS.field))
            local htop = Solid(hdr, "ARTWORK"); htop:SetVertexColor(unpack(GOLD)); htop:SetHeight(2)
            htop:SetPoint("TOPLEFT"); htop:SetPoint("TOPRIGHT")
            hdr:EnableMouse(true)
            hdr:SetScript("OnMouseDown", function() w:StartMoving() end)
            hdr:SetScript("OnMouseUp", function() w:StopMovingOrSizing() end)
            local dot = Solid(hdr, "OVERLAY"); dot:SetSize(12, 12); dot:SetPoint("LEFT", 12, 0); w.dot = dot
            local wt = Label(hdr, 13); wt:SetPoint("LEFT", 30, 0); w.title = wt
            -- bouton fermer : même croix DESSINÉE que la fenêtre principale (en plus petit).
            -- ENFANT du bandeau (comme la croix principale l'est de la barre) + niveau
            -- relevé : sinon le bandeau déplaçable capte le clic et la croix ne ferme rien.
            local xb = CreateFrame("Button", nil, hdr)
            xb:SetSize(22, 22); xb:SetPoint("TOPRIGHT", -4, -4)
            xb:SetFrameLevel((hdr:GetFrameLevel() or 1) + 5)
            xb:RegisterForClicks("LeftButtonUp")
            local xbg = Solid(xb); xbg:SetAllPoints(); xbg:SetVertexColor(1, 0.3, 0.35, 0)
            local xf = CreateFrame("Frame", nil, xb); xf:SetSize(10, 10); xf:SetPoint("CENTER")
            local xlines = {}
            for _, ang in ipairs({ 45, -45 }) do
                local ln = xf:CreateTexture(nil, "ARTWORK")
                ln:SetTexture(WHITE); ln:SetSize(13, 1.6); ln:SetPoint("CENTER"); ln:SetRotation(math.rad(ang))
                xlines[#xlines + 1] = ln
            end
            local function XCol(...) for _, ln in ipairs(xlines) do ln:SetVertexColor(...) end end
            XCol(unpack(COLORS.muted))
            xb:SetScript("OnEnter", function() xbg:SetVertexColor(1, 0.3, 0.35, 0.16); XCol(1, 1, 1) end)
            xb:SetScript("OnLeave", function() xbg:SetVertexColor(1, 0.3, 0.35, 0); XCol(unpack(COLORS.muted)) end)
            xb:SetScript("OnClick", function() w:Hide() end)
            -- liste défilante des sorts
            local sc = CreateFrame("ScrollFrame", nil, w, "UIPanelScrollFrameTemplate")
            sc:SetPoint("TOPLEFT", 10, -72); sc:SetPoint("BOTTOMRIGHT", -28, 44)
            local ch = CreateFrame("Frame", nil, sc); ch:SetSize(300, 10); sc:SetScrollChild(ch)
            w.listChild = ch
            w.sc = sc   -- pour ajuster la largeur de la liste au redimensionnement
            -- boutons bas : Activer tout / Désactiver tout (catégorie courante)
            local bAll = MakeButton(w, NS.L("Activer tout"), 158, 22); bAll:SetPoint("BOTTOMLEFT", 12, 12)
            bAll:SetScript("OnClick", function() ApplyToCat(winClass, winCat, true) end)
            local bNone = MakeButton(w, NS.L("Désactiver tout"), 158, 22); bNone:SetPoint("BOTTOMRIGHT", -12, 12)
            bNone:SetScript("OnClick", function() ApplyToCat(winClass, winCat, false) end)
            -- largeur de la liste qui suit la fenêtre + poignée de redimensionnement (coin bas-droit)
            local function fitList()
                if w.listChild and sc then w.listChild:SetWidth(math.max((sc:GetWidth() or 300) - 2, 120)) end
            end
            w:SetScript("OnSizeChanged", fitList)
            local grip = CreateFrame("Button", nil, w)
            grip:SetSize(16, 16); grip:SetPoint("BOTTOMRIGHT", -2, 2)
            grip:SetFrameLevel((w:GetFrameLevel() or 1) + 10)
            grip:SetNormalTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up")
            grip:SetHighlightTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight")
            grip:SetPushedTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down")
            grip:SetScript("OnMouseDown", function() w:StartSizing("BOTTOMRIGHT") end)
            grip:SetScript("OnMouseUp", function()
                w:StopMovingOrSizing()
                if NS.db then
                    NS.db.cdClassWinW = math.floor(w:GetWidth() + 0.5)
                    NS.db.cdClassWinH = math.floor(w:GetHeight() + 0.5)
                end
                fitList()
                if RenderClassWindow then RenderClassWindow() end
            end)
            fitList()
            classWin = w
            NS.HideCDClassWindow = function() if classWin then classWin:Hide() end end
            return w
        end
        local function OpenClassWindow(class)
            BuildClassWindow()
            winClass, winCat = class, nil
            RenderClassWindow()
            classWin:Show(); classWin:Raise()
        end

        -- ---------- Grille de cadres par classe (couleur de classe) ----------
        local cdGridBuilt = false
        local function BuildClassGrid()
            if cdGridBuilt then return end
            cdGridBuilt = true
            local cols, cardW, cardH, gx, gy = 3, 168, 24, 8, 6
            local i = 0
            for _, class in ipairs(NS.CLASS_ORDER or {}) do
                local n = 0   -- nombre de sorts UNIQUES (déjà dédupliqués par nom)
                for _, groups in pairs(ClassSpells(class)) do n = n + #groups end
                if n > 0 then
                    local card = CreateFrame("Button", nil, cdChild)
                    card:SetSize(cardW, cardH)
                    card:SetPoint("TOPLEFT", (i % cols) * (cardW + gx),
                        -math.floor(i / cols) * (cardH + gy) - 2)
                    local cbg = Solid(card); cbg:SetAllPoints(); cbg:SetVertexColor(unpack(COLORS.field))
                    Border(card, COLORS.line)
                    local cc = RAID_CLASS_COLORS and RAID_CLASS_COLORS[class]
                    local barL = Solid(card, "ARTWORK"); barL:SetWidth(3)
                    barL:SetPoint("TOPLEFT"); barL:SetPoint("BOTTOMLEFT")
                    barL:SetVertexColor(cc and cc.r or 0.6, cc and cc.g or 0.6, cc and cc.b or 0.6)
                    local hl = card:CreateTexture(nil, "HIGHLIGHT"); hl:SetTexture(WHITE); hl:SetAllPoints(); hl:SetVertexColor(1, 1, 1, 0.07)
                    local nm = Label(card, 11); nm:SetPoint("LEFT", 12, 0)
                    nm:SetText(("|c%s%s|r"):format(ClassColorStr(class), NS.ClassLabel(class)))
                    local ctn = Label(card, 10, COLORS.muted); ctn:SetPoint("RIGHT", -10, 0)
                    ctn:SetText(("(%d)"):format(n))
                    card:SetScript("OnClick", function() OpenClassWindow(class) end)
                    i = i + 1
                end
            end
            cdChild:SetHeight(math.ceil(i / cols) * (cardH + gy) + 8)
        end

        UpdateCDPage = function()
            UpdateCDCheckbox()
            SyncCDPreview()
            if NS._syncCDMenuLock then NS._syncCDMenuLock() end   -- reflète l'état du cadenas overlay
            SetCDOpacityDisplay((NS.db and NS.db.cdBgAlpha) or 0.6)
            BuildClassGrid()
        end
    end

    -- ============================================================
    -- PAGE : BUFFS & AURAS (check de préparation raid/M+)
    -- ============================================================
    local buffsPage = CreateFrame("Frame", nil, content)
    buffsPage:SetAllPoints()
    buffsPage:Hide()
    pages.buffs = buffsPage

    local bTitle = Label(buffsPage, 13)
    bTitle:SetPoint("TOPLEFT", 20, -18)
    bTitle:SetText(NS.L("Préparation du groupe"))

    local bRefresh = MakeButton(buffsPage, NS.L("Actualiser"), 110, 24)
    bRefresh:SetPoint("TOPRIGHT", -20, -14)

    local CELL_OK   = "|TInterface\\RaidFrame\\ReadyCheck-Ready:13|t"
    local CELL_KO   = "|TInterface\\RaidFrame\\ReadyCheck-NotReady:13|t"
    local CELL_MID  = "|TInterface\\COMMON\\Indicator-Yellow:13|t"  -- rond orange : présent mais pas OR
    local CELL_NA   = "|cff8b8fa3—|r"

    -- légende des 3 états, sous le titre
    local bLegend = Label(buffsPage, 9, COLORS.muted)
    bLegend:SetPoint("TOPLEFT", 20, -44)
    bLegend:SetText(("%s %s   ·   %s %s   ·   %s %s"):format(
        CELL_OK, NS.L("or / max"), CELL_MID, NS.L("qualité inférieure"), CELL_KO, NS.L("absent")))

    local bGrid = CreateFrame("Frame", nil, buffsPage)
    bGrid:SetPoint("TOPLEFT", 20, -70)
    bGrid:SetPoint("BOTTOMRIGHT", -20, 16)

    local bHeaders, bNames, bCells = {}, {}, {}
    local eHeaders, eNames, eCells = {}, {}, {}
    -- séparateurs dorés : « Consommables » fixe en tête, « Enchantements »
    -- repositionné sous la 1re grille (placeEnch) à chaque refresh.
    GoldSep(bGrid, 0, 0, 560, NS.L("Consommables"), false)
    local placeEnch = GoldSep(bGrid, 0, -260, 560, NS.L("Enchantements"), false)

    -- Dessine un rapport {columns, rows} dans bGrid depuis yTop, avec les
    -- pools fournis. Renvoie le Y du bas (pour empiler une 2e grille dessous).
    local function DrawBuffGrid(rep, yTop, headers, names, cellsPool)
        local nameW = 120
        local nCols = #rep.columns
        local cellW = math.floor(math.min(64, (bGrid:GetWidth() > 0 and (bGrid:GetWidth() - nameW) or 448) / math.max(1, nCols)))
        for j, label in ipairs(rep.columns) do
            local h = headers[j]
            if not h then h = Label(bGrid, 10, COLORS.muted); headers[j] = h end
            h:ClearAllPoints()
            h:SetPoint("TOPLEFT", nameW + (j - 1) * cellW, yTop)
            h:SetWidth(cellW); h:SetJustifyH("CENTER"); h:SetWordWrap(false)
            h:SetText(label); h:Show()
        end
        for j = nCols + 1, #headers do headers[j]:Hide() end
        for i, row in ipairs(rep.rows) do
            local y = yTop - (20 + (i - 1) * 24)
            local nameFS = names[i]
            if not nameFS then nameFS = Label(bGrid, 12); names[i] = nameFS; cellsPool[i] = {} end
            nameFS:ClearAllPoints()
            nameFS:SetPoint("TOPLEFT", 0, y)
            nameFS:SetWidth(nameW - 8); nameFS:SetJustifyH("LEFT"); nameFS:SetWordWrap(false)
            nameFS:SetText(("|c%s%s|r"):format(ClassColorStr(row.class), row.name))
            nameFS:Show()
            for j = 1, nCols do
                local c = cellsPool[i][j]
                if not c then c = Label(bGrid, 12); cellsPool[i][j] = c end
                c:ClearAllPoints()
                c:SetPoint("TOPLEFT", nameW + (j - 1) * cellW, y)
                c:SetWidth(cellW); c:SetJustifyH("CENTER")
                -- booléen (buffs/enchant) OU niveau qualité (2=OR, 1=inférieur, 0=rien)
                local v = row.cells[j]
                if v == true or v == 2 then c:SetText(CELL_OK)
                elseif v == 1 then c:SetText(CELL_MID)
                elseif v == false or v == 0 then c:SetText(CELL_KO)
                else c:SetText(CELL_NA) end
                c:Show()
            end
            for j = nCols + 1, #cellsPool[i] do cellsPool[i][j]:Hide() end
        end
        for i = #rep.rows + 1, #names do
            names[i]:Hide()
            for _, c in ipairs(cellsPool[i]) do c:Hide() end
        end
        return yTop - (20 + #rep.rows * 24)
    end

    RefreshBuffsPage = function()
        if not (NS.Buffs and NS.Buffs.GetReport) then return end
        -- grille « Consommables » sous son séparateur (à y=0)
        local bottom = DrawBuffGrid(NS.Buffs.GetReport(), -24, bHeaders, bNames, bCells)
        -- séparateur « Enchantements » repositionné puis 2e grille dessous
        if NS.Buffs.GetEnchantReport then
            local sy = bottom - 12
            placeEnch(sy)
            DrawBuffGrid(NS.Buffs.GetEnchantReport(), sy - 26, eHeaders, eNames, eCells)
        end
    end

    bRefresh:SetScript("OnClick", function() RefreshBuffsPage() end)

    -- rafraîchissement live de la grille buffs + accueil (quand MES flags changent
    -- ou qu'un pair rediffuse les siens — voir Buffs.lua / Sync.OnHello).
    NS.RefreshBuffsUI = function()
        if pages and pages.buffs and pages.buffs:IsShown() and RefreshBuffsPage then RefreshBuffsPage() end
        if pages and pages.home and pages.home:IsShown() and UpdateHomePage then UpdateHomePage() end
    end

    -- ============================================================
    -- PAGE : BUTIN (aide au loot — upgrades pour le groupe)
    -- ============================================================
    local lootPage = CreateFrame("Frame", nil, content)
    lootPage:SetAllPoints()
    lootPage:Hide()
    pages.loot = lootPage

    local lTitle = Label(lootPage, 13)
    lTitle:SetPoint("TOPLEFT", 20, -18)
    lTitle:SetText(NS.L("Aide au butin"))

    local lHint = Label(lootPage, 11, COLORS.muted)
    lHint:SetPoint("TOPLEFT", 20, -40)
    lHint:SetWidth(WIN_W - SIDEBAR_W - 150)
    lHint:SetJustifyH("LEFT")
    lHint:SetText(NS.L("Survole un objet équipable (sac, butin, lien de chat) : l'addon dit pour qui du groupe c'est un up, d'après l'équipement partagé. Comparaison par ilvl, filtrée par classe et spé."))

    local lRefresh = MakeButton(lootPage, NS.L("Actualiser"), 110, 24)
    lRefresh:SetPoint("TOPRIGHT", -20, -14)

    -- Case : afficher les upgrades dans les infobulles
    local ttBox = CreateFrame("Button", nil, lootPage)
    ttBox:SetSize(16, 16)
    ttBox:SetPoint("TOPLEFT", 20, -82)
    local ttBg = Solid(ttBox)
    ttBg:SetAllPoints()
    ttBg:SetVertexColor(unpack(COLORS.field))
    Border(ttBox, COLORS.line)
    local ttInner = Solid(ttBox, "ARTWORK")
    ttInner:SetSize(10, 10)
    ttInner:SetPoint("CENTER")
    ttInner:SetVertexColor(unpack(COLORS.accent))
    local ttLbl = Label(lootPage, 12)
    ttLbl:SetPoint("LEFT", ttBox, "RIGHT", 10, 0)
    ttLbl:SetText(NS.L("Afficher les upgrades dans les infobulles"))
    local function UpdateTTBox()
        ttInner:SetShown(not (NS.db and NS.db.lootTooltip == false))
    end
    ttBox:SetScript("OnClick", function()
        if not NS.db then return end
        NS.db.lootTooltip = (NS.db.lootTooltip == false) and true or false
        UpdateTTBox()
    end)

    -- (l'option « SpellID / IconID dans les infobulles » a été déplacée
    --  dans le menu Toolbox)

    local lGrid = CreateFrame("Frame", nil, lootPage)
    lGrid:SetPoint("TOPLEFT", 20, -110)
    lGrid:SetPoint("BOTTOMRIGHT", -20, 16)

    local lRows = {}
    RefreshLootPage = function()
        UpdateTTBox()
        if not (NS.Loot and NS.Loot.GetGroupReport) then return end
        local rep = NS.Loot.GetGroupReport()
        for i, e in ipairs(rep) do
            local fs = lRows[i]
            if not fs then
                fs = Label(lGrid, 12)
                fs:SetPoint("TOPLEFT", 0, -(i - 1) * 26)
                fs:SetPoint("RIGHT", lGrid, "RIGHT", 0, 0)
                fs:SetJustifyH("LEFT")
                fs:SetWordWrap(false)
                lRows[i] = fs
            end
            local weakParts = {}
            for _, w in ipairs(e.weak) do
                weakParts[#weakParts + 1] =
                    (NS.L(NS.LOOT_SLOT_LABEL[w.inv]) or ("slot " .. w.inv)) .. " " .. w.ilvl
            end
            local weakStr = (#weakParts > 0)
                and (NS.L(" |cff8b8fa3· faibles : ") .. table.concat(weakParts, ", ") .. "|r") or ""
            local avgStr = (e.avg > 0)
                and (NS.L(" |cffcfd2dc· ilvl moy ") .. e.avg .. "|r")
                or NS.L(" |cff8b8fa3· pas de données|r")
            fs:SetText(("|c%s%s|r%s%s%s"):format(
                ClassColorStr(e.class), e.name,
                "", avgStr, weakStr))
            fs:Show()
        end
        for i = #rep + 1, #lRows do lRows[i]:Hide() end
    end
    lRefresh:SetScript("OnClick", function() RefreshLootPage() end)

    -- appelé par Loot.lua quand des slots d'un pair arrivent
    function NS.RefreshLootUI()
        if UI and UI:IsShown() and pages.loot and pages.loot:IsShown() then
            RefreshLootPage()
        end
    end

    -- ============================================================
    -- PAGE : CLÉS & COFFRE (clé M+ de la semaine + Great Vault)
    -- ============================================================
    local vaultPage = CreateFrame("Frame", nil, content)
    vaultPage:SetAllPoints()
    vaultPage:Hide()
    pages.vault = vaultPage

    local vTitle = Label(vaultPage, 13)
    vTitle:SetPoint("TOPLEFT", 20, -18)
    vTitle:SetText(NS.L("Clés Mythique+ & Coffre convoité"))

    local vRefresh = MakeButton(vaultPage, NS.L("Actualiser"), 110, 24)
    vRefresh:SetPoint("TOPRIGHT", -20, -14)

    local vHint = Label(vaultPage, 11, COLORS.muted)
    vHint:SetPoint("TOPLEFT", 20, -42)
    vHint:SetText(NS.L("Survole un nom pour son coffre (9 cases). Joueurs en ligne : ton groupe, puis ta guilde."))

    -- réglages de l'assistant de clé (auto-slot + boutons)
    local function VaultCheck(y, text, dbKey, onToggle)
        local box = CreateFrame("Button", nil, vaultPage)
        box:SetSize(16, 16)
        box:SetPoint("TOPLEFT", 20, y)
        local bg = Solid(box)
        bg:SetAllPoints()
        bg:SetVertexColor(unpack(COLORS.field))
        Border(box, COLORS.line)
        local inner = Solid(box, "ARTWORK")
        inner:SetSize(10, 10)
        inner:SetPoint("CENTER")
        inner:SetVertexColor(unpack(COLORS.accent))
        inner:SetShown(not (NS.db and NS.db[dbKey] == false))
        local lbl = Label(vaultPage, 10)
        lbl:SetPoint("LEFT", box, "RIGHT", 8, 0)
        lbl:SetText(NS.L(text))
        box:SetScript("OnClick", function()
            if not NS.db then return end
            NS.db[dbKey] = (NS.db[dbKey] == false) and true or false
            inner:SetShown(NS.db[dbKey] ~= false)
            if onToggle then onToggle() end
        end)
    end
    VaultCheck(-62, "Insérer ma clé automatiquement", "keyAutoSlot")
    VaultCheck(-84, "Boutons Ready check + Pull sur la fenêtre de clé", "keyAssistant",
        function() if NS.Keystone and NS.Keystone.RefreshButtons then NS.Keystone.RefreshButtons() end end)

    -- popup des 9 cases (3 pistes × 3), affiché au survol
    local vaultPopup = CreateFrame("Frame", nil, UIParent)
    vaultPopup:SetFrameStrata("DIALOG")
    vaultPopup:SetSize(520, 236)
    vaultPopup:SetClampedToScreen(true) -- ne sort jamais de l'écran
    vaultPopup:Hide()
    local vpBg = Solid(vaultPopup)
    vpBg:SetAllPoints()
    vpBg:SetVertexColor(0.05, 0.055, 0.07, 0.98)
    Border(vaultPopup, COLORS.accent)
    local vpTitle = Label(vaultPopup, 13)
    vpTitle:SetPoint("TOPLEFT", 16, -12)
    vaultPopup.tracks = {}
    for r = 1, 3 do
        local yy = -44 - (r - 1) * 50
        local lab = Label(vaultPopup, 13, COLORS.muted)
        lab:SetPoint("TOPLEFT", 16, yy)
        lab:SetWidth(84)
        lab:SetJustifyH("LEFT")
        lab:SetWordWrap(false)
        local cells = {}
        for c = 1, 3 do
            local cell = CreateFrame("Frame", nil, vaultPopup)
            cell:SetSize(92, 42)
            cell:SetPoint("TOPLEFT", 108 + (c - 1) * 102, yy + 6)
            local cbg = Solid(cell)
            cbg:SetAllPoints()
            cbg:SetVertexColor(unpack(COLORS.field))
            Border(cell, COLORS.line)
            local fs = Label(cell, 14)
            fs:SetPoint("CENTER")
            cells[c] = { frame = cell, fs = fs, bg = cbg }
        end
        local hintFs = Label(vaultPopup, 14)
        hintFs:SetPoint("LEFT", cells[3].frame, "RIGHT", 12, 0)
        hintFs:SetJustifyH("LEFT")
        vaultPopup.tracks[r] = { label = lab, cells = cells, hint = hintFs }
    end
    local vpEmpty = Label(vaultPopup, 12, COLORS.muted)
    vpEmpty:SetPoint("TOPLEFT", 16, -44)
    vpEmpty:SetText(NS.L("Pas encore de données de coffre."))
    local vpLegend = Label(vaultPopup, 11, COLORS.muted)
    vpLegend:SetPoint("BOTTOMLEFT", 16, 10)
    vpLegend:SetText(NS.L("cases = ilvl du reward · → ilvl au palier suivant"))

    local function FillPopup(entry)
        vpTitle:SetText(("|c%s%s|r  |cff8b8fa3%s|r"):format(
            ClassColorStr(entry.class), entry.name, entry.keyText or ""))
        local tracks = entry.tracks or {}
        vpEmpty:SetShown(#tracks == 0)
        vpLegend:SetShown(#tracks > 0)
        for r = 1, 3 do
            local ui = vaultPopup.tracks[r]
            local tr = tracks[r]
            if tr then
                ui.label:SetText(tr.label)
                ui.label:Show()
                for c = 1, 3 do
                    local cell = ui.cells[c]
                    local data = tr.cells and tr.cells[c]
                    if data then
                        if data.filled then
                            cell.fs:SetText(data.ilvl and tostring(data.ilvl)
                                or ("%d/%d"):format(data.progress, data.threshold))
                            cell.fs:SetTextColor(0.35, 0.85, 0.45, 1)
                            cell.bg:SetVertexColor(0.12, 0.30, 0.16, 1)
                        else
                            cell.fs:SetText(("%d/%d"):format(data.progress, data.threshold))
                            cell.fs:SetTextColor(0.55, 0.56, 0.62, 1)
                            cell.bg:SetVertexColor(unpack(COLORS.field))
                        end
                        cell.frame:Show()
                    else
                        cell.frame:Hide()
                    end
                end
                if tr.nextIlvl then
                    ui.hint:SetText(("→ %d"):format(tr.nextIlvl))
                    ui.hint:SetTextColor(0.35, 0.85, 0.45, 1)
                else
                    ui.hint:SetText("")
                end
                ui.hint:Show()
            else
                ui.label:Hide()
                ui.hint:SetText("")
                for c = 1, 3 do ui.cells[c].frame:Hide() end
            end
        end
    end

    -- Rangées joueurs + en-têtes de section (Groupe / Guilde). On n'affiche QUE
    -- des joueurs EN LIGNE, mon groupe en tête puis les membres de guilde en
    -- ligne (hors groupe). Le curseur « y » descend au fil des sections.
    local vRows, vHeaders = {}, {}
    RefreshVaultPage = function()
        if not (NS.Vault and NS.Vault.GetReport) then return end
        local rep = NS.Vault.GetReport()
        for _, r in ipairs(vRows) do r:Hide() end
        for _, h in ipairs(vHeaders) do h:Hide() end
        local y, ri, hi, lastSection = 112, 0, 0, nil
        for _, e in ipairs(rep) do
            if e.section ~= lastSection then                 -- nouvel en-tête de section
                if lastSection then y = y + 8 end            -- respiration entre les sections
                hi = hi + 1
                local h = vHeaders[hi]
                if not h then
                    h = Label(vaultPage, 11, COLORS.accent)
                    h:SetJustifyH("LEFT")
                    vHeaders[hi] = h
                end
                h:ClearAllPoints(); h:SetPoint("TOPLEFT", 20, -y)
                h:SetText(e.section == "guild" and NS.L("Guilde")
                    or e.section == "solo" and NS.L("Solo")
                    or NS.L("Groupe"))
                -- couleur par section : Guilde = vert ; Groupe/Solo = violet (groupe)
                h:SetTextColor(unpack(e.section == "guild" and COLORS.green or COLORS.accent))
                h:Show()
                y = y + 20
                lastSection = e.section
            end
            ri = ri + 1
            local row = vRows[ri]
            if not row then
                row = CreateFrame("Button", nil, vaultPage)
                row:SetHeight(24)
                local hl = row:CreateTexture(nil, "HIGHLIGHT")
                hl:SetTexture(WHITE)
                hl:SetAllPoints()
                hl:SetVertexColor(1, 1, 1, 0.05)
                row.txt = Label(row, 12)
                row.txt:SetPoint("LEFT", 4, 0)
                row.txt:SetJustifyH("LEFT")
                row:SetScript("OnEnter", function(self)
                    if not self.entry then return end
                    FillPopup(self.entry)
                    vaultPopup:ClearAllPoints()
                    vaultPopup:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 40, -2)
                    vaultPopup:Show()
                end)
                row:SetScript("OnLeave", function() vaultPopup:Hide() end)
                vRows[ri] = row
            end
            row:ClearAllPoints()
            row:SetPoint("TOPLEFT", 20, -y)
            row:SetPoint("TOPRIGHT", -20, -y)
            row.entry = e
            row.txt:SetText(("|c%s%s|r%s   |cff8b8fa3%s|r"):format(
                ClassColorStr(e.class), e.name,
                "", e.keyText or ""))
            row:Show()
            y = y + 26
        end
    end
    vRefresh:SetScript("OnClick", function()
        if NS.RequestGuildRoster then NS.RequestGuildRoster() end
        RefreshVaultPage()
    end)

    function NS.RefreshVaultUI()
        if UI and UI:IsShown() and pages.vault and pages.vault:IsShown() then
            RefreshVaultPage()
        end
    end

    -- ============================================================
    -- PAGE : ALERTES (choix des notifications)
    -- ============================================================
    -- Page ALERTES : construite hors de BuildUI (fonction dédiée), exactement
    -- comme la Boîte à outils, pour un agencement identique et pour rester sous
    -- la limite Lua des 200 variables locales par fonction.
    UpdateAlertsPage = BuildAlertsPage(content, pages)

    -- appelé par Sync.lua / SimC.lua quand des données changent
    function NS.RefreshOptimUI(kind, who)
        if not UI or not UI:IsShown() then return end
        if pages.optim:IsShown() then
            refreshOptimList()
            if NS.RefreshGearCompassUI then NS.RefreshGearCompassUI() end
        elseif pages.inv:IsShown() then
            refreshInvList()
        elseif pages.detail:IsShown() and currentDetail then
            if (kind == "self" and currentDetail == "ME") or (who and who == currentDetail) then
                FillDetail(currentDetail)
            end
        elseif pages.invdetail:IsShown() and currentInv then
            if (kind == "self" and currentInv == "ME") or (who and who == currentInv) then
                FillInvDetail(currentInv)
            end
        end
    end

    -- ============================================================
    -- PAGE : TOOLBOX — construite hors de BuildUI (fonction dédiée) pour
    -- rester sous la limite Lua de 200 variables locales par fonction.
    -- ============================================================
    UpdateToolboxPage = BuildToolboxPage(content, pages)

    -- PAGE : STATS M+ (fonction dédiée ; se rafraîchit via NS.RefreshStatsPage)
    BuildStatsPage(content, pages)

    -- PAGE : DIAG (fonction dédiée) — communication, combat + zone copiable
    BuildDiagPage(content, pages)

    -- ----- Animation d'ouverture + sons -----
    local anim = UI:CreateAnimationGroup()
    local fade = anim:CreateAnimation("Alpha")
    fade:SetFromAlpha(0)
    fade:SetToAlpha(1)
    fade:SetDuration(0.16)
    fade:SetSmoothing("OUT")
    local grow = anim:CreateAnimation("Scale")
    grow:SetScaleFrom(0.96, 0.96)
    grow:SetScaleTo(1, 1)
    grow:SetDuration(0.16)
    grow:SetSmoothing("OUT")
    grow:SetOrigin("CENTER", 0, 0)

    UI:SetScript("OnShow", function()
        anim:Play()
        if SOUNDKIT then PlaySound(SOUNDKIT.IG_MAINMENU_OPEN) end
        sVersion:SetText("v" .. (NS.VERSION or "0.43.4"))
        if NS.Sync and NS.Sync.available then
            syncDot:SetVertexColor(unpack(COLORS.green))
            syncLabel:SetText(NS.L("Synchro active"))
        else
            syncDot:SetVertexColor(unpack(COLORS.gray))
            syncLabel:SetText(NS.L("Synchro indisponible"))
        end
        if pages.home:IsShown() and UpdateHomePage then UpdateHomePage() end
        if pages.optim:IsShown() then refreshOptimList() end
        if pages.inv:IsShown() then refreshInvList() end
    end)
    UI:SetScript("OnHide", function()
        if SOUNDKIT then PlaySound(SOUNDKIT.IG_MAINMENU_CLOSE) end
    end)

    ShowPage("home")
end

-- ---------- API publique ----------
function NS.Toggle()
    if not UI then BuildUI() end
    if UI:IsShown() then
        UI:Hide()
    else
        UI:Show()
    end
end
-- fin de UI.lua
