if select(2, ...).IS_LEGACY_CLIENT then return end -- HET-LEGACY-1207: no-op once Legacy1207 is deleted
local addonName, ns = ...
local HET = ns.addon

local AC = LibStub("AceConfig-3.0")
local ACD = LibStub("AceConfigDialog-3.0")
local ACR = LibStub("AceConfigRegistry-3.0")

local LSM = LibStub("LibSharedMedia-3.0", true)

local MAX_ELEMENTS = ns.MAX_ELEMENTS or 4

local STYLE_VALUES = {
    icon    = "Icon",
    bar     = "Bar",
    circle  = "Circle",
    text    = "Text",
    texture = "Texture",
    sound   = "Sound Alert",
}
local STYLE_SORTING = { "icon", "bar", "circle", "text", "texture", "sound" }

-- Font outline flags (WoW SetFont). Keys are the literal flag strings
-- passed to FontString:SetFont.
local FONT_BORDER_VALUES = {
    [""]                         = "None",
    ["OUTLINE"]                  = "Outline",
    ["THICKOUTLINE"]             = "Thick Outline",
    ["MONOCHROME"]               = "Monochrome",
    ["OUTLINE, MONOCHROME"]      = "Outline + Monochrome",
    ["THICKOUTLINE, MONOCHROME"] = "Thick Outline + Monochrome",
}
local FONT_BORDER_SORTING = {
    "", "OUTLINE", "THICKOUTLINE", "MONOCHROME",
    "OUTLINE, MONOCHROME", "THICKOUTLINE, MONOCHROME",
}

-- "Show When" visibility modes for ID-kind (ability/item) indicators. Shared by
-- the per-id row dropdown in the Tracked Abilities / Items list and (historically)
-- the element-level dropdown. Matches the runtime IDPasses modes in Tracker.lua.
local ID_VISIBILITY_VALUES = {
    always  = "Always (while loaded)",
    oncd    = "Only While On Cooldown",
    usable  = "Only When Usable",
}
local ID_VISIBILITY_SORTING = { "always", "oncd", "usable" }

-- Icon-parented text needs corners (a countdown and a stack count share
-- one square); bars keep their 3-position left/center/right scheme.
local ICON_TEXT_POS = {
    topleft    = "Top Left",    top    = "Top",    topright    = "Top Right",
    left       = "Left",        center = "Center", right       = "Right",
    bottomleft = "Bottom Left", bottom = "Bottom", bottomright = "Bottom Right",
}
local ICON_TEXT_POS_SORTING = {
    "topleft", "top", "topright",
    "left", "center", "right",
    "bottomleft", "bottom", "bottomright",
}
local BAR_TEXT_POS = { left = "Left", center = "Center", right = "Right" }
local BAR_TEXT_POS_SORTING = { "left", "center", "right" }
-- Vertical bars run bottom->top, so the 3 positions along the fill are
-- named for that axis instead.
local BAR_TEXT_POS_V = { bottom = "Bottom", center = "Center", top = "Top" }
local BAR_TEXT_POS_V_SORTING = { "bottom", "center", "top" }

-- Text entry types. Each maps to ONE engine binding (ns.TEXT_BINDING),
-- and a binding drives only one FontString — so Stacks and
-- Stacks / Max Stacks are alternatives, not companions.
local TEXT_TYPE_VALUES = {
    duration = "Duration",
    stacks   = "Stacks",
    name     = "Buff Name",
}
local TEXT_TYPE_SORTING = { "duration", "stacks", "name" }

-- How a Stacks entry renders. Current is the bare binding (engine hides
-- it below 2 applications); the other two supply a format string and so
-- always render.
local STACK_DISPLAY_VALUES = {
    current    = "Current Stacks",
    currentmax = "Current / Max",
    max        = "Max",
}
local STACK_DISPLAY_SORTING = { "current", "currentmax", "max" }
-- Distinct bindings = the hard ceiling on simultaneous text entries.
local MAX_TEXTS = 3

-- Load Conditions "Instance Type" buckets, in display order. Keys match
-- ns.CurrentInstanceBucket() (Tracker.lua).
local INSTANCE_TYPES = {
    { key = "world",   label = "Open World" },
    { key = "dungeon", label = "Dungeon" },
    { key = "delve",   label = "Delve" },
    { key = "raid",    label = "Raid" },
    { key = "arena",   label = "Arena" },
    { key = "bg",      label = "Battleground" },
}

-- Load Conditions "Role" buckets, in display order. Keys match the strings
-- returned by GetSpecializationRoleByID / cached in ns.playerRole
-- (Tracker.lua). Role icons use the standard Blizzard role atlases.
local ROLE_TYPES = {
    { key = "TANK",    label = "Tank",   atlas = "roleicon-tiny-tank"   },
    { key = "HEALER",  label = "Healer", atlas = "roleicon-tiny-healer" },
    { key = "DAMAGER", label = "DPS",    atlas = "roleicon-tiny-dps"    },
}

-- Load Conditions "Spec" data (WoW 12.0 specializations), with a `class`
-- field so the list can be grouped by class then alphabetized. Pre-sorted
-- here: class alphabetical
-- (by display name), then spec name alphabetical within each class — the
-- SPEC_SORTING array below mirrors this order for the AceConfig group.
local SPEC_TYPES = {
    { id=250, name="Blood", icon="Interface\\Icons\\Spell_DeathKnight_BloodPresence", role="TANK", class="DEATHKNIGHT" },
    { id=251, name="Frost", icon="Interface\\Icons\\Spell_Deathknight_FrostPresence", role="DAMAGER", class="DEATHKNIGHT" },
    { id=252, name="Unholy", icon="Interface\\Icons\\Spell_DeathKnight_UnholyPresence", role="DAMAGER", class="DEATHKNIGHT" },
    { id=1480, name="Devourer", icon="Interface\\Icons\\Classicon_demonhunter_void", role="DAMAGER", class="DEMONHUNTER" },
    { id=577, name="Havoc", icon="Interface\\Icons\\Ability_DemonHunter_SpecDPS", role="DAMAGER", class="DEMONHUNTER" },
    { id=581, name="Vengeance", icon="Interface\\Icons\\Ability_DemonHunter_SpecTank", role="TANK", class="DEMONHUNTER" },
    { id=102, name="Balance", icon="Interface\\Icons\\Spell_Nature_StarFall", role="DAMAGER", class="DRUID" },
    { id=103, name="Feral", icon="Interface\\Icons\\Ability_Druid_CatForm", role="DAMAGER", class="DRUID" },
    { id=104, name="Guardian", icon="Interface\\Icons\\Ability_Racial_BearForm", role="TANK", class="DRUID" },
    { id=105, name="Restoration", icon="Interface\\Icons\\Spell_Nature_HealingTouch", role="HEALER", class="DRUID" },
    { id=1473, name="Augmentation", icon="Interface\\Icons\\ClassIcon_Evoker_Augmentation", role="DAMAGER", class="EVOKER" },
    { id=1467, name="Devastation", icon="Interface\\Icons\\ClassIcon_Evoker_Devastation", role="DAMAGER", class="EVOKER" },
    { id=1468, name="Preservation", icon="Interface\\Icons\\ClassIcon_Evoker_Preservation", role="HEALER", class="EVOKER" },
    { id=253, name="Beast Mastery", icon="Interface\\Icons\\Ability_Hunter_BeastMastery", role="DAMAGER", class="HUNTER" },
    { id=254, name="Marksmanship", icon="Interface\\Icons\\Ability_Hunter_FocusedAim", role="DAMAGER", class="HUNTER" },
    { id=255, name="Survival", icon="Interface\\Icons\\Ability_Hunter_Camouflage", role="DAMAGER", class="HUNTER" },
    { id=62, name="Arcane", icon="Interface\\Icons\\Spell_Holy_MagicalSentry", role="DAMAGER", class="MAGE" },
    { id=63, name="Fire", icon="Interface\\Icons\\Spell_Fire_FireBolt02", role="DAMAGER", class="MAGE" },
    { id=64, name="Frost", icon="Interface\\Icons\\Spell_Frost_FrostBolt02", role="DAMAGER", class="MAGE" },
    { id=268, name="Brewmaster", icon="Interface\\Icons\\Monk_Stance_DrunkenOx", role="TANK", class="MONK" },
    { id=270, name="Mistweaver", icon="Interface\\Icons\\Monk_Stance_WiseSerpent", role="HEALER", class="MONK" },
    { id=269, name="Windwalker", icon="Interface\\Icons\\Spell_Monk_Windwalker_Spec", role="DAMAGER", class="MONK" },
    { id=65, name="Holy", icon="Interface\\Icons\\Spell_Holy_HolyBolt", role="HEALER", class="PALADIN" },
    { id=66, name="Protection", icon="Interface\\Icons\\Ability_Paladin_ShieldOfTheTemplar", role="TANK", class="PALADIN" },
    { id=70, name="Retribution", icon="Interface\\Icons\\Spell_Holy_AuraOfLight", role="DAMAGER", class="PALADIN" },
    { id=256, name="Discipline", icon="Interface\\Icons\\Spell_Holy_PowerWordShield", role="HEALER", class="PRIEST" },
    { id=257, name="Holy", icon="Interface\\Icons\\Spell_Holy_GuardianSpirit", role="HEALER", class="PRIEST" },
    { id=258, name="Shadow", icon="Interface\\Icons\\Spell_Shadow_ShadowWordPain", role="DAMAGER", class="PRIEST" },
    { id=259, name="Assassination", icon="Interface\\Icons\\Ability_Rogue_DeadlyBrew", role="DAMAGER", class="ROGUE" },
    { id=260, name="Outlaw", icon="Interface\\Icons\\Ability_Rogue_Waylay", role="DAMAGER", class="ROGUE" },
    { id=261, name="Subtlety", icon="Interface\\Icons\\Ability_Stealth", role="DAMAGER", class="ROGUE" },
    { id=262, name="Elemental", icon="Interface\\Icons\\Spell_Nature_Lightning", role="DAMAGER", class="SHAMAN" },
    { id=263, name="Enhancement", icon="Interface\\Icons\\Spell_Nature_LightningShield", role="DAMAGER", class="SHAMAN" },
    { id=264, name="Restoration", icon="Interface\\Icons\\Spell_Nature_MagicImmunity", role="HEALER", class="SHAMAN" },
    { id=265, name="Affliction", icon="Interface\\Icons\\Spell_Shadow_DeathCoil", role="DAMAGER", class="WARLOCK" },
    { id=266, name="Demonology", icon="Interface\\Icons\\Spell_Shadow_Metamorphosis", role="DAMAGER", class="WARLOCK" },
    { id=267, name="Destruction", icon="Interface\\Icons\\Spell_Shadow_RainOfFire", role="DAMAGER", class="WARLOCK" },
    { id=71, name="Arms", icon="Interface\\Icons\\Ability_Warrior_SavageBlow", role="DAMAGER", class="WARRIOR" },
    { id=72, name="Fury", icon="Interface\\Icons\\Ability_Warrior_InnerRage", role="DAMAGER", class="WARRIOR" },
    { id=73, name="Protection", icon="Interface\\Icons\\Ability_Warrior_DefensiveStance", role="TANK", class="WARRIOR" },
}

-- Multi-aura tracker = spellList group OR group-kind custom OR ID-kind group
-- (abilitygroup/itemgroup, which fan out over their id list just like a custom
-- group). "Multi-aura" here means "presents as a fan-out" for the element
-- display options — the ID groups have no aura, but share the same layout UI.
local function IsMultiAuraCfg(cfgKey)
    local cfg = ns.trackerConfigs[cfgKey]
    if cfg.spellList then return true end
    if cfg.custom then
        local t = ns.trackers[cfgKey]
        local entry = t and t:GetCustomEntry()
        return (entry and (entry.kind == "group"
            or entry.kind == "abilitygroup" or entry.kind == "itemgroup"))
            and true or false
    end
    return false
end

-- Single-active groups are spellList groups where only ONE of their buffs
-- can be on the player at a time (Time Spiral, Bloodlust/Heroism), so a
-- single display always shows the active one. Data-driven via
-- config.singleActive so any such group opts in with one field.
local function IsSingleActiveCfg(cfgKey)
    local cfg = ns.trackerConfigs[cfgKey]
    return (cfg and cfg.singleActive) and true or false
end

-- Type labels pluralize on multi-aura trackers (Icons/Bars fan out) —
-- EXCEPT single-active groups, which show one buff so keep Icon/Bar.
local function StyleLabel(cfgKey, k)
    if IsMultiAuraCfg(cfgKey) and not IsSingleActiveCfg(cfgKey) then
        if k == "icon" then return "Icons" end
        if k == "bar" then return "Bars" end
    end
    return STYLE_VALUES[k]
end

-- Multi-aura trackers don't offer the Text type (single-display
-- countdown text is meaningless across several simultaneous buffs) —
-- except single-active groups (config.singleActive), which show one buff
-- and so keep it. A grandfathered text element keeps working regardless.
-- Is this tracker a custom DEBUFF? Only custom trackers can be debuffs;
-- every built-in is a buff. Drives the subtab it lives under and which
-- tracker types it offers (Sound Alert is buff-only).
local function IsDebuffCfg(cfgKey)
    local id = cfgKey:match("^custom(%d+)$")
    if not id then return false end
    id = tonumber(id)
    local list = HET.db.profile.customTrackers
    if type(list) == "table" then
        for i = 1, #list do
            if list[i].id == id then return list[i].harmful and true or false end
        end
    end
    return false
end

-- The customTrackers entry for a cfgKey ("custom<id>"), or nil for built-ins.
local function CustomEntryFor(cfgKey)
    local id = cfgKey:match("^custom(%d+)$")
    if not id then return nil end
    id = tonumber(id)
    local list = HET.db.profile.customTrackers
    if type(list) == "table" then
        for i = 1, #list do
            if list[i].id == id then return list[i] end
        end
    end
    return nil
end

-- Is this tracker a "custom" kind (no aura dependency, Custom subtab)? These
-- render a user icon/text/sound driven purely by Load Conditions.
local function IsCustomKindCfg(cfgKey)
    local e = CustomEntryFor(cfgKey)
    return (e and e.kind == "custom") and true or false
end

-- Ability/Item kinds (no aura): a spell/item ID drives an icon with an
-- optional Cooldown swipe + Charges/Stacks text. Groups fan out over an ID
-- list. These live in the merged Abilities/Items subtab.
--
-- MIXED-GROUP MODEL (12.1): a single ID-kind GROUP now holds BOTH abilities
-- and items, distinguished PER ENTRY by a side-table p[prefix.."idKinds"] =
-- { [id] = "ability" | "item" }. We keep the two kind values abilitygroup /
-- itemgroup as-is for storage compatibility (no kind migration) and treat
-- BOTH as mixed-capable: the kind only sets the group's DEFAULT entry type
-- (which of the two "Add ..." rows is primed), while the per-id tag governs
-- each entry's actual ability/item behavior. IsAbilityKindCfg / IsItemKindCfg
-- therefore describe a group's DEFAULT type only — for per-entry decisions in
-- a group, use IDKindOfCfg. Single ability/item trackers stay pure (they have
-- exactly one id, tagged by their own kind).
local function IsAbilityKindCfg(cfgKey)
    local e = CustomEntryFor(cfgKey)
    return (e and (e.kind == "ability" or e.kind == "abilitygroup")) and true or false
end
local function IsItemKindCfg(cfgKey)
    local e = CustomEntryFor(cfgKey)
    return (e and (e.kind == "item" or e.kind == "itemgroup")) and true or false
end
-- Either ability or item — the shared "ID kind" that has no aura but a real
-- ID and cooldown/charge/stack tracking (unlike custom, which is static art).
local function IsIDKindCfg(cfgKey)
    return IsAbilityKindCfg(cfgKey) or IsItemKindCfg(cfgKey)
end
-- Is this an ID-kind GROUP (fan-out over an ID list)?
local function IsIDGroupCfg(cfgKey)
    local e = CustomEntryFor(cfgKey)
    return (e and (e.kind == "abilitygroup" or e.kind == "itemgroup")) and true or false
end
-- Per-id "is this entry an item?" for a tracker. Reads the per-entry side-table
-- p[prefix.."idKinds"][id] ("ability"/"item"); when absent falls back to the
-- tracker's own default kind (IsItemKindCfg). This is the cfg-level mirror of
-- ns.IDKindOf (Tracker.lua). Callers deciding an ID's ability/item behavior in
-- a GROUP must use this, not IsItemKindCfg (which is the group DEFAULT only).
local function IDKindOfCfg(cfgKey, prefix, id)
    local kinds = HET.db.profile[prefix .. "idKinds"]
    local tag = kinds and kinds[id]
    if tag == "item" then return true end
    if tag == "ability" then return false end
    return IsItemKindCfg(cfgKey)
end

-- Custom-kind trackers have no aura, so bar/circle (duration/stacks fills)
-- make no sense; only Icon, Text, and Sound Alert are offered.
local CUSTOM_STYLE_SORTING = { "icon", "text", "sound" }

local function AllowedTypeSorting(cfgKey)
    if IsCustomKindCfg(cfgKey) then return CUSTOM_STYLE_SORTING end
    local debuff = IsDebuffCfg(cfgKey)
    local multi  = IsMultiAuraCfg(cfgKey)
    local single = IsSingleActiveCfg(cfgKey)
    -- ID kinds (ability/item) have NO aura, so they never create a slot
    -- (CreateElement's skipSlots) and draw on holder regions through the ID
    -- art pass instead. That pass has no Texture arm, so offering the style
    -- here would produce an element that silently renders nothing.
    local idKind = IsIDKindCfg(cfgKey)
    if not (debuff or multi or idKind) then return STYLE_SORTING end
    -- Multi-aura groups drop "text" (a single countdown is meaningless across
    -- several simultaneous buffs) and "texture" (a single chosen image fanned
    -- out over every match says nothing about which is which — the same reason
    -- Icon Source is single-slot only) — but single-active groups only ever
    -- show one buff, so they keep both. Debuff trackers drop "sound".
    local order = {}
    for _, k in ipairs(STYLE_SORTING) do
        local dropMulti = multi and not single and (k == "text" or k == "texture")
        local dropID    = idKind and k == "texture"
        if not dropMulti and not dropID and not (debuff and k == "sound") then
            order[#order + 1] = k
        end
    end
    return order
end

-- "Custom Sound" dropdown: LSM sounds only (the bundled voice clips are
-- selected via the Sound Type dropdown, not a sentinel entry). Rendered
-- via SharedMediaWidgets' LSM30_Sound control (per-entry speaker button
-- to preview) — it expects the LSM name→path hash as values.
local function GetCustomSoundValues()
    if LSM then
        return LSM:HashTable("sound")
    end
    return { ["None"] = "None" }
end

-- Spell name + icon for arbitrary IDs (custom trackers). Unknown IDs
-- render as "#12345" with a question-mark icon.
local function SpellIDLabel(id)
    local name = C_Spell.GetSpellName and C_Spell.GetSpellName(id)
    local tex = C_Spell.GetSpellTexture(id) or 134400
    return ("|T%s:16|t %s"):format(tex, name or ("#" .. id))
end

-- Does this spell have multiple charges? C_Spell.GetSpellCharges returns a
-- table with maxCharges; a spell "has charges" when maxCharges > 1.
-- Options-UI only (never a combat hot path), so the read
-- is direct. nil/unknown → false (no charges), so a charge-less ability never
-- gets an auto-added Charges text. Returns false for items (they use bag
-- Stacks, not charges) so this only ever gates ability trackers.
local function SpellHasCharges(id)
    id = tonumber(id)
    if not id or id <= 0 then return false end
    if not (C_Spell and C_Spell.GetSpellCharges) then return false end
    local info = C_Spell.GetSpellCharges(id)
    return (info and info.maxCharges and info.maxCharges > 1) and true or false
end

-- Item name + icon for an item ID (Item Usable rows). Uncached items resolve
-- to nil name until the client caches them, so fall back to "#id".
local function ItemIDLabel(id)
    local name = C_Item and C_Item.GetItemInfo and C_Item.GetItemInfo(id)
    local tex = (C_Item and C_Item.GetItemIconByID and C_Item.GetItemIconByID(id))
        or 134400
    return ("|T%s:16|t %s"):format(tex, name or ("#" .. id))
end

-- Can this item be equipped? GetItemInfoInstant's 4th return is the equip
-- location (an INVTYPE_* string), or "" for non-equippable items — and it
-- never needs a server query, so it's reliable in the options UI. Nil id or a
-- missing API means "unknown" → treated as equippable (fail open, so the
-- toggle still shows rather than silently vanishing).
local function IsEquippableItem(id)
    if not (id and id > 0) then return false end
    if not (C_Item and C_Item.GetItemInfoInstant) then return true end
    local _, _, _, equipLoc = C_Item.GetItemInfoInstant(id)
    return equipLoc ~= nil and equipLoc ~= ""
end

-- Unique display name across the spell list (built-ins + other customs,
-- case-insensitive): "Innervate" taken → "Innervate 2", "Innervate 3"...
local BUILTIN_NAMES = {
    ["external defensives"] = true,
    ["innervate"] = true,
    ["power infusion"] = true,
    ["spatial paradox"] = true,
}
local function UniqueCustomName(base, selfEntry)
    local taken = {}
    for k in pairs(BUILTIN_NAMES) do taken[k] = true end
    local list = HET.db.profile.customTrackers
    if type(list) == "table" then
        for j = 1, #list do
            if list[j] ~= selfEntry and list[j].name then
                taken[list[j].name:lower()] = true
            end
        end
    end
    if not taken[base:lower()] then return base end
    local n = 2
    while taken[(base .. " " .. n):lower()] do n = n + 1 end
    return base .. " " .. n
end

local function Notify()
    ACR:NotifyChange(addonName)
end

-------------------------------------------------------------------------------
-- Reusable red X button.
--
-- Attaches (or re-points) one X to `parent`, cached on the parent under
-- `key` so repeated calls reuse the same frame — the AceGUI widgets these
-- ride on are POOLED and restyled constantly, so this must be cheap and
-- idempotent. Pass spec = nil to hide the X without destroying it.
--
--   spec = {
--     point, x, y   -- anchor on the parent (default TOPRIGHT, -4, -4)
--     size          -- default 16
--     level         -- frame levels ABOVE the parent (default 2)
--     tooltip       -- tooltip text
--     anchor        -- tooltip anchor (default ANCHOR_RIGHT)
--     confirm       -- StaticPopup name; omit to fire onClick directly
--     confirmArg    -- %s substituted into the popup text
--     onClick       -- the actual removal
--   }
--
-- Options UI only, so no combat-path cost.
-------------------------------------------------------------------------------
local X_TEXTURE = "Interface\\RaidFrame\\ReadyCheck-NotReady"
local function CornerX(parent, spec, key)
    if not parent then return end
    key = key or "_hetX"
    local x = parent[key]
    if not spec then
        if x then
            x:Hide()
            x._hetOnClick = nil
        end
        return x
    end
    if not x then
        x = CreateFrame("Button", nil, parent)
        x:SetNormalTexture(X_TEXTURE)
        x:SetHighlightTexture(X_TEXTURE, "ADD")
        x:SetScript("OnClick", function(f)
            -- Combat lock: raw decoration buttons aren't covered by the
            -- AceConfig root `disabled`, so guard here (covers every
            -- remove-tracker / delete-custom X — all go through CornerX).
            if InCombatLockdown() then return end
            if f._hetOnClick then f._hetOnClick() end
        end)
        x:SetScript("OnEnter", function(f)
            GameTooltip:SetOwner(f, f._hetAnchor or "ANCHOR_RIGHT")
            GameTooltip:SetText(f._hetTip or "Remove", 1, 1, 1)
            GameTooltip:Show()
        end)
        x:SetScript("OnLeave", function() GameTooltip:Hide() end)
        parent[key] = x
    end
    local size = spec.size or 16
    x:SetSize(size, size)
    x:ClearAllPoints()
    x:SetPoint(spec.point or "TOPRIGHT", spec.x or -4, spec.y or -4)
    x:SetFrameLevel(parent:GetFrameLevel() + (spec.level or 2))
    x._hetTip = spec.tooltip
    x._hetAnchor = spec.anchor
    local fn, confirm, carg = spec.onClick, spec.confirm, spec.confirmArg
    x._hetOnClick = function()
        if confirm then
            local dialog = StaticPopup_Show(confirm, carg)
            if dialog then dialog.data = { fn = fn } end
        elseif fn then
            fn()
        end
    end
    x:Show()
    return x
end
ns.CornerX = CornerX

-- Color pickers fire their setter on EVERY tick while the color wheel
-- is dragged; a full RefreshTracking per tick (global restyle + engine
-- sound re-registration) stutters the whole client. Debounce to a
-- trailing 0.1s edge — the db value is already written before each
-- call, so the last scheduled run always applies the final color.
-- (this addon's refresh is global by design, hence the debounce.)
local refreshQueued = false
local function RefreshTrackingThrottled()
    if refreshQueued then return end
    refreshQueued = true
    C_Timer.After(0.1, function()
        refreshQueued = false
        HET:RefreshTracking()
    end)
end

-- DEBOUNCED NotifyChange, for drag controls that have to repaint a DIFFERENT
-- widget — currently only the Texture style's preview swatch, which reads
-- color / rotation / blend / desaturate / size off the element rather than from
-- its own value. Most controls need no NotifyChange at all; AceGUI already
-- shows the value the user just set.
--
-- Debounced, NOT periodic, for two reasons. ACR:NotifyChange on an open panel
-- makes ACD REBUILD the group, which (a) is far too expensive to run on every
-- drag tick and (b) destroys and recreates the very slider being dragged, which
-- can drop the drag mid-gesture. Firing only once motion has stopped for
-- DEBOUNCE seconds avoids both: nothing rebuilds while the user is moving, and
-- the swatch snaps the moment they pause or release.
--
-- One closure per drag burst, not per tick: the poller re-arms itself while
-- motion continues instead of scheduling a timer per setter call.
-- Longest edge of the Texture style's preview swatch, in pixels.
local TEX_PREVIEW_MAX = 96

local NOTIFY_DEBOUNCE = 0.2
local notifyPending, notifyLastTouch = false, 0
local function NotifyThrottled()
    notifyLastTouch = GetTime()
    if notifyPending then return end
    notifyPending = true
    local function tick()
        if GetTime() - notifyLastTouch < NOTIFY_DEBOUNCE then
            C_Timer.After(0.05, tick)   -- still moving; wait for the lull
            return
        end
        notifyPending = false
        ACR:NotifyChange(addonName)
    end
    C_Timer.After(NOTIFY_DEBOUNCE, tick)
end

-- The main left tree groups spells under Enabled/Disabled section nodes,
-- so every ACD path to a spell runs through its CURRENT section. Custom
-- trackers ("customN") use their cfgKey as node key and their config's
-- dbPrefix.
local SPELL_PREFIX = {
    spatial = "", innervate = "inn_", powerinfusion = "pi_",
    extdef = "ed_", persdef = "pd_",
}
local SPELL_NODE = {
    spatial = "spatialParadox",
    innervate = "innervate",
    powerinfusion = "powerInfusion",
    extdef = "externalDefensives",
    persdef = "personalDefensives",
}

-- Does this tracker have bundled voice clips? (Customs never; spellList
-- groups only when at least one listed spell has one.)
local function HasVoice(cfgKey)
    local cfg = ns.trackerConfigs[cfgKey]
    if cfg.custom then return false end
    if cfg.spellList then return (cfg.hasVoiceClips or cfg.voiceOptions) and true or false end
    return true
end
local function PrefixFor(cfgKey)
    return SPELL_PREFIX[cfgKey]
        or (ns.trackerConfigs[cfgKey] and ns.trackerConfigs[cfgKey].dbPrefix)
        or cfgKey
end
local function NodeFor(cfgKey)
    return SPELL_NODE[cfgKey] or cfgKey
end
local function SpellSection(cfgKey)
    local enabled = HET.db.profile[PrefixFor(cfgKey) .. "enabled"]
    if enabled == nil then enabled = true end -- over-pool custom stubs
    return enabled and "sectionEnabled" or "sectionDisabled"
end

-- Top-level subtab key for a tracker ("buffs" / "debuffs"). The tree paths
-- (and every ACD:SelectGroup) are prefixed with this now that the panel is
-- split into subtabs.
local function SpellTab(cfgKey)
    -- ID kinds (ability/item, single or group) share ONE merged subtab now.
    if IsIDKindCfg(cfgKey) then return "idkinds" end
    if IsCustomKindCfg(cfgKey) then return "custom" end
    return IsDebuffCfg(cfgKey) and "debuffs" or "buffs"
end

local function GetEls(prefix)
    return HET.db.profile[prefix .. "elements"] or {}
end

-- A "tracker" is an element with a style set. True when a spell has NONE
-- configured (empty list, or every entry style-less) — used to flag enabled
-- spells that won't show anything with a warning glyph in the tree.
local function SpellHasNoTrackers(prefix)
    local els = GetEls(prefix)
    for j = 1, #els do
        if els[j].style then return false end
    end
    return true
end

-- Any bar-type tracker on this spell? Gates the per-buff color pickers
-- in the Tracked Buffs list (Ordered bars color per buff).
local function HasBarTracker(prefix)
    local els = GetEls(prefix)
    for j = 1, #els do
        if els[j].style == "bar" then return true end
    end
    return false
end

-- Reorderable Tracked Buffs lists. spellList groups store their display
-- order in profile[prefix.."buffOrder"] (array of entry keys; entries
-- missing from it append in default order — new spells tagged in
-- SpellData land at the bottom). Custom groups reorder their ids array
-- directly.
local UP_TEX = "Interface\\ChatFrame\\UI-ChatIcon-ScrollUp-Up"
local DOWN_TEX = "Interface\\ChatFrame\\UI-ChatIcon-ScrollDown-Up"
-- Main-tree preview eye icons (custom Media; see TreeEye in the
-- widget-decoration section). PREVIEW_MASK_TEX = white eye, tinted per tab
-- and shown while a preview is ACTIVE; PREVIEW_HIDE_TEX = red eye, shown
-- while hidden.
-- No .tga extension (WoW infers it).
-- PREVIEW_MASK_TEX is the same eye art with its RGB flattened to white, so
-- it can be vertex-coloured per tab (see PREVIEW_TINT / TreeEye). The red
-- hidden eye keeps its baked-in colour.
local PREVIEW_MASK_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\previewmask"
local PREVIEW_HIDE_TEX = "Interface\\AddOns\\BuzzardAuras\\Media\\previewhide"
-- Active-preview eye colour per main tab: blue = buffs (the art's original
-- colour), orange = debuffs, purple = abilities/items. Anything else (the
-- "Other" tab) falls back to blue.
local PREVIEW_TINT = {
    buffs   = { 0.063, 0.463, 0.816 },
    debuffs = { 1.000, 0.549, 0.078 },
    idkinds = { 0.667, 0.353, 1.000 },
}
local PREVIEW_TINT_DEFAULT = PREVIEW_TINT.buffs
-- The GLOBAL (all-tabs) eye on the title bar is yellow — deliberately not
-- any single tab's colour, since it drives every tab at once.
local PREVIEW_TINT_GLOBAL = { 1.000, 0.820, 0.000 }
-- Per-tab PRE-TINTED active-eye art for the subtab preview icon-buttons
-- (AceConfig image-executes render through AceGUI's Icon widget, which
-- can't be vertex-coloured like the raw-frame eyes, so each tab colour is
-- baked into its own file — previeworange/previewpurple were generated from
-- previewmask.tga with the matching PREVIEW_TINT colour; preview.tga IS the
-- blue original). "custom"/Other reuses blue, same as PREVIEW_TINT_DEFAULT.
local PREVIEW_TAB_TEX = {
    buffs   = "Interface\\AddOns\\BuzzardAuras\\Media\\preview",
    debuffs = "Interface\\AddOns\\BuzzardAuras\\Media\\previeworange",
    idkinds = "Interface\\AddOns\\BuzzardAuras\\Media\\previewpurple",
    custom  = "Interface\\AddOns\\BuzzardAuras\\Media\\preview",
}
-- Noun used in each subtab's scoped preview button ("Show Buff Previews").
local SUBTAB_LABELS = {
    buffs   = "Buff",
    debuffs = "Debuff",
    idkinds = "Ability/Item",
    custom  = "Other",
}

local function OrderedSpellList(cfgKey, prefix)
    local list = ns.trackerConfigs[cfgKey].spellList
    local order = HET.db.profile[prefix .. "buffOrder"]
    if type(order) ~= "table" then return list end
    local byKey, out, seen = {}, {}, {}
    for _, sp in ipairs(list) do byKey[sp.key] = sp end
    for _, k in ipairs(order) do
        local sp = byKey[k]
        if sp and not seen[k] then
            out[#out + 1] = sp
            seen[k] = true
        end
    end
    for _, sp in ipairs(list) do
        if not seen[sp.key] then out[#out + 1] = sp end
    end
    return out
end

local function MoveOrderedKey(cfgKey, prefix, idx, delta)
    local display = OrderedSpellList(cfgKey, prefix)
    local o = {}
    for i = 1, #display do o[i] = display[i].key end
    local j = idx + delta
    if not (o[idx] and o[j]) then return end
    o[idx], o[j] = o[j], o[idx]
    HET.db.profile[prefix .. "buffOrder"] = o
    -- Order drives the live fan-out (per-spell group layoutIndex).
    HET:RefreshTracking()
    Notify()
end

local function MoveCustomID(prefix, idx, delta)
    local ids = HET.db.profile[prefix .. "ids"]
    if type(ids) ~= "table" then return end
    local j = idx + delta
    if not (ids[idx] and ids[j]) then return end
    ids[idx], ids[j] = ids[j], ids[idx]
    -- Order drives the live fan-out (per-spell group layoutIndex).
    HET:RefreshTracking()
    Notify()
end

-- Overlay-capable pair styles: only opaque styles can obscure the cooldown
-- art underneath — circle (transparent center) can't, and TEXT pairs are a
-- separate standalone kind (aura duration text, no parent element).
-- Declared here because DoRemoveTracker's cascade below reads it; the rest
-- of the pair helpers live after NewElementDefaults.
local PAIRABLE_STYLES = { icon = true, bar = true }

-- Tracker removal, shared by the tree-row X buttons. Pins selection off
-- the removed key so the re-render fallback can't land on the "+" entry.
local NODE_TO_CFG = {}
for cfg, node in pairs(SPELL_NODE) do NODE_TO_CFG[node] = cfg end
local function DoRemoveTracker(cfgKey, i)
    local prefix = PrefixFor(cfgKey)
    local els = GetEls(prefix)
    if not els[i] then return end
    local removed = els[i]
    table.remove(els, i)
    -- Compound parent removal cascades to its "(Active)" overlay pair
    -- (identified by style — see the pair helpers after NewElementDefaults).
    -- Only icon/bar overlays cascade: removing a regular Text element must
    -- NOT delete the standalone Text (Active).
    if not removed.pairFor and removed.style and PAIRABLE_STYLES[removed.style] then
        for j = #els, 1, -1 do
            if els[j].pairFor == removed.style then table.remove(els, j) end
        end
    end
    local t = ns.trackers[cfgKey]
    if t then t:RestoreAllPositions() end
    HET:RefreshTracking()
    HET:RefreshPreviewFor(cfgKey)
    Notify()
    if #GetEls(prefix) > 0 then
        ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), NodeFor(cfgKey), "trackers", "el1")
    else
        ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), NodeFor(cfgKey), "trackers")
    end
end

-- Custom-tracker deletion, shared by the pane Delete button and the
-- main-tree row X buttons. Works even when no tracker object exists
-- (prefix is derivable); an orphaned tracker object parks itself on
-- the next refresh once its customTrackers entry is gone.
local function DoDeleteCustom(id)
    local p = HET.db.profile
    local list = p.customTrackers or {}
    local wasDebuff = false
    local wasTab = "buffs"  -- subtab the deleted tracker lived in (for re-select)
    for idx = #list, 1, -1 do
        if list[idx].id == id then
            wasDebuff = list[idx].harmful and true or false
            local k = list[idx].kind
            if k == "ability" or k == "abilitygroup"
                or k == "item" or k == "itemgroup" then
                -- Abilities + Items share ONE merged subtab.
                wasTab = "idkinds"
            elseif k == "custom" then
                wasTab = "custom"
            elseif wasDebuff then
                wasTab = "debuffs"
            end
            table.remove(list, idx)
        end
    end
    -- Wipe every stored key of this identity slot.
    local pref = "cu" .. id .. "_"
    for k in pairs(p) do
        if type(k) == "string" and k:sub(1, #pref) == pref then
            p[k] = nil
        end
    end
    local tracker = ns.trackers["custom" .. id]
    if tracker then tracker:HidePreview() end
    HET:RefreshTracking()
    Notify()
    -- Stay in the tab the deleted tracker lived in, landing on its Addon
    -- Options node (present under both subtabs). Re-feed the frame so the
    -- pane updates in place (same as the add flow).
    ACD:SelectGroup(addonName, wasTab, "addonOptions")
    if ACD.OpenFrames and ACD.OpenFrames[addonName] then
        ACD:Open(addonName)
    end
end

StaticPopupDialogs["HET_REMOVE_TRACKER"] = {
    text = "Remove %s?",
    button1 = YES,
    button2 = NO,
    OnAccept = function(self, data)
        if data and data.fn then data.fn() end
    end,
    -- The options panel sits at FULLSCREEN_DIALOG strata; StaticPopups
    -- default to DIALOG and would open BEHIND it. Raise while shown.
    OnShow = function(self)
        self._hetOldStrata = self:GetFrameStrata()
        self:SetFrameStrata("TOOLTIP")
    end,
    OnHide = function(self)
        if self._hetOldStrata then
            self:SetFrameStrata(self._hetOldStrata)
            self._hetOldStrata = nil
        end
    end,
    timeout = 0,
    whileDead = 1,
    hideOnEscape = 1,
    preferredIndex = 3,
}

StaticPopupDialogs["HET_DELETE_CUSTOM"] = {
    text = "Delete %s and all of its settings?",
    button1 = YES,
    button2 = NO,
    OnAccept = function(self, data)
        if data and data.fn then data.fn() end
    end,
    OnShow = function(self)
        self._hetOldStrata = self:GetFrameStrata()
        self:SetFrameStrata("TOOLTIP")
    end,
    OnHide = function(self)
        if self._hetOldStrata then
            self:SetFrameStrata(self._hetOldStrata)
            self._hetOldStrata = nil
        end
    end,
    timeout = 0,
    whileDead = 1,
    hideOnEscape = 1,
    preferredIndex = 3,
}

-- Kind-specific default bar/circle fill for USER-CREATED (custom) entries:
-- debuffs red, buffs blue, abilities/items purple, all at 70% alpha.
-- Predefined spell-list entries keep their authored defaults.barCircleColor,
-- and "custom"-kind (no aura) entries keep the neutral white cfg default.
-- Per-id colors in a GROUP need no seeding: an unset buffColors entry falls
-- back to the bar element's own color (both in the per-id picker's get and
-- in the Tracker fill path), so they inherit these defaults automatically.
local KIND_BAR_COLOR_DEBUFF = { 1.000000, 0.172549, 0.062745, 0.7 } -- #FF2C10 @ 70%
local KIND_BAR_COLOR_BUFF   = { 0.196078, 0.623529, 1.000000, 0.7 } -- #329FFF @ 70%
local KIND_BAR_COLOR_ID     = { 0.588235, 0.286275, 1.000000, 0.7 } -- #9649FF @ 70%
local function DefaultElementColor(cfgKey)
    local cfg = ns.trackerConfigs[cfgKey]
    if cfg.custom then
        local e = CustomEntryFor(cfgKey)
        local kind = e and e.kind
        if kind == "aura" or kind == "group" then
            return e.harmful and KIND_BAR_COLOR_DEBUFF or KIND_BAR_COLOR_BUFF
        elseif kind == "ability" or kind == "item"
            or kind == "abilitygroup" or kind == "itemgroup" then
            return KIND_BAR_COLOR_ID
        end
        -- kind == "custom" (or unknown): fall through to the cfg default.
    end
    return cfg.defaults.barCircleColor or { 1, 1, 1, 1 }
end

-- Fresh element config for "Add Tracker": full field set so style
-- switches never hit nil options; style itself starts unselected — the
-- style-specific options (and the live slot) activate once it's chosen.
local function NewElementDefaults(cfgKey)
    local defColor = DefaultElementColor(cfgKey)
    return {
        style          = nil,
        trackedProperty = "duration",
        maxStacks      = 10,
        showTooltip    = false,
        iconSize       = 40,
        -- Icon source override (aura Icon trackers): "auto" uses the aura's
        -- own icon (engine-bound); "spell"/"icon" replace the TEXTURE only
        -- (the aura still gates visibility, cooldown, duration/stacks).
        auraIconMode   = "auto",
        auraIconInput  = nil,
        cooldownSwipe  = true,
        iconShowDurText = true,
        iconDurFontSize = 14,
        iconDurTextPosition = "center",
        -- None of the predefined buffs/groups track a stacking aura, so a
        -- fresh icon/bar tracker on them omits the stack text by default.
        -- Custom trackers keep it on (a user-chosen aura may stack).
        iconShowStackText = ns.trackerConfigs[cfgKey].custom and true or false,
        iconStackFontSize = 12,
        iconStackTextPosition = "bottomright",
        barShowStackText = false,
        barStackFontSize = 12,
        barStackTextPosition = "right",
        borderType     = "rounded",
        borderThickness = 1,
        borderColor    = { 0.815686, 0.815686, 0.815686, 0.85 }, -- #D0D0D0 @ 85%
        roundedColor   = { 0, 0, 0, 0.9 },
        color          = { defColor[1], defColor[2], defColor[3], defColor[4] },
        barOrientation = "horizontal",
        barWidth       = 200,
        barHeight      = 20,
        barBgColor     = { 0.1, 0.1, 0.1, 0.7 },
        barTexture     = ns.DEFAULT_BAR_TEXTURE,
        barShowText    = true,
        barFontSize    = 12,
        barTextPosition = "center",
        barShowIcon    = true,
        barIconPosition = "left",
        barIconBorderType = "rounded",
        barIconBorderThickness = 1,
        barIconBorderColor = { 0.815686, 0.815686, 0.815686, 0.85 }, -- #D0D0D0 @ 85% (matches the icon border default)
        barIconRoundedColor = { 0, 0, 0, 0.9 }, -- legacy; unused (bar-icon border color unified onto barIconBorderColor)
        barIconSwipe   = true,
        circleRadius   = 80,
        ringTexture    = "Ring_20px",
        -- Texture style. `textureKind` discriminates the engine call needed to
        -- draw `textureValue`: "file" = SetTexture (FileDataID or path),
        -- "atlas" = SetAtlas. Sniffing the value at render time cannot tell an
        -- atlas name from a texture path, hence the explicit tag.
        textureValue      = 449490,   -- Hot Streak; a visible default beats a blank holder
        textureKind       = "file",
        textureWidth      = 128,
        textureHeight     = 128,
        textureBlend      = "ADD",    -- the alert art is authored for additive
        textureRotation   = 0,
        textureDesaturate = false,
        soundType      =ns.trackerConfigs[cfgKey].custom and "custom" or "voice",
        soundName      = "None",
        soundChannel   = "Master",
        voiceShort     = false,
        growDirection  = "right",
        spacing        = 2,
        -- Ability/item GROUPS default to showing all 10 (the list cap); aura
        -- groups default to 3 (Unordered overflow).
        maxFrames      = IsIDGroupCfg(cfgKey) and 10 or 3,
        -- Custom-kind (no aura) fields; inert for aura trackers.
        customIconMode    = "icon",   -- "icon" | "spell"
        customIconInput   = nil,      -- raw ID/path string
        customText        = "",       -- single static string
        customTextSize    = 14,
        customTextColor   = { 1, 1, 1, 1 },
        customTextPosition = "bottom",
        -- ID-kind (ability/item) visibility: "always" shows whenever enabled +
        -- Load Conditions met (like a buff); "oncd" shows only while the
        -- ability/item is on cooldown. Inert for aura/custom kinds.
        idVisibility      = "always",
    }
end

-------------------------------------------------------------------------------
-- COMPOUND (ability/item + applied aura) "(Active)" PAIR elements. A single
-- ability/item tracker whose stored ID is compound-flagged (ns.CompoundInfo,
-- SpellData_AbilitiesItems.lua) auto-adds an aura-display overlay per
-- Icon/Bar element: the engine shows the applied aura's display over the
-- cooldown art while the aura is up. Pair configs are marked pairFor =
-- <parent element's style> (style-based identity — one non-pair element per
-- type — so it survives list renumbering).
-------------------------------------------------------------------------------
local function CompoundInfoFor(cfgKey, prefix)
    if not ns.CompoundInfo then return nil end
    local e = CustomEntryFor(cfgKey)
    local kind = e and e.kind
    -- SINGLE ability/item trackers only (groups are out of scope).
    if kind ~= "ability" and kind ~= "item" then return nil end
    local ids = HET.db.profile[prefix .. "ids"]
    return ns.CompoundInfo(ids and ids[1], kind == "item")
end

-- v1 ships self-buff ("help") pairs only; "harm" (enemy-target debuffs) is
-- a data-shape reservation until the target-unit overlay is PTR-verified.
local function PairableFor(cfgKey, prefix)
    local ci = CompoundInfoFor(cfgKey, prefix)
    return (ci and ci.polarity == "help") and ci or nil
end
-- (PAIRABLE_STYLES is declared above DoRemoveTracker, which needs it.)

local function FindPairIndex(els, style)
    for j = 1, #els do
        if els[j].pairFor == style then return j end
    end
end
local function FindParentIndex(els, style)
    for j = 1, #els do
        if els[j].style == style and not els[j].pairFor then return j end
    end
end

local function MakePairElement(cfgKey, style)
    local el = NewElementDefaults(cfgKey)
    el.style = style
    el.pairFor = style
    -- The overlay is an AURA display: default its fill to the buff blue
    -- rather than the ability/item purple, and skip the charge/stack text
    -- (the predefined-buff rule; the parent already shows charges).
    el.color = { KIND_BAR_COLOR_BUFF[1], KIND_BAR_COLOR_BUFF[2],
        KIND_BAR_COLOR_BUFF[3], KIND_BAR_COLOR_BUFF[4] }
    el.iconShowStackText = false
    el.barShowStackText = false
    if style == "bar" then
        -- Bar pairs default to the "Border" animation: transparent body,
        -- yellow border wiped away by the engine drain. Overlay mode
        -- shares these stored defaults (no static border, fully
        -- transparent background), so switching Animation Type is purely
        -- presentational — nothing gets stomped.
        el.pairBarAnim = "border"
        el.color = { 1, 0.82, 0, 1 }  -- yellow border / fill
        el.barBgColor = { 0, 0, 0, 0 }
        el.borderType = "none"
    elseif style == "icon" then
        -- Icons default to Replace Cooldown; the color only matters after
        -- switching to Animate Border (yellow, matching the bar default).
        el.pairIconAnim = "replace"
        el.color = { 1, 0.82, 0, 1 }
    end
    return el
end

-------------------------------------------------------------------------------
-- Per-element options subtab
-------------------------------------------------------------------------------
local function BuildElementGroup(cfgKey, prefix, i, isSpellDisabled)
    local groupKey = NodeFor(cfgKey)
    -- Custom-kind trackers render user art driven by Load Conditions (no aura).
    local isCustomKind = IsCustomKindCfg(cfgKey)
    -- ID kinds (ability/item): no aura, but a real spell/item ID drives an icon
    -- with an optional Cooldown swipe + Charges/Stacks text. The element UI
    -- relabels Duration→Cooldown and Stacks→Charges (abilities) / Stacks
    -- (items), keeping the internal binding/property keys unchanged.
    local isIDKind      = IsIDKindCfg(cfgKey)
    local isItemKind    = IsItemKindCfg(cfgKey)
    -- PAIR "(Active)" elements on a compound ability/item tracker are AURA
    -- displays: present the aura option set (labels, bindings, thresholds)
    -- rather than the ID one. Geometry/position options are hidden below —
    -- pairs are locked over their parent element.
    local e0 = GetEls(prefix)[i]
    local isPairEl = (e0 and e0.pairFor) and true or false
    -- Locked pairs (icon/bar) are position/size-slaved to a parent element;
    -- TEXT pairs are standalone (free-positioned, draggable).
    local isLockedPair = (isPairEl and e0.pairFor ~= "text") and true or false
    if isPairEl then
        isIDKind, isItemKind = false, false
    end
    -- The user-facing label for the "stacks" binding/property. The unified
    -- Ability/Item section always uses "Charges" (a single tracker may be either
    -- type and groups mix both, so a per-type Ability=Charges/Item=Stacks split
    -- would read inconsistently). Auras keep "Stacks".
    local stacksLabel   = isIDKind and "Charges" or "Stacks"
    -- The user-facing label for the "duration" binding/property: ID kinds track
    -- an ability/item COOLDOWN rather than an aura's remaining duration.
    local durationLabel = isIDKind and "Cooldown" or "Duration"
    -- Text-type dropdown labels for THIS tracker (relabels Duration/Stacks for
    -- ID kinds; "name" stays "Buff Name" — inert for ID kinds, which don't
    -- offer a name binding meaningfully but the machinery is harmless).
    local function TextTypeLabel(ty)
        if ty == "duration" then return durationLabel end
        if ty == "stacks" then return stacksLabel end
        return TEXT_TYPE_VALUES[ty]
    end
    -- "Presents as multi-aura" for the element display options: true fan-out
    -- groups show Icons/Bars + grow/max/spacing controls, while single-active
    -- groups (only one buff up at a time) present like a single tracker. The
    -- Tracked Buffs spell list is separate (config.spellList) and unaffected.
    local function isMultiAura()
        return IsMultiAuraCfg(cfgKey) and not IsSingleActiveCfg(cfgKey)
    end
    local function el()
        return GetEls(prefix)[i]
    end
    local function refresh()
        HET:RefreshTracking()
        HET:RefreshPreviewFor(cfgKey)
    end
    -- For continuous-drag controls (color pickers AND range sliders —
    -- both fire their setter on every drag tick): debounced refresh.
    local function refreshThrottled()
        RefreshTrackingThrottled()
        HET:RefreshPreviewFor(cfgKey)
    end
    local function styleIs(s)
        local e = el()
        return e and e.style == s
    end
    local function hasVisualStyle()
        local e = el()
        local s = e and e.style
        return s ~= nil and s ~= "sound"
    end
    -- Texture preview swatch dimensions: the element's real Width/Height
    -- scaled so the LONGEST edge is TEX_PREVIEW_MAX. Keeps the swatch's aspect
    -- ratio honest (a 256x64 banner previews as a banner, not a square) and
    -- makes rotation clip the same way it will in game, while capping how much
    -- vertical space the row can claim. Defaults mirror ApplyStyle's 128x128.
    local function texPreviewSize()
        local e = el()
        local w = tonumber(e and e.textureWidth) or 128
        local h = tonumber(e and e.textureHeight) or 128
        local longest = math.max(w, h)
        if longest <= 0 then return TEX_PREVIEW_MAX, TEX_PREVIEW_MAX end
        local scale = TEX_PREVIEW_MAX / longest
        return math.max(1, math.floor(w * scale + 0.5)),
               math.max(1, math.floor(h * scale + 0.5))
    end
    -- Bar-pair "Border" animation (Animation Type = Border): the fill
    -- texture, background, and static border are all forced by the
    -- animation, so their controls hide while it's active.
    local function pairBorderMode()
        if not isPairEl then return false end
        local e = el()
        return (e and e.style == "bar")
            and ((e.pairBarAnim or "border") == "border") and true or false
    end
    -- Icon-pair "Animate Border" (Animation Type = Animate Border): no icon
    -- overlay; the bound Cooldown sweeps a border-shaped swipe around the
    -- parent's icon. Hides the icon-content and static-border controls.
    local function pairIconBorderMode()
        if not isPairEl then return false end
        local e = el()
        return (e and e.style == "icon")
            and ((e.pairIconAnim or "replace") == "border") and true or false
    end
    -- One tracker of each type per spell: types claimed by OTHER
    -- elements disappear from this element's dropdown (its own current
    -- type always stays listed).
    local function usedByOthers()
        local used = {}
        local els = GetEls(prefix)
        for j = 1, #els do
            -- PAIR elements don't claim a type: "one tracker of each type"
            -- applies to the non-pair elements (a pair shares its parent's).
            if j ~= i and els[j].style and not els[j].pairFor then
                used[els[j].style] = true
            end
        end
        return used
    end

    local tabName = (e0 and e0.style and StyleLabel(cfgKey, e0.style)) or "Tracker"
    if isPairEl then tabName = tabName .. " (Active)" end


    -- Text entries: one per engine binding. ns.EnsureTexts migrates the
    -- old fixed bar/icon text keys into el.texts on first access.
    local function texts()
        local e = el()
        return (e and ns.EnsureTexts(e)) or {}
    end
    local function hasTextStyle()
        -- Custom-kind uses a single static string (customText), not the
        -- aura-property text-entry system, so its text UI is suppressed here.
        if isCustomKind then return false end
        return styleIs("icon") or styleIs("bar") or styleIs("text")
    end
    -- Bindings claimed by text entries OTHER than k.
    local function bindingsUsedByOthers(k)
        local used, t = {}, texts()
        for j = 1, #t do
            -- An unrecognised type (hand-edited SavedVariables, or a
            -- profile from another version) maps to NO binding; indexing
            -- `used` with nil would error out of the whole options build.
            local b = t[j].type and ns.TEXT_BINDING[t[j].type]
            if j ~= k and b then used[b] = true end
        end
        return used
    end
    local function CanAddText()
        local used, t = {}, texts()
        for j = 1, #t do
            local b = t[j].type and ns.TEXT_BINDING[t[j].type]
            if b then used[b] = true end
        end
        local n = 0
        for _ in pairs(used) do n = n + 1 end
        return #t < MAX_TEXTS and n < MAX_TEXTS
    end
    local function AddText()
        -- EnsureTexts returns a THROWAWAY table on a style-less element
        -- (it refuses to cache one), so appending there would vanish.
        if not hasTextStyle() then return end
        local e = el(); if not e then return end
        local t = ns.EnsureTexts(e)
        local used = {}
        for j = 1, #t do
            local b = t[j].type and ns.TEXT_BINDING[t[j].type]
            if b then used[b] = true end
        end
        -- First type whose binding is still free.
        local pick
        for _, ty in ipairs(TEXT_TYPE_SORTING) do
            if not pick and not used[ns.TEXT_BINDING[ty]] then pick = ty end
        end
        if not pick then return end
        t[#t + 1] = {
            type = pick,
            stackDisplay = "current",
            fontSize = ns.DefaultTextSize(e.style, ns.TEXT_BINDING[pick]),
        }
        refresh()
        Notify()
    end
    -- Position set follows the style: icons get the 9-point grid, bars
    -- the 3 positions along the fill.
    local function barIsVertical()
        local e = el()
        return (e and e.barOrientation == "vertical") and true or false
    end
    local function posValues()
        if styleIs("bar") then
            return barIsVertical() and BAR_TEXT_POS_V or BAR_TEXT_POS
        end
        return ICON_TEXT_POS
    end
    local function posSorting()
        if styleIs("bar") then
            return barIsVertical() and BAR_TEXT_POS_V_SORTING or BAR_TEXT_POS_SORTING
        end
        return ICON_TEXT_POS_SORTING
    end
    local function defaultPos(ty)
        local b = ns.TEXT_BINDING[ty]
        if styleIs("bar") then
            if barIsVertical() then
                return (b == "stacks" and "top") or (b == "name" and "bottom") or "center"
            end
            return (b == "stacks" and "right") or (b == "name" and "left") or "center"
        end
        return (b == "stacks" and "bottomright") or (b == "name" and "top") or "center"
    end
    -- A position stored under the OTHER anchoring scheme (after a style
    -- switch) would render the dropdown blank — coerce to this style's
    -- default instead.
    local function readPos(k)
        local t = texts()[k]
        if not t then return "center" end
        local pos = t.position or defaultPos(t.type)
        if posValues()[pos] == nil then return defaultPos(t.type) end
        return pos
    end
    local function BuildTextGroup(k)
        return {
            type = "group",
            inline = true,
            name = function()
                local t = texts()[k]
                local label = t and TextTypeLabel(t.type)
                return "Text " .. k .. (label and (" \226\128\148 " .. label) or "")
            end,
            order = 80.1 + k / 100,
            hidden = function() return not hasTextStyle() end,
            args = {
                ttype = {
                    type = "select",
                    name = "Text Type",
                    desc = "Which property this text shows.",
                    order = 1,
                    values = function()
                        local used = bindingsUsedByOthers(k)
                        local t = texts()[k]
                        local own = t and t.type
                        local v = {}
                        for _, ty in ipairs(TEXT_TYPE_SORTING) do
                            if ty == own or not used[ns.TEXT_BINDING[ty]] then
                                v[ty] = TextTypeLabel(ty)
                            end
                        end
                        return v
                    end,
                    sorting = function()
                        local used = bindingsUsedByOthers(k)
                        local t = texts()[k]
                        local own = t and t.type
                        local o = {}
                        for _, ty in ipairs(TEXT_TYPE_SORTING) do
                            if ty == own or not used[ns.TEXT_BINDING[ty]] then
                                o[#o + 1] = ty
                            end
                        end
                        return o
                    end,
                    get = function() local t = texts()[k]; return t and t.type end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        if bindingsUsedByOthers(k)[ns.TEXT_BINDING[val]] then return end
                        t.type = val
                        refresh()
                        Notify()
                    end,
                },
                stackDisplay = {
                    type = "select",
                    -- Relabel for ability trackers (Charges) and items (Stacks);
                    -- auras keep Stacks. stacksLabel is "Charges" for abilities,
                    -- "Stacks" otherwise (see top of BuildElementGroup).
                    name = stacksLabel .. " Display Type",
                    desc = ("Current %s shows the count on its own. Current / Max appends your Max %s value. Max shows the maximum alone."):format(stacksLabel, stacksLabel),
                    order = 2,
                    -- "Current Stacks"/"Current Charges" relabels the first entry;
                    -- Current / Max and Max are kind-agnostic. (Abilities render
                    -- Current / Max via a separate static "/max" FontString since the
                    -- secret charge count can't be concatenated — see RenderIDText.)
                    values = {
                        current    = "Current " .. stacksLabel,
                        currentmax = "Current / Max",
                        max        = "Max",
                    },
                    sorting = STACK_DISPLAY_SORTING,
                    hidden = function()
                        local t = texts()[k]
                        return not (t and t.type == "stacks")
                    end,
                    get = function()
                        local t = texts()[k]
                        return (t and t.stackDisplay) or "current"
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.stackDisplay = val
                        refresh()
                        Notify()
                    end,
                },
                hideBelow2 = {
                    type = "toggle",
                    name = "Show at 2+ " .. stacksLabel .. " Only",
                    desc = ("Hide this text while there is only a single %s, so the count only appears at 2 %s or more. On by default for Icon and Text trackers."):format(
                        stacksLabel:lower():gsub("s$", ""), stacksLabel:lower()),
                    order = 2.5,
                    hidden = function()
                        local t = texts()[k]
                        if not (t and t.type == "stacks") then return true end
                        -- Not offered for ability/item (ID-kind) trackers.
                        if isIDKind then return true end
                        return false
                    end,
                    get = function()
                        local e = el()
                        return ns.HideBelow2(e and e.style, texts()[k])
                    end,
                    -- Stored as an explicit true/false: nil is the
                    -- "untouched" state that reads the style default, so
                    -- collapsing false to nil would make an unchecked box
                    -- re-check itself on the next options build.
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.hideBelow2 = val and true or false
                        refresh()
                    end,
                },
                font = {
                    type = "select",
                    name = "Font",
                    order = 3,
                    dialogControl = "LSM30_Font",
                    -- Function form so the list is re-read from LSM's live
                    -- table every time the panel builds, not snapshotted once.
                    values = function() return LSM and LSM:HashTable("font") or {} end,
                    get = function()
                        local t = texts()[k]
                        return (t and t.font) or ns.DEFAULT_FONT_NAME
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.font = val
                        refresh()
                    end,
                },
                fontBorder = {
                    type = "select",
                    name = "Font Border",
                    order = 3.5,
                    values = FONT_BORDER_VALUES,
                    sorting = FONT_BORDER_SORTING,
                    get = function()
                        local t = texts()[k]
                        return (t and t.fontBorder) or "OUTLINE"
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.fontBorder = val
                        refresh()
                    end,
                },
                fontSize = {
                    type = "range",
                    name = "Font Size",
                    order = 4,
                    min = 6, max = 72, step = 1,
                    get = function()
                        local e, t = el(), texts()[k]
                        return (t and t.fontSize)
                            or ns.DefaultTextSize(e and e.style, t and ns.TEXT_BINDING[t.type])
                    end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.fontSize = val
                        refreshThrottled()
                    end,
                },
                color = {
                    type = "color",
                    name = "Text Color",
                    order = 4.5,
                    hasAlpha = true,
                    get = function()
                        local t = texts()[k]
                        local c = (t and t.color) or { 1, 1, 1, 1 }
                        return c[1], c[2], c[3], c[4]
                    end,
                    set = function(_, r, g, b, a)
                        local t = texts()[k]; if not t then return end
                        t.color = { r, g, b, a }
                        refreshThrottled()
                    end,
                },
                position = {
                    type = "select",
                    name = "Text Position",
                    order = 5,
                    values = posValues,
                    sorting = posSorting,
                    get = function() return readPos(k) end,
                    set = function(_, val)
                        local t = texts()[k]; if not t then return end
                        t.position = val
                        refresh()
                    end,
                },
                -- Duration-only: change the timer text color as the aura
                -- runs out. Evaluated engine-side against the (secret)
                -- remaining duration. Hidden for non-duration text.
                thresholdColors = {
                    type = "group",
                    name = "Threshold Colors",
                    order = 6,
                    inline = true,
                    hidden = function()
                        local t = texts()[k]
                        return not (t and t.type == "duration")
                    end,
                    args = {
                        enabled = {
                            type = "toggle",
                            name = "Change Color based on Remaining Time",
                            desc = "When enabled, the duration text changes color as the remaining time falls below the threshold(s) below.",
                            order = 1,
                            width = "full",
                            get = function()
                                local t = texts()[k]
                                return (t and t.thresholdColorEnabled) or false
                            end,
                            set = function(_, val)
                                local t = texts()[k]; if not t then return end
                                t.thresholdColorEnabled = val or nil
                                refresh()
                            end,
                        },
                        threshold1Color = {
                            type = "color",
                            name = "Threshold Color",
                            order = 2,
                            hasAlpha = true,
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                local c = (t and t.thresholdColor) or { 1, 0.5, 0, 1 }
                                return c[1], c[2], c[3], c[4]
                            end,
                            set = function(_, r, g, b, a)
                                local t = texts()[k]; if not t then return end
                                t.thresholdColor = { r, g, b, a }
                                refreshThrottled()
                            end,
                        },
                        threshold1Seconds = {
                            type = "range",
                            name = "Threshold (seconds)",
                            order = 3,
                            min = 1, max = 59, step = 1,
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                return (t and t.thresholdSeconds) or 8
                            end,
                            set = function(_, val)
                                local t = texts()[k]; if not t then return end
                                t.thresholdSeconds = val
                                -- Keep the secondary threshold strictly below
                                -- the primary one.
                                if t.threshold2Seconds and t.threshold2Seconds >= val then
                                    t.threshold2Seconds = math.max(1, val - 1)
                                end
                                refresh()
                            end,
                        },
                        threshold2Enabled = {
                            type = "toggle",
                            name = "Enable Secondary Threshold",
                            desc = "When enabled, a second color is applied when the remaining time falls below a lower threshold.",
                            order = 4,
                            width = "full",
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                return (t and t.threshold2Enabled) or false
                            end,
                            set = function(_, val)
                                local t = texts()[k]; if not t then return end
                                t.threshold2Enabled = val or nil
                                refresh()
                            end,
                        },
                        threshold2Color = {
                            type = "color",
                            name = "Secondary Threshold Color",
                            order = 5,
                            hasAlpha = true,
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled and t.threshold2Enabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                local c = (t and t.threshold2Color) or { 1, 0, 0, 1 }
                                return c[1], c[2], c[3], c[4]
                            end,
                            set = function(_, r, g, b, a)
                                local t = texts()[k]; if not t then return end
                                t.threshold2Color = { r, g, b, a }
                                refreshThrottled()
                            end,
                        },
                        threshold2Seconds = {
                            type = "range",
                            name = "Secondary Threshold (seconds)",
                            order = 6,
                            min = 1, max = 59, step = 1,
                            hidden = function()
                                local t = texts()[k]
                                return not (t and t.thresholdColorEnabled and t.threshold2Enabled)
                            end,
                            get = function()
                                local t = texts()[k]
                                local prim = (t and t.thresholdSeconds) or 8
                                local limit = math.max(1, prim - 1)
                                return math.min((t and t.threshold2Seconds) or 4, limit)
                            end,
                            set = function(_, val)
                                local t = texts()[k]; if not t then return end
                                local prim = t.thresholdSeconds or 8
                                t.threshold2Seconds = math.min(val, math.max(1, prim - 1))
                                refresh()
                            end,
                        },
                    },
                },
            },
        }
    end

    local group = {
        type = "group",
        name = tabName,
        order = 100 + i,
        hidden = isSpellDisabled,
        args = {
            -- PAIR "(Active)" explainer. Locked pairs (icon/bar) follow the
            -- parent element; standalone Text pairs are free-positioned.
            pairNote = {
                type = "description",
                name = isLockedPair
                    and ("This tracker shows the aura this ability/item applies, overlaid on its "
                        .. ((e0 and e0.pairFor and StyleLabel(cfgKey, e0.pairFor)) or "parent")
                        .. " tracker while the aura is active (the cooldown is revealed when it drops). "
                        .. "Position and size follow the parent tracker; the appearance settings below are its own.")
                    or "This tracker shows the remaining duration of the aura this ability/item applies, only while the aura is active. It is positioned independently (use Move/Preview to drag it).",
                order = 0.5,
                width = "full",
                hidden = function() return not isPairEl end,
            },
            -- Icon (Active) pairs: how the overlay presents the aura.
            pairIconAnim = {
                type = "select",
                name = "Animation Type",
                desc = "Replace Cooldown: the aura's icon covers the cooldown icon while active, with its own swipe and text. Animate Border: nothing covers the icon — a colored border (in the parent icon's border style) sweeps away around it like a cooldown swipe as the aura runs out.",
                order = 0.8,
                values = { replace = "Replace Cooldown", border = "Animate Border" },
                sorting = { "replace", "border" },
                hidden = function() return not (isPairEl and styleIs("icon")) end,
                get = function()
                    local e = el()
                    return (e and e.pairIconAnim) or "replace"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.pairIconAnim = val
                    refresh()
                    Notify()
                end,
            },
            -- Weight of the animated border ring (the shape still follows
            -- the parent icon's border style).
            pairAnimThickness = {
                type = "range",
                name = "Border Thickness",
                desc = "Thickness of the animated border. (Scales with the icon: the value reads as pixels at a 64px icon, slightly thinner below that.)",
                order = 0.85,
                min = 1, max = 6, step = 1,
                hidden = function() return not pairIconBorderMode() end,
                get = function()
                    local e = el()
                    return (e and e.pairAnimThickness) or 2
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.pairAnimThickness = val
                    refreshThrottled()
                end,
            },
            -- Bar (Active) pairs: how the overlay renders the aura duration.
            pairBarAnim = {
                type = "select",
                name = "Animation Type",
                desc = "Border: a transparent bar body with an outline that wipes away right-to-left (top-to-bottom on vertical bars) as the aura runs out. Overlay: a regular bar fill drawn over the cooldown bar, using the bar options below.",
                order = 0.8,
                values = { border = "Border", overlay = "Overlay" },
                sorting = { "border", "overlay" },
                hidden = function() return not (isPairEl and styleIs("bar")) end,
                get = function()
                    local e = el()
                    return (e and e.pairBarAnim) or "border"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.pairBarAnim = val
                    refresh()
                    Notify()
                end,
            },
            style = {
                type = "select",
                name = "Tracker Type",
                desc = "What kind of tracker this is. Visual types show while the buff is active; Sound Alert plays a sound when the buff is applied. Only one tracker of each type per spell.",
                order = 1,
                -- PAIR elements mirror their parent's type; not selectable.
                hidden = function() return isPairEl end,
                values = function()
                    local used = usedByOthers()
                    local own = el() and el().style
                    local v = {}
                    for _, k in ipairs(AllowedTypeSorting(cfgKey)) do
                        if not used[k] then v[k] = StyleLabel(cfgKey, k) end
                    end
                    if own and not v[own] then v[own] = StyleLabel(cfgKey, own) end
                    return v
                end,
                sorting = function()
                    local used = usedByOthers()
                    local own = el() and el().style
                    local order = {}
                    local seenOwn = false
                    for _, k in ipairs(AllowedTypeSorting(cfgKey)) do
                        if not used[k] then
                            order[#order + 1] = k
                            if k == own then seenOwn = true end
                        end
                    end
                    if own and not seenOwn then order[#order + 1] = own end
                    return order
                end,
                get = function() local e = el(); return e and e.style end,
                set = function(_, val)
                    local e = el()
                    if not e or usedByOthers()[val] then return end
                    -- Compound parent type change: its "(Active)" pair follows
                    -- when the new type is pairable, else it's removed. Pairs
                    -- are appended AFTER their parent, so the remove can't
                    -- shift THIS element's index i.
                    if not e.pairFor and PAIRABLE_STYLES[e.style] then
                        local els = GetEls(prefix)
                        local pj = FindPairIndex(els, e.style)
                        if pj then
                            if PAIRABLE_STYLES[val] and PairableFor(cfgKey, prefix) then
                                els[pj].pairFor = val
                                els[pj].style = val
                            else
                                table.remove(els, pj)
                                local t = ns.trackers[cfgKey]
                                if t then t:RestoreAllPositions() end
                            end
                        end
                    end
                    e.style = val
                    refresh()
                    Notify()
                    -- Pin selection to THIS entry. The rebuild un-hides
                    -- the "+" entry, and ACD's re-render fallback
                    -- (SelectByValue saved-or-first) could otherwise land
                    -- on it and fire its add trigger.
                    ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), groupKey, "trackers", "el" .. i)
                end,
            },

            -- ── Custom-kind controls (no aura) ──────────────────────────────
            -- Icon source: an explicit mode dropdown BEFORE the ID field so a
            -- number is never ambiguous. "Icon ID" = texture file ID / path,
            -- "Spell ID" = derive the icon via GetSpellTexture.
            customIconMode = {
                type = "select",
                name = "Icon Source",
                desc = "Icon ID: a texture file ID (e.g. 134400) or an icon path. Spell ID: the icon is taken from that spell.",
                order = 1.2,
                width = 0.9,
                values = { icon = "Icon ID", spell = "Spell ID" },
                sorting = { "icon", "spell" },
                hidden = function() return not (isCustomKind and styleIs("icon")) end,
                get = function() local e = el(); return (e and e.customIconMode) or "icon" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customIconMode = val
                    refresh()
                    Notify()
                end,
            },
            customIconInput = {
                type = "input",
                name = "Icon ID",
                desc = "The icon to show. Interpreted per the Icon Source dropdown.",
                order = 1.3,
                width = 1.2,
                hidden = function() return not (isCustomKind and styleIs("icon")) end,
                get = function() local e = el(); return (e and e.customIconInput) or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customIconInput = (val and val ~= "") and val or nil
                    refresh()
                    Notify()
                end,
            },
            -- One optional static label for an Icon element; the full label
            -- string for a Text element. Positioned via the same text controls.
            customText = {
                type = "input",
                name = function() return styleIs("text") and "Text" or "Label Text" end,
                desc = "A static text string to show (no aura, so nothing counts down).",
                order = 1.4,
                width = 1.5,
                hidden = function()
                    return not (isCustomKind and (styleIs("icon") or styleIs("text")))
                end,
                get = function() local e = el(); return (e and e.customText) or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customText = val or ""
                    refresh()
                    Notify()
                end,
            },
            customTextBreak = {
                type = "description",
                name = "",
                order = 1.45,
                width = "full",
                hidden = function()
                    return not (isCustomKind and (styleIs("icon") or styleIs("text")))
                end,
            },
            customTextSize = {
                type = "range",
                name = "Text Size",
                desc = "Font size of the custom text.",
                order = 1.46,
                min = 6, max = 48, step = 1,
                hidden = function()
                    return not (isCustomKind and (styleIs("icon") or styleIs("text")))
                end,
                get = function() local e = el(); return (e and e.customTextSize) or 14 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customTextSize = val
                    refreshThrottled()
                end,
            },
            customTextColor = {
                type = "color",
                name = "Text Color",
                desc = "Color of the custom text.",
                order = 1.47,
                hasAlpha = true,
                hidden = function()
                    return not (isCustomKind and (styleIs("icon") or styleIs("text")))
                end,
                get = function()
                    local e = el()
                    local c = (e and e.customTextColor) or { 1, 1, 1, 1 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.customTextColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            -- Icon label placement (Text style is centered on its own holder).
            customTextPosition = {
                type = "select",
                name = "Label Position",
                desc = "Where the label sits on the icon.",
                order = 1.48,
                values = ICON_TEXT_POS,
                sorting = ICON_TEXT_POS_SORTING,
                hidden = function()
                    return not (isCustomKind and styleIs("icon"))
                end,
                get = function() local e = el(); return (e and e.customTextPosition) or "bottom" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.customTextPosition = val
                    refresh()
                end,
            },

            -- Single-buff only: on a multi-aura group the engine assigns
            -- each frame a secret aura, and a stack fill needs a known
            -- per-buff maximum.
            trackedProperty = {
                type = "select",
                name = "Tracked Property",
                desc = isIDKind
                    and "Which property the bar fill follows. Cooldown drains as the ability/item cooldown runs out; "
                        .. (isItemKind and "Stacks fills toward Max Stacks (item count in bags)." or "Charges fills toward Max Stacks (spell charges).")
                    or  "Which property of the buff the bar fill follows. Duration drains as the buff runs out; Stacks fills toward Max Stacks.",
                order = 2,
                -- Internal keys stay "duration"/"stacks" (no text-system churn);
                -- the LABELS relabel for ID kinds (Cooldown / Charges|Stacks).
                values = { duration = durationLabel, stacks = stacksLabel },
                sorting = { "duration", "stacks" },
                hidden = function()
                    -- Irrelevant for ability/item bars: the fill always follows
                    -- the cooldown / charge recharge, never a "stacks" property.
                    if isIDKind then return true end
                    if isMultiAura() then return true end
                    return not styleIs("bar")
                end,
                get = function()
                    local e = el()
                    return (e and e.trackedProperty) or "duration"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.trackedProperty = val
                    refresh()
                end,
            },
            -- ID-kind (ability/item) visibility. "Always" shows the art whenever
            -- the tracker is enabled + its Load Conditions are met (like a buff);
            -- "Only While On Cooldown" restricts it to while the ability/item is
            -- on cooldown; "Only When Usable" restricts it to while the
            -- ability/item is currently usable (off cooldown + has charges /
            -- resources). Shown for ID kinds only; inert for aura/custom.
            idVisibility = {
                type = "select",
                name = "Show When",
                desc = "When this indicator is shown. Always (while loaded) shows it whenever the tracker is enabled and its Conditions are met. Only While On Cooldown restricts it to while the ability/item is on cooldown. Only When Usable restricts it to while the ability/item is ready to use (off cooldown and, for abilities, having charges and resources).",
                order = 2.2,
                values = ID_VISIBILITY_VALUES,
                sorting = ID_VISIBILITY_SORTING,
                -- GROUPS drive visibility PER ENTRY from the Tracked Abilities /
                -- Items list, so this element-level dropdown is hidden for them
                -- (kept only for SINGLE ID trackers, which have no per-id list).
                hidden = function()
                    if not isIDKind or IsIDGroupCfg(cfgKey) then return true end
                    local e = el()
                    -- Sound elements have no visible art to gate.
                    return not (e and e.style and e.style ~= "sound")
                end,
                get = function() local e = el(); return (e and e.idVisibility) or "always" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.idVisibility = val
                    refresh()
                end,
            },
            -- Qualifies every "Stacks" reading on this element: the bar
            -- fill AND the Stacks / Max Stacks text. The engine cannot
            -- infer a buff's maximum, so it has to be stated once here.
            maxStacks = {
                type = "range",
                name = "Max Stacks",
                desc = "Stack count that counts as full. Used by a stack-tracking bar and by the Stacks / Max Stacks text.",
                order = 2.5,
                min = 1, max = 99, step = 1,
                hidden = function()
                    local e = el()
                    if not e then return true end
                    if styleIs("bar") and not isMultiAura()
                        and e.trackedProperty == "stacks" then
                        return false
                    end
                    local t = ns.EnsureTexts(e)
                    for j = 1, #t do
                        if t[j].type == "stacks" then
                            local d = t[j].stackDisplay or "current"
                            if d == "currentmax" or d == "max" then return false end
                        end
                    end
                    return true
                end,
                get = function() local e = el(); return (e and e.maxStacks) or 10 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.maxStacks = val
                    refreshThrottled()
                end,
            },
            showTooltip = {
                type = "toggle",
                name = "Show Tooltip",
                desc = "Show the aura tooltip when hovering this indicator. If a change doesn't take effect immediately, /reload applies it.",
                order = 3,
                hidden = function() return not hasVisualStyle() end,
                get = function() local e = el(); return e and e.showTooltip end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.showTooltip = val
                    refresh()
                end,
            },

            -- Icon
            iconHeader = {
                type = "header",
                name = "Icon Settings",
                order = 10,
                hidden = function() return not styleIs("icon") end,
            },
            iconSize = {
                type = "range",
                name = "Icon Size",
                desc = "Size of the tracker icon in pixels.",
                order = 11,
                min = 20, max = 100, step = 1,
                -- Locked PAIR elements mirror the parent element's geometry.
                hidden = function() return isLockedPair or not styleIs("icon") end,
                -- Fallbacks mirror the render defaults: preset-seeded elements
                -- are SPARSE (see Core.SeedPresets), so unset fields must show
                -- the value the renderer actually uses — a nil here made the
                -- slider display 0 (below its own minimum).
                get = function() local e = el(); return (e and e.iconSize) or 40 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.iconSize = val
                    refreshThrottled()
                end,
            },
            -- Icon Source (aura Icon trackers): Auto uses the aura's own icon;
            -- the Custom modes replace only the displayed TEXTURE (the aura
            -- still gates visibility, cooldown, and duration/stack text).
            -- Custom-kind trackers use their own customIconMode instead.
            auraIconMode = {
                type = "select",
                name = "Icon Source",
                desc = "Auto uses the tracked aura's own icon. Custom (Spell ID) shows a chosen spell's icon; Custom (Icon ID) shows a texture file ID or icon path. Only the image changes — the aura still drives when the tracker shows and its cooldown/text.",
                order = 11.3,
                width = 1.1,
                values = {
                    auto  = "Auto (Aura Icon)",
                    spell = "Custom (Spell ID)",
                    icon  = "Custom (Icon ID)",
                },
                sorting = { "auto", "spell", "icon" },
                -- Single-aura Icon trackers only: a multi-aura fan-out would
                -- have every icon replaced by one texture.
                hidden = function()
                    return pairIconBorderMode() or isCustomKind
                        or isMultiAura() or not styleIs("icon")
                end,
                get = function() local e = el(); return (e and e.auraIconMode) or "auto" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.auraIconMode = val
                    e._iconBindDirty = true  -- rebind the slot icon region (see RebindSlotIcon)
                    refresh()
                    Notify()
                end,
            },
            auraIconInput = {
                type = "input",
                name = function()
                    local e = el()
                    return (e and e.auraIconMode == "icon") and "Icon ID" or "Spell ID"
                end,
                desc = "The icon to show instead of the aura's own icon. Interpreted per Icon Source.",
                order = 11.4,
                width = 1.1,
                hidden = function()
                    if pairIconBorderMode() then return true end
                    local e = el()
                    local mode = e and e.auraIconMode
                    return isCustomKind or isMultiAura() or not styleIs("icon")
                        or not (mode == "spell" or mode == "icon")
                end,
                get = function() local e = el(); return (e and e.auraIconInput) or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.auraIconInput = (val and val ~= "") and val or nil
                    e._iconBindDirty = true  -- rebind the slot icon region (see RebindSlotIcon)
                    refresh()
                    Notify()
                end,
            },
            cooldownSwipe = {
                type = "toggle",
                name = "Cooldown Swipe",
                desc = "Show a cooldown swipe overlay on the icon.",
                order = 12,
                -- Custom-kind has no aura/cooldown to swipe. Icon-pair
                -- Animate Border repurposes the swipe as the border itself.
                hidden = function()
                    return pairIconBorderMode() or not styleIs("icon") or isCustomKind
                end,
                -- Render treats nil as ON (`~= false`), so the toggle must too.
                get = function() local e = el(); return e and e.cooldownSwipe ~= false end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.cooldownSwipe = val
                    refresh()
                end,
            },

            -- Texture. A chosen image shown while the aura is present: no
            -- engine icon binding, no swipe, no stack text. Single-slot only
            -- (see AllowedTypeSorting).
            textureHeader = {
                type = "header",
                name = "Texture Settings",
                order = 12.5,
                hidden = function() return not styleIs("texture") end,
            },
            texturePreview = {
                type = "description",
                order = 12.51,
                width = "full",
                -- Custom widget, not AceConfig's stock description image: the
                -- stock path can only SetTexture/SetTexCoord, so it could show
                -- neither the Color/Desaturate/Rotation/Blend settings nor an
                -- atlas. See RegisterTexturePreviewWidget.
                dialogControl = "HETTexturePreview",
                -- Returns the raw value AND stages the draw state for the
                -- widget. ACD calls this immediately before the control's
                -- SetImage, which consumes and clears the staging table — the
                -- option member API has no other channel for these. The return
                -- must stay a string or number or ACD skips SetImage entirely.
                image = function()
                    local e = el()
                    if not e then return nil end
                    local v = e.textureValue
                    if v == nil or v == "" then return nil end
                    -- Mirrors the live render (Tracker.lua ApplyStyle
                    -- "texture"): color is el.color, rotation is stored in
                    -- degrees but set in radians.
                    local c = e.color or { 1, 1, 1, 1 }
                    ns.texPreviewPending = {
                        kind  = e.textureKind,
                        r     = c[1] or 1,
                        g     = c[2] or 1,
                        b     = c[3] or 1,
                        a     = c[4] or 1,
                        blend = e.textureBlend or "ADD",
                        desat = e.textureDesaturate and true or false,
                        rot   = math.rad(e.textureRotation or 0),
                    }
                    -- Size rides the 2nd/3rd RETURNS, not imageWidth/
                    -- imageHeight members: AceConfigRegistry types those as
                    -- `optnumber` with no function form and hard-rejects a
                    -- getter (validation error on panel open), while `image` is
                    -- `optstringnumberfunc` and ACD reads
                    -- `local image, width, height = GetOptionsMemberValue(...)`,
                    -- consulting the members only when width comes back nil.
                    -- This is the only way to size the swatch dynamically.
                    local pw, ph = texPreviewSize()
                    if e.textureKind == "atlas" then return tostring(v), pw, ph end
                    return (tonumber(v) or v), pw, ph
                end,
                name = function()
                    local e = el()
                    if not e then return "" end
                    if e.textureKind == "atlas" then
                        return "Atlas: |cffffff00" .. tostring(e.textureValue or "-") .. "|r"
                    end
                    return " "
                end,
                hidden = function() return not styleIs("texture") end,
            },
            texturePick = {
                type = "execute",
                name = "Choose Texture...",
                desc = "Browse the bundled spell-alert art, every Blizzard atlas, and your shared-media textures.",
                order = 12.52,
                width = 1.1,
                hidden = function() return not styleIs("texture") end,
                func = function()
                    local e = el(); if not e then return end
                    ns.OpenTexturePicker(e.textureValue, e.textureKind,
                        function(value, kind)
                            e.textureValue = value
                            e.textureKind  = kind
                            refresh()
                            Notify()
                        end)
                end,
            },
            textureKind = {
                type = "select",
                name = "Source",
                desc = "How the value below is interpreted. File covers FileDataIDs and texture paths; Atlas is a named Blizzard atlas.",
                order = 12.53,
                width = 0.8,
                values = { file = "File / ID", atlas = "Atlas" },
                sorting = { "file", "atlas" },
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureKind) or "file" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureKind = val
                    refresh()
                    Notify()
                end,
            },
            textureValue = {
                type = "input",
                name = "Texture",
                desc = "FileDataID, texture path, or atlas name. Anything the picker does not list can be typed here directly.",
                order = 12.54,
                width = 1.3,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return e and tostring(e.textureValue or "") or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    val = val and val:gsub("^%s+", ""):gsub("%s+$", "") or ""
                    e.textureValue = (val ~= "") and (tonumber(val) or val) or nil
                    refresh()
                    Notify()
                end,
            },
            textureWidth = {
                type = "range",
                name = "Width",
                desc = "Texture width in pixels.",
                order = 12.55,
                min = 8, max = 512, step = 1,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureWidth) or 128 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureWidth = val
                    refreshThrottled()
                    NotifyThrottled()  -- reshape the preview swatch
                end,
            },
            textureHeight = {
                type = "range",
                name = "Height",
                desc = "Texture height in pixels.",
                order = 12.56,
                min = 8, max = 512, step = 1,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureHeight) or 128 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureHeight = val
                    refreshThrottled()
                    NotifyThrottled()  -- reshape the preview swatch
                end,
            },
            textureBlend = {
                type = "select",
                name = "Blend Mode",
                desc = "Additive makes the art glow against whatever is behind it — the spell-alert textures are authored for it. Normal blends conventionally.",
                order = 12.57,
                width = 0.9,
                values = { ADD = "Additive", BLEND = "Normal" },
                sorting = { "ADD", "BLEND" },
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureBlend) or "ADD" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureBlend = val
                    refresh()
                    Notify()  -- repaint the preview swatch
                end,
            },
            textureRotation = {
                type = "range",
                name = "Rotation",
                desc = "Rotation in degrees.",
                order = 12.58,
                min = 0, max = 359, step = 1,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return (e and e.textureRotation) or 0 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureRotation = val
                    refreshThrottled()
                    NotifyThrottled()  -- repaint the preview swatch (drag control)
                end,
            },
            textureDesaturate = {
                type = "toggle",
                name = "Desaturate",
                desc = "Draw the texture in greyscale.",
                order = 12.59,
                hidden = function() return not styleIs("texture") end,
                get = function() local e = el(); return e and e.textureDesaturate == true end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.textureDesaturate = val and true or nil
                    refresh()
                    Notify()  -- repaint the preview swatch
                end,
            },

            -- Border (icon type)
            borderHeader = {
                type = "header",
                name = "Border Settings",
                order = 13,
                -- Animate Border IS the border (parent-styled, animated).
                hidden = function()
                    return pairIconBorderMode() or not styleIs("icon")
                end,
            },
            borderType = {
                type = "select",
                name = "Border Style",
                desc = "Blizzard-Style keeps the icon's default border. Rounded crops it and draws the rounded frame instead, with the icon masked to match; Thick is the same frame ~1px heavier. None zooms the icon to hide it. Square draws a flat colored border.",
                order = 14,
                values = {
                    blizzard      = "Blizzard-Style",
                    rounded       = "Rounded",
                    rounded_thick = "Rounded (Thick)",
                    none          = "None",
                    square        = "Square",
                },
                sorting = { "rounded", "rounded_thick", "square", "blizzard", "none" },
                hidden = function() return pairIconBorderMode() or not styleIs("icon") end,
                get = function() local e = el(); return ns.BorderType(e and e.style, e) end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.borderType = val
                    refresh()
                end,
            },
            iconRoundedColor = {
                type = "color",
                name = "Border Color",
                desc = "Tint of the rounded frame.",
                order = 14.5,
                hasAlpha = true,
                hidden = function()
                    if pairIconBorderMode() then return true end
                    local e = el()
                    return not (styleIs("icon") and ns.IsRoundedBorder(ns.BorderType(e and e.style, e)))
                end,
                get = function()
                    local e = el()
                    local c = (e and e.borderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.borderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            borderThickness = {
                type = "range",
                name = "Border Thickness",
                desc = "Thickness of the square border in pixels.",
                order = 15,
                min = 1, max = 8, step = 1,
                hidden = function()
                    if pairIconBorderMode() then return true end
                    local e = el()
                    return not (styleIs("icon") and e and e.borderType == "square")
                end,
                get = function() local e = el(); return (e and e.borderThickness) or 1 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.borderThickness = val
                    refreshThrottled()
                end,
            },
            borderColor = {
                type = "color",
                name = "Border Color",
                order = 16,
                hasAlpha = true,
                hidden = function()
                    if pairIconBorderMode() then return true end
                    local e = el()
                    return not (styleIs("icon") and e and e.borderType == "square")
                end,
                get = function()
                    local e = el()
                    local c = (e and e.borderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.borderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Text entries (Icon, Bar and Text). Each TYPE maps to one
            -- engine binding, and a binding drives exactly ONE FontString
            -- - so a type whose binding another entry already claims
            -- drops out of the dropdown, the same rule as Tracker Type.
            -- The icon's Cooldown countdown numbers stay off; duration
            -- renders through the engine-bound FontString so it is
            -- configurable.
            textEntriesHeader = {
                type = "header",
                name = "Text",
                order = 80,
                -- Also publishes this element's text closures for the
                -- InlineGroup corner-X override below. ACD evaluates
                -- hidden only for the pane it is actually feeding, and
                -- this header sorts ahead of its sibling text groups, so
                -- the context is always the visible element's.
                hidden = function()
                    local show = hasTextStyle()
                    if show then
                        ns._activeTextCtx = {
                            hasTextStyle = hasTextStyle,
                            texts = texts,
                            refresh = refresh,
                        }
                    end
                    return not show
                end,
            },
            addText = {
                type = "execute",
                name = "+ Add Text",
                desc = "Add another text readout to this tracker.",
                -- Below the per-text groups (order 80.1 + k/100, k up to
                -- MAX_TEXTS = 3, so max 80.13) instead of above them.
                order = 80.2,
                hidden = function() return not (hasTextStyle() and CanAddText()) end,
                func = function() AddText() end,
            },


            -- Bar/Circle shared color
            colorHeader = {
                type = "header",
                name = "Style Settings",
                order = 20,
                hidden = function()
                    return not (styleIs("bar") or styleIs("circle")
                        or styleIs("texture"))
                end,
            },
            color = {
                type = "color",
                -- In the pair Border animations this tints the animated border.
                name = function()
                    return (pairBorderMode() or pairIconBorderMode())
                        and "Border Color" or "Color"
                end,
                -- On the Texture style this is the vertex color: it TINTS the
                -- art rather than replacing it (white = untinted), and the
                -- alpha channel is the texture's opacity.
                desc = function()
                    return styleIs("texture")
                        and "Tint applied to the texture. White leaves the art unchanged; the alpha channel sets its opacity."
                        or  "Color of the bar or circle."
                end,
                order = 24,
                hasAlpha = true,
                hidden = function()
                    return not (styleIs("bar") or styleIs("circle")
                        or styleIs("texture") or pairIconBorderMode())
                end,
                get = function()
                    local e = el()
                    local c = (e and e.color) or { 1, 1, 1, 1 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.color = { r, g, b, a }
                    refreshThrottled()
                    -- Texture style only: repaint the preview swatch, which
                    -- tints from el.color. Bar/circle have no swatch, so they
                    -- must not pay for a pane rebuild per drag tick.
                    if styleIs("texture") then NotifyThrottled() end
                end,
            },

            -- Bar
            barOrientation = {
                type = "select",
                name = "Bar Orientation",
                desc = "Direction of the bar fill.",
                order = 21,
                values = {
                    horizontal = "Horizontal",
                    vertical   = "Vertical",
                },
                sorting = { "horizontal", "vertical" },
                -- Locked PAIR elements mirror the parent element's geometry.
                hidden = function() return isLockedPair or not styleIs("bar") end,
                get = function() local e = el(); return (e and e.barOrientation) or "horizontal" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barOrientation = val
                    if val == "vertical" then
                        e.barWidth, e.barHeight = 20, 200
                    else
                        e.barWidth, e.barHeight = 200, 20
                    end
                    refresh()
                end,
            },
            barWidth = {
                type = "range",
                name = "Bar Width",
                order = 22,
                min = 4, max = 600, step = 1,
                hidden = function() return isLockedPair or not styleIs("bar") end,
                get = function() local e = el(); return (e and e.barWidth) or 200 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barWidth = val
                    refreshThrottled()
                end,
            },
            barHeight = {
                type = "range",
                name = "Bar Height",
                order = 23,
                min = 4, max = 600, step = 1,
                hidden = function() return isLockedPair or not styleIs("bar") end,
                get = function() local e = el(); return (e and e.barHeight) or 20 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barHeight = val
                    refreshThrottled()
                end,
            },

            barBgColor = {
                -- (Hidden in bar-pair Border mode: background is forced
                -- transparent by the animation — see pairBorderMode.)
                type = "color",
                name = "Background Color",
                desc = "Background fill behind the bar.",
                order = 25,
                hasAlpha = true,
                hidden = function() return pairBorderMode() or not styleIs("bar") end,
                get = function()
                    local e = el()
                    local bg = (e and e.barBgColor) or { 0.1, 0.1, 0.1, 0.7 }
                    return bg[1], bg[2], bg[3], bg[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.barBgColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            barTexture = {
                type = "select",
                name = "Bar Texture",
                desc = "Statusbar texture for the bar fill (from LibSharedMedia).",
                order = 26,
                dialogControl = "LSM30_Statusbar",
                values = function()
                    local vals = {}
                    if LSM then
                        for name in pairs(LSM:HashTable("statusbar")) do
                            vals[name] = name
                        end
                    end
                    return vals
                end,
                -- Border mode forces the outline texture.
                hidden = function() return pairBorderMode() or not styleIs("bar") end,
                get = function() local e = el(); return (e and e.barTexture) or ns.DEFAULT_BAR_TEXTURE end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barTexture = val
                    refreshThrottled()
                end,
            },

            -- Unusable styling (ID kinds only, all visual styles that draw
            -- an icon): grays out and/or color-overlays the icon while the
            -- ability/item is not usable (on cooldown or missing
            -- resources). Combat-safe boolean reads; the ID watcher
            -- re-renders on flips (enabling these arms the usable events —
            -- Core.UpdateIDReadyWatch). Ordered to land at the END of the
            -- style section for each style (icons after Cooldown Swipe,
            -- bars/circles after their fill options).
            idDesatUnusable = {
                type = "toggle",
                name = "Desaturate when Unusable",
                desc = "Gray out the icon while the ability/item is not usable (on cooldown or missing charges/resources). Previews show the effect while active.",
                order = function() return styleIs("icon") and 12.6 or 26.5 end,
                width = "full",
                hidden = function()
                    return not isIDKind
                        or not (styleIs("icon") or styleIs("bar") or styleIs("circle"))
                end,
                get = function()
                    local e = el()
                    return (e and e.idDesatUnusable) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.idDesatUnusable = val or nil
                    refresh()
                end,
            },
            idRecolorUnusable = {
                type = "toggle",
                name = "Recolor when Unusable",
                desc = "While the ability/item is not usable (on cooldown or missing charges/resources): icons get a colored overlay; bar fills are recolored (the picker's opacity applies to the icon overlay only). Previews show the effect while active.",
                order = function() return styleIs("icon") and 12.7 or 26.6 end,
                hidden = function()
                    return not isIDKind
                        or not (styleIs("icon") or styleIs("bar") or styleIs("circle"))
                end,
                get = function()
                    local e = el()
                    return (e and e.idRecolorUnusable) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.idRecolorUnusable = val or nil
                    refresh()
                end,
            },
            idRecolorColor = {
                type = "color",
                name = "Unusable Overlay Color",
                desc = "Color (and opacity) of the unusable overlay.",
                order = function() return styleIs("icon") and 12.8 or 26.7 end,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    return not isIDKind
                        or not (styleIs("icon") or styleIs("bar") or styleIs("circle"))
                        or not (e and e.idRecolorUnusable)
                end,
                get = function()
                    local e = el()
                    local c = (e and e.idRecolorColor) or { 1, 0.15, 0.15, 0.45 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.idRecolorColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Always-on icon styling — the NON-ability/item counterpart of
            -- the "when Unusable" options above. No state to evaluate: these
            -- apply whenever the icon is drawn, so they cost two widget
            -- setters at style time and nothing on any render path. Shown for
            -- the Icon style (the icon) and the Bar style (its icon square);
            -- the bar FILL keeps its own Color option untouched.
            iconDesaturate = {
                type = "toggle",
                name = "Desaturate",
                desc = "Gray out the icon whenever it is visible.",
                order = function() return styleIs("icon") and 12.62 or 28.7 end,
                width = "full",
                hidden = function()
                    local e = el()
                    if isIDKind then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    return (e and e.iconDesaturate) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.iconDesaturate = val or nil
                    refresh()
                end,
            },
            iconRecolor = {
                type = "toggle",
                name = "Recolor",
                desc = "Lay a colored overlay over the icon whenever it is visible.",
                order = function() return styleIs("icon") and 12.72 or 28.72 end,
                hidden = function()
                    local e = el()
                    if isIDKind then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    return (e and e.iconRecolor) and true or false
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.iconRecolor = val or nil
                    refresh()
                end,
            },
            iconTintColor = {
                type = "color",
                name = "Overlay Color",
                desc = "Color (and opacity) of the overlay laid over the icon.",
                order = function() return styleIs("icon") and 12.82 or 28.74 end,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    if isIDKind or not (e and e.iconRecolor) then return true end
                    if styleIs("icon") then return false end
                    return not (styleIs("bar") and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    local c = (e and e.iconTintColor) or { 0.2, 0.45, 1, 0.45 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.iconTintColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Bar border (same fields as the icon border, bar-specific
            -- labels/ordering)
            barBorderHeader = {
                type = "header",
                name = "Border Settings",
                order = 27,
                -- Border mode IS the border (animated); no static one.
                hidden = function() return pairBorderMode() or not styleIs("bar") end,
            },
            barBorderType = {
                type = "select",
                name = "Border Style",
                desc = "Rounded draws the rounded bar frame, with the fill and background masked to match; Thick is the same frame ~1px heavier. None leaves the bar bare. Square draws a flat colored border.",
                order = 27.1,
                values = {
                    rounded       = "Rounded",
                    rounded_thick = "Rounded (Thick)",
                    none          = "None",
                    square        = "Square",
                },
                sorting = { "rounded", "rounded_thick", "square", "none" },
                hidden = function() return pairBorderMode() or not styleIs("bar") end,
                get = function() local e = el(); return ns.BorderType(e and e.style, e) end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.borderType = val
                    refresh()
                end,
            },
            -- Shared with the square Border Color (both read/write
            -- e.borderColor) so switching Border Type keeps the same color.
            barRoundedColor = {
                type = "color",
                name = "Border Color",
                desc = "Tint of the rounded frame.",
                order = 27.15,
                hasAlpha = true,
                hidden = function()
                    if pairBorderMode() then return true end
                    local e = el()
                    return not (styleIs("bar") and ns.IsRoundedBorder(ns.BorderType(e and e.style, e)))
                end,
                get = function()
                    local e = el()
                    local c = (e and e.borderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.borderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            barBorderThickness = {
                type = "range",
                name = "Border Thickness",
                desc = "Thickness of the square border in pixels.",
                order = 27.2,
                min = 1, max = 8, step = 1,
                hidden = function()
                    if pairBorderMode() then return true end
                    local e = el()
                    return not (styleIs("bar") and e and e.borderType == "square")
                end,
                get = function() local e = el(); return (e and e.borderThickness) or 1 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.borderThickness = val
                    refreshThrottled()
                end,
            },
            barBorderColor = {
                type = "color",
                name = "Border Color",
                order = 27.3,
                hasAlpha = true,
                hidden = function()
                    if pairBorderMode() then return true end
                    local e = el()
                    return not (styleIs("bar") and e and e.borderType == "square")
                end,
                get = function()
                    local e = el()
                    local c = (e and e.borderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.borderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },

            -- Bar icon
            barIconHeader = {
                type = "header",
                name = "Icon",
                order = 28,
                hidden = function() return not styleIs("bar") end,
            },
            barShowIcon = {
                type = "toggle",
                name = "Show Icon",
                desc = "Show the buff's icon at the end of the bar.",
                order = 28.1,
                -- PAIR elements mirror the parent element's geometry (the icon
                -- square changes the holder footprint).
                hidden = function() return isLockedPair or not styleIs("bar") end,
                get = function() local e = el(); return not e or e.barShowIcon ~= false end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barShowIcon = val
                    refresh()
                end,
            },
            -- Icon Source for the BAR's icon square (single-aura only; hidden
            -- when Show Icon is off). Writes the SAME el.auraIconMode /
            -- el.auraIconInput fields as the Icon-style dropdown, so the
            -- override logic is unified.
            barAuraIconMode = {
                type = "select",
                name = "Icon Source",
                desc = "Auto uses the tracked aura's own icon. Custom (Spell ID) shows a chosen spell's icon; Custom (Icon ID) shows a texture file ID or icon path. Only the bar's icon image changes — the aura still drives when the tracker shows and its cooldown/text.",
                order = 28.15,
                width = 1.1,
                values = {
                    auto  = "Auto (Aura Icon)",
                    spell = "Custom (Spell ID)",
                    icon  = "Custom (Icon ID)",
                },
                sorting = { "auto", "spell", "icon" },
                hidden = function()
                    local e = el()
                    return isCustomKind or isMultiAura()
                        or not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function() local e = el(); return (e and e.auraIconMode) or "auto" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.auraIconMode = val
                    e._iconBindDirty = true  -- rebind the slot icon region (see RebindSlotIcon)
                    refresh()
                    Notify()
                end,
            },
            barAuraIconInput = {
                type = "input",
                name = function()
                    local e = el()
                    return (e and e.auraIconMode == "icon") and "Icon ID" or "Spell ID"
                end,
                desc = "The icon to show instead of the aura's own icon. Interpreted per Icon Source.",
                order = 28.16,
                width = 1.1,
                hidden = function()
                    local e = el()
                    local mode = e and e.auraIconMode
                    return isCustomKind or isMultiAura()
                        or not (styleIs("bar") and e and e.barShowIcon ~= false)
                        or not (mode == "spell" or mode == "icon")
                end,
                get = function() local e = el(); return (e and e.auraIconInput) or "" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.auraIconInput = (val and val ~= "") and val or nil
                    e._iconBindDirty = true  -- rebind the slot icon region (see RebindSlotIcon)
                    refresh()
                    Notify()
                end,
            },
            barIconPosition = {
                type = "select",
                name = "Icon Position",
                desc = "Which end of the bar the icon sits on.",
                order = 28.2,
                values = function()
                    local e = el()
                    if e and e.barOrientation == "vertical" then
                        return { bottom = "Bottom", top = "Top" }
                    end
                    return { left = "Left", right = "Right" }
                end,
                sorting = function()
                    local e = el()
                    if e and e.barOrientation == "vertical" then
                        return { "bottom", "top" }
                    end
                    return { "left", "right" }
                end,
                hidden = function()
                    if isLockedPair then return true end -- mirrors parent geometry
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function()
                    local e = el()
                    local v = e and e.barIconPosition
                    if e and e.barOrientation == "vertical" then
                        return v == "top" and "top" or "bottom"
                    end
                    return v == "right" and "right" or "left"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barIconPosition = val
                    refresh()
                end,
            },
            barIconBorderType = {
                type = "select",
                name = "Icon Border Style",
                desc = "Blizzard-Style keeps the icon's default border. Rounded masks the icon to a rounded rect and frames it; Thick is the same frame ~1px heavier. None zooms the icon to hide its border. Square draws a flat colored border.",
                order = 28.3,
                values = {
                    blizzard      = "Blizzard-Style",
                    rounded       = "Rounded",
                    rounded_thick = "Rounded (Thick)",
                    none          = "None",
                    square        = "Square",
                },
                sorting = { "rounded", "rounded_thick", "square", "blizzard", "none" },
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function() local e = el(); return (e and e.barIconBorderType) or "rounded" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barIconBorderType = val
                    refresh()
                end,
            },
            barIconRoundedColor = {
                type = "color",
                name = "Icon Border Color",
                desc = "Color of the border around the bar's icon.",
                order = 28.35,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false
                        and ns.IsRoundedBorder(e.barIconBorderType))
                end,
                -- Shared with the square Icon Border Color (both read/write
                -- e.barIconBorderColor) so switching border style keeps it.
                get = function()
                    local e = el()
                    local c = (e and e.barIconBorderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.barIconBorderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            barIconBorderThickness = {
                type = "range",
                name = "Icon Border Thickness",
                desc = "Thickness of the square icon border in pixels.",
                order = 28.4,
                min = 1, max = 8, step = 1,
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false
                        and e.barIconBorderType == "square")
                end,
                get = function() local e = el(); return (e and e.barIconBorderThickness) or 1 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barIconBorderThickness = val
                    refreshThrottled()
                end,
            },
            barIconBorderColor = {
                type = "color",
                name = "Icon Border Color",
                order = 28.5,
                hasAlpha = true,
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false
                        and e.barIconBorderType == "square")
                end,
                get = function()
                    local e = el()
                    local c = (e and e.barIconBorderColor) or { 0.815686, 0.815686, 0.815686, 0.85 }
                    return c[1], c[2], c[3], c[4]
                end,
                set = function(_, r, g, b, a)
                    local e = el(); if not e then return end
                    e.barIconBorderColor = { r, g, b, a }
                    refreshThrottled()
                end,
            },
            barIconSwipe = {
                type = "toggle",
                name = "Show Cooldown Swipe",
                desc = "Darken the icon progressively as the buff runs out.",
                order = 28.6,
                hidden = function()
                    local e = el()
                    return not (styleIs("bar") and e and e.barShowIcon ~= false)
                end,
                get = function() local e = el(); return not e or e.barIconSwipe ~= false end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.barIconSwipe = val
                    refresh()
                end,
            },

            -- Circle
            circleRadius = {
                type = "range",
                name = "Circle Size",
                order = 25,
                min = 50, max = 150, step = 1,
                hidden = function() return not styleIs("circle") end,
                get = function() local e = el(); return (e and e.circleRadius) or 80 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.circleRadius = val
                    refreshThrottled()
                end,
            },
            ringTexture = {
                type = "select",
                name = "Circle Thickness",
                desc = "Thickness of the circle ring.",
                order = 26,
                values = {
                    ["Ring_10px"] = "Thin (10px)",
                    ["Ring_20px"] = "Medium (20px)",
                    ["Ring_30px"] = "Thick (30px)",
                    ["Ring_40px"] = "Extra Thick (40px)",
                },
                sorting = { "Ring_10px", "Ring_20px", "Ring_30px", "Ring_40px" },
                hidden = function() return not styleIs("circle") end,
                get = function() local e = el(); return (e and e.ringTexture) or "Ring_20px" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.ringTexture = val
                    refresh()
                end,
            },

            -- Growth (multi-spell groups only: icon/bar fan out when
            -- several tracked buffs are active at once)
            growHeader = {
                type = "header",
                name = "Growth",
                order = 35,
                hidden = function()
                    return not (isMultiAura()
                        and (styleIs("icon") or styleIs("bar")))
                end,
            },
            growDirection = {
                type = "select",
                name = "Grow Direction",
                desc = "Direction additional icons/bars grow when more than one tracked buff is active. Bars stack perpendicular to their fill: horizontal bars grow down/up, vertical bars grow left/right.",
                order = 36,
                values = function()
                    local labels = { right = "Right", left = "Left", down = "Down", up = "Up" }
                    local e = el()
                    if e and e.style == "bar" then
                        if e.barOrientation == "vertical" then
                            return { left = "Left", right = "Right" }
                        end
                        return { down = "Down", up = "Up" }
                    end
                    return labels
                end,
                sorting = function()
                    local e = el()
                    if e and e.style == "bar" then
                        if e.barOrientation == "vertical" then
                            return { "left", "right" }
                        end
                        return { "down", "up" }
                    end
                    return { "right", "left", "down", "up" }
                end,
                hidden = function()
                    return not (isMultiAura()
                        and (styleIs("icon") or styleIs("bar")))
                end,
                -- Effective value: coerced to the allowed set for the
                -- current style/orientation (stored value may predate an
                -- orientation switch).
                get = function() local e = el(); return e and ns.EffectiveGrowDirection(e) end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.growDirection = val
                    refresh()
                end,
            },
            growMax = {
                type = "range",
                name = function() return styleIs("bar") and "Max Bars" or "Max Icons" end,
                -- Aura groups: hidden in Ordered mode (per-spell 1-frame groups
                -- have no engine-side total cap). Ability/Item groups: ALWAYS
                -- shown — they fan out over a fixed id list, collapse gaps via
                -- Show When, and use this as the display cap.
                desc = function()
                    if IsIDGroupCfg(cfgKey) then
                        return "Maximum number of icons/bars shown at once (the rest of the tracked list is capped off)."
                    end
                    return "Maximum number shown at once when several tracked buffs are active (Unordered group type only)."
                end,
                order = 36.5,
                min = 1, max = 10, step = 1,
                hidden = function()
                    if not (isMultiAura() and (styleIs("icon") or styleIs("bar"))) then
                        return true
                    end
                    -- Single-active groups are hard-capped to ONE frame in
                    -- ApplyGroupAssignments; a Max Icons control would be a lie.
                    if IsSingleActiveCfg(cfgKey) then return true end
                    if IsIDGroupCfg(cfgKey) then return false end  -- always capped
                    local t = ns.trackers[cfgKey]
                    return (t and t:IsOrdered()) and true or false
                end,
                get = function() local e = el(); return (e and e.maxFrames) or 3 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.maxFrames = val
                    refreshThrottled()
                end,
            },
            growSpacing = {
                type = "range",
                name = "Spacing",
                desc = "Gap between icons/bars in pixels.",
                order = 37,
                min = 0, max = 30, step = 1,
                hidden = function()
                    return not (isMultiAura()
                        and (styleIs("icon") or styleIs("bar")))
                end,
                get = function() local e = el(); return (e and e.spacing) or 2 end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.spacing = val
                    refreshThrottled()
                end,
            },

            -- Sound Alert
            soundHeader = {
                type = "header",
                name = "Sound Settings",
                order = 40,
                hidden = function() return not styleIs("sound") end,
            },
            -- Sound Type: Spell Name (bundled clip) vs Custom Sound
            -- (LSM). Custom trackers have no bundled clips — the
            -- dropdown is hidden and Custom Sound shows directly.
            soundType = {
                type = "select",
                name = "Sound Type",
                desc = "Spell Name plays a bundled voice clip saying the spell name; Custom Sound plays a sound of your choice.",
                order = 41,
                values = function()
                    -- Groups with a voiceOptions list label the bundled
                    -- option "Voice" (the clip is chosen below, not fixed
                    -- to the spell name).
                    local voiceLabel = ns.trackerConfigs[cfgKey].voiceOptions
                        and "Voice" or "Spell Name"
                    return { voice = voiceLabel, custom = "Custom Sound" }
                end,
                sorting = { "voice", "custom" },
                hidden = function() return not styleIs("sound") or not HasVoice(cfgKey) end,
                get = function()
                    local e = el()
                    return (e and e.soundType) or "voice"
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.soundType = val
                    refresh()
                end,
            },
            voiceShort = {
                type = "toggle",
                name = "Short Callouts",
                desc = "Use the short voice callouts (e.g. \"Bop\", \"Sac\", \"Pain Supp\") instead of full spell names. Spells without a short clip use their full one.",
                order = 41.5,
                hidden = function()
                    local e = el()
                    return not (styleIs("sound")
                        and HasVoice(cfgKey)
                        and e and (e.soundType or "voice") == "voice"
                        and ns.trackerConfigs[cfgKey].spellList
                        and not ns.trackerConfigs[cfgKey].voiceOptions)
                end,
                get = function() local e = el(); return e and e.voiceShort end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.voiceShort = val
                    refresh()
                end,
            },
            -- Voice clip picker for voiceOptions groups (e.g. Time Spiral:
            -- "Time Spiral" or "Free Movement"). Shown only in voice mode.
            voiceChoice = {
                type = "select",
                name = "Voice Clip",
                desc = "Which voice clip to play when the buff is applied.",
                order = 41.6,
                values = function()
                    local opts = ns.trackerConfigs[cfgKey].voiceOptions
                    local t = {}
                    if opts then
                        for _, o in ipairs(opts) do t[o.value] = o.name end
                    end
                    return t
                end,
                sorting = function()
                    local opts = ns.trackerConfigs[cfgKey].voiceOptions
                    local t = {}
                    if opts then
                        for _, o in ipairs(opts) do t[#t + 1] = o.value end
                    end
                    return t
                end,
                hidden = function()
                    local cfg = ns.trackerConfigs[cfgKey]
                    if not (styleIs("sound") and cfg.voiceOptions) then return true end
                    local e = el()
                    return not (e and (e.soundType or "voice") == "voice")
                end,
                get = function()
                    local e = el()
                    local opts = ns.trackerConfigs[cfgKey].voiceOptions
                    return (e and e.voiceChoice) or (opts and opts[1] and opts[1].value)
                end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.voiceChoice = val
                    refresh()
                end,
            },
            soundName = {
                type = "select",
                name = "Custom Sound",
                desc = "Sound to play when the buff is applied.",
                dialogControl = "LSM30_Sound",
                order = 41.7,
                width = 1.5,
                values = GetCustomSoundValues,
                hidden = function()
                    if not styleIs("sound") then return true end
                    if not HasVoice(cfgKey) then return false end
                    local e = el()
                    return (e and e.soundType or "voice") ~= "custom"
                end,
                get = function() local e = el(); return (e and e.soundName) or "None" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.soundName = val
                    refresh()
                end,
            },
            soundChannel = {
                type = "select",
                name = "Channel",
                desc = "Audio channel to play the alert on.",
                order = 42,
                values = {
                    Master = "Master",
                    SFX    = "Sound Effects",
                    Dialog = "Dialog",
                },
                sorting = { "Master", "SFX", "Dialog" },
                hidden = function() return not styleIs("sound") end,
                get = function() local e = el(); return (e and e.soundChannel) or "Master" end,
                set = function(_, val)
                    local e = el(); if not e then return end
                    e.soundChannel = val
                    refresh()
                end,
            },
            soundTest = {
                type = "execute",
                name = "Test",
                desc = "Preview this sound.",
                order = 43,
                width = "half",
                hidden = function() return not styleIs("sound") end,
                func = function()
                    local e = el()
                    local t = ns.trackers[cfgKey]
                    if not (e and t) then return end
                    local path = ns.ResolveElementSoundPath(t, e)
                    if path then
                        PlaySoundFile(path, e.soundChannel or "Master")
                    end
                end,
            },

            -- Position
            positionHeader = {
                type = "header",
                name = "Position",
                order = 90,
                -- Locked PAIR elements are position-locked over their parent.
                hidden = function() return isLockedPair or not hasVisualStyle() end,
            },
            resetPosition = {
                type = "execute",
                name = "Reset Position",
                desc = "Reset this tracker's position to the center of the screen.",
                order = 92,
                hidden = function() return isLockedPair or not hasVisualStyle() end,
                func = function()
                    local t = ns.trackers[cfgKey]
                    if t then
                        t:ResetElementPosition(i)
                        t:RefreshPreview()
                    end
                end,
            },
        },
    }

    -- Per-text inline groups, generated from the element's texts array.
    -- BuildOptions is registered as a FUNCTION, so the whole table is
    -- rebuilt on every NotifyChange and dynamic keys are safe.
    if e0 and (e0.style == "icon" or e0.style == "bar" or e0.style == "text") then
        local t0 = ns.EnsureTexts(e0)
        for k = 1, #t0 do
            group.args["text" .. k] = BuildTextGroup(k)
        end
    end

    return group
end

-------------------------------------------------------------------------------
-- Per-spell group (tree node with element subtabs)
-------------------------------------------------------------------------------
local function BuildSpellGroup(cfgKey, prefix, displayName, iconTex, order)
    local function P(key) return HET.db.profile[prefix .. key] end
    local function SetP(key, val) HET.db.profile[prefix .. key] = val end
    local function isDisabled() return not P("enabled") end
    local groupKey = NodeFor(cfgKey)
    local cfgIsCustom = ns.trackerConfigs[cfgKey].custom and true or false
    -- Show Previews is pointless without at least one VISUAL tracker.
    local function hasVisualTracker()
        local els = GetEls(prefix)
        for j = 1, #els do
            local s = els[j].style
            if s and s ~= "sound" then return true end
        end
        return false
    end

    -- Adding is allowed while there's an unclaimed (allowed) type — or, on
    -- compound trackers, while a deleted "(Active)" overlay could be
    -- re-added for an existing Icon/Bar. PAIR elements don't claim a type.
    local function CanAddTracker()
        local els = GetEls(prefix)
        if #els >= MAX_ELEMENTS then return false end
        local n = 0
        for j = 1, #els do
            if not els[j].pairFor then n = n + 1 end
        end
        if n < #AllowedTypeSorting(cfgKey) then return true end
        if PairableFor(cfgKey, prefix) then
            for k in pairs(PAIRABLE_STYLES) do
                if FindParentIndex(els, k) and not FindPairIndex(els, k) then
                    return true
                end
            end
            -- Standalone Text (Active): addable whenever missing.
            if not FindPairIndex(els, "text") then return true end
        end
        return false
    end

    -- Tracker list in its own column: nested trees MERGE into the main
    -- left tree, so the spell group is a TAB group (breaks the tree
    -- chain) holding one "Trackers" child whose childGroups="tree"
    -- renders the tracker entries as a vertical list column inside the
    -- pane (tab group → tree group → entries).
    local group = {
        type = "group",
        name = displayName,
        icon = iconTex,
        order = order,
        childGroups = "tab",
        args = {
            enableToggle = {
                type = "toggle",
                name = "Enable",
                desc = "Enable or disable the " .. displayName .. " tracker.",
                order = 1,
                -- Compact on custom pages so Enable + Name + Show
                -- Previews share the top row.
                width = cfgIsCustom and 0.6 or 1.1,
                get = function() return P("enabled") end,
                set = function(_, val)
                    SetP("enabled", val)
                    if not val then
                        HET:HidePreviewFor(cfgKey)
                    end
                    HET:RefreshTracking()
                    if val then
                        -- Enabling turns the tracker's previews ON (same rule
                        -- as adding a new tracker). After RefreshTracking so a
                        -- first enable has preset-seeded its elements.
                        HET:ShowPreviewFor(cfgKey)
                    end
                    -- The spell node moves between the Enabled/Disabled
                    -- sections — rebuild and follow it.
                    Notify()
                    ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), groupKey)
                end,
            },
            groupTypeBreak = {
                type = "description",
                name = "",
                order = 3.9,
                width = "full",
                hidden = function()
                    return isDisabled() or not IsMultiAuraCfg(cfgKey)
                        or IsSingleActiveCfg(cfgKey)
                        or ns.trackerConfigs[cfgKey].spellList ~= nil
                end,
            },
            groupType = {
                type = "select",
                name = "Group Type",
                desc = "Unordered shows active buffs in the engine's order with a Max Icons/Bars cap. Ordered shows them in the Tracked Buffs list order (reorder with the arrows); no total cap is available in that mode. The built-in defensive groups are always Unordered.",
                order = 4,
                values = {
                    unordered = "Unordered",
                    ordered   = "Ordered",
                },
                sorting = { "unordered", "ordered" },
                -- Built-in group trackers (spellList) are locked to Unordered,
                -- so the dropdown is hidden for them. Ability/Item groups have NO
                -- Unordered mode (always ordered — see methods:IsOrdered), so the
                -- dropdown is hidden for them too.
                hidden = function()
                    return isDisabled() or not IsMultiAuraCfg(cfgKey)
                        or IsSingleActiveCfg(cfgKey)
                        or ns.trackerConfigs[cfgKey].spellList ~= nil
                        or IsIDGroupCfg(cfgKey)
                end,
                get = function()
                    if ns.trackerConfigs[cfgKey].spellList then
                        return "unordered"
                    end
                    return P("groupOrdered") and "ordered" or "unordered"
                end,
                set = function(_, val)
                    SetP("groupOrdered", (val == "ordered") and true or nil)
                    -- Toggling Ordered restructures the options tree (per-row
                    -- reorder arrows appear, multi-aura element visibility
                    -- flips). Running RefreshTracking + NotifyChange
                    -- SYNCHRONOUSLY here rebuilds and RELEASES the very dropdown
                    -- widget that is still executing, so AceConfigDialog then
                    -- reads user.rootframe (nil) on the released widget and
                    -- errors ("attempt to index field 'rootframe'"). Defer to the
                    -- next tick so the widget finishes its OnClick first, THEN
                    -- rebuild. (C_Timer.After(0) = next frame.)
                    C_Timer.After(0, function()
                        HET:RefreshTracking()
                        HET:RefreshPreviewFor(cfgKey)
                        Notify()
                    end)
                end,
            },
            togglePreviews = {
                type = "execute",
                name = function()
                    local t = ns.trackers[cfgKey]
                    if t then
                        local els = GetEls(prefix)
                        for j = 1, #els do
                            if t:IsElementPreviewActive(j) then
                                return "Hide Previews"
                            end
                        end
                    end
                    return "Move/Preview"
                end,
                desc = "Show or hide previews for all of this spell's trackers so you can drag them into position.",
                order = 1.5,
                width = cfgIsCustom and 0.85 or nil,
                hidden = function()
                    return isDisabled() or not hasVisualTracker()
                end,
                func = function()
                    local t = ns.trackers[cfgKey]
                    if not t then return end
                    local els = GetEls(prefix)
                    local anyActive = false
                    for j = 1, #els do
                        if t:IsElementPreviewActive(j) then
                            anyActive = true
                            break
                        end
                    end
                    if anyActive then
                        t:HidePreview()
                    else
                        for j = 1, #els do
                            if els[j].style and els[j].style ~= "sound" then
                                t:ShowElementPreview(j)
                            end
                        end
                    end
                end,
            },
        },
    }

    -- (The old Spatial-only "Enable for DPS Evoker" toggle was removed on
    -- 12.1 — Show Conditions → Spec covers the same opt-out.)

    -- Tracker entries (dynamic — the options table is a generator, so
    -- Add/Remove rebuild this list via NotifyChange). They live in a
    -- single "Trackers" tab whose childGroups="tree" gives them their
    -- own vertical list column inside the pane.
    local trackerArgs = {}
    for i = 1, #GetEls(prefix) do
        trackerArgs["el" .. i] = BuildElementGroup(cfgKey, prefix, i, isDisabled)
    end

    -- "+ Add Tracker" entry: its pane IS the new-tracker page — a
    -- Tracker Type dropdown (only unclaimed types listed); choosing a
    -- type creates the tracker and selects its entry. No render-time
    -- side effects, so ACD's fallback selection landing here (e.g. when
    -- the list is empty) is harmless and shows exactly this page.
    local function ClaimedTypes()
        local used = {}
        local els = GetEls(prefix)
        for j = 1, #els do
            -- PAIR "(Active)" elements don't claim a type (they share their
            -- parent's; an orphan pair must not block re-adding the parent).
            if els[j].style and not els[j].pairFor then used[els[j].style] = true end
        end
        return used
    end
    trackerArgs.addTab = {
        type = "group",
        name = "|cff11ace9+ Add Tracker|r",
        order = 999,
        hidden = function() return isDisabled() or not CanAddTracker() end,
        args = {
            -- Nudge shown only while this aura has no trackers yet, so a fresh
            -- aura reads as "you still need to pick a type".
            emptyNote = {
                type = "description",
                name = "|cffff4444No trackers defined for this aura: select a tracker type.|r",
                order = 1,
                width = "full",
                hidden = function()
                    local els = GetEls(prefix)
                    for j = 1, #els do
                        if els[j].style then return true end
                    end
                    return false
                end,
            },
            newType = {
                type = "select",
                name = "Tracker Type",
                desc = "Visual types show while the buff is active; Sound Alert plays a sound when the buff is applied. Only one tracker of each type per spell.",
                order = 2,
                values = function()
                    local used = ClaimedTypes()
                    local v = {}
                    for _, k in ipairs(AllowedTypeSorting(cfgKey)) do
                        if not used[k] then v[k] = StyleLabel(cfgKey, k) end
                    end
                    -- Compound trackers: a deleted "(Active)" overlay can be
                    -- re-added while its parent Icon/Bar exists, and the
                    -- standalone Text (Active) is offered regardless of any
                    -- regular Text element.
                    if PairableFor(cfgKey, prefix) then
                        local els = GetEls(prefix)
                        for k in pairs(PAIRABLE_STYLES) do
                            if FindParentIndex(els, k) and not FindPairIndex(els, k) then
                                v["pair_" .. k] = StyleLabel(cfgKey, k) .. " (Active)"
                            end
                        end
                        if not FindPairIndex(els, "text") then
                            v.pair_text = "Text (Active)"
                        end
                    end
                    return v
                end,
                sorting = function()
                    local used = ClaimedTypes()
                    local order = {}
                    local els = GetEls(prefix)
                    local pairable = PairableFor(cfgKey, prefix)
                    for _, k in ipairs(AllowedTypeSorting(cfgKey)) do
                        if not used[k] then order[#order + 1] = k end
                        -- "(Active)" entries sort right after their type.
                        if pairable and PAIRABLE_STYLES[k]
                            and FindParentIndex(els, k) and not FindPairIndex(els, k) then
                            order[#order + 1] = "pair_" .. k
                        end
                        if pairable and k == "text"
                            and not FindPairIndex(els, "text") then
                            order[#order + 1] = "pair_text"
                        end
                    end
                    return order
                end,
                get = function() return nil end,
                set = function(_, val)
                    -- "(Active)" overlay re-add for an existing Icon/Bar on a
                    -- compound tracker.
                    local pairStyle = val:match("^pair_(%a+)$")
                    if pairStyle then
                        local els = GetEls(prefix)
                        -- Text (Active) is standalone (no parent needed);
                        -- icon/bar overlays require their parent element.
                        local okStyle = PAIRABLE_STYLES[pairStyle]
                            and FindParentIndex(els, pairStyle) ~= nil
                            or pairStyle == "text"
                        if not PairableFor(cfgKey, prefix)
                            or not okStyle
                            or FindPairIndex(els, pairStyle)
                            or #els >= MAX_ELEMENTS then return end
                        els[#els + 1] = MakePairElement(cfgKey, pairStyle)
                        local newIdx = #els
                        HET:RefreshTracking()
                        HET:RefreshPreviewFor(cfgKey)
                        Notify()
                        ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), groupKey, "trackers", "el" .. newIdx)
                        return
                    end
                    if not CanAddTracker() or ClaimedTypes()[val] then return end
                    local els = GetEls(prefix)
                    local newEl = NewElementDefaults(cfgKey)
                    newEl.style = val
                    -- Texture TINTS the chosen art (ApplyStyle SetVertexColor);
                    -- it doesn't fill a shape the way bar/circle do. The kind
                    -- default from DefaultElementColor (buff blue / debuff
                    -- orange / ability purple) would therefore recolor the
                    -- user's image the instant they picked it. White leaves the
                    -- art exactly as authored.
                    if val == "texture" then
                        newEl.color = { 1, 1, 1, 1 }
                    end
                    -- Ability trackers auto-add a Charges text by default, but a
                    -- single-charge ability has no charges to show — suppress the
                    -- auto Charges text unless the tracked spell actually has
                    -- charges (maxCharges > 1). Item trackers keep Stacks (bag
                    -- count); other kinds are unaffected.
                    if IsAbilityKindCfg(cfgKey) then
                        local ids = HET.db.profile[prefix .. "ids"]
                        local firstID = ids and ids[1]
                        if not SpellHasCharges(firstID) then
                            newEl.iconShowStackText = false
                            newEl.barShowStackText = false
                        end
                    end
                    els[#els + 1] = newEl
                    local newIdx = #els
                    -- Compound ability/item: a new Icon/Bar auto-adds its
                    -- "(Active)" aura overlay right after it.
                    if PAIRABLE_STYLES[val] and PairableFor(cfgKey, prefix)
                        and not FindPairIndex(els, val) and #els < MAX_ELEMENTS then
                        els[#els + 1] = MakePairElement(cfgKey, val)
                    end
                    HET:RefreshTracking()
                    -- A newly added visual tracker previews IMMEDIATELY (not
                    -- just when siblings are already previewing) so the user
                    -- can see and position it right away. Sound trackers have
                    -- nothing to preview.
                    local t = ns.trackers[cfgKey]
                    if t and val ~= "sound" then
                        t:ShowElementPreview(newIdx)
                    end
                    HET:RefreshPreviewFor(cfgKey)
                    Notify()
                    ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), groupKey, "trackers", "el" .. newIdx)
                end,
            },
        },
    }

    group.args.trackers = {
        type = "group",
        name = "Trackers",
        order = 100,
        childGroups = "tree",
        hidden = isDisabled,
        args = trackerArgs,
    }

    -- Custom trackers: Name / Delete / (aura kind) Spell ID on the main
    -- page; (group kind) Tracked Buffs tab below.
    local cfg = ns.trackerConfigs[cfgKey]
    if cfg and cfg.custom then
        local tracker = ns.trackers[cfgKey]
        local isDebuff = IsDebuffCfg(cfgKey)
        local function entry()
            return tracker and tracker:GetCustomEntry()
        end
        -- ID-kind (ability/item) helpers, mirroring the aura-kind ones above.
        -- Single kinds ("ability"/"item") get a labeled ID input + label;
        -- group kinds ("abilitygroup"/"itemgroup") reuse the auraList machinery.
        local function isAbilitySingle()
            local en = entry(); return en and en.kind == "ability"
        end
        local function isItemSingle()
            local en = entry(); return en and en.kind == "item"
        end
        local function isIDSingle()
            return isAbilitySingle() or isItemSingle()
        end
        local function isIDGroup()
            local en = entry()
            return en and (en.kind == "abilitygroup" or en.kind == "itemgroup")
        end
        -- Is this a SINGLE item tracker? Picks item vs spell labels for the
        -- single-input path (customIDLabel). GROUPS are mixed now — their rows
        -- resolve item-ness PER ID via idKinds (IDKindOfCfg), not this.
        local function entryIsItem()
            local en = entry()
            return en and en.kind == "item"
        end
        group.args.customName = {
            type = "input",
            name = "Name",
            desc = "Display name for this tracker in the spell list.",
            order = 1.2,
            width = 0.9,
            get = function()
                local en = entry()
                return (en and en.name) or ""
            end,
            set = function(_, val)
                local en = entry()
                if not en then return end
                val = val:gsub("^%s+", ""):gsub("%s+$", "")
                if val ~= "" then
                    en.name = val
                    -- Manually named: stop auto-naming from spell ID.
                    en.autoName = false
                end
                Notify()
            end,
        }
        -- Unit + Own Only. Debuffs pick Target/Focus and default Own Only ON
        -- (HARMFUL|PLAYER); buffs are always on the player (single "Player
        -- (Self)" option) and default Own Only OFF (HELPFUL, opt in to
        -- HELPFUL|PLAYER). Both apply live via the refresh pipeline. Shown for
        -- aura-kind customs only (not custom-"Other" trackers, which have no
        -- aura).
        local function isAuraCustom()
            local en = entry()
            return en and en.kind == "aura"
        end
        group.args.customUnit = {
            type = "select",
            name = "Unit",
            desc = function()
                local en = entry()
                return (en and en.harmful)
                    and "Which unit to watch this debuff on."
                    or  "Buffs are tracked on you."
            end,
            order = 1.3,
            width = 0.9,
            values = function()
                local en = entry()
                if en and en.harmful then return { target = "Target (Enemy)", focus = "Focus (Enemy)" } end
                return { player = "Player (Self)" }
            end,
            sorting = function()
                local en = entry()
                if en and en.harmful then return { "target", "focus" } end
                return { "player" }
            end,
            hidden = function()
                return isDisabled() or not isAuraCustom()
            end,
            get = function()
                local en = entry()
                if en and en.harmful then return en.unit or "target" end
                return "player"
            end,
            set = function(_, val)
                local en = entry(); if not en then return end
                -- Buffs have only the "player" option; nothing to store.
                if not en.harmful then return end
                if en.unit == val then return end
                en.unit = val
                HET:RefreshTracking() -- SyncElementUnits rebuilds in the new container
                Notify()
            end,
        }
        -- (Own Only lives at the top of the Load Conditions subtab — see lcArgs.)
        group.args.customSpellBreak = {
            type = "description",
            name = "",
            order = 2.5,
            width = "full",
            hidden = function()
                local en = entry()
                return isDisabled() or not en or en.kind ~= "aura"
            end,
        }
        group.args.customSpellID = {
            type = "input",
            name = "Spell ID or Name",
            desc = isDebuff
                and "The debuff to track — type a spell ID, or start typing a name to search."
                or  "The buff to track on you — type a spell ID, or start typing a name to search.",
            -- Debuff input suggests only "Debuff"-category spells; buff input
            -- excludes them.
            dialogControl = isDebuff and "EditBoxHET_DebuffSearch" or "EditBoxHET_SpellSearch",
            order = 2.6,
            width = 1.2,
            hidden = function()
                local en = entry()
                return isDisabled() or not en or en.kind ~= "aura"
            end,
            get = function()
                local ids = P("ids")
                return (ids and ids[1] and tostring(ids[1])) or ""
            end,
            set = function(_, val)
                local n = tonumber(val)
                if not n or n <= 0 then return end
                n = math.floor(n)
                SetP("ids", { n })
                -- Single-aura trackers default their name to the aura's
                -- name (deduped with a numeric suffix) until the user
                -- names them manually. Pre-autoName entries still on the
                -- creation default opt in.
                local en = entry()
                if en and (en.autoName or en.name == "New Aura" or en.name == "New Buff") then
                    local spellName = C_Spell.GetSpellName and C_Spell.GetSpellName(n)
                    if spellName and spellName ~= "" then
                        en.name = UniqueCustomName(spellName, en)
                        en.autoName = true
                    end
                end
                HET:RefreshTracking()
                Notify()
            end,
        }
        group.args.customSpellLabel = {
            type = "description",
            order = 2.7,
            width = 1.4,
            name = function()
                local ids = P("ids")
                if ids and ids[1] then
                    return SpellIDLabel(ids[1])
                end
                return "|cffff4444Enter a spell ID to start tracking.|r"
            end,
            -- Mousing over the icon/name shows the spell's tooltip. A valid
            -- base option key (see AceConfigRegistry basekeys); rendered by
            -- the Label patcher in ns.EnsureUIHooks (description Labels do not
            -- get AceConfigDialog's native OnEnter tooltip path).
            tooltipHyperlink = function()
                local ids = P("ids")
                if ids and ids[1] then return "spell:" .. ids[1] end
            end,
            hidden = function()
                local en = entry()
                return isDisabled() or not en or en.kind ~= "aura"
            end,
        }

        -- Compound reconcile after the tracked ID or kind changes: gaining
        -- the flag (ns.CompoundInfo) auto-adds "(Active)" overlays for the
        -- existing Icon/Bar elements; losing it removes the now-inert pair
        -- entries (they'd park anyway, but dead "(Active)" tree entries
        -- would only confuse).
        local function ReconcilePairs()
            local els = GetEls(prefix)
            local changed = false
            if PairableFor(cfgKey, prefix) then
                for k in pairs(PAIRABLE_STYLES) do
                    if FindParentIndex(els, k) and not FindPairIndex(els, k)
                        and #els < MAX_ELEMENTS then
                        els[#els + 1] = MakePairElement(cfgKey, k)
                        changed = true
                    end
                end
            else
                for j = #els, 1, -1 do
                    if els[j].pairFor then
                        table.remove(els, j)
                        changed = true
                    end
                end
            end
            if changed then
                local t = ns.trackers[cfgKey]
                if t then t:RestoreAllPositions() end
            end
        end

        -- Single ID tracker Type toggle (one "Add Ability/Item" button creates
        -- these): flips the tracker between Ability and Item by rewriting
        -- entry.kind (+ idKind), which every ability/item helper keys off. The
        -- ID field, icon resolution, cooldown/charge reads, and the watcher all
        -- follow the kind live via the refresh pipeline.
        group.args.customIDType = {
            type = "select",
            name = "Type",
            desc = "Whether this tracker follows an ability (spell ID) or an item (item ID).",
            order = 1.1,  -- before Name
            width = 0.7,
            values = { ability = "Ability", item = "Item" },
            sorting = { "ability", "item" },
            hidden = function()
                return isDisabled() or not isIDSingle()
            end,
            get = function()
                return isItemSingle() and "item" or "ability"
            end,
            set = function(_, val)
                local en = entry(); if not en then return end
                if en.kind == val then return end
                en.kind = val            -- "ability" | "item"
                en.idKind = val
                -- If the name is still a creation default ("New Ability" / "New
                -- Item"), swap it to match the new type so the tree label stays
                -- sensible. A manually-set name is left untouched.
                if en.name == "New Ability" or en.name == "New Item" then
                    en.name = (val == "item") and "New Item" or "New Ability"
                end
                -- The compound flag is looked up per kind (ability vs item
                -- tables), so a kind flip can gain/lose "(Active)" overlays.
                ReconcilePairs()
                HET:RefreshTracking()
                Notify()
            end,
        }
        -- Break: pushes the ID input onto its OWN LINE below the Type / Name /
        -- Move-Preview top row.
        group.args.customIDBreak = {
            type = "description",
            name = "",
            order = 1.6,
            width = "full",
            hidden = function()
                return isDisabled() or not isIDSingle()
            end,
        }
        -- ID-kind (single ability/item): a labeled ID input storing into
        -- ids[1] (same storage as the aura Spell ID), with name→ID search
        -- scoped to the tracker's kind (SpellData_AbilitiesItems.lua). A raw
        -- numeric ID is always accepted. dialogControl is resolved statically
        -- by AceConfig, and a single tracker can flip between ability and item
        -- at runtime, so we use TWO inputs (one per kind), each hidden unless
        -- its kind is active — mirroring the ability/item group add-inputs.
        local function customIDSet(val)
            local n = tonumber(val)
            if not n or n <= 0 then return end
            n = math.floor(n)
            SetP("ids", { n })
            ReconcilePairs()
            -- Single ID kinds auto-name from the spell/item name until
            -- manually renamed (same rule as the aura Spell ID).
            local en = entry()
            if en and (en.autoName or en.name == "New Ability" or en.name == "New Item") then
                local nm
                if isItemSingle() then
                    nm = C_Item and C_Item.GetItemInfo and C_Item.GetItemInfo(n)
                else
                    nm = C_Spell.GetSpellName and C_Spell.GetSpellName(n)
                end
                if nm and nm ~= "" then
                    en.name = UniqueCustomName(nm, en)
                    en.autoName = true
                end
            end
            HET:RefreshTracking()
            Notify()
        end
        local function customIDGet()
            local ids = P("ids")
            return (ids and ids[1] and tostring(ids[1])) or ""
        end
        group.args.customAbilityInput = {
            type = "input",
            name = "Spell ID or Name",
            desc = "The ability to track — type a spell ID, or start typing a name to search.",
            dialogControl = "EditBoxHET_AbilitySearch",
            order = 1.7,
            width = 1.2,
            hidden = function()
                return isDisabled() or not isIDSingle() or isItemSingle()
            end,
            get = customIDGet,
            set = function(_, val) customIDSet(val) end,
        }
        group.args.customItemInput = {
            type = "input",
            name = "Item ID or Name",
            desc = "The item to track — type an item ID, or start typing a name to search.",
            dialogControl = "EditBoxHET_ItemSearch",
            order = 1.71,
            width = 1.2,
            hidden = function()
                return isDisabled() or not isIDSingle() or not isItemSingle()
            end,
            get = customIDGet,
            set = function(_, val) customIDSet(val) end,
        }
        group.args.customIDLabel = {
            type = "description",
            order = 1.75,  -- right after the ID input on its own line
            width = 1.4,
            name = function()
                local ids = P("ids")
                if ids and ids[1] then
                    return entryIsItem() and ItemIDLabel(ids[1]) or SpellIDLabel(ids[1])
                end
                return isItemSingle()
                    and "|cffff4444Enter an item ID to start tracking.|r"
                    or  "|cffff4444Enter a spell ID to start tracking.|r"
            end,
            tooltipHyperlink = function()
                local ids = P("ids")
                if not (ids and ids[1]) then return end
                return (entryIsItem() and "item:" or "spell:") .. ids[1]
            end,
            hidden = function()
                return isDisabled() or not isIDSingle()
            end,
        }

        -- Tracked Buffs (group kind): add/remove IDs; each ID is a checkbox
        -- row driving the trackers' filters and sound registrations.
        --
        -- MIXED ID GROUPS: an ID-kind group (abilitygroup/itemgroup) may hold
        -- BOTH abilities and items — so it gets TWO add inputs ("Add Ability ID"
        -- and "Add Item ID"), each tagging the new id's kind in the idKinds
        -- side-table. Buff/debuff groups keep their single spell-search input.
        -- Append helper: adds a numeric id to the ids array (dedup) and, when a
        -- kindTag is given, records it in the per-entry idKinds side-table so the
        -- row/runtime resolve the right ability/item path.
        local function AppendGroupID(val, kindTag)
            local n = tonumber(val)
            if not n or n <= 0 then return end
            n = math.floor(n)
            local ids = P("ids")
            if not ids then ids = {}; SetP("ids", ids) end
            for j = 1, #ids do
                if ids[j] == n then return end
            end
            ids[#ids + 1] = n
            if kindTag then
                local kinds = P("idKinds")
                if not kinds then kinds = {}; SetP("idKinds", kinds) end
                kinds[n] = kindTag
            end
            HET:RefreshTracking()
            Notify()
        end
        local alArgs = {}
        if isIDGroup() then
            -- ID groups: an ability input and an item input, each with name→ID
            -- autocomplete scoped to its own kind (SpellData_AbilitiesItems.lua).
            -- A raw numeric ID is always accepted too. Each tags the id's kind so
            -- a single group can fan out over a mix of abilities and items.
            alArgs.addAbility = {
                type = "input",
                name = "Add Ability (ID or Name)",
                desc = "Add an ability to this group — type a spell ID, or start typing a name to search.",
                dialogControl = "EditBoxHET_AbilitySearch",
                order = 1,
                width = 1.2,
                get = function() return "" end,
                set = function(_, val) AppendGroupID(val, "ability") end,
            }
            alArgs.addItem = {
                type = "input",
                name = "Add Item (ID or Name)",
                desc = "Add an item to this group — type an item ID, or start typing a name to search.",
                dialogControl = "EditBoxHET_ItemSearch",
                order = 1.5,
                width = 1.2,
                get = function() return "" end,
                set = function(_, val) AppendGroupID(val, "item") end,
            }
        else
            -- Buff/debuff group: single spell-search input (name→ID autocomplete).
            -- No idKinds tagging (these groups aren't ID kinds; buffColors is
            -- keyed by id as before). Kind is fixed for the tracker's lifetime,
            -- so resolve dialogControl statically (it is not a function).
            alArgs.addInput = {
                type = "input",
                name = "Add Spell (ID or Name)",
                desc = "Add a buff to this group — type a spell ID, or start typing a name to search.",
                dialogControl = "EditBoxHET_SpellSearch",
                order = 1,
                width = 1.2,
                get = function() return "" end,
                set = function(_, val) AppendGroupID(val, nil) end,
            }
        end
        -- Full-width empty description = line break: the buff rows always start
        -- below the Add box(es).
        alArgs.addBreak = {
            type = "description",
            name = "",
            order = 2,
            width = "full",
        }
        -- "Ordered behavior" gates the reorder arrows + per-entry colors. Aura
        -- groups toggle it via Group Type (groupOrdered); ability/item groups are
        -- ALWAYS ordered (no Unordered mode — see methods:IsOrdered), so treat
        -- them as ordered unconditionally.
        local function groupOrderedActive()
            return isIDGroup() or P("groupOrdered")
        end
        -- Column headings for the ID-kind (ability/item) list. Ordered just
        -- below the header (order 9.x, ahead of the first data row at 11) and
        -- shown only for ID groups. Widths mirror the row controls beneath so
        -- each heading sits over its column, followed by a full-width break.
        --   [arrows spacer 0.24][Ability/Item = label][Bar Color = c][When to Show = v]
        -- Headings show only for an ID group that actually has ≥1 entry (no
        -- orphan headers above an empty list).
        local function hdrShown()
            local ids = HET.db.profile[prefix .. "ids"]
            return isIDGroup() and type(ids) == "table" and ids[1] ~= nil
        end
        local hasBarCol = function() return groupOrderedActive() and HasBarTracker(prefix) end
        alArgs["hdrSpace"] = {
            type = "description", name = "", order = 9.0, width = 0.24,
            hidden = function() return not hdrShown() end,
        }
        alArgs["hdrName"] = {
            type = "description", name = "|cffffd100Ability/Item|r",
            fontSize = "medium", order = 9.1, width = 0.9,
            hidden = function() return not hdrShown() end,
        }
        alArgs["hdrColor"] = {
            type = "description", name = "|cffffd100Bar Color|r",
            fontSize = "medium", order = 9.2, width = 0.5,
            hidden = function() return not (hdrShown() and hasBarCol()) end,
        }
        alArgs["hdrShow"] = {
            type = "description", name = "|cffffd100When to Show|r",
            fontSize = "medium", order = 9.3, width = 1.15,
            hidden = function() return not hdrShown() end,
        }
        alArgs["hdrBreak"] = {
            type = "description", name = "", order = 9.4, width = "full",
            hidden = function() return not hdrShown() end,
        }
        local storedIDs = HET.db.profile[prefix .. "ids"]
        if type(storedIDs) == "table" then
            for idx = 1, #storedIDs do
                local id = storedIDs[idx]
                local o = 10 + idx
                alArgs["row" .. idx .. "u"] = {
                    type = "execute",
                    name = "",
                    desc = "Move up",
                    image = UP_TEX, imageWidth = 20, imageHeight = 20,
                    order = o,
                    width = 0.12,
                    -- Literal disabled overrides the root's combat lock
                    -- (deepest-wins); restated. Build-time eval is fine —
                    -- the combat watcher NotifyChanges on both combat edges.
                    disabled = idx == 1 or InCombatLockdown(),
                    hidden = function() return not groupOrderedActive() end,
                    func = function() MoveCustomID(prefix, idx, -1) end,
                }
                alArgs["row" .. idx .. "d"] = {
                    type = "execute",
                    name = "",
                    desc = "Move down",
                    image = DOWN_TEX, imageWidth = 20, imageHeight = 20,
                    order = o + 0.01,
                    width = 0.12,
                    disabled = idx == #storedIDs or InCombatLockdown(),
                    hidden = function() return not groupOrderedActive() end,
                    func = function() MoveCustomID(prefix, idx, 1) end,
                }
                -- Per-ROW item-ness: a mixed ID group tags each id in idKinds,
                -- so the label + tooltip follow THIS id's kind (not the group's
                -- default). Buff/debuff groups have no idKinds → falls back to
                -- false (spell labels), preserving old behavior. Resolved at
                -- build time (rows rebuild on every Notify).
                local rowIsItem = IDKindOfCfg(cfgKey, prefix, id)
                -- Label narrows for ID-kind groups to make room for the per-id
                -- Show When dropdown on the same row; other groups keep the wider
                -- label (no dropdown).
                alArgs["row" .. idx .. "t"] = {
                    type = "description",
                    name = rowIsItem and ItemIDLabel(id) or SpellIDLabel(id),
                    fontSize = "medium",
                    order = o + 0.02,
                    width = isIDGroup() and 0.9 or 1.4,
                    -- Hover shows this row's spell/item tooltip (see the Label
                    -- patcher in ns.EnsureUIHooks).
                    tooltipHyperlink = (rowIsItem and "item:" or "spell:") .. id,
                }
                alArgs["row" .. idx .. "c"] = {
                    type = "color",
                    name = "",
                    desc = "Bar color for this buff. Applies to Bars trackers while the Group Type is Ordered.",
                    order = o + 0.025,
                    -- Wider than the swatch needs so the "Bar Color" heading
                    -- above it aligns; the extra is just trailing pad.
                    width = 0.5,
                    hasAlpha = true,
                    hidden = function()
                        return not (groupOrderedActive() and HasBarTracker(prefix))
                    end,
                    get = function()
                        local bc = P("buffColors")
                        local v = bc and bc[id]
                        if not v then
                            -- Unset: show the bar tracker's own color.
                            local els = GetEls(prefix)
                            for j = 1, #els do
                                if els[j].style == "bar" then
                                    v = els[j].color
                                    break
                                end
                            end
                        end
                        v = v or { 1, 1, 1, 1 }
                        return v[1], v[2], v[3], v[4]
                    end,
                    set = function(_, cr, cg, cb, ca)
                        local bc = P("buffColors")
                        if not bc then bc = {}; SetP("buffColors", bc) end
                        bc[id] = { cr, cg, cb, ca }
                        RefreshTrackingThrottled()
                    end,
                }
                -- Per-id "Show When": overrides nothing at the element level —
                -- for ID-kind groups the visibility gate is PER ENTRY, stored in
                -- the idVisibilities side-table keyed by id (mirrors buffColors).
                -- Unset ⇒ "always". Only shown for ID-kind groups (the element
                -- dropdown is hidden for them; see idVisibility.hidden).
                alArgs["row" .. idx .. "v"] = {
                    type = "select",
                    name = "",
                    desc = "When this ability/item is shown: Always, Only While On Cooldown, or Only When Usable.",
                    order = o + 0.028,
                    width = 1.15,
                    values = ID_VISIBILITY_VALUES,
                    sorting = ID_VISIBILITY_SORTING,
                    hidden = function() return not isIDGroup() end,
                    get = function()
                        local v = P("idVisibilities")
                        return (v and v[id]) or "always"
                    end,
                    set = function(_, val)
                        local v = P("idVisibilities")
                        if not v then v = {}; SetP("idVisibilities", v) end
                        v[id] = val
                        HET:RefreshTracking()
                    end,
                }
                alArgs["row" .. idx .. "x"] = {
                    type = "execute",
                    name = "",
                    desc = "Remove",
                    image = X_TEXTURE,
                    imageWidth = 16, imageHeight = 16,
                    order = o + 0.03,
                    width = 0.12,
                    func = function()
                        local ids = P("ids") or {}
                        for j = #ids, 1, -1 do
                            if ids[j] == id then table.remove(ids, j) end
                        end
                        local bc = P("buffColors")
                        if bc then bc[id] = nil end
                        -- Drop this id's per-entry visibility + kind tag too, so a
                        -- later id reusing the number doesn't inherit stale state.
                        local vis = P("idVisibilities")
                        if vis then vis[id] = nil end
                        local kinds = P("idKinds")
                        if kinds then kinds[id] = nil end
                        HET:RefreshTracking()
                        Notify()
                    end,
                }
                -- Full-width break keeps the list strictly one row per
                -- buff.
                alArgs["row" .. idx .. "b"] = {
                    type = "description",
                    name = "",
                    order = o + 0.04,
                    width = "full",
                }
            end
        end
        group.args.auraList = {
            type = "group",
            -- Relabel per kind: buff/debuff groups → "Tracked Buffs"; ID-kind
            -- groups (mixed ability+item) → "Tracked Abilities / Items".
            name = function()
                if isIDGroup() then return "Tracked Abilities / Items" end
                return "Tracked Buffs"
            end,
            order = 101,
            -- Shown for every group kind (buff/debuff "group" AND the ID-kind
            -- groups abilitygroup/itemgroup).
            hidden = function()
                local en = entry()
                if isDisabled() or not en then return true end
                return not (en.kind == "group"
                    or en.kind == "abilitygroup" or en.kind == "itemgroup")
            end,
            args = alArgs,
        }
    end

    -- Multi-spell groups: a Tracked Buffs tab, one checkbox row per
    -- spell (always vertical) with up/down reorder arrows. Checked
    -- spells drive the trackers' aura filters and the per-spell sound
    -- registrations.
    local spellList = ns.trackerConfigs[cfgKey].spellList
    if spellList then
        local function isOrdered()
            -- Built-in spellList groups are always Unordered.
            return false
        end
        local spellArgs = {}
        local display = OrderedSpellList(cfgKey, prefix)
        for idx, sp in ipairs(display) do
            local o = 10 + idx
            local key = sp.key
            local tex = C_Spell.GetSpellTexture(sp.ids[1])
            local label = (tex and ("|T" .. tex .. ":16|t ") or "") .. sp.name
            spellArgs["up" .. idx] = {
                type = "execute",
                name = "",
                desc = "Move up",
                image = UP_TEX, imageWidth = 20, imageHeight = 20,
                order = o,
                width = 0.12,
                -- Restates the root combat lock (deepest-wins inheritance).
                disabled = idx == 1 or InCombatLockdown(),
                hidden = function() return not isOrdered() end,
                func = function() MoveOrderedKey(cfgKey, prefix, idx, -1) end,
            }
            spellArgs["down" .. idx] = {
                type = "execute",
                name = "",
                desc = "Move down",
                image = DOWN_TEX, imageWidth = 20, imageHeight = 20,
                order = o + 0.01,
                width = 0.12,
                disabled = idx == #display or InCombatLockdown(),
                hidden = function() return not isOrdered() end,
                func = function() MoveOrderedKey(cfgKey, prefix, idx, 1) end,
            }
            spellArgs["sp_" .. key] = {
                type = "toggle",
                name = label,
                order = o + 0.02,
                width = 1.6,
                get = function()
                    local sel = P("spells")
                    return sel and sel[key]
                end,
                set = function(_, val)
                    local sel = P("spells")
                    if not sel then
                        sel = {}
                        SetP("spells", sel)
                    end
                    sel[key] = val
                    HET:RefreshTracking()
                end,
            }
            -- Full-width break keeps the list strictly one row per buff.
            spellArgs["brk" .. idx] = {
                type = "description",
                name = "",
                order = o + 0.03,
                width = "full",
            }
        end
        group.args.spells = {
            type = "group",
            name = "Tracked Buffs",
            order = 101,
            hidden = isDisabled,
            args = spellArgs,
        }
    end

    -- Built-in trackers (non-custom): they're always player buffs, so show a
    -- read-only "Player (Self)" Unit field to match custom trackers,
    -- and — for single-aura built-ins — a "Spell ID: X" label + spell icon in
    -- place of the (custom-only) Spell ID input.
    if not (ns.trackerConfigs[cfgKey].custom) then
        local biCfg = ns.trackerConfigs[cfgKey]
        group.args.builtinUnit = {
            type = "select",
            name = "Unit",
            desc = "This tracker watches buffs on you.",
            order = 1.3,
            width = 0.9,
            values = { player = "Player (Self)" },
            sorting = { "player" },
            hidden = isDisabled,
            get = function() return "player" end,
            set = function() end,  -- single fixed option; nothing to store
        }
        -- Single-aura built-ins have a displaySpell; groups (spellList) don't.
        if not biCfg.spellList and biCfg.displaySpell then
            group.args.builtinSpellBreak = {
                type = "description",
                name = "",
                order = 2.5,
                width = "full",
                hidden = isDisabled,
            }
            group.args.builtinSpellID = {
                type = "description",
                order = 2.6,
                width = 1.4,
                name = function()
                    local id = biCfg.displaySpell
                    return ("Spell ID: %d   %s"):format(id, SpellIDLabel(id))
                end,
                tooltipHyperlink = function()
                    return "spell:" .. biCfg.displaySpell
                end,
                hidden = isDisabled,
            }
        end
    end

    -- Load Conditions subtab (after Trackers / Tracked Buffs). With no
    -- toggle checked the tracker loads always, as before. Combat Status
    -- gates the visual + sound; Instance Type gates the slot.
    -- Own Only (aura-kind customs only) sits at the top of Load Conditions:
    -- only track the aura when YOU applied it. Debuffs default ON, buffs OFF.
    local function lcCustomEntry() return CustomEntryFor(cfgKey) end
    local lcIsCustomKind = IsCustomKindCfg(cfgKey)
    local lcArgs = {
        -- Header note explaining the no-conditions default. Custom-"Other"
        -- trackers show ALWAYS with no conditions; aura trackers show while the
        -- aura is present on the unit.
        intro = {
            type = "description",
            -- AceConfigDialog sorts NEGATIVE orders to the BOTTOM
            -- (compareOptions), so pin to the top with a small POSITIVE order
            -- below every other arg here (ownOnly bumped to 0.5).
            order = 0.1,
            width = "full",
            fontSize = "small",
            name = lcIsCustomKind
                and "If no conditions are selected, configured trackers will show Always."
                or  "If no conditions are selected, configured trackers will show whenever the Aura is present on the Unit.",
        },
        ownOnly = {
            type = "toggle",
            name = "Own Only",
            desc = function()
                local en = lcCustomEntry()
                local isGroup = en and en.kind == "group"
                if en and en.harmful then
                    return isGroup
                        and "Only track these debuffs when YOU applied them."
                        or  "Only track this debuff when YOU applied it."
                end
                return isGroup
                    and "Only track these buffs when YOU applied them."
                    or  "Only track this buff when YOU applied it."
            end,
            order = 0.5,  -- below the intro note (0.1), above Combat (1).
            width = "full",
            -- Single-aura AND group customs (not custom-"Other"). For groups
            -- the filter applies to every aura in the group.
            hidden = function()
                local en = lcCustomEntry()
                return isDisabled()
                    or not (en and (en.kind == "aura" or en.kind == "group"))
            end,
            get = function()
                local en = lcCustomEntry()
                if not en then return false end
                -- Debuffs default ON (nil = checked); buffs default OFF.
                if en.harmful then return en.ownOnly ~= false end
                return en.ownOnly == true
            end,
            set = function(_, val)
                local en = lcCustomEntry(); if not en then return end
                en.ownOnly = val and true or false
                HET:RefreshTracking() -- SyncElementFilters pushes the new filter live
                Notify()
            end,
        },
        combatToggle = {
            type = "toggle",
            name = "Combat Status",
            -- Sound Alerts are registered engine-side (C_UnitAuras.AddAuraSound)
            -- and the registration can only be created/destroyed OUT of combat,
            -- so the combat edge can't arm or disarm them. Called out here
            -- rather than silently surprising the user.
            desc = "Only load this tracker based on your combat state."
                .. "\n\n|cffff8080This setting does not apply to Sound Alerts.|r",
            order = 1,
            width = 0.9,
            get = function() return P("loadCombat") end,
            set = function(_, val)
                SetP("loadCombat", val or nil)
                HET:RefreshTracking()
            end,
        },
        combatMode = {
            type = "select",
            name = "Combat State",
            desc = "In Combat Only loads the tracker while you are in combat; Out of Combat Only loads it while you are not.",
            order = 2,
            width = 1.2,
            values = { ["in"] = "In Combat Only", out = "Out of Combat Only" },
            sorting = { "in", "out" },
            hidden = function() return not P("loadCombat") end,
            get = function() return P("loadCombatMode") or "in" end,
            set = function(_, val)
                SetP("loadCombatMode", val)
                HET:RefreshTracking()
            end,
        },
        instanceToggle = {
            type = "toggle",
            name = "Instance Type",
            desc = "Only load this tracker in the checked instance types.",
            order = 10,
            width = "full",
            get = function() return P("loadInstance") end,
            set = function(_, val)
                SetP("loadInstance", val or nil)
                HET:RefreshTracking()
            end,
        },
        roleToggle = {
            type = "toggle",
            name = "Role",
            desc = "Only load this tracker while your current role is checked below. Combines with Spec by OR: if Spec is also enabled, the tracker loads when EITHER your role OR your spec is checked.",
            order = 12,
            width = "full",
            get = function() return P("loadRole") end,
            set = function(_, val)
                SetP("loadRole", val or nil)
                HET:RefreshTracking()
            end,
        },
        specToggle = {
            type = "toggle",
            name = "Spec",
            desc = "Only load this tracker while your current specialization is checked below. Combines with Role by OR: if Role is also enabled, the tracker loads when EITHER your role OR your spec is checked.",
            order = 14,
            width = "full",
            get = function() return P("loadSpec") end,
            set = function(_, val)
                SetP("loadSpec", val or nil)
                HET:RefreshTracking()
            end,
        },
        spellToggle = {
            type = "toggle",
            name = "Spell Usable/Unusable",
            desc = "Only show this tracker while specific spells are usable (usable and off cooldown) — or, per row, while they are UNusable.",
            order = 20,
            width = "full",
            get = function() return P("loadSpell") end,
            set = function(_, val)
                SetP("loadSpell", val or nil)
                -- Turning it ON seeds the first empty spell row so the user
                -- lands straight on an input (mirrors "+ Add Spell Usable
                -- Condition"). Only when the list is empty, so re-toggling
                -- never stacks blank rows.
                if val then
                    local t = P("loadSpellIDs")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadSpellIDs", t)
                    end
                    if #t == 0 then t[1] = 0 end
                end
                HET:RefreshTracking()
                Notify()
            end,
        },
        itemToggle = {
            type = "toggle",
            name = "Item Usable/Unusable",
            desc = "Only show this tracker while specific items are usable (usable and off cooldown), e.g. a trinket or potion — or, per row, while they are UNusable.",
            order = 22,
            width = "full",
            get = function() return P("loadItem") end,
            set = function(_, val)
                SetP("loadItem", val or nil)
                -- Seed the first empty item row on enable (same as Spell Usable).
                if val then
                    local t = P("loadItemIDs")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadItemIDs", t)
                    end
                    if #t == 0 then t[1] = 0 end
                end
                HET:RefreshTracking()
                Notify()
            end,
        },
    }
    local itArgs = {}
    for i = 1, #INSTANCE_TYPES do
        local key = INSTANCE_TYPES[i].key
        itArgs["it_" .. key] = {
            type = "toggle",
            name = INSTANCE_TYPES[i].label,
            order = i,
            width = 0.9,
            -- Unset table / unset entry = checked (all six default on).
            get = function()
                local t = P("loadInstanceTypes")
                return t == nil or t[key] ~= false
            end,
            set = function(_, val)
                local t = P("loadInstanceTypes")
                if not t then
                    -- Materialize with the all-checked default first, then
                    -- apply this change, so unticking one box doesn't also
                    -- silently clear the other five.
                    t = {}
                    for j = 1, #INSTANCE_TYPES do t[INSTANCE_TYPES[j].key] = true end
                    SetP("loadInstanceTypes", t)
                end
                t[key] = val and true or false
                HET:RefreshTracking()
            end,
        }
    end
    lcArgs.instanceGroup = {
        type = "group",
        name = "Instance Types",
        inline = true,
        order = 11,
        hidden = function() return not P("loadInstance") end,
        args = itArgs,
    }

    -- Role checkboxes. Same "unset table / unset entry = checked" default as
    -- Instance Types, so all three roles start on without seeding defaults.
    local roArgs = {}
    for i = 1, #ROLE_TYPES do
        local key   = ROLE_TYPES[i].key
        local icon  = (CreateAtlasMarkup and CreateAtlasMarkup(ROLE_TYPES[i].atlas, 16, 16)) or ""
        roArgs["ro_" .. key] = {
            type = "toggle",
            name = (icon ~= "" and (icon .. " ") or "") .. ROLE_TYPES[i].label,
            order = i,
            width = 0.9,
            get = function()
                local t = P("loadRoleTypes")
                return t == nil or t[key] ~= false
            end,
            set = function(_, val)
                local t = P("loadRoleTypes")
                if not t then
                    t = {}
                    for j = 1, #ROLE_TYPES do t[ROLE_TYPES[j].key] = true end
                    SetP("loadRoleTypes", t)
                end
                t[key] = val and true or false
                HET:RefreshTracking()
            end,
        }
    end
    lcArgs.roleGroup = {
        type = "group",
        name = "Roles",
        inline = true,
        order = 13,
        hidden = function() return not P("loadRole") end,
        args = roArgs,
    }

    -- Spec checkboxes, grouped by class (class header rows) then spec name.
    -- Same "unset = checked" default so every spec starts on. A Deselect
    -- All / Select All toggle sits at the top.
    local spcArgs = {}
    spcArgs.toggleAll = {
        type = "execute",
        order = 0,
        width = "normal",
        -- Label reflects the action: if every spec is currently checked,
        -- the button deselects all; otherwise it selects all.
        name = function()
            local t = P("loadSpecTypes")
            if t == nil then return "Deselect All" end
            for i = 1, #SPEC_TYPES do
                if t[SPEC_TYPES[i].id] == false then return "Select All" end
            end
            return "Deselect All"
        end,
        func = function()
            local t = P("loadSpecTypes")
            local allChecked = true
            if t ~= nil then
                for i = 1, #SPEC_TYPES do
                    if t[SPEC_TYPES[i].id] == false then allChecked = false; break end
                end
            end
            -- Checked -> set all false; otherwise -> all true.
            local target = not allChecked
            t = {}
            for i = 1, #SPEC_TYPES do t[SPEC_TYPES[i].id] = target end
            SetP("loadSpecTypes", t)
            HET:RefreshTracking()
            Notify()
        end,
    }
    -- Full-width spacer keeps the normal-width button alone on its row.
    spcArgs.toggleAllBreak = {
        type = "description",
        name = "",
        order = 0.5,
        width = "full",
    }
    do
        local lastClass
        for i = 1, #SPEC_TYPES do
            local s = SPEC_TYPES[i]
            local id = s.id
            -- Class header row: emitted once when the class changes. Orders
            -- are (i * 10) so a header can sit just before its first spec.
            -- The header is a toggle that checks/unchecks every spec in its
            -- class at once; its own checked state is "all specs of this
            -- class checked" (matching the "unset entry = checked" default).
            if s.class ~= lastClass then
                lastClass = s.class
                local class = s.class
                local cname = (LOCALIZED_CLASS_NAMES_MALE and LOCALIZED_CLASS_NAMES_MALE[class])
                    or class
                local ccol = (RAID_CLASS_COLORS and RAID_CLASS_COLORS[class])
                local hdr  = ccol and ("|c" .. ccol.colorStr .. cname .. "|r") or cname
                -- Spec IDs belonging to this class (captured once per header).
                local classIDs = {}
                for j = 1, #SPEC_TYPES do
                    if SPEC_TYPES[j].class == class then
                        classIDs[#classIDs + 1] = SPEC_TYPES[j].id
                    end
                end
                spcArgs["hdr_" .. class] = {
                    type = "toggle",
                    name = hdr,
                    order = i * 10 - 1,
                    width = "full",
                    -- Checked only when EVERY spec in the class is checked.
                    get = function()
                        local t = P("loadSpecTypes")
                        if t == nil then return true end
                        for k = 1, #classIDs do
                            if t[classIDs[k]] == false then return false end
                        end
                        return true
                    end,
                    set = function(_, val)
                        local t = P("loadSpecTypes")
                        if not t then
                            t = {}
                            for j = 1, #SPEC_TYPES do t[SPEC_TYPES[j].id] = true end
                            SetP("loadSpecTypes", t)
                        end
                        local on = val and true or false
                        for k = 1, #classIDs do t[classIDs[k]] = on end
                        HET:RefreshTracking()
                        Notify()
                    end,
                }
            end
            local scol = (RAID_CLASS_COLORS and RAID_CLASS_COLORS[s.class])
            local sname = scol and ("|c" .. scol.colorStr .. s.name .. "|r") or s.name
            spcArgs["sp_" .. id] = {
                type = "toggle",
                name = "|T" .. s.icon .. ":14|t " .. sname,
                order = i * 10,
                width = 1.1,
                get = function()
                    local t = P("loadSpecTypes")
                    return t == nil or t[id] ~= false
                end,
                set = function(_, val)
                    local t = P("loadSpecTypes")
                    if not t then
                        t = {}
                        for j = 1, #SPEC_TYPES do t[SPEC_TYPES[j].id] = true end
                        SetP("loadSpecTypes", t)
                    end
                    t[id] = val and true or false
                    HET:RefreshTracking()
                    Notify()
                end,
            }
        end
    end
    lcArgs.specGroup = {
        type = "group",
        name = "Specs",
        inline = true,
        order = 15,
        hidden = function() return not P("loadSpec") end,
        args = spcArgs,
    }

    -- Spell Usable rows: generated from the stored list (the options
    -- generator re-runs on every NotifyChange, so dynamic keys are safe —
    -- same pattern as the custom aura ID list).
    local spArgs = {}
    local storedSpells = HET.db.profile[prefix .. "loadSpellIDs"]
    if type(storedSpells) == "table" then
        for idx = 1, #storedSpells do
            local o = 10 + idx
            -- Direction dropdown, sits to the LEFT of the input. Stored in
            -- loadSpellUsable (array parallel to loadSpellIDs); only an
            -- explicit false means "Unusable", so absent/nil = Usable (the
            -- pre-existing behaviour, so old profiles need no migration).
            spArgs["sp" .. idx .. "d"] = {
                type = "select",
                name = "",
                desc = "Usable: this spell must be usable and off cooldown. Unusable: it must NOT be (on cooldown, or not castable).",
                order = o - 0.005,
                width = 0.7,
                values = { usable = "Usable", unusable = "Unusable" },
                sorting = { "usable", "unusable" },
                get = function()
                    local t = P("loadSpellUsable")
                    return (t and t[idx] == false) and "unusable" or "usable"
                end,
                set = function(_, val)
                    local t = P("loadSpellUsable")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadSpellUsable", t)
                    end
                    t[idx] = (val ~= "unusable")
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            spArgs["sp" .. idx] = {
                type = "input",
                name = "Spell " .. idx,
                desc = function()
                    local t = P("loadSpellIDs")
                    local id = t and tonumber(t[idx])
                    local name = id and id > 0 and C_Spell
                        and C_Spell.GetSpellName and C_Spell.GetSpellName(id)
                    return name and ("Currently: " .. name)
                        or "The spell this condition checks — type a spell ID, or start typing a name to search."
                end,
                -- Name search over the curated ability list (same predictor the
                -- Ability tracker input uses). Suggestions are keyed by ID, so
                -- picking one writes the ID; a raw numeric ID is still accepted
                -- for anything unlisted.
                dialogControl = "EditBoxHET_AbilitySearch",
                order = o,
                width = 1.2,
                get = function()
                    local t = P("loadSpellIDs")
                    local id = t and tonumber(t[idx])
                    return (id and id > 0) and tostring(id) or ""
                end,
                set = function(_, val)
                    local t = P("loadSpellIDs")
                    if type(t) ~= "table" then return end
                    -- CLICKING a predictor suggestion fires OnEnterPressed with
                    -- the suggestion KEY, and for these ID-keyed lists that is a
                    -- NUMBER (Search-EditBox-Widget PredictorButton_OnClick ->
                    -- ValidateValue returns `key`). Typing an ID by hand passes
                    -- a string, which is why only the click path errored.
                    -- Coerce before touching any string method.
                    val = tostring(val or ""):gsub("^%s+", ""):gsub("%s+$", "")
                    if val == "" then
                        t[idx] = 0
                    else
                        -- A half-typed name that matched no suggestion must not
                        -- wipe an already-good row, so unresolvable text is
                        -- ignored rather than coerced to 0.
                        local n = tonumber(val)
                        if not n then return end
                        t[idx] = n
                    end
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            -- Resolved spell icon + name, shown inline once the ID resolves.
            spArgs["sp" .. idx .. "l"] = {
                type = "description",
                order = o + 0.005,
                width = 1.4,
                name = function()
                    local t = P("loadSpellIDs")
                    local id = t and tonumber(t[idx])
                    if id and id > 0 then return SpellIDLabel(id) end
                    return ""
                end,
                -- Hover the icon/name for the spell tooltip (Label patcher).
                tooltipHyperlink = function()
                    local t = P("loadSpellIDs")
                    local id = t and tonumber(t[idx])
                    if id and id > 0 then return "spell:" .. id end
                end,
            }
            spArgs["sp" .. idx .. "x"] = {
                type = "execute",
                name = "",
                desc = "Remove",
                image = X_TEXTURE,
                imageWidth = 16, imageHeight = 16,
                order = o + 0.01,
                width = 0.12,
                func = function()
                    local t = P("loadSpellIDs")
                    if type(t) == "table" then table.remove(t, idx) end
                    -- Keep the parallel Usable/Unusable array aligned.
                    local u = P("loadSpellUsable")
                    if type(u) == "table" then table.remove(u, idx) end
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            -- Full-width break keeps the list strictly one row per spell.
            spArgs["sp" .. idx .. "b"] = {
                type = "description",
                name = "",
                order = o + 0.02,
                width = "full",
            }
        end
    end
    spArgs.addSpell = {
        type = "execute",
        name = "+ Add Spell Usable Condition",
        desc = "Add another spell whose usable/unusable state gates this tracker.",
        order = 50,
        width = 1.5,
        func = function()
            local t = P("loadSpellIDs")
            if type(t) ~= "table" then
                t = {}
                SetP("loadSpellIDs", t)
            end
            t[#t + 1] = 0
            Notify()
        end,
    }
    spArgs.spellMode = {
        type = "select",
        name = "Mode",
        desc = "All: every listed spell must match its Usable/Unusable setting. Any: at least one must match.",
        order = 51,
        width = 0.7,
        values = { any = "Any", all = "All" },
        sorting = { "any", "all" },
        hidden = function()
            local t = P("loadSpellIDs")
            return type(t) ~= "table" or #t < 2
        end,
        get = function() return P("loadSpellMode") or "all" end,
        set = function(_, val)
            SetP("loadSpellMode", val)
            HET:RefreshTracking()
        end,
    }
    lcArgs.spellGroup = {
        type = "group",
        name = "Spell Usable/Unusable",
        inline = true,
        order = 21,
        hidden = function() return not P("loadSpell") end,
        args = spArgs,
    }

    -- Item Usable rows: exact parallel of the Spell Usable list above, keyed
    -- off loadItemIDs / loadItemMode. ns.itemReady drives the runtime gate.
    local itemArgs = {}
    local storedItems = HET.db.profile[prefix .. "loadItemIDs"]
    if type(storedItems) == "table" then
        for idx = 1, #storedItems do
            local o = 10 + idx
            -- Direction dropdown to the LEFT of the input; see the spell row.
            -- loadItemUsable is parallel to loadItemIDs, absent/nil = Usable.
            itemArgs["item" .. idx .. "d"] = {
                type = "select",
                name = "",
                desc = "Usable: this item must be usable and off cooldown. Unusable: it must NOT be (on cooldown, unequipped, or otherwise unusable).",
                order = o - 0.005,
                width = 0.7,
                values = { usable = "Usable", unusable = "Unusable" },
                sorting = { "usable", "unusable" },
                get = function()
                    local t = P("loadItemUsable")
                    return (t and t[idx] == false) and "unusable" or "usable"
                end,
                set = function(_, val)
                    local t = P("loadItemUsable")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadItemUsable", t)
                    end
                    t[idx] = (val ~= "unusable")
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            itemArgs["item" .. idx] = {
                type = "input",
                name = "Item " .. idx,
                desc = function()
                    local t = P("loadItemIDs")
                    local id = t and tonumber(t[idx])
                    -- GetItemInfo returns the name as its first value (nil for
                    -- an uncached item — the generic hint shows until cached).
                    local name = id and id > 0 and C_Item
                        and C_Item.GetItemInfo and C_Item.GetItemInfo(id)
                    return name and ("Currently: " .. name)
                        or "The item this condition checks — type an item ID, or start typing a name to search."
                end,
                -- Name search over the curated item list (same predictor the
                -- Item tracker input uses); see the spell row above.
                dialogControl = "EditBoxHET_ItemSearch",
                order = o,
                width = 1.2,
                get = function()
                    local t = P("loadItemIDs")
                    local id = t and tonumber(t[idx])
                    return (id and id > 0) and tostring(id) or ""
                end,
                set = function(_, val)
                    local t = P("loadItemIDs")
                    if type(t) ~= "table" then return end
                    -- Number-vs-string on the predictor click path; see the
                    -- Spell row above.
                    val = tostring(val or ""):gsub("^%s+", ""):gsub("%s+$", "")
                    if val == "" then
                        t[idx] = 0
                    else
                        local n = tonumber(val)
                        if not n then return end
                        t[idx] = n
                    end
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            -- Resolved item icon + name, shown inline once the ID resolves
            -- (blank until the client has the item cached).
            itemArgs["item" .. idx .. "l"] = {
                type = "description",
                order = o + 0.005,
                -- Narrower than the spell row's label: this row also carries
                -- the Require Equipped toggle, and the new direction dropdown
                -- has to fit alongside both without wrapping.
                width = 0.8,
                name = function()
                    local t = P("loadItemIDs")
                    local id = t and tonumber(t[idx])
                    if id and id > 0 then return ItemIDLabel(id) end
                    return ""
                end,
                tooltipHyperlink = function()
                    local t = P("loadItemIDs")
                    local id = t and tonumber(t[idx])
                    if id and id > 0 then return "item:" .. id end
                end,
            }
            -- Require Equipped (ON by default): the item must be equipped to
            -- count. Stored in loadItemEquipped (array parallel to loadItemIDs);
            -- only an explicit false disables it, so absent = required.
            itemArgs["item" .. idx .. "e"] = {
                type = "toggle",
                name = "Require Equipped",
                desc = "Only count this item as ready while it is equipped (e.g. a trinket in a trinket slot). On by default.",
                order = o + 0.008,
                width = 1.0,
                -- Only meaningful for equippable items (trinkets, weapons…);
                -- hidden for consumables like potions, which can't be equipped.
                hidden = function()
                    local t = P("loadItemIDs")
                    return not IsEquippableItem(t and tonumber(t[idx]))
                end,
                get = function()
                    local t = P("loadItemEquipped")
                    return not (t and t[idx] == false)
                end,
                set = function(_, val)
                    local t = P("loadItemEquipped")
                    if type(t) ~= "table" then
                        t = {}
                        SetP("loadItemEquipped", t)
                    end
                    t[idx] = val and true or false
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            itemArgs["item" .. idx .. "x"] = {
                type = "execute",
                name = "",
                desc = "Remove",
                image = X_TEXTURE,
                imageWidth = 16, imageHeight = 16,
                order = o + 0.01,
                width = 0.12,
                func = function()
                    local t = P("loadItemIDs")
                    if type(t) == "table" then table.remove(t, idx) end
                    -- Keep the parallel Require-Equipped / direction arrays aligned.
                    local eq = P("loadItemEquipped")
                    if type(eq) == "table" then table.remove(eq, idx) end
                    local u = P("loadItemUsable")
                    if type(u) == "table" then table.remove(u, idx) end
                    HET:RefreshTracking()
                    Notify()
                end,
            }
            itemArgs["item" .. idx .. "b"] = {
                type = "description",
                name = "",
                order = o + 0.02,
                width = "full",
            }
        end
    end
    itemArgs.addItem = {
        type = "execute",
        name = "+ Add Item Usable Condition",
        desc = "Add another item whose usable/unusable state gates this tracker.",
        order = 50,
        width = 1.5,
        func = function()
            local t = P("loadItemIDs")
            if type(t) ~= "table" then
                t = {}
                SetP("loadItemIDs", t)
            end
            t[#t + 1] = 0
            Notify()
        end,
    }
    itemArgs.itemMode = {
        type = "select",
        name = "Mode",
        desc = "All: every listed item must match its Usable/Unusable setting. Any: at least one must match.",
        order = 51,
        width = 0.7,
        values = { any = "Any", all = "All" },
        sorting = { "any", "all" },
        hidden = function()
            local t = P("loadItemIDs")
            return type(t) ~= "table" or #t < 2
        end,
        get = function() return P("loadItemMode") or "all" end,
        set = function(_, val)
            SetP("loadItemMode", val)
            HET:RefreshTracking()
        end,
    }
    lcArgs.itemGroup = {
        type = "group",
        name = "Item Usable/Unusable",
        inline = true,
        order = 23,
        hidden = function() return not P("loadItem") end,
        args = itemArgs,
    }
    group.args.loadConditions = {
        type = "group",
        name = "Conditions",
        order = 102,
        hidden = isDisabled,
        args = lcArgs,
    }

    return group
end

-------------------------------------------------------------------------------
-- Options generator — AceConfig re-invokes this on every NotifyChange,
-- which is what lets the element subtabs come and go dynamically.
-------------------------------------------------------------------------------
-- "Add Aura" / "Add Aura Group": create a customTrackers entry using
-- the smallest unused id, register its config, and jump to the new
-- node. The tracker object itself is created by the refresh pipeline —
-- immediately when unrestricted, or on leaving combat otherwise.
local function AddCustom(kind, harmful)
    local p = HET.db.profile
    if type(p.customTrackers) ~= "table" then
        p.customTrackers = {}
    end
    local list = p.customTrackers
    local used = {}
    for j = 1, #list do
        used[list[j].id] = true
    end
    local id = 1
    while used[id] do id = id + 1 end
    -- Ability/Item kinds (no aura): a single spell/item ID drives an icon with
    -- an optional Cooldown swipe + Charges/Stacks text. `idKind` distinguishes
    -- ability (Spell ID) from item (Item ID); groups fan out over an ID list.
    -- kind is one of: "aura","group","custom","ability","abilitygroup",
    -- "item","itemgroup".
    local isAbility = (kind == "ability" or kind == "abilitygroup")
    local isItem    = (kind == "item" or kind == "itemgroup")
    local defName
    if kind == "custom" then
        defName = "New Custom"
    elseif kind == "ability" then
        defName = "New Ability"
    elseif kind == "abilitygroup" then
        defName = "New Ability/Item Group"
    elseif kind == "item" then
        defName = "New Item"
    elseif kind == "itemgroup" then
        defName = "New Item Group"
    elseif harmful then
        defName = (kind == "group") and "New Debuff Group" or "New Debuff"
    else
        defName = (kind == "group") and "New Buff Group" or "New Buff"
    end
    list[#list + 1] = {
        id = id,
        kind = kind,
        name = defName,
        -- Single-aura trackers auto-name from their spell until manually
        -- renamed. Ability/Item single kinds auto-name from their spell/item
        -- name too. Group/custom kinds never auto-name.
        autoName = (kind == "aura" or kind == "ability" or kind == "item"),
        -- ability/item id kind marker (read by IsAbilityKindCfg / IsItemKindCfg).
        idKind = (isAbility and "ability") or (isItem and "item") or nil,
        -- Debuff trackers: HARMFUL on target/focus, "Own Only" (HARMFUL|
        -- PLAYER) on by default. Buffs, custom, ability, and item kinds omit
        -- these (ability/item have no Unit — always the player).
        harmful = harmful or nil,
        unit    = harmful and "target" or nil,
        ownOnly = harmful and true or nil,
    }
    local pref = "cu" .. id .. "_"
    p[pref .. "ids"] = {}
    if type(p[pref .. "elements"]) ~= "table" then
        p[pref .. "elements"] = {}
    end
    p[pref .. "enabled"] = true
    -- Ability/Item groups are ALWAYS ordered (no Unordered mode — see
    -- methods:IsOrdered), so no groupOrdered flag is stored for them: the icons
    -- follow the list order and the Show When gate collapses gaps per-icon.
    ns.RegisterCustomConfig(id)
    -- Creates the tracker via the pipeline when unrestricted.
    HET:RefreshTracking()
    Notify()
    local cfgKey = "custom" .. id
    if not ns.trackers[cfgKey] then
        -- Mid-combat: the refresh pipeline builds it once the
        -- restriction lifts.
        print("|cff11ace9HET|r Tracker will activate when you leave combat.")
    end
    -- Show the brand-new node's options. Selecting a freshly-added node in
    -- place — especially while already on its subtab — doesn't reliably
    -- re-feed the content pane under the Buffs/Debuffs split (a manual click
    -- does, which is why navigating away and back worked). Set the selection,
    -- then re-feed the EXISTING frame with :Open (exactly what ACD's own
    -- refresh does on the next frame — no close, so the panel stays up).
    ACD:SelectGroup(addonName, SpellTab(cfgKey), SpellSection(cfgKey), cfgKey)
    if ACD.OpenFrames and ACD.OpenFrames[addonName] then
        ACD:Open(addonName)
    end
end

local function BuildOptions()
    -- Spells are grouped under Enabled/Disabled section nodes in the
    -- left tree; the generator re-sorts them on every NotifyChange, and
    -- the enable toggle re-selects the moved node. Custom trackers mix
    -- in alphabetically by their user-given name.
    -- Enabled/Disabled args split per subtab: built-ins + buff customs feed
    -- the Buffs tab; debuff customs feed the Debuffs tab.
    local buffEnabled, buffDisabled = {}, {}
    local debuffEnabled, debuffDisabled = {}, {}
    -- Custom-kind trackers (no aura dependency) feed their own third subtab.
    local customEnabled, customDisabled = {}, {}
    -- ID-kind trackers (ability/item, single or group) feed ONE merged
    -- "Abilities/Items" subtab (keyed "idkinds").
    local idEnabled, idDisabled = {}, {}
    -- First enabled (and, as a fallback, first disabled) tracker node per
    -- subtab, recorded in tree-sort order below; hetOpen reads these to seed
    -- each subtab's default selection.
    ns._firstEnabledNode  = { buffs = nil, debuffs = nil, custom = nil, idkinds = nil }
    ns._firstDisabledNode = { buffs = nil, debuffs = nil, custom = nil, idkinds = nil }
    local spellDefs = {
        { cfgKey = "bloodlust",     name = "Bloodlust/Heroism",   iconID = 2825,  isGroup = true, spellIcon = 80353 },
        { cfgKey = "extdef",        name = "External Defensives", iconID = 33206, isGroup = true },
        { cfgKey = "innervate",     name = "Innervate",           iconID = 29166 },
        { cfgKey = "persdef",       name = "Personal Defensives", iconID = 22812, isGroup = true },
        { cfgKey = "powerinfusion", name = "Power Infusion",      iconID = 10060 },
        { cfgKey = "spatial",       name = "Spatial Paradox",     iconID = 406732, isGroup = true, spellIcon = 406732 },
        { cfgKey = "timespiral",    name = "Time Spiral",         iconID = 375234, isGroup = true, spellIcon = 375234 },
    }
    local customs = HET.db.profile.customTrackers
    if type(customs) == "table" then
        for j = 1, #customs do
            local ck = customs[j].kind
            spellDefs[#spellDefs + 1] = {
                cfgKey = "custom" .. customs[j].id,
                name = customs[j].name or "Custom",
                custom = true,
                -- ID-kind groups fan out like buff/debuff groups, so they clump
                -- with the groups in the tree sort.
                isGroup = (ck == "group" or ck == "abilitygroup" or ck == "itemgroup"),
                customKind = ck == "custom",
                -- ID-kind flags: route to the Abilities / Items subtabs and pick
                -- the tree icon (spell vs item texture) below.
                abilityKind = (ck == "ability" or ck == "abilitygroup"),
                itemKind    = (ck == "item" or ck == "itemgroup"),
                harmful = customs[j].harmful and true or false,
            }
        end
    end
    -- Groups clump FIRST, then single auras; alphabetical within each.
    table.sort(spellDefs, function(a, b)
        if (a.isGroup or false) ~= (b.isGroup or false) then
            return a.isGroup and true or false
        end
        local an, bn = a.name:lower(), b.name:lower()
        if an ~= bn then return an < bn end
        return a.cfgKey < b.cfgKey
    end)

    -- Multi-spell groups show a custom overlapping-squares glyph IN
    -- PLACE of a spell icon in the tree (Media\GroupIcon.tga, white on
    -- transparent), marking them as nested lists rather than single
    -- auras.
    local GROUP_ICON = "Interface\\AddOns\\BuzzardAuras\\Media\\GroupIcon"
    for o = 1, #spellDefs do
        local s = spellDefs[o]
        local prefix = PrefixFor(s.cfgKey)
        local iconTex
        if s.isGroup then
            iconTex = GROUP_ICON
        elseif s.customKind then
            -- Custom-kind: no aura id — pull the first Icon element's custom
            -- icon (resolved by mode), else the question-mark fallback.
            iconTex = ns.ResolveCustomTreeIcon(prefix) or 134400
        elseif s.itemKind then
            -- Item single: the item's own icon (groups use GROUP_ICON above).
            local ids = HET.db.profile[prefix .. "ids"]
            iconTex = (ids and ids[1] and C_Item and C_Item.GetItemIconByID
                and C_Item.GetItemIconByID(ids[1])) or 134400
        elseif s.abilityKind then
            -- Ability single: the spell's texture.
            local ids = HET.db.profile[prefix .. "ids"]
            iconTex = (ids and ids[1] and C_Spell.GetSpellTexture(ids[1])) or 134400
        elseif s.custom then
            local ids = HET.db.profile[prefix .. "ids"]
            iconTex = (ids and ids[1] and C_Spell.GetSpellTexture(ids[1])) or 134400
        else
            iconTex = C_Spell.GetSpellTexture(s.iconID)
        end
        local group
        if s.custom and not ns.trackers[s.cfgKey] then
            -- Saved custom whose tracker object doesn't exist YET
            -- (added mid-combat; the refresh pipeline builds it when
            -- the restriction lifts). Transient only.
            s._stub = true
            group = {
                type = "group",
                name = s.name,
                icon = iconTex,
                order = o,
                args = {
                    desc = {
                        type = "description",
                        name = "Will activate when you leave combat.",
                        order = 1,
                        width = "full",
                    },
                },
            }
        else
            group = BuildSpellGroup(s.cfgKey, prefix, s.name, iconTex, o)
        end
        -- Built-in trackers get a light-blue name so they read distinctly from
        -- the (white) user-created custom trackers in the same list. Applied to
        -- the NAME before brackets/glyphs are added around it.
        if not s.custom then
            group.name = "|cff66ccff" .. group.name .. "|r"
        end
        -- Groups render bracketed in the tree: [External Defensives]
        -- (plus the icon swap); grayed while pending combat-end
        -- activation.
        if s.isGroup then
            group.name = "[" .. group.name .. "]"
        end
        -- Optional per-group spell glyph: the group icon stays the
        -- overlapping-squares GroupIcon (node icon), and the spell's
        -- own texture is embedded inline so the row shows the group
        -- icon THEN the spell icon (e.g. Time Spiral).
        if s.spellIcon then
            local stex = C_Spell.GetSpellTexture(s.spellIcon)
            if stex then
                group.name = "|T" .. stex .. ":16|t " .. group.name
            end
        end
        if s._stub then
            group.name = "|cff888888" .. group.name .. "|r"
        end
        local enabled = HET.db.profile[prefix .. "enabled"]
        if enabled == nil then enabled = true end
        -- Enabled spells with NO trackers configured used to prepend a
        -- warning glyph to the name here; the glyph now renders at the
        -- row's RIGHT edge instead, in the preview-eye slot (see TreeEye
        -- in the widget-decoration section), so the label stays clean.
        -- Route to the correct subtab's Enabled/Disabled bucket. Custom-kind
        -- goes to the Custom subtab; harmful customs to Debuffs; else Buffs.
        local en, dis, subtab
        if s.abilityKind or s.itemKind then
            -- Abilities + Items share ONE merged subtab; the per-tracker (and,
            -- for groups, per-id) kind governs behavior, not which tab it lives in.
            en, dis, subtab = idEnabled, idDisabled, "idkinds"
        elseif s.customKind then
            en, dis, subtab = customEnabled, customDisabled, "custom"
        elseif s.harmful then
            en, dis, subtab = debuffEnabled, debuffDisabled, "debuffs"
        else
            en, dis, subtab = buffEnabled, buffDisabled, "buffs"
        end
        local node = NodeFor(s.cfgKey)
        if enabled then
            en[node] = group
            -- Record the FIRST enabled tracker per subtab (this loop is already
            -- in tree-sort order), so hetOpen can default the selection to it
            -- (in the Enabled section) instead of Addon Options.
            if ns._firstEnabledNode[subtab] == nil then
                ns._firstEnabledNode[subtab] = node
            end
        else
            dis[node] = group
            -- Fallback: first DISABLED tracker, used only when a subtab has no
            -- enabled trackers at all.
            if ns._firstDisabledNode[subtab] == nil then
                ns._firstDisabledNode[subtab] = node
            end
        end
    end
    local haveBuffEnabled    = next(buffEnabled)    ~= nil
    local haveBuffDisabled   = next(buffDisabled)   ~= nil
    local haveDebuffEnabled  = next(debuffEnabled)  ~= nil
    local haveDebuffDisabled = next(debuffDisabled) ~= nil
    local haveCustomEnabled  = next(customEnabled)  ~= nil
    local haveCustomDisabled = next(customDisabled) ~= nil
    local haveIDEnabled      = next(idEnabled)      ~= nil
    local haveIDDisabled     = next(idDisabled)     ~= nil

    -- Add buttons + Enabled/Disabled sections have the same shape in both
    -- subtabs — built from helpers to keep them in sync. Sections are non-
    -- selectable HEADINGS: `disabled` is inherited by children, so a path-
    -- depth test keeps the heading itself unselectable while its spells stay
    -- live. Under the subtabs the heading sits ONE LEVEL DEEPER than before
    -- (tab + section), so the threshold is `#info <= 2`, not <= 1.
    local function AddButton(label, descText, order, width, kind, harmful)
        return {
            type = "execute", name = label, desc = descText,
            order = order, width = width,
            func = function() AddCustom(kind, harmful) end,
        }
    end
    -- Per-tab preview toggle: scoped to the subtab it sits on, so the Buffs
    -- eye only drives buff previews, Debuffs only debuffs, etc. The panel's
    -- top-right eye button (see the chrome setup in EnsureOpenHook) is the
    -- global, all-tabs version. Rendered as an icon-execute (image set →
    -- AceGUI Icon widget): tab-coloured eye while any of the tab's previews
    -- show, red eye while all are hidden — same visual language as the
    -- tree-row and global eyes. The image function re-evaluates on every
    -- NotifyChange (executes trigger one after func), keeping it truthful.
    local function PreviewButton(subtab)
        local label = SUBTAB_LABELS[subtab] or "Previews"
        return {
            type = "execute",
            -- Icon-only: a non-empty name would render as a text label
            -- under the eye (Icon:SetLabel), recreating the old button look.
            name = "",
            -- Our own widget (see RegisterPreviewEyeWidget): hover brightens
            -- the eye art itself, exactly like the tree-row eyes, instead of
            -- the stock Icon widget's blue background glow. Falls back to the
            -- stock Icon if the type is somehow unregistered.
            dialogControl = "HETPreviewEye",
            image = function()
                return HET:IsAnyPreviewActive(subtab)
                    and (PREVIEW_TAB_TEX[subtab] or PREVIEW_TAB_TEX.custom)
                    or PREVIEW_HIDE_TEX
            end,
            imageWidth = 20, imageHeight = 20,
            desc = function()
                return HET:IsAnyPreviewActive(subtab)
                    and ("Hide every " .. label .. " preview currently showing.")
                    or  ("Show a preview for every " .. label
                         .. " tracker so you can drag them into position.")
            end,
            order = 0.3, width = 0.2,
            func = function()
                if HET:IsAnyPreviewActive(subtab) then
                    HET:HideAllPreviews(subtab)
                else
                    HET:ShowAllPreviews(subtab)
                end
            end,
        }
    end
    local function Section(name, order, args, have)
        return {
            type = "group", name = name, order = order,
            -- Own `disabled` overrides the root's (deepest-wins), so the
            -- combat lock is restated — with the same info.type guard: the
            -- spell/tracker nodes under this section are GROUPS, and a
            -- combat-disabled group entry blanks the pane / blocks the tree
            -- instead of greying (see the root group's comment). #info <= 2
            -- keeps the section heading itself unselectable, as before.
            disabled = function(info)
                return (info.type ~= "group" and InCombatLockdown())
                    or #info <= 2
            end,
            hidden = not have, args = args,
        }
    end
    -- Addon Options is global (not per-polarity); show it in BOTH subtabs so
    -- it's reachable wherever the user is. Each call returns its own table so
    -- the two nodes get distinct ACD paths; the get/set act on global db.
    local function AddonOptions()
        return {
            type = "group",
            name = "|cffffd100Addon Options|r",
            order = 3,
            args = {
                showMinimapIcon = {
                    type = "toggle",
                    name = "Show Minimap Icon",
                    desc = "Show or hide the minimap button.",
                    order = 1,
                    width = "full",
                    get = function() return not HET.db.global.minimapIcon.hide end,
                    set = function(_, val)
                        HET.db.global.minimapIcon.hide = not val
                        local LibDBIcon = LibStub("LibDBIcon-1.0", true)
                        if not LibDBIcon then return end
                        if val then
                            LibDBIcon:Show("BuzzardAuras")
                        else
                            LibDBIcon:Hide("BuzzardAuras")
                        end
                    end,
                },
            },
        }
    end

    return {
        type = "group",
        name = "|cff11ace9Buzzard Auras|r",
        childGroups = "tab",
        -- COMBAT LOCK: every CONTROL is disabled in combat (previews and
        -- tracker restyles touch engine-owned regions that are forbidden
        -- then). Two ACD subtleties shape this:
        -- 1. `disabled` inheritance is deepest-defined-WINS, not OR — any
        --    nested `disabled` in this table overrides this one and must
        --    include its own combat check (see Section and the reorder
        --    arrows).
        -- 2. GROUP nodes must NEVER be combat-disabled: ACD skips tab/tree
        --    selection for disabled group entries ("if not entry.disabled
        --    then SelectTab/SelectByValue"), which blanks the whole pane
        --    instead of greying its contents. Hence the info.type guard —
        --    groups stay enabled (navigation works, content feeds), only
        --    the controls inside grey out.
        -- The combat watcher at the bottom of this file refreshes an open
        -- panel on both combat edges.
        disabled = function(info)
            return info.type ~= "group" and InCombatLockdown()
        end,
        args = {
            buffs = {
                type = "group",
                name = "Buffs",
                order = 1,
                childGroups = "tree",
                args = {
                    -- Inline execute args render as a top row above the tree.
                    addAura = AddButton("|cff11ace9+Add Buff|r",
                        "Add a custom tracker for a single buff spell ID.",
                        0.1, 0.8, "aura", false),
                    addAuraGroup = AddButton("|cff11ace9+Add Buff Group|r",
                        "Add a custom tracker for a list of buff spell IDs, with a checkbox per spell (like the External Defensives list).",
                        0.2, 0.9, "group", false),
                    hideAllPreviews = PreviewButton("buffs"),
                    sectionEnabled  = Section("|cff44ff44Enabled|r",  1, buffEnabled,  haveBuffEnabled),
                    sectionDisabled = Section("|cffff4444Disabled|r", 2, buffDisabled, haveBuffDisabled),
                    addonOptions = AddonOptions(),
                },
            },
            debuffs = {
                type = "group",
                name = "Debuffs",
                order = 2,
                childGroups = "tree",
                args = {
                    addDebuff = AddButton("|cff11ace9+Add Debuff|r",
                        "Add a custom tracker for a single debuff spell ID on your target or focus.",
                        0.1, 0.85, "aura", true),
                    addDebuffGroup = AddButton("|cff11ace9+Add Debuff Group|r",
                        "Add a custom tracker for a list of debuff spell IDs, with a checkbox per spell.",
                        0.2, 1.0, "group", true),
                    hideAllPreviews = PreviewButton("debuffs"),
                    sectionEnabled  = Section("|cff44ff44Enabled|r",  1, debuffEnabled,  haveDebuffEnabled),
                    sectionDisabled = Section("|cffff4444Disabled|r", 2, debuffDisabled, haveDebuffDisabled),
                    addonOptions = AddonOptions(),
                },
            },
            -- Merged Abilities/Items subtab: all four add buttons live here.
            -- Single ability/item trackers and mixed ability+item GROUPS coexist
            -- in one Enabled/Disabled list. The "Group" buttons set the group's
            -- DEFAULT entry type (which "Add ..." row is primed); the group panel
            -- then lets the user add either abilities or items (per-id idKinds).
            idkinds = {
                type = "group",
                name = "Abilities/Items",
                order = 3,
                childGroups = "tree",
                args = {
                    -- Two buttons only: one flexible single tracker (its panel
                    -- has an Ability/Item Type toggle), and one flexible group
                    -- (its list takes both ability and item entries).
                    addAbilityItem = AddButton("|cff11ace9+Add Ability/Item|r",
                        "Add a tracker for a single ability or item — shows its icon with an optional cooldown swipe and charge/count, driven by its Load Conditions. Choose Ability or Item in its options.",
                        0.1, 1.1, "ability", false),
                    addAbilityItemGroup = AddButton("|cff11ace9+Add Ability/Item Group|r",
                        "Add a tracker for a list of abilities and/or items, fanning out an icon or bar per entry. Add either type to it.",
                        0.2, 1.3, "abilitygroup", false),
                    hideAllPreviews = PreviewButton("idkinds"),
                    sectionEnabled  = Section("|cff44ff44Enabled|r",  1, idEnabled,  haveIDEnabled),
                    sectionDisabled = Section("|cffff4444Disabled|r", 2, idDisabled, haveIDDisabled),
                    addonOptions = AddonOptions(),
                },
            },
            custom = {
                type = "group",
                name = "Other",
                order = 4,
                childGroups = "tree",
                args = {
                    addCustomTracker = AddButton("|cff11ace9+Add Custom Tracker|r",
                        "Add a tracker with no aura dependency — show a custom icon, text, or sound driven purely by its Load Conditions (e.g. show an icon whenever specific spells are usable).",
                        0.1, 1.2, "custom", false),
                    hideAllPreviews = PreviewButton("custom"),
                    sectionEnabled  = Section("|cff44ff44Enabled|r",  1, customEnabled,  haveCustomEnabled),
                    sectionDisabled = Section("|cffff4444Disabled|r", 2, customDisabled, haveCustomDisabled),
                    addonOptions = AddonOptions(),
                },
            },
        },
    }
end

function HET:RegisterOptions()
    AC:RegisterOptionsTable(addonName, BuildOptions)
    -- Prime ns._firstEnabledNode now: AceConfig only calls BuildOptions
    -- lazily on the first feed (inside ACD:Open), but hetOpen reads that table
    -- BEFORE calling the original Open to seed the default selection. Without
    -- this, the first open would find it empty and fall back to Addon Options.
    pcall(BuildOptions)
    -- Other addons often register LibSharedMedia fonts/media a moment after
    -- login (deferred registration). The font dropdown reads LSM's live list
    -- when it's built, so media registered after the panel was last built
    -- only appears on a later open — the "missing until I reopen" symptom.
    -- Refresh the options table (debounced) whenever new media registers so
    -- an open panel's font list stays complete without a manual reopen.
    if LSM and ACR and not self._lsmRefreshHooked then
        self._lsmRefreshHooked = true
        local pending = false
        LSM.RegisterCallback(self, "LibSharedMedia_Registered", function()
            if pending then return end
            pending = true
            C_Timer.After(0.5, function()
                pending = false
                ACR:NotifyChange(addonName)
            end)
        end)
    end
    -- Installed here AND re-checked on every panel open: a load-on-demand
    -- addon can register a newer AceGUI widget version long after us.
    ns.EnsureUIHooks()

    local g = self.db.global
    local w = g.optionsPanelW or 850
    local h = g.optionsPanelH or 550
    ACD:SetDefaultSize(addonName, w, h)
end

-------------------------------------------------------------------------------
-- AceGUI widget decoration
--
-- LibStub hands ONE AceGUI to every addon, and we cannot know whose copy
-- wins, what version it is, or whether a load-on-demand addon will
-- register a NEWER widget version over ours later. Everything below is
-- built for that:
--   * every field is capability-checked, so an unrecognised widget shape
--     passes through completely untouched;
--   * our added work is pcall'd, so an unexpected library version can
--     never break our options UI or any other addon's;
--   * installation is idempotent and re-run every time the panel opens,
--     wrapping whatever constructor is current by then;
--   * widgets already sitting in AceGUI's pool are patched in place,
--     since they were built by a constructor that predates ours and
--     would otherwise never gain the decoration.
-------------------------------------------------------------------------------

-- Main-tree preview eyes: each ENABLED spell row gets an eye button at the
-- row's right edge that toggles that tracker's previews. Previews are
-- per-TRACKER (same rule as the Tracked Buffs row eyes), so one click
-- shows/hides ALL of the spell's visual elements — exact same toggle logic
-- as the row eye above (reuses ShowElementPreview / HidePreview, no engine
-- changes). Icon swaps by state: preview.tga (blue) while a preview is
-- active, previewhide.tga (red) otherwise. Tree buttons are pooled and
-- restyled every RefreshTree, so state is re-resolved on each pass.
local function TrackerPreviewActive(cfgKey)
    local t = ns.trackers[cfgKey]
    if not t then return false end
    local els = GetEls(PrefixFor(cfgKey))
    for j = 1, #els do
        if t:IsElementPreviewActive(j) then return true end
    end
    return false
end

local function ToggleTrackerPreview(cfgKey)
    local t = ns.trackers[cfgKey]
    if not t then return end
    if TrackerPreviewActive(cfgKey) then
        t:HidePreview()
        return
    end
    local els = GetEls(PrefixFor(cfgKey))
    for j = 1, #els do
        if els[j].style and els[j].style ~= "sound" then
            t:ShowElementPreview(j)
        end
    end
end

-- Yellow "!" warning mark (AvailableQuestIcon — stable across clients),
-- shown in the eye slot for enabled spells with no configured trackers.
local NO_TRACKERS_TEX = "Interface\\GossipFrame\\AvailableQuestIcon"

-- Applies the eye's look for the current preview state: showing = tinted
-- (per-tab colour) white eye mask at full alpha, hidden = red eye at 50%.
local function SetEyeState(f, active)
    local tint = f._hetTint or PREVIEW_TINT_DEFAULT
    if active then
        f:SetAlpha(1)
        f:SetNormalTexture(PREVIEW_MASK_TEX)
        f:SetHighlightTexture(PREVIEW_MASK_TEX, "ADD")
        f:GetNormalTexture():SetVertexColor(tint[1], tint[2], tint[3])
        f:GetHighlightTexture():SetVertexColor(tint[1], tint[2], tint[3])
    else
        f:SetAlpha(0.5)
        f:SetNormalTexture(PREVIEW_HIDE_TEX)
        f:SetHighlightTexture(PREVIEW_HIDE_TEX, "ADD")
        f:GetNormalTexture():SetVertexColor(1, 1, 1)
        f:GetHighlightTexture():SetVertexColor(1, 1, 1)
    end
    -- Combat lock: grey the eye while options are frozen (re-runs on the
    -- combat watcher's NotifyChange via DecorateTreeXs; OnClick guards too).
    local locked = InCombatLockdown()
    f:GetNormalTexture():SetDesaturated(locked)
    if locked then f:SetAlpha(0.3) end
end

local function TreeEye(button, cfgKey, warn, tab)
    local eye = button._hetEye
    if not cfgKey then
        if eye then eye:Hide() end
        return
    end
    if not eye then
        eye = CreateFrame("Button", nil, button)
        eye:SetSize(16, 16)
        eye:SetPoint("RIGHT", -4, 0)
        eye:SetScript("OnClick", function(f)
            if InCombatLockdown() then return end  -- combat lock
            if f._hetWarn then return end
            ToggleTrackerPreview(f._hetCfgKey)
            local active = TrackerPreviewActive(f._hetCfgKey)
            SetEyeState(f, active)
            if GameTooltip:GetOwner() == f then
                GameTooltip:SetText(active and "Preview showing" or "Preview hidden", 1, 1, 1)
            end
        end)
        eye:SetScript("OnEnter", function(f)
            GameTooltip:SetOwner(f, "ANCHOR_RIGHT")
            if InCombatLockdown() then
                GameTooltip:SetText("Locked during combat", 1, 0.3, 0.3)
            elseif f._hetWarn then
                GameTooltip:SetText("No trackers configured", 1, 1, 1)
            else
                GameTooltip:SetText(TrackerPreviewActive(f._hetCfgKey)
                    and "Preview showing" or "Preview hidden", 1, 1, 1)
            end
            GameTooltip:Show()
        end)
        eye:SetScript("OnLeave", function() GameTooltip:Hide() end)
        button._hetEye = eye
    end
    eye._hetCfgKey = cfgKey
    eye._hetWarn = warn or nil
    eye._hetTint = PREVIEW_TINT[tab] or PREVIEW_TINT_DEFAULT
    eye:SetFrameLevel(button:GetFrameLevel() + 2)
    if warn then
        eye:SetAlpha(1)
        eye:SetNormalTexture(NO_TRACKERS_TEX)
        eye:SetHighlightTexture(NO_TRACKERS_TEX, "ADD")
        eye:GetNormalTexture():SetVertexColor(1, 1, 1)
        eye:GetHighlightTexture():SetVertexColor(1, 1, 1)
    else
        SetEyeState(eye, TrackerPreviewActive(cfgKey))
    end
    eye:Show()
end

-- TreeGroup: remove-tracker Xs on the rows of HET's tracker lists, plus a
-- delete-custom X on the content pane's top-right while a custom entry is
-- selected. Rows are pooled plain Buttons the lib restyles on every
-- RefreshTree, so this re-runs after each refresh. Scoped hard to our
-- appName; other addons' trees are left alone.
local function DecorateTreeXs(self)
    local user = type(self.GetUserDataTable) == "function" and self:GetUserDataTable()
    local path = user and user.path
    local ours = user and user.appName == addonName
    local isTrackers = ours and path and path[#path] == "trackers"
    -- The spell-list trees are the Buffs/Debuffs tab trees now (path length
    -- 1), not the old root tree (length 0) — same shift as HETTreeWidthKey.
    local isMain = ours and path and #path == 1

    local buttons = self.buttons
    if type(buttons) == "table" then
        for bi = 1, #buttons do
            local button = buttons[bi]
            local tip, popup, parg, fn
            local uv = type(button) == "table"
                and type(button.IsShown) == "function"
                and button:IsShown() and button.uniquevalue
            if isTrackers and uv then
                local idx = type(uv) == "string" and uv:match("^el(%d+)$")
                if idx then
                    local i = tonumber(idx)
                    local node = path[#path - 1]
                    local cfgKey = NODE_TO_CFG[node] or node
                    local e = GetEls(PrefixFor(cfgKey))[i]
                    parg = (e and e.style and StyleLabel(cfgKey, e.style)) or "Tracker"
                    if e and e.pairFor then parg = parg .. " (Active)" end
                    tip, popup = "Remove " .. parg, "HET_REMOVE_TRACKER"
                    fn = function() DoRemoveTracker(cfgKey, i) end
                end
            end
            CornerX(button, fn and {
                point = "RIGHT", x = -4, y = 0, size = 14, level = 2,
                tooltip = tip, anchor = "ANCHOR_RIGHT",
                confirm = popup, confirmArg = parg, onClick = fn,
            } or nil)
            -- Right-edge slot on ENABLED spell rows only (disabled spells
            -- have nothing to preview; section headers and the "+" add rows
            -- never match). Custom nodes use their cfgKey as node key, so
            -- the NODE_TO_CFG fallback covers them. Slot contents:
            --   * no styled elements   → yellow "!" (was inline in the label)
            --   * any visual tracker   → preview eye
            --   * styled but sound-only → nothing (no visual to preview)
            local eyeKey, eyeWarn
            if isMain and uv and type(uv) == "string" then
                local node = uv:match("^sectionEnabled\1(.+)$")
                if node then
                    local ck = NODE_TO_CFG[node] or node
                    if ns.trackerConfigs[ck] then
                        local els = GetEls(PrefixFor(ck))
                        local anyStyled, anyVisual = false, false
                        for j = 1, #els do
                            local s = els[j].style
                            if s then
                                anyStyled = true
                                if s ~= "sound" then anyVisual = true end
                            end
                        end
                        if not anyStyled then
                            eyeKey, eyeWarn = ck, true
                        elseif anyVisual and ns.trackers[ck] then
                            eyeKey = ck
                        end
                    end
                end
            end
            TreeEye(button, eyeKey, eyeWarn, isMain and path[1] or nil)
        end
    end

    local cid
    if isMain then
        local status = self.status or self.localstatus
        local selected = status and status.selected
        cid = type(selected) == "string"
            and selected:match("^section%a+\1custom(%d+)$")
    end
    local spec
    if cid then
        local id = tonumber(cid)
        local nm
        local list = HET.db.profile.customTrackers
        if type(list) == "table" then
            for j = 1, #list do
                if list[j].id == id then nm = list[j].name end
            end
        end
        nm = nm or ("Custom " .. id)
        spec = {
            point = "TOPRIGHT", x = -4, y = -4, size = 16, level = 10,
            tooltip = "Delete " .. nm, anchor = "ANCHOR_LEFT",
            confirm = "HET_DELETE_CUSTOM", confirmArg = nm,
            onClick = function() DoDeleteCustom(id) end,
        }
    end
    CornerX(self.border, spec, "_hetDeleteX")
end

-- TreeGroup divider fix: the stock dragger uses Frame:StartSizing(), which
-- does nothing on a freshly-opened dialog until the frame has been moved
-- or re-laid-out (PTR-observed). Resize manually from cursor deltas via
-- the widget's own SetTreeWidth instead — works from the first frame.
-- Default tree widths (px), both persisted per-profile. The far-left
-- Enabled/Disabled spell (left navigation) tree; the per-tracker "Tracker
-- list" tree. Left-nav default trimmed 10px (202 → 192).
local SPELL_LIST_TREE_WIDTH   = 192
local TRACKER_LIST_TREE_WIDTH = 140

-- Which saved-width key + default this tree uses, or nil if it isn't one of
-- HET's. Re-evaluated at call time because AceGUI recycles tree widgets
-- across addons. The panel root is now a TAB group (not a tree), so the
-- Enabled/Disabled spell trees sit one level down under the Buffs/Debuffs
-- tabs (path length 1); a per-tracker "Tracker list" tree is deeper (length
-- >= 2). Both subtabs' spell lists share one saved width.
local function HETTreeWidthKey(widget)
    if type(widget.GetUserData) ~= "function"
        or widget:GetUserData("appName") ~= addonName then return nil end
    local path = widget:GetUserData("path")
    if not path or #path <= 1 then
        return "spellListTreeWidth", SPELL_LIST_TREE_WIDTH
    end
    return "trackerListTreeWidth", TRACKER_LIST_TREE_WIDTH
end

local function PatchTreeGroup(widget)
    if widget._hetPatched then return end
    local dragger, treeframe = widget.dragger, widget.treeframe
    if dragger and treeframe
        and type(dragger.SetScript) == "function"
        and type(treeframe.GetWidth) == "function"
        and type(widget.SetTreeWidth) == "function" then
        dragger:SetScript("OnMouseDown", function(f)
            f._hetStartX = GetCursorPosition()
            f._hetStartW = treeframe:GetWidth()
            f:SetScript("OnUpdate", function(f2)
                local cx = GetCursorPosition()
                local scale = treeframe:GetEffectiveScale()
                local w = f2._hetStartW + (cx - f2._hetStartX) / scale
                local status = widget.status or widget.localstatus
                local maxW = math.min(400, ((status and status.fullwidth) or 800) - 50)
                if w < 100 then w = 100 elseif w > maxW then w = maxW end
                widget:SetTreeWidth(w, true)
            end)
        end)
        dragger:SetScript("OnMouseUp", function(f)
            f:SetScript("OnUpdate", nil)
            widget:Fire("OnTreeResize", treeframe:GetWidth())
        end)
    end
    if type(widget.RefreshTree) == "function" then
        local origRefreshTree = widget.RefreshTree
        widget.RefreshTree = function(self, ...)
            origRefreshTree(self, ...)
            pcall(DecorateTreeXs, self)
        end
    end

    -- Tree width defaults + reload persistence. AceGUI wipes a widget's
    -- callbacks on release but keeps its methods, so wrap methods (not
    -- SetCallback) and re-check ownership at call time. SetStatusTable seeds
    -- the width from the saved profile value (or the narrower default) when a
    -- tree is built; SetTreeWidth writes the applied width back on drag.
    if type(widget.SetStatusTable) == "function" then
        local origSetStatusTable = widget.SetStatusTable
        widget.SetStatusTable = function(self, status, ...)
            -- The library's SetStatusTable, if status.treewidth is nil, seeds
            -- it to its OWN default and immediately applies that width to the
            -- tree frame. So we can't just set status.treewidth afterwards —
            -- the frame is already sized to the wrong (default) width and won't
            -- update on its own. Seed our saved/default width into the status
            -- table BEFORE the library reads it, so it applies the right one.
            if status and status.treewidth == nil and HET.db then
                local key, default = HETTreeWidthKey(self)
                if key then status.treewidth = HET.db.global[key] or default end
            end
            origSetStatusTable(self, status, ...)
        end
    end
    if type(widget.SetTreeWidth) == "function" then
        local origSetTreeWidth = widget.SetTreeWidth
        widget.SetTreeWidth = function(self, ...)
            origSetTreeWidth(self, ...)
            if HET.db then
                local key = HETTreeWidthKey(self)
                local st = self.status or self.localstatus
                local w = st and st.treewidth
                if key and type(w) == "number" then HET.db.global[key] = w end
            end
        end
    end

    widget._hetPatched = true
end

-- InlineGroup: the per-text groups get a red X inside their top-right
-- corner. The text index comes out of the title; the element it belongs
-- to comes from the context the Text header publishes while its pane is
-- fed. Runs for EVERY addon's inline groups, so anything that is not one
-- of ours simply clears the X.
local function DecorateInlineGroupTitle(self, title)
    local k = type(title) == "string" and title:match("^Text (%d+)")
    local ctx = k and ns._activeTextCtx
    local spec
    if ctx and type(ctx.texts) == "function"
        and type(ctx.hasTextStyle) == "function"
        and type(ctx.refresh) == "function" then
        local idx = tonumber(k)
        spec = {
            point = "TOPRIGHT", x = -4, y = -4, size = 14, level = 10,
            tooltip = "Remove " .. title, anchor = "ANCHOR_LEFT",
            onClick = function()
                if not ctx.hasTextStyle() then return end
                local t = ctx.texts()
                if not t[idx] then return end
                table.remove(t, idx)
                ctx.refresh()
                Notify()
            end,
        }
    end
    -- InlineGroup's widget table exposes only frame / content / titletext
    -- — the bordered box its constructor creates is NOT stored (unlike
    -- TreeGroup, which does expose .border). content's parent IS that box.
    local content = self.content
    local box = (content and type(content.GetParent) == "function"
        and content:GetParent()) or self.frame
    CornerX(box, spec, "_hetTextX")
end

local function PatchInlineGroup(widget)
    if widget._hetPatched then return end
    local origSetTitle = widget.SetTitle
    if type(origSetTitle) ~= "function" then return end
    widget.SetTitle = function(self, title, ...)
        origSetTitle(self, title, ...)
        pcall(DecorateInlineGroupTitle, self, title)
    end
    widget._hetPatched = true
end

-- Label (type = "description") mouse-over tooltip. AceConfigDialog only
-- wires its native OnEnter tooltip path (OptionOnMouseOver, which honours
-- tooltipHyperlink) onto interactive controls, not description Labels, so
-- our spell-ID rows would otherwise be inert. This patcher gives OUR
-- description Labels a hover tooltip driven by the option's tooltipHyperlink
-- (e.g. "spell:12345"), leaving every other addon's Labels untouched.
--
-- Timing: AceConfigDialog sets the widget's userdata (option/appName) via
-- InjectInfo AFTER all the SetText/SetImage calls, then AddChild → layout →
-- frame:Show. So OnShow is the first point where the userdata is readable;
-- it recomputes each feed (widgets are pooled and re-fed) and only enables
-- mouse for rows that actually carry a hyperlink.
local function LabelTip_OnEnter(frame)
    local hyperlink = frame._hetHyperlink
    if not hyperlink then return end
    -- Match AceConfigDialog's own option tooltips (OptionOnMouseOver uses
    -- SetOwner(frame, "ANCHOR_TOPRIGHT")).
    GameTooltip:SetOwner(frame, "ANCHOR_TOPRIGHT")
    GameTooltip:SetHyperlink(hyperlink)
    GameTooltip:Show()
end
local function LabelTip_OnLeave()
    GameTooltip:Hide()
end
local function LabelTip_OnShow(frame)
    local w = frame._hetWidget
    local hyperlink
    if w and type(w.GetUserData) == "function"
        and w:GetUserData("appName") == addonName then
        local opt = w:GetUserData("option")
        local h = opt and opt.tooltipHyperlink
        if type(h) == "function" then
            local ok, v = pcall(h)
            h = ok and v or nil
        end
        if type(h) == "string" then hyperlink = h end
    end
    frame._hetHyperlink = hyperlink
    frame:EnableMouse(hyperlink ~= nil)
end

local function PatchLabel(widget)
    if widget._hetPatched then return end
    widget._hetPatched = true
    local frame = widget.frame
    if type(frame) ~= "table" or type(frame.HookScript) ~= "function" then return end
    frame._hetWidget = widget
    frame:HookScript("OnShow", LabelTip_OnShow)
    frame:HookScript("OnEnter", LabelTip_OnEnter)
    frame:HookScript("OnLeave", LabelTip_OnLeave)
end

-- Defined at the end of the file; forward-declared so the single public
-- ensure entry point below can drive it too.
local EnsureOpenHook

-- Custom AceGUI widget for the per-tab preview toggles (used via
-- dialogControl on their icon-executes). AceGUI's stock Icon widget hovers
-- with a big blue glow texture behind the image
-- (UI-Character-Tab-Highlight); the tree-row/global eyes hover by
-- ADD-brightening the eye art over itself. This is a stripped-down Icon
-- whose HIGHLIGHT texture always mirrors the current image, so hovering
-- looks identical to the row eyes. Registered under our own type name, so
-- no other addon's widget versions can collide with it; safe to call
-- repeatedly (RegisterWidgetType no-ops on an equal-or-older version).
local function RegisterPreviewEyeWidget(AceGUI)
    local Type, Version = "HETPreviewEye", 1
    if (AceGUI:GetWidgetVersion(Type) or 0) >= Version then return end
    local methods = {
        OnAcquire = function(self)
            self:SetWidth(24)
            self:SetImage(nil)
            self:SetImageSize(20, 20)
            self:SetDisabled(false)
        end,
        -- Icon-only widget: ACD calls SetLabel(name) on icon-executes, but
        -- our options pass name = "" — accept and ignore any text so nothing
        -- ever renders under the eye (and height stays image-driven).
        SetLabel = function() end,
        SetImage = function(self, path, ...)
            local image = self.image
            image:SetTexture(path)
            if image:GetTexture() then
                local n = select("#", ...)
                if n == 4 or n == 8 then
                    image:SetTexCoord(...)
                else
                    image:SetTexCoord(0, 1, 0, 1)
                end
            end
            -- The hover highlight IS the current image (ADD-blended).
            self.highlight:SetTexture(path)
        end,
        SetImageSize = function(self, width, height)
            self.image:SetWidth(width)
            self.image:SetHeight(height)
            -- +4 keeps the eye row the same height as the +Add buttons (24).
            self:SetHeight(height + 4)
        end,
        SetDisabled = function(self, disabled)
            self.disabled = disabled
            if disabled then
                self.frame:Disable()
                self.image:SetVertexColor(0.5, 0.5, 0.5, 0.5)
            else
                self.frame:Enable()
                self.image:SetVertexColor(1, 1, 1, 1)
            end
        end,
    }
    local function Constructor()
        local frame = CreateFrame("Button", nil, UIParent)
        frame:Hide()
        frame:EnableMouse(true)
        frame:SetScript("OnEnter", function(f) f.obj:Fire("OnEnter") end)
        frame:SetScript("OnLeave", function(f) f.obj:Fire("OnLeave") end)
        frame:SetScript("OnClick", function(f, button)
            f.obj:Fire("OnClick", button)
            AceGUI:ClearFocus()
        end)
        local image = frame:CreateTexture(nil, "BACKGROUND")
        image:SetSize(20, 20)
        image:SetPoint("CENTER")
        local highlight = frame:CreateTexture(nil, "HIGHLIGHT")
        highlight:SetAllPoints(image)
        highlight:SetBlendMode("ADD")
        local widget = {
            image = image,
            highlight = highlight,
            frame = frame,
            type = Type,
        }
        for method, func in pairs(methods) do widget[method] = func end
        return AceGUI:RegisterAsWidget(widget)
    end
    AceGUI:RegisterWidgetType(Type, Constructor, Version)
end

-- Custom AceGUI widget for the Texture style's preview swatch (used via
-- dialogControl on a `description` entry — ACD honours dialogControl there,
-- AceConfigDialog-3.0.lua CreateControl(v.dialogControl or v.control, "Label")).
--
-- WHY NOT THE STOCK LABEL: AceGUI's Label:SetImage only does SetTexture +
-- SetTexCoord, so a description image can never show vertex color, alpha,
-- blend mode, desaturation or rotation — and Label:SetColor is the TEXT color,
-- not the image's. This widget applies exactly the five calls the live render
-- makes (Tracker.lua ApplyStyle, "texture" branch) so the swatch matches what
-- lands on screen. It also previews ATLAS picks, which the stock path could not
-- (AceConfig's image member has no atlas form).
--
-- Own type name, own pool: AceGUI recycles widgets across every Ace3 addon, and
-- rotation/blend/desaturation are sticky texture state. Patching the shared
-- Label would bleed a rotated ADD-blended image into other addons' icons. Even
-- within our own pool SetImage resets to neutral first, so a recycled swatch
-- can't inherit the previous element's look.
local function RegisterTexturePreviewWidget(AceGUI)
    local Type, Version = "HETTexturePreview", 1
    if (AceGUI:GetWidgetVersion(Type) or 0) >= Version then return end

    -- Lifted from AceGUIWidget-Label's UpdateImageAnchor: image on top when
    -- there is no caption or the row is narrow, otherwise image left of it.
    -- Height is driven by the content, which is what makes the row lay out.
    local function UpdateAnchor(self)
        if self.resizing then return end
        local frame, image, label = self.frame, self.image, self.label
        local width = frame.width or frame:GetWidth() or 0
        local height
        label:ClearAllPoints()
        image:ClearAllPoints()
        if self.imageshown then
            local iw = image:GetWidth()
            if (width - iw) < 200 or (label:GetText() or "") == "" then
                image:SetPoint("TOP")
                label:SetPoint("TOP", image, "BOTTOM")
                label:SetPoint("LEFT")
                label:SetWidth(width)
                height = image:GetHeight() + label:GetStringHeight()
            else
                image:SetPoint("TOPLEFT")
                if image:GetHeight() > label:GetStringHeight() then
                    label:SetPoint("LEFT", image, "RIGHT", 4, 0)
                else
                    label:SetPoint("TOPLEFT", image, "TOPRIGHT", 4, 0)
                end
                label:SetWidth(width - iw - 4)
                height = math.max(image:GetHeight(), label:GetStringHeight())
            end
        else
            label:SetPoint("TOPLEFT")
            label:SetWidth(width)
            height = label:GetStringHeight()
        end
        if not height or height == 0 then height = 1 end
        self.resizing = true
        frame:SetHeight(height)
        frame.height = height
        self.resizing = nil
    end

    local methods = {
        OnAcquire = function(self)
            self.resizing = true
            self:SetWidth(200)
            self:SetText("")
            self:SetFontObject(GameFontHighlightSmall)
            self:SetColor()
            self:SetImage(nil)
            self:SetImageSize(96, 96)
            self.resizing = nil
            UpdateAnchor(self)
        end,
        SetText = function(self, text)
            self.label:SetText(text or "")
            UpdateAnchor(self)
        end,
        SetFontObject = function(self, font)
            self.label:SetFontObject(font or GameFontHighlightSmall)
        end,
        -- Label parity: this is the CAPTION color, never the image's.
        SetColor = function(self, r, g, b)
            if not (r and g and b) then r, g, b = 1, 0.82, 0 end
            self.label:SetTextColor(r, g, b)
        end,
        SetImageSize = function(self, width, height)
            self.image:SetWidth(width)
            self.image:SetHeight(height)
            UpdateAnchor(self)
        end,
        -- The draw state rides in ns.texPreviewPending rather than through the
        -- argument list: ACD's option member API only carries a path. The
        -- handoff is ordered, not racy — ACD evaluates the `image` getter
        -- immediately before calling this (AceConfigDialog-3.0.lua ~:1414 then
        -- ~:1424/1426) — and we consume-and-clear so a stale table can never be
        -- picked up by the next swatch.
        SetImage = function(self, value)
            local st = ns.texPreviewPending
            ns.texPreviewPending = nil
            local image = self.image

            -- Neutral FIRST. Pooled widget: every one of these persists across
            -- a release/acquire, and SetAtlas overwrites texcoords, so the
            -- reset has to precede the apply.
            image:SetTexCoord(0, 1, 0, 1)
            image:SetRotation(0)
            image:SetBlendMode("BLEND")
            image:SetDesaturated(false)
            image:SetVertexColor(1, 1, 1, 1)
            image:SetAlpha(1)

            if value == nil or value == "" then
                image:SetTexture(nil)
            elseif type(ns.ApplyTextureValue) == "function" then
                -- Branches file vs atlas for us (TexturePicker.lua).
                ns.ApplyTextureValue(image, value, st and st.kind)
            else
                image:SetTexture(tonumber(value) or value)
            end
            -- Ask the region, don't trust the setter: SetAtlas fails silently
            -- on an unknown name and SetTexture on a bad path.
            self.imageshown = image:GetTexture() and true or nil

            if self.imageshown and st then
                image:SetVertexColor(st.r, st.g, st.b)
                image:SetAlpha(st.a)
                image:SetBlendMode(st.blend)
                image:SetDesaturated(st.desat)
                image:SetRotation(st.rot)
            end
            UpdateAnchor(self)
        end,
        OnWidthSet = function(self)
            UpdateAnchor(self)
        end,
    }

    local function Constructor()
        local frame = CreateFrame("Frame", nil, UIParent)
        frame:Hide()
        local label = frame:CreateFontString(nil, "BACKGROUND", "GameFontHighlightSmall")
        label:SetJustifyH("LEFT")
        label:SetJustifyV("TOP")
        local image = frame:CreateTexture(nil, "BACKGROUND")
        local widget = {
            label = label,
            image = image,
            frame = frame,
            type  = Type,
        }
        for method, func in pairs(methods) do widget[method] = func end
        return AceGUI:RegisterAsWidget(widget)
    end
    AceGUI:RegisterWidgetType(Type, Constructor, Version)
end

local WIDGET_PATCHERS = {
    TreeGroup   = PatchTreeGroup,
    InlineGroup = PatchInlineGroup,
    Label       = PatchLabel,
}
local installedCtor = {}

-- One entry point for every shared-library hook HET installs. Safe to
-- call repeatedly; each piece re-wraps only if something replaced it.
function ns.EnsureUIHooks()
    if EnsureOpenHook then pcall(EnsureOpenHook) end
    local AceGUI = LibStub("AceGUI-3.0", true)
    if type(AceGUI) ~= "table" then return end
    local reg = AceGUI.WidgetRegistry
    if type(reg) ~= "table" then return end
    local canRegister = type(AceGUI.RegisterWidgetType) == "function"
        and type(AceGUI.GetWidgetVersion) == "function"
    if canRegister then
        pcall(RegisterPreviewEyeWidget, AceGUI)
        pcall(RegisterTexturePreviewWidget, AceGUI)
    end
    for widgetType, patch in pairs(WIDGET_PATCHERS) do
        local current = reg[widgetType]
        -- Re-wrap whenever the registered constructor is not the one we
        -- installed: a later-loading addon may have registered a newer
        -- widget version over ours.
        if canRegister and type(current) == "function"
            and current ~= installedCtor[widgetType] then
            local base = current
            local ours = function()
                local widget = base()
                if type(widget) == "table" then pcall(patch, widget) end
                return widget
            end
            local ok = pcall(AceGUI.RegisterWidgetType, AceGUI, widgetType, ours,
                (AceGUI:GetWidgetVersion(widgetType) or 0) + 1)
            installedCtor[widgetType] = ok and ours or nil
        end
        local pools = AceGUI.objPools
        local pool = type(pools) == "table" and pools[widgetType]
        if type(pool) == "table" then
            for obj in pairs(pool) do
                if type(obj) == "table" then pcall(patch, obj) end
            end
        end
    end
end

-- AceConfigDialog panel customisation: the top-right X (replacing the
-- native Close button), panel size persistence, tree-state seeding and
-- preview cleanup on hide.
--
-- Same load-order hazard as the widget overrides above. ACD declares
-- `function AceConfigDialog:Open(...)` as a plain assignment on the
-- SHARED library table (AceConfigDialog-3.0.lua:1858) and only bails for
-- an equal-or-older MINOR, so a NEWER copy loading after us reassigns
-- :Open and silently discards this wrapper — no error, the X just stops
-- appearing. Hence: idempotent, and re-checked on every panel open so it
-- wraps whatever :Open is current by then.
-- Collapsed (minimized) panel height: just the title bar. File-scope because
-- both the minimize machinery below AND the status-height seeding in hetOpen
-- need it (a refresh of a minimized panel must seed the COLLAPSED height, see
-- below).
local COLLAPSED_HEIGHT = 40
local hetOpen
function EnsureOpenHook()
    if type(ACD) ~= "table" then return end
    local current = ACD.Open
    if type(current) ~= "function" or current == hetOpen then return end
    local origOpen = current
    hetOpen = function(self, appName, ...)
        if appName == addonName and HET.db then
            -- The panel root is a TAB group now. Default to the Buffs subtab,
            -- then seed each subtab's tree. (Tree widths are handled by the
            -- PatchTreeGroup method wrappers, so nothing to seed for those.)
            local root = self:GetStatusTable(addonName, {})
            root.groups = root.groups or {}
            if root.groups.selected == nil then root.groups.selected = "buffs" end
            -- Panel size: AceGUI's Frame reads status.width/height on open, so
            -- seed them here (every open) from the saved size, else the default.
            -- SetDefaultSize alone isn't reliable — the status table can be
            -- created before it runs, and AceGUI then falls back to its own
            -- built-in default size. Seeding here is authoritative.
            --
            -- MINIMIZED refresh: this wrapper also runs for NotifyChange
            -- refreshes of an already-open panel (ACD re-Opens it), and
            -- AceGUI's ApplyStatus re-applies status.height every time. If the
            -- panel is currently minimized, seeding the saved FULL height here
            -- would expand the frame while the body regions stay hidden — a
            -- big blank box (seen when the title-bar eye's NotifyChange fired
            -- while collapsed). Seed the collapsed height instead so a refresh
            -- never changes a minimized panel's geometry. Real opens are safe:
            -- they force _hetRestore below, which restores the true height.
            local prior = self.OpenFrames and self.OpenFrames[addonName]
            local priorMinimized = prior and prior.frame and prior.frame._hetMinimized
            root.width  = HET.db.global.optionsPanelW or 850
            root.height = priorMinimized and COLLAPSED_HEIGHT
                or HET.db.global.optionsPanelH or 550

            -- Seed each subtab's tree the same way: keep the (non-selectable)
            -- Enabled/Disabled headings expanded so their trackers are always
            -- visible, and default the selection to the FIRST enabled tracker
            -- rather than Addon Options (the first non-disabled top-level group,
            -- which AceConfigDialog would otherwise land on). Re-asserted on
            -- every open so a re-feed can't leave a section collapsed.
            local firstEnabled  = ns._firstEnabledNode  or {}
            local firstDisabled = ns._firstDisabledNode or {}
            for _, subtab in ipairs({ "buffs", "debuffs", "idkinds", "custom" }) do
                local st = self:GetStatusTable(addonName, { subtab })
                st.groups = st.groups or {}
                st.groups.groups = st.groups.groups or {}
                -- Always keep both section headings expanded.
                st.groups.groups.sectionEnabled = true
                st.groups.groups.sectionDisabled = true
                -- Default selection (only when nothing is remembered): the
                -- first enabled tracker in the Enabled section, else the first
                -- disabled one. Nothing is seeded for an empty subtab.
                if st.groups.selected == nil then
                    if firstEnabled[subtab] then
                        st.groups.selected = "sectionEnabled\001" .. firstEnabled[subtab]
                    elseif firstDisabled[subtab] then
                        st.groups.selected = "sectionDisabled\001" .. firstDisabled[subtab]
                    end
                end
            end
        end
        -- Distinguish refresh-vs-open BEFORE the real Open runs: during a
        -- NotifyChange refresh of an already-open panel the OpenFrames entry
        -- exists here; on a real user open it's only created inside origOpen.
        -- Needed below so a refresh (e.g. the global eye's NotifyChange, which
        -- can fire while minimized — the eye lives on the title bar) doesn't
        -- get treated as an open and force-expand a minimized panel.
        local wasOpen = self.OpenFrames and self.OpenFrames[appName] and true or false
        origOpen(self, appName, ...)
        if appName ~= addonName then return end

        local aceFrame = self.OpenFrames and self.OpenFrames[addonName]
        if not aceFrame then return end

        -- AceGUI Frame widgets are POOLED across addons, and `content`'s
        -- anchors are set ONCE in the constructor
        -- (AceGUIContainer-Frame.lua:295) -- neither OnAcquire nor
        -- OnRelease restores them. So any addon that re-anchors content
        -- to make room for its own chrome (e.g. pushing TOPLEFT down for a
        -- status row) leaks that offset into whoever acquires the frame
        -- next, as a gap at the top of the panel.
        -- Don't assume the shared frame is pristine: assert what we need
        -- on EVERY open, ahead of the one-time decoration below.
        if aceFrame.content then
            aceFrame.content:ClearAllPoints()
            aceFrame.content:SetPoint("TOPLEFT",     aceFrame.frame, "TOPLEFT",      17, -27)
            aceFrame.content:SetPoint("BOTTOMRIGHT", aceFrame.frame, "BOTTOMRIGHT", -17,  40)
        end

        local frame = aceFrame.frame
        -- Real opens start expanded: if the panel was left minimized when
        -- closed, restore it now. Refreshes (wasOpen) must NOT do this —
        -- a minimized panel being refreshed stays minimized.
        if frame and not wasOpen and frame._hetMinimized and frame._hetRestore then
            frame._hetRestore()
        end

        -- EVERY-OPEN ownership assertion. The AceGUI panel frame is POOLED across
        -- all Ace3 addons, so a frame we get may have just been used by another
        -- addon (which left ITS chrome/native-Close state on it), OR may be one we
        -- hooked in a PRIOR open (so the one-time block below is skipped). Either
        -- way our chrome must be re-asserted here, not just at first-hook time.
        -- Find + cache the native Close button on this frame if not already known
        -- (it may have been hidden by another addon, so match regardless of shown).
        if frame and not frame._hetNativeCloseBtn then
            for i = 1, select("#", frame:GetChildren()) do
                local child = select(i, frame:GetChildren())
                if child and child.GetObjectType and child:GetObjectType() == "Button" then
                    local text = child.GetText and child:GetText()
                    if text == CLOSE or text == "Close" then
                        frame._hetNativeCloseBtn = child
                        break
                    end
                end
            end
        end
        if frame and frame._hetApplyChromeOwnership then
            frame._hetApplyChromeOwnership()
        end

        if not frame or frame._hetHooked then return end
        frame._hetHooked = true

        -- Save panel size on resize — but NOT while minimized (the collapsed
        -- height must never overwrite the user's real panel height).
        frame:HookScript("OnSizeChanged", function(self, width, height)
            if HET.db and not frame._hetMinimized then
                HET.db.global.optionsPanelW = width
                HET.db.global.optionsPanelH = height
            end
        end)

        -- Cache the native AceConfigDialog "Close" button (may already be cached by
        -- the every-open block above). Visibility is driven by the ownership
        -- assertion, not hidden unconditionally here.
        if not frame._hetNativeCloseBtn then
            for i = 1, select("#", frame:GetChildren()) do
                local child = select(i, frame:GetChildren())
                if child and child.GetObjectType and child:GetObjectType() == "Button" then
                    local text = child.GetText and child:GetText()
                    if text == CLOSE or text == "Close" then
                        frame._hetNativeCloseBtn = child
                        break
                    end
                end
            end
        end

        -- Hide previews when panel closes. Also un-minimize on the way out so
        -- the pooled AceGUI frame is released EXPANDED — otherwise the hidden
        -- content/status regions (which the library's OnRelease/OnAcquire never
        -- re-show) would leak into the next addon that reuses the frame.
        frame:HookScript("OnHide", function(self)
            HET:HideAllPreviews()
            if self._hetMinimized and self._hetRestore then
                self._hetRestore()
            end
        end)

        -- Add X button in top-right corner
        local xBtn = CreateFrame("Button", nil, frame, "UIPanelCloseButton")
        xBtn:SetSize(26, 26)
        xBtn:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -8, -8)
        xBtn:SetScript("OnClick", function()
            PlaySound(799)
            aceFrame:Hide()
        end)
        frame._hetXBtn = xBtn  -- stored so OnShow/OnHide can toggle it by owner

        -- Global preview toggle, to the LEFT of the minimize button: one eye
        -- that drives EVERY tracker on every subtab (the per-tab buttons in
        -- the tree header are the scoped versions). Blue eye = previews
        -- showing, red eye = none showing, same art as the tree-row eyes.
        local eyeBtn = CreateFrame("Button", nil, frame)
        eyeBtn:SetSize(20, 20)
        frame._hetEyeBtn = eyeBtn  -- stored so OnShow/OnHide can toggle it by owner
        local function ApplyEyeLook()
            local active = HET:IsAnyPreviewActive()
            eyeBtn:SetAlpha(active and 1 or 0.5)
            eyeBtn:SetNormalTexture(active and PREVIEW_MASK_TEX or PREVIEW_HIDE_TEX)
            eyeBtn:SetHighlightTexture(active and PREVIEW_MASK_TEX or PREVIEW_HIDE_TEX, "ADD")
            if active then
                local t = PREVIEW_TINT_GLOBAL
                eyeBtn:GetNormalTexture():SetVertexColor(t[1], t[2], t[3])
                eyeBtn:GetHighlightTexture():SetVertexColor(t[1], t[2], t[3])
            else
                -- Reset the tint: SetNormalTexture reuses the same texture
                -- object, so the active tint would otherwise persist onto the
                -- red PREVIEW_HIDE_TEX (blue tint x red art = black = the
                -- button "disappears"). The hidden eye's colour is baked into
                -- the art, so it wants plain white vertex colour — same rule
                -- as SetEyeState for the tree-row eyes.
                eyeBtn:GetNormalTexture():SetVertexColor(1, 1, 1)
                eyeBtn:GetHighlightTexture():SetVertexColor(1, 1, 1)
            end
            -- Combat lock: greyed while options are frozen.
            local locked = InCombatLockdown()
            eyeBtn:GetNormalTexture():SetDesaturated(locked)
            if locked then eyeBtn:SetAlpha(0.3) end
            eyeBtn._hetLocked = locked
            eyeBtn._hetActive = active
        end
        eyeBtn:SetScript("OnClick", function()
            if InCombatLockdown() then return end  -- combat lock
            if HET:IsAnyPreviewActive() then
                HET:HideAllPreviews()
            else
                HET:ShowAllPreviews()
            end
            ApplyEyeLook()
            -- The per-tab buttons are AceConfig executes, so clicking them
            -- refreshes the dialog (re-running DecorateTreeXs → TreeEye, which
            -- restyles every row eye). This is a raw button, so trigger that
            -- same refresh explicitly or the tree-row eyes go stale.
            ACR:NotifyChange(addonName)
        end)
        eyeBtn:SetScript("OnEnter", function(self)
            GameTooltip:SetOwner(self, "ANCHOR_LEFT")
            if InCombatLockdown() then
                GameTooltip:SetText("Locked during combat", 1, 0.3, 0.3)
            else
                GameTooltip:SetText(HET:IsAnyPreviewActive()
                    and "Hide all previews" or "Show all previews", 1, 1, 1)
            end
            GameTooltip:Show()
        end)
        eyeBtn:SetScript("OnLeave", function() GameTooltip:Hide() end)
        -- The per-tab buttons and the tree-row eyes change the same state, so
        -- poll (options panel only, 4x/sec) to keep this icon truthful. Also
        -- watches combat edges so the greyed combat-lock look applies even
        -- though this raw button gets no AceConfig refresh.
        eyeBtn:SetScript("OnUpdate", function(self, elapsed)
            self._hetT = (self._hetT or 0) + elapsed
            if self._hetT < 0.25 then return end
            self._hetT = 0
            if self._hetActive ~= HET:IsAnyPreviewActive()
                or self._hetLocked ~= InCombatLockdown() then
                ApplyEyeLook()
            end
        end)
        eyeBtn:SetScript("OnShow", ApplyEyeLook)
        ApplyEyeLook()

        -- Minimize / restore button, to the LEFT of the X: Blizzard's native
        -- MaximizeMinimizeButtonFrameTemplate, a clean up/down chevron widget.
        -- Collapses the panel to just its
        -- title bar; content, status bar and resize grips are hidden so a
        -- collapsed panel can't be dragged/resized. (COLLAPSED_HEIGHT is
        -- file-scope — the status seeding in hetOpen needs it too.)
        local minBtn = CreateFrame("Frame", nil, frame, "MaximizeMinimizeButtonFrameTemplate")
        minBtn:SetPoint("RIGHT", xBtn, "LEFT", 0, 0)
        frame._hetMinBtn = minBtn  -- stored so OnShow/OnHide can toggle it by owner
        eyeBtn:SetPoint("RIGHT", minBtn, "LEFT", -2, 0)
        -- The template's default arrows point the opposite way to what the user
        -- expects (it shows a DOWN arrow while expanded). Flip each sub-button's
        -- glyphs vertically so: EXPANDED shows an UP arrow (click to minimize),
        -- MINIMIZED shows a DOWN arrow (click to expand). Vertical texcoord flip
        -- reverses the arrow regardless of the underlying atlas, so no atlas
        -- names are hardcoded (robust across client builds).
        local function FlipButtonArrows(btn)
            if not btn then return end
            for _, getter in ipairs({ "GetNormalTexture", "GetPushedTexture",
                "GetHighlightTexture", "GetDisabledTexture" }) do
                local tex = btn[getter] and btn[getter](btn)
                if tex and tex.SetTexCoord then tex:SetTexCoord(0, 1, 1, 0) end
            end
        end
        -- Flip now AND on every OnShow, so a template atlas re-apply (which
        -- resets texcoords) can never leave the arrow pointing the wrong way,
        -- even for one frame.
        local function FlipAndHookShow(btn)
            if not btn then return end
            FlipButtonArrows(btn)
            btn:HookScript("OnShow", function(self) FlipButtonArrows(self) end)
        end
        FlipAndHookShow(minBtn.MinimizeButton)
        FlipAndHookShow(minBtn.MaximizeButton)
        -- Body regions to hide while minimized. content is the pane; the
        -- status bar frame is the parent of statustext (AceGUI doesn't expose
        -- it on the widget directly); the three sizers are the resize grips.
        local statusbg = aceFrame.statustext and aceFrame.statustext:GetParent()
        -- Fixed list; iterated with a numeric loop (not ipairs) so a nil entry
        -- never truncates the rest.
        local bodyRegions = {
            aceFrame.content,
            statusbg,
            aceFrame.sizer_se, aceFrame.sizer_s, aceFrame.sizer_e,
        }
        local BODY_N = 5
        local function ShowBody(show)
            for i = 1, BODY_N do
                local r = bodyRegions[i]
                if r then if show then r:Show() else r:Hide() end end
            end
        end
        -- Pin the frame by its current TOP-RIGHT before changing height, so the
        -- top edge never moves when we collapse/expand. The AceGUI frame may be
        -- anchored by its bottom/SE corner (depending on how the user dragged
        -- the resize grips), which is why height changes otherwise shift the top
        -- inconsistently. Re-anchoring TOP-RIGHT to a fixed screen point makes it
        -- deterministic.
        local function PinTop()
            local right, top = frame:GetRight(), frame:GetTop()
            if not (right and top) then return end
            frame:ClearAllPoints()
            frame:SetPoint("TOPRIGHT", UIParent, "BOTTOMLEFT", right, top)
        end
        -- Apply the collapse/restore effect for a given state. Pure effect —
        -- the template owns the button visuals via its callbacks/Minimize()/
        -- Maximize(); this resizes the frame (top-pinned) and shows/hides body.
        local function ApplyMinimized(min)
            frame._hetMinimized = min and true or false
            PinTop()  -- lock the top edge BEFORE resizing
            if frame._hetMinimized then
                -- Remember the real height BEFORE collapsing so restore is exact.
                frame._hetRestoreHeight = frame:GetHeight()
                ShowBody(false)
                frame:SetHeight(COLLAPSED_HEIGHT)
            else
                ShowBody(true)
                local h = frame._hetRestoreHeight
                    or (HET.db and HET.db.global.optionsPanelH) or 550
                frame:SetHeight(h)
            end
        end
        -- Re-flip after each look change too, in case the template re-applies
        -- its atlas (which would reset the texcoords) when swapping buttons.
        local function ReflipBoth()
            FlipButtonArrows(minBtn.MinimizeButton)
            FlipButtonArrows(minBtn.MaximizeButton)
        end
        -- Template callbacks fire on the user's click. "Minimized look" =
        -- collapsed; "Maximized look" = expanded.
        minBtn:SetOnMinimizedCallback(function() ApplyMinimized(true); ReflipBoth() end)
        minBtn:SetOnMaximizedCallback(function() ApplyMinimized(false); ReflipBoth() end)
        -- Programmatic restore (reopen while previously minimized): drive the
        -- template so its button state and our effect stay in sync. Maximize()
        -- fires the callback above, which does the restore.
        frame._hetRestore = function()
            if minBtn.Maximize then minBtn:Maximize() else ApplyMinimized(false) end
        end
        -- Hover tooltips on the two sub-buttons.
        local function AddTip(btn, text)
            if not btn then return end
            btn:HookScript("OnEnter", function(self)
                GameTooltip:SetOwner(self, "ANCHOR_LEFT")
                GameTooltip:SetText(text, 1, 1, 1)
                GameTooltip:Show()
            end)
            btn:HookScript("OnLeave", function() GameTooltip:Hide() end)
        end
        AddTip(minBtn.MinimizeButton, "Minimize")
        AddTip(minBtn.MaximizeButton, "Expand")
        -- Each fresh open starts expanded; put the widget in its maximized look.
        frame._hetMinimized = false
        if minBtn.Maximize then minBtn:Maximize() end

        -- Shared-frame ownership toggle. The AceGUI Frame is POOLED across every
        -- Ace3 addon, and our chrome (X + minimize buttons) is parented to it —
        -- so without this it leaks onto the NEXT addon that reuses the frame, and
        -- another addon's native Close button leaks onto US. Show our chrome and
        -- hide the native Close ONLY while WE own the frame; otherwise hide our
        -- chrome and restore the native Close so the other addon looks correct.
        local function hetOwnsFrame()
            local of = ACD.OpenFrames and ACD.OpenFrames[addonName]
            return of and of.frame and of.frame == frame and frame:IsShown()
        end
        -- Stored on the frame so the EVERY-OPEN block above can re-assert it even
        -- when this one-time setup is skipped (frame already hooked).
        frame._hetApplyChromeOwnership = function()
            if hetOwnsFrame() then
                if frame._hetXBtn then frame._hetXBtn:Show() end
                if frame._hetMinBtn then frame._hetMinBtn:Show() end
                if frame._hetEyeBtn then frame._hetEyeBtn:Show() end
                if frame._hetNativeCloseBtn then frame._hetNativeCloseBtn:Hide() end
            else
                if frame._hetXBtn then frame._hetXBtn:Hide() end
                if frame._hetMinBtn then frame._hetMinBtn:Hide() end
                if frame._hetEyeBtn then frame._hetEyeBtn:Hide() end
                if frame._hetNativeCloseBtn then frame._hetNativeCloseBtn:Show() end
            end
        end
        frame:HookScript("OnShow", frame._hetApplyChromeOwnership)
        -- On hide, always retract our chrome and restore the native Close so the
        -- next opener (us or another addon) starts from a clean frame.
        frame:HookScript("OnHide", function()
            if frame._hetXBtn then frame._hetXBtn:Hide() end
            if frame._hetMinBtn then frame._hetMinBtn:Hide() end
            if frame._hetEyeBtn then frame._hetEyeBtn:Hide() end
            if frame._hetNativeCloseBtn then frame._hetNativeCloseBtn:Show() end
        end)
        -- Assert the correct state for this open (we own the frame right now).
        frame._hetApplyChromeOwnership()
    end
    ACD.Open = hetOpen
end

-- Install now; ns.EnsureUIHooks re-checks on every open.
EnsureOpenHook()

-- COMBAT WATCHER for the options combat lock. On both combat edges,
-- re-render an open panel so every AceConfig control picks up / drops its
-- disabled state (the root `disabled = InCombatLockdown` only evaluates on
-- a render). On leaving combat, also run a HideAllPreviews that was
-- deferred by its combat guard (panel closed mid-combat with previews up —
-- see Core.lua). The raw chrome eye greys itself via its OnUpdate poll;
-- tree-row eyes restyle through this NotifyChange (DecorateTreeXs).
local combatWatcher = CreateFrame("Frame")
combatWatcher:RegisterEvent("PLAYER_REGEN_DISABLED")
combatWatcher:RegisterEvent("PLAYER_REGEN_ENABLED")
combatWatcher:SetScript("OnEvent", function(_, event)
    if event == "PLAYER_REGEN_ENABLED" and ns.pendingHideAllPreviews then
        ns.pendingHideAllPreviews = nil
        HET:HideAllPreviews()
    end
    if ACD.OpenFrames and ACD.OpenFrames[addonName] then
        ACR:NotifyChange(addonName)
    end
end)
